From c2f6e5036e1fbf8eba666378fd8142db45bf11ac Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 23 Dec 2024 15:56:12 +0000 Subject: [PATCH 0001/2987] Bump version to 2025.1.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index eed8d73a4ee..6cdb7f5fb07 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 369f6f40921..8c66e5a3bdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.1.0.dev0" +version = "2025.1.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 7ce563b0b4b964cb2259fb72d5ee33d4f3ef3903 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 23 Dec 2024 22:49:59 +0100 Subject: [PATCH 0002/2987] Catch ClientConnectorError and TimeOutError in APSystems (#132027) --- homeassistant/components/apsystems/number.py | 10 +++++++++- tests/components/apsystems/test_number.py | 13 ++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/apsystems/number.py b/homeassistant/components/apsystems/number.py index 6463d10f3e8..b5ed60a7754 100644 --- a/homeassistant/components/apsystems/number.py +++ b/homeassistant/components/apsystems/number.py @@ -2,6 +2,8 @@ from __future__ import annotations +from aiohttp import ClientConnectorError + from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant @@ -45,7 +47,13 @@ class ApSystemsMaxOutputNumber(ApSystemsEntity, NumberEntity): async def async_update(self) -> None: """Set the state with the value fetched from the inverter.""" - self._attr_native_value = await self._api.get_max_power() + try: + status = await self._api.get_max_power() + except (TimeoutError, ClientConnectorError): + self._attr_available = False + else: + self._attr_available = True + self._attr_native_value = status async def async_set_native_value(self, value: float) -> None: """Set the desired output power.""" diff --git a/tests/components/apsystems/test_number.py b/tests/components/apsystems/test_number.py index 5868bd3da34..912759b4a17 100644 --- a/tests/components/apsystems/test_number.py +++ b/tests/components/apsystems/test_number.py @@ -12,7 +12,7 @@ from homeassistant.components.number import ( DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -46,6 +46,17 @@ async def test_number( await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == "50" + mock_apsystems.get_max_power.side_effect = TimeoutError() + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: 50.1}, + target={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE @pytest.mark.usefixtures("mock_apsystems") From bb371c87d55383583238a0c0b9cde1178470cc77 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Mon, 23 Dec 2024 13:47:26 -0800 Subject: [PATCH 0003/2987] Fix a history stats bug when window and tracked state change simultaneously (#133770) --- .../components/history_stats/data.py | 14 ++- tests/components/history_stats/test_sensor.py | 99 +++++++++++++++++++ 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index f9b79d74cb4..83528b73f6f 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -118,9 +118,7 @@ class HistoryStats: <= current_period_end_timestamp ): self._history_current_period.append( - HistoryState( - new_state.state, new_state.last_changed.timestamp() - ) + HistoryState(new_state.state, new_state.last_changed_timestamp) ) new_data = True if not new_data and current_period_end_timestamp < now_timestamp: @@ -131,6 +129,16 @@ class HistoryStats: await self._async_history_from_db( current_period_start_timestamp, current_period_end_timestamp ) + if event and (new_state := event.data["new_state"]) is not None: + if ( + current_period_start_timestamp + <= floored_timestamp(new_state.last_changed) + <= current_period_end_timestamp + ): + self._history_current_period.append( + HistoryState(new_state.state, new_state.last_changed_timestamp) + ) + self._previous_run_before_start = False seconds_matched, match_count = self._async_compute_seconds_and_changes( diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index d60203676e6..3039612d1a0 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -1465,6 +1465,105 @@ async def test_measure_cet(recorder_mock: Recorder, hass: HomeAssistant) -> None assert hass.states.get("sensor.sensor4").state == "50.0" +async def test_state_change_during_window_rollover( + recorder_mock: Recorder, + hass: HomeAssistant, +) -> None: + """Test when the tracked sensor and the start/end window change during the same update.""" + await hass.config.async_set_time_zone("UTC") + utcnow = dt_util.utcnow() + start_time = utcnow.replace(hour=23, minute=0, second=0, microsecond=0) + + def _fake_states(*args, **kwargs): + return { + "binary_sensor.state": [ + ha.State( + "binary_sensor.state", + "on", + last_changed=start_time - timedelta(hours=11), + last_updated=start_time - timedelta(hours=11), + ), + ] + } + + # The test begins at 23:00, and queries from the database that the sensor has been on since 12:00. + with ( + patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states, + ), + freeze_time(start_time), + ): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.state", + "name": "sensor1", + "state": "on", + "start": "{{ today_at() }}", + "end": "{{ now() }}", + "type": "time", + } + ] + }, + ) + await hass.async_block_till_done() + + await async_update_entity(hass, "sensor.sensor1") + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "11.0" + + # Advance 59 minutes, to record the last minute update just before midnight, just like a real system would do. + t2 = start_time + timedelta(minutes=59, microseconds=300) + with freeze_time(t2): + async_fire_time_changed(hass, t2) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "11.98" + + # One minute has passed and the time has now rolled over into a new day, resetting the recorder window. The sensor will then query the database for updates, + # and will see that the sensor is ON starting from midnight. + t3 = t2 + timedelta(minutes=1) + + def _fake_states_t3(*args, **kwargs): + return { + "binary_sensor.state": [ + ha.State( + "binary_sensor.state", + "on", + last_changed=t3.replace(hour=0, minute=0, second=0, microsecond=0), + last_updated=t3.replace(hour=0, minute=0, second=0, microsecond=0), + ), + ] + } + + with ( + patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states_t3, + ), + freeze_time(t3), + ): + # The sensor turns off around this time, before the sensor does its normal polled update. + hass.states.async_set("binary_sensor.state", "off") + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get("sensor.sensor1").state == "0.0" + + # More time passes, and the history stats does a polled update again. It should be 0 since the sensor has been off since midnight. + t4 = t3 + timedelta(minutes=10) + with freeze_time(t4): + async_fire_time_changed(hass, t4) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "0.0" + + @pytest.mark.parametrize("time_zone", ["Europe/Berlin", "America/Chicago", "US/Hawaii"]) async def test_end_time_with_microseconds_zeroed( time_zone: str, From 80955ba82188e07f1b28b37dc51a22aca9f0b634 Mon Sep 17 00:00:00 2001 From: Jordi Date: Tue, 24 Dec 2024 08:01:50 +0100 Subject: [PATCH 0004/2987] Add Harvey virtual integration (#133874) Add harvey virtual integration --- homeassistant/components/harvey/__init__.py | 1 + homeassistant/components/harvey/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/harvey/__init__.py create mode 100644 homeassistant/components/harvey/manifest.json diff --git a/homeassistant/components/harvey/__init__.py b/homeassistant/components/harvey/__init__.py new file mode 100644 index 00000000000..e40d1799a64 --- /dev/null +++ b/homeassistant/components/harvey/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Harvey.""" diff --git a/homeassistant/components/harvey/manifest.json b/homeassistant/components/harvey/manifest.json new file mode 100644 index 00000000000..3cb2a1b9aff --- /dev/null +++ b/homeassistant/components/harvey/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "harvey", + "name": "Harvey", + "integration_type": "virtual", + "supported_by": "aquacell" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ad4af2f024c..005fb7f694f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2475,6 +2475,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "harvey": { + "name": "Harvey", + "integration_type": "virtual", + "supported_by": "aquacell" + }, "hassio": { "name": "Home Assistant Supervisor", "integration_type": "hub", From efabb82cb6c5907903b6fbb73e6158548d01b5d1 Mon Sep 17 00:00:00 2001 From: Martin Mrazik Date: Mon, 23 Dec 2024 22:26:38 +0100 Subject: [PATCH 0005/2987] Map RGB+CCT to RGB for WLED (#133900) --- homeassistant/components/wled/const.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wled/const.py b/homeassistant/components/wled/const.py index 69ff6ccb1fa..8d09867a46e 100644 --- a/homeassistant/components/wled/const.py +++ b/homeassistant/components/wled/const.py @@ -53,7 +53,9 @@ LIGHT_CAPABILITIES_COLOR_MODE_MAPPING: dict[LightCapability, list[ColorMode]] = ColorMode.COLOR_TEMP, ], LightCapability.RGB_COLOR | LightCapability.COLOR_TEMPERATURE: [ - ColorMode.RGBWW, + # Technically this is RGBWW but wled does not support RGBWW colors (with warm and cold white separately) + # but rather RGB + CCT which does not have a direct mapping in HA + ColorMode.RGB, ], LightCapability.WHITE_CHANNEL | LightCapability.COLOR_TEMPERATURE: [ ColorMode.COLOR_TEMP, From 2b8240746a760b210ca73b1346bc7b9146c118c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Dec 2024 12:38:59 -1000 Subject: [PATCH 0006/2987] Sort integration platforms preload list (#133905) * Sort integration platforms preload list https://github.com/home-assistant/core/pull/133856#discussion_r1895385026 * sort * Sort them all --------- Co-authored-by: Franck Nijhof --- homeassistant/loader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 78c89b94765..93dc7677bba 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -65,20 +65,20 @@ _LOGGER = logging.getLogger(__name__) # This list can be extended by calling async_register_preload_platform # BASE_PRELOAD_PLATFORMS = [ + "backup", "config", "config_flow", "diagnostics", "energy", "group", - "logbook", "hardware", "intent", + "logbook", "media_source", "recorder", "repairs", "system_health", "trigger", - "backup", ] From bed186cce4cf5c66151cf7e855789a984525ec5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Dec 2024 11:19:28 -1000 Subject: [PATCH 0007/2987] Ensure cloud and recorder backup platforms do not have to wait for the import executor (#133907) * Ensure cloud and recorder backup platforms do not have to wait for the import executor partially fixes #133904 * backup.backup as well --- homeassistant/components/backup/__init__.py | 4 ++++ homeassistant/components/cloud/__init__.py | 9 ++++++++- homeassistant/components/recorder/__init__.py | 9 ++++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index f1a6f3be196..ab324a44e3b 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -5,6 +5,10 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.typing import ConfigType +# Pre-import backup to avoid it being imported +# later when the import executor is busy and delaying +# startup +from . import backup # noqa: F401 from .agent import ( BackupAgent, BackupAgentError, diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 80c02571d24..80b00237fd3 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -36,7 +36,14 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.signal_type import SignalType -from . import account_link, http_api +# Pre-import backup to avoid it being imported +# later when the import executor is busy and delaying +# startup +from . import ( + account_link, + backup, # noqa: F401 + http_api, +) from .client import CloudClient from .const import ( CONF_ACCOUNT_LINK_SERVER, diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 8564827d839..a40760c67f4 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -28,7 +28,14 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.event_type import EventType -from . import entity_registry, websocket_api +# Pre-import backup to avoid it being imported +# later when the import executor is busy and delaying +# startup +from . import ( + backup, # noqa: F401 + entity_registry, + websocket_api, +) from .const import ( # noqa: F401 CONF_DB_INTEGRITY_CHECK, DOMAIN, From d3666ecf8a894fb90e8a450970b58f08b6fd776a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Dec 2024 09:20:44 -1000 Subject: [PATCH 0008/2987] Fix duplicate call to async_register_preload_platform (#133909) --- homeassistant/helpers/integration_platform.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index a3eb19657e8..4ded7444989 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -175,6 +175,9 @@ async def async_process_integration_platforms( else: integration_platforms = hass.data[DATA_INTEGRATION_PLATFORMS] + # Tell the loader that it should try to pre-load the integration + # for any future components that are loaded so we can reduce the + # amount of import executor usage. async_register_preload_platform(hass, platform_name) top_level_components = hass.config.top_level_components.copy() process_job = HassJob( @@ -187,10 +190,6 @@ async def async_process_integration_platforms( integration_platform = IntegrationPlatform( platform_name, process_job, top_level_components ) - # Tell the loader that it should try to pre-load the integration - # for any future components that are loaded so we can reduce the - # amount of import executor usage. - async_register_preload_platform(hass, platform_name) integration_platforms.append(integration_platform) if not top_level_components: return From 657e5b73b6fdd1f4ec0607761747432817104717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 23 Dec 2024 20:34:36 +0000 Subject: [PATCH 0009/2987] Add cronsim to default dependencies (#133913) --- homeassistant/package_constraints.txt | 1 + pyproject.toml | 1 + requirements.txt | 1 + 3 files changed, 3 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f46248d2e1c..b88fef0f64f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,6 +25,7 @@ bluetooth-data-tools==1.20.0 cached-ipaddress==0.8.0 certifi>=2021.5.30 ciso8601==2.3.2 +cronsim==2.6 cryptography==44.0.0 dbus-fast==2.24.3 fnv-hash-fast==1.0.2 diff --git a/pyproject.toml b/pyproject.toml index 8c66e5a3bdd..3e432b6e8ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "bcrypt==4.2.0", "certifi>=2021.5.30", "ciso8601==2.3.2", + "cronsim==2.6", "fnv-hash-fast==1.0.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration diff --git a/requirements.txt b/requirements.txt index 82405dc44ef..3f1fd48ed57 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,6 +18,7 @@ awesomeversion==24.6.0 bcrypt==4.2.0 certifi>=2021.5.30 ciso8601==2.3.2 +cronsim==2.6 fnv-hash-fast==1.0.2 hass-nabucasa==0.87.0 httpx==0.27.2 From cf9686a802d97eb35a65f7720212237396738ab5 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 24 Dec 2024 10:59:36 +1000 Subject: [PATCH 0010/2987] Slow down polling in Teslemetry (#133924) --- homeassistant/components/teslemetry/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index e7232d0f87c..303a3250edf 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -18,7 +18,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import ENERGY_HISTORY_FIELDS, LOGGER from .helpers import flatten -VEHICLE_INTERVAL = timedelta(seconds=30) +VEHICLE_INTERVAL = timedelta(seconds=60) VEHICLE_WAIT = timedelta(minutes=15) ENERGY_LIVE_INTERVAL = timedelta(seconds=30) ENERGY_INFO_INTERVAL = timedelta(seconds=30) From 44150e9fd70beb4199c334da50f4abbfef729f8d Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Tue, 24 Dec 2024 06:45:13 +0000 Subject: [PATCH 0011/2987] Fix missing % in string for generic camera (#133925) Fix missing % in generic camera string --- homeassistant/components/generic/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index b3ecadacba5..45841e6255f 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -77,7 +77,7 @@ }, "error": { "unknown": "[%key:common::config_flow::error::unknown%]", - "unknown_with_details": "[%key:common::config_flow::error::unknown_with_details]", + "unknown_with_details": "[%key:component::generic::config::error::unknown_with_details%]", "already_exists": "[%key:component::generic::config::error::already_exists%]", "unable_still_load": "[%key:component::generic::config::error::unable_still_load%]", "unable_still_load_auth": "[%key:component::generic::config::error::unable_still_load_auth%]", From f23bc51b88579c7f225ee804eff9ca7116c0230a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 24 Dec 2024 07:42:48 +0100 Subject: [PATCH 0012/2987] Fix Peblar import in data coordinator (#133926) --- homeassistant/components/peblar/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/peblar/coordinator.py b/homeassistant/components/peblar/coordinator.py index 398788f1f9f..058f2aefb3b 100644 --- a/homeassistant/components/peblar/coordinator.py +++ b/homeassistant/components/peblar/coordinator.py @@ -16,6 +16,7 @@ from peblar import ( PeblarEVInterface, PeblarMeter, PeblarSystem, + PeblarSystemInformation, PeblarUserConfiguration, PeblarVersions, ) @@ -24,7 +25,6 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from tests.components.peblar.conftest import PeblarSystemInformation from .const import DOMAIN, LOGGER From 4f1e9b2338c8cf5c31dca49254e465971d3d4839 Mon Sep 17 00:00:00 2001 From: G-Two <7310260+G-Two@users.noreply.github.com> Date: Tue, 24 Dec 2024 02:59:51 -0500 Subject: [PATCH 0013/2987] Stop using shared aiohttp client session for Subaru integration (#133931) --- homeassistant/components/subaru/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/subaru/__init__.py b/homeassistant/components/subaru/__init__.py index 3762b16e58b..4068507ed14 100644 --- a/homeassistant/components/subaru/__init__.py +++ b/homeassistant/components/subaru/__init__.py @@ -49,7 +49,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Subaru from a config entry.""" config = entry.data - websession = aiohttp_client.async_get_clientsession(hass) + websession = aiohttp_client.async_create_clientsession(hass) try: controller = SubaruAPI( websession, From ce830719000256e75c704a06957e71e48589f479 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 24 Dec 2024 08:24:58 +0000 Subject: [PATCH 0014/2987] Bump version to 2025.1.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6cdb7f5fb07..d8c94a55e37 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 3e432b6e8ad..dbbe6dd7110 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.1.0b0" +version = "2025.1.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 502fbe65eefa50e86b2e1deea60354601b7449c2 Mon Sep 17 00:00:00 2001 From: Claudio Ruggeri - CR-Tech <41435902+crug80@users.noreply.github.com> Date: Tue, 24 Dec 2024 13:57:18 +0100 Subject: [PATCH 0015/2987] Fix reload modbus component issue (#133820) fix issue 116675 --- homeassistant/components/modbus/__init__.py | 39 ++++++++---- homeassistant/components/modbus/modbus.py | 3 - .../modbus/fixtures/configuration_2.yaml | 12 ++++ .../modbus/fixtures/configuration_empty.yaml | 0 tests/components/modbus/test_init.py | 60 ++++++++++++++----- 5 files changed, 85 insertions(+), 29 deletions(-) create mode 100644 tests/components/modbus/fixtures/configuration_2.yaml create mode 100644 tests/components/modbus/fixtures/configuration_empty.yaml diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 48f8c726836..bbd2ba5c02d 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -46,9 +46,13 @@ from homeassistant.const import ( CONF_TYPE, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, + SERVICE_RELOAD, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import async_get_platforms +from homeassistant.helpers.reload import async_integration_yaml_config +from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from .const import ( @@ -451,18 +455,29 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Modbus component.""" if DOMAIN not in config: return True + + async def _reload_config(call: Event | ServiceCall) -> None: + """Reload Modbus.""" + if DOMAIN not in hass.data: + _LOGGER.error("Modbus cannot reload, because it was never loaded") + return + hubs = hass.data[DOMAIN] + for name in hubs: + await hubs[name].async_close() + reset_platforms = async_get_platforms(hass, DOMAIN) + for reset_platform in reset_platforms: + _LOGGER.debug("Reload modbus resetting platform: %s", reset_platform.domain) + await reset_platform.async_reset() + reload_config = await async_integration_yaml_config(hass, DOMAIN) + if not reload_config: + _LOGGER.debug("Modbus not present anymore") + return + _LOGGER.debug("Modbus reloading") + await async_modbus_setup(hass, reload_config) + + async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config) + return await async_modbus_setup( hass, config, ) - - -async def async_reset_platform(hass: HomeAssistant, integration_name: str) -> None: - """Release modbus resources.""" - if DOMAIN not in hass.data: - _LOGGER.error("Modbus cannot reload, because it was never loaded") - return - _LOGGER.debug("Modbus reloading") - hubs = hass.data[DOMAIN] - for name in hubs: - await hubs[name].async_close() diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index efce44d7979..8c8a879ead6 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -34,7 +34,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType from .const import ( @@ -125,8 +124,6 @@ async def async_modbus_setup( ) -> bool: """Set up Modbus component.""" - await async_setup_reload_service(hass, DOMAIN, [DOMAIN]) - if config[DOMAIN]: config[DOMAIN] = check_config(hass, config[DOMAIN]) if not config[DOMAIN]: diff --git a/tests/components/modbus/fixtures/configuration_2.yaml b/tests/components/modbus/fixtures/configuration_2.yaml new file mode 100644 index 00000000000..3f7b062c4cb --- /dev/null +++ b/tests/components/modbus/fixtures/configuration_2.yaml @@ -0,0 +1,12 @@ +modbus: + type: "tcp" + host: "testHost" + port: 5001 + name: "testModbus" + sensors: + - name: "dummy" + address: 117 + slave: 0 + - name: "dummy_2" + address: 118 + slave: 1 diff --git a/tests/components/modbus/fixtures/configuration_empty.yaml b/tests/components/modbus/fixtures/configuration_empty.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 0cfa7ba8b24..5dd3f6e9033 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -25,7 +25,6 @@ import voluptuous as vol from homeassistant import config as hass_config from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.modbus import async_reset_platform from homeassistant.components.modbus.const import ( ATTR_ADDRESS, ATTR_HUB, @@ -1159,22 +1158,61 @@ async def test_integration_reload( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus, - freezer: FrozenDateTimeFactory, ) -> None: """Run test for integration reload.""" caplog.set_level(logging.DEBUG) caplog.clear() - yaml_path = get_fixture_path("configuration.yaml", "modbus") + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + yaml_path = get_fixture_path("configuration.yaml", DOMAIN) with mock.patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True) + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) await hass.async_block_till_done() - for _ in range(4): - freezer.tick(timedelta(seconds=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() assert "Modbus reloading" in caplog.text + state_sensor_1 = hass.states.get("sensor.dummy") + state_sensor_2 = hass.states.get("sensor.dummy_2") + assert state_sensor_1 + assert not state_sensor_2 + + caplog.clear() + yaml_path = get_fixture_path("configuration_2.yaml", DOMAIN) + with mock.patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + assert "Modbus reloading" in caplog.text + state_sensor_1 = hass.states.get("sensor.dummy") + state_sensor_2 = hass.states.get("sensor.dummy_2") + assert state_sensor_1 + assert state_sensor_2 + + caplog.clear() + yaml_path = get_fixture_path("configuration_empty.yaml", DOMAIN) + with mock.patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + assert "Modbus not present anymore" in caplog.text + state_sensor_1 = hass.states.get("sensor.dummy") + state_sensor_2 = hass.states.get("sensor.dummy_2") + assert not state_sensor_1 + assert not state_sensor_2 @pytest.mark.parametrize("do_config", [{}]) @@ -1227,9 +1265,3 @@ async def test_no_entities(hass: HomeAssistant) -> None: ] } assert await async_setup_component(hass, DOMAIN, config) is False - - -async def test_reset_platform(hass: HomeAssistant) -> None: - """Run test for async_reset_platform.""" - await async_reset_platform(hass, "modbus") - assert DOMAIN not in hass.data From 5d7a22fa7655099e45af849605e117f7d5f2de4e Mon Sep 17 00:00:00 2001 From: Khole <29937485+KJonline@users.noreply.github.com> Date: Tue, 24 Dec 2024 09:42:35 +0000 Subject: [PATCH 0016/2987] Hive: Fix error when device goes offline (#133848) --- homeassistant/components/hive/binary_sensor.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py index d14d98bcf50..d2938896f92 100644 --- a/homeassistant/components/hive/binary_sensor.py +++ b/homeassistant/components/hive/binary_sensor.py @@ -113,12 +113,17 @@ class HiveBinarySensorEntity(HiveEntity, BinarySensorEntity): await self.hive.session.updateData(self.device) self.device = await self.hive.sensor.getSensor(self.device) self.attributes = self.device.get("attributes", {}) - self._attr_is_on = self.device["status"]["state"] + if self.device["hiveType"] != "Connectivity": - self._attr_available = self.device["deviceData"].get("online") + self._attr_available = ( + self.device["deviceData"].get("online") and "status" in self.device + ) else: self._attr_available = True + if self._attr_available: + self._attr_is_on = self.device["status"].get("state") + class HiveSensorEntity(HiveEntity, BinarySensorEntity): """Hive Sensor Entity.""" From 4ca17dbb9eafc9db71d9649028e89d08a4de38a5 Mon Sep 17 00:00:00 2001 From: Philipp Danner Date: Tue, 24 Dec 2024 14:00:34 +0100 Subject: [PATCH 0017/2987] fix "Slow" response leads to "Could not find a charging station" #124129 (#133889) fix #124129 --- homeassistant/components/keba/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/keba/manifest.json b/homeassistant/components/keba/manifest.json index d86ce053187..6427a30f000 100644 --- a/homeassistant/components/keba/manifest.json +++ b/homeassistant/components/keba/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["keba_kecontact"], "quality_scale": "legacy", - "requirements": ["keba-kecontact==1.1.0"] + "requirements": ["keba-kecontact==1.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a087e3ff509..42dd4546e8b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1248,7 +1248,7 @@ justnimbus==0.7.4 kaiterra-async-client==1.0.0 # homeassistant.components.keba -keba-kecontact==1.1.0 +keba-kecontact==1.3.0 # homeassistant.components.kegtron kegtron-ble==0.4.0 From 7b2fc282e57e2203748213ee8c1493d34f80b81f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 24 Dec 2024 10:15:21 +0100 Subject: [PATCH 0018/2987] Update apprise to v1.9.1 (#133936) --- homeassistant/components/apprise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index 4f3c4d7ef4e..ebe27d42471 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["apprise"], "quality_scale": "legacy", - "requirements": ["apprise==1.9.0"] + "requirements": ["apprise==1.9.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 42dd4546e8b..c3988ae69f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -470,7 +470,7 @@ anthropic==0.31.2 apple_weatherkit==1.1.3 # homeassistant.components.apprise -apprise==1.9.0 +apprise==1.9.1 # homeassistant.components.aprs aprslib==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de9d048d72c..4365e33b8fa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -443,7 +443,7 @@ anthropic==0.31.2 apple_weatherkit==1.1.3 # homeassistant.components.apprise -apprise==1.9.0 +apprise==1.9.1 # homeassistant.components.aprs aprslib==0.7.2 From ef05133a663126b9b0a3dbaaef3fc48de657239b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 24 Dec 2024 10:17:02 +0100 Subject: [PATCH 0019/2987] Use SignedSession in Xbox (#133938) --- homeassistant/components/xbox/__init__.py | 10 ++-------- homeassistant/components/xbox/api.py | 12 ++++-------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 6ab46cea069..5282a34903a 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -10,11 +10,7 @@ from xbox.webapi.api.provider.smartglass.models import SmartglassConsoleList from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - aiohttp_client, - config_entry_oauth2_flow, - config_validation as cv, -) +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from . import api from .const import DOMAIN @@ -40,9 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - auth = api.AsyncConfigEntryAuth( - aiohttp_client.async_get_clientsession(hass), session - ) + auth = api.AsyncConfigEntryAuth(session) client = XboxLiveClient(auth) consoles: SmartglassConsoleList = await client.smartglass.get_console_list() diff --git a/homeassistant/components/xbox/api.py b/homeassistant/components/xbox/api.py index a0c2d4cfb16..d4c47e4cc39 100644 --- a/homeassistant/components/xbox/api.py +++ b/homeassistant/components/xbox/api.py @@ -1,24 +1,20 @@ """API for xbox bound to Home Assistant OAuth.""" -from aiohttp import ClientSession from xbox.webapi.authentication.manager import AuthenticationManager from xbox.webapi.authentication.models import OAuth2TokenResponse +from xbox.webapi.common.signed_session import SignedSession -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.util.dt import utc_from_timestamp class AsyncConfigEntryAuth(AuthenticationManager): """Provide xbox authentication tied to an OAuth2 based config entry.""" - def __init__( - self, - websession: ClientSession, - oauth_session: config_entry_oauth2_flow.OAuth2Session, - ) -> None: + def __init__(self, oauth_session: OAuth2Session) -> None: """Initialize xbox auth.""" # Leaving out client credentials as they are handled by Home Assistant - super().__init__(websession, "", "", "") + super().__init__(SignedSession(), "", "", "") self._oauth_session = oauth_session self.oauth = self._get_oauth_token() From 6e7d09583147d23e8d81ad8718c41d52fa29da63 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Dec 2024 13:44:09 +0100 Subject: [PATCH 0020/2987] Update Jinja2 to 3.1.5 (#133951) --- 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 b88fef0f64f..620eb4c00ed 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ home-assistant-frontend==20241223.1 home-assistant-intents==2024.12.20 httpx==0.27.2 ifaddr==0.2.0 -Jinja2==3.1.4 +Jinja2==3.1.5 lru-dict==1.3.0 mutagen==1.47.0 orjson==3.10.12 diff --git a/pyproject.toml b/pyproject.toml index dbbe6dd7110..3ada9fa51c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ dependencies = [ "httpx==0.27.2", "home-assistant-bluetooth==1.13.0", "ifaddr==0.2.0", - "Jinja2==3.1.4", + "Jinja2==3.1.5", "lru-dict==1.3.0", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. diff --git a/requirements.txt b/requirements.txt index 3f1fd48ed57..0d898edcd4b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ hass-nabucasa==0.87.0 httpx==0.27.2 home-assistant-bluetooth==1.13.0 ifaddr==0.2.0 -Jinja2==3.1.4 +Jinja2==3.1.5 lru-dict==1.3.0 PyJWT==2.10.1 cryptography==44.0.0 From 9242b67e0d6cc825cfba6bcbcd3c5e6ea07ac41e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 24 Dec 2024 16:41:36 +0100 Subject: [PATCH 0021/2987] Update frontend to 20241224.0 (#133963) --- 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 2d3604330f6..4a70889c1d2 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241223.1"] + "requirements": ["home-assistant-frontend==20241224.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 620eb4c00ed..a66137ef8c3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ habluetooth==3.6.0 hass-nabucasa==0.87.0 hassil==2.0.5 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241223.1 +home-assistant-frontend==20241224.0 home-assistant-intents==2024.12.20 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c3988ae69f1..fa2082b50e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1134,7 +1134,7 @@ hole==0.8.0 holidays==0.63 # homeassistant.components.frontend -home-assistant-frontend==20241223.1 +home-assistant-frontend==20241224.0 # homeassistant.components.conversation home-assistant-intents==2024.12.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4365e33b8fa..715cb26d398 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -963,7 +963,7 @@ hole==0.8.0 holidays==0.63 # homeassistant.components.frontend -home-assistant-frontend==20241223.1 +home-assistant-frontend==20241224.0 # homeassistant.components.conversation home-assistant-intents==2024.12.20 From d415b7bc8dc344902ab269ae24673f752ae6906d Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 24 Dec 2024 16:42:54 +0100 Subject: [PATCH 0022/2987] Bump version to 2025.1.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index d8c94a55e37..42407f46fb5 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 3ada9fa51c7..95cc634a333 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.1.0b1" +version = "2025.1.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 03fb1362187e5b2ae73ae43f1f195da20b64be54 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Fri, 27 Dec 2024 00:24:47 +0100 Subject: [PATCH 0023/2987] Fix swiss public transport line field none (#133964) * fix #133116 The line can theoretically be none, when no line info is available (lets say walking sections first?) * fix line field * add unit test with missing line field --- .../components/swiss_public_transport/coordinator.py | 4 ++-- .../swiss_public_transport/fixtures/connections.json | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py index 59602e7b982..c4cf2390dd0 100644 --- a/homeassistant/components/swiss_public_transport/coordinator.py +++ b/homeassistant/components/swiss_public_transport/coordinator.py @@ -113,7 +113,7 @@ class SwissPublicTransportDataUpdateCoordinator( destination=self._opendata.to_name, remaining_time=str(self.remaining_time(connections[i]["departure"])), delay=connections[i]["delay"], - line=connections[i]["line"], + line=connections[i].get("line"), ) for i in range(limit) if len(connections) > i and connections[i] is not None @@ -134,7 +134,7 @@ class SwissPublicTransportDataUpdateCoordinator( "train_number": connection["train_number"], "transfers": connection["transfers"], "delay": connection["delay"], - "line": connection["line"], + "line": connection.get("line"), } for connection in await self.fetch_connections(limit) ] diff --git a/tests/components/swiss_public_transport/fixtures/connections.json b/tests/components/swiss_public_transport/fixtures/connections.json index 7e61206c366..1e8e5022bdf 100644 --- a/tests/components/swiss_public_transport/fixtures/connections.json +++ b/tests/components/swiss_public_transport/fixtures/connections.json @@ -23,8 +23,7 @@ "platform": 2, "transfers": 0, "duration": "10", - "delay": 0, - "line": "T10" + "delay": 0 }, { "departure": "2024-01-06T18:06:00+0100", From f0e8360401e145a1448fece86c421738a39f21fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Dec 2024 07:48:55 -1000 Subject: [PATCH 0024/2987] Ensure all states have been migrated to use timestamps (#134007) --- .../components/recorder/db_schema.py | 2 +- .../components/recorder/migration.py | 17 ++- .../recorder/test_migration_from_schema_32.py | 140 ++++++++++++++++++ 3 files changed, 156 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index fa4162f4183..2afbed9cb75 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -77,7 +77,7 @@ class LegacyBase(DeclarativeBase): """Base class for tables, used for schema migration.""" -SCHEMA_VERSION = 47 +SCHEMA_VERSION = 48 _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index d57db03f90e..8c9252ba28b 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1976,6 +1976,17 @@ class _SchemaVersion47Migrator(_SchemaVersionMigrator, target_version=47): ) +class _SchemaVersion48Migrator(_SchemaVersionMigrator, target_version=48): + def _apply_update(self) -> None: + """Version specific update method.""" + # https://github.com/home-assistant/core/issues/134002 + # If the system has unmigrated states rows, we need to + # ensure they are migrated now so the new optimized + # queries can be used. For most systems, this should + # be very fast and nothing will be migrated. + _migrate_columns_to_timestamp(self.instance, self.session_maker, self.engine) + + def _migrate_statistics_columns_to_timestamp_removing_duplicates( hass: HomeAssistant, instance: Recorder, @@ -2109,7 +2120,8 @@ def _migrate_columns_to_timestamp( connection.execute( text( 'UPDATE events set time_fired_ts=strftime("%s",time_fired) + ' - "cast(substr(time_fired,-7) AS FLOAT);" + "cast(substr(time_fired,-7) AS FLOAT) " + "WHERE time_fired_ts is NULL;" ) ) connection.execute( @@ -2117,7 +2129,8 @@ def _migrate_columns_to_timestamp( 'UPDATE states set last_updated_ts=strftime("%s",last_updated) + ' "cast(substr(last_updated,-7) AS FLOAT), " 'last_changed_ts=strftime("%s",last_changed) + ' - "cast(substr(last_changed,-7) AS FLOAT);" + "cast(substr(last_changed,-7) AS FLOAT) " + " WHERE last_updated_ts is NULL;" ) ) elif engine.dialect.name == SupportedDialect.MYSQL: diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 3cc654c0fa1..0624955b0e9 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -2142,3 +2142,143 @@ async def test_stats_migrate_times( ) await hass.async_stop() + + +@pytest.mark.parametrize("persistent_database", [True]) +@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage +async def test_cleanup_unmigrated_state_timestamps( + async_test_recorder: RecorderInstanceGenerator, +) -> None: + """Ensure schema 48 migration cleans up any unmigrated state timestamps.""" + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] + + test_uuid = uuid.uuid4() + uuid_hex = test_uuid.hex + + def _object_as_dict(obj): + return {c.key: getattr(obj, c.key) for c in inspect(obj).mapper.column_attrs} + + def _insert_states(): + with session_scope(hass=hass) as session: + state1 = old_db_schema.States( + entity_id="state.test_state1", + last_updated=datetime.datetime( + 2016, 10, 28, 20, 13, 52, 452529, tzinfo=datetime.UTC + ), + last_updated_ts=None, + last_changed=datetime.datetime( + 2016, 10, 28, 20, 13, 52, 452529, tzinfo=datetime.UTC + ), + last_changed_ts=None, + context_id=uuid_hex, + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ) + state2 = old_db_schema.States( + entity_id="state.test_state2", + last_updated=datetime.datetime( + 2016, 10, 28, 20, 13, 52, 552529, tzinfo=datetime.UTC + ), + last_updated_ts=None, + last_changed=datetime.datetime( + 2016, 10, 28, 20, 13, 52, 452529, tzinfo=datetime.UTC + ), + last_changed_ts=None, + context_id=None, + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ) + session.add_all((state1, state2)) + # There is a default of now() for last_updated_ts so make sure it's not set + session.query(old_db_schema.States).update( + {old_db_schema.States.last_updated_ts: None} + ) + state3 = old_db_schema.States( + entity_id="state.already_migrated", + last_updated=None, + last_updated_ts=1477685632.452529, + last_changed=None, + last_changed_ts=1477685632.452529, + context_id=uuid_hex, + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ) + session.add_all((state3,)) + + with session_scope(hass=hass, read_only=True) as session: + states = session.query(old_db_schema.States).all() + assert len(states) == 3 + + # Create database with old schema + with ( + patch.object(recorder, "db_schema", old_db_schema), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + ): + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + await instance.async_add_executor_job(_insert_states) + + await async_wait_recording_done(hass) + now = dt_util.utcnow() + await _async_wait_migration_done(hass) + await async_wait_recording_done(hass) + + await hass.async_stop() + await hass.async_block_till_done() + + def _fetch_migrated_states(): + with session_scope(hass=hass) as session: + states = session.query(States).all() + assert len(states) == 3 + return {state.state_id: _object_as_dict(state) for state in states} + + # Run again with new schema, let migration run + async with async_test_home_assistant() as hass: + with ( + freeze_time(now), + instrument_migration(hass) as instrumented_migration, + ): + async with async_test_recorder( + hass, wait_recorder=False, wait_recorder_setup=False + ) as instance: + # Check the context ID migrator is considered non-live + assert recorder.util.async_migration_is_live(hass) is False + instrumented_migration.migration_stall.set() + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + states_by_metadata_id = await instance.async_add_executor_job( + _fetch_migrated_states + ) + + await hass.async_stop() + await hass.async_block_till_done() + + assert len(states_by_metadata_id) == 3 + for state in states_by_metadata_id.values(): + assert state["last_updated_ts"] is not None + + by_entity_id = { + state["entity_id"]: state for state in states_by_metadata_id.values() + } + assert by_entity_id["state.test_state1"]["last_updated_ts"] == 1477685632.452529 + assert by_entity_id["state.test_state2"]["last_updated_ts"] == 1477685632.552529 + assert ( + by_entity_id["state.already_migrated"]["last_updated_ts"] == 1477685632.452529 + ) From ef2af44795088fb6cb2d3719116feb4808c027da Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 26 Dec 2024 01:25:13 +0100 Subject: [PATCH 0025/2987] Bump pylamarzocco to 1.4.3 (#134008) --- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 309b858c77c..71d2278b51b 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_polling", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==1.4.2"] + "requirements": ["pylamarzocco==1.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index fa2082b50e0..2988073f2a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2043,7 +2043,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==1.4.2 +pylamarzocco==1.4.3 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 715cb26d398..c13cad719ca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1657,7 +1657,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.2 # homeassistant.components.lamarzocco -pylamarzocco==1.4.2 +pylamarzocco==1.4.3 # homeassistant.components.lastfm pylast==5.1.0 From 1957ab1ccfa1c4f0f64a71b50ade6a0219c1e330 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 26 Dec 2024 00:53:20 -0800 Subject: [PATCH 0026/2987] Improve Google Tasks error messages (#134023) --- homeassistant/components/google_tasks/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_tasks/api.py b/homeassistant/components/google_tasks/api.py index 2a294b84654..475f98443a6 100644 --- a/homeassistant/components/google_tasks/api.py +++ b/homeassistant/components/google_tasks/api.py @@ -115,7 +115,7 @@ class AsyncConfigEntryAuth: def response_handler(_, response, exception: HttpError) -> None: if exception is not None: raise GoogleTasksApiError( - f"Google Tasks API responded with error ({exception.status_code})" + f"Google Tasks API responded with error ({exception.reason or exception.status_code})" ) from exception if response: data = json.loads(response) @@ -152,7 +152,7 @@ class AsyncConfigEntryAuth: result = await self._hass.async_add_executor_job(request.execute) except HttpError as err: raise GoogleTasksApiError( - f"Google Tasks API responded with error ({err.status_code})" + f"Google Tasks API responded with: {err.reason or err.status_code})" ) from err if result: _raise_if_error(result) From c11bdcc9498e851e58e1107aa7fec13e0d6d3001 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 28 Dec 2024 21:38:04 +0100 Subject: [PATCH 0027/2987] Fix Nord Pool empty response (#134033) * Fix Nord Pool empty response * Mods * reset validate prices --- .../components/nordpool/coordinator.py | 17 ++- .../components/nordpool/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nordpool/conftest.py | 8 - tests/components/nordpool/test_coordinator.py | 9 +- tests/components/nordpool/test_sensor.py | 139 +++++++++++++++++- 7 files changed, 155 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py index 0c9a7e9f337..a6cfd40c323 100644 --- a/homeassistant/components/nordpool/coordinator.py +++ b/homeassistant/components/nordpool/coordinator.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio from collections.abc import Callable from datetime import datetime, timedelta from typing import TYPE_CHECKING @@ -73,7 +72,7 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): self.hass, self.fetch_data, self.get_next_interval(dt_util.utcnow()) ) data = await self.api_call() - if data: + if data and data.entries: self.async_set_updated_data(data) async def api_call(self, retry: int = 3) -> DeliveryPeriodsData | None: @@ -90,18 +89,20 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): self.config_entry.data[CONF_AREAS], ) except ( - NordPoolEmptyResponseError, NordPoolResponseError, NordPoolError, ) as error: LOGGER.debug("Connection error: %s", error) - if retry > 0: - next_run = (4 - retry) * 15 - LOGGER.debug("Wait %d seconds for next try", next_run) - await asyncio.sleep(next_run) - return await self.api_call(retry - 1) self.async_set_update_error(error) + if data: + current_day = dt_util.utcnow().strftime("%Y-%m-%d") + for entry in data.entries: + if entry.requested_date == current_day: + LOGGER.debug("Data for current day found") + return data + + self.async_set_update_error(NordPoolEmptyResponseError("No current day data")) return data def merge_price_entries(self) -> list[DeliveryPeriodEntry]: diff --git a/homeassistant/components/nordpool/manifest.json b/homeassistant/components/nordpool/manifest.json index 215494e10a0..b096d2bd506 100644 --- a/homeassistant/components/nordpool/manifest.json +++ b/homeassistant/components/nordpool/manifest.json @@ -8,6 +8,6 @@ "iot_class": "cloud_polling", "loggers": ["pynordpool"], "quality_scale": "platinum", - "requirements": ["pynordpool==0.2.3"], + "requirements": ["pynordpool==0.2.4"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 2988073f2a3..0c48fe1ab2c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2118,7 +2118,7 @@ pynetio==0.1.9.1 pynobo==1.8.1 # homeassistant.components.nordpool -pynordpool==0.2.3 +pynordpool==0.2.4 # homeassistant.components.nuki pynuki==1.6.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c13cad719ca..e092afbe528 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1720,7 +1720,7 @@ pynetgear==0.10.10 pynobo==1.8.1 # homeassistant.components.nordpool -pynordpool==0.2.3 +pynordpool==0.2.4 # homeassistant.components.nuki pynuki==1.6.3 diff --git a/tests/components/nordpool/conftest.py b/tests/components/nordpool/conftest.py index 1c26c7f84eb..ca1e2a05a0b 100644 --- a/tests/components/nordpool/conftest.py +++ b/tests/components/nordpool/conftest.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import AsyncGenerator import json from typing import Any -from unittest.mock import patch from pynordpool import API, NordPoolClient import pytest @@ -20,13 +19,6 @@ from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker -@pytest.fixture(autouse=True) -async def no_sleep() -> AsyncGenerator[None]: - """No sleeping.""" - with patch("homeassistant.components.nordpool.coordinator.asyncio.sleep"): - yield - - @pytest.fixture async def load_int(hass: HomeAssistant, get_client: NordPoolClient) -> MockConfigEntry: """Set up the Nord Pool integration in Home Assistant.""" diff --git a/tests/components/nordpool/test_coordinator.py b/tests/components/nordpool/test_coordinator.py index 7647fe4bdfe..71c4644ea95 100644 --- a/tests/components/nordpool/test_coordinator.py +++ b/tests/components/nordpool/test_coordinator.py @@ -55,7 +55,7 @@ async def test_coordinator( freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert mock_data.call_count == 4 + assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") assert state.state == STATE_UNAVAILABLE @@ -69,7 +69,7 @@ async def test_coordinator( freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert mock_data.call_count == 4 + assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") assert state.state == STATE_UNAVAILABLE assert "Authentication error" in caplog.text @@ -84,7 +84,8 @@ async def test_coordinator( freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert mock_data.call_count == 4 + # Empty responses does not raise + assert mock_data.call_count == 3 state = hass.states.get("sensor.nord_pool_se3_current_price") assert state.state == STATE_UNAVAILABLE assert "Empty response" in caplog.text @@ -99,7 +100,7 @@ async def test_coordinator( freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert mock_data.call_count == 4 + assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") assert state.state == STATE_UNAVAILABLE assert "Response error" in caplog.text diff --git a/tests/components/nordpool/test_sensor.py b/tests/components/nordpool/test_sensor.py index a1a27b5feec..60be1ee3258 100644 --- a/tests/components/nordpool/test_sensor.py +++ b/tests/components/nordpool/test_sensor.py @@ -2,14 +2,22 @@ from __future__ import annotations +from datetime import timedelta +from http import HTTPStatus +from typing import Any + +from freezegun.api import FrozenDateTimeFactory +from pynordpool import API import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import snapshot_platform +from tests.common import async_fire_time_changed, snapshot_platform +from tests.test_util.aiohttp import AiohttpClientMocker @pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") @@ -59,3 +67,132 @@ async def test_sensor_no_previous_price( assert current_price.state == "0.12666" # SE3 2024-11-05T23:00:00Z assert last_price.state == "0.28914" # SE3 2024-11-05T22:00:00Z assert next_price.state == "0.07406" # SE3 2024-11-06T00:00:00Z + + +@pytest.mark.freeze_time("2024-11-05T11:00:01+01:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_empty_response( + hass: HomeAssistant, + load_int: ConfigEntry, + load_json: list[dict[str, Any]], + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Nord Pool sensor with empty response.""" + + responses = list(load_json) + + current_price = hass.states.get("sensor.nord_pool_se3_current_price") + last_price = hass.states.get("sensor.nord_pool_se3_previous_price") + next_price = hass.states.get("sensor.nord_pool_se3_next_price") + assert current_price is not None + assert last_price is not None + assert next_price is not None + assert current_price.state == "0.92737" + assert last_price.state == "1.03132" + assert next_price.state == "0.92505" + + aioclient_mock.clear_requests() + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-04", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + json=responses[1], + ) + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-05", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + json=responses[0], + ) + # Future date without data should return 204 + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-06", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + status=HTTPStatus.NO_CONTENT, + ) + + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # All prices should be known as tomorrow is not loaded by sensors + + current_price = hass.states.get("sensor.nord_pool_se3_current_price") + last_price = hass.states.get("sensor.nord_pool_se3_previous_price") + next_price = hass.states.get("sensor.nord_pool_se3_next_price") + assert current_price is not None + assert last_price is not None + assert next_price is not None + assert current_price.state == "0.92505" + assert last_price.state == "0.92737" + assert next_price.state == "0.94949" + + aioclient_mock.clear_requests() + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-04", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + json=responses[1], + ) + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-05", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + json=responses[0], + ) + # Future date without data should return 204 + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-06", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + status=HTTPStatus.NO_CONTENT, + ) + + freezer.move_to("2024-11-05T22:00:01+00:00") + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Current and last price should be known, next price should be unknown + # as api responds with empty data (204) + + current_price = hass.states.get("sensor.nord_pool_se3_current_price") + last_price = hass.states.get("sensor.nord_pool_se3_previous_price") + next_price = hass.states.get("sensor.nord_pool_se3_next_price") + assert current_price is not None + assert last_price is not None + assert next_price is not None + assert current_price.state == "0.28914" + assert last_price.state == "0.5223" + assert next_price.state == STATE_UNKNOWN From 15b80c59fcfb859c482e171ce0455b18b5dfc6b3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 27 Dec 2024 21:33:37 +0100 Subject: [PATCH 0028/2987] Cleanup devices in Nord Pool from reconfiguration (#134043) * Cleanup devices in Nord Pool from reconfiguration * Mods * Mod --- homeassistant/components/nordpool/__init__.py | 40 ++- .../nordpool/fixtures/delivery_period_nl.json | 229 ++++++++++++++++++ tests/components/nordpool/test_init.py | 107 +++++++- 3 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 tests/components/nordpool/fixtures/delivery_period_nl.json diff --git a/homeassistant/components/nordpool/__init__.py b/homeassistant/components/nordpool/__init__.py index 83f8edc8a8d..77f4b263b54 100644 --- a/homeassistant/components/nordpool/__init__.py +++ b/homeassistant/components/nordpool/__init__.py @@ -5,11 +5,11 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from .const import DOMAIN, PLATFORMS +from .const import CONF_AREAS, DOMAIN, LOGGER, PLATFORMS from .coordinator import NordPoolDataUpdateCoordinator from .services import async_setup_services @@ -25,10 +25,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: NordPoolConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: NordPoolConfigEntry +) -> bool: """Set up Nord Pool from a config entry.""" - coordinator = NordPoolDataUpdateCoordinator(hass, entry) + await cleanup_device(hass, config_entry) + + coordinator = NordPoolDataUpdateCoordinator(hass, config_entry) await coordinator.fetch_data(dt_util.utcnow()) if not coordinator.last_update_success: raise ConfigEntryNotReady( @@ -36,13 +40,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: NordPoolConfigEntry) -> translation_key="initial_update_failed", translation_placeholders={"error": str(coordinator.last_exception)}, ) - entry.runtime_data = coordinator + config_entry.runtime_data = 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: NordPoolConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: NordPoolConfigEntry +) -> bool: """Unload Nord Pool config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + + +async def cleanup_device( + hass: HomeAssistant, config_entry: NordPoolConfigEntry +) -> None: + """Cleanup device and entities.""" + device_reg = dr.async_get(hass) + + entries = dr.async_entries_for_config_entry(device_reg, config_entry.entry_id) + for area in config_entry.data[CONF_AREAS]: + for entry in entries: + if entry.identifiers == {(DOMAIN, area)}: + continue + + LOGGER.debug("Removing device %s", entry.name) + device_reg.async_update_device( + entry.id, remove_config_entry_id=config_entry.entry_id + ) diff --git a/tests/components/nordpool/fixtures/delivery_period_nl.json b/tests/components/nordpool/fixtures/delivery_period_nl.json new file mode 100644 index 00000000000..cd326e05d01 --- /dev/null +++ b/tests/components/nordpool/fixtures/delivery_period_nl.json @@ -0,0 +1,229 @@ +{ + "deliveryDateCET": "2024-11-05", + "version": 2, + "updatedAt": "2024-11-04T11:58:10.7711584Z", + "deliveryAreas": ["NL"], + "market": "DayAhead", + "multiAreaEntries": [ + { + "deliveryStart": "2024-11-04T23:00:00Z", + "deliveryEnd": "2024-11-05T00:00:00Z", + "entryPerArea": { + "NL": 83.63 + } + }, + { + "deliveryStart": "2024-11-05T00:00:00Z", + "deliveryEnd": "2024-11-05T01:00:00Z", + "entryPerArea": { + "NL": 94.0 + } + }, + { + "deliveryStart": "2024-11-05T01:00:00Z", + "deliveryEnd": "2024-11-05T02:00:00Z", + "entryPerArea": { + "NL": 90.68 + } + }, + { + "deliveryStart": "2024-11-05T02:00:00Z", + "deliveryEnd": "2024-11-05T03:00:00Z", + "entryPerArea": { + "NL": 91.3 + } + }, + { + "deliveryStart": "2024-11-05T03:00:00Z", + "deliveryEnd": "2024-11-05T04:00:00Z", + "entryPerArea": { + "NL": 94.0 + } + }, + { + "deliveryStart": "2024-11-05T04:00:00Z", + "deliveryEnd": "2024-11-05T05:00:00Z", + "entryPerArea": { + "NL": 96.09 + } + }, + { + "deliveryStart": "2024-11-05T05:00:00Z", + "deliveryEnd": "2024-11-05T06:00:00Z", + "entryPerArea": { + "NL": 106.0 + } + }, + { + "deliveryStart": "2024-11-05T06:00:00Z", + "deliveryEnd": "2024-11-05T07:00:00Z", + "entryPerArea": { + "NL": 135.99 + } + }, + { + "deliveryStart": "2024-11-05T07:00:00Z", + "deliveryEnd": "2024-11-05T08:00:00Z", + "entryPerArea": { + "NL": 136.21 + } + }, + { + "deliveryStart": "2024-11-05T08:00:00Z", + "deliveryEnd": "2024-11-05T09:00:00Z", + "entryPerArea": { + "NL": 118.23 + } + }, + { + "deliveryStart": "2024-11-05T09:00:00Z", + "deliveryEnd": "2024-11-05T10:00:00Z", + "entryPerArea": { + "NL": 105.87 + } + }, + { + "deliveryStart": "2024-11-05T10:00:00Z", + "deliveryEnd": "2024-11-05T11:00:00Z", + "entryPerArea": { + "NL": 95.28 + } + }, + { + "deliveryStart": "2024-11-05T11:00:00Z", + "deliveryEnd": "2024-11-05T12:00:00Z", + "entryPerArea": { + "NL": 94.92 + } + }, + { + "deliveryStart": "2024-11-05T12:00:00Z", + "deliveryEnd": "2024-11-05T13:00:00Z", + "entryPerArea": { + "NL": 99.25 + } + }, + { + "deliveryStart": "2024-11-05T13:00:00Z", + "deliveryEnd": "2024-11-05T14:00:00Z", + "entryPerArea": { + "NL": 107.98 + } + }, + { + "deliveryStart": "2024-11-05T14:00:00Z", + "deliveryEnd": "2024-11-05T15:00:00Z", + "entryPerArea": { + "NL": 149.86 + } + }, + { + "deliveryStart": "2024-11-05T15:00:00Z", + "deliveryEnd": "2024-11-05T16:00:00Z", + "entryPerArea": { + "NL": 303.24 + } + }, + { + "deliveryStart": "2024-11-05T16:00:00Z", + "deliveryEnd": "2024-11-05T17:00:00Z", + "entryPerArea": { + "NL": 472.99 + } + }, + { + "deliveryStart": "2024-11-05T17:00:00Z", + "deliveryEnd": "2024-11-05T18:00:00Z", + "entryPerArea": { + "NL": 431.02 + } + }, + { + "deliveryStart": "2024-11-05T18:00:00Z", + "deliveryEnd": "2024-11-05T19:00:00Z", + "entryPerArea": { + "NL": 320.33 + } + }, + { + "deliveryStart": "2024-11-05T19:00:00Z", + "deliveryEnd": "2024-11-05T20:00:00Z", + "entryPerArea": { + "NL": 169.7 + } + }, + { + "deliveryStart": "2024-11-05T20:00:00Z", + "deliveryEnd": "2024-11-05T21:00:00Z", + "entryPerArea": { + "NL": 129.9 + } + }, + { + "deliveryStart": "2024-11-05T21:00:00Z", + "deliveryEnd": "2024-11-05T22:00:00Z", + "entryPerArea": { + "NL": 117.77 + } + }, + { + "deliveryStart": "2024-11-05T22:00:00Z", + "deliveryEnd": "2024-11-05T23:00:00Z", + "entryPerArea": { + "NL": 110.03 + } + } + ], + "blockPriceAggregates": [ + { + "blockName": "Off-peak 1", + "deliveryStart": "2024-11-04T23:00:00Z", + "deliveryEnd": "2024-11-05T07:00:00Z", + "averagePricePerArea": { + "NL": { + "average": 98.96, + "min": 83.63, + "max": 135.99 + } + } + }, + { + "blockName": "Peak", + "deliveryStart": "2024-11-05T07:00:00Z", + "deliveryEnd": "2024-11-05T19:00:00Z", + "averagePricePerArea": { + "NL": { + "average": 202.93, + "min": 94.92, + "max": 472.99 + } + } + }, + { + "blockName": "Off-peak 2", + "deliveryStart": "2024-11-05T19:00:00Z", + "deliveryEnd": "2024-11-05T23:00:00Z", + "averagePricePerArea": { + "NL": { + "average": 131.85, + "min": 110.03, + "max": 169.7 + } + } + } + ], + "currency": "EUR", + "exchangeRate": 1, + "areaStates": [ + { + "state": "Final", + "areas": ["NL"] + } + ], + "areaAverages": [ + { + "areaCode": "NL", + "price": 156.43 + } + ] +} diff --git a/tests/components/nordpool/test_init.py b/tests/components/nordpool/test_init.py index 3b1fc1fd8ec..c9b6167ff3c 100644 --- a/tests/components/nordpool/test_init.py +++ b/tests/components/nordpool/test_init.py @@ -2,9 +2,11 @@ from __future__ import annotations +import json from unittest.mock import patch from pynordpool import ( + API, NordPoolClient, NordPoolConnectionError, NordPoolEmptyResponseError, @@ -13,13 +15,17 @@ from pynordpool import ( ) import pytest -from homeassistant.components.nordpool.const import DOMAIN +from homeassistant.components.nordpool.const import CONF_AREAS, DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import CONF_CURRENCY from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import ENTRY_CONFIG -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker @pytest.mark.freeze_time("2024-11-05T10:00:00+00:00") @@ -71,3 +77,100 @@ async def test_initial_startup_fails( await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.freeze_time("2024-11-05T10:00:00+00:00") +async def test_reconfigure_cleans_up_device( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + get_client: NordPoolClient, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test clean up devices due to reconfiguration.""" + nl_json_file = load_fixture("delivery_period_nl.json", DOMAIN) + load_nl_json = json.loads(nl_json_file) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + assert entry.state is ConfigEntryState.LOADED + + assert device_registry.async_get_device(identifiers={(DOMAIN, "SE3")}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "SE4")}) + assert entity_registry.async_get("sensor.nord_pool_se3_current_price") + assert entity_registry.async_get("sensor.nord_pool_se4_current_price") + assert hass.states.get("sensor.nord_pool_se3_current_price") + assert hass.states.get("sensor.nord_pool_se4_current_price") + + aioclient_mock.clear_requests() + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-04", + "market": "DayAhead", + "deliveryArea": "NL", + "currency": "EUR", + }, + json=load_nl_json, + ) + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-05", + "market": "DayAhead", + "deliveryArea": "NL", + "currency": "EUR", + }, + json=load_nl_json, + ) + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-06", + "market": "DayAhead", + "deliveryArea": "NL", + "currency": "EUR", + }, + json=load_nl_json, + ) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_AREAS: ["NL"], + CONF_CURRENCY: "EUR", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + "areas": [ + "NL", + ], + "currency": "EUR", + } + await hass.async_block_till_done(wait_background_tasks=True) + + assert device_registry.async_get_device(identifiers={(DOMAIN, "NL")}) + assert entity_registry.async_get("sensor.nord_pool_nl_current_price") + assert hass.states.get("sensor.nord_pool_nl_current_price") + + assert not device_registry.async_get_device(identifiers={(DOMAIN, "SE3")}) + assert not entity_registry.async_get("sensor.nord_pool_se3_current_price") + assert not hass.states.get("sensor.nord_pool_se3_current_price") + assert not device_registry.async_get_device(identifiers={(DOMAIN, "SE4")}) + assert not entity_registry.async_get("sensor.nord_pool_se4_current_price") + assert not hass.states.get("sensor.nord_pool_se4_current_price") From b84ae2abc377e7336b273022ba14bc40ea4bac3c Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Thu, 26 Dec 2024 23:03:50 -0500 Subject: [PATCH 0029/2987] Bump aiorussound to 4.1.1 (#134058) * Bump aiorussound to 4.1.1 * Trigger Build * Trigger Build --- homeassistant/components/russound_rio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index ab77ca3ab6a..f1d3671970d 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.1.0"] + "requirements": ["aiorussound==4.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0c48fe1ab2c..abc3f2777b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -356,7 +356,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.1.0 +aiorussound==4.1.1 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e092afbe528..304416e4dd6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -338,7 +338,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.1.0 +aiorussound==4.1.1 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 From 1a909d3a8a636a75483c3d90ec3a3aace116a7c0 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 29 Dec 2024 09:23:44 -0700 Subject: [PATCH 0030/2987] Change SimpliSafe websocket reconnection log to `DEBUG`-level (#134063) * Change SimpliSafe websocket reconnection log to `DEBUG`-level * revert --- homeassistant/components/simplisafe/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index b72519f9734..2f19c5117a4 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -485,7 +485,7 @@ class SimpliSafe: except Exception as err: # noqa: BLE001 LOGGER.error("Unknown exception while connecting to websocket: %s", err) - LOGGER.warning("Reconnecting to websocket") + LOGGER.debug("Reconnecting to websocket") await self._async_cancel_websocket_loop() self._websocket_reconnect_task = self._hass.async_create_task( self._async_start_websocket_loop() From f6a9cd38c05718d31e22b0199b65a3fd26f03bd1 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Fri, 27 Dec 2024 05:01:10 -0500 Subject: [PATCH 0031/2987] Remove timeout from Russound RIO initialization (#134070) --- .../components/russound_rio/__init__.py | 6 ++---- .../components/russound_rio/config_flow.py | 17 +++++++---------- homeassistant/components/russound_rio/const.py | 3 --- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py index b068fbd1892..fedf5d8c686 100644 --- a/homeassistant/components/russound_rio/__init__.py +++ b/homeassistant/components/russound_rio/__init__.py @@ -1,6 +1,5 @@ """The russound_rio component.""" -import asyncio import logging from aiorussound import RussoundClient, RussoundTcpConnectionHandler @@ -11,7 +10,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONNECT_TIMEOUT, DOMAIN, RUSSOUND_RIO_EXCEPTIONS +from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS PLATFORMS = [Platform.MEDIA_PLAYER] @@ -40,8 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> await client.register_state_update_callbacks(_connection_update_callback) try: - async with asyncio.timeout(CONNECT_TIMEOUT): - await client.connect() + await client.connect() except RUSSOUND_RIO_EXCEPTIONS as err: raise ConfigEntryNotReady( translation_domain=DOMAIN, diff --git a/homeassistant/components/russound_rio/config_flow.py b/homeassistant/components/russound_rio/config_flow.py index e5efd309a23..f7f2e5b1d00 100644 --- a/homeassistant/components/russound_rio/config_flow.py +++ b/homeassistant/components/russound_rio/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import logging from typing import Any @@ -17,7 +16,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.helpers import config_validation as cv -from .const import CONNECT_TIMEOUT, DOMAIN, RUSSOUND_RIO_EXCEPTIONS +from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS DATA_SCHEMA = vol.Schema( { @@ -45,10 +44,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): client = RussoundClient(RussoundTcpConnectionHandler(host, port)) try: - async with asyncio.timeout(CONNECT_TIMEOUT): - await client.connect() - controller = client.controllers[1] - await client.disconnect() + await client.connect() + controller = client.controllers[1] + await client.disconnect() except RUSSOUND_RIO_EXCEPTIONS: _LOGGER.exception("Could not connect to Russound RIO") errors["base"] = "cannot_connect" @@ -90,10 +88,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): # Connection logic is repeated here since this method will be removed in future releases client = RussoundClient(RussoundTcpConnectionHandler(host, port)) try: - async with asyncio.timeout(CONNECT_TIMEOUT): - await client.connect() - controller = client.controllers[1] - await client.disconnect() + await client.connect() + controller = client.controllers[1] + await client.disconnect() except RUSSOUND_RIO_EXCEPTIONS: _LOGGER.exception("Could not connect to Russound RIO") return self.async_abort( diff --git a/homeassistant/components/russound_rio/const.py b/homeassistant/components/russound_rio/const.py index af52e89d399..a142ba8641d 100644 --- a/homeassistant/components/russound_rio/const.py +++ b/homeassistant/components/russound_rio/const.py @@ -16,9 +16,6 @@ RUSSOUND_RIO_EXCEPTIONS = ( asyncio.CancelledError, ) - -CONNECT_TIMEOUT = 15 - MP_FEATURES_BY_FLAG = { FeatureFlag.COMMANDS_ZONE_MUTE_OFF_ON: MediaPlayerEntityFeature.VOLUME_MUTE } From bd786b53eecd7a3c90b3c3d443951d5ca1e00228 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 27 Dec 2024 12:59:52 +0100 Subject: [PATCH 0032/2987] Fix KNX config flow translations and add data descriptions (#134078) * Fix KNX config flow translations and add data descriptions * Update strings.json * typo --- homeassistant/components/knx/strings.json | 84 +++++++++++++++-------- 1 file changed, 56 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 6c717c932b8..80ff1105e15 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -3,23 +3,30 @@ "step": { "connection_type": { "title": "KNX connection", - "description": "Please enter the connection type we should use for your KNX connection. \n AUTOMATIC - The integration takes care of the connectivity to your KNX Bus by performing a gateway scan. \n TUNNELING - The integration will connect to your KNX bus via tunneling. \n ROUTING - The integration will connect to your KNX bus via routing.", + "description": "'Automatic' performs a gateway scan on start, to find a KNX IP interface. It will connect via a tunnel. (Not available if a gateway scan was not successful.) \n\n 'Tunneling' will connect to a specific KNX IP interface over a tunnel. \n\n 'Routing' will use Multicast to communicate with KNX IP routers.", "data": { "connection_type": "KNX Connection Type" + }, + "data_description": { + "connection_type": "Please select the connection type you want to use for your KNX connection." } }, "tunnel": { "title": "Tunnel", - "description": "Please select a gateway from the list.", "data": { - "gateway": "KNX Tunnel Connection" + "gateway": "Please select a gateway from the list." + }, + "data_description": { + "gateway": "Select a KNX tunneling interface you want use for the connection." } }, "tcp_tunnel_endpoint": { - "title": "[%key:component::knx::config::step::knxkeys_tunnel_select::title%]", - "description": "[%key:component::knx::config::step::knxkeys_tunnel_select::description%]", + "title": "Tunnel endpoint", "data": { - "tunnel_endpoint_ia": "[%key:component::knx::config::step::knxkeys_tunnel_select::data::user_id%]" + "tunnel_endpoint_ia": "Select the tunnel endpoint used for the connection." + }, + "data_description": { + "tunnel_endpoint_ia": "'Automatic' selects a free tunnel endpoint for you when connecting. If you're unsure, this is the best option." } }, "manual_tunnel": { @@ -27,23 +34,24 @@ "description": "Please enter the connection information of your tunneling device.", "data": { "tunneling_type": "KNX Tunneling Type", - "port": "[%key:common::config_flow::data::port%]", "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", "route_back": "Route back / NAT mode", "local_ip": "Local IP interface" }, "data_description": { - "port": "Port of the KNX/IP tunneling device.", + "tunneling_type": "Select the tunneling type of your KNX/IP tunneling device. Older interfaces may only support `UDP`.", "host": "IP address or hostname of the KNX/IP tunneling device.", + "port": "Port used by the KNX/IP tunneling device.", "route_back": "Enable if your KNXnet/IP tunneling server is behind NAT. Only applies for UDP connections.", "local_ip": "Local IP or interface name used for the connection from Home Assistant. Leave blank to use auto-discovery." } }, "secure_key_source_menu_tunnel": { "title": "KNX IP-Secure", - "description": "Select how you want to configure KNX/IP Secure.", + "description": "How do you want to configure KNX/IP Secure?", "menu_options": { - "secure_knxkeys": "Use a `.knxkeys` file containing IP secure keys", + "secure_knxkeys": "Use a `.knxkeys` file providing IP secure keys", "secure_tunnel_manual": "Configure IP secure credentials manually" } }, @@ -57,20 +65,23 @@ }, "secure_knxkeys": { "title": "Import KNX Keyring", - "description": "Please select a `.knxkeys` file to import.", + "description": "The Keyring is used to encrypt and decrypt KNX IP Secure communication.", "data": { "knxkeys_file": "Keyring file", - "knxkeys_password": "The password to decrypt the `.knxkeys` file" + "knxkeys_password": "Keyring password" }, "data_description": { - "knxkeys_password": "This was set when exporting the file from ETS." + "knxkeys_file": "Select a `.knxkeys` file. This can be exported from ETS.", + "knxkeys_password": "The password to open the `.knxkeys` file was set when exporting." } }, "knxkeys_tunnel_select": { - "title": "Tunnel endpoint", - "description": "Select the tunnel endpoint used for the connection.", + "title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]", "data": { - "user_id": "'Automatic' selects a free tunnel endpoint for you when connecting. If you're unsure, this is the best option." + "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]" + }, + "data_description": { + "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]" } }, "secure_tunnel_manual": { @@ -82,7 +93,7 @@ "device_authentication": "Device authentication password" }, "data_description": { - "user_id": "This is often tunnel number +1. So 'Tunnel 2' would have User-ID '3'.", + "user_id": "This usually is tunnel number +1. So first tunnel in the list presented in ETS would have User-ID `2`.", "user_password": "Password for the specific tunnel connection set in the 'Properties' panel of the tunnel in ETS.", "device_authentication": "This is set in the 'IP' panel of the interface in ETS." } @@ -95,8 +106,8 @@ "sync_latency_tolerance": "Network latency tolerance" }, "data_description": { - "backbone_key": "Can be seen in the 'Security' report of an ETS project. Eg. '00112233445566778899AABBCCDDEEFF'", - "sync_latency_tolerance": "Default is 1000." + "backbone_key": "Can be seen in the 'Security' report of your ETS project. Eg. `00112233445566778899AABBCCDDEEFF`", + "sync_latency_tolerance": "Should be equal to the backbone configuration of your ETS project. Default is `1000`" } }, "routing": { @@ -104,13 +115,16 @@ "description": "Please configure the routing options.", "data": { "individual_address": "Individual address", - "routing_secure": "Use KNX IP Secure", + "routing_secure": "KNX IP Secure Routing", "multicast_group": "Multicast group", "multicast_port": "Multicast port", "local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]" }, "data_description": { "individual_address": "KNX address to be used by Home Assistant, e.g. `0.0.4`", + "routing_secure": "Select if your installation uses encrypted communication according to the KNX IP Secure standard. This setting requires compatible devices and configuration. You'll be prompted for credentials in the next step.", + "multicast_group": "Multicast group used by your installation. Default is `224.0.23.12`", + "multicast_port": "Multicast port used by your installation. Default is `3671`", "local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]" } } @@ -148,7 +162,7 @@ }, "data_description": { "state_updater": "Set default for reading states from the KNX Bus. When disabled, Home Assistant will not actively retrieve entity states from the KNX Bus. Can be overridden by `sync_state` entity options.", - "rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: 0 or 20 to 40", + "rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: `0` or between `20` and `40`", "telegram_log_size": "Telegrams to keep in memory for KNX panel group monitor. Maximum: {telegram_log_size_max}" } }, @@ -157,20 +171,27 @@ "description": "[%key:component::knx::config::step::connection_type::description%]", "data": { "connection_type": "[%key:component::knx::config::step::connection_type::data::connection_type%]" + }, + "data_description": { + "connection_type": "[%key:component::knx::config::step::connection_type::data_description::connection_type%]" } }, "tunnel": { "title": "[%key:component::knx::config::step::tunnel::title%]", - "description": "[%key:component::knx::config::step::tunnel::description%]", "data": { "gateway": "[%key:component::knx::config::step::tunnel::data::gateway%]" + }, + "data_description": { + "gateway": "[%key:component::knx::config::step::tunnel::data_description::gateway%]" } }, "tcp_tunnel_endpoint": { - "title": "[%key:component::knx::config::step::knxkeys_tunnel_select::title%]", - "description": "[%key:component::knx::config::step::knxkeys_tunnel_select::description%]", + "title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]", "data": { - "tunnel_endpoint_ia": "[%key:component::knx::config::step::knxkeys_tunnel_select::data::user_id%]" + "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]" + }, + "data_description": { + "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]" } }, "manual_tunnel": { @@ -184,6 +205,7 @@ "local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]" }, "data_description": { + "tunneling_type": "[%key:component::knx::config::step::manual_tunnel::data_description::tunneling_type%]", "port": "[%key:component::knx::config::step::manual_tunnel::data_description::port%]", "host": "[%key:component::knx::config::step::manual_tunnel::data_description::host%]", "route_back": "[%key:component::knx::config::step::manual_tunnel::data_description::route_back%]", @@ -214,14 +236,17 @@ "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_password%]" }, "data_description": { + "knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_file%]", "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]" } }, "knxkeys_tunnel_select": { - "title": "[%key:component::knx::config::step::knxkeys_tunnel_select::title%]", - "description": "[%key:component::knx::config::step::knxkeys_tunnel_select::description%]", + "title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]", "data": { - "user_id": "[%key:component::knx::config::step::knxkeys_tunnel_select::data::user_id%]" + "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]" + }, + "data_description": { + "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]" } }, "secure_tunnel_manual": { @@ -262,6 +287,9 @@ }, "data_description": { "individual_address": "[%key:component::knx::config::step::routing::data_description::individual_address%]", + "routing_secure": "[%key:component::knx::config::step::routing::data_description::routing_secure%]", + "multicast_group": "[%key:component::knx::config::step::routing::data_description::multicast_group%]", + "multicast_port": "[%key:component::knx::config::step::routing::data_description::multicast_port%]", "local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]" } } From 7032361bf5da98348974e2f716cc8419378e791d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 27 Dec 2024 17:52:33 +0100 Subject: [PATCH 0033/2987] Make google tasks recoverable (#134092) --- homeassistant/components/google_tasks/api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_tasks/api.py b/homeassistant/components/google_tasks/api.py index 475f98443a6..f51c5103b87 100644 --- a/homeassistant/components/google_tasks/api.py +++ b/homeassistant/components/google_tasks/api.py @@ -9,6 +9,7 @@ from google.oauth2.credentials import Credentials from googleapiclient.discovery import Resource, build from googleapiclient.errors import HttpError from googleapiclient.http import BatchHttpRequest, HttpRequest +from httplib2 import ServerNotFoundError from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant @@ -150,7 +151,7 @@ class AsyncConfigEntryAuth: async def _execute(self, request: HttpRequest | BatchHttpRequest) -> Any: try: result = await self._hass.async_add_executor_job(request.execute) - except HttpError as err: + except (HttpError, ServerNotFoundError) as err: raise GoogleTasksApiError( f"Google Tasks API responded with: {err.reason or err.status_code})" ) from err From 3120a90f2690fd11b0bbf86318878f0ecd10c7e8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 29 Dec 2024 14:26:59 +0100 Subject: [PATCH 0034/2987] Make elevenlabs recoverable (#134094) * Make elevenlabs recoverable * Add tests for entry setup * Use the same fixtures for setup and config flow * Update tests/components/elevenlabs/test_setup.py Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com> --------- Co-authored-by: Simon Sorg Co-authored-by: G Johansson Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com> --- .../components/elevenlabs/__init__.py | 9 ++- tests/components/elevenlabs/conftest.py | 55 +++++++++++++++---- .../components/elevenlabs/test_config_flow.py | 11 +++- tests/components/elevenlabs/test_setup.py | 36 ++++++++++++ 4 files changed, 97 insertions(+), 14 deletions(-) create mode 100644 tests/components/elevenlabs/test_setup.py diff --git a/homeassistant/components/elevenlabs/__init__.py b/homeassistant/components/elevenlabs/__init__.py index e8a378d56c6..e5807fec67c 100644 --- a/homeassistant/components/elevenlabs/__init__.py +++ b/homeassistant/components/elevenlabs/__init__.py @@ -6,11 +6,16 @@ from dataclasses import dataclass from elevenlabs import AsyncElevenLabs, Model from elevenlabs.core import ApiError +from httpx import ConnectError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers.httpx_client import get_async_client from .const import CONF_MODEL @@ -48,6 +53,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ElevenLabsConfigEntry) - model_id = entry.options[CONF_MODEL] try: model = await get_model_by_id(client, model_id) + except ConnectError as err: + raise ConfigEntryNotReady("Failed to connect") from err except ApiError as err: raise ConfigEntryAuthFailed("Auth failed") from err diff --git a/tests/components/elevenlabs/conftest.py b/tests/components/elevenlabs/conftest.py index d410f8bccdd..1c261e2947a 100644 --- a/tests/components/elevenlabs/conftest.py +++ b/tests/components/elevenlabs/conftest.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch from elevenlabs.core import ApiError from elevenlabs.types import GetVoicesResponse +from httpx import ConnectError import pytest from homeassistant.components.elevenlabs.const import CONF_MODEL, CONF_VOICE @@ -34,21 +35,55 @@ def _client_mock(): @pytest.fixture def mock_async_client() -> Generator[AsyncMock]: """Override async ElevenLabs client.""" - with patch( - "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", - return_value=_client_mock(), - ) as mock_async_client: + with ( + patch( + "homeassistant.components.elevenlabs.AsyncElevenLabs", + return_value=_client_mock(), + ) as mock_async_client, + patch( + "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", + new=mock_async_client, + ), + ): yield mock_async_client @pytest.fixture -def mock_async_client_fail() -> Generator[AsyncMock]: +def mock_async_client_api_error() -> Generator[AsyncMock]: + """Override async ElevenLabs client with ApiError side effect.""" + client_mock = _client_mock() + client_mock.models.get_all.side_effect = ApiError + client_mock.voices.get_all.side_effect = ApiError + + with ( + patch( + "homeassistant.components.elevenlabs.AsyncElevenLabs", + return_value=client_mock, + ) as mock_async_client, + patch( + "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", + new=mock_async_client, + ), + ): + yield mock_async_client + + +@pytest.fixture +def mock_async_client_connect_error() -> Generator[AsyncMock]: """Override async ElevenLabs client.""" - with patch( - "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", - return_value=_client_mock(), - ) as mock_async_client: - mock_async_client.side_effect = ApiError + client_mock = _client_mock() + client_mock.models.get_all.side_effect = ConnectError("Unknown") + client_mock.voices.get_all.side_effect = ConnectError("Unknown") + with ( + patch( + "homeassistant.components.elevenlabs.AsyncElevenLabs", + return_value=client_mock, + ) as mock_async_client, + patch( + "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", + new=mock_async_client, + ), + ): yield mock_async_client diff --git a/tests/components/elevenlabs/test_config_flow.py b/tests/components/elevenlabs/test_config_flow.py index 95e7ab5214e..7eeb0a6eb46 100644 --- a/tests/components/elevenlabs/test_config_flow.py +++ b/tests/components/elevenlabs/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock +import pytest + from homeassistant.components.elevenlabs.const import ( CONF_CONFIGURE_VOICE, CONF_MODEL, @@ -56,7 +58,10 @@ async def test_user_step( async def test_invalid_api_key( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_async_client_fail: AsyncMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_async_client_api_error: AsyncMock, + request: pytest.FixtureRequest, ) -> None: """Test user step with invalid api key.""" @@ -77,8 +82,8 @@ async def test_invalid_api_key( mock_setup_entry.assert_not_called() - # Reset the side effect - mock_async_client_fail.side_effect = None + # Use a working client + request.getfixturevalue("mock_async_client") result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/elevenlabs/test_setup.py b/tests/components/elevenlabs/test_setup.py new file mode 100644 index 00000000000..18b90ca3561 --- /dev/null +++ b/tests/components/elevenlabs/test_setup.py @@ -0,0 +1,36 @@ +"""Tests for the ElevenLabs TTS entity.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup( + hass: HomeAssistant, + mock_async_client: MagicMock, + mock_entry: MockConfigEntry, +) -> None: + """Test entry setup without any exceptions.""" + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + assert mock_entry.state == ConfigEntryState.LOADED + # Unload + await hass.config_entries.async_unload(mock_entry.entry_id) + assert mock_entry.state == ConfigEntryState.NOT_LOADED + + +async def test_setup_connect_error( + hass: HomeAssistant, + mock_async_client_connect_error: MagicMock, + mock_entry: MockConfigEntry, +) -> None: + """Test entry setup with a connection error.""" + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + # Ensure is not ready + assert mock_entry.state == ConfigEntryState.SETUP_RETRY From 1874eec8b34a269df2eb690fd25e1262ef6b36e5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 27 Dec 2024 20:21:12 +0100 Subject: [PATCH 0035/2987] Bump python-homeassistant-analytics to 0.8.1 (#134101) --- homeassistant/components/analytics_insights/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/analytics_insights/manifest.json b/homeassistant/components/analytics_insights/manifest.json index 841cf1caf42..bf99d89e073 100644 --- a/homeassistant/components/analytics_insights/manifest.json +++ b/homeassistant/components/analytics_insights/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["python_homeassistant_analytics"], - "requirements": ["python-homeassistant-analytics==0.8.0"], + "requirements": ["python-homeassistant-analytics==0.8.1"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index abc3f2777b1..5bbad424f1d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2360,7 +2360,7 @@ python-gc100==1.0.3a0 python-gitlab==1.6.0 # homeassistant.components.analytics_insights -python-homeassistant-analytics==0.8.0 +python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard python-homewizard-energy==v7.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 304416e4dd6..0714206ed5a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1902,7 +1902,7 @@ python-fullykiosk==0.0.14 # python-gammu==3.2.4 # homeassistant.components.analytics_insights -python-homeassistant-analytics==0.8.0 +python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard python-homewizard-energy==v7.0.0 From 951baa3972f0956b5bfcc2029fbaa7f06d1a5ded Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 27 Dec 2024 12:04:35 -0700 Subject: [PATCH 0036/2987] Bump `pytile` to 2024.12.0 (#134103) --- homeassistant/components/tile/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tile/manifest.json b/homeassistant/components/tile/manifest.json index 8dceddcb77f..f8acbc0bf1a 100644 --- a/homeassistant/components/tile/manifest.json +++ b/homeassistant/components/tile/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pytile"], - "requirements": ["pytile==2023.12.0"] + "requirements": ["pytile==2024.12.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5bbad424f1d..efd969cb543 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2442,7 +2442,7 @@ python-vlc==3.0.18122 pythonegardia==1.0.52 # homeassistant.components.tile -pytile==2023.12.0 +pytile==2024.12.0 # homeassistant.components.tomorrowio pytomorrowio==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0714206ed5a..96a25319338 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1966,7 +1966,7 @@ python-technove==1.3.1 python-telegram-bot[socks]==21.5 # homeassistant.components.tile -pytile==2023.12.0 +pytile==2024.12.0 # homeassistant.components.tomorrowio pytomorrowio==0.3.6 From bd243f68a48d3275dc75f8e92c18fea856ac7274 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 28 Dec 2024 13:13:07 +0100 Subject: [PATCH 0037/2987] Bump yt-dlp to 2024.12.23 (#134131) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 21c07607573..144904fe58c 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2024.12.13"], + "requirements": ["yt-dlp[default]==2024.12.23"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index efd969cb543..2b32db06322 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3082,7 +3082,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.12.13 +yt-dlp[default]==2024.12.23 # homeassistant.components.zabbix zabbix-utils==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 96a25319338..34c535a7832 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2477,7 +2477,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.12.13 +yt-dlp[default]==2024.12.23 # homeassistant.components.zamg zamg==0.3.6 From ef873663465f7ac127c5d30e69b1d9588dc7ef0a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 28 Dec 2024 15:36:23 +0100 Subject: [PATCH 0038/2987] Add missing device classes in scrape (#134141) --- homeassistant/components/scrape/strings.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 42cf3001b75..27115836157 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -141,8 +141,10 @@ "options": { "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "area": "[%key:component::sensor::entity_component::area::name%]", "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", "battery": "[%key:component::sensor::entity_component::battery::name%]", + "blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]", "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", "conductivity": "[%key:component::sensor::entity_component::conductivity::name%]", From 291dd6dc66886628a4e058469cdf6b7198b431f0 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sun, 29 Dec 2024 16:39:37 +0100 Subject: [PATCH 0039/2987] Update knx-frontend to 2024.12.26.233449 (#134184) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 55c19443aa0..8d18f11c798 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "requirements": [ "xknx==3.4.0", "xknxproject==3.8.1", - "knx-frontend==2024.11.16.205004" + "knx-frontend==2024.12.26.233449" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 2b32db06322..f52514eac04 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1260,7 +1260,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2024.11.16.205004 +knx-frontend==2024.12.26.233449 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34c535a7832..41bf2f7835d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1062,7 +1062,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2024.11.16.205004 +knx-frontend==2024.12.26.233449 # homeassistant.components.konnected konnected==1.2.0 From 394b2be40a685f384541addb79e16a3a6c4a0cff Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 29 Dec 2024 14:07:45 +0100 Subject: [PATCH 0040/2987] Make PEGELONLINE recoverable (#134199) --- .../components/pegel_online/__init__.py | 7 +++++- tests/components/pegel_online/test_init.py | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/pegel_online/__init__.py b/homeassistant/components/pegel_online/__init__.py index 2c465342493..30e5f4d2a38 100644 --- a/homeassistant/components/pegel_online/__init__.py +++ b/homeassistant/components/pegel_online/__init__.py @@ -5,10 +5,12 @@ from __future__ import annotations import logging from aiopegelonline import PegelOnline +from aiopegelonline.const import CONNECT_ERRORS from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_STATION @@ -28,7 +30,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) _LOGGER.debug("Setting up station with uuid %s", station_uuid) api = PegelOnline(async_get_clientsession(hass)) - station = await api.async_get_station_details(station_uuid) + try: + station = await api.async_get_station_details(station_uuid) + except CONNECT_ERRORS as err: + raise ConfigEntryNotReady("Failed to connect") from err coordinator = PegelOnlineDataUpdateCoordinator(hass, entry.title, api, station) diff --git a/tests/components/pegel_online/test_init.py b/tests/components/pegel_online/test_init.py index c1b8f1861c4..ac153193983 100644 --- a/tests/components/pegel_online/test_init.py +++ b/tests/components/pegel_online/test_init.py @@ -10,6 +10,7 @@ from homeassistant.components.pegel_online.const import ( DOMAIN, MIN_TIME_BETWEEN_UPDATES, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.util import utcnow @@ -24,6 +25,27 @@ from .const import ( from tests.common import MockConfigEntry, async_fire_time_changed +async def test_setup_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Tests error during config entry setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_ENTRY_DATA_DRESDEN, + unique_id=MOCK_CONFIG_ENTRY_DATA_DRESDEN[CONF_STATION], + ) + entry.add_to_hass(hass) + with patch("homeassistant.components.pegel_online.PegelOnline") as pegelonline: + pegelonline.return_value = PegelOnlineMock( + station_details=MOCK_STATION_DETAILS_DRESDEN, + station_measurements=MOCK_STATION_MEASUREMENT_DRESDEN, + ) + pegelonline().override_side_effect(ClientError("Boom")) + await hass.config_entries.async_setup(entry.entry_id) + + assert entry.state is ConfigEntryState.SETUP_RETRY + + async def test_update_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From a38839b420779054cdd2831e0d053d2d35ce4ae1 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 29 Dec 2024 15:08:15 +0100 Subject: [PATCH 0041/2987] Make feedreader recoverable (#134202) raise ConfigEntryNotReady on connection errors during setup --- .../components/feedreader/coordinator.py | 7 ++++++- tests/components/feedreader/test_init.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/feedreader/coordinator.py b/homeassistant/components/feedreader/coordinator.py index f45b303946a..fc338d63268 100644 --- a/homeassistant/components/feedreader/coordinator.py +++ b/homeassistant/components/feedreader/coordinator.py @@ -14,6 +14,7 @@ import feedparser from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -101,7 +102,11 @@ class FeedReaderCoordinator( async def async_setup(self) -> None: """Set up the feed manager.""" - feed = await self._async_fetch_feed() + try: + feed = await self._async_fetch_feed() + except UpdateFailed as err: + raise ConfigEntryNotReady from err + self.logger.debug("Feed data fetched from %s : %s", self.url, feed["feed"]) if feed_author := feed["feed"].get("author"): self.feed_author = html.unescape(feed_author) diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index bc7a66dc86e..9a2575bf591 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -11,6 +11,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.feedreader.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr import homeassistant.util.dt as dt_util @@ -52,6 +53,23 @@ async def test_setup( assert not events +async def test_setup_error( + hass: HomeAssistant, + feed_one_event, +) -> None: + """Test setup error.""" + entry = create_mock_entry(VALID_CONFIG_DEFAULT) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.feedreader.coordinator.feedparser.http.get" + ) as feedreader: + feedreader.side_effect = urllib.error.URLError("Test") + feedreader.return_value = feed_one_event + await hass.config_entries.async_setup(entry.entry_id) + + assert entry.state is ConfigEntryState.SETUP_RETRY + + async def test_storage_data_writing( hass: HomeAssistant, events: list[Event], From 0470bff9a221e8d8c876bcc4e59017f3c148168f Mon Sep 17 00:00:00 2001 From: Lucas Gasenzer Date: Sun, 29 Dec 2024 18:03:41 +0100 Subject: [PATCH 0042/2987] Fix Wake on LAN Port input as Box instead of Slider (#134216) --- homeassistant/components/wake_on_lan/services.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/wake_on_lan/services.yaml b/homeassistant/components/wake_on_lan/services.yaml index 48d3df5c4f9..e7c048daf64 100644 --- a/homeassistant/components/wake_on_lan/services.yaml +++ b/homeassistant/components/wake_on_lan/services.yaml @@ -15,3 +15,4 @@ send_magic_packet: number: min: 1 max: 65535 + mode: "box" From 52e47f55c87ce5dc36a24f36141d05b95ab0fd0b Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Sun, 29 Dec 2024 11:56:27 -0600 Subject: [PATCH 0043/2987] Bump VoIP utils to 0.2.2 (#134219) --- homeassistant/components/voip/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index 7dd2e797058..ed7f11f8fbc 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["voip-utils==0.2.1"] + "requirements": ["voip-utils==0.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index f52514eac04..5055b1842f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2960,7 +2960,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.2.1 +voip-utils==0.2.2 # homeassistant.components.volkszaehler volkszaehler==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 41bf2f7835d..5f5a4008f31 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2376,7 +2376,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.2.1 +voip-utils==0.2.2 # homeassistant.components.volvooncall volvooncall==0.10.3 From 352d5d14a33ca3c57ab0ac532c64c2f3e6dfdc75 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 29 Dec 2024 13:35:46 -0500 Subject: [PATCH 0044/2987] Bump frontend to 20241229.0 (#134225) --- 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 4a70889c1d2..ce40ce35a65 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241224.0"] + "requirements": ["home-assistant-frontend==20241229.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a66137ef8c3..1d4e86e9671 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ habluetooth==3.6.0 hass-nabucasa==0.87.0 hassil==2.0.5 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241224.0 +home-assistant-frontend==20241229.0 home-assistant-intents==2024.12.20 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5055b1842f2..20dd8e7709b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1134,7 +1134,7 @@ hole==0.8.0 holidays==0.63 # homeassistant.components.frontend -home-assistant-frontend==20241224.0 +home-assistant-frontend==20241229.0 # homeassistant.components.conversation home-assistant-intents==2024.12.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f5a4008f31..7bb70d209ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -963,7 +963,7 @@ hole==0.8.0 holidays==0.63 # homeassistant.components.frontend -home-assistant-frontend==20241224.0 +home-assistant-frontend==20241229.0 # homeassistant.components.conversation home-assistant-intents==2024.12.20 From b05b9b9a33746601331591abab9efdb37045b5d9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 29 Dec 2024 18:37:17 +0000 Subject: [PATCH 0045/2987] Bump version to 2025.1.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 42407f46fb5..91b31959854 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 95cc634a333..7fdc8b16719 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.1.0b2" +version = "2025.1.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From cf9ccc6fb424f9a59a5680606a94eee252935a57 Mon Sep 17 00:00:00 2001 From: Paul Daumlechner Date: Sun, 29 Dec 2024 21:00:26 +0100 Subject: [PATCH 0046/2987] Bump pyvlx to 0.2.26 (#115483) --- homeassistant/components/velux/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index c3576aca925..053b7fcc594 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/velux", "iot_class": "local_polling", "loggers": ["pyvlx"], - "requirements": ["pyvlx==0.2.21"] + "requirements": ["pyvlx==0.2.26"] } diff --git a/requirements_all.txt b/requirements_all.txt index 20dd8e7709b..b66662c2756 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2491,7 +2491,7 @@ pyvesync==2.1.12 pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.21 +pyvlx==0.2.26 # homeassistant.components.volumio pyvolumio==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7bb70d209ff..1f8fcd4476f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2006,7 +2006,7 @@ pyvesync==2.1.12 pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.21 +pyvlx==0.2.26 # homeassistant.components.volumio pyvolumio==0.1.5 From 2f8a92c7253184486bfea6ba54b3bacb72a2f6ac Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 30 Dec 2024 13:47:16 +0100 Subject: [PATCH 0047/2987] Make triggers and condition for monetary sensor consistent (#131184) --- homeassistant/components/sensor/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 0bc370398b5..d44d621f82d 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -23,7 +23,7 @@ "is_illuminance": "Current {entity_name} illuminance", "is_irradiance": "Current {entity_name} irradiance", "is_moisture": "Current {entity_name} moisture", - "is_monetary": "Current {entity_name} money", + "is_monetary": "Current {entity_name} balance", "is_nitrogen_dioxide": "Current {entity_name} nitrogen dioxide concentration level", "is_nitrogen_monoxide": "Current {entity_name} nitrogen monoxide concentration level", "is_nitrous_oxide": "Current {entity_name} nitrous oxide concentration level", @@ -75,7 +75,7 @@ "illuminance": "{entity_name} illuminance changes", "irradiance": "{entity_name} irradiance changes", "moisture": "{entity_name} moisture changes", - "monetary": "{entity_name} money changes", + "monetary": "{entity_name} balance changes", "nitrogen_dioxide": "{entity_name} nitrogen dioxide concentration changes", "nitrogen_monoxide": "{entity_name} nitrogen monoxide concentration changes", "nitrous_oxide": "{entity_name} nitrous oxide concentration changes", From 57561665453b429c93ec5ce5f1a59043cd7dce31 Mon Sep 17 00:00:00 2001 From: Adam Goode Date: Mon, 30 Dec 2024 06:05:33 -0500 Subject: [PATCH 0048/2987] Quickly process unavailable metrics in Prometheus (#133219) --- .../components/prometheus/__init__.py | 547 ++++++++++-------- 1 file changed, 293 insertions(+), 254 deletions(-) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index c243bf90dc0..ab012847bba 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections import defaultdict from collections.abc import Callable -from contextlib import suppress +from dataclasses import astuple, dataclass import logging import string from typing import Any, cast @@ -158,6 +159,22 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +@dataclass(frozen=True, slots=True) +class MetricNameWithLabelValues: + """Class to represent a metric with its label values. + + The prometheus client library doesn't easily allow us to get back the + information we put into it. Specifically, it is very expensive to query + which label values have been set for metrics. + + This class is used to hold a bit of data we need to efficiently remove + labelsets from metrics. + """ + + metric_name: str + label_values: tuple[str, ...] + + class PrometheusMetrics: """Model all of the metrics which should be exposed to Prometheus.""" @@ -191,6 +208,9 @@ class PrometheusMetrics: else: self.metrics_prefix = "" self._metrics: dict[str, MetricWrapperBase] = {} + self._metrics_by_entity_id: dict[str, set[MetricNameWithLabelValues]] = ( + defaultdict(set) + ) self._climate_units = climate_units def handle_state_changed_event(self, event: Event[EventStateChangedData]) -> None: @@ -202,10 +222,12 @@ class PrometheusMetrics: _LOGGER.debug("Filtered out entity %s", state.entity_id) return - if (old_state := event.data.get("old_state")) is not None and ( - old_friendly_name := old_state.attributes.get(ATTR_FRIENDLY_NAME) + if ( + old_state := event.data.get("old_state") + ) is not None and old_state.attributes.get( + ATTR_FRIENDLY_NAME ) != state.attributes.get(ATTR_FRIENDLY_NAME): - self._remove_labelsets(old_state.entity_id, old_friendly_name) + self._remove_labelsets(old_state.entity_id) self.handle_state(state) @@ -215,30 +237,32 @@ class PrometheusMetrics: _LOGGER.debug("Handling state update for %s", entity_id) labels = self._labels(state) - state_change = self._metric( - "state_change", prometheus_client.Counter, "The number of state changes" - ) - state_change.labels(**labels).inc() - entity_available = self._metric( + self._metric( + "state_change", + prometheus_client.Counter, + "The number of state changes", + labels, + ).inc() + + self._metric( "entity_available", prometheus_client.Gauge, "Entity is available (not in the unavailable or unknown state)", - ) - entity_available.labels(**labels).set(float(state.state not in IGNORED_STATES)) + labels, + ).set(float(state.state not in IGNORED_STATES)) - last_updated_time_seconds = self._metric( + self._metric( "last_updated_time_seconds", prometheus_client.Gauge, "The last_updated timestamp", - ) - last_updated_time_seconds.labels(**labels).set(state.last_updated.timestamp()) + labels, + ).set(state.last_updated.timestamp()) if state.state in IGNORED_STATES: self._remove_labelsets( entity_id, - None, - {state_change, entity_available, last_updated_time_seconds}, + {"state_change", "entity_available", "last_updated_time_seconds"}, ) else: domain, _ = hacore.split_entity_id(entity_id) @@ -274,67 +298,68 @@ class PrometheusMetrics: def _remove_labelsets( self, entity_id: str, - friendly_name: str | None = None, - ignored_metrics: set[MetricWrapperBase] | None = None, + ignored_metric_names: set[str] | None = None, ) -> None: """Remove labelsets matching the given entity id from all non-ignored metrics.""" - if ignored_metrics is None: - ignored_metrics = set() - for metric in list(self._metrics.values()): - if metric in ignored_metrics: + if ignored_metric_names is None: + ignored_metric_names = set() + metric_set = self._metrics_by_entity_id[entity_id] + removed_metrics = set() + for metric in metric_set: + metric_name, label_values = astuple(metric) + if metric_name in ignored_metric_names: continue - for sample in cast(list[prometheus_client.Metric], metric.collect())[ - 0 - ].samples: - if sample.labels["entity"] == entity_id and ( - not friendly_name or sample.labels["friendly_name"] == friendly_name - ): - _LOGGER.debug( - "Removing labelset from %s for entity_id: %s", - sample.name, - entity_id, - ) - with suppress(KeyError): - metric.remove(*sample.labels.values()) + + _LOGGER.debug( + "Removing labelset %s from %s for entity_id: %s", + label_values, + metric_name, + entity_id, + ) + removed_metrics.add(metric) + self._metrics[metric_name].remove(*label_values) + metric_set -= removed_metrics + if not metric_set: + del self._metrics_by_entity_id[entity_id] def _handle_attributes(self, state: State) -> None: for key, value in state.attributes.items(): - metric = self._metric( + try: + value = float(value) + except (ValueError, TypeError): + continue + + self._metric( f"{state.domain}_attr_{key.lower()}", prometheus_client.Gauge, f"{key} attribute of {state.domain} entity", - ) - - try: - value = float(value) - metric.labels(**self._labels(state)).set(value) - except (ValueError, TypeError): - pass + self._labels(state), + ).set(value) def _metric[_MetricBaseT: MetricWrapperBase]( self, - metric: str, + metric_name: str, factory: type[_MetricBaseT], documentation: str, - extra_labels: list[str] | None = None, + labels: dict[str, str], ) -> _MetricBaseT: - labels = ["entity", "friendly_name", "domain"] - if extra_labels is not None: - labels.extend(extra_labels) - try: - return cast(_MetricBaseT, self._metrics[metric]) + metric = cast(_MetricBaseT, self._metrics[metric_name]) except KeyError: full_metric_name = self._sanitize_metric_name( - f"{self.metrics_prefix}{metric}" + f"{self.metrics_prefix}{metric_name}" ) - self._metrics[metric] = factory( + self._metrics[metric_name] = factory( full_metric_name, documentation, - labels, + labels.keys(), registry=prometheus_client.REGISTRY, ) - return cast(_MetricBaseT, self._metrics[metric]) + metric = cast(_MetricBaseT, self._metrics[metric_name]) + self._metrics_by_entity_id[labels["entity"]].add( + MetricNameWithLabelValues(metric_name, tuple(labels.values())) + ) + return metric.labels(**labels) @staticmethod def _sanitize_metric_name(metric: str) -> str: @@ -356,67 +381,90 @@ class PrometheusMetrics: return value @staticmethod - def _labels(state: State) -> dict[str, Any]: - return { + def _labels( + state: State, + extra_labels: dict[str, str] | None = None, + ) -> dict[str, Any]: + if extra_labels is None: + extra_labels = {} + labels = { "entity": state.entity_id, "domain": state.domain, "friendly_name": state.attributes.get(ATTR_FRIENDLY_NAME), } + if not labels.keys().isdisjoint(extra_labels.keys()): + conflicting_keys = labels.keys() & extra_labels.keys() + raise ValueError( + f"extra_labels contains conflicting keys: {conflicting_keys}" + ) + return labels | extra_labels def _battery(self, state: State) -> None: - if (battery_level := state.attributes.get(ATTR_BATTERY_LEVEL)) is not None: - metric = self._metric( - "battery_level_percent", - prometheus_client.Gauge, - "Battery level as a percentage of its capacity", - ) - try: - value = float(battery_level) - metric.labels(**self._labels(state)).set(value) - except ValueError: - pass + if (battery_level := state.attributes.get(ATTR_BATTERY_LEVEL)) is None: + return + + try: + value = float(battery_level) + except ValueError: + return + + self._metric( + "battery_level_percent", + prometheus_client.Gauge, + "Battery level as a percentage of its capacity", + self._labels(state), + ).set(value) def _handle_binary_sensor(self, state: State) -> None: - metric = self._metric( + if (value := self.state_as_number(state)) is None: + return + + self._metric( "binary_sensor_state", prometheus_client.Gauge, "State of the binary sensor (0/1)", - ) - if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + self._labels(state), + ).set(value) def _handle_input_boolean(self, state: State) -> None: - metric = self._metric( + if (value := self.state_as_number(state)) is None: + return + + self._metric( "input_boolean_state", prometheus_client.Gauge, "State of the input boolean (0/1)", - ) - if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + self._labels(state), + ).set(value) def _numeric_handler(self, state: State, domain: str, title: str) -> None: + if (value := self.state_as_number(state)) is None: + return + if unit := self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)): metric = self._metric( f"{domain}_state_{unit}", prometheus_client.Gauge, f"State of the {title} measured in {unit}", + self._labels(state), ) else: metric = self._metric( f"{domain}_state", prometheus_client.Gauge, f"State of the {title}", + self._labels(state), ) - if (value := self.state_as_number(state)) is not None: - if ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfTemperature.FAHRENHEIT - ): - value = TemperatureConverter.convert( - value, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS - ) - metric.labels(**self._labels(state)).set(value) + if ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.FAHRENHEIT + ): + value = TemperatureConverter.convert( + value, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS + ) + + metric.set(value) def _handle_input_number(self, state: State) -> None: self._numeric_handler(state, "input_number", "input number") @@ -425,88 +473,99 @@ class PrometheusMetrics: self._numeric_handler(state, "number", "number") def _handle_device_tracker(self, state: State) -> None: - metric = self._metric( + if (value := self.state_as_number(state)) is None: + return + + self._metric( "device_tracker_state", prometheus_client.Gauge, "State of the device tracker (0/1)", - ) - if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + self._labels(state), + ).set(value) def _handle_person(self, state: State) -> None: - metric = self._metric( - "person_state", prometheus_client.Gauge, "State of the person (0/1)" - ) - if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + if (value := self.state_as_number(state)) is None: + return + + self._metric( + "person_state", + prometheus_client.Gauge, + "State of the person (0/1)", + self._labels(state), + ).set(value) def _handle_cover(self, state: State) -> None: - metric = self._metric( - "cover_state", - prometheus_client.Gauge, - "State of the cover (0/1)", - ["state"], - ) - cover_states = [STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING] for cover_state in cover_states: - metric.labels(**dict(self._labels(state), state=cover_state)).set( - float(cover_state == state.state) + metric = self._metric( + "cover_state", + prometheus_client.Gauge, + "State of the cover (0/1)", + self._labels(state, {"state": cover_state}), ) + metric.set(float(cover_state == state.state)) position = state.attributes.get(ATTR_CURRENT_POSITION) if position is not None: - position_metric = self._metric( + self._metric( "cover_position", prometheus_client.Gauge, "Position of the cover (0-100)", - ) - position_metric.labels(**self._labels(state)).set(float(position)) + self._labels(state), + ).set(float(position)) tilt_position = state.attributes.get(ATTR_CURRENT_TILT_POSITION) if tilt_position is not None: - tilt_position_metric = self._metric( + self._metric( "cover_tilt_position", prometheus_client.Gauge, "Tilt Position of the cover (0-100)", - ) - tilt_position_metric.labels(**self._labels(state)).set(float(tilt_position)) + self._labels(state), + ).set(float(tilt_position)) def _handle_light(self, state: State) -> None: - metric = self._metric( + if (value := self.state_as_number(state)) is None: + return + + brightness = state.attributes.get(ATTR_BRIGHTNESS) + if state.state == STATE_ON and brightness is not None: + value = float(brightness) / 255.0 + value = value * 100 + + self._metric( "light_brightness_percent", prometheus_client.Gauge, "Light brightness percentage (0..100)", - ) - - if (value := self.state_as_number(state)) is not None: - brightness = state.attributes.get(ATTR_BRIGHTNESS) - if state.state == STATE_ON and brightness is not None: - value = float(brightness) / 255.0 - value = value * 100 - metric.labels(**self._labels(state)).set(value) + self._labels(state), + ).set(value) def _handle_lock(self, state: State) -> None: - metric = self._metric( - "lock_state", prometheus_client.Gauge, "State of the lock (0/1)" - ) - if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + if (value := self.state_as_number(state)) is None: + return + + self._metric( + "lock_state", + prometheus_client.Gauge, + "State of the lock (0/1)", + self._labels(state), + ).set(value) def _handle_climate_temp( self, state: State, attr: str, metric_name: str, metric_description: str ) -> None: - if (temp := state.attributes.get(attr)) is not None: - if self._climate_units == UnitOfTemperature.FAHRENHEIT: - temp = TemperatureConverter.convert( - temp, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS - ) - metric = self._metric( - metric_name, - prometheus_client.Gauge, - metric_description, + if (temp := state.attributes.get(attr)) is None: + return + + if self._climate_units == UnitOfTemperature.FAHRENHEIT: + temp = TemperatureConverter.convert( + temp, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS ) - metric.labels(**self._labels(state)).set(temp) + self._metric( + metric_name, + prometheus_client.Gauge, + metric_description, + self._labels(state), + ).set(temp) def _handle_climate(self, state: State) -> None: self._handle_climate_temp( @@ -535,90 +594,75 @@ class PrometheusMetrics: ) if current_action := state.attributes.get(ATTR_HVAC_ACTION): - metric = self._metric( - "climate_action", - prometheus_client.Gauge, - "HVAC action", - ["action"], - ) for action in HVACAction: - metric.labels(**dict(self._labels(state), action=action.value)).set( - float(action == current_action) - ) + self._metric( + "climate_action", + prometheus_client.Gauge, + "HVAC action", + self._labels(state, {"action": action.value}), + ).set(float(action == current_action)) current_mode = state.state available_modes = state.attributes.get(ATTR_HVAC_MODES) if current_mode and available_modes: - metric = self._metric( - "climate_mode", - prometheus_client.Gauge, - "HVAC mode", - ["mode"], - ) for mode in available_modes: - metric.labels(**dict(self._labels(state), mode=mode)).set( - float(mode == current_mode) - ) + self._metric( + "climate_mode", + prometheus_client.Gauge, + "HVAC mode", + self._labels(state, {"mode": mode}), + ).set(float(mode == current_mode)) preset_mode = state.attributes.get(ATTR_PRESET_MODE) available_preset_modes = state.attributes.get(ATTR_PRESET_MODES) if preset_mode and available_preset_modes: - preset_metric = self._metric( - "climate_preset_mode", - prometheus_client.Gauge, - "Preset mode enum", - ["mode"], - ) for mode in available_preset_modes: - preset_metric.labels(**dict(self._labels(state), mode=mode)).set( - float(mode == preset_mode) - ) + self._metric( + "climate_preset_mode", + prometheus_client.Gauge, + "Preset mode enum", + self._labels(state, {"mode": mode}), + ).set(float(mode == preset_mode)) fan_mode = state.attributes.get(ATTR_FAN_MODE) available_fan_modes = state.attributes.get(ATTR_FAN_MODES) if fan_mode and available_fan_modes: - fan_mode_metric = self._metric( - "climate_fan_mode", - prometheus_client.Gauge, - "Fan mode enum", - ["mode"], - ) for mode in available_fan_modes: - fan_mode_metric.labels(**dict(self._labels(state), mode=mode)).set( - float(mode == fan_mode) - ) + self._metric( + "climate_fan_mode", + prometheus_client.Gauge, + "Fan mode enum", + self._labels(state, {"mode": mode}), + ).set(float(mode == fan_mode)) def _handle_humidifier(self, state: State) -> None: humidifier_target_humidity_percent = state.attributes.get(ATTR_HUMIDITY) if humidifier_target_humidity_percent: - metric = self._metric( + self._metric( "humidifier_target_humidity_percent", prometheus_client.Gauge, "Target Relative Humidity", - ) - metric.labels(**self._labels(state)).set(humidifier_target_humidity_percent) + self._labels(state), + ).set(humidifier_target_humidity_percent) - metric = self._metric( - "humidifier_state", - prometheus_client.Gauge, - "State of the humidifier (0/1)", - ) if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + self._metric( + "humidifier_state", + prometheus_client.Gauge, + "State of the humidifier (0/1)", + self._labels(state), + ).set(value) current_mode = state.attributes.get(ATTR_MODE) available_modes = state.attributes.get(ATTR_AVAILABLE_MODES) if current_mode and available_modes: - metric = self._metric( - "humidifier_mode", - prometheus_client.Gauge, - "Humidifier Mode", - ["mode"], - ) for mode in available_modes: - metric.labels(**dict(self._labels(state), mode=mode)).set( - float(mode == current_mode) - ) + self._metric( + "humidifier_mode", + prometheus_client.Gauge, + "Humidifier Mode", + self._labels(state, {"mode": mode}), + ).set(float(mode == current_mode)) def _handle_sensor(self, state: State) -> None: unit = self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) @@ -628,22 +672,24 @@ class PrometheusMetrics: if metric is not None: break - if metric is not None: + if metric is not None and (value := self.state_as_number(state)) is not None: documentation = "State of the sensor" if unit: documentation = f"Sensor data measured in {unit}" - _metric = self._metric(metric, prometheus_client.Gauge, documentation) - - if (value := self.state_as_number(state)) is not None: - if ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfTemperature.FAHRENHEIT - ): - value = TemperatureConverter.convert( - value, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS - ) - _metric.labels(**self._labels(state)).set(value) + if ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.FAHRENHEIT + ): + value = TemperatureConverter.convert( + value, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS + ) + self._metric( + metric, + prometheus_client.Gauge, + documentation, + self._labels(state), + ).set(value) self._battery(state) @@ -702,114 +748,107 @@ class PrometheusMetrics: return units.get(unit, default) def _handle_switch(self, state: State) -> None: - metric = self._metric( - "switch_state", prometheus_client.Gauge, "State of the switch (0/1)" - ) - if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + self._metric( + "switch_state", + prometheus_client.Gauge, + "State of the switch (0/1)", + self._labels(state), + ).set(value) self._handle_attributes(state) def _handle_fan(self, state: State) -> None: - metric = self._metric( - "fan_state", prometheus_client.Gauge, "State of the fan (0/1)" - ) - if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + self._metric( + "fan_state", + prometheus_client.Gauge, + "State of the fan (0/1)", + self._labels(state), + ).set(value) fan_speed_percent = state.attributes.get(ATTR_PERCENTAGE) if fan_speed_percent is not None: - fan_speed_metric = self._metric( + self._metric( "fan_speed_percent", prometheus_client.Gauge, "Fan speed percent (0-100)", - ) - fan_speed_metric.labels(**self._labels(state)).set(float(fan_speed_percent)) + self._labels(state), + ).set(float(fan_speed_percent)) fan_is_oscillating = state.attributes.get(ATTR_OSCILLATING) if fan_is_oscillating is not None: - fan_oscillating_metric = self._metric( + self._metric( "fan_is_oscillating", prometheus_client.Gauge, "Whether the fan is oscillating (0/1)", - ) - fan_oscillating_metric.labels(**self._labels(state)).set( - float(fan_is_oscillating) - ) + self._labels(state), + ).set(float(fan_is_oscillating)) fan_preset_mode = state.attributes.get(ATTR_PRESET_MODE) available_modes = state.attributes.get(ATTR_PRESET_MODES) if fan_preset_mode and available_modes: - fan_preset_metric = self._metric( - "fan_preset_mode", - prometheus_client.Gauge, - "Fan preset mode enum", - ["mode"], - ) for mode in available_modes: - fan_preset_metric.labels(**dict(self._labels(state), mode=mode)).set( - float(mode == fan_preset_mode) - ) + self._metric( + "fan_preset_mode", + prometheus_client.Gauge, + "Fan preset mode enum", + self._labels(state, {"mode": mode}), + ).set(float(mode == fan_preset_mode)) fan_direction = state.attributes.get(ATTR_DIRECTION) - if fan_direction is not None: - fan_direction_metric = self._metric( + if fan_direction in {DIRECTION_FORWARD, DIRECTION_REVERSE}: + self._metric( "fan_direction_reversed", prometheus_client.Gauge, "Fan direction reversed (bool)", - ) - if fan_direction == DIRECTION_FORWARD: - fan_direction_metric.labels(**self._labels(state)).set(0) - elif fan_direction == DIRECTION_REVERSE: - fan_direction_metric.labels(**self._labels(state)).set(1) + self._labels(state), + ).set(float(fan_direction == DIRECTION_REVERSE)) def _handle_zwave(self, state: State) -> None: self._battery(state) def _handle_automation(self, state: State) -> None: - metric = self._metric( + self._metric( "automation_triggered_count", prometheus_client.Counter, "Count of times an automation has been triggered", - ) - - metric.labels(**self._labels(state)).inc() + self._labels(state), + ).inc() def _handle_counter(self, state: State) -> None: - metric = self._metric( + if (value := self.state_as_number(state)) is None: + return + + self._metric( "counter_value", prometheus_client.Gauge, "Value of counter entities", - ) - if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + self._labels(state), + ).set(value) def _handle_update(self, state: State) -> None: - metric = self._metric( + if (value := self.state_as_number(state)) is None: + return + + self._metric( "update_state", prometheus_client.Gauge, "Update state, indicating if an update is available (0/1)", - ) - if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + self._labels(state), + ).set(value) def _handle_alarm_control_panel(self, state: State) -> None: current_state = state.state if current_state: - metric = self._metric( - "alarm_control_panel_state", - prometheus_client.Gauge, - "State of the alarm control panel (0/1)", - ["state"], - ) - for alarm_state in AlarmControlPanelState: - metric.labels(**dict(self._labels(state), state=alarm_state.value)).set( - float(alarm_state.value == current_state) - ) + self._metric( + "alarm_control_panel_state", + prometheus_client.Gauge, + "State of the alarm control panel (0/1)", + self._labels(state, {"state": alarm_state.value}), + ).set(float(alarm_state.value == current_state)) class PrometheusView(HomeAssistantView): From e22685640c5e9da86d7e198ea52bebfb02f1e731 Mon Sep 17 00:00:00 2001 From: Alberto Geniola Date: Mon, 30 Dec 2024 13:46:53 +0100 Subject: [PATCH 0049/2987] Bump elmax-api (#133845) --- homeassistant/components/elmax/config_flow.py | 4 +++- homeassistant/components/elmax/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py index 3bb01efd3d5..09e0bc0d260 100644 --- a/homeassistant/components/elmax/config_flow.py +++ b/homeassistant/components/elmax/config_flow.py @@ -151,7 +151,9 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN): port=self._panel_direct_port, ) ) - ssl_context = build_direct_ssl_context(cadata=self._panel_direct_ssl_cert) + ssl_context = await self.hass.async_add_executor_job( + build_direct_ssl_context, self._panel_direct_ssl_cert + ) # Attempt the connection to make sure the pin works. Also, take the chance to retrieve the panel ID via APIs. client_api_url = get_direct_api_url( diff --git a/homeassistant/components/elmax/manifest.json b/homeassistant/components/elmax/manifest.json index dfa20326d0c..f4b184c0475 100644 --- a/homeassistant/components/elmax/manifest.json +++ b/homeassistant/components/elmax/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/elmax", "iot_class": "cloud_polling", "loggers": ["elmax_api"], - "requirements": ["elmax-api==0.0.6.3"], + "requirements": ["elmax-api==0.0.6.4rc0"], "zeroconf": [ { "type": "_elmax-ssl._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index b66662c2756..1c215b31351 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -827,7 +827,7 @@ eliqonline==1.2.2 elkm1-lib==2.2.10 # homeassistant.components.elmax -elmax-api==0.0.6.3 +elmax-api==0.0.6.4rc0 # homeassistant.components.elvia elvia==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f8fcd4476f..007aacda6bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -702,7 +702,7 @@ elgato==5.1.2 elkm1-lib==2.2.10 # homeassistant.components.elmax -elmax-api==0.0.6.3 +elmax-api==0.0.6.4rc0 # homeassistant.components.elvia elvia==0.1.0 From 45fd7fb6d5d1372f6d080aff57cce1ab95240324 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 30 Dec 2024 13:38:48 +0100 Subject: [PATCH 0050/2987] Fix duplicate sensor disk entities in Systemmonitor (#134139) --- .../components/systemmonitor/sensor.py | 19 +++++---- tests/components/systemmonitor/test_sensor.py | 42 ++++++++++++++++++- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index ef1153f09e8..048d7cefd6c 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -429,16 +429,17 @@ async def async_setup_entry( is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) - loaded_resources.add(slugify(f"{_type}_{argument}")) - entities.append( - SystemMonitorSensor( - coordinator, - sensor_description, - entry.entry_id, - argument, - is_enabled, + if (_add := slugify(f"{_type}_{argument}")) not in loaded_resources: + loaded_resources.add(_add) + entities.append( + SystemMonitorSensor( + coordinator, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) ) - ) continue if _type.startswith("ipv"): diff --git a/tests/components/systemmonitor/test_sensor.py b/tests/components/systemmonitor/test_sensor.py index 6d22c5354a4..a5f5e7623e9 100644 --- a/tests/components/systemmonitor/test_sensor.py +++ b/tests/components/systemmonitor/test_sensor.py @@ -5,7 +5,7 @@ import socket from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory -from psutil._common import sdiskusage, shwtemp, snetio, snicaddr +from psutil._common import sdiskpart, sdiskusage, shwtemp, snetio, snicaddr import pytest from syrupy.assertion import SnapshotAssertion @@ -504,3 +504,43 @@ async def test_remove_obsolete_entities( entity_registry.async_get("sensor.systemmonitor_network_out_veth54321") is not None ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_no_duplicate_disk_entities( + hass: HomeAssistant, + mock_psutil: Mock, + mock_os: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the sensor.""" + mock_psutil.disk_usage.return_value = sdiskusage( + 500 * 1024**3, 300 * 1024**3, 200 * 1024**3, 60.0 + ) + mock_psutil.disk_partitions.return_value = [ + sdiskpart("test", "/", "ext4", ""), + sdiskpart("test2", "/media/share", "ext4", ""), + sdiskpart("test3", "/incorrect", "", ""), + sdiskpart("test4", "/media/frigate", "ext4", ""), + sdiskpart("test4", "/media/FRIGATE", "ext4", ""), + sdiskpart("hosts", "/etc/hosts", "bind", ""), + sdiskpart("proc", "/proc/run", "proc", ""), + ] + + mock_config_entry = MockConfigEntry( + title="System Monitor", + domain=DOMAIN, + data={}, + options={ + "binary_sensor": {"process": ["python3", "pip"]}, + }, + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + disk_sensor = hass.states.get("sensor.system_monitor_disk_usage_media_frigate") + assert disk_sensor is not None + assert disk_sensor.state == "60.0" + + assert "Platform systemmonitor does not generate unique IDs." not in caplog.text From 0873d27d7b3b88970896a7672a95e4f506cac46e Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Sat, 28 Dec 2024 21:34:01 +0100 Subject: [PATCH 0051/2987] Fix Onkyo volume rounding (#134157) --- homeassistant/components/onkyo/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 76194672bb7..97a82fc8a1a 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -427,7 +427,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): """ # HA_VOL * (MAX VOL / 100) * VOL_RESOLUTION self._update_receiver( - "volume", int(volume * (self._max_volume / 100) * self._volume_resolution) + "volume", round(volume * (self._max_volume / 100) * self._volume_resolution) ) async def async_volume_up(self) -> None: From ea51ecd384cf7b1a811b795a10239eed541b7085 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 29 Dec 2024 11:44:33 -0800 Subject: [PATCH 0052/2987] Bump opower to 0.8.7 (#134228) * Bump opower to 0.8.7 * update deps --- 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 593e4cf34b8..bd68cc84d13 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.6"] + "requirements": ["opower==0.8.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1c215b31351..175a3913be9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1570,7 +1570,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.8.6 +opower==0.8.7 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 007aacda6bf..1e0ebfcf904 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1306,7 +1306,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.0 # homeassistant.components.opower -opower==0.8.6 +opower==0.8.7 # homeassistant.components.oralb oralb-ble==0.17.6 From c402eaec3f9819ac4320e56d4da80ca8cb8e1288 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 29 Dec 2024 21:36:49 +0100 Subject: [PATCH 0053/2987] Bump aiopegelonline to 0.1.1 (#134230) bump aiopegelonline to 0.1.1 --- homeassistant/components/pegel_online/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pegel_online/manifest.json b/homeassistant/components/pegel_online/manifest.json index 443e8c58467..0a0f31532b1 100644 --- a/homeassistant/components/pegel_online/manifest.json +++ b/homeassistant/components/pegel_online/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aiopegelonline"], - "requirements": ["aiopegelonline==0.1.0"] + "requirements": ["aiopegelonline==0.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 175a3913be9..e3e54acf7b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -321,7 +321,7 @@ aioopenexchangerates==0.6.8 aiooui==0.1.7 # homeassistant.components.pegel_online -aiopegelonline==0.1.0 +aiopegelonline==0.1.1 # homeassistant.components.acmeda aiopulse==0.4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1e0ebfcf904..67ae0ebbbf3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -303,7 +303,7 @@ aioopenexchangerates==0.6.8 aiooui==0.1.7 # homeassistant.components.pegel_online -aiopegelonline==0.1.0 +aiopegelonline==0.1.1 # homeassistant.components.acmeda aiopulse==0.4.6 From a627fa70a7c78e585a37bbe74549fd71fdc1b040 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 30 Dec 2024 00:13:51 -0800 Subject: [PATCH 0054/2987] Avoid KeyError for ignored entries in async_step_zeroconf of Android TV Remote (#134250) --- homeassistant/components/androidtv_remote/config_flow.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 3500e4ff47b..4df25247881 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -156,7 +156,12 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): # and one of them, which could end up being in discovery_info.host, is from a # different device. If any of the discovery_info.ip_addresses matches the # existing host, don't update the host. - if existing_config_entry and len(discovery_info.ip_addresses) > 1: + if ( + existing_config_entry + # Ignored entries don't have host + and CONF_HOST in existing_config_entry.data + and len(discovery_info.ip_addresses) > 1 + ): existing_host = existing_config_entry.data[CONF_HOST] if existing_host != self.host: if existing_host in [ From 7456ce1c0170c41217bccd53bd06794edc554971 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 30 Dec 2024 00:20:35 -0800 Subject: [PATCH 0055/2987] Fix 400 This voice does not support speaking rate or pitch parameters at this time for Google Cloud Journey voices (#134255) --- .../components/google_cloud/const.py | 4 ++++ .../components/google_cloud/helpers.py | 9 +++++--- homeassistant/components/google_cloud/tts.py | 21 ++++++++++++++++--- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google_cloud/const.py b/homeassistant/components/google_cloud/const.py index f416d36483a..16b1463f0f3 100644 --- a/homeassistant/components/google_cloud/const.py +++ b/homeassistant/components/google_cloud/const.py @@ -20,6 +20,10 @@ CONF_GAIN = "gain" CONF_PROFILES = "profiles" CONF_TEXT_TYPE = "text_type" +DEFAULT_SPEED = 1.0 +DEFAULT_PITCH = 0 +DEFAULT_GAIN = 0 + # STT constants CONF_STT_MODEL = "stt_model" diff --git a/homeassistant/components/google_cloud/helpers.py b/homeassistant/components/google_cloud/helpers.py index f6e89fae7fa..f1adc42b4cd 100644 --- a/homeassistant/components/google_cloud/helpers.py +++ b/homeassistant/components/google_cloud/helpers.py @@ -31,7 +31,10 @@ from .const import ( CONF_SPEED, CONF_TEXT_TYPE, CONF_VOICE, + DEFAULT_GAIN, DEFAULT_LANG, + DEFAULT_PITCH, + DEFAULT_SPEED, ) DEFAULT_VOICE = "" @@ -104,15 +107,15 @@ def tts_options_schema( ), vol.Optional( CONF_SPEED, - default=defaults.get(CONF_SPEED, 1.0), + default=defaults.get(CONF_SPEED, DEFAULT_SPEED), ): NumberSelector(NumberSelectorConfig(min=0.25, max=4.0, step=0.01)), vol.Optional( CONF_PITCH, - default=defaults.get(CONF_PITCH, 0), + default=defaults.get(CONF_PITCH, DEFAULT_PITCH), ): NumberSelector(NumberSelectorConfig(min=-20.0, max=20.0, step=0.1)), vol.Optional( CONF_GAIN, - default=defaults.get(CONF_GAIN, 0), + default=defaults.get(CONF_GAIN, DEFAULT_GAIN), ): NumberSelector(NumberSelectorConfig(min=-96.0, max=16.0, step=0.1)), vol.Optional( CONF_PROFILES, diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index c3a8254ad90..7f22dda4faf 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -35,7 +35,10 @@ from .const import ( CONF_SPEED, CONF_TEXT_TYPE, CONF_VOICE, + DEFAULT_GAIN, DEFAULT_LANG, + DEFAULT_PITCH, + DEFAULT_SPEED, DOMAIN, ) from .helpers import async_tts_voices, tts_options_schema, tts_platform_schema @@ -191,11 +194,23 @@ class BaseGoogleCloudProvider: ssml_gender=gender, name=voice, ), + # Avoid: "This voice does not support speaking rate or pitch parameters at this time." + # by not specifying the fields unless they differ from the defaults audio_config=texttospeech.AudioConfig( audio_encoding=encoding, - speaking_rate=options[CONF_SPEED], - pitch=options[CONF_PITCH], - volume_gain_db=options[CONF_GAIN], + speaking_rate=( + options[CONF_SPEED] + if options[CONF_SPEED] != DEFAULT_SPEED + else None + ), + pitch=( + options[CONF_PITCH] + if options[CONF_PITCH] != DEFAULT_PITCH + else None + ), + volume_gain_db=( + options[CONF_GAIN] if options[CONF_GAIN] != DEFAULT_GAIN else None + ), effects_profile_id=options[CONF_PROFILES], ), ) From 077c9e62b47ac8082810ee64e6a0f7b69bf30b37 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 30 Dec 2024 12:27:32 +0100 Subject: [PATCH 0056/2987] Bump pylamarzocco to 1.4.5 (#134259) * Bump pylamarzocco to 1.4.4 * Bump pylamarzocco to 1.4.5 --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 71d2278b51b..6b586a5cfb8 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_polling", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==1.4.3"] + "requirements": ["pylamarzocco==1.4.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index e3e54acf7b3..89fb362204e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2043,7 +2043,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==1.4.3 +pylamarzocco==1.4.5 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 67ae0ebbbf3..94b8cc6871d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1657,7 +1657,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.2 # homeassistant.components.lamarzocco -pylamarzocco==1.4.3 +pylamarzocco==1.4.5 # homeassistant.components.lastfm pylast==5.1.0 From d9057fc43ec6f11c532c1a6fc3bc4489904334f1 Mon Sep 17 00:00:00 2001 From: Arne Keller Date: Mon, 30 Dec 2024 14:42:46 +0100 Subject: [PATCH 0057/2987] ollama: update to 0.4.5 (#134265) --- homeassistant/components/ollama/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/ollama/test_conversation.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ollama/manifest.json b/homeassistant/components/ollama/manifest.json index dca4c2dd6be..dbecbf87e4e 100644 --- a/homeassistant/components/ollama/manifest.json +++ b/homeassistant/components/ollama/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/ollama", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["ollama==0.3.3"] + "requirements": ["ollama==0.4.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 89fb362204e..3ff99ee2955 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1528,7 +1528,7 @@ oemthermostat==1.1.1 ohme==1.2.0 # homeassistant.components.ollama -ollama==0.3.3 +ollama==0.4.5 # homeassistant.components.omnilogic omnilogic==0.4.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 94b8cc6871d..6c763fca83d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1276,7 +1276,7 @@ odp-amsterdam==6.0.2 ohme==1.2.0 # homeassistant.components.ollama -ollama==0.3.3 +ollama==0.4.5 # homeassistant.components.omnilogic omnilogic==0.4.5 diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 66dc8a0c603..3202b42d9b3 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -51,8 +51,8 @@ async def test_chat( assert args["model"] == "test model" assert args["messages"] == [ - Message({"role": "system", "content": prompt}), - Message({"role": "user", "content": "test message"}), + Message(role="system", content=prompt), + Message(role="user", content="test message"), ] assert ( From 0c732510049e7e89da6c9962322d3477bfb5975e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 30 Dec 2024 16:22:30 +0100 Subject: [PATCH 0058/2987] Remove excessive period at end of action name (#134272) --- homeassistant/components/zwave_js/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 0c3ca6313d4..fc63b7e9119 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -290,7 +290,7 @@ "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]" } }, - "name": "Bulk set partial configuration parameters (advanced)." + "name": "Bulk set partial configuration parameters (advanced)" }, "clear_lock_usercode": { "description": "Clears a user code from a lock.", From 623e1b08b8beadf601df35dc2699f44b4906d749 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 30 Dec 2024 16:47:58 +0000 Subject: [PATCH 0059/2987] Bump aiomealie to 0.9.5 (#134274) --- homeassistant/components/mealie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index c555fcbc3d6..6e55abcdcad 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mealie", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["aiomealie==0.9.4"] + "requirements": ["aiomealie==0.9.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3ff99ee2955..49c801260fb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -294,7 +294,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.4 +aiomealie==0.9.5 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c763fca83d..42b3d42c9d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -276,7 +276,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.4 +aiomealie==0.9.5 # homeassistant.components.modern_forms aiomodernforms==0.1.8 From 82f0e8cc19e749fec98edc239e5c8a9ec3023fd2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 30 Dec 2024 20:04:50 +0100 Subject: [PATCH 0060/2987] Update frontend to 20241230.0 (#134284) --- 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 ce40ce35a65..01fe363d69e 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241229.0"] + "requirements": ["home-assistant-frontend==20241230.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1d4e86e9671..d1ccc31a0ed 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ habluetooth==3.6.0 hass-nabucasa==0.87.0 hassil==2.0.5 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241229.0 +home-assistant-frontend==20241230.0 home-assistant-intents==2024.12.20 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 49c801260fb..dee52f46c3b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1134,7 +1134,7 @@ hole==0.8.0 holidays==0.63 # homeassistant.components.frontend -home-assistant-frontend==20241229.0 +home-assistant-frontend==20241230.0 # homeassistant.components.conversation home-assistant-intents==2024.12.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42b3d42c9d7..02cf1e06481 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -963,7 +963,7 @@ hole==0.8.0 holidays==0.63 # homeassistant.components.frontend -home-assistant-frontend==20241229.0 +home-assistant-frontend==20241230.0 # homeassistant.components.conversation home-assistant-intents==2024.12.20 From c10175e25c90ba62bc81a750b44c699d7771914b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 30 Dec 2024 20:06:44 +0100 Subject: [PATCH 0061/2987] Bump version to 2025.1.0b4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 91b31959854..e45608ce9bb 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 7fdc8b16719..6219a7cee8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.1.0b3" +version = "2025.1.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From fbd6cf72441988740d102060f68561bb4d709300 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 31 Dec 2024 23:06:42 +0100 Subject: [PATCH 0062/2987] Improve Mealie set mealplan service (#130606) * Improve Mealie set mealplan service * Fix * Fix --- homeassistant/components/mealie/services.py | 2 +- homeassistant/components/mealie/strings.json | 4 +-- .../mealie/snapshots/test_services.ambr | 26 +++++++++++++++++++ tests/components/mealie/test_services.py | 6 +++++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index f195be37b11..ca8c28f9d13 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -92,7 +92,7 @@ SERVICE_SET_MEALPLAN_SCHEMA = vol.Any( [x.lower() for x in MealplanEntryType] ), vol.Required(ATTR_NOTE_TITLE): str, - vol.Required(ATTR_NOTE_TEXT): str, + vol.Optional(ATTR_NOTE_TEXT): str, } ), ) diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index e80db7ab3b0..fa63252e837 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -229,8 +229,8 @@ "description": "The type of dish to set the recipe to." }, "recipe_id": { - "name": "[%key:component::mealie::services::get_recipe::fields::recipe_id::name%]", - "description": "[%key:component::mealie::services::get_recipe::fields::recipe_id::description%]" + "name": "Recipe ID", + "description": "The recipe ID or the slug of the recipe to get." }, "note_title": { "name": "Meal note title", diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index 4f9ee6a5c09..56626c7b5c4 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -758,6 +758,32 @@ }), }) # --- +# name: test_service_set_mealplan[payload2-kwargs2] + dict({ + 'mealplan': dict({ + 'description': None, + 'entry_type': , + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'mealplan_date': datetime.date(2024, 1, 22), + 'mealplan_id': 230, + 'recipe': dict({ + 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'image': 'AiIo', + 'name': 'Zoete aardappel curry traybake', + 'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/', + 'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93', + 'recipe_yield': '2 servings', + 'slug': 'zoete-aardappel-curry-traybake', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + 'title': None, + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + }) +# --- # name: test_service_set_random_mealplan dict({ 'mealplan': dict({ diff --git a/tests/components/mealie/test_services.py b/tests/components/mealie/test_services.py index 1c8c6f19de7..63668379490 100644 --- a/tests/components/mealie/test_services.py +++ b/tests/components/mealie/test_services.py @@ -250,6 +250,12 @@ async def test_service_set_random_mealplan( }, {"recipe_id": None, "note_title": "Note Title", "note_text": "Note Text"}, ), + ( + { + ATTR_NOTE_TITLE: "Note Title", + }, + {"recipe_id": None, "note_title": "Note Title", "note_text": None}, + ), ], ) async def test_service_set_mealplan( From 54fa30c2b8a5ca06fa8ac7b66b58ef4980fc76ad Mon Sep 17 00:00:00 2001 From: Brynley McDonald Date: Wed, 1 Jan 2025 02:28:24 +1300 Subject: [PATCH 0063/2987] Update Flick Electric API (#133475) --- .../components/flick_electric/__init__.py | 62 +- .../components/flick_electric/config_flow.py | 157 ++++- .../components/flick_electric/const.py | 2 + .../components/flick_electric/coordinator.py | 47 ++ .../components/flick_electric/manifest.json | 2 +- .../components/flick_electric/sensor.py | 64 +- .../components/flick_electric/strings.json | 11 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/flick_electric/__init__.py | 50 ++ .../flick_electric/test_config_flow.py | 594 +++++++++++++++++- tests/components/flick_electric/test_init.py | 135 ++++ 12 files changed, 1046 insertions(+), 82 deletions(-) create mode 100644 homeassistant/components/flick_electric/coordinator.py create mode 100644 tests/components/flick_electric/test_init.py diff --git a/homeassistant/components/flick_electric/__init__.py b/homeassistant/components/flick_electric/__init__.py index a963d199c5a..190947e4c6f 100644 --- a/homeassistant/components/flick_electric/__init__.py +++ b/homeassistant/components/flick_electric/__init__.py @@ -20,7 +20,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import CONF_TOKEN_EXPIRY, DOMAIN +from .const import CONF_ACCOUNT_ID, CONF_SUPPLY_NODE_REF, CONF_TOKEN_EXPIRY +from .coordinator import FlickConfigEntry, FlickElectricDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -29,24 +30,67 @@ CONF_ID_TOKEN = "id_token" PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FlickConfigEntry) -> bool: """Set up Flick Electric from a config entry.""" auth = HassFlickAuth(hass, entry) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = FlickAPI(auth) + coordinator = FlickElectricDataCoordinator( + hass, FlickAPI(auth), entry.data[CONF_SUPPLY_NODE_REF] + ) + + await coordinator.async_config_entry_first_refresh() + + 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: FlickConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 2: + return False + + if config_entry.version == 1: + api = FlickAPI(HassFlickAuth(hass, config_entry)) + + accounts = await api.getCustomerAccounts() + active_accounts = [ + account for account in accounts if account["status"] == "active" + ] + + # A single active account can be auto-migrated + if (len(active_accounts)) == 1: + account = active_accounts[0] + + new_data = {**config_entry.data} + new_data[CONF_ACCOUNT_ID] = account["id"] + new_data[CONF_SUPPLY_NODE_REF] = account["main_consumer"]["supply_node_ref"] + hass.config_entries.async_update_entry( + config_entry, + title=account["address"], + unique_id=account["id"], + data=new_data, + version=2, + ) + return True + + config_entry.async_start_reauth(hass, data={**config_entry.data}) + return False + + return True class HassFlickAuth(AbstractFlickAuth): diff --git a/homeassistant/components/flick_electric/config_flow.py b/homeassistant/components/flick_electric/config_flow.py index 8a2455b9d14..b6b7327fcb0 100644 --- a/homeassistant/components/flick_electric/config_flow.py +++ b/homeassistant/components/flick_electric/config_flow.py @@ -1,14 +1,18 @@ """Config Flow for Flick Electric integration.""" import asyncio +from collections.abc import Mapping import logging from typing import Any -from pyflick.authentication import AuthException, SimpleFlickAuth +from aiohttp import ClientResponseError +from pyflick import FlickAPI +from pyflick.authentication import AbstractFlickAuth, SimpleFlickAuth from pyflick.const import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET +from pyflick.types import APIException, AuthException, CustomerAccount import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, @@ -17,12 +21,18 @@ from homeassistant.const import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) -from .const import DOMAIN +from .const import CONF_ACCOUNT_ID, CONF_SUPPLY_NODE_REF, DOMAIN _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema( +LOGIN_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, @@ -35,10 +45,13 @@ DATA_SCHEMA = vol.Schema( class FlickConfigFlow(ConfigFlow, domain=DOMAIN): """Flick config flow.""" - VERSION = 1 + VERSION = 2 + auth: AbstractFlickAuth + accounts: list[CustomerAccount] + data: dict[str, Any] - async def _validate_input(self, user_input): - auth = SimpleFlickAuth( + async def _validate_auth(self, user_input: Mapping[str, Any]) -> bool: + self.auth = SimpleFlickAuth( username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD], websession=aiohttp_client.async_get_clientsession(self.hass), @@ -48,22 +61,83 @@ class FlickConfigFlow(ConfigFlow, domain=DOMAIN): try: async with asyncio.timeout(60): - token = await auth.async_get_access_token() - except TimeoutError as err: + token = await self.auth.async_get_access_token() + except (TimeoutError, ClientResponseError) as err: raise CannotConnect from err except AuthException as err: raise InvalidAuth from err return token is not None + async def async_step_select_account( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask user to select account.""" + + errors = {} + if user_input is not None and CONF_ACCOUNT_ID in user_input: + self.data[CONF_ACCOUNT_ID] = user_input[CONF_ACCOUNT_ID] + self.data[CONF_SUPPLY_NODE_REF] = self._get_supply_node_ref( + user_input[CONF_ACCOUNT_ID] + ) + try: + # Ensure supply node is active + await FlickAPI(self.auth).getPricing(self.data[CONF_SUPPLY_NODE_REF]) + except (APIException, ClientResponseError): + errors["base"] = "cannot_connect" + except AuthException: + # We should never get here as we have a valid token + return self.async_abort(reason="no_permissions") + else: + # Supply node is active + return await self._async_create_entry() + + try: + self.accounts = await FlickAPI(self.auth).getCustomerAccounts() + except (APIException, ClientResponseError): + errors["base"] = "cannot_connect" + + active_accounts = [a for a in self.accounts if a["status"] == "active"] + + if len(active_accounts) == 0: + return self.async_abort(reason="no_accounts") + + if len(active_accounts) == 1: + self.data[CONF_ACCOUNT_ID] = active_accounts[0]["id"] + self.data[CONF_SUPPLY_NODE_REF] = self._get_supply_node_ref( + active_accounts[0]["id"] + ) + + return await self._async_create_entry() + + return self.async_show_form( + step_id="select_account", + data_schema=vol.Schema( + { + vol.Required(CONF_ACCOUNT_ID): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value=account["id"], label=account["address"] + ) + for account in active_accounts + ], + mode=SelectSelectorMode.LIST, + ) + ) + } + ), + errors=errors, + ) + async def async_step_user( - self, user_input: dict[str, Any] | None = None + self, user_input: Mapping[str, Any] | None = None ) -> ConfigFlowResult: """Handle gathering login info.""" errors = {} if user_input is not None: try: - await self._validate_input(user_input) + await self._validate_auth(user_input) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -72,20 +146,61 @@ class FlickConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id( - f"flick_electric_{user_input[CONF_USERNAME]}" - ) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=f"Flick Electric: {user_input[CONF_USERNAME]}", - data=user_input, - ) + self.data = dict(user_input) + return await self.async_step_select_account(user_input) return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", data_schema=LOGIN_SCHEMA, errors=errors ) + async def async_step_reauth( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication.""" + + self.data = {**user_input} + + return await self.async_step_user(user_input) + + async def _async_create_entry(self) -> ConfigFlowResult: + """Create an entry for the flow.""" + + await self.async_set_unique_id(self.data[CONF_ACCOUNT_ID]) + + account = self._get_account(self.data[CONF_ACCOUNT_ID]) + + if self.source == SOURCE_REAUTH: + # Migration completed + if self._get_reauth_entry().version == 1: + self.hass.config_entries.async_update_entry( + self._get_reauth_entry(), + unique_id=self.unique_id, + data=self.data, + version=self.VERSION, + ) + + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + unique_id=self.unique_id, + title=account["address"], + data=self.data, + ) + + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=account["address"], + data=self.data, + ) + + def _get_account(self, account_id: str) -> CustomerAccount: + """Get the account for the account ID.""" + return next(a for a in self.accounts if a["id"] == account_id) + + def _get_supply_node_ref(self, account_id: str) -> str: + """Get the supply node ref for the account.""" + return self._get_account(account_id)["main_consumer"][CONF_SUPPLY_NODE_REF] + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/flick_electric/const.py b/homeassistant/components/flick_electric/const.py index de1942096b5..0f94aa909b7 100644 --- a/homeassistant/components/flick_electric/const.py +++ b/homeassistant/components/flick_electric/const.py @@ -3,6 +3,8 @@ DOMAIN = "flick_electric" CONF_TOKEN_EXPIRY = "expires" +CONF_ACCOUNT_ID = "account_id" +CONF_SUPPLY_NODE_REF = "supply_node_ref" ATTR_START_AT = "start_at" ATTR_END_AT = "end_at" diff --git a/homeassistant/components/flick_electric/coordinator.py b/homeassistant/components/flick_electric/coordinator.py new file mode 100644 index 00000000000..474efc5297d --- /dev/null +++ b/homeassistant/components/flick_electric/coordinator.py @@ -0,0 +1,47 @@ +"""Data Coordinator for Flick Electric.""" + +import asyncio +from datetime import timedelta +import logging + +import aiohttp +from pyflick import FlickAPI, FlickPrice +from pyflick.types import APIException, AuthException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=5) + +type FlickConfigEntry = ConfigEntry[FlickElectricDataCoordinator] + + +class FlickElectricDataCoordinator(DataUpdateCoordinator[FlickPrice]): + """Coordinator for flick power price.""" + + def __init__( + self, hass: HomeAssistant, api: FlickAPI, supply_node_ref: str + ) -> None: + """Initialize FlickElectricDataCoordinator.""" + super().__init__( + hass, + _LOGGER, + name="Flick Electric", + update_interval=SCAN_INTERVAL, + ) + self.supply_node_ref = supply_node_ref + self._api = api + + async def _async_update_data(self) -> FlickPrice: + """Fetch pricing data from Flick Electric.""" + try: + async with asyncio.timeout(60): + return await self._api.getPricing(self.supply_node_ref) + except AuthException as err: + raise ConfigEntryAuthFailed from err + except (APIException, aiohttp.ClientResponseError) as err: + raise UpdateFailed from err diff --git a/homeassistant/components/flick_electric/manifest.json b/homeassistant/components/flick_electric/manifest.json index 0b1f2677d6a..3aee25995a9 100644 --- a/homeassistant/components/flick_electric/manifest.json +++ b/homeassistant/components/flick_electric/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyflick"], - "requirements": ["PyFlick==0.0.2"] + "requirements": ["PyFlick==1.1.2"] } diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py index 347109c66c0..147d00c943d 100644 --- a/homeassistant/components/flick_electric/sensor.py +++ b/homeassistant/components/flick_electric/sensor.py @@ -1,74 +1,72 @@ """Support for Flick Electric Pricing data.""" -import asyncio from datetime import timedelta +from decimal import Decimal import logging from typing import Any -from pyflick import FlickAPI, FlickPrice - from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CURRENCY_CENT, UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.dt import utcnow +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTR_COMPONENTS, ATTR_END_AT, ATTR_START_AT, DOMAIN +from .const import ATTR_COMPONENTS, ATTR_END_AT, ATTR_START_AT +from .coordinator import FlickConfigEntry, FlickElectricDataCoordinator _LOGGER = logging.getLogger(__name__) - SCAN_INTERVAL = timedelta(minutes=5) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FlickConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Flick Sensor Setup.""" - api: FlickAPI = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data - async_add_entities([FlickPricingSensor(api)], True) + async_add_entities([FlickPricingSensor(coordinator)]) -class FlickPricingSensor(SensorEntity): +class FlickPricingSensor(CoordinatorEntity[FlickElectricDataCoordinator], SensorEntity): """Entity object for Flick Electric sensor.""" _attr_attribution = "Data provided by Flick Electric" _attr_native_unit_of_measurement = f"{CURRENCY_CENT}/{UnitOfEnergy.KILO_WATT_HOUR}" _attr_has_entity_name = True _attr_translation_key = "power_price" - _attributes: dict[str, Any] = {} - def __init__(self, api: FlickAPI) -> None: + def __init__(self, coordinator: FlickElectricDataCoordinator) -> None: """Entity object for Flick Electric sensor.""" - self._api: FlickAPI = api - self._price: FlickPrice = None + super().__init__(coordinator) + + self._attr_unique_id = f"{coordinator.supply_node_ref}_pricing" @property - def native_value(self): + def native_value(self) -> Decimal: """Return the state of the sensor.""" - return self._price.price + # The API should return a unit price with quantity of 1.0 when no start/end time is provided + if self.coordinator.data.quantity != 1: + _LOGGER.warning( + "Unexpected quantity for unit price: %s", self.coordinator.data + ) + return self.coordinator.data.cost @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" - return self._attributes + components: dict[str, Decimal] = {} - async def async_update(self) -> None: - """Get the Flick Pricing data from the web service.""" - if self._price and self._price.end_at >= utcnow(): - return # Power price data is still valid - - async with asyncio.timeout(60): - self._price = await self._api.getPricing() - - _LOGGER.debug("Pricing data: %s", self._price) - - self._attributes[ATTR_START_AT] = self._price.start_at - self._attributes[ATTR_END_AT] = self._price.end_at - for component in self._price.components: + for component in self.coordinator.data.components: if component.charge_setter not in ATTR_COMPONENTS: _LOGGER.warning("Found unknown component: %s", component.charge_setter) continue - self._attributes[component.charge_setter] = float(component.value) + components[component.charge_setter] = component.value + + return { + ATTR_START_AT: self.coordinator.data.start_at, + ATTR_END_AT: self.coordinator.data.end_at, + **components, + } diff --git a/homeassistant/components/flick_electric/strings.json b/homeassistant/components/flick_electric/strings.json index 8b55bef939e..4b1fd300e2b 100644 --- a/homeassistant/components/flick_electric/strings.json +++ b/homeassistant/components/flick_electric/strings.json @@ -9,6 +9,12 @@ "client_id": "Client ID (optional)", "client_secret": "Client Secret (optional)" } + }, + "select_account": { + "title": "Select account", + "data": { + "account_id": "Account" + } } }, "error": { @@ -17,7 +23,10 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "no_permissions": "Cannot get pricing for this account. Please check user permissions.", + "no_accounts": "No services are active on this Flick account" } }, "entity": { diff --git a/requirements_all.txt b/requirements_all.txt index dee52f46c3b..438690ac560 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -48,7 +48,7 @@ ProgettiHWSW==0.1.3 PyChromecast==14.0.5 # homeassistant.components.flick_electric -PyFlick==0.0.2 +PyFlick==1.1.2 # homeassistant.components.flume PyFlume==0.6.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 02cf1e06481..ebf6ac82782 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -45,7 +45,7 @@ ProgettiHWSW==0.1.3 PyChromecast==14.0.5 # homeassistant.components.flick_electric -PyFlick==0.0.2 +PyFlick==1.1.2 # homeassistant.components.flume PyFlume==0.6.5 diff --git a/tests/components/flick_electric/__init__.py b/tests/components/flick_electric/__init__.py index 7ba25e6c180..36936cad047 100644 --- a/tests/components/flick_electric/__init__.py +++ b/tests/components/flick_electric/__init__.py @@ -1 +1,51 @@ """Tests for the Flick Electric integration.""" + +from pyflick.types import FlickPrice + +from homeassistant.components.flick_electric.const import ( + CONF_ACCOUNT_ID, + CONF_SUPPLY_NODE_REF, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +CONF = { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_ACCOUNT_ID: "1234", + CONF_SUPPLY_NODE_REF: "123", +} + + +def _mock_flick_price(): + return FlickPrice( + { + "cost": "0.25", + "quantity": "1.0", + "status": "final", + "start_at": "2024-01-01T00:00:00Z", + "end_at": "2024-01-01T00:00:00Z", + "type": "flat", + "components": [ + { + "charge_method": "kwh", + "charge_setter": "network", + "value": "1.00", + "single_unit_price": "1.00", + "quantity": "1.0", + "unit_code": "NZD", + "charge_per": "kwh", + "flow_direction": "import", + }, + { + "charge_method": "kwh", + "charge_setter": "nonsupported", + "value": "1.00", + "single_unit_price": "1.00", + "quantity": "1.0", + "unit_code": "NZD", + "charge_per": "kwh", + "flow_direction": "import", + }, + ], + } + ) diff --git a/tests/components/flick_electric/test_config_flow.py b/tests/components/flick_electric/test_config_flow.py index 85a6495d3c5..7ac605f1c8c 100644 --- a/tests/components/flick_electric/test_config_flow.py +++ b/tests/components/flick_electric/test_config_flow.py @@ -3,29 +3,37 @@ from unittest.mock import patch from pyflick.authentication import AuthException +from pyflick.types import APIException from homeassistant import config_entries -from homeassistant.components.flick_electric.const import DOMAIN +from homeassistant.components.flick_electric.const import ( + CONF_ACCOUNT_ID, + CONF_SUPPLY_NODE_REF, + DOMAIN, +) from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from . import CONF, _mock_flick_price -CONF = {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"} +from tests.common import MockConfigEntry async def _flow_submit(hass: HomeAssistant) -> ConfigFlowResult: return await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, - data=CONF, + data={ + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], + }, ) async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" + """Test we get the form with only one, with no account picker.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -38,6 +46,21 @@ async def test_form(hass: HomeAssistant) -> None: "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", return_value="123456789abcdef", ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + } + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + return_value=_mock_flick_price(), + ), patch( "homeassistant.components.flick_electric.async_setup_entry", return_value=True, @@ -45,29 +68,293 @@ async def test_form(hass: HomeAssistant) -> None: ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - CONF, + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Flick Electric: test-username" + assert result2["title"] == "123 Fake St" assert result2["data"] == CONF + assert result2["result"].unique_id == "1234" assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_duplicate_login(hass: HomeAssistant) -> None: - """Test uniqueness of username.""" +async def test_form_multi_account(hass: HomeAssistant) -> None: + """Test the form when multiple accounts are available.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + { + "id": "5678", + "status": "active", + "address": "456 Fake St", + "main_consumer": {"supply_node_ref": "456"}, + }, + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + return_value=_mock_flick_price(), + ), + patch( + "homeassistant.components.flick_electric.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "select_account" + assert len(mock_setup_entry.mock_calls) == 0 + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"account_id": "5678"}, + ) + + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "456 Fake St" + assert result3["data"] == { + **CONF, + CONF_SUPPLY_NODE_REF: "456", + CONF_ACCOUNT_ID: "5678", + } + assert result3["result"].unique_id == "5678" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_token(hass: HomeAssistant) -> None: + """Test reauth flow when username/password is wrong.""" entry = MockConfigEntry( domain=DOMAIN, - data=CONF, - title="Flick Electric: test-username", - unique_id="flick_electric_test-username", + data={**CONF}, + title="123 Fake St", + unique_id="1234", + version=2, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + side_effect=AuthException, + ), + ): + result = await entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + assert result["step_id"] == "user" + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + return_value=_mock_flick_price(), + ), + patch( + "homeassistant.config_entries.ConfigEntries.async_update_entry", + return_value=True, + ) as mock_update_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert len(mock_update_entry.mock_calls) > 0 + + +async def test_form_reauth_migrate(hass: HomeAssistant) -> None: + """Test reauth flow for v1 with single account.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + title="123 Fake St", + unique_id="test-username", + version=1, + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + return_value=_mock_flick_price(), + ), + ): + result = await entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.version == 2 + assert entry.unique_id == "1234" + assert entry.data == CONF + + +async def test_form_reauth_migrate_multi_account(hass: HomeAssistant) -> None: + """Test the form when multiple accounts are available.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + title="123 Fake St", + unique_id="test-username", + version=1, + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + { + "id": "5678", + "status": "active", + "address": "456 Fake St", + "main_consumer": {"supply_node_ref": "456"}, + }, + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + return_value=_mock_flick_price(), + ), + ): + result = await entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "select_account" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"account_id": "5678"}, + ) + + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + assert entry.version == 2 + assert entry.unique_id == "5678" + assert entry.data == { + **CONF, + CONF_ACCOUNT_ID: "5678", + CONF_SUPPLY_NODE_REF: "456", + } + + +async def test_form_duplicate_account(hass: HomeAssistant) -> None: + """Test uniqueness for account_id.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={**CONF, CONF_ACCOUNT_ID: "1234", CONF_SUPPLY_NODE_REF: "123"}, + title="123 Fake St", + unique_id="1234", + version=2, + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + } + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + return_value=_mock_flick_price(), + ), ): result = await _flow_submit(hass) @@ -109,3 +396,280 @@ async def test_form_generic_exception(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "unknown"} + + +async def test_form_select_account_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle connection errors for select account.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + { + "id": "5678", + "status": "active", + "address": "456 Fake St", + "main_consumer": {"supply_node_ref": "456"}, + }, + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + side_effect=APIException, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "select_account" + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"account_id": "5678"}, + ) + + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "select_account" + assert result3["errors"] == {"base": "cannot_connect"} + + +async def test_form_select_account_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle auth errors for select account.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + { + "id": "5678", + "status": "active", + "address": "456 Fake St", + "main_consumer": {"supply_node_ref": "456"}, + }, + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + side_effect=AuthException, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "select_account" + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + side_effect=AuthException, + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + side_effect=AuthException, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"account_id": "5678"}, + ) + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "no_permissions" + + +async def test_form_select_account_failed_to_connect(hass: HomeAssistant) -> None: + """Test we handle connection errors for select account.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + { + "id": "5678", + "status": "active", + "address": "456 Fake St", + "main_consumer": {"supply_node_ref": "456"}, + }, + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + side_effect=AuthException, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "select_account" + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + side_effect=APIException, + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + side_effect=APIException, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"account_id": "5678"}, + ) + + assert result3["type"] is FlowResultType.FORM + assert result3["errors"] == {"base": "cannot_connect"} + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + { + "id": "5678", + "status": "active", + "address": "456 Fake St", + "main_consumer": {"supply_node_ref": "456"}, + }, + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + return_value=_mock_flick_price(), + ), + patch( + "homeassistant.components.flick_electric.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {"account_id": "5678"}, + ) + + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["title"] == "456 Fake St" + assert result4["data"] == { + **CONF, + CONF_SUPPLY_NODE_REF: "456", + CONF_ACCOUNT_ID: "5678", + } + assert result4["result"].unique_id == "5678" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_select_account_no_accounts(hass: HomeAssistant) -> None: + """Test we handle connection errors for select account.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "closed", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + ], + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "no_accounts" diff --git a/tests/components/flick_electric/test_init.py b/tests/components/flick_electric/test_init.py new file mode 100644 index 00000000000..e022b6e03bc --- /dev/null +++ b/tests/components/flick_electric/test_init.py @@ -0,0 +1,135 @@ +"""Test the Flick Electric config flow.""" + +from unittest.mock import patch + +from pyflick.authentication import AuthException + +from homeassistant.components.flick_electric.const import CONF_ACCOUNT_ID, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from . import CONF, _mock_flick_price + +from tests.common import MockConfigEntry + + +async def test_init_auth_failure_triggers_auth(hass: HomeAssistant) -> None: + """Test reauth flow is triggered when username/password is wrong.""" + with ( + patch( + "homeassistant.components.flick_electric.HassFlickAuth.async_get_access_token", + side_effect=AuthException, + ), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={**CONF}, + title="123 Fake St", + unique_id="1234", + version=2, + ) + entry.add_to_hass(hass) + + # Ensure setup fails + assert not await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_ERROR + + # Ensure reauth flow is triggered + await hass.async_block_till_done() + assert len(hass.config_entries.flow.async_progress()) == 1 + + +async def test_init_migration_single_account(hass: HomeAssistant) -> None: + """Test migration with single account.""" + with ( + patch( + "homeassistant.components.flick_electric.HassFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + } + ], + ), + patch( + "homeassistant.components.flick_electric.FlickAPI.getPricing", + return_value=_mock_flick_price(), + ), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], + }, + title=CONF_USERNAME, + unique_id=CONF_USERNAME, + version=1, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert len(hass.config_entries.flow.async_progress()) == 0 + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 2 + assert entry.unique_id == CONF[CONF_ACCOUNT_ID] + assert entry.data == CONF + + +async def test_init_migration_multi_account_reauth(hass: HomeAssistant) -> None: + """Test migration triggers reauth with multiple accounts.""" + with ( + patch( + "homeassistant.components.flick_electric.HassFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + { + "id": "5678", + "status": "active", + "address": "456 Fake St", + "main_consumer": {"supply_node_ref": "456"}, + }, + ], + ), + patch( + "homeassistant.components.flick_electric.FlickAPI.getPricing", + return_value=_mock_flick_price(), + ), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], + }, + title=CONF_USERNAME, + unique_id=CONF_USERNAME, + version=1, + ) + entry.add_to_hass(hass) + + # ensure setup fails + assert not await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.MIGRATION_ERROR + await hass.async_block_till_done() + + # Ensure reauth flow is triggered + await hass.async_block_till_done() + assert len(hass.config_entries.flow.async_progress()) == 1 From e303a9a2b58a038e69212805ac9413e493f33711 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Mon, 30 Dec 2024 23:46:42 +0000 Subject: [PATCH 0064/2987] Add stream preview to options flow in generic camera (#133927) * Add stream preview to options flow * Increase test coverage * Code review: use correct flow handler type in cast * Restore test coverage to 100% * Remove error and test that can't be triggered yet --- .../components/generic/config_flow.py | 113 ++++++++++-------- homeassistant/components/generic/strings.json | 8 +- tests/components/generic/test_config_flow.py | 98 ++++++++++++--- 3 files changed, 148 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 83894b489f0..4b0717815c5 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -349,7 +349,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the start of the config flow.""" errors = {} - description_placeholders = {} hass = self.hass if user_input: # Secondary validation because serialised vol can't seem to handle this complexity: @@ -365,8 +364,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): ) except InvalidStreamException as err: errors[CONF_STREAM_SOURCE] = str(err) - if err.details: - errors["error_details"] = err.details self.preview_stream = None if not errors: user_input[CONF_CONTENT_TYPE] = still_format @@ -385,8 +382,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): # temporary preview for user to check the image self.preview_cam = user_input return await self.async_step_user_confirm() - if "error_details" in errors: - description_placeholders["error"] = errors.pop("error_details") elif self.user_input: user_input = self.user_input else: @@ -394,7 +389,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=build_schema(user_input), - description_placeholders=description_placeholders, errors=errors, ) @@ -412,7 +406,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): title=self.title, data={}, options=self.user_input ) register_preview(self.hass) - preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}" return self.async_show_form( step_id="user_confirm", data_schema=vol.Schema( @@ -420,7 +413,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): vol.Required(CONF_CONFIRMED_OK, default=False): bool, } ), - description_placeholders={"preview_url": preview_url}, errors=None, preview="generic_camera", ) @@ -437,6 +429,7 @@ class GenericOptionsFlowHandler(OptionsFlow): def __init__(self) -> None: """Initialize Generic IP Camera options flow.""" self.preview_cam: dict[str, Any] = {} + self.preview_stream: Stream | None = None self.user_input: dict[str, Any] = {} async def async_step_init( @@ -444,42 +437,45 @@ class GenericOptionsFlowHandler(OptionsFlow): ) -> ConfigFlowResult: """Manage Generic IP Camera options.""" errors: dict[str, str] = {} - description_placeholders = {} hass = self.hass - if user_input is not None: - errors, still_format = await async_test_still( - hass, self.config_entry.options | user_input - ) - try: - await async_test_and_preview_stream(hass, user_input) - except InvalidStreamException as err: - errors[CONF_STREAM_SOURCE] = str(err) - if err.details: - errors["error_details"] = err.details - # Stream preview during options flow not yet implemented - - still_url = user_input.get(CONF_STILL_IMAGE_URL) - if not errors: - if still_url is None: - # If user didn't specify a still image URL, - # The automatically generated still image that stream generates - # is always jpeg - still_format = "image/jpeg" - data = { - CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get( - CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False - ), - **user_input, - CONF_CONTENT_TYPE: still_format - or self.config_entry.options.get(CONF_CONTENT_TYPE), - } - self.user_input = data - # temporary preview for user to check the image - self.preview_cam = data - return await self.async_step_confirm_still() - if "error_details" in errors: - description_placeholders["error"] = errors.pop("error_details") + if user_input: + # Secondary validation because serialised vol can't seem to handle this complexity: + if not user_input.get(CONF_STILL_IMAGE_URL) and not user_input.get( + CONF_STREAM_SOURCE + ): + errors["base"] = "no_still_image_or_stream_url" + else: + errors, still_format = await async_test_still(hass, user_input) + try: + self.preview_stream = await async_test_and_preview_stream( + hass, user_input + ) + except InvalidStreamException as err: + errors[CONF_STREAM_SOURCE] = str(err) + self.preview_stream = None + if not errors: + user_input[CONF_CONTENT_TYPE] = still_format + still_url = user_input.get(CONF_STILL_IMAGE_URL) + if still_url is None: + # If user didn't specify a still image URL, + # The automatically generated still image that stream generates + # is always jpeg + still_format = "image/jpeg" + data = { + CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get( + CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False + ), + **user_input, + CONF_CONTENT_TYPE: still_format + or self.config_entry.options.get(CONF_CONTENT_TYPE), + } + self.user_input = data + # temporary preview for user to check the image + self.preview_cam = data + return await self.async_step_user_confirm() + elif self.user_input: + user_input = self.user_input return self.async_show_form( step_id="init", data_schema=build_schema( @@ -487,15 +483,17 @@ class GenericOptionsFlowHandler(OptionsFlow): True, self.show_advanced_options, ), - description_placeholders=description_placeholders, errors=errors, ) - async def async_step_confirm_still( + async def async_step_user_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle user clicking confirm after still preview.""" if user_input: + if ha_stream := self.preview_stream: + # Kill off the temp stream we created. + await ha_stream.stop() if not user_input.get(CONF_CONFIRMED_OK): return await self.async_step_init() return self.async_create_entry( @@ -503,18 +501,22 @@ class GenericOptionsFlowHandler(OptionsFlow): data=self.user_input, ) register_preview(self.hass) - preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}" return self.async_show_form( - step_id="confirm_still", + step_id="user_confirm", data_schema=vol.Schema( { vol.Required(CONF_CONFIRMED_OK, default=False): bool, } ), - description_placeholders={"preview_url": preview_url}, errors=None, + preview="generic_camera", ) + @staticmethod + async def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview WS API.""" + websocket_api.async_register_command(hass, ws_start_preview) + class CameraImagePreview(HomeAssistantView): """Camera view to temporarily serve an image.""" @@ -556,7 +558,7 @@ class CameraImagePreview(HomeAssistantView): { vol.Required("type"): "generic_camera/start_preview", vol.Required("flow_id"): str, - vol.Optional("flow_type"): vol.Any("config_flow"), + vol.Optional("flow_type"): vol.Any("config_flow", "options_flow"), vol.Optional("user_input"): dict, } ) @@ -570,10 +572,17 @@ async def ws_start_preview( _LOGGER.debug("Generating websocket handler for generic camera preview") flow_id = msg["flow_id"] - flow = cast( - GenericIPCamConfigFlow, - hass.config_entries.flow._progress.get(flow_id), # noqa: SLF001 - ) + flow: GenericIPCamConfigFlow | GenericOptionsFlowHandler + if msg.get("flow_type", "config_flow") == "config_flow": + flow = cast( + GenericIPCamConfigFlow, + hass.config_entries.flow._progress.get(flow_id), # noqa: SLF001 + ) + else: # (flow type == "options flow") + flow = cast( + GenericOptionsFlowHandler, + hass.config_entries.options._progress.get(flow_id), # noqa: SLF001 + ) user_input = flow.preview_cam # Create an EntityPlatform, needed for name translations diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index 45841e6255f..854ceb93b3e 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -67,11 +67,11 @@ "use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras" } }, - "confirm_still": { - "title": "Preview", - "description": "![Camera Still Image Preview]({preview_url})", + "user_confirm": { + "title": "Confirmation", + "description": "Please wait for previews to load...", "data": { - "confirmed_ok": "This image looks good." + "confirmed_ok": "Everything looks good." } } }, diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index f121b210c0c..4892496c486 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -93,12 +93,6 @@ async def test_form( ) assert result1["type"] is FlowResultType.FORM assert result1["step_id"] == "user_confirm" - client = await hass_client() - preview_url = result1["description_placeholders"]["preview_url"] - # Check the preview image works. - resp = await client.get(preview_url) - assert resp.status == HTTPStatus.OK - assert await resp.read() == fakeimgbytes_png # HA should now be serving a WS connection for a preview stream. ws_client = await hass_ws_client() @@ -109,7 +103,14 @@ async def test_form( "flow_id": flow_id, }, ) - _ = await ws_client.receive_json() + json = await ws_client.receive_json() + + client = await hass_client() + still_preview_url = json["event"]["attributes"]["still_url"] + # Check the preview image works. + resp = await client.get(still_preview_url) + assert resp.status == HTTPStatus.OK + assert await resp.read() == fakeimgbytes_png result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], @@ -129,7 +130,7 @@ async def test_form( } # Check that the preview image is disabled after. - resp = await client.get(preview_url) + resp = await client.get(still_preview_url) assert resp.status == HTTPStatus.NOT_FOUND assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -207,6 +208,7 @@ async def test_form_still_preview_cam_off( mock_create_stream: _patch[MagicMock], user_flow: ConfigFlowResult, hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, ) -> None: """Test camera errors are triggered during preview.""" with ( @@ -222,10 +224,23 @@ async def test_form_still_preview_cam_off( ) assert result1["type"] is FlowResultType.FORM assert result1["step_id"] == "user_confirm" - preview_url = result1["description_placeholders"]["preview_url"] + + # HA should now be serving a WS connection for a preview stream. + ws_client = await hass_ws_client() + flow_id = user_flow["flow_id"] + await ws_client.send_json_auto_id( + { + "type": "generic_camera/start_preview", + "flow_id": flow_id, + }, + ) + json = await ws_client.receive_json() + + client = await hass_client() + still_preview_url = json["event"]["attributes"]["still_url"] # Try to view the image, should be unavailable. client = await hass_client() - resp = await client.get(preview_url) + resp = await client.get(still_preview_url) assert resp.status == HTTPStatus.SERVICE_UNAVAILABLE @@ -706,7 +721,7 @@ async def test_form_no_route_to_host( async def test_form_stream_io_error( hass: HomeAssistant, user_flow: ConfigFlowResult ) -> None: - """Test we handle no io error when setting up stream.""" + """Test we handle an io error when setting up stream.""" with patch( "homeassistant.components.generic.config_flow.create_stream", side_effect=OSError(errno.EIO, "Input/output error"), @@ -799,7 +814,7 @@ async def test_options_template_error( user_input=data, ) assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "confirm_still" + assert result2["step_id"] == "user_confirm" result2a = await hass.config_entries.options.async_configure( result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} @@ -894,7 +909,7 @@ async def test_options_only_stream( user_input=data, ) assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "confirm_still" + assert result2["step_id"] == "user_confirm" result3 = await hass.config_entries.options.async_configure( result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} @@ -903,6 +918,35 @@ async def test_options_only_stream( assert result3["data"][CONF_CONTENT_TYPE] == "image/jpeg" +async def test_options_still_and_stream_not_provided( + hass: HomeAssistant, +) -> None: + """Test we show a suitable error if neither still or stream URL are provided.""" + data = TESTDATA.copy() + + mock_entry = MockConfigEntry( + title="Test Camera", + domain=DOMAIN, + data={}, + options=data, + ) + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + data.pop(CONF_STILL_IMAGE_URL) + data.pop(CONF_STREAM_SOURCE) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=data, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "no_still_image_or_stream_url"} + + @respx.mock @pytest.mark.usefixtures("fakeimg_png") async def test_form_options_stream_worker_error( @@ -997,10 +1041,15 @@ async def test_migrate_existing_ids( @respx.mock @pytest.mark.usefixtures("fakeimg_png") async def test_use_wallclock_as_timestamps_option( - hass: HomeAssistant, mock_create_stream: _patch[MagicMock] + hass: HomeAssistant, + mock_create_stream: _patch[MagicMock], + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + fakeimgbytes_png: bytes, ) -> None: """Test the use_wallclock_as_timestamps option flow.""" + respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) mock_entry = MockConfigEntry( title="Test Camera", domain=DOMAIN, @@ -1026,6 +1075,25 @@ async def test_use_wallclock_as_timestamps_option( user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, ) assert result2["type"] is FlowResultType.FORM + + ws_client = await hass_ws_client() + flow_id = result2["flow_id"] + await ws_client.send_json_auto_id( + { + "type": "generic_camera/start_preview", + "flow_id": flow_id, + "flow_type": "options_flow", + }, + ) + json = await ws_client.receive_json() + + client = await hass_client() + still_preview_url = json["event"]["attributes"]["still_url"] + # Check the preview image works. + resp = await client.get(still_preview_url) + assert resp.status == HTTPStatus.OK + assert await resp.read() == fakeimgbytes_png + # Test what happens if user rejects the preview result3 = await hass.config_entries.options.async_configure( result2["flow_id"], user_input={CONF_CONFIRMED_OK: False} @@ -1041,7 +1109,7 @@ async def test_use_wallclock_as_timestamps_option( user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, ) assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "confirm_still" + assert result4["step_id"] == "user_confirm" result5 = await hass.config_entries.options.async_configure( result4["flow_id"], user_input={CONF_CONFIRMED_OK: True}, From 229c32b0daaee63a397a239062ba40fe99fc46e2 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 29 Dec 2024 11:30:52 -0500 Subject: [PATCH 0065/2987] Bump aiocomelit to 0.10.1 (#134214) --- homeassistant/components/comelit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index d7417ad4aad..238dede8546 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.9.1"] + "requirements": ["aiocomelit==0.10.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 438690ac560..6232a47865f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -213,7 +213,7 @@ aiobafi6==0.9.0 aiobotocore==2.13.1 # homeassistant.components.comelit -aiocomelit==0.9.1 +aiocomelit==0.10.1 # homeassistant.components.dhcp aiodhcpwatcher==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ebf6ac82782..72ddec608a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -201,7 +201,7 @@ aiobafi6==0.9.0 aiobotocore==2.13.1 # homeassistant.components.comelit -aiocomelit==0.9.1 +aiocomelit==0.10.1 # homeassistant.components.dhcp aiodhcpwatcher==1.0.2 From c908f823c51fcb48ccf65b5e11823ec525f6c755 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 30 Dec 2024 16:21:18 +1000 Subject: [PATCH 0066/2987] Handle missing application credentials in Tesla Fleet (#134237) * Handle missing application credentials * Add tests * Test reauth starts * Only catch ValueError --- .../components/tesla_fleet/__init__.py | 10 +++++++++- tests/components/tesla_fleet/conftest.py | 12 ++++++++++-- tests/components/tesla_fleet/test_init.py | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index bc837aa4cac..ff50a99748e 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -64,6 +64,15 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -> bool: """Set up TeslaFleet config.""" + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ValueError as e: + # Remove invalid implementation from config entry then raise AuthFailed + hass.config_entries.async_update_entry( + entry, data={"auth_implementation": None} + ) + raise ConfigEntryAuthFailed from e + access_token = entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] session = async_get_clientsession(hass) @@ -71,7 +80,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - scopes: list[Scope] = [Scope(s) for s in token["scp"]] region: str = token["ou_code"].lower() - implementation = await async_get_config_entry_implementation(hass, entry) oauth_session = OAuth2Session(hass, entry, implementation) refresh_lock = asyncio.Lock() diff --git a/tests/components/tesla_fleet/conftest.py b/tests/components/tesla_fleet/conftest.py index 0dc5d87984f..2396e2a88f3 100644 --- a/tests/components/tesla_fleet/conftest.py +++ b/tests/components/tesla_fleet/conftest.py @@ -33,7 +33,9 @@ def mock_expires_at() -> int: return time.time() + 3600 -def create_config_entry(expires_at: int, scopes: list[Scope]) -> MockConfigEntry: +def create_config_entry( + expires_at: int, scopes: list[Scope], implementation: str = DOMAIN +) -> MockConfigEntry: """Create Tesla Fleet entry in Home Assistant.""" access_token = jwt.encode( { @@ -51,7 +53,7 @@ def create_config_entry(expires_at: int, scopes: list[Scope]) -> MockConfigEntry title=UID, unique_id=UID, data={ - "auth_implementation": DOMAIN, + "auth_implementation": implementation, "token": { "status": 0, "userid": UID, @@ -90,6 +92,12 @@ def readonly_config_entry(expires_at: int) -> MockConfigEntry: ) +@pytest.fixture +def bad_config_entry(expires_at: int) -> MockConfigEntry: + """Create Tesla Fleet entry in Home Assistant.""" + return create_config_entry(expires_at, SCOPES, "bad") + + @pytest.fixture(autouse=True) def mock_products() -> Generator[AsyncMock]: """Mock Tesla Fleet Api products method.""" diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index 7c17f986663..7e97096e4e8 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -30,6 +30,7 @@ from homeassistant.components.tesla_fleet.coordinator import ( from homeassistant.components.tesla_fleet.models import TeslaFleetData from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr from . import setup_platform @@ -424,3 +425,20 @@ async def test_signing( ) as mock_get_private_key: await setup_platform(hass, normal_config_entry) mock_get_private_key.assert_called_once() + + +async def test_bad_implementation( + hass: HomeAssistant, + bad_config_entry: MockConfigEntry, +) -> None: + """Test handling of a bad authentication implementation.""" + + await setup_platform(hass, bad_config_entry) + assert bad_config_entry.state is ConfigEntryState.SETUP_ERROR + + # Ensure reauth flow starts + assert any(bad_config_entry.async_get_active_flows(hass, {"reauth"})) + result = await bad_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert not result["errors"] From b89995a79fecb4dcc13d3db1cd5ff7584fd0b69e Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 31 Dec 2024 12:52:29 -0800 Subject: [PATCH 0067/2987] Allow automations to pass any conversation_id for Google Generative AI (#134251) --- .../google_generative_ai_conversation/conversation.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 0d24ddbf39f..dad9c8a1920 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -204,9 +204,7 @@ class GoogleGenerativeAIConversationEntity( """Process a sentence.""" result = conversation.ConversationResult( response=intent.IntentResponse(language=user_input.language), - conversation_id=user_input.conversation_id - if user_input.conversation_id in self.history - else ulid.ulid_now(), + conversation_id=user_input.conversation_id or ulid.ulid_now(), ) assert result.conversation_id From a36fd0964453ebf24bd3d189663f24bda19e3d17 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 31 Dec 2024 15:01:06 +0100 Subject: [PATCH 0068/2987] Set backup manager state to completed when restore is finished (#134283) --- homeassistant/components/backup/manager.py | 3 +++ tests/components/hassio/test_backup.py | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 9b20c82d709..9515ab89cd2 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -829,6 +829,9 @@ class BackupManager: restore_folders=restore_folders, restore_homeassistant=restore_homeassistant, ) + self.async_on_backup_event( + RestoreBackupEvent(stage=None, state=RestoreBackupState.COMPLETED) + ) except Exception: self.async_on_backup_event( RestoreBackupEvent(stage=None, state=RestoreBackupState.FAILED) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index c39574fd941..3c9440c41ff 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -942,7 +942,9 @@ async def test_reader_writer_restore( await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() - assert response["event"] == {"manager_state": "idle"} + assert response["event"] == { + "manager_state": "idle", + } response = await client.receive_json() assert response["success"] @@ -980,6 +982,13 @@ async def test_reader_writer_restore( response = await client.receive_json() assert response["success"] + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "stage": None, + "state": "completed", + } + response = await client.receive_json() assert response["event"] == {"manager_state": "idle"} From c2f06fbd4775568fc92fa79bcd120786fb27f238 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 31 Dec 2024 10:31:40 +0100 Subject: [PATCH 0069/2987] Bump reolink-aio to 0.11.6 (#134286) --- homeassistant/components/reolink/camera.py | 2 +- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/media_source.py | 2 ++ homeassistant/components/reolink/strings.json | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/reolink/test_media_source.py | 8 +++++++- 7 files changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index d9b3cb67f70..a597be3ec7a 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -100,7 +100,7 @@ async def async_setup_entry( if not entity_description.supported(reolink_data.host.api, channel): continue stream_url = await reolink_data.host.api.get_stream_source( - channel, entity_description.stream + channel, entity_description.stream, False ) if stream_url is None and "snapshots" not in entity_description.stream: continue diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index e5e8afc1d63..7d01ca808e1 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.11.5"] + "requirements": ["reolink-aio==0.11.6"] } diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 0c23bed7e2f..538a06a08f8 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -81,6 +81,8 @@ class ReolinkVODMediaSource(MediaSource): def get_vod_type() -> VodRequestType: if filename.endswith(".mp4"): + if host.api.is_nvr: + return VodRequestType.DOWNLOAD return VodRequestType.PLAYBACK if host.api.is_nvr: return VodRequestType.FLV diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 283c1d42e89..50163fa1aca 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -567,6 +567,7 @@ "stayoff": "Stay off", "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]", "alwaysonatnight": "Auto & always on at night", + "always": "Always on", "alwayson": "Always on" } }, diff --git a/requirements_all.txt b/requirements_all.txt index 6232a47865f..209c4740202 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2572,7 +2572,7 @@ renault-api==0.2.8 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.5 +reolink-aio==0.11.6 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 72ddec608a4..b714bed884d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2072,7 +2072,7 @@ renault-api==0.2.8 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.5 +reolink-aio==0.11.6 # homeassistant.components.rflink rflink==0.0.66 diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 32afd1f73ca..9c5be08e9b6 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -109,11 +109,17 @@ async def test_resolve( ) assert play_media.mime_type == TEST_MIME_TYPE_MP4 + reolink_connect.is_nvr = False + + play_media = await async_resolve_media( + hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None + ) + assert play_media.mime_type == TEST_MIME_TYPE_MP4 + file_id = ( f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}" ) reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE, TEST_URL) - reolink_connect.is_nvr = False play_media = await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None From 1064ef9dc61be0c4369a2872b1127833674554fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20M=C3=BCndler?= Date: Tue, 31 Dec 2024 23:03:35 +0100 Subject: [PATCH 0070/2987] Bump pysynthru version to 0.8.0 (#134294) --- homeassistant/components/syncthru/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/syncthru/manifest.json b/homeassistant/components/syncthru/manifest.json index a93e02a51c7..461ce9bfd3a 100644 --- a/homeassistant/components/syncthru/manifest.json +++ b/homeassistant/components/syncthru/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/syncthru", "iot_class": "local_polling", "loggers": ["pysyncthru"], - "requirements": ["PySyncThru==0.7.10", "url-normalize==1.4.3"], + "requirements": ["PySyncThru==0.8.0", "url-normalize==1.4.3"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:Printer:1", diff --git a/requirements_all.txt b/requirements_all.txt index 209c4740202..65a6986b9ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -90,7 +90,7 @@ PySwitchbot==0.55.4 PySwitchmate==0.5.1 # homeassistant.components.syncthru -PySyncThru==0.7.10 +PySyncThru==0.8.0 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b714bed884d..e09e2c51379 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -84,7 +84,7 @@ PyRMVtransport==0.3.3 PySwitchbot==0.55.4 # homeassistant.components.syncthru -PySyncThru==0.7.10 +PySyncThru==0.8.0 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 From a7995e00938c799df83bcdadf4a84df09dfd5be6 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 31 Dec 2024 11:16:12 -0500 Subject: [PATCH 0071/2987] Bump aioshelly to 12.2.0 (#134352) --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 3489a2d06d9..29c8fd4c369 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==12.1.0"], + "requirements": ["aioshelly==12.2.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 65a6986b9ef..bfd0d8320e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -365,7 +365,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.1.0 +aioshelly==12.2.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e09e2c51379..0ea7592e3b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -347,7 +347,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.1.0 +aioshelly==12.2.0 # homeassistant.components.skybell aioskybell==22.7.0 From 952363eca30493121eb43daef71b70705175a50f Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 31 Dec 2024 14:52:15 -0600 Subject: [PATCH 0072/2987] Bump hassil to 2.1.0 (#134359) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index a2ddd5f734c..4017ed82be1 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.0.5", "home-assistant-intents==2024.12.20"] + "requirements": ["hassil==2.1.0", "home-assistant-intents==2024.12.20"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d1ccc31a0ed..46cd4485188 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.6.0 hass-nabucasa==0.87.0 -hassil==2.0.5 +hassil==2.1.0 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20241230.0 home-assistant-intents==2024.12.20 diff --git a/requirements_all.txt b/requirements_all.txt index bfd0d8320e8..9c93955e03a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ hass-nabucasa==0.87.0 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==2.0.5 +hassil==2.1.0 # homeassistant.components.jewish_calendar hdate==0.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ea7592e3b1..827eb5d3713 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -938,7 +938,7 @@ habluetooth==3.6.0 hass-nabucasa==0.87.0 # homeassistant.components.conversation -hassil==2.0.5 +hassil==2.1.0 # homeassistant.components.jewish_calendar hdate==0.11.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index bd2c9d328ac..52948484ed8 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.8,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.8.3 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.0.5 home-assistant-intents==2024.12.20 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.1.0 home-assistant-intents==2024.12.20 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From f709989717e4062ff19e9c313776fd715773fc37 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 31 Dec 2024 13:04:41 -0600 Subject: [PATCH 0073/2987] Revert speech seconds to 0.3 (#134360) --- homeassistant/components/assist_pipeline/vad.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py index c7fe1bc10c7..d4647fafe2a 100644 --- a/homeassistant/components/assist_pipeline/vad.py +++ b/homeassistant/components/assist_pipeline/vad.py @@ -75,7 +75,7 @@ class AudioBuffer: class VoiceCommandSegmenter: """Segments an audio stream into voice commands.""" - speech_seconds: float = 0.1 + speech_seconds: float = 0.3 """Seconds of speech before voice command has started.""" command_seconds: float = 1.0 From 0ae4a9a9111590ba187f46215ebbacc1410a77c0 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 31 Dec 2024 23:04:28 +0100 Subject: [PATCH 0074/2987] Update frontend to 20241231.0 (#134363) --- 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 01fe363d69e..d1bb15b5d3b 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241230.0"] + "requirements": ["home-assistant-frontend==20241231.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 46cd4485188..c97dbe11d29 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ habluetooth==3.6.0 hass-nabucasa==0.87.0 hassil==2.1.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241230.0 +home-assistant-frontend==20241231.0 home-assistant-intents==2024.12.20 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 9c93955e03a..b8ec2a85be8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1134,7 +1134,7 @@ hole==0.8.0 holidays==0.63 # homeassistant.components.frontend -home-assistant-frontend==20241230.0 +home-assistant-frontend==20241231.0 # homeassistant.components.conversation home-assistant-intents==2024.12.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 827eb5d3713..f9019326d89 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -963,7 +963,7 @@ hole==0.8.0 holidays==0.63 # homeassistant.components.frontend -home-assistant-frontend==20241230.0 +home-assistant-frontend==20241231.0 # homeassistant.components.conversation home-assistant-intents==2024.12.20 From ab6394b26ca82c524fa0eb1c75947e3d68b4c72c Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 31 Dec 2024 22:49:29 +0100 Subject: [PATCH 0075/2987] Bump pylamarzocco to 1.4.6 (#134367) --- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 6b586a5cfb8..afd367b0f6e 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_polling", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==1.4.5"] + "requirements": ["pylamarzocco==1.4.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index b8ec2a85be8..e57074933c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2043,7 +2043,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==1.4.5 +pylamarzocco==1.4.6 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f9019326d89..223502ece25 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1657,7 +1657,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.2 # homeassistant.components.lamarzocco -pylamarzocco==1.4.5 +pylamarzocco==1.4.6 # homeassistant.components.lastfm pylast==5.1.0 From 2e21ac700111fb44eb88081dbc5c7a61ea584787 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 31 Dec 2024 22:10:20 +0000 Subject: [PATCH 0076/2987] Bump version to 2025.1.0b5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e45608ce9bb..d44095629f0 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 6219a7cee8d..a461427b070 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.1.0b4" +version = "2025.1.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From bd5477729a6d067d447921ffc8383bf2ebd405a2 Mon Sep 17 00:00:00 2001 From: Craig Andrews Date: Thu, 2 Jan 2025 11:21:49 -0500 Subject: [PATCH 0077/2987] Improve is docker env checks (#132404) Co-authored-by: Franck Nijhof Co-authored-by: Sander Hoentjen Co-authored-by: Paulus Schoutsen Co-authored-by: Robert Resch --- homeassistant/bootstrap.py | 3 +- homeassistant/components/ffmpeg/__init__.py | 2 +- homeassistant/helpers/system_info.py | 8 +--- homeassistant/util/package.py | 11 +++++- homeassistant/util/system_info.py | 12 ++++++ tests/helpers/test_system_info.py | 12 +----- tests/util/test_package.py | 44 +++++++++++++++++++++ tests/util/test_system_info.py | 15 +++++++ 8 files changed, 85 insertions(+), 22 deletions(-) create mode 100644 homeassistant/util/system_info.py create mode 100644 tests/util/test_system_info.py diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 78c7d91fae0..f1f1835863b 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -89,7 +89,7 @@ from .helpers import ( ) from .helpers.dispatcher import async_dispatcher_send_internal from .helpers.storage import get_internal_store_manager -from .helpers.system_info import async_get_system_info, is_official_image +from .helpers.system_info import async_get_system_info from .helpers.typing import ConfigType from .setup import ( # _setup_started is marked as protected to make it clear @@ -106,6 +106,7 @@ from .util.async_ import create_eager_task from .util.hass_dict import HassKey from .util.logging import async_activate_log_queue_handler from .util.package import async_get_user_site, is_docker_env, is_virtual_env +from .util.system_info import is_official_image with contextlib.suppress(ImportError): # Ensure anyio backend is imported to avoid it being imported in the event loop diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index 9a88317027e..99803e9636c 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -23,10 +23,10 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import Entity -from homeassistant.helpers.system_info import is_official_image from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.signal_type import SignalType +from homeassistant.util.system_info import is_official_image DOMAIN = "ffmpeg" diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py index 53866428332..df9679dcb08 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -5,7 +5,6 @@ from __future__ import annotations from functools import cache from getpass import getuser import logging -import os import platform from typing import TYPE_CHECKING, Any @@ -13,6 +12,7 @@ from homeassistant.const import __version__ as current_version from homeassistant.core import HomeAssistant from homeassistant.loader import bind_hass from homeassistant.util.package import is_docker_env, is_virtual_env +from homeassistant.util.system_info import is_official_image from .hassio import is_hassio from .importlib import async_import_module @@ -23,12 +23,6 @@ _LOGGER = logging.getLogger(__name__) _DATA_MAC_VER = "system_info_mac_ver" -@cache -def is_official_image() -> bool: - """Return True if Home Assistant is running in an official container.""" - return os.path.isfile("/OFFICIAL_IMAGE") - - @singleton(_DATA_MAC_VER) async def async_get_mac_ver(hass: HomeAssistant) -> str: """Return the macOS version.""" diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index da0666290a1..9720bbd4ca3 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -15,6 +15,8 @@ from urllib.parse import urlparse from packaging.requirements import InvalidRequirement, Requirement +from .system_info import is_official_image + _LOGGER = logging.getLogger(__name__) @@ -28,8 +30,13 @@ def is_virtual_env() -> bool: @cache def is_docker_env() -> bool: - """Return True if we run in a docker env.""" - return Path("/.dockerenv").exists() + """Return True if we run in a container env.""" + return ( + Path("/.dockerenv").exists() + or Path("/run/.containerenv").exists() + or "KUBERNETES_SERVICE_HOST" in os.environ + or is_official_image() + ) def get_installed_versions(specifiers: set[str]) -> set[str]: diff --git a/homeassistant/util/system_info.py b/homeassistant/util/system_info.py new file mode 100644 index 00000000000..80621bd16a5 --- /dev/null +++ b/homeassistant/util/system_info.py @@ -0,0 +1,12 @@ +"""Util to gather system info.""" + +from __future__ import annotations + +from functools import cache +import os + + +@cache +def is_official_image() -> bool: + """Return True if Home Assistant is running in an official container.""" + return os.path.isfile("/OFFICIAL_IMAGE") diff --git a/tests/helpers/test_system_info.py b/tests/helpers/test_system_info.py index 2c4b95302fc..ad140834199 100644 --- a/tests/helpers/test_system_info.py +++ b/tests/helpers/test_system_info.py @@ -9,17 +9,7 @@ import pytest from homeassistant.components import hassio from homeassistant.const import __version__ as current_version from homeassistant.core import HomeAssistant -from homeassistant.helpers.system_info import async_get_system_info, is_official_image - - -async def test_is_official_image() -> None: - """Test is_official_image.""" - is_official_image.cache_clear() - with patch("homeassistant.helpers.system_info.os.path.isfile", return_value=True): - assert is_official_image() is True - is_official_image.cache_clear() - with patch("homeassistant.helpers.system_info.os.path.isfile", return_value=False): - assert is_official_image() is False +from homeassistant.helpers.system_info import async_get_system_info async def test_get_system_info(hass: HomeAssistant) -> None: diff --git a/tests/util/test_package.py b/tests/util/test_package.py index b7497d620cd..e3635dd2bea 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -410,3 +410,47 @@ def test_check_package_previous_failed_install() -> None: with patch("homeassistant.util.package.version", return_value=None): assert not package.is_installed(installed_package) assert not package.is_installed(f"{installed_package}=={installed_version}") + + +@pytest.mark.parametrize("dockerenv", [True, False], ids=["dockerenv", "not_dockerenv"]) +@pytest.mark.parametrize( + "containerenv", [True, False], ids=["containerenv", "not_containerenv"] +) +@pytest.mark.parametrize( + "kubernetes_service_host", [True, False], ids=["kubernetes", "not_kubernetes"] +) +@pytest.mark.parametrize( + "is_official_image", [True, False], ids=["official_image", "not_official_image"] +) +async def test_is_docker_env( + dockerenv: bool, + containerenv: bool, + kubernetes_service_host: bool, + is_official_image: bool, +) -> None: + """Test is_docker_env.""" + + def new_path_mock(path: str): + mock = Mock() + if path == "/.dockerenv": + mock.exists.return_value = dockerenv + elif path == "/run/.containerenv": + mock.exists.return_value = containerenv + return mock + + env = {} + if kubernetes_service_host: + env["KUBERNETES_SERVICE_HOST"] = "True" + + package.is_docker_env.cache_clear() + with ( + patch("homeassistant.util.package.Path", side_effect=new_path_mock), + patch( + "homeassistant.util.package.is_official_image", + return_value=is_official_image, + ), + patch.dict(os.environ, env), + ): + assert package.is_docker_env() is any( + [dockerenv, containerenv, kubernetes_service_host, is_official_image] + ) diff --git a/tests/util/test_system_info.py b/tests/util/test_system_info.py new file mode 100644 index 00000000000..270e91d37db --- /dev/null +++ b/tests/util/test_system_info.py @@ -0,0 +1,15 @@ +"""Tests for the system info helper.""" + +from unittest.mock import patch + +from homeassistant.util.system_info import is_official_image + + +async def test_is_official_image() -> None: + """Test is_official_image.""" + is_official_image.cache_clear() + with patch("homeassistant.util.system_info.os.path.isfile", return_value=True): + assert is_official_image() is True + is_official_image.cache_clear() + with patch("homeassistant.util.system_info.os.path.isfile", return_value=False): + assert is_official_image() is False From 5895aa4cdea06336b96f093bbe626436a3fad93d Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 2 Jan 2025 15:45:46 +0100 Subject: [PATCH 0078/2987] Handle backup errors more consistently (#133522) * Add backup manager and read writer errors * Clean up not needed default argument * Clean up todo comment * Trap agent bugs during upload * Always release stream * Clean up leftover * Update test for backup with automatic settings * Fix use of vol.Any * Refactor test helper * Only update successful timestamp if completed event is sent * Always delete surplus copies * Fix after rebase * Fix after rebase * Revert "Fix use of vol.Any" This reverts commit 28fd7a544899bb6ed05f771e9e608bc5b41d2b5e. * Inherit BackupReaderWriterError in IncorrectPasswordError --------- Co-authored-by: Erik Montnemery --- homeassistant/components/backup/__init__.py | 2 + homeassistant/components/backup/config.py | 6 +- homeassistant/components/backup/manager.py | 217 +++++--- homeassistant/components/backup/models.py | 6 + homeassistant/components/hassio/backup.py | 62 ++- tests/components/backup/common.py | 12 + tests/components/backup/test_manager.py | 546 +++++++++++++++++--- tests/components/backup/test_websocket.py | 166 +++++- tests/components/cloud/test_backup.py | 40 +- tests/components/hassio/test_backup.py | 295 ++++++++++- 10 files changed, 1152 insertions(+), 200 deletions(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index ab324a44e3b..7d9979ce9a2 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -21,6 +21,7 @@ from .manager import ( BackupManager, BackupPlatformProtocol, BackupReaderWriter, + BackupReaderWriterError, CoreBackupReaderWriter, CreateBackupEvent, ManagerBackup, @@ -39,6 +40,7 @@ __all__ = [ "BackupAgentPlatformProtocol", "BackupPlatformProtocol", "BackupReaderWriter", + "BackupReaderWriterError", "CreateBackupEvent", "Folder", "LocalBackupAgent", diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index cdecf55848f..d58c7365c8a 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -17,7 +17,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util from .const import LOGGER -from .models import Folder +from .models import BackupManagerError, Folder if TYPE_CHECKING: from .manager import BackupManager, ManagerBackup @@ -318,9 +318,9 @@ class BackupSchedule: password=config_data.create_backup.password, with_automatic_settings=True, ) + except BackupManagerError as err: + LOGGER.error("Error creating backup: %s", err) except Exception: # noqa: BLE001 - # another more specific exception will be added - # and handled in the future LOGGER.exception("Unexpected error creating automatic backup") manager.remove_next_backup_event = async_track_point_in_time( diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 9515ab89cd2..8421448f619 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -46,15 +46,11 @@ from .const import ( EXCLUDE_FROM_BACKUP, LOGGER, ) -from .models import AgentBackup, Folder +from .models import AgentBackup, BackupManagerError, Folder from .store import BackupStore from .util import make_backup_dir, read_backup, validate_password -class IncorrectPasswordError(HomeAssistantError): - """Raised when the password is incorrect.""" - - @dataclass(frozen=True, kw_only=True, slots=True) class NewBackup: """New backup class.""" @@ -245,6 +241,14 @@ class BackupReaderWriter(abc.ABC): """Restore a backup.""" +class BackupReaderWriterError(HomeAssistantError): + """Backup reader/writer error.""" + + +class IncorrectPasswordError(BackupReaderWriterError): + """Raised when the password is incorrect.""" + + class BackupManager: """Define the format that backup managers can have.""" @@ -373,7 +377,9 @@ class BackupManager: ) for result in pre_backup_results: if isinstance(result, Exception): - raise result + raise BackupManagerError( + f"Error during pre-backup: {result}" + ) from result async def async_post_backup_actions(self) -> None: """Perform post backup actions.""" @@ -386,7 +392,9 @@ class BackupManager: ) for result in post_backup_results: if isinstance(result, Exception): - raise result + raise BackupManagerError( + f"Error during post-backup: {result}" + ) from result async def load_platforms(self) -> None: """Load backup platforms.""" @@ -422,11 +430,21 @@ class BackupManager: return_exceptions=True, ) for idx, result in enumerate(sync_backup_results): - if isinstance(result, Exception): + if isinstance(result, BackupReaderWriterError): + # writer errors will affect all agents + # no point in continuing + raise BackupManagerError(str(result)) from result + if isinstance(result, BackupAgentError): agent_errors[agent_ids[idx]] = result - LOGGER.exception( - "Error during backup upload - %s", result, exc_info=result - ) + continue + if isinstance(result, Exception): + # trap bugs from agents + agent_errors[agent_ids[idx]] = result + LOGGER.error("Unexpected error: %s", result, exc_info=result) + continue + if isinstance(result, BaseException): + raise result + return agent_errors async def async_get_backups( @@ -449,7 +467,7 @@ class BackupManager: agent_errors[agent_ids[idx]] = result continue if isinstance(result, BaseException): - raise result + raise result # unexpected error for agent_backup in result: if (backup_id := agent_backup.backup_id) not in backups: if known_backup := self.known_backups.get(backup_id): @@ -499,7 +517,7 @@ class BackupManager: agent_errors[agent_ids[idx]] = result continue if isinstance(result, BaseException): - raise result + raise result # unexpected error if not result: continue if backup is None: @@ -563,7 +581,7 @@ class BackupManager: agent_errors[agent_ids[idx]] = result continue if isinstance(result, BaseException): - raise result + raise result # unexpected error if not agent_errors: self.known_backups.remove(backup_id) @@ -578,7 +596,7 @@ class BackupManager: ) -> None: """Receive and store a backup file from upload.""" if self.state is not BackupManagerState.IDLE: - raise HomeAssistantError(f"Backup manager busy: {self.state}") + raise BackupManagerError(f"Backup manager busy: {self.state}") self.async_on_backup_event( ReceiveBackupEvent(stage=None, state=ReceiveBackupState.IN_PROGRESS) ) @@ -652,6 +670,7 @@ class BackupManager: include_homeassistant=include_homeassistant, name=name, password=password, + raise_task_error=True, with_automatic_settings=with_automatic_settings, ) assert self._backup_finish_task @@ -669,11 +688,12 @@ class BackupManager: include_homeassistant: bool, name: str | None, password: str | None, + raise_task_error: bool = False, with_automatic_settings: bool = False, ) -> NewBackup: """Initiate generating a backup.""" if self.state is not BackupManagerState.IDLE: - raise HomeAssistantError(f"Backup manager busy: {self.state}") + raise BackupManagerError(f"Backup manager busy: {self.state}") if with_automatic_settings: self.config.data.last_attempted_automatic_backup = dt_util.now() @@ -692,6 +712,7 @@ class BackupManager: include_homeassistant=include_homeassistant, name=name, password=password, + raise_task_error=raise_task_error, with_automatic_settings=with_automatic_settings, ) except Exception: @@ -714,15 +735,18 @@ class BackupManager: include_homeassistant: bool, name: str | None, password: str | None, + raise_task_error: bool, with_automatic_settings: bool, ) -> NewBackup: """Initiate generating a backup.""" if not agent_ids: - raise HomeAssistantError("At least one agent must be selected") - if any(agent_id not in self.backup_agents for agent_id in agent_ids): - raise HomeAssistantError("Invalid agent selected") + raise BackupManagerError("At least one agent must be selected") + if invalid_agents := [ + agent_id for agent_id in agent_ids if agent_id not in self.backup_agents + ]: + raise BackupManagerError(f"Invalid agents selected: {invalid_agents}") if include_all_addons and include_addons: - raise HomeAssistantError( + raise BackupManagerError( "Cannot include all addons and specify specific addons" ) @@ -730,41 +754,64 @@ class BackupManager: name or f"{"Automatic" if with_automatic_settings else "Custom"} {HAVERSION}" ) - new_backup, self._backup_task = await self._reader_writer.async_create_backup( - agent_ids=agent_ids, - backup_name=backup_name, - extra_metadata={ - "instance_id": await instance_id.async_get(self.hass), - "with_automatic_settings": with_automatic_settings, - }, - include_addons=include_addons, - include_all_addons=include_all_addons, - include_database=include_database, - include_folders=include_folders, - include_homeassistant=include_homeassistant, - on_progress=self.async_on_backup_event, - password=password, - ) - self._backup_finish_task = self.hass.async_create_task( + + try: + ( + new_backup, + self._backup_task, + ) = await self._reader_writer.async_create_backup( + agent_ids=agent_ids, + backup_name=backup_name, + extra_metadata={ + "instance_id": await instance_id.async_get(self.hass), + "with_automatic_settings": with_automatic_settings, + }, + include_addons=include_addons, + include_all_addons=include_all_addons, + include_database=include_database, + include_folders=include_folders, + include_homeassistant=include_homeassistant, + on_progress=self.async_on_backup_event, + password=password, + ) + except BackupReaderWriterError as err: + raise BackupManagerError(str(err)) from err + + backup_finish_task = self._backup_finish_task = self.hass.async_create_task( self._async_finish_backup(agent_ids, with_automatic_settings), name="backup_manager_finish_backup", ) + if not raise_task_error: + + def log_finish_task_error(task: asyncio.Task[None]) -> None: + if task.done() and not task.cancelled() and (err := task.exception()): + if isinstance(err, BackupManagerError): + LOGGER.error("Error creating backup: %s", err) + else: + LOGGER.error("Unexpected error: %s", err, exc_info=err) + + backup_finish_task.add_done_callback(log_finish_task_error) + return new_backup async def _async_finish_backup( self, agent_ids: list[str], with_automatic_settings: bool ) -> None: + """Finish a backup.""" if TYPE_CHECKING: assert self._backup_task is not None try: written_backup = await self._backup_task - except Exception as err: # noqa: BLE001 - LOGGER.debug("Generating backup failed", exc_info=err) + except Exception as err: self.async_on_backup_event( CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) ) if with_automatic_settings: self._update_issue_backup_failed() + + if isinstance(err, BackupReaderWriterError): + raise BackupManagerError(str(err)) from err + raise # unexpected error else: LOGGER.debug( "Generated new backup with backup_id %s, uploading to agents %s", @@ -777,25 +824,47 @@ class BackupManager: state=CreateBackupState.IN_PROGRESS, ) ) - agent_errors = await self._async_upload_backup( - backup=written_backup.backup, - agent_ids=agent_ids, - open_stream=written_backup.open_stream, - ) - await written_backup.release_stream() - if with_automatic_settings: - # create backup was successful, update last_completed_automatic_backup - self.config.data.last_completed_automatic_backup = dt_util.now() - self.store.save() - self._update_issue_after_agent_upload(agent_errors) - self.known_backups.add(written_backup.backup, agent_errors) + try: + agent_errors = await self._async_upload_backup( + backup=written_backup.backup, + agent_ids=agent_ids, + open_stream=written_backup.open_stream, + ) + except BaseException: + self.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) + ) + raise # manager or unexpected error + finally: + try: + await written_backup.release_stream() + except Exception: + self.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) + ) + raise + self.known_backups.add(written_backup.backup, agent_errors) + if agent_errors: + self.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) + ) + else: + if with_automatic_settings: + # create backup was successful, update last_completed_automatic_backup + self.config.data.last_completed_automatic_backup = dt_util.now() + self.store.save() + + self.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.COMPLETED) + ) + + if with_automatic_settings: + self._update_issue_after_agent_upload(agent_errors) # delete old backups more numerous than copies + # try this regardless of agent errors above await delete_backups_exceeding_configured_count(self) - self.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.COMPLETED) - ) finally: self._backup_task = None self._backup_finish_task = None @@ -814,7 +883,7 @@ class BackupManager: ) -> None: """Initiate restoring a backup.""" if self.state is not BackupManagerState.IDLE: - raise HomeAssistantError(f"Backup manager busy: {self.state}") + raise BackupManagerError(f"Backup manager busy: {self.state}") self.async_on_backup_event( RestoreBackupEvent(stage=None, state=RestoreBackupState.IN_PROGRESS) @@ -854,7 +923,7 @@ class BackupManager: """Initiate restoring a backup.""" agent = self.backup_agents[agent_id] if not await agent.async_get_backup(backup_id): - raise HomeAssistantError( + raise BackupManagerError( f"Backup {backup_id} not found in agent {agent_id}" ) @@ -1027,11 +1096,11 @@ class CoreBackupReaderWriter(BackupReaderWriter): backup_id = _generate_backup_id(date_str, backup_name) if include_addons or include_all_addons or include_folders: - raise HomeAssistantError( + raise BackupReaderWriterError( "Addons and folders are not supported by core backup" ) if not include_homeassistant: - raise HomeAssistantError("Home Assistant must be included in backup") + raise BackupReaderWriterError("Home Assistant must be included in backup") backup_task = self._hass.async_create_task( self._async_create_backup( @@ -1102,6 +1171,13 @@ class CoreBackupReaderWriter(BackupReaderWriter): password, local_agent_tar_file_path, ) + except (BackupManagerError, OSError, tarfile.TarError, ValueError) as err: + # BackupManagerError from async_pre_backup_actions + # OSError from file operations + # TarError from tarfile + # ValueError from json_bytes + raise BackupReaderWriterError(str(err)) from err + else: backup = AgentBackup( addons=[], backup_id=backup_id, @@ -1119,12 +1195,15 @@ class CoreBackupReaderWriter(BackupReaderWriter): async_add_executor_job = self._hass.async_add_executor_job async def send_backup() -> AsyncIterator[bytes]: - f = await async_add_executor_job(tar_file_path.open, "rb") try: - while chunk := await async_add_executor_job(f.read, 2**20): - yield chunk - finally: - await async_add_executor_job(f.close) + f = await async_add_executor_job(tar_file_path.open, "rb") + try: + while chunk := await async_add_executor_job(f.read, 2**20): + yield chunk + finally: + await async_add_executor_job(f.close) + except OSError as err: + raise BackupReaderWriterError(str(err)) from err async def open_backup() -> AsyncIterator[bytes]: return send_backup() @@ -1132,14 +1211,20 @@ class CoreBackupReaderWriter(BackupReaderWriter): async def remove_backup() -> None: if local_agent_tar_file_path: return - await async_add_executor_job(tar_file_path.unlink, True) + try: + await async_add_executor_job(tar_file_path.unlink, True) + except OSError as err: + raise BackupReaderWriterError(str(err)) from err return WrittenBackup( backup=backup, open_stream=open_backup, release_stream=remove_backup ) finally: # Inform integrations the backup is done - await manager.async_post_backup_actions() + try: + await manager.async_post_backup_actions() + except BackupManagerError as err: + raise BackupReaderWriterError(str(err)) from err def _mkdir_and_generate_backup_contents( self, @@ -1252,11 +1337,11 @@ class CoreBackupReaderWriter(BackupReaderWriter): """ if restore_addons or restore_folders: - raise HomeAssistantError( + raise BackupReaderWriterError( "Addons and folders are not supported in core restore" ) if not restore_homeassistant and not restore_database: - raise HomeAssistantError( + raise BackupReaderWriterError( "Home Assistant or database must be included in restore" ) diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py index a937933f04c..81c00d699c6 100644 --- a/homeassistant/components/backup/models.py +++ b/homeassistant/components/backup/models.py @@ -6,6 +6,8 @@ from dataclasses import asdict, dataclass from enum import StrEnum from typing import Any, Self +from homeassistant.exceptions import HomeAssistantError + @dataclass(frozen=True, kw_only=True) class AddonInfo: @@ -67,3 +69,7 @@ class AgentBackup: protected=data["protected"], size=data["size"], ) + + +class BackupManagerError(HomeAssistantError): + """Backup manager error.""" diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 1b7cf930588..9edffe985ae 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -10,6 +10,7 @@ from typing import Any, cast from aiohasupervisor.exceptions import ( SupervisorBadRequestError, + SupervisorError, SupervisorNotFoundError, ) from aiohasupervisor.models import ( @@ -23,6 +24,7 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgent, BackupReaderWriter, + BackupReaderWriterError, CreateBackupEvent, Folder, NewBackup, @@ -233,20 +235,23 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ] locations = [agent.location for agent in hassio_agents] - backup = await self._client.backups.partial_backup( - supervisor_backups.PartialBackupOptions( - addons=include_addons_set, - folders=include_folders_set, - homeassistant=include_homeassistant, - name=backup_name, - password=password, - compressed=True, - location=locations or LOCATION_CLOUD_BACKUP, - homeassistant_exclude_database=not include_database, - background=True, - extra=extra_metadata, + try: + backup = await self._client.backups.partial_backup( + supervisor_backups.PartialBackupOptions( + addons=include_addons_set, + folders=include_folders_set, + homeassistant=include_homeassistant, + name=backup_name, + password=password, + compressed=True, + location=locations or LOCATION_CLOUD_BACKUP, + homeassistant_exclude_database=not include_database, + background=True, + extra=extra_metadata, + ) ) - ) + except SupervisorError as err: + raise BackupReaderWriterError(f"Error creating backup: {err}") from err backup_task = self._hass.async_create_task( self._async_wait_for_backup( backup, remove_after_upload=not bool(locations) @@ -278,22 +283,35 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): finally: unsub() if not backup_id: - raise HomeAssistantError("Backup failed") + raise BackupReaderWriterError("Backup failed") async def open_backup() -> AsyncIterator[bytes]: - return await self._client.backups.download_backup(backup_id) + try: + return await self._client.backups.download_backup(backup_id) + except SupervisorError as err: + raise BackupReaderWriterError( + f"Error downloading backup: {err}" + ) from err async def remove_backup() -> None: if not remove_after_upload: return - await self._client.backups.remove_backup( - backup_id, - options=supervisor_backups.RemoveBackupOptions( - location={LOCATION_CLOUD_BACKUP} - ), - ) + try: + await self._client.backups.remove_backup( + backup_id, + options=supervisor_backups.RemoveBackupOptions( + location={LOCATION_CLOUD_BACKUP} + ), + ) + except SupervisorError as err: + raise BackupReaderWriterError(f"Error removing backup: {err}") from err - details = await self._client.backups.backup_info(backup_id) + try: + details = await self._client.backups.backup_info(backup_id) + except SupervisorError as err: + raise BackupReaderWriterError( + f"Error getting backup details: {err}" + ) from err return WrittenBackup( backup=_backup_details_to_agent_backup(details), diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index ffecd1c4186..4f456cc6d72 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -166,3 +166,15 @@ async def setup_backup_integration( agent._loaded_backups = True return result + + +async def setup_backup_platform( + hass: HomeAssistant, + *, + domain: str, + platform: Any, +) -> None: + """Set up a mock domain.""" + mock_platform(hass, f"{domain}.backup", platform) + assert await async_setup_component(hass, domain, {}) + await hass.async_block_till_done() diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 9b652edb087..4b5f43edb82 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Generator +from dataclasses import replace from io import StringIO import json from pathlib import Path @@ -17,13 +18,15 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgentPlatformProtocol, BackupManager, - BackupPlatformProtocol, + BackupReaderWriterError, Folder, LocalBackupAgent, backup as local_backup_platform, ) +from homeassistant.components.backup.agent import BackupAgentError from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.components.backup.manager import ( + BackupManagerError, BackupManagerState, CoreBackupReaderWriter, CreateBackupEvent, @@ -42,9 +45,9 @@ from .common import ( TEST_BACKUP_ABC123, TEST_BACKUP_DEF456, BackupAgentTest, + setup_backup_platform, ) -from tests.common import MockPlatform, mock_platform from tests.typing import ClientSessionGenerator, WebSocketGenerator _EXPECTED_FILES = [ @@ -61,18 +64,6 @@ _EXPECTED_FILES_WITH_DATABASE = { } -async def _setup_backup_platform( - hass: HomeAssistant, - *, - domain: str = "some_domain", - platform: BackupPlatformProtocol | BackupAgentPlatformProtocol | None = None, -) -> None: - """Set up a mock domain.""" - mock_platform(hass, f"{domain}.backup", platform or MockPlatform()) - assert await async_setup_component(hass, domain, {}) - await hass.async_block_till_done() - - @pytest.fixture(autouse=True) def mock_delay_save() -> Generator[None]: """Mock the delay save constant.""" @@ -159,12 +150,15 @@ async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None: ("parameters", "expected_error"), [ ({"agent_ids": []}, "At least one agent must be selected"), - ({"agent_ids": ["non_existing"]}, "Invalid agent selected"), + ({"agent_ids": ["non_existing"]}, "Invalid agents selected: ['non_existing']"), ( {"include_addons": ["ssl"], "include_all_addons": True}, "Cannot include all addons and specify specific addons", ), - ({"include_homeassistant": False}, "Home Assistant must be included in backup"), + ( + {"include_homeassistant": False}, + "Home Assistant must be included in backup", + ), ], ) async def test_create_backup_wrong_parameters( @@ -242,7 +236,7 @@ async def test_async_initiate_backup( core_get_backup_agents.return_value = [local_agent] await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - await _setup_backup_platform( + await setup_backup_platform( hass, domain="test", platform=Mock( @@ -393,19 +387,96 @@ async def test_async_initiate_backup( @pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize("exception", [BackupAgentError("Boom!"), Exception("Boom!")]) async def test_async_initiate_backup_with_agent_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mocked_json_bytes: Mock, - mocked_tarfile: Mock, generate_backup_id: MagicMock, path_glob: MagicMock, hass_storage: dict[str, Any], + exception: Exception, ) -> None: - """Test generate backup.""" + """Test agent upload error during backup generation.""" agent_ids = [LOCAL_AGENT_ID, "test.remote"] local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + 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 + backups_info: list[dict[str, Any]] = [ + { + "addons": [ + { + "name": "Test", + "slug": "test", + "version": "1.0.0", + }, + ], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup1", + "database_included": True, + "date": "1970-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 0, + "with_automatic_settings": True, + }, + { + "addons": [], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup2", + "database_included": False, + "date": "1980-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test 2", + "protected": False, + "size": 1, + "with_automatic_settings": None, + }, + { + "addons": [ + { + "name": "Test", + "slug": "test", + "version": "1.0.0", + }, + ], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup3", + "database_included": True, + "date": "1970-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 0, + "with_automatic_settings": True, + }, + ] + remote_agent = BackupAgentTest("remote", backups=[backup_1, backup_2, backup_3]) with patch( "homeassistant.components.backup.backup.async_get_backup_agents" @@ -413,7 +484,7 @@ async def test_async_initiate_backup_with_agent_error( core_get_backup_agents.return_value = [local_agent] await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - await _setup_backup_platform( + await setup_backup_platform( hass, domain="test", platform=Mock( @@ -431,12 +502,18 @@ async def test_async_initiate_backup_with_agent_error( assert result["success"] is True assert result["result"] == { - "backups": [], + "backups": backups_info, "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, } + await ws_client.send_json_auto_id( + {"type": "backup/config/update", "retention": {"copies": 1, "days": None}} + ) + result = await ws_client.receive_json() + assert result["success"] + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) result = await ws_client.receive_json() @@ -445,11 +522,16 @@ async def test_async_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("Test exception") + remote_agent, + "async_upload_backup", + side_effect=exception, ), + patch.object(remote_agent, "async_delete_backup", delete_backup), ): await ws_client.send_json_auto_id( {"type": "backup/generate", "agent_ids": agent_ids} @@ -486,13 +568,13 @@ async def test_async_initiate_backup_with_agent_error( assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, "stage": None, - "state": CreateBackupState.COMPLETED, + "state": CreateBackupState.FAILED, } result = await ws_client.receive_json() assert result["event"] == {"manager_state": BackupManagerState.IDLE} - expected_backup_data = { + new_expected_backup_data = { "addons": [], "agent_ids": ["backup.local"], "backup_id": "abc123", @@ -508,20 +590,14 @@ async def test_async_initiate_backup_with_agent_error( "with_automatic_settings": False, } - await ws_client.send_json_auto_id( - {"type": "backup/details", "backup_id": backup_id} - ) - result = await ws_client.receive_json() - assert result["result"] == { - "agent_errors": {}, - "backup": expected_backup_data, - } - await ws_client.send_json_auto_id({"type": "backup/info"}) result = await ws_client.receive_json() + backups_response = result["result"].pop("backups") + + assert len(backups_response) == 4 + assert new_expected_backup_data in backups_response assert result["result"] == { "agent_errors": {}, - "backups": [expected_backup_data], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, } @@ -534,6 +610,9 @@ async def test_async_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 + @pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.parametrize( @@ -702,7 +781,7 @@ async def test_create_backup_failure_raises_issue( await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - await _setup_backup_platform( + await setup_backup_platform( hass, domain="test", platform=Mock( @@ -743,6 +822,337 @@ async def test_create_backup_failure_raises_issue( assert issue.translation_placeholders == issue_data["translation_placeholders"] +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + "exception", [BackupReaderWriterError("Boom!"), BaseException("Boom!")] +) +async def test_async_initiate_backup_non_agent_upload_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + generate_backup_id: MagicMock, + path_glob: MagicMock, + hass_storage: dict[str, Any], + exception: Exception, +) -> None: + """Test an unknown or writer upload error during backup generation.""" + hass_storage[DOMAIN] = { + "data": {}, + "key": DOMAIN, + "version": 1, + } + agent_ids = [LOCAL_AGENT_ID, "test.remote"] + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + + 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, + ), + ) + + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + 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, + ), + ): + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": agent_ids} + ) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.HOME_ASSISTANT, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.UPLOAD_TO_AGENTS, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert not hass_storage[DOMAIN]["data"] + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + "exception", [BackupReaderWriterError("Boom!"), Exception("Boom!")] +) +async def test_async_initiate_backup_with_task_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + generate_backup_id: MagicMock, + path_glob: MagicMock, + create_backup: AsyncMock, + exception: Exception, +) -> None: + """Test backup task error during backup generation.""" + backup_task: asyncio.Future[Any] = asyncio.Future() + 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 = BackupAgentTest("remote", backups=[]) + + 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, + ), + ) + + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": agent_ids} + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "open_call_count", + "open_exception", + "read_call_count", + "read_exception", + "close_call_count", + "close_exception", + "unlink_call_count", + "unlink_exception", + ), + [ + (1, OSError("Boom!"), 0, None, 0, None, 1, None), + (1, None, 1, OSError("Boom!"), 1, None, 1, None), + (1, None, 1, None, 1, OSError("Boom!"), 1, None), + (1, None, 1, None, 1, None, 1, OSError("Boom!")), + ], +) +async def test_initiate_backup_file_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + generate_backup_id: MagicMock, + path_glob: MagicMock, + open_call_count: int, + open_exception: Exception | None, + read_call_count: int, + read_exception: Exception | None, + close_call_count: int, + close_exception: Exception | None, + unlink_call_count: int, + unlink_exception: Exception | None, +) -> None: + """Test file error during generate backup.""" + agent_ids = ["test.remote"] + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + 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, + ), + ) + + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + open_mock = mock_open(read_data=b"test") + open_mock.side_effect = open_exception + open_mock.return_value.read.side_effect = read_exception + open_mock.return_value.close.side_effect = close_exception + + with ( + patch("pathlib.Path.open", open_mock), + patch("pathlib.Path.unlink", side_effect=unlink_exception) as unlink_mock, + ): + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": agent_ids} + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.HOME_ASSISTANT, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.UPLOAD_TO_AGENTS, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert open_mock.call_count == open_call_count + assert open_mock.return_value.read.call_count == read_call_count + assert open_mock.return_value.close.call_count == close_call_count + assert unlink_mock.call_count == unlink_call_count + + async def test_loading_platforms( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -754,8 +1164,9 @@ async def test_loading_platforms( get_agents_mock = AsyncMock(return_value=[]) - await _setup_backup_platform( + await setup_backup_platform( hass, + domain="test", platform=Mock( async_pre_backup=AsyncMock(), async_post_backup=AsyncMock(), @@ -776,7 +1187,7 @@ class LocalBackupAgentTest(BackupAgentTest, LocalBackupAgent): def get_backup_path(self, backup_id: str) -> Path: """Return the local path to a backup.""" - return "test.tar" + return Path("test.tar") @pytest.mark.parametrize( @@ -797,7 +1208,7 @@ async def test_loading_platform_with_listener( get_agents_mock = AsyncMock(return_value=[agent_class("remote1", backups=[])]) register_listener_mock = Mock() - await _setup_backup_platform( + await setup_backup_platform( hass, domain="test", platform=Mock( @@ -846,7 +1257,7 @@ async def test_not_loading_bad_platforms( platform_mock: Mock, ) -> None: """Test not loading bad backup platforms.""" - await _setup_backup_platform( + await setup_backup_platform( hass, domain="test", platform=platform_mock, @@ -857,16 +1268,14 @@ async def test_not_loading_bad_platforms( assert platform_mock.mock_calls == [] -async def test_exception_platform_pre( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: +async def test_exception_platform_pre(hass: HomeAssistant) -> None: """Test exception in pre step.""" async def _mock_step(hass: HomeAssistant) -> None: raise HomeAssistantError("Test exception") remote_agent = BackupAgentTest("remote", backups=[]) - await _setup_backup_platform( + await setup_backup_platform( hass, domain="test", platform=Mock( @@ -878,28 +1287,25 @@ async def test_exception_platform_pre( assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - await hass.services.async_call( - DOMAIN, - "create", - blocking=True, - ) + with pytest.raises(BackupManagerError) as err: + await hass.services.async_call( + DOMAIN, + "create", + blocking=True, + ) - assert "Generating backup failed" in caplog.text - assert "Test exception" in caplog.text + assert str(err.value) == "Error during pre-backup: Test exception" @pytest.mark.usefixtures("mock_backup_generation") -async def test_exception_platform_post( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: +async def test_exception_platform_post(hass: HomeAssistant) -> None: """Test exception in post step.""" async def _mock_step(hass: HomeAssistant) -> None: raise HomeAssistantError("Test exception") remote_agent = BackupAgentTest("remote", backups=[]) - await _setup_backup_platform( + await setup_backup_platform( hass, domain="test", platform=Mock( @@ -911,14 +1317,14 @@ async def test_exception_platform_post( assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - await hass.services.async_call( - DOMAIN, - "create", - blocking=True, - ) + with pytest.raises(BackupManagerError) as err: + await hass.services.async_call( + DOMAIN, + "create", + blocking=True, + ) - assert "Generating backup failed" in caplog.text - assert "Test exception" in caplog.text + assert str(err.value) == "Error during post-backup: Test exception" @pytest.mark.parametrize( @@ -974,7 +1380,7 @@ async def test_receive_backup( ) -> None: """Test receive backup and upload to the local and a remote agent.""" remote_agent = BackupAgentTest("remote", backups=[]) - await _setup_backup_platform( + await setup_backup_platform( hass, domain="test", platform=Mock( @@ -1098,8 +1504,8 @@ async def test_async_trigger_restore( manager = BackupManager(hass, CoreBackupReaderWriter(hass)) hass.data[DATA_MANAGER] = manager - await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) - await _setup_backup_platform( + await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) + await setup_backup_platform( hass, domain="test", platform=Mock( @@ -1156,8 +1562,8 @@ async def test_async_trigger_restore_wrong_password(hass: HomeAssistant) -> None manager = BackupManager(hass, CoreBackupReaderWriter(hass)) hass.data[DATA_MANAGER] = manager - await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) - await _setup_backup_platform( + await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) + await setup_backup_platform( hass, domain="test", platform=Mock( @@ -1228,7 +1634,7 @@ async def test_async_trigger_restore_wrong_parameters( """Test trigger restore.""" manager = BackupManager(hass, CoreBackupReaderWriter(hass)) - await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) + await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) await manager.load_platforms() local_agent = manager.backup_agents[LOCAL_AGENT_ID] diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index b407241be54..a3b29a55ad8 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -2,13 +2,19 @@ from collections.abc import Generator from typing import Any -from unittest.mock import ANY, AsyncMock, MagicMock, call, patch +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion -from homeassistant.components.backup import AgentBackup, BackupAgentError +from homeassistant.components.backup import ( + AgentBackup, + BackupAgentError, + BackupAgentPlatformProtocol, + BackupReaderWriterError, + Folder, +) from homeassistant.components.backup.agent import BackupAgentUnreachableError from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.components.backup.manager import ( @@ -19,6 +25,7 @@ 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, @@ -26,6 +33,7 @@ from .common import ( TEST_BACKUP_DEF456, BackupAgentTest, setup_backup_integration, + setup_backup_platform, ) from tests.common import async_fire_time_changed, async_mock_service @@ -472,27 +480,45 @@ async def test_generate_calls_create( ) -@pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.parametrize( - ("create_backup_settings", "expected_call_params"), + ( + "create_backup_settings", + "expected_call_params", + "side_effect", + "last_completed_automatic_backup", + ), [ ( - {}, { - "agent_ids": [], + "agent_ids": ["test.remote"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + { + "agent_ids": ["test.remote"], + "backup_name": ANY, + "extra_metadata": { + "instance_id": ANY, + "with_automatic_settings": True, + }, "include_addons": None, "include_all_addons": False, "include_database": True, "include_folders": None, "include_homeassistant": True, - "name": None, + "on_progress": ANY, "password": None, - "with_automatic_settings": True, }, + None, + "2024-11-13T12:01:01+01:00", ), ( { - "agent_ids": ["test-agent"], + "agent_ids": ["test.remote"], "include_addons": ["test-addon"], "include_all_addons": False, "include_database": True, @@ -501,32 +527,78 @@ async def test_generate_calls_create( "password": "test-password", }, { - "agent_ids": ["test-agent"], + "agent_ids": ["test.remote"], + "backup_name": "test-name", + "extra_metadata": { + "instance_id": ANY, + "with_automatic_settings": True, + }, "include_addons": ["test-addon"], "include_all_addons": False, "include_database": True, - "include_folders": ["media"], + "include_folders": [Folder.MEDIA], "include_homeassistant": True, - "name": "test-name", + "on_progress": ANY, "password": "test-password", - "with_automatic_settings": True, }, + None, + "2024-11-13T12:01:01+01:00", + ), + ( + { + "agent_ids": ["test.remote"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + { + "agent_ids": ["test.remote"], + "backup_name": ANY, + "extra_metadata": { + "instance_id": ANY, + "with_automatic_settings": True, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + BackupAgentError("Boom!"), + None, ), ], ) async def test_generate_with_default_settings_calls_create( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], freezer: FrozenDateTimeFactory, - snapshot: SnapshotAssertion, + create_backup: AsyncMock, create_backup_settings: dict[str, Any], expected_call_params: dict[str, Any], + side_effect: Exception | None, + last_completed_automatic_backup: str, ) -> None: """Test backup/generate_with_automatic_settings calls async_initiate_backup.""" - await setup_backup_integration(hass, with_hassio=False) - client = await hass_ws_client(hass) - freezer.move_to("2024-11-13 12:01:00+01:00") + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-13T12:01:00+01:00") + remote_agent = BackupAgentTest("remote", backups=[]) + 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() await client.send_json_auto_id( @@ -535,17 +607,47 @@ async def test_generate_with_default_settings_calls_create( result = await client.receive_json() assert result["success"] - with patch( - "homeassistant.components.backup.manager.BackupManager.async_initiate_backup", - return_value=NewBackup(backup_job_id="abc123"), - ) as generate_backup: + freezer.tick() + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass_storage[DOMAIN]["data"]["config"]["create_backup"] + == create_backup_settings + ) + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_attempted_automatic_backup"] + is None + ) + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_completed_automatic_backup"] + 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"} - generate_backup.assert_called_once_with(**expected_call_params) + + await hass.async_block_till_done() + + create_backup.assert_called_once_with(**expected_call_params) + + freezer.tick() + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_attempted_automatic_backup"] + == "2024-11-13T12:01:01+01:00" + ) + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_completed_automatic_backup"] + == last_completed_automatic_backup + ) @pytest.mark.parametrize( @@ -1193,7 +1295,23 @@ async def test_config_update_errors( 1, 2, BACKUP_CALL, - [Exception("Boom"), None], + [BackupReaderWriterError("Boom"), None], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": "daily", + }, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-13T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", # attempted to create backup but failed + "2024-11-11T04:45:00+01:00", + 1, + 2, + BACKUP_CALL, + [Exception("Boom"), None], # unknown error ), ], ) @@ -2272,7 +2390,7 @@ async def test_subscribe_event( hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion, ) -> None: - """Test generating a backup.""" + """Test subscribe event.""" await setup_backup_integration(hass, with_hassio=False) manager = hass.data[DATA_MANAGER] diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 86b25d61d88..5d9513a1d1b 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -35,7 +35,10 @@ async def setup_integration( cloud_logged_in: None, ) -> AsyncGenerator[None]: """Set up cloud integration.""" - with patch("homeassistant.components.backup.is_hassio", return_value=False): + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -345,7 +348,7 @@ async def test_agents_upload( homeassistant_version="2024.12.0", name="Test", protected=True, - size=0.0, + size=0, ) aioclient_mock.put(mock_get_upload_details.return_value["url"]) @@ -382,7 +385,7 @@ async def test_agents_upload( async def test_agents_upload_fail_put( hass: HomeAssistant, hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, + hass_storage: dict[str, Any], aioclient_mock: AiohttpClientMocker, mock_get_upload_details: Mock, put_mock_kwargs: dict[str, Any], @@ -401,7 +404,7 @@ async def test_agents_upload_fail_put( homeassistant_version="2024.12.0", name="Test", protected=True, - size=0.0, + size=0, ) aioclient_mock.put(mock_get_upload_details.return_value["url"], **put_mock_kwargs) @@ -421,9 +424,14 @@ async def test_agents_upload_fail_put( "/api/backup/upload?agent_id=cloud.cloud", data={"file": StringIO("test")}, ) + await hass.async_block_till_done() assert resp.status == 201 - assert "Error during backup upload - Failed to upload backup" in caplog.text + store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] + assert len(store_backups) == 1 + stored_backup = store_backups[0] + assert stored_backup["backup_id"] == backup_id + assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] @pytest.mark.parametrize("side_effect", [ClientError, CloudError]) @@ -431,9 +439,9 @@ async def test_agents_upload_fail_put( async def test_agents_upload_fail_cloud( hass: HomeAssistant, hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], mock_get_upload_details: Mock, side_effect: Exception, - caplog: pytest.LogCaptureFixture, ) -> None: """Test agent upload backup, when cloud user is logged in.""" client = await hass_client() @@ -450,7 +458,7 @@ async def test_agents_upload_fail_cloud( homeassistant_version="2024.12.0", name="Test", protected=True, - size=0.0, + size=0, ) with ( patch( @@ -468,15 +476,20 @@ async def test_agents_upload_fail_cloud( "/api/backup/upload?agent_id=cloud.cloud", data={"file": StringIO("test")}, ) + await hass.async_block_till_done() assert resp.status == 201 - assert "Error during backup upload - Failed to get upload details" in caplog.text + store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] + assert len(store_backups) == 1 + stored_backup = store_backups[0] + assert stored_backup["backup_id"] == backup_id + assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] async def test_agents_upload_not_protected( hass: HomeAssistant, hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, + hass_storage: dict[str, Any], ) -> None: """Test agent upload backup, when cloud user is logged in.""" client = await hass_client() @@ -492,7 +505,7 @@ async def test_agents_upload_not_protected( homeassistant_version="2024.12.0", name="Test", protected=False, - size=0.0, + size=0, ) with ( patch("pathlib.Path.open"), @@ -505,9 +518,14 @@ async def test_agents_upload_not_protected( "/api/backup/upload?agent_id=cloud.cloud", data={"file": StringIO("test")}, ) + await hass.async_block_till_done() assert resp.status == 201 - assert "Error during backup upload - Cloud backups must be protected" in caplog.text + store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] + assert len(store_backups) == 1 + stored_backup = store_backups[0] + assert stored_backup["backup_id"] == backup_id + assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 3c9440c41ff..620532d30cf 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -16,6 +16,7 @@ from unittest.mock import ANY, AsyncMock, Mock, patch from aiohasupervisor.exceptions import ( SupervisorBadRequestError, + SupervisorError, SupervisorNotFoundError, ) from aiohasupervisor.models import ( @@ -46,7 +47,7 @@ TEST_BACKUP = supervisor_backups.Backup( compressed=False, content=supervisor_backups.BackupContent( addons=["ssl"], - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), @@ -71,7 +72,7 @@ TEST_BACKUP_DETAILS = supervisor_backups.BackupComplete( compressed=TEST_BACKUP.compressed, date=TEST_BACKUP.date, extra=None, - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant="2024.12.0", location=TEST_BACKUP.location, @@ -197,7 +198,7 @@ async def hassio_enabled( @pytest.fixture async def setup_integration( hass: HomeAssistant, hassio_enabled: None, supervisor_client: AsyncMock -) -> AsyncGenerator[None]: +) -> None: """Set up Backup integration.""" assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await hass.async_block_till_done() @@ -451,7 +452,7 @@ async def test_agent_upload( homeassistant_version="2024.12.0", name="Test", protected=False, - size=0.0, + size=0, ) supervisor_client.backups.reload.assert_not_called() @@ -732,6 +733,292 @@ async def test_reader_writer_create( supervisor_client.backups.download_backup.assert_not_called() supervisor_client.backups.remove_backup.assert_not_called() + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + ("side_effect", "error_code", "error_message"), + [ + ( + SupervisorError("Boom!"), + "home_assistant_error", + "Error creating backup: Boom!", + ), + (Exception("Boom!"), "unknown_error", "Unknown error"), + ], +) +async def test_reader_writer_create_partial_backup_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + side_effect: Exception, + error_code: str, + error_message: str, +) -> None: + """Test client partial backup error when generating a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.side_effect = side_effect + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "failed", + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == error_code + assert response["error"]["message"] == error_message + + assert supervisor_client.backups.partial_backup.call_count == 1 + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_create_missing_reference_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test missing reference error when generating a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": "abc123"} + + assert supervisor_client.backups.partial_backup.call_count == 1 + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "failed", + } + + await hass.async_block_till_done() + + assert supervisor_client.backups.backup_info.call_count == 0 + assert supervisor_client.backups.download_backup.call_count == 0 + assert supervisor_client.backups.remove_backup.call_count == 0 + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")]) +@pytest.mark.parametrize( + ("method", "download_call_count", "remove_call_count"), + [("download_backup", 1, 1), ("remove_backup", 1, 1)], +) +async def test_reader_writer_create_download_remove_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + exception: Exception, + method: str, + download_call_count: int, + remove_call_count: int, +) -> None: + """Test download and remove error when generating a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + method_mock = getattr(supervisor_client.backups, method) + method_mock.side_effect = exception + + remote_agent = BackupAgentTest("remote") + await _setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["test.remote"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": "abc123"} + + assert supervisor_client.backups.partial_backup.call_count == 1 + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "failed", + } + + await hass.async_block_till_done() + + assert supervisor_client.backups.backup_info.call_count == 1 + assert supervisor_client.backups.download_backup.call_count == download_call_count + assert supervisor_client.backups.remove_backup.call_count == remove_call_count + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")]) +async def test_reader_writer_create_info_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + exception: Exception, +) -> None: + """Test backup info error when generating a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.backup_info.side_effect = exception + + remote_agent = BackupAgentTest("remote") + await _setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["test.remote"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": "abc123"} + + assert supervisor_client.backups.partial_backup.call_count == 1 + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "failed", + } + + await hass.async_block_till_done() + + assert supervisor_client.backups.backup_info.call_count == 1 + assert supervisor_client.backups.download_backup.call_count == 0 + assert supervisor_client.backups.remove_backup.call_count == 0 + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + @pytest.mark.usefixtures("hassio_client", "setup_integration") async def test_reader_writer_create_remote_backup( From ce7a0650e4c33a31ab3d1512830ceae099e8c96e Mon Sep 17 00:00:00 2001 From: Matthew FitzGerald-Chamberlain Date: Thu, 2 Jan 2025 01:39:57 -0600 Subject: [PATCH 0079/2987] Improve support for Aprilaire S86WMUPR (#133974) --- homeassistant/components/aprilaire/coordinator.py | 15 ++++++++++----- homeassistant/components/aprilaire/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/aprilaire/test_config_flow.py | 1 - 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/aprilaire/coordinator.py b/homeassistant/components/aprilaire/coordinator.py index 737fd768140..6b132cfcc95 100644 --- a/homeassistant/components/aprilaire/coordinator.py +++ b/homeassistant/components/aprilaire/coordinator.py @@ -120,6 +120,8 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol): """Wait for the client to be ready.""" if not self.data or Attribute.MAC_ADDRESS not in self.data: + await self.client.read_mac_address() + data = await self.client.wait_for_response( FunctionalDomain.IDENTIFICATION, 2, WAIT_TIMEOUT ) @@ -130,12 +132,9 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol): return False - if not self.data or Attribute.NAME not in self.data: - await self.client.wait_for_response( - FunctionalDomain.IDENTIFICATION, 4, WAIT_TIMEOUT - ) - if not self.data or Attribute.THERMOSTAT_MODES not in self.data: + await self.client.read_thermostat_iaq_available() + await self.client.wait_for_response( FunctionalDomain.CONTROL, 7, WAIT_TIMEOUT ) @@ -144,10 +143,16 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol): not self.data or Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_STATUS not in self.data ): + await self.client.read_sensors() + await self.client.wait_for_response( FunctionalDomain.SENSORS, 2, WAIT_TIMEOUT ) + await self.client.read_thermostat_status() + + await self.client.read_iaq_status() + await ready_callback(True) return True diff --git a/homeassistant/components/aprilaire/manifest.json b/homeassistant/components/aprilaire/manifest.json index 179a101885b..577de8ae88d 100644 --- a/homeassistant/components/aprilaire/manifest.json +++ b/homeassistant/components/aprilaire/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["pyaprilaire"], - "requirements": ["pyaprilaire==0.7.4"] + "requirements": ["pyaprilaire==0.7.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index e57074933c0..4427d01f93b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1779,7 +1779,7 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.aprilaire -pyaprilaire==0.7.4 +pyaprilaire==0.7.7 # homeassistant.components.asuswrt pyasuswrt==0.1.21 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 223502ece25..7130ac0e6f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1459,7 +1459,7 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.aprilaire -pyaprilaire==0.7.4 +pyaprilaire==0.7.7 # homeassistant.components.asuswrt pyasuswrt==0.1.21 diff --git a/tests/components/aprilaire/test_config_flow.py b/tests/components/aprilaire/test_config_flow.py index e4b7c167256..0cda1ed40ad 100644 --- a/tests/components/aprilaire/test_config_flow.py +++ b/tests/components/aprilaire/test_config_flow.py @@ -95,7 +95,6 @@ async def test_config_flow_data(client: AprilaireClient, hass: HomeAssistant) -> ) client.start_listen.assert_called_once() - client.wait_for_response.assert_any_call(FunctionalDomain.IDENTIFICATION, 4, 30) client.wait_for_response.assert_any_call(FunctionalDomain.CONTROL, 7, 30) client.wait_for_response.assert_any_call(FunctionalDomain.SENSORS, 2, 30) client.stop_listen.assert_called_once() From 554cdd1784836d765c2a27999f8ab9e781cfa511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C5=A0trauch?= Date: Wed, 1 Jan 2025 13:10:40 +0100 Subject: [PATCH 0080/2987] Add new ID LAP-V201S-AEUR for Vital200S AirPurifier in Vesync integration (#133999) --- 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 48215819ce5..b1bad8cfa11 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -56,6 +56,7 @@ SKU_TO_BASE_DEVICE = { "LAP-V201S-WEU": "Vital200S", # Alt ID Model Vital200S "LAP-V201S-WUS": "Vital200S", # Alt ID Model Vital200S "LAP-V201-AUSR": "Vital200S", # Alt ID Model Vital200S + "LAP-V201S-AEUR": "Vital200S", # Alt ID Model Vital200S "LAP-V201S-AUSR": "Vital200S", # Alt ID Model Vital200S "Vital100S": "Vital100S", "LAP-V102S-WUS": "Vital100S", # Alt ID Model Vital100S From fea3dfda9439370671cb53329511d1ae58565967 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Wed, 1 Jan 2025 05:03:39 -0700 Subject: [PATCH 0081/2987] Vesync unload error when not all platforms used (#134166) --- homeassistant/components/vesync/__init__.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index b6f263f3037..0993743d461 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -135,7 +135,18 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + in_use_platforms = [] + if hass.data[DOMAIN][VS_SWITCHES]: + in_use_platforms.append(Platform.SWITCH) + if hass.data[DOMAIN][VS_FANS]: + in_use_platforms.append(Platform.FAN) + if hass.data[DOMAIN][VS_LIGHTS]: + in_use_platforms.append(Platform.LIGHT) + if hass.data[DOMAIN][VS_SENSORS]: + in_use_platforms.append(Platform.SENSOR) + unload_ok = await hass.config_entries.async_unload_platforms( + entry, in_use_platforms + ) if unload_ok: hass.data.pop(DOMAIN) From 3a8f71a64a747377e30fa215480d089ec8bd3c88 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 2 Jan 2025 11:37:25 +0100 Subject: [PATCH 0082/2987] Improve Supervisor backup error handling (#134346) * Raise Home Assistant error in case backup restore fails This change raises a Home Assistant error in case the backup restore fails. The Supervisor is checking some common issues before starting the actual restore in background. This early checks raise an exception (represented by a HTTP 400 error). This change catches such errors and raises a Home Assistant error with the message from the Supervisor exception. * Add test coverage --- homeassistant/components/hassio/backup.py | 32 ++++++---- tests/components/hassio/test_backup.py | 71 +++++++++++++++++++++++ 2 files changed, 92 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 9edffe985ae..e915e56622b 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -30,6 +30,9 @@ from homeassistant.components.backup import ( NewBackup, WrittenBackup, ) + +# pylint: disable-next=hass-component-root-import +from homeassistant.components.backup.manager import IncorrectPasswordError from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -403,17 +406,24 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): agent = cast(SupervisorBackupAgent, manager.backup_agents[agent_id]) restore_location = agent.location - job = await self._client.backups.partial_restore( - backup_id, - supervisor_backups.PartialRestoreOptions( - addons=restore_addons_set, - folders=restore_folders_set, - homeassistant=restore_homeassistant, - password=password, - background=True, - location=restore_location, - ), - ) + try: + job = await self._client.backups.partial_restore( + backup_id, + supervisor_backups.PartialRestoreOptions( + addons=restore_addons_set, + folders=restore_folders_set, + homeassistant=restore_homeassistant, + password=password, + background=True, + location=restore_location, + ), + ) + except SupervisorBadRequestError as err: + # Supervisor currently does not transmit machine parsable error types + message = err.args[0] + if message.startswith("Invalid password for backup"): + raise IncorrectPasswordError(message) from err + raise HomeAssistantError(message) from err restore_complete = asyncio.Event() diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 620532d30cf..5657193fc49 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -1284,6 +1284,77 @@ async def test_reader_writer_restore( assert response["result"] is None +@pytest.mark.parametrize( + ("supervisor_error_string", "expected_error_code"), + [ + ( + "Invalid password for backup", + "password_incorrect", + ), + ( + "Backup was made on supervisor version 2025.12.0, can't restore on 2024.12.0. Must update supervisor first.", + "home_assistant_error", + ), + ], +) +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_restore_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + supervisor_error_string: str, + expected_error_code: str, +) -> None: + """Test restoring a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_restore.side_effect = SupervisorBadRequestError( + supervisor_error_string + ) + supervisor_client.backups.list.return_value = [TEST_BACKUP] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/restore", "agent_id": "hassio.local", "backup_id": "abc123"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "stage": None, + "state": "in_progress", + } + + supervisor_client.backups.partial_restore.assert_called_once_with( + "abc123", + supervisor_backups.PartialRestoreOptions( + addons=None, + background=True, + folders=None, + homeassistant=True, + location=None, + password=None, + ), + ) + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "stage": None, + "state": "failed", + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + response = await client.receive_json() + assert response["error"]["code"] == expected_error_code + + @pytest.mark.parametrize( ("parameters", "expected_error"), [ From 568b637dc598f952cd7c9fe8578d8105ea5f6d2d Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Wed, 1 Jan 2025 02:42:16 -0800 Subject: [PATCH 0083/2987] Bump zabbix-utils to 2.0.2 (#134373) --- homeassistant/components/zabbix/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zabbix/manifest.json b/homeassistant/components/zabbix/manifest.json index 86389d2b839..6707cb7ddb3 100644 --- a/homeassistant/components/zabbix/manifest.json +++ b/homeassistant/components/zabbix/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["zabbix_utils"], "quality_scale": "legacy", - "requirements": ["zabbix-utils==2.0.1"] + "requirements": ["zabbix-utils==2.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4427d01f93b..22c4a7a55e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3085,7 +3085,7 @@ youtubeaio==1.1.5 yt-dlp[default]==2024.12.23 # homeassistant.components.zabbix -zabbix-utils==2.0.1 +zabbix-utils==2.0.2 # homeassistant.components.zamg zamg==0.3.6 From f97439eaab818446330c50eb610f5ca27cae20d6 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 1 Jan 2025 21:09:15 +1000 Subject: [PATCH 0084/2987] Check vehicle metadata (#134381) --- homeassistant/components/teslemetry/__init__.py | 7 ++++++- tests/components/teslemetry/const.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 0b61120877a..5779283b955 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -85,6 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - scopes = calls[0]["scopes"] region = calls[0]["region"] + vehicle_metadata = calls[0]["vehicles"] products = calls[1]["response"] device_registry = dr.async_get(hass) @@ -102,7 +103,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - ) for product in products: - if "vin" in product and Scope.VEHICLE_DEVICE_DATA in scopes: + if ( + "vin" in product + and vehicle_metadata.get(product["vin"], {}).get("access") + and Scope.VEHICLE_DEVICE_DATA in scopes + ): # Remove the protobuff 'cached_data' that we do not use to save memory product.pop("cached_data", None) vin = product["vin"] diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index bf483d576cd..46efed2153d 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -46,9 +46,25 @@ METADATA = { "energy_device_data", "energy_cmds", ], + "vehicles": { + "LRW3F7EK4NC700000": { + "proxy": False, + "access": True, + "polling": True, + "firmware": "2024.44.25", + } + }, } METADATA_NOSCOPE = { "uid": "abc-123", "region": "NA", "scopes": ["openid", "offline_access", "vehicle_device_data"], + "vehicles": { + "LRW3F7EK4NC700000": { + "proxy": False, + "access": True, + "polling": True, + "firmware": "2024.44.25", + } + }, } From 4cb413521db312eae8c22f5584402ccac14dcfa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20D=C4=85browski?= Date: Thu, 2 Jan 2025 11:38:12 +0100 Subject: [PATCH 0085/2987] Add state attributes translations to GIOS (#134390) --- homeassistant/components/gios/strings.json | 72 ++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index ee0f50ef40c..fc82f1c843d 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -34,6 +34,18 @@ "moderate": "Moderate", "good": "Good", "very_good": "Very good" + }, + "state_attributes": { + "options": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + } } }, "c6h6": { @@ -51,6 +63,18 @@ "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + }, + "state_attributes": { + "options": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + } } }, "o3_index": { @@ -62,6 +86,18 @@ "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + }, + "state_attributes": { + "options": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + } } }, "pm10_index": { @@ -73,6 +109,18 @@ "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + }, + "state_attributes": { + "options": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + } } }, "pm25_index": { @@ -84,6 +132,18 @@ "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + }, + "state_attributes": { + "options": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + } } }, "so2_index": { @@ -95,6 +155,18 @@ "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + }, + "state_attributes": { + "options": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + } } } } From 0e79c17cb8d4438b0ba56c61ed5283d2b4cb4ae8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 2 Jan 2025 08:51:49 +0100 Subject: [PATCH 0086/2987] Fix SQL sensor name (#134414) --- homeassistant/components/sql/sensor.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 1d033728c0d..312b0cd345e 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -331,9 +331,16 @@ class SQLSensor(ManualTriggerSensorEntity): entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, unique_id)}, manufacturer="SQL", - name=self.name, + name=self._rendered.get(CONF_NAME), ) + @property + def name(self) -> str | None: + """Name of the entity.""" + if self.has_entity_name: + return self._attr_name + return self._rendered.get(CONF_NAME) + async def async_added_to_hass(self) -> None: """Call when entity about to be added to hass.""" await super().async_added_to_hass() From c9ba267fecd56ac443574cf8192b2c6cfdb59938 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 1 Jan 2025 20:03:17 -0600 Subject: [PATCH 0087/2987] Bump intents to 2025.1.1 (#134424) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- tests/components/conversation/snapshots/test_http.ambr | 1 + 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 4017ed82be1..979ea7538c4 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.1.0", "home-assistant-intents==2024.12.20"] + "requirements": ["hassil==2.1.0", "home-assistant-intents==2025.1.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c97dbe11d29..8f51b47ba30 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,7 +36,7 @@ hass-nabucasa==0.87.0 hassil==2.1.0 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20241231.0 -home-assistant-intents==2024.12.20 +home-assistant-intents==2025.1.1 httpx==0.27.2 ifaddr==0.2.0 Jinja2==3.1.5 diff --git a/requirements_all.txt b/requirements_all.txt index 22c4a7a55e3..fb137a1d1ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1137,7 +1137,7 @@ holidays==0.63 home-assistant-frontend==20241231.0 # homeassistant.components.conversation -home-assistant-intents==2024.12.20 +home-assistant-intents==2025.1.1 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7130ac0e6f6..dee17173304 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -966,7 +966,7 @@ holidays==0.63 home-assistant-frontend==20241231.0 # homeassistant.components.conversation -home-assistant-intents==2024.12.20 +home-assistant-intents==2025.1.1 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 52948484ed8..962ab58d981 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.8,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.8.3 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.1.0 home-assistant-intents==2024.12.20 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.1.0 home-assistant-intents==2025.1.1 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index ce3247fbbad..0de575790db 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -39,6 +39,7 @@ 'mn', 'ms', 'nb', + 'ne', 'nl', 'pl', 'pt', From ca6bae6b158f0da7b68e71f1ed48f70ea235fcea Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 2 Jan 2025 08:43:38 +0100 Subject: [PATCH 0088/2987] Bump ZHA to 0.0.44 (#134427) --- homeassistant/components/zha/manifest.json | 2 +- homeassistant/components/zha/strings.json | 9 +++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index e396c8776e7..45d8f6bb25f 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["universal-silabs-flasher==0.0.25", "zha==0.0.43"], + "requirements": ["universal-silabs-flasher==0.0.25", "zha==0.0.44"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 8e4d3f78eb4..da76c62e82e 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -879,6 +879,12 @@ }, "regulator_set_point": { "name": "Regulator set point" + }, + "detection_delay": { + "name": "Detection delay" + }, + "fading_time": { + "name": "Fading time" } }, "select": { @@ -1237,6 +1243,9 @@ }, "local_temperature_floor": { "name": "Floor temperature" + }, + "self_test": { + "name": "Self test result" } }, "switch": { diff --git a/requirements_all.txt b/requirements_all.txt index fb137a1d1ad..9d8a43694d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3100,7 +3100,7 @@ zeroconf==0.136.2 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.43 +zha==0.0.44 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dee17173304..d5076f45aa0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2489,7 +2489,7 @@ zeroconf==0.136.2 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.43 +zha==0.0.44 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 From 8ace126d9f602ba59311153e25d60a919f8fafc3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Jan 2025 17:52:50 +0100 Subject: [PATCH 0089/2987] Improve hassio backup create and restore parameter checks (#134434) --- homeassistant/components/hassio/backup.py | 17 +++- tests/components/hassio/test_backup.py | 98 ++++++++++++++++++++--- 2 files changed, 103 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index e915e56622b..0abb0e0d953 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -218,6 +218,10 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): password: str | None, ) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]: """Create a backup.""" + if not include_homeassistant and include_database: + raise HomeAssistantError( + "Cannot create a backup with database but without Home Assistant" + ) manager = self._hass.data[DATA_MANAGER] include_addons_set: supervisor_backups.AddonSet | set[str] | None = None @@ -380,8 +384,16 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): restore_homeassistant: bool, ) -> None: """Restore a backup.""" - if restore_homeassistant and not restore_database: - raise HomeAssistantError("Cannot restore Home Assistant without database") + manager = self._hass.data[DATA_MANAGER] + # The backup manager has already checked that the backup exists so we don't need to + # check that here. + backup = await manager.backup_agents[agent_id].async_get_backup(backup_id) + if ( + backup + and restore_homeassistant + and restore_database != backup.database_included + ): + raise HomeAssistantError("Restore database must match backup") if not restore_homeassistant and restore_database: raise HomeAssistantError("Cannot restore database without Home Assistant") restore_addons_set = set(restore_addons) if restore_addons else None @@ -391,7 +403,6 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): else None ) - manager = self._hass.data[DATA_MANAGER] restore_location: str | None if manager.backup_agents[agent_id].domain != DOMAIN: # Download the backup to the supervisor. Supervisor will clean up the backup diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 5657193fc49..10a804d983f 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -176,6 +176,51 @@ TEST_BACKUP_DETAILS_3 = supervisor_backups.BackupComplete( ) +TEST_BACKUP_4 = supervisor_backups.Backup( + compressed=False, + content=supervisor_backups.BackupContent( + addons=["ssl"], + folders=["share"], + homeassistant=True, + ), + date=datetime.fromisoformat("1970-01-01T00:00:00Z"), + location=None, + locations={None}, + name="Test", + protected=False, + size=1.0, + size_bytes=1048576, + slug="abc123", + type=supervisor_backups.BackupType.PARTIAL, +) +TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete( + addons=[ + supervisor_backups.BackupAddon( + name="Terminal & SSH", + size=0.0, + slug="core_ssh", + version="9.14.0", + ) + ], + compressed=TEST_BACKUP.compressed, + date=TEST_BACKUP.date, + extra=None, + folders=["share"], + homeassistant_exclude_database=True, + homeassistant="2024.12.0", + location=TEST_BACKUP.location, + locations=TEST_BACKUP.locations, + name=TEST_BACKUP.name, + protected=TEST_BACKUP.protected, + repositories=[], + size=TEST_BACKUP.size, + size_bytes=TEST_BACKUP.size_bytes, + slug=TEST_BACKUP.slug, + supervisor_version="2024.11.2", + type=TEST_BACKUP.type, +) + + @pytest.fixture(autouse=True) def fixture_supervisor_environ() -> Generator[None]: """Mock os environ for supervisor.""" @@ -662,8 +707,17 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( replace(DEFAULT_BACKUP_OPTIONS, folders={"media", "share"}), ), ( - {"include_folders": ["media"], "include_homeassistant": False}, - replace(DEFAULT_BACKUP_OPTIONS, folders={"media"}, homeassistant=False), + { + "include_folders": ["media"], + "include_database": False, + "include_homeassistant": False, + }, + replace( + DEFAULT_BACKUP_OPTIONS, + folders={"media"}, + homeassistant=False, + homeassistant_exclude_database=True, + ), ), ], ) @@ -1100,9 +1154,22 @@ async def test_reader_writer_create_remote_backup( @pytest.mark.usefixtures("hassio_client", "setup_integration") @pytest.mark.parametrize( - ("extra_generate_options"), + ("extra_generate_options", "expected_error"), [ - {"include_homeassistant": False}, + ( + {"include_homeassistant": False}, + { + "code": "home_assistant_error", + "message": "Cannot create a backup with database but without Home Assistant", + }, + ), + ( + {"include_homeassistant": False, "include_database": False}, + { + "code": "unknown_error", + "message": "Unknown error", + }, + ), ], ) async def test_reader_writer_create_wrong_parameters( @@ -1110,6 +1177,7 @@ async def test_reader_writer_create_wrong_parameters( hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, extra_generate_options: dict[str, Any], + expected_error: dict[str, str], ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) @@ -1147,7 +1215,7 @@ async def test_reader_writer_create_wrong_parameters( response = await client.receive_json() assert not response["success"] - assert response["error"] == {"code": "unknown_error", "message": "Unknown error"} + assert response["error"] == expected_error supervisor_client.backups.partial_backup.assert_not_called() @@ -1356,16 +1424,26 @@ async def test_reader_writer_restore_error( @pytest.mark.parametrize( - ("parameters", "expected_error"), + ("backup", "backup_details", "parameters", "expected_error"), [ ( + TEST_BACKUP, + TEST_BACKUP_DETAILS, {"restore_database": False}, - "Cannot restore Home Assistant without database", + "Restore database must match backup", ), ( + TEST_BACKUP, + TEST_BACKUP_DETAILS, {"restore_homeassistant": False}, "Cannot restore database without Home Assistant", ), + ( + TEST_BACKUP_4, + TEST_BACKUP_DETAILS_4, + {"restore_homeassistant": True, "restore_database": True}, + "Restore database must match backup", + ), ], ) @pytest.mark.usefixtures("hassio_client", "setup_integration") @@ -1373,13 +1451,15 @@ async def test_reader_writer_restore_wrong_parameters( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + backup: supervisor_backups.Backup, + backup_details: supervisor_backups.BackupComplete, parameters: dict[str, Any], expected_error: str, ) -> None: """Test trigger restore.""" client = await hass_ws_client(hass) - supervisor_client.backups.list.return_value = [TEST_BACKUP] - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.list.return_value = [backup] + supervisor_client.backups.backup_info.return_value = backup_details default_parameters = { "type": "backup/restore", From e89a1da46283ca9fc7f09384a8f7fc00688d52e0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Jan 2025 12:40:10 +0100 Subject: [PATCH 0090/2987] Export IncorrectPasswordError from backup integration (#134436) --- homeassistant/components/backup/__init__.py | 2 ++ homeassistant/components/hassio/backup.py | 4 +--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 7d9979ce9a2..00b226a9fee 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -24,6 +24,7 @@ from .manager import ( BackupReaderWriterError, CoreBackupReaderWriter, CreateBackupEvent, + IncorrectPasswordError, ManagerBackup, NewBackup, WrittenBackup, @@ -43,6 +44,7 @@ __all__ = [ "BackupReaderWriterError", "CreateBackupEvent", "Folder", + "IncorrectPasswordError", "LocalBackupAgent", "NewBackup", "WrittenBackup", diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 0abb0e0d953..537588e856a 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -27,12 +27,10 @@ from homeassistant.components.backup import ( BackupReaderWriterError, CreateBackupEvent, Folder, + IncorrectPasswordError, NewBackup, WrittenBackup, ) - -# pylint: disable-next=hass-component-root-import -from homeassistant.components.backup.manager import IncorrectPasswordError from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect From faf9c2ee401cd15e4fabfd0e088356bbfff5de54 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Jan 2025 13:29:46 +0100 Subject: [PATCH 0091/2987] Adjust language in backup integration (#134440) * Adjust language in backup integration * Update tests --- homeassistant/components/backup/manager.py | 2 +- homeassistant/components/backup/strings.json | 4 ++-- tests/components/backup/snapshots/test_websocket.ambr | 6 +++--- tests/components/backup/test_manager.py | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 8421448f619..33405d97883 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -752,7 +752,7 @@ class BackupManager: backup_name = ( name - or f"{"Automatic" if with_automatic_settings else "Custom"} {HAVERSION}" + or f"{"Automatic" if with_automatic_settings else "Custom"} backup {HAVERSION}" ) try: diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json index d9de2bff861..43ae57cc781 100644 --- a/homeassistant/components/backup/strings.json +++ b/homeassistant/components/backup/strings.json @@ -5,8 +5,8 @@ "description": "The automatic backup could not be created. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured." }, "automatic_backup_failed_upload_agents": { - "title": "Automatic backup could not be uploaded to agents", - "description": "The automatic backup could not be uploaded to agents {failed_agents}. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured." + "title": "Automatic backup could not be uploaded to the configured locations", + "description": "The automatic backup could not be uploaded to the configured locations {failed_agents}. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured." } }, "services": { diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 16640a95ddb..98b2f764d43 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -2574,7 +2574,7 @@ dict({ 'id': 2, 'result': dict({ - 'backup_job_id': 'fceef4e6', + 'backup_job_id': '64331d85', }), 'success': True, 'type': 'result', @@ -2645,7 +2645,7 @@ dict({ 'id': 2, 'result': dict({ - 'backup_job_id': 'fceef4e6', + 'backup_job_id': '64331d85', }), 'success': True, 'type': 'result', @@ -2716,7 +2716,7 @@ dict({ 'id': 2, 'result': dict({ - 'backup_job_id': 'fceef4e6', + 'backup_job_id': '64331d85', }), 'success': True, 'type': 'result', diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 4b5f43edb82..0797eef2274 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -112,7 +112,7 @@ async def test_async_create_backup( assert create_backup.called assert create_backup.call_args == call( agent_ids=["backup.local"], - backup_name="Custom 2025.1.0", + backup_name="Custom backup 2025.1.0", extra_metadata={ "instance_id": hass.data["core.uuid"], "with_automatic_settings": False, @@ -248,7 +248,7 @@ async def test_async_initiate_backup( ws_client = await hass_ws_client(hass) include_database = params.get("include_database", True) - name = params.get("name", "Custom 2025.1.0") + name = params.get("name", "Custom backup 2025.1.0") password = params.get("password") path_glob.return_value = [] @@ -584,7 +584,7 @@ async def test_async_initiate_backup_with_agent_error( "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.1.0", - "name": "Custom 2025.1.0", + "name": "Custom backup 2025.1.0", "protected": False, "size": 123, "with_automatic_settings": False, From 21aca3c14643c245bad817c5484f8c2f1b631f7f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Jan 2025 12:49:03 +0100 Subject: [PATCH 0092/2987] Initialize AppleTVConfigFlow.identifiers (#134443) --- homeassistant/components/apple_tv/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index b0741cc9c61..5cb92ed892a 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -98,7 +98,6 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 scan_filter: str | None = None - all_identifiers: set[str] atv: BaseConfig | None = None atv_identifiers: list[str] | None = None _host: str # host in zeroconf discovery info, should not be accessed by other flows @@ -118,6 +117,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize a new AppleTVConfigFlow.""" self.credentials: dict[int, str | None] = {} # Protocol -> credentials + self.all_identifiers: set[str] = set() @property def device_identifier(self) -> str | None: From 0a13516ddd380862fc9912d5b492289f426ad43c Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 2 Jan 2025 15:33:22 +0100 Subject: [PATCH 0093/2987] Bump aioacaia to 0.1.12 (#134454) --- homeassistant/components/acaia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/acaia/manifest.json b/homeassistant/components/acaia/manifest.json index 36551e9c695..fef8c1219a8 100644 --- a/homeassistant/components/acaia/manifest.json +++ b/homeassistant/components/acaia/manifest.json @@ -26,5 +26,5 @@ "iot_class": "local_push", "loggers": ["aioacaia"], "quality_scale": "platinum", - "requirements": ["aioacaia==0.1.11"] + "requirements": ["aioacaia==0.1.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9d8a43694d8..b1f9b9555d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -173,7 +173,7 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.11 +aioacaia==0.1.12 # homeassistant.components.airq aioairq==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d5076f45aa0..80b3772500b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -161,7 +161,7 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.11 +aioacaia==0.1.12 # homeassistant.components.airq aioairq==0.4.3 From d75d970fc7bfda78e86d2d757ee62e6284ad0177 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 2 Jan 2025 17:17:57 +0100 Subject: [PATCH 0094/2987] Update frontend to 20250102.0 (#134462) --- 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 d1bb15b5d3b..33d1be3aad7 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241231.0"] + "requirements": ["home-assistant-frontend==20250102.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8f51b47ba30..d8372ab6bc1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ habluetooth==3.6.0 hass-nabucasa==0.87.0 hassil==2.1.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241231.0 +home-assistant-frontend==20250102.0 home-assistant-intents==2025.1.1 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b1f9b9555d6..864a980e54c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1134,7 +1134,7 @@ hole==0.8.0 holidays==0.63 # homeassistant.components.frontend -home-assistant-frontend==20241231.0 +home-assistant-frontend==20250102.0 # homeassistant.components.conversation home-assistant-intents==2025.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80b3772500b..252db100182 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -963,7 +963,7 @@ hole==0.8.0 holidays==0.63 # homeassistant.components.frontend -home-assistant-frontend==20241231.0 +home-assistant-frontend==20250102.0 # homeassistant.components.conversation home-assistant-intents==2025.1.1 From 59f866bcf7a222684b6c1cb4720f58ed8690f773 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 2 Jan 2025 17:21:58 +0000 Subject: [PATCH 0095/2987] Bump version to 2025.1.0b6 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index d44095629f0..3bf985cfea3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index a461427b070..cc2991c3837 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.1.0b5" +version = "2025.1.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 67ec71031d4cf1b349e9bd46c6ca571603142296 Mon Sep 17 00:00:00 2001 From: Andrea Arcangeli Date: Thu, 2 Jan 2025 18:37:36 +0000 Subject: [PATCH 0096/2987] open_meteo: correct UTC timezone handling in hourly forecast (#129664) Co-authored-by: G Johansson --- .../components/open_meteo/weather.py | 13 +- .../open_meteo/snapshots/test_weather.ambr | 1070 +++++++++++++++++ tests/components/open_meteo/test_weather.py | 46 + 3 files changed, 1125 insertions(+), 4 deletions(-) create mode 100644 tests/components/open_meteo/snapshots/test_weather.ambr create mode 100644 tests/components/open_meteo/test_weather.py diff --git a/homeassistant/components/open_meteo/weather.py b/homeassistant/components/open_meteo/weather.py index a2be81f0928..1faa66c56de 100644 --- a/homeassistant/components/open_meteo/weather.py +++ b/homeassistant/components/open_meteo/weather.py @@ -2,6 +2,8 @@ from __future__ import annotations +from datetime import datetime, time + from open_meteo import Forecast as OpenMeteoForecast from homeassistant.components.weather import ( @@ -107,8 +109,9 @@ class OpenMeteoWeatherEntity( daily = self.coordinator.data.daily for index, date in enumerate(self.coordinator.data.daily.time): + _datetime = datetime.combine(date=date, time=time(0), tzinfo=dt_util.UTC) forecast = Forecast( - datetime=date.isoformat(), + datetime=_datetime.isoformat(), ) if daily.weathercode is not None: @@ -155,12 +158,14 @@ class OpenMeteoWeatherEntity( today = dt_util.utcnow() hourly = self.coordinator.data.hourly - for index, datetime in enumerate(self.coordinator.data.hourly.time): - if dt_util.as_utc(datetime) < today: + for index, _datetime in enumerate(self.coordinator.data.hourly.time): + if _datetime.tzinfo is None: + _datetime = _datetime.replace(tzinfo=dt_util.UTC) + if _datetime < today: continue forecast = Forecast( - datetime=datetime.isoformat(), + datetime=_datetime.isoformat(), ) if hourly.weather_code is not None: diff --git a/tests/components/open_meteo/snapshots/test_weather.ambr b/tests/components/open_meteo/snapshots/test_weather.ambr new file mode 100644 index 00000000000..dd5beb56d77 --- /dev/null +++ b/tests/components/open_meteo/snapshots/test_weather.ambr @@ -0,0 +1,1070 @@ +# serializer version: 1 +# name: test_forecast_service[forecast_daily] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-24T00:00:00+00:00', + 'precipitation': 0.19, + 'temperature': 7.6, + 'templow': 5.5, + 'wind_bearing': 251, + 'wind_speed': 10.9, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-25T00:00:00+00:00', + 'precipitation': 0.29, + 'temperature': 5.4, + 'templow': 0.2, + 'wind_bearing': 210, + 'wind_speed': 12.9, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-26T00:00:00+00:00', + 'precipitation': 0.76, + 'temperature': 4.8, + 'templow': 1.8, + 'wind_bearing': 230, + 'wind_speed': 14.8, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-27T00:00:00+00:00', + 'precipitation': 0.12, + 'temperature': 4.5, + 'templow': -0.1, + 'wind_bearing': 143, + 'wind_speed': 10.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-28T00:00:00+00:00', + 'precipitation': 0.15, + 'temperature': 3.4, + 'templow': -0.2, + 'wind_bearing': 143, + 'wind_speed': 7.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-11-29T00:00:00+00:00', + 'precipitation': 0.64, + 'temperature': 2.2, + 'templow': -0.5, + 'wind_bearing': 248, + 'wind_speed': 13.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T00:00:00+00:00', + 'precipitation': 1.74, + 'temperature': 3.0, + 'templow': -0.3, + 'wind_bearing': 256, + 'wind_speed': 16.1, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast_hourly] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T03:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-24T04:00:00+00:00', + 'precipitation': 0.03, + 'temperature': 6.5, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T05:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 6.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T06:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T07:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T08:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 5.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T09:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T10:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.5, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T11:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T12:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 7.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T13:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 7.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T14:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 7.6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-24T15:00:00+00:00', + 'precipitation': 0.06, + 'temperature': 7.5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-24T16:00:00+00:00', + 'precipitation': 0.06, + 'temperature': 7.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T17:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T18:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T19:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T20:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T21:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 5.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T22:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 5.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T23:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 5.5, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T00:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 5.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T01:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 5.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T02:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 5.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T03:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.9, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-11-25T04:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.2, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-25T05:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.2, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-25T06:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.2, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-25T07:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.5, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-11-25T08:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T09:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T10:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T11:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T12:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T13:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T14:00:00+00:00', + 'precipitation': 0.03, + 'temperature': 4.5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-25T15:00:00+00:00', + 'precipitation': 0.07, + 'temperature': 4.5, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T16:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T17:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T18:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.9, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-25T19:00:00+00:00', + 'precipitation': 0.09, + 'temperature': 3.9, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-25T20:00:00+00:00', + 'precipitation': 0.09, + 'temperature': 4.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T21:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 3.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T22:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T23:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T00:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T01:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T02:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T03:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T04:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T05:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T06:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T07:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T08:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T09:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T10:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.5, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T11:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T12:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T13:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T14:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T15:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 4.6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-26T16:00:00+00:00', + 'precipitation': 0.1, + 'temperature': 4.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-26T17:00:00+00:00', + 'precipitation': 0.3, + 'temperature': 3.5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-26T18:00:00+00:00', + 'precipitation': 0.2, + 'temperature': 3.3, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-26T19:00:00+00:00', + 'precipitation': 0.15, + 'temperature': 3.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T20:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T21:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T22:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T23:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T00:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 1.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T01:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T02:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T03:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T04:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T05:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T06:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.2, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-11-27T07:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T08:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T09:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T10:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.6, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-27T11:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-27T12:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.9, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-27T13:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.0, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-27T14:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.5, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-11-27T15:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T16:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-27T17:00:00+00:00', + 'precipitation': 0.1, + 'temperature': 3.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T18:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 2.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T19:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T20:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T21:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T22:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T23:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.0, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-28T00:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.6, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-28T01:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T02:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T03:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T04:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T05:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T06:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T07:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T08:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T09:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T10:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.5, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T11:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T12:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T13:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T14:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T15:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T16:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.2, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-28T17:00:00+00:00', + 'precipitation': 0.05, + 'temperature': 3.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-28T18:00:00+00:00', + 'precipitation': 0.05, + 'temperature': 2.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-28T19:00:00+00:00', + 'precipitation': 0.05, + 'temperature': 2.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T20:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T21:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.5, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T22:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T23:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T00:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T01:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T02:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T03:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T04:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T05:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T06:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T07:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.5, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-11-29T08:00:00+00:00', + 'precipitation': 0.01, + 'temperature': -0.5, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-11-29T09:00:00+00:00', + 'precipitation': 0.01, + 'temperature': -0.5, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-11-29T10:00:00+00:00', + 'precipitation': 0.01, + 'temperature': -0.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T11:00:00+00:00', + 'precipitation': 0.03, + 'temperature': 0.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T12:00:00+00:00', + 'precipitation': 0.03, + 'temperature': 1.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T13:00:00+00:00', + 'precipitation': 0.03, + 'temperature': 2.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T14:00:00+00:00', + 'precipitation': 0.02, + 'temperature': 2.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T15:00:00+00:00', + 'precipitation': 0.02, + 'temperature': 2.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T16:00:00+00:00', + 'precipitation': 0.02, + 'temperature': 1.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-29T17:00:00+00:00', + 'precipitation': 0.13, + 'temperature': 1.4, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-29T18:00:00+00:00', + 'precipitation': 0.13, + 'temperature': 1.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-29T19:00:00+00:00', + 'precipitation': 0.13, + 'temperature': 0.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T20:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T21:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T22:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T23:00:00+00:00', + 'precipitation': 0.07, + 'temperature': 1.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-30T00:00:00+00:00', + 'precipitation': 0.07, + 'temperature': 1.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-30T01:00:00+00:00', + 'precipitation': 0.07, + 'temperature': 1.6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T02:00:00+00:00', + 'precipitation': 0.16, + 'temperature': 1.5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T03:00:00+00:00', + 'precipitation': 0.16, + 'temperature': 1.5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T04:00:00+00:00', + 'precipitation': 0.16, + 'temperature': 1.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-30T05:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 1.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-30T06:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 0.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-30T07:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 0.5, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-11-30T08:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.1, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-11-30T09:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.2, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-11-30T10:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.3, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-30T11:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.2, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-30T12:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.0, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-30T13:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-30T14:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-30T15:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-30T16:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T17:00:00+00:00', + 'precipitation': 0.03, + 'temperature': 2.6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T18:00:00+00:00', + 'precipitation': 0.03, + 'temperature': 2.5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T19:00:00+00:00', + 'precipitation': 0.03, + 'temperature': 2.4, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T20:00:00+00:00', + 'precipitation': 0.04, + 'temperature': 2.5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T21:00:00+00:00', + 'precipitation': 0.04, + 'temperature': 2.8, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T22:00:00+00:00', + 'precipitation': 0.04, + 'temperature': 3.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T23:00:00+00:00', + 'precipitation': 0.88, + 'temperature': 3.0, + }), + ]), + }), + }) +# --- diff --git a/tests/components/open_meteo/test_weather.py b/tests/components/open_meteo/test_weather.py new file mode 100644 index 00000000000..b43385c924a --- /dev/null +++ b/tests/components/open_meteo/test_weather.py @@ -0,0 +1,46 @@ +"""Test for the open meteo weather entity.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.weather import ( + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.freeze_time("2021-11-24T03:00:00+00:00") +async def test_forecast_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_open_meteo: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test forecast service.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + {ATTR_ENTITY_ID: "weather.home", "type": "daily"}, + blocking=True, + return_response=True, + ) + assert response == snapshot(name="forecast_daily") + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + {ATTR_ENTITY_ID: "weather.home", "type": "hourly"}, + blocking=True, + return_response=True, + ) + assert response == snapshot(name="forecast_hourly") From 61ac8e7e8cc606061932aea2a12560deb66f17fb Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Thu, 2 Jan 2025 19:34:51 +0100 Subject: [PATCH 0097/2987] Include host in Peblar EV-Charger discovery setup description (#133954) Co-authored-by: Franck Nijhof --- .../components/peblar/config_flow.py | 21 ++++++++++++++----- homeassistant/components/peblar/strings.json | 2 +- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/peblar/config_flow.py b/homeassistant/components/peblar/config_flow.py index 29bf456b7ea..24248355f72 100644 --- a/homeassistant/components/peblar/config_flow.py +++ b/homeassistant/components/peblar/config_flow.py @@ -27,7 +27,7 @@ class PeblarFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - _host: str + _discovery_info: zeroconf.ZeroconfServiceInfo async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -137,8 +137,15 @@ class PeblarFlowHandler(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(sn) self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) - self._host = discovery_info.host - self.context.update({"configuration_url": f"http://{discovery_info.host}"}) + self._discovery_info = discovery_info + self.context.update( + { + "title_placeholders": { + "name": discovery_info.name.replace("._http._tcp.local.", "") + }, + "configuration_url": f"http://{discovery_info.host}", + }, + ) return await self.async_step_zeroconf_confirm() async def async_step_zeroconf_confirm( @@ -149,7 +156,7 @@ class PeblarFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: peblar = Peblar( - host=self._host, + host=self._discovery_info.host, session=async_create_clientsession( self.hass, cookie_jar=CookieJar(unsafe=True) ), @@ -165,7 +172,7 @@ class PeblarFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title="Peblar", data={ - CONF_HOST: self._host, + CONF_HOST: self._discovery_info.host, CONF_PASSWORD: user_input[CONF_PASSWORD], }, ) @@ -179,6 +186,10 @@ class PeblarFlowHandler(ConfigFlow, domain=DOMAIN): ), } ), + description_placeholders={ + "hostname": self._discovery_info.name.replace("._http._tcp.local.", ""), + "host": self._discovery_info.host, + }, errors=errors, ) diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index f6a228ca236..3fcd7a14664 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -51,7 +51,7 @@ "data_description": { "password": "[%key:component::peblar::config::step::user::data_description::password%]" }, - "description": "Set up your Peblar EV charger to integrate with Home Assistant.\n\nTo do so, you will need the password you use to log into the Peblar EV charger' web interface.\n\nHome Assistant will automatically configure your Peblar EV charger for use with Home Assistant." + "description": "Set up your Peblar EV charger {hostname}, on IP address {host}, to integrate with Home Assistant\n\nTo do so, you will need the password you use to log into the Peblar EV charger' web interface.\n\nHome Assistant will automatically configure your Peblar EV charger for use with Home Assistant." } } }, From 995e2229597158f81ddc54796d4d426657603876 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Jan 2025 18:56:23 +0100 Subject: [PATCH 0098/2987] Don't start recorder if a database from the future is used (#134467) --- homeassistant/components/recorder/core.py | 10 ++++++ tests/components/recorder/test_init.py | 40 +++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 61c64be105c..e027922e8c4 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -719,6 +719,16 @@ class Recorder(threading.Thread): if schema_status is None: # Give up if we could not validate the schema return + if schema_status.current_version > SCHEMA_VERSION: + _LOGGER.error( + "The database schema version %s is newer than %s which is the maximum " + "database schema version supported by the installed version of " + "Home Assistant Core, either upgrade Home Assistant Core or restore " + "the database from a backup compatible with this version", + schema_status.current_version, + SCHEMA_VERSION, + ) + return self.schema_version = schema_status.current_version if not schema_status.migration_needed and not schema_status.schema_errors: diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 7e5abf1b514..2e9e9a7c729 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -2615,6 +2615,46 @@ async def test_clean_shutdown_when_schema_migration_fails( assert instance.engine is None +async def test_setup_fails_after_downgrade( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test we fail to setup after a downgrade. + + Also test we shutdown cleanly. + """ + with ( + patch.object( + migration, + "_get_current_schema_version", + side_effect=[None, SCHEMA_VERSION + 1], + ), + patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True), + ): + if recorder.DOMAIN not in hass.data: + recorder_helper.async_initialize_recorder(hass) + assert not await async_setup_component( + hass, + recorder.DOMAIN, + { + recorder.DOMAIN: { + CONF_DB_URL: "sqlite://", + CONF_DB_RETRY_WAIT: 0, + CONF_DB_MAX_RETRIES: 1, + } + }, + ) + await hass.async_block_till_done() + + instance = recorder.get_instance(hass) + await hass.async_stop() + assert instance.engine is None + assert ( + f"The database schema version {SCHEMA_VERSION+1} is newer than {SCHEMA_VERSION}" + " which is the maximum database schema version supported by the installed " + "version of Home Assistant Core" + ) in caplog.text + + async def test_events_are_recorded_until_final_write( hass: HomeAssistant, async_setup_recorder_instance: RecorderInstanceGenerator, From 5ac4d5bef7e0bd42952e316aefebacc63ec6c67d Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 2 Jan 2025 18:54:27 +0100 Subject: [PATCH 0099/2987] Bump deebot-client to 10.1.0 (#134470) --- 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 3a2d4e7704b..67d18c4784c 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==10.0.1"] + "requirements": ["py-sucks==0.9.10", "deebot-client==10.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 864a980e54c..ef819c7b25c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -738,7 +738,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==10.0.1 +deebot-client==10.1.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 252db100182..8cce8edcee9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -628,7 +628,7 @@ dbus-fast==2.24.3 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==10.0.1 +deebot-client==10.1.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 9b906e94c7eb8973a2c13d471e37d241002f0bad Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 2 Jan 2025 21:17:29 +0100 Subject: [PATCH 0100/2987] Fix a few small typos in peblar (#134481) --- homeassistant/components/peblar/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index 3fcd7a14664..fffa2b08d85 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -20,7 +20,7 @@ "data_description": { "password": "[%key:component::peblar::config::step::user::data_description::password%]" }, - "description": "Reauthenticate with your Peblar EV charger.\n\nTo do so, you will need to enter your new password you use to log into Peblar EV charger' web interface." + "description": "Reauthenticate with your Peblar EV charger.\n\nTo do so, you will need to enter your new password you use to log in to the Peblar EV charger's web interface." }, "reconfigure": { "data": { @@ -31,7 +31,7 @@ "host": "[%key:component::peblar::config::step::user::data_description::host%]", "password": "[%key:component::peblar::config::step::user::data_description::password%]" }, - "description": "Reconfigure your Peblar EV charger.\n\nThis allows you to change the IP address of your Peblar EV charger and the password you use to log into its web interface." + "description": "Reconfigure your Peblar EV charger.\n\nThis allows you to change the IP address of your Peblar EV charger and the password you use to log in to its web interface." }, "user": { "data": { @@ -40,9 +40,9 @@ }, "data_description": { "host": "The hostname or IP address of your Peblar EV charger on your home network.", - "password": "The same password as you use to log in to the Peblar EV charger' local web interface." + "password": "The same password as you use to log in to the Peblar EV charger's local web interface." }, - "description": "Set up your Peblar EV charger to integrate with Home Assistant.\n\nTo do so, you will need to get the IP address of your Peblar EV charger and the password you use to log into its web interface.\n\nHome Assistant will automatically configure your Peblar EV charger for use with Home Assistant." + "description": "Set up your Peblar EV charger to integrate with Home Assistant.\n\nTo do so, you will need to get the IP address of your Peblar EV charger and the password you use to log in to its web interface.\n\nHome Assistant will automatically configure your Peblar EV charger for use with Home Assistant." }, "zeroconf_confirm": { "data": { @@ -51,7 +51,7 @@ "data_description": { "password": "[%key:component::peblar::config::step::user::data_description::password%]" }, - "description": "Set up your Peblar EV charger {hostname}, on IP address {host}, to integrate with Home Assistant\n\nTo do so, you will need the password you use to log into the Peblar EV charger' web interface.\n\nHome Assistant will automatically configure your Peblar EV charger for use with Home Assistant." + "description": "Set up your Peblar EV charger {hostname}, on IP address {host}, to integrate with Home Assistant\n\nTo do so, you will need the password you use to log in to the Peblar EV charger's web interface.\n\nHome Assistant will automatically configure your Peblar EV charger for use with Home Assistant." } } }, From 7fa1983da051ff4808e3ae1dfba2ab0912b17514 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 2 Jan 2025 21:41:54 +0100 Subject: [PATCH 0101/2987] Update peblar to 0.3.1 (#134486) --- homeassistant/components/peblar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/peblar/manifest.json b/homeassistant/components/peblar/manifest.json index ab5572e66d0..76e228351e5 100644 --- a/homeassistant/components/peblar/manifest.json +++ b/homeassistant/components/peblar/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["peblar==0.3.0"], + "requirements": ["peblar==0.3.1"], "zeroconf": [{ "type": "_http._tcp.local.", "name": "pblr-*" }] } diff --git a/requirements_all.txt b/requirements_all.txt index ef819c7b25c..8a5b3d85546 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1603,7 +1603,7 @@ panasonic-viera==0.4.2 pdunehd==1.3.2 # homeassistant.components.peblar -peblar==0.3.0 +peblar==0.3.1 # homeassistant.components.peco peco==0.0.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8cce8edcee9..7c5f2e9306e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1330,7 +1330,7 @@ panasonic-viera==0.4.2 pdunehd==1.3.2 # homeassistant.components.peblar -peblar==0.3.0 +peblar==0.3.1 # homeassistant.components.peco peco==0.0.30 From 47190e4ac16af6ddb2e2c99e21917014bd1001e7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 2 Jan 2025 22:23:54 +0000 Subject: [PATCH 0102/2987] Bump version to 2025.1.0b7 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3bf985cfea3..a09482f3bd2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0b7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index cc2991c3837..8f6b72462ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.1.0b6" +version = "2025.1.0b7" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From f364e2914814a806a4af2165bba1973107899514 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 2 Jan 2025 23:45:00 +0100 Subject: [PATCH 0103/2987] Fix input_datetime.set_datetime not accepting 0 timestamp value (#134489) --- .../components/input_datetime/__init__.py | 2 +- tests/components/input_datetime/test_init.py | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index dcc2865acad..428ffccb7c1 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -385,7 +385,7 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity): @callback def async_set_datetime(self, date=None, time=None, datetime=None, timestamp=None): """Set a new date / time.""" - if timestamp: + if timestamp is not None: datetime = dt_util.as_local(dt_util.utc_from_timestamp(timestamp)) if datetime: diff --git a/tests/components/input_datetime/test_init.py b/tests/components/input_datetime/test_init.py index 411f084d39a..7d491f0cdcd 100644 --- a/tests/components/input_datetime/test_init.py +++ b/tests/components/input_datetime/test_init.py @@ -217,6 +217,34 @@ async def test_set_datetime_3(hass: HomeAssistant) -> None: assert state.attributes["timestamp"] == dt_obj.timestamp() +async def test_set_datetime_4(hass: HomeAssistant) -> None: + """Test set_datetime method using timestamp 0.""" + await async_setup_component( + hass, DOMAIN, {DOMAIN: {"test_datetime": {"has_time": True, "has_date": True}}} + ) + + entity_id = "input_datetime.test_datetime" + + dt_obj = datetime.datetime( + 1969, 12, 31, 16, 00, 00, tzinfo=dt_util.get_time_zone(hass.config.time_zone) + ) + + await async_set_timestamp(hass, entity_id, 0) + + state = hass.states.get(entity_id) + assert state.state == dt_obj.strftime(FORMAT_DATETIME) + assert state.attributes["has_time"] + assert state.attributes["has_date"] + + assert state.attributes["year"] == 1969 + assert state.attributes["month"] == 12 + assert state.attributes["day"] == 31 + assert state.attributes["hour"] == 16 + assert state.attributes["minute"] == 00 + assert state.attributes["second"] == 0 + assert state.attributes["timestamp"] == 0 + + async def test_set_datetime_time(hass: HomeAssistant) -> None: """Test set_datetime method with only time.""" await async_setup_component( From 59a3fe857b34e7e373137fcc0cbc34c3cc262534 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 2 Jan 2025 23:28:29 +0100 Subject: [PATCH 0104/2987] Bump aioacaia to 0.1.13 (#134496) --- homeassistant/components/acaia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/acaia/manifest.json b/homeassistant/components/acaia/manifest.json index fef8c1219a8..681f3f08555 100644 --- a/homeassistant/components/acaia/manifest.json +++ b/homeassistant/components/acaia/manifest.json @@ -26,5 +26,5 @@ "iot_class": "local_push", "loggers": ["aioacaia"], "quality_scale": "platinum", - "requirements": ["aioacaia==0.1.12"] + "requirements": ["aioacaia==0.1.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8a5b3d85546..166e5426553 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -173,7 +173,7 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.12 +aioacaia==0.1.13 # homeassistant.components.airq aioairq==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7c5f2e9306e..2e3a5348473 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -161,7 +161,7 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.12 +aioacaia==0.1.13 # homeassistant.components.airq aioairq==0.4.3 From e1f647562312c0a53c36bf4f2f7d2887d87b0260 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Jan 2025 00:21:19 -0500 Subject: [PATCH 0105/2987] Fix backup dir not existing (#134506) --- homeassistant/components/backup/manager.py | 1 + tests/components/backup/test_manager.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 33405d97883..4d509003a21 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1294,6 +1294,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): if self._local_agent_id in agent_ids: local_agent = manager.local_backup_agents[self._local_agent_id] tar_file_path = local_agent.get_backup_path(backup.backup_id) + await async_add_executor_job(make_backup_dir, tar_file_path.parent) await async_add_executor_job(shutil.move, temp_file, tar_file_path) else: tar_file_path = temp_file diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 0797eef2274..ad90e2e23bf 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -1397,6 +1397,9 @@ async def test_receive_backup( with ( patch("pathlib.Path.open", open_mock), + patch( + "homeassistant.components.backup.manager.make_backup_dir" + ) as make_backup_dir_mock, patch("shutil.move") as move_mock, patch( "homeassistant.components.backup.manager.read_backup", @@ -1412,6 +1415,7 @@ async def test_receive_backup( assert resp.status == 201 assert open_mock.call_count == open_call_count + assert make_backup_dir_mock.call_count == move_call_count + 1 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 From 1b67d51e24e619cf64f15342244c5d681e684147 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 3 Jan 2025 10:01:35 +0100 Subject: [PATCH 0106/2987] Add error prints for recorder fatal errors (#134517) --- homeassistant/components/recorder/core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index e027922e8c4..fee72ce273f 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -712,12 +712,14 @@ class Recorder(threading.Thread): setup_result = self._setup_recorder() if not setup_result: + _LOGGER.error("Recorder setup failed, recorder shutting down") # Give up if we could not connect return schema_status = migration.validate_db_schema(self.hass, self, self.get_session) if schema_status is None: # Give up if we could not validate the schema + _LOGGER.error("Failed to validate schema, recorder shutting down") return if schema_status.current_version > SCHEMA_VERSION: _LOGGER.error( From a830a1434238971f41f1140919bc08525720d012 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 3 Jan 2025 10:05:07 +0100 Subject: [PATCH 0107/2987] Improve recorder schema migration error test (#134518) --- tests/components/recorder/test_init.py | 30 +++++++++++++++----------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 2e9e9a7c729..74d8861ae1e 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -9,7 +9,7 @@ import sqlite3 import sys import threading from typing import Any, cast -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -2575,23 +2575,25 @@ async def test_clean_shutdown_when_recorder_thread_raises_during_validate_db_sch @pytest.mark.parametrize( ("func_to_patch", "expected_setup_result"), - [("migrate_schema_non_live", False), ("migrate_schema_live", False)], + [ + ("migrate_schema_non_live", False), + ("migrate_schema_live", True), + ], ) async def test_clean_shutdown_when_schema_migration_fails( - hass: HomeAssistant, func_to_patch: str, expected_setup_result: bool + hass: HomeAssistant, + func_to_patch: str, + expected_setup_result: bool, + caplog: pytest.LogCaptureFixture, ) -> None: """Test we still shutdown cleanly when schema migration fails.""" with ( - patch.object( - migration, - "validate_db_schema", - return_value=MagicMock(valid=False, current_version=1), - ), + patch.object(migration, "_get_current_schema_version", side_effect=[None, 1]), patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True), patch.object( migration, func_to_patch, - side_effect=Exception, + side_effect=Exception("Boom!"), ), ): if recorder.DOMAIN not in hass.data: @@ -2610,9 +2612,13 @@ async def test_clean_shutdown_when_schema_migration_fails( assert setup_result == expected_setup_result await hass.async_block_till_done() - instance = recorder.get_instance(hass) - await hass.async_stop() - assert instance.engine is None + instance = recorder.get_instance(hass) + await hass.async_stop() + assert instance.engine is None + + assert "Error during schema migration" in caplog.text + # Check the injected exception was logged + assert "Boom!" in caplog.text async def test_setup_fails_after_downgrade( From f719a1453777396e72b5f837df5cb1817bbb1044 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 3 Jan 2025 10:51:20 +0100 Subject: [PATCH 0108/2987] Handle deCONZ color temp 0 is never used when calculating kelvin CT (#134521) --- homeassistant/components/deconz/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index b1df32efc31..d82c05f14eb 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -266,7 +266,7 @@ class DeconzBaseLight[_LightDeviceT: Group | Light]( @property def color_temp_kelvin(self) -> int | None: """Return the CT color value.""" - if self._device.color_temp is None: + if self._device.color_temp is None or self._device.color_temp == 0: return None return color_temperature_mired_to_kelvin(self._device.color_temp) From 316f93f2083ceee69f9f0965f899aa2df32cf8ec Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 3 Jan 2025 10:29:29 +0100 Subject: [PATCH 0109/2987] Fix activating backup retention config on startup (#134523) --- homeassistant/components/backup/config.py | 6 + tests/components/backup/test_websocket.py | 319 ++++++++++++++++------ 2 files changed, 239 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index d58c7365c8a..3c5d5d39f7e 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -124,6 +124,7 @@ class BackupConfig: def load(self, stored_config: StoredBackupConfig) -> None: """Load config.""" self.data = BackupConfigData.from_dict(stored_config) + self.data.retention.apply(self._manager) self.data.schedule.apply(self._manager) async def update( @@ -160,8 +161,13 @@ class RetentionConfig: def apply(self, manager: BackupManager) -> None: """Apply backup retention configuration.""" if self.days is not None: + LOGGER.debug( + "Scheduling next automatic delete of backups older than %s in 1 day", + self.days, + ) self._schedule_next(manager) else: + LOGGER.debug("Unscheduling next automatic delete") self._unschedule_next(manager) def to_dict(self) -> StoredRetentionConfig: diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index a3b29a55ad8..307a1d79e0c 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -1173,7 +1173,7 @@ async def test_config_update_errors( @pytest.mark.parametrize( ( - "command", + "commands", "last_completed_automatic_backup", "time_1", "time_2", @@ -1186,11 +1186,8 @@ async def test_config_update_errors( ), [ ( - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", - }, + # No config update + [], "2024-11-11T04:45:00+01:00", "2024-11-12T04:45:00+01:00", "2024-11-13T04:45:00+01:00", @@ -1202,11 +1199,32 @@ async def test_config_update_errors( None, ), ( - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "mon", - }, + # Unchanged schedule + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": "daily", + } + ], + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-13T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 2, + BACKUP_CALL, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": "mon", + } + ], "2024-11-11T04:45:00+01:00", "2024-11-18T04:45:00+01:00", "2024-11-25T04:45:00+01:00", @@ -1218,11 +1236,13 @@ async def test_config_update_errors( None, ), ( - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "never", - }, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": "never", + } + ], "2024-11-11T04:45:00+01:00", "2034-11-11T12:00:00+01:00", # ten years later and still no backups "2034-11-11T13:00:00+01:00", @@ -1234,11 +1254,13 @@ async def test_config_update_errors( None, ), ( - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", - }, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": "daily", + } + ], "2024-10-26T04:45:00+01:00", "2024-11-12T04:45:00+01:00", "2024-11-13T04:45:00+01:00", @@ -1250,11 +1272,13 @@ async def test_config_update_errors( None, ), ( - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "mon", - }, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": "mon", + } + ], "2024-10-26T04:45:00+01:00", "2024-11-12T04:45:00+01:00", "2024-11-13T04:45:00+01:00", @@ -1266,11 +1290,13 @@ async def test_config_update_errors( None, ), ( - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "never", - }, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": "never", + } + ], "2024-10-26T04:45:00+01:00", "2034-11-11T12:00:00+01:00", # ten years later and still no backups "2034-11-12T12:00:00+01:00", @@ -1282,11 +1308,13 @@ async def test_config_update_errors( None, ), ( - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", - }, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": "daily", + } + ], "2024-11-11T04:45:00+01:00", "2024-11-12T04:45:00+01:00", "2024-11-13T04:45:00+01:00", @@ -1298,11 +1326,13 @@ async def test_config_update_errors( [BackupReaderWriterError("Boom"), None], ), ( - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", - }, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": "daily", + } + ], "2024-11-11T04:45:00+01:00", "2024-11-12T04:45:00+01:00", "2024-11-13T04:45:00+01:00", @@ -1321,7 +1351,7 @@ async def test_config_schedule_logic( freezer: FrozenDateTimeFactory, hass_storage: dict[str, Any], create_backup: AsyncMock, - command: dict[str, Any], + commands: list[dict[str, Any]], last_completed_automatic_backup: str, time_1: str, time_2: str, @@ -1338,7 +1368,7 @@ async def test_config_schedule_logic( "backups": {}, "config": { "create_backup": { - "agent_ids": ["test-agent"], + "agent_ids": ["test.test-agent"], "include_addons": ["test-addon"], "include_all_addons": False, "include_database": True, @@ -1364,10 +1394,10 @@ async def test_config_schedule_logic( await setup_backup_integration(hass, remote_agents=["test-agent"]) await hass.async_block_till_done() - await client.send_json_auto_id(command) - result = await client.receive_json() - - assert result["success"] + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] freezer.move_to(time_1) async_fire_time_changed(hass) @@ -2097,7 +2127,8 @@ async def test_config_retention_copies_logic_manual_backup( @pytest.mark.parametrize( ( - "command", + "stored_retained_days", + "commands", "backups", "get_backups_agent_errors", "delete_backup_agent_errors", @@ -2109,13 +2140,77 @@ async def test_config_retention_copies_logic_manual_backup( "delete_args_list", ), [ + # No config update - cleanup backups older than 2 days ( + 2, + [], { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": 2}, - "schedule": "never", + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + 1, + [call("backup-1")], + ), + # No config update - No cleanup + ( + None, + [], + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 0, + 0, + [], + ), + # Unchanged config + ( + 2, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": "never", + } + ], { "backup-1": MagicMock( date="2024-11-10T04:45:00+01:00", @@ -2143,12 +2238,51 @@ async def test_config_retention_copies_logic_manual_backup( [call("backup-1")], ), ( + None, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": "never", + } + ], { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": 3}, - "schedule": "never", + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + 1, + [call("backup-1")], + ), + ( + None, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 3}, + "schedule": "never", + } + ], { "backup-1": MagicMock( date="2024-11-10T04:45:00+01:00", @@ -2176,12 +2310,15 @@ async def test_config_retention_copies_logic_manual_backup( [], ), ( - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": 2}, - "schedule": "never", - }, + None, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": "never", + } + ], { "backup-1": MagicMock( date="2024-11-09T04:45:00+01:00", @@ -2214,12 +2351,15 @@ async def test_config_retention_copies_logic_manual_backup( [call("backup-1"), call("backup-2")], ), ( - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": 2}, - "schedule": "never", - }, + None, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": "never", + } + ], { "backup-1": MagicMock( date="2024-11-10T04:45:00+01:00", @@ -2247,12 +2387,15 @@ async def test_config_retention_copies_logic_manual_backup( [call("backup-1")], ), ( - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": 2}, - "schedule": "never", - }, + None, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": "never", + } + ], { "backup-1": MagicMock( date="2024-11-10T04:45:00+01:00", @@ -2280,12 +2423,15 @@ async def test_config_retention_copies_logic_manual_backup( [call("backup-1")], ), ( - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": 0}, - "schedule": "never", - }, + None, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 0}, + "schedule": "never", + } + ], { "backup-1": MagicMock( date="2024-11-09T04:45:00+01:00", @@ -2326,7 +2472,8 @@ async def test_config_retention_days_logic( hass_storage: dict[str, Any], delete_backup: AsyncMock, get_backups: AsyncMock, - command: dict[str, Any], + stored_retained_days: int | None, + commands: list[dict[str, Any]], backups: dict[str, Any], get_backups_agent_errors: dict[str, Exception], delete_backup_agent_errors: dict[str, Exception], @@ -2351,7 +2498,7 @@ async def test_config_retention_days_logic( "name": "test-name", "password": "test-password", }, - "retention": {"copies": None, "days": None}, + "retention": {"copies": None, "days": stored_retained_days}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": last_backup_time, "schedule": {"state": "never"}, @@ -2370,10 +2517,10 @@ async def test_config_retention_days_logic( await setup_backup_integration(hass) await hass.async_block_till_done() - await client.send_json_auto_id(command) - result = await client.receive_json() - - assert result["success"] + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] freezer.move_to(next_time) async_fire_time_changed(hass) From 96936f5f4a310119bdd265a5a1386133485c26a6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 3 Jan 2025 10:37:39 +0100 Subject: [PATCH 0110/2987] Update peblar to v0.3.2 (#134524) --- homeassistant/components/peblar/manifest.json | 2 +- homeassistant/components/peblar/update.py | 6 ++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/peblar/manifest.json b/homeassistant/components/peblar/manifest.json index 76e228351e5..2c3e73ba76e 100644 --- a/homeassistant/components/peblar/manifest.json +++ b/homeassistant/components/peblar/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["peblar==0.3.1"], + "requirements": ["peblar==0.3.2"], "zeroconf": [{ "type": "_http._tcp.local.", "name": "pblr-*" }] } diff --git a/homeassistant/components/peblar/update.py b/homeassistant/components/peblar/update.py index 67ce30a89a6..29dfbfdcd47 100644 --- a/homeassistant/components/peblar/update.py +++ b/homeassistant/components/peblar/update.py @@ -27,8 +27,9 @@ PARALLEL_UPDATES = 1 class PeblarUpdateEntityDescription(UpdateEntityDescription): """Describe an Peblar update entity.""" - installed_fn: Callable[[PeblarVersionInformation], str | None] available_fn: Callable[[PeblarVersionInformation], str | None] + has_fn: Callable[[PeblarVersionInformation], bool] = lambda _: True + installed_fn: Callable[[PeblarVersionInformation], str | None] DESCRIPTIONS: tuple[PeblarUpdateEntityDescription, ...] = ( @@ -41,8 +42,9 @@ DESCRIPTIONS: tuple[PeblarUpdateEntityDescription, ...] = ( PeblarUpdateEntityDescription( key="customization", translation_key="customization", - installed_fn=lambda x: x.current.customization, available_fn=lambda x: x.available.customization, + has_fn=lambda x: x.current.customization is not None, + installed_fn=lambda x: x.current.customization, ), ) diff --git a/requirements_all.txt b/requirements_all.txt index 166e5426553..0363d3d2650 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1603,7 +1603,7 @@ panasonic-viera==0.4.2 pdunehd==1.3.2 # homeassistant.components.peblar -peblar==0.3.1 +peblar==0.3.2 # homeassistant.components.peco peco==0.0.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e3a5348473..ec70c179e15 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1330,7 +1330,7 @@ panasonic-viera==0.4.2 pdunehd==1.3.2 # homeassistant.components.peblar -peblar==0.3.1 +peblar==0.3.2 # homeassistant.components.peco peco==0.0.30 From ea82c1b73e0b5f13a276def20a0e7d998d7477e0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 3 Jan 2025 10:51:05 +0100 Subject: [PATCH 0111/2987] Only load Peblar customization update entity when present (#134526) --- homeassistant/components/peblar/update.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/peblar/update.py b/homeassistant/components/peblar/update.py index 29dfbfdcd47..77879030f6c 100644 --- a/homeassistant/components/peblar/update.py +++ b/homeassistant/components/peblar/update.py @@ -62,6 +62,7 @@ async def async_setup_entry( description=description, ) for description in DESCRIPTIONS + if description.has_fn(entry.runtime_data.version_coordinator.data) ) From 1af384bc0adce8597e466dda11c117e81913421c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 3 Jan 2025 09:56:51 +0000 Subject: [PATCH 0112/2987] Bump version to 2025.1.0b8 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index a09482f3bd2..5898c682d89 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b7" +PATCH_VERSION: Final = "0b8" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 8f6b72462ef..1d6fbc8cefe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.1.0b7" +version = "2025.1.0b8" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From c5746291cc23225825a58110c18570a69802d10a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 3 Jan 2025 14:24:39 +0100 Subject: [PATCH 0113/2987] Add Reolink proxy for playback (#133916) --- homeassistant/components/reolink/__init__.py | 3 + .../components/reolink/manifest.json | 2 +- .../components/reolink/media_source.py | 31 +-- homeassistant/components/reolink/util.py | 13 + homeassistant/components/reolink/views.py | 147 +++++++++++ tests/components/reolink/test_views.py | 243 ++++++++++++++++++ 6 files changed, 418 insertions(+), 21 deletions(-) create mode 100644 homeassistant/components/reolink/views.py create mode 100644 tests/components/reolink/test_views.py diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 29dfb4ee57b..dd791bbaf1a 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -27,6 +27,7 @@ from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .host import ReolinkHost from .services import async_setup_services from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch +from .views import PlaybackProxyView _LOGGER = logging.getLogger(__name__) @@ -189,6 +190,8 @@ async def async_setup_entry( migrate_entity_ids(hass, config_entry.entry_id, host) + hass.http.register_view(PlaybackProxyView(hass)) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) config_entry.async_on_unload( diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 7d01ca808e1..bb6b668368b 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -3,7 +3,7 @@ "name": "Reolink", "codeowners": ["@starkillerOG"], "config_flow": true, - "dependencies": ["webhook"], + "dependencies": ["http", "webhook"], "dhcp": [ { "hostname": "reolink*" diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 538a06a08f8..e912bfb5100 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -23,8 +23,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import DOMAIN -from .host import ReolinkHost -from .util import ReolinkConfigEntry +from .util import get_host +from .views import async_generate_playback_proxy_url _LOGGER = logging.getLogger(__name__) @@ -47,15 +47,6 @@ def res_name(stream: str) -> str: return "Low res." -def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost: - """Return the Reolink host from the config entry id.""" - config_entry: ReolinkConfigEntry | None = hass.config_entries.async_get_entry( - config_entry_id - ) - assert config_entry is not None - return config_entry.runtime_data.host - - class ReolinkVODMediaSource(MediaSource): """Provide Reolink camera VODs as media sources.""" @@ -90,22 +81,22 @@ class ReolinkVODMediaSource(MediaSource): vod_type = get_vod_type() + if vod_type in [VodRequestType.DOWNLOAD, VodRequestType.PLAYBACK]: + proxy_url = async_generate_playback_proxy_url( + config_entry_id, channel, filename, stream_res, vod_type.value + ) + return PlayMedia(proxy_url, "video/mp4") + mime_type, url = await host.api.get_vod_source( channel, filename, stream_res, vod_type ) if _LOGGER.isEnabledFor(logging.DEBUG): - url_log = url - if "&user=" in url_log: - url_log = f"{url_log.split('&user=')[0]}&user=xxxxx&password=xxxxx" - elif "&token=" in url_log: - url_log = f"{url_log.split('&token=')[0]}&token=xxxxx" _LOGGER.debug( - "Opening VOD stream from %s: %s", host.api.camera_name(channel), url_log + "Opening VOD stream from %s: %s", + host.api.camera_name(channel), + host.api.hide_password(url), ) - if mime_type == "video/mp4": - return PlayMedia(url, mime_type) - stream = create_stream(self.hass, url, {}, DynamicStreamSettings()) stream.add_provider("hls", timeout=3600) stream_url: str = stream.endpoint_url("hls") diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index 1a6eab3f61d..f52cb08286c 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -22,6 +22,7 @@ from reolink_aio.exceptions import ( ) from homeassistant import config_entries +from homeassistant.components.media_source import Unresolvable from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr @@ -51,6 +52,18 @@ def is_connected(hass: HomeAssistant, config_entry: config_entries.ConfigEntry) ) +def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost: + """Return the Reolink host from the config entry id.""" + config_entry: ReolinkConfigEntry | None = hass.config_entries.async_get_entry( + config_entry_id + ) + if config_entry is None: + raise Unresolvable( + f"Could not find Reolink config entry id '{config_entry_id}'." + ) + return config_entry.runtime_data.host + + def get_device_uid_and_ch( device: dr.DeviceEntry, host: ReolinkHost ) -> tuple[list[str], int | None, bool]: diff --git a/homeassistant/components/reolink/views.py b/homeassistant/components/reolink/views.py new file mode 100644 index 00000000000..3b32ebaf74e --- /dev/null +++ b/homeassistant/components/reolink/views.py @@ -0,0 +1,147 @@ +"""Reolink Integration views.""" + +from __future__ import annotations + +from http import HTTPStatus +import logging +from urllib import parse + +from aiohttp import ClientError, ClientTimeout, web +from reolink_aio.enums import VodRequestType +from reolink_aio.exceptions import ReolinkError + +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.media_source import Unresolvable +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.ssl import SSLCipherList + +from .util import get_host + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_generate_playback_proxy_url( + config_entry_id: str, channel: int, filename: str, stream_res: str, vod_type: str +) -> str: + """Generate proxy URL for event video.""" + + url_format = PlaybackProxyView.url + return url_format.format( + config_entry_id=config_entry_id, + channel=channel, + filename=parse.quote(filename, safe=""), + stream_res=stream_res, + vod_type=vod_type, + ) + + +class PlaybackProxyView(HomeAssistantView): + """View to proxy playback video from Reolink.""" + + requires_auth = True + url = "/api/reolink/video/{config_entry_id}/{channel}/{stream_res}/{vod_type}/{filename}" + name = "api:reolink_playback" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize a proxy view.""" + self.hass = hass + self.session = async_get_clientsession( + hass, + verify_ssl=False, + ssl_cipher=SSLCipherList.INSECURE, + ) + + async def get( + self, + request: web.Request, + config_entry_id: str, + channel: str, + stream_res: str, + vod_type: str, + filename: str, + retry: int = 2, + ) -> web.StreamResponse: + """Get playback proxy video response.""" + retry = retry - 1 + + filename = parse.unquote(filename) + ch = int(channel) + try: + host = get_host(self.hass, config_entry_id) + except Unresolvable: + err_str = f"Reolink playback proxy could not find config entry id: {config_entry_id}" + _LOGGER.warning(err_str) + return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST) + + try: + mime_type, reolink_url = await host.api.get_vod_source( + ch, filename, stream_res, VodRequestType(vod_type) + ) + except ReolinkError as err: + _LOGGER.warning("Reolink playback proxy error: %s", str(err)) + return web.Response(body=str(err), status=HTTPStatus.BAD_REQUEST) + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Opening VOD stream from %s: %s", + host.api.camera_name(ch), + host.api.hide_password(reolink_url), + ) + + try: + reolink_response = await self.session.get( + reolink_url, + timeout=ClientTimeout( + connect=15, sock_connect=15, sock_read=5, total=None + ), + ) + except ClientError as err: + err_str = host.api.hide_password( + f"Reolink playback error while getting mp4: {err!s}" + ) + if retry <= 0: + _LOGGER.warning(err_str) + return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST) + _LOGGER.debug("%s, renewing token", err_str) + await host.api.expire_session(unsubscribe=False) + return await self.get( + request, config_entry_id, channel, stream_res, vod_type, filename, retry + ) + + # Reolink typo "apolication/octet-stream" instead of "application/octet-stream" + if reolink_response.content_type not in [ + "video/mp4", + "application/octet-stream", + "apolication/octet-stream", + ]: + err_str = f"Reolink playback expected video/mp4 but got {reolink_response.content_type}" + _LOGGER.error(err_str) + return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST) + + response = web.StreamResponse( + status=200, + reason="OK", + headers={ + "Content-Type": "video/mp4", + }, + ) + + if reolink_response.content_length is not None: + response.content_length = reolink_response.content_length + + await response.prepare(request) + + try: + async for chunk in reolink_response.content.iter_chunked(65536): + await response.write(chunk) + except TimeoutError: + _LOGGER.debug( + "Timeout while reading Reolink playback from %s, writing EOF", + host.api.nvr_name, + ) + + reolink_response.release() + await response.write_eof() + return response diff --git a/tests/components/reolink/test_views.py b/tests/components/reolink/test_views.py new file mode 100644 index 00000000000..1eb184950bc --- /dev/null +++ b/tests/components/reolink/test_views.py @@ -0,0 +1,243 @@ +"""Tests for the Reolink views platform.""" + +from http import HTTPStatus +import logging +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from aiohttp import ClientConnectionError, ClientResponse +import pytest +from reolink_aio.enums import VodRequestType +from reolink_aio.exceptions import ReolinkError + +from homeassistant.components.reolink.views import async_generate_playback_proxy_url +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator + +TEST_YEAR = 2023 +TEST_MONTH = 11 +TEST_DAY = 14 +TEST_DAY2 = 15 +TEST_HOUR = 13 +TEST_MINUTE = 12 +TEST_FILE_NAME_MP4 = f"{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE}00.mp4" +TEST_STREAM = "sub" +TEST_CHANNEL = "0" +TEST_VOD_TYPE = VodRequestType.PLAYBACK.value +TEST_MIME_TYPE_MP4 = "video/mp4" +TEST_URL = "http://test_url&token=test" +TEST_ERROR = "TestError" + + +def get_mock_session( + response: list[Any] | None = None, + content_length: int = 8, + content_type: str = TEST_MIME_TYPE_MP4, +) -> Mock: + """Get a mock session to mock the camera response.""" + if response is None: + response = [b"test", b"test", StopAsyncIteration()] + + content = Mock() + content.__anext__ = AsyncMock(side_effect=response) + content.__aiter__ = Mock(return_value=content) + + mock_response = Mock() + mock_response.content_length = content_length + mock_response.content_type = content_type + mock_response.content.iter_chunked = Mock(return_value=content) + + mock_session = Mock() + mock_session.get = AsyncMock(return_value=mock_response) + return mock_session + + +async def test_playback_proxy( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test successful playback proxy URL.""" + reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + + mock_session = get_mock_session() + + with patch( + "homeassistant.components.reolink.views.async_get_clientsession", + return_value=mock_session, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + caplog.set_level(logging.DEBUG) + + proxy_url = async_generate_playback_proxy_url( + config_entry.entry_id, + TEST_CHANNEL, + TEST_FILE_NAME_MP4, + TEST_STREAM, + TEST_VOD_TYPE, + ) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(proxy_url)) + + assert await response.content.read() == b"testtest" + assert response.status == 200 + + +async def test_proxy_get_source_error( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test error while getting source for playback proxy URL.""" + reolink_connect.get_vod_source.side_effect = ReolinkError(TEST_ERROR) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + proxy_url = async_generate_playback_proxy_url( + config_entry.entry_id, + TEST_CHANNEL, + TEST_FILE_NAME_MP4, + TEST_STREAM, + TEST_VOD_TYPE, + ) + + http_client = await hass_client() + response = await http_client.get(proxy_url) + + assert await response.content.read() == bytes(TEST_ERROR, "utf-8") + assert response.status == HTTPStatus.BAD_REQUEST + reolink_connect.get_vod_source.side_effect = None + + +async def test_proxy_invalid_config_entry_id( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test config entry id not found for playback proxy URL.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + proxy_url = async_generate_playback_proxy_url( + "wrong_config_id", + TEST_CHANNEL, + TEST_FILE_NAME_MP4, + TEST_STREAM, + TEST_VOD_TYPE, + ) + + http_client = await hass_client() + response = await http_client.get(proxy_url) + + assert await response.content.read() == bytes( + "Reolink playback proxy could not find config entry id: wrong_config_id", + "utf-8", + ) + assert response.status == HTTPStatus.BAD_REQUEST + + +async def test_playback_proxy_timeout( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test playback proxy URL with a timeout in the second chunk.""" + reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + + mock_session = get_mock_session([b"test", TimeoutError()], 4) + + with patch( + "homeassistant.components.reolink.views.async_get_clientsession", + return_value=mock_session, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + proxy_url = async_generate_playback_proxy_url( + config_entry.entry_id, + TEST_CHANNEL, + TEST_FILE_NAME_MP4, + TEST_STREAM, + TEST_VOD_TYPE, + ) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(proxy_url)) + + assert await response.content.read() == b"test" + assert response.status == 200 + + +async def test_playback_wrong_content( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test playback proxy URL with a wrong content type in the response.""" + reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + + mock_session = get_mock_session(content_type="video/x-flv") + + with patch( + "homeassistant.components.reolink.views.async_get_clientsession", + return_value=mock_session, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + proxy_url = async_generate_playback_proxy_url( + config_entry.entry_id, + TEST_CHANNEL, + TEST_FILE_NAME_MP4, + TEST_STREAM, + TEST_VOD_TYPE, + ) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(proxy_url)) + + assert response.status == HTTPStatus.BAD_REQUEST + + +async def test_playback_connect_error( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test playback proxy URL with a connection error.""" + reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + + mock_session = Mock() + mock_session.get = AsyncMock(side_effect=ClientConnectionError(TEST_ERROR)) + + with patch( + "homeassistant.components.reolink.views.async_get_clientsession", + return_value=mock_session, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + proxy_url = async_generate_playback_proxy_url( + config_entry.entry_id, + TEST_CHANNEL, + TEST_FILE_NAME_MP4, + TEST_STREAM, + TEST_VOD_TYPE, + ) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(proxy_url)) + + assert response.status == HTTPStatus.BAD_REQUEST From 7ea7178aa91478d4e9437f8ea72ecb0c159a7ca2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 3 Jan 2025 14:16:05 +0100 Subject: [PATCH 0114/2987] Simplify error handling when creating backup (#134528) --- homeassistant/components/backup/manager.py | 36 ++++++++-------------- 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 4d509003a21..2fbd5014847 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -800,12 +800,10 @@ class BackupManager: """Finish a backup.""" if TYPE_CHECKING: assert self._backup_task is not None + backup_success = False try: written_backup = await self._backup_task except Exception as err: - self.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) - ) if with_automatic_settings: self._update_issue_backup_failed() @@ -831,33 +829,15 @@ class BackupManager: agent_ids=agent_ids, open_stream=written_backup.open_stream, ) - except BaseException: - self.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) - ) - raise # manager or unexpected error finally: - try: - await written_backup.release_stream() - except Exception: - self.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) - ) - raise + await written_backup.release_stream() self.known_backups.add(written_backup.backup, agent_errors) - if agent_errors: - self.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) - ) - else: + if not agent_errors: if with_automatic_settings: # create backup was successful, update last_completed_automatic_backup self.config.data.last_completed_automatic_backup = dt_util.now() self.store.save() - - self.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.COMPLETED) - ) + backup_success = True if with_automatic_settings: self._update_issue_after_agent_upload(agent_errors) @@ -868,6 +848,14 @@ class BackupManager: finally: self._backup_task = None self._backup_finish_task = None + self.async_on_backup_event( + CreateBackupEvent( + stage=None, + state=CreateBackupState.COMPLETED + if backup_success + else CreateBackupState.FAILED, + ) + ) self.async_on_backup_event(IdleEvent()) async def async_restore_backup( From 9b8ed9643fd48da830c5336da9a27a99510aa1ba Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 3 Jan 2025 13:35:56 +0100 Subject: [PATCH 0115/2987] Add backup as after_dependency of frontend (#134534) --- homeassistant/components/frontend/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 33d1be3aad7..4b18330010a 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -1,6 +1,7 @@ { "domain": "frontend", "name": "Home Assistant Frontend", + "after_dependencies": ["backup"], "codeowners": ["@home-assistant/frontend"], "dependencies": [ "api", From c9f1fee6bb8a6f10d511c0a053a4db46a4be432a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 3 Jan 2025 16:31:31 +0100 Subject: [PATCH 0116/2987] Set Ituran to silver (#134538) --- homeassistant/components/ituran/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/ituran/manifest.json b/homeassistant/components/ituran/manifest.json index 93860427a77..0cf20d3c6b2 100644 --- a/homeassistant/components/ituran/manifest.json +++ b/homeassistant/components/ituran/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/ituran", "integration_type": "hub", "iot_class": "cloud_polling", + "quality_scale": "silver", "requirements": ["pyituran==0.1.4"] } From 9c98125d20b316c8c3c5a6d4ecc666db0e872829 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 3 Jan 2025 14:44:24 +0100 Subject: [PATCH 0117/2987] Avoid early COMPLETED event when restoring backup (#134546) --- homeassistant/components/backup/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 2fbd5014847..1910f8a55fb 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1375,7 +1375,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): ) await self._hass.async_add_executor_job(_write_restore_file) - await self._hass.services.async_call("homeassistant", "restart", {}) + await self._hass.services.async_call("homeassistant", "restart", blocking=True) def _generate_backup_id(date: str, name: str) -> str: From 962b880146ba72fbc88b329eb536efc5c629ab6d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 3 Jan 2025 16:30:14 +0100 Subject: [PATCH 0118/2987] Log cloud backup agent file list (#134556) --- homeassistant/components/cloud/backup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index d21e28be50a..57145e52c44 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -5,6 +5,7 @@ from __future__ import annotations import base64 from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import hashlib +import logging from typing import Any, Self from aiohttp import ClientError, ClientTimeout, StreamReader @@ -23,6 +24,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from .client import CloudClient from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT +_LOGGER = logging.getLogger(__name__) _STORAGE_BACKUP = "backup" @@ -208,6 +210,7 @@ class CloudBackupAgent(BackupAgent): """List backups.""" try: backups = await async_files_list(self._cloud, storage_type=_STORAGE_BACKUP) + _LOGGER.debug("Cloud backups: %s", backups) except (ClientError, CloudError) as err: raise BackupAgentError("Failed to list backups") from err From b416ae1387cbfb7ae190c870ce31816263714cf8 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 3 Jan 2025 16:36:40 +0100 Subject: [PATCH 0119/2987] Update frontend to 20250103.0 (#134561) --- 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 4b18330010a..2094f817dcd 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==20250102.0"] + "requirements": ["home-assistant-frontend==20250103.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d8372ab6bc1..b07909e08eb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ habluetooth==3.6.0 hass-nabucasa==0.87.0 hassil==2.1.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250102.0 +home-assistant-frontend==20250103.0 home-assistant-intents==2025.1.1 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0363d3d2650..68996b86ccb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1134,7 +1134,7 @@ hole==0.8.0 holidays==0.63 # homeassistant.components.frontend -home-assistant-frontend==20250102.0 +home-assistant-frontend==20250103.0 # homeassistant.components.conversation home-assistant-intents==2025.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ec70c179e15..273373c223e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -963,7 +963,7 @@ hole==0.8.0 holidays==0.63 # homeassistant.components.frontend -home-assistant-frontend==20250102.0 +home-assistant-frontend==20250103.0 # homeassistant.components.conversation home-assistant-intents==2025.1.1 From 46b283069906077470088a94e3595d85404fbbae Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 3 Jan 2025 15:41:14 +0000 Subject: [PATCH 0120/2987] Bump version to 2025.1.0b9 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5898c682d89..e8824f9dade 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b8" +PATCH_VERSION: Final = "0b9" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 1d6fbc8cefe..31e63101198 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.1.0b8" +version = "2025.1.0b9" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 03fd6a901b623d5beccccec2b4a9f531fe4275ac Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 3 Jan 2025 18:24:46 +0100 Subject: [PATCH 0121/2987] Cherry pick single file from #134020 to fix generic component tests (#134569) --- tests/components/generic/test_config_flow.py | 29 +++----------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index 4892496c486..9eee49619b5 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -30,7 +30,6 @@ from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, CONF_USE_WALLCLOCK_AS_TIMESTAMPS, ) -from homeassistant.components.stream.worker import StreamWorkerError from homeassistant.config_entries import ConfigEntryState, ConfigFlowResult from homeassistant.const import ( CONF_AUTHENTICATION, @@ -661,25 +660,6 @@ async def test_form_stream_other_error(hass: HomeAssistant, user_flow) -> None: await hass.async_block_till_done() -@respx.mock -@pytest.mark.usefixtures("fakeimg_png") -async def test_form_stream_worker_error( - hass: HomeAssistant, user_flow: ConfigFlowResult -) -> None: - """Test we handle a StreamWorkerError and pass the message through.""" - with patch( - "homeassistant.components.generic.config_flow.create_stream", - side_effect=StreamWorkerError("Some message"), - ): - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"stream_source": "unknown_with_details"} - assert result2["description_placeholders"] == {"error": "Some message"} - - @respx.mock async def test_form_stream_permission_error( hass: HomeAssistant, fakeimgbytes_png: bytes, user_flow: ConfigFlowResult @@ -949,23 +929,22 @@ async def test_options_still_and_stream_not_provided( @respx.mock @pytest.mark.usefixtures("fakeimg_png") -async def test_form_options_stream_worker_error( +async def test_form_options_permission_error( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: - """Test we handle a StreamWorkerError and pass the message through.""" + """Test we handle a PermissionError and pass the message through.""" result = await hass.config_entries.options.async_init(config_entry.entry_id) with patch( "homeassistant.components.generic.config_flow.create_stream", - side_effect=StreamWorkerError("Some message"), + side_effect=PermissionError("Some message"), ): result2 = await hass.config_entries.options.async_configure( result["flow_id"], TESTDATA, ) assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"stream_source": "unknown_with_details"} - assert result2["description_placeholders"] == {"error": "Some message"} + assert result2["errors"] == {"stream_source": "stream_not_permitted"} @pytest.mark.usefixtures("fakeimg_png") From 7e1e63374fc0289e9b6659cdc77ee58ed2fa3166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 3 Jan 2025 16:45:27 +0000 Subject: [PATCH 0122/2987] Bump whirlpool-sixth-sense to 0.18.11 (#134562) --- homeassistant/components/whirlpool/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index 5618a3f61cb..b463a1a76f8 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["whirlpool"], - "requirements": ["whirlpool-sixth-sense==0.18.8"] + "requirements": ["whirlpool-sixth-sense==0.18.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 68996b86ccb..36025003d9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3012,7 +3012,7 @@ webmin-xmlrpc==0.0.2 weheat==2024.12.22 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.8 +whirlpool-sixth-sense==0.18.11 # homeassistant.components.whois whois==0.9.27 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 273373c223e..03e594dcf53 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2416,7 +2416,7 @@ webmin-xmlrpc==0.0.2 weheat==2024.12.22 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.8 +whirlpool-sixth-sense==0.18.11 # homeassistant.components.whois whois==0.9.27 From ac4bd32137050c8f073838bdfb1916a40bd96131 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 3 Jan 2025 17:31:21 +0000 Subject: [PATCH 0123/2987] Bump version to 2025.1.0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e8824f9dade..5a088d36449 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b9" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 31e63101198..c87e499155c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.1.0b9" +version = "2025.1.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 45142b0cc0ef2f47ee7989599951f727c9c30192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 6 Jan 2025 15:35:42 +0100 Subject: [PATCH 0124/2987] Matter Battery replacement icon (#134460) --- homeassistant/components/matter/icons.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index adcdcd05137..ef29601b831 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -57,6 +57,9 @@ }, "valve_position": { "default": "mdi:valve" + }, + "battery_replacement_description": { + "default": "mdi:battery-sync-outline" } } } From aafc1ff074e6bae588e7d828dcd37fbfd5e54402 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Fri, 3 Jan 2025 19:28:05 +0000 Subject: [PATCH 0125/2987] Small fix to allow playing of expandable favorites on Squeezebox (#134572) --- homeassistant/components/squeezebox/browse_media.py | 10 ++++++---- tests/components/squeezebox/conftest.py | 3 +++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 4d1c98bc4fc..331bf383c70 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -115,6 +115,7 @@ async def build_item_response( item_type = CONTENT_TYPE_TO_CHILD_TYPE[search_type] children = [] + list_playable = [] for item in result["items"]: item_id = str(item["id"]) item_thumbnail: str | None = None @@ -131,7 +132,7 @@ async def build_item_response( child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM] can_expand = True can_play = True - elif item["hasitems"]: + elif item["hasitems"] and not item["isaudio"]: child_item_type = "Favorites" child_media_class = CONTENT_TYPE_MEDIA_CLASS["Favorites"] can_expand = True @@ -139,8 +140,8 @@ async def build_item_response( else: child_item_type = "Favorites" child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK] - can_expand = False - can_play = True + can_expand = item["hasitems"] + can_play = item["isaudio"] and item.get("url") if artwork_track_id := item.get("artwork_track_id"): if internal_request: @@ -166,6 +167,7 @@ async def build_item_response( thumbnail=item_thumbnail, ) ) + list_playable.append(can_play) if children is None: raise BrowseError(f"Media not found: {search_type} / {search_id}") @@ -179,7 +181,7 @@ async def build_item_response( children_media_class=media_class["children"], media_content_id=search_id, media_content_type=search_type, - can_play=search_type != "Favorites", + can_play=any(list_playable), children=children, can_expand=True, ) diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 2dc0cabeaa6..7b007114420 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -137,6 +137,7 @@ async def mock_async_browse( "title": "Fake Item 1", "id": FAKE_VALID_ITEM_ID, "hasitems": False, + "isaudio": True, "item_type": child_types[media_type], "artwork_track_id": "b35bb9e9", "url": "file:///var/lib/squeezeboxserver/music/track_1.mp3", @@ -145,6 +146,7 @@ async def mock_async_browse( "title": "Fake Item 2", "id": FAKE_VALID_ITEM_ID + "_2", "hasitems": media_type == "favorites", + "isaudio": True, "item_type": child_types[media_type], "image_url": "http://lms.internal:9000/html/images/favorites.png", "url": "file:///var/lib/squeezeboxserver/music/track_2.mp3", @@ -153,6 +155,7 @@ async def mock_async_browse( "title": "Fake Item 3", "id": FAKE_VALID_ITEM_ID + "_3", "hasitems": media_type == "favorites", + "isaudio": True, "album_id": FAKE_VALID_ITEM_ID if media_type == "favorites" else None, "url": "file:///var/lib/squeezeboxserver/music/track_3.mp3", }, From 3063f0b565ee2a86af73227626caba02ef7b57f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Jan 2025 00:30:41 -1000 Subject: [PATCH 0126/2987] Bump bleak-esphome to 2.0.0 (#134580) --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/bluetooth.py | 4 +--- homeassistant/components/esphome/domain_data.py | 5 ----- homeassistant/components/esphome/manager.py | 4 +--- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/bluetooth/test_client.py | 2 -- 8 files changed, 6 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index ed80ad9aabf..43f18d4fffc 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==1.1.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.0.0"] } diff --git a/homeassistant/components/esphome/bluetooth.py b/homeassistant/components/esphome/bluetooth.py index 37ae28df0ca..004bea1835d 100644 --- a/homeassistant/components/esphome/bluetooth.py +++ b/homeassistant/components/esphome/bluetooth.py @@ -7,7 +7,6 @@ from typing import TYPE_CHECKING from aioesphomeapi import APIClient, DeviceInfo from bleak_esphome import connect_scanner -from bleak_esphome.backend.cache import ESPHomeBluetoothCache from homeassistant.components.bluetooth import async_register_scanner from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback @@ -28,10 +27,9 @@ def async_connect_scanner( entry_data: RuntimeEntryData, cli: APIClient, device_info: DeviceInfo, - cache: ESPHomeBluetoothCache, ) -> CALLBACK_TYPE: """Connect scanner.""" - client_data = connect_scanner(cli, device_info, cache, entry_data.available) + client_data = connect_scanner(cli, device_info, entry_data.available) entry_data.bluetooth_device = client_data.bluetooth_device client_data.disconnect_callbacks = entry_data.disconnect_callbacks scanner = client_data.scanner diff --git a/homeassistant/components/esphome/domain_data.py b/homeassistant/components/esphome/domain_data.py index aa46469c40e..ed307b46fd6 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -6,8 +6,6 @@ from dataclasses import dataclass, field from functools import cache from typing import Self -from bleak_esphome.backend.cache import ESPHomeBluetoothCache - from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSONEncoder @@ -22,9 +20,6 @@ class DomainData: """Define a class that stores global esphome data in hass.data[DOMAIN].""" _stores: dict[str, ESPHomeStorage] = field(default_factory=dict) - bluetooth_cache: ESPHomeBluetoothCache = field( - default_factory=ESPHomeBluetoothCache - ) def get_entry_data(self, entry: ESPHomeConfigEntry) -> RuntimeEntryData: """Return the runtime entry data associated with this config entry. diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 007b4e791e1..dfd318c0c74 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -423,9 +423,7 @@ class ESPHomeManager: if device_info.bluetooth_proxy_feature_flags_compat(api_version): entry_data.disconnect_callbacks.add( - async_connect_scanner( - hass, entry_data, cli, device_info, self.domain_data.bluetooth_cache - ) + async_connect_scanner(hass, entry_data, cli, device_info) ) if device_info.voice_assistant_feature_flags_compat(api_version) and ( diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 775ffbff4c8..b04fa4db428 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==28.0.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==1.1.0" + "bleak-esphome==2.0.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 36025003d9d..8cbee02e331 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -585,7 +585,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==1.1.0 +bleak-esphome==2.0.0 # homeassistant.components.bluetooth bleak-retry-connector==3.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03e594dcf53..1c55c4c7b23 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -516,7 +516,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==1.1.0 +bleak-esphome==2.0.0 # homeassistant.components.bluetooth bleak-retry-connector==3.6.0 diff --git a/tests/components/esphome/bluetooth/test_client.py b/tests/components/esphome/bluetooth/test_client.py index 98993be37d0..77d315f096d 100644 --- a/tests/components/esphome/bluetooth/test_client.py +++ b/tests/components/esphome/bluetooth/test_client.py @@ -4,7 +4,6 @@ from __future__ import annotations from aioesphomeapi import APIClient, APIVersion, BluetoothProxyFeature, DeviceInfo from bleak.exc import BleakError -from bleak_esphome.backend.cache import ESPHomeBluetoothCache from bleak_esphome.backend.client import ESPHomeClient, ESPHomeClientData from bleak_esphome.backend.device import ESPHomeBluetoothDevice from bleak_esphome.backend.scanner import ESPHomeScanner @@ -27,7 +26,6 @@ async def client_data_fixture( connector = HaBluetoothConnector(ESPHomeClientData, ESP_MAC_ADDRESS, lambda: True) return ESPHomeClientData( bluetooth_device=ESPHomeBluetoothDevice(ESP_NAME, ESP_MAC_ADDRESS), - cache=ESPHomeBluetoothCache(), client=mock_client, device_info=DeviceInfo( mac_address=ESP_MAC_ADDRESS, From 8c2ec5e7c8305912893d48cb135bbaf7bf238a5d Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sat, 4 Jan 2025 00:27:06 +0100 Subject: [PATCH 0127/2987] Bump uiprotect to version 7.2.0 (#134587) --- .../components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../fixtures/sample_bootstrap.json | 18 ++++++++++++++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 1226f96c253..d4877798208 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.1.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.2.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 8cbee02e331..a055be570ea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2910,7 +2910,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.1.0 +uiprotect==7.2.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c55c4c7b23..7ee457a21c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2332,7 +2332,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.1.0 +uiprotect==7.2.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/tests/components/unifiprotect/fixtures/sample_bootstrap.json b/tests/components/unifiprotect/fixtures/sample_bootstrap.json index 240a9938b64..4c8d86a787d 100644 --- a/tests/components/unifiprotect/fixtures/sample_bootstrap.json +++ b/tests/components/unifiprotect/fixtures/sample_bootstrap.json @@ -564,6 +564,24 @@ "legacyUFVs": [], "lastUpdateId": "ebf25bac-d5a1-4f1d-a0ee-74c15981eb70", "displays": [], + "ringtones": [ + { + "id": "66a14fa502d44203e40003eb", + "name": "Default", + "size": 208, + "isDefault": true, + "nvrMac": "A1E00C826924", + "modelKey": "ringtone" + }, + { + "id": "66a14fa502da4203e40003ec", + "name": "Traditional", + "size": 180, + "isDefault": false, + "nvrMac": "A1E00C826924", + "modelKey": "ringtone" + } + ], "bridges": [ { "mac": "A28D0DB15AE1", From c46a70fdcff200810512ee59adbe43150882bd2e Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sat, 4 Jan 2025 11:12:46 +0100 Subject: [PATCH 0128/2987] Mention case-sensitivity in tplink credentials prompt (#134606) --- homeassistant/components/tplink/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 664d52c16af..c0aef09e8c3 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -21,7 +21,7 @@ }, "user_auth_confirm": { "title": "Authenticate", - "description": "The device requires authentication, please input your TP-Link credentials below.", + "description": "The device requires authentication, please input your TP-Link credentials below. Note, that both e-mail and password are case-sensitive.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" From 0bd7b793fe4d8b9508ede8579602158cd9490d4e Mon Sep 17 00:00:00 2001 From: Brynley McDonald Date: Sun, 5 Jan 2025 04:21:21 +1300 Subject: [PATCH 0129/2987] Fix Flick Electric authentication (#134611) --- .../components/flick_electric/__init__.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/flick_electric/__init__.py b/homeassistant/components/flick_electric/__init__.py index 190947e4c6f..3ffddee1c7d 100644 --- a/homeassistant/components/flick_electric/__init__.py +++ b/homeassistant/components/flick_electric/__init__.py @@ -2,10 +2,11 @@ from datetime import datetime as dt import logging +from typing import Any import jwt from pyflick import FlickAPI -from pyflick.authentication import AbstractFlickAuth +from pyflick.authentication import SimpleFlickAuth from pyflick.const import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET from homeassistant.config_entries import ConfigEntry @@ -93,16 +94,22 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True -class HassFlickAuth(AbstractFlickAuth): +class HassFlickAuth(SimpleFlickAuth): """Implementation of AbstractFlickAuth based on a Home Assistant entity config.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: FlickConfigEntry) -> None: """Flick authentication based on a Home Assistant entity config.""" - super().__init__(aiohttp_client.async_get_clientsession(hass)) + super().__init__( + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + client_id=entry.data.get(CONF_CLIENT_ID, DEFAULT_CLIENT_ID), + client_secret=entry.data.get(CONF_CLIENT_SECRET, DEFAULT_CLIENT_SECRET), + websession=aiohttp_client.async_get_clientsession(hass), + ) self._entry = entry self._hass = hass - async def _get_entry_token(self): + async def _get_entry_token(self) -> dict[str, Any]: # No token saved, generate one if ( CONF_TOKEN_EXPIRY not in self._entry.data @@ -119,13 +126,8 @@ class HassFlickAuth(AbstractFlickAuth): async def _update_token(self): _LOGGER.debug("Fetching new access token") - token = await self.get_new_token( - username=self._entry.data[CONF_USERNAME], - password=self._entry.data[CONF_PASSWORD], - client_id=self._entry.data.get(CONF_CLIENT_ID, DEFAULT_CLIENT_ID), - client_secret=self._entry.data.get( - CONF_CLIENT_SECRET, DEFAULT_CLIENT_SECRET - ), + token = await super().get_new_token( + self._username, self._password, self._client_id, self._client_secret ) _LOGGER.debug("New token: %s", token) From 017679abe149cffcfca175acacf01207c27e992d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 4 Jan 2025 16:19:38 +0100 Subject: [PATCH 0130/2987] Fix hive color tunable light (#134628) --- homeassistant/components/hive/light.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index b510569eb47..8d09c902f36 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -114,6 +114,7 @@ class HiveDeviceLight(HiveEntity, LightEntity): self._attr_hs_color = color_util.color_RGB_to_hs(*rgb) self._attr_color_mode = ColorMode.HS else: + color_temp = self.device["status"].get("color_temp") self._attr_color_temp_kelvin = ( None if color_temp is None From 9ead6fe36284be21a599d5b15106e945292d31f7 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Sat, 4 Jan 2025 12:23:22 +0100 Subject: [PATCH 0131/2987] Set logging in manifest for Cookidoo (#134645) --- homeassistant/components/cookidoo/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/cookidoo/manifest.json b/homeassistant/components/cookidoo/manifest.json index 59d58200fdf..7b2e7c84bba 100644 --- a/homeassistant/components/cookidoo/manifest.json +++ b/homeassistant/components/cookidoo/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/cookidoo", "integration_type": "service", "iot_class": "cloud_polling", + "loggers": ["cookidoo", "cookidoo-api"], "quality_scale": "silver", "requirements": ["cookidoo-api==0.10.0"] } From a4d0794fe4f83b8af12a34db40ae638eb3bbffdf Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 4 Jan 2025 12:56:58 +0100 Subject: [PATCH 0132/2987] Remove call to remove slide (#134647) --- homeassistant/components/slide_local/config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py index 23c509a02dc..78e2b411153 100644 --- a/homeassistant/components/slide_local/config_flow.py +++ b/homeassistant/components/slide_local/config_flow.py @@ -73,7 +73,6 @@ class SlideConfigFlow(ConfigFlow, domain=DOMAIN): return {} # API version 2 is not working, try API version 1 instead - await slide.slide_del(user_input[CONF_HOST]) await slide.slide_add( user_input[CONF_HOST], user_input.get(CONF_PASSWORD, ""), From a9a14381d379737672a76b7c0d259cf5516ef71f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 4 Jan 2025 14:05:24 +0100 Subject: [PATCH 0133/2987] Update twentemilieu to 2.2.1 (#134651) --- homeassistant/components/twentemilieu/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json index c04c5492a40..b1cb98dbca6 100644 --- a/homeassistant/components/twentemilieu/manifest.json +++ b/homeassistant/components/twentemilieu/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["twentemilieu"], "quality_scale": "silver", - "requirements": ["twentemilieu==2.2.0"] + "requirements": ["twentemilieu==2.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a055be570ea..6e90c5ac6bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2895,7 +2895,7 @@ ttn_client==1.2.0 tuya-device-sharing-sdk==0.2.1 # homeassistant.components.twentemilieu -twentemilieu==2.2.0 +twentemilieu==2.2.1 # homeassistant.components.twilio twilio==6.32.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7ee457a21c4..7622914d070 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2317,7 +2317,7 @@ ttn_client==1.2.0 tuya-device-sharing-sdk==0.2.1 # homeassistant.components.twentemilieu -twentemilieu==2.2.0 +twentemilieu==2.2.1 # homeassistant.components.twilio twilio==6.32.0 From a14f6faaaf3ddfbd9b6d811c71fa9cf64f6e5e3e Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 6 Jan 2025 18:54:32 +0100 Subject: [PATCH 0134/2987] Fix Reolink playback of recodings (#134652) --- homeassistant/components/reolink/views.py | 8 ++++---- tests/components/reolink/test_views.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/reolink/views.py b/homeassistant/components/reolink/views.py index 3b32ebaf74e..1a4585bc997 100644 --- a/homeassistant/components/reolink/views.py +++ b/homeassistant/components/reolink/views.py @@ -2,9 +2,9 @@ from __future__ import annotations +from base64 import urlsafe_b64decode, urlsafe_b64encode from http import HTTPStatus import logging -from urllib import parse from aiohttp import ClientError, ClientTimeout, web from reolink_aio.enums import VodRequestType @@ -31,7 +31,7 @@ def async_generate_playback_proxy_url( return url_format.format( config_entry_id=config_entry_id, channel=channel, - filename=parse.quote(filename, safe=""), + filename=urlsafe_b64encode(filename.encode("utf-8")).decode("utf-8"), stream_res=stream_res, vod_type=vod_type, ) @@ -66,7 +66,7 @@ class PlaybackProxyView(HomeAssistantView): """Get playback proxy video response.""" retry = retry - 1 - filename = parse.unquote(filename) + filename_decoded = urlsafe_b64decode(filename.encode("utf-8")).decode("utf-8") ch = int(channel) try: host = get_host(self.hass, config_entry_id) @@ -77,7 +77,7 @@ class PlaybackProxyView(HomeAssistantView): try: mime_type, reolink_url = await host.api.get_vod_source( - ch, filename, stream_res, VodRequestType(vod_type) + ch, filename_decoded, stream_res, VodRequestType(vod_type) ) except ReolinkError as err: _LOGGER.warning("Reolink playback proxy error: %s", str(err)) diff --git a/tests/components/reolink/test_views.py b/tests/components/reolink/test_views.py index 1eb184950bc..c994cc59c5d 100644 --- a/tests/components/reolink/test_views.py +++ b/tests/components/reolink/test_views.py @@ -22,7 +22,7 @@ TEST_DAY = 14 TEST_DAY2 = 15 TEST_HOUR = 13 TEST_MINUTE = 12 -TEST_FILE_NAME_MP4 = f"{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE}00.mp4" +TEST_FILE_NAME_MP4 = f"Mp4Record/{TEST_YEAR}-{TEST_MONTH}-{TEST_DAY}/RecS04_{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE}00_123456_AB123C.mp4" TEST_STREAM = "sub" TEST_CHANNEL = "0" TEST_VOD_TYPE = VodRequestType.PLAYBACK.value From ca8416fe503a1f166a970514efaa88d083cb7ad0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 4 Jan 2025 14:34:45 +0100 Subject: [PATCH 0135/2987] Update peblar to 0.3.3 (#134658) --- homeassistant/components/peblar/manifest.json | 2 +- homeassistant/components/peblar/update.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/peblar/manifest.json b/homeassistant/components/peblar/manifest.json index 2c3e73ba76e..859682d3f1d 100644 --- a/homeassistant/components/peblar/manifest.json +++ b/homeassistant/components/peblar/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["peblar==0.3.2"], + "requirements": ["peblar==0.3.3"], "zeroconf": [{ "type": "_http._tcp.local.", "name": "pblr-*" }] } diff --git a/homeassistant/components/peblar/update.py b/homeassistant/components/peblar/update.py index 77879030f6c..9e132da63bc 100644 --- a/homeassistant/components/peblar/update.py +++ b/homeassistant/components/peblar/update.py @@ -37,6 +37,7 @@ DESCRIPTIONS: tuple[PeblarUpdateEntityDescription, ...] = ( key="firmware", device_class=UpdateDeviceClass.FIRMWARE, installed_fn=lambda x: x.current.firmware, + has_fn=lambda x: x.current.firmware is not None, available_fn=lambda x: x.available.firmware, ), PeblarUpdateEntityDescription( diff --git a/requirements_all.txt b/requirements_all.txt index 6e90c5ac6bf..d54ea27f84d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1603,7 +1603,7 @@ panasonic-viera==0.4.2 pdunehd==1.3.2 # homeassistant.components.peblar -peblar==0.3.2 +peblar==0.3.3 # homeassistant.components.peco peco==0.0.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7622914d070..434c53f7ba5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1330,7 +1330,7 @@ panasonic-viera==0.4.2 pdunehd==1.3.2 # homeassistant.components.peblar -peblar==0.3.2 +peblar==0.3.3 # homeassistant.components.peco peco==0.0.30 From 0daac090082a5510ff97e6b1a9c9d7d9482c5f32 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Sat, 4 Jan 2025 15:02:00 +0100 Subject: [PATCH 0136/2987] Bump cookidoo-api library to 0.11.1 of for Cookidoo (#134661) --- homeassistant/components/cookidoo/manifest.json | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cookidoo/manifest.json b/homeassistant/components/cookidoo/manifest.json index 7b2e7c84bba..0854f0a1b95 100644 --- a/homeassistant/components/cookidoo/manifest.json +++ b/homeassistant/components/cookidoo/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/cookidoo", "integration_type": "service", "iot_class": "cloud_polling", - "loggers": ["cookidoo", "cookidoo-api"], + "loggers": ["cookidoo_api"], "quality_scale": "silver", - "requirements": ["cookidoo-api==0.10.0"] + "requirements": ["cookidoo-api==0.11.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index d54ea27f84d..7ebf0efa9db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -704,7 +704,7 @@ connect-box==0.3.1 construct==2.10.68 # homeassistant.components.cookidoo -cookidoo-api==0.10.0 +cookidoo-api==0.11.1 # homeassistant.components.backup # homeassistant.components.utility_meter diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 434c53f7ba5..bb02bbb11b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -600,7 +600,7 @@ colorthief==0.2.1 construct==2.10.68 # homeassistant.components.cookidoo -cookidoo-api==0.10.0 +cookidoo-api==0.11.1 # homeassistant.components.backup # homeassistant.components.utility_meter From c022d91baadb28ff2ec4a162152e296f5a615be2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 4 Jan 2025 16:19:16 +0100 Subject: [PATCH 0137/2987] Update demetriek to 1.1.1 (#134663) --- homeassistant/components/lametric/manifest.json | 2 +- homeassistant/components/lametric/number.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/lametric/snapshots/test_diagnostics.ambr | 1 + 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lametric/manifest.json b/homeassistant/components/lametric/manifest.json index 5a066d015f2..f66ffb0c6ae 100644 --- a/homeassistant/components/lametric/manifest.json +++ b/homeassistant/components/lametric/manifest.json @@ -13,7 +13,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["demetriek"], - "requirements": ["demetriek==1.1.0"], + "requirements": ["demetriek==1.1.1"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:LaMetric:1" diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index 1025e04a4a8..a1d922c2d80 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -50,7 +50,7 @@ NUMBERS = [ native_step=1, native_min_value=0, native_max_value=100, - has_fn=lambda device: bool(device.audio), + has_fn=lambda device: bool(device.audio and device.audio.available), value_fn=lambda device: device.audio.volume if device.audio else 0, set_value_fn=lambda api, volume: api.audio(volume=int(volume)), ), diff --git a/requirements_all.txt b/requirements_all.txt index 7ebf0efa9db..bf57d0c2d18 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -749,7 +749,7 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==1.1.0 +demetriek==1.1.1 # homeassistant.components.denonavr denonavr==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb02bbb11b7..08c3419ccfc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -639,7 +639,7 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==1.1.0 +demetriek==1.1.1 # homeassistant.components.denonavr denonavr==1.0.1 diff --git a/tests/components/lametric/snapshots/test_diagnostics.ambr b/tests/components/lametric/snapshots/test_diagnostics.ambr index 7517cfe035e..8b8f98b5806 100644 --- a/tests/components/lametric/snapshots/test_diagnostics.ambr +++ b/tests/components/lametric/snapshots/test_diagnostics.ambr @@ -2,6 +2,7 @@ # name: test_diagnostics dict({ 'audio': dict({ + 'available': True, 'volume': 100, 'volume_limit': dict({ 'range_max': 100, From 27b8b8458bbb6c3d9cbfe172677358a92a5562ae Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Sat, 4 Jan 2025 16:33:42 +0100 Subject: [PATCH 0138/2987] Cookidoo exotic domains (#134676) --- homeassistant/components/cookidoo/__init__.py | 12 +++++++----- homeassistant/components/cookidoo/config_flow.py | 16 ++++++++-------- homeassistant/components/cookidoo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/cookidoo/__init__.py b/homeassistant/components/cookidoo/__init__.py index bb78f2a569d..5d3c211e78d 100644 --- a/homeassistant/components/cookidoo/__init__.py +++ b/homeassistant/components/cookidoo/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from cookidoo_api import Cookidoo, CookidooConfig, CookidooLocalizationConfig +from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options from homeassistant.const import ( CONF_COUNTRY, @@ -22,15 +22,17 @@ PLATFORMS: list[Platform] = [Platform.TODO] async def async_setup_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool: """Set up Cookidoo from a config entry.""" + localizations = await get_localization_options( + country=entry.data[CONF_COUNTRY].lower(), + language=entry.data[CONF_LANGUAGE], + ) + cookidoo = Cookidoo( async_get_clientsession(hass), CookidooConfig( email=entry.data[CONF_EMAIL], password=entry.data[CONF_PASSWORD], - localization=CookidooLocalizationConfig( - country_code=entry.data[CONF_COUNTRY].lower(), - language=entry.data[CONF_LANGUAGE], - ), + localization=localizations[0], ), ) diff --git a/homeassistant/components/cookidoo/config_flow.py b/homeassistant/components/cookidoo/config_flow.py index 120ab162a6c..80487ed757f 100644 --- a/homeassistant/components/cookidoo/config_flow.py +++ b/homeassistant/components/cookidoo/config_flow.py @@ -10,7 +10,6 @@ from cookidoo_api import ( Cookidoo, CookidooAuthException, CookidooConfig, - CookidooLocalizationConfig, CookidooRequestException, get_country_options, get_localization_options, @@ -219,18 +218,19 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): else: data_input[CONF_LANGUAGE] = ( await get_localization_options(country=data_input[CONF_COUNTRY].lower()) - )[0] # Pick any language to test login + )[0].language # Pick any language to test login + + localizations = await get_localization_options( + country=data_input[CONF_COUNTRY].lower(), + language=data_input[CONF_LANGUAGE], + ) - session = async_get_clientsession(self.hass) cookidoo = Cookidoo( - session, + async_get_clientsession(self.hass), CookidooConfig( email=data_input[CONF_EMAIL], password=data_input[CONF_PASSWORD], - localization=CookidooLocalizationConfig( - country_code=data_input[CONF_COUNTRY].lower(), - language=data_input[CONF_LANGUAGE], - ), + localization=localizations[0], ), ) try: diff --git a/homeassistant/components/cookidoo/manifest.json b/homeassistant/components/cookidoo/manifest.json index 0854f0a1b95..b1a3e9c0267 100644 --- a/homeassistant/components/cookidoo/manifest.json +++ b/homeassistant/components/cookidoo/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["cookidoo_api"], "quality_scale": "silver", - "requirements": ["cookidoo-api==0.11.1"] + "requirements": ["cookidoo-api==0.11.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index bf57d0c2d18..e3dec070f0e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -704,7 +704,7 @@ connect-box==0.3.1 construct==2.10.68 # homeassistant.components.cookidoo -cookidoo-api==0.11.1 +cookidoo-api==0.11.2 # homeassistant.components.backup # homeassistant.components.utility_meter diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 08c3419ccfc..34f8ec79c0e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -600,7 +600,7 @@ colorthief==0.2.1 construct==2.10.68 # homeassistant.components.cookidoo -cookidoo-api==0.11.1 +cookidoo-api==0.11.2 # homeassistant.components.backup # homeassistant.components.utility_meter From 0f0209d4bb0cb197c96674868f6274cd23ac53d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Mon, 6 Jan 2025 15:21:02 +0100 Subject: [PATCH 0139/2987] Iterate over a copy of the list of programs at Home Connect select setup entry (#134684) --- .../components/home_connect/select.py | 2 +- tests/components/home_connect/test_select.py | 41 ++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index c97b3db28e0..a4a5861afbe 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -220,7 +220,7 @@ async def async_setup_entry( with contextlib.suppress(HomeConnectError): programs = device.appliance.get_programs_available() if programs: - for program in programs: + for program in programs.copy(): if program not in PROGRAMS_TRANSLATION_KEYS_MAP: programs.remove(program) if program not in programs_not_found: diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 7d5843e9525..af975979196 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -10,11 +10,16 @@ from homeassistant.components.home_connect.const import ( BSH_ACTIVE_PROGRAM, BSH_SELECTED_PROGRAM, ) -from homeassistant.components.select import ATTR_OPTION, DOMAIN as SELECT_DOMAIN +from homeassistant.components.select import ( + ATTR_OPTION, + ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from .conftest import get_all_appliances @@ -52,6 +57,40 @@ async def test_select( assert config_entry.state is ConfigEntryState.LOADED +async def test_filter_unknown_programs( + bypass_throttle: Generator[None], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + get_appliances: Mock, + appliance: Mock, + entity_registry: er.EntityRegistry, +) -> None: + """Test select that programs that are not part of the official Home Connect API specification are filtered out. + + We use two programs to ensure that programs are iterated over a copy of the list, + and it does not raise problems when removing an element from the original list. + """ + appliance.status.update(SETTINGS_STATUS) + appliance.get_programs_available.return_value = [ + PROGRAM, + "NonOfficialProgram", + "AntotherNonOfficialProgram", + ] + get_appliances.return_value = [appliance] + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state is ConfigEntryState.LOADED + + entity = entity_registry.async_get("select.washer_selected_program") + assert entity + assert entity.capabilities.get(ATTR_OPTIONS) == [ + "dishcare_dishwasher_program_eco_50" + ] + + @pytest.mark.parametrize( ("entity_id", "status", "program_to_set"), [ From 1c4273ce91104fd6b92315ae4df4423cc6d4b0a0 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Sat, 4 Jan 2025 21:28:47 +0100 Subject: [PATCH 0140/2987] Change from host to ip in zeroconf discovery for slide_local (#134709) --- homeassistant/components/slide_local/config_flow.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py index 78e2b411153..a4255f0769f 100644 --- a/homeassistant/components/slide_local/config_flow.py +++ b/homeassistant/components/slide_local/config_flow.py @@ -184,14 +184,15 @@ class SlideConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(self._mac) - self._abort_if_unique_id_configured( - {CONF_HOST: discovery_info.host}, reload_on_update=True - ) + ip = str(discovery_info.ip_address) + _LOGGER.debug("Slide device discovered, ip %s", ip) + + self._abort_if_unique_id_configured({CONF_HOST: ip}, reload_on_update=True) errors = {} if errors := await self.async_test_connection( { - CONF_HOST: self._host, + CONF_HOST: ip, } ): return self.async_abort( @@ -201,7 +202,7 @@ class SlideConfigFlow(ConfigFlow, domain=DOMAIN): }, ) - self._host = discovery_info.host + self._host = ip return await self.async_step_zeroconf_confirm() From 103960e0a7601071a0652b2afc9fd7b4b4e028fd Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Sun, 5 Jan 2025 16:49:58 +0100 Subject: [PATCH 0141/2987] Bump ZHA to 0.0.45 (#134726) --- 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 45d8f6bb25f..975a1804853 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["universal-silabs-flasher==0.0.25", "zha==0.0.44"], + "requirements": ["universal-silabs-flasher==0.0.25", "zha==0.0.45"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index e3dec070f0e..a4ba72784f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3100,7 +3100,7 @@ zeroconf==0.136.2 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.44 +zha==0.0.45 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34f8ec79c0e..60f192f2253 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2489,7 +2489,7 @@ zeroconf==0.136.2 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.44 +zha==0.0.45 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 From b461bc2fb583ed16be241d7a59e7d4f5a5e55584 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Sun, 5 Jan 2025 10:04:59 +0100 Subject: [PATCH 0142/2987] Bump openwebifpy to 4.3.1 (#134746) --- homeassistant/components/enigma2/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json index 7d6887ad14c..2bb299722b7 100644 --- a/homeassistant/components/enigma2/manifest.json +++ b/homeassistant/components/enigma2/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["openwebif"], - "requirements": ["openwebifpy==4.3.0"] + "requirements": ["openwebifpy==4.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a4ba72784f3..d9b221b6b1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1561,7 +1561,7 @@ openhomedevice==2.2.0 opensensemap-api==0.2.0 # homeassistant.components.enigma2 -openwebifpy==4.3.0 +openwebifpy==4.3.1 # homeassistant.components.luci openwrt-luci-rpc==1.1.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 60f192f2253..216fcab4854 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1303,7 +1303,7 @@ openerz-api==0.3.0 openhomedevice==2.2.0 # homeassistant.components.enigma2 -openwebifpy==4.3.0 +openwebifpy==4.3.1 # homeassistant.components.opower opower==0.8.7 From 538a2ea057b30e222711875baee20444c0632c23 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 5 Jan 2025 10:43:32 +0100 Subject: [PATCH 0143/2987] =?UTF-8?q?Fix=20swapped=20letter=20order=20in?= =?UTF-8?q?=20"=C2=B0F"=20and=20"=C2=B0C"=20temperature=20units=20(#134750?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the wrong order "F°" and "C°" for the temperature units. --- homeassistant/components/iron_os/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index 04c55280550..967b966e44e 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -128,8 +128,8 @@ "temp_unit": { "name": "Temperature display unit", "state": { - "celsius": "Celsius (C°)", - "fahrenheit": "Fahrenheit (F°)" + "celsius": "Celsius (°C)", + "fahrenheit": "Fahrenheit (°F)" } }, "desc_scroll_speed": { From bd8ea646a9485926d6765d1ed054aa38059a0683 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Sun, 5 Jan 2025 12:37:06 +0100 Subject: [PATCH 0144/2987] Bumb python-homewizard-energy to 7.0.1 (#134753) --- homeassistant/components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 13bfc512551..83937809b60 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==v7.0.0"], + "requirements": ["python-homewizard-energy==v7.0.1"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index d9b221b6b1e..b73a455f7f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2363,7 +2363,7 @@ python-gitlab==1.6.0 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v7.0.0 +python-homewizard-energy==v7.0.1 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 216fcab4854..2222fe90f74 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1905,7 +1905,7 @@ python-fullykiosk==0.0.14 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v7.0.0 +python-homewizard-energy==v7.0.1 # homeassistant.components.izone python-izone==1.2.9 From a4ea25631a33875bbb8d5bd51f0bd5226d3a1b90 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 5 Jan 2025 14:16:33 +0100 Subject: [PATCH 0145/2987] Register base device entry during coordinator setup in AVM Fritz!Tools integration (#134764) * register base device entry during coordinator setup * make mypy happy --- homeassistant/components/fritz/coordinator.py | 12 ++++++++++++ homeassistant/components/fritz/entity.py | 11 +---------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 90bd6068ecb..272295cd512 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -214,6 +214,18 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): self._options = options await self.hass.async_add_executor_job(self.setup) + device_registry = dr.async_get(self.hass) + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + configuration_url=f"http://{self.host}", + connections={(dr.CONNECTION_NETWORK_MAC, self.mac)}, + identifiers={(DOMAIN, self.unique_id)}, + manufacturer="AVM", + model=self.model, + name=self.config_entry.title, + sw_version=self.current_firmware, + ) + def setup(self) -> None: """Set up FritzboxTools class.""" diff --git a/homeassistant/components/fritz/entity.py b/homeassistant/components/fritz/entity.py index 45665c786d4..33eb60d72cf 100644 --- a/homeassistant/components/fritz/entity.py +++ b/homeassistant/components/fritz/entity.py @@ -68,23 +68,14 @@ class FritzBoxBaseEntity: """Init device info class.""" self._avm_wrapper = avm_wrapper self._device_name = device_name - - @property - def mac_address(self) -> str: - """Return the mac address of the main device.""" - return self._avm_wrapper.mac + self.mac_address = self._avm_wrapper.mac @property def device_info(self) -> DeviceInfo: """Return the device information.""" return DeviceInfo( - configuration_url=f"http://{self._avm_wrapper.host}", connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}, identifiers={(DOMAIN, self._avm_wrapper.unique_id)}, - manufacturer="AVM", - model=self._avm_wrapper.model, - name=self._device_name, - sw_version=self._avm_wrapper.current_firmware, ) From b32a791ea4c86a09886d8adf6da4ce9fb6533813 Mon Sep 17 00:00:00 2001 From: jb101010-2 <168106462+jb101010-2@users.noreply.github.com> Date: Mon, 6 Jan 2025 15:27:23 +0100 Subject: [PATCH 0146/2987] Bump pysuezV2 to 2.0.1 (#134769) --- homeassistant/components/suez_water/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index f39411e8afa..176b059f3d5 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], "quality_scale": "bronze", - "requirements": ["pysuezV2==1.3.5"] + "requirements": ["pysuezV2==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index b73a455f7f3..4e91bca1cde 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2309,7 +2309,7 @@ pysqueezebox==0.10.0 pystiebeleltron==0.0.1.dev2 # homeassistant.components.suez_water -pysuezV2==1.3.5 +pysuezV2==2.0.1 # homeassistant.components.switchbee pyswitchbee==1.8.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2222fe90f74..4b601e01647 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1875,7 +1875,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.10.0 # homeassistant.components.suez_water -pysuezV2==1.3.5 +pysuezV2==2.0.1 # homeassistant.components.switchbee pyswitchbee==1.8.3 From 09ffa38ddf2aff16ce98eef01a5538b95c04c234 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 6 Jan 2025 14:53:28 +0100 Subject: [PATCH 0147/2987] Fix missing sentence-casing etc. in several strings (#134775) --- .../components/waze_travel_time/strings.json | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/waze_travel_time/strings.json b/homeassistant/components/waze_travel_time/strings.json index cca1789bf7e..8f8de694b2d 100644 --- a/homeassistant/components/waze_travel_time/strings.json +++ b/homeassistant/components/waze_travel_time/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "description": "For Origin and Destination, enter the address or the GPS coordinates of the location (GPS coordinates has to be separated by a comma). You can also enter an entity id which provides this information in its state, an entity id with latitude and longitude attributes, or zone friendly name.", + "description": "For Origin and Destination, enter the address or the GPS coordinates of the location (GPS coordinates has to be separated by a comma). You can also enter an entity ID which provides this information in its state, an entity ID with latitude and longitude attributes, or zone friendly name.", "data": { "name": "[%key:common::config_flow::data::name%]", "origin": "Origin", @@ -26,13 +26,13 @@ "description": "Some options will allow you to force the integration to use a particular route or avoid a particular route in its time travel calculation.", "data": { "units": "Units", - "vehicle_type": "Vehicle Type", + "vehicle_type": "Vehicle type", "incl_filter": "Exact streetname which must be part of the selected route", "excl_filter": "Exact streetname which must NOT be part of the selected route", - "realtime": "Realtime Travel Time?", - "avoid_toll_roads": "Avoid Toll Roads?", - "avoid_ferries": "Avoid Ferries?", - "avoid_subscription_roads": "Avoid Roads Needing a Vignette / Subscription?" + "realtime": "Realtime travel time?", + "avoid_toll_roads": "Avoid toll roads?", + "avoid_ferries": "Avoid ferries?", + "avoid_subscription_roads": "Avoid roads needing a vignette / subscription?" } } } @@ -47,8 +47,8 @@ }, "units": { "options": { - "metric": "Metric System", - "imperial": "Imperial System" + "metric": "Metric system", + "imperial": "Imperial system" } }, "region": { @@ -63,8 +63,8 @@ }, "services": { "get_travel_times": { - "name": "Get Travel Times", - "description": "Get route alternatives and travel times between two locations.", + "name": "Get travel times", + "description": "Retrieves route alternatives and travel times between two locations.", "fields": { "origin": { "name": "[%key:component::waze_travel_time::config::step::user::data::origin%]", @@ -76,7 +76,7 @@ }, "region": { "name": "[%key:component::waze_travel_time::config::step::user::data::region%]", - "description": "The region. Controls which waze server is used." + "description": "The region. Controls which Waze server is used." }, "units": { "name": "[%key:component::waze_travel_time::options::step::init::data::units%]", From eda60073ee0d3ce28dddfddc07556bf3a3c7b193 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 6 Jan 2025 14:52:40 +0100 Subject: [PATCH 0148/2987] Raise ImportError in python_script (#134792) --- homeassistant/components/python_script/__init__.py | 2 +- tests/components/python_script/test_init.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index af773278029..a45107181de 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -180,7 +180,7 @@ def guarded_import( # Allow import of _strptime needed by datetime.datetime.strptime if name == "_strptime": return __import__(name, globals, locals, fromlist, level) - raise ScriptError(f"Not allowed to import {name}") + raise ImportError(f"Not allowed to import {name}") def guarded_inplacevar(op: str, target: Any, operand: Any) -> Any: diff --git a/tests/components/python_script/test_init.py b/tests/components/python_script/test_init.py index 2d151b4b81e..14229e83662 100644 --- a/tests/components/python_script/test_init.py +++ b/tests/components/python_script/test_init.py @@ -711,4 +711,4 @@ async def test_no_other_imports_allowed( source = "import sys" hass.async_add_executor_job(execute, hass, "test.py", source, {}) await hass.async_block_till_done(wait_background_tasks=True) - assert "Error executing script: Not allowed to import sys" in caplog.text + assert "ImportError: Not allowed to import sys" in caplog.text From 07f3d939e343e4894e172f785ada0fe28dbbaadd Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 6 Jan 2025 15:10:29 +0100 Subject: [PATCH 0149/2987] Replace "id" with "ID" for consistency across HA (#134798) --- homeassistant/components/cambridge_audio/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cambridge_audio/strings.json b/homeassistant/components/cambridge_audio/strings.json index 9f5e031815b..6041232fe65 100644 --- a/homeassistant/components/cambridge_audio/strings.json +++ b/homeassistant/components/cambridge_audio/strings.json @@ -12,7 +12,7 @@ } }, "discovery_confirm": { - "description": "Do you want to setup {name}?" + "description": "Do you want to set up {name}?" }, "reconfigure": { "description": "Reconfigure your Cambridge Audio Streamer.", @@ -28,7 +28,7 @@ "cannot_connect": "Failed to connect to Cambridge Audio device. Please make sure the device is powered up and connected to the network. Try power-cycling the device if it does not connect." }, "abort": { - "wrong_device": "This Cambridge Audio device does not match the existing device id. Please make sure you entered the correct IP address.", + "wrong_device": "This Cambridge Audio device does not match the existing device ID. Please make sure you entered the correct IP address.", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" From 39d16ed5ceaf6c8b940633fdb45f226bdc63a9fb Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 6 Jan 2025 15:15:08 +0100 Subject: [PATCH 0150/2987] Fix a few typos or grammar issues in asus_wrt (#134813) --- homeassistant/components/asuswrt/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index bab40f281f5..9d50f50c7e9 100644 --- a/homeassistant/components/asuswrt/strings.json +++ b/homeassistant/components/asuswrt/strings.json @@ -31,8 +31,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "invalid_unique_id": "Impossible to determine a valid unique id for the device", - "no_unique_id": "A device without a valid unique id is already configured. Configuration of multiple instance is not possible" + "invalid_unique_id": "Impossible to determine a valid unique ID for the device", + "no_unique_id": "A device without a valid unique ID is already configured. Configuration of multiple instances is not possible" } }, "options": { @@ -42,7 +42,7 @@ "consider_home": "Seconds to wait before considering a device away", "track_unknown": "Track unknown / unnamed devices", "interface": "The interface that you want statistics from (e.g. eth0, eth1 etc)", - "dnsmasq": "The location in the router of the dnsmasq.leases files", + "dnsmasq": "The location of the dnsmasq.leases file in the router", "require_ip": "Devices must have IP (for access point mode)" } } From 43ffdd0eef847a6f815e2afd65f5d8150989244d Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 5 Jan 2025 22:25:44 +0100 Subject: [PATCH 0151/2987] Bump uiprotect to version 7.4.1 (#134829) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index d4877798208..018a600f037 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.2.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.4.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 4e91bca1cde..4fa7d7719cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2910,7 +2910,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.2.0 +uiprotect==7.4.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b601e01647..e2a0678fc72 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2332,7 +2332,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.2.0 +uiprotect==7.4.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 914c6459dc3a8b4fbb816ec3e676f124e1b861ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Jan 2025 12:44:37 -1000 Subject: [PATCH 0152/2987] Bump habluetooth to 3.7.0 (#134833) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index e25c077b57f..ef1ec6a8936 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.20.0", "dbus-fast==2.24.3", - "habluetooth==3.6.0" + "habluetooth==3.7.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b07909e08eb..5d3c4156a5c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -31,7 +31,7 @@ dbus-fast==2.24.3 fnv-hash-fast==1.0.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.6.0 +habluetooth==3.7.0 hass-nabucasa==0.87.0 hassil==2.1.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 4fa7d7719cd..08df15c57c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1091,7 +1091,7 @@ ha-philipsjs==3.2.2 habitipy==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.6.0 +habluetooth==3.7.0 # homeassistant.components.cloud hass-nabucasa==0.87.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e2a0678fc72..8c7998faac6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -932,7 +932,7 @@ ha-philipsjs==3.2.2 habitipy==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.6.0 +habluetooth==3.7.0 # homeassistant.components.cloud hass-nabucasa==0.87.0 From fe1ce3983185f22196a90202dde8c860465043c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Mon, 6 Jan 2025 15:03:25 +0100 Subject: [PATCH 0153/2987] Fix how function arguments are passed on actions at Home Connect (#134845) --- homeassistant/components/home_connect/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 818c4e6fe19..d7c042c2a91 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -168,7 +168,7 @@ async def _run_appliance_service[*_Ts]( error_translation_placeholders: dict[str, str], ) -> None: try: - await hass.async_add_executor_job(getattr(appliance, method), args) + await hass.async_add_executor_job(getattr(appliance, method), *args) except api.HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, From fbd031a03d6cce8e83542970da259b864e4d9289 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Tue, 7 Jan 2025 01:02:57 +1100 Subject: [PATCH 0154/2987] Bump aiolifx-themes to update colors (#134846) --- homeassistant/components/lifx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 2e16eb2082b..9940ee15dca 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -53,6 +53,6 @@ "requirements": [ "aiolifx==1.1.2", "aiolifx-effects==0.3.2", - "aiolifx-themes==0.5.5" + "aiolifx-themes==0.6.0" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 08df15c57c7..16a3bb8d6b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -282,7 +282,7 @@ aiokef==0.2.16 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.5.5 +aiolifx-themes==0.6.0 # homeassistant.components.lifx aiolifx==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8c7998faac6..e16cf18fbd0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -264,7 +264,7 @@ aiokafka==0.10.0 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.5.5 +aiolifx-themes==0.6.0 # homeassistant.components.lifx aiolifx==1.1.2 From 29989e903481cd4007373a0134661c67db6f892e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 6 Jan 2025 02:24:06 -0800 Subject: [PATCH 0155/2987] Update Roborock config flow message when an account is already configured (#134854) --- .../components/roborock/config_flow.py | 2 +- .../components/roborock/strings.json | 2 +- tests/components/roborock/conftest.py | 1 + tests/components/roborock/test_config_flow.py | 25 +++++++++++++++++++ 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 200614b024e..1a6b67286bb 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -60,7 +60,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: username = user_input[CONF_USERNAME] await self.async_set_unique_id(username.lower()) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(error="already_configured_account") self._username = username _LOGGER.debug("Requesting code for Roborock account") self._client = RoborockApiClient(username) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 8ff82cae393..c96a697ce2e 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -28,7 +28,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 357c644e2fe..44084574e01 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -161,6 +161,7 @@ def mock_roborock_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_USER_DATA: USER_DATA.as_dict(), CONF_BASE_URL: BASE_URL, }, + unique_id=USER_EMAIL, ) mock_entry.add_to_hass(hass) return mock_entry diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 39d8117847c..13bc23e6e2b 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -244,3 +244,28 @@ async def test_reauth_flow( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert mock_roborock_entry.data["user_data"]["rriot"]["s"] == "new_password_hash" + + +async def test_account_already_configured( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Handle the config flow and make sure it succeeds.""" + with patch( + "homeassistant.components.roborock.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USER_EMAIL} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_account" From 58805f721cd683264b63391b58019a7186d5da39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 6 Jan 2025 14:19:34 +0100 Subject: [PATCH 0156/2987] Log upload BackupAgentError (#134865) * Log out BackupAgentError * Update homeassistant/components/backup/manager.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/backup/manager.py Co-authored-by: Martin Hjelmare * Format --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/backup/manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 1910f8a55fb..ba1c457561f 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -435,6 +435,7 @@ class BackupManager: # no point in continuing raise BackupManagerError(str(result)) from result if isinstance(result, BackupAgentError): + LOGGER.error("Error uploading to %s: %s", agent_ids[idx], result) agent_errors[agent_ids[idx]] = result continue if isinstance(result, Exception): From e5c986171bd961a00df1f10173548ace4e03e3a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 6 Jan 2025 13:10:38 +0100 Subject: [PATCH 0157/2987] Log cloud backup upload response status (#134871) Log the status of the upload response --- homeassistant/components/cloud/backup.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 57145e52c44..b9da6dfb6a4 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -181,6 +181,11 @@ class CloudBackupAgent(BackupAgent): headers=details["headers"] | {"content-length": str(backup.size)}, timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h ) + _LOGGER.log( + logging.DEBUG if upload_status.status < 400 else logging.WARNING, + "Backup upload status: %s", + upload_status.status, + ) upload_status.raise_for_status() except (TimeoutError, ClientError) as err: raise BackupAgentError("Failed to upload backup") from err From 279785b22eeebaff0066395ccc47b9ca75029449 Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Tue, 7 Jan 2025 00:17:52 +1030 Subject: [PATCH 0158/2987] Bump solax to 3.2.3 (#134876) --- homeassistant/components/solax/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/solax/manifest.json b/homeassistant/components/solax/manifest.json index 631ace3792f..925f11e4c65 100644 --- a/homeassistant/components/solax/manifest.json +++ b/homeassistant/components/solax/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/solax", "iot_class": "local_polling", "loggers": ["solax"], - "requirements": ["solax==3.2.1"] + "requirements": ["solax==3.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 16a3bb8d6b6..3093615ce8b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2720,7 +2720,7 @@ solaredge-local==0.2.3 solarlog_cli==0.4.0 # homeassistant.components.solax -solax==3.2.1 +solax==3.2.3 # homeassistant.components.somfy_mylink somfy-mylink-synergy==1.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e16cf18fbd0..e747ca8f671 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2181,7 +2181,7 @@ soco==0.30.6 solarlog_cli==0.4.0 # homeassistant.components.solax -solax==3.2.1 +solax==3.2.3 # homeassistant.components.somfy_mylink somfy-mylink-synergy==1.0.6 From 2fc489d17dfe7bf253527a308ca710b7113c5ff9 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 6 Jan 2025 09:46:21 -0500 Subject: [PATCH 0159/2987] Add extra failure exceptions during roborock setup (#134889) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/roborock/__init__.py | 19 ++++++++- .../components/roborock/strings.json | 6 +++ tests/components/roborock/test_init.py | 39 ++++++++++++++++++- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index d02dddece42..bc82aadffed 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -9,7 +9,13 @@ from datetime import timedelta import logging from typing import Any -from roborock import HomeDataRoom, RoborockException, RoborockInvalidCredentials +from roborock import ( + HomeDataRoom, + RoborockException, + RoborockInvalidCredentials, + RoborockInvalidUserAgreement, + RoborockNoUserAgreement, +) from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, UserData from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_a01_apis import RoborockMqttClientA01 @@ -60,12 +66,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> translation_domain=DOMAIN, translation_key="invalid_credentials", ) from err + except RoborockInvalidUserAgreement as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="invalid_user_agreement", + ) from err + except RoborockNoUserAgreement as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="no_user_agreement", + ) from err except RoborockException as err: raise ConfigEntryNotReady( "Failed to get Roborock home data", translation_domain=DOMAIN, translation_key="home_data_fail", ) from err + _LOGGER.debug("Got home data %s", home_data) all_devices: list[HomeDataDevice] = home_data.devices + home_data.received_devices device_map: dict[str, HomeDataDevice] = { diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index c96a697ce2e..8c66f6ab986 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -422,6 +422,12 @@ }, "update_options_failed": { "message": "Failed to update Roborock options" + }, + "invalid_user_agreement": { + "message": "User agreement must be accepted again. Open your Roborock app and accept the agreement." + }, + "no_user_agreement": { + "message": "You have not valid user agreement. Open your Roborock app and accept the agreement." } }, "services": { diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index cace9a8ed67..4cd2a37effc 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -4,7 +4,12 @@ from copy import deepcopy from unittest.mock import patch import pytest -from roborock import RoborockException, RoborockInvalidCredentials +from roborock import ( + RoborockException, + RoborockInvalidCredentials, + RoborockInvalidUserAgreement, + RoborockNoUserAgreement, +) from homeassistant.components.roborock.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -194,3 +199,35 @@ async def test_not_supported_a01_device( await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert "The device you added is not yet supported" in caplog.text + + +async def test_invalid_user_agreement( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test that we fail setting up if the user agreement is out of date.""" + with patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + side_effect=RoborockInvalidUserAgreement(), + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + assert ( + mock_roborock_entry.error_reason_translation_key == "invalid_user_agreement" + ) + + +async def test_no_user_agreement( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test that we fail setting up if the user has no agreement.""" + with patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + side_effect=RoborockNoUserAgreement(), + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_roborock_entry.error_reason_translation_key == "no_user_agreement" From c40771ba6aa4e2a8b8c5ad57420e068ebb32ea20 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 6 Jan 2025 16:30:40 +0100 Subject: [PATCH 0160/2987] Use uppercase for "ID" and sentence-case for "name" / "icon" (#134890) --- homeassistant/components/androidtv_remote/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index 33970171d40..e41cbcf9a76 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -44,12 +44,12 @@ } }, "apps": { - "title": "Configure Android Apps", - "description": "Configure application id {app_id}", + "title": "Configure Android apps", + "description": "Configure application ID {app_id}", "data": { - "app_name": "Application Name", + "app_name": "Application name", "app_id": "Application ID", - "app_icon": "Application Icon", + "app_icon": "Application icon", "app_delete": "Check to delete this application" } } From 4867d3a187652e6cd58eba2dfa695b203ba71f7e Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 6 Jan 2025 15:58:33 +0000 Subject: [PATCH 0161/2987] Bump python-kasa to 0.9.1 (#134893) Bump tplink python-kasa dependency to 0.9.1 --- 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 7797f0a36a3..a975e675ceb 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -300,5 +300,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink", "iot_class": "local_polling", "loggers": ["kasa"], - "requirements": ["python-kasa[speedups]==0.9.0"] + "requirements": ["python-kasa[speedups]==0.9.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3093615ce8b..06e784ec9ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2378,7 +2378,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.9.0 +python-kasa[speedups]==0.9.1 # homeassistant.components.linkplay python-linkplay==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e747ca8f671..b184e4fbb58 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1914,7 +1914,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.9.0 +python-kasa[speedups]==0.9.1 # homeassistant.components.linkplay python-linkplay==0.1.1 From 9288dce7edbfe7565f506c75429b82e4805d6014 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 6 Jan 2025 20:37:01 +0100 Subject: [PATCH 0162/2987] Add `bring_api` to loggers in Bring integration (#134897) Add bring-api to loggers --- homeassistant/components/bring/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index ff24a991350..71fe733ccf5 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/bring", "integration_type": "service", "iot_class": "cloud_polling", + "loggers": ["bring_api"], "requirements": ["bring-api==0.9.1"] } From eb345971b44e6280554b543d4db19faf7e3f3136 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 6 Jan 2025 19:55:47 +0100 Subject: [PATCH 0163/2987] Fix wrong power limit decimal place in IronOS (#134902) --- homeassistant/components/iron_os/number.py | 4 ++-- tests/components/iron_os/snapshots/test_number.ambr | 8 ++++---- tests/components/iron_os/test_number.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py index 583844223dd..e50b227bbef 100644 --- a/homeassistant/components/iron_os/number.py +++ b/homeassistant/components/iron_os/number.py @@ -188,8 +188,8 @@ PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( characteristic=CharSetting.POWER_LIMIT, mode=NumberMode.BOX, native_min_value=0, - native_max_value=12, - native_step=0.1, + native_max_value=120, + native_step=5, entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfPower.WATT, entity_registry_enabled_default=False, diff --git a/tests/components/iron_os/snapshots/test_number.ambr b/tests/components/iron_os/snapshots/test_number.ambr index 24663cc4b0f..fc4fe96d746 100644 --- a/tests/components/iron_os/snapshots/test_number.ambr +++ b/tests/components/iron_os/snapshots/test_number.ambr @@ -620,10 +620,10 @@ }), 'area_id': None, 'capabilities': dict({ - 'max': 12, + 'max': 120, 'min': 0, 'mode': , - 'step': 0.1, + 'step': 5, }), 'config_entry_id': , 'device_class': None, @@ -656,10 +656,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Pinecil Power limit', - 'max': 12, + 'max': 120, 'min': 0, 'mode': , - 'step': 0.1, + 'step': 5, 'unit_of_measurement': , }), 'context': , diff --git a/tests/components/iron_os/test_number.py b/tests/components/iron_os/test_number.py index 088b66feb64..bdec922a88c 100644 --- a/tests/components/iron_os/test_number.py +++ b/tests/components/iron_os/test_number.py @@ -126,7 +126,7 @@ async def test_state( 2.0, 2.0, ), - ("number.pinecil_power_limit", CharSetting.POWER_LIMIT, 12.0, 12.0), + ("number.pinecil_power_limit", CharSetting.POWER_LIMIT, 120, 120), ("number.pinecil_quick_charge_voltage", CharSetting.QC_IDEAL_VOLTAGE, 9.0, 9.0), ( "number.pinecil_short_press_temperature_step", From 188def51c66acce3de2fa7b440b9026e7acced79 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 6 Jan 2025 19:11:01 +0100 Subject: [PATCH 0164/2987] Update frontend to 20250106.0 (#134905) --- 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 2094f817dcd..267374aa302 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==20250103.0"] + "requirements": ["home-assistant-frontend==20250106.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5d3c4156a5c..dac77fd4276 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ habluetooth==3.7.0 hass-nabucasa==0.87.0 hassil==2.1.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250103.0 +home-assistant-frontend==20250106.0 home-assistant-intents==2025.1.1 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 06e784ec9ac..af648a8993b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1134,7 +1134,7 @@ hole==0.8.0 holidays==0.63 # homeassistant.components.frontend -home-assistant-frontend==20250103.0 +home-assistant-frontend==20250106.0 # homeassistant.components.conversation home-assistant-intents==2025.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b184e4fbb58..6c8de46f147 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -963,7 +963,7 @@ hole==0.8.0 holidays==0.63 # homeassistant.components.frontend -home-assistant-frontend==20250103.0 +home-assistant-frontend==20250106.0 # homeassistant.components.conversation home-assistant-intents==2025.1.1 From 81a669c163a993e9be91640a62bb13a4a24f80f8 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Sun, 5 Jan 2025 10:10:55 +0100 Subject: [PATCH 0165/2987] Bump powerfox to v1.1.0 (#134730) --- homeassistant/components/powerfox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/powerfox/manifest.json b/homeassistant/components/powerfox/manifest.json index 7083ffe8de7..c5499c26dd7 100644 --- a/homeassistant/components/powerfox/manifest.json +++ b/homeassistant/components/powerfox/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/powerfox", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["powerfox==1.0.0"], + "requirements": ["powerfox==1.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index af648a8993b..9da0a3d62f7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1650,7 +1650,7 @@ pmsensor==0.4 poolsense==0.0.8 # homeassistant.components.powerfox -powerfox==1.0.0 +powerfox==1.1.0 # homeassistant.components.reddit praw==7.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c8de46f147..19bd9f6c0ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1360,7 +1360,7 @@ plumlightpad==0.0.11 poolsense==0.0.8 # homeassistant.components.powerfox -powerfox==1.0.0 +powerfox==1.1.0 # homeassistant.components.reddit praw==7.5.0 From b815899fdccbe73593ae17ee36e437dd2c35456c Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 6 Jan 2025 20:52:54 +0100 Subject: [PATCH 0166/2987] Bump powerfox to v1.2.0 (#134908) --- homeassistant/components/powerfox/coordinator.py | 3 ++- homeassistant/components/powerfox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/powerfox/coordinator.py b/homeassistant/components/powerfox/coordinator.py index f7ec5ab6716..a4a26759b69 100644 --- a/homeassistant/components/powerfox/coordinator.py +++ b/homeassistant/components/powerfox/coordinator.py @@ -7,6 +7,7 @@ from powerfox import ( Powerfox, PowerfoxAuthenticationError, PowerfoxConnectionError, + PowerfoxNoDataError, Poweropti, ) @@ -45,5 +46,5 @@ class PowerfoxDataUpdateCoordinator(DataUpdateCoordinator[Poweropti]): return await self.client.device(device_id=self.device.id) except PowerfoxAuthenticationError as err: raise ConfigEntryAuthFailed(err) from err - except PowerfoxConnectionError as err: + except (PowerfoxConnectionError, PowerfoxNoDataError) as err: raise UpdateFailed(err) from err diff --git a/homeassistant/components/powerfox/manifest.json b/homeassistant/components/powerfox/manifest.json index c5499c26dd7..bb72d73b5a8 100644 --- a/homeassistant/components/powerfox/manifest.json +++ b/homeassistant/components/powerfox/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/powerfox", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["powerfox==1.1.0"], + "requirements": ["powerfox==1.2.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 9da0a3d62f7..bdf5221bd7c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1650,7 +1650,7 @@ pmsensor==0.4 poolsense==0.0.8 # homeassistant.components.powerfox -powerfox==1.1.0 +powerfox==1.2.0 # homeassistant.components.reddit praw==7.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 19bd9f6c0ed..0e981563c35 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1360,7 +1360,7 @@ plumlightpad==0.0.11 poolsense==0.0.8 # homeassistant.components.powerfox -powerfox==1.1.0 +powerfox==1.2.0 # homeassistant.components.reddit praw==7.5.0 From 5337ab2e72ccf515693c62093f2e877a46d594c8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 6 Jan 2025 22:45:04 +0100 Subject: [PATCH 0167/2987] Bump holidays to 0.64 (#134922) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 33cae231595..09943faf0a2 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.63", "babel==2.15.0"] + "requirements": ["holidays==0.64", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index de9cbe694d8..bb5e6333b8b 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.63"] + "requirements": ["holidays==0.64"] } diff --git a/requirements_all.txt b/requirements_all.txt index bdf5221bd7c..e64a48cbb81 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1131,7 +1131,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.63 +holidays==0.64 # homeassistant.components.frontend home-assistant-frontend==20250106.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e981563c35..bf0bcb7f9d3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -960,7 +960,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.63 +holidays==0.64 # homeassistant.components.frontend home-assistant-frontend==20250106.0 From 9a9514d53b3737530a81908ea6b5d00777cf83b8 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 6 Jan 2025 22:39:24 +0100 Subject: [PATCH 0168/2987] Revert "Remove deprecated supported features warning in LightEntity" (#134927) --- homeassistant/components/light/__init__.py | 81 ++++- tests/components/light/common.py | 3 +- tests/components/light/test_init.py | 347 ++++++++++++++++++--- 3 files changed, 381 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 33bd259469b..76fbea70322 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -354,7 +354,7 @@ def filter_turn_off_params( if not params: return params - supported_features = light.supported_features + supported_features = light.supported_features_compat if LightEntityFeature.FLASH not in supported_features: params.pop(ATTR_FLASH, None) @@ -366,7 +366,7 @@ def filter_turn_off_params( def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]: """Filter out params not supported by the light.""" - supported_features = light.supported_features + supported_features = light.supported_features_compat if LightEntityFeature.EFFECT not in supported_features: params.pop(ATTR_EFFECT, None) @@ -1093,7 +1093,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat supported_color_modes = self._light_internal_supported_color_modes if ColorMode.COLOR_TEMP in supported_color_modes: @@ -1255,11 +1255,12 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def state_attributes(self) -> dict[str, Any] | None: """Return state attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat supported_color_modes = self.supported_color_modes legacy_supported_color_modes = ( supported_color_modes or self._light_internal_supported_color_modes ) + supported_features_value = supported_features.value _is_on = self.is_on color_mode = self._light_internal_color_mode if _is_on else None @@ -1278,6 +1279,13 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): data[ATTR_BRIGHTNESS] = self.brightness else: data[ATTR_BRIGHTNESS] = None + elif supported_features_value & _DEPRECATED_SUPPORT_BRIGHTNESS.value: + # Backwards compatibility for ambiguous / incomplete states + # Warning is printed by supported_features_compat, remove in 2025.1 + if _is_on: + data[ATTR_BRIGHTNESS] = self.brightness + else: + data[ATTR_BRIGHTNESS] = None if color_temp_supported(supported_color_modes): if color_mode == ColorMode.COLOR_TEMP: @@ -1292,6 +1300,21 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): else: data[ATTR_COLOR_TEMP_KELVIN] = None data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None + elif supported_features_value & _DEPRECATED_SUPPORT_COLOR_TEMP.value: + # Backwards compatibility + # Warning is printed by supported_features_compat, remove in 2025.1 + if _is_on: + color_temp_kelvin = self.color_temp_kelvin + data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin + if color_temp_kelvin: + data[_DEPRECATED_ATTR_COLOR_TEMP.value] = ( + color_util.color_temperature_kelvin_to_mired(color_temp_kelvin) + ) + else: + data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None + else: + data[ATTR_COLOR_TEMP_KELVIN] = None + data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None if color_supported(legacy_supported_color_modes) or color_temp_supported( legacy_supported_color_modes @@ -1329,7 +1352,24 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): type(self), report_issue, ) - return {ColorMode.ONOFF} + supported_features = self.supported_features_compat + supported_features_value = supported_features.value + supported_color_modes: set[ColorMode] = set() + + if supported_features_value & _DEPRECATED_SUPPORT_COLOR_TEMP.value: + supported_color_modes.add(ColorMode.COLOR_TEMP) + if supported_features_value & _DEPRECATED_SUPPORT_COLOR.value: + supported_color_modes.add(ColorMode.HS) + if ( + not supported_color_modes + and supported_features_value & _DEPRECATED_SUPPORT_BRIGHTNESS.value + ): + supported_color_modes = {ColorMode.BRIGHTNESS} + + if not supported_color_modes: + supported_color_modes = {ColorMode.ONOFF} + + return supported_color_modes @cached_property def supported_color_modes(self) -> set[ColorMode] | set[str] | None: @@ -1341,6 +1381,37 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Flag supported features.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> LightEntityFeature: + """Return the supported features as LightEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is not int: # noqa: E721 + return features + new_features = LightEntityFeature(features) + if self._deprecated_supported_features_reported is True: + return new_features + self._deprecated_supported_features_reported = True + report_issue = self._suggest_report_issue() + report_issue += ( + " and reference " + "https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation" + ) + _LOGGER.warning( + ( + "Entity %s (%s) is using deprecated supported features" + " values which will be removed in HA Core 2025.1. Instead it should use" + " %s and color modes, please %s" + ), + self.entity_id, + type(self), + repr(new_features), + report_issue, + ) + return new_features + def __should_report_light_issue(self) -> bool: """Return if light color mode issues should be reported.""" if not self.platform: diff --git a/tests/components/light/common.py b/tests/components/light/common.py index b29ac0c7c89..77411cd637d 100644 --- a/tests/components/light/common.py +++ b/tests/components/light/common.py @@ -26,7 +26,6 @@ from homeassistant.components.light import ( DOMAIN, ColorMode, LightEntity, - LightEntityFeature, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -157,7 +156,7 @@ class MockLight(MockToggleEntity, LightEntity): _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN - supported_features = LightEntityFeature(0) + supported_features = 0 brightness = None color_temp_kelvin = None diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 303bf68f68c..776995ee523 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1,6 +1,7 @@ """The tests for the Light component.""" from types import ModuleType +from typing import Literal from unittest.mock import MagicMock, mock_open, patch import pytest @@ -137,8 +138,13 @@ async def test_services( ent3.supported_color_modes = [light.ColorMode.HS] ent1.supported_features = light.LightEntityFeature.TRANSITION ent2.supported_features = ( - light.LightEntityFeature.EFFECT | light.LightEntityFeature.TRANSITION + light.SUPPORT_COLOR + | light.LightEntityFeature.EFFECT + | light.LightEntityFeature.TRANSITION ) + # Set color modes to none to trigger backwards compatibility in LightEntity + ent2.supported_color_modes = None + ent2.color_mode = None ent3.supported_features = ( light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION ) @@ -254,7 +260,10 @@ async def test_services( } _, data = ent2.last_call("turn_on") - assert data == {light.ATTR_EFFECT: "fun_effect"} + assert data == { + light.ATTR_EFFECT: "fun_effect", + light.ATTR_HS_COLOR: (0, 0), + } _, data = ent3.last_call("turn_on") assert data == {light.ATTR_FLASH: "short", light.ATTR_HS_COLOR: (71.059, 100)} @@ -338,6 +347,8 @@ async def test_services( _, data = ent2.last_call("turn_on") assert data == { + light.ATTR_BRIGHTNESS: 100, + light.ATTR_HS_COLOR: profile.hs_color, light.ATTR_TRANSITION: 1, } @@ -915,12 +926,16 @@ async def test_light_brightness_step(hass: HomeAssistant) -> None: setup_test_component_platform(hass, light.DOMAIN, entities) entity0 = entities[0] - entity0.supported_color_modes = {light.ColorMode.BRIGHTNESS} - entity0.color_mode = light.ColorMode.BRIGHTNESS + entity0.supported_features = light.SUPPORT_BRIGHTNESS + # Set color modes to none to trigger backwards compatibility in LightEntity + entity0.supported_color_modes = None + entity0.color_mode = None entity0.brightness = 100 entity1 = entities[1] - entity1.supported_color_modes = {light.ColorMode.BRIGHTNESS} - entity1.color_mode = light.ColorMode.BRIGHTNESS + entity1.supported_features = light.SUPPORT_BRIGHTNESS + # Set color modes to none to trigger backwards compatibility in LightEntity + entity1.supported_color_modes = None + entity1.color_mode = None entity1.brightness = 50 assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -981,8 +996,10 @@ async def test_light_brightness_pct_conversion( setup_test_component_platform(hass, light.DOMAIN, mock_light_entities) entity = mock_light_entities[0] - entity.supported_color_modes = {light.ColorMode.BRIGHTNESS} - entity.color_mode = light.ColorMode.BRIGHTNESS + entity.supported_features = light.SUPPORT_BRIGHTNESS + # Set color modes to none to trigger backwards compatibility in LightEntity + entity.supported_color_modes = None + entity.color_mode = None entity.brightness = 100 assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1131,6 +1148,167 @@ invalid_no_brightness_no_color_no_transition,,, assert invalid_profile_name not in profiles.data +@pytest.mark.parametrize("light_state", [STATE_ON, STATE_OFF]) +async def test_light_backwards_compatibility_supported_color_modes( + hass: HomeAssistant, light_state: Literal["on", "off"] +) -> None: + """Test supported_color_modes if not implemented by the entity.""" + entities = [ + MockLight("Test_0", light_state), + MockLight("Test_1", light_state), + MockLight("Test_2", light_state), + MockLight("Test_3", light_state), + MockLight("Test_4", light_state), + ] + + entity0 = entities[0] + + entity1 = entities[1] + entity1.supported_features = light.SUPPORT_BRIGHTNESS + # Set color modes to none to trigger backwards compatibility in LightEntity + entity1.supported_color_modes = None + entity1.color_mode = None + + entity2 = entities[2] + entity2.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR_TEMP + # Set color modes to none to trigger backwards compatibility in LightEntity + entity2.supported_color_modes = None + entity2.color_mode = None + + entity3 = entities[3] + entity3.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR + # Set color modes to none to trigger backwards compatibility in LightEntity + entity3.supported_color_modes = None + entity3.color_mode = None + + entity4 = entities[4] + entity4.supported_features = ( + light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR | light.SUPPORT_COLOR_TEMP + ) + # Set color modes to none to trigger backwards compatibility in LightEntity + entity4.supported_color_modes = None + entity4.color_mode = None + + setup_test_component_platform(hass, light.DOMAIN, entities) + + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.ONOFF] + if light_state == STATE_OFF: + assert state.attributes["color_mode"] is None + else: + assert state.attributes["color_mode"] == light.ColorMode.ONOFF + + state = hass.states.get(entity1.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.BRIGHTNESS] + if light_state == STATE_OFF: + assert state.attributes["color_mode"] is None + else: + assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN + + state = hass.states.get(entity2.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] + if light_state == STATE_OFF: + assert state.attributes["color_mode"] is None + else: + assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN + + state = hass.states.get(entity3.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.HS] + if light_state == STATE_OFF: + assert state.attributes["color_mode"] is None + else: + assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN + + state = hass.states.get(entity4.entity_id) + assert state.attributes["supported_color_modes"] == [ + light.ColorMode.COLOR_TEMP, + light.ColorMode.HS, + ] + if light_state == STATE_OFF: + assert state.attributes["color_mode"] is None + else: + assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN + + +async def test_light_backwards_compatibility_color_mode(hass: HomeAssistant) -> None: + """Test color_mode if not implemented by the entity.""" + entities = [ + MockLight("Test_0", STATE_ON), + MockLight("Test_1", STATE_ON), + MockLight("Test_2", STATE_ON), + MockLight("Test_3", STATE_ON), + MockLight("Test_4", STATE_ON), + ] + + entity0 = entities[0] + + entity1 = entities[1] + entity1.supported_features = light.SUPPORT_BRIGHTNESS + # Set color modes to none to trigger backwards compatibility in LightEntity + entity1.supported_color_modes = None + entity1.color_mode = None + entity1.brightness = 100 + + entity2 = entities[2] + entity2.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR_TEMP + # Set color modes to none to trigger backwards compatibility in LightEntity + entity2.supported_color_modes = None + entity2.color_mode = None + entity2.color_temp_kelvin = 10000 + + entity3 = entities[3] + entity3.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR + # Set color modes to none to trigger backwards compatibility in LightEntity + entity3.supported_color_modes = None + entity3.color_mode = None + entity3.hs_color = (240, 100) + + entity4 = entities[4] + entity4.supported_features = ( + light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR | light.SUPPORT_COLOR_TEMP + ) + # Set color modes to none to trigger backwards compatibility in LightEntity + entity4.supported_color_modes = None + entity4.color_mode = None + entity4.hs_color = (240, 100) + entity4.color_temp_kelvin = 10000 + + setup_test_component_platform(hass, light.DOMAIN, entities) + + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.ONOFF] + assert state.attributes["color_mode"] == light.ColorMode.ONOFF + + state = hass.states.get(entity1.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.BRIGHTNESS] + assert state.attributes["color_mode"] == light.ColorMode.BRIGHTNESS + + state = hass.states.get(entity2.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] + assert state.attributes["color_mode"] == light.ColorMode.COLOR_TEMP + assert state.attributes["rgb_color"] == (202, 218, 255) + assert state.attributes["hs_color"] == (221.575, 20.9) + assert state.attributes["xy_color"] == (0.278, 0.287) + + state = hass.states.get(entity3.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.HS] + assert state.attributes["color_mode"] == light.ColorMode.HS + + state = hass.states.get(entity4.entity_id) + assert state.attributes["supported_color_modes"] == [ + light.ColorMode.COLOR_TEMP, + light.ColorMode.HS, + ] + # hs color prioritized over color_temp, light should report mode ColorMode.HS + assert state.attributes["color_mode"] == light.ColorMode.HS + + async def test_light_service_call_rgbw(hass: HomeAssistant) -> None: """Test rgbw functionality in service calls.""" entity0 = MockLight("Test_rgbw", STATE_ON) @@ -1186,7 +1364,7 @@ async def test_light_state_off(hass: HomeAssistant) -> None: "color_mode": None, "friendly_name": "Test_onoff", "supported_color_modes": [light.ColorMode.ONOFF], - "supported_features": light.LightEntityFeature(0), + "supported_features": 0, } state = hass.states.get(entity1.entity_id) @@ -1194,7 +1372,7 @@ async def test_light_state_off(hass: HomeAssistant) -> None: "color_mode": None, "friendly_name": "Test_brightness", "supported_color_modes": [light.ColorMode.BRIGHTNESS], - "supported_features": light.LightEntityFeature(0), + "supported_features": 0, "brightness": None, } @@ -1203,7 +1381,7 @@ async def test_light_state_off(hass: HomeAssistant) -> None: "color_mode": None, "friendly_name": "Test_ct", "supported_color_modes": [light.ColorMode.COLOR_TEMP], - "supported_features": light.LightEntityFeature(0), + "supported_features": 0, "brightness": None, "color_temp": None, "color_temp_kelvin": None, @@ -1221,7 +1399,7 @@ async def test_light_state_off(hass: HomeAssistant) -> None: "color_mode": None, "friendly_name": "Test_rgbw", "supported_color_modes": [light.ColorMode.RGBW], - "supported_features": light.LightEntityFeature(0), + "supported_features": 0, "brightness": None, "rgbw_color": None, "hs_color": None, @@ -1252,7 +1430,7 @@ async def test_light_state_rgbw(hass: HomeAssistant) -> None: "color_mode": light.ColorMode.RGBW, "friendly_name": "Test_rgbw", "supported_color_modes": [light.ColorMode.RGBW], - "supported_features": light.LightEntityFeature(0), + "supported_features": 0, "hs_color": (240.0, 25.0), "rgb_color": (3, 3, 4), "rgbw_color": (1, 2, 3, 4), @@ -1283,7 +1461,7 @@ async def test_light_state_rgbww(hass: HomeAssistant) -> None: "color_mode": light.ColorMode.RGBWW, "friendly_name": "Test_rgbww", "supported_color_modes": [light.ColorMode.RGBWW], - "supported_features": light.LightEntityFeature(0), + "supported_features": 0, "hs_color": (60.0, 20.0), "rgb_color": (5, 5, 4), "rgbww_color": (1, 2, 3, 4, 5), @@ -1299,6 +1477,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: MockLight("Test_rgb", STATE_ON), MockLight("Test_xy", STATE_ON), MockLight("Test_all", STATE_ON), + MockLight("Test_legacy", STATE_ON), MockLight("Test_rgbw", STATE_ON), MockLight("Test_rgbww", STATE_ON), MockLight("Test_temperature", STATE_ON), @@ -1322,13 +1501,19 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: } entity4 = entities[4] - entity4.supported_color_modes = {light.ColorMode.RGBW} + entity4.supported_features = light.SUPPORT_COLOR + # Set color modes to none to trigger backwards compatibility in LightEntity + entity4.supported_color_modes = None + entity4.color_mode = None entity5 = entities[5] - entity5.supported_color_modes = {light.ColorMode.RGBWW} + entity5.supported_color_modes = {light.ColorMode.RGBW} entity6 = entities[6] - entity6.supported_color_modes = {light.ColorMode.COLOR_TEMP} + entity6.supported_color_modes = {light.ColorMode.RGBWW} + + entity7 = entities[7] + entity7.supported_color_modes = {light.ColorMode.COLOR_TEMP} assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1350,12 +1535,15 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: ] state = hass.states.get(entity4.entity_id) - assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBW] + assert state.attributes["supported_color_modes"] == [light.ColorMode.HS] state = hass.states.get(entity5.entity_id) - assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW] + assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBW] state = hass.states.get(entity6.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW] + + state = hass.states.get(entity7.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] await hass.services.async_call( @@ -1370,6 +1558,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 100, "hs_color": (240, 100), @@ -1385,10 +1574,12 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 255, "hs_color": (240.0, 100.0)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 255, "rgbw_color": (0, 0, 255, 0)} + assert data == {"brightness": 255, "hs_color": (240.0, 100.0)} _, data = entity5.last_call("turn_on") - assert data == {"brightness": 255, "rgbww_color": (0, 0, 255, 0, 0)} + assert data == {"brightness": 255, "rgbw_color": (0, 0, 255, 0)} _, data = entity6.last_call("turn_on") + assert data == {"brightness": 255, "rgbww_color": (0, 0, 255, 0, 0)} + _, data = entity7.last_call("turn_on") assert data == {"brightness": 255, "color_temp_kelvin": 1739, "color_temp": 575} await hass.services.async_call( @@ -1403,6 +1594,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 100, "hs_color": (240, 0), @@ -1418,11 +1610,13 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 255, "hs_color": (240.0, 0.0)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 255, "rgbw_color": (0, 0, 0, 255)} + assert data == {"brightness": 255, "hs_color": (240.0, 0.0)} _, data = entity5.last_call("turn_on") + assert data == {"brightness": 255, "rgbw_color": (0, 0, 0, 255)} + _, data = entity6.last_call("turn_on") # The midpoint of the white channels is warm, compensated by adding green + blue assert data == {"brightness": 255, "rgbww_color": (0, 76, 141, 255, 255)} - _, data = entity6.last_call("turn_on") + _, data = entity7.last_call("turn_on") assert data == {"brightness": 255, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( @@ -1437,6 +1631,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgb_color": (128, 0, 0), @@ -1451,12 +1646,13 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: assert data == {"brightness": 128, "xy_color": (0.701, 0.299)} _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "rgb_color": (128, 0, 0)} - _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 0)} + assert data == {"brightness": 128, "hs_color": (0.0, 100.0)} _, data = entity5.last_call("turn_on") - assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 0, 0)} + assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 0)} _, data = entity6.last_call("turn_on") + assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 0, 0)} + _, data = entity7.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 6279, "color_temp": 159} await hass.services.async_call( @@ -1471,6 +1667,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgb_color": (255, 255, 255), @@ -1486,11 +1683,13 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "rgb_color": (255, 255, 255)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "rgbw_color": (0, 0, 0, 255)} + assert data == {"brightness": 128, "hs_color": (0.0, 0.0)} _, data = entity5.last_call("turn_on") + assert data == {"brightness": 128, "rgbw_color": (0, 0, 0, 255)} + _, data = entity6.last_call("turn_on") # The midpoint the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} - _, data = entity6.last_call("turn_on") + _, data = entity7.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( @@ -1505,6 +1704,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "xy_color": (0.1, 0.8), @@ -1520,10 +1720,12 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "xy_color": (0.1, 0.8)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "rgbw_color": (0, 255, 22, 0)} + assert data == {"brightness": 128, "hs_color": (125.176, 100.0)} _, data = entity5.last_call("turn_on") - assert data == {"brightness": 128, "rgbww_color": (0, 255, 22, 0, 0)} + assert data == {"brightness": 128, "rgbw_color": (0, 255, 22, 0)} _, data = entity6.last_call("turn_on") + assert data == {"brightness": 128, "rgbww_color": (0, 255, 22, 0, 0)} + _, data = entity7.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 8645, "color_temp": 115} await hass.services.async_call( @@ -1538,6 +1740,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "xy_color": (0.323, 0.329), @@ -1553,11 +1756,13 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "xy_color": (0.323, 0.329)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "rgbw_color": (1, 0, 0, 255)} + assert data == {"brightness": 128, "hs_color": (0.0, 0.392)} _, data = entity5.last_call("turn_on") + assert data == {"brightness": 128, "rgbw_color": (1, 0, 0, 255)} + _, data = entity6.last_call("turn_on") # The midpoint the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 75, 140, 255, 255)} - _, data = entity6.last_call("turn_on") + _, data = entity7.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( @@ -1572,6 +1777,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbw_color": (128, 0, 0, 64), @@ -1587,11 +1793,13 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "rgb_color": (128, 43, 43)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 64)} + assert data == {"brightness": 128, "hs_color": (0.0, 66.406)} _, data = entity5.last_call("turn_on") + assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 64)} + _, data = entity6.last_call("turn_on") # The midpoint the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (128, 0, 30, 117, 117)} - _, data = entity6.last_call("turn_on") + _, data = entity7.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 3011, "color_temp": 332} await hass.services.async_call( @@ -1606,6 +1814,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbw_color": (255, 255, 255, 255), @@ -1621,11 +1830,13 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "rgb_color": (255, 255, 255)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "rgbw_color": (255, 255, 255, 255)} + assert data == {"brightness": 128, "hs_color": (0.0, 0.0)} _, data = entity5.last_call("turn_on") + assert data == {"brightness": 128, "rgbw_color": (255, 255, 255, 255)} + _, data = entity6.last_call("turn_on") # The midpoint the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} - _, data = entity6.last_call("turn_on") + _, data = entity7.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( @@ -1640,6 +1851,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbww_color": (128, 0, 0, 64, 32), @@ -1655,10 +1867,12 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "rgb_color": (128, 33, 26)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "rgbw_color": (128, 9, 0, 33)} + assert data == {"brightness": 128, "hs_color": (4.118, 79.688)} _, data = entity5.last_call("turn_on") - assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 64, 32)} + assert data == {"brightness": 128, "rgbw_color": (128, 9, 0, 33)} _, data = entity6.last_call("turn_on") + assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 64, 32)} + _, data = entity7.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 3845, "color_temp": 260} await hass.services.async_call( @@ -1673,6 +1887,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbww_color": (255, 255, 255, 255, 255), @@ -1688,11 +1903,13 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "rgb_color": (255, 217, 185)} _, data = entity4.last_call("turn_on") + assert data == {"brightness": 128, "hs_color": (27.429, 27.451)} + _, data = entity5.last_call("turn_on") # The midpoint the white channels is warm, compensated by decreasing green + blue assert data == {"brightness": 128, "rgbw_color": (96, 44, 0, 255)} - _, data = entity5.last_call("turn_on") - assert data == {"brightness": 128, "rgbww_color": (255, 255, 255, 255, 255)} _, data = entity6.last_call("turn_on") + assert data == {"brightness": 128, "rgbww_color": (255, 255, 255, 255, 255)} + _, data = entity7.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 3451, "color_temp": 289} @@ -1705,6 +1922,7 @@ async def test_light_service_call_color_conversion_named_tuple( MockLight("Test_rgb", STATE_ON), MockLight("Test_xy", STATE_ON), MockLight("Test_all", STATE_ON), + MockLight("Test_legacy", STATE_ON), MockLight("Test_rgbw", STATE_ON), MockLight("Test_rgbww", STATE_ON), ] @@ -1727,10 +1945,16 @@ async def test_light_service_call_color_conversion_named_tuple( } entity4 = entities[4] - entity4.supported_color_modes = {light.ColorMode.RGBW} + entity4.supported_features = light.SUPPORT_COLOR + # Set color modes to none to trigger backwards compatibility in LightEntity + entity4.supported_color_modes = None + entity4.color_mode = None entity5 = entities[5] - entity5.supported_color_modes = {light.ColorMode.RGBWW} + entity5.supported_color_modes = {light.ColorMode.RGBW} + + entity6 = entities[6] + entity6.supported_color_modes = {light.ColorMode.RGBWW} assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1746,6 +1970,7 @@ async def test_light_service_call_color_conversion_named_tuple( entity3.entity_id, entity4.entity_id, entity5.entity_id, + entity6.entity_id, ], "brightness_pct": 25, "rgb_color": color_util.RGBColor(128, 0, 0), @@ -1761,8 +1986,10 @@ async def test_light_service_call_color_conversion_named_tuple( _, data = entity3.last_call("turn_on") assert data == {"brightness": 64, "rgb_color": (128, 0, 0)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 64, "rgbw_color": (128, 0, 0, 0)} + assert data == {"brightness": 64, "hs_color": (0.0, 100.0)} _, data = entity5.last_call("turn_on") + assert data == {"brightness": 64, "rgbw_color": (128, 0, 0, 0)} + _, data = entity6.last_call("turn_on") assert data == {"brightness": 64, "rgbww_color": (128, 0, 0, 0, 0)} @@ -2131,6 +2358,13 @@ async def test_light_state_color_conversion(hass: HomeAssistant) -> None: entity2.rgb_color = "Invalid" # Should be ignored entity2.xy_color = (0.1, 0.8) + entity3 = entities[3] + entity3.hs_color = (240, 100) + entity3.supported_features = light.SUPPORT_COLOR + # Set color modes to none to trigger backwards compatibility in LightEntity + entity3.supported_color_modes = None + entity3.color_mode = None + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -2152,6 +2386,12 @@ async def test_light_state_color_conversion(hass: HomeAssistant) -> None: assert state.attributes["rgb_color"] == (0, 255, 22) assert state.attributes["xy_color"] == (0.1, 0.8) + state = hass.states.get(entity3.entity_id) + assert state.attributes["color_mode"] == light.ColorMode.HS + assert state.attributes["hs_color"] == (240, 100) + assert state.attributes["rgb_color"] == (0, 0, 255) + assert state.attributes["xy_color"] == (0.136, 0.04) + async def test_services_filter_parameters( hass: HomeAssistant, @@ -2386,6 +2626,27 @@ def test_filter_supported_color_modes() -> None: assert light.filter_supported_color_modes(supported) == {light.ColorMode.BRIGHTNESS} +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockLightEntityEntity(light.LightEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockLightEntityEntity() + assert entity.supported_features_compat is light.LightEntityFeature(1) + assert "MockLightEntityEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "LightEntityFeature" in caplog.text + assert "and color modes" in caplog.text + caplog.clear() + assert entity.supported_features_compat is light.LightEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text + + @pytest.mark.parametrize( ("color_mode", "supported_color_modes", "warning_expected"), [ From 7a5525951d9f52b5766b3a724041ca70eaeeb8ee Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Jan 2025 23:42:21 +0000 Subject: [PATCH 0169/2987] Bump version to 2025.1.1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5a088d36449..e641ae4254c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index c87e499155c..f94d54feb88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.1.0" +version = "2025.1.1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 298f05948808cbf5390c3bd16b004d23f08b453c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 7 Jan 2025 00:08:02 +0100 Subject: [PATCH 0170/2987] Revert "Remove deprecated supported features warning in ..." (multiple) (#134933) --- homeassistant/components/camera/__init__.py | 26 ++++++++-- homeassistant/components/cover/__init__.py | 4 ++ .../components/media_player/__init__.py | 51 ++++++++++++------- homeassistant/components/vacuum/__init__.py | 17 ++++++- homeassistant/helpers/entity.py | 27 +++++++++- tests/components/camera/test_init.py | 20 ++++++++ tests/components/cover/test_init.py | 19 +++++++ tests/components/media_player/test_init.py | 22 +++++++- tests/components/vacuum/test_init.py | 36 +++++++++++++ tests/helpers/test_entity.py | 26 ++++++++++ 10 files changed, 221 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 725fc84adc3..4d718433fca 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -516,6 +516,19 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Flag supported features.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> CameraEntityFeature: + """Return the supported features as CameraEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = CameraEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + @cached_property def is_recording(self) -> bool: """Return true if the device is recording.""" @@ -569,7 +582,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): self._deprecate_attr_frontend_stream_type_logged = True return self._attr_frontend_stream_type - if CameraEntityFeature.STREAM not in self.supported_features: + if CameraEntityFeature.STREAM not in self.supported_features_compat: return None if ( self._webrtc_provider @@ -798,7 +811,9 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): async def async_internal_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_internal_added_to_hass() - self.__supports_stream = self.supported_features & CameraEntityFeature.STREAM + self.__supports_stream = ( + self.supported_features_compat & CameraEntityFeature.STREAM + ) await self.async_refresh_providers(write_state=False) async def async_refresh_providers(self, *, write_state: bool = True) -> None: @@ -838,7 +853,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): self, fn: Callable[[HomeAssistant, Camera], Coroutine[None, None, _T | None]] ) -> _T | None: """Get first provider that supports this camera.""" - if CameraEntityFeature.STREAM not in self.supported_features: + if CameraEntityFeature.STREAM not in self.supported_features_compat: return None return await fn(self.hass, self) @@ -896,7 +911,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def camera_capabilities(self) -> CameraCapabilities: """Return the camera capabilities.""" frontend_stream_types = set() - if CameraEntityFeature.STREAM in self.supported_features: + if CameraEntityFeature.STREAM in self.supported_features_compat: if self._supports_native_sync_webrtc or self._supports_native_async_webrtc: # The camera has a native WebRTC implementation frontend_stream_types.add(StreamType.WEB_RTC) @@ -916,7 +931,8 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ super().async_write_ha_state() if self.__supports_stream != ( - supports_stream := self.supported_features & CameraEntityFeature.STREAM + supports_stream := self.supported_features_compat + & CameraEntityFeature.STREAM ): self.__supports_stream = supports_stream self._invalidate_camera_capabilities_cache() diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 9ce526712f0..001bff51991 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -300,6 +300,10 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" if (features := self._attr_supported_features) is not None: + if type(features) is int: # noqa: E721 + new_features = CoverEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features return features supported_features = ( diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index e7bbe1d19bd..291b1ec1e2a 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -773,6 +773,19 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Flag media player features that are supported.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> MediaPlayerEntityFeature: + """Return the supported features as MediaPlayerEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = MediaPlayerEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + def turn_on(self) -> None: """Turn the media player on.""" raise NotImplementedError @@ -912,85 +925,87 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def support_play(self) -> bool: """Boolean if play is supported.""" - return MediaPlayerEntityFeature.PLAY in self.supported_features + return MediaPlayerEntityFeature.PLAY in self.supported_features_compat @final @property def support_pause(self) -> bool: """Boolean if pause is supported.""" - return MediaPlayerEntityFeature.PAUSE in self.supported_features + return MediaPlayerEntityFeature.PAUSE in self.supported_features_compat @final @property def support_stop(self) -> bool: """Boolean if stop is supported.""" - return MediaPlayerEntityFeature.STOP in self.supported_features + return MediaPlayerEntityFeature.STOP in self.supported_features_compat @final @property def support_seek(self) -> bool: """Boolean if seek is supported.""" - return MediaPlayerEntityFeature.SEEK in self.supported_features + return MediaPlayerEntityFeature.SEEK in self.supported_features_compat @final @property def support_volume_set(self) -> bool: """Boolean if setting volume is supported.""" - return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features + return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat @final @property def support_volume_mute(self) -> bool: """Boolean if muting volume is supported.""" - return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features + return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features_compat @final @property def support_previous_track(self) -> bool: """Boolean if previous track command supported.""" - return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features + return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features_compat @final @property def support_next_track(self) -> bool: """Boolean if next track command supported.""" - return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features + return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features_compat @final @property def support_play_media(self) -> bool: """Boolean if play media command supported.""" - return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features + return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features_compat @final @property def support_select_source(self) -> bool: """Boolean if select source command supported.""" - return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features + return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features_compat @final @property def support_select_sound_mode(self) -> bool: """Boolean if select sound mode command supported.""" - return MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features + return ( + MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features_compat + ) @final @property def support_clear_playlist(self) -> bool: """Boolean if clear playlist command supported.""" - return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features + return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features_compat @final @property def support_shuffle_set(self) -> bool: """Boolean if shuffle is supported.""" - return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features + return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features_compat @final @property def support_grouping(self) -> bool: """Boolean if player grouping is supported.""" - return MediaPlayerEntityFeature.GROUPING in self.supported_features + return MediaPlayerEntityFeature.GROUPING in self.supported_features_compat async def async_toggle(self) -> None: """Toggle the power on the media player.""" @@ -1019,7 +1034,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ( self.volume_level is not None and self.volume_level < 1 - and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat ): await self.async_set_volume_level( min(1, self.volume_level + self.volume_step) @@ -1037,7 +1052,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ( self.volume_level is not None and self.volume_level > 0 - and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat ): await self.async_set_volume_level( max(0, self.volume_level - self.volume_step) @@ -1080,7 +1095,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat if ( source_list := self.source_list @@ -1286,7 +1301,7 @@ async def websocket_browse_media( connection.send_error(msg["id"], "entity_not_found", "Entity not found") return - if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features: + if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features_compat: connection.send_message( websocket_api.error_message( msg["id"], ERR_NOT_SUPPORTED, "Player does not support browsing media" diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 46e35bb3e11..6fe2c3e2a5b 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -312,7 +312,7 @@ class StateVacuumEntity( @property def capability_attributes(self) -> dict[str, Any] | None: """Return capability attributes.""" - if VacuumEntityFeature.FAN_SPEED in self.supported_features: + if VacuumEntityFeature.FAN_SPEED in self.supported_features_compat: return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} return None @@ -330,7 +330,7 @@ class StateVacuumEntity( def state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat if VacuumEntityFeature.BATTERY in supported_features: data[ATTR_BATTERY_LEVEL] = self.battery_level @@ -369,6 +369,19 @@ class StateVacuumEntity( """Flag vacuum cleaner features that are supported.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> VacuumEntityFeature: + """Return the supported features as VacuumEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = VacuumEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + def stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" raise NotImplementedError diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 91845cdf521..19076c4edc0 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -7,7 +7,7 @@ import asyncio from collections import deque from collections.abc import Callable, Coroutine, Iterable, Mapping import dataclasses -from enum import Enum, auto +from enum import Enum, IntFlag, auto import functools as ft import logging import math @@ -1639,6 +1639,31 @@ class Entity( self.hass, integration_domain=platform_name, module=type(self).__module__ ) + @callback + def _report_deprecated_supported_features_values( + self, replacement: IntFlag + ) -> None: + """Report deprecated supported features values.""" + if self._deprecated_supported_features_reported is True: + return + self._deprecated_supported_features_reported = True + report_issue = self._suggest_report_issue() + report_issue += ( + " and reference " + "https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation" + ) + _LOGGER.warning( + ( + "Entity %s (%s) is using deprecated supported features" + " values which will be removed in HA Core 2025.1. Instead it should use" + " %s, please %s" + ), + self.entity_id, + type(self), + repr(replacement), + report_issue, + ) + class ToggleEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes toggle entities.""" diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index a3045e27cf1..32520fcad23 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -826,6 +826,26 @@ def test_deprecated_state_constants( import_and_test_deprecated_constant_enum(caplog, module, enum, "STATE_", "2025.10") +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockCamera(camera.Camera): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockCamera() + assert entity.supported_features_compat is camera.CameraEntityFeature(1) + assert "MockCamera" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "CameraEntityFeature.ON_OFF" in caplog.text + caplog.clear() + assert entity.supported_features_compat is camera.CameraEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text + + @pytest.mark.usefixtures("mock_camera") async def test_entity_picture_url_changes_on_token_update(hass: HomeAssistant) -> None: """Test the token is rotated and entity entity picture cache is cleared.""" diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index e43b64b16a7..646c44e4ac2 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -2,6 +2,8 @@ from enum import Enum +import pytest + from homeassistant.components import cover from homeassistant.components.cover import CoverState from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, SERVICE_TOGGLE @@ -153,3 +155,20 @@ def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, s def test_all() -> None: """Test module.__all__ is correctly set.""" help_test_all(cover) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockCoverEntity(cover.CoverEntity): + _attr_supported_features = 1 + + entity = MockCoverEntity() + assert entity.supported_features is cover.CoverEntityFeature(1) + assert "MockCoverEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "CoverEntityFeature.OPEN" in caplog.text + caplog.clear() + assert entity.supported_features is cover.CoverEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 7c64f846df1..a45fa5b6668 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -129,7 +129,7 @@ def test_support_properties(property_suffix: str) -> None: entity3 = MediaPlayerEntity() entity3._attr_supported_features = feature entity4 = MediaPlayerEntity() - entity4._attr_supported_features = all_features & ~feature + entity4._attr_supported_features = all_features - feature assert getattr(entity1, f"support_{property_suffix}") is False assert getattr(entity2, f"support_{property_suffix}") is True @@ -447,3 +447,23 @@ async def test_get_async_get_browse_image_quoting( url = player.get_browse_image_url("album", media_content_id) await client.get(url) mock_browse_image.assert_called_with("album", media_content_id, None) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockMediaPlayerEntity(MediaPlayerEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockMediaPlayerEntity() + assert entity.supported_features_compat is MediaPlayerEntityFeature(1) + assert "MockMediaPlayerEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "MediaPlayerEntityFeature.PAUSE" in caplog.text + caplog.clear() + assert entity.supported_features_compat is MediaPlayerEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index db6cd242f3f..8babd9fa265 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -272,6 +272,42 @@ async def test_send_command(hass: HomeAssistant, config_flow_fixture: None) -> N assert "test" in strings +async def test_supported_features_compat(hass: HomeAssistant) -> None: + """Test StateVacuumEntity using deprecated feature constants features.""" + + features = ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE + ) + + class _LegacyConstantsStateVacuum(StateVacuumEntity): + _attr_supported_features = int(features) + _attr_fan_speed_list = ["silent", "normal", "pet hair"] + + entity = _LegacyConstantsStateVacuum() + assert isinstance(entity.supported_features, int) + assert entity.supported_features == int(features) + assert entity.supported_features_compat is ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE + ) + assert entity.state_attributes == { + "battery_level": None, + "battery_icon": "mdi:battery-unknown", + "fan_speed": None, + } + assert entity.capability_attributes == { + "fan_speed_list": ["silent", "normal", "pet hair"] + } + assert entity._deprecated_supported_features_reported + + async def test_vacuum_not_log_deprecated_state_warning( hass: HomeAssistant, mock_vacuum_entity: MockVacuum, diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index dc579ab6e8d..2bf441f70fd 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Iterable import dataclasses from datetime import timedelta +from enum import IntFlag import logging import threading from typing import Any @@ -2485,6 +2486,31 @@ async def test_cached_entity_property_override(hass: HomeAssistant) -> None: return "🤡" +async def test_entity_report_deprecated_supported_features_values( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test reporting deprecated supported feature values only happens once.""" + ent = entity.Entity() + + class MockEntityFeatures(IntFlag): + VALUE1 = 1 + VALUE2 = 2 + + ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) + assert ( + "is using deprecated supported features values which will be removed" + in caplog.text + ) + assert "MockEntityFeatures.VALUE2" in caplog.text + + caplog.clear() + ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) + assert ( + "is using deprecated supported features values which will be removed" + not in caplog.text + ) + + async def test_remove_entity_registry( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: From a1d43b93870d3396313ddfc2212b09c6d3fa4802 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 7 Jan 2025 22:11:24 +0000 Subject: [PATCH 0171/2987] Add weather warning sensor to IPMA (#134054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/ipma/sensor.py | 44 +++++++++++++++++----- homeassistant/components/ipma/strings.json | 9 +++++ tests/components/ipma/__init__.py | 15 ++++++++ tests/components/ipma/test_sensor.py | 16 ++++++++ 4 files changed, 75 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py index 5f2cb98646b..2a921cdbb04 100644 --- a/homeassistant/components/ipma/sensor.py +++ b/homeassistant/components/ipma/sensor.py @@ -4,8 +4,9 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine -from dataclasses import dataclass +from dataclasses import asdict, dataclass import logging +from typing import Any from pyipma.api import IPMA_API from pyipma.location import Location @@ -28,23 +29,41 @@ _LOGGER = logging.getLogger(__name__) class IPMASensorEntityDescription(SensorEntityDescription): """Describes a IPMA sensor entity.""" - value_fn: Callable[[Location, IPMA_API], Coroutine[Location, IPMA_API, int | None]] + value_fn: Callable[ + [Location, IPMA_API], Coroutine[Location, IPMA_API, tuple[Any, dict[str, Any]]] + ] -async def async_retrieve_rcm(location: Location, api: IPMA_API) -> int | None: +async def async_retrieve_rcm( + location: Location, api: IPMA_API +) -> tuple[int, dict[str, Any]] | tuple[None, dict[str, Any]]: """Retrieve RCM.""" fire_risk: RCM = await location.fire_risk(api) if fire_risk: - return fire_risk.rcm - return None + return fire_risk.rcm, {} + return None, {} -async def async_retrieve_uvi(location: Location, api: IPMA_API) -> int | None: +async def async_retrieve_uvi( + location: Location, api: IPMA_API +) -> tuple[int, dict[str, Any]] | tuple[None, dict[str, Any]]: """Retrieve UV.""" uv_risk: UV = await location.uv_risk(api) if uv_risk: - return round(uv_risk.iUv) - return None + return round(uv_risk.iUv), {} + return None, {} + + +async def async_retrieve_warning( + location: Location, api: IPMA_API +) -> tuple[Any, dict[str, str]]: + """Retrieve Warning.""" + warnings = await location.warnings(api) + if len(warnings): + return warnings[0].awarenessLevelID, { + k: str(v) for k, v in asdict(warnings[0]).items() + } + return "green", {} SENSOR_TYPES: tuple[IPMASensorEntityDescription, ...] = ( @@ -58,6 +77,11 @@ SENSOR_TYPES: tuple[IPMASensorEntityDescription, ...] = ( translation_key="uv_index", value_fn=async_retrieve_uvi, ), + IPMASensorEntityDescription( + key="alert", + translation_key="weather_alert", + value_fn=async_retrieve_warning, + ), ) @@ -94,6 +118,8 @@ class IPMASensor(SensorEntity, IPMADevice): async def async_update(self) -> None: """Update sensors.""" async with asyncio.timeout(10): - self._attr_native_value = await self.entity_description.value_fn( + state, attrs = await self.entity_description.value_fn( self._location, self._api ) + self._attr_native_value = state + self._attr_extra_state_attributes = attrs diff --git a/homeassistant/components/ipma/strings.json b/homeassistant/components/ipma/strings.json index ea5e5ff4759..ff9c23dd7ca 100644 --- a/homeassistant/components/ipma/strings.json +++ b/homeassistant/components/ipma/strings.json @@ -31,6 +31,15 @@ }, "uv_index": { "name": "UV index" + }, + "weather_alert": { + "name": "Weather Alert", + "state": { + "red": "Red", + "yellow": "Yellow", + "orange": "Orange", + "green": "Green" + } } } } diff --git a/tests/components/ipma/__init__.py b/tests/components/ipma/__init__.py index ab5998c922f..031ff3c31d4 100644 --- a/tests/components/ipma/__init__.py +++ b/tests/components/ipma/__init__.py @@ -6,6 +6,7 @@ from pyipma.forecast import Forecast, Forecast_Location, Weather_Type from pyipma.observation import Observation from pyipma.rcm import RCM from pyipma.uv import UV +from pyipma.warnings import Warning from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME @@ -20,6 +21,20 @@ ENTRY_CONFIG = { class MockLocation: """Mock Location from pyipma.""" + async def warnings(self, api): + """Mock Warnings.""" + return [ + Warning( + text="Na costa Sul, ondas de sueste com 2 a 2,5 metros, em especial " + "no barlavento.", + awarenessTypeName="Agitação Marítima", + idAreaAviso="FAR", + startTime=datetime(2024, 12, 26, 12, 24), + awarenessLevelID="yellow", + endTime=datetime(2024, 12, 28, 6, 0), + ) + ] + async def fire_risk(self, api): """Mock Fire Risk.""" return RCM("some place", 3, (0, 0)) diff --git a/tests/components/ipma/test_sensor.py b/tests/components/ipma/test_sensor.py index adff8206add..455a85002d3 100644 --- a/tests/components/ipma/test_sensor.py +++ b/tests/components/ipma/test_sensor.py @@ -35,3 +35,19 @@ async def test_ipma_uv_index_create_sensors(hass: HomeAssistant) -> None: state = hass.states.get("sensor.hometown_uv_index") assert state.state == "6" + + +async def test_ipma_warning_create_sensors(hass: HomeAssistant) -> None: + """Test creation of warning sensors.""" + + with patch("pyipma.location.Location.get", return_value=MockLocation()): + entry = MockConfigEntry(domain="ipma", data=ENTRY_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.hometown_weather_alert") + + assert state.state == "yellow" + + assert state.attributes["awarenessTypeName"] == "Agitação Marítima" From 20db7fdc96e06edaf76f02b146bedbb62fac69cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 8 Jan 2025 08:16:18 +0100 Subject: [PATCH 0172/2987] Implement upload retry logic in CloudBackupAgent (#135062) * Implement upload retry logic in CloudBackupAgent * Update backup.py Co-authored-by: Erik Montnemery * nit --------- Co-authored-by: Erik Montnemery --- homeassistant/components/cloud/backup.py | 97 +++++++++++++++++------- tests/components/cloud/test_backup.py | 7 ++ 2 files changed, 77 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 0f137553d34..153d0741770 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -2,10 +2,12 @@ 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 from aiohttp import ClientError, ClientTimeout @@ -27,6 +29,9 @@ from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT _LOGGER = logging.getLogger(__name__) _STORAGE_BACKUP = "backup" +_RETRY_LIMIT = 5 +_RETRY_SECONDS_MIN = 60 +_RETRY_SECONDS_MAX = 600 async def _b64md5(stream: AsyncIterator[bytes]) -> str: @@ -125,6 +130,44 @@ class CloudBackupAgent(BackupAgent): return ChunkAsyncStreamIterator(resp.content) + async def _async_do_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + filename: str, + base64md5hash: str, + metadata: dict[str, Any], + size: int, + ) -> None: + """Upload a backup.""" + try: + details = await async_files_upload_details( + self._cloud, + storage_type=_STORAGE_BACKUP, + filename=filename, + metadata=metadata, + size=size, + base64md5hash=base64md5hash, + ) + except (ClientError, CloudError) as err: + raise BackupAgentError("Failed to get upload details") from err + + try: + upload_status = await self._cloud.websession.put( + details["url"], + data=await open_stream(), + headers=details["headers"] | {"content-length": str(size)}, + timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h + ) + _LOGGER.log( + logging.DEBUG if upload_status.status < 400 else logging.WARNING, + "Backup upload status: %s", + upload_status.status, + ) + upload_status.raise_for_status() + except (TimeoutError, ClientError) as err: + raise BackupAgentError("Failed to upload backup") from err + async def async_upload_backup( self, *, @@ -141,34 +184,34 @@ class CloudBackupAgent(BackupAgent): raise BackupAgentError("Cloud backups must be protected") base64md5hash = await _b64md5(await open_stream()) + filename = self._get_backup_filename() + metadata = backup.as_dict() + size = backup.size - try: - details = await async_files_upload_details( - self._cloud, - storage_type=_STORAGE_BACKUP, - filename=self._get_backup_filename(), - metadata=backup.as_dict(), - size=backup.size, - base64md5hash=base64md5hash, - ) - except (ClientError, CloudError) as err: - raise BackupAgentError("Failed to get upload details") from err - - try: - upload_status = await self._cloud.websession.put( - details["url"], - data=await open_stream(), - headers=details["headers"] | {"content-length": str(backup.size)}, - timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h - ) - _LOGGER.log( - logging.DEBUG if upload_status.status < 400 else logging.WARNING, - "Backup upload status: %s", - upload_status.status, - ) - upload_status.raise_for_status() - except (TimeoutError, ClientError) as err: - raise BackupAgentError("Failed to upload backup") from err + tries = 1 + while tries <= _RETRY_LIMIT: + try: + await self._async_do_upload_backup( + open_stream=open_stream, + filename=filename, + base64md5hash=base64md5hash, + metadata=metadata, + size=size, + ) + break + except BackupAgentError as err: + if tries == _RETRY_LIMIT: + raise + tries += 1 + retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX) + _LOGGER.info( + "Failed to upload backup, retrying (%s/%s) in %ss: %s", + tries, + _RETRY_LIMIT, + retry_timer, + err, + ) + await asyncio.sleep(retry_timer) async def async_delete_backup( self, diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 5d9513a1d1b..fc8c7f27e56 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -389,6 +389,7 @@ async def test_agents_upload_fail_put( aioclient_mock: AiohttpClientMocker, mock_get_upload_details: Mock, put_mock_kwargs: dict[str, Any], + caplog: pytest.LogCaptureFixture, ) -> None: """Test agent upload backup fails.""" client = await hass_client() @@ -417,6 +418,9 @@ async def test_agents_upload_fail_put( return_value=test_backup, ), patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.cloud.backup.asyncio.sleep"), + 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""]) fetch_backup.return_value = test_backup @@ -426,6 +430,8 @@ async def test_agents_upload_fail_put( ) await hass.async_block_till_done() + assert len(aioclient_mock.mock_calls) == 2 + assert "Failed to upload backup, retrying (2/2) in 60s" in caplog.text assert resp.status == 201 store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] assert len(store_backups) == 1 @@ -469,6 +475,7 @@ async def test_agents_upload_fail_cloud( return_value=test_backup, ), patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.cloud.backup.asyncio.sleep"), ): mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) fetch_backup.return_value = test_backup From 85ecb04abff2004dac9d0ae1334788835d1027f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Jan 2025 22:19:03 -1000 Subject: [PATCH 0173/2987] Bump dbus-fast to 2.28.0 (#135049) changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.24.3...v2.28.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index ef1ec6a8936..b0ddac2f7f4 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bluetooth-adapters==0.20.2", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.20.0", - "dbus-fast==2.24.3", + "dbus-fast==2.28.0", "habluetooth==3.7.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 35a603415f9..715e98e56e6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.0 -dbus-fast==2.24.3 +dbus-fast==2.28.0 fnv-hash-fast==1.0.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 34e7a267c7d..04787269045 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -732,7 +732,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.24.3 +dbus-fast==2.28.0 # homeassistant.components.debugpy debugpy==1.8.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 59cd9320e44..36a03aaaae4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -628,7 +628,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.24.3 +dbus-fast==2.28.0 # homeassistant.components.debugpy debugpy==1.8.11 From b8f458458bc61213a630653d4ff8136d7221daf1 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Wed, 8 Jan 2025 09:24:09 +0100 Subject: [PATCH 0174/2987] Bump cookidoo-api to 0.12.2 (#135045) fix cookidoo .co.uk countries and group api endpoint --- homeassistant/components/cookidoo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cookidoo/manifest.json b/homeassistant/components/cookidoo/manifest.json index b1a3e9c0267..5264e47a709 100644 --- a/homeassistant/components/cookidoo/manifest.json +++ b/homeassistant/components/cookidoo/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["cookidoo_api"], "quality_scale": "silver", - "requirements": ["cookidoo-api==0.11.2"] + "requirements": ["cookidoo-api==0.12.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 04787269045..0b947fa205a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -710,7 +710,7 @@ connect-box==0.3.1 construct==2.10.68 # homeassistant.components.cookidoo -cookidoo-api==0.11.2 +cookidoo-api==0.12.2 # homeassistant.components.backup # homeassistant.components.utility_meter diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 36a03aaaae4..f4b28b12f18 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -606,7 +606,7 @@ colorthief==0.2.1 construct==2.10.68 # homeassistant.components.cookidoo -cookidoo-api==0.11.2 +cookidoo-api==0.12.2 # homeassistant.components.backup # homeassistant.components.utility_meter From 7daf44227178fb44a475386d09c580c0b724c3de Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 8 Jan 2025 09:26:48 +0100 Subject: [PATCH 0175/2987] Bump aioautomower to 2025.1.0 (#135039) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 02e87a3a772..1eed2be4575 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2024.12.0"] + "requirements": ["aioautomower==2025.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0b947fa205a..a932e4d2a02 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -201,7 +201,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.12.0 +aioautomower==2025.1.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f4b28b12f18..003864c9c8e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -189,7 +189,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.12.0 +aioautomower==2025.1.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 From d0005582277ba33d1b4cb96efd7284dbaecb9875 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 8 Jan 2025 09:28:01 +0100 Subject: [PATCH 0176/2987] Fix channel retrieval for Reolink DUO V1 connected to a NVR (#135035) fix channel retrieval for DUO V1 connected to a NVR --- homeassistant/components/reolink/util.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index f52cb08286c..f10da8e4b96 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -82,7 +82,8 @@ def get_device_uid_and_ch( ch = int(device_uid[1][5:]) is_chime = True else: - ch = host.api.channel_for_uid(device_uid[1]) + device_uid_part = "_".join(device_uid[1:]) + ch = host.api.channel_for_uid(device_uid_part) return (device_uid, ch, is_chime) From e99aaed7fab34ec30689f05e796cd87351e064ab Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 8 Jan 2025 09:30:14 +0100 Subject: [PATCH 0177/2987] Fix climate react type (#135030) --- homeassistant/components/sensibo/climate.py | 2 +- homeassistant/components/sensibo/sensor.py | 9 ++++++++- tests/components/sensibo/test_climate.py | 8 ++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index b35cb8af9a9..ff9aed6f4e7 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -199,7 +199,7 @@ async def async_setup_entry( vol.Required(ATTR_LOW_TEMPERATURE_THRESHOLD): vol.Coerce(float), vol.Required(ATTR_LOW_TEMPERATURE_STATE): dict, vol.Required(ATTR_SMART_TYPE): vol.In( - ["temperature", "feelsLike", "humidity"] + ["temperature", "feelslike", "humidity"] ), }, "async_enable_climate_react", diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index b395f8eb1ee..bea1326181c 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -36,6 +36,13 @@ from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity PARALLEL_UPDATES = 0 +def _smart_type_name(_type: str | None) -> str | None: + """Return a lowercase name of smart type.""" + if _type and _type == "feelsLike": + return "feelslike" + return _type + + @dataclass(frozen=True, kw_only=True) class SensiboMotionSensorEntityDescription(SensorEntityDescription): """Describes Sensibo Motion sensor entity.""" @@ -153,7 +160,7 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( SensiboDeviceSensorEntityDescription( key="climate_react_type", translation_key="smart_type", - value_fn=lambda data: data.smart_type, + value_fn=lambda data: _smart_type_name(data.smart_type), extra_fn=None, entity_registry_enabled_default=False, ), diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index d6176003582..7e848f3870c 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -954,7 +954,7 @@ async def test_climate_climate_react( "light": "on", }, "lowTemperatureThreshold": 5.5, - "type": "temperature", + "type": "feelsLike", }, } @@ -985,7 +985,7 @@ async def test_climate_climate_react( "horizontalSwing": "stopped", "light": "on", }, - ATTR_SMART_TYPE: "temperature", + ATTR_SMART_TYPE: "feelslike", }, blocking=True, ) @@ -993,7 +993,7 @@ async def test_climate_climate_react( mock_client.async_get_devices_data.return_value.parsed["ABC999111"].smart_on = True mock_client.async_get_devices_data.return_value.parsed[ "ABC999111" - ].smart_type = "temperature" + ].smart_type = "feelsLike" mock_client.async_get_devices_data.return_value.parsed[ "ABC999111" ].smart_low_temp_threshold = 5.5 @@ -1038,7 +1038,7 @@ async def test_climate_climate_react( hass.states.get("sensor.hallway_climate_react_high_temperature_threshold").state == "30.5" ) - assert hass.states.get("sensor.hallway_climate_react_type").state == "temperature" + assert hass.states.get("sensor.hallway_climate_react_type").state == "feelslike" @pytest.mark.usefixtures("entity_registry_enabled_by_default") From f8618e65f6bedf6c1b8863cbb58846636b310a4a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jan 2025 09:33:04 +0100 Subject: [PATCH 0178/2987] Improve type hints in onewire tests (#134993) --- tests/components/onewire/conftest.py | 8 ++++---- tests/components/onewire/test_binary_sensor.py | 5 +++-- tests/components/onewire/test_config_flow.py | 17 +++++++++-------- tests/components/onewire/test_diagnostics.py | 4 ++-- tests/components/onewire/test_init.py | 15 +++++++++------ tests/components/onewire/test_sensor.py | 7 ++++--- tests/components/onewire/test_switch.py | 7 ++++--- 7 files changed, 35 insertions(+), 28 deletions(-) diff --git a/tests/components/onewire/conftest.py b/tests/components/onewire/conftest.py index 65a86b58f2f..9d4303eaa1c 100644 --- a/tests/components/onewire/conftest.py +++ b/tests/components/onewire/conftest.py @@ -7,7 +7,7 @@ from pyownet.protocol import ConnError import pytest from homeassistant.components.onewire.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, ConfigEntry +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant @@ -32,7 +32,7 @@ def get_device_id(request: pytest.FixtureRequest) -> str: @pytest.fixture(name="config_entry") -def get_config_entry(hass: HomeAssistant) -> ConfigEntry: +def get_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Create and register mock config entry.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -54,14 +54,14 @@ def get_config_entry(hass: HomeAssistant) -> ConfigEntry: @pytest.fixture(name="owproxy") -def get_owproxy() -> MagicMock: +def get_owproxy() -> Generator[MagicMock]: """Mock owproxy.""" with patch("homeassistant.components.onewire.onewirehub.protocol.proxy") as owproxy: yield owproxy @pytest.fixture(name="owproxy_with_connerror") -def get_owproxy_with_connerror() -> MagicMock: +def get_owproxy_with_connerror() -> Generator[MagicMock]: """Mock owproxy.""" with patch( "homeassistant.components.onewire.onewirehub.protocol.proxy", diff --git a/tests/components/onewire/test_binary_sensor.py b/tests/components/onewire/test_binary_sensor.py index 31895f705ff..4f30689550a 100644 --- a/tests/components/onewire/test_binary_sensor.py +++ b/tests/components/onewire/test_binary_sensor.py @@ -6,13 +6,14 @@ from unittest.mock import MagicMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_owproxy_mock_devices +from tests.common import MockConfigEntry + @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: @@ -23,7 +24,7 @@ def override_platforms() -> Generator[None]: async def test_binary_sensors( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, device_id: str, device_registry: dr.DeviceRegistry, diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index cf61ab190db..e89450dd32b 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -11,7 +11,7 @@ from homeassistant.components.onewire.const import ( INPUT_ENTRY_DEVICE_SELECTION, MANUFACTURER_MAXIM, ) -from homeassistant.config_entries import SOURCE_USER, ConfigEntry +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -24,7 +24,8 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.fixture async def filled_device_registry( - hass: HomeAssistant, config_entry: ConfigEntry, device_registry: dr.DeviceRegistry + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, ) -> dr.DeviceRegistry: """Fill device registry with mock devices.""" for key in ("28.111111111111", "28.222222222222", "28.222222222223"): @@ -81,7 +82,7 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No async def test_user_duplicate( - hass: HomeAssistant, config_entry: ConfigEntry, mock_setup_entry: AsyncMock + hass: HomeAssistant, config_entry: MockConfigEntry, mock_setup_entry: AsyncMock ) -> None: """Test user duplicate flow.""" await hass.config_entries.async_setup(config_entry.entry_id) @@ -178,7 +179,7 @@ async def test_reconfigure_duplicate( @pytest.mark.usefixtures("filled_device_registry") async def test_user_options_clear( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test clearing the options.""" assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -202,7 +203,7 @@ async def test_user_options_clear( @pytest.mark.usefixtures("filled_device_registry") async def test_user_options_empty_selection( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test leaving the selection of devices empty.""" assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -227,7 +228,7 @@ async def test_user_options_empty_selection( @pytest.mark.usefixtures("filled_device_registry") async def test_user_options_set_single( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test configuring a single device.""" # Clear config options to certify functionality when starting from scratch @@ -265,7 +266,7 @@ async def test_user_options_set_single( async def test_user_options_set_multiple( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, filled_device_registry: dr.DeviceRegistry, ) -> None: """Test configuring multiple consecutive devices in a row.""" @@ -328,7 +329,7 @@ async def test_user_options_set_multiple( async def test_user_options_no_devices( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test that options does not change when no devices are available.""" assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/onewire/test_diagnostics.py b/tests/components/onewire/test_diagnostics.py index ecdae859597..c73558f2984 100644 --- a/tests/components/onewire/test_diagnostics.py +++ b/tests/components/onewire/test_diagnostics.py @@ -6,12 +6,12 @@ from unittest.mock import MagicMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from . import setup_owproxy_mock_devices +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -40,7 +40,7 @@ DEVICE_DETAILS = { @pytest.mark.parametrize("device_id", ["EF.111111111113"], indirect=True) async def test_entry_diagnostics( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, owproxy: MagicMock, device_id: str, diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 82ff75628c2..942abb865b9 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -7,7 +7,7 @@ from pyownet import protocol import pytest from homeassistant.components.onewire.const import DOMAIN -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -15,11 +15,14 @@ from homeassistant.setup import async_setup_component from . import setup_owproxy_mock_devices +from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @pytest.mark.usefixtures("owproxy_with_connerror") -async def test_connect_failure(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def test_connect_failure( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test connection failure raises ConfigEntryNotReady.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -29,7 +32,7 @@ async def test_connect_failure(hass: HomeAssistant, config_entry: ConfigEntry) - async def test_listing_failure( - hass: HomeAssistant, config_entry: ConfigEntry, owproxy: MagicMock + hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock ) -> None: """Test listing failure raises ConfigEntryNotReady.""" owproxy.return_value.dir.side_effect = protocol.OwnetError() @@ -42,7 +45,7 @@ async def test_listing_failure( @pytest.mark.usefixtures("owproxy") -async def test_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def test_unload_entry(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test being able to unload an entry.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -57,7 +60,7 @@ async def test_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N async def test_update_options( - hass: HomeAssistant, config_entry: ConfigEntry, owproxy: MagicMock + hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock ) -> None: """Test update options triggers reload.""" await hass.config_entries.async_setup(config_entry.entry_id) @@ -81,7 +84,7 @@ async def test_update_options( async def test_registry_cleanup( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, hass_ws_client: WebSocketGenerator, ) -> None: diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index ba0e21701f8..1cdb3b2b850 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -9,7 +9,6 @@ from pyownet.protocol import OwnetError import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -17,6 +16,8 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_owproxy_mock_devices from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES +from tests.common import MockConfigEntry + @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: @@ -27,7 +28,7 @@ def override_platforms() -> Generator[None]: async def test_sensors( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, device_id: str, device_registry: dr.DeviceRegistry, @@ -66,7 +67,7 @@ async def test_sensors( @pytest.mark.parametrize("device_id", ["12.111111111111"]) async def test_tai8570_sensors( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, device_id: str, entity_registry: er.EntityRegistry, diff --git a/tests/components/onewire/test_switch.py b/tests/components/onewire/test_switch.py index 936e83f66ec..681bf29cf48 100644 --- a/tests/components/onewire/test_switch.py +++ b/tests/components/onewire/test_switch.py @@ -7,7 +7,6 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TOGGLE, @@ -20,6 +19,8 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_owproxy_mock_devices +from tests.common import MockConfigEntry + @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: @@ -30,7 +31,7 @@ def override_platforms() -> Generator[None]: async def test_switches( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, device_id: str, device_registry: dr.DeviceRegistry, @@ -70,7 +71,7 @@ async def test_switches( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_switch_toggle( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, device_id: str, ) -> None: From dc1928f3eba4fc4b70242926ad7aecf6816a5b9e Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 8 Jan 2025 09:35:44 +0100 Subject: [PATCH 0179/2987] Delete KNX config storage when removing the integration (#135071) --- homeassistant/components/knx/__init__.py | 4 +++- tests/components/knx/test_init.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index edb9cc62008..7925628c079 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -91,7 +91,7 @@ from .schema import ( WeatherSchema, ) from .services import register_knx_services -from .storage.config_store import KNXConfigStore +from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY, KNXConfigStore from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams from .websocket import register_panel @@ -226,6 +226,8 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: if knxkeys_filename is not None: with contextlib.suppress(FileNotFoundError): (storage_dir / knxkeys_filename).unlink() + with contextlib.suppress(FileNotFoundError): + (storage_dir / CONFIG_STORAGE_KEY).unlink() with contextlib.suppress(FileNotFoundError): (storage_dir / PROJECT_STORAGE_KEY).unlink() with contextlib.suppress(FileNotFoundError): diff --git a/tests/components/knx/test_init.py b/tests/components/knx/test_init.py index 48cc46ef1ee..d005487b8f2 100644 --- a/tests/components/knx/test_init.py +++ b/tests/components/knx/test_init.py @@ -282,7 +282,7 @@ async def test_async_remove_entry( patch("pathlib.Path.rmdir") as rmdir_mock, ): assert await hass.config_entries.async_remove(config_entry.entry_id) - assert unlink_mock.call_count == 3 + assert unlink_mock.call_count == 4 rmdir_mock.assert_called_once() assert hass.config_entries.async_entries() == [] From 3fea4efb9fdb2647d39910809883a01df82e9f37 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 8 Jan 2025 02:36:02 -0600 Subject: [PATCH 0180/2987] Update pyheos to 0.9.0 (#134947) Bump pyheos --- homeassistant/components/heos/__init__.py | 14 ++-- homeassistant/components/heos/manifest.json | 2 +- homeassistant/components/heos/media_player.py | 18 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/heos/conftest.py | 83 +++++++++++-------- tests/components/heos/test_media_player.py | 34 ++++---- 7 files changed, 87 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index d983f3c2547..9fd276c244e 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -275,11 +275,11 @@ class GroupManager: player_id_to_entity_id_map = self.entity_id_map for group in groups.values(): - leader_entity_id = player_id_to_entity_id_map.get(group.leader.player_id) + leader_entity_id = player_id_to_entity_id_map.get(group.lead_player_id) member_entity_ids = [ - player_id_to_entity_id_map[member.player_id] - for member in group.members - if member.player_id in player_id_to_entity_id_map + player_id_to_entity_id_map[member] + for member in group.member_player_ids + if member in player_id_to_entity_id_map ] # Make sure the group leader is always the first element group_info = [leader_entity_id, *member_entity_ids] @@ -422,7 +422,7 @@ class SourceManager: None, ) if index is not None: - await player.play_favorite(index) + await player.play_preset_station(index) return input_source = next( @@ -434,7 +434,7 @@ class SourceManager: None, ) if input_source is not None: - await player.play_input_source(input_source) + await player.play_input_source(input_source.media_id) return _LOGGER.error("Unknown source: %s", source) @@ -447,7 +447,7 @@ class SourceManager: ( input_source.name for input_source in self.inputs - if input_source.input_name == now_playing_media.media_id + if input_source.media_id == now_playing_media.media_id ), None, ) diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 20694196e82..d14ad71ff49 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/heos", "iot_class": "local_push", "loggers": ["pyheos"], - "requirements": ["pyheos==0.8.0"], + "requirements": ["pyheos==0.9.0"], "single_config_entry": true, "ssdp": [ { diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index adbeadbc24f..924dcbe6b92 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -47,9 +47,9 @@ BASE_SUPPORTED_FEATURES = ( ) PLAY_STATE_TO_STATE = { - heos_const.PLAY_STATE_PLAY: MediaPlayerState.PLAYING, - heos_const.PLAY_STATE_STOP: MediaPlayerState.IDLE, - heos_const.PLAY_STATE_PAUSE: MediaPlayerState.PAUSED, + heos_const.PlayState.PLAY: MediaPlayerState.PLAYING, + heos_const.PlayState.STOP: MediaPlayerState.IDLE, + heos_const.PlayState.PAUSE: MediaPlayerState.PAUSED, } CONTROL_TO_SUPPORT = { @@ -61,11 +61,11 @@ CONTROL_TO_SUPPORT = { } HA_HEOS_ENQUEUE_MAP = { - None: heos_const.ADD_QUEUE_REPLACE_AND_PLAY, - MediaPlayerEnqueue.ADD: heos_const.ADD_QUEUE_ADD_TO_END, - MediaPlayerEnqueue.REPLACE: heos_const.ADD_QUEUE_REPLACE_AND_PLAY, - MediaPlayerEnqueue.NEXT: heos_const.ADD_QUEUE_PLAY_NEXT, - MediaPlayerEnqueue.PLAY: heos_const.ADD_QUEUE_PLAY_NOW, + None: heos_const.AddCriteriaType.REPLACE_AND_PLAY, + MediaPlayerEnqueue.ADD: heos_const.AddCriteriaType.ADD_TO_END, + MediaPlayerEnqueue.REPLACE: heos_const.AddCriteriaType.REPLACE_AND_PLAY, + MediaPlayerEnqueue.NEXT: heos_const.AddCriteriaType.PLAY_NEXT, + MediaPlayerEnqueue.PLAY: heos_const.AddCriteriaType.PLAY_NOW, } _LOGGER = logging.getLogger(__name__) @@ -268,7 +268,7 @@ class HeosMediaPlayer(MediaPlayerEntity): ) if index is None: raise ValueError(f"Invalid favorite '{media_id}'") - await self._player.play_favorite(index) + await self._player.play_preset_station(index) return raise ValueError(f"Unsupported media type '{media_type}'") diff --git a/requirements_all.txt b/requirements_all.txt index a932e4d2a02..55072438060 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1977,7 +1977,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==0.8.0 +pyheos==0.9.0 # homeassistant.components.hive pyhiveapi==0.5.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 003864c9c8e..2ebabbfa942 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1606,7 +1606,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==0.8.0 +pyheos==0.9.0 # homeassistant.components.hive pyhiveapi==0.5.16 diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 6451f5bc69e..eec74d2dd18 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -5,15 +5,7 @@ from __future__ import annotations from collections.abc import Sequence from unittest.mock import Mock, patch -from pyheos import ( - Dispatcher, - Heos, - HeosGroup, - HeosPlayer, - HeosSource, - InputSource, - const, -) +from pyheos import Dispatcher, Heos, HeosGroup, HeosPlayer, MediaItem, const import pytest import pytest_asyncio @@ -124,12 +116,13 @@ def player_fixture(quick_selects): player.version = "1.0.0" player.is_muted = False player.available = True - player.state = const.PLAY_STATE_STOP + player.state = const.PlayState.STOP player.ip_address = f"127.0.0.{i}" player.network = "wired" player.shuffle = False - player.repeat = const.REPEAT_OFF + player.repeat = const.RepeatType.OFF player.volume = 25 + player.now_playing_media = Mock() player.now_playing_media.supported_controls = const.CONTROLS_ALL player.now_playing_media.album_id = 1 player.now_playing_media.queue_id = 1 @@ -151,34 +144,52 @@ def player_fixture(quick_selects): @pytest.fixture(name="group") def group_fixture(players): """Create a HEOS group consisting of two players.""" - group = Mock(HeosGroup) - group.leader = players[1] - group.members = [players[2]] - group.group_id = 999 + group = HeosGroup( + name="Group", group_id=999, lead_player_id=1, member_player_ids=[2] + ) + return {group.group_id: group} @pytest.fixture(name="favorites") -def favorites_fixture() -> dict[int, HeosSource]: +def favorites_fixture() -> dict[int, MediaItem]: """Create favorites fixture.""" - station = Mock(HeosSource) - station.type = const.TYPE_STATION - station.name = "Today's Hits Radio" - station.media_id = "123456789" - radio = Mock(HeosSource) - radio.type = const.TYPE_STATION - radio.name = "Classical MPR (Classical Music)" - radio.media_id = "s1234" + station = MediaItem( + source_id=const.MUSIC_SOURCE_PANDORA, + name="Today's Hits Radio", + media_id="123456789", + type=const.MediaType.STATION, + playable=True, + browsable=False, + image_url="", + heos=None, + ) + radio = MediaItem( + source_id=const.MUSIC_SOURCE_TUNEIN, + name="Classical MPR (Classical Music)", + media_id="s1234", + type=const.MediaType.STATION, + playable=True, + browsable=False, + image_url="", + heos=None, + ) return {1: station, 2: radio} @pytest.fixture(name="input_sources") -def input_sources_fixture() -> Sequence[InputSource]: +def input_sources_fixture() -> Sequence[MediaItem]: """Create a set of input sources for testing.""" - source = Mock(InputSource) - source.player_id = 1 - source.input_name = const.INPUT_AUX_IN_1 - source.name = "HEOS Drive - Line In 1" + source = MediaItem( + source_id=1, + name="HEOS Drive - Line In 1", + media_id=const.INPUT_AUX_IN_1, + type=const.MediaType.STATION, + playable=True, + browsable=False, + image_url="", + heos=None, + ) return [source] @@ -240,11 +251,17 @@ def quick_selects_fixture() -> dict[int, str]: @pytest.fixture(name="playlists") -def playlists_fixture() -> Sequence[HeosSource]: +def playlists_fixture() -> Sequence[MediaItem]: """Create favorites fixture.""" - playlist = Mock(HeosSource) - playlist.type = const.TYPE_PLAYLIST - playlist.name = "Awesome Music" + playlist = MediaItem( + source_id=const.MUSIC_SOURCE_PLAYLISTS, + name="Awesome Music", + type=const.MediaType.PLAYLIST, + playable=True, + browsable=True, + image_url="", + heos=None, + ) return [playlist] diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 355cb47a0d9..155c425b91e 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -115,7 +115,7 @@ async def test_updates_from_signals( player = controller.players[1] # Test player does not update for other players - player.state = const.PLAY_STATE_PLAY + player.state = const.PlayState.PLAY player.heos.dispatcher.send( const.SIGNAL_PLAYER_EVENT, 2, const.EVENT_PLAYER_STATE_CHANGED ) @@ -124,7 +124,7 @@ async def test_updates_from_signals( assert state.state == STATE_IDLE # Test player_update standard events - player.state = const.PLAY_STATE_PLAY + player.state = const.PlayState.PLAY player.heos.dispatcher.send( const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) @@ -241,7 +241,7 @@ async def test_updates_from_players_changed( async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) assert hass.states.get("media_player.test_player").state == STATE_IDLE - player.state = const.PLAY_STATE_PLAY + player.state = const.PlayState.PLAY player.heos.dispatcher.send( const.SIGNAL_CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data ) @@ -551,7 +551,7 @@ async def test_select_favorite( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, blocking=True, ) - player.play_favorite.assert_called_once_with(1) + player.play_preset_station.assert_called_once_with(1) # Test state is matched by station name player.now_playing_media.station = favorite.name player.heos.dispatcher.send( @@ -576,7 +576,7 @@ async def test_select_radio_favorite( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, blocking=True, ) - player.play_favorite.assert_called_once_with(2) + player.play_preset_station.assert_called_once_with(2) # Test state is matched by album id player.now_playing_media.station = "Classical" player.now_playing_media.album_id = favorite.media_id @@ -601,14 +601,14 @@ async def test_select_radio_favorite_command_error( player = controller.players[1] # Test set radio preset favorite = favorites[2] - player.play_favorite.side_effect = CommandFailedError(None, "Failure", 1) + player.play_preset_station.side_effect = CommandFailedError(None, "Failure", 1) await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_SELECT_SOURCE, {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, blocking=True, ) - player.play_favorite.assert_called_once_with(2) + player.play_preset_station.assert_called_once_with(2) assert "Unable to select source: Failure (1)" in caplog.text @@ -629,7 +629,7 @@ async def test_select_input_source( }, blocking=True, ) - player.play_input_source.assert_called_once_with(input_source) + player.play_input_source.assert_called_once_with(input_source.media_id) # Test state is matched by media id player.now_playing_media.source_id = const.MUSIC_SOURCE_AUX_INPUT player.now_playing_media.media_id = const.INPUT_AUX_IN_1 @@ -681,7 +681,7 @@ async def test_select_input_command_error( }, blocking=True, ) - player.play_input_source.assert_called_once_with(input_source) + player.play_input_source.assert_called_once_with(input_source.media_id) assert "Unable to select source: Failure (1)" in caplog.text @@ -831,7 +831,7 @@ async def test_play_media_playlist( blocking=True, ) player.add_to_queue.assert_called_once_with( - playlist, const.ADD_QUEUE_REPLACE_AND_PLAY + playlist, const.AddCriteriaType.REPLACE_AND_PLAY ) # Play with enqueuing player.add_to_queue.reset_mock() @@ -846,7 +846,9 @@ async def test_play_media_playlist( }, blocking=True, ) - player.add_to_queue.assert_called_once_with(playlist, const.ADD_QUEUE_ADD_TO_END) + player.add_to_queue.assert_called_once_with( + playlist, const.AddCriteriaType.ADD_TO_END + ) # Invalid name player.add_to_queue.reset_mock() await hass.services.async_call( @@ -888,9 +890,9 @@ async def test_play_media_favorite( }, blocking=True, ) - player.play_favorite.assert_called_once_with(index) + player.play_preset_station.assert_called_once_with(index) # Play by name - player.play_favorite.reset_mock() + player.play_preset_station.reset_mock() await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -901,9 +903,9 @@ async def test_play_media_favorite( }, blocking=True, ) - player.play_favorite.assert_called_once_with(index) + player.play_preset_station.assert_called_once_with(index) # Invalid name - player.play_favorite.reset_mock() + player.play_preset_station.reset_mock() await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -914,7 +916,7 @@ async def test_play_media_favorite( }, blocking=True, ) - assert player.play_favorite.call_count == 0 + assert player.play_preset_station.call_count == 0 assert "Unable to play media: Invalid favorite 'Invalid'" in caplog.text From eff440d2a8e91b260cea4ada9475899c0c72f3aa Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 8 Jan 2025 04:51:57 -0500 Subject: [PATCH 0181/2987] Fix ZHA "referencing a non existing `via_device`" warning (#135008) --- homeassistant/components/zha/entity.py | 2 +- tests/components/zha/test_entity.py | 47 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/components/zha/test_entity.py diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 3e3d0642ca2..77ba048312a 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -87,7 +87,7 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity): manufacturer=zha_device_info[ATTR_MANUFACTURER], model=zha_device_info[ATTR_MODEL], name=zha_device_info[ATTR_NAME], - via_device=(DOMAIN, zha_gateway.state.node_info.ieee), + via_device=(DOMAIN, str(zha_gateway.state.node_info.ieee)), ) @callback diff --git a/tests/components/zha/test_entity.py b/tests/components/zha/test_entity.py new file mode 100644 index 00000000000..add98bb96bf --- /dev/null +++ b/tests/components/zha/test_entity.py @@ -0,0 +1,47 @@ +"""Test ZHA entities.""" + +from zigpy.profiles import zha +from zigpy.zcl.clusters import general + +from homeassistant.components.zha.helpers import get_zha_gateway +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + + +async def test_device_registry_via_device( + hass: HomeAssistant, + setup_zha, + zigpy_device_mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test ZHA `via_device` is set correctly.""" + + await setup_zha() + gateway = get_zha_gateway(hass) + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ) + + zha_device = gateway.get_or_create_device(zigpy_device) + await gateway.async_device_initialized(zigpy_device) + await hass.async_block_till_done(wait_background_tasks=True) + + reg_coordinator_device = device_registry.async_get_device( + identifiers={("zha", str(gateway.state.node_info.ieee))} + ) + + reg_device = device_registry.async_get_device( + identifiers={("zha", str(zha_device.ieee))} + ) + + assert reg_device.via_device_id == reg_coordinator_device.id From 7a2a6cf7d8bfff6aa5cb3f03d7bf20f382395448 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 8 Jan 2025 10:58:28 +0100 Subject: [PATCH 0182/2987] Add Reolink unexpected error translation (#134807) --- homeassistant/components/reolink/strings.json | 3 +++ homeassistant/components/reolink/util.py | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 9b15166361a..412362fc447 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -89,6 +89,9 @@ "timeout": { "message": "Timeout waiting on a response: {err}" }, + "unexpected": { + "message": "Unexpected Reolink error: {err}" + }, "firmware_install_error": { "message": "Error trying to update Reolink firmware: {err}" }, diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index f10da8e4b96..bf7018dfba2 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -168,6 +168,10 @@ def raise_translated_error( translation_placeholders={"err": str(err)}, ) from err except ReolinkError as err: - raise HomeAssistantError(err) from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unexpected", + translation_placeholders={"err": str(err)}, + ) from err return decorator_raise_translated_error From 43ec63eabce0dcf1fbe918723bca83265cf72125 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 8 Jan 2025 13:06:02 +0200 Subject: [PATCH 0183/2987] Cleanup LG webOS TV name (#135028) --- .../components/webostv/config_flow.py | 11 ++++--- homeassistant/components/webostv/const.py | 2 +- .../components/webostv/quality_scale.yaml | 6 ++-- homeassistant/components/webostv/strings.json | 7 ++-- tests/components/webostv/conftest.py | 12 +++++-- tests/components/webostv/const.py | 6 ++-- tests/components/webostv/test_config_flow.py | 32 ++++++++++++------- tests/components/webostv/test_diagnostics.py | 4 +-- tests/components/webostv/test_media_player.py | 4 +-- tests/components/webostv/test_notify.py | 24 +++++++------- 10 files changed, 67 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index 9a5eda7bbf7..c62ecaa78cf 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME +from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST from homeassistant.core import callback from homeassistant.helpers import config_validation as cv @@ -27,7 +27,6 @@ from .helpers import async_get_sources DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }, extra=vol.ALLOW_EXTRA, ) @@ -57,7 +56,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: self._host = user_input[CONF_HOST] - self._name = user_input[CONF_NAME] return await self.async_step_pairing() return self.async_show_form( @@ -86,6 +84,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured({CONF_HOST: self._host}) data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key} + + if not self._name: + self._name = f"{DEFAULT_NAME} {client.system_info["modelName"]}" return self.async_create_entry(title=self._name, data=data) return self.async_show_form(step_id="pairing", errors=errors) @@ -98,7 +99,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): host = urlparse(discovery_info.ssdp_location).hostname assert host self._host = host - self._name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, DEFAULT_NAME) + self._name = discovery_info.upnp.get( + ssdp.ATTR_UPNP_FRIENDLY_NAME, DEFAULT_NAME + ).replace("[LG]", "LG") uuid = discovery_info.upnp[ssdp.ATTR_UPNP_UDN] assert uuid diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py index c20060cae91..0d839568f13 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -11,7 +11,7 @@ DOMAIN = "webostv" PLATFORMS = [Platform.MEDIA_PLAYER] DATA_CONFIG_ENTRY = "config_entry" DATA_HASS_CONFIG = "hass_config" -DEFAULT_NAME = "LG webOS Smart TV" +DEFAULT_NAME = "LG webOS TV" ATTR_BUTTON = "button" ATTR_CONFIG_ENTRY_ID = "entry_id" diff --git a/homeassistant/components/webostv/quality_scale.yaml b/homeassistant/components/webostv/quality_scale.yaml index b6a6a5e99a4..693cefcdbfc 100644 --- a/homeassistant/components/webostv/quality_scale.yaml +++ b/homeassistant/components/webostv/quality_scale.yaml @@ -8,10 +8,12 @@ rules: common-modules: status: exempt comment: The integration does not use common patterns. - config-flow-test-coverage: todo + config-flow-test-coverage: + status: todo + comment: remove duplicated config flow start in tests, make sure tests ends with CREATE_ENTRY or ABORT, use hass.config_entries.async_setup instead of async_setup_component, snapshot in diagnostics (and other tests when possible), test_client_disconnected validate no error in log config-flow: status: todo - comment: remove duplicated config flow start in tests, make sure tests ends with CREATE_ENTRY or ABORT, remove name parameter, use hass.config_entries.async_setup instead of async_setup_component, snapshot in diagnostics (and other tests when possible), test_client_disconnected validate no error in log, make reauth flow more graceful + comment: make reauth flow more graceful dependency-transparency: done docs-actions: status: todo diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index 3ceab5f50a3..34c1b44e195 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -1,12 +1,11 @@ { "config": { - "flow_title": "LG webOS Smart TV", + "flow_title": "{name}", "step": { "user": { - "description": "Turn on TV, fill the following fields and select **Submit**", + "description": "Turn on the TV, fill the host field and select **Submit**", "data": { - "host": "[%key:common::config_flow::data::host%]", - "name": "[%key:common::config_flow::data::name%]" + "host": "[%key:common::config_flow::data::host%]" }, "data_description": { "host": "Hostname or IP address of your webOS TV." diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py index a30ae933cca..1e3f7ecdc67 100644 --- a/tests/components/webostv/conftest.py +++ b/tests/components/webostv/conftest.py @@ -7,7 +7,15 @@ import pytest from homeassistant.components.webostv.const import LIVE_TV_APP_ID -from .const import CHANNEL_1, CHANNEL_2, CLIENT_KEY, FAKE_UUID, MOCK_APPS, MOCK_INPUTS +from .const import ( + CHANNEL_1, + CHANNEL_2, + CLIENT_KEY, + FAKE_UUID, + MOCK_APPS, + MOCK_INPUTS, + TV_MODEL, +) @pytest.fixture @@ -28,7 +36,7 @@ def client_fixture(): client = mock_client_class.return_value client.hello_info = {"deviceUUID": FAKE_UUID} client.software_info = {"major_ver": "major", "minor_ver": "minor"} - client.system_info = {"modelName": "TVFAKE"} + client.system_info = {"modelName": TV_MODEL} client.client_key = CLIENT_KEY client.apps = MOCK_APPS client.inputs = MOCK_INPUTS diff --git a/tests/components/webostv/const.py b/tests/components/webostv/const.py index afaed224e83..52453d4ffa9 100644 --- a/tests/components/webostv/const.py +++ b/tests/components/webostv/const.py @@ -2,10 +2,12 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.webostv.const import LIVE_TV_APP_ID +from homeassistant.util import slugify FAKE_UUID = "some-fake-uuid" -TV_NAME = "fake_webos" -ENTITY_ID = f"{MP_DOMAIN}.{TV_NAME}" +TV_MODEL = "MODEL" +TV_NAME = f"LG webOS TV {TV_MODEL}" +ENTITY_ID = f"{MP_DOMAIN}.{slugify(TV_NAME)}" HOST = "1.2.3.4" CLIENT_KEY = "some-secret" diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index cc335a4fb41..1c0c0e935e5 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -10,26 +10,31 @@ from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN, LIVE_TV_APP_ID from homeassistant.config_entries import SOURCE_SSDP -from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME, CONF_SOURCE +from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import setup_webostv -from .const import CLIENT_KEY, FAKE_UUID, HOST, MOCK_APPS, MOCK_INPUTS, TV_NAME +from .const import ( + CLIENT_KEY, + FAKE_UUID, + HOST, + MOCK_APPS, + MOCK_INPUTS, + TV_MODEL, + TV_NAME, +) pytestmark = pytest.mark.usefixtures("mock_setup_entry") -MOCK_USER_CONFIG = { - CONF_HOST: HOST, - CONF_NAME: TV_NAME, -} +MOCK_USER_CONFIG = {CONF_HOST: HOST} MOCK_DISCOVERY_INFO = ssdp.SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{HOST}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "LG Webostv", + ssdp.ATTR_UPNP_FRIENDLY_NAME: f"[LG] webOS TV {TV_MODEL}", ssdp.ATTR_UPNP_UDN: f"uuid:{FAKE_UUID}", }, ) @@ -194,6 +199,14 @@ async def test_form_ssdp(hass: HomeAssistant, client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "pairing" + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == TV_NAME + async def test_ssdp_in_progress(hass: HomeAssistant, client) -> None: """Test abort if ssdp paring is already in progress.""" @@ -253,10 +266,7 @@ async def test_form_abort_uuid_configured(hass: HomeAssistant, client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - user_config = { - CONF_HOST: "new_host", - CONF_NAME: TV_NAME, - } + user_config = {CONF_HOST: "new_host"} result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/webostv/test_diagnostics.py b/tests/components/webostv/test_diagnostics.py index 3d7cb00e021..0dfb13b0424 100644 --- a/tests/components/webostv/test_diagnostics.py +++ b/tests/components/webostv/test_diagnostics.py @@ -36,7 +36,7 @@ async def test_diagnostics( "in1": {"appId": "app0", "id": "in1", "label": "Input01"}, "in2": {"appId": "app1", "id": "in2", "label": "Input02"}, }, - "system_info": {"modelName": "TVFAKE"}, + "system_info": {"modelName": "MODEL"}, "software_info": {"major_ver": "major", "minor_ver": "minor"}, "hello_info": {"deviceUUID": "**REDACTED**"}, "sound_output": "speaker", @@ -47,7 +47,7 @@ async def test_diagnostics( "version": 1, "minor_version": 1, "domain": "webostv", - "title": "fake_webos", + "title": "LG webOS TV MODEL", "data": { "client_secret": "**REDACTED**", "host": "**REDACTED**", diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index e4c02e680bd..7c89b749bbe 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -67,7 +67,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import setup_webostv -from .const import CHANNEL_2, ENTITY_ID, TV_NAME +from .const import CHANNEL_2, ENTITY_ID, TV_MODEL, TV_NAME from tests.common import async_fire_time_changed, mock_restore_cache from tests.test_util.aiohttp import AiohttpClientMocker @@ -340,7 +340,7 @@ async def test_entity_attributes( assert device.manufacturer == "LG" assert device.name == TV_NAME assert device.sw_version == "major.minor" - assert device.model == "TVFAKE" + assert device.model == TV_MODEL # Sound output when off monkeypatch.setattr(client, "sound_output", None) diff --git a/tests/components/webostv/test_notify.py b/tests/components/webostv/test_notify.py index 75c2e148310..2f29281a496 100644 --- a/tests/components/webostv/test_notify.py +++ b/tests/components/webostv/test_notify.py @@ -14,22 +14,24 @@ from homeassistant.components.webostv import DOMAIN from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import slugify from . import setup_webostv from .const import TV_NAME ICON_PATH = "/some/path" MESSAGE = "one, two, testing, testing" +SERVICE_NAME = slugify(TV_NAME) async def test_notify(hass: HomeAssistant, client) -> None: """Test sending a message.""" await setup_webostv(hass) - assert hass.services.has_service(NOTIFY_DOMAIN, TV_NAME) + assert hass.services.has_service(NOTIFY_DOMAIN, SERVICE_NAME) await hass.services.async_call( NOTIFY_DOMAIN, - TV_NAME, + SERVICE_NAME, { ATTR_MESSAGE: MESSAGE, ATTR_DATA: { @@ -44,7 +46,7 @@ async def test_notify(hass: HomeAssistant, client) -> None: await hass.services.async_call( NOTIFY_DOMAIN, - TV_NAME, + SERVICE_NAME, { ATTR_MESSAGE: MESSAGE, ATTR_DATA: { @@ -59,7 +61,7 @@ async def test_notify(hass: HomeAssistant, client) -> None: await hass.services.async_call( NOTIFY_DOMAIN, - TV_NAME, + SERVICE_NAME, { ATTR_MESSAGE: "only message, no data", }, @@ -77,12 +79,12 @@ async def test_notify_not_connected( ) -> None: """Test sending a message when client is not connected.""" await setup_webostv(hass) - assert hass.services.has_service(NOTIFY_DOMAIN, TV_NAME) + assert hass.services.has_service(NOTIFY_DOMAIN, SERVICE_NAME) monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) await hass.services.async_call( NOTIFY_DOMAIN, - TV_NAME, + SERVICE_NAME, { ATTR_MESSAGE: MESSAGE, ATTR_DATA: { @@ -104,12 +106,12 @@ async def test_icon_not_found( ) -> None: """Test notify icon not found error.""" await setup_webostv(hass) - assert hass.services.has_service(NOTIFY_DOMAIN, TV_NAME) + assert hass.services.has_service(NOTIFY_DOMAIN, SERVICE_NAME) monkeypatch.setattr(client, "send_message", Mock(side_effect=FileNotFoundError)) await hass.services.async_call( NOTIFY_DOMAIN, - TV_NAME, + SERVICE_NAME, { ATTR_MESSAGE: MESSAGE, ATTR_DATA: { @@ -141,13 +143,13 @@ async def test_connection_errors( ) -> None: """Test connection errors scenarios.""" await setup_webostv(hass) - assert hass.services.has_service("notify", TV_NAME) + assert hass.services.has_service("notify", SERVICE_NAME) monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) monkeypatch.setattr(client, "connect", Mock(side_effect=side_effect)) await hass.services.async_call( NOTIFY_DOMAIN, - TV_NAME, + SERVICE_NAME, { ATTR_MESSAGE: MESSAGE, ATTR_DATA: { @@ -175,4 +177,4 @@ async def test_no_discovery_info( await hass.async_block_till_done() assert NOTIFY_DOMAIN in hass.config.components assert f"Failed to initialize notification service {DOMAIN}" in caplog.text - assert not hass.services.has_service("notify", TV_NAME) + assert not hass.services.has_service("notify", SERVICE_NAME) From e052ab27f27279f9bee890ae8a6d9d39d2e53a8c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Jan 2025 12:20:35 +0100 Subject: [PATCH 0184/2987] Fix DSMR migration (#135068) --- homeassistant/components/dsmr/sensor.py | 33 ++++++---- tests/components/dsmr/test_mbus_migration.py | 63 +++++++++++++++++++- 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index f2b88c6c598..e05785b8b26 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -20,6 +20,7 @@ from dsmr_parser.objects import DSMRObject, MbusDevice, Telegram import serial from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -456,23 +457,29 @@ def rename_old_gas_to_mbus( if entity.unique_id.endswith( "belgium_5min_gas_meter_reading" ) or entity.unique_id.endswith("hourly_gas_meter_reading"): - try: - ent_reg.async_update_entity( - entity.entity_id, - new_unique_id=mbus_device_id, - ) - except ValueError: + if ent_reg.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, mbus_device_id + ): LOGGER.debug( "Skip migration of %s because it already exists", entity.entity_id, ) - else: - LOGGER.debug( - "Migrated entity %s from unique id %s to %s", - entity.entity_id, - entity.unique_id, - mbus_device_id, - ) + continue + new_device = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, mbus_device_id)}, + ) + ent_reg.async_update_entity( + entity.entity_id, + new_unique_id=mbus_device_id, + device_id=new_device.id, + ) + LOGGER.debug( + "Migrated entity %s from unique id %s to %s", + entity.entity_id, + entity.unique_id, + mbus_device_id, + ) # Cleanup old device dev_entities = er.async_entries_for_device( ent_reg, device_id, include_disabled_entities=True diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py index 7c7d182aa97..8c090690beb 100644 --- a/tests/components/dsmr/test_mbus_migration.py +++ b/tests/components/dsmr/test_mbus_migration.py @@ -10,6 +10,7 @@ from dsmr_parser.obis_references import ( MBUS_METER_READING, ) from dsmr_parser.objects import CosemObject, MBusObject, Telegram +import pytest from homeassistant.components.dsmr.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -102,6 +103,17 @@ async def test_migrate_gas_to_mbus( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() + # Check a new device is created and the old device has been removed + assert len(device_registry.devices) == 1 + assert not device_registry.async_get(device.id) + new_entity = entity_registry.async_get("sensor.gas_meter_reading") + new_device = device_registry.async_get(new_entity.device_id) + new_dev_entities = er.async_entries_for_device( + entity_registry, new_device.id, include_disabled_entities=True + ) + assert new_dev_entities == [new_entity] + + # Check no entities are connected to the old device dev_entities = er.async_entries_for_device( entity_registry, device.id, include_disabled_entities=True ) @@ -202,6 +214,17 @@ async def test_migrate_hourly_gas_to_mbus( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() + # Check a new device is created and the old device has been removed + assert len(device_registry.devices) == 1 + assert not device_registry.async_get(device.id) + new_entity = entity_registry.async_get("sensor.gas_meter_reading") + new_device = device_registry.async_get(new_entity.device_id) + new_dev_entities = er.async_entries_for_device( + entity_registry, new_device.id, include_disabled_entities=True + ) + assert new_dev_entities == [new_entity] + + # Check no entities are connected to the old device dev_entities = er.async_entries_for_device( entity_registry, device.id, include_disabled_entities=True ) @@ -302,6 +325,18 @@ async def test_migrate_gas_with_devid_to_mbus( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() + # Check a new device is not created and the old device has not been removed + assert len(device_registry.devices) == 1 + assert device_registry.async_get(device.id) + new_entity = entity_registry.async_get("sensor.gas_meter_reading") + new_device = device_registry.async_get(new_entity.device_id) + assert new_device.id == device.id + # Check entities are still connected to the old device + dev_entities = er.async_entries_for_device( + entity_registry, device.id, include_disabled_entities=True + ) + assert dev_entities == [new_entity] + assert ( entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) is None @@ -319,6 +354,7 @@ async def test_migrate_gas_to_mbus_exists( entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock], + caplog: pytest.LogCaptureFixture, ) -> None: """Test migration of unique_id.""" (connection_factory, transport, protocol) = dsmr_connection_fixture @@ -380,7 +416,7 @@ async def test_migrate_gas_to_mbus_exists( telegram = Telegram() telegram.add( MBUS_DEVICE_TYPE, - CosemObject((0, 0), [{"value": "003", "unit": ""}]), + CosemObject((0, 1), [{"value": "003", "unit": ""}]), "MBUS_DEVICE_TYPE", ) telegram.add( @@ -414,7 +450,32 @@ async def test_migrate_gas_to_mbus_exists( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() + # Check a new device is not created and the old device has not been removed + assert len(device_registry.devices) == 2 + assert device_registry.async_get(device.id) + assert device_registry.async_get(device2.id) + entity = entity_registry.async_get("sensor.gas_meter_reading") + dev_entities = er.async_entries_for_device( + entity_registry, device.id, include_disabled_entities=True + ) + assert dev_entities == [entity] + entity2 = entity_registry.async_get("sensor.gas_meter_reading_alt") + dev2_entities = er.async_entries_for_device( + entity_registry, device2.id, include_disabled_entities=True + ) + assert dev2_entities == [entity2] + assert ( entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) == "sensor.gas_meter_reading" ) + assert ( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, "37464C4F32313139303333373331" + ) + == "sensor.gas_meter_reading_alt" + ) + assert ( + "Skip migration of sensor.gas_meter_reading because it already exists" + in caplog.text + ) From 8be01ac9d6d73a2d5428587dfcc9fa10165aba87 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Wed, 8 Jan 2025 01:37:04 -1000 Subject: [PATCH 0185/2987] TotalConnect improved config flow and test before setup (#133852) Co-authored-by: Joost Lekkerkerker --- .../totalconnect/quality_scale.yaml | 4 ++-- .../components/totalconnect/strings.json | 23 +++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/totalconnect/quality_scale.yaml b/homeassistant/components/totalconnect/quality_scale.yaml index fb0f1e5098a..606f1b3b6c3 100644 --- a/homeassistant/components/totalconnect/quality_scale.yaml +++ b/homeassistant/components/totalconnect/quality_scale.yaml @@ -1,11 +1,11 @@ rules: # Bronze - config-flow: todo + config-flow: done test-before-configure: done unique-config-entry: done config-flow-test-coverage: todo runtime-data: done - test-before-setup: todo + test-before-setup: done appropriate-polling: done entity-unique-id: done has-entity-name: done diff --git a/homeassistant/components/totalconnect/strings.json b/homeassistant/components/totalconnect/strings.json index 004056ef9ac..daf720084a5 100644 --- a/homeassistant/components/totalconnect/strings.json +++ b/homeassistant/components/totalconnect/strings.json @@ -2,21 +2,36 @@ "config": { "step": { "user": { + "title": "Total Connect 2.0 Account Credentials", + "description": "It is highly recommended to use a 'standard' Total Connect user account with Home Assistant. The account should not have full administrative privileges.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "The Total Connect username", + "password": "The Total Connect password" } }, "locations": { "title": "Location Usercodes", "description": "Enter the usercode for this user at location {location_id}", "data": { - "usercode": "Usercode" + "usercodes": "Usercode" + }, + "data_description": { + "usercodes": "The usercode is usually a 4 digit number" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "Total Connect needs to re-authenticate your account" + "description": "Total Connect needs to re-authenticate your account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::totalconnect::config::step::user::data_description::password%]" + } } }, "error": { @@ -36,6 +51,10 @@ "data": { "auto_bypass_low_battery": "Auto bypass low battery", "code_required": "Require user to enter code for alarm actions" + }, + "data_description": { + "auto_bypass_low_battery": "If enabled, Total Connect zones will immediately be bypassed when they report low battery. This option helps because zones tend to report low battery in the middle of the night. The downside of this option is that when the alarm system is armed, the bypassed zone will not be monitored.", + "code_required": "If enabled, you must enter the user code to arm or disarm the alarm" } } } From d43187327f714bcefcad755fcdf6f63d8efb7afd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:25:05 +0100 Subject: [PATCH 0186/2987] Remove rounding from onewire sensors (#135095) --- homeassistant/components/onewire/entity.py | 2 +- .../onewire/snapshots/test_sensor.ambr | 90 +++++++++---------- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/onewire/entity.py b/homeassistant/components/onewire/entity.py index bbf36deaaa0..c8ad87fa34e 100644 --- a/homeassistant/components/onewire/entity.py +++ b/homeassistant/components/onewire/entity.py @@ -84,4 +84,4 @@ class OneWireEntity(Entity): elif self.entity_description.read_mode == READ_MODE_BOOL: self._state = int(self._value_raw) == 1 else: - self._state = round(self._value_raw, 1) + self._state = self._value_raw diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index 261b081060c..b251e7e181c 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -140,7 +140,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '25.1', + 'state': '25.123', }), ]) # --- @@ -264,7 +264,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '25.1', + 'state': '25.123', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -280,7 +280,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1025.1', + 'state': '1025.123', }), ]) # --- @@ -1106,7 +1106,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '25.1', + 'state': '25.123', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1122,7 +1122,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '72.8', + 'state': '72.7563', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1138,7 +1138,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '73.8', + 'state': '73.7563', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1154,7 +1154,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '74.8', + 'state': '74.7563', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1170,7 +1170,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '75.8', + 'state': '75.7563', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1202,7 +1202,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '969.3', + 'state': '969.265', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1218,7 +1218,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '65.9', + 'state': '65.8839', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1234,7 +1234,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3.0', + 'state': '2.97', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1250,7 +1250,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '4.7', + 'state': '4.74', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1266,7 +1266,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.1', + 'state': '0.12', }), ]) # --- @@ -1357,7 +1357,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '27.0', + 'state': '26.984', }), ]) # --- @@ -1448,7 +1448,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '27.0', + 'state': '26.984', }), ]) # --- @@ -1539,7 +1539,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '27.0', + 'state': '26.984', }), ]) # --- @@ -1771,7 +1771,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '27.0', + 'state': '26.984', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1787,7 +1787,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '173.8', + 'state': '173.7563', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1803,7 +1803,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3.0', + 'state': '2.97', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1819,7 +1819,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.1', + 'state': '0.12', }), ]) # --- @@ -1952,7 +1952,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '28.2', + 'state': '28.243', }), ]) # --- @@ -2043,7 +2043,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '29.1', + 'state': '29.123', }), ]) # --- @@ -2233,7 +2233,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '13.9', + 'state': '13.9375', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2249,7 +2249,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1012.2', + 'state': '1012.21', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2265,7 +2265,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '65.9', + 'state': '65.8839', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2281,7 +2281,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '41.4', + 'state': '41.375', }), ]) # --- @@ -2405,7 +2405,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '13.9', + 'state': '13.9375', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2421,7 +2421,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1012.2', + 'state': '1012.21', }), ]) # --- @@ -2842,7 +2842,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '25.1', + 'state': '25.123', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2858,7 +2858,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '72.8', + 'state': '72.7563', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2874,7 +2874,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '73.8', + 'state': '73.7563', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2890,7 +2890,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '74.8', + 'state': '74.7563', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2906,7 +2906,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '75.8', + 'state': '75.7563', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2938,7 +2938,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '969.3', + 'state': '969.265', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2954,7 +2954,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '65.9', + 'state': '65.8839', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2970,7 +2970,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3.0', + 'state': '2.97', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2986,7 +2986,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '4.7', + 'state': '4.74', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3002,7 +3002,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.1', + 'state': '0.12', }), ]) # --- @@ -3159,7 +3159,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '67.7', + 'state': '67.745', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3175,7 +3175,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '65.5', + 'state': '65.541', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3191,7 +3191,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '25.1', + 'state': '25.123', }), ]) # --- @@ -3381,7 +3381,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '41.7', + 'state': '41.745', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3397,7 +3397,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '42.5', + 'state': '42.541', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3413,7 +3413,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '43.1', + 'state': '43.123', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3429,7 +3429,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '44.1', + 'state': '44.123', }), ]) # --- From ec7d2f373134e611103305dfd3b1a3755e292173 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:41:20 +0100 Subject: [PATCH 0187/2987] Add quality_scale file to onewire (#134951) --- .../components/onewire/quality_scale.yaml | 130 ++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/onewire/quality_scale.yaml diff --git a/homeassistant/components/onewire/quality_scale.yaml b/homeassistant/components/onewire/quality_scale.yaml new file mode 100644 index 00000000000..726587d13b2 --- /dev/null +++ b/homeassistant/components/onewire/quality_scale.yaml @@ -0,0 +1,130 @@ +rules: + ## Bronze + config-flow: + status: todo + comment: missing data_description on options flow + test-before-configure: done + unique-config-entry: + status: done + comment: unique ID is not available, but duplicates are prevented based on host/port + config-flow-test-coverage: + status: todo + comment: > + Let's have test_user_options_empty_selection end in CREATE_ENTRY + Split the happy flow and the not happy flow in test_user_flow + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: entities do not subscribe to events + dependency-transparency: + status: todo + comment: The package is not built and published inside a CI pipeline + action-setup: + status: exempt + comment: No service actions currently available + common-modules: + status: done + comment: base entity available, but no coordinator + docs-high-level-description: + status: todo + comment: Under review + docs-installation-instructions: + status: todo + comment: Under review + docs-removal-instructions: + status: todo + comment: Under review + docs-actions: + status: todo + comment: Under review + brands: done + + ## Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: No service actions currently available + reauthentication-flow: + status: exempt + comment: Local polling without authentication + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: + status: todo + comment: Under review + docs-configuration-parameters: + status: todo + comment: Under review + + ## Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: + status: todo + comment: mDNS should be possible - https://owfs.org/index_php_page_avahi-discovery.html + stale-devices: + status: done + comment: > + Manual removal, as it is not possible to distinguish + between a flaky device and a device that has been removed + diagnostics: + status: todo + comment: config-entry diagnostics level available, might be nice to have device-level diagnostics + exception-translations: + status: todo + comment: Under review + icon-translations: + status: exempt + comment: It doesn't make sense to override defaults + reconfiguration-flow: done + dynamic-devices: + status: todo + comment: Not yet implemented + discovery-update-info: + status: todo + comment: Under review + repair-issues: + status: exempt + comment: No repairs available + docs-use-cases: + status: todo + comment: Under review + docs-supported-devices: + status: todo + comment: Under review + docs-supported-functions: + status: todo + comment: Under review + docs-data-update: + status: todo + comment: Under review + docs-known-limitations: + status: todo + comment: Under review + docs-troubleshooting: + status: todo + comment: Under review + docs-examples: + status: todo + comment: Under review + + ## Platinum + async-dependency: + status: todo + comment: The dependency is not async + inject-websession: + status: exempt + comment: No websession + strict-typing: + status: todo + comment: The dependency is not typed diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index c24f1d9af26..a8cf1f1b618 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -742,7 +742,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "omnilogic", "oncue", "ondilo_ico", - "onewire", "onvif", "open_meteo", "openai_conversation", From 99e65c38b0cf0a3598c9f8d8dd3f277337dfa678 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:41:33 +0100 Subject: [PATCH 0188/2987] Add binary sensors to fyta (#134900) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/fyta/__init__.py | 1 + .../components/fyta/binary_sensor.py | 117 +++ homeassistant/components/fyta/entity.py | 4 +- homeassistant/components/fyta/icons.json | 20 + homeassistant/components/fyta/strings.json | 23 + .../fyta/fixtures/plant_status1.json | 7 +- .../fyta/fixtures/plant_status2.json | 3 + .../fyta/fixtures/plant_status3.json | 3 + .../fyta/snapshots/test_binary_sensor.ambr | 741 ++++++++++++++++++ .../fyta/snapshots/test_diagnostics.ambr | 8 +- tests/components/fyta/test_binary_sensor.py | 95 +++ 11 files changed, 1014 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/fyta/binary_sensor.py create mode 100644 tests/components/fyta/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/fyta/test_binary_sensor.py diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py index 1969ebfffe9..77724e3f673 100644 --- a/homeassistant/components/fyta/__init__.py +++ b/homeassistant/components/fyta/__init__.py @@ -24,6 +24,7 @@ from .coordinator import FytaCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.SENSOR, ] type FytaConfigEntry = ConfigEntry[FytaCoordinator] diff --git a/homeassistant/components/fyta/binary_sensor.py b/homeassistant/components/fyta/binary_sensor.py new file mode 100644 index 00000000000..bcef609d01a --- /dev/null +++ b/homeassistant/components/fyta/binary_sensor.py @@ -0,0 +1,117 @@ +"""Binary sensors for Fyta.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final + +from fyta_cli.fyta_models import Plant + +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 AddEntitiesCallback + +from . import FytaConfigEntry +from .entity import FytaPlantEntity + + +@dataclass(frozen=True, kw_only=True) +class FytaBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Fyta binary sensor entity.""" + + value_fn: Callable[[Plant], bool] + + +BINARY_SENSORS: Final[list[FytaBinarySensorEntityDescription]] = [ + FytaBinarySensorEntityDescription( + key="low_battery", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda plant: plant.low_battery, + ), + FytaBinarySensorEntityDescription( + key="notification_light", + translation_key="notification_light", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda plant: plant.notification_light, + ), + FytaBinarySensorEntityDescription( + key="notification_nutrition", + translation_key="notification_nutrition", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda plant: plant.notification_nutrition, + ), + FytaBinarySensorEntityDescription( + key="notification_temperature", + translation_key="notification_temperature", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda plant: plant.notification_temperature, + ), + FytaBinarySensorEntityDescription( + key="notification_water", + translation_key="notification_water", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda plant: plant.notification_water, + ), + FytaBinarySensorEntityDescription( + key="sensor_update_available", + device_class=BinarySensorDeviceClass.UPDATE, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda plant: plant.sensor_update_available, + ), + FytaBinarySensorEntityDescription( + key="productive_plant", + translation_key="productive_plant", + value_fn=lambda plant: plant.productive_plant, + ), + FytaBinarySensorEntityDescription( + key="repotted", + translation_key="repotted", + value_fn=lambda plant: plant.repotted, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: FytaConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the FYTA binary sensors.""" + coordinator = entry.runtime_data + + async_add_entities( + FytaPlantBinarySensor(coordinator, entry, sensor, plant_id) + for plant_id in coordinator.fyta.plant_list + for sensor in BINARY_SENSORS + if sensor.key in dir(coordinator.data.get(plant_id)) + ) + + def _async_add_new_device(plant_id: int) -> None: + async_add_entities( + FytaPlantBinarySensor(coordinator, entry, sensor, plant_id) + for sensor in BINARY_SENSORS + if sensor.key in dir(coordinator.data.get(plant_id)) + ) + + coordinator.new_device_callbacks.append(_async_add_new_device) + + +class FytaPlantBinarySensor(FytaPlantEntity, BinarySensorEntity): + """Represents a Fyta binary sensor.""" + + entity_description: FytaBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return value of the binary sensor.""" + + return self.entity_description.value_fn(self.plant) diff --git a/homeassistant/components/fyta/entity.py b/homeassistant/components/fyta/entity.py index 4c078098ec1..0d0ec533c44 100644 --- a/homeassistant/components/fyta/entity.py +++ b/homeassistant/components/fyta/entity.py @@ -2,8 +2,8 @@ from fyta_cli.fyta_models import Plant -from homeassistant.components.sensor import SensorEntityDescription from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import FytaConfigEntry @@ -20,7 +20,7 @@ class FytaPlantEntity(CoordinatorEntity[FytaCoordinator]): self, coordinator: FytaCoordinator, entry: FytaConfigEntry, - description: SensorEntityDescription, + description: EntityDescription, plant_id: int, ) -> None: """Initialize the Fyta sensor.""" diff --git a/homeassistant/components/fyta/icons.json b/homeassistant/components/fyta/icons.json index 8bb61c63464..5b6380196f4 100644 --- a/homeassistant/components/fyta/icons.json +++ b/homeassistant/components/fyta/icons.json @@ -1,5 +1,25 @@ { "entity": { + "binary_sensor": { + "notification_light": { + "default": "mdi:lightbulb-alert-outline" + }, + "notification_nutrition": { + "default": "mdi:beaker-alert-outline" + }, + "notification_temperature": { + "default": "mdi:thermometer-alert" + }, + "notification_water": { + "default": "mdi:watering-can-outline" + }, + "productive_plant": { + "default": "mdi:fruit-grapes" + }, + "repotted": { + "default": "mdi:shovel" + } + }, "sensor": { "status": { "default": "mdi:flower" diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index d59b79bf92c..1a25f654e19 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -38,6 +38,29 @@ } }, "entity": { + "binary_sensor": { + "notification_light": { + "name": "Light notification" + }, + "notification_nutrition": { + "name": "Nutrition notification" + }, + "notification_temperature": { + "name": "Temperature notification" + }, + "notification_water": { + "name": "Water notification" + }, + "productive_plant": { + "name": "Productive plant" + }, + "repotted": { + "name": "Repotted" + }, + "sensor_update_available": { + "name": "Sensor update available" + } + }, "sensor": { "scientific_name": { "name": "Scientific name" diff --git a/tests/components/fyta/fixtures/plant_status1.json b/tests/components/fyta/fixtures/plant_status1.json index 600fc46608c..ca5662714a0 100644 --- a/tests/components/fyta/fixtures/plant_status1.json +++ b/tests/components/fyta/fixtures/plant_status1.json @@ -1,6 +1,9 @@ { "battery_level": 80, - "low_battery": true, + "fertilisation": { + "was_repotted": true + }, + "low_battery": false, "last_updated": "2023-01-10 10:10:00", "light": 2, "light_status": 3, @@ -10,7 +13,7 @@ "moisture_status": 3, "sensor_available": true, "sensor_id": "FD:1D:B7:E3:D0:E2", - "sensor_update_available": false, + "sensor_update_available": true, "sw_version": "1.0", "status": 1, "online": true, diff --git a/tests/components/fyta/fixtures/plant_status2.json b/tests/components/fyta/fixtures/plant_status2.json index c39e2ac8685..bf90ab1e50d 100644 --- a/tests/components/fyta/fixtures/plant_status2.json +++ b/tests/components/fyta/fixtures/plant_status2.json @@ -1,5 +1,8 @@ { "battery_level": 80, + "fertilisation": { + "was_repotted": true + }, "low_battery": true, "last_updated": "2023-01-02 10:10:00", "light": 2, diff --git a/tests/components/fyta/fixtures/plant_status3.json b/tests/components/fyta/fixtures/plant_status3.json index 58e3e1b86a0..2bedd196fe1 100644 --- a/tests/components/fyta/fixtures/plant_status3.json +++ b/tests/components/fyta/fixtures/plant_status3.json @@ -1,5 +1,8 @@ { "battery_level": 80, + "fertilisation": { + "was_repotted": false + }, "low_battery": true, "last_updated": "2023-01-02 10:10:00", "light": 2, diff --git a/tests/components/fyta/snapshots/test_binary_sensor.ambr b/tests/components/fyta/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..c90db22bc7f --- /dev/null +++ b/tests/components/fyta/snapshots/test_binary_sensor.ambr @@ -0,0 +1,741 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.gummibaum_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_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': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Gummibaum Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_light_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_light_notification', + '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': 'Light notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_light', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_light_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Light notification', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_light_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_nutrition_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_nutrition_notification', + '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': 'Nutrition notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_nutrition', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_nutrition', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_nutrition_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Nutrition notification', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_nutrition_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_productive_plant-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gummibaum_productive_plant', + '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': 'Productive plant', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'productive_plant', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-productive_plant', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_productive_plant-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Productive plant', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_productive_plant', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_repotted-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gummibaum_repotted', + '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': 'Repotted', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'repotted', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-repotted', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_repotted-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Repotted', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_repotted', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_temperature_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_temperature_notification', + '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': 'Temperature notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_temperature', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_temperature_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Temperature notification', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_temperature_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-sensor_update_available', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'update', + 'friendly_name': 'Gummibaum Update', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_water_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_water_notification', + '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 notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_water', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_water_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Water notification', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_water_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_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': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Kakaobaum Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_light_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_light_notification', + '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': 'Light notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_light', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_light_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Light notification', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_light_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_nutrition_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_nutrition_notification', + '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': 'Nutrition notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_nutrition', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_nutrition', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_nutrition_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Nutrition notification', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_nutrition_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_productive_plant-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.kakaobaum_productive_plant', + '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': 'Productive plant', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'productive_plant', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-productive_plant', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_productive_plant-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Productive plant', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_productive_plant', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_repotted-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.kakaobaum_repotted', + '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': 'Repotted', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'repotted', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-repotted', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_repotted-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Repotted', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_repotted', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_temperature_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_temperature_notification', + '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': 'Temperature notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_temperature', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_temperature_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Temperature notification', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_temperature_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-sensor_update_available', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'update', + 'friendly_name': 'Kakaobaum Update', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_water_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_water_notification', + '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 notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_water', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_water_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Water notification', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_water_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr index eb19797e5b1..b4da0238db0 100644 --- a/tests/components/fyta/snapshots/test_diagnostics.ambr +++ b/tests/components/fyta/snapshots/test_diagnostics.ambr @@ -31,7 +31,7 @@ 'last_updated': '2023-01-10T10:10:00', 'light': 2.0, 'light_status': 3, - 'low_battery': True, + 'low_battery': False, 'moisture': 61.0, 'moisture_status': 3, 'name': 'Gummibaum', @@ -46,14 +46,14 @@ 'plant_origin_path': '', 'plant_thumb_path': '', 'productive_plant': False, - 'repotted': False, + 'repotted': True, 'salinity': 1.0, 'salinity_status': 4, 'scientific_name': 'Ficus elastica', 'sensor_available': True, 'sensor_id': 'FD:1D:B7:E3:D0:E2', 'sensor_status': 0, - 'sensor_update_available': False, + 'sensor_update_available': True, 'status': 1, 'sw_version': '1.0', 'temperature': 25.2, @@ -81,7 +81,7 @@ 'plant_origin_path': '', 'plant_thumb_path': '', 'productive_plant': False, - 'repotted': False, + 'repotted': True, 'salinity': 1.0, 'salinity_status': 4, 'scientific_name': 'Theobroma cacao', diff --git a/tests/components/fyta/test_binary_sensor.py b/tests/components/fyta/test_binary_sensor.py new file mode 100644 index 00000000000..9d6a4ae3b0e --- /dev/null +++ b/tests/components/fyta/test_binary_sensor.py @@ -0,0 +1,95 @@ +"""Test the Home Assistant fyta binary sensor module.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from fyta_cli.fyta_exceptions import FytaConnectionError, FytaPlantError +from fyta_cli.fyta_models import Plant +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.fyta.const import DOMAIN as FYTA_DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_platform + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "exception", + [ + FytaConnectionError, + FytaPlantError, + ], +) +async def test_connection_error( + hass: HomeAssistant, + exception: Exception, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection error.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + mock_fyta_connector.update_all_plants.side_effect = exception + + freezer.tick(delta=timedelta(hours=12)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("binary_sensor.gummibaum_repotted").state == STATE_UNAVAILABLE + ) + + +async def test_add_remove_entities( + hass: HomeAssistant, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if entities are added and old are removed.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + assert hass.states.get("binary_sensor.gummibaum_repotted").state == STATE_ON + + plants: dict[int, Plant] = { + 0: Plant.from_dict(load_json_object_fixture("plant_status1.json", FYTA_DOMAIN)), + 2: Plant.from_dict(load_json_object_fixture("plant_status3.json", FYTA_DOMAIN)), + } + mock_fyta_connector.update_all_plants.return_value = plants + mock_fyta_connector.plant_list = { + 0: "Kautschukbaum", + 2: "Tomatenpflanze", + } + + freezer.tick(delta=timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.kakaobaum_repotted") is None + assert hass.states.get("binary_sensor.tomatenpflanze_repotted").state == STATE_OFF From 39143a2e791b8cad417ffd77a42069aa51dac9ff Mon Sep 17 00:00:00 2001 From: Dawid Pietryga Date: Wed, 8 Jan 2025 14:49:43 +0100 Subject: [PATCH 0189/2987] Add satel integra switches and alarm control panels unique_id (#129636) --- homeassistant/components/satel_integra/alarm_control_panel.py | 1 + homeassistant/components/satel_integra/switch.py | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py index 39c0d6b876d..41b2d0d561b 100644 --- a/homeassistant/components/satel_integra/alarm_control_panel.py +++ b/homeassistant/components/satel_integra/alarm_control_panel.py @@ -69,6 +69,7 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity): def __init__(self, controller, name, arm_home_mode, partition_id): """Initialize the alarm panel.""" self._attr_name = name + self._attr_unique_id = f"satel_alarm_panel_{partition_id}" self._arm_home_mode = arm_home_mode self._partition_id = partition_id self._satel = controller diff --git a/homeassistant/components/satel_integra/switch.py b/homeassistant/components/satel_integra/switch.py index 6ce82908de7..9135b58bc50 100644 --- a/homeassistant/components/satel_integra/switch.py +++ b/homeassistant/components/satel_integra/switch.py @@ -58,6 +58,7 @@ class SatelIntegraSwitch(SwitchEntity): def __init__(self, controller, device_number, device_name, code): """Initialize the binary_sensor.""" self._device_number = device_number + self._attr_unique_id = f"satel_switch_{device_number}" self._name = device_name self._state = False self._code = code From c2f6f93f1d6b286f7955432628915d67445ca080 Mon Sep 17 00:00:00 2001 From: farkasdi <93778865+farkasdi@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:58:50 +0100 Subject: [PATCH 0190/2987] Update addition logger string in fan.py (#135098) --- homeassistant/components/netatmo/fan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py index 71a8c548622..9f3fe7174ff 100644 --- a/homeassistant/components/netatmo/fan.py +++ b/homeassistant/components/netatmo/fan.py @@ -35,7 +35,7 @@ async def async_setup_entry( @callback def _create_entity(netatmo_device: NetatmoDevice) -> None: entity = NetatmoFan(netatmo_device) - _LOGGER.debug("Adding cover %s", entity) + _LOGGER.debug("Adding fan %s", entity) async_add_entities([entity]) entry.async_on_unload( From da29b2f711e50c2204d1ba39eb857779ac327cb4 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Wed, 8 Jan 2025 15:00:33 +0100 Subject: [PATCH 0191/2987] Add quality_scale.yaml to Minecraft Server (#132551) --- .../minecraft_server/quality_scale.yaml | 114 ++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/minecraft_server/quality_scale.yaml diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml new file mode 100644 index 00000000000..fc3db3b3075 --- /dev/null +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -0,0 +1,114 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration doesn't provide any service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: + status: todo + comment: Check removal and replacement of name in config flow with the title (server address). + config-flow-test-coverage: + status: todo + comment: | + Merge test_show_config_form with full flow test. + Move full flow test to the top of all tests. + All test cases should end in either CREATE_ENTRY or ABORT. + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration doesn't provide any service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: done + comment: Handled by coordinator. + entity-unique-id: + status: done + comment: Using confid entry ID as the dependency mcstatus doesn't provide a unique information. + has-entity-name: done + runtime-data: todo + test-before-configure: done + test-before-setup: + status: done + comment: | + Raising ConfigEntryNotReady, if either the initialization or + refresh of coordinator isn't successful. + unique-config-entry: + status: done + comment: | + As there is no unique information available from the dependency mcstatus, + the server address is used to identify that the same service is already configured. + + # Silver + action-exceptions: + status: exempt + comment: Integration doesn't provide any service actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration doesn't support any configuration parameters. + docs-installation-parameters: done + entity-unavailable: + status: done + comment: Handled by coordinator. + integration-owner: done + log-when-unavailable: + status: done + comment: Handled by coordinator. + parallel-updates: + status: todo + comment: | + Although this is handled by the coordinator and no service actions are provided, + PARALLEL_UPDATES should still be set to 0 in binary_sensor and sensor according to the rule. + reauthentication-flow: + status: exempt + comment: No authentication is required for the integration. + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery: + status: exempt + comment: No discovery possible. + discovery-update-info: + status: exempt + comment: | + No discovery possible. Users can use the (local or public) hostname instead of an IP address, + if static IP addresses cannot be configured. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: A minecraft server can only have one device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repair use-cases for this integration. + stale-devices: todo + + # Platinum + async-dependency: + status: done + comment: | + Lookup API of the dependency mcstatus for Bedrock Edition servers is not async, + but is non-blocking and therefore OK to be called. Refer to mcstatus FAQ + https://mcstatus.readthedocs.io/en/stable/pages/faq/#why-doesn-t-bedrockserver-have-an-async-lookup-method + inject-websession: + status: exempt + comment: Integration isn't making any HTTP requests. + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index a8cf1f1b618..4876ab225e9 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -653,7 +653,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "mikrotik", "mill", "min_max", - "minecraft_server", "minio", "mjpeg", "moat", From 63eb27df7b745857302502d296480b38857e9b9b Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:11:06 +0000 Subject: [PATCH 0192/2987] Add PARALLEL_UPDATES constant to tplink integration platforms (#135083) --- homeassistant/components/tplink/binary_sensor.py | 3 +++ homeassistant/components/tplink/button.py | 4 ++++ homeassistant/components/tplink/camera.py | 4 ++++ homeassistant/components/tplink/climate.py | 4 ++++ homeassistant/components/tplink/fan.py | 4 ++++ homeassistant/components/tplink/light.py | 4 ++++ homeassistant/components/tplink/number.py | 5 +++++ homeassistant/components/tplink/select.py | 4 ++++ homeassistant/components/tplink/sensor.py | 3 +++ homeassistant/components/tplink/siren.py | 4 ++++ homeassistant/components/tplink/switch.py | 4 ++++ 11 files changed, 43 insertions(+) diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py index f3a7e7a7ce7..318d0803e53 100644 --- a/homeassistant/components/tplink/binary_sensor.py +++ b/homeassistant/components/tplink/binary_sensor.py @@ -26,6 +26,9 @@ class TPLinkBinarySensorEntityDescription( """Base class for a TPLink feature based sensor entity description.""" +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + BINARY_SENSOR_DESCRIPTIONS: Final = ( TPLinkBinarySensorEntityDescription( key="overheated", diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py index 753efcf89f4..d8a7c8f1281 100644 --- a/homeassistant/components/tplink/button.py +++ b/homeassistant/components/tplink/button.py @@ -29,6 +29,10 @@ class TPLinkButtonEntityDescription( """Base class for a TPLink feature based button entity description.""" +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + BUTTON_DESCRIPTIONS: Final = [ TPLinkButtonEntityDescription( key="test_alarm", diff --git a/homeassistant/components/tplink/camera.py b/homeassistant/components/tplink/camera.py index 01b47db7082..e1db7254428 100644 --- a/homeassistant/components/tplink/camera.py +++ b/homeassistant/components/tplink/camera.py @@ -36,6 +36,10 @@ class TPLinkCameraEntityDescription( """Base class for camera entity description.""" +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + CAMERA_DESCRIPTIONS: tuple[TPLinkCameraEntityDescription, ...] = ( TPLinkCameraEntityDescription( key="live_view", diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py index f53a0d093ac..cef9a732cfd 100644 --- a/homeassistant/components/tplink/climate.py +++ b/homeassistant/components/tplink/climate.py @@ -25,6 +25,10 @@ from .const import UNIT_MAPPING from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity, async_refresh_after +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + # Upstream state to HVACAction STATE_TO_ACTION = { ThermostatState.Idle: HVACAction.IDLE, diff --git a/homeassistant/components/tplink/fan.py b/homeassistant/components/tplink/fan.py index a1e62e4ed69..92cf049c11a 100644 --- a/homeassistant/components/tplink/fan.py +++ b/homeassistant/components/tplink/fan.py @@ -20,6 +20,10 @@ from . import TPLinkConfigEntry from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity, async_refresh_after +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 91e2a784af2..c95b5086e3e 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -33,6 +33,10 @@ from . import TPLinkConfigEntry, legacy_device_id from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity, async_refresh_after +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) SERVICE_RANDOM_EFFECT = "random_effect" diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py index 3f7fa9c3e0f..464597fd249 100644 --- a/homeassistant/components/tplink/number.py +++ b/homeassistant/components/tplink/number.py @@ -34,6 +34,11 @@ class TPLinkNumberEntityDescription( """Base class for a TPLink feature based sensor entity description.""" +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + + NUMBER_DESCRIPTIONS: Final = ( TPLinkNumberEntityDescription( key="smooth_transition_on", diff --git a/homeassistant/components/tplink/select.py b/homeassistant/components/tplink/select.py index 5dd8e54fca8..2c46bba8671 100644 --- a/homeassistant/components/tplink/select.py +++ b/homeassistant/components/tplink/select.py @@ -27,6 +27,10 @@ class TPLinkSelectEntityDescription( """Base class for a TPLink feature based sensor entity description.""" +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + SELECT_DESCRIPTIONS: Final = [ TPLinkSelectEntityDescription( key="light_preset", diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index da4bf72122d..e18a849ccd6 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -30,6 +30,9 @@ class TPLinkSensorEntityDescription( """Base class for a TPLink feature based sensor entity description.""" +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="current_consumption", diff --git a/homeassistant/components/tplink/siren.py b/homeassistant/components/tplink/siren.py index 141ea696358..400ca5248b3 100644 --- a/homeassistant/components/tplink/siren.py +++ b/homeassistant/components/tplink/siren.py @@ -15,6 +15,10 @@ from . import TPLinkConfigEntry from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity, async_refresh_after +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 7a879fb3c70..dcaef87bf35 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -29,6 +29,10 @@ class TPLinkSwitchEntityDescription( """Base class for a TPLink feature based sensor entity description.""" +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + SWITCH_DESCRIPTIONS: tuple[TPLinkSwitchEntityDescription, ...] = ( TPLinkSwitchEntityDescription( key="state", From 6f6d4855307f000b93bec278a0876d2a452fb846 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:12:21 +0000 Subject: [PATCH 0193/2987] Raise HomeAssistantError from tplink light effect service (#135081) --- homeassistant/components/tplink/light.py | 28 ++++++- homeassistant/components/tplink/strings.json | 3 + tests/components/tplink/test_light.py | 83 ++++++++++++++++++++ 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index c95b5086e3e..e65fda52e44 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -6,7 +6,7 @@ from collections.abc import Sequence import logging from typing import Any -from kasa import Device, DeviceType, LightState, Module +from kasa import Device, DeviceType, KasaException, LightState, Module from kasa.interfaces import Light, LightEffect from kasa.iot import IotDevice import voluptuous as vol @@ -24,12 +24,14 @@ from homeassistant.components.light import ( filter_supported_color_modes, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType from . import TPLinkConfigEntry, legacy_device_id +from .const import DOMAIN from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity, async_refresh_after @@ -462,7 +464,17 @@ class TPLinkLightEffectEntity(TPLinkLightEntity): if transition_range: effect["transition_range"] = transition_range effect["transition"] = 0 - await self._effect_module.set_custom_effect(effect) + try: + await self._effect_module.set_custom_effect(effect) + except KasaException as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_custom_effect", + translation_placeholders={ + "effect": str(effect), + "exc": str(ex), + }, + ) from ex async def async_set_sequence_effect( self, @@ -484,4 +496,14 @@ class TPLinkLightEffectEntity(TPLinkLightEntity): "spread": spread, "direction": direction, } - await self._effect_module.set_custom_effect(effect) + try: + await self._effect_module.set_custom_effect(effect) + except KasaException as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_custom_effect", + translation_placeholders={ + "effect": str(effect), + "exc": str(ex), + }, + ) from ex diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 185705ff163..9cf302ed717 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -394,6 +394,9 @@ }, "device_authentication": { "message": "Device authentication error {func}: {exc}" + }, + "set_custom_effect": { + "message": "Error trying to set custom effect {effect}: {exc}" } }, "issues": { diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 6549711b7fc..e19f2e11a40 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta +import re from unittest.mock import MagicMock, PropertyMock from freezegun.api import FrozenDateTimeFactory @@ -36,6 +37,10 @@ from homeassistant.components.light import ( EFFECT_OFF, ) from homeassistant.components.tplink.const import DOMAIN +from homeassistant.components.tplink.light import ( + SERVICE_RANDOM_EFFECT, + SERVICE_SEQUENCE_EFFECT, +) from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import ( ATTR_ENTITY_ID, @@ -839,6 +844,84 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: light_effect.set_custom_effect.reset_mock() +@pytest.mark.parametrize( + ("service_name", "service_params", "expected_extra_params"), + [ + pytest.param( + SERVICE_SEQUENCE_EFFECT, + { + "sequence": [[340, 20, 50], [20, 50, 50], [0, 100, 50]], + }, + { + "type": "sequence", + "sequence": [(340, 20, 50), (20, 50, 50), (0, 100, 50)], + "repeat_times": 0, + "spread": 1, + "direction": 4, + }, + id="sequence", + ), + pytest.param( + SERVICE_RANDOM_EFFECT, + {"init_states": [340, 20, 50]}, + {"type": "random", "init_states": [[340, 20, 50]], "random_seed": 100}, + id="random", + ), + ], +) +async def test_smart_strip_effect_service_error( + hass: HomeAssistant, + service_name: str, + service_params: dict, + expected_extra_params: dict, +) -> None: + """Test smart strip custom random effects.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + device = _mocked_device( + modules=[Module.Light, Module.LightEffect], alias="my_light" + ) + light_effect = device.modules[Module.LightEffect] + + with _patch_discovery(device=device), _patch_connect(device=device): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_light" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + light_effect.set_custom_effect.side_effect = KasaException("failed") + + base = { + "custom": 1, + "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", + "brightness": 100, + "name": "Custom", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "duration": 0, + "transition": 0, + } + expected_params = {**base, **expected_extra_params} + expected_msg = f"Error trying to set custom effect {expected_params}: failed" + + with pytest.raises(HomeAssistantError, match=re.escape(expected_msg)): + await hass.services.async_call( + DOMAIN, + service_name, + { + ATTR_ENTITY_ID: entity_id, + **service_params, + }, + blocking=True, + ) + + async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> None: """Test smart strip custom random effects at startup.""" already_migrated_config_entry = MockConfigEntry( From bc09e825a9c1b629aa035aeb4111b7838548fc7a Mon Sep 17 00:00:00 2001 From: jb101010-2 <168106462+jb101010-2@users.noreply.github.com> Date: Wed, 8 Jan 2025 15:12:56 +0100 Subject: [PATCH 0194/2987] Bump pysuezV2 to 2.0.3 (#135080) --- homeassistant/components/suez_water/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 176b059f3d5..5d317ea5ba3 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], "quality_scale": "bronze", - "requirements": ["pysuezV2==2.0.1"] + "requirements": ["pysuezV2==2.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 55072438060..ba7f5883a45 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2324,7 +2324,7 @@ pysqueezebox==0.11.1 pystiebeleltron==0.0.1.dev2 # homeassistant.components.suez_water -pysuezV2==2.0.1 +pysuezV2==2.0.3 # homeassistant.components.switchbee pyswitchbee==1.8.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ebabbfa942..3457fd666a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1890,7 +1890,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.11.1 # homeassistant.components.suez_water -pysuezV2==2.0.1 +pysuezV2==2.0.3 # homeassistant.components.switchbee pyswitchbee==1.8.3 From f05e234c306fed594c50061ef2b169d08f7d055e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jan 2025 15:14:51 +0100 Subject: [PATCH 0195/2987] Refactor patching in onewire tests (#135070) --- tests/components/onewire/__init__.py | 116 +++-- tests/components/onewire/const.py | 447 +++++++----------- .../components/onewire/test_binary_sensor.py | 4 +- tests/components/onewire/test_diagnostics.py | 2 +- tests/components/onewire/test_init.py | 4 +- tests/components/onewire/test_sensor.py | 10 +- tests/components/onewire/test_switch.py | 6 +- 7 files changed, 244 insertions(+), 345 deletions(-) diff --git a/tests/components/onewire/__init__.py b/tests/components/onewire/__init__.py index ed15cac94be..ac7e917d10a 100644 --- a/tests/components/onewire/__init__.py +++ b/tests/components/onewire/__init__.py @@ -7,57 +7,62 @@ from unittest.mock import MagicMock from pyownet.protocol import ProtocolError -from homeassistant.const import Platform - from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES -def setup_owproxy_mock_devices( - owproxy: MagicMock, platform: Platform, device_ids: list[str] -) -> None: +def setup_owproxy_mock_devices(owproxy: MagicMock, device_ids: list[str]) -> None: """Set up mock for owproxy.""" - main_dir_return_value = [] - sub_dir_side_effect = [] - main_read_side_effect = [] - sub_read_side_effect = [] + dir_side_effect: dict[str, list] = {} + read_side_effect: dict[str, list] = {} + + # Setup directory listing + dir_side_effect["/"] = [[f"/{device_id}/" for device_id in device_ids]] for device_id in device_ids: - _setup_owproxy_mock_device( - main_dir_return_value, - sub_dir_side_effect, - main_read_side_effect, - sub_read_side_effect, - device_id, - platform, - ) + _setup_owproxy_mock_device(dir_side_effect, read_side_effect, device_id) - # Ensure enough read side effect - dir_side_effect = [main_dir_return_value, *sub_dir_side_effect] - read_side_effect = ( - main_read_side_effect - + sub_read_side_effect - + [ProtocolError("Missing injected value")] * 20 - ) - owproxy.return_value.dir.side_effect = dir_side_effect - owproxy.return_value.read.side_effect = read_side_effect + def _dir(path: str) -> Any: + if (side_effect := dir_side_effect.get(path)) is None: + raise NotImplementedError(f"Unexpected _dir call: {path}") + result = side_effect.pop(0) + if ( + isinstance(result, Exception) + or isinstance(result, type) + and issubclass(result, Exception) + ): + raise result + return result + + def _read(path: str) -> Any: + if (side_effect := read_side_effect.get(path)) is None: + raise NotImplementedError(f"Unexpected _read call: {path}") + if len(side_effect) == 0: + raise ProtocolError(f"Missing injected value for: {path}") + result = side_effect.pop(0) + if ( + isinstance(result, Exception) + or isinstance(result, type) + and issubclass(result, Exception) + ): + raise result + return result + + owproxy.return_value.dir.side_effect = _dir + owproxy.return_value.read.side_effect = _read def _setup_owproxy_mock_device( - main_dir_return_value: list, - sub_dir_side_effect: list, - main_read_side_effect: list, - sub_read_side_effect: list, - device_id: str, - platform: Platform, + dir_side_effect: dict[str, list], read_side_effect: dict[str, list], device_id: str ) -> None: """Set up mock for owproxy.""" mock_device = MOCK_OWPROXY_DEVICES[device_id] - # Setup directory listing - main_dir_return_value += [f"/{device_id}/"] if "branches" in mock_device: # Setup branch directory listing for branch, branch_details in mock_device["branches"].items(): + sub_dir_side_effect = dir_side_effect.setdefault( + f"/{device_id}/{branch}", [] + ) sub_dir_side_effect.append( [ # dir on branch f"/{device_id}/{branch}/{sub_device_id}/" @@ -65,46 +70,31 @@ def _setup_owproxy_mock_device( ] ) - _setup_owproxy_mock_device_reads( - main_read_side_effect, - sub_read_side_effect, - mock_device, - device_id, - platform, - ) + _setup_owproxy_mock_device_reads(read_side_effect, mock_device, "/", device_id) if "branches" in mock_device: - for branch_details in mock_device["branches"].values(): + for branch, branch_details in mock_device["branches"].items(): for sub_device_id, sub_device in branch_details.items(): _setup_owproxy_mock_device_reads( - main_read_side_effect, - sub_read_side_effect, + read_side_effect, sub_device, + f"/{device_id}/{branch}/", sub_device_id, - platform, ) def _setup_owproxy_mock_device_reads( - main_read_side_effect: list, - sub_read_side_effect: list, - mock_device: Any, - device_id: str, - platform: Platform, + read_side_effect: dict[str, list], mock_device: Any, root_path: str, device_id: str ) -> None: """Set up mock for owproxy.""" # Setup device reads - main_read_side_effect += [device_id[0:2].encode()] - if ATTR_INJECT_READS in mock_device: - main_read_side_effect += mock_device[ATTR_INJECT_READS] - - # Setup sub-device reads - device_sensors = mock_device.get(platform, []) - if platform is Platform.SENSOR and device_id.startswith("12"): - # We need to check if there is TAI8570 plugged in - sub_read_side_effect.extend( - expected_sensor[ATTR_INJECT_READS] for expected_sensor in device_sensors - ) - sub_read_side_effect.extend( - expected_sensor[ATTR_INJECT_READS] for expected_sensor in device_sensors + family_read_side_effect = read_side_effect.setdefault( + f"{root_path}{device_id}/family", [] ) + family_read_side_effect += [device_id[0:2].encode()] + if ATTR_INJECT_READS in mock_device: + for k, v in mock_device[ATTR_INJECT_READS].items(): + device_read_side_effect = read_side_effect.setdefault( + f"{root_path}{device_id}{k}", [] + ) + device_read_side_effect += v diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index a1bab9807d5..0ce725d1a0a 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -2,333 +2,242 @@ from pyownet.protocol import ProtocolError -from homeassistant.components.onewire.const import Platform - -ATTR_DEVICE_FILE = "device_file" ATTR_INJECT_READS = "inject_reads" MOCK_OWPROXY_DEVICES = { "00.111111111111": { - ATTR_INJECT_READS: [ - b"", # read device type - ], + ATTR_INJECT_READS: { + "/type": [b""], + }, }, "05.111111111111": { - ATTR_INJECT_READS: [ - b"DS2405", # read device type - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2405"], + "/PIO": [b" 1"], + }, }, "10.111111111111": { - ATTR_INJECT_READS: [ - b"DS18S20", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 25.123"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS18S20"], + "/temperature": [b" 25.123"], + }, }, "12.111111111111": { - ATTR_INJECT_READS: [ - b"DS2406", # read device type - ], - Platform.BINARY_SENSOR: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 25.123"}, - {ATTR_INJECT_READS: b" 1025.123"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2406"], + # TAI8570 values are read twice: + # - once during init to make sure TAI8570 is accessible + # - once during first update to get the actual values + "/TAI8570/temperature": [b" 25.123", b" 25.123"], + "/TAI8570/pressure": [b" 1025.123", b" 1025.123"], + "/PIO.A": [b" 1"], + "/PIO.B": [b" 0"], + "/latch.A": [b" 1"], + "/latch.B": [b" 0"], + "/sensed.A": [b" 1"], + "/sensed.B": [b" 0"], + }, }, "1D.111111111111": { - ATTR_INJECT_READS: [ - b"DS2423", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 251123"}, - {ATTR_INJECT_READS: b" 248125"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2423"], + "/counter.A": [b" 251123"], + "/counter.B": [b" 248125"], + } }, "16.111111111111": { # Test case for issue #115984, where the device type cannot be read - ATTR_INJECT_READS: [ - ProtocolError(), # read device type - ], + ATTR_INJECT_READS: {"/type": [ProtocolError()]}, }, "1F.111111111111": { - ATTR_INJECT_READS: [ - b"DS2409", # read device type - ], + ATTR_INJECT_READS: {"/type": [b"DS2409"]}, "branches": { "aux": {}, "main": { "1D.111111111111": { - ATTR_INJECT_READS: [ - b"DS2423", # read device type - ], - Platform.SENSOR: [ - { - ATTR_DEVICE_FILE: "/1F.111111111111/main/1D.111111111111/counter.A", - ATTR_INJECT_READS: b" 251123", - }, - { - ATTR_DEVICE_FILE: "/1F.111111111111/main/1D.111111111111/counter.B", - ATTR_INJECT_READS: b" 248125", - }, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2423"], + "/counter.A": [b" 251123"], + "/counter.B": [b" 248125"], + }, }, }, }, }, "22.111111111111": { - ATTR_INJECT_READS: [ - b"DS1822", # read device type - ], - Platform.SENSOR: [ - { - ATTR_INJECT_READS: ProtocolError, - }, - ], + ATTR_INJECT_READS: { + "/type": [b"DS1822"], + "/temperature": [ProtocolError], + }, }, "26.111111111111": { - ATTR_INJECT_READS: [ - b"DS2438", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 25.123"}, - {ATTR_INJECT_READS: b" 72.7563"}, - {ATTR_INJECT_READS: b" 73.7563"}, - {ATTR_INJECT_READS: b" 74.7563"}, - {ATTR_INJECT_READS: b" 75.7563"}, - { - ATTR_INJECT_READS: ProtocolError, - }, - {ATTR_INJECT_READS: b" 969.265"}, - {ATTR_INJECT_READS: b" 65.8839"}, - {ATTR_INJECT_READS: b" 2.97"}, - {ATTR_INJECT_READS: b" 4.74"}, - {ATTR_INJECT_READS: b" 0.12"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2438"], + "/temperature": [b" 25.123"], + "/humidity": [b" 72.7563"], + "/HIH3600/humidity": [b" 73.7563"], + "/HIH4000/humidity": [b" 74.7563"], + "/HIH5030/humidity": [b" 75.7563"], + "/HTM1735/humidity": [ProtocolError], + "/B1-R1-A/pressure": [b" 969.265"], + "/S3-R1-A/illuminance": [b" 65.8839"], + "/VAD": [b" 2.97"], + "/VDD": [b" 4.74"], + "/vis": [b" 0.12"], + "/IAD": [b" 1"], + }, }, "28.111111111111": { - ATTR_INJECT_READS: [ - b"DS18B20", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 26.984"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS18B20"], + "/temperature": [b" 26.984"], + }, }, "28.222222222222": { # This device has precision options in the config entry - ATTR_INJECT_READS: [ - b"DS18B20", # read device type - ], - Platform.SENSOR: [ - { - ATTR_DEVICE_FILE: "/28.222222222222/temperature9", - ATTR_INJECT_READS: b" 26.984", - }, - ], + ATTR_INJECT_READS: { + "/type": [b"DS18B20"], + "/temperature9": [b" 26.984"], + }, }, "28.222222222223": { # This device has an illegal precision option in the config entry - ATTR_INJECT_READS: [ - b"DS18B20", # read device type - ], - Platform.SENSOR: [ - { - ATTR_DEVICE_FILE: "/28.222222222223/temperature", - ATTR_INJECT_READS: b" 26.984", - }, - ], + ATTR_INJECT_READS: { + "/type": [b"DS18B20"], + "/temperature": [b" 26.984"], + }, }, "29.111111111111": { - ATTR_INJECT_READS: [ - b"DS2408", # read device type - ], - Platform.BINARY_SENSOR: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 0"}, - { - ATTR_INJECT_READS: ProtocolError, - }, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 0"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - { - ATTR_INJECT_READS: ProtocolError, - }, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2408"], + "/PIO.0": [b" 1"], + "/PIO.1": [b" 0"], + "/PIO.2": [b" 1"], + "/PIO.3": [ProtocolError], + "/PIO.4": [b" 1"], + "/PIO.5": [b" 0"], + "/PIO.6": [b" 1"], + "/PIO.7": [b" 0"], + "/latch.0": [b" 1"], + "/latch.1": [b" 0"], + "/latch.2": [b" 1"], + "/latch.3": [b" 0"], + "/latch.4": [b" 1"], + "/latch.5": [b" 0"], + "/latch.6": [b" 1"], + "/latch.7": [b" 0"], + "/sensed.0": [b" 1"], + "/sensed.1": [b" 0"], + "/sensed.2": [b" 0"], + "/sensed.3": [ProtocolError], + "/sensed.4": [b" 0"], + "/sensed.5": [b" 0"], + "/sensed.6": [b" 0"], + "/sensed.7": [b" 0"], + }, }, "30.111111111111": { - ATTR_INJECT_READS: [ - b"DS2760", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 26.984"}, - { - ATTR_DEVICE_FILE: "/30.111111111111/typeK/temperature", - ATTR_INJECT_READS: b" 173.7563", - }, - {ATTR_INJECT_READS: b" 2.97"}, - {ATTR_INJECT_READS: b" 0.12"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2760"], + "/temperature": [b" 26.984"], + "/typeK/temperature": [b" 173.7563"], + "/volt": [b" 2.97"], + "/vis": [b" 0.12"], + }, }, "3A.111111111111": { - ATTR_INJECT_READS: [ - b"DS2413", # read device type - ], - Platform.BINARY_SENSOR: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2413"], + "/PIO.A": [b" 1"], + "/PIO.B": [b" 0"], + "/sensed.A": [b" 1"], + "/sensed.B": [b" 0"], + }, }, "3B.111111111111": { - ATTR_INJECT_READS: [ - b"DS1825", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 28.243"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS1825"], + "/temperature": [b" 28.243"], + }, }, "42.111111111111": { - ATTR_INJECT_READS: [ - b"DS28EA00", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 29.123"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS28EA00"], + "/temperature": [b" 29.123"], + }, }, "A6.111111111111": { - ATTR_INJECT_READS: [ - b"DS2438", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 25.123"}, - {ATTR_INJECT_READS: b" 72.7563"}, - {ATTR_INJECT_READS: b" 73.7563"}, - {ATTR_INJECT_READS: b" 74.7563"}, - {ATTR_INJECT_READS: b" 75.7563"}, - { - ATTR_INJECT_READS: ProtocolError, - }, - {ATTR_INJECT_READS: b" 969.265"}, - {ATTR_INJECT_READS: b" 65.8839"}, - {ATTR_INJECT_READS: b" 2.97"}, - {ATTR_INJECT_READS: b" 4.74"}, - {ATTR_INJECT_READS: b" 0.12"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2438"], + "/temperature": [b" 25.123"], + "/humidity": [b" 72.7563"], + "/HIH3600/humidity": [b" 73.7563"], + "/HIH4000/humidity": [b" 74.7563"], + "/HIH5030/humidity": [b" 75.7563"], + "/HTM1735/humidity": [ProtocolError], + "/B1-R1-A/pressure": [b" 969.265"], + "/S3-R1-A/illuminance": [b" 65.8839"], + "/VAD": [b" 2.97"], + "/VDD": [b" 4.74"], + "/vis": [b" 0.12"], + "/IAD": [b" 1"], + }, }, "EF.111111111111": { - ATTR_INJECT_READS: [ - b"HobbyBoards_EF", # read type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 67.745"}, - {ATTR_INJECT_READS: b" 65.541"}, - {ATTR_INJECT_READS: b" 25.123"}, - ], + ATTR_INJECT_READS: { + "/type": [b"HobbyBoards_EF"], + "/humidity/humidity_corrected": [b" 67.745"], + "/humidity/humidity_raw": [b" 65.541"], + "/humidity/temperature": [b" 25.123"], + }, }, "EF.111111111112": { - ATTR_INJECT_READS: [ - b"HB_MOISTURE_METER", # read type - b" 1", # read is_leaf_0 - b" 1", # read is_leaf_1 - b" 0", # read is_leaf_2 - b" 0", # read is_leaf_3 - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 41.745"}, - {ATTR_INJECT_READS: b" 42.541"}, - {ATTR_INJECT_READS: b" 43.123"}, - {ATTR_INJECT_READS: b" 44.123"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"HB_MOISTURE_METER"], + "/moisture/is_leaf.0": [b" 1"], + "/moisture/is_leaf.1": [b" 1"], + "/moisture/is_leaf.2": [b" 0"], + "/moisture/is_leaf.3": [b" 0"], + "/moisture/sensor.0": [b" 41.745"], + "/moisture/sensor.1": [b" 42.541"], + "/moisture/sensor.2": [b" 43.123"], + "/moisture/sensor.3": [b" 44.123"], + "/moisture/is_moisture.0": [b" 1"], + "/moisture/is_moisture.1": [b" 1"], + "/moisture/is_moisture.2": [b" 0"], + "/moisture/is_moisture.3": [b" 0"], + }, }, "EF.111111111113": { - ATTR_INJECT_READS: [ - b"HB_HUB", # read type - ], - Platform.BINARY_SENSOR: [ - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"HB_HUB"], + "/hub/branch.0": [b" 1"], + "/hub/branch.1": [b" 0"], + "/hub/branch.2": [b" 1"], + "/hub/branch.3": [b" 0"], + "/hub/short.0": [b" 1"], + "/hub/short.1": [b" 0"], + "/hub/short.2": [b" 1"], + "/hub/short.3": [b" 0"], + }, }, "7E.111111111111": { - ATTR_INJECT_READS: [ - b"EDS", # read type - b"EDS0068", # read device_type - note EDS specific - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 13.9375"}, - {ATTR_INJECT_READS: b" 1012.21"}, - {ATTR_INJECT_READS: b" 65.8839"}, - {ATTR_INJECT_READS: b" 41.375"}, - ], + ATTR_INJECT_READS: { + "/type": [b"EDS"], + "/device_type": [b"EDS0068"], + "/EDS0068/temperature": [b" 13.9375"], + "/EDS0068/pressure": [b" 1012.21"], + "/EDS0068/light": [b" 65.8839"], + "/EDS0068/humidity": [b" 41.375"], + }, }, "7E.222222222222": { - ATTR_INJECT_READS: [ - b"EDS", # read type - b"EDS0066", # read device_type - note EDS specific - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 13.9375"}, - {ATTR_INJECT_READS: b" 1012.21"}, - ], + ATTR_INJECT_READS: { + "/type": [b"EDS"], + "/device_type": [b"EDS0066"], + "/EDS0066/temperature": [b" 13.9375"], + "/EDS0066/pressure": [b" 1012.21"], + }, }, } diff --git a/tests/components/onewire/test_binary_sensor.py b/tests/components/onewire/test_binary_sensor.py index 4f30689550a..162952c0216 100644 --- a/tests/components/onewire/test_binary_sensor.py +++ b/tests/components/onewire/test_binary_sensor.py @@ -32,7 +32,7 @@ async def test_binary_sensors( snapshot: SnapshotAssertion, ) -> None: """Test for 1-Wire binary sensors.""" - setup_owproxy_mock_devices(owproxy, Platform.BINARY_SENSOR, [device_id]) + setup_owproxy_mock_devices(owproxy, [device_id]) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -48,7 +48,7 @@ async def test_binary_sensors( ) assert entity_entries == snapshot - setup_owproxy_mock_devices(owproxy, Platform.BINARY_SENSOR, [device_id]) + setup_owproxy_mock_devices(owproxy, [device_id]) # Some entities are disabled, enable them and reload before checking states for ent in entity_entries: entity_registry.async_update_entity(ent.entity_id, disabled_by=None) diff --git a/tests/components/onewire/test_diagnostics.py b/tests/components/onewire/test_diagnostics.py index c73558f2984..c8427cc7126 100644 --- a/tests/components/onewire/test_diagnostics.py +++ b/tests/components/onewire/test_diagnostics.py @@ -47,7 +47,7 @@ async def test_entry_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) + setup_owproxy_mock_devices(owproxy, [device_id]) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 942abb865b9..633fbc09360 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -96,12 +96,12 @@ async def test_registry_cleanup( dead_id = "28.111111111111" # Initialise with two components - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [live_id, dead_id]) + setup_owproxy_mock_devices(owproxy, [live_id, dead_id]) await hass.config_entries.async_setup(entry_id) await hass.async_block_till_done() # Reload with a device no longer on bus - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [live_id]) + setup_owproxy_mock_devices(owproxy, [live_id]) await hass.config_entries.async_reload(entry_id) await hass.async_block_till_done() assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index 1cdb3b2b850..11f8993242e 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -36,7 +36,7 @@ async def test_sensors( snapshot: SnapshotAssertion, ) -> None: """Test for 1-Wire sensors.""" - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) + setup_owproxy_mock_devices(owproxy, [device_id]) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -52,7 +52,7 @@ async def test_sensors( ) assert entity_entries == snapshot - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) + setup_owproxy_mock_devices(owproxy, [device_id]) # Some entities are disabled, enable them and reload before checking states for ent in entity_entries: entity_registry.async_update_entity(ent.entity_id, disabled_by=None) @@ -79,11 +79,11 @@ async def test_tai8570_sensors( """ mock_devices = deepcopy(MOCK_OWPROXY_DEVICES) mock_device = mock_devices[device_id] - mock_device[ATTR_INJECT_READS].append(OwnetError) - mock_device[ATTR_INJECT_READS].append(OwnetError) + mock_device[ATTR_INJECT_READS]["/TAI8570/temperature"] = [OwnetError] + mock_device[ATTR_INJECT_READS]["/TAI8570/pressure"] = [OwnetError] with _patch_dict(MOCK_OWPROXY_DEVICES, mock_devices): - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) + setup_owproxy_mock_devices(owproxy, [device_id]) with caplog.at_level(logging.DEBUG): await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/onewire/test_switch.py b/tests/components/onewire/test_switch.py index 681bf29cf48..34b7f320350 100644 --- a/tests/components/onewire/test_switch.py +++ b/tests/components/onewire/test_switch.py @@ -39,7 +39,7 @@ async def test_switches( snapshot: SnapshotAssertion, ) -> None: """Test for 1-Wire switches.""" - setup_owproxy_mock_devices(owproxy, Platform.SWITCH, [device_id]) + setup_owproxy_mock_devices(owproxy, [device_id]) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -55,7 +55,7 @@ async def test_switches( ) assert entity_entries == snapshot - setup_owproxy_mock_devices(owproxy, Platform.SWITCH, [device_id]) + setup_owproxy_mock_devices(owproxy, [device_id]) # Some entities are disabled, enable them and reload before checking states for ent in entity_entries: entity_registry.async_update_entity(ent.entity_id, disabled_by=None) @@ -76,7 +76,7 @@ async def test_switch_toggle( device_id: str, ) -> None: """Test for 1-Wire switch TOGGLE service.""" - setup_owproxy_mock_devices(owproxy, Platform.SWITCH, [device_id]) + setup_owproxy_mock_devices(owproxy, [device_id]) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From d46be61b6f8944e1fad91a180afee4448a88ea4d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jan 2025 15:25:39 +0100 Subject: [PATCH 0196/2987] Split simple and recovery in onewire config-flow user tests (#135102) --- .../components/onewire/quality_scale.yaml | 1 - tests/components/onewire/test_config_flow.py | 45 ++++++++++++------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/onewire/quality_scale.yaml b/homeassistant/components/onewire/quality_scale.yaml index 726587d13b2..08440a0d6a9 100644 --- a/homeassistant/components/onewire/quality_scale.yaml +++ b/homeassistant/components/onewire/quality_scale.yaml @@ -11,7 +11,6 @@ rules: status: todo comment: > Let's have test_user_options_empty_selection end in CREATE_ENTRY - Split the happy flow and the not happy flow in test_user_flow runtime-data: done test-before-setup: done appropriate-polling: done diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index e89450dd32b..39a1352b644 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -39,13 +39,31 @@ async def filled_device_registry( return device_registry -async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +async def test_user_flow(hass: HomeAssistant) -> None: """Test user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM - assert not result["errors"] + + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 1234}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + new_entry = result["result"] + assert new_entry.title == "1.2.3.4" + assert new_entry.data == {CONF_HOST: "1.2.3.4", CONF_PORT: 1234} + + +async def test_user_flow_recovery(hass: HomeAssistant) -> None: + """Test user flow recovery after invalid server.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) # Invalid server with patch( @@ -57,9 +75,9 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 1234}, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} # Valid server with patch( @@ -70,19 +88,14 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 1234}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "1.2.3.4" - assert result["data"] == { - CONF_HOST: "1.2.3.4", - CONF_PORT: 1234, - } - await hass.async_block_till_done() - - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + new_entry = result["result"] + assert new_entry.title == "1.2.3.4" + assert new_entry.data == {CONF_HOST: "1.2.3.4", CONF_PORT: 1234} async def test_user_duplicate( - hass: HomeAssistant, config_entry: MockConfigEntry, mock_setup_entry: AsyncMock + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test user duplicate flow.""" await hass.config_entries.async_setup(config_entry.entry_id) From 0e52ea482f40c7b4f1e48992a984cb700b226913 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 8 Jan 2025 15:27:26 +0100 Subject: [PATCH 0197/2987] Fix hvac_modes never empty in Sensibo (#135029) --- homeassistant/components/sensibo/climate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index ff9aed6f4e7..9a2f265041f 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -3,7 +3,7 @@ from __future__ import annotations from bisect import bisect_left -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -255,8 +255,8 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes.""" - if not self.device_data.hvac_modes: - return [HVACMode.OFF] + if TYPE_CHECKING: + assert self.device_data.hvac_modes return [SENSIBO_TO_HA[mode] for mode in self.device_data.hvac_modes] @property From 02e30edc6c7617237975346029cba50f37f96f60 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jan 2025 17:00:35 +0100 Subject: [PATCH 0198/2987] Improve onewire options flow tests (#135109) --- .../components/onewire/quality_scale.yaml | 5 +--- tests/components/onewire/test_config_flow.py | 23 +++++++++++++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/onewire/quality_scale.yaml b/homeassistant/components/onewire/quality_scale.yaml index 08440a0d6a9..a262f9cd714 100644 --- a/homeassistant/components/onewire/quality_scale.yaml +++ b/homeassistant/components/onewire/quality_scale.yaml @@ -7,10 +7,7 @@ rules: unique-config-entry: status: done comment: unique ID is not available, but duplicates are prevented based on host/port - config-flow-test-coverage: - status: todo - comment: > - Let's have test_user_options_empty_selection end in CREATE_ENTRY + config-flow-test-coverage: done runtime-data: done test-before-setup: done appropriate-polling: done diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index 39a1352b644..0c7daf2aeff 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -215,7 +215,7 @@ async def test_user_options_clear( @pytest.mark.usefixtures("filled_device_registry") -async def test_user_options_empty_selection( +async def test_user_options_empty_selection_recovery( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test leaving the selection of devices empty.""" @@ -229,7 +229,7 @@ async def test_user_options_empty_selection( "28.222222222223": False, } - # Verify that an empty selection does not modify the options + # Verify that an empty selection shows the form again result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={INPUT_ENTRY_DEVICE_SELECTION: []}, @@ -238,6 +238,25 @@ async def test_user_options_empty_selection( assert result["step_id"] == "device_selection" assert result["errors"] == {"base": "device_not_selected"} + # Verify that a single selected device to configure comes back as a form with the device to configure + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={INPUT_ENTRY_DEVICE_SELECTION: ["28.111111111111"]}, + ) + assert result["type"] is FlowResultType.FORM + assert result["description_placeholders"]["sensor_id"] == "28.111111111111" + + # Verify that the setting for the device comes back as default when no input is given + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["data"]["device_options"]["28.111111111111"]["precision"] + == "temperature" + ) + @pytest.mark.usefixtures("filled_device_registry") async def test_user_options_set_single( From d2a188ad3cea7bf3b02bc0955930c848b8ef9927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Herv=C3=A9=20Cauwelier?= Date: Wed, 8 Jan 2025 17:19:28 +0100 Subject: [PATCH 0199/2987] Improve holidays config form and naming (#133663) The holidays library is improving over time, let's make use of their data for a more user-friendly experience. Co-authored-by: G Johansson --- .../components/holiday/config_flow.py | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py index 00a71351ca7..6d29e09c0f8 100644 --- a/homeassistant/components/holiday/config_flow.py +++ b/homeassistant/components/holiday/config_flow.py @@ -19,6 +19,7 @@ from homeassistant.core import callback from homeassistant.helpers.selector import ( CountrySelector, CountrySelectorConfig, + SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -30,6 +31,30 @@ from .const import CONF_CATEGORIES, CONF_PROVINCE, DOMAIN SUPPORTED_COUNTRIES = list_supported_countries(include_aliases=False) +def get_optional_provinces(country: str) -> list[Any]: + """Return the country provinces (territories). + + Some territories can have extra or different holidays + from another within the same country. + Some territories can have different names (aliases). + """ + province_options: list[Any] = [] + + if provinces := SUPPORTED_COUNTRIES[country]: + country_data = country_holidays(country, years=dt_util.utcnow().year) + if country_data.subdivisions_aliases and ( + subdiv_aliases := country_data.get_subdivision_aliases() + ): + province_options = [ + SelectOptionDict(value=k, label=", ".join(v)) + for k, v in subdiv_aliases.items() + ] + else: + province_options = provinces + + return province_options + + def get_optional_categories(country: str) -> list[str]: """Return the country categories. @@ -45,7 +70,7 @@ def get_optional_categories(country: str) -> list[str]: def get_options_schema(country: str) -> vol.Schema: """Return the options schema.""" schema = {} - if provinces := SUPPORTED_COUNTRIES[country]: + if provinces := get_optional_provinces(country): schema[vol.Optional(CONF_PROVINCE)] = SelectSelector( SelectSelectorConfig( options=provinces, From f05cffea177f4db08c145d2bb680040d60d97dab Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Wed, 8 Jan 2025 20:06:51 +0100 Subject: [PATCH 0200/2987] Update enphase_envoy test_init to use str for unique_id and test for loaded config entry (#133810) --- .../enphase_envoy/quality_scale.yaml | 6 +---- tests/components/enphase_envoy/test_init.py | 23 ++++++++++++++++--- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/enphase_envoy/quality_scale.yaml b/homeassistant/components/enphase_envoy/quality_scale.yaml index a7038b4e0da..772158a1b8c 100644 --- a/homeassistant/components/enphase_envoy/quality_scale.yaml +++ b/homeassistant/components/enphase_envoy/quality_scale.yaml @@ -60,11 +60,7 @@ rules: status: done comment: pending https://github.com/home-assistant/core/pull/132373 reauthentication-flow: done - test-coverage: - status: todo - comment: | - - test_config_different_unique_id -> unique_id set to the mock config entry is an int, not a str - - Apart from the coverage, test_option_change_reload does not verify that the config entry is reloaded + test-coverage: done # Gold devices: done diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index 2b35aaff5e9..10cf65a298d 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -263,7 +263,7 @@ async def test_config_different_unique_id( domain=DOMAIN, entry_id="45a36e55aaddb2007c5f6602e0c38e72", title="Envoy 1234", - unique_id=4321, + unique_id="4321", data={ CONF_HOST: "1.1.1.1", CONF_NAME: "Envoy 1234", @@ -346,8 +346,10 @@ async def test_option_change_reload( await setup_integration(hass, config_entry) await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED + # By default neither option is available + assert config_entry.options == {} - # option change will take care of COV of init::async_reload_entry + # option change will also take care of COV of init::async_reload_entry hass.config_entries.async_update_entry( config_entry, options={ @@ -355,8 +357,23 @@ async def test_option_change_reload( OPTION_DISABLE_KEEP_ALIVE: True, }, ) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED assert config_entry.options == { OPTION_DIAGNOSTICS_INCLUDE_FIXTURES: False, OPTION_DISABLE_KEEP_ALIVE: True, } + # flip em + hass.config_entries.async_update_entry( + config_entry, + options={ + OPTION_DIAGNOSTICS_INCLUDE_FIXTURES: True, + OPTION_DISABLE_KEEP_ALIVE: False, + }, + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.options == { + OPTION_DIAGNOSTICS_INCLUDE_FIXTURES: True, + OPTION_DISABLE_KEEP_ALIVE: False, + } From c9c553047c721540df06387c59bf473d19d25190 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 8 Jan 2025 19:08:04 +0000 Subject: [PATCH 0201/2987] Add quality scale file to tplink integration (#135017) --- homeassistant/components/tplink/__init__.py | 4 +- .../components/tplink/quality_scale.yaml | 78 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/tplink/quality_scale.yaml diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index e2a2f99517f..13261ed752e 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -325,7 +325,9 @@ def _device_id_is_mac_or_none(mac: str, device_ids: Iterable[str]) -> str | None ) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: TPLinkConfigEntry +) -> bool: """Migrate old entry.""" entry_version = config_entry.version entry_minor_version = config_entry.minor_version diff --git a/homeassistant/components/tplink/quality_scale.yaml b/homeassistant/components/tplink/quality_scale.yaml new file mode 100644 index 00000000000..3a2e10bc426 --- /dev/null +++ b/homeassistant/components/tplink/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: + status: todo + comment: Clean up stale docstrings + runtime-data: + status: todo + comment: Use typed config entry in coordinator + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: The integration does not use events. + dependency-transparency: todo + action-setup: + status: exempt + comment: The integration only uses platform services. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: done + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: done + reauthentication-flow: done + parallel-updates: done + test-coverage: + status: todo + comment: Move test constants to const.py, mock_init \ + docstrings, entity_registry fixture, unused freezers \ + match exceptions, use freezer in test_fan, use async_setup \ + remove if statements from light tests, use constants in service calls + integration-owner: done + docs-installation-parameters: todo + docs-configuration-parameters: + status: exempt + comment: The integration does not have any options configuration parameters. + + # Gold + entity-translations: + status: todo + comment: Use device class translations, remove unused translations \ + translate Unnamed, setup exceptions, mac mismatch, async_set_hvac_mode + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: done + stale-devices: todo + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + dynamic-devices: todo + discovery-update-info: done + repair-issues: done + docs-use-cases: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-data-update: todo + docs-known-limitations: todo + docs-troubleshooting: todo + docs-examples: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 4876ab225e9..e16d83028b7 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1041,7 +1041,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "torque", "touchline", "touchline_sl", - "tplink", "tplink_lte", "tplink_omada", "traccar", From 988a0639f4da0a1a1f5bbb43f304d6c9c3709439 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Wed, 8 Jan 2025 20:09:06 +0100 Subject: [PATCH 0202/2987] Remove enphase_envoy config flow tests that make no sense (#133833) --- .../enphase_envoy/quality_scale.yaml | 6 +- .../enphase_envoy/test_config_flow.py | 113 ------------------ 2 files changed, 1 insertion(+), 118 deletions(-) diff --git a/homeassistant/components/enphase_envoy/quality_scale.yaml b/homeassistant/components/enphase_envoy/quality_scale.yaml index 772158a1b8c..4708a3cc11a 100644 --- a/homeassistant/components/enphase_envoy/quality_scale.yaml +++ b/homeassistant/components/enphase_envoy/quality_scale.yaml @@ -8,11 +8,7 @@ rules: comment: fixed 1 minute cycle based on Enphase Envoy device characteristics brands: done common-modules: done - config-flow-test-coverage: - status: todo - comment: | - - test_zero_conf_malformed_serial_property - with pytest.raises(KeyError) as ex:: - I don't believe this should be able to raise a KeyError Shouldn't we abort the flow? + config-flow-test-coverage: done config-flow: status: todo comment: | diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index 121c2583050..c78e847e4a2 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -434,119 +434,6 @@ async def test_zero_conf_second_envoy_while_form( assert result4["type"] is FlowResultType.ABORT -async def test_zero_conf_malformed_serial_property( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_setup_entry: AsyncMock, - mock_envoy: AsyncMock, -) -> None: - """Test malformed zeroconf properties.""" - await setup_integration(hass, config_entry) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - - with pytest.raises(KeyError) as ex: - await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("1.1.1.1"), - ip_addresses=[ip_address("1.1.1.1")], - hostname="mock_hostname", - name="mock_name", - port=None, - properties={"serilnum": "1234", "protovers": "7.1.2"}, - type="mock_type", - ), - ) - assert "serialnum" in str(ex.value) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - assert result["type"] is FlowResultType.ABORT - - -async def test_zero_conf_malformed_serial( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_setup_entry: AsyncMock, - mock_envoy: AsyncMock, -) -> None: - """Test malformed zeroconf properties.""" - await setup_integration(hass, config_entry) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("1.1.1.1"), - ip_addresses=[ip_address("1.1.1.1")], - hostname="mock_hostname", - name="mock_name", - port=None, - properties={"serialnum": "12%4", "protovers": "7.1.2"}, - type="mock_type", - ), - ) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Envoy 12%4" - - -async def test_zero_conf_malformed_fw_property( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_setup_entry: AsyncMock, - mock_envoy: AsyncMock, -) -> None: - """Test malformed zeroconf property.""" - await setup_integration(hass, config_entry) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("1.1.1.1"), - ip_addresses=[ip_address("1.1.1.1")], - hostname="mock_hostname", - name="mock_name", - port=None, - properties={"serialnum": "1234", "protvers": "7.1.2"}, - type="mock_type", - ), - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - assert config_entry.data[CONF_HOST] == "1.1.1.1" - assert config_entry.unique_id == "1234" - assert config_entry.title == "Envoy 1234" - - async def test_zero_conf_old_blank_entry( hass: HomeAssistant, mock_setup_entry: AsyncMock, From 4086d092ffed7f1c7c96b2bcfd3614ee60f7d28c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Wed, 8 Jan 2025 21:05:42 +0100 Subject: [PATCH 0203/2987] Add suggested precision for Airthings BLE integration (#134985) Add suggested precision --- homeassistant/components/airthings_ble/sensor.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index 0dfd82a38c4..248561706a3 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -67,18 +67,21 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), "humidity": SensorEntityDescription( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), "pressure": SensorEntityDescription( key="pressure", device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), "battery": SensorEntityDescription( key="battery", @@ -86,24 +89,28 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, ), "co2": SensorEntityDescription( key="co2", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), "voc": SensorEntityDescription( key="voc", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), "illuminance": SensorEntityDescription( key="illuminance", translation_key="illuminance", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), } From 4129697dd9f80479fa167a72e40fa0c0e95091fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Wed, 8 Jan 2025 21:38:52 +0100 Subject: [PATCH 0204/2987] Add LetPot integration (#134925) --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/letpot/__init__.py | 94 +++++++++++ .../components/letpot/config_flow.py | 92 +++++++++++ homeassistant/components/letpot/const.py | 10 ++ .../components/letpot/coordinator.py | 67 ++++++++ homeassistant/components/letpot/entity.py | 25 +++ homeassistant/components/letpot/manifest.json | 11 ++ .../components/letpot/quality_scale.yaml | 75 +++++++++ homeassistant/components/letpot/strings.json | 34 ++++ homeassistant/components/letpot/time.py | 93 +++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/letpot/__init__.py | 12 ++ tests/components/letpot/conftest.py | 46 ++++++ tests/components/letpot/test_config_flow.py | 147 ++++++++++++++++++ 19 files changed, 732 insertions(+) create mode 100644 homeassistant/components/letpot/__init__.py create mode 100644 homeassistant/components/letpot/config_flow.py create mode 100644 homeassistant/components/letpot/const.py create mode 100644 homeassistant/components/letpot/coordinator.py create mode 100644 homeassistant/components/letpot/entity.py create mode 100644 homeassistant/components/letpot/manifest.json create mode 100644 homeassistant/components/letpot/quality_scale.yaml create mode 100644 homeassistant/components/letpot/strings.json create mode 100644 homeassistant/components/letpot/time.py create mode 100644 tests/components/letpot/__init__.py create mode 100644 tests/components/letpot/conftest.py create mode 100644 tests/components/letpot/test_config_flow.py diff --git a/.strict-typing b/.strict-typing index 98fbb16ff45..97b1301fdd7 100644 --- a/.strict-typing +++ b/.strict-typing @@ -291,6 +291,7 @@ homeassistant.components.lcn.* homeassistant.components.ld2410_ble.* homeassistant.components.led_ble.* homeassistant.components.lektrico.* +homeassistant.components.letpot.* homeassistant.components.lidarr.* homeassistant.components.lifx.* homeassistant.components.light.* diff --git a/CODEOWNERS b/CODEOWNERS index 4ef40a79bd1..86cfa6ed22a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -831,6 +831,8 @@ build.json @home-assistant/supervisor /tests/components/led_ble/ @bdraco /homeassistant/components/lektrico/ @lektrico /tests/components/lektrico/ @lektrico +/homeassistant/components/letpot/ @jpelgrom +/tests/components/letpot/ @jpelgrom /homeassistant/components/lg_netcast/ @Drafteed @splinter98 /tests/components/lg_netcast/ @Drafteed @splinter98 /homeassistant/components/lg_thinq/ @LG-ThinQ-Integration diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py new file mode 100644 index 00000000000..82fc05c6b0f --- /dev/null +++ b/homeassistant/components/letpot/__init__.py @@ -0,0 +1,94 @@ +"""The LetPot integration.""" + +from __future__ import annotations + +import asyncio + +from letpot.client import LetPotClient +from letpot.converters import CONVERTERS +from letpot.exceptions import LetPotAuthenticationException, LetPotException +from letpot.models import AuthenticationInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_ACCESS_TOKEN_EXPIRES, + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_EXPIRES, + CONF_USER_ID, +) +from .coordinator import LetPotDeviceCoordinator + +PLATFORMS: list[Platform] = [Platform.TIME] + +type LetPotConfigEntry = ConfigEntry[list[LetPotDeviceCoordinator]] + + +async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool: + """Set up LetPot from a config entry.""" + + auth = AuthenticationInfo( + access_token=entry.data[CONF_ACCESS_TOKEN], + access_token_expires=entry.data[CONF_ACCESS_TOKEN_EXPIRES], + refresh_token=entry.data[CONF_REFRESH_TOKEN], + refresh_token_expires=entry.data[CONF_REFRESH_TOKEN_EXPIRES], + user_id=entry.data[CONF_USER_ID], + email=entry.data[CONF_EMAIL], + ) + websession = async_get_clientsession(hass) + client = LetPotClient(websession, auth) + + if not auth.is_valid: + try: + auth = await client.refresh_token() + hass.config_entries.async_update_entry( + entry, + data={ + CONF_ACCESS_TOKEN: auth.access_token, + CONF_ACCESS_TOKEN_EXPIRES: auth.access_token_expires, + CONF_REFRESH_TOKEN: auth.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: auth.refresh_token_expires, + CONF_USER_ID: auth.user_id, + CONF_EMAIL: auth.email, + }, + ) + except LetPotAuthenticationException as exc: + raise ConfigEntryError from exc + + try: + devices = await client.get_devices() + except LetPotAuthenticationException as exc: + raise ConfigEntryError from exc + except LetPotException as exc: + raise ConfigEntryNotReady from exc + + coordinators: list[LetPotDeviceCoordinator] = [ + LetPotDeviceCoordinator(hass, auth, device) + for device in devices + if any(converter.supports_type(device.device_type) for converter in CONVERTERS) + ] + + await asyncio.gather( + *[ + coordinator.async_config_entry_first_refresh() + for coordinator in coordinators + ] + ) + + entry.runtime_data = coordinators + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + for coordinator in entry.runtime_data: + coordinator.device_client.disconnect() + return unload_ok diff --git a/homeassistant/components/letpot/config_flow.py b/homeassistant/components/letpot/config_flow.py new file mode 100644 index 00000000000..7f2f3be1e32 --- /dev/null +++ b/homeassistant/components/letpot/config_flow.py @@ -0,0 +1,92 @@ +"""Config flow for the LetPot integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from letpot.client import LetPotClient +from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import ( + CONF_ACCESS_TOKEN_EXPIRES, + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_EXPIRES, + CONF_USER_ID, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + ), + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + ), + ), + } +) + + +class LetPotConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for LetPot.""" + + VERSION = 1 + + async def _async_validate_credentials( + self, email: str, password: str + ) -> dict[str, Any]: + websession = async_get_clientsession(self.hass) + client = LetPotClient(websession) + auth = await client.login(email, password) + return { + CONF_ACCESS_TOKEN: auth.access_token, + CONF_ACCESS_TOKEN_EXPIRES: auth.access_token_expires, + CONF_REFRESH_TOKEN: auth.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: auth.refresh_token_expires, + CONF_USER_ID: auth.user_id, + CONF_EMAIL: auth.email, + } + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + data_dict = await self._async_validate_credentials( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + except LetPotConnectionException: + errors["base"] = "cannot_connect" + except LetPotAuthenticationException: + errors["base"] = "invalid_auth" + except Exception: # noqa: BLE001 + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(data_dict[CONF_USER_ID]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=data_dict[CONF_EMAIL], data=data_dict + ) + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/letpot/const.py b/homeassistant/components/letpot/const.py new file mode 100644 index 00000000000..af01bbfdffc --- /dev/null +++ b/homeassistant/components/letpot/const.py @@ -0,0 +1,10 @@ +"""Constants for the LetPot integration.""" + +DOMAIN = "letpot" + +CONF_ACCESS_TOKEN_EXPIRES = "access_token_expires" +CONF_REFRESH_TOKEN = "refresh_token" +CONF_REFRESH_TOKEN_EXPIRES = "refresh_token_expires" +CONF_USER_ID = "user_id" + +REQUEST_UPDATE_TIMEOUT = 10 diff --git a/homeassistant/components/letpot/coordinator.py b/homeassistant/components/letpot/coordinator.py new file mode 100644 index 00000000000..4be2fc79253 --- /dev/null +++ b/homeassistant/components/letpot/coordinator.py @@ -0,0 +1,67 @@ +"""Coordinator for the LetPot integration.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from letpot.deviceclient import LetPotDeviceClient +from letpot.exceptions import LetPotAuthenticationException, LetPotException +from letpot.models import AuthenticationInfo, LetPotDevice, LetPotDeviceStatus + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import REQUEST_UPDATE_TIMEOUT + +if TYPE_CHECKING: + from . import LetPotConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): + """Class to handle data updates for a specific garden.""" + + config_entry: LetPotConfigEntry + + device: LetPotDevice + device_client: LetPotDeviceClient + + def __init__( + self, hass: HomeAssistant, info: AuthenticationInfo, device: LetPotDevice + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"LetPot {device.serial_number}", + ) + self._info = info + self.device = device + self.device_client = LetPotDeviceClient(info, device.serial_number) + + def _handle_status_update(self, status: LetPotDeviceStatus) -> None: + """Distribute status update to entities.""" + self.async_set_updated_data(data=status) + + async def _async_setup(self) -> None: + """Set up subscription for coordinator.""" + try: + await self.device_client.subscribe(self._handle_status_update) + except LetPotAuthenticationException as exc: + raise ConfigEntryError from exc + + async def _async_update_data(self) -> LetPotDeviceStatus: + """Request an update from the device and wait for a status update or timeout.""" + try: + async with asyncio.timeout(REQUEST_UPDATE_TIMEOUT): + await self.device_client.get_current_status() + except LetPotException as exc: + raise UpdateFailed(exc) from exc + + # The subscription task will have updated coordinator.data, so return that data. + # If we don't return anything here, coordinator.data will be set to None. + return self.data diff --git a/homeassistant/components/letpot/entity.py b/homeassistant/components/letpot/entity.py new file mode 100644 index 00000000000..c9a8953b5d5 --- /dev/null +++ b/homeassistant/components/letpot/entity.py @@ -0,0 +1,25 @@ +"""Base class for LetPot entities.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LetPotDeviceCoordinator + + +class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]): + """Defines a base LetPot entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: LetPotDeviceCoordinator) -> None: + """Initialize a LetPot entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device.serial_number)}, + name=coordinator.device.name, + manufacturer="LetPot", + model=coordinator.device_client.device_model_name, + model_id=coordinator.device_client.device_model_code, + serial_number=coordinator.device.serial_number, + ) diff --git a/homeassistant/components/letpot/manifest.json b/homeassistant/components/letpot/manifest.json new file mode 100644 index 00000000000..f575279fa69 --- /dev/null +++ b/homeassistant/components/letpot/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "letpot", + "name": "LetPot", + "codeowners": ["@jpelgrom"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/letpot", + "integration_type": "hub", + "iot_class": "cloud_push", + "quality_scale": "bronze", + "requirements": ["letpot==0.2.0"] +} diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml new file mode 100644 index 00000000000..6d6848c5d52 --- /dev/null +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -0,0 +1,75 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration only receives push-based updates. + 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 provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: + status: done + comment: | + Push connection connects in coordinator _async_setup, disconnects in init async_unload_entry. + docs-configuration-parameters: + status: exempt + comment: | + The integration does not have configuration options. + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json new file mode 100644 index 00000000000..2f7dec6f295 --- /dev/null +++ b/homeassistant/components/letpot/strings.json @@ -0,0 +1,34 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email address of your LetPot account.", + "password": "The password of your LetPot account." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "entity": { + "time": { + "light_schedule_end": { + "name": "Light off" + }, + "light_schedule_start": { + "name": "Light on" + } + } + } +} diff --git a/homeassistant/components/letpot/time.py b/homeassistant/components/letpot/time.py new file mode 100644 index 00000000000..229f02e0806 --- /dev/null +++ b/homeassistant/components/letpot/time.py @@ -0,0 +1,93 @@ +"""Support for LetPot time entities.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from datetime import time +from typing import Any + +from letpot.deviceclient import LetPotDeviceClient +from letpot.models import LetPotDeviceStatus + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import LetPotConfigEntry +from .coordinator import LetPotDeviceCoordinator +from .entity import LetPotEntity + +# 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. +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class LetPotTimeEntityDescription(TimeEntityDescription): + """Describes a LetPot time entity.""" + + value_fn: Callable[[LetPotDeviceStatus], time | None] + set_value_fn: Callable[[LetPotDeviceClient, time], Coroutine[Any, Any, None]] + + +TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = ( + LetPotTimeEntityDescription( + key="light_schedule_end", + translation_key="light_schedule_end", + value_fn=lambda status: None if status is None else status.light_schedule_end, + set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule( + start=None, end=value + ), + entity_category=EntityCategory.CONFIG, + ), + LetPotTimeEntityDescription( + key="light_schedule_start", + translation_key="light_schedule_start", + value_fn=lambda status: None if status is None else status.light_schedule_start, + set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule( + start=value, end=None + ), + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LetPotConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up LetPot time entities based on a config entry.""" + coordinators = entry.runtime_data + async_add_entities( + LetPotTimeEntity(coordinator, description) + for description in TIME_SENSORS + for coordinator in coordinators + ) + + +class LetPotTimeEntity(LetPotEntity, TimeEntity): + """Defines a LetPot time entity.""" + + entity_description: LetPotTimeEntityDescription + + def __init__( + self, + coordinator: LetPotDeviceCoordinator, + description: LetPotTimeEntityDescription, + ) -> None: + """Initialize LetPot time 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_value(self) -> time | None: + """Return the time.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_set_value(self, value: time) -> None: + """Set the time.""" + await self.entity_description.set_value_fn( + self.coordinator.device_client, value + ) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 14061d2e960..624665118e6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -331,6 +331,7 @@ FLOWS = { "leaone", "led_ble", "lektrico", + "letpot", "lg_netcast", "lg_soundbar", "lg_thinq", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 768443c36ee..07f4a3ae8ba 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3303,6 +3303,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "letpot": { + "name": "LetPot", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "leviton": { "name": "Leviton", "iot_standards": [ diff --git a/mypy.ini b/mypy.ini index 55fd0b3cd65..617d26545c6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2666,6 +2666,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.letpot.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lidarr.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index ba7f5883a45..6ef97868c15 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1301,6 +1301,9 @@ led-ble==1.1.1 # homeassistant.components.lektrico lektricowifi==0.0.43 +# homeassistant.components.letpot +letpot==0.2.0 + # homeassistant.components.foscam libpyfoscam==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3457fd666a3..95dc48ac863 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1100,6 +1100,9 @@ led-ble==1.1.1 # homeassistant.components.lektrico lektricowifi==0.0.43 +# homeassistant.components.letpot +letpot==0.2.0 + # homeassistant.components.foscam libpyfoscam==1.2.2 diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py new file mode 100644 index 00000000000..f7686f815fe --- /dev/null +++ b/tests/components/letpot/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the LetPot integration.""" + +from letpot.models import AuthenticationInfo + +AUTHENTICATION = AuthenticationInfo( + access_token="access_token", + access_token_expires=0, + refresh_token="refresh_token", + refresh_token_expires=0, + user_id="a1b2c3d4e5f6a1b2c3d4e5f6", + email="email@example.com", +) diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py new file mode 100644 index 00000000000..4cd7ef442a6 --- /dev/null +++ b/tests/components/letpot/conftest.py @@ -0,0 +1,46 @@ +"""Common fixtures for the LetPot tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.letpot.const import ( + CONF_ACCESS_TOKEN_EXPIRES, + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_EXPIRES, + CONF_USER_ID, + DOMAIN, +) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL + +from . import AUTHENTICATION + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.letpot.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=AUTHENTICATION.email, + data={ + CONF_ACCESS_TOKEN: AUTHENTICATION.access_token, + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: AUTHENTICATION.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: AUTHENTICATION.user_id, + CONF_EMAIL: AUTHENTICATION.email, + }, + unique_id=AUTHENTICATION.user_id, + ) diff --git a/tests/components/letpot/test_config_flow.py b/tests/components/letpot/test_config_flow.py new file mode 100644 index 00000000000..c587d31a625 --- /dev/null +++ b/tests/components/letpot/test_config_flow.py @@ -0,0 +1,147 @@ +"""Test the LetPot config flow.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException +import pytest + +from homeassistant.components.letpot.const import ( + CONF_ACCESS_TOKEN_EXPIRES, + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_EXPIRES, + CONF_USER_ID, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import AUTHENTICATION + +from tests.common import MockConfigEntry + + +def _assert_result_success(result: Any) -> None: + """Assert successful end of flow result, creating an entry.""" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == AUTHENTICATION.email + assert result["data"] == { + CONF_ACCESS_TOKEN: AUTHENTICATION.access_token, + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: AUTHENTICATION.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: AUTHENTICATION.user_id, + CONF_EMAIL: AUTHENTICATION.email, + } + assert result["result"].unique_id == AUTHENTICATION.user_id + + +async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test full flow with success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=AUTHENTICATION, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + _assert_result_success(result) + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (LetPotAuthenticationException, "invalid_auth"), + (LetPotConnectionException, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_flow_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test flow with exception during login and recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + # Retry to show recovery. + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=AUTHENTICATION, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + _assert_result_success(result) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_flow_duplicate( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test flow aborts when trying to add a previously added account.""" + mock_config_entry.add_to_hass(hass) + + 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"] == {} + + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=AUTHENTICATION, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(mock_setup_entry.mock_calls) == 0 From d06cd1ad3bc54041be480438d1962ab210851047 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 8 Jan 2025 23:08:13 +0200 Subject: [PATCH 0205/2987] Set PARALLEL_UPDATES in LG webOS TV (#135135) --- homeassistant/components/webostv/media_player.py | 1 + homeassistant/components/webostv/notify.py | 2 ++ homeassistant/components/webostv/quality_scale.yaml | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 239780e3f01..e0520cb7bf5 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -68,6 +68,7 @@ SUPPORT_WEBOSTV_VOLUME = ( MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) +PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=10) diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py index 43320687ce8..e46e3cb202d 100644 --- a/homeassistant/components/webostv/notify.py +++ b/homeassistant/components/webostv/notify.py @@ -16,6 +16,8 @@ from .const import ATTR_CONFIG_ENTRY_ID, DATA_CONFIG_ENTRY, DOMAIN, WEBOSTV_EXCE _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_get_service( hass: HomeAssistant, diff --git a/homeassistant/components/webostv/quality_scale.yaml b/homeassistant/components/webostv/quality_scale.yaml index 693cefcdbfc..9d9b52ab930 100644 --- a/homeassistant/components/webostv/quality_scale.yaml +++ b/homeassistant/components/webostv/quality_scale.yaml @@ -37,7 +37,7 @@ rules: entity-unavailable: todo integration-owner: done log-when-unavailable: todo - parallel-updates: todo + parallel-updates: done reauthentication-flow: done test-coverage: todo From acbd501ede95e3bfd3b7fd3943537f139401f482 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Wed, 8 Jan 2025 22:09:59 +0100 Subject: [PATCH 0206/2987] Add DataUpdateCoordinator to bluesound integration (#135125) --- .../components/bluesound/__init__.py | 24 +- .../components/bluesound/coordinator.py | 160 +++++++++++++ .../components/bluesound/media_player.py | 218 ++++-------------- .../snapshots/test_media_player.ambr | 1 - .../components/bluesound/test_media_player.py | 4 +- 5 files changed, 222 insertions(+), 185 deletions(-) create mode 100644 homeassistant/components/bluesound/coordinator.py diff --git a/homeassistant/components/bluesound/__init__.py b/homeassistant/components/bluesound/__init__.py index b3facc0b8ac..6cf1957f799 100644 --- a/homeassistant/components/bluesound/__init__.py +++ b/homeassistant/components/bluesound/__init__.py @@ -14,10 +14,13 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .const import DOMAIN +from .coordinator import BluesoundCoordinator CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [ + Platform.MEDIA_PLAYER, +] @dataclass @@ -26,6 +29,7 @@ class BluesoundRuntimeData: player: Player sync_status: SyncStatus + coordinator: BluesoundCoordinator type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData] @@ -33,9 +37,6 @@ type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Bluesound.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = [] - return True @@ -46,13 +47,16 @@ async def async_setup_entry( host = config_entry.data[CONF_HOST] port = config_entry.data[CONF_PORT] session = async_get_clientsession(hass) - async with Player(host, port, session=session, default_timeout=10) as player: - try: - sync_status = await player.sync_status(timeout=1) - except PlayerUnreachableError as ex: - raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex + player = Player(host, port, session=session, default_timeout=10) + try: + sync_status = await player.sync_status(timeout=1) + except PlayerUnreachableError as ex: + raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex - config_entry.runtime_data = BluesoundRuntimeData(player, sync_status) + coordinator = BluesoundCoordinator(hass, player, sync_status) + await coordinator.async_config_entry_first_refresh() + + config_entry.runtime_data = BluesoundRuntimeData(player, sync_status, coordinator) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/homeassistant/components/bluesound/coordinator.py b/homeassistant/components/bluesound/coordinator.py new file mode 100644 index 00000000000..e62f3ef96cf --- /dev/null +++ b/homeassistant/components/bluesound/coordinator.py @@ -0,0 +1,160 @@ +"""Define a base coordinator for Bluesound entities.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Coroutine +import contextlib +from dataclasses import dataclass, replace +from datetime import timedelta +import logging + +from pyblu import Input, Player, Preset, Status, SyncStatus +from pyblu.errors import PlayerUnreachableError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +NODE_OFFLINE_CHECK_TIMEOUT = timedelta(minutes=3) +PRESET_AND_INPUTS_INTERVAL = timedelta(minutes=15) + + +@dataclass +class BluesoundData: + """Define a class to hold Bluesound data.""" + + sync_status: SyncStatus + status: Status + presets: list[Preset] + inputs: list[Input] + + +def cancel_task(task: asyncio.Task) -> Callable[[], Coroutine[None, None, None]]: + """Cancel a task.""" + + async def _cancel_task() -> None: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + return _cancel_task + + +class BluesoundCoordinator(DataUpdateCoordinator[BluesoundData]): + """Define an object to hold Bluesound data.""" + + def __init__( + self, hass: HomeAssistant, player: Player, sync_status: SyncStatus + ) -> None: + """Initialize.""" + self.player = player + self._inital_sync_status = sync_status + + super().__init__( + hass, + logger=_LOGGER, + name=sync_status.name, + ) + + async def _async_setup(self) -> None: + assert self.config_entry is not None + + preset = await self.player.presets() + inputs = await self.player.inputs() + status = await self.player.status() + + self.async_set_updated_data( + BluesoundData( + sync_status=self._inital_sync_status, + status=status, + presets=preset, + inputs=inputs, + ) + ) + + status_loop_task = self.hass.async_create_background_task( + self._poll_status_loop(), + name=f"bluesound.poll_status_loop_{self.data.sync_status.id}", + ) + self.config_entry.async_on_unload(cancel_task(status_loop_task)) + + sync_status_loop_task = self.hass.async_create_background_task( + self._poll_sync_status_loop(), + name=f"bluesound.poll_sync_status_loop_{self.data.sync_status.id}", + ) + self.config_entry.async_on_unload(cancel_task(sync_status_loop_task)) + + presets_and_inputs_loop_task = self.hass.async_create_background_task( + self._poll_presets_and_inputs_loop(), + name=f"bluesound.poll_presets_and_inputs_loop_{self.data.sync_status.id}", + ) + self.config_entry.async_on_unload(cancel_task(presets_and_inputs_loop_task)) + + async def _async_update_data(self) -> BluesoundData: + return self.data + + async def _poll_presets_and_inputs_loop(self) -> None: + while True: + await asyncio.sleep(PRESET_AND_INPUTS_INTERVAL.total_seconds()) + try: + preset = await self.player.presets() + inputs = await self.player.inputs() + self.async_set_updated_data( + replace( + self.data, + presets=preset, + inputs=inputs, + ) + ) + except PlayerUnreachableError as ex: + self.async_set_update_error(ex) + except asyncio.CancelledError: + return + except Exception as ex: # noqa: BLE001 - this loop should never stop + self.async_set_update_error(ex) + + async def _poll_status_loop(self) -> None: + """Loop which polls the status of the player.""" + while True: + try: + status = await self.player.status( + etag=self.data.status.etag, poll_timeout=120, timeout=125 + ) + self.async_set_updated_data( + replace( + self.data, + status=status, + ) + ) + except PlayerUnreachableError as ex: + self.async_set_update_error(ex) + await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds()) + except asyncio.CancelledError: + return + except Exception as ex: # noqa: BLE001 - this loop should never stop + self.async_set_update_error(ex) + await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds()) + + async def _poll_sync_status_loop(self) -> None: + """Loop which polls the sync status of the player.""" + while True: + try: + sync_status = await self.player.sync_status( + etag=self.data.sync_status.etag, poll_timeout=120, timeout=125 + ) + self.async_set_updated_data( + replace( + self.data, + sync_status=sync_status, + ) + ) + except PlayerUnreachableError as ex: + self.async_set_update_error(ex) + await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds()) + except asyncio.CancelledError: + raise + except Exception as ex: # noqa: BLE001 - this loop should never stop + self.async_set_update_error(ex) + await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds()) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index e850c059e52..12e2f537935 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -2,15 +2,12 @@ from __future__ import annotations -import asyncio -from asyncio import CancelledError, Task -from contextlib import suppress +from asyncio import Task from datetime import datetime, timedelta import logging from typing import TYPE_CHECKING, Any from pyblu import Input, Player, Preset, Status, SyncStatus -from pyblu.errors import PlayerUnreachableError import voluptuous as vol from homeassistant.components import media_source @@ -23,7 +20,7 @@ from homeassistant.components.media_player import ( async_process_play_media_url, ) from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import ( @@ -36,9 +33,11 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity import homeassistant.util.dt as dt_util from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN +from .coordinator import BluesoundCoordinator from .utils import dispatcher_join_signal, dispatcher_unjoin_signal, format_unique_id if TYPE_CHECKING: @@ -56,11 +55,6 @@ SERVICE_JOIN = "join" SERVICE_SET_TIMER = "set_sleep_timer" SERVICE_UNJOIN = "unjoin" -NODE_OFFLINE_CHECK_TIMEOUT = 180 -NODE_RETRY_INITIATION = timedelta(minutes=3) - -SYNC_STATUS_INTERVAL = timedelta(minutes=5) - POLL_TIMEOUT = 120 @@ -71,10 +65,10 @@ async def async_setup_entry( ) -> None: """Set up the Bluesound entry.""" bluesound_player = BluesoundPlayer( + config_entry.runtime_data.coordinator, config_entry.data[CONF_HOST], config_entry.data[CONF_PORT], config_entry.runtime_data.player, - config_entry.runtime_data.sync_status, ) platform = entity_platform.async_get_current_platform() @@ -89,11 +83,10 @@ async def async_setup_entry( ) platform.async_register_entity_service(SERVICE_UNJOIN, None, "async_unjoin") - hass.data[DATA_BLUESOUND].append(bluesound_player) async_add_entities([bluesound_player], update_before_add=True) -class BluesoundPlayer(MediaPlayerEntity): +class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity): """Representation of a Bluesound Player.""" _attr_media_content_type = MediaType.MUSIC @@ -102,12 +95,15 @@ class BluesoundPlayer(MediaPlayerEntity): def __init__( self, + coordinator: BluesoundCoordinator, host: str, port: int, player: Player, - sync_status: SyncStatus, ) -> None: """Initialize the media player.""" + super().__init__(coordinator) + sync_status = coordinator.data.sync_status + self.host = host self.port = port self._poll_status_loop_task: Task[None] | None = None @@ -115,15 +111,14 @@ class BluesoundPlayer(MediaPlayerEntity): self._id = sync_status.id self._last_status_update: datetime | None = None self._sync_status = sync_status - self._status: Status | None = None - self._inputs: list[Input] = [] - self._presets: list[Preset] = [] + self._status: Status = coordinator.data.status + self._inputs: list[Input] = coordinator.data.inputs + self._presets: list[Preset] = coordinator.data.presets self._group_name: str | None = None self._group_list: list[str] = [] self._bluesound_device_name = sync_status.name self._player = player - self._is_leader = False - self._leader: BluesoundPlayer | None = None + self._last_status_update = dt_util.utcnow() self._attr_unique_id = format_unique_id(sync_status.mac, port) # there should always be one player with the default port per mac @@ -146,52 +141,10 @@ class BluesoundPlayer(MediaPlayerEntity): via_device=(DOMAIN, format_mac(sync_status.mac)), ) - async def _poll_status_loop(self) -> None: - """Loop which polls the status of the player.""" - while True: - try: - await self.async_update_status() - except PlayerUnreachableError: - _LOGGER.error( - "Node %s:%s is offline, retrying later", self.host, self.port - ) - await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) - except CancelledError: - _LOGGER.debug( - "Stopping the polling of node %s:%s", self.host, self.port - ) - return - except: # noqa: E722 - this loop should never stop - _LOGGER.exception( - "Unexpected error for %s:%s, retrying later", self.host, self.port - ) - await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) - - async def _poll_sync_status_loop(self) -> None: - """Loop which polls the sync status of the player.""" - while True: - try: - await self.update_sync_status() - except PlayerUnreachableError: - await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) - except CancelledError: - raise - except: # noqa: E722 - all errors must be caught for this loop - await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) - async def async_added_to_hass(self) -> None: """Start the polling task.""" await super().async_added_to_hass() - self._poll_status_loop_task = self.hass.async_create_background_task( - self._poll_status_loop(), - name=f"bluesound.poll_status_loop_{self.host}:{self.port}", - ) - self._poll_sync_status_loop_task = self.hass.async_create_background_task( - self._poll_sync_status_loop(), - name=f"bluesound.poll_sync_status_loop_{self.host}:{self.port}", - ) - assert self._sync_status.id is not None self.async_on_remove( async_dispatcher_connect( @@ -212,105 +165,24 @@ class BluesoundPlayer(MediaPlayerEntity): """Stop the polling task.""" await super().async_will_remove_from_hass() - assert self._poll_status_loop_task is not None - if self._poll_status_loop_task.cancel(): - # the sleeps in _poll_loop will raise CancelledError - with suppress(CancelledError): - await self._poll_status_loop_task + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._sync_status = self.coordinator.data.sync_status + self._status = self.coordinator.data.status + self._inputs = self.coordinator.data.inputs + self._presets = self.coordinator.data.presets - assert self._poll_sync_status_loop_task is not None - if self._poll_sync_status_loop_task.cancel(): - # the sleeps in _poll_sync_status_loop will raise CancelledError - with suppress(CancelledError): - await self._poll_sync_status_loop_task - - self.hass.data[DATA_BLUESOUND].remove(self) - - async def async_update(self) -> None: - """Update internal status of the entity.""" - if not self.available: - return - - with suppress(PlayerUnreachableError): - await self.async_update_presets() - await self.async_update_captures() - - async def async_update_status(self) -> None: - """Use the poll session to always get the status of the player.""" - etag = None - if self._status is not None: - etag = self._status.etag - - try: - status = await self._player.status( - etag=etag, poll_timeout=POLL_TIMEOUT, timeout=POLL_TIMEOUT + 5 - ) - - self._attr_available = True - self._last_status_update = dt_util.utcnow() - self._status = status - - self.async_write_ha_state() - except PlayerUnreachableError: - self._attr_available = False - self._last_status_update = None - self._status = None - self.async_write_ha_state() - _LOGGER.error( - "Client connection error, marking %s as offline", - self._bluesound_device_name, - ) - raise - - async def update_sync_status(self) -> None: - """Update the internal status.""" - etag = None - if self._sync_status: - etag = self._sync_status.etag - sync_status = await self._player.sync_status( - etag=etag, poll_timeout=POLL_TIMEOUT, timeout=POLL_TIMEOUT + 5 - ) - - self._sync_status = sync_status + self._last_status_update = dt_util.utcnow() self._group_list = self.rebuild_bluesound_group() - if sync_status.leader is not None: - self._is_leader = False - leader_id = f"{sync_status.leader.ip}:{sync_status.leader.port}" - leader_device = [ - device - for device in self.hass.data[DATA_BLUESOUND] - if device.id == leader_id - ] - - if leader_device and leader_id != self.id: - self._leader = leader_device[0] - else: - self._leader = None - _LOGGER.error("Leader not found %s", leader_id) - else: - if self._leader is not None: - self._leader = None - followers = self._sync_status.followers - self._is_leader = followers is not None - self.async_write_ha_state() - async def async_update_captures(self) -> None: - """Update Capture sources.""" - inputs = await self._player.inputs() - self._inputs = inputs - - async def async_update_presets(self) -> None: - """Update Presets.""" - presets = await self._player.presets() - self._presets = presets - @property def state(self) -> MediaPlayerState: """Return the state of the device.""" - if self._status is None: + if self.available is False: return MediaPlayerState.OFF if self.is_grouped and not self.is_leader: @@ -327,7 +199,7 @@ class BluesoundPlayer(MediaPlayerEntity): @property def media_title(self) -> str | None: """Title of current playing media.""" - if self._status is None or (self.is_grouped and not self.is_leader): + if self.available is False or (self.is_grouped and not self.is_leader): return None return self._status.name @@ -335,7 +207,7 @@ class BluesoundPlayer(MediaPlayerEntity): @property def media_artist(self) -> str | None: """Artist of current playing media (Music track only).""" - if self._status is None: + if self.available is False: return None if self.is_grouped and not self.is_leader: @@ -346,7 +218,7 @@ class BluesoundPlayer(MediaPlayerEntity): @property def media_album_name(self) -> str | None: """Artist of current playing media (Music track only).""" - if self._status is None or (self.is_grouped and not self.is_leader): + if self.available is False or (self.is_grouped and not self.is_leader): return None return self._status.album @@ -354,7 +226,7 @@ class BluesoundPlayer(MediaPlayerEntity): @property def media_image_url(self) -> str | None: """Image url of current playing media.""" - if self._status is None or (self.is_grouped and not self.is_leader): + if self.available is False or (self.is_grouped and not self.is_leader): return None url = self._status.image @@ -369,7 +241,7 @@ class BluesoundPlayer(MediaPlayerEntity): @property def media_position(self) -> int | None: """Position of current playing media in seconds.""" - if self._status is None or (self.is_grouped and not self.is_leader): + if self.available is False or (self.is_grouped and not self.is_leader): return None mediastate = self.state @@ -388,7 +260,7 @@ class BluesoundPlayer(MediaPlayerEntity): @property def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" - if self._status is None or (self.is_grouped and not self.is_leader): + if self.available is False or (self.is_grouped and not self.is_leader): return None duration = self._status.total_seconds @@ -405,16 +277,11 @@ class BluesoundPlayer(MediaPlayerEntity): @property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" - volume = None + volume = self._status.volume - if self._status is not None: - volume = self._status.volume if self.is_grouped: volume = self._sync_status.volume - if volume is None: - return None - return volume / 100 @property @@ -447,7 +314,7 @@ class BluesoundPlayer(MediaPlayerEntity): @property def source_list(self) -> list[str] | None: """List of available input sources.""" - if self._status is None or (self.is_grouped and not self.is_leader): + if self.available is False or (self.is_grouped and not self.is_leader): return None sources = [x.text for x in self._inputs] @@ -458,7 +325,7 @@ class BluesoundPlayer(MediaPlayerEntity): @property def source(self) -> str | None: """Name of the current input source.""" - if self._status is None or (self.is_grouped and not self.is_leader): + if self.available is False or (self.is_grouped and not self.is_leader): return None if self._status.input_id is not None: @@ -475,7 +342,7 @@ class BluesoundPlayer(MediaPlayerEntity): @property def supported_features(self) -> MediaPlayerEntityFeature: """Flag of media commands that are supported.""" - if self._status is None: + if self.available is False: return MediaPlayerEntityFeature(0) if self.is_grouped and not self.is_leader: @@ -577,16 +444,21 @@ class BluesoundPlayer(MediaPlayerEntity): if self.sync_status.leader is None and self.sync_status.followers is None: return [] - player_entities: list[BluesoundPlayer] = self.hass.data[DATA_BLUESOUND] + config_entries: list[BluesoundConfigEntry] = ( + self.hass.config_entries.async_entries(DOMAIN) + ) + sync_status_list = [ + x.runtime_data.coordinator.data.sync_status for x in config_entries + ] leader_sync_status: SyncStatus | None = None if self.sync_status.leader is None: leader_sync_status = self.sync_status else: required_id = f"{self.sync_status.leader.ip}:{self.sync_status.leader.port}" - for x in player_entities: - if x.sync_status.id == required_id: - leader_sync_status = x.sync_status + for sync_status in sync_status_list: + if sync_status.id == required_id: + leader_sync_status = sync_status break if leader_sync_status is None or leader_sync_status.followers is None: @@ -594,9 +466,9 @@ class BluesoundPlayer(MediaPlayerEntity): follower_ids = [f"{x.ip}:{x.port}" for x in leader_sync_status.followers] follower_names = [ - x.sync_status.name - for x in player_entities - if x.sync_status.id in follower_ids + sync_status.name + for sync_status in sync_status_list + if sync_status.id in follower_ids ] follower_names.insert(0, leader_sync_status.name) return follower_names diff --git a/tests/components/bluesound/snapshots/test_media_player.ambr b/tests/components/bluesound/snapshots/test_media_player.ambr index 3e644d3038a..f71302f286d 100644 --- a/tests/components/bluesound/snapshots/test_media_player.ambr +++ b/tests/components/bluesound/snapshots/test_media_player.ambr @@ -9,7 +9,6 @@ 'media_artist': 'artist', 'media_content_type': , 'media_duration': 123, - 'media_position': 2, 'media_title': 'song', 'shuffle': False, 'source_list': list([ diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index a43696a0a7f..ed537d0bc57 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -127,7 +127,9 @@ async def test_attributes_set( ) -> None: """Test the media player attributes set.""" state = hass.states.get("media_player.player_name1111") - assert state == snapshot(exclude=props("media_position_updated_at")) + assert state == snapshot( + exclude=props("media_position_updated_at", "media_position") + ) async def test_stop_maps_to_idle( From 488c5a6b9fb113a467563d99b9ddc8bb60d4f52a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Wed, 8 Jan 2025 22:10:29 +0100 Subject: [PATCH 0207/2987] Use is in FlowResultType enum comparison in integration scaffold tests (#135133) --- .../templates/config_flow/tests/test_config_flow.py | 12 ++++++------ .../config_flow_helper/tests/test_config_flow.py | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index 9a712834bae..66209f77e6a 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -15,7 +15,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {} with patch( @@ -32,7 +32,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" assert result["data"] == { CONF_HOST: "1.1.1.1", @@ -63,7 +63,7 @@ async def test_form_invalid_auth( }, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} # Make sure the config flow tests finish with either an @@ -83,7 +83,7 @@ async def test_form_invalid_auth( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" assert result["data"] == { CONF_HOST: "1.1.1.1", @@ -114,7 +114,7 @@ async def test_form_cannot_connect( }, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} # Make sure the config flow tests finish with either an @@ -135,7 +135,7 @@ async def test_form_cannot_connect( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" assert result["data"] == { CONF_HOST: "1.1.1.1", diff --git a/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py b/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py index 8e7854835d8..fbf705cfb26 100644 --- a/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py @@ -24,7 +24,7 @@ async def test_config_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None result = await hass.config_entries.flow.async_configure( @@ -33,7 +33,7 @@ async def test_config_flow( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "My NEW_DOMAIN" assert result["data"] == {} assert result["options"] == { @@ -83,7 +83,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: await hass.async_block_till_done() result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema assert get_suggested(schema, "entity_id") == input_sensor_1_entity_id @@ -94,7 +94,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: "entity_id": input_sensor_2_entity_id, }, ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { "entity_id": input_sensor_2_entity_id, "name": "My NEW_DOMAIN", From bb4a497247b7345cbea3f40b2b2c80aa4cad0592 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 8 Jan 2025 23:12:09 +0200 Subject: [PATCH 0208/2987] Impove LG webOS TV tests quality (#135130) * Impove LG webOS TV tests quality * Review comments --- .../components/webostv/quality_scale.yaml | 6 +- tests/components/webostv/__init__.py | 7 +- .../webostv/snapshots/test_diagnostics.ambr | 66 +++++++++ .../webostv/snapshots/test_media_player.ambr | 59 ++++++++ tests/components/webostv/test_config_flow.py | 132 +++++++----------- tests/components/webostv/test_diagnostics.py | 61 ++------ tests/components/webostv/test_media_player.py | 41 +++--- 7 files changed, 208 insertions(+), 164 deletions(-) create mode 100644 tests/components/webostv/snapshots/test_diagnostics.ambr create mode 100644 tests/components/webostv/snapshots/test_media_player.ambr diff --git a/homeassistant/components/webostv/quality_scale.yaml b/homeassistant/components/webostv/quality_scale.yaml index 9d9b52ab930..a5d898b1de7 100644 --- a/homeassistant/components/webostv/quality_scale.yaml +++ b/homeassistant/components/webostv/quality_scale.yaml @@ -8,9 +8,7 @@ rules: common-modules: status: exempt comment: The integration does not use common patterns. - config-flow-test-coverage: - status: todo - comment: remove duplicated config flow start in tests, make sure tests ends with CREATE_ENTRY or ABORT, use hass.config_entries.async_setup instead of async_setup_component, snapshot in diagnostics (and other tests when possible), test_client_disconnected validate no error in log + config-flow-test-coverage: done config-flow: status: todo comment: make reauth flow more graceful @@ -39,7 +37,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/webostv/__init__.py b/tests/components/webostv/__init__.py index d6c096f9d3a..5027b235eb1 100644 --- a/tests/components/webostv/__init__.py +++ b/tests/components/webostv/__init__.py @@ -3,7 +3,6 @@ from homeassistant.components.webostv.const import DOMAIN from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .const import CLIENT_KEY, FAKE_UUID, HOST, TV_NAME @@ -25,11 +24,7 @@ async def setup_webostv( ) entry.add_to_hass(hass) - await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_HOST: HOST}}, - ) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() return entry diff --git a/tests/components/webostv/snapshots/test_diagnostics.ambr b/tests/components/webostv/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..a9bd6e91ee0 --- /dev/null +++ b/tests/components/webostv/snapshots/test_diagnostics.ambr @@ -0,0 +1,66 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'client': dict({ + 'apps': dict({ + 'com.webos.app.livetv': dict({ + 'icon': '**REDACTED**', + 'id': 'com.webos.app.livetv', + 'largeIcon': '**REDACTED**', + 'title': 'Live TV', + }), + }), + 'current_app_id': 'com.webos.app.livetv', + 'current_channel': dict({ + 'channelId': 'ch1id', + 'channelName': 'Channel 1', + 'channelNumber': '1', + }), + 'hello_info': dict({ + 'deviceUUID': '**REDACTED**', + }), + 'inputs': dict({ + 'in1': dict({ + 'appId': 'app0', + 'id': 'in1', + 'label': 'Input01', + }), + 'in2': dict({ + 'appId': 'app1', + 'id': 'in2', + 'label': 'Input02', + }), + }), + 'is_connected': True, + 'is_on': True, + 'is_registered': True, + 'software_info': dict({ + 'major_ver': 'major', + 'minor_ver': 'minor', + }), + 'sound_output': 'speaker', + 'system_info': dict({ + 'modelName': 'MODEL', + }), + }), + 'entry': dict({ + 'data': dict({ + 'client_secret': '**REDACTED**', + 'host': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'webostv', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'LG webOS TV MODEL', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/webostv/snapshots/test_media_player.ambr b/tests/components/webostv/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..78c0bd517a6 --- /dev/null +++ b/tests/components/webostv/snapshots/test_media_player.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_entity_attributes + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tv', + 'friendly_name': 'LG webOS TV MODEL', + 'is_volume_muted': False, + 'media_content_type': , + 'media_title': 'Channel 1', + 'sound_output': 'speaker', + 'source': 'Live TV', + 'source_list': list([ + 'Input01', + 'Input02', + 'Live TV', + ]), + 'supported_features': , + 'volume_level': 0.37, + }), + 'context': , + 'entity_id': 'media_player.lg_webos_tv_model', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_attributes.1 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'webostv', + 'some-fake-uuid', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'LG', + 'model': 'MODEL', + 'model_id': None, + 'name': 'LG webOS TV MODEL', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'major.minor', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index 1c0c0e935e5..608e3bd306a 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -1,7 +1,6 @@ """Test the WebOS Tv config flow.""" -import dataclasses -from unittest.mock import Mock +from unittest.mock import AsyncMock from aiowebostv import WebOsTvPairError import pytest @@ -41,28 +40,7 @@ MOCK_DISCOVERY_INFO = ssdp.SsdpServiceInfo( async def test_form(hass: HomeAssistant, client) -> None: - """Test we get the form.""" - assert client - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: config_entries.SOURCE_USER}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: config_entries.SOURCE_USER}, - data=MOCK_USER_CONFIG, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pairing" - + """Test successful user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER}, @@ -77,10 +55,10 @@ async def test_form(hass: HomeAssistant, client) -> None: result["flow_id"], user_input={} ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TV_NAME + config_entry = result["result"] + assert config_entry.unique_id == FAKE_UUID @pytest.mark.parametrize( @@ -114,27 +92,44 @@ async def test_options_flow_live_tv_in_apps( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_SOURCES: ["Live TV", "Input01", "Input02"]}, ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["data"][CONF_SOURCES] == ["Live TV", "Input01", "Input02"] + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_SOURCES] == ["Live TV", "Input01", "Input02"] async def test_options_flow_cannot_retrieve(hass: HomeAssistant, client) -> None: """Test options config flow cannot retrieve sources.""" entry = await setup_webostv(hass) - client.connect = Mock(side_effect=ConnectionRefusedError()) + client.connect = AsyncMock(side_effect=ConnectionRefusedError()) result = await hass.config_entries.options.async_init(entry.entry_id) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_retrieve"} + # recover + client.connect = AsyncMock(return_value=True) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=None, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_SOURCES: ["Input01", "Input02"]}, + ) + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["data"][CONF_SOURCES] == ["Input01", "Input02"] + async def test_form_cannot_connect(hass: HomeAssistant, client) -> None: """Test we handle cannot connect error.""" @@ -144,14 +139,22 @@ async def test_form_cannot_connect(hass: HomeAssistant, client) -> None: data=MOCK_USER_CONFIG, ) - client.connect = Mock(side_effect=ConnectionRefusedError()) - result2 = await hass.config_entries.flow.async_configure( + client.connect = AsyncMock(side_effect=ConnectionRefusedError()) + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # recover + client.connect = AsyncMock(return_value=True) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TV_NAME async def test_form_pairexception(hass: HomeAssistant, client) -> None: @@ -162,20 +165,18 @@ async def test_form_pairexception(hass: HomeAssistant, client) -> None: data=MOCK_USER_CONFIG, ) - client.connect = Mock(side_effect=WebOsTvPairError("error")) - result2 = await hass.config_entries.flow.async_configure( + client.connect = AsyncMock(side_effect=WebOsTvPairError("error")) + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "error_pairing" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "error_pairing" async def test_entry_already_configured(hass: HomeAssistant, client) -> None: """Test entry already configured.""" await setup_webostv(hass) - assert client result = await hass.config_entries.flow.async_init( DOMAIN, @@ -189,8 +190,6 @@ async def test_entry_already_configured(hass: HomeAssistant, client) -> None: async def test_form_ssdp(hass: HomeAssistant, client) -> None: """Test that the ssdp confirmation form is served.""" - assert client - result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVERY_INFO ) @@ -199,19 +198,18 @@ async def test_form_ssdp(hass: HomeAssistant, client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "pairing" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == TV_NAME + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TV_NAME + config_entry = result["result"] + assert config_entry.unique_id == FAKE_UUID async def test_ssdp_in_progress(hass: HomeAssistant, client) -> None: """Test abort if ssdp paring is already in progress.""" - assert client - result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER}, @@ -222,38 +220,19 @@ async def test_ssdp_in_progress(hass: HomeAssistant, client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "pairing" - result2 = await hass.config_entries.flow.async_init( + # Start another ssdp flow to make sure it aborts as already in progress + result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVERY_INFO ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_in_progress" - - -async def test_ssdp_not_update_uuid(hass: HomeAssistant, client) -> None: - """Test that ssdp not updates different host.""" - entry = await setup_webostv(hass, None) - assert client - assert entry.unique_id is None - - discovery_info = dataclasses.replace(MOCK_DISCOVERY_INFO) - discovery_info.ssdp_location = "http://1.2.3.5" - - result2 = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "pairing" - assert entry.unique_id is None + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" async def test_form_abort_uuid_configured(hass: HomeAssistant, client) -> None: """Test abort if uuid is already configured, verify host update.""" entry = await setup_webostv(hass, MOCK_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN][5:]) - assert client assert entry.unique_id == MOCK_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN][5:] assert entry.data[CONF_HOST] == HOST @@ -268,6 +247,7 @@ async def test_form_abort_uuid_configured(hass: HomeAssistant, client) -> None: user_config = {CONF_HOST: "new_host"} + # Start another flow to make sure it aborts and updates host result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER}, @@ -282,8 +262,6 @@ async def test_form_abort_uuid_configured(hass: HomeAssistant, client) -> None: result["flow_id"], user_input={} ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert entry.data[CONF_HOST] == "new_host" @@ -294,7 +272,6 @@ async def test_reauth_successful( ) -> None: """Test that the reauthorization is successful.""" entry = await setup_webostv(hass) - assert client result = await entry.start_reauth_flow(hass) assert result["step_id"] == "reauth_confirm" @@ -327,7 +304,6 @@ async def test_reauth_errors( ) -> None: """Test reauthorization errors.""" entry = await setup_webostv(hass) - assert client result = await entry.start_reauth_flow(hass) assert result["step_id"] == "reauth_confirm" @@ -337,7 +313,7 @@ async def test_reauth_errors( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - monkeypatch.setattr(client, "connect", Mock(side_effect=side_effect)) + client.connect.side_effect = side_effect() result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) diff --git a/tests/components/webostv/test_diagnostics.py b/tests/components/webostv/test_diagnostics.py index 0dfb13b0424..d35dd1fb883 100644 --- a/tests/components/webostv/test_diagnostics.py +++ b/tests/components/webostv/test_diagnostics.py @@ -1,6 +1,8 @@ """Tests for the diagnostics data provided by LG webOS Smart TV.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + from homeassistant.core import HomeAssistant from . import setup_webostv @@ -10,56 +12,13 @@ from tests.typing import ClientSessionGenerator async def test_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator, client + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + client, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" entry = await setup_webostv(hass) - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == { - "client": { - "is_registered": True, - "is_connected": True, - "current_app_id": "com.webos.app.livetv", - "current_channel": { - "channelId": "ch1id", - "channelName": "Channel 1", - "channelNumber": "1", - }, - "apps": { - "com.webos.app.livetv": { - "icon": REDACTED, - "id": "com.webos.app.livetv", - "largeIcon": REDACTED, - "title": "Live TV", - } - }, - "inputs": { - "in1": {"appId": "app0", "id": "in1", "label": "Input01"}, - "in2": {"appId": "app1", "id": "in2", "label": "Input02"}, - }, - "system_info": {"modelName": "MODEL"}, - "software_info": {"major_ver": "major", "minor_ver": "minor"}, - "hello_info": {"deviceUUID": "**REDACTED**"}, - "sound_output": "speaker", - "is_on": True, - }, - "entry": { - "entry_id": entry.entry_id, - "version": 1, - "minor_version": 1, - "domain": "webostv", - "title": "LG webOS TV MODEL", - "data": { - "client_secret": "**REDACTED**", - "host": "**REDACTED**", - }, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - "created_at": entry.created_at.isoformat(), - "modified_at": entry.modified_at.isoformat(), - "discovery_keys": {}, - }, - } + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( + exclude=props("created_at", "modified_at", "entry_id") + ) diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 7c89b749bbe..9e5958d21cc 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -5,7 +5,10 @@ from http import HTTPStatus from unittest.mock import Mock from aiowebostv import WebOsTvPairError +from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props from homeassistant.components import automation from homeassistant.components.media_player import ( @@ -19,7 +22,6 @@ from homeassistant.components.media_player import ( DOMAIN as MP_DOMAIN, SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, - MediaPlayerDeviceClass, MediaPlayerEntityFeature, MediaPlayerState, MediaType, @@ -42,7 +44,6 @@ from homeassistant.components.webostv.media_player import ( from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_COMMAND, - ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ENTITY_MATCH_NONE, @@ -58,7 +59,6 @@ from homeassistant.const import ( SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, STATE_OFF, - STATE_ON, ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError @@ -67,7 +67,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import setup_webostv -from .const import CHANNEL_2, ENTITY_ID, TV_MODEL, TV_NAME +from .const import CHANNEL_2, ENTITY_ID, TV_NAME from tests.common import async_fire_time_changed, mock_restore_cache from tests.test_util.aiohttp import AiohttpClientMocker @@ -298,6 +298,7 @@ async def test_entity_attributes( client, monkeypatch: pytest.MonkeyPatch, device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test entity attributes.""" entry = await setup_webostv(hass) @@ -305,18 +306,7 @@ async def test_entity_attributes( # Attributes when device is on state = hass.states.get(ENTITY_ID) - attrs = state.attributes - - assert state.state == STATE_ON - assert state.name == TV_NAME - assert attrs[ATTR_DEVICE_CLASS] == MediaPlayerDeviceClass.TV - assert attrs[ATTR_MEDIA_VOLUME_MUTED] is False - assert attrs[ATTR_MEDIA_VOLUME_LEVEL] == 0.37 - assert attrs[ATTR_INPUT_SOURCE] == "Live TV" - assert attrs[ATTR_INPUT_SOURCE_LIST] == ["Input01", "Input02", "Live TV"] - assert attrs[ATTR_MEDIA_CONTENT_TYPE] == MediaType.CHANNEL - assert attrs[ATTR_MEDIA_TITLE] == "Channel 1" - assert attrs[ATTR_SOUND_OUTPUT] == "speaker" + assert state == snapshot(exclude=props("entity_picture")) # Volume level not available monkeypatch.setattr(client, "volume", None) @@ -334,13 +324,7 @@ async def test_entity_attributes( # Device Info device = device_registry.async_get_device(identifiers={(DOMAIN, entry.unique_id)}) - - assert device - assert device.identifiers == {(DOMAIN, entry.unique_id)} - assert device.manufacturer == "LG" - assert device.name == TV_NAME - assert device.sw_version == "major.minor" - assert device.model == TV_MODEL + assert device == snapshot # Sound output when off monkeypatch.setattr(client, "sound_output", None) @@ -473,16 +457,23 @@ async def test_update_sources_live_tv_find( async def test_client_disconnected( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + client, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test error not raised when client is disconnected.""" await setup_webostv(hass) monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) monkeypatch.setattr(client, "connect", Mock(side_effect=TimeoutError)) - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20)) + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) await hass.async_block_till_done() + assert "TimeoutError" not in caplog.text + async def test_control_error_handling( hass: HomeAssistant, From f01c860c4414cd0826293d74c18e697d0ea1eabd Mon Sep 17 00:00:00 2001 From: Tomer Shemesh Date: Wed, 8 Jan 2025 16:40:13 -0500 Subject: [PATCH 0209/2987] Add support for Lutron Wood Tilt Blinds (#135057) --- homeassistant/components/lutron_caseta/cover.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index 11da2220be9..d8fac38ce2b 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -99,6 +99,7 @@ class LutronCasetaTiltOnlyBlind(LutronCasetaUpdatableEntity, CoverEntity): PYLUTRON_TYPE_TO_CLASSES = { "SerenaTiltOnlyWoodBlind": LutronCasetaTiltOnlyBlind, + "Tilt": LutronCasetaTiltOnlyBlind, "SerenaHoneycombShade": LutronCasetaShade, "SerenaRollerShade": LutronCasetaShade, "TriathlonHoneycombShade": LutronCasetaShade, From 2704090418743ee6068506af146bc4e2954aa240 Mon Sep 17 00:00:00 2001 From: Quentame Date: Wed, 8 Jan 2025 22:51:37 +0100 Subject: [PATCH 0210/2987] =?UTF-8?q?Fix=20M=C3=A9t=C3=A9o-France=20setup?= =?UTF-8?q?=20in=20non=20French=20cities=20(because=20of=20failed=20next?= =?UTF-8?q?=20rain=20sensor)=20(#134782)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/meteo_france/__init__.py | 12 ++++++++++-- homeassistant/components/meteo_france/sensor.py | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 1d4f8293c5e..4b79b046b75 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -6,6 +6,7 @@ import logging from meteofrance_api.client import MeteoFranceClient from meteofrance_api.helpers import is_valid_warning_department from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain +from requests import RequestException import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -83,7 +84,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_method=_async_update_data_rain, update_interval=SCAN_INTERVAL_RAIN, ) - await coordinator_rain.async_config_entry_first_refresh() + try: + await coordinator_rain._async_refresh(log_failures=False) # noqa: SLF001 + except RequestException: + _LOGGER.warning( + "1 hour rain forecast not available: %s is not in covered zone", + entry.title, + ) department = coordinator_forecast.data.position.get("dept") _LOGGER.debug( @@ -128,8 +135,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = { UNDO_UPDATE_LISTENER: undo_listener, COORDINATOR_FORECAST: coordinator_forecast, - COORDINATOR_RAIN: coordinator_rain, } + if coordinator_rain and coordinator_rain.last_update_success: + hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN] = coordinator_rain if coordinator_alert and coordinator_alert.last_update_success: hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] = coordinator_alert diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index d8dbdfc4265..826716f1679 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -187,7 +187,7 @@ async def async_setup_entry( """Set up the Meteo-France sensor platform.""" data = hass.data[DOMAIN][entry.entry_id] coordinator_forecast: DataUpdateCoordinator[Forecast] = data[COORDINATOR_FORECAST] - coordinator_rain: DataUpdateCoordinator[Rain] | None = data[COORDINATOR_RAIN] + coordinator_rain: DataUpdateCoordinator[Rain] | None = data.get(COORDINATOR_RAIN) coordinator_alert: DataUpdateCoordinator[CurrentPhenomenons] | None = data.get( COORDINATOR_ALERT ) From c5f80dd01d07d22e20800e44f8a52bf28b095b7e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 8 Jan 2025 22:55:31 +0100 Subject: [PATCH 0211/2987] Render select entity unavailable when active feature is missing in Sensibo (#135031) --- homeassistant/components/sensibo/select.py | 19 ++++++--------- homeassistant/components/sensibo/strings.json | 3 --- tests/components/sensibo/test_select.py | 23 +++++++++++++++---- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 12e7364d6ee..b542c51d22f 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -16,7 +16,6 @@ from homeassistant.components.select import ( SelectEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import ( @@ -137,6 +136,13 @@ class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity): self.entity_description = entity_description self._attr_unique_id = f"{device_id}-{entity_description.key}" + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.entity_description.key not in self.device_data.active_features: + return False + return super().available + @property def current_option(self) -> str | None: """Return the current selected option.""" @@ -152,17 +158,6 @@ class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Set state to the selected option.""" - if self.entity_description.key not in self.device_data.active_features: - hvac_mode = self.device_data.hvac_mode if self.device_data.hvac_mode else "" - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="select_option_not_available", - translation_placeholders={ - "hvac_mode": hvac_mode, - "key": self.entity_description.key, - }, - ) - await self.async_send_api_call( key=self.entity_description.data_key, value=option, diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index a1f60c247a3..adebd268ccd 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -575,9 +575,6 @@ "service_raised": { "message": "Could not perform action for {name} with error {error}" }, - "select_option_not_available": { - "message": "Current mode {hvac_mode} doesn't support setting {key}" - }, "climate_react_not_available": { "message": "Use Sensibo Enable Climate React action once to enable switch or the Sensibo app" }, diff --git a/tests/components/sensibo/test_select.py b/tests/components/sensibo/test_select.py index c93eff92f3a..75dbdc88840 100644 --- a/tests/components/sensibo/test_select.py +++ b/tests/components/sensibo/test_select.py @@ -16,7 +16,7 @@ from homeassistant.components.select import ( ) from homeassistant.components.sensibo.const import DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir @@ -63,7 +63,7 @@ async def test_select_set_option( """Test the Sensibo select service.""" mock_client.async_get_devices_data.return_value.parsed[ - "ABC999111" + "AAZZAAZZ" ].active_features = [ "timestamp", "on", @@ -97,13 +97,11 @@ async def test_select_set_option( assert state.state == "on" mock_client.async_get_devices_data.return_value.parsed[ - "ABC999111" + "AAZZAAZZ" ].active_features = [ "timestamp", "on", "mode", - "targetTemperature", - "horizontalSwing", "light", ] @@ -142,6 +140,21 @@ async def test_select_set_option( state = hass.states.get("select.kitchen_light") assert state.state == "dim" + mock_client.async_get_devices_data.return_value.parsed[ + "AAZZAAZZ" + ].active_features = [ + "timestamp", + "on", + "mode", + ] + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("select.kitchen_light") + assert state.state == STATE_UNAVAILABLE + @pytest.mark.parametrize( "load_platforms", From 64752af4c2f7460f7e1bd584c3661a6015f4ded1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 9 Jan 2025 03:34:36 +0100 Subject: [PATCH 0212/2987] Change minimum SQLite version to 3.40.1 (#135042) Co-authored-by: J. Nick Koston --- .../components/recorder/strings.json | 4 -- homeassistant/components/recorder/util.py | 50 +------------ tests/components/recorder/test_util.py | 70 ++----------------- tests/components/sensor/test_recorder.py | 9 --- 4 files changed, 8 insertions(+), 125 deletions(-) diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index 2ded6be58d6..43c2ecdc14f 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -16,10 +16,6 @@ "backup_failed_out_of_resources": { "title": "Database backup failed due to lack of resources", "description": "The database backup stated at {start_time} failed due to lack of resources. The backup cannot be trusted and must be restarted. This can happen if the database is too large or if the system is under heavy load. Consider upgrading the system hardware or reducing the size of the database by decreasing the number of history days to keep or creating a filter." - }, - "sqlite_too_old": { - "title": "Update SQLite to {min_version} or later to continue using the recorder", - "description": "Support for version {server_version} of SQLite is ending; the minimum supported version is {min_version}. Please upgrade your database software." } }, "services": { diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 4cf24eb79c5..632553838c2 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -95,9 +95,8 @@ RECOMMENDED_MIN_VERSION_MARIA_DB_108 = _simple_version("10.8.4") MARIADB_WITH_FIXED_IN_QUERIES_108 = _simple_version("10.8.4") MIN_VERSION_MYSQL = _simple_version("8.0.0") MIN_VERSION_PGSQL = _simple_version("12.0") -MIN_VERSION_SQLITE = _simple_version("3.31.0") -UPCOMING_MIN_VERSION_SQLITE = _simple_version("3.40.1") -MIN_VERSION_SQLITE_MODERN_BIND_VARS = _simple_version("3.32.0") +MIN_VERSION_SQLITE = _simple_version("3.40.1") +MIN_VERSION_SQLITE_MODERN_BIND_VARS = _simple_version("3.40.1") # This is the maximum time after the recorder ends the session @@ -376,37 +375,6 @@ def _raise_if_version_unsupported( raise UnsupportedDialect -@callback -def _async_delete_issue_deprecated_version( - hass: HomeAssistant, dialect_name: str -) -> None: - """Delete the issue about upcoming unsupported database version.""" - ir.async_delete_issue(hass, DOMAIN, f"{dialect_name}_too_old") - - -@callback -def _async_create_issue_deprecated_version( - hass: HomeAssistant, - server_version: AwesomeVersion, - dialect_name: str, - min_version: AwesomeVersion, -) -> None: - """Warn about upcoming unsupported database version.""" - ir.async_create_issue( - hass, - DOMAIN, - f"{dialect_name}_too_old", - is_fixable=False, - severity=ir.IssueSeverity.CRITICAL, - translation_key=f"{dialect_name}_too_old", - translation_placeholders={ - "server_version": str(server_version), - "min_version": str(min_version), - }, - breaks_in_ha_version="2025.2.0", - ) - - def _extract_version_from_server_response_or_raise( server_response: str, ) -> AwesomeVersion: @@ -523,20 +491,6 @@ def setup_connection_for_dialect( version or version_string, "SQLite", MIN_VERSION_SQLITE ) - # No elif here since _raise_if_version_unsupported raises - if version < UPCOMING_MIN_VERSION_SQLITE: - instance.hass.add_job( - _async_create_issue_deprecated_version, - instance.hass, - version or version_string, - dialect_name, - UPCOMING_MIN_VERSION_SQLITE, - ) - else: - instance.hass.add_job( - _async_delete_issue_deprecated_version, instance.hass, dialect_name - ) - if version and version > MIN_VERSION_SQLITE_MODERN_BIND_VARS: max_bind_vars = SQLITE_MODERN_MAX_BIND_VARS diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index aeeeba1865a..4e6d664ec0a 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -35,7 +35,6 @@ from homeassistant.components.recorder.models import ( from homeassistant.components.recorder.util import ( MIN_VERSION_SQLITE, RETRYABLE_MYSQL_ERRORS, - UPCOMING_MIN_VERSION_SQLITE, database_job_retry_wrapper, end_incomplete_runs, is_second_sunday, @@ -236,7 +235,7 @@ def test_setup_connection_for_dialect_mysql(mysql_version) -> None: @pytest.mark.parametrize( "sqlite_version", - [str(UPCOMING_MIN_VERSION_SQLITE)], + [str(MIN_VERSION_SQLITE)], ) def test_setup_connection_for_dialect_sqlite(sqlite_version: str) -> None: """Test setting up the connection for a sqlite dialect.""" @@ -289,7 +288,7 @@ def test_setup_connection_for_dialect_sqlite(sqlite_version: str) -> None: @pytest.mark.parametrize( "sqlite_version", - [str(UPCOMING_MIN_VERSION_SQLITE)], + [str(MIN_VERSION_SQLITE)], ) def test_setup_connection_for_dialect_sqlite_zero_commit_interval( sqlite_version: str, @@ -510,11 +509,11 @@ def test_supported_pgsql(caplog: pytest.LogCaptureFixture, pgsql_version) -> Non [ ( "3.30.0", - "Version 3.30.0 of SQLite is not supported; minimum supported version is 3.31.0.", + "Version 3.30.0 of SQLite is not supported; minimum supported version is 3.40.1.", ), ( "2.0.0", - "Version 2.0.0 of SQLite is not supported; minimum supported version is 3.31.0.", + "Version 2.0.0 of SQLite is not supported; minimum supported version is 3.40.1.", ), ], ) @@ -552,8 +551,8 @@ def test_fail_outdated_sqlite( @pytest.mark.parametrize( "sqlite_version", [ - ("3.31.0"), - ("3.33.0"), + ("3.40.1"), + ("3.41.0"), ], ) def test_supported_sqlite(caplog: pytest.LogCaptureFixture, sqlite_version) -> None: @@ -734,63 +733,6 @@ async def test_no_issue_for_mariadb_with_MDEV_25020( assert database_engine.optimizer.slow_range_in_select is False -async def test_issue_for_old_sqlite( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -) -> None: - """Test we create and delete an issue for old sqlite versions.""" - instance_mock = MagicMock() - instance_mock.hass = hass - execute_args = [] - close_mock = MagicMock() - min_version = str(MIN_VERSION_SQLITE) - - def execute_mock(statement): - nonlocal execute_args - execute_args.append(statement) - - def fetchall_mock(): - nonlocal execute_args - if execute_args[-1] == "SELECT sqlite_version()": - return [[min_version]] - return None - - def _make_cursor_mock(*_): - return MagicMock(execute=execute_mock, close=close_mock, fetchall=fetchall_mock) - - dbapi_connection = MagicMock(cursor=_make_cursor_mock) - - database_engine = await hass.async_add_executor_job( - util.setup_connection_for_dialect, - instance_mock, - "sqlite", - dbapi_connection, - True, - ) - await hass.async_block_till_done() - - issue = issue_registry.async_get_issue(DOMAIN, "sqlite_too_old") - assert issue is not None - assert issue.translation_placeholders == { - "min_version": str(UPCOMING_MIN_VERSION_SQLITE), - "server_version": min_version, - } - - min_version = str(UPCOMING_MIN_VERSION_SQLITE) - database_engine = await hass.async_add_executor_job( - util.setup_connection_for_dialect, - instance_mock, - "sqlite", - dbapi_connection, - True, - ) - await hass.async_block_till_done() - - issue = issue_registry.async_get_issue(DOMAIN, "sqlite_too_old") - assert issue is None - assert database_engine is not None - - @pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) @pytest.mark.usefixtures("skip_by_db_engine") async def test_basic_sanity_check( diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 636fb9871c9..d011926848d 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -121,15 +121,6 @@ def disable_mariadb_issue() -> None: yield -@pytest.fixture(autouse=True) -def disable_sqlite_issue() -> None: - """Disable creating issue about outdated SQLite version.""" - with patch( - "homeassistant.components.recorder.util._async_create_issue_deprecated_version" - ): - yield - - async def async_list_statistic_ids( hass: HomeAssistant, statistic_ids: set[str] | None = None, From fe8cae8eb533c8743e876604d7b3c9b34f497c1d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 9 Jan 2025 09:02:14 +0100 Subject: [PATCH 0213/2987] Make devices dynamic in Sensibo (#134935) --- .../components/sensibo/binary_sensor.py | 59 ++++++++++---- homeassistant/components/sensibo/button.py | 20 ++++- homeassistant/components/sensibo/climate.py | 20 +++-- .../components/sensibo/coordinator.py | 39 ++++++++++ homeassistant/components/sensibo/number.py | 22 ++++-- .../components/sensibo/quality_scale.yaml | 4 +- homeassistant/components/sensibo/select.py | 28 ++++--- homeassistant/components/sensibo/sensor.py | 37 ++++++--- homeassistant/components/sensibo/switch.py | 26 +++++-- homeassistant/components/sensibo/update.py | 24 ++++-- tests/components/sensibo/test_init.py | 78 ++++++++++++++++++- 11 files changed, 290 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 8d47fb11526..a66ab46c882 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +import logging from typing import TYPE_CHECKING from pysensibo.model import MotionSensor, SensiboDevice @@ -18,6 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SensiboConfigEntry +from .const import LOGGER from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity @@ -122,32 +124,55 @@ async def async_setup_entry( coordinator = entry.runtime_data - entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] + added_devices: set[str] = set() - for device_id, device_data in coordinator.data.parsed.items(): - if device_data.motion_sensors: + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] + nonlocal added_devices + new_devices, remove_devices, added_devices = coordinator.get_devices( + added_devices + ) + + if LOGGER.isEnabledFor(logging.DEBUG): + LOGGER.debug( + "New devices: %s, Removed devices: %s, Existing devices: %s", + new_devices, + remove_devices, + added_devices, + ) + + if new_devices: entities.extend( SensiboMotionSensor( coordinator, device_id, sensor_id, sensor_data, description ) + for device_id, device_data in coordinator.data.parsed.items() + if device_data.motion_sensors for sensor_id, sensor_data in device_data.motion_sensors.items() + if sensor_id in new_devices for description in MOTION_SENSOR_TYPES ) - entities.extend( - SensiboDeviceSensor(coordinator, device_id, description) - for description in MOTION_DEVICE_SENSOR_TYPES - for device_id, device_data in coordinator.data.parsed.items() - if device_data.motion_sensors - ) - entities.extend( - SensiboDeviceSensor(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DESCRIPTION_BY_MODELS.get( - device_data.model, DEVICE_SENSOR_TYPES - ) - ) - async_add_entities(entities) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_data.motion_sensors and device_id in new_devices + for description in MOTION_DEVICE_SENSOR_TYPES + ) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DESCRIPTION_BY_MODELS.get( + device_data.model, DEVICE_SENSOR_TYPES + ) + ) + + async_add_entities(entities) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboMotionSensor(SensiboMotionBaseEntity, BinarySensorEntity): diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py index 7adafe2e7fc..df8d4625840 100644 --- a/homeassistant/components/sensibo/button.py +++ b/homeassistant/components/sensibo/button.py @@ -41,10 +41,22 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - SensiboDeviceButton(coordinator, device_id, DEVICE_BUTTON_TYPES) - for device_id, device_data in coordinator.data.parsed.items() - ) + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, added_devices = coordinator.get_devices(added_devices) + + if new_devices: + async_add_entities( + SensiboDeviceButton(coordinator, device_id, DEVICE_BUTTON_TYPES) + for device_id in coordinator.data.parsed + if device_id in new_devices + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboDeviceButton(SensiboDeviceBaseEntity, ButtonEntity): diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 9a2f265041f..5d1c6ff9e79 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -144,12 +144,22 @@ async def async_setup_entry( coordinator = entry.runtime_data - entities = [ - SensiboClimate(coordinator, device_id) - for device_id, device_data in coordinator.data.parsed.items() - ] + added_devices: set[str] = set() - async_add_entities(entities) + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, added_devices = coordinator.get_devices(added_devices) + + if new_devices: + async_add_entities( + SensiboClimate(coordinator, device_id) + for device_id in coordinator.data.parsed + if device_id in new_devices + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( diff --git a/homeassistant/components/sensibo/coordinator.py b/homeassistant/components/sensibo/coordinator.py index e512935dfce..e19f24295b9 100644 --- a/homeassistant/components/sensibo/coordinator.py +++ b/homeassistant/components/sensibo/coordinator.py @@ -12,6 +12,7 @@ from pysensibo.model import SensiboData from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -48,6 +49,25 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]): session=async_get_clientsession(hass), timeout=TIMEOUT, ) + self.previous_devices: set[str] = set() + + def get_devices( + self, added_devices: set[str] + ) -> tuple[set[str], set[str], set[str]]: + """Addition and removal of devices.""" + data = self.data + motion_sensors = { + sensor_id + for device_data in data.parsed.values() + if device_data.motion_sensors + for sensor_id in device_data.motion_sensors + } + devices: set[str] = set(data.parsed) + new_devices: set[str] = motion_sensors | devices - added_devices + remove_devices = added_devices - devices - motion_sensors + added_devices = (added_devices - remove_devices) | new_devices + + return (new_devices, remove_devices, added_devices) async def _async_update_data(self) -> SensiboData: """Fetch data from Sensibo.""" @@ -67,4 +87,23 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]): if not data.raw: raise UpdateFailed(translation_domain=DOMAIN, translation_key="no_data") + + current_devices = set(data.parsed) + for device_data in data.parsed.values(): + if device_data.motion_sensors: + for motion_sensor_id in device_data.motion_sensors: + current_devices.add(motion_sensor_id) + + if stale_devices := self.previous_devices - current_devices: + LOGGER.debug("Removing stale devices: %s", stale_devices) + device_registry = dr.async_get(self.hass) + for _id in stale_devices: + device = device_registry.async_get_device(identifiers={(DOMAIN, _id)}) + if device: + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + self.previous_devices = current_devices + return data diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index baa056f0eea..aa46c7f8c1e 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -71,11 +71,23 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - SensiboNumber(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DEVICE_NUMBER_TYPES - ) + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, added_devices = coordinator.get_devices(added_devices) + + if new_devices: + async_add_entities( + SensiboNumber(coordinator, device_id, description) + for device_id in coordinator.data.parsed + for description in DEVICE_NUMBER_TYPES + if device_id in new_devices + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboNumber(SensiboDeviceBaseEntity, NumberEntity): diff --git a/homeassistant/components/sensibo/quality_scale.yaml b/homeassistant/components/sensibo/quality_scale.yaml index 08632ddac0f..c21cf100e9d 100644 --- a/homeassistant/components/sensibo/quality_scale.yaml +++ b/homeassistant/components/sensibo/quality_scale.yaml @@ -54,7 +54,7 @@ rules: entity-category: done entity-disabled-by-default: done discovery: done - stale-devices: todo + stale-devices: done diagnostics: status: done comment: | @@ -62,7 +62,7 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - dynamic-devices: todo + dynamic-devices: done discovery-update-info: status: exempt comment: | diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index b542c51d22f..51521b59f03 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -108,17 +108,27 @@ async def async_setup_entry( "entity": entity_id, }, ) - - entities.extend( - [ - SensiboSelect(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DEVICE_SELECT_TYPES - if description.key in device_data.full_features - ] - ) async_add_entities(entities) + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, added_devices = coordinator.get_devices(added_devices) + + if new_devices: + async_add_entities( + SensiboSelect(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DEVICE_SELECT_TYPES + if description.key in device_data.full_features + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() + class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity): """Representation of a Sensibo Select.""" diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index bea1326181c..b242f38febe 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -246,25 +246,40 @@ async def async_setup_entry( coordinator = entry.runtime_data - entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] + added_devices: set[str] = set() - for device_id, device_data in coordinator.data.parsed.items(): - if device_data.motion_sensors: + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + + entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] + nonlocal added_devices + new_devices, remove_devices, added_devices = coordinator.get_devices( + added_devices + ) + + if new_devices: entities.extend( SensiboMotionSensor( coordinator, device_id, sensor_id, sensor_data, description ) + for device_id, device_data in coordinator.data.parsed.items() + if device_data.motion_sensors for sensor_id, sensor_data in device_data.motion_sensors.items() + if sensor_id in new_devices for description in MOTION_SENSOR_TYPES ) - entities.extend( - SensiboDeviceSensor(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DESCRIPTION_BY_MODELS.get( - device_data.model, DEVICE_SENSOR_TYPES - ) - ) - async_add_entities(entities) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DESCRIPTION_BY_MODELS.get( + device_data.model, DEVICE_SENSOR_TYPES + ) + ) + async_add_entities(entities) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboMotionSensor(SensiboMotionBaseEntity, SensorEntity): diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index 46906ac1871..0bc2c55a706 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -84,13 +84,25 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - SensiboDeviceSwitch(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DESCRIPTION_BY_MODELS.get( - device_data.model, DEVICE_SWITCH_TYPES - ) - ) + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, added_devices = coordinator.get_devices(added_devices) + + if new_devices: + async_add_entities( + SensiboDeviceSwitch(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DESCRIPTION_BY_MODELS.get( + device_data.model, DEVICE_SWITCH_TYPES + ) + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index d52565564a6..0b02264b3e0 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -51,12 +51,24 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - SensiboDeviceUpdate(coordinator, device_id, description) - for description in DEVICE_SENSOR_TYPES - for device_id, device_data in coordinator.data.parsed.items() - if description.value_available(device_data) is not None - ) + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, added_devices = coordinator.get_devices(added_devices) + + if new_devices: + async_add_entities( + SensiboDeviceUpdate(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DEVICE_SENSOR_TYPES + if description.value_available(device_data) is not None + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboDeviceUpdate(SensiboDeviceBaseEntity, UpdateEntity): diff --git a/tests/components/sensibo/test_init.py b/tests/components/sensibo/test_init.py index 78eee6ceba0..b4911983fe7 100644 --- a/tests/components/sensibo/test_init.py +++ b/tests/components/sensibo/test_init.py @@ -2,8 +2,14 @@ from __future__ import annotations +from datetime import timedelta +from typing import Any from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory +from pysensibo.model import SensiboData +import pytest + from homeassistant.components.sensibo.const import DOMAIN from homeassistant.components.sensibo.util import NoUsernameError from homeassistant.config_entries import SOURCE_USER, ConfigEntry, ConfigEntryState @@ -13,7 +19,7 @@ from homeassistant.setup import async_setup_component from . import ENTRY_CONFIG -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator @@ -103,3 +109,73 @@ async def test_device_remove_devices( ) response = await client.remove_device(dead_device_entry.id, load_int.entry_id) assert response["success"] + + +@pytest.mark.parametrize( + ("entity_id", "device_ids"), + [ + # Device is ABC999111 + ("climate.hallway", ["ABC999111"]), + ("binary_sensor.hallway_filter_clean_required", ["ABC999111"]), + ("number.hallway_temperature_calibration", ["ABC999111"]), + ("sensor.hallway_filter_last_reset", ["ABC999111"]), + ("update.hallway_firmware", ["ABC999111"]), + # Device is AABBCC belonging to device ABC999111 + ("binary_sensor.hallway_motion_sensor_motion", ["ABC999111", "AABBCC"]), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_automatic_device_addition_and_removal( + hass: HomeAssistant, + load_int: ConfigEntry, + mock_client: MagicMock, + get_data: tuple[SensiboData, dict[str, Any], dict[str, Any]], + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, + entity_id: str, + device_ids: list[str], +) -> None: + """Test for automatic device addition and removal.""" + + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + for device_id in device_ids: + assert device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + + # Remove one of the devices + new_device_list = [ + device for device in get_data[2]["result"] if device["id"] != device_ids[0] + ] + mock_client.async_get_devices.return_value = { + "status": "success", + "result": new_device_list, + } + new_data = {k: v for k, v in get_data[0].parsed.items() if k != device_ids[0]} + new_raw = mock_client.async_get_devices.return_value["result"] + mock_client.async_get_devices_data.return_value = SensiboData(new_raw, new_data) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert not state + assert not entity_registry.async_get(entity_id) + for device_id in device_ids: + assert not device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + + # Add the device back + mock_client.async_get_devices.return_value = get_data[2] + mock_client.async_get_devices_data.return_value = get_data[0] + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + for device_id in device_ids: + assert device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) From 2f892678f6e6c04693644aafe155b12ea46d404c Mon Sep 17 00:00:00 2001 From: Brynley McDonald Date: Thu, 9 Jan 2025 22:09:04 +1300 Subject: [PATCH 0214/2987] Fix Flick Electric Pricing (#135154) --- homeassistant/components/flick_electric/manifest.json | 2 +- homeassistant/components/flick_electric/sensor.py | 6 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/flick_electric/manifest.json b/homeassistant/components/flick_electric/manifest.json index 3aee25995a9..3096590f47a 100644 --- a/homeassistant/components/flick_electric/manifest.json +++ b/homeassistant/components/flick_electric/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyflick"], - "requirements": ["PyFlick==1.1.2"] + "requirements": ["PyFlick==1.1.3"] } diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py index 147d00c943d..73b6f8793fb 100644 --- a/homeassistant/components/flick_electric/sensor.py +++ b/homeassistant/components/flick_electric/sensor.py @@ -51,19 +51,19 @@ class FlickPricingSensor(CoordinatorEntity[FlickElectricDataCoordinator], Sensor _LOGGER.warning( "Unexpected quantity for unit price: %s", self.coordinator.data ) - return self.coordinator.data.cost + return self.coordinator.data.cost * 100 @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" - components: dict[str, Decimal] = {} + components: dict[str, float] = {} for component in self.coordinator.data.components: if component.charge_setter not in ATTR_COMPONENTS: _LOGGER.warning("Found unknown component: %s", component.charge_setter) continue - components[component.charge_setter] = component.value + components[component.charge_setter] = float(component.value * 100) return { ATTR_START_AT: self.coordinator.data.start_at, diff --git a/requirements_all.txt b/requirements_all.txt index 6ef97868c15..bc08e6e8276 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -48,7 +48,7 @@ ProgettiHWSW==0.1.3 PyChromecast==14.0.5 # homeassistant.components.flick_electric -PyFlick==1.1.2 +PyFlick==1.1.3 # homeassistant.components.flume PyFlume==0.6.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 95dc48ac863..ee2c83c07f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -45,7 +45,7 @@ ProgettiHWSW==0.1.3 PyChromecast==14.0.5 # homeassistant.components.flick_electric -PyFlick==1.1.2 +PyFlick==1.1.3 # homeassistant.components.flume PyFlume==0.6.5 From 0184d8e954f1d3c1346cbf4a52101c70714d623c Mon Sep 17 00:00:00 2001 From: Nikolay Vasilchuk Date: Thu, 9 Jan 2025 12:24:04 +0300 Subject: [PATCH 0215/2987] Deprecate StarLine engine switch attributes (#133958) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/starline/account.py | 1 + homeassistant/components/starline/binary_sensor.py | 9 +++++++-- homeassistant/components/starline/icons.json | 7 +++++-- homeassistant/components/starline/strings.json | 7 +++++-- homeassistant/components/starline/switch.py | 1 + 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/starline/account.py b/homeassistant/components/starline/account.py index 4b1425ae7d9..0fb5a367148 100644 --- a/homeassistant/components/starline/account.py +++ b/homeassistant/components/starline/account.py @@ -180,6 +180,7 @@ class StarlineAccount: "online": device.online, } + # Deprecated and should be removed in 2025.8 @staticmethod def engine_attrs(device: StarlineDevice) -> dict[str, Any]: """Attributes for engine switch.""" diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py index 69f0ae06d02..ac1ad4f2b6e 100644 --- a/homeassistant/components/starline/binary_sensor.py +++ b/homeassistant/components/starline/binary_sensor.py @@ -43,8 +43,13 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( ), BinarySensorEntityDescription( key="run", - translation_key="is_running", - device_class=BinarySensorDeviceClass.RUNNING, + translation_key="ignition", + entity_registry_enabled_default=False, + ), + BinarySensorEntityDescription( + key="r_start", + translation_key="autostart", + entity_registry_enabled_default=False, ), BinarySensorEntityDescription( key="hfree", diff --git a/homeassistant/components/starline/icons.json b/homeassistant/components/starline/icons.json index d7d20ae03bd..07713a0cdfe 100644 --- a/homeassistant/components/starline/icons.json +++ b/homeassistant/components/starline/icons.json @@ -13,8 +13,11 @@ "moving_ban": { "default": "mdi:car-off" }, - "is_running": { - "default": "mdi:speedometer" + "ignition": { + "default": "mdi:key-variant" + }, + "autostart": { + "default": "mdi:auto-mode" } }, "button": { diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json index f292a74621c..b3ce755778e 100644 --- a/homeassistant/components/starline/strings.json +++ b/homeassistant/components/starline/strings.json @@ -64,8 +64,11 @@ "moving_ban": { "name": "Moving ban" }, - "is_running": { - "name": "Running" + "ignition": { + "name": "Ignition" + }, + "autostart": { + "name": "Autostart" } }, "device_tracker": { diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index 05193d98c8a..eb71f0b73b5 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -72,6 +72,7 @@ class StarlineSwitch(StarlineEntity, SwitchEntity): def extra_state_attributes(self): """Return the state attributes of the switch.""" if self._key == "ign": + # Deprecated and should be removed in 2025.8 return self._account.engine_attrs(self._device) return None From c9d8c59b45d7f3b48036a43f1f0cf177bb980c62 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Jan 2025 23:33:24 -1000 Subject: [PATCH 0216/2987] Bump zeroconf to 0.138.1 (#135148) --- 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 98fa02a716e..3e3780d397c 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.137.2"] + "requirements": ["zeroconf==0.138.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 715e98e56e6..0ac837c15e1 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.137.2 +zeroconf==0.138.1 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 3889dadff74..3d8168e4857 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ dependencies = [ "voluptuous-openapi==0.0.6", "yarl==1.18.3", "webrtc-models==0.3.0", - "zeroconf==0.137.2" + "zeroconf==0.138.1" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 9aaef2a6b79..d217f7f50ab 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.137.2 +zeroconf==0.138.1 diff --git a/requirements_all.txt b/requirements_all.txt index bc08e6e8276..dd98c845c12 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3115,7 +3115,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.137.2 +zeroconf==0.138.1 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ee2c83c07f9..24f94facdf4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2501,7 +2501,7 @@ yt-dlp[default]==2024.12.23 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.137.2 +zeroconf==0.138.1 # homeassistant.components.zeversolar zeversolar==0.3.2 From 9901f3c3dd0346fb7bf2ed3a157d47fec9d2ce2c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 9 Jan 2025 10:53:33 +0100 Subject: [PATCH 0217/2987] Add jitter to backup start time to avoid thundering herd (#135065) --- homeassistant/components/backup/config.py | 7 +++++++ tests/components/backup/test_websocket.py | 2 ++ 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 3c5d5d39f7e..7c40792aec5 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -7,6 +7,7 @@ from collections.abc import Callable from dataclasses import dataclass, field, replace from datetime import datetime, timedelta from enum import StrEnum +import random from typing import TYPE_CHECKING, Self, TypedDict from cronsim import CronSim @@ -28,6 +29,10 @@ if TYPE_CHECKING: CRON_PATTERN_DAILY = "45 4 * * *" CRON_PATTERN_WEEKLY = "45 4 * * {}" +# Randomize the start time of the backup by up to 60 minutes to avoid +# all backups running at the same time. +BACKUP_START_TIME_JITTER = 60 * 60 + class StoredBackupConfig(TypedDict): """Represent the stored backup config.""" @@ -329,6 +334,8 @@ class BackupSchedule: except Exception: # noqa: BLE001 LOGGER.exception("Unexpected error creating automatic backup") + next_time += timedelta(seconds=random.randint(0, BACKUP_START_TIME_JITTER)) + LOGGER.debug("Scheduling next automatic backup at %s", next_time) manager.remove_next_backup_event = async_track_point_in_time( manager.hass, _create_backup, next_time ) diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 307a1d79e0c..e95481373d6 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -1345,6 +1345,7 @@ async def test_config_update_errors( ), ], ) +@patch("homeassistant.components.backup.config.BACKUP_START_TIME_JITTER", 0) async def test_config_schedule_logic( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1787,6 +1788,7 @@ async def test_config_schedule_logic( ), ], ) +@patch("homeassistant.components.backup.config.BACKUP_START_TIME_JITTER", 0) async def test_config_retention_copies_logic( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, From 316a61fcdeb18f85e3ace4bc0434e3cf6655a2df Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jan 2025 11:20:08 +0100 Subject: [PATCH 0218/2987] Deprecate raw_value attribute in onewire entity (#135171) * Drop raw_value attribute in onewire entity * Deprecate only --- homeassistant/components/onewire/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/onewire/entity.py b/homeassistant/components/onewire/entity.py index c8ad87fa34e..2ea21aca488 100644 --- a/homeassistant/components/onewire/entity.py +++ b/homeassistant/components/onewire/entity.py @@ -54,6 +54,7 @@ class OneWireEntity(Entity): """Return the state attributes of the entity.""" return { "device_file": self._device_file, + # raw_value attribute is deprecated and can be removed in 2025.8 "raw_value": self._value_raw, } From 071e675d9d7a5bf9d7ee83d1e07f347919f3018e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 9 Jan 2025 11:21:07 +0100 Subject: [PATCH 0219/2987] Mark docs-installation-parameters and docs-removal-instructions for inexogy as done (#135126) --- homeassistant/components/discovergy/quality_scale.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/discovergy/quality_scale.yaml b/homeassistant/components/discovergy/quality_scale.yaml index 3caeaa6bbe0..792a76b2696 100644 --- a/homeassistant/components/discovergy/quality_scale.yaml +++ b/homeassistant/components/discovergy/quality_scale.yaml @@ -19,7 +19,7 @@ rules: The integration does not provide any additional actions. docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: exempt comment: | @@ -41,7 +41,7 @@ rules: status: exempt comment: | The integration does not provide any additional options. - docs-installation-parameters: todo + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done From 13527768cca2722c27056d015ab832322a8227ec Mon Sep 17 00:00:00 2001 From: Antoine Reversat Date: Thu, 9 Jan 2025 05:21:27 -0500 Subject: [PATCH 0220/2987] Add outside temperature sensor to fujitsu_fglair (#130717) --- .../components/fujitsu_fglair/__init__.py | 2 +- .../components/fujitsu_fglair/climate.py | 22 +--- .../components/fujitsu_fglair/entity.py | 33 ++++++ .../components/fujitsu_fglair/sensor.py | 47 ++++++++ .../components/fujitsu_fglair/strings.json | 7 ++ tests/components/fujitsu_fglair/conftest.py | 30 ++++- .../fujitsu_fglair/snapshots/test_sensor.ambr | 103 ++++++++++++++++++ .../components/fujitsu_fglair/test_climate.py | 19 +++- tests/components/fujitsu_fglair/test_init.py | 42 +------ .../components/fujitsu_fglair/test_sensor.py | 33 ++++++ 10 files changed, 272 insertions(+), 66 deletions(-) create mode 100644 homeassistant/components/fujitsu_fglair/entity.py create mode 100644 homeassistant/components/fujitsu_fglair/sensor.py create mode 100644 tests/components/fujitsu_fglair/snapshots/test_sensor.ambr create mode 100644 tests/components/fujitsu_fglair/test_sensor.py diff --git a/homeassistant/components/fujitsu_fglair/__init__.py b/homeassistant/components/fujitsu_fglair/__init__.py index f25e01bcd11..547545e4feb 100644 --- a/homeassistant/components/fujitsu_fglair/__init__.py +++ b/homeassistant/components/fujitsu_fglair/__init__.py @@ -15,7 +15,7 @@ from homeassistant.helpers import aiohttp_client from .const import API_TIMEOUT, CONF_EUROPE, CONF_REGION, REGION_DEFAULT, REGION_EU from .coordinator import FGLairCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] type FGLairConfigEntry = ConfigEntry[FGLairCoordinator] diff --git a/homeassistant/components/fujitsu_fglair/climate.py b/homeassistant/components/fujitsu_fglair/climate.py index 5359075c728..c0f5ab7dce4 100644 --- a/homeassistant/components/fujitsu_fglair/climate.py +++ b/homeassistant/components/fujitsu_fglair/climate.py @@ -25,13 +25,11 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import FGLairConfigEntry -from .const import DOMAIN from .coordinator import FGLairCoordinator +from .entity import FGLairEntity HA_TO_FUJI_FAN = { FAN_LOW: FanSpeed.LOW, @@ -72,28 +70,19 @@ async def async_setup_entry( ) -class FGLairDevice(CoordinatorEntity[FGLairCoordinator], ClimateEntity): +class FGLairDevice(FGLairEntity, ClimateEntity): """Represent a Fujitsu HVAC device.""" _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_precision = PRECISION_HALVES _attr_target_temperature_step = 0.5 - _attr_has_entity_name = True _attr_name = None def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None: """Store the representation of the device and set the static attributes.""" - super().__init__(coordinator, context=device.device_serial_number) + super().__init__(coordinator, device) self._attr_unique_id = device.device_serial_number - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.device_serial_number)}, - name=device.device_name, - manufacturer="Fujitsu", - model=device.property_values["model_name"], - serial_number=device.device_serial_number, - sw_version=device.property_values["mcu_firmware_version"], - ) self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE @@ -109,11 +98,6 @@ class FGLairDevice(CoordinatorEntity[FGLairCoordinator], ClimateEntity): self._attr_supported_features |= ClimateEntityFeature.SWING_MODE self._set_attr() - @property - def device(self) -> FujitsuHVAC: - """Return the device object from the coordinator data.""" - return self.coordinator.data[self.coordinator_context] - @property def available(self) -> bool: """Return if the device is available.""" diff --git a/homeassistant/components/fujitsu_fglair/entity.py b/homeassistant/components/fujitsu_fglair/entity.py new file mode 100644 index 00000000000..54d33d0e463 --- /dev/null +++ b/homeassistant/components/fujitsu_fglair/entity.py @@ -0,0 +1,33 @@ +"""Fujitsu FGlair base entity.""" + +from ayla_iot_unofficial.fujitsu_hvac import FujitsuHVAC + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import FGLairCoordinator + + +class FGLairEntity(CoordinatorEntity[FGLairCoordinator]): + """Generic Fglair entity (base class).""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None: + """Store the representation of the device.""" + super().__init__(coordinator, context=device.device_serial_number) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_serial_number)}, + name=device.device_name, + manufacturer="Fujitsu", + model=device.property_values["model_name"], + serial_number=device.device_serial_number, + sw_version=device.property_values["mcu_firmware_version"], + ) + + @property + def device(self) -> FujitsuHVAC: + """Return the device object from the coordinator data.""" + return self.coordinator.data[self.coordinator_context] diff --git a/homeassistant/components/fujitsu_fglair/sensor.py b/homeassistant/components/fujitsu_fglair/sensor.py new file mode 100644 index 00000000000..1426e2349ea --- /dev/null +++ b/homeassistant/components/fujitsu_fglair/sensor.py @@ -0,0 +1,47 @@ +"""Outside temperature sensor for Fujitsu FGlair HVAC systems.""" + +from ayla_iot_unofficial.fujitsu_hvac import FujitsuHVAC + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .climate import FGLairConfigEntry +from .coordinator import FGLairCoordinator +from .entity import FGLairEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FGLairConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up one Fujitsu HVAC device.""" + async_add_entities( + FGLairOutsideTemperature(entry.runtime_data, device) + for device in entry.runtime_data.data.values() + ) + + +class FGLairOutsideTemperature(FGLairEntity, SensorEntity): + """Entity representing outside temperature sensed by the outside unit of a Fujitsu Heatpump.""" + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_translation_key = "fglair_outside_temp" + + def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None: + """Store the representation of the device.""" + super().__init__(coordinator, device) + self._attr_unique_id = f"{device.device_serial_number}_outside_temperature" + + @property + def native_value(self) -> float | None: + """Return the sensed outdoor temperature un celsius.""" + return self.device.outdoor_temperature # type: ignore[no-any-return] diff --git a/homeassistant/components/fujitsu_fglair/strings.json b/homeassistant/components/fujitsu_fglair/strings.json index 3ad4e59ec1c..ea97ca416e5 100644 --- a/homeassistant/components/fujitsu_fglair/strings.json +++ b/homeassistant/components/fujitsu_fglair/strings.json @@ -35,5 +35,12 @@ "cn": "China" } } + }, + "entity": { + "sensor": { + "fglair_outside_temp": { + "name": "Outside temperature" + } + } } } diff --git a/tests/components/fujitsu_fglair/conftest.py b/tests/components/fujitsu_fglair/conftest.py index 5974adbeb0d..71a11557b44 100644 --- a/tests/components/fujitsu_fglair/conftest.py +++ b/tests/components/fujitsu_fglair/conftest.py @@ -1,6 +1,6 @@ """Common fixtures for the Fujitsu HVAC (based on Ayla IOT) tests.""" -from collections.abc import Generator +from collections.abc import Awaitable, Callable, Generator from unittest.mock import AsyncMock, create_autospec, patch from ayla_iot_unofficial import AylaApi @@ -12,7 +12,8 @@ from homeassistant.components.fujitsu_fglair.const import ( DOMAIN, REGION_DEFAULT, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -33,6 +34,12 @@ TEST_PROPERTY_VALUES = { } +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" @@ -78,6 +85,24 @@ def mock_config_entry(request: pytest.FixtureRequest) -> MockConfigEntry: ) +@pytest.fixture(name="integration_setup") +async def mock_integration_setup( + hass: HomeAssistant, + platforms: list[Platform], + mock_config_entry: MockConfigEntry, +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + mock_config_entry.add_to_hass(hass) + + async def run() -> bool: + with patch("homeassistant.components.fujitsu_fglair.PLATFORMS", platforms): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return result + + return run + + def _create_device(serial_number: str) -> AsyncMock: dev = AsyncMock(spec=FujitsuHVAC) dev.device_serial_number = serial_number @@ -109,6 +134,7 @@ def _create_device(serial_number: str) -> AsyncMock: dev.temperature_range = [18.0, 26.0] dev.sensed_temp = 22.0 dev.set_temp = 21.0 + dev.outdoor_temperature = 5.0 return dev diff --git a/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr b/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..89738cc4a66 --- /dev/null +++ b/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_entities[sensor.testserial123_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testserial123_outside_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': 'Outside temperature', + 'platform': 'fujitsu_fglair', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fglair_outside_temp', + 'unique_id': 'testserial123_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.testserial123_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'testserial123 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.testserial123_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_entities[sensor.testserial345_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testserial345_outside_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': 'Outside temperature', + 'platform': 'fujitsu_fglair', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fglair_outside_temp', + 'unique_id': 'testserial345_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.testserial345_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'testserial345 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.testserial345_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- diff --git a/tests/components/fujitsu_fglair/test_climate.py b/tests/components/fujitsu_fglair/test_climate.py index daddc83a871..676ff97f26a 100644 --- a/tests/components/fujitsu_fglair/test_climate.py +++ b/tests/components/fujitsu_fglair/test_climate.py @@ -1,7 +1,9 @@ """Test for the climate entities of Fujitsu HVAC.""" +from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock +import pytest from syrupy import SnapshotAssertion from homeassistant.components.climate import ( @@ -23,24 +25,32 @@ from homeassistant.components.fujitsu_fglair.climate import ( HA_TO_FUJI_HVAC, HA_TO_FUJI_SWING, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import entity_id, setup_integration +from . import entity_id from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.CLIMATE] + + async def test_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, mock_ayla_api: AsyncMock, mock_config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], ) -> None: """Test that coordinator returns the data we expect after the first refresh.""" - await setup_integration(hass, mock_config_entry) + assert await integration_setup() + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @@ -51,9 +61,10 @@ async def test_set_attributes( mock_ayla_api: AsyncMock, mock_devices: list[AsyncMock], mock_config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], ) -> None: """Test that setting the attributes calls the correct functions on the device.""" - await setup_integration(hass, mock_config_entry) + assert await integration_setup() await hass.services.async_call( CLIMATE_DOMAIN, diff --git a/tests/components/fujitsu_fglair/test_init.py b/tests/components/fujitsu_fglair/test_init.py index af51b222c19..d400d85c33a 100644 --- a/tests/components/fujitsu_fglair/test_init.py +++ b/tests/components/fujitsu_fglair/test_init.py @@ -17,14 +17,9 @@ from homeassistant.components.fujitsu_fglair.const import ( REGION_EU, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - CONF_PASSWORD, - CONF_USERNAME, - STATE_UNAVAILABLE, - Platform, -) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, entity_registry as er +from homeassistant.helpers import aiohttp_client from . import entity_id, setup_integration from .conftest import TEST_PASSWORD, TEST_USERNAME @@ -166,36 +161,3 @@ async def test_startup_exception( await setup_integration(hass, mock_config_entry) assert len(hass.states.async_all()) == 0 - - -async def test_one_device_disabled( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - freezer: FrozenDateTimeFactory, - mock_devices: list[AsyncMock], - mock_ayla_api: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test that coordinator only updates devices that are currently listening.""" - await setup_integration(hass, mock_config_entry) - - for d in mock_devices: - d.async_update.assert_called_once() - d.reset_mock() - - entity = entity_registry.async_get( - entity_registry.async_get_entity_id( - Platform.CLIMATE, DOMAIN, mock_devices[0].device_serial_number - ) - ) - entity_registry.async_update_entity( - entity.entity_id, disabled_by=er.RegistryEntryDisabler.USER - ) - await hass.async_block_till_done() - freezer.tick(API_REFRESH) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == len(mock_devices) - 1 - mock_devices[0].async_update.assert_not_called() - mock_devices[1].async_update.assert_called_once() diff --git a/tests/components/fujitsu_fglair/test_sensor.py b/tests/components/fujitsu_fglair/test_sensor.py new file mode 100644 index 00000000000..e3f6109a2e8 --- /dev/null +++ b/tests/components/fujitsu_fglair/test_sensor.py @@ -0,0 +1,33 @@ +"""Test for the sensor platform entity of the fujitsu_fglair component.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock + +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 tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR] + + +async def test_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_ayla_api: AsyncMock, + mock_config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test that coordinator returns the data we expect after the first refresh.""" + assert await integration_setup() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 15e785b974a29907f9ed0892a06e1adfa581d20d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jan 2025 11:22:08 +0100 Subject: [PATCH 0221/2987] Move OneWire PLATFORM constant back to init (#135172) --- homeassistant/components/onewire/__init__.py | 13 ++++++++++--- homeassistant/components/onewire/const.py | 8 -------- tests/components/onewire/test_binary_sensor.py | 2 +- tests/components/onewire/test_diagnostics.py | 2 +- tests/components/onewire/test_init.py | 2 +- tests/components/onewire/test_sensor.py | 2 +- tests/components/onewire/test_switch.py | 2 +- 7 files changed, 15 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index b144e12795e..17c772121d0 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -4,15 +4,22 @@ import logging from pyownet import protocol +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from .const import DOMAIN, PLATFORMS +from .const import DOMAIN from .onewirehub import CannotConnect, OneWireConfigEntry, OneWireHub _LOGGER = logging.getLogger(__name__) +_PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.SWITCH, +] + async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> bool: """Set up a 1-Wire proxy for a config entry.""" @@ -27,7 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> b entry.runtime_data = onewire_hub - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) entry.async_on_unload(entry.add_update_listener(options_update_listener)) @@ -48,7 +55,7 @@ async def async_unload_entry( hass: HomeAssistant, config_entry: OneWireConfigEntry ) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(config_entry, _PLATFORMS) async def options_update_listener( diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py index a4f3ebe9a78..2ab44c47892 100644 --- a/homeassistant/components/onewire/const.py +++ b/homeassistant/components/onewire/const.py @@ -2,8 +2,6 @@ from __future__ import annotations -from homeassistant.const import Platform - DEFAULT_HOST = "localhost" DEFAULT_PORT = 4304 @@ -54,9 +52,3 @@ MANUFACTURER_EDS = "Embedded Data Systems" READ_MODE_BOOL = "bool" READ_MODE_FLOAT = "float" READ_MODE_INT = "int" - -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.SENSOR, - Platform.SWITCH, -] diff --git a/tests/components/onewire/test_binary_sensor.py b/tests/components/onewire/test_binary_sensor.py index 162952c0216..532b3ffec3a 100644 --- a/tests/components/onewire/test_binary_sensor.py +++ b/tests/components/onewire/test_binary_sensor.py @@ -18,7 +18,7 @@ from tests.common import MockConfigEntry @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [Platform.BINARY_SENSOR]): + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.BINARY_SENSOR]): yield diff --git a/tests/components/onewire/test_diagnostics.py b/tests/components/onewire/test_diagnostics.py index c8427cc7126..ebbd8fdbf02 100644 --- a/tests/components/onewire/test_diagnostics.py +++ b/tests/components/onewire/test_diagnostics.py @@ -19,7 +19,7 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [Platform.SWITCH]): + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.SWITCH]): yield diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 633fbc09360..6027891f883 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -80,7 +80,7 @@ async def test_update_options( assert owproxy.call_count == 2 -@patch("homeassistant.components.onewire.PLATFORMS", [Platform.SENSOR]) +@patch("homeassistant.components.onewire._PLATFORMS", [Platform.SENSOR]) async def test_registry_cleanup( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index 11f8993242e..bce7ebf9191 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -22,7 +22,7 @@ from tests.common import MockConfigEntry @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [Platform.SENSOR]): + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.SENSOR]): yield diff --git a/tests/components/onewire/test_switch.py b/tests/components/onewire/test_switch.py index 34b7f320350..7df4beec427 100644 --- a/tests/components/onewire/test_switch.py +++ b/tests/components/onewire/test_switch.py @@ -25,7 +25,7 @@ from tests.common import MockConfigEntry @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [Platform.SWITCH]): + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.SWITCH]): yield From 0d9ac252577e15f2a9311dda5bc9861af26b4dcf Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 9 Jan 2025 10:28:10 +0000 Subject: [PATCH 0222/2987] Add and cleanup tplink translations (#135120) --- homeassistant/components/tplink/__init__.py | 33 +++++++++--- .../components/tplink/binary_sensor.py | 5 -- homeassistant/components/tplink/climate.py | 10 +++- homeassistant/components/tplink/entity.py | 24 ++++++++- homeassistant/components/tplink/icons.json | 6 --- homeassistant/components/tplink/sensor.py | 5 -- homeassistant/components/tplink/strings.json | 52 ++++--------------- .../tplink/snapshots/test_binary_sensor.ambr | 6 +-- .../tplink/snapshots/test_sensor.ambr | 12 ++--- tests/components/tplink/test_switch.py | 20 ++++++- 10 files changed, 97 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 13261ed752e..90f97e113ca 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import Iterable from datetime import timedelta import logging -from typing import Any +from typing import Any, cast from aiohttp import ClientSession from kasa import ( @@ -178,9 +178,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo if not credentials and entry_credentials_hash: data = {k: v for k, v in entry.data.items() if k != CONF_CREDENTIALS_HASH} hass.config_entries.async_update_entry(entry, data=data) - raise ConfigEntryAuthFailed from ex + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="device_authentication", + translation_placeholders={ + "func": "connect", + "exc": str(ex), + }, + ) from ex except KasaException as ex: - raise ConfigEntryNotReady from ex + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_error", + translation_placeholders={ + "func": "connect", + "exc": str(ex), + }, + ) from ex device_credentials_hash = device.credentials_hash @@ -212,7 +226,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo # wait for the next discovery to find the device at its new address # and update the config entry so we do not mix up devices. raise ConfigEntryNotReady( - f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}" + translation_domain=DOMAIN, + translation_key="unexpected_device", + translation_placeholders={ + "host": host, + # all entries have a unique id + "expected": cast(str, entry.unique_id), + "found": found_mac, + }, ) parent_coordinator = TPLinkDataUpdateCoordinator(hass, device, timedelta(seconds=5)) @@ -263,7 +284,7 @@ def legacy_device_id(device: Device) -> str: return device_id.split("_")[1] -def get_device_name(device: Device, parent: Device | None = None) -> str: +def get_device_name(device: Device, parent: Device | None = None) -> str | None: """Get a name for the device. alias can be none on some devices.""" if device.alias: return device.alias @@ -278,7 +299,7 @@ def get_device_name(device: Device, parent: Device | None = None) -> str: ] suffix = f" {devices.index(device.device_id) + 1}" if len(devices) > 1 else "" return f"{device.device_type.value.capitalize()}{suffix}" - return f"Unnamed {device.model}" + return None async def get_credentials(hass: HomeAssistant) -> Credentials | None: diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py index 318d0803e53..34f32ca3954 100644 --- a/homeassistant/components/tplink/binary_sensor.py +++ b/homeassistant/components/tplink/binary_sensor.py @@ -42,11 +42,6 @@ BINARY_SENSOR_DESCRIPTIONS: Final = ( key="cloud_connection", device_class=BinarySensorDeviceClass.CONNECTIVITY, ), - # To be replaced & disabled per default by the upcoming update platform. - TPLinkBinarySensorEntityDescription( - key="update_available", - device_class=BinarySensorDeviceClass.UPDATE, - ), TPLinkBinarySensorEntityDescription( key="temperature_warning", ), diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py index cef9a732cfd..e8b7336f391 100644 --- a/homeassistant/components/tplink/climate.py +++ b/homeassistant/components/tplink/climate.py @@ -21,7 +21,7 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry -from .const import UNIT_MAPPING +from .const import DOMAIN, UNIT_MAPPING from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity, async_refresh_after @@ -104,7 +104,13 @@ class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity): elif hvac_mode is HVACMode.OFF: await self._state_feature.set_value(False) else: - raise ServiceValidationError(f"Tried to set unsupported mode: {hvac_mode}") + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_mode", + translation_placeholders={ + "mode": hvac_mode, + }, + ) @async_refresh_after async def async_turn_on(self) -> None: diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 935857e5db1..01342339bef 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -162,6 +162,9 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB registry_device = device device_name = get_device_name(device, parent=parent) + translation_key: str | None = None + translation_placeholders: Mapping[str, str] | None = None + if parent and parent.device_type is not Device.Type.Hub: if not feature or feature.id == PRIMARY_STATE_ID: # Entity will be added to parent if not a hub and no feature @@ -169,6 +172,9 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB # is the primary state registry_device = parent device_name = get_device_name(registry_device) + if not device_name: + translation_key = "unnamed_device" + translation_placeholders = {"model": parent.model} else: # Prefix the device name with the parent name unless it is a # hub attached device. Sensible default for child devices like @@ -177,13 +183,28 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB # Bedroom Ceiling Fan; Child device aliases will be Ceiling Fan # and Dimmer Switch for both so should be distinguished by the # parent name. - device_name = f"{get_device_name(parent)} {get_device_name(device, parent=parent)}" + parent_device_name = get_device_name(parent) + child_device_name = get_device_name(device, parent=parent) + if parent_device_name: + device_name = f"{parent_device_name} {child_device_name}" + else: + device_name = None + translation_key = "unnamed_device" + translation_placeholders = { + "model": f"{parent.model} {child_device_name}" + } + + if device_name is None and not translation_key: + translation_key = "unnamed_device" + translation_placeholders = {"model": device.model} self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, str(registry_device.device_id))}, manufacturer="TP-Link", model=registry_device.model, name=device_name, + translation_key=translation_key, + translation_placeholders=translation_placeholders, sw_version=registry_device.hw_info["sw_ver"], hw_version=registry_device.hw_info["hw_ver"], ) @@ -320,6 +341,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): if descriptions and (desc := descriptions.get(feature.id)): translation_key: str | None = feature.id + # HA logic is to name entities based on the following logic: # _attr_name > translation.name > description.name # > device_class (if base platform supports). diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json index 9cc0326b59f..aedbccfbd51 100644 --- a/homeassistant/components/tplink/icons.json +++ b/homeassistant/components/tplink/icons.json @@ -125,12 +125,6 @@ "signal_level": { "default": "mdi:signal" }, - "current_firmware_version": { - "default": "mdi:information" - }, - "available_firmware_version": { - "default": "mdi:information-outline" - }, "alarm_source": { "default": "mdi:bell" }, diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index e18a849ccd6..59e29d7a010 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -116,11 +116,6 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="alarm_source", ), - TPLinkSensorEntityDescription( - key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), ) SENSOR_DESCRIPTIONS_MAP = {desc.key: desc for desc in SENSOR_DESCRIPTIONS} diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 9cf302ed717..9c32dd5bbf4 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -109,26 +109,9 @@ "overheated": { "name": "Overheated" }, - "battery_low": { - "name": "Battery low" - }, "cloud_connection": { "name": "Cloud connection" }, - "update_available": { - "name": "[%key:component::binary_sensor::entity_component::update::name%]", - "state": { - "off": "[%key:component::binary_sensor::entity_component::update::state::off%]", - "on": "[%key:component::binary_sensor::entity_component::update::state::on%]" - } - }, - "is_open": { - "name": "[%key:component::binary_sensor::entity_component::door::name%]", - "state": { - "off": "[%key:common::state::closed%]", - "on": "[%key:common::state::open%]" - } - }, "water_alert": { "name": "[%key:component::binary_sensor::entity_component::moisture::name%]", "state": { @@ -195,27 +178,6 @@ "signal_level": { "name": "Signal level" }, - "current_firmware_version": { - "name": "Current firmware version" - }, - "available_firmware_version": { - "name": "Available firmware version" - }, - "battery_level": { - "name": "Battery level" - }, - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, - "voltage": { - "name": "[%key:component::sensor::entity_component::voltage::name%]" - }, - "current": { - "name": "[%key:component::sensor::entity_component::current::name%]" - }, - "humidity": { - "name": "[%key:component::sensor::entity_component::humidity::name%]" - }, "device_time": { "name": "Device time" }, @@ -230,9 +192,6 @@ }, "alarm_source": { "name": "Alarm source" - }, - "rssi": { - "name": "[%key:component::sensor::entity_component::signal_strength::name%]" } }, "switch": { @@ -291,6 +250,11 @@ } } }, + "device": { + "unnamed_device": { + "name": "Unnamed {model}" + } + }, "services": { "sequence_effect": { "name": "Sequence effect", @@ -397,6 +361,12 @@ }, "set_custom_effect": { "message": "Error trying to set custom effect {effect}: {exc}" + }, + "unexpected_device": { + "message": "Unexpected device found at {host}; expected {expected}, found {found}" + }, + "unsupported_mode": { + "message": "Tried to set unsupported mode: {mode}" } }, "issues": { diff --git a/tests/components/tplink/snapshots/test_binary_sensor.ambr b/tests/components/tplink/snapshots/test_binary_sensor.ambr index 4a1cfe5b411..e16d4409511 100644 --- a/tests/components/tplink/snapshots/test_binary_sensor.ambr +++ b/tests/components/tplink/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_states[binary_sensor.my_device_battery_low-entry] +# name: test_states[binary_sensor.my_device_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,7 +11,7 @@ 'disabled_by': , 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.my_device_battery_low', + 'entity_id': 'binary_sensor.my_device_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -23,7 +23,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery low', + 'original_name': 'Battery', 'platform': 'tplink', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 739f02e51f0..461e8c6e505 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -115,7 +115,7 @@ 'state': '2024-06-24T09:03:11+00:00', }) # --- -# name: test_states[sensor.my_device_battery_level-entry] +# name: test_states[sensor.my_device_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -129,7 +129,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.my_device_battery_level', + 'entity_id': 'sensor.my_device_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -141,7 +141,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery level', + 'original_name': 'Battery', 'platform': 'tplink', 'previous_unique_id': None, 'supported_features': 0, @@ -150,16 +150,16 @@ 'unit_of_measurement': '%', }) # --- -# name: test_states[sensor.my_device_battery_level-state] +# name: test_states[sensor.my_device_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'my_device Battery level', + 'friendly_name': 'my_device Battery', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.my_device_battery_level', + 'entity_id': 'sensor.my_device_battery', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/tplink/test_switch.py b/tests/components/tplink/test_switch.py index e9c8cc07b67..47b2e078f5a 100644 --- a/tests/components/tplink/test_switch.py +++ b/tests/components/tplink/test_switch.py @@ -260,7 +260,9 @@ async def test_strip_unique_ids( async def test_strip_blank_alias( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test a strip unique id.""" already_migrated_config_entry = MockConfigEntry( @@ -277,11 +279,27 @@ async def test_strip_blank_alias( await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() + strip_entity_id = "switch.unnamed_ks123" + state = hass.states.get(strip_entity_id) + assert state.name == "Unnamed KS123" + reg_ent = entity_registry.async_get(strip_entity_id) + assert reg_ent + reg_dev = device_registry.async_get(reg_ent.device_id) + assert reg_dev + assert reg_dev.name == "Unnamed KS123" + for plug_id in range(2): entity_id = f"switch.unnamed_ks123_stripsocket_{plug_id + 1}" state = hass.states.get(entity_id) assert state.name == f"Unnamed KS123 Stripsocket {plug_id + 1}" + reg_ent = entity_registry.async_get(entity_id) + assert reg_ent + reg_dev = device_registry.async_get(reg_ent.device_id) + assert reg_dev + # Switch is a primary feature so entities go on the parent device. + assert reg_dev.name == "Unnamed KS123" + @pytest.mark.parametrize( ("exception_type", "msg", "reauth_expected"), From 3ce4c47cfc96d68752004eb1cd09e845eea3ea3d Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Thu, 9 Jan 2025 11:28:28 +0100 Subject: [PATCH 0223/2987] Add uuid as unique_id to config entries for Cookidoo (#134831) --- homeassistant/components/cookidoo/__init__.py | 87 +++++-- homeassistant/components/cookidoo/button.py | 3 +- .../components/cookidoo/config_flow.py | 36 ++- homeassistant/components/cookidoo/entity.py | 4 +- homeassistant/components/cookidoo/helpers.py | 37 +++ .../components/cookidoo/strings.json | 3 +- homeassistant/components/cookidoo/todo.py | 6 +- tests/components/cookidoo/conftest.py | 23 +- tests/components/cookidoo/fixtures/login.json | 7 + .../cookidoo/snapshots/test_button.ambr | 2 +- .../cookidoo/snapshots/test_todo.ambr | 4 +- tests/components/cookidoo/test_config_flow.py | 70 ++++-- tests/components/cookidoo/test_init.py | 235 ++++++++++++++++++ 13 files changed, 426 insertions(+), 91 deletions(-) create mode 100644 homeassistant/components/cookidoo/helpers.py create mode 100644 tests/components/cookidoo/fixtures/login.json diff --git a/homeassistant/components/cookidoo/__init__.py b/homeassistant/components/cookidoo/__init__.py index 48c37c64db0..67095422e65 100644 --- a/homeassistant/components/cookidoo/__init__.py +++ b/homeassistant/components/cookidoo/__init__.py @@ -2,41 +2,29 @@ from __future__ import annotations -from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options +import logging -from homeassistant.const import ( - CONF_COUNTRY, - CONF_EMAIL, - CONF_LANGUAGE, - CONF_PASSWORD, - Platform, -) +from cookidoo_api import CookidooAuthException, CookidooRequestException + +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import device_registry as dr, entity_registry as er +from .const import DOMAIN from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator +from .helpers import cookidoo_from_config_entry PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.TODO] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool: """Set up Cookidoo from a config entry.""" - localizations = await get_localization_options( - country=entry.data[CONF_COUNTRY].lower(), - language=entry.data[CONF_LANGUAGE], + coordinator = CookidooDataUpdateCoordinator( + hass, await cookidoo_from_config_entry(hass, entry), entry ) - - cookidoo = Cookidoo( - async_get_clientsession(hass), - CookidooConfig( - email=entry.data[CONF_EMAIL], - password=entry.data[CONF_PASSWORD], - localization=localizations[0], - ), - ) - - coordinator = CookidooDataUpdateCoordinator(hass, cookidoo, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -49,3 +37,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: CookidooConfigEntry +) -> bool: + """Migrate config entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1 and config_entry.minor_version == 1: + # Add the unique uuid + cookidoo = await cookidoo_from_config_entry(hass, config_entry) + + try: + auth_data = await cookidoo.login() + except (CookidooRequestException, CookidooAuthException) as e: + _LOGGER.error( + "Could not migrate config config_entry: %s", + str(e), + ) + return False + + unique_id = auth_data.sub + + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_id=config_entry.entry_id + ) + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry_id=config_entry.entry_id + ) + for dev in device_entries: + device_registry.async_update_device( + dev.id, new_identifiers={(DOMAIN, unique_id)} + ) + for ent in entity_entries: + assert ent.config_entry_id + entity_registry.async_update_entity( + ent.entity_id, + new_unique_id=ent.unique_id.replace(ent.config_entry_id, unique_id), + ) + + hass.config_entries.async_update_entry( + config_entry, unique_id=auth_data.sub, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/cookidoo/button.py b/homeassistant/components/cookidoo/button.py index 2a20a156db4..b292a7309ba 100644 --- a/homeassistant/components/cookidoo/button.py +++ b/homeassistant/components/cookidoo/button.py @@ -56,7 +56,8 @@ class CookidooButton(CookidooBaseEntity, ButtonEntity): """Initialize cookidoo button.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + assert coordinator.config_entry.unique_id + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/cookidoo/config_flow.py b/homeassistant/components/cookidoo/config_flow.py index 80487ed757f..71ad3015730 100644 --- a/homeassistant/components/cookidoo/config_flow.py +++ b/homeassistant/components/cookidoo/config_flow.py @@ -7,9 +7,7 @@ import logging from typing import Any from cookidoo_api import ( - Cookidoo, CookidooAuthException, - CookidooConfig, CookidooRequestException, get_country_options, get_localization_options, @@ -23,7 +21,6 @@ from homeassistant.config_entries import ( ConfigFlowResult, ) from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( CountrySelector, CountrySelectorConfig, @@ -35,6 +32,7 @@ from homeassistant.helpers.selector import ( ) from .const import DOMAIN +from .helpers import cookidoo_from_config_data _LOGGER = logging.getLogger(__name__) @@ -57,10 +55,14 @@ AUTH_DATA_SCHEMA = { class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Cookidoo.""" + VERSION = 1 + MINOR_VERSION = 2 + COUNTRY_DATA_SCHEMA: dict LANGUAGE_DATA_SCHEMA: dict user_input: dict[str, Any] + user_uuid: str async def async_step_reconfigure( self, user_input: dict[str, Any] @@ -78,8 +80,11 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None and not ( errors := await self.validate_input(user_input) ): + await self.async_set_unique_id(self.user_uuid) if self.source == SOURCE_USER: - self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]}) + self._abort_if_unique_id_configured() + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() self.user_input = user_input return await self.async_step_language() await self.generate_country_schema() @@ -153,10 +158,8 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): if not ( errors := await self.validate_input({**reauth_entry.data, **user_input}) ): - if user_input[CONF_EMAIL] != reauth_entry.data[CONF_EMAIL]: - self._async_abort_entries_match( - {CONF_EMAIL: user_input[CONF_EMAIL]} - ) + await self.async_set_unique_id(self.user_uuid) + self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( reauth_entry, data_updates=user_input ) @@ -220,21 +223,10 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): await get_localization_options(country=data_input[CONF_COUNTRY].lower()) )[0].language # Pick any language to test login - localizations = await get_localization_options( - country=data_input[CONF_COUNTRY].lower(), - language=data_input[CONF_LANGUAGE], - ) - - cookidoo = Cookidoo( - async_get_clientsession(self.hass), - CookidooConfig( - email=data_input[CONF_EMAIL], - password=data_input[CONF_PASSWORD], - localization=localizations[0], - ), - ) + cookidoo = await cookidoo_from_config_data(self.hass, data_input) try: - await cookidoo.login() + auth_data = await cookidoo.login() + self.user_uuid = auth_data.sub if language_input: await cookidoo.get_additional_items() except CookidooRequestException: diff --git a/homeassistant/components/cookidoo/entity.py b/homeassistant/components/cookidoo/entity.py index 5c8f3ec8441..97ebb384ecb 100644 --- a/homeassistant/components/cookidoo/entity.py +++ b/homeassistant/components/cookidoo/entity.py @@ -21,10 +21,12 @@ class CookidooBaseEntity(CoordinatorEntity[CookidooDataUpdateCoordinator]): """Initialize the entity.""" super().__init__(coordinator) + assert coordinator.config_entry.unique_id + self.device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, name="Cookidoo", - identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, manufacturer="Vorwerk International & Co. KmG", model="Cookidoo - Thermomix® recipe portal", ) diff --git a/homeassistant/components/cookidoo/helpers.py b/homeassistant/components/cookidoo/helpers.py new file mode 100644 index 00000000000..199abb2e05d --- /dev/null +++ b/homeassistant/components/cookidoo/helpers.py @@ -0,0 +1,37 @@ +"""Helpers for cookidoo.""" + +from typing import Any + +from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options + +from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import CookidooConfigEntry + + +async def cookidoo_from_config_data( + hass: HomeAssistant, data: dict[str, Any] +) -> Cookidoo: + """Build cookidoo from config data.""" + localizations = await get_localization_options( + country=data[CONF_COUNTRY].lower(), + language=data[CONF_LANGUAGE], + ) + + return Cookidoo( + async_get_clientsession(hass), + CookidooConfig( + email=data[CONF_EMAIL], + password=data[CONF_PASSWORD], + localization=localizations[0], + ), + ) + + +async def cookidoo_from_config_entry( + hass: HomeAssistant, entry: CookidooConfigEntry +) -> Cookidoo: + """Build cookidoo from config entry.""" + return await cookidoo_from_config_data(hass, dict(entry.data)) diff --git a/homeassistant/components/cookidoo/strings.json b/homeassistant/components/cookidoo/strings.json index 83cc182be16..3f786fe937a 100644 --- a/homeassistant/components/cookidoo/strings.json +++ b/homeassistant/components/cookidoo/strings.json @@ -44,7 +44,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The user identifier does not match the previous identifier" } }, "entity": { diff --git a/homeassistant/components/cookidoo/todo.py b/homeassistant/components/cookidoo/todo.py index 4a70dadc65a..3d5264f4e01 100644 --- a/homeassistant/components/cookidoo/todo.py +++ b/homeassistant/components/cookidoo/todo.py @@ -52,7 +52,8 @@ class CookidooIngredientsTodoListEntity(CookidooBaseEntity, TodoListEntity): def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None: """Initialize the entity.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_ingredients" + assert coordinator.config_entry.unique_id + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_ingredients" @property def todo_items(self) -> list[TodoItem]: @@ -112,7 +113,8 @@ class CookidooAdditionalItemTodoListEntity(CookidooBaseEntity, TodoListEntity): def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None: """Initialize the entity.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_additional_items" + assert coordinator.config_entry.unique_id + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_additional_items" @property def todo_items(self) -> list[TodoItem]: diff --git a/tests/components/cookidoo/conftest.py b/tests/components/cookidoo/conftest.py index 66c2064eb3a..a14bc285379 100644 --- a/tests/components/cookidoo/conftest.py +++ b/tests/components/cookidoo/conftest.py @@ -21,6 +21,8 @@ PASSWORD = "test-password" COUNTRY = "CH" LANGUAGE = "de-CH" +TEST_UUID = "sub_uuid" + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -34,16 +36,10 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture def mock_cookidoo_client() -> Generator[AsyncMock]: """Mock a Cookidoo client.""" - with ( - patch( - "homeassistant.components.cookidoo.Cookidoo", - autospec=True, - ) as mock_client, - patch( - "homeassistant.components.cookidoo.config_flow.Cookidoo", - new=mock_client, - ), - ): + with patch( + "homeassistant.components.cookidoo.helpers.Cookidoo", + autospec=True, + ) as mock_client: client = mock_client.return_value client.login.return_value = cast(CookidooAuthResponse, {"name": "Cookidoo"}) client.get_ingredient_items.return_value = [ @@ -58,7 +54,9 @@ def mock_cookidoo_client() -> Generator[AsyncMock]: "data" ] ] - client.login.return_value = None + client.login.return_value = CookidooAuthResponse( + **load_json_object_fixture("login.json", DOMAIN) + ) yield client @@ -67,6 +65,8 @@ def mock_cookidoo_config_entry() -> MockConfigEntry: """Mock cookidoo configuration entry.""" return MockConfigEntry( domain=DOMAIN, + version=1, + minor_version=2, data={ CONF_EMAIL: EMAIL, CONF_PASSWORD: PASSWORD, @@ -74,4 +74,5 @@ def mock_cookidoo_config_entry() -> MockConfigEntry: CONF_LANGUAGE: LANGUAGE, }, entry_id="01JBVVVJ87F6G5V0QJX6HBC94T", + unique_id=TEST_UUID, ) diff --git a/tests/components/cookidoo/fixtures/login.json b/tests/components/cookidoo/fixtures/login.json new file mode 100644 index 00000000000..e7bd6e8716c --- /dev/null +++ b/tests/components/cookidoo/fixtures/login.json @@ -0,0 +1,7 @@ +{ + "access_token": "eyJhbGci", + "expires_in": 43199, + "refresh_token": "eyJhbGciOiJSUzI1NiI", + "token_type": "bearer", + "sub": "sub_uuid" +} diff --git a/tests/components/cookidoo/snapshots/test_button.ambr b/tests/components/cookidoo/snapshots/test_button.ambr index 60f9e95bee7..a6223059aa1 100644 --- a/tests/components/cookidoo/snapshots/test_button.ambr +++ b/tests/components/cookidoo/snapshots/test_button.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'todo_clear', - 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_todo_clear', + 'unique_id': 'sub_uuid_todo_clear', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/cookidoo/snapshots/test_todo.ambr b/tests/components/cookidoo/snapshots/test_todo.ambr index 965cbb0adde..be641432929 100644 --- a/tests/components/cookidoo/snapshots/test_todo.ambr +++ b/tests/components/cookidoo/snapshots/test_todo.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'additional_item_list', - 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_additional_items', + 'unique_id': 'sub_uuid_additional_items', 'unit_of_measurement': None, }) # --- @@ -75,7 +75,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'ingredient_list', - 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_ingredients', + 'unique_id': 'sub_uuid_ingredients', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/cookidoo/test_config_flow.py b/tests/components/cookidoo/test_config_flow.py index 0057bb3767e..069442517a0 100644 --- a/tests/components/cookidoo/test_config_flow.py +++ b/tests/components/cookidoo/test_config_flow.py @@ -200,7 +200,12 @@ async def test_flow_reconfigure_success( result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={**MOCK_DATA_USER_STEP, CONF_COUNTRY: "DE"}, + user_input={ + **MOCK_DATA_USER_STEP, + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", + CONF_COUNTRY: "DE", + }, ) assert result["type"] is FlowResultType.FORM @@ -215,6 +220,8 @@ async def test_flow_reconfigure_success( assert result["reason"] == "reconfigure_successful" assert cookidoo_config_entry.data == { **MOCK_DATA_USER_STEP, + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", CONF_COUNTRY: "DE", CONF_LANGUAGE: "de-DE", } @@ -340,6 +347,35 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_2( assert len(hass.config_entries.async_entries()) == 1 +async def test_flow_reconfigure_id_mismatch( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test we abort when the new config is not for the same user.""" + + cookidoo_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + cookidoo_config_entry, unique_id="some_other_uuid" + ) + + result = await cookidoo_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + **MOCK_DATA_USER_STEP, + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", + CONF_COUNTRY: "DE", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + async def test_flow_reauth( hass: HomeAssistant, mock_cookidoo_client: AsyncMock, @@ -419,46 +455,26 @@ async def test_flow_reauth_error_and_recover( assert len(hass.config_entries.async_entries()) == 1 -@pytest.mark.parametrize( - ("new_email", "saved_email", "result_reason"), - [ - (EMAIL, EMAIL, "reauth_successful"), - ("another-email", EMAIL, "already_configured"), - ], -) -async def test_flow_reauth_init_data_already_configured( +async def test_flow_reauth_id_mismatch( hass: HomeAssistant, mock_cookidoo_client: AsyncMock, cookidoo_config_entry: MockConfigEntry, - new_email: str, - saved_email: str, - result_reason: str, ) -> None: - """Test we abort user data set when entry is already configured.""" + """Test we abort when the new auth is not for the same user.""" cookidoo_config_entry.add_to_hass(hass) - - another_cookidoo_config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_EMAIL: "another-email", - CONF_PASSWORD: PASSWORD, - CONF_COUNTRY: COUNTRY, - CONF_LANGUAGE: LANGUAGE, - }, + hass.config_entries.async_update_entry( + cookidoo_config_entry, unique_id="some_other_uuid" ) - another_cookidoo_config_entry.add_to_hass(hass) - result = await cookidoo_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_EMAIL: new_email, CONF_PASSWORD: PASSWORD}, + {CONF_EMAIL: "new-email", CONF_PASSWORD: PASSWORD}, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == result_reason - assert cookidoo_config_entry.data[CONF_EMAIL] == saved_email + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/components/cookidoo/test_init.py b/tests/components/cookidoo/test_init.py index b1b9b880526..e97bf93bb21 100644 --- a/tests/components/cookidoo/test_init.py +++ b/tests/components/cookidoo/test_init.py @@ -7,9 +7,18 @@ import pytest from homeassistant.components.cookidoo.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_COUNTRY, + CONF_EMAIL, + CONF_LANGUAGE, + CONF_PASSWORD, + Platform, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration +from .conftest import COUNTRY, EMAIL, LANGUAGE, PASSWORD, TEST_UUID from tests.common import MockConfigEntry @@ -100,3 +109,229 @@ async def test_config_entry_not_ready_auth_error( await hass.async_block_till_done() assert cookidoo_config_entry.state is status + + +MOCK_CONFIG_ENTRY_MIGRATION = { + CONF_EMAIL: EMAIL, + CONF_PASSWORD: PASSWORD, + CONF_COUNTRY: COUNTRY, + CONF_LANGUAGE: LANGUAGE, +} + +OLD_ENTRY_ID = "OLD_OLD_ENTRY_ID" + + +@pytest.mark.parametrize( + ( + "from_version", + "from_minor_version", + "config_data", + "unique_id", + ), + [ + ( + 1, + 1, + MOCK_CONFIG_ENTRY_MIGRATION, + None, + ), + (1, 2, MOCK_CONFIG_ENTRY_MIGRATION, TEST_UUID), + ], +) +async def test_migration_from( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + from_version, + from_minor_version, + config_data, + unique_id, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test different expected migration paths.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=config_data, + title=f"MIGRATION_TEST from {from_version}.{from_minor_version}", + version=from_version, + minor_version=from_minor_version, + unique_id=unique_id, + entry_id=OLD_ENTRY_ID, + ) + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, OLD_ENTRY_ID)}, + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_ingredients", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_additional_items", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="button", + unique_id=f"{OLD_ENTRY_ID}_todo_clear", + device_id=device.id, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.LOADED + + # Check change in config entry and verify most recent version + assert config_entry.version == 1 + assert config_entry.minor_version == 2 + assert config_entry.unique_id == TEST_UUID + + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{TEST_UUID}_ingredients", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{TEST_UUID}_additional_items", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.BUTTON, + DOMAIN, + f"{TEST_UUID}_todo_clear", + ) + ) + ) + + +@pytest.mark.parametrize( + ( + "from_version", + "from_minor_version", + "config_data", + "unique_id", + "login_exception", + ), + [ + ( + 1, + 1, + MOCK_CONFIG_ENTRY_MIGRATION, + None, + CookidooRequestException, + ), + ( + 1, + 1, + MOCK_CONFIG_ENTRY_MIGRATION, + None, + CookidooAuthException, + ), + ], +) +async def test_migration_from_with_error( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + from_version, + from_minor_version, + config_data, + unique_id, + login_exception: Exception, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test different expected migration paths but with connection issues.""" + # Migration can fail due to connection issues as we have to fetch the uuid + mock_cookidoo_client.login.side_effect = login_exception + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=config_data, + title=f"MIGRATION_TEST from {from_version}.{from_minor_version} with login exception '{login_exception}'", + version=from_version, + minor_version=from_minor_version, + unique_id=unique_id, + entry_id=OLD_ENTRY_ID, + ) + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, OLD_ENTRY_ID)}, + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_ingredients", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_additional_items", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="button", + unique_id=f"{OLD_ENTRY_ID}_todo_clear", + device_id=device.id, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR + + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{OLD_ENTRY_ID}_ingredients", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{OLD_ENTRY_ID}_additional_items", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.BUTTON, + DOMAIN, + f"{OLD_ENTRY_ID}_todo_clear", + ) + ) + ) From 8bfdbc173ad3ebe5064e17fda3f76e1e1e864c92 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jan 2025 11:45:29 +0100 Subject: [PATCH 0224/2987] Use snapshot_platform helper in onewire tests (#135176) * Use snapshot_platform helper in onewire tests * Snapshot device registry --- .../onewire/snapshots/test_binary_sensor.ambr | 2276 ++----- .../onewire/snapshots/test_init.ambr | 673 ++ .../onewire/snapshots/test_sensor.ambr | 5914 +++++++---------- .../onewire/snapshots/test_switch.ambr | 4338 +++++------- .../components/onewire/test_binary_sensor.py | 34 +- tests/components/onewire/test_init.py | 22 + tests/components/onewire/test_sensor.py | 33 +- tests/components/onewire/test_switch.py | 34 +- 8 files changed, 5731 insertions(+), 7593 deletions(-) create mode 100644 tests/components/onewire/snapshots/test_init.ambr diff --git a/tests/components/onewire/snapshots/test_binary_sensor.ambr b/tests/components/onewire/snapshots/test_binary_sensor.ambr index 450cc4c7486..d94eda5b7c3 100644 --- a/tests/components/onewire/snapshots/test_binary_sensor.ambr +++ b/tests/components/onewire/snapshots/test_binary_sensor.ambr @@ -1,1645 +1,773 @@ # serializer version: 1 -# name: test_binary_sensors[00.111111111111] - list([ - ]) -# --- -# name: test_binary_sensors[00.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[00.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[05.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '05.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2405', - 'model_id': None, - 'name': '05.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.12_111111111111_sensed_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_binary_sensors[05.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[05.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[10.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '10.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18S20', - 'model_id': None, - 'name': '10.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.12_111111111111_sensed_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_binary_sensors[10.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[10.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[12.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '12.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2406', - 'model_id': None, - 'name': '12.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/12.111111111111/sensed.A', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[12.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.12_111111111111_sensed_a', - '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': 'Sensed A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/12.111111111111/sensed.A', - 'unit_of_measurement': None, +# name: test_binary_sensors[binary_sensor.12_111111111111_sensed_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/sensed.A', + 'friendly_name': '12.111111111111 Sensed A', + 'raw_value': 1.0, }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.12_111111111111_sensed_b', - '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': 'Sensed B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/12.111111111111/sensed.B', - 'unit_of_measurement': None, + 'context': , + 'entity_id': 'binary_sensor.12_111111111111_sensed_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.12_111111111111_sensed_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_binary_sensors[12.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/sensed.A', - 'friendly_name': '12.111111111111 Sensed A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.12_111111111111_sensed_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.12_111111111111_sensed_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/sensed.B', - 'friendly_name': '12.111111111111 Sensed B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.12_111111111111_sensed_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/12.111111111111/sensed.B', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[16.111111111111] - list([ - ]) -# --- -# name: test_binary_sensors[16.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[16.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[1D.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.12_111111111111_sensed_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/sensed.B', + 'friendly_name': '12.111111111111 Sensed B', + 'raw_value': 0.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.12_111111111111_sensed_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[1D.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[1D.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[1F.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1F.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2409', - 'model_id': None, - 'name': '1F.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': , + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_binary_sensors[1F.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[1F.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[22.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '22.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1822', - 'model_id': None, - 'name': '22.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.0', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[22.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[22.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[26.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '26.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': '26.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.0', + 'friendly_name': '29.111111111111 Sensed 0', + 'raw_value': 1.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) # --- -# name: test_binary_sensors[26.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[26.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[28.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_binary_sensors[28.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[28.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[28.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_1', + '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': 'Sensed 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.1', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[28.222222222222].1 - list([ - ]) +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.1', + 'friendly_name': '29.111111111111 Sensed 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[28.222222222222].2 - list([ - ]) +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_2', + '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': 'Sensed 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.2', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[28.222222222223] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222223', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222223', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.2', + 'friendly_name': '29.111111111111 Sensed 2', + 'raw_value': 0.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[28.222222222223].1 - list([ - ]) +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_3', + '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': 'Sensed 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.3', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[28.222222222223].2 - list([ - ]) +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.3', + 'friendly_name': '29.111111111111 Sensed 3', + 'raw_value': None, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_binary_sensors[29.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '29.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2408', - 'model_id': None, - 'name': '29.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_4', + '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': 'Sensed 4', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.4', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[29.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_0', - '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': 'Sensed 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.0', - 'unit_of_measurement': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.4', + 'friendly_name': '29.111111111111 Sensed 4', + 'raw_value': 0.0, }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_1', - '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': 'Sensed 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_2', - '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': 'Sensed 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_3', - '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': 'Sensed 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.3', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_4', - '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': 'Sensed 4', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.4', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_5', - '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': 'Sensed 5', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.5', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_6', - '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': 'Sensed 6', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.6', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_7', - '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': 'Sensed 7', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.7', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[29.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.0', - 'friendly_name': '29.111111111111 Sensed 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.1', - 'friendly_name': '29.111111111111 Sensed 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.2', - 'friendly_name': '29.111111111111 Sensed 2', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.3', - 'friendly_name': '29.111111111111 Sensed 3', - 'raw_value': None, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 5', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.5', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.5', + 'friendly_name': '29.111111111111 Sensed 5', + 'raw_value': 0.0, }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.4', - 'friendly_name': '29.111111111111 Sensed 4', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.5', - 'friendly_name': '29.111111111111 Sensed 5', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.6', - 'friendly_name': '29.111111111111 Sensed 6', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_6', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.7', - 'friendly_name': '29.111111111111 Sensed 7', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_7', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 6', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.6', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.6', + 'friendly_name': '29.111111111111 Sensed 6', + 'raw_value': 0.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[30.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '30.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2760', - 'model_id': None, - 'name': '30.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_binary_sensors[30.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[30.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[3A.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3A.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2413', - 'model_id': None, - 'name': '3A.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_binary_sensors[3A.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', - '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': 'Sensed A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/3A.111111111111/sensed.A', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', - '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': 'Sensed B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/3A.111111111111/sensed.B', - 'unit_of_measurement': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 7', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.7', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.7', + 'friendly_name': '29.111111111111 Sensed 7', + 'raw_value': 0.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[3A.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/3A.111111111111/sensed.A', - 'friendly_name': '3A.111111111111 Sensed A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', +# name: test_binary_sensors[binary_sensor.3a_111111111111_sensed_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/3A.111111111111/sensed.B', - 'friendly_name': '3A.111111111111 Sensed B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_binary_sensors[3B.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3B.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1825', - 'model_id': None, - 'name': '3B.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/3A.111111111111/sensed.A', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[3B.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[3B.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[42.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '42.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS28EA00', - 'model_id': None, - 'name': '42.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.3a_111111111111_sensed_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/sensed.A', + 'friendly_name': '3A.111111111111 Sensed A', + 'raw_value': 1.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) # --- -# name: test_binary_sensors[42.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[42.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[7E.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0068', - 'model_id': None, - 'name': '7E.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.3a_111111111111_sensed_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_binary_sensors[7E.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[7E.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[7E.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0066', - 'model_id': None, - 'name': '7E.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_binary_sensors[7E.222222222222].1 - list([ - ]) -# --- -# name: test_binary_sensors[7E.222222222222].2 - list([ - ]) -# --- -# name: test_binary_sensors[A6.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'A6.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': 'A6.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/3A.111111111111/sensed.B', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[A6.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[A6.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[EF.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HobbyBoards_EF', - 'model_id': None, - 'name': 'EF.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.3a_111111111111_sensed_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/sensed.B', + 'friendly_name': '3A.111111111111 Sensed B', + 'raw_value': 0.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[EF.111111111111].1 - list([ - ]) +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hub short on branch 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_short_id', + 'unique_id': '/EF.111111111113/hub/short.0', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[EF.111111111111].2 - list([ - ]) +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.0', + 'friendly_name': 'EF.111111111113 Hub short on branch 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) # --- -# name: test_binary_sensors[EF.111111111112] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111112', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_MOISTURE_METER', - 'model_id': None, - 'name': 'EF.111111111112', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_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': 'Hub short on branch 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_short_id', + 'unique_id': '/EF.111111111113/hub/short.1', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[EF.111111111112].1 - list([ - ]) +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.1', + 'friendly_name': 'EF.111111111113 Hub short on branch 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[EF.111111111112].2 - list([ - ]) +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_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': 'Hub short on branch 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_short_id', + 'unique_id': '/EF.111111111113/hub/short.2', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[EF.111111111113] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111113', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_HUB', - 'model_id': None, - 'name': 'EF.111111111113', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.2', + 'friendly_name': 'EF.111111111113 Hub short on branch 2', + 'raw_value': 1.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) # --- -# name: test_binary_sensors[EF.111111111113].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hub short on branch 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_short_id', - 'unique_id': '/EF.111111111113/hub/short.0', - 'unit_of_measurement': None, +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_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': 'Hub short on branch 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_short_id', - 'unique_id': '/EF.111111111113/hub/short.1', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_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': 'Hub short on branch 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_short_id', - 'unique_id': '/EF.111111111113/hub/short.2', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hub short on branch 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_short_id', - 'unique_id': '/EF.111111111113/hub/short.3', - 'unit_of_measurement': None, - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hub short on branch 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_short_id', + 'unique_id': '/EF.111111111113/hub/short.3', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[EF.111111111113].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'device_file': '/EF.111111111113/hub/short.0', - 'friendly_name': 'EF.111111111113 Hub short on branch 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.3', + 'friendly_name': 'EF.111111111113 Hub short on branch 3', + 'raw_value': 0.0, }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'device_file': '/EF.111111111113/hub/short.1', - 'friendly_name': 'EF.111111111113 Hub short on branch 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'device_file': '/EF.111111111113/hub/short.2', - 'friendly_name': 'EF.111111111113 Hub short on branch 2', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'device_file': '/EF.111111111113/hub/short.3', - 'friendly_name': 'EF.111111111113 Hub short on branch 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr new file mode 100644 index 00000000000..e2d818534f5 --- /dev/null +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -0,0 +1,673 @@ +# serializer version: 1 +# name: test_registry[05.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '05.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2405', + 'model_id': None, + 'name': '05.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[10.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '10.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18S20', + 'model_id': None, + 'name': '10.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[12.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '12.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2406', + 'model_id': None, + 'name': '12.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[1D.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '1D.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2423', + 'model_id': None, + 'name': '1D.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_registry[1F.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '1F.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2409', + 'model_id': None, + 'name': '1F.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[22.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '22.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS1822', + 'model_id': None, + 'name': '22.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[26.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '26.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2438', + 'model_id': None, + 'name': '26.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[28.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '28.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'model_id': None, + 'name': '28.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[28.222222222222-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '28.222222222222', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'model_id': None, + 'name': '28.222222222222', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[28.222222222223-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '28.222222222223', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'model_id': None, + 'name': '28.222222222223', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[29.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '29.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2408', + 'model_id': None, + 'name': '29.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[30.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '30.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2760', + 'model_id': None, + 'name': '30.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[3A.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '3A.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2413', + 'model_id': None, + 'name': '3A.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[3B.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '3B.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS1825', + 'model_id': None, + 'name': '3B.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[42.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '42.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS28EA00', + 'model_id': None, + 'name': '42.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[7E.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '7E.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Embedded Data Systems', + 'model': 'EDS0068', + 'model_id': None, + 'name': '7E.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[7E.222222222222-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '7E.222222222222', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Embedded Data Systems', + 'model': 'EDS0066', + 'model_id': None, + 'name': '7E.222222222222', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[A6.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + 'A6.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2438', + 'model_id': None, + 'name': 'A6.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[EF.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + 'EF.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Hobby Boards', + 'model': 'HobbyBoards_EF', + 'model_id': None, + 'name': 'EF.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[EF.111111111112-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + 'EF.111111111112', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Hobby Boards', + 'model': 'HB_MOISTURE_METER', + 'model_id': None, + 'name': 'EF.111111111112', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[EF.111111111113-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + 'EF.111111111113', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Hobby Boards', + 'model': 'HB_HUB', + 'model_id': None, + 'name': 'EF.111111111113', + '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/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index b251e7e181c..1b8484b27a4 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -1,3477 +1,2647 @@ # serializer version: 1 -# name: test_sensors[00.111111111111] - list([ - ]) -# --- -# name: test_sensors[00.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[00.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[05.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '05.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2405', - 'model_id': None, - 'name': '05.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[sensor.10_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_sensors[05.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[05.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[10.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '10.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18S20', - 'model_id': None, - 'name': '10.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - ]) + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.10_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/10.111111111111/temperature', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[10.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.10_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/10.111111111111/temperature', +# name: test_sensors[sensor.10_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/10.111111111111/temperature', + 'friendly_name': '10.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.10_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) # --- -# name: test_sensors[10.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/10.111111111111/temperature', - 'friendly_name': '10.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.10_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.123', +# name: test_sensors[sensor.12_111111111111_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.12_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/12.111111111111/TAI8570/pressure', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[12.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '12.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2406', - 'model_id': None, - 'name': '12.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[12.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.12_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/12.111111111111/TAI8570/temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.12_111111111111_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/12.111111111111/TAI8570/pressure', +# name: test_sensors[sensor.12_111111111111_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/12.111111111111/TAI8570/pressure', + 'friendly_name': '12.111111111111 Pressure', + 'raw_value': 1025.123, + 'state_class': , 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.12_111111111111_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1025.123', + }) # --- -# name: test_sensors[12.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/12.111111111111/TAI8570/temperature', - 'friendly_name': '12.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.12_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.123', +# name: test_sensors[sensor.12_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/12.111111111111/TAI8570/pressure', - 'friendly_name': '12.111111111111 Pressure', - 'raw_value': 1025.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.12_111111111111_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1025.123', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - ]) + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.12_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/12.111111111111/TAI8570/temperature', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[16.111111111111] - list([ - ]) -# --- -# name: test_sensors[16.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[16.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[1D.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[1D.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.1d_111111111111_counter_a', - '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': 'Counter A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'counter_id', - 'unique_id': '/1D.111111111111/counter.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.1d_111111111111_counter_b', - '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': 'Counter B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'counter_id', - 'unique_id': '/1D.111111111111/counter.B', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_sensors[1D.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/1D.111111111111/counter.A', - 'friendly_name': '1D.111111111111 Counter A', - 'raw_value': 251123.0, - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.1d_111111111111_counter_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '251123', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/1D.111111111111/counter.B', - 'friendly_name': '1D.111111111111 Counter B', - 'raw_value': 248125.0, - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.1d_111111111111_counter_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '248125', - }), - ]) -# --- -# name: test_sensors[1F.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1F.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2409', - 'model_id': None, - 'name': '1F.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': , - }), - ]) -# --- -# name: test_sensors[1F.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.1d_111111111111_counter_a', - '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': 'Counter A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'counter_id', - 'unique_id': '/1D.111111111111/counter.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.1d_111111111111_counter_b', - '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': 'Counter B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'counter_id', - 'unique_id': '/1D.111111111111/counter.B', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_sensors[1F.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/1F.111111111111/main/1D.111111111111/counter.A', - 'friendly_name': '1D.111111111111 Counter A', - 'raw_value': 251123.0, - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.1d_111111111111_counter_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '251123', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/1F.111111111111/main/1D.111111111111/counter.B', - 'friendly_name': '1D.111111111111 Counter B', - 'raw_value': 248125.0, - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.1d_111111111111_counter_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '248125', - }), - ]) -# --- -# name: test_sensors[22.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '22.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1822', - 'model_id': None, - 'name': '22.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[22.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.22_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/22.111111111111/temperature', +# name: test_sensors[sensor.12_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/12.111111111111/TAI8570/temperature', + 'friendly_name': '12.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.12_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) # --- -# name: test_sensors[22.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/22.111111111111/temperature', - 'friendly_name': '22.111111111111 Temperature', - 'raw_value': None, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.22_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_sensors[sensor.1d_111111111111_counter_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_sensors[26.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '26.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': '26.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - ]) + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1d_111111111111_counter_a', + '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': 'Counter A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'counter_id', + 'unique_id': '/1D.111111111111/counter.A', + 'unit_of_measurement': None, + }) # --- -# name: test_sensors[26.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/26.111111111111/temperature', +# name: test_sensors[sensor.1d_111111111111_counter_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/1D.111111111111/counter.A', + 'friendly_name': '1D.111111111111 Counter A', + 'raw_value': 251123.0, + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.1d_111111111111_counter_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '251123', + }) +# --- +# name: test_sensors[sensor.1d_111111111111_counter_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1d_111111111111_counter_b', + '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': 'Counter B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'counter_id', + 'unique_id': '/1D.111111111111/counter.B', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.1d_111111111111_counter_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/1D.111111111111/counter.B', + 'friendly_name': '1D.111111111111 Counter B', + 'raw_value': 248125.0, + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.1d_111111111111_counter_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '248125', + }) +# --- +# name: test_sensors[sensor.22_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.22_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/22.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.22_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/22.111111111111/temperature', + 'friendly_name': '22.111111111111 Temperature', + 'raw_value': None, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/26.111111111111/humidity', + 'context': , + 'entity_id': 'sensor.22_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih3600_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_hih3600_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': 'HIH3600 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih3600', + 'unique_id': '/26.111111111111/HIH3600/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih3600_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HIH3600/humidity', + 'friendly_name': '26.111111111111 HIH3600 humidity', + 'raw_value': 73.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_hih3600_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': 'HIH3600 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih3600', - 'unique_id': '/26.111111111111/HIH3600/humidity', + 'context': , + 'entity_id': 'sensor.26_111111111111_hih3600_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '73.7563', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih4000_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_hih4000_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': 'HIH4000 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih4000', + 'unique_id': '/26.111111111111/HIH4000/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih4000_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HIH4000/humidity', + 'friendly_name': '26.111111111111 HIH4000 humidity', + 'raw_value': 74.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_hih4000_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': 'HIH4000 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih4000', - 'unique_id': '/26.111111111111/HIH4000/humidity', + 'context': , + 'entity_id': 'sensor.26_111111111111_hih4000_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '74.7563', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih5030_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_hih5030_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': 'HIH5030 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih5030', + 'unique_id': '/26.111111111111/HIH5030/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih5030_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HIH5030/humidity', + 'friendly_name': '26.111111111111 HIH5030 humidity', + 'raw_value': 75.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_hih5030_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': 'HIH5030 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih5030', - 'unique_id': '/26.111111111111/HIH5030/humidity', + 'context': , + 'entity_id': 'sensor.26_111111111111_hih5030_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75.7563', + }) +# --- +# name: test_sensors[sensor.26_111111111111_htm1735_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_htm1735_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': 'HTM1735 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_htm1735', + 'unique_id': '/26.111111111111/HTM1735/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_htm1735_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HTM1735/humidity', + 'friendly_name': '26.111111111111 HTM1735 humidity', + 'raw_value': None, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_htm1735_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': 'HTM1735 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_htm1735', - 'unique_id': '/26.111111111111/HTM1735/humidity', + 'context': , + 'entity_id': 'sensor.26_111111111111_htm1735_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.26_111111111111_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/26.111111111111/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/humidity', + 'friendly_name': '26.111111111111 Humidity', + 'raw_value': 72.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/26.111111111111/B1-R1-A/pressure', - 'unit_of_measurement': , + 'context': , + 'entity_id': 'sensor.26_111111111111_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '72.7563', + }) +# --- +# name: test_sensors[sensor.26_111111111111_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_illuminance', - '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', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/26.111111111111/S3-R1-A/illuminance', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_illuminance', + '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', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/26.111111111111/S3-R1-A/illuminance', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensors[sensor.26_111111111111_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'device_file': '/26.111111111111/S3-R1-A/illuminance', + 'friendly_name': '26.111111111111 Illuminance', + 'raw_value': 65.8839, + 'state_class': , 'unit_of_measurement': 'lx', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_vad_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': 'VAD voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vad', - 'unique_id': '/26.111111111111/VAD', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_vdd_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': 'VDD voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vdd', - 'unique_id': '/26.111111111111/VDD', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_vis_voltage_difference', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VIS voltage difference', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vis', - 'unique_id': '/26.111111111111/vis', - 'unit_of_measurement': , - }), - ]) + 'context': , + 'entity_id': 'sensor.26_111111111111_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.8839', + }) # --- -# name: test_sensors[26.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/26.111111111111/temperature', - 'friendly_name': '26.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.123', +# name: test_sensors[sensor.26_111111111111_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/humidity', - 'friendly_name': '26.111111111111 Humidity', - 'raw_value': 72.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '72.7563', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/HIH3600/humidity', - 'friendly_name': '26.111111111111 HIH3600 humidity', - 'raw_value': 73.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_hih3600_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '73.7563', + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/HIH4000/humidity', - 'friendly_name': '26.111111111111 HIH4000 humidity', - 'raw_value': 74.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_hih4000_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '74.7563', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/HIH5030/humidity', - 'friendly_name': '26.111111111111 HIH5030 humidity', - 'raw_value': 75.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_hih5030_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '75.7563', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/HTM1735/humidity', - 'friendly_name': '26.111111111111 HTM1735 humidity', - 'raw_value': None, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_htm1735_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/26.111111111111/B1-R1-A/pressure', - 'friendly_name': '26.111111111111 Pressure', - 'raw_value': 969.265, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '969.265', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'illuminance', - 'device_file': '/26.111111111111/S3-R1-A/illuminance', - 'friendly_name': '26.111111111111 Illuminance', - 'raw_value': 65.8839, - 'state_class': , - 'unit_of_measurement': 'lx', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_illuminance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '65.8839', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/26.111111111111/VAD', - 'friendly_name': '26.111111111111 VAD voltage', - 'raw_value': 2.97, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_vad_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2.97', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/26.111111111111/VDD', - 'friendly_name': '26.111111111111 VDD voltage', - 'raw_value': 4.74, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_vdd_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4.74', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/26.111111111111/vis', - 'friendly_name': '26.111111111111 VIS voltage difference', - 'raw_value': 0.12, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_vis_voltage_difference', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.12', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/26.111111111111/B1-R1-A/pressure', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[28.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[28.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.28_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/28.111111111111/temperature', - 'unit_of_measurement': , - }), - ]) -# --- -# name: test_sensors[28.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/28.111111111111/temperature', - 'friendly_name': '28.111111111111 Temperature', - 'raw_value': 26.984, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.28_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '26.984', - }), - ]) -# --- -# name: test_sensors[28.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[28.222222222222].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.28_222222222222_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/28.222222222222/temperature', - 'unit_of_measurement': , - }), - ]) -# --- -# name: test_sensors[28.222222222222].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/28.222222222222/temperature9', - 'friendly_name': '28.222222222222 Temperature', - 'raw_value': 26.984, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.28_222222222222_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '26.984', - }), - ]) -# --- -# name: test_sensors[28.222222222223] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222223', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222223', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[28.222222222223].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.28_222222222223_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/28.222222222223/temperature', - 'unit_of_measurement': , - }), - ]) -# --- -# name: test_sensors[28.222222222223].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/28.222222222223/temperature', - 'friendly_name': '28.222222222223 Temperature', - 'raw_value': 26.984, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.28_222222222223_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '26.984', - }), - ]) -# --- -# name: test_sensors[29.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '29.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2408', - 'model_id': None, - 'name': '29.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[29.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[29.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[30.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '30.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2760', - 'model_id': None, - 'name': '30.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[30.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.30_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/30.111111111111/temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.30_111111111111_thermocouple_k_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': 'Thermocouple K temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'thermocouple_temperature_k', - 'unique_id': '/30.111111111111/typeX/temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.30_111111111111_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': 'Voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/30.111111111111/volt', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.30_111111111111_vis_voltage_gradient', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VIS voltage gradient', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vis_gradient', - 'unique_id': '/30.111111111111/vis', - 'unit_of_measurement': , - }), - ]) -# --- -# name: test_sensors[30.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/30.111111111111/temperature', - 'friendly_name': '30.111111111111 Temperature', - 'raw_value': 26.984, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.30_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '26.984', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/30.111111111111/typeK/temperature', - 'friendly_name': '30.111111111111 Thermocouple K temperature', - 'raw_value': 173.7563, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.30_111111111111_thermocouple_k_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '173.7563', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/30.111111111111/volt', - 'friendly_name': '30.111111111111 Voltage', - 'raw_value': 2.97, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.30_111111111111_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2.97', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/30.111111111111/vis', - 'friendly_name': '30.111111111111 VIS voltage gradient', - 'raw_value': 0.12, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.30_111111111111_vis_voltage_gradient', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.12', - }), - ]) -# --- -# name: test_sensors[3A.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3A.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2413', - 'model_id': None, - 'name': '3A.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[3A.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[3A.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[3B.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3B.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1825', - 'model_id': None, - 'name': '3B.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[3B.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.3b_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/3B.111111111111/temperature', - 'unit_of_measurement': , - }), - ]) -# --- -# name: test_sensors[3B.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/3B.111111111111/temperature', - 'friendly_name': '3B.111111111111 Temperature', - 'raw_value': 28.243, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.3b_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '28.243', - }), - ]) -# --- -# name: test_sensors[42.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '42.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS28EA00', - 'model_id': None, - 'name': '42.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[42.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.42_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/42.111111111111/temperature', - 'unit_of_measurement': , - }), - ]) -# --- -# name: test_sensors[42.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/42.111111111111/temperature', - 'friendly_name': '42.111111111111 Temperature', - 'raw_value': 29.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.42_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '29.123', - }), - ]) -# --- -# name: test_sensors[7E.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0068', - 'model_id': None, - 'name': '7E.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[7E.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.111111111111/EDS0068/temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_111111111111_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.111111111111/EDS0068/pressure', +# name: test_sensors[sensor.26_111111111111_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/26.111111111111/B1-R1-A/pressure', + 'friendly_name': '26.111111111111 Pressure', + 'raw_value': 969.265, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_111111111111_illuminance', - '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', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.111111111111/EDS0068/light', + 'context': , + 'entity_id': 'sensor.26_111111111111_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '969.265', + }) +# --- +# name: test_sensors[sensor.26_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/26.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.26_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/26.111111111111/temperature', + 'friendly_name': '26.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) +# --- +# name: test_sensors[sensor.26_111111111111_vad_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_vad_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': 'VAD voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vad', + 'unique_id': '/26.111111111111/VAD', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.26_111111111111_vad_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/26.111111111111/VAD', + 'friendly_name': '26.111111111111 VAD voltage', + 'raw_value': 2.97, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_vad_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.97', + }) +# --- +# name: test_sensors[sensor.26_111111111111_vdd_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_vdd_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': 'VDD voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vdd', + 'unique_id': '/26.111111111111/VDD', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.26_111111111111_vdd_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/26.111111111111/VDD', + 'friendly_name': '26.111111111111 VDD voltage', + 'raw_value': 4.74, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_vdd_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.74', + }) +# --- +# name: test_sensors[sensor.26_111111111111_vis_voltage_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_vis_voltage_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VIS voltage difference', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vis', + 'unique_id': '/26.111111111111/vis', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.26_111111111111_vis_voltage_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/26.111111111111/vis', + 'friendly_name': '26.111111111111 VIS voltage difference', + 'raw_value': 0.12, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_vis_voltage_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.12', + }) +# --- +# name: test_sensors[sensor.28_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.28_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/28.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.28_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/28.111111111111/temperature', + 'friendly_name': '28.111111111111 Temperature', + 'raw_value': 26.984, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.28_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.984', + }) +# --- +# name: test_sensors[sensor.28_222222222222_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.28_222222222222_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/28.222222222222/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.28_222222222222_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/28.222222222222/temperature9', + 'friendly_name': '28.222222222222 Temperature', + 'raw_value': 26.984, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.28_222222222222_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.984', + }) +# --- +# name: test_sensors[sensor.28_222222222223_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.28_222222222223_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/28.222222222223/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.28_222222222223_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/28.222222222223/temperature', + 'friendly_name': '28.222222222223 Temperature', + 'raw_value': 26.984, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.28_222222222223_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.984', + }) +# --- +# name: test_sensors[sensor.30_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/30.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.30_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/30.111111111111/temperature', + 'friendly_name': '30.111111111111 Temperature', + 'raw_value': 26.984, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.30_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.984', + }) +# --- +# name: test_sensors[sensor.30_111111111111_thermocouple_k_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_thermocouple_k_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': 'Thermocouple K temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermocouple_temperature_k', + 'unique_id': '/30.111111111111/typeX/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.30_111111111111_thermocouple_k_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/30.111111111111/typeK/temperature', + 'friendly_name': '30.111111111111 Thermocouple K temperature', + 'raw_value': 173.7563, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.30_111111111111_thermocouple_k_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '173.7563', + }) +# --- +# name: test_sensors[sensor.30_111111111111_vis_voltage_gradient-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_vis_voltage_gradient', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VIS voltage gradient', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vis_gradient', + 'unique_id': '/30.111111111111/vis', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.30_111111111111_vis_voltage_gradient-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/30.111111111111/vis', + 'friendly_name': '30.111111111111 VIS voltage gradient', + 'raw_value': 0.12, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.30_111111111111_vis_voltage_gradient', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.12', + }) +# --- +# name: test_sensors[sensor.30_111111111111_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_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': 'Voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/30.111111111111/volt', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.30_111111111111_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/30.111111111111/volt', + 'friendly_name': '30.111111111111 Voltage', + 'raw_value': 2.97, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.30_111111111111_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.97', + }) +# --- +# name: test_sensors[sensor.3b_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.3b_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/3B.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.3b_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/3B.111111111111/temperature', + 'friendly_name': '3B.111111111111 Temperature', + 'raw_value': 28.243, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.3b_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.243', + }) +# --- +# name: test_sensors[sensor.42_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.42_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/42.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.42_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/42.111111111111/temperature', + 'friendly_name': '42.111111111111 Temperature', + 'raw_value': 29.123, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.42_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.123', + }) +# --- +# name: test_sensors[sensor.7e_111111111111_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.111111111111/EDS0068/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.7e_111111111111_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/7E.111111111111/EDS0068/humidity', + 'friendly_name': '7E.111111111111 Humidity', + 'raw_value': 41.375, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.7e_111111111111_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '41.375', + }) +# --- +# name: test_sensors[sensor.7e_111111111111_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_111111111111_illuminance', + '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', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.111111111111/EDS0068/light', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensors[sensor.7e_111111111111_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'device_file': '/7E.111111111111/EDS0068/light', + 'friendly_name': '7E.111111111111 Illuminance', + 'raw_value': 65.8839, + 'state_class': , 'unit_of_measurement': 'lx', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.111111111111/EDS0068/humidity', - 'unit_of_measurement': '%', - }), - ]) + 'context': , + 'entity_id': 'sensor.7e_111111111111_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.8839', + }) # --- -# name: test_sensors[7E.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/7E.111111111111/EDS0068/temperature', - 'friendly_name': '7E.111111111111 Temperature', - 'raw_value': 13.9375, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.7e_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '13.9375', +# name: test_sensors[sensor.7e_111111111111_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/7E.111111111111/EDS0068/pressure', - 'friendly_name': '7E.111111111111 Pressure', - 'raw_value': 1012.21, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.7e_111111111111_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1012.21', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'illuminance', - 'device_file': '/7E.111111111111/EDS0068/light', - 'friendly_name': '7E.111111111111 Illuminance', - 'raw_value': 65.8839, - 'state_class': , - 'unit_of_measurement': 'lx', - }), - 'context': , - 'entity_id': 'sensor.7e_111111111111_illuminance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '65.8839', + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/7E.111111111111/EDS0068/humidity', - 'friendly_name': '7E.111111111111 Humidity', - 'raw_value': 41.375, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.7e_111111111111_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '41.375', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.111111111111/EDS0068/pressure', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[7E.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0066', - 'model_id': None, - 'name': '7E.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[7E.222222222222].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_222222222222_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.222222222222/EDS0066/temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_222222222222_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.222222222222/EDS0066/pressure', +# name: test_sensors[sensor.7e_111111111111_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/7E.111111111111/EDS0068/pressure', + 'friendly_name': '7E.111111111111 Pressure', + 'raw_value': 1012.21, + 'state_class': , 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.7e_111111111111_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1012.21', + }) # --- -# name: test_sensors[7E.222222222222].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/7E.222222222222/EDS0066/temperature', - 'friendly_name': '7E.222222222222 Temperature', - 'raw_value': 13.9375, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.7e_222222222222_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '13.9375', +# name: test_sensors[sensor.7e_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/7E.222222222222/EDS0066/pressure', - 'friendly_name': '7E.222222222222 Pressure', - 'raw_value': 1012.21, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.7e_222222222222_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1012.21', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - ]) + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.111111111111/EDS0068/temperature', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[A6.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'A6.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': 'A6.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[A6.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/A6.111111111111/temperature', +# name: test_sensors[sensor.7e_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/7E.111111111111/EDS0068/temperature', + 'friendly_name': '7E.111111111111 Temperature', + 'raw_value': 13.9375, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/A6.111111111111/humidity', - 'unit_of_measurement': '%', + 'context': , + 'entity_id': 'sensor.7e_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.9375', + }) +# --- +# name: test_sensors[sensor.7e_222222222222_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_hih3600_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': 'HIH3600 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih3600', - 'unique_id': '/A6.111111111111/HIH3600/humidity', - 'unit_of_measurement': '%', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_hih4000_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': 'HIH4000 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih4000', - 'unique_id': '/A6.111111111111/HIH4000/humidity', - 'unit_of_measurement': '%', + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_222222222222_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_hih5030_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': 'HIH5030 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih5030', - 'unique_id': '/A6.111111111111/HIH5030/humidity', - 'unit_of_measurement': '%', + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_htm1735_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': 'HTM1735 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_htm1735', - 'unique_id': '/A6.111111111111/HTM1735/humidity', - 'unit_of_measurement': '%', - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/A6.111111111111/B1-R1-A/pressure', + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.222222222222/EDS0066/pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.7e_222222222222_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/7E.222222222222/EDS0066/pressure', + 'friendly_name': '7E.222222222222 Pressure', + 'raw_value': 1012.21, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_illuminance', - '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', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/A6.111111111111/S3-R1-A/illuminance', + 'context': , + 'entity_id': 'sensor.7e_222222222222_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1012.21', + }) +# --- +# name: test_sensors[sensor.7e_222222222222_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_222222222222_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.222222222222/EDS0066/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.7e_222222222222_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/7E.222222222222/EDS0066/temperature', + 'friendly_name': '7E.222222222222 Temperature', + 'raw_value': 13.9375, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.7e_222222222222_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.9375', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih3600_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_hih3600_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': 'HIH3600 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih3600', + 'unique_id': '/A6.111111111111/HIH3600/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih3600_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/HIH3600/humidity', + 'friendly_name': 'A6.111111111111 HIH3600 humidity', + 'raw_value': 73.7563, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_hih3600_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '73.7563', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih4000_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_hih4000_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': 'HIH4000 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih4000', + 'unique_id': '/A6.111111111111/HIH4000/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih4000_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/HIH4000/humidity', + 'friendly_name': 'A6.111111111111 HIH4000 humidity', + 'raw_value': 74.7563, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_hih4000_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '74.7563', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih5030_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_hih5030_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': 'HIH5030 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih5030', + 'unique_id': '/A6.111111111111/HIH5030/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih5030_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/HIH5030/humidity', + 'friendly_name': 'A6.111111111111 HIH5030 humidity', + 'raw_value': 75.7563, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_hih5030_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75.7563', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_htm1735_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_htm1735_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': 'HTM1735 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_htm1735', + 'unique_id': '/A6.111111111111/HTM1735/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_htm1735_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/HTM1735/humidity', + 'friendly_name': 'A6.111111111111 HTM1735 humidity', + 'raw_value': None, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_htm1735_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/A6.111111111111/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/humidity', + 'friendly_name': 'A6.111111111111 Humidity', + 'raw_value': 72.7563, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '72.7563', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_illuminance', + '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', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/A6.111111111111/S3-R1-A/illuminance', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'device_file': '/A6.111111111111/S3-R1-A/illuminance', + 'friendly_name': 'A6.111111111111 Illuminance', + 'raw_value': 65.8839, + 'state_class': , 'unit_of_measurement': 'lx', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_vad_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': 'VAD voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vad', - 'unique_id': '/A6.111111111111/VAD', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_vdd_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': 'VDD voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vdd', - 'unique_id': '/A6.111111111111/VDD', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_vis_voltage_difference', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VIS voltage difference', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vis', - 'unique_id': '/A6.111111111111/vis', - 'unit_of_measurement': , - }), - ]) + 'context': , + 'entity_id': 'sensor.a6_111111111111_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.8839', + }) # --- -# name: test_sensors[A6.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/A6.111111111111/temperature', - 'friendly_name': 'A6.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.123', +# name: test_sensors[sensor.a6_111111111111_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/humidity', - 'friendly_name': 'A6.111111111111 Humidity', - 'raw_value': 72.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '72.7563', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/HIH3600/humidity', - 'friendly_name': 'A6.111111111111 HIH3600 humidity', - 'raw_value': 73.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_hih3600_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '73.7563', + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/HIH4000/humidity', - 'friendly_name': 'A6.111111111111 HIH4000 humidity', - 'raw_value': 74.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_hih4000_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '74.7563', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/HIH5030/humidity', - 'friendly_name': 'A6.111111111111 HIH5030 humidity', - 'raw_value': 75.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_hih5030_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '75.7563', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/HTM1735/humidity', - 'friendly_name': 'A6.111111111111 HTM1735 humidity', - 'raw_value': None, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_htm1735_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/A6.111111111111/B1-R1-A/pressure', - 'friendly_name': 'A6.111111111111 Pressure', - 'raw_value': 969.265, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '969.265', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'illuminance', - 'device_file': '/A6.111111111111/S3-R1-A/illuminance', - 'friendly_name': 'A6.111111111111 Illuminance', - 'raw_value': 65.8839, - 'state_class': , - 'unit_of_measurement': 'lx', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_illuminance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '65.8839', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/A6.111111111111/VAD', - 'friendly_name': 'A6.111111111111 VAD voltage', - 'raw_value': 2.97, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_vad_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2.97', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/A6.111111111111/VDD', - 'friendly_name': 'A6.111111111111 VDD voltage', - 'raw_value': 4.74, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_vdd_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4.74', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/A6.111111111111/vis', - 'friendly_name': 'A6.111111111111 VIS voltage difference', - 'raw_value': 0.12, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_vis_voltage_difference', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.12', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/A6.111111111111/B1-R1-A/pressure', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[EF.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HobbyBoards_EF', - 'model_id': None, - 'name': 'EF.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[sensor.a6_111111111111_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/A6.111111111111/B1-R1-A/pressure', + 'friendly_name': 'A6.111111111111 Pressure', + 'raw_value': 969.265, + 'state_class': , + 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.a6_111111111111_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '969.265', + }) # --- -# name: test_sensors[EF.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/EF.111111111111/humidity/humidity_corrected', - 'unit_of_measurement': '%', +# name: test_sensors[sensor.a6_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111111_raw_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': 'Raw humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_raw', - 'unique_id': '/EF.111111111111/humidity/humidity_raw', - 'unit_of_measurement': '%', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111111_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': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/EF.111111111111/humidity/temperature', + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/A6.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.a6_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/A6.111111111111/temperature', + 'friendly_name': 'A6.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.a6_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) # --- -# name: test_sensors[EF.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/EF.111111111111/humidity/humidity_corrected', - 'friendly_name': 'EF.111111111111 Humidity', - 'raw_value': 67.745, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ef_111111111111_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '67.745', +# name: test_sensors[sensor.a6_111111111111_vad_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/EF.111111111111/humidity/humidity_raw', - 'friendly_name': 'EF.111111111111 Raw humidity', - 'raw_value': 65.541, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ef_111111111111_raw_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '65.541', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/EF.111111111111/humidity/temperature', - 'friendly_name': 'EF.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ef_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.123', + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_vad_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': 'VAD voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vad', + 'unique_id': '/A6.111111111111/VAD', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[EF.111111111112] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111112', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_MOISTURE_METER', - 'model_id': None, - 'name': 'EF.111111111112', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[sensor.a6_111111111111_vad_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/A6.111111111111/VAD', + 'friendly_name': 'A6.111111111111 VAD voltage', + 'raw_value': 2.97, + 'state_class': , + 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.a6_111111111111_vad_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.97', + }) # --- -# name: test_sensors[EF.111111111112].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111112_wetness_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wetness 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'wetness_id', - 'unique_id': '/EF.111111111112/moisture/sensor.0', +# name: test_sensors[sensor.a6_111111111111_vdd_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_vdd_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': 'VDD voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vdd', + 'unique_id': '/A6.111111111111/VDD', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.a6_111111111111_vdd_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/A6.111111111111/VDD', + 'friendly_name': 'A6.111111111111 VDD voltage', + 'raw_value': 4.74, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_vdd_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.74', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_vis_voltage_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_vis_voltage_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VIS voltage difference', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vis', + 'unique_id': '/A6.111111111111/vis', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.a6_111111111111_vis_voltage_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/A6.111111111111/vis', + 'friendly_name': 'A6.111111111111 VIS voltage difference', + 'raw_value': 0.12, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_vis_voltage_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.12', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/EF.111111111111/humidity/humidity_corrected', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111111/humidity/humidity_corrected', + 'friendly_name': 'EF.111111111111 Humidity', + 'raw_value': 67.745, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111112_wetness_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': 'Wetness 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'wetness_id', - 'unique_id': '/EF.111111111112/moisture/sensor.1', + 'context': , + 'entity_id': 'sensor.ef_111111111111_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '67.745', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_raw_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111111_raw_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': 'Raw humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_raw', + 'unique_id': '/EF.111111111111/humidity/humidity_raw', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_raw_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111111/humidity/humidity_raw', + 'friendly_name': 'EF.111111111111 Raw humidity', + 'raw_value': 65.541, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111112_moisture_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': 'Moisture 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_id', - 'unique_id': '/EF.111111111112/moisture/sensor.2', + 'context': , + 'entity_id': 'sensor.ef_111111111111_raw_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.541', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111111_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': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/EF.111111111111/humidity/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ef_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/EF.111111111111/humidity/temperature', + 'friendly_name': 'EF.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ef_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) +# --- +# name: test_sensors[sensor.ef_111111111112_moisture_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111112_moisture_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': 'Moisture 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_id', + 'unique_id': '/EF.111111111112/moisture/sensor.2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ef_111111111112_moisture_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/EF.111111111112/moisture/sensor.2', + 'friendly_name': 'EF.111111111112 Moisture 2', + 'raw_value': 43.123, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111112_moisture_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Moisture 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_id', - 'unique_id': '/EF.111111111112/moisture/sensor.3', + 'context': , + 'entity_id': 'sensor.ef_111111111112_moisture_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.123', + }) +# --- +# name: test_sensors[sensor.ef_111111111112_moisture_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111112_moisture_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Moisture 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_id', + 'unique_id': '/EF.111111111112/moisture/sensor.3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ef_111111111112_moisture_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/EF.111111111112/moisture/sensor.3', + 'friendly_name': 'EF.111111111112 Moisture 3', + 'raw_value': 44.123, + 'state_class': , 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.ef_111111111112_moisture_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '44.123', + }) # --- -# name: test_sensors[EF.111111111112].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/EF.111111111112/moisture/sensor.0', - 'friendly_name': 'EF.111111111112 Wetness 0', - 'raw_value': 41.745, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ef_111111111112_wetness_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '41.745', +# name: test_sensors[sensor.ef_111111111112_wetness_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/EF.111111111112/moisture/sensor.1', - 'friendly_name': 'EF.111111111112 Wetness 1', - 'raw_value': 42.541, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ef_111111111112_wetness_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '42.541', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/EF.111111111112/moisture/sensor.2', - 'friendly_name': 'EF.111111111112 Moisture 2', - 'raw_value': 43.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ef_111111111112_moisture_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '43.123', + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111112_wetness_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/EF.111111111112/moisture/sensor.3', - 'friendly_name': 'EF.111111111112 Moisture 3', - 'raw_value': 44.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ef_111111111112_moisture_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '44.123', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wetness 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wetness_id', + 'unique_id': '/EF.111111111112/moisture/sensor.0', + 'unit_of_measurement': '%', + }) # --- -# name: test_sensors[EF.111111111113] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111113', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_HUB', - 'model_id': None, - 'name': 'EF.111111111113', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[sensor.ef_111111111112_wetness_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111112/moisture/sensor.0', + 'friendly_name': 'EF.111111111112 Wetness 0', + 'raw_value': 41.745, + 'state_class': , + 'unit_of_measurement': '%', }), - ]) + 'context': , + 'entity_id': 'sensor.ef_111111111112_wetness_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '41.745', + }) # --- -# name: test_sensors[EF.111111111113].1 - list([ - ]) +# name: test_sensors[sensor.ef_111111111112_wetness_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111112_wetness_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': 'Wetness 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wetness_id', + 'unique_id': '/EF.111111111112/moisture/sensor.1', + 'unit_of_measurement': '%', + }) # --- -# name: test_sensors[EF.111111111113].2 - list([ - ]) +# name: test_sensors[sensor.ef_111111111112_wetness_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111112/moisture/sensor.1', + 'friendly_name': 'EF.111111111112 Wetness 1', + 'raw_value': 42.541, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ef_111111111112_wetness_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.541', + }) # --- diff --git a/tests/components/onewire/snapshots/test_switch.ambr b/tests/components/onewire/snapshots/test_switch.ambr index 3bc7a2d3def..cb752982bec 100644 --- a/tests/components/onewire/snapshots/test_switch.ambr +++ b/tests/components/onewire/snapshots/test_switch.ambr @@ -1,2565 +1,1777 @@ # serializer version: 1 -# name: test_switches[00.111111111111] - list([ - ]) -# --- -# name: test_switches[00.111111111111].1 - list([ - ]) -# --- -# name: test_switches[00.111111111111].2 - list([ - ]) -# --- -# name: test_switches[05.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '05.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2405', - 'model_id': None, - 'name': '05.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[05.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.05_111111111111_programmed_input_output', - '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': 'Programmed input-output', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio', - 'unique_id': '/05.111111111111/PIO', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[05.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/05.111111111111/PIO', - 'friendly_name': '05.111111111111 Programmed input-output', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.05_111111111111_programmed_input_output', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - ]) -# --- -# name: test_switches[10.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '10.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18S20', - 'model_id': None, - 'name': '10.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[10.111111111111].1 - list([ - ]) -# --- -# name: test_switches[10.111111111111].2 - list([ - ]) -# --- -# name: test_switches[12.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '12.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2406', - 'model_id': None, - 'name': '12.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[12.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.12_111111111111_programmed_input_output_a', - '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': 'Programmed input-output A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/12.111111111111/PIO.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.12_111111111111_programmed_input_output_b', - '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': 'Programmed input-output B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/12.111111111111/PIO.B', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.12_111111111111_latch_a', - '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': 'Latch A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/12.111111111111/latch.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.12_111111111111_latch_b', - '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': 'Latch B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/12.111111111111/latch.B', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[12.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/PIO.A', - 'friendly_name': '12.111111111111 Programmed input-output A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.12_111111111111_programmed_input_output_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/PIO.B', - 'friendly_name': '12.111111111111 Programmed input-output B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.12_111111111111_programmed_input_output_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/latch.A', - 'friendly_name': '12.111111111111 Latch A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.12_111111111111_latch_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/latch.B', - 'friendly_name': '12.111111111111 Latch B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.12_111111111111_latch_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_switches[16.111111111111] - list([ - ]) -# --- -# name: test_switches[16.111111111111].1 - list([ - ]) -# --- -# name: test_switches[16.111111111111].2 - list([ - ]) -# --- -# name: test_switches[1D.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[1D.111111111111].1 - list([ - ]) -# --- -# name: test_switches[1D.111111111111].2 - list([ - ]) -# --- -# name: test_switches[1F.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1F.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2409', - 'model_id': None, - 'name': '1F.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': , - }), - ]) -# --- -# name: test_switches[1F.111111111111].1 - list([ - ]) -# --- -# name: test_switches[1F.111111111111].2 - list([ - ]) -# --- -# name: test_switches[22.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '22.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1822', - 'model_id': None, - 'name': '22.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[22.111111111111].1 - list([ - ]) -# --- -# name: test_switches[22.111111111111].2 - list([ - ]) -# --- -# name: test_switches[26.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '26.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': '26.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[26.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.26_111111111111_current_a_d_control', - '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': 'Current A/D control', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'iad', - 'unique_id': '/26.111111111111/IAD', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[26.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/26.111111111111/IAD', - 'friendly_name': '26.111111111111 Current A/D control', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.26_111111111111_current_a_d_control', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - ]) -# --- -# name: test_switches[28.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[28.111111111111].1 - list([ - ]) -# --- -# name: test_switches[28.111111111111].2 - list([ - ]) -# --- -# name: test_switches[28.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[28.222222222222].1 - list([ - ]) -# --- -# name: test_switches[28.222222222222].2 - list([ - ]) -# --- -# name: test_switches[28.222222222223] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222223', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222223', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[28.222222222223].1 - list([ - ]) -# --- -# name: test_switches[28.222222222223].2 - list([ - ]) -# --- -# name: test_switches[29.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '29.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2408', - 'model_id': None, - 'name': '29.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[29.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_0', - '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': 'Programmed input-output 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_1', - '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': 'Programmed input-output 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_2', - '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': 'Programmed input-output 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_3', - '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': 'Programmed input-output 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.3', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_4', - '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': 'Programmed input-output 4', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.4', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_5', - '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': 'Programmed input-output 5', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.5', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_6', - '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': 'Programmed input-output 6', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.6', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_7', - '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': 'Programmed input-output 7', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.7', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_0', - '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': 'Latch 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_1', - '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': 'Latch 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_2', - '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': 'Latch 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_3', - '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': 'Latch 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.3', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_4', - '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': 'Latch 4', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.4', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_5', - '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': 'Latch 5', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.5', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_6', - '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': 'Latch 6', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.6', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_7', - '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': 'Latch 7', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.7', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[29.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.0', - 'friendly_name': '29.111111111111 Programmed input-output 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.1', - 'friendly_name': '29.111111111111 Programmed input-output 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.2', - 'friendly_name': '29.111111111111 Programmed input-output 2', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.3', - 'friendly_name': '29.111111111111 Programmed input-output 3', - 'raw_value': None, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.4', - 'friendly_name': '29.111111111111 Programmed input-output 4', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.5', - 'friendly_name': '29.111111111111 Programmed input-output 5', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.6', - 'friendly_name': '29.111111111111 Programmed input-output 6', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_6', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.7', - 'friendly_name': '29.111111111111 Programmed input-output 7', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_7', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.0', - 'friendly_name': '29.111111111111 Latch 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.1', - 'friendly_name': '29.111111111111 Latch 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.2', - 'friendly_name': '29.111111111111 Latch 2', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.3', - 'friendly_name': '29.111111111111 Latch 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.4', - 'friendly_name': '29.111111111111 Latch 4', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.5', - 'friendly_name': '29.111111111111 Latch 5', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.6', - 'friendly_name': '29.111111111111 Latch 6', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_6', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.7', - 'friendly_name': '29.111111111111 Latch 7', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_7', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_switches[30.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '30.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2760', - 'model_id': None, - 'name': '30.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[30.111111111111].1 - list([ - ]) -# --- -# name: test_switches[30.111111111111].2 - list([ - ]) -# --- -# name: test_switches[3A.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3A.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2413', - 'model_id': None, - 'name': '3A.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[3A.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', - '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': 'Programmed input-output A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/3A.111111111111/PIO.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', - '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': 'Programmed input-output B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/3A.111111111111/PIO.B', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[3A.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/3A.111111111111/PIO.A', - 'friendly_name': '3A.111111111111 Programmed input-output A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/3A.111111111111/PIO.B', - 'friendly_name': '3A.111111111111 Programmed input-output B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_switches[3B.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3B.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1825', - 'model_id': None, - 'name': '3B.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[3B.111111111111].1 - list([ - ]) -# --- -# name: test_switches[3B.111111111111].2 - list([ - ]) -# --- -# name: test_switches[42.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '42.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS28EA00', - 'model_id': None, - 'name': '42.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[42.111111111111].1 - list([ - ]) -# --- -# name: test_switches[42.111111111111].2 - list([ - ]) -# --- -# name: test_switches[7E.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0068', - 'model_id': None, - 'name': '7E.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[7E.111111111111].1 - list([ - ]) -# --- -# name: test_switches[7E.111111111111].2 - list([ - ]) -# --- -# name: test_switches[7E.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0066', - 'model_id': None, - 'name': '7E.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[7E.222222222222].1 - list([ - ]) -# --- -# name: test_switches[7E.222222222222].2 - list([ - ]) -# --- -# name: test_switches[A6.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'A6.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': 'A6.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[A6.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.a6_111111111111_current_a_d_control', - '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': 'Current A/D control', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'iad', - 'unique_id': '/A6.111111111111/IAD', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[A6.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/A6.111111111111/IAD', - 'friendly_name': 'A6.111111111111 Current A/D control', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.a6_111111111111_current_a_d_control', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - ]) -# --- -# name: test_switches[EF.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HobbyBoards_EF', - 'model_id': None, - 'name': 'EF.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[EF.111111111111].1 - list([ - ]) -# --- -# name: test_switches[EF.111111111111].2 - list([ - ]) -# --- -# name: test_switches[EF.111111111112] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111112', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_MOISTURE_METER', - 'model_id': None, - 'name': 'EF.111111111112', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[EF.111111111112].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', - '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': 'Leaf sensor 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'leaf_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_leaf.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', - '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': 'Leaf sensor 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'leaf_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_leaf.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', - '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': 'Leaf sensor 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'leaf_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_leaf.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', - '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': 'Leaf sensor 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'leaf_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_leaf.3', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', - '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': 'Moisture sensor 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_moisture.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', - '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': 'Moisture sensor 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_moisture.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', - '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': 'Moisture sensor 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_moisture.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', - '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': 'Moisture sensor 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_moisture.3', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[EF.111111111112].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_leaf.0', - 'friendly_name': 'EF.111111111112 Leaf sensor 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_leaf.1', - 'friendly_name': 'EF.111111111112 Leaf sensor 1', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_leaf.2', - 'friendly_name': 'EF.111111111112 Leaf sensor 2', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_leaf.3', - 'friendly_name': 'EF.111111111112 Leaf sensor 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_moisture.0', - 'friendly_name': 'EF.111111111112 Moisture sensor 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_moisture.1', - 'friendly_name': 'EF.111111111112 Moisture sensor 1', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_moisture.2', - 'friendly_name': 'EF.111111111112 Moisture sensor 2', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_moisture.3', - 'friendly_name': 'EF.111111111112 Moisture sensor 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_switches[EF.111111111113] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111113', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_HUB', - 'model_id': None, - 'name': 'EF.111111111113', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[EF.111111111113].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111113_hub_branch_0', - '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': 'Hub branch 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_branch_id', - 'unique_id': '/EF.111111111113/hub/branch.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111113_hub_branch_1', - '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': 'Hub branch 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_branch_id', - 'unique_id': '/EF.111111111113/hub/branch.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111113_hub_branch_2', - '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': 'Hub branch 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_branch_id', - 'unique_id': '/EF.111111111113/hub/branch.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111113_hub_branch_3', - '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': 'Hub branch 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_branch_id', - 'unique_id': '/EF.111111111113/hub/branch.3', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[EF.111111111113].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111113/hub/branch.0', - 'friendly_name': 'EF.111111111113 Hub branch 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111113_hub_branch_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111113/hub/branch.1', - 'friendly_name': 'EF.111111111113 Hub branch 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111113_hub_branch_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111113/hub/branch.2', - 'friendly_name': 'EF.111111111113 Hub branch 2', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111113_hub_branch_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111113/hub/branch.3', - 'friendly_name': 'EF.111111111113 Hub branch 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111113_hub_branch_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) +# name: test_switches[switch.05_111111111111_programmed_input_output-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.05_111111111111_programmed_input_output', + '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': 'Programmed input-output', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio', + 'unique_id': '/05.111111111111/PIO', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.05_111111111111_programmed_input_output-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/05.111111111111/PIO', + 'friendly_name': '05.111111111111 Programmed input-output', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.05_111111111111_programmed_input_output', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.12_111111111111_latch_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_latch_a', + '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': 'Latch A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/12.111111111111/latch.A', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12_111111111111_latch_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/latch.A', + 'friendly_name': '12.111111111111 Latch A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_latch_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.12_111111111111_latch_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_latch_b', + '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': 'Latch B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/12.111111111111/latch.B', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12_111111111111_latch_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/latch.B', + 'friendly_name': '12.111111111111 Latch B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_latch_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12_111111111111_programmed_input_output_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_programmed_input_output_a', + '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': 'Programmed input-output A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/12.111111111111/PIO.A', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12_111111111111_programmed_input_output_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/PIO.A', + 'friendly_name': '12.111111111111 Programmed input-output A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_programmed_input_output_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.12_111111111111_programmed_input_output_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_programmed_input_output_b', + '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': 'Programmed input-output B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/12.111111111111/PIO.B', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12_111111111111_programmed_input_output_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/PIO.B', + 'friendly_name': '12.111111111111 Programmed input-output B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_programmed_input_output_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.26_111111111111_current_a_d_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.26_111111111111_current_a_d_control', + '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': 'Current A/D control', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'iad', + 'unique_id': '/26.111111111111/IAD', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.26_111111111111_current_a_d_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/26.111111111111/IAD', + 'friendly_name': '26.111111111111 Current A/D control', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.26_111111111111_current_a_d_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_0', + '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': 'Latch 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.0', + 'friendly_name': '29.111111111111 Latch 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_1', + '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': 'Latch 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.1', + 'friendly_name': '29.111111111111 Latch 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_2', + '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': 'Latch 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.2', + 'friendly_name': '29.111111111111 Latch 2', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_3', + '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': 'Latch 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.3', + 'friendly_name': '29.111111111111 Latch 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_4', + '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': 'Latch 4', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.4', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.4', + 'friendly_name': '29.111111111111 Latch 4', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_5', + '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': 'Latch 5', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.5', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.5', + 'friendly_name': '29.111111111111 Latch 5', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_6', + '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': 'Latch 6', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.6', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.6', + 'friendly_name': '29.111111111111 Latch 6', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_7', + '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': 'Latch 7', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.7', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.7', + 'friendly_name': '29.111111111111 Latch 7', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_0', + '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': 'Programmed input-output 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.0', + 'friendly_name': '29.111111111111 Programmed input-output 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_1', + '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': 'Programmed input-output 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.1', + 'friendly_name': '29.111111111111 Programmed input-output 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_2', + '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': 'Programmed input-output 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.2', + 'friendly_name': '29.111111111111 Programmed input-output 2', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_3', + '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': 'Programmed input-output 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.3', + 'friendly_name': '29.111111111111 Programmed input-output 3', + 'raw_value': None, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_4', + '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': 'Programmed input-output 4', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.4', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.4', + 'friendly_name': '29.111111111111 Programmed input-output 4', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_5', + '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': 'Programmed input-output 5', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.5', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.5', + 'friendly_name': '29.111111111111 Programmed input-output 5', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_6', + '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': 'Programmed input-output 6', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.6', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.6', + 'friendly_name': '29.111111111111 Programmed input-output 6', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_7', + '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': 'Programmed input-output 7', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.7', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.7', + 'friendly_name': '29.111111111111 Programmed input-output 7', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.3a_111111111111_programmed_input_output_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', + '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': 'Programmed input-output A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/3A.111111111111/PIO.A', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.3a_111111111111_programmed_input_output_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/PIO.A', + 'friendly_name': '3A.111111111111 Programmed input-output A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.3a_111111111111_programmed_input_output_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', + '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': 'Programmed input-output B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/3A.111111111111/PIO.B', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.3a_111111111111_programmed_input_output_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/PIO.B', + 'friendly_name': '3A.111111111111 Programmed input-output B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.a6_111111111111_current_a_d_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.a6_111111111111_current_a_d_control', + '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': 'Current A/D control', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'iad', + 'unique_id': '/A6.111111111111/IAD', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.a6_111111111111_current_a_d_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/A6.111111111111/IAD', + 'friendly_name': 'A6.111111111111 Current A/D control', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.a6_111111111111_current_a_d_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', + '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': 'Leaf sensor 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leaf_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_leaf.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.0', + 'friendly_name': 'EF.111111111112 Leaf sensor 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', + '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': 'Leaf sensor 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leaf_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_leaf.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.1', + 'friendly_name': 'EF.111111111112 Leaf sensor 1', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', + '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': 'Leaf sensor 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leaf_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_leaf.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.2', + 'friendly_name': 'EF.111111111112 Leaf sensor 2', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', + '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': 'Leaf sensor 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leaf_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_leaf.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.3', + 'friendly_name': 'EF.111111111112 Leaf sensor 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', + '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': 'Moisture sensor 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_moisture.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.0', + 'friendly_name': 'EF.111111111112 Moisture sensor 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', + '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': 'Moisture sensor 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_moisture.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.1', + 'friendly_name': 'EF.111111111112 Moisture sensor 1', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', + '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': 'Moisture sensor 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_moisture.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.2', + 'friendly_name': 'EF.111111111112 Moisture sensor 2', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', + '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': 'Moisture sensor 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_moisture.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.3', + 'friendly_name': 'EF.111111111112 Moisture sensor 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_0', + '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': 'Hub branch 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_branch_id', + 'unique_id': '/EF.111111111113/hub/branch.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.0', + 'friendly_name': 'EF.111111111113 Hub branch 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_1', + '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': 'Hub branch 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_branch_id', + 'unique_id': '/EF.111111111113/hub/branch.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.1', + 'friendly_name': 'EF.111111111113 Hub branch 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_2', + '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': 'Hub branch 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_branch_id', + 'unique_id': '/EF.111111111113/hub/branch.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.2', + 'friendly_name': 'EF.111111111113 Hub branch 2', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_3', + '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': 'Hub branch 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_branch_id', + 'unique_id': '/EF.111111111113/hub/branch.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.3', + 'friendly_name': 'EF.111111111113 Hub branch 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- diff --git a/tests/components/onewire/test_binary_sensor.py b/tests/components/onewire/test_binary_sensor.py index 532b3ffec3a..fb50c9dc367 100644 --- a/tests/components/onewire/test_binary_sensor.py +++ b/tests/components/onewire/test_binary_sensor.py @@ -8,11 +8,12 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_owproxy_mock_devices +from .const import MOCK_OWPROXY_DEVICES -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform @pytest.fixture(autouse=True) @@ -22,39 +23,16 @@ def override_platforms() -> Generator[None]: yield +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock, - device_id: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test for 1-Wire binary sensors.""" - setup_owproxy_mock_devices(owproxy, [device_id]) + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - # Ensure devices are correctly registered - device_entries = dr.async_entries_for_config_entry( - device_registry, config_entry.entry_id - ) - assert device_entries == snapshot - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - setup_owproxy_mock_devices(owproxy, [device_id]) - # Some entities are disabled, enable them and reload before checking states - for ent in entity_entries: - entity_registry.async_update_entity(ent.entity_id, disabled_by=None) - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 6027891f883..e417eea8748 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch from pyownet import protocol import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.onewire.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -14,6 +15,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import setup_owproxy_mock_devices +from .const import MOCK_OWPROXY_DEVICES from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -80,6 +82,26 @@ async def test_update_options( assert owproxy.call_count == 2 +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_registry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device are correctly registered.""" + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) + await hass.config_entries.async_setup(config_entry.entry_id) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries + for device_entry in device_entries: + assert device_entry == snapshot(name=f"{device_entry.name}-entry") + + @patch("homeassistant.components.onewire._PLATFORMS", [Platform.SENSOR]) async def test_registry_cleanup( hass: HomeAssistant, diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index bce7ebf9191..9b0d4ea8ca6 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -11,12 +11,12 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_owproxy_mock_devices from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform @pytest.fixture(autouse=True) @@ -26,42 +26,19 @@ def override_platforms() -> Generator[None]: yield +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock, - device_id: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test for 1-Wire sensors.""" - setup_owproxy_mock_devices(owproxy, [device_id]) + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - # Ensure devices are correctly registered - device_entries = dr.async_entries_for_config_entry( - device_registry, config_entry.entry_id - ) - assert device_entries == snapshot - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - setup_owproxy_mock_devices(owproxy, [device_id]) - # Some entities are disabled, enable them and reload before checking states - for ent in entity_entries: - entity_registry.async_update_entity(ent.entity_id, disabled_by=None) - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.parametrize("device_id", ["12.111111111111"]) diff --git a/tests/components/onewire/test_switch.py b/tests/components/onewire/test_switch.py index 7df4beec427..6bd76d89184 100644 --- a/tests/components/onewire/test_switch.py +++ b/tests/components/onewire/test_switch.py @@ -15,11 +15,12 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_owproxy_mock_devices +from .const import MOCK_OWPROXY_DEVICES -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform @pytest.fixture(autouse=True) @@ -29,42 +30,19 @@ def override_platforms() -> Generator[None]: yield +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_switches( hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock, - device_id: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test for 1-Wire switches.""" - setup_owproxy_mock_devices(owproxy, [device_id]) + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - # Ensure devices are correctly registered - device_entries = dr.async_entries_for_config_entry( - device_registry, config_entry.entry_id - ) - assert device_entries == snapshot - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - setup_owproxy_mock_devices(owproxy, [device_id]) - # Some entities are disabled, enable them and reload before checking states - for ent in entity_entries: - entity_registry.async_update_entity(ent.entity_id, disabled_by=None) - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.parametrize("device_id", ["05.111111111111"]) From 4a33b1d936aa34bbddeebf3f31a0d5826e5fa04a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jan 2025 12:15:32 +0100 Subject: [PATCH 0225/2987] Set PARALLEL_UPDATES to 0 in onewire (#135178) --- homeassistant/components/onewire/binary_sensor.py | 4 +++- homeassistant/components/onewire/sensor.py | 4 +++- homeassistant/components/onewire/switch.py | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 5d3c71b5eae..c8127980d5e 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -19,7 +19,9 @@ from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_ from .entity import OneWireEntity, OneWireEntityDescription from .onewirehub import OneWireConfigEntry, OneWireHub -PARALLEL_UPDATES = 1 +# the library uses non-persistent connections +# and concurrent access to the bus is managed by the server +PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index e345550c265..e019a064e3d 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -41,7 +41,9 @@ from .const import ( from .entity import OneWireEntity, OneWireEntityDescription from .onewirehub import OneWireConfigEntry, OneWireHub -PARALLEL_UPDATES = 1 +# the library uses non-persistent connections +# and concurrent access to the bus is managed by the server +PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 57f4f41924e..645a3b5f2ea 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -16,7 +16,9 @@ from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_ from .entity import OneWireEntity, OneWireEntityDescription from .onewirehub import OneWireConfigEntry, OneWireHub -PARALLEL_UPDATES = 1 +# the library uses non-persistent connections +# and concurrent access to the bus is managed by the server +PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=30) From 8e28b7b49b670c67b400148fce91a7d74a4ae037 Mon Sep 17 00:00:00 2001 From: beginner2047 <165547380+beginner2047@users.noreply.github.com> Date: Thu, 9 Jan 2025 11:16:54 +0000 Subject: [PATCH 0226/2987] Add yue language support to Google Translate TTS (#134480) --- homeassistant/components/google_translate/const.py | 1 + homeassistant/components/google_translate/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_translate/const.py b/homeassistant/components/google_translate/const.py index ed9709d2811..ab0291bc58f 100644 --- a/homeassistant/components/google_translate/const.py +++ b/homeassistant/components/google_translate/const.py @@ -88,6 +88,7 @@ SUPPORT_LANGUAGES = [ "uk", "ur", "vi", + "yue", # dialects "zh-CN", "zh-cn", diff --git a/homeassistant/components/google_translate/manifest.json b/homeassistant/components/google_translate/manifest.json index 7074d0ed444..b5b1f670675 100644 --- a/homeassistant/components/google_translate/manifest.json +++ b/homeassistant/components/google_translate/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_translate", "iot_class": "cloud_push", "loggers": ["gtts"], - "requirements": ["gTTS==2.2.4"] + "requirements": ["gTTS==2.5.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index dd98c845c12..da85ef69c6d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -959,7 +959,7 @@ fritzconnection[qr]==1.14.0 fyta_cli==0.7.0 # homeassistant.components.google_translate -gTTS==2.2.4 +gTTS==2.5.3 # homeassistant.components.gardena_bluetooth gardena-bluetooth==1.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 24f94facdf4..fe203064278 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -812,7 +812,7 @@ fritzconnection[qr]==1.14.0 fyta_cli==0.7.0 # homeassistant.components.google_translate -gTTS==2.2.4 +gTTS==2.5.3 # homeassistant.components.gardena_bluetooth gardena-bluetooth==1.5.0 From 1550086dd6368969d78f413026325819593e07aa Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 9 Jan 2025 11:37:32 +0000 Subject: [PATCH 0227/2987] Fix stale docstrings in tplink integration (#135183) --- homeassistant/components/tplink/binary_sensor.py | 2 +- homeassistant/components/tplink/number.py | 8 ++++---- homeassistant/components/tplink/select.py | 4 ++-- homeassistant/components/tplink/siren.py | 4 ++-- homeassistant/components/tplink/switch.py | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py index 34f32ca3954..e3e27d2d1a4 100644 --- a/homeassistant/components/tplink/binary_sensor.py +++ b/homeassistant/components/tplink/binary_sensor.py @@ -23,7 +23,7 @@ from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescripti class TPLinkBinarySensorEntityDescription( BinarySensorEntityDescription, TPLinkFeatureEntityDescription ): - """Base class for a TPLink feature based sensor entity description.""" + """Base class for a TPLink feature based binary sensor entity description.""" # Coordinator is used to centralize the data updates diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py index 464597fd249..7bd56067f20 100644 --- a/homeassistant/components/tplink/number.py +++ b/homeassistant/components/tplink/number.py @@ -31,7 +31,7 @@ _LOGGER = logging.getLogger(__name__) class TPLinkNumberEntityDescription( NumberEntityDescription, TPLinkFeatureEntityDescription ): - """Base class for a TPLink feature based sensor entity description.""" + """Base class for a TPLink feature based number entity description.""" # Coordinator is used to centralize the data updates @@ -74,7 +74,7 @@ async def async_setup_entry( config_entry: TPLinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up sensors.""" + """Set up number entities.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator children_coordinators = data.children_coordinators @@ -93,7 +93,7 @@ async def async_setup_entry( class TPLinkNumberEntity(CoordinatedTPLinkFeatureEntity, NumberEntity): - """Representation of a feature-based TPLink sensor.""" + """Representation of a feature-based TPLink number entity.""" entity_description: TPLinkNumberEntityDescription @@ -106,7 +106,7 @@ class TPLinkNumberEntity(CoordinatedTPLinkFeatureEntity, NumberEntity): description: TPLinkFeatureEntityDescription, parent: Device | None = None, ) -> None: - """Initialize the a switch.""" + """Initialize the number entity.""" super().__init__( device, coordinator, feature=feature, description=description, parent=parent ) diff --git a/homeassistant/components/tplink/select.py b/homeassistant/components/tplink/select.py index 2c46bba8671..c41b4b5f54c 100644 --- a/homeassistant/components/tplink/select.py +++ b/homeassistant/components/tplink/select.py @@ -24,7 +24,7 @@ from .entity import ( class TPLinkSelectEntityDescription( SelectEntityDescription, TPLinkFeatureEntityDescription ): - """Base class for a TPLink feature based sensor entity description.""" + """Base class for a TPLink feature based select entity description.""" # Coordinator is used to centralize the data updates @@ -51,7 +51,7 @@ async def async_setup_entry( config_entry: TPLinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up sensors.""" + """Set up select entities.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator children_coordinators = data.children_coordinators diff --git a/homeassistant/components/tplink/siren.py b/homeassistant/components/tplink/siren.py index 400ca5248b3..bd1bfcead6d 100644 --- a/homeassistant/components/tplink/siren.py +++ b/homeassistant/components/tplink/siren.py @@ -1,4 +1,4 @@ -"""Support for TPLink hub alarm.""" +"""Support for TPLink siren entity.""" from __future__ import annotations @@ -35,7 +35,7 @@ async def async_setup_entry( class TPLinkSirenEntity(CoordinatedTPLinkEntity, SirenEntity): - """Representation of a tplink hub alarm.""" + """Representation of a tplink siren entity.""" _attr_name = None _attr_supported_features = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index dcaef87bf35..86efa39b7be 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) class TPLinkSwitchEntityDescription( SwitchEntityDescription, TPLinkFeatureEntityDescription ): - """Base class for a TPLink feature based sensor entity description.""" + """Base class for a TPLink feature based switch entity description.""" # Coordinator is used to centralize the data updates From e9616f38d85cb8d8dc655ba4b17ed4bd6e6319aa Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jan 2025 12:41:29 +0100 Subject: [PATCH 0228/2987] Update scaffold to use internal _PLATFORM constant (#135177) --- .../scaffold/templates/config_flow/integration/__init__.py | 6 +++--- .../templates/config_flow_discovery/integration/__init__.py | 6 +++--- .../templates/config_flow_oauth2/integration/__init__.py | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/script/scaffold/templates/config_flow/integration/__init__.py b/script/scaffold/templates/config_flow/integration/__init__.py index 0b752e71013..11759c48cf3 100644 --- a/script/scaffold/templates/config_flow/integration/__init__.py +++ b/script/scaffold/templates/config_flow/integration/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. -PLATFORMS: list[Platform] = [Platform.LIGHT] +_PLATFORMS: list[Platform] = [Platform.LIGHT] # TODO Create ConfigEntry type alias with API object # TODO Rename type alias and update all entry annotations @@ -24,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> # TODO 3. Store an API object for your platforms to access # entry.runtime_data = MyAPI(...) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True @@ -32,4 +32,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> # TODO Update entry annotation async def async_unload_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/script/scaffold/templates/config_flow_discovery/integration/__init__.py b/script/scaffold/templates/config_flow_discovery/integration/__init__.py index 06b91f51949..ba56b958273 100644 --- a/script/scaffold/templates/config_flow_discovery/integration/__init__.py +++ b/script/scaffold/templates/config_flow_discovery/integration/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] # TODO Create ConfigEntry type alias with API object # Alias name should be prefixed by integration name @@ -24,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # TODO 3. Store an API object for your platforms to access # entry.runtime_data = MyAPI(...) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True @@ -32,4 +32,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # TODO Update entry annotation async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py index b8403392471..8eaf8b0e25a 100644 --- a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py +++ b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py @@ -11,7 +11,7 @@ from . import api # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. -PLATFORMS: list[Platform] = [Platform.LIGHT] +_PLATFORMS: list[Platform] = [Platform.LIGHT] # TODO Create ConfigEntry type alias with ConfigEntryAuth or AsyncConfigEntryAuth object # TODO Rename type alias and update all entry annotations @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> aiohttp_client.async_get_clientsession(hass), session ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True @@ -45,4 +45,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> # TODO Update entry annotation async def async_unload_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) From c4ac648a2bbcdb70f37f382f8c0b913b5b61c311 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jan 2025 12:45:49 +0100 Subject: [PATCH 0229/2987] Add select platform to onewire (#135181) * Add select platform to onewire * Add tests * Apply suggestions from code review --- homeassistant/components/onewire/__init__.py | 1 + homeassistant/components/onewire/select.py | 95 +++++++++++++++++++ homeassistant/components/onewire/strings.json | 11 +++ tests/components/onewire/const.py | 1 + .../onewire/snapshots/test_select.ambr | 62 ++++++++++++ tests/components/onewire/test_select.py | 67 +++++++++++++ 6 files changed, 237 insertions(+) create mode 100644 homeassistant/components/onewire/select.py create mode 100644 tests/components/onewire/snapshots/test_select.ambr create mode 100644 tests/components/onewire/test_select.py diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index 17c772121d0..038a06fe8d2 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -16,6 +16,7 @@ _LOGGER = logging.getLogger(__name__) _PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/onewire/select.py b/homeassistant/components/onewire/select.py new file mode 100644 index 00000000000..e2ee3b9222c --- /dev/null +++ b/homeassistant/components/onewire/select.py @@ -0,0 +1,95 @@ +"""Support for 1-Wire environment select entities.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import os + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import READ_MODE_INT +from .entity import OneWireEntity, OneWireEntityDescription +from .onewirehub import OneWireConfigEntry, OneWireHub + +# the library uses non-persistent connections +# and concurrent access to the bus is managed by the server +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=30) + + +@dataclass(frozen=True) +class OneWireSelectEntityDescription(OneWireEntityDescription, SelectEntityDescription): + """Class describing OneWire select entities.""" + + +ENTITY_DESCRIPTIONS: dict[str, tuple[OneWireEntityDescription, ...]] = { + "28": ( + OneWireSelectEntityDescription( + key="tempres", + entity_category=EntityCategory.CONFIG, + read_mode=READ_MODE_INT, + options=["9", "10", "11", "12"], + translation_key="tempres", + ), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OneWireConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up 1-Wire platform.""" + entities = await hass.async_add_executor_job( + get_entities, config_entry.runtime_data + ) + async_add_entities(entities, True) + + +def get_entities(onewire_hub: OneWireHub) -> list[OneWireSelectEntity]: + """Get a list of entities.""" + if not onewire_hub.devices: + return [] + + entities: list[OneWireSelectEntity] = [] + + for device in onewire_hub.devices: + family = device.family + device_id = device.id + device_info = device.device_info + + if family not in ENTITY_DESCRIPTIONS: + continue + for description in ENTITY_DESCRIPTIONS[family]: + device_file = os.path.join(os.path.split(device.path)[0], description.key) + entities.append( + OneWireSelectEntity( + description=description, + device_id=device_id, + device_file=device_file, + device_info=device_info, + owproxy=onewire_hub.owproxy, + ) + ) + + return entities + + +class OneWireSelectEntity(OneWireEntity, SelectEntity): + """Implementation of a 1-Wire switch.""" + + entity_description: OneWireSelectEntityDescription + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return str(self._state) + + def select_option(self, option: str) -> None: + """Change the selected option.""" + self._write_value(option.encode("ascii")) diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index cd8615dc5aa..9613a927f8d 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -41,6 +41,17 @@ "name": "Hub short on branch {id}" } }, + "select": { + "tempres": { + "name": "Temperature resolution", + "state": { + "9": "9 bits (0.5°C, fastest, up to 93.75ms)", + "10": "10 bits (0.25°C, up to 187.5ms)", + "11": "11 bits (0.125°C, up to 375ms)", + "12": "12 bits (0.0625°C, slowest, up to 750ms)" + } + } + }, "sensor": { "counter_id": { "name": "Counter {id}" diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index 0ce725d1a0a..4c05442eadc 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -92,6 +92,7 @@ MOCK_OWPROXY_DEVICES = { ATTR_INJECT_READS: { "/type": [b"DS18B20"], "/temperature": [b" 26.984"], + "/tempres": [b" 12"], }, }, "28.222222222222": { diff --git a/tests/components/onewire/snapshots/test_select.ambr b/tests/components/onewire/snapshots/test_select.ambr new file mode 100644 index 00000000000..7c4027cd046 --- /dev/null +++ b/tests/components/onewire/snapshots/test_select.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_selects[select.28_111111111111_temperature_resolution-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '9', + '10', + '11', + '12', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.28_111111111111_temperature_resolution', + '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': 'Temperature resolution', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tempres', + 'unique_id': '/28.111111111111/tempres', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.28_111111111111_temperature_resolution-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/28.111111111111/tempres', + 'friendly_name': '28.111111111111 Temperature resolution', + 'options': list([ + '9', + '10', + '11', + '12', + ]), + 'raw_value': 12.0, + }), + 'context': , + 'entity_id': 'select.28_111111111111_temperature_resolution', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12', + }) +# --- diff --git a/tests/components/onewire/test_select.py b/tests/components/onewire/test_select.py new file mode 100644 index 00000000000..0a594e2c076 --- /dev/null +++ b/tests/components/onewire/test_select.py @@ -0,0 +1,67 @@ +"""Tests for 1-Wire selects.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_owproxy_mock_devices +from .const import MOCK_OWPROXY_DEVICES + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.SELECT]): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_selects( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test for 1-Wire select entities.""" + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) + await hass.config_entries.async_setup(config_entry.entry_id) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize("device_id", ["28.111111111111"]) +async def test_selection_option_service( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, +) -> None: + """Test for 1-Wire select option service.""" + setup_owproxy_mock_devices(owproxy, [device_id]) + await hass.config_entries.async_setup(config_entry.entry_id) + + entity_id = "select.28_111111111111_temperature_resolution" + assert hass.states.get(entity_id).state == "12" + + # Test SELECT_OPTION service + owproxy.return_value.read.side_effect = [b" 9"] + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "9"}, + blocking=True, + ) + assert hass.states.get(entity_id).state == "9" From d7315f4500a4087b7193f7d37c8ee38fdd9c458d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 9 Jan 2025 12:48:09 +0100 Subject: [PATCH 0230/2987] Add event entities to Overseerr (#134975) --- .../components/overseerr/__init__.py | 6 +- homeassistant/components/overseerr/const.py | 46 ++++---- homeassistant/components/overseerr/event.py | 99 ++++++++++++++++ .../components/overseerr/strings.json | 13 +++ .../overseerr/fixtures/webhook_config.json | 2 +- ...ebhook_request_automatically_approved.json | 18 ++- .../overseerr/snapshots/test_event.ambr | 86 ++++++++++++++ tests/components/overseerr/test_event.py | 109 ++++++++++++++++++ 8 files changed, 342 insertions(+), 37 deletions(-) create mode 100644 homeassistant/components/overseerr/event.py create mode 100644 tests/components/overseerr/snapshots/test_event.ambr create mode 100644 tests/components/overseerr/test_event.py diff --git a/homeassistant/components/overseerr/__init__.py b/homeassistant/components/overseerr/__init__.py index c16b02739ed..704bf99c147 100644 --- a/homeassistant/components/overseerr/__init__.py +++ b/homeassistant/components/overseerr/__init__.py @@ -17,14 +17,15 @@ from homeassistant.components.webhook import ( from homeassistant.const import CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.http import HomeAssistantView from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, JSON_PAYLOAD, LOGGER, REGISTERED_NOTIFICATIONS +from .const import DOMAIN, EVENT_KEY, JSON_PAYLOAD, LOGGER, REGISTERED_NOTIFICATIONS from .coordinator import OverseerrConfigEntry, OverseerrCoordinator from .services import setup_services -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -129,6 +130,7 @@ class OverseerrWebhookManager: LOGGER.debug("Received webhook payload: %s", data) if data["notification_type"].startswith("MEDIA"): await self.entry.runtime_data.async_refresh() + async_dispatcher_send(hass, EVENT_KEY, data) return HomeAssistantView.json({"message": "ok"}) async def unregister_webhook(self) -> None: diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py index 48f5436c336..5c33ca3fcec 100644 --- a/homeassistant/components/overseerr/const.py +++ b/homeassistant/components/overseerr/const.py @@ -14,6 +14,8 @@ ATTR_STATUS = "status" ATTR_SORT_ORDER = "sort_order" ATTR_REQUESTED_BY = "requested_by" +EVENT_KEY = f"{DOMAIN}_event" + REGISTERED_NOTIFICATIONS = ( NotificationType.REQUEST_PENDING_APPROVAL | NotificationType.REQUEST_APPROVED @@ -23,28 +25,24 @@ REGISTERED_NOTIFICATIONS = ( | NotificationType.REQUEST_AUTOMATICALLY_APPROVED ) JSON_PAYLOAD = ( - '"{\\"notification_type\\":\\"{{notification_type}}\\",\\"event\\":\\"' - '{{event}}\\",\\"subject\\":\\"{{subject}}\\",\\"message\\":\\"{{messa' - 'ge}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":{\\"media_type\\"' - ':\\"{{media_type}}\\",\\"tmdbId\\":\\"{{media_tmdbid}}\\",\\"tvdbId\\' - '":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"statu' - 's4k\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":' - '\\"{{request_id}}\\",\\"requestedBy_email\\":\\"{{requestedBy_email}}' - '\\",\\"requestedBy_username\\":\\"{{requestedBy_username}}\\",\\"requ' - 'estedBy_avatar\\":\\"{{requestedBy_avatar}}\\",\\"requestedBy_setting' - 's_discordId\\":\\"{{requestedBy_settings_discordId}}\\",\\"requestedB' - 'y_settings_telegramChatId\\":\\"{{requestedBy_settings_telegramChatId' - '}}\\"},\\"{{issue}}\\":{\\"issue_id\\":\\"{{issue_id}}\\",\\"issue_ty' - 'pe\\":\\"{{issue_type}}\\",\\"issue_status\\":\\"{{issue_status}}\\",' - '\\"reportedBy_email\\":\\"{{reportedBy_email}}\\",\\"reportedBy_usern' - 'ame\\":\\"{{reportedBy_username}}\\",\\"reportedBy_avatar\\":\\"{{rep' - 'ortedBy_avatar}}\\",\\"reportedBy_settings_discordId\\":\\"{{reported' - 'By_settings_discordId}}\\",\\"reportedBy_settings_telegramChatId\\":' - '\\"{{reportedBy_settings_telegramChatId}}\\"},\\"{{comment}}\\":{\\"c' - 'omment_message\\":\\"{{comment_message}}\\",\\"commentedBy_email\\":' - '\\"{{commentedBy_email}}\\",\\"commentedBy_username\\":\\"{{commented' - 'By_username}}\\",\\"commentedBy_avatar\\":\\"{{commentedBy_avatar}}' - '\\",\\"commentedBy_settings_discordId\\":\\"{{commentedBy_settings_di' - 'scordId}}\\",\\"commentedBy_settings_telegramChatId\\":\\"{{commented' - 'By_settings_telegramChatId}}\\"},\\"{{extra}}\\":[]\\n}"' + '"{\\"notification_type\\":\\"{{notification_type}}\\",\\"subject\\":\\"{{subject}' + '}\\",\\"message\\":\\"{{message}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":' + '{\\"media_type\\":\\"{{media_type}}\\",\\"tmdb_idd\\":\\"{{media_tmdbid}}\\",\\"t' + 'vdb_id\\":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"status4k' + '\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":\\"{{request_id' + '}}\\",\\"requested_by_email\\":\\"{{requestedBy_email}}\\",\\"requested_by_userna' + 'me\\":\\"{{requestedBy_username}}\\",\\"requested_by_avatar\\":\\"{{requestedBy_a' + 'vatar}}\\",\\"requested_by_settings_discord_id\\":\\"{{requestedBy_settings_disco' + 'rdId}}\\",\\"requested_by_settings_telegram_chat_id\\":\\"{{requestedBy_settings_' + 'telegramChatId}}\\"},\\"{{issue}}\\":{\\"issue_id\\":\\"{{issue_id}}\\",\\"issue_' + 'type\\":\\"{{issue_type}}\\",\\"issue_status\\":\\"{{issue_status}}\\",\\"reporte' + 'd_by_email\\":\\"{{reportedBy_email}}\\",\\"reported_by_username\\":\\"{{reported' + 'By_username}}\\",\\"reported_by_avatar\\":\\"{{reportedBy_avatar}}\\",\\"reported' + '_by_settings_discord_id\\":\\"{{reportedBy_settings_discordId}}\\",\\"reported_by' + '_settings_telegram_chat_id\\":\\"{{reportedBy_settings_telegramChatId}}\\"},\\"{{' + 'comment}}\\":{\\"comment_message\\":\\"{{comment_message}}\\",\\"commented_by_ema' + 'il\\":\\"{{commentedBy_email}}\\",\\"commented_by_username\\":\\"{{commentedBy_us' + 'ername}}\\",\\"commented_by_avatar\\":\\"{{commentedBy_avatar}}\\",\\"commented_b' + 'y_settings_discord_id\\":\\"{{commentedBy_settings_discordId}}\\",\\"commented_by' + '_settings_telegram_chat_id\\":\\"{{commentedBy_settings_telegramChatId}}\\"}}"' ) diff --git a/homeassistant/components/overseerr/event.py b/homeassistant/components/overseerr/event.py new file mode 100644 index 00000000000..b1b2efd6ec5 --- /dev/null +++ b/homeassistant/components/overseerr/event.py @@ -0,0 +1,99 @@ +"""Support for Overseerr events.""" + +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.event import EventEntity, EventEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import EVENT_KEY +from .coordinator import OverseerrConfigEntry, OverseerrCoordinator +from .entity import OverseerrEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class OverseerrEventEntityDescription(EventEntityDescription): + """Describes Overseerr config event entity.""" + + nullable_fields: list[str] + + +EVENTS: tuple[OverseerrEventEntityDescription, ...] = ( + OverseerrEventEntityDescription( + key="media", + translation_key="last_media_event", + event_types=[ + "pending", + "approved", + "available", + "failed", + "declined", + "auto_approved", + ], + nullable_fields=["comment", "issue"], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OverseerrConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Overseerr sensor entities based on a config entry.""" + + coordinator = entry.runtime_data + async_add_entities( + OverseerrEvent(coordinator, description) for description in EVENTS + ) + + +class OverseerrEvent(OverseerrEntity, EventEntity): + """Defines a Overseerr event entity.""" + + def __init__( + self, + coordinator: OverseerrCoordinator, + description: OverseerrEventEntityDescription, + ) -> None: + """Initialize Overseerr event entity.""" + super().__init__(coordinator, description.key) + self.entity_description = description + self._attr_available = True + + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect(self.hass, EVENT_KEY, self._handle_update) + ) + + async def _handle_update(self, event: dict[str, Any]) -> None: + """Handle incoming event.""" + event_type = event["notification_type"].lower() + if event_type.split("_")[0] == self.entity_description.key: + self._trigger_event(event_type[6:], event) + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + if super().available != self._attr_available: + self._attr_available = super().available + super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._attr_available + + +def parse_event(event: dict[str, Any], nullable_fields: list[str]) -> dict[str, Any]: + """Parse event.""" + event.pop("notification_type") + for field in nullable_fields: + event.pop(field) + return event diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index 338c9d91a38..c68963247ee 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -21,6 +21,19 @@ } }, "entity": { + "event": { + "last_media_event": { + "name": "Last media event", + "state": { + "pending": "Pending", + "approved": "Approved", + "available": "Available", + "failed": "Failed", + "declined": "Declined", + "auto_approved": "Auto-approved" + } + } + }, "sensor": { "total_requests": { "name": "Total requests" diff --git a/tests/components/overseerr/fixtures/webhook_config.json b/tests/components/overseerr/fixtures/webhook_config.json index a4d07d6e9d3..40028e1f80f 100644 --- a/tests/components/overseerr/fixtures/webhook_config.json +++ b/tests/components/overseerr/fixtures/webhook_config.json @@ -2,7 +2,7 @@ "enabled": true, "types": 222, "options": { - "jsonPayload": "{\"notification_type\":\"{{notification_type}}\",\"event\":\"{{event}}\",\"subject\":\"{{subject}}\",\"message\":\"{{message}}\",\"image\":\"{{image}}\",\"{{media}}\":{\"media_type\":\"{{media_type}}\",\"tmdbId\":\"{{media_tmdbid}}\",\"tvdbId\":\"{{media_tvdbid}}\",\"status\":\"{{media_status}}\",\"status4k\":\"{{media_status4k}}\"},\"{{request}}\":{\"request_id\":\"{{request_id}}\",\"requestedBy_email\":\"{{requestedBy_email}}\",\"requestedBy_username\":\"{{requestedBy_username}}\",\"requestedBy_avatar\":\"{{requestedBy_avatar}}\",\"requestedBy_settings_discordId\":\"{{requestedBy_settings_discordId}}\",\"requestedBy_settings_telegramChatId\":\"{{requestedBy_settings_telegramChatId}}\"},\"{{issue}}\":{\"issue_id\":\"{{issue_id}}\",\"issue_type\":\"{{issue_type}}\",\"issue_status\":\"{{issue_status}}\",\"reportedBy_email\":\"{{reportedBy_email}}\",\"reportedBy_username\":\"{{reportedBy_username}}\",\"reportedBy_avatar\":\"{{reportedBy_avatar}}\",\"reportedBy_settings_discordId\":\"{{reportedBy_settings_discordId}}\",\"reportedBy_settings_telegramChatId\":\"{{reportedBy_settings_telegramChatId}}\"},\"{{comment}}\":{\"comment_message\":\"{{comment_message}}\",\"commentedBy_email\":\"{{commentedBy_email}}\",\"commentedBy_username\":\"{{commentedBy_username}}\",\"commentedBy_avatar\":\"{{commentedBy_avatar}}\",\"commentedBy_settings_discordId\":\"{{commentedBy_settings_discordId}}\",\"commentedBy_settings_telegramChatId\":\"{{commentedBy_settings_telegramChatId}}\"},\"{{extra}}\":[]\n}", + "jsonPayload": "{\"notification_type\":\"{{notification_type}}\",\"subject\":\"{{subject}}\",\"message\":\"{{message}}\",\"image\":\"{{image}}\",\"{{media}}\":{\"media_type\":\"{{media_type}}\",\"tmdb_idd\":\"{{media_tmdbid}}\",\"tvdb_id\":\"{{media_tvdbid}}\",\"status\":\"{{media_status}}\",\"status4k\":\"{{media_status4k}}\"},\"{{request}}\":{\"request_id\":\"{{request_id}}\",\"requested_by_email\":\"{{requestedBy_email}}\",\"requested_by_username\":\"{{requestedBy_username}}\",\"requested_by_avatar\":\"{{requestedBy_avatar}}\",\"requested_by_settings_discord_id\":\"{{requestedBy_settings_discordId}}\",\"requested_by_settings_telegram_chat_id\":\"{{requestedBy_settings_telegramChatId}}\"},\"{{issue}}\":{\"issue_id\":\"{{issue_id}}\",\"issue_type\":\"{{issue_type}}\",\"issue_status\":\"{{issue_status}}\",\"reported_by_email\":\"{{reportedBy_email}}\",\"reported_by_username\":\"{{reportedBy_username}}\",\"reported_by_avatar\":\"{{reportedBy_avatar}}\",\"reported_by_settings_discord_id\":\"{{reportedBy_settings_discordId}}\",\"reported_by_settings_telegram_chat_id\":\"{{reportedBy_settings_telegramChatId}}\"},\"{{comment}}\":{\"comment_message\":\"{{comment_message}}\",\"commented_by_email\":\"{{commentedBy_email}}\",\"commented_by_username\":\"{{commentedBy_username}}\",\"commented_by_avatar\":\"{{commentedBy_avatar}}\",\"commented_by_settings_discord_id\":\"{{commentedBy_settings_discordId}}\",\"commented_by_settings_telegram_chat_id\":\"{{commentedBy_settings_telegramChatId}}\"}}", "webhookUrl": "http://10.10.10.10:8123/api/webhook/test-webhook-id" } } diff --git a/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json b/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json index cc8795c9821..75059bcaf96 100644 --- a/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json +++ b/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json @@ -1,25 +1,23 @@ { "notification_type": "MEDIA_AUTO_APPROVED", - "event": "Movie Request Automatically Approved", "subject": "Something (2024)", "message": "Here is an interesting Linux ISO that was automatically approved.", "image": "https://image.tmdb.org/t/p/w600_and_h900_bestv2/something.jpg", "media": { "media_type": "movie", - "tmdbId": "123", - "tvdbId": "", + "tmdb_id": "123", + "tvdb_id": "", "status": "PENDING", "status4k": "UNKNOWN" }, "request": { "request_id": "16", - "requestedBy_email": "my@email.com", - "requestedBy_username": "henk", - "requestedBy_avatar": "https://plex.tv/users/abc/avatar?c=123", - "requestedBy_settings_discordId": "123", - "requestedBy_settings_telegramChatId": "" + "requested_by_email": "my@email.com", + "requested_by_username": "henk", + "requested_by_avatar": "https://plex.tv/users/abc/avatar?c=123", + "requested_by_settings_discord_id": "123", + "requested_by_settings_telegram_chat_id": "" }, "issue": null, - "comment": null, - "extra": [] + "comment": null } diff --git a/tests/components/overseerr/snapshots/test_event.ambr b/tests/components/overseerr/snapshots/test_event.ambr new file mode 100644 index 00000000000..9bf23efb8f6 --- /dev/null +++ b/tests/components/overseerr/snapshots/test_event.ambr @@ -0,0 +1,86 @@ +# serializer version: 1 +# name: test_entities[event.overseerr_last_media_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pending', + 'approved', + 'available', + 'failed', + 'declined', + 'auto_approved', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.overseerr_last_media_event', + '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': 'Last media event', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_media_event', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-media', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[event.overseerr_last_media_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'comment': None, + 'event_type': 'auto_approved', + 'event_types': list([ + 'pending', + 'approved', + 'available', + 'failed', + 'declined', + 'auto_approved', + ]), + 'friendly_name': 'Overseerr Last media event', + 'image': 'https://image.tmdb.org/t/p/w600_and_h900_bestv2/something.jpg', + 'issue': None, + 'media': dict({ + 'media_type': 'movie', + 'status': 'PENDING', + 'status4k': 'UNKNOWN', + 'tmdb_id': '123', + 'tvdb_id': '', + }), + 'message': 'Here is an interesting Linux ISO that was automatically approved.', + 'notification_type': 'MEDIA_AUTO_APPROVED', + 'request': dict({ + 'request_id': '16', + 'requested_by_avatar': 'https://plex.tv/users/abc/avatar?c=123', + 'requested_by_email': 'my@email.com', + 'requested_by_settings_discord_id': '123', + 'requested_by_settings_telegram_chat_id': '', + 'requested_by_username': 'henk', + }), + 'subject': 'Something (2024)', + }), + 'context': , + 'entity_id': 'event.overseerr_last_media_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:00:00.000+00:00', + }) +# --- diff --git a/tests/components/overseerr/test_event.py b/tests/components/overseerr/test_event.py new file mode 100644 index 00000000000..7ad6b53c7ed --- /dev/null +++ b/tests/components/overseerr/test_event.py @@ -0,0 +1,109 @@ +"""Tests for the Overseerr event platform.""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from future.backports.datetime import timedelta +import pytest +from python_overseerr import OverseerrConnectionError +from syrupy import SnapshotAssertion + +from homeassistant.components.overseerr import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import call_webhook, setup_integration + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) +from tests.typing import ClientSessionGenerator + + +@pytest.mark.freeze_time("2023-10-21") +async def test_entities( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.overseerr.PLATFORMS", [Platform.EVENT]): + await setup_integration(hass, mock_config_entry) + + client = await hass_client_no_auth() + + await call_webhook( + hass, + load_json_object_fixture("webhook_request_automatically_approved.json", DOMAIN), + client, + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.freeze_time("2023-10-21") +async def test_event_does_not_write_state( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test event entities don't write state on coordinator update.""" + await setup_integration(hass, mock_config_entry) + + client = await hass_client_no_auth() + + await call_webhook( + hass, + load_json_object_fixture("webhook_request_automatically_approved.json", DOMAIN), + client, + ) + await hass.async_block_till_done() + + assert hass.states.get( + "event.overseerr_last_media_event" + ).last_reported == datetime(2023, 10, 21, 0, 0, 0, tzinfo=UTC) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get( + "event.overseerr_last_media_event" + ).last_reported == datetime(2023, 10, 21, 0, 0, 0, tzinfo=UTC) + + +async def test_event_goes_unavailable( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test event entities go unavailable when we can't fetch data.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("event.overseerr_last_media_event").state != STATE_UNAVAILABLE + ) + + mock_overseerr_client.get_request_count.side_effect = OverseerrConnectionError( + "Boom" + ) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("event.overseerr_last_media_event").state == STATE_UNAVAILABLE + ) From 411d14c2ce640be6b79be4fa9ab35812c774b22c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 9 Jan 2025 13:07:03 +0100 Subject: [PATCH 0231/2987] Update title and description for setup dialog of thethingsnetwork (#134954) --- homeassistant/components/thethingsnetwork/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/thethingsnetwork/strings.json b/homeassistant/components/thethingsnetwork/strings.json index f5a4fcef8fd..8b3eb7b53c4 100644 --- a/homeassistant/components/thethingsnetwork/strings.json +++ b/homeassistant/components/thethingsnetwork/strings.json @@ -2,12 +2,12 @@ "config": { "step": { "user": { - "title": "Connect to The Things Network v3 App", - "description": "Enter the API hostname, app id and API key for your TTN application.\n\nYou can find your API key in the [The Things Network console](https://console.thethingsnetwork.org) -> Applications -> application_id -> API keys.", + "title": "Connect to The Things Network v3", + "description": "Enter the API hostname, application ID and API key to use with Home Assistant.\n\n[Read the instructions](https://www.thethingsindustries.com/docs/integrations/adding-applications/) on how to register your application and create an API key.", "data": { - "hostname": "[%key:common::config_flow::data::host%]", + "host": "[%key:common::config_flow::data::host%]", "app_id": "Application ID", - "access_key": "[%key:common::config_flow::data::api_key%]" + "api_key": "[%key:common::config_flow::data::api_key%]" } }, "reauth_confirm": { From 6a4160bcc4c147153464dd52eb010d1d93297a2d Mon Sep 17 00:00:00 2001 From: Kerey Roper Date: Thu, 9 Jan 2025 04:07:24 -0800 Subject: [PATCH 0232/2987] add support for dimming/brightening X10 lamps (#130196) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/x10/light.py | 44 ++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/x10/light.py b/homeassistant/components/x10/light.py index 23343cb0f8d..d98f1f51d54 100644 --- a/homeassistant/components/x10/light.py +++ b/homeassistant/components/x10/light.py @@ -81,9 +81,16 @@ class X10Light(LightEntity): @property def brightness(self): - """Return the brightness of the light.""" + """Return the brightness of the light, scaled to base class 0..255. + + This needs to be scaled from 0..x for use with X10 dimmers. + """ return self._brightness + def normalize_x10_brightness(self, brightness: float) -> float: + """Return calculated brightness values.""" + return int((brightness / 255) * 32) + @property def is_on(self): """Return true if light is on.""" @@ -91,11 +98,37 @@ class X10Light(LightEntity): def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - if self._is_cm11a: - x10_command(f"on {self._id}") - else: - x10_command(f"fon {self._id}") + old_brightness = self._brightness + if old_brightness == 0: + # Dim down from max if applicable, also avoids a "dim" command if an "on" is more appropriate + old_brightness = 255 self._brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + brightness_diff = self.normalize_x10_brightness( + self._brightness + ) - self.normalize_x10_brightness(old_brightness) + command_suffix = "" + # heyu has quite a messy command structure - we'll just deal with it here + if brightness_diff == 0: + if self._is_cm11a: + command_prefix = "on" + else: + command_prefix = "fon" + elif brightness_diff > 0: + if self._is_cm11a: + command_prefix = "bright" + else: + command_prefix = "fbright" + command_suffix = f" {brightness_diff}" + else: + if self._is_cm11a: + if self._state: + command_prefix = "dim" + else: + command_prefix = "dimb" + else: + command_prefix = "fdim" + command_suffix = f" {-brightness_diff}" + x10_command(f"{command_prefix} {self._id}{command_suffix}") self._state = True def turn_off(self, **kwargs: Any) -> None: @@ -104,6 +137,7 @@ class X10Light(LightEntity): x10_command(f"off {self._id}") else: x10_command(f"foff {self._id}") + self._brightness = 0 self._state = False def update(self) -> None: From 9dd7021d63cd4ddc11fb7b7b19f80d2caa63716f Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 9 Jan 2025 13:31:29 +0100 Subject: [PATCH 0233/2987] No need to set unique_id in enphase_envoy reauth step (#133615) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/enphase_envoy/config_flow.py | 134 ++++++++++-------- .../enphase_envoy/quality_scale.yaml | 3 +- .../components/enphase_envoy/strings.json | 7 + 3 files changed, 86 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 1a2186d305e..031d1883d1f 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -13,7 +13,6 @@ import voluptuous as vol from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_REAUTH, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -43,12 +42,28 @@ INSTALLER_AUTH_USERNAME = "installer" async def validate_input( - hass: HomeAssistant, host: str, username: str, password: str + hass: HomeAssistant, + host: str, + username: str, + password: str, + errors: dict[str, str], + description_placeholders: dict[str, str], ) -> Envoy: """Validate the user input allows us to connect.""" envoy = Envoy(host, get_async_client(hass, verify_ssl=False)) - await envoy.setup() - await envoy.authenticate(username=username, password=password) + try: + await envoy.setup() + await envoy.authenticate(username=username, password=password) + except INVALID_AUTH_ERRORS as e: + errors["base"] = "invalid_auth" + description_placeholders["reason"] = str(e) + except EnvoyError as e: + errors["base"] = "cannot_connect" + description_placeholders["reason"] = str(e) + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return envoy @@ -57,8 +72,6 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - _reauth_entry: ConfigEntry - def __init__(self) -> None: """Initialize an envoy flow.""" self.ip_address: str | None = None @@ -159,10 +172,43 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle configuration by re-auth.""" - self._reauth_entry = self._get_reauth_entry() - if unique_id := self._reauth_entry.unique_id: - await self.async_set_unique_id(unique_id, raise_on_progress=False) - return await self.async_step_user() + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + reauth_entry = self._get_reauth_entry() + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} + + if user_input is not None: + await validate_input( + self.hass, + reauth_entry.data[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + errors, + description_placeholders, + ) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + + serial = reauth_entry.unique_id or "-" + self.context["title_placeholders"] = { + CONF_SERIAL: serial, + CONF_HOST: reauth_entry.data[CONF_HOST], + } + description_placeholders["serial"] = serial + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self._async_generate_schema(), + description_placeholders=description_placeholders, + errors=errors, + ) def _async_envoy_name(self) -> str: """Return the name of the envoy.""" @@ -174,38 +220,20 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} - - if self.source == SOURCE_REAUTH: - host = self._reauth_entry.data[CONF_HOST] - else: - host = (user_input or {}).get(CONF_HOST) or self.ip_address or "" + host = (user_input or {}).get(CONF_HOST) or self.ip_address or "" if user_input is not None: - try: - envoy = await validate_input( - self.hass, - host, - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ) - except INVALID_AUTH_ERRORS as e: - errors["base"] = "invalid_auth" - description_placeholders = {"reason": str(e)} - except EnvoyError as e: - errors["base"] = "cannot_connect" - description_placeholders = {"reason": str(e)} - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + envoy = await validate_input( + self.hass, + host, + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + errors, + description_placeholders, + ) + if not errors: name = self._async_envoy_name() - if self.source == SOURCE_REAUTH: - return self.async_update_reload_and_abort( - self._reauth_entry, - data=self._reauth_entry.data | user_input, - ) - if not self.unique_id: await self.async_set_unique_id(envoy.serial_number) name = self._async_envoy_name() @@ -251,23 +279,15 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): host: str = user_input[CONF_HOST] username: str = user_input[CONF_USERNAME] password: str = user_input[CONF_PASSWORD] - try: - envoy = await validate_input( - self.hass, - host, - username, - password, - ) - except INVALID_AUTH_ERRORS as e: - errors["base"] = "invalid_auth" - description_placeholders = {"reason": str(e)} - except EnvoyError as e: - errors["base"] = "cannot_connect" - description_placeholders = {"reason": str(e)} - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + envoy = await validate_input( + self.hass, + host, + username, + password, + errors, + description_placeholders, + ) + if not errors: await self.async_set_unique_id(envoy.serial_number) self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( @@ -279,10 +299,12 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): }, ) + serial = reconfigure_entry.unique_id or "-" self.context["title_placeholders"] = { - CONF_SERIAL: reconfigure_entry.unique_id or "-", + CONF_SERIAL: serial, CONF_HOST: reconfigure_entry.data[CONF_HOST], } + description_placeholders["serial"] = serial suggested_values: Mapping[str, Any] = user_input or reconfigure_entry.data return self.async_show_form( diff --git a/homeassistant/components/enphase_envoy/quality_scale.yaml b/homeassistant/components/enphase_envoy/quality_scale.yaml index 4708a3cc11a..9e5b3a5921e 100644 --- a/homeassistant/components/enphase_envoy/quality_scale.yaml +++ b/homeassistant/components/enphase_envoy/quality_scale.yaml @@ -11,8 +11,7 @@ rules: config-flow-test-coverage: done config-flow: status: todo - comment: | - - async_step_reaut L160: I believe that the unique is already set when starting a reauth flow + comment: Even though redundant as explained in PR133726, add data-description fields for config-flow steps dependency-transparency: done docs-actions: status: done diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index a78d0bc032a..9747fa35a82 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -23,6 +23,13 @@ "data_description": { "host": "[%key:component::enphase_envoy::config::step::user::data_description::host%]" } + }, + "reauth_confirm": { + "description": "[%key:component::enphase_envoy::config::step::user::description%]", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { From 9dc4597f59c63550cad7912e91110ea6b4825c0a Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Thu, 9 Jan 2025 13:44:57 +0100 Subject: [PATCH 0234/2987] Update module properties on module scan for LCN (#135018) --- homeassistant/components/lcn/websocket.py | 31 ++++++++++------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py index d3268dfbf91..46df71d4235 100644 --- a/homeassistant/components/lcn/websocket.py +++ b/homeassistant/components/lcn/websocket.py @@ -423,25 +423,22 @@ async def async_create_or_update_device_in_config_entry( device_connection.is_group, ) - device_configs = [*config_entry.data[CONF_DEVICES]] - data = {**config_entry.data, CONF_DEVICES: device_configs} - for device_config in data[CONF_DEVICES]: - if tuple(device_config[CONF_ADDRESS]) == address: - break # device already in config_entry - else: - # create new device_entry - device_config = { - CONF_ADDRESS: address, - CONF_NAME: "", - CONF_HARDWARE_SERIAL: -1, - CONF_SOFTWARE_SERIAL: -1, - CONF_HARDWARE_TYPE: -1, - } - data[CONF_DEVICES].append(device_config) + device_config = { + CONF_ADDRESS: address, + CONF_NAME: "", + CONF_HARDWARE_SERIAL: -1, + CONF_SOFTWARE_SERIAL: -1, + CONF_HARDWARE_TYPE: -1, + } + + device_configs = [ + device + for device in config_entry.data[CONF_DEVICES] + if tuple(device[CONF_ADDRESS]) != address + ] + data = {**config_entry.data, CONF_DEVICES: [*device_configs, device_config]} - # update device_entry await async_update_device_config(device_connection, device_config) - hass.config_entries.async_update_entry(config_entry, data=data) From 050a17db4dc5f72322f39510726fa859b214e0a5 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 9 Jan 2025 13:45:32 +0100 Subject: [PATCH 0235/2987] Use friendly names in add_to_playlist action, fix "ID" (#134978) --- homeassistant/components/kodi/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/kodi/strings.json b/homeassistant/components/kodi/strings.json index 5b472e0c193..8d5e76df71e 100644 --- a/homeassistant/components/kodi/strings.json +++ b/homeassistant/components/kodi/strings.json @@ -41,7 +41,7 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "no_uuid": "Kodi instance does not have a unique id. This is most likely due to an old Kodi version (17.x or below). You can configure the integration manually or upgrade to a more recent Kodi version." + "no_uuid": "Kodi instance does not have a unique ID. This is most likely due to an old Kodi version (17.x or below). You can configure the integration manually or upgrade to a more recent Kodi version." } }, "device_automation": { @@ -57,15 +57,15 @@ "fields": { "media_type": { "name": "Media type", - "description": "Media type identifier. It must be one of SONG or ALBUM." + "description": "Media type identifier. It must be one of 'SONG' or 'ALBUM'." }, "media_id": { "name": "Media ID", - "description": "Unique Id of the media entry to add (`songid` or albumid`). If not defined, `media_name` and `artist_name` are needed to search the Kodi music library." + "description": "Unique ID of the media entry to add (`songid` or albumid`). If not defined, Media name and Artist name are needed to search the Kodi music library." }, "media_name": { "name": "Media name", - "description": "Optional media name for filtering media. Can be 'ALL' when `media_type` is 'ALBUM' and `artist_name` is specified, to add all songs from one artist." + "description": "Optional media name for filtering media. Can be 'ALL' when Media type is 'ALBUM' and Artist name is specified, to add all songs from one artist." }, "artist_name": { "name": "Artist name", From 8705fd85463317f89f81f9cf951247dbb5d23a5a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jan 2025 15:11:33 +0100 Subject: [PATCH 0236/2987] Avoid unnecessary executor calls in onewire (#135187) --- homeassistant/components/onewire/binary_sensor.py | 5 +---- homeassistant/components/onewire/select.py | 5 +---- homeassistant/components/onewire/sensor.py | 2 ++ homeassistant/components/onewire/switch.py | 5 +---- 4 files changed, 5 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index c8127980d5e..d9e21ce013d 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -98,10 +98,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - entities = await hass.async_add_executor_job( - get_entities, config_entry.runtime_data - ) - async_add_entities(entities, True) + async_add_entities(get_entities(config_entry.runtime_data), True) def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]: diff --git a/homeassistant/components/onewire/select.py b/homeassistant/components/onewire/select.py index e2ee3b9222c..108cc7b2ec8 100644 --- a/homeassistant/components/onewire/select.py +++ b/homeassistant/components/onewire/select.py @@ -45,10 +45,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - entities = await hass.async_add_executor_job( - get_entities, config_entry.runtime_data - ) - async_add_entities(entities, True) + async_add_entities(get_entities(config_entry.runtime_data), True) def get_entities(onewire_hub: OneWireHub) -> list[OneWireSelectEntity]: diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index e019a064e3d..6d3819f7ca3 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -357,6 +357,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" + # note: we have to go through the executor as SENSOR platform + # makes extra calls to the hub during device listing entities = await hass.async_add_executor_job( get_entities, config_entry.runtime_data, config_entry.options ) diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 645a3b5f2ea..b2cdec014f6 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -158,10 +158,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - entities = await hass.async_add_executor_job( - get_entities, config_entry.runtime_data - ) - async_add_entities(entities, True) + async_add_entities(get_entities(config_entry.runtime_data), True) def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]: From ec37e1ff8d1daac5137edf15dabbbe46886de25a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 9 Jan 2025 16:31:09 +0100 Subject: [PATCH 0237/2987] Allow to process kelvin as color_temp for mqtt basic light (#133953) --- .../components/mqtt/abbreviations.py | 3 + .../components/mqtt/light/schema_basic.py | 20 +- tests/components/mqtt/test_light.py | 238 ++++++++++++++++-- 3 files changed, 241 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 65e24d5d780..584b238b3a8 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -23,6 +23,7 @@ ABBREVIATIONS = { "clrm_stat_t": "color_mode_state_topic", "clrm_val_tpl": "color_mode_value_template", "clr_temp_cmd_t": "color_temp_command_topic", + "clr_temp_k": "color_temp_kelvin", "clr_temp_stat_t": "color_temp_state_topic", "clr_temp_tpl": "color_temp_template", "clr_temp_val_tpl": "color_temp_value_template", @@ -92,6 +93,8 @@ ABBREVIATIONS = { "min_hum": "min_humidity", "max_mirs": "max_mireds", "min_mirs": "min_mireds", + "max_k": "max_kelvin", + "min_k": "min_kelvin", "max_temp": "max_temp", "min_temp": "min_temp", "migr_discvry": "migrate_discovery", diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 159a23d14d9..632c651e3a5 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -82,6 +82,7 @@ CONF_COLOR_TEMP_COMMAND_TEMPLATE = "color_temp_command_template" CONF_COLOR_TEMP_COMMAND_TOPIC = "color_temp_command_topic" CONF_COLOR_TEMP_STATE_TOPIC = "color_temp_state_topic" CONF_COLOR_TEMP_VALUE_TEMPLATE = "color_temp_value_template" +CONF_COLOR_TEMP_KELVIN = "color_temp_kelvin" CONF_EFFECT_COMMAND_TEMPLATE = "effect_command_template" CONF_EFFECT_COMMAND_TOPIC = "effect_command_topic" CONF_EFFECT_LIST = "effect_list" @@ -93,6 +94,8 @@ CONF_HS_STATE_TOPIC = "hs_state_topic" CONF_HS_VALUE_TEMPLATE = "hs_value_template" CONF_MAX_MIREDS = "max_mireds" CONF_MIN_MIREDS = "min_mireds" +CONF_MAX_KELVIN = "max_kelvin" +CONF_MIN_KELVIN = "min_kelvin" CONF_RGB_COMMAND_TEMPLATE = "rgb_command_template" CONF_RGB_COMMAND_TOPIC = "rgb_command_topic" CONF_RGB_STATE_TOPIC = "rgb_state_topic" @@ -182,6 +185,7 @@ PLATFORM_SCHEMA_MODERN_BASIC = ( vol.Optional(CONF_COLOR_TEMP_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_COLOR_TEMP_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_COLOR_TEMP_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean, vol.Optional(CONF_EFFECT_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_EFFECT_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]), @@ -193,6 +197,8 @@ PLATFORM_SCHEMA_MODERN_BASIC = ( vol.Optional(CONF_HS_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_MAX_MIREDS): cv.positive_int, vol.Optional(CONF_MIN_MIREDS): cv.positive_int, + vol.Optional(CONF_MAX_KELVIN): cv.positive_int, + vol.Optional(CONF_MIN_KELVIN): cv.positive_int, vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_ON_COMMAND_TYPE, default=DEFAULT_ON_COMMAND_TYPE): vol.In( VALUES_ON_COMMAND_TYPE @@ -239,6 +245,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): _attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED _topic: dict[str, str | None] _payload: dict[str, str] + _color_temp_kelvin: bool _command_templates: dict[ str, Callable[[PublishPayloadType, TemplateVarsType], PublishPayloadType] ] @@ -263,16 +270,18 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" + self._color_temp_kelvin = config[CONF_COLOR_TEMP_KELVIN] self._attr_min_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(max_mireds) if (max_mireds := config.get(CONF_MAX_MIREDS)) - else DEFAULT_MIN_KELVIN + else config.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) ) self._attr_max_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(min_mireds) if (min_mireds := config.get(CONF_MIN_MIREDS)) - else DEFAULT_MAX_KELVIN + else config.get(CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN) ) + self._attr_effect_list = config.get(CONF_EFFECT_LIST) topic: dict[str, str | None] = { @@ -526,6 +535,9 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if self._optimistic_color_mode: self._attr_color_mode = ColorMode.COLOR_TEMP + if self._color_temp_kelvin: + self._attr_color_temp_kelvin = int(payload) + return self._attr_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin( int(payload) ) @@ -818,7 +830,9 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): ): ct_command_tpl = self._command_templates[CONF_COLOR_TEMP_COMMAND_TEMPLATE] color_temp = ct_command_tpl( - color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + if self._color_temp_kelvin + else color_util.color_temperature_kelvin_to_mired( kwargs[ATTR_COLOR_TEMP_KELVIN] ), None, diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index dbca09e803c..f8c66a3de1d 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -72,7 +72,7 @@ mqtt: payload_on: "on" payload_off: "off" -config with brightness and color temp +config with brightness and color temp (mired) mqtt: light: @@ -88,6 +88,23 @@ mqtt: payload_on: "on" payload_off: "off" +config with brightness and color temp (Kelvin) + +mqtt: + light: + - name: "Office Light Color Temp" + state_topic: "office/rgb1/light/status" + command_topic: "office/rgb1/light/switch" + brightness_state_topic: "office/rgb1/brightness/status" + brightness_command_topic: "office/rgb1/brightness/set" + brightness_scale: 99 + color_temp_kelvin: true + color_temp_state_topic: "office/rgb1/color_temp/status" + color_temp_command_topic: "office/rgb1/color_temp/set" + qos: 0 + payload_on: "on" + payload_off: "off" + config with brightness and effect mqtt: @@ -305,6 +322,101 @@ async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("hass_config", "min_kelvin", "max_kelvin"), + [ + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + }, + ), + ), + light.DEFAULT_MIN_KELVIN, + light.DEFAULT_MAX_KELVIN, + ), + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "min_mireds": 180, + }, + ), + ), + light.DEFAULT_MIN_KELVIN, + 5555, + ), + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "max_mireds": 400, + }, + ), + ), + 2500, + light.DEFAULT_MAX_KELVIN, + ), + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "max_kelvin": 5555, + }, + ), + ), + light.DEFAULT_MIN_KELVIN, + 5555, + ), + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "min_kelvin": 2500, + }, + ), + ), + 2500, + light.DEFAULT_MAX_KELVIN, + ), + ], +) +async def test_no_min_max_kelvin( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + min_kelvin: int, + max_kelvin: int, +) -> None: + """Test if there is no color and brightness if no topic.""" + await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test-topic", "ON") + state = hass.states.get("light.test") + assert state is not None and state.state == STATE_UNKNOWN + assert state.attributes.get(light.ATTR_MIN_COLOR_TEMP_KELVIN) == min_kelvin + assert state.attributes.get(light.ATTR_MAX_COLOR_TEMP_KELVIN) == max_kelvin + + @pytest.mark.parametrize( "hass_config", [ @@ -431,6 +543,76 @@ async def test_controlling_state_via_topic( assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + ("hass_config", "payload", "kelvin"), + [ + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_color_temp/status", + "command_topic": "test_light_color_temp/set", + "brightness_state_topic": "test_light_color_temp/brightness/status", + "brightness_command_topic": "test_light_color_temp/brightness/set", + "color_temp_state_topic": "test_light_color_temp/color_temp/status", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_kelvin": False, + } + } + }, + "300", + 3333, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_color_temp/status", + "command_topic": "test_light_color_temp/set", + "brightness_state_topic": "test_light_color_temp/brightness/status", + "brightness_command_topic": "test_light_color_temp/brightness/set", + "color_temp_state_topic": "test_light_color_temp/color_temp/status", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_kelvin": True, + } + } + }, + "3333", + 3333, + ), + ], + ids=["mireds", "kelvin"], +) +async def test_controlling_color_mode_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + payload: str, + kelvin: int, +) -> None: + """Test the controlling of the color mode state via topic.""" + color_modes = ["color_temp"] + + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get(light.ATTR_COLOR_MODE) is None + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "test_light_color_temp/status", "ON") + async_fire_mqtt_message(hass, "test_light_color_temp/brightness/status", "70") + async_fire_mqtt_message(hass, "test_light_color_temp/color_temp/status", payload) + light_state = hass.states.get("light.test") + assert light_state.attributes.get("brightness") == 70 + assert light_state.attributes["color_temp_kelvin"] == kelvin + assert light_state.attributes.get(light.ATTR_COLOR_MODE) == "color_temp" + assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes + + @pytest.mark.parametrize( "hass_config", [ @@ -1295,25 +1477,47 @@ async def test_sending_mqtt_rgbww_command_with_template( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "payload"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_color_temp/set", - "color_temp_command_topic": "test_light_color_temp/color_temp/set", - "color_temp_command_template": "{{ (1000 / value) | round(0) }}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_color_temp/set", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_command_template": "{{ (1000 / value) | round(0) }}", + "color_temp_kelvin": False, + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } } - } - } + }, + "10", + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_color_temp/set", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_command_template": "{{ (0.5 * value) | round(0) }}", + "color_temp_kelvin": True, + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + }, + "5000", + ), ], + ids=["mireds", "kelvin"], ) async def test_sending_mqtt_color_temp_command_with_template( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, payload: str ) -> None: """Test the sending of Color Temp command with template.""" mqtt_mock = await mqtt_mock_entry() @@ -1326,14 +1530,14 @@ async def test_sending_mqtt_color_temp_command_with_template( mqtt_mock.async_publish.assert_has_calls( [ call("test_light_color_temp/set", "on", 0, False), - call("test_light_color_temp/color_temp/set", "10", 0, False), + call("test_light_color_temp/color_temp/set", payload, 0, False), ], any_order=True, ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes["color_temp"] == 100 + assert state.attributes["color_temp_kelvin"] == 10000 @pytest.mark.parametrize( From 6e111d18ec71c750f9d6d59c49a7abbe2f55c0b8 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 9 Jan 2025 08:18:25 -0800 Subject: [PATCH 0238/2987] Allow unregistering LLM APIs (#135162) --- homeassistant/helpers/llm.py | 9 ++++- tests/helpers/test_llm.py | 66 +++++++++++++++++++++++++++++++++--- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 38d80d5649d..cb303f4aa65 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -85,7 +85,7 @@ def _async_get_apis(hass: HomeAssistant) -> dict[str, API]: @callback -def async_register_api(hass: HomeAssistant, api: API) -> None: +def async_register_api(hass: HomeAssistant, api: API) -> Callable[[], None]: """Register an API to be exposed to LLMs.""" apis = _async_get_apis(hass) @@ -94,6 +94,13 @@ def async_register_api(hass: HomeAssistant, api: API) -> None: apis[api.id] = api + @callback + def unregister() -> None: + """Unregister the API.""" + apis.pop(api.id) + + return unregister + async def async_get_api( hass: HomeAssistant, api_id: str, llm_context: LLMContext diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 3787526c433..5348348bb0d 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -39,6 +39,14 @@ def llm_context() -> llm.LLMContext: ) +class MyAPI(llm.API): + """Test API.""" + + async def async_get_api_instance(self, _: llm.ToolInput) -> llm.APIInstance: + """Return a list of tools.""" + return llm.APIInstance(self, "", [], llm_context) + + async def test_get_api_no_existing( hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: @@ -50,11 +58,6 @@ async def test_get_api_no_existing( async def test_register_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: """Test registering an llm api.""" - class MyAPI(llm.API): - async def async_get_api_instance(self, _: llm.ToolInput) -> llm.APIInstance: - """Return a list of tools.""" - return llm.APIInstance(self, "", [], llm_context) - api = MyAPI(hass=hass, id="test", name="Test") llm.async_register_api(hass, api) @@ -66,6 +69,59 @@ async def test_register_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> llm.async_register_api(hass, api) +async def test_unregister_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Test unregistering an llm api.""" + + unreg = llm.async_register_api(hass, MyAPI(hass=hass, id="test", name="Test")) + assert await llm.async_get_api(hass, "test", llm_context) + unreg() + with pytest.raises(HomeAssistantError): + assert await llm.async_get_api(hass, "test", llm_context) + + +async def test_reregister_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Test unregistering an llm api then re-registering with the same id.""" + + unreg = llm.async_register_api(hass, MyAPI(hass=hass, id="test", name="Test")) + assert await llm.async_get_api(hass, "test", llm_context) + unreg() + llm.async_register_api(hass, MyAPI(hass=hass, id="test", name="Test")) + assert await llm.async_get_api(hass, "test", llm_context) + + +async def test_unregister_twice( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Test unregistering an llm api twice.""" + + unreg = llm.async_register_api(hass, MyAPI(hass=hass, id="test", name="Test")) + assert await llm.async_get_api(hass, "test", llm_context) + unreg() + + # Unregistering twice is a bug that should not happen + with pytest.raises(KeyError): + unreg() + + +async def test_multiple_apis(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Test registering multiple APIs.""" + + unreg1 = llm.async_register_api(hass, MyAPI(hass=hass, id="test-1", name="Test 1")) + llm.async_register_api(hass, MyAPI(hass=hass, id="test-2", name="Test 2")) + + # Verify both Apis are registered + assert await llm.async_get_api(hass, "test-1", llm_context) + assert await llm.async_get_api(hass, "test-2", llm_context) + + # Unregister and verify only one is left + unreg1() + + with pytest.raises(HomeAssistantError): + assert await llm.async_get_api(hass, "test-1", llm_context) + + assert await llm.async_get_api(hass, "test-2", llm_context) + + async def test_call_tool_no_existing( hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: From a5f70dec967428d26e0be3ce2495588b7542a7b5 Mon Sep 17 00:00:00 2001 From: Ruslan Sayfutdinov Date: Thu, 9 Jan 2025 16:26:46 +0000 Subject: [PATCH 0239/2987] Make generated files appear as generated (#134991) --- .gitattributes | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.gitattributes b/.gitattributes index eca98fc228f..6a18819be9d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,3 +11,14 @@ *.pcm binary Dockerfile.dev linguist-language=Dockerfile + +# Generated files +CODEOWNERS linguist-generated=true +Dockerfile linguist-generated=true +homeassistant/generated/*.py linguist-generated=true +mypy.ini linguist-generated=true +requirements.txt linguist-generated=true +requirements_all.txt linguist-generated=true +requirements_test_all.txt linguist-generated=true +requirements_test_pre_commit.txt linguist-generated=true +script/hassfest/docker/Dockerfile linguist-generated=true From 1ca5f79708ed05986b9287274d3cf850557506ea Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 9 Jan 2025 16:43:38 +0000 Subject: [PATCH 0240/2987] Use typed config entry in tplink coordinator (#135182) --- homeassistant/components/tplink/__init__.py | 12 +++--- .../components/tplink/coordinator.py | 42 ++++++++++++++++--- homeassistant/components/tplink/models.py | 19 --------- 3 files changed, 42 insertions(+), 31 deletions(-) delete mode 100644 homeassistant/components/tplink/models.py diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 90f97e113ca..43f5a7da5fd 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -22,7 +22,6 @@ from kasa.iot import IotStrip from homeassistant import config_entries from homeassistant.components import network -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ALIAS, CONF_AUTHENTICATION, @@ -59,10 +58,7 @@ from .const import ( DOMAIN, PLATFORMS, ) -from .coordinator import TPLinkDataUpdateCoordinator -from .models import TPLinkData - -type TPLinkConfigEntry = ConfigEntry[TPLinkData] +from .coordinator import TPLinkConfigEntry, TPLinkData, TPLinkDataUpdateCoordinator DISCOVERY_INTERVAL = timedelta(minutes=15) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -236,7 +232,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo }, ) - parent_coordinator = TPLinkDataUpdateCoordinator(hass, device, timedelta(seconds=5)) + parent_coordinator = TPLinkDataUpdateCoordinator( + hass, device, timedelta(seconds=5), entry + ) child_coordinators: list[TPLinkDataUpdateCoordinator] = [] # The iot HS300 allows a limited number of concurrent requests and fetching the @@ -245,7 +243,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo child_coordinators = [ # The child coordinators only update energy data so we can # set a longer update interval to avoid flooding the device - TPLinkDataUpdateCoordinator(hass, child, timedelta(seconds=60)) + TPLinkDataUpdateCoordinator(hass, child, timedelta(seconds=60), entry) for child in device.children ] diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index 1c362d33746..337cad47673 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -2,38 +2,56 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging -from kasa import AuthenticationError, Device, KasaException +from kasa import AuthenticationError, Credentials, Device, KasaException -from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) + +@dataclass(slots=True) +class TPLinkData: + """Data for the tplink integration.""" + + parent_coordinator: TPLinkDataUpdateCoordinator + children_coordinators: list[TPLinkDataUpdateCoordinator] + camera_credentials: Credentials | None + live_view: bool | None + + +type TPLinkConfigEntry = ConfigEntry[TPLinkData] + REQUEST_REFRESH_DELAY = 0.35 class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): """DataUpdateCoordinator to gather data for a specific TPLink device.""" - config_entry: config_entries.ConfigEntry + config_entry: TPLinkConfigEntry def __init__( self, hass: HomeAssistant, device: Device, update_interval: timedelta, + config_entry: TPLinkConfigEntry, ) -> None: """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" self.device = device super().__init__( hass, _LOGGER, + config_entry=config_entry, name=device.host, update_interval=update_interval, # We don't want an immediate refresh since the device @@ -48,6 +66,20 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): try: await self.device.update(update_children=False) except AuthenticationError as ex: - raise ConfigEntryAuthFailed from ex + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="device_authentication", + translation_placeholders={ + "func": "update", + "exc": str(ex), + }, + ) from ex except KasaException as ex: - raise UpdateFailed(ex) from ex + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="device_error", + translation_placeholders={ + "func": "update", + "exc": str(ex), + }, + ) from ex diff --git a/homeassistant/components/tplink/models.py b/homeassistant/components/tplink/models.py deleted file mode 100644 index 389260a388b..00000000000 --- a/homeassistant/components/tplink/models.py +++ /dev/null @@ -1,19 +0,0 @@ -"""The tplink integration models.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from kasa import Credentials - -from .coordinator import TPLinkDataUpdateCoordinator - - -@dataclass(slots=True) -class TPLinkData: - """Data for the tplink integration.""" - - parent_coordinator: TPLinkDataUpdateCoordinator - children_coordinators: list[TPLinkDataUpdateCoordinator] - camera_credentials: Credentials | None - live_view: bool | None From 31719bc84c7ff6667592ef98797a42325fad8e87 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jan 2025 18:17:21 +0100 Subject: [PATCH 0241/2987] Refactor onewire hub (#135186) * Improve type hints in onewire hub * More cleanups * Improve * Get host/port from entry data * Use DeviceInfo object --- homeassistant/components/onewire/__init__.py | 8 +- .../components/onewire/config_flow.py | 11 +- .../components/onewire/onewirehub.py | 170 ++++++++---------- homeassistant/components/onewire/sensor.py | 1 - 4 files changed, 83 insertions(+), 107 deletions(-) diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index 038a06fe8d2..753960f0ae3 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -10,7 +10,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .const import DOMAIN -from .onewirehub import CannotConnect, OneWireConfigEntry, OneWireHub +from .onewirehub import OneWireConfigEntry, OneWireHub _LOGGER = logging.getLogger(__name__) @@ -24,11 +24,11 @@ _PLATFORMS = [ async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> bool: """Set up a 1-Wire proxy for a config entry.""" - onewire_hub = OneWireHub(hass) + onewire_hub = OneWireHub(hass, entry) try: - await onewire_hub.initialize(entry) + await onewire_hub.initialize() except ( - CannotConnect, # Failed to connect to the server + protocol.ConnError, # Failed to connect to the server protocol.OwnetError, # Connected to server, but failed to list the devices ) as exc: raise ConfigEntryNotReady from exc diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 31c0d35ee4b..b9f6ba77c2e 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -5,6 +5,7 @@ from __future__ import annotations from copy import deepcopy from typing import Any +from pyownet import protocol import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow @@ -24,7 +25,7 @@ from .const import ( OPTION_ENTRY_SENSOR_PRECISION, PRECISION_MAPPING_FAMILY_28, ) -from .onewirehub import CannotConnect, OneWireConfigEntry, OneWireHub +from .onewirehub import OneWireConfigEntry DATA_SCHEMA = vol.Schema( { @@ -38,11 +39,11 @@ async def validate_input( hass: HomeAssistant, data: dict[str, Any], errors: dict[str, str] ) -> None: """Validate the user input allows us to connect.""" - - hub = OneWireHub(hass) try: - await hub.connect(data[CONF_HOST], data[CONF_PORT]) - except CannotConnect: + await hass.async_add_executor_job( + protocol.proxy, data[CONF_HOST], data[CONF_PORT] + ) + except protocol.ConnError: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index 3bf4de006f5..2fd445a1ca3 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging import os -from typing import TYPE_CHECKING from pyownet import protocol @@ -19,7 +18,6 @@ from homeassistant.const import ( CONF_PORT, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -57,36 +55,34 @@ def _is_known_device(device_family: str, device_type: str | None) -> bool: class OneWireHub: """Hub to communicate with server.""" - def __init__(self, hass: HomeAssistant) -> None: + owproxy: protocol._Proxy + devices: list[OWDeviceDescription] + + def __init__(self, hass: HomeAssistant, config_entry: OneWireConfigEntry) -> None: """Initialize.""" - self.hass = hass - self.owproxy: protocol._Proxy | None = None - self.devices: list[OWDeviceDescription] | None = None + self._hass = hass + self._config_entry = config_entry - async def connect(self, host: str, port: int) -> None: - """Connect to the server.""" - try: - self.owproxy = await self.hass.async_add_executor_job( - protocol.proxy, host, port - ) - except protocol.ConnError as exc: - raise CannotConnect from exc + def _initialize(self) -> None: + """Connect to the server, and discover connected devices. - async def initialize(self, config_entry: OneWireConfigEntry) -> None: - """Initialize a config entry.""" - host = config_entry.data[CONF_HOST] - port = config_entry.data[CONF_PORT] + Needs to be run in executor. + """ + host = self._config_entry.data[CONF_HOST] + port = self._config_entry.data[CONF_PORT] _LOGGER.debug("Initializing connection to %s:%s", host, port) - await self.connect(host, port) - await self.discover_devices() - if TYPE_CHECKING: - assert self.devices - # Register discovered devices on Hub - device_registry = dr.async_get(self.hass) + self.owproxy = protocol.proxy(host, port) + self.devices = _discover_devices(self.owproxy) + + async def initialize(self) -> None: + """Initialize a config entry.""" + await self._hass.async_add_executor_job(self._initialize) + # Populate the device registry + device_registry = dr.async_get(self._hass) for device in self.devices: - device_info: DeviceInfo = device.device_info + device_info = device.device_info device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, + config_entry_id=self._config_entry.entry_id, identifiers=device_info[ATTR_IDENTIFIERS], manufacturer=device_info[ATTR_MANUFACTURER], model=device_info[ATTR_MODEL], @@ -94,79 +90,59 @@ class OneWireHub: via_device=device_info.get(ATTR_VIA_DEVICE), ) - async def discover_devices(self) -> None: - """Discover all devices.""" - if self.devices is None: - self.devices = await self.hass.async_add_executor_job( - self._discover_devices - ) - def _discover_devices( - self, path: str = "/", parent_id: str | None = None - ) -> list[OWDeviceDescription]: - """Discover all server devices.""" - devices: list[OWDeviceDescription] = [] - assert self.owproxy - for device_path in self.owproxy.dir(path): - device_id = os.path.split(os.path.split(device_path)[0])[1] - device_family = self.owproxy.read(f"{device_path}family").decode() - _LOGGER.debug("read `%sfamily`: %s", device_path, device_family) - device_type = self._get_device_type(device_path) - if not _is_known_device(device_family, device_type): - _LOGGER.warning( - "Ignoring unknown device family/type (%s/%s) found for device %s", - device_family, - device_type, - device_id, +def _discover_devices( + owproxy: protocol._Proxy, path: str = "/", parent_id: str | None = None +) -> list[OWDeviceDescription]: + """Discover all server devices.""" + devices: list[OWDeviceDescription] = [] + for device_path in owproxy.dir(path): + device_id = os.path.split(os.path.split(device_path)[0])[1] + device_family = owproxy.read(f"{device_path}family").decode() + _LOGGER.debug("read `%sfamily`: %s", device_path, device_family) + device_type = _get_device_type(owproxy, device_path) + if not _is_known_device(device_family, device_type): + _LOGGER.warning( + "Ignoring unknown device family/type (%s/%s) found for device %s", + device_family, + device_type, + device_id, + ) + continue + device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + manufacturer=DEVICE_MANUFACTURER.get(device_family, MANUFACTURER_MAXIM), + model=device_type, + name=device_id, + ) + if parent_id: + device_info[ATTR_VIA_DEVICE] = (DOMAIN, parent_id) + device = OWDeviceDescription( + device_info=device_info, + id=device_id, + family=device_family, + path=device_path, + type=device_type, + ) + devices.append(device) + if device_branches := DEVICE_COUPLERS.get(device_family): + for branch in device_branches: + devices += _discover_devices( + owproxy, f"{device_path}{branch}", device_id ) - continue - device_info: DeviceInfo = { - ATTR_IDENTIFIERS: {(DOMAIN, device_id)}, - ATTR_MANUFACTURER: DEVICE_MANUFACTURER.get( - device_family, MANUFACTURER_MAXIM - ), - ATTR_MODEL: device_type, - ATTR_NAME: device_id, - } - if parent_id: - device_info[ATTR_VIA_DEVICE] = (DOMAIN, parent_id) - device = OWDeviceDescription( - device_info=device_info, - id=device_id, - family=device_family, - path=device_path, - type=device_type, - ) - devices.append(device) - if device_branches := DEVICE_COUPLERS.get(device_family): - for branch in device_branches: - devices += self._discover_devices( - f"{device_path}{branch}", device_id - ) - return devices - - def _get_device_type(self, device_path: str) -> str | None: - """Get device model.""" - if TYPE_CHECKING: - assert self.owproxy - try: - device_type = self.owproxy.read(f"{device_path}type").decode() - except protocol.ProtocolError as exc: - _LOGGER.debug("Unable to read `%stype`: %s", device_path, exc) - return None - _LOGGER.debug("read `%stype`: %s", device_path, device_type) - if device_type == "EDS": - device_type = self.owproxy.read(f"{device_path}device_type").decode() - _LOGGER.debug("read `%sdevice_type`: %s", device_path, device_type) - if TYPE_CHECKING: - assert isinstance(device_type, str) - return device_type + return devices -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidPath(HomeAssistantError): - """Error to indicate the path is invalid.""" +def _get_device_type(owproxy: protocol._Proxy, device_path: str) -> str | None: + """Get device model.""" + try: + device_type: str = owproxy.read(f"{device_path}type").decode() + except protocol.ProtocolError as exc: + _LOGGER.debug("Unable to read `%stype`: %s", device_path, exc) + return None + _LOGGER.debug("read `%stype`: %s", device_path, device_type) + if device_type == "EDS": + device_type = owproxy.read(f"{device_path}device_type").decode() + _LOGGER.debug("read `%sdevice_type`: %s", device_path, device_type) + return device_type diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 6d3819f7ca3..0f430e1be35 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -373,7 +373,6 @@ def get_entities( return [] entities: list[OneWireSensor] = [] - assert onewire_hub.owproxy for device in onewire_hub.devices: family = device.family device_type = device.type From 07482de4abd7bf14b6433e9ea8ab8702e5cfa735 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Thu, 9 Jan 2025 19:29:17 +0200 Subject: [PATCH 0242/2987] Fix LG webOS TV init test coverage (#135194) --- homeassistant/components/webostv/__init__.py | 3 +- tests/components/webostv/test_init.py | 42 +++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index 499d0a85518..be0002cc588 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -129,8 +129,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - if not entry.update_listeners: - entry.async_on_unload(entry.add_update_listener(async_update_options)) + entry.async_on_unload(entry.add_update_listener(async_update_options)) async def async_on_stop(_event: Event) -> None: """Unregister callbacks and disconnect.""" diff --git a/tests/components/webostv/test_init.py b/tests/components/webostv/test_init.py index e2638c86f5e..ba755d80b30 100644 --- a/tests/components/webostv/test_init.py +++ b/tests/components/webostv/test_init.py @@ -5,12 +5,14 @@ from unittest.mock import Mock from aiowebostv import WebOsTvPairError import pytest -from homeassistant.components.webostv.const import DOMAIN +from homeassistant.components.media_player import ATTR_INPUT_SOURCE_LIST +from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_CLIENT_SECRET +from homeassistant.const import CONF_CLIENT_SECRET, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from . import setup_webostv +from .const import ENTITY_ID async def test_reauth_setup_entry( @@ -44,3 +46,39 @@ async def test_key_update_setup_entry( assert entry.state is ConfigEntryState.LOADED assert entry.data[CONF_CLIENT_SECRET] == "new_key" + + +async def test_update_options(hass: HomeAssistant, client) -> None: + """Test update options triggers reload.""" + config_entry = await setup_webostv(hass) + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.update_listeners is not None + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] + assert sources == ["Input01", "Input02", "Live TV"] + + # remove Input01 and reload + new_options = config_entry.options.copy() + new_options[CONF_SOURCES] = ["Input02", "Live TV"] + hass.config_entries.async_update_entry(config_entry, options=new_options) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] + assert sources == ["Input02", "Live TV"] + + +async def test_disconnect_on_stop(hass: HomeAssistant, client) -> None: + """Test we disconnect the client and clear callbacks when Home Assistants stops.""" + config_entry = await setup_webostv(hass) + + assert client.disconnect.call_count == 0 + assert client.clear_state_update_callbacks.call_count == 0 + assert config_entry.state is ConfigEntryState.LOADED + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + assert client.disconnect.call_count == 1 + assert client.clear_state_update_callbacks.call_count == 1 + assert config_entry.state is ConfigEntryState.LOADED From cabdae98e806dc45f21ea9cb55ad49e6efad6a03 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 9 Jan 2025 18:34:42 +0100 Subject: [PATCH 0243/2987] Allow to process kelvin as color_temp for mqtt json light (#133955) --- homeassistant/components/mqtt/const.py | 3 + .../components/mqtt/light/schema_basic.py | 6 +- .../components/mqtt/light/schema_json.py | 26 +- tests/components/mqtt/test_light_json.py | 242 +++++++++++++++--- 4 files changed, 235 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 9f1c55a54e0..db27495154b 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -56,12 +56,15 @@ CONF_SUPPORTED_FEATURES = "supported_features" CONF_ACTION_TEMPLATE = "action_template" CONF_ACTION_TOPIC = "action_topic" +CONF_COLOR_TEMP_KELVIN = "color_temp_kelvin" CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template" CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic" CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template" CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic" CONF_ENABLED_BY_DEFAULT = "enabled_by_default" CONF_ENTITY_PICTURE = "entity_picture" +CONF_MAX_KELVIN = "max_kelvin" +CONF_MIN_KELVIN = "min_kelvin" CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" CONF_MODE_COMMAND_TOPIC = "mode_command_topic" CONF_MODE_LIST = "modes" diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 632c651e3a5..3234e9a2986 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -51,7 +51,10 @@ import homeassistant.util.color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA from ..const import ( + CONF_COLOR_TEMP_KELVIN, CONF_COMMAND_TOPIC, + CONF_MAX_KELVIN, + CONF_MIN_KELVIN, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, @@ -82,7 +85,6 @@ CONF_COLOR_TEMP_COMMAND_TEMPLATE = "color_temp_command_template" CONF_COLOR_TEMP_COMMAND_TOPIC = "color_temp_command_topic" CONF_COLOR_TEMP_STATE_TOPIC = "color_temp_state_topic" CONF_COLOR_TEMP_VALUE_TEMPLATE = "color_temp_value_template" -CONF_COLOR_TEMP_KELVIN = "color_temp_kelvin" CONF_EFFECT_COMMAND_TEMPLATE = "effect_command_template" CONF_EFFECT_COMMAND_TOPIC = "effect_command_topic" CONF_EFFECT_LIST = "effect_list" @@ -94,8 +96,6 @@ CONF_HS_STATE_TOPIC = "hs_state_topic" CONF_HS_VALUE_TEMPLATE = "hs_value_template" CONF_MAX_MIREDS = "max_mireds" CONF_MIN_MIREDS = "min_mireds" -CONF_MAX_KELVIN = "max_kelvin" -CONF_MIN_KELVIN = "min_kelvin" CONF_RGB_COMMAND_TEMPLATE = "rgb_command_template" CONF_RGB_COMMAND_TOPIC = "rgb_command_topic" CONF_RGB_STATE_TOPIC = "rgb_state_topic" diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index f6efdd3281d..2d152ca12c8 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -61,7 +61,10 @@ from homeassistant.util.yaml import dump as yaml_dump from .. import subscription from ..config import DEFAULT_QOS, DEFAULT_RETAIN, MQTT_RW_SCHEMA from ..const import ( + CONF_COLOR_TEMP_KELVIN, CONF_COMMAND_TOPIC, + CONF_MAX_KELVIN, + CONF_MIN_KELVIN, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, @@ -203,6 +206,7 @@ _PLATFORM_SCHEMA_BASE = ( # CONF_COLOR_TEMP was deprecated with HA Core 2024.4 and will be # removed with HA Core 2025.3 vol.Optional(CONF_COLOR_TEMP, default=DEFAULT_COLOR_TEMP): cv.boolean, + vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean, vol.Optional(CONF_EFFECT, default=DEFAULT_EFFECT): cv.boolean, vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]), vol.Optional( @@ -216,6 +220,8 @@ _PLATFORM_SCHEMA_BASE = ( vol.Optional(CONF_HS, default=DEFAULT_HS): cv.boolean, vol.Optional(CONF_MAX_MIREDS): cv.positive_int, vol.Optional(CONF_MIN_MIREDS): cv.positive_int, + vol.Optional(CONF_MAX_KELVIN): cv.positive_int, + vol.Optional(CONF_MIN_KELVIN): cv.positive_int, vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_QOS, default=DEFAULT_QOS): vol.All( vol.Coerce(int), vol.In([0, 1, 2]) @@ -275,15 +281,16 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" + self._color_temp_kelvin = config[CONF_COLOR_TEMP_KELVIN] self._attr_min_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(max_mireds) if (max_mireds := config.get(CONF_MAX_MIREDS)) - else DEFAULT_MIN_KELVIN + else config.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) ) self._attr_max_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(min_mireds) if (min_mireds := config.get(CONF_MIN_MIREDS)) - else DEFAULT_MAX_KELVIN + else config.get(CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN) ) self._attr_effect_list = config.get(CONF_EFFECT_LIST) @@ -381,7 +388,9 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): try: if color_mode == ColorMode.COLOR_TEMP: self._attr_color_temp_kelvin = ( - color_util.color_temperature_mired_to_kelvin( + values["color_temp"] + if self._color_temp_kelvin + else color_util.color_temperature_mired_to_kelvin( values["color_temp"] ) ) @@ -486,7 +495,9 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_color_temp_kelvin = None else: self._attr_color_temp_kelvin = ( - color_util.color_temperature_mired_to_kelvin( + values["color_temp"] # type: ignore[assignment] + if self._color_temp_kelvin + else color_util.color_temperature_mired_to_kelvin( values["color_temp"] # type: ignore[arg-type] ) ) @@ -709,10 +720,13 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): should_update = True if ATTR_COLOR_TEMP_KELVIN in kwargs: - message["color_temp"] = color_util.color_temperature_kelvin_to_mired( + message["color_temp"] = ( kwargs[ATTR_COLOR_TEMP_KELVIN] + if self._color_temp_kelvin + else color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) ) - if self._optimistic: self._attr_color_mode = ColorMode.COLOR_TEMP self._attr_color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN] diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index c127c86de39..512e4091438 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -14,7 +14,7 @@ mqtt: rgb: true xy: true -Configuration with RGB, brightness, color temp and effect: +Configuration with RGB, brightness, color temp (mireds) and effect: mqtt: light: @@ -24,10 +24,11 @@ mqtt: command_topic: "home/rgb1/set" brightness: true color_temp: true + color_temp_kelvin: false effect: true rgb: true -Configuration with RGB, brightness and color temp: +Configuration with RGB, brightness and color temp (Kelvin): mqtt: light: @@ -38,6 +39,7 @@ mqtt: brightness: true rgb: true color_temp: true + color_temp_kelvin: true Configuration with RGB, brightness: @@ -399,24 +401,50 @@ async def test_fail_setup_if_color_modes_invalid( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "kelvin", "color_temp_payload_value"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_light/set", - "state_topic": "test_light", - "color_mode": True, - "supported_color_modes": "color_temp", + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_light/set", + "state_topic": "test_light", + "color_mode": True, + "color_temp_kelvin": False, + "supported_color_modes": "color_temp", + } } - } - } + }, + 5208, + 192, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_light/set", + "state_topic": "test_light", + "color_mode": True, + "color_temp_kelvin": True, + "supported_color_modes": "color_temp", + } + } + }, + 5208, + 5208, + ), ], + ids=["mireds", "kelvin"], ) async def test_single_color_mode( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + kelvin: int, + color_temp_payload_value: int, ) -> None: """Test setup with single color_mode.""" await mqtt_mock_entry() @@ -424,13 +452,19 @@ async def test_single_color_mode( assert state.state == STATE_UNKNOWN await common.async_turn_on( - hass, "light.test", brightness=50, color_temp_kelvin=5208 + hass, "light.test", brightness=50, color_temp_kelvin=kelvin ) + payload = { + "state": "ON", + "brightness": 50, + "color_mode": "color_temp", + "color_temp": color_temp_payload_value, + } async_fire_mqtt_message( hass, "test_light", - '{"state": "ON", "brightness": 50, "color_mode": "color_temp", "color_temp": 192}', + json_dumps(payload), ) color_modes = [light.ColorMode.COLOR_TEMP] state = hass.states.get("light.test") @@ -788,6 +822,96 @@ async def test_controlling_state_via_topic( assert light_state.attributes.get("brightness") == 128 +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "brightness": True, + "color_temp": True, + "color_temp_kelvin": True, + "effect": True, + "rgb": True, + "xy": True, + "hs": True, + "qos": "0", + } + } + } + ], +) +async def test_controlling_state_color_temp_kelvin( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the controlling of the state via topic in Kelvin mode.""" + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + color_modes = [light.ColorMode.COLOR_TEMP, light.ColorMode.HS] + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes + expected_features = ( + light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") is None + assert state.attributes.get("hs_color") is None + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + # Turn on the light + async_fire_mqtt_message( + hass, + "test_light_rgb", + '{"state":"ON",' + '"color":{"r":255,"g":255,"b":255},' + '"brightness":255,' + '"color_temp":155,' + '"effect":"colorloop"}', + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 255, 255) + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") == "colorloop" + assert state.attributes.get("xy_color") == (0.323, 0.329) + assert state.attributes.get("hs_color") == (0.0, 0.0) + + # Turn on the light + async_fire_mqtt_message( + hass, + "test_light_rgb", + '{"state":"ON",' + '"brightness":255,' + '"color":null,' + '"color_temp":6451,' # Kelvin + '"effect":"colorloop"}', + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == ( + 255, + 253, + 249, + ) # temp converted to color + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_temp_kelvin") == 6451 + assert state.attributes.get("effect") == "colorloop" + assert state.attributes.get("xy_color") == (0.328, 0.333) # temp converted to color + assert state.attributes.get("hs_color") == (44.098, 2.43) # temp converted to color + + @pytest.mark.parametrize( "hass_config", [ @@ -2591,30 +2715,82 @@ async def test_entity_debug_info_message( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "min_kelvin", "max_kelvin"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_max_mireds/set", - "color_temp": True, - "max_mireds": 370, + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "supported_color_modes": ["color_temp"], + "max_mireds": 370, # 2702 Kelvin + } } - } - } + }, + 2702, + light.DEFAULT_MAX_KELVIN, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "supported_color_modes": ["color_temp"], + "min_mireds": 150, # 6666 Kelvin + } + } + }, + light.DEFAULT_MIN_KELVIN, + 6666, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "supported_color_modes": ["color_temp"], + "min_kelvin": 2702, + } + } + }, + 2702, + light.DEFAULT_MAX_KELVIN, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "supported_color_modes": ["color_temp"], + "max_kelvin": 6666, + } + } + }, + light.DEFAULT_MIN_KELVIN, + 6666, + ), ], ) -async def test_max_mireds( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +async def test_min_max_kelvin( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + min_kelvin: int, + max_kelvin: int, ) -> None: - """Test setting min_mireds and max_mireds.""" + """Test setting min_color_temp_kelvin and max_color_temp_kelvin.""" await mqtt_mock_entry() state = hass.states.get("light.test") - assert state.attributes.get("min_mireds") == 153 - assert state.attributes.get("max_mireds") == 370 + assert state.attributes.get("min_color_temp_kelvin") == min_kelvin + assert state.attributes.get("max_color_temp_kelvin") == max_kelvin @pytest.mark.parametrize( From b6c0257c43608ec47f52a6e01e487e6fec55495c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 10 Jan 2025 03:58:12 +1000 Subject: [PATCH 0244/2987] Add streaming sensors to Teslemetry (#132783) Co-authored-by: Joost Lekkerkerker --- .../components/teslemetry/__init__.py | 16 + homeassistant/components/teslemetry/entity.py | 53 ++- .../components/teslemetry/manifest.json | 2 +- homeassistant/components/teslemetry/models.py | 5 +- homeassistant/components/teslemetry/sensor.py | 318 +++++++++++++----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/teslemetry/conftest.py | 53 ++- tests/components/teslemetry/const.py | 1 + .../teslemetry/fixtures/config.json | 10 + .../teslemetry/fixtures/metadata.json | 22 ++ .../teslemetry/snapshots/test_sensor.ambr | 32 +- tests/components/teslemetry/test_init.py | 37 +- tests/components/teslemetry/test_sensor.py | 62 +++- 14 files changed, 497 insertions(+), 118 deletions(-) create mode 100644 tests/components/teslemetry/fixtures/config.json create mode 100644 tests/components/teslemetry/fixtures/metadata.json diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 5779283b955..2d35720d1b4 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -126,13 +126,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - create_handle_vehicle_stream(vin, coordinator), {"vin": vin}, ) + firmware = vehicle_metadata[vin].get("firmware", "Unknown") vehicles.append( TeslemetryVehicleData( api=api, + config_entry=entry, coordinator=coordinator, stream=stream, vin=vin, + firmware=firmware, device=device, remove_listener=remove_listener, ) @@ -179,6 +182,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - # Run all first refreshes await asyncio.gather( + *(async_setup_stream(hass, entry, vehicle) for vehicle in vehicles), *( vehicle.coordinator.async_config_entry_first_refresh() for vehicle in vehicles @@ -265,3 +269,15 @@ def create_handle_vehicle_stream(vin: str, coordinator) -> Callable[[dict], None coordinator.async_set_updated_data(coordinator.data) return handle_vehicle_stream + + +async def async_setup_stream( + hass: HomeAssistant, entry: ConfigEntry, vehicle: TeslemetryVehicleData +): + """Set up the stream for a vehicle.""" + + vehicle_stream = vehicle.stream.get_vehicle(vehicle.vin) + await vehicle_stream.get_config() + entry.async_create_background_task( + hass, vehicle_stream.prefer_typed(True), f"Prefer typed for {vehicle.vin}" + ) diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index d14f3a42734..f2126dddf4b 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -5,9 +5,11 @@ from typing import Any from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from teslemetry_stream import Signal from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -73,11 +75,6 @@ class TeslemetryEntity( """Return if the value is a literal None.""" return self.get(self.key, False) is None - @property - def has(self) -> bool: - """Return True if a specific value is in coordinator data.""" - return self.key in self.coordinator.data - def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self._async_update_attrs() @@ -236,3 +233,49 @@ class TeslemetryWallConnectorEntity(TeslemetryEntity): return self.key in self.coordinator.data.get("wall_connectors", {}).get( self.din, {} ) + + +class TeslemetryVehicleStreamEntity(Entity): + """Parent class for Teslemetry Vehicle Stream entities.""" + + _attr_has_entity_name = True + + def __init__( + self, data: TeslemetryVehicleData, key: str, streaming_key: Signal + ) -> None: + """Initialize common aspects of a Teslemetry entity.""" + self.streaming_key = streaming_key + self.vehicle = data + + self.api = data.api + self.stream = data.stream + self.vin = data.vin + self.add_field = data.stream.get_vehicle(self.vin).add_field + + self._attr_translation_key = key + self._attr_unique_id = f"{data.vin}-{key}" + self._attr_device_info = data.device + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.stream.async_add_listener( + self._handle_stream_update, + {"vin": self.vin, "data": {self.streaming_key: None}}, + ) + ) + self.vehicle.config_entry.async_create_background_task( + self.hass, + self.add_field(self.streaming_key), + f"Adding field {self.streaming_key.value} to {self.vehicle.vin}", + ) + + def _handle_stream_update(self, data: dict[str, Any]) -> None: + """Handle updated data from the stream.""" + self._async_value_from_stream(data["data"][self.streaming_key]) + self.async_write_ha_state() + + def _async_value_from_stream(self, value: Any) -> None: + """Update the entity with the latest value from the stream.""" + raise NotImplementedError diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index a2782d25393..cf81f3bc521 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.2", "teslemetry-stream==0.4.2"] + "requirements": ["tesla-fleet-api==0.9.2", "teslemetry-stream==0.5.3"] } diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index d3969b30a7c..c2f50ab90df 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -10,6 +10,7 @@ from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope from teslemetry_stream import TeslemetryStream +from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceInfo from .coordinator import ( @@ -34,12 +35,14 @@ class TeslemetryVehicleData: """Data for a vehicle in the Teslemetry integration.""" api: VehicleSpecific + config_entry: ConfigEntry coordinator: TeslemetryVehicleDataCoordinator stream: TeslemetryStream vin: str - wakelock = asyncio.Lock() + firmware: str device: DeviceInfo remove_listener: Callable + wakelock = asyncio.Lock() @dataclass diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 95876cc2cf9..cf4be6e8cda 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -5,10 +5,12 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta -from itertools import chain -from typing import cast + +from propcache import cached_property +from teslemetry_stream import Signal from homeassistant.components.sensor import ( + RestoreSensor, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -40,6 +42,7 @@ from .entity import ( TeslemetryEnergyInfoEntity, TeslemetryEnergyLiveEntity, TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, TeslemetryWallConnectorEntity, ) from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -59,125 +62,165 @@ SHIFT_STATES = {"P": "p", "D": "d", "R": "r", "N": "n"} @dataclass(frozen=True, kw_only=True) -class TeslemetrySensorEntityDescription(SensorEntityDescription): +class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): """Describes Teslemetry Sensor entity.""" - value_fn: Callable[[StateType], StateType] = lambda x: x + polling: bool = False + polling_value_fn: Callable[[StateType], StateType] = lambda x: x + polling_available_fn: Callable[[StateType], bool] = lambda x: x is not None + streaming_key: Signal | None = None + streaming_value_fn: Callable[[StateType], StateType] = lambda x: x + streaming_firmware: str = "2024.26" -VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( - TeslemetrySensorEntityDescription( +VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charging_state", + polling=True, + streaming_key=Signal.DETAILED_CHARGE_STATE, + polling_value_fn=lambda value: CHARGE_STATES.get(str(value)), + streaming_value_fn=lambda value: CHARGE_STATES.get( + str(value).replace("DetailedChargeState", "") + ), options=list(CHARGE_STATES.values()), device_class=SensorDeviceClass.ENUM, - value_fn=lambda value: CHARGE_STATES.get(cast(str, value)), ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_battery_level", + polling=True, + streaming_key=Signal.BATTERY_LEVEL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_usable_battery_level", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charge_energy_added", + polling=True, + streaming_key=Signal.AC_CHARGING_ENERGY_IN, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_power", + polling=True, + streaming_key=Signal.AC_CHARGING_POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_voltage", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_actual_current", + polling=True, + streaming_key=Signal.CHARGE_AMPS, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, entity_category=EntityCategory.DIAGNOSTIC, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charge_rate", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.SPEED, entity_category=EntityCategory.DIAGNOSTIC, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_conn_charge_cable", + polling=True, + streaming_key=Signal.CHARGING_CABLE_TYPE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_fast_charger_type", + polling=True, + streaming_key=Signal.FAST_CHARGER_TYPE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_battery_range", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_est_battery_range", + polling=True, + streaming_key=Signal.EST_BATTERY_RANGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=1, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_ideal_battery_range", + polling=True, + streaming_key=Signal.IDEAL_BATTERY_RANGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=1, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_speed", + polling=True, + polling_value_fn=lambda value: value or 0, + streaming_key=Signal.VEHICLE_SPEED, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.SPEED, entity_registry_enabled_default=False, - value_fn=lambda value: value or 0, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_power", + polling=True, + polling_value_fn=lambda value: value or 0, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda value: value or 0, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_shift_state", + polling=True, + polling_available_fn=lambda x: True, + polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"), + streaming_key=Signal.GEAR, + streaming_value_fn=lambda x: SHIFT_STATES.get(str(x)), options=list(SHIFT_STATES.values()), device_class=SensorDeviceClass.ENUM, - value_fn=lambda x: SHIFT_STATES.get(str(x), "p"), entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_odometer", + polling=True, + streaming_key=Signal.ODOMETER, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -185,8 +228,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_fl", + polling=True, + streaming_key=Signal.TPMS_PRESSURE_FL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -195,8 +240,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_fr", + polling=True, + streaming_key=Signal.TPMS_PRESSURE_FR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -205,8 +252,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_rl", + polling=True, + streaming_key=Signal.TPMS_PRESSURE_RL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -215,8 +264,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_rr", + polling=True, + streaming_key=Signal.TPMS_PRESSURE_RR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -225,22 +276,27 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="climate_state_inside_temp", + polling=True, + streaming_key=Signal.INSIDE_TEMP, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="climate_state_outside_temp", + polling=True, + streaming_key=Signal.OUTSIDE_TEMP, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="climate_state_driver_temp_setting", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -248,8 +304,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="climate_state_passenger_temp_setting", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -257,23 +314,29 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_traffic_minutes_delay", + polling=True, + streaming_key=Signal.ROUTE_TRAFFIC_MINUTES_DELAY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.MINUTES, device_class=SensorDeviceClass.DURATION, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_energy_at_arrival", + polling=True, + streaming_key=Signal.EXPECTED_ENERGY_PERCENT_AT_TRIP_ARRIVAL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_miles_to_arrival", + polling=True, + streaming_key=Signal.MILES_TO_ARRIVAL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -286,17 +349,21 @@ class TeslemetryTimeEntityDescription(SensorEntityDescription): """Describes Teslemetry Sensor entity.""" variance: int + streaming_key: Signal + streaming_firmware: str = "2024.26" VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = ( TeslemetryTimeEntityDescription( key="charge_state_minutes_to_full_charge", + streaming_key=Signal.TIME_TO_FULL_CHARGE, device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, variance=4, ), TeslemetryTimeEntityDescription( key="drive_state_active_route_minutes_to_arrival", + streaming_key=Signal.MINUTES_TO_ARRIVAL, device_class=SensorDeviceClass.TIMESTAMP, variance=1, ), @@ -391,6 +458,14 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( ), ) + +@dataclass(frozen=True, kw_only=True) +class TeslemetrySensorEntityDescription(SensorEntityDescription): + """Describes Teslemetry Sensor entity.""" + + value_fn: Callable[[StateType], StateType] = lambda x: x + + WALL_CONNECTOR_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( TeslemetrySensorEntityDescription( key="wall_connector_state", @@ -448,55 +523,106 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Teslemetry sensor platform from a config entry.""" - async_add_entities( - chain( - ( # Add vehicles - TeslemetryVehicleSensorEntity(vehicle, description) - for vehicle in entry.runtime_data.vehicles - for description in VEHICLE_DESCRIPTIONS - ), - ( # Add vehicles time sensors - TeslemetryVehicleTimeSensorEntity(vehicle, description) - for vehicle in entry.runtime_data.vehicles - for description in VEHICLE_TIME_DESCRIPTIONS - ), - ( # Add energy site live - TeslemetryEnergyLiveSensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_LIVE_DESCRIPTIONS - if description.key in energysite.live_coordinator.data - ), - ( # Add wall connectors - TeslemetryWallConnectorSensorEntity(energysite, din, description) - for energysite in entry.runtime_data.energysites - for din in energysite.live_coordinator.data.get("wall_connectors", {}) - for description in WALL_CONNECTOR_DESCRIPTIONS - ), - ( # Add energy site info - TeslemetryEnergyInfoSensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_INFO_DESCRIPTIONS - if description.key in energysite.info_coordinator.data - ), - ( # Add energy history sensor - TeslemetryEnergyHistorySensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_HISTORY_DESCRIPTIONS - if energysite.history_coordinator - ), - ) + entities: list[SensorEntity] = [] + for vehicle in entry.runtime_data.vehicles: + for description in VEHICLE_DESCRIPTIONS: + if ( + not vehicle.api.pre2021 + and description.streaming_key + and vehicle.firmware >= description.streaming_firmware + ): + entities.append(TeslemetryStreamSensorEntity(vehicle, description)) + elif description.polling: + entities.append(TeslemetryVehicleSensorEntity(vehicle, description)) + + for time_description in VEHICLE_TIME_DESCRIPTIONS: + if ( + not vehicle.api.pre2021 + and vehicle.firmware >= time_description.streaming_firmware + ): + entities.append( + TeslemetryStreamTimeSensorEntity(vehicle, time_description) + ) + else: + entities.append( + TeslemetryVehicleTimeSensorEntity(vehicle, time_description) + ) + + entities.extend( + TeslemetryEnergyLiveSensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_LIVE_DESCRIPTIONS + if description.key in energysite.live_coordinator.data ) + entities.extend( + TeslemetryWallConnectorSensorEntity(energysite, din, description) + for energysite in entry.runtime_data.energysites + for din in energysite.live_coordinator.data.get("wall_connectors", {}) + for description in WALL_CONNECTOR_DESCRIPTIONS + ) + + entities.extend( + TeslemetryEnergyInfoSensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_INFO_DESCRIPTIONS + if description.key in energysite.info_coordinator.data + ) + + entities.extend( + TeslemetryEnergyHistorySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_HISTORY_DESCRIPTIONS + if energysite.history_coordinator is not None + ) + + async_add_entities(entities) + + +class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor): + """Base class for Teslemetry vehicle streaming sensors.""" + + entity_description: TeslemetryVehicleSensorEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryVehicleSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + assert description.streaming_key + super().__init__(data, description.key, description.streaming_key) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + if (sensor_data := await self.async_get_last_sensor_data()) is not None: + self._attr_native_value = sensor_data.native_value + + @cached_property + def available(self) -> bool: + """Return True if entity is available.""" + return self.stream.connected + + def _async_value_from_stream(self, value) -> None: + """Update the value of the entity.""" + if value is None: + self._attr_native_value = None + else: + self._attr_native_value = self.entity_description.streaming_value_fn(value) + class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): """Base class for Teslemetry vehicle metric sensors.""" - entity_description: TeslemetrySensorEntityDescription + entity_description: TeslemetryVehicleSensorEntityDescription def __init__( self, data: TeslemetryVehicleData, - description: TeslemetrySensorEntityDescription, + description: TeslemetryVehicleSensorEntityDescription, ) -> None: """Initialize the sensor.""" self.entity_description = description @@ -504,12 +630,48 @@ class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - if self.has: - self._attr_native_value = self.entity_description.value_fn(self._value) + if self.entity_description.polling_available_fn(self._value): + self._attr_available = True + self._attr_native_value = self.entity_description.polling_value_fn( + self._value + ) else: + self._attr_available = False self._attr_native_value = None +class TeslemetryStreamTimeSensorEntity(TeslemetryVehicleStreamEntity, SensorEntity): + """Base class for Teslemetry vehicle streaming sensors.""" + + entity_description: TeslemetryTimeEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryTimeEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + self._get_timestamp = ignore_variance( + func=lambda value: dt_util.now() + timedelta(minutes=value), + ignored_variance=timedelta(minutes=description.variance), + ) + assert description.streaming_key + super().__init__(data, description.key, description.streaming_key) + + @cached_property + def available(self) -> bool: + """Return True if entity is available.""" + return self.stream.connected + + def _async_value_from_stream(self, value) -> None: + """Update the value of the entity.""" + if value is None: + self._attr_native_value = None + else: + self._attr_native_value = self._get_timestamp(value) + + class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity): """Base class for Teslemetry vehicle time sensors.""" diff --git a/requirements_all.txt b/requirements_all.txt index da85ef69c6d..35487590461 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2853,7 +2853,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.4.2 +teslemetry-stream==0.5.3 # homeassistant.components.tessie tessie-api==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fe203064278..8c58f425dcc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2290,7 +2290,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.4.2 +teslemetry-stream==0.5.3 # homeassistant.components.tessie tessie-api==0.1.1 diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index 256428aa703..960e30bce88 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -7,6 +7,7 @@ from copy import deepcopy from unittest.mock import AsyncMock, patch import pytest +from teslemetry_stream.stream import recursive_match from .const import ( COMMAND_OK, @@ -109,9 +110,53 @@ def mock_energy_history(): @pytest.fixture(autouse=True) -def mock_listen(): +def mock_add_listener(): """Mock Teslemetry Stream listen method.""" with patch( - "homeassistant.components.teslemetry.TeslemetryStream.listen", - ) as mock_listen: - yield mock_listen + "homeassistant.components.teslemetry.TeslemetryStream.async_add_listener", + ) as mock_add_listener: + mock_add_listener.listeners = [] + + def unsubscribe() -> None: + return + + def side_effect(callback, filters): + mock_add_listener.listeners.append((callback, filters)) + return unsubscribe + + def send(event) -> None: + for listener, filters in mock_add_listener.listeners: + if recursive_match(filters, event): + listener(event) + + mock_add_listener.send = send + mock_add_listener.side_effect = side_effect + yield mock_add_listener + + +@pytest.fixture(autouse=True) +def mock_stream_get_config(): + """Mock Teslemetry Stream listen method.""" + with patch( + "teslemetry_stream.TeslemetryStreamVehicle.get_config", + ) as mock_stream_get_config: + yield mock_stream_get_config + + +@pytest.fixture(autouse=True) +def mock_stream_update_config(): + """Mock Teslemetry Stream listen method.""" + with patch( + "teslemetry_stream.TeslemetryStreamVehicle.update_config", + ) as mock_stream_update_config: + yield mock_stream_update_config + + +@pytest.fixture(autouse=True) +def mock_stream_connected(): + """Mock Teslemetry Stream listen method.""" + with patch( + "homeassistant.components.teslemetry.TeslemetryStream.connected", + return_value=True, + ) as mock_stream_connected: + yield mock_stream_connected diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index 46efed2153d..40d55dab71f 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -18,6 +18,7 @@ VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) ENERGY_HISTORY = load_json_object_fixture("energy_history.json", DOMAIN) +METADATA = load_json_object_fixture("metadata.json", DOMAIN) COMMAND_OK = {"response": {"result": True, "reason": ""}} COMMAND_REASON = {"response": {"result": False, "reason": "already closed"}} diff --git a/tests/components/teslemetry/fixtures/config.json b/tests/components/teslemetry/fixtures/config.json new file mode 100644 index 00000000000..0a6d2b11ab0 --- /dev/null +++ b/tests/components/teslemetry/fixtures/config.json @@ -0,0 +1,10 @@ +{ + "exp": 1749261108, + "hostname": "na.teslemetry.com", + "port": 4431, + "prefer_typed": true, + "pending": false, + "fields": { + "ChargeAmps": { "interval_seconds": 60 } + } +} diff --git a/tests/components/teslemetry/fixtures/metadata.json b/tests/components/teslemetry/fixtures/metadata.json new file mode 100644 index 00000000000..48b9034da00 --- /dev/null +++ b/tests/components/teslemetry/fixtures/metadata.json @@ -0,0 +1,22 @@ +{ + "uid": "abc-123", + "region": "NA", + "scopes": [ + "openid", + "offline_access", + "user_data", + "vehicle_device_data", + "vehicle_cmds", + "vehicle_charging_cmds", + "energy_device_data", + "energy_cmds" + ], + "vehicles": { + "LRW3F7EK4NC700000": { + "access": true, + "polling": true, + "proxy": true, + "firmware": "2024.38.7" + } + } +} diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index acff157bfea..6439e74eecc 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -2414,6 +2414,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, @@ -3843,7 +3846,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.test_speed-statealt] @@ -3859,7 +3862,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.test_state_of_charge_at_arrival-entry] @@ -3910,7 +3913,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.test_state_of_charge_at_arrival-statealt] @@ -3926,7 +3929,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.test_time_to_arrival-entry] @@ -4977,3 +4980,24 @@ 'state': 'unknown', }) # --- +# name: test_sensors_streaming[sensor.test_battery_level-state] + '90' +# --- +# name: test_sensors_streaming[sensor.test_charge_cable-state] + 'unknown' +# --- +# name: test_sensors_streaming[sensor.test_charge_energy_added-state] + '10' +# --- +# name: test_sensors_streaming[sensor.test_charger_power-state] + '2' +# --- +# name: test_sensors_streaming[sensor.test_charging-state] + 'charging' +# --- +# name: test_sensors_streaming[sensor.test_time_to_arrival-state] + 'unknown' +# --- +# name: test_sensors_streaming[sensor.test_time_to_full_charge-state] + 'unknown' +# --- diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 6d4e04c21b4..3794ffb93d8 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -142,13 +142,13 @@ async def test_energy_history_refresh_error( async def test_vehicle_stream( hass: HomeAssistant, - mock_listen: AsyncMock, + mock_add_listener: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test vehicle stream events.""" - entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) - mock_listen.assert_called_once() + await setup_platform(hass, [Platform.BINARY_SENSOR]) + mock_add_listener.assert_called() state = hass.states.get("binary_sensor.test_status") assert state.state == STATE_ON @@ -156,28 +156,25 @@ async def test_vehicle_stream( state = hass.states.get("binary_sensor.test_user_present") assert state.state == STATE_OFF - runtime_data: TeslemetryData = entry.runtime_data - for listener, _ in runtime_data.vehicles[0].stream._listeners.values(): - listener( - { - "vin": VEHICLE_DATA_ALT["response"]["vin"], - "vehicle_data": VEHICLE_DATA_ALT["response"], - "createdAt": "2024-10-04T10:45:17.537Z", - } - ) + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "vehicle_data": VEHICLE_DATA_ALT["response"], + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_user_present") assert state.state == STATE_ON - for listener, _ in runtime_data.vehicles[0].stream._listeners.values(): - listener( - { - "vin": VEHICLE_DATA_ALT["response"]["vin"], - "state": "offline", - "createdAt": "2024-10-04T10:45:17.537Z", - } - ) + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "state": "offline", + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_status") diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index f0b472a7183..a488ebc8a06 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -1,10 +1,11 @@ """Test the Teslemetry sensor platform.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.const import Platform @@ -25,11 +26,15 @@ async def test_sensors( freezer: FrozenDateTimeFactory, mock_vehicle_data: AsyncMock, ) -> None: - """Tests that the sensor entities are correct.""" + """Tests that the sensor entities with the legacy polling are correct.""" freezer.move_to("2024-01-01 00:00:00+00:00") - entry = await setup_platform(hass, [Platform.SENSOR]) + # Force the vehicle to use polling + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.pre2021", return_value=True + ): + entry = await setup_platform(hass, [Platform.SENSOR]) assert_entities(hass, entry.entry_id, entity_registry, snapshot) @@ -40,3 +45,54 @@ async def test_sensors( await hass.async_block_till_done() assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the sensor entities with streaming are correct.""" + + freezer.move_to("2024-01-01 00:00:00+00:00") + + entry = await setup_platform(hass, [Platform.SENSOR]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.DETAILED_CHARGE_STATE: "DetailedChargeStateCharging", + Signal.BATTERY_LEVEL: 90, + Signal.AC_CHARGING_ENERGY_IN: 10, + Signal.AC_CHARGING_POWER: 2, + Signal.CHARGING_CABLE_TYPE: None, + Signal.TIME_TO_FULL_CHARGE: 10, + Signal.MINUTES_TO_ARRIVAL: None, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Reload the entry + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "sensor.test_charging", + "sensor.test_battery_level", + "sensor.test_charge_energy_added", + "sensor.test_charger_power", + "sensor.test_charge_cable", + "sensor.test_time_to_full_charge", + "sensor.test_time_to_arrival", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-state") From 0cc586a3ac9e5f92dffba63a7f1cf4429bc29b28 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Jan 2025 08:01:49 -1000 Subject: [PATCH 0245/2987] Bump zeroconf to 0.139.0 (#135213) --- 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 3e3780d397c..663f196dd1d 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.138.1"] + "requirements": ["zeroconf==0.139.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0ac837c15e1..9f9a3d113e7 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.138.1 +zeroconf==0.139.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 3d8168e4857..12867a82912 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ dependencies = [ "voluptuous-openapi==0.0.6", "yarl==1.18.3", "webrtc-models==0.3.0", - "zeroconf==0.138.1" + "zeroconf==0.139.0" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index d217f7f50ab..a59ce1837d0 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.138.1 +zeroconf==0.139.0 diff --git a/requirements_all.txt b/requirements_all.txt index 35487590461..50ce62e9cb1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3115,7 +3115,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.138.1 +zeroconf==0.139.0 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8c58f425dcc..0303dfdf83c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2501,7 +2501,7 @@ yt-dlp[default]==2024.12.23 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.138.1 +zeroconf==0.139.0 # homeassistant.components.zeversolar zeversolar==0.3.2 From dd57c75e641da4c79897c56096b69635df91f417 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jan 2025 21:15:22 +0100 Subject: [PATCH 0246/2987] Use remove-prefix/suffix introduced in Python 3.9 (#135206) Use removeprefix/removesuffix --- homeassistant/components/blueprint/importer.py | 3 +-- homeassistant/components/fritz/config_flow.py | 3 +-- homeassistant/components/fritzbox/config_flow.py | 3 +-- homeassistant/components/private_ble_device/config_flow.py | 3 +-- homeassistant/components/samsungtv/config_flow.py | 2 +- homeassistant/components/tasmota/config_flow.py | 3 +-- homeassistant/components/webostv/config_flow.py | 3 +-- homeassistant/util/network.py | 3 +-- 8 files changed, 8 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/blueprint/importer.py b/homeassistant/components/blueprint/importer.py index c10da532324..32fe7b56495 100644 --- a/homeassistant/components/blueprint/importer.py +++ b/homeassistant/components/blueprint/importer.py @@ -173,8 +173,7 @@ async def fetch_blueprint_from_github_url( parsed_import_url = yarl.URL(import_url) suggested_filename = f"{parsed_import_url.parts[1]}/{parsed_import_url.parts[-1]}" - if suggested_filename.endswith(".yaml"): - suggested_filename = suggested_filename[:-5] + suggested_filename = suggested_filename.removesuffix(".yaml") return ImportedBlueprint(suggested_filename, raw_yaml, blueprint) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 920ecda1c52..244c7036a1c 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -166,8 +166,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): uuid: str | None if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): - if uuid.startswith("uuid:"): - uuid = uuid[5:] + uuid = uuid.removeprefix("uuid:") await self.async_set_unique_id(uuid) self._abort_if_unique_id_configured({CONF_HOST: self._host}) diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py index ffec4a9ea29..c0e0f62285a 100644 --- a/homeassistant/components/fritzbox/config_flow.py +++ b/homeassistant/components/fritzbox/config_flow.py @@ -122,8 +122,7 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="ignore_ip6_link_local") if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): - if uuid.startswith("uuid:"): - uuid = uuid[5:] + uuid = uuid.removeprefix("uuid:") await self.async_set_unique_id(uuid) self._abort_if_unique_id_configured({CONF_HOST: host}) diff --git a/homeassistant/components/private_ble_device/config_flow.py b/homeassistant/components/private_ble_device/config_flow.py index c7311e8691b..90340bc70fa 100644 --- a/homeassistant/components/private_ble_device/config_flow.py +++ b/homeassistant/components/private_ble_device/config_flow.py @@ -20,8 +20,7 @@ CONF_IRK = "irk" def _parse_irk(irk: str) -> bytes | None: - if irk.startswith("irk:"): - irk = irk[4:] + irk = irk.removeprefix("irk:") if irk.endswith("="): try: diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 837651f9900..b3dabca1df4 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -59,7 +59,7 @@ DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): def _strip_uuid(udn: str) -> str: - return udn[5:] if udn.startswith("uuid:") else udn + return udn.removeprefix("uuid:") def _entry_is_complete( diff --git a/homeassistant/components/tasmota/config_flow.py b/homeassistant/components/tasmota/config_flow.py index 9deb846f8e2..5b1adc839ac 100644 --- a/homeassistant/components/tasmota/config_flow.py +++ b/homeassistant/components/tasmota/config_flow.py @@ -66,8 +66,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: bad_prefix = False prefix = user_input[CONF_DISCOVERY_PREFIX] - if prefix.endswith("/#"): - prefix = prefix[:-2] + prefix = prefix.removesuffix("/#") try: valid_subscribe_topic(f"{prefix}/#") except vol.Invalid: diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index c62ecaa78cf..55dd45153f7 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -105,8 +105,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): uuid = discovery_info.upnp[ssdp.ATTR_UPNP_UDN] assert uuid - if uuid.startswith("uuid:"): - uuid = uuid[5:] + uuid = uuid.removeprefix("uuid:") await self.async_set_unique_id(uuid) self._abort_if_unique_id_configured({CONF_HOST: self._host}) diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py index 08a2c2a3967..70d7dc80505 100644 --- a/homeassistant/util/network.py +++ b/homeassistant/util/network.py @@ -98,8 +98,7 @@ def is_host_valid(host: str) -> bool: return False if re.match(r"^[0-9\.]+$", host): # reject invalid IPv4 return False - if host.endswith("."): # dot at the end is correct - host = host[:-1] + host = host.removesuffix(".") allowed = re.compile(r"(?!-)[A-Z\d\-]{1,63}(? Date: Thu, 9 Jan 2025 21:21:47 +0100 Subject: [PATCH 0247/2987] Add exception-translations rule to quality_scale pytest validation (#131914) * Add exception-translations rule to quality_scale pytest validation * Adjust * Return empty dict if file is missing * Fix * Improve typing * Address comments * Update tests/components/conftest.py * Update tests/components/conftest.py * Update tests/components/conftest.py --------- Co-authored-by: Robert Resch --- tests/common.py | 31 +++++++++++++++++++++++++++++-- tests/components/conftest.py | 30 ++++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/tests/common.py b/tests/common.py index ac6f10b8c44..9386fdee729 100644 --- a/tests/common.py +++ b/tests/common.py @@ -15,7 +15,7 @@ from collections.abc import ( ) from contextlib import asynccontextmanager, contextmanager, suppress from datetime import UTC, datetime, timedelta -from enum import Enum +from enum import Enum, StrEnum import functools as ft from functools import lru_cache from io import StringIO @@ -108,7 +108,7 @@ from homeassistant.util.json import ( from homeassistant.util.signal_type import SignalType import homeassistant.util.ulid as ulid_util from homeassistant.util.unit_system import METRIC_SYSTEM -import homeassistant.util.yaml.loader as yaml_loader +from homeassistant.util.yaml import load_yaml_dict, loader as yaml_loader from .testing_config.custom_components.test_constant_deprecation import ( import_deprecated_constant, @@ -122,6 +122,14 @@ CLIENT_ID = "https://example.com/app" CLIENT_REDIRECT_URI = "https://example.com/app/callback" +class QualityScaleStatus(StrEnum): + """Source of core configuration.""" + + DONE = "done" + EXEMPT = "exempt" + TODO = "todo" + + async def async_get_device_automations( hass: HomeAssistant, automation_type: device_automation.DeviceAutomationType, @@ -1832,3 +1840,22 @@ def reset_translation_cache(hass: HomeAssistant, components: list[str]) -> None: for loaded_components in loaded_categories.values(): for component_to_unload in components: loaded_components.pop(component_to_unload, None) + + +@lru_cache +def get_quality_scale(integration: str) -> dict[str, QualityScaleStatus]: + """Load quality scale for integration.""" + quality_scale_file = pathlib.Path( + f"homeassistant/components/{integration}/quality_scale.yaml" + ) + if not quality_scale_file.exists(): + return {} + raw = load_yaml_dict(quality_scale_file) + return { + rule: ( + QualityScaleStatus(details) + if isinstance(details, str) + else QualityScaleStatus(details["status"]) + ) + for rule, details in raw["rules"].items() + } diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 81f7b2044d6..362a1bff4ee 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -7,6 +7,7 @@ from collections.abc import AsyncGenerator, Callable, Generator from functools import lru_cache from importlib.util import find_spec from pathlib import Path +import re import string from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, patch @@ -42,6 +43,8 @@ from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.translation import async_get_translations from homeassistant.util import yaml +from tests.common import QualityScaleStatus, get_quality_scale + if TYPE_CHECKING: from homeassistant.components.hassio import AddonManager @@ -51,6 +54,9 @@ if TYPE_CHECKING: from .sensor.common import MockSensor from .switch.common import MockSwitch +# Regex for accessing the integration name from the test path +RE_REQUEST_DOMAIN = re.compile(r".*tests\/components\/([^/]+)\/.*") + @pytest.fixture(scope="session", autouse=find_spec("zeroconf") is not None) def patch_zeroconf_multiple_catcher() -> Generator[None]: @@ -804,12 +810,29 @@ async def _check_create_issue_translations( ) +def _get_request_quality_scale( + request: pytest.FixtureRequest, rule: str +) -> QualityScaleStatus: + if not (match := RE_REQUEST_DOMAIN.match(str(request.path))): + return QualityScaleStatus.TODO + integration = match.groups(1)[0] + return get_quality_scale(integration).get(rule, QualityScaleStatus.TODO) + + async def _check_exception_translation( hass: HomeAssistant, exception: HomeAssistantError, translation_errors: dict[str, str], + request: pytest.FixtureRequest, ) -> None: if exception.translation_key is None: + if ( + _get_request_quality_scale(request, "exception-translations") + is QualityScaleStatus.DONE + ): + translation_errors["quality_scale"] = ( + f"Found untranslated {type(exception).__name__} exception: {exception}" + ) return await _validate_translation( hass, @@ -823,13 +846,14 @@ async def _check_exception_translation( @pytest.fixture(autouse=True) async def check_translations( - ignore_translations: str | list[str], + ignore_translations: str | list[str], request: pytest.FixtureRequest ) -> AsyncGenerator[None]: """Check that translation requirements are met. Current checks: - data entry flow results (ConfigFlow/OptionsFlow/RepairFlow) - issue registry entries + - action (service) exceptions """ if not isinstance(ignore_translations, list): ignore_translations = [ignore_translations] @@ -887,7 +911,9 @@ async def check_translations( ) except HomeAssistantError as err: translation_coros.add( - _check_exception_translation(self._hass, err, translation_errors) + _check_exception_translation( + self._hass, err, translation_errors, request + ) ) raise From 6e1a13f878e4e93fb69b1f546f9a671419d31d55 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 9 Jan 2025 15:28:36 -0500 Subject: [PATCH 0248/2987] Add support for Shelly BLU TRV (#128439) * feat: add support for Shelly BLU TRV * chore: apply some fixes * make BLUTRV a separate device * apply review comment * review comments and small optimization * add HVACMode.OFF * a couple of fixes * 2 more fixes * better approach * cleanup * small optimization * remove cooling as not supported by firmware * tweaks * humidity and entity name * fix naming * allign async_set_hvac_mode * align settings * restore temp * fix * remove OFF * cleanup * hvac_mode * add tests * typo * more tests * bump aioshelly --- homeassistant/components/shelly/climate.py | 89 ++++++++++++++++- homeassistant/components/shelly/const.py | 7 ++ .../components/shelly/coordinator.py | 1 + homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/shelly/__init__.py | 7 ++ tests/components/shelly/conftest.py | 97 ++++++++++++++++++ tests/components/shelly/test_climate.py | 98 ++++++++++++++++++- 9 files changed, 293 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 842abc5ecc4..940343fc069 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -7,7 +7,7 @@ from dataclasses import asdict, dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import RPC_GENERATIONS +from aioshelly.const import BLU_TRV_IDENTIFIER, BLU_TRV_MODEL_NAME, RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.climate import ( @@ -22,7 +22,11 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + CONNECTION_NETWORK_MAC, + DeviceInfo, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity @@ -31,6 +35,7 @@ from homeassistant.util.unit_conversion import TemperatureConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( + BLU_TRV_TEMPERATURE_SETTINGS, DOMAIN, LOGGER, NOT_CALIBRATED_ISSUE_ID, @@ -124,6 +129,7 @@ def async_setup_rpc_entry( coordinator = config_entry.runtime_data.rpc assert coordinator climate_key_ids = get_rpc_key_ids(coordinator.device.status, "thermostat") + blutrv_key_ids = get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER) climate_ids = [] for id_ in climate_key_ids: @@ -139,10 +145,11 @@ def async_setup_rpc_entry( unique_id = f"{coordinator.mac}-switch:{id_}" async_remove_shelly_entity(hass, "switch", unique_id) - if not climate_ids: - return + if climate_ids: + async_add_entities(RpcClimate(coordinator, id_) for id_ in climate_ids) - async_add_entities(RpcClimate(coordinator, id_) for id_ in climate_ids) + if blutrv_key_ids: + async_add_entities(RpcBluTrvClimate(coordinator, id_) for id_ in blutrv_key_ids) @dataclass @@ -526,3 +533,75 @@ class RpcClimate(ShellyRpcEntity, ClimateEntity): await self.call_rpc( "Thermostat.SetConfig", {"config": {"id": self._id, "enable": mode}} ) + + +class RpcBluTrvClimate(ShellyRpcEntity, ClimateEntity): + """Entity that controls a thermostat on RPC based Shelly devices.""" + + _attr_max_temp = BLU_TRV_TEMPERATURE_SETTINGS["max"] + _attr_min_temp = BLU_TRV_TEMPERATURE_SETTINGS["min"] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_ON + ) + _attr_hvac_modes = [HVACMode.HEAT] + _attr_hvac_mode = HVACMode.HEAT + _attr_target_temperature_step = BLU_TRV_TEMPERATURE_SETTINGS["step"] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + + def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: + """Initialize.""" + + super().__init__(coordinator, f"{BLU_TRV_IDENTIFIER}:{id_}") + self._id = id_ + self._config = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"] + ble_addr: str = self._config["addr"] + self._attr_unique_id = f"{ble_addr}-{self.key}" + name = self._config["name"] or f"shellyblutrv-{ble_addr.replace(":", "")}" + model_id = self._config.get("local_name") + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)}, + identifiers={(DOMAIN, ble_addr)}, + via_device=(DOMAIN, self.coordinator.mac), + manufacturer="Shelly", + model=BLU_TRV_MODEL_NAME.get(model_id), + model_id=model_id, + name=name, + ) + # Added intentionally to the constructor to avoid double name from base class + self._attr_name = None + + @property + def target_temperature(self) -> float | None: + """Set target temperature.""" + if not self._config["enable"]: + return None + + return cast(float, self.status["target_C"]) + + @property + def current_temperature(self) -> float | None: + """Return current temperature.""" + return cast(float, self.status["current_C"]) + + @property + def hvac_action(self) -> HVACAction: + """HVAC current action.""" + if not self.status["pos"]: + return HVACAction.IDLE + + return HVACAction.HEATING + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (target_temp := kwargs.get(ATTR_TEMPERATURE)) is None: + return + + await self.call_rpc( + "BluTRV.Call", + { + "id": self._id, + "method": "Trv.SetTarget", + "params": {"id": 0, "target_C": target_temp}, + }, + ) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 88d8c1f5f17..1adaad8f975 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -187,6 +187,13 @@ RPC_THERMOSTAT_SETTINGS: Final = { "step": 0.5, } +BLU_TRV_TEMPERATURE_SETTINGS: Final = { + "min": 4, + "max": 30, + "step": 0.1, + "default": 20.0, +} + # Kelvin value for colorTemp KELVIN_MAX_VALUE: Final = 6500 KELVIN_MIN_VALUE_WHITE: Final = 2700 diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 8273c7626eb..f58e42a78d8 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -154,6 +154,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( config_entry_id=self.entry.entry_id, name=self.name, connections={(CONNECTION_NETWORK_MAC, self.mac)}, + identifiers={(DOMAIN, self.mac)}, manufacturer="Shelly", model=MODEL_NAMES.get(self.model), model_id=self.model, diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 29c8fd4c369..2db45c3fb03 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==12.2.0"], + "requirements": ["aioshelly==12.3.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 50ce62e9cb1..1c7cb80f566 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -368,7 +368,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.2.0 +aioshelly==12.3.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0303dfdf83c..bbe7448d891 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -350,7 +350,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.2.0 +aioshelly==12.3.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 7de45eeee98..7a20560e25f 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -148,6 +148,13 @@ def get_entity_state(hass: HomeAssistant, entity_id: str) -> str: return entity.state +def get_entity_attribute(hass: HomeAssistant, entity_id: str, attribute: str) -> str: + """Return entity attribute.""" + entity = hass.states.get(entity_id) + assert entity + return entity.attributes[attribute] + + def register_device( device_registry: DeviceRegistry, config_entry: ConfigEntry ) -> DeviceEntry: diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index b2550c2b9d4..7bcc1c04c6a 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -202,6 +202,64 @@ MOCK_CONFIG = { "voltmeter:100": {"xvoltage": {"unit": "ppm"}}, } + +MOCK_BLU_TRV_REMOTE_CONFIG = { + "components": [ + { + "key": "blutrv:200", + "status": { + "id": 200, + "target_C": 17.1, + "current_C": 17.1, + "pos": 0, + "rssi": -60, + "battery": 100, + "packet_id": 58, + "last_updated_ts": 1734967725, + "paired": True, + "rpc": True, + "rsv": 61, + }, + "config": { + "id": 200, + "addr": "f8:44:77:25:f0:dd", + "name": "TRV-Name", + "key": None, + "trv": "bthomedevice:200", + "temp_sensors": [], + "dw_sensors": [], + "override_delay": 30, + "meta": {}, + }, + }, + ], + "blutrv:200": { + "id": 0, + "enable": True, + "min_valve_position": 0, + "default_boost_duration": 1800, + "default_override_duration": 2147483647, + "default_override_target_C": 8, + "addr": "f8:44:77:25:f0:dd", + "name": "TRV-Name", + "local_name": "SBTR-001AEU", + }, +} + + +MOCK_BLU_TRV_REMOTE_STATUS = { + "blutrv:200": { + "id": 0, + "pos": 0, + "steps": 0, + "current_C": 15.2, + "target_C": 17.1, + "schedule_rev": 0, + "errors": [], + }, +} + + MOCK_SHELLY_COAP = { "mac": MOCK_MAC, "auth": False, @@ -373,6 +431,24 @@ def _mock_rpc_device(version: str | None = None): return device +def _mock_blu_rtv_device(version: str | None = None): + """Mock rpc (Gen2, Websocket) device.""" + device = Mock( + spec=RpcDevice, + config=MOCK_CONFIG | MOCK_BLU_TRV_REMOTE_CONFIG, + event={}, + shelly=MOCK_SHELLY_RPC, + version=version or "1.0.0", + hostname="test-host", + status=MOCK_STATUS_RPC | MOCK_BLU_TRV_REMOTE_STATUS, + firmware_version="some fw string", + initialized=True, + connected=True, + ) + type(device).name = PropertyMock(return_value="Test name") + return device + + @pytest.fixture async def mock_rpc_device(): """Mock rpc (Gen2, Websocket) device with BLE support.""" @@ -420,3 +496,24 @@ async def mock_rpc_device(): @pytest.fixture(autouse=True) def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" + + +@pytest.fixture(autouse=True) +async def mock_blu_trv(): + """Mock BLU TRV.""" + + with ( + patch("aioshelly.rpc_device.RpcDevice.create") as blu_trv_device_mock, + patch("homeassistant.components.shelly.bluetooth.async_start_scanner"), + ): + + def update(): + blu_trv_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, RpcUpdateType.STATUS + ) + + device = _mock_blu_rtv_device() + blu_trv_device_mock.return_value = device + blu_trv_device_mock.return_value.mock_update = Mock(side_effect=update) + + yield blu_trv_device_mock.return_value diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index aeeeca30edd..352bdcb0a7d 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -3,7 +3,12 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock, PropertyMock -from aioshelly.const import MODEL_VALVE, MODEL_WALL_DISPLAY +from aioshelly.const import ( + BLU_TRV_IDENTIFIER, + MODEL_BLU_GATEWAY_GEN3, + MODEL_VALVE, + MODEL_WALL_DISPLAY, +) from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest @@ -37,7 +42,13 @@ from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import MOCK_MAC, init_integration, register_device, register_entity +from . import ( + MOCK_MAC, + get_entity_attribute, + init_integration, + register_device, + register_entity, +) from .conftest import MOCK_STATUS_COAP from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data @@ -294,13 +305,13 @@ async def test_block_restored_climate( assert hass.states.get(entity_id).attributes.get("temperature") == 22.0 -async def test_block_restored_climate_us_customery( +async def test_block_restored_climate_us_customary( hass: HomeAssistant, mock_block_device: Mock, device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test block restored climate with US CUSTOMATY unit system.""" + """Test block restored climate with US CUSTOMARY unit system.""" hass.config.units = US_CUSTOMARY_SYSTEM monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") @@ -759,3 +770,82 @@ async def test_wall_display_thermostat_mode_external_actuator( entry = entity_registry.async_get(climate_entity_id) assert entry assert entry.unique_id == "123456789ABC-thermostat:0" + + +async def test_blu_trv_climate_set_temperature( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test BLU TRV set target temperature.""" + + entity_id = "climate.trv_name" + monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 + + monkeypatch.setitem( + mock_blu_trv.status[f"{BLU_TRV_IDENTIFIER}:200"], "target_C", 28 + ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 28}, + blocking=True, + ) + mock_blu_trv.mock_update() + + mock_blu_trv.call_rpc.assert_called_once_with( + "BluTRV.Call", + { + "id": 200, + "method": "Trv.SetTarget", + "params": {"id": 0, "target_C": 28.0}, + }, + ) + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 28 + + +async def test_blu_trv_climate_disabled( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test BLU TRV disabled.""" + + entity_id = "climate.trv_name" + monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 + + monkeypatch.setitem( + mock_blu_trv.config[f"{BLU_TRV_IDENTIFIER}:200"], "enable", False + ) + mock_blu_trv.mock_update() + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) is None + + +async def test_blu_trv_climate_hvac_action( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test BLU TRV is heating.""" + + entity_id = "climate.trv_name" + monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + assert get_entity_attribute(hass, entity_id, ATTR_HVAC_ACTION) == HVACAction.IDLE + + monkeypatch.setitem(mock_blu_trv.status[f"{BLU_TRV_IDENTIFIER}:200"], "pos", 10) + mock_blu_trv.mock_update() + + assert get_entity_attribute(hass, entity_id, ATTR_HVAC_ACTION) == HVACAction.HEATING From 3b6f47e438bb7e4fb0b02067ce6c2c647c6e7432 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Jan 2025 11:12:34 -1000 Subject: [PATCH 0249/2987] Bump anyio to 4.8.0 (#135224) --- homeassistant/components/mcp_server/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mcp_server/manifest.json b/homeassistant/components/mcp_server/manifest.json index 755d2c39065..18b2e5bc417 100644 --- a/homeassistant/components/mcp_server/manifest.json +++ b/homeassistant/components/mcp_server/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["mcp==1.1.2", "aiohttp_sse==2.2.0", "anyio==4.7.0"], + "requirements": ["mcp==1.1.2", "aiohttp_sse==2.2.0", "anyio==4.8.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9f9a3d113e7..fbaaeea969d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -108,7 +108,7 @@ uuid==1000000000.0.0 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.7.0 +anyio==4.8.0 h11==0.14.0 httpcore==1.0.5 diff --git a/requirements_all.txt b/requirements_all.txt index 1c7cb80f566..e9b7838d36a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -470,7 +470,7 @@ anthemav==1.4.1 anthropic==0.31.2 # homeassistant.components.mcp_server -anyio==4.7.0 +anyio==4.8.0 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bbe7448d891..ed6e2a29e4a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -443,7 +443,7 @@ anthemav==1.4.1 anthropic==0.31.2 # homeassistant.components.mcp_server -anyio==4.7.0 +anyio==4.8.0 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 59ecec939f3..48944f61592 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -139,7 +139,7 @@ uuid==1000000000.0.0 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.7.0 +anyio==4.8.0 h11==0.14.0 httpcore==1.0.5 From 1abcac5fb5e8c4c7d0e874185f088fd913e40217 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 9 Jan 2025 22:13:39 +0100 Subject: [PATCH 0250/2987] Update frontend to 20250109.0 (#135235) --- 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 267374aa302..3d9f12bd3d3 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==20250106.0"] + "requirements": ["home-assistant-frontend==20250109.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index fbaaeea969d..5a267483eea 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,7 +36,7 @@ habluetooth==3.7.0 hass-nabucasa==0.87.0 hassil==2.1.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250106.0 +home-assistant-frontend==20250109.0 home-assistant-intents==2025.1.1 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index e9b7838d36a..f92136f9ec9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1140,7 +1140,7 @@ hole==0.8.0 holidays==0.64 # homeassistant.components.frontend -home-assistant-frontend==20250106.0 +home-assistant-frontend==20250109.0 # homeassistant.components.conversation home-assistant-intents==2025.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ed6e2a29e4a..6a163c24fce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -969,7 +969,7 @@ hole==0.8.0 holidays==0.64 # homeassistant.components.frontend -home-assistant-frontend==20250106.0 +home-assistant-frontend==20250109.0 # homeassistant.components.conversation home-assistant-intents==2025.1.1 From 44808c02f9cc7b927559dc1098729fa5d4a8df41 Mon Sep 17 00:00:00 2001 From: Quentame Date: Wed, 8 Jan 2025 22:51:37 +0100 Subject: [PATCH 0251/2987] =?UTF-8?q?Fix=20M=C3=A9t=C3=A9o-France=20setup?= =?UTF-8?q?=20in=20non=20French=20cities=20(because=20of=20failed=20next?= =?UTF-8?q?=20rain=20sensor)=20(#134782)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/meteo_france/__init__.py | 12 ++++++++++-- homeassistant/components/meteo_france/sensor.py | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 1d4f8293c5e..4b79b046b75 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -6,6 +6,7 @@ import logging from meteofrance_api.client import MeteoFranceClient from meteofrance_api.helpers import is_valid_warning_department from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain +from requests import RequestException import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -83,7 +84,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_method=_async_update_data_rain, update_interval=SCAN_INTERVAL_RAIN, ) - await coordinator_rain.async_config_entry_first_refresh() + try: + await coordinator_rain._async_refresh(log_failures=False) # noqa: SLF001 + except RequestException: + _LOGGER.warning( + "1 hour rain forecast not available: %s is not in covered zone", + entry.title, + ) department = coordinator_forecast.data.position.get("dept") _LOGGER.debug( @@ -128,8 +135,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = { UNDO_UPDATE_LISTENER: undo_listener, COORDINATOR_FORECAST: coordinator_forecast, - COORDINATOR_RAIN: coordinator_rain, } + if coordinator_rain and coordinator_rain.last_update_success: + hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN] = coordinator_rain if coordinator_alert and coordinator_alert.last_update_success: hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] = coordinator_alert diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index d8dbdfc4265..826716f1679 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -187,7 +187,7 @@ async def async_setup_entry( """Set up the Meteo-France sensor platform.""" data = hass.data[DOMAIN][entry.entry_id] coordinator_forecast: DataUpdateCoordinator[Forecast] = data[COORDINATOR_FORECAST] - coordinator_rain: DataUpdateCoordinator[Rain] | None = data[COORDINATOR_RAIN] + coordinator_rain: DataUpdateCoordinator[Rain] | None = data.get(COORDINATOR_RAIN) coordinator_alert: DataUpdateCoordinator[CurrentPhenomenons] | None = data.get( COORDINATOR_ALERT ) From 2c02eefa119d3f64fe338646f6016b1d84aee9da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 7 Jan 2025 13:18:02 +0100 Subject: [PATCH 0252/2987] Increase cloud backup download timeout (#134961) Increese download timeout --- homeassistant/components/cloud/backup.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index b9da6dfb6a4..632248224a2 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -138,7 +138,11 @@ class CloudBackupAgent(BackupAgent): raise BackupAgentError("Failed to get download details") from err try: - resp = await self._cloud.websession.get(details["url"]) + resp = await self._cloud.websession.get( + details["url"], + timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h + ) + resp.raise_for_status() except ClientError as err: raise BackupAgentError("Failed to download backup") from err From ab071d1c1bcafc8602dfb61e5df2b5a4e97c34f6 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 8 Jan 2025 04:51:57 -0500 Subject: [PATCH 0253/2987] Fix ZHA "referencing a non existing `via_device`" warning (#135008) --- homeassistant/components/zha/entity.py | 2 +- tests/components/zha/test_entity.py | 47 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/components/zha/test_entity.py diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 3e3d0642ca2..77ba048312a 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -87,7 +87,7 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity): manufacturer=zha_device_info[ATTR_MANUFACTURER], model=zha_device_info[ATTR_MODEL], name=zha_device_info[ATTR_NAME], - via_device=(DOMAIN, zha_gateway.state.node_info.ieee), + via_device=(DOMAIN, str(zha_gateway.state.node_info.ieee)), ) @callback diff --git a/tests/components/zha/test_entity.py b/tests/components/zha/test_entity.py new file mode 100644 index 00000000000..add98bb96bf --- /dev/null +++ b/tests/components/zha/test_entity.py @@ -0,0 +1,47 @@ +"""Test ZHA entities.""" + +from zigpy.profiles import zha +from zigpy.zcl.clusters import general + +from homeassistant.components.zha.helpers import get_zha_gateway +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + + +async def test_device_registry_via_device( + hass: HomeAssistant, + setup_zha, + zigpy_device_mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test ZHA `via_device` is set correctly.""" + + await setup_zha() + gateway = get_zha_gateway(hass) + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ) + + zha_device = gateway.get_or_create_device(zigpy_device) + await gateway.async_device_initialized(zigpy_device) + await hass.async_block_till_done(wait_background_tasks=True) + + reg_coordinator_device = device_registry.async_get_device( + identifiers={("zha", str(gateway.state.node_info.ieee))} + ) + + reg_device = device_registry.async_get_device( + identifiers={("zha", str(zha_device.ieee))} + ) + + assert reg_device.via_device_id == reg_coordinator_device.id From 902bd57b4bb07fd3defdaa19410e1ba9dc463679 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 7 Jan 2025 20:24:39 +0100 Subject: [PATCH 0254/2987] Catch errors in automation (instead of raise unexpected error) in Overkiz (#135026) Catch errors in automation (instead of raise unexpected error) --- homeassistant/components/overkiz/executor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overkiz/executor.py b/homeassistant/components/overkiz/executor.py index 02829eaf1a3..220c6fe7cb2 100644 --- a/homeassistant/components/overkiz/executor.py +++ b/homeassistant/components/overkiz/executor.py @@ -6,7 +6,7 @@ from typing import Any, cast from urllib.parse import urlparse from pyoverkiz.enums import OverkizCommand, Protocol -from pyoverkiz.exceptions import OverkizException +from pyoverkiz.exceptions import BaseOverkizException from pyoverkiz.models import Command, Device, StateDefinition from pyoverkiz.types import StateType as OverkizStateType @@ -105,7 +105,7 @@ class OverkizExecutor: "Home Assistant", ) # Catch Overkiz exceptions to support `continue_on_error` functionality - except OverkizException as exception: + except BaseOverkizException as exception: raise HomeAssistantError(exception) from exception # ExecutionRegisteredEvent doesn't contain the device_url, thus we need to register it here From 9601455d9f75822fc76806d7ca959cd62114f074 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 8 Jan 2025 09:28:01 +0100 Subject: [PATCH 0255/2987] Fix channel retrieval for Reolink DUO V1 connected to a NVR (#135035) fix channel retrieval for DUO V1 connected to a NVR --- homeassistant/components/reolink/util.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index f52cb08286c..f10da8e4b96 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -82,7 +82,8 @@ def get_device_uid_and_ch( ch = int(device_uid[1][5:]) is_chime = True else: - ch = host.api.channel_for_uid(device_uid[1]) + device_uid_part = "_".join(device_uid[1:]) + ch = host.api.channel_for_uid(device_uid_part) return (device_uid, ch, is_chime) From 3c14e2f0a80e12da014ad13e70ed0099fcce8da3 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 8 Jan 2025 09:26:48 +0100 Subject: [PATCH 0256/2987] Bump aioautomower to 2025.1.0 (#135039) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 02e87a3a772..1eed2be4575 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2024.12.0"] + "requirements": ["aioautomower==2025.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e64a48cbb81..713c8ac2ab4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -201,7 +201,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.12.0 +aioautomower==2025.1.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf0bcb7f9d3..6567f4ea4ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -189,7 +189,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.12.0 +aioautomower==2025.1.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 From 7f3f550b7bcb9763f39bafad9061fe78fce707f4 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Wed, 8 Jan 2025 09:24:09 +0100 Subject: [PATCH 0257/2987] Bump cookidoo-api to 0.12.2 (#135045) fix cookidoo .co.uk countries and group api endpoint --- homeassistant/components/cookidoo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cookidoo/manifest.json b/homeassistant/components/cookidoo/manifest.json index b1a3e9c0267..5264e47a709 100644 --- a/homeassistant/components/cookidoo/manifest.json +++ b/homeassistant/components/cookidoo/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["cookidoo_api"], "quality_scale": "silver", - "requirements": ["cookidoo-api==0.11.2"] + "requirements": ["cookidoo-api==0.12.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 713c8ac2ab4..23189df493d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -704,7 +704,7 @@ connect-box==0.3.1 construct==2.10.68 # homeassistant.components.cookidoo -cookidoo-api==0.11.2 +cookidoo-api==0.12.2 # homeassistant.components.backup # homeassistant.components.utility_meter diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6567f4ea4ec..ad17b89cd0a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -600,7 +600,7 @@ colorthief==0.2.1 construct==2.10.68 # homeassistant.components.cookidoo -cookidoo-api==0.11.2 +cookidoo-api==0.12.2 # homeassistant.components.backup # homeassistant.components.utility_meter From 0deb46295d6fac7faa07346c08264d68a2073238 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 9 Jan 2025 16:22:37 -0500 Subject: [PATCH 0258/2987] Refactor Vodafone Station tests (#134956) --- tests/components/vodafone_station/__init__.py | 12 + tests/components/vodafone_station/conftest.py | 57 ++- tests/components/vodafone_station/const.py | 115 ------ .../fixtures/get_sensor_data.json | 81 +++++ .../snapshots/test_button.ambr | 48 +++ .../snapshots/test_device_tracker.ambr | 50 +++ .../snapshots/test_sensor.ambr | 246 +++++++++++++ .../vodafone_station/test_button.py | 80 ++--- .../vodafone_station/test_config_flow.py | 329 +++++++++--------- .../vodafone_station/test_device_tracker.py | 50 ++- .../vodafone_station/test_diagnostics.py | 30 +- .../vodafone_station/test_sensor.py | 97 ++++-- 12 files changed, 789 insertions(+), 406 deletions(-) create mode 100644 tests/components/vodafone_station/fixtures/get_sensor_data.json create mode 100644 tests/components/vodafone_station/snapshots/test_button.ambr create mode 100644 tests/components/vodafone_station/snapshots/test_device_tracker.ambr create mode 100644 tests/components/vodafone_station/snapshots/test_sensor.ambr diff --git a/tests/components/vodafone_station/__init__.py b/tests/components/vodafone_station/__init__.py index 68f11a27b95..6119d94c06c 100644 --- a/tests/components/vodafone_station/__init__.py +++ b/tests/components/vodafone_station/__init__.py @@ -1 +1,13 @@ """Tests for the Vodafone Station integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/vodafone_station/conftest.py b/tests/components/vodafone_station/conftest.py index c36382e4c01..7763db5044a 100644 --- a/tests/components/vodafone_station/conftest.py +++ b/tests/components/vodafone_station/conftest.py @@ -2,11 +2,31 @@ from datetime import UTC, datetime +from aiovodafone import VodafoneStationDevice import pytest -from .const import DEVICE_DATA_QUERY, SENSOR_DATA_QUERY +from homeassistant.components.vodafone_station import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from tests.common import AsyncMock, Generator, patch +from .const import DEVICE_1_MAC + +from tests.common import ( + AsyncMock, + Generator, + MockConfigEntry, + load_json_object_fixture, + patch, +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.vodafone_station.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry @pytest.fixture @@ -17,12 +37,41 @@ def mock_vodafone_station_router() -> Generator[AsyncMock]: "homeassistant.components.vodafone_station.coordinator.VodafoneStationSercommApi", autospec=True, ) as mock_router, + patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi", + new=mock_router, + ), ): router = mock_router.return_value - router.get_devices_data.return_value = DEVICE_DATA_QUERY - router.get_sensor_data.return_value = SENSOR_DATA_QUERY + router.get_devices_data.return_value = { + DEVICE_1_MAC: VodafoneStationDevice( + connected=True, + connection_type="wifi", + ip_address="192.168.1.10", + name="WifiDevice0", + mac=DEVICE_1_MAC, + type="laptop", + wifi="2.4G", + ), + } + router.get_sensor_data.return_value = load_json_object_fixture( + "get_sensor_data.json", DOMAIN + ) router.convert_uptime.return_value = datetime( 2024, 11, 19, 20, 19, 0, tzinfo=UTC ) router.base_url = "https://fake_host" yield router + + +@pytest.fixture +def mock_config_entry() -> Generator[MockConfigEntry]: + """Mock a Vodafone Station config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) diff --git a/tests/components/vodafone_station/const.py b/tests/components/vodafone_station/const.py index 60eb2aff6f4..0f1ed2ba7da 100644 --- a/tests/components/vodafone_station/const.py +++ b/tests/components/vodafone_station/const.py @@ -1,118 +1,3 @@ """Common stuff for Vodafone Station tests.""" -from aiovodafone.api import VodafoneStationDevice - -from homeassistant.components.vodafone_station.const import DOMAIN -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME - -MOCK_CONFIG = { - DOMAIN: { - CONF_DEVICES: [ - { - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", - } - ] - } -} - -MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] - DEVICE_1_MAC = "xx:xx:xx:xx:xx:xx" -DEVICE_1 = { - DEVICE_1_MAC: VodafoneStationDevice( - connected=True, - connection_type="wifi", - ip_address="192.168.1.10", - name="WifiDevice0", - mac=DEVICE_1_MAC, - type="laptop", - wifi="2.4G", - ), -} -DEVICE_DATA_QUERY = DEVICE_1 - -SERIAL = "m123456789" - -SENSOR_DATA_QUERY = { - "sys_serial_number": SERIAL, - "sys_firmware_version": "XF6_4.0.05.04", - "sys_bootloader_version": "0220", - "sys_hardware_version": "RHG3006 v1", - "omci_software_version": "\t\t1.0.0.1_41032\t\t\n", - "sys_uptime": "12:16:41", - "sys_cpu_usage": "97%", - "sys_reboot_cause": "Web Reboot", - "sys_memory_usage": "51.94%", - "sys_wireless_driver_version": "17.10.188.75;17.10.188.75", - "sys_wireless_driver_version_5g": "17.10.188.75;17.10.188.75", - "vf_internet_key_online_since": "", - "vf_internet_key_ip_addr": "0.0.0.0", - "vf_internet_key_system": "0.0.0.0", - "vf_internet_key_mode": "Auto", - "sys_voip_version": "v02.01.00_01.13a\n", - "sys_date_time": "20.10.2024 | 03:44 pm", - "sys_build_time": "Sun Jun 23 17:55:49 CST 2024\n", - "sys_model_name": "RHG3006", - "inter_ip_address": "1.1.1.1", - "inter_gateway": "1.1.1.2", - "inter_primary_dns": "1.1.1.3", - "inter_secondary_dns": "1.1.1.4", - "inter_firewall": "601036", - "inter_wan_ip_address": "1.1.1.1", - "inter_ipv6_link_local_address": "", - "inter_ipv6_link_global_address": "", - "inter_ipv6_gateway": "", - "inter_ipv6_prefix_delegation": "", - "inter_ipv6_dns_address1": "", - "inter_ipv6_dns_address2": "", - "lan_ip_network": "192.168.0.1/24", - "lan_default_gateway": "192.168.0.1", - "lan_subnet_address_subnet1": "", - "lan_mac_address": "11:22:33:44:55:66", - "lan_dhcp_server": "601036", - "lan_dhcpv6_server": "601036", - "lan_router_advertisement": "601036", - "lan_ipv6_default_gateway": "fe80::1", - "lan_port1_switch_mode": "1301722", - "lan_port2_switch_mode": "1301722", - "lan_port3_switch_mode": "1301722", - "lan_port4_switch_mode": "1301722", - "lan_port1_switch_speed": "10", - "lan_port2_switch_speed": "100", - "lan_port3_switch_speed": "1000", - "lan_port4_switch_speed": "1000", - "lan_port1_switch_status": "1301724", - "lan_port2_switch_status": "1301724", - "lan_port3_switch_status": "1301724", - "lan_port4_switch_status": "1301724", - "wifi_status": "601036", - "wifi_name": "Wifi-Main-Network", - "wifi_mac_address": "AA:BB:CC:DD:EE:FF", - "wifi_security": "401027", - "wifi_channel": "8", - "wifi_bandwidth": "573", - "guest_wifi_status": "601037", - "guest_wifi_name": "Wifi-Guest", - "guest_wifi_mac_addr": "AA:BB:CC:DD:EE:GG", - "guest_wifi_security": "401027", - "guest_wifi_channel": "N/A", - "guest_wifi_ip": "192.168.2.1", - "guest_wifi_subnet_addr": "255.255.255.0", - "guest_wifi_dhcp_server": "192.168.2.1", - "wifi_status_5g": "601036", - "wifi_name_5g": "Wifi-Main-Network", - "wifi_mac_address_5g": "AA:BB:CC:DD:EE:HH", - "wifi_security_5g": "401027", - "wifi_channel_5g": "36", - "wifi_bandwidth_5g": "4803", - "guest_wifi_status_5g": "601037", - "guest_wifi_name_5g": "Wifi-Guest", - "guest_wifi_mac_addr_5g": "AA:BB:CC:DD:EE:II", - "guest_wifi_channel_5g": "N/A", - "guest_wifi_security_5g": "401027", - "guest_wifi_ip_5g": "192.168.2.1", - "guest_wifi_subnet_addr_5g": "255.255.255.0", - "guest_wifi_dhcp_server_5g": "192.168.2.1", -} diff --git a/tests/components/vodafone_station/fixtures/get_sensor_data.json b/tests/components/vodafone_station/fixtures/get_sensor_data.json new file mode 100644 index 00000000000..6a6229ebd18 --- /dev/null +++ b/tests/components/vodafone_station/fixtures/get_sensor_data.json @@ -0,0 +1,81 @@ +{ + "sys_serial_number": "m123456789", + "sys_firmware_version": "XF6_4.0.05.04", + "sys_bootloader_version": "0220", + "sys_hardware_version": "RHG3006 v1", + "omci_software_version": "\t\t1.0.0.1_41032\t\t\n", + "sys_uptime": "12:16:41", + "sys_cpu_usage": "97%", + "sys_reboot_cause": "Web Reboot", + "sys_memory_usage": "51.94%", + "sys_wireless_driver_version": "17.10.188.75;17.10.188.75", + "sys_wireless_driver_version_5g": "17.10.188.75;17.10.188.75", + "vf_internet_key_online_since": "", + "vf_internet_key_ip_addr": "0.0.0.0", + "vf_internet_key_system": "0.0.0.0", + "vf_internet_key_mode": "Auto", + "sys_voip_version": "v02.01.00_01.13a\n", + "sys_date_time": "20.10.2024 | 03:44 pm", + "sys_build_time": "Sun Jun 23 17:55:49 CST 2024\n", + "sys_model_name": "RHG3006", + "inter_ip_address": "1.1.1.1", + "inter_gateway": "1.1.1.2", + "inter_primary_dns": "1.1.1.3", + "inter_secondary_dns": "1.1.1.4", + "inter_firewall": "601036", + "inter_wan_ip_address": "1.1.1.1", + "inter_ipv6_link_local_address": "", + "inter_ipv6_link_global_address": "", + "inter_ipv6_gateway": "", + "inter_ipv6_prefix_delegation": "", + "inter_ipv6_dns_address1": "", + "inter_ipv6_dns_address2": "", + "lan_ip_network": "192.168.0.1/24", + "lan_default_gateway": "192.168.0.1", + "lan_subnet_address_subnet1": "", + "lan_mac_address": "11:22:33:44:55:66", + "lan_dhcp_server": "601036", + "lan_dhcpv6_server": "601036", + "lan_router_advertisement": "601036", + "lan_ipv6_default_gateway": "fe80::1", + "lan_port1_switch_mode": "1301722", + "lan_port2_switch_mode": "1301722", + "lan_port3_switch_mode": "1301722", + "lan_port4_switch_mode": "1301722", + "lan_port1_switch_speed": "10", + "lan_port2_switch_speed": "100", + "lan_port3_switch_speed": "1000", + "lan_port4_switch_speed": "1000", + "lan_port1_switch_status": "1301724", + "lan_port2_switch_status": "1301724", + "lan_port3_switch_status": "1301724", + "lan_port4_switch_status": "1301724", + "wifi_status": "601036", + "wifi_name": "Wifi-Main-Network", + "wifi_mac_address": "AA:BB:CC:DD:EE:FF", + "wifi_security": "401027", + "wifi_channel": "8", + "wifi_bandwidth": "573", + "guest_wifi_status": "601037", + "guest_wifi_name": "Wifi-Guest", + "guest_wifi_mac_addr": "AA:BB:CC:DD:EE:GG", + "guest_wifi_security": "401027", + "guest_wifi_channel": "N/A", + "guest_wifi_ip": "192.168.2.1", + "guest_wifi_subnet_addr": "255.255.255.0", + "guest_wifi_dhcp_server": "192.168.2.1", + "wifi_status_5g": "601036", + "wifi_name_5g": "Wifi-Main-Network", + "wifi_mac_address_5g": "AA:BB:CC:DD:EE:HH", + "wifi_security_5g": "401027", + "wifi_channel_5g": "36", + "wifi_bandwidth_5g": "4803", + "guest_wifi_status_5g": "601037", + "guest_wifi_name_5g": "Wifi-Guest", + "guest_wifi_mac_addr_5g": "AA:BB:CC:DD:EE:II", + "guest_wifi_channel_5g": "N/A", + "guest_wifi_security_5g": "401027", + "guest_wifi_ip_5g": "192.168.2.1", + "guest_wifi_subnet_addr_5g": "255.255.255.0", + "guest_wifi_dhcp_server_5g": "192.168.2.1" +} diff --git a/tests/components/vodafone_station/snapshots/test_button.ambr b/tests/components/vodafone_station/snapshots/test_button.ambr new file mode 100644 index 00000000000..dc7953ac42a --- /dev/null +++ b/tests/components/vodafone_station/snapshots/test_button.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_all_entities[button.vodafone_station_m123456789_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.vodafone_station_m123456789_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'm123456789_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.vodafone_station_m123456789_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Vodafone Station (m123456789) Restart', + }), + 'context': , + 'entity_id': 'button.vodafone_station_m123456789_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/vodafone_station/snapshots/test_device_tracker.ambr b/tests/components/vodafone_station/snapshots/test_device_tracker.ambr new file mode 100644 index 00000000000..834c8b14459 --- /dev/null +++ b/tests/components/vodafone_station/snapshots/test_device_tracker.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_all_entities[device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_tracker', + 'unique_id': 'xx:xx:xx:xx:xx:xx', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'host_name': 'WifiDevice0', + 'ip': '192.168.1.10', + 'mac': 'xx:xx:xx:xx:xx:xx', + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'home', + }) +# --- diff --git a/tests/components/vodafone_station/snapshots/test_sensor.ambr b/tests/components/vodafone_station/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..eb1676938b5 --- /dev/null +++ b/tests/components/vodafone_station/snapshots/test_sensor.ambr @@ -0,0 +1,246 @@ +# serializer version: 1 +# name: test_all_entities[sensor.vodafone_station_m123456789_active_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'dsl', + 'fiber', + 'internet_key', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vodafone_station_m123456789_active_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active connection', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_connection', + 'unique_id': 'm123456789_inter_ip_address', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_active_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Vodafone Station (m123456789) Active connection', + 'options': list([ + 'dsl', + 'fiber', + 'internet_key', + ]), + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_active_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_cpu_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vodafone_station_m123456789_cpu_usage', + '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': 'CPU usage', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sys_cpu_usage', + 'unique_id': 'm123456789_sys_cpu_usage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_cpu_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vodafone Station (m123456789) CPU usage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_cpu_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97.0', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_memory_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vodafone_station_m123456789_memory_usage', + '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': 'Memory usage', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sys_memory_usage', + 'unique_id': 'm123456789_sys_memory_usage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_memory_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vodafone Station (m123456789) Memory usage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_memory_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51.94', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_reboot_cause-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vodafone_station_m123456789_reboot_cause', + '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': 'Reboot cause', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sys_reboot_cause', + 'unique_id': 'm123456789_sys_reboot_cause', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_reboot_cause-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vodafone Station (m123456789) Reboot cause', + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_reboot_cause', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Web Reboot', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vodafone_station_m123456789_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sys_uptime', + 'unique_id': 'm123456789_sys_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Vodafone Station (m123456789) Uptime', + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-19T20:19:00+00:00', + }) +# --- diff --git a/tests/components/vodafone_station/test_button.py b/tests/components/vodafone_station/test_button.py index 8b9b0753caa..d5f377d3f6f 100644 --- a/tests/components/vodafone_station/test_button.py +++ b/tests/components/vodafone_station/test_button.py @@ -1,56 +1,48 @@ """Tests for Vodafone Station button platform.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.components.vodafone_station.const import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.helpers import entity_registry as er -from .const import DEVICE_DATA_QUERY, MOCK_USER_DATA, SENSOR_DATA_QUERY, SERIAL +from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform -async def test_button(hass: HomeAssistant, entity_registry: EntityRegistry) -> None: +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.vodafone_station.PLATFORMS", [Platform.BUTTON] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_pressing_button( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test device restart button.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_config_entry) - with ( - patch("aiovodafone.api.VodafoneStationSercommApi.login"), - patch( - "aiovodafone.api.VodafoneStationSercommApi.get_devices_data", - return_value=DEVICE_DATA_QUERY, - ), - patch( - "aiovodafone.api.VodafoneStationSercommApi.get_sensor_data", - return_value=SENSOR_DATA_QUERY, - ), - patch( - "aiovodafone.api.VodafoneStationSercommApi.restart_router", - ) as mock_router_restart, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_id = f"button.vodafone_station_{SERIAL}_restart" - - # restart button - state = hass.states.get(entity_id) - assert state - assert state.state == STATE_UNKNOWN - - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.unique_id == f"{SERIAL}_reboot" - - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert mock_router_restart.call_count == 1 + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.vodafone_station_m123456789_restart"}, + blocking=True, + ) + mock_vodafone_station_router.restart_router.assert_called_once() diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 3a54f250871..68f8247bdf9 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -1,8 +1,13 @@ """Tests for Vodafone Station config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock -from aiovodafone import exceptions as aiovodafone_exceptions +from aiovodafone import ( + AlreadyLogged, + CannotAuthenticate, + CannotConnect, + ModelNotSupported, +) import pytest from homeassistant.components.device_tracker import CONF_CONSIDER_HOME @@ -12,39 +17,36 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import MOCK_USER_DATA - from tests.common import MockConfigEntry -async def test_user(hass: HomeAssistant) -> None: +async def test_user( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: """Test starting a flow by user.""" - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry" - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_DATA - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_USERNAME] == "fake_username" - assert result["data"][CONF_PASSWORD] == "fake_password" - assert not result["result"].unique_id - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + } + assert not result["result"].unique_id assert mock_setup_entry.called @@ -52,14 +54,20 @@ async def test_user(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("side_effect", "error"), [ - (aiovodafone_exceptions.CannotConnect, "cannot_connect"), - (aiovodafone_exceptions.CannotAuthenticate, "invalid_auth"), - (aiovodafone_exceptions.AlreadyLogged, "already_logged"), - (aiovodafone_exceptions.ModelNotSupported, "model_not_supported"), + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + (AlreadyLogged, "already_logged"), + (ModelNotSupported, "model_not_supported"), (ConnectionResetError, "unknown"), ], ) -async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> None: +async def test_exception_connection( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + side_effect: Exception, + error: str, +) -> None: """Test starting a flow by user with a connection error.""" result = await hass.config_entries.flow.async_init( @@ -68,178 +76,153 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "user" - with patch( - "aiovodafone.api.VodafoneStationSercommApi.login", - side_effect=side_effect, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_DATA - ) + mock_vodafone_station_router.login.side_effect = side_effect - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] is not None - assert result["errors"]["base"] == error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) - # Should be recoverable after hits error - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.get_devices_data", - return_value={ - "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", - "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", - }, - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry", - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", - }, - ) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "fake_host" - assert result2["data"] == { - "host": "fake_host", - "username": "fake_username", - "password": "fake_password", - } + mock_vodafone_station_router.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "fake_host" + assert result["data"] == { + "host": "fake_host", + "username": "fake_username", + "password": "fake_password", + } -async def test_reauth_successful(hass: HomeAssistant) -> None: +async def test_duplicate_entry( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test starting a flow by user with a duplicate entry.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_successful( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test starting a reauthentication flow.""" - - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - mock_config.add_to_hass(hass) - result = await mock_config.start_reauth_flow(hass) + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry", - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PASSWORD: "other_fake_password", - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + }, + ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" @pytest.mark.parametrize( ("side_effect", "error"), [ - (aiovodafone_exceptions.CannotConnect, "cannot_connect"), - (aiovodafone_exceptions.CannotAuthenticate, "invalid_auth"), - (aiovodafone_exceptions.AlreadyLogged, "already_logged"), + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + (AlreadyLogged, "already_logged"), (ConnectionResetError, "unknown"), ], ) -async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> None: +async def test_reauth_not_successful( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: """Test starting a reauthentication flow but no connection found.""" - - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - mock_config.add_to_hass(hass) - - result = await mock_config.start_reauth_flow(hass) + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - side_effect=side_effect, - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry", - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PASSWORD: "other_fake_password", - }, - ) + mock_vodafone_station_router.login.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + }, + ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] is not None - assert result["errors"]["base"] == error + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} - # Should be recoverable after hits error - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.get_devices_data", - return_value={ - "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", - "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", - }, - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry", - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PASSWORD: "fake_password", - }, - ) - await hass.async_block_till_done() + mock_vodafone_station_router.login.side_effect = None - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "fake_password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "fake_password" -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test options flow.""" - - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - mock_config.add_to_hass(hass) - - result = await hass.config_entries.options.async_init(mock_config.entry_id) - await hass.async_block_till_done() + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_CONSIDER_HOME: 37, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { diff --git a/tests/components/vodafone_station/test_device_tracker.py b/tests/components/vodafone_station/test_device_tracker.py index 1434d682ec9..5133d0da980 100644 --- a/tests/components/vodafone_station/test_device_tracker.py +++ b/tests/components/vodafone_station/test_device_tracker.py @@ -1,43 +1,59 @@ """Define tests for the Vodafone Station device tracker.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.vodafone_station.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.vodafone_station.const import SCAN_INTERVAL from homeassistant.components.vodafone_station.coordinator import CONSIDER_HOME_SECONDS -from homeassistant.const import STATE_HOME, STATE_NOT_HOME +from homeassistant.const import STATE_HOME, STATE_NOT_HOME, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .const import DEVICE_1, DEVICE_1_MAC, DEVICE_DATA_QUERY, MOCK_USER_DATA +from . import setup_integration +from .const import DEVICE_1_MAC -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_coordinator_consider_home( +async def test_all_entities( hass: HomeAssistant, - freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.vodafone_station.PLATFORMS", [Platform.DEVICE_TRACKER] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_consider_home( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test if device is considered not_home when disconnected.""" + await setup_integration(hass, mock_config_entry) - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) - - device_tracker = f"device_tracker.vodafone_station_{DEVICE_1_MAC.replace(":", "_")}" - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + device_tracker = "device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx" state = hass.states.get(device_tracker) assert state assert state.state == STATE_HOME - DEVICE_1[DEVICE_1_MAC].connected = False - DEVICE_DATA_QUERY.update(DEVICE_1) - mock_vodafone_station_router.get_devices_data.return_value = DEVICE_DATA_QUERY + mock_vodafone_station_router.get_devices_data.return_value[ + DEVICE_1_MAC + ].connected = False freezer.tick(SCAN_INTERVAL + CONSIDER_HOME_SECONDS) async_fire_time_changed(hass) diff --git a/tests/components/vodafone_station/test_diagnostics.py b/tests/components/vodafone_station/test_diagnostics.py index 02918d81912..5a4a46ce693 100644 --- a/tests/components/vodafone_station/test_diagnostics.py +++ b/tests/components/vodafone_station/test_diagnostics.py @@ -2,16 +2,14 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock from syrupy import SnapshotAssertion from syrupy.filters import props -from homeassistant.components.vodafone_station.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .const import DEVICE_DATA_QUERY, MOCK_USER_DATA, SENSOR_DATA_QUERY +from . import setup_integration from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -20,29 +18,17 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_config_entry) - with ( - patch("aiovodafone.api.VodafoneStationSercommApi.login"), - patch( - "aiovodafone.api.VodafoneStationSercommApi.get_devices_data", - return_value=DEVICE_DATA_QUERY, - ), - patch( - "aiovodafone.api.VodafoneStationSercommApi.get_sensor_data", - return_value=SENSOR_DATA_QUERY, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state == ConfigEntryState.LOADED - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) == snapshot( exclude=props( "entry_id", "created_at", diff --git a/tests/components/vodafone_station/test_sensor.py b/tests/components/vodafone_station/test_sensor.py index 3a63566b5dc..ddf97824c75 100644 --- a/tests/components/vodafone_station/test_sensor.py +++ b/tests/components/vodafone_station/test_sensor.py @@ -1,21 +1,37 @@ """Tests for Vodafone Station sensor platform.""" -from copy import deepcopy -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch +from aiovodafone import CannotAuthenticate +from aiovodafone.exceptions import AlreadyLogged, CannotConnect from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.vodafone_station.const import ( - DOMAIN, - LINE_TYPES, - SCAN_INTERVAL, -) +from homeassistant.components.vodafone_station.const import LINE_TYPES, SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .const import MOCK_USER_DATA, SENSOR_DATA_QUERY +from . import setup_integration -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.vodafone_station.PLATFORMS", [Platform.SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( @@ -30,26 +46,22 @@ async def test_active_connection_type( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_vodafone_station_router: AsyncMock, - connection_type, - index, + mock_config_entry: MockConfigEntry, + connection_type: str, + index: int, ) -> None: """Test device connection type.""" + await setup_integration(hass, mock_config_entry) - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) - - active_connection_entity = f"sensor.vodafone_station_{SENSOR_DATA_QUERY['sys_serial_number']}_active_connection" - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + active_connection_entity = "sensor.vodafone_station_m123456789_active_connection" state = hass.states.get(active_connection_entity) assert state - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN - sensor_data = deepcopy(SENSOR_DATA_QUERY) - sensor_data[connection_type] = "1.1.1.1" - mock_vodafone_station_router.get_sensor_data.return_value = sensor_data + mock_vodafone_station_router.get_sensor_data.return_value[connection_type] = ( + "1.1.1.1" + ) freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) @@ -65,19 +77,13 @@ async def test_uptime( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test device uptime shift.""" - - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_config_entry) uptime = "2024-11-19T20:19:00+00:00" - uptime_entity = ( - f"sensor.vodafone_station_{SENSOR_DATA_QUERY['sys_serial_number']}_uptime" - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + uptime_entity = "sensor.vodafone_station_m123456789_uptime" state = hass.states.get(uptime_entity) assert state @@ -92,3 +98,32 @@ async def test_uptime( state = hass.states.get(uptime_entity) assert state assert state.state == uptime + + +@pytest.mark.parametrize( + "side_effect", + [ + CannotConnect, + CannotAuthenticate, + AlreadyLogged, + ConnectionResetError, + ], +) +async def test_coordinator_client_connector_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test ClientConnectorError on coordinator update.""" + await setup_integration(hass, mock_config_entry) + + mock_vodafone_station_router.get_devices_data.side_effect = side_effect + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.vodafone_station_m123456789_uptime") + assert state + assert state.state == STATE_UNAVAILABLE From b8b7daff5aa2746b7295b86ae01d89effc4f2846 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 9 Jan 2025 22:23:53 +0100 Subject: [PATCH 0259/2987] Implement upload retry logic in CloudBackupAgent (#135062) * Implement upload retry logic in CloudBackupAgent * Update backup.py Co-authored-by: Erik Montnemery * nit --------- Co-authored-by: Erik Montnemery --- homeassistant/components/cloud/backup.py | 97 +++++++++++++++++------- tests/components/cloud/test_backup.py | 7 ++ 2 files changed, 77 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 632248224a2..f94a3a0ff49 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -2,10 +2,12 @@ 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, Self from aiohttp import ClientError, ClientTimeout, StreamReader @@ -26,6 +28,9 @@ from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT _LOGGER = logging.getLogger(__name__) _STORAGE_BACKUP = "backup" +_RETRY_LIMIT = 5 +_RETRY_SECONDS_MIN = 60 +_RETRY_SECONDS_MAX = 600 async def _b64md5(stream: AsyncIterator[bytes]) -> str: @@ -149,6 +154,44 @@ class CloudBackupAgent(BackupAgent): return ChunkAsyncStreamIterator(resp.content) + async def _async_do_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + filename: str, + base64md5hash: str, + metadata: dict[str, Any], + size: int, + ) -> None: + """Upload a backup.""" + try: + details = await async_files_upload_details( + self._cloud, + storage_type=_STORAGE_BACKUP, + filename=filename, + metadata=metadata, + size=size, + base64md5hash=base64md5hash, + ) + except (ClientError, CloudError) as err: + raise BackupAgentError("Failed to get upload details") from err + + try: + upload_status = await self._cloud.websession.put( + details["url"], + data=await open_stream(), + headers=details["headers"] | {"content-length": str(size)}, + timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h + ) + _LOGGER.log( + logging.DEBUG if upload_status.status < 400 else logging.WARNING, + "Backup upload status: %s", + upload_status.status, + ) + upload_status.raise_for_status() + except (TimeoutError, ClientError) as err: + raise BackupAgentError("Failed to upload backup") from err + async def async_upload_backup( self, *, @@ -165,34 +208,34 @@ class CloudBackupAgent(BackupAgent): raise BackupAgentError("Cloud backups must be protected") base64md5hash = await _b64md5(await open_stream()) + filename = self._get_backup_filename() + metadata = backup.as_dict() + size = backup.size - try: - details = await async_files_upload_details( - self._cloud, - storage_type=_STORAGE_BACKUP, - filename=self._get_backup_filename(), - metadata=backup.as_dict(), - size=backup.size, - base64md5hash=base64md5hash, - ) - except (ClientError, CloudError) as err: - raise BackupAgentError("Failed to get upload details") from err - - try: - upload_status = await self._cloud.websession.put( - details["url"], - data=await open_stream(), - headers=details["headers"] | {"content-length": str(backup.size)}, - timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h - ) - _LOGGER.log( - logging.DEBUG if upload_status.status < 400 else logging.WARNING, - "Backup upload status: %s", - upload_status.status, - ) - upload_status.raise_for_status() - except (TimeoutError, ClientError) as err: - raise BackupAgentError("Failed to upload backup") from err + tries = 1 + while tries <= _RETRY_LIMIT: + try: + await self._async_do_upload_backup( + open_stream=open_stream, + filename=filename, + base64md5hash=base64md5hash, + metadata=metadata, + size=size, + ) + break + except BackupAgentError as err: + if tries == _RETRY_LIMIT: + raise + tries += 1 + retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX) + _LOGGER.info( + "Failed to upload backup, retrying (%s/%s) in %ss: %s", + tries, + _RETRY_LIMIT, + retry_timer, + err, + ) + await asyncio.sleep(retry_timer) async def async_delete_backup( self, diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 5d9513a1d1b..fc8c7f27e56 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -389,6 +389,7 @@ async def test_agents_upload_fail_put( aioclient_mock: AiohttpClientMocker, mock_get_upload_details: Mock, put_mock_kwargs: dict[str, Any], + caplog: pytest.LogCaptureFixture, ) -> None: """Test agent upload backup fails.""" client = await hass_client() @@ -417,6 +418,9 @@ async def test_agents_upload_fail_put( return_value=test_backup, ), patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.cloud.backup.asyncio.sleep"), + 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""]) fetch_backup.return_value = test_backup @@ -426,6 +430,8 @@ async def test_agents_upload_fail_put( ) await hass.async_block_till_done() + assert len(aioclient_mock.mock_calls) == 2 + assert "Failed to upload backup, retrying (2/2) in 60s" in caplog.text assert resp.status == 201 store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] assert len(store_backups) == 1 @@ -469,6 +475,7 @@ async def test_agents_upload_fail_cloud( return_value=test_backup, ), patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.cloud.backup.asyncio.sleep"), ): mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) fetch_backup.return_value = test_backup From 42cdd25d908978c5a879033ef8abbc7a20820d0c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 9 Jan 2025 10:53:33 +0100 Subject: [PATCH 0260/2987] Add jitter to backup start time to avoid thundering herd (#135065) --- homeassistant/components/backup/config.py | 7 +++++++ tests/components/backup/test_websocket.py | 2 ++ 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 3c5d5d39f7e..7c40792aec5 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -7,6 +7,7 @@ from collections.abc import Callable from dataclasses import dataclass, field, replace from datetime import datetime, timedelta from enum import StrEnum +import random from typing import TYPE_CHECKING, Self, TypedDict from cronsim import CronSim @@ -28,6 +29,10 @@ if TYPE_CHECKING: CRON_PATTERN_DAILY = "45 4 * * *" CRON_PATTERN_WEEKLY = "45 4 * * {}" +# Randomize the start time of the backup by up to 60 minutes to avoid +# all backups running at the same time. +BACKUP_START_TIME_JITTER = 60 * 60 + class StoredBackupConfig(TypedDict): """Represent the stored backup config.""" @@ -329,6 +334,8 @@ class BackupSchedule: except Exception: # noqa: BLE001 LOGGER.exception("Unexpected error creating automatic backup") + next_time += timedelta(seconds=random.randint(0, BACKUP_START_TIME_JITTER)) + LOGGER.debug("Scheduling next automatic backup at %s", next_time) manager.remove_next_backup_event = async_track_point_in_time( manager.hass, _create_backup, next_time ) diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 307a1d79e0c..e95481373d6 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -1345,6 +1345,7 @@ async def test_config_update_errors( ), ], ) +@patch("homeassistant.components.backup.config.BACKUP_START_TIME_JITTER", 0) async def test_config_schedule_logic( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1787,6 +1788,7 @@ async def test_config_schedule_logic( ), ], ) +@patch("homeassistant.components.backup.config.BACKUP_START_TIME_JITTER", 0) async def test_config_retention_copies_logic( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, From 1eddb4a21b4cc7a2251736f2d0a7055859c9ecbd Mon Sep 17 00:00:00 2001 From: jb101010-2 <168106462+jb101010-2@users.noreply.github.com> Date: Wed, 8 Jan 2025 15:12:56 +0100 Subject: [PATCH 0261/2987] Bump pysuezV2 to 2.0.3 (#135080) --- homeassistant/components/suez_water/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 176b059f3d5..5d317ea5ba3 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], "quality_scale": "bronze", - "requirements": ["pysuezV2==2.0.1"] + "requirements": ["pysuezV2==2.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 23189df493d..18804643b7c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2309,7 +2309,7 @@ pysqueezebox==0.10.0 pystiebeleltron==0.0.1.dev2 # homeassistant.components.suez_water -pysuezV2==2.0.1 +pysuezV2==2.0.3 # homeassistant.components.switchbee pyswitchbee==1.8.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ad17b89cd0a..469ff2cbe19 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1875,7 +1875,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.10.0 # homeassistant.components.suez_water -pysuezV2==2.0.1 +pysuezV2==2.0.3 # homeassistant.components.switchbee pyswitchbee==1.8.3 From 30924b561a8d9d2e109457ce19c7b6945b4bc208 Mon Sep 17 00:00:00 2001 From: Brynley McDonald Date: Thu, 9 Jan 2025 22:09:04 +1300 Subject: [PATCH 0262/2987] Fix Flick Electric Pricing (#135154) --- homeassistant/components/flick_electric/manifest.json | 2 +- homeassistant/components/flick_electric/sensor.py | 6 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/flick_electric/manifest.json b/homeassistant/components/flick_electric/manifest.json index 3aee25995a9..3096590f47a 100644 --- a/homeassistant/components/flick_electric/manifest.json +++ b/homeassistant/components/flick_electric/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyflick"], - "requirements": ["PyFlick==1.1.2"] + "requirements": ["PyFlick==1.1.3"] } diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py index 147d00c943d..73b6f8793fb 100644 --- a/homeassistant/components/flick_electric/sensor.py +++ b/homeassistant/components/flick_electric/sensor.py @@ -51,19 +51,19 @@ class FlickPricingSensor(CoordinatorEntity[FlickElectricDataCoordinator], Sensor _LOGGER.warning( "Unexpected quantity for unit price: %s", self.coordinator.data ) - return self.coordinator.data.cost + return self.coordinator.data.cost * 100 @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" - components: dict[str, Decimal] = {} + components: dict[str, float] = {} for component in self.coordinator.data.components: if component.charge_setter not in ATTR_COMPONENTS: _LOGGER.warning("Found unknown component: %s", component.charge_setter) continue - components[component.charge_setter] = component.value + components[component.charge_setter] = float(component.value * 100) return { ATTR_START_AT: self.coordinator.data.start_at, diff --git a/requirements_all.txt b/requirements_all.txt index 18804643b7c..92172aec637 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -48,7 +48,7 @@ ProgettiHWSW==0.1.3 PyChromecast==14.0.5 # homeassistant.components.flick_electric -PyFlick==1.1.2 +PyFlick==1.1.3 # homeassistant.components.flume PyFlume==0.6.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 469ff2cbe19..055fc3700a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -45,7 +45,7 @@ ProgettiHWSW==0.1.3 PyChromecast==14.0.5 # homeassistant.components.flick_electric -PyFlick==1.1.2 +PyFlick==1.1.3 # homeassistant.components.flume PyFlume==0.6.5 From 5d201406cb10c0bfbd3b3969a9c4a1869bd59442 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 9 Jan 2025 22:13:39 +0100 Subject: [PATCH 0263/2987] Update frontend to 20250109.0 (#135235) --- 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 267374aa302..3d9f12bd3d3 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==20250106.0"] + "requirements": ["home-assistant-frontend==20250109.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index dac77fd4276..3d9ecdece06 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ habluetooth==3.7.0 hass-nabucasa==0.87.0 hassil==2.1.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250106.0 +home-assistant-frontend==20250109.0 home-assistant-intents==2025.1.1 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 92172aec637..f11d3b691e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1134,7 +1134,7 @@ hole==0.8.0 holidays==0.64 # homeassistant.components.frontend -home-assistant-frontend==20250106.0 +home-assistant-frontend==20250109.0 # homeassistant.components.conversation home-assistant-intents==2025.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 055fc3700a1..11260d55e59 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -963,7 +963,7 @@ hole==0.8.0 holidays==0.64 # homeassistant.components.frontend -home-assistant-frontend==20250106.0 +home-assistant-frontend==20250109.0 # homeassistant.components.conversation home-assistant-intents==2025.1.1 From 0027d907a410097db6cd24386feb415efebb8e79 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 9 Jan 2025 22:25:42 +0100 Subject: [PATCH 0264/2987] Bump version to 2025.1.2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e641ae4254c..9f25ff3f80a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "1" +PATCH_VERSION: Final = "2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index f94d54feb88..4d88c5641fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.1.1" +version = "2025.1.2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From da30dbcfe4eb5becc0afe89e85223f13ee950d35 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Jan 2025 12:03:08 -1000 Subject: [PATCH 0265/2987] Bump fnv-hash-fast to 1.1.0 (#135237) --- homeassistant/components/homekit/manifest.json | 2 +- homeassistant/components/recorder/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, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index cf74bcc7d67..74b1f96b26e 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -10,7 +10,7 @@ "loggers": ["pyhap"], "requirements": [ "HAP-python==4.9.2", - "fnv-hash-fast==1.0.2", + "fnv-hash-fast==1.1.0", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 93ffb12d18c..a0f559fcc13 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -8,7 +8,7 @@ "quality_scale": "internal", "requirements": [ "SQLAlchemy==2.0.36", - "fnv-hash-fast==1.0.2", + "fnv-hash-fast==1.1.0", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5a267483eea..f45aaadf77e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.0 dbus-fast==2.28.0 -fnv-hash-fast==1.0.2 +fnv-hash-fast==1.1.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.7.0 diff --git a/pyproject.toml b/pyproject.toml index 12867a82912..9a484f35bba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "certifi>=2021.5.30", "ciso8601==2.3.2", "cronsim==2.6", - "fnv-hash-fast==1.0.2", + "fnv-hash-fast==1.1.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration "hass-nabucasa==0.87.0", diff --git a/requirements.txt b/requirements.txt index a59ce1837d0..4ad1140979a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ bcrypt==4.2.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 -fnv-hash-fast==1.0.2 +fnv-hash-fast==1.1.0 hass-nabucasa==0.87.0 httpx==0.27.2 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index f92136f9ec9..d1dbb51a92a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -934,7 +934,7 @@ flux-led==1.1.0 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.0.2 +fnv-hash-fast==1.1.0 # homeassistant.components.foobot foobot_async==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6a163c24fce..50e2675131e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -793,7 +793,7 @@ flux-led==1.1.0 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.0.2 +fnv-hash-fast==1.1.0 # homeassistant.components.foobot foobot_async==1.0.0 From 139b747a702ba644eb8527a40b0ee7d1850ad55f Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Thu, 9 Jan 2025 22:47:53 +0000 Subject: [PATCH 0266/2987] Expand Squeezebox auth test for config_flow to finish on create_entry (#133612) Expand auth test to create_entry --- .../components/squeezebox/test_config_flow.py | 58 ++++++++++++++++--- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index f2c9636c470..455d4c962b0 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -143,27 +143,67 @@ async def test_user_form_duplicate(hass: HomeAssistant) -> None: async def test_form_invalid_auth(hass: HomeAssistant) -> None: """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "edit"} - ) async def patch_async_query(self, *args): self.http_status = HTTPStatus.UNAUTHORIZED return False - with patch("pysqueezebox.Server.async_query", new=patch_async_query): + with ( + patch( + "pysqueezebox.Server.async_query", + return_value={"uuid": UUID}, + ), + patch( + "homeassistant.components.squeezebox.async_setup_entry", + return_value=True, + ), + patch( + "homeassistant.components.squeezebox.config_flow.async_discover", + mock_discover, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "edit" + + with patch( + "homeassistant.components.squeezebox.config_flow.Server.async_query", + new=patch_async_query, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_HOST: HOST, CONF_PORT: PORT, - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, }, ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + } async def test_form_validate_exception(hass: HomeAssistant) -> None: From 3c6113e37c71709a54551e2d257a7e41d42cbfa1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Jan 2025 12:50:13 -1000 Subject: [PATCH 0267/2987] Remove per engine max bind vars (#135153) --- .../auto_repairs/statistics/duplicates.py | 14 +++++++------- homeassistant/components/recorder/const.py | 10 ---------- homeassistant/components/recorder/core.py | 7 ++----- homeassistant/components/recorder/util.py | 18 ++---------------- 4 files changed, 11 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py index b73744ef0d1..f203d6ab69a 100644 --- a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py +++ b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py @@ -17,7 +17,7 @@ from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.util import dt as dt_util -from ...const import SQLITE_MAX_BIND_VARS +from ...const import DEFAULT_MAX_BIND_VARS from ...db_schema import Statistics, StatisticsBase, StatisticsMeta, StatisticsShortTerm from ...util import database_job_retry_wrapper, execute @@ -61,7 +61,7 @@ def _find_duplicates( ) .filter(subquery.c.is_duplicate == 1) .order_by(table.metadata_id, table.start, table.id.desc()) - .limit(1000 * SQLITE_MAX_BIND_VARS) + .limit(1000 * DEFAULT_MAX_BIND_VARS) ) duplicates = execute(query) original_as_dict = {} @@ -125,10 +125,10 @@ def _delete_duplicates_from_table( if not duplicate_ids: break all_non_identical_duplicates.extend(non_identical_duplicates) - for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): + for i in range(0, len(duplicate_ids), DEFAULT_MAX_BIND_VARS): deleted_rows = ( session.query(table) - .filter(table.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS])) + .filter(table.id.in_(duplicate_ids[i : i + DEFAULT_MAX_BIND_VARS])) .delete(synchronize_session=False) ) total_deleted_rows += deleted_rows @@ -205,7 +205,7 @@ def _find_statistics_meta_duplicates(session: Session) -> list[int]: ) .filter(subquery.c.is_duplicate == 1) .order_by(StatisticsMeta.statistic_id, StatisticsMeta.id.desc()) - .limit(1000 * SQLITE_MAX_BIND_VARS) + .limit(1000 * DEFAULT_MAX_BIND_VARS) ) duplicates = execute(query) statistic_id = None @@ -230,11 +230,11 @@ def _delete_statistics_meta_duplicates(session: Session) -> int: duplicate_ids = _find_statistics_meta_duplicates(session) if not duplicate_ids: break - for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): + for i in range(0, len(duplicate_ids), DEFAULT_MAX_BIND_VARS): deleted_rows = ( session.query(StatisticsMeta) .filter( - StatisticsMeta.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS]) + StatisticsMeta.id.in_(duplicate_ids[i : i + DEFAULT_MAX_BIND_VARS]) ) .delete(synchronize_session=False) ) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 409641e54c9..c91845e8436 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -32,16 +32,6 @@ MIN_AVAILABLE_MEMORY_FOR_QUEUE_BACKLOG = 256 * 1024**2 # The maximum number of rows (events) we purge in one delete statement -# sqlite3 has a limit of 999 until version 3.32.0 -# in https://github.com/sqlite/sqlite/commit/efdba1a8b3c6c967e7fae9c1989c40d420ce64cc -# We can increase this back to 1000 once most -# have upgraded their sqlite version -SQLITE_MAX_BIND_VARS = 998 - -# The maximum bind vars for sqlite 3.32.0 and above, but -# capped at 4000 to avoid performance issues -SQLITE_MODERN_MAX_BIND_VARS = 4000 - DEFAULT_MAX_BIND_VARS = 4000 DB_WORKER_PREFIX = "DbWorker" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index fee72ce273f..5a405061a94 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -52,6 +52,7 @@ from homeassistant.util.event_type import EventType from . import migration, statistics from .const import ( DB_WORKER_PREFIX, + DEFAULT_MAX_BIND_VARS, DOMAIN, KEEPALIVE_TIME, LAST_REPORTED_SCHEMA_VERSION, @@ -61,7 +62,6 @@ from .const import ( MIN_AVAILABLE_MEMORY_FOR_QUEUE_BACKLOG, MYSQLDB_PYMYSQL_URL_PREFIX, MYSQLDB_URL_PREFIX, - SQLITE_MAX_BIND_VARS, SQLITE_URL_PREFIX, SupportedDialect, ) @@ -230,12 +230,9 @@ class Recorder(threading.Thread): self._dialect_name: SupportedDialect | None = None self.enabled = True - # For safety we default to the lowest value for max_bind_vars - # of all the DB types (SQLITE_MAX_BIND_VARS). - # # We update the value once we connect to the DB # and determine what is actually supported. - self.max_bind_vars = SQLITE_MAX_BIND_VARS + self.max_bind_vars = DEFAULT_MAX_BIND_VARS @property def backlog(self) -> int: diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 632553838c2..55364863f7e 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -36,14 +36,7 @@ from homeassistant.helpers.recorder import ( # noqa: F401 ) import homeassistant.util.dt as dt_util -from .const import ( - DEFAULT_MAX_BIND_VARS, - DOMAIN, - SQLITE_MAX_BIND_VARS, - SQLITE_MODERN_MAX_BIND_VARS, - SQLITE_URL_PREFIX, - SupportedDialect, -) +from .const import DEFAULT_MAX_BIND_VARS, DOMAIN, SQLITE_URL_PREFIX, SupportedDialect from .db_schema import ( TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, @@ -96,7 +89,6 @@ MARIADB_WITH_FIXED_IN_QUERIES_108 = _simple_version("10.8.4") MIN_VERSION_MYSQL = _simple_version("8.0.0") MIN_VERSION_PGSQL = _simple_version("12.0") MIN_VERSION_SQLITE = _simple_version("3.40.1") -MIN_VERSION_SQLITE_MODERN_BIND_VARS = _simple_version("3.40.1") # This is the maximum time after the recorder ends the session @@ -473,7 +465,6 @@ def setup_connection_for_dialect( version: AwesomeVersion | None = None slow_range_in_select = False if dialect_name == SupportedDialect.SQLITE: - max_bind_vars = SQLITE_MAX_BIND_VARS if first_connection: old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined] dbapi_connection.isolation_level = None # type: ignore[attr-defined] @@ -491,9 +482,6 @@ def setup_connection_for_dialect( version or version_string, "SQLite", MIN_VERSION_SQLITE ) - if version and version > MIN_VERSION_SQLITE_MODERN_BIND_VARS: - max_bind_vars = SQLITE_MODERN_MAX_BIND_VARS - # The upper bound on the cache size is approximately 16MiB of memory execute_on_connection(dbapi_connection, "PRAGMA cache_size = -16384") @@ -512,7 +500,6 @@ def setup_connection_for_dialect( execute_on_connection(dbapi_connection, "PRAGMA foreign_keys=ON") elif dialect_name == SupportedDialect.MYSQL: - max_bind_vars = DEFAULT_MAX_BIND_VARS execute_on_connection(dbapi_connection, "SET session wait_timeout=28800") if first_connection: result = query_on_connection(dbapi_connection, "SELECT VERSION()") @@ -553,7 +540,6 @@ def setup_connection_for_dialect( # Ensure all times are using UTC to avoid issues with daylight savings execute_on_connection(dbapi_connection, "SET time_zone = '+00:00'") elif dialect_name == SupportedDialect.POSTGRESQL: - max_bind_vars = DEFAULT_MAX_BIND_VARS # PostgreSQL does not support a skip/loose index scan so its # also slow for large distinct queries: # https://wiki.postgresql.org/wiki/Loose_indexscan @@ -580,7 +566,7 @@ def setup_connection_for_dialect( dialect=SupportedDialect(dialect_name), version=version, optimizer=DatabaseOptimizer(slow_range_in_select=slow_range_in_select), - max_bind_vars=max_bind_vars, + max_bind_vars=DEFAULT_MAX_BIND_VARS, ) From 823feae0f9bf03cb56189f998441a4fd8b7853e7 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 10 Jan 2025 08:45:06 +0100 Subject: [PATCH 0268/2987] Make description of alarm_arm_vacation consistent (#135257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Small fix to also use "Arms …" in the description of the alarm_arm_vacation action, making it consistent with the other two alarm_arm_… actions. --- homeassistant/components/elkm1/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json index bf02d727280..f184483646d 100644 --- a/homeassistant/components/elkm1/strings.json +++ b/homeassistant/components/elkm1/strings.json @@ -89,7 +89,7 @@ }, "alarm_arm_vacation": { "name": "Alarm arm vacation", - "description": "Arm the ElkM1 in vacation mode.", + "description": "Arms the ElkM1 in vacation mode.", "fields": { "code": { "name": "Code", From 5df7092f41b44953c3357e03ecdb256c098e885f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jan 2025 09:08:37 +0100 Subject: [PATCH 0269/2987] Improve formatting in core files (#135256) * Adjust core files formatting * Adjust translations script --- homeassistant/backup_restore.py | 2 +- homeassistant/config_entries.py | 5 +---- homeassistant/core.py | 3 +-- homeassistant/exceptions.py | 2 +- homeassistant/helpers/dispatcher.py | 2 +- homeassistant/helpers/entity_registry.py | 4 ++-- homeassistant/helpers/script.py | 12 ++++++------ homeassistant/helpers/service.py | 3 +-- homeassistant/loader.py | 3 +-- homeassistant/util/loop.py | 3 ++- script/hassfest/config_flow.py | 3 +-- .../hassfest/quality_scale_validation/discovery.py | 3 +-- script/hassfest/translations.py | 11 +++++++---- script/scaffold/gather_info.py | 2 +- script/split_tests.py | 2 +- tests/components/conftest.py | 2 +- tests/conftest.py | 2 +- tests/helpers/test_translation.py | 2 +- tests/syrupy.py | 2 +- tests/test_config_entries.py | 4 ++-- 20 files changed, 34 insertions(+), 38 deletions(-) diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py index 57e1c734dfc..3d24d807a06 100644 --- a/homeassistant/backup_restore.py +++ b/homeassistant/backup_restore.py @@ -119,7 +119,7 @@ def _extract_backup( Path( tempdir, "extracted", - f"homeassistant.tar{'.gz' if backup_meta["compressed"] else ''}", + f"homeassistant.tar{'.gz' if backup_meta['compressed'] else ''}", ), gzip=backup_meta["compressed"], key=password_to_key(restore_content.password) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ade4cd855ca..b52515a7d5b 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -691,10 +691,7 @@ class ConfigEntry(Generic[_DataT]): self._tries += 1 ready_message = f"ready yet: {message}" if message else "ready yet" _LOGGER.debug( - ( - "Config entry '%s' for %s integration not %s; Retrying in %d" - " seconds" - ), + "Config entry '%s' for %s integration not %s; Retrying in %d seconds", self.title, self.domain, ready_message, diff --git a/homeassistant/core.py b/homeassistant/core.py index da7a206b14e..5d0fcdc2b09 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1153,8 +1153,7 @@ class HomeAssistant: await self.async_block_till_done() except TimeoutError: _LOGGER.warning( - "Timed out waiting for integrations to stop, the shutdown will" - " continue" + "Timed out waiting for integrations to stop, the shutdown will continue" ) self._async_log_running_tasks("stop integrations") diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 85fe55277fa..4f017e4390d 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -174,7 +174,7 @@ class ConditionErrorIndex(ConditionError): """Yield an indented representation.""" if self.total > 1: yield self._indent( - indent, f"In '{self.type}' (item {self.index+1} of {self.total}):" + indent, f"In '{self.type}' (item {self.index + 1} of {self.total}):" ) else: yield self._indent(indent, f"In '{self.type}':") diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index a5a790b7ce5..350ae6dbd6a 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -154,7 +154,7 @@ def _format_err[*_Ts]( return ( # Functions wrapped in partial do not have a __name__ - f"Exception in {getattr(target, "__name__", None) or target} " + f"Exception in {getattr(target, '__name__', None) or target} " f"when dispatching '{signal}': {args}" ) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 6b6becd4dd3..a810eb89558 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -666,7 +666,7 @@ def _validate_item( # In HA Core 2025.10, we should fail if unique_id is not a string report_issue = async_suggest_report_issue(hass, integration_domain=platform) _LOGGER.error( - ("'%s' from integration %s has a non string unique_id" " '%s', please %s"), + "'%s' from integration %s has a non string unique_id '%s', please %s", domain, platform, unique_id, @@ -799,7 +799,7 @@ class EntityRegistry(BaseRegistry): tries += 1 len_suffix = len(str(tries)) + 1 test_string = ( - f"{preferred_string[:MAX_LENGTH_STATE_ENTITY_ID-len_suffix]}_{tries}" + f"{preferred_string[: MAX_LENGTH_STATE_ENTITY_ID - len_suffix]}_{tries}" ) return test_string diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index a67ef60c799..f9d623a41c3 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1770,7 +1770,7 @@ class Script: f"{self.domain}.{self.name} which is already running " "in the current execution path; " "Traceback (most recent call last):\n" - f"{"\n".join(formatted_stack)}", + f"{'\n'.join(formatted_stack)}", level=logging.WARNING, ) return None @@ -1834,7 +1834,7 @@ class Script: def _prep_repeat_script(self, step: int) -> Script: action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"Repeat at step {step+1}") + step_name = action.get(CONF_ALIAS, f"Repeat at step {step + 1}") sub_script = Script( self._hass, action[CONF_REPEAT][CONF_SEQUENCE], @@ -1857,7 +1857,7 @@ class Script: async def _async_prep_choose_data(self, step: int) -> _ChooseData: action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"Choose at step {step+1}") + step_name = action.get(CONF_ALIAS, f"Choose at step {step + 1}") choices = [] for idx, choice in enumerate(action[CONF_CHOOSE], start=1): conditions = [ @@ -1911,7 +1911,7 @@ class Script: async def _async_prep_if_data(self, step: int) -> _IfData: """Prepare data for an if statement.""" action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"If at step {step+1}") + step_name = action.get(CONF_ALIAS, f"If at step {step + 1}") conditions = [ await self._async_get_condition(config) for config in action[CONF_IF] @@ -1962,7 +1962,7 @@ class Script: async def _async_prep_parallel_scripts(self, step: int) -> list[Script]: action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"Parallel action at step {step+1}") + step_name = action.get(CONF_ALIAS, f"Parallel action at step {step + 1}") parallel_scripts: list[Script] = [] for idx, parallel_script in enumerate(action[CONF_PARALLEL], start=1): parallel_name = parallel_script.get(CONF_ALIAS, f"parallel {idx}") @@ -1994,7 +1994,7 @@ class Script: async def _async_prep_sequence_script(self, step: int) -> Script: """Prepare a sequence script.""" action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"Sequence action at step {step+1}") + step_name = action.get(CONF_ALIAS, f"Sequence action at step {step + 1}") sequence_script = Script( self._hass, diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 35135010452..e28e8aed105 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -133,8 +133,7 @@ def _validate_option_or_feature(option_or_feature: str, label: str) -> Any: domain, enum, option = option_or_feature.split(".", 2) except ValueError as exc: raise vol.Invalid( - f"Invalid {label} '{option_or_feature}', expected " - ".." + f"Invalid {label} '{option_or_feature}', expected .." ) from exc base_components = _base_components() diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 93dc7677bba..39dbe20c7c6 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1765,8 +1765,7 @@ def async_suggest_report_issue( if not integration_domain: return "report it to the custom integration author" return ( - f"report it to the author of the '{integration_domain}' " - "custom integration" + f"report it to the author of the '{integration_domain}' custom integration" ) return f"create a bug report at {issue_tracker}" diff --git a/homeassistant/util/loop.py b/homeassistant/util/loop.py index d7593013046..6ee554a3ef3 100644 --- a/homeassistant/util/loop.py +++ b/homeassistant/util/loop.py @@ -94,7 +94,8 @@ def raise_for_blocking_call( if found_frame is None: raise RuntimeError( # noqa: TRY200 - f"Caught blocking call to {func.__name__} with args {mapped_args.get("args")} " + f"Caught blocking call to {func.__name__} " + f"with args {mapped_args.get('args')} " f"in {offender_filename}, line {offender_lineno}: {offender_line} " "inside the event loop; " "This is causing stability issues. " diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index 83d406a0036..f842ec61b97 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -231,8 +231,7 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: if integrations_path.read_text() != content + "\n": config.add_error( "config_flow", - "File integrations.json is not up to date. " - "Run python3 -m script.hassfest", + "File integrations.json is not up to date. Run python3 -m script.hassfest", fixable=True, ) diff --git a/script/hassfest/quality_scale_validation/discovery.py b/script/hassfest/quality_scale_validation/discovery.py index d11bcaf2cec..45eaafde0b5 100644 --- a/script/hassfest/quality_scale_validation/discovery.py +++ b/script/hassfest/quality_scale_validation/discovery.py @@ -55,8 +55,7 @@ def validate( config_flow = ast_parse_module(config_flow_file) if not (_has_discovery_function(config_flow)): return [ - f"Integration is missing one of {CONFIG_FLOW_STEPS} " - f"in {config_flow_file}" + f"Integration is missing one of {CONFIG_FLOW_STEPS} in {config_flow_file}" ] return None diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 2fb70b6e0be..6acff1633c1 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -510,8 +510,8 @@ def validate_translation_file( # noqa: C901 ): integration.add_error( "translations", - "Don't specify title in translation strings if it's a brand " - "name or add exception to ALLOW_NAME_TRANSLATION", + "Don't specify title in translation strings if it's " + "a brand name or add exception to ALLOW_NAME_TRANSLATION", ) if config.specific_integrations: @@ -532,12 +532,15 @@ def validate_translation_file( # noqa: C901 if parts or key not in search: integration.add_error( "translations", - f"{reference['source']} contains invalid reference {reference['ref']}: Could not find {key}", + f"{reference['source']} contains invalid reference" + f"{reference['ref']}: Could not find {key}", ) elif match := re.match(RE_REFERENCE, search[key]): integration.add_error( "translations", - f"Lokalise supports only one level of references: \"{reference['source']}\" should point to directly to \"{match.groups()[0]}\"", + "Lokalise supports only one level of references: " + f'"{reference["source"]}" should point to directly ' + f'to "{match.groups()[0]}"', ) diff --git a/script/scaffold/gather_info.py b/script/scaffold/gather_info.py index cfa2669ebfe..d90e01c3ebd 100644 --- a/script/scaffold/gather_info.py +++ b/script/scaffold/gather_info.py @@ -93,7 +93,7 @@ def gather_new_integration(determine_auth: bool) -> Info: "prompt": ( f"""How will your integration gather data? -Valid values are {', '.join(SUPPORTED_IOT_CLASSES)} +Valid values are {", ".join(SUPPORTED_IOT_CLASSES)} More info @ https://developers.home-assistant.io/docs/creating_integration_manifest#iot-class """ diff --git a/script/split_tests.py b/script/split_tests.py index c64de46a068..0018472e54e 100755 --- a/script/split_tests.py +++ b/script/split_tests.py @@ -79,7 +79,7 @@ class BucketHolder: """Create output file.""" with Path("pytest_buckets.txt").open("w") as file: for idx, bucket in enumerate(self._buckets): - print(f"Bucket {idx+1} has {bucket.total_tests} tests") + print(f"Bucket {idx + 1} has {bucket.total_tests} tests") file.write(bucket.get_paths_line()) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 362a1bff4ee..490f8e3dabc 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -777,7 +777,7 @@ async def _check_config_flow_result_translations( translation_errors, category, integration, - f"{key_prefix}abort.{result["reason"]}", + f"{key_prefix}abort.{result['reason']}", result["description_placeholders"], ) diff --git a/tests/conftest.py b/tests/conftest.py index 987173a0b5e..d38d1dbb6b7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -580,7 +580,7 @@ async def hass( exceptions.append( Exception( "Received exception handler without exception, " - f"but with message: {context["message"]}" + f"but with message: {context['message']}" ) ) orig_exception_handler(loop, context) diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index d4a78807e2b..3593db9cf87 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -74,7 +74,7 @@ def test_load_translations_files_by_language( "name": "Other 4", "unit_of_measurement": "quantities", }, - "outlet": {"name": "Outlet " "{placeholder}"}, + "outlet": {"name": "Outlet {placeholder}"}, } }, "something": "else", diff --git a/tests/syrupy.py b/tests/syrupy.py index a3b3f763063..8812b3c3880 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -376,7 +376,7 @@ def override_syrupy_finish(self: SnapshotSession) -> int: with open(".pytest_syrupy_worker_count", "w", encoding="utf-8") as f: f.write(os.getenv("PYTEST_XDIST_WORKER_COUNT")) with open( - f".pytest_syrupy_{os.getenv("PYTEST_XDIST_WORKER")}_result", + f".pytest_syrupy_{os.getenv('PYTEST_XDIST_WORKER')}_result", "w", encoding="utf-8", ) as f: diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index aba85a35349..ee0df28a6e4 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -7266,9 +7266,9 @@ async def test_unique_id_collision_issues( mock_setup_entry = AsyncMock(return_value=True) for i in range(3): mock_integration( - hass, MockModule(f"test{i+1}", async_setup_entry=mock_setup_entry) + hass, MockModule(f"test{i + 1}", async_setup_entry=mock_setup_entry) ) - mock_platform(hass, f"test{i+1}.config_flow", None) + mock_platform(hass, f"test{i + 1}.config_flow", None) test2_group_1: list[MockConfigEntry] = [] test2_group_2: list[MockConfigEntry] = [] From e29ead2a368c9a7e6aa97ae72779585698020107 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jan 2025 10:13:36 +0100 Subject: [PATCH 0270/2987] Split long strings in components (#135263) --- homeassistant/components/bayesian/binary_sensor.py | 10 ++++++++-- .../components/frontier_silicon/browse_media.py | 4 +++- homeassistant/components/ista_ecotrend/sensor.py | 4 ++-- homeassistant/components/mqtt/discovery.py | 5 ++++- homeassistant/components/picnic/coordinator.py | 5 ++++- homeassistant/components/sonarr/sensor.py | 5 +++-- homeassistant/components/stream/hls.py | 14 ++++++++++---- homeassistant/components/vicare/__init__.py | 3 ++- homeassistant/components/vicare/entity.py | 6 +++++- 9 files changed, 41 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 6d203c344f2..74e3db34b68 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -131,7 +131,10 @@ def _no_overlapping(configs: list[dict]) -> list[dict]: for i, tup in enumerate(intervals): if len(intervals) > i + 1 and tup.below > intervals[i + 1].above: raise vol.Invalid( - f"Ranges for bayesian numeric state entities must not overlap, but {ent_id} has overlapping ranges, above:{tup.above}, below:{tup.below} overlaps with above:{intervals[i+1].above}, below:{intervals[i+1].below}." + "Ranges for bayesian numeric state entities must not overlap, " + f"but {ent_id} has overlapping ranges, above:{tup.above}, " + f"below:{tup.below} overlaps with above:{intervals[i + 1].above}, " + f"below:{intervals[i + 1].below}." ) return configs @@ -206,7 +209,10 @@ async def async_setup_platform( broken_observations: list[dict[str, Any]] = [] for observation in observations: if CONF_P_GIVEN_F not in observation: - text: str = f"{name}/{observation.get(CONF_ENTITY_ID,'')}{observation.get(CONF_VALUE_TEMPLATE,'')}" + text = ( + f"{name}/{observation.get(CONF_ENTITY_ID, '')}" + f"{observation.get(CONF_VALUE_TEMPLATE, '')}" + ) raise_no_prob_given_false(hass, text) _LOGGER.error("Missing prob_given_false YAML entry for %s", text) broken_observations.append(observation) diff --git a/homeassistant/components/frontier_silicon/browse_media.py b/homeassistant/components/frontier_silicon/browse_media.py index 0b51cb767c7..9bad880a9b3 100644 --- a/homeassistant/components/frontier_silicon/browse_media.py +++ b/homeassistant/components/frontier_silicon/browse_media.py @@ -38,7 +38,9 @@ def _item_preset_payload(preset: Preset, player_mode: str) -> BrowseMedia: media_content_type=MediaType.CHANNEL, # We add 1 to the preset key to keep it in sync with the numbering shown # on the interface of the device - media_content_id=f"{player_mode}/{MEDIA_CONTENT_ID_PRESET}/{int(preset.key)+1}", + media_content_id=( + f"{player_mode}/{MEDIA_CONTENT_ID_PRESET}/{int(preset.key) + 1}" + ), can_play=True, can_expand=False, ) diff --git a/homeassistant/components/ista_ecotrend/sensor.py b/homeassistant/components/ista_ecotrend/sensor.py index eb06fabe373..e96ac103741 100644 --- a/homeassistant/components/ista_ecotrend/sensor.py +++ b/homeassistant/components/ista_ecotrend/sensor.py @@ -184,12 +184,12 @@ class IstaSensor(CoordinatorEntity[IstaCoordinator], SensorEntity): self.consumption_unit = consumption_unit self.entity_description = entity_description self._attr_unique_id = f"{consumption_unit}_{entity_description.key}" + address = coordinator.details[consumption_unit]["address"] self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, manufacturer="ista SE", model="ista EcoTrend", - name=f"{coordinator.details[consumption_unit]["address"]["street"]} " - f"{coordinator.details[consumption_unit]["address"]["houseNumber"]}".strip(), + name=f"{address['street']} {address['houseNumber']}".strip(), configuration_url="https://ecotrend.ista.de/", identifiers={(DOMAIN, consumption_unit)}, ) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index a5ddb3ef4e6..a5af8430629 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -138,7 +138,10 @@ def get_origin_log_string( support_url_log = "" if include_url and (support_url := get_origin_support_url(discovery_payload)): support_url_log = f", support URL: {support_url}" - return f" from external application {origin_info["name"]}{sw_version_log}{support_url_log}" + return ( + " from external application " + f"{origin_info['name']}{sw_version_log}{support_url_log}" + ) @callback diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index c367d5ec548..b3979580990 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -79,7 +79,10 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): """Get the address that identifies the Picnic service.""" if self._user_address is None: address = self.picnic_api_client.get_user()["address"] - self._user_address = f'{address["street"]} {address["house_number"]}{address["house_number_ext"]}' + self._user_address = ( + f"{address['street']} " + f"{address['house_number']}{address['house_number_ext']}" + ) return self._user_address diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index bdb647de39c..f25c885ed84 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -67,7 +67,7 @@ def get_queue_attr(queue: SonarrQueue) -> dict[str, str]: remaining = 1 if item.size == 0 else item.sizeleft / item.size remaining_pct = 100 * (1 - remaining) identifier = ( - f"S{item.episode.seasonNumber:02d}E{item.episode. episodeNumber:02d}" + f"S{item.episode.seasonNumber:02d}E{item.episode.episodeNumber:02d}" ) attrs[f"{item.series.title} {identifier}"] = f"{remaining_pct:.2f}%" return attrs @@ -120,7 +120,8 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { value_fn=len, attributes_fn=lambda data: { i.title: ( - f"{getattr(i.statistics,'episodeFileCount', 0)}/{getattr(i.statistics, 'episodeCount', 0)} Episodes" + f"{getattr(i.statistics, 'episodeFileCount', 0)}/" + f"{getattr(i.statistics, 'episodeCount', 0)} Episodes" ) for i in data }, diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py index 16694822b01..32845840f38 100644 --- a/homeassistant/components/stream/hls.py +++ b/homeassistant/components/stream/hls.py @@ -188,9 +188,13 @@ class HlsPlaylistView(StreamView): if track.stream_settings.ll_hls: playlist.extend( [ - f"#EXT-X-PART-INF:PART-TARGET={track.stream_settings.part_target_duration:.3f}", - f"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={2*track.stream_settings.part_target_duration:.3f}", - f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_LL_HLS*track.stream_settings.part_target_duration:.3f},PRECISE=YES", + "#EXT-X-PART-INF:PART-TARGET=" + f"{track.stream_settings.part_target_duration:.3f}", + "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=" + f"{2 * track.stream_settings.part_target_duration:.3f}", + "#EXT-X-START:TIME-OFFSET=-" + f"{EXT_X_START_LL_HLS * track.stream_settings.part_target_duration:.3f}" + ",PRECISE=YES", ] ) else: @@ -203,7 +207,9 @@ class HlsPlaylistView(StreamView): # which seems to take precedence for setting target delay. Yet it also # doesn't seem to hurt, so we can stick with it for now. playlist.append( - f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_NON_LL_HLS*track.target_duration:.3f},PRECISE=YES" + "#EXT-X-START:TIME-OFFSET=-" + f"{EXT_X_START_NON_LL_HLS * track.target_duration:.3f}" + ",PRECISE=YES" ) last_stream_id = first_segment.stream_id diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 9c331f0e9ec..12d8ba520f1 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -146,7 +146,8 @@ async def async_migrate_devices_and_entities( # to `-heating-` if entity_entry.domain == DOMAIN_CLIMATE: unique_id_parts[len(unique_id_parts) - 1] = ( - f"{entity_entry.translation_key}-{unique_id_parts[len(unique_id_parts)-1]}" + f"{entity_entry.translation_key}-" + f"{unique_id_parts[len(unique_id_parts) - 1]}" ) entity_new_unique_id = "-".join(unique_id_parts) diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py index 2d858185b9f..11955a94b94 100644 --- a/homeassistant/components/vicare/entity.py +++ b/homeassistant/components/vicare/entity.py @@ -29,7 +29,11 @@ class ViCareEntity(Entity): gateway_serial = device_config.getConfig().serial device_id = device_config.getId() - identifier = f"{gateway_serial}_{device_serial.replace("zigbee-", "zigbee_") if device_serial is not None else device_id}" + identifier = ( + f"{gateway_serial}_{device_serial.replace('zigbee-', 'zigbee_')}" + if device_serial is not None + else f"{gateway_serial}_{device_id}" + ) self._api: PyViCareDevice | PyViCareHeatingDeviceComponent = ( component if component else device From 04d5cc8f79bf336dff0e44eae0bd43d6ccb4ca9d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jan 2025 10:19:28 +0100 Subject: [PATCH 0271/2987] Combine short byte strings in xiaomi_ble tests (#135268) --- tests/components/xiaomi_ble/test_device_trigger.py | 14 +++++++------- tests/components/xiaomi_ble/test_event.py | 9 ++++----- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/tests/components/xiaomi_ble/test_device_trigger.py b/tests/components/xiaomi_ble/test_device_trigger.py index 218a382ada5..f415a968f25 100644 --- a/tests/components/xiaomi_ble/test_device_trigger.py +++ b/tests/components/xiaomi_ble/test_device_trigger.py @@ -52,7 +52,7 @@ async def test_event_button_press(hass: HomeAssistant) -> None: hass, make_advertisement( mac, - b'XY\x97\td\xbc\x9c\xe3D\xefT" `' b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + b'XY\x97\td\xbc\x9c\xe3D\xefT" `\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3', ), ) @@ -78,7 +78,7 @@ async def test_event_unlock_outside_the_door(hass: HomeAssistant) -> None: hass, make_advertisement( mac, - b"PD\x9e\x06C\x91\x8a\xebD\x1f\xd7\x0b\x00\t" b" \x02\x00\x01\x80|D/a", + b"PD\x9e\x06C\x91\x8a\xebD\x1f\xd7\x0b\x00\t \x02\x00\x01\x80|D/a", ), ) @@ -104,7 +104,7 @@ async def test_event_successful_fingerprint_match_the_door(hass: HomeAssistant) hass, make_advertisement( mac, - b"PD\x9e\x06B\x91\x8a\xebD\x1f\xd7" b"\x06\x00\x05\xff\xff\xff\xff\x00", + b"PD\x9e\x06B\x91\x8a\xebD\x1f\xd7\x06\x00\x05\xff\xff\xff\xff\x00", ), ) @@ -153,7 +153,7 @@ async def test_event_dimmer_rotate(hass: HomeAssistant) -> None: inject_bluetooth_service_info_bleak( hass, make_advertisement( - mac, b"X0\xb6\x036\x8b\x98\xc5A$\xf8\x8b\xb8\xf2f" b"\x13Q\x00\x00\x00\xd6" + mac, b"X0\xb6\x036\x8b\x98\xc5A$\xf8\x8b\xb8\xf2f\x13Q\x00\x00\x00\xd6" ), ) @@ -182,7 +182,7 @@ async def test_get_triggers_button( hass, make_advertisement( mac, - b'XY\x97\td\xbc\x9c\xe3D\xefT" `' b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + b'XY\x97\td\xbc\x9c\xe3D\xefT" `\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3', ), ) @@ -406,7 +406,7 @@ async def test_if_fires_on_button_press( hass, make_advertisement( mac, - b"XY\x97\tf\xbc\x9c\xe3D\xefT\x01" b"\x08\x12\x05\x00\x00\x00q^\xbe\x90", + b"XY\x97\tf\xbc\x9c\xe3D\xefT\x01\x08\x12\x05\x00\x00\x00q^\xbe\x90", ), ) @@ -442,7 +442,7 @@ async def test_if_fires_on_button_press( hass, make_advertisement( mac, - b'XY\x97\td\xbc\x9c\xe3D\xefT" `' b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + b'XY\x97\td\xbc\x9c\xe3D\xefT" `\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3', ), ) await hass.async_block_till_done() diff --git a/tests/components/xiaomi_ble/test_event.py b/tests/components/xiaomi_ble/test_event.py index 1de5859c35e..7f31fe048aa 100644 --- a/tests/components/xiaomi_ble/test_event.py +++ b/tests/components/xiaomi_ble/test_event.py @@ -23,8 +23,7 @@ from tests.components.bluetooth import ( "54:EF:44:E3:9C:BC", make_advertisement( "54:EF:44:E3:9C:BC", - b'XY\x97\td\xbc\x9c\xe3D\xefT" `' - b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + b'XY\x97\td\xbc\x9c\xe3D\xefT" `\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3', ), "5b51a7c91cde6707c9ef18dfda143a58", [ @@ -114,7 +113,7 @@ from tests.components.bluetooth import ( "F8:24:41:C5:98:8B", make_advertisement( "F8:24:41:C5:98:8B", - b"X0\xb6\x036\x8b\x98\xc5A$\xf8\x8b\xb8\xf2f" b"\x13Q\x00\x00\x00\xd6", + b"X0\xb6\x036\x8b\x98\xc5A$\xf8\x8b\xb8\xf2f\x13Q\x00\x00\x00\xd6", ), "b853075158487ca39a5b5ea9", [ @@ -221,7 +220,7 @@ async def test_xiaomi_fingerprint(hass: HomeAssistant) -> None: hass, make_advertisement( "D7:1F:44:EB:8A:91", - b"PD\x9e\x06B\x91\x8a\xebD\x1f\xd7" b"\x06\x00\x05\xff\xff\xff\xff\x00", + b"PD\x9e\x06B\x91\x8a\xebD\x1f\xd7\x06\x00\x05\xff\xff\xff\xff\x00", ), ) @@ -264,7 +263,7 @@ async def test_xiaomi_lock(hass: HomeAssistant) -> None: hass, make_advertisement( "D7:1F:44:EB:8A:91", - b"PD\x9e\x06C\x91\x8a\xebD\x1f\xd7\x0b\x00\t" b" \x02\x00\x01\x80|D/a", + b"PD\x9e\x06C\x91\x8a\xebD\x1f\xd7\x0b\x00\t \x02\x00\x01\x80|D/a", ), ) From 9d1989125f3c66da2d5e82338e15fbc9077abfc5 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 10 Jan 2025 11:44:23 +0200 Subject: [PATCH 0272/2987] Fix LG webOS TV media player test coverage (#135225) Co-authored-by: Joost Lekkerkerker --- .../components/webostv/media_player.py | 2 +- tests/components/webostv/test_media_player.py | 159 +++++++++--------- 2 files changed, 76 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index e0520cb7bf5..399acb9b44d 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -326,7 +326,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): if self._client.is_connected(): return - with suppress(*WEBOSTV_EXCEPTIONS, WebOsTvPairError): + with suppress(*WEBOSTV_EXCEPTIONS): try: await self._client.connect() except WebOsTvPairError: diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 9e5958d21cc..7dea412f4fa 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -2,7 +2,6 @@ from datetime import timedelta from http import HTTPStatus -from unittest.mock import Mock from aiowebostv import WebOsTvPairError from freezegun.api import FrozenDateTimeFactory @@ -46,6 +45,7 @@ from homeassistant.const import ( ATTR_COMMAND, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + CONF_CLIENT_SECRET, ENTITY_MATCH_NONE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, @@ -64,7 +64,6 @@ from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util from . import setup_webostv from .const import CHANNEL_2, ENTITY_ID, TV_NAME @@ -144,7 +143,7 @@ async def test_media_play_pause(hass: HomeAssistant, client) -> None: ], ) async def test_media_next_previous_track( - hass: HomeAssistant, client, service, client_call, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, client, service, client_call ) -> None: """Test media next/previous track services.""" await setup_webostv(hass) @@ -157,7 +156,7 @@ async def test_media_next_previous_track( getattr(client, client_call[1]).assert_called_once() # check next/previous for not Live TV channels - monkeypatch.setattr(client, "current_app_id", "in1") + client.current_app_id = "in1" data = {ATTR_ENTITY_ID: ENTITY_ID} await hass.services.async_call(MP_DOMAIN, service, data, True) @@ -270,14 +269,11 @@ async def test_select_sound_output(hass: HomeAssistant, client) -> None: async def test_device_info_startup_off( - hass: HomeAssistant, - client, - monkeypatch: pytest.MonkeyPatch, - device_registry: dr.DeviceRegistry, + hass: HomeAssistant, client, device_registry: dr.DeviceRegistry ) -> None: """Test device info when device is off at startup.""" - monkeypatch.setattr(client, "system_info", None) - monkeypatch.setattr(client, "is_on", False) + client.system_info = None + client.is_on = False entry = await setup_webostv(hass) await client.mock_state_update() @@ -296,7 +292,6 @@ async def test_device_info_startup_off( async def test_entity_attributes( hass: HomeAssistant, client, - monkeypatch: pytest.MonkeyPatch, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -309,14 +304,14 @@ async def test_entity_attributes( assert state == snapshot(exclude=props("entity_picture")) # Volume level not available - monkeypatch.setattr(client, "volume", None) + client.volume = None await client.mock_state_update() attrs = hass.states.get(ENTITY_ID).attributes assert attrs.get(ATTR_MEDIA_VOLUME_LEVEL) is None # Channel change - monkeypatch.setattr(client, "current_channel", CHANNEL_2) + client.current_channel = CHANNEL_2 await client.mock_state_update() attrs = hass.states.get(ENTITY_ID).attributes @@ -327,8 +322,8 @@ async def test_entity_attributes( assert device == snapshot # Sound output when off - monkeypatch.setattr(client, "sound_output", None) - monkeypatch.setattr(client, "is_on", False) + client.sound_output = None + client.is_on = False await client.mock_state_update() state = hass.states.get(ENTITY_ID) @@ -372,9 +367,7 @@ async def test_play_media(hass: HomeAssistant, client, media_id, ch_id) -> None: client.set_channel.assert_called_once_with(ch_id) -async def test_update_sources_live_tv_find( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: """Test finding live TV app id in update sources.""" await setup_webostv(hass) await client.mock_state_update() @@ -386,14 +379,13 @@ async def test_update_sources_live_tv_find( assert len(sources) == 3 # Live TV is current app - apps = { + client.apps = { LIVE_TV_APP_ID: { "title": "Live TV", "id": "some_id", }, } - monkeypatch.setattr(client, "apps", apps) - monkeypatch.setattr(client, "current_app_id", "some_id") + client.current_app_id = "some_id" await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -401,14 +393,13 @@ async def test_update_sources_live_tv_find( assert len(sources) == 3 # Live TV is is in inputs - inputs = { + client.inputs = { LIVE_TV_APP_ID: { "label": "Live TV", "id": "some_id", "appId": LIVE_TV_APP_ID, }, } - monkeypatch.setattr(client, "inputs", inputs) await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -416,14 +407,13 @@ async def test_update_sources_live_tv_find( assert len(sources) == 1 # Live TV is current input - inputs = { + client.inputs = { LIVE_TV_APP_ID: { "label": "Live TV", "id": "some_id", "appId": "some_id", }, } - monkeypatch.setattr(client, "inputs", inputs) await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -431,7 +421,7 @@ async def test_update_sources_live_tv_find( assert len(sources) == 1 # Live TV not found - monkeypatch.setattr(client, "current_app_id", "other_id") + client.current_app_id = "other_id" await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -439,8 +429,8 @@ async def test_update_sources_live_tv_find( assert len(sources) == 1 # Live TV not found in sources/apps but is current app - monkeypatch.setattr(client, "apps", {}) - monkeypatch.setattr(client, "current_app_id", LIVE_TV_APP_ID) + client.apps = {} + client.current_app_id = LIVE_TV_APP_ID await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -448,7 +438,7 @@ async def test_update_sources_live_tv_find( assert len(sources) == 1 # Bad update, keep old update - monkeypatch.setattr(client, "inputs", {}) + client.inputs = {} await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -459,14 +449,13 @@ async def test_update_sources_live_tv_find( async def test_client_disconnected( hass: HomeAssistant, client, - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, freezer: FrozenDateTimeFactory, ) -> None: """Test error not raised when client is disconnected.""" await setup_webostv(hass) - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - monkeypatch.setattr(client, "connect", Mock(side_effect=TimeoutError)) + client.is_connected.return_value = False + client.connect.side_effect = TimeoutError freezer.tick(timedelta(seconds=20)) async_fire_time_changed(hass) @@ -475,15 +464,30 @@ async def test_client_disconnected( assert "TimeoutError" not in caplog.text +async def test_client_key_update_on_connect( + hass: HomeAssistant, client, freezer: FrozenDateTimeFactory +) -> None: + """Test client key update upon connect.""" + config_entry = await setup_webostv(hass) + + assert config_entry.data[CONF_CLIENT_SECRET] == client.client_key + + client.is_connected.return_value = False + client.client_key = "new_key" + + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert config_entry.data[CONF_CLIENT_SECRET] == client.client_key + + async def test_control_error_handling( - hass: HomeAssistant, - client, - caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, + hass: HomeAssistant, client, caplog: pytest.LogCaptureFixture ) -> None: """Test control errors handling.""" await setup_webostv(hass) - monkeypatch.setattr(client, "play", Mock(side_effect=WebOsTvCommandError)) + client.play.side_effect = WebOsTvCommandError data = {ATTR_ENTITY_ID: ENTITY_ID} # Device on, raise HomeAssistantError @@ -497,23 +501,21 @@ async def test_control_error_handling( assert client.play.call_count == 1 # Device off, log a warning - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "play", Mock(side_effect=TimeoutError)) + client.is_on = False + client.play.side_effect = TimeoutError await client.mock_state_update() await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data, True) - assert client.play.call_count == 1 + assert client.play.call_count == 2 assert ( f"Error calling async_media_play on entity {ENTITY_ID}, state:off, error:" " TimeoutError()" in caplog.text ) -async def test_supported_features( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_supported_features(hass: HomeAssistant, client) -> None: """Test test supported features.""" - monkeypatch.setattr(client, "sound_output", "lineout") + client.sound_output = "lineout" await setup_webostv(hass) await client.mock_state_update() @@ -524,7 +526,7 @@ async def test_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # Support volume mute, step - monkeypatch.setattr(client, "sound_output", "external_speaker") + client.sound_output = "external_speaker" await client.mock_state_update() supported = supported | SUPPORT_WEBOSTV_VOLUME attrs = hass.states.get(ENTITY_ID).attributes @@ -532,7 +534,7 @@ async def test_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # Support volume mute, step, set - monkeypatch.setattr(client, "sound_output", "speaker") + client.sound_output = "speaker" await client.mock_state_update() supported = supported | SUPPORT_WEBOSTV_VOLUME | MediaPlayerEntityFeature.VOLUME_SET attrs = hass.states.get(ENTITY_ID).attributes @@ -568,12 +570,10 @@ async def test_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported -async def test_cached_supported_features( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_cached_supported_features(hass: HomeAssistant, client) -> None: """Test test supported features.""" - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "sound_output", None) + client.is_on = False + client.sound_output = None supported = ( SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME | MediaPlayerEntityFeature.TURN_ON ) @@ -601,8 +601,8 @@ async def test_cached_supported_features( ) # TV on, support volume mute, step - monkeypatch.setattr(client, "is_on", True) - monkeypatch.setattr(client, "sound_output", "external_speaker") + client.is_on = True + client.sound_output = "external_speaker" await client.mock_state_update() supported = SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME @@ -611,8 +611,8 @@ async def test_cached_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV off, support volume mute, step - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "sound_output", None) + client.is_on = False + client.sound_output = None await client.mock_state_update() supported = SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME @@ -621,8 +621,8 @@ async def test_cached_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV on, support volume mute, step, set - monkeypatch.setattr(client, "is_on", True) - monkeypatch.setattr(client, "sound_output", "speaker") + client.is_on = True + client.sound_output = "speaker" await client.mock_state_update() supported = ( @@ -633,8 +633,8 @@ async def test_cached_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV off, support volume mute, step, set - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "sound_output", None) + client.is_on = False + client.sound_output = None await client.mock_state_update() supported = ( @@ -675,12 +675,10 @@ async def test_cached_supported_features( ) -async def test_supported_features_no_cache( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_supported_features_no_cache(hass: HomeAssistant, client) -> None: """Test supported features if device is off and no cache.""" - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "sound_output", None) + client.is_on = False + client.sound_output = None await setup_webostv(hass) supported = ( @@ -720,11 +718,10 @@ async def test_get_image_http( client, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Test get image via http.""" url = "http://something/valid_icon" - monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url) + client.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -746,11 +743,10 @@ async def test_get_image_http_error( hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Test get image via http error.""" url = "http://something/icon_error" - monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url) + client.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -773,11 +769,10 @@ async def test_get_image_https( client, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Test get image via http.""" url = "https://something/valid_icon_https" - monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url) + client.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -794,16 +789,17 @@ async def test_get_image_https( async def test_reauth_reconnect( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, client, freezer: FrozenDateTimeFactory ) -> None: """Test reauth flow triggered by reconnect.""" entry = await setup_webostv(hass) - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - monkeypatch.setattr(client, "connect", Mock(side_effect=WebOsTvPairError)) + client.is_connected.return_value = False + client.connect.side_effect = WebOsTvPairError assert entry.state is ConfigEntryState.LOADED - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20)) + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED @@ -820,27 +816,22 @@ async def test_reauth_reconnect( assert flow["context"].get("entry_id") == entry.entry_id -async def test_update_media_state( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_update_media_state(hass: HomeAssistant, client) -> None: """Test updating media state.""" await setup_webostv(hass) - data = {"foregroundAppInfo": [{"playState": "playing"}]} - monkeypatch.setattr(client, "media_state", data) + client.media_state = {"foregroundAppInfo": [{"playState": "playing"}]} await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PLAYING - data = {"foregroundAppInfo": [{"playState": "paused"}]} - monkeypatch.setattr(client, "media_state", data) + client.media_state = {"foregroundAppInfo": [{"playState": "paused"}]} await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PAUSED - data = {"foregroundAppInfo": [{"playState": "unloaded"}]} - monkeypatch.setattr(client, "media_state", data) + client.media_state = {"foregroundAppInfo": [{"playState": "unloaded"}]} await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.IDLE - monkeypatch.setattr(client, "is_on", False) + client.is_on = False await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == STATE_OFF From 02956f9a837955c99a2922ea2008701ae5d3a555 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jan 2025 10:53:45 +0100 Subject: [PATCH 0273/2987] Improve formatting in component test files (#135267) Improve formatting in test files --- tests/components/androidtv/test_remote.py | 4 ++-- tests/components/axis/test_camera.py | 4 ++-- tests/components/backup/test_manager.py | 2 +- tests/components/broadlink/test_switch.py | 2 +- tests/components/cover/test_device_trigger.py | 10 ++-------- tests/components/google_assistant/test_trait.py | 2 +- .../homeassistant/triggers/test_numeric_state.py | 6 ++---- .../homeassistant/triggers/test_state.py | 6 ++---- tests/components/ista_ecotrend/test_statistics.py | 4 ++-- tests/components/local_file/test_camera.py | 2 +- tests/components/media_player/test_init.py | 3 +-- tests/components/modbus/test_binary_sensor.py | 4 ++-- tests/components/motioneye/__init__.py | 2 +- tests/components/mqtt/test_discovery.py | 2 +- tests/components/mystrom/__init__.py | 2 +- tests/components/ollama/test_conversation.py | 6 +++--- tests/components/otbr/test_config_flow.py | 14 ++++++++++---- tests/components/plex/test_config_flow.py | 2 +- tests/components/prometheus/test_init.py | 6 ++---- tests/components/recorder/test_init.py | 6 +++--- tests/components/tasmota/test_light.py | 8 ++++---- tests/components/tessie/test_cover.py | 2 +- tests/components/tplink/test_init.py | 2 +- tests/components/unifi/test_button.py | 2 +- tests/components/unifi/test_device_tracker.py | 4 ++-- tests/components/unifi/test_switch.py | 4 ++-- 26 files changed, 52 insertions(+), 59 deletions(-) diff --git a/tests/components/androidtv/test_remote.py b/tests/components/androidtv/test_remote.py index d18e08d4df8..5a52fe52b3a 100644 --- a/tests/components/androidtv/test_remote.py +++ b/tests/components/androidtv/test_remote.py @@ -99,9 +99,9 @@ async def test_services_remote(hass: HomeAssistant, config) -> None: "adb_shell", {ATTR_COMMAND: ["BACK", "test"], ATTR_NUM_REPEATS: 2}, [ - f"input keyevent {KEYS["BACK"]}", + f"input keyevent {KEYS['BACK']}", "test", - f"input keyevent {KEYS["BACK"]}", + f"input keyevent {KEYS['BACK']}", "test", ], ) diff --git a/tests/components/axis/test_camera.py b/tests/components/axis/test_camera.py index 6cc4bbd7c2f..9dcfbac4e7b 100644 --- a/tests/components/axis/test_camera.py +++ b/tests/components/axis/test_camera.py @@ -63,12 +63,12 @@ async def test_camera( assert camera_entity.image_source == "http://1.2.3.4:80/axis-cgi/jpg/image.cgi" assert ( camera_entity.mjpeg_source == "http://1.2.3.4:80/axis-cgi/mjpg/video.cgi" - f"{"" if not stream_profile else f"?{stream_profile}"}" + f"{'' if not stream_profile else f'?{stream_profile}'}" ) assert ( await camera_entity.stream_source() == "rtsp://root:pass@1.2.3.4/axis-media/media.amp?videocodec=h264" - f"{"" if not stream_profile else f"&{stream_profile}"}" + f"{'' if not stream_profile else f'&{stream_profile}'}" ) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index ad90e2e23bf..144646301cd 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -383,7 +383,7 @@ async def test_async_initiate_backup( tar_file_path = str(mocked_tarfile.call_args_list[0][0][0]) backup_directory = hass.config.path(backup_directory) - assert tar_file_path == f"{backup_directory}/{backup_data["backup_id"]}.tar" + assert tar_file_path == f"{backup_directory}/{backup_data['backup_id']}.tar" @pytest.mark.usefixtures("mock_backup_generation") diff --git a/tests/components/broadlink/test_switch.py b/tests/components/broadlink/test_switch.py index 2d4eb8e0e0b..7e3ae4efcab 100644 --- a/tests/components/broadlink/test_switch.py +++ b/tests/components/broadlink/test_switch.py @@ -92,7 +92,7 @@ async def test_slots_switch_setup_works( for slot, switch in enumerate(switches): assert ( hass.states.get(switch.entity_id).attributes[ATTR_FRIENDLY_NAME] - == f"{device.name} S{slot+1}" + == f"{device.name} S{slot + 1}" ) assert hass.states.get(switch.entity_id).state == STATE_OFF assert mock_setup.api.auth.call_count == 1 diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index e6021d22326..a6c10d4acf1 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -766,10 +766,7 @@ async def test_if_fires_on_position( ] ) == sorted( [ - ( - f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open" - " - None" - ), + f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open - None", f"is_pos_lt_90 - device - {entry.entity_id} - closed - open - None", f"is_pos_gt_45 - device - {entry.entity_id} - open - closed - None", ] @@ -925,10 +922,7 @@ async def test_if_fires_on_tilt_position( ] ) == sorted( [ - ( - f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open" - " - None" - ), + f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open - None", f"is_pos_lt_90 - device - {entry.entity_id} - closed - open - None", f"is_pos_gt_45 - device - {entry.entity_id} - open - closed - None", ] diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index d269b5ff0d7..dafe85d97b2 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -2208,7 +2208,7 @@ async def test_fan_speed_ordered( "ordered": True, "speeds": [ { - "speed_name": f"{idx+1}/{len(speeds)}", + "speed_name": f"{idx + 1}/{len(speeds)}", "speed_values": [{"lang": "en", "speed_synonym": x}], } for idx, x in enumerate(speeds) diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index 85882274fec..fe4fb53962a 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -1770,8 +1770,7 @@ async def test_if_fires_on_entities_change_overlap_for_template( "entity_id": ["test.entity_1", "test.entity_2"], "above": above, "below": below, - "for": '{{ 5 if trigger.entity_id == "test.entity_1"' - " else 10 }}", + "for": '{{ 5 if trigger.entity_id == "test.entity_1" else 10 }}', }, "action": { "service": "test.automation", @@ -1938,8 +1937,7 @@ async def test_variables_priority( "entity_id": ["test.entity_1", "test.entity_2"], "above": above, "below": below, - "for": '{{ 5 if trigger.entity_id == "test.entity_1"' - " else 10 }}", + "for": '{{ 5 if trigger.entity_id == "test.entity_1" else 10 }}', }, "action": { "service": "test.automation", diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index 83157a158a6..c3117bbb660 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -1423,8 +1423,7 @@ async def test_if_fires_on_entities_change_overlap_for_template( "platform": "state", "entity_id": ["test.entity_1", "test.entity_2"], "to": "world", - "for": '{{ 5 if trigger.entity_id == "test.entity_1"' - " else 10 }}", + "for": '{{ 5 if trigger.entity_id == "test.entity_1" else 10 }}', }, "action": { "service": "test.automation", @@ -1727,8 +1726,7 @@ async def test_variables_priority( "platform": "state", "entity_id": ["test.entity_1", "test.entity_2"], "to": "world", - "for": '{{ 5 if trigger.entity_id == "test.entity_1"' - " else 10 }}", + "for": '{{ 5 if trigger.entity_id == "test.entity_1" else 10 }}', }, "action": { "service": "test.automation", diff --git a/tests/components/ista_ecotrend/test_statistics.py b/tests/components/ista_ecotrend/test_statistics.py index 21877f686df..aa4f71037c4 100644 --- a/tests/components/ista_ecotrend/test_statistics.py +++ b/tests/components/ista_ecotrend/test_statistics.py @@ -41,7 +41,7 @@ async def test_statistics_import( # Test that consumption statistics for 2 months have been added for entity in entities: - statistic_id = f"ista_ecotrend:{entity.entity_id.removeprefix("sensor.")}" + statistic_id = f"ista_ecotrend:{entity.entity_id.removeprefix('sensor.')}" stats = await hass.async_add_executor_job( statistics_during_period, hass, @@ -70,7 +70,7 @@ async def test_statistics_import( await async_wait_recording_done(hass) for entity in entities: - statistic_id = f"ista_ecotrend:{entity.entity_id.removeprefix("sensor.")}" + statistic_id = f"ista_ecotrend:{entity.entity_id.removeprefix('sensor.')}" stats = await hass.async_add_executor_job( statistics_during_period, hass, diff --git a/tests/components/local_file/test_camera.py b/tests/components/local_file/test_camera.py index ddfdf4249bd..0eb48aa3060 100644 --- a/tests/components/local_file/test_camera.py +++ b/tests/components/local_file/test_camera.py @@ -281,7 +281,7 @@ async def test_import_from_yaml_fails( assert not hass.states.get("camera.config_test") issue = issue_registry.async_get_issue( - DOMAIN, f"no_access_path_{slugify("mock.file")}" + DOMAIN, f"no_access_path_{slugify('mock.file')}" ) assert issue assert issue.translation_key == "no_access_path" diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index a45fa5b6668..de267f2719e 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -152,8 +152,7 @@ async def test_get_image_http( client = await hass_client_no_auth() with patch( - "homeassistant.components.media_player.MediaPlayerEntity." - "async_get_media_image", + "homeassistant.components.media_player.MediaPlayerEntity.async_get_media_image", return_value=(b"image", "image/jpeg"), ): resp = await client.get(state.attributes["entity_picture"]) diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 24293377174..e1c0e08a113 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -422,9 +422,9 @@ async def test_virtual_binary_sensor( assert hass.states.get(ENTITY_ID).state == expected for i, slave in enumerate(slaves): - entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}_{i+1}".replace(" ", "_") + entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}_{i + 1}".replace(" ", "_") assert hass.states.get(entity_id).state == slave - unique_id = f"{SLAVE_UNIQUE_ID}_{i+1}" + unique_id = f"{SLAVE_UNIQUE_ID}_{i + 1}" entry = entity_registry.async_get(entity_id) assert entry.unique_id == unique_id diff --git a/tests/components/motioneye/__init__.py b/tests/components/motioneye/__init__.py index 842d862a222..c403f9f072f 100644 --- a/tests/components/motioneye/__init__.py +++ b/tests/components/motioneye/__init__.py @@ -18,7 +18,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry TEST_CONFIG_ENTRY_ID = "74565ad414754616000674c87bdc876c" -TEST_URL = f"http://test:{DEFAULT_PORT+1}" +TEST_URL = f"http://test:{DEFAULT_PORT + 1}" TEST_CAMERA_ID = 100 TEST_CAMERA_NAME = "Test Camera" TEST_CAMERA_ENTITY_ID = "camera.test_camera" diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 8a674a4e1cd..2980b3b6fcc 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -356,7 +356,7 @@ async def test_invalid_device_discovery_config( async_fire_mqtt_message( hass, "homeassistant/device/bla/config", - '{ "o": {"name": "foobar"}, "dev": {"identifiers": ["ABDE03"]}, ' '"cmps": ""}', + '{ "o": {"name": "foobar"}, "dev": {"identifiers": ["ABDE03"]}, "cmps": ""}', ) await hass.async_block_till_done() assert ( diff --git a/tests/components/mystrom/__init__.py b/tests/components/mystrom/__init__.py index 8ee62996f92..aee6657b270 100644 --- a/tests/components/mystrom/__init__.py +++ b/tests/components/mystrom/__init__.py @@ -179,4 +179,4 @@ class MyStromSwitchMock(MyStromDeviceMock): """Return the URI.""" if not self._requested_state: return None - return f"http://{self._state["ip"]}" + return f"http://{self._state['ip']}" diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 3202b42d9b3..3186374a040 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -341,7 +341,7 @@ async def test_message_history_trimming( for i in range(5): result = await conversation.async_converse( hass, - f"message {i+1}", + f"message {i + 1}", conversation_id="1234", context=Context(), agent_id=mock_config_entry.entry_id, @@ -432,7 +432,7 @@ async def test_message_history_pruning( for i in range(3): result = await conversation.async_converse( hass, - f"message {i+1}", + f"message {i + 1}", conversation_id=None, context=Context(), agent_id=mock_config_entry.entry_id, @@ -490,7 +490,7 @@ async def test_message_history_unlimited( for i in range(100): result = await conversation.async_converse( hass, - f"message {i+1}", + f"message {i + 1}", conversation_id=conversation_id, context=Context(), agent_id=mock_config_entry.entry_id, diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index cd02c14e4eb..d14fbc5cbd1 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -830,7 +830,9 @@ async def test_hassio_discovery_flow_new_port_missing_unique_id( # Setup the config entry config_entry = MockConfigEntry( data={ - "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']+1}" + "url": ( + f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port'] + 1}" + ) }, domain=otbr.DOMAIN, options={}, @@ -861,7 +863,9 @@ async def test_hassio_discovery_flow_new_port(hass: HomeAssistant) -> None: # Setup the config entry config_entry = MockConfigEntry( data={ - "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']+1}" + "url": ( + f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port'] + 1}" + ) }, domain=otbr.DOMAIN, options={}, @@ -897,7 +901,9 @@ async def test_hassio_discovery_flow_new_port_other_addon(hass: HomeAssistant) - # Setup the config entry config_entry = MockConfigEntry( - data={"url": f"http://openthread_border_router:{HASSIO_DATA.config['port']+1}"}, + data={ + "url": f"http://openthread_border_router:{HASSIO_DATA.config['port'] + 1}" + }, domain=otbr.DOMAIN, options={}, source="hassio", @@ -914,7 +920,7 @@ async def test_hassio_discovery_flow_new_port_other_addon(hass: HomeAssistant) - # Make sure the data of the existing entry was not updated expected_data = { - "url": f"http://openthread_border_router:{HASSIO_DATA.config['port']+1}", + "url": f"http://openthread_border_router:{HASSIO_DATA.config['port'] + 1}", } config_entry = hass.config_entries.async_get_entry(config_entry.entry_id) assert config_entry.data == expected_data diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index c4ec108bb6b..42dcf449168 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -523,7 +523,7 @@ async def test_callback_view( assert result["type"] is FlowResultType.EXTERNAL_STEP client = await hass_client_no_auth() - forward_url = f'{config_flow.AUTH_CALLBACK_PATH}?flow_id={result["flow_id"]}' + forward_url = f"{config_flow.AUTH_CALLBACK_PATH}?flow_id={result['flow_id']}" resp = await client.get(forward_url) assert resp.status == HTTPStatus.OK diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 043a9cc4389..bbd58619b12 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -541,8 +541,7 @@ async def test_view_empty_namespace( assert "# HELP python_info Python platform information" in body assert ( - "# HELP python_gc_objects_collected_total " - "Objects collected during gc" in body + "# HELP python_gc_objects_collected_total Objects collected during gc" in body ) EntityMetric( @@ -569,8 +568,7 @@ async def test_view_default_namespace( assert "# HELP python_info Python platform information" in body assert ( - "# HELP python_gc_objects_collected_total " - "Objects collected during gc" in body + "# HELP python_gc_objects_collected_total Objects collected during gc" in body ) EntityMetric( diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 74d8861ae1e..24070e6f156 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -2655,9 +2655,9 @@ async def test_setup_fails_after_downgrade( await hass.async_stop() assert instance.engine is None assert ( - f"The database schema version {SCHEMA_VERSION+1} is newer than {SCHEMA_VERSION}" - " which is the maximum database schema version supported by the installed " - "version of Home Assistant Core" + f"The database schema version {SCHEMA_VERSION + 1} is newer " + f"than {SCHEMA_VERSION} which is the maximum database schema " + "version supported by the installed version of Home Assistant Core" ) in caplog.text diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index 4f4daee1301..6a2b7699840 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -1514,7 +1514,7 @@ async def _test_split_light( await common.async_turn_on(hass, entity) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - f"NoDelay;Power{idx+num_switches+1} ON", + f"NoDelay;Power{idx + num_switches + 1} ON", 0, False, ) @@ -1524,7 +1524,7 @@ async def _test_split_light( await common.async_turn_on(hass, entity, brightness=(idx + 1) * 25.5) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - f"NoDelay;Channel{idx+num_switches+1} {(idx+1)*10}", + f"NoDelay;Channel{idx + num_switches + 1} {(idx + 1) * 10}", 0, False, ) @@ -1595,7 +1595,7 @@ async def _test_unlinked_light( await common.async_turn_on(hass, entity) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - f"NoDelay;Power{idx+num_switches+1} ON", + f"NoDelay;Power{idx + num_switches + 1} ON", 0, False, ) @@ -1605,7 +1605,7 @@ async def _test_unlinked_light( await common.async_turn_on(hass, entity, brightness=(idx + 1) * 25.5) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - f"NoDelay;Dimmer{idx+1} {(idx+1)*10}", + f"NoDelay;Dimmer{idx + 1} {(idx + 1) * 10}", 0, False, ) diff --git a/tests/components/tessie/test_cover.py b/tests/components/tessie/test_cover.py index 49a53fd327c..02a8f22b6ea 100644 --- a/tests/components/tessie/test_cover.py +++ b/tests/components/tessie/test_cover.py @@ -112,4 +112,4 @@ async def test_errors(hass: HomeAssistant) -> None: blocking=True, ) mock_set.assert_called_once() - assert str(error.value) == f"Command failed, {TEST_RESPONSE_ERROR["reason"]}" + assert str(error.value) == f"Command failed, {TEST_RESPONSE_ERROR['reason']}" diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 8dad8881b9b..59cdda3ad92 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -501,7 +501,7 @@ async def test_unlink_devices( # Generate list of test identifiers test_identifiers = [ - (domain, f"{device_id}{"" if i == 0 else f"_000{i}"}") + (domain, f"{device_id}{'' if i == 0 else f'_000{i}'}") for i in range(id_count) for domain in domains ] diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index fc3aeccea9f..6a493e32b02 100644 --- a/tests/components/unifi/test_button.py +++ b/tests/components/unifi/test_button.py @@ -262,7 +262,7 @@ async def test_device_button_entities( WLAN_REGENERATE_PASSWORD, "button.ssid_1_regenerate_password", "put", - f"/rest/wlanconf/{WLAN_REGENERATE_PASSWORD[0]["_id"]}", + f"/rest/wlanconf/{WLAN_REGENERATE_PASSWORD[0]['_id']}", { "json": {"data": "password changed successfully", "meta": {"rc": "ok"}}, "headers": {"content-type": CONTENT_TYPE_JSON}, diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index c653370656d..b37e4f47137 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -589,14 +589,14 @@ async def test_restoring_client( entity_registry.async_get_or_create( # Make sure unique ID converts to site_id-mac TRACKER_DOMAIN, UNIFI_DOMAIN, - f'{clients_all_payload[0]["mac"]}-site_id', + f"{clients_all_payload[0]['mac']}-site_id", suggested_object_id=clients_all_payload[0]["hostname"], config_entry=config_entry, ) entity_registry.async_get_or_create( # Unique ID already follow format site_id-mac TRACKER_DOMAIN, UNIFI_DOMAIN, - f'site_id-{client_payload[0]["mac"]}', + f"site_id-{client_payload[0]['mac']}", suggested_object_id=client_payload[0]["hostname"], config_entry=config_entry, ) diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index ef93afa7e3e..cb5dcdac428 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -1577,14 +1577,14 @@ async def test_updating_unique_id( entity_registry.async_get_or_create( SWITCH_DOMAIN, UNIFI_DOMAIN, - f'{device_payload[0]["mac"]}-outlet-1', + f"{device_payload[0]['mac']}-outlet-1", suggested_object_id="plug_outlet_1", config_entry=config_entry, ) entity_registry.async_get_or_create( SWITCH_DOMAIN, UNIFI_DOMAIN, - f'{device_payload[1]["mac"]}-poe-1', + f"{device_payload[1]['mac']}-poe-1", suggested_object_id="switch_port_1_poe", config_entry=config_entry, ) From 024b9ae4142980411c605d66a61e88cb83812330 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 10 Jan 2025 11:06:08 +0100 Subject: [PATCH 0274/2987] Change 'entity_id' to UI-friendly 'Entity ID', fix spelling of "setpoint" (#135234) In addition this makes the description of the first action consistent, using third-person singular like the other two and adhering to the HA standard. --- homeassistant/components/geniushub/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/geniushub/strings.json b/homeassistant/components/geniushub/strings.json index faf5011d752..42d53c7fa00 100644 --- a/homeassistant/components/geniushub/strings.json +++ b/homeassistant/components/geniushub/strings.json @@ -37,11 +37,11 @@ "services": { "set_zone_mode": { "name": "Set zone mode", - "description": "Set the zone to an operating mode.", + "description": "Sets the zone to an operating mode.", "fields": { "entity_id": { "name": "Entity", - "description": "The zone's entity_id." + "description": "The zone's entity ID." }, "mode": { "name": "[%key:common::config_flow::data::mode%]", @@ -51,7 +51,7 @@ }, "set_zone_override": { "name": "Set zone override", - "description": "Overrides the zone's set point for a given duration.", + "description": "Overrides the zone's setpoint for a given duration.", "fields": { "entity_id": { "name": "Entity", From aa741a920740fb4e23425401a5f63214092fc92b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jan 2025 11:07:51 +0100 Subject: [PATCH 0275/2987] Combine short strings in components (#135265) --- homeassistant/components/mqtt/client.py | 3 +-- homeassistant/components/mqtt/event.py | 2 +- homeassistant/components/mqtt/siren.py | 5 +---- homeassistant/components/mqtt/update.py | 5 +---- homeassistant/components/recorder/migration.py | 5 +---- homeassistant/components/recorder/util.py | 5 +---- homeassistant/components/zwave_js/services.py | 5 +---- 7 files changed, 7 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 6500c9f91c9..16a02e4956e 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -220,8 +220,7 @@ def async_subscribe_internal( mqtt_data = hass.data[DATA_MQTT] except KeyError as exc: raise HomeAssistantError( - f"Cannot subscribe to topic '{topic}', " - "make sure MQTT is set up correctly", + f"Cannot subscribe to topic '{topic}', make sure MQTT is set up correctly", translation_key="mqtt_not_setup_cannot_subscribe", translation_domain=DOMAIN, translation_placeholders={"topic": topic}, diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index d9812aaaf48..f665f2c4016 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -151,7 +151,7 @@ class MqttEvent(MqttEntity, EventEntity): ) except KeyError: _LOGGER.warning( - ("`event_type` missing in JSON event payload, " " '%s' on topic %s"), + "`event_type` missing in JSON event payload, '%s' on topic %s", payload, msg.topic, ) diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 22f64053d23..1cc5ba2d2e5 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -217,10 +217,7 @@ class MqttSiren(MqttEntity, SirenEntity): try: json_payload = json_loads_object(payload) _LOGGER.debug( - ( - "JSON payload detected after processing payload '%s' on" - " topic %s" - ), + "JSON payload detected after processing payload '%s' on topic %s", json_payload, msg.topic, ) diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index 99b4e5cb821..59742d24b60 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -151,10 +151,7 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): rendered_json_payload = json_loads(payload) if isinstance(rendered_json_payload, dict): _LOGGER.debug( - ( - "JSON payload detected after processing payload '%s' on" - " topic %s" - ), + "JSON payload detected after processing payload '%s' on topic %s", rendered_json_payload, msg.topic, ) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 6ae1e265901..860a3ef8c0f 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -2073,10 +2073,7 @@ def _wipe_old_string_time_columns( session.execute(text("UPDATE events set time_fired=NULL LIMIT 100000;")) session.commit() session.execute( - text( - "UPDATE states set last_updated=NULL, last_changed=NULL " - " LIMIT 100000;" - ) + text("UPDATE states set last_updated=NULL, last_changed=NULL LIMIT 100000;") ) session.commit() elif engine.dialect.name == SupportedDialect.POSTGRESQL: diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 55364863f7e..a1f8d90953c 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -927,10 +927,7 @@ def filter_unique_constraint_integrity_error( if ignore: _LOGGER.warning( - ( - "Blocked attempt to insert duplicated %s rows, please report" - " at %s" - ), + "Blocked attempt to insert duplicated %s rows, please report at %s", row_type, "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+recorder%22", exc_info=err, diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index d1cb66ceafc..fe293fd178b 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -488,10 +488,7 @@ class ZWaveServices: ) if nodes_without_endpoints and _LOGGER.isEnabledFor(logging.WARNING): _LOGGER.warning( - ( - "The following nodes do not have endpoint %x and will be " - "skipped: %s" - ), + "The following nodes do not have endpoint %x and will be skipped: %s", endpoint, nodes_without_endpoints, ) From 8386eaa92b028082f4083acedc7f4c5375b72057 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jan 2025 11:09:20 +0100 Subject: [PATCH 0276/2987] Split long strings in stream hls tests (#135271) --- tests/components/stream/test_hls.py | 9 ++++++--- tests/components/stream/test_ll_hls.py | 26 +++++++++++++------------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index 9ce297c3fb6..cd48fd94c24 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -119,13 +119,16 @@ def make_playlist( response.extend( [ f"#EXT-X-PART-INF:PART-TARGET={part_target_duration:.3f}", - f"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={2*part_target_duration:.3f}", - f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_LL_HLS*part_target_duration:.3f},PRECISE=YES", + "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=" + f"{2 * part_target_duration:.3f}", + "#EXT-X-START:TIME-OFFSET=-" + f"{EXT_X_START_LL_HLS * part_target_duration:.3f},PRECISE=YES", ] ) else: response.append( - f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_NON_LL_HLS*segment_duration:.3f},PRECISE=YES", + "#EXT-X-START:TIME-OFFSET=-" + f"{EXT_X_START_NON_LL_HLS * segment_duration:.3f},PRECISE=YES", ) if segments: response.extend(segments) diff --git a/tests/components/stream/test_ll_hls.py b/tests/components/stream/test_ll_hls.py index 5577076830b..f37cba8ea9f 100644 --- a/tests/components/stream/test_ll_hls.py +++ b/tests/components/stream/test_ll_hls.py @@ -99,18 +99,17 @@ def make_segment_with_parts( if discontinuity: response.append("#EXT-X-DISCONTINUITY") response.extend( - f'#EXT-X-PART:DURATION={TEST_PART_DURATION:.3f},URI="./segment/{segment}.{i}.m4s"{",INDEPENDENT=YES" if i%independent_period==0 else ""}' + f'#EXT-X-PART:DURATION={TEST_PART_DURATION:.3f},' + f'URI="./segment/{segment}.{i}.m4s"' + f'{",INDEPENDENT=YES" if i % independent_period == 0 else ""}' for i in range(num_parts) ) - response.extend( - [ - "#EXT-X-PROGRAM-DATE-TIME:" - + FAKE_TIME.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] - + "Z", - f"#EXTINF:{math.ceil(SEGMENT_DURATION/TEST_PART_DURATION)*TEST_PART_DURATION:.3f},", - f"./segment/{segment}.m4s", - ] + response.append( + f"#EXT-X-PROGRAM-DATE-TIME:{FAKE_TIME.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3]}Z" ) + duration = math.ceil(SEGMENT_DURATION / TEST_PART_DURATION) * TEST_PART_DURATION + response.append(f"#EXTINF:{duration:.3f},") + response.append(f"./segment/{segment}.m4s") return "\n".join(response) @@ -166,7 +165,7 @@ async def test_ll_hls_stream( # Fetch playlist playlist_url = "/" + master_playlist.splitlines()[-1] playlist_response = await hls_client.get( - playlist_url + f"?_HLS_msn={num_playlist_segments-1}" + playlist_url + f"?_HLS_msn={num_playlist_segments - 1}" ) assert playlist_response.status == HTTPStatus.OK @@ -465,7 +464,8 @@ async def test_ll_hls_playlist_bad_msn_part( ).status == HTTPStatus.BAD_REQUEST assert ( await hls_client.get( - f"/playlist.m3u8?_HLS_msn=1&_HLS_part={num_completed_parts-1+hass.data[DOMAIN][ATTR_SETTINGS].hls_advance_part_limit}" + "/playlist.m3u8?_HLS_msn=1&_HLS_part=" + f"{num_completed_parts - 1 + hass.data[DOMAIN][ATTR_SETTINGS].hls_advance_part_limit}" ) ).status == HTTPStatus.BAD_REQUEST stream_worker_sync.resume() @@ -515,13 +515,13 @@ async def test_ll_hls_playlist_rollover_part( *( [ hls_client.get( - f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts)-1}" + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts) - 1}" ), hls_client.get( f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts)}" ), hls_client.get( - f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts)+1}" + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts) + 1}" ), hls_client.get("/playlist.m3u8?_HLS_msn=2&_HLS_part=0"), ] From a2d9920aa90d904e7c032ef3f382acfc14b4c03b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jan 2025 11:09:49 +0100 Subject: [PATCH 0277/2987] Fix missing comma in ollama MODEL_NAMES (#135262) --- homeassistant/components/ollama/const.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py index 69c0a3d6296..857f0bff34a 100644 --- a/homeassistant/components/ollama/const.py +++ b/homeassistant/components/ollama/const.py @@ -61,7 +61,8 @@ MODEL_NAMES = [ # https://ollama.com/library "goliath", "granite-code", "granite3-dense", - "granite3-guardian" "granite3-moe", + "granite3-guardian", + "granite3-moe", "hermes3", "internlm2", "llama-guard3", From 033064f83217e920000c8d3b6521f7511a1d3c69 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Fri, 10 Jan 2025 11:10:09 +0100 Subject: [PATCH 0278/2987] Velbus light platform code cleanup (#134482) --- homeassistant/components/velbus/light.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py index 1adf52a8198..c134095c2ff 100644 --- a/homeassistant/components/velbus/light.py +++ b/homeassistant/components/velbus/light.py @@ -122,19 +122,14 @@ class VelbusButtonLight(VelbusEntity, LightEntity): @api_call async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the Velbus light to turn on.""" - if ATTR_FLASH in kwargs: - if kwargs[ATTR_FLASH] == FLASH_LONG: - attr, *args = "set_led_state", "slow" - elif kwargs[ATTR_FLASH] == FLASH_SHORT: - attr, *args = "set_led_state", "fast" - else: - attr, *args = "set_led_state", "on" + if (flash := ATTR_FLASH in kwargs) and kwargs[ATTR_FLASH] == FLASH_LONG: + await self._channel.set_led_state("slow") + elif flash and kwargs[ATTR_FLASH] == FLASH_SHORT: + await self._channel.set_led_state("fast") else: - attr, *args = "set_led_state", "on" - await getattr(self._channel, attr)(*args) + await self._channel.set_led_state("on") @api_call async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the velbus light to turn off.""" - attr, *args = "set_led_state", "off" - await getattr(self._channel, attr)(*args) + await self._channel.set_led_state("off") From ad8449054129da5e76caa4f6fc61c1466ba182d7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jan 2025 11:10:23 +0100 Subject: [PATCH 0279/2987] Fix incorrect test in test_core_config (#135260) --- tests/test_core_config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_core_config.py b/tests/test_core_config.py index dae50bae097..6c7e188df6d 100644 --- a/tests/test_core_config.py +++ b/tests/test_core_config.py @@ -181,7 +181,8 @@ def test_validate_stun_or_turn_url() -> None: invalid_urls = ( "custom_stun_server", "custom_stun_server:3478", - "bum:custom_stun_server:3478" "http://blah.com:80", + "bum:custom_stun_server:3478", + "http://blah.com:80", ) valid_urls = ( From b5971ec55dbd346905a077210717f59668bd379d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jan 2025 11:18:50 +0100 Subject: [PATCH 0280/2987] Add model_id and serial_number to onewire device info (#135279) --- .../components/onewire/onewirehub.py | 19 +---- .../onewire/snapshots/test_diagnostics.ambr | 2 + .../onewire/snapshots/test_init.ambr | 84 +++++++++---------- tests/components/onewire/test_diagnostics.py | 14 ---- 4 files changed, 48 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index 2fd445a1ca3..deeaaa6283d 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -8,15 +8,7 @@ import os from pyownet import protocol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_NAME, - ATTR_VIA_DEVICE, - CONF_HOST, - CONF_PORT, -) +from homeassistant.const import ATTR_VIA_DEVICE, CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -80,14 +72,9 @@ class OneWireHub: # Populate the device registry device_registry = dr.async_get(self._hass) for device in self.devices: - device_info = device.device_info device_registry.async_get_or_create( config_entry_id=self._config_entry.entry_id, - identifiers=device_info[ATTR_IDENTIFIERS], - manufacturer=device_info[ATTR_MANUFACTURER], - model=device_info[ATTR_MODEL], - name=device_info[ATTR_NAME], - via_device=device_info.get(ATTR_VIA_DEVICE), + **device.device_info, ) @@ -113,7 +100,9 @@ def _discover_devices( identifiers={(DOMAIN, device_id)}, manufacturer=DEVICE_MANUFACTURER.get(device_family, MANUFACTURER_MAXIM), model=device_type, + model_id=device_type, name=device_id, + serial_number=device_id[3:], ) if parent_id: device_info[ATTR_VIA_DEVICE] = (DOMAIN, parent_id) diff --git a/tests/components/onewire/snapshots/test_diagnostics.ambr b/tests/components/onewire/snapshots/test_diagnostics.ambr index f51fca7e988..6c5631331ca 100644 --- a/tests/components/onewire/snapshots/test_diagnostics.ambr +++ b/tests/components/onewire/snapshots/test_diagnostics.ambr @@ -12,7 +12,9 @@ ]), 'manufacturer': 'Hobby Boards', 'model': 'HB_HUB', + 'model_id': 'HB_HUB', 'name': 'EF.111111111113', + 'serial_number': '111111111113', }), 'family': 'EF', 'id': 'EF.111111111113', diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr index e2d818534f5..159f3acea42 100644 --- a/tests/components/onewire/snapshots/test_init.ambr +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -21,11 +21,11 @@ }), 'manufacturer': 'Maxim Integrated', 'model': 'DS2405', - 'model_id': None, + 'model_id': 'DS2405', 'name': '05.111111111111', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '111111111111', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -53,11 +53,11 @@ }), 'manufacturer': 'Maxim Integrated', 'model': 'DS18S20', - 'model_id': None, + 'model_id': 'DS18S20', 'name': '10.111111111111', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '111111111111', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -85,11 +85,11 @@ }), 'manufacturer': 'Maxim Integrated', 'model': 'DS2406', - 'model_id': None, + 'model_id': 'DS2406', 'name': '12.111111111111', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '111111111111', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -117,11 +117,11 @@ }), 'manufacturer': 'Maxim Integrated', 'model': 'DS2423', - 'model_id': None, + 'model_id': 'DS2423', 'name': '1D.111111111111', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '111111111111', 'suggested_area': None, 'sw_version': None, 'via_device_id': , @@ -149,11 +149,11 @@ }), 'manufacturer': 'Maxim Integrated', 'model': 'DS2409', - 'model_id': None, + 'model_id': 'DS2409', 'name': '1F.111111111111', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '111111111111', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -181,11 +181,11 @@ }), 'manufacturer': 'Maxim Integrated', 'model': 'DS1822', - 'model_id': None, + 'model_id': 'DS1822', 'name': '22.111111111111', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '111111111111', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -213,11 +213,11 @@ }), 'manufacturer': 'Maxim Integrated', 'model': 'DS2438', - 'model_id': None, + 'model_id': 'DS2438', 'name': '26.111111111111', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '111111111111', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -245,11 +245,11 @@ }), 'manufacturer': 'Maxim Integrated', 'model': 'DS18B20', - 'model_id': None, + 'model_id': 'DS18B20', 'name': '28.111111111111', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '111111111111', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -277,11 +277,11 @@ }), 'manufacturer': 'Maxim Integrated', 'model': 'DS18B20', - 'model_id': None, + 'model_id': 'DS18B20', 'name': '28.222222222222', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '222222222222', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -309,11 +309,11 @@ }), 'manufacturer': 'Maxim Integrated', 'model': 'DS18B20', - 'model_id': None, + 'model_id': 'DS18B20', 'name': '28.222222222223', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '222222222223', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -341,11 +341,11 @@ }), 'manufacturer': 'Maxim Integrated', 'model': 'DS2408', - 'model_id': None, + 'model_id': 'DS2408', 'name': '29.111111111111', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '111111111111', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -373,11 +373,11 @@ }), 'manufacturer': 'Maxim Integrated', 'model': 'DS2760', - 'model_id': None, + 'model_id': 'DS2760', 'name': '30.111111111111', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '111111111111', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -405,11 +405,11 @@ }), 'manufacturer': 'Maxim Integrated', 'model': 'DS2413', - 'model_id': None, + 'model_id': 'DS2413', 'name': '3A.111111111111', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '111111111111', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -437,11 +437,11 @@ }), 'manufacturer': 'Maxim Integrated', 'model': 'DS1825', - 'model_id': None, + 'model_id': 'DS1825', 'name': '3B.111111111111', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '111111111111', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -469,11 +469,11 @@ }), 'manufacturer': 'Maxim Integrated', 'model': 'DS28EA00', - 'model_id': None, + 'model_id': 'DS28EA00', 'name': '42.111111111111', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '111111111111', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -501,11 +501,11 @@ }), 'manufacturer': 'Embedded Data Systems', 'model': 'EDS0068', - 'model_id': None, + 'model_id': 'EDS0068', 'name': '7E.111111111111', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '111111111111', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -533,11 +533,11 @@ }), 'manufacturer': 'Embedded Data Systems', 'model': 'EDS0066', - 'model_id': None, + 'model_id': 'EDS0066', 'name': '7E.222222222222', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '222222222222', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -565,11 +565,11 @@ }), 'manufacturer': 'Maxim Integrated', 'model': 'DS2438', - 'model_id': None, + 'model_id': 'DS2438', 'name': 'A6.111111111111', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '111111111111', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -597,11 +597,11 @@ }), 'manufacturer': 'Hobby Boards', 'model': 'HobbyBoards_EF', - 'model_id': None, + 'model_id': 'HobbyBoards_EF', 'name': 'EF.111111111111', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '111111111111', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -629,11 +629,11 @@ }), 'manufacturer': 'Hobby Boards', 'model': 'HB_MOISTURE_METER', - 'model_id': None, + 'model_id': 'HB_MOISTURE_METER', 'name': 'EF.111111111112', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '111111111112', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -661,11 +661,11 @@ }), 'manufacturer': 'Hobby Boards', 'model': 'HB_HUB', - 'model_id': None, + 'model_id': 'HB_HUB', 'name': 'EF.111111111113', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '111111111113', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/onewire/test_diagnostics.py b/tests/components/onewire/test_diagnostics.py index ebbd8fdbf02..60b57bd14f7 100644 --- a/tests/components/onewire/test_diagnostics.py +++ b/tests/components/onewire/test_diagnostics.py @@ -23,20 +23,6 @@ def override_platforms() -> Generator[None]: yield -DEVICE_DETAILS = { - "device_info": { - "identifiers": [["onewire", "EF.111111111113"]], - "manufacturer": "Hobby Boards", - "model": "HB_HUB", - "name": "EF.111111111113", - }, - "family": "EF", - "id": "EF.111111111113", - "path": "/EF.111111111113/", - "type": "HB_HUB", -} - - @pytest.mark.parametrize("device_id", ["EF.111111111113"], indirect=True) async def test_entry_diagnostics( hass: HomeAssistant, From eba090c9ef6fc64d70cd44322e73c81d2784b7c8 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 10 Jan 2025 11:43:36 +0100 Subject: [PATCH 0281/2987] Allow to process kelvin as color_temp for mqtt template light (#133957) --- .../components/mqtt/light/schema_template.py | 27 ++- tests/components/mqtt/test_light_template.py | 163 ++++++++++++------ 2 files changed, 137 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 722bd864366..69bc801ff1e 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -39,7 +39,14 @@ import homeassistant.util.color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA -from ..const import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC, PAYLOAD_NONE +from ..const import ( + CONF_COLOR_TEMP_KELVIN, + CONF_COMMAND_TOPIC, + CONF_MAX_KELVIN, + CONF_MIN_KELVIN, + CONF_STATE_TOPIC, + PAYLOAD_NONE, +) from ..entity import MqttEntity from ..models import ( MqttCommandTemplate, @@ -85,12 +92,15 @@ PLATFORM_SCHEMA_MODERN_TEMPLATE = ( { vol.Optional(CONF_BLUE_TEMPLATE): cv.template, vol.Optional(CONF_BRIGHTNESS_TEMPLATE): cv.template, + vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean, vol.Optional(CONF_COLOR_TEMP_TEMPLATE): cv.template, vol.Required(CONF_COMMAND_OFF_TEMPLATE): cv.template, vol.Required(CONF_COMMAND_ON_TEMPLATE): cv.template, vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_EFFECT_TEMPLATE): cv.template, vol.Optional(CONF_GREEN_TEMPLATE): cv.template, + vol.Optional(CONF_MAX_KELVIN): cv.positive_int, + vol.Optional(CONF_MIN_KELVIN): cv.positive_int, vol.Optional(CONF_MAX_MIREDS): cv.positive_int, vol.Optional(CONF_MIN_MIREDS): cv.positive_int, vol.Optional(CONF_NAME): vol.Any(cv.string, None), @@ -128,15 +138,16 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" + self._color_temp_kelvin = config[CONF_COLOR_TEMP_KELVIN] self._attr_min_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(max_mireds) if (max_mireds := config.get(CONF_MAX_MIREDS)) - else DEFAULT_MIN_KELVIN + else config.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) ) self._attr_max_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(min_mireds) if (min_mireds := config.get(CONF_MIN_MIREDS)) - else DEFAULT_MAX_KELVIN + else config.get(CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN) ) self._attr_effect_list = config.get(CONF_EFFECT_LIST) @@ -224,7 +235,9 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): msg.payload ) self._attr_color_temp_kelvin = ( - color_util.color_temperature_mired_to_kelvin(int(color_temp)) + int(color_temp) + if self._color_temp_kelvin + else color_util.color_temperature_mired_to_kelvin(int(color_temp)) if color_temp != "None" else None ) @@ -310,8 +323,12 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): self._attr_brightness = kwargs[ATTR_BRIGHTNESS] if ATTR_COLOR_TEMP_KELVIN in kwargs: - values["color_temp"] = color_util.color_temperature_kelvin_to_mired( + values["color_temp"] = ( kwargs[ATTR_COLOR_TEMP_KELVIN] + if self._color_temp_kelvin + else color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) ) if self._optimistic: diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 4d2b93ff159..568d86f8bd9 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -179,25 +179,50 @@ async def test_rgb_light( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "kelvin", "payload"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "template", - "name": "test", - "command_topic": "test_light/set", - "command_on_template": "on,{{ brightness|d }},{{ color_temp|d }}", - "command_off_template": "off", - "brightness_template": "{{ value.split(',')[1] }}", - "color_temp_template": "{{ value.split(',')[2] }}", + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_light/set", + "command_on_template": "on,{{ brightness|d }},{{ color_temp|d }}", + "command_off_template": "off", + "brightness_template": "{{ value.split(',')[1] }}", + "color_temp_template": "{{ value.split(',')[2] }}", + } } - } - } + }, + 5208, + "192", + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_light/set", + "command_on_template": "on,{{ brightness|d }},{{ color_temp|d }}", + "command_off_template": "off", + "brightness_template": "{{ value.split(',')[1] }}", + "color_temp_template": "{{ value.split(',')[2] }}", + } + } + }, + 5208, + "5208", + ), ], + ids=["mireds", "kelvin"], ) async def test_single_color_mode( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + kelvin: int, + payload: str, ) -> None: """Test the color mode when we only have one supported color_mode.""" await mqtt_mock_entry() @@ -206,15 +231,15 @@ async def test_single_color_mode( assert state.state == STATE_UNKNOWN await common.async_turn_on( - hass, "light.test", brightness=50, color_temp_kelvin=5208 + hass, "light.test", brightness=50, color_temp_kelvin=kelvin ) - async_fire_mqtt_message(hass, "test_light", "on,50,192") + async_fire_mqtt_message(hass, "test_light", f"on,50,{payload}") color_modes = [light.ColorMode.COLOR_TEMP] state = hass.states.get("light.test") assert state.state == STATE_ON assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes - assert state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) == 5208 + assert state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) == kelvin assert state.attributes.get(light.ATTR_BRIGHTNESS) == 50 assert state.attributes.get(light.ATTR_COLOR_MODE) == color_modes[0] @@ -392,39 +417,80 @@ async def test_state_brightness_color_effect_temp_change_via_topic( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "kelvin", "payload"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "template", - "name": "test", - "command_topic": "test_light_rgb/set", - "command_on_template": "on," - "{{ brightness|d }}," - "{{ color_temp|d }}," - "{{ red|d }}-" - "{{ green|d }}-" - "{{ blue|d }}," - "{{ hue|d }}-" - "{{ sat|d }}", - "command_off_template": "off", - "effect_list": ["colorloop", "random"], - "optimistic": True, - "state_template": '{{ value.split(",")[0] }}', - "color_temp_template": '{{ value.split(",")[2] }}', - "red_template": '{{ value.split(",")[3].split("-")[0] }}', - "green_template": '{{ value.split(",")[3].split("-")[1] }}', - "blue_template": '{{ value.split(",")[3].split("-")[2] }}', - "effect_template": '{{ value.split(",")[4] }}', - "qos": 2, + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_light_rgb/set", + "command_on_template": "on," + "{{ brightness|d }}," + "{{ color_temp|d }}," + "{{ red|d }}-" + "{{ green|d }}-" + "{{ blue|d }}," + "{{ hue|d }}-" + "{{ sat|d }}", + "command_off_template": "off", + "effect_list": ["colorloop", "random"], + "optimistic": True, + "state_template": '{{ value.split(",")[0] }}', + "color_temp_kelvin": False, + "color_temp_template": '{{ value.split(",")[2] }}', + "red_template": '{{ value.split(",")[3].split("-")[0] }}', + "green_template": '{{ value.split(",")[3].split("-")[1] }}', + "blue_template": '{{ value.split(",")[3].split("-")[2] }}', + "effect_template": '{{ value.split(",")[4] }}', + "qos": 2, + } } - } - } + }, + 14285, + "on,,70,--,-", + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_light_rgb/set", + "command_on_template": "on," + "{{ brightness|d }}," + "{{ color_temp|d }}," + "{{ red|d }}-" + "{{ green|d }}-" + "{{ blue|d }}," + "{{ hue|d }}-" + "{{ sat|d }}", + "command_off_template": "off", + "effect_list": ["colorloop", "random"], + "optimistic": True, + "state_template": '{{ value.split(",")[0] }}', + "color_temp_kelvin": True, + "color_temp_template": '{{ value.split(",")[2] }}', + "red_template": '{{ value.split(",")[3].split("-")[0] }}', + "green_template": '{{ value.split(",")[3].split("-")[1] }}', + "blue_template": '{{ value.split(",")[3].split("-")[2] }}', + "effect_template": '{{ value.split(",")[4] }}', + "qos": 2, + } + }, + }, + 14285, + "on,,14285,--,-", + ), ], + ids=["mireds", "kelvin"], ) async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + kelvin: int, + payload: str, ) -> None: """Test the sending of command in optimistic mode.""" fake_state = State( @@ -465,14 +531,15 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == STATE_ON # Set color_temp - await common.async_turn_on(hass, "light.test", color_temp_kelvin=14285) + await common.async_turn_on(hass, "light.test", color_temp_kelvin=kelvin) + # Assert mireds or Kelvin as payload mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,70,--,-", 2, False + "test_light_rgb/set", payload, 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("color_temp_kelvin") == 14285 + assert state.attributes.get("color_temp_kelvin") == kelvin # Set full brightness await common.async_turn_on(hass, "light.test", brightness=255) From 24c70caf33c07a64e1d6777718be90135551ada3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jan 2025 11:46:15 +0100 Subject: [PATCH 0282/2987] Improve formatting in component files (#135261) * Improve formatting in component files * Apply suggestions from code review --- .../components/advantage_air/binary_sensor.py | 4 ++-- .../components/advantage_air/sensor.py | 6 +++--- homeassistant/components/airthings/sensor.py | 3 +-- .../components/airvisual_pro/sensor.py | 2 +- .../components/automation/__init__.py | 4 ++-- homeassistant/components/balboa/entity.py | 2 +- homeassistant/components/blueprint/importer.py | 2 +- homeassistant/components/bthome/logbook.py | 2 +- .../components/compensation/__init__.py | 2 +- .../components/doods/image_processing.py | 2 +- .../components/duke_energy/coordinator.py | 4 ++-- homeassistant/components/ecobee/climate.py | 4 ++-- homeassistant/components/ecobee/notify.py | 2 +- homeassistant/components/flux_led/__init__.py | 2 +- homeassistant/components/fronius/sensor.py | 8 ++++---- homeassistant/components/harmony/data.py | 3 +-- .../components/hisense_aehw4a1/climate.py | 2 +- homeassistant/components/hive/entity.py | 2 +- homeassistant/components/homekit/iidmanager.py | 4 ++-- .../components/homekit/type_triggers.py | 4 ++-- .../homekit_controller/connection.py | 3 +-- homeassistant/components/ihc/entity.py | 2 +- .../components/ista_ecotrend/config_flow.py | 2 +- homeassistant/components/isy994/select.py | 2 +- .../components/keymitt_ble/config_flow.py | 2 +- homeassistant/components/knx/expose.py | 2 +- .../kostal_plenticore/coordinator.py | 4 ++-- .../components/lacrosse_view/config_flow.py | 2 +- homeassistant/components/lcn/helpers.py | 4 ++-- .../components/lektrico/config_flow.py | 2 +- homeassistant/components/lg_thinq/mqtt.py | 2 +- homeassistant/components/lovelace/cast.py | 2 +- .../components/lutron_caseta/button.py | 2 +- .../components/lutron_caseta/switch.py | 2 +- homeassistant/components/picnic/config_flow.py | 4 ++-- .../components/progettihwsw/__init__.py | 2 +- .../components/progettihwsw/config_flow.py | 2 +- homeassistant/components/pyload/__init__.py | 2 +- homeassistant/components/pyload/config_flow.py | 2 +- .../recorder/system_health/__init__.py | 2 +- homeassistant/components/reolink/__init__.py | 2 +- homeassistant/components/rfxtrx/sensor.py | 2 +- homeassistant/components/sense/entity.py | 2 +- homeassistant/components/shelly/climate.py | 2 +- homeassistant/components/shelly/logbook.py | 2 +- homeassistant/components/shelly/utils.py | 6 +++--- homeassistant/components/slide_local/button.py | 2 +- homeassistant/components/slide_local/switch.py | 2 +- homeassistant/components/soma/cover.py | 16 ++++++++-------- homeassistant/components/stream/core.py | 2 +- homeassistant/components/stream/worker.py | 2 +- .../components/surepetcare/binary_sensor.py | 4 ++-- homeassistant/components/switchbee/__init__.py | 2 +- .../components/switchbot/config_flow.py | 2 +- homeassistant/components/tailwind/entity.py | 2 +- homeassistant/components/template/__init__.py | 2 +- homeassistant/components/twinkly/select.py | 2 +- homeassistant/components/webhook/__init__.py | 18 ++++++++++-------- .../components/webostv/config_flow.py | 2 +- homeassistant/components/websocket_api/http.py | 2 +- .../yale_smart_alarm/binary_sensor.py | 2 +- .../components/yale_smart_alarm/coordinator.py | 2 +- homeassistant/components/zha/update.py | 6 +----- 63 files changed, 96 insertions(+), 101 deletions(-) diff --git a/homeassistant/components/advantage_air/binary_sensor.py b/homeassistant/components/advantage_air/binary_sensor.py index 2ad8c2217a2..601b10aeb4a 100644 --- a/homeassistant/components/advantage_air/binary_sensor.py +++ b/homeassistant/components/advantage_air/binary_sensor.py @@ -66,7 +66,7 @@ class AdvantageAirZoneMotion(AdvantageAirZoneEntity, BinarySensorEntity): def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone Motion sensor.""" super().__init__(instance, ac_key, zone_key) - self._attr_name = f'{self._zone["name"]} motion' + self._attr_name = f"{self._zone['name']} motion" self._attr_unique_id += "-motion" @property @@ -84,7 +84,7 @@ class AdvantageAirZoneMyZone(AdvantageAirZoneEntity, BinarySensorEntity): def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone MyZone sensor.""" super().__init__(instance, ac_key, zone_key) - self._attr_name = f'{self._zone["name"]} myZone' + self._attr_name = f"{self._zone['name']} myZone" self._attr_unique_id += "-myzone" @property diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index bd3fa970fb9..ab1a1c4f9a0 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -103,7 +103,7 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, SensorEntity): def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone Vent Sensor.""" super().__init__(instance, ac_key, zone_key=zone_key) - self._attr_name = f'{self._zone["name"]} vent' + self._attr_name = f"{self._zone['name']} vent" self._attr_unique_id += "-vent" @property @@ -131,7 +131,7 @@ class AdvantageAirZoneSignal(AdvantageAirZoneEntity, SensorEntity): def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone wireless signal sensor.""" super().__init__(instance, ac_key, zone_key) - self._attr_name = f'{self._zone["name"]} signal' + self._attr_name = f"{self._zone['name']} signal" self._attr_unique_id += "-signal" @property @@ -165,7 +165,7 @@ class AdvantageAirZoneTemp(AdvantageAirZoneEntity, SensorEntity): def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone Temp Sensor.""" super().__init__(instance, ac_key, zone_key) - self._attr_name = f'{self._zone["name"]} temperature' + self._attr_name = f"{self._zone['name']} temperature" self._attr_unique_id += "-temp" @property diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index f35d5c9667c..1b604d72032 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -155,8 +155,7 @@ class AirthingsHeaterEnergySensor( self._id = airthings_device.device_id self._attr_device_info = DeviceInfo( configuration_url=( - "https://dashboard.airthings.com/devices/" - f"{airthings_device.device_id}" + f"https://dashboard.airthings.com/devices/{airthings_device.device_id}" ), identifiers={(DOMAIN, airthings_device.device_id)}, name=airthings_device.name, diff --git a/homeassistant/components/airvisual_pro/sensor.py b/homeassistant/components/airvisual_pro/sensor.py index 66726832843..58ad730bc31 100644 --- a/homeassistant/components/airvisual_pro/sensor.py +++ b/homeassistant/components/airvisual_pro/sensor.py @@ -50,7 +50,7 @@ SENSOR_DESCRIPTIONS = ( state_class=SensorStateClass.MEASUREMENT, value_fn=lambda settings, status, measurements, history: int( history.get( - f'Outdoor {"AQI(US)" if settings["is_aqi_usa"] else "AQI(CN)"}', -1 + f"Outdoor {'AQI(US)' if settings['is_aqi_usa'] else 'AQI(CN)'}", -1 ) ), translation_key="outdoor_air_quality_index", diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index bd8af526d75..955a6215096 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -636,9 +636,9 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): alias = "" if "trigger" in run_variables: if "description" in run_variables["trigger"]: - reason = f' by {run_variables["trigger"]["description"]}' + reason = f" by {run_variables['trigger']['description']}" if "alias" in run_variables["trigger"]: - alias = f' trigger \'{run_variables["trigger"]["alias"]}\'' + alias = f" trigger '{run_variables['trigger']['alias']}'" self._logger.debug("Automation%s triggered%s", alias, reason) # Create a new context referring to the old context. diff --git a/homeassistant/components/balboa/entity.py b/homeassistant/components/balboa/entity.py index a7d75bfbdf5..a541d044a21 100644 --- a/homeassistant/components/balboa/entity.py +++ b/homeassistant/components/balboa/entity.py @@ -20,7 +20,7 @@ class BalboaEntity(Entity): """Initialize the control.""" mac = client.mac_address model = client.model - self._attr_unique_id = f'{model}-{key}-{mac.replace(":","")[-6:]}' + self._attr_unique_id = f"{model}-{key}-{mac.replace(':', '')[-6:]}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, mac)}, name=model, diff --git a/homeassistant/components/blueprint/importer.py b/homeassistant/components/blueprint/importer.py index 32fe7b56495..544f9554b9f 100644 --- a/homeassistant/components/blueprint/importer.py +++ b/homeassistant/components/blueprint/importer.py @@ -136,7 +136,7 @@ def _extract_blueprint_from_community_topic( ) return ImportedBlueprint( - f'{post["username"]}/{topic["slug"]}', block_content, blueprint + f"{post['username']}/{topic['slug']}", block_content, blueprint ) diff --git a/homeassistant/components/bthome/logbook.py b/homeassistant/components/bthome/logbook.py index be5e156e99c..32e90118dea 100644 --- a/homeassistant/components/bthome/logbook.py +++ b/homeassistant/components/bthome/logbook.py @@ -26,7 +26,7 @@ def async_describe_events( """Describe bthome logbook event.""" data = event.data device = dev_reg.async_get(data["device_id"]) - name = device and device.name or f'BTHome {data["address"]}' + name = device and device.name or f"BTHome {data['address']}" if properties := data["event_properties"]: message = f"{data['event_class']} {data['event_type']}: {properties}" else: diff --git a/homeassistant/components/compensation/__init__.py b/homeassistant/components/compensation/__init__.py index fae416e7fc2..e83339d2c18 100644 --- a/homeassistant/components/compensation/__init__.py +++ b/homeassistant/components/compensation/__init__.py @@ -42,7 +42,7 @@ def datapoints_greater_than_degree(value: dict) -> dict: if len(value[CONF_DATAPOINTS]) <= value[CONF_DEGREE]: raise vol.Invalid( f"{CONF_DATAPOINTS} must have at least" - f" {value[CONF_DEGREE]+1} {CONF_DATAPOINTS}" + f" {value[CONF_DEGREE] + 1} {CONF_DATAPOINTS}" ) return value diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py index 51633d0e05d..7b055c6dd05 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -266,7 +266,7 @@ class Doods(ImageProcessingEntity): # Draw detected objects for instance in values: - box_label = f'{label} {instance["score"]:.1f}%' + box_label = f"{label} {instance['score']:.1f}%" # Already scaled, use 1 for width and height draw_box( draw, diff --git a/homeassistant/components/duke_energy/coordinator.py b/homeassistant/components/duke_energy/coordinator.py index 68b7db12d45..2b0ae46b405 100644 --- a/homeassistant/components/duke_energy/coordinator.py +++ b/homeassistant/components/duke_energy/coordinator.py @@ -85,7 +85,7 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]): ) continue - id_prefix = f"{meter["serviceType"].lower()}_{serial_number}" + id_prefix = f"{meter['serviceType'].lower()}_{serial_number}" consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption" self._statistic_ids.add(consumption_statistic_id) _LOGGER.debug( @@ -136,7 +136,7 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]): ) name_prefix = ( - f"Duke Energy " f"{meter["serviceType"].capitalize()} {serial_number}" + f"Duke Energy {meter['serviceType'].capitalize()} {serial_number}" ) consumption_metadata = StatisticMetaData( has_mean=False, diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 709926d8496..bfb2635481c 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -603,7 +603,7 @@ class Thermostat(ClimateEntity): """Return the remote sensor device name_by_user or name for the thermostat.""" return sorted( [ - f'{item["name_by_user"]} ({item["id"]})' + f"{item['name_by_user']} ({item['id']})" for item in self.remote_sensor_ids_names ] ) @@ -873,7 +873,7 @@ class Thermostat(ClimateEntity): translation_placeholders={ "options": ", ".join( [ - f'{item["name_by_user"]} ({item["id"]})' + f"{item['name_by_user']} ({item['id']})" for item in self.remote_sensor_ids_names ] ) diff --git a/homeassistant/components/ecobee/notify.py b/homeassistant/components/ecobee/notify.py index 28cfbebe506..70860003b2a 100644 --- a/homeassistant/components/ecobee/notify.py +++ b/homeassistant/components/ecobee/notify.py @@ -34,7 +34,7 @@ class EcobeeNotifyEntity(EcobeeBaseEntity, NotifyEntity): """Initialize the thermostat.""" super().__init__(data, thermostat_index) self._attr_unique_id = ( - f"{self.thermostat["identifier"]}_notify_{thermostat_index}" + f"{self.thermostat['identifier']}_notify_{thermostat_index}" ) def send_message(self, message: str, title: str | None = None) -> None: diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 1472dfa4bf1..7597a7c9c9a 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -133,7 +133,7 @@ async def _async_migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> and mac_matches_by_one(entity_mac, unique_id) ): # Old format {dhcp_mac}....., New format {discovery_mac}.... - new_unique_id = f"{unique_id}{entity_unique_id[len(unique_id):]}" + new_unique_id = f"{unique_id}{entity_unique_id[len(unique_id) :]}" else: return None _LOGGER.debug( diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 03f666ffafd..c6c3ff4b602 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -794,7 +794,7 @@ class LoggerSensor(_FroniusSensorEntity): "unit" ) self._attr_unique_id = ( - f'{logger_data["unique_identifier"]["value"]}-{description.key}' + f"{logger_data['unique_identifier']['value']}-{description.key}" ) @@ -815,7 +815,7 @@ class MeterSensor(_FroniusSensorEntity): if (meter_uid := meter_data["serial"]["value"]) == "n.a.": meter_uid = ( f"{coordinator.solar_net.solar_net_device_id}:" - f'{meter_data["model"]["value"]}' + f"{meter_data['model']['value']}" ) self._attr_device_info = DeviceInfo( @@ -849,7 +849,7 @@ class OhmpilotSensor(_FroniusSensorEntity): sw_version=device_data["software"]["value"], via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id), ) - self._attr_unique_id = f'{device_data["serial"]["value"]}-{description.key}' + self._attr_unique_id = f"{device_data['serial']['value']}-{description.key}" class PowerFlowSensor(_FroniusSensorEntity): @@ -883,7 +883,7 @@ class StorageSensor(_FroniusSensorEntity): super().__init__(coordinator, description, solar_net_id) storage_data = self._device_data() - self._attr_unique_id = f'{storage_data["serial"]["value"]}-{description.key}' + self._attr_unique_id = f"{storage_data['serial']['value']}-{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, storage_data["serial"]["value"])}, manufacturer=storage_data["manufacturer"]["value"], diff --git a/homeassistant/components/harmony/data.py b/homeassistant/components/harmony/data.py index 41c55bfc855..4dba412a17c 100644 --- a/homeassistant/components/harmony/data.py +++ b/homeassistant/components/harmony/data.py @@ -124,8 +124,7 @@ class HarmonyData(HarmonySubscriberMixin): except (ValueError, AttributeError) as err: await self._client.close() raise ConfigEntryNotReady( - f"{self.name}: Error {err} while connected HUB at:" - f" {self._address}:8088" + f"{self.name}: Error {err} while connected HUB at: {self._address}:8088" ) from err if not connected: await self._client.close() diff --git a/homeassistant/components/hisense_aehw4a1/climate.py b/homeassistant/components/hisense_aehw4a1/climate.py index 68f79439162..1dc1eaabcaa 100644 --- a/homeassistant/components/hisense_aehw4a1/climate.py +++ b/homeassistant/components/hisense_aehw4a1/climate.py @@ -195,7 +195,7 @@ class ClimateAehW4a1(ClimateEntity): fan_mode = status["wind_status"] self._attr_fan_mode = AC_TO_HA_FAN_MODES[fan_mode] - swing_mode = f'{status["up_down"]}{status["left_right"]}' + swing_mode = f"{status['up_down']}{status['left_right']}" self._attr_swing_mode = AC_TO_HA_SWING[swing_mode] if self._attr_hvac_mode in (HVACMode.COOL, HVACMode.HEAT): diff --git a/homeassistant/components/hive/entity.py b/homeassistant/components/hive/entity.py index 1209e8c8f05..f5648690201 100644 --- a/homeassistant/components/hive/entity.py +++ b/homeassistant/components/hive/entity.py @@ -21,7 +21,7 @@ class HiveEntity(Entity): self.hive = hive self.device = hive_device self._attr_name = self.device["haName"] - self._attr_unique_id = f'{self.device["hiveID"]}-{self.device["hiveType"]}' + self._attr_unique_id = f"{self.device['hiveID']}-{self.device['hiveType']}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.device["device_id"])}, model=self.device["deviceData"]["model"], diff --git a/homeassistant/components/homekit/iidmanager.py b/homeassistant/components/homekit/iidmanager.py index d6daeb49f82..a477dde9c9d 100644 --- a/homeassistant/components/homekit/iidmanager.py +++ b/homeassistant/components/homekit/iidmanager.py @@ -102,8 +102,8 @@ class AccessoryIIDStorage: char_hap_type: str | None = uuid_to_hap_type(char_uuid) if char_uuid else None # Allocation key must be a string since we are saving it to JSON allocation_key = ( - f'{service_hap_type}_{service_unique_id or ""}_' - f'{char_hap_type or ""}_{char_unique_id or ""}' + f"{service_hap_type}_{service_unique_id or ''}_" + f"{char_hap_type or ''}_{char_unique_id or ''}" ) # AID must be a string since JSON keys cannot be int aid_str = str(aid) diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index b958817bbac..f32c4f55a0f 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -48,7 +48,7 @@ class DeviceTriggerAccessory(HomeAccessory): for idx, trigger in enumerate(device_triggers): type_: str = trigger["type"] subtype: str | None = trigger.get("subtype") - unique_id = f'{type_}-{subtype or ""}' + unique_id = f"{type_}-{subtype or ''}" entity_id: str | None = None if (entity_id_or_uuid := trigger.get("entity_id")) and ( entry := ent_reg.async_get(entity_id_or_uuid) @@ -122,7 +122,7 @@ class DeviceTriggerAccessory(HomeAccessory): """ reason = "" if "trigger" in run_variables and "description" in run_variables["trigger"]: - reason = f' by {run_variables["trigger"]["description"]}' + reason = f" by {run_variables['trigger']['description']}" _LOGGER.debug("Button triggered%s - %s", reason, run_variables) idx = int(run_variables["trigger"]["idx"]) self.triggers[idx].set_value(0) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 52f22bcc9f4..211aec2c2d5 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -323,8 +323,7 @@ class HKDevice: self.hass, self.async_update_available_state, timedelta(seconds=BLE_AVAILABILITY_CHECK_INTERVAL), - name=f"HomeKit Device {self.unique_id} BLE availability " - "check poll", + name=f"HomeKit Device {self.unique_id} BLE availability check poll", ) ) # BLE devices always get an RSSI sensor as well diff --git a/homeassistant/components/ihc/entity.py b/homeassistant/components/ihc/entity.py index f73c3079867..f90b2ee943c 100644 --- a/homeassistant/components/ihc/entity.py +++ b/homeassistant/components/ihc/entity.py @@ -43,7 +43,7 @@ class IHCEntity(Entity): self.suggested_area = product.get("group") if "id" in product: product_id = product["id"] - self.device_id = f"{controller_id}_{product_id }" + self.device_id = f"{controller_id}_{product_id}" # this will name the device the same way as the IHC visual application: Product name + position self.device_name = product["name"] if self.ihc_position: diff --git a/homeassistant/components/ista_ecotrend/config_flow.py b/homeassistant/components/ista_ecotrend/config_flow.py index c11c43070df..1a3b2109d0c 100644 --- a/homeassistant/components/ista_ecotrend/config_flow.py +++ b/homeassistant/components/ista_ecotrend/config_flow.py @@ -66,7 +66,7 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN): else: if TYPE_CHECKING: assert info - title = f"{info["firstName"]} {info["lastName"]}".strip() + title = f"{info['firstName']} {info['lastName']}".strip() await self.async_set_unique_id(info["activeConsumptionUnit"]) self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/isy994/select.py b/homeassistant/components/isy994/select.py index 41e5899504d..8befcf024d1 100644 --- a/homeassistant/components/isy994/select.py +++ b/homeassistant/components/isy994/select.py @@ -45,7 +45,7 @@ from .models import IsyData def time_string(i: int) -> str: """Return a formatted ramp rate time string.""" if i >= 60: - return f"{(float(i)/60):.1f} {UnitOfTime.MINUTES}" + return f"{(float(i) / 60):.1f} {UnitOfTime.MINUTES}" return f"{i} {UnitOfTime.SECONDS}" diff --git a/homeassistant/components/keymitt_ble/config_flow.py b/homeassistant/components/keymitt_ble/config_flow.py index 217ce3cc923..821fbc410f7 100644 --- a/homeassistant/components/keymitt_ble/config_flow.py +++ b/homeassistant/components/keymitt_ble/config_flow.py @@ -34,7 +34,7 @@ def short_address(address: str) -> str: def name_from_discovery(discovery: MicroBotAdvertisement) -> str: """Get the name from a discovery.""" - return f'{discovery.data["local_name"]} {short_address(discovery.address)}' + return f"{discovery.data['local_name']} {short_address(discovery.address)}" class MicroBotConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 82bee48ba69..6585b848d8a 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -88,7 +88,7 @@ class KNXExposeSensor: self._remove_listener: Callable[[], None] | None = None self.device: ExposeSensor = ExposeSensor( xknx=self.xknx, - name=f"{self.entity_id}__{self.expose_attribute or "state"}", + name=f"{self.entity_id}__{self.expose_attribute or 'state'}", group_address=config[KNX_ADDRESS], respond_to_read=config[CONF_RESPOND_TO_READ], value_type=self.expose_type, diff --git a/homeassistant/components/kostal_plenticore/coordinator.py b/homeassistant/components/kostal_plenticore/coordinator.py index fa6aa92856b..5f4393146f0 100644 --- a/homeassistant/components/kostal_plenticore/coordinator.py +++ b/homeassistant/components/kostal_plenticore/coordinator.py @@ -101,8 +101,8 @@ class Plenticore: model=f"{prod1} {prod2}", name=settings["scb:network"][hostname_id], sw_version=( - f'IOC: {device_local["Properties:VersionIOC"]}' - f' MC: {device_local["Properties:VersionMC"]}' + f"IOC: {device_local['Properties:VersionIOC']}" + f" MC: {device_local['Properties:VersionMC']}" ), ) diff --git a/homeassistant/components/lacrosse_view/config_flow.py b/homeassistant/components/lacrosse_view/config_flow.py index ecf30f9a197..75a5c737034 100644 --- a/homeassistant/components/lacrosse_view/config_flow.py +++ b/homeassistant/components/lacrosse_view/config_flow.py @@ -40,7 +40,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> list[Loca raise InvalidAuth from error if not locations: - raise NoLocations(f'No locations found for account {data["username"]}') + raise NoLocations(f"No locations found for account {data['username']}") return locations diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 348305c775e..b999c6f3770 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -90,9 +90,9 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str: if domain_name == "cover": return cast(str, domain_data["motor"]) if domain_name == "climate": - return f'{domain_data["source"]}.{domain_data["setpoint"]}' + return f"{domain_data['source']}.{domain_data['setpoint']}" if domain_name == "scene": - return f'{domain_data["register"]}.{domain_data["scene"]}' + return f"{domain_data['register']}.{domain_data['scene']}" raise ValueError("Unknown domain") diff --git a/homeassistant/components/lektrico/config_flow.py b/homeassistant/components/lektrico/config_flow.py index 7091856f4fd..77f7b60853d 100644 --- a/homeassistant/components/lektrico/config_flow.py +++ b/homeassistant/components/lektrico/config_flow.py @@ -116,7 +116,7 @@ class LektricoFlowHandler(ConfigFlow, domain=DOMAIN): self._serial_number = str(settings["serial_number"]) self._device_type = settings["type"] self._board_revision = settings["board_revision"] - self._name = f"{settings["type"]}_{self._serial_number}" + self._name = f"{settings['type']}_{self._serial_number}" # Check if already configured # Set unique id diff --git a/homeassistant/components/lg_thinq/mqtt.py b/homeassistant/components/lg_thinq/mqtt.py index 8759869aad3..025f80f78b1 100644 --- a/homeassistant/components/lg_thinq/mqtt.py +++ b/homeassistant/components/lg_thinq/mqtt.py @@ -168,7 +168,7 @@ class ThinQMQTT: async def async_handle_device_event(self, message: dict) -> None: """Handle received mqtt message.""" unique_id = ( - f"{message["deviceId"]}_{list(message["report"].keys())[0]}" + f"{message['deviceId']}_{list(message['report'].keys())[0]}" if message["deviceType"] == DeviceType.WASHTOWER else message["deviceId"] ) diff --git a/homeassistant/components/lovelace/cast.py b/homeassistant/components/lovelace/cast.py index 82a92b94ae5..c380a296fc0 100644 --- a/homeassistant/components/lovelace/cast.py +++ b/homeassistant/components/lovelace/cast.py @@ -101,7 +101,7 @@ async def async_browse_media( BrowseMedia( title=view["title"], media_class=MediaClass.APP, - media_content_id=f'{info["url_path"]}/{view["path"]}', + media_content_id=f"{info['url_path']}/{view['path']}", media_content_type=DOMAIN, thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png", can_play=True, diff --git a/homeassistant/components/lutron_caseta/button.py b/homeassistant/components/lutron_caseta/button.py index a74de46346b..e56758b0af6 100644 --- a/homeassistant/components/lutron_caseta/button.py +++ b/homeassistant/components/lutron_caseta/button.py @@ -53,7 +53,7 @@ async def async_setup_entry( # Append the child device name to the end of the parent keypad # name to create the entity name - full_name = f'{parent_device_info.get("name")} {device_name}' + full_name = f"{parent_device_info.get('name')} {device_name}" # Set the device_info to the same as the Parent Keypad # The entities will be nested inside the keypad device entities.append( diff --git a/homeassistant/components/lutron_caseta/switch.py b/homeassistant/components/lutron_caseta/switch.py index 5037d077a02..66f23926fbf 100644 --- a/homeassistant/components/lutron_caseta/switch.py +++ b/homeassistant/components/lutron_caseta/switch.py @@ -44,7 +44,7 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, SwitchEntity): parent_keypad = keypads[device["parent_device"]] parent_device_info = parent_keypad["device_info"] # Append the child device name to the end of the parent keypad name to create the entity name - self._attr_name = f'{parent_device_info["name"]} {device["device_name"]}' + self._attr_name = f"{parent_device_info['name']} {device['device_name']}" # Set the device_info to the same as the Parent Keypad # The entities will be nested inside the keypad device self._attr_device_info = parent_device_info diff --git a/homeassistant/components/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py index 9548029209b..4c8281f21de 100644 --- a/homeassistant/components/picnic/config_flow.py +++ b/homeassistant/components/picnic/config_flow.py @@ -67,8 +67,8 @@ async def validate_input(hass: HomeAssistant, data): # Return the validation result address = ( - f'{user_data["address"]["street"]} {user_data["address"]["house_number"]}' - f'{user_data["address"]["house_number_ext"]}' + f"{user_data['address']['street']} {user_data['address']['house_number']}" + f"{user_data['address']['house_number_ext']}" ) return auth_token, { "title": address, diff --git a/homeassistant/components/progettihwsw/__init__.py b/homeassistant/components/progettihwsw/__init__.py index 1bf23befbdb..4d090f4d0c1 100644 --- a/homeassistant/components/progettihwsw/__init__.py +++ b/homeassistant/components/progettihwsw/__init__.py @@ -17,7 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up ProgettiHWSW Automation from a config entry.""" hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = ProgettiHWSWAPI( - f'{entry.data["host"]}:{entry.data["port"]}' + f"{entry.data['host']}:{entry.data['port']}" ) # Check board validation again to load new values to API. diff --git a/homeassistant/components/progettihwsw/config_flow.py b/homeassistant/components/progettihwsw/config_flow.py index 2202678da9b..2e5ea221dca 100644 --- a/homeassistant/components/progettihwsw/config_flow.py +++ b/homeassistant/components/progettihwsw/config_flow.py @@ -19,7 +19,7 @@ DATA_SCHEMA = vol.Schema( async def validate_input(hass: HomeAssistant, data): """Validate the user host input.""" - api_instance = ProgettiHWSWAPI(f'{data["host"]}:{data["port"]}') + api_instance = ProgettiHWSWAPI(f"{data['host']}:{data['port']}") is_valid = await api_instance.check_board() if not is_valid: diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py index 8b85dfa29a4..f07db509630 100644 --- a/homeassistant/components/pyload/__init__.py +++ b/homeassistant/components/pyload/__init__.py @@ -32,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo """Set up pyLoad from a config entry.""" url = ( - f"{"https" if entry.data[CONF_SSL] else "http"}://" + f"{'https' if entry.data[CONF_SSL] else 'http'}://" f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}/" ) diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index c8d08f997f9..5df11711d6f 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -83,7 +83,7 @@ async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> Non ) url = ( - f"{"https" if user_input[CONF_SSL] else "http"}://" + f"{'https' if user_input[CONF_SSL] else 'http'}://" f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}/" ) pyload = PyLoadAPI( diff --git a/homeassistant/components/recorder/system_health/__init__.py b/homeassistant/components/recorder/system_health/__init__.py index 16feaa19886..6923b792b8b 100644 --- a/homeassistant/components/recorder/system_health/__init__.py +++ b/homeassistant/components/recorder/system_health/__init__.py @@ -40,7 +40,7 @@ def _get_db_stats(instance: Recorder, database_name: str) -> dict[str, Any]: and (get_size := DIALECT_TO_GET_SIZE.get(dialect_name)) and (db_bytes := get_size(session, database_name)) ): - db_stats["estimated_db_size"] = f"{db_bytes/1024/1024:.2f} MiB" + db_stats["estimated_db_size"] = f"{db_bytes / 1024 / 1024:.2f} MiB" return db_stats diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index dd791bbaf1a..747e68e8a00 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -361,7 +361,7 @@ def migrate_entity_ids( if host.api.supported(None, "UID") and not entity.unique_id.startswith( host.unique_id ): - new_id = f"{host.unique_id}_{entity.unique_id.split("_", 1)[1]}" + new_id = f"{host.unique_id}_{entity.unique_id.split('_', 1)[1]}" entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id) if entity.device_id in ch_device_ids: diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 4f8ae9767e2..13f3c012af8 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -57,7 +57,7 @@ def _rssi_convert(value: int | None) -> str | None: """Rssi is given as dBm value.""" if value is None: return None - return f"{value*8-120}" + return f"{value * 8 - 120}" @dataclass(frozen=True) diff --git a/homeassistant/components/sense/entity.py b/homeassistant/components/sense/entity.py index 248be53ceb7..35c556a51f2 100644 --- a/homeassistant/components/sense/entity.py +++ b/homeassistant/components/sense/entity.py @@ -12,7 +12,7 @@ from .coordinator import SenseCoordinator def sense_to_mdi(sense_icon: str) -> str: """Convert sense icon to mdi icon.""" - return f"mdi:{MDI_ICONS.get(sense_icon, "power-plug")}" + return f"mdi:{MDI_ICONS.get(sense_icon, 'power-plug')}" class SenseEntity(CoordinatorEntity[SenseCoordinator]): diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 940343fc069..f8e157a6a5d 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -557,7 +557,7 @@ class RpcBluTrvClimate(ShellyRpcEntity, ClimateEntity): self._config = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"] ble_addr: str = self._config["addr"] self._attr_unique_id = f"{ble_addr}-{self.key}" - name = self._config["name"] or f"shellyblutrv-{ble_addr.replace(":", "")}" + name = self._config["name"] or f"shellyblutrv-{ble_addr.replace(':', '')}" model_id = self._config.get("local_name") self._attr_device_info = DeviceInfo( connections={(CONNECTION_BLUETOOTH, ble_addr)}, diff --git a/homeassistant/components/shelly/logbook.py b/homeassistant/components/shelly/logbook.py index fbf72e6ebe8..e18cd7ca465 100644 --- a/homeassistant/components/shelly/logbook.py +++ b/homeassistant/components/shelly/logbook.py @@ -42,7 +42,7 @@ def async_describe_events( if click_type in RPC_INPUTS_EVENTS_TYPES: rpc_coordinator = get_rpc_coordinator_by_device_id(hass, device_id) if rpc_coordinator and rpc_coordinator.device.initialized: - key = f"input:{channel-1}" + key = f"input:{channel - 1}" input_name = get_rpc_entity_name(rpc_coordinator.device, key) elif click_type in BLOCK_INPUTS_EVENTS_TYPES: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index df374624e3d..d450727ead6 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -137,7 +137,7 @@ def get_block_channel_name(device: BlockDevice, block: Block | None) -> str: else: base = ord("1") - return f"{entity_name} channel {chr(int(block.channel)+base)}" + return f"{entity_name} channel {chr(int(block.channel) + base)}" def is_block_momentary_input( @@ -200,7 +200,7 @@ def get_block_input_triggers( subtype = "button" else: assert block.channel - subtype = f"button{int(block.channel)+1}" + subtype = f"button{int(block.channel) + 1}" if device.settings["device"]["type"] in SHBTN_MODELS: trigger_types = SHBTN_INPUTS_EVENTS_TYPES @@ -409,7 +409,7 @@ def get_rpc_input_triggers(device: RpcDevice) -> list[tuple[str, str]]: continue for trigger_type in RPC_INPUTS_EVENTS_TYPES: - subtype = f"button{id_+1}" + subtype = f"button{id_ + 1}" triggers.append((trigger_type, subtype)) return triggers diff --git a/homeassistant/components/slide_local/button.py b/homeassistant/components/slide_local/button.py index 795cd4f1c2e..faca7cb3f2b 100644 --- a/homeassistant/components/slide_local/button.py +++ b/homeassistant/components/slide_local/button.py @@ -44,7 +44,7 @@ class SlideButton(SlideEntity, ButtonEntity): def __init__(self, coordinator: SlideCoordinator) -> None: """Initialize the slide button.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.data["mac"]}-calibrate" + self._attr_unique_id = f"{coordinator.data['mac']}-calibrate" async def async_press(self) -> None: """Send out a calibrate command.""" diff --git a/homeassistant/components/slide_local/switch.py b/homeassistant/components/slide_local/switch.py index f1c33f9a76f..0471dfcc4e6 100644 --- a/homeassistant/components/slide_local/switch.py +++ b/homeassistant/components/slide_local/switch.py @@ -47,7 +47,7 @@ class SlideSwitch(SlideEntity, SwitchEntity): def __init__(self, coordinator: SlideCoordinator) -> None: """Initialize the slide switch.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.data["mac"]}-touchgo" + self._attr_unique_id = f"{coordinator.data['mac']}-touchgo" @property def is_on(self) -> bool: diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py index 50f7d34e406..e64fee00f16 100644 --- a/homeassistant/components/soma/cover.py +++ b/homeassistant/components/soma/cover.py @@ -76,7 +76,7 @@ class SomaTilt(SomaEntity, CoverEntity): response = self.api.set_shade_position(self.device["mac"], 100) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while closing the cover ({self.name}): {response["msg"]}' + f"Error while closing the cover ({self.name}): {response['msg']}" ) self.set_position(0) @@ -85,7 +85,7 @@ class SomaTilt(SomaEntity, CoverEntity): response = self.api.set_shade_position(self.device["mac"], -100) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while opening the cover ({self.name}): {response["msg"]}' + f"Error while opening the cover ({self.name}): {response['msg']}" ) self.set_position(100) @@ -94,7 +94,7 @@ class SomaTilt(SomaEntity, CoverEntity): response = self.api.stop_shade(self.device["mac"]) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while stopping the cover ({self.name}): {response["msg"]}' + f"Error while stopping the cover ({self.name}): {response['msg']}" ) # Set cover position to some value where up/down are both enabled self.set_position(50) @@ -109,7 +109,7 @@ class SomaTilt(SomaEntity, CoverEntity): if not is_api_response_success(response): raise HomeAssistantError( f"Error while setting the cover position ({self.name}):" - f' {response["msg"]}' + f" {response['msg']}" ) self.set_position(kwargs[ATTR_TILT_POSITION]) @@ -152,7 +152,7 @@ class SomaShade(SomaEntity, CoverEntity): response = self.api.set_shade_position(self.device["mac"], 100) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while closing the cover ({self.name}): {response["msg"]}' + f"Error while closing the cover ({self.name}): {response['msg']}" ) def open_cover(self, **kwargs: Any) -> None: @@ -160,7 +160,7 @@ class SomaShade(SomaEntity, CoverEntity): response = self.api.set_shade_position(self.device["mac"], 0) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while opening the cover ({self.name}): {response["msg"]}' + f"Error while opening the cover ({self.name}): {response['msg']}" ) def stop_cover(self, **kwargs: Any) -> None: @@ -168,7 +168,7 @@ class SomaShade(SomaEntity, CoverEntity): response = self.api.stop_shade(self.device["mac"]) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while stopping the cover ({self.name}): {response["msg"]}' + f"Error while stopping the cover ({self.name}): {response['msg']}" ) # Set cover position to some value where up/down are both enabled self.set_position(50) @@ -182,7 +182,7 @@ class SomaShade(SomaEntity, CoverEntity): if not is_api_response_success(response): raise HomeAssistantError( f"Error while setting the cover position ({self.name}):" - f' {response["msg"]}' + f" {response['msg']}" ) async def async_update(self) -> None: diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 4184b23b9a0..b804055a740 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -166,7 +166,7 @@ class Segment: self.hls_playlist_parts.append( f"#EXT-X-PART:DURATION={part.duration:.3f},URI=" f'"./segment/{self.sequence}.{part_num}.m4s"' - f'{",INDEPENDENT=YES" if part.has_keyframe else ""}' + f"{',INDEPENDENT=YES' if part.has_keyframe else ''}" ) if self.complete: # Construct the final playlist_template. The placeholder will share a diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 0c1f38938eb..c196e57baa4 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -460,7 +460,7 @@ class TimestampValidator: if packet.dts is None: if self._missing_dts >= MAX_MISSING_DTS: # type: ignore[unreachable] raise StreamWorkerError( - f"No dts in {MAX_MISSING_DTS+1} consecutive packets" + f"No dts in {MAX_MISSING_DTS + 1} consecutive packets" ) self._missing_dts += 1 return False diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index b422e40ef2d..3acd768cb30 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -135,8 +135,8 @@ class DeviceConnectivity(SurePetcareBinarySensor): self._attr_is_on = bool(state) if state: self._attr_extra_state_attributes = { - "device_rssi": f'{state["signal"]["device_rssi"]:.2f}', - "hub_rssi": f'{state["signal"]["hub_rssi"]:.2f}', + "device_rssi": f"{state['signal']['device_rssi']:.2f}", + "hub_rssi": f"{state['signal']['hub_rssi']:.2f}", } else: self._attr_extra_state_attributes = {} diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index 758698a7d67..b1a71665222 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -114,7 +114,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if match := re.match( rf"(?:{old_unique_id})-(?P\d+)", entity_entry.unique_id ): - entity_new_unique_id = f'{new_unique_id}-{match.group("id")}' + entity_new_unique_id = f"{new_unique_id}-{match.group('id')}" _LOGGER.debug( "Migrating entity %s from %s to new id %s", entity_entry.entity_id, diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index fc2d9f491ac..31c0c42168d 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -67,7 +67,7 @@ def short_address(address: str) -> str: def name_from_discovery(discovery: SwitchBotAdvertisement) -> str: """Get the name from a discovery.""" - return f'{discovery.data["modelFriendlyName"]} {short_address(discovery.address)}' + return f"{discovery.data['modelFriendlyName']} {short_address(discovery.address)}" class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/tailwind/entity.py b/homeassistant/components/tailwind/entity.py index ec13dc7bd1f..dafb46e6f63 100644 --- a/homeassistant/components/tailwind/entity.py +++ b/homeassistant/components/tailwind/entity.py @@ -58,7 +58,7 @@ class TailwindDoorEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{coordinator.data.device_id}-{door_id}")}, via_device=(DOMAIN, coordinator.data.device_id), - name=f"Door {coordinator.data.doors[door_id].index+1}", + name=f"Door {coordinator.data.doors[door_id].index + 1}", manufacturer="Tailwind", model=coordinator.data.product, sw_version=coordinator.data.firmware_version, diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 390a4a31bdb..7b7b5eb9b29 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -93,7 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: continue if isinstance(entry.options[key], str): raise ConfigEntryError( - f"The '{entry.options.get(CONF_NAME) or ""}' number template needs to " + f"The '{entry.options.get(CONF_NAME) or ''}' number template needs to " f"be reconfigured, {key} must be a number, got '{entry.options[key]}'" ) diff --git a/homeassistant/components/twinkly/select.py b/homeassistant/components/twinkly/select.py index 2542d325b47..38e5c9a6fc7 100644 --- a/homeassistant/components/twinkly/select.py +++ b/homeassistant/components/twinkly/select.py @@ -35,7 +35,7 @@ class TwinklyModeSelect(TwinklyEntity, SelectEntity): def __init__(self, coordinator: TwinklyCoordinator) -> None: """Initialize TwinklyModeSelect.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.data.device_info["mac"]}_mode" + self._attr_unique_id = f"{coordinator.data.device_info['mac']}_mode" self.client = coordinator.client @property diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 34e11f49978..01c4212d99e 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -97,14 +97,16 @@ def async_generate_url( ) -> str: """Generate the full URL for a webhook_id.""" return ( - f"{get_url( - hass, - allow_internal=allow_internal, - allow_external=allow_external, - allow_cloud=False, - allow_ip=allow_ip, - prefer_external=prefer_external, - )}" + f"{ + get_url( + hass, + allow_internal=allow_internal, + allow_external=allow_external, + allow_cloud=False, + allow_ip=allow_ip, + prefer_external=prefer_external, + ) + }" f"{async_generate_path(webhook_id)}" ) diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index 55dd45153f7..f3fbd3e0610 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -86,7 +86,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key} if not self._name: - self._name = f"{DEFAULT_NAME} {client.system_info["modelName"]}" + self._name = f"{DEFAULT_NAME} {client.system_info['modelName']}" return self.async_create_entry(title=self._name, data=data) return self.async_show_form(step_id="pairing", errors=errors) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index e7d57aebab6..aa2e8b547c9 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -63,7 +63,7 @@ class WebSocketAdapter(logging.LoggerAdapter): def process(self, msg: str, kwargs: Any) -> tuple[str, Any]: """Add connid to websocket log messages.""" assert self.extra is not None - return f'[{self.extra["connid"]}] {msg}', kwargs + return f"[{self.extra['connid']}] {msg}", kwargs class WebSocketHandler: diff --git a/homeassistant/components/yale_smart_alarm/binary_sensor.py b/homeassistant/components/yale_smart_alarm/binary_sensor.py index 17b6035321a..fa9584505e2 100644 --- a/homeassistant/components/yale_smart_alarm/binary_sensor.py +++ b/homeassistant/components/yale_smart_alarm/binary_sensor.py @@ -86,7 +86,7 @@ class YaleDoorBatterySensor(YaleEntity, BinarySensorEntity): ) -> None: """Initiate Yale door battery Sensor.""" super().__init__(coordinator, data) - self._attr_unique_id = f"{data["address"]}-battery" + self._attr_unique_id = f"{data['address']}-battery" @property def is_on(self) -> bool: diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py index 7ece2a3448b..db63567fa92 100644 --- a/homeassistant/components/yale_smart_alarm/coordinator.py +++ b/homeassistant/components/yale_smart_alarm/coordinator.py @@ -84,7 +84,7 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): contact["address"]: contact["_state"] for contact in door_windows } _sensor_battery_map = { - f"{contact["address"]}-battery": contact["_battery"] + f"{contact['address']}-battery": contact["_battery"] for contact in door_windows } _temp_map = {temp["address"]: temp["status_temp"] for temp in temp_sensors} diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py index cb5c160e7b3..5b813453a8b 100644 --- a/homeassistant/components/zha/update.py +++ b/homeassistant/components/zha/update.py @@ -163,11 +163,7 @@ class ZHAFirmwareUpdateEntity( """ if self.entity_data.device_proxy.device.is_mains_powered: - header = ( - "" - f"{OTA_MESSAGE_RELIABILITY}" - "" - ) + header = f"{OTA_MESSAGE_RELIABILITY}" else: header = ( "" From 475a2fb8284819b606597f86920447632b1c42e6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jan 2025 11:53:31 +0100 Subject: [PATCH 0283/2987] Discover new devices at runtime in onewire (#135199) --- homeassistant/components/onewire/__init__.py | 2 + .../components/onewire/binary_sensor.py | 32 +++++++++++--- .../components/onewire/onewirehub.py | 43 +++++++++++++++++-- .../components/onewire/quality_scale.yaml | 4 +- homeassistant/components/onewire/select.py | 32 +++++++++++--- homeassistant/components/onewire/sensor.py | 40 ++++++++++++----- homeassistant/components/onewire/switch.py | 32 +++++++++++--- .../components/onewire/test_binary_sensor.py | 33 +++++++++++++- tests/components/onewire/test_init.py | 29 ++++++++++++- tests/components/onewire/test_select.py | 31 ++++++++++++- tests/components/onewire/test_sensor.py | 33 +++++++++++++- tests/components/onewire/test_switch.py | 33 +++++++++++++- 12 files changed, 299 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index 753960f0ae3..c77d87d91b9 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -37,6 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + onewire_hub.schedule_scan_for_new_devices() + entry.async_on_unload(entry.add_update_listener(options_update_listener)) return True diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index d9e21ce013d..7a8f81eec0e 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -13,11 +13,17 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL from .entity import OneWireEntity, OneWireEntityDescription -from .onewirehub import OneWireConfigEntry, OneWireHub +from .onewirehub import ( + SIGNAL_NEW_DEVICE_CONNECTED, + OneWireConfigEntry, + OneWireHub, + OWDeviceDescription, +) # the library uses non-persistent connections # and concurrent access to the bus is managed by the server @@ -98,16 +104,28 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - async_add_entities(get_entities(config_entry.runtime_data), True) + + async def _add_entities( + hub: OneWireHub, devices: list[OWDeviceDescription] + ) -> None: + """Add 1-Wire entities for all devices.""" + if not devices: + return + async_add_entities(get_entities(hub, devices), True) + + hub = config_entry.runtime_data + await _add_entities(hub, hub.devices) + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities) + ) -def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]: +def get_entities( + onewire_hub: OneWireHub, devices: list[OWDeviceDescription] +) -> list[OneWireBinarySensor]: """Get a list of entities.""" - if not onewire_hub.devices: - return [] - entities: list[OneWireBinarySensor] = [] - for device in onewire_hub.devices: + for device in devices: family = device.family device_id = device.id device_type = device.type diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index deeaaa6283d..a8d8dd06034 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime, timedelta import logging import os @@ -9,9 +10,12 @@ from pyownet import protocol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_VIA_DEVICE, CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.signal_type import SignalType from .const import ( DEVICE_SUPPORT, @@ -32,10 +36,15 @@ DEVICE_MANUFACTURER = { "EF": MANUFACTURER_HOBBYBOARDS, } +_DEVICE_SCAN_INTERVAL = timedelta(minutes=5) _LOGGER = logging.getLogger(__name__) type OneWireConfigEntry = ConfigEntry[OneWireHub] +SIGNAL_NEW_DEVICE_CONNECTED = SignalType["OneWireHub", list[OWDeviceDescription]]( + f"{DOMAIN}_new_device_connected" +) + def _is_known_device(device_family: str, device_type: str | None) -> bool: """Check if device family/type is known to the library.""" @@ -69,14 +78,42 @@ class OneWireHub: async def initialize(self) -> None: """Initialize a config entry.""" await self._hass.async_add_executor_job(self._initialize) - # Populate the device registry + self._populate_device_registry(self.devices) + + @callback + def _populate_device_registry(self, devices: list[OWDeviceDescription]) -> None: + """Populate the device registry.""" device_registry = dr.async_get(self._hass) - for device in self.devices: + for device in devices: device_registry.async_get_or_create( config_entry_id=self._config_entry.entry_id, **device.device_info, ) + def schedule_scan_for_new_devices(self) -> None: + """Schedule a regular scan of the bus for new devices.""" + self._config_entry.async_on_unload( + async_track_time_interval( + self._hass, self._scan_for_new_devices, _DEVICE_SCAN_INTERVAL + ) + ) + + async def _scan_for_new_devices(self, _: datetime) -> None: + """Scan the bus for new devices.""" + devices = await self._hass.async_add_executor_job( + _discover_devices, self.owproxy + ) + existing_device_ids = [device.id for device in self.devices] + new_devices = [ + device for device in devices if device.id not in existing_device_ids + ] + if new_devices: + self.devices.extend(new_devices) + self._populate_device_registry(new_devices) + async_dispatcher_send( + self._hass, SIGNAL_NEW_DEVICE_CONNECTED, self, new_devices + ) + def _discover_devices( owproxy: protocol._Proxy, path: str = "/", parent_id: str | None = None diff --git a/homeassistant/components/onewire/quality_scale.yaml b/homeassistant/components/onewire/quality_scale.yaml index a262f9cd714..9e706c16607 100644 --- a/homeassistant/components/onewire/quality_scale.yaml +++ b/homeassistant/components/onewire/quality_scale.yaml @@ -84,8 +84,8 @@ rules: comment: It doesn't make sense to override defaults reconfiguration-flow: done dynamic-devices: - status: todo - comment: Not yet implemented + status: done + comment: The bus is scanned for new devices at regular interval discovery-update-info: status: todo comment: Under review diff --git a/homeassistant/components/onewire/select.py b/homeassistant/components/onewire/select.py index 108cc7b2ec8..7a26ecdbb31 100644 --- a/homeassistant/components/onewire/select.py +++ b/homeassistant/components/onewire/select.py @@ -9,11 +9,17 @@ import os from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import READ_MODE_INT from .entity import OneWireEntity, OneWireEntityDescription -from .onewirehub import OneWireConfigEntry, OneWireHub +from .onewirehub import ( + SIGNAL_NEW_DEVICE_CONNECTED, + OneWireConfigEntry, + OneWireHub, + OWDeviceDescription, +) # the library uses non-persistent connections # and concurrent access to the bus is managed by the server @@ -45,17 +51,29 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - async_add_entities(get_entities(config_entry.runtime_data), True) + + async def _add_entities( + hub: OneWireHub, devices: list[OWDeviceDescription] + ) -> None: + """Add 1-Wire entities for all devices.""" + if not devices: + return + async_add_entities(get_entities(hub, devices), True) + + hub = config_entry.runtime_data + await _add_entities(hub, hub.devices) + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities) + ) -def get_entities(onewire_hub: OneWireHub) -> list[OneWireSelectEntity]: +def get_entities( + onewire_hub: OneWireHub, devices: list[OWDeviceDescription] +) -> list[OneWireSelectEntity]: """Get a list of entities.""" - if not onewire_hub.devices: - return [] - entities: list[OneWireSelectEntity] = [] - for device in onewire_hub.devices: + for device in devices: family = device.family device_id = device.id device_info = device.device_info diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 0f430e1be35..ae6a3642c58 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -26,6 +26,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -39,7 +40,12 @@ from .const import ( READ_MODE_INT, ) from .entity import OneWireEntity, OneWireEntityDescription -from .onewirehub import OneWireConfigEntry, OneWireHub +from .onewirehub import ( + SIGNAL_NEW_DEVICE_CONNECTED, + OneWireConfigEntry, + OneWireHub, + OWDeviceDescription, +) # the library uses non-persistent connections # and concurrent access to the bus is managed by the server @@ -357,23 +363,35 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - # note: we have to go through the executor as SENSOR platform - # makes extra calls to the hub during device listing - entities = await hass.async_add_executor_job( - get_entities, config_entry.runtime_data, config_entry.options + + async def _add_entities( + hub: OneWireHub, devices: list[OWDeviceDescription] + ) -> None: + """Add 1-Wire entities for all devices.""" + if not devices: + return + # note: we have to go through the executor as SENSOR platform + # makes extra calls to the hub during device listing + entities = await hass.async_add_executor_job( + get_entities, hub, devices, config_entry.options + ) + async_add_entities(entities, True) + + hub = config_entry.runtime_data + await _add_entities(hub, hub.devices) + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities) ) - async_add_entities(entities, True) def get_entities( - onewire_hub: OneWireHub, options: MappingProxyType[str, Any] + onewire_hub: OneWireHub, + devices: list[OWDeviceDescription], + options: MappingProxyType[str, Any], ) -> list[OneWireSensor]: """Get a list of entities.""" - if not onewire_hub.devices: - return [] - entities: list[OneWireSensor] = [] - for device in onewire_hub.devices: + for device in devices: family = device.family device_type = device.type device_id = device.id diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index b2cdec014f6..0df2ba0dd57 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -10,11 +10,17 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL from .entity import OneWireEntity, OneWireEntityDescription -from .onewirehub import OneWireConfigEntry, OneWireHub +from .onewirehub import ( + SIGNAL_NEW_DEVICE_CONNECTED, + OneWireConfigEntry, + OneWireHub, + OWDeviceDescription, +) # the library uses non-persistent connections # and concurrent access to the bus is managed by the server @@ -158,17 +164,29 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - async_add_entities(get_entities(config_entry.runtime_data), True) + + async def _add_entities( + hub: OneWireHub, devices: list[OWDeviceDescription] + ) -> None: + """Add 1-Wire entities for all devices.""" + if not devices: + return + async_add_entities(get_entities(hub, devices), True) + + hub = config_entry.runtime_data + await _add_entities(hub, hub.devices) + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities) + ) -def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]: +def get_entities( + onewire_hub: OneWireHub, devices: list[OWDeviceDescription] +) -> list[OneWireSwitch]: """Get a list of entities.""" - if not onewire_hub.devices: - return [] - entities: list[OneWireSwitch] = [] - for device in onewire_hub.devices: + for device in devices: family = device.family device_type = device.type device_id = device.id diff --git a/tests/components/onewire/test_binary_sensor.py b/tests/components/onewire/test_binary_sensor.py index fb50c9dc367..dd2f3874e36 100644 --- a/tests/components/onewire/test_binary_sensor.py +++ b/tests/components/onewire/test_binary_sensor.py @@ -3,9 +3,11 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -13,7 +15,7 @@ from homeassistant.helpers import entity_registry as er from . import setup_owproxy_mock_devices from .const import MOCK_OWPROXY_DEVICES -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.fixture(autouse=True) @@ -31,8 +33,35 @@ async def test_binary_sensors( entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test for 1-Wire binary sensors.""" + """Test for 1-Wire binary sensor entities.""" setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) await hass.config_entries.async_setup(config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize("device_id", ["29.111111111111"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensors_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for delayed 1-Wire binary sensor entities.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, [device_id]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 8 + ) diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index e417eea8748..0748481c40b 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -3,11 +3,13 @@ from copy import deepcopy from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory from pyownet import protocol import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.onewire.const import DOMAIN +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -17,7 +19,7 @@ from homeassistant.setup import async_setup_component from . import setup_owproxy_mock_devices from .const import MOCK_OWPROXY_DEVICES -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator @@ -102,6 +104,31 @@ async def test_registry( assert device_entry == snapshot(name=f"{device_entry.name}-entry") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_registry_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test device are correctly registered.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, ["1F.111111111111"]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) + == 2 + ) + + @patch("homeassistant.components.onewire._PLATFORMS", [Platform.SENSOR]) async def test_registry_cleanup( hass: HomeAssistant, diff --git a/tests/components/onewire/test_select.py b/tests/components/onewire/test_select.py index 0a594e2c076..6e1c3277c73 100644 --- a/tests/components/onewire/test_select.py +++ b/tests/components/onewire/test_select.py @@ -3,9 +3,11 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, @@ -17,7 +19,7 @@ from homeassistant.helpers import entity_registry as er from . import setup_owproxy_mock_devices from .const import MOCK_OWPROXY_DEVICES -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.fixture(autouse=True) @@ -42,6 +44,33 @@ async def test_selects( await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) +@pytest.mark.parametrize("device_id", ["28.111111111111"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_selects_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for delayed 1-Wire select entities.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, [device_id]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 1 + ) + + @pytest.mark.parametrize("device_id", ["28.111111111111"]) async def test_selection_option_service( hass: HomeAssistant, diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index 9b0d4ea8ca6..f1ef2dfa11b 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -5,10 +5,12 @@ from copy import deepcopy import logging from unittest.mock import MagicMock, _patch_dict, patch +from freezegun.api import FrozenDateTimeFactory from pyownet.protocol import OwnetError import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -16,7 +18,7 @@ from homeassistant.helpers import entity_registry as er from . import setup_owproxy_mock_devices from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.fixture(autouse=True) @@ -34,13 +36,40 @@ async def test_sensors( entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test for 1-Wire sensors.""" + """Test for 1-Wire sensor entities.""" setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) await hass.config_entries.async_setup(config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) +@pytest.mark.parametrize("device_id", ["12.111111111111"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for delayed 1-Wire sensor entities.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, [device_id]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 2 + ) + + @pytest.mark.parametrize("device_id", ["12.111111111111"]) async def test_tai8570_sensors( hass: HomeAssistant, diff --git a/tests/components/onewire/test_switch.py b/tests/components/onewire/test_switch.py index 6bd76d89184..ca13a69e2da 100644 --- a/tests/components/onewire/test_switch.py +++ b/tests/components/onewire/test_switch.py @@ -3,9 +3,11 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -20,7 +22,7 @@ from homeassistant.helpers import entity_registry as er from . import setup_owproxy_mock_devices from .const import MOCK_OWPROXY_DEVICES -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.fixture(autouse=True) @@ -38,13 +40,40 @@ async def test_switches( entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test for 1-Wire switches.""" + """Test for 1-Wire switch entities.""" setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) await hass.config_entries.async_setup(config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) +@pytest.mark.parametrize("device_id", ["05.111111111111"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_switches_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for delayed 1-Wire switch entities.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, [device_id]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 1 + ) + + @pytest.mark.parametrize("device_id", ["05.111111111111"]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_switch_toggle( From bce7e9ba5e6604da250ad2cd53defe58fca04a5f Mon Sep 17 00:00:00 2001 From: cdnninja Date: Fri, 10 Jan 2025 04:30:29 -0700 Subject: [PATCH 0284/2987] Simplify vesync init loading (#135052) --- homeassistant/components/vesync/__init__.py | 105 +++----------------- homeassistant/components/vesync/common.py | 39 ++------ homeassistant/components/vesync/const.py | 6 +- homeassistant/components/vesync/fan.py | 6 +- homeassistant/components/vesync/light.py | 6 +- homeassistant/components/vesync/sensor.py | 6 +- homeassistant/components/vesync/switch.py | 6 +- tests/components/vesync/test_init.py | 65 ++++++++---- 8 files changed, 78 insertions(+), 161 deletions(-) diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 8e8b7744988..c48363b046d 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -9,17 +9,14 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.dispatcher import async_dispatcher_send -from .common import async_process_devices +from .common import async_generate_device_list from .const import ( DOMAIN, SERVICE_UPDATE_DEVS, VS_COORDINATOR, + VS_DEVICES, VS_DISCOVERY, - VS_FANS, - VS_LIGHTS, VS_MANAGER, - VS_SENSORS, - VS_SWITCHES, ) from .coordinator import VeSyncDataCoordinator @@ -43,10 +40,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b _LOGGER.error("Unable to login to the VeSync server") return False - device_dict = await async_process_devices(hass, manager) - - forward_setups = hass.config_entries.async_forward_entry_setups - hass.data[DOMAIN] = {} hass.data[DOMAIN][VS_MANAGER] = manager @@ -55,83 +48,25 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b # Store coordinator at domain level since only single integration instance is permitted. hass.data[DOMAIN][VS_COORDINATOR] = coordinator - switches = hass.data[DOMAIN][VS_SWITCHES] = [] - fans = hass.data[DOMAIN][VS_FANS] = [] - lights = hass.data[DOMAIN][VS_LIGHTS] = [] - sensors = hass.data[DOMAIN][VS_SENSORS] = [] - platforms = [] + hass.data[DOMAIN][VS_DEVICES] = await async_generate_device_list(hass, manager) - if device_dict[VS_SWITCHES]: - switches.extend(device_dict[VS_SWITCHES]) - platforms.append(Platform.SWITCH) - - if device_dict[VS_FANS]: - fans.extend(device_dict[VS_FANS]) - platforms.append(Platform.FAN) - - if device_dict[VS_LIGHTS]: - lights.extend(device_dict[VS_LIGHTS]) - platforms.append(Platform.LIGHT) - - if device_dict[VS_SENSORS]: - sensors.extend(device_dict[VS_SENSORS]) - platforms.append(Platform.SENSOR) - - await hass.config_entries.async_forward_entry_setups(config_entry, platforms) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) async def async_new_device_discovery(service: ServiceCall) -> None: """Discover if new devices should be added.""" manager = hass.data[DOMAIN][VS_MANAGER] - switches = hass.data[DOMAIN][VS_SWITCHES] - fans = hass.data[DOMAIN][VS_FANS] - lights = hass.data[DOMAIN][VS_LIGHTS] - sensors = hass.data[DOMAIN][VS_SENSORS] + devices = hass.data[DOMAIN][VS_DEVICES] - dev_dict = await async_process_devices(hass, manager) - switch_devs = dev_dict.get(VS_SWITCHES, []) - fan_devs = dev_dict.get(VS_FANS, []) - light_devs = dev_dict.get(VS_LIGHTS, []) - sensor_devs = dev_dict.get(VS_SENSORS, []) + new_devices = await async_generate_device_list(hass, manager) - switch_set = set(switch_devs) - new_switches = list(switch_set.difference(switches)) - if new_switches and switches: - switches.extend(new_switches) - async_dispatcher_send(hass, VS_DISCOVERY.format(VS_SWITCHES), new_switches) + device_set = set(new_devices) + new_devices = list(device_set.difference(devices)) + if new_devices and devices: + devices.extend(new_devices) + async_dispatcher_send(hass, VS_DISCOVERY.format(VS_DEVICES), new_devices) return - if new_switches and not switches: - switches.extend(new_switches) - hass.async_create_task(forward_setups(config_entry, [Platform.SWITCH])) - - fan_set = set(fan_devs) - new_fans = list(fan_set.difference(fans)) - if new_fans and fans: - fans.extend(new_fans) - async_dispatcher_send(hass, VS_DISCOVERY.format(VS_FANS), new_fans) - return - if new_fans and not fans: - fans.extend(new_fans) - hass.async_create_task(forward_setups(config_entry, [Platform.FAN])) - - light_set = set(light_devs) - new_lights = list(light_set.difference(lights)) - if new_lights and lights: - lights.extend(new_lights) - async_dispatcher_send(hass, VS_DISCOVERY.format(VS_LIGHTS), new_lights) - return - if new_lights and not lights: - lights.extend(new_lights) - hass.async_create_task(forward_setups(config_entry, [Platform.LIGHT])) - - sensor_set = set(sensor_devs) - new_sensors = list(sensor_set.difference(sensors)) - if new_sensors and sensors: - sensors.extend(new_sensors) - async_dispatcher_send(hass, VS_DISCOVERY.format(VS_SENSORS), new_sensors) - return - if new_sensors and not sensors: - sensors.extend(new_sensors) - hass.async_create_task(forward_setups(config_entry, [Platform.SENSOR])) + if new_devices and not devices: + devices.extend(new_devices) hass.services.async_register( DOMAIN, SERVICE_UPDATE_DEVS, async_new_device_discovery @@ -142,18 +77,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - in_use_platforms = [] - if hass.data[DOMAIN][VS_SWITCHES]: - in_use_platforms.append(Platform.SWITCH) - if hass.data[DOMAIN][VS_FANS]: - in_use_platforms.append(Platform.FAN) - if hass.data[DOMAIN][VS_LIGHTS]: - in_use_platforms.append(Platform.LIGHT) - if hass.data[DOMAIN][VS_SENSORS]: - in_use_platforms.append(Platform.SENSOR) - unload_ok = await hass.config_entries.async_unload_platforms( - entry, in_use_platforms - ) + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data.pop(DOMAIN) diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index 5412b4f970c..ce4235d20f8 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -7,45 +7,20 @@ from pyvesync.vesyncbasedevice import VeSyncBaseDevice from homeassistant.core import HomeAssistant -from .const import VS_FANS, VS_LIGHTS, VS_SENSORS, VS_SWITCHES - _LOGGER = logging.getLogger(__name__) -async def async_process_devices( +async def async_generate_device_list( hass: HomeAssistant, manager: VeSync -) -> dict[str, list[VeSyncBaseDevice]]: +) -> list[VeSyncBaseDevice]: """Assign devices to proper component.""" - devices: dict[str, list[VeSyncBaseDevice]] = {} - devices[VS_SWITCHES] = [] - devices[VS_FANS] = [] - devices[VS_LIGHTS] = [] - devices[VS_SENSORS] = [] + devices: list[VeSyncBaseDevice] = [] await hass.async_add_executor_job(manager.update) - if manager.fans: - devices[VS_FANS].extend(manager.fans) - # Expose fan sensors separately - devices[VS_SENSORS].extend(manager.fans) - _LOGGER.debug("%d VeSync fans found", len(manager.fans)) - - if manager.bulbs: - devices[VS_LIGHTS].extend(manager.bulbs) - _LOGGER.debug("%d VeSync lights found", len(manager.bulbs)) - - if manager.outlets: - devices[VS_SWITCHES].extend(manager.outlets) - # Expose outlets' voltage, power & energy usage as separate sensors - devices[VS_SENSORS].extend(manager.outlets) - _LOGGER.debug("%d VeSync outlets found", len(manager.outlets)) - - if manager.switches: - for switch in manager.switches: - if not switch.is_dimmable(): - devices[VS_SWITCHES].append(switch) - else: - devices[VS_LIGHTS].append(switch) - _LOGGER.debug("%d VeSync switches found", len(manager.switches)) + devices.extend(manager.fans) + devices.extend(manager.bulbs) + devices.extend(manager.outlets) + devices.extend(manager.switches) return devices diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 2a8c5722340..6a27e7330ac 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -17,11 +17,7 @@ total would be 2880. Using 30 seconds interval gives 8640 for 3 devices which exceeds the quota of 7700. """ - -VS_SWITCHES = "switches" -VS_FANS = "fans" -VS_LIGHTS = "lights" -VS_SENSORS = "sensors" +VS_DEVICES = "devices" VS_COORDINATOR = "coordinator" VS_MANAGER = "manager" diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index c6d61feebef..9ef0940e8d0 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -24,8 +24,8 @@ from .const import ( DOMAIN, SKU_TO_BASE_DEVICE, VS_COORDINATOR, + VS_DEVICES, VS_DISCOVERY, - VS_FANS, ) from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity @@ -74,10 +74,10 @@ async def async_setup_entry( _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( - async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_FANS), discover) + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_FANS], async_add_entities, coordinator) + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) @callback diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index 84324e0af6e..f58b9180e12 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -17,7 +17,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import color as color_util -from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DISCOVERY, VS_LIGHTS +from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity @@ -41,10 +41,10 @@ async def async_setup_entry( _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( - async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_LIGHTS), discover) + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_LIGHTS], async_add_entities, coordinator) + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) @callback diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index f283e3a3c0a..59c45d435d4 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -36,8 +36,8 @@ from .const import ( DOMAIN, SKU_TO_BASE_DEVICE, VS_COORDINATOR, + VS_DEVICES, VS_DISCOVERY, - VS_SENSORS, ) from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity @@ -204,10 +204,10 @@ async def async_setup_entry( _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( - async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_SENSORS), discover) + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_SENSORS], async_add_entities, coordinator) + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) @callback diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index 0b69ca3d44a..a3c628c596d 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DISCOVERY, VS_SWITCHES +from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity @@ -33,10 +33,10 @@ async def async_setup_entry( _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( - async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_SWITCHES), discover) + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_SWITCHES], async_add_entities, coordinator) + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) @callback diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index a089a270c94..dc0541b3c21 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -5,16 +5,9 @@ from unittest.mock import Mock, patch import pytest from pyvesync import VeSync -from homeassistant.components.vesync import async_setup_entry -from homeassistant.components.vesync.const import ( - DOMAIN, - VS_FANS, - VS_LIGHTS, - VS_MANAGER, - VS_SENSORS, - VS_SWITCHES, -) -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.vesync import SERVICE_UPDATE_DEVS, async_setup_entry +from homeassistant.components.vesync.const import DOMAIN, VS_DEVICES, VS_MANAGER +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -30,7 +23,9 @@ async def test_async_setup_entry__not_login( with ( patch.object(hass.config_entries, "async_forward_entry_setups") as setups_mock, - patch("homeassistant.components.vesync.async_process_devices") as process_mock, + patch( + "homeassistant.components.vesync.async_generate_device_list" + ) as process_mock, ): assert not await async_setup_entry(hass, config_entry) await hass.async_block_till_done() @@ -52,20 +47,22 @@ async def test_async_setup_entry__no_devices( await hass.async_block_till_done() assert setups_mock.call_count == 1 assert setups_mock.call_args.args[0] == config_entry - assert setups_mock.call_args.args[1] == [] + assert setups_mock.call_args.args[1] == [ + Platform.FAN, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, + ] assert manager.login.call_count == 1 assert hass.data[DOMAIN][VS_MANAGER] == manager - assert not hass.data[DOMAIN][VS_SWITCHES] - assert not hass.data[DOMAIN][VS_FANS] - assert not hass.data[DOMAIN][VS_LIGHTS] - assert not hass.data[DOMAIN][VS_SENSORS] + assert not hass.data[DOMAIN][VS_DEVICES] async def test_async_setup_entry__loads_fans( hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan ) -> None: - """Test setup connects to vesync and loads fan platform.""" + """Test setup connects to vesync and loads fan.""" fans = [fan] manager.fans = fans manager._dev_list = { @@ -78,10 +75,34 @@ async def test_async_setup_entry__loads_fans( await hass.async_block_till_done() assert setups_mock.call_count == 1 assert setups_mock.call_args.args[0] == config_entry - assert setups_mock.call_args.args[1] == [Platform.FAN, Platform.SENSOR] + assert setups_mock.call_args.args[1] == [ + Platform.FAN, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, + ] assert manager.login.call_count == 1 assert hass.data[DOMAIN][VS_MANAGER] == manager - assert not hass.data[DOMAIN][VS_SWITCHES] - assert hass.data[DOMAIN][VS_FANS] == [fan] - assert not hass.data[DOMAIN][VS_LIGHTS] - assert hass.data[DOMAIN][VS_SENSORS] == [fan] + assert hass.data[DOMAIN][VS_DEVICES] == [fan] + + +async def test_async_new_device_discovery__loads_fans( + hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan +) -> None: + """Test setup connects to vesync and loads fan as an update call.""" + + assert await hass.config_entries.async_setup(config_entry.entry_id) + # Assert platforms loaded + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + assert not hass.data[DOMAIN][VS_DEVICES] + fans = [fan] + manager.fans = fans + manager._dev_list = { + "fans": fans, + } + await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) + + assert manager.login.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert hass.data[DOMAIN][VS_DEVICES] == [fan] From 1f0eda8e475b284e8b6b2610d9b0ad3ef16ae962 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 10 Jan 2025 14:02:03 +0200 Subject: [PATCH 0285/2987] Move LG webOS TV actions to entitiy services (#135285) --- homeassistant/components/webostv/__init__.py | 61 +------------------ .../components/webostv/media_player.py | 50 +++++++-------- .../components/webostv/quality_scale.yaml | 4 +- 3 files changed, 29 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index be0002cc588..410b3d853a1 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -4,72 +4,33 @@ from __future__ import annotations from contextlib import suppress import logging -from typing import NamedTuple from aiowebostv import WebOsClient, WebOsTvPairError -import voluptuous as vol from homeassistant.components import notify as hass_notify from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_COMMAND, - ATTR_ENTITY_ID, CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType from .const import ( - ATTR_BUTTON, ATTR_CONFIG_ENTRY_ID, - ATTR_PAYLOAD, - ATTR_SOUND_OUTPUT, DATA_CONFIG_ENTRY, DATA_HASS_CONFIG, DOMAIN, PLATFORMS, - SERVICE_BUTTON, - SERVICE_COMMAND, - SERVICE_SELECT_SOUND_OUTPUT, WEBOSTV_EXCEPTIONS, ) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -CALL_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids}) - - -class ServiceMethodDetails(NamedTuple): - """Details for SERVICE_TO_METHOD mapping.""" - - method: str - schema: vol.Schema - - -BUTTON_SCHEMA = CALL_SCHEMA.extend({vol.Required(ATTR_BUTTON): cv.string}) - -COMMAND_SCHEMA = CALL_SCHEMA.extend( - {vol.Required(ATTR_COMMAND): cv.string, vol.Optional(ATTR_PAYLOAD): dict} -) - -SOUND_OUTPUT_SCHEMA = CALL_SCHEMA.extend({vol.Required(ATTR_SOUND_OUTPUT): cv.string}) - -SERVICE_TO_METHOD = { - SERVICE_BUTTON: ServiceMethodDetails(method="async_button", schema=BUTTON_SCHEMA), - SERVICE_COMMAND: ServiceMethodDetails( - method="async_command", schema=COMMAND_SCHEMA - ), - SERVICE_SELECT_SOUND_OUTPUT: ServiceMethodDetails( - method="async_select_sound_output", - schema=SOUND_OUTPUT_SCHEMA, - ), -} _LOGGER = logging.getLogger(__name__) @@ -100,17 +61,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Update the stored key without triggering reauth update_client_key(hass, entry, client) - async def async_service_handler(service: ServiceCall) -> None: - method = SERVICE_TO_METHOD[service.service] - data = service.data.copy() - data["method"] = method.method - async_dispatcher_send(hass, DOMAIN, data) - - for service, method in SERVICE_TO_METHOD.items(): - hass.services.async_register( - DOMAIN, service, async_service_handler, schema=method.schema - ) - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = client await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -174,17 +124,10 @@ def update_client_key( async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): client = hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) await hass_notify.async_reload(hass, DOMAIN) client.clear_state_update_callbacks() await client.disconnect() - # unregister service calls, check if this is the last entry to unload - if unload_ok and not hass.data[DOMAIN][DATA_CONFIG_ENTRY]: - for service in SERVICE_TO_METHOD: - hass.services.async_remove(DOMAIN, service) - return unload_ok diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 399acb9b44d..599eb48a69d 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -12,6 +12,7 @@ import logging from typing import Any, Concatenate, cast from aiowebostv import WebOsClient, WebOsTvPairError +import voluptuous as vol from homeassistant import util from homeassistant.components.media_player import ( @@ -22,29 +23,29 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - ENTITY_MATCH_ALL, - ENTITY_MATCH_NONE, -) +from homeassistant.const import ATTR_COMMAND, ATTR_SUPPORTED_FEATURES from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.trigger import PluggableAction +from homeassistant.helpers.typing import VolDictType from . import update_client_key from .const import ( + ATTR_BUTTON, ATTR_PAYLOAD, ATTR_SOUND_OUTPUT, CONF_SOURCES, DATA_CONFIG_ENTRY, DOMAIN, LIVE_TV_APP_ID, + SERVICE_BUTTON, + SERVICE_COMMAND, + SERVICE_SELECT_SOUND_OUTPUT, WEBOSTV_EXCEPTIONS, ) from .triggers.turn_on import async_get_turn_on_trigger @@ -71,11 +72,29 @@ MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=10) +BUTTON_SCHEMA: VolDictType = {vol.Required(ATTR_BUTTON): cv.string} +COMMAND_SCHEMA: VolDictType = { + vol.Required(ATTR_COMMAND): cv.string, + vol.Optional(ATTR_PAYLOAD): dict, +} +SOUND_OUTPUT_SCHEMA: VolDictType = {vol.Required(ATTR_SOUND_OUTPUT): cv.string} + +SERVICES = ( + (SERVICE_BUTTON, BUTTON_SCHEMA, "async_button"), + (SERVICE_COMMAND, COMMAND_SCHEMA, "async_command"), + (SERVICE_SELECT_SOUND_OUTPUT, SOUND_OUTPUT_SCHEMA, "async_select_sound_output"), +) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the LG webOS Smart TV platform.""" + platform = entity_platform.async_get_current_platform() + + for service_name, schema, method in SERVICES: + platform.async_register_entity_service(service_name, schema, method) + client = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] async_add_entities([LgWebOSMediaPlayerEntity(entry, client)]) @@ -143,10 +162,6 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): ) ) - self.async_on_remove( - async_dispatcher_connect(self.hass, DOMAIN, self.async_signal_handler) - ) - await self._client.register_state_update_callback( self.async_handle_state_update ) @@ -166,19 +181,6 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): """Call disconnect on removal.""" self._client.unregister_state_update_callback(self.async_handle_state_update) - async def async_signal_handler(self, data: dict[str, Any]) -> None: - """Handle domain-specific signal by calling appropriate method.""" - if (entity_ids := data[ATTR_ENTITY_ID]) == ENTITY_MATCH_NONE: - return - - if entity_ids == ENTITY_MATCH_ALL or self.entity_id in entity_ids: - params = { - key: value - for key, value in data.items() - if key not in ["entity_id", "method"] - } - await getattr(self, data["method"])(**params) - async def async_handle_state_update(self, _client: WebOsClient) -> None: """Update state from WebOsClient.""" self._update_states() diff --git a/homeassistant/components/webostv/quality_scale.yaml b/homeassistant/components/webostv/quality_scale.yaml index a5d898b1de7..1bf521ef2e2 100644 --- a/homeassistant/components/webostv/quality_scale.yaml +++ b/homeassistant/components/webostv/quality_scale.yaml @@ -1,8 +1,6 @@ rules: # Bronze - action-setup: - status: todo - comment: move actions to entity services + action-setup: done appropriate-polling: done brands: done common-modules: From f31f6d7ed00dfa3e706de08647650928cf465c18 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 10 Jan 2025 13:19:55 +0100 Subject: [PATCH 0286/2987] Adjust HomeWizard to use updated python-homewizard-energy library (#135046) --- homeassistant/components/homewizard/button.py | 2 +- .../components/homewizard/config_flow.py | 3 +- homeassistant/components/homewizard/const.py | 13 - .../components/homewizard/coordinator.py | 49 +- .../components/homewizard/diagnostics.py | 24 +- homeassistant/components/homewizard/entity.py | 4 +- .../components/homewizard/manifest.json | 2 +- homeassistant/components/homewizard/number.py | 16 +- homeassistant/components/homewizard/sensor.py | 200 ++-- homeassistant/components/homewizard/switch.py | 32 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/homewizard/conftest.py | 44 +- .../snapshots/test_diagnostics.ambr | 931 +++++++++--------- .../homewizard/snapshots/test_sensor.ambr | 62 +- .../homewizard/snapshots/test_switch.ambr | 66 +- tests/components/homewizard/test_init.py | 8 +- tests/components/homewizard/test_number.py | 15 +- tests/components/homewizard/test_sensor.py | 7 +- tests/components/homewizard/test_switch.py | 28 +- 20 files changed, 723 insertions(+), 787 deletions(-) diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py index 7b05cb95271..b86f797ec2d 100644 --- a/homeassistant/components/homewizard/button.py +++ b/homeassistant/components/homewizard/button.py @@ -19,7 +19,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Identify button.""" - if entry.runtime_data.supports_identify(): + if entry.runtime_data.data.device.supports_identify(): async_add_entities([HomeWizardIdentifyButton(entry.runtime_data)]) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index a6e4356328e..8536672651f 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -8,7 +8,7 @@ from typing import Any, NamedTuple from homewizard_energy import HomeWizardEnergyV1 from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError -from homewizard_energy.v1.models import Device +from homewizard_energy.models import Device import voluptuous as vol from homeassistant.components import onboarding, zeroconf @@ -206,6 +206,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: try: device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS]) + except RecoverableError as ex: _LOGGER.error(ex) errors = {"base": ex.error_code} diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index 809ecc1416b..4bed4675833 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -2,12 +2,9 @@ from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import logging -from homewizard_energy.v1.models import Data, Device, State, System - from homeassistant.const import Platform DOMAIN = "homewizard" @@ -23,13 +20,3 @@ CONF_PRODUCT_TYPE = "product_type" CONF_SERIAL = "serial" UPDATE_INTERVAL = timedelta(seconds=5) - - -@dataclass -class DeviceResponseEntry: - """Dict describing a single response entry.""" - - device: Device - data: Data - state: State | None = None - system: System | None = None diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index 8f5045d3b94..b5282c145cd 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -4,10 +4,9 @@ from __future__ import annotations import logging -from homewizard_energy import HomeWizardEnergyV1 -from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError -from homewizard_energy.v1.const import SUPPORTS_IDENTIFY, SUPPORTS_STATE -from homewizard_energy.v1.models import Device +from homewizard_energy import HomeWizardEnergy, HomeWizardEnergyV1 +from homewizard_energy.errors import DisabledError, RequestError +from homewizard_energy.models import CombinedModels as DeviceResponseEntry from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS @@ -15,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, UPDATE_INTERVAL, DeviceResponseEntry +from .const import DOMAIN, UPDATE_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -23,11 +22,9 @@ _LOGGER = logging.getLogger(__name__) class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]): """Gather data for the energy device.""" - api: HomeWizardEnergyV1 + api: HomeWizardEnergy api_disabled: bool = False - _unsupported_error: bool = False - config_entry: ConfigEntry def __init__( @@ -44,26 +41,7 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] async def _async_update_data(self) -> DeviceResponseEntry: """Fetch all device and sensor data from api.""" try: - data = DeviceResponseEntry( - device=await self.api.device(), - data=await self.api.data(), - ) - - try: - if self.supports_state(data.device): - data.state = await self.api.state() - - data.system = await self.api.system() - - except UnsupportedError as ex: - # Old firmware, ignore - if not self._unsupported_error: - self._unsupported_error = True - _LOGGER.warning( - "%s is running an outdated firmware version (%s). Contact HomeWizard support to update your device", - self.config_entry.title, - ex, - ) + data = await self.api.combined() except RequestError as ex: raise UpdateFailed( @@ -89,18 +67,3 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] self.data = data return data - - def supports_state(self, device: Device | None = None) -> bool: - """Return True if the device supports state.""" - - if device is None: - device = self.data.device - - return device.product_type in SUPPORTS_STATE - - def supports_identify(self, device: Device | None = None) -> bool: - """Return True if the device supports identify.""" - if device is None: - device = self.data.device - - return device.product_type in SUPPORTS_IDENTIFY diff --git a/homeassistant/components/homewizard/diagnostics.py b/homeassistant/components/homewizard/diagnostics.py index 128e70d276a..c776cdb18f2 100644 --- a/homeassistant/components/homewizard/diagnostics.py +++ b/homeassistant/components/homewizard/diagnostics.py @@ -13,11 +13,12 @@ from . import HomeWizardConfigEntry TO_REDACT = { CONF_IP_ADDRESS, - "serial", - "wifi_ssid", - "unique_meter_id", - "unique_id", "gas_unique_id", + "id", + "serial", + "unique_id", + "unique_meter_id", + "wifi_ssid", } @@ -27,23 +28,10 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" data = entry.runtime_data.data - state: dict[str, Any] | None = None - if data.state: - state = asdict(data.state) - - system: dict[str, Any] | None = None - if data.system: - system = asdict(data.system) - return async_redact_data( { "entry": async_redact_data(entry.data, TO_REDACT), - "data": { - "device": asdict(data.device), - "data": asdict(data.data), - "state": state, - "system": system, - }, + "data": asdict(data), }, TO_REDACT, ) diff --git a/homeassistant/components/homewizard/entity.py b/homeassistant/components/homewizard/entity.py index 0aea899c044..1090f561838 100644 --- a/homeassistant/components/homewizard/entity.py +++ b/homeassistant/components/homewizard/entity.py @@ -22,9 +22,7 @@ class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]): manufacturer="HomeWizard", sw_version=coordinator.data.device.firmware_version, model_id=coordinator.data.device.product_type, - model=coordinator.data.device.product.name - if coordinator.data.device.product - else None, + model=coordinator.data.device.model_name, ) if (serial_number := coordinator.data.device.serial) is not None: diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 83937809b60..c65ba1d5357 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==v7.0.1"], + "requirements": ["python-homewizard-energy==v8.0.0"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index 1ed4c642f6b..5806295fc81 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -6,7 +6,6 @@ from homeassistant.components.number import NumberEntity from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import brightness_to_value, value_to_brightness from . import HomeWizardConfigEntry from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -22,7 +21,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up numbers for device.""" - if entry.runtime_data.supports_state(): + if entry.runtime_data.data.device.supports_state(): async_add_entities([HWEnergyNumberEntity(entry.runtime_data)]) @@ -46,22 +45,21 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): @homewizard_exception_handler async def async_set_native_value(self, value: float) -> None: """Set a new value.""" - await self.coordinator.api.state_set( - brightness=value_to_brightness((0, 100), value) - ) + await self.coordinator.api.system(status_led_brightness_pct=int(value)) await self.coordinator.async_refresh() @property def available(self) -> bool: """Return if entity is available.""" - return super().available and self.coordinator.data.state is not None + return super().available and self.coordinator.data.system is not None @property def native_value(self) -> float | None: """Return the current value.""" if ( - not self.coordinator.data.state - or (brightness := self.coordinator.data.state.brightness) is None + not self.coordinator.data.system + or (brightness := self.coordinator.data.system.status_led_brightness_pct) + is None ): return None - return round(brightness_to_value((0, 100), brightness)) + return round(brightness) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 8b822bffc50..8a9738e7ae7 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Final -from homewizard_energy.v1.models import Data, ExternalDevice +from homewizard_energy.models import ExternalDevice, Measurement from homeassistant.components.sensor import ( DEVICE_CLASS_UNITS, @@ -46,9 +46,9 @@ PARALLEL_UPDATES = 1 class HomeWizardSensorEntityDescription(SensorEntityDescription): """Class describing HomeWizard sensor entities.""" - enabled_fn: Callable[[Data], bool] = lambda data: True - has_fn: Callable[[Data], bool] - value_fn: Callable[[Data], StateType] + enabled_fn: Callable[[Measurement], bool] = lambda x: True + has_fn: Callable[[Measurement], bool] + value_fn: Callable[[Measurement], StateType] @dataclass(frozen=True, kw_only=True) @@ -69,8 +69,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( key="smr_version", translation_key="dsmr_version", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.smr_version is not None, - value_fn=lambda data: data.smr_version, + has_fn=lambda data: data.protocol_version is not None, + value_fn=lambda data: data.protocol_version, ), HomeWizardSensorEntityDescription( key="meter_model", @@ -83,8 +83,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( key="unique_meter_id", translation_key="unique_meter_id", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.unique_meter_id is not None, - value_fn=lambda data: data.unique_meter_id, + has_fn=lambda data: data.unique_id is not None, + value_fn=lambda data: data.unique_id, ), HomeWizardSensorEntityDescription( key="wifi_ssid", @@ -96,10 +96,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="active_tariff", translation_key="active_tariff", - has_fn=lambda data: data.active_tariff is not None, - value_fn=lambda data: ( - None if data.active_tariff is None else str(data.active_tariff) - ), + has_fn=lambda data: data.tariff is not None, + value_fn=lambda data: None if data.tariff is None else str(data.tariff), device_class=SensorDeviceClass.ENUM, options=["1", "2", "3", "4"], ), @@ -119,8 +117,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_import_kwh is not None, - value_fn=lambda data: data.total_energy_import_kwh, + has_fn=lambda data: data.energy_import_kwh is not None, + value_fn=lambda data: data.energy_import_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t1_kwh", @@ -131,10 +129,10 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: ( # SKT/SDM230/630 provides both total and tariff 1: duplicate. - data.total_energy_import_t1_kwh is not None - and data.total_energy_export_t2_kwh is not None + data.energy_import_t1_kwh is not None + and data.energy_export_t2_kwh is not None ), - value_fn=lambda data: data.total_energy_import_t1_kwh, + value_fn=lambda data: data.energy_import_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t2_kwh", @@ -143,8 +141,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_import_t2_kwh is not None, - value_fn=lambda data: data.total_energy_import_t2_kwh, + has_fn=lambda data: data.energy_import_t2_kwh is not None, + value_fn=lambda data: data.energy_import_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t3_kwh", @@ -153,8 +151,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_import_t3_kwh is not None, - value_fn=lambda data: data.total_energy_import_t3_kwh, + has_fn=lambda data: data.energy_import_t3_kwh is not None, + value_fn=lambda data: data.energy_import_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t4_kwh", @@ -163,8 +161,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_import_t4_kwh is not None, - value_fn=lambda data: data.total_energy_import_t4_kwh, + has_fn=lambda data: data.energy_import_t4_kwh is not None, + value_fn=lambda data: data.energy_import_t4_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_kwh", @@ -172,9 +170,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_export_kwh is not None, - enabled_fn=lambda data: data.total_energy_export_kwh != 0, - value_fn=lambda data: data.total_energy_export_kwh, + has_fn=lambda data: data.energy_export_kwh is not None, + enabled_fn=lambda data: data.energy_export_kwh != 0, + value_fn=lambda data: data.energy_export_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t1_kwh", @@ -185,11 +183,11 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: ( # SKT/SDM230/630 provides both total and tariff 1: duplicate. - data.total_energy_export_t1_kwh is not None - and data.total_energy_export_t2_kwh is not None + data.energy_export_t1_kwh is not None + and data.energy_export_t2_kwh is not None ), - enabled_fn=lambda data: data.total_energy_export_t1_kwh != 0, - value_fn=lambda data: data.total_energy_export_t1_kwh, + enabled_fn=lambda data: data.energy_export_t1_kwh != 0, + value_fn=lambda data: data.energy_export_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t2_kwh", @@ -198,9 +196,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_export_t2_kwh is not None, - enabled_fn=lambda data: data.total_energy_export_t2_kwh != 0, - value_fn=lambda data: data.total_energy_export_t2_kwh, + has_fn=lambda data: data.energy_export_t2_kwh is not None, + enabled_fn=lambda data: data.energy_export_t2_kwh != 0, + value_fn=lambda data: data.energy_export_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t3_kwh", @@ -209,9 +207,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_export_t3_kwh is not None, - enabled_fn=lambda data: data.total_energy_export_t3_kwh != 0, - value_fn=lambda data: data.total_energy_export_t3_kwh, + has_fn=lambda data: data.energy_export_t3_kwh is not None, + enabled_fn=lambda data: data.energy_export_t3_kwh != 0, + value_fn=lambda data: data.energy_export_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t4_kwh", @@ -220,9 +218,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_export_t4_kwh is not None, - enabled_fn=lambda data: data.total_energy_export_t4_kwh != 0, - value_fn=lambda data: data.total_energy_export_t4_kwh, + has_fn=lambda data: data.energy_export_t4_kwh is not None, + enabled_fn=lambda data: data.energy_export_t4_kwh != 0, + value_fn=lambda data: data.energy_export_t4_kwh, ), HomeWizardSensorEntityDescription( key="active_power_w", @@ -230,8 +228,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.active_power_w is not None, - value_fn=lambda data: data.active_power_w, + has_fn=lambda data: data.power_w is not None, + value_fn=lambda data: data.power_w, ), HomeWizardSensorEntityDescription( key="active_power_l1_w", @@ -241,8 +239,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.active_power_l1_w is not None, - value_fn=lambda data: data.active_power_l1_w, + has_fn=lambda data: data.power_l1_w is not None, + value_fn=lambda data: data.power_l1_w, ), HomeWizardSensorEntityDescription( key="active_power_l2_w", @@ -252,8 +250,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.active_power_l2_w is not None, - value_fn=lambda data: data.active_power_l2_w, + has_fn=lambda data: data.power_l2_w is not None, + value_fn=lambda data: data.power_l2_w, ), HomeWizardSensorEntityDescription( key="active_power_l3_w", @@ -263,8 +261,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.active_power_l3_w is not None, - value_fn=lambda data: data.active_power_l3_w, + has_fn=lambda data: data.power_l3_w is not None, + value_fn=lambda data: data.power_l3_w, ), HomeWizardSensorEntityDescription( key="active_voltage_v", @@ -272,8 +270,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_voltage_v is not None, - value_fn=lambda data: data.active_voltage_v, + has_fn=lambda data: data.voltage_v is not None, + value_fn=lambda data: data.voltage_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l1_v", @@ -283,8 +281,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_voltage_l1_v is not None, - value_fn=lambda data: data.active_voltage_l1_v, + has_fn=lambda data: data.voltage_l1_v is not None, + value_fn=lambda data: data.voltage_l1_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l2_v", @@ -294,8 +292,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_voltage_l2_v is not None, - value_fn=lambda data: data.active_voltage_l2_v, + has_fn=lambda data: data.voltage_l2_v is not None, + value_fn=lambda data: data.voltage_l2_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l3_v", @@ -305,8 +303,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_voltage_l3_v is not None, - value_fn=lambda data: data.active_voltage_l3_v, + has_fn=lambda data: data.voltage_l3_v is not None, + value_fn=lambda data: data.voltage_l3_v, ), HomeWizardSensorEntityDescription( key="active_current_a", @@ -314,8 +312,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_current_a is not None, - value_fn=lambda data: data.active_current_a, + has_fn=lambda data: data.current_a is not None, + value_fn=lambda data: data.current_a, ), HomeWizardSensorEntityDescription( key="active_current_l1_a", @@ -325,8 +323,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_current_l1_a is not None, - value_fn=lambda data: data.active_current_l1_a, + has_fn=lambda data: data.current_l1_a is not None, + value_fn=lambda data: data.current_l1_a, ), HomeWizardSensorEntityDescription( key="active_current_l2_a", @@ -336,8 +334,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_current_l2_a is not None, - value_fn=lambda data: data.active_current_l2_a, + has_fn=lambda data: data.current_l2_a is not None, + value_fn=lambda data: data.current_l2_a, ), HomeWizardSensorEntityDescription( key="active_current_l3_a", @@ -347,8 +345,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_current_l3_a is not None, - value_fn=lambda data: data.active_current_l3_a, + has_fn=lambda data: data.current_l3_a is not None, + value_fn=lambda data: data.current_l3_a, ), HomeWizardSensorEntityDescription( key="active_frequency_hz", @@ -356,8 +354,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_frequency_hz is not None, - value_fn=lambda data: data.active_frequency_hz, + has_fn=lambda data: data.frequency_hz is not None, + value_fn=lambda data: data.frequency_hz, ), HomeWizardSensorEntityDescription( key="active_apparent_power_va", @@ -365,8 +363,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_apparent_power_va is not None, - value_fn=lambda data: data.active_apparent_power_va, + has_fn=lambda data: data.apparent_power_va is not None, + value_fn=lambda data: data.apparent_power_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l1_va", @@ -376,8 +374,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_apparent_power_l1_va is not None, - value_fn=lambda data: data.active_apparent_power_l1_va, + has_fn=lambda data: data.apparent_power_l1_va is not None, + value_fn=lambda data: data.apparent_power_l1_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l2_va", @@ -387,8 +385,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_apparent_power_l2_va is not None, - value_fn=lambda data: data.active_apparent_power_l2_va, + has_fn=lambda data: data.apparent_power_l2_va is not None, + value_fn=lambda data: data.apparent_power_l2_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l3_va", @@ -398,8 +396,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_apparent_power_l3_va is not None, - value_fn=lambda data: data.active_apparent_power_l3_va, + has_fn=lambda data: data.apparent_power_l3_va is not None, + value_fn=lambda data: data.apparent_power_l3_va, ), HomeWizardSensorEntityDescription( key="active_reactive_power_var", @@ -407,8 +405,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_reactive_power_var is not None, - value_fn=lambda data: data.active_reactive_power_var, + has_fn=lambda data: data.reactive_power_var is not None, + value_fn=lambda data: data.reactive_power_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l1_var", @@ -418,8 +416,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_reactive_power_l1_var is not None, - value_fn=lambda data: data.active_reactive_power_l1_var, + has_fn=lambda data: data.reactive_power_l1_var is not None, + value_fn=lambda data: data.reactive_power_l1_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l2_var", @@ -429,8 +427,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_reactive_power_l2_var is not None, - value_fn=lambda data: data.active_reactive_power_l2_var, + has_fn=lambda data: data.reactive_power_l2_var is not None, + value_fn=lambda data: data.reactive_power_l2_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l3_var", @@ -440,8 +438,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_reactive_power_l3_var is not None, - value_fn=lambda data: data.active_reactive_power_l3_var, + has_fn=lambda data: data.reactive_power_l3_var is not None, + value_fn=lambda data: data.reactive_power_l3_var, ), HomeWizardSensorEntityDescription( key="active_power_factor", @@ -449,8 +447,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_power_factor is not None, - value_fn=lambda data: to_percentage(data.active_power_factor), + has_fn=lambda data: data.power_factor is not None, + value_fn=lambda data: to_percentage(data.power_factor), ), HomeWizardSensorEntityDescription( key="active_power_factor_l1", @@ -460,8 +458,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_power_factor_l1 is not None, - value_fn=lambda data: to_percentage(data.active_power_factor_l1), + has_fn=lambda data: data.power_factor_l1 is not None, + value_fn=lambda data: to_percentage(data.power_factor_l1), ), HomeWizardSensorEntityDescription( key="active_power_factor_l2", @@ -471,8 +469,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_power_factor_l2 is not None, - value_fn=lambda data: to_percentage(data.active_power_factor_l2), + has_fn=lambda data: data.power_factor_l2 is not None, + value_fn=lambda data: to_percentage(data.power_factor_l2), ), HomeWizardSensorEntityDescription( key="active_power_factor_l3", @@ -482,8 +480,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_power_factor_l3 is not None, - value_fn=lambda data: to_percentage(data.active_power_factor_l3), + has_fn=lambda data: data.power_factor_l3 is not None, + value_fn=lambda data: to_percentage(data.power_factor_l3), ), HomeWizardSensorEntityDescription( key="voltage_sag_l1_count", @@ -552,8 +550,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( translation_key="active_power_average_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, - has_fn=lambda data: data.active_power_average_w is not None, - value_fn=lambda data: data.active_power_average_w, + has_fn=lambda data: data.average_power_15m_w is not None, + value_fn=lambda data: data.average_power_15m_w, ), HomeWizardSensorEntityDescription( key="monthly_power_peak_w", @@ -624,19 +622,21 @@ async def async_setup_entry( ) -> None: """Initialize sensors.""" - data = entry.runtime_data.data.data + measurement = entry.runtime_data.data.measurement # Initialize default sensors entities: list = [ HomeWizardSensorEntity(entry.runtime_data, description) for description in SENSORS - if description.has_fn(data) + if description.has_fn(measurement) ] # Initialize external devices - if data.external_devices is not None: - for unique_id, device in data.external_devices.items(): - if description := EXTERNAL_SENSORS.get(device.meter_type): + if measurement.external_devices is not None: + for unique_id, device in measurement.external_devices.items(): + if device.type is not None and ( + description := EXTERNAL_SENSORS.get(device.type) + ): # Add external device entities.append( HomeWizardExternalSensorEntity( @@ -661,13 +661,13 @@ class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity): super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" - if not description.enabled_fn(self.coordinator.data.data): + if not description.enabled_fn(self.coordinator.data.measurement): self._attr_entity_registry_enabled_default = False @property def native_value(self) -> StateType: """Return the sensor value.""" - return self.entity_description.value_fn(self.coordinator.data.data) + return self.entity_description.value_fn(self.coordinator.data.measurement) @property def available(self) -> bool: @@ -712,8 +712,8 @@ class HomeWizardExternalSensorEntity(HomeWizardEntity, SensorEntity): def device(self) -> ExternalDevice | None: """Return ExternalDevice object.""" return ( - self.coordinator.data.data.external_devices[self._device_id] - if self.coordinator.data.data.external_devices is not None + self.coordinator.data.measurement.external_devices[self._device_id] + if self.coordinator.data.measurement.external_devices is not None else None ) diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index aa0af17f578..0878703e4d5 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -6,7 +6,8 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from homewizard_energy import HomeWizardEnergyV1 +from homewizard_energy import HomeWizardEnergy +from homewizard_energy.models import CombinedModels as DeviceResponseEntry from homeassistant.components.switch import ( SwitchDeviceClass, @@ -18,7 +19,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeWizardConfigEntry -from .const import DeviceResponseEntry from .coordinator import HWEnergyDeviceUpdateCoordinator from .entity import HomeWizardEntity from .helpers import homewizard_exception_handler @@ -31,9 +31,9 @@ class HomeWizardSwitchEntityDescription(SwitchEntityDescription): """Class describing HomeWizard switch entities.""" available_fn: Callable[[DeviceResponseEntry], bool] - create_fn: Callable[[HWEnergyDeviceUpdateCoordinator], bool] + create_fn: Callable[[DeviceResponseEntry], bool] is_on_fn: Callable[[DeviceResponseEntry], bool | None] - set_fn: Callable[[HomeWizardEnergyV1, bool], Awaitable[Any]] + set_fn: Callable[[HomeWizardEnergy, bool], Awaitable[Any]] SWITCHES = [ @@ -41,28 +41,28 @@ SWITCHES = [ key="power_on", name=None, device_class=SwitchDeviceClass.OUTLET, - create_fn=lambda coordinator: coordinator.supports_state(), - available_fn=lambda data: data.state is not None and not data.state.switch_lock, - is_on_fn=lambda data: data.state.power_on if data.state else None, - set_fn=lambda api, active: api.state_set(power_on=active), + create_fn=lambda x: x.device.supports_state(), + available_fn=lambda x: x.state is not None and not x.state.switch_lock, + is_on_fn=lambda x: x.state.power_on if x.state else None, + set_fn=lambda api, active: api.state(power_on=active), ), HomeWizardSwitchEntityDescription( key="switch_lock", translation_key="switch_lock", entity_category=EntityCategory.CONFIG, - create_fn=lambda coordinator: coordinator.supports_state(), - available_fn=lambda data: data.state is not None, - is_on_fn=lambda data: data.state.switch_lock if data.state else None, - set_fn=lambda api, active: api.state_set(switch_lock=active), + create_fn=lambda x: x.device.supports_state(), + available_fn=lambda x: x.state is not None, + is_on_fn=lambda x: x.state.switch_lock if x.state else None, + set_fn=lambda api, active: api.state(switch_lock=active), ), HomeWizardSwitchEntityDescription( key="cloud_connection", translation_key="cloud_connection", entity_category=EntityCategory.CONFIG, create_fn=lambda _: True, - available_fn=lambda data: data.system is not None, - is_on_fn=lambda data: data.system.cloud_enabled if data.system else None, - set_fn=lambda api, active: api.system_set(cloud_enabled=active), + available_fn=lambda x: x.system is not None, + is_on_fn=lambda x: x.system.cloud_enabled if x.system else None, + set_fn=lambda api, active: api.system(cloud_enabled=active), ), ] @@ -76,7 +76,7 @@ async def async_setup_entry( async_add_entities( HomeWizardSwitchEntity(entry.runtime_data, description) for description in SWITCHES - if description.create_fn(entry.runtime_data) + if description.create_fn(entry.runtime_data.data) ) diff --git a/requirements_all.txt b/requirements_all.txt index d1dbb51a92a..d38e1288366 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2381,7 +2381,7 @@ python-gitlab==1.6.0 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v7.0.1 +python-homewizard-energy==v8.0.0 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 50e2675131e..dfa438f1860 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1923,7 +1923,7 @@ python-fullykiosk==0.0.14 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v7.0.1 +python-homewizard-energy==v8.0.0 # homeassistant.components.izone python-izone==1.2.9 diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index dfd92577a04..2e7728c6afb 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -3,8 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from homewizard_energy.errors import NotFoundError -from homewizard_energy.v1.models import Data, Device, State, System +from homewizard_energy.models import CombinedModels, Device, Measurement, State, System import pytest from homeassistant.components.homewizard.const import DOMAIN @@ -37,26 +36,31 @@ def mock_homewizardenergy( ): client = homewizard.return_value - client.device.return_value = Device.from_dict( - load_json_object_fixture(f"{device_fixture}/device.json", DOMAIN) - ) - client.data.return_value = Data.from_dict( - load_json_object_fixture(f"{device_fixture}/data.json", DOMAIN) + client.combined.return_value = CombinedModels( + device=Device.from_dict( + load_json_object_fixture(f"{device_fixture}/device.json", DOMAIN) + ), + measurement=Measurement.from_dict( + load_json_object_fixture(f"{device_fixture}/data.json", DOMAIN) + ), + state=( + State.from_dict( + load_json_object_fixture(f"{device_fixture}/state.json", DOMAIN) + ) + if get_fixture_path(f"{device_fixture}/state.json", DOMAIN).exists() + else None + ), + system=( + System.from_dict( + load_json_object_fixture(f"{device_fixture}/system.json", DOMAIN) + ) + if get_fixture_path(f"{device_fixture}/system.json", DOMAIN).exists() + else None + ), ) - if get_fixture_path(f"{device_fixture}/state.json", DOMAIN).exists(): - client.state.return_value = State.from_dict( - load_json_object_fixture(f"{device_fixture}/state.json", DOMAIN) - ) - else: - client.state.side_effect = NotFoundError - - if get_fixture_path(f"{device_fixture}/system.json", DOMAIN).exists(): - client.system.return_value = System.from_dict( - load_json_object_fixture(f"{device_fixture}/system.json", DOMAIN) - ) - else: - client.system.side_effect = NotFoundError + # device() call is used during configuration flow + client.device.return_value = client.combined.return_value.device yield client diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index cb5e7ef1f43..b8cf98d9211 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -2,82 +2,83 @@ # name: test_diagnostics[HWE-KWH1] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': 74.052, - 'active_current_a': 0.273, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': 50, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi kWh Meter 1-phase', + 'product_name': 'kWh meter', + 'product_type': 'HWE-KWH1', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': 0.611, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': -1058.296, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': -1058.296, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': -58.612, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': 228.472, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': 74.052, + 'average_power_15m_w': None, + 'current_a': 0.273, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': 255.551, + 'energy_export_t1_kwh': 255.551, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 2.705, + 'energy_import_t1_kwh': 2.705, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 50.0, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 255.551, - 'total_energy_export_t1_kwh': 255.551, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 2.705, - 'total_energy_import_t1_kwh': 2.705, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': 0.611, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': -1058.296, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': -1058.296, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': -58.612, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': 228.472, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 92, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.06', - 'product': dict({ - 'description': 'Measure solar panels, car chargers and more.', - 'model': 'HWE-KWH1', - 'name': 'Wi-Fi kWh Meter 1-phase', - 'url': 'https://www.homewizard.com/kwh-meter/', - }), - 'product_name': 'kWh meter', - 'product_type': 'HWE-KWH1', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -91,82 +92,83 @@ # name: test_diagnostics[HWE-KWH3] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': 0, - 'active_apparent_power_l2_va': 3548.879, - 'active_apparent_power_l3_va': 3563.414, - 'active_apparent_power_va': 7112.293, - 'active_current_a': 30.999, - 'active_current_l1_a': 0, - 'active_current_l2_a': 15.521, - 'active_current_l3_a': 15.477, - 'active_frequency_hz': 49.926, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi kWh Meter 3-phase', + 'product_name': 'KWh meter 3-phase', + 'product_type': 'HWE-KWH3', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': None, - 'active_power_factor_l1': 1, - 'active_power_factor_l2': 0.999, - 'active_power_factor_l3': 0.997, - 'active_power_l1_w': -1058.296, - 'active_power_l2_w': 158.102, - 'active_power_l3_w': 0.0, - 'active_power_w': -900.194, - 'active_reactive_power_l1_var': 0, - 'active_reactive_power_l2_var': -166.675, - 'active_reactive_power_l3_var': -262.35, - 'active_reactive_power_var': -429.025, - 'active_tariff': None, - 'active_voltage_l1_v': 230.751, - 'active_voltage_l2_v': 228.391, - 'active_voltage_l3_v': 229.612, - 'active_voltage_v': None, 'any_power_fail_count': None, + 'apparent_power_l1_va': 0.0, + 'apparent_power_l2_va': 3548.879, + 'apparent_power_l3_va': 3563.414, + 'apparent_power_va': 7112.293, + 'average_power_15m_w': None, + 'current_a': 30.999, + 'current_l1_a': 0.0, + 'current_l2_a': 15.521, + 'current_l3_a': 15.477, + 'cycles': None, + 'energy_export_kwh': 0.523, + 'energy_export_t1_kwh': 0.523, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 0.101, + 'energy_import_t1_kwh': 0.101, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 49.926, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 0.523, - 'total_energy_export_t1_kwh': 0.523, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 0.101, - 'total_energy_import_t1_kwh': 0.101, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': None, + 'power_factor_l1': 1.0, + 'power_factor_l2': 0.999, + 'power_factor_l3': 0.997, + 'power_l1_w': -1058.296, + 'power_l2_w': 158.102, + 'power_l3_w': 0.0, + 'power_w': -900.194, + 'protocol_version': None, + 'reactive_power_l1_var': 0.0, + 'reactive_power_l2_var': -166.675, + 'reactive_power_l3_var': -262.35, + 'reactive_power_var': -429.025, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': 230.751, + 'voltage_l2_v': 228.391, + 'voltage_l3_v': 229.612, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 92, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.06', - 'product': dict({ - 'description': 'Measure solar panels, car chargers and more.', - 'model': 'HWE-KWH3', - 'name': 'Wi-Fi kWh Meter 3-phase', - 'url': 'https://www.homewizard.com/kwh-meter/', - }), - 'product_name': 'KWh meter 3-phase', - 'product_type': 'HWE-KWH3', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -180,133 +182,119 @@ # name: test_diagnostics[HWE-P1] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': None, - 'active_current_a': None, - 'active_current_l1_a': -4, - 'active_current_l2_a': 2, - 'active_current_l3_a': 0, - 'active_frequency_hz': 50, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '4.19', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi P1 Meter', + 'product_name': 'P1 meter', + 'product_type': 'HWE-P1', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': 12.345, - 'active_power_average_w': 123.0, - 'active_power_factor': None, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': -123, - 'active_power_l2_w': 456, - 'active_power_l3_w': 123.456, - 'active_power_w': -123, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': None, - 'active_tariff': 2, - 'active_voltage_l1_v': 230.111, - 'active_voltage_l2_v': 230.222, - 'active_voltage_l3_v': 230.333, - 'active_voltage_v': None, 'any_power_fail_count': 4, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': None, + 'average_power_15m_w': 123.0, + 'current_a': None, + 'current_l1_a': -4.0, + 'current_l2_a': 2.0, + 'current_l3_a': 0.0, + 'cycles': None, + 'energy_export_kwh': 13086.777, + 'energy_export_t1_kwh': 4321.333, + 'energy_export_t2_kwh': 8765.444, + 'energy_export_t3_kwh': 8765.444, + 'energy_export_t4_kwh': 8765.444, + 'energy_import_kwh': 13779.338, + 'energy_import_t1_kwh': 10830.511, + 'energy_import_t2_kwh': 2948.827, + 'energy_import_t3_kwh': 2948.827, + 'energy_import_t4_kwh': 2948.827, 'external_devices': dict({ 'gas_meter_G001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'gas_meter', 'unique_id': '**REDACTED**', 'unit': 'm3', 'value': 111.111, }), 'heat_meter_H001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'heat_meter', 'unique_id': '**REDACTED**', 'unit': 'GJ', 'value': 444.444, }), 'inlet_heat_meter_IH001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'inlet_heat_meter', 'unique_id': '**REDACTED**', 'unit': 'm3', 'value': 555.555, }), 'warm_water_meter_WW001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'warm_water_meter', 'unique_id': '**REDACTED**', 'unit': 'm3', 'value': 333.333, }), 'water_meter_W001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'water_meter', 'unique_id': '**REDACTED**', 'unit': 'm3', 'value': 222.222, }), }), - 'gas_timestamp': '2021-03-14T11:22:33', - 'gas_unique_id': '**REDACTED**', + 'frequency_hz': 50.0, 'long_power_fail_count': 5, 'meter_model': 'ISKRA 2M550T-101', 'monthly_power_peak_timestamp': '2023-01-01T08:00:10', 'monthly_power_peak_w': 1111.0, - 'smr_version': 50, - 'total_energy_export_kwh': 13086.777, - 'total_energy_export_t1_kwh': 4321.333, - 'total_energy_export_t2_kwh': 8765.444, - 'total_energy_export_t3_kwh': 8765.444, - 'total_energy_export_t4_kwh': 8765.444, - 'total_energy_import_kwh': 13779.338, - 'total_energy_import_t1_kwh': 10830.511, - 'total_energy_import_t2_kwh': 2948.827, - 'total_energy_import_t3_kwh': 2948.827, - 'total_energy_import_t4_kwh': 2948.827, - 'total_gas_m3': 1122.333, + 'power_factor': None, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': -123.0, + 'power_l2_w': 456.0, + 'power_l3_w': 123.456, + 'power_w': -123.0, + 'protocol_version': 50, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': None, + 'state_of_charge_pct': None, + 'tariff': 2, + 'timestamp': None, 'total_liter_m3': 1234.567, - 'unique_meter_id': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'voltage_l1_v': 230.111, + 'voltage_l2_v': 230.222, + 'voltage_l3_v': 230.333, 'voltage_sag_l1_count': 1, 'voltage_sag_l2_count': 2, 'voltage_sag_l3_count': 3, 'voltage_swell_l1_count': 4, 'voltage_swell_l2_count': 5, 'voltage_swell_l3_count': 6, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 100, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '4.19', - 'product': dict({ - 'description': 'The HomeWizard P1 Meter gives you detailed insight in your electricity-, gas consumption and solar surplus.', - 'model': 'HWE-P1', - 'name': 'Wi-Fi P1 Meter', - 'url': 'https://www.homewizard.com/p1-meter/', - }), - 'product_name': 'P1 meter', - 'product_type': 'HWE-P1', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -320,86 +308,87 @@ # name: test_diagnostics[HWE-SKT-11] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': None, - 'active_current_a': None, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': None, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.03', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi Energy Socket', + 'product_name': 'Energy Socket', + 'product_type': 'HWE-SKT', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': None, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': 1457.277, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': 1457.277, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': None, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': None, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': None, + 'average_power_15m_w': None, + 'current_a': None, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': 0.0, + 'energy_export_t1_kwh': 0.0, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 63.651, + 'energy_import_t1_kwh': 63.651, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': None, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 0, - 'total_energy_export_t1_kwh': 0, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 63.651, - 'total_energy_import_t1_kwh': 63.651, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': None, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': 1457.277, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': 1457.277, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': None, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 94, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.03', - 'product': dict({ - 'description': 'Measure and switch every device.', - 'model': 'HWE-SKT', - 'name': 'Wi-Fi Energy Socket', - 'url': 'https://www.homewizard.com/energy-socket/', - }), - 'product_name': 'Energy Socket', - 'product_type': 'HWE-SKT', - 'serial': '**REDACTED**', - }), 'state': dict({ 'brightness': 255, 'power_on': True, 'switch_lock': False, }), 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': 100.0, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -413,86 +402,87 @@ # name: test_diagnostics[HWE-SKT-21] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': 666.768, - 'active_current_a': 2.346, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': 50.005, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '4.07', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi Energy Socket', + 'product_name': 'Energy Socket', + 'product_type': 'HWE-SKT', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': 0.81688, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': 543.312, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': 543.312, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': 123.456, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': 231.539, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': 666.768, + 'average_power_15m_w': None, + 'current_a': 2.346, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': 85.951, + 'energy_export_t1_kwh': 85.951, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 30.511, + 'energy_import_t1_kwh': 30.511, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 50.005, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 85.951, - 'total_energy_export_t1_kwh': 85.951, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 30.511, - 'total_energy_import_t1_kwh': 30.511, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': 0.81688, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': 543.312, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': 543.312, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': 123.456, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': 231.539, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 100, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '4.07', - 'product': dict({ - 'description': 'Measure and switch every device.', - 'model': 'HWE-SKT', - 'name': 'Wi-Fi Energy Socket', - 'url': 'https://www.homewizard.com/energy-socket/', - }), - 'product_name': 'Energy Socket', - 'product_type': 'HWE-SKT', - 'serial': '**REDACTED**', - }), 'state': dict({ 'brightness': 255, 'power_on': True, 'switch_lock': False, }), 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': 100.0, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -506,82 +496,83 @@ # name: test_diagnostics[HWE-WTR] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': None, - 'active_current_a': None, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': None, - 'active_liter_lpm': 0, - 'active_power_average_w': None, - 'active_power_factor': None, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': None, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': None, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': None, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': None, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '2.03', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi Watermeter', + 'product_name': 'Watermeter', + 'product_type': 'HWE-WTR', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ + 'active_liter_lpm': 0.0, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': None, + 'average_power_15m_w': None, + 'current_a': None, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': None, + 'energy_export_t1_kwh': None, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': None, + 'energy_import_t1_kwh': None, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': None, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': None, - 'total_energy_export_t1_kwh': None, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': None, - 'total_energy_import_t1_kwh': None, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': None, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': None, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': None, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': None, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': 17.014, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 84, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '2.03', - 'product': dict({ - 'description': 'Real-time water consumption insights', - 'model': 'HWE-WTR', - 'name': 'Wi-Fi Watermeter', - 'url': 'https://www.homewizard.com/watermeter/', - }), - 'product_name': 'Watermeter', - 'product_type': 'HWE-WTR', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -595,82 +586,83 @@ # name: test_diagnostics[SDM230] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': 74.052, - 'active_current_a': 0.273, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': 50, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi kWh Meter 1-phase', + 'product_name': 'kWh meter', + 'product_type': 'SDM230-wifi', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': 0.611, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': -1058.296, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': -1058.296, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': -58.612, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': 228.472, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': 74.052, + 'average_power_15m_w': None, + 'current_a': 0.273, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': 255.551, + 'energy_export_t1_kwh': 255.551, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 2.705, + 'energy_import_t1_kwh': 2.705, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 50.0, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 255.551, - 'total_energy_export_t1_kwh': 255.551, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 2.705, - 'total_energy_import_t1_kwh': 2.705, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': 0.611, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': -1058.296, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': -1058.296, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': -58.612, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': 228.472, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 92, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.06', - 'product': dict({ - 'description': 'Measure solar panels, car chargers and more.', - 'model': 'SDM230-wifi', - 'name': 'Wi-Fi kWh Meter 1-phase', - 'url': 'https://www.homewizard.com/kwh-meter/', - }), - 'product_name': 'kWh meter', - 'product_type': 'SDM230-wifi', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -684,82 +676,83 @@ # name: test_diagnostics[SDM630] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': 0, - 'active_apparent_power_l2_va': 3548.879, - 'active_apparent_power_l3_va': 3563.414, - 'active_apparent_power_va': 7112.293, - 'active_current_a': 30.999, - 'active_current_l1_a': 0, - 'active_current_l2_a': 15.521, - 'active_current_l3_a': 15.477, - 'active_frequency_hz': 49.926, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi kWh Meter 3-phase', + 'product_name': 'KWh meter 3-phase', + 'product_type': 'SDM630-wifi', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': None, - 'active_power_factor_l1': 1, - 'active_power_factor_l2': 0.999, - 'active_power_factor_l3': 0.997, - 'active_power_l1_w': -1058.296, - 'active_power_l2_w': 158.102, - 'active_power_l3_w': 0.0, - 'active_power_w': -900.194, - 'active_reactive_power_l1_var': 0, - 'active_reactive_power_l2_var': -166.675, - 'active_reactive_power_l3_var': -262.35, - 'active_reactive_power_var': -429.025, - 'active_tariff': None, - 'active_voltage_l1_v': 230.751, - 'active_voltage_l2_v': 228.391, - 'active_voltage_l3_v': 229.612, - 'active_voltage_v': None, 'any_power_fail_count': None, + 'apparent_power_l1_va': 0.0, + 'apparent_power_l2_va': 3548.879, + 'apparent_power_l3_va': 3563.414, + 'apparent_power_va': 7112.293, + 'average_power_15m_w': None, + 'current_a': 30.999, + 'current_l1_a': 0.0, + 'current_l2_a': 15.521, + 'current_l3_a': 15.477, + 'cycles': None, + 'energy_export_kwh': 0.523, + 'energy_export_t1_kwh': 0.523, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 0.101, + 'energy_import_t1_kwh': 0.101, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 49.926, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 0.523, - 'total_energy_export_t1_kwh': 0.523, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 0.101, - 'total_energy_import_t1_kwh': 0.101, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': None, + 'power_factor_l1': 1.0, + 'power_factor_l2': 0.999, + 'power_factor_l3': 0.997, + 'power_l1_w': -1058.296, + 'power_l2_w': 158.102, + 'power_l3_w': 0.0, + 'power_w': -900.194, + 'protocol_version': None, + 'reactive_power_l1_var': 0.0, + 'reactive_power_l2_var': -166.675, + 'reactive_power_l3_var': -262.35, + 'reactive_power_var': -429.025, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': 230.751, + 'voltage_l2_v': 228.391, + 'voltage_l3_v': 229.612, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 92, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.06', - 'product': dict({ - 'description': 'Measure solar panels, car chargers and more.', - 'model': 'SDM630-wifi', - 'name': 'Wi-Fi kWh Meter 3-phase', - 'url': 'https://www.homewizard.com/kwh-meter/', - }), - 'product_name': 'KWh meter 3-phase', - 'product_type': 'SDM630-wifi', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index c5de96cbf8f..31a949ca7bd 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -431,7 +431,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': '50.0', }) # --- # name: test_sensors[HWE-KWH1-entity_ids7][sensor.device_power:device-registry] @@ -1124,7 +1124,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-KWH3-entity_ids8][sensor.device_apparent_power_phase_2:device-registry] @@ -1472,7 +1472,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-KWH3-entity_ids8][sensor.device_current_phase_2:device-registry] @@ -2084,7 +2084,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_sensors[HWE-KWH3-entity_ids8][sensor.device_power_factor_phase_2:device-registry] @@ -2702,7 +2702,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-KWH3-entity_ids8][sensor.device_reactive_power_phase_2:device-registry] @@ -3476,7 +3476,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-4', + 'state': '-4.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_2:device-registry] @@ -3563,7 +3563,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2', + 'state': '2.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_3:device-registry] @@ -3650,7 +3650,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_dsmr_version:device-registry] @@ -4689,7 +4689,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': '50.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:device-registry] @@ -4945,7 +4945,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-123', + 'state': '-123.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_failures_detected:device-registry] @@ -5117,7 +5117,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-123', + 'state': '-123.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_2:device-registry] @@ -5207,7 +5207,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '456', + 'state': '456.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_3:device-registry] @@ -7236,7 +7236,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-4', + 'state': '-4.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_current_phase_2:device-registry] @@ -7323,7 +7323,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2', + 'state': '2.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_current_phase_3:device-registry] @@ -7410,7 +7410,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_dsmr_version:device-registry] @@ -8449,7 +8449,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': '50.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_long_power_failures_detected:device-registry] @@ -8705,7 +8705,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-123', + 'state': '-123.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_failures_detected:device-registry] @@ -8877,7 +8877,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-123', + 'state': '-123.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_phase_2:device-registry] @@ -8967,7 +8967,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '456', + 'state': '456.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_phase_3:device-registry] @@ -10909,7 +10909,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_1:device-registry] @@ -10996,7 +10996,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_2:device-registry] @@ -11083,7 +11083,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_3:device-registry] @@ -11170,7 +11170,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export:device-registry] @@ -12127,7 +12127,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_long_power_failures_detected:device-registry] @@ -13664,7 +13664,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-SKT-11-entity_ids2][sensor.device_energy_import:device-registry] @@ -15316,7 +15316,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-WTR-entity_ids4][sensor.device_wi_fi_ssid:device-registry] @@ -15919,7 +15919,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': '50.0', }) # --- # name: test_sensors[SDM230-entity_ids5][sensor.device_power:device-registry] @@ -16612,7 +16612,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[SDM630-entity_ids6][sensor.device_apparent_power_phase_2:device-registry] @@ -16960,7 +16960,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[SDM630-entity_ids6][sensor.device_current_phase_2:device-registry] @@ -17572,7 +17572,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_sensors[SDM630-entity_ids6][sensor.device_power_factor_phase_2:device-registry] @@ -18190,7 +18190,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[SDM630-entity_ids6][sensor.device_reactive_power_phase_2:device-registry] diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index c2ef87970f3..8f6af16068d 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -12,7 +12,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -45,7 +45,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -81,7 +81,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -94,7 +94,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -127,7 +127,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -163,7 +163,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device-state_set-power_on] +# name: test_switch_entities[HWE-SKT-11-switch.device-state-power_on] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -177,7 +177,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device-state_set-power_on].1 +# name: test_switch_entities[HWE-SKT-11-switch.device-state-power_on].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -210,7 +210,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device-state_set-power_on].2 +# name: test_switch_entities[HWE-SKT-11-switch.device-state-power_on].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -246,7 +246,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -259,7 +259,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -292,7 +292,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -328,7 +328,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state_set-switch_lock] +# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state-switch_lock] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Switch lock', @@ -341,7 +341,7 @@ 'state': 'off', }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state_set-switch_lock].1 +# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state-switch_lock].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -374,7 +374,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state_set-switch_lock].2 +# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state-switch_lock].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -410,7 +410,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device-state_set-power_on] +# name: test_switch_entities[HWE-SKT-21-switch.device-state-power_on] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -424,7 +424,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device-state_set-power_on].1 +# name: test_switch_entities[HWE-SKT-21-switch.device-state-power_on].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -457,7 +457,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device-state_set-power_on].2 +# name: test_switch_entities[HWE-SKT-21-switch.device-state-power_on].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -493,7 +493,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -506,7 +506,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -539,7 +539,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -575,7 +575,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state_set-switch_lock] +# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state-switch_lock] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Switch lock', @@ -588,7 +588,7 @@ 'state': 'off', }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state_set-switch_lock].1 +# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state-switch_lock].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -621,7 +621,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state_set-switch_lock].2 +# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state-switch_lock].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -657,7 +657,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -670,7 +670,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -703,7 +703,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -739,7 +739,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[SDM230-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[SDM230-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -752,7 +752,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[SDM230-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[SDM230-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -785,7 +785,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[SDM230-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[SDM230-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -821,7 +821,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[SDM630-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[SDM630-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -834,7 +834,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[SDM630-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[SDM630-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -867,7 +867,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[SDM630-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[SDM630-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index a01f075ee61..ed4bad8b2e8 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -25,7 +25,7 @@ async def test_load_unload( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED - assert len(mock_homewizardenergy.device.mock_calls) == 1 + assert len(mock_homewizardenergy.combined.mock_calls) == 1 await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -39,7 +39,7 @@ async def test_load_failed_host_unavailable( mock_homewizardenergy: MagicMock, ) -> None: """Test setup handles unreachable host.""" - mock_homewizardenergy.device.side_effect = TimeoutError() + mock_homewizardenergy.combined.side_effect = TimeoutError() mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -53,7 +53,7 @@ async def test_load_detect_api_disabled( mock_homewizardenergy: MagicMock, ) -> None: """Test setup detects disabled API.""" - mock_homewizardenergy.device.side_effect = DisabledError() + mock_homewizardenergy.combined.side_effect = DisabledError() mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -115,7 +115,7 @@ async def test_disablederror_reloads_integration( assert len(flows) == 0 # Simulate DisabledError and wait for next update - mock_homewizardenergy.device.side_effect = DisabledError() + mock_homewizardenergy.combined.side_effect = DisabledError() freezer.tick(timedelta(seconds=5)) async_fire_time_changed(hass) diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index 623ba018dee..b668043608c 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock from homewizard_energy.errors import DisabledError, RequestError +from homewizard_energy.models import CombinedModels, Measurement, State, System import pytest from syrupy.assertion import SnapshotAssertion @@ -44,7 +45,9 @@ async def test_number_entities( # Test unknown handling assert state.state == "100" - mock_homewizardenergy.state.return_value.brightness = None + mock_homewizardenergy.combined.return_value = CombinedModels( + device=None, measurement=Measurement(), system=System(), state=State() + ) async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() @@ -53,7 +56,7 @@ async def test_number_entities( assert state.state == STATE_UNKNOWN # Test service methods - assert len(mock_homewizardenergy.state_set.mock_calls) == 0 + assert len(mock_homewizardenergy.state.mock_calls) == 0 await hass.services.async_call( number.DOMAIN, SERVICE_SET_VALUE, @@ -64,10 +67,10 @@ async def test_number_entities( blocking=True, ) - assert len(mock_homewizardenergy.state_set.mock_calls) == 1 - mock_homewizardenergy.state_set.assert_called_with(brightness=129) + assert len(mock_homewizardenergy.system.mock_calls) == 1 + mock_homewizardenergy.system.assert_called_with(status_led_brightness_pct=50) - mock_homewizardenergy.state_set.side_effect = RequestError + mock_homewizardenergy.system.side_effect = RequestError with pytest.raises( HomeAssistantError, match=r"^An error occurred while communicating with HomeWizard device$", @@ -82,7 +85,7 @@ async def test_number_entities( blocking=True, ) - mock_homewizardenergy.state_set.side_effect = DisabledError + mock_homewizardenergy.system.side_effect = DisabledError with pytest.raises( HomeAssistantError, match=r"^The local API is disabled$", diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 60077c2cdf9..128a3de2ebf 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -3,7 +3,6 @@ from unittest.mock import MagicMock from homewizard_energy.errors import RequestError -from homewizard_energy.v1.models import Data import pytest from syrupy.assertion import SnapshotAssertion @@ -456,7 +455,7 @@ async def test_sensors_unreachable( assert (state := hass.states.get("sensor.device_energy_import_tariff_1")) assert state.state == "10830.511" - mock_homewizardenergy.data.side_effect = exception + mock_homewizardenergy.combined.side_effect = exception async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() @@ -464,15 +463,17 @@ async def test_sensors_unreachable( assert state.state == STATE_UNAVAILABLE +@pytest.mark.parametrize("exception", [RequestError]) async def test_external_sensors_unreachable( hass: HomeAssistant, mock_homewizardenergy: MagicMock, + exception: Exception, ) -> None: """Test external device sensor handles API unreachable.""" assert (state := hass.states.get("sensor.gas_meter_gas")) assert state.state == "111.111" - mock_homewizardenergy.data.return_value = Data.from_dict({}) + mock_homewizardenergy.combined.side_effect = exception async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index d9f1ac26b4f..ccf99ee27fa 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -86,17 +86,17 @@ async def test_entities_not_created_for_device( @pytest.mark.parametrize( ("device_fixture", "entity_id", "method", "parameter"), [ - ("HWE-SKT-11", "switch.device", "state_set", "power_on"), - ("HWE-SKT-11", "switch.device_switch_lock", "state_set", "switch_lock"), - ("HWE-SKT-11", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("HWE-SKT-21", "switch.device", "state_set", "power_on"), - ("HWE-SKT-21", "switch.device_switch_lock", "state_set", "switch_lock"), - ("HWE-SKT-21", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("HWE-WTR", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("SDM230", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("SDM630", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("HWE-KWH1", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("HWE-KWH3", "switch.device_cloud_connection", "system_set", "cloud_enabled"), + ("HWE-SKT-11", "switch.device", "state", "power_on"), + ("HWE-SKT-11", "switch.device_switch_lock", "state", "switch_lock"), + ("HWE-SKT-11", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("HWE-SKT-21", "switch.device", "state", "power_on"), + ("HWE-SKT-21", "switch.device_switch_lock", "state", "switch_lock"), + ("HWE-SKT-21", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("HWE-WTR", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("SDM230", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("SDM630", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("HWE-KWH1", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("HWE-KWH3", "switch.device_cloud_connection", "system", "cloud_enabled"), ], ) async def test_switch_entities( @@ -200,9 +200,9 @@ async def test_switch_entities( @pytest.mark.parametrize( ("entity_id", "method"), [ - ("switch.device", "state"), - ("switch.device_switch_lock", "state"), - ("switch.device_cloud_connection", "system"), + ("switch.device", "combined"), + ("switch.device_switch_lock", "combined"), + ("switch.device_cloud_connection", "combined"), ], ) async def test_switch_unreachable( From 246a9f95a3eefe24418a3bcb72bcb43f25bee35e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 10 Jan 2025 13:23:33 +0100 Subject: [PATCH 0287/2987] Smaller grammar fixes, replace 'entity_id' with UI-friendly 'ID' (#135236) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/hive/strings.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json index c8062a64ade..219776ad7e6 100644 --- a/homeassistant/components/hive/strings.json +++ b/homeassistant/components/hive/strings.json @@ -35,7 +35,7 @@ }, "error": { "invalid_username": "Failed to sign into Hive. Your email address is not recognised.", - "invalid_password": "Failed to sign into Hive. Incorrect password please try again.", + "invalid_password": "Failed to sign into Hive. Incorrect password, please try again.", "invalid_code": "Failed to sign into Hive. Your two-factor authentication code was incorrect.", "no_internet_available": "An internet connection is required to connect to Hive.", "unknown": "[%key:common::config_flow::error::unknown%]" @@ -52,7 +52,7 @@ "title": "Options for Hive", "description": "Update the scan interval to poll for data more often.", "data": { - "scan_interval": "Scan Interval (seconds)" + "scan_interval": "Scan interval (seconds)" } } } @@ -60,15 +60,15 @@ "services": { "boost_heating_on": { "name": "Boost heating on", - "description": "Sets the boost mode ON defining the period of time and the desired target temperature for the boost.", + "description": "Sets the boost mode ON, defining the period of time and the desired target temperature for the boost.", "fields": { "time_period": { - "name": "Time Period", - "description": "Set the time period for the boost." + "name": "[%key:component::hive::services::boost_hot_water::fields::time_period::name%]", + "description": "[%key:component::hive::services::boost_hot_water::fields::time_period::description%]" }, "temperature": { "name": "Temperature", - "description": "Set the target temperature for the boost period." + "description": "The target temperature for the boost period." } } }, @@ -78,21 +78,21 @@ "fields": { "entity_id": { "name": "Entity ID", - "description": "Select entity_id to turn boost off." + "description": "The entity ID to turn boost off." } } }, "boost_hot_water": { "name": "Boost hotwater", - "description": "Sets the boost mode ON or OFF defining the period of time for the boost.", + "description": "Sets the boost mode ON or OFF, defining the period of time for the boost.", "fields": { "entity_id": { "name": "Entity ID", - "description": "Select entity_id to boost." + "description": "The entity ID to boost." }, "time_period": { "name": "Time period", - "description": "Set the time period for the boost." + "description": "The time period for the boost." }, "on_off": { "name": "[%key:common::config_flow::data::mode%]", From 9388879b78900ccfd024b279c4e9d0be17820048 Mon Sep 17 00:00:00 2001 From: Antoine Reversat Date: Fri, 10 Jan 2025 07:24:33 -0500 Subject: [PATCH 0288/2987] Mark FGLAir entities unavailable if they are reporting to be offline (#135202) --- .../components/fujitsu_fglair/coordinator.py | 10 +++++++-- .../components/fujitsu_fglair/entity.py | 5 +++++ tests/components/fujitsu_fglair/test_init.py | 21 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fujitsu_fglair/coordinator.py b/homeassistant/components/fujitsu_fglair/coordinator.py index eac3cfd6ce5..d98464e4751 100644 --- a/homeassistant/components/fujitsu_fglair/coordinator.py +++ b/homeassistant/components/fujitsu_fglair/coordinator.py @@ -48,10 +48,16 @@ class FGLairCoordinator(DataUpdateCoordinator[dict[str, FujitsuHVAC]]): raise ConfigEntryAuthFailed("Credentials expired for Ayla IoT API") from e if not listening_entities: - devices = [dev for dev in devices if isinstance(dev, FujitsuHVAC)] + devices = [ + dev + for dev in devices + if isinstance(dev, FujitsuHVAC) and dev.is_online() + ] else: devices = [ - dev for dev in devices if dev.device_serial_number in listening_entities + dev + for dev in devices + if dev.device_serial_number in listening_entities and dev.is_online() ] try: diff --git a/homeassistant/components/fujitsu_fglair/entity.py b/homeassistant/components/fujitsu_fglair/entity.py index 54d33d0e463..5c41a8ab18e 100644 --- a/homeassistant/components/fujitsu_fglair/entity.py +++ b/homeassistant/components/fujitsu_fglair/entity.py @@ -27,6 +27,11 @@ class FGLairEntity(CoordinatorEntity[FGLairCoordinator]): sw_version=device.property_values["mcu_firmware_version"], ) + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator_context in self.coordinator.data + @property def device(self) -> FujitsuHVAC: """Return the device object from the coordinator data.""" diff --git a/tests/components/fujitsu_fglair/test_init.py b/tests/components/fujitsu_fglair/test_init.py index d400d85c33a..a69610c416d 100644 --- a/tests/components/fujitsu_fglair/test_init.py +++ b/tests/components/fujitsu_fglair/test_init.py @@ -7,6 +7,7 @@ from ayla_iot_unofficial.fujitsu_consts import FGLAIR_APP_CREDENTIALS from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.climate import HVACMode from homeassistant.components.fujitsu_fglair.const import ( API_REFRESH, API_TIMEOUT, @@ -124,6 +125,26 @@ async def test_device_auth_failure( assert hass.states.get(entity_id(mock_devices[1])).state == STATE_UNAVAILABLE +async def test_device_offline( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ayla_api: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_devices: list[AsyncMock], +) -> None: + """Test entities become unavailable if device if offline.""" + await setup_integration(hass, mock_config_entry) + + mock_ayla_api.async_get_devices.return_value[0].is_online.return_value = False + + freezer.tick(API_REFRESH) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id(mock_devices[0])).state == STATE_UNAVAILABLE + assert hass.states.get(entity_id(mock_devices[1])).state == HVACMode.COOL + + async def test_token_expired( hass: HomeAssistant, mock_ayla_api: AsyncMock, From 028c5349ac05debde80379df18933dfbdeb599be Mon Sep 17 00:00:00 2001 From: dotvav Date: Fri, 10 Jan 2025 14:06:17 +0100 Subject: [PATCH 0289/2987] Bump pypalazzetti to 0.1.16 (#135269) --- homeassistant/components/palazzetti/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/palazzetti/manifest.json b/homeassistant/components/palazzetti/manifest.json index 70e58507159..2117a76bf15 100644 --- a/homeassistant/components/palazzetti/manifest.json +++ b/homeassistant/components/palazzetti/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/palazzetti", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pypalazzetti==0.1.15"] + "requirements": ["pypalazzetti==0.1.16"] } diff --git a/requirements_all.txt b/requirements_all.txt index d38e1288366..e3359ee95f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2189,7 +2189,7 @@ pyoverkiz==1.15.5 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.15 +pypalazzetti==0.1.16 # homeassistant.components.elv pypca==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dfa438f1860..141b3ed5643 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1785,7 +1785,7 @@ pyoverkiz==1.15.5 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.15 +pypalazzetti==0.1.16 # homeassistant.components.lcn pypck==0.8.1 From 59d61104d10d1c9e2e37b6e48ac630365b316181 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 10 Jan 2025 14:06:58 +0100 Subject: [PATCH 0290/2987] Replace 'entity_id' with UI-friendly, localizable 'entity ID' (#135232) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/evohome/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/evohome/strings.json b/homeassistant/components/evohome/strings.json index 9e88c9bb031..ca032643c9d 100644 --- a/homeassistant/components/evohome/strings.json +++ b/homeassistant/components/evohome/strings.json @@ -32,7 +32,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "The entity_id of the Evohome zone." + "description": "The entity ID of the Evohome zone." }, "setpoint": { "name": "Setpoint", @@ -49,8 +49,8 @@ "description": "Sets a zone to follow its schedule.", "fields": { "entity_id": { - "name": "Entity", - "description": "The entity_id of the zone." + "name": "[%key:component::evohome::services::set_zone_override::fields::entity_id::name%]", + "description": "[%key:component::evohome::services::set_zone_override::fields::entity_id::description%]" } } } From 6fd0760f25f83840b7172e52216a5d417efe7701 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 10 Jan 2025 14:07:14 +0100 Subject: [PATCH 0291/2987] Add USB-PD Mode select entity to IronOS integration (#134901) Add USB-PD Mode select entity --- homeassistant/components/iron_os/icons.json | 3 + homeassistant/components/iron_os/select.py | 12 ++++ homeassistant/components/iron_os/strings.json | 7 +++ .../iron_os/snapshots/test_select.ambr | 55 +++++++++++++++++++ tests/components/iron_os/test_select.py | 6 ++ 5 files changed, 83 insertions(+) diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json index ee8badf3c89..6410c561b9d 100644 --- a/homeassistant/components/iron_os/icons.json +++ b/homeassistant/components/iron_os/icons.json @@ -102,6 +102,9 @@ }, "logo_duration": { "default": "mdi:clock-digital" + }, + "usb_pd_mode": { + "default": "mdi:meter-electric-outline" } }, "sensor": { diff --git a/homeassistant/components/iron_os/select.py b/homeassistant/components/iron_os/select.py index 10d8a6fcef5..cc275e7c63c 100644 --- a/homeassistant/components/iron_os/select.py +++ b/homeassistant/components/iron_os/select.py @@ -19,6 +19,7 @@ from pynecil import ( ScrollSpeed, SettingsDataResponse, TempUnit, + USBPDMode, ) from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -55,6 +56,7 @@ class PinecilSelect(StrEnum): DESC_SCROLL_SPEED = "desc_scroll_speed" LOCKING_MODE = "locking_mode" LOGO_DURATION = "logo_duration" + USB_PD_MODE = "usb_pd_mode" def enum_to_str(enum: Enum | None) -> str | None: @@ -140,6 +142,16 @@ PINECIL_SELECT_DESCRIPTIONS: tuple[IronOSSelectEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ), + IronOSSelectEntityDescription( + key=PinecilSelect.USB_PD_MODE, + translation_key=PinecilSelect.USB_PD_MODE, + characteristic=CharSetting.USB_PD_MODE, + value_fn=lambda x: enum_to_str(x.get("usb_pd_mode")), + raw_value_fn=lambda value: USBPDMode[value.upper()], + options=["off", "on"], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), ) diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index 548ba1d8127..60168699427 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -166,6 +166,13 @@ "seconds_5": "5 second", "loop": "Loop" } + }, + "usb_pd_mode": { + "name": "Power Delivery 3.1 EPR", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } }, "sensor": { diff --git a/tests/components/iron_os/snapshots/test_select.ambr b/tests/components/iron_os/snapshots/test_select.ambr index ce6045c1243..e3989fbf863 100644 --- a/tests/components/iron_os/snapshots/test_select.ambr +++ b/tests/components/iron_os/snapshots/test_select.ambr @@ -237,6 +237,61 @@ 'state': 'right_handed', }) # --- +# name: test_state[select.pinecil_power_delivery_3_1_epr-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'on', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pinecil_power_delivery_3_1_epr', + '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': 'Power Delivery 3.1 EPR', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_usb_pd_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[select.pinecil_power_delivery_3_1_epr-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Power Delivery 3.1 EPR', + 'options': list([ + 'off', + 'on', + ]), + }), + 'context': , + 'entity_id': 'select.pinecil_power_delivery_3_1_epr', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_state[select.pinecil_power_source-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/iron_os/test_select.py b/tests/components/iron_os/test_select.py index cfd4d8ecbb1..8cc848dd4cb 100644 --- a/tests/components/iron_os/test_select.py +++ b/tests/components/iron_os/test_select.py @@ -16,6 +16,7 @@ from pynecil import ( ScreenOrientationMode, ScrollSpeed, TempUnit, + USBPDMode, ) import pytest from syrupy.assertion import SnapshotAssertion @@ -105,6 +106,11 @@ async def test_state( "loop", (CharSetting.LOGO_DURATION, LogoDuration.LOOP), ), + ( + "select.pinecil_power_delivery_3_1_epr", + "on", + (CharSetting.USB_PD_MODE, USBPDMode.ON), + ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") From 32d3fe714fe6ab6eab0ba4e75a8d4f8db625af24 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 10 Jan 2025 16:15:14 +0100 Subject: [PATCH 0292/2987] Grammar and consistency fixes in hdmi_cec strings (#135292) --- homeassistant/components/hdmi_cec/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hdmi_cec/strings.json b/homeassistant/components/hdmi_cec/strings.json index d280cfc1a2b..449b9f72fe7 100644 --- a/homeassistant/components/hdmi_cec/strings.json +++ b/homeassistant/components/hdmi_cec/strings.json @@ -2,7 +2,7 @@ "services": { "power_on": { "name": "Power on", - "description": "Power on all devices which supports it." + "description": "Powers on all devices which support this function." }, "select_device": { "name": "Select device", @@ -10,7 +10,7 @@ "fields": { "device": { "name": "[%key:common::config_flow::data::device%]", - "description": "Address of device to select. Can be entity_id, physical address or alias from configuration." + "description": "Address of device to select. Can be an entity ID, physical address or alias from configuration." } } }, @@ -42,7 +42,7 @@ }, "standby": { "name": "[%key:common::state::standby%]", - "description": "Standby all devices which supports it." + "description": "Places in standby all devices which support this function." }, "update": { "name": "Update", @@ -50,19 +50,19 @@ }, "volume": { "name": "Volume", - "description": "Increases or decreases volume of system.", + "description": "Increases or decreases the system volume.", "fields": { "down": { "name": "Down", - "description": "Decreases volume x levels." + "description": "Decreases the volume x levels." }, "mute": { "name": "Mute", - "description": "Mutes audio system." + "description": "Mutes the audio system." }, "up": { "name": "Up", - "description": "Increases volume x levels." + "description": "Increases the volume x levels." } } } From c4b4cad335d712e9fcfb95c1e012566fe0cd69c4 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 10 Jan 2025 16:18:00 +0000 Subject: [PATCH 0293/2987] Bump aioshelly to version 12.3.1 (#135299) --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 2db45c3fb03..cf5c59da5e3 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==12.3.0"], + "requirements": ["aioshelly==12.3.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index e3359ee95f2..c0898f709be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -368,7 +368,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.3.0 +aioshelly==12.3.1 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 141b3ed5643..e25e08d7e89 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -350,7 +350,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.3.0 +aioshelly==12.3.1 # homeassistant.components.skybell aioskybell==22.7.0 From 6fd4d7acaae3e8f2919cce3de80aeb660ea5aed2 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 10 Jan 2025 19:16:25 +0200 Subject: [PATCH 0294/2987] Use runtime_data in LG webOS TV (#135301) --- homeassistant/components/webostv/__init__.py | 21 +++++++++---------- homeassistant/components/webostv/const.py | 1 - .../components/webostv/diagnostics.py | 7 +++---- homeassistant/components/webostv/helpers.py | 5 +++-- .../components/webostv/media_player.py | 15 +++++++------ homeassistant/components/webostv/notify.py | 9 +++++--- .../components/webostv/quality_scale.yaml | 2 +- .../components/webostv/test_device_trigger.py | 1 + 8 files changed, 31 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index 410b3d853a1..3a3ee8e4c7e 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, + Platform, ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -22,7 +23,6 @@ from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_CONFIG_ENTRY_ID, - DATA_CONFIG_ENTRY, DATA_HASS_CONFIG, DOMAIN, PLATFORMS, @@ -34,23 +34,23 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) _LOGGER = logging.getLogger(__name__) +type WebOsTvConfigEntry = ConfigEntry[WebOsClient] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LG WebOS TV platform.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN].setdefault(DATA_CONFIG_ENTRY, {}) - hass.data[DOMAIN][DATA_HASS_CONFIG] = config + hass.data.setdefault(DOMAIN, {DATA_HASS_CONFIG: config}) return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> bool: """Set the config entry up.""" host = entry.data[CONF_HOST] key = entry.data[CONF_CLIENT_SECRET] # Attempt a connection, but fail gracefully if tv is off for example. - client = WebOsClient(host, key) + entry.runtime_data = client = WebOsClient(host, key) with suppress(*WEBOSTV_EXCEPTIONS): try: await client.connect() @@ -61,7 +61,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Update the stored key without triggering reauth update_client_key(hass, entry, client) - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = client await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # set up notify platform, no entry support for notify component yet, @@ -69,7 +68,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.async_create_task( discovery.async_load_platform( hass, - "notify", + Platform.NOTIFY, DOMAIN, { CONF_NAME: entry.title, @@ -92,7 +91,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_update_options(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> None: """Update options.""" await hass.config_entries.async_reload(entry.entry_id) @@ -122,10 +121,10 @@ def update_client_key( hass.config_entries.async_update_entry(entry, data=data) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - client = hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) + client = entry.runtime_data await hass_notify.async_reload(hass, DOMAIN) client.clear_state_update_callbacks() await client.disconnect() diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py index 0d839568f13..65d964d8fd4 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -9,7 +9,6 @@ from homeassistant.const import Platform DOMAIN = "webostv" PLATFORMS = [Platform.MEDIA_PLAYER] -DATA_CONFIG_ENTRY = "config_entry" DATA_HASS_CONFIG = "hass_config" DEFAULT_NAME = "LG webOS TV" diff --git a/homeassistant/components/webostv/diagnostics.py b/homeassistant/components/webostv/diagnostics.py index 1657fb71d26..d5e2dac06dc 100644 --- a/homeassistant/components/webostv/diagnostics.py +++ b/homeassistant/components/webostv/diagnostics.py @@ -7,11 +7,10 @@ from typing import Any from aiowebostv import WebOsClient from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from .const import DATA_CONFIG_ENTRY, DOMAIN +from . import WebOsTvConfigEntry TO_REDACT = { CONF_CLIENT_SECRET, @@ -25,10 +24,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: WebOsTvConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - client: WebOsClient = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] + client: WebOsClient = entry.runtime_data client_data = { "is_registered": client.is_registered(), diff --git a/homeassistant/components/webostv/helpers.py b/homeassistant/components/webostv/helpers.py index edcfdcfed8b..f6b5fa04d86 100644 --- a/homeassistant/components/webostv/helpers.py +++ b/homeassistant/components/webostv/helpers.py @@ -9,7 +9,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from . import async_control_connect -from .const import DATA_CONFIG_ENTRY, DOMAIN, LIVE_TV_APP_ID, WEBOSTV_EXCEPTIONS +from .const import DOMAIN, LIVE_TV_APP_ID, WEBOSTV_EXCEPTIONS @callback @@ -55,7 +55,8 @@ def async_get_client_by_device_entry( Raises ValueError if client is not found. """ for config_entry_id in device.config_entries: - if client := hass.data[DOMAIN][DATA_CONFIG_ENTRY].get(config_entry_id): + if entry := hass.config_entries.async_get_entry(config_entry_id): + client = entry.runtime_data break if not client: diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 599eb48a69d..719e3edbf4b 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -22,7 +22,6 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_COMMAND, ATTR_SUPPORTED_FEATURES from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -34,13 +33,12 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.trigger import PluggableAction from homeassistant.helpers.typing import VolDictType -from . import update_client_key +from . import WebOsTvConfigEntry, update_client_key from .const import ( ATTR_BUTTON, ATTR_PAYLOAD, ATTR_SOUND_OUTPUT, CONF_SOURCES, - DATA_CONFIG_ENTRY, DOMAIN, LIVE_TV_APP_ID, SERVICE_BUTTON, @@ -87,7 +85,9 @@ SERVICES = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: WebOsTvConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the LG webOS Smart TV platform.""" platform = entity_platform.async_get_current_platform() @@ -95,8 +95,7 @@ async def async_setup_entry( for service_name, schema, method in SERVICES: platform.async_register_entity_service(service_name, schema, method) - client = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] - async_add_entities([LgWebOSMediaPlayerEntity(entry, client)]) + async_add_entities([LgWebOSMediaPlayerEntity(entry)]) def cmd[_T: LgWebOSMediaPlayerEntity, **_P]( @@ -133,10 +132,10 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): _attr_has_entity_name = True _attr_name = None - def __init__(self, entry: ConfigEntry, client: WebOsClient) -> None: + def __init__(self, entry: WebOsTvConfigEntry) -> None: """Initialize the webos device.""" self._entry = entry - self._client = client + self._client = entry.runtime_data self._attr_assumed_state = True self._device_name = entry.title self._attr_unique_id = entry.unique_id diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py index e46e3cb202d..fde0e6ad607 100644 --- a/homeassistant/components/webostv/notify.py +++ b/homeassistant/components/webostv/notify.py @@ -12,7 +12,7 @@ from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ATTR_CONFIG_ENTRY_ID, DATA_CONFIG_ENTRY, DOMAIN, WEBOSTV_EXCEPTIONS +from .const import ATTR_CONFIG_ENTRY_ID, WEBOSTV_EXCEPTIONS _LOGGER = logging.getLogger(__name__) @@ -29,9 +29,12 @@ async def async_get_service( if discovery_info is None: return None - client = hass.data[DOMAIN][DATA_CONFIG_ENTRY][discovery_info[ATTR_CONFIG_ENTRY_ID]] + config_entry = hass.config_entries.async_get_entry( + discovery_info[ATTR_CONFIG_ENTRY_ID] + ) + assert config_entry is not None - return LgWebOSNotificationService(client) + return LgWebOSNotificationService(config_entry.runtime_data) class LgWebOSNotificationService(BaseNotificationService): diff --git a/homeassistant/components/webostv/quality_scale.yaml b/homeassistant/components/webostv/quality_scale.yaml index 1bf521ef2e2..22c0b4155ab 100644 --- a/homeassistant/components/webostv/quality_scale.yaml +++ b/homeassistant/components/webostv/quality_scale.yaml @@ -20,7 +20,7 @@ rules: entity-event-setup: done entity-unique-id: done has-entity-name: done - runtime-data: todo + runtime-data: done test-before-configure: done test-before-setup: done unique-config-entry: done diff --git a/tests/components/webostv/test_device_trigger.py b/tests/components/webostv/test_device_trigger.py index 41045969335..5c3d7d63346 100644 --- a/tests/components/webostv/test_device_trigger.py +++ b/tests/components/webostv/test_device_trigger.py @@ -129,6 +129,7 @@ async def test_failure_scenarios( ) entry = MockConfigEntry(domain="fake", state=ConfigEntryState.LOADED, data={}) + entry.runtime_data = None entry.add_to_hass(hass) device = device_registry.async_get_or_create( From 31b45e6d3fc96e81072a7ca80d94be4b8ceab07a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 10 Jan 2025 18:20:50 +0100 Subject: [PATCH 0295/2987] Fix typos and inconsistent spelling of "tedee" brand name (#135305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change "Setup your tedee locks" to "Set up …" - Remove two excessive commas - Change one occurrence of "Tedee" to "tedee". --- homeassistant/components/tedee/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tedee/strings.json b/homeassistant/components/tedee/strings.json index 78cacd706d3..c7204b6d2a9 100644 --- a/homeassistant/components/tedee/strings.json +++ b/homeassistant/components/tedee/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Setup your tedee locks", + "title": "Set up your tedee locks", "data": { "local_access_token": "Local access token", "host": "[%key:common::config_flow::data::host%]" @@ -14,7 +14,7 @@ }, "reauth_confirm": { "title": "Update of access key required", - "description": "Tedee needs an updated access key, because the existing one is invalid, or might have expired.", + "description": "Tedee needs an updated access key because the existing one is invalid or might have expired.", "data": { "local_access_token": "[%key:component::tedee::config::step::user::data::local_access_token%]" }, @@ -23,7 +23,7 @@ } }, "reconfigure": { - "title": "Reconfigure Tedee", + "title": "Reconfigure tedee", "description": "Update the settings of this integration.", "data": { "host": "[%key:common::config_flow::data::host%]", From 675cc3253487acdfcc1b32631d69c082a4bb8a20 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 10 Jan 2025 18:21:39 +0100 Subject: [PATCH 0296/2987] Fix typos, replace duplicated strings with references (#135303) --- homeassistant/components/madvr/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/madvr/strings.json b/homeassistant/components/madvr/strings.json index 1a4f0f79aae..19f23afddaf 100644 --- a/homeassistant/components/madvr/strings.json +++ b/homeassistant/components/madvr/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Setup madVR Envy", + "title": "Set up madVR Envy", "description": "Your device needs to be on in order to add the integation.", "data": { "host": "[%key:common::config_flow::data::host%]", @@ -21,8 +21,8 @@ "port": "[%key:common::config_flow::data::port%]" }, "data_description": { - "host": "The hostname or IP address of your madVR Envy device.", - "port": "The port your madVR Envy is listening on. In 99% of cases, leave this as the default." + "host": "[%key:component::madvr::config::step::user::data_description::host%]", + "port": "[%key:component::madvr::config::step::user::data_description::port%]" } } }, @@ -33,7 +33,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "no_mac": "A MAC address was not found. It required to identify the device. Please ensure your device is connectable." + "no_mac": "A MAC address was not found. It is required to identify the device. Please ensure your device is connectable." } }, "entity": { From 39aa0339ac828b82e566b7f6e551465d5ab3038e Mon Sep 17 00:00:00 2001 From: Quentame Date: Fri, 10 Jan 2025 20:47:48 +0100 Subject: [PATCH 0297/2987] Bump Freebox to 1.2.2 (#135313) --- homeassistant/components/freebox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json index 46422cee105..0cfe37c7a31 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/freebox", "iot_class": "local_polling", "loggers": ["freebox_api"], - "requirements": ["freebox-api==1.2.1"], + "requirements": ["freebox-api==1.2.2"], "zeroconf": ["_fbx-api._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index c0898f709be..d3b317798c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -946,7 +946,7 @@ forecast-solar==4.0.0 fortiosapi==1.0.5 # homeassistant.components.freebox -freebox-api==1.2.1 +freebox-api==1.2.2 # homeassistant.components.free_mobile freesms==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e25e08d7e89..385965f771f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -802,7 +802,7 @@ foobot_async==1.0.0 forecast-solar==4.0.0 # homeassistant.components.freebox -freebox-api==1.2.1 +freebox-api==1.2.2 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor From 560d15effb2500d593a168be298ceec3cf152dd8 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 10 Jan 2025 21:15:44 +0100 Subject: [PATCH 0298/2987] Don't store uv's lockfile in hassfest image (#135214) --- script/hassfest/docker.py | 2 ++ script/hassfest/docker/Dockerfile | 2 ++ 2 files changed, 4 insertions(+) diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 022caee30cd..edc47e2f9d7 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -94,6 +94,8 @@ COPY . /usr/src/homeassistant # Uv is only needed during build RUN --mount=from=ghcr.io/astral-sh/uv:{uv},source=/uv,target=/bin/uv \ + # Uv creates a lock file in /tmp + --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ && uv pip install \ diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 3da4eb386de..4e711a29b28 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -15,6 +15,8 @@ COPY . /usr/src/homeassistant # Uv is only needed during build RUN --mount=from=ghcr.io/astral-sh/uv:0.5.8,source=/uv,target=/bin/uv \ + # Uv creates a lock file in /tmp + --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ && uv pip install \ From bf747bb7330d4888eb7b2d0544505685002ecd12 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 10 Jan 2025 22:47:05 +0100 Subject: [PATCH 0299/2987] Fix Habitica gems/hourglass sensors (#135323) --- homeassistant/components/habitica/sensor.py | 4 ++-- tests/components/habitica/fixtures/user.json | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 60dbf0d99b0..2bcb534af42 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -135,14 +135,14 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( HabiticaSensorEntityDescription( key=HabiticaSensorEntity.GEMS, translation_key=HabiticaSensorEntity.GEMS, - value_fn=lambda user, _: round(user.balance * 4) if user.balance else None, + value_fn=lambda user, _: None if (b := user.balance) is None else round(b * 4), suggested_display_precision=0, entity_picture="shop_gem.png", ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.TRINKETS, translation_key=HabiticaSensorEntity.TRINKETS, - value_fn=lambda user, _: user.purchased.plan.consecutive.trinkets or 0, + value_fn=lambda user, _: user.purchased.plan.consecutive.trinkets, suggested_display_precision=0, native_unit_of_measurement="⧖", entity_picture="notif_subscriber_reward.png", diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index d97ad458c77..876ea2550d3 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -114,6 +114,13 @@ } } }, - "balance": 10 + "balance": 10, + "purchased": { + "plan": { + "consecutive": { + "trinkets": 0 + } + } + } } } From 00c3b8cc3e1e383f924185594619ba0ccd0d0ee9 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 10 Jan 2025 22:49:36 +0100 Subject: [PATCH 0300/2987] Use LOGGER from homewizard.const instead per-file loggers (#135320) --- .../components/homewizard/config_flow.py | 18 ++++++++---------- .../components/homewizard/coordinator.py | 8 ++------ 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index 8536672651f..f88d1f1d701 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping -import logging from typing import Any, NamedTuple from homewizard_energy import HomeWizardEnergyV1 @@ -25,10 +24,9 @@ from .const import ( CONF_PRODUCT_TYPE, CONF_SERIAL, DOMAIN, + LOGGER, ) -_LOGGER = logging.getLogger(__name__) - class DiscoveryData(NamedTuple): """User metadata.""" @@ -55,7 +53,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): try: device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS]) except RecoverableError as ex: - _LOGGER.error(ex) + LOGGER.error(ex) errors = {"base": ex.error_code} else: await self.async_set_unique_id( @@ -122,7 +120,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): try: device = await self._async_try_connect(discovery_info.ip) except RecoverableError as ex: - _LOGGER.error(ex) + LOGGER.error(ex) return self.async_abort(reason="unknown") await self.async_set_unique_id( @@ -147,7 +145,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): try: await self._async_try_connect(self.discovery.ip) except RecoverableError as ex: - _LOGGER.error(ex) + LOGGER.error(ex) errors = {"base": ex.error_code} else: return self.async_create_entry( @@ -190,7 +188,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): try: await self._async_try_connect(reauth_entry.data[CONF_IP_ADDRESS]) except RecoverableError as ex: - _LOGGER.error(ex) + LOGGER.error(ex) errors = {"base": ex.error_code} else: await self.hass.config_entries.async_reload(reauth_entry.entry_id) @@ -208,7 +206,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS]) except RecoverableError as ex: - _LOGGER.error(ex) + LOGGER.error(ex) errors = {"base": ex.error_code} else: await self.async_set_unique_id( @@ -253,7 +251,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): ) from ex except UnsupportedError as ex: - _LOGGER.error("API version unsuppored") + LOGGER.error("API version unsuppored") raise AbortFlow("unsupported_api_version") from ex except RequestError as ex: @@ -262,7 +260,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): ) from ex except Exception as ex: - _LOGGER.exception("Unexpected exception") + LOGGER.exception("Unexpected exception") raise AbortFlow("unknown_error") from ex finally: diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index b5282c145cd..f646051a0e1 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -2,8 +2,6 @@ from __future__ import annotations -import logging - from homewizard_energy import HomeWizardEnergy, HomeWizardEnergyV1 from homewizard_energy.errors import DisabledError, RequestError from homewizard_energy.models import CombinedModels as DeviceResponseEntry @@ -14,9 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, UPDATE_INTERVAL - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, LOGGER, UPDATE_INTERVAL class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]): @@ -32,7 +28,7 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] hass: HomeAssistant, ) -> None: """Initialize update coordinator.""" - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) self.api = HomeWizardEnergyV1( self.config_entry.data[CONF_IP_ADDRESS], clientsession=async_get_clientsession(hass), From 619dee5d9340ba82dbfeaf84c13494066d8da080 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Jan 2025 11:50:03 -1000 Subject: [PATCH 0301/2987] Bump habluetooth to 3.8.0 (#135322) changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v3.7.0...v3.8.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index b0ddac2f7f4..e08b91cfc7f 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.20.0", "dbus-fast==2.28.0", - "habluetooth==3.7.0" + "habluetooth==3.8.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f45aaadf77e..3494504b5a8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ dbus-fast==2.28.0 fnv-hash-fast==1.1.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.7.0 +habluetooth==3.8.0 hass-nabucasa==0.87.0 hassil==2.1.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index d3b317798c0..01b59dfcd7e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1097,7 +1097,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.2 # homeassistant.components.bluetooth -habluetooth==3.7.0 +habluetooth==3.8.0 # homeassistant.components.cloud hass-nabucasa==0.87.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 385965f771f..4b3170cbb6c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -938,7 +938,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.2 # homeassistant.components.bluetooth -habluetooth==3.7.0 +habluetooth==3.8.0 # homeassistant.components.cloud hass-nabucasa==0.87.0 From ab8af033c04c82f3ef0eb7e7a791dbd2c0127134 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 10 Jan 2025 18:33:49 -0800 Subject: [PATCH 0302/2987] Extract resolve announcement media ID for AssistSatelliteEntity (#134917) --- .../components/assist_satellite/entity.py | 88 ++++++++++--------- 1 file changed, 47 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index ba8b54f7da2..8be136653ba 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -187,47 +187,10 @@ class AssistSatelliteEntity(entity.Entity): """ await self._cancel_running_pipeline() - media_id_source: Literal["url", "media_id", "tts"] | None = None - if message is None: message = "" - if not media_id: - media_id_source = "tts" - # Synthesize audio and get URL - pipeline_id = self._resolve_pipeline() - pipeline = async_get_pipeline(self.hass, pipeline_id) - - tts_options: dict[str, Any] = {} - if pipeline.tts_voice is not None: - tts_options[tts.ATTR_VOICE] = pipeline.tts_voice - - if self.tts_options is not None: - tts_options.update(self.tts_options) - - media_id = tts_generate_media_source_id( - self.hass, - message, - engine=pipeline.tts_engine, - language=pipeline.tts_language, - options=tts_options, - ) - - if media_source.is_media_source_id(media_id): - if not media_id_source: - media_id_source = "media_id" - media = await media_source.async_resolve_media( - self.hass, - media_id, - None, - ) - media_id = media.url - - if not media_id_source: - media_id_source = "url" - - # Resolve to full URL - media_id = async_process_play_media_url(self.hass, media_id) + announcement = await self._resolve_announcement_media_id(message, media_id) if self._is_announcing: raise SatelliteBusyError @@ -237,9 +200,7 @@ class AssistSatelliteEntity(entity.Entity): try: # Block until announcement is finished - await self.async_announce( - AssistSatelliteAnnouncement(message, media_id, media_id_source) - ) + await self.async_announce(announcement) finally: self._is_announcing = False self._set_state(AssistSatelliteState.IDLE) @@ -428,3 +389,48 @@ class AssistSatelliteEntity(entity.Entity): vad_sensitivity = vad.VadSensitivity(vad_sensitivity_state.state) return vad.VadSensitivity.to_seconds(vad_sensitivity) + + async def _resolve_announcement_media_id( + self, message: str, media_id: str | None + ) -> AssistSatelliteAnnouncement: + """Resolve the media ID.""" + media_id_source: Literal["url", "media_id", "tts"] | None = None + + if not media_id: + media_id_source = "tts" + # Synthesize audio and get URL + pipeline_id = self._resolve_pipeline() + pipeline = async_get_pipeline(self.hass, pipeline_id) + + tts_options: dict[str, Any] = {} + if pipeline.tts_voice is not None: + tts_options[tts.ATTR_VOICE] = pipeline.tts_voice + + if self.tts_options is not None: + tts_options.update(self.tts_options) + + media_id = tts_generate_media_source_id( + self.hass, + message, + engine=pipeline.tts_engine, + language=pipeline.tts_language, + options=tts_options, + ) + + if media_source.is_media_source_id(media_id): + if not media_id_source: + media_id_source = "media_id" + media = await media_source.async_resolve_media( + self.hass, + media_id, + None, + ) + media_id = media.url + + if not media_id_source: + media_id_source = "url" + + # Resolve to full URL + media_id = async_process_play_media_url(self.hass, media_id) + + return AssistSatelliteAnnouncement(message, media_id, media_id_source) From cdc96fdf6ff7ecb2193a9261888cf1aca79bc613 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Jan 2025 16:49:53 -1000 Subject: [PATCH 0303/2987] Add bluetooth subscribe_advertisements WebSocket API (#134291) --- .../components/bluetooth/__init__.py | 3 +- .../components/bluetooth/websocket_api.py | 163 ++++++++++++++++++ tests/components/bluetooth/conftest.py | 24 +++ tests/components/bluetooth/test_manager.py | 90 ++++++++-- .../bluetooth/test_websocket_api.py | 116 +++++++++++++ 5 files changed, 376 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/bluetooth/websocket_api.py create mode 100644 tests/components/bluetooth/test_websocket_api.py diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 645adfdcd2d..5e96e5e336f 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -51,7 +51,7 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.loader import async_get_bluetooth -from . import passive_update_processor +from . import passive_update_processor, websocket_api from .api import ( _get_manager, async_address_present, @@ -232,6 +232,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: set_manager(manager) await storage_setup_task await manager.async_setup() + websocket_api.async_setup(hass) hass.async_create_background_task( _async_start_adapter_discovery(hass, manager, bluetooth_adapters), diff --git a/homeassistant/components/bluetooth/websocket_api.py b/homeassistant/components/bluetooth/websocket_api.py new file mode 100644 index 00000000000..b295fb2ac63 --- /dev/null +++ b/homeassistant/components/bluetooth/websocket_api.py @@ -0,0 +1,163 @@ +"""The bluetooth integration websocket apis.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from functools import lru_cache, partial +import time +from typing import Any + +from habluetooth import BluetoothScanningMode +from home_assistant_bluetooth import BluetoothServiceInfoBleak +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.json import json_bytes + +from .api import _get_manager, async_register_callback +from .match import BluetoothCallbackMatcher +from .models import BluetoothChange + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the bluetooth websocket API.""" + websocket_api.async_register_command(hass, ws_subscribe_advertisements) + + +@lru_cache(maxsize=1024) +def serialize_service_info( + service_info: BluetoothServiceInfoBleak, time_diff: float +) -> dict[str, Any]: + """Serialize a BluetoothServiceInfoBleak object.""" + return { + "name": service_info.name, + "address": service_info.address, + "rssi": service_info.rssi, + "manufacturer_data": { + str(manufacturer_id): manufacturer_data.hex() + for manufacturer_id, manufacturer_data in service_info.manufacturer_data.items() + }, + "service_data": { + service_uuid: service_data.hex() + for service_uuid, service_data in service_info.service_data.items() + }, + "service_uuids": service_info.service_uuids, + "source": service_info.source, + "connectable": service_info.connectable, + "time": service_info.time + time_diff, + "tx_power": service_info.tx_power, + } + + +class _AdvertisementSubscription: + """Class to hold and manage the subscription data.""" + + def __init__( + self, + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + ws_msg_id: int, + match_dict: BluetoothCallbackMatcher, + ) -> None: + """Initialize the subscription data.""" + self.hass = hass + self.match_dict = match_dict + self.pending_service_infos: list[BluetoothServiceInfoBleak] = [] + self.ws_msg_id = ws_msg_id + self.connection = connection + self.pending = True + # Keep time_diff precise to 2 decimal places + # so the cached serialization can be reused, + # however we still want to calculate it each + # subscription in case the system clock is wrong + # and gets corrected. + self.time_diff = round(time.time() - time.monotonic(), 2) + + @callback + def _async_unsubscribe( + self, cancel_callbacks: tuple[Callable[[], None], ...] + ) -> None: + """Unsubscribe the callback.""" + for cancel_callback in cancel_callbacks: + cancel_callback() + + @callback + def async_start(self) -> None: + """Start the subscription.""" + connection = self.connection + cancel_adv_callback = async_register_callback( + self.hass, + self._async_on_advertisement, + self.match_dict, + BluetoothScanningMode.PASSIVE, + ) + cancel_disappeared_callback = _get_manager( + self.hass + ).async_register_disappeared_callback(self._async_removed) + connection.subscriptions[self.ws_msg_id] = partial( + self._async_unsubscribe, (cancel_adv_callback, cancel_disappeared_callback) + ) + self.pending = False + self.connection.send_message( + json_bytes(websocket_api.result_message(self.ws_msg_id)) + ) + self._async_added(self.pending_service_infos) + self.pending_service_infos.clear() + + def _async_added(self, service_infos: Iterable[BluetoothServiceInfoBleak]) -> None: + self.connection.send_message( + json_bytes( + websocket_api.event_message( + self.ws_msg_id, + { + "add": [ + serialize_service_info(service_info, self.time_diff) + for service_info in service_infos + ] + }, + ) + ) + ) + + def _async_removed(self, address: str) -> None: + self.connection.send_message( + json_bytes( + websocket_api.event_message( + self.ws_msg_id, + { + "remove": [ + { + "address": address, + } + ] + }, + ) + ) + ) + + @callback + def _async_on_advertisement( + self, service_info: BluetoothServiceInfoBleak, change: BluetoothChange + ) -> None: + """Handle the callback.""" + if self.pending: + self.pending_service_infos.append(service_info) + return + self._async_added((service_info,)) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "bluetooth/subscribe_advertisements", + } +) +@websocket_api.async_response +async def ws_subscribe_advertisements( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe advertisements websocket command.""" + _AdvertisementSubscription( + hass, connection, msg["id"], BluetoothCallbackMatcher(connectable=False) + ).async_start() diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 93a1c59cba1..71ed155cbc7 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -8,6 +8,12 @@ from dbus_fast.aio import message_bus import habluetooth.util as habluetooth_utils import pytest +# pylint: disable-next=no-name-in-module +from homeassistant.components import bluetooth +from homeassistant.core import HomeAssistant + +from . import FakeScanner + @pytest.fixture(name="disable_bluez_manager_socket", autouse=True, scope="package") def disable_bluez_manager_socket(): @@ -304,3 +310,21 @@ def disable_new_discovery_flows_fixture(): "homeassistant.components.bluetooth.manager.discovery_flow.async_create_flow" ) as mock_create_flow: yield mock_create_flow + + +@pytest.fixture +def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]: + """Register an hci0 scanner.""" + hci0_scanner = FakeScanner("hci0", "hci0") + cancel = bluetooth.async_register_scanner(hass, hci0_scanner) + yield + cancel() + + +@pytest.fixture +def register_hci1_scanner(hass: HomeAssistant) -> Generator[None]: + """Register an hci1 scanner.""" + hci1_scanner = FakeScanner("hci1", "hci1") + cancel = bluetooth.async_register_scanner(hass, hci1_scanner) + yield + cancel() diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 0454df9a4a7..77071368dd0 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -1,6 +1,5 @@ """Tests for the Bluetooth integration manager.""" -from collections.abc import Generator from datetime import timedelta import time from typing import Any @@ -8,6 +7,7 @@ from unittest.mock import patch from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import AdvertisementHistory +from freezegun import freeze_time # pylint: disable-next=no-name-in-module from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS @@ -36,10 +36,12 @@ from homeassistant.components.bluetooth.const import ( SOURCE_LOCAL, UNAVAILABLE_TRACK_SECONDS, ) +from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from homeassistant.util.dt import utcnow from homeassistant.util.json import json_loads from . import ( @@ -63,24 +65,6 @@ from tests.common import ( ) -@pytest.fixture -def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]: - """Register an hci0 scanner.""" - hci0_scanner = FakeScanner("hci0", "hci0") - cancel = bluetooth.async_register_scanner(hass, hci0_scanner) - yield - cancel() - - -@pytest.fixture -def register_hci1_scanner(hass: HomeAssistant) -> Generator[None]: - """Register an hci1 scanner.""" - hci1_scanner = FakeScanner("hci1", "hci1") - cancel = bluetooth.async_register_scanner(hass, hci1_scanner) - yield - cancel() - - @pytest.mark.usefixtures("enable_bluetooth") async def test_advertisements_do_not_switch_adapters_for_no_reason( hass: HomeAssistant, @@ -1660,3 +1644,71 @@ async def test_bluetooth_rediscover_no_match( cancel() unsetup_connectable_scanner() cancel_connectable_scanner() + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_register_disappeared_callback( + hass: HomeAssistant, + register_hci0_scanner: None, + register_hci1_scanner: None, +) -> None: + """Test bluetooth async_register_disappeared_callback handles failures.""" + address = "44:44:33:11:23:12" + + switchbot_device_signal_100 = generate_ble_device( + address, "wohand_signal_100", rssi=-100 + ) + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", service_uuids=[] + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" + ) + + failed_disappeared: list[str] = [] + + def _failing_callback(_address: str) -> None: + """Failing callback.""" + failed_disappeared.append(_address) + raise ValueError("This is a test") + + ok_disappeared: list[str] = [] + + def _ok_callback(_address: str) -> None: + """Ok callback.""" + ok_disappeared.append(_address) + + manager: HomeAssistantBluetoothManager = _get_manager() + cancel1 = manager.async_register_disappeared_callback(_failing_callback) + # Make sure the second callback still works if the first one fails and + # raises an exception + cancel2 = manager.async_register_disappeared_callback(_ok_callback) + + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", + manufacturer_data={123: b"abc"}, + service_uuids=[], + rssi=-80, + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci1" + ) + + future_time = utcnow() + timedelta(seconds=3600) + future_monotonic_time = time.monotonic() + 3600 + with ( + freeze_time(future_time), + patch( + "habluetooth.manager.monotonic_time_coarse", + return_value=future_monotonic_time, + ), + ): + async_fire_time_changed(hass, future_time) + + assert len(ok_disappeared) == 1 + assert ok_disappeared[0] == address + assert len(failed_disappeared) == 1 + assert failed_disappeared[0] == address + + cancel1() + cancel2() diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py new file mode 100644 index 00000000000..c9670f2f895 --- /dev/null +++ b/tests/components/bluetooth/test_websocket_api.py @@ -0,0 +1,116 @@ +"""The tests for the bluetooth WebSocket API.""" + +import asyncio +from datetime import timedelta +import time +from unittest.mock import ANY, patch + +from freezegun import freeze_time +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from . import ( + generate_advertisement_data, + generate_ble_device, + inject_advertisement_with_source, +) + +from tests.common import async_fire_time_changed +from tests.typing import WebSocketGenerator + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_advertisements( + hass: HomeAssistant, + register_hci0_scanner: None, + register_hci1_scanner: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_advertisements.""" + address = "44:44:33:11:23:12" + + switchbot_device_signal_100 = generate_ble_device( + address, "wohand_signal_100", rssi=-100 + ) + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", service_uuids=[] + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" + ) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_advertisements", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "address": "44:44:33:11:23:12", + "connectable": True, + "manufacturer_data": {}, + "name": "wohand_signal_100", + "rssi": -127, + "service_data": {}, + "service_uuids": [], + "source": "hci0", + "time": ANY, + "tx_power": -127, + } + ] + } + adv_time = response["event"]["add"][0]["time"] + + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", + manufacturer_data={123: b"abc"}, + service_uuids=[], + rssi=-80, + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci1" + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "address": "44:44:33:11:23:12", + "connectable": True, + "manufacturer_data": {"123": "616263"}, + "name": "wohand_signal_100", + "rssi": -80, + "service_data": {}, + "service_uuids": [], + "source": "hci1", + "time": ANY, + "tx_power": -127, + } + ] + } + new_time = response["event"]["add"][0]["time"] + assert new_time > adv_time + future_time = utcnow() + timedelta(seconds=3600) + future_monotonic_time = time.monotonic() + 3600 + with ( + freeze_time(future_time), + patch( + "habluetooth.manager.monotonic_time_coarse", + return_value=future_monotonic_time, + ), + ): + async_fire_time_changed(hass, future_time) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == {"remove": [{"address": "44:44:33:11:23:12"}]} From 9ef93517e7a003bb5f3c07057ae6e929ebb33dc2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 11 Jan 2025 10:00:59 +0100 Subject: [PATCH 0304/2987] Fix spelling of "Log in", fix "outdated student" (#135348) --- homeassistant/components/vulcan/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vulcan/strings.json b/homeassistant/components/vulcan/strings.json index 61b5a954389..d8344cbdeec 100644 --- a/homeassistant/components/vulcan/strings.json +++ b/homeassistant/components/vulcan/strings.json @@ -4,7 +4,7 @@ "already_configured": "That student has already been added.", "all_student_already_configured": "All students have already been added.", "reauth_successful": "Reauth successful", - "no_matching_entries": "No matching entries found, please use different account or remove integration with outdated student." + "no_matching_entries": "No matching entries found, please use different account or remove outdated student integration." }, "error": { "unknown": "[%key:common::config_flow::error::unknown%]", @@ -17,7 +17,7 @@ }, "step": { "auth": { - "description": "Login to your Vulcan Account using mobile app registration page.", + "description": "Log in to your Vulcan Account using mobile app registration page.", "data": { "token": "Token", "region": "Symbol", From 22b84450e8e1be2b32f5e4b28c548be6feec7961 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 11 Jan 2025 10:10:40 +0100 Subject: [PATCH 0305/2987] Small fixes in setup flow strings, correct sentence-case (#135349) --- homeassistant/components/tellduslive/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index e363aced667..937b40f13ab 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -11,15 +11,15 @@ }, "step": { "auth": { - "description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorize **{app_name}** (select **Yes**).\n 4. Come back here and select **Submit**.\n\n [Link TelldusLive account]({auth_url})", - "title": "Authenticate against TelldusLive" + "description": "To link your TelldusLive account:\n1. Open the link below\n1. Log in to Telldus Live\n1. Authorize **{app_name}** (select **Yes**).\n1. Come back here and select **Submit**.\n\n [Link TelldusLive account]({auth_url})", + "title": "Authenticate with TelldusLive" }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "Hostname or IP address to Tellstick Net or Tellstick ZNet for Local API." + "host": "Hostname or IP address to Tellstick Net or Tellstick ZNet for local API." } } } From b9259b6f77446a7259150cd89b19d9949539712d Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sat, 11 Jan 2025 10:31:47 +0100 Subject: [PATCH 0306/2987] Add config flow to NMBS (#121548) Co-authored-by: Joostlek --- CODEOWNERS | 1 + homeassistant/components/nmbs/__init__.py | 46 ++- homeassistant/components/nmbs/config_flow.py | 180 +++++++++++ homeassistant/components/nmbs/const.py | 36 +++ homeassistant/components/nmbs/manifest.json | 1 + homeassistant/components/nmbs/sensor.py | 144 +++++++-- homeassistant/components/nmbs/strings.json | 35 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/nmbs/__init__.py | 20 ++ tests/components/nmbs/conftest.py | 58 ++++ tests/components/nmbs/fixtures/stations.json | 30 ++ tests/components/nmbs/test_config_flow.py | 310 +++++++++++++++++++ 14 files changed, 835 insertions(+), 32 deletions(-) create mode 100644 homeassistant/components/nmbs/config_flow.py create mode 100644 homeassistant/components/nmbs/const.py create mode 100644 homeassistant/components/nmbs/strings.json create mode 100644 tests/components/nmbs/__init__.py create mode 100644 tests/components/nmbs/conftest.py create mode 100644 tests/components/nmbs/fixtures/stations.json create mode 100644 tests/components/nmbs/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 86cfa6ed22a..96348b37246 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1025,6 +1025,7 @@ build.json @home-assistant/supervisor /tests/components/nina/ @DeerMaximum /homeassistant/components/nissan_leaf/ @filcole /homeassistant/components/nmbs/ @thibmaek +/tests/components/nmbs/ @thibmaek /homeassistant/components/noaa_tides/ @jdelaney72 /homeassistant/components/nobo_hub/ @echoromeo @oyvindwe /tests/components/nobo_hub/ @echoromeo @oyvindwe diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py index 11013d471b5..9972d41ac7b 100644 --- a/homeassistant/components/nmbs/__init__.py +++ b/homeassistant/components/nmbs/__init__.py @@ -1 +1,45 @@ -"""The nmbs component.""" +"""The NMBS component.""" + +import logging + +from pyrail import iRail + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +PLATFORMS = [Platform.SENSOR] + + +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the NMBS component.""" + + api_client = iRail() + + hass.data.setdefault(DOMAIN, {}) + station_response = await hass.async_add_executor_job(api_client.get_stations) + if station_response == -1: + return False + hass.data[DOMAIN] = station_response["station"] + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up NMBS from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nmbs/config_flow.py b/homeassistant/components/nmbs/config_flow.py new file mode 100644 index 00000000000..553e6492d2a --- /dev/null +++ b/homeassistant/components/nmbs/config_flow.py @@ -0,0 +1,180 @@ +"""Config flow for NMBS integration.""" + +from typing import Any + +from pyrail import iRail +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import Platform +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.selector import ( + BooleanSelector, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import ( + CONF_EXCLUDE_VIAS, + CONF_SHOW_ON_MAP, + CONF_STATION_FROM, + CONF_STATION_LIVE, + CONF_STATION_TO, + DOMAIN, +) + + +class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): + """NMBS config flow.""" + + def __init__(self) -> None: + """Initialize.""" + self.api_client = iRail() + self.stations: list[dict[str, Any]] = [] + + async def _fetch_stations(self) -> list[dict[str, Any]]: + """Fetch the stations.""" + stations_response = await self.hass.async_add_executor_job( + self.api_client.get_stations + ) + if stations_response == -1: + raise CannotConnect("The API is currently unavailable.") + return stations_response["station"] + + async def _fetch_stations_choices(self) -> list[SelectOptionDict]: + """Fetch the stations options.""" + + if len(self.stations) == 0: + self.stations = await self._fetch_stations() + + return [ + SelectOptionDict(value=station["id"], label=station["standardname"]) + for station in self.stations + ] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the step to setup a connection between 2 stations.""" + + try: + choices = await self._fetch_stations_choices() + except CannotConnect: + return self.async_abort(reason="api_unavailable") + + errors: dict = {} + if user_input is not None: + if user_input[CONF_STATION_FROM] == user_input[CONF_STATION_TO]: + errors["base"] = "same_station" + else: + [station_from] = [ + station + for station in self.stations + if station["id"] == user_input[CONF_STATION_FROM] + ] + [station_to] = [ + station + for station in self.stations + if station["id"] == user_input[CONF_STATION_TO] + ] + await self.async_set_unique_id( + f"{user_input[CONF_STATION_FROM]}_{user_input[CONF_STATION_TO]}" + ) + self._abort_if_unique_id_configured() + + config_entry_name = f"Train from {station_from["standardname"]} to {station_to["standardname"]}" + return self.async_create_entry( + title=config_entry_name, + data=user_input, + ) + + schema = vol.Schema( + { + vol.Required(CONF_STATION_FROM): SelectSelector( + SelectSelectorConfig( + options=choices, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required(CONF_STATION_TO): SelectSelector( + SelectSelectorConfig( + options=choices, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_EXCLUDE_VIAS): BooleanSelector(), + vol.Optional(CONF_SHOW_ON_MAP): BooleanSelector(), + }, + ) + return self.async_show_form( + step_id="user", + data_schema=schema, + errors=errors, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: + """Import configuration from yaml.""" + try: + self.stations = await self._fetch_stations() + except CannotConnect: + return self.async_abort(reason="api_unavailable") + + station_from = None + station_to = None + station_live = None + for station in self.stations: + if user_input[CONF_STATION_FROM] in ( + station["standardname"], + station["name"], + ): + station_from = station + if user_input[CONF_STATION_TO] in ( + station["standardname"], + station["name"], + ): + station_to = station + if CONF_STATION_LIVE in user_input and user_input[CONF_STATION_LIVE] in ( + station["standardname"], + station["name"], + ): + station_live = station + + if station_from is None or station_to is None: + return self.async_abort(reason="invalid_station") + if station_from == station_to: + return self.async_abort(reason="same_station") + + # config flow uses id and not the standard name + user_input[CONF_STATION_FROM] = station_from["id"] + user_input[CONF_STATION_TO] = station_to["id"] + + if station_live: + user_input[CONF_STATION_LIVE] = station_live["id"] + entity_registry = er.async_get(self.hass) + prefix = "live" + if entity_id := entity_registry.async_get_entity_id( + Platform.SENSOR, + DOMAIN, + f"{prefix}_{station_live["standardname"]}_{station_from["standardname"]}_{station_to["standardname"]}", + ): + new_unique_id = f"{DOMAIN}_{prefix}_{station_live["id"]}_{station_from["id"]}_{station_to["id"]}" + entity_registry.async_update_entity( + entity_id, new_unique_id=new_unique_id + ) + if entity_id := entity_registry.async_get_entity_id( + Platform.SENSOR, + DOMAIN, + f"{prefix}_{station_live["name"]}_{station_from["name"]}_{station_to["name"]}", + ): + new_unique_id = f"{DOMAIN}_{prefix}_{station_live["id"]}_{station_from["id"]}_{station_to["id"]}" + entity_registry.async_update_entity( + entity_id, new_unique_id=new_unique_id + ) + + return await self.async_step_user(user_input) + + +class CannotConnect(Exception): + """Error to indicate we cannot connect to NMBS.""" diff --git a/homeassistant/components/nmbs/const.py b/homeassistant/components/nmbs/const.py new file mode 100644 index 00000000000..fddb7365501 --- /dev/null +++ b/homeassistant/components/nmbs/const.py @@ -0,0 +1,36 @@ +"""The NMBS integration.""" + +from typing import Final + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +DOMAIN: Final = "nmbs" + +PLATFORMS: Final = [Platform.SENSOR] + +CONF_STATION_FROM = "station_from" +CONF_STATION_TO = "station_to" +CONF_STATION_LIVE = "station_live" +CONF_EXCLUDE_VIAS = "exclude_vias" +CONF_SHOW_ON_MAP = "show_on_map" + + +def find_station_by_name(hass: HomeAssistant, station_name: str): + """Find given station_name in the station list.""" + return next( + ( + s + for s in hass.data[DOMAIN] + if station_name in (s["standardname"], s["name"]) + ), + None, + ) + + +def find_station(hass: HomeAssistant, station_name: str): + """Find given station_id in the station list.""" + return next( + (s for s in hass.data[DOMAIN] if station_name in s["id"]), + None, + ) diff --git a/homeassistant/components/nmbs/manifest.json b/homeassistant/components/nmbs/manifest.json index e17d1227bed..2cff1d89b79 100644 --- a/homeassistant/components/nmbs/manifest.json +++ b/homeassistant/components/nmbs/manifest.json @@ -2,6 +2,7 @@ "domain": "nmbs", "name": "NMBS", "codeowners": ["@thibmaek"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nmbs", "iot_class": "cloud_polling", "loggers": ["pyrail"], diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 6ccdc742430..448dda73228 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -11,19 +11,33 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, CONF_NAME, + CONF_PLATFORM, CONF_SHOW_ON_MAP, UnitOfTime, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util +from .const import ( # noqa: F401 + CONF_EXCLUDE_VIAS, + CONF_STATION_FROM, + CONF_STATION_LIVE, + CONF_STATION_TO, + DOMAIN, + PLATFORMS, + find_station, + find_station_by_name, +) + _LOGGER = logging.getLogger(__name__) API_FAILURE = -1 @@ -33,11 +47,6 @@ DEFAULT_NAME = "NMBS" DEFAULT_ICON = "mdi:train" DEFAULT_ICON_ALERT = "mdi:alert-octagon" -CONF_STATION_FROM = "station_from" -CONF_STATION_TO = "station_to" -CONF_STATION_LIVE = "station_live" -CONF_EXCLUDE_VIAS = "exclude_vias" - PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( { vol.Required(CONF_STATION_FROM): cv.string, @@ -73,33 +82,97 @@ def get_ride_duration(departure_time, arrival_time, delay=0): return duration_time + get_delay_in_minutes(delay) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the NMBS sensor with iRail API.""" - api_client = iRail() + if config[CONF_PLATFORM] == DOMAIN: + if CONF_SHOW_ON_MAP not in config: + config[CONF_SHOW_ON_MAP] = False + if CONF_EXCLUDE_VIAS not in config: + config[CONF_EXCLUDE_VIAS] = False - name = config[CONF_NAME] - show_on_map = config[CONF_SHOW_ON_MAP] - station_from = config[CONF_STATION_FROM] - station_to = config[CONF_STATION_TO] - station_live = config.get(CONF_STATION_LIVE) - excl_vias = config[CONF_EXCLUDE_VIAS] + station_types = [CONF_STATION_FROM, CONF_STATION_TO, CONF_STATION_LIVE] - sensors: list[SensorEntity] = [ - NMBSSensor(api_client, name, show_on_map, station_from, station_to, excl_vias) - ] + for station_type in station_types: + station = ( + find_station_by_name(hass, config[station_type]) + if station_type in config + else None + ) + if station is None and station_type in config: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_station_not_found", + breaks_in_ha_version="2025.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_station_not_found", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "NMBS", + "station_name": config[station_type], + "url": "/config/integrations/dashboard/add?domain=nmbs", + }, + ) + return - if station_live is not None: - sensors.append( - NMBSLiveBoard(api_client, station_live, station_from, station_to) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) ) - add_entities(sensors, True) + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2025.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "NMBS", + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up NMBS sensor entities based on a config entry.""" + api_client = iRail() + + name = config_entry.data.get(CONF_NAME, None) + show_on_map = config_entry.data.get(CONF_SHOW_ON_MAP, False) + excl_vias = config_entry.data.get(CONF_EXCLUDE_VIAS, False) + + station_from = find_station(hass, config_entry.data[CONF_STATION_FROM]) + station_to = find_station(hass, config_entry.data[CONF_STATION_TO]) + + # setup the connection from station to station + # setup a disabled liveboard for both from and to station + async_add_entities( + [ + NMBSSensor( + api_client, name, show_on_map, station_from, station_to, excl_vias + ), + NMBSLiveBoard(api_client, station_from, station_from, station_to), + NMBSLiveBoard(api_client, station_to, station_from, station_to), + ] + ) class NMBSLiveBoard(SensorEntity): @@ -116,16 +189,18 @@ class NMBSLiveBoard(SensorEntity): self._attrs = {} self._state = None + self.entity_registry_enabled_default = False + @property def name(self): """Return the sensor default name.""" - return f"NMBS Live ({self._station})" + return f"Trains in {self._station["standardname"]}" @property def unique_id(self): - """Return a unique ID.""" - unique_id = f"{self._station}_{self._station_from}_{self._station_to}" + """Return the unique ID.""" + unique_id = f"{self._station}_{self._station_from}_{self._station_to}" return f"nmbs_live_{unique_id}" @property @@ -155,7 +230,7 @@ class NMBSLiveBoard(SensorEntity): "departure_minutes": departure, "extra_train": int(self._attrs["isExtra"]) > 0, "vehicle_id": self._attrs["vehicle"], - "monitored_station": self._station, + "monitored_station": self._station["standardname"], } if delay > 0: @@ -166,7 +241,7 @@ class NMBSLiveBoard(SensorEntity): def update(self) -> None: """Set the state equal to the next departure.""" - liveboard = self._api_client.get_liveboard(self._station) + liveboard = self._api_client.get_liveboard(self._station["id"]) if liveboard == API_FAILURE: _LOGGER.warning("API failed in NMBSLiveBoard") @@ -209,8 +284,17 @@ class NMBSSensor(SensorEntity): self._state = None @property - def name(self): + def unique_id(self) -> str: + """Return the unique ID.""" + unique_id = f"{self._station_from["id"]}_{self._station_to["id"]}" + + return f"nmbs_connection_{unique_id}" + + @property + def name(self) -> str: """Return the name of the sensor.""" + if self._name is None: + return f"Train from {self._station_from["standardname"]} to {self._station_to["standardname"]}" return self._name @property @@ -234,7 +318,7 @@ class NMBSSensor(SensorEntity): canceled = int(self._attrs["departure"]["canceled"]) attrs = { - "destination": self._station_to, + "destination": self._attrs["departure"]["station"], "direction": self._attrs["departure"]["direction"]["name"], "platform_arriving": self._attrs["arrival"]["platform"], "platform_departing": self._attrs["departure"]["platform"], @@ -296,7 +380,7 @@ class NMBSSensor(SensorEntity): def update(self) -> None: """Set the state to the duration of a connection.""" connections = self._api_client.get_connections( - self._station_from, self._station_to + self._station_from["id"], self._station_to["id"] ) if connections == API_FAILURE: diff --git a/homeassistant/components/nmbs/strings.json b/homeassistant/components/nmbs/strings.json new file mode 100644 index 00000000000..3e7aa8d05bd --- /dev/null +++ b/homeassistant/components/nmbs/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "api_unavailable": "The API is currently unavailable.", + "same_station": "[%key:component::nmbs::config::error::same_station%]", + "invalid_station": "Invalid station." + }, + "error": { + "same_station": "Departure and arrival station can not be the same." + }, + "step": { + "user": { + "data": { + "station_from": "Departure station", + "station_to": "Arrival station", + "exclude_vias": "Direct connections only", + "show_on_map": "Display on map" + }, + "data_description": { + "station_from": "Station where the train departs", + "station_to": "Station where the train arrives", + "exclude_vias": "Exclude connections with transfers", + "show_on_map": "Show the station on the map" + } + } + } + }, + "issues": { + "deprecated_yaml_import_issue_station_not_found": { + "title": "The {integration_title} YAML configuration import failed", + "description": "Configuring {integration_title} using YAML is being removed but there was an problem importing your YAML configuration.\n\nThe used station \"{station_name}\" could not be found. Fix it or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 624665118e6..49db871cb55 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -416,6 +416,7 @@ FLOWS = { "niko_home_control", "nina", "nmap_tracker", + "nmbs", "nobo_hub", "nordpool", "notion", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 07f4a3ae8ba..bf395336707 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4224,7 +4224,7 @@ "nmbs": { "name": "NMBS", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "no_ip": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b3170cbb6c..eb9db5d1ca7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1814,6 +1814,9 @@ pyps4-2ndscreen==1.3.1 # homeassistant.components.qwikswitch pyqwikswitch==0.93 +# homeassistant.components.nmbs +pyrail==0.0.3 + # homeassistant.components.rainbird pyrainbird==6.0.1 diff --git a/tests/components/nmbs/__init__.py b/tests/components/nmbs/__init__.py new file mode 100644 index 00000000000..91226950aba --- /dev/null +++ b/tests/components/nmbs/__init__.py @@ -0,0 +1,20 @@ +"""Tests for the NMBS integration.""" + +import json +from typing import Any + +from tests.common import load_fixture + + +def mock_api_unavailable() -> dict[str, Any]: + """Mock for unavailable api.""" + return -1 + + +def mock_station_response() -> dict[str, Any]: + """Mock for valid station response.""" + dummy_stations_response: dict[str, Any] = json.loads( + load_fixture("stations.json", "nmbs") + ) + + return dummy_stations_response diff --git a/tests/components/nmbs/conftest.py b/tests/components/nmbs/conftest.py new file mode 100644 index 00000000000..69200fc4c98 --- /dev/null +++ b/tests/components/nmbs/conftest.py @@ -0,0 +1,58 @@ +"""NMBS tests configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.nmbs.const import ( + CONF_STATION_FROM, + CONF_STATION_TO, + DOMAIN, +) + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nmbs.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_nmbs_client() -> Generator[AsyncMock]: + """Mock a NMBS client.""" + with ( + patch( + "homeassistant.components.nmbs.iRail", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.nmbs.config_flow.iRail", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_stations.return_value = load_json_object_fixture( + "stations.json", DOMAIN + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Train from Brussel-Noord/Bruxelles-Nord to Brussel-Zuid/Bruxelles-Midi", + data={ + CONF_STATION_FROM: "BE.NMBS.008812005", + CONF_STATION_TO: "BE.NMBS.008814001", + }, + unique_id="BE.NMBS.008812005_BE.NMBS.008814001", + ) diff --git a/tests/components/nmbs/fixtures/stations.json b/tests/components/nmbs/fixtures/stations.json new file mode 100644 index 00000000000..b774e064f78 --- /dev/null +++ b/tests/components/nmbs/fixtures/stations.json @@ -0,0 +1,30 @@ +{ + "version": "1.3", + "timestamp": "1720252400", + "station": [ + { + "@id": "http://irail.be/stations/NMBS/008812005", + "id": "BE.NMBS.008812005", + "name": "Brussels-North", + "locationX": "4.360846", + "locationY": "50.859663", + "standardname": "Brussel-Noord/Bruxelles-Nord" + }, + { + "@id": "http://irail.be/stations/NMBS/008813003", + "id": "BE.NMBS.008813003", + "name": "Brussels-Central", + "locationX": "4.356801", + "locationY": "50.845658", + "standardname": "Brussel-Centraal/Bruxelles-Central" + }, + { + "@id": "http://irail.be/stations/NMBS/008814001", + "id": "BE.NMBS.008814001", + "name": "Brussels-South/Brussels-Midi", + "locationX": "4.336531", + "locationY": "50.835707", + "standardname": "Brussel-Zuid/Bruxelles-Midi" + } + ] +} diff --git a/tests/components/nmbs/test_config_flow.py b/tests/components/nmbs/test_config_flow.py new file mode 100644 index 00000000000..08ecfbfd136 --- /dev/null +++ b/tests/components/nmbs/test_config_flow.py @@ -0,0 +1,310 @@ +"""Test the NMBS config flow.""" + +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.nmbs.const import ( + CONF_STATION_FROM, + CONF_STATION_LIVE, + CONF_STATION_TO, + DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + +DUMMY_DATA_IMPORT: dict[str, Any] = { + "STAT_BRUSSELS_NORTH": "Brussel-Noord/Bruxelles-Nord", + "STAT_BRUSSELS_CENTRAL": "Brussel-Centraal/Bruxelles-Central", + "STAT_BRUSSELS_SOUTH": "Brussel-Zuid/Bruxelles-Midi", +} + +DUMMY_DATA_ALTERNATIVE_IMPORT: dict[str, Any] = { + "STAT_BRUSSELS_NORTH": "Brussels-North", + "STAT_BRUSSELS_CENTRAL": "Brussels-Central", + "STAT_BRUSSELS_SOUTH": "Brussels-South/Brussels-Midi", +} + +DUMMY_DATA: dict[str, Any] = { + "STAT_BRUSSELS_NORTH": "BE.NMBS.008812005", + "STAT_BRUSSELS_CENTRAL": "BE.NMBS.008813003", + "STAT_BRUSSELS_SOUTH": "BE.NMBS.008814001", +} + + +async def test_full_flow( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test the full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["title"] + == "Train from Brussel-Noord/Bruxelles-Nord to Brussel-Zuid/Bruxelles-Midi" + ) + assert result["data"] == { + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + } + assert ( + result["result"].unique_id + == f"{DUMMY_DATA["STAT_BRUSSELS_NORTH"]}_{DUMMY_DATA["STAT_BRUSSELS_SOUTH"]}" + ) + + +async def test_same_station( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test selecting the same station.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "same_station"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_abort_if_exists( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test aborting the flow if the entry already exists.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_unavailable_api( + hass: HomeAssistant, mock_nmbs_client: AsyncMock +) -> None: + """Test starting a flow by user and api is unavailable.""" + mock_nmbs_client.get_stations.return_value = -1 + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_unavailable" + + +async def test_import( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test starting a flow by user which filled in data for connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_LIVE: DUMMY_DATA_IMPORT["STAT_BRUSSELS_CENTRAL"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["title"] + == "Train from Brussel-Noord/Bruxelles-Nord to Brussel-Zuid/Bruxelles-Midi" + ) + assert result["data"] == { + CONF_STATION_FROM: "BE.NMBS.008812005", + CONF_STATION_LIVE: "BE.NMBS.008813003", + CONF_STATION_TO: "BE.NMBS.008814001", + } + assert result["result"].unique_id == "BE.NMBS.008812005_BE.NMBS.008814001" + + +async def test_step_import_abort_if_already_setup( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test starting a flow by user which filled in data for connection for already existing connection.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_unavailable_api_import( + hass: HomeAssistant, mock_nmbs_client: AsyncMock +) -> None: + """Test starting a flow by import and api is unavailable.""" + mock_nmbs_client.get_stations.return_value = -1 + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_LIVE: DUMMY_DATA_IMPORT["STAT_BRUSSELS_CENTRAL"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_unavailable" + + +@pytest.mark.parametrize( + ("config", "reason"), + [ + ( + { + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: "Utrecht Centraal", + }, + "invalid_station", + ), + ( + { + CONF_STATION_FROM: "Utrecht Centraal", + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + "invalid_station", + ), + ( + { + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + }, + "same_station", + ), + ], +) +async def test_invalid_station_name( + hass: HomeAssistant, + mock_nmbs_client: AsyncMock, + config: dict[str, Any], + reason: str, +) -> None: + """Test importing invalid YAML.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_sensor_id_migration_standardname( + hass: HomeAssistant, + mock_nmbs_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating unique id.""" + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"live_{DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"]}_{DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"]}_{DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"]}", + config_entry=mock_config_entry, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_LIVE: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entities) == 1 + assert ( + entities[0].unique_id + == f"nmbs_live_{DUMMY_DATA["STAT_BRUSSELS_NORTH"]}_{DUMMY_DATA["STAT_BRUSSELS_NORTH"]}_{DUMMY_DATA["STAT_BRUSSELS_SOUTH"]}" + ) + + +async def test_sensor_id_migration_localized_name( + hass: HomeAssistant, + mock_nmbs_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating unique id.""" + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"live_{DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_NORTH"]}_{DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_NORTH"]}_{DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_SOUTH"]}", + config_entry=mock_config_entry, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_LIVE: DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_FROM: DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entities) == 1 + assert ( + entities[0].unique_id + == f"nmbs_live_{DUMMY_DATA["STAT_BRUSSELS_NORTH"]}_{DUMMY_DATA["STAT_BRUSSELS_NORTH"]}_{DUMMY_DATA["STAT_BRUSSELS_SOUTH"]}" + ) From 4d93fbcb529596b4fe440fd67868c3538cf21b1e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 11 Jan 2025 11:15:00 +0100 Subject: [PATCH 0307/2987] Fix backup formatting (#135350) --- homeassistant/components/backup/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index ba1c457561f..378cf1c0335 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -753,7 +753,7 @@ class BackupManager: backup_name = ( name - or f"{"Automatic" if with_automatic_settings else "Custom"} backup {HAVERSION}" + or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}" ) try: From d356d4bb824f1038e6c6973e5da82fa67ff3926a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Jan 2025 12:31:46 +0100 Subject: [PATCH 0308/2987] Bump actions/upload-artifact from 4.5.0 to 4.6.0 (#135255) --- .github/workflows/builder.yml | 2 +- .github/workflows/ci.yaml | 22 +++++++++++----------- .github/workflows/wheels.yml | 8 ++++---- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 20b1bd4c718..36aac442a0a 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -69,7 +69,7 @@ jobs: run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - - name: Upload translations - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: translations path: translations.tar.gz diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7bfebcd1f07..bb75027224a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -537,7 +537,7 @@ jobs: python --version uv pip freeze >> pip_freeze.txt - name: Upload pip_freeze artifact - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: pip-freeze-${{ matrix.python-version }} path: pip_freeze.txt @@ -661,7 +661,7 @@ jobs: . venv/bin/activate python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json - name: Upload licenses - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: licenses-${{ github.run_number }}-${{ matrix.python-version }} path: licenses-${{ matrix.python-version }}.json @@ -877,7 +877,7 @@ jobs: . venv/bin/activate python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests - name: Upload pytest_buckets - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: pytest_buckets path: pytest_buckets.txt @@ -979,14 +979,14 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-full.conclusion == 'failure' - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml @@ -1106,7 +1106,7 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1114,7 +1114,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1236,7 +1236,7 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1244,7 +1244,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1378,14 +1378,14 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index cdf07f5c8d1..1bab64e66e5 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -91,7 +91,7 @@ jobs: ) > build_constraints.txt - name: Upload env_file - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: env_file path: ./.env_file @@ -99,14 +99,14 @@ jobs: overwrite: true - name: Upload build_constraints - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: build_constraints path: ./build_constraints.txt overwrite: true - name: Upload requirements_diff - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: requirements_diff path: ./requirements_diff.txt @@ -118,7 +118,7 @@ jobs: python -m script.gen_requirements_all ci - name: Upload requirements_all_wheels - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.0 with: name: requirements_all_wheels path: ./requirements_all_wheels_*.txt From 81c390d3b813ff258b762eb5ddbc713221f18c2f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Jan 2025 12:32:30 +0100 Subject: [PATCH 0309/2987] Bump docker/build-push-action from 6.10.0 to 6.11.0 (#135254) --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 36aac442a0a..5394a09d9bc 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -509,7 +509,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0 + uses: docker/build-push-action@b32b51a8eda65d6793cd0494a773d4f6bcef32dc # v6.11.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -522,7 +522,7 @@ jobs: - name: Push Docker image if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' id: push - uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0 + uses: docker/build-push-action@b32b51a8eda65d6793cd0494a773d4f6bcef32dc # v6.11.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile From fd169affd707a8ce174e33bc2d28d7d185c09d4e Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sat, 11 Jan 2025 12:49:10 +0100 Subject: [PATCH 0310/2987] Remove code owner for nmbs (#135357) --- CODEOWNERS | 2 -- homeassistant/components/nmbs/manifest.json | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 96348b37246..a82296722c2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1024,8 +1024,6 @@ build.json @home-assistant/supervisor /homeassistant/components/nina/ @DeerMaximum /tests/components/nina/ @DeerMaximum /homeassistant/components/nissan_leaf/ @filcole -/homeassistant/components/nmbs/ @thibmaek -/tests/components/nmbs/ @thibmaek /homeassistant/components/noaa_tides/ @jdelaney72 /homeassistant/components/nobo_hub/ @echoromeo @oyvindwe /tests/components/nobo_hub/ @echoromeo @oyvindwe diff --git a/homeassistant/components/nmbs/manifest.json b/homeassistant/components/nmbs/manifest.json index 2cff1d89b79..9016eff11f8 100644 --- a/homeassistant/components/nmbs/manifest.json +++ b/homeassistant/components/nmbs/manifest.json @@ -1,7 +1,7 @@ { "domain": "nmbs", "name": "NMBS", - "codeowners": ["@thibmaek"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nmbs", "iot_class": "cloud_polling", From 907f1e062ac0c8fb3d8d5eb9ae9a0e710d615624 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 11 Jan 2025 12:51:56 +0100 Subject: [PATCH 0311/2987] =?UTF-8?q?Fix=20spelling=20of=20"Log=20in=20?= =?UTF-8?q?=E2=80=A6"=20and=20"API=20key"=20in=20LOQED=20integration=20(#1?= =?UTF-8?q?35347)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/loqed/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/loqed/strings.json b/homeassistant/components/loqed/strings.json index e4cd4b71045..38eae7eb7b2 100644 --- a/homeassistant/components/loqed/strings.json +++ b/homeassistant/components/loqed/strings.json @@ -3,7 +3,7 @@ "flow_title": "LOQED Touch Smartlock setup", "step": { "user": { - "description": "Login at LOQED's [personal access tokens portal]({config_url}) and: \n* Create an API-key by clicking 'Create' \n* Copy the created access token.", + "description": "Log in at LOQED's [personal access tokens portal]({config_url}) and: \n* Create an API key by clicking 'Create' \n* Copy the created access token.", "data": { "name": "Name of your lock in the LOQED app.", "api_token": "[%key:common::config_flow::data::api_token%]" From 74c3e9629f6153563de8a3fd4a27f0e20fe3499c Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 11 Jan 2025 12:52:40 +0100 Subject: [PATCH 0312/2987] Fix Tado config flow (#135353) --- homeassistant/components/tado/config_flow.py | 2 +- tests/components/tado/test_config_flow.py | 24 ++++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index c7bb7684901..efe138e2e6c 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -49,7 +49,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, tado = await hass.async_add_executor_job( Tado, data[CONF_USERNAME], data[CONF_PASSWORD] ) - tado_me = await hass.async_add_executor_job(tado.getMe) + tado_me = await hass.async_add_executor_job(tado.get_me) except KeyError as ex: raise InvalidAuth from ex except RuntimeError as ex: diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index 63b17dad13e..b4a5b196a39 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -23,12 +23,12 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -def _get_mock_tado_api(getMe=None) -> MagicMock: +def _get_mock_tado_api(get_me=None) -> MagicMock: mock_tado = MagicMock() - if isinstance(getMe, Exception): - type(mock_tado).getMe = MagicMock(side_effect=getMe) + if isinstance(get_me, Exception): + type(mock_tado).get_me = MagicMock(side_effect=get_me) else: - type(mock_tado).getMe = MagicMock(return_value=getMe) + type(mock_tado).get_me = MagicMock(return_value=get_me) return mock_tado @@ -61,7 +61,7 @@ async def test_form_exceptions( assert result["errors"] == {"base": error} # Test a retry to recover, upon failure - mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) with ( patch( @@ -131,7 +131,7 @@ async def test_create_entry(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) with ( patch( @@ -166,7 +166,9 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: response_mock = MagicMock() type(response_mock).status_code = HTTPStatus.UNAUTHORIZED - mock_tado_api = _get_mock_tado_api(getMe=requests.HTTPError(response=response_mock)) + mock_tado_api = _get_mock_tado_api( + get_me=requests.HTTPError(response=response_mock) + ) with patch( "homeassistant.components.tado.config_flow.Tado", @@ -189,7 +191,9 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: response_mock = MagicMock() type(response_mock).status_code = HTTPStatus.INTERNAL_SERVER_ERROR - mock_tado_api = _get_mock_tado_api(getMe=requests.HTTPError(response=response_mock)) + mock_tado_api = _get_mock_tado_api( + get_me=requests.HTTPError(response=response_mock) + ) with patch( "homeassistant.components.tado.config_flow.Tado", @@ -210,7 +214,7 @@ async def test_no_homes(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_tado_api = _get_mock_tado_api(getMe={"homes": []}) + mock_tado_api = _get_mock_tado_api(get_me={"homes": []}) with patch( "homeassistant.components.tado.config_flow.Tado", @@ -314,7 +318,7 @@ async def test_reconfigure_flow( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) with ( patch( "homeassistant.components.tado.config_flow.Tado", From 8e2b284a7ffb7b824f6a8b5d665c68a95674e0e9 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sat, 11 Jan 2025 13:04:37 +0100 Subject: [PATCH 0313/2987] Add more typings to nmbs sensor (#135359) --- homeassistant/components/nmbs/sensor.py | 45 ++++++++++++++++--------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 448dda73228..ffb539a39a0 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any from pyrail import iRail import voluptuous as vol @@ -180,31 +181,37 @@ class NMBSLiveBoard(SensorEntity): _attr_attribution = "https://api.irail.be/" - def __init__(self, api_client, live_station, station_from, station_to): + def __init__( + self, + api_client: iRail, + live_station: dict[str, Any], + station_from: dict[str, Any], + station_to: dict[str, Any], + ) -> None: """Initialize the sensor for getting liveboard data.""" self._station = live_station self._api_client = api_client self._station_from = station_from self._station_to = station_to - self._attrs = {} - self._state = None + self._attrs: dict[str, Any] | None = {} + self._state: str | None = None self.entity_registry_enabled_default = False @property - def name(self): + def name(self) -> str: """Return the sensor default name.""" return f"Trains in {self._station["standardname"]}" @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique ID.""" unique_id = f"{self._station}_{self._station_from}_{self._station_to}" return f"nmbs_live_{unique_id}" @property - def icon(self): + def icon(self) -> str: """Return the default icon or an alert icon if delays.""" if self._attrs and int(self._attrs["delay"]) > 0: return DEFAULT_ICON_ALERT @@ -212,12 +219,12 @@ class NMBSLiveBoard(SensorEntity): return DEFAULT_ICON @property - def native_value(self): + def native_value(self) -> str | None: """Return sensor state.""" return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the sensor attributes if data is available.""" if self._state is None or not self._attrs: return None @@ -270,8 +277,14 @@ class NMBSSensor(SensorEntity): _attr_native_unit_of_measurement = UnitOfTime.MINUTES def __init__( - self, api_client, name, show_on_map, station_from, station_to, excl_vias - ): + self, + api_client: iRail, + name: str, + show_on_map: bool, + station_from: dict[str, Any], + station_to: dict[str, Any], + excl_vias: bool, + ) -> None: """Initialize the NMBS connection sensor.""" self._name = name self._show_on_map = show_on_map @@ -280,7 +293,7 @@ class NMBSSensor(SensorEntity): self._station_to = station_to self._excl_vias = excl_vias - self._attrs = {} + self._attrs: dict[str, Any] | None = {} self._state = None @property @@ -298,7 +311,7 @@ class NMBSSensor(SensorEntity): return self._name @property - def icon(self): + def icon(self) -> str: """Return the sensor default icon or an alert icon if any delay.""" if self._attrs: delay = get_delay_in_minutes(self._attrs["departure"]["delay"]) @@ -308,7 +321,7 @@ class NMBSSensor(SensorEntity): return "mdi:train" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return sensor attributes if data is available.""" if self._state is None or not self._attrs: return None @@ -355,12 +368,12 @@ class NMBSSensor(SensorEntity): return attrs @property - def native_value(self): + def native_value(self) -> int | None: """Return the state of the device.""" return self._state @property - def station_coordinates(self): + def station_coordinates(self) -> list[float]: """Get the lat, long coordinates for station.""" if self._state is None or not self._attrs: return [] @@ -370,7 +383,7 @@ class NMBSSensor(SensorEntity): return [latitude, longitude] @property - def is_via_connection(self): + def is_via_connection(self) -> bool: """Return whether the connection goes through another station.""" if not self._attrs: return False From 4cf7a51a050513576000643001e62bb5f6515898 Mon Sep 17 00:00:00 2001 From: dotvav Date: Sat, 11 Jan 2025 13:24:00 +0100 Subject: [PATCH 0314/2987] Palazzetti Quality Scale update after doc improvement (#135277) --- .../components/palazzetti/quality_scale.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/palazzetti/quality_scale.yaml b/homeassistant/components/palazzetti/quality_scale.yaml index 493b2595117..c1c83be235c 100644 --- a/homeassistant/components/palazzetti/quality_scale.yaml +++ b/homeassistant/components/palazzetti/quality_scale.yaml @@ -15,7 +15,7 @@ rules: comment: | This integration does not register actions. docs-high-level-description: done - docs-installation-instructions: todo + docs-installation-instructions: done docs-removal-instructions: todo entity-event-setup: status: exempt @@ -35,7 +35,7 @@ rules: status: exempt comment: | This integration does not have configuration. - docs-installation-parameters: todo + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done @@ -51,12 +51,12 @@ rules: discovery-update-info: done discovery: done docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo + docs-examples: done + docs-known-limitations: done docs-supported-devices: todo docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: todo + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | From 20d6ba42860acf663ea574c35455fca0db8fd92a Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 12 Jan 2025 00:09:53 +1000 Subject: [PATCH 0315/2987] Bump Teslemetry Stream (#135344) bump --- homeassistant/components/teslemetry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index cf81f3bc521..35f974bd95c 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.2", "teslemetry-stream==0.5.3"] + "requirements": ["tesla-fleet-api==0.9.2", "teslemetry-stream==0.6.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 01b59dfcd7e..ba852635bc5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2853,7 +2853,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.5.3 +teslemetry-stream==0.6.3 # homeassistant.components.tessie tessie-api==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb9db5d1ca7..ec2516bca99 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2293,7 +2293,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.5.3 +teslemetry-stream==0.6.3 # homeassistant.components.tessie tessie-api==0.1.1 From 19f460614e6defd60e12f63304e71451a2ab259a Mon Sep 17 00:00:00 2001 From: Markus Lanthaler Date: Sat, 11 Jan 2025 15:29:31 +0100 Subject: [PATCH 0316/2987] Enable slowly-changing, important diagnostics for connected devices by default (#134776) --- homeassistant/components/netgear/sensor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 72087dd28db..4751e58a6a1 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -47,18 +47,21 @@ SENSOR_TYPES = { key="type", translation_key="link_type", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "link_rate": SensorEntityDescription( key="link_rate", translation_key="link_rate", native_unit_of_measurement="Mbps", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "signal": SensorEntityDescription( key="signal", translation_key="signal_strength", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ssid": SensorEntityDescription( key="ssid", @@ -69,6 +72,7 @@ SENSOR_TYPES = { key="conn_ap_mac", translation_key="access_point_mac", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), } @@ -326,8 +330,6 @@ async def async_setup_entry( class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity): """Representation of a device connected to a Netgear router.""" - _attr_entity_registry_enabled_default = False - def __init__( self, coordinator: DataUpdateCoordinator, From a745e079e9132075d2d661d41a4a3cd67b9561c5 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 11 Jan 2025 17:16:35 +0200 Subject: [PATCH 0317/2987] Add reconfigure to LG webOS TV (#135360) Co-authored-by: Joost Lekkerkerker --- .../components/webostv/config_flow.py | 61 +++++++-- .../components/webostv/quality_scale.yaml | 6 +- homeassistant/components/webostv/strings.json | 20 ++- tests/components/webostv/test_config_flow.py | 124 +++++++++++++++--- tests/components/webostv/test_init.py | 17 +-- tests/components/webostv/test_notify.py | 28 ++-- 6 files changed, 184 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index f3fbd3e0610..3bf3bc82dc1 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -20,7 +20,7 @@ from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from . import async_control_connect, update_client_key +from . import async_control_connect from .const import CONF_SOURCES, DEFAULT_NAME, DOMAIN, WEBOSTV_EXCEPTIONS from .helpers import async_get_sources @@ -53,14 +53,11 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - errors: dict[str, str] = {} if user_input is not None: self._host = user_input[CONF_HOST] return await self.async_step_pairing() - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors - ) + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) async def async_step_pairing( self, user_input: dict[str, Any] | None = None @@ -69,13 +66,13 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_HOST: self._host}) self.context["title_placeholders"] = {"name": self._name} - errors = {} + errors: dict[str, str] = {} if user_input is not None: try: client = await async_control_connect(self._host, None) except WebOsTvPairError: - return self.async_abort(reason="error_pairing") + errors["base"] = "error_pairing" except WEBOSTV_EXCEPTIONS: errors["base"] = "cannot_connect" else: @@ -130,20 +127,56 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + if user_input is not None: try: client = await async_control_connect(self._host, None) except WebOsTvPairError: - return self.async_abort(reason="error_pairing") + errors["base"] = "error_pairing" except WEBOSTV_EXCEPTIONS: - return self.async_abort(reason="reauth_unsuccessful") + errors["base"] = "cannot_connect" + else: + reauth_entry = self._get_reauth_entry() + data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key} + return self.async_update_reload_and_abort(reauth_entry, data=data) - reauth_entry = self._get_reauth_entry() - update_client_key(self.hass, reauth_entry, client) - await self.hass.config_entries.async_reload(reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") + return self.async_show_form(step_id="reauth_confirm", errors=errors) - return self.async_show_form(step_id="reauth_confirm") + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + host = user_input[CONF_HOST] + client_key = reconfigure_entry.data.get(CONF_CLIENT_SECRET) + + try: + client = await async_control_connect(host, client_key) + except WebOsTvPairError: + errors["base"] = "error_pairing" + except WEBOSTV_EXCEPTIONS: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(client.hello_info["deviceUUID"]) + self._abort_if_unique_id_mismatch(reason="wrong_device") + data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key} + return self.async_update_reload_and_abort(reconfigure_entry, data=data) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, default=reconfigure_entry.data.get(CONF_HOST) + ): cv.string + } + ), + errors=errors, + ) class OptionsFlowHandler(OptionsFlow): diff --git a/homeassistant/components/webostv/quality_scale.yaml b/homeassistant/components/webostv/quality_scale.yaml index 22c0b4155ab..3a31c20f256 100644 --- a/homeassistant/components/webostv/quality_scale.yaml +++ b/homeassistant/components/webostv/quality_scale.yaml @@ -7,9 +7,7 @@ rules: status: exempt comment: The integration does not use common patterns. config-flow-test-coverage: done - config-flow: - status: todo - comment: make reauth flow more graceful + config-flow: done dependency-transparency: done docs-actions: status: todo @@ -66,7 +64,7 @@ rules: icon-translations: status: exempt comment: The only entity can use the device class. - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: The integration does not have anything to repair. diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index 34c1b44e195..b0786bd06de 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -8,7 +8,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "Hostname or IP address of your webOS TV." + "host": "Hostname or IP address of your LG webOS TV." } }, "pairing": { @@ -18,17 +18,26 @@ "reauth_confirm": { "title": "[%key:component::webostv::config::step::pairing::title%]", "description": "[%key:component::webostv::config::step::pairing::description%]" + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::webostv::config::step::user::data_description::host%]" + } } }, "error": { - "cannot_connect": "Failed to connect, please turn on your TV or check the IP address" + "cannot_connect": "Failed to connect, please turn on your TV and try again.", + "error_pairing": "Pairing failed, make sure to accept the pairing request on the TV and try again." }, "abort": { - "error_pairing": "Connected to LG webOS TV but not paired", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reauth_unsuccessful": "Re-authentication was unsuccessful, please turn on your TV and try again." + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "wrong_device": "The configured device is not the same found on this Hostname or IP address." } }, "options": { @@ -38,6 +47,9 @@ "description": "Select enabled sources", "data": { "sources": "Sources list" + }, + "data_description": { + "sources": "List of sources to enable" } } }, diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index 608e3bd306a..c8ac54be4bd 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -1,7 +1,5 @@ """Test the WebOS Tv config flow.""" -from unittest.mock import AsyncMock - from aiowebostv import WebOsTvPairError import pytest @@ -105,7 +103,7 @@ async def test_options_flow_cannot_retrieve(hass: HomeAssistant, client) -> None """Test options config flow cannot retrieve sources.""" entry = await setup_webostv(hass) - client.connect = AsyncMock(side_effect=ConnectionRefusedError()) + client.connect.side_effect = ConnectionRefusedError result = await hass.config_entries.options.async_init(entry.entry_id) await hass.async_block_till_done() @@ -113,7 +111,7 @@ async def test_options_flow_cannot_retrieve(hass: HomeAssistant, client) -> None assert result["errors"] == {"base": "cannot_retrieve"} # recover - client.connect = AsyncMock(return_value=True) + client.connect.side_effect = None result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=None, @@ -139,7 +137,7 @@ async def test_form_cannot_connect(hass: HomeAssistant, client) -> None: data=MOCK_USER_CONFIG, ) - client.connect = AsyncMock(side_effect=ConnectionRefusedError()) + client.connect.side_effect = ConnectionRefusedError result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) @@ -148,7 +146,7 @@ async def test_form_cannot_connect(hass: HomeAssistant, client) -> None: assert result["errors"] == {"base": "cannot_connect"} # recover - client.connect = AsyncMock(return_value=True) + client.connect.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) @@ -165,13 +163,22 @@ async def test_form_pairexception(hass: HomeAssistant, client) -> None: data=MOCK_USER_CONFIG, ) - client.connect = AsyncMock(side_effect=WebOsTvPairError("error")) + client.connect.side_effect = WebOsTvPairError result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "error_pairing" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "error_pairing"} + + # recover + client.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TV_NAME async def test_entry_already_configured(hass: HomeAssistant, client) -> None: @@ -267,9 +274,7 @@ async def test_form_abort_uuid_configured(hass: HomeAssistant, client) -> None: assert entry.data[CONF_HOST] == "new_host" -async def test_reauth_successful( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_reauth_successful(hass: HomeAssistant, client) -> None: """Test that the reauthorization is successful.""" entry = await setup_webostv(hass) @@ -282,7 +287,7 @@ async def test_reauth_successful( assert result["step_id"] == "reauth_confirm" assert entry.data[CONF_CLIENT_SECRET] == CLIENT_KEY - monkeypatch.setattr(client, "client_key", "new_key") + client.client_key = "new_key" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) @@ -293,15 +298,13 @@ async def test_reauth_successful( @pytest.mark.parametrize( - ("side_effect", "reason"), + ("side_effect", "error"), [ (WebOsTvPairError, "error_pairing"), - (ConnectionRefusedError, "reauth_unsuccessful"), + (ConnectionRefusedError, "cannot_connect"), ], ) -async def test_reauth_errors( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch, side_effect, reason -) -> None: +async def test_reauth_errors(hass: HomeAssistant, client, side_effect, error) -> None: """Test reauthorization errors.""" entry = await setup_webostv(hass) @@ -318,5 +321,88 @@ async def test_reauth_errors( result["flow_id"], user_input={} ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + client.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.ABORT - assert result["reason"] == reason + assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_successful(hass: HomeAssistant, client) -> None: + """Test that the reconfigure is successful.""" + entry = await setup_webostv(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "new_host" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (WebOsTvPairError, "error_pairing"), + (ConnectionRefusedError, "cannot_connect"), + ], +) +async def test_reconfigure_errors( + hass: HomeAssistant, client, side_effect, error +) -> None: + """Test reconfigure errors.""" + entry = await setup_webostv(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + client.connect.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + client.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_wrong_device(hass: HomeAssistant, client) -> None: + """Test abort if reconfigure host is wrong webOS TV device.""" + entry = await setup_webostv(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + client.hello_info = {"deviceUUID": "wrong_uuid"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" diff --git a/tests/components/webostv/test_init.py b/tests/components/webostv/test_init.py index ba755d80b30..cd8f443c8fd 100644 --- a/tests/components/webostv/test_init.py +++ b/tests/components/webostv/test_init.py @@ -1,9 +1,6 @@ """The tests for the LG webOS TV platform.""" -from unittest.mock import Mock - from aiowebostv import WebOsTvPairError -import pytest from homeassistant.components.media_player import ATTR_INPUT_SOURCE_LIST from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN @@ -15,12 +12,10 @@ from . import setup_webostv from .const import ENTITY_ID -async def test_reauth_setup_entry( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_reauth_setup_entry(hass: HomeAssistant, client) -> None: """Test reauth flow triggered by setup entry.""" - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - monkeypatch.setattr(client, "connect", Mock(side_effect=WebOsTvPairError)) + client.is_connected.return_value = False + client.connect.side_effect = WebOsTvPairError entry = await setup_webostv(hass) assert entry.state is ConfigEntryState.SETUP_ERROR @@ -37,11 +32,9 @@ async def test_reauth_setup_entry( assert flow["context"].get("entry_id") == entry.entry_id -async def test_key_update_setup_entry( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_key_update_setup_entry(hass: HomeAssistant, client) -> None: """Test key update from setup entry.""" - monkeypatch.setattr(client, "client_key", "new_key") + client.client_key = "new_key" entry = await setup_webostv(hass) assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/webostv/test_notify.py b/tests/components/webostv/test_notify.py index 2f29281a496..b12cd0c7c6c 100644 --- a/tests/components/webostv/test_notify.py +++ b/tests/components/webostv/test_notify.py @@ -1,6 +1,6 @@ """The tests for the WebOS TV notify platform.""" -from unittest.mock import Mock, call +from unittest.mock import call from aiowebostv import WebOsTvPairError import pytest @@ -74,14 +74,12 @@ async def test_notify(hass: HomeAssistant, client) -> None: ) -async def test_notify_not_connected( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_notify_not_connected(hass: HomeAssistant, client) -> None: """Test sending a message when client is not connected.""" await setup_webostv(hass) assert hass.services.has_service(NOTIFY_DOMAIN, SERVICE_NAME) - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) + client.is_connected.return_value = False await hass.services.async_call( NOTIFY_DOMAIN, SERVICE_NAME, @@ -99,16 +97,13 @@ async def test_notify_not_connected( async def test_icon_not_found( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - client, - monkeypatch: pytest.MonkeyPatch, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, client ) -> None: """Test notify icon not found error.""" await setup_webostv(hass) assert hass.services.has_service(NOTIFY_DOMAIN, SERVICE_NAME) - monkeypatch.setattr(client, "send_message", Mock(side_effect=FileNotFoundError)) + client.send_message.side_effect = FileNotFoundError await hass.services.async_call( NOTIFY_DOMAIN, SERVICE_NAME, @@ -134,19 +129,14 @@ async def test_icon_not_found( ], ) async def test_connection_errors( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - client, - monkeypatch: pytest.MonkeyPatch, - side_effect, - error, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, client, side_effect, error ) -> None: """Test connection errors scenarios.""" await setup_webostv(hass) assert hass.services.has_service("notify", SERVICE_NAME) - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - monkeypatch.setattr(client, "connect", Mock(side_effect=side_effect)) + client.is_connected.return_value = False + client.connect.side_effect = side_effect await hass.services.async_call( NOTIFY_DOMAIN, SERVICE_NAME, @@ -159,7 +149,7 @@ async def test_connection_errors( blocking=True, ) assert client.mock_calls[0] == call.connect() - assert client.connect.call_count == 1 + assert client.connect.call_count == 2 client.send_message.assert_not_called() assert error in caplog.text From 6dc9c6819fa1784b70a90ca56a3e2652089919b6 Mon Sep 17 00:00:00 2001 From: Jeff Terrace Date: Sat, 11 Jan 2025 13:30:51 -0500 Subject: [PATCH 0318/2987] Add @jterrace to onvif integration owners (#135398) --- CODEOWNERS | 4 ++-- homeassistant/components/onvif/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index a82296722c2..a8a53f8272d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1075,8 +1075,8 @@ build.json @home-assistant/supervisor /tests/components/onewire/ @garbled1 @epenet /homeassistant/components/onkyo/ @arturpragacz @eclair4151 /tests/components/onkyo/ @arturpragacz @eclair4151 -/homeassistant/components/onvif/ @hunterjm -/tests/components/onvif/ @hunterjm +/homeassistant/components/onvif/ @hunterjm @jterrace +/tests/components/onvif/ @hunterjm @jterrace /homeassistant/components/open_meteo/ @frenck /tests/components/open_meteo/ @frenck /homeassistant/components/openai_conversation/ @balloob diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 02ef16b6787..c4f030ebe9f 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -1,7 +1,7 @@ { "domain": "onvif", "name": "ONVIF", - "codeowners": ["@hunterjm"], + "codeowners": ["@hunterjm", "@jterrace"], "config_flow": true, "dependencies": ["ffmpeg"], "dhcp": [{ "registered_devices": true }], From c442935fdd4eb1147ca8236563b570c99e23ce3a Mon Sep 17 00:00:00 2001 From: YogevBokobza Date: Sat, 11 Jan 2025 21:01:10 +0200 Subject: [PATCH 0319/2987] Switcher runner child lock support (#133270) * Switcher runner child lock support * fix based on requested changes * Update homeassistant/components/switcher_kis/switch.py Co-authored-by: Joost Lekkerkerker * Fix --------- Co-authored-by: Shay Levy Co-authored-by: Joost Lekkerkerker --- .../components/switcher_kis/strings.json | 8 + .../components/switcher_kis/switch.py | 118 +++++++++- tests/components/switcher_kis/consts.py | 4 +- tests/components/switcher_kis/test_switch.py | 206 +++++++++++++++++- 4 files changed, 327 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json index 844cbb4ca98..e380711303d 100644 --- a/homeassistant/components/switcher_kis/strings.json +++ b/homeassistant/components/switcher_kis/strings.json @@ -63,6 +63,14 @@ "temperature": { "name": "Current temperature" } + }, + "switch": { + "child_lock": { + "name": "Child lock" + }, + "multi_child_lock": { + "name": "Child lock {cover_id}" + } } }, "services": { diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index ba0a99b4089..7d3d71a0615 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -4,14 +4,15 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any +from typing import Any, cast -from aioswitcher.api import Command -from aioswitcher.device import DeviceCategory, DeviceState +from aioswitcher.api import Command, ShutterChildLock +from aioswitcher.device import DeviceCategory, DeviceState, SwitcherShutter import voluptuous as vol from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -32,6 +33,7 @@ _LOGGER = logging.getLogger(__name__) API_CONTROL_DEVICE = "control_device" API_SET_AUTO_SHUTDOWN = "set_auto_shutdown" +API_SET_CHILD_LOCK = "set_shutter_child_lock" SERVICE_SET_AUTO_OFF_SCHEMA: VolDictType = { vol.Required(CONF_AUTO_OFF): cv.time_period_str, @@ -67,10 +69,28 @@ async def async_setup_entry( @callback def async_add_switch(coordinator: SwitcherDataUpdateCoordinator) -> None: """Add switch from Switcher device.""" + entities: list[SwitchEntity] = [] + if coordinator.data.device_type.category == DeviceCategory.POWER_PLUG: - async_add_entities([SwitcherPowerPlugSwitchEntity(coordinator)]) + entities.append(SwitcherPowerPlugSwitchEntity(coordinator)) elif coordinator.data.device_type.category == DeviceCategory.WATER_HEATER: - async_add_entities([SwitcherWaterHeaterSwitchEntity(coordinator)]) + entities.append(SwitcherWaterHeaterSwitchEntity(coordinator)) + elif coordinator.data.device_type.category in ( + DeviceCategory.SHUTTER, + DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT, + DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT, + ): + number_of_covers = len(cast(SwitcherShutter, coordinator.data).position) + if number_of_covers == 1: + entities.append( + SwitchereShutterChildLockSingleSwitchEntity(coordinator, 0) + ) + else: + entities.extend( + SwitchereShutterChildLockMultiSwitchEntity(coordinator, i) + for i in range(number_of_covers) + ) + async_add_entities(entities) config_entry.async_on_unload( async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_switch) @@ -154,3 +174,91 @@ class SwitcherWaterHeaterSwitchEntity(SwitcherBaseSwitchEntity): await self._async_call_api(API_CONTROL_DEVICE, Command.ON, timer_minutes) self.control_result = True self.async_write_ha_state() + + +class SwitchereShutterChildLockBaseSwitchEntity(SwitcherEntity, SwitchEntity): + """Representation of a Switcher shutter base switch entity.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + _attr_entity_category = EntityCategory.CONFIG + _attr_icon = "mdi:lock-open" + _cover_id: int + + def __init__(self, coordinator: SwitcherDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.control_result: bool | None = None + + @callback + def _handle_coordinator_update(self) -> None: + """When device updates, clear control result that overrides state.""" + self.control_result = None + super()._handle_coordinator_update() + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + if self.control_result is not None: + return self.control_result + + data = cast(SwitcherShutter, self.coordinator.data) + return bool(data.child_lock[self._cover_id] == ShutterChildLock.ON) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self._async_call_api( + API_SET_CHILD_LOCK, ShutterChildLock.ON, self._cover_id + ) + self.control_result = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._async_call_api( + API_SET_CHILD_LOCK, ShutterChildLock.OFF, self._cover_id + ) + self.control_result = False + self.async_write_ha_state() + + +class SwitchereShutterChildLockSingleSwitchEntity( + SwitchereShutterChildLockBaseSwitchEntity +): + """Representation of a Switcher runner child lock single switch entity.""" + + _attr_translation_key = "child_lock" + + def __init__( + self, + coordinator: SwitcherDataUpdateCoordinator, + cover_id: int, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._cover_id = cover_id + + self._attr_unique_id = ( + f"{coordinator.device_id}-{coordinator.mac_address}-child_lock" + ) + + +class SwitchereShutterChildLockMultiSwitchEntity( + SwitchereShutterChildLockBaseSwitchEntity +): + """Representation of a Switcher runner child lock multiple switch entity.""" + + _attr_translation_key = "multi_child_lock" + + def __init__( + self, + coordinator: SwitcherDataUpdateCoordinator, + cover_id: int, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._cover_id = cover_id + + self._attr_translation_placeholders = {"cover_id": str(cover_id + 1)} + self._attr_unique_id = ( + f"{coordinator.device_id}-{coordinator.mac_address}-{cover_id}-child_lock" + ) diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index defe970c674..57454e38062 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -91,8 +91,8 @@ DUMMY_POSITION = [54] DUMMY_POSITION_2 = [54, 54] DUMMY_DIRECTION = [ShutterDirection.SHUTTER_STOP] DUMMY_DIRECTION_2 = [ShutterDirection.SHUTTER_STOP, ShutterDirection.SHUTTER_STOP] -DUMMY_CHILD_LOCK = [ShutterChildLock.OFF] -DUMMY_CHILD_LOCK_2 = [ShutterChildLock.OFF, ShutterChildLock.OFF] +DUMMY_CHILD_LOCK = [ShutterChildLock.ON] +DUMMY_CHILD_LOCK_2 = [ShutterChildLock.ON, ShutterChildLock.ON] DUMMY_USERNAME = "email" DUMMY_TOKEN = "zvVvd7JxtN7CgvkD1Psujw==" DUMMY_LIGHT = [DeviceState.ON] diff --git a/tests/components/switcher_kis/test_switch.py b/tests/components/switcher_kis/test_switch.py index 9bfe11fe202..c20149de074 100644 --- a/tests/components/switcher_kis/test_switch.py +++ b/tests/components/switcher_kis/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from aioswitcher.api import Command, SwitcherBaseResponse +from aioswitcher.api import Command, ShutterChildLock, SwitcherBaseResponse from aioswitcher.device import DeviceState import pytest @@ -20,7 +20,20 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util import slugify from . import init_integration -from .consts import DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE +from .consts import ( + DUMMY_DUAL_SHUTTER_SINGLE_LIGHT_DEVICE as DEVICE3, + DUMMY_PLUG_DEVICE, + DUMMY_SHUTTER_DEVICE as DEVICE, + DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE as DEVICE2, + DUMMY_TOKEN as TOKEN, + DUMMY_USERNAME as USERNAME, + DUMMY_WATER_HEATER_DEVICE, +) + +ENTITY_ID = f"{SWITCH_DOMAIN}.{slugify(DEVICE.name)}_child_lock" +ENTITY_ID2 = f"{SWITCH_DOMAIN}.{slugify(DEVICE2.name)}_child_lock" +ENTITY_ID3 = f"{SWITCH_DOMAIN}.{slugify(DEVICE3.name)}_child_lock_1" +ENTITY_ID3_2 = f"{SWITCH_DOMAIN}.{slugify(DEVICE3.name)}_child_lock_2" @pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) @@ -137,3 +150,192 @@ async def test_switch_control_fail( mock_control_device.assert_called_once_with(Command.ON) state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ( + "device", + "entity_id", + "cover_id", + "child_lock_state", + ), + [ + ( + DEVICE, + ENTITY_ID, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE2, + ENTITY_ID2, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE3, + ENTITY_ID3, + 0, + [ShutterChildLock.OFF, ShutterChildLock.ON], + ), + ( + DEVICE3, + ENTITY_ID3_2, + 1, + [ShutterChildLock.ON, ShutterChildLock.OFF], + ), + ], +) +@pytest.mark.parametrize("mock_bridge", [[DEVICE, DEVICE2, DEVICE3]], indirect=True) +async def test_child_lock_switch( + hass: HomeAssistant, + mock_bridge, + mock_api, + monkeypatch: pytest.MonkeyPatch, + device, + entity_id: str, + cover_id: int, + child_lock_state: list[ShutterChildLock], +) -> None: + """Test the switch.""" + await init_integration(hass, USERNAME, TOKEN) + assert mock_bridge + + # Test initial state - on + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test state change on --> off + monkeypatch.setattr(device, "child_lock", child_lock_state) + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test turning on child lock + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock", + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ShutterChildLock.ON, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test turning off + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock" + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(ShutterChildLock.OFF, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ( + "device", + "entity_id", + "cover_id", + "child_lock_state", + ), + [ + ( + DEVICE, + ENTITY_ID, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE2, + ENTITY_ID2, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE3, + ENTITY_ID3, + 0, + [ShutterChildLock.OFF, ShutterChildLock.ON], + ), + ( + DEVICE3, + ENTITY_ID3_2, + 1, + [ShutterChildLock.ON, ShutterChildLock.OFF], + ), + ], +) +@pytest.mark.parametrize("mock_bridge", [[DEVICE, DEVICE2, DEVICE3]], indirect=True) +async def test_child_lock_control_fail( + hass: HomeAssistant, + mock_bridge, + mock_api, + monkeypatch: pytest.MonkeyPatch, + device, + entity_id: str, + cover_id: int, + child_lock_state: list[ShutterChildLock], +) -> None: + """Test switch control fail.""" + await init_integration(hass, USERNAME, TOKEN) + assert mock_bridge + + # Test initial state - off + monkeypatch.setattr(device, "child_lock", child_lock_state) + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test exception during turn on + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock", + side_effect=RuntimeError("fake error"), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ShutterChildLock.ON, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + # Make device available again + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test error response during turn on + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock", + return_value=SwitcherBaseResponse(None), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(ShutterChildLock.ON, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE From 2237ed9af7b897cee2b71980ddaf8a3d053885a3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 11 Jan 2025 20:44:59 +0100 Subject: [PATCH 0320/2987] Make all three numbered lists consistent, using 1. 1. 1. for the syntax (#135400) Make all three numbered lists use 1. 1. 1. for the syntax Currently only two of the setup descriptions of the Nest integration use automatic syntax for a numbered list. This commit makes the third one consistent, using 1. 1. 1. as well. This helps translators in Lokalise understand that this is the expected format for all numbered lists in Home Assistant. --- homeassistant/components/nest/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index a31a2856544..e6d0dfce2d4 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -27,7 +27,7 @@ }, "pubsub": { "title": "Configure Google Cloud Pub/Sub", - "description": "Home Assistant uses Cloud Pub/Sub receive realtime Nest device updates. Nest servers publish updates to a Pub/Sub topic and Home Assistant receives the updates through a Pub/Sub subscription.\n\n1. Visit the [Device Access Console]({device_access_console_url}) and ensure a Pub/Sub topic is configured.\n2. Visit the [Cloud Console]({url}) to find your Google Cloud Project ID and confirm it is correct below.\n3. The next step will attempt to auto-discover Pub/Sub topics and subscriptions.\n\nSee the integration documentation for [more info]({more_info_url}).", + "description": "Home Assistant uses Cloud Pub/Sub receive realtime Nest device updates. Nest servers publish updates to a Pub/Sub topic and Home Assistant receives the updates through a Pub/Sub subscription.\n\n1. Visit the [Device Access Console]({device_access_console_url}) and ensure a Pub/Sub topic is configured.\n1. Visit the [Cloud Console]({url}) to find your Google Cloud Project ID and confirm it is correct below.\n1. The next step will attempt to auto-discover Pub/Sub topics and subscriptions.\n\nSee the integration documentation for [more info]({more_info_url}).", "data": { "cloud_project_id": "[%key:component::nest::config::step::cloud_project::data::cloud_project_id%]" } From 6571ebf15bf5fc10fa03ef692a4ae177c06f88c4 Mon Sep 17 00:00:00 2001 From: Jeff Terrace Date: Sat, 11 Jan 2025 14:52:46 -0500 Subject: [PATCH 0321/2987] Add additional Tapo ONVIF Person/Vehicle/Line/Tamper/Intrusion events (#135399) --- homeassistant/components/onvif/parsers.py | 88 +++-- tests/components/onvif/test_parsers.py | 384 +++++++++++++++++++++- 2 files changed, 436 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index d7bbaa4fb3f..9904a4bbfa9 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine +import dataclasses import datetime from typing import Any @@ -370,22 +371,56 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: return None -@PARSERS.register("tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent") +_TAPO_EVENT_TEMPLATES: dict[str, Event] = { + "IsVehicle": Event( + uid="", + name="Vehicle Detection", + platform="binary_sensor", + device_class="motion", + ), + "IsPeople": Event( + uid="", name="Person Detection", platform="binary_sensor", device_class="motion" + ), + "IsLineCross": Event( + uid="", + name="Line Detector Crossed", + platform="binary_sensor", + device_class="motion", + ), + "IsTamper": Event( + uid="", name="Tamper Detection", platform="binary_sensor", device_class="tamper" + ), + "IsIntrusion": Event( + uid="", + name="Intrusion Detection", + platform="binary_sensor", + device_class="safety", + ), +} + + +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Intrusion") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/LineCross") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/People") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Tamper") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/TpSmartEvent") @PARSERS.register("tns1:RuleEngine/PeopleDetector/People") +@PARSERS.register("tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent") async def async_parse_tplink_detector(uid: str, msg) -> Event | None: """Handle parsing tplink smart event messages. - Topic: tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent + Topic: tns1:RuleEngine/CellMotionDetector/Intrusion + Topic: tns1:RuleEngine/CellMotionDetector/LineCross + Topic: tns1:RuleEngine/CellMotionDetector/People + Topic: tns1:RuleEngine/CellMotionDetector/Tamper + Topic: tns1:RuleEngine/CellMotionDetector/TpSmartEvent Topic: tns1:RuleEngine/PeopleDetector/People + Topic: tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent """ - video_source = "" - video_analytics = "" - rule = "" - topic = "" - vehicle = False - person = False - enabled = False try: + video_source = "" + video_analytics = "" + rule = "" topic, payload = extract_message(msg) for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": @@ -396,34 +431,19 @@ async def async_parse_tplink_detector(uid: str, msg) -> Event | None: rule = source.Value for item in payload.Data.SimpleItem: - if item.Name == "IsVehicle": - vehicle = True - enabled = item.Value == "true" - if item.Name == "IsPeople": - person = True - enabled = item.Value == "true" + event_template = _TAPO_EVENT_TEMPLATES.get(item.Name, None) + if event_template is None: + continue + + return dataclasses.replace( + event_template, + uid=f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + value=item.Value == "true", + ) + except (AttributeError, KeyError): return None - if vehicle: - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Vehicle Detection", - "binary_sensor", - "motion", - None, - enabled, - ) - if person: - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Person Detection", - "binary_sensor", - "motion", - None, - enabled, - ) - return None diff --git a/tests/components/onvif/test_parsers.py b/tests/components/onvif/test_parsers.py index 209e7cbccef..16172112c11 100644 --- a/tests/components/onvif/test_parsers.py +++ b/tests/components/onvif/test_parsers.py @@ -119,7 +119,83 @@ async def test_line_detector_crossed(hass: HomeAssistant) -> None: ) -async def test_tapo_vehicle(hass: HomeAssistant) -> None: +async def test_tapo_line_crossed(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/LineCross.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/LineCross", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyLineCrossDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsLineCross", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 3, 21, 5, 14, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Line Detector Crossed" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "LineCross_VideoSourceToken_VideoAnalyticsToken_MyLineCrossDetectorRule" + ) + + +async def test_tapo_tpsmartevent_vehicle(hass: HomeAssistant) -> None: """Tests tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - vehicle.""" event = await get_event( { @@ -198,7 +274,83 @@ async def test_tapo_vehicle(hass: HomeAssistant) -> None: ) -async def test_tapo_person(hass: HomeAssistant) -> None: +async def test_tapo_cellmotiondetector_vehicle(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/TpSmartEvent - vehicle.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/TpSmartEvent", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyTPSmartEventDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsVehicle", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 5, 14, 2, 9, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Vehicle Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "TpSmartEvent_VideoSourceToken_VideoAnalyticsToken_MyTPSmartEventDetectorRule" + ) + + +async def test_tapo_tpsmartevent_person(hass: HomeAssistant) -> None: """Tests tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - person.""" event = await get_event( { @@ -274,6 +426,234 @@ async def test_tapo_person(hass: HomeAssistant) -> None: ) +async def test_tapo_cellmotiondetector_person(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/People - person.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://192.168.56.63:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/People", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://192.168.56.63:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyPeopleDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsPeople", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 3, 20, 9, 22, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Person Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "People_VideoSourceToken_VideoAnalyticsToken_MyPeopleDetectorRule" + ) + + +async def test_tapo_tamper(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/Tamper - tamper.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/Tamper", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyTamperDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsTamper", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 5, 21, 1, 5, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Tamper Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "tamper" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "Tamper_VideoSourceToken_VideoAnalyticsToken_MyTamperDetectorRule" + ) + + +async def test_tapo_intrusion(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/Intrusion - intrusion.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://192.168.100.155:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/Intrusion", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://192.168.100.155:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyIntrusionDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsIntrusion", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 11, 10, 40, 45, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Intrusion Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "safety" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "Intrusion_VideoSourceToken_VideoAnalyticsToken_MyIntrusionDetectorRule" + ) + + async def test_tapo_missing_attributes(hass: HomeAssistant) -> None: """Tests async_parse_tplink_detector with missing fields.""" event = await get_event( From b3af12c9b17f7c08070e15546ad8aeaeaeec3628 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 11 Jan 2025 21:15:41 +0100 Subject: [PATCH 0322/2987] Reword action descriptions for better translations in Teslemetry (#135370) Slightly reword action descriptions for better translations Currently only one of the action descriptions in the Teslemetry integration uses the descriptive form of third person plural. This commit changes the remaining descriptions to adopt the same language and changes "the" to "a" as the actual action target is defined below that in the UI. --- homeassistant/components/teslemetry/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 4f4bc2ae60c..736762dc6f4 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -610,7 +610,7 @@ }, "services": { "navigation_gps_request": { - "description": "Set vehicle navigation to the provided latitude/longitude coordinates.", + "description": "Sets vehicle navigation to the provided latitude/longitude coordinates.", "fields": { "device_id": { "description": "Vehicle to share to.", @@ -646,7 +646,7 @@ "name": "Set scheduled charging" }, "set_scheduled_departure": { - "description": "Sets a time at which departure should be completed.", + "description": "Sets the departure time for a vehicle to schedule charging and preconditioning.", "fields": { "departure_time": { "description": "Time to be preconditioned by.", @@ -684,7 +684,7 @@ "name": "Set scheduled departure" }, "speed_limit": { - "description": "Activate the speed limit of the vehicle.", + "description": "Activates the speed limit of a vehicle.", "fields": { "device_id": { "description": "Vehicle to limit.", @@ -702,7 +702,7 @@ "name": "Set speed limit" }, "time_of_use": { - "description": "Update the time of use settings for the energy site.", + "description": "Updates the time of use settings for an energy site.", "fields": { "device_id": { "description": "Energy Site to configure.", @@ -716,7 +716,7 @@ "name": "Time of use settings" }, "valet_mode": { - "description": "Activate the valet mode of the vehicle.", + "description": "Activates the valet mode of a vehicle.", "fields": { "device_id": { "description": "Vehicle to limit.", From 0d85f54e76580a7b28e645874998be5945d02246 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 11 Jan 2025 21:31:36 +0100 Subject: [PATCH 0323/2987] Add sensors for inventory items to Habitica (#135331) Add sensors for inventory items --- homeassistant/components/habitica/icons.json | 21 ++ homeassistant/components/habitica/sensor.py | 45 +++- .../components/habitica/strings.json | 20 ++ homeassistant/components/habitica/util.py | 11 + .../components/habitica/fixtures/content.json | 199 +++++++++++++- tests/components/habitica/fixtures/user.json | 22 ++ .../habitica/snapshots/test_sensor.ambr | 253 ++++++++++++++++++ 7 files changed, 566 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index b74600a2789..6ae6ebd728b 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -138,6 +138,27 @@ }, "constitution": { "default": "mdi:run-fast" + }, + "food_total": { + "default": "mdi:candy", + "state": { + "0": "mdi:candy-off" + } + }, + "eggs_total": { + "default": "mdi:egg", + "state": { + "0": "mdi:egg-off" + } + }, + "hatching_potions_total": { + "default": "mdi:flask-round-bottom" + }, + "saddle": { + "default": "mdi:horse" + }, + "quest_scrolls": { + "default": "mdi:script-text-outline" } }, "switch": { diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 2bcb534af42..b42ffa68dc9 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -29,7 +29,7 @@ from homeassistant.helpers.typing import StateType from .const import ASSETS_URL from .entity import HabiticaBase from .types import HabiticaConfigEntry -from .util import get_attribute_points, get_attributes_total +from .util import get_attribute_points, get_attributes_total, inventory_list _LOGGER = logging.getLogger(__name__) @@ -73,6 +73,11 @@ class HabiticaSensorEntity(StrEnum): INTELLIGENCE = "intelligence" CONSTITUTION = "constitution" PERCEPTION = "perception" + EGGS_TOTAL = "eggs_total" + HATCHING_POTIONS_TOTAL = "hatching_potions_total" + FOOD_TOTAL = "food_total" + SADDLE = "saddle" + QUEST_SCROLLS = "quest_scrolls" SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( @@ -179,6 +184,44 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( suggested_display_precision=0, native_unit_of_measurement="CON", ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.EGGS_TOTAL, + translation_key=HabiticaSensorEntity.EGGS_TOTAL, + value_fn=lambda user, _: sum(n for n in user.items.eggs.values()), + entity_picture="Pet_Egg_Egg.png", + attributes_fn=lambda user, content: inventory_list(user, content, "eggs"), + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.HATCHING_POTIONS_TOTAL, + translation_key=HabiticaSensorEntity.HATCHING_POTIONS_TOTAL, + value_fn=lambda user, _: sum(n for n in user.items.hatchingPotions.values()), + entity_picture="Pet_HatchingPotion_RoyalPurple.png", + attributes_fn=( + lambda user, content: inventory_list(user, content, "hatchingPotions") + ), + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.FOOD_TOTAL, + translation_key=HabiticaSensorEntity.FOOD_TOTAL, + value_fn=( + lambda user, _: sum(n for k, n in user.items.food.items() if k != "Saddle") + ), + entity_picture="Pet_Food_Strawberry.png", + attributes_fn=lambda user, content: inventory_list(user, content, "food"), + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.SADDLE, + translation_key=HabiticaSensorEntity.SADDLE, + value_fn=lambda user, _: user.items.food.get("Saddle", 0), + entity_picture="Pet_Food_Saddle.png", + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.QUEST_SCROLLS, + translation_key=HabiticaSensorEntity.QUEST_SCROLLS, + value_fn=(lambda user, _: sum(n for n in user.items.quests.values())), + entity_picture="inventory_quest_scroll_dustbunnies.png", + attributes_fn=lambda user, content: inventory_list(user, content, "quests"), + ), ) diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index b4925861d67..fc6d6aee687 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -310,6 +310,26 @@ "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::buffs::name%]" } } + }, + "eggs_total": { + "name": "Eggs", + "unit_of_measurement": "eggs" + }, + "hatching_potions_total": { + "name": "Hatching potions", + "unit_of_measurement": "potions" + }, + "food_total": { + "name": "Pet food", + "unit_of_measurement": "foods" + }, + "saddle": { + "name": "Saddles", + "unit_of_measurement": "saddles" + }, + "quest_scrolls": { + "name": "Quest scrolls", + "unit_of_measurement": "scrolls" } }, "switch": { diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 4c1e54639d0..0a7c861eb7e 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -159,3 +159,14 @@ def get_attributes_total(user: UserData, content: ContentData, attribute: str) - return floor( sum(value for value in get_attribute_points(user, content, attribute).values()) ) + + +def inventory_list( + user: UserData, content: ContentData, item_type: str +) -> dict[str, int]: + """List inventory items of given type.""" + return { + getattr(content, item_type)[k].text: v + for k, v in getattr(user.items, item_type, {}).items() + if k != "Saddle" + } diff --git a/tests/components/habitica/fixtures/content.json b/tests/components/habitica/fixtures/content.json index b4458aa647a..e26dbeb17cc 100644 --- a/tests/components/habitica/fixtures/content.json +++ b/tests/components/habitica/fixtures/content.json @@ -370,7 +370,109 @@ "animalSetAchievements": {}, "stableAchievements": {}, "petSetCompleteAchievs": [], - "quests": {}, + "quests": { + "atom1": { + "text": "Angriff des Banalen, Teil 1: Abwasch-Katastrophe!", + "notes": "Du erreichst die Ufer des Waschbeckensees für eine wohlverdiente Auszeit ... Aber der See ist verschmutzt mit nicht abgespültem Geschirr! Wie ist das passiert? Wie auch immer, Du kannst den See jedenfalls nicht in diesem Zustand lassen. Es gibt nur eine Sache die Du tun kannst: Abspülen und den Ferienort retten! Dazu musst Du aber Seife für den Abwasch finden. Viel Seife ...", + "completion": "Nach gründlichem Schrubben ist das Geschirr sicher am Ufer gestapelt! Du trittst zurück und begutachtest stolz Deiner Hände Arbeit.", + "group": "questGroupAtom", + "prerequisite": { + "lvl": 15 + }, + "value": 4, + "lvl": 15, + "category": "unlockable", + "collect": { + "soapBars": { + "text": "Seifenstücke", + "count": 20 + } + }, + "drop": { + "items": [ + { + "type": "quests", + "key": "atom2", + "text": "Das Monster vom KochLess (Schriftrolle)", + "onlyOwner": true + } + ], + "gp": 7, + "exp": 50 + }, + "key": "atom1" + }, + "goldenknight1": { + "text": "Die goldene Ritterin, Teil 1: Ein ernstes Gespräch", + "notes": "Die goldene Ritterin ist Habiticanern mit ihrer Kritik ganz schön auf die Nerven gegangen. Nicht alle Tagesaufgaben erledigt? Eine negative Gewohnheit angeklickt? Sie nimmt dies zum Anlass Dich zu bedrängen, dass Du doch ihrem Beispiel folgen sollst. Sie ist das leuchtende Beispiel eines perfekten Habiticaners und Du bist nichts als ein Versager. Das ist ja mal gar nicht nett! Jeder macht Fehler. Man sollte deshalb nicht mit solcher Kritik drangsaliert werden. Vielleicht solltest Du einige Zeugenaussagen von verletzten Habiticanern zusammentragen und die Goldene Ritterin mal ordentlich zurechtweisen!", + "completion": "Schau Dir nur all diese Zeugenaussagen an! Bestimmt wird das reichen, um die Goldene Ritterin zu überzeugen. Nun musst Du sie nur noch finden.", + "group": "questGroupGoldenknight", + "value": 4, + "lvl": 40, + "category": "unlockable", + "collect": { + "testimony": { + "text": "Zeugenaussagen", + "count": 60 + } + }, + "drop": { + "items": [ + { + "type": "quests", + "key": "goldenknight2", + "text": "Die goldene Ritterin Teil 2: Die goldene Ritterin (Schriftrolle)", + "onlyOwner": true + } + ], + "gp": 15, + "exp": 120 + }, + "key": "goldenknight1" + }, + "dustbunnies": { + "text": "Die ungezähmten Staubmäuse", + "notes": "Es ist schon etwas her, seit Du hier drinnen das letzte Mal Staub gewischt hast, aber Du sorgst dich nicht allzusehr - ein Wenig Staub hat noch nie jemandem geschadet, oder? Erst, als Du Deine Hand in eine der staubigsten Ecken steckst und einen Biss spürst, erinnerst du dich an @InspectorCaracals Warnung: Harmlosen Staub zu lange in Ruhe zu lassen, verwandelt ihn in boshafte Staubmäuse! Du solltest sie besser besiegen, bevor sie ganz Habitica mit feinen Schmutzpartikeln bedecken!", + "group": "questGroupEarnable", + "completion": "Die Staubmäuse verschwinden in einer Rauch-, äh… Staubwolke. Als sich der Staub legt, siehst du dich um. Du hast vergessen, wie hübsch es hier doch aussieht, wenn es sauber ist. Du erkennst einen kleinen Haufen Gold, wo der Staub vorher war. Huch, du hattest dich schon gefragt, wo er abgeblieben war!", + "value": 1, + "category": "unlockable", + "boss": { + "name": "Ungezähmte Staubmäuse", + "hp": 100, + "str": 0.5, + "def": 1 + }, + "drop": { + "gp": 8, + "exp": 42 + }, + "key": "dustbunnies" + }, + "basilist": { + "text": "Der Basi-List", + "notes": "Da ist ein Aufruhr auf dem Marktplatz – es sieht ganz so aus, als ob man lieber in die andere Richtung rennen sollte. Da Du aber ein mutiger Abenteurer bist, rennst Du stattdessen darauf zu und entdeckst einen Basi-List, der sich aus einem Haufen unerledigter Aufgaben geformt hat! Alle umstehenden Habiticaner sind aus Angst vor der Länge des Basi-Lists gelähmt und können nicht anfangen zu arbeiten. Von irgendwo in der Nähe hörst Du @Arcosine schreien: \"Schnell! Erledige Deine To-Dos und Tagesaufgaben, um dem Monster die Zähne zu entfernen, bevor sich jemand am Papier schneidet!\" Greife schnell an, Abenteurer, und hake etwas ab - aber Vorsicht! Wenn Du irgendwelche Tagesaufgaben nicht erledigst, wird der Basi-List Dich und Deine Party angreifen!", + "group": "questGroupEarnable", + "completion": "Der Basi-List ist in Papierschnitzel zerfallen, die sanft in Regenbogenfarben schimmern. \"Puh!\" sagt @Arcosine. \"Gut, dass ihr gerade hier wart!\" Du fühlst Dich erfahrener als vorher und sammelst ein paar verstreute Goldstücke zwischen den Papierstücken auf.", + "goldValue": 100, + "category": "unlockable", + "unlockCondition": { + "condition": "party invite", + "text": "Lade Freunde ein" + }, + "boss": { + "name": "Der Basi-List", + "hp": 100, + "str": 0.5, + "def": 1 + }, + "drop": { + "gp": 8, + "exp": 42 + }, + "key": "basilist" + } + }, "questsByLevel": {}, "userCanOwnQuestCategories": [], "itemList": { @@ -450,11 +552,61 @@ "special": {}, "dropEggs": {}, "questEggs": {}, - "eggs": {}, + "eggs": { + "Wolf": { + "text": "Wolfsjunges", + "mountText": "Wolfs-Reittier", + "adjective": "ein treues", + "value": 3, + "key": "Wolf", + "notes": "Finde ein Schlüpfelixier, das Du über dieses Ei gießen kannst, damit ein ein treues Wolfsjunges schlüpfen kann." + }, + "TigerCub": { + "text": "Tigerjunges", + "mountText": "Tiger-Reittier", + "adjective": "ein wildes", + "value": 3, + "key": "TigerCub", + "notes": "Finde ein Schlüpfelixier, das Du über dieses Ei gießen kannst, damit ein ein wildes Tigerjunges schlüpfen kann." + }, + "PandaCub": { + "text": "Pandajunges", + "mountText": "Panda-Reittier", + "adjective": "ein sanftes", + "value": 3, + "key": "PandaCub", + "notes": "Finde ein Schlüpfelixier, das Du über dieses Ei gießen kannst, damit ein ein sanftes Pandajunges schlüpfen kann." + } + }, "dropHatchingPotions": {}, "premiumHatchingPotions": {}, "wackyHatchingPotions": {}, - "hatchingPotions": {}, + "hatchingPotions": { + "Base": { + "value": 2, + "key": "Base", + "text": "Normales", + "notes": "Gieße dies über ein Ei und es wird ein Normales Haustier daraus schlüpfen.", + "premium": false, + "limited": false + }, + "White": { + "value": 2, + "key": "White", + "text": "Weißes", + "notes": "Gieße dies über ein Ei und es wird ein Weißes Haustier daraus schlüpfen.", + "premium": false, + "limited": false + }, + "Desert": { + "value": 2, + "key": "Desert", + "text": "Wüstenfarbenes", + "notes": "Gieße dies über ein Ei und es wird ein Wüstenfarbenes Haustier daraus schlüpfen.", + "premium": false, + "limited": false + } + }, "pets": {}, "premiumPets": {}, "questPets": {}, @@ -466,7 +618,46 @@ "questMounts": {}, "specialMounts": {}, "mountInfo": {}, - "food": {} + "food": { + "Meat": { + "text": "Fleisch", + "textA": "Fleisch", + "textThe": "das Fleisch", + "target": "Base", + "value": 1, + "key": "Meat", + "notes": "Verfüttere dies an ein Haustier und es wächst bald zu einem kräftigen Reittier heran.", + "canDrop": true + }, + "Milk": { + "text": "Milch", + "textA": "Milch", + "textThe": "die Milch", + "target": "White", + "value": 1, + "key": "Milk", + "notes": "Verfüttere dies an ein Haustier und es wächst bald zu einem kräftigen Reittier heran.", + "canDrop": true + }, + "Potatoe": { + "text": "Kartoffel", + "textA": "eine Kartoffel", + "textThe": "die Kartoffel", + "target": "Desert", + "value": 1, + "key": "Potatoe", + "notes": "Verfüttere dies an ein Haustier und es wächst bald zu einem kräftigen Reittier heran.", + "canDrop": true + }, + "Saddle": { + "sellWarningNote": "Hey! Das ist ein sehr nützlicher Gegenstand! Weißt Du, wie Du den Sattel mit Deinen Haustieren nutzt?", + "text": "Magischer Sattel", + "value": 5, + "notes": "Lässt eines Deiner Haustiere augenblicklich zum Reittier heranwachsen.", + "canDrop": false, + "key": "Saddle" + } + } }, "appVersion": "5.29.2" } diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index 876ea2550d3..255d9c7c3b5 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -112,6 +112,28 @@ "eyewear": "eyewear_armoire_plagueDoctorMask", "body": "body_special_aetherAmulet" } + }, + "quests": { + "atom1": 1, + "goldenknight1": 0, + "dustbunnies": 1, + "basilist": 0 + }, + "food": { + "Saddle": 2, + "Meat": 0, + "Milk": 1, + "Potatoe": 2 + }, + "hatchingPotions": { + "Base": 2, + "White": 0, + "Desert": 1 + }, + "eggs": { + "Wolf": 1, + "TigerCub": 0, + "PandaCub": 2 } }, "balance": 10, diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 7464a5fd36d..b217a1418b9 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -160,6 +160,57 @@ 'state': 'test-user', }) # --- +# name: test_sensors[sensor.test_user_eggs-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_eggs', + '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': 'Eggs', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_eggs_total', + 'unit_of_measurement': 'eggs', + }) +# --- +# name: test_sensors[sensor.test_user_eggs-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Pandajunges': 2, + 'Tigerjunges': 0, + 'Wolfsjunges': 1, + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_Egg_Egg.png', + 'friendly_name': 'test-user Eggs', + 'unit_of_measurement': 'eggs', + }), + 'context': , + 'entity_id': 'sensor.test_user_eggs', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_sensors[sensor.test_user_experience-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -514,6 +565,57 @@ 'state': '4', }) # --- +# name: test_sensors[sensor.test_user_hatching_potions-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_hatching_potions', + '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': 'Hatching potions', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_hatching_potions_total', + 'unit_of_measurement': 'potions', + }) +# --- +# name: test_sensors[sensor.test_user_hatching_potions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Normales': 2, + 'Weißes': 0, + 'Wüstenfarbenes': 1, + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_RoyalPurple.png', + 'friendly_name': 'test-user Hatching potions', + 'unit_of_measurement': 'potions', + }), + 'context': , + 'entity_id': 'sensor.test_user_hatching_potions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_sensors[sensor.test_user_health-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -962,6 +1064,109 @@ 'state': '75', }) # --- +# name: test_sensors[sensor.test_user_pet_food-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_pet_food', + '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': 'Pet food', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_food_total', + 'unit_of_measurement': 'foods', + }) +# --- +# name: test_sensors[sensor.test_user_pet_food-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Fleisch': 0, + 'Kartoffel': 2, + 'Milch': 1, + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_Food_Strawberry.png', + 'friendly_name': 'test-user Pet food', + 'unit_of_measurement': 'foods', + }), + 'context': , + 'entity_id': 'sensor.test_user_pet_food', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensors[sensor.test_user_quest_scrolls-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_quest_scrolls', + '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': 'Quest scrolls', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_quest_scrolls', + 'unit_of_measurement': 'scrolls', + }) +# --- +# name: test_sensors[sensor.test_user_quest_scrolls-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Angriff des Banalen, Teil 1: Abwasch-Katastrophe!': 1, + 'Der Basi-List': 0, + 'Die goldene Ritterin, Teil 1: Ein ernstes Gespräch': 0, + 'Die ungezähmten Staubmäuse': 1, + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/inventory_quest_scroll_dustbunnies.png', + 'friendly_name': 'test-user Quest scrolls', + 'unit_of_measurement': 'scrolls', + }), + 'context': , + 'entity_id': 'sensor.test_user_quest_scrolls', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- # name: test_sensors[sensor.test_user_rewards-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1048,6 +1253,54 @@ 'state': '1', }) # --- +# name: test_sensors[sensor.test_user_saddles-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_saddles', + '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': 'Saddles', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_saddle', + 'unit_of_measurement': 'saddles', + }) +# --- +# name: test_sensors[sensor.test_user_saddles-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_Food_Saddle.png', + 'friendly_name': 'test-user Saddles', + 'unit_of_measurement': 'saddles', + }), + 'context': , + 'entity_id': 'sensor.test_user_saddles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- # name: test_sensors[sensor.test_user_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 52c57eb2e5f61e8bf5c1421e8761b7641954021d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 11 Jan 2025 23:15:49 +0100 Subject: [PATCH 0324/2987] Actually use translated entity names in Lametric (#135381) --- homeassistant/components/lametric/number.py | 3 +-- homeassistant/components/lametric/strings.json | 8 ++++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index a1d922c2d80..0d299a2e93a 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -33,7 +33,6 @@ NUMBERS = [ LaMetricNumberEntityDescription( key="brightness", translation_key="brightness", - name="Brightness", entity_category=EntityCategory.CONFIG, native_step=1, native_min_value=0, @@ -45,11 +44,11 @@ NUMBERS = [ LaMetricNumberEntityDescription( key="volume", translation_key="volume", - name="Volume", entity_category=EntityCategory.CONFIG, native_step=1, native_min_value=0, native_max_value=100, + native_unit_of_measurement=PERCENTAGE, has_fn=lambda device: bool(device.audio and device.audio.available), value_fn=lambda device: device.audio.volume if device.audio else 0, set_value_fn=lambda api, volume: api.audio(volume=int(volume)), diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index 0fd6f5a12dc..01e7823c76b 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -66,6 +66,14 @@ "name": "Dismiss all notifications" } }, + "number": { + "brightness": { + "name": "Brightness" + }, + "volume": { + "name": "Volume" + } + }, "sensor": { "rssi": { "name": "Wi-Fi signal" From 11fa6b2e4e6386763d8914d0716272472d2df5c0 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 11 Jan 2025 23:06:06 -0600 Subject: [PATCH 0325/2987] Bump pyheos to 1.0.0 (#135415) --- homeassistant/components/heos/__init__.py | 118 ++++++++--------- homeassistant/components/heos/config_flow.py | 12 +- homeassistant/components/heos/manifest.json | 2 +- homeassistant/components/heos/media_player.py | 52 ++++---- homeassistant/components/heos/services.py | 11 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/heos/conftest.py | 122 +++++++++++------- tests/components/heos/test_config_flow.py | 16 +-- tests/components/heos/test_init.py | 14 +- tests/components/heos/test_media_player.py | 56 ++++---- tests/components/heos/test_services.py | 37 +----- 12 files changed, 217 insertions(+), 227 deletions(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 9fd276c244e..3b38e5c935a 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -6,6 +6,7 @@ import asyncio from dataclasses import dataclass from datetime import timedelta import logging +from typing import Any from pyheos import ( Credentials, @@ -13,6 +14,8 @@ from pyheos import ( HeosError, HeosOptions, HeosPlayer, + PlayerUpdateResult, + SignalHeosEvent, const as heos_const, ) @@ -98,14 +101,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool # Auth failure handler must be added before connecting to the host, otherwise # the event will be missed when login fails during connection. - async def auth_failure(event: str) -> None: + async def auth_failure() -> None: """Handle authentication failure.""" - if event == heos_const.EVENT_USER_CREDENTIALS_INVALID: - entry.async_start_reauth(hass) + entry.async_start_reauth(hass) - entry.async_on_unload( - controller.dispatcher.connect(heos_const.SIGNAL_HEOS_EVENT, auth_failure) - ) + entry.async_on_unload(controller.add_on_user_credentials_invalid(auth_failure)) try: # Auto reconnect only operates if initial connection was successful. @@ -168,11 +168,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> boo class ControllerManager: """Class that manages events of the controller.""" - def __init__(self, hass, controller): + def __init__(self, hass: HomeAssistant, controller: Heos) -> None: """Init the controller manager.""" self._hass = hass - self._device_registry = None - self._entity_registry = None + self._device_registry: dr.DeviceRegistry | None = None + self._entity_registry: er.EntityRegistry | None = None self.controller = controller async def connect_listeners(self): @@ -181,56 +181,59 @@ class ControllerManager: self._entity_registry = er.async_get(self._hass) # Handle controller events - self.controller.dispatcher.connect( - heos_const.SIGNAL_CONTROLLER_EVENT, self._controller_event - ) + self.controller.add_on_controller_event(self._controller_event) # Handle connection-related events - self.controller.dispatcher.connect( - heos_const.SIGNAL_HEOS_EVENT, self._heos_event - ) + self.controller.add_on_heos_event(self._heos_event) async def disconnect(self): """Disconnect subscriptions.""" self.controller.dispatcher.disconnect_all() await self.controller.disconnect() - async def _controller_event(self, event, data): + async def _controller_event( + self, event: str, data: PlayerUpdateResult | None + ) -> None: """Handle controller event.""" if event == heos_const.EVENT_PLAYERS_CHANGED: - self.update_ids(data[heos_const.DATA_MAPPED_IDS]) + assert data is not None + self.update_ids(data.updated_player_ids) # Update players async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) async def _heos_event(self, event): """Handle connection event.""" - if event == heos_const.EVENT_CONNECTED: + if event == SignalHeosEvent.CONNECTED: try: # Retrieve latest players and refresh status data = await self.controller.load_players() - self.update_ids(data[heos_const.DATA_MAPPED_IDS]) + self.update_ids(data.updated_player_ids) except HeosError as ex: _LOGGER.error("Unable to refresh players: %s", ex) # Update players + _LOGGER.debug("HEOS Controller event called, calling dispatcher") async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) def update_ids(self, mapped_ids: dict[int, int]): """Update the IDs in the device and entity registry.""" # mapped_ids contains the mapped IDs (new:old) - for new_id, old_id in mapped_ids.items(): + for old_id, new_id in mapped_ids.items(): # update device registry + assert self._device_registry is not None entry = self._device_registry.async_get_device( - identifiers={(DOMAIN, old_id)} + identifiers={(DOMAIN, old_id)} # type: ignore[arg-type] # Fix in the future ) new_identifiers = {(DOMAIN, new_id)} if entry: self._device_registry.async_update_device( - entry.id, new_identifiers=new_identifiers + entry.id, + new_identifiers=new_identifiers, # type: ignore[arg-type] # Fix in the future ) _LOGGER.debug( "Updated device %s identifiers to %s", entry.id, new_identifiers ) # update entity registry + assert self._entity_registry is not None entity_id = self._entity_registry.async_get_entity_id( Platform.MEDIA_PLAYER, DOMAIN, str(old_id) ) @@ -249,7 +252,7 @@ class GroupManager: ) -> None: """Init group manager.""" self._hass = hass - self._group_membership: dict[str, str] = {} + self._group_membership: dict[str, list[str]] = {} self._disconnect_player_added = None self._initialized = False self.controller = controller @@ -268,7 +271,7 @@ class GroupManager: } try: - groups = await self.controller.get_groups(refresh=True) + groups = await self.controller.get_groups() except HeosError as err: _LOGGER.error("Unable to get HEOS group info: %s", err) return group_info_by_entity_id @@ -326,29 +329,26 @@ class GroupManager: err, ) - async def async_update_groups(self, event, data=None): + async def async_update_groups(self) -> None: """Update the group membership from the controller.""" - if event in ( - heos_const.EVENT_GROUPS_CHANGED, - heos_const.EVENT_CONNECTED, - SIGNAL_HEOS_PLAYER_ADDED, - ): - if groups := await self.async_get_group_membership(): - self._group_membership = groups - _LOGGER.debug("Groups updated due to change event") - # Let players know to update - async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) - else: - _LOGGER.debug("Groups empty") + if groups := await self.async_get_group_membership(): + self._group_membership = groups + _LOGGER.debug("Groups updated due to change event") + # Let players know to update + async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) + else: + _LOGGER.debug("Groups empty") + @callback def connect_update(self): """Connect listener for when groups change and signal player update.""" - self.controller.dispatcher.connect( - heos_const.SIGNAL_CONTROLLER_EVENT, self.async_update_groups - ) - self.controller.dispatcher.connect( - heos_const.SIGNAL_HEOS_EVENT, self.async_update_groups - ) + + async def _on_controller_event(event: str, data: Any | None) -> None: + if event == heos_const.EVENT_GROUPS_CHANGED: + await self.async_update_groups() + + self.controller.add_on_controller_event(_on_controller_event) + self.controller.add_on_connected(self.async_update_groups) # When adding a new HEOS player we need to update the groups. async def _async_handle_player_added(): @@ -356,7 +356,7 @@ class GroupManager: # fully populated yet. This may only happen during early startup. if len(self.players) <= len(self.entity_id_map) and not self._initialized: self._initialized = True - await self.async_update_groups(SIGNAL_HEOS_PLAYER_ADDED) + await self.async_update_groups() self._disconnect_player_added = async_dispatcher_connect( self._hass, SIGNAL_HEOS_PLAYER_ADDED, _async_handle_player_added @@ -462,7 +462,8 @@ class SourceManager: None, ) - def connect_update(self, hass, controller): + @callback + def connect_update(self, hass: HomeAssistant, controller: Heos) -> None: """Connect listener for when sources change and signal player update. EVENT_SOURCES_CHANGED is often raised multiple times in response to a @@ -492,21 +493,22 @@ class SourceManager: else: return favorites, inputs - async def update_sources(event, data=None): + async def _update_sources() -> None: + # If throttled, it will return None + if sources := await get_sources(): + self.favorites, self.inputs = sources + self.source_list = self._build_source_list() + _LOGGER.debug("Sources updated due to changed event") + # Let players know to update + async_dispatcher_send(hass, SIGNAL_HEOS_UPDATED) + + async def _on_controller_event(event: str, data: Any | None) -> None: if event in ( heos_const.EVENT_SOURCES_CHANGED, heos_const.EVENT_USER_CHANGED, - heos_const.EVENT_CONNECTED, ): - # If throttled, it will return None - if sources := await get_sources(): - self.favorites, self.inputs = sources - self.source_list = self._build_source_list() - _LOGGER.debug("Sources updated due to changed event") - # Let players know to update - async_dispatcher_send(hass, SIGNAL_HEOS_UPDATED) + await _update_sources() - controller.dispatcher.connect( - heos_const.SIGNAL_CONTROLLER_EVENT, update_sources - ) - controller.dispatcher.connect(heos_const.SIGNAL_HEOS_EVENT, update_sources) + controller.add_on_connected(_update_sources) + controller.add_on_user_credentials_invalid(_update_sources) + controller.add_on_controller_event(_on_controller_event) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index c47d83d3475..d9b1b77a671 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -5,7 +5,7 @@ import logging from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlparse -from pyheos import CommandFailedError, Heos, HeosError, HeosOptions +from pyheos import CommandAuthenticationError, Heos, HeosError, HeosOptions import voluptuous as vol from homeassistant.components import ssdp @@ -79,13 +79,9 @@ async def _validate_auth( # Attempt to login (both username and password provided) try: await heos.sign_in(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) - except CommandFailedError as err: - if err.error_id in (6, 8, 10): # Auth-specific errors - errors["base"] = "invalid_auth" - _LOGGER.warning("Failed to sign-in to HEOS Account: %s", err) - else: - errors["base"] = "unknown" - _LOGGER.exception("Unexpected error occurred during sign-in") + except CommandAuthenticationError as err: + errors["base"] = "invalid_auth" + _LOGGER.warning("Failed to sign-in to HEOS Account: %s", err) return False except HeosError: errors["base"] = "unknown" diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index d14ad71ff49..6a631861b1c 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/heos", "iot_class": "local_push", "loggers": ["pyheos"], - "requirements": ["pyheos==0.9.0"], + "requirements": ["pyheos==1.0.0"], "single_config_entry": true, "ssdp": [ { diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 924dcbe6b92..981a39f53dc 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -8,7 +8,14 @@ import logging from operator import ior from typing import Any -from pyheos import HeosError, const as heos_const +from pyheos import ( + AddCriteriaType, + ControlType, + HeosError, + HeosPlayer, + PlayState, + const as heos_const, +) from homeassistant.components import media_source from homeassistant.components.media_player import ( @@ -47,25 +54,25 @@ BASE_SUPPORTED_FEATURES = ( ) PLAY_STATE_TO_STATE = { - heos_const.PlayState.PLAY: MediaPlayerState.PLAYING, - heos_const.PlayState.STOP: MediaPlayerState.IDLE, - heos_const.PlayState.PAUSE: MediaPlayerState.PAUSED, + PlayState.PLAY: MediaPlayerState.PLAYING, + PlayState.STOP: MediaPlayerState.IDLE, + PlayState.PAUSE: MediaPlayerState.PAUSED, } CONTROL_TO_SUPPORT = { - heos_const.CONTROL_PLAY: MediaPlayerEntityFeature.PLAY, - heos_const.CONTROL_PAUSE: MediaPlayerEntityFeature.PAUSE, - heos_const.CONTROL_STOP: MediaPlayerEntityFeature.STOP, - heos_const.CONTROL_PLAY_PREVIOUS: MediaPlayerEntityFeature.PREVIOUS_TRACK, - heos_const.CONTROL_PLAY_NEXT: MediaPlayerEntityFeature.NEXT_TRACK, + ControlType.PLAY: MediaPlayerEntityFeature.PLAY, + ControlType.PAUSE: MediaPlayerEntityFeature.PAUSE, + ControlType.STOP: MediaPlayerEntityFeature.STOP, + ControlType.PLAY_PREVIOUS: MediaPlayerEntityFeature.PREVIOUS_TRACK, + ControlType.PLAY_NEXT: MediaPlayerEntityFeature.NEXT_TRACK, } HA_HEOS_ENQUEUE_MAP = { - None: heos_const.AddCriteriaType.REPLACE_AND_PLAY, - MediaPlayerEnqueue.ADD: heos_const.AddCriteriaType.ADD_TO_END, - MediaPlayerEnqueue.REPLACE: heos_const.AddCriteriaType.REPLACE_AND_PLAY, - MediaPlayerEnqueue.NEXT: heos_const.AddCriteriaType.PLAY_NEXT, - MediaPlayerEnqueue.PLAY: heos_const.AddCriteriaType.PLAY_NOW, + None: AddCriteriaType.REPLACE_AND_PLAY, + MediaPlayerEnqueue.ADD: AddCriteriaType.ADD_TO_END, + MediaPlayerEnqueue.REPLACE: AddCriteriaType.REPLACE_AND_PLAY, + MediaPlayerEnqueue.NEXT: AddCriteriaType.PLAY_NEXT, + MediaPlayerEnqueue.PLAY: AddCriteriaType.PLAY_NOW, } _LOGGER = logging.getLogger(__name__) @@ -118,11 +125,14 @@ class HeosMediaPlayer(MediaPlayerEntity): _attr_name = None def __init__( - self, player, source_manager: SourceManager, group_manager: GroupManager + self, + player: HeosPlayer, + source_manager: SourceManager, + group_manager: GroupManager, ) -> None: """Initialize.""" self._media_position_updated_at = None - self._player = player + self._player: HeosPlayer = player self._source_manager = source_manager self._group_manager = group_manager self._attr_unique_id = str(player.player_id) @@ -134,10 +144,8 @@ class HeosMediaPlayer(MediaPlayerEntity): sw_version=player.version, ) - async def _player_update(self, player_id, event): + async def _player_update(self, event): """Handle player attribute updated.""" - if self._player.player_id != player_id: - return if event == heos_const.EVENT_PLAYER_NOW_PLAYING_PROGRESS: self._media_position_updated_at = utcnow() await self.async_update_ha_state(True) @@ -149,11 +157,7 @@ class HeosMediaPlayer(MediaPlayerEntity): async def async_added_to_hass(self) -> None: """Device added to hass.""" # Update state when attributes of the player change - self.async_on_remove( - self._player.heos.dispatcher.connect( - heos_const.SIGNAL_PLAYER_EVENT, self._player_update - ) - ) + self.async_on_remove(self._player.add_on_player_event(self._player_update)) # Update state when heos changes self.async_on_remove( async_dispatcher_connect(self.hass, SIGNAL_HEOS_UPDATED, self._heos_updated) diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index edd9cf37714..a780c26fca6 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -2,7 +2,7 @@ import logging -from pyheos import CommandFailedError, Heos, HeosError, const +from pyheos import CommandAuthenticationError, Heos, HeosError import voluptuous as vol from homeassistant.config_entries import ConfigEntryState @@ -69,16 +69,12 @@ def _get_controller(hass: HomeAssistant) -> Heos: async def _sign_in_handler(service: ServiceCall) -> None: """Sign in to the HEOS account.""" - controller = _get_controller(service.hass) - if controller.connection_state != const.STATE_CONNECTED: - _LOGGER.error("Unable to sign in because HEOS is not connected") - return username = service.data[ATTR_USERNAME] password = service.data[ATTR_PASSWORD] try: await controller.sign_in(username, password) - except CommandFailedError as err: + except CommandAuthenticationError as err: _LOGGER.error("Sign in failed: %s", err) except HeosError as err: _LOGGER.error("Unable to sign in: %s", err) @@ -88,9 +84,6 @@ async def _sign_out_handler(service: ServiceCall) -> None: """Sign out of the HEOS account.""" controller = _get_controller(service.hass) - if controller.connection_state != const.STATE_CONNECTED: - _LOGGER.error("Unable to sign out because HEOS is not connected") - return try: await controller.sign_out() except HeosError as err: diff --git a/requirements_all.txt b/requirements_all.txt index ba852635bc5..1d88272397e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1980,7 +1980,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==0.9.0 +pyheos==1.0.0 # homeassistant.components.hive pyhiveapi==0.5.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ec2516bca99..527243ba62c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1609,7 +1609,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==0.9.0 +pyheos==1.0.0 # homeassistant.components.hive pyhiveapi==0.5.16 diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index eec74d2dd18..38d2f237907 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -3,9 +3,24 @@ from __future__ import annotations from collections.abc import Sequence -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch -from pyheos import Dispatcher, Heos, HeosGroup, HeosPlayer, MediaItem, const +from pyheos import ( + CONTROLS_ALL, + Dispatcher, + Heos, + HeosGroup, + HeosOptions, + HeosPlayer, + LineOutLevelType, + MediaItem, + MediaType, + NetworkType, + PlayerUpdateResult, + PlayState, + RepeatType, + const, +) import pytest import pytest_asyncio @@ -71,26 +86,27 @@ def controller_fixture( players, favorites, input_sources, playlists, change_data, dispatcher, group ): """Create a mock Heos controller fixture.""" - mock_heos = Mock(Heos) + mock_heos = Heos(HeosOptions(host="127.0.0.1", dispatcher=dispatcher)) for player in players.values(): player.heos = mock_heos - mock_heos.return_value = mock_heos - mock_heos.dispatcher = dispatcher - mock_heos.get_players.return_value = players - mock_heos.players = players - mock_heos.get_favorites.return_value = favorites - mock_heos.get_input_sources.return_value = input_sources - mock_heos.get_playlists.return_value = playlists - mock_heos.load_players.return_value = change_data - mock_heos.is_signed_in = True - mock_heos.signed_in_username = "user@user.com" - mock_heos.connection_state = const.STATE_CONNECTED - mock_heos.get_groups.return_value = group - mock_heos.create_group.return_value = None - + mock_heos.connect = AsyncMock() + mock_heos.disconnect = AsyncMock() + mock_heos.sign_in = AsyncMock() + mock_heos.sign_out = AsyncMock() + mock_heos.get_players = AsyncMock(return_value=players) + mock_heos._players = players + mock_heos.get_favorites = AsyncMock(return_value=favorites) + mock_heos.get_input_sources = AsyncMock(return_value=input_sources) + mock_heos.get_playlists = AsyncMock(return_value=playlists) + mock_heos.load_players = AsyncMock(return_value=change_data) + mock_heos._signed_in_username = "user@user.com" + mock_heos.get_groups = AsyncMock(return_value=group) + mock_heos.create_group = AsyncMock(return_value=None) + new_mock = Mock(return_value=mock_heos) + mock_heos.new_mock = new_mock with ( - patch("homeassistant.components.heos.Heos", new=mock_heos), - patch("homeassistant.components.heos.config_flow.Heos", new=mock_heos), + patch("homeassistant.components.heos.Heos", new=new_mock), + patch("homeassistant.components.heos.config_flow.Heos", new=new_mock), ): yield mock_heos @@ -106,24 +122,25 @@ def player_fixture(quick_selects): """Create two mock HeosPlayers.""" players = {} for i in (1, 2): - player = Mock(HeosPlayer) - player.player_id = i - if i > 1: - player.name = f"Test Player {i}" - else: - player.name = "Test Player" - player.model = "Test Model" - player.version = "1.0.0" - player.is_muted = False - player.available = True - player.state = const.PlayState.STOP - player.ip_address = f"127.0.0.{i}" - player.network = "wired" - player.shuffle = False - player.repeat = const.RepeatType.OFF - player.volume = 25 + player = HeosPlayer( + player_id=i, + name="Test Player" if i == 1 else f"Test Player {i}", + model="Test Model", + serial="", + version="1.0.0", + line_out=LineOutLevelType.VARIABLE, + is_muted=False, + available=True, + state=PlayState.STOP, + ip_address=f"127.0.0.{i}", + network=NetworkType.WIRED, + shuffle=False, + repeat=RepeatType.OFF, + volume=25, + heos=None, + ) player.now_playing_media = Mock() - player.now_playing_media.supported_controls = const.CONTROLS_ALL + player.now_playing_media.supported_controls = CONTROLS_ALL player.now_playing_media.album_id = 1 player.now_playing_media.queue_id = 1 player.now_playing_media.source_id = 1 @@ -136,13 +153,30 @@ def player_fixture(quick_selects): player.now_playing_media.current_position = None player.now_playing_media.image_url = "http://" player.now_playing_media.song = "Song" - player.get_quick_selects.return_value = quick_selects + player.add_to_queue = AsyncMock() + player.clear_queue = AsyncMock() + player.get_quick_selects = AsyncMock(return_value=quick_selects) + player.mute = AsyncMock() + player.pause = AsyncMock() + player.play = AsyncMock() + player.play_input_source = AsyncMock() + player.play_next = AsyncMock() + player.play_previous = AsyncMock() + player.play_preset_station = AsyncMock() + player.play_quick_select = AsyncMock() + player.play_url = AsyncMock() + player.set_mute = AsyncMock() + player.set_play_mode = AsyncMock() + player.set_quick_select = AsyncMock() + player.set_volume = AsyncMock() + player.stop = AsyncMock() + player.unmute = AsyncMock() players[player.player_id] = player return players @pytest.fixture(name="group") -def group_fixture(players): +def group_fixture(): """Create a HEOS group consisting of two players.""" group = HeosGroup( name="Group", group_id=999, lead_player_id=1, member_player_ids=[2] @@ -158,7 +192,7 @@ def favorites_fixture() -> dict[int, MediaItem]: source_id=const.MUSIC_SOURCE_PANDORA, name="Today's Hits Radio", media_id="123456789", - type=const.MediaType.STATION, + type=MediaType.STATION, playable=True, browsable=False, image_url="", @@ -168,7 +202,7 @@ def favorites_fixture() -> dict[int, MediaItem]: source_id=const.MUSIC_SOURCE_TUNEIN, name="Classical MPR (Classical Music)", media_id="s1234", - type=const.MediaType.STATION, + type=MediaType.STATION, playable=True, browsable=False, image_url="", @@ -184,7 +218,7 @@ def input_sources_fixture() -> Sequence[MediaItem]: source_id=1, name="HEOS Drive - Line In 1", media_id=const.INPUT_AUX_IN_1, - type=const.MediaType.STATION, + type=MediaType.STATION, playable=True, browsable=False, image_url="", @@ -256,7 +290,7 @@ def playlists_fixture() -> Sequence[MediaItem]: playlist = MediaItem( source_id=const.MUSIC_SOURCE_PLAYLISTS, name="Awesome Music", - type=const.MediaType.PLAYLIST, + type=MediaType.PLAYLIST, playable=True, browsable=True, image_url="", @@ -268,10 +302,10 @@ def playlists_fixture() -> Sequence[MediaItem]: @pytest.fixture(name="change_data") def change_data_fixture() -> dict: """Create player change data for testing.""" - return {const.DATA_MAPPED_IDS: {}, const.DATA_NEW: []} + return PlayerUpdateResult() @pytest.fixture(name="change_data_mapped_ids") def change_data_mapped_ids_fixture() -> dict: """Create player change data for testing.""" - return {const.DATA_MAPPED_IDS: {101: 1}, const.DATA_NEW: []} + return PlayerUpdateResult(updated_player_ids={1: 101}) diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 45c2fbf4eb1..0a1da2d986f 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -1,6 +1,6 @@ """Tests for the Heos config flow module.""" -from pyheos import CommandFailedError, HeosError +from pyheos import CommandAuthenticationError, CommandFailedError, HeosError import pytest from homeassistant.components import heos, ssdp @@ -199,14 +199,9 @@ async def test_reconfigure_cannot_connect_recovers( ("error", "expected_error_key"), [ ( - CommandFailedError("sign_in", "Invalid credentials", 6), + CommandAuthenticationError("sign_in", "Invalid credentials", 6), "invalid_auth", ), - ( - CommandFailedError("sign_in", "User not logged in", 8), - "invalid_auth", - ), - (CommandFailedError("sign_in", "user not found", 10), "invalid_auth"), (CommandFailedError("sign_in", "System error", 12), "unknown"), (HeosError(), "unknown"), ], @@ -337,14 +332,9 @@ async def test_options_flow_missing_one_param_recovers( ("error", "expected_error_key"), [ ( - CommandFailedError("sign_in", "Invalid credentials", 6), + CommandAuthenticationError("sign_in", "Invalid credentials", 6), "invalid_auth", ), - ( - CommandFailedError("sign_in", "User not logged in", 8), - "invalid_auth", - ), - (CommandFailedError("sign_in", "user not found", 10), "invalid_auth"), (CommandFailedError("sign_in", "System error", 12), "unknown"), (HeosError(), "unknown"), ], diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 905346b8b4a..a8cd4bea1d2 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -4,7 +4,7 @@ import asyncio from typing import cast from unittest.mock import Mock, patch -from pyheos import CommandFailedError, HeosError, const +from pyheos import CommandFailedError, HeosError, SignalHeosEvent, SignalType, const import pytest from homeassistant.components.heos import ( @@ -82,7 +82,7 @@ async def test_async_setup_entry_with_options_loads_platforms( # Assert options passed and methods called assert config_entry_options.state is ConfigEntryState.LOADED - options = cast(HeosOptions, controller.call_args[0][0]) + options = cast(HeosOptions, controller.new_mock.call_args[0][0]) assert options.host == config_entry_options.data[CONF_HOST] assert options.credentials.username == config_entry_options.options[CONF_USERNAME] assert options.credentials.password == config_entry_options.options[CONF_PASSWORD] @@ -103,10 +103,9 @@ async def test_async_setup_entry_auth_failure_starts_reauth( # Simulates what happens when the controller can't sign-in during connection async def connect_send_auth_failure() -> None: - controller.is_signed_in = False - controller.signed_in_username = None + controller._signed_in_username = None controller.dispatcher.send( - const.SIGNAL_HEOS_EVENT, const.EVENT_USER_CREDENTIALS_INVALID + SignalType.HEOS_EVENT, SignalHeosEvent.USER_CREDENTIALS_INVALID ) controller.connect.side_effect = connect_send_auth_failure @@ -133,8 +132,7 @@ async def test_async_setup_entry_not_signed_in_loads_platforms( ) -> None: """Test setup does not retrieve favorites when not logged in.""" config_entry.add_to_hass(hass) - controller.is_signed_in = False - controller.signed_in_username = None + controller._signed_in_username = None with patch.object( hass.config_entries, "async_forward_entry_setups" ) as forward_mock: @@ -213,7 +211,7 @@ async def test_update_sources_retry( source_manager.max_retry_attempts = 1 controller.get_favorites.side_effect = CommandFailedError("Test", "test", 0) controller.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} + SignalType.CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} ) # Wait until it's finished while "Unable to update sources" not in caplog.text: diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 155c425b91e..f2b54ecec81 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -3,8 +3,15 @@ import asyncio from typing import Any -from pyheos import CommandFailedError, const -from pyheos.error import HeosError +from pyheos import ( + AddCriteriaType, + CommandFailedError, + HeosError, + PlayState, + SignalHeosEvent, + SignalType, + const, +) import pytest from homeassistant.components.heos import media_player @@ -115,18 +122,18 @@ async def test_updates_from_signals( player = controller.players[1] # Test player does not update for other players - player.state = const.PlayState.PLAY + player.state = PlayState.PLAY player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, 2, const.EVENT_PLAYER_STATE_CHANGED + SignalType.PLAYER_EVENT, 2, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") assert state.state == STATE_IDLE # Test player_update standard events - player.state = const.PlayState.PLAY + player.state = PlayState.PLAY player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() @@ -137,7 +144,7 @@ async def test_updates_from_signals( player.now_playing_media.duration = 360000 player.now_playing_media.current_position = 1000 player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_NOW_PLAYING_PROGRESS, ) @@ -167,7 +174,7 @@ async def test_updates_from_connection_event( # Connected player.available = True - player.heos.dispatcher.send(const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) + player.heos.dispatcher.send(SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED) await event.wait() state = hass.states.get("media_player.test_player") assert state.state == STATE_IDLE @@ -175,10 +182,9 @@ async def test_updates_from_connection_event( # Disconnected event.clear() - player.reset_mock() controller.load_players.reset_mock() player.available = False - player.heos.dispatcher.send(const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED) + player.heos.dispatcher.send(SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED) await event.wait() state = hass.states.get("media_player.test_player") assert state.state == STATE_UNAVAILABLE @@ -186,11 +192,10 @@ async def test_updates_from_connection_event( # Connected handles refresh failure event.clear() - player.reset_mock() controller.load_players.reset_mock() controller.load_players.side_effect = CommandFailedError(None, "Failure", 1) player.available = True - player.heos.dispatcher.send(const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) + player.heos.dispatcher.send(SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED) await event.wait() state = hass.states.get("media_player.test_player") assert state.state == STATE_IDLE @@ -213,7 +218,7 @@ async def test_updates_from_sources_updated( input_sources.clear() player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} + SignalType.CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} ) await event.wait() source_list = config_entry.runtime_data.source_manager.source_list @@ -241,9 +246,9 @@ async def test_updates_from_players_changed( async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) assert hass.states.get("media_player.test_player").state == STATE_IDLE - player.state = const.PlayState.PLAY + player.state = PlayState.PLAY player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data + SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data ) await event.wait() await hass.async_block_till_done() @@ -279,7 +284,7 @@ async def test_updates_from_players_changed_new_ids( async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, + SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data_mapped_ids, ) @@ -309,10 +314,9 @@ async def test_updates_from_user_changed( async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) - controller.is_signed_in = False - controller.signed_in_username = None + controller._signed_in_username = None player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_USER_CHANGED, None + SignalType.CONTROLLER_EVENT, const.EVENT_USER_CHANGED, None ) await event.wait() source_list = config_entry.runtime_data.source_manager.source_list @@ -555,7 +559,7 @@ async def test_select_favorite( # Test state is matched by station name player.now_playing_media.station = favorite.name player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") @@ -581,7 +585,7 @@ async def test_select_radio_favorite( player.now_playing_media.station = "Classical" player.now_playing_media.album_id = favorite.media_id player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") @@ -634,7 +638,7 @@ async def test_select_input_source( player.now_playing_media.source_id = const.MUSIC_SOURCE_AUX_INPUT player.now_playing_media.media_id = const.INPUT_AUX_IN_1 player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") @@ -831,7 +835,7 @@ async def test_play_media_playlist( blocking=True, ) player.add_to_queue.assert_called_once_with( - playlist, const.AddCriteriaType.REPLACE_AND_PLAY + playlist, AddCriteriaType.REPLACE_AND_PLAY ) # Play with enqueuing player.add_to_queue.reset_mock() @@ -846,9 +850,7 @@ async def test_play_media_playlist( }, blocking=True, ) - player.add_to_queue.assert_called_once_with( - playlist, const.AddCriteriaType.ADD_TO_END - ) + player.add_to_queue.assert_called_once_with(playlist, AddCriteriaType.ADD_TO_END) # Invalid name player.add_to_queue.reset_mock() await hass.services.async_call( @@ -1028,7 +1030,7 @@ async def test_media_player_unjoin_group( player = controller.players[1] player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED, ) diff --git a/tests/components/heos/test_services.py b/tests/components/heos/test_services.py index b1cffe0891e..175e072e8e7 100644 --- a/tests/components/heos/test_services.py +++ b/tests/components/heos/test_services.py @@ -1,6 +1,6 @@ """Tests for the services module.""" -from pyheos import CommandFailedError, HeosError, const +from pyheos import CommandAuthenticationError, HeosError import pytest from homeassistant.components.heos.const import ( @@ -38,30 +38,14 @@ async def test_sign_in(hass: HomeAssistant, config_entry, controller) -> None: controller.sign_in.assert_called_once_with("test@test.com", "password") -async def test_sign_in_not_connected( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture -) -> None: - """Test sign-in service logs error when not connected.""" - await setup_component(hass, config_entry) - controller.connection_state = const.STATE_RECONNECTING - - await hass.services.async_call( - DOMAIN, - SERVICE_SIGN_IN, - {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, - blocking=True, - ) - - assert controller.sign_in.call_count == 0 - assert "Unable to sign in because HEOS is not connected" in caplog.text - - async def test_sign_in_failed( hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture ) -> None: """Test sign-in service logs error when not connected.""" await setup_component(hass, config_entry) - controller.sign_in.side_effect = CommandFailedError("", "Invalid credentials", 6) + controller.sign_in.side_effect = CommandAuthenticationError( + "", "Invalid credentials", 6 + ) await hass.services.async_call( DOMAIN, @@ -115,19 +99,6 @@ async def test_sign_out(hass: HomeAssistant, config_entry, controller) -> None: assert controller.sign_out.call_count == 1 -async def test_sign_out_not_connected( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture -) -> None: - """Test the sign-out service.""" - await setup_component(hass, config_entry) - controller.connection_state = const.STATE_RECONNECTING - - await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) - - assert controller.sign_out.call_count == 0 - assert "Unable to sign out because HEOS is not connected" in caplog.text - - async def test_sign_out_not_loaded_raises(hass: HomeAssistant, config_entry) -> None: """Test the sign-out service when entry not loaded raises exception.""" await setup_component(hass, config_entry) From f7df214dd8300d150df55ec92d84eb7541fc6139 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 12 Jan 2025 02:07:45 -0800 Subject: [PATCH 0326/2987] Fix config entries typo s/entruis/entries/ (#135431) Fix typo s/entruis/entries/ --- homeassistant/config_entries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index b52515a7d5b..f73bed101ee 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1887,7 +1887,7 @@ class ConfigEntries: def async_loaded_entries(self, domain: str) -> list[ConfigEntry]: """Return loaded entries for a specific domain. - This will exclude ignored or disabled config entruis. + This will exclude ignored or disabled config entries. """ entries = self._entries.get_entries_for_domain(domain) From 8b0be70fdde871d21fe155bc93972ce3af2bdc9e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 12 Jan 2025 14:36:23 +0100 Subject: [PATCH 0327/2987] Fix descriptions of send_message action of Bring! integration (#135446) * Make "Urgent message" selector consistent, use "Bring!" as name - Replace one occurrence of "bring" with the brand name "Bring!" - Change description of action to third-person singular for consistency in Home Assistant - Make all occurrences of the selector "Urgent message" consistent (in sentence case) so they all get consistent translations, too - Change one related error message to refer to the UI name of the required "Article" field * Changed ` to ' to avoid Regex problems * Reverted change to notify_missing_argument_item Reverted to avoid failing test * Reverted change to "bring" * Add "is" to description of "Article" Co-authored-by: Jan Bouwhuis --------- Co-authored-by: Jan Bouwhuis --- homeassistant/components/bring/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index 7331f68a161..e65f9607afb 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -111,7 +111,7 @@ "services": { "send_message": { "name": "[%key:component::notify::services::notify::name%]", - "description": "Send a mobile push notification to members of a shared Bring! list.", + "description": "Sends a mobile push notification to members of a shared Bring! list.", "fields": { "entity_id": { "name": "List", @@ -122,8 +122,8 @@ "description": "Type of push notification to send to list members." }, "item": { - "name": "Article (Required if message type `Urgent Message` selected)", - "description": "Article name to include in an urgent message e.g. `Urgent Message - Please buy Cilantro urgently`" + "name": "Article (Required if notification type `Urgent message` is selected)", + "description": "Article name to include in an urgent message e.g. `Urgent message - Please buy Cilantro urgently`" } } } @@ -134,7 +134,7 @@ "going_shopping": "I'm going shopping! - Last chance to make changes", "changed_list": "List updated - Take a look at the articles", "shopping_done": "Shopping done - The fridge is well stocked", - "urgent_message": "Urgent Message - Please buy `Article name` urgently" + "urgent_message": "Urgent message - Please buy `Article` urgently" } } } From ab0dfe304c86efc1575aaa9fdea7d108fb69e13d Mon Sep 17 00:00:00 2001 From: WaterInTheLake <46621766+WaterInTheLake@users.noreply.github.com> Date: Sun, 12 Jan 2025 14:42:45 +0100 Subject: [PATCH 0328/2987] Fix translation string: numbering in list (#135441) --- homeassistant/components/tellduslive/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index 937b40f13ab..b0750a7785d 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -11,7 +11,7 @@ }, "step": { "auth": { - "description": "To link your TelldusLive account:\n1. Open the link below\n1. Log in to Telldus Live\n1. Authorize **{app_name}** (select **Yes**).\n1. Come back here and select **Submit**.\n\n [Link TelldusLive account]({auth_url})", + "description": "To link your TelldusLive account:\n1. Open the link below\n2. Log in to Telldus Live\n3. Authorize **{app_name}** (select **Yes**).\n4. Come back here and select **Submit**.\n\n [Link TelldusLive account]({auth_url})", "title": "Authenticate with TelldusLive" }, "user": { From ccb94ac6a6bf58cf210f2d6f85b66ab07a18d463 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 12 Jan 2025 16:27:31 +0100 Subject: [PATCH 0329/2987] Update translations and error messages in Bring! integration (#135455) * Update translations and error messages * use placeholder for field name * change key for translation string --- homeassistant/components/bring/strings.json | 18 +++++++++--------- homeassistant/components/bring/todo.py | 6 ++---- tests/components/bring/test_notification.py | 4 ++-- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index e65f9607afb..ea9af03484e 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -101,11 +101,11 @@ "setup_authentication_exception": { "message": "Authentication failed for {email}, check your email and password" }, - "notify_missing_argument_item": { - "message": "Failed to perform action {service}. 'URGENT_MESSAGE' requires a value @ data['item']. Got None" + "notify_missing_argument": { + "message": "This action requires field {field}, please enter a valid value for {field}" }, "notify_request_failed": { - "message": "Failed to send push notification for bring due to a connection error, try again later" + "message": "Failed to send push notification for Bring! due to a connection error, try again later" } }, "services": { @@ -122,8 +122,8 @@ "description": "Type of push notification to send to list members." }, "item": { - "name": "Article (Required if notification type `Urgent message` is selected)", - "description": "Article name to include in an urgent message e.g. `Urgent message - Please buy Cilantro urgently`" + "name": "Item (Required if notification type 'Urgent message' is selected)", + "description": "Item name(s) to include in an urgent message e.g. 'Attention! Attention! - We still urgently need: [Items]'" } } } @@ -131,10 +131,10 @@ "selector": { "notification_type_selector": { "options": { - "going_shopping": "I'm going shopping! - Last chance to make changes", - "changed_list": "List updated - Take a look at the articles", - "shopping_done": "Shopping done - The fridge is well stocked", - "urgent_message": "Urgent message - Please buy `Article` urgently" + "going_shopping": "I'm going shopping! - Last chance for adjustments", + "changed_list": "I changed the list! - Take a look at the items", + "shopping_done": "The shopping is done - Our fridge is well stocked", + "urgent_message": "Attention! Attention! - We still urgently need: [Items]" } } } diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index c53b5788b68..75657e2fd64 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -262,8 +262,6 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): except ValueError as e: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="notify_missing_argument_item", - translation_placeholders={ - "service": f"{DOMAIN}.{SERVICE_PUSH_NOTIFICATION}", - }, + translation_key="notify_missing_argument", + translation_placeholders={"field": "item"}, ) from e diff --git a/tests/components/bring/test_notification.py b/tests/components/bring/test_notification.py index b1fa28335ad..711598d3f4b 100644 --- a/tests/components/bring/test_notification.py +++ b/tests/components/bring/test_notification.py @@ -65,7 +65,7 @@ async def test_send_notification_exception( mock_bring_client.notify.side_effect = BringRequestException with pytest.raises( HomeAssistantError, - match="Failed to send push notification for bring due to a connection error, try again later", + match="Failed to send push notification for Bring! due to a connection error, try again later", ): await hass.services.async_call( DOMAIN, @@ -94,7 +94,7 @@ async def test_send_notification_service_validation_error( with pytest.raises( HomeAssistantError, match=re.escape( - "Failed to perform action bring.send_message. 'URGENT_MESSAGE' requires a value @ data['item']. Got None" + "This action requires field item, please enter a valid value for item" ), ): await hass.services.async_call( From 11ebc27bfe4ed55acf19c73d40e22f5a079e0f47 Mon Sep 17 00:00:00 2001 From: Ravaka Razafimanantsoa <3774520+SeraphicRav@users.noreply.github.com> Date: Mon, 13 Jan 2025 01:29:01 +0900 Subject: [PATCH 0330/2987] Bump switchbot-api to 2.3.1 (#135451) --- homeassistant/components/switchbot_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index eb08d2183b1..6fc6d8030d2 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["switchbot-api"], - "requirements": ["switchbot-api==2.2.1"] + "requirements": ["switchbot-api==2.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1d88272397e..521773c23b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2803,7 +2803,7 @@ surepy==0.9.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.2.1 +switchbot-api==2.3.1 # homeassistant.components.synology_srm synology-srm==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 527243ba62c..53e601c17c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2261,7 +2261,7 @@ sunweg==3.0.2 surepy==0.9.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.2.1 +switchbot-api==2.3.1 # homeassistant.components.system_bridge systembridgeconnector==4.1.5 From 61ea732caa5aa64a49d53c9e9de5a8d5f90a8760 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 12 Jan 2025 09:15:33 -0800 Subject: [PATCH 0331/2987] Fix strings for the Google integrations (#135445) --- homeassistant/components/google/strings.json | 22 +++++++++------- .../google_assistant_sdk/strings.json | 26 +++++++++---------- .../components/google_mail/strings.json | 20 +++++++------- .../components/google_photos/strings.json | 22 +++++++++++----- .../components/google_sheets/strings.json | 24 +++++++++-------- .../components/google_tasks/strings.json | 22 +++++++++++----- 6 files changed, 78 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index acc69c3799a..5ee0cdd9c14 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -13,20 +13,22 @@ } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", - "code_expired": "Authentication code expired or credential setup is invalid, please try again.", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "api_disabled": "You must enable the Google Calendar API in the Google Cloud Console", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "code_expired": "Authentication code expired or credential setup is invalid, please try again.", + "api_disabled": "You must enable the Google Calendar API in the Google Cloud Console" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index 4fd817aadce..87c93023900 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -4,27 +4,27 @@ "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "auth": { - "title": "Link Google Account" - }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Google Assistant SDK integration needs to re-authenticate your account" + }, + "auth": { + "title": "Link Google Account" } }, "abort": { + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", - "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "unknown": "[%key:common::config_flow::error::unknown%]", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json index f93a8581e1c..759242593ff 100644 --- a/homeassistant/components/google_mail/strings.json +++ b/homeassistant/components/google_mail/strings.json @@ -13,19 +13,19 @@ } }, "abort": { + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", - "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "unknown": "[%key:common::config_flow::error::unknown%]", - "wrong_account": "Wrong account: Please authenticate with {email}.", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "wrong_account": "Wrong account: Please authenticate with {email}." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index aa4529ff5ea..5695192dd27 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -6,23 +6,31 @@ "step": { "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Google Photos integration needs to re-authenticate your account" + }, + "auth": { + "title": "Link Google Account" } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", - "access_not_configured": "Unable to access the Google API:\n\n{message}", - "unknown": "[%key:common::config_flow::error::unknown%]", - "wrong_account": "Wrong account: Please authenticate with the right account.", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "access_not_configured": "Unable to access the Google API:\n\n{message}", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "Wrong account: Please authenticate with the right account." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_sheets/strings.json b/homeassistant/components/google_sheets/strings.json index d8cb06d9bcd..406c4440d00 100644 --- a/homeassistant/components/google_sheets/strings.json +++ b/homeassistant/components/google_sheets/strings.json @@ -4,27 +4,29 @@ "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "auth": { "title": "Link Google Account" }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Google Sheets integration needs to re-authenticate your account" + }, + "auth": { + "title": "Link Google Account" } }, "abort": { + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", - "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "unknown": "[%key:common::config_flow::error::unknown%]", "create_spreadsheet_failure": "Error while creating spreadsheet, see error log for details", - "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details" }, "create_entry": { "default": "Successfully authenticated and spreadsheet created at: {url}" diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json index a26cf8c58ec..b58678f6d30 100644 --- a/homeassistant/components/google_tasks/strings.json +++ b/homeassistant/components/google_tasks/strings.json @@ -6,23 +6,31 @@ "step": { "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Google Tasks integration needs to re-authenticate your account" + }, + "auth": { + "title": "Link Google Account" } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", - "access_not_configured": "Unable to access the Google API:\n\n{message}", - "unknown": "[%key:common::config_flow::error::unknown%]", - "wrong_account": "Wrong account: Please authenticate with the right account.", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "access_not_configured": "Unable to access the Google API:\n\n{message}", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "Wrong account: Please authenticate with the right account." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" From 559c411dd241db50a8aa30c0f567a3e2c1d8009c Mon Sep 17 00:00:00 2001 From: Ravaka Razafimanantsoa <3774520+SeraphicRav@users.noreply.github.com> Date: Mon, 13 Jan 2025 02:42:06 +0900 Subject: [PATCH 0332/2987] Add current and voltage for plugs to switchbot_cloud (#135458) SwitchBot Cloud: Adding current and voltage for plugs --- .../components/switchbot_cloud/__init__.py | 2 ++ .../components/switchbot_cloud/sensor.py | 23 +++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 827dce550ef..5f17ca516b9 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -91,6 +91,8 @@ def make_device_data( "MeterPro", "MeterPro(CO2)", "Relay Switch 1PM", + "Plug Mini (US)", + "Plug Mini (JP)", ]: devices_data.sensors.append( prepare_device(hass, api, device, coordinators_by_id) diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index ae912e914ba..227b46d467c 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -61,20 +61,27 @@ POWER_DESCRIPTION = SensorEntityDescription( native_unit_of_measurement=UnitOfPower.WATT, ) -VOLATGE_DESCRIPTION = SensorEntityDescription( +VOLTAGE_DESCRIPTION = SensorEntityDescription( key=SENSOR_TYPE_VOLTAGE, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, ) -CURRENT_DESCRIPTION = SensorEntityDescription( +CURRENT_DESCRIPTION_IN_MA = SensorEntityDescription( key=SENSOR_TYPE_CURRENT, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, ) +CURRENT_DESCRIPTION_IN_A = SensorEntityDescription( + key=SENSOR_TYPE_CURRENT, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, +) + CO2_DESCRIPTION = SensorEntityDescription( key=SENSOR_TYPE_CO2, device_class=SensorDeviceClass.CO2, @@ -100,8 +107,16 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { ), "Relay Switch 1PM": ( POWER_DESCRIPTION, - VOLATGE_DESCRIPTION, - CURRENT_DESCRIPTION, + VOLTAGE_DESCRIPTION, + CURRENT_DESCRIPTION_IN_MA, + ), + "Plug Mini (US)": ( + VOLTAGE_DESCRIPTION, + CURRENT_DESCRIPTION_IN_A, + ), + "Plug Mini (JP)": ( + VOLTAGE_DESCRIPTION, + CURRENT_DESCRIPTION_IN_A, ), "Hub 2": ( TEMPERATURE_DESCRIPTION, From 0a444de39c7c6c746700139bd87687ba6dcec033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 12 Jan 2025 22:43:37 -0100 Subject: [PATCH 0333/2987] Refactor upcloud to use config entry runtime data (#135449) --- homeassistant/components/upcloud/__init__.py | 38 +++++-------------- .../components/upcloud/binary_sensor.py | 10 ++--- .../components/upcloud/config_flow.py | 10 ++--- homeassistant/components/upcloud/const.py | 1 - .../components/upcloud/coordinator.py | 7 +++- homeassistant/components/upcloud/entity.py | 12 +++--- homeassistant/components/upcloud/switch.py | 11 +++--- 7 files changed, 33 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index 30d7cacba8e..2450e3d5852 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -import dataclasses from datetime import timedelta import logging @@ -23,34 +22,23 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) -from .const import ( - CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE, - DATA_UPCLOUD, - DEFAULT_SCAN_INTERVAL, -) +from .const import CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE, DEFAULT_SCAN_INTERVAL from .coordinator import UpCloudDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SWITCH] - -@dataclasses.dataclass -class UpCloudHassData: - """Home Assistant UpCloud runtime data.""" - - coordinators: dict[str, UpCloudDataUpdateCoordinator] = dataclasses.field( - default_factory=dict - ) +type UpCloudConfigEntry = ConfigEntry[UpCloudDataUpdateCoordinator] -def _config_entry_update_signal_name(config_entry: ConfigEntry) -> str: +def _config_entry_update_signal_name(config_entry: UpCloudConfigEntry) -> str: """Get signal name for updates to a config entry.""" return CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE.format(config_entry.unique_id) async def _async_signal_options_update( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: UpCloudConfigEntry ) -> None: """Signal config entry options update.""" async_dispatcher_send( @@ -58,7 +46,7 @@ async def _async_signal_options_update( ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: UpCloudConfigEntry) -> bool: """Set up the UpCloud config entry.""" manager = upcloud_api.CloudManager( @@ -85,6 +73,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: cloud_manager=manager, username=entry.data[CONF_USERNAME], ) + entry.runtime_data = coordinator # Call the UpCloud API to refresh data await coordinator.async_config_entry_first_refresh() @@ -99,21 +88,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - hass.data[DATA_UPCLOUD] = UpCloudHassData() - hass.data[DATA_UPCLOUD].coordinators[entry.data[CONF_USERNAME]] = coordinator - # Forward entry setup await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: UpCloudConfigEntry +) -> bool: """Unload the config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - - hass.data[DATA_UPCLOUD].coordinators.pop(config_entry.data[CONF_USERNAME]) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/upcloud/binary_sensor.py b/homeassistant/components/upcloud/binary_sensor.py index f135eea24b1..77bbfdbffaa 100644 --- a/homeassistant/components/upcloud/binary_sensor.py +++ b/homeassistant/components/upcloud/binary_sensor.py @@ -4,23 +4,21 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_UPCLOUD +from . import UpCloudConfigEntry from .entity import UpCloudServerEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UpCloudConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the UpCloud server binary sensor.""" - coordinator = hass.data[DATA_UPCLOUD].coordinators[config_entry.data[CONF_USERNAME]] - entities = [UpCloudBinarySensor(coordinator, uuid) for uuid in coordinator.data] + coordinator = config_entry.runtime_data + entities = [UpCloudBinarySensor(config_entry, uuid) for uuid in coordinator.data] async_add_entities(entities, True) diff --git a/homeassistant/components/upcloud/config_flow.py b/homeassistant/components/upcloud/config_flow.py index bb988726ba5..4777c75ae3c 100644 --- a/homeassistant/components/upcloud/config_flow.py +++ b/homeassistant/components/upcloud/config_flow.py @@ -9,15 +9,11 @@ import requests.exceptions import upcloud_api import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import callback +from . import UpCloudConfigEntry from .const import DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -92,7 +88,7 @@ class UpCloudConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: UpCloudConfigEntry, ) -> UpCloudOptionsFlow: """Get options flow.""" return UpCloudOptionsFlow() diff --git a/homeassistant/components/upcloud/const.py b/homeassistant/components/upcloud/const.py index a967a43c46e..763462c37f4 100644 --- a/homeassistant/components/upcloud/const.py +++ b/homeassistant/components/upcloud/const.py @@ -3,6 +3,5 @@ from datetime import timedelta DOMAIN = "upcloud" -DATA_UPCLOUD = "data_upcloud" DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE = f"{DOMAIN}_config_entry_update:{{}}" diff --git a/homeassistant/components/upcloud/coordinator.py b/homeassistant/components/upcloud/coordinator.py index e10128a30e4..4eb92018dcf 100644 --- a/homeassistant/components/upcloud/coordinator.py +++ b/homeassistant/components/upcloud/coordinator.py @@ -4,14 +4,17 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import TYPE_CHECKING import upcloud_api -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +if TYPE_CHECKING: + from . import UpCloudConfigEntry + _LOGGER = logging.getLogger(__name__) @@ -34,7 +37,7 @@ class UpCloudDataUpdateCoordinator( ) self.cloud_manager = cloud_manager - async def async_update_config(self, config_entry: ConfigEntry) -> None: + async def async_update_config(self, config_entry: UpCloudConfigEntry) -> None: """Handle config update.""" self.update_interval = timedelta( seconds=config_entry.options[CONF_SCAN_INTERVAL] diff --git a/homeassistant/components/upcloud/entity.py b/homeassistant/components/upcloud/entity.py index c64ca7be2ea..3d727f90d9e 100644 --- a/homeassistant/components/upcloud/entity.py +++ b/homeassistant/components/upcloud/entity.py @@ -11,6 +11,7 @@ from homeassistant.const import CONF_USERNAME, STATE_OFF, STATE_ON, STATE_PROBLE from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import UpCloudConfigEntry from .const import DOMAIN from .coordinator import UpCloudDataUpdateCoordinator @@ -33,11 +34,12 @@ class UpCloudServerEntity(CoordinatorEntity[UpCloudDataUpdateCoordinator]): def __init__( self, - coordinator: UpCloudDataUpdateCoordinator, + config_entry: UpCloudConfigEntry, uuid: str, ) -> None: """Initialize the UpCloud server entity.""" - super().__init__(coordinator) + super().__init__(config_entry.runtime_data) + self.config_entry = config_entry self.uuid = uuid @property @@ -95,13 +97,11 @@ class UpCloudServerEntity(CoordinatorEntity[UpCloudDataUpdateCoordinator]): @property def device_info(self) -> DeviceInfo: """Return info for device registry.""" - assert self.coordinator.config_entry is not None + assert self.config_entry is not None return DeviceInfo( configuration_url="https://hub.upcloud.com", model="Control Panel", entry_type=DeviceEntryType.SERVICE, - identifiers={ - (DOMAIN, f"{self.coordinator.config_entry.data[CONF_USERNAME]}@hub") - }, + identifiers={(DOMAIN, f"{self.config_entry.data[CONF_USERNAME]}@hub")}, manufacturer="UpCloud Ltd", ) diff --git a/homeassistant/components/upcloud/switch.py b/homeassistant/components/upcloud/switch.py index 7495357ca9e..5ee2adfc9f6 100644 --- a/homeassistant/components/upcloud/switch.py +++ b/homeassistant/components/upcloud/switch.py @@ -3,13 +3,12 @@ from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME, STATE_OFF +from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_UPCLOUD +from . import UpCloudConfigEntry from .entity import UpCloudServerEntity SIGNAL_UPDATE_UPCLOUD = "upcloud_update" @@ -17,12 +16,12 @@ SIGNAL_UPDATE_UPCLOUD = "upcloud_update" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UpCloudConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the UpCloud server switch.""" - coordinator = hass.data[DATA_UPCLOUD].coordinators[config_entry.data[CONF_USERNAME]] - entities = [UpCloudSwitch(coordinator, uuid) for uuid in coordinator.data] + coordinator = config_entry.runtime_data + entities = [UpCloudSwitch(config_entry, uuid) for uuid in coordinator.data] async_add_entities(entities, True) From c9a7afe439d72a2c14934358a030d8348577e79c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Jan 2025 14:03:05 -1000 Subject: [PATCH 0334/2987] Add bluetooth API to remove scanners that are no longer used (#135408) --- homeassistant/components/bluetooth/__init__.py | 2 ++ homeassistant/components/bluetooth/api.py | 6 ++++++ homeassistant/components/bluetooth/manager.py | 5 +++++ homeassistant/components/bluetooth/storage.py | 6 ++++++ tests/components/bluetooth/conftest.py | 2 ++ tests/components/bluetooth/test_init.py | 18 ++++++++++++++++++ 6 files changed, 39 insertions(+) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 5e96e5e336f..ef89bef7ca1 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -66,6 +66,7 @@ from .api import ( async_rediscover_address, async_register_callback, async_register_scanner, + async_remove_scanner, async_scanner_by_source, async_scanner_count, async_scanner_devices_by_address, @@ -109,6 +110,7 @@ __all__ = [ "async_scanner_count", "async_scanner_devices_by_address", "async_get_advertisement_callback", + "async_remove_scanner", "BaseHaScanner", "HomeAssistantRemoteScanner", "BluetoothCallbackMatcher", diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 505651edafd..9fd16ef1f43 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -183,6 +183,12 @@ def async_register_scanner( return _get_manager(hass).async_register_scanner(scanner, connection_slots) +@hass_callback +def async_remove_scanner(hass: HomeAssistant, source: str) -> None: + """Permanently remove a BleakScanner by source address.""" + return _get_manager(hass).async_remove_scanner(source) + + @hass_callback def async_get_advertisement_callback( hass: HomeAssistant, diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index e192423484c..7ec5427af2b 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -253,6 +253,11 @@ class HomeAssistantBluetoothManager(BluetoothManager): unregister = super().async_register_scanner(scanner, connection_slots) return partial(self._async_unregister_scanner, scanner, unregister) + @hass_callback + def async_remove_scanner(self, source: str) -> None: + """Remove a scanner.""" + self.storage.async_remove_advertisement_history(source) + @hass_callback def _handle_config_entry_removed( self, diff --git a/homeassistant/components/bluetooth/storage.py b/homeassistant/components/bluetooth/storage.py index 6b4c7695fd2..369db4a7760 100644 --- a/homeassistant/components/bluetooth/storage.py +++ b/homeassistant/components/bluetooth/storage.py @@ -38,6 +38,12 @@ class BluetoothStorage: """Get all scanners.""" return list(self._data.keys()) + @callback + def async_remove_advertisement_history(self, scanner: str) -> None: + """Remove discovered devices by scanner.""" + if self._data.pop(scanner, None): + self._store.async_delay_save(self._async_get_data, SCANNER_SAVE_DELAY) + @callback def async_get_advertisement_history( self, scanner: str diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 71ed155cbc7..1be39bfaa94 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -319,6 +319,7 @@ def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]: cancel = bluetooth.async_register_scanner(hass, hci0_scanner) yield cancel() + bluetooth.async_remove_scanner(hass, hci0_scanner.source) @pytest.fixture @@ -328,3 +329,4 @@ def register_hci1_scanner(hass: HomeAssistant) -> Generator[None]: cancel = bluetooth.async_register_scanner(hass, hci1_scanner) yield cancel() + bluetooth.async_remove_scanner(hass, hci1_scanner.source) diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index ba8792a79a3..9ad2c0e6caa 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -30,6 +30,7 @@ from homeassistant.components.bluetooth.const import ( SOURCE_LOCAL, UNAVAILABLE_TRACK_SECONDS, ) +from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager from homeassistant.components.bluetooth.match import ( ADDRESS, CONNECTABLE, @@ -3022,6 +3023,23 @@ async def test_scanner_count_connectable(hass: HomeAssistant) -> None: cancel() +@pytest.mark.usefixtures("enable_bluetooth") +async def test_scanner_remove(hass: HomeAssistant) -> None: + """Test permanently removing a scanner.""" + scanner = FakeScanner("any", "any") + cancel = bluetooth.async_register_scanner(hass, scanner) + assert bluetooth.async_scanner_count(hass, connectable=True) == 1 + device = generate_ble_device("44:44:33:11:23:45", "name") + adv = generate_advertisement_data(local_name="name", service_uuids=[]) + inject_advertisement_with_time_and_source_connectable( + hass, device, adv, time.monotonic(), scanner.source, True + ) + cancel() + bluetooth.async_remove_scanner(hass, scanner.source) + manager: HomeAssistantBluetoothManager = _get_manager() + assert not manager.storage.async_get_advertisement_history(scanner.source) + + @pytest.mark.usefixtures("enable_bluetooth") async def test_scanner_count(hass: HomeAssistant) -> None: """Test getting the connectable and non-connectable scanner count.""" From 2e5e2c50dd0354888a5799ce62d2947f01e6ee1b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Jan 2025 17:41:21 -1000 Subject: [PATCH 0335/2987] Ensure Shelly cleanups Bluetooth scanner data upon removal (#135472) * Add bluetooth API to remove scanners that are no longer used - Cleanup the advertisment history right away when a scanner is removed In the future we will do some additional cleanup * coverage * finish tests * Ensure Shelly cleanups Bluetooth scanner data upon removal needs https://github.com/home-assistant/core/pull/135408 --- homeassistant/components/shelly/__init__.py | 9 +++++++++ .../components/shelly/coordinator.py | 4 +++- tests/components/shelly/test_init.py | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index e0d9d17d55d..5ca58ec7d01 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -15,6 +15,7 @@ from aioshelly.exceptions import ( from aioshelly.rpc_device import RpcDevice import voluptuous as vol +from homeassistant.components.bluetooth import async_remove_scanner from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -331,3 +332,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> b return await hass.config_entries.async_unload_platforms( entry, runtime_data.platforms ) + + +async def async_remove_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> None: + """Remove a config entry.""" + if get_device_entry_gen(entry) in RPC_GENERATIONS and ( + mac_address := entry.unique_id + ): + async_remove_scanner(hass, mac_address) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index f58e42a78d8..e6129b5559a 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -20,6 +20,7 @@ from aioshelly.exceptions import ( from aioshelly.rpc_device import RpcDevice, RpcUpdateType from propcache import cached_property +from homeassistant.components.bluetooth import async_remove_scanner from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_DEVICE_ID, @@ -30,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .bluetooth import async_connect_scanner @@ -697,6 +698,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ) if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected: await async_stop_scanner(self.device) + async_remove_scanner(self.hass, format_mac(self.mac).upper()) return if await async_ensure_ble_enabled(self.device): # BLE enable required a reboot, don't bother connecting diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index b5516485501..270e2163635 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -545,3 +545,22 @@ async def test_sleeping_block_device_wrong_sleep_period( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.data[CONF_SLEEP_PERIOD] == BLOCK_EXPECTED_SLEEP_PERIOD + + +async def test_bluetooth_cleanup_on_remove_entry( + hass: HomeAssistant, + mock_rpc_device: Mock, +) -> None: + """Test bluetooth is cleaned up on entry removal.""" + entry = await init_integration(hass, 2) + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + with patch("homeassistant.components.shelly.async_remove_scanner") as remove_mock: + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + remove_mock.assert_called_once_with(hass, entry.unique_id.upper()) From 4e5bf5ac22b3e38478fc589d65528a0a7c49b59e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Jan 2025 17:41:49 -1000 Subject: [PATCH 0336/2987] Ensure ESPHome cleanups Bluetooth scanner data upon removal (#135470) * Add bluetooth API to remove scanners that are no longer used - Cleanup the advertisment history right away when a scanner is removed In the future we will do some additional cleanup * coverage * finish tests * Ensure ESPHome cleanups Bluetooth scanner data upon removal needs https://github.com/home-assistant/core/pull/135408 --- homeassistant/components/esphome/__init__.py | 3 +++ homeassistant/components/esphome/manager.py | 4 +++- tests/components/esphome/test_bluetooth.py | 21 ++++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 13e9496a9fd..5934c9a6f68 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations from aioesphomeapi import APIClient from homeassistant.components import ffmpeg, zeroconf +from homeassistant.components.bluetooth import async_remove_scanner from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -86,4 +87,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> None: """Remove an esphome config entry.""" + if mac_address := entry.unique_id: + async_remove_scanner(hass, mac_address.upper()) await DomainData.get(hass).get_or_create_store(hass, entry).async_remove() diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index dfd318c0c74..7fcd859142a 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -24,7 +24,7 @@ from aioesphomeapi import ( from awesomeversion import AwesomeVersion import voluptuous as vol -from homeassistant.components import tag, zeroconf +from homeassistant.components import bluetooth, tag, zeroconf from homeassistant.const import ( ATTR_DEVICE_ID, CONF_MODE, @@ -425,6 +425,8 @@ class ESPHomeManager: entry_data.disconnect_callbacks.add( async_connect_scanner(hass, entry_data, cli, device_info) ) + else: + bluetooth.async_remove_scanner(hass, device_info.mac_address) if device_info.voice_assistant_feature_flags_compat(api_version) and ( Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms diff --git a/tests/components/esphome/test_bluetooth.py b/tests/components/esphome/test_bluetooth.py index 46858c5826b..31d9fcd34f9 100644 --- a/tests/components/esphome/test_bluetooth.py +++ b/tests/components/esphome/test_bluetooth.py @@ -1,5 +1,7 @@ """Test the ESPHome bluetooth integration.""" +from unittest.mock import patch + from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant @@ -44,3 +46,22 @@ async def test_bluetooth_connect_with_legacy_adv( await hass.async_block_till_done() scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") assert scanner.scanning is True + + +async def test_bluetooth_cleanup_on_remove_entry( + hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice +) -> None: + """Test bluetooth is cleaned up on entry removal.""" + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner.connectable is True + await hass.config_entries.async_unload( + mock_bluetooth_entry_with_raw_adv.entry.entry_id + ) + + with patch("homeassistant.components.esphome.async_remove_scanner") as remove_mock: + await hass.config_entries.async_remove( + mock_bluetooth_entry_with_raw_adv.entry.entry_id + ) + await hass.async_block_till_done() + + remove_mock.assert_called_once_with(hass, scanner.source) From ac279d9794803ad73a663a1206fbe4fb4747d39d Mon Sep 17 00:00:00 2001 From: Khole <29937485+KJonline@users.noreply.github.com> Date: Mon, 13 Jan 2025 07:50:25 +0000 Subject: [PATCH 0337/2987] Replace pyhiveapi with pyhive-integration (#135482) --- 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 870223f8fe6..f68478516ab 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -9,5 +9,5 @@ }, "iot_class": "cloud_polling", "loggers": ["apyhiveapi"], - "requirements": ["pyhiveapi==0.5.16"] + "requirements": ["pyhive-integration==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 521773c23b1..2187e4180a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1983,7 +1983,7 @@ pyhaversion==22.8.0 pyheos==1.0.0 # homeassistant.components.hive -pyhiveapi==0.5.16 +pyhive-integration==1.0.1 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53e601c17c0..ddb90008221 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1612,7 +1612,7 @@ pyhaversion==22.8.0 pyheos==1.0.0 # homeassistant.components.hive -pyhiveapi==0.5.16 +pyhive-integration==1.0.1 # homeassistant.components.homematic pyhomematic==0.1.77 From c36d73e469a302ee89d7d081bb9b7d1e3c1af254 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jan 2025 08:54:42 +0100 Subject: [PATCH 0338/2987] Bump github/codeql-action from 3.28.0 to 3.28.1 (#135492) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 511ec963db3..7c9a076de64 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.0 + uses: github/codeql-action/init@v3.28.1 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.0 + uses: github/codeql-action/analyze@v3.28.1 with: category: "/language:python" From e67a131bd905b8faafdc429eab9414c171ee35d0 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 13 Jan 2025 09:11:46 +0100 Subject: [PATCH 0339/2987] Bump uv to 0.5.18 (#135454) --- 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 630fc19496c..fde47288428 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv==0.5.8 +RUN pip3 install uv==0.5.18 WORKDIR /usr/src diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3494504b5a8..1355ce5d6af 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -67,7 +67,7 @@ standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 -uv==0.5.8 +uv==0.5.18 voluptuous-openapi==0.0.6 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 9a484f35bba..0582a6ff881 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,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.8", + "uv==0.5.18", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.6", diff --git a/requirements.txt b/requirements.txt index 4ad1140979a..ebfc0fabdbe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,7 +45,7 @@ standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 -uv==0.5.8 +uv==0.5.18 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 4e711a29b28..89eaaf316d5 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.8,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.5.18,source=/uv,target=/bin/uv \ # Uv creates a lock file in /tmp --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG From 86ea68eaecc5c5bde0cfec793a8ccf8613561efb Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 13 Jan 2025 10:12:04 +0000 Subject: [PATCH 0340/2987] Add missing `total active returned energy` sensor for Shelly Mini PM Gen3 (#135433) Add missing total active returned energy sensor for Mini PM Gen3 --- homeassistant/components/shelly/sensor.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 03ce080db8e..139a427f087 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -770,6 +770,18 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + "ret_energy_pm1": RpcSensorDescription( + key="pm1", + sub_key="ret_aenergy", + name="Total active returned energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: status["total"], + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), "energy_cct": RpcSensorDescription( key="cct", sub_key="aenergy", From 3a0072d42da57e2a0a9563be85593d84ff03f25b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Jan 2025 11:27:20 +0100 Subject: [PATCH 0341/2987] Fix typing in zha update entity (#135500) --- homeassistant/components/zha/update.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py index 5b813453a8b..2f540da5ea7 100644 --- a/homeassistant/components/zha/update.py +++ b/homeassistant/components/zha/update.py @@ -124,7 +124,7 @@ class ZHAFirmwareUpdateEntity( return self.entity_data.entity.installed_version @property - def in_progress(self) -> bool | int | None: + def in_progress(self) -> bool | None: """Update installation progress. Should return a boolean (True if in progress, False if not). From 98ef32c668f85436ed49441fa9f2b36ab0863f68 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Mon, 13 Jan 2025 19:29:09 +0900 Subject: [PATCH 0342/2987] Add remain, running, schedule time sensors to LG ThinQ (#131133) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/sensor.py | 168 +++++++++++++++++- .../lg_thinq/snapshots/test_sensor.ambr | 143 +++++++++++++++ tests/components/lg_thinq/test_sensor.py | 3 + 3 files changed, 313 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 99b4df8176e..7baaab52403 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime, time, timedelta import logging from thinqconnect import DeviceType @@ -22,6 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util from . import ThinqConfigEntry from .coordinator import DeviceDataUpdateCoordinator @@ -93,6 +95,11 @@ FILTER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { native_unit_of_measurement=UnitOfTime.HOURS, translation_key=ThinQProperty.FILTER_LIFETIME, ), + ThinQProperty.FILTER_REMAIN_PERCENT: SensorEntityDescription( + key=ThinQProperty.FILTER_REMAIN_PERCENT, + native_unit_of_measurement=PERCENTAGE, + translation_key=ThinQProperty.FILTER_LIFETIME, + ), } HUMIDITY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { ThinQProperty.CURRENT_HUMIDITY: SensorEntityDescription( @@ -255,9 +262,90 @@ WATER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { translation_key=ThinQProperty.WATER_TYPE, ), } +ELAPSED_DAY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { + ThinQProperty.ELAPSED_DAY_STATE: SensorEntityDescription( + key=ThinQProperty.ELAPSED_DAY_STATE, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, + translation_key=ThinQProperty.ELAPSED_DAY_STATE, + ), + ThinQProperty.ELAPSED_DAY_TOTAL: SensorEntityDescription( + key=ThinQProperty.ELAPSED_DAY_TOTAL, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, + translation_key=ThinQProperty.ELAPSED_DAY_TOTAL, + ), +} +TIME_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { + TimerProperty.LIGHT_START: SensorEntityDescription( + key=TimerProperty.LIGHT_START, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.LIGHT_START, + ), + TimerProperty.ABSOLUTE_TO_START: SensorEntityDescription( + key=TimerProperty.ABSOLUTE_TO_START, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.ABSOLUTE_TO_START, + ), + TimerProperty.ABSOLUTE_TO_STOP: SensorEntityDescription( + key=TimerProperty.ABSOLUTE_TO_STOP, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.ABSOLUTE_TO_STOP, + ), +} +TIMER_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { + TimerProperty.TOTAL: SensorEntityDescription( + key=TimerProperty.TOTAL, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + translation_key=TimerProperty.TOTAL, + ), + TimerProperty.RELATIVE_TO_START: SensorEntityDescription( + key=TimerProperty.RELATIVE_TO_START, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + translation_key=TimerProperty.RELATIVE_TO_START, + ), + TimerProperty.RELATIVE_TO_STOP: SensorEntityDescription( + key=TimerProperty.RELATIVE_TO_STOP, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + translation_key=TimerProperty.RELATIVE_TO_STOP, + ), + TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP: SensorEntityDescription( + key=TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + translation_key=TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP, + ), + TimerProperty.RELATIVE_TO_START_WM: SensorEntityDescription( + key=TimerProperty.RELATIVE_TO_START, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.RELATIVE_TO_START_WM, + ), + TimerProperty.RELATIVE_TO_STOP_WM: SensorEntityDescription( + key=TimerProperty.RELATIVE_TO_STOP, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.RELATIVE_TO_STOP_WM, + ), + TimerProperty.REMAIN: SensorEntityDescription( + key=TimerProperty.REMAIN, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.REMAIN, + ), + TimerProperty.RUNNING: SensorEntityDescription( + key=TimerProperty.RUNNING, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.RUNNING, + ), +} WASHER_SENSORS: tuple[SensorEntityDescription, ...] = ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], + TIMER_SENSOR_DESC[TimerProperty.TOTAL], + TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM], + TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_STOP_WM], + TIMER_SENSOR_DESC[TimerProperty.REMAIN], ) DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = { DeviceType.AIR_CONDITIONER: ( @@ -268,6 +356,12 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_LIFETIME], + FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_REMAIN_PERCENT], + TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START], + TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_STOP], + TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], ), DeviceType.AIR_PURIFIER_FAN: ( AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], @@ -278,6 +372,9 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED], AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], + TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], ), DeviceType.AIR_PURIFIER: ( AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], @@ -287,8 +384,11 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED], AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], + FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_REMAIN_PERCENT], JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE], JOB_MODE_SENSOR_DESC[ThinQProperty.PERSONALIZATION_MODE], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], ), DeviceType.COOKTOP: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], @@ -303,6 +403,9 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = PREFERENCE_SENSOR_DESC[ThinQProperty.RINSE_LEVEL], PREFERENCE_SENSOR_DESC[ThinQProperty.SOFTENING_LEVEL], RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], + TIMER_SENSOR_DESC[TimerProperty.TOTAL], + TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM], + TIMER_SENSOR_DESC[TimerProperty.REMAIN], ), DeviceType.DRYER: WASHER_SENSORS, DeviceType.HOME_BREW: ( @@ -313,6 +416,8 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_INFO], RECIPE_SENSOR_DESC[ThinQProperty.BEER_REMAIN], RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], + ELAPSED_DAY_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_STATE], + ELAPSED_DAY_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_TOTAL], ), DeviceType.HUMIDIFIER: ( AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], @@ -322,6 +427,9 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = AIR_QUALITY_SENSOR_DESC[ThinQProperty.TEMPERATURE], AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], + TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], ), DeviceType.KIMCHI_REFRIGERATOR: ( REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER], @@ -344,6 +452,7 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = TEMPERATURE_SENSOR_DESC[ThinQProperty.DAY_TARGET_TEMPERATURE], TEMPERATURE_SENSOR_DESC[ThinQProperty.NIGHT_TARGET_TEMPERATURE], TEMPERATURE_SENSOR_DESC[ThinQProperty.TEMPERATURE_STATE], + TIME_SENSOR_DESC[TimerProperty.LIGHT_START], ), DeviceType.REFRIGERATOR: ( REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER], @@ -352,6 +461,8 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = DeviceType.ROBOT_CLEANER: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE], + TIMER_SENSOR_DESC[TimerProperty.RUNNING], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], ), DeviceType.STICK_CLEANER: ( BATTERY_SENSOR_DESC[ThinQProperty.BATTERY_PERCENT], @@ -426,11 +537,59 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity): if entity_description.device_class == SensorDeviceClass.ENUM: self._attr_options = self.data.options + self._device_state: str | None = None + self._device_state_id = ( + ThinQProperty.CURRENT_STATE + if self.location is None + else f"{self.location}_{ThinQProperty.CURRENT_STATE}" + ) + def _update_status(self) -> None: """Update status itself.""" super()._update_status() - self._attr_native_value = self.data.value + value = self.data.value + + if isinstance(value, time): + local_now = datetime.now( + tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone) + ) + if value in [0, None, time.min]: + # Reset to None + value = None + elif self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: + if self.entity_description.key in TIME_SENSOR_DESC: + # Set timestamp for time + value = local_now.replace(hour=value.hour, minute=value.minute) + else: + # Set timestamp for delta + new_state = ( + self.coordinator.data[self._device_state_id].value + if self._device_state_id in self.coordinator.data + else None + ) + if ( + self.native_value is not None + and self._device_state == new_state + ): + # Skip update when same state + return + + self._device_state = new_state + time_delta = timedelta( + hours=value.hour, minutes=value.minute, seconds=value.second + ) + value = ( + (local_now - time_delta) + if self.entity_description.key == TimerProperty.RUNNING + else (local_now + time_delta) + ) + elif self.entity_description.device_class == SensorDeviceClass.DURATION: + # Set duration + value = self._get_duration( + value, self.entity_description.native_unit_of_measurement + ) + self._attr_native_value = value if (data_unit := self._get_unit_of_measurement(self.data.unit)) is not None: # For different from description's unit @@ -445,3 +604,10 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity): self.options, self.native_unit_of_measurement, ) + + def _get_duration(self, data: time, unit: str | None) -> float | None: + if unit == UnitOfTime.MINUTES: + return (data.hour * 60) + data.minute + if unit == UnitOfTime.SECONDS: + return (data.hour * 3600) + (data.minute * 60) + data.second + return 0 diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index 387df916eba..2c58b109e61 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -203,3 +203,146 @@ 'state': '24', }) # --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Schedule turn-off', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_stop', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test air conditioner Schedule turn-off', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Schedule turn-on', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_start', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test air conditioner Schedule turn-on', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on_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': 'Schedule turn-on', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_absolute_to_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test air conditioner Schedule turn-on', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-10-10T13:14:00+00:00', + }) +# --- \ No newline at end of file diff --git a/tests/components/lg_thinq/test_sensor.py b/tests/components/lg_thinq/test_sensor.py index 02b91b4771b..e1f1a7ed93d 100644 --- a/tests/components/lg_thinq/test_sensor.py +++ b/tests/components/lg_thinq/test_sensor.py @@ -1,5 +1,6 @@ """Tests for the LG Thinq sensor platform.""" +from datetime import UTC, datetime from unittest.mock import AsyncMock, patch import pytest @@ -15,6 +16,7 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.freeze_time(datetime(2024, 10, 10, tzinfo=UTC)) async def test_all_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -23,6 +25,7 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" + hass.config.time_zone = "UTC" with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.SENSOR]): await setup_integration(hass, mock_config_entry) From 2d67aca5508ca27bc2e43b8c99352507817a0074 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 13 Jan 2025 11:36:20 +0100 Subject: [PATCH 0343/2987] Rework velbus services to deprecated the interface parameter (#134816) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/velbus/const.py | 1 + homeassistant/components/velbus/services.py | 164 +++++++++++++---- homeassistant/components/velbus/services.yaml | 20 +- homeassistant/components/velbus/strings.json | 28 +++ tests/components/velbus/conftest.py | 5 +- tests/components/velbus/test_services.py | 172 ++++++++++++++++++ 6 files changed, 344 insertions(+), 46 deletions(-) create mode 100644 tests/components/velbus/test_services.py diff --git a/homeassistant/components/velbus/const.py b/homeassistant/components/velbus/const.py index 2d9f6e98a4c..b40f64e8607 100644 --- a/homeassistant/components/velbus/const.py +++ b/homeassistant/components/velbus/const.py @@ -11,6 +11,7 @@ from homeassistant.components.climate import ( DOMAIN: Final = "velbus" +CONF_CONFIG_ENTRY: Final = "config_entry" CONF_INTERFACE: Final = "interface" CONF_MEMO_TEXT: Final = "memo_text" diff --git a/homeassistant/components/velbus/services.py b/homeassistant/components/velbus/services.py index 3f0b1bd6cdb..765c5a0f674 100644 --- a/homeassistant/components/velbus/services.py +++ b/homeassistant/components/velbus/services.py @@ -9,15 +9,19 @@ from typing import TYPE_CHECKING import voluptuous as vol +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.storage import STORAGE_DIR if TYPE_CHECKING: from . import VelbusConfigEntry from .const import ( + CONF_CONFIG_ENTRY, CONF_INTERFACE, CONF_MEMO_TEXT, DOMAIN, @@ -32,6 +36,7 @@ def setup_services(hass: HomeAssistant) -> None: """Register the velbus services.""" def check_entry_id(interface: str) -> str: + """Check the config_entry for a specific interface.""" for config_entry in hass.config_entries.async_entries(DOMAIN): if "port" in config_entry.data and config_entry.data["port"] == interface: return config_entry.entry_id @@ -39,51 +44,71 @@ def setup_services(hass: HomeAssistant) -> None: "The interface provided is not defined as a port in a Velbus integration" ) - def get_config_entry(interface: str) -> VelbusConfigEntry | None: - for config_entry in hass.config_entries.async_entries(DOMAIN): - if "port" in config_entry.data and config_entry.data["port"] == interface: - return config_entry - return None + async def get_config_entry(call: ServiceCall) -> VelbusConfigEntry: + """Get the config entry for this service call.""" + if CONF_CONFIG_ENTRY in call.data: + entry_id = call.data[CONF_CONFIG_ENTRY] + elif CONF_INTERFACE in call.data: + # Deprecated in 2025.2, to remove in 2025.8 + async_create_issue( + hass, + DOMAIN, + "deprecated_interface_parameter", + breaks_in_ha_version="2025.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_interface_parameter", + ) + entry_id = call.data[CONF_INTERFACE] + if not (entry := hass.config_entries.async_get_entry(entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return entry async def scan(call: ServiceCall) -> None: """Handle a scan service call.""" - entry = get_config_entry(call.data[CONF_INTERFACE]) - if entry: - await entry.runtime_data.controller.scan() + entry = await get_config_entry(call) + await entry.runtime_data.controller.scan() async def syn_clock(call: ServiceCall) -> None: """Handle a sync clock service call.""" - entry = get_config_entry(call.data[CONF_INTERFACE]) - if entry: - await entry.runtime_data.controller.sync_clock() + entry = await get_config_entry(call) + await entry.runtime_data.controller.sync_clock() async def set_memo_text(call: ServiceCall) -> None: """Handle Memo Text service call.""" - entry = get_config_entry(call.data[CONF_INTERFACE]) - if entry: - memo_text = call.data[CONF_MEMO_TEXT] - module = entry.runtime_data.controller.get_module(call.data[CONF_ADDRESS]) - if module: - await module.set_memo_text(memo_text.async_render()) + entry = await get_config_entry(call) + memo_text = call.data[CONF_MEMO_TEXT] + module = entry.runtime_data.controller.get_module(call.data[CONF_ADDRESS]) + if not module: + raise ServiceValidationError("Module not found") + await module.set_memo_text(memo_text.async_render()) async def clear_cache(call: ServiceCall) -> None: """Handle a clear cache service call.""" - # clear the cache + entry = await get_config_entry(call) with suppress(FileNotFoundError): if call.data.get(CONF_ADDRESS): await hass.async_add_executor_job( os.unlink, hass.config.path( STORAGE_DIR, - f"velbuscache-{call.data[CONF_INTERFACE]}/{call.data[CONF_ADDRESS]}.p", + f"velbuscache-{entry.entry_id}/{call.data[CONF_ADDRESS]}.p", ), ) else: await hass.async_add_executor_job( shutil.rmtree, - hass.config.path( - STORAGE_DIR, f"velbuscache-{call.data[CONF_INTERFACE]}/" - ), + hass.config.path(STORAGE_DIR, f"velbuscache-{entry.entry_id}/"), ) # call a scan to repopulate await scan(call) @@ -92,28 +117,73 @@ def setup_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_SCAN, scan, - vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ) + } + ), + ), ) hass.services.async_register( DOMAIN, SERVICE_SYNC, syn_clock, - vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ) + } + ), + ), ) hass.services.async_register( DOMAIN, SERVICE_SET_MEMO_TEXT, set_memo_text, - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - vol.Required(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, - } + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + vol.Required(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, + } + ), ), ) @@ -121,12 +191,26 @@ def setup_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_CLEAR_CACHE, clear_cache, - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - vol.Optional(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - } + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + vol.Optional(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Optional(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + } + ), ), ) diff --git a/homeassistant/components/velbus/services.yaml b/homeassistant/components/velbus/services.yaml index e3ecc3556f0..39886913692 100644 --- a/homeassistant/components/velbus/services.yaml +++ b/homeassistant/components/velbus/services.yaml @@ -1,29 +1,38 @@ sync_clock: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus scan: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus clear_cache: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus address: required: false selector: @@ -34,11 +43,14 @@ clear_cache: set_memo_text: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus address: required: true selector: diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index be1d992056e..90938a6c1d2 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -20,6 +20,12 @@ "exceptions": { "invalid_hvac_mode": { "message": "Climate mode {hvac_mode} is not supported." + }, + "not_loaded": { + "message": "{target} is not loaded." + }, + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." } }, "services": { @@ -30,6 +36,10 @@ "interface": { "name": "Interface", "description": "The velbus interface to send the command to, this will be the same value as used during configuration." + }, + "config_entry": { + "name": "Config entry", + "description": "The config entry of the velbus integration" } } }, @@ -40,6 +50,10 @@ "interface": { "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" + }, + "config_entry": { + "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", + "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" } } }, @@ -51,6 +65,10 @@ "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" }, + "config_entry": { + "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", + "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" + }, "address": { "name": "Address", "description": "The module address in decimal format, if this is provided we only clear this module, if nothing is provided we clear the whole cache directory (all modules) The decimal addresses are displayed in front of the modules listed at the integration page." @@ -65,6 +83,10 @@ "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" }, + "config_entry": { + "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", + "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" + }, "address": { "name": "Address", "description": "The module address in decimal format. The decimal addresses are displayed in front of the modules listed at the integration page." @@ -75,5 +97,11 @@ } } } + }, + "issues": { + "deprecated_interface_parameter": { + "title": "Deprecated 'interface' parameter", + "description": "The 'interface' parameter in the Velbus service calls is deprecated. The 'config_entry' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." + } } } diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index 95f691b34f8..20d26a895c0 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -1,7 +1,7 @@ """Fixtures for the Velbus tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest from velbusaio.channels import ( @@ -72,6 +72,7 @@ def mock_controller( 4: mock_module_no_subdevices, 99: mock_module_subdevices, } + cont.get_module.return_value = mock_module_subdevices yield controller @@ -300,7 +301,7 @@ def mock_cover_no_position() -> AsyncMock: @pytest.fixture(name="config_entry") async def mock_config_entry( hass: HomeAssistant, - controller: MagicMock, + controller: AsyncMock, ) -> VelbusConfigEntry: """Create and register mock config entry.""" config_entry = MockConfigEntry( diff --git a/tests/components/velbus/test_services.py b/tests/components/velbus/test_services.py new file mode 100644 index 00000000000..2bcbac7b80d --- /dev/null +++ b/tests/components/velbus/test_services.py @@ -0,0 +1,172 @@ +"""Velbus services tests.""" + +from unittest.mock import AsyncMock + +import pytest +import voluptuous as vol + +from homeassistant.components.velbus.const import ( + CONF_CONFIG_ENTRY, + CONF_INTERFACE, + CONF_MEMO_TEXT, + DOMAIN, + SERVICE_CLEAR_CACHE, + SERVICE_SCAN, + SERVICE_SET_MEMO_TEXT, + SERVICE_SYNC, +) +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +import homeassistant.helpers.issue_registry as ir + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_global_services_with_interface( + hass: HomeAssistant, + config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test services directed at the bus with an interface parameter.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_INTERFACE: config_entry.data["port"]}, + blocking=True, + ) + config_entry.runtime_data.controller.scan.assert_called_once_with() + assert issue_registry.async_get_issue(DOMAIN, "deprecated_interface_parameter") + + await hass.services.async_call( + DOMAIN, + SERVICE_SYNC, + {CONF_INTERFACE: config_entry.data["port"]}, + blocking=True, + ) + config_entry.runtime_data.controller.sync_clock.assert_called_once_with() + + # Test invalid interface + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_INTERFACE: "nonexistent"}, + blocking=True, + ) + + # Test missing interface + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {}, + blocking=True, + ) + + +async def test_global_survices_with_config_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test services directed at the bus with a config_entry.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_CONFIG_ENTRY: config_entry.entry_id}, + blocking=True, + ) + config_entry.runtime_data.controller.scan.assert_called_once_with() + + await hass.services.async_call( + DOMAIN, + SERVICE_SYNC, + {CONF_CONFIG_ENTRY: config_entry.entry_id}, + blocking=True, + ) + config_entry.runtime_data.controller.sync_clock.assert_called_once_with() + + # Test invalid interface + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_CONFIG_ENTRY: "nonexistent"}, + blocking=True, + ) + + # Test missing interface + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {}, + blocking=True, + ) + + +async def test_set_memo_text( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: AsyncMock, +) -> None: + """Test the set_memo_text service.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MEMO_TEXT, + { + CONF_CONFIG_ENTRY: config_entry.entry_id, + CONF_MEMO_TEXT: "Test", + CONF_ADDRESS: 1, + }, + blocking=True, + ) + config_entry.runtime_data.controller.get_module( + 1 + ).set_memo_text.assert_called_once_with("Test") + + # Test with unfound module + controller.return_value.get_module.return_value = None + with pytest.raises(ServiceValidationError, match="Module not found"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MEMO_TEXT, + { + CONF_CONFIG_ENTRY: config_entry.entry_id, + CONF_MEMO_TEXT: "Test", + CONF_ADDRESS: 2, + }, + blocking=True, + ) + + +async def test_clear_cache( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test the clear_cache service.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAR_CACHE, + {CONF_CONFIG_ENTRY: config_entry.entry_id}, + blocking=True, + ) + config_entry.runtime_data.controller.scan.assert_called_once_with() + + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAR_CACHE, + {CONF_CONFIG_ENTRY: config_entry.entry_id, CONF_ADDRESS: 1}, + blocking=True, + ) + assert config_entry.runtime_data.controller.scan.call_count == 2 From b009f1101390ea57a33f251982abd3136a9863ec Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 13 Jan 2025 11:40:53 +0100 Subject: [PATCH 0344/2987] Fix referenced objects in script sequences (#135499) --- homeassistant/helpers/script.py | 9 +++++++ tests/helpers/test_script.py | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index f9d623a41c3..f3f798e1d6b 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1589,6 +1589,9 @@ class Script: target, referenced, script[CONF_SEQUENCE] ) + elif action == cv.SCRIPT_ACTION_SEQUENCE: + Script._find_referenced_target(target, referenced, step[CONF_SEQUENCE]) + @cached_property def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" @@ -1636,6 +1639,9 @@ class Script: for script in step[CONF_PARALLEL]: Script._find_referenced_devices(referenced, script[CONF_SEQUENCE]) + elif action == cv.SCRIPT_ACTION_SEQUENCE: + Script._find_referenced_devices(referenced, step[CONF_SEQUENCE]) + @cached_property def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" @@ -1684,6 +1690,9 @@ class Script: for script in step[CONF_PARALLEL]: Script._find_referenced_entities(referenced, script[CONF_SEQUENCE]) + elif action == cv.SCRIPT_ACTION_SEQUENCE: + Script._find_referenced_entities(referenced, step[CONF_SEQUENCE]) + def run( self, variables: _VarsType | None = None, context: Context | None = None ) -> None: diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index c438e333ae6..d7c00e90bd6 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -4118,6 +4118,14 @@ async def test_referenced_labels(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"label_id": "label_sequence"}, + } + ], + }, ] ), "Test Name", @@ -4135,6 +4143,7 @@ async def test_referenced_labels(hass: HomeAssistant) -> None: "label_if_then", "label_if_else", "label_parallel", + "label_sequence", } # Test we cache results. assert script_obj.referenced_labels is script_obj.referenced_labels @@ -4220,6 +4229,14 @@ async def test_referenced_floors(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"floor_id": "floor_sequence"}, + } + ], + }, ] ), "Test Name", @@ -4236,6 +4253,7 @@ async def test_referenced_floors(hass: HomeAssistant) -> None: "floor_if_then", "floor_if_else", "floor_parallel", + "floor_sequence", } # Test we cache results. assert script_obj.referenced_floors is script_obj.referenced_floors @@ -4321,6 +4339,14 @@ async def test_referenced_areas(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"area_id": "area_sequence"}, + } + ], + }, ] ), "Test Name", @@ -4337,6 +4363,7 @@ async def test_referenced_areas(hass: HomeAssistant) -> None: "area_if_then", "area_if_else", "area_parallel", + "area_sequence", # 'area_service_template', # no area extraction from template } # Test we cache results. @@ -4437,6 +4464,14 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"entity_id": "light.sequence"}, + } + ], + }, ] ), "Test Name", @@ -4456,6 +4491,7 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: "light.if_then", "light.if_else", "light.parallel", + "light.sequence", # "light.service_template", # no entity extraction from template "scene.hello", "sensor.condition", @@ -4554,6 +4590,14 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "target": {"device_id": "sequence-device"}, + } + ], + }, ] ), "Test Name", @@ -4575,6 +4619,7 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: "if-then", "if-else", "parallel-device", + "sequence-device", } # Test we cache results. assert script_obj.referenced_devices is script_obj.referenced_devices From 1ceebd92a9b43a0eb4b8f5eaa03e5b4b51172443 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 13 Jan 2025 11:48:00 +0100 Subject: [PATCH 0345/2987] Change icon ID name in Lametric (#135368) --- homeassistant/components/lametric/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index 01e7823c76b..3c2f05fa535 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -138,7 +138,7 @@ "description": "The message to display." }, "icon": { - "name": "Icon", + "name": "Icon ID", "description": "The ID number of the icon or animation to display. List of all icons and their IDs can be found at: https://developer.lametric.com/icons." }, "sound": { From a649ff4a916ac04db5e529fe1228e4b52876948f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jan 2025 11:55:18 +0100 Subject: [PATCH 0346/2987] Add hassio discovery to onewire (#135294) --- .../components/onewire/config_flow.py | 36 +++++++++++ .../components/onewire/quality_scale.yaml | 6 +- tests/components/onewire/test_config_flow.py | 62 ++++++++++++++++++- 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index b9f6ba77c2e..9ab8dc32f68 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -13,6 +13,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .const import ( DEFAULT_HOST, @@ -51,6 +52,7 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): """Handle 1-Wire config flow.""" VERSION = 1 + _discovery_data: dict[str, Any] async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -100,6 +102,40 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle hassio discovery.""" + await self._async_handle_discovery_without_unique_id() + + self._discovery_data = { + "title": discovery_info.config["addon"], + CONF_HOST: discovery_info.config[CONF_HOST], + CONF_PORT: discovery_info.config[CONF_PORT], + } + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + errors: dict[str, str] = {} + if user_input is not None: + data = { + CONF_HOST: self._discovery_data[CONF_HOST], + CONF_PORT: self._discovery_data[CONF_PORT], + } + await validate_input(self.hass, data, errors) + if not errors: + return self.async_create_entry( + title=self._discovery_data["title"], data=data + ) + + return self.async_show_form( + step_id="discovery_confirm", + errors=errors, + ) + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/onewire/quality_scale.yaml b/homeassistant/components/onewire/quality_scale.yaml index 9e706c16607..b64bfb775ce 100644 --- a/homeassistant/components/onewire/quality_scale.yaml +++ b/homeassistant/components/onewire/quality_scale.yaml @@ -66,8 +66,10 @@ rules: entity-category: done entity-disabled-by-default: done discovery: - status: todo - comment: mDNS should be possible - https://owfs.org/index_php_page_avahi-discovery.html + status: done + comment: | + hassio discovery implemented, mDNS/zeroconf should also be possible + https://owfs.org/index_php_page_avahi-discovery.html (see PR 135295) stale-devices: status: done comment: > diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index 0c7daf2aeff..09a7cdfcbb0 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -11,14 +11,22 @@ from homeassistant.components.onewire.const import ( INPUT_ENTRY_DEVICE_SELECTION, MANUFACTURER_MAXIM, ) -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from tests.common import MockConfigEntry +_HASSIO_DISCOVERY = HassioServiceInfo( + config={"host": "1302b8e0-owserver", "port": 4304, "addon": "owserver (1-wire)"}, + name="owserver (1-wire)", + slug="1302b8e0_owserver", + uuid="e3fa56560d93458b96a594cbcea3017e", +) + pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -190,6 +198,58 @@ async def test_reconfigure_duplicate( assert other_config_entry.data == {CONF_HOST: "2.3.4.5", CONF_PORT: 2345} +async def test_hassio_flow(hass: HomeAssistant) -> None: + """Test HassIO discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_HASSIO}, + data=_HASSIO_DISCOVERY, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert not result["errors"] + + # Cannot connect to server => retry + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + side_effect=protocol.ConnError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + # Connect OK + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + new_entry = result["result"] + assert new_entry.title == "owserver (1-wire)" + assert new_entry.data == {CONF_HOST: "1302b8e0-owserver", CONF_PORT: 4304} + + +@pytest.mark.usefixtures("config_entry") +async def test_hassio_duplicate(hass: HomeAssistant) -> None: + """Test HassIO discovery duplicate flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_HASSIO}, + data=_HASSIO_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures("filled_device_registry") async def test_user_options_clear( hass: HomeAssistant, config_entry: MockConfigEntry From 96ad2b6ed87a9d6269f9374ea263573a0c58ae11 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 13 Jan 2025 11:55:55 +0100 Subject: [PATCH 0347/2987] =?UTF-8?q?Replace=20"Login=20=E2=80=A6"=20with?= =?UTF-8?q?=20"Log=20in=20=E2=80=A6"=20in=20two=20strings=20of=20Habitica?= =?UTF-8?q?=20integration=20(#135383)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/habitica/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index fc6d6aee687..3e29b9110be 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -25,8 +25,8 @@ "user": { "title": "Habitica - Gamify your life", "menu_options": { - "login": "Login to Habitica", - "advanced": "Login to other instances" + "login": "Log in to Habitica", + "advanced": "Log in to other instances" }, "description": "![Habiticans]({habiticans}) Connect your Habitica account to keep track of your adventurer's stats, progress, and manage your to-dos and daily tasks.\n\n[Don't have a Habitica account? Sign up here.]({signup})" }, From 25041aa02dc95afedea48d3a99d344a98ddba809 Mon Sep 17 00:00:00 2001 From: Paul Daumlechner Date: Mon, 13 Jan 2025 12:01:04 +0100 Subject: [PATCH 0348/2987] Add dhcp discovery to velux (#135138) Co-authored-by: Joostlek --- CODEOWNERS | 4 +- homeassistant/components/velux/config_flow.py | 112 ++++++- homeassistant/components/velux/manifest.json | 8 +- homeassistant/components/velux/strings.json | 7 + homeassistant/generated/dhcp.py | 5 + tests/components/velux/conftest.py | 46 +++ tests/components/velux/test_config_flow.py | 312 +++++++++++++++--- 7 files changed, 418 insertions(+), 76 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index a8a53f8272d..748d461d3ce 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1627,8 +1627,8 @@ build.json @home-assistant/supervisor /tests/components/valve/ @home-assistant/core /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra -/homeassistant/components/velux/ @Julius2342 @DeerMaximum -/tests/components/velux/ @Julius2342 @DeerMaximum +/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio +/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio /homeassistant/components/venstar/ @garbled1 @jhollowe /tests/components/venstar/ @garbled1 @jhollowe /homeassistant/components/versasense/ @imstevenxyz diff --git a/homeassistant/components/velux/config_flow.py b/homeassistant/components/velux/config_flow.py index f4bfa13b4d5..da6745a6673 100644 --- a/homeassistant/components/velux/config_flow.py +++ b/homeassistant/components/velux/config_flow.py @@ -1,15 +1,19 @@ """Config flow for Velux integration.""" +from typing import Any + from pyvlx import PyVLX, PyVLXException import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.components.dhcp import DhcpServiceInfo +from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN, LOGGER -DATA_SCHEMA = vol.Schema( +USER_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, @@ -17,9 +21,31 @@ DATA_SCHEMA = vol.Schema( ) +async def _check_connection(host: str, password: str) -> dict[str, Any]: + """Check if we can connect to the Velux bridge.""" + pyvlx = PyVLX(host=host, password=password) + try: + await pyvlx.connect() + await pyvlx.disconnect() + except (PyVLXException, ConnectionError) as err: + LOGGER.debug("Cannot connect: %s", err) + return {"base": "cannot_connect"} + except Exception as err: # noqa: BLE001 + LOGGER.exception("Unexpected exception: %s", err) + return {"base": "unknown"} + + return {} + + class VeluxConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for velux.""" + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self.discovery_data: dict[str, Any] = {} + async def async_step_user( self, user_input: dict[str, str] | None = None ) -> ConfigFlowResult: @@ -28,28 +54,78 @@ class VeluxConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - - pyvlx = PyVLX( - host=user_input[CONF_HOST], password=user_input[CONF_PASSWORD] + errors = await _check_connection( + user_input[CONF_HOST], user_input[CONF_PASSWORD] ) - try: - await pyvlx.connect() - await pyvlx.disconnect() - except (PyVLXException, ConnectionError) as err: - errors["base"] = "cannot_connect" - LOGGER.debug("Cannot connect: %s", err) - except Exception as err: # noqa: BLE001 - LOGGER.exception("Unexpected exception: %s", err) - errors["base"] = "unknown" - else: + if not errors: return self.async_create_entry( title=user_input[CONF_HOST], data=user_input, ) - data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input) return self.async_show_form( step_id="user", - data_schema=data_schema, + data_schema=USER_SCHEMA, errors=errors, ) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle discovery by DHCP.""" + # The hostname ends with the last 4 digits of the device MAC address. + self.discovery_data[CONF_HOST] = discovery_info.ip + self.discovery_data[CONF_MAC] = format_mac(discovery_info.macaddress) + self.discovery_data[CONF_NAME] = discovery_info.hostname.upper().replace( + "LAN_", "" + ) + + await self.async_set_unique_id(self.discovery_data[CONF_NAME]) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self.discovery_data[CONF_HOST]} + ) + + # Abort if config_entry already exists without unigue_id configured. + for entry in self.hass.config_entries.async_entries(DOMAIN): + if ( + entry.data[CONF_HOST] == self.discovery_data[CONF_HOST] + and entry.unique_id is None + and entry.state is ConfigEntryState.LOADED + ): + self.hass.config_entries.async_update_entry( + entry=entry, + unique_id=self.discovery_data[CONF_NAME], + data={**entry.data, **self.discovery_data}, + ) + return self.async_abort(reason="already_configured") + self._async_abort_entries_match({CONF_HOST: self.discovery_data[CONF_HOST]}) + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Prepare configuration for a discovered Velux device.""" + errors: dict[str, str] = {} + if user_input is not None: + errors = await _check_connection( + self.discovery_data[CONF_HOST], user_input[CONF_PASSWORD] + ) + if not errors: + return self.async_create_entry( + title=self.discovery_data[CONF_NAME], + data={**self.discovery_data, **user_input}, + ) + + return self.async_show_form( + step_id="discovery_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): cv.string, + } + ), + errors=errors, + description_placeholders={ + "name": self.discovery_data[CONF_NAME], + "host": self.discovery_data[CONF_HOST], + }, + ) diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index 053b7fcc594..cb21fef299d 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -1,8 +1,14 @@ { "domain": "velux", "name": "Velux", - "codeowners": ["@Julius2342", "@DeerMaximum"], + "codeowners": ["@Julius2342", "@DeerMaximum", "@pawlizio"], "config_flow": true, + "dhcp": [ + { + "hostname": "velux_klf*", + "macaddress": "646184*" + } + ], "documentation": "https://www.home-assistant.io/integrations/velux", "iot_class": "local_polling", "loggers": ["pyvlx"], diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index 5b7b459a3f7..1d0f86bfc6b 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -7,6 +7,13 @@ "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "discovery_confirm": { + "title": "Setup Velux", + "description": "Please enter the password for {name} ({host})", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 67531ceced8..5fef087a868 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -1111,6 +1111,11 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "unifiprotect", "macaddress": "74ACB9*", }, + { + "domain": "velux", + "hostname": "velux_klf*", + "macaddress": "646184*", + }, { "domain": "verisure", "macaddress": "0023C1*", diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index 512b2a007ed..c88a21d2bba 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -5,6 +5,11 @@ from unittest.mock import AsyncMock, patch import pytest +from homeassistant.components.velux import DOMAIN +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD + +from tests.common import MockConfigEntry + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -13,3 +18,44 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.velux.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_velux_client() -> Generator[AsyncMock]: + """Mock a Velux client.""" + with ( + patch( + "homeassistant.components.velux.config_flow.PyVLX", + autospec=True, + ) as mock_client, + ): + client = mock_client.return_value + yield client + + +@pytest.fixture +def mock_user_config_entry() -> MockConfigEntry: + """Return the user config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="127.0.0.1", + data={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + + +@pytest.fixture +def mock_discovered_config_entry() -> MockConfigEntry: + """Return the user config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="127.0.0.1", + data={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + CONF_MAC: "64:61:84:00:ab:cd", + }, + unique_id="VELUX_KLF_ABCD", + ) diff --git a/tests/components/velux/test_config_flow.py b/tests/components/velux/test_config_flow.py index 5f7932d358a..19512337590 100644 --- a/tests/components/velux/test_config_flow.py +++ b/tests/components/velux/test_config_flow.py @@ -2,86 +2,288 @@ from __future__ import annotations -from copy import deepcopy -from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock import pytest from pyvlx import PyVLXException +from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.velux import DOMAIN -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -DUMMY_DATA: dict[str, Any] = { - CONF_HOST: "127.0.0.1", - CONF_PASSWORD: "NotAStrongPassword", -} - -PYVLX_CONFIG_FLOW_CONNECT_FUNCTION_PATH = ( - "homeassistant.components.velux.config_flow.PyVLX.connect" +DHCP_DISCOVERY = DhcpServiceInfo( + ip="127.0.0.1", + hostname="VELUX_KLF_LAN_ABCD", + macaddress="64618400abcd", ) -PYVLX_CONFIG_FLOW_CLASS_PATH = "homeassistant.components.velux.config_flow.PyVLX" - -error_types_to_test: list[tuple[Exception, str]] = [ - (PyVLXException("DUMMY"), "cannot_connect"), - (Exception("DUMMY"), "unknown"), -] - -pytest.mark.usefixtures("mock_setup_entry") -async def test_user_success(hass: HomeAssistant) -> None: +async def test_user_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_velux_client: AsyncMock, +) -> None: """Test starting a flow by user with valid values.""" - with patch(PYVLX_CONFIG_FLOW_CLASS_PATH, autospec=True) as client_mock: - result: dict[str, Any] = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA) - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) - client_mock.return_value.disconnect.assert_called_once() - client_mock.return_value.connect.assert_called_once() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == DUMMY_DATA[CONF_HOST] - assert result["data"] == DUMMY_DATA + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "127.0.0.1" + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + } + assert not result["result"].unique_id + + mock_velux_client.disconnect.assert_called_once() + mock_velux_client.connect.assert_called_once() -@pytest.mark.parametrize(("error", "error_name"), error_types_to_test) +@pytest.mark.parametrize( + ("exception", "error"), + [ + (PyVLXException("DUMMY"), "cannot_connect"), + (Exception("DUMMY"), "unknown"), + ], +) async def test_user_errors( - hass: HomeAssistant, error: Exception, error_name: str + hass: HomeAssistant, + mock_velux_client: AsyncMock, + exception: Exception, + error: str, + mock_setup_entry: AsyncMock, ) -> None: """Test starting a flow by user but with exceptions.""" - with patch( - PYVLX_CONFIG_FLOW_CONNECT_FUNCTION_PATH, side_effect=error - ) as connect_mock: - result: dict[str, Any] = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA) - ) - connect_mock.assert_called_once() + mock_velux_client.connect.side_effect = exception - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": error_name} + 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"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_velux_client.connect.assert_called_once() + + mock_velux_client.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_flow_duplicate_entry(hass: HomeAssistant) -> None: +async def test_user_flow_duplicate_entry( + hass: HomeAssistant, + mock_user_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: """Test initialized flow with a duplicate entry.""" - with patch(PYVLX_CONFIG_FLOW_CLASS_PATH, autospec=True): - conf_entry: MockConfigEntry = MockConfigEntry( - domain=DOMAIN, title=DUMMY_DATA[CONF_HOST], data=DUMMY_DATA - ) + mock_user_config_entry.add_to_hass(hass) - conf_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=DUMMY_DATA, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery( + hass: HomeAssistant, + mock_velux_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we can setup from dhcp discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "NotAStrongPassword"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "VELUX_KLF_ABCD" + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_MAC: "64:61:84:00:ab:cd", + CONF_NAME: "VELUX_KLF_ABCD", + CONF_PASSWORD: "NotAStrongPassword", + } + assert result["result"].unique_id == "VELUX_KLF_ABCD" + + mock_velux_client.disconnect.assert_called() + mock_velux_client.connect.assert_called() + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (PyVLXException("DUMMY"), "cannot_connect"), + (Exception("DUMMY"), "unknown"), + ], +) +async def test_dhcp_discovery_errors( + hass: HomeAssistant, + mock_velux_client: AsyncMock, + exception: Exception, + error: str, + mock_setup_entry: AsyncMock, +) -> None: + """Test we can setup from dhcp discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + mock_velux_client.connect.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "NotAStrongPassword"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert result["errors"] == {"base": error} + + mock_velux_client.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "NotAStrongPassword"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "VELUX_KLF_ABCD" + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_MAC: "64:61:84:00:ab:cd", + CONF_NAME: "VELUX_KLF_ABCD", + CONF_PASSWORD: "NotAStrongPassword", + } + + +async def test_dhcp_discovery_already_configured( + hass: HomeAssistant, + mock_velux_client: AsyncMock, + mock_discovered_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test dhcp discovery when already configured.""" + mock_discovered_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discover_unique_id( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_velux_client: AsyncMock, + mock_user_config_entry: MockConfigEntry, +) -> None: + """Test dhcp discovery when already configured.""" + mock_user_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_user_config_entry.entry_id) + + assert mock_user_config_entry.state is ConfigEntryState.LOADED + assert mock_user_config_entry.unique_id is None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_user_config_entry.unique_id == "VELUX_KLF_ABCD" + + +async def test_dhcp_discovery_not_loaded( + hass: HomeAssistant, + mock_velux_client: AsyncMock, + mock_user_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test dhcp discovery when entry with same host not loaded.""" + mock_user_config_entry.add_to_hass(hass) + + assert mock_user_config_entry.state is not ConfigEntryState.LOADED + assert mock_user_config_entry.unique_id is None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_user_config_entry.unique_id is None From c15073cc27ece258c7059049020fcf0fa0137969 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 13 Jan 2025 12:11:01 +0100 Subject: [PATCH 0349/2987] Fix incorrect cast in HitachiAirToWaterHeatingZone in Overkiz (#135468) --- .../overkiz/climate/hitachi_air_to_water_heating_zone.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py index 8410e50873d..c5465128bba 100644 --- a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py +++ b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py @@ -119,5 +119,5 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity): temperature = cast(float, kwargs.get(ATTR_TEMPERATURE)) await self.executor.async_execute_command( - OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1, int(temperature) + OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1, float(temperature) ) From fba1b4be5b658f98aeb04513436c162def0a255a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 13 Jan 2025 12:32:07 +0100 Subject: [PATCH 0350/2987] Replace "click" with "select" to fit for mobile app (#135382) --- homeassistant/components/broadlink/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/broadlink/strings.json b/homeassistant/components/broadlink/strings.json index 17c98f0182f..492023afd66 100644 --- a/homeassistant/components/broadlink/strings.json +++ b/homeassistant/components/broadlink/strings.json @@ -17,7 +17,7 @@ }, "reset": { "title": "Unlock the device", - "description": "{name} ({model} at {host}) is locked. You need to unlock the device in order to authenticate and complete the configuration. Instructions:\n1. Open the Broadlink app.\n2. Click on the device.\n3. Click `...` in the upper right.\n4. Scroll to the bottom of the page.\n5. Disable the lock." + "description": "{name} ({model} at {host}) is locked. You need to unlock the device in order to authenticate and complete the configuration. Instructions:\n1. Open the Broadlink app.\n2. Select the device.\n3. Select `...` in the upper right.\n4. Scroll to the bottom of the page.\n5. Disable the lock." }, "unlock": { "title": "Unlock the device (optional)", From dae87db2446e11cf1187fbe6c8a7284968ad5791 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 13 Jan 2025 21:44:36 +1000 Subject: [PATCH 0351/2987] Fix when live status is blank in Telsemetry (#130408) --- .../components/teslemetry/__init__.py | 19 ++++++++++++++----- .../components/teslemetry/binary_sensor.py | 1 + .../components/teslemetry/coordinator.py | 10 +++++++++- .../components/teslemetry/diagnostics.py | 4 +++- homeassistant/components/teslemetry/entity.py | 4 ++++ homeassistant/components/teslemetry/models.py | 2 +- homeassistant/components/teslemetry/sensor.py | 3 +++ tests/components/teslemetry/test_init.py | 11 +++++++++++ 8 files changed, 46 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 2d35720d1b4..285aff1d0cf 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -7,6 +7,7 @@ from typing import Final from tesla_fleet_api import EnergySpecific, Teslemetry, VehicleSpecific from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( + Forbidden, InvalidToken, SubscriptionRequired, TeslaFleetError, @@ -163,10 +164,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - serial_number=str(site_id), ) + # Check live status endpoint works before creating its coordinator + try: + live_status = (await api.live_status())["response"] + except (InvalidToken, Forbidden, SubscriptionRequired) as e: + raise ConfigEntryAuthFailed from e + except TeslaFleetError as e: + raise ConfigEntryNotReady(e.message) from e + energysites.append( TeslemetryEnergyData( api=api, - live_coordinator=TeslemetryEnergySiteLiveCoordinator(hass, api), + live_coordinator=( + TeslemetryEnergySiteLiveCoordinator(hass, api, live_status) + if isinstance(live_status, dict) + else None + ), info_coordinator=TeslemetryEnergySiteInfoCoordinator( hass, api, product ), @@ -187,10 +200,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - vehicle.coordinator.async_config_entry_first_refresh() for vehicle in vehicles ), - *( - energysite.live_coordinator.async_config_entry_first_refresh() - for energysite in energysites - ), *( energysite.info_coordinator.async_config_entry_first_refresh() for energysite in energysites diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 29ebfea4db1..e7016fe4a91 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -193,6 +193,7 @@ async def async_setup_entry( ( # Energy Site Live TeslemetryEnergyLiveBinarySensorEntity(energysite, description) for energysite in entry.runtime_data.energysites + if energysite.live_coordinator for description in ENERGY_LIVE_DESCRIPTIONS if energysite.info_coordinator.data.get("components_battery") ), diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 303a3250edf..d39402c622c 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -69,7 +69,9 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site live status from the Teslemetry API.""" - def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + updated_once: bool + + def __init__(self, hass: HomeAssistant, api: EnergySpecific, data: dict) -> None: """Initialize Teslemetry Energy Site Live coordinator.""" super().__init__( hass, @@ -79,6 +81,12 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) ) self.api = api + # Convert Wall Connectors from array to dict + data["wall_connectors"] = { + wc["din"]: wc for wc in (data.get("wall_connectors") or []) + } + self.data = data + async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Teslemetry API.""" diff --git a/homeassistant/components/teslemetry/diagnostics.py b/homeassistant/components/teslemetry/diagnostics.py index 7e9c8a9a5b0..fc601a58ae6 100644 --- a/homeassistant/components/teslemetry/diagnostics.py +++ b/homeassistant/components/teslemetry/diagnostics.py @@ -41,7 +41,9 @@ async def async_get_config_entry_diagnostics( ] energysites = [ { - "live": async_redact_data(x.live_coordinator.data, ENERGY_LIVE_REDACT), + "live": async_redact_data(x.live_coordinator.data, ENERGY_LIVE_REDACT) + if x.live_coordinator + else None, "info": async_redact_data(x.info_coordinator.data, ENERGY_INFO_REDACT), } for x in entry.runtime_data.energysites diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index f2126dddf4b..5178c543f1a 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -136,6 +136,8 @@ class TeslemetryEnergyLiveEntity(TeslemetryEntity): ) -> None: """Initialize common aspects of a Teslemetry Energy Site Live entity.""" + assert data.live_coordinator + self.api = data.api self._attr_unique_id = f"{data.id}-{key}" self._attr_device_info = data.device @@ -195,6 +197,8 @@ class TeslemetryWallConnectorEntity(TeslemetryEntity): ) -> None: """Initialize common aspects of a Teslemetry entity.""" + assert data.live_coordinator + self.api = data.api self.din = din self._attr_unique_id = f"{data.id}-{din}-{key}" diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index c2f50ab90df..547bda4be9b 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -50,7 +50,7 @@ class TeslemetryEnergyData: """Data for a vehicle in the Teslemetry integration.""" api: EnergySpecific - live_coordinator: TeslemetryEnergySiteLiveCoordinator + live_coordinator: TeslemetryEnergySiteLiveCoordinator | None info_coordinator: TeslemetryEnergySiteInfoCoordinator history_coordinator: TeslemetryEnergyHistoryCoordinator | None id: int diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index cf4be6e8cda..524d8579703 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -523,6 +523,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Teslemetry sensor platform from a config entry.""" + entities: list[SensorEntity] = [] for vehicle in entry.runtime_data.vehicles: for description in VEHICLE_DESCRIPTIONS: @@ -551,6 +552,7 @@ async def async_setup_entry( entities.extend( TeslemetryEnergyLiveSensorEntity(energysite, description) for energysite in entry.runtime_data.energysites + if energysite.live_coordinator for description in ENERGY_LIVE_DESCRIPTIONS if description.key in energysite.live_coordinator.data ) @@ -558,6 +560,7 @@ async def async_setup_entry( entities.extend( TeslemetryWallConnectorSensorEntity(energysite, din, description) for energysite in entry.runtime_data.energysites + if energysite.live_coordinator for din in energysite.live_coordinator.data.get("wall_connectors", {}) for description in WALL_CONNECTOR_DESCRIPTIONS ) diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 3794ffb93d8..5481e6cc034 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -179,3 +179,14 @@ async def test_vehicle_stream( state = hass.states.get("binary_sensor.test_status") assert state.state == STATE_OFF + + +async def test_no_live_status( + hass: HomeAssistant, + mock_live_status: AsyncMock, +) -> None: + """Test coordinator refresh with an error.""" + mock_live_status.side_effect = AsyncMock({"response": ""}) + await setup_platform(hass) + + assert hass.states.get("sensor.energy_site_grid_power") is None From 7b63c1710151b53c88e5e807ecb6c41854295ed7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Schl=C3=B6tterer?= <80917404+lschloetterer@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:00:35 +0100 Subject: [PATCH 0352/2987] Add kV and MV unit conversion for voltages (#135396) --- homeassistant/components/number/const.py | 2 +- homeassistant/components/sensor/const.py | 2 +- homeassistant/const.py | 2 ++ homeassistant/util/unit_conversion.py | 4 ++++ tests/util/test_unit_conversion.py | 14 ++++++++++++++ 5 files changed, 22 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 91a9d6adfe4..1a9c6c91ca7 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -363,7 +363,7 @@ class NumberDeviceClass(StrEnum): VOLTAGE = "voltage" """Voltage. - Unit of measurement: `V`, `mV`, `µV` + Unit of measurement: `V`, `mV`, `µV`, `kV`, `MV` """ VOLUME = "volume" diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 8c3c3925513..aaa14f4637c 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -392,7 +392,7 @@ class SensorDeviceClass(StrEnum): VOLTAGE = "voltage" """Voltage. - Unit of measurement: `V`, `mV`, `µV` + Unit of measurement: `V`, `mV`, `µV`, `kV`, `MV` """ VOLUME = "volume" diff --git a/homeassistant/const.py b/homeassistant/const.py index 4c4d2fa90c2..efc01047caf 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -647,6 +647,8 @@ class UnitOfElectricPotential(StrEnum): MICROVOLT = "µV" MILLIVOLT = "mV" VOLT = "V" + KILOVOLT = "kV" + MEGAVOLT = "MV" # Degree units diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 8ea290f01d1..ad320cdb9ae 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -249,11 +249,15 @@ class ElectricPotentialConverter(BaseUnitConverter): UnitOfElectricPotential.VOLT: 1, UnitOfElectricPotential.MILLIVOLT: 1e3, UnitOfElectricPotential.MICROVOLT: 1e6, + UnitOfElectricPotential.KILOVOLT: 1 / 1e3, + UnitOfElectricPotential.MEGAVOLT: 1 / 1e6, } VALID_UNITS = { UnitOfElectricPotential.VOLT, UnitOfElectricPotential.MILLIVOLT, UnitOfElectricPotential.MICROVOLT, + UnitOfElectricPotential.KILOVOLT, + UnitOfElectricPotential.MEGAVOLT, } diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 9c123d93f62..1336364f4cb 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -435,10 +435,24 @@ _CONVERTED_VALUE: dict[ ElectricPotentialConverter: [ (5, UnitOfElectricPotential.VOLT, 5000, UnitOfElectricPotential.MILLIVOLT), (5, UnitOfElectricPotential.VOLT, 5e6, UnitOfElectricPotential.MICROVOLT), + (5, UnitOfElectricPotential.VOLT, 5e-3, UnitOfElectricPotential.KILOVOLT), + (5, UnitOfElectricPotential.VOLT, 5e-6, UnitOfElectricPotential.MEGAVOLT), (5, UnitOfElectricPotential.MILLIVOLT, 0.005, UnitOfElectricPotential.VOLT), (5, UnitOfElectricPotential.MILLIVOLT, 5e3, UnitOfElectricPotential.MICROVOLT), + (5, UnitOfElectricPotential.MILLIVOLT, 5e-6, UnitOfElectricPotential.KILOVOLT), + (5, UnitOfElectricPotential.MILLIVOLT, 5e-9, UnitOfElectricPotential.MEGAVOLT), (5, UnitOfElectricPotential.MICROVOLT, 5e-3, UnitOfElectricPotential.MILLIVOLT), (5, UnitOfElectricPotential.MICROVOLT, 5e-6, UnitOfElectricPotential.VOLT), + (5, UnitOfElectricPotential.MICROVOLT, 5e-9, UnitOfElectricPotential.KILOVOLT), + (5, UnitOfElectricPotential.MICROVOLT, 5e-12, UnitOfElectricPotential.MEGAVOLT), + (5, UnitOfElectricPotential.KILOVOLT, 5e9, UnitOfElectricPotential.MICROVOLT), + (5, UnitOfElectricPotential.KILOVOLT, 5e6, UnitOfElectricPotential.MILLIVOLT), + (5, UnitOfElectricPotential.KILOVOLT, 5e3, UnitOfElectricPotential.VOLT), + (5, UnitOfElectricPotential.KILOVOLT, 5e-3, UnitOfElectricPotential.MEGAVOLT), + (5, UnitOfElectricPotential.MEGAVOLT, 5e12, UnitOfElectricPotential.MICROVOLT), + (5, UnitOfElectricPotential.MEGAVOLT, 5e9, UnitOfElectricPotential.MILLIVOLT), + (5, UnitOfElectricPotential.MEGAVOLT, 5e6, UnitOfElectricPotential.VOLT), + (5, UnitOfElectricPotential.MEGAVOLT, 5e3, UnitOfElectricPotential.KILOVOLT), ], EnergyConverter: [ (10, UnitOfEnergy.MILLIWATT_HOUR, 0.00001, UnitOfEnergy.KILO_WATT_HOUR), From 3aa466806ed849e155b9e4220c64e4a10724e485 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Mon, 13 Jan 2025 04:11:56 -0800 Subject: [PATCH 0353/2987] TotalConnect update quality_scale with documentation updates (#134049) --- .../components/totalconnect/quality_scale.yaml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/totalconnect/quality_scale.yaml b/homeassistant/components/totalconnect/quality_scale.yaml index 606f1b3b6c3..2ec54250b72 100644 --- a/homeassistant/components/totalconnect/quality_scale.yaml +++ b/homeassistant/components/totalconnect/quality_scale.yaml @@ -15,7 +15,7 @@ rules: common-modules: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done docs-actions: done brands: done @@ -47,13 +47,11 @@ rules: discovery-update-info: todo repair-issues: todo docs-use-cases: done - - # stopped here.... - docs-supported-devices: todo - docs-supported-functions: todo - docs-data-update: todo - docs-known-limitations: todo - docs-troubleshooting: todo + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done docs-examples: done # Platinum From 4dbf2b032066bddca3ee96afa564ac5aad14089f Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:20:15 +0100 Subject: [PATCH 0354/2987] Fix grey dailies with weekly frequency and no weekdays selected in Habitica (#135419) --- homeassistant/components/habitica/calendar.py | 12 +- homeassistant/components/habitica/util.py | 4 +- .../habitica/fixtures/duedate_fixture_9.json | 51 +++ tests/components/habitica/fixtures/tasks.json | 50 +++ .../habitica/snapshots/test_services.ambr | 312 ++++++++++++++++++ .../habitica/snapshots/test_todo.ambr | 8 +- tests/components/habitica/test_todo.py | 2 + 7 files changed, 436 insertions(+), 3 deletions(-) create mode 100644 tests/components/habitica/fixtures/duedate_fixture_9.json diff --git a/homeassistant/components/habitica/calendar.py b/homeassistant/components/habitica/calendar.py index 46191acf270..e890dfa9123 100644 --- a/homeassistant/components/habitica/calendar.py +++ b/homeassistant/components/habitica/calendar.py @@ -3,13 +3,14 @@ from __future__ import annotations from abc import abstractmethod +from dataclasses import asdict from datetime import date, datetime, timedelta from enum import StrEnum from typing import TYPE_CHECKING from uuid import UUID from dateutil.rrule import rrule -from habiticalib import TaskType +from habiticalib import Frequency, TaskType from homeassistant.components.calendar import ( CalendarEntity, @@ -193,6 +194,10 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity): # only dailies that that are not 'grey dailies' if not (task.Type is TaskType.DAILY and task.everyX): continue + if task.frequency is Frequency.WEEKLY and not any( + asdict(task.repeat).values() + ): + continue recurrences = build_rrule(task) recurrence_dates = self.get_recurrence_dates( @@ -334,6 +339,11 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity): if not (task.Type is TaskType.DAILY and task.everyX): continue + if task.frequency is Frequency.WEEKLY and not any( + asdict(task.repeat).values() + ): + continue + recurrences = build_rrule(task) recurrences_start = self.start_of_today diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 0a7c861eb7e..d265d56021a 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import fields +from dataclasses import asdict, fields import datetime from math import floor from typing import TYPE_CHECKING @@ -34,6 +34,8 @@ def next_due_date(task: TaskData, today: datetime.datetime) -> datetime.date | N if task.everyX == 0 or not task.nextDue: # grey dailies never become due return None + if task.frequency is Frequency.WEEKLY and not any(asdict(task.repeat).values()): + return None if TYPE_CHECKING: assert task.startDate diff --git a/tests/components/habitica/fixtures/duedate_fixture_9.json b/tests/components/habitica/fixtures/duedate_fixture_9.json new file mode 100644 index 00000000000..f908ad0deae --- /dev/null +++ b/tests/components/habitica/fixtures/duedate_fixture_9.json @@ -0,0 +1,51 @@ +{ + "success": true, + "data": [ + { + "_id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "frequency": "weekly", + "everyX": 1, + "repeat": { + "m": false, + "t": false, + "w": false, + "th": false, + "f": false, + "s": false, + "su": false + }, + "streak": 1, + "nextDue": ["2024-09-20T22:00:00.000Z", "2024-09-27T22:00:00.000Z"], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Zahnseide benutzen", + "notes": "Klicke um Änderungen zu machen!", + "tags": [], + "value": -2.9663035443712333, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "byHabitica": false, + "startDate": "2024-09-25T22:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [], + "checklist": [], + "reminders": [], + "createdAt": "2024-07-07T17:51:53.268Z", + "updatedAt": "2024-09-21T22:24:20.154Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa" + } + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index 3bb646be512..cf6e3864675 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -598,6 +598,56 @@ "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", "isDue": false, "id": "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + }, + { + "repeat": { + "m": false, + "t": false, + "w": false, + "th": false, + "f": false, + "s": false, + "su": false + }, + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "_id": "7d92278b-9361-4854-83b6-0a66b57dce20", + "frequency": "weekly", + "everyX": 1, + "streak": 1, + "nextDue": [ + "2024-12-14T23:00:00.000Z", + "2025-01-18T23:00:00.000Z", + "2025-02-15T23:00:00.000Z", + "2025-03-15T23:00:00.000Z", + "2025-04-19T23:00:00.000Z", + "2025-05-17T23:00:00.000Z" + ], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Lerne eine neue Programmiersprache", + "notes": "Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.", + "tags": [], + "value": -0.9215181434950852, + "priority": 1, + "attribute": "str", + "byHabitica": false, + "startDate": "2024-09-20T23:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [], + "checklist": [], + "reminders": [], + "createdAt": "2024-10-10T15:57:14.304Z", + "updatedAt": "2024-11-27T23:47:29.986Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "7d92278b-9361-4854-83b6-0a66b57dce20" } ], "notifications": [ diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr index 3030b228d38..d0062212775 100644 --- a/tests/components/habitica/snapshots/test_services.ambr +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -1166,6 +1166,84 @@ ]), 'yesterDaily': True, }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'isDue': False, + 'nextDue': list([ + datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': False, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Lerne eine neue Programmiersprache', + 'up': None, + 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), dict({ 'Type': , 'alias': None, @@ -3320,6 +3398,84 @@ ]), 'yesterDaily': True, }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'isDue': False, + 'nextDue': list([ + datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': False, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Lerne eine neue Programmiersprache', + 'up': None, + 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), ]), }) # --- @@ -4373,6 +4529,84 @@ ]), 'yesterDaily': True, }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'isDue': False, + 'nextDue': list([ + datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': False, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Lerne eine neue Programmiersprache', + 'up': None, + 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), ]), }) # --- @@ -4876,6 +5110,84 @@ ]), 'yesterDaily': True, }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'isDue': False, + 'nextDue': list([ + datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': False, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Lerne eine neue Programmiersprache', + 'up': None, + 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), ]), }) # --- diff --git a/tests/components/habitica/snapshots/test_todo.ambr b/tests/components/habitica/snapshots/test_todo.ambr index 25976270622..9cd6d9a540f 100644 --- a/tests/components/habitica/snapshots/test_todo.ambr +++ b/tests/components/habitica/snapshots/test_todo.ambr @@ -49,6 +49,12 @@ 'summary': 'Arbeite an einem kreativen Projekt', 'uid': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', }), + dict({ + 'description': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'status': 'needs_action', + 'summary': 'Lerne eine neue Programmiersprache', + 'uid': '7d92278b-9361-4854-83b6-0a66b57dce20', + }), ]), }), }) @@ -144,7 +150,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3', + 'state': '4', }) # --- # name: test_todos[todo.test_user_to_do_s-entry] diff --git a/tests/components/habitica/test_todo.py b/tests/components/habitica/test_todo.py index ea817013169..6453510a97f 100644 --- a/tests/components/habitica/test_todo.py +++ b/tests/components/habitica/test_todo.py @@ -622,6 +622,7 @@ async def test_move_todo_item_exception( ("duedate_fixture_6.json", "2024-10-21"), ("duedate_fixture_7.json", None), ("duedate_fixture_8.json", None), + ("duedate_fixture_9.json", None), ], ids=[ "default", @@ -632,6 +633,7 @@ async def test_move_todo_item_exception( "monthly starts on fixed day", "grey daily", "empty nextDue", + "grey daily no weekdays", ], ) @pytest.mark.usefixtures("set_tz") From 8f71d7a6f30d0557db474c4d1f6780ada0c73729 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:35:50 +0100 Subject: [PATCH 0355/2987] Move HomeWizard API initialisation to async_setup_entry (#135315) --- .../components/homewizard/__init__.py | 21 ++++++++++++++++++- .../components/homewizard/coordinator.py | 14 +++---------- tests/components/homewizard/conftest.py | 2 +- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 4733bc67073..a911f5398da 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -1,8 +1,12 @@ """The Homewizard integration.""" +from homewizard_energy import HomeWizardEnergy, HomeWizardEnergyV1, HomeWizardEnergyV2 + from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, PLATFORMS from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -12,7 +16,22 @@ type HomeWizardConfigEntry = ConfigEntry[HWEnergyDeviceUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -> bool: """Set up Homewizard from a config entry.""" - coordinator = HWEnergyDeviceUpdateCoordinator(hass) + + api: HomeWizardEnergy + + if token := entry.data.get(CONF_TOKEN): + api = HomeWizardEnergyV2( + entry.data[CONF_IP_ADDRESS], + token=token, + clientsession=async_get_clientsession(hass), + ) + else: + api = HomeWizardEnergyV1( + entry.data[CONF_IP_ADDRESS], + clientsession=async_get_clientsession(hass), + ) + + coordinator = HWEnergyDeviceUpdateCoordinator(hass, api) try: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index f646051a0e1..7024c760b93 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -2,14 +2,12 @@ from __future__ import annotations -from homewizard_energy import HomeWizardEnergy, HomeWizardEnergyV1 +from homewizard_energy import HomeWizardEnergy from homewizard_energy.errors import DisabledError, RequestError from homewizard_energy.models import CombinedModels as DeviceResponseEntry from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, UPDATE_INTERVAL @@ -23,16 +21,10 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] config_entry: ConfigEntry - def __init__( - self, - hass: HomeAssistant, - ) -> None: + def __init__(self, hass: HomeAssistant, api: HomeWizardEnergy) -> None: """Initialize update coordinator.""" super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) - self.api = HomeWizardEnergyV1( - self.config_entry.data[CONF_IP_ADDRESS], - clientsession=async_get_clientsession(hass), - ) + self.api = api async def _async_update_data(self) -> DeviceResponseEntry: """Fetch all device and sensor data from api.""" diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index 2e7728c6afb..b540ebac91a 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -26,7 +26,7 @@ def mock_homewizardenergy( """Return a mock bridge.""" with ( patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergyV1", + "homeassistant.components.homewizard.HomeWizardEnergyV1", autospec=True, ) as homewizard, patch( From fc6695b05c8ff0aeb50dd4517572f6deb7a1385d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 13 Jan 2025 13:47:40 +0100 Subject: [PATCH 0356/2987] Use proper sentence-case for all strings in azure_event_hub (#135328) --- .../components/azure_event_hub/strings.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/azure_event_hub/strings.json b/homeassistant/components/azure_event_hub/strings.json index d17c4a385c0..8ec559ac8b7 100644 --- a/homeassistant/components/azure_event_hub/strings.json +++ b/homeassistant/components/azure_event_hub/strings.json @@ -2,26 +2,26 @@ "config": { "step": { "user": { - "title": "Set up your Azure Event Hub integration", + "title": "Set up Azure Event Hub", "data": { - "event_hub_instance_name": "Event Hub Instance Name", - "use_connection_string": "Use Connection String" + "event_hub_instance_name": "Event Hub instance name", + "use_connection_string": "Use connection string" } }, "conn_string": { - "title": "Connection String method", + "title": "Connection string method", "description": "Please enter the connection string for: {event_hub_instance_name}", "data": { - "event_hub_connection_string": "Event Hub Connection String" + "event_hub_connection_string": "Event Hub connection string" } }, "sas": { - "title": "SAS Credentials method", + "title": "SAS credentials method", "description": "Please enter the SAS (shared access signature) credentials for: {event_hub_instance_name}", "data": { - "event_hub_namespace": "Event Hub Namespace", - "event_hub_sas_policy": "Event Hub SAS Policy", - "event_hub_sas_key": "Event Hub SAS Key" + "event_hub_namespace": "Event Hub namespace", + "event_hub_sas_policy": "Event Hub SAS policy", + "event_hub_sas_key": "Event Hub SAS key" } } }, @@ -38,7 +38,7 @@ "options": { "step": { "init": { - "title": "Options for the Azure Event Hub.", + "title": "Options for Azure Event Hub.", "data": { "send_interval": "Interval between sending batches to the hub." } From e1ffd9380dcae03f4e83d5a407995aa93e5c2818 Mon Sep 17 00:00:00 2001 From: dotvav Date: Mon, 13 Jan 2025 13:51:20 +0100 Subject: [PATCH 0357/2987] Replace climate fan speed 'silent' with a button (#135075) --- .../components/palazzetti/__init__.py | 7 +- homeassistant/components/palazzetti/button.py | 52 ++++++++++++++ .../components/palazzetti/climate.py | 8 +-- homeassistant/components/palazzetti/const.py | 2 +- .../components/palazzetti/icons.json | 9 +++ .../components/palazzetti/quality_scale.yaml | 4 +- .../components/palazzetti/strings.json | 5 ++ .../palazzetti/snapshots/test_button.ambr | 47 +++++++++++++ .../palazzetti/snapshots/test_climate.ambr | 2 - tests/components/palazzetti/test_button.py | 69 +++++++++++++++++++ tests/components/palazzetti/test_climate.py | 11 +-- tests/components/palazzetti/test_number.py | 4 +- 12 files changed, 195 insertions(+), 25 deletions(-) create mode 100644 homeassistant/components/palazzetti/button.py create mode 100644 homeassistant/components/palazzetti/icons.json create mode 100644 tests/components/palazzetti/snapshots/test_button.ambr create mode 100644 tests/components/palazzetti/test_button.py diff --git a/homeassistant/components/palazzetti/__init__.py b/homeassistant/components/palazzetti/__init__.py index f20b3d11261..dbf1baa0c28 100644 --- a/homeassistant/components/palazzetti/__init__.py +++ b/homeassistant/components/palazzetti/__init__.py @@ -7,7 +7,12 @@ from homeassistant.core import HomeAssistant from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.CLIMATE, + Platform.NUMBER, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: PalazzettiConfigEntry) -> bool: diff --git a/homeassistant/components/palazzetti/button.py b/homeassistant/components/palazzetti/button.py new file mode 100644 index 00000000000..cd4765576ed --- /dev/null +++ b/homeassistant/components/palazzetti/button.py @@ -0,0 +1,52 @@ +"""Support for Palazzetti buttons.""" + +from __future__ import annotations + +from pypalazzetti.exceptions import CommunicationError + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import PalazzettiConfigEntry +from .const import DOMAIN +from .coordinator import PalazzettiDataUpdateCoordinator +from .entity import PalazzettiEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PalazzettiConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Palazzetti button platform.""" + + coordinator = config_entry.runtime_data + if coordinator.client.has_fan_silent: + async_add_entities([PalazzettiSilentButtonEntity(coordinator)]) + + +class PalazzettiSilentButtonEntity(PalazzettiEntity, ButtonEntity): + """Representation of a Palazzetti Silent button.""" + + _attr_translation_key = "silent" + + def __init__( + self, + coordinator: PalazzettiDataUpdateCoordinator, + ) -> None: + """Initialize a Palazzetti Silent button.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.config_entry.unique_id}-silent" + + async def async_press(self) -> None: + """Press the button.""" + try: + await self.coordinator.client.set_fan_silent() + except CommunicationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from err + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/palazzetti/climate.py b/homeassistant/components/palazzetti/climate.py index 356f3a7306f..301ede422d6 100644 --- a/homeassistant/components/palazzetti/climate.py +++ b/homeassistant/components/palazzetti/climate.py @@ -16,7 +16,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import PalazzettiConfigEntry -from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES, FAN_SILENT +from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES from .coordinator import PalazzettiDataUpdateCoordinator from .entity import PalazzettiEntity @@ -57,8 +57,6 @@ class PalazzettiClimateEntity(PalazzettiEntity, ClimateEntity): self._attr_fan_modes = list( map(str, range(client.fan_speed_min, client.fan_speed_max + 1)) ) - if client.has_fan_silent: - self._attr_fan_modes.insert(0, FAN_SILENT) if client.has_fan_high: self._attr_fan_modes.append(FAN_HIGH) if client.has_fan_auto: @@ -130,9 +128,7 @@ class PalazzettiClimateEntity(PalazzettiEntity, ClimateEntity): async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new fan mode.""" try: - if fan_mode == FAN_SILENT: - await self.coordinator.client.set_fan_silent() - elif fan_mode == FAN_HIGH: + if fan_mode == FAN_HIGH: await self.coordinator.client.set_fan_high() elif fan_mode == FAN_AUTO: await self.coordinator.client.set_fan_auto() diff --git a/homeassistant/components/palazzetti/const.py b/homeassistant/components/palazzetti/const.py index b2e27b2a6fd..1b68cf99f9d 100644 --- a/homeassistant/components/palazzetti/const.py +++ b/homeassistant/components/palazzetti/const.py @@ -18,7 +18,7 @@ ERROR_CANNOT_CONNECT = "cannot_connect" FAN_SILENT: Final = "silent" FAN_HIGH: Final = "high" FAN_AUTO: Final = "auto" -FAN_MODES: Final = [FAN_SILENT, "1", "2", "3", "4", "5", FAN_HIGH, FAN_AUTO] +FAN_MODES: Final = ["0", "1", "2", "3", "4", "5", FAN_HIGH, FAN_AUTO] STATUS_TO_HA: Final[dict[StateType, str]] = { 0: "off", diff --git a/homeassistant/components/palazzetti/icons.json b/homeassistant/components/palazzetti/icons.json new file mode 100644 index 00000000000..c20a9572618 --- /dev/null +++ b/homeassistant/components/palazzetti/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "button": { + "silent": { + "default": "mdi:volume-mute" + } + } + } +} diff --git a/homeassistant/components/palazzetti/quality_scale.yaml b/homeassistant/components/palazzetti/quality_scale.yaml index c1c83be235c..5d57313bfde 100644 --- a/homeassistant/components/palazzetti/quality_scale.yaml +++ b/homeassistant/components/palazzetti/quality_scale.yaml @@ -67,9 +67,7 @@ rules: entity-translations: done exception-translations: done icon-translations: - status: exempt - comment: | - This integration does not have custom icons. + status: done reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json index ad7bc498bd1..93233a9b1e4 100644 --- a/homeassistant/components/palazzetti/strings.json +++ b/homeassistant/components/palazzetti/strings.json @@ -38,6 +38,11 @@ } }, "entity": { + "button": { + "silent": { + "name": "Silent" + } + }, "climate": { "palazzetti": { "state_attributes": { diff --git a/tests/components/palazzetti/snapshots/test_button.ambr b/tests/components/palazzetti/snapshots/test_button.ambr new file mode 100644 index 00000000000..6827c9a1f22 --- /dev/null +++ b/tests/components/palazzetti/snapshots/test_button.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_all_entities[button.stove_silent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.stove_silent', + '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': 'Silent', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'silent', + 'unique_id': '11:22:33:44:55:66-silent', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.stove_silent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Stove Silent', + }), + 'context': , + 'entity_id': 'button.stove_silent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/palazzetti/snapshots/test_climate.ambr b/tests/components/palazzetti/snapshots/test_climate.ambr index e7cea3749a1..aa637039df9 100644 --- a/tests/components/palazzetti/snapshots/test_climate.ambr +++ b/tests/components/palazzetti/snapshots/test_climate.ambr @@ -6,7 +6,6 @@ 'area_id': None, 'capabilities': dict({ 'fan_modes': list([ - 'silent', '1', '2', '3', @@ -56,7 +55,6 @@ 'current_temperature': 18, 'fan_mode': '3', 'fan_modes': list([ - 'silent', '1', '2', '3', diff --git a/tests/components/palazzetti/test_button.py b/tests/components/palazzetti/test_button.py new file mode 100644 index 00000000000..de0f26fe8aa --- /dev/null +++ b/tests/components/palazzetti/test_button.py @@ -0,0 +1,69 @@ +"""Tests for the Palazzetti button platform.""" + +from unittest.mock import AsyncMock, patch + +from pypalazzetti.exceptions import CommunicationError +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "button.stove_silent" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.palazzetti.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_async_press( + hass: HomeAssistant, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pressing via service call.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_palazzetti_client.set_fan_silent.assert_called_once() + + +async def test_async_press_error( + hass: HomeAssistant, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pressing with error via service call.""" + await setup_integration(hass, mock_config_entry) + + mock_palazzetti_client.set_fan_silent.side_effect = CommunicationError() + error_message = "Could not connect to the device" + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) diff --git a/tests/components/palazzetti/test_climate.py b/tests/components/palazzetti/test_climate.py index 78af8f00bdb..22bd04f234e 100644 --- a/tests/components/palazzetti/test_climate.py +++ b/tests/components/palazzetti/test_climate.py @@ -15,7 +15,7 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.components.palazzetti.const import FAN_AUTO, FAN_HIGH, FAN_SILENT +from homeassistant.components.palazzetti.const import FAN_AUTO, FAN_HIGH from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -118,15 +118,6 @@ async def test_async_set_data( ) # Set Fan Mode: Success - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: FAN_SILENT}, - blocking=True, - ) - mock_palazzetti_client.set_fan_silent.assert_called_once() - mock_palazzetti_client.set_fan_silent.reset_mock() - await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, diff --git a/tests/components/palazzetti/test_number.py b/tests/components/palazzetti/test_number.py index 939c7c72c19..54318ed5c74 100644 --- a/tests/components/palazzetti/test_number.py +++ b/tests/components/palazzetti/test_number.py @@ -49,7 +49,7 @@ async def test_async_set_data( blocking=True, ) mock_palazzetti_client.set_power_mode.assert_called_once_with(1) - mock_palazzetti_client.set_on.reset_mock() + mock_palazzetti_client.set_power_mode.reset_mock() # Set value: Error mock_palazzetti_client.set_power_mode.side_effect = CommunicationError() @@ -60,7 +60,7 @@ async def test_async_set_data( {ATTR_ENTITY_ID: ENTITY_ID, "value": 1}, blocking=True, ) - mock_palazzetti_client.set_on.reset_mock() + mock_palazzetti_client.set_power_mode.reset_mock() mock_palazzetti_client.set_power_mode.side_effect = ValidationError() with pytest.raises(ServiceValidationError): From d33ee130bcba29d19c0631b31cd52a2d013db6e8 Mon Sep 17 00:00:00 2001 From: dotvav Date: Mon, 13 Jan 2025 13:59:34 +0100 Subject: [PATCH 0358/2987] Bump pypalazzetti to 0.1.19 (#135465) --- homeassistant/components/palazzetti/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/palazzetti/manifest.json b/homeassistant/components/palazzetti/manifest.json index 2117a76bf15..41e8e0fb4de 100644 --- a/homeassistant/components/palazzetti/manifest.json +++ b/homeassistant/components/palazzetti/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/palazzetti", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pypalazzetti==0.1.16"] + "requirements": ["pypalazzetti==0.1.19"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2187e4180a8..7b6a8067652 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2189,7 +2189,7 @@ pyoverkiz==1.15.5 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.16 +pypalazzetti==0.1.19 # homeassistant.components.elv pypca==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ddb90008221..ebf2ae1ae1b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1785,7 +1785,7 @@ pyoverkiz==1.15.5 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.16 +pypalazzetti==0.1.19 # homeassistant.components.lcn pypck==0.8.1 From 6fd9476bb9b0e18c11e44dfeccb9ee874362f173 Mon Sep 17 00:00:00 2001 From: jesperraemaekers <146726232+jesperraemaekers@users.noreply.github.com> Date: Mon, 13 Jan 2025 14:01:57 +0100 Subject: [PATCH 0359/2987] Refresh token before setting up weheat (#135264) --- homeassistant/components/weheat/__init__.py | 17 ++++- tests/components/weheat/test_init.py | 85 +++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/components/weheat/test_init.py diff --git a/homeassistant/components/weheat/__init__.py b/homeassistant/components/weheat/__init__.py index a043a3a6845..37c1f721078 100644 --- a/homeassistant/components/weheat/__init__.py +++ b/homeassistant/components/weheat/__init__.py @@ -2,13 +2,16 @@ from __future__ import annotations +from http import HTTPStatus + +import aiohttp from weheat.abstractions.discovery import HeatPumpDiscovery from weheat.exceptions import UnauthorizedException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, @@ -28,6 +31,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bo session = OAuth2Session(hass, entry, implementation) + try: + await session.async_ensure_token_valid() + except aiohttp.ClientResponseError as ex: + LOGGER.warning("API error: %s (%s)", ex.status, ex.message) + if ex.status in ( + HTTPStatus.BAD_REQUEST, + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + ): + raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex + raise ConfigEntryNotReady from ex + token = session.token[CONF_ACCESS_TOKEN] entry.runtime_data = [] diff --git a/tests/components/weheat/test_init.py b/tests/components/weheat/test_init.py new file mode 100644 index 00000000000..af5e2b8411b --- /dev/null +++ b/tests/components/weheat/test_init.py @@ -0,0 +1,85 @@ +"""Tests for the weheat initialization.""" + +from http import HTTPStatus +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from weheat.abstractions.discovery import HeatPumpDiscovery + +from homeassistant.components.weheat import UnauthorizedException +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import ClientResponseError + + +@pytest.mark.usefixtures("setup_credentials") +async def test_setup( + hass: HomeAssistant, + mock_weheat_discover: AsyncMock, + mock_weheat_heat_pump: AsyncMock, + mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Weheat setup.""" + mock_weheat_discover.return_value = [mock_heat_pump_info] + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("setup_credentials") +@pytest.mark.parametrize( + ("setup_exception", "expected_setup_state"), + [ + (HTTPStatus.BAD_REQUEST, ConfigEntryState.SETUP_ERROR), + (HTTPStatus.UNAUTHORIZED, ConfigEntryState.SETUP_ERROR), + (HTTPStatus.FORBIDDEN, ConfigEntryState.SETUP_ERROR), + (HTTPStatus.GATEWAY_TIMEOUT, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_fail( + hass: HomeAssistant, + mock_weheat_discover: AsyncMock, + mock_weheat_heat_pump: AsyncMock, + mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, + mock_config_entry: MockConfigEntry, + setup_exception: Exception, + expected_setup_state: ConfigEntryState, +) -> None: + """Test the Weheat setup with invalid token setup.""" + with ( + patch( + "homeassistant.components.weheat.OAuth2Session.async_ensure_token_valid", + side_effect=ClientResponseError( + Mock(real_url="http://example.com"), None, status=setup_exception + ), + ), + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_setup_state + + +@pytest.mark.usefixtures("setup_credentials") +async def test_setup_fail_discover( + hass: HomeAssistant, + mock_weheat_discover: AsyncMock, + mock_weheat_heat_pump: AsyncMock, + mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Weheat setup with and error from the heat pump discovery.""" + mock_weheat_discover.side_effect = UnauthorizedException() + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR From 9b55faa8792d0b730a2efcff24fa5cc5c6910802 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:15:21 +0000 Subject: [PATCH 0360/2987] Refactor config flow tests in generic camera (#134385) Co-authored-by: Dave T <17680170+davet2001@users.noreply.github.com> Co-authored-by: Allen Porter --- tests/components/generic/conftest.py | 35 +- tests/components/generic/test_config_flow.py | 565 +++++++------------ tests/components/generic/test_init.py | 37 ++ 3 files changed, 281 insertions(+), 356 deletions(-) create mode 100644 tests/components/generic/test_init.py diff --git a/tests/components/generic/conftest.py b/tests/components/generic/conftest.py index cdea83b599c..96cdfe41d0d 100644 --- a/tests/components/generic/conftest.py +++ b/tests/components/generic/conftest.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Generator from io import BytesIO -from unittest.mock import AsyncMock, MagicMock, Mock, _patch, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from PIL import Image import pytest @@ -54,11 +54,15 @@ def fakeimgbytes_gif() -> bytes: @pytest.fixture def fakeimg_png(fakeimgbytes_png: bytes) -> Generator[None]: """Set up respx to respond to test url with fake image bytes.""" - respx.get("http://127.0.0.1/testurl/1", name="fake_img").respond( + respx.get("http://127.0.0.1/testurl/1", name="fake_img1").respond( + stream=fakeimgbytes_png + ) + respx.get("http://127.0.0.1/testurl/2", name="fake_img2").respond( stream=fakeimgbytes_png ) yield - respx.pop("fake_img") + respx.pop("fake_img1") + respx.pop("fake_img2") @pytest.fixture @@ -71,8 +75,8 @@ def fakeimg_gif(fakeimgbytes_gif: bytes) -> Generator[None]: respx.pop("fake_img") -@pytest.fixture -def mock_create_stream(hass: HomeAssistant) -> _patch[MagicMock]: +@pytest.fixture(name="mock_create_stream") +def mock_create_stream(hass: HomeAssistant) -> Generator[AsyncMock]: """Mock create stream.""" mock_stream = MagicMock() mock_stream.hass = hass @@ -83,14 +87,25 @@ def mock_create_stream(hass: HomeAssistant) -> _patch[MagicMock]: mock_stream.start = AsyncMock() mock_stream.stop = AsyncMock() mock_stream.endpoint_url.return_value = "http://127.0.0.1/nothing" - return patch( + with patch( "homeassistant.components.generic.config_flow.create_stream", return_value=mock_stream, - ) + ) as mock_create_stream: + yield mock_create_stream @pytest.fixture -async def user_flow(hass: HomeAssistant) -> ConfigFlowResult: +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setup entry.""" + with patch( + "homeassistant.components.generic.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="user_flow") +async def user_flow_fixture(hass: HomeAssistant) -> ConfigFlowResult: """Initiate a user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -126,8 +141,8 @@ def config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: return entry -@pytest.fixture -async def setup_entry( +@pytest.fixture(name="setup_entry") +async def setup_entry_fixture( hass: HomeAssistant, config_entry: MockConfigEntry ) -> MockConfigEntry: """Set up a config entry ready to be used in tests.""" diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index 9eee49619b5..19af6cd7a09 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator import contextlib import errno from http import HTTPStatus @@ -9,12 +10,10 @@ import os.path from pathlib import Path from unittest.mock import AsyncMock, MagicMock, PropertyMock, _patch, patch -from freezegun.api import FrozenDateTimeFactory import httpx import pytest import respx -from homeassistant import config_entries from homeassistant.components.camera import async_get_image from homeassistant.components.generic.config_flow import slug from homeassistant.components.generic.const import ( @@ -30,7 +29,7 @@ from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, CONF_USE_WALLCLOCK_AS_TIMESTAMPS, ) -from homeassistant.config_entries import ConfigEntryState, ConfigFlowResult +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import ( CONF_AUTHENTICATION, CONF_NAME, @@ -44,7 +43,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator TESTDATA = { @@ -69,52 +68,47 @@ TESTDATA_YAML = { @respx.mock +@pytest.mark.usefixtures("fakeimg_png") async def test_form( hass: HomeAssistant, fakeimgbytes_png: bytes, hass_client: ClientSessionGenerator, user_flow: ConfigFlowResult, mock_create_stream: _patch[MagicMock], + mock_setup_entry: _patch[MagicMock], hass_ws_client: WebSocketGenerator, ) -> None: """Test the form with a normal set of settings.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) - with ( - mock_create_stream as mock_setup, - patch( - "homeassistant.components.generic.async_setup_entry", return_value=True - ) as mock_setup_entry, - ): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" - # HA should now be serving a WS connection for a preview stream. - ws_client = await hass_ws_client() - flow_id = user_flow["flow_id"] - await ws_client.send_json_auto_id( - { - "type": "generic_camera/start_preview", - "flow_id": flow_id, - }, - ) - json = await ws_client.receive_json() + # HA should now be serving a WS connection for a preview stream. + ws_client = await hass_ws_client() + flow_id = user_flow["flow_id"] + await ws_client.send_json_auto_id( + { + "type": "generic_camera/start_preview", + "flow_id": flow_id, + }, + ) + json = await ws_client.receive_json() - client = await hass_client() - still_preview_url = json["event"]["attributes"]["still_url"] - # Check the preview image works. - resp = await client.get(still_preview_url) - assert resp.status == HTTPStatus.OK - assert await resp.read() == fakeimgbytes_png + client = await hass_client() + still_preview_url = json["event"]["attributes"]["still_url"] + # Check the preview image works. + resp = await client.get(still_preview_url) + assert resp.status == HTTPStatus.OK + assert await resp.read() == fakeimgbytes_png - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -131,36 +125,29 @@ async def test_form( # Check that the preview image is disabled after. resp = await client.get(still_preview_url) assert resp.status == HTTPStatus.NOT_FOUND - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 @respx.mock @pytest.mark.usefixtures("fakeimg_png") async def test_form_only_stillimage( - hass: HomeAssistant, user_flow: ConfigFlowResult + hass: HomeAssistant, + user_flow: ConfigFlowResult, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test we complete ok if the user wants still images only.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - await hass.async_block_till_done() - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + await hass.async_block_till_done() + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -177,19 +164,17 @@ async def test_form_only_stillimage( @respx.mock +@pytest.mark.usefixtures("fakeimg_png") async def test_form_reject_preview( hass: HomeAssistant, - fakeimgbytes_png: bytes, mock_create_stream: _patch[MagicMock], user_flow: ConfigFlowResult, ) -> None: """Test we go back to the config screen if the user rejects the preview.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) - with mock_create_stream: - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result1["type"] is FlowResultType.FORM assert result1["step_id"] == "user_confirm" result2 = await hass.config_entries.flow.async_configure( @@ -215,7 +200,6 @@ async def test_form_still_preview_cam_off( "homeassistant.components.generic.camera.GenericCamera.is_on", new_callable=PropertyMock(return_value=False), ), - mock_create_stream, ): result1 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], @@ -246,47 +230,50 @@ async def test_form_still_preview_cam_off( @respx.mock @pytest.mark.usefixtures("fakeimg_gif") async def test_form_only_stillimage_gif( - hass: HomeAssistant, user_flow: ConfigFlowResult + hass: HomeAssistant, + user_flow: ConfigFlowResult, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test we complete ok if the user wants a gif.""" data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) - await hass.async_block_till_done() + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["options"][CONF_CONTENT_TYPE] == "image/gif" @respx.mock async def test_form_only_svg_whitespace( - hass: HomeAssistant, fakeimgbytes_svg: bytes, user_flow: ConfigFlowResult + hass: HomeAssistant, + fakeimgbytes_svg: bytes, + user_flow: ConfigFlowResult, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test we complete ok if svg starts with whitespace, issue #68889.""" fakeimgbytes_wspace_svg = bytes(" \n ", encoding="utf-8") + fakeimgbytes_svg respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_wspace_svg) data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY @@ -303,7 +290,7 @@ async def test_form_only_svg_whitespace( ], ) async def test_form_only_still_sample( - hass: HomeAssistant, user_flow: ConfigFlowResult, image_file + hass: HomeAssistant, user_flow: ConfigFlowResult, image_file, mock_setup_entry ) -> None: """Test various sample images #69037.""" image_path = os.path.join(os.path.dirname(__file__), image_file) @@ -311,18 +298,17 @@ async def test_form_only_still_sample( respx.get("http://127.0.0.1/testurl/1").respond(stream=image_bytes) data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) - await hass.async_block_till_done() + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY @@ -363,10 +349,11 @@ async def test_form_only_still_sample( ), ], ) -async def test_still_template( +async def test_form_still_template( hass: HomeAssistant, user_flow: ConfigFlowResult, fakeimgbytes_png: bytes, + mock_setup_entry: Generator[AsyncMock], template, url, expected_result, @@ -380,12 +367,11 @@ async def test_still_template( data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) data[CONF_STILL_IMAGE_URL] = template - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + await hass.async_block_till_done() assert result2["step_id"] == expected_result assert result2.get("errors") == expected_errors @@ -396,24 +382,19 @@ async def test_form_rtsp_mode( hass: HomeAssistant, user_flow: ConfigFlowResult, mock_create_stream: _patch[MagicMock], + mock_setup_entry: _patch[MagicMock], ) -> None: """Test we complete ok if the user enters a stream url.""" data = TESTDATA.copy() data[CONF_RTSP_TRANSPORT] = "tcp" data[CONF_STREAM_SOURCE] = "rtsp://127.0.0.1/testurl/2" - with ( - mock_create_stream as mock_setup, - patch("homeassistant.components.generic.async_setup_entry", return_value=True), - ): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], data - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) + result1 = await hass.config_entries.flow.async_configure(user_flow["flow_id"], data) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -428,8 +409,6 @@ async def test_form_rtsp_mode( CONF_VERIFY_SSL: False, } - assert len(mock_setup.mock_calls) == 1 - async def test_form_only_stream( hass: HomeAssistant, @@ -441,18 +420,16 @@ async def test_form_only_stream( data = TESTDATA.copy() data.pop(CONF_STILL_IMAGE_URL) data[CONF_STREAM_SOURCE] = "rtsp://user:pass@127.0.0.1/testurl/2" - with mock_create_stream: - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) assert result1["type"] is FlowResultType.FORM - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -528,15 +505,11 @@ async def test_form_image_http_exceptions( mock_create_stream: _patch[MagicMock], ) -> None: """Test we handle image http exceptions.""" - respx.get("http://127.0.0.1/testurl/1").side_effect = [ - side_effect, - ] - - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + respx.get("http://127.0.0.1/testurl/1").side_effect = [side_effect] + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == expected_message @@ -550,11 +523,10 @@ async def test_form_stream_invalidimage( ) -> None: """Test we handle invalid image when a stream is specified.""" respx.get("http://127.0.0.1/testurl/1").respond(stream=b"invalid") - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"still_image_url": "invalid_still_image"} @@ -568,11 +540,10 @@ async def test_form_stream_invalidimage2( ) -> None: """Test we handle invalid image when a stream is specified.""" respx.get("http://127.0.0.1/testurl/1").respond(content=None) - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"still_image_url": "unable_still_load_no_image"} @@ -586,11 +557,10 @@ async def test_form_stream_invalidimage3( ) -> None: """Test we handle invalid image when a stream is specified.""" respx.get("http://127.0.0.1/testurl/1").respond(content=bytes([0xFF])) - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"still_image_url": "invalid_still_image"} @@ -599,23 +569,22 @@ async def test_form_stream_invalidimage3( @respx.mock @pytest.mark.usefixtures("fakeimg_png") async def test_form_stream_timeout( - hass: HomeAssistant, user_flow: ConfigFlowResult + hass: HomeAssistant, + user_flow: ConfigFlowResult, + mock_create_stream: _patch[MagicMock], ) -> None: """Test we handle invalid auth.""" - with patch( - "homeassistant.components.generic.config_flow.create_stream" - ) as create_stream: - create_stream.return_value.start = AsyncMock() - create_stream.return_value.stop = AsyncMock() - create_stream.return_value.hass = hass - create_stream.return_value.add_provider.return_value.part_recv = AsyncMock() - create_stream.return_value.add_provider.return_value.part_recv.return_value = ( - False - ) - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + mock_create_stream.return_value.start = AsyncMock() + mock_create_stream.return_value.stop = AsyncMock() + mock_create_stream.return_value.hass = hass + mock_create_stream.return_value.add_provider.return_value.part_recv = AsyncMock() + mock_create_stream.return_value.add_provider.return_value.part_recv.return_value = ( + False + ) + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"stream_source": "timeout"} @@ -661,11 +630,11 @@ async def test_form_stream_other_error(hass: HomeAssistant, user_flow) -> None: @respx.mock +@pytest.mark.usefixtures("fakeimg_png") async def test_form_stream_permission_error( - hass: HomeAssistant, fakeimgbytes_png: bytes, user_flow: ConfigFlowResult + hass: HomeAssistant, user_flow: ConfigFlowResult ) -> None: """Test we handle permission error.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) with patch( "homeassistant.components.generic.config_flow.create_stream", side_effect=PermissionError(), @@ -732,116 +701,73 @@ async def test_form_oserror(hass: HomeAssistant, user_flow: ConfigFlowResult) -> @respx.mock -async def test_form_stream_preview_auto_timeout( - hass: HomeAssistant, - user_flow: ConfigFlowResult, - mock_create_stream: _patch[MagicMock], - freezer: FrozenDateTimeFactory, - fakeimgbytes_png: bytes, -) -> None: - """Test that the stream preview times out after 10mins.""" - respx.get("http://fred_flintstone:bambam@127.0.0.1/testurl/2").respond( - stream=fakeimgbytes_png - ) - data = TESTDATA.copy() - data.pop(CONF_STILL_IMAGE_URL) - - with mock_create_stream as mock_stream: - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" - - freezer.tick(600 + 12) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - mock_str = mock_stream.return_value - mock_str.start.assert_awaited_once() - - -@respx.mock +@pytest.mark.usefixtures("fakeimg_png") async def test_options_template_error( - hass: HomeAssistant, fakeimgbytes_png: bytes, mock_create_stream: _patch[MagicMock] + hass: HomeAssistant, + mock_create_stream: _patch[MagicMock], + config_entry: MockConfigEntry, ) -> None: """Test the options flow with a template error.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) - respx.get("http://127.0.0.1/testurl/2").respond(stream=fakeimgbytes_png) - - mock_entry = MockConfigEntry( - title="Test Camera", - domain=DOMAIN, - data={}, - options=TESTDATA, - ) - - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init(mock_entry.entry_id) + result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" # try updating the still image url data = TESTDATA.copy() data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/2" - with mock_create_stream: - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input=data, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "user_confirm" + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=data, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user_confirm" - result2a = await hass.config_entries.options.async_configure( - result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} - ) - assert result2a["type"] is FlowResultType.CREATE_ENTRY + result2a = await hass.config_entries.options.async_configure( + result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} + ) + assert result2a["type"] is FlowResultType.CREATE_ENTRY - result3 = await hass.config_entries.options.async_init(mock_entry.entry_id) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "init" + result3 = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "init" - # verify that an invalid template reports the correct UI error. - data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/{{1/0}}" - result4 = await hass.config_entries.options.async_configure( - result3["flow_id"], - user_input=data, - ) - assert result4.get("type") is FlowResultType.FORM - assert result4["errors"] == {"still_image_url": "template_error"} + # verify that an invalid template reports the correct UI error. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/{{1/0}}" + result4 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input=data, + ) + assert result4.get("type") is FlowResultType.FORM + assert result4["errors"] == {"still_image_url": "template_error"} - # verify that an invalid template reports the correct UI error. - data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" - data[CONF_STREAM_SOURCE] = "http://127.0.0.2/testurl/{{1/0}}" - result5 = await hass.config_entries.options.async_configure( - result4["flow_id"], - user_input=data, - ) + # verify that an invalid template reports the correct UI error. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" + data[CONF_STREAM_SOURCE] = "http://127.0.0.2/testurl/{{1/0}}" + result5 = await hass.config_entries.options.async_configure( + result4["flow_id"], + user_input=data, + ) - assert result5.get("type") is FlowResultType.FORM - assert result5["errors"] == {"stream_source": "template_error"} + assert result5.get("type") is FlowResultType.FORM + assert result5["errors"] == {"stream_source": "template_error"} - # verify that an relative stream url is rejected. - data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" - data[CONF_STREAM_SOURCE] = "relative/stream.mjpeg" - result6 = await hass.config_entries.options.async_configure( - result5["flow_id"], - user_input=data, - ) - assert result6.get("type") is FlowResultType.FORM - assert result6["errors"] == {"stream_source": "relative_url"} + # verify that an relative stream url is rejected. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" + data[CONF_STREAM_SOURCE] = "relative/stream.mjpeg" + result6 = await hass.config_entries.options.async_configure( + result5["flow_id"], + user_input=data, + ) + assert result6.get("type") is FlowResultType.FORM + assert result6["errors"] == {"stream_source": "relative_url"} - # verify that an malformed stream url is rejected. - data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" - data[CONF_STREAM_SOURCE] = "http://example.com:45:56" - result7 = await hass.config_entries.options.async_configure( - result6["flow_id"], - user_input=data, - ) + # verify that an malformed stream url is rejected. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" + data[CONF_STREAM_SOURCE] = "http://example.com:45:56" + result7 = await hass.config_entries.options.async_configure( + result6["flow_id"], + user_input=data, + ) assert result7.get("type") is FlowResultType.FORM assert result7["errors"] == {"stream_source": "malformed_url"} @@ -861,11 +787,13 @@ async def test_slug(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> No @respx.mock +@pytest.mark.usefixtures("fakeimg_png") async def test_options_only_stream( - hass: HomeAssistant, fakeimgbytes_png: bytes, mock_create_stream: _patch[MagicMock] + hass: HomeAssistant, + mock_setup_entry: _patch[MagicMock], + mock_create_stream: _patch[MagicMock], ) -> None: """Test the options flow without a still_image_url.""" - respx.get("http://127.0.0.1/testurl/2").respond(stream=fakeimgbytes_png) data = TESTDATA.copy() data.pop(CONF_STILL_IMAGE_URL) @@ -883,11 +811,10 @@ async def test_options_only_stream( assert result["step_id"] == "init" # try updating the config options - with mock_create_stream: - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input=data, - ) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=data, + ) assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "user_confirm" @@ -900,6 +827,7 @@ async def test_options_only_stream( async def test_options_still_and_stream_not_provided( hass: HomeAssistant, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test we show a suitable error if neither still or stream URL are provided.""" data = TESTDATA.copy() @@ -929,7 +857,7 @@ async def test_options_still_and_stream_not_provided( @respx.mock @pytest.mark.usefixtures("fakeimg_png") -async def test_form_options_permission_error( +async def test_options_permission_error( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test we handle a PermissionError and pass the message through.""" @@ -947,43 +875,6 @@ async def test_form_options_permission_error( assert result2["errors"] == {"stream_source": "stream_not_permitted"} -@pytest.mark.usefixtures("fakeimg_png") -async def test_unload_entry(hass: HomeAssistant) -> None: - """Test unloading the generic IP Camera entry.""" - mock_entry = MockConfigEntry(domain=DOMAIN, options=TESTDATA) - mock_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - assert mock_entry.state is ConfigEntryState.LOADED - - await hass.config_entries.async_unload(mock_entry.entry_id) - await hass.async_block_till_done() - assert mock_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_reload_on_title_change(hass: HomeAssistant) -> None: - """Test the integration gets reloaded when the title is updated.""" - - test_data = TESTDATA_OPTIONS - test_data[CONF_CONTENT_TYPE] = "image/png" - mock_entry = MockConfigEntry( - domain=DOMAIN, unique_id="54321", options=test_data, title="My Title" - ) - mock_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - assert mock_entry.state is ConfigEntryState.LOADED - assert hass.states.get("camera.my_title").attributes["friendly_name"] == "My Title" - - hass.config_entries.async_update_entry(mock_entry, title="New Title") - assert mock_entry.title == "New Title" - await hass.async_block_till_done() - - assert hass.states.get("camera.my_title").attributes["friendly_name"] == "New Title" - - async def test_migrate_existing_ids( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: @@ -1019,40 +910,26 @@ async def test_migrate_existing_ids( @respx.mock @pytest.mark.usefixtures("fakeimg_png") -async def test_use_wallclock_as_timestamps_option( +async def test_options_use_wallclock_as_timestamps( hass: HomeAssistant, mock_create_stream: _patch[MagicMock], hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, fakeimgbytes_png: bytes, + config_entry: MockConfigEntry, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test the use_wallclock_as_timestamps option flow.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) - mock_entry = MockConfigEntry( - title="Test Camera", - domain=DOMAIN, - data={}, - options=TESTDATA, - ) - - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - result = await hass.config_entries.options.async_init( - mock_entry.entry_id, context={"show_advanced_options": True} + config_entry.entry_id, context={"show_advanced_options": True} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with ( - patch("homeassistant.components.generic.async_setup_entry", return_value=True), - mock_create_stream, - ): - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, - ) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, + ) assert result2["type"] is FlowResultType.FORM ws_client = await hass_ws_client() @@ -1079,14 +956,10 @@ async def test_use_wallclock_as_timestamps_option( ) assert result3["type"] is FlowResultType.FORM assert result3["step_id"] == "init" - with ( - patch("homeassistant.components.generic.async_setup_entry", return_value=True), - mock_create_stream, - ): - result4 = await hass.config_entries.options.async_configure( - result3["flow_id"], - user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, - ) + result4 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, + ) assert result4["type"] is FlowResultType.FORM assert result4["step_id"] == "user_confirm" result5 = await hass.config_entries.options.async_configure( diff --git a/tests/components/generic/test_init.py b/tests/components/generic/test_init.py new file mode 100644 index 00000000000..faa00ee9144 --- /dev/null +++ b/tests/components/generic/test_init.py @@ -0,0 +1,37 @@ +"""Define tests for the generic (IP camera) integration.""" + +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("fakeimg_png") +async def test_unload_entry(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: + """Test unloading the generic IP Camera entry.""" + assert setup_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(setup_entry.entry_id) + await hass.async_block_till_done() + assert setup_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_reload_on_title_change( + hass: HomeAssistant, setup_entry: MockConfigEntry +) -> None: + """Test the integration gets reloaded when the title is updated.""" + assert setup_entry.state is ConfigEntryState.LOADED + assert ( + hass.states.get("camera.test_camera").attributes["friendly_name"] + == "Test Camera" + ) + + hass.config_entries.async_update_entry(setup_entry, title="New Title") + assert setup_entry.title == "New Title" + await hass.async_block_till_done() + + assert ( + hass.states.get("camera.test_camera").attributes["friendly_name"] == "New Title" + ) From c7a5c49a03241866a976a2ddb69a1acb0fdc4fe6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 13 Jan 2025 14:16:00 +0100 Subject: [PATCH 0361/2987] Small fixes in the strings file of the azure_data_explorer integration (#135309) --- homeassistant/components/azure_data_explorer/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/azure_data_explorer/strings.json b/homeassistant/components/azure_data_explorer/strings.json index c8ec158a844..b4734ad2262 100644 --- a/homeassistant/components/azure_data_explorer/strings.json +++ b/homeassistant/components/azure_data_explorer/strings.json @@ -2,10 +2,10 @@ "config": { "step": { "user": { - "title": "Setup your Azure Data Explorer integration", + "title": "Set up Azure Data Explorer", "description": "Enter connection details", "data": { - "cluster_ingest_uri": "Cluster Ingest URI", + "cluster_ingest_uri": "Cluster ingestion URI", "authority_id": "Authority ID", "client_id": "Client ID", "client_secret": "Client secret", @@ -14,7 +14,7 @@ "use_queued_ingestion": "Use queued ingestion" }, "data_description": { - "cluster_ingest_uri": "Ingest-URI of the cluster", + "cluster_ingest_uri": "Ingestion URI of the cluster", "use_queued_ingestion": "Must be enabled when using ADX free cluster" } } From ec5759d3b90be6ff28c4bca7ee963d844656166f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 13 Jan 2025 14:16:25 +0100 Subject: [PATCH 0362/2987] Fix typos "Login" > "Log in" and "Setup" > "Set up" (#135306) --- homeassistant/components/cookidoo/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cookidoo/strings.json b/homeassistant/components/cookidoo/strings.json index 3f786fe937a..8a2a288d11b 100644 --- a/homeassistant/components/cookidoo/strings.json +++ b/homeassistant/components/cookidoo/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Setup {cookidoo}", + "title": "Set up {cookidoo}", "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]", @@ -15,7 +15,7 @@ } }, "language": { - "title": "Setup {cookidoo}", + "title": "[%key:component::cookidoo::config::step::user::title%]", "data": { "language": "[%key:common::config_flow::data::language%]" }, @@ -24,7 +24,7 @@ } }, "reauth_confirm": { - "title": "Login again to {cookidoo}", + "title": "Log in again to {cookidoo}", "description": "Please log in to {cookidoo} again to continue using this integration.", "data": { "email": "[%key:common::config_flow::data::email%]", From ba9ad009e9596651ae7bb854a264c9af714a2995 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 13 Jan 2025 15:37:40 +0200 Subject: [PATCH 0363/2987] Fix LG webOS TV trigger validation (#135312) * Fix LG webOS TV trigger validation * Raise if not loaded --- .../components/webostv/device_trigger.py | 4 +-- homeassistant/components/webostv/helpers.py | 19 ++++++++------ .../components/webostv/test_device_trigger.py | 25 ++++++++++++++++--- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/webostv/device_trigger.py b/homeassistant/components/webostv/device_trigger.py index f16b1cec4f5..877c607f939 100644 --- a/homeassistant/components/webostv/device_trigger.py +++ b/homeassistant/components/webostv/device_trigger.py @@ -15,7 +15,6 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import trigger -from .const import DOMAIN from .helpers import ( async_get_client_by_device_entry, async_get_device_entry_by_device_id, @@ -43,8 +42,7 @@ async def async_validate_trigger_config( device_id = config[CONF_DEVICE_ID] try: device = async_get_device_entry_by_device_id(hass, device_id) - if DOMAIN in hass.data: - async_get_client_by_device_entry(hass, device) + async_get_client_by_device_entry(hass, device) except ValueError as err: raise InvalidDeviceAutomationConfig(err) from err diff --git a/homeassistant/components/webostv/helpers.py b/homeassistant/components/webostv/helpers.py index f6b5fa04d86..63724069f17 100644 --- a/homeassistant/components/webostv/helpers.py +++ b/homeassistant/components/webostv/helpers.py @@ -4,6 +4,7 @@ from __future__ import annotations from aiowebostv import WebOsClient +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry @@ -55,16 +56,18 @@ def async_get_client_by_device_entry( Raises ValueError if client is not found. """ for config_entry_id in device.config_entries: - if entry := hass.config_entries.async_get_entry(config_entry_id): - client = entry.runtime_data - break + entry = hass.config_entries.async_get_entry(config_entry_id) + if entry and entry.domain == DOMAIN: + if entry.state is ConfigEntryState.LOADED: + return entry.runtime_data - if not client: - raise ValueError( - f"Device {device.id} is not from an existing {DOMAIN} config entry" - ) + raise ValueError( + f"Device {device.id} is not from a loaded {DOMAIN} config entry" + ) - return client + raise ValueError( + f"Device {device.id} is not from an existing {DOMAIN} config entry" + ) async def async_get_sources(host: str, key: str) -> list[str]: diff --git a/tests/components/webostv/test_device_trigger.py b/tests/components/webostv/test_device_trigger.py index 5c3d7d63346..284cd8ad108 100644 --- a/tests/components/webostv/test_device_trigger.py +++ b/tests/components/webostv/test_device_trigger.py @@ -104,10 +104,10 @@ async def test_if_fires_on_turn_on_request( assert service_calls[2].data["id"] == 0 -async def test_failure_scenarios( +async def test_invalid_trigger_raises( hass: HomeAssistant, device_registry: dr.DeviceRegistry, client ) -> None: - """Test failure scenarios.""" + """Test invalid trigger platform or device id raises.""" await setup_webostv(hass) # Test wrong trigger platform type @@ -128,7 +128,26 @@ async def test_failure_scenarios( }, ) - entry = MockConfigEntry(domain="fake", state=ConfigEntryState.LOADED, data={}) + +@pytest.mark.parametrize( + ("domain", "entry_state"), + [ + (DOMAIN, ConfigEntryState.NOT_LOADED), + ("fake", ConfigEntryState.LOADED), + ], +) +async def test_invalid_entry_raises( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + caplog: pytest.LogCaptureFixture, + domain: str, + entry_state: ConfigEntryState, +) -> None: + """Test device id not loaded or from another domain raises.""" + await setup_webostv(hass) + + entry = MockConfigEntry(domain=domain, state=entry_state, data={}) entry.runtime_data = None entry.add_to_hass(hass) From 6060f637a884eed8d3c61223c0f1ac118e74f666 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 13 Jan 2025 14:47:32 +0100 Subject: [PATCH 0364/2987] Update getmac to 0.9.5 (#135506) --- homeassistant/components/dlna_dmr/manifest.json | 2 +- homeassistant/components/kef/manifest.json | 2 +- homeassistant/components/nmap_tracker/manifest.json | 2 +- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index af16379e9c9..adbb4198b9f 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.42.0", "getmac==0.9.4"], + "requirements": ["async-upnp-client==0.42.0", "getmac==0.9.5"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/kef/manifest.json b/homeassistant/components/kef/manifest.json index 1bbce2ff35d..db331fe6874 100644 --- a/homeassistant/components/kef/manifest.json +++ b/homeassistant/components/kef/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["aiokef", "tenacity"], "quality_scale": "legacy", - "requirements": ["aiokef==0.2.16", "getmac==0.9.4"] + "requirements": ["aiokef==0.2.16", "getmac==0.9.5"] } diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json index 5b2dab50812..72e5764fed7 100644 --- a/homeassistant/components/nmap_tracker/manifest.json +++ b/homeassistant/components/nmap_tracker/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/nmap_tracker", "iot_class": "local_polling", "loggers": ["nmap"], - "requirements": ["netmap==0.7.0.2", "getmac==0.9.4", "aiooui==0.1.7"] + "requirements": ["netmap==0.7.0.2", "getmac==0.9.5", "aiooui==0.1.7"] } diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index a1fda25589e..43bd92799a8 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -35,7 +35,7 @@ "iot_class": "local_push", "loggers": ["samsungctl", "samsungtvws"], "requirements": [ - "getmac==0.9.4", + "getmac==0.9.5", "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", "wakeonlan==2.1.0", diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 08e0be2d712..0ca103300da 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.42.0", "getmac==0.9.4"], + "requirements": ["async-upnp-client==0.42.0", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/requirements_all.txt b/requirements_all.txt index 7b6a8067652..239f68cc317 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -993,7 +993,7 @@ georss-qld-bushfire-alert-client==0.8 # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp -getmac==0.9.4 +getmac==0.9.5 # homeassistant.components.gios gios==5.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ebf2ae1ae1b..e248ec9f6c1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -846,7 +846,7 @@ georss-qld-bushfire-alert-client==0.8 # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp -getmac==0.9.4 +getmac==0.9.5 # homeassistant.components.gios gios==5.0.0 From 0d116ec6a25024b1722469b7367c9fce13d93c65 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 13 Jan 2025 14:49:01 +0100 Subject: [PATCH 0365/2987] Improve tests of energyzero integration (#133452) Co-authored-by: Franck Nijhof --- tests/components/energyzero/__init__.py | 11 + tests/components/energyzero/conftest.py | 3 +- .../snapshots/test_config_flow.ambr | 39 -- .../snapshots/test_diagnostics.ambr | 44 +- .../energyzero/snapshots/test_sensor.ambr | 490 +++++++++++------- .../components/energyzero/test_config_flow.py | 6 +- .../components/energyzero/test_diagnostics.py | 37 +- tests/components/energyzero/test_sensor.py | 87 +--- 8 files changed, 366 insertions(+), 351 deletions(-) delete mode 100644 tests/components/energyzero/snapshots/test_config_flow.ambr diff --git a/tests/components/energyzero/__init__.py b/tests/components/energyzero/__init__.py index 287bdf6a2f4..35a1346790f 100644 --- a/tests/components/energyzero/__init__.py +++ b/tests/components/energyzero/__init__.py @@ -1 +1,12 @@ """Tests for the EnergyZero integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the integration.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/energyzero/conftest.py b/tests/components/energyzero/conftest.py index d42283c0d4b..3fd93ee31f8 100644 --- a/tests/components/energyzero/conftest.py +++ b/tests/components/energyzero/conftest.py @@ -29,7 +29,8 @@ def mock_config_entry() -> MockConfigEntry: title="energy", domain=DOMAIN, data={}, - unique_id="unique_thingy", + unique_id=DOMAIN, + entry_id="12345", ) diff --git a/tests/components/energyzero/snapshots/test_config_flow.ambr b/tests/components/energyzero/snapshots/test_config_flow.ambr deleted file mode 100644 index 72e504c97c8..00000000000 --- a/tests/components/energyzero/snapshots/test_config_flow.ambr +++ /dev/null @@ -1,39 +0,0 @@ -# serializer version: 1 -# name: test_full_user_flow - FlowResultSnapshot({ - 'context': dict({ - 'source': 'user', - 'unique_id': 'energyzero', - }), - 'data': dict({ - }), - 'description': None, - 'description_placeholders': None, - 'flow_id': , - 'handler': 'energyzero', - 'minor_version': 1, - 'options': dict({ - }), - 'result': ConfigEntrySnapshot({ - 'data': dict({ - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'energyzero', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'user', - 'title': 'EnergyZero', - 'unique_id': 'energyzero', - 'version': 1, - }), - 'title': 'EnergyZero', - 'type': , - 'version': 1, - }) -# --- diff --git a/tests/components/energyzero/snapshots/test_diagnostics.ambr b/tests/components/energyzero/snapshots/test_diagnostics.ambr index 90c11ecfc6f..aaa52cfeb7e 100644 --- a/tests/components/energyzero/snapshots/test_diagnostics.ambr +++ b/tests/components/energyzero/snapshots/test_diagnostics.ambr @@ -1,26 +1,4 @@ # serializer version: 1 -# name: test_diagnostics - dict({ - 'energy': dict({ - 'average_price': 0.37, - 'current_hour_price': 0.49, - 'highest_price_time': '2022-12-07T16:00:00+00:00', - 'hours_priced_equal_or_lower': 23, - 'lowest_price_time': '2022-12-07T02:00:00+00:00', - 'max_price': 0.55, - 'min_price': 0.26, - 'next_hour_price': 0.55, - 'percentage_of_max': 89.09, - }), - 'entry': dict({ - 'title': 'energy', - }), - 'gas': dict({ - 'current_hour_price': 1.47, - 'next_hour_price': 1.47, - }), - }) -# --- # name: test_diagnostics_no_gas_today dict({ 'energy': dict({ @@ -43,3 +21,25 @@ }), }) # --- +# name: test_entry_diagnostics + dict({ + 'energy': dict({ + 'average_price': 0.37, + 'current_hour_price': 0.49, + 'highest_price_time': '2022-12-07T16:00:00+00:00', + 'hours_priced_equal_or_lower': 23, + 'lowest_price_time': '2022-12-07T02:00:00+00:00', + 'max_price': 0.55, + 'min_price': 0.26, + 'next_hour_price': 0.55, + 'percentage_of_max': 89.09, + }), + 'entry': dict({ + 'title': 'energy', + }), + 'gas': dict({ + 'current_hour_price': 1.47, + 'next_hour_price': 1.47, + }), + }) +# --- diff --git a/tests/components/energyzero/snapshots/test_sensor.ambr b/tests/components/energyzero/snapshots/test_sensor.ambr index 3a66f25fd32..452f4ae748e 100644 --- a/tests/components/energyzero/snapshots/test_sensor.ambr +++ b/tests/components/energyzero/snapshots/test_sensor.ambr @@ -1,20 +1,5 @@ # serializer version: 1 -# name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Average - today', - 'unit_of_measurement': '€/kWh', - }), - 'context': , - 'entity_id': 'sensor.energyzero_today_energy_average_price', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.37', - }) -# --- -# name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_average_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -43,52 +28,26 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'average_price', + 'unique_id': '12345_today_energy_average_price', 'unit_of_measurement': '€/kWh', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy] +# name: test_sensor[sensor.energyzero_today_energy_average_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Current hour', - 'state_class': , + 'friendly_name': 'Energy market price Average - today', 'unit_of_measurement': '€/kWh', }), 'context': , - 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', + 'entity_id': 'sensor.energyzero_today_energy_average_price', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.49', + 'state': '0.37', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -119,51 +78,27 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'current_hour_price', + 'unique_id': '12345_today_energy_current_hour_price', 'unit_of_measurement': '€/kWh', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy] +# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', - 'device_class': 'timestamp', - 'friendly_name': 'Energy market price Time of highest price - today', + 'friendly_name': 'Energy market price Current hour', + 'state_class': , + 'unit_of_measurement': '€/kWh', }), 'context': , - 'entity_id': 'sensor.energyzero_today_energy_highest_price_time', + 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2022-12-07T16:00:00+00:00', + 'state': '0.49', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -192,51 +127,26 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'highest_price_time', + 'unique_id': '12345_today_energy_highest_price_time', 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy] +# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Hours priced equal or lower than current - today', - 'unit_of_measurement': , + 'device_class': 'timestamp', + 'friendly_name': 'Energy market price Time of highest price - today', }), 'context': , - 'entity_id': 'sensor.energyzero_today_energy_hours_priced_equal_or_lower', + 'entity_id': 'sensor.energyzero_today_energy_highest_price_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '23', + 'state': '2022-12-07T16:00:00+00:00', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -265,51 +175,74 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hours_priced_equal_or_lower', + 'unique_id': '12345_today_energy_hours_priced_equal_or_lower', 'unit_of_measurement': , }) # --- -# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy] +# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Highest price - today', - 'unit_of_measurement': '€/kWh', + 'friendly_name': 'Energy market price Hours priced equal or lower than current - today', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energyzero_today_energy_max_price', + 'entity_id': 'sensor.energyzero_today_energy_hours_priced_equal_or_lower', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.55', + 'state': '23', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_lowest_price_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_lowest_price_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time of lowest price - today', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lowest_price_time', + 'unique_id': '12345_today_energy_lowest_price_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_lowest_price_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'device_class': 'timestamp', + 'friendly_name': 'Energy market price Time of lowest price - today', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_lowest_price_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2022-12-07T02:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_max_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -338,52 +271,170 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'max_price', + 'unique_id': '12345_today_energy_max_price', 'unit_of_measurement': '€/kWh', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-today_gas_current_hour_price-today_gas] +# name: test_sensor[sensor.energyzero_today_energy_max_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Gas market price Current hour', - 'state_class': , - 'unit_of_measurement': '€/m³', + 'friendly_name': 'Energy market price Highest price - today', + 'unit_of_measurement': '€/kWh', }), 'context': , - 'entity_id': 'sensor.energyzero_today_gas_current_hour_price', + 'entity_id': 'sensor.energyzero_today_energy_max_price', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.47', + 'state': '0.55', }) # --- -# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-today_gas_current_hour_price-today_gas].1 +# name: test_sensor[sensor.energyzero_today_energy_min_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_min_price', + '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': 'Lowest price - today', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'min_price', + 'unique_id': '12345_today_energy_min_price', + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_min_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Lowest price - today', + 'unit_of_measurement': '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_min_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.26', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_next_hour_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_next_hour_price', + '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': 'Next hour', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_hour_price', + 'unique_id': '12345_today_energy_next_hour_price', + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_next_hour_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Next hour', + 'unit_of_measurement': '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_next_hour_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.55', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_percentage_of_max-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_percentage_of_max', + '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': 'Current percentage of highest price - today', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'percentage_of_max', + 'unique_id': '12345_today_energy_percentage_of_max', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_percentage_of_max-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Current percentage of highest price - today', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_percentage_of_max', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '89.09', + }) +# --- +# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -414,32 +465,71 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'current_hour_price', + 'unique_id': '12345_today_gas_current_hour_price', 'unit_of_measurement': '€/m³', }) # --- -# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-today_gas_current_hour_price-today_gas].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ +# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Gas market price Current hour', + 'state_class': , + 'unit_of_measurement': '€/m³', }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Gas market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'context': , + 'entity_id': 'sensor.energyzero_today_gas_current_hour_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.47', + }) +# --- +# name: test_sensor[sensor.energyzero_today_gas_next_hour_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_gas_next_hour_price', + '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': 'Next hour', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_hour_price', + 'unique_id': '12345_today_gas_next_hour_price', + 'unit_of_measurement': '€/m³', + }) +# --- +# name: test_sensor[sensor.energyzero_today_gas_next_hour_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Gas market price Next hour', + 'unit_of_measurement': '€/m³', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_gas_next_hour_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.47', }) # --- diff --git a/tests/components/energyzero/test_config_flow.py b/tests/components/energyzero/test_config_flow.py index 4c4e831e448..09884ff4cf6 100644 --- a/tests/components/energyzero/test_config_flow.py +++ b/tests/components/energyzero/test_config_flow.py @@ -2,8 +2,6 @@ from unittest.mock import MagicMock -from syrupy.assertion import SnapshotAssertion - from homeassistant.components.energyzero.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant @@ -15,7 +13,6 @@ from tests.common import MockConfigEntry async def test_full_user_flow( hass: HomeAssistant, mock_setup_entry: MagicMock, - snapshot: SnapshotAssertion, ) -> None: """Test the full user configuration flow.""" result = await hass.config_entries.flow.async_init( @@ -32,7 +29,8 @@ async def test_full_user_flow( ) assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2 == snapshot + assert result2.get("title") == "EnergyZero" + assert result2.get("data") == {} assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/energyzero/test_diagnostics.py b/tests/components/energyzero/test_diagnostics.py index f4408ded05d..198f21822c7 100644 --- a/tests/components/energyzero/test_diagnostics.py +++ b/tests/components/energyzero/test_diagnostics.py @@ -1,53 +1,54 @@ """Tests for the diagnostics data provided by the EnergyZero integration.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from energyzero import EnergyZeroNoDataError +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.components.energyzero.const import SCAN_INTERVAL from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator pytestmark = pytest.mark.freeze_time("2022-12-07 15:00:00") -async def test_diagnostics( +async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - init_integration: MockConfigEntry, + mock_energyzero: AsyncMock, + mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test diagnostics.""" - assert ( - await get_diagnostics_for_config_entry(hass, hass_client, init_integration) - == snapshot + """Test the EnergyZero entry diagnostics.""" + await setup_integration(hass, mock_config_entry) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry ) + assert result == snapshot + async def test_diagnostics_no_gas_today( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_energyzero: MagicMock, init_integration: MockConfigEntry, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test diagnostics, no gas sensors available.""" - await async_setup_component(hass, "homeassistant", {}) mock_energyzero.gas_prices.side_effect = EnergyZeroNoDataError - await hass.services.async_call( - "homeassistant", - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: ["sensor.energyzero_today_gas_current_hour_price"]}, - blocking=True, - ) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ( diff --git a/tests/components/energyzero/test_sensor.py b/tests/components/energyzero/test_sensor.py index 5c4700c21f1..d952ac77515 100644 --- a/tests/components/energyzero/test_sensor.py +++ b/tests/components/energyzero/test_sensor.py @@ -1,96 +1,49 @@ """Tests for the sensors provided by the EnergyZero integration.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from energyzero import EnergyZeroNoDataError +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from syrupy.filters import props -from homeassistant.components.energyzero.const import DOMAIN -from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.components.energyzero.const import SCAN_INTERVAL +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform pytestmark = [pytest.mark.freeze_time("2022-12-07 15:00:00")] -@pytest.mark.parametrize( - ("entity_id", "entity_unique_id", "device_identifier"), - [ - ( - "sensor.energyzero_today_energy_current_hour_price", - "today_energy_current_hour_price", - "today_energy", - ), - ( - "sensor.energyzero_today_energy_average_price", - "today_energy_average_price", - "today_energy", - ), - ( - "sensor.energyzero_today_energy_max_price", - "today_energy_max_price", - "today_energy", - ), - ( - "sensor.energyzero_today_energy_highest_price_time", - "today_energy_highest_price_time", - "today_energy", - ), - ( - "sensor.energyzero_today_energy_hours_priced_equal_or_lower", - "today_energy_hours_priced_equal_or_lower", - "today_energy", - ), - ( - "sensor.energyzero_today_gas_current_hour_price", - "today_gas_current_hour_price", - "today_gas", - ), - ], -) async def test_sensor( hass: HomeAssistant, - init_integration: MockConfigEntry, - device_registry: dr.DeviceRegistry, + mock_energyzero: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, - entity_id: str, - entity_unique_id: str, - device_identifier: str, ) -> None: """Test the EnergyZero - Energy sensors.""" - entry_id = init_integration.entry_id - assert (state := hass.states.get(entity_id)) - assert state == snapshot - assert (entity_entry := entity_registry.async_get(entity_id)) - assert entity_entry == snapshot(exclude=props("unique_id")) - assert entity_entry.unique_id == f"{entry_id}_{entity_unique_id}" + with patch("homeassistant.components.energyzero.PLATFORMS", ["sensor"]): + await setup_integration(hass, mock_config_entry) - assert entity_entry.device_id - assert (device_entry := device_registry.async_get(entity_entry.device_id)) - assert device_entry == snapshot(exclude=props("identifiers")) - assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_{device_identifier}")} + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.usefixtures("init_integration") -async def test_no_gas_today(hass: HomeAssistant, mock_energyzero: MagicMock) -> None: +async def test_no_gas_today( + hass: HomeAssistant, + mock_energyzero: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: """Test the EnergyZero - No gas sensors available.""" - await async_setup_component(hass, "homeassistant", {}) - mock_energyzero.gas_prices.side_effect = EnergyZeroNoDataError - await hass.services.async_call( - "homeassistant", - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: ["sensor.energyzero_today_gas_current_hour_price"]}, - blocking=True, - ) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (state := hass.states.get("sensor.energyzero_today_gas_current_hour_price")) From fc0a6c2ff31f00b00617714b7b60a97cb10abaad Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 13 Jan 2025 14:50:55 +0100 Subject: [PATCH 0366/2987] Refactor number/select to use common method in IronOS (#134173) --- .../components/iron_os/coordinator.py | 7 +++++- homeassistant/components/iron_os/number.py | 22 ++++--------------- homeassistant/components/iron_os/select.py | 22 +++++-------------- 3 files changed, 15 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index 339cbdcca28..080fee20762 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta +from enum import Enum import logging from typing import cast @@ -151,7 +152,11 @@ class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]): return self.data or SettingsDataResponse() - async def write(self, characteristic: CharSetting, value: bool) -> None: + async def write( + self, + characteristic: CharSetting, + value: bool | Enum | float, + ) -> None: """Write value to the settings characteristic.""" try: diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py index e50b227bbef..518c11372c4 100644 --- a/homeassistant/components/iron_os/number.py +++ b/homeassistant/components/iron_os/number.py @@ -6,12 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum -from pynecil import ( - CharSetting, - CommunicationError, - LiveDataResponse, - SettingsDataResponse, -) +from pynecil import CharSetting, LiveDataResponse, SettingsDataResponse from homeassistant.components.number import ( DEFAULT_MAX_VALUE, @@ -28,11 +23,10 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import IronOSConfigEntry -from .const import DOMAIN, MAX_TEMP, MIN_TEMP +from .const import MAX_TEMP, MIN_TEMP from .coordinator import IronOSCoordinators from .entity import IronOSBaseEntity @@ -363,16 +357,8 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity): """Update the current value.""" if raw_value_fn := self.entity_description.raw_value_fn: value = raw_value_fn(value) - try: - await self.coordinator.device.write( - self.entity_description.characteristic, value - ) - except CommunicationError as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="submit_setting_failed", - ) from e - await self.settings.async_request_refresh() + + await self.settings.write(self.entity_description.characteristic, value) @property def native_value(self) -> float | int | None: diff --git a/homeassistant/components/iron_os/select.py b/homeassistant/components/iron_os/select.py index cc275e7c63c..e9c7f81c208 100644 --- a/homeassistant/components/iron_os/select.py +++ b/homeassistant/components/iron_os/select.py @@ -5,14 +5,12 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from enum import Enum, StrEnum -from typing import Any from pynecil import ( AnimationSpeed, AutostartMode, BatteryType, CharSetting, - CommunicationError, LockingMode, LogoDuration, ScreenOrientationMode, @@ -25,11 +23,9 @@ from pynecil import ( from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import IronOSConfigEntry -from .const import DOMAIN from .coordinator import IronOSCoordinators from .entity import IronOSBaseEntity @@ -42,7 +38,7 @@ class IronOSSelectEntityDescription(SelectEntityDescription): value_fn: Callable[[SettingsDataResponse], str | None] characteristic: CharSetting - raw_value_fn: Callable[[str], Any] | None = None + raw_value_fn: Callable[[str], Enum] class PinecilSelect(StrEnum): @@ -193,18 +189,10 @@ class IronOSSelectEntity(IronOSBaseEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" - if raw_value_fn := self.entity_description.raw_value_fn: - value = raw_value_fn(option) - try: - await self.coordinator.device.write( - self.entity_description.characteristic, value - ) - except CommunicationError as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="submit_setting_failed", - ) from e - await self.settings.async_request_refresh() + await self.settings.write( + self.entity_description.characteristic, + self.entity_description.raw_value_fn(option), + ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" From 157548609b41f631ea3557e002bb304a19cd709b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 13 Jan 2025 15:18:47 +0100 Subject: [PATCH 0367/2987] Revert "Make all three numbered lists consistent, using 1. 1. 1. for the syntax" (#135510) --- homeassistant/components/nest/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index e6d0dfce2d4..a31a2856544 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -27,7 +27,7 @@ }, "pubsub": { "title": "Configure Google Cloud Pub/Sub", - "description": "Home Assistant uses Cloud Pub/Sub receive realtime Nest device updates. Nest servers publish updates to a Pub/Sub topic and Home Assistant receives the updates through a Pub/Sub subscription.\n\n1. Visit the [Device Access Console]({device_access_console_url}) and ensure a Pub/Sub topic is configured.\n1. Visit the [Cloud Console]({url}) to find your Google Cloud Project ID and confirm it is correct below.\n1. The next step will attempt to auto-discover Pub/Sub topics and subscriptions.\n\nSee the integration documentation for [more info]({more_info_url}).", + "description": "Home Assistant uses Cloud Pub/Sub receive realtime Nest device updates. Nest servers publish updates to a Pub/Sub topic and Home Assistant receives the updates through a Pub/Sub subscription.\n\n1. Visit the [Device Access Console]({device_access_console_url}) and ensure a Pub/Sub topic is configured.\n2. Visit the [Cloud Console]({url}) to find your Google Cloud Project ID and confirm it is correct below.\n3. The next step will attempt to auto-discover Pub/Sub topics and subscriptions.\n\nSee the integration documentation for [more info]({more_info_url}).", "data": { "cloud_project_id": "[%key:component::nest::config::step::cloud_project::data::cloud_project_id%]" } From 4709a3162c0dc108ed531ee8720a60504985da53 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 13 Jan 2025 15:38:02 +0100 Subject: [PATCH 0368/2987] Change Trafikverket Train to use station signatures (#131416) Co-authored-by: Robert Resch --- .../components/trafikverket_train/__init__.py | 68 ++++- .../trafikverket_train/config_flow.py | 201 ++++++++------ .../trafikverket_train/coordinator.py | 19 +- .../trafikverket_train/strings.json | 7 + .../components/trafikverket_train/conftest.py | 67 ++++- .../trafikverket_train/test_config_flow.py | 250 +++++++++++------- .../trafikverket_train/test_init.py | 124 +++++++-- 7 files changed, 516 insertions(+), 220 deletions(-) diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index d09077dd01a..19f88817e71 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -4,11 +4,21 @@ from __future__ import annotations import logging -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from pytrafikverket import ( + InvalidAuthentication, + NoTrainStationFound, + TrafikverketTrain, + UnknownError, +) -from .const import PLATFORMS +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_FROM, CONF_TO, PLATFORMS from .coordinator import TVDataUpdateCoordinator TVTrainConfigEntry = ConfigEntry[TVDataUpdateCoordinator] @@ -52,13 +62,55 @@ async def async_migrate_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> """Migrate config entry.""" _LOGGER.debug("Migrating from version %s", entry.version) - if entry.version > 1: + if entry.version > 2: # This means the user has downgraded from a future version return False - if entry.version == 1 and entry.minor_version == 1: - # Remove unique id - hass.config_entries.async_update_entry(entry, unique_id=None, minor_version=2) + if entry.version == 1: + if entry.minor_version == 1: + # Remove unique id + hass.config_entries.async_update_entry( + entry, unique_id=None, minor_version=2 + ) + + # Change from station names to station signatures + try: + web_session = async_get_clientsession(hass) + train_api = TrafikverketTrain(web_session, entry.data[CONF_API_KEY]) + from_stations = await train_api.async_search_train_stations( + entry.data[CONF_FROM] + ) + to_stations = await train_api.async_search_train_stations( + entry.data[CONF_TO] + ) + except InvalidAuthentication as error: + raise ConfigEntryAuthFailed from error + except NoTrainStationFound as error: + _LOGGER.error( + "Migration failed as no train station found with provided name %s", + str(error), + ) + return False + except UnknownError as error: + _LOGGER.error("Unknown error occurred during validation %s", str(error)) + return False + except Exception as error: # noqa: BLE001 + _LOGGER.error("Unknown exception occurred during validation %s", str(error)) + return False + + if len(from_stations) > 1 or len(to_stations) > 1: + _LOGGER.error( + "Migration failed as more than one station found with provided name" + ) + return False + + new_data = entry.data.copy() + new_data[CONF_FROM] = from_stations[0].signature + new_data[CONF_TO] = to_stations[0].signature + + hass.config_entries.async_update_entry( + entry, data=new_data, version=2, minor_version=1 + ) _LOGGER.debug( "Migration to version %s.%s successful", diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index 363b9bb2542..4b01f7ba4d4 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -3,16 +3,14 @@ from __future__ import annotations from collections.abc import Mapping -from datetime import datetime import logging from typing import Any -from pytrafikverket import TrafikverketTrain -from pytrafikverket.exceptions import ( +from pytrafikverket import ( InvalidAuthentication, - MultipleTrainStationsFound, - NoTrainAnnouncementFound, NoTrainStationFound, + StationInfoModel, + TrafikverketTrain, UnknownError, ) import voluptuous as vol @@ -28,16 +26,15 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( + SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, TextSelector, TimeSelector, ) -import homeassistant.util.dt as dt_util from .const import CONF_FILTER_PRODUCT, CONF_FROM, CONF_TIME, CONF_TO, DOMAIN -from .util import next_departuredate _LOGGER = logging.getLogger(__name__) @@ -68,49 +65,23 @@ DATA_SCHEMA_REAUTH = vol.Schema( ) -async def validate_input( +async def validate_station( hass: HomeAssistant, api_key: str, - train_from: str, - train_to: str, - train_time: str | None, - weekdays: list[str], - product_filter: str | None, -) -> dict[str, str]: + train_station: str, + field: str, +) -> tuple[list[StationInfoModel], dict[str, str]]: """Validate input from user input.""" errors: dict[str, str] = {} - - when = dt_util.now() - if train_time: - departure_day = next_departuredate(weekdays) - if _time := dt_util.parse_time(train_time): - when = datetime.combine( - departure_day, - _time, - dt_util.get_default_time_zone(), - ) - + stations = [] try: web_session = async_get_clientsession(hass) train_api = TrafikverketTrain(web_session, api_key) - from_station = await train_api.async_search_train_station(train_from) - to_station = await train_api.async_search_train_station(train_to) - if train_time: - await train_api.async_get_train_stop( - from_station, to_station, when, product_filter - ) - else: - await train_api.async_get_next_train_stop( - from_station, to_station, when, product_filter - ) + stations = await train_api.async_search_train_stations(train_station) except InvalidAuthentication: errors["base"] = "invalid_auth" except NoTrainStationFound: - errors["base"] = "invalid_station" - except MultipleTrainStationsFound: - errors["base"] = "more_stations" - except NoTrainAnnouncementFound: - errors["base"] = "no_trains" + errors[field] = "invalid_station" except UnknownError as error: _LOGGER.error("Unknown error occurred during validation %s", str(error)) errors["base"] = "cannot_connect" @@ -118,14 +89,18 @@ async def validate_input( _LOGGER.error("Unknown exception occurred during validation %s", str(error)) errors["base"] = "cannot_connect" - return errors + return (stations, errors) class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Trafikverket Train integration.""" - VERSION = 1 - MINOR_VERSION = 2 + VERSION = 2 + MINOR_VERSION = 1 + + _from_stations: list[StationInfoModel] + _to_stations: list[StationInfoModel] + _data: dict[str, Any] @staticmethod @callback @@ -151,14 +126,11 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] reauth_entry = self._get_reauth_entry() - errors = await validate_input( + _, errors = await validate_station( self.hass, api_key, reauth_entry.data[CONF_FROM], - reauth_entry.data[CONF_TO], - reauth_entry.data.get(CONF_TIME), - reauth_entry.data[CONF_WEEKDAY], - reauth_entry.options.get(CONF_FILTER_PRODUCT), + CONF_FROM, ) if not errors: return self.async_update_reload_and_abort( @@ -193,38 +165,40 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): if train_time: name = f"{train_from} to {train_to} at {train_time}" - errors = await validate_input( - self.hass, - api_key, - train_from, - train_to, - train_time, - train_days, - filter_product, + self._from_stations, from_errors = await validate_station( + self.hass, api_key, train_from, CONF_FROM ) + self._to_stations, to_errors = await validate_station( + self.hass, api_key, train_to, CONF_TO + ) + errors = {**from_errors, **to_errors} + if not errors: - self._async_abort_entries_match( - { - CONF_API_KEY: api_key, - CONF_FROM: train_from, - CONF_TO: train_to, - CONF_TIME: train_time, - CONF_WEEKDAY: train_days, - CONF_FILTER_PRODUCT: filter_product, - } - ) - return self.async_create_entry( - title=name, - data={ - CONF_API_KEY: api_key, - CONF_NAME: name, - CONF_FROM: train_from, - CONF_TO: train_to, - CONF_TIME: train_time, - CONF_WEEKDAY: train_days, - }, - options={CONF_FILTER_PRODUCT: filter_product}, - ) + if len(self._from_stations) == 1 and len(self._to_stations) == 1: + self._async_abort_entries_match( + { + CONF_API_KEY: api_key, + CONF_FROM: self._from_stations[0].signature, + CONF_TO: self._to_stations[0].signature, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + CONF_FILTER_PRODUCT: filter_product, + } + ) + return self.async_create_entry( + title=name, + data={ + CONF_API_KEY: api_key, + CONF_NAME: name, + CONF_FROM: self._from_stations[0].signature, + CONF_TO: self._to_stations[0].signature, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + }, + options={CONF_FILTER_PRODUCT: filter_product}, + ) + self._data = user_input + return await self.async_step_select_stations() return self.async_show_form( step_id="user", @@ -234,6 +208,77 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_select_stations( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the select station step.""" + if user_input is not None: + api_key: str = self._data[CONF_API_KEY] + train_from: str = user_input[CONF_FROM] + train_to: str = user_input[CONF_TO] + train_time: str | None = self._data.get(CONF_TIME) + train_days: list = self._data[CONF_WEEKDAY] + filter_product: str | None = self._data[CONF_FILTER_PRODUCT] + + if filter_product == "": + filter_product = None + + name = f"{self._data[CONF_FROM]} to {self._data[CONF_TO]}" + if train_time: + name = ( + f"{self._data[CONF_FROM]} to {self._data[CONF_TO]} at {train_time}" + ) + self._async_abort_entries_match( + { + CONF_API_KEY: api_key, + CONF_FROM: train_from, + CONF_TO: user_input[CONF_TO], + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + CONF_FILTER_PRODUCT: filter_product, + } + ) + return self.async_create_entry( + title=name, + data={ + CONF_API_KEY: api_key, + CONF_NAME: name, + CONF_FROM: train_from, + CONF_TO: train_to, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + }, + options={CONF_FILTER_PRODUCT: filter_product}, + ) + from_options = [ + SelectOptionDict(value=station.signature, label=station.station_name) + for station in self._from_stations + ] + to_options = [ + SelectOptionDict(value=station.signature, label=station.station_name) + for station in self._to_stations + ] + schema = {} + if len(from_options) > 1: + schema[vol.Required(CONF_FROM)] = SelectSelector( + SelectSelectorConfig( + options=from_options, mode=SelectSelectorMode.DROPDOWN, sort=True + ) + ) + if len(to_options) > 1: + schema[vol.Required(CONF_TO)] = SelectSelector( + SelectSelectorConfig( + options=to_options, mode=SelectSelectorMode.DROPDOWN, sort=True + ) + ) + + return self.async_show_form( + step_id="select_stations", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(schema), user_input or {} + ), + ) + class TVTrainOptionsFlowHandler(OptionsFlow): """Handle Trafikverket Train options.""" diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py index c4e1a418371..28c9ab6fe8e 100644 --- a/homeassistant/components/trafikverket_train/coordinator.py +++ b/homeassistant/components/trafikverket_train/coordinator.py @@ -7,15 +7,16 @@ from datetime import datetime, time, timedelta import logging from typing import TYPE_CHECKING -from pytrafikverket import TrafikverketTrain -from pytrafikverket.exceptions import ( +from pytrafikverket import ( InvalidAuthentication, MultipleTrainStationsFound, NoTrainAnnouncementFound, NoTrainStationFound, + StationInfoModel, + TrafikverketTrain, + TrainStopModel, UnknownError, ) -from pytrafikverket.models import StationInfoModel, TrainStopModel from homeassistant.const import CONF_API_KEY, CONF_WEEKDAY from homeassistant.core import HomeAssistant @@ -93,11 +94,15 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): async def _async_setup(self) -> None: """Initiate stations.""" try: - self.to_station = await self._train_api.async_search_train_station( - self.config_entry.data[CONF_TO] + self.to_station = ( + await self._train_api.async_get_train_station_from_signature( + self.config_entry.data[CONF_TO] + ) ) - self.from_station = await self._train_api.async_search_train_station( - self.config_entry.data[CONF_FROM] + self.from_station = ( + await self._train_api.async_get_train_station_from_signature( + self.config_entry.data[CONF_FROM] + ) ) except InvalidAuthentication as error: raise ConfigEntryAuthFailed from error diff --git a/homeassistant/components/trafikverket_train/strings.json b/homeassistant/components/trafikverket_train/strings.json index 89542211a92..3cecda1ded9 100644 --- a/homeassistant/components/trafikverket_train/strings.json +++ b/homeassistant/components/trafikverket_train/strings.json @@ -27,6 +27,13 @@ "filter_product": "To filter by product description add the phrase here to match" } }, + "select_stations": { + "description": "More than one station was found with the provided name, select the correct ones from the provided lists", + "data": { + "to": "To station", + "from": "From station" + } + }, "reauth_confirm": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" diff --git a/tests/components/trafikverket_train/conftest.py b/tests/components/trafikverket_train/conftest.py index 234269cc9f8..9d7a3873957 100644 --- a/tests/components/trafikverket_train/conftest.py +++ b/tests/components/trafikverket_train/conftest.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta from unittest.mock import patch import pytest -from pytrafikverket.models import TrainStopModel +from pytrafikverket import StationInfoModel, TrainStopModel from homeassistant.components.trafikverket_train.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -40,6 +40,9 @@ async def load_integration_from_entry( patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", + ), ): await hass.config_entries.async_setup(config_entry_id) await hass.async_block_till_done() @@ -50,8 +53,8 @@ async def load_integration_from_entry( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) config_entry.add_to_hass(hass) await setup_config_entry_with_mocked_data(config_entry.entry_id) @@ -61,8 +64,8 @@ async def load_integration_from_entry( source=SOURCE_USER, data=ENTRY_CONFIG2, entry_id="2", - version=1, - minor_version=2, + version=2, + minor_version=1, ) config_entry2.add_to_hass(hass) await setup_config_entry_with_mocked_data(config_entry2.entry_id) @@ -171,3 +174,57 @@ def fixture_get_train_stop() -> TrainStopModel: modified_time=datetime(2023, 5, 1, 11, 0, tzinfo=dt_util.UTC), product_description=["Regionaltåg"], ) + + +@pytest.fixture(name="get_train_stations") +def fixture_get_train_station() -> list[list[StationInfoModel]]: + """Construct StationInfoModel Mock.""" + + return [ + [ + StationInfoModel( + signature="Cst", + station_name="Stockholm C", + advertised=True, + ) + ], + [ + StationInfoModel( + signature="U", + station_name="Uppsala C", + advertised=True, + ) + ], + ] + + +@pytest.fixture(name="get_multiple_train_stations") +def fixture_get_multiple_train_station() -> list[list[StationInfoModel]]: + """Construct StationInfoModel Mock.""" + + return [ + [ + StationInfoModel( + signature="Cst", + station_name="Stockholm C", + advertised=True, + ), + StationInfoModel( + signature="Csu", + station_name="Stockholm City", + advertised=True, + ), + ], + [ + StationInfoModel( + signature="U", + station_name="Uppsala C", + advertised=True, + ), + StationInfoModel( + signature="Ups", + station_name="Uppsala City", + advertised=True, + ), + ], + ] diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index eac5e629bf0..d001215ec39 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -5,14 +5,13 @@ from __future__ import annotations from unittest.mock import patch import pytest -from pytrafikverket.exceptions import ( +from pytrafikverket import ( InvalidAuthentication, - MultipleTrainStationsFound, - NoTrainAnnouncementFound, NoTrainStationFound, + StationInfoModel, + TrainStopModel, UnknownError, ) -from pytrafikverket.models import TrainStopModel from homeassistant import config_entries from homeassistant.components.trafikverket_train.const import ( @@ -29,7 +28,9 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant) -> None: +async def test_form( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -40,10 +41,8 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", @@ -67,8 +66,8 @@ async def test_form(hass: HomeAssistant) -> None: assert result["data"] == { "api_key": "1234567890", "name": "Stockholm C to Uppsala C at 10:00", - "from": "Stockholm C", - "to": "Uppsala C", + "from": "Cst", + "to": "U", "time": "10:00", "weekday": ["mon", "fri"], } @@ -76,7 +75,70 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_entry_already_exist(hass: HomeAssistant) -> None: +async def test_form_multiple_stations( + hass: HomeAssistant, get_multiple_train_stations: list[StationInfoModel] +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: ["mon", "fri"], + }, + ) + await hass.async_block_till_done() + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_FROM: "Csu", + CONF_TO: "Ups", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Stockholm C to Uppsala C at 10:00" + assert result["data"] == { + "api_key": "1234567890", + "name": "Stockholm C to Uppsala C at 10:00", + "from": "Csu", + "to": "Ups", + "time": "10:00", + "weekday": ["mon", "fri"], + } + assert result["options"] == {"filter_product": None} + + +async def test_form_entry_already_exist( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: """Test flow aborts when entry already exist.""" entry = MockConfigEntry( @@ -84,14 +146,14 @@ async def test_form_entry_already_exist(hass: HomeAssistant) -> None: data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, CONF_FILTER_PRODUCT: None, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -103,10 +165,11 @@ async def test_form_entry_already_exist(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", ), patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", @@ -130,28 +193,24 @@ async def test_form_entry_already_exist(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("side_effect", "base_error"), + ("side_effect", "p_error"), [ ( InvalidAuthentication, - "invalid_auth", + {"base": "invalid_auth"}, ), ( NoTrainStationFound, - "invalid_station", - ), - ( - MultipleTrainStationsFound, - "more_stations", + {"from": "invalid_station", "to": "invalid_station"}, ), ( Exception, - "cannot_connect", + {"base": "cannot_connect"}, ), ], ) async def test_flow_fails( - hass: HomeAssistant, side_effect: Exception, base_error: str + hass: HomeAssistant, side_effect: Exception, p_error: dict[str, str] ) -> None: """Test config flow errors.""" result = await hass.config_entries.flow.async_init( @@ -163,12 +222,9 @@ async def test_flow_fails( with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", side_effect=side_effect(), ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", - ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -179,24 +235,24 @@ async def test_flow_fails( }, ) - assert result["errors"] == {"base": base_error} + assert result["errors"] == p_error @pytest.mark.parametrize( - ("side_effect", "base_error"), + ("side_effect", "p_error"), [ ( - NoTrainAnnouncementFound, - "no_trains", + NoTrainStationFound, + {"from": "invalid_station", "to": "invalid_station"}, ), ( UnknownError, - "cannot_connect", + {"base": "cannot_connect"}, ), ], ) async def test_flow_fails_departures( - hass: HomeAssistant, side_effect: Exception, base_error: str + hass: HomeAssistant, side_effect: Exception, p_error: dict[str, str] ) -> None: """Test config flow errors.""" result = await hass.config_entries.flow.async_init( @@ -208,15 +264,9 @@ async def test_flow_fails_departures( with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_next_train_stops", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", side_effect=side_effect(), ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", - ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -227,23 +277,25 @@ async def test_flow_fails_departures( }, ) - assert result["errors"] == {"base": base_error} + assert result["errors"] == p_error -async def test_reauth_flow(hass: HomeAssistant) -> None: +async def test_reauth_flow( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( domain=DOMAIN, data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -254,10 +306,8 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", @@ -275,8 +325,8 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert entry.data == { "api_key": "1234567891", "name": "Stockholm C to Uppsala C at 10:00", - "from": "Stockholm C", - "to": "Uppsala C", + "from": "Cst", + "to": "U", "time": "10:00", "weekday": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], } @@ -287,24 +337,27 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: [ ( InvalidAuthentication, - "invalid_auth", + {"base": "invalid_auth"}, ), ( NoTrainStationFound, - "invalid_station", + {"from": "invalid_station"}, ), ( - MultipleTrainStationsFound, - "more_stations", + UnknownError, + {"base": "cannot_connect"}, ), ( Exception, - "cannot_connect", + {"base": "cannot_connect"}, ), ], ) async def test_reauth_flow_error( - hass: HomeAssistant, side_effect: Exception, p_error: str + hass: HomeAssistant, + side_effect: Exception, + p_error: dict[str, str], + get_train_stations: list[StationInfoModel], ) -> None: """Test a reauthentication flow with error.""" entry = MockConfigEntry( @@ -312,13 +365,13 @@ async def test_reauth_flow_error( data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -326,12 +379,9 @@ async def test_reauth_flow_error( with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", side_effect=side_effect(), ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", - ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -341,14 +391,12 @@ async def test_reauth_flow_error( assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": p_error} + assert result["errors"] == p_error with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", @@ -366,8 +414,8 @@ async def test_reauth_flow_error( assert entry.data == { "api_key": "1234567891", "name": "Stockholm C to Uppsala C at 10:00", - "from": "Stockholm C", - "to": "Uppsala C", + "from": "Cst", + "to": "U", "time": "10:00", "weekday": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], } @@ -377,17 +425,20 @@ async def test_reauth_flow_error( ("side_effect", "p_error"), [ ( - NoTrainAnnouncementFound, - "no_trains", + NoTrainStationFound, + {"from": "invalid_station"}, ), ( UnknownError, - "cannot_connect", + {"base": "cannot_connect"}, ), ], ) async def test_reauth_flow_error_departures( - hass: HomeAssistant, side_effect: Exception, p_error: str + hass: HomeAssistant, + side_effect: Exception, + p_error: dict[str, str], + get_train_stations: list[StationInfoModel], ) -> None: """Test a reauthentication flow with error.""" entry = MockConfigEntry( @@ -395,13 +446,13 @@ async def test_reauth_flow_error_departures( data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -409,10 +460,7 @@ async def test_reauth_flow_error_departures( with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", side_effect=side_effect(), ), ): @@ -424,11 +472,12 @@ async def test_reauth_flow_error_departures( assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": p_error} + assert result["errors"] == p_error with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", @@ -449,8 +498,8 @@ async def test_reauth_flow_error_departures( assert entry.data == { "api_key": "1234567891", "name": "Stockholm C to Uppsala C at 10:00", - "from": "Stockholm C", - "to": "Uppsala C", + "from": "Cst", + "to": "U", "time": "10:00", "weekday": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], } @@ -460,6 +509,7 @@ async def test_options_flow( hass: HomeAssistant, get_trains: list[TrainStopModel], get_train_stop: TrainStopModel, + get_train_stations: list[StationInfoModel], ) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( @@ -467,24 +517,28 @@ async def test_options_flow( data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", return_value=get_trains, ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", + ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", return_value=get_train_stop, diff --git a/tests/components/trafikverket_train/test_init.py b/tests/components/trafikverket_train/test_init.py index 41c8e2432ef..cb048365700 100644 --- a/tests/components/trafikverket_train/test_init.py +++ b/tests/components/trafikverket_train/test_init.py @@ -4,8 +4,14 @@ from __future__ import annotations from unittest.mock import patch -from pytrafikverket.exceptions import InvalidAuthentication, NoTrainStationFound -from pytrafikverket.models import TrainStopModel +import pytest +from pytrafikverket import ( + InvalidAuthentication, + NoTrainStationFound, + StationInfoModel, + TrainStopModel, + UnknownError, +) from syrupy.assertion import SnapshotAssertion from homeassistant.components.trafikverket_train.const import DOMAIN @@ -28,14 +34,14 @@ async def test_unload_entry( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", @@ -65,13 +71,13 @@ async def test_auth_failed( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) with patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", side_effect=InvalidAuthentication, ): await hass.config_entries.async_setup(entry.entry_id) @@ -96,13 +102,13 @@ async def test_no_stations( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) with patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", side_effect=NoTrainStationFound, ): await hass.config_entries.async_setup(entry.entry_id) @@ -124,8 +130,8 @@ async def test_migrate_entity_unique_id( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -139,7 +145,7 @@ async def test_migrate_entity_unique_id( with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", @@ -158,8 +164,9 @@ async def test_migrate_entity_unique_id( async def test_migrate_entry( hass: HomeAssistant, get_trains: list[TrainStopModel], + get_train_stations: list[StationInfoModel], ) -> None: - """Test migrate entry unique id.""" + """Test migrate entry.""" entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, @@ -174,7 +181,11 @@ async def test_migrate_entry( with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", + ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", @@ -186,8 +197,18 @@ async def test_migrate_entry( assert entry.state is ConfigEntryState.LOADED - assert entry.version == 1 - assert entry.minor_version == 2 + assert entry.version == 2 + assert entry.minor_version == 1 + # Migration to version 2.1 changed from/to to use station signatures + assert entry.data == { + "api_key": "1234567890", + "from": "Cst", + "to": "U", + "time": None, + "weekday": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + "name": "Stockholm C to Uppsala C", + } + # Migration to version 1.2 removed unique_id assert entry.unique_id is None @@ -201,18 +222,73 @@ async def test_migrate_entry_from_future_version_fails( source=SOURCE_USER, data=ENTRY_CONFIG, options=OPTIONS_CONFIG, - version=2, + version=3, + minor_version=1, + entry_id="1", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.MIGRATION_ERROR + + +@pytest.mark.parametrize( + ("side_effect"), + [ + (InvalidAuthentication), + (NoTrainStationFound), + (UnknownError), + (Exception), + ], +) +async def test_migrate_entry_fails(hass: HomeAssistant, side_effect: Exception) -> None: + """Test migrate entry fails.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + version=1, + minor_version=1, entry_id="1", ) entry.add_to_hass(hass) with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", - return_value=get_trains, + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=side_effect(), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_migrate_entry_fails_multiple_stations( + hass: HomeAssistant, + get_multiple_train_stations: list[StationInfoModel], +) -> None: + """Test migrate entry fails on multiple stations found.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + version=1, + minor_version=1, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, ), ): await hass.config_entries.async_setup(entry.entry_id) From 275365a9d3ba7d6ce4b11d12a601c20f84caed6e Mon Sep 17 00:00:00 2001 From: Maxim Mikityanskiy Date: Mon, 13 Jan 2025 16:42:53 +0200 Subject: [PATCH 0369/2987] Expose raw PM2.5 in Airgradient (#135457) --- .../components/airgradient/sensor.py | 9 ++++ .../components/airgradient/strings.json | 3 ++ .../airgradient/snapshots/test_sensor.ambr | 51 +++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/homeassistant/components/airgradient/sensor.py b/homeassistant/components/airgradient/sensor.py index 497d4cc0488..273ba20d6b7 100644 --- a/homeassistant/components/airgradient/sensor.py +++ b/homeassistant/components/airgradient/sensor.py @@ -137,6 +137,15 @@ MEASUREMENT_SENSOR_TYPES: tuple[AirGradientMeasurementSensorEntityDescription, . entity_registry_enabled_default=False, value_fn=lambda status: status.raw_total_volatile_organic_component, ), + AirGradientMeasurementSensorEntityDescription( + key="pm02_raw", + translation_key="raw_pm02", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda status: status.raw_pm02, + ), ) CONFIG_SENSOR_TYPES: tuple[AirGradientConfigSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json index 6bf7242f2f1..f3f78ea8fc9 100644 --- a/homeassistant/components/airgradient/strings.json +++ b/homeassistant/components/airgradient/strings.json @@ -119,6 +119,9 @@ "raw_nitrogen": { "name": "Raw NOx" }, + "raw_pm02": { + "name": "Raw PM2.5" + }, "display_pm_standard": { "name": "[%key:component::airgradient::entity::select::display_pm_standard::name%]", "state": { diff --git a/tests/components/airgradient/snapshots/test_sensor.ambr b/tests/components/airgradient/snapshots/test_sensor.ambr index 941369ff266..3db188bed95 100644 --- a/tests/components/airgradient/snapshots/test_sensor.ambr +++ b/tests/components/airgradient/snapshots/test_sensor.ambr @@ -763,6 +763,57 @@ 'state': '16931', }) # --- +# name: test_all_entities[indoor][sensor.airgradient_raw_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_raw_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Raw PM2.5', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'raw_pm02', + 'unique_id': '84fce612f5b8-pm02_raw', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[indoor][sensor.airgradient_raw_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Airgradient Raw PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.airgradient_raw_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34', + }) +# --- # name: test_all_entities[indoor][sensor.airgradient_raw_voc-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 1e4c7e832df6100390161418ff81070e77500378 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 13 Jan 2025 17:02:23 +0100 Subject: [PATCH 0370/2987] Bump go2rtc recommended version to 1.9.8 (#135523) --- Dockerfile | 2 +- homeassistant/components/go2rtc/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index fde47288428..917b9ca19c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,7 +55,7 @@ RUN \ "armv7") go2rtc_suffix='arm' ;; \ *) go2rtc_suffix=${BUILD_ARCH} ;; \ esac \ - && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.7/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ + && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.8/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ && chmod +x /bin/go2rtc \ # Verify go2rtc can be executed && go2rtc --version diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index 3c1c84c42b5..234411936cb 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -6,4 +6,4 @@ CONF_DEBUG_UI = "debug_ui" DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." HA_MANAGED_API_PORT = 11984 HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" -RECOMMENDED_VERSION = "1.9.7" +RECOMMENDED_VERSION = "1.9.8" From 1fa3d90d73e12387ddc41936355dd70a41f31014 Mon Sep 17 00:00:00 2001 From: jesperraemaekers <146726232+jesperraemaekers@users.noreply.github.com> Date: Mon, 13 Jan 2025 17:14:10 +0100 Subject: [PATCH 0371/2987] Removing unused API file form weheat (#135518) --- homeassistant/components/weheat/api.py | 28 -------------------------- 1 file changed, 28 deletions(-) delete mode 100644 homeassistant/components/weheat/api.py diff --git a/homeassistant/components/weheat/api.py b/homeassistant/components/weheat/api.py deleted file mode 100644 index b1f5c0b3eff..00000000000 --- a/homeassistant/components/weheat/api.py +++ /dev/null @@ -1,28 +0,0 @@ -"""API for Weheat bound to Home Assistant OAuth.""" - -from aiohttp import ClientSession -from weheat.abstractions import AbstractAuth - -from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session - -from .const import API_URL - - -class AsyncConfigEntryAuth(AbstractAuth): - """Provide Weheat authentication tied to an OAuth2 based config entry.""" - - def __init__( - self, - websession: ClientSession, - oauth_session: OAuth2Session, - ) -> None: - """Initialize Weheat auth.""" - super().__init__(websession, host=API_URL) - self._oauth_session = oauth_session - - async def async_get_access_token(self) -> str: - """Return a valid access token.""" - await self._oauth_session.async_ensure_token_valid() - - return self._oauth_session.token[CONF_ACCESS_TOKEN] From 153496b5f43ef3d14b17f92e20068dc3c6a2e11e Mon Sep 17 00:00:00 2001 From: dotvav Date: Mon, 13 Jan 2025 17:17:46 +0100 Subject: [PATCH 0372/2987] Palazzetti integration: Add support for additional fans (#135377) * Add support for second and third fans * Update test mock and snapshot * Test coverage and error message * Rename fans left and right instead of 2 and 3 --- .../components/palazzetti/climate.py | 2 +- homeassistant/components/palazzetti/number.py | 60 +++++++++- .../components/palazzetti/strings.json | 9 ++ tests/components/palazzetti/conftest.py | 5 + .../palazzetti/snapshots/test_number.ambr | 112 ++++++++++++++++++ tests/components/palazzetti/test_number.py | 59 +++++++-- 6 files changed, 238 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/palazzetti/climate.py b/homeassistant/components/palazzetti/climate.py index 301ede422d6..0722b97e4b7 100644 --- a/homeassistant/components/palazzetti/climate.py +++ b/homeassistant/components/palazzetti/climate.py @@ -122,7 +122,7 @@ class PalazzettiClimateEntity(PalazzettiEntity, ClimateEntity): @property def fan_mode(self) -> str | None: """Return the fan mode.""" - api_state = self.coordinator.client.fan_speed + api_state = self.coordinator.client.current_fan_speed() return FAN_MODES[api_state] async def async_set_fan_mode(self, fan_mode: str) -> None: diff --git a/homeassistant/components/palazzetti/number.py b/homeassistant/components/palazzetti/number.py index 06114bfef54..2b303f71fd6 100644 --- a/homeassistant/components/palazzetti/number.py +++ b/homeassistant/components/palazzetti/number.py @@ -3,6 +3,7 @@ from __future__ import annotations from pypalazzetti.exceptions import CommunicationError, ValidationError +from pypalazzetti.fan import FanType from homeassistant.components.number import NumberDeviceClass, NumberEntity from homeassistant.core import HomeAssistant @@ -21,7 +22,18 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Palazzetti number platform.""" - async_add_entities([PalazzettiCombustionPowerEntity(config_entry.runtime_data)]) + + entities: list[PalazzettiEntity] = [ + PalazzettiCombustionPowerEntity(config_entry.runtime_data) + ] + + if config_entry.runtime_data.client.has_fan(FanType.LEFT): + entities.append(PalazzettiFanEntity(config_entry.runtime_data, FanType.LEFT)) + + if config_entry.runtime_data.client.has_fan(FanType.RIGHT): + entities.append(PalazzettiFanEntity(config_entry.runtime_data, FanType.RIGHT)) + + async_add_entities(entities) class PalazzettiCombustionPowerEntity(PalazzettiEntity, NumberEntity): @@ -64,3 +76,49 @@ class PalazzettiCombustionPowerEntity(PalazzettiEntity, NumberEntity): ) from err await self.coordinator.async_request_refresh() + + +class PalazzettiFanEntity(PalazzettiEntity, NumberEntity): + """Representation of Palazzetti number entity for Combustion power.""" + + _attr_device_class = NumberDeviceClass.WIND_SPEED + _attr_native_step = 1 + + def __init__( + self, coordinator: PalazzettiDataUpdateCoordinator, fan: FanType + ) -> None: + """Initialize the Palazzetti number entity.""" + super().__init__(coordinator) + self.fan = fan + + self._attr_translation_key = f"fan_{str.lower(fan.name)}_speed" + self._attr_native_min_value = coordinator.client.min_fan_speed(fan) + self._attr_native_max_value = coordinator.client.max_fan_speed(fan) + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}-fan_{str.lower(fan.name)}_speed" + ) + + @property + def native_value(self) -> float: + """Return the state of the setting entity.""" + return self.coordinator.client.current_fan_speed(self.fan) + + async def async_set_native_value(self, value: float) -> None: + """Update the setting.""" + try: + await self.coordinator.client.set_fan_speed(int(value), self.fan) + except CommunicationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from err + except ValidationError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_fan_speed", + translation_placeholders={ + "name": str.lower(self.fan.name), + "value": str(value), + }, + ) from err + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json index 93233a9b1e4..501ee777fe9 100644 --- a/homeassistant/components/palazzetti/strings.json +++ b/homeassistant/components/palazzetti/strings.json @@ -27,6 +27,9 @@ "invalid_fan_mode": { "message": "Fan mode {value} is invalid." }, + "invalid_fan_speed": { + "message": "Fan {name} speed {value} is invalid." + }, "invalid_target_temperature": { "message": "Target temperature {value} is invalid." }, @@ -59,6 +62,12 @@ "number": { "combustion_power": { "name": "Combustion power" + }, + "fan_left_speed": { + "name": "Left fan speed" + }, + "fan_right_speed": { + "name": "Right fan speed" } }, "sensor": { diff --git a/tests/components/palazzetti/conftest.py b/tests/components/palazzetti/conftest.py index fad535df914..d3694653cd4 100644 --- a/tests/components/palazzetti/conftest.py +++ b/tests/components/palazzetti/conftest.py @@ -79,7 +79,12 @@ def mock_palazzetti_client() -> Generator[AsyncMock]: mock_client.target_temperature_max = 50 mock_client.pellet_quantity = 1248 mock_client.pellet_level = 0 + mock_client.has_second_fan = True + mock_client.has_second_fan = False mock_client.fan_speed = 3 + mock_client.current_fan_speed.return_value = 3 + mock_client.min_fan_speed.return_value = 0 + mock_client.max_fan_speed.return_value = 5 mock_client.connect.return_value = True mock_client.update_state.return_value = True mock_client.set_on.return_value = True diff --git a/tests/components/palazzetti/snapshots/test_number.ambr b/tests/components/palazzetti/snapshots/test_number.ambr index 0a25a1cfa8b..7ace1149e0a 100644 --- a/tests/components/palazzetti/snapshots/test_number.ambr +++ b/tests/components/palazzetti/snapshots/test_number.ambr @@ -55,3 +55,115 @@ 'state': '3', }) # --- +# name: test_all_entities[number.stove_left_fan_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.stove_left_fan_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Left fan speed', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan_left_speed', + 'unique_id': '11:22:33:44:55:66-fan_left_speed', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.stove_left_fan_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'Stove Left fan speed', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.stove_left_fan_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_all_entities[number.stove_right_fan_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.stove_right_fan_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Right fan speed', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan_right_speed', + 'unique_id': '11:22:33:44:55:66-fan_right_speed', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.stove_right_fan_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'Stove Right fan speed', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.stove_right_fan_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- diff --git a/tests/components/palazzetti/test_number.py b/tests/components/palazzetti/test_number.py index 54318ed5c74..8f09384c1b7 100644 --- a/tests/components/palazzetti/test_number.py +++ b/tests/components/palazzetti/test_number.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, patch from pypalazzetti.exceptions import CommunicationError, ValidationError +from pypalazzetti.fan import FanType import pytest from syrupy import SnapshotAssertion @@ -16,7 +17,8 @@ from . import setup_integration from tests.common import MockConfigEntry, snapshot_platform -ENTITY_ID = "number.stove_combustion_power" +POWER_ENTITY_ID = "number.stove_combustion_power" +FAN_ENTITY_ID = "number.stove_left_fan_speed" async def test_all_entities( @@ -33,7 +35,7 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_async_set_data( +async def test_async_set_data_power( hass: HomeAssistant, mock_palazzetti_client: AsyncMock, mock_config_entry: MockConfigEntry, @@ -45,7 +47,7 @@ async def test_async_set_data( await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: ENTITY_ID, "value": 1}, + {ATTR_ENTITY_ID: POWER_ENTITY_ID, "value": 1}, blocking=True, ) mock_palazzetti_client.set_power_mode.assert_called_once_with(1) @@ -53,20 +55,63 @@ async def test_async_set_data( # Set value: Error mock_palazzetti_client.set_power_mode.side_effect = CommunicationError() - with pytest.raises(HomeAssistantError): + message = "Could not connect to the device" + with pytest.raises(HomeAssistantError, match=message): await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: ENTITY_ID, "value": 1}, + {ATTR_ENTITY_ID: POWER_ENTITY_ID, "value": 1}, blocking=True, ) mock_palazzetti_client.set_power_mode.reset_mock() mock_palazzetti_client.set_power_mode.side_effect = ValidationError() - with pytest.raises(ServiceValidationError): + message = "Combustion power 1.0 is invalid" + with pytest.raises(ServiceValidationError, match=message): await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: ENTITY_ID, "value": 1}, + {ATTR_ENTITY_ID: POWER_ENTITY_ID, "value": 1}, + blocking=True, + ) + + +async def test_async_set_data_fan( + hass: HomeAssistant, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting number data via service call.""" + await setup_integration(hass, mock_config_entry) + + # Set value: Success + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: FAN_ENTITY_ID, "value": 1}, + blocking=True, + ) + mock_palazzetti_client.set_fan_speed.assert_called_once_with(1, FanType.LEFT) + mock_palazzetti_client.set_on.reset_mock() + + # Set value: Error + mock_palazzetti_client.set_fan_speed.side_effect = CommunicationError() + message = "Could not connect to the device" + with pytest.raises(HomeAssistantError, match=message): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: FAN_ENTITY_ID, "value": 1}, + blocking=True, + ) + mock_palazzetti_client.set_on.reset_mock() + + mock_palazzetti_client.set_fan_speed.side_effect = ValidationError() + message = "Fan left speed 1.0 is invalid" + with pytest.raises(ServiceValidationError, match=message): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: FAN_ENTITY_ID, "value": 1}, blocking=True, ) From 8d382799936e0558d843b9aabddc93aad50f4899 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 13 Jan 2025 17:18:46 +0100 Subject: [PATCH 0373/2987] Bump velbusaio to 2025.1.0 (#135525) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 94c823888b7..7a2354a7283 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2024.12.3"], + "requirements": ["velbus-aio==2025.1.0"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index 239f68cc317..e3efc5aa69b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2972,7 +2972,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2024.12.3 +velbus-aio==2025.1.0 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e248ec9f6c1..2b3910945c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2388,7 +2388,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2024.12.3 +velbus-aio==2025.1.0 # homeassistant.components.venstar venstarcolortouch==0.19 From cdcc7dbbe83d1c7ede2d6ffd4f60a254152aa52e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 13 Jan 2025 18:35:14 +0100 Subject: [PATCH 0374/2987] Deprecate sensors in Habitica integration (#134036) * Deprecate sensors * move to setup, remove disabled * changes * add breaking version to string * fixes * fix entity id in tests --- homeassistant/components/habitica/sensor.py | 77 ++++++++++-- .../components/habitica/strings.json | 4 + homeassistant/components/habitica/util.py | 10 -- tests/components/habitica/test_sensor.py | 111 +++++++++++++++++- 4 files changed, 183 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index b42ffa68dc9..d6fc85b2045 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -17,16 +17,26 @@ from habiticalib import ( deserialize_task, ) +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.typing import StateType -from .const import ASSETS_URL +from .const import ASSETS_URL, DOMAIN +from .coordinator import HabiticaDataUpdateCoordinator from .entity import HabiticaBase from .types import HabiticaConfigEntry from .util import get_attribute_points, get_attributes_total, inventory_list @@ -269,6 +279,13 @@ TASK_SENSOR_DESCRIPTION: tuple[HabiticaTaskSensorEntityDescription, ...] = ( ) +def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: + """Get list of related automations and scripts.""" + used_in = automations_with_entity(hass, entity_id) + used_in += scripts_with_entity(hass, entity_id) + return used_in + + async def async_setup_entry( hass: HomeAssistant, config_entry: HabiticaConfigEntry, @@ -277,14 +294,58 @@ async def async_setup_entry( """Set up the habitica sensors.""" coordinator = config_entry.runtime_data + ent_reg = er.async_get(hass) + entities: list[SensorEntity] = [] + description: SensorEntityDescription + + def add_deprecated_entity( + description: SensorEntityDescription, + entity_cls: Callable[ + [HabiticaDataUpdateCoordinator, SensorEntityDescription], SensorEntity + ], + ) -> None: + """Add deprecated entities.""" + if entity_id := ent_reg.async_get_entity_id( + SENSOR_DOMAIN, + DOMAIN, + f"{config_entry.unique_id}_{description.key}", + ): + entity_entry = ent_reg.async_get(entity_id) + if entity_entry and entity_entry.disabled: + ent_reg.async_remove(entity_id) + async_delete_issue( + hass, + DOMAIN, + f"deprecated_entity_{description.key}", + ) + elif entity_entry: + entities.append(entity_cls(coordinator, description)) + if entity_used_in(hass, entity_id): + async_create_issue( + hass, + DOMAIN, + f"deprecated_entity_{description.key}", + breaks_in_ha_version="2025.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_entity", + translation_placeholders={ + "name": str( + entity_entry.name or entity_entry.original_name + ), + "entity": entity_id, + }, + ) + + for description in SENSOR_DESCRIPTIONS: + if description.key is HabiticaSensorEntity.HEALTH_MAX: + add_deprecated_entity(description, HabiticaSensor) + else: + entities.append(HabiticaSensor(coordinator, description)) + + for description in TASK_SENSOR_DESCRIPTION: + add_deprecated_entity(description, HabiticaTaskSensor) - entities: list[SensorEntity] = [ - HabiticaSensor(coordinator, description) for description in SENSOR_DESCRIPTIONS - ] - entities.extend( - HabiticaTaskSensor(coordinator, description) - for description in TASK_SENSOR_DESCRIPTION - ) async_add_entities(entities, True) diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 3e29b9110be..4e1a0ac9f64 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -421,6 +421,10 @@ } }, "issues": { + "deprecated_entity": { + "title": "The Habitica {name} entity is deprecated", + "description": "The Habitica entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue." + }, "deprecated_api_call": { "title": "The Habitica action habitica.api_call is deprecated", "description": "The Habitica action `habitica.api_call` is deprecated and will be removed in Home Assistant 2025.5.0.\n\nPlease update your automations and scripts to use other Habitica actions and entities." diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index d265d56021a..757c675b045 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -23,9 +23,6 @@ from dateutil.rrule import ( ) from habiticalib import ContentData, Frequency, TaskData, UserData -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.script import scripts_with_entity -from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -59,13 +56,6 @@ def next_due_date(task: TaskData, today: datetime.datetime) -> datetime.date | N return dt_util.as_local(task.nextDue[0]).date() -def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: - """Get list of related automations and scripts.""" - used_in = automations_with_entity(hass, entity_id) - used_in += scripts_with_entity(hass, entity_id) - return used_in - - FREQUENCY_MAP = {"daily": DAILY, "weekly": WEEKLY, "monthly": MONTHLY, "yearly": YEARLY} WEEKDAY_MAP = {"m": MO, "t": TU, "w": WE, "th": TH, "f": FR, "s": SA, "su": SU} diff --git a/tests/components/habitica/test_sensor.py b/tests/components/habitica/test_sensor.py index 9dde266d214..1c648e38720 100644 --- a/tests/components/habitica/test_sensor.py +++ b/tests/components/habitica/test_sensor.py @@ -6,10 +6,13 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.habitica.const import DOMAIN +from homeassistant.components.habitica.sensor import HabiticaSensorEntity +from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from tests.common import MockConfigEntry, snapshot_platform @@ -33,6 +36,19 @@ async def test_sensors( ) -> None: """Test setup of the Habitica sensor platform.""" + for entity in ( + ("test_user_habits", "habits"), + ("test_user_rewards", "rewards"), + ("test_user_max_health", "health_max"), + ): + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"a380546a-94be-4b8e-8a0b-23e0d5c03303_{entity[1]}", + suggested_object_id=entity[0], + disabled_by=None, + ) + config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -40,3 +56,96 @@ async def test_sensors( assert config_entry.state is ConfigEntryState.LOADED await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "key"), + [ + ("test_user_habits", HabiticaSensorEntity.HABITS), + ("test_user_rewards", HabiticaSensorEntity.REWARDS), + ("test_user_max_health", HabiticaSensorEntity.HEALTH_MAX), + ], +) +@pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default") +async def test_sensor_deprecation_issue( + hass: HomeAssistant, + config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + entity_id: str, + key: HabiticaSensorEntity, +) -> None: + """Test sensor deprecation issue.""" + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"a380546a-94be-4b8e-8a0b-23e0d5c03303_{key}", + suggested_object_id=entity_id, + disabled_by=None, + ) + + assert entity_registry is not None + with patch( + "homeassistant.components.habitica.sensor.entity_used_in", return_value=True + ): + 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 entity_registry.async_get(f"sensor.{entity_id}") is not None + assert issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_entity_{key}", + ) + + +@pytest.mark.parametrize( + ("entity_id", "key"), + [ + ("test_user_habits", HabiticaSensorEntity.HABITS), + ("test_user_rewards", HabiticaSensorEntity.REWARDS), + ("test_user_max_health", HabiticaSensorEntity.HEALTH_MAX), + ], +) +@pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default") +async def test_sensor_deprecation_delete_disabled( + hass: HomeAssistant, + config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + entity_id: str, + key: HabiticaSensorEntity, +) -> None: + """Test sensor deletion .""" + + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"a380546a-94be-4b8e-8a0b-23e0d5c03303_{key}", + suggested_object_id=entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + assert entity_registry is not None + with patch( + "homeassistant.components.habitica.sensor.entity_used_in", return_value=True + ): + 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 ( + issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_entity_{key}", + ) + is None + ) + + assert entity_registry.async_get(f"sensor.{entity_id}") is None From b84a4dc12084cadbdc94b9dddd14a9a276713782 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jan 2025 18:52:37 +0100 Subject: [PATCH 0375/2987] Add zeroconf discovery to onewire (#135295) --- .../components/onewire/config_flow.py | 14 ++++ .../components/onewire/manifest.json | 3 +- .../components/onewire/quality_scale.yaml | 4 +- homeassistant/generated/zeroconf.py | 5 ++ tests/components/onewire/test_config_flow.py | 65 ++++++++++++++++++- 5 files changed, 86 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 9ab8dc32f68..c5d4bb065e0 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -8,6 +8,7 @@ from typing import Any from pyownet import protocol import voluptuous as vol +from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback @@ -115,6 +116,19 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): } return await self.async_step_discovery_confirm() + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + await self._async_handle_discovery_without_unique_id() + + self._discovery_data = { + "title": discovery_info.name, + CONF_HOST: discovery_info.hostname, + CONF_PORT: discovery_info.port, + } + return await self.async_step_discovery_confirm() + async def async_step_discovery_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/onewire/manifest.json b/homeassistant/components/onewire/manifest.json index 4f3cb5d04ab..844c4c1afb9 100644 --- a/homeassistant/components/onewire/manifest.json +++ b/homeassistant/components/onewire/manifest.json @@ -7,5 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyownet"], - "requirements": ["pyownet==0.10.0.post1"] + "requirements": ["pyownet==0.10.0.post1"], + "zeroconf": ["_owserver._tcp.local."] } diff --git a/homeassistant/components/onewire/quality_scale.yaml b/homeassistant/components/onewire/quality_scale.yaml index b64bfb775ce..d46ed69f0d6 100644 --- a/homeassistant/components/onewire/quality_scale.yaml +++ b/homeassistant/components/onewire/quality_scale.yaml @@ -67,9 +67,7 @@ rules: entity-disabled-by-default: done discovery: status: done - comment: | - hassio discovery implemented, mDNS/zeroconf should also be possible - https://owfs.org/index_php_page_avahi-discovery.html (see PR 135295) + comment: hassio and mDNS/zeroconf discovery implemented stale-devices: status: done comment: > diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 0766e1ce011..203f01e7d68 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -729,6 +729,11 @@ ZEROCONF = { "domain": "octoprint", }, ], + "_owserver._tcp.local.": [ + { + "domain": "onewire", + }, + ], "_plexmediasvr._tcp.local.": [ { "domain": "plex", diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index 09a7cdfcbb0..db551c0a1c3 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for 1-Wire config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, patch from pyownet import protocol @@ -11,7 +12,8 @@ from homeassistant.components.onewire.const import ( INPUT_ENTRY_DEVICE_SELECTION, MANUFACTURER_MAXIM, ) -from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_USER +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -26,6 +28,15 @@ _HASSIO_DISCOVERY = HassioServiceInfo( slug="1302b8e0_owserver", uuid="e3fa56560d93458b96a594cbcea3017e", ) +_ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address("5.6.7.8"), + ip_addresses=[ip_address("5.6.7.8")], + hostname="ubuntu.local.", + name="OWFS (1-wire) Server", + port=4304, + type="_owserver._tcp.local.", + properties={}, +) pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -250,6 +261,58 @@ async def test_hassio_duplicate(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" +async def test_zeroconf_flow(hass: HomeAssistant) -> None: + """Test zeroconf discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=_ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert not result["errors"] + + # Cannot connect to server => retry + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + side_effect=protocol.ConnError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + # Connect OK + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + new_entry = result["result"] + assert new_entry.title == "OWFS (1-wire) Server" + assert new_entry.data == {CONF_HOST: "ubuntu.local.", CONF_PORT: 4304} + + +@pytest.mark.usefixtures("config_entry") +async def test_zeroconf_duplicate(hass: HomeAssistant) -> None: + """Test zeroconf discovery duplicate flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=_ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures("filled_device_registry") async def test_user_options_clear( hass: HomeAssistant, config_entry: MockConfigEntry From b93aa760c54dd3c376b275f8926f881095ce8752 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 13 Jan 2025 19:00:18 +0100 Subject: [PATCH 0376/2987] Refactor the MQTT option and reconfigure flow (#133342) * Move entry options to entry.options en remove broker setup from mqtt option flow * UPdate diagnostics to export both entry data and options * Parameterize entry options directly not depending on migration * Update tests to use v2 entry and add separate migration test * use start_reconfigure_flow helper * Update quality scale comment * Do minor entry upgrade, and do not force to upgrade entry * Ensure options are read from older entries * Add comment * Follow up on code review * Assert config entry version checking the broker connection * Update comment --- homeassistant/components/mqtt/__init__.py | 37 +- homeassistant/components/mqtt/config_flow.py | 80 +--- homeassistant/components/mqtt/const.py | 16 +- homeassistant/components/mqtt/diagnostics.py | 12 +- .../components/mqtt/quality_scale.yaml | 5 +- tests/components/mqtt/conftest.py | 5 +- tests/components/mqtt/test_client.py | 155 ++++-- tests/components/mqtt/test_common.py | 7 +- tests/components/mqtt/test_config_flow.py | 447 ++++++++++-------- tests/components/mqtt/test_diagnostics.py | 39 +- tests/components/mqtt/test_discovery.py | 46 +- tests/components/mqtt/test_init.py | 14 +- tests/components/mqtt/test_mixins.py | 7 +- tests/components/mqtt/test_util.py | 10 + tests/conftest.py | 24 +- 15 files changed, 533 insertions(+), 371 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 624f99d350a..94c417bfd6d 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -69,6 +69,8 @@ from .const import ( # noqa: F401 CONF_WILL_MESSAGE, CONF_WS_HEADERS, CONF_WS_PATH, + CONFIG_ENTRY_MINOR_VERSION, + CONFIG_ENTRY_VERSION, DEFAULT_DISCOVERY, DEFAULT_ENCODING, DEFAULT_PREFIX, @@ -76,6 +78,7 @@ from .const import ( # noqa: F401 DEFAULT_RETAIN, DOMAIN, ENTITY_PLATFORMS, + ENTRY_OPTION_FIELDS, MQTT_CONNECTION_STATE, TEMPLATE_ERRORS, ) @@ -282,15 +285,45 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate the options from config entry data.""" + _LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + data: dict[str, Any] = dict(entry.data) + options: dict[str, Any] = dict(entry.options) + if entry.version > 1: + # This means the user has downgraded from a future version + return False + + if entry.version == 1 and entry.minor_version < 2: + # Can be removed when config entry is bumped to version 2.1 + # with HA Core 2026.1.0. Read support for version 2.1 is expected before 2026.1 + # From 2026.1 we will write version 2.1 + for key in ENTRY_OPTION_FIELDS: + if key not in data: + continue + options[key] = data.pop(key) + hass.config_entries.async_update_entry( + entry, + data=data, + options=options, + version=CONFIG_ENTRY_VERSION, + minor_version=CONFIG_ENTRY_MINOR_VERSION, + ) + + _LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" - conf: dict[str, Any] mqtt_data: MqttData async def _setup_client() -> tuple[MqttData, dict[str, Any]]: """Set up the MQTT client.""" # Fetch configuration - conf = dict(entry.data) + conf = dict(entry.data | entry.options) hass_config = await conf_util.async_hass_config_yaml(hass) mqtt_yaml = CONFIG_SCHEMA(hass_config).get(DOMAIN, []) await async_create_certificate_temp_files(hass, conf) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 0081246c705..f07777742ee 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -76,6 +76,8 @@ from .const import ( CONF_WILL_MESSAGE, CONF_WS_HEADERS, CONF_WS_PATH, + CONFIG_ENTRY_MINOR_VERSION, + CONFIG_ENTRY_VERSION, DEFAULT_BIRTH, DEFAULT_DISCOVERY, DEFAULT_ENCODING, @@ -205,7 +207,9 @@ def update_password_from_user_input( class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" - VERSION = 1 + # Can be bumped to version 2.1 with HA Core 2026.1.0 + VERSION = CONFIG_ENTRY_VERSION # 1 + MINOR_VERSION = CONFIG_ENTRY_MINOR_VERSION # 2 _hassio_discovery: dict[str, Any] | None = None _addon_manager: AddonManager @@ -496,7 +500,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): reconfigure_entry, data=validated_user_input, ) - validated_user_input[CONF_DISCOVERY] = DEFAULT_DISCOVERY return self.async_create_entry( title=validated_user_input[CONF_BROKER], data=validated_user_input, @@ -564,58 +567,17 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): class MQTTOptionsFlowHandler(OptionsFlow): """Handle MQTT options.""" - def __init__(self) -> None: - """Initialize MQTT options flow.""" - self.broker_config: dict[str, Any] = {} - async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: """Manage the MQTT options.""" - return await self.async_step_broker() - - async def async_step_broker( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage the MQTT broker configuration.""" - errors: dict[str, str] = {} - fields: OrderedDict[Any, Any] = OrderedDict() - validated_user_input: dict[str, Any] = {} - if await async_get_broker_settings( - self, - fields, - self.config_entry.data, - user_input, - validated_user_input, - errors, - ): - self.broker_config.update( - update_password_from_user_input( - self.config_entry.data.get(CONF_PASSWORD), validated_user_input - ), - ) - can_connect = await self.hass.async_add_executor_job( - try_connection, - self.broker_config, - ) - - if can_connect: - return await self.async_step_options() - - errors["base"] = "cannot_connect" - - return self.async_show_form( - step_id="broker", - data_schema=vol.Schema(fields), - errors=errors, - last_step=False, - ) + return await self.async_step_options() async def async_step_options( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the MQTT options.""" errors = {} - current_config = self.config_entry.data - options_config: dict[str, Any] = {} + + options_config: dict[str, Any] = dict(self.config_entry.options) bad_input: bool = False def _birth_will(birt_or_will: str) -> dict[str, Any]: @@ -674,26 +636,18 @@ class MQTTOptionsFlowHandler(OptionsFlow): options_config[CONF_WILL_MESSAGE] = {} if not bad_input: - updated_config = {} - updated_config.update(self.broker_config) - updated_config.update(options_config) - self.hass.config_entries.async_update_entry( - self.config_entry, - data=updated_config, - title=str(self.broker_config[CONF_BROKER]), - ) - return self.async_create_entry(title="", data={}) + return self.async_create_entry(data=options_config) birth = { **DEFAULT_BIRTH, - **current_config.get(CONF_BIRTH_MESSAGE, {}), + **options_config.get(CONF_BIRTH_MESSAGE, {}), } will = { **DEFAULT_WILL, - **current_config.get(CONF_WILL_MESSAGE, {}), + **options_config.get(CONF_WILL_MESSAGE, {}), } - discovery = current_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY) - discovery_prefix = current_config.get(CONF_DISCOVERY_PREFIX, DEFAULT_PREFIX) + discovery = options_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY) + discovery_prefix = options_config.get(CONF_DISCOVERY_PREFIX, DEFAULT_PREFIX) # build form fields: OrderedDict[vol.Marker, Any] = OrderedDict() @@ -706,8 +660,8 @@ class MQTTOptionsFlowHandler(OptionsFlow): fields[ vol.Optional( "birth_enable", - default=CONF_BIRTH_MESSAGE not in current_config - or current_config[CONF_BIRTH_MESSAGE] != {}, + default=CONF_BIRTH_MESSAGE not in options_config + or options_config[CONF_BIRTH_MESSAGE] != {}, ) ] = BOOLEAN_SELECTOR fields[ @@ -729,8 +683,8 @@ class MQTTOptionsFlowHandler(OptionsFlow): fields[ vol.Optional( "will_enable", - default=CONF_WILL_MESSAGE not in current_config - or current_config[CONF_WILL_MESSAGE] != {}, + default=CONF_WILL_MESSAGE not in options_config + or options_config[CONF_WILL_MESSAGE] != {}, ) ] = BOOLEAN_SELECTOR fields[ diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index db27495154b..007b3b7e576 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -4,7 +4,7 @@ import logging import jinja2 -from homeassistant.const import CONF_PAYLOAD, Platform +from homeassistant.const import CONF_DISCOVERY, CONF_PAYLOAD, Platform from homeassistant.exceptions import TemplateError ATTR_DISCOVERY_HASH = "discovery_hash" @@ -163,6 +163,20 @@ MQTT_CONNECTION_STATE = "mqtt_connection_state" PAYLOAD_EMPTY_JSON = "{}" PAYLOAD_NONE = "None" +CONFIG_ENTRY_VERSION = 1 +CONFIG_ENTRY_MINOR_VERSION = 2 + +# Split mqtt entry data and options +# Can be removed when config entry is bumped to version 2.1 +# with HA Core 2026.1.0. Read support for version 2.1 is expected before 2026.1 +# From 2026.1 we will write version 2.1 +ENTRY_OPTION_FIELDS = ( + CONF_DISCOVERY, + CONF_DISCOVERY_PREFIX, + "birth_message", + "will_message", +) + ENTITY_PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, diff --git a/homeassistant/components/mqtt/diagnostics.py b/homeassistant/components/mqtt/diagnostics.py index 8104c37574b..7a17c1f3409 100644 --- a/homeassistant/components/mqtt/diagnostics.py +++ b/homeassistant/components/mqtt/diagnostics.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any from homeassistant.components import device_tracker from homeassistant.components.diagnostics import async_redact_data @@ -18,7 +18,6 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from . import debug_info, is_connected -from .models import DATA_MQTT REDACT_CONFIG = {CONF_PASSWORD, CONF_USERNAME} REDACT_STATE_DEVICE_TRACKER = {ATTR_LATITUDE, ATTR_LONGITUDE} @@ -45,11 +44,10 @@ def _async_get_diagnostics( device: DeviceEntry | None = None, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - mqtt_instance = hass.data[DATA_MQTT].client - if TYPE_CHECKING: - assert mqtt_instance is not None - - redacted_config = async_redact_data(mqtt_instance.conf, REDACT_CONFIG) + redacted_config = { + "data": async_redact_data(dict(entry.data), REDACT_CONFIG), + "options": dict(entry.options), + } data = { "connected": is_connected(hass), diff --git a/homeassistant/components/mqtt/quality_scale.yaml b/homeassistant/components/mqtt/quality_scale.yaml index 26ce8cb08dd..c178147bf71 100644 --- a/homeassistant/components/mqtt/quality_scale.yaml +++ b/homeassistant/components/mqtt/quality_scale.yaml @@ -89,10 +89,7 @@ rules: comment: > This is not possible because the integrations generates entities based on a user supplied config or discovery. - reconfiguration-flow: - status: done - comment: > - This integration can also be reconfigured via options flow. + reconfiguration-flow: done dynamic-devices: status: done comment: | diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index 22f0416a2c6..2a1e4012f51 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -18,7 +18,6 @@ from tests.common import MockConfigEntry from tests.typing import MqttMockPahoClient ENTRY_DEFAULT_BIRTH_MESSAGE = { - mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_BIRTH_MESSAGE: { mqtt.ATTR_TOPIC: "homeassistant/status", mqtt.ATTR_PAYLOAD: "online", @@ -77,6 +76,7 @@ def mock_debouncer(hass: HomeAssistant) -> Generator[asyncio.Event]: async def setup_with_birth_msg_client_mock( hass: HomeAssistant, mqtt_config_entry_data: dict[str, Any] | None, + mqtt_config_entry_options: dict[str, Any] | None, mqtt_client_mock: MqttMockPahoClient, ) -> AsyncGenerator[MqttMockPahoClient]: """Test sending birth message.""" @@ -89,6 +89,9 @@ async def setup_with_birth_msg_client_mock( entry = MockConfigEntry( domain=mqtt.DOMAIN, data=mqtt_config_entry_data or {mqtt.CONF_BROKER: "test-broker"}, + options=mqtt_config_entry_options or {}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index 1daad0e3914..ad64b39a480 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -105,6 +105,8 @@ async def test_mqtt_await_ack_at_disconnect(hass: HomeAssistant) -> None: mqtt.CONF_BROKER: "test-broker", mqtt.CONF_DISCOVERY: False, }, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) @@ -132,7 +134,7 @@ async def test_mqtt_await_ack_at_disconnect(hass: HomeAssistant) -> None: await hass.async_block_till_done(wait_background_tasks=True) -@pytest.mark.parametrize("mqtt_config_entry_data", [ENTRY_DEFAULT_BIRTH_MESSAGE]) +@pytest.mark.parametrize("mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE]) async def test_publish( hass: HomeAssistant, setup_with_birth_msg_client_mock: MqttMockPahoClient ) -> None: @@ -1022,8 +1024,8 @@ async def test_unsubscribe_race( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})], ) async def test_restore_subscriptions_on_reconnect( hass: HomeAssistant, @@ -1059,8 +1061,8 @@ async def test_restore_subscriptions_on_reconnect( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})], ) async def test_restore_all_active_subscriptions_on_reconnect( hass: HomeAssistant, @@ -1100,8 +1102,8 @@ async def test_restore_all_active_subscriptions_on_reconnect( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})], ) async def test_subscribed_at_highest_qos( hass: HomeAssistant, @@ -1136,7 +1138,12 @@ async def test_initial_setup_logs_error( mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test for setup failure if initial client connection fails.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) mqtt_client_mock.connect.side_effect = MagicMock(return_value=1) try: @@ -1239,7 +1246,12 @@ async def test_publish_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test publish error.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) # simulate an Out of memory error @@ -1381,7 +1393,10 @@ async def test_handle_mqtt_timeout_on_callback( ) entry = MockConfigEntry( - domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"} + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -1414,7 +1429,12 @@ async def test_setup_raises_config_entry_not_ready_if_no_connect_broker( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, exception: Exception ) -> None: """Test for setup failure if connection to broker is missing.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) with patch( @@ -1495,17 +1515,19 @@ async def test_tls_version( @pytest.mark.parametrize( - "mqtt_config_entry_data", + ("mqtt_config_entry_data", "mqtt_config_entry_options"), [ - { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_BIRTH_MESSAGE: { - mqtt.ATTR_TOPIC: "birth", - mqtt.ATTR_PAYLOAD: "birth", - mqtt.ATTR_QOS: 0, - mqtt.ATTR_RETAIN: False, + ( + {mqtt.CONF_BROKER: "mock-broker"}, + { + mqtt.CONF_BIRTH_MESSAGE: { + mqtt.ATTR_TOPIC: "birth", + mqtt.ATTR_PAYLOAD: "birth", + mqtt.ATTR_QOS: 0, + mqtt.ATTR_RETAIN: False, + } }, - } + ) ], ) @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @@ -1515,11 +1537,18 @@ async def test_custom_birth_message( hass: HomeAssistant, mock_debouncer: asyncio.Event, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test sending birth message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) assert await hass.config_entries.async_setup(entry.entry_id) @@ -1533,7 +1562,7 @@ async def test_custom_birth_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE], ) async def test_default_birth_message( @@ -1548,8 +1577,8 @@ async def test_default_birth_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_BIRTH_MESSAGE: {}}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_BIRTH_MESSAGE: {}})], ) @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) @@ -1559,10 +1588,17 @@ async def test_no_birth_message( record_calls: MessageCallbackType, mock_debouncer: asyncio.Event, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test disabling birth message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) mock_debouncer.clear() @@ -1582,20 +1618,27 @@ async def test_no_birth_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [ENTRY_DEFAULT_BIRTH_MESSAGE], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, ENTRY_DEFAULT_BIRTH_MESSAGE)], ) @patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.2) async def test_delayed_birth_message( hass: HomeAssistant, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test sending birth message does not happen until Home Assistant starts.""" hass.set_state(CoreState.starting) await hass.async_block_till_done() birth = asyncio.Event() - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) assert await hass.config_entries.async_setup(entry.entry_id) @@ -1619,7 +1662,7 @@ async def test_delayed_birth_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE], ) async def test_subscription_done_when_birth_message_is_sent( @@ -1637,26 +1680,37 @@ async def test_subscription_done_when_birth_message_is_sent( @pytest.mark.parametrize( - "mqtt_config_entry_data", + ("mqtt_config_entry_data", "mqtt_config_entry_options"), [ - { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_WILL_MESSAGE: { - mqtt.ATTR_TOPIC: "death", - mqtt.ATTR_PAYLOAD: "death", - mqtt.ATTR_QOS: 0, - mqtt.ATTR_RETAIN: False, + ( + { + mqtt.CONF_BROKER: "mock-broker", }, - } + { + mqtt.CONF_WILL_MESSAGE: { + mqtt.ATTR_TOPIC: "death", + mqtt.ATTR_PAYLOAD: "death", + mqtt.ATTR_QOS: 0, + mqtt.ATTR_RETAIN: False, + }, + }, + ) ], ) async def test_custom_will_message( hass: HomeAssistant, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test will message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) assert await hass.config_entries.async_setup(entry.entry_id) @@ -1678,16 +1732,23 @@ async def test_default_will_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_WILL_MESSAGE: {}}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_WILL_MESSAGE: {}})], ) async def test_no_will_message( hass: HomeAssistant, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test will message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) assert await hass.config_entries.async_setup(entry.entry_id) @@ -1697,7 +1758,7 @@ async def test_no_will_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE | {mqtt.CONF_DISCOVERY: False}], ) async def test_mqtt_subscribes_topics_on_connect( @@ -1730,7 +1791,7 @@ async def test_mqtt_subscribes_topics_on_connect( assert ("still/pending", 1) in subscribe_calls -@pytest.mark.parametrize("mqtt_config_entry_data", [ENTRY_DEFAULT_BIRTH_MESSAGE]) +@pytest.mark.parametrize("mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE]) async def test_mqtt_subscribes_wildcard_topics_in_correct_order( hass: HomeAssistant, mock_debouncer: asyncio.Event, @@ -1789,7 +1850,7 @@ async def test_mqtt_subscribes_wildcard_topics_in_correct_order( @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE | {mqtt.CONF_DISCOVERY: False}], ) async def test_mqtt_discovery_not_subscribes_when_disabled( @@ -1822,7 +1883,7 @@ async def test_mqtt_discovery_not_subscribes_when_disabled( @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE], ) async def test_mqtt_subscribes_in_single_call( @@ -1848,7 +1909,7 @@ async def test_mqtt_subscribes_in_single_call( ] -@pytest.mark.parametrize("mqtt_config_entry_data", [ENTRY_DEFAULT_BIRTH_MESSAGE]) +@pytest.mark.parametrize("mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE]) @patch("homeassistant.components.mqtt.client.MAX_SUBSCRIBES_PER_CALL", 2) @patch("homeassistant.components.mqtt.client.MAX_UNSUBSCRIBES_PER_CALL", 2) async def test_mqtt_subscribes_and_unsubscribes_in_chunks( diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index fbf393dc105..a34907adbaf 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -1887,7 +1887,12 @@ async def help_test_reloadable( mqtt.DOMAIN: {domain: [old_config_1, old_config_2]}, } # Start the MQTT entry with the old config - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) mqtt_client_mock.connect.return_value = 0 with patch("homeassistant.config.load_yaml_config_file", return_value=old_config): diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 38dbda50cdd..072998f9b8d 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -43,6 +43,28 @@ ADD_ON_DISCOVERY_INFO = { MOCK_CLIENT_CERT = b"## mock client certificate file ##" MOCK_CLIENT_KEY = b"## mock key file ##" +MOCK_ENTRY_DATA = { + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 1234, + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", +} +MOCK_ENTRY_OPTIONS = { + mqtt.CONF_DISCOVERY: True, + mqtt.CONF_BIRTH_MESSAGE: { + mqtt.ATTR_TOPIC: "ha_state/online", + mqtt.ATTR_PAYLOAD: "online", + mqtt.ATTR_QOS: 1, + mqtt.ATTR_RETAIN: True, + }, + mqtt.CONF_WILL_MESSAGE: { + mqtt.ATTR_TOPIC: "ha_state/offline", + mqtt.ATTR_PAYLOAD: "offline", + mqtt.ATTR_QOS: 2, + mqtt.ATTR_RETAIN: False, + }, +} + @pytest.fixture(autouse=True) def mock_finish_setup() -> Generator[MagicMock]: @@ -243,8 +265,10 @@ async def test_user_connection_works( assert result["result"].data == { "broker": "127.0.0.1", "port": 1883, - "discovery": True, } + # Check we have the latest Config Entry version + assert result["result"].version == 1 + assert result["result"].minor_version == 2 # Check we tried the connection assert len(mock_try_connection.mock_calls) == 1 # Check config entry got setup @@ -283,7 +307,6 @@ async def test_user_connection_works_with_supervisor( assert result["result"].data == { "broker": "127.0.0.1", "port": 1883, - "discovery": True, } # Check we tried the connection assert len(mock_try_connection.mock_calls) == 1 @@ -324,7 +347,6 @@ async def test_user_v5_connection_works( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].data == { "broker": "another-broker", - "discovery": True, "port": 2345, "protocol": "5", } @@ -383,14 +405,12 @@ async def test_manual_config_set( assert result["result"].data == { "broker": "127.0.0.1", "port": 1883, - "discovery": True, } # Check we tried the connection, with precedence for config entry settings mock_try_connection.assert_called_once_with( { "broker": "127.0.0.1", "port": 1883, - "discovery": True, }, ) # Check config entry got setup @@ -401,7 +421,11 @@ async def test_manual_config_set( async def test_user_single_instance(hass: HomeAssistant) -> None: """Test we only allow a single config flow.""" - MockConfigEntry(domain="mqtt").add_to_hass(hass) + MockConfigEntry( + domain="mqtt", + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( "mqtt", context={"source": config_entries.SOURCE_USER} @@ -412,7 +436,11 @@ async def test_user_single_instance(hass: HomeAssistant) -> None: async def test_hassio_already_configured(hass: HomeAssistant) -> None: """Test we only allow a single config flow.""" - MockConfigEntry(domain="mqtt").add_to_hass(hass) + MockConfigEntry( + domain="mqtt", + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( "mqtt", context={"source": config_entries.SOURCE_HASSIO} @@ -424,7 +452,10 @@ async def test_hassio_already_configured(hass: HomeAssistant) -> None: async def test_hassio_ignored(hass: HomeAssistant) -> None: """Test we supervisor discovered instance can be ignored.""" MockConfigEntry( - domain=mqtt.DOMAIN, source=config_entries.SOURCE_IGNORE + domain=mqtt.DOMAIN, + source=config_entries.SOURCE_IGNORE, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -934,43 +965,19 @@ async def test_addon_not_installed_failures( async def test_option_flow( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - mock_try_connection: MagicMock, ) -> None: """Test config flow options.""" with patch( "homeassistant.config.async_hass_config_yaml", AsyncMock(return_value={}) ) as yaml_mock: - mqtt_mock = await mqtt_mock_entry() - mock_try_connection.return_value = True + await mqtt_mock_entry() config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - hass.config_entries.async_update_entry( - config_entry, - data={ - mqtt.CONF_BROKER: "test-broker", - CONF_PORT: 1234, - }, - ) - - mqtt_mock.async_connect.reset_mock() result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - }, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" await hass.async_block_till_done() - assert mqtt_mock.async_connect.call_count == 0 yaml_mock.reset_mock() @@ -992,12 +999,10 @@ async def test_option_flow( }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {} - assert config_entry.data == { - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", + await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.data == {mqtt.CONF_BROKER: "mock-broker"} + assert config_entry.options == { mqtt.CONF_DISCOVERY: True, mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", mqtt.CONF_BIRTH_MESSAGE: { @@ -1015,8 +1020,7 @@ async def test_option_flow( } await hass.async_block_till_done() - assert config_entry.title == "another-broker" - # assert that the entry was reloaded with the new config + # assert that the entry was reloaded with the new config assert yaml_mock.await_count @@ -1071,7 +1075,7 @@ async def test_bad_certificate( test_input.pop(mqtt.CONF_CLIENT_KEY) mqtt_mock = await mqtt_mock_entry() - config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] # Add at least one advanced option to get the full form hass.config_entries.async_update_entry( config_entry, @@ -1088,11 +1092,11 @@ async def test_bad_certificate( mqtt_mock.async_connect.reset_mock() - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "another-broker", @@ -1109,14 +1113,14 @@ async def test_bad_certificate( test_input["set_ca_cert"] = set_ca_cert test_input["tls_insecure"] = tls_insecure - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=test_input, ) if test_error is not None: assert result["errors"]["base"] == test_error return - assert result["errors"] == {} + assert "errors" not in result @pytest.mark.parametrize( @@ -1148,7 +1152,7 @@ async def test_keepalive_validation( mqtt_mock = await mqtt_mock_entry() mock_try_connection.return_value = True - config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] # Add at least one advanced option to get the full form hass.config_entries.async_update_entry( config_entry, @@ -1161,22 +1165,23 @@ async def test_keepalive_validation( mqtt_mock.async_connect.reset_mock() - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" if error: with pytest.raises(vol.Invalid): - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=test_input, ) return - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=test_input, ) - assert not result["errors"] + assert "errors" not in result + assert result["reason"] == "reconfigure_successful" async def test_disable_birth_will( @@ -1186,7 +1191,7 @@ async def test_disable_birth_will( mock_reload_after_entry_update: MagicMock, ) -> None: """Test disabling birth and will.""" - mqtt_mock = await mqtt_mock_entry() + await mqtt_mock_entry() mock_try_connection.return_value = True config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] hass.config_entries.async_update_entry( @@ -1199,26 +1204,10 @@ async def test_disable_birth_will( await hass.async_block_till_done() mock_reload_after_entry_update.reset_mock() - mqtt_mock.async_connect.reset_mock() - result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - }, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" - await hass.async_block_till_done() - assert mqtt_mock.async_connect.call_count == 0 result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -1238,12 +1227,14 @@ async def test_disable_birth_will( }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {} - assert config_entry.data == { - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", + assert result["data"] == { + "birth_message": {}, + "discovery": True, + "discovery_prefix": "homeassistant", + "will_message": {}, + } + assert config_entry.data == {mqtt.CONF_BROKER: "test-broker", CONF_PORT: 1234} + assert config_entry.options == { mqtt.CONF_DISCOVERY: True, mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", mqtt.CONF_BIRTH_MESSAGE: {}, @@ -1270,6 +1261,8 @@ async def test_invalid_discovery_prefix( data={ mqtt.CONF_BROKER: "test-broker", CONF_PORT: 1234, + }, + options={ mqtt.CONF_DISCOVERY: True, mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", }, @@ -1280,16 +1273,6 @@ async def test_invalid_discovery_prefix( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - }, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" await hass.async_block_till_done() @@ -1308,6 +1291,8 @@ async def test_invalid_discovery_prefix( assert config_entry.data == { mqtt.CONF_BROKER: "test-broker", CONF_PORT: 1234, + } + assert config_entry.options == { mqtt.CONF_DISCOVERY: True, mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", } @@ -1356,6 +1341,8 @@ async def test_option_flow_default_suggested_values( CONF_PORT: 1234, CONF_USERNAME: "user", CONF_PASSWORD: "pass", + }, + options={ mqtt.CONF_DISCOVERY: True, mqtt.CONF_BIRTH_MESSAGE: { mqtt.ATTR_TOPIC: "ha_state/online", @@ -1371,37 +1358,13 @@ async def test_option_flow_default_suggested_values( }, }, ) + await hass.async_block_till_done() # Test default/suggested values from config result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - defaults = { - mqtt.CONF_BROKER: "test-broker", - CONF_PORT: 1234, - } - suggested = { - CONF_USERNAME: "user", - CONF_PASSWORD: PWD_NOT_CHANGED, - } - for key, value in defaults.items(): - assert get_default(result["data_schema"].schema, key) == value - for key, value in suggested.items(): - assert get_suggested(result["data_schema"].schema, key) == value - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "us3r", - CONF_PASSWORD: "p4ss", - }, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" defaults = { - mqtt.CONF_DISCOVERY: True, "birth_qos": 1, "birth_retain": True, "will_qos": 2, @@ -1421,7 +1384,6 @@ async def test_option_flow_default_suggested_values( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - mqtt.CONF_DISCOVERY: False, "birth_topic": "ha_state/onl1ne", "birth_payload": "onl1ne", "birth_qos": 2, @@ -1437,28 +1399,8 @@ async def test_option_flow_default_suggested_values( # Test updated default/suggested values from config result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - defaults = { - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - } - suggested = { - CONF_USERNAME: "us3r", - CONF_PASSWORD: PWD_NOT_CHANGED, - } - for key, value in defaults.items(): - assert get_default(result["data_schema"].schema, key) == value - for key, value in suggested.items(): - assert get_suggested(result["data_schema"].schema, key) == value - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345}, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" defaults = { - mqtt.CONF_DISCOVERY: False, "birth_qos": 2, "birth_retain": False, "will_qos": 1, @@ -1478,7 +1420,6 @@ async def test_option_flow_default_suggested_values( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - mqtt.CONF_DISCOVERY: True, "birth_topic": "ha_state/onl1ne", "birth_payload": "onl1ne", "birth_qos": 2, @@ -1496,7 +1437,8 @@ async def test_option_flow_default_suggested_values( @pytest.mark.parametrize( - ("advanced_options", "step_id"), [(False, "options"), (True, "broker")] + ("advanced_options", "flow_result"), + [(False, FlowResultType.ABORT), (True, FlowResultType.FORM)], ) @pytest.mark.usefixtures("mock_reload_after_entry_update") async def test_skipping_advanced_options( @@ -1504,41 +1446,35 @@ async def test_skipping_advanced_options( mqtt_mock_entry: MqttMockHAClientGenerator, mock_try_connection: MagicMock, advanced_options: bool, - step_id: str, + flow_result: FlowResultType, ) -> None: """Test advanced options option.""" test_input = { mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345, - "advanced_options": advanced_options, } + if advanced_options: + test_input["advanced_options"] = True mqtt_mock = await mqtt_mock_entry() mock_try_connection.return_value = True - config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - # Initiate with a basic setup - hass.config_entries.async_update_entry( - config_entry, - data={ - mqtt.CONF_BROKER: "test-broker", - CONF_PORT: 1234, - }, - ) - + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] mqtt_mock.async_connect.reset_mock() - result = await hass.config_entries.options.async_init( - config_entry.entry_id, context={"show_advanced_options": True} + result = await config_entry.start_reconfigure_flow( + hass, show_advanced_options=advanced_options ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" - result = await hass.config_entries.options.async_configure( + assert ("advanced_options" in result["data_schema"].schema) == advanced_options + + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=test_input, ) - assert result["step_id"] == step_id + assert result["type"] is flow_result @pytest.mark.parametrize( @@ -1582,7 +1518,12 @@ async def test_step_reauth( """Test that the reauth step works.""" # Prepare the config entry - config_entry = MockConfigEntry(domain=mqtt.DOMAIN, data=test_input) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=test_input, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -1658,7 +1599,12 @@ async def test_step_hassio_reauth( addon_info["hostname"] = "core-mosquitto" # Prepare the config entry - config_entry = MockConfigEntry(domain=mqtt.DOMAIN, data=entry_data) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=entry_data, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -1740,7 +1686,12 @@ async def test_step_hassio_reauth_no_discovery_info( addon_info["hostname"] = "core-mosquitto" # Prepare the config entry - config_entry = MockConfigEntry(domain=mqtt.DOMAIN, data=entry_data) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=entry_data, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -1762,11 +1713,15 @@ async def test_step_hassio_reauth_no_discovery_info( mock_try_connection.assert_not_called() -async def test_options_user_connection_fails( +async def test_reconfigure_user_connection_fails( hass: HomeAssistant, mock_try_connection_time_out: MagicMock ) -> None: """Test if connection cannot be made.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -1775,11 +1730,11 @@ async def test_options_user_connection_fails( CONF_PORT: 1234, }, ) - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM mock_try_connection_time_out.reset_mock() - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={mqtt.CONF_BROKER: "bad-broker", CONF_PORT: 2345}, ) @@ -1800,7 +1755,11 @@ async def test_options_bad_birth_message_fails( hass: HomeAssistant, mock_try_connection: MqttMockPahoClient ) -> None: """Test bad birth message.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -1813,13 +1772,6 @@ async def test_options_bad_birth_message_fails( mock_try_connection.return_value = True result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345}, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" @@ -1841,7 +1793,11 @@ async def test_options_bad_will_message_fails( hass: HomeAssistant, mock_try_connection: MagicMock ) -> None: """Test bad will message.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -1854,13 +1810,6 @@ async def test_options_bad_will_message_fails( mock_try_connection.return_value = True result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345}, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" @@ -1878,15 +1827,16 @@ async def test_options_bad_will_message_fails( } -@pytest.mark.parametrize( - "hass_config", [{"mqtt": {"sensor": [{"state_topic": "some-topic"}]}}] -) @pytest.mark.usefixtures("mock_ssl_context", "mock_process_uploaded_file") async def test_try_connection_with_advanced_parameters( hass: HomeAssistant, mock_try_connection_success: MqttMockPahoClient ) -> None: """Test config flow with advanced parameters from config.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -1920,7 +1870,7 @@ async def test_try_connection_with_advanced_parameters( ) # Test default/suggested values from config - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" defaults = { @@ -1944,9 +1894,8 @@ async def test_try_connection_with_advanced_parameters( assert get_suggested(result["data_schema"].schema, k) == v # test we can change username and password - # as it was configured as auto in configuration.yaml is is migrated now mock_try_connection_success.reset_mock() - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "another-broker", @@ -1961,9 +1910,8 @@ async def test_try_connection_with_advanced_parameters( mqtt.CONF_WS_HEADERS: '{"h3": "v3"}', }, ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - assert result["step_id"] == "options" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" await hass.async_block_till_done() # check if the username and password was set from config flow and not from configuration.yaml @@ -1987,12 +1935,6 @@ async def test_try_connection_with_advanced_parameters( "/new/path", {"h3": "v3"}, ) - # Accept default option - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={}, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() @@ -2005,7 +1947,11 @@ async def test_setup_with_advanced_settings( """Test config flow setup with advanced parameters.""" file_id = mock_process_uploaded_file.file_id - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -2017,15 +1963,13 @@ async def test_setup_with_advanced_settings( mock_try_connection.return_value = True - result = await hass.config_entries.options.async_init( - config_entry.entry_id, context={"show_advanced_options": True} - ) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" assert result["data_schema"].schema["advanced_options"] # first iteration, basic settings - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "test-broker", @@ -2049,7 +1993,7 @@ async def test_setup_with_advanced_settings( assert mqtt.CONF_CLIENT_KEY not in result["data_schema"].schema # second iteration, advanced settings with request for client cert - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "test-broker", @@ -2080,7 +2024,7 @@ async def test_setup_with_advanced_settings( assert result["data_schema"].schema[mqtt.CONF_WS_HEADERS] # third iteration, advanced settings with client cert and key set and bad json payload - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "test-broker", @@ -2105,7 +2049,7 @@ async def test_setup_with_advanced_settings( # fourth iteration, advanced settings with client cert and key set # and correct json payload for ws_headers - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "test-broker", @@ -2124,17 +2068,8 @@ async def test_setup_with_advanced_settings( }, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "options" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_DISCOVERY: True, - mqtt.CONF_DISCOVERY_PREFIX: "homeassistant_test", - }, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" # Check config entry result assert config_entry.data == { @@ -2153,8 +2088,6 @@ async def test_setup_with_advanced_settings( "header_2": "content_header_2", }, mqtt.CONF_CERTIFICATE: "auto", - mqtt.CONF_DISCOVERY: True, - mqtt.CONF_DISCOVERY_PREFIX: "homeassistant_test", } @@ -2163,7 +2096,11 @@ async def test_change_websockets_transport_to_tcp( hass: HomeAssistant, mock_try_connection: MagicMock ) -> None: """Test reconfiguration flow changing websockets transport settings.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -2254,3 +2191,95 @@ async def test_reconfigure_flow_form( mqtt.CONF_WS_PATH: "/some_new_path", } await hass.async_block_till_done(wait_background_tasks=True) + + +@pytest.mark.parametrize( + ( + "version", + "minor_version", + "data", + "options", + "expected_version", + "expected_minor_version", + ), + [ + (1, 1, MOCK_ENTRY_DATA | MOCK_ENTRY_OPTIONS, {}, 1, 2), + (1, 2, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 1, 2), + (1, 3, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 1, 3), + ], +) +@pytest.mark.usefixtures("mock_reload_after_entry_update") +async def test_migrate_config_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + version: int, + minor_version: int, + data: dict[str, Any], + options: dict[str, Any], + expected_version: int, + expected_minor_version: int, +) -> None: + """Test migrating a config entry.""" + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + # Mock to a migratable or compatbible config entry version + hass.config_entries.async_update_entry( + config_entry, + data=data, + options=options, + version=version, + minor_version=minor_version, + ) + await hass.async_block_till_done() + # Start MQTT + await mqtt_mock_entry() + await hass.async_block_till_done() + assert ( + config_entry.data | config_entry.options == MOCK_ENTRY_DATA | MOCK_ENTRY_OPTIONS + ) + assert config_entry.version == expected_version + assert config_entry.minor_version == expected_minor_version + + +@pytest.mark.parametrize( + ( + "version", + "minor_version", + "data", + "options", + "expected_version", + "expected_minor_version", + ), + [ + (2, 1, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 2, 1), + ], +) +@pytest.mark.usefixtures("mock_reload_after_entry_update") +async def test_migrate_of_incompatible_config_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + version: int, + minor_version: int, + data: dict[str, Any], + options: dict[str, Any], + expected_version: int, + expected_minor_version: int, +) -> None: + """Test migrating a config entry.""" + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + # Mock an incompatible config entry version + hass.config_entries.async_update_entry( + config_entry, + data=data, + options=options, + version=version, + minor_version=minor_version, + ) + await hass.async_block_till_done() + assert config_entry.version == expected_version + assert config_entry.minor_version == expected_minor_version + + # Try to start MQTT with incompatible config entry + with pytest.raises(AssertionError): + await mqtt_mock_entry() + + assert config_entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index b8499ba5812..bbd60329d0a 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -17,10 +17,12 @@ from tests.components.diagnostics import ( ) from tests.typing import ClientSessionGenerator, MqttMockHAClientGenerator -default_config = { - "birth_message": {}, +default_entry_data = { "broker": "mock-broker", } +default_entry_options = { + "birth_message": {}, +} async def test_entry_diagnostics( @@ -38,7 +40,7 @@ async def test_entry_diagnostics( assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "connected": True, "devices": [], - "mqtt_config": default_config, + "mqtt_config": {"data": default_entry_data, "options": default_entry_options}, "mqtt_debug_info": {"entities": [], "triggers": []}, } @@ -123,7 +125,7 @@ async def test_entry_diagnostics( assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "connected": True, "devices": [expected_device], - "mqtt_config": default_config, + "mqtt_config": {"data": default_entry_data, "options": default_entry_options}, "mqtt_debug_info": expected_debug_info, } @@ -132,20 +134,24 @@ async def test_entry_diagnostics( ) == { "connected": True, "device": expected_device, - "mqtt_config": default_config, + "mqtt_config": {"data": default_entry_data, "options": default_entry_options}, "mqtt_debug_info": expected_debug_info, } @pytest.mark.parametrize( - "mqtt_config_entry_data", + ("mqtt_config_entry_data", "mqtt_config_entry_options"), [ - { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_BIRTH_MESSAGE: {}, - CONF_PASSWORD: "hunter2", - CONF_USERNAME: "my_user", - } + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PASSWORD: "hunter2", + CONF_USERNAME: "my_user", + }, + { + mqtt.CONF_BIRTH_MESSAGE: {}, + }, + ) ], ) async def test_redact_diagnostics( @@ -157,9 +163,12 @@ async def test_redact_diagnostics( ) -> None: """Test redacting diagnostics.""" mqtt_mock = await mqtt_mock_entry() - expected_config = dict(default_config) - expected_config["password"] = "**REDACTED**" - expected_config["username"] = "**REDACTED**" + expected_config = { + "data": dict(default_entry_data), + "options": dict(default_entry_options), + } + expected_config["data"]["password"] = "**REDACTED**" + expected_config["data"]["username"] = "**REDACTED**" config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] mqtt_mock.connected = True diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 2980b3b6fcc..982167feee1 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -195,8 +195,8 @@ async def mock_mqtt_flow( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})], ) async def test_subscribing_config_topic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator @@ -1946,7 +1946,12 @@ async def test_cleanup_device_multiple_config_entries( mqtt_mock = await mqtt_mock_entry() ws_client = await hass_ws_client(hass) - config_entry = MockConfigEntry(domain="test", data={}) + config_entry = MockConfigEntry( + domain="test", + data={}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -2042,7 +2047,12 @@ async def test_cleanup_device_multiple_config_entries_mqtt( ) -> None: """Test discovered device is cleaned up when removed through MQTT.""" mqtt_mock = await mqtt_mock_entry() - config_entry = MockConfigEntry(domain="test", data={}) + config_entry = MockConfigEntry( + domain="test", + data={}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -2437,12 +2447,14 @@ async def test_no_implicit_state_topic_switch( @pytest.mark.parametrize( - "mqtt_config_entry_data", + ("mqtt_config_entry_data", "mqtt_config_entry_options"), [ - { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_DISCOVERY_PREFIX: "my_home/homeassistant/register", - } + ( + {mqtt.CONF_BROKER: "mock-broker"}, + { + mqtt.CONF_DISCOVERY_PREFIX: "my_home/homeassistant/register", + }, + ) ], ) async def test_complex_discovery_topic_prefix( @@ -2497,7 +2509,13 @@ async def test_mqtt_integration_discovery_flow_fitering_on_redundant_payload( """Handle birth message.""" birth.set() - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=ENTRY_DEFAULT_BIRTH_MESSAGE) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "mock-broker"}, + options=ENTRY_DEFAULT_BIRTH_MESSAGE, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) with ( patch( @@ -2562,7 +2580,13 @@ async def test_mqtt_discovery_flow_starts_once( """Handle birth message.""" birth.set() - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=ENTRY_DEFAULT_BIRTH_MESSAGE) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "mock-broker"}, + options=ENTRY_DEFAULT_BIRTH_MESSAGE, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) with ( diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index ad9d65894d0..4e0873c6e1b 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -695,7 +695,12 @@ async def test_reload_entry_with_restored_subscriptions( ) -> None: """Test reloading the config entry with with subscriptions restored.""" # Setup the MQTT entry - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) with patch("homeassistant.config.load_yaml_config_file", return_value={}): @@ -800,7 +805,10 @@ async def test_default_entry_setting_are_applied( # Config entry data is incomplete but valid according the schema entry = MockConfigEntry( - domain=mqtt.DOMAIN, data={"broker": "test-broker", "port": 1234} + domain=mqtt.DOMAIN, + data={"broker": "test-broker", "port": 1234}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) @@ -1614,6 +1622,8 @@ async def test_unload_config_entry( entry = MockConfigEntry( domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 5b7984cad62..d65f1a4d661 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -313,7 +313,12 @@ async def test_default_entity_and_device_name( hass.set_state(CoreState.starting) await hass.async_block_till_done() - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "mock-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "mock-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) diff --git a/tests/components/mqtt/test_util.py b/tests/components/mqtt/test_util.py index 37bf6982b7a..dd72902056d 100644 --- a/tests/components/mqtt/test_util.py +++ b/tests/components/mqtt/test_util.py @@ -231,6 +231,8 @@ async def test_waiting_for_client_not_loaded( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -286,6 +288,8 @@ async def test_waiting_for_client_entry_fails( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -314,6 +318,8 @@ async def test_waiting_for_client_setup_fails( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -341,6 +347,8 @@ async def test_waiting_for_client_timeout( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -360,6 +368,8 @@ async def test_waiting_for_client_with_disabled_entry( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) diff --git a/tests/conftest.py b/tests/conftest.py index d38d1dbb6b7..a1e3bcd31c6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -924,7 +924,13 @@ def fail_on_log_exception( @pytest.fixture def mqtt_config_entry_data() -> dict[str, Any] | None: - """Fixture to allow overriding MQTT config.""" + """Fixture to allow overriding MQTT entry data.""" + return None + + +@pytest.fixture +def mqtt_config_entry_options() -> dict[str, Any] | None: + """Fixture to allow overriding MQTT entry options.""" return None @@ -1001,6 +1007,7 @@ async def mqtt_mock( mock_hass_config: None, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, + mqtt_config_entry_options: dict[str, Any] | None, mqtt_mock_entry: MqttMockHAClientGenerator, ) -> AsyncGenerator[MqttMockHAClient]: """Fixture to mock MQTT component.""" @@ -1012,6 +1019,7 @@ async def _mqtt_mock_entry( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, + mqtt_config_entry_options: dict[str, Any] | None, ) -> AsyncGenerator[MqttMockHAClientGenerator]: """Fixture to mock a delayed setup of the MQTT config entry.""" # Local import to avoid processing MQTT modules when running a testcase @@ -1019,17 +1027,19 @@ async def _mqtt_mock_entry( from homeassistant.components import mqtt # pylint: disable=import-outside-toplevel if mqtt_config_entry_data is None: - mqtt_config_entry_data = { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_BIRTH_MESSAGE: {}, - } + mqtt_config_entry_data = {mqtt.CONF_BROKER: "mock-broker"} + if mqtt_config_entry_options is None: + mqtt_config_entry_options = {mqtt.CONF_BIRTH_MESSAGE: {}} await hass.async_block_till_done() entry = MockConfigEntry( data=mqtt_config_entry_data, + options=mqtt_config_entry_options, domain=mqtt.DOMAIN, title="MQTT", + version=1, + minor_version=2, ) entry.add_to_hass(hass) @@ -1045,7 +1055,6 @@ async def _mqtt_mock_entry( # Assert that MQTT is setup assert real_mqtt_instance is not None, "MQTT was not setup correctly" - mock_mqtt_instance.conf = real_mqtt_instance.conf # For diagnostics mock_mqtt_instance._mqttc = mqtt_client_mock # connected set to True to get a more realistic behavior when subscribing @@ -1140,6 +1149,7 @@ async def mqtt_mock_entry( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, + mqtt_config_entry_options: dict[str, Any] | None, ) -> AsyncGenerator[MqttMockHAClientGenerator]: """Set up an MQTT config entry.""" @@ -1156,7 +1166,7 @@ async def mqtt_mock_entry( return await mqtt_mock_entry(_async_setup_config_entry) async with _mqtt_mock_entry( - hass, mqtt_client_mock, mqtt_config_entry_data + hass, mqtt_client_mock, mqtt_config_entry_data, mqtt_config_entry_options ) as mqtt_mock_entry: yield _setup_mqtt_entry From 6fd73730cc3149b19b84971b21a31e327d3b5560 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 13 Jan 2025 19:19:06 +0100 Subject: [PATCH 0377/2987] Bump aioimaplib to 2.0.0 (#135448) --- homeassistant/components/imap/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imap/manifest.json b/homeassistant/components/imap/manifest.json index b058a3d50f4..a3370de94ca 100644 --- a/homeassistant/components/imap/manifest.json +++ b/homeassistant/components/imap/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/imap", "iot_class": "cloud_push", "loggers": ["aioimaplib"], - "requirements": ["aioimaplib==1.1.0"] + "requirements": ["aioimaplib==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e3efc5aa69b..9c6fcf3eebd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -273,7 +273,7 @@ aiohttp_sse==2.2.0 aiohue==4.7.3 # homeassistant.components.imap -aioimaplib==1.1.0 +aioimaplib==2.0.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b3910945c0..e9850bee894 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -258,7 +258,7 @@ aiohttp_sse==2.2.0 aiohue==4.7.3 # homeassistant.components.imap -aioimaplib==1.1.0 +aioimaplib==2.0.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 From d986fe7a071dc73bc7d4d9494ced320c7ebb5c3a Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Mon, 13 Jan 2025 12:26:18 -0600 Subject: [PATCH 0378/2987] Add humidifier entity for Vesync devices (#134333) --- homeassistant/components/vesync/__init__.py | 8 +- homeassistant/components/vesync/common.py | 8 + homeassistant/components/vesync/const.py | 10 + homeassistant/components/vesync/humidifier.py | 161 ++++++++++++++ homeassistant/components/vesync/sensor.py | 28 +-- tests/components/vesync/common.py | 2 +- tests/components/vesync/conftest.py | 31 ++- .../vesync/fixtures/humidifier-200s.json | 26 +++ .../components/vesync/snapshots/test_fan.ambr | 60 ++++++ .../vesync/snapshots/test_light.ambr | 60 ++++++ .../vesync/snapshots/test_sensor.ambr | 158 ++++++++++++++ .../vesync/snapshots/test_switch.ambr | 60 ++++++ tests/components/vesync/test_humidifier.py | 201 ++++++++++++++++++ tests/components/vesync/test_init.py | 2 + 14 files changed, 799 insertions(+), 16 deletions(-) create mode 100644 homeassistant/components/vesync/humidifier.py create mode 100644 tests/components/vesync/fixtures/humidifier-200s.json create mode 100644 tests/components/vesync/test_humidifier.py diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index c48363b046d..db093d6802d 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -20,7 +20,13 @@ from .const import ( ) from .coordinator import VeSyncDataCoordinator -PLATFORMS = [Platform.FAN, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.FAN, + Platform.HUMIDIFIER, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index ce4235d20f8..c51b6a913d3 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -7,6 +7,8 @@ from pyvesync.vesyncbasedevice import VeSyncBaseDevice from homeassistant.core import HomeAssistant +from .const import VeSyncHumidifierDevice + _LOGGER = logging.getLogger(__name__) @@ -24,3 +26,9 @@ async def async_generate_device_list( devices.extend(manager.switches) return devices + + +def is_humidifier(device: VeSyncBaseDevice) -> bool: + """Check if the device represents a humidifier.""" + + return isinstance(device, VeSyncHumidifierDevice) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 6a27e7330ac..6d1de28825f 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -1,5 +1,7 @@ """Constants for VeSync Component.""" +from pyvesync.vesyncfan import VeSyncHumid200300S, VeSyncSuperior6000S + DOMAIN = "vesync" VS_DISCOVERY = "vesync_discovery_{}" SERVICE_UPDATE_DEVS = "update_devices" @@ -21,6 +23,14 @@ VS_DEVICES = "devices" VS_COORDINATOR = "coordinator" VS_MANAGER = "manager" +VS_HUMIDIFIER_MODE_AUTO = "auto" +VS_HUMIDIFIER_MODE_HUMIDITY = "humidity" +VS_HUMIDIFIER_MODE_MANUAL = "manual" +VS_HUMIDIFIER_MODE_SLEEP = "sleep" + +VeSyncHumidifierDevice = VeSyncHumid200300S | VeSyncSuperior6000S +"""Humidifier device types""" + DEV_TYPE_TO_HA = { "wifi-switch-1.3": "outlet", "ESW03-USA": "outlet", diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py new file mode 100644 index 00000000000..794dbb33e1c --- /dev/null +++ b/homeassistant/components/vesync/humidifier.py @@ -0,0 +1,161 @@ +"""Support for VeSync humidifiers.""" + +import logging +from typing import Any + +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + MODE_AUTO, + MODE_NORMAL, + MODE_SLEEP, + HumidifierEntity, + HumidifierEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .common import is_humidifier +from .const import ( + DOMAIN, + VS_COORDINATOR, + VS_DEVICES, + VS_DISCOVERY, + VS_HUMIDIFIER_MODE_AUTO, + VS_HUMIDIFIER_MODE_HUMIDITY, + VS_HUMIDIFIER_MODE_MANUAL, + VS_HUMIDIFIER_MODE_SLEEP, + VeSyncHumidifierDevice, +) +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +MIN_HUMIDITY = 30 +MAX_HUMIDITY = 80 + +VS_TO_HA_ATTRIBUTES = {ATTR_HUMIDITY: "current_humidity"} + +VS_TO_HA_MODE_MAP = { + VS_HUMIDIFIER_MODE_AUTO: MODE_AUTO, + VS_HUMIDIFIER_MODE_HUMIDITY: MODE_AUTO, + VS_HUMIDIFIER_MODE_MANUAL: MODE_NORMAL, + VS_HUMIDIFIER_MODE_SLEEP: MODE_SLEEP, +} + +HA_TO_VS_MODE_MAP = {v: k for k, v in VS_TO_HA_MODE_MAP.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the VeSync humidifier platform.""" + + 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: AddEntitiesCallback, + coordinator: VeSyncDataCoordinator, +): + """Add humidifier entities.""" + async_add_entities( + VeSyncHumidifierHA(dev, coordinator) for dev in devices if is_humidifier(dev) + ) + + +def _get_ha_mode(vs_mode: str) -> str | None: + ha_mode = VS_TO_HA_MODE_MAP.get(vs_mode) + if ha_mode is None: + _LOGGER.warning("Unknown mode '%s'", vs_mode) + return ha_mode + + +def _get_vs_mode(ha_mode: str) -> str | None: + return HA_TO_VS_MODE_MAP.get(ha_mode) + + +class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): + """Representation of a VeSync humidifier.""" + + # The base VeSyncBaseEntity has _attr_has_entity_name and this is to follow the device name + _attr_name = None + + _attr_max_humidity = MAX_HUMIDITY + _attr_min_humidity = MIN_HUMIDITY + _attr_supported_features = HumidifierEntityFeature.MODES + + device: VeSyncHumidifierDevice + + @property + def available_modes(self) -> list[str]: + """Return the available mist modes.""" + return [ + ha_mode + for ha_mode in (_get_ha_mode(vs_mode) for vs_mode in self.device.mist_modes) + if ha_mode + ] + + @property + def target_humidity(self) -> int: + """Return the humidity we try to reach.""" + return self.device.config["auto_target_humidity"] + + @property + def mode(self) -> str | None: + """Get the current preset mode.""" + return _get_ha_mode(self.device.details["mode"]) + + def set_humidity(self, humidity: int) -> None: + """Set the target humidity of the device.""" + if not self.device.set_humidity(humidity): + raise HomeAssistantError( + f"An error occurred while setting humidity {humidity}." + ) + + def set_mode(self, mode: str) -> None: + """Set the mode of the device.""" + if mode not in self.available_modes: + raise HomeAssistantError( + "{mode} is not one of the valid available modes: {self.available_modes}" + ) + if not self.device.set_humidity_mode(_get_vs_mode(mode)): + raise HomeAssistantError(f"An error occurred while setting mode {mode}.") + + def turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + success = self.device.turn_on() + if not success: + raise HomeAssistantError("An error occurred while turning on.") + + def turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + success = self.device.turn_off() + if not success: + raise HomeAssistantError("An error occurred while turning off.") + + @property + def is_on(self) -> bool: + """Return True if device is on.""" + return self.device.device_status == "on" diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index 59c45d435d4..bf52050d745 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -7,9 +7,6 @@ from dataclasses import dataclass import logging from pyvesync.vesyncbasedevice import VeSyncBaseDevice -from pyvesync.vesyncfan import VeSyncAirBypass -from pyvesync.vesyncoutlet import VeSyncOutlet -from pyvesync.vesyncswitch import VeSyncSwitch from homeassistant.components.sensor import ( SensorDeviceClass, @@ -31,6 +28,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from .common import is_humidifier from .const import ( DEV_TYPE_TO_HA, DOMAIN, @@ -49,14 +47,10 @@ _LOGGER = logging.getLogger(__name__) class VeSyncSensorEntityDescription(SensorEntityDescription): """Describe VeSync sensor entity.""" - value_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], StateType] + value_fn: Callable[[VeSyncBaseDevice], StateType] - exists_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], bool] = ( - lambda _: True - ) - update_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], None] = ( - lambda _: None - ) + exists_fn: Callable[[VeSyncBaseDevice], bool] = lambda _: True + update_fn: Callable[[VeSyncBaseDevice], None] = lambda _: None def update_energy(device): @@ -186,6 +180,14 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( update_fn=update_energy, exists_fn=lambda device: ha_dev_type(device) == "outlet", ), + VeSyncSensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.details["humidity"], + exists_fn=is_humidifier, + ), ) @@ -213,7 +215,7 @@ async def async_setup_entry( @callback def _setup_entities( devices: list[VeSyncBaseDevice], - async_add_entities, + async_add_entities: AddEntitiesCallback, coordinator: VeSyncDataCoordinator, ): """Check if device is online and add entity.""" @@ -236,9 +238,9 @@ class VeSyncSensorEntity(VeSyncBaseEntity, SensorEntity): def __init__( self, - device: VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch, + device: VeSyncBaseDevice, description: VeSyncSensorEntityDescription, - coordinator, + coordinator: VeSyncDataCoordinator, ) -> None: """Initialize the VeSync outlet device.""" super().__init__(device, coordinator) diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 954affb4c1a..2c2ec9a5d1d 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -16,7 +16,7 @@ ALL_DEVICE_NAMES: list[str] = [ ] DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { "Humidifier 200s": [ - ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") + ("post", "/cloud/v2/deviceManaged/bypassV2", "humidifier-200s.json") ], "Humidifier 600S": [ ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 5500ef1a55f..9bc0888e8f5 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -7,9 +7,10 @@ from unittest.mock import Mock, patch import pytest from pyvesync import VeSync from pyvesync.vesyncbulb import VeSyncBulb -from pyvesync.vesyncfan import VeSyncAirBypass +from pyvesync.vesyncfan import VeSyncAirBypass, VeSyncHumid200300S from pyvesync.vesyncoutlet import VeSyncOutlet from pyvesync.vesyncswitch import VeSyncSwitch +import requests_mock from homeassistant.components.vesync import DOMAIN from homeassistant.config_entries import ConfigEntry @@ -17,6 +18,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType +from .common import mock_multiple_device_responses + from tests.common import MockConfigEntry @@ -100,3 +103,29 @@ def dimmable_switch_fixture(): def outlet_fixture(): """Create a mock VeSync outlet fixture.""" return Mock(VeSyncOutlet) + + +@pytest.fixture(name="humidifier") +def humidifier_fixture(): + """Create a mock VeSync humidifier fixture.""" + return Mock(VeSyncHumid200300S) + + +@pytest.fixture(name="humidifier_config_entry") +async def humidifier_config_entry( + hass: HomeAssistant, requests_mock: requests_mock.Mocker, config +) -> MockConfigEntry: + """Create a mock VeSync config entry for Humidifier 200s.""" + entry = MockConfigEntry( + title="VeSync", + domain=DOMAIN, + data=config[DOMAIN], + ) + entry.add_to_hass(hass) + + device_name = "Humidifier 200s" + mock_multiple_device_responses(requests_mock, [device_name]) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/vesync/fixtures/humidifier-200s.json b/tests/components/vesync/fixtures/humidifier-200s.json new file mode 100644 index 00000000000..668072db0ea --- /dev/null +++ b/tests/components/vesync/fixtures/humidifier-200s.json @@ -0,0 +1,26 @@ +{ + "code": 0, + "result": { + "result": { + "humidity": 35, + "mist_virtual_level": 6, + "mode": "manual", + "water_lacks": true, + "water_tank_lifted": true, + "automatic_stop_reach_target": true, + "night_light_brightness": 10, + "enabled": true, + "level": 1, + "display": true, + "display_forever": false, + "child_lock": false, + "night_light": "off", + "configuration": { + "auto_target_humidity": 40, + "display": true, + "automatic_stop": true + } + }, + "code": 0 + } +} diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 60af4ae3d5b..1dea5f28f2c 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -464,6 +464,36 @@ # --- # name: test_fan_state[Humidifier 200s][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '200s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Classic200S', + 'model_id': None, + 'name': 'Humidifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_fan_state[Humidifier 200s][entities] @@ -472,6 +502,36 @@ # --- # name: test_fan_state[Humidifier 600S][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LUH-A602S-WUS', + 'model_id': None, + 'name': 'Humidifier 600S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_fan_state[Humidifier 600S][entities] diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index 2e7fe9ac1bb..ba6c7ab51b9 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -335,6 +335,36 @@ # --- # name: test_light_state[Humidifier 200s][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '200s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Classic200S', + 'model_id': None, + 'name': 'Humidifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_light_state[Humidifier 200s][entities] @@ -343,6 +373,36 @@ # --- # name: test_light_state[Humidifier 600S][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LUH-A602S-WUS', + 'model_id': None, + 'name': 'Humidifier 600S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_light_state[Humidifier 600S][entities] diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index 11d931e023a..50bee417a28 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -651,20 +651,178 @@ # --- # name: test_sensor_state[Humidifier 200s][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '200s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Classic200S', + 'model_id': None, + 'name': 'Humidifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_sensor_state[Humidifier 200s][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.humidifier_200s_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': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '200s-humidifier-humidity', + 'unit_of_measurement': '%', + }), ]) # --- +# name: test_sensor_state[Humidifier 200s][sensor.humidifier_200s_humidity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Humidifier 200s Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.humidifier_200s_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- # name: test_sensor_state[Humidifier 600S][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LUH-A602S-WUS', + 'model_id': None, + 'name': 'Humidifier 600S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_sensor_state[Humidifier 600S][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.humidifier_600s_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': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '600s-humidifier-humidity', + 'unit_of_measurement': '%', + }), ]) # --- +# name: test_sensor_state[Humidifier 600S][sensor.humidifier_600s_humidity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Humidifier 600S Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.humidifier_600s_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- # name: test_sensor_state[Outlet][devices] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index 4b271ee55d9..596aa0c94ad 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -229,6 +229,36 @@ # --- # name: test_switch_state[Humidifier 200s][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '200s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Classic200S', + 'model_id': None, + 'name': 'Humidifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_switch_state[Humidifier 200s][entities] @@ -237,6 +267,36 @@ # --- # name: test_switch_state[Humidifier 600S][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LUH-A602S-WUS', + 'model_id': None, + 'name': 'Humidifier 600S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_switch_state[Humidifier 600S][entities] diff --git a/tests/components/vesync/test_humidifier.py b/tests/components/vesync/test_humidifier.py new file mode 100644 index 00000000000..9a807cc903e --- /dev/null +++ b/tests/components/vesync/test_humidifier.py @@ -0,0 +1,201 @@ +"""Tests for the humidifer module.""" + +from contextlib import nullcontext +from unittest.mock import patch + +import pytest + +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + ATTR_MODE, + DOMAIN as HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from tests.common import MockConfigEntry + +NoException = nullcontext() + + +async def test_humidifier_state( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test the resulting setup state is as expected for the platform.""" + + humidifier_id = "humidifier.humidifier_200s" + expected_entities = [ + humidifier_id, + "sensor.humidifier_200s_humidity", + ] + + assert humidifier_config_entry.state is ConfigEntryState.LOADED + + for entity_id in expected_entities: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + assert hass.states.get("sensor.humidifier_200s_humidity").state == "35" + + state = hass.states.get(humidifier_id) + + # ATTR_HUMIDITY represents the target_humidity which comes from configuration.auto_target_humidity node + assert state.attributes.get(ATTR_HUMIDITY) == 40 + + +async def test_set_target_humidity_invalid( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, +) -> None: + """Test handling of invalid value in set_humidify method.""" + + humidifier_entity_id = "humidifier.humidifier_200s" + + # Setting value out of range results in ServiceValidationError and + # VeSyncHumid200300S.set_humidity does not get called. + with ( + patch("pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity") as method_mock, + pytest.raises(ServiceValidationError), + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: humidifier_entity_id, ATTR_HUMIDITY: 20}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_not_called() + + +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(True, NoException), (False, pytest.raises(HomeAssistantError))], +) +async def test_set_target_humidity_VeSync( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + api_response: bool, + expectation, +) -> None: + """Test handling of return value from VeSyncHumid200300S.set_humidity.""" + + humidifier_entity_id = "humidifier.humidifier_200s" + + # If VeSyncHumid200300S.set_humidity fails (returns False), then HomeAssistantError is raised + with ( + expectation, + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity", + return_value=api_response, + ) as method_mock, + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: humidifier_entity_id, ATTR_HUMIDITY: 54}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("turn_on", "api_response", "expectation"), + [ + (False, False, pytest.raises(HomeAssistantError)), + (False, True, NoException), + (True, False, pytest.raises(HomeAssistantError)), + (True, True, NoException), + ], +) +async def test_turn_on_off( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + turn_on: bool, + api_response: bool, + expectation, +) -> None: + """Test turn_on/off methods.""" + + humidifier_entity_id = "humidifier.humidifier_200s" + + # turn_on/turn_off returns False indicating failure in which case humidifier.turn_on/turn_off + # raises HomeAssistantError. + with ( + expectation, + patch( + f"pyvesync.vesyncfan.VeSyncHumid200300S.{"turn_on" if turn_on else "turn_off"}", + return_value=api_response, + ) as method_mock, + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON if turn_on else SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: humidifier_entity_id}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + + +async def test_set_mode_invalid( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, +) -> None: + """Test handling of invalid value in set_mode method.""" + + humidifier_entity_id = "humidifier.humidifier_200s" + + with patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity_mode" + ) as method_mock: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: humidifier_entity_id, ATTR_MODE: "something_invalid"}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_not_called() + + +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(True, NoException), (False, pytest.raises(HomeAssistantError))], +) +async def test_set_mode_VeSync( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + api_response: bool, + expectation, +) -> None: + """Test handling of value in set_mode method.""" + + humidifier_entity_id = "humidifier.humidifier_200s" + + # If VeSyncHumid200300S.set_humidity_mode fails (returns False), then HomeAssistantError is raised + with ( + expectation, + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity_mode", + return_value=api_response, + ) as method_mock, + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: humidifier_entity_id, ATTR_MODE: "auto"}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_called_once() diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index dc0541b3c21..7e2603b7401 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -49,6 +49,7 @@ async def test_async_setup_entry__no_devices( assert setups_mock.call_args.args[0] == config_entry assert setups_mock.call_args.args[1] == [ Platform.FAN, + Platform.HUMIDIFIER, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, @@ -77,6 +78,7 @@ async def test_async_setup_entry__loads_fans( assert setups_mock.call_args.args[0] == config_entry assert setups_mock.call_args.args[1] == [ Platform.FAN, + Platform.HUMIDIFIER, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, From ab28115d2b8998412e2cca53aac0baac0c53ddf7 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 13 Jan 2025 18:32:22 +0000 Subject: [PATCH 0379/2987] Cleanup tplink test framework (#135205) --- tests/components/tplink/__init__.py | 164 +----- tests/components/tplink/conftest.py | 12 +- tests/components/tplink/const.py | 146 ++++++ tests/components/tplink/test_binary_sensor.py | 15 +- tests/components/tplink/test_button.py | 26 +- tests/components/tplink/test_camera.py | 38 +- tests/components/tplink/test_climate.py | 7 +- tests/components/tplink/test_config_flow.py | 101 ++-- tests/components/tplink/test_fan.py | 31 +- tests/components/tplink/test_init.py | 28 +- tests/components/tplink/test_light.py | 475 +++++++++++++----- tests/components/tplink/test_number.py | 17 +- tests/components/tplink/test_select.py | 19 +- tests/components/tplink/test_sensor.py | 30 +- tests/components/tplink/test_switch.py | 48 +- 15 files changed, 693 insertions(+), 464 deletions(-) create mode 100644 tests/components/tplink/const.py diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index e322cf9f5de..4107610c121 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -6,174 +6,36 @@ from datetime import datetime from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from kasa import ( - BaseProtocol, - Device, - DeviceConfig, - DeviceConnectionParameters, - DeviceEncryptionType, - DeviceFamily, - DeviceType, - Feature, - KasaException, - Module, -) +from kasa import BaseProtocol, Device, DeviceType, Feature, KasaException, Module from kasa.interfaces import Fan, Light, LightEffect, LightState from kasa.smart.modules.alarm import Alarm from kasa.smartcam.modules.camera import LOCAL_STREAMING_PORT, Camera from syrupy import SnapshotAssertion from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN -from homeassistant.components.tplink import ( - CONF_AES_KEYS, - CONF_ALIAS, - CONF_CAMERA_CREDENTIALS, - CONF_CONNECTION_PARAMETERS, - CONF_CREDENTIALS_HASH, - CONF_HOST, - CONF_LIVE_VIEW, - CONF_MODEL, - CONF_USES_HTTP, - Credentials, -) from homeassistant.components.tplink.const import DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import UNDEFINED from homeassistant.setup import async_setup_component +from .const import ( + ALIAS, + CREDENTIALS_HASH_LEGACY, + DEVICE_CONFIG_LEGACY, + DEVICE_ID, + IP_ADDRESS, + MAC_ADDRESS, + MODEL, +) + from tests.common import MockConfigEntry, load_json_value_fixture ColorTempRange = namedtuple("ColorTempRange", ["min", "max"]) # noqa: PYI024 -MODULE = "homeassistant.components.tplink" -MODULE_CONFIG_FLOW = "homeassistant.components.tplink.config_flow" -IP_ADDRESS = "127.0.0.1" -IP_ADDRESS2 = "127.0.0.2" -IP_ADDRESS3 = "127.0.0.3" -ALIAS = "My Bulb" -ALIAS_CAMERA = "My Camera" -MODEL = "HS100" -MODEL_CAMERA = "C210" -MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" -DEVICE_ID = "123456789ABCDEFGH" -DEVICE_ID_MAC = "AA:BB:CC:DD:EE:FF" -DHCP_FORMATTED_MAC_ADDRESS = MAC_ADDRESS.replace(":", "") -MAC_ADDRESS2 = "11:22:33:44:55:66" -MAC_ADDRESS3 = "66:55:44:33:22:11" -DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}" -DEFAULT_ENTRY_TITLE_CAMERA = f"{ALIAS_CAMERA} {MODEL_CAMERA}" -CREDENTIALS_HASH_LEGACY = "" -CONN_PARAMS_LEGACY = DeviceConnectionParameters( - DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor -) -DEVICE_CONFIG_LEGACY = DeviceConfig(IP_ADDRESS) -DEVICE_CONFIG_DICT_LEGACY = { - k: v for k, v in DEVICE_CONFIG_LEGACY.to_dict().items() if k != "credentials" -} -CREDENTIALS = Credentials("foo", "bar") -CREDENTIALS_HASH_AES = "AES/abcdefghijklmnopqrstuvabcdefghijklmnopqrstuv==" -CREDENTIALS_HASH_KLAP = "KLAP/abcdefghijklmnopqrstuv==" -CONN_PARAMS_KLAP = DeviceConnectionParameters( - DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Klap -) -DEVICE_CONFIG_KLAP = DeviceConfig( - IP_ADDRESS, - credentials=CREDENTIALS, - connection_type=CONN_PARAMS_KLAP, - uses_http=True, -) -CONN_PARAMS_AES = DeviceConnectionParameters( - DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Aes -) -_test_privkey = ( - "MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKLJKmBWGj6WYo9sewI8vkqar" - "Ed5H1JUr8Jj/LEWLTtV6+Mm4mfyEk6YKFHSmIG4AGgrVsGK/EbEkTZk9CwtixNQpBVc36oN2R" - "vuWWV38YnP4vI63mNxTA/gQonCsahjN4HfwE87pM7O5z39aeunoYm6Be663t33DbJH1ZUbZjm" - "tAgMBAAECgYB1Bn1KaFvRprcQOIJt51E9vNghQbf8rhj0fIEKpdC6mVhNIoUdCO+URNqnh+hP" - "SQIx4QYreUlHbsSeABFxOQSDJm6/kqyQsp59nCVDo/bXTtlvcSJ/sU3riqJNxYqEU1iJ0xMvU" - "N1VKKTmik89J8e5sN9R0AFfUSJIk7MpdOoD2QJBANTbV27nenyvbqee/ul4frdt2rrPGcGpcV" - "QmY87qbbrZgqgL5LMHHD7T/v/I8D1wRog1sBz/AiZGcnv/ox8dHKsCQQDDx8DCGPySSVqKVua" - "yUkBNpglN83wiCXZjyEtWIt+aB1A2n5ektE/o8oHnnOuvMdooxvtid7Mdapi2VLHV7VMHAkAE" - "d0GjWwnv2cJpk+VnQpbuBEkFiFjS/loZWODZM4Pv2qZqHi3DL9AA5XPBLBcWQufH7dBvG06RP" - "QMj5N4oRfUXAkEAuJJkVliqHNvM4OkGewzyFII4+WVYHNqg43dcFuuvtA27AJQ6qYtYXrvp3k" - "phI3yzOIhHTNCea1goepSkR5ODFwJBAJCTRbB+P47aEr/xA51ZFHE6VefDBJG9yg6yK4jcOxg" - "5ficXEpx8442okNtlzwa+QHpm/L3JOFrHwiEeVqXtiqY=" -) -_test_pubkey = ( - "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCiySpgVho+lmKPbHsCPL5KmqxHeR9SVK/CY" - "/yxFi07VevjJuJn8hJOmChR0piBuABoK1bBivxGxJE2ZPQsLYsTUKQVXN+qDdkb7llld/GJz+" - "LyOt5jcUwP4EKJwrGoYzeB38BPO6TOzuc9/Wnrp6GJugXuut7d9w2yR9WVG2Y5rQIDAQAB" -) -AES_KEYS = {"private": _test_privkey, "public": _test_pubkey} -DEVICE_CONFIG_AES = DeviceConfig( - IP_ADDRESS2, - credentials=CREDENTIALS, - connection_type=CONN_PARAMS_AES, - uses_http=True, - aes_keys=AES_KEYS, -) -CONN_PARAMS_AES_CAMERA = DeviceConnectionParameters( - DeviceFamily.SmartIpCamera, DeviceEncryptionType.Aes, https=True, login_version=2 -) -DEVICE_CONFIG_AES_CAMERA = DeviceConfig( - IP_ADDRESS3, - credentials=CREDENTIALS, - connection_type=CONN_PARAMS_AES_CAMERA, - uses_http=True, -) - -DEVICE_CONFIG_DICT_KLAP = { - k: v for k, v in DEVICE_CONFIG_KLAP.to_dict().items() if k != "credentials" -} -DEVICE_CONFIG_DICT_AES = { - k: v for k, v in DEVICE_CONFIG_AES.to_dict().items() if k != "credentials" -} -CREATE_ENTRY_DATA_LEGACY = { - CONF_HOST: IP_ADDRESS, - CONF_ALIAS: ALIAS, - CONF_MODEL: MODEL, - CONF_CONNECTION_PARAMETERS: CONN_PARAMS_LEGACY.to_dict(), - CONF_USES_HTTP: False, -} - -CREATE_ENTRY_DATA_KLAP = { - CONF_HOST: IP_ADDRESS, - CONF_ALIAS: ALIAS, - CONF_MODEL: MODEL, - CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_KLAP, - CONF_CONNECTION_PARAMETERS: CONN_PARAMS_KLAP.to_dict(), - CONF_USES_HTTP: True, -} -CREATE_ENTRY_DATA_AES = { - CONF_HOST: IP_ADDRESS2, - CONF_ALIAS: ALIAS, - CONF_MODEL: MODEL, - CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AES, - CONF_CONNECTION_PARAMETERS: CONN_PARAMS_AES.to_dict(), - CONF_USES_HTTP: True, - CONF_AES_KEYS: AES_KEYS, -} -CREATE_ENTRY_DATA_AES_CAMERA = { - CONF_HOST: IP_ADDRESS3, - CONF_ALIAS: ALIAS_CAMERA, - CONF_MODEL: MODEL_CAMERA, - CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AES, - CONF_CONNECTION_PARAMETERS: CONN_PARAMS_AES_CAMERA.to_dict(), - CONF_USES_HTTP: True, - CONF_LIVE_VIEW: True, - CONF_CAMERA_CREDENTIALS: {"username": "camuser", "password": "campass"}, -} -SMALLEST_VALID_JPEG = ( - "ffd8ffe000104a46494600010101004800480000ffdb00430003020202020203020202030303030406040404040408060" - "6050609080a0a090809090a0c0f0c0a0b0e0b09090d110d0e0f101011100a0c12131210130f101010ffc9000b08000100" - "0101011100ffcc000600101005ffda0008010100003f00d2cf20ffd9" -) -SMALLEST_VALID_JPEG_BYTES = bytes.fromhex(SMALLEST_VALID_JPEG) - def _load_feature_fixtures(): fixtures = load_json_value_fixture("features.json", DOMAIN) @@ -201,7 +63,7 @@ async def setup_platform_for_device( _patch_discovery(device=device), _patch_connect(device=device), ): - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) # Good practice to wait background tasks in tests see PR #112726 await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/tplink/conftest.py b/tests/components/tplink/conftest.py index f1bbb80b80c..19cd5aa9acf 100644 --- a/tests/components/tplink/conftest.py +++ b/tests/components/tplink/conftest.py @@ -10,7 +10,8 @@ import pytest from homeassistant.components.tplink import DOMAIN from homeassistant.core import HomeAssistant -from . import ( +from . import _mocked_device +from .const import ( ALIAS_CAMERA, CREATE_ENTRY_DATA_AES_CAMERA, CREATE_ENTRY_DATA_LEGACY, @@ -26,7 +27,6 @@ from . import ( MAC_ADDRESS2, MAC_ADDRESS3, MODEL_CAMERA, - _mocked_device, ) from tests.common import MockConfigEntry @@ -115,8 +115,12 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_init() -> Generator[AsyncMock]: - """Override async_setup_entry.""" +def mock_init() -> Generator[dict[str, AsyncMock]]: + """Override async_setup and async_setup_entry. + + This fixture must be declared before the hass fixture to avoid errors + in the logs during teardown of the hass fixture which calls async_unload. + """ with patch.multiple( "homeassistant.components.tplink", async_setup=DEFAULT, diff --git a/tests/components/tplink/const.py b/tests/components/tplink/const.py new file mode 100644 index 00000000000..57829a7aa34 --- /dev/null +++ b/tests/components/tplink/const.py @@ -0,0 +1,146 @@ +"""Constants for the tplink component tests.""" + +from kasa import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) + +from homeassistant.components.tplink import ( + CONF_AES_KEYS, + CONF_ALIAS, + CONF_CAMERA_CREDENTIALS, + CONF_CONNECTION_PARAMETERS, + CONF_CREDENTIALS_HASH, + CONF_HOST, + CONF_LIVE_VIEW, + CONF_MODEL, + CONF_USES_HTTP, + Credentials, +) + +MODULE = "homeassistant.components.tplink" +MODULE_CONFIG_FLOW = "homeassistant.components.tplink.config_flow" +IP_ADDRESS = "127.0.0.1" +IP_ADDRESS2 = "127.0.0.2" +IP_ADDRESS3 = "127.0.0.3" +ALIAS = "My Bulb" +ALIAS_CAMERA = "My Camera" +MODEL = "HS100" +MODEL_CAMERA = "C210" +MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" +DEVICE_ID = "123456789ABCDEFGH" +DEVICE_ID_MAC = "AA:BB:CC:DD:EE:FF" +DHCP_FORMATTED_MAC_ADDRESS = MAC_ADDRESS.replace(":", "") +MAC_ADDRESS2 = "11:22:33:44:55:66" +MAC_ADDRESS3 = "66:55:44:33:22:11" +DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}" +DEFAULT_ENTRY_TITLE_CAMERA = f"{ALIAS_CAMERA} {MODEL_CAMERA}" +CREDENTIALS_HASH_LEGACY = "" +CONN_PARAMS_LEGACY = DeviceConnectionParameters( + DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor +) +DEVICE_CONFIG_LEGACY = DeviceConfig(IP_ADDRESS) +DEVICE_CONFIG_DICT_LEGACY = { + k: v for k, v in DEVICE_CONFIG_LEGACY.to_dict().items() if k != "credentials" +} +CREDENTIALS = Credentials("foo", "bar") +CREDENTIALS_HASH_AES = "AES/abcdefghijklmnopqrstuvabcdefghijklmnopqrstuv==" +CREDENTIALS_HASH_KLAP = "KLAP/abcdefghijklmnopqrstuv==" +CONN_PARAMS_KLAP = DeviceConnectionParameters( + DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Klap +) +DEVICE_CONFIG_KLAP = DeviceConfig( + IP_ADDRESS, + credentials=CREDENTIALS, + connection_type=CONN_PARAMS_KLAP, + uses_http=True, +) +CONN_PARAMS_AES = DeviceConnectionParameters( + DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Aes +) +_test_privkey = ( + "MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKLJKmBWGj6WYo9sewI8vkqar" + "Ed5H1JUr8Jj/LEWLTtV6+Mm4mfyEk6YKFHSmIG4AGgrVsGK/EbEkTZk9CwtixNQpBVc36oN2R" + "vuWWV38YnP4vI63mNxTA/gQonCsahjN4HfwE87pM7O5z39aeunoYm6Be663t33DbJH1ZUbZjm" + "tAgMBAAECgYB1Bn1KaFvRprcQOIJt51E9vNghQbf8rhj0fIEKpdC6mVhNIoUdCO+URNqnh+hP" + "SQIx4QYreUlHbsSeABFxOQSDJm6/kqyQsp59nCVDo/bXTtlvcSJ/sU3riqJNxYqEU1iJ0xMvU" + "N1VKKTmik89J8e5sN9R0AFfUSJIk7MpdOoD2QJBANTbV27nenyvbqee/ul4frdt2rrPGcGpcV" + "QmY87qbbrZgqgL5LMHHD7T/v/I8D1wRog1sBz/AiZGcnv/ox8dHKsCQQDDx8DCGPySSVqKVua" + "yUkBNpglN83wiCXZjyEtWIt+aB1A2n5ektE/o8oHnnOuvMdooxvtid7Mdapi2VLHV7VMHAkAE" + "d0GjWwnv2cJpk+VnQpbuBEkFiFjS/loZWODZM4Pv2qZqHi3DL9AA5XPBLBcWQufH7dBvG06RP" + "QMj5N4oRfUXAkEAuJJkVliqHNvM4OkGewzyFII4+WVYHNqg43dcFuuvtA27AJQ6qYtYXrvp3k" + "phI3yzOIhHTNCea1goepSkR5ODFwJBAJCTRbB+P47aEr/xA51ZFHE6VefDBJG9yg6yK4jcOxg" + "5ficXEpx8442okNtlzwa+QHpm/L3JOFrHwiEeVqXtiqY=" +) +_test_pubkey = ( + "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCiySpgVho+lmKPbHsCPL5KmqxHeR9SVK/CY" + "/yxFi07VevjJuJn8hJOmChR0piBuABoK1bBivxGxJE2ZPQsLYsTUKQVXN+qDdkb7llld/GJz+" + "LyOt5jcUwP4EKJwrGoYzeB38BPO6TOzuc9/Wnrp6GJugXuut7d9w2yR9WVG2Y5rQIDAQAB" +) +AES_KEYS = {"private": _test_privkey, "public": _test_pubkey} +DEVICE_CONFIG_AES = DeviceConfig( + IP_ADDRESS2, + credentials=CREDENTIALS, + connection_type=CONN_PARAMS_AES, + uses_http=True, + aes_keys=AES_KEYS, +) +CONN_PARAMS_AES_CAMERA = DeviceConnectionParameters( + DeviceFamily.SmartIpCamera, DeviceEncryptionType.Aes, https=True, login_version=2 +) +DEVICE_CONFIG_AES_CAMERA = DeviceConfig( + IP_ADDRESS3, + credentials=CREDENTIALS, + connection_type=CONN_PARAMS_AES_CAMERA, + uses_http=True, +) + +DEVICE_CONFIG_DICT_KLAP = { + k: v for k, v in DEVICE_CONFIG_KLAP.to_dict().items() if k != "credentials" +} +DEVICE_CONFIG_DICT_AES = { + k: v for k, v in DEVICE_CONFIG_AES.to_dict().items() if k != "credentials" +} +CREATE_ENTRY_DATA_LEGACY = { + CONF_HOST: IP_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_LEGACY.to_dict(), + CONF_USES_HTTP: False, +} + +CREATE_ENTRY_DATA_KLAP = { + CONF_HOST: IP_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_KLAP, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_KLAP.to_dict(), + CONF_USES_HTTP: True, +} +CREATE_ENTRY_DATA_AES = { + CONF_HOST: IP_ADDRESS2, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AES, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_AES.to_dict(), + CONF_USES_HTTP: True, + CONF_AES_KEYS: AES_KEYS, +} +CREATE_ENTRY_DATA_AES_CAMERA = { + CONF_HOST: IP_ADDRESS3, + CONF_ALIAS: ALIAS_CAMERA, + CONF_MODEL: MODEL_CAMERA, + CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AES, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_AES_CAMERA.to_dict(), + CONF_USES_HTTP: True, + CONF_LIVE_VIEW: True, + CONF_CAMERA_CREDENTIALS: {"username": "camuser", "password": "campass"}, +} +SMALLEST_VALID_JPEG = ( + "ffd8ffe000104a46494600010101004800480000ffdb00430003020202020203020202030303030406040404040408060" + "6050609080a0a090809090a0c0f0c0a0b0e0b09090d110d0e0f101011100a0c12131210130f101010ffc9000b08000100" + "0101011100ffcc000600101005ffda0008010100003f00d2cf20ffd9" +) +SMALLEST_VALID_JPEG_BYTES = bytes.fromhex(SMALLEST_VALID_JPEG) diff --git a/tests/components/tplink/test_binary_sensor.py b/tests/components/tplink/test_binary_sensor.py index e2b9cd08d13..b487fa51baf 100644 --- a/tests/components/tplink/test_binary_sensor.py +++ b/tests/components/tplink/test_binary_sensor.py @@ -4,18 +4,14 @@ from kasa import Feature import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.tplink.binary_sensor import BINARY_SENSOR_DESCRIPTIONS from homeassistant.components.tplink.const import DOMAIN from homeassistant.components.tplink.entity import EXCLUDED_FEATURES from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _mocked_strip_children, @@ -24,6 +20,7 @@ from . import ( setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @@ -47,7 +44,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test binary sensor states.""" features = {description.key for description in BINARY_SENSOR_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -68,7 +65,7 @@ async def test_binary_sensor( entity_registry: er.EntityRegistry, mocked_feature_binary_sensor: Feature, ) -> None: - """Test a sensor unique ids.""" + """Test binary sensor unique ids.""" mocked_feature = mocked_feature_binary_sensor already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -77,7 +74,7 @@ async def test_binary_sensor( plug = _mocked_device(alias="my_plug", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() # The entity_id is based on standard name from core. @@ -93,7 +90,7 @@ async def test_binary_sensor_children( device_registry: dr.DeviceRegistry, mocked_feature_binary_sensor: Feature, ) -> None: - """Test a sensor unique ids.""" + """Test binary sensor children.""" mocked_feature = mocked_feature_binary_sensor already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -105,7 +102,7 @@ async def test_binary_sensor_children( children=_mocked_strip_children(features=[mocked_feature]), ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "binary_sensor.my_plug_overheated" diff --git a/tests/components/tplink/test_button.py b/tests/components/tplink/test_button.py index a3eb8950336..c36d08337a7 100644 --- a/tests/components/tplink/test_button.py +++ b/tests/components/tplink/test_button.py @@ -4,7 +4,6 @@ from kasa import Feature import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.tplink.button import BUTTON_DESCRIPTIONS from homeassistant.components.tplink.const import DOMAIN @@ -16,11 +15,8 @@ from homeassistant.helpers import ( entity_registry as er, issue_registry as ir, ) -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _mocked_strip_children, @@ -30,6 +26,7 @@ from . import ( setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @@ -83,7 +80,7 @@ def create_deprecated_child_button_entities( @pytest.fixture def mocked_feature_button() -> Feature: - """Return mocked tplink binary sensor feature.""" + """Return mocked tplink button feature.""" return _mocked_feature( "test_alarm", value="", @@ -101,7 +98,7 @@ async def test_states( snapshot: SnapshotAssertion, create_deprecated_button_entities, ) -> None: - """Test a sensor unique ids.""" + """Test button states.""" features = {description.key for description in BUTTON_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -118,14 +115,15 @@ async def test_states( async def test_button( hass: HomeAssistant, entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, mocked_feature_button: Feature, create_deprecated_button_entities, ) -> None: - """Test a sensor unique ids.""" + """Test button unique ids.""" mocked_feature = mocked_feature_button plug = _mocked_device(alias="my_device", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # The entity_id is based on standard name from core. @@ -139,11 +137,12 @@ async def test_button_children( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, mocked_feature_button: Feature, create_deprecated_button_entities, create_deprecated_child_button_entities, ) -> None: - """Test a sensor unique ids.""" + """Test button children.""" mocked_feature = mocked_feature_button plug = _mocked_device( alias="my_device", @@ -151,7 +150,7 @@ async def test_button_children( children=_mocked_strip_children(features=[mocked_feature]), ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() entity_id = "button.my_device_test_alarm" @@ -173,6 +172,7 @@ async def test_button_children( async def test_button_press( hass: HomeAssistant, entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, mocked_feature_button: Feature, create_deprecated_button_entities, ) -> None: @@ -180,7 +180,7 @@ async def test_button_press( mocked_feature = mocked_feature_button plug = _mocked_device(alias="my_device", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() entity_id = "button.my_device_test_alarm" @@ -213,7 +213,7 @@ async def test_button_not_exists_with_deprecation( mocked_feature = mocked_feature_button dev = _mocked_device(alias="my_device", features=[mocked_feature]) with _patch_discovery(device=dev), _patch_connect(device=dev): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert not entity_registry.async_get(entity_id) @@ -265,7 +265,7 @@ async def test_button_exists_with_deprecation( mocked_feature = mocked_feature_button dev = _mocked_device(alias="my_device", features=[mocked_feature]) with _patch_discovery(device=dev), _patch_connect(device=dev): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() entity = entity_registry.async_get(entity_id) diff --git a/tests/components/tplink/test_camera.py b/tests/components/tplink/test_camera.py index aa83ae659fb..ceb74e3a61a 100644 --- a/tests/components/tplink/test_camera.py +++ b/tests/components/tplink/test_camera.py @@ -11,6 +11,9 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import stream from homeassistant.components.camera import ( + DOMAIN as CAMERA_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, CameraEntityFeature, StreamType, async_get_image, @@ -23,15 +26,8 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import ( - DEVICE_ID, - IP_ADDRESS3, - MAC_ADDRESS3, - SMALLEST_VALID_JPEG_BYTES, - _mocked_device, - setup_platform_for_device, - snapshot_platform, -) +from . import _mocked_device, setup_platform_for_device, snapshot_platform +from .const import DEVICE_ID, IP_ADDRESS3, MAC_ADDRESS3, SMALLEST_VALID_JPEG_BYTES from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator @@ -44,7 +40,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test states.""" + """Test camera states.""" mock_camera_config_entry.add_to_hass(hass) mock_device = _mocked_device( @@ -73,6 +69,7 @@ async def test_camera_unique_id( hass: HomeAssistant, mock_camera_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test camera unique id.""" mock_device = _mocked_device( @@ -92,14 +89,13 @@ async def test_camera_unique_id( ) assert device_entries entity_id = "camera.my_camera_live_view" - entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == f"{DEVICE_ID}-live_view" async def test_handle_mjpeg_stream( hass: HomeAssistant, mock_camera_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: """Test handle_async_mjpeg_stream.""" mock_device = _mocked_device( @@ -126,7 +122,6 @@ async def test_handle_mjpeg_stream( async def test_handle_mjpeg_stream_not_supported( hass: HomeAssistant, mock_camera_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: """Test handle_async_mjpeg_stream.""" mock_device = _mocked_device( @@ -216,7 +211,7 @@ async def test_no_camera_image_when_streaming( mock_camera_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: - """Test async_get_image.""" + """Test no camera image when streaming.""" mock_device = _mocked_device( modules=[Module.Camera], alias="my_camera", @@ -272,9 +267,8 @@ async def test_no_camera_image_when_streaming( async def test_no_concurrent_camera_image( hass: HomeAssistant, mock_camera_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: - """Test async_get_image.""" + """Test async_get_image doesn't make concurrent requests.""" mock_device = _mocked_device( modules=[Module.Camera], alias="my_camera", @@ -321,7 +315,7 @@ async def test_camera_image_auth_error( mock_connect: AsyncMock, mock_discovery: AsyncMock, ) -> None: - """Test async_get_image.""" + """Test async_get_image auth error.""" mock_device = _mocked_device( modules=[Module.Camera], alias="my_camera", @@ -367,7 +361,7 @@ async def test_camera_stream_source( mock_camera_config_entry: MockConfigEntry, hass_ws_client: WebSocketGenerator, ) -> None: - """Test async_get_image. + """Test camera stream source. This test would fail if the integration didn't properly put stream in the dependencies. @@ -444,16 +438,16 @@ async def test_camera_turn_on_off( assert state is not None await hass.services.async_call( - "camera", - "turn_on", + CAMERA_DOMAIN, + SERVICE_TURN_ON, {"entity_id": "camera.my_camera_live_view"}, blocking=True, ) mock_camera.set_state.assert_called_with(True) await hass.services.async_call( - "camera", - "turn_off", + CAMERA_DOMAIN, + SERVICE_TURN_OFF, {"entity_id": "camera.my_camera_live_view"}, blocking=True, ) diff --git a/tests/components/tplink/test_climate.py b/tests/components/tplink/test_climate.py index 3a54048e1d6..b1c8abd3a9b 100644 --- a/tests/components/tplink/test_climate.py +++ b/tests/components/tplink/test_climate.py @@ -27,12 +27,12 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util from . import ( - DEVICE_ID, _mocked_device, _mocked_feature, setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID from tests.common import MockConfigEntry, async_fire_time_changed @@ -41,7 +41,7 @@ ENTITY_ID = "climate.thermostat" @pytest.fixture async def mocked_hub(hass: HomeAssistant) -> Device: - """Return mocked tplink binary sensor feature.""" + """Return mocked tplink hub.""" features = [ _mocked_feature( @@ -166,7 +166,8 @@ async def test_set_hvac_mode( ) mocked_state.set_value.assert_called_with(True) - with pytest.raises(ServiceValidationError): + msg = "Tried to set unsupported mode: dry" + with pytest.raises(ServiceValidationError, match=msg): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index 14f1260e2ec..08903e29a71 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -37,7 +37,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import ( +from . import _mocked_device, _patch_connect, _patch_discovery, _patch_single_discovery +from .conftest import override_side_effect +from .const import ( AES_KEYS, ALIAS, ALIAS_CAMERA, @@ -67,12 +69,7 @@ from . import ( MODEL_CAMERA, MODULE, SMALLEST_VALID_JPEG_BYTES, - _mocked_device, - _patch_connect, - _patch_discovery, - _patch_single_discovery, ) -from .conftest import override_side_effect from tests.common import MockConfigEntry @@ -168,8 +165,11 @@ async def test_discovery( assert result2["reason"] == "no_devices_found" +@pytest.mark.usefixtures("mock_init") async def test_discovery_camera( - hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, ) -> None: """Test authenticated discovery for camera with stream.""" mock_device = _mocked_device( @@ -228,8 +228,11 @@ async def test_discovery_camera( assert result["context"]["unique_id"] == MAC_ADDRESS3 +@pytest.mark.usefixtures("mock_init") async def test_discovery_pick_device_camera( - hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, ) -> None: """Test authenticated discovery for camera with stream.""" mock_device = _mocked_device( @@ -293,8 +296,11 @@ async def test_discovery_pick_device_camera( assert result["context"]["unique_id"] == MAC_ADDRESS3 +@pytest.mark.usefixtures("mock_init") async def test_discovery_auth( - hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, ) -> None: """Test authenticated discovery.""" mock_device = _mocked_device( @@ -336,8 +342,11 @@ async def test_discovery_auth( assert result2["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_discovery_auth_camera( - hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, ) -> None: """Test authenticated discovery for camera with stream.""" mock_device = _mocked_device( @@ -407,13 +416,13 @@ async def test_discovery_auth_camera( ], ids=["invalid-auth", "unknown-error"], ) +@pytest.mark.usefixtures("mock_init") async def test_discovery_auth_errors( hass: HomeAssistant, mock_connect: AsyncMock, - mock_init, - error_type, - errors_msg, - error_placement, + error_type: Exception, + errors_msg: str, + error_placement: str, ) -> None: """Test handling of discovery authentication errors. @@ -465,10 +474,10 @@ async def test_discovery_auth_errors( assert result3["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_discovery_new_credentials( hass: HomeAssistant, mock_connect: AsyncMock, - mock_init, ) -> None: """Test setting up discovery with new credentials.""" mock_device = mock_connect["mock_devices"][IP_ADDRESS] @@ -514,10 +523,10 @@ async def test_discovery_new_credentials( assert result3["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_discovery_new_credentials_invalid( hass: HomeAssistant, mock_connect: AsyncMock, - mock_init, ) -> None: """Test setting up discovery with new invalid credentials.""" mock_device = mock_connect["mock_devices"][IP_ADDRESS] @@ -977,11 +986,11 @@ async def test_manual_no_capabilities(hass: HomeAssistant) -> None: assert result["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_manual_auth( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, ) -> None: """Test manually setup.""" result = await hass.config_entries.flow.async_init( @@ -1083,14 +1092,14 @@ async def test_manual_auth_camera( ], ids=["invalid-auth", "unknown-error"], ) +@pytest.mark.usefixtures("mock_init") async def test_manual_auth_errors( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, - error_type, - errors_msg, - error_placement, + error_type: Exception, + errors_msg: str, + error_placement: str, ) -> None: """Test manually setup auth errors.""" result = await hass.config_entries.flow.async_init( @@ -1150,9 +1159,9 @@ async def test_manual_port_override( hass: HomeAssistant, mock_connect: AsyncMock, mock_discovery: AsyncMock, - host_str, - host, - port, + host_str: str, + host: str, + port: int, ) -> None: """Test manually setup.""" config = DeviceConfig( @@ -1342,7 +1351,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: ], ) async def test_discovered_by_dhcp_or_discovery( - hass: HomeAssistant, source, data + hass: HomeAssistant, source: str, data: dict ) -> None: """Test we can setup when discovered from dhcp or discovery.""" @@ -1396,7 +1405,7 @@ async def test_discovered_by_dhcp_or_discovery( ], ) async def test_discovered_by_dhcp_or_discovery_failed_to_get_device( - hass: HomeAssistant, source, data + hass: HomeAssistant, source: str, data: dict ) -> None: """Test we abort if we cannot get the unique id when discovered from dhcp.""" @@ -1419,7 +1428,7 @@ async def test_integration_discovery_with_ip_change( mock_discovery: AsyncMock, mock_connect: AsyncMock, ) -> None: - """Test reauth flow.""" + """Test integration updates ip address from discovery.""" mock_config_entry.add_to_hass(hass) with ( patch("homeassistant.components.tplink.Discover.discover", return_value={}), @@ -1670,7 +1679,7 @@ async def test_reauth_camera( mock_discovery: AsyncMock, mock_connect: AsyncMock, ) -> None: - """Test async_get_image.""" + """Test reauth flow on invalid camera credentials.""" mock_device = mock_connect["mock_devices"][IP_ADDRESS3] mock_camera_config_entry.add_to_hass(hass) mock_camera_config_entry.async_start_reauth( @@ -1762,7 +1771,7 @@ async def test_reauth_try_connect_all_fail( override_side_effect(mock_discovery["discover_single"], TimeoutError), override_side_effect(mock_discovery["try_connect_all"], lambda *_, **__: None), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_USERNAME: "fake_username", @@ -1774,7 +1783,23 @@ async def test_reauth_try_connect_all_fail( IP_ADDRESS, credentials=credentials, port=None ) mock_discovery["try_connect_all"].assert_called_once() - assert result2["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "cannot_connect"} + + mock_discovery["try_connect_all"].reset_mock() + with ( + override_side_effect(mock_discovery["discover_single"], TimeoutError), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + + mock_discovery["try_connect_all"].assert_called_once() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" async def test_reauth_update_with_encryption_change( @@ -2025,9 +2050,9 @@ async def test_reauth_errors( mock_added_config_entry: MockConfigEntry, mock_discovery: AsyncMock, mock_connect: AsyncMock, - error_type, - errors_msg, - error_placement, + error_type: Exception, + errors_msg: str, + error_placement: str, ) -> None: """Test reauth errors.""" mock_added_config_entry.async_start_reauth(hass) @@ -2089,8 +2114,8 @@ async def test_pick_device_errors( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - error_type, - expected_flow, + error_type: type[Exception], + expected_flow: FlowResultType, ) -> None: """Test errors on pick_device.""" result = await hass.config_entries.flow.async_init( @@ -2127,11 +2152,11 @@ async def test_pick_device_errors( assert result4["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_discovery_timeout_try_connect_all( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, ) -> None: """Test discovery tries legacy connect on timeout.""" result = await hass.config_entries.flow.async_init( @@ -2153,11 +2178,11 @@ async def test_discovery_timeout_try_connect_all( assert mock_connect["connect"].call_count == 1 +@pytest.mark.usefixtures("mock_init") async def test_discovery_timeout_try_connect_all_needs_creds( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, ) -> None: """Test discovery tries legacy connect on timeout.""" result = await hass.config_entries.flow.async_init( @@ -2191,11 +2216,11 @@ async def test_discovery_timeout_try_connect_all_needs_creds( assert mock_connect["connect"].call_count == 1 +@pytest.mark.usefixtures("mock_init") async def test_discovery_timeout_try_connect_all_fail( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, ) -> None: """Test discovery tries legacy connect on timeout.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/tplink/test_fan.py b/tests/components/tplink/test_fan.py index deba33abfa5..13a768f683c 100644 --- a/tests/components/tplink/test_fan.py +++ b/tests/components/tplink/test_fan.py @@ -2,8 +2,7 @@ from __future__ import annotations -from datetime import timedelta - +from freezegun.api import FrozenDateTimeFactory from kasa import Device, Module from syrupy.assertion import SnapshotAssertion @@ -11,13 +10,15 @@ from homeassistant.components.fan import ( ATTR_PERCENTAGE, DOMAIN as FAN_DOMAIN, SERVICE_SET_PERCENTAGE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util -from . import DEVICE_ID, _mocked_device, setup_platform_for_device, snapshot_platform +from . import _mocked_device, setup_platform_for_device, snapshot_platform +from .const import DEVICE_ID from tests.common import MockConfigEntry, async_fire_time_changed @@ -56,6 +57,7 @@ async def test_fan_unique_id( hass: HomeAssistant, mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test a fan unique id.""" fan = _mocked_device(modules=[Module.Fan], alias="my_fan") @@ -66,12 +68,16 @@ async def test_fan_unique_id( ) assert device_entries entity_id = "fan.my_fan" - entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == DEVICE_ID -async def test_fan(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: - """Test a color fan and that all transitions are correctly passed.""" +async def test_fan( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test fan functionality.""" device = _mocked_device(modules=[Module.Fan], alias="my_fan") fan = device.modules[Module.Fan] fan.fan_speed_level = 0 @@ -83,26 +89,29 @@ async def test_fan(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> N assert state.state == "off" await hass.services.async_call( - FAN_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) fan.set_fan_speed_level.assert_called_once_with(4) fan.set_fan_speed_level.reset_mock() fan.fan_speed_level = 4 - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + + freezer.tick(10) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(entity_id) assert state.state == "on" await hass.services.async_call( - FAN_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) fan.set_fan_speed_level.assert_called_once_with(0) fan.set_fan_speed_level.reset_mock() await hass.services.async_call( FAN_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 50}, blocking=True, ) diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 59cdda3ad92..7dbd723aa2d 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -38,6 +38,14 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import ( + _mocked_device, + _mocked_feature, + _patch_connect, + _patch_discovery, + _patch_single_discovery, +) +from .conftest import override_side_effect +from .const import ( ALIAS, CREATE_ENTRY_DATA_AES, CREATE_ENTRY_DATA_KLAP, @@ -53,13 +61,7 @@ from . import ( IP_ADDRESS, MAC_ADDRESS, MODEL, - _mocked_device, - _mocked_feature, - _patch_connect, - _patch_discovery, - _patch_single_discovery, ) -from .conftest import override_side_effect from tests.common import MockConfigEntry, async_fire_time_changed @@ -98,7 +100,7 @@ async def test_config_entry_reload(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) with _patch_discovery(), _patch_single_discovery(), _patch_connect(): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() assert already_migrated_config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(already_migrated_config_entry.entry_id) @@ -117,7 +119,7 @@ async def test_config_entry_retry(hass: HomeAssistant) -> None: _patch_single_discovery(no_device=True), _patch_connect(no_device=True), ): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() assert already_migrated_config_entry.state is ConfigEntryState.SETUP_RETRY @@ -175,7 +177,7 @@ async def test_config_entry_wrong_mac_Address( ) already_migrated_config_entry.add_to_hass(hass) with _patch_discovery(), _patch_single_discovery(), _patch_connect(): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() assert already_migrated_config_entry.state is ConfigEntryState.SETUP_RETRY @@ -309,7 +311,7 @@ async def test_plug_auth_fails(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) device = _mocked_device(alias="my_plug", features=["state"]) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -355,7 +357,7 @@ async def test_update_attrs_fails_in_init( type(light_module).color_temp = p light.__str__ = lambda _: "MockLight" with _patch_discovery(device=light), _patch_connect(device=light): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -388,7 +390,7 @@ async def test_update_attrs_fails_on_update( light_module = light.modules[Module.Light] with _patch_discovery(device=light), _patch_connect(device=light): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -434,7 +436,7 @@ async def test_feature_no_category( ) dev.features["led"].category = Feature.Category.Unset with _patch_discovery(device=dev), _patch_connect(device=dev): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug_led" diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index e19f2e11a40..565d4f1221a 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -20,6 +20,11 @@ from kasa.iot import IotDevice import pytest from homeassistant.components import tplink +from homeassistant.components.homeassistant.scene import ( + CONF_SCENE_ID, + CONF_SNAPSHOT, + SERVICE_CREATE, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, @@ -35,7 +40,10 @@ from homeassistant.components.light import ( ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, EFFECT_OFF, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, ) +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN from homeassistant.components.tplink.const import DOMAIN from homeassistant.components.tplink.light import ( SERVICE_RANDOM_EFFECT, @@ -56,14 +64,13 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _patch_connect, _patch_discovery, _patch_single_discovery, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry, async_fire_time_changed @@ -88,7 +95,7 @@ async def test_light_unique_id( light = _mocked_device(modules=[Module.Light], alias="my_light") light.device_type = device_type with _patch_discovery(device=light), _patch_connect(device=light): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -98,8 +105,11 @@ async def test_light_unique_id( ) -async def test_legacy_dimmer_unique_id(hass: HomeAssistant) -> None: - """Test a light unique id.""" +async def test_legacy_dimmer_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test dimmer unique id.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -113,16 +123,16 @@ async def test_legacy_dimmer_unique_id(hass: HomeAssistant) -> None: light.device_type = DeviceType.Dimmer with _patch_discovery(device=light), _patch_connect(device=light): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" - entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == "aa:bb:cc:dd:ee:ff" @pytest.mark.parametrize( - ("device", "transition"), + ("device", "extra_data", "expected_transition"), [ ( _mocked_device( @@ -135,7 +145,138 @@ async def test_legacy_dimmer_unique_id(hass: HomeAssistant) -> None: ), ], ), - 2.0, + {ATTR_TRANSITION: 2.0}, + 2.0 * 1_000, + ), + ( + _mocked_device( + modules=[Module.Light], + features=[ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 + ), + ], + ), + {}, + None, + ), + ], +) +async def test_color_light( + hass: HomeAssistant, + device: MagicMock, + extra_data: dict, + expected_transition: float | None, +) -> None: + """Test a color light and that all transitions are correctly passed.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + light = device.modules[Module.Light] + + # Setting color_temp to None emulates a device without color temp + light.color_temp = None + + with _patch_discovery(device=device), _patch_connect(device=device): + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + BASE_PAYLOAD = {ATTR_ENTITY_ID: entity_id} + BASE_PAYLOAD |= extra_data + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + + assert attributes.get(ATTR_EFFECT) is None + + assert attributes[ATTR_COLOR_MODE] == "hs" + assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 4000 + assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 9000 + assert attributes[ATTR_HS_COLOR] == (10, 30) + assert attributes[ATTR_RGB_COLOR] == (255, 191, 178) + assert attributes[ATTR_XY_COLOR] == (0.42, 0.336) + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, BASE_PAYLOAD, blocking=True + ) + light.set_state.assert_called_once_with( + LightState(light_on=False, transition=expected_transition) + ) + light.set_state.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, BASE_PAYLOAD, blocking=True + ) + light.set_state.assert_called_once_with( + LightState(light_on=True, transition=expected_transition) + ) + light.set_state.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {**BASE_PAYLOAD, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + light.set_brightness.assert_called_with(39, transition=expected_transition) + light.set_brightness.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, + blocking=True, + ) + light.set_color_temp.assert_called_with( + 6666, brightness=None, transition=expected_transition + ) + light.set_color_temp.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, + blocking=True, + ) + light.set_color_temp.assert_called_with( + 6666, brightness=None, transition=expected_transition + ) + light.set_color_temp.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {**BASE_PAYLOAD, ATTR_HS_COLOR: (10, 30)}, + blocking=True, + ) + light.set_hsv.assert_called_with(10, 30, None, transition=expected_transition) + light.set_hsv.reset_mock() + + +@pytest.mark.parametrize( + ("device", "extra_data", "expected_transition"), + [ + ( + _mocked_device( + modules=[Module.Light, Module.LightEffect], + features=[ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 + ), + ], + ), + {ATTR_TRANSITION: 2.0}, + 2.0 * 1_000, ), ( _mocked_device( @@ -148,12 +289,16 @@ async def test_legacy_dimmer_unique_id(hass: HomeAssistant) -> None: ), ], ), + {}, None, ), ], ) -async def test_color_light( - hass: HomeAssistant, device: MagicMock, transition: float | None +async def test_color_light_with_active_effect( + hass: HomeAssistant, + device: MagicMock, + extra_data: dict, + expected_transition: float | None, ) -> None: """Test a color light and that all transitions are correctly passed.""" already_migrated_config_entry = MockConfigEntry( @@ -162,93 +307,84 @@ async def test_color_light( already_migrated_config_entry.add_to_hass(hass) light = device.modules[Module.Light] - # Setting color_temp to None emulates a device with active effects - light.color_temp = None - with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_bulb" - KASA_TRANSITION_VALUE = transition * 1_000 if transition is not None else None BASE_PAYLOAD = {ATTR_ENTITY_ID: entity_id} - if transition: - BASE_PAYLOAD[ATTR_TRANSITION] = transition + BASE_PAYLOAD |= extra_data state = hass.states.get(entity_id) assert state.state == "on" attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + # If effect is active, only the brightness can be controlled - if attributes.get(ATTR_EFFECT) is not None: - assert attributes[ATTR_COLOR_MODE] == "brightness" - else: - assert attributes[ATTR_COLOR_MODE] == "hs" - assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 4000 - assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 9000 - assert attributes[ATTR_HS_COLOR] == (10, 30) - assert attributes[ATTR_RGB_COLOR] == (255, 191, 178) - assert attributes[ATTR_XY_COLOR] == (0.42, 0.336) + assert attributes.get(ATTR_EFFECT) is not None + assert attributes[ATTR_COLOR_MODE] == "brightness" await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", BASE_PAYLOAD, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, BASE_PAYLOAD, blocking=True ) light.set_state.assert_called_once_with( - LightState(light_on=False, transition=KASA_TRANSITION_VALUE) + LightState(light_on=False, transition=expected_transition) ) light.set_state.reset_mock() - await hass.services.async_call(LIGHT_DOMAIN, "turn_on", BASE_PAYLOAD, blocking=True) + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, BASE_PAYLOAD, blocking=True + ) light.set_state.assert_called_once_with( - LightState(light_on=True, transition=KASA_TRANSITION_VALUE) + LightState(light_on=True, transition=expected_transition) ) light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {**BASE_PAYLOAD, ATTR_BRIGHTNESS: 100}, blocking=True, ) - light.set_brightness.assert_called_with(39, transition=KASA_TRANSITION_VALUE) + light.set_brightness.assert_called_with(39, transition=expected_transition) light.set_brightness.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, blocking=True, ) light.set_color_temp.assert_called_with( - 6666, brightness=None, transition=KASA_TRANSITION_VALUE + 6666, brightness=None, transition=expected_transition ) light.set_color_temp.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, blocking=True, ) light.set_color_temp.assert_called_with( - 6666, brightness=None, transition=KASA_TRANSITION_VALUE + 6666, brightness=None, transition=expected_transition ) light.set_color_temp.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {**BASE_PAYLOAD, ATTR_HS_COLOR: (10, 30)}, blocking=True, ) - light.set_hsv.assert_called_with(10, 30, None, transition=KASA_TRANSITION_VALUE) + light.set_hsv.assert_called_with(10, 30, None, transition=expected_transition) light.set_hsv.reset_mock() async def test_color_light_no_temp(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a color light with no color temp.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -263,7 +399,7 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: type(light).color_temp = PropertyMock(side_effect=Exception) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -279,20 +415,20 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: assert attributes[ATTR_XY_COLOR] == (0.42, 0.336) await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) @@ -301,7 +437,7 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, blocking=True, ) @@ -309,51 +445,28 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: light.set_hsv.reset_mock() -@pytest.mark.parametrize( - ("device", "is_color"), - [ - ( - _mocked_device( - modules=[Module.Light], - alias="my_light", - features=[ - _mocked_feature("brightness", value=50), - _mocked_feature("hsv", value=(10, 30, 5)), - _mocked_feature( - "color_temp", value=4000, minimum_value=4000, maximum_value=9000 - ), - ], +async def test_color_temp_light_color(hass: HomeAssistant) -> None: + """Test a color temp light with color.""" + device = _mocked_device( + modules=[Module.Light], + alias="my_light", + features=[ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 ), - True, - ), - ( - _mocked_device( - modules=[Module.Light], - alias="my_light", - features=[ - _mocked_feature("brightness", value=50), - _mocked_feature( - "color_temp", value=4000, minimum_value=4000, maximum_value=9000 - ), - ], - ), - False, - ), - ], -) -async def test_color_temp_light( - hass: HomeAssistant, device: MagicMock, is_color: bool -) -> None: - """Test a light.""" + ], + ) already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - # device = _mocked_device(modules=[Module.Light], alias="my_light") + light = device.modules[Module.Light] with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -363,29 +476,24 @@ async def test_color_temp_light( attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "color_temp" - if is_color: - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] - else: - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"] - assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 9000 - assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 4000 - assert attributes[ATTR_COLOR_TEMP_KELVIN] == 4000 + + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) @@ -394,7 +502,7 @@ async def test_color_temp_light( await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6666}, blocking=True, ) @@ -404,7 +512,7 @@ async def test_color_temp_light( # Verify color temp is clamped to the valid range await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 20000}, blocking=True, ) @@ -414,7 +522,94 @@ async def test_color_temp_light( # Verify color temp is clamped to the valid range await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 1}, + blocking=True, + ) + light.set_color_temp.assert_called_with(4000, brightness=None, transition=None) + light.set_color_temp.reset_mock() + + +async def test_color_temp_light_no_color(hass: HomeAssistant) -> None: + """Test a color temp light with no color.""" + device = _mocked_device( + modules=[Module.Light], + alias="my_light", + features=[ + _mocked_feature("brightness", value=50), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 + ), + ], + ) + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + + light = device.modules[Module.Light] + + with _patch_discovery(device=device), _patch_connect(device=device): + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "light.my_light" + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_COLOR_MODE] == "color_temp" + + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"] + assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 9000 + assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 4000 + assert attributes[ATTR_COLOR_TEMP_KELVIN] == 4000 + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + light.set_state.assert_called_once() + light.set_state.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + light.set_state.assert_called_once() + light.set_state.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + light.set_brightness.assert_called_with(39, transition=None) + light.set_brightness.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6666}, + blocking=True, + ) + light.set_color_temp.assert_called_with(6666, brightness=None, transition=None) + light.set_color_temp.reset_mock() + + # Verify color temp is clamped to the valid range + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 20000}, + blocking=True, + ) + light.set_color_temp.assert_called_with(9000, brightness=None, transition=None) + light.set_color_temp.reset_mock() + + # Verify color temp is clamped to the valid range + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 1}, blocking=True, ) @@ -423,7 +618,7 @@ async def test_color_temp_light( async def test_brightness_only_light(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a light brightness.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -435,7 +630,7 @@ async def test_brightness_only_light(hass: HomeAssistant) -> None: light = device.modules[Module.Light] with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -448,20 +643,20 @@ async def test_brightness_only_light(hass: HomeAssistant) -> None: assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness"] await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) @@ -470,7 +665,7 @@ async def test_brightness_only_light(hass: HomeAssistant) -> None: async def test_on_off_light(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a light turns on and off.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -479,7 +674,7 @@ async def test_on_off_light(hass: HomeAssistant) -> None: light = device.modules[Module.Light] with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -490,20 +685,20 @@ async def test_on_off_light(hass: HomeAssistant) -> None: assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["onoff"] await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() async def test_off_at_start_light(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a light off at startup.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -514,7 +709,7 @@ async def test_off_at_start_light(hass: HomeAssistant) -> None: light.state = LightState(light_on=False) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -526,7 +721,7 @@ async def test_off_at_start_light(hass: HomeAssistant) -> None: async def test_dimmer_turn_on_fix(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a dimmer turns on without brightness being set.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -537,7 +732,7 @@ async def test_dimmer_turn_on_fix(hass: HomeAssistant) -> None: light.state = LightState(light_on=False) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -546,7 +741,7 @@ async def test_dimmer_turn_on_fix(hass: HomeAssistant) -> None: assert state.state == "off" await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once_with( LightState( @@ -587,7 +782,7 @@ async def test_smart_strip_effects( _patch_single_discovery(device=device), _patch_connect(device=device), ): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -601,7 +796,7 @@ async def test_smart_strip_effects( # is in progress calls set_effect to clear the effect await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 4000}, blocking=True, ) @@ -612,7 +807,7 @@ async def test_smart_strip_effects( await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "Effect2"}, blocking=True, ) @@ -629,7 +824,7 @@ async def test_smart_strip_effects( # Test setting light effect off await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "off"}, blocking=True, ) @@ -644,7 +839,7 @@ async def test_smart_strip_effects( caplog.clear() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "Effect3"}, blocking=True, ) @@ -673,7 +868,7 @@ async def test_smart_strip_effects( await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) @@ -703,7 +898,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: light_effect = device.modules[Module.LightEffect] with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -713,7 +908,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, - "random_effect", + SERVICE_RANDOM_EFFECT, { ATTR_ENTITY_ID: entity_id, "init_states": [340, 20, 50], @@ -742,7 +937,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, - "random_effect", + SERVICE_RANDOM_EFFECT, { ATTR_ENTITY_ID: entity_id, "init_states": [340, 20, 50], @@ -792,7 +987,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) @@ -801,7 +996,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, - "random_effect", + SERVICE_RANDOM_EFFECT, { ATTR_ENTITY_ID: entity_id, "init_states": [340, 20, 50], @@ -875,7 +1070,7 @@ async def test_smart_strip_effect_service_error( service_params: dict, expected_extra_params: dict, ) -> None: - """Test smart strip custom random effects.""" + """Test smart strip effect service errors.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -886,7 +1081,7 @@ async def test_smart_strip_effect_service_error( light_effect = device.modules[Module.LightEffect] with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -935,7 +1130,7 @@ async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> light_effect = device.modules[Module.LightEffect] light_effect.effect = LightEffect.LIGHT_EFFECTS_OFF with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -945,7 +1140,7 @@ async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> # fallback to set HSV when custom effect is not known so it does turn back on await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) @@ -965,7 +1160,7 @@ async def test_smart_strip_custom_sequence_effect(hass: HomeAssistant) -> None: light_effect = device.modules[Module.LightEffect] with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -975,7 +1170,7 @@ async def test_smart_strip_custom_sequence_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, - "sequence_effect", + SERVICE_SEQUENCE_EFFECT, { ATTR_ENTITY_ID: entity_id, "sequence": [[340, 20, 50], [20, 50, 50], [0, 100, 50]], @@ -1040,7 +1235,7 @@ async def test_light_errors_when_turned_on( light.set_state.side_effect = exception_type(msg) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -1051,7 +1246,7 @@ async def test_light_errors_when_turned_on( with pytest.raises(HomeAssistantError, match=msg): await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() assert light.set_state.call_count == 1 @@ -1091,7 +1286,7 @@ async def test_light_child( ) with _patch_discovery(device=parent_device), _patch_connect(device=parent_device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_device" @@ -1132,14 +1327,16 @@ async def test_scene_effect_light( light_effect.effect = LightEffect.LIGHT_EFFECTS_OFF with _patch_discovery(device=device), _patch_connect(device=device): - assert await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) - assert await async_setup_component(hass, "scene", {}) + assert await hass.config_entries.async_setup( + already_migrated_config_entry.entry_id + ) + assert await async_setup_component(hass, SCENE_DOMAIN, {}) await hass.async_block_till_done() entity_id = "light.my_light" await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() freezer.tick(5) @@ -1151,9 +1348,9 @@ async def test_scene_effect_light( assert state.attributes["effect"] is EFFECT_OFF await hass.services.async_call( - "scene", - "create", - {"scene_id": "effect_off_scene", "snapshot_entities": [entity_id]}, + SCENE_DOMAIN, + SERVICE_CREATE, + {CONF_SCENE_ID: "effect_off_scene", CONF_SNAPSHOT: [entity_id]}, blocking=True, ) await hass.async_block_till_done() @@ -1161,7 +1358,7 @@ async def test_scene_effect_light( assert scene_state.state is STATE_UNKNOWN await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() freezer.tick(5) @@ -1172,10 +1369,10 @@ async def test_scene_effect_light( assert state.state is STATE_OFF await hass.services.async_call( - "scene", - "turn_on", + SCENE_DOMAIN, + SERVICE_TURN_ON, { - "entity_id": "scene.effect_off_scene", + ATTR_ENTITY_ID: "scene.effect_off_scene", }, blocking=True, ) diff --git a/tests/components/tplink/test_number.py b/tests/components/tplink/test_number.py index 865ce27ffc0..07d64178dfa 100644 --- a/tests/components/tplink/test_number.py +++ b/tests/components/tplink/test_number.py @@ -3,7 +3,6 @@ from kasa import Feature from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, @@ -15,11 +14,8 @@ from homeassistant.components.tplink.number import NUMBER_DESCRIPTIONS from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _mocked_strip_children, @@ -28,6 +24,7 @@ from . import ( setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @@ -39,7 +36,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test a number states.""" features = {description.key for description in NUMBER_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -54,7 +51,7 @@ async def test_states( async def test_number(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: - """Test a sensor unique ids.""" + """Test number unique ids.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -70,7 +67,7 @@ async def test_number(hass: HomeAssistant, entity_registry: er.EntityRegistry) - ) plug = _mocked_device(alias="my_plug", features=[new_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "number.my_plug_temperature_offset" @@ -84,7 +81,7 @@ async def test_number_children( entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: - """Test a sensor unique ids.""" + """Test number children.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -104,7 +101,7 @@ async def test_number_children( children=_mocked_strip_children(features=[new_feature]), ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "number.my_plug_temperature_offset" @@ -142,7 +139,7 @@ async def test_number_set( ) plug = _mocked_device(alias="my_plug", features=[new_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "number.my_plug_temperature_offset" diff --git a/tests/components/tplink/test_select.py b/tests/components/tplink/test_select.py index 6c49185d91c..3b99412740a 100644 --- a/tests/components/tplink/test_select.py +++ b/tests/components/tplink/test_select.py @@ -4,7 +4,6 @@ from kasa import Feature import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.select import ( ATTR_OPTION, DOMAIN as SELECT_DOMAIN, @@ -16,11 +15,8 @@ from homeassistant.components.tplink.select import SELECT_DESCRIPTIONS from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _mocked_strip_children, @@ -29,13 +25,14 @@ from . import ( setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @pytest.fixture def mocked_feature_select() -> Feature: - """Return mocked tplink binary sensor feature.""" + """Return mocked tplink select feature.""" return _mocked_feature( "light_preset", value="First choice", @@ -53,7 +50,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test select states.""" features = {description.key for description in SELECT_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -72,7 +69,7 @@ async def test_select( entity_registry: er.EntityRegistry, mocked_feature_select: Feature, ) -> None: - """Test a sensor unique ids.""" + """Test select unique ids.""" mocked_feature = mocked_feature_select already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -81,7 +78,7 @@ async def test_select( plug = _mocked_device(alias="my_plug", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() # The entity_id is based on standard name from core. @@ -97,7 +94,7 @@ async def test_select_children( device_registry: dr.DeviceRegistry, mocked_feature_select: Feature, ) -> None: - """Test a sensor unique ids.""" + """Test select children.""" mocked_feature = mocked_feature_select already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -109,7 +106,7 @@ async def test_select_children( children=_mocked_strip_children(features=[mocked_feature]), ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "select.my_plug_light_preset" @@ -141,7 +138,7 @@ async def test_select_select( already_migrated_config_entry.add_to_hass(hass) plug = _mocked_device(alias="my_plug", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "select.my_plug_light_preset" diff --git a/tests/components/tplink/test_sensor.py b/tests/components/tplink/test_sensor.py index a53b59df0dc..857a2365527 100644 --- a/tests/components/tplink/test_sensor.py +++ b/tests/components/tplink/test_sensor.py @@ -4,18 +4,14 @@ from kasa import Device, Feature, Module import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.tplink.const import DOMAIN from homeassistant.components.tplink.entity import EXCLUDED_FEATURES from homeassistant.components.tplink.sensor import SENSOR_DESCRIPTIONS from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_energy_features, _mocked_feature, @@ -25,6 +21,7 @@ from . import ( setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @@ -36,7 +33,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test a sensor states.""" features = {description.key for description in SENSOR_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -67,7 +64,7 @@ async def test_color_light_with_an_emeter(hass: HomeAssistant) -> None: alias="my_bulb", modules=[Module.Light], features=["state", *emeter_features] ) with _patch_discovery(device=bulb), _patch_connect(device=bulb): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -104,7 +101,7 @@ async def test_plug_with_an_emeter(hass: HomeAssistant) -> None: ) plug = _mocked_device(alias="my_plug", features=["state", *emeter_features]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -131,7 +128,7 @@ async def test_color_light_no_emeter(hass: HomeAssistant) -> None: bulb = _mocked_device(alias="my_bulb", modules=[Module.Light]) with _patch_discovery(device=bulb), _patch_connect(device=bulb): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -167,7 +164,7 @@ async def test_sensor_unique_id( ) plug = _mocked_device(alias="my_plug", features=emeter_features) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() expected = { @@ -202,7 +199,7 @@ async def test_undefined_sensor( ) plug = _mocked_device(alias="my_plug", features=[new_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() msg = ( @@ -240,7 +237,7 @@ async def test_sensor_children_on_parent( device_type=Device.Type.WallSwitch, ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "sensor.my_plug_this_month_s_consumption" @@ -288,7 +285,7 @@ async def test_sensor_children_on_child( device_type=Device.Type.Strip, ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "sensor.my_plug_this_month_s_consumption" @@ -308,19 +305,18 @@ async def test_sensor_children_on_child( assert child_device.via_device_id == device.id -@pytest.mark.skip -async def test_new_datetime_sensor( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_datetime_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: - """Test a sensor unique ids.""" - # Skipped temporarily while datetime handling on hold. + """Test a timestamp sensor.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) plug = _mocked_device(alias="my_plug", features=["on_since"]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "sensor.my_plug_on_since" diff --git a/tests/components/tplink/test_switch.py b/tests/components/tplink/test_switch.py index 47b2e078f5a..bdf54f10e8b 100644 --- a/tests/components/tplink/test_switch.py +++ b/tests/components/tplink/test_switch.py @@ -9,7 +9,11 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import tplink -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.components.tplink.const import DOMAIN from homeassistant.components.tplink.entity import EXCLUDED_FEATURES from homeassistant.components.tplink.switch import SWITCH_DESCRIPTIONS @@ -25,12 +29,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util, slugify from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_strip_children, _patch_connect, @@ -38,6 +39,7 @@ from . import ( setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry, async_fire_time_changed @@ -49,7 +51,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test a switch states.""" features = {description.key for description in SWITCH_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -72,7 +74,7 @@ async def test_plug(hass: HomeAssistant) -> None: plug = _mocked_device(alias="my_plug", features=["state"]) feat = plug.features["state"] with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -80,13 +82,13 @@ async def test_plug(hass: HomeAssistant) -> None: assert state.state == STATE_ON await hass.services.async_call( - SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat.set_value.assert_called_once() feat.set_value.reset_mock() await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat.set_value.assert_called_once() feat.set_value.reset_mock() @@ -120,7 +122,7 @@ async def test_led_switch(hass: HomeAssistant, dev: Device, domain: str) -> None feat = dev.features["led"] already_migrated_config_entry.add_to_hass(hass) with _patch_discovery(device=dev), _patch_connect(device=dev): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_name = slugify(dev.alias) @@ -131,13 +133,13 @@ async def test_led_switch(hass: HomeAssistant, dev: Device, domain: str) -> None assert led_state.name == f"{dev.alias} LED" await hass.services.async_call( - SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: led_entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: led_entity_id}, blocking=True ) feat.set_value.assert_called_once_with(False) feat.set_value.reset_mock() await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: led_entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: led_entity_id}, blocking=True ) feat.set_value.assert_called_once_with(True) feat.set_value.reset_mock() @@ -153,7 +155,7 @@ async def test_plug_unique_id( already_migrated_config_entry.add_to_hass(hass) plug = _mocked_device(alias="my_plug", features=["state", "led"]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -168,7 +170,7 @@ async def test_plug_update_fails(hass: HomeAssistant) -> None: already_migrated_config_entry.add_to_hass(hass) plug = _mocked_device(alias="my_plug", features=["state", "led"]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -197,7 +199,7 @@ async def test_strip(hass: HomeAssistant) -> None: strip.children[0].features["state"].value = True strip.children[1].features["state"].value = False with _patch_discovery(device=strip), _patch_connect(device=strip): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_strip_plug0" @@ -205,14 +207,14 @@ async def test_strip(hass: HomeAssistant) -> None: assert state.state == STATE_ON await hass.services.async_call( - SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat = strip.children[0].features["state"] feat.set_value.assert_called_once() feat.set_value.reset_mock() await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat.set_value.assert_called_once() feat.set_value.reset_mock() @@ -222,14 +224,14 @@ async def test_strip(hass: HomeAssistant) -> None: assert state.state == STATE_OFF await hass.services.async_call( - SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat = strip.children[1].features["state"] feat.set_value.assert_called_once() feat.set_value.reset_mock() await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat.set_value.assert_called_once() feat.set_value.reset_mock() @@ -249,7 +251,7 @@ async def test_strip_unique_ids( features=["state", "led"], ) with _patch_discovery(device=strip), _patch_connect(device=strip): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() for plug_id in range(2): @@ -264,7 +266,7 @@ async def test_strip_blank_alias( entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: - """Test a strip unique id.""" + """Test a strip with blank parent alias.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -276,7 +278,7 @@ async def test_strip_blank_alias( features=["state", "led"], ) with _patch_discovery(device=strip), _patch_connect(device=strip): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() strip_entity_id = "switch.unnamed_ks123" @@ -338,7 +340,7 @@ async def test_plug_errors_when_turned_on( feat.set_value.side_effect = exception_type("test error") with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -349,7 +351,7 @@ async def test_plug_errors_when_turned_on( with pytest.raises(HomeAssistantError, match=msg): await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() assert feat.set_value.call_count == 1 From 1c053485a959dc9024ec10d43ce5c467147eec84 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 13 Jan 2025 19:40:01 +0100 Subject: [PATCH 0380/2987] Bump smhi-pkg to 1.0.19 (#135537) --- homeassistant/components/smhi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json index 76f9812e815..645ace41cab 100644 --- a/homeassistant/components/smhi/manifest.json +++ b/homeassistant/components/smhi/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/smhi", "iot_class": "cloud_polling", "loggers": ["smhi"], - "requirements": ["smhi-pkg==1.0.18"] + "requirements": ["smhi-pkg==1.0.19"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9c6fcf3eebd..3393f72659c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2726,7 +2726,7 @@ slixmpp==1.8.5 smart-meter-texas==0.5.5 # homeassistant.components.smhi -smhi-pkg==1.0.18 +smhi-pkg==1.0.19 # homeassistant.components.snapcast snapcast==2.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e9850bee894..084db292f20 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2193,7 +2193,7 @@ slackclient==2.5.0 smart-meter-texas==0.5.5 # homeassistant.components.smhi -smhi-pkg==1.0.18 +smhi-pkg==1.0.19 # homeassistant.components.snapcast snapcast==2.3.6 From 984c380e13d5e0d99c807889d5214007eac615a2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 13 Jan 2025 19:50:06 +0100 Subject: [PATCH 0381/2987] Add option to allow to use setpoint instead of override for legacy incomfort RF gateway (#135143) * Add option to allow to use setpoint in stead of override for legacy incomfort RF gateway * Add test to assert state with legacy_setpoint_status option * Use selector * Update homeassistant/components/incomfort/strings.json Co-authored-by: Joost Lekkerkerker * Follow up on code review * Rephrase data_description * Rephrase * Use async_schedule_reload helper * Move option flow after config flow --------- Co-authored-by: Joost Lekkerkerker --- .../components/incomfort/__init__.py | 2 +- homeassistant/components/incomfort/climate.py | 15 +- .../components/incomfort/config_flow.py | 57 ++++++- homeassistant/components/incomfort/const.py | 2 + .../components/incomfort/strings.json | 13 ++ tests/components/incomfort/conftest.py | 14 +- .../incomfort/snapshots/test_climate.ambr | 142 +++++++++++++++++- tests/components/incomfort/test_climate.py | 14 +- .../components/incomfort/test_config_flow.py | 39 ++++- 9 files changed, 278 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index 4b6a6a5fcc3..e6775f5baca 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -25,7 +25,7 @@ INTEGRATION_TITLE = "Intergas InComfort/Intouch Lan2RF gateway" type InComfortConfigEntry = ConfigEntry[InComfortDataCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> bool: """Set up a config entry.""" try: data = await async_connect_gateway(hass, dict(entry.data)) diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index caebcfdb23b..545666a826f 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -18,7 +18,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import InComfortConfigEntry -from .const import DOMAIN +from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN from .coordinator import InComfortDataCoordinator from .entity import IncomfortEntity @@ -32,9 +32,12 @@ async def async_setup_entry( ) -> None: """Set up InComfort/InTouch climate devices.""" incomfort_coordinator = entry.runtime_data + legacy_setpoint_status = entry.options.get(CONF_LEGACY_SETPOINT_STATUS, False) heaters = incomfort_coordinator.data.heaters async_add_entities( - InComfortClimate(incomfort_coordinator, h, r) for h in heaters for r in h.rooms + InComfortClimate(incomfort_coordinator, h, r, legacy_setpoint_status) + for h in heaters + for r in h.rooms ) @@ -54,12 +57,14 @@ class InComfortClimate(IncomfortEntity, ClimateEntity): coordinator: InComfortDataCoordinator, heater: InComfortHeater, room: InComfortRoom, + legacy_setpoint_status: bool, ) -> None: """Initialize the climate device.""" super().__init__(coordinator) self._heater = heater self._room = room + self._legacy_setpoint_status = legacy_setpoint_status self._attr_unique_id = f"{heater.serial_no}_{room.room_no}" self._attr_device_info = DeviceInfo( @@ -91,9 +96,11 @@ class InComfortClimate(IncomfortEntity, ClimateEntity): As we set the override, we report back the override. The actual set point is is returned at a later time. - Some older thermostats return 0.0 as override, in that case we fallback to - the actual setpoint. + Some older thermostats do not clear the override setting in that case, in that case + we fallback to the returning actual setpoint. """ + if self._legacy_setpoint_status: + return self._room.setpoint return self._room.override or self._room.setpoint async def async_set_temperature(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py index f4838a9771d..ffaee2a38a4 100644 --- a/homeassistant/components/incomfort/config_flow.py +++ b/homeassistant/components/incomfort/config_flow.py @@ -1,21 +1,30 @@ """Config flow support for Intergas InComfort integration.""" +from __future__ import annotations + from typing import Any from aiohttp import ClientResponseError from incomfortclient import IncomfortError, InvalidHeaterList import voluptuous as vol -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_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.selector import ( + BooleanSelector, + BooleanSelectorConfig, TextSelector, TextSelectorConfig, TextSelectorType, ) -from .const import DOMAIN +from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN from .coordinator import async_connect_gateway TITLE = "Intergas InComfort/Intouch Lan2RF gateway" @@ -34,6 +43,14 @@ CONFIG_SCHEMA = vol.Schema( } ) +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_LEGACY_SETPOINT_STATUS, default=False): BooleanSelector( + BooleanSelectorConfig() + ) + } +) + ERROR_STATUS_MAPPING: dict[int, tuple[str, str]] = { 401: (CONF_PASSWORD, "auth_error"), 404: ("base", "not_found"), @@ -66,6 +83,14 @@ async def async_try_connect_gateway( class InComfortConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow to set up an Intergas InComfort boyler and thermostats.""" + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> InComfortOptionsFlowHandler: + """Get the options flow for this handler.""" + return InComfortOptionsFlowHandler() + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -81,3 +106,29 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=CONFIG_SCHEMA, errors=errors ) + + +class InComfortOptionsFlowHandler(OptionsFlow): + """Handle InComfort Lan2RF gateway options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + errors: dict[str, str] | None = None + if user_input is not None: + new_options: dict[str, Any] = self.config_entry.options | user_input + self.hass.config_entries.async_update_entry( + self.config_entry, options=new_options + ) + self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) + return self.async_create_entry(data=new_options) + + data_schema = self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, self.config_entry.options + ) + return self.async_show_form( + step_id="init", + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/incomfort/const.py b/homeassistant/components/incomfort/const.py index 721dd8591b0..b3b9312acd6 100644 --- a/homeassistant/components/incomfort/const.py +++ b/homeassistant/components/incomfort/const.py @@ -1,3 +1,5 @@ """Constants for Intergas InComfort integration.""" DOMAIN = "incomfort" + +CONF_LEGACY_SETPOINT_STATUS = "legacy_setpoint_status" diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index a2bb874142b..8687be19bb6 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -31,6 +31,19 @@ "unknown": "[%key:component::incomfort::config::abort::unknown%]" } }, + "options": { + "step": { + "init": { + "title": "Intergas InComfort Lan2RF Gateway options", + "data": { + "legacy_setpoint_status": "Legacy setpoint handling" + }, + "data_description": { + "legacy_setpoint_status": "Some older gateway models with an older firmware versions might not update the thermostat setpoint and override settings correctly. Enable this option if you experience issues in updating the setpoint for your thermostat. It will use the actual setpoint of the thermostat instead of the override. As side effect is that it might take a few minutes before the setpoint is updated." + } + } + } + }, "issues": { "deprecated_yaml_import_issue_unknown": { "title": "YAML import failed with unknown error", diff --git a/tests/components/incomfort/conftest.py b/tests/components/incomfort/conftest.py index b00e3a638c8..a6acd79764c 100644 --- a/tests/components/incomfort/conftest.py +++ b/tests/components/incomfort/conftest.py @@ -53,12 +53,22 @@ def mock_entry_data() -> dict[str, Any]: return MOCK_CONFIG +@pytest.fixture +def mock_entry_options() -> dict[str, Any] | None: + """Mock config entry options for fixture.""" + return None + + @pytest.fixture def mock_config_entry( - hass: HomeAssistant, mock_entry_data: dict[str, Any] + hass: HomeAssistant, + mock_entry_data: dict[str, Any], + mock_entry_options: dict[str, Any], ) -> ConfigEntry: """Mock a config entry setup for incomfort integration.""" - entry = MockConfigEntry(domain=DOMAIN, data=mock_entry_data) + entry = MockConfigEntry( + domain=DOMAIN, data=mock_entry_data, options=mock_entry_options + ) entry.add_to_hass(hass) return entry diff --git a/tests/components/incomfort/snapshots/test_climate.ambr b/tests/components/incomfort/snapshots/test_climate.ambr index 17adcbb3bab..bd940bbc2ce 100644 --- a/tests/components/incomfort/snapshots/test_climate.ambr +++ b/tests/components/incomfort/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_platform[legacy_thermostat][climate.thermostat_1-entry] +# name: test_setup_platform[legacy-override][climate.thermostat_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -38,7 +38,73 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup_platform[legacy_thermostat][climate.thermostat_1-state] +# name: test_setup_platform[legacy-override][climate.thermostat_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.4, + 'friendly_name': 'Thermostat 1', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'status': dict({ + 'override': 19.0, + 'room_temp': 21.42, + 'setpoint': 18.0, + }), + 'supported_features': , + 'temperature': 18.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_setup_platform[legacy-zero_override][climate.thermostat_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.thermostat_1', + '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': None, + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c0ffeec0ffee_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_platform[legacy-zero_override][climate.thermostat_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 21.4, @@ -65,7 +131,7 @@ 'state': 'heat', }) # --- -# name: test_setup_platform[new_thermostat][climate.thermostat_1-entry] +# name: test_setup_platform[modern-override][climate.thermostat_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -104,7 +170,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup_platform[new_thermostat][climate.thermostat_1-state] +# name: test_setup_platform[modern-override][climate.thermostat_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 21.4, @@ -116,7 +182,73 @@ 'max_temp': 30.0, 'min_temp': 5.0, 'status': dict({ - 'override': 18.0, + 'override': 19.0, + 'room_temp': 21.42, + 'setpoint': 18.0, + }), + 'supported_features': , + 'temperature': 19.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_setup_platform[modern-zero_override][climate.thermostat_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.thermostat_1', + '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': None, + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c0ffeec0ffee_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_platform[modern-zero_override][climate.thermostat_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.4, + 'friendly_name': 'Thermostat 1', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'status': dict({ + 'override': 0.0, 'room_temp': 21.42, 'setpoint': 18.0, }), diff --git a/tests/components/incomfort/test_climate.py b/tests/components/incomfort/test_climate.py index ae4c1cf31f7..06aa8fc056e 100644 --- a/tests/components/incomfort/test_climate.py +++ b/tests/components/incomfort/test_climate.py @@ -17,10 +17,15 @@ from tests.common import snapshot_platform @pytest.mark.parametrize( "mock_room_status", [ - {"room_temp": 21.42, "setpoint": 18.0, "override": 18.0}, + {"room_temp": 21.42, "setpoint": 18.0, "override": 19.0}, {"room_temp": 21.42, "setpoint": 18.0, "override": 0.0}, ], - ids=["new_thermostat", "legacy_thermostat"], + ids=["override", "zero_override"], +) +@pytest.mark.parametrize( + "mock_entry_options", + [None, {"legacy_setpoint_status": True}], + ids=["modern", "legacy"], ) async def test_setup_platform( hass: HomeAssistant, @@ -31,8 +36,9 @@ async def test_setup_platform( ) -> None: """Test the incomfort entities are set up correctly. - Legacy thermostats report 0.0 as override if no override is set, - but new thermostat sync the override with the actual setpoint instead. + Thermostats report 0.0 as override if no override is set + or when the setpoint has been changed manually, + Some older thermostats do not reset the override setpoint has been changed manually. """ await hass.config_entries.async_setup(mock_config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/incomfort/test_config_flow.py b/tests/components/incomfort/test_config_flow.py index 287fd85715f..ab24728874c 100644 --- a/tests/components/incomfort/test_config_flow.py +++ b/tests/components/incomfort/test_config_flow.py @@ -1,6 +1,7 @@ """Tests for the Intergas InComfort config flow.""" -from unittest.mock import AsyncMock, MagicMock +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientResponseError from incomfortclient import IncomfortError, InvalidHeaterList @@ -113,3 +114,39 @@ async def test_form_validation( ) assert result["type"] is FlowResultType.CREATE_ENTRY assert "errors" not in result + + +@pytest.mark.parametrize( + ("user_input", "legacy_setpoint_status"), + [ + ({}, False), + ({"legacy_setpoint_status": False}, False), + ({"legacy_setpoint_status": True}, True), + ], +) +async def test_options_flow( + hass: HomeAssistant, + mock_incomfort: MagicMock, + user_input: dict[str, Any], + legacy_setpoint_status: bool, +) -> None: + """Test options flow.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + with patch("homeassistant.components.incomfort.async_setup_entry") as restart_mock: + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert restart_mock.call_count == 1 + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == {"legacy_setpoint_status": legacy_setpoint_status} + assert entry.options.get("legacy_setpoint_status", False) is legacy_setpoint_status From ca34541b04671e30bae88f6b2b9d91d4d6babae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 13 Jan 2025 20:06:19 +0100 Subject: [PATCH 0382/2987] Register Airzone WebServer device (#135538) --- homeassistant/components/airzone/__init__.py | 26 +++++++++++++++++++- homeassistant/components/airzone/entity.py | 6 +++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/__init__.py b/homeassistant/components/airzone/__init__.py index 5d1f9f051a3..39e4f73aa38 100644 --- a/homeassistant/components/airzone/__init__.py +++ b/homeassistant/components/airzone/__init__.py @@ -5,7 +5,14 @@ from __future__ import annotations import logging from typing import Any -from aioairzone.const import AZD_MAC, AZD_WEBSERVER, DEFAULT_SYSTEM_ID +from aioairzone.const import ( + AZD_FIRMWARE, + AZD_FULL_NAME, + AZD_MAC, + AZD_MODEL, + AZD_WEBSERVER, + DEFAULT_SYSTEM_ID, +) from aioairzone.localapi import AirzoneLocalApi, ConnectionOptions from homeassistant.config_entries import ConfigEntry @@ -17,6 +24,7 @@ from homeassistant.helpers import ( entity_registry as er, ) +from .const import DOMAIN, MANUFACTURER from .coordinator import AirzoneUpdateCoordinator PLATFORMS: list[Platform] = [ @@ -88,6 +96,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b entry.runtime_data = coordinator + device_registry = dr.async_get(hass) + + ws_data: dict[str, Any] | None = coordinator.data.get(AZD_WEBSERVER) + if ws_data is not None: + mac = ws_data.get(AZD_MAC, "") + + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={(DOMAIN, f"{entry.entry_id}_ws")}, + manufacturer=MANUFACTURER, + model=ws_data.get(AZD_MODEL), + name=ws_data.get(AZD_FULL_NAME), + sw_version=ws_data.get(AZD_FIRMWARE), + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index 61f79eabf52..59d58fb62b0 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -68,8 +68,9 @@ class AirzoneSystemEntity(AirzoneEntity): model=self.get_airzone_value(AZD_MODEL), name=f"System {self.system_id}", sw_version=self.get_airzone_value(AZD_FIRMWARE), - via_device=(DOMAIN, f"{entry.entry_id}_ws"), ) + if AZD_WEBSERVER in self.coordinator.data: + self._attr_device_info["via_device"] = (DOMAIN, f"{entry.entry_id}_ws") self._attr_unique_id = entry.unique_id or entry.entry_id @property @@ -102,8 +103,9 @@ class AirzoneHotWaterEntity(AirzoneEntity): manufacturer=MANUFACTURER, model="DHW", name=self.get_airzone_value(AZD_NAME), - via_device=(DOMAIN, f"{entry.entry_id}_ws"), ) + if AZD_WEBSERVER in self.coordinator.data: + self._attr_device_info["via_device"] = (DOMAIN, f"{entry.entry_id}_ws") self._attr_unique_id = entry.unique_id or entry.entry_id def get_airzone_value(self, key: str) -> Any: From 2d2f4f5cec52fd926b48a875a9f071c9a78b78b0 Mon Sep 17 00:00:00 2001 From: qbus-iot Date: Mon, 13 Jan 2025 20:06:52 +0100 Subject: [PATCH 0383/2987] Add new integration Qbus (#127280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa Co-authored-by: Thomas D <11554546+thomasddn@users.noreply.github.com> --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/qbus/__init__.py | 87 ++++++ homeassistant/components/qbus/config_flow.py | 160 ++++++++++ homeassistant/components/qbus/const.py | 12 + homeassistant/components/qbus/coordinator.py | 279 ++++++++++++++++++ homeassistant/components/qbus/entity.py | 76 +++++ homeassistant/components/qbus/manifest.json | 17 ++ .../components/qbus/quality_scale.yaml | 89 ++++++ homeassistant/components/qbus/strings.json | 19 ++ homeassistant/components/qbus/switch.py | 83 ++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/mqtt.py | 5 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/qbus/__init__.py | 1 + tests/components/qbus/conftest.py | 33 +++ tests/components/qbus/const.py | 4 + .../qbus/fixtures/payload_config.json | 49 +++ tests/components/qbus/test_config_flow.py | 202 +++++++++++++ tests/components/qbus/test_switch.py | 84 ++++++ 23 files changed, 1226 insertions(+) create mode 100644 homeassistant/components/qbus/__init__.py create mode 100644 homeassistant/components/qbus/config_flow.py create mode 100644 homeassistant/components/qbus/const.py create mode 100644 homeassistant/components/qbus/coordinator.py create mode 100644 homeassistant/components/qbus/entity.py create mode 100644 homeassistant/components/qbus/manifest.json create mode 100644 homeassistant/components/qbus/quality_scale.yaml create mode 100644 homeassistant/components/qbus/strings.json create mode 100644 homeassistant/components/qbus/switch.py create mode 100644 tests/components/qbus/__init__.py create mode 100644 tests/components/qbus/conftest.py create mode 100644 tests/components/qbus/const.py create mode 100644 tests/components/qbus/fixtures/payload_config.json create mode 100644 tests/components/qbus/test_config_flow.py create mode 100644 tests/components/qbus/test_switch.py diff --git a/.strict-typing b/.strict-typing index 97b1301fdd7..1d5d220efc1 100644 --- a/.strict-typing +++ b/.strict-typing @@ -385,6 +385,7 @@ homeassistant.components.purpleair.* homeassistant.components.pushbullet.* homeassistant.components.pvoutput.* homeassistant.components.python_script.* +homeassistant.components.qbus.* homeassistant.components.qnap_qsw.* homeassistant.components.rabbitair.* homeassistant.components.radarr.* diff --git a/CODEOWNERS b/CODEOWNERS index 748d461d3ce..9517cc86139 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1191,6 +1191,8 @@ build.json @home-assistant/supervisor /tests/components/pyload/ @tr4nt0r /homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39 /tests/components/qbittorrent/ @geoffreylagaisse @finder39 +/homeassistant/components/qbus/ @Qbus-iot @thomasddn +/tests/components/qbus/ @Qbus-iot @thomasddn /homeassistant/components/qingping/ @bdraco /tests/components/qingping/ @bdraco /homeassistant/components/qld_bushfire/ @exxamalte diff --git a/homeassistant/components/qbus/__init__.py b/homeassistant/components/qbus/__init__.py new file mode 100644 index 00000000000..da9dcfe69be --- /dev/null +++ b/homeassistant/components/qbus/__init__.py @@ -0,0 +1,87 @@ +"""The Qbus integration.""" + +import logging + +from homeassistant.components.mqtt import async_wait_for_mqtt_client +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, PLATFORMS +from .coordinator import ( + QBUS_KEY, + QbusConfigCoordinator, + QbusConfigEntry, + QbusControllerCoordinator, +) + +_LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Qbus integration. + + We set up a single coordinator for managing Qbus config updates. The + config update contains the configuration for all controllers (and + config entries). This avoids having each device requesting and managing + the config on its own. + """ + _LOGGER.debug("Loading integration") + + if not await async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration not available") + return False + + config_coordinator = QbusConfigCoordinator.get_or_create(hass) + await config_coordinator.async_subscribe_to_config() + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: QbusConfigEntry) -> bool: + """Set up Qbus from a config entry.""" + _LOGGER.debug("%s - Loading entry", entry.unique_id) + + if not await async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration not available") + raise ConfigEntryNotReady("MQTT integration not available") + + coordinator = QbusControllerCoordinator(hass, entry) + entry.runtime_data = coordinator + + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Get current config + config = await QbusConfigCoordinator.get_or_create( + hass + ).async_get_or_request_config() + + # Update the controller config + if config: + await coordinator.async_update_controller_config(config) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: QbusConfigEntry) -> bool: + """Unload a config entry.""" + _LOGGER.debug("%s - Unloading entry", entry.unique_id) + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + entry.runtime_data.shutdown() + cleanup(hass, entry) + + return unload_ok + + +def cleanup(hass: HomeAssistant, entry: QbusConfigEntry) -> None: + """Shutdown if no more entries are loaded.""" + entries = hass.config_entries.async_loaded_entries(DOMAIN) + count = len(entries) + + # During unloading of the entry, it is not marked as unloaded yet. So + # count can be 1 if it is the last one. + if count <= 1 and (config_coordinator := hass.data.get(QBUS_KEY)): + config_coordinator.shutdown() diff --git a/homeassistant/components/qbus/config_flow.py b/homeassistant/components/qbus/config_flow.py new file mode 100644 index 00000000000..2f08c5b47e2 --- /dev/null +++ b/homeassistant/components/qbus/config_flow.py @@ -0,0 +1,160 @@ +"""Config flow for Qbus.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from qbusmqttapi.discovery import QbusMqttDevice +from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory + +from homeassistant.components.mqtt import client as mqtt +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ID +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from .const import CONF_SERIAL_NUMBER, DOMAIN +from .coordinator import QbusConfigCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class QbusFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle Qbus config flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize.""" + self._message_factory = QbusMqttMessageFactory() + self._topic_factory = QbusMqttTopicFactory() + + self._gateway_topic = self._topic_factory.get_gateway_state_topic() + self._config_topic = self._topic_factory.get_config_topic() + self._device_topic = self._topic_factory.get_device_state_topic("+") + + self._device: QbusMqttDevice | None = None + + async def async_step_mqtt( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by MQTT discovery.""" + _LOGGER.debug("Running mqtt discovery for topic %s", discovery_info.topic) + + # Abort if the payload is empty + if not discovery_info.payload: + _LOGGER.debug("Payload empty") + return self.async_abort(reason="invalid_discovery_info") + + match discovery_info.subscribed_topic: + case self._gateway_topic: + return await self._async_handle_gateway_topic(discovery_info) + + case self._config_topic: + return await self._async_handle_config_topic(discovery_info) + + case self._device_topic: + return await self._async_handle_device_topic(discovery_info) + + return self.async_abort(reason="invalid_discovery_info") + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the setup.""" + if TYPE_CHECKING: + assert self._device is not None + + if user_input is not None: + return self.async_create_entry( + title=f"Controller {self._device.serial_number}", + data={ + CONF_SERIAL_NUMBER: self._device.serial_number, + CONF_ID: self._device.id, + }, + ) + + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={ + CONF_SERIAL_NUMBER: self._device.serial_number, + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + return self.async_abort(reason="not_supported") + + async def _async_handle_gateway_topic( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + _LOGGER.debug("Handling gateway state") + gateway_state = self._message_factory.parse_gateway_state( + discovery_info.payload + ) + + if gateway_state is not None and gateway_state.online is True: + _LOGGER.debug("Requesting config") + await mqtt.async_publish( + self.hass, self._topic_factory.get_get_config_topic(), b"" + ) + + # Abort to wait for config topic + return self.async_abort(reason="discovery_in_progress") + + async def _async_handle_config_topic( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + _LOGGER.debug("Handling config topic") + qbus_config = self._message_factory.parse_discovery(discovery_info.payload) + + if qbus_config is not None: + QbusConfigCoordinator.get_or_create(self.hass).store_config(qbus_config) + + _LOGGER.debug("Requesting device states") + device_ids = [x.id for x in qbus_config.devices] + request = self._message_factory.create_state_request(device_ids) + await mqtt.async_publish(self.hass, request.topic, request.payload) + + # Abort to wait for device topic + return self.async_abort(reason="discovery_in_progress") + + async def _async_handle_device_topic( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + _LOGGER.debug("Discovering device") + qbus_config = await QbusConfigCoordinator.get_or_create( + self.hass + ).async_get_or_request_config() + + if qbus_config is None: + _LOGGER.error("Qbus config not ready") + return self.async_abort(reason="invalid_discovery_info") + + device_id = discovery_info.topic.split("/")[2] + self._device = qbus_config.get_device_by_id(device_id) + + if self._device is None: + _LOGGER.warning("Device with id '%s' not found in config", device_id) + return self.async_abort(reason="invalid_discovery_info") + + await self.async_set_unique_id(self._device.serial_number) + + # Do not use error message "already_configured" (which is the + # default), as this will result in unsubscribing from the triggered + # mqtt topic. The topic subscribed to has a wildcard to allow + # discovery of multiple devices. Unsubscribing would result in + # not discovering new or unconfigured devices. + self._abort_if_unique_id_configured(error="device_already_configured") + + self.context.update( + { + "title_placeholders": { + CONF_SERIAL_NUMBER: self._device.serial_number, + } + } + ) + + return await self.async_step_discovery_confirm() diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py new file mode 100644 index 00000000000..ddfb8963cb7 --- /dev/null +++ b/homeassistant/components/qbus/const.py @@ -0,0 +1,12 @@ +"""Constants for the Qbus integration.""" + +from typing import Final + +from homeassistant.const import Platform + +DOMAIN: Final = "qbus" +PLATFORMS: list[Platform] = [Platform.SWITCH] + +CONF_SERIAL_NUMBER: Final = "serial" + +MANUFACTURER: Final = "Qbus" diff --git a/homeassistant/components/qbus/coordinator.py b/homeassistant/components/qbus/coordinator.py new file mode 100644 index 00000000000..dd57a98787b --- /dev/null +++ b/homeassistant/components/qbus/coordinator.py @@ -0,0 +1,279 @@ +"""Qbus coordinator.""" + +from __future__ import annotations + +from datetime import datetime +import logging +from typing import cast + +from qbusmqttapi.discovery import QbusDiscovery, QbusMqttDevice, QbusMqttOutput +from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory + +from homeassistant.components.mqtt import ( + ReceiveMessage, + async_wait_for_mqtt_client, + client as mqtt, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util.hass_dict import HassKey + +from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +type QbusConfigEntry = ConfigEntry[QbusControllerCoordinator] +QBUS_KEY: HassKey[QbusConfigCoordinator] = HassKey(DOMAIN) + + +class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]): + """Qbus data coordinator.""" + + _STATE_REQUEST_DELAY = 3 + + def __init__(self, hass: HomeAssistant, entry: QbusConfigEntry) -> None: + """Initialize Qbus coordinator.""" + + _LOGGER.debug("%s - Initializing coordinator", entry.unique_id) + self.config_entry: QbusConfigEntry + + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=entry.unique_id or entry.entry_id, + always_update=False, + ) + + self._message_factory = QbusMqttMessageFactory() + self._topic_factory = QbusMqttTopicFactory() + + self._controller_activated = False + self._subscribed_to_controller_state = False + self._controller: QbusMqttDevice | None = None + + # Clean up when HA stops + self.config_entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) + ) + + async def _async_update_data(self) -> list[QbusMqttOutput]: + return self._controller.outputs if self._controller else [] + + def shutdown(self, event: Event | None = None) -> None: + """Shutdown Qbus coordinator.""" + _LOGGER.debug( + "%s - Shutting down entry coordinator", self.config_entry.unique_id + ) + + self._controller_activated = False + self._subscribed_to_controller_state = False + self._controller = None + + async def async_update_controller_config(self, config: QbusDiscovery) -> None: + """Update the controller based on the config.""" + _LOGGER.debug("%s - Updating config", self.config_entry.unique_id) + serial = self.config_entry.data.get(CONF_SERIAL_NUMBER, "") + controller = config.get_device_by_serial(serial) + + if controller is None: + _LOGGER.warning( + "%s - Controller with serial %s not found", + self.config_entry.unique_id, + serial, + ) + return + + self._controller = controller + + self._update_device_info() + await self._async_subscribe_to_controller_state() + await self.async_refresh() + self._request_controller_state() + self._request_entity_states() + + def _update_device_info(self) -> None: + if self._controller is None: + return + + device_registry = dr.async_get(self.hass) + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, format_mac(self._controller.mac))}, + manufacturer=MANUFACTURER, + model="CTD3.x", + name=f"CTD {self._controller.serial_number}", + serial_number=self._controller.serial_number, + sw_version=self._controller.version, + ) + + async def _async_subscribe_to_controller_state(self) -> None: + if self._controller is None or self._subscribed_to_controller_state is True: + return + + controller_state_topic = self._topic_factory.get_device_state_topic( + self._controller.id + ) + _LOGGER.debug( + "%s - Subscribing to %s", + self.config_entry.unique_id, + controller_state_topic, + ) + self._subscribed_to_controller_state = True + self.config_entry.async_on_unload( + await mqtt.async_subscribe( + self.hass, + controller_state_topic, + self._controller_state_received, + ) + ) + + async def _controller_state_received(self, msg: ReceiveMessage) -> None: + _LOGGER.debug( + "%s - Receiving controller state %s", self.config_entry.unique_id, msg.topic + ) + + if self._controller is None or self._controller_activated: + return + + state = self._message_factory.parse_device_state(msg.payload) + + if state and state.properties and state.properties.connectable is False: + _LOGGER.debug( + "%s - Activating controller %s", self.config_entry.unique_id, state.id + ) + self._controller_activated = True + request = self._message_factory.create_device_activate_request( + self._controller + ) + await mqtt.async_publish(self.hass, request.topic, request.payload) + + def _request_entity_states(self) -> None: + async def request_state(_: datetime) -> None: + if self._controller is None: + return + + _LOGGER.debug( + "%s - Requesting %s entity states", + self.config_entry.unique_id, + len(self._controller.outputs), + ) + + request = self._message_factory.create_state_request( + [item.id for item in self._controller.outputs] + ) + + await mqtt.async_publish(self.hass, request.topic, request.payload) + + if self._controller and len(self._controller.outputs) > 0: + async_call_later(self.hass, self._STATE_REQUEST_DELAY, request_state) + + def _request_controller_state(self) -> None: + async def request_controller_state(_: datetime) -> None: + if self._controller is None: + return + + _LOGGER.debug( + "%s - Requesting controller state", self.config_entry.unique_id + ) + request = self._message_factory.create_device_state_request( + self._controller + ) + await mqtt.async_publish(self.hass, request.topic, request.payload) + + if self._controller: + async_call_later( + self.hass, self._STATE_REQUEST_DELAY, request_controller_state + ) + + +class QbusConfigCoordinator: + """Class responsible for Qbus config updates.""" + + _qbus_config: QbusDiscovery | None = None + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize config coordinator.""" + + self._hass = hass + self._message_factory = QbusMqttMessageFactory() + self._topic_factory = QbusMqttTopicFactory() + self._cleanup_callbacks: list[CALLBACK_TYPE] = [] + + self._cleanup_callbacks.append( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) + ) + + @classmethod + def get_or_create(cls, hass: HomeAssistant) -> QbusConfigCoordinator: + """Get the coordinator and create if necessary.""" + if (coordinator := hass.data.get(QBUS_KEY)) is None: + coordinator = cls(hass) + hass.data[QBUS_KEY] = coordinator + + return coordinator + + def shutdown(self, event: Event | None = None) -> None: + """Shutdown Qbus config coordinator.""" + _LOGGER.debug("Shutting down Qbus config coordinator") + while self._cleanup_callbacks: + cleanup_callback = self._cleanup_callbacks.pop() + cleanup_callback() + + async def async_subscribe_to_config(self) -> None: + """Subscribe to config changes.""" + config_topic = self._topic_factory.get_config_topic() + _LOGGER.debug("Subscribing to %s", config_topic) + + self._cleanup_callbacks.append( + await mqtt.async_subscribe(self._hass, config_topic, self._config_received) + ) + + async def async_get_or_request_config(self) -> QbusDiscovery | None: + """Get or request Qbus config.""" + _LOGGER.debug("Requesting Qbus config") + + # Config already available + if self._qbus_config: + _LOGGER.debug("Qbus config already available") + return self._qbus_config + + if not await async_wait_for_mqtt_client(self._hass): + _LOGGER.debug("MQTT client not ready yet") + return None + + # Request config + _LOGGER.debug("Publishing config request") + await mqtt.async_publish( + self._hass, self._topic_factory.get_get_config_topic(), b"" + ) + + return self._qbus_config + + def store_config(self, config: QbusDiscovery) -> None: + "Store the Qbus config." + _LOGGER.debug("Storing config") + + self._qbus_config = config + + async def _config_received(self, msg: ReceiveMessage) -> None: + """Handle the received MQTT message containing the Qbus config.""" + _LOGGER.debug("Receiving Qbus config") + + config = self._message_factory.parse_discovery(msg.payload) + + if config is None: + _LOGGER.debug("Incomplete Qbus config") + return + + self.store_config(config) + + for entry in self._hass.config_entries.async_loaded_entries(DOMAIN): + entry = cast(QbusConfigEntry, entry) + await entry.runtime_data.async_update_controller_config(config) diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py new file mode 100644 index 00000000000..39bcddaaf4f --- /dev/null +++ b/homeassistant/components/qbus/entity.py @@ -0,0 +1,76 @@ +"""Base class for Qbus entities.""" + +from abc import ABC, abstractmethod +import re + +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory +from qbusmqttapi.state import QbusMqttState + +from homeassistant.components.mqtt import ReceiveMessage, client as mqtt +from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, MANUFACTURER + +_REFID_REGEX = re.compile(r"^\d+\/(\d+(?:\/\d+)?)$") + + +def format_ref_id(ref_id: str) -> str | None: + """Format the Qbus ref_id.""" + matches: list[str] = re.findall(_REFID_REGEX, ref_id) + + if len(matches) > 0: + if ref_id := matches[0]: + return ref_id.replace("/", "-") + + return None + + +class QbusEntity(Entity, ABC): + """Representation of a Qbus entity.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_should_poll = False + + def __init__(self, mqtt_output: QbusMqttOutput) -> None: + """Initialize the Qbus entity.""" + + self._topic_factory = QbusMqttTopicFactory() + self._message_factory = QbusMqttMessageFactory() + + ref_id = format_ref_id(mqtt_output.ref_id) + + self._attr_unique_id = f"ctd_{mqtt_output.device.serial_number}_{ref_id}" + + self._attr_device_info = DeviceInfo( + name=mqtt_output.name.title(), + manufacturer=MANUFACTURER, + identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")}, + suggested_area=mqtt_output.location.title(), + via_device=(DOMAIN, format_mac(mqtt_output.device.mac)), + ) + + self._mqtt_output = mqtt_output + self._state_topic = self._topic_factory.get_output_state_topic( + mqtt_output.device.id, mqtt_output.id + ) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + self.async_on_remove( + await mqtt.async_subscribe( + self.hass, self._state_topic, self._state_received + ) + ) + + @abstractmethod + async def _state_received(self, msg: ReceiveMessage) -> None: + pass + + async def _async_publish_output_state(self, state: QbusMqttState) -> None: + request = self._message_factory.create_set_output_state_request( + self._mqtt_output.device, state + ) + await mqtt.async_publish(self.hass, request.topic, request.payload) diff --git a/homeassistant/components/qbus/manifest.json b/homeassistant/components/qbus/manifest.json new file mode 100644 index 00000000000..ac76110363f --- /dev/null +++ b/homeassistant/components/qbus/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "qbus", + "name": "Qbus", + "codeowners": ["@Qbus-iot", "@thomasddn"], + "config_flow": true, + "dependencies": ["mqtt"], + "documentation": "https://www.home-assistant.io/integrations/qbus", + "integration_type": "hub", + "iot_class": "local_push", + "mqtt": [ + "cloudapp/QBUSMQTTGW/state", + "cloudapp/QBUSMQTTGW/config", + "cloudapp/QBUSMQTTGW/+/state" + ], + "quality_scale": "bronze", + "requirements": ["qbusmqttapi==1.2.3"] +} diff --git a/homeassistant/components/qbus/quality_scale.yaml b/homeassistant/components/qbus/quality_scale.yaml new file mode 100644 index 00000000000..7e106ef6b93 --- /dev/null +++ b/homeassistant/components/qbus/quality_scale.yaml @@ -0,0 +1,89 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: + status: exempt + comment: | + The integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: + status: exempt + comment: | + The integration relies solely on auto-discovery. + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + The integration does not provide any additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options flow. + docs-installation-parameters: + status: exempt + comment: There are no parameters. + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: + status: exempt + comment: The integration does not require authentication. + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: + status: exempt + comment: The integration uses the name of what the user configured in the closed system. + exception-translations: todo + icon-translations: + status: exempt + comment: The integration creates unknown number of entities based on what is in the closed system and does not know what each entity stands for. + reconfiguration-flow: + status: exempt + comment: The integration has no settings. + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: The integration does not make HTTP requests. + strict-typing: done diff --git a/homeassistant/components/qbus/strings.json b/homeassistant/components/qbus/strings.json new file mode 100644 index 00000000000..b8918497c41 --- /dev/null +++ b/homeassistant/components/qbus/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "flow_title": "Controller {serial}", + "step": { + "discovery_confirm": { + "title": "Add controller", + "description": "Add controller {serial}?" + } + }, + "abort": { + "already_configured": "Controller already configured", + "discovery_in_progress": "Discovery in progress", + "not_supported": "Configuration for QBUS happens through MQTT discovery. If your controller is not automatically discovered, check the prerequisites and troubleshooting section of the documention." + }, + "error": { + "no_controller": "No controllers were found" + } + } +} diff --git a/homeassistant/components/qbus/switch.py b/homeassistant/components/qbus/switch.py new file mode 100644 index 00000000000..2413b8f152f --- /dev/null +++ b/homeassistant/components/qbus/switch.py @@ -0,0 +1,83 @@ +"""Support for Qbus switch.""" + +from typing import Any + +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.state import QbusMqttOnOffState, StateType + +from homeassistant.components.mqtt import ReceiveMessage +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import QbusConfigEntry +from .entity import QbusEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, entry: QbusConfigEntry, add_entities: AddEntitiesCallback +) -> None: + """Set up switch entities.""" + coordinator = entry.runtime_data + + added_outputs: list[QbusMqttOutput] = [] + + # Local function that calls add_entities for new entities + def _check_outputs() -> None: + added_output_ids = {k.id for k in added_outputs} + + new_outputs = [ + item + for item in coordinator.data + if item.type == "onoff" and item.id not in added_output_ids + ] + + if new_outputs: + added_outputs.extend(new_outputs) + add_entities([QbusSwitch(output) for output in new_outputs]) + + _check_outputs() + entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + + +class QbusSwitch(QbusEntity, SwitchEntity): + """Representation of a Qbus switch entity.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__( + self, + mqtt_output: QbusMqttOutput, + ) -> None: + """Initialize switch entity.""" + + super().__init__(mqtt_output) + + self._attr_is_on = False + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + state = QbusMqttOnOffState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_value(True) + + await self._async_publish_output_state(state) + self._attr_is_on = True + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + state = QbusMqttOnOffState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_value(False) + + await self._async_publish_output_state(state) + self._attr_is_on = False + + async def _state_received(self, msg: ReceiveMessage) -> None: + output = self._message_factory.parse_output_state( + QbusMqttOnOffState, msg.payload + ) + + if output is not None: + self._attr_is_on = output.read_value() + self.async_schedule_update_ha_state() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 49db871cb55..b393e5c8851 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -489,6 +489,7 @@ FLOWS = { "pvpc_hourly_pricing", "pyload", "qbittorrent", + "qbus", "qingping", "qnap", "qnap_qsw", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bf395336707..2ee871964c9 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4981,6 +4981,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "qbus": { + "name": "Qbus", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "qingping": { "name": "Qingping", "integration_type": "hub", diff --git a/homeassistant/generated/mqtt.py b/homeassistant/generated/mqtt.py index f73388b203c..72f160ee2ec 100644 --- a/homeassistant/generated/mqtt.py +++ b/homeassistant/generated/mqtt.py @@ -16,6 +16,11 @@ MQTT = { "fully_kiosk": [ "fully/deviceInfo/+", ], + "qbus": [ + "cloudapp/QBUSMQTTGW/state", + "cloudapp/QBUSMQTTGW/config", + "cloudapp/QBUSMQTTGW/+/state", + ], "tasmota": [ "tasmota/discovery/#", ], diff --git a/mypy.ini b/mypy.ini index 617d26545c6..5b6824250e2 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3606,6 +3606,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.qbus.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.qnap_qsw.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 3393f72659c..02a8320f8a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2556,6 +2556,9 @@ pyzerproc==0.4.8 # homeassistant.components.qbittorrent qbittorrent-api==2024.2.59 +# homeassistant.components.qbus +qbusmqttapi==1.2.3 + # homeassistant.components.qingping qingping-ble==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 084db292f20..642d9416dbc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2068,6 +2068,9 @@ pyzerproc==0.4.8 # homeassistant.components.qbittorrent qbittorrent-api==2024.2.59 +# homeassistant.components.qbus +qbusmqttapi==1.2.3 + # homeassistant.components.qingping qingping-ble==0.10.0 diff --git a/tests/components/qbus/__init__.py b/tests/components/qbus/__init__.py new file mode 100644 index 00000000000..e8c002d1ed9 --- /dev/null +++ b/tests/components/qbus/__init__.py @@ -0,0 +1 @@ +"""Tests for the Qbus integration.""" diff --git a/tests/components/qbus/conftest.py b/tests/components/qbus/conftest.py new file mode 100644 index 00000000000..8268d091bda --- /dev/null +++ b/tests/components/qbus/conftest.py @@ -0,0 +1,33 @@ +"""Test fixtures for qbus.""" + +import pytest + +from homeassistant.components.qbus.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.const import CONF_ID +from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonObjectType + +from .const import FIXTURE_PAYLOAD_CONFIG + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="000001", + data={ + CONF_ID: "UL1", + CONF_SERIAL_NUMBER: "000001", + }, + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture +def payload_config() -> JsonObjectType: + """Return the config topic payload.""" + return load_json_object_fixture(FIXTURE_PAYLOAD_CONFIG, DOMAIN) diff --git a/tests/components/qbus/const.py b/tests/components/qbus/const.py new file mode 100644 index 00000000000..408ef59d5b1 --- /dev/null +++ b/tests/components/qbus/const.py @@ -0,0 +1,4 @@ +"""Define const for unit tests.""" + +FIXTURE_PAYLOAD_CONFIG = "payload_config.json" +TOPIC_CONFIG = "cloudapp/QBUSMQTTGW/config" diff --git a/tests/components/qbus/fixtures/payload_config.json b/tests/components/qbus/fixtures/payload_config.json new file mode 100644 index 00000000000..2ee38a9927e --- /dev/null +++ b/tests/components/qbus/fixtures/payload_config.json @@ -0,0 +1,49 @@ +{ + "app": "abc", + "devices": [ + { + "id": "UL1", + "ip": "192.168.1.123", + "mac": "001122334455", + "name": "", + "serialNr": "000001", + "type": "Qbus", + "version": "3.14.0", + "properties": { + "connectable": { + "read": true, + "type": "boolean", + "write": false + }, + "connected": { + "read": true, + "type": "boolean", + "write": false + } + }, + "functionBlocks": [ + { + "id": "UL10", + "location": "Living", + "locationId": 0, + "name": "LIVING", + "originalName": "LIVING", + "refId": "000001/10", + "type": "onoff", + "variant": [null], + "actions": { + "off": null, + "on": null + }, + "properties": { + "value": { + "read": true, + "type": "boolean", + "write": true + } + } + } + ] + } + ] +} diff --git a/tests/components/qbus/test_config_flow.py b/tests/components/qbus/test_config_flow.py new file mode 100644 index 00000000000..4f94f2bb277 --- /dev/null +++ b/tests/components/qbus/test_config_flow.py @@ -0,0 +1,202 @@ +"""Test config flow.""" + +import json +import time +from unittest.mock import patch + +import pytest +from qbusmqttapi.discovery import QbusDiscovery + +from homeassistant.components.qbus.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.components.qbus.coordinator import QbusConfigCoordinator +from homeassistant.config_entries import SOURCE_MQTT, SOURCE_USER +from homeassistant.const import CONF_ID +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo +from homeassistant.util.json import JsonObjectType + +from .const import TOPIC_CONFIG + +_PAYLOAD_DEVICE_STATE = '{"id":"UL1","properties":{"connected":true},"type":"event"}' + + +async def test_step_discovery_confirm_create_entry( + hass: HomeAssistant, payload_config: JsonObjectType +) -> None: + """Test mqtt confirm step and entry creation.""" + discovery = MqttServiceInfo( + subscribed_topic="cloudapp/QBUSMQTTGW/+/state", + topic="cloudapp/QBUSMQTTGW/UL1/state", + payload=_PAYLOAD_DEVICE_STATE, + qos=0, + retain=False, + timestamp=time.time(), + ) + + with ( + patch.object( + QbusConfigCoordinator, + "async_get_or_request_config", + return_value=QbusDiscovery(payload_config), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("data") == { + CONF_ID: "UL1", + CONF_SERIAL_NUMBER: "000001", + } + assert result.get("result").unique_id == "000001" + + +@pytest.mark.parametrize( + ("topic", "payload"), + [ + ("cloudapp/QBUSMQTTGW/state", b""), + ("invalid/topic", b"{}"), + ], +) +async def test_step_mqtt_invalid( + hass: HomeAssistant, topic: str, payload: bytes +) -> None: + """Test mqtt discovery with empty payload.""" + discovery = MqttServiceInfo( + subscribed_topic=topic, + topic=topic, + payload=payload, + qos=0, + retain=False, + timestamp=time.time(), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "invalid_discovery_info" + + +@pytest.mark.parametrize( + ("payload", "mqtt_publish"), + [ + ('{ "online": true }', True), + ('{ "online": false }', False), + ], +) +async def test_handle_gateway_topic_when_online( + hass: HomeAssistant, payload: str, mqtt_publish: bool +) -> None: + """Test handling of gateway topic with payload indicating online.""" + discovery = MqttServiceInfo( + subscribed_topic="cloudapp/QBUSMQTTGW/state", + topic="cloudapp/QBUSMQTTGW/state", + payload=payload, + qos=0, + retain=False, + timestamp=time.time(), + ) + + with ( + patch("homeassistant.components.mqtt.client.async_publish") as mock_publish, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert mock_publish.called is mqtt_publish + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "discovery_in_progress" + + +async def test_handle_config_topic( + hass: HomeAssistant, payload_config: JsonObjectType +) -> None: + """Test handling of config topic.""" + + discovery = MqttServiceInfo( + subscribed_topic=TOPIC_CONFIG, + topic=TOPIC_CONFIG, + payload=json.dumps(payload_config), + qos=0, + retain=False, + timestamp=time.time(), + ) + + with ( + patch("homeassistant.components.mqtt.client.async_publish") as mock_publish, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert mock_publish.called + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "discovery_in_progress" + + +async def test_handle_device_topic_missing_config(hass: HomeAssistant) -> None: + """Test handling of device topic when config is missing.""" + discovery = MqttServiceInfo( + subscribed_topic="cloudapp/QBUSMQTTGW/+/state", + topic="cloudapp/QBUSMQTTGW/UL1/state", + payload=_PAYLOAD_DEVICE_STATE, + qos=0, + retain=False, + timestamp=time.time(), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "invalid_discovery_info" + + +async def test_handle_device_topic_device_not_found( + hass: HomeAssistant, payload_config: JsonObjectType +) -> None: + """Test handling of device topic when device is not found.""" + discovery = MqttServiceInfo( + subscribed_topic="cloudapp/QBUSMQTTGW/+/state", + topic="cloudapp/QBUSMQTTGW/UL2/state", + payload='{"id":"UL2","properties":{"connected":true},"type":"event"}', + qos=0, + retain=False, + timestamp=time.time(), + ) + + with patch.object( + QbusConfigCoordinator, + "async_get_or_request_config", + return_value=QbusDiscovery(payload_config), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "invalid_discovery_info" + + +async def test_step_user_not_supported(hass: HomeAssistant) -> None: + """Test user step, which should abort.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "not_supported" diff --git a/tests/components/qbus/test_switch.py b/tests/components/qbus/test_switch.py new file mode 100644 index 00000000000..83bb667e4eb --- /dev/null +++ b/tests/components/qbus/test_switch.py @@ -0,0 +1,84 @@ +"""Test Qbus switch entities.""" + +import json + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonObjectType + +from .const import TOPIC_CONFIG + +from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.typing import MqttMockHAClient + +_PAYLOAD_SWITCH_STATE_ON = '{"id":"UL10","properties":{"value":true},"type":"state"}' +_PAYLOAD_SWITCH_STATE_OFF = '{"id":"UL10","properties":{"value":false},"type":"state"}' +_PAYLOAD_SWITCH_SET_STATE_ON = ( + '{"id": "UL10", "type": "state", "properties": {"value": true}}' +) +_PAYLOAD_SWITCH_SET_STATE_OFF = ( + '{"id": "UL10", "type": "state", "properties": {"value": false}}' +) + +_TOPIC_SWITCH_STATE = "cloudapp/QBUSMQTTGW/UL1/UL10/state" +_TOPIC_SWITCH_SET_STATE = "cloudapp/QBUSMQTTGW/UL1/UL10/setState" + +_SWITCH_ENTITY_ID = "switch.living" + + +async def test_switch_turn_on_off( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + mock_config_entry: MockConfigEntry, + payload_config: JsonObjectType, +) -> None: + """Test turning on and off.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) + await hass.async_block_till_done() + + # Switch ON + mqtt_mock.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: _SWITCH_ENTITY_ID}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_SWITCH_SET_STATE, _PAYLOAD_SWITCH_SET_STATE_ON, 0, False + ) + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_SWITCH_STATE, _PAYLOAD_SWITCH_STATE_ON) + await hass.async_block_till_done() + + assert hass.states.get(_SWITCH_ENTITY_ID).state == STATE_ON + + # Switch OFF + mqtt_mock.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: _SWITCH_ENTITY_ID}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_SWITCH_SET_STATE, _PAYLOAD_SWITCH_SET_STATE_OFF, 0, False + ) + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_SWITCH_STATE, _PAYLOAD_SWITCH_STATE_OFF) + await hass.async_block_till_done() + + assert hass.states.get(_SWITCH_ENTITY_ID).state == STATE_OFF From 38dcc782d1750539b410a3243b04c6559098f79c Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 13 Jan 2025 20:07:47 +0100 Subject: [PATCH 0384/2987] Velbus update unique-config-entry quality score (#135524) --- homeassistant/components/velbus/quality_scale.yaml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml index 477b6768e71..b045493f4e4 100644 --- a/homeassistant/components/velbus/quality_scale.yaml +++ b/homeassistant/components/velbus/quality_scale.yaml @@ -23,10 +23,7 @@ rules: runtime-data: done test-before-configure: done test-before-setup: done - unique-config-entry: - status: todo - comment: | - Manual step does not generate an unique-id + unique-config-entry: done # Silver action-exceptions: todo From c489f9402643c233cbd248a5d74c15e2c5349bbb Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 13 Jan 2025 20:08:04 +0100 Subject: [PATCH 0385/2987] Velbus unsubscribe to the status updates on removal (#135530) --- homeassistant/components/velbus/entity.py | 4 ++++ homeassistant/components/velbus/quality_scale.yaml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index 65f8a1d8d31..82d06cdca28 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -40,6 +40,10 @@ class VelbusEntity(Entity): """Add listener for state changes.""" self._channel.on_status_update(self._on_update) + async def async_will_remove_from_hass(self) -> None: + """Remove listener for state changes.""" + self._channel.remove_on_status_update(self._on_update) + async def _on_update(self) -> None: self.async_write_ha_state() diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml index b045493f4e4..05e9c168b92 100644 --- a/homeassistant/components/velbus/quality_scale.yaml +++ b/homeassistant/components/velbus/quality_scale.yaml @@ -17,7 +17,7 @@ rules: docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done - entity-event-setup: todo + entity-event-setup: done entity-unique-id: done has-entity-name: todo runtime-data: done From 4ddb72314d2db545b9176bef7c987ebd95978e24 Mon Sep 17 00:00:00 2001 From: jesperraemaekers <146726232+jesperraemaekers@users.noreply.github.com> Date: Mon, 13 Jan 2025 20:09:19 +0100 Subject: [PATCH 0386/2987] Add quality scale for weheat (#135384) --- .../components/weheat/quality_scale.yaml | 96 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - tests/components/weheat/test_binary_sensor.py | 2 - tests/components/weheat/test_sensor.py | 2 - 4 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/weheat/quality_scale.yaml diff --git a/homeassistant/components/weheat/quality_scale.yaml b/homeassistant/components/weheat/quality_scale.yaml new file mode 100644 index 00000000000..f6b28c2765d --- /dev/null +++ b/homeassistant/components/weheat/quality_scale.yaml @@ -0,0 +1,96 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No service actions currently available + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + No explicit event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: + status: todo + comment: | + There are two servers that are used for this integration. + If the authentication server is unreachable, the user will not pass the configuration step. + If the backend is unreachable, an empty error message is displayed. + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: No service actions currently available + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + No configuration parameters available. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: todo + comment: | + PARALLEL_UPDATES is not set. + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration is a cloud service and thus does not support discovery. + discovery: + status: exempt + comment: | + This integration is a cloud service and thus does not support discovery. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: todo + comment: | + While unlikely to happen. Check if it is easily integrated. + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + There is no reconfiguration, as the only configuration step is authentication. + repair-issues: + status: exempt + comment: | + This is a cloud service and apart form reauthentication there are not user repairable issues. + stale-devices: + status: todo + comment: | + While unlikely to happen. Check if it is easily integrated. + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index e16d83028b7..4ba312a4ffb 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1118,7 +1118,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "weatherflow_cloud", "weatherkit", "webmin", - "weheat", "wemo", "whirlpool", "whois", diff --git a/tests/components/weheat/test_binary_sensor.py b/tests/components/weheat/test_binary_sensor.py index e75cb282e24..5769fc9a1a8 100644 --- a/tests/components/weheat/test_binary_sensor.py +++ b/tests/components/weheat/test_binary_sensor.py @@ -2,7 +2,6 @@ from unittest.mock import AsyncMock, patch -from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion from weheat.abstractions.discovery import HeatPumpDiscovery @@ -40,7 +39,6 @@ async def test_create_binary_entities( mock_weheat_heat_pump: AsyncMock, mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: """Test creating entities.""" mock_weheat_discover.return_value = [mock_heat_pump_info] diff --git a/tests/components/weheat/test_sensor.py b/tests/components/weheat/test_sensor.py index 062b84d0423..f3eec282704 100644 --- a/tests/components/weheat/test_sensor.py +++ b/tests/components/weheat/test_sensor.py @@ -2,7 +2,6 @@ from unittest.mock import AsyncMock, patch -from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion from weheat.abstractions.discovery import HeatPumpDiscovery @@ -41,7 +40,6 @@ async def test_create_entities( mock_weheat_heat_pump: AsyncMock, mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, has_dhw: bool, nr_of_entities: int, ) -> None: From eaaab4ccfeff3e4d8ec3f47aed0e0ec39dc1a51f Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 13 Jan 2025 20:10:45 +0100 Subject: [PATCH 0387/2987] Velbus add subdevices for din-rail modules (#131371) --- homeassistant/components/velbus/entity.py | 24 +- tests/components/velbus/conftest.py | 38 ++- .../velbus/snapshots/test_cover.ambr | 4 +- .../velbus/snapshots/test_init.ambr | 245 ++++++++++++++++++ .../velbus/snapshots/test_light.ambr | 2 +- tests/components/velbus/test_init.py | 51 +++- 6 files changed, 348 insertions(+), 16 deletions(-) create mode 100644 tests/components/velbus/snapshots/test_init.ambr diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index 82d06cdca28..634d20dcfa6 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -14,6 +14,12 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN +# device identifiers for modules +# (DOMAIN, module_address) + +# device identifiers for channels that are subdevices of a module +# (DOMAIN, f"{module_address}-{channel_number}") + class VelbusEntity(Entity): """Representation of a Velbus entity.""" @@ -23,19 +29,33 @@ class VelbusEntity(Entity): def __init__(self, channel: VelbusChannel) -> None: """Initialize a Velbus entity.""" self._channel = channel + self._module_adress = str(channel.get_module_address()) self._attr_name = channel.get_name() self._attr_device_info = DeviceInfo( identifiers={ - (DOMAIN, str(channel.get_module_address())), + (DOMAIN, self._get_identifier()), }, manufacturer="Velleman", model=channel.get_module_type_name(), + model_id=str(channel.get_module_type()), name=channel.get_full_name(), sw_version=channel.get_module_sw_version(), + serial_number=channel.get_module_serial(), ) - serial = channel.get_module_serial() or str(channel.get_module_address()) + if self._channel.is_sub_device(): + self._attr_device_info["via_device"] = ( + DOMAIN, + self._module_adress, + ) + serial = channel.get_module_serial() or self._module_adress self._attr_unique_id = f"{serial}-{channel.get_channel_number()}" + def _get_identifier(self) -> str: + """Return the identifier of the entity.""" + if not self._channel.is_sub_device(): + return self._module_adress + return f"{self._module_adress}-{self._channel.get_channel_number()}" + async def async_added_to_hass(self) -> None: """Add listener for state changes.""" self._channel.on_status_update(self._on_update) diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index 20d26a895c0..b9145cc256a 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -113,9 +113,11 @@ def mock_button() -> AsyncMock: channel.get_module_address.return_value = 1 channel.get_channel_number.return_value = 1 channel.get_module_type_name.return_value = "VMB4RYLD" + channel.get_module_type.return_value = 99 channel.get_full_name.return_value = "Channel full name" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = False channel.is_closed.return_value = True channel.is_on.return_value = False return channel @@ -133,6 +135,8 @@ def mock_temperature() -> AsyncMock: channel.get_full_name.return_value = "Channel full name" channel.get_module_sw_version.return_value = "3.0.0" channel.get_module_serial.return_value = "asdfghjk" + channel.get_module_type.return_value = 1 + channel.is_sub_device.return_value = False channel.is_counter_channel.return_value = False channel.get_class.return_value = "temperature" channel.get_unit.return_value = "°C" @@ -153,12 +157,14 @@ def mock_relay() -> AsyncMock: channel = AsyncMock(spec=Relay) channel.get_categories.return_value = ["switch"] channel.get_name.return_value = "RelayName" - channel.get_module_address.return_value = 99 + channel.get_module_address.return_value = 88 channel.get_channel_number.return_value = 55 channel.get_module_type_name.return_value = "VMB4RYNO" channel.get_full_name.return_value = "Full relay name" channel.get_module_sw_version.return_value = "1.0.1" channel.get_module_serial.return_value = "qwerty123" + channel.get_module_type.return_value = 2 + channel.is_sub_device.return_value = True channel.is_on.return_value = True return channel @@ -169,12 +175,14 @@ def mock_select() -> AsyncMock: channel = AsyncMock(spec=SelectedProgram) channel.get_categories.return_value = ["select"] channel.get_name.return_value = "select" - channel.get_module_address.return_value = 55 + channel.get_module_address.return_value = 88 channel.get_channel_number.return_value = 33 channel.get_module_type_name.return_value = "VMB4RYNO" + channel.get_module_type.return_value = 3 channel.get_full_name.return_value = "Full module name" channel.get_module_sw_version.return_value = "1.1.1" channel.get_module_serial.return_value = "qwerty1234567" + channel.is_sub_device.return_value = False channel.get_options.return_value = ["none", "summer", "winter", "holiday"] channel.get_selected_program.return_value = "winter" return channel @@ -186,12 +194,14 @@ def mock_buttoncounter() -> AsyncMock: channel = AsyncMock(spec=ButtonCounter) channel.get_categories.return_value = ["sensor"] channel.get_name.return_value = "ButtonCounter" - channel.get_module_address.return_value = 2 + channel.get_module_address.return_value = 88 channel.get_channel_number.return_value = 2 channel.get_module_type_name.return_value = "VMB7IN" + channel.get_module_type.return_value = 4 channel.get_full_name.return_value = "Channel full name" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = True channel.is_counter_channel.return_value = True channel.is_temperature.return_value = False channel.get_state.return_value = 100 @@ -210,9 +220,11 @@ def mock_sensornumber() -> AsyncMock: channel.get_module_address.return_value = 2 channel.get_channel_number.return_value = 3 channel.get_module_type_name.return_value = "VMB7IN" + channel.get_module_type.return_value = 8 channel.get_full_name.return_value = "Channel full name" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = False channel.is_counter_channel.return_value = False channel.is_temperature.return_value = False channel.get_unit.return_value = "m" @@ -229,9 +241,11 @@ def mock_lightsensor() -> AsyncMock: channel.get_module_address.return_value = 2 channel.get_channel_number.return_value = 4 channel.get_module_type_name.return_value = "VMB7IN" + channel.get_module_type.return_value = 8 channel.get_full_name.return_value = "Channel full name" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = False channel.is_counter_channel.return_value = False channel.is_temperature.return_value = False channel.get_unit.return_value = "illuminance" @@ -245,12 +259,14 @@ def mock_dimmer() -> AsyncMock: channel = AsyncMock(spec=Dimmer) channel.get_categories.return_value = ["light"] channel.get_name.return_value = "Dimmer" - channel.get_module_address.return_value = 3 - channel.get_channel_number.return_value = 1 + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 10 channel.get_module_type_name.return_value = "VMBDN1" + channel.get_module_type.return_value = 9 channel.get_full_name.return_value = "Dimmer full name" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6g7" + channel.is_sub_device.return_value = True channel.is_on.return_value = False channel.get_dimmer_state.return_value = 33 return channel @@ -262,12 +278,14 @@ def mock_cover() -> AsyncMock: channel = AsyncMock(spec=Blind) channel.get_categories.return_value = ["cover"] channel.get_name.return_value = "CoverName" - channel.get_module_address.return_value = 201 - channel.get_channel_number.return_value = 2 + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 9 channel.get_module_type_name.return_value = "VMB2BLE" + channel.get_module_type.return_value = 10 channel.get_full_name.return_value = "Full cover name" channel.get_module_sw_version.return_value = "1.0.1" channel.get_module_serial.return_value = "1234" + channel.is_sub_device.return_value = True channel.support_position.return_value = True channel.get_position.return_value = 50 channel.is_closed.return_value = False @@ -283,12 +301,14 @@ def mock_cover_no_position() -> AsyncMock: channel = AsyncMock(spec=Blind) channel.get_categories.return_value = ["cover"] channel.get_name.return_value = "CoverNameNoPos" - channel.get_module_address.return_value = 200 - channel.get_channel_number.return_value = 1 + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 11 channel.get_module_type_name.return_value = "VMB2BLE" + channel.get_module_type.return_value = 10 channel.get_full_name.return_value = "Full cover name no position" channel.get_module_sw_version.return_value = "1.0.1" channel.get_module_serial.return_value = "12345" + channel.is_sub_device.return_value = True channel.support_position.return_value = False channel.get_position.return_value = None channel.is_closed.return_value = False diff --git a/tests/components/velbus/snapshots/test_cover.ambr b/tests/components/velbus/snapshots/test_cover.ambr index eb41839078d..1ca867ec9a4 100644 --- a/tests/components/velbus/snapshots/test_cover.ambr +++ b/tests/components/velbus/snapshots/test_cover.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '1234-2', + 'unique_id': '1234-9', 'unit_of_measurement': None, }) # --- @@ -76,7 +76,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '12345-1', + 'unique_id': '12345-11', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/velbus/snapshots/test_init.ambr b/tests/components/velbus/snapshots/test_init.ambr new file mode 100644 index 00000000000..850231a45d2 --- /dev/null +++ b/tests/components/velbus/snapshots/test_init.ambr @@ -0,0 +1,245 @@ +# serializer version: 1 +# name: test_device_registry + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '1', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB4RYLD', + 'model_id': '99', + 'name': 'Channel full name', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-9', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB2BLE', + 'model_id': '10', + 'name': 'Full cover name', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234', + 'suggested_area': None, + 'sw_version': '1.0.1', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-11', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB2BLE', + 'model_id': '10', + 'name': 'Full cover name no position', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '12345', + 'suggested_area': None, + 'sw_version': '1.0.1', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-10', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMBDN1', + 'model_id': '9', + 'name': 'Dimmer full name', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6g7', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-2', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB7IN', + 'model_id': '4', + 'name': 'Channel full name', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB4GPO', + 'model_id': '1', + 'name': 'Channel full name', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'asdfghjk', + 'suggested_area': None, + 'sw_version': '3.0.0', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '2', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB7IN', + 'model_id': '8', + 'name': 'Channel full name', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-55', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB4RYNO', + 'model_id': '2', + 'name': 'Full relay name', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'qwerty123', + 'suggested_area': None, + 'sw_version': '1.0.1', + 'via_device_id': , + }), + ]) +# --- diff --git a/tests/components/velbus/snapshots/test_light.ambr b/tests/components/velbus/snapshots/test_light.ambr index a4574f1b339..ec18305984c 100644 --- a/tests/components/velbus/snapshots/test_light.ambr +++ b/tests/components/velbus/snapshots/test_light.ambr @@ -32,7 +32,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'a1b2c3d4e5f6g7-1', + 'unique_id': 'a1b2c3d4e5f6g7-10', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/velbus/test_init.py b/tests/components/velbus/test_init.py index b7c334d7814..436a3d8fa95 100644 --- a/tests/components/velbus/test_init.py +++ b/tests/components/velbus/test_init.py @@ -1,14 +1,18 @@ """Tests for the Velbus component initialisation.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +import pytest +from syrupy.assertion import SnapshotAssertion from velbusaio.exceptions import VelbusConnectionFailed +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.velbus import VelbusConfigEntry from homeassistant.components.velbus.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_PORT, SERVICE_TURN_ON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from . import init_integration @@ -113,3 +117,46 @@ async def test_migrate_config_entry( await hass.config_entries.async_setup(entry.entry_id) assert dict(entry.data) == legacy_config assert entry.version == 2 + + +async def test_api_call( + hass: HomeAssistant, + mock_relay: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test the api call decorator action.""" + await init_integration(hass, config_entry) + + mock_relay.turn_on.side_effect = OSError() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.relayname"}, + blocking=True, + ) + + +async def test_device_registry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the velbus device registry.""" + await init_integration(hass, config_entry) + + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot + + device_parent = device_registry.async_get_device(identifiers={(DOMAIN, "88")}) + assert device_parent.via_device_id is None + + device = device_registry.async_get_device(identifiers={(DOMAIN, "88-9")}) + assert device.via_device_id == device_parent.id + + device_no_sub = device_registry.async_get_device(identifiers={(DOMAIN, "2")}) + assert device_no_sub.via_device_id is None From 504ed83ffbbe3ab96761e9d3f8a1fba6ecb1d455 Mon Sep 17 00:00:00 2001 From: JJ <6438760+IgnusG@users.noreply.github.com> Date: Mon, 13 Jan 2025 20:11:17 +0100 Subject: [PATCH 0388/2987] Add person component to strict type checking (#132754) --- .strict-typing | 1 + homeassistant/components/person/__init__.py | 2 +- mypy.ini | 10 ++++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.strict-typing b/.strict-typing index 1d5d220efc1..b83bb0f6a95 100644 --- a/.strict-typing +++ b/.strict-typing @@ -371,6 +371,7 @@ homeassistant.components.panel_custom.* homeassistant.components.peblar.* homeassistant.components.peco.* homeassistant.components.persistent_notification.* +homeassistant.components.person.* homeassistant.components.pi_hole.* homeassistant.components.ping.* homeassistant.components.plugwise.* diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index b793f4b33ae..856e07bb2ee 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -280,7 +280,7 @@ class PersonStorageCollection(collection.DictStorageCollection): return data @callback - def _get_suggested_id(self, info: dict) -> str: + def _get_suggested_id(self, info: dict[str, str]) -> str: """Suggest an ID based on the config.""" return info[CONF_NAME] diff --git a/mypy.ini b/mypy.ini index 5b6824250e2..4eb6bdff80b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3466,6 +3466,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.person.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.pi_hole.*] check_untyped_defs = true disallow_incomplete_defs = true From e8ad391df271bba4fb1e099bd861824bd93488c5 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 13 Jan 2025 20:31:13 +0100 Subject: [PATCH 0389/2987] Add data_descriptions to inexogy config flow (#135536) --- homeassistant/components/discovergy/quality_scale.yaml | 5 +---- homeassistant/components/discovergy/strings.json | 4 ++++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/discovergy/quality_scale.yaml b/homeassistant/components/discovergy/quality_scale.yaml index 792a76b2696..f0d84903552 100644 --- a/homeassistant/components/discovergy/quality_scale.yaml +++ b/homeassistant/components/discovergy/quality_scale.yaml @@ -8,10 +8,7 @@ rules: brands: done common-modules: done config-flow-test-coverage: done - config-flow: - status: todo - comment: | - The data_descriptions are missing. + config-flow: done dependency-transparency: done docs-actions: status: exempt diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json index b626a11ea1e..f4b155b4b1b 100644 --- a/homeassistant/components/discovergy/strings.json +++ b/homeassistant/components/discovergy/strings.json @@ -5,6 +5,10 @@ "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email address used to log in to your inexogy account.", + "password": "The password used to log in to your inexogy account." } } }, From 3c825bb826dc558e8f5f6377633fd45e60586e17 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 13 Jan 2025 20:48:24 +0100 Subject: [PATCH 0390/2987] Set PARALLEL_UPDATES for inexogy (#135545) --- homeassistant/components/discovergy/quality_scale.yaml | 2 +- homeassistant/components/discovergy/sensor.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/discovergy/quality_scale.yaml b/homeassistant/components/discovergy/quality_scale.yaml index f0d84903552..56af1d97304 100644 --- a/homeassistant/components/discovergy/quality_scale.yaml +++ b/homeassistant/components/discovergy/quality_scale.yaml @@ -42,7 +42,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: done test-coverage: done diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index 531904c8740..a3ec132db9b 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -28,6 +28,8 @@ from . import DiscovergyConfigEntry from .const import DOMAIN, MANUFACTURER from .coordinator import DiscovergyUpdateCoordinator +PARALLEL_UPDATES = 0 + def _get_and_scale(reading: Reading, key: str, scale: int) -> datetime | float | None: """Get a value from a Reading and divide with scale it.""" From 3e9b410b7caa93f4e302498a0fcfaf13bd0ab6ff Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 13 Jan 2025 20:56:10 +0100 Subject: [PATCH 0391/2987] Fix grammar issue in 'invalid_auth' string (#135546) Remove that wrong comma and add a "that" to clarify the meaning of the error message. --- homeassistant/components/discovergy/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json index f4b155b4b1b..0058f874a36 100644 --- a/homeassistant/components/discovergy/strings.json +++ b/homeassistant/components/discovergy/strings.json @@ -19,7 +19,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "account_mismatch": "The inexogy account authenticated with, does not match the account needed re-authentication.", + "account_mismatch": "The inexogy account authenticated with does not match the account that needed re-authentication.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, From b897e6a85f388ffe521eadae69a7f0de5026a071 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 13 Jan 2025 14:17:12 -0600 Subject: [PATCH 0392/2987] Use STT/TTS languages for LLM fallback (#135533) --- .../components/assist_pipeline/pipeline.py | 15 +- .../assist_pipeline/snapshots/test_init.ambr | 102 ++++++++++++ tests/components/assist_pipeline/test_init.py | 154 +++++++++++++++++- 3 files changed, 265 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index c3a5b93ca6a..1b76121fcd2 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1021,9 +1021,18 @@ class PipelineRun: raise RuntimeError("Recognize intent was not prepared") if self.pipeline.conversation_language == MATCH_ALL: - # LLMs support all languages ('*') so use pipeline language for - # intent fallback. - input_language = self.pipeline.language + # LLMs support all languages ('*') so use languages from the + # pipeline for intent fallback. + # + # We prioritize the STT and TTS languages because they may be more + # specific, such as "zh-CN" instead of just "zh". This is necessary + # for languages whose intents are split out by region when + # preferring local intent matching. + input_language = ( + self.pipeline.stt_language + or self.pipeline.tts_language + or self.pipeline.language + ) else: input_language = self.pipeline.conversation_language diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index f63a28efbb7..171014fdc4a 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -474,6 +474,108 @@ }), ]) # --- +# name: test_stt_language_used_instead_of_conversation_language + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'conversation.home_assistant', + 'intent_input': 'test input', + 'language': 'en-US', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + }), + }), + }), + 'processed_locally': True, + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_tts_language_used_instead_of_conversation_language + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'conversation.home_assistant', + 'intent_input': 'test input', + 'language': 'en-us', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + }), + }), + }), + 'processed_locally': True, + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- # name: test_wake_word_detection_aborted list([ dict({ diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index d4cce4e2e98..a2cb9ef382a 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -1102,13 +1102,13 @@ async def test_prefer_local_intents( ) -async def test_pipeline_language_used_instead_of_conversation_language( +async def test_stt_language_used_instead_of_conversation_language( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components, snapshot: SnapshotAssertion, ) -> None: - """Test that the pipeline language is used when the conversation language is '*' (all languages).""" + """Test that the STT language is used first when the conversation language is '*' (all languages).""" client = await hass_ws_client(hass) events: list[assist_pipeline.PipelineEvent] = [] @@ -1165,7 +1165,155 @@ async def test_pipeline_language_used_instead_of_conversation_language( assert intent_start is not None - # Pipeline language (en) should be used instead of '*' + # STT language (en-US) should be used instead of '*' + assert intent_start.data.get("language") == pipeline.stt_language + + # Check input to async_converse + mock_async_converse.assert_called_once() + assert ( + mock_async_converse.call_args_list[0].kwargs.get("language") + == pipeline.stt_language + ) + + +async def test_tts_language_used_instead_of_conversation_language( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test that the TTS language is used after STT when the conversation language is '*' (all languages).""" + client = await hass_ws_client(hass) + + events: list[assist_pipeline.PipelineEvent] = [] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": MATCH_ALL, + "language": "en", + "name": "test_name", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": "en-us", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test input", + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + intent.IntentResponse(pipeline.language) + ), + ) as mock_async_converse: + await pipeline_input.execute() + + # Check intent start event + assert process_events(events) == snapshot + intent_start: assist_pipeline.PipelineEvent | None = None + for event in events: + if event.type == assist_pipeline.PipelineEventType.INTENT_START: + intent_start = event + break + + assert intent_start is not None + + # STT language (en-US) should be used instead of '*' + assert intent_start.data.get("language") == pipeline.tts_language + + # Check input to async_converse + mock_async_converse.assert_called_once() + assert ( + mock_async_converse.call_args_list[0].kwargs.get("language") + == pipeline.tts_language + ) + + +async def test_pipeline_language_used_instead_of_conversation_language( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test that the pipeline language is used last when the conversation language is '*' (all languages).""" + client = await hass_ws_client(hass) + + events: list[assist_pipeline.PipelineEvent] = [] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": MATCH_ALL, + "language": "en", + "name": "test_name", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "wake_word_entity": None, + "wake_word_id": None, + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test input", + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + intent.IntentResponse(pipeline.language) + ), + ) as mock_async_converse: + await pipeline_input.execute() + + # Check intent start event + assert process_events(events) == snapshot + intent_start: assist_pipeline.PipelineEvent | None = None + for event in events: + if event.type == assist_pipeline.PipelineEventType.INTENT_START: + intent_start = event + break + + assert intent_start is not None + + # STT language (en-US) should be used instead of '*' assert intent_start.data.get("language") == pipeline.language # Check input to async_converse From 09e2168f724fb06426c2862658e495885583a378 Mon Sep 17 00:00:00 2001 From: Master-Guy <566429+Master-Guy@users.noreply.github.com> Date: Mon, 13 Jan 2025 21:46:32 +0100 Subject: [PATCH 0393/2987] Changed json.schemas.url for devcontainers (#135281) --- .devcontainer/devcontainer.json | 2 +- .vscode/settings.default.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 44c38afdec6..29d5a95ea01 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -62,7 +62,7 @@ "json.schemas": [ { "fileMatch": ["homeassistant/components/*/manifest.json"], - "url": "./script/json_schemas/manifest_schema.json" + "url": "${containerWorkspaceFolder}/script/json_schemas/manifest_schema.json" } ] } diff --git a/.vscode/settings.default.json b/.vscode/settings.default.json index ace0a988bf5..8c57059959b 100644 --- a/.vscode/settings.default.json +++ b/.vscode/settings.default.json @@ -1,5 +1,5 @@ { - // Please keep this file in sync with settings in home-assistant/.devcontainer/devcontainer.json + // Please keep this file (mostly!) in sync with settings in home-assistant/.devcontainer/devcontainer.json // Added --no-cov to work around TypeError: message must be set // https://github.com/microsoft/vscode-python/issues/14067 "python.testing.pytestArgs": ["--no-cov"], @@ -12,6 +12,7 @@ "fileMatch": [ "homeassistant/components/*/manifest.json" ], + // This value differs between working with devcontainer and locally, therefor this value should NOT be in sync! "url": "./script/json_schemas/manifest_schema.json" } ] From 440cd5bee0107aa4c6aef2a8621e699763086f20 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Jan 2025 10:00:21 +0100 Subject: [PATCH 0394/2987] Improve improv via BLE log messages (#135575) --- homeassistant/components/improv_ble/config_flow.py | 14 +++++++++++--- tests/components/improv_ble/test_config_flow.py | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index 05dd1de449a..22f2bf3623c 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -126,15 +126,23 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN): ) except improv_ble_errors.InvalidCommand as err: _LOGGER.warning( - "Aborting improv flow, device %s sent invalid improv data: '%s'", - self._discovery_info.address, + ( + "Received invalid improv via BLE data '%s' from device with " + "bluetooth address '%s'; if the device is a self-configured " + "ESPHome device, either correct or disable the 'esp32_improv' " + "configuration; if it's a commercial device, contact the vendor" + ), service_data[SERVICE_DATA_UUID].hex(), + self._discovery_info.address, ) raise AbortFlow("invalid_improv_data") from err if improv_service_data.state in (State.PROVISIONING, State.PROVISIONED): _LOGGER.debug( - "Aborting improv flow, device %s is already provisioned: %s", + ( + "Aborting improv flow, device with bluetooth address '%s' is " + "already provisioned: %s" + ), self._discovery_info.address, improv_service_data.state, ) diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index 2df4be2ba7d..4536c64349c 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -664,6 +664,6 @@ async def test_provision_fails_invalid_data( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "invalid_improv_data" assert ( - "Aborting improv flow, device AA:BB:CC:DD:EE:F0 sent invalid improv data: '000000000000'" + "Received invalid improv via BLE data '000000000000' from device with bluetooth address 'AA:BB:CC:DD:EE:F0'" in caplog.text ) From 1de4d0efda1fdf0c66f96be4f984769210a93d7c Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 14 Jan 2025 10:04:48 +0100 Subject: [PATCH 0395/2987] Fix deprecated enums (#134824) --- homeassistant/helpers/deprecation.py | 6 +++--- tests/helpers/test_deprecation.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 81f7821ec79..f02c6507d02 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from contextlib import suppress -from enum import Enum, EnumType, _EnumDict +from enum import EnumType, IntEnum, IntFlag, StrEnum, _EnumDict import functools import inspect import logging @@ -255,7 +255,7 @@ class DeprecatedConstant(NamedTuple): class DeprecatedConstantEnum(NamedTuple): """Deprecated constant.""" - enum: Enum + enum: StrEnum | IntEnum | IntFlag breaks_in_ha_version: str | None @@ -306,7 +306,7 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A replacement = deprecated_const.replacement breaks_in_ha_version = deprecated_const.breaks_in_ha_version elif isinstance(deprecated_const, DeprecatedConstantEnum): - value = deprecated_const.enum.value + value = deprecated_const.enum replacement = ( f"{deprecated_const.enum.__class__.__name__}.{deprecated_const.enum.name}" ) diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 4cf7e851af3..a74055c59ec 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -295,7 +295,7 @@ def _get_value( return obj.value if isinstance(obj, DeprecatedConstantEnum): - return obj.enum.value + return obj.enum if isinstance(obj, DeprecatedAlias): return obj.value From 0c144092c634d58a22db86645a17eae280f7b74d Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 14 Jan 2025 10:07:23 +0100 Subject: [PATCH 0396/2987] Bump habiticalib to v.0.3.3 (#135551) --- .../components/habitica/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/habitica/conftest.py | 5 +- .../fixtures/cast_skill_response.json | 216 ++++++++++++++++++ 5 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 tests/components/habitica/fixtures/cast_skill_response.json diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index a1c1ae7787b..1c92c314e66 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/habitica", "iot_class": "cloud_polling", "loggers": ["habiticalib"], - "requirements": ["habiticalib==0.3.2"] + "requirements": ["habiticalib==0.3.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 02a8320f8a5..91fb812e0eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1094,7 +1094,7 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.2 +habiticalib==0.3.3 # homeassistant.components.bluetooth habluetooth==3.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 642d9416dbc..8602ae394b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -935,7 +935,7 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.2 +habiticalib==0.3.3 # homeassistant.components.bluetooth habluetooth==3.8.0 diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index 935a203f993..b1410f559db 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch from habiticalib import ( BadRequestError, + HabiticaCastSkillResponse, HabiticaContentResponse, HabiticaErrorResponse, HabiticaGroupMembersResponse, @@ -91,8 +92,8 @@ async def mock_habiticalib() -> Generator[AsyncMock]: load_fixture("user.json", DOMAIN) ) - client.cast_skill.return_value = HabiticaUserResponse.from_json( - load_fixture("user.json", DOMAIN) + client.cast_skill.return_value = HabiticaCastSkillResponse.from_json( + load_fixture("cast_skill_response.json", DOMAIN) ) client.toggle_sleep.return_value = HabiticaSleepResponse( success=True, data=True diff --git a/tests/components/habitica/fixtures/cast_skill_response.json b/tests/components/habitica/fixtures/cast_skill_response.json new file mode 100644 index 00000000000..41880770394 --- /dev/null +++ b/tests/components/habitica/fixtures/cast_skill_response.json @@ -0,0 +1,216 @@ +{ + "success": true, + "data": { + "user": { + "api_user": "test-api-user", + "profile": { "name": "test-user" }, + "auth": { "local": { "username": "test-username" } }, + "stats": { + "buffs": { + "str": 26, + "int": 26, + "per": 26, + "con": 26, + "stealth": 0, + "streaks": false, + "seafoam": false, + "shinySeed": false, + "snowball": false, + "spookySparkles": false + }, + "hp": 0, + "mp": 50.89999999999998, + "exp": 737, + "gp": 137.62587214609795, + "lvl": 38, + "class": "wizard", + "maxHealth": 50, + "maxMP": 166, + "toNextLevel": 880, + "points": 5, + "str": 15, + "con": 15, + "int": 15, + "per": 15 + }, + "preferences": { + "sleep": false, + "automaticAllocation": true, + "disableClasses": false, + "language": "en" + }, + "flags": { + "classSelected": true + }, + "tasksOrder": { + "rewards": ["5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"], + "todos": [ + "88de7cd9-af2b-49ce-9afd-bf941d87336b", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "1aa3137e-ef72-4d1f-91ee-41933602f438", + "86ea2475-d1b5-4020-bdcc-c188c7996afa" + ], + "dailys": [ + "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a", + "bc1d1855-b2b8-4663-98ff-62e7b763dfc4", + "e97659e0-2c42-4599-a7bb-00282adc410d", + "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", + "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + ], + "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] + }, + "party": { + "quest": { + "RSVPNeeded": true, + "key": "dustbunnies" + }, + "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" + }, + "tags": [ + { + "id": "8515e4ae-2f4b-455a-b4a4-8939e04b1bfd", + "name": "Arbeit" + }, + { + "id": "6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab", + "name": "Training" + }, + { + "id": "20409521-c096-447f-9a90-23e8da615710", + "name": "Gesundheit + Wohlbefinden" + }, + { + "id": "2ac458af-0833-4f3f-bf04-98a0c33ef60b", + "name": "Schule" + }, + { + "id": "1bcb1a0f-4d05-4087-8223-5ea779e258b0", + "name": "Teams" + }, + { + "id": "b2780f82-b3b5-49a3-a677-48f2c8c7e3bb", + "name": "Hausarbeiten" + }, + { + "id": "3450351f-1323-4c7e-9fd2-0cdff25b3ce0", + "name": "Kreativität" + } + ], + "needsCron": true, + "lastCron": "2024-09-21T22:01:55.586Z", + "id": "a380546a-94be-4b8e-8a0b-23e0d5c03303", + "items": { + "gear": { + "equipped": { + "weapon": "weapon_warrior_5", + "armor": "armor_warrior_5", + "head": "head_warrior_5", + "shield": "shield_warrior_5", + "back": "back_special_heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "eyewear_armoire_plagueDoctorMask", + "body": "body_special_aetherAmulet" + } + } + }, + "balance": 10 + }, + "task": { + "_id": "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "date": "2024-08-31T22:16:00.000Z", + "completed": false, + "collapseChecklist": false, + "checklist": [], + "type": "todo", + "text": "Rechnungen bezahlen", + "notes": "Strom- und Internetrechnungen rechtzeitig überweisen.", + "tags": [], + "value": 0, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "reminders": [ + { + "id": "91c09432-10ac-4a49-bd20-823081ec29ed", + "time": "2024-09-22T02:00:00.0000Z" + } + ], + "byHabitica": false, + "createdAt": "2024-09-21T22:17:19.513Z", + "updatedAt": "2024-09-21T22:19:35.576Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "id": "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "alias": "pay_bills" + } + }, + "notifications": [ + { + "type": "ITEM_RECEIVED", + "data": { + "icon": "notif_habitoween_base_pet", + "title": "Happy Habitoween!", + "text": "For this spooky celebration, you've received a Jack-O-Lantern Pet and an assortment of candy for your Pets!", + "destination": "/inventory/stable" + }, + "seen": false, + "id": "5af98f52-f72a-4540-bdeb-3ffc39b34196" + }, + { + "type": "ITEM_RECEIVED", + "data": { + "icon": "notif_harvestfeast_base_pet", + "title": "Happy Harvest Feast!", + "text": "Gobble gobble, you've received the Turkey Pet!", + "destination": "/inventory/stable" + }, + "seen": false, + "id": "1e4a1481-e7ca-42d1-9b3f-3f442bef9435" + }, + { + "type": "ITEM_RECEIVED", + "data": { + "title": "Happy New Year!", + "destination": "/inventory/equipment", + "icon": "notif_2013hat_nye", + "text": "Take on your resolutions with style in this Absurd Party Hat!" + }, + "seen": false, + "id": "61fe229b-feee-48b9-bb4d-9e4b9c088ab1" + }, + { + "type": "NEW_STUFF", + "data": { + "title": "SNAG YOUR FAVES FROM THE QUEST SHOP - NEW SELECTIONS ARE ON THE WAY!" + }, + "seen": true, + "id": "0decabad-57f8-4cb2-a158-ba7b44da890f" + }, + { + "id": "f6fb06bc-6e63-40e1-b8c8-76bd5889ef51", + "type": "NEW_CHAT_MESSAGE", + "data": { + "group": { + "id": "a8289328-c2ae-4007-9ef4-833b9ac90d37", + "name": "tr4nt0r_2's Party" + } + }, + "seen": false + }, + { + "type": "UNALLOCATED_STATS_POINTS", + "data": { + "points": 21 + }, + "seen": false, + "id": "a88ba092-2d4d-40f9-bf87-902aedf954fe" + } + ], + "userV": 677, + "appVersion": "5.32.5" +} From 6d7e9f10d93a16256f10f71f55c9c3dba543a647 Mon Sep 17 00:00:00 2001 From: jesperraemaekers <146726232+jesperraemaekers@users.noreply.github.com> Date: Tue, 14 Jan 2025 10:19:28 +0100 Subject: [PATCH 0397/2987] Set PARALLEL_UPDATES for Weheat (#135574) Add PARALLEL_UPDATES --- homeassistant/components/weheat/binary_sensor.py | 3 +++ homeassistant/components/weheat/quality_scale.yaml | 5 +---- homeassistant/components/weheat/sensor.py | 3 +++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/weheat/binary_sensor.py b/homeassistant/components/weheat/binary_sensor.py index ea939227e77..1fb8f614a40 100644 --- a/homeassistant/components/weheat/binary_sensor.py +++ b/homeassistant/components/weheat/binary_sensor.py @@ -18,6 +18,9 @@ from . import WeheatConfigEntry from .coordinator import WeheatDataUpdateCoordinator from .entity import WeheatEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class WeHeatBinarySensorEntityDescription(BinarySensorEntityDescription): diff --git a/homeassistant/components/weheat/quality_scale.yaml b/homeassistant/components/weheat/quality_scale.yaml index f6b28c2765d..a8431ad3aad 100644 --- a/homeassistant/components/weheat/quality_scale.yaml +++ b/homeassistant/components/weheat/quality_scale.yaml @@ -42,10 +42,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: - status: todo - comment: | - PARALLEL_UPDATES is not set. + parallel-updates: done reauthentication-flow: done test-coverage: done diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index 3e5d9376c34..2d840aec86a 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -31,6 +31,9 @@ from .const import ( from .coordinator import WeheatDataUpdateCoordinator from .entity import WeheatEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class WeHeatSensorEntityDescription(SensorEntityDescription): From d333fa320f13f620fba4ddf9a85027dd1b374a0b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Jan 2025 10:24:48 +0100 Subject: [PATCH 0398/2987] Fix nmbs sensor unique_id (#135576) --- homeassistant/components/nmbs/sensor.py | 5 +- tests/components/nmbs/test_config_flow.py | 66 +++++++++++++---------- 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index ffb539a39a0..eea1745fcc1 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -207,7 +207,10 @@ class NMBSLiveBoard(SensorEntity): def unique_id(self) -> str: """Return the unique ID.""" - unique_id = f"{self._station}_{self._station_from}_{self._station_to}" + unique_id = ( + f"{self._station["id"]}_{self._station_from["id"]}_" + f"{self._station_to["id"]}" + ) return f"nmbs_live_{unique_id}" @property diff --git a/tests/components/nmbs/test_config_flow.py b/tests/components/nmbs/test_config_flow.py index 08ecfbfd136..448bcdfbc51 100644 --- a/tests/components/nmbs/test_config_flow.py +++ b/tests/components/nmbs/test_config_flow.py @@ -243,15 +243,21 @@ async def test_invalid_station_name( async def test_sensor_id_migration_standardname( hass: HomeAssistant, mock_nmbs_client: AsyncMock, - mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test migrating unique id.""" - entity_registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - f"live_{DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"]}_{DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"]}_{DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"]}", - config_entry=mock_config_entry, + old_unique_id = ( + f"live_{DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"]}_" + f"{DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"]}_" + f"{DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"]}" + ) + new_unique_id = ( + f"nmbs_live_{DUMMY_DATA["STAT_BRUSSELS_NORTH"]}_" + f"{DUMMY_DATA["STAT_BRUSSELS_NORTH"]}_" + f"{DUMMY_DATA["STAT_BRUSSELS_SOUTH"]}" + ) + old_entry = entity_registry.async_get_or_create( + SENSOR_DOMAIN, DOMAIN, old_unique_id ) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -264,29 +270,34 @@ async def test_sensor_id_migration_standardname( ) assert result["type"] is FlowResultType.CREATE_ENTRY + config_entry_id = result["result"].entry_id await hass.async_block_till_done() - entities = er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - assert len(entities) == 1 - assert ( - entities[0].unique_id - == f"nmbs_live_{DUMMY_DATA["STAT_BRUSSELS_NORTH"]}_{DUMMY_DATA["STAT_BRUSSELS_NORTH"]}_{DUMMY_DATA["STAT_BRUSSELS_SOUTH"]}" - ) + entities = er.async_entries_for_config_entry(entity_registry, config_entry_id) + assert len(entities) == 3 + entities_map = {entity.unique_id: entity for entity in entities} + assert old_unique_id not in entities_map + assert new_unique_id in entities_map + assert entities_map[new_unique_id].id == old_entry.id async def test_sensor_id_migration_localized_name( hass: HomeAssistant, mock_nmbs_client: AsyncMock, - mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test migrating unique id.""" - entity_registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - f"live_{DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_NORTH"]}_{DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_NORTH"]}_{DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_SOUTH"]}", - config_entry=mock_config_entry, + old_unique_id = ( + f"live_{DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_NORTH"]}_" + f"{DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_NORTH"]}_" + f"{DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_SOUTH"]}" + ) + new_unique_id = ( + f"nmbs_live_{DUMMY_DATA["STAT_BRUSSELS_NORTH"]}_" + f"{DUMMY_DATA["STAT_BRUSSELS_NORTH"]}_" + f"{DUMMY_DATA["STAT_BRUSSELS_SOUTH"]}" + ) + old_entry = entity_registry.async_get_or_create( + SENSOR_DOMAIN, DOMAIN, old_unique_id ) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -299,12 +310,11 @@ async def test_sensor_id_migration_localized_name( ) assert result["type"] is FlowResultType.CREATE_ENTRY + config_entry_id = result["result"].entry_id await hass.async_block_till_done() - entities = er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - assert len(entities) == 1 - assert ( - entities[0].unique_id - == f"nmbs_live_{DUMMY_DATA["STAT_BRUSSELS_NORTH"]}_{DUMMY_DATA["STAT_BRUSSELS_NORTH"]}_{DUMMY_DATA["STAT_BRUSSELS_SOUTH"]}" - ) + entities = er.async_entries_for_config_entry(entity_registry, config_entry_id) + assert len(entities) == 3 + entities_map = {entity.unique_id: entity for entity in entities} + assert old_unique_id not in entities_map + assert new_unique_id in entities_map + assert entities_map[new_unique_id].id == old_entry.id From 58df5f23941cb9b0fc313ef87bebbca250578a3d Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Tue, 14 Jan 2025 03:51:13 -0600 Subject: [PATCH 0399/2987] Add iprak to to vesync code owners (#135562) --- CODEOWNERS | 4 ++-- homeassistant/components/vesync/manifest.json | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 9517cc86139..bf8b1502a10 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1636,8 +1636,8 @@ build.json @home-assistant/supervisor /homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus -/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja -/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja +/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak +/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak /homeassistant/components/vicare/ @CFenner /tests/components/vicare/ @CFenner /homeassistant/components/vilfo/ @ManneW diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index a706b2157ba..81fb1a764f0 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -1,7 +1,13 @@ { "domain": "vesync", "name": "VeSync", - "codeowners": ["@markperdue", "@webdjoe", "@thegardenmonkey", "@cdnninja"], + "codeowners": [ + "@markperdue", + "@webdjoe", + "@thegardenmonkey", + "@cdnninja", + "@iprak" + ], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", From 1426c421f3b9c26eeb001e508ae3c672b4c95477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 14 Jan 2025 09:15:38 -0100 Subject: [PATCH 0400/2987] Use percent formatting in logging per guidelines (#135550) --- homeassistant/components/acmeda/hub.py | 2 +- homeassistant/components/emoncms/__init__.py | 2 +- homeassistant/components/intellifire/__init__.py | 2 +- homeassistant/components/intellifire/config_flow.py | 4 ++-- homeassistant/components/mastodon/notify.py | 2 +- homeassistant/components/monarch_money/config_flow.py | 2 +- homeassistant/components/webmin/config_flow.py | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/acmeda/hub.py b/homeassistant/components/acmeda/hub.py index a5daf27f445..4f2e4f4f63f 100644 --- a/homeassistant/components/acmeda/hub.py +++ b/homeassistant/components/acmeda/hub.py @@ -70,7 +70,7 @@ class PulseHub: async def async_notify_update(self, update_type: aiopulse.UpdateType) -> None: """Evaluate entities when hub reports that update has occurred.""" - LOGGER.debug("Hub {update_type.name} updated") + LOGGER.debug("Hub %s updated", update_type.name) if update_type == aiopulse.UpdateType.rollers: await update_devices(self.hass, self.config_entry, self.api.rollers) diff --git a/homeassistant/components/emoncms/__init__.py b/homeassistant/components/emoncms/__init__.py index 0cd686b5b56..581948bbc6f 100644 --- a/homeassistant/components/emoncms/__init__.py +++ b/homeassistant/components/emoncms/__init__.py @@ -26,7 +26,7 @@ def _migrate_unique_id( for entity in entry_entities: if entity.unique_id.split("-")[0] == entry.entry_id: feed_id = entity.unique_id.split("-")[-1] - LOGGER.debug(f"moving feed {feed_id} to hardware uuid") + LOGGER.debug("moving feed %s to hardware uuid", feed_id) ent_reg.async_update_entity( entity.entity_id, new_unique_id=f"{emoncms_unique_id}-{feed_id}" ) diff --git a/homeassistant/components/intellifire/__init__.py b/homeassistant/components/intellifire/__init__.py index 7609398673b..ce78f1a6fa3 100644 --- a/homeassistant/components/intellifire/__init__.py +++ b/homeassistant/components/intellifire/__init__.py @@ -149,7 +149,7 @@ async def _async_wait_for_initialization( while ( fireplace.data.ipv4_address == "127.0.0.1" and fireplace.data.serial == "unset" ): - LOGGER.debug(f"Waiting for fireplace to initialize [{fireplace.read_mode}]") + LOGGER.debug("Waiting for fireplace to initialize [%s]", fireplace.read_mode) await asyncio.sleep(INIT_WAIT_TIME_SECONDS) diff --git a/homeassistant/components/intellifire/config_flow.py b/homeassistant/components/intellifire/config_flow.py index a6b63f3b3e8..e7c4fbbdd2a 100644 --- a/homeassistant/components/intellifire/config_flow.py +++ b/homeassistant/components/intellifire/config_flow.py @@ -145,13 +145,13 @@ class IntelliFireConfigFlow(ConfigFlow, domain=DOMAIN): """ errors: dict[str, str] = {} LOGGER.debug( - f"STEP: pick_cloud_device: {user_input} - DHCP_MODE[{self._dhcp_mode}" + "STEP: pick_cloud_device: %s - DHCP_MODE[%s]", user_input, self._dhcp_mode ) if self._dhcp_mode or user_input is not None: if self._dhcp_mode: serial = self._dhcp_discovered_serial - LOGGER.debug(f"DHCP Mode detected for serial [{serial}]") + LOGGER.debug("DHCP Mode detected for serial [%s]", serial) if user_input is not None: serial = user_input[CONF_SERIAL] diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py index bdfdbbf6e99..dd76d44a02c 100644 --- a/homeassistant/components/mastodon/notify.py +++ b/homeassistant/components/mastodon/notify.py @@ -115,7 +115,7 @@ class MastodonNotificationService(BaseNotificationService): try: mediadata = self.client.media_post(media_path, mime_type=media_type) except MastodonAPIError: - LOGGER.error(f"Unable to upload image {media_path}") + LOGGER.error("Unable to upload image %s", media_path) return mediadata diff --git a/homeassistant/components/monarch_money/config_flow.py b/homeassistant/components/monarch_money/config_flow.py index 5bfdc02c61e..e6ab84a4e74 100644 --- a/homeassistant/components/monarch_money/config_flow.py +++ b/homeassistant/components/monarch_money/config_flow.py @@ -87,7 +87,7 @@ async def validate_login( except LoginFailedException as err: raise InvalidAuth from err - LOGGER.debug(f"Connection successful - saving session to file {SESSION_FILE}") + LOGGER.debug("Connection successful - saving session to file %s", SESSION_FILE) LOGGER.debug("Obtaining subscription id") subs: MonarchSubscription = await monarch_client.get_subscription_details() assert subs is not None diff --git a/homeassistant/components/webmin/config_flow.py b/homeassistant/components/webmin/config_flow.py index 64f8c684dfa..903d6c50a09 100644 --- a/homeassistant/components/webmin/config_flow.py +++ b/homeassistant/components/webmin/config_flow.py @@ -45,7 +45,7 @@ async def validate_user_input( raise SchemaFlowError("invalid_auth") from err raise SchemaFlowError("cannot_connect") from err except Fault as fault: - LOGGER.exception(f"Fault {fault.faultCode}: {fault.faultString}") + LOGGER.exception("Fault %s: %s", fault.faultCode, fault.faultString) raise SchemaFlowError("unknown") from fault except ClientConnectionError as err: raise SchemaFlowError("cannot_connect") from err From e3f03c9da1ead7e9b64064e14db781ac9815b8b9 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 14 Jan 2025 11:20:35 +0100 Subject: [PATCH 0401/2987] Set inexogy quality scale to silver (#135547) --- homeassistant/components/discovergy/manifest.json | 1 + script/hassfest/quality_scale.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json index b82f28a5d11..2f74928c19e 100644 --- a/homeassistant/components/discovergy/manifest.json +++ b/homeassistant/components/discovergy/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/discovergy", "integration_type": "service", "iot_class": "cloud_polling", + "quality_scale": "silver", "requirements": ["pydiscovergy==3.0.2"] } diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 4ba312a4ffb..8c21bd1432d 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1360,7 +1360,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "directv", "discogs", "discord", - "discovergy", "dlib_face_detect", "dlib_face_identify", "dlink", From 959cea45b8b9496469494c709b9ee8c371310454 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 14 Jan 2025 11:30:10 +0100 Subject: [PATCH 0402/2987] Migrate Velbus to have Entity name (#135520) --- homeassistant/components/velbus/entity.py | 1 + .../components/velbus/quality_scale.yaml | 2 +- tests/components/velbus/conftest.py | 18 +-- .../velbus/snapshots/test_binary_sensor.ambr | 12 +- .../velbus/snapshots/test_button.ambr | 12 +- .../velbus/snapshots/test_climate.ambr | 12 +- .../velbus/snapshots/test_cover.ambr | 24 ++-- .../velbus/snapshots/test_init.ambr | 14 +- .../velbus/snapshots/test_light.ambr | 124 +++++++++--------- .../velbus/snapshots/test_select.ambr | 12 +- .../velbus/snapshots/test_sensor.ambr | 60 ++++----- .../velbus/snapshots/test_switch.ambr | 12 +- tests/components/velbus/test_button.py | 5 +- tests/components/velbus/test_climate.py | 8 +- tests/components/velbus/test_cover.py | 6 +- tests/components/velbus/test_init.py | 2 +- tests/components/velbus/test_light.py | 22 ++-- tests/components/velbus/test_select.py | 2 +- tests/components/velbus/test_switch.py | 4 +- 19 files changed, 180 insertions(+), 172 deletions(-) diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index 634d20dcfa6..07dac78b6f1 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -24,6 +24,7 @@ from .const import DOMAIN class VelbusEntity(Entity): """Representation of a Velbus entity.""" + _attr_has_entity_name = True _attr_should_poll: bool = False def __init__(self, channel: VelbusChannel) -> None: diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml index 05e9c168b92..95d0cc0355f 100644 --- a/homeassistant/components/velbus/quality_scale.yaml +++ b/homeassistant/components/velbus/quality_scale.yaml @@ -19,7 +19,7 @@ rules: docs-removal-instructions: done entity-event-setup: done entity-unique-id: done - has-entity-name: todo + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index b9145cc256a..5094b35d0aa 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -114,7 +114,7 @@ def mock_button() -> AsyncMock: channel.get_channel_number.return_value = 1 channel.get_module_type_name.return_value = "VMB4RYLD" channel.get_module_type.return_value = 99 - channel.get_full_name.return_value = "Channel full name" + channel.get_full_name.return_value = "Bedroom kid 1" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" channel.is_sub_device.return_value = False @@ -132,7 +132,7 @@ def mock_temperature() -> AsyncMock: channel.get_module_address.return_value = 88 channel.get_channel_number.return_value = 3 channel.get_module_type_name.return_value = "VMB4GPO" - channel.get_full_name.return_value = "Channel full name" + channel.get_full_name.return_value = "Living room" channel.get_module_sw_version.return_value = "3.0.0" channel.get_module_serial.return_value = "asdfghjk" channel.get_module_type.return_value = 1 @@ -160,7 +160,7 @@ def mock_relay() -> AsyncMock: channel.get_module_address.return_value = 88 channel.get_channel_number.return_value = 55 channel.get_module_type_name.return_value = "VMB4RYNO" - channel.get_full_name.return_value = "Full relay name" + channel.get_full_name.return_value = "Living room" channel.get_module_sw_version.return_value = "1.0.1" channel.get_module_serial.return_value = "qwerty123" channel.get_module_type.return_value = 2 @@ -179,7 +179,7 @@ def mock_select() -> AsyncMock: channel.get_channel_number.return_value = 33 channel.get_module_type_name.return_value = "VMB4RYNO" channel.get_module_type.return_value = 3 - channel.get_full_name.return_value = "Full module name" + channel.get_full_name.return_value = "Kitchen" channel.get_module_sw_version.return_value = "1.1.1" channel.get_module_serial.return_value = "qwerty1234567" channel.is_sub_device.return_value = False @@ -198,7 +198,7 @@ def mock_buttoncounter() -> AsyncMock: channel.get_channel_number.return_value = 2 channel.get_module_type_name.return_value = "VMB7IN" channel.get_module_type.return_value = 4 - channel.get_full_name.return_value = "Channel full name" + channel.get_full_name.return_value = "Input" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" channel.is_sub_device.return_value = True @@ -221,7 +221,7 @@ def mock_sensornumber() -> AsyncMock: channel.get_channel_number.return_value = 3 channel.get_module_type_name.return_value = "VMB7IN" channel.get_module_type.return_value = 8 - channel.get_full_name.return_value = "Channel full name" + channel.get_full_name.return_value = "Input" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" channel.is_sub_device.return_value = False @@ -242,7 +242,7 @@ def mock_lightsensor() -> AsyncMock: channel.get_channel_number.return_value = 4 channel.get_module_type_name.return_value = "VMB7IN" channel.get_module_type.return_value = 8 - channel.get_full_name.return_value = "Channel full name" + channel.get_full_name.return_value = "Input" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" channel.is_sub_device.return_value = False @@ -282,7 +282,7 @@ def mock_cover() -> AsyncMock: channel.get_channel_number.return_value = 9 channel.get_module_type_name.return_value = "VMB2BLE" channel.get_module_type.return_value = 10 - channel.get_full_name.return_value = "Full cover name" + channel.get_full_name.return_value = "Basement" channel.get_module_sw_version.return_value = "1.0.1" channel.get_module_serial.return_value = "1234" channel.is_sub_device.return_value = True @@ -305,7 +305,7 @@ def mock_cover_no_position() -> AsyncMock: channel.get_channel_number.return_value = 11 channel.get_module_type_name.return_value = "VMB2BLE" channel.get_module_type.return_value = 10 - channel.get_full_name.return_value = "Full cover name no position" + channel.get_full_name.return_value = "Basement" channel.get_module_sw_version.return_value = "1.0.1" channel.get_module_serial.return_value = "12345" channel.is_sub_device.return_value = True diff --git a/tests/components/velbus/snapshots/test_binary_sensor.ambr b/tests/components/velbus/snapshots/test_binary_sensor.ambr index 998f2528d9c..58630b9f6c9 100644 --- a/tests/components/velbus/snapshots/test_binary_sensor.ambr +++ b/tests/components/velbus/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[binary_sensor.buttonon-entry] +# name: test_entities[binary_sensor.bedroom_kid_1_buttonon-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,8 +11,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.buttonon', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.bedroom_kid_1_buttonon', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -32,13 +32,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[binary_sensor.buttonon-state] +# name: test_entities[binary_sensor.bedroom_kid_1_buttonon-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'ButtonOn', + 'friendly_name': 'Bedroom kid 1 ButtonOn', }), 'context': , - 'entity_id': 'binary_sensor.buttonon', + 'entity_id': 'binary_sensor.bedroom_kid_1_buttonon', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_button.ambr b/tests/components/velbus/snapshots/test_button.ambr index afe79466a44..952af21b43c 100644 --- a/tests/components/velbus/snapshots/test_button.ambr +++ b/tests/components/velbus/snapshots/test_button.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[button.buttonon-entry] +# name: test_entities[button.bedroom_kid_1_buttonon-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,8 +11,8 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': , - 'entity_id': 'button.buttonon', - 'has_entity_name': False, + 'entity_id': 'button.bedroom_kid_1_buttonon', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -32,13 +32,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.buttonon-state] +# name: test_entities[button.bedroom_kid_1_buttonon-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'ButtonOn', + 'friendly_name': 'Bedroom kid 1 ButtonOn', }), 'context': , - 'entity_id': 'button.buttonon', + 'entity_id': 'button.bedroom_kid_1_buttonon', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_climate.ambr b/tests/components/velbus/snapshots/test_climate.ambr index 567e45d9299..b1933e51868 100644 --- a/tests/components/velbus/snapshots/test_climate.ambr +++ b/tests/components/velbus/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[climate.temperature-entry] +# name: test_entities[climate.living_room_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24,8 +24,8 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.temperature', - 'has_entity_name': False, + 'entity_id': 'climate.living_room_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -45,11 +45,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[climate.temperature-state] +# name: test_entities[climate.living_room_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 20.0, - 'friendly_name': 'Temperature', + 'friendly_name': 'Living room Temperature', 'hvac_modes': list([ , , @@ -67,7 +67,7 @@ 'temperature': 21.0, }), 'context': , - 'entity_id': 'climate.temperature', + 'entity_id': 'climate.living_room_temperature', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_cover.ambr b/tests/components/velbus/snapshots/test_cover.ambr index 1ca867ec9a4..a9cbd4aae73 100644 --- a/tests/components/velbus/snapshots/test_cover.ambr +++ b/tests/components/velbus/snapshots/test_cover.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[cover.covername-entry] +# name: test_entities[cover.basement_covername-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,8 +11,8 @@ 'disabled_by': None, 'domain': 'cover', 'entity_category': None, - 'entity_id': 'cover.covername', - 'has_entity_name': False, + 'entity_id': 'cover.basement_covername', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -32,22 +32,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[cover.covername-state] +# name: test_entities[cover.basement_covername-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_position': 50, - 'friendly_name': 'CoverName', + 'friendly_name': 'Basement CoverName', 'supported_features': , }), 'context': , - 'entity_id': 'cover.covername', + 'entity_id': 'cover.basement_covername', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'open', }) # --- -# name: test_entities[cover.covernamenopos-entry] +# name: test_entities[cover.basement_covernamenopos-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -59,8 +59,8 @@ 'disabled_by': None, 'domain': 'cover', 'entity_category': None, - 'entity_id': 'cover.covernamenopos', - 'has_entity_name': False, + 'entity_id': 'cover.basement_covernamenopos', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -80,15 +80,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[cover.covernamenopos-state] +# name: test_entities[cover.basement_covernamenopos-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'assumed_state': True, - 'friendly_name': 'CoverNameNoPos', + 'friendly_name': 'Basement CoverNameNoPos', 'supported_features': , }), 'context': , - 'entity_id': 'cover.covernamenopos', + 'entity_id': 'cover.basement_covernamenopos', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_init.ambr b/tests/components/velbus/snapshots/test_init.ambr index 850231a45d2..a55a00ef0f2 100644 --- a/tests/components/velbus/snapshots/test_init.ambr +++ b/tests/components/velbus/snapshots/test_init.ambr @@ -23,7 +23,7 @@ 'manufacturer': 'Velleman', 'model': 'VMB4RYLD', 'model_id': '99', - 'name': 'Channel full name', + 'name': 'Bedroom kid 1', 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', @@ -53,7 +53,7 @@ 'manufacturer': 'Velleman', 'model': 'VMB2BLE', 'model_id': '10', - 'name': 'Full cover name', + 'name': 'Basement', 'name_by_user': None, 'primary_config_entry': , 'serial_number': '1234', @@ -83,7 +83,7 @@ 'manufacturer': 'Velleman', 'model': 'VMB2BLE', 'model_id': '10', - 'name': 'Full cover name no position', + 'name': 'Basement', 'name_by_user': None, 'primary_config_entry': , 'serial_number': '12345', @@ -143,7 +143,7 @@ 'manufacturer': 'Velleman', 'model': 'VMB7IN', 'model_id': '4', - 'name': 'Channel full name', + 'name': 'Input', 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', @@ -173,7 +173,7 @@ 'manufacturer': 'Velleman', 'model': 'VMB4GPO', 'model_id': '1', - 'name': 'Channel full name', + 'name': 'Living room', 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'asdfghjk', @@ -203,7 +203,7 @@ 'manufacturer': 'Velleman', 'model': 'VMB7IN', 'model_id': '8', - 'name': 'Channel full name', + 'name': 'Input', 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', @@ -233,7 +233,7 @@ 'manufacturer': 'Velleman', 'model': 'VMB4RYNO', 'model_id': '2', - 'name': 'Full relay name', + 'name': 'Living room', 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'qwerty123', diff --git a/tests/components/velbus/snapshots/test_light.ambr b/tests/components/velbus/snapshots/test_light.ambr index ec18305984c..b7009a0c66a 100644 --- a/tests/components/velbus/snapshots/test_light.ambr +++ b/tests/components/velbus/snapshots/test_light.ambr @@ -1,61 +1,5 @@ # serializer version: 1 -# name: test_entities[light.dimmer-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'supported_color_modes': list([ - , - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'light', - 'entity_category': None, - 'entity_id': 'light.dimmer', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Dimmer', - 'platform': 'velbus', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': 'a1b2c3d4e5f6g7-10', - 'unit_of_measurement': None, - }) -# --- -# name: test_entities[light.dimmer-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': None, - 'color_mode': None, - 'friendly_name': 'Dimmer', - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.dimmer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_entities[light.led_buttonon-entry] +# name: test_entities[light.bedroom_kid_1_led_buttonon-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -71,8 +15,8 @@ 'disabled_by': None, 'domain': 'light', 'entity_category': , - 'entity_id': 'light.led_buttonon', - 'has_entity_name': False, + 'entity_id': 'light.bedroom_kid_1_led_buttonon', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -92,18 +36,74 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[light.led_buttonon-state] +# name: test_entities[light.bedroom_kid_1_led_buttonon-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'color_mode': None, - 'friendly_name': 'LED ButtonOn', + 'friendly_name': 'Bedroom kid 1 LED ButtonOn', 'supported_color_modes': list([ , ]), 'supported_features': , }), 'context': , - 'entity_id': 'light.led_buttonon', + 'entity_id': 'light.bedroom_kid_1_led_buttonon', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[light.dimmer_full_name_dimmer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.dimmer_full_name_dimmer', + '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': 'Dimmer', + 'platform': 'velbus', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'a1b2c3d4e5f6g7-10', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.dimmer_full_name_dimmer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Dimmer full name Dimmer', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.dimmer_full_name_dimmer', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_select.ambr b/tests/components/velbus/snapshots/test_select.ambr index 5678c0ded5f..288eb10a3c3 100644 --- a/tests/components/velbus/snapshots/test_select.ambr +++ b/tests/components/velbus/snapshots/test_select.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[select.select-entry] +# name: test_entities[select.kitchen_select-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -18,8 +18,8 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': , - 'entity_id': 'select.select', - 'has_entity_name': False, + 'entity_id': 'select.kitchen_select', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -39,10 +39,10 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[select.select-state] +# name: test_entities[select.kitchen_select-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'select', + 'friendly_name': 'Kitchen select', 'options': list([ 'none', 'summer', @@ -51,7 +51,7 @@ ]), }), 'context': , - 'entity_id': 'select.select', + 'entity_id': 'select.kitchen_select', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_sensor.ambr b/tests/components/velbus/snapshots/test_sensor.ambr index 132f4c7a059..6860ad73e2c 100644 --- a/tests/components/velbus/snapshots/test_sensor.ambr +++ b/tests/components/velbus/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[sensor.buttoncounter-entry] +# name: test_entities[sensor.input_buttoncounter-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -13,8 +13,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.buttoncounter', - 'has_entity_name': False, + 'entity_id': 'sensor.input_buttoncounter', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -34,23 +34,23 @@ 'unit_of_measurement': 'W', }) # --- -# name: test_entities[sensor.buttoncounter-state] +# name: test_entities[sensor.input_buttoncounter-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'ButtonCounter', + 'friendly_name': 'Input ButtonCounter', 'state_class': , 'unit_of_measurement': 'W', }), 'context': , - 'entity_id': 'sensor.buttoncounter', + 'entity_id': 'sensor.input_buttoncounter', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '100.0', }) # --- -# name: test_entities[sensor.buttoncounter_counter-entry] +# name: test_entities[sensor.input_buttoncounter_counter-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -64,8 +64,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.buttoncounter_counter', - 'has_entity_name': False, + 'entity_id': 'sensor.input_buttoncounter_counter', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -85,24 +85,24 @@ 'unit_of_measurement': 'kWh', }) # --- -# name: test_entities[sensor.buttoncounter_counter-state] +# name: test_entities[sensor.input_buttoncounter_counter-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'ButtonCounter-counter', + 'friendly_name': 'Input ButtonCounter-counter', 'icon': 'mdi:counter', 'state_class': , 'unit_of_measurement': 'kWh', }), 'context': , - 'entity_id': 'sensor.buttoncounter_counter', + 'entity_id': 'sensor.input_buttoncounter_counter', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '100.0', }) # --- -# name: test_entities[sensor.lightsensor-entry] +# name: test_entities[sensor.input_lightsensor-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -116,8 +116,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.lightsensor', - 'has_entity_name': False, + 'entity_id': 'sensor.input_lightsensor', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -137,22 +137,22 @@ 'unit_of_measurement': 'illuminance', }) # --- -# name: test_entities[sensor.lightsensor-state] +# name: test_entities[sensor.input_lightsensor-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'LightSensor', + 'friendly_name': 'Input LightSensor', 'state_class': , 'unit_of_measurement': 'illuminance', }), 'context': , - 'entity_id': 'sensor.lightsensor', + 'entity_id': 'sensor.input_lightsensor', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '250.0', }) # --- -# name: test_entities[sensor.sensornumber-entry] +# name: test_entities[sensor.input_sensornumber-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -166,8 +166,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.sensornumber', - 'has_entity_name': False, + 'entity_id': 'sensor.input_sensornumber', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -187,22 +187,22 @@ 'unit_of_measurement': 'm', }) # --- -# name: test_entities[sensor.sensornumber-state] +# name: test_entities[sensor.input_sensornumber-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'SensorNumber', + 'friendly_name': 'Input SensorNumber', 'state_class': , 'unit_of_measurement': 'm', }), 'context': , - 'entity_id': 'sensor.sensornumber', + 'entity_id': 'sensor.input_sensornumber', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '10.0', }) # --- -# name: test_entities[sensor.temperature-entry] +# name: test_entities[sensor.living_room_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -216,8 +216,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.temperature', - 'has_entity_name': False, + 'entity_id': 'sensor.living_room_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -237,16 +237,16 @@ 'unit_of_measurement': , }) # --- -# name: test_entities[sensor.temperature-state] +# name: test_entities[sensor.living_room_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Temperature', + 'friendly_name': 'Living room Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.temperature', + 'entity_id': 'sensor.living_room_temperature', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_switch.ambr b/tests/components/velbus/snapshots/test_switch.ambr index 87f0f7eac02..e9090c396d1 100644 --- a/tests/components/velbus/snapshots/test_switch.ambr +++ b/tests/components/velbus/snapshots/test_switch.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[switch.relayname-entry] +# name: test_entities[switch.living_room_relayname-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,8 +11,8 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.relayname', - 'has_entity_name': False, + 'entity_id': 'switch.living_room_relayname', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -32,13 +32,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[switch.relayname-state] +# name: test_entities[switch.living_room_relayname-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'RelayName', + 'friendly_name': 'Living room RelayName', }), 'context': , - 'entity_id': 'switch.relayname', + 'entity_id': 'switch.living_room_relayname', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/test_button.py b/tests/components/velbus/test_button.py index d1079497879..cf334a29b05 100644 --- a/tests/components/velbus/test_button.py +++ b/tests/components/velbus/test_button.py @@ -38,6 +38,9 @@ async def test_button_press( """Test button press.""" await init_integration(hass, config_entry) await hass.services.async_call( - BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: "button.buttonon"}, blocking=True + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.bedroom_kid_1_buttonon"}, + blocking=True, ) mock_button.press.assert_called_once_with() diff --git a/tests/components/velbus/test_climate.py b/tests/components/velbus/test_climate.py index fd0a268bb0f..c843bca6e68 100644 --- a/tests/components/velbus/test_climate.py +++ b/tests/components/velbus/test_climate.py @@ -51,7 +51,7 @@ async def test_set_target_temperature( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.temperature", ATTR_TEMPERATURE: 29}, + {ATTR_ENTITY_ID: "climate.living_room_temperature", ATTR_TEMPERATURE: 29}, blocking=True, ) mock_temperature.set_temp.assert_called_once_with(29) @@ -78,7 +78,7 @@ async def test_set_preset_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.temperature", ATTR_PRESET_MODE: set_mode}, + {ATTR_ENTITY_ID: "climate.living_room_temperature", ATTR_PRESET_MODE: set_mode}, blocking=True, ) mock_temperature.set_preset.assert_called_once_with(expected_mode) @@ -102,7 +102,7 @@ async def test_set_hvac_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.temperature", ATTR_HVAC_MODE: set_mode}, + {ATTR_ENTITY_ID: "climate.living_room_temperature", ATTR_HVAC_MODE: set_mode}, blocking=True, ) mock_temperature.set_mode.assert_called_once_with(set_mode) @@ -119,7 +119,7 @@ async def test_set_hvac_mode_invalid( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.temperature", ATTR_HVAC_MODE: "auto"}, + {ATTR_ENTITY_ID: "climate.living_room_temperature", ATTR_HVAC_MODE: "auto"}, blocking=True, ) mock_temperature.set_mode.assert_not_called() diff --git a/tests/components/velbus/test_cover.py b/tests/components/velbus/test_cover.py index fe3fbbe1594..24a90e0f8d1 100644 --- a/tests/components/velbus/test_cover.py +++ b/tests/components/velbus/test_cover.py @@ -38,8 +38,8 @@ async def test_entities( @pytest.mark.parametrize( ("entity_id", "entity_num"), [ - ("cover.covername", 0), - ("cover.covernamenopos", 1), + ("cover.basement_covername", 0), + ("cover.basement_covernamenopos", 1), ], ) async def test_actions( @@ -84,7 +84,7 @@ async def test_position( await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.covername", ATTR_POSITION: 25}, + {ATTR_ENTITY_ID: "cover.basement_covername", ATTR_POSITION: 25}, blocking=True, ) mock_cover.set_position.assert_called_once_with(75) diff --git a/tests/components/velbus/test_init.py b/tests/components/velbus/test_init.py index 436a3d8fa95..3285099f2a2 100644 --- a/tests/components/velbus/test_init.py +++ b/tests/components/velbus/test_init.py @@ -132,7 +132,7 @@ async def test_api_call( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.relayname"}, + {ATTR_ENTITY_ID: "switch.living_room_relayname"}, blocking=True, ) diff --git a/tests/components/velbus/test_light.py b/tests/components/velbus/test_light.py index 344d1626bbd..0ce93d6e6bb 100644 --- a/tests/components/velbus/test_light.py +++ b/tests/components/velbus/test_light.py @@ -52,7 +52,7 @@ async def test_dimmer_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.dimmer"}, + {ATTR_ENTITY_ID: "light.dimmer_full_name_dimmer"}, blocking=True, ) mock_dimmer.set_dimmer_state.assert_called_once_with(0, 0) @@ -60,7 +60,7 @@ async def test_dimmer_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.dimmer", ATTR_TRANSITION: 1}, + {ATTR_ENTITY_ID: "light.dimmer_full_name_dimmer", ATTR_TRANSITION: 1}, blocking=True, ) mock_dimmer.restore_dimmer_state.assert_called_once_with(1) @@ -68,7 +68,11 @@ async def test_dimmer_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.dimmer", ATTR_BRIGHTNESS: 0, ATTR_TRANSITION: 1}, + { + ATTR_ENTITY_ID: "light.dimmer_full_name_dimmer", + ATTR_BRIGHTNESS: 0, + ATTR_TRANSITION: 1, + }, blocking=True, ) mock_dimmer.set_dimmer_state.assert_called_with(0, 1) @@ -77,7 +81,7 @@ async def test_dimmer_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.dimmer", ATTR_BRIGHTNESS: 33}, + {ATTR_ENTITY_ID: "light.dimmer_full_name_dimmer", ATTR_BRIGHTNESS: 33}, blocking=True, ) mock_dimmer.set_dimmer_state.assert_called_with(12, 0) @@ -96,7 +100,7 @@ async def test_led_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.led_buttonon"}, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon"}, blocking=True, ) mock_button.set_led_state.assert_called_once_with("off") @@ -104,7 +108,7 @@ async def test_led_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.led_buttonon"}, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon"}, blocking=True, ) mock_button.set_led_state.assert_called_with("on") @@ -113,7 +117,7 @@ async def test_led_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.led_buttonon", ATTR_FLASH: FLASH_LONG}, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon", ATTR_FLASH: FLASH_LONG}, blocking=True, ) mock_button.set_led_state.assert_called_with("slow") @@ -122,7 +126,7 @@ async def test_led_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.led_buttonon", ATTR_FLASH: FLASH_SHORT}, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon", ATTR_FLASH: FLASH_SHORT}, blocking=True, ) mock_button.set_led_state.assert_called_with("fast") @@ -131,7 +135,7 @@ async def test_led_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.led_buttonon", ATTR_FLASH: FLASH_SHORT}, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon", ATTR_FLASH: FLASH_SHORT}, blocking=True, ) mock_button.set_led_state.assert_called_with("fast") diff --git a/tests/components/velbus/test_select.py b/tests/components/velbus/test_select.py index 64ac2c98009..782ae53d440 100644 --- a/tests/components/velbus/test_select.py +++ b/tests/components/velbus/test_select.py @@ -46,7 +46,7 @@ async def test_select_program( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: "select.select", ATTR_OPTION: set_program}, + {ATTR_ENTITY_ID: "select.kitchen_select", ATTR_OPTION: set_program}, blocking=True, ) mock_select.set_selected_program.assert_called_once_with(set_program) diff --git a/tests/components/velbus/test_switch.py b/tests/components/velbus/test_switch.py index 9efb65af68d..ebb1da084c4 100644 --- a/tests/components/velbus/test_switch.py +++ b/tests/components/velbus/test_switch.py @@ -43,7 +43,7 @@ async def test_switch_on_off( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.relayname"}, + {ATTR_ENTITY_ID: "switch.living_room_relayname"}, blocking=True, ) mock_relay.turn_off.assert_called_once_with() @@ -51,7 +51,7 @@ async def test_switch_on_off( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.relayname"}, + {ATTR_ENTITY_ID: "switch.living_room_relayname"}, blocking=True, ) mock_relay.turn_on.assert_called_once_with() From 096c6b85757a107d0ec6683fb8de2c3a860180dd Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 14 Jan 2025 11:32:33 +0100 Subject: [PATCH 0403/2987] Mark Velbus test coverage as done (#135571) --- homeassistant/components/velbus/quality_scale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml index 95d0cc0355f..0ad3e3ce485 100644 --- a/homeassistant/components/velbus/quality_scale.yaml +++ b/homeassistant/components/velbus/quality_scale.yaml @@ -38,7 +38,7 @@ rules: status: exempt comment: | This integration does not require authentication. - test-coverage: todo + test-coverage: done # Gold devices: done diagnostics: done From 6359a759774afc005a1e971891f0382882561269 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 14 Jan 2025 11:34:37 +0100 Subject: [PATCH 0404/2987] Cleanup tedee callbacks (#135577) --- .../components/tedee/binary_sensor.py | 11 +++------ homeassistant/components/tedee/coordinator.py | 7 +++--- homeassistant/components/tedee/lock.py | 23 ++++++++----------- homeassistant/components/tedee/sensor.py | 11 +++------ 4 files changed, 18 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py index 94d3f0b6831..4f167619f04 100644 --- a/homeassistant/components/tedee/binary_sensor.py +++ b/homeassistant/components/tedee/binary_sensor.py @@ -69,20 +69,15 @@ async def async_setup_entry( """Set up the Tedee sensor entity.""" coordinator = entry.runtime_data - async_add_entities( - TedeeBinarySensorEntity(lock, coordinator, entity_description) - for lock in coordinator.data.values() - for entity_description in ENTITIES - ) - - def _async_add_new_lock(lock_id: int) -> None: - lock = coordinator.data[lock_id] + def _async_add_new_lock(locks: list[TedeeLock]) -> None: async_add_entities( TedeeBinarySensorEntity(lock, coordinator, entity_description) for entity_description in ENTITIES + for lock in locks ) coordinator.new_lock_callbacks.append(_async_add_new_lock) + _async_add_new_lock(list(coordinator.data.values())) class TedeeBinarySensorEntity(TedeeDescriptionEntity, BinarySensorEntity): diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index 4012b6d07c5..f9ebb29dd04 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -60,7 +60,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): self._next_get_locks = time.time() self._locks_last_update: set[int] = set() - self.new_lock_callbacks: list[Callable[[int], None]] = [] + self.new_lock_callbacks: list[Callable[[list[TedeeLock]], None]] = [] self.tedee_webhook_id: int | None = None async def _async_setup(self) -> None: @@ -158,8 +158,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): # add new locks if new_locks := current_locks - self._locks_last_update: _LOGGER.debug("New locks found: %s", ", ".join(map(str, new_locks))) - for lock_id in new_locks: - for callback in self.new_lock_callbacks: - callback(lock_id) + for callback in self.new_lock_callbacks: + callback([self.data[lock_id] for lock_id in new_locks]) self._locks_last_update = current_locks diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py index 38df85a9cdb..482cd039a98 100644 --- a/homeassistant/components/tedee/lock.py +++ b/homeassistant/components/tedee/lock.py @@ -24,23 +24,18 @@ async def async_setup_entry( """Set up the Tedee lock entity.""" coordinator = entry.runtime_data - entities: list[TedeeLockEntity] = [] - for lock in coordinator.data.values(): - if lock.is_enabled_pullspring: - entities.append(TedeeLockWithLatchEntity(lock, coordinator)) - else: - entities.append(TedeeLockEntity(lock, coordinator)) - - def _async_add_new_lock(lock_id: int) -> None: - lock = coordinator.data[lock_id] - if lock.is_enabled_pullspring: - async_add_entities([TedeeLockWithLatchEntity(lock, coordinator)]) - else: - async_add_entities([TedeeLockEntity(lock, coordinator)]) + def _async_add_new_lock(locks: list[TedeeLock]) -> None: + entities: list[TedeeLockEntity] = [] + for lock in locks: + if lock.is_enabled_pullspring: + entities.append(TedeeLockWithLatchEntity(lock, coordinator)) + else: + entities.append(TedeeLockEntity(lock, coordinator)) + async_add_entities(entities) coordinator.new_lock_callbacks.append(_async_add_new_lock) - async_add_entities(entities) + _async_add_new_lock(list(coordinator.data.values())) class TedeeLockEntity(TedeeEntity, LockEntity): diff --git a/homeassistant/components/tedee/sensor.py b/homeassistant/components/tedee/sensor.py index d61e7360dc4..828793b4458 100644 --- a/homeassistant/components/tedee/sensor.py +++ b/homeassistant/components/tedee/sensor.py @@ -58,20 +58,15 @@ async def async_setup_entry( """Set up the Tedee sensor entity.""" coordinator = entry.runtime_data - async_add_entities( - TedeeSensorEntity(lock, coordinator, entity_description) - for lock in coordinator.data.values() - for entity_description in ENTITIES - ) - - def _async_add_new_lock(lock_id: int) -> None: - lock = coordinator.data[lock_id] + def _async_add_new_lock(locks: list[TedeeLock]) -> None: async_add_entities( TedeeSensorEntity(lock, coordinator, entity_description) for entity_description in ENTITIES + for lock in locks ) coordinator.new_lock_callbacks.append(_async_add_new_lock) + _async_add_new_lock(list(coordinator.data.values())) class TedeeSensorEntity(TedeeDescriptionEntity, SensorEntity): From f4e7c9d6c39ba68fa970c8557581abeb6604aa2f Mon Sep 17 00:00:00 2001 From: jesperraemaekers <146726232+jesperraemaekers@users.noreply.github.com> Date: Tue, 14 Jan 2025 11:36:26 +0100 Subject: [PATCH 0405/2987] Bump Weheat to 2025.1.14 (#135578) --- homeassistant/components/weheat/__init__.py | 4 +++- homeassistant/components/weheat/config_flow.py | 4 ++-- homeassistant/components/weheat/coordinator.py | 16 +++++++--------- homeassistant/components/weheat/manifest.json | 2 +- .../components/weheat/quality_scale.yaml | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/weheat/conftest.py | 4 ++-- tests/components/weheat/test_config_flow.py | 4 ++-- 9 files changed, 20 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/weheat/__init__.py b/homeassistant/components/weheat/__init__.py index 37c1f721078..c14bbbcb028 100644 --- a/homeassistant/components/weheat/__init__.py +++ b/homeassistant/components/weheat/__init__.py @@ -48,7 +48,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bo # fetch a list of the heat pumps the entry can access try: - discovered_heat_pumps = await HeatPumpDiscovery.discover_active(API_URL, token) + discovered_heat_pumps = await HeatPumpDiscovery.async_discover_active( + API_URL, token + ) except UnauthorizedException as error: raise ConfigEntryAuthFailed from error diff --git a/homeassistant/components/weheat/config_flow.py b/homeassistant/components/weheat/config_flow.py index b1a0b5dd4ea..318a02ee47f 100644 --- a/homeassistant/components/weheat/config_flow.py +++ b/homeassistant/components/weheat/config_flow.py @@ -4,7 +4,7 @@ from collections.abc import Mapping import logging from typing import Any -from weheat.abstractions.user import get_user_id_from_token +from weheat.abstractions.user import async_get_user_id_from_token from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN @@ -33,7 +33,7 @@ class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Override the create entry method to change to the step to find the heat pumps.""" # get the user id and use that as unique id for this entry - user_id = await get_user_id_from_token( + user_id = await async_get_user_id_from_token( API_URL, data[CONF_TOKEN][CONF_ACCESS_TOKEN] ) await self.async_set_unique_id(user_id) diff --git a/homeassistant/components/weheat/coordinator.py b/homeassistant/components/weheat/coordinator.py index a50e9daec18..94d897351eb 100644 --- a/homeassistant/components/weheat/coordinator.py +++ b/homeassistant/components/weheat/coordinator.py @@ -68,19 +68,17 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): """Return the model of the heat pump.""" return self.heat_pump_info.model - def fetch_data(self) -> HeatPump: - """Get the data from the API.""" + async def _async_update_data(self) -> HeatPump: + """Fetch data from the API.""" + await self.session.async_ensure_token_valid() + try: - self._heat_pump_data.get_status(self.session.token[CONF_ACCESS_TOKEN]) + await self._heat_pump_data.async_get_status( + self.session.token[CONF_ACCESS_TOKEN] + ) except UnauthorizedException as error: raise ConfigEntryAuthFailed from error except EXCEPTIONS as error: raise UpdateFailed(error) from error return self._heat_pump_data - - async def _async_update_data(self) -> HeatPump: - """Fetch data from the API.""" - await self.session.async_ensure_token_valid() - - return await self.hass.async_add_executor_job(self.fetch_data) diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index 1c6242de29c..c81fe570691 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", "iot_class": "cloud_polling", - "requirements": ["weheat==2024.12.22"] + "requirements": ["weheat==2025.1.14"] } diff --git a/homeassistant/components/weheat/quality_scale.yaml b/homeassistant/components/weheat/quality_scale.yaml index a8431ad3aad..f924c0c3582 100644 --- a/homeassistant/components/weheat/quality_scale.yaml +++ b/homeassistant/components/weheat/quality_scale.yaml @@ -88,6 +88,6 @@ rules: While unlikely to happen. Check if it is easily integrated. # Platinum - async-dependency: todo + async-dependency: done inject-websession: todo strict-typing: todo diff --git a/requirements_all.txt b/requirements_all.txt index 91fb812e0eb..0086bb8a3b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3033,7 +3033,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2024.12.22 +weheat==2025.1.14 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8602ae394b0..66a7a289438 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2437,7 +2437,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2024.12.22 +weheat==2025.1.14 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.11 diff --git a/tests/components/weheat/conftest.py b/tests/components/weheat/conftest.py index 1bbe91fc573..dbdeb0726dd 100644 --- a/tests/components/weheat/conftest.py +++ b/tests/components/weheat/conftest.py @@ -81,7 +81,7 @@ def mock_user_id() -> Generator[AsyncMock]: """Mock the user API call.""" with ( patch( - "homeassistant.components.weheat.config_flow.get_user_id_from_token", + "homeassistant.components.weheat.config_flow.async_get_user_id_from_token", return_value=USER_UUID_1, ) as user_mock, ): @@ -93,7 +93,7 @@ def mock_weheat_discover(mock_heat_pump_info) -> Generator[AsyncMock]: """Mock an Weheat discovery.""" with ( patch( - "homeassistant.components.weheat.HeatPumpDiscovery.discover_active", + "homeassistant.components.weheat.HeatPumpDiscovery.async_discover_active", autospec=True, ) as mock_discover, ): diff --git a/tests/components/weheat/test_config_flow.py b/tests/components/weheat/test_config_flow.py index b33dd0a8db8..45f2285fd03 100644 --- a/tests/components/weheat/test_config_flow.py +++ b/tests/components/weheat/test_config_flow.py @@ -47,7 +47,7 @@ async def test_full_flow( with ( patch( - "homeassistant.components.weheat.config_flow.get_user_id_from_token", + "homeassistant.components.weheat.config_flow.async_get_user_id_from_token", return_value=USER_UUID_1, ) as mock_weheat, ): @@ -89,7 +89,7 @@ async def test_duplicate_unique_id( with ( patch( - "homeassistant.components.weheat.config_flow.get_user_id_from_token", + "homeassistant.components.weheat.config_flow.async_get_user_id_from_token", return_value=USER_UUID_1, ), ): From 2b51ab1c75bbd69c1f883c2ec7d42960e2c867e4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 14 Jan 2025 11:45:07 +0100 Subject: [PATCH 0406/2987] Set MQTT quality scale to gold (#135579) --- homeassistant/components/mqtt/manifest.json | 1 + script/hassfest/quality_scale.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json index 081449b142a..2e5b19b49a9 100644 --- a/homeassistant/components/mqtt/manifest.json +++ b/homeassistant/components/mqtt/manifest.json @@ -7,6 +7,7 @@ "dependencies": ["file_upload", "http"], "documentation": "https://www.home-assistant.io/integrations/mqtt", "iot_class": "local_push", + "quality_scale": "gold", "requirements": ["paho-mqtt==1.6.1"], "single_config_entry": true } diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 8c21bd1432d..d1e1a43b48e 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1756,7 +1756,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "motioneye", "motionmount", "mpd", - "mqtt", "mqtt_eventstream", "mqtt_json", "mqtt_room", From 8db63adc119df961e6abb7a72fa4a220c6ebb49c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 14 Jan 2025 11:46:12 +0100 Subject: [PATCH 0407/2987] Bump ruff to 0.9.1 (#135197) --- .pre-commit-config.yaml | 2 +- .../components/assist_pipeline/pipeline.py | 6 +-- .../components/device_tracker/legacy.py | 18 +++---- homeassistant/components/google_cloud/stt.py | 6 +-- homeassistant/components/logbook/processor.py | 6 +-- homeassistant/components/matter/climate.py | 6 +-- homeassistant/components/modbus/entity.py | 6 +-- homeassistant/components/nmbs/config_flow.py | 10 ++-- homeassistant/components/nmbs/sensor.py | 9 ++-- homeassistant/components/onvif/event.py | 12 ++--- .../components/openhome/media_player.py | 8 ++- homeassistant/components/qwikswitch/sensor.py | 6 +-- .../components/recorder/migration.py | 12 ++--- homeassistant/components/recorder/pool.py | 6 +-- .../components/recorder/statistics.py | 30 +++++------ homeassistant/components/zeroconf/__init__.py | 6 +-- homeassistant/exceptions.py | 6 +-- homeassistant/helpers/entity.py | 6 +-- homeassistant/helpers/http.py | 6 +-- homeassistant/helpers/template.py | 18 +++---- pyproject.toml | 2 +- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- tests/common.py | 18 +++---- .../components/anthropic/test_conversation.py | 6 +-- .../aurora_abb_powerone/test_sensor.py | 6 +-- tests/components/configurator/test_init.py | 6 +-- .../conversation/test_default_agent.py | 12 ++--- tests/components/device_tracker/test_init.py | 12 ++--- tests/components/dsmr/conftest.py | 18 +++---- tests/components/ecovacs/test_button.py | 6 +-- tests/components/ecovacs/test_init.py | 6 +-- tests/components/ecovacs/test_number.py | 6 +-- tests/components/ecovacs/test_sensor.py | 6 +-- tests/components/ecovacs/test_switch.py | 6 +-- .../test_conversation.py | 6 +-- tests/components/http/test_auth.py | 24 ++++----- tests/components/knx/conftest.py | 12 ++--- tests/components/nest/test_media_source.py | 12 ++--- tests/components/nmbs/test_config_flow.py | 26 ++++----- tests/components/ollama/test_conversation.py | 18 +++---- .../openai_conversation/test_conversation.py | 30 +++++------ tests/components/smartthings/test_sensor.py | 6 +-- tests/components/stream/test_ll_hls.py | 4 +- .../tesla_wall_connector/conftest.py | 12 ++--- tests/components/tplink/__init__.py | 54 +++++++++---------- tests/components/vesync/test_humidifier.py | 2 +- tests/components/zwave_js/conftest.py | 12 ++--- tests/conftest.py | 4 +- 49 files changed, 256 insertions(+), 265 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b99aa41f0cc..c59e6ebb147 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.6 + rev: v0.9.1 hooks: - id: ruff args: diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 1b76121fcd2..8979cae068e 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1472,9 +1472,9 @@ class PipelineInput: if stt_audio_buffer: # Send audio in the buffer first to speech-to-text, then move on to stt_stream. # This is basically an async itertools.chain. - async def buffer_then_audio_stream() -> ( - AsyncGenerator[EnhancedAudioChunk] - ): + async def buffer_then_audio_stream() -> AsyncGenerator[ + EnhancedAudioChunk + ]: # Buffered audio for chunk in stt_audio_buffer: yield chunk diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 5dff5837b4b..b1520866bb5 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -978,9 +978,9 @@ class DeviceScanner: async def async_scan_devices(self) -> list[str]: """Scan for devices.""" - assert ( - self.hass is not None - ), "hass should be set by async_setup_scanner_platform" + assert self.hass is not None, ( + "hass should be set by async_setup_scanner_platform" + ) return await self.hass.async_add_executor_job(self.scan_devices) def get_device_name(self, device: str) -> str | None: @@ -989,9 +989,9 @@ class DeviceScanner: async def async_get_device_name(self, device: str) -> str | None: """Get the name of a device.""" - assert ( - self.hass is not None - ), "hass should be set by async_setup_scanner_platform" + assert self.hass is not None, ( + "hass should be set by async_setup_scanner_platform" + ) return await self.hass.async_add_executor_job(self.get_device_name, device) def get_extra_attributes(self, device: str) -> dict: @@ -1000,9 +1000,9 @@ class DeviceScanner: async def async_get_extra_attributes(self, device: str) -> dict: """Get the extra attributes of a device.""" - assert ( - self.hass is not None - ), "hass should be set by async_setup_scanner_platform" + assert self.hass is not None, ( + "hass should be set by async_setup_scanner_platform" + ) return await self.hass.async_add_executor_job(self.get_extra_attributes, device) diff --git a/homeassistant/components/google_cloud/stt.py b/homeassistant/components/google_cloud/stt.py index 99b7dadbb0e..ebca586d1a3 100644 --- a/homeassistant/components/google_cloud/stt.py +++ b/homeassistant/components/google_cloud/stt.py @@ -114,9 +114,9 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): ) ) - async def request_generator() -> ( - AsyncGenerator[speech_v1.StreamingRecognizeRequest] - ): + async def request_generator() -> AsyncGenerator[ + speech_v1.StreamingRecognizeRequest + ]: # The first request must only contain a streaming_config yield speech_v1.StreamingRecognizeRequest(streaming_config=streaming_config) # All subsequent requests must only contain audio_content diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 77aa71740f1..a53a604daae 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -115,9 +115,9 @@ class EventProcessor: include_entity_name: bool = True, ) -> None: """Init the event stream.""" - assert not ( - context_id and (entity_ids or device_ids) - ), "can't pass in both context_id and (entity_ids or device_ids)" + assert not (context_id and (entity_ids or device_ids)), ( + "can't pass in both context_id and (entity_ids or device_ids)" + ) self.hass = hass self.ent_reg = er.async_get(hass) self.event_types = event_types diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 0378d0ea226..be6f024695d 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -310,13 +310,11 @@ class MatterClimate(MatterEntity, ClimateEntity): ): match running_state_value: case ( - ThermostatRunningState.Heat - | ThermostatRunningState.HeatStage2 + ThermostatRunningState.Heat | ThermostatRunningState.HeatStage2 ): self._attr_hvac_action = HVACAction.HEATING case ( - ThermostatRunningState.Cool - | ThermostatRunningState.CoolStage2 + ThermostatRunningState.Cool | ThermostatRunningState.CoolStage2 ): self._attr_hvac_action = HVACAction.COOLING case ( diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 90833516e59..03bcc98de40 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -98,9 +98,9 @@ class BasePlatform(Entity): def get_optional_numeric_config(config_name: str) -> int | float | None: if (val := entry.get(config_name)) is None: return None - assert isinstance( - val, (float, int) - ), f"Expected float or int but {config_name} was {type(val)}" + assert isinstance(val, (float, int)), ( + f"Expected float or int but {config_name} was {type(val)}" + ) return val self._min_value = get_optional_numeric_config(CONF_MIN_VALUE) diff --git a/homeassistant/components/nmbs/config_flow.py b/homeassistant/components/nmbs/config_flow.py index 553e6492d2a..24ef8cd4995 100644 --- a/homeassistant/components/nmbs/config_flow.py +++ b/homeassistant/components/nmbs/config_flow.py @@ -84,7 +84,7 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured() - config_entry_name = f"Train from {station_from["standardname"]} to {station_to["standardname"]}" + config_entry_name = f"Train from {station_from['standardname']} to {station_to['standardname']}" return self.async_create_entry( title=config_entry_name, data=user_input, @@ -157,18 +157,18 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): if entity_id := entity_registry.async_get_entity_id( Platform.SENSOR, DOMAIN, - f"{prefix}_{station_live["standardname"]}_{station_from["standardname"]}_{station_to["standardname"]}", + f"{prefix}_{station_live['standardname']}_{station_from['standardname']}_{station_to['standardname']}", ): - new_unique_id = f"{DOMAIN}_{prefix}_{station_live["id"]}_{station_from["id"]}_{station_to["id"]}" + new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}" entity_registry.async_update_entity( entity_id, new_unique_id=new_unique_id ) if entity_id := entity_registry.async_get_entity_id( Platform.SENSOR, DOMAIN, - f"{prefix}_{station_live["name"]}_{station_from["name"]}_{station_to["name"]}", + f"{prefix}_{station_live['name']}_{station_from['name']}_{station_to['name']}", ): - new_unique_id = f"{DOMAIN}_{prefix}_{station_live["id"]}_{station_from["id"]}_{station_to["id"]}" + new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}" entity_registry.async_update_entity( entity_id, new_unique_id=new_unique_id ) diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index eea1745fcc1..85ae56144a0 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -201,15 +201,14 @@ class NMBSLiveBoard(SensorEntity): @property def name(self) -> str: """Return the sensor default name.""" - return f"Trains in {self._station["standardname"]}" + return f"Trains in {self._station['standardname']}" @property def unique_id(self) -> str: """Return the unique ID.""" unique_id = ( - f"{self._station["id"]}_{self._station_from["id"]}_" - f"{self._station_to["id"]}" + f"{self._station['id']}_{self._station_from['id']}_{self._station_to['id']}" ) return f"nmbs_live_{unique_id}" @@ -302,7 +301,7 @@ class NMBSSensor(SensorEntity): @property def unique_id(self) -> str: """Return the unique ID.""" - unique_id = f"{self._station_from["id"]}_{self._station_to["id"]}" + unique_id = f"{self._station_from['id']}_{self._station_to['id']}" return f"nmbs_connection_{unique_id}" @@ -310,7 +309,7 @@ class NMBSSensor(SensorEntity): def name(self) -> str: """Return the name of the sensor.""" if self._name is None: - return f"Train from {self._station_from["standardname"]} to {self._station_to["standardname"]}" + return f"Train from {self._station_from['standardname']} to {self._station_to['standardname']}" return self._name @property diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 4b5335f1eb6..b7b34f7be9f 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -252,9 +252,9 @@ class PullPointManager: async def async_start(self) -> bool: """Start pullpoint subscription.""" - assert ( - self.state == PullPointManagerState.STOPPED - ), "PullPoint manager already started" + assert self.state == PullPointManagerState.STOPPED, ( + "PullPoint manager already started" + ) LOGGER.debug("%s: Starting PullPoint manager", self._name) if not await self._async_start_pullpoint(): self.state = PullPointManagerState.FAILED @@ -501,9 +501,9 @@ class WebHookManager: async def async_start(self) -> bool: """Start polling events.""" LOGGER.debug("%s: Starting webhook manager", self._name) - assert ( - self.state == WebHookManagerState.STOPPED - ), "Webhook manager already started" + assert self.state == WebHookManagerState.STOPPED, ( + "Webhook manager already started" + ) assert self._webhook_url is None, "Webhook already registered" self._async_register_webhook() if not await self._async_start_webhook(): diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index c9143c977ce..8c903c90bbb 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -67,11 +67,9 @@ type _ReturnFuncType[_T, **_P, _R] = Callable[ ] -def catch_request_errors[_OpenhomeDeviceT: OpenhomeDevice, **_P, _R]() -> ( - Callable[ - [_FuncType[_OpenhomeDeviceT, _P, _R]], _ReturnFuncType[_OpenhomeDeviceT, _P, _R] - ] -): +def catch_request_errors[_OpenhomeDeviceT: OpenhomeDevice, **_P, _R]() -> Callable[ + [_FuncType[_OpenhomeDeviceT, _P, _R]], _ReturnFuncType[_OpenhomeDeviceT, _P, _R] +]: """Catch TimeoutError, aiohttp.ClientError, UpnpError errors.""" def call_wrapper( diff --git a/homeassistant/components/qwikswitch/sensor.py b/homeassistant/components/qwikswitch/sensor.py index 64e560b4f08..64b95fb17f6 100644 --- a/homeassistant/components/qwikswitch/sensor.py +++ b/homeassistant/components/qwikswitch/sensor.py @@ -48,9 +48,9 @@ class QSSensor(QSEntity, SensorEntity): self._decode, self.unit = SENSORS[sensor_type] # this cannot happen because it only happens in bool and this should be redirected to binary_sensor - assert not isinstance( - self.unit, type - ), f"boolean sensor id={sensor['id']} name={sensor['name']}" + assert not isinstance(self.unit, type), ( + f"boolean sensor id={sensor['id']} name={sensor['name']}" + ) @callback def update_packet(self, packet): diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 860a3ef8c0f..2efcef1c768 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -2752,9 +2752,9 @@ class EventTypeIDMigration(BaseMigrationWithQuery, BaseOffLineMigration): for db_event_type in missing_db_event_types: # We cannot add the assigned ids to the event_type_manager # because the commit could get rolled back - assert ( - db_event_type.event_type is not None - ), "event_type should never be None" + assert db_event_type.event_type is not None, ( + "event_type should never be None" + ) event_type_to_id[db_event_type.event_type] = ( db_event_type.event_type_id ) @@ -2830,9 +2830,9 @@ class EntityIDMigration(BaseMigrationWithQuery, BaseOffLineMigration): for db_states_metadata in missing_states_metadata: # We cannot add the assigned ids to the event_type_manager # because the commit could get rolled back - assert ( - db_states_metadata.entity_id is not None - ), "entity_id should never be None" + assert db_states_metadata.entity_id is not None, ( + "entity_id should never be None" + ) entity_id_to_metadata_id[db_states_metadata.entity_id] = ( db_states_metadata.metadata_id ) diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index fc2a8ccb1cc..30e277d7c0a 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -47,9 +47,9 @@ class RecorderPool(SingletonThreadPool, NullPool): ) -> None: """Create the pool.""" kw["pool_size"] = POOL_SIZE - assert ( - recorder_and_worker_thread_ids is not None - ), "recorder_and_worker_thread_ids is required" + assert recorder_and_worker_thread_ids is not None, ( + "recorder_and_worker_thread_ids is required" + ) self.recorder_and_worker_thread_ids = recorder_and_worker_thread_ids SingletonThreadPool.__init__(self, creator, **kw) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index c6783a5cbc2..8995f57ef30 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -968,12 +968,10 @@ def _reduce_statistics( return result -def reduce_day_ts_factory() -> ( - tuple[ - Callable[[float, float], bool], - Callable[[float], tuple[float, float]], - ] -): +def reduce_day_ts_factory() -> tuple[ + Callable[[float, float], bool], + Callable[[float], tuple[float, float]], +]: """Return functions to match same day and day start end.""" _lower_bound: float = 0 _upper_bound: float = 0 @@ -1017,12 +1015,10 @@ def _reduce_statistics_per_day( ) -def reduce_week_ts_factory() -> ( - tuple[ - Callable[[float, float], bool], - Callable[[float], tuple[float, float]], - ] -): +def reduce_week_ts_factory() -> tuple[ + Callable[[float, float], bool], + Callable[[float], tuple[float, float]], +]: """Return functions to match same week and week start end.""" _lower_bound: float = 0 _upper_bound: float = 0 @@ -1075,12 +1071,10 @@ def _find_month_end_time(timestamp: datetime) -> datetime: ) -def reduce_month_ts_factory() -> ( - tuple[ - Callable[[float, float], bool], - Callable[[float], tuple[float, float]], - ] -): +def reduce_month_ts_factory() -> tuple[ + Callable[[float, float], bool], + Callable[[float], tuple[float, float]], +]: """Return functions to match same month and month start end.""" _lower_bound: float = 0 _upper_bound: float = 0 diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 69c745c46a3..5480f71a34a 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -615,9 +615,9 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: return None if TYPE_CHECKING: - assert ( - service.server is not None - ), "server cannot be none if there are addresses" + assert service.server is not None, ( + "server cannot be none if there are addresses" + ) return ZeroconfServiceInfo( ip_address=ip_address, ip_addresses=ip_addresses, diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 4f017e4390d..0b2d2c071c5 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -15,9 +15,9 @@ if TYPE_CHECKING: _function_cache: dict[str, Callable[[str, str, dict[str, str] | None], str]] = {} -def import_async_get_exception_message() -> ( - Callable[[str, str, dict[str, str] | None], str] -): +def import_async_get_exception_message() -> Callable[ + [str, str, dict[str, str] | None], str +]: """Return a method that can fetch a translated exception message. Defaults to English, requires translations to already be cached. diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 19076c4edc0..16dee75cd23 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1480,9 +1480,9 @@ class Entity( if self.registry_entry is not None: # This is an assert as it should never happen, but helps in tests - assert ( - not self.registry_entry.disabled_by - ), f"Entity '{self.entity_id}' is being added while it's disabled" + assert not self.registry_entry.disabled_by, ( + f"Entity '{self.entity_id}' is being added while it's disabled" + ) self.async_on_remove( async_track_entity_registry_updated_event( diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py index 22f8e2acbeb..68daf5c7939 100644 --- a/homeassistant/helpers/http.py +++ b/homeassistant/helpers/http.py @@ -46,9 +46,9 @@ def request_handler_factory( ) -> Callable[[web.Request], Awaitable[web.StreamResponse]]: """Wrap the handler classes.""" is_coroutinefunction = asyncio.iscoroutinefunction(handler) - assert is_coroutinefunction or is_callback( - handler - ), "Handler should be a coroutine or a callback." + assert is_coroutinefunction or is_callback(handler), ( + "Handler should be a coroutine or a callback." + ) async def handle(request: web.Request) -> web.StreamResponse: """Handle incoming request.""" diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 5b4a48bb07c..fd2c11e4eb9 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -841,16 +841,16 @@ class Template: self.ensure_valid() assert self.hass is not None, "hass variable not set on template" - assert ( - self._limited is None or self._limited == limited - ), "can't change between limited and non limited template" - assert ( - self._strict is None or self._strict == strict - ), "can't change between strict and non strict template" + assert self._limited is None or self._limited == limited, ( + "can't change between limited and non limited template" + ) + assert self._strict is None or self._strict == strict, ( + "can't change between strict and non strict template" + ) assert not (strict and limited), "can't combine strict and limited template" - assert ( - self._log_fn is None or self._log_fn == log_fn - ), "can't change custom log function" + assert self._log_fn is None or self._log_fn == log_fn, ( + "can't change custom log function" + ) assert self._compiled_code is not None, "template code was not compiled" self._limited = limited diff --git a/pyproject.toml b/pyproject.toml index 0582a6ff881..6b934d11433 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -701,7 +701,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.8.0" +required-version = ">=0.9.1" [tool.ruff.lint] select = [ diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 7760beef113..4dd3bc46010 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.3.0 -ruff==0.8.6 +ruff==0.9.1 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 89eaaf316d5..a64859274d0 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -24,7 +24,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.18,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.8.6 \ + stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.9.1 \ PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.1.0 home-assistant-intents==2025.1.1 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" diff --git a/tests/common.py b/tests/common.py index 9386fdee729..d83955758de 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1197,16 +1197,16 @@ def assert_setup_component(count, domain=None): yield config if domain is None: - assert ( - len(config) == 1 - ), f"assert_setup_component requires DOMAIN: {list(config.keys())}" + assert len(config) == 1, ( + f"assert_setup_component requires DOMAIN: {list(config.keys())}" + ) domain = list(config.keys())[0] res = config.get(domain) res_len = 0 if res is None else len(res) - assert ( - res_len == count - ), f"setup_component failed, expected {count} got {res_len}: {res}" + assert res_len == count, ( + f"setup_component failed, expected {count} got {res_len}: {res}" + ) def mock_restore_cache(hass: HomeAssistant, states: Sequence[State]) -> None: @@ -1814,9 +1814,9 @@ async def snapshot_platform( """Snapshot a platform.""" entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) assert entity_entries - assert ( - len({entity_entry.domain for entity_entry in entity_entries}) == 1 - ), "Please limit the loaded platforms to 1 platform." + assert len({entity_entry.domain for entity_entry in entity_entries}) == 1, ( + "Please limit the loaded platforms to 1 platform." + ) for entity_entry in entity_entries: assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") assert entity_entry.disabled_by is None, "Please enable all entities." diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 65ede877281..ba290d95ed5 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -127,9 +127,9 @@ async def test_template_variables( hass, "hello", None, context, agent_id="conversation.claude" ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) 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"] diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index 4bc5a5d3086..0de8d923bb8 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -123,9 +123,9 @@ async def test_sensors(hass: HomeAssistant, entity_registry: EntityRegistry) -> ] for entity_id, _ in sensors: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/configurator/test_init.py b/tests/components/configurator/test_init.py index 6c937473ddc..a4faab483ee 100644 --- a/tests/components/configurator/test_init.py +++ b/tests/components/configurator/test_init.py @@ -14,9 +14,9 @@ async def test_request_least_info(hass: HomeAssistant) -> None: """Test request config with least amount of data.""" request_id = configurator.async_request_config(hass, "Test Request", lambda _: None) - assert ( - len(hass.services.async_services().get(configurator.DOMAIN, [])) == 1 - ), "No new service registered" + assert len(hass.services.async_services().get(configurator.DOMAIN, [])) == 1, ( + "No new service registered" + ) states = hass.states.async_all() diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 7e05476a349..2cca9858c93 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -399,9 +399,9 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None: result = await conversation.async_converse(hass, sentence, None, Context()) assert callback.call_count == 1 assert callback.call_args[0][0].text == sentence - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), sentence + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + sentence + ) assert result.response.speech == { "plain": {"speech": trigger_response, "extra_data": None} } @@ -412,9 +412,9 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None: callback.reset_mock() for sentence in test_sentences: result = await conversation.async_converse(hass, sentence, None, Context()) - assert ( - result.response.response_type == intent.IntentResponseType.ERROR - ), sentence + assert result.response.response_type == intent.IntentResponseType.ERROR, ( + sentence + ) assert len(callback.mock_calls) == 0 diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index e73c18919c5..6226669aa0f 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -159,9 +159,9 @@ async def test_duplicate_mac_dev_id(mock_warning, hass: HomeAssistant) -> None: ] legacy.DeviceTracker(hass, False, True, {}, devices) _LOGGER.debug(mock_warning.call_args_list) - assert ( - mock_warning.call_count == 1 - ), "The only warning call should be duplicates (check DEBUG)" + assert mock_warning.call_count == 1, ( + "The only warning call should be duplicates (check DEBUG)" + ) args, _ = mock_warning.call_args assert "Duplicate device MAC" in args[0], "Duplicate MAC warning expected" @@ -177,9 +177,9 @@ async def test_duplicate_mac_dev_id(mock_warning, hass: HomeAssistant) -> None: legacy.DeviceTracker(hass, False, True, {}, devices) _LOGGER.debug(mock_warning.call_args_list) - assert ( - mock_warning.call_count == 1 - ), "The only warning call should be duplicates (check DEBUG)" + assert mock_warning.call_count == 1, ( + "The only warning call should be duplicates (check DEBUG)" + ) args, _ = mock_warning.call_args assert "Duplicate device IDs" in args[0], "Duplicate device IDs warning expected" diff --git a/tests/components/dsmr/conftest.py b/tests/components/dsmr/conftest.py index 2301b9dfc80..ccb7920e141 100644 --- a/tests/components/dsmr/conftest.py +++ b/tests/components/dsmr/conftest.py @@ -45,9 +45,9 @@ def dsmr_connection_fixture() -> Generator[tuple[MagicMock, MagicMock, MagicMock @pytest.fixture -def rfxtrx_dsmr_connection_fixture() -> ( - Generator[tuple[MagicMock, MagicMock, MagicMock]] -): +def rfxtrx_dsmr_connection_fixture() -> Generator[ + tuple[MagicMock, MagicMock, MagicMock] +]: """Fixture that mocks RFXtrx connection.""" transport = MagicMock(spec=asyncio.Transport) @@ -73,9 +73,9 @@ def rfxtrx_dsmr_connection_fixture() -> ( @pytest.fixture -def dsmr_connection_send_validate_fixture() -> ( - Generator[tuple[MagicMock, MagicMock, MagicMock]] -): +def dsmr_connection_send_validate_fixture() -> Generator[ + tuple[MagicMock, MagicMock, MagicMock] +]: """Fixture that mocks serial connection.""" transport = MagicMock(spec=asyncio.Transport) @@ -156,9 +156,9 @@ def dsmr_connection_send_validate_fixture() -> ( @pytest.fixture -def rfxtrx_dsmr_connection_send_validate_fixture() -> ( - Generator[tuple[MagicMock, MagicMock, MagicMock]] -): +def rfxtrx_dsmr_connection_send_validate_fixture() -> Generator[ + tuple[MagicMock, MagicMock, MagicMock] +]: """Fixture that mocks serial connection.""" transport = MagicMock(spec=asyncio.Transport) diff --git a/tests/components/ecovacs/test_button.py b/tests/components/ecovacs/test_button.py index 65e0b19ea02..3021db62e6f 100644 --- a/tests/components/ecovacs/test_button.py +++ b/tests/components/ecovacs/test_button.py @@ -161,8 +161,8 @@ async def test_disabled_by_default_buttons( for entity_id in entity_ids: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 2185ae4c9eb..13b73d853d5 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -116,6 +116,6 @@ async def test_all_entities_loaded( entities: int, ) -> None: """Test that all entities are loaded together.""" - assert ( - hass.states.async_entity_ids_count() == entities - ), f"loaded entities for {device_fixture}: {hass.states.async_entity_ids()}" + assert hass.states.async_entity_ids_count() == entities, ( + f"loaded entities for {device_fixture}: {hass.states.async_entity_ids()}" + ) diff --git a/tests/components/ecovacs/test_number.py b/tests/components/ecovacs/test_number.py index a735863d40a..32bc8f90696 100644 --- a/tests/components/ecovacs/test_number.py +++ b/tests/components/ecovacs/test_number.py @@ -136,9 +136,9 @@ async def test_disabled_by_default_number_entities( for entity_id in entity_ids: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/ecovacs/test_sensor.py b/tests/components/ecovacs/test_sensor.py index 5bcd8385320..8222e9976d5 100644 --- a/tests/components/ecovacs/test_sensor.py +++ b/tests/components/ecovacs/test_sensor.py @@ -172,9 +172,9 @@ async def test_disabled_by_default_sensors( for entity_id in entity_ids: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/ecovacs/test_switch.py b/tests/components/ecovacs/test_switch.py index b14cafeaba4..040528debaa 100644 --- a/tests/components/ecovacs/test_switch.py +++ b/tests/components/ecovacs/test_switch.py @@ -214,8 +214,8 @@ async def test_disabled_by_default_switch_entities( for entity_id in entity_ids: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 4192a60513e..df0b11487d8 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -603,9 +603,9 @@ async def test_template_variables( hass, "hello", None, context, agent_id=mock_config_entry.entry_id ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) assert ( "The user name is Test User." in mock_model.mock_calls[0][2]["system_instruction"] diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 052c0031469..e31e630807e 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -192,16 +192,16 @@ async def test_cannot_access_with_trusted_ip( for remote_addr in UNTRUSTED_ADDRESSES: set_mock_ip(remote_addr) resp = await client.get("/") - assert ( - resp.status == HTTPStatus.UNAUTHORIZED - ), f"{remote_addr} shouldn't be trusted" + assert resp.status == HTTPStatus.UNAUTHORIZED, ( + f"{remote_addr} shouldn't be trusted" + ) for remote_addr in TRUSTED_ADDRESSES: set_mock_ip(remote_addr) resp = await client.get("/") - assert ( - resp.status == HTTPStatus.UNAUTHORIZED - ), f"{remote_addr} shouldn't be trusted" + assert resp.status == HTTPStatus.UNAUTHORIZED, ( + f"{remote_addr} shouldn't be trusted" + ) async def test_auth_active_access_with_access_token_in_header( @@ -256,16 +256,16 @@ async def test_auth_active_access_with_trusted_ip( for remote_addr in UNTRUSTED_ADDRESSES: set_mock_ip(remote_addr) resp = await client.get("/") - assert ( - resp.status == HTTPStatus.UNAUTHORIZED - ), f"{remote_addr} shouldn't be trusted" + assert resp.status == HTTPStatus.UNAUTHORIZED, ( + f"{remote_addr} shouldn't be trusted" + ) for remote_addr in TRUSTED_ADDRESSES: set_mock_ip(remote_addr) resp = await client.get("/") - assert ( - resp.status == HTTPStatus.UNAUTHORIZED - ), f"{remote_addr} shouldn't be trusted" + assert resp.status == HTTPStatus.UNAUTHORIZED, ( + f"{remote_addr} shouldn't be trusted" + ) async def test_auth_legacy_support_api_password_cannot_access( diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index c0ec1dd9b9a..80d75769cdc 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -174,12 +174,12 @@ class KNXTestKit: ) telegram = self._outgoing_telegrams.pop(0) - assert isinstance( - telegram.payload, apci_type - ), f"APCI type mismatch in {telegram} - Expected: {apci_type.__name__}" - assert ( - telegram.destination_address == _expected_ga - ), f"Group address mismatch in {telegram} - Expected: {group_address}" + assert isinstance(telegram.payload, apci_type), ( + f"APCI type mismatch in {telegram} - Expected: {apci_type.__name__}" + ) + assert telegram.destination_address == _expected_ga, ( + f"Group address mismatch in {telegram} - Expected: {group_address}" + ) if payload is not None: assert ( telegram.payload.value.value == payload # type: ignore[attr-defined] diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 276dd45d0ab..051f7bb87e4 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -1012,9 +1012,9 @@ async def test_media_permission_unauthorized( client = await hass_client() response = await client.get(media_url) - assert ( - response.status == HTTPStatus.UNAUTHORIZED - ), f"Response not matched: {response}" + assert response.status == HTTPStatus.UNAUTHORIZED, ( + f"Response not matched: {response}" + ) async def test_multiple_devices( @@ -1306,9 +1306,9 @@ async def test_media_store_load_filesystem_error( response = await client.get( f"/api/nest/event_media/{device.id}/{event_identifier}" ) - assert ( - response.status == HTTPStatus.NOT_FOUND - ), f"Response not matched: {response}" + assert response.status == HTTPStatus.NOT_FOUND, ( + f"Response not matched: {response}" + ) @pytest.mark.parametrize(("device_traits", "cache_size"), [(BATTERY_CAMERA_TRAITS, 5)]) diff --git a/tests/components/nmbs/test_config_flow.py b/tests/components/nmbs/test_config_flow.py index 448bcdfbc51..6e55f89e54a 100644 --- a/tests/components/nmbs/test_config_flow.py +++ b/tests/components/nmbs/test_config_flow.py @@ -68,7 +68,7 @@ async def test_full_flow( } assert ( result["result"].unique_id - == f"{DUMMY_DATA["STAT_BRUSSELS_NORTH"]}_{DUMMY_DATA["STAT_BRUSSELS_SOUTH"]}" + == f"{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_{DUMMY_DATA['STAT_BRUSSELS_SOUTH']}" ) @@ -247,14 +247,14 @@ async def test_sensor_id_migration_standardname( ) -> None: """Test migrating unique id.""" old_unique_id = ( - f"live_{DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"]}_" - f"{DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"]}_" - f"{DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"]}" + f"live_{DUMMY_DATA_IMPORT['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA_IMPORT['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA_IMPORT['STAT_BRUSSELS_SOUTH']}" ) new_unique_id = ( - f"nmbs_live_{DUMMY_DATA["STAT_BRUSSELS_NORTH"]}_" - f"{DUMMY_DATA["STAT_BRUSSELS_NORTH"]}_" - f"{DUMMY_DATA["STAT_BRUSSELS_SOUTH"]}" + f"nmbs_live_{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA['STAT_BRUSSELS_SOUTH']}" ) old_entry = entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, old_unique_id @@ -287,14 +287,14 @@ async def test_sensor_id_migration_localized_name( ) -> None: """Test migrating unique id.""" old_unique_id = ( - f"live_{DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_NORTH"]}_" - f"{DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_NORTH"]}_" - f"{DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_SOUTH"]}" + f"live_{DUMMY_DATA_ALTERNATIVE_IMPORT['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA_ALTERNATIVE_IMPORT['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA_ALTERNATIVE_IMPORT['STAT_BRUSSELS_SOUTH']}" ) new_unique_id = ( - f"nmbs_live_{DUMMY_DATA["STAT_BRUSSELS_NORTH"]}_" - f"{DUMMY_DATA["STAT_BRUSSELS_NORTH"]}_" - f"{DUMMY_DATA["STAT_BRUSSELS_SOUTH"]}" + f"nmbs_live_{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA['STAT_BRUSSELS_SOUTH']}" ) old_entry = entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, old_unique_id diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 3186374a040..202f7385697 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -55,9 +55,9 @@ async def test_chat( Message(role="user", content="test message"), ] - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) assert result.response.speech["plain"]["speech"] == "test response" # Test Conversation tracing @@ -106,9 +106,9 @@ async def test_template_variables( hass, "hello", None, context, agent_id=mock_config_entry.entry_id ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) args = mock_chat.call_args.kwargs prompt = args["messages"][0]["content"] @@ -463,9 +463,9 @@ async def test_message_history_pruning( context=Context(), agent_id=mock_config_entry.entry_id, ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) # Only the most recent histories should remain assert len(agent._history) == 2 diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index eed396786e2..774f60ed666 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -136,9 +136,9 @@ async def test_template_variables( hass, "hello", None, context, agent_id=mock_config_entry.entry_id ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) assert ( "The user name is Test User." in mock_create.mock_calls[0][2]["messages"][0]["content"] @@ -178,9 +178,9 @@ async def test_extra_systen_prompt( extra_system_prompt=extra_system_prompt, ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) assert mock_create.mock_calls[0][2]["messages"][0]["content"].endswith( extra_system_prompt ) @@ -201,9 +201,9 @@ async def test_extra_systen_prompt( extra_system_prompt=None, ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) assert mock_create.mock_calls[0][2]["messages"][0]["content"].endswith( extra_system_prompt ) @@ -222,9 +222,9 @@ async def test_extra_systen_prompt( extra_system_prompt=extra_system_prompt2, ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) assert mock_create.mock_calls[0][2]["messages"][0]["content"].endswith( extra_system_prompt2 ) @@ -242,9 +242,9 @@ async def test_extra_systen_prompt( agent_id=mock_config_entry.entry_id, ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) assert mock_create.mock_calls[0][2]["messages"][0]["content"].endswith( extra_system_prompt2 ) diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 021ee9cc810..7e6768e4d7d 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -36,9 +36,9 @@ async def test_mapping_integrity() -> None: for sensor_map in maps: assert sensor_map.attribute in ATTRIBUTES, sensor_map.attribute if sensor_map.device_class: - assert ( - sensor_map.device_class in DEVICE_CLASSES - ), sensor_map.device_class + assert sensor_map.device_class in DEVICE_CLASSES, ( + sensor_map.device_class + ) if sensor_map.state_class: assert sensor_map.state_class in STATE_CLASSES, sensor_map.state_class diff --git a/tests/components/stream/test_ll_hls.py b/tests/components/stream/test_ll_hls.py index f37cba8ea9f..443103fdf92 100644 --- a/tests/components/stream/test_ll_hls.py +++ b/tests/components/stream/test_ll_hls.py @@ -99,9 +99,9 @@ def make_segment_with_parts( if discontinuity: response.append("#EXT-X-DISCONTINUITY") response.extend( - f'#EXT-X-PART:DURATION={TEST_PART_DURATION:.3f},' + f"#EXT-X-PART:DURATION={TEST_PART_DURATION:.3f}," f'URI="./segment/{segment}.{i}.m4s"' - f'{",INDEPENDENT=YES" if i % independent_period == 0 else ""}' + f"{',INDEPENDENT=YES' if i % independent_period == 0 else ''}" for i in range(num_parts) ) response.append( diff --git a/tests/components/tesla_wall_connector/conftest.py b/tests/components/tesla_wall_connector/conftest.py index e10ae190a59..9533b7e691e 100644 --- a/tests/components/tesla_wall_connector/conftest.py +++ b/tests/components/tesla_wall_connector/conftest.py @@ -124,9 +124,9 @@ async def _test_sensors( for entity in entities_and_expected_values: state = hass.states.get(entity.entity_id) assert state, f"Unable to get state of {entity.entity_id}" - assert ( - state.state == entity.first_value - ), f"First update: {entity.entity_id} is expected to have state {entity.first_value} but has {state.state}" + assert state.state == entity.first_value, ( + f"First update: {entity.entity_id} is expected to have state {entity.first_value} but has {state.state}" + ) # Simulate second data update with ( @@ -147,6 +147,6 @@ async def _test_sensors( # Verify expected vs actual values of second update for entity in entities_and_expected_values: state = hass.states.get(entity.entity_id) - assert ( - state.state == entity.second_value - ), f"Second update: {entity.entity_id} is expected to have state {entity.second_value} but has {state.state}" + assert state.state == entity.second_value, ( + f"Second update: {entity.entity_id} is expected to have state {entity.second_value} but has {state.state}" + ) diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 4107610c121..23e36eacdd5 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -79,15 +79,15 @@ async def snapshot_platform( device_entries = dr.async_entries_for_config_entry(device_registry, config_entry_id) assert device_entries for device_entry in device_entries: - assert device_entry == snapshot( - name=f"{device_entry.name}-entry" - ), f"device entry snapshot failed for {device_entry.name}" + assert device_entry == snapshot(name=f"{device_entry.name}-entry"), ( + f"device entry snapshot failed for {device_entry.name}" + ) entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) assert entity_entries - assert ( - len({entity_entry.domain for entity_entry in entity_entries}) == 1 - ), "Please limit the loaded platforms to 1 platform." + assert len({entity_entry.domain for entity_entry in entity_entries}) == 1, ( + "Please limit the loaded platforms to 1 platform." + ) translations = await async_get_translations(hass, "en", "entity", [DOMAIN]) unique_device_classes = [] @@ -99,18 +99,18 @@ async def snapshot_platform( if entity_entry.original_device_class not in unique_device_classes: single_device_class_translation = True unique_device_classes.append(entity_entry.original_device_class) - assert ( - (key in translations) or single_device_class_translation - ), f"No translation or non unique device_class for entity {entity_entry.unique_id}, expected {key}" - assert entity_entry == snapshot( - name=f"{entity_entry.entity_id}-entry" - ), f"entity entry snapshot failed for {entity_entry.entity_id}" + assert (key in translations) or single_device_class_translation, ( + f"No translation or non unique device_class for entity {entity_entry.unique_id}, expected {key}" + ) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry"), ( + f"entity entry snapshot failed for {entity_entry.entity_id}" + ) if entity_entry.disabled_by is None: state = hass.states.get(entity_entry.entity_id) assert state, f"State not found for {entity_entry.entity_id}" - assert state == snapshot( - name=f"{entity_entry.entity_id}-state" - ), f"state snapshot failed for {entity_entry.entity_id}" + assert state == snapshot(name=f"{entity_entry.entity_id}-state"), ( + f"state snapshot failed for {entity_entry.entity_id}" + ) async def setup_automation(hass: HomeAssistant, alias: str, entity_id: str) -> None: @@ -242,12 +242,12 @@ def _mocked_feature( feature.name = name or id.upper() feature.set_value = AsyncMock() if not (fixture := FEATURES_FIXTURE.get(id)): - assert ( - require_fixture is False - ), f"No fixture defined for feature {id} and require_fixture is True" - assert ( - value is not UNDEFINED - ), f"Value must be provided if feature {id} not defined in features.json" + assert require_fixture is False, ( + f"No fixture defined for feature {id} and require_fixture is True" + ) + assert value is not UNDEFINED, ( + f"Value must be provided if feature {id} not defined in features.json" + ) fixture = {"value": value, "category": "Primary", "type": "Sensor"} elif value is not UNDEFINED: fixture["value"] = value @@ -318,12 +318,12 @@ def _mocked_light_effect_module(device) -> LightEffect: effect.effect_list = ["Off", "Effect1", "Effect2"] async def _set_effect(effect_name, *_, **__): - assert ( - effect_name in effect.effect_list - ), f"set_effect '{effect_name}' not in {effect.effect_list}" - assert device.modules[ - Module.Light - ], "Need a light module to test set_effect method" + assert effect_name in effect.effect_list, ( + f"set_effect '{effect_name}' not in {effect.effect_list}" + ) + assert device.modules[Module.Light], ( + "Need a light module to test set_effect method" + ) device.modules[Module.Light].state.light_on = True effect.effect = effect_name diff --git a/tests/components/vesync/test_humidifier.py b/tests/components/vesync/test_humidifier.py index 9a807cc903e..5251e977c75 100644 --- a/tests/components/vesync/test_humidifier.py +++ b/tests/components/vesync/test_humidifier.py @@ -132,7 +132,7 @@ async def test_turn_on_off( with ( expectation, patch( - f"pyvesync.vesyncfan.VeSyncHumid200300S.{"turn_on" if turn_on else "turn_off"}", + f"pyvesync.vesyncfan.VeSyncHumid200300S.{'turn_on' if turn_on else 'turn_off'}", return_value=api_response, ) as method_mock, ): diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 37b1dde7316..bcdc0c3ce16 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -133,9 +133,9 @@ def climate_radio_thermostat_ct100_plus_state_fixture() -> dict[str, Any]: name="climate_radio_thermostat_ct100_plus_different_endpoints_state", scope="package", ) -def climate_radio_thermostat_ct100_plus_different_endpoints_state_fixture() -> ( - dict[str, Any] -): +def climate_radio_thermostat_ct100_plus_different_endpoints_state_fixture() -> dict[ + str, Any +]: """Load the thermostat fixture state with values on different endpoints. This device is a radio thermostat ct100. @@ -336,9 +336,9 @@ def lock_id_lock_as_id150_state_fixture() -> dict[str, Any]: @pytest.fixture( name="climate_radio_thermostat_ct101_multiple_temp_units_state", scope="package" ) -def climate_radio_thermostat_ct101_multiple_temp_units_state_fixture() -> ( - dict[str, Any] -): +def climate_radio_thermostat_ct101_multiple_temp_units_state_fixture() -> dict[ + str, Any +]: """Load the climate multiple temp units node state fixture data.""" return load_json_object_fixture( "climate_radio_thermostat_ct101_multiple_temp_units_state.json", DOMAIN diff --git a/tests/conftest.py b/tests/conftest.py index a1e3bcd31c6..24c4b0ceb37 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -412,7 +412,9 @@ def verify_cleanup( try: # Verify respx.mock has been cleaned up - assert not respx.mock.routes, "respx.mock routes not cleaned up, maybe the test needs to be decorated with @respx.mock" + assert not respx.mock.routes, ( + "respx.mock routes not cleaned up, maybe the test needs to be decorated with @respx.mock" + ) finally: # Clear mock routes not break subsequent tests respx.mock.clear() From 6e80ad505b9e48efa662695870b6ddca6f94af07 Mon Sep 17 00:00:00 2001 From: Krisjanis Lejejs Date: Tue, 14 Jan 2025 13:17:22 +0200 Subject: [PATCH 0408/2987] Bump hass-nabucasa from 0.87.0 to 0.88.1 (#135521) * Bump hass-nabucasa from 0.87.0 to 0.88.0 * Bump hass-nabucasa from 0.88.0 to 0.88.1 * Fix Alexa breaking changes --- homeassistant/components/cloud/__init__.py | 2 -- homeassistant/components/cloud/const.py | 1 - 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 +- tests/components/cloud/test_alexa_config.py | 12 ++++++------ tests/components/cloud/test_http_api.py | 1 - tests/components/cloud/test_init.py | 2 -- 11 files changed, 12 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 80b00237fd3..55ffedd2781 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -50,7 +50,6 @@ from .const import ( CONF_ACCOUNTS_SERVER, CONF_ACME_SERVER, CONF_ALEXA, - CONF_ALEXA_SERVER, CONF_ALIASES, CONF_CLOUDHOOK_SERVER, CONF_COGNITO_CLIENT_ID, @@ -128,7 +127,6 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_ACCOUNT_LINK_SERVER): str, vol.Optional(CONF_ACCOUNTS_SERVER): str, vol.Optional(CONF_ACME_SERVER): str, - vol.Optional(CONF_ALEXA_SERVER): str, vol.Optional(CONF_CLOUDHOOK_SERVER): str, vol.Optional(CONF_RELAYER_SERVER): str, vol.Optional(CONF_REMOTESTATE_SERVER): str, diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index cff71bacebc..3883f19d1b7 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -76,7 +76,6 @@ CONF_USER_POOL_ID = "user_pool_id" CONF_ACCOUNT_LINK_SERVER = "account_link_server" CONF_ACCOUNTS_SERVER = "accounts_server" CONF_ACME_SERVER = "acme_server" -CONF_ALEXA_SERVER = "alexa_server" CONF_CLOUDHOOK_SERVER = "cloudhook_server" CONF_RELAYER_SERVER = "relayer_server" CONF_REMOTESTATE_SERVER = "remotestate_server" diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 7ee8cf46b86..0f415b1738a 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": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.87.0"], + "requirements": ["hass-nabucasa==0.88.1"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1355ce5d6af..2582c872f16 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ fnv-hash-fast==1.1.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.8.0 -hass-nabucasa==0.87.0 +hass-nabucasa==0.88.1 hassil==2.1.0 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20250109.0 diff --git a/pyproject.toml b/pyproject.toml index 6b934d11433..3179d93b9d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.1.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.87.0", + "hass-nabucasa==0.88.1", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.27.2", diff --git a/requirements.txt b/requirements.txt index ebfc0fabdbe..925985dfd42 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.1.0 -hass-nabucasa==0.87.0 +hass-nabucasa==0.88.1 httpx==0.27.2 home-assistant-bluetooth==1.13.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0086bb8a3b6..ac5a824f51d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ habiticalib==0.3.3 habluetooth==3.8.0 # homeassistant.components.cloud -hass-nabucasa==0.87.0 +hass-nabucasa==0.88.1 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 66a7a289438..d24f85ab0bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -941,7 +941,7 @@ habiticalib==0.3.3 habluetooth==3.8.0 # homeassistant.components.cloud -hass-nabucasa==0.87.0 +hass-nabucasa==0.88.1 # homeassistant.components.conversation hassil==2.1.0 diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 3b4868b56ac..ef7a99453f0 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -179,7 +179,7 @@ async def test_alexa_config_invalidate_token( assert await async_setup_component(hass, "homeassistant", {}) aioclient_mock.post( - "https://example/access_token", + "https://example/alexa/access_token", json={ "access_token": "mock-token", "event_endpoint": "http://example.com/alexa_endpoint", @@ -192,7 +192,7 @@ async def test_alexa_config_invalidate_token( "mock-user-id", cloud_prefs, Mock( - alexa_server="example", + servicehandlers_server="example", auth=Mock(async_check_token=AsyncMock()), websession=async_get_clientsession(hass), ), @@ -239,7 +239,7 @@ async def test_alexa_config_fail_refresh_token( ) aioclient_mock.post( - "https://example/access_token", + "https://example/alexa/access_token", json={ "access_token": "mock-token", "event_endpoint": "http://example.com/alexa_endpoint", @@ -256,7 +256,7 @@ async def test_alexa_config_fail_refresh_token( "mock-user-id", cloud_prefs, Mock( - alexa_server="example", + servicehandlers_server="example", auth=Mock(async_check_token=AsyncMock()), websession=async_get_clientsession(hass), ), @@ -286,7 +286,7 @@ async def test_alexa_config_fail_refresh_token( conf.async_invalidate_access_token() aioclient_mock.clear_requests() aioclient_mock.post( - "https://example/access_token", + "https://example/alexa/access_token", json={"reason": reject_reason}, status=400, ) @@ -312,7 +312,7 @@ async def test_alexa_config_fail_refresh_token( # State reporting should now be re-enabled for Alexa aioclient_mock.clear_requests() aioclient_mock.post( - "https://example/access_token", + "https://example/alexa/access_token", json={ "access_token": "mock-token", "event_endpoint": "http://example.com/alexa_endpoint", diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index d915f158af0..910fa03d46c 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -112,7 +112,6 @@ async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: "cognito_client_id": "cognito_client_id", "user_pool_id": "user_pool_id", "region": "region", - "alexa_server": "alexa-api.nabucasa.com", "relayer_server": "relayer", "accounts_server": "api-test.hass.io", "google_actions": {"filter": {"include_domains": "light"}}, diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index ad123cded84..9a6d4abfc93 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -45,7 +45,6 @@ async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None: "relayer_server": "test-relayer-server", "accounts_server": "test-acounts-server", "cloudhook_server": "test-cloudhook-server", - "alexa_server": "test-alexa-server", "acme_server": "test-acme-server", "remotestate_server": "test-remotestate-server", }, @@ -62,7 +61,6 @@ async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None: assert cl.iot.ws_server_url == "wss://test-relayer-server/websocket" assert cl.accounts_server == "test-acounts-server" assert cl.cloudhook_server == "test-cloudhook-server" - assert cl.alexa_server == "test-alexa-server" assert cl.acme_server == "test-acme-server" assert cl.remotestate_server == "test-remotestate-server" From 6f138c71b4bf162df15db36241ddd70c997c06d7 Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Tue, 14 Jan 2025 05:38:31 -0600 Subject: [PATCH 0409/2987] Remove incorrect logging about Unknown device (#135585) --- homeassistant/components/vesync/fan.py | 16 ++++++---------- homeassistant/components/vesync/light.py | 7 +------ homeassistant/components/vesync/switch.py | 7 +------ 3 files changed, 8 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 9ef0940e8d0..ba1880f2492 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -86,16 +86,12 @@ def _setup_entities( async_add_entities, coordinator: VeSyncDataCoordinator, ): - """Check if device is online and add entity.""" - entities = [] - for dev in devices: - if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type, "")) == "fan": - entities.append(VeSyncFanHA(dev, coordinator)) - else: - _LOGGER.warning( - "%s - Unknown device type - %s", dev.device_name, dev.device_type - ) - continue + """Check if device is fan and add entity.""" + entities = [ + VeSyncFanHA(dev, coordinator) + for dev in devices + if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type, "")) == "fan" + ] async_add_entities(entities, update_before_add=True) diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index f58b9180e12..40f68986145 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -53,18 +53,13 @@ def _setup_entities( async_add_entities, coordinator: VeSyncDataCoordinator, ): - """Check if device is online and add entity.""" + """Check if device is a light and add entity.""" entities: list[VeSyncBaseLightHA] = [] for dev in devices: if DEV_TYPE_TO_HA.get(dev.device_type) in ("walldimmer", "bulb-dimmable"): entities.append(VeSyncDimmableLightHA(dev, coordinator)) elif DEV_TYPE_TO_HA.get(dev.device_type) in ("bulb-tunable-white",): entities.append(VeSyncTunableWhiteLightHA(dev, coordinator)) - else: - _LOGGER.debug( - "%s - Unknown device type - %s", dev.device_name, dev.device_type - ) - continue async_add_entities(entities, update_before_add=True) diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index a3c628c596d..ef8e6c6051f 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -45,18 +45,13 @@ def _setup_entities( async_add_entities, coordinator: VeSyncDataCoordinator, ): - """Check if device is online and add entity.""" + """Check if device is a switch and add entity.""" entities: list[VeSyncBaseSwitch] = [] for dev in devices: if DEV_TYPE_TO_HA.get(dev.device_type) == "outlet": entities.append(VeSyncSwitchHA(dev, coordinator)) elif DEV_TYPE_TO_HA.get(dev.device_type) == "switch": entities.append(VeSyncLightSwitch(dev, coordinator)) - else: - _LOGGER.warning( - "%s - Unknown device type - %s", dev.device_name, dev.device_type - ) - continue async_add_entities(entities, update_before_add=True) From c66176cfa58f92273ae543c5be089a900acadc7c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 14 Jan 2025 12:40:43 +0100 Subject: [PATCH 0410/2987] Unignore ruff rule ISC001 (#135581) --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3179d93b9d1..7e32b6b61b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -839,7 +839,6 @@ ignore = [ "Q", "COM812", "COM819", - "ISC001", # Disabled because ruff does not understand type of __all__ generated by a function "PLE0605" From d970b728ce4b3ef963e0257d44e6cd1fe8180b65 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 14 Jan 2025 11:41:48 +0000 Subject: [PATCH 0411/2987] Update tplink quality_scale.yaml (#135209) --- homeassistant/components/tplink/light.py | 4 ++-- .../components/tplink/quality_scale.yaml | 22 +++++-------------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index e65fda52e44..731ee919c98 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -361,6 +361,8 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity): class TPLinkLightEffectEntity(TPLinkLightEntity): """Representation of a TPLink Smart Light Strip.""" + _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT + def __init__( self, device: Device, @@ -373,8 +375,6 @@ class TPLinkLightEffectEntity(TPLinkLightEntity): self._effect_module = effect_module super().__init__(device, coordinator, light_module=light_module) - _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT - @callback def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" diff --git a/homeassistant/components/tplink/quality_scale.yaml b/homeassistant/components/tplink/quality_scale.yaml index 3a2e10bc426..c170cf8c169 100644 --- a/homeassistant/components/tplink/quality_scale.yaml +++ b/homeassistant/components/tplink/quality_scale.yaml @@ -3,12 +3,8 @@ rules: config-flow: done test-before-configure: done unique-config-entry: done - config-flow-test-coverage: - status: todo - comment: Clean up stale docstrings - runtime-data: - status: todo - comment: Use typed config entry in coordinator + config-flow-test-coverage: done + runtime-data: done test-before-setup: done appropriate-polling: done entity-unique-id: done @@ -16,7 +12,7 @@ rules: entity-event-setup: status: exempt comment: The integration does not use events. - dependency-transparency: todo + dependency-transparency: done action-setup: status: exempt comment: The integration only uses platform services. @@ -34,12 +30,7 @@ rules: action-exceptions: done reauthentication-flow: done parallel-updates: done - test-coverage: - status: todo - comment: Move test constants to const.py, mock_init \ - docstrings, entity_registry fixture, unused freezers \ - match exceptions, use freezer in test_fan, use async_setup \ - remove if statements from light tests, use constants in service calls + test-coverage: done integration-owner: done docs-installation-parameters: todo docs-configuration-parameters: @@ -47,10 +38,7 @@ rules: comment: The integration does not have any options configuration parameters. # Gold - entity-translations: - status: todo - comment: Use device class translations, remove unused translations \ - translate Unnamed, setup exceptions, mac mismatch, async_set_hvac_mode + entity-translations: done entity-device-class: done devices: done entity-category: done From 5fc3618b4abe770589ab32b9a578a9631f7790a8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 14 Jan 2025 12:56:31 +0100 Subject: [PATCH 0412/2987] Bump demetriek to 1.2.0 (#135580) --- homeassistant/components/lametric/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/lametric/snapshots/test_diagnostics.ambr | 8 ++++++++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lametric/manifest.json b/homeassistant/components/lametric/manifest.json index f66ffb0c6ae..4c4359d0ddb 100644 --- a/homeassistant/components/lametric/manifest.json +++ b/homeassistant/components/lametric/manifest.json @@ -13,7 +13,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["demetriek"], - "requirements": ["demetriek==1.1.1"], + "requirements": ["demetriek==1.2.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:LaMetric:1" diff --git a/requirements_all.txt b/requirements_all.txt index ac5a824f51d..b918cc3e944 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -755,7 +755,7 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==1.1.1 +demetriek==1.2.0 # homeassistant.components.denonavr denonavr==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d24f85ab0bb..25dc0470bfd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -645,7 +645,7 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==1.1.1 +demetriek==1.2.0 # homeassistant.components.denonavr denonavr==1.0.1 diff --git a/tests/components/lametric/snapshots/test_diagnostics.ambr b/tests/components/lametric/snapshots/test_diagnostics.ambr index 8b8f98b5806..d8f21424216 100644 --- a/tests/components/lametric/snapshots/test_diagnostics.ambr +++ b/tests/components/lametric/snapshots/test_diagnostics.ambr @@ -24,7 +24,15 @@ 'device_id': '**REDACTED**', 'display': dict({ 'brightness': 100, + 'brightness_limit': dict({ + 'range_max': 100, + 'range_min': 2, + }), 'brightness_mode': 'auto', + 'brightness_range': dict({ + 'range_max': 100, + 'range_min': 0, + }), 'display_type': 'mixed', 'height': 8, 'on': None, From 4f796174fdf45b42fa4b01c1b2d95c17b45fb836 Mon Sep 17 00:00:00 2001 From: jiriappl Date: Tue, 14 Jan 2025 04:17:09 -0800 Subject: [PATCH 0413/2987] Match the upstream alt id of the new Levoit air purifier (#135426) --- 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 6d1de28825f..e3a72a51658 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -63,6 +63,7 @@ SKU_TO_BASE_DEVICE = { "Core300S": "Core300S", "LAP-C301S-WJP": "Core300S", # Alt ID Model Core300S "LAP-C301S-WAAA": "Core300S", # Alt ID Model Core300S + "LAP-C302S-WUSB": "Core300S", # Alt ID Model Core300S "Core400S": "Core400S", "LAP-C401S-WJP": "Core400S", # Alt ID Model Core400S "LAP-C401S-WUSR": "Core400S", # Alt ID Model Core400S From 5e50b11114ef5246d6cab8ef617822944c617b3a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 14 Jan 2025 13:17:25 +0100 Subject: [PATCH 0414/2987] Avoid core documentation url hosted elsewhere (#130513) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: Franck Nijhof --- script/hassfest/manifest.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index fdbcf5bcb78..6e9cd8bdedc 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -27,6 +27,8 @@ DOCUMENTATION_URL_HOST = "www.home-assistant.io" DOCUMENTATION_URL_PATH_PREFIX = "/integrations/" DOCUMENTATION_URL_EXCEPTIONS = {"https://www.home-assistant.io/hassio"} +_CORE_DOCUMENTATION_BASE = "https://www.home-assistant.io/integrations" + class NonScaledQualityScaleTiers(StrEnum): """Supported manifest quality scales.""" @@ -117,19 +119,26 @@ NO_IOT_CLASS = [ ] -def documentation_url(value: str) -> str: +def core_documentation_url(value: str) -> str: """Validate that a documentation url has the correct path and domain.""" if value in DOCUMENTATION_URL_EXCEPTIONS: return value + if not value.startswith(_CORE_DOCUMENTATION_BASE): + raise vol.Invalid( + f"Documentation URL does not begin with {_CORE_DOCUMENTATION_BASE}" + ) + return value + + +def custom_documentation_url(value: str) -> str: + """Validate that a custom integration documentation url is correct.""" parsed_url = urlparse(value) if parsed_url.scheme != DOCUMENTATION_URL_SCHEMA: raise vol.Invalid("Documentation url is not prefixed with https") - if parsed_url.netloc == DOCUMENTATION_URL_HOST and not parsed_url.path.startswith( - DOCUMENTATION_URL_PATH_PREFIX - ): + if value.startswith(_CORE_DOCUMENTATION_BASE): raise vol.Invalid( - "Documentation url does not begin with www.home-assistant.io/integrations" + "Documentation URL should point to the custom integration documentation" ) return value @@ -258,7 +267,7 @@ INTEGRATION_MANIFEST_SCHEMA = vol.Schema( } ) ], - vol.Required("documentation"): vol.All(vol.Url(), documentation_url), + vol.Required("documentation"): vol.All(vol.Url(), core_documentation_url), vol.Optional("quality_scale"): vol.In(SUPPORTED_QUALITY_SCALES), vol.Optional("requirements"): [str], vol.Optional("dependencies"): [str], @@ -293,6 +302,7 @@ def manifest_schema(value: dict[str, Any]) -> vol.Schema: CUSTOM_INTEGRATION_MANIFEST_SCHEMA = INTEGRATION_MANIFEST_SCHEMA.extend( { + vol.Required("documentation"): vol.All(vol.Url(), custom_documentation_url), vol.Optional("version"): vol.All(str, verify_version), vol.Optional("issue_tracker"): vol.Url(), vol.Optional("import_executor"): bool, From 8109efe8108160fed239d58a304aaacda878bb37 Mon Sep 17 00:00:00 2001 From: jesperraemaekers <146726232+jesperraemaekers@users.noreply.github.com> Date: Tue, 14 Jan 2025 13:27:47 +0100 Subject: [PATCH 0415/2987] Reverted async-dependency to todo for Weheat (#135588) --- homeassistant/components/weheat/quality_scale.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/weheat/quality_scale.yaml b/homeassistant/components/weheat/quality_scale.yaml index f924c0c3582..aa5606ffe2a 100644 --- a/homeassistant/components/weheat/quality_scale.yaml +++ b/homeassistant/components/weheat/quality_scale.yaml @@ -88,6 +88,9 @@ rules: While unlikely to happen. Check if it is easily integrated. # Platinum - async-dependency: done + async-dependency: + status: todo + comment: | + Dependency uses asyncio.to_thread, but this is not real async. inject-websession: todo strict-typing: todo From edc7c0ff2ffad6353c92ed0d8428e38a58d63b34 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Jan 2025 13:28:43 +0100 Subject: [PATCH 0416/2987] Bump securetar to 2025.1.1 (#135582) --- homeassistant/components/backup/manager.py | 15 +++++++++++++-- homeassistant/components/backup/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, 19 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 378cf1c0335..83121e8bf38 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -10,7 +10,7 @@ from enum import StrEnum import hashlib import io import json -from pathlib import Path +from pathlib import Path, PurePath import shutil import tarfile import time @@ -1231,6 +1231,17 @@ class CoreBackupReaderWriter(BackupReaderWriter): if not database_included: excludes = excludes + EXCLUDE_DATABASE_FROM_BACKUP + def is_excluded_by_filter(path: PurePath) -> bool: + """Filter to filter excludes.""" + + for exclude in excludes: + if not path.match(exclude): + continue + LOGGER.debug("Ignoring %s because of %s", path, exclude) + return True + + return False + outer_secure_tarfile = SecureTarFile( tar_file_path, "w", gzip=False, bufsize=BUF_SIZE ) @@ -1249,7 +1260,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): atomic_contents_add( tar_file=core_tar, origin_path=Path(self._hass.config.path()), - excludes=excludes, + file_filter=is_excluded_by_filter, arcname="data", ) return (tar_file_path, tar_file_path.stat().st_size) diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index b399043e013..9d8d9956097 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["cronsim==2.6", "securetar==2024.11.0"] + "requirements": ["cronsim==2.6", "securetar==2025.1.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2582c872f16..0c601f37b0b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -60,7 +60,7 @@ PyTurboJPEG==1.7.5 pyudev==0.24.1 PyYAML==6.0.2 requests==2.32.3 -securetar==2024.11.0 +securetar==2025.1.1 SQLAlchemy==2.0.36 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' diff --git a/pyproject.toml b/pyproject.toml index 7e32b6b61b1..b1bac5a2e15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ dependencies = [ "python-slugify==8.0.4", "PyYAML==6.0.2", "requests==2.32.3", - "securetar==2024.11.0", + "securetar==2025.1.1", "SQLAlchemy==2.0.36", "standard-aifc==3.13.0;python_version>='3.13'", "standard-telnetlib==3.13.0;python_version>='3.13'", diff --git a/requirements.txt b/requirements.txt index 925985dfd42..a8b816b89d5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,7 +38,7 @@ psutil-home-assistant==0.0.1 python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 -securetar==2024.11.0 +securetar==2025.1.1 SQLAlchemy==2.0.36 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' diff --git a/requirements_all.txt b/requirements_all.txt index b918cc3e944..374199406f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2665,7 +2665,7 @@ screenlogicpy==0.10.0 scsgate==0.1.0 # homeassistant.components.backup -securetar==2024.11.0 +securetar==2025.1.1 # homeassistant.components.sendgrid sendgrid==6.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25dc0470bfd..13afc39bf18 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2147,7 +2147,7 @@ sanix==1.0.6 screenlogicpy==0.10.0 # homeassistant.components.backup -securetar==2024.11.0 +securetar==2025.1.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense From 6a032baa48dee4ec3a99787428a5a0727d07946f Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 14 Jan 2025 22:46:10 +1000 Subject: [PATCH 0417/2987] Add streaming binary sensors to Teslemetry (#135248) Co-authored-by: Joost Lekkerkerker --- .../components/teslemetry/binary_sensor.py | 307 +++- .../components/teslemetry/strings.json | 83 +- .../snapshots/test_binary_sensor.ambr | 1499 ++++++++++++++++- .../teslemetry/test_binary_sensor.py | 56 + 4 files changed, 1876 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index e7016fe4a91..0b6823f8b61 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -4,17 +4,20 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from itertools import chain from typing import cast +from teslemetry_stream import Signal +from teslemetry_stream.const import WindowState + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType from . import TeslemetryConfigEntry @@ -23,6 +26,7 @@ from .entity import ( TeslemetryEnergyInfoEntity, TeslemetryEnergyLiveEntity, TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, ) from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -33,133 +37,327 @@ PARALLEL_UPDATES = 0 class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes Teslemetry binary sensor entity.""" - is_on: Callable[[StateType], bool] = bool + polling_value_fn: Callable[[StateType], bool | None] = bool + polling: bool = False + streaming_key: Signal | None = None + streaming_firmware: str = "2024.26" + streaming_value_fn: Callable[[StateType], bool | None] = ( + lambda x: x is True or x == "true" + ) VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="state", + polling=True, + polling_value_fn=lambda x: x == TeslemetryState.ONLINE, device_class=BinarySensorDeviceClass.CONNECTIVITY, - is_on=lambda x: x == TeslemetryState.ONLINE, ), TeslemetryBinarySensorEntityDescription( key="charge_state_battery_heater_on", + polling=True, + streaming_key=Signal.BATTERY_HEATER_ON, device_class=BinarySensorDeviceClass.HEAT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_charger_phases", - is_on=lambda x: cast(int, x) > 1, + polling=True, + streaming_key=Signal.CHARGER_PHASES, + polling_value_fn=lambda x: cast(int, x) > 1, + streaming_value_fn=lambda x: cast(int, x) > 1, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_preconditioning_enabled", + polling=True, + streaming_key=Signal.PRECONDITIONING_ENABLED, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="climate_state_is_preconditioning", + polling=True, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_scheduled_charging_pending", + polling=True, + streaming_key=Signal.SCHEDULED_CHARGING_PENDING, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_trip_charging", + polling=True, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_conn_charge_cable", - is_on=lambda x: x != "", + polling=True, + polling_value_fn=lambda x: x != "", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, ), TeslemetryBinarySensorEntityDescription( key="climate_state_cabin_overheat_protection_actively_cooling", + polling=True, device_class=BinarySensorDeviceClass.HEAT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_dashcam_state", + polling=True, device_class=BinarySensorDeviceClass.RUNNING, - is_on=lambda x: x == "Recording", + polling_value_fn=lambda x: x == "Recording", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_is_user_present", + polling=True, device_class=BinarySensorDeviceClass.PRESENCE, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_tpms_soft_warning_fl", + polling=True, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_tpms_soft_warning_fr", + polling=True, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_tpms_soft_warning_rl", + polling=True, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_tpms_soft_warning_rr", + polling=True, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_fd_window", + polling=True, + streaming_key=Signal.FD_WINDOW, + streaming_value_fn=lambda x: WindowState.get(x) != "Closed", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_fp_window", + polling=True, + streaming_key=Signal.FP_WINDOW, + streaming_value_fn=lambda x: WindowState.get(x) != "Closed", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_rd_window", + polling=True, + streaming_key=Signal.RD_WINDOW, + streaming_value_fn=lambda x: WindowState.get(x) != "Closed", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_rp_window", + polling=True, + streaming_key=Signal.RP_WINDOW, + streaming_value_fn=lambda x: WindowState.get(x) != "Closed", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_df", + polling=True, device_class=BinarySensorDeviceClass.DOOR, + streaming_key=Signal.DOOR_STATE, + streaming_value_fn=lambda x: cast(dict, x).get("DriverFront"), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_dr", + polling=True, device_class=BinarySensorDeviceClass.DOOR, + streaming_key=Signal.DOOR_STATE, + streaming_value_fn=lambda x: cast(dict, x).get("DriverRear"), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_pf", + polling=True, device_class=BinarySensorDeviceClass.DOOR, + streaming_key=Signal.DOOR_STATE, + streaming_value_fn=lambda x: cast(dict, x).get("PassengerFront"), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_pr", + polling=True, device_class=BinarySensorDeviceClass.DOOR, + streaming_key=Signal.DOOR_STATE, + streaming_value_fn=lambda x: cast(dict, x).get("PassengerRear"), entity_category=EntityCategory.DIAGNOSTIC, ), + TeslemetryBinarySensorEntityDescription( + key="automatic_blind_spot_camera", + streaming_key=Signal.AUTOMATIC_BLIND_SPOT_CAMERA, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="automatic_emergency_braking_off", + streaming_key=Signal.AUTOMATIC_EMERGENCY_BRAKING_OFF, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="blind_spot_collision_warning_chime", + streaming_key=Signal.BLIND_SPOT_COLLISION_WARNING_CHIME, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="bms_full_charge_complete", + streaming_key=Signal.BMS_FULL_CHARGE_COMPLETE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="brake_pedal", + streaming_key=Signal.BRAKE_PEDAL, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="charge_port_cold_weather_mode", + streaming_key=Signal.CHARGE_PORT_COLD_WEATHER_MODE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="service_mode", + streaming_key=Signal.SERVICE_MODE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="pin_to_drive_enabled", + streaming_key=Signal.PIN_TO_DRIVE_ENABLED, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="drive_rail", + streaming_key=Signal.DRIVE_RAIL, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="driver_seat_belt", + streaming_key=Signal.DRIVER_SEAT_BELT, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="driver_seat_occupied", + streaming_key=Signal.DRIVER_SEAT_OCCUPIED, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="passenger_seat_belt", + streaming_key=Signal.PASSENGER_SEAT_BELT, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="fast_charger_present", + streaming_key=Signal.FAST_CHARGER_PRESENT, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="gps_state", + streaming_key=Signal.GPS_STATE, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), + TeslemetryBinarySensorEntityDescription( + key="guest_mode_enabled", + streaming_key=Signal.GUEST_MODE_ENABLED, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="dc_dc_enable", + streaming_key=Signal.DC_DC_ENABLE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="emergency_lane_departure_avoidance", + streaming_key=Signal.EMERGENCY_LANE_DEPARTURE_AVOIDANCE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="supercharger_session_trip_planner", + streaming_key=Signal.SUPERCHARGER_SESSION_TRIP_PLANNER, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="wiper_heat_enabled", + streaming_key=Signal.WIPER_HEAT_ENABLED, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="rear_display_hvac_enabled", + streaming_key=Signal.REAR_DISPLAY_HVAC_ENABLED, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="offroad_lightbar_present", + streaming_key=Signal.OFFROAD_LIGHTBAR_PRESENT, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="homelink_nearby", + streaming_key=Signal.HOMELINK_NEARBY, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="europe_vehicle", + streaming_key=Signal.EUROPE_VEHICLE, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="right_hand_drive", + streaming_key=Signal.RIGHT_HAND_DRIVE, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="located_at_home", + streaming_key=Signal.LOCATED_AT_HOME, + streaming_firmware="2024.44.32", + ), + TeslemetryBinarySensorEntityDescription( + key="located_at_work", + streaming_key=Signal.LOCATED_AT_WORK, + streaming_firmware="2024.44.32", + ), + TeslemetryBinarySensorEntityDescription( + key="located_at_favorite", + streaming_key=Signal.LOCATED_AT_FAVORITE, + streaming_firmware="2024.44.32", + entity_registry_enabled_default=False, + ), ) ENERGY_LIVE_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( @@ -183,31 +381,42 @@ async def async_setup_entry( ) -> None: """Set up the Teslemetry binary sensor platform from a config entry.""" - async_add_entities( - chain( - ( # Vehicles - TeslemetryVehicleBinarySensorEntity(vehicle, description) - for vehicle in entry.runtime_data.vehicles - for description in VEHICLE_DESCRIPTIONS - ), - ( # Energy Site Live - TeslemetryEnergyLiveBinarySensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - if energysite.live_coordinator - for description in ENERGY_LIVE_DESCRIPTIONS - if energysite.info_coordinator.data.get("components_battery") - ), - ( # Energy Site Info - TeslemetryEnergyInfoBinarySensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_INFO_DESCRIPTIONS - if energysite.info_coordinator.data.get("components_battery") - ), - ) + entities: list[BinarySensorEntity] = [] + for vehicle in entry.runtime_data.vehicles: + for description in VEHICLE_DESCRIPTIONS: + if ( + not vehicle.api.pre2021 + and description.streaming_key + and vehicle.firmware >= description.streaming_firmware + ): + entities.append( + TeslemetryVehicleStreamingBinarySensorEntity(vehicle, description) + ) + elif description.polling: + entities.append( + TeslemetryVehiclePollingBinarySensorEntity(vehicle, description) + ) + + entities.extend( + TeslemetryEnergyLiveBinarySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + if energysite.live_coordinator + for description in ENERGY_LIVE_DESCRIPTIONS + if description.key in energysite.live_coordinator.data + ) + entities.extend( + TeslemetryEnergyInfoBinarySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_INFO_DESCRIPTIONS + if description.key in energysite.info_coordinator.data ) + async_add_entities(entities) -class TeslemetryVehicleBinarySensorEntity(TeslemetryVehicleEntity, BinarySensorEntity): + +class TeslemetryVehiclePollingBinarySensorEntity( + TeslemetryVehicleEntity, BinarySensorEntity +): """Base class for Teslemetry vehicle binary sensors.""" entity_description: TeslemetryBinarySensorEntityDescription @@ -224,12 +433,40 @@ class TeslemetryVehicleBinarySensorEntity(TeslemetryVehicleEntity, BinarySensorE def _async_update_attrs(self) -> None: """Update the attributes of the binary sensor.""" - if self._value is None: - self._attr_available = False - self._attr_is_on = None - else: - self._attr_available = True - self._attr_is_on = self.entity_description.is_on(self._value) + self._attr_available = self._value is not None + if self._attr_available: + assert self._value is not None + self._attr_is_on = self.entity_description.polling_value_fn(self._value) + + +class TeslemetryVehicleStreamingBinarySensorEntity( + TeslemetryVehicleStreamEntity, BinarySensorEntity, RestoreEntity +): + """Base class for Teslemetry vehicle streaming sensors.""" + + entity_description: TeslemetryBinarySensorEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryBinarySensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + assert description.streaming_key + super().__init__(data, description.key, description.streaming_key) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + self._attr_is_on = state.state == STATE_ON + + def _async_value_from_stream(self, value) -> None: + """Update the value of the entity.""" + self._attr_available = value is not None + if self._attr_available: + self._attr_is_on = self.entity_description.streaming_value_fn(value) class TeslemetryEnergyLiveBinarySensorEntity( diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 736762dc6f4..b40d1a83d7d 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -51,7 +51,7 @@ "name": "Trip charging" }, "climate_state_cabin_overheat_protection_actively_cooling": { - "name": "Cabin overheat protection actively cooling" + "name": "Cabin overheat protection active" }, "climate_state_is_preconditioning": { "name": "Preconditioning" @@ -68,6 +68,27 @@ "storm_mode_active": { "name": "Storm watch active" }, + "automatic_blind_spot_camera": { + "name": "Automatic blind spot camera" + }, + "automatic_emergency_braking_off": { + "name": "Automatic emergency braking off" + }, + "blind_spot_collision_warning_chime": { + "name": "Blind spot collision warning chime" + }, + "bms_full_charge_complete": { + "name": "BMS full charge" + }, + "brake_pedal": { + "name": "Brake pedal" + }, + "charge_port_cold_weather_mode": { + "name": "Charge port cold weather mode" + }, + "service_mode": { + "name": "Service mode" + }, "vehicle_state_dashcam_state": { "name": "Dashcam" }, @@ -109,6 +130,66 @@ }, "vehicle_state_tpms_soft_warning_rr": { "name": "Tire pressure warning rear right" + }, + "pin_to_drive_enabled": { + "name": "Pin to drive enabled" + }, + "drive_rail": { + "name": "Drive rail" + }, + "driver_seat_belt": { + "name": "Driver seat belt" + }, + "driver_seat_occupied": { + "name": "Driver seat occupied" + }, + "passenger_seat_belt": { + "name": "Passenger seat belt" + }, + "fast_charger_present": { + "name": "Fast charger present" + }, + "gps_state": { + "name": "GPS state" + }, + "guest_mode_enabled": { + "name": "Guest mode enabled" + }, + "dc_dc_enable": { + "name": "DC to DC converter" + }, + "emergency_lane_departure_avoidance": { + "name": "Emergency lane departure avoidance" + }, + "supercharger_session_trip_planner": { + "name": "Supercharger session trip planner" + }, + "wiper_heat_enabled": { + "name": "Wiper heat" + }, + "rear_display_hvac_enabled": { + "name": "Rear display HVAC" + }, + "offroad_lightbar_present": { + "name": "Offroad lightbar" + }, + "homelink_nearby": { + "name": "Homelink nearby" + }, + "europe_vehicle": { + "name": "European vehicle" + }, + "right_hand_drive": { + "name": "Right hand drive" + }, + "located_at_home": { + "name": "Located at home" + }, + "located_at_work": { + "name": "Located at work" + }, + "located_at_favorite": { + "name": "Located at favorite" } }, "button": { diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index 95330840109..e90cc9ced55 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -183,6 +183,98 @@ 'state': 'off', }) # --- +# name: test_binary_sensor[binary_sensor.test_automatic_blind_spot_camera-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', + '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': 'Automatic blind spot camera', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'automatic_blind_spot_camera', + 'unique_id': 'LRW3F7EK4NC700000-automatic_blind_spot_camera', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_automatic_blind_spot_camera-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Automatic blind spot camera', + }), + 'context': , + 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_automatic_emergency_braking_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', + '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': 'Automatic emergency braking off', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'automatic_emergency_braking_off', + 'unique_id': 'LRW3F7EK4NC700000-automatic_emergency_braking_off', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_automatic_emergency_braking_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Automatic emergency braking off', + }), + 'context': , + 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_battery_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -227,10 +319,148 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- -# name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_actively_cooling-entry] +# name: test_binary_sensor[binary_sensor.test_blind_spot_collision_warning_chime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', + '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': 'Blind spot collision warning chime', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'blind_spot_collision_warning_chime', + 'unique_id': 'LRW3F7EK4NC700000-blind_spot_collision_warning_chime', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_blind_spot_collision_warning_chime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Blind spot collision warning chime', + }), + 'context': , + 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_bms_full_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_bms_full_charge', + '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': 'BMS full charge', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bms_full_charge_complete', + 'unique_id': 'LRW3F7EK4NC700000-bms_full_charge_complete', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_bms_full_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test BMS full charge', + }), + 'context': , + 'entity_id': 'binary_sensor.test_bms_full_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_brake_pedal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_brake_pedal', + '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': 'Brake pedal', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brake_pedal', + 'unique_id': 'LRW3F7EK4NC700000-brake_pedal', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_brake_pedal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Brake pedal', + }), + 'context': , + 'entity_id': 'binary_sensor.test_brake_pedal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_active-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -242,7 +472,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_active', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -254,7 +484,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Cabin overheat protection actively cooling', + 'original_name': 'Cabin overheat protection active', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, @@ -263,14 +493,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_actively_cooling-state] +# name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_active-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'heat', - 'friendly_name': 'Test Cabin overheat protection actively cooling', + 'friendly_name': 'Test Cabin overheat protection active', }), 'context': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_active', 'last_changed': , 'last_reported': , 'last_updated': , @@ -324,6 +554,52 @@ 'state': 'on', }) # --- +# name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', + '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': 'Charge port cold weather mode', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_port_cold_weather_mode', + 'unique_id': 'LRW3F7EK4NC700000-charge_port_cold_weather_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge port cold weather mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_charger_has_multiple_phases-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -367,7 +643,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_dashcam-entry] @@ -417,6 +693,328 @@ 'state': 'on', }) # --- +# name: test_binary_sensor[binary_sensor.test_dc_to_dc_converter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_dc_to_dc_converter', + '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': 'DC to DC converter', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dc_dc_enable', + 'unique_id': 'LRW3F7EK4NC700000-dc_dc_enable', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_dc_to_dc_converter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test DC to DC converter', + }), + 'context': , + 'entity_id': 'binary_sensor.test_dc_to_dc_converter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_drive_rail-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_drive_rail', + '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': 'Drive rail', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_rail', + 'unique_id': 'LRW3F7EK4NC700000-drive_rail', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_drive_rail-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Drive rail', + }), + 'context': , + 'entity_id': 'binary_sensor.test_drive_rail', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_driver_seat_belt-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_driver_seat_belt', + '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': 'Driver seat belt', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'driver_seat_belt', + 'unique_id': 'LRW3F7EK4NC700000-driver_seat_belt', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_driver_seat_belt-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Driver seat belt', + }), + 'context': , + 'entity_id': 'binary_sensor.test_driver_seat_belt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_driver_seat_occupied-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_driver_seat_occupied', + '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': 'Driver seat occupied', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'driver_seat_occupied', + 'unique_id': 'LRW3F7EK4NC700000-driver_seat_occupied', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_driver_seat_occupied-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Driver seat occupied', + }), + 'context': , + 'entity_id': 'binary_sensor.test_driver_seat_occupied', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_emergency_lane_departure_avoidance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', + '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': 'Emergency lane departure avoidance', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'emergency_lane_departure_avoidance', + 'unique_id': 'LRW3F7EK4NC700000-emergency_lane_departure_avoidance', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_emergency_lane_departure_avoidance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Emergency lane departure avoidance', + }), + 'context': , + 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_european_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_european_vehicle', + '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': 'European vehicle', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'europe_vehicle', + 'unique_id': 'LRW3F7EK4NC700000-europe_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_european_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test European vehicle', + }), + 'context': , + 'entity_id': 'binary_sensor.test_european_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_fast_charger_present-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_fast_charger_present', + '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': 'Fast charger present', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fast_charger_present', + 'unique_id': 'LRW3F7EK4NC700000-fast_charger_present', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_fast_charger_present-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Fast charger present', + }), + 'context': , + 'entity_id': 'binary_sensor.test_fast_charger_present', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_front_driver_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -461,7 +1059,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_front_driver_window-entry] @@ -508,7 +1106,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_front_passenger_door-entry] @@ -555,7 +1153,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_front_passenger_window-entry] @@ -602,7 +1200,284 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_gps_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_gps_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': 'GPS state', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gps_state', + 'unique_id': 'LRW3F7EK4NC700000-gps_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_gps_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test GPS state', + }), + 'context': , + 'entity_id': 'binary_sensor.test_gps_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_guest_mode_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_guest_mode_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': 'Guest mode enabled', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'guest_mode_enabled', + 'unique_id': 'LRW3F7EK4NC700000-guest_mode_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_guest_mode_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Guest mode enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_guest_mode_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_homelink_nearby-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_homelink_nearby', + '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': 'Homelink nearby', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'homelink_nearby', + 'unique_id': 'LRW3F7EK4NC700000-homelink_nearby', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_homelink_nearby-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Homelink nearby', + }), + 'context': , + 'entity_id': 'binary_sensor.test_homelink_nearby', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_offroad_lightbar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_offroad_lightbar', + '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': 'Offroad lightbar', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'offroad_lightbar_present', + 'unique_id': 'LRW3F7EK4NC700000-offroad_lightbar_present', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_offroad_lightbar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Offroad lightbar', + }), + 'context': , + 'entity_id': 'binary_sensor.test_offroad_lightbar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_passenger_seat_belt-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_passenger_seat_belt', + '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': 'Passenger seat belt', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'passenger_seat_belt', + 'unique_id': 'LRW3F7EK4NC700000-passenger_seat_belt', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_passenger_seat_belt-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Passenger seat belt', + }), + 'context': , + 'entity_id': 'binary_sensor.test_passenger_seat_belt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_pin_to_drive_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': 'Pin to drive enabled', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pin_to_drive_enabled', + 'unique_id': 'LRW3F7EK4NC700000-pin_to_drive_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pin to drive enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_preconditioning-entry] @@ -694,7 +1569,53 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_display_hvac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_rear_display_hvac', + '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': 'Rear display HVAC', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rear_display_hvac_enabled', + 'unique_id': 'LRW3F7EK4NC700000-rear_display_hvac_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_display_hvac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Rear display HVAC', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_display_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_driver_door-entry] @@ -741,7 +1662,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_driver_window-entry] @@ -788,7 +1709,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_passenger_door-entry] @@ -835,7 +1756,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_passenger_window-entry] @@ -882,7 +1803,53 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_right_hand_drive-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_right_hand_drive', + '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': 'Right hand drive', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'right_hand_drive', + 'unique_id': 'LRW3F7EK4NC700000-right_hand_drive', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_right_hand_drive-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Right hand drive', + }), + 'context': , + 'entity_id': 'binary_sensor.test_right_hand_drive', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_scheduled_charging_pending-entry] @@ -928,7 +1895,53 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_service_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_service_mode', + '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': 'Service mode', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'service_mode', + 'unique_id': 'LRW3F7EK4NC700000-service_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_service_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Service mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_service_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_status-entry] @@ -978,6 +1991,52 @@ 'state': 'on', }) # --- +# name: test_binary_sensor[binary_sensor.test_supercharger_session_trip_planner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', + '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': 'Supercharger session trip planner', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supercharger_session_trip_planner', + 'unique_id': 'LRW3F7EK4NC700000-supercharger_session_trip_planner', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_supercharger_session_trip_planner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Supercharger session trip planner', + }), + 'context': , + 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_tire_pressure_warning_front_left-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1259,6 +2318,52 @@ 'state': 'off', }) # --- +# name: test_binary_sensor[binary_sensor.test_wiper_heat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_wiper_heat', + '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': 'Wiper heat', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wiper_heat_enabled', + 'unique_id': 'LRW3F7EK4NC700000-wiper_heat_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_wiper_heat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Wiper heat', + }), + 'context': , + 'entity_id': 'binary_sensor.test_wiper_heat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.energy_site_backup_capable-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1311,6 +2416,32 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_automatic_blind_spot_camera-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Automatic blind spot camera', + }), + 'context': , + 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_automatic_emergency_braking_off-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Automatic emergency braking off', + }), + 'context': , + 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_battery_heater-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1322,17 +2453,56 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_cabin_overheat_protection_actively_cooling-statealt] +# name: test_binary_sensor_refresh[binary_sensor.test_blind_spot_collision_warning_chime-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Blind spot collision warning chime', + }), + 'context': , + 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_bms_full_charge-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test BMS full charge', + }), + 'context': , + 'entity_id': 'binary_sensor.test_bms_full_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_brake_pedal-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Brake pedal', + }), + 'context': , + 'entity_id': 'binary_sensor.test_brake_pedal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_cabin_overheat_protection_active-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'heat', - 'friendly_name': 'Test Cabin overheat protection actively cooling', + 'friendly_name': 'Test Cabin overheat protection active', }), 'context': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_active', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1353,6 +2523,19 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_charge_port_cold_weather_mode-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge port cold weather mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_charger_has_multiple_phases-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1363,7 +2546,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_dashcam-statealt] @@ -1380,6 +2563,97 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_dc_to_dc_converter-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test DC to DC converter', + }), + 'context': , + 'entity_id': 'binary_sensor.test_dc_to_dc_converter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_drive_rail-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Drive rail', + }), + 'context': , + 'entity_id': 'binary_sensor.test_drive_rail', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_driver_seat_belt-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Driver seat belt', + }), + 'context': , + 'entity_id': 'binary_sensor.test_driver_seat_belt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_driver_seat_occupied-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Driver seat occupied', + }), + 'context': , + 'entity_id': 'binary_sensor.test_driver_seat_occupied', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_emergency_lane_departure_avoidance-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Emergency lane departure avoidance', + }), + 'context': , + 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_european_vehicle-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test European vehicle', + }), + 'context': , + 'entity_id': 'binary_sensor.test_european_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_fast_charger_present-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Fast charger present', + }), + 'context': , + 'entity_id': 'binary_sensor.test_fast_charger_present', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_front_driver_door-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1391,7 +2665,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_driver_window-statealt] @@ -1405,7 +2679,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_passenger_door-statealt] @@ -1419,7 +2693,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_passenger_window-statealt] @@ -1433,7 +2707,86 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_gps_state-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test GPS state', + }), + 'context': , + 'entity_id': 'binary_sensor.test_gps_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_guest_mode_enabled-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Guest mode enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_guest_mode_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_homelink_nearby-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Homelink nearby', + }), + 'context': , + 'entity_id': 'binary_sensor.test_homelink_nearby', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_offroad_lightbar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Offroad lightbar', + }), + 'context': , + 'entity_id': 'binary_sensor.test_offroad_lightbar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_passenger_seat_belt-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Passenger seat belt', + }), + 'context': , + 'entity_id': 'binary_sensor.test_passenger_seat_belt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_pin_to_drive_enabled-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pin to drive enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_preconditioning-statealt] @@ -1459,7 +2812,20 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_rear_display_hvac-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Rear display HVAC', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_display_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_door-statealt] @@ -1473,7 +2839,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_window-statealt] @@ -1487,7 +2853,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_passenger_door-statealt] @@ -1501,7 +2867,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_passenger_window-statealt] @@ -1515,7 +2881,20 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_right_hand_drive-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Right hand drive', + }), + 'context': , + 'entity_id': 'binary_sensor.test_right_hand_drive', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_scheduled_charging_pending-statealt] @@ -1528,7 +2907,20 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_service_mode-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Service mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_service_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_status-statealt] @@ -1545,6 +2937,19 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_supercharger_session_trip_planner-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Supercharger session trip planner', + }), + 'context': , + 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_tire_pressure_warning_front_left-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1628,3 +3033,31 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_wiper_heat-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Wiper heat', + }), + 'context': , + 'entity_id': 'binary_sensor.test_wiper_heat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_driver_seat_belt-state] + 'off' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_front_driver_door-state] + 'off' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_front_driver_window-state] + 'on' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_front_passenger_door-state] + 'off' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_front_passenger_window-state] + 'on' +# --- diff --git a/tests/components/teslemetry/test_binary_sensor.py b/tests/components/teslemetry/test_binary_sensor.py index 0a47dce9537..5a7126afe1b 100644 --- a/tests/components/teslemetry/test_binary_sensor.py +++ b/tests/components/teslemetry/test_binary_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.const import Platform @@ -48,3 +49,58 @@ async def test_binary_sensor_refresh( await hass.async_block_till_done() assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensors_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the binary sensor entities with streaming are correct.""" + + freezer.move_to("2024-01-01 00:00:00+00:00") + + entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.FD_WINDOW: "WindowStateOpened", + Signal.FP_WINDOW: "INVALID_VALUE", + Signal.DOOR_STATE: { + "DoorState": { + "DriverFront": True, + "DriverRear": False, + "PassengerFront": False, + "PassengerRear": False, + "TrunkFront": False, + "TrunkRear": False, + } + }, + Signal.DRIVER_SEAT_BELT: None, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Reload the entry + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "binary_sensor.test_front_driver_window", + "binary_sensor.test_front_passenger_window", + "binary_sensor.test_front_driver_door", + "binary_sensor.test_front_passenger_door", + "binary_sensor.test_driver_seat_belt", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-state") From d6ee7a2c1e4506a3c2a4f6b11df3f00ade939e9d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 14 Jan 2025 13:54:08 +0100 Subject: [PATCH 0418/2987] Add serial number to LaMetric (#135591) --- homeassistant/components/lametric/entity.py | 1 + tests/components/lametric/test_button.py | 1 + tests/components/lametric/test_number.py | 1 + tests/components/lametric/test_select.py | 1 + tests/components/lametric/test_sensor.py | 1 + tests/components/lametric/test_switch.py | 1 + 6 files changed, 6 insertions(+) diff --git a/homeassistant/components/lametric/entity.py b/homeassistant/components/lametric/entity.py index 2a952851712..eb331650870 100644 --- a/homeassistant/components/lametric/entity.py +++ b/homeassistant/components/lametric/entity.py @@ -30,4 +30,5 @@ class LaMetricEntity(CoordinatorEntity[LaMetricDataUpdateCoordinator]): model_id=coordinator.data.model, name=coordinator.data.name, sw_version=coordinator.data.os_version, + serial_number=coordinator.data.serial_number, ) diff --git a/tests/components/lametric/test_button.py b/tests/components/lametric/test_button.py index 04efeaac87f..cc8c1379fe0 100644 --- a/tests/components/lametric/test_button.py +++ b/tests/components/lametric/test_button.py @@ -52,6 +52,7 @@ async def test_button_app_next( assert device_entry.model_id == "LM 37X8" assert device_entry.name == "Frenck's LaMetric" assert device_entry.sw_version == "2.2.2" + assert device_entry.serial_number == "SA110405124500W00BS9" assert device_entry.hw_version is None await hass.services.async_call( diff --git a/tests/components/lametric/test_number.py b/tests/components/lametric/test_number.py index 681abf850d2..c85639fc9f1 100644 --- a/tests/components/lametric/test_number.py +++ b/tests/components/lametric/test_number.py @@ -62,6 +62,7 @@ async def test_brightness( assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} assert device.manufacturer == "LaMetric Inc." assert device.name == "Frenck's LaMetric" + assert device.serial_number == "SA110405124500W00BS9" assert device.sw_version == "2.2.2" await hass.services.async_call( diff --git a/tests/components/lametric/test_select.py b/tests/components/lametric/test_select.py index 6b3fa291e9c..e4b9870f52b 100644 --- a/tests/components/lametric/test_select.py +++ b/tests/components/lametric/test_select.py @@ -55,6 +55,7 @@ async def test_brightness_mode( assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} assert device.manufacturer == "LaMetric Inc." assert device.name == "Frenck's LaMetric" + assert device.serial_number == "SA110405124500W00BS9" assert device.sw_version == "2.2.2" await hass.services.async_call( diff --git a/tests/components/lametric/test_sensor.py b/tests/components/lametric/test_sensor.py index 8dff11fb450..08b289e2425 100644 --- a/tests/components/lametric/test_sensor.py +++ b/tests/components/lametric/test_sensor.py @@ -48,4 +48,5 @@ async def test_wifi_signal( assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} assert device.manufacturer == "LaMetric Inc." assert device.name == "Frenck's LaMetric" + assert device.serial_number == "SA110405124500W00BS9" assert device.sw_version == "2.2.2" diff --git a/tests/components/lametric/test_switch.py b/tests/components/lametric/test_switch.py index 367d5605e06..64ebe22e98b 100644 --- a/tests/components/lametric/test_switch.py +++ b/tests/components/lametric/test_switch.py @@ -57,6 +57,7 @@ async def test_bluetooth( assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} assert device.manufacturer == "LaMetric Inc." assert device.name == "Frenck's LaMetric" + assert device.serial_number == "SA110405124500W00BS9" assert device.sw_version == "2.2.2" await hass.services.async_call( From 421c4889bfd9fa231cf561ed70902a4bea1545cd Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 14 Jan 2025 14:02:17 +0100 Subject: [PATCH 0419/2987] Use device supplied ranges in LaMetric (#135590) --- homeassistant/components/lametric/number.py | 23 +++++-- .../lametric/fixtures/computer_powered.json | 68 +++++++++++++++++++ tests/components/lametric/test_number.py | 15 +++- 3 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 tests/components/lametric/fixtures/computer_powered.json diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index 0d299a2e93a..ccfd48a3abf 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from demetriek import Device, LaMetricDevice +from demetriek import Device, LaMetricDevice, Range from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry @@ -25,6 +25,7 @@ class LaMetricNumberEntityDescription(NumberEntityDescription): """Class describing LaMetric number entities.""" value_fn: Callable[[Device], int | None] + range_fn: Callable[[Device], Range | None] has_fn: Callable[[Device], bool] = lambda device: True set_value_fn: Callable[[LaMetricDevice, float], Awaitable[Any]] @@ -35,8 +36,7 @@ NUMBERS = [ translation_key="brightness", entity_category=EntityCategory.CONFIG, native_step=1, - native_min_value=0, - native_max_value=100, + range_fn=lambda device: device.display.brightness_limit, native_unit_of_measurement=PERCENTAGE, value_fn=lambda device: device.display.brightness, set_value_fn=lambda device, bri: device.display(brightness=int(bri)), @@ -46,8 +46,7 @@ NUMBERS = [ translation_key="volume", entity_category=EntityCategory.CONFIG, native_step=1, - native_min_value=0, - native_max_value=100, + range_fn=lambda device: device.audio.volume_range if device.audio else None, native_unit_of_measurement=PERCENTAGE, has_fn=lambda device: bool(device.audio and device.audio.available), value_fn=lambda device: device.audio.volume if device.audio else 0, @@ -92,6 +91,20 @@ class LaMetricNumberEntity(LaMetricEntity, NumberEntity): """Return the number value.""" return self.entity_description.value_fn(self.coordinator.data) + @property + def native_min_value(self) -> int: + """Return the min range.""" + if limits := self.entity_description.range_fn(self.coordinator.data): + return limits.range_min + return 0 + + @property + def native_max_value(self) -> int: + """Return the max range.""" + if limits := self.entity_description.range_fn(self.coordinator.data): + return limits.range_max + return 100 + @lametric_exception_handler async def async_set_native_value(self, value: float) -> None: """Change to new number value.""" diff --git a/tests/components/lametric/fixtures/computer_powered.json b/tests/components/lametric/fixtures/computer_powered.json new file mode 100644 index 00000000000..0465dd4dd3a --- /dev/null +++ b/tests/components/lametric/fixtures/computer_powered.json @@ -0,0 +1,68 @@ +{ + "audio": { + "available": true, + "volume": 53, + "volume_limit": { + "max": 100, + "min": 0 + }, + "volume_range": { + "max": 100, + "min": 0 + } + }, + "bluetooth": { + "active": false, + "address": "40:F4:C9:AA:AA:AA", + "available": true, + "discoverable": true, + "mac": "40:F4:C9:AA:AA:AA", + "name": "LM8367", + "pairable": false + }, + "display": { + "brightness": 75, + "brightness_limit": { + "max": 76, + "min": 2 + }, + "brightness_mode": "manual", + "brightness_range": { + "max": 100, + "min": 0 + }, + "height": 8, + "on": true, + "screensaver": { + "enabled": true, + "modes": { + "time_based": { + "enabled": false + }, + "when_dark": { + "enabled": true + } + }, + "widget": "1_com.lametric.clock" + }, + "type": "mixed", + "width": 37 + }, + "id": "67790", + "mode": "manual", + "model": "sa8", + "name": "TIME", + "os_version": "3.1.3", + "serial_number": "SA840700836700W00BAA", + "wifi": { + "active": true, + "mac": "40:F4:C9:AA:AA:AA", + "available": true, + "encryption": "WPA", + "ssid": "My wifi", + "ip": "10.0.0.99", + "mode": "dhcp", + "netmask": "255.255.255.0", + "rssi": 78 + } +} diff --git a/tests/components/lametric/test_number.py b/tests/components/lametric/test_number.py index c85639fc9f1..6e052603c24 100644 --- a/tests/components/lametric/test_number.py +++ b/tests/components/lametric/test_number.py @@ -42,7 +42,7 @@ async def test_brightness( assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's LaMetric Brightness" assert state.attributes.get(ATTR_MAX) == 100 - assert state.attributes.get(ATTR_MIN) == 0 + assert state.attributes.get(ATTR_MIN) == 2 assert state.attributes.get(ATTR_STEP) == 1 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "100" @@ -184,3 +184,16 @@ async def test_number_connection_error( state = hass.states.get("number.frenck_s_lametric_volume") assert state assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("device_fixture", ["computer_powered"]) +async def test_computer_powered_devices( + hass: HomeAssistant, + mock_lametric: MagicMock, +) -> None: + """Test Brightness is properly limited for computer powered devices.""" + state = hass.states.get("number.time_brightness") + assert state + assert state.state == "75" + assert state.attributes[ATTR_MIN] == 2 + assert state.attributes[ATTR_MAX] == 76 From 7cc61d1b861941406455437e82cef09ffb1eb8f6 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 14 Jan 2025 14:07:07 +0100 Subject: [PATCH 0420/2987] Skip fetching deactivated shopping lists in Bring integration (#135336) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/bring/coordinator.py | 2 + homeassistant/components/bring/entity.py | 2 +- tests/components/bring/test_init.py | 38 ++++++++++++++++++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index 7678213f117..a8d0a4ec322 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -70,6 +70,8 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): list_dict: dict[str, BringData] = {} for lst in lists_response["lists"]: + if (ctx := set(self.async_contexts())) and lst["listUuid"] not in ctx: + continue try: items = await self.bring.get_list(lst["listUuid"]) except BringRequestException as e: diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py index 5b6bf975764..a1e0cb2edc0 100644 --- a/homeassistant/components/bring/entity.py +++ b/homeassistant/components/bring/entity.py @@ -20,7 +20,7 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): bring_list: BringData, ) -> None: """Initialize the entity.""" - super().__init__(coordinator) + super().__init__(coordinator, bring_list["listUuid"]) self._list_uuid = bring_list["listUuid"] diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py index 5ee66999ea4..659a4768600 100644 --- a/tests/components/bring/test_init.py +++ b/tests/components/bring/test_init.py @@ -1,7 +1,9 @@ """Unit tests for the bring integration.""" +from datetime import timedelta from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.bring import ( @@ -11,11 +13,14 @@ from homeassistant.components.bring import ( async_setup_entry, ) from homeassistant.components.bring.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr -from tests.common import MockConfigEntry +from .conftest import UUID + +from tests.common import MockConfigEntry, async_fire_time_changed async def setup_integration( @@ -133,3 +138,32 @@ async def test_config_entry_not_ready_auth_error( await hass.async_block_till_done() assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("mock_bring_client") +async def test_coordinator_skips_deactivated( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + mock_bring_client: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the coordinator skips fetching lists for deactivated lists.""" + await setup_integration(hass, bring_config_entry) + + assert bring_config_entry.state is ConfigEntryState.LOADED + + assert mock_bring_client.get_list.await_count == 2 + + device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{UUID}_b4776778-7f6c-496e-951b-92a35d3db0dd")} + ) + device_registry.async_update_device(device.id, disabled_by=ConfigEntryDisabler.USER) + + mock_bring_client.get_list.reset_mock() + + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_bring_client.get_list.await_count == 1 From 406c3b5925bade4d417eb68b037e9f5c06d976ad Mon Sep 17 00:00:00 2001 From: Rob Date: Tue, 14 Jan 2025 08:07:20 -0500 Subject: [PATCH 0421/2987] Adding support for new Lutron RGB tape light (#130731) --- .../components/lutron_caseta/const.py | 1 + .../components/lutron_caseta/light.py | 27 ++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lutron_caseta/const.py b/homeassistant/components/lutron_caseta/const.py index 7493878bece..809b9e8d007 100644 --- a/homeassistant/components/lutron_caseta/const.py +++ b/homeassistant/components/lutron_caseta/const.py @@ -16,6 +16,7 @@ BRIDGE_DEVICE_ID = "1" DEVICE_TYPE_WHITE_TUNE = "WhiteTune" DEVICE_TYPE_SPECTRUM_TUNE = "SpectrumTune" +DEVICE_TYPE_COLOR_TUNE = "ColorTune" MANUFACTURER = "Lutron Electronics Co., Inc" diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py index 146ed826c14..722c9a15d91 100644 --- a/homeassistant/components/lutron_caseta/light.py +++ b/homeassistant/components/lutron_caseta/light.py @@ -24,7 +24,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEVICE_TYPE_SPECTRUM_TUNE, DEVICE_TYPE_WHITE_TUNE +from .const import ( + DEVICE_TYPE_COLOR_TUNE, + DEVICE_TYPE_SPECTRUM_TUNE, + DEVICE_TYPE_WHITE_TUNE, +) from .entity import LutronCasetaUpdatableEntity from .models import LutronCasetaData @@ -35,9 +39,18 @@ SUPPORTED_COLOR_MODE_DICT = { ColorMode.WHITE, }, DEVICE_TYPE_WHITE_TUNE: {ColorMode.COLOR_TEMP}, + DEVICE_TYPE_COLOR_TUNE: { + ColorMode.HS, + ColorMode.COLOR_TEMP, + ColorMode.WHITE, + }, } -WARM_DEVICE_TYPES = {DEVICE_TYPE_WHITE_TUNE, DEVICE_TYPE_SPECTRUM_TUNE} +WARM_DEVICE_TYPES = { + DEVICE_TYPE_WHITE_TUNE, + DEVICE_TYPE_SPECTRUM_TUNE, + DEVICE_TYPE_COLOR_TUNE, +} def to_lutron_level(level): @@ -90,8 +103,14 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, LightEntity): ) self.supports_warm_cool = light_type in WARM_DEVICE_TYPES - self.supports_warm_dim = light_type == DEVICE_TYPE_SPECTRUM_TUNE - self.supports_spectrum_tune = light_type == DEVICE_TYPE_SPECTRUM_TUNE + self.supports_warm_dim = light_type in ( + DEVICE_TYPE_SPECTRUM_TUNE, + DEVICE_TYPE_COLOR_TUNE, + ) + self.supports_spectrum_tune = light_type in ( + DEVICE_TYPE_SPECTRUM_TUNE, + DEVICE_TYPE_COLOR_TUNE, + ) def _get_min_color_temp_kelvin(self, light: dict[str, Any]) -> int: """Return minimum supported color temperature. From 38d008bb66d061c1dce2a799ac35e186b13f2083 Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Tue, 14 Jan 2025 07:33:48 -0600 Subject: [PATCH 0422/2987] Add vesync number platform (#135564) --- homeassistant/components/vesync/__init__.py | 1 + homeassistant/components/vesync/const.py | 1 + homeassistant/components/vesync/number.py | 114 ++++++++++++++++++ homeassistant/components/vesync/strings.json | 5 + tests/components/vesync/common.py | 4 + tests/components/vesync/conftest.py | 2 +- .../vesync/fixtures/humidifier-200s.json | 1 + tests/components/vesync/test_humidifier.py | 42 +++---- tests/components/vesync/test_init.py | 2 + tests/components/vesync/test_number.py | 66 ++++++++++ tests/components/vesync/test_sensor.py | 10 +- 11 files changed, 222 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/vesync/number.py create mode 100644 tests/components/vesync/test_number.py diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index db093d6802d..240a793f518 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -24,6 +24,7 @@ PLATFORMS = [ Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index e3a72a51658..841185e4308 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -22,6 +22,7 @@ exceeds the quota of 7700. VS_DEVICES = "devices" VS_COORDINATOR = "coordinator" VS_MANAGER = "manager" +VS_NUMBERS = "numbers" VS_HUMIDIFIER_MODE_AUTO = "auto" VS_HUMIDIFIER_MODE_HUMIDITY = "humidity" diff --git a/homeassistant/components/vesync/number.py b/homeassistant/components/vesync/number.py new file mode 100644 index 00000000000..3c43cce28cf --- /dev/null +++ b/homeassistant/components/vesync/number.py @@ -0,0 +1,114 @@ +"""Support for VeSync numeric entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +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 AddEntitiesCallback + +from .common import is_humidifier +from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class VeSyncNumberEntityDescription(NumberEntityDescription): + """Class to describe a Vesync number entity.""" + + exists_fn: Callable[[VeSyncBaseDevice], bool] + value_fn: Callable[[VeSyncBaseDevice], float] + set_value_fn: Callable[[VeSyncBaseDevice, float], bool] + + +NUMBER_DESCRIPTIONS: list[VeSyncNumberEntityDescription] = [ + VeSyncNumberEntityDescription( + key="mist_level", + translation_key="mist_level", + native_min_value=1, + native_max_value=9, + native_step=1, + mode=NumberMode.SLIDER, + exists_fn=is_humidifier, + set_value_fn=lambda device, value: device.set_mist_level(value), + value_fn=lambda device: device.mist_level, + ) +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up number 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: AddEntitiesCallback, + coordinator: VeSyncDataCoordinator, +): + """Add number entities.""" + + async_add_entities( + VeSyncNumberEntity(dev, description, coordinator) + for dev in devices + for description in NUMBER_DESCRIPTIONS + if description.exists_fn(dev) + ) + + +class VeSyncNumberEntity(VeSyncBaseEntity, NumberEntity): + """A class to set numeric options on Vesync device.""" + + entity_description: VeSyncNumberEntityDescription + + def __init__( + self, + device: VeSyncBaseDevice, + description: VeSyncNumberEntityDescription, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the VeSync number device.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}-{description.key}" + + @property + def native_value(self) -> float: + """Return the value reported by the number.""" + return self.entity_description.value_fn(self.device) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + if await self.hass.async_add_executor_job( + self.entity_description.set_value_fn, self.device, value + ): + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index b6e4e2fd957..a23fe7936e7 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -43,6 +43,11 @@ "name": "Current voltage" } }, + "number": { + "mist_level": { + "name": "Mist level" + } + }, "fan": { "vesync": { "state_attributes": { diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 2c2ec9a5d1d..ead3ecdc173 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -10,6 +10,10 @@ from homeassistant.util.json import JsonObjectType 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" + ALL_DEVICES = load_json_object_fixture("vesync-devices.json", DOMAIN) ALL_DEVICE_NAMES: list[str] = [ dev["deviceName"] for dev in ALL_DEVICES["result"]["list"] diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 9bc0888e8f5..8272da8dfad 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -115,7 +115,7 @@ def humidifier_fixture(): async def humidifier_config_entry( hass: HomeAssistant, requests_mock: requests_mock.Mocker, config ) -> MockConfigEntry: - """Create a mock VeSync config entry for Humidifier 200s.""" + """Create a mock VeSync config entry for `Humidifier 200s`.""" entry = MockConfigEntry( title="VeSync", domain=DOMAIN, diff --git a/tests/components/vesync/fixtures/humidifier-200s.json b/tests/components/vesync/fixtures/humidifier-200s.json index 668072db0ea..a0a98bde110 100644 --- a/tests/components/vesync/fixtures/humidifier-200s.json +++ b/tests/components/vesync/fixtures/humidifier-200s.json @@ -3,6 +3,7 @@ "result": { "result": { "humidity": 35, + "mist_level": 6, "mist_virtual_level": 6, "mode": "manual", "water_lacks": true, diff --git a/tests/components/vesync/test_humidifier.py b/tests/components/vesync/test_humidifier.py index 5251e977c75..e3ab42993db 100644 --- a/tests/components/vesync/test_humidifier.py +++ b/tests/components/vesync/test_humidifier.py @@ -1,4 +1,4 @@ -"""Tests for the humidifer module.""" +"""Tests for the humidifier platform.""" from contextlib import nullcontext from unittest.mock import patch @@ -22,6 +22,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from .common import ( + ENTITY_HUMIDIFIER, + ENTITY_HUMIDIFIER_HUMIDITY, + ENTITY_HUMIDIFIER_MIST_LEVEL, +) + from tests.common import MockConfigEntry NoException = nullcontext() @@ -32,10 +38,10 @@ async def test_humidifier_state( ) -> None: """Test the resulting setup state is as expected for the platform.""" - humidifier_id = "humidifier.humidifier_200s" expected_entities = [ - humidifier_id, - "sensor.humidifier_200s_humidity", + ENTITY_HUMIDIFIER, + ENTITY_HUMIDIFIER_HUMIDITY, + ENTITY_HUMIDIFIER_MIST_LEVEL, ] assert humidifier_config_entry.state is ConfigEntryState.LOADED @@ -43,9 +49,7 @@ async def test_humidifier_state( for entity_id in expected_entities: assert hass.states.get(entity_id).state != STATE_UNAVAILABLE - assert hass.states.get("sensor.humidifier_200s_humidity").state == "35" - - state = hass.states.get(humidifier_id) + state = hass.states.get(ENTITY_HUMIDIFIER) # ATTR_HUMIDITY represents the target_humidity which comes from configuration.auto_target_humidity node assert state.attributes.get(ATTR_HUMIDITY) == 40 @@ -57,8 +61,6 @@ async def test_set_target_humidity_invalid( ) -> None: """Test handling of invalid value in set_humidify method.""" - humidifier_entity_id = "humidifier.humidifier_200s" - # Setting value out of range results in ServiceValidationError and # VeSyncHumid200300S.set_humidity does not get called. with ( @@ -68,7 +70,7 @@ async def test_set_target_humidity_invalid( await hass.services.async_call( HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, - {ATTR_ENTITY_ID: humidifier_entity_id, ATTR_HUMIDITY: 20}, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_HUMIDITY: 20}, blocking=True, ) await hass.async_block_till_done() @@ -79,7 +81,7 @@ async def test_set_target_humidity_invalid( ("api_response", "expectation"), [(True, NoException), (False, pytest.raises(HomeAssistantError))], ) -async def test_set_target_humidity_VeSync( +async def test_set_target_humidity( hass: HomeAssistant, humidifier_config_entry: MockConfigEntry, api_response: bool, @@ -87,8 +89,6 @@ async def test_set_target_humidity_VeSync( ) -> None: """Test handling of return value from VeSyncHumid200300S.set_humidity.""" - humidifier_entity_id = "humidifier.humidifier_200s" - # If VeSyncHumid200300S.set_humidity fails (returns False), then HomeAssistantError is raised with ( expectation, @@ -100,7 +100,7 @@ async def test_set_target_humidity_VeSync( await hass.services.async_call( HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, - {ATTR_ENTITY_ID: humidifier_entity_id, ATTR_HUMIDITY: 54}, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_HUMIDITY: 54}, blocking=True, ) await hass.async_block_till_done() @@ -125,8 +125,6 @@ async def test_turn_on_off( ) -> None: """Test turn_on/off methods.""" - humidifier_entity_id = "humidifier.humidifier_200s" - # turn_on/turn_off returns False indicating failure in which case humidifier.turn_on/turn_off # raises HomeAssistantError. with ( @@ -139,7 +137,7 @@ async def test_turn_on_off( await hass.services.async_call( HUMIDIFIER_DOMAIN, SERVICE_TURN_ON if turn_on else SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: humidifier_entity_id}, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER}, blocking=True, ) @@ -153,8 +151,6 @@ async def test_set_mode_invalid( ) -> None: """Test handling of invalid value in set_mode method.""" - humidifier_entity_id = "humidifier.humidifier_200s" - with patch( "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity_mode" ) as method_mock: @@ -162,7 +158,7 @@ async def test_set_mode_invalid( await hass.services.async_call( HUMIDIFIER_DOMAIN, SERVICE_SET_MODE, - {ATTR_ENTITY_ID: humidifier_entity_id, ATTR_MODE: "something_invalid"}, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_MODE: "something_invalid"}, blocking=True, ) await hass.async_block_till_done() @@ -173,7 +169,7 @@ async def test_set_mode_invalid( ("api_response", "expectation"), [(True, NoException), (False, pytest.raises(HomeAssistantError))], ) -async def test_set_mode_VeSync( +async def test_set_mode( hass: HomeAssistant, humidifier_config_entry: MockConfigEntry, api_response: bool, @@ -181,8 +177,6 @@ async def test_set_mode_VeSync( ) -> None: """Test handling of value in set_mode method.""" - humidifier_entity_id = "humidifier.humidifier_200s" - # If VeSyncHumid200300S.set_humidity_mode fails (returns False), then HomeAssistantError is raised with ( expectation, @@ -194,7 +188,7 @@ async def test_set_mode_VeSync( await hass.services.async_call( HUMIDIFIER_DOMAIN, SERVICE_SET_MODE, - {ATTR_ENTITY_ID: humidifier_entity_id, ATTR_MODE: "auto"}, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_MODE: "auto"}, blocking=True, ) await hass.async_block_till_done() diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index 7e2603b7401..3b0df128240 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -51,6 +51,7 @@ async def test_async_setup_entry__no_devices( Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ] @@ -80,6 +81,7 @@ async def test_async_setup_entry__loads_fans( Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ] diff --git a/tests/components/vesync/test_number.py b/tests/components/vesync/test_number.py new file mode 100644 index 00000000000..a9230b76db0 --- /dev/null +++ b/tests/components/vesync/test_number.py @@ -0,0 +1,66 @@ +"""Tests for the number platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from .common import ENTITY_HUMIDIFIER_MIST_LEVEL + +from tests.common import MockConfigEntry + + +async def test_set_mist_level_bad_range( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test set_mist_level invalid value.""" + with ( + pytest.raises(ServiceValidationError), + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_mist_level", + return_value=True, + ) as method_mock, + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER_MIST_LEVEL, ATTR_VALUE: "10"}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_not_called() + + +async def test_set_mist_level( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test set_mist_level usage.""" + + with patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_mist_level", + return_value=True, + ) as method_mock: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER_MIST_LEVEL, ATTR_VALUE: "3"}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_called_once() + + +async def test_mist_level( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test the state of mist_level number entity.""" + + assert hass.states.get(ENTITY_HUMIDIFIER_MIST_LEVEL).state == "6" diff --git a/tests/components/vesync/test_sensor.py b/tests/components/vesync/test_sensor.py index bd3a8eb8591..04d759de584 100644 --- a/tests/components/vesync/test_sensor.py +++ b/tests/components/vesync/test_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .common import ALL_DEVICE_NAMES, mock_devices_response +from .common import ALL_DEVICE_NAMES, ENTITY_HUMIDIFIER_HUMIDITY, mock_devices_response from tests.common import MockConfigEntry @@ -49,3 +49,11 @@ async def test_sensor_state( # Check states for entity in entities: assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) + + +async def test_humidity( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test the state of humidity sensor entity.""" + + assert hass.states.get(ENTITY_HUMIDIFIER_HUMIDITY).state == "35" From 026df0745125bb74487be5fcc5d0e9e998c81094 Mon Sep 17 00:00:00 2001 From: adam-the-hero <132444842+adam-the-hero@users.noreply.github.com> Date: Tue, 14 Jan 2025 14:40:01 +0100 Subject: [PATCH 0423/2987] Fix Watergate Power supply mode description and MQTT/Wifi uptimes (#135085) --- homeassistant/components/watergate/sensor.py | 10 +++++++--- tests/components/watergate/snapshots/test_sensor.ambr | 4 ++-- tests/components/watergate/test_sensor.py | 4 ++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/watergate/sensor.py b/homeassistant/components/watergate/sensor.py index 638bf297415..6782a93541b 100644 --- a/homeassistant/components/watergate/sensor.py +++ b/homeassistant/components/watergate/sensor.py @@ -90,7 +90,7 @@ DESCRIPTIONS: list[WatergateSensorEntityDescription] = [ WatergateSensorEntityDescription( value_fn=lambda data: ( dt_util.as_utc( - dt_util.now() - timedelta(microseconds=data.networking.wifi_uptime) + dt_util.now() - timedelta(milliseconds=data.networking.wifi_uptime) ) if data.networking else None @@ -104,7 +104,7 @@ DESCRIPTIONS: list[WatergateSensorEntityDescription] = [ WatergateSensorEntityDescription( value_fn=lambda data: ( dt_util.as_utc( - dt_util.now() - timedelta(microseconds=data.networking.mqtt_uptime) + dt_util.now() - timedelta(milliseconds=data.networking.mqtt_uptime) ) if data.networking else None @@ -158,7 +158,11 @@ DESCRIPTIONS: list[WatergateSensorEntityDescription] = [ ), WatergateSensorEntityDescription( value_fn=lambda data: ( - PowerSupplyMode(data.state.power_supply.replace("+", "_")) + PowerSupplyMode( + data.state.power_supply.replace("+", "_").replace( + "external_battery", "battery_external" + ) + ) if data.state else None ), diff --git a/tests/components/watergate/snapshots/test_sensor.ambr b/tests/components/watergate/snapshots/test_sensor.ambr index 479a879a583..a58c7c0eab8 100644 --- a/tests/components/watergate/snapshots/test_sensor.ambr +++ b/tests/components/watergate/snapshots/test_sensor.ambr @@ -43,7 +43,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2021-01-09T11:59:59+00:00', + 'state': '2021-01-09T11:59:58+00:00', }) # --- # name: test_sensor[sensor.sonic_power_supply_mode-entry] @@ -501,6 +501,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2021-01-09T11:59:59+00:00', + 'state': '2021-01-09T11:59:57+00:00', }) # --- diff --git a/tests/components/watergate/test_sensor.py b/tests/components/watergate/test_sensor.py index 58632c7548b..78e375857ed 100644 --- a/tests/components/watergate/test_sensor.py +++ b/tests/components/watergate/test_sensor.py @@ -140,11 +140,11 @@ async def test_power_supply_webhook( power_supply_change_data = { "type": "power-supply-changed", - "data": {"supply": "external"}, + "data": {"supply": "external_battery"}, } client = await hass_client_no_auth() await client.post(f"/api/webhook/{MOCK_WEBHOOK_ID}", json=power_supply_change_data) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "external" + assert hass.states.get(entity_id).state == "battery_external" From 934f59449d7604ff838debc242b454795a587942 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 14 Jan 2025 15:17:28 +0100 Subject: [PATCH 0424/2987] Make mqtt integration exports explicit (#135595) --- homeassistant/components/mqtt/__init__.py | 89 +++++++++++++++++++++-- 1 file changed, 83 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 94c417bfd6d..b494b636916 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -38,7 +38,7 @@ from homeassistant.util.async_ import create_eager_task # Loading the config flow file will register the flow from . import debug_info, discovery -from .client import ( # noqa: F401 +from .client import ( MQTT, async_publish, async_subscribe, @@ -46,9 +46,9 @@ from .client import ( # noqa: F401 publish, subscribe, ) -from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA # noqa: F401 +from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA from .config_integration import CONFIG_SCHEMA_BASE -from .const import ( # noqa: F401 +from .const import ( ATTR_PAYLOAD, ATTR_QOS, ATTR_RETAIN, @@ -82,7 +82,7 @@ from .const import ( # noqa: F401 MQTT_CONNECTION_STATE, TEMPLATE_ERRORS, ) -from .models import ( # noqa: F401 +from .models import ( DATA_MQTT, DATA_MQTT_AVAILABLE, MqttCommandTemplate, @@ -93,13 +93,13 @@ from .models import ( # noqa: F401 ReceiveMessage, convert_outgoing_mqtt_payload, ) -from .subscription import ( # noqa: F401 +from .subscription import ( EntitySubscription, async_prepare_subscribe_topics, async_subscribe_topics, async_unsubscribe_topics, ) -from .util import ( # noqa: F401 +from .util import ( async_create_certificate_temp_files, async_forward_entry_setup_and_setup_discovery, async_wait_for_mqtt_client, @@ -110,6 +110,83 @@ from .util import ( # noqa: F401 valid_subscribe_topic, ) +__all__ = [ + "ATTR_PAYLOAD", + "ATTR_QOS", + "ATTR_RETAIN", + "ATTR_TOPIC", + "CONFIG_ENTRY_MINOR_VERSION", + "CONFIG_ENTRY_VERSION", + "CONF_BIRTH_MESSAGE", + "CONF_BROKER", + "CONF_CERTIFICATE", + "CONF_CLIENT_CERT", + "CONF_CLIENT_KEY", + "CONF_COMMAND_TOPIC", + "CONF_DISCOVERY_PREFIX", + "CONF_KEEPALIVE", + "CONF_QOS", + "CONF_STATE_TOPIC", + "CONF_TLS_INSECURE", + "CONF_TOPIC", + "CONF_TRANSPORT", + "CONF_WILL_MESSAGE", + "CONF_WS_HEADERS", + "CONF_WS_PATH", + "DATA_MQTT", + "DATA_MQTT_AVAILABLE", + "DEFAULT_DISCOVERY", + "DEFAULT_ENCODING", + "DEFAULT_PREFIX", + "DEFAULT_QOS", + "DEFAULT_RETAIN", + "DOMAIN", + "ENTITY_PLATFORMS", + "ENTRY_OPTION_FIELDS", + "EntitySubscription", + "MQTT", + "MQTT_BASE_SCHEMA", + "MQTT_CONNECTION_STATE", + "MQTT_RO_SCHEMA", + "MQTT_RW_SCHEMA", + "MqttCommandTemplate", + "MqttData", + "MqttValueTemplate", + "PayloadSentinel", + "PublishPayloadType", + "ReceiveMessage", + "SERVICE_RELOAD", + "SetupPhases", + "TEMPLATE_ERRORS", + "async_check_config_schema", + "async_create_certificate_temp_files", + "async_forward_entry_setup_and_setup_discovery", + "async_migrate_entry", + "async_prepare_subscribe_topics", + "async_publish", + "async_remove_config_entry_device", + "async_setup", + "async_setup_entry", + "async_subscribe", + "async_subscribe_connection_status", + "async_subscribe_topics", + "async_unload_entry", + "async_unsubscribe_topics", + "async_wait_for_mqtt_client", + "convert_outgoing_mqtt_payload", + "create_eager_task", + "is_connected", + "mqtt_config_entry_enabled", + "platforms_from_config", + "publish", + "subscribe", + "valid_publish_topic", + "valid_qos_schema", + "valid_subscribe_topic", + "websocket_mqtt_info", + "websocket_subscribe", +] + _LOGGER = logging.getLogger(__name__) SERVICE_PUBLISH = "publish" From 526277da0f4da6afcbd636668b220b3f4e3a45ad Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 14 Jan 2025 15:23:22 +0100 Subject: [PATCH 0425/2987] Add entity pictures to Habitica integration (#134179) --- homeassistant/components/habitica/sensor.py | 28 +++++++++++++++++-- .../habitica/snapshots/test_sensor.ambr | 9 +++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index d6fc85b2045..f969b1344d9 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -15,6 +15,7 @@ from habiticalib import ( TaskType, UserData, deserialize_task, + ha, ) from homeassistant.components.automation import automations_with_entity @@ -43,6 +44,13 @@ from .util import get_attribute_points, get_attributes_total, inventory_list _LOGGER = logging.getLogger(__name__) +SVG_CLASS = { + HabiticaClass.WARRIOR: ha.WARRIOR, + HabiticaClass.ROGUE: ha.ROGUE, + HabiticaClass.MAGE: ha.WIZARD, + HabiticaClass.HEALER: ha.HEALER, +} + @dataclass(kw_only=True, frozen=True) class HabiticaSensorEntityDescription(SensorEntityDescription): @@ -101,6 +109,7 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( translation_key=HabiticaSensorEntity.HEALTH, suggested_display_precision=0, value_fn=lambda user, _: user.stats.hp, + entity_picture=ha.HP, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.HEALTH_MAX, @@ -113,21 +122,25 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( translation_key=HabiticaSensorEntity.MANA, suggested_display_precision=0, value_fn=lambda user, _: user.stats.mp, + entity_picture=ha.MP, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.MANA_MAX, translation_key=HabiticaSensorEntity.MANA_MAX, value_fn=lambda user, _: user.stats.maxMP, + entity_picture=ha.MP, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.EXPERIENCE, translation_key=HabiticaSensorEntity.EXPERIENCE, value_fn=lambda user, _: user.stats.exp, + entity_picture=ha.XP, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.EXPERIENCE_MAX, translation_key=HabiticaSensorEntity.EXPERIENCE_MAX, value_fn=lambda user, _: user.stats.toNextLevel, + entity_picture=ha.XP, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.LEVEL, @@ -139,6 +152,7 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( translation_key=HabiticaSensorEntity.GOLD, suggested_display_precision=2, value_fn=lambda user, _: user.stats.gp, + entity_picture=ha.GP, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.CLASS, @@ -216,7 +230,7 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( value_fn=( lambda user, _: sum(n for k, n in user.items.food.items() if k != "Saddle") ), - entity_picture="Pet_Food_Strawberry.png", + entity_picture=ha.FOOD, attributes_fn=lambda user, content: inventory_list(user, content, "food"), ), HabiticaSensorEntityDescription( @@ -372,8 +386,18 @@ class HabiticaSensor(HabiticaBase, SensorEntity): @property def entity_picture(self) -> str | None: """Return the entity picture to use in the frontend, if any.""" + if self.entity_description.key is HabiticaSensorEntity.CLASS and ( + _class := self.coordinator.data.user.stats.Class + ): + return SVG_CLASS[_class] + if entity_picture := self.entity_description.entity_picture: - return f"{ASSETS_URL}{entity_picture}" + return ( + entity_picture + if entity_picture.startswith("data:image") + else f"{ASSETS_URL}{entity_picture}" + ) + return None diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index b217a1418b9..9050db1946d 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -43,6 +43,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItMTIgLTEyIDcyIDcyIj4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPHBhdGggZmlsbD0iIzFGNkVBMiIgZD0iTTI0LjQ2MiAxLjE1MWMtMy42OTUgMy43MTQtNy4zMjEgMTQuNjItOS4yMzYgMjAuOTEzLS44NC0xLjA2Mi0xLjc5OS0yLjA3Ni0zLjE1OC0zLjMyNi00LjgwNiAzLjUxNC03LjM1NyA3LjI1LTEwLjY0NiAxMS44MTYgOC45MTYgNy45MjYgMTEuMjMzIDkuMjU2IDIyLjk5NSAxNi4wMDVsLjA0NS0uMDI1aC4wMDFsLjA0NS4wMjVjMTEuNzYtNi43NDkgMTQuMDc5LTguMDggMjIuOTk0LTE2LjAwNS0zLjI4Ny00LjU2Ni01Ljg0MS04LjMwMi0xMC42NDUtMTEuODE2LTEuMzYxIDEuMjUtMi4zMTcgMi4yNjQtMy4xNTggMy4zMjYtMS45MTUtNi4yOTQtNS41NDMtMTcuMi05LjIzNi0yMC45MTNoLS4wMDF6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iIzI3OEFCRiIgZD0iTTI0LjQ2NCAzMi4xMDhsMy41NjEtMS4yNjVjNi4wNzUtLjYwNSA4LjQyNS0yLjI4MSA5LjQyNi0zLjQ5OGw2LjA1MyAyLjcwNy4wMDIuMDEzYS40NS40NSAwIDAgMS0uMTA1LjI4NiA0Ljk3IDQuOTcgMCAwIDEtLjMyLjMzYy0zLjQwNSAzLjMxNS0xOC4yMzcgMTEuOTgxLTE4LjYxNyAxMi4yMDJWMzIuMTA4eiIvPgogICAgICAgIDxwYXRoIGZpbGw9IiM1M0I0RTUiIGQ9Ik0zNi40MDYgMjMuMDQxYy4yMy0uMTg4LjI5Mi0uMzQuNjc0LS4zMzYgMS4xODMuMDY3IDUuMTY5IDUuMTc1IDYuMDY0IDYuNDYxLjE5NC4yNzguMzY2LjYxLjM2Ljg4NWwtNi4wNTMtMi43MDdjLjIzNi0uMzY3LjE5LS45Mi0uMzU1LTEuMjUyLS44MTgtLjUwMS0yLjUyOS45NDktMi45NTkuMjg0LS41OTktLjkyNyAyLjA0LTMuMTQ3IDIuMjctMy4zMzUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjMkFBMENGIiBkPSJNMjMuNTkxIDcuNDg3Yy01LjM5IDE1LjQxOS04LjIyNyAyMy44MTctOC4yMjcgMjMuODE3bDkuMDk3IDQuNzZWNi44MzljLS4zNiAwLS43MTguMjE2LS44Ny42NDgiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjMjc4QUJGIiBkPSJNMjUuMzMyIDcuNDg3Yy0uMTUyLS40MzItLjUxLS42NDgtLjg3LS42NDh2MjkuMjI1bDkuMDk3LTQuNzZzLTIuODM3LTguMzk4LTguMjI3LTIzLjgxNyIvPgogICAgICAgIDxwYXRoIGZpbGw9IiM0REIyRDYiIGQ9Ik0yNC40NjIgMzIuMTA4TDIwLjkgMzAuODQzYy02LjA3NS0uNjA1LTguMzQyLTIuMzUyLTkuNDI1LTMuNDk4TDUuNDIgMzAuMDUybC0uMDAyLjAxM2EuNDUuNDUgMCAwIDAgLjEwNi4yODZjLjA5LjEwNC4xOTcuMjE1LjMxOC4zMyAzLjQwNiAzLjMxNSAxOC4yMzggMTEuOTgxIDE4LjYxOSAxMi4yMDJWMzIuMTA4eiIvPgogICAgICAgIDxwYXRoIGZpbGw9IiM2QkM0RTkiIGQ9Ik0xMi41MTkgMjMuMDQxYy0uMjMtLjE4OC0uMjkyLS4zNC0uNjc0LS4zMzYtMS4xODMuMDY3LTUuMTY5IDUuMTc1LTYuMDYzIDYuNDYxLS4xOTQuMjc4LS4zNjcuNjEtLjM2MS44ODVsNi4wNTMtMi43MDdjLS4yMzYtLjM2Ny0uMTktLjkyLjM1Ni0xLjI1Mi44MTctLjUwMSAyLjUyOC45NDkgMi45NTguMjg0LjYtLjkyNy0yLjAzOS0zLjE0Ny0yLjI3LTMuMzM1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0E5REJGNiIgZD0iTTI0LjQ2MyAzNC45OTh2LTUuMTAxbC03LjIxLTQuMTMtMS40OCA0LjA0OXoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjODRDRkYyIiBkPSJNMjQuNDYzIDM0Ljk5OHYtNS4xMDFsNy4yMDctNC4xMyAxLjQ4MyA0LjA0OXoiLz4KICAgIDwvZz4KPC9zdmc+', 'friendly_name': 'test-user Class', 'options': list([ 'warrior', @@ -247,6 +248,7 @@ # name: test_sensors[sensor.test_user_experience-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkE2MjMiIGQ9Ik0xNiAxNmw4LTQtOC00LTQtOC00IDgtOCA0IDggNCA0IDh6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1LTIuNUwxMiAxMnpNMTIgMTkuNWwtMi41LTVMMTIgMTJ6TTE5LjUgMTJsLTUgMi41TDEyIDEyek0xMiA0LjVsMi41IDVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTkuNSAxMmwtNS0yLjVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTIgMTkuNWwyLjUtNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1IDIuNUwxMiAxMnpNMTIgNC41bC0yLjUgNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEwLjggMTMuMkw4LjUgMTJsMi4zLTEuMkwxMiA4LjVsMS4yIDIuMyAyLjMgMS4yLTIuMyAxLjItMS4yIDIuM3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', 'friendly_name': 'test-user Experience', 'unit_of_measurement': 'XP', }), @@ -348,6 +350,7 @@ # name: test_sensors[sensor.test_user_gold-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxjaXJjbGUgY3g9IjEyIiBjeT0iMTIiIHI9IjEyIiBmaWxsPSIjRkZBNjIzIi8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTYuMyAxNy43Yy0zLjEtMy4xLTMuMS04LjIgMC0xMS4zIDMuMS0zLjEgOC4yLTMuMSAxMS4zIDAiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTE3LjcgNi4zYzMuMSAzLjEgMy4xIDguMiAwIDExLjMtMy4xIDMuMS04LjIgMy4xLTExLjMgMCIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0JGN0QxQSIgZD0iTTEyIDJDNi41IDIgMiA2LjUgMiAxMnM0LjUgMTAgMTAgMTAgMTAtNC41IDEwLTEwUzE3LjUgMiAxMiAyem0wIDE4Yy00LjQgMC04LTMuNi04LThzMy42LTggOC04IDggMy42IDggOC0zLjYgOC04IDh6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCRjdEMUEiIGQ9Ik0xMyA5djJoLTJWOUg5djZoMnYtMmgydjJoMlY5eiIgb3BhY2l0eT0iLjc1Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', 'friendly_name': 'test-user Gold', 'unit_of_measurement': 'GP', }), @@ -655,6 +658,7 @@ # name: test_sensors[sensor.test_user_health-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGNzRFNTIiIGQ9Ik0yIDQuNUw2LjE2NyAyIDEyIDUuMTY3IDE3LjgzMyAyIDIyIDQuNVYxMmwtNC4xNjcgNS44MzNMMTIgMjJsLTUuODMzLTQuMTY3TDIgMTJ6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGNjE2NSIgZD0iTTcuMzMzIDE2LjY2N0wzLjY2NyAxMS41VjUuNDE3bDIuNS0xLjVMMTIgNy4wODNsNS44MzMtMy4xNjYgMi41IDEuNVYxMS41bC0zLjY2NiA1LjE2N0wxMiAxOS45MTd6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w0LjY2NyAyLjU4NEwxMiAxOS45MTd6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsLTQuNjY3IDIuNTg0TDEyIDE5LjkxN3oiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjMzMyAxNi42NjdMMy42NjcgMTEuNSAxMiAxNC4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQjUyNDI4IiBkPSJNMTYuNjY3IDE2LjY2N2wzLjY2Ni01LjE2N0wxMiAxNC4wODN6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsNS44MzMtMTAuMTY2IDIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNMNi4xNjcgMy45MTdsLTIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M0w2LjE2NyAzLjkxNyAxMiA3LjA4M3oiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w1LjgzMy0xMC4xNjZMMTIgNy4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjRkZGIiBkPSJNOS4xNjcgMTQuODMzbC0zLTQuMTY2VjYuODMzaC4wODNMMTIgOS45MTdsNS43NS0zLjA4NGguMDgzdjMuODM0bC0zIDQuMTY2TDEyIDE2LjkxN3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', 'friendly_name': 'test-user Health', 'unit_of_measurement': 'HP', }), @@ -806,6 +810,7 @@ # name: test_sensors[sensor.test_user_mana-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiMyOTk1Q0QiIGQ9Ik0yMiAxNWwtMTAgOS0xMC05TDEyIDB6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iIzUwQjVFOSIgZD0iTTQuNiAxNC43bDcuNC0zdjkuNnoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjMUY3MDlBIiBkPSJNMTIgMTEuN2w3LjQgMy03LjQgNi42eiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDExLjdWMy42bDcuNCAxMS4xeiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNiAxNC43TDEyIDMuNnY4LjF6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjIgMTQuM0wxMiA3LjJsNC44IDcuMS00LjggNC4zeiIgb3BhY2l0eT0iLjUiLz4KICAgIDwvZz4KPC9zdmc+', 'friendly_name': 'test-user Mana', 'unit_of_measurement': 'MP', }), @@ -900,6 +905,7 @@ # name: test_sensors[sensor.test_user_max_mana-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiMyOTk1Q0QiIGQ9Ik0yMiAxNWwtMTAgOS0xMC05TDEyIDB6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iIzUwQjVFOSIgZD0iTTQuNiAxNC43bDcuNC0zdjkuNnoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjMUY3MDlBIiBkPSJNMTIgMTEuN2w3LjQgMy03LjQgNi42eiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDExLjdWMy42bDcuNCAxMS4xeiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNiAxNC43TDEyIDMuNnY4LjF6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjIgMTQuM0wxMiA3LjJsNC44IDcuMS00LjggNC4zeiIgb3BhY2l0eT0iLjUiLz4KICAgIDwvZz4KPC9zdmc+', 'friendly_name': 'test-user Max. mana', 'unit_of_measurement': 'MP', }), @@ -998,6 +1004,7 @@ # name: test_sensors[sensor.test_user_next_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkE2MjMiIGQ9Ik0xNiAxNmw4LTQtOC00LTQtOC00IDgtOCA0IDggNCA0IDh6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1LTIuNUwxMiAxMnpNMTIgMTkuNWwtMi41LTVMMTIgMTJ6TTE5LjUgMTJsLTUgMi41TDEyIDEyek0xMiA0LjVsMi41IDVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTkuNSAxMmwtNS0yLjVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTIgMTkuNWwyLjUtNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1IDIuNUwxMiAxMnpNMTIgNC41bC0yLjUgNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEwLjggMTMuMkw4LjUgMTJsMi4zLTEuMkwxMiA4LjVsMS4yIDIuMyAyLjMgMS4yLTIuMyAxLjItMS4yIDIuM3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', 'friendly_name': 'test-user Next level', 'unit_of_measurement': 'XP', }), @@ -1103,7 +1110,7 @@ 'Fleisch': 0, 'Kartoffel': 2, 'Milch': 1, - 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_Food_Strawberry.png', + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNTgiIHZpZXdCb3g9IjAgMCA2MCA1OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CjxyZWN0IHg9IjM4IiB5PSI2IiB3aWR0aD0iMjIiIGhlaWdodD0iMjYiIGZpbGw9InVybCgjcGF0dGVybjBfMTMyN18yNTYpIj48L3JlY3Q+CjxyZWN0IHdpZHRoPSIyNiIgaGVpZ2h0PSIyNiIgZmlsbD0idXJsKCNwYXR0ZXJuMV8xMzI3XzI1NikiPjwvcmVjdD4KPHJlY3QgeD0iOCIgeT0iMzYiIHdpZHRoPSIzMCIgaGVpZ2h0PSIyMiIgZmlsbD0idXJsKCNwYXR0ZXJuMl8xMzI3XzI1NikiPjwvcmVjdD4KPGRlZnM+CjxwYXR0ZXJuIGlkPSJwYXR0ZXJuMF8xMzI3XzI1NiIgcGF0dGVybkNvbnRlbnRVbml0cz0ib2JqZWN0Qm91bmRpbmdCb3giIHdpZHRoPSIxIiBoZWlnaHQ9IjEiPgo8dXNlIHhsaW5rOmhyZWY9IiNpbWFnZTBfMTMyN18yNTYiIHRyYW5zZm9ybT0ic2NhbGUoMC4wNDU0NTQ1IDAuMDM4NDYxNSkiPjwvdXNlPgo8L3BhdHRlcm4+CjxwYXR0ZXJuIGlkPSJwYXR0ZXJuMV8xMzI3XzI1NiIgcGF0dGVybkNvbnRlbnRVbml0cz0ib2JqZWN0Qm91bmRpbmdCb3giIHdpZHRoPSIxIiBoZWlnaHQ9IjEiPgo8dXNlIHhsaW5rOmhyZWY9IiNpbWFnZTFfMTMyN18yNTYiIHRyYW5zZm9ybT0ic2NhbGUoMC4wMzg0NjE1KSI+PC91c2U+CjwvcGF0dGVybj4KPHBhdHRlcm4gaWQ9InBhdHRlcm4yXzEzMjdfMjU2IiBwYXR0ZXJuQ29udGVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgd2lkdGg9IjEiIGhlaWdodD0iMSI+Cjx1c2UgeGxpbms6aHJlZj0iI2ltYWdlMl8xMzI3XzI1NiIgdHJhbnNmb3JtPSJzY2FsZSgwLjAzMzMzMzMgMC4wNDU0NTQ1KSI+PC91c2U+CjwvcGF0dGVybj4KPGltYWdlIGlkPSJpbWFnZTBfMTMyN18yNTYiIHdpZHRoPSIyMiIgaGVpZ2h0PSIyNiIgeGxpbms6aHJlZj0iZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFCWUFBQUFhQ0FZQUFBQ3pkcXhBQUFBQUFYTlNSMElBcnM0YzZRQUFBUWxKUkVGVVNBMjkwazFxd2xBVXhmRnNwK3NvWFlkem9lQWFXcUZiY1N4MTRMVFFTYnVBanJ1TFZnZFBudkFyY3NJalJsT0Z5OG45K3Q5alNOY04vQjd1N3N0cERJeWYzejZGMXVmek4yTXlRZnZmOTFMalo3czZSdlpqdlozbTR0Vmd3TjM2cTlUZ2tLclQ3K2VQVXNOZTA2b0JpNEJVblE2Q0UyaUJBcVhxVTV3LzV3b1dEVkwxVkgySzB3TWJBSGliZlpZYTZpMDFkenN3Nnk3bTUrVWZjTXhoUzNGd080WEp3SUNjNWVmRmFlcWc0NXVCMDVtODVWQ2R3ZDY3OVNxQVVnRmEyZ1FEV1RRb1Q4Mit2T2Q0Y3JBTEx0S3N6eDhYcFVhcmI3Nm5GcWdCK2NWZ29GVGdwK1ZMcVNIUHVkRTUwTCtCMTYrYlVzT2gwUTV6QVdoeXNFTU95Q2ZUc2VBRFNTQzNudTVpRmFjQUFBQUFTVVZPUks1Q1lJST0iPjwvaW1hZ2U+CjxpbWFnZSBpZD0iaW1hZ2UxXzEzMjdfMjU2IiB3aWR0aD0iMjYiIGhlaWdodD0iMjYiIHhsaW5rOmhyZWY9ImRhdGE6aW1hZ2UvcG5nO2Jhc2U2NCxpVkJPUncwS0dnb0FBQUFOU1VoRVVnQUFBQm9BQUFBYUNBWUFBQUNwU2t6T0FBQUFBWE5TUjBJQXJzNGM2UUFBQVNsSlJFRlVTQTNGMURGT3cwQVFCVkRUUmhTcDZSSFVhVkxRY0FXT0VDa05SNkhpUHRSY0psMUtxQlp0cEVmeGpiV3J4QTZXUmw4em52bC8vcTdsWVlqbmJuTmIvZ3B0K2U3NWZWMXFxT3Ryb29GRWcxay9XeWdKNzNjM3BRWUIrY1VDL3k2VVRqaDYyMjVMamQwd25NS2kzWmhITlpzUVlvaFl2djk0TERYVU9UbSt2cFFhWHc5UHAxQ2ZkSVFRSXBSZkxJUVFPbnZFTU92Nk9XazZNd0NUY0hZaFJ3UlR3QUtKN3VUNzgxQnFjS2orZTFlY0VJQ3pDeUdtdkZtdFNnMmJFN1JwYnE3UCs4bTd1cG9RSjVBalo1d2IyeHltZzNROCtjZTR1cENOYzhPV0EzT3c2VWpqNGtMdWlDQnNDYWZqcHFQRmhhYSt2bDVoanZYN3VQQ09VSU1CNkFnaDRzejE0eGtKWkVFamRPYUlXbWd1ZVVlNVJyaVlVQ29UN01XYzc4NTdCZlFoL2dGQ2FOUWFobDJ1M2dBQUFBQkpSVTVFcmtKZ2dnPT0iPjwvaW1hZ2U+CjxpbWFnZSBpZD0iaW1hZ2UyXzEzMjdfMjU2IiB3aWR0aD0iMzAiIGhlaWdodD0iMjIiIHhsaW5rOmhyZWY9ImRhdGE6aW1hZ2UvcG5nO2Jhc2U2NCxpVkJPUncwS0dnb0FBQUFOU1VoRVVnQUFBQjRBQUFBV0NBWUFBQURYWXl6UEFBQUFBWE5TUjBJQXJzNGM2UUFBQVJSSlJFRlVTQTNGMDhGdEFqRVVCRkNhZ0FZU0tkM1FBc1VnY1VrRHFTTzMxSkJqTHJuU0JsSms1TU9EMVdndDdSbzVJSDJOL2NlZStiUHNiamFEZjY4dmIyVmFnKzN1OGxQVHVyNHpLMWNwWko4eStoK0g3MUxMUHM4dDNoTklUQUY4dHpFQitQbHpLZFA2UGYrVld2akUwLzZyMUZvOVFBcE5UZXQ2dURIRDQvdTV6Qlhlb0ptME96SGhPZFBhd3o5c1RJQmdHbnJFeWV2dnR0dFNpMDVpdm95M3ZZTXBiQUFHeWVzL2JFeklmOFlJdGdZeCtDM0owb1dMdzR3WkpIcFVra2tzb1g0aW5sNHpxQU9KLzJic080TVNHa2d5aWZCUTMvbG1Vb1NERENGQi9IRGpOR1FNRFFDOWZIZ29XQk1kYkNYRlE0YXcyOWhFaEExZ2o0ZjZpZmpWU09ocHhnWllQWG52Qllhd1YyZnB2U3R4R0Z5dUlYV3NXUUFBQUFCSlJVNUVya0pnZ2c9PSI+PC9pbWFnZT4KPC9kZWZzPgo8L3N2Zz4=', 'friendly_name': 'test-user Pet food', 'unit_of_measurement': 'foods', }), From fa961684882aabeddeb9aaa3b799b97864588926 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Jan 2025 15:44:18 +0100 Subject: [PATCH 0426/2987] Rename onewire entity classes (#135601) --- homeassistant/components/onewire/binary_sensor.py | 8 ++++---- homeassistant/components/onewire/sensor.py | 8 ++++---- homeassistant/components/onewire/switch.py | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 7a8f81eec0e..60a1d165b15 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -122,9 +122,9 @@ async def async_setup_entry( def get_entities( onewire_hub: OneWireHub, devices: list[OWDeviceDescription] -) -> list[OneWireBinarySensor]: +) -> list[OneWireBinarySensorEntity]: """Get a list of entities.""" - entities: list[OneWireBinarySensor] = [] + entities: list[OneWireBinarySensorEntity] = [] for device in devices: family = device.family device_id = device.id @@ -140,7 +140,7 @@ def get_entities( for description in get_sensor_types(device_sub_type)[family]: device_file = os.path.join(os.path.split(device.path)[0], description.key) entities.append( - OneWireBinarySensor( + OneWireBinarySensorEntity( description=description, device_id=device_id, device_file=device_file, @@ -152,7 +152,7 @@ def get_entities( return entities -class OneWireBinarySensor(OneWireEntity, BinarySensorEntity): +class OneWireBinarySensorEntity(OneWireEntity, BinarySensorEntity): """Implementation of a 1-Wire binary sensor.""" entity_description: OneWireBinarySensorEntityDescription diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index ae6a3642c58..1c4047abf0a 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -388,9 +388,9 @@ def get_entities( onewire_hub: OneWireHub, devices: list[OWDeviceDescription], options: MappingProxyType[str, Any], -) -> list[OneWireSensor]: +) -> list[OneWireSensorEntity]: """Get a list of entities.""" - entities: list[OneWireSensor] = [] + entities: list[OneWireSensorEntity] = [] for device in devices: family = device.family device_type = device.type @@ -445,7 +445,7 @@ def get_entities( ) continue entities.append( - OneWireSensor( + OneWireSensorEntity( description=description, device_id=device_id, device_file=device_file, @@ -456,7 +456,7 @@ def get_entities( return entities -class OneWireSensor(OneWireEntity, SensorEntity): +class OneWireSensorEntity(OneWireEntity, SensorEntity): """Implementation of a 1-Wire sensor.""" entity_description: OneWireSensorEntityDescription diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 0df2ba0dd57..7215b1ec020 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -182,9 +182,9 @@ async def async_setup_entry( def get_entities( onewire_hub: OneWireHub, devices: list[OWDeviceDescription] -) -> list[OneWireSwitch]: +) -> list[OneWireSwitchEntity]: """Get a list of entities.""" - entities: list[OneWireSwitch] = [] + entities: list[OneWireSwitchEntity] = [] for device in devices: family = device.family @@ -204,7 +204,7 @@ def get_entities( for description in get_sensor_types(device_sub_type)[family]: device_file = os.path.join(os.path.split(device.path)[0], description.key) entities.append( - OneWireSwitch( + OneWireSwitchEntity( description=description, device_id=device_id, device_file=device_file, @@ -216,7 +216,7 @@ def get_entities( return entities -class OneWireSwitch(OneWireEntity, SwitchEntity): +class OneWireSwitchEntity(OneWireEntity, SwitchEntity): """Implementation of a 1-Wire switch.""" entity_description: OneWireSwitchEntityDescription From 60bdc13c9448e60ea257e7281ed4e78ac0f96d4b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 14 Jan 2025 16:23:15 +0100 Subject: [PATCH 0427/2987] Drop Python 3.12 support (#135589) --- .github/workflows/ci.yaml | 4 ++-- .github/workflows/translations.yml | 2 +- .github/workflows/wheels.yml | 2 +- homeassistant/auth/mfa_modules/__init__.py | 3 +-- homeassistant/auth/providers/__init__.py | 3 +-- .../components/actiontec/device_tracker.py | 2 +- .../components/assist_pipeline/websocket_api.py | 5 ++--- .../bluetooth/passive_update_coordinator.py | 4 +--- homeassistant/components/broadlink/device.py | 3 +-- homeassistant/components/broadlink/updater.py | 3 +-- homeassistant/components/denon/media_player.py | 2 +- homeassistant/components/hddtemp/sensor.py | 2 +- homeassistant/components/http/static.py | 15 ++++++--------- homeassistant/components/pioneer/media_player.py | 2 +- homeassistant/components/ring/entity.py | 3 +-- homeassistant/components/telnet/switch.py | 2 +- .../components/thomson/device_tracker.py | 2 +- homeassistant/components/weather/__init__.py | 13 +++++++++++-- homeassistant/config_entries.py | 3 +-- homeassistant/const.py | 4 ++-- homeassistant/core.py | 2 +- homeassistant/data_entry_flow.py | 3 +-- homeassistant/helpers/collection.py | 3 +-- homeassistant/helpers/data_entry_flow.py | 3 +-- homeassistant/helpers/entity_component.py | 4 +--- homeassistant/helpers/update_coordinator.py | 3 +-- homeassistant/util/event_type.py | 4 +--- homeassistant/util/event_type.pyi | 4 +--- pyproject.toml | 5 ++--- script/hassfest/mypy_config.py | 15 ++++++++++++--- tests/common.py | 3 +-- tests/components/homee/conftest.py | 2 +- 32 files changed, 62 insertions(+), 68 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bb75027224a..fb07d60da3b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -41,8 +41,8 @@ env: UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 9 HA_SHORT_VERSION: "2025.2" - DEFAULT_PYTHON: "3.12" - ALL_PYTHON_VERSIONS: "['3.12', '3.13']" + DEFAULT_PYTHON: "3.13" + ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version # - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022) # 10.6 is the current long-term-support diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 3fffc41e60c..fa3c2305190 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -10,7 +10,7 @@ on: - "**strings.json" env: - DEFAULT_PYTHON: "3.12" + DEFAULT_PYTHON: "3.13" jobs: upload: diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 1bab64e66e5..00f0c507414 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -17,7 +17,7 @@ on: - "script/gen_requirements_all.py" env: - DEFAULT_PYTHON: "3.12" + DEFAULT_PYTHON: "3.13" concurrency: group: ${{ github.workflow }}-${{ github.ref_name}} diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py index 8a6430d770a..d0cb190ec6e 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -4,9 +4,8 @@ from __future__ import annotations import logging import types -from typing import Any, Generic +from typing import Any, Generic, TypeVar -from typing_extensions import TypeVar import voluptuous as vol from voluptuous.humanize import humanize_error diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 02f99e7bd71..36faf0e5e9c 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -5,9 +5,8 @@ from __future__ import annotations from collections.abc import Mapping import logging import types -from typing import Any, Generic +from typing import Any, Generic, TypeVar -from typing_extensions import TypeVar import voluptuous as vol from voluptuous.humanize import humanize_error diff --git a/homeassistant/components/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py index b1b9c81c674..273ca6a772f 100644 --- a/homeassistant/components/actiontec/device_tracker.py +++ b/homeassistant/components/actiontec/device_tracker.py @@ -3,9 +3,9 @@ from __future__ import annotations import logging -import telnetlib # pylint: disable=deprecated-module from typing import Final +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.device_tracker import ( diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index c96af655589..d61580f4a14 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -1,9 +1,6 @@ """Assist pipeline Websocket API.""" import asyncio - -# Suppressing disable=deprecated-module is needed for Python 3.11 -import audioop # pylint: disable=deprecated-module import base64 from collections.abc import AsyncGenerator, Callable import contextlib @@ -11,6 +8,8 @@ import logging import math from typing import Any, Final +# Suppressing disable=deprecated-module is needed for Python 3.11 +import audioop # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components import conversation, stt, tts, websocket_api diff --git a/homeassistant/components/bluetooth/passive_update_coordinator.py b/homeassistant/components/bluetooth/passive_update_coordinator.py index be232f87b24..c20f55abcee 100644 --- a/homeassistant/components/bluetooth/passive_update_coordinator.py +++ b/homeassistant/components/bluetooth/passive_update_coordinator.py @@ -2,9 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any - -from typing_extensions import TypeVar +from typing import TYPE_CHECKING, Any, TypeVar from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.update_coordinator import ( diff --git a/homeassistant/components/broadlink/device.py b/homeassistant/components/broadlink/device.py index 75b6236a473..ac90dd9af79 100644 --- a/homeassistant/components/broadlink/device.py +++ b/homeassistant/components/broadlink/device.py @@ -3,7 +3,7 @@ from contextlib import suppress from functools import partial import logging -from typing import Generic +from typing import Generic, TypeVar import broadlink as blk from broadlink.exceptions import ( @@ -13,7 +13,6 @@ from broadlink.exceptions import ( ConnectionClosedError, NetworkTimeoutError, ) -from typing_extensions import TypeVar from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index f1455f5a541..8e0a521e182 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -5,11 +5,10 @@ from __future__ import annotations from abc import ABC, abstractmethod from datetime import datetime, timedelta import logging -from typing import TYPE_CHECKING, Any, Generic +from typing import TYPE_CHECKING, Any, Generic, TypeVar import broadlink as blk from broadlink.exceptions import AuthorizationError, BroadlinkException -from typing_extensions import TypeVar from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/denon/media_player.py b/homeassistant/components/denon/media_player.py index 0a6fe18d986..2f46cd42294 100644 --- a/homeassistant/components/denon/media_player.py +++ b/homeassistant/components/denon/media_player.py @@ -3,8 +3,8 @@ from __future__ import annotations import logging -import telnetlib # pylint: disable=deprecated-module +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.media_player import ( diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index fbb6a6b48f9..7ff00b8e282 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -5,9 +5,9 @@ from __future__ import annotations from datetime import timedelta import logging import socket -from telnetlib import Telnet # pylint: disable=deprecated-module from typing import Any +from telnetlib import Telnet # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.sensor import ( diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 9ca34af3741..022eb9387e5 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -4,8 +4,7 @@ from __future__ import annotations from collections.abc import Mapping from pathlib import Path -import sys -from typing import Final +from typing import TYPE_CHECKING, Final from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE from aiohttp.web import FileResponse, Request, StreamResponse @@ -18,14 +17,12 @@ CACHE_HEADER = f"public, max-age={CACHE_TIME}" CACHE_HEADERS: Mapping[str, str] = {CACHE_CONTROL: CACHE_HEADER} RESPONSE_CACHE: LRU[tuple[str, Path], tuple[Path, str]] = LRU(512) -if sys.version_info >= (3, 13): - # guess_type is soft-deprecated in 3.13 - # for paths and should only be used for - # URLs. guess_file_type should be used - # for paths instead. - _GUESSER = CONTENT_TYPES.guess_file_type -else: +if TYPE_CHECKING: + # mypy uses Python 3.12 syntax for type checking + # once it uses Python 3.13, this can be removed _GUESSER = CONTENT_TYPES.guess_type +else: + _GUESSER = CONTENT_TYPES.guess_file_type class CachingStaticResource(StaticResource): diff --git a/homeassistant/components/pioneer/media_player.py b/homeassistant/components/pioneer/media_player.py index 670ccffaea7..02072b6cb43 100644 --- a/homeassistant/components/pioneer/media_player.py +++ b/homeassistant/components/pioneer/media_player.py @@ -3,9 +3,9 @@ from __future__ import annotations import logging -import telnetlib # pylint: disable=deprecated-module from typing import Final +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.media_player import ( diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index b93a7f35322..d48cc35a4f5 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from typing import Any, Concatenate, Generic, cast +from typing import Any, Concatenate, Generic, TypeVar, cast from ring_doorbell import ( AuthenticationError, @@ -11,7 +11,6 @@ from ring_doorbell import ( RingGeneric, RingTimeout, ) -from typing_extensions import TypeVar from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity diff --git a/homeassistant/components/telnet/switch.py b/homeassistant/components/telnet/switch.py index 82d8905a775..0178a6521c4 100644 --- a/homeassistant/components/telnet/switch.py +++ b/homeassistant/components/telnet/switch.py @@ -4,9 +4,9 @@ from __future__ import annotations from datetime import timedelta import logging -import telnetlib # pylint: disable=deprecated-module from typing import Any +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.switch import ( diff --git a/homeassistant/components/thomson/device_tracker.py b/homeassistant/components/thomson/device_tracker.py index abf3e604472..4e44b2b1ffd 100644 --- a/homeassistant/components/thomson/device_tracker.py +++ b/homeassistant/components/thomson/device_tracker.py @@ -4,8 +4,8 @@ from __future__ import annotations import logging import re -import telnetlib # pylint: disable=deprecated-module +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.device_tracker import ( diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 557765795ee..50d90c59d37 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -8,10 +8,19 @@ from contextlib import suppress from datetime import timedelta from functools import partial import logging -from typing import Any, Final, Generic, Literal, Required, TypedDict, cast, final +from typing import ( + Any, + Final, + Generic, + Literal, + Required, + TypedDict, + TypeVar, + cast, + final, +) from propcache import cached_property -from typing_extensions import TypeVar import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index f73bed101ee..00532152b53 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -22,11 +22,10 @@ from functools import cache import logging from random import randint from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Generic, Self, cast +from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar, cast from async_interrupt import interrupt from propcache import cached_property -from typing_extensions import TypeVar import voluptuous as vol from . import data_entry_flow, loader diff --git a/homeassistant/const.py b/homeassistant/const.py index efc01047caf..699aebcafdf 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -28,10 +28,10 @@ MINOR_VERSION: Final = 2 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" -REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) +REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) # Truthy date string triggers showing related deprecation warning messages. -REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "2025.2" +REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" # Format for platform files PLATFORM_FORMAT: Final = "{platform}.{domain}" diff --git a/homeassistant/core.py b/homeassistant/core.py index 5d0fcdc2b09..58f96ed0ad2 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -36,12 +36,12 @@ from typing import ( NotRequired, Self, TypedDict, + TypeVar, cast, overload, ) from propcache import cached_property, under_cached_property -from typing_extensions import TypeVar import voluptuous as vol from . import util diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 6df77443e7e..e5ee5a79922 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -12,9 +12,8 @@ from dataclasses import dataclass from enum import StrEnum import logging from types import MappingProxyType -from typing import Any, Generic, Required, TypedDict, cast +from typing import Any, Generic, Required, TypedDict, TypeVar, cast -from typing_extensions import TypeVar import voluptuous as vol from .core import HomeAssistant, callback diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 86d3450c3a0..1b01f1c3f5b 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -11,9 +11,8 @@ from hashlib import md5 from itertools import groupby import logging from operator import attrgetter -from typing import Any, Generic, TypedDict +from typing import Any, Generic, TypedDict, TypeVar -from typing_extensions import TypeVar import voluptuous as vol from voluptuous.humanize import humanize_error diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index adb2062a8ea..b15d8b9e607 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -3,10 +3,9 @@ from __future__ import annotations from http import HTTPStatus -from typing import Any, Generic +from typing import Any, Generic, TypeVar from aiohttp import web -from typing_extensions import TypeVar import voluptuous as vol import voluptuous_serialize diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 1be7289401c..de20a257a9f 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -7,9 +7,7 @@ from collections.abc import Callable, Iterable from datetime import timedelta import logging from types import ModuleType -from typing import Any, Generic - -from typing_extensions import TypeVar +from typing import Any, Generic, TypeVar from homeassistant import config as conf_util from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 6cc4584935e..039fbac5787 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -9,13 +9,12 @@ from datetime import datetime, timedelta import logging from random import randint from time import monotonic -from typing import Any, Generic, Protocol +from typing import Any, Generic, Protocol, TypeVar import urllib.error import aiohttp from propcache import cached_property import requests -from typing_extensions import TypeVar from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP diff --git a/homeassistant/util/event_type.py b/homeassistant/util/event_type.py index 509a35d33ae..7755a45d3b9 100644 --- a/homeassistant/util/event_type.py +++ b/homeassistant/util/event_type.py @@ -6,9 +6,7 @@ Custom for type checking. See stub file. from __future__ import annotations from collections.abc import Mapping -from typing import Any, Generic - -from typing_extensions import TypeVar +from typing import Any, Generic, TypeVar _DataT = TypeVar("_DataT", bound=Mapping[str, Any], default=Mapping[str, Any]) diff --git a/homeassistant/util/event_type.pyi b/homeassistant/util/event_type.pyi index 4285e54e8c9..d3adb8a1c54 100644 --- a/homeassistant/util/event_type.pyi +++ b/homeassistant/util/event_type.pyi @@ -2,9 +2,7 @@ # ruff: noqa: PYI021 # Allow docstrings from collections.abc import Mapping -from typing import Any, Generic - -from typing_extensions import TypeVar +from typing import Any, Generic, TypeVar __all__ = [ "EventType", diff --git a/pyproject.toml b/pyproject.toml index b1bac5a2e15..1269e6668d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,11 +18,10 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", - "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Home Automation", ] -requires-python = ">=3.12.0" +requires-python = ">=3.13.0" dependencies = [ "aiodns==3.2.0", # Integrations may depend on hassio integration without listing it to @@ -104,7 +103,7 @@ include-package-data = true include = ["homeassistant*"] [tool.pylint.MAIN] -py-version = "3.12" +py-version = "3.13" # Use a conservative default here; 2 should speed up most setups and not hurt # any too bad. Override on command line as appropriate. jobs = 2 diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 1d7f2b5ed88..cd37dbd543d 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -9,8 +9,6 @@ import os from pathlib import Path from typing import Final -from homeassistant.const import REQUIRED_PYTHON_VER - from .model import Config, Integration # Component modules which should set no_implicit_reexport = true. @@ -31,7 +29,18 @@ HEADER: Final = """ """.lstrip() GENERAL_SETTINGS: Final[dict[str, str]] = { - "python_version": ".".join(str(x) for x in REQUIRED_PYTHON_VER[:2]), + # We use @dataclass_transform in all our EntityDescriptions, causing + # `__replace__` to be already synthesized by mypy, causing **every** use of + # our entity descriptions to fail: + # + # error: Signature of "__replace__" incompatible with supertype "EntityDescription" + # + # Until this is fixed in mypy, we keep mypy locked on to Python 3.12 as we + # have done for the past few releases. + # + # Ref: https://github.com/python/mypy/issues/18216 + # "python_version": ".".join(str(x) for x in REQUIRED_PYTHON_VER[:2]), + "python_version": "3.12", "platform": "linux", "plugins": ", ".join( # noqa: FLY002 [ diff --git a/tests/common.py b/tests/common.py index d83955758de..d9315ef074f 100644 --- a/tests/common.py +++ b/tests/common.py @@ -25,13 +25,12 @@ import os import pathlib import time from types import FrameType, ModuleType -from typing import Any, Literal, NoReturn +from typing import Any, Literal, NoReturn, TypeVar from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 import pytest from syrupy import SnapshotAssertion -from typing_extensions import TypeVar import voluptuous as vol from homeassistant import auth, bootstrap, config_entries, loader diff --git a/tests/components/homee/conftest.py b/tests/components/homee/conftest.py index 881a24656f3..a777f6b59a9 100644 --- a/tests/components/homee/conftest.py +++ b/tests/components/homee/conftest.py @@ -1,9 +1,9 @@ """Fixtures for Homee integration tests.""" +from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest -from typing_extensions import Generator from homeassistant.components.homee.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME From faf2c64cc4dfd8f04807a7c48f2c575a7ee0c20e Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Tue, 14 Jan 2025 14:14:41 -0500 Subject: [PATCH 0428/2987] Bump elkm1-lib to 2.2.11 (#135616) --- homeassistant/components/elkm1/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index 7822307e12e..12c22e23ff0 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/elkm1", "iot_class": "local_push", "loggers": ["elkm1_lib"], - "requirements": ["elkm1-lib==2.2.10"] + "requirements": ["elkm1-lib==2.2.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 374199406f6..1336c643b22 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -830,7 +830,7 @@ elgato==5.1.2 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.10 +elkm1-lib==2.2.11 # homeassistant.components.elmax elmax-api==0.0.6.4rc0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 13afc39bf18..d56e87d3002 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -705,7 +705,7 @@ elevenlabs==1.9.0 elgato==5.1.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.10 +elkm1-lib==2.2.11 # homeassistant.components.elmax elmax-api==0.0.6.4rc0 From c408bd6aadcecf9119ac2858f95c91b051c30b47 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Jan 2025 20:39:58 +0100 Subject: [PATCH 0429/2987] Bump securetar to 2025.1.2 (#135614) --- homeassistant/components/backup/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/backup/manifest.json b/homeassistant/components/backup/manifest.json index 9d8d9956097..b1b6e6f70c6 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["cronsim==2.6", "securetar==2025.1.1"] + "requirements": ["cronsim==2.6", "securetar==2025.1.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0c601f37b0b..6b994679780 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -60,7 +60,7 @@ PyTurboJPEG==1.7.5 pyudev==0.24.1 PyYAML==6.0.2 requests==2.32.3 -securetar==2025.1.1 +securetar==2025.1.2 SQLAlchemy==2.0.36 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' diff --git a/pyproject.toml b/pyproject.toml index 1269e6668d1..acf9c4b5ee6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ dependencies = [ "python-slugify==8.0.4", "PyYAML==6.0.2", "requests==2.32.3", - "securetar==2025.1.1", + "securetar==2025.1.2", "SQLAlchemy==2.0.36", "standard-aifc==3.13.0;python_version>='3.13'", "standard-telnetlib==3.13.0;python_version>='3.13'", diff --git a/requirements.txt b/requirements.txt index a8b816b89d5..7f12eb14274 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,7 +38,7 @@ psutil-home-assistant==0.0.1 python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 -securetar==2025.1.1 +securetar==2025.1.2 SQLAlchemy==2.0.36 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' diff --git a/requirements_all.txt b/requirements_all.txt index 1336c643b22..8fe65b12c21 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2665,7 +2665,7 @@ screenlogicpy==0.10.0 scsgate==0.1.0 # homeassistant.components.backup -securetar==2025.1.1 +securetar==2025.1.2 # homeassistant.components.sendgrid sendgrid==6.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d56e87d3002..5417c60642b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2147,7 +2147,7 @@ sanix==1.0.6 screenlogicpy==0.10.0 # homeassistant.components.backup -securetar==2025.1.1 +securetar==2025.1.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense From f80f6d9e3d8278146a2d308eda4e7a1ac7491436 Mon Sep 17 00:00:00 2001 From: Jordan Sitkin Date: Tue, 14 Jan 2025 12:28:10 -0800 Subject: [PATCH 0430/2987] Add `PaddleSwitchPico` (Pico Paddle Remote) device trigger to Lutron Caseta (#135615) --- .../components/lutron_caseta/device_trigger.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index 0b432f88045..79b792935a8 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -277,6 +277,20 @@ FOUR_GROUP_REMOTE_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( } ) +PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP = { + "button_0": 2, + "button_2": 4, +} +PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP = { + "button_0": 0, + "button_2": 2, +} +PADDLE_SWITCH_PICO_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( + { + vol.Required(CONF_SUBTYPE): vol.In(PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP), + } +) + DEVICE_TYPE_SCHEMA_MAP = { "Pico2Button": PICO_2_BUTTON_TRIGGER_SCHEMA, @@ -288,6 +302,7 @@ DEVICE_TYPE_SCHEMA_MAP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA, "FourGroupRemote": FOUR_GROUP_REMOTE_TRIGGER_SCHEMA, + "PaddleSwitchPico": PADDLE_SWITCH_PICO_TRIGGER_SCHEMA, } DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = { @@ -300,6 +315,7 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LIP, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LIP, "FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LIP, + "PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP, } DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = { @@ -312,6 +328,7 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LEAP, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LEAP, "FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LEAP, + "PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP, } LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP: dict[str, dict[int, str]] = { @@ -326,6 +343,7 @@ TRIGGER_SCHEMA = vol.Any( PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA, PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA, FOUR_GROUP_REMOTE_TRIGGER_SCHEMA, + PADDLE_SWITCH_PICO_TRIGGER_SCHEMA, ) From 18de735619f696da714668067dc5e331f39711a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 14 Jan 2025 20:49:00 -0100 Subject: [PATCH 0431/2987] More UpCloud config entry refactors (#135548) --- homeassistant/components/upcloud/__init__.py | 12 ++++-------- homeassistant/components/upcloud/binary_sensor.py | 2 +- homeassistant/components/upcloud/config_flow.py | 2 +- homeassistant/components/upcloud/coordinator.py | 15 ++++++++++----- homeassistant/components/upcloud/entity.py | 6 +----- homeassistant/components/upcloud/switch.py | 2 +- 6 files changed, 18 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index 2450e3d5852..a3fec73dca8 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -8,7 +8,6 @@ import logging import requests.exceptions import upcloud_api -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_SCAN_INTERVAL, @@ -23,14 +22,12 @@ from homeassistant.helpers.dispatcher import ( ) from .const import CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE, DEFAULT_SCAN_INTERVAL -from .coordinator import UpCloudDataUpdateCoordinator +from .coordinator import UpCloudConfigEntry, UpCloudDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SWITCH] -type UpCloudConfigEntry = ConfigEntry[UpCloudDataUpdateCoordinator] - def _config_entry_update_signal_name(config_entry: UpCloudConfigEntry) -> str: """Get signal name for updates to a config entry.""" @@ -69,6 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpCloudConfigEntry) -> b coordinator = UpCloudDataUpdateCoordinator( hass, + config_entry=entry, update_interval=update_interval, cloud_manager=manager, username=entry.data[CONF_USERNAME], @@ -94,8 +92,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpCloudConfigEntry) -> b return True -async def async_unload_entry( - hass: HomeAssistant, config_entry: UpCloudConfigEntry -) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: UpCloudConfigEntry) -> bool: """Unload the config entry.""" - return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/upcloud/binary_sensor.py b/homeassistant/components/upcloud/binary_sensor.py index 77bbfdbffaa..bca313d306f 100644 --- a/homeassistant/components/upcloud/binary_sensor.py +++ b/homeassistant/components/upcloud/binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UpCloudConfigEntry +from .coordinator import UpCloudConfigEntry from .entity import UpCloudServerEntity diff --git a/homeassistant/components/upcloud/config_flow.py b/homeassistant/components/upcloud/config_flow.py index 4777c75ae3c..16adcc51ddf 100644 --- a/homeassistant/components/upcloud/config_flow.py +++ b/homeassistant/components/upcloud/config_flow.py @@ -13,8 +13,8 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFl from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import callback -from . import UpCloudConfigEntry from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .coordinator import UpCloudConfigEntry _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/upcloud/coordinator.py b/homeassistant/components/upcloud/coordinator.py index 4eb92018dcf..8088b3a72ea 100644 --- a/homeassistant/components/upcloud/coordinator.py +++ b/homeassistant/components/upcloud/coordinator.py @@ -4,20 +4,20 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import TYPE_CHECKING import upcloud_api +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -if TYPE_CHECKING: - from . import UpCloudConfigEntry - _LOGGER = logging.getLogger(__name__) +type UpCloudConfigEntry = ConfigEntry[UpCloudDataUpdateCoordinator] + + class UpCloudDataUpdateCoordinator( DataUpdateCoordinator[dict[str, upcloud_api.Server]] ): @@ -27,13 +27,18 @@ class UpCloudDataUpdateCoordinator( self, hass: HomeAssistant, *, + config_entry: UpCloudConfigEntry, cloud_manager: upcloud_api.CloudManager, update_interval: timedelta, username: str, ) -> None: """Initialize coordinator.""" super().__init__( - hass, _LOGGER, name=f"{username}@UpCloud", update_interval=update_interval + hass, + _LOGGER, + config_entry=config_entry, + name=f"{username}@UpCloud", + update_interval=update_interval, ) self.cloud_manager = cloud_manager diff --git a/homeassistant/components/upcloud/entity.py b/homeassistant/components/upcloud/entity.py index 3d727f90d9e..1ff5374bcaf 100644 --- a/homeassistant/components/upcloud/entity.py +++ b/homeassistant/components/upcloud/entity.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from typing import Any import upcloud_api @@ -11,11 +10,8 @@ from homeassistant.const import CONF_USERNAME, STATE_OFF, STATE_ON, STATE_PROBLE from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import UpCloudConfigEntry from .const import DOMAIN -from .coordinator import UpCloudDataUpdateCoordinator - -_LOGGER = logging.getLogger(__name__) +from .coordinator import UpCloudConfigEntry, UpCloudDataUpdateCoordinator ATTR_CORE_NUMBER = "core_number" ATTR_HOSTNAME = "hostname" diff --git a/homeassistant/components/upcloud/switch.py b/homeassistant/components/upcloud/switch.py index 5ee2adfc9f6..97c08b19188 100644 --- a/homeassistant/components/upcloud/switch.py +++ b/homeassistant/components/upcloud/switch.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UpCloudConfigEntry +from .coordinator import UpCloudConfigEntry from .entity import UpCloudServerEntity SIGNAL_UPDATE_UPCLOUD = "upcloud_update" From ecc89fd9a940c2015de25ee52e118fefee3bcf1f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 15 Jan 2025 00:02:22 +0100 Subject: [PATCH 0432/2987] Fix spotify typing for Python 3.13 (#135628) --- homeassistant/components/spotify/browse_media.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/spotify/browse_media.py b/homeassistant/components/spotify/browse_media.py index 81cdfdfb3cf..458525dde28 100644 --- a/homeassistant/components/spotify/browse_media.py +++ b/homeassistant/components/spotify/browse_media.py @@ -14,7 +14,7 @@ from spotifyaio import ( SpotifyClient, Track, ) -from spotifyaio.models import ItemType, SimplifiedEpisode +from spotifyaio.models import Episode, ItemType, SimplifiedEpisode import yarl from homeassistant.components.media_player import ( @@ -363,7 +363,7 @@ async def build_item_response( # noqa: C901 items.append(_get_track_item_payload(playlist_item.track)) elif playlist_item.track.type is ItemType.EPISODE: if TYPE_CHECKING: - assert isinstance(playlist_item.track, SimplifiedEpisode) + assert isinstance(playlist_item.track, Episode) items.append(_get_episode_item_payload(playlist_item.track)) elif media_content_type == MediaType.ALBUM: if album := await spotify.get_album(media_content_id): From 6e88c6570ed33d75269482ae91a87bbfa7ff253b Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 14 Jan 2025 18:15:49 -0500 Subject: [PATCH 0433/2987] Return OFF in hvac_action for Honeywell climate (#135620) --- homeassistant/components/honeywell/climate.py | 2 +- tests/components/honeywell/snapshots/test_climate.ambr | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 7398ada23be..1df5eb9601b 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -294,7 +294,7 @@ class HoneywellUSThermostat(ClimateEntity): def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" if self.hvac_mode == HVACMode.OFF: - return None + return HVACAction.OFF return HW_MODE_TO_HA_HVAC_ACTION.get(self._device.equipment_output_status) @property diff --git a/tests/components/honeywell/snapshots/test_climate.ambr b/tests/components/honeywell/snapshots/test_climate.ambr index f26064b335a..1e9958acb3f 100644 --- a/tests/components/honeywell/snapshots/test_climate.ambr +++ b/tests/components/honeywell/snapshots/test_climate.ambr @@ -12,6 +12,7 @@ ]), 'friendly_name': 'device1', 'humidity': None, + 'hvac_action': , 'hvac_modes': list([ , , From c4d8cda92be330311fd83809588c7e0131d1c49b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 15 Jan 2025 00:54:54 +0100 Subject: [PATCH 0434/2987] Update mypy-dev to 1.15.0a2 (#135633) --- homeassistant/components/recorder/migration.py | 8 ++++---- homeassistant/components/recorder/models/state.py | 4 ++-- homeassistant/components/schedule/__init__.py | 2 +- homeassistant/components/twentemilieu/calendar.py | 3 +-- homeassistant/components/zha/device_tracker.py | 2 +- requirements_test.txt | 2 +- 6 files changed, 10 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 2efcef1c768..c6cdd6d317f 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -2147,7 +2147,7 @@ def _migrate_columns_to_timestamp( ) ) result = None - while result is None or result.rowcount > 0: # type: ignore[unreachable] + while result is None or result.rowcount > 0: with session_scope(session=session_maker()) as session: result = session.connection().execute( text( @@ -2178,7 +2178,7 @@ def _migrate_columns_to_timestamp( ) ) result = None - while result is None or result.rowcount > 0: # type: ignore[unreachable] + while result is None or result.rowcount > 0: with session_scope(session=session_maker()) as session: result = session.connection().execute( text( @@ -2277,7 +2277,7 @@ def _migrate_statistics_columns_to_timestamp( # updated all rows in the table until the rowcount is 0 for table in STATISTICS_TABLES: result = None - while result is None or result.rowcount > 0: # type: ignore[unreachable] + while result is None or result.rowcount > 0: with session_scope(session=session_maker()) as session: result = session.connection().execute( text( @@ -2299,7 +2299,7 @@ def _migrate_statistics_columns_to_timestamp( # updated all rows in the table until the rowcount is 0 for table in STATISTICS_TABLES: result = None - while result is None or result.rowcount > 0: # type: ignore[unreachable] + while result is None or result.rowcount > 0: with session_scope(session=session_maker()) as session: result = session.connection().execute( text( diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index fbf73e75025..d73c204079d 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -58,8 +58,8 @@ class LazyState(State): self.attr_cache = attr_cache self.context = EMPTY_CONTEXT - @cached_property # type: ignore[override] - def attributes(self) -> dict[str, Any]: + @cached_property + def attributes(self) -> dict[str, Any]: # type: ignore[override] """State attributes.""" return decode_attributes_from_source( getattr(self._row, "attributes", None), self.attr_cache diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index 24ce4f3b3fa..30ca44fe3ee 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -73,7 +73,7 @@ def valid_schedule(schedule: list[dict[str, str]]) -> list[dict[str, str]]: ) # Check if the from time of the event is after the to time of the previous event - if previous_to is not None and previous_to > time_range[CONF_FROM]: # type: ignore[unreachable] + if previous_to is not None and previous_to > time_range[CONF_FROM]: raise vol.Invalid("Overlapping times found in schedule") previous_to = time_range[CONF_TO] diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py index d163ae4e564..69c509b9edf 100644 --- a/homeassistant/components/twentemilieu/calendar.py +++ b/homeassistant/components/twentemilieu/calendar.py @@ -70,8 +70,7 @@ class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEntity): waste_dates and ( next_waste_pickup_date is None - or waste_dates[0] # type: ignore[unreachable] - < next_waste_pickup_date + or waste_dates[0] < next_waste_pickup_date ) and waste_dates[0] >= dt_util.now().date() ): diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index fc374f6c44d..7bdfc54c986 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -61,7 +61,7 @@ class ZHADeviceScannerEntity(ScannerEntity, ZHAEntity): """ return self.entity_data.entity.battery_level - @property # type: ignore[explicit-override, misc] + @property # type: ignore[misc] def device_info(self) -> DeviceInfo: """Return device info.""" # We opt ZHA device tracker back into overriding this method because diff --git a/requirements_test.txt b/requirements_test.txt index b3a50bd96a6..b6d061577e5 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==7.6.8 freezegun==1.5.1 license-expression==30.4.0 mock-open==1.4.0 -mypy-dev==1.15.0a1 +mypy-dev==1.15.0a2 pre-commit==4.0.0 pydantic==2.10.4 pylint==3.3.2 From 239aa94b6f3997354e76f2ddc597ea7f9be3ad8b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 15 Jan 2025 01:43:13 +0100 Subject: [PATCH 0435/2987] Update Python version for mypy to 3.13 (#135636) --- homeassistant/components/http/static.py | 9 ++------- homeassistant/helpers/config_validation.py | 6 ++---- mypy.ini | 2 +- script/hassfest/mypy_config.py | 15 +++------------ 4 files changed, 8 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 022eb9387e5..99877eaf0be 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Mapping from pathlib import Path -from typing import TYPE_CHECKING, Final +from typing import Final from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE from aiohttp.web import FileResponse, Request, StreamResponse @@ -17,12 +17,7 @@ CACHE_HEADER = f"public, max-age={CACHE_TIME}" CACHE_HEADERS: Mapping[str, str] = {CACHE_CONTROL: CACHE_HEADER} RESPONSE_CACHE: LRU[tuple[str, Path], tuple[Path, str]] = LRU(512) -if TYPE_CHECKING: - # mypy uses Python 3.12 syntax for type checking - # once it uses Python 3.13, this can be removed - _GUESSER = CONTENT_TYPES.guess_type -else: - _GUESSER = CONTENT_TYPES.guess_file_type +_GUESSER = CONTENT_TYPES.guess_file_type class CachingStaticResource(StaticResource): diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 3681e941eee..b4655289469 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1,8 +1,6 @@ """Helpers for config validation using voluptuous.""" -# PEP 563 seems to break typing.get_type_hints when used -# with PEP 695 syntax. Fixed in Python 3.13. -# from __future__ import annotations +from __future__ import annotations from collections.abc import Callable, Hashable, Mapping import contextlib @@ -354,7 +352,7 @@ def ensure_list[_T](value: _T | None) -> list[_T] | list[Any]: """Wrap value in list if it is not one.""" if value is None: return [] - return cast("list[_T]", value) if isinstance(value, list) else [value] + return cast(list[_T], value) if isinstance(value, list) else [value] def entity_id(value: Any) -> str: diff --git a/mypy.ini b/mypy.ini index 4eb6bdff80b..6a9bb29c360 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3,7 +3,7 @@ # To update, run python3 -m script.hassfest -p mypy_config [mypy] -python_version = 3.12 +python_version = 3.13 platform = linux plugins = pydantic.mypy, pydantic.v1.mypy show_error_codes = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index cd37dbd543d..1d7f2b5ed88 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -9,6 +9,8 @@ import os from pathlib import Path from typing import Final +from homeassistant.const import REQUIRED_PYTHON_VER + from .model import Config, Integration # Component modules which should set no_implicit_reexport = true. @@ -29,18 +31,7 @@ HEADER: Final = """ """.lstrip() GENERAL_SETTINGS: Final[dict[str, str]] = { - # We use @dataclass_transform in all our EntityDescriptions, causing - # `__replace__` to be already synthesized by mypy, causing **every** use of - # our entity descriptions to fail: - # - # error: Signature of "__replace__" incompatible with supertype "EntityDescription" - # - # Until this is fixed in mypy, we keep mypy locked on to Python 3.12 as we - # have done for the past few releases. - # - # Ref: https://github.com/python/mypy/issues/18216 - # "python_version": ".".join(str(x) for x in REQUIRED_PYTHON_VER[:2]), - "python_version": "3.12", + "python_version": ".".join(str(x) for x in REQUIRED_PYTHON_VER[:2]), "platform": "linux", "plugins": ", ".join( # noqa: FLY002 [ From c1520a9b20b4940015dbb1d645c244ad7ee7c00d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 15 Jan 2025 01:49:10 +0100 Subject: [PATCH 0436/2987] Fix spelling of EnOcean in strings file of the integration (#135622) --- homeassistant/components/enocean/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/enocean/strings.json b/homeassistant/components/enocean/strings.json index 9d9699481b1..1a6f08cbf37 100644 --- a/homeassistant/components/enocean/strings.json +++ b/homeassistant/components/enocean/strings.json @@ -2,13 +2,13 @@ "config": { "step": { "detect": { - "title": "Select the path to your ENOcean dongle", + "title": "Select the path to your EnOcean dongle", "data": { "path": "USB dongle path" } }, "manual": { - "title": "Enter the path to your ENOcean dongle", + "title": "Enter the path to your EnOcean dongle", "data": { "path": "[%key:component::enocean::config::step::detect::data::path%]" } From 4b37b367def87bec24e219aeb386b32000f2c1f1 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 15 Jan 2025 08:31:24 +0100 Subject: [PATCH 0437/2987] Dynamic devices for Husqvarna Automower (#133227) * Dynamic devices for Husqvarna Automower * callbacks * add stayout-zones together * add alltogether on init * fix stale lock names * also for workareas * separate "normal" vs callback entity adding * mark quality scale * Apply suggestions from code review Co-authored-by: Josef Zweck * Apply suggestions from code review Co-authored-by: Josef Zweck --------- Co-authored-by: Josef Zweck --- .../husqvarna_automower/__init__.py | 43 +---- .../husqvarna_automower/binary_sensor.py | 15 +- .../components/husqvarna_automower/button.py | 17 +- .../husqvarna_automower/calendar.py | 11 +- .../husqvarna_automower/coordinator.py | 152 +++++++++++++++++- .../husqvarna_automower/device_tracker.py | 15 +- .../husqvarna_automower/lawn_mower.py | 11 +- .../components/husqvarna_automower/number.py | 75 ++++----- .../husqvarna_automower/quality_scale.yaml | 8 +- .../components/husqvarna_automower/select.py | 16 +- .../components/husqvarna_automower/sensor.py | 84 +++++----- .../components/husqvarna_automower/switch.py | 112 ++++++------- .../husqvarna_automower/conftest.py | 32 ++++ .../husqvarna_automower/test_init.py | 55 ++++++- 14 files changed, 428 insertions(+), 218 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index da7965250cd..a08256fb0b5 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -9,16 +9,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import ( - aiohttp_client, - config_entry_oauth2_flow, - device_registry as dr, - entity_registry as er, -) +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from homeassistant.util import dt as dt_util from . import api -from .const import DOMAIN from .coordinator import AutomowerDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -69,8 +63,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> coordinator = AutomowerDataUpdateCoordinator(hass, automower_api) await coordinator.async_config_entry_first_refresh() - available_devices = list(coordinator.data) - cleanup_removed_devices(hass, coordinator.config_entry, available_devices) entry.runtime_data = coordinator entry.async_create_background_task( @@ -86,36 +78,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> bool: """Handle unload of an entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -def cleanup_removed_devices( - hass: HomeAssistant, - config_entry: AutomowerConfigEntry, - available_devices: list[str], -) -> None: - """Cleanup entity and device registry from removed devices.""" - device_reg = dr.async_get(hass) - identifiers = {(DOMAIN, mower_id) for mower_id in available_devices} - for device in dr.async_entries_for_config_entry(device_reg, config_entry.entry_id): - if not set(device.identifiers) & identifiers: - _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id - ) - - -def remove_work_area_entities( - hass: HomeAssistant, - config_entry: AutomowerConfigEntry, - removed_work_areas: set[int], - mower_id: str, -) -> None: - """Remove all unused work area entities for the specified mower.""" - entity_reg = er.async_get(hass) - for entity_entry in er.async_entries_for_config_entry( - entity_reg, config_entry.entry_id - ): - for work_area_id in removed_work_areas: - if entity_entry.unique_id.startswith(f"{mower_id}_{work_area_id}_"): - _LOGGER.info("Deleting: %s", entity_entry.entity_id) - entity_reg.async_remove(entity_entry.entity_id) diff --git a/homeassistant/components/husqvarna_automower/binary_sensor.py b/homeassistant/components/husqvarna_automower/binary_sensor.py index 3c23da76797..907d34e812a 100644 --- a/homeassistant/components/husqvarna_automower/binary_sensor.py +++ b/homeassistant/components/husqvarna_automower/binary_sensor.py @@ -75,11 +75,16 @@ async def async_setup_entry( ) -> None: """Set up binary sensor platform.""" coordinator = entry.runtime_data - async_add_entities( - AutomowerBinarySensorEntity(mower_id, coordinator, description) - for mower_id in coordinator.data - for description in MOWER_BINARY_SENSOR_TYPES - ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerBinarySensorEntity(mower_id, coordinator, description) + for mower_id in mower_ids + for description in MOWER_BINARY_SENSOR_TYPES + ) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) + _async_add_new_devices(set(coordinator.data)) class AutomowerBinarySensorEntity(AutomowerBaseEntity, BinarySensorEntity): diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py index ce303325496..7e6e581cdf1 100644 --- a/homeassistant/components/husqvarna_automower/button.py +++ b/homeassistant/components/husqvarna_automower/button.py @@ -58,12 +58,17 @@ async def async_setup_entry( ) -> None: """Set up button platform.""" coordinator = entry.runtime_data - async_add_entities( - AutomowerButtonEntity(mower_id, coordinator, description) - for mower_id in coordinator.data - for description in MOWER_BUTTON_TYPES - if description.exists_fn(coordinator.data[mower_id]) - ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerButtonEntity(mower_id, coordinator, description) + for mower_id in mower_ids + for description in MOWER_BUTTON_TYPES + if description.exists_fn(coordinator.data[mower_id]) + ) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) + _async_add_new_devices(set(coordinator.data)) class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity): diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py index f3e82fde5d4..9e2ea037afb 100644 --- a/homeassistant/components/husqvarna_automower/calendar.py +++ b/homeassistant/components/husqvarna_automower/calendar.py @@ -26,9 +26,14 @@ async def async_setup_entry( ) -> None: """Set up lawn mower platform.""" coordinator = entry.runtime_data - async_add_entities( - AutomowerCalendarEntity(mower_id, coordinator) for mower_id in coordinator.data - ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerCalendarEntity(mower_id, coordinator) for mower_id in mower_ids + ) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) + _async_add_new_devices(set(coordinator.data)) class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 57be02e7066..2921b5ca68e 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from datetime import timedelta import logging from typing import TYPE_CHECKING @@ -18,6 +19,7 @@ from aioautomower.session import AutomowerSession from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -47,6 +49,12 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib self.api = api self.ws_connected: bool = False self.reconnect_time = DEFAULT_RECONNECT_TIME + self.new_devices_callbacks: list[Callable[[set[str]], None]] = [] + self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = [] + self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = [] + self._devices_last_update: set[str] = set() + self._zones_last_update: dict[str, set[str]] = {} + self._areas_last_update: dict[str, set[int]] = {} async def _async_update_data(self) -> dict[str, MowerAttributes]: """Subscribe for websocket and poll data from the API.""" @@ -55,12 +63,21 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib self.api.register_data_callback(self.callback) self.ws_connected = True try: - return await self.api.get_status() + data = await self.api.get_status() except ApiException as err: raise UpdateFailed(err) from err except AuthException as err: raise ConfigEntryAuthFailed(err) from err + self._async_add_remove_devices(data) + for mower_id in data: + if data[mower_id].capabilities.stay_out_zones: + self._async_add_remove_stay_out_zones(data) + for mower_id in data: + if data[mower_id].capabilities.work_areas: + self._async_add_remove_work_areas(data) + return data + @callback def callback(self, ws_data: dict[str, MowerAttributes]) -> None: """Process websocket callbacks and write them to the DataUpdateCoordinator.""" @@ -96,3 +113,136 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib self.client_listen(hass, entry, automower_client), "reconnect_task", ) + + def _async_add_remove_devices(self, data: dict[str, MowerAttributes]) -> None: + """Add new device, remove non-existing device.""" + current_devices = set(data) + + # Skip update if no changes + if current_devices == self._devices_last_update: + return + + # Process removed devices + removed_devices = self._devices_last_update - current_devices + if removed_devices: + _LOGGER.debug("Removed devices: %s", ", ".join(map(str, removed_devices))) + self._remove_device(removed_devices) + + # Process new device + new_devices = current_devices - self._devices_last_update + if new_devices: + _LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices))) + self._add_new_devices(new_devices) + + # Update device state + self._devices_last_update = current_devices + + def _remove_device(self, removed_devices: set[str]) -> None: + """Remove device from the registry.""" + device_registry = dr.async_get(self.hass) + for mower_id in removed_devices: + if device := device_registry.async_get_device( + identifiers={(DOMAIN, str(mower_id))} + ): + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + def _add_new_devices(self, new_devices: set[str]) -> None: + """Add new device and trigger callbacks.""" + for mower_callback in self.new_devices_callbacks: + mower_callback(new_devices) + + def _async_add_remove_stay_out_zones( + self, data: dict[str, MowerAttributes] + ) -> None: + """Add new stay-out zones, remove non-existing stay-out zones.""" + current_zones = { + mower_id: set(mower_data.stay_out_zones.zones) + for mower_id, mower_data in data.items() + if mower_data.capabilities.stay_out_zones + and mower_data.stay_out_zones is not None + } + + if not self._zones_last_update: + self._zones_last_update = current_zones + return + + if current_zones == self._zones_last_update: + return + + self._zones_last_update = self._update_stay_out_zones(current_zones) + + def _update_stay_out_zones( + self, current_zones: dict[str, set[str]] + ) -> dict[str, set[str]]: + """Update stay-out zones by adding and removing as needed.""" + new_zones = { + mower_id: zones - self._zones_last_update.get(mower_id, set()) + for mower_id, zones in current_zones.items() + } + removed_zones = { + mower_id: self._zones_last_update.get(mower_id, set()) - zones + for mower_id, zones in current_zones.items() + } + + for mower_id, zones in new_zones.items(): + for zone_callback in self.new_zones_callbacks: + zone_callback(mower_id, set(zones)) + + entity_registry = er.async_get(self.hass) + for mower_id, zones in removed_zones.items(): + for entity_entry in er.async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ): + for zone in zones: + if entity_entry.unique_id.startswith(f"{mower_id}_{zone}"): + entity_registry.async_remove(entity_entry.entity_id) + + return current_zones + + def _async_add_remove_work_areas(self, data: dict[str, MowerAttributes]) -> None: + """Add new work areas, remove non-existing work areas.""" + current_areas = { + mower_id: set(mower_data.work_areas) + for mower_id, mower_data in data.items() + if mower_data.capabilities.work_areas and mower_data.work_areas is not None + } + + if not self._areas_last_update: + self._areas_last_update = current_areas + return + + if current_areas == self._areas_last_update: + return + + self._areas_last_update = self._update_work_areas(current_areas) + + def _update_work_areas( + self, current_areas: dict[str, set[int]] + ) -> dict[str, set[int]]: + """Update work areas by adding and removing as needed.""" + new_areas = { + mower_id: areas - self._areas_last_update.get(mower_id, set()) + for mower_id, areas in current_areas.items() + } + removed_areas = { + mower_id: self._areas_last_update.get(mower_id, set()) - areas + for mower_id, areas in current_areas.items() + } + + for mower_id, areas in new_areas.items(): + for area_callback in self.new_areas_callbacks: + area_callback(mower_id, set(areas)) + + entity_registry = er.async_get(self.hass) + for mower_id, areas in removed_areas.items(): + for entity_entry in er.async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ): + for area in areas: + if entity_entry.unique_id.startswith(f"{mower_id}_{area}_"): + entity_registry.async_remove(entity_entry.entity_id) + + return current_areas diff --git a/homeassistant/components/husqvarna_automower/device_tracker.py b/homeassistant/components/husqvarna_automower/device_tracker.py index 520eaceb1d0..2fd59b63014 100644 --- a/homeassistant/components/husqvarna_automower/device_tracker.py +++ b/homeassistant/components/husqvarna_automower/device_tracker.py @@ -19,11 +19,16 @@ async def async_setup_entry( ) -> None: """Set up device tracker platform.""" coordinator = entry.runtime_data - async_add_entities( - AutomowerDeviceTrackerEntity(mower_id, coordinator) - for mower_id in coordinator.data - if coordinator.data[mower_id].capabilities.position - ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerDeviceTrackerEntity(mower_id, coordinator) + for mower_id in mower_ids + if coordinator.data[mower_id].capabilities.position + ) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) + _async_add_new_devices(set(coordinator.data)) class AutomowerDeviceTrackerEntity(AutomowerBaseEntity, TrackerEntity): diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py index 9b3ce7dab1a..dd75a8b9bc4 100644 --- a/homeassistant/components/husqvarna_automower/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -53,10 +53,15 @@ async def async_setup_entry( ) -> None: """Set up lawn mower platform.""" coordinator = entry.runtime_data - async_add_entities( - AutomowerLawnMowerEntity(mower_id, coordinator) for mower_id in coordinator.data - ) + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + [AutomowerLawnMowerEntity(mower_id, coordinator) for mower_id in mower_ids] + ) + + _async_add_new_devices(set(coordinator.data)) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( "override_schedule", diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py index e69b52fab93..d3666494646 100644 --- a/homeassistant/components/husqvarna_automower/number.py +++ b/homeassistant/components/husqvarna_automower/number.py @@ -13,7 +13,7 @@ from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AutomowerConfigEntry, remove_work_area_entities +from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator from .entity import ( AutomowerControlEntity, @@ -111,44 +111,47 @@ async def async_setup_entry( ) -> None: """Set up number platform.""" coordinator = entry.runtime_data - current_work_areas: dict[str, set[int]] = {} - - async_add_entities( - AutomowerNumberEntity(mower_id, coordinator, description) - for mower_id in coordinator.data - for description in MOWER_NUMBER_TYPES - if description.exists_fn(coordinator.data[mower_id]) - ) - - def _async_work_area_listener() -> None: - """Listen for new work areas and add/remove entities as needed.""" - for mower_id in coordinator.data: - if ( - coordinator.data[mower_id].capabilities.work_areas - and (_work_areas := coordinator.data[mower_id].work_areas) is not None - ): - received_work_areas = set(_work_areas.keys()) - current_work_area_set = current_work_areas.setdefault(mower_id, set()) - - new_work_areas = received_work_areas - current_work_area_set - removed_work_areas = current_work_area_set - received_work_areas - - if new_work_areas: - current_work_area_set.update(new_work_areas) - async_add_entities( - WorkAreaNumberEntity( - mower_id, coordinator, description, work_area_id - ) - for description in WORK_AREA_NUMBER_TYPES - for work_area_id in new_work_areas + entities: list[NumberEntity] = [] + for mower_id in coordinator.data: + if coordinator.data[mower_id].capabilities.work_areas: + _work_areas = coordinator.data[mower_id].work_areas + if _work_areas is not None: + entities.extend( + WorkAreaNumberEntity( + mower_id, coordinator, description, work_area_id ) + for description in WORK_AREA_NUMBER_TYPES + for work_area_id in _work_areas + ) + entities.extend( + AutomowerNumberEntity(mower_id, coordinator, description) + for description in MOWER_NUMBER_TYPES + if description.exists_fn(coordinator.data[mower_id]) + ) + async_add_entities(entities) - if removed_work_areas: - remove_work_area_entities(hass, entry, removed_work_areas, mower_id) - current_work_area_set.difference_update(removed_work_areas) + def _async_add_new_work_areas(mower_id: str, work_area_ids: set[int]) -> None: + async_add_entities( + WorkAreaNumberEntity(mower_id, coordinator, description, work_area_id) + for description in WORK_AREA_NUMBER_TYPES + for work_area_id in work_area_ids + ) - coordinator.async_add_listener(_async_work_area_listener) - _async_work_area_listener() + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerNumberEntity(mower_id, coordinator, description) + for description in MOWER_NUMBER_TYPES + for mower_id in mower_ids + if description.exists_fn(coordinator.data[mower_id]) + ) + for mower_id in mower_ids: + mower_data = coordinator.data[mower_id] + if mower_data.capabilities.work_areas and mower_data.work_areas is not None: + work_area_ids = set(mower_data.work_areas.keys()) + _async_add_new_work_areas(mower_id, work_area_ids) + + coordinator.new_areas_callbacks.append(_async_add_new_work_areas) + coordinator.new_devices_callbacks.append(_async_add_new_devices) class AutomowerNumberEntity(AutomowerControlEntity, NumberEntity): diff --git a/homeassistant/components/husqvarna_automower/quality_scale.yaml b/homeassistant/components/husqvarna_automower/quality_scale.yaml index 2287ccb4d4f..2fa41c02a4c 100644 --- a/homeassistant/components/husqvarna_automower/quality_scale.yaml +++ b/homeassistant/components/husqvarna_automower/quality_scale.yaml @@ -57,9 +57,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: todo - dynamic-devices: - status: todo - comment: Add devices dynamically + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done @@ -70,9 +68,7 @@ rules: status: exempt comment: no configuration possible repair-issues: done - stale-devices: - status: todo - comment: We only remove devices on reload + stale-devices: done # Platinum async-dependency: done diff --git a/homeassistant/components/husqvarna_automower/select.py b/homeassistant/components/husqvarna_automower/select.py index 65960e897e4..03b1ac02587 100644 --- a/homeassistant/components/husqvarna_automower/select.py +++ b/homeassistant/components/husqvarna_automower/select.py @@ -33,11 +33,17 @@ async def async_setup_entry( ) -> None: """Set up select platform.""" coordinator = entry.runtime_data - async_add_entities( - AutomowerSelectEntity(mower_id, coordinator) - for mower_id in coordinator.data - if coordinator.data[mower_id].capabilities.headlights - ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerSelectEntity(mower_id, coordinator) + for mower_id in mower_ids + if coordinator.data[mower_id].capabilities.headlights + ) + + _async_add_new_devices(set(coordinator.data)) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) class AutomowerSelectEntity(AutomowerControlEntity, SelectEntity): diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index fb8603623e4..a2f4b5f4bab 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -434,44 +434,56 @@ async def async_setup_entry( ) -> None: """Set up sensor platform.""" coordinator = entry.runtime_data - current_work_areas: dict[str, set[int]] = {} - - async_add_entities( - AutomowerSensorEntity(mower_id, coordinator, description) - for mower_id, data in coordinator.data.items() - for description in MOWER_SENSOR_TYPES - if description.exists_fn(data) - ) - - def _async_work_area_listener() -> None: - """Listen for new work areas and add sensor entities if they did not exist. - - Listening for deletable work areas is managed in the number platform. - """ - for mower_id in coordinator.data: - if ( - coordinator.data[mower_id].capabilities.work_areas - and (_work_areas := coordinator.data[mower_id].work_areas) is not None - ): - received_work_areas = set(_work_areas.keys()) - new_work_areas = received_work_areas - current_work_areas.get( - mower_id, set() + entities: list[SensorEntity] = [] + for mower_id in coordinator.data: + if coordinator.data[mower_id].capabilities.work_areas: + _work_areas = coordinator.data[mower_id].work_areas + if _work_areas is not None: + entities.extend( + WorkAreaSensorEntity( + mower_id, coordinator, description, work_area_id + ) + for description in WORK_AREA_SENSOR_TYPES + for work_area_id in _work_areas + if description.exists_fn(_work_areas[work_area_id]) ) - if new_work_areas: - current_work_areas.setdefault(mower_id, set()).update( - new_work_areas - ) - async_add_entities( - WorkAreaSensorEntity( - mower_id, coordinator, description, work_area_id - ) - for description in WORK_AREA_SENSOR_TYPES - for work_area_id in new_work_areas - if description.exists_fn(_work_areas[work_area_id]) - ) + entities.extend( + AutomowerSensorEntity(mower_id, coordinator, description) + for description in MOWER_SENSOR_TYPES + if description.exists_fn(coordinator.data[mower_id]) + ) + async_add_entities(entities) - coordinator.async_add_listener(_async_work_area_listener) - _async_work_area_listener() + def _async_add_new_work_areas(mower_id: str, work_area_ids: set[int]) -> None: + mower_data = coordinator.data[mower_id] + if mower_data.work_areas is None: + return + + async_add_entities( + WorkAreaSensorEntity(mower_id, coordinator, description, work_area_id) + for description in WORK_AREA_SENSOR_TYPES + for work_area_id in work_area_ids + if work_area_id in mower_data.work_areas + and description.exists_fn(mower_data.work_areas[work_area_id]) + ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerSensorEntity(mower_id, coordinator, description) + for mower_id in mower_ids + for description in MOWER_SENSOR_TYPES + if description.exists_fn(coordinator.data[mower_id]) + ) + for mower_id in mower_ids: + mower_data = coordinator.data[mower_id] + if mower_data.capabilities.work_areas and mower_data.work_areas is not None: + _async_add_new_work_areas( + mower_id, + set(mower_data.work_areas.keys()), + ) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) + coordinator.new_areas_callbacks.append(_async_add_new_work_areas) class AutomowerSensorEntity(AutomowerBaseEntity, SensorEntity): diff --git a/homeassistant/components/husqvarna_automower/switch.py b/homeassistant/components/husqvarna_automower/switch.py index 352b4c59ba1..b8004e17066 100644 --- a/homeassistant/components/husqvarna_automower/switch.py +++ b/homeassistant/components/husqvarna_automower/switch.py @@ -7,7 +7,6 @@ from aioautomower.model import MowerModes, StayOutZones, Zone from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AutomowerConfigEntry @@ -31,82 +30,63 @@ async def async_setup_entry( ) -> None: """Set up switch platform.""" coordinator = entry.runtime_data - current_work_areas: dict[str, set[int]] = {} - current_stay_out_zones: dict[str, set[str]] = {} - - async_add_entities( + entities: list[SwitchEntity] = [] + entities.extend( AutomowerScheduleSwitchEntity(mower_id, coordinator) for mower_id in coordinator.data ) - - def _async_work_area_listener() -> None: - """Listen for new work areas and add switch entities if they did not exist. - - Listening for deletable work areas is managed in the number platform. - """ - for mower_id in coordinator.data: - if ( - coordinator.data[mower_id].capabilities.work_areas - and (_work_areas := coordinator.data[mower_id].work_areas) is not None - ): - received_work_areas = set(_work_areas.keys()) - new_work_areas = received_work_areas - current_work_areas.get( - mower_id, set() + for mower_id in coordinator.data: + if coordinator.data[mower_id].capabilities.stay_out_zones: + _stay_out_zones = coordinator.data[mower_id].stay_out_zones + if _stay_out_zones is not None: + entities.extend( + StayOutZoneSwitchEntity(coordinator, mower_id, stay_out_zone_uid) + for stay_out_zone_uid in _stay_out_zones.zones ) - if new_work_areas: - current_work_areas.setdefault(mower_id, set()).update( - new_work_areas - ) - async_add_entities( - WorkAreaSwitchEntity(coordinator, mower_id, work_area_id) - for work_area_id in new_work_areas - ) + if coordinator.data[mower_id].capabilities.work_areas: + _work_areas = coordinator.data[mower_id].work_areas + if _work_areas is not None: + entities.extend( + WorkAreaSwitchEntity(coordinator, mower_id, work_area_id) + for work_area_id in _work_areas + ) + async_add_entities(entities) - def _remove_stay_out_zone_entities( - removed_stay_out_zones: set, mower_id: str + def _async_add_new_stay_out_zones( + mower_id: str, stay_out_zone_uids: set[str] ) -> None: - """Remove all unused stay-out zones for all platforms.""" - entity_reg = er.async_get(hass) - for entity_entry in er.async_entries_for_config_entry( - entity_reg, entry.entry_id - ): - for stay_out_zone_uid in removed_stay_out_zones: - if entity_entry.unique_id.startswith(f"{mower_id}_{stay_out_zone_uid}"): - entity_reg.async_remove(entity_entry.entity_id) + async_add_entities( + StayOutZoneSwitchEntity(coordinator, mower_id, zone_uid) + for zone_uid in stay_out_zone_uids + ) - def _async_stay_out_zone_listener() -> None: - """Listen for new stay-out zones and add/remove switch entities if they did not exist.""" - for mower_id in coordinator.data: + def _async_add_new_work_areas(mower_id: str, work_area_ids: set[int]) -> None: + async_add_entities( + WorkAreaSwitchEntity(coordinator, mower_id, work_area_id) + for work_area_id in work_area_ids + ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerScheduleSwitchEntity(mower_id, coordinator) + for mower_id in mower_ids + ) + for mower_id in mower_ids: + mower_data = coordinator.data[mower_id] if ( - coordinator.data[mower_id].capabilities.stay_out_zones - and (_stay_out_zones := coordinator.data[mower_id].stay_out_zones) - is not None + mower_data.capabilities.stay_out_zones + and mower_data.stay_out_zones is not None + and mower_data.stay_out_zones.zones is not None ): - received_stay_out_zones = set(_stay_out_zones.zones) - current_stay_out_zones_set = current_stay_out_zones.get(mower_id, set()) - new_stay_out_zones = ( - received_stay_out_zones - current_stay_out_zones_set + _async_add_new_stay_out_zones( + mower_id, set(mower_data.stay_out_zones.zones.keys()) ) - removed_stay_out_zones = ( - current_stay_out_zones_set - received_stay_out_zones - ) - if new_stay_out_zones: - current_stay_out_zones.setdefault(mower_id, set()).update( - new_stay_out_zones - ) - async_add_entities( - StayOutZoneSwitchEntity( - coordinator, mower_id, stay_out_zone_uid - ) - for stay_out_zone_uid in new_stay_out_zones - ) - if removed_stay_out_zones: - _remove_stay_out_zone_entities(removed_stay_out_zones, mower_id) + if mower_data.capabilities.work_areas and mower_data.work_areas is not None: + _async_add_new_work_areas(mower_id, set(mower_data.work_areas.keys())) - coordinator.async_add_listener(_async_work_area_listener) - coordinator.async_add_listener(_async_stay_out_zone_listener) - _async_work_area_listener() - _async_stay_out_zone_listener() + coordinator.new_devices_callbacks.append(_async_add_new_devices) + coordinator.new_zones_callbacks.append(_async_add_new_stay_out_zones) + coordinator.new_areas_callbacks.append(_async_add_new_work_areas) class AutomowerScheduleSwitchEntity(AutomowerControlEntity, SwitchEntity): diff --git a/tests/components/husqvarna_automower/conftest.py b/tests/components/husqvarna_automower/conftest.py index 0202cec05b9..49994e4f3ae 100644 --- a/tests/components/husqvarna_automower/conftest.py +++ b/tests/components/husqvarna_automower/conftest.py @@ -58,6 +58,15 @@ def mock_values(mower_time_zone) -> dict[str, MowerAttributes]: ) +@pytest.fixture(name="values_one_mower") +def mock_values_one_mower(mower_time_zone) -> dict[str, MowerAttributes]: + """Fixture to set correct scope for the token.""" + return mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower1.json", DOMAIN), + mower_time_zone, + ) + + @pytest.fixture def mock_config_entry(jwt: str, expires_at: int, scope: str) -> MockConfigEntry: """Return the default mocked config entry.""" @@ -119,3 +128,26 @@ def mock_automower_client(values) -> Generator[AsyncMock]: return_value=mock, ): yield mock + + +@pytest.fixture +def mock_automower_client_one_mower(values) -> Generator[AsyncMock]: + """Mock a Husqvarna Automower client.""" + + async def listen() -> None: + """Mock listen.""" + listen_block = asyncio.Event() + await listen_block.wait() + pytest.fail("Listen was not cancelled!") + + mock = AsyncMock(spec=AutomowerSession) + mock.auth = AsyncMock(side_effect=ClientWebSocketResponse) + mock.commands = AsyncMock(spec_set=_MowerCommands) + mock.get_status.return_value = values + mock.start_listening = AsyncMock(side_effect=listen) + + with patch( + "homeassistant.components.husqvarna_automower.AutomowerSession", + return_value=mock, + ): + yield mock diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index ae688571d2c..627cd065e79 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -227,32 +227,79 @@ async def test_coordinator_automatic_registry_cleanup( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, values: dict[str, MowerAttributes], + freezer: FrozenDateTimeFactory, ) -> None: """Test automatic registry cleanup.""" await setup_integration(hass, mock_config_entry) entry = hass.config_entries.async_entries(DOMAIN)[0] await hass.async_block_till_done() + # Count current entitties and devices current_entites = len( er.async_entries_for_config_entry(entity_registry, entry.entry_id) ) current_devices = len( dr.async_entries_for_config_entry(device_registry, entry.entry_id) ) - - values.pop(TEST_MOWER_ID) + # Remove mower 2 and check if it worked + mower2 = values.pop("1234") mock_automower_client.get_status.return_value = values - await hass.config_entries.async_reload(mock_config_entry.entry_id) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ( len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) - == current_entites - 37 + == current_entites - 12 ) assert ( len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == current_devices - 1 ) + # Add mower 2 and check if it worked + values["1234"] = mower2 + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) + == current_entites + ) + assert ( + len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) + == current_devices + ) + + # Remove mower 1 and check if it worked + mower1 = values.pop(TEST_MOWER_ID) + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 12 + assert ( + len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) + == current_devices - 1 + ) + # Add mower 1 and check if it worked + values[TEST_MOWER_ID] = mower1 + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) + == current_devices + ) + assert ( + len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) + == current_entites + ) async def test_add_and_remove_work_area( From 65df8b946fff46e6869779025f2f3c9c68df4daf Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 15 Jan 2025 17:32:46 +1000 Subject: [PATCH 0438/2987] Update buttons in Teslemetry (#135631) * Update button * tests --- homeassistant/components/teslemetry/button.py | 8 +++----- tests/components/teslemetry/test_button.py | 1 + 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py index a9bf3eddd6a..ecdcd016221 100644 --- a/homeassistant/components/teslemetry/button.py +++ b/homeassistant/components/teslemetry/button.py @@ -24,11 +24,11 @@ PARALLEL_UPDATES = 0 class TeslemetryButtonEntityDescription(ButtonEntityDescription): """Describes a Teslemetry Button entity.""" - func: Callable[[TeslemetryButtonEntity], Awaitable[Any]] | None = None + func: Callable[[TeslemetryButtonEntity], Awaitable[Any]] DESCRIPTIONS: tuple[TeslemetryButtonEntityDescription, ...] = ( - TeslemetryButtonEntityDescription(key="wake"), # Every button runs wakeup + TeslemetryButtonEntityDescription(key="wake", func=lambda self: self.api.wake_up()), TeslemetryButtonEntityDescription( key="flash_lights", func=lambda self: self.api.flash_lights() ), @@ -85,6 +85,4 @@ class TeslemetryButtonEntity(TeslemetryVehicleEntity, ButtonEntity): async def async_press(self) -> None: """Press the button.""" - await self.wake_up_if_asleep() - if self.entity_description.func: - await handle_vehicle_command(self.entity_description.func(self)) + await handle_vehicle_command(self.entity_description.func(self)) diff --git a/tests/components/teslemetry/test_button.py b/tests/components/teslemetry/test_button.py index 04edf668765..75f94342f1e 100644 --- a/tests/components/teslemetry/test_button.py +++ b/tests/components/teslemetry/test_button.py @@ -29,6 +29,7 @@ async def test_button( @pytest.mark.parametrize( ("name", "func"), [ + ("wake", "wake_up"), ("flash_lights", "flash_lights"), ("honk_horn", "honk_horn"), ("keyless_driving", "remote_start_drive"), From 6cbbfec5f536cc03ca84433441a21e4d94d5c47b Mon Sep 17 00:00:00 2001 From: TimL Date: Wed, 15 Jan 2025 18:56:01 +1100 Subject: [PATCH 0439/2987] Reduce scan interval on SMLIGHT firmware updates (#135650) Reduce scan interval on firmware updates --- homeassistant/components/smlight/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smlight/const.py b/homeassistant/components/smlight/const.py index 669094b2441..0a45363f8ad 100644 --- a/homeassistant/components/smlight/const.py +++ b/homeassistant/components/smlight/const.py @@ -9,7 +9,7 @@ ATTR_MANUFACTURER = "SMLIGHT" DATA_COORDINATOR = "data" FIRMWARE_COORDINATOR = "firmware" -SCAN_FIRMWARE_INTERVAL = timedelta(hours=6) +SCAN_FIRMWARE_INTERVAL = timedelta(hours=24) LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(seconds=300) SCAN_INTERNET_INTERVAL = timedelta(minutes=15) From 23a2b19ca0fdf5dce47eccf359d9f1c6af6debcd Mon Sep 17 00:00:00 2001 From: TimL Date: Wed, 15 Jan 2025 18:58:38 +1100 Subject: [PATCH 0440/2987] Bump pysmlight v0.1.5 (#135647) --- homeassistant/components/smlight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index cb791ac111b..6518cc81989 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.1.4"], + "requirements": ["pysmlight==0.1.5"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 8fe65b12c21..a00cd999360 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2303,7 +2303,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.4 +pysmlight==0.1.5 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5417c60642b..80951ad9d84 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1875,7 +1875,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.4 +pysmlight==0.1.5 # homeassistant.components.snmp pysnmp==6.2.6 From f57640c2cdf07efaa495e61931903a964a040713 Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Wed, 15 Jan 2025 09:31:48 +0100 Subject: [PATCH 0441/2987] Bump homematicip to 1.1.6 (#135649) --- homeassistant/components/homematicip_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index a44d0586952..6fc422498ab 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==1.1.5"] + "requirements": ["homematicip==1.1.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index a00cd999360..e12f151b455 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1149,7 +1149,7 @@ home-assistant-intents==2025.1.1 homeconnect==0.8.0 # homeassistant.components.homematicip_cloud -homematicip==1.1.5 +homematicip==1.1.6 # homeassistant.components.horizon horimote==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80951ad9d84..d4f66f4ab3e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -978,7 +978,7 @@ home-assistant-intents==2025.1.1 homeconnect==0.8.0 # homeassistant.components.homematicip_cloud -homematicip==1.1.5 +homematicip==1.1.6 # homeassistant.components.remember_the_milk httplib2==0.20.4 From 8a35261fd8216ad3a66107aa29742793af70a511 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 15 Jan 2025 10:02:18 +0100 Subject: [PATCH 0442/2987] Remove unused noqas (#135583) --- homeassistant/auth/auth_store.py | 2 +- homeassistant/block_async_io.py | 2 +- .../components/bluetooth/active_update_coordinator.py | 2 +- .../components/bluetooth/active_update_processor.py | 2 +- homeassistant/components/camera/__init__.py | 2 +- homeassistant/components/cloud/account_link.py | 2 +- homeassistant/components/cover/__init__.py | 2 +- homeassistant/components/deluge/__init__.py | 2 +- homeassistant/components/emulated_hue/hue_api.py | 2 +- homeassistant/components/fritz/coordinator.py | 2 +- homeassistant/components/geniushub/config_flow.py | 4 ++-- homeassistant/components/gtfs/sensor.py | 2 +- homeassistant/components/hassio/__init__.py | 2 +- homeassistant/components/homematicip_cloud/sensor.py | 2 +- homeassistant/components/letpot/config_flow.py | 2 +- homeassistant/components/lifx/coordinator.py | 4 ++-- homeassistant/components/light/__init__.py | 2 +- homeassistant/components/matter/__init__.py | 2 +- homeassistant/components/media_player/__init__.py | 2 +- homeassistant/components/mqtt/discovery.py | 2 +- homeassistant/components/mqtt/light/schema_basic.py | 2 +- homeassistant/components/nice_go/config_flow.py | 4 ++-- homeassistant/components/plugwise/config_flow.py | 2 +- homeassistant/components/slide_local/config_flow.py | 4 ++-- homeassistant/components/teslemetry/services.py | 2 +- homeassistant/components/vacuum/__init__.py | 2 +- homeassistant/components/websocket_api/connection.py | 6 +++--- homeassistant/components/websocket_api/http.py | 4 ++-- homeassistant/components/xiaomi_miio/select.py | 4 ++-- homeassistant/components/zha/helpers.py | 2 +- homeassistant/helpers/check_config.py | 2 +- homeassistant/helpers/config_validation.py | 6 +----- homeassistant/helpers/entity.py | 2 +- homeassistant/helpers/json.py | 2 +- homeassistant/helpers/service.py | 2 +- homeassistant/util/json.py | 8 ++++---- script/hassfest/mypy_config.py | 2 +- script/hassfest/translations.py | 2 +- tests/components/point/test_config_flow.py | 2 +- tests/components/sensor/test_init.py | 4 ++-- tests/conftest.py | 2 +- tests/patch_recorder.py | 2 +- tests/test_block_async_io.py | 8 ++++---- 43 files changed, 58 insertions(+), 62 deletions(-) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index fc47a7d71e9..1c2e8b0dfab 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -308,7 +308,7 @@ class AuthStore: credentials.data = data self._async_schedule_save() - async def async_load(self) -> None: # noqa: C901 + async def async_load(self) -> None: """Load the users.""" if self._loaded: raise RuntimeError("Auth storage is already loaded") diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py index 767716dbe27..d224b0b151d 100644 --- a/homeassistant/block_async_io.py +++ b/homeassistant/block_async_io.py @@ -31,7 +31,7 @@ def _check_import_call_allowed(mapped_args: dict[str, Any]) -> bool: def _check_file_allowed(mapped_args: dict[str, Any]) -> bool: # If the file is in /proc we can ignore it. args = mapped_args["args"] - path = args[0] if type(args[0]) is str else str(args[0]) # noqa: E721 + path = args[0] if type(args[0]) is str else str(args[0]) return path.startswith(ALLOWED_FILE_PREFIXES) diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index 7c3d1bc3620..03c278d6b0d 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -132,7 +132,7 @@ class ActiveBluetoothDataUpdateCoordinator[_T](PassiveBluetoothDataUpdateCoordin ) self.last_poll_successful = False return - except Exception: # noqa: BLE001 + except Exception: if self.last_poll_successful: self.logger.exception("%s: Failure while polling", self.address) self.last_poll_successful = False diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index e7b65067070..8a23de682e6 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -127,7 +127,7 @@ class ActiveBluetoothProcessorCoordinator[_DataT]( ) self.last_poll_successful = False return - except Exception: # noqa: BLE001 + except Exception: if self.last_poll_successful: self.logger.exception("%s: Failure while polling", self.address) self.last_poll_successful = False diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 4d718433fca..16b9fb06dbb 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -523,7 +523,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): Remove this compatibility shim in 2025.1 or later. """ features = self.supported_features - if type(features) is int: # noqa: E721 + if type(features) is int: new_features = CameraEntityFeature(features) self._report_deprecated_supported_features_values(new_features) return new_features diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index b67c1afad71..851d658f8e0 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -65,7 +65,7 @@ async def _get_services(hass: HomeAssistant) -> list[dict[str, Any]]: services: list[dict[str, Any]] if DATA_SERVICES in hass.data: services = hass.data[DATA_SERVICES] - return services # noqa: RET504 + return services try: services = await account_link.async_fetch_available_services( diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 001bff51991..c4795e0e7d9 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -300,7 +300,7 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" if (features := self._attr_supported_features) is not None: - if type(features) is int: # noqa: E721 + if type(features) is int: new_features = CoverEntityFeature(features) self._report_deprecated_supported_features_values(new_features) return new_features diff --git a/homeassistant/components/deluge/__init__.py b/homeassistant/components/deluge/__init__.py index f4608b37006..9b07ae9c875 100644 --- a/homeassistant/components/deluge/__init__.py +++ b/homeassistant/components/deluge/__init__.py @@ -41,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DelugeConfigEntry) -> bo await hass.async_add_executor_job(api.connect) except (ConnectionRefusedError, TimeoutError, SSLError) as ex: raise ConfigEntryNotReady("Connection to Deluge Daemon failed") from ex - except Exception as ex: # noqa: BLE001 + except Exception as ex: if type(ex).__name__ == "BadLoginError": raise ConfigEntryAuthFailed( "Credentials for Deluge client are not valid" diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index e13112f20bb..464d2bcb7e7 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -865,7 +865,7 @@ def state_supports_hue_brightness( return False features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) enum = ENTITY_FEATURES_BY_DOMAIN[domain] - features = enum(features) if type(features) is int else features # noqa: E721 + features = enum(features) if type(features) is int else features return required_feature in features diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 272295cd512..52bff67c229 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -441,7 +441,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): hosts_info = await self.hass.async_add_executor_job( self.fritz_hosts.get_hosts_info ) - except Exception as ex: # noqa: BLE001 + except Exception as ex: if not self.hass.is_stopping: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/geniushub/config_flow.py b/homeassistant/components/geniushub/config_flow.py index b106f9907bb..18f50593dca 100644 --- a/homeassistant/components/geniushub/config_flow.py +++ b/homeassistant/components/geniushub/config_flow.py @@ -78,7 +78,7 @@ class GeniusHubConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_host" except (TimeoutError, aiohttp.ClientConnectionError): errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -113,7 +113,7 @@ class GeniusHubConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_host" except (TimeoutError, aiohttp.ClientConnectionError): errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index fbc65050704..f9e9c31ce46 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -342,7 +342,7 @@ def get_next_departure( {tomorrow_order} origin_stop_time.departure_time LIMIT :limit - """ # noqa: S608 + """ result = schedule.engine.connect().execute( text(sql_query), { diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index fec84737e78..b95f520b9e0 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -115,7 +115,7 @@ from .coordinator import ( get_supervisor_info, # noqa: F401 get_supervisor_stats, # noqa: F401 ) -from .discovery import async_setup_discovery_view # noqa: F401 +from .discovery import async_setup_discovery_view from .handler import ( # noqa: F401 HassIO, HassioAPIError, diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index c44d280c190..9ed9b33d7c7 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -93,7 +93,7 @@ ILLUMINATION_DEVICE_ATTRIBUTES = { } -async def async_setup_entry( # noqa: C901 +async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, diff --git a/homeassistant/components/letpot/config_flow.py b/homeassistant/components/letpot/config_flow.py index 7f2f3be1e32..fac78e440db 100644 --- a/homeassistant/components/letpot/config_flow.py +++ b/homeassistant/components/letpot/config_flow.py @@ -78,7 +78,7 @@ class LetPotConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except LetPotAuthenticationException: errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index 41fa04057f7..5558828a143 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -83,7 +83,7 @@ class SkyType(IntEnum): CLOUDS = 2 -class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): # noqa: PLR0904 +class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): """DataUpdateCoordinator to gather data for a specific lifx device.""" def __init__( @@ -456,7 +456,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): # noqa: PLR0904 ) self.active_effect = FirmwareEffect[effect.upper()] - async def async_set_matrix_effect( # noqa: PLR0917 + async def async_set_matrix_effect( self, effect: str, palette: list[tuple[int, int, int, int]] | None = None, diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 76fbea70322..412ee1e6c16 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -1388,7 +1388,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): Remove this compatibility shim in 2025.1 or later. """ features = self.supported_features - if type(features) is not int: # noqa: E721 + if type(features) is not int: return features new_features = LightEntityFeature(features) if self._deprecated_supported_features_reported is True: diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index e751387d7e8..e3e30fb704b 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -178,7 +178,7 @@ async def _client_listen( if entry.state != ConfigEntryState.LOADED: raise LOGGER.error("Failed to listen: %s", err) - except Exception as err: # noqa: BLE001 + except Exception as err: # We need to guard against unknown exceptions to not crash this task. LOGGER.exception("Unexpected exception: %s", err) if entry.state != ConfigEntryState.LOADED: diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 291b1ec1e2a..b82cab401c5 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -780,7 +780,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): Remove this compatibility shim in 2025.1 or later. """ features = self.supported_features - if type(features) is int: # noqa: E721 + if type(features) is int: new_features = MediaPlayerEntityFeature(features) self._report_deprecated_supported_features_values(new_features) return new_features diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index a5af8430629..21d250db29a 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -386,7 +386,7 @@ async def async_start( # noqa: C901 _async_add_component(discovery_payload) @callback - def async_discovery_message_received(msg: ReceiveMessage) -> None: # noqa: C901 + def async_discovery_message_received(msg: ReceiveMessage) -> None: """Process the received message.""" mqtt_data.last_discovery = msg.timestamp payload = msg.payload diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 3234e9a2986..eaaa80af223 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -587,7 +587,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): self._attr_xy_color = cast(tuple[float, float], xy_color) @callback - def _prepare_subscribe_topics(self) -> None: # noqa: C901 + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" self.add_subscription(CONF_STATE_TOPIC, self._state_received, {"_attr_is_on"}) self.add_subscription( diff --git a/homeassistant/components/nice_go/config_flow.py b/homeassistant/components/nice_go/config_flow.py index da3940117e9..291d4221d6c 100644 --- a/homeassistant/components/nice_go/config_flow.py +++ b/homeassistant/components/nice_go/config_flow.py @@ -50,7 +50,7 @@ class NiceGOConfigFlow(ConfigFlow, domain=DOMAIN): ) except AuthFailedError: errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -92,7 +92,7 @@ class NiceGOConfigFlow(ConfigFlow, domain=DOMAIN): ) except AuthFailedError: errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index 6114dd39a6d..1c97870cb75 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -105,7 +105,7 @@ async def verify_connection( errors[CONF_BASE] = "response_error" except UnsupportedDeviceError: errors[CONF_BASE] = "unsupported" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception( "Unknown exception while verifying connection with your Plugwise Smile" ) diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py index a4255f0769f..7cf3f39b758 100644 --- a/homeassistant/components/slide_local/config_flow.py +++ b/homeassistant/components/slide_local/config_flow.py @@ -63,7 +63,7 @@ class SlideConfigFlow(ConfigFlow, domain=DOMAIN): return {"base": "cannot_connect"} except (AuthenticationFailed, DigestAuthCalcError): return {"base": "invalid_auth"} - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Exception occurred during connection test") return {"base": "unknown"} @@ -85,7 +85,7 @@ class SlideConfigFlow(ConfigFlow, domain=DOMAIN): return {"base": "cannot_connect"} except (AuthenticationFailed, DigestAuthCalcError): return {"base": "invalid_auth"} - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Exception occurred during connection test") return {"base": "unknown"} diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index 97cfffa1699..8215adb5711 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -98,7 +98,7 @@ def async_get_energy_site_for_entry( return energy_data -def async_register_services(hass: HomeAssistant) -> None: # noqa: C901 +def async_register_services(hass: HomeAssistant) -> None: """Set up the Teslemetry services.""" async def navigate_gps_request(call: ServiceCall) -> None: diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 6fe2c3e2a5b..0cafda82786 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -376,7 +376,7 @@ class StateVacuumEntity( Remove this compatibility shim in 2025.1 or later. """ features = self.supported_features - if type(features) is int: # noqa: E721 + if type(features) is int: new_features = VacuumEntityFeature(features) self._report_deprecated_supported_features_values(new_features) return new_features diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 62f1adc39b9..817444a970b 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -189,13 +189,13 @@ class ActiveConnection: if ( # Not using isinstance as we don't care about children # as these are always coming from JSON - type(msg) is not dict # noqa: E721 + type(msg) is not dict or ( not (cur_id := msg.get("id")) - or type(cur_id) is not int # noqa: E721 + or type(cur_id) is not int or cur_id < 0 or not (type_ := msg.get("type")) - or type(type_) is not str # noqa: E721 + or type(type_) is not str ) ): self.logger.error("Received invalid command: %s", msg) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index aa2e8b547c9..b718d8e28c8 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -197,7 +197,7 @@ class WebSocketHandler: # max pending messages. return - if type(message) is not bytes: # noqa: E721 + if type(message) is not bytes: if isinstance(message, dict): message = message_to_json_bytes(message) elif isinstance(message, str): @@ -490,7 +490,7 @@ class WebSocketHandler: ) # command_msg_data is always deserialized from JSON as a list - if type(command_msg_data) is not list: # noqa: E721 + if type(command_msg_data) is not list: async_handle_str(command_msg_data) continue diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index eb0d6bca205..6729ce2e0f4 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -260,10 +260,10 @@ class XiaomiGenericSelector(XiaomiSelector): if description.options_map: self._options_map = {} - for key, val in enum_class._member_map_.items(): # noqa: SLF001 + for key, val in enum_class._member_map_.items(): self._options_map[description.options_map[key]] = val else: - self._options_map = enum_class._member_map_ # noqa: SLF001 + self._options_map = enum_class._member_map_ self._reverse_map = {val: key for key, val in self._options_map.items()} self._enum_class = enum_class diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 2440e18cf53..c31627d3dc3 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -1170,7 +1170,7 @@ def async_add_entities( # broad exception to prevent a single entity from preventing an entire platform from loading # this can potentially be caused by a misbehaving device or a bad quirk. Not ideal but the # alternative is adding try/catch to each entity class __init__ method with a specific exception - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception( "Error while adding entity from entity data: %s", entity_data ) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 4b5e2f277a0..a8e8fa4160d 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -220,7 +220,7 @@ async def async_check_ha_config_file( # noqa: C901 except (vol.Invalid, HomeAssistantError) as ex: _comp_error(ex, domain, config, config[domain]) continue - except Exception as err: # noqa: BLE001 + except Exception as err: logging.getLogger(__name__).exception( "Unexpected error validating config" ) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index b4655289469..2c8dbe69c22 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -674,11 +674,7 @@ def string(value: Any) -> str: raise vol.Invalid("string value is None") # This is expected to be the most common case, so check it first. - if ( - type(value) is str # noqa: E721 - or type(value) is NodeStrClass - or isinstance(value, str) - ): + if type(value) is str or type(value) is NodeStrClass or isinstance(value, str): return value if isinstance(value, template_helper.ResultWrapper): diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 16dee75cd23..9e8fe40c6b0 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1028,7 +1028,7 @@ class Entity( return STATE_UNAVAILABLE if (state := self.state) is None: return STATE_UNKNOWN - if type(state) is str: # noqa: E721 + if type(state) is str: # fast path for strings return state if isinstance(state, float): diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index ebb74856429..a97dd48bf61 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Any, Final import orjson from homeassistant.util.file import write_utf8_file, write_utf8_file_atomic -from homeassistant.util.json import ( # noqa: F401 +from homeassistant.util.json import ( JSON_DECODE_EXCEPTIONS as _JSON_DECODE_EXCEPTIONS, JSON_ENCODE_EXCEPTIONS as _JSON_ENCODE_EXCEPTIONS, SerializationError, diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index e28e8aed105..8e9754ccb4d 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -502,7 +502,7 @@ def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]: @bind_hass -def async_extract_referenced_entity_ids( # noqa: C901 +def async_extract_referenced_entity_ids( hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True ) -> SelectedEntities: """Extract referenced entity IDs from a service call.""" diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 968567ae0c9..a935d44d585 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -46,7 +46,7 @@ def json_loads_array(obj: bytes | bytearray | memoryview | str, /) -> JsonArrayT """Parse JSON data and ensure result is a list.""" value: JsonValueType = json_loads(obj) # Avoid isinstance overhead as we are not interested in list subclasses - if type(value) is list: # noqa: E721 + if type(value) is list: return value raise ValueError(f"Expected JSON to be parsed as a list got {type(value)}") @@ -55,7 +55,7 @@ def json_loads_object(obj: bytes | bytearray | memoryview | str, /) -> JsonObjec """Parse JSON data and ensure result is a dictionary.""" value: JsonValueType = json_loads(obj) # Avoid isinstance overhead as we are not interested in dict subclasses - if type(value) is dict: # noqa: E721 + if type(value) is dict: return value raise ValueError(f"Expected JSON to be parsed as a dict got {type(value)}") @@ -95,7 +95,7 @@ def load_json_array( default = [] value: JsonValueType = load_json(filename, default=default) # Avoid isinstance overhead as we are not interested in list subclasses - if type(value) is list: # noqa: E721 + if type(value) is list: return value _LOGGER.exception( "Expected JSON to be parsed as a list got %s in: %s", {type(value)}, filename @@ -115,7 +115,7 @@ def load_json_object( default = {} value: JsonValueType = load_json(filename, default=default) # Avoid isinstance overhead as we are not interested in dict subclasses - if type(value) is dict: # noqa: E721 + if type(value) is dict: return value _LOGGER.exception( "Expected JSON to be parsed as a dict got %s in: %s", {type(value)}, filename diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 1d7f2b5ed88..ac27df85ccc 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -41,7 +41,7 @@ GENERAL_SETTINGS: Final[dict[str, str]] = { ), "show_error_codes": "true", "follow_imports": "normal", - # "enable_incomplete_feature": ", ".join( # noqa: FLY002 + # "enable_incomplete_feature": ", ".join( # [] # ), # Enable some checks globally. diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 6acff1633c1..b3d397dbd55 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -454,7 +454,7 @@ ONBOARDING_SCHEMA = vol.Schema( ) -def validate_translation_file( # noqa: C901 +def validate_translation_file( config: Config, integration: Integration, all_strings: dict[str, Any] | None, diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py index bd1e3cfac29..ea003af86c7 100644 --- a/tests/components/point/test_config_flow.py +++ b/tests/components/point/test_config_flow.py @@ -47,7 +47,7 @@ async def test_full_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - state = config_entry_oauth2_flow._encode_jwt( # noqa: SLF001 + state = config_entry_oauth2_flow._encode_jwt( hass, { "flow_id": result["flow_id"], diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 0ea46a41273..58c61715c72 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -570,7 +570,7 @@ async def test_unit_translation_key_without_platform_raises( match="cannot have a translation key for unit of measurement before " "being added to the entity platform", ): - unit = entity0.unit_of_measurement # noqa: F841 + unit = entity0.unit_of_measurement setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) @@ -580,7 +580,7 @@ async def test_unit_translation_key_without_platform_raises( await hass.async_block_till_done() # Should not raise after being added to the platform - unit = entity0.unit_of_measurement # noqa: F841 + unit = entity0.unit_of_measurement assert unit == "Tests" diff --git a/tests/conftest.py b/tests/conftest.py index 24c4b0ceb37..83409792f5a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,7 +43,7 @@ from homeassistant import block_async_io from homeassistant.exceptions import ServiceNotFound # Setup patching of recorder functions before any other Home Assistant imports -from . import patch_recorder # noqa: F401, isort:skip +from . import patch_recorder # Setup patching of dt_util time functions before any other Home Assistant imports from . import patch_time # noqa: F401, isort:skip diff --git a/tests/patch_recorder.py b/tests/patch_recorder.py index 4993e84fc30..e0e66de19a5 100644 --- a/tests/patch_recorder.py +++ b/tests/patch_recorder.py @@ -6,7 +6,7 @@ from contextlib import contextmanager import sys # Patch recorder util session scope -from homeassistant.helpers import recorder as recorder_helper # noqa: E402 +from homeassistant.helpers import recorder as recorder_helper # Make sure homeassistant.components.recorder.util is not already imported assert "homeassistant.components.recorder.util" not in sys.modules diff --git a/tests/test_block_async_io.py b/tests/test_block_async_io.py index dd23d4e9709..f42fbb9f4ef 100644 --- a/tests/test_block_async_io.py +++ b/tests/test_block_async_io.py @@ -261,7 +261,7 @@ async def test_protect_path_read_bytes(caplog: pytest.LogCaptureFixture) -> None block_async_io.enable() with ( contextlib.suppress(FileNotFoundError), - Path("/config/data_not_exist").read_bytes(), # noqa: ASYNC230 + Path("/config/data_not_exist").read_bytes(), ): pass @@ -274,7 +274,7 @@ async def test_protect_path_read_text(caplog: pytest.LogCaptureFixture) -> None: block_async_io.enable() with ( contextlib.suppress(FileNotFoundError), - Path("/config/data_not_exist").read_text(encoding="utf8"), # noqa: ASYNC230 + Path("/config/data_not_exist").read_text(encoding="utf8"), ): pass @@ -287,7 +287,7 @@ async def test_protect_path_write_bytes(caplog: pytest.LogCaptureFixture) -> Non block_async_io.enable() with ( contextlib.suppress(FileNotFoundError), - Path("/config/data/not/exist").write_bytes(b"xxx"), # noqa: ASYNC230 + Path("/config/data/not/exist").write_bytes(b"xxx"), ): pass @@ -300,7 +300,7 @@ async def test_protect_path_write_text(caplog: pytest.LogCaptureFixture) -> None block_async_io.enable() with ( contextlib.suppress(FileNotFoundError), - Path("/config/data/not/exist").write_text("xxx", encoding="utf8"), # noqa: ASYNC230 + Path("/config/data/not/exist").write_text("xxx", encoding="utf8"), ): pass From f0257fec887585ed0b4a6c2274c77f60728e0cea Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 15 Jan 2025 10:13:27 +0100 Subject: [PATCH 0443/2987] Fix mqtt number state validation (#135621) --- homeassistant/components/mqtt/number.py | 6 +- tests/components/mqtt/test_number.py | 96 +++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index a9bf1829b63..9b47a3ad23a 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -179,14 +179,14 @@ class MqttNumber(MqttEntity, RestoreNumber): return if num_value is not None and ( - num_value < self.min_value or num_value > self.max_value + num_value < self.native_min_value or num_value > self.native_max_value ): _LOGGER.error( "Invalid value for %s: %s (range %s - %s)", self.entity_id, num_value, - self.min_value, - self.max_value, + self.native_min_value, + self.native_max_value, ) return diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index 48aaa11f672..7bdd39e81a7 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -29,6 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, State +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .test_common import ( help_custom_config, @@ -157,6 +158,101 @@ async def test_run_number_setup( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "min": 15, + "max": 28, + "device_class": "temperature", + "unit_of_measurement": UnitOfTemperature.CELSIUS.value, + } + } + } + ], +) +async def test_native_value_validation( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test state validation and native value conversion.""" + mqtt_mock = await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test/state_number", "23.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 15 + assert state.attributes.get(ATTR_MAX) == 28 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.CELSIUS.value + ) + assert state.state == "23.5" + + # Test out of range validation + async_fire_mqtt_message(hass, "test/state_number", "29.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 15 + assert state.attributes.get(ATTR_MAX) == 28 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.CELSIUS.value + ) + assert state.state == "23.5" + assert ( + "Invalid value for number.test_number: 29.5 (range 15.0 - 28.0)" in caplog.text + ) + caplog.clear() + + # Check if validation still works when changing unit system + hass.config.units = US_CUSTOMARY_SYSTEM + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "test/state_number", "24.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 59.0 + assert state.attributes.get(ATTR_MAX) == 82.4 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.FAHRENHEIT.value + ) + assert state.state == "76.1" + + # Test out of range validation again + async_fire_mqtt_message(hass, "test/state_number", "29.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 59.0 + assert state.attributes.get(ATTR_MAX) == 82.4 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.FAHRENHEIT.value + ) + assert state.state == "76.1" + assert ( + "Invalid value for number.test_number: 29.5 (range 15.0 - 28.0)" in caplog.text + ) + caplog.clear() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number", ATTR_VALUE: 68}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with("test/cmd_number", "20", 0, False) + mqtt_mock.async_publish.reset_mock() + + @pytest.mark.parametrize( "hass_config", [ From 1421f4c124146b7d237cac21db430708c6929834 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 15 Jan 2025 10:51:41 +0100 Subject: [PATCH 0444/2987] Set MQTT quality scale to platinum (#135612) * Set MQTT quality scale to platinum * Add test for type stub --- homeassistant/components/mqtt/manifest.json | 2 +- homeassistant/components/mqtt/quality_scale.yaml | 7 ++++--- script/hassfest/quality_scale_validation/strict_typing.py | 6 +++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json index 2e5b19b49a9..25e98c01aaf 100644 --- a/homeassistant/components/mqtt/manifest.json +++ b/homeassistant/components/mqtt/manifest.json @@ -7,7 +7,7 @@ "dependencies": ["file_upload", "http"], "documentation": "https://www.home-assistant.io/integrations/mqtt", "iot_class": "local_push", - "quality_scale": "gold", + "quality_scale": "platinum", "requirements": ["paho-mqtt==1.6.1"], "single_config_entry": true } diff --git a/homeassistant/components/mqtt/quality_scale.yaml b/homeassistant/components/mqtt/quality_scale.yaml index c178147bf71..b17812acd91 100644 --- a/homeassistant/components/mqtt/quality_scale.yaml +++ b/homeassistant/components/mqtt/quality_scale.yaml @@ -123,6 +123,7 @@ rules: comment: | This integration does not use web sessions. strict-typing: - status: todo - comment: | - Requirement 'paho-mqtt==1.6.1' appears untyped + status: done + comment: > + Typing for 'paho-mqtt==1.6.1' supported via 'types-paho-mqtt==1.6.0.20240321' + (requirements_test.txt). diff --git a/script/hassfest/quality_scale_validation/strict_typing.py b/script/hassfest/quality_scale_validation/strict_typing.py index c1373032ff8..1f5a5665835 100644 --- a/script/hassfest/quality_scale_validation/strict_typing.py +++ b/script/hassfest/quality_scale_validation/strict_typing.py @@ -43,7 +43,11 @@ def _check_requirements_are_typed(integration: Integration) -> list[str]: if not any(file for file in distribution.files if file.name == "py.typed"): # no py.typed file - invalid_requirements.append(requirement) + try: + metadata.distribution(f"types-{requirement_name}") + except metadata.PackageNotFoundError: + # also no stubs-only package + invalid_requirements.append(requirement) return invalid_requirements From 650e14379cc434898b92167783f2e855251790a9 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Wed, 15 Jan 2025 21:59:15 +1100 Subject: [PATCH 0445/2987] Bump aiolifx-themes to v0.6.2 (#135645) * Bump aiolifx-themes to v0.6.1 Signed-off-by: Avi Miller * Bump aiolifx-themes to 0.6.2 to fix deps issue with 0.6.1 Signed-off-by: Avi Miller --------- Signed-off-by: Avi Miller --- homeassistant/components/lifx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 9940ee15dca..205435a7b2e 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -53,6 +53,6 @@ "requirements": [ "aiolifx==1.1.2", "aiolifx-effects==0.3.2", - "aiolifx-themes==0.6.0" + "aiolifx-themes==0.6.2" ] } diff --git a/requirements_all.txt b/requirements_all.txt index e12f151b455..21d77d9720a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -285,7 +285,7 @@ aiokef==0.2.16 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.6.0 +aiolifx-themes==0.6.2 # homeassistant.components.lifx aiolifx==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d4f66f4ab3e..55b0ce54b2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -267,7 +267,7 @@ aiokafka==0.10.0 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.6.0 +aiolifx-themes==0.6.2 # homeassistant.components.lifx aiolifx==1.1.2 From b046ca9abe3457876ec5cdf935732ab813f48018 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Jan 2025 12:25:42 +0100 Subject: [PATCH 0446/2987] Move ZeroconfServiceInfo to service_info helpers (#135653) * Move ZeroconfServiceInfo to service_info helpers * Adjust deprecation date * Fix mypy/pylint * Fix DeprecatedConstant * Add deprecation test * Adjust * Also deprecate ATTR_PROPERTIES_ID --- .../components/bang_olufsen/config_flow.py | 2 +- .../components/brother/config_flow.py | 4 +- .../components/eheimdigital/config_flow.py | 2 +- homeassistant/components/elmax/config_flow.py | 2 +- homeassistant/components/kodi/config_flow.py | 4 +- .../components/lektrico/config_flow.py | 2 +- homeassistant/components/loqed/config_flow.py | 2 +- .../components/matter/config_flow.py | 2 +- .../components/plugwise/config_flow.py | 2 +- .../components/shelly/config_flow.py | 2 +- .../components/slide_local/config_flow.py | 2 +- .../components/soundtouch/config_flow.py | 2 +- homeassistant/components/zeroconf/__init__.py | 81 ++++++++----------- .../components/zwave_js/config_flow.py | 2 +- .../components/zwave_me/config_flow.py | 2 +- homeassistant/config_entries.py | 2 +- homeassistant/helpers/config_entry_flow.py | 2 +- .../helpers/service_info/zeroconf.py | 50 ++++++++++++ .../airgradient/test_config_flow.py | 2 +- tests/components/bang_olufsen/const.py | 2 +- .../components/bluesound/test_config_flow.py | 2 +- .../cambridge_audio/test_config_flow.py | 2 +- tests/components/devolo_home_network/const.py | 2 +- .../eheimdigital/test_config_flow.py | 2 +- tests/components/lektrico/conftest.py | 2 +- tests/components/linkplay/test_config_flow.py | 2 +- tests/components/lookin/__init__.py | 2 +- tests/components/matter/test_config_flow.py | 2 +- .../music_assistant/test_config_flow.py | 2 +- tests/components/onewire/test_config_flow.py | 2 +- tests/components/overkiz/test_config_flow.py | 2 +- tests/components/plugwise/test_config_flow.py | 2 +- .../russound_rio/test_config_flow.py | 2 +- .../slide_local/test_config_flow.py | 2 +- .../components/soundtouch/test_config_flow.py | 2 +- tests/components/wyoming/test_config_flow.py | 2 +- tests/components/zeroconf/test_init.py | 43 +++++++++- tests/components/zwave_js/test_config_flow.py | 2 +- 38 files changed, 162 insertions(+), 86 deletions(-) create mode 100644 homeassistant/helpers/service_info/zeroconf.py diff --git a/homeassistant/components/bang_olufsen/config_flow.py b/homeassistant/components/bang_olufsen/config_flow.py index e1c1c7ab538..e776b63b945 100644 --- a/homeassistant/components/bang_olufsen/config_flow.py +++ b/homeassistant/components/bang_olufsen/config_flow.py @@ -10,10 +10,10 @@ from mozart_api.exceptions import ApiException from mozart_api.mozart_client import MozartClient import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.ssl import get_default_context from .const import ( diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py index d9130b96300..f6b3f456056 100644 --- a/homeassistant/components/brother/config_flow.py +++ b/homeassistant/components/brother/config_flow.py @@ -7,12 +7,12 @@ from typing import Any from brother import Brother, SnmpError, UnsupportedModelError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.components.snmp import async_get_snmp_engine from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.network import is_host_valid from .const import DOMAIN, PRINTER_TYPES @@ -83,7 +83,7 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.host = discovery_info.host diff --git a/homeassistant/components/eheimdigital/config_flow.py b/homeassistant/components/eheimdigital/config_flow.py index 6994c6f65b5..c6535608b0c 100644 --- a/homeassistant/components/eheimdigital/config_flow.py +++ b/homeassistant/components/eheimdigital/config_flow.py @@ -10,11 +10,11 @@ from eheimdigital.device import EheimDigitalDevice from eheimdigital.hub import EheimDigitalHub import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py index 09e0bc0d260..b8697552626 100644 --- a/homeassistant/components/elmax/config_flow.py +++ b/homeassistant/components/elmax/config_flow.py @@ -12,9 +12,9 @@ from elmax_api.model.panel import PanelEntry, PanelStatus import httpx import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .common import ( build_direct_ssl_context, diff --git a/homeassistant/components/kodi/config_flow.py b/homeassistant/components/kodi/config_flow.py index f87b94b23fd..0bd51f27ab6 100644 --- a/homeassistant/components/kodi/config_flow.py +++ b/homeassistant/components/kodi/config_flow.py @@ -8,7 +8,6 @@ from typing import Any from pykodi import CannotConnectError, InvalidAuthError, Kodi, get_kodi_connection import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -22,6 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_WS_PORT, @@ -103,7 +103,7 @@ class KodiConfigFlow(ConfigFlow, domain=DOMAIN): self._discovery_name: str | None = None async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self._host = discovery_info.host diff --git a/homeassistant/components/lektrico/config_flow.py b/homeassistant/components/lektrico/config_flow.py index 77f7b60853d..0641749a2b9 100644 --- a/homeassistant/components/lektrico/config_flow.py +++ b/homeassistant/components/lektrico/config_flow.py @@ -7,7 +7,6 @@ from typing import Any from lektricowifi import Device, DeviceConnectionError import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( ATTR_HW_VERSION, @@ -17,6 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index 8c82a7a6964..a3879d0412f 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -11,12 +11,12 @@ from loqedAPI import cloud_loqed, loqed import voluptuous as vol from homeassistant.components import webhook -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN diff --git a/homeassistant/components/matter/config_flow.py b/homeassistant/components/matter/config_flow.py index 6f7505eb61f..0c73ccd4089 100644 --- a/homeassistant/components/matter/config_flow.py +++ b/homeassistant/components/matter/config_flow.py @@ -16,7 +16,6 @@ from homeassistant.components.hassio import ( AddonState, ) from homeassistant.components.onboarding import async_is_onboarded -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant @@ -25,6 +24,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .addon import get_addon_manager from .const import ( diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index 1c97870cb75..a94000934eb 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -16,7 +16,6 @@ from plugwise.exceptions import ( ) import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import ( ATTR_CONFIGURATION_URL, @@ -29,6 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( DEFAULT_PORT, diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 55686464637..f53da8bd766 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -17,7 +17,6 @@ from aioshelly.exceptions import ( from aioshelly.rpc_device import RpcDevice import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -34,6 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_BLE_SCANNER_MODE, diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py index 7cf3f39b758..4ceb347568f 100644 --- a/homeassistant/components/slide_local/config_flow.py +++ b/homeassistant/components/slide_local/config_flow.py @@ -14,11 +14,11 @@ from goslideapi.goslideapi import ( ) import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_MAC, CONF_PASSWORD from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import SlideConfigEntry from .const import CONF_INVERT_POSITION, DOMAIN diff --git a/homeassistant/components/soundtouch/config_flow.py b/homeassistant/components/soundtouch/config_flow.py index af45b8f6bdc..f30065d1157 100644 --- a/homeassistant/components/soundtouch/config_flow.py +++ b/homeassistant/components/soundtouch/config_flow.py @@ -6,10 +6,10 @@ from libsoundtouch import soundtouch_device from requests import RequestException import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 5480f71a34a..b748006336c 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -4,9 +4,8 @@ from __future__ import annotations import contextlib from contextlib import suppress -from dataclasses import dataclass from fnmatch import translate -from functools import lru_cache +from functools import lru_cache, partial from ipaddress import IPv4Address, IPv6Address import logging import re @@ -30,12 +29,20 @@ from homeassistant.const import ( __version__, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.data_entry_flow import BaseServiceInfo -from homeassistant.helpers import discovery_flow, instance_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery_flow, instance_id +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID as _ATTR_PROPERTIES_ID, + ZeroconfServiceInfo as _ZeroconfServiceInfo, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import ( HomeKitDiscoveredIntegration, @@ -83,7 +90,11 @@ ATTR_NAME: Final = "name" ATTR_PROPERTIES: Final = "properties" # Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] -ATTR_PROPERTIES_ID: Final = "id" +_DEPRECATED_ATTR_PROPERTIES_ID = DeprecatedConstant( + _ATTR_PROPERTIES_ID, + "homeassistant.helpers.service_info.zeroconf.ATTR_PROPERTIES_ID", + "2026.2", +) CONFIG_SCHEMA = vol.Schema( { @@ -101,45 +112,11 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) - -@dataclass(slots=True) -class ZeroconfServiceInfo(BaseServiceInfo): - """Prepared info from mDNS entries. - - The ip_address is the most recently updated address - that is not a link local or unspecified address. - - The ip_addresses are all addresses in order of most - recently updated to least recently updated. - - The host is the string representation of the ip_address. - - The addresses are the string representations of the - ip_addresses. - - It is recommended to use the ip_address to determine - the address to connect to as it will be the most - recently updated address that is not a link local - or unspecified address. - """ - - ip_address: IPv4Address | IPv6Address - ip_addresses: list[IPv4Address | IPv6Address] - port: int | None - hostname: str - type: str - name: str - properties: dict[str, Any] - - @property - def host(self) -> str: - """Return the host.""" - return str(self.ip_address) - - @property - def addresses(self) -> list[str]: - """Return the addresses.""" - return [str(ip_address) for ip_address in self.ip_addresses] +_DEPRECATED_ZeroconfServiceInfo = DeprecatedConstant( + _ZeroconfServiceInfo, + "homeassistant.helpers.service_info.zeroconf.ZeroconfServiceInfo", + "2026.2", +) @bind_hass @@ -419,7 +396,7 @@ class ZeroconfDiscovery: def _async_dismiss_discoveries(self, name: str) -> None: """Dismiss all discoveries for the given name.""" for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( - ZeroconfServiceInfo, + _ZeroconfServiceInfo, lambda service_info: bool(service_info.name == name), ): self.hass.config_entries.flow.async_abort(flow["flow_id"]) @@ -595,7 +572,7 @@ def async_get_homekit_discovery( return None -def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: +def info_from_service(service: AsyncServiceInfo) -> _ZeroconfServiceInfo | None: """Return prepared info from mDNS entries.""" # See https://ietf.org/rfc/rfc6763.html#section-6.4 and # https://ietf.org/rfc/rfc6763.html#section-6.5 for expected encodings @@ -618,7 +595,7 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: assert service.server is not None, ( "server cannot be none if there are addresses" ) - return ZeroconfServiceInfo( + return _ZeroconfServiceInfo( ip_address=ip_address, ip_addresses=ip_addresses, port=service.port, @@ -684,3 +661,11 @@ def _memorized_fnmatch(name: str, pattern: str) -> bool: since the devices will not change frequently """ return bool(_compile_fnmatch(pattern).match(name)) + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 711eb14070d..71fe4472f23 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -19,7 +19,6 @@ from homeassistant.components.hassio import ( AddonManager, AddonState, ) -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ( SOURCE_USB, ConfigEntriesFlowManager, @@ -39,6 +38,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType from . import disconnect_client diff --git a/homeassistant/components/zwave_me/config_flow.py b/homeassistant/components/zwave_me/config_flow.py index 1444bfc1b95..d37d76a093b 100644 --- a/homeassistant/components/zwave_me/config_flow.py +++ b/homeassistant/components/zwave_me/config_flow.py @@ -7,9 +7,9 @@ import logging from url_normalize import url_normalize import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_TOKEN, CONF_URL +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import helpers from .const import DOMAIN diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 00532152b53..9d0d09e2994 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -90,9 +90,9 @@ if TYPE_CHECKING: from .components.dhcp import DhcpServiceInfo from .components.ssdp import SsdpServiceInfo from .components.usb import UsbServiceInfo - from .components.zeroconf import ZeroconfServiceInfo from .helpers.service_info.hassio import HassioServiceInfo from .helpers.service_info.mqtt import MqttServiceInfo + from .helpers.service_info.zeroconf import ZeroconfServiceInfo _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index b047e1aef81..3bbf9a563b4 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -18,9 +18,9 @@ if TYPE_CHECKING: from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.ssdp import SsdpServiceInfo - from homeassistant.components.zeroconf import ZeroconfServiceInfo from .service_info.mqtt import MqttServiceInfo + from .service_info.zeroconf import ZeroconfServiceInfo type DiscoveryFunctionType[_R] = Callable[[HomeAssistant], _R] diff --git a/homeassistant/helpers/service_info/zeroconf.py b/homeassistant/helpers/service_info/zeroconf.py new file mode 100644 index 00000000000..a91bc5e77d9 --- /dev/null +++ b/homeassistant/helpers/service_info/zeroconf.py @@ -0,0 +1,50 @@ +"""Zeroconf discovery data.""" + +from dataclasses import dataclass +from ipaddress import IPv4Address, IPv6Address +from typing import Any, Final + +from homeassistant.data_entry_flow import BaseServiceInfo + +# Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] +ATTR_PROPERTIES_ID: Final = "id" + + +@dataclass(slots=True) +class ZeroconfServiceInfo(BaseServiceInfo): + """Prepared info from mDNS entries. + + The ip_address is the most recently updated address + that is not a link local or unspecified address. + + The ip_addresses are all addresses in order of most + recently updated to least recently updated. + + The host is the string representation of the ip_address. + + The addresses are the string representations of the + ip_addresses. + + It is recommended to use the ip_address to determine + the address to connect to as it will be the most + recently updated address that is not a link local + or unspecified address. + """ + + ip_address: IPv4Address | IPv6Address + ip_addresses: list[IPv4Address | IPv6Address] + port: int | None + hostname: str + type: str + name: str + properties: dict[str, Any] + + @property + def host(self) -> str: + """Return the host.""" + return str(self.ip_address) + + @property + def addresses(self) -> list[str]: + """Return the addresses.""" + return [str(ip_address) for ip_address in self.ip_addresses] diff --git a/tests/components/airgradient/test_config_flow.py b/tests/components/airgradient/test_config_flow.py index 8927947c40e..01d48e852ca 100644 --- a/tests/components/airgradient/test_config_flow.py +++ b/tests/components/airgradient/test_config_flow.py @@ -10,11 +10,11 @@ from airgradient import ( ) from homeassistant.components.airgradient.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index 27292e5a28c..dde51351b39 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -31,8 +31,8 @@ from homeassistant.components.bang_olufsen.const import ( CONF_BEOLINK_JID, BangOlufsenSource, ) -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo TEST_HOST = "192.168.0.1" TEST_HOST_INVALID = "192.168.0" diff --git a/tests/components/bluesound/test_config_flow.py b/tests/components/bluesound/test_config_flow.py index a1d67c120db..d0e0f75991b 100644 --- a/tests/components/bluesound/test_config_flow.py +++ b/tests/components/bluesound/test_config_flow.py @@ -5,11 +5,11 @@ from unittest.mock import AsyncMock from pyblu.errors import PlayerUnreachableError from homeassistant.components.bluesound.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import PlayerMocks diff --git a/tests/components/cambridge_audio/test_config_flow.py b/tests/components/cambridge_audio/test_config_flow.py index 8d01db6e015..fc184ae2ef5 100644 --- a/tests/components/cambridge_audio/test_config_flow.py +++ b/tests/components/cambridge_audio/test_config_flow.py @@ -6,11 +6,11 @@ from unittest.mock import AsyncMock from aiostreammagic import StreamMagicError from homeassistant.components.cambridge_audio.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py index 7b0551b1daf..f3c469e61b2 100644 --- a/tests/components/devolo_home_network/const.py +++ b/tests/components/devolo_home_network/const.py @@ -14,7 +14,7 @@ from devolo_plc_api.device_api import ( ) from devolo_plc_api.plcnet_api import LOCAL, REMOTE, LogicalNetwork -from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo IP = "192.0.2.1" IP_ALT = "192.0.2.2" diff --git a/tests/components/eheimdigital/test_config_flow.py b/tests/components/eheimdigital/test_config_flow.py index e75cf31eb98..4bfd45e9259 100644 --- a/tests/components/eheimdigital/test_config_flow.py +++ b/tests/components/eheimdigital/test_config_flow.py @@ -7,11 +7,11 @@ from aiohttp import ClientConnectionError import pytest from homeassistant.components.eheimdigital.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo ZEROCONF_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address("192.0.2.1"), diff --git a/tests/components/lektrico/conftest.py b/tests/components/lektrico/conftest.py index fd840b0c290..0b120cd6e23 100644 --- a/tests/components/lektrico/conftest.py +++ b/tests/components/lektrico/conftest.py @@ -8,13 +8,13 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant.components.lektrico.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.const import ( ATTR_HW_VERSION, ATTR_SERIAL_NUMBER, CONF_HOST, CONF_TYPE, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/linkplay/test_config_flow.py b/tests/components/linkplay/test_config_flow.py index 3fd1fbea95e..adf6aa601ae 100644 --- a/tests/components/linkplay/test_config_flow.py +++ b/tests/components/linkplay/test_config_flow.py @@ -7,11 +7,11 @@ from linkplay.exceptions import LinkPlayRequestException import pytest from homeassistant.components.linkplay.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import HOST, HOST_REENTRY, NAME, UUID diff --git a/tests/components/lookin/__init__.py b/tests/components/lookin/__init__.py index cea0f969893..64dc1220683 100644 --- a/tests/components/lookin/__init__.py +++ b/tests/components/lookin/__init__.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch from aiolookin import Climate, Device, Remote -from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo DEVICE_ID = "98F33163" MODULE = "homeassistant.components.lookin" diff --git a/tests/components/matter/test_config_flow.py b/tests/components/matter/test_config_flow.py index eed776c132e..24243fa2038 100644 --- a/tests/components/matter/test_config_flow.py +++ b/tests/components/matter/test_config_flow.py @@ -14,10 +14,10 @@ import pytest from homeassistant import config_entries from homeassistant.components.matter.const import ADDON_SLUG, DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/music_assistant/test_config_flow.py b/tests/components/music_assistant/test_config_flow.py index c700060889c..89cda62961b 100644 --- a/tests/components/music_assistant/test_config_flow.py +++ b/tests/components/music_assistant/test_config_flow.py @@ -15,10 +15,10 @@ import pytest from homeassistant.components.music_assistant.config_flow import CONF_URL from homeassistant.components.music_assistant.const import DEFAULT_NAME, DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index db551c0a1c3..65bdaafc131 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -12,13 +12,13 @@ from homeassistant.components.onewire.const import ( INPUT_ENTRY_DEVICE_SELECTION, MANUFACTURER_MAXIM, ) -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index cef5ef350a9..6fe584a511c 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -19,9 +19,9 @@ import pytest from homeassistant import config_entries from homeassistant.components import dhcp from homeassistant.components.overkiz.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/plugwise/test_config_flow.py b/tests/components/plugwise/test_config_flow.py index 1f30fc972bb..16af7065c49 100644 --- a/tests/components/plugwise/test_config_flow.py +++ b/tests/components/plugwise/test_config_flow.py @@ -13,7 +13,6 @@ from plugwise.exceptions import ( import pytest from homeassistant.components.plugwise.const import DEFAULT_PORT, DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -25,6 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/russound_rio/test_config_flow.py b/tests/components/russound_rio/test_config_flow.py index 51cbb9772dc..550ea9404df 100644 --- a/tests/components/russound_rio/test_config_flow.py +++ b/tests/components/russound_rio/test_config_flow.py @@ -4,11 +4,11 @@ from ipaddress import ip_address from unittest.mock import AsyncMock from homeassistant.components.russound_rio.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import MOCK_CONFIG, MOCK_RECONFIGURATION_CONFIG, MODEL diff --git a/tests/components/slide_local/test_config_flow.py b/tests/components/slide_local/test_config_flow.py index 9f2923988ca..b8b69d99fd8 100644 --- a/tests/components/slide_local/test_config_flow.py +++ b/tests/components/slide_local/test_config_flow.py @@ -12,11 +12,11 @@ from goslideapi.goslideapi import ( import pytest from homeassistant.components.slide_local.const import CONF_INVERT_POSITION, DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import setup_platform from .const import HOST, SLIDE_INFO_DATA diff --git a/tests/components/soundtouch/test_config_flow.py b/tests/components/soundtouch/test_config_flow.py index 264049ab5fc..fe524da5603 100644 --- a/tests/components/soundtouch/test_config_flow.py +++ b/tests/components/soundtouch/test_config_flow.py @@ -8,11 +8,11 @@ import requests_mock from requests_mock import ANY, Mocker from homeassistant.components.soundtouch.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import DEVICE_1_ID, DEVICE_1_IP, DEVICE_1_NAME diff --git a/tests/components/wyoming/test_config_flow.py b/tests/components/wyoming/test_config_flow.py index 0a314f477b1..30faa2dd441 100644 --- a/tests/components/wyoming/test_config_flow.py +++ b/tests/components/wyoming/test_config_flow.py @@ -9,10 +9,10 @@ from wyoming.info import Info from homeassistant import config_entries from homeassistant.components.wyoming.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import EMPTY_INFO, SATELLITE_INFO, STT_INFO, TTS_INFO diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index be78964f231..3586f54a59a 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -24,9 +24,18 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.generated import zeroconf as zc_gen from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.setup import ATTR_COMPONENT, async_setup_component -from tests.common import MockConfigEntry, MockModule, mock_integration +from tests.common import ( + MockConfigEntry, + MockModule, + import_and_test_deprecated_constant, + mock_integration, +) NON_UTF8_VALUE = b"ABCDEF\x8a" NON_ASCII_KEY = b"non-ascii-key\x8a" @@ -1655,3 +1664,35 @@ async def test_zeroconf_rediscover_no_match( assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("constant_name", "replacement_name", "replacement"), + [ + ( + "ATTR_PROPERTIES_ID", + "homeassistant.helpers.service_info.zeroconf.ATTR_PROPERTIES_ID", + ATTR_PROPERTIES_ID, + ), + ( + "ZeroconfServiceInfo", + "homeassistant.helpers.service_info.zeroconf.ZeroconfServiceInfo", + ZeroconfServiceInfo, + ), + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement_name: str, + replacement: Any, +) -> None: + """Test deprecated automation constants.""" + import_and_test_deprecated_constant( + caplog, + zeroconf, + constant_name, + replacement_name, + replacement, + "2026.2", + ) diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index b60515cacd4..75bce869a74 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -17,12 +17,12 @@ from zwave_js_server.version import VersionInfo from homeassistant import config_entries from homeassistant.components import usb -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.components.zwave_js.config_flow import SERVER_VERSION_TIMEOUT, TITLE from homeassistant.components.zwave_js.const import ADDON_SLUG, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry From 9c5c1a35a4e49a48f9c0f1ff6df3b1652ee8c398 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 15 Jan 2025 13:00:40 +0100 Subject: [PATCH 0447/2987] Fix descriptions of send_command action for consistency (#135670) Three small fixes for the description keys of the send_command action of the Homeworks integration: - use third-person singular for descriptive wording - Change to "the command" to match "the controller" in two strings Both ensure better and more consistent machine and human translations. --- homeassistant/components/homeworks/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homeworks/strings.json b/homeassistant/components/homeworks/strings.json index 977e6be8afd..10cc2e61fb9 100644 --- a/homeassistant/components/homeworks/strings.json +++ b/homeassistant/components/homeworks/strings.json @@ -167,15 +167,15 @@ "services": { "send_command": { "name": "Send command", - "description": "Send custom command to a controller", + "description": "Sends a custom command to a controller", "fields": { "command": { "name": "Command", - "description": "Command to send to the controller. This can either be a single command or a list of commands." + "description": "The command to send to the controller. This can either be a single command or a list of commands." }, "controller_id": { "name": "Controller ID", - "description": "The controller to which to send command." + "description": "The controller to which to send the command." } } } From 31c36beb2e71e1510e471b067d92ec4ca719ed2c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Jan 2025 13:09:18 +0100 Subject: [PATCH 0448/2987] Move DhcpServiceInfo to service_info helpers (#135658) * Move DhcpServiceInfo to service_info helpers * Fix mypy/pylint --- .../components/airzone/config_flow.py | 4 +-- homeassistant/components/dhcp/__init__.py | 32 +++++++++++++------ .../components/fronius/config_flow.py | 2 +- .../components/fully_kiosk/config_flow.py | 2 +- .../components/homewizard/config_flow.py | 2 +- .../components/intellifire/config_flow.py | 2 +- .../components/lamarzocco/config_flow.py | 2 +- .../components/lametric/config_flow.py | 2 +- homeassistant/components/lifx/config_flow.py | 2 +- .../components/powerwall/config_flow.py | 4 +-- .../components/somfy_mylink/config_flow.py | 4 +-- .../components/tailwind/config_flow.py | 2 +- .../tesla_wall_connector/config_flow.py | 4 +-- homeassistant/components/velux/config_flow.py | 2 +- .../components/wmspro/config_flow.py | 2 +- homeassistant/config_entries.py | 2 +- homeassistant/helpers/config_entry_flow.py | 2 +- homeassistant/helpers/service_info/dhcp.py | 14 ++++++++ tests/components/dhcp/test_init.py | 29 +++++++++++++++++ tests/components/fronius/test_config_flow.py | 2 +- .../fully_kiosk/test_config_flow.py | 2 +- tests/components/fyta/test_config_flow.py | 2 +- .../components/lamarzocco/test_config_flow.py | 2 +- tests/components/lametric/test_config_flow.py | 2 +- tests/components/tailwind/test_config_flow.py | 2 +- tests/components/velux/test_config_flow.py | 2 +- tests/components/withings/test_config_flow.py | 2 +- tests/components/wmspro/test_config_flow.py | 2 +- 28 files changed, 94 insertions(+), 39 deletions(-) create mode 100644 homeassistant/helpers/service_info/dhcp.py diff --git a/homeassistant/components/airzone/config_flow.py b/homeassistant/components/airzone/config_flow.py index 406fd72a6db..b0a87dd4e57 100644 --- a/homeassistant/components/airzone/config_flow.py +++ b/homeassistant/components/airzone/config_flow.py @@ -10,12 +10,12 @@ from aioairzone.exceptions import AirzoneError, InvalidSystem from aioairzone.localapi import AirzoneLocalApi, ConnectionOptions import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN @@ -93,7 +93,7 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" self._discovered_ip = discovery_info.ip diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 2de676ef52a..a11a0b262b0 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -7,7 +7,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta from fnmatch import translate -from functools import lru_cache +from functools import lru_cache, partial import itertools import logging import re @@ -44,12 +44,17 @@ from homeassistant.core import ( State, callback, ) -from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import ( config_validation as cv, device_registry as dr, discovery_flow, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -57,6 +62,7 @@ from homeassistant.helpers.event import ( async_track_state_added_domain, async_track_time_interval, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo as _DhcpServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.loader import DHCPMatcher, async_get_dhcp @@ -74,13 +80,11 @@ SCAN_INTERVAL = timedelta(minutes=60) _LOGGER = logging.getLogger(__name__) -@dataclass(slots=True) -class DhcpServiceInfo(BaseServiceInfo): - """Prepared info from dhcp entries.""" - - ip: str - hostname: str - macaddress: str +_DEPRECATED_DhcpServiceInfo = DeprecatedConstant( + _DhcpServiceInfo, + "homeassistant.helpers.service_info.dhcp.DhcpServiceInfo", + "2026.2", +) @dataclass(slots=True) @@ -296,7 +300,7 @@ class WatcherBase: self.hass, domain, {"source": config_entries.SOURCE_DHCP}, - DhcpServiceInfo( + _DhcpServiceInfo( ip=ip_address, hostname=lowercase_hostname, macaddress=mac_address, @@ -486,3 +490,11 @@ def _memorized_fnmatch(name: str, pattern: str) -> bool: since the devices will not change frequently """ return bool(_compile_fnmatch(pattern).match(name)) + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/fronius/config_flow.py b/homeassistant/components/fronius/config_flow.py index ccc15d80401..f35c9ce5bc1 100644 --- a/homeassistant/components/fronius/config_flow.py +++ b/homeassistant/components/fronius/config_flow.py @@ -9,12 +9,12 @@ from typing import Any, Final from pyfronius import Fronius, FroniusError import voluptuous as vol -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, FroniusConfigEntryData diff --git a/homeassistant/components/fully_kiosk/config_flow.py b/homeassistant/components/fully_kiosk/config_flow.py index 15771d12b5d..53185e8ab76 100644 --- a/homeassistant/components/fully_kiosk/config_flow.py +++ b/homeassistant/components/fully_kiosk/config_flow.py @@ -11,7 +11,6 @@ from fullykiosk import FullyKiosk from fullykiosk.exceptions import FullyKioskError import voluptuous as vol -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -22,6 +21,7 @@ from homeassistant.const import ( ) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from .const import DEFAULT_PORT, DOMAIN, LOGGER diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index f88d1f1d701..d5b19a0c030 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -11,12 +11,12 @@ from homewizard_energy.models import Device import voluptuous as vol from homeassistant.components import onboarding, zeroconf -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_PATH from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import TextSelector +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_API_ENABLED, diff --git a/homeassistant/components/intellifire/config_flow.py b/homeassistant/components/intellifire/config_flow.py index e7c4fbbdd2a..35c3bc09010 100644 --- a/homeassistant/components/intellifire/config_flow.py +++ b/homeassistant/components/intellifire/config_flow.py @@ -13,7 +13,6 @@ from intellifire4py.local_api import IntelliFireAPILocal from intellifire4py.model import IntelliFireCommonFireplaceData import voluptuous as vol -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_API_KEY, @@ -22,6 +21,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( API_MODE_LOCAL, diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 70fb2c08b34..87a9824423a 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -17,7 +17,6 @@ from homeassistant.components.bluetooth import ( BluetoothServiceInfo, async_discovered_service_info, ) -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, @@ -47,6 +46,7 @@ from homeassistant.helpers.selector import ( TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_USE_BLUETOOTH, DOMAIN from .coordinator import LaMarzoccoConfigEntry diff --git a/homeassistant/components/lametric/config_flow.py b/homeassistant/components/lametric/config_flow.py index 05c5dea77d1..3af34a8b2ec 100644 --- a/homeassistant/components/lametric/config_flow.py +++ b/homeassistant/components/lametric/config_flow.py @@ -23,7 +23,6 @@ from demetriek import ( import voluptuous as vol from yarl import URL -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL, @@ -44,6 +43,7 @@ from homeassistant.helpers.selector import ( TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.util.network import is_link_local from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/lifx/config_flow.py b/homeassistant/components/lifx/config_flow.py index 053bb72c4fd..1a5870b1935 100644 --- a/homeassistant/components/lifx/config_flow.py +++ b/homeassistant/components/lifx/config_flow.py @@ -10,11 +10,11 @@ from aiolifx.connection import LIFXConnection import voluptuous as vol from homeassistant.components import zeroconf -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType from .const import ( diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index 0c39392ca19..396ba31b4ee 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -17,7 +17,6 @@ from tesla_powerwall import ( ) import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( ConfigEntry, ConfigEntryState, @@ -28,6 +27,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.util.network import is_ip_address from . import async_last_update_was_successful @@ -116,7 +116,7 @@ class PowerwallConfigFlow(ConfigFlow, domain=DOMAIN): ) and not await _powerwall_is_reachable(ip_address, password) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" self.ip_address = discovery_info.ip diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index c2d85160175..a806d581aec 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -9,7 +9,6 @@ from typing import Any from somfy_mylink_synergy import SomfyMyLinkSynergy import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( ConfigEntry, ConfigEntryState, @@ -21,6 +20,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_REVERSE, @@ -69,7 +69,7 @@ class SomfyConfigFlow(ConfigFlow, domain=DOMAIN): self.ip_address: str | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) diff --git a/homeassistant/components/tailwind/config_flow.py b/homeassistant/components/tailwind/config_flow.py index 48fe2d23727..15b947dc7af 100644 --- a/homeassistant/components/tailwind/config_flow.py +++ b/homeassistant/components/tailwind/config_flow.py @@ -16,7 +16,6 @@ from gotailwind import ( import voluptuous as vol from homeassistant.components import zeroconf -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.data_entry_flow import AbortFlow @@ -27,6 +26,7 @@ from homeassistant.helpers.selector import ( TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/tesla_wall_connector/config_flow.py b/homeassistant/components/tesla_wall_connector/config_flow.py index 3296539f701..d100b1e5549 100644 --- a/homeassistant/components/tesla_wall_connector/config_flow.py +++ b/homeassistant/components/tesla_wall_connector/config_flow.py @@ -9,11 +9,11 @@ from tesla_wall_connector import WallConnector from tesla_wall_connector.exceptions import WallConnectorError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, WALLCONNECTOR_DEVICE_NAME, WALLCONNECTOR_SERIAL_NUMBER @@ -48,7 +48,7 @@ class TeslaWallConnectorConfigFlow(ConfigFlow, domain=DOMAIN): self.ip_address: str | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" self.ip_address = discovery_info.ip diff --git a/homeassistant/components/velux/config_flow.py b/homeassistant/components/velux/config_flow.py index da6745a6673..fba023f7638 100644 --- a/homeassistant/components/velux/config_flow.py +++ b/homeassistant/components/velux/config_flow.py @@ -5,11 +5,11 @@ from typing import Any from pyvlx import PyVLX, PyVLXException import voluptuous as vol -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/wmspro/config_flow.py b/homeassistant/components/wmspro/config_flow.py index 2ce58ec9eca..4b51bba3990 100644 --- a/homeassistant/components/wmspro/config_flow.py +++ b/homeassistant/components/wmspro/config_flow.py @@ -11,11 +11,11 @@ import voluptuous as vol from wmspro.webcontrol import WebControlPro from homeassistant.components import dhcp -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, SUGGESTED_HOST diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 9d0d09e2994..7b12acf3571 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -87,9 +87,9 @@ from .util.enum import try_parse_enum if TYPE_CHECKING: from .components.bluetooth import BluetoothServiceInfoBleak - from .components.dhcp import DhcpServiceInfo from .components.ssdp import SsdpServiceInfo from .components.usb import UsbServiceInfo + from .helpers.service_info.dhcp import DhcpServiceInfo from .helpers.service_info.hassio import HassioServiceInfo from .helpers.service_info.mqtt import MqttServiceInfo from .helpers.service_info.zeroconf import ZeroconfServiceInfo diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 3bbf9a563b4..e6670544acc 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -16,9 +16,9 @@ if TYPE_CHECKING: import asyncio from homeassistant.components.bluetooth import BluetoothServiceInfoBleak - from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.ssdp import SsdpServiceInfo + from .service_info.dhcp import DhcpServiceInfo from .service_info.mqtt import MqttServiceInfo from .service_info.zeroconf import ZeroconfServiceInfo diff --git a/homeassistant/helpers/service_info/dhcp.py b/homeassistant/helpers/service_info/dhcp.py new file mode 100644 index 00000000000..47479a53a8a --- /dev/null +++ b/homeassistant/helpers/service_info/dhcp.py @@ -0,0 +1,14 @@ +"""DHCP discovery data.""" + +from dataclasses import dataclass + +from homeassistant.data_entry_flow import BaseServiceInfo + + +@dataclass(slots=True) +class DhcpServiceInfo(BaseServiceInfo): + """Prepared info from dhcp entries.""" + + ip: str + hostname: str + macaddress: str diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 6852f4369cc..9f3435f0cd9 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -34,6 +34,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.device_registry as dr from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -41,6 +42,7 @@ from tests.common import ( MockConfigEntry, MockModule, async_fire_time_changed, + import_and_test_deprecated_constant, mock_integration, ) @@ -1353,3 +1355,30 @@ async def test_dhcp_rediscover_no_match( await hass.async_block_till_done() assert len(mock_init.mock_calls) == 0 + + +@pytest.mark.parametrize( + ("constant_name", "replacement_name", "replacement"), + [ + ( + "DhcpServiceInfo", + "homeassistant.helpers.service_info.dhcp.DhcpServiceInfo", + DhcpServiceInfo, + ), + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement_name: str, + replacement: Any, +) -> None: + """Test deprecated automation constants.""" + import_and_test_deprecated_constant( + caplog, + dhcp, + constant_name, + replacement_name, + replacement, + "2026.2", + ) diff --git a/tests/components/fronius/test_config_flow.py b/tests/components/fronius/test_config_flow.py index 933b8fad8ef..819f960c64b 100644 --- a/tests/components/fronius/test_config_flow.py +++ b/tests/components/fronius/test_config_flow.py @@ -6,11 +6,11 @@ from pyfronius import FroniusError import pytest from homeassistant import config_entries -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.fronius.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import mock_responses diff --git a/tests/components/fully_kiosk/test_config_flow.py b/tests/components/fully_kiosk/test_config_flow.py index 873fb2c6796..4ce393a417d 100644 --- a/tests/components/fully_kiosk/test_config_flow.py +++ b/tests/components/fully_kiosk/test_config_flow.py @@ -6,7 +6,6 @@ from aiohttp.client_exceptions import ClientConnectorError from fullykiosk import FullyKioskError import pytest -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.fully_kiosk.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_MQTT, SOURCE_USER from homeassistant.const import ( @@ -18,6 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/fyta/test_config_flow.py b/tests/components/fyta/test_config_flow.py index 21101db8534..d1e6e326737 100644 --- a/tests/components/fyta/test_config_flow.py +++ b/tests/components/fyta/test_config_flow.py @@ -10,11 +10,11 @@ from fyta_cli.fyta_exceptions import ( import pytest from homeassistant import config_entries -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.fyta.const import CONF_EXPIRATION, DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ACCESS_TOKEN, EXPIRATION, PASSWORD, USERNAME diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index e25aab39012..02ade8f2b9c 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -8,7 +8,6 @@ from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from pylamarzocco.models import LaMarzoccoDeviceInfo import pytest -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE from homeassistant.components.lamarzocco.const import CONF_USE_BLUETOOTH, DOMAIN from homeassistant.config_entries import ( @@ -28,6 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import USER_INPUT, async_init_integration, get_bluetooth_service_info diff --git a/tests/components/lametric/test_config_flow.py b/tests/components/lametric/test_config_flow.py index ccbbe005639..354cd4fa120 100644 --- a/tests/components/lametric/test_config_flow.py +++ b/tests/components/lametric/test_config_flow.py @@ -13,7 +13,6 @@ from demetriek import ( ) import pytest -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.lametric.const import DOMAIN from homeassistant.components.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, @@ -25,6 +24,7 @@ from homeassistant.const import CONF_API_KEY, CONF_DEVICE, CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker diff --git a/tests/components/tailwind/test_config_flow.py b/tests/components/tailwind/test_config_flow.py index ca6fbacf0fc..5619ea7e400 100644 --- a/tests/components/tailwind/test_config_flow.py +++ b/tests/components/tailwind/test_config_flow.py @@ -12,12 +12,12 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import zeroconf -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.tailwind.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/velux/test_config_flow.py b/tests/components/velux/test_config_flow.py index 19512337590..22ad10e1188 100644 --- a/tests/components/velux/test_config_flow.py +++ b/tests/components/velux/test_config_flow.py @@ -7,12 +7,12 @@ from unittest.mock import AsyncMock import pytest from pyvlx import PyVLXException -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.velux import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index d0ad5b2659a..b61a54150e4 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -4,12 +4,12 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.withings.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import setup_integration from .conftest import CLIENT_ID, USER_ID diff --git a/tests/components/wmspro/test_config_flow.py b/tests/components/wmspro/test_config_flow.py index 782dc051c8c..2c628bbc296 100644 --- a/tests/components/wmspro/test_config_flow.py +++ b/tests/components/wmspro/test_config_flow.py @@ -4,12 +4,12 @@ from unittest.mock import AsyncMock, patch import aiohttp -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.wmspro.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import setup_config_entry From 4ccc686295022721e24c45d34bd19ee7c0eab6a0 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 15 Jan 2025 13:59:42 +0100 Subject: [PATCH 0449/2987] Improve logging of backup upload errors (#135672) Improve logging for upload errors --- homeassistant/components/backup/manager.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 83121e8bf38..76e1c261e31 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -430,18 +430,21 @@ class BackupManager: return_exceptions=True, ) for idx, result in enumerate(sync_backup_results): + agent_id = agent_ids[idx] if isinstance(result, BackupReaderWriterError): # writer errors will affect all agents # no point in continuing raise BackupManagerError(str(result)) from result if isinstance(result, BackupAgentError): - LOGGER.error("Error uploading to %s: %s", agent_ids[idx], result) - agent_errors[agent_ids[idx]] = result + agent_errors[agent_id] = result + LOGGER.error("Upload failed for %s: %s", agent_id, result) continue if isinstance(result, Exception): # trap bugs from agents - agent_errors[agent_ids[idx]] = result - LOGGER.error("Unexpected error: %s", result, exc_info=result) + agent_errors[agent_id] = result + LOGGER.error( + "Unexpected error for %s: %s", agent_id, result, exc_info=result + ) continue if isinstance(result, BaseException): raise result From 8c13daf6d9b42abf5b21130f67e216c577246dad Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Jan 2025 14:00:27 +0100 Subject: [PATCH 0450/2987] Move SsdpServiceInfo to service_info helpers (#135661) * Move SsdpServiceInfo to service_info helpers * docstring * Move string constants * Adjust components --- .../components/dlna_dms/config_flow.py | 21 +- .../components/lametric/config_flow.py | 10 +- .../components/openhome/config_flow.py | 6 +- homeassistant/components/ssdp/__init__.py | 189 +++++++++++++----- homeassistant/components/upnp/config_flow.py | 23 ++- .../yamaha_musiccast/config_flow.py | 14 +- homeassistant/config_entries.py | 2 +- homeassistant/helpers/config_entry_flow.py | 2 +- homeassistant/helpers/service_info/ssdp.py | 41 ++++ tests/components/deconz/test_config_flow.py | 5 +- tests/components/deconz/test_hub.py | 10 +- tests/components/directv/test_config_flow.py | 2 +- tests/components/fritz/const.py | 5 +- tests/components/fritzbox/test_config_flow.py | 5 +- tests/components/kaleidescape/__init__.py | 5 +- tests/components/lametric/test_config_flow.py | 10 +- tests/components/openhome/test_config_flow.py | 13 +- tests/components/roku/__init__.py | 5 +- tests/components/samsungtv/const.py | 12 +- .../components/samsungtv/test_config_flow.py | 14 +- tests/components/ssdp/test_init.py | 122 +++++++++++ tests/components/wilight/__init__.py | 12 +- tests/components/zha/test_config_flow.py | 5 +- 23 files changed, 405 insertions(+), 128 deletions(-) create mode 100644 homeassistant/helpers/service_info/ssdp.py diff --git a/homeassistant/components/dlna_dms/config_flow.py b/homeassistant/components/dlna_dms/config_flow.py index ad959ece3b6..a87b4a510f5 100644 --- a/homeassistant/components/dlna_dms/config_flow.py +++ b/homeassistant/components/dlna_dms/config_flow.py @@ -14,6 +14,11 @@ from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_URL from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERVICE_LIST, + SsdpServiceInfo, +) from .const import CONF_SOURCE_ID, CONFIG_VERSION, DEFAULT_NAME, DOMAIN from .util import generate_source_id @@ -33,7 +38,7 @@ class DlnaDmsFlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize flow.""" - self._discoveries: dict[str, ssdp.SsdpServiceInfo] = {} + self._discoveries: dict[str, SsdpServiceInfo] = {} self._location: str | None = None self._usn: str | None = None self._name: str | None = None @@ -60,14 +65,14 @@ class DlnaDmsFlowHandler(ConfigFlow, domain=DOMAIN): } discovery_choices = { - host: f"{discovery.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)} ({host})" + host: f"{discovery.upnp.get(ATTR_UPNP_FRIENDLY_NAME)} ({host})" for host, discovery in self._discoveries.items() } data_schema = vol.Schema({vol.Optional(CONF_HOST): vol.In(discovery_choices)}) return self.async_show_form(step_id="user", data_schema=data_schema) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by SSDP discovery.""" if LOGGER.isEnabledFor(logging.DEBUG): @@ -81,7 +86,7 @@ class DlnaDmsFlowHandler(ConfigFlow, domain=DOMAIN): # Abort if the device doesn't support all services required for a DmsDevice. # Use the discovery_info instead of DmsDevice.is_profile_device to avoid # contacting the device again. - discovery_service_list = discovery_info.upnp.get(ssdp.ATTR_UPNP_SERVICE_LIST) + discovery_service_list = discovery_info.upnp.get(ATTR_UPNP_SERVICE_LIST) if not discovery_service_list: return self.async_abort(reason="not_dms") @@ -135,7 +140,7 @@ class DlnaDmsFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=self._name, data=data) async def _async_parse_discovery( - self, discovery_info: ssdp.SsdpServiceInfo, raise_on_progress: bool = True + self, discovery_info: SsdpServiceInfo, raise_on_progress: bool = True ) -> None: """Get required details from an SSDP discovery. @@ -162,15 +167,15 @@ class DlnaDmsFlowHandler(ConfigFlow, domain=DOMAIN): ) self._name = ( - discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) or urlparse(self._location).hostname or DEFAULT_NAME ) - async def _async_get_discoveries(self) -> list[ssdp.SsdpServiceInfo]: + async def _async_get_discoveries(self) -> list[SsdpServiceInfo]: """Get list of unconfigured DLNA devices discovered by SSDP.""" # Get all compatible devices from ssdp's cache - discoveries: list[ssdp.SsdpServiceInfo] = [] + discoveries: list[SsdpServiceInfo] = [] for udn_st in DmsDevice.DEVICE_TYPES: st_discoveries = await ssdp.async_get_discovery_info_by_st( self.hass, udn_st diff --git a/homeassistant/components/lametric/config_flow.py b/homeassistant/components/lametric/config_flow.py index 3af34a8b2ec..23b0062fc82 100644 --- a/homeassistant/components/lametric/config_flow.py +++ b/homeassistant/components/lametric/config_flow.py @@ -23,11 +23,6 @@ from demetriek import ( import voluptuous as vol from yarl import URL -from homeassistant.components.ssdp import ( - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_SERIAL, - SsdpServiceInfo, -) from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_DEVICE, CONF_HOST, CONF_MAC from homeassistant.data_entry_flow import AbortFlow @@ -44,6 +39,11 @@ from homeassistant.helpers.selector import ( TextSelectorType, ) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from homeassistant.util.network import is_link_local from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/openhome/config_flow.py b/homeassistant/components/openhome/config_flow.py index b495819211b..9cd6a79f012 100644 --- a/homeassistant/components/openhome/config_flow.py +++ b/homeassistant/components/openhome/config_flow.py @@ -3,13 +3,13 @@ import logging from typing import Any -from homeassistant.components.ssdp import ( +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN, SsdpServiceInfo, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_NAME from .const import DOMAIN diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index ccd69961975..637974853f6 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine, Mapping -from dataclasses import dataclass, field from datetime import timedelta from enum import Enum from functools import partial @@ -44,13 +43,36 @@ from homeassistant.const import ( __version__ as current_version, ) from homeassistant.core import Event, HassJob, HomeAssistant, callback as core_callback -from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.instance_id import async_get as async_get_instance_id from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.service_info.ssdp import ( + ATTR_NT as _ATTR_NT, + ATTR_ST as _ATTR_ST, + ATTR_UPNP_DEVICE_TYPE as _ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME as _ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER as _ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MANUFACTURER_URL as _ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_MODEL_DESCRIPTION as _ATTR_UPNP_MODEL_DESCRIPTION, + ATTR_UPNP_MODEL_NAME as _ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER as _ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_MODEL_URL as _ATTR_UPNP_MODEL_URL, + ATTR_UPNP_PRESENTATION_URL as _ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL as _ATTR_UPNP_SERIAL, + ATTR_UPNP_SERVICE_LIST as _ATTR_UPNP_SERVICE_LIST, + ATTR_UPNP_UDN as _ATTR_UPNP_UDN, + ATTR_UPNP_UPC as _ATTR_UPNP_UPC, + SsdpServiceInfo as _SsdpServiceInfo, +) from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_ssdp, bind_hass @@ -77,30 +99,90 @@ ATTR_SSDP_SERVER = "ssdp_server" ATTR_SSDP_BOOTID = "BOOTID.UPNP.ORG" ATTR_SSDP_NEXTBOOTID = "NEXTBOOTID.UPNP.ORG" # Attributes for accessing info from retrieved UPnP device description -ATTR_ST = "st" -ATTR_NT = "nt" -ATTR_UPNP_DEVICE_TYPE = "deviceType" -ATTR_UPNP_FRIENDLY_NAME = "friendlyName" -ATTR_UPNP_MANUFACTURER = "manufacturer" -ATTR_UPNP_MANUFACTURER_URL = "manufacturerURL" -ATTR_UPNP_MODEL_DESCRIPTION = "modelDescription" -ATTR_UPNP_MODEL_NAME = "modelName" -ATTR_UPNP_MODEL_NUMBER = "modelNumber" -ATTR_UPNP_MODEL_URL = "modelURL" -ATTR_UPNP_SERIAL = "serialNumber" -ATTR_UPNP_SERVICE_LIST = "serviceList" -ATTR_UPNP_UDN = "UDN" -ATTR_UPNP_UPC = "UPC" -ATTR_UPNP_PRESENTATION_URL = "presentationURL" +_DEPRECATED_ATTR_ST = DeprecatedConstant( + _ATTR_ST, + "homeassistant.helpers.service_info.ssdp.ATTR_ST", + "2026.2", +) +_DEPRECATED_ATTR_NT = DeprecatedConstant( + _ATTR_NT, + "homeassistant.helpers.service_info.ssdp.ATTR_NT", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_DEVICE_TYPE = DeprecatedConstant( + _ATTR_UPNP_DEVICE_TYPE, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_DEVICE_TYPE", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_FRIENDLY_NAME = DeprecatedConstant( + _ATTR_UPNP_FRIENDLY_NAME, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_FRIENDLY_NAME", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MANUFACTURER = DeprecatedConstant( + _ATTR_UPNP_MANUFACTURER, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MANUFACTURER_URL = DeprecatedConstant( + _ATTR_UPNP_MANUFACTURER_URL, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER_URL", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MODEL_DESCRIPTION = DeprecatedConstant( + _ATTR_UPNP_MODEL_DESCRIPTION, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_DESCRIPTION", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MODEL_NAME = DeprecatedConstant( + _ATTR_UPNP_MODEL_NAME, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NAME", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MODEL_NUMBER = DeprecatedConstant( + _ATTR_UPNP_MODEL_NUMBER, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NUMBER", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MODEL_URL = DeprecatedConstant( + _ATTR_UPNP_MODEL_URL, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_URL", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_SERIAL = DeprecatedConstant( + _ATTR_UPNP_SERIAL, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERIAL", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_SERVICE_LIST = DeprecatedConstant( + _ATTR_UPNP_SERVICE_LIST, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERVICE_LIST", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_UDN = DeprecatedConstant( + _ATTR_UPNP_UDN, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UDN", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_UPC = DeprecatedConstant( + _ATTR_UPNP_UPC, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UPC", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_PRESENTATION_URL = DeprecatedConstant( + _ATTR_UPNP_PRESENTATION_URL, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_PRESENTATION_URL", + "2026.2", +) # Attributes for accessing info added by Home Assistant ATTR_HA_MATCHING_DOMAINS = "x_homeassistant_matching_domains" PRIMARY_MATCH_KEYS = [ - ATTR_UPNP_MANUFACTURER, - ATTR_ST, - ATTR_UPNP_DEVICE_TYPE, - ATTR_NT, - ATTR_UPNP_MANUFACTURER_URL, + _ATTR_UPNP_MANUFACTURER, + _ATTR_ST, + _ATTR_UPNP_DEVICE_TYPE, + _ATTR_NT, + _ATTR_UPNP_MANUFACTURER_URL, ] _LOGGER = logging.getLogger(__name__) @@ -108,27 +190,16 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) - -@dataclass(slots=True) -class SsdpServiceInfo(BaseServiceInfo): - """Prepared info from ssdp/upnp entries.""" - - ssdp_usn: str - ssdp_st: str - upnp: Mapping[str, Any] - ssdp_location: str | None = None - ssdp_nt: str | None = None - ssdp_udn: str | None = None - ssdp_ext: str | None = None - ssdp_server: str | None = None - ssdp_headers: Mapping[str, Any] = field(default_factory=dict) - ssdp_all_locations: set[str] = field(default_factory=set) - x_homeassistant_matching_domains: set[str] = field(default_factory=set) +_DEPRECATED_SsdpServiceInfo = DeprecatedConstant( + _SsdpServiceInfo, + "homeassistant.helpers.service_info.ssdp.SsdpServiceInfo", + "2026.2", +) SsdpChange = Enum("SsdpChange", "ALIVE BYEBYE UPDATE") type SsdpHassJobCallback = HassJob[ - [SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None + [_SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None ] SSDP_SOURCE_SSDP_CHANGE_MAPPING: Mapping[SsdpSource, SsdpChange] = { @@ -148,7 +219,9 @@ def _format_err(name: str, *args: Any) -> str: @bind_hass async def async_register_callback( hass: HomeAssistant, - callback: Callable[[SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None], + callback: Callable[ + [_SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None + ], match_dict: dict[str, str] | None = None, ) -> Callable[[], None]: """Register to receive a callback on ssdp broadcast. @@ -169,7 +242,7 @@ async def async_register_callback( @bind_hass async def async_get_discovery_info_by_udn_st( hass: HomeAssistant, udn: str, st: str -) -> SsdpServiceInfo | None: +) -> _SsdpServiceInfo | None: """Fetch the discovery info cache.""" scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER] return await scanner.async_get_discovery_info_by_udn_st(udn, st) @@ -178,7 +251,7 @@ async def async_get_discovery_info_by_udn_st( @bind_hass async def async_get_discovery_info_by_st( hass: HomeAssistant, st: str -) -> list[SsdpServiceInfo]: +) -> list[_SsdpServiceInfo]: """Fetch all the entries matching the st.""" scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER] return await scanner.async_get_discovery_info_by_st(st) @@ -187,7 +260,7 @@ async def async_get_discovery_info_by_st( @bind_hass async def async_get_discovery_info_by_udn( hass: HomeAssistant, udn: str -) -> list[SsdpServiceInfo]: +) -> list[_SsdpServiceInfo]: """Fetch all the entries matching the udn.""" scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER] return await scanner.async_get_discovery_info_by_udn(udn) @@ -227,7 +300,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: def _async_process_callbacks( hass: HomeAssistant, callbacks: list[SsdpHassJobCallback], - discovery_info: SsdpServiceInfo, + discovery_info: _SsdpServiceInfo, ssdp_change: SsdpChange, ) -> None: for callback in callbacks: @@ -562,11 +635,11 @@ class Scanner: ) def _async_dismiss_discoveries( - self, byebye_discovery_info: SsdpServiceInfo + self, byebye_discovery_info: _SsdpServiceInfo ) -> None: """Dismiss all discoveries for the given address.""" for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( - SsdpServiceInfo, + _SsdpServiceInfo, lambda service_info: bool( service_info.ssdp_st == byebye_discovery_info.ssdp_st and service_info.ssdp_location == byebye_discovery_info.ssdp_location @@ -589,7 +662,7 @@ class Scanner: async def _async_headers_to_discovery_info( self, ssdp_device: SsdpDevice, headers: CaseInsensitiveDict - ) -> SsdpServiceInfo: + ) -> _SsdpServiceInfo: """Combine the headers and description into discovery_info. Building this is a bit expensive so we only do it on demand. @@ -602,7 +675,7 @@ class Scanner: async def async_get_discovery_info_by_udn_st( self, udn: str, st: str - ) -> SsdpServiceInfo | None: + ) -> _SsdpServiceInfo | None: """Return discovery_info for a udn and st.""" for ssdp_device in self._ssdp_devices: if ssdp_device.udn == udn: @@ -612,7 +685,7 @@ class Scanner: ) return None - async def async_get_discovery_info_by_st(self, st: str) -> list[SsdpServiceInfo]: + async def async_get_discovery_info_by_st(self, st: str) -> list[_SsdpServiceInfo]: """Return matching discovery_infos for a st.""" return [ await self._async_headers_to_discovery_info(ssdp_device, headers) @@ -620,7 +693,7 @@ class Scanner: if (headers := ssdp_device.combined_headers(st)) ] - async def async_get_discovery_info_by_udn(self, udn: str) -> list[SsdpServiceInfo]: + async def async_get_discovery_info_by_udn(self, udn: str) -> list[_SsdpServiceInfo]: """Return matching discovery_infos for a udn.""" return [ await self._async_headers_to_discovery_info(ssdp_device, headers) @@ -665,7 +738,7 @@ def discovery_info_from_headers_and_description( ssdp_device: SsdpDevice, combined_headers: CaseInsensitiveDict, info_desc: Mapping[str, Any], -) -> SsdpServiceInfo: +) -> _SsdpServiceInfo: """Convert headers and description to discovery_info.""" ssdp_usn = combined_headers["usn"] ssdp_st = combined_headers.get_lower("st") @@ -681,11 +754,11 @@ def discovery_info_from_headers_and_description( ssdp_st = combined_headers["nt"] # Ensure UPnP "udn" is set - if ATTR_UPNP_UDN not in upnp_info: + if _ATTR_UPNP_UDN not in upnp_info: if udn := _udn_from_usn(ssdp_usn): - upnp_info[ATTR_UPNP_UDN] = udn + upnp_info[_ATTR_UPNP_UDN] = udn - return SsdpServiceInfo( + return _SsdpServiceInfo( ssdp_usn=ssdp_usn, ssdp_st=ssdp_st, ssdp_ext=combined_headers.get_lower("ext"), @@ -887,3 +960,11 @@ class Server: """Stop UPnP/SSDP servers.""" for server in self._upnp_servers: await server.async_stop() + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 41e481fa58c..95fd1ff0ea5 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -9,7 +9,6 @@ from urllib.parse import urlparse import voluptuous as vol from homeassistant.components import ssdp -from homeassistant.components.ssdp import SsdpServiceInfo from homeassistant.config_entries import ( SOURCE_IGNORE, ConfigEntry, @@ -18,6 +17,12 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MODEL_NAME, + SsdpServiceInfo, +) from .const import ( CONFIG_ENTRY_FORCE_POLL, @@ -37,17 +42,17 @@ from .const import ( from .device import async_get_mac_address_from_host, get_preferred_location -def _friendly_name_from_discovery(discovery_info: ssdp.SsdpServiceInfo) -> str: +def _friendly_name_from_discovery(discovery_info: SsdpServiceInfo) -> str: """Extract user-friendly name from discovery.""" return cast( str, - discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) - or discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) + discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) + or discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or discovery_info.ssdp_headers.get("_host", ""), ) -def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool: +def _is_complete_discovery(discovery_info: SsdpServiceInfo) -> bool: """Test if discovery is complete and usable.""" return bool( discovery_info.ssdp_udn @@ -59,7 +64,7 @@ def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool: async def _async_discovered_igd_devices( hass: HomeAssistant, -) -> list[ssdp.SsdpServiceInfo]: +) -> list[SsdpServiceInfo]: """Discovery IGD devices.""" return await ssdp.async_get_discovery_info_by_st( hass, ST_IGD_V1 @@ -76,10 +81,10 @@ async def _async_mac_address_from_discovery( return await async_get_mac_address_from_host(hass, host) -def _is_igd_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: +def _is_igd_device(discovery_info: SsdpServiceInfo) -> bool: """Test if discovery is a complete IGD device.""" root_device_info = discovery_info.upnp - return root_device_info.get(ssdp.ATTR_UPNP_DEVICE_TYPE) in {ST_IGD_V1, ST_IGD_V2} + return root_device_info.get(ATTR_UPNP_DEVICE_TYPE) in {ST_IGD_V1, ST_IGD_V2} class UpnpFlowHandler(ConfigFlow, domain=DOMAIN): @@ -167,7 +172,7 @@ class UpnpFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered UPnP/IGD device. diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py index d6ad54c4a3d..c43e547a71e 100644 --- a/homeassistant/components/yamaha_musiccast/config_flow.py +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -10,10 +10,14 @@ from aiohttp import ClientConnectorError from aiomusiccast import MusicCastConnectionException, MusicCastDevice import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from . import get_upnp_desc from .const import CONF_SERIAL, CONF_UPNP_DESC, DOMAIN @@ -81,7 +85,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle ssdp discoveries.""" if not await MusicCastDevice.check_yamaha_ssdp( @@ -89,7 +93,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): ): return self.async_abort(reason="yxc_control_url_missing") - self.serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + self.serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] self.upnp_description = discovery_info.ssdp_location # ssdp_location and hostname have been checked in check_yamaha_ssdp so it is safe to ignore type assignment @@ -105,9 +109,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): self.context.update( { "title_placeholders": { - "name": discovery_info.upnp.get( - ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host - ) + "name": discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, self.host) } } ) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 7b12acf3571..a8d1eb10ee7 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -87,11 +87,11 @@ from .util.enum import try_parse_enum if TYPE_CHECKING: from .components.bluetooth import BluetoothServiceInfoBleak - from .components.ssdp import SsdpServiceInfo from .components.usb import UsbServiceInfo from .helpers.service_info.dhcp import DhcpServiceInfo from .helpers.service_info.hassio import HassioServiceInfo from .helpers.service_info.mqtt import MqttServiceInfo + from .helpers.service_info.ssdp import SsdpServiceInfo from .helpers.service_info.zeroconf import ZeroconfServiceInfo diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index e6670544acc..57cf5d9c1bc 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -16,10 +16,10 @@ if TYPE_CHECKING: import asyncio from homeassistant.components.bluetooth import BluetoothServiceInfoBleak - from homeassistant.components.ssdp import SsdpServiceInfo from .service_info.dhcp import DhcpServiceInfo from .service_info.mqtt import MqttServiceInfo + from .service_info.ssdp import SsdpServiceInfo from .service_info.zeroconf import ZeroconfServiceInfo type DiscoveryFunctionType[_R] = Callable[[HomeAssistant], _R] diff --git a/homeassistant/helpers/service_info/ssdp.py b/homeassistant/helpers/service_info/ssdp.py new file mode 100644 index 00000000000..4a3a5a24474 --- /dev/null +++ b/homeassistant/helpers/service_info/ssdp.py @@ -0,0 +1,41 @@ +"""SSDP discovery data.""" + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any, Final + +from homeassistant.data_entry_flow import BaseServiceInfo + +# Attributes for accessing info from retrieved UPnP device description +ATTR_ST: Final = "st" +ATTR_NT: Final = "nt" +ATTR_UPNP_DEVICE_TYPE: Final = "deviceType" +ATTR_UPNP_FRIENDLY_NAME: Final = "friendlyName" +ATTR_UPNP_MANUFACTURER: Final = "manufacturer" +ATTR_UPNP_MANUFACTURER_URL: Final = "manufacturerURL" +ATTR_UPNP_MODEL_DESCRIPTION: Final = "modelDescription" +ATTR_UPNP_MODEL_NAME: Final = "modelName" +ATTR_UPNP_MODEL_NUMBER: Final = "modelNumber" +ATTR_UPNP_MODEL_URL: Final = "modelURL" +ATTR_UPNP_SERIAL: Final = "serialNumber" +ATTR_UPNP_SERVICE_LIST: Final = "serviceList" +ATTR_UPNP_UDN: Final = "UDN" +ATTR_UPNP_UPC: Final = "UPC" +ATTR_UPNP_PRESENTATION_URL: Final = "presentationURL" + + +@dataclass(slots=True) +class SsdpServiceInfo(BaseServiceInfo): + """Prepared info from ssdp/upnp entries.""" + + ssdp_usn: str + ssdp_st: str + upnp: Mapping[str, Any] + ssdp_location: str | None = None + ssdp_nt: str | None = None + ssdp_udn: str | None = None + ssdp_ext: str | None = None + ssdp_server: str | None = None + ssdp_headers: Mapping[str, Any] = field(default_factory=dict) + ssdp_all_locations: set[str] = field(default_factory=set) + x_homeassistant_matching_domains: set[str] = field(default_factory=set) diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index ce13bbfa5d4..c595cc4e311 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -20,12 +20,15 @@ from homeassistant.components.deconz.const import ( DOMAIN as DECONZ_DOMAIN, HASSIO_CONFIGURATION_URL, ) -from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_SERIAL, +) from .conftest import API_KEY, BRIDGE_ID diff --git a/tests/components/deconz/test_hub.py b/tests/components/deconz/test_hub.py index 43c51179337..7fe89aaf550 100644 --- a/tests/components/deconz/test_hub.py +++ b/tests/components/deconz/test_hub.py @@ -9,15 +9,15 @@ from syrupy import SnapshotAssertion from homeassistant.components import ssdp from homeassistant.components.deconz.config_flow import DECONZ_MANUFACTURERURL from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN -from homeassistant.components.ssdp import ( - ATTR_UPNP_MANUFACTURER_URL, - ATTR_UPNP_SERIAL, - ATTR_UPNP_UDN, -) from homeassistant.config_entries import SOURCE_SSDP from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, +) from .conftest import BRIDGE_ID diff --git a/tests/components/directv/test_config_flow.py b/tests/components/directv/test_config_flow.py index ad22aa871b7..b698873d1e9 100644 --- a/tests/components/directv/test_config_flow.py +++ b/tests/components/directv/test_config_flow.py @@ -6,11 +6,11 @@ from unittest.mock import patch from aiohttp import ClientError as HTTPClientError from homeassistant.components.directv.const import CONF_RECEIVER_ID, DOMAIN -from homeassistant.components.ssdp import ATTR_UPNP_SERIAL from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL from . import ( HOST, diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py index acd96879b1e..f9271e75169 100644 --- a/tests/components/fritz/const.py +++ b/tests/components/fritz/const.py @@ -2,7 +2,6 @@ from homeassistant.components import ssdp from homeassistant.components.fritz.const import DOMAIN -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN from homeassistant.const import ( CONF_DEVICES, CONF_HOST, @@ -11,6 +10,10 @@ from homeassistant.const import ( CONF_SSL, CONF_USERNAME, ) +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, +) ATTR_HOST = "host" ATTR_NEW_SERIAL_NUMBER = "NewSerialNumber" diff --git a/tests/components/fritzbox/test_config_flow.py b/tests/components/fritzbox/test_config_flow.py index 0df6d0b2ea9..1387d5a9c1b 100644 --- a/tests/components/fritzbox/test_config_flow.py +++ b/tests/components/fritzbox/test_config_flow.py @@ -11,11 +11,14 @@ from requests.exceptions import HTTPError from homeassistant.components import ssdp from homeassistant.components.fritzbox.const import DOMAIN -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, +) from .const import CONF_FAKE_NAME, MOCK_CONFIG diff --git a/tests/components/kaleidescape/__init__.py b/tests/components/kaleidescape/__init__.py index 8182cb73743..a888d882d63 100644 --- a/tests/components/kaleidescape/__init__.py +++ b/tests/components/kaleidescape/__init__.py @@ -1,7 +1,10 @@ """Tests for Kaleidescape integration.""" from homeassistant.components import ssdp -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, +) MOCK_HOST = "127.0.0.1" MOCK_SERIAL = "123456" diff --git a/tests/components/lametric/test_config_flow.py b/tests/components/lametric/test_config_flow.py index 354cd4fa120..c0fb98f1908 100644 --- a/tests/components/lametric/test_config_flow.py +++ b/tests/components/lametric/test_config_flow.py @@ -14,17 +14,17 @@ from demetriek import ( import pytest from homeassistant.components.lametric.const import DOMAIN -from homeassistant.components.ssdp import ( - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_SERIAL, - SsdpServiceInfo, -) from homeassistant.config_entries import SOURCE_DHCP, SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_DEVICE, CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker diff --git a/tests/components/openhome/test_config_flow.py b/tests/components/openhome/test_config_flow.py index 7ab1e69106c..6430b8610e9 100644 --- a/tests/components/openhome/test_config_flow.py +++ b/tests/components/openhome/test_config_flow.py @@ -1,12 +1,15 @@ """Tests for the Openhome config flow module.""" -from homeassistant.components import ssdp from homeassistant.components.openhome.const import DOMAIN -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN from homeassistant.config_entries import SOURCE_SSDP from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -14,7 +17,7 @@ MOCK_UDN = "uuid:4c494e4e-1234-ab12-abcd-01234567819f" MOCK_FRIENDLY_NAME = "Test Client" MOCK_SSDP_LOCATION = "http://device:12345/description.xml" -MOCK_DISCOVER = ssdp.SsdpServiceInfo( +MOCK_DISCOVER = SsdpServiceInfo( ssdp_usn="usn", ssdp_st="st", ssdp_location=MOCK_SSDP_LOCATION, @@ -60,7 +63,7 @@ async def test_device_exists(hass: HomeAssistant) -> None: async def test_missing_udn(hass: HomeAssistant) -> None: """Test a ssdp import where discovery is missing udn.""" - broken_discovery = ssdp.SsdpServiceInfo( + broken_discovery = SsdpServiceInfo( ssdp_usn="usn", ssdp_st="st", ssdp_location=MOCK_SSDP_LOCATION, @@ -79,7 +82,7 @@ async def test_missing_udn(hass: HomeAssistant) -> None: async def test_missing_ssdp_location(hass: HomeAssistant) -> None: """Test a ssdp import where discovery is missing udn.""" - broken_discovery = ssdp.SsdpServiceInfo( + broken_discovery = SsdpServiceInfo( ssdp_usn="usn", ssdp_st="st", ssdp_location="", diff --git a/tests/components/roku/__init__.py b/tests/components/roku/__init__.py index fe3ef215524..36b09587d63 100644 --- a/tests/components/roku/__init__.py +++ b/tests/components/roku/__init__.py @@ -3,7 +3,10 @@ from ipaddress import ip_address from homeassistant.components import ssdp, zeroconf -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, +) NAME = "Roku 3" NAME_ROKUTV = '58" Onn Roku TV' diff --git a/tests/components/samsungtv/const.py b/tests/components/samsungtv/const.py index 1a7347ff0ce..5976c28c6ce 100644 --- a/tests/components/samsungtv/const.py +++ b/tests/components/samsungtv/const.py @@ -8,12 +8,6 @@ from homeassistant.components.samsungtv.const import ( METHOD_LEGACY, METHOD_WEBSOCKET, ) -from homeassistant.components.ssdp import ( - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_MANUFACTURER, - ATTR_UPNP_MODEL_NAME, - ATTR_UPNP_UDN, -) from homeassistant.const import ( CONF_HOST, CONF_IP_ADDRESS, @@ -24,6 +18,12 @@ from homeassistant.const import ( CONF_PORT, CONF_TOKEN, ) +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, +) MOCK_CONFIG = { CONF_HOST: "fake_host", diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index eb78332b7b3..f4a8badc2d9 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -33,13 +33,6 @@ from homeassistant.components.samsungtv.const import ( TIMEOUT_REQUEST, TIMEOUT_WEBSOCKET, ) -from homeassistant.components.ssdp import ( - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_MANUFACTURER, - ATTR_UPNP_MODEL_NAME, - ATTR_UPNP_UDN, - SsdpServiceInfo, -) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, @@ -54,6 +47,13 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import BaseServiceInfo, FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from homeassistant.setup import async_setup_component from .const import ( diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 7dc0f0095d4..a4ad1274fa6 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -2,6 +2,7 @@ from datetime import datetime from ipaddress import IPv4Address +from typing import Any from unittest.mock import ANY, AsyncMock, patch from async_upnp_client.server import UpnpServer @@ -19,6 +20,24 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.helpers.service_info.ssdp import ( + ATTR_NT, + ATTR_ST, + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_MODEL_DESCRIPTION, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_MODEL_URL, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_SERVICE_LIST, + ATTR_UPNP_UDN, + ATTR_UPNP_UPC, + SsdpServiceInfo, +) from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -26,6 +45,7 @@ from tests.common import ( MockConfigEntry, MockModule, async_fire_time_changed, + import_and_test_deprecated_constant, mock_integration, ) from tests.test_util.aiohttp import AiohttpClientMocker @@ -1094,3 +1114,105 @@ async def test_ssdp_rediscover_no_match( await hass.async_block_till_done() assert len(mock_flow_init.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("constant_name", "replacement_name", "replacement"), + [ + ( + "SsdpServiceInfo", + "homeassistant.helpers.service_info.ssdp.SsdpServiceInfo", + SsdpServiceInfo, + ), + ( + "ATTR_ST", + "homeassistant.helpers.service_info.ssdp.ATTR_ST", + ATTR_ST, + ), + ( + "ATTR_NT", + "homeassistant.helpers.service_info.ssdp.ATTR_NT", + ATTR_NT, + ), + ( + "ATTR_UPNP_DEVICE_TYPE", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_DEVICE_TYPE", + ATTR_UPNP_DEVICE_TYPE, + ), + ( + "ATTR_UPNP_FRIENDLY_NAME", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_FRIENDLY_NAME", + ATTR_UPNP_FRIENDLY_NAME, + ), + ( + "ATTR_UPNP_MANUFACTURER", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER", + ATTR_UPNP_MANUFACTURER, + ), + ( + "ATTR_UPNP_MANUFACTURER_URL", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER_URL", + ATTR_UPNP_MANUFACTURER_URL, + ), + ( + "ATTR_UPNP_MODEL_DESCRIPTION", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_DESCRIPTION", + ATTR_UPNP_MODEL_DESCRIPTION, + ), + ( + "ATTR_UPNP_MODEL_NAME", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NAME", + ATTR_UPNP_MODEL_NAME, + ), + ( + "ATTR_UPNP_MODEL_NUMBER", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NUMBER", + ATTR_UPNP_MODEL_NUMBER, + ), + ( + "ATTR_UPNP_MODEL_URL", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_URL", + ATTR_UPNP_MODEL_URL, + ), + ( + "ATTR_UPNP_SERIAL", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERIAL", + ATTR_UPNP_SERIAL, + ), + ( + "ATTR_UPNP_SERVICE_LIST", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERVICE_LIST", + ATTR_UPNP_SERVICE_LIST, + ), + ( + "ATTR_UPNP_UDN", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UDN", + ATTR_UPNP_UDN, + ), + ( + "ATTR_UPNP_UPC", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UPC", + ATTR_UPNP_UPC, + ), + ( + "ATTR_UPNP_PRESENTATION_URL", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_PRESENTATION_URL", + ATTR_UPNP_PRESENTATION_URL, + ), + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement_name: str, + replacement: Any, +) -> None: + """Test deprecated automation constants.""" + import_and_test_deprecated_constant( + caplog, + ssdp, + constant_name, + replacement_name, + replacement, + "2026.2", + ) diff --git a/tests/components/wilight/__init__.py b/tests/components/wilight/__init__.py index acaf2aef2a8..3710c6b9a9f 100644 --- a/tests/components/wilight/__init__.py +++ b/tests/components/wilight/__init__.py @@ -3,18 +3,18 @@ from pywilight.const import DOMAIN from homeassistant.components import ssdp -from homeassistant.components.ssdp import ( - ATTR_UPNP_MANUFACTURER, - ATTR_UPNP_MODEL_NAME, - ATTR_UPNP_MODEL_NUMBER, - ATTR_UPNP_SERIAL, -) from homeassistant.components.wilight.config_flow import ( CONF_MODEL_NAME, CONF_SERIAL_NUMBER, ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, +) from tests.common import MockConfigEntry diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index e0229ebe049..87433ef3911 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -22,7 +22,6 @@ import zigpy.types from homeassistant import config_entries from homeassistant.components import ssdp, usb, zeroconf from homeassistant.components.hassio import AddonError, AddonState -from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL from homeassistant.components.zha import config_flow, radio_manager from homeassistant.components.zha.const import ( CONF_BAUDRATE, @@ -43,6 +42,10 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_SERIAL, +) from tests.common import MockConfigEntry From 0eea2654152eaf31989c07340c8d0f17435e33bb Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 15 Jan 2025 08:04:42 -0500 Subject: [PATCH 0451/2987] Bump python-otbr-api to 2.7.0 (#135638) Bump OTBR API to 2.7.0 Bump `python-otbr-api` to 2.7.0 in `thread` as well --- homeassistant/components/otbr/manifest.json | 2 +- homeassistant/components/thread/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index ca0faa160f0..f4029f4aa9e 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.6.0"] + "requirements": ["python-otbr-api==2.7.0"] } diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 65d4c9d044c..868ced022b8 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.6.0", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.7.0", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 21d77d9720a..7158a43fd88 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2424,7 +2424,7 @@ python-opensky==1.0.1 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.6.0 +python-otbr-api==2.7.0 # homeassistant.components.overseerr python-overseerr==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 55b0ce54b2f..d2dbd17dda6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1960,7 +1960,7 @@ python-opensky==1.0.1 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.6.0 +python-otbr-api==2.7.0 # homeassistant.components.overseerr python-overseerr==0.5.0 From 8ae02aaba0f4e510a2eb77936d89d141fc06a0f4 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 15 Jan 2025 14:53:08 +0100 Subject: [PATCH 0452/2987] Add missing camera functions to pylint type hints plugin (#135676) --- pylint/plugins/hass_enforce_type_hints.py | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index a837650f3b5..d66845583d1 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -1017,6 +1017,34 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { return_type=None, has_async_counterpart=True, ), + TypeHintMatch( + function_name="async_handle_async_webrtc_offer", + arg_types={ + 1: "str", + 2: "str", + 3: "WebRTCSendMessage", + }, + return_type=None, + ), + TypeHintMatch( + function_name="async_on_webrtc_candidate", + arg_types={ + 1: "str", + 2: "RTCIceCandidateInit", + }, + return_type=None, + ), + TypeHintMatch( + function_name="close_webrtc_session", + arg_types={ + 1: "str", + }, + return_type=None, + ), + TypeHintMatch( + function_name="_async_get_webrtc_client_configuration", + return_type="WebRTCClientConfiguration", + ), ], ), ], From e83ee00af8961d26fe9a0834f1a9fd23b49d0bb4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Jan 2025 15:10:25 +0100 Subject: [PATCH 0453/2987] Move UsbServiceInfo to service_info helpers (#135663) * Move UsbServiceInfo to service_info helpers * Adjust components --- homeassistant/components/usb/__init__.py | 37 ++++++++++++------- homeassistant/config_entries.py | 2 +- homeassistant/helpers/service_info/usb.py | 17 +++++++++ .../homeassistant_sky_connect/test_util.py | 2 +- tests/components/usb/test_init.py | 30 +++++++++++++++ tests/components/zha/test_radio_manager.py | 2 +- 6 files changed, 73 insertions(+), 17 deletions(-) create mode 100644 homeassistant/helpers/service_info/usb.py diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 4517501bf43..c502c81dae6 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Coroutine, Sequence import dataclasses import fnmatch +from functools import partial import logging import os import sys @@ -24,9 +25,15 @@ from homeassistant.core import ( HomeAssistant, callback as hass_callback, ) -from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import config_validation as cv, discovery_flow, system_info from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) +from homeassistant.helpers.service_info.usb import UsbServiceInfo as _UsbServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.loader import USBMatcher, async_get_usb @@ -45,7 +52,6 @@ __all__ = [ "async_is_plugged_in", "async_register_scan_request_callback", "USBCallbackMatcher", - "UsbServiceInfo", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -104,16 +110,11 @@ def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> boo ) -@dataclasses.dataclass(slots=True) -class UsbServiceInfo(BaseServiceInfo): - """Prepared info from usb entries.""" - - device: str - vid: str - pid: str - serial_number: str | None - manufacturer: str | None - description: str | None +_DEPRECATED_UsbServiceInfo = DeprecatedConstant( + _UsbServiceInfo, + "homeassistant.helpers.service_info.usb.UsbServiceInfo", + "2026.2", +) @overload @@ -352,7 +353,7 @@ class USBDiscovery: if not matched: return - service_info: UsbServiceInfo | None = None + service_info: _UsbServiceInfo | None = None sorted_by_most_targeted = sorted(matched, key=lambda item: -len(item)) most_matched_fields = len(sorted_by_most_targeted[0]) @@ -364,7 +365,7 @@ class USBDiscovery: break if service_info is None: - service_info = UsbServiceInfo( + service_info = _UsbServiceInfo( device=await self.hass.async_add_executor_job( get_serial_by_id, device.device ), @@ -457,3 +458,11 @@ async def websocket_usb_scan( if not usb_discovery.observer_active: await usb_discovery.async_request_scan() connection.send_result(msg["id"]) + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a8d1eb10ee7..930b3242aad 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -87,11 +87,11 @@ from .util.enum import try_parse_enum if TYPE_CHECKING: from .components.bluetooth import BluetoothServiceInfoBleak - from .components.usb import UsbServiceInfo from .helpers.service_info.dhcp import DhcpServiceInfo from .helpers.service_info.hassio import HassioServiceInfo from .helpers.service_info.mqtt import MqttServiceInfo from .helpers.service_info.ssdp import SsdpServiceInfo + from .helpers.service_info.usb import UsbServiceInfo from .helpers.service_info.zeroconf import ZeroconfServiceInfo diff --git a/homeassistant/helpers/service_info/usb.py b/homeassistant/helpers/service_info/usb.py new file mode 100644 index 00000000000..c7d6f6ea143 --- /dev/null +++ b/homeassistant/helpers/service_info/usb.py @@ -0,0 +1,17 @@ +"""USB discovery data.""" + +from dataclasses import dataclass + +from homeassistant.data_entry_flow import BaseServiceInfo + + +@dataclass(slots=True) +class UsbServiceInfo(BaseServiceInfo): + """Prepared info from usb entries.""" + + device: str + vid: str + pid: str + serial_number: str | None + manufacturer: str | None + description: str | None diff --git a/tests/components/homeassistant_sky_connect/test_util.py b/tests/components/homeassistant_sky_connect/test_util.py index 1d1d70c1b4c..2801b3d00bb 100644 --- a/tests/components/homeassistant_sky_connect/test_util.py +++ b/tests/components/homeassistant_sky_connect/test_util.py @@ -8,7 +8,7 @@ from homeassistant.components.homeassistant_sky_connect.util import ( get_hardware_variant, get_usb_service_info, ) -from homeassistant.components.usb import UsbServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index bbd802afc95..2f6dc72b4f8 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -2,6 +2,7 @@ import os import sys +from typing import Any from unittest.mock import MagicMock, Mock, call, patch, sentinel import pytest @@ -9,10 +10,12 @@ import pytest from homeassistant.components import usb from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.setup import async_setup_component from . import conbee_device, slae_sh_device +from tests.common import import_and_test_deprecated_constant from tests.typing import WebSocketGenerator @@ -1160,3 +1163,30 @@ async def test_cp2102n_ordering_on_macos( # We always use `cu.SLAB_USBtoUART` assert mock_config_flow.mock_calls[0][2]["data"].device == "/dev/cu.SLAB_USBtoUART2" + + +@pytest.mark.parametrize( + ("constant_name", "replacement_name", "replacement"), + [ + ( + "UsbServiceInfo", + "homeassistant.helpers.service_info.usb.UsbServiceInfo", + UsbServiceInfo, + ), + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement_name: str, + replacement: Any, +) -> None: + """Test deprecated automation constants.""" + import_and_test_deprecated_constant( + caplog, + usb, + constant_name, + replacement_name, + replacement, + "2026.2", + ) diff --git a/tests/components/zha/test_radio_manager.py b/tests/components/zha/test_radio_manager.py index 0a51aaa6dba..59494dd0d09 100644 --- a/tests/components/zha/test_radio_manager.py +++ b/tests/components/zha/test_radio_manager.py @@ -11,12 +11,12 @@ import zigpy.config from zigpy.config import CONF_DEVICE_PATH import zigpy.types -from homeassistant.components.usb import UsbServiceInfo from homeassistant.components.zha import radio_manager from homeassistant.components.zha.const import DOMAIN from homeassistant.components.zha.radio_manager import ProbeResult, ZhaRadioManager from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.usb import UsbServiceInfo from tests.common import MockConfigEntry From 6a506482231488078d62570b71e0b9fc5d36a371 Mon Sep 17 00:00:00 2001 From: Mick Montorier-Aberman Date: Wed, 15 Jan 2025 15:33:21 +0100 Subject: [PATCH 0454/2987] Call async_forward_setup_entry after the first refresh in SwitchBot Cloud (#135625) --- .../components/switchbot_cloud/__init__.py | 2 +- .../components/switchbot_cloud/entity.py | 15 +++++++++++++++ .../components/switchbot_cloud/lock.py | 8 +++----- .../components/switchbot_cloud/sensor.py | 8 +++----- .../components/switchbot_cloud/switch.py | 17 ++++++----------- .../components/switchbot_cloud/vacuum.py | 7 ++----- 6 files changed, 30 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 5f17ca516b9..e7313648e6a 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -135,10 +135,10 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: hass.data[DOMAIN][config.entry_id] = SwitchbotCloudData( api=api, devices=make_device_data(hass, api, devices, coordinators_by_id) ) - await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) await gather( *[coordinator.async_refresh() for coordinator in coordinators_by_id.values()] ) + await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) return True diff --git a/homeassistant/components/switchbot_cloud/entity.py b/homeassistant/components/switchbot_cloud/entity.py index f77adb7b192..74adcb049c1 100644 --- a/homeassistant/components/switchbot_cloud/entity.py +++ b/homeassistant/components/switchbot_cloud/entity.py @@ -4,6 +4,7 @@ from typing import Any from switchbot_api import Commands, Device, Remote, SwitchBotAPI +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -48,3 +49,17 @@ class SwitchBotCloudEntity(CoordinatorEntity[SwitchBotCoordinator]): command_type, parameters, ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._set_attributes() + super()._handle_coordinator_update() + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + + async def async_added_to_hass(self) -> None: + """Run when entity is about to be added to hass.""" + await super().async_added_to_hass() + self._set_attributes() diff --git a/homeassistant/components/switchbot_cloud/lock.py b/homeassistant/components/switchbot_cloud/lock.py index 2fbd551b919..52f48c66d38 100644 --- a/homeassistant/components/switchbot_cloud/lock.py +++ b/homeassistant/components/switchbot_cloud/lock.py @@ -6,7 +6,7 @@ from switchbot_api import LockCommands from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SwitchbotCloudData @@ -32,12 +32,10 @@ class SwitchBotCloudLock(SwitchBotCloudEntity, LockEntity): _attr_name = None - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if coord_data := self.coordinator.data: self._attr_is_locked = coord_data["lockState"] == "locked" - self.async_write_ha_state() async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 227b46d467c..1f755c141a2 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SwitchbotCloudData @@ -166,10 +166,8 @@ class SwitchBotCloudSensor(SwitchBotCloudEntity, SensorEntity): self.entity_description = description self._attr_unique_id = f"{device.device_id}_{description.key}" - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if not self.coordinator.data: return self._attr_native_value = self.coordinator.data.get(self.entity_description.key) - self.async_write_ha_state() diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py index 281ebb9322e..0781c91bc35 100644 --- a/homeassistant/components/switchbot_cloud/switch.py +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -46,21 +46,18 @@ class SwitchBotCloudSwitch(SwitchBotCloudEntity, SwitchEntity): self._attr_is_on = False self.async_write_ha_state() - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if not self.coordinator.data: return self._attr_is_on = self.coordinator.data.get("power") == PowerState.ON.value - self.async_write_ha_state() class SwitchBotCloudRemoteSwitch(SwitchBotCloudSwitch): """Representation of a SwitchBot switch provider by a remote.""" - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" class SwitchBotCloudPlugSwitch(SwitchBotCloudSwitch): @@ -72,13 +69,11 @@ class SwitchBotCloudPlugSwitch(SwitchBotCloudSwitch): class SwitchBotCloudRelaySwitchSwitch(SwitchBotCloudSwitch): """Representation of a SwitchBot relay switch.""" - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if not self.coordinator.data: return self._attr_is_on = self.coordinator.data.get("switchStatus") == 1 - self.async_write_ha_state() @callback diff --git a/homeassistant/components/switchbot_cloud/vacuum.py b/homeassistant/components/switchbot_cloud/vacuum.py index 2d2a1783d73..84db7cfdbb8 100644 --- a/homeassistant/components/switchbot_cloud/vacuum.py +++ b/homeassistant/components/switchbot_cloud/vacuum.py @@ -99,9 +99,8 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): """Start or resume the cleaning task.""" await self.send_api_command(VacuumCommands.START) - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if not self.coordinator.data: return @@ -111,8 +110,6 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): switchbot_state = str(self.coordinator.data.get("workingStatus")) self._attr_activity = VACUUM_SWITCHBOT_STATE_TO_HA_STATE.get(switchbot_state) - self.async_write_ha_state() - @callback def _async_make_entity( From bc8a2b58d33f9a2b17db0ba4e39f2e306a587249 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Jan 2025 15:43:46 +0100 Subject: [PATCH 0455/2987] Use new ServiceInfo location in components (part 1) (#135682) --- .../components/airgradient/config_flow.py | 4 +-- .../androidtv_remote/config_flow.py | 4 +-- .../components/arcam_fmj/config_flow.py | 6 ++--- homeassistant/components/awair/config_flow.py | 5 ++-- homeassistant/components/axis/config_flow.py | 21 ++++++++++----- homeassistant/components/baf/config_flow.py | 4 +-- .../components/blebox/config_flow.py | 4 +-- .../components/bluesound/config_flow.py | 4 +-- homeassistant/components/bond/config_flow.py | 4 +-- .../components/braviatv/config_flow.py | 15 +++++++---- .../components/broadlink/config_flow.py | 4 +-- .../components/cambridge_audio/config_flow.py | 4 +-- homeassistant/components/cast/config_flow.py | 5 ++-- .../components/daikin/config_flow.py | 4 +-- .../components/deconz/config_flow.py | 6 ++--- .../components/denonavr/config_flow.py | 26 ++++++++++--------- .../components/devialet/config_flow.py | 4 +-- .../devolo_home_control/config_flow.py | 4 +-- .../components/directv/config_flow.py | 10 +++---- homeassistant/components/dlink/config_flow.py | 4 +-- .../components/doorbird/config_flow.py | 4 +-- .../components/elgato/config_flow.py | 5 ++-- homeassistant/components/elkm1/config_flow.py | 4 +-- .../components/emonitor/config_flow.py | 4 +-- .../components/enphase_envoy/config_flow.py | 4 +-- .../components/flux_led/config_flow.py | 4 +-- .../components/forked_daapd/config_flow.py | 4 +-- .../components/freebox/config_flow.py | 4 +-- homeassistant/components/fritz/config_flow.py | 15 +++++++---- .../components/fritzbox/config_flow.py | 12 ++++++--- .../frontier_silicon/config_flow.py | 4 +-- .../components/goalzero/config_flow.py | 4 +-- .../components/gogogate2/config_flow.py | 14 +++++----- .../components/guardian/config_flow.py | 7 ++--- .../components/harmony/config_flow.py | 9 ++++--- homeassistant/components/heos/config_flow.py | 11 ++++---- .../homekit_controller/config_flow.py | 11 +++++--- .../components/homewizard/config_flow.py | 5 ++-- .../components/huawei_lte/config_flow.py | 19 +++++++++----- homeassistant/components/hue/config_flow.py | 6 ++--- .../hunterdouglas_powerview/config_flow.py | 9 ++++--- .../components/hyperion/config_flow.py | 6 ++--- 42 files changed, 175 insertions(+), 132 deletions(-) diff --git a/homeassistant/components/airgradient/config_flow.py b/homeassistant/components/airgradient/config_flow.py index 70fa8a1755b..a2f9440d376 100644 --- a/homeassistant/components/airgradient/config_flow.py +++ b/homeassistant/components/airgradient/config_flow.py @@ -11,10 +11,10 @@ from airgradient import ( from awesomeversion import AwesomeVersion import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -37,7 +37,7 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN): await self.client.set_configuration_control(ConfigurationControl.LOCAL) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.data[CONF_HOST] = host = discovery_info.host diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 4df25247881..78f24fc498c 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -14,7 +14,6 @@ from androidtvremote2 import ( ) import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, @@ -31,6 +30,7 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, SelectSelectorMode, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_APP_ICON, CONF_APP_NAME, CONF_APPS, CONF_ENABLE_IME, DOMAIN from .helpers import create_api, get_enable_ime @@ -142,7 +142,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" _LOGGER.debug("Android TV device found via zeroconf: %s", discovery_info) diff --git a/homeassistant/components/arcam_fmj/config_flow.py b/homeassistant/components/arcam_fmj/config_flow.py index 6c037591688..e1886a1db60 100644 --- a/homeassistant/components/arcam_fmj/config_flow.py +++ b/homeassistant/components/arcam_fmj/config_flow.py @@ -9,10 +9,10 @@ from arcam.fmj.client import Client, ConnectionFailed from arcam.fmj.utils import get_uniqueid_from_host, get_uniqueid_from_udn import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN @@ -88,12 +88,12 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered device.""" host = str(urlparse(discovery_info.ssdp_location).hostname) port = DEFAULT_PORT - uuid = get_uniqueid_from_udn(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + uuid = get_uniqueid_from_udn(discovery_info.upnp[ATTR_UPNP_UDN]) if not uuid: return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/awair/config_flow.py b/homeassistant/components/awair/config_flow.py index 88985b0db10..429187e1f5b 100644 --- a/homeassistant/components/awair/config_flow.py +++ b/homeassistant/components/awair/config_flow.py @@ -11,11 +11,12 @@ from python_awair.exceptions import AuthError, AwairError from python_awair.user import AwairUser import voluptuous as vol -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DEVICE, CONF_HOST from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER @@ -29,7 +30,7 @@ class AwairFlowHandler(ConfigFlow, domain=DOMAIN): host: str async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 592b1e2d41f..9f801882387 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -10,7 +10,6 @@ from urllib.parse import urlsplit import voluptuous as vol -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.config_entries import ( SOURCE_IGNORE, SOURCE_REAUTH, @@ -32,6 +31,14 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType from homeassistant.util.network import is_link_local @@ -190,7 +197,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN): return await self.async_step_user() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a DHCP discovered Axis device.""" return await self._process_discovered_device( @@ -203,21 +210,21 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a SSDP discovered Axis device.""" - url = urlsplit(discovery_info.upnp[ssdp.ATTR_UPNP_PRESENTATION_URL]) + url = urlsplit(discovery_info.upnp[ATTR_UPNP_PRESENTATION_URL]) return await self._process_discovered_device( { CONF_HOST: url.hostname, - CONF_MAC: format_mac(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]), - CONF_NAME: f"{discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]}", + CONF_MAC: format_mac(discovery_info.upnp[ATTR_UPNP_SERIAL]), + CONF_NAME: f"{discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]}", CONF_PORT: url.port, } ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a Zeroconf discovered Axis device.""" return await self._process_discovered_device( diff --git a/homeassistant/components/baf/config_flow.py b/homeassistant/components/baf/config_flow.py index 0d56699e1ce..4dbb59165fa 100644 --- a/homeassistant/components/baf/config_flow.py +++ b/homeassistant/components/baf/config_flow.py @@ -10,9 +10,9 @@ from aiobafi6 import Device, Service from aiobafi6.discovery import PORT import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, RUN_TIMEOUT from .models import BAFDiscovery @@ -44,7 +44,7 @@ class BAFFlowHandler(ConfigFlow, domain=DOMAIN): self.discovery: BAFDiscovery | None = None async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" if discovery_info.ip_address.version == 6: diff --git a/homeassistant/components/blebox/config_flow.py b/homeassistant/components/blebox/config_flow.py index 2221e35a81f..523b5af793f 100644 --- a/homeassistant/components/blebox/config_flow.py +++ b/homeassistant/components/blebox/config_flow.py @@ -15,10 +15,10 @@ from blebox_uniapi.error import ( from blebox_uniapi.session import ApiHost import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import get_maybe_authenticated_session from .const import ( @@ -84,7 +84,7 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" hass = self.hass diff --git a/homeassistant/components/bluesound/config_flow.py b/homeassistant/components/bluesound/config_flow.py index b5e31fb2ed7..2f002b70e1d 100644 --- a/homeassistant/components/bluesound/config_flow.py +++ b/homeassistant/components/bluesound/config_flow.py @@ -7,10 +7,10 @@ from pyblu import Player, SyncStatus from pyblu.errors import PlayerUnreachableError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN from .media_player import DEFAULT_PORT @@ -72,7 +72,7 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" if discovery_info.port is not None: diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index a12d3057258..38abd63186a 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -11,12 +11,12 @@ from aiohttp import ClientConnectionError, ClientResponseError from bond_async import Bond import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN from .utils import BondHub @@ -97,7 +97,7 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered[CONF_NAME] = hub_name async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" name: str = discovery_info.name diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py index db5c72d7932..5d775b98180 100644 --- a/homeassistant/components/braviatv/config_flow.py +++ b/homeassistant/components/braviatv/config_flow.py @@ -10,11 +10,16 @@ from aiohttp import CookieJar from pybravia import BraviaAuthError, BraviaClient, BraviaError, BraviaNotSupported import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_CLIENT_ID, CONF_HOST, CONF_MAC, CONF_NAME, CONF_PIN from homeassistant.helpers import instance_id from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from homeassistant.util.network import is_host_valid from .const import ( @@ -202,14 +207,14 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered device.""" # We can cast the hostname to str because the ssdp_location is not bytes and # not a relative url host = cast(str, urlparse(discovery_info.ssdp_location).hostname) - await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN]) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._async_abort_entries_match({CONF_HOST: host}) @@ -221,8 +226,8 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN): if "videoScreen" not in service_types: return self.async_abort(reason="not_bravia_device") - model_name = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] - friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + model_name = discovery_info.upnp[ATTR_UPNP_MODEL_NAME] + friendly_name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] self.context["title_placeholders"] = { CONF_NAME: f"{model_name} ({friendly_name})", diff --git a/homeassistant/components/broadlink/config_flow.py b/homeassistant/components/broadlink/config_flow.py index c9b2fb46608..617e466a1b1 100644 --- a/homeassistant/components/broadlink/config_flow.py +++ b/homeassistant/components/broadlink/config_flow.py @@ -15,7 +15,6 @@ from broadlink.exceptions import ( ) import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( SOURCE_IMPORT, SOURCE_REAUTH, @@ -25,6 +24,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_TIMEOUT, CONF_TYPE from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEFAULT_PORT, DEFAULT_TIMEOUT, DEVICE_TYPES, DOMAIN from .helpers import format_mac @@ -65,7 +65,7 @@ class BroadlinkFlowHandler(ConfigFlow, domain=DOMAIN): } async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" host = discovery_info.ip diff --git a/homeassistant/components/cambridge_audio/config_flow.py b/homeassistant/components/cambridge_audio/config_flow.py index 6f5a92feac0..fb0ad66c652 100644 --- a/homeassistant/components/cambridge_audio/config_flow.py +++ b/homeassistant/components/cambridge_audio/config_flow.py @@ -6,7 +6,6 @@ from typing import Any from aiostreammagic import StreamMagicClient import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, @@ -14,6 +13,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONNECT_TIMEOUT, DOMAIN, STREAM_MAGIC_EXCEPTIONS @@ -30,7 +30,7 @@ class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN): self.data: dict[str, Any] = {} async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.data[CONF_HOST] = host = discovery_info.host diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index 03a3f2ea1f8..034cf856023 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -6,7 +6,7 @@ from typing import Any import voluptuous as vol -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -16,6 +16,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_UUID from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, DOMAIN @@ -50,7 +51,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_config() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" await self.async_set_unique_id(DOMAIN) diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index 5956d31c5fb..cc25a88ae39 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -14,10 +14,10 @@ from pydaikin.exceptions import DaikinException from pydaikin.factory import DaikinFactory import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_UUID from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, KEY_MAC, TIMEOUT @@ -142,7 +142,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a discovered Daikin device.""" _LOGGER.debug("Zeroconf user_input: %s", discovery_info) diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index ed54701f656..7f5fc96c022 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -19,7 +19,6 @@ from pydeconz.utils import ( ) import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( SOURCE_HASSIO, ConfigEntry, @@ -31,6 +30,7 @@ from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo from .const import ( CONF_ALLOW_CLIP_SENSOR, @@ -220,13 +220,13 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_link() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered deCONZ bridge.""" if LOGGER.isEnabledFor(logging.DEBUG): LOGGER.debug("deCONZ SSDP discovery %s", pformat(discovery_info)) - self.bridge_id = normalize_bridge_id(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]) + self.bridge_id = normalize_bridge_id(discovery_info.upnp[ATTR_UPNP_SERIAL]) parsed_url = urlparse(discovery_info.ssdp_location) entry = await self.async_set_unique_id(self.bridge_id) diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py index 9ff05411588..9601b67081c 100644 --- a/homeassistant/components/denonavr/config_flow.py +++ b/homeassistant/components/denonavr/config_flow.py @@ -10,7 +10,6 @@ import denonavr from denonavr.exceptions import AvrNetworkError, AvrTimoutError import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -20,6 +19,13 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TYPE from homeassistant.core import callback from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from .receiver import ConnectDenonAVR @@ -232,7 +238,7 @@ class DenonAvrFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered Denon AVR. @@ -241,22 +247,20 @@ class DenonAvrFlowHandler(ConfigFlow, domain=DOMAIN): """ # Filter out non-Denon AVRs#1 if ( - discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) + discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER) not in SUPPORTED_MANUFACTURERS ): return self.async_abort(reason="not_denonavr_manufacturer") # Check if required information is present to set the unique_id if ( - ssdp.ATTR_UPNP_MODEL_NAME not in discovery_info.upnp - or ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp + ATTR_UPNP_MODEL_NAME not in discovery_info.upnp + or ATTR_UPNP_SERIAL not in discovery_info.upnp ): return self.async_abort(reason="not_denonavr_missing") - self.model_name = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME].replace( - "*", "" - ) - self.serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + self.model_name = discovery_info.upnp[ATTR_UPNP_MODEL_NAME].replace("*", "") + self.serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] assert discovery_info.ssdp_location is not None self.host = urlparse(discovery_info.ssdp_location).hostname @@ -270,9 +274,7 @@ class DenonAvrFlowHandler(ConfigFlow, domain=DOMAIN): self.context.update( { "title_placeholders": { - "name": discovery_info.upnp.get( - ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host - ) + "name": discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, self.host) } } ) diff --git a/homeassistant/components/devialet/config_flow.py b/homeassistant/components/devialet/config_flow.py index 41acfa4b5a7..45a00fc4073 100644 --- a/homeassistant/components/devialet/config_flow.py +++ b/homeassistant/components/devialet/config_flow.py @@ -8,10 +8,10 @@ from typing import Any from devialet.devialet_api import DevialetApi import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -70,7 +70,7 @@ class DevialetFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" LOGGER.debug("Devialet device found via ZEROCONF: %s", discovery_info) diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py index e15204af7c2..c4f57b2398a 100644 --- a/homeassistant/components/devolo_home_control/config_flow.py +++ b/homeassistant/components/devolo_home_control/config_flow.py @@ -7,7 +7,6 @@ from typing import Any import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, @@ -16,6 +15,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import configure_mydevolo from .const import DOMAIN, SUPPORTED_MODEL_TYPES @@ -48,7 +48,7 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): return self._show_form(step_id="user", errors={"base": "invalid_auth"}) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" # Check if it is a gateway diff --git a/homeassistant/components/directv/config_flow.py b/homeassistant/components/directv/config_flow.py index 1e0577b4f7c..927d2325c2d 100644 --- a/homeassistant/components/directv/config_flow.py +++ b/homeassistant/components/directv/config_flow.py @@ -9,11 +9,11 @@ from urllib.parse import urlparse from directv import DIRECTV, DIRECTVError import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo from .const import CONF_RECEIVER_ID, DOMAIN @@ -67,7 +67,7 @@ class DirecTVConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle SSDP discovery.""" # We can cast the hostname to str because the ssdp_location is not bytes and @@ -75,10 +75,8 @@ class DirecTVConfigFlow(ConfigFlow, domain=DOMAIN): host = cast(str, urlparse(discovery_info.ssdp_location).hostname) receiver_id = None - if discovery_info.upnp.get(ssdp.ATTR_UPNP_SERIAL): - receiver_id = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL][ - 4: - ] # strips off RID- + if discovery_info.upnp.get(ATTR_UPNP_SERIAL): + receiver_id = discovery_info.upnp[ATTR_UPNP_SERIAL][4:] # strips off RID- self.context.update({"title_placeholders": {"name": host}}) diff --git a/homeassistant/components/dlink/config_flow.py b/homeassistant/components/dlink/config_flow.py index 4452a2958fc..02ef94dae7d 100644 --- a/homeassistant/components/dlink/config_flow.py +++ b/homeassistant/components/dlink/config_flow.py @@ -8,9 +8,9 @@ from typing import Any from pyW215.pyW215 import SmartPlug import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_USE_LEGACY_PROTOCOL, DEFAULT_NAME, DEFAULT_USERNAME, DOMAIN @@ -25,7 +25,7 @@ class DLinkFlowHandler(ConfigFlow, domain=DOMAIN): self.ip_address: str | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" await self.async_set_unique_id(discovery_info.macaddress) diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index ebb1d6fc126..6a954f5310f 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -11,7 +11,6 @@ from aiohttp import ClientResponseError from doorbirdpy import DoorBird import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -22,6 +21,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNA from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType from .const import ( @@ -158,7 +158,7 @@ class DoorBirdConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="user", data_schema=data, errors=errors) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a discovered doorbird device.""" macaddress = discovery_info.properties["macaddress"] diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py index e20afc73a2d..a47f039384c 100644 --- a/homeassistant/components/elgato/config_flow.py +++ b/homeassistant/components/elgato/config_flow.py @@ -7,11 +7,12 @@ from typing import Any from elgato import Elgato, ElgatoError import voluptuous as vol -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -43,7 +44,7 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): return self._async_create_entry() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.host = discovery_info.host diff --git a/homeassistant/components/elkm1/config_flow.py b/homeassistant/components/elkm1/config_flow.py index a3dd1d46f8b..c486a385721 100644 --- a/homeassistant/components/elkm1/config_flow.py +++ b/homeassistant/components/elkm1/config_flow.py @@ -9,7 +9,6 @@ from elkm1_lib.discovery import ElkSystem from elkm1_lib.elk import Elk import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_ADDRESS, @@ -21,6 +20,7 @@ from homeassistant.const import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType, VolDictType from homeassistant.util import slugify from homeassistant.util.network import is_ip_address @@ -140,7 +140,7 @@ class Elkm1ConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_devices: dict[str, ElkSystem] = {} async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" self._discovered_device = ElkSystem( diff --git a/homeassistant/components/emonitor/config_flow.py b/homeassistant/components/emonitor/config_flow.py index 833b80f9d47..458eb5ae3c7 100644 --- a/homeassistant/components/emonitor/config_flow.py +++ b/homeassistant/components/emonitor/config_flow.py @@ -7,12 +7,12 @@ from aioemonitor import Emonitor import aiohttp import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import name_short_mac from .const import DOMAIN @@ -69,7 +69,7 @@ class EmonitorConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" self.discovered_ip = discovery_info.ip diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 031d1883d1f..654e2262730 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -10,7 +10,6 @@ from awesomeversion import AwesomeVersion from pyenphase import AUTH_TOKEN_MIN_VERSION, Envoy, EnvoyError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigFlow, @@ -20,6 +19,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType from .const import ( @@ -123,7 +123,7 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): } async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" if _LOGGER.isEnabledFor(logging.DEBUG): diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py index 9a02120f33a..035be5b115c 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -16,7 +16,6 @@ from flux_led.const import ( from flux_led.scanner import FluxLEDDiscovery import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( SOURCE_IGNORE, ConfigEntry, @@ -30,6 +29,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType from . import async_wifi_bulb_for_host @@ -78,7 +78,7 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN): return FluxLedOptionsFlow() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" self._discovered_device = FluxLEDDiscovery( diff --git a/homeassistant/components/forked_daapd/config_flow.py b/homeassistant/components/forked_daapd/config_flow.py index 5fb9f08f1c0..b2b2d498f60 100644 --- a/homeassistant/components/forked_daapd/config_flow.py +++ b/homeassistant/components/forked_daapd/config_flow.py @@ -7,7 +7,6 @@ from typing import Any from pyforked_daapd import ForkedDaapdAPI import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -17,6 +16,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_LIBRESPOT_JAVA_PORT, @@ -164,7 +164,7 @@ class ForkedDaapdFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a discovered forked-daapd device.""" version_num = 0 diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index 88e2165defd..62a1cd14b3d 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -6,9 +6,9 @@ from typing import Any from freebox_api.exceptions import AuthorizationError, HttpRequestError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN from .router import get_api, get_hosts_list_if_supported @@ -99,7 +99,7 @@ class FreeboxFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="link", errors=errors) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Initialize flow from zeroconf.""" zeroconf_properties = discovery_info.properties diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 244c7036a1c..7b6057b3ba2 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -13,7 +13,6 @@ from fritzconnection import FritzConnection from fritzconnection.core.exceptions import FritzConnectionException import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, @@ -32,6 +31,12 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from homeassistant.helpers.typing import VolDictType from .const import ( @@ -150,7 +155,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): return DEFAULT_HTTPS_PORT if user_input[CONF_SSL] else DEFAULT_HTTP_PORT async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" ssdp_location: ParseResult = urlparse(discovery_info.ssdp_location or "") @@ -160,12 +165,12 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): self._host = host self._name = ( - discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) - or discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) + or discovery_info.upnp[ATTR_UPNP_MODEL_NAME] ) uuid: str | None - if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): + if uuid := discovery_info.upnp.get(ATTR_UPNP_UDN): uuid = uuid.removeprefix("uuid:") await self.async_set_unique_id(uuid) self._abort_if_unique_id_configured({CONF_HOST: self._host}) diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py index c0e0f62285a..3f66b43cc0c 100644 --- a/homeassistant/components/fritzbox/config_flow.py +++ b/homeassistant/components/fritzbox/config_flow.py @@ -11,9 +11,13 @@ from pyfritzhome import Fritzhome, LoginError from requests.exceptions import HTTPError import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN @@ -109,7 +113,7 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" host = urlparse(discovery_info.ssdp_location).hostname @@ -121,7 +125,7 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): ): return self.async_abort(reason="ignore_ip6_link_local") - if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): + if uuid := discovery_info.upnp.get(ATTR_UPNP_UDN): uuid = uuid.removeprefix("uuid:") await self.async_set_unique_id(uuid) self._abort_if_unique_id_configured({CONF_HOST: host}) @@ -137,7 +141,7 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): self.hass.config_entries.async_update_entry(entry, unique_id=uuid) return self.async_abort(reason="already_configured") - self._name = str(discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) or host) + self._name = str(discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) or host) self.context["title_placeholders"] = {"name": self._name} return await self.async_step_confirm() diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index 0612419fc33..f6514da28ff 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -15,9 +15,9 @@ from afsapi import ( ) import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( CONF_WEBFSAPI_URL, @@ -87,7 +87,7 @@ class FrontierSiliconConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Process entity discovered via SSDP.""" diff --git a/homeassistant/components/goalzero/config_flow.py b/homeassistant/components/goalzero/config_flow.py index dabe642b658..9764d36e42c 100644 --- a/homeassistant/components/goalzero/config_flow.py +++ b/homeassistant/components/goalzero/config_flow.py @@ -8,11 +8,11 @@ from typing import Any from goalzero import Yeti, exceptions import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEFAULT_NAME, DOMAIN, MANUFACTURER @@ -27,7 +27,7 @@ class GoalZeroFlowHandler(ConfigFlow, domain=DOMAIN): _discovered_ip: str async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" diff --git a/homeassistant/components/gogogate2/config_flow.py b/homeassistant/components/gogogate2/config_flow.py index 837c0454719..0348d0b428c 100644 --- a/homeassistant/components/gogogate2/config_flow.py +++ b/homeassistant/components/gogogate2/config_flow.py @@ -10,7 +10,6 @@ from ismartgate.common import AbstractInfoResponse, ApiError from ismartgate.const import GogoGate2ApiErrorCode, ISmartGateApiErrorCode import voluptuous as vol -from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_DEVICE, @@ -19,6 +18,11 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .common import get_api from .const import DEVICE_TYPE_GOGOGATE2, DEVICE_TYPE_ISMARTGATE, DOMAIN @@ -40,16 +44,14 @@ class Gogogate2FlowHandler(ConfigFlow, domain=DOMAIN): self._device_type: str | None = None async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle homekit discovery.""" - await self.async_set_unique_id( - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] - ) + await self.async_set_unique_id(discovery_info.properties[ATTR_PROPERTIES_ID]) return await self._async_discovery_handler(discovery_info.host) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" await self.async_set_unique_id(discovery_info.macaddress) diff --git a/homeassistant/components/guardian/config_flow.py b/homeassistant/components/guardian/config_flow.py index c4146d72469..55e4893e31b 100644 --- a/homeassistant/components/guardian/config_flow.py +++ b/homeassistant/components/guardian/config_flow.py @@ -8,10 +8,11 @@ from aioguardian import Client from aioguardian.errors import GuardianError import voluptuous as vol -from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_UID, DOMAIN, LOGGER @@ -101,7 +102,7 @@ class GuardianConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle the configuration via dhcp.""" self.discovery_info = { @@ -114,7 +115,7 @@ class GuardianConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle the configuration via zeroconf.""" self.discovery_info = { diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py index b75ad617b39..b507c0ae112 100644 --- a/homeassistant/components/harmony/config_flow.py +++ b/homeassistant/components/harmony/config_flow.py @@ -11,7 +11,6 @@ from aioharmony.hubconnector_websocket import HubConnector import aiohttp import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.components.remote import ( ATTR_ACTIVITY, ATTR_DELAY_SECS, @@ -26,6 +25,10 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + SsdpServiceInfo, +) from .const import DOMAIN, PREVIOUS_ACTIVE_ACTIVITY, UNIQUE_ID from .util import ( @@ -93,13 +96,13 @@ class HarmonyConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered Harmony device.""" _LOGGER.debug("SSDP discovery_info: %s", discovery_info) parsed_url = urlparse(discovery_info.ssdp_location) - friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + friendly_name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] self._async_abort_entries_match({CONF_HOST: parsed_url.hostname}) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index d9b1b77a671..86d5123bccf 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -8,7 +8,6 @@ from urllib.parse import urlparse from pyheos import CommandAuthenticationError, Heos, HeosError, HeosOptions import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -18,6 +17,10 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import selector +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + SsdpServiceInfo, +) from .const import DOMAIN @@ -107,16 +110,14 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): return HeosOptionsFlowHandler() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered Heos device.""" # Store discovered host if TYPE_CHECKING: assert discovery_info.ssdp_location hostname = urlparse(discovery_info.ssdp_location).hostname - friendly_name = ( - f"{discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]} ({hostname})" - ) + friendly_name = f"{discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]} ({hostname})" self.hass.data.setdefault(DOMAIN, {}) self.hass.data[DOMAIN][friendly_name] = hostname await self.async_set_unique_id(DOMAIN) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 9e67d618079..0acf57fe55b 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -19,11 +19,14 @@ from aiohomekit.model.status_flags import StatusFlags from aiohomekit.utils import domain_supported, domain_to_name, serialize_broadcast_key import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.helpers.typing import VolDictType from .const import DOMAIN, KNOWN_DEVICES @@ -189,7 +192,7 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN): return False async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered HomeKit accessory. @@ -202,7 +205,7 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN): key.lower(): value for (key, value) in discovery_info.properties.items() } - if zeroconf.ATTR_PROPERTIES_ID not in properties: + if ATTR_PROPERTIES_ID not in properties: # This can happen if the TXT record is received after the PTR record # we will wait for the next update in this case _LOGGER.debug( @@ -216,7 +219,7 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN): # The hkid is a unique random number that looks like a pairing code. # It changes if a device is factory reset. - hkid: str = properties[zeroconf.ATTR_PROPERTIES_ID] + hkid: str = properties[ATTR_PROPERTIES_ID] normalized_hkid = normalize_hkid(hkid) upper_case_hkid = hkid.upper() status_flags = int(properties["sf"]) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index d5b19a0c030..71ff9df5443 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -10,13 +10,14 @@ from homewizard_energy.errors import DisabledError, RequestError, UnsupportedErr from homewizard_energy.models import Device import voluptuous as vol -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_PATH from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import TextSelector from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_API_ENABLED, @@ -79,7 +80,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" if ( diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 08fdae50c51..96e160ece7b 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -21,7 +21,6 @@ from requests.exceptions import SSLError, Timeout from url_normalize import url_normalize import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -38,6 +37,14 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import callback +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import ( CONF_MANUFACTURER, @@ -262,7 +269,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=title, data=user_input) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle SSDP initiated config flow.""" @@ -270,13 +277,13 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): assert discovery_info.ssdp_location url = url_normalize( discovery_info.upnp.get( - ssdp.ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_PRESENTATION_URL, f"http://{urlparse(discovery_info.ssdp_location).hostname}/", ) ) unique_id = discovery_info.upnp.get( - ssdp.ATTR_UPNP_SERIAL, discovery_info.upnp[ssdp.ATTR_UPNP_UDN] + ATTR_UPNP_SERIAL, discovery_info.upnp[ATTR_UPNP_UDN] ) await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured(updates={CONF_URL: url}) @@ -301,12 +308,12 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): self.context.update( { "title_placeholders": { - CONF_NAME: discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + CONF_NAME: discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) or "Huawei LTE" } } ) - self.manufacturer = discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) + self.manufacturer = discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER) self.url = url return await self._async_show_user_form() diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 8d17f810461..db025922ef8 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -13,7 +13,6 @@ from aiohue.util import normalize_bridge_id import slugify as unicode_slug import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -27,6 +26,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_ALLOW_HUE_GROUPS, @@ -214,7 +214,7 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Hue bridge. @@ -243,7 +243,7 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_link() async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Hue bridge on HomeKit. diff --git a/homeassistant/components/hunterdouglas_powerview/config_flow.py b/homeassistant/components/hunterdouglas_powerview/config_flow.py index debb9710dbd..c53c08c8ac7 100644 --- a/homeassistant/components/hunterdouglas_powerview/config_flow.py +++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py @@ -7,11 +7,12 @@ from typing import TYPE_CHECKING, Any, Self import voluptuous as vol -from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, HUB_EXCEPTIONS from .util import async_connect_hub @@ -110,7 +111,7 @@ class PowerviewConfigFlow(ConfigFlow, domain=DOMAIN): return info, None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" self.discovered_ip = discovery_info.ip @@ -118,7 +119,7 @@ class PowerviewConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.discovered_ip = discovery_info.host @@ -128,7 +129,7 @@ class PowerviewConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle HomeKit discovery.""" self.discovered_ip = discovery_info.host diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index b2b7dbdf531..045fbd986cc 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -12,7 +12,6 @@ from urllib.parse import urlparse from hyperion import client, const import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, @@ -30,6 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo from . import create_hyperion_client from .const import ( @@ -155,7 +155,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): return await self._advance_to_auth_step_if_necessary(hyperion_client) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initiated by SSDP.""" # Sample data provided by SSDP: { @@ -210,7 +210,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): except ValueError: self._data[CONF_PORT] = const.DEFAULT_PORT_JSON - if not (hyperion_id := discovery_info.upnp.get(ssdp.ATTR_UPNP_SERIAL)): + if not (hyperion_id := discovery_info.upnp.get(ATTR_UPNP_SERIAL)): return self.async_abort(reason="no_id") # For discovery mechanisms, we set the unique_id as early as possible to From 19a89ebcf3793f9bc3f0473558c684bd2e7df6f1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Jan 2025 15:49:01 +0100 Subject: [PATCH 0456/2987] Use new ServiceInfo location in components (part 2) (#135685) --- homeassistant/components/ipp/config_flow.py | 4 +-- .../components/isy994/config_flow.py | 15 +++++++---- .../components/kaleidescape/config_flow.py | 6 ++--- .../components/keenetic_ndms2/config_flow.py | 14 +++++++---- .../components/konnected/config_flow.py | 14 +++++++---- homeassistant/components/lifx/config_flow.py | 4 +-- .../components/linkplay/config_flow.py | 4 +-- .../components/lookin/config_flow.py | 4 +-- .../components/lutron_caseta/config_flow.py | 6 ++--- .../components/modern_forms/config_flow.py | 4 +-- .../components/motion_blinds/config_flow.py | 4 +-- .../components/motionmount/config_flow.py | 4 +-- .../components/music_assistant/config_flow.py | 4 +-- homeassistant/components/nam/config_flow.py | 4 +-- .../components/nanoleaf/config_flow.py | 16 +++++++----- .../components/netgear/config_flow.py | 25 ++++++++++--------- homeassistant/components/nuki/config_flow.py | 4 +-- homeassistant/components/nut/config_flow.py | 4 +-- .../components/obihai/config_flow.py | 6 ++--- .../components/octoprint/config_flow.py | 7 +++--- .../components/onewire/config_flow.py | 4 +-- homeassistant/components/onkyo/config_flow.py | 4 +-- homeassistant/components/onvif/config_flow.py | 4 +-- .../components/overkiz/config_flow.py | 7 +++--- .../components/palazzetti/config_flow.py | 4 +-- .../components/peblar/config_flow.py | 6 ++--- .../components/pure_energie/config_flow.py | 4 +-- .../components/qnap_qsw/config_flow.py | 4 +-- .../components/rachio/config_flow.py | 11 ++++---- .../components/radiotherm/config_flow.py | 4 +-- .../components/rainmachine/config_flow.py | 8 +++--- .../components/reolink/config_flow.py | 4 +-- homeassistant/components/ring/config_flow.py | 4 +-- homeassistant/components/roku/config_flow.py | 15 +++++++---- homeassistant/components/romy/config_flow.py | 4 +-- .../components/roomba/config_flow.py | 7 +++--- .../components/russound_rio/config_flow.py | 4 +-- .../components/ruuvi_gateway/config_flow.py | 4 +-- 38 files changed, 141 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py index ecd4d1af9f6..4d0c43242e4 100644 --- a/homeassistant/components/ipp/config_flow.py +++ b/homeassistant/components/ipp/config_flow.py @@ -16,7 +16,6 @@ from pyipp import ( ) import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -28,6 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_BASE_PATH, CONF_SERIAL, DOMAIN @@ -103,7 +103,7 @@ class IPPFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" host = discovery_info.host diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 3575fa99a55..b44096e2ccd 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -14,7 +14,6 @@ from pyisy.configuration import Configuration from pyisy.connection import Connection import voluptuous as vol -from homeassistant.components import dhcp, ssdp from homeassistant.config_entries import ( SOURCE_IGNORE, ConfigEntry, @@ -27,6 +26,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import ( CONF_IGNORE_STRING, @@ -209,7 +214,7 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): raise AbortFlow("already_configured") async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle a discovered ISY/IoX device via dhcp.""" friendly_name = discovery_info.hostname @@ -232,14 +237,14 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_user() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered ISY/IoX Device.""" - friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + friendly_name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] url = discovery_info.ssdp_location assert isinstance(url, str) parsed_url = urlparse(url) - mac = discovery_info.upnp[ssdp.ATTR_UPNP_UDN] + mac = discovery_info.upnp[ATTR_UPNP_UDN] mac = mac.removeprefix(UDN_UUID_PREFIX) url = url.removesuffix(ISY_URL_POSTFIX) diff --git a/homeassistant/components/kaleidescape/config_flow.py b/homeassistant/components/kaleidescape/config_flow.py index e4a562dc00b..031709db9f2 100644 --- a/homeassistant/components/kaleidescape/config_flow.py +++ b/homeassistant/components/kaleidescape/config_flow.py @@ -7,9 +7,9 @@ from urllib.parse import urlparse import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo from . import KaleidescapeDeviceInfo, UnsupportedError, validate_host from .const import DEFAULT_HOST, DOMAIN, NAME as KALEIDESCAPE_NAME @@ -61,11 +61,11 @@ class KaleidescapeConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle discovered device.""" host = cast(str, urlparse(discovery_info.ssdp_location).hostname) - serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] await self.async_set_unique_id(serial_number) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py index d11fedac385..3dc4c8b1b77 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -8,7 +8,6 @@ from urllib.parse import urlparse from ndms2_client import Client, ConnectionException, InterfaceInfo, TelnetConnection import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -24,6 +23,11 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from homeassistant.helpers.typing import VolDictType from .const import ( @@ -105,23 +109,23 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered device.""" - friendly_name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "") + friendly_name = discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, "") # Filter out items not having "keenetic" in their name if "keenetic" not in friendly_name.lower(): return self.async_abort(reason="not_keenetic_ndms2") # Filters out items having no/empty UDN - if not discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): + if not discovery_info.upnp.get(ATTR_UPNP_UDN): return self.async_abort(reason="no_udn") # We can cast the hostname to str because the ssdp_location is not bytes and # not a relative url host = cast(str, urlparse(discovery_info.ssdp_location).hostname) - await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN]) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._async_abort_entries_match({CONF_HOST: host}) diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py index 65dd7cf39b3..7f5f4d8abd4 100644 --- a/homeassistant/components/konnected/config_flow.py +++ b/homeassistant/components/konnected/config_flow.py @@ -12,7 +12,6 @@ from urllib.parse import urlparse import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, BinarySensorDeviceClass, @@ -40,6 +39,11 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + SsdpServiceInfo, +) from .const import ( CONF_ACTIVATION, @@ -254,7 +258,7 @@ class KonnectedFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_user() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered konnected panel. @@ -264,16 +268,16 @@ class KonnectedFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.debug(discovery_info) try: - if discovery_info.upnp[ssdp.ATTR_UPNP_MANUFACTURER] != KONN_MANUFACTURER: + if discovery_info.upnp[ATTR_UPNP_MANUFACTURER] != KONN_MANUFACTURER: return self.async_abort(reason="not_konn_panel") if not any( - name in discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + name in discovery_info.upnp[ATTR_UPNP_MODEL_NAME] for name in KONN_PANEL_MODEL_NAMES ): _LOGGER.warning( "Discovered unrecognized Konnected device %s", - discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "Unknown"), + discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME, "Unknown"), ) return self.async_abort(reason="not_konn_panel") diff --git a/homeassistant/components/lifx/config_flow.py b/homeassistant/components/lifx/config_flow.py index 1a5870b1935..ee55a7589e2 100644 --- a/homeassistant/components/lifx/config_flow.py +++ b/homeassistant/components/lifx/config_flow.py @@ -9,12 +9,12 @@ from aiolifx.aiolifx import Light from aiolifx.connection import LIFXConnection import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType from .const import ( @@ -72,7 +72,7 @@ class LifXConfigFlow(ConfigFlow, domain=DOMAIN): return await self._async_handle_discovery(host) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle HomeKit discovery.""" return await self._async_handle_discovery(host=discovery_info.host) diff --git a/homeassistant/components/linkplay/config_flow.py b/homeassistant/components/linkplay/config_flow.py index 7dfdce238ff..11e4aabf257 100644 --- a/homeassistant/components/linkplay/config_flow.py +++ b/homeassistant/components/linkplay/config_flow.py @@ -9,9 +9,9 @@ from linkplay.discovery import linkplay_factory_httpapi_bridge from linkplay.exceptions import LinkPlayRequestException import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MODEL +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN from .utils import async_get_client_session @@ -27,7 +27,7 @@ class LinkPlayConfigFlow(ConfigFlow, domain=DOMAIN): self.data: dict[str, Any] = {} async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle Zeroconf discovery.""" diff --git a/homeassistant/components/lookin/config_flow.py b/homeassistant/components/lookin/config_flow.py index aaf98a06fa8..abf2982765d 100644 --- a/homeassistant/components/lookin/config_flow.py +++ b/homeassistant/components/lookin/config_flow.py @@ -9,10 +9,10 @@ import aiohttp from aiolookin import Device, LookInHttpProtocol, NoUsableService import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -28,7 +28,7 @@ class LookinFlowHandler(ConfigFlow, domain=DOMAIN): self._name: str | None = None async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Start a discovery flow from zeroconf.""" uid: str = discovery_info.hostname.removesuffix(".local.") diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index cd566b767fb..767c3d2f2b7 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -12,10 +12,10 @@ from pylutron_caseta.pairing import PAIR_CA, PAIR_CERT, PAIR_KEY, async_pair from pylutron_caseta.smartbridge import Smartbridge import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( ABORT_REASON_CANNOT_CONNECT, @@ -69,7 +69,7 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA_USER) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" hostname = discovery_info.hostname @@ -90,7 +90,7 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_link() async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by homekit discovery.""" return await self.async_step_zeroconf(discovery_info) diff --git a/homeassistant/components/modern_forms/config_flow.py b/homeassistant/components/modern_forms/config_flow.py index 3c217b5747f..d10c7604722 100644 --- a/homeassistant/components/modern_forms/config_flow.py +++ b/homeassistant/components/modern_forms/config_flow.py @@ -7,10 +7,10 @@ from typing import Any from aiomodernforms import ModernFormsConnectionError, ModernFormsDevice import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -39,7 +39,7 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN): return await self._handle_config_flow() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" host = discovery_info.hostname.rstrip(".") diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index e961880375c..d8d1e7c21f1 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -7,7 +7,6 @@ from typing import Any from motionblinds import MotionDiscovery, MotionGateway import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -17,6 +16,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_INTERFACE, @@ -82,7 +82,7 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" mac_address = format_mac(discovery_info.macaddress).replace(":", "") diff --git a/homeassistant/components/motionmount/config_flow.py b/homeassistant/components/motionmount/config_flow.py index 19d3557d36b..50a1e334f1d 100644 --- a/homeassistant/components/motionmount/config_flow.py +++ b/homeassistant/components/motionmount/config_flow.py @@ -7,7 +7,6 @@ from typing import Any import motionmount import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( DEFAULT_DISCOVERY_UNIQUE_ID, ConfigFlow, @@ -15,6 +14,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_UUID from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, EMPTY_MAC @@ -80,7 +80,7 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=name, data=user_input) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" diff --git a/homeassistant/components/music_assistant/config_flow.py b/homeassistant/components/music_assistant/config_flow.py index fc50a2d654b..b00924c97a5 100644 --- a/homeassistant/components/music_assistant/config_flow.py +++ b/homeassistant/components/music_assistant/config_flow.py @@ -13,11 +13,11 @@ from music_assistant_client.exceptions import ( from music_assistant_models.api import ServerInfoMessage import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER @@ -93,7 +93,7 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="user", data_schema=get_manual_schema({})) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Mass server. diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index 494ce9fdac0..fa94971e2ef 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -17,12 +17,12 @@ from nettigo_air_monitor import ( ) import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -138,7 +138,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.host = discovery_info.host diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index 27ef9a887fe..253387c254a 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -10,11 +10,15 @@ from typing import Any, Final, cast from aionanoleaf import InvalidToken, Nanoleaf, Unauthorized, Unavailable import voluptuous as vol -from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import save_json +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.util.json import JsonObjectType, JsonValueType, load_json_object from .const import DOMAIN @@ -86,31 +90,31 @@ class NanoleafConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_link() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle Nanoleaf Zeroconf discovery.""" _LOGGER.debug("Zeroconf discovered: %s", discovery_info) return await self._async_homekit_zeroconf_discovery_handler(discovery_info) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle Nanoleaf Homekit discovery.""" _LOGGER.debug("Homekit discovered: %s", discovery_info) return await self._async_homekit_zeroconf_discovery_handler(discovery_info) async def _async_homekit_zeroconf_discovery_handler( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle Nanoleaf Homekit and Zeroconf discovery.""" return await self._async_discovery_handler( discovery_info.host, discovery_info.name.replace(f".{discovery_info.type}", ""), - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID], + discovery_info.properties[ATTR_PROPERTIES_ID], ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle Nanoleaf SSDP discovery.""" _LOGGER.debug("SSDP discovered: %s", discovery_info) diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py index 965e3618645..a0a5b76eee5 100644 --- a/homeassistant/components/netgear/config_flow.py +++ b/homeassistant/components/netgear/config_flow.py @@ -9,7 +9,6 @@ from urllib.parse import urlparse from pynetgear import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_USER import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -24,6 +23,12 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from homeassistant.util.network import is_ipv4_address from .const import ( @@ -129,7 +134,7 @@ class NetgearFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Initialize flow from ssdp.""" updated_data: dict[str, str | int | bool] = {} @@ -144,10 +149,10 @@ class NetgearFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.debug("Netgear ssdp discovery info: %s", discovery_info) - if ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp: + if ATTR_UPNP_SERIAL not in discovery_info.upnp: return self.async_abort(reason="no_serial") - await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]) + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_SERIAL]) self._abort_if_unique_id_configured(updates=updated_data) if device_url.scheme == "https": @@ -157,18 +162,14 @@ class NetgearFlowHandler(ConfigFlow, domain=DOMAIN): updated_data[CONF_PORT] = DEFAULT_PORT for model in MODELS_PORT_80: - if discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NUMBER, "").startswith( + if discovery_info.upnp.get(ATTR_UPNP_MODEL_NUMBER, "").startswith( model - ) or discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "").startswith( - model - ): + ) or discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME, "").startswith(model): updated_data[CONF_PORT] = PORT_80 for model in MODELS_PORT_5555: - if discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NUMBER, "").startswith( + if discovery_info.upnp.get(ATTR_UPNP_MODEL_NUMBER, "").startswith( model - ) or discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "").startswith( - model - ): + ) or discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME, "").startswith(model): updated_data[CONF_PORT] = PORT_5555 updated_data[CONF_SSL] = True diff --git a/homeassistant/components/nuki/config_flow.py b/homeassistant/components/nuki/config_flow.py index 4a9789c7e51..ac6771bb1bd 100644 --- a/homeassistant/components/nuki/config_flow.py +++ b/homeassistant/components/nuki/config_flow.py @@ -9,10 +9,10 @@ from pynuki.bridge import InvalidCredentialsException from requests.exceptions import RequestException import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_ENCRYPT_TOKEN, DEFAULT_PORT, DEFAULT_TIMEOUT, DOMAIN from .helpers import CannotConnect, InvalidAuth, parse_id @@ -75,7 +75,7 @@ class NukiConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_validate(user_input) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a DHCP discovered Nuki bridge.""" await self.async_set_unique_id(discovery_info.hostname[12:].upper()) diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index 966c51e98e9..b1b44966d14 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -9,7 +9,6 @@ from typing import Any from aionut import NUTError, NUTLoginError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -27,6 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import PyNUTData from .const import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN @@ -95,7 +95,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): self.reauth_entry: ConfigEntry | None = None async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a discovered nut device.""" await self._async_handle_discovery_without_unique_id() diff --git a/homeassistant/components/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py index 559900db5d0..03f6348ebac 100644 --- a/homeassistant/components/obihai/config_flow.py +++ b/homeassistant/components/obihai/config_flow.py @@ -8,11 +8,11 @@ from typing import Any from pyobihai import PyObihai import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .connectivity import validate_auth from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN @@ -54,7 +54,7 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 2 discovery_schema: vol.Schema | None = None - _dhcp_discovery_info: dhcp.DhcpServiceInfo | None = None + _dhcp_discovery_info: DhcpServiceInfo | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -94,7 +94,7 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a DHCP discovered Obihai.""" diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index 9bbf21d71fa..627ca999acd 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -12,7 +12,6 @@ from pyoctoprintapi import ApiError, OctoprintClient, OctoprintException import voluptuous as vol from yarl import URL -from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_API_KEY, @@ -26,6 +25,8 @@ from homeassistant.const import ( from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.ssl import get_default_context, get_default_no_verify_context from .const import DOMAIN @@ -167,7 +168,7 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_user(import_data) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery flow.""" uuid = discovery_info.properties["uuid"] @@ -193,7 +194,7 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_user() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle ssdp discovery flow.""" uuid = discovery_info.upnp["UDN"][5:] diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index c5d4bb065e0..e40e99d0903 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -8,13 +8,13 @@ from typing import Any from pyownet import protocol import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( DEFAULT_HOST, @@ -117,7 +117,7 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" await self._async_handle_discovery_without_unique_id() diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index a484b3aaa04..974b4082cae 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -6,7 +6,6 @@ from typing import Any import voluptuous as vol from yarl import URL -from homeassistant.components import ssdp from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigEntry, @@ -26,6 +25,7 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, TextSelector, ) +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( CONF_RECEIVER_MAX_VOLUME, @@ -168,7 +168,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle flow initialized by SSDP discovery.""" _LOGGER.debug("Config flow start ssdp: %s", discovery_info) diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 66e566af0bf..fc5de57508b 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -15,7 +15,6 @@ from wsdiscovery.scope import Scope from wsdiscovery.service import Service from zeep.exceptions import Fault -from homeassistant.components import dhcp from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, @@ -39,6 +38,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_DEVICE_ID, @@ -170,7 +170,7 @@ class OnvifFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" hass = self.hass diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index 9a94c30d95d..af955e5fb95 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -23,7 +23,6 @@ from pyoverkiz.obfuscate import obfuscate_id from pyoverkiz.utils import generate_local_server, is_overkiz_gateway import voluptuous as vol -from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -34,6 +33,8 @@ from homeassistant.const import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_API_TYPE, CONF_HUB, DEFAULT_SERVER, DOMAIN, LOGGER @@ -273,7 +274,7 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" hostname = discovery_info.hostname @@ -284,7 +285,7 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): return await self._process_discovery(gateway_id) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle ZeroConf discovery.""" properties = discovery_info.properties diff --git a/homeassistant/components/palazzetti/config_flow.py b/homeassistant/components/palazzetti/config_flow.py index fe892b6624d..91762216ff5 100644 --- a/homeassistant/components/palazzetti/config_flow.py +++ b/homeassistant/components/palazzetti/config_flow.py @@ -6,10 +6,10 @@ from pypalazzetti.client import PalazzettiClient from pypalazzetti.exceptions import CommunicationError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, LOGGER @@ -53,7 +53,7 @@ class PalazzettiConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" diff --git a/homeassistant/components/peblar/config_flow.py b/homeassistant/components/peblar/config_flow.py index 24248355f72..b9b42cd6ca5 100644 --- a/homeassistant/components/peblar/config_flow.py +++ b/homeassistant/components/peblar/config_flow.py @@ -9,7 +9,6 @@ from aiohttp import CookieJar from peblar import Peblar, PeblarAuthenticationError, PeblarConnectionError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.helpers.aiohttp_client import async_create_clientsession @@ -18,6 +17,7 @@ from homeassistant.helpers.selector import ( TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER @@ -27,7 +27,7 @@ class PeblarFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - _discovery_info: zeroconf.ZeroconfServiceInfo + _discovery_info: ZeroconfServiceInfo async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -128,7 +128,7 @@ class PeblarFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery of a Peblar device.""" if not (sn := discovery_info.properties.get("sn")): diff --git a/homeassistant/components/pure_energie/config_flow.py b/homeassistant/components/pure_energie/config_flow.py index a2bbb671ff7..0dcb1a9ab13 100644 --- a/homeassistant/components/pure_energie/config_flow.py +++ b/homeassistant/components/pure_energie/config_flow.py @@ -7,11 +7,11 @@ from typing import Any from gridnet import Device, GridNet, GridNetConnectionError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import TextSelector +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -58,7 +58,7 @@ class PureEnergieFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.discovered_host = discovery_info.host diff --git a/homeassistant/components/qnap_qsw/config_flow.py b/homeassistant/components/qnap_qsw/config_flow.py index 3a10e54ac82..3ccb13e0f64 100644 --- a/homeassistant/components/qnap_qsw/config_flow.py +++ b/homeassistant/components/qnap_qsw/config_flow.py @@ -9,12 +9,12 @@ from aioqsw.exceptions import LoginError, QswError from aioqsw.localapi import ConnectionOptions, QnapQswApi import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN @@ -73,7 +73,7 @@ class QNapQSWConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" self._discovered_url = f"http://{discovery_info.ip}" diff --git a/homeassistant/components/rachio/config_flow.py b/homeassistant/components/rachio/config_flow.py index fac93952b35..cc32bd2e56f 100644 --- a/homeassistant/components/rachio/config_flow.py +++ b/homeassistant/components/rachio/config_flow.py @@ -10,7 +10,6 @@ from rachiopy import Rachio from requests.exceptions import ConnectTimeout import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -20,6 +19,10 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .const import ( CONF_MANUAL_RUN_MINS, @@ -92,13 +95,11 @@ class RachioConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle HomeKit discovery.""" self._async_abort_entries_match() - await self.async_set_unique_id( - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] - ) + await self.async_set_unique_id(discovery_info.properties[ATTR_PROPERTIES_ID]) self._abort_if_unique_id_configured() return await self.async_step_user() diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py index e29c4703e08..298421d3964 100644 --- a/homeassistant/components/radiotherm/config_flow.py +++ b/homeassistant/components/radiotherm/config_flow.py @@ -9,11 +9,11 @@ from urllib.error import URLError from radiotherm.validate import RadiothermTstatError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN from .data import RadioThermInitData, async_get_init_data @@ -44,7 +44,7 @@ class RadioThermConfigFlow(ConfigFlow, domain=DOMAIN): self.discovered_init_data: RadioThermInitData | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Discover via DHCP.""" self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index 0b40d506566..6ce95d7e547 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -9,7 +9,6 @@ from regenmaschine.controller import Controller from regenmaschine.errors import RainMachineError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -19,6 +18,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_ALLOW_INACTIVE_ZONES_TO_RUN, @@ -66,19 +66,19 @@ class RainMachineFlowHandler(ConfigFlow, domain=DOMAIN): return RainMachineOptionsFlowHandler() async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by homekit discovery.""" return await self.async_step_homekit_zeroconf(discovery_info) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery via zeroconf.""" return await self.async_step_homekit_zeroconf(discovery_info) async def async_step_homekit_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery via zeroconf.""" ip_address = discovery_info.host diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index c28e076aab4..48be2fc8ca7 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -15,7 +15,6 @@ from reolink_aio.exceptions import ( ) import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, @@ -34,6 +33,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_USE_HTTPS, DOMAIN from .exceptions import ( @@ -142,7 +142,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_user() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" mac_address = format_mac(discovery_info.macaddress) diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index a1024186349..a23fd8f73de 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -8,7 +8,6 @@ import uuid from ring_doorbell import Auth, AuthenticationError, Requires2FAError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, @@ -26,6 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import get_auth_user_agent from .const import CONF_2FA, CONF_CONFIG_ENTRY_MINOR_VERSION, DOMAIN @@ -78,7 +78,7 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN): hardware_id: str | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" # Ring has a single config entry per cloud username rather than per device diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index bc0092d6953..2fb016b5467 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -9,7 +9,6 @@ from urllib.parse import urlparse from rokuecp import Roku, RokuError import voluptuous as vol -from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, @@ -19,6 +18,12 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import RokuConfigEntry from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID, DOMAIN @@ -117,7 +122,7 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=info["title"], data=user_input) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by homekit discovery.""" @@ -147,12 +152,12 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" host = urlparse(discovery_info.ssdp_location).hostname - name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] - serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] + serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] await self.async_set_unique_id(serial_number) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) diff --git a/homeassistant/components/romy/config_flow.py b/homeassistant/components/romy/config_flow.py index e571ff41c9a..6bb5c337b29 100644 --- a/homeassistant/components/romy/config_flow.py +++ b/homeassistant/components/romy/config_flow.py @@ -5,10 +5,10 @@ from __future__ import annotations import romy import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER @@ -84,7 +84,7 @@ class RomyConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index d040074246a..b7d259e3131 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -11,7 +11,6 @@ from roombapy.discovery import RoombaDiscovery from roombapy.getpassword import RoombaPassword import voluptuous as vol -from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -20,6 +19,8 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import CannotConnect, async_connect_or_timeout, async_disconnect_or_timeout from .const import ( @@ -95,7 +96,7 @@ class RoombaConfigFlow(ConfigFlow, domain=DOMAIN): return RoombaOptionsFlowHandler() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" return await self._async_step_discovery( @@ -103,7 +104,7 @@ class RoombaConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" return await self._async_step_discovery( diff --git a/homeassistant/components/russound_rio/config_flow.py b/homeassistant/components/russound_rio/config_flow.py index 5618a424726..edf542b5de2 100644 --- a/homeassistant/components/russound_rio/config_flow.py +++ b/homeassistant/components/russound_rio/config_flow.py @@ -8,7 +8,6 @@ from typing import Any from aiorussound import RussoundClient, RussoundTcpConnectionHandler import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, @@ -16,6 +15,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS @@ -39,7 +39,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): self.data: dict[str, Any] = {} async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.data[CONF_HOST] = host = discovery_info.host diff --git a/homeassistant/components/ruuvi_gateway/config_flow.py b/homeassistant/components/ruuvi_gateway/config_flow.py index c22f100e87a..05ca93de9f2 100644 --- a/homeassistant/components/ruuvi_gateway/config_flow.py +++ b/homeassistant/components/ruuvi_gateway/config_flow.py @@ -8,11 +8,11 @@ from typing import Any import aioruuvigateway.api as gw_api from aioruuvigateway.excs import CannotConnect, InvalidAuth -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import DOMAIN from .schemata import CONFIG_SCHEMA, get_config_schema_with_default_host @@ -82,7 +82,7 @@ class RuuviConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a DHCP discovered Ruuvi Gateway.""" await self.async_set_unique_id(format_mac(discovery_info.macaddress)) From 406c00997fbf60527abe5491dee48d35d68ebe0a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Jan 2025 15:49:45 +0100 Subject: [PATCH 0457/2987] Use new ServiceInfo location in components (part 3) (#135687) --- .../components/samsungtv/config_flow.py | 23 +++++++++++------- .../components/screenlogic/config_flow.py | 4 ++-- .../components/smappee/config_flow.py | 4 ++-- .../components/smlight/config_flow.py | 4 ++-- .../components/songpal/config_flow.py | 12 ++++++---- .../components/squeezebox/config_flow.py | 4 ++-- .../components/steamist/config_flow.py | 4 ++-- .../components/syncthru/config_flow.py | 17 ++++++++----- .../components/synology_dsm/config_flow.py | 15 ++++++++---- .../components/system_bridge/config_flow.py | 4 ++-- homeassistant/components/tado/config_flow.py | 9 ++++--- .../components/tailwind/config_flow.py | 4 ++-- .../components/technove/config_flow.py | 5 ++-- .../components/thread/config_flow.py | 5 ++-- homeassistant/components/tolo/config_flow.py | 4 ++-- .../components/tradfri/config_flow.py | 13 +++++----- .../components/twinkly/config_flow.py | 4 ++-- homeassistant/components/unifi/config_flow.py | 12 ++++++---- .../components/unifiprotect/config_flow.py | 7 +++--- .../components/velbus/config_flow.py | 6 ++--- .../components/vicare/config_flow.py | 4 ++-- homeassistant/components/vizio/config_flow.py | 4 ++-- .../components/volumio/config_flow.py | 4 ++-- .../components/webostv/config_flow.py | 12 ++++++---- .../components/wilight/config_flow.py | 24 ++++++++++++------- homeassistant/components/wiz/config_flow.py | 5 ++-- homeassistant/components/wled/config_flow.py | 5 ++-- .../components/wyoming/config_flow.py | 4 ++-- .../components/xiaomi_aqara/config_flow.py | 4 ++-- .../components/xiaomi_miio/config_flow.py | 4 ++-- .../components/yeelight/config_flow.py | 13 ++++++---- 31 files changed, 143 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index b3dabca1df4..3f34520e87a 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -12,7 +12,6 @@ import getmac from samsungtvws.encrypted.authenticator import SamsungTVEncryptedWSAsyncAuthenticator import voluptuous as vol -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigEntryState, @@ -32,6 +31,14 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .bridge import SamsungTVBridge, async_get_device_info, mac_from_device_info from .const import ( @@ -439,11 +446,11 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): raise AbortFlow(RESULT_NOT_SUPPORTED) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by ssdp discovery.""" LOGGER.debug("Samsung device found via SSDP: %s", discovery_info) - model_name: str = discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) or "" + model_name: str = discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or "" if discovery_info.ssdp_st == UPNP_SVC_RENDERING_CONTROL: self._ssdp_rendering_control_location = discovery_info.ssdp_location LOGGER.debug( @@ -456,12 +463,10 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): "Set SSDP MainTvAgent location to: %s", self._ssdp_main_tv_agent_location, ) - self._udn = self._upnp_udn = _strip_uuid( - discovery_info.upnp[ssdp.ATTR_UPNP_UDN] - ) + self._udn = self._upnp_udn = _strip_uuid(discovery_info.upnp[ATTR_UPNP_UDN]) if hostname := urlparse(discovery_info.ssdp_location or "").hostname: self._host = hostname - self._manufacturer = discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) + self._manufacturer = discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER) self._abort_if_manufacturer_is_not_samsung() # Set defaults, in case they cannot be extracted from device_info @@ -486,7 +491,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_confirm() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by dhcp discovery.""" LOGGER.debug("Samsung device found via DHCP: %s", discovery_info) @@ -498,7 +503,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_confirm() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" LOGGER.debug("Samsung device found via ZEROCONF: %s", discovery_info) diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 19db89dc03d..54067055a69 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -10,7 +10,6 @@ from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWA from screenlogicpy.requests import login import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -21,6 +20,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, MIN_SCAN_INTERVAL @@ -91,7 +91,7 @@ class ScreenlogicConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_gateway_select() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" mac = format_mac(discovery_info.macaddress) diff --git a/homeassistant/components/smappee/config_flow.py b/homeassistant/components/smappee/config_flow.py index 4f7a71218ab..01b69a76b28 100644 --- a/homeassistant/components/smappee/config_flow.py +++ b/homeassistant/components/smappee/config_flow.py @@ -6,10 +6,10 @@ from typing import Any from pysmappee import helper, mqtt import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import api from .const import ( @@ -43,7 +43,7 @@ class SmappeeFlowHandler( return logging.getLogger(__name__) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index 92b543e0441..1a222f1b21f 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -9,11 +9,11 @@ from pysmlight import Api2 from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -82,7 +82,7 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Lan coordinator.""" local_name = discovery_info.hostname[:-1] diff --git a/homeassistant/components/songpal/config_flow.py b/homeassistant/components/songpal/config_flow.py index 1c13013108f..e71454f0aa8 100644 --- a/homeassistant/components/songpal/config_flow.py +++ b/homeassistant/components/songpal/config_flow.py @@ -9,9 +9,13 @@ from urllib.parse import urlparse from songpal import Device, SongpalException import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import CONF_ENDPOINT, DOMAIN @@ -99,15 +103,15 @@ class SongpalConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered Songpal device.""" - await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN]) self._abort_if_unique_id_configured() _LOGGER.debug("Discovered: %s", discovery_info) - friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + friendly_name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] hostname = urlparse(discovery_info.ssdp_location).hostname scalarweb_info = discovery_info.upnp["X_ScalarWebAPI_DeviceInfo"] endpoint = scalarweb_info["X_ScalarWebAPI_BaseURL"] diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index c372c7262d4..97eb848c21c 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Any from pysqueezebox import Server, async_discover import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME @@ -18,6 +17,7 @@ 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.service_info.dhcp import DhcpServiceInfo from .const import CONF_HTTPS, DEFAULT_PORT, DOMAIN @@ -200,7 +200,7 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_edit() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery of a Squeezebox player.""" _LOGGER.debug( diff --git a/homeassistant/components/steamist/config_flow.py b/homeassistant/components/steamist/config_flow.py index f22eafc6afd..cadcba118a1 100644 --- a/homeassistant/components/steamist/config_flow.py +++ b/homeassistant/components/steamist/config_flow.py @@ -9,12 +9,12 @@ from aiosteamist import Steamist from discovery30303 import Device30303, normalize_mac import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_NAME from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType from .const import CONNECTION_EXCEPTIONS, DISCOVER_SCAN_TIMEOUT, DOMAIN @@ -41,7 +41,7 @@ class SteamistConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_device: Device30303 | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" self._discovered_device = Device30303( diff --git a/homeassistant/components/syncthru/config_flow.py b/homeassistant/components/syncthru/config_flow.py index 1fb155a5648..1407814f838 100644 --- a/homeassistant/components/syncthru/config_flow.py +++ b/homeassistant/components/syncthru/config_flow.py @@ -8,10 +8,15 @@ from pysyncthru import ConnectionMode, SyncThru, SyncThruAPINotSupported from url_normalize import url_normalize import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import DEFAULT_MODEL, DEFAULT_NAME_TEMPLATE, DOMAIN @@ -33,15 +38,15 @@ class SyncThruConfigFlow(ConfigFlow, domain=DOMAIN): return await self._async_check_and_create("user", user_input) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle SSDP initiated flow.""" - await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN]) self._abort_if_unique_id_configured() self.url = url_normalize( discovery_info.upnp.get( - ssdp.ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_PRESENTATION_URL, f"http://{urlparse(discovery_info.ssdp_location or '').hostname}/", ) ) @@ -52,11 +57,11 @@ class SyncThruConfigFlow(ConfigFlow, domain=DOMAIN): # Update unique id of entry with the same URL if not existing_entry.unique_id: self.hass.config_entries.async_update_entry( - existing_entry, unique_id=discovery_info.upnp[ssdp.ATTR_UPNP_UDN] + existing_entry, unique_id=discovery_info.upnp[ATTR_UPNP_UDN] ) return self.async_abort(reason="already_configured") - self.name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "") + self.name = discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, "") if self.name: # Remove trailing " (ip)" if present for consistency with user driven config self.name = re.sub(r"\s+\([\d.]+\)\s*$", "", self.name) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 918a24035f8..03e2eaf8e7b 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -18,7 +18,6 @@ from synology_dsm.exceptions import ( ) import voluptuous as vol -from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -41,6 +40,12 @@ from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType, VolDictType from homeassistant.util.network import is_ip_address as is_ip @@ -243,7 +248,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_validate_input_create_entry(user_input, step_id=step) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered synology_dsm via zeroconf.""" discovered_macs = [ @@ -258,13 +263,13 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return await self._async_from_discovery(host, friendly_name, discovered_macs) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered synology_dsm via ssdp.""" parsed_url = urlparse(discovery_info.ssdp_location) - upnp_friendly_name: str = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + upnp_friendly_name: str = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] friendly_name = upnp_friendly_name.split("(", 1)[0].strip() - mac_address = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + mac_address = discovery_info.upnp[ATTR_UPNP_SERIAL] discovered_macs = [format_synology_mac(mac_address)] # Synology NAS can broadcast on multiple IP addresses, since they can be connected to multiple ethernets. # The serial of the NAS is actually its MAC address. diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index 98396e52545..60b57b1e87f 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -16,13 +16,13 @@ from systembridgeconnector.websocket_client import WebSocketClient from systembridgemodels.modules import GetData, Module import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DATA_WAIT_TIMEOUT, DOMAIN @@ -179,7 +179,7 @@ class SystemBridgeConfigFlow( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" properties = discovery_info.properties diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index efe138e2e6c..f251a292800 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -10,7 +10,6 @@ from PyTado.interface import Tado import requests.exceptions import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -20,6 +19,10 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .const import ( CONF_FALLBACK, @@ -104,14 +107,14 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle HomeKit discovery.""" self._async_abort_entries_match() properties = { key.lower(): value for (key, value) in discovery_info.properties.items() } - await self.async_set_unique_id(properties[zeroconf.ATTR_PROPERTIES_ID]) + await self.async_set_unique_id(properties[ATTR_PROPERTIES_ID]) self._abort_if_unique_id_configured() return await self.async_step_user() diff --git a/homeassistant/components/tailwind/config_flow.py b/homeassistant/components/tailwind/config_flow.py index 15b947dc7af..daf0fbd32b7 100644 --- a/homeassistant/components/tailwind/config_flow.py +++ b/homeassistant/components/tailwind/config_flow.py @@ -15,7 +15,6 @@ from gotailwind import ( ) import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.data_entry_flow import AbortFlow @@ -27,6 +26,7 @@ from homeassistant.helpers.selector import ( TextSelectorType, ) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER @@ -83,7 +83,7 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery of a Tailwind device.""" if not (device_id := discovery_info.properties.get("device_id")): diff --git a/homeassistant/components/technove/config_flow.py b/homeassistant/components/technove/config_flow.py index 0e4f026ba5c..7ad9829b631 100644 --- a/homeassistant/components/technove/config_flow.py +++ b/homeassistant/components/technove/config_flow.py @@ -5,10 +5,11 @@ from typing import Any from technove import Station as TechnoVEStation, TechnoVE, TechnoVEConnectionError import voluptuous as vol -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -49,7 +50,7 @@ class TechnoVEConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" # Abort quick if the device with provided mac is already configured diff --git a/homeassistant/components/thread/config_flow.py b/homeassistant/components/thread/config_flow.py index 568b76d4999..bf202a50c34 100644 --- a/homeassistant/components/thread/config_flow.py +++ b/homeassistant/components/thread/config_flow.py @@ -4,8 +4,9 @@ from __future__ import annotations from typing import Any -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -28,7 +29,7 @@ class ThreadConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title="Thread", data={}) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Set up because the user has border routers.""" await self._async_handle_discovery_without_unique_id() diff --git a/homeassistant/components/tolo/config_flow.py b/homeassistant/components/tolo/config_flow.py index d5d7e33a5e0..fed4ff332fc 100644 --- a/homeassistant/components/tolo/config_flow.py +++ b/homeassistant/components/tolo/config_flow.py @@ -8,10 +8,10 @@ from typing import Any from tololib import ToloClient, ToloCommunicationError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEFAULT_NAME, DOMAIN @@ -61,7 +61,7 @@ class ToloSaunaConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" await self.async_set_unique_id(format_mac(discovery_info.macaddress)) diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index d9911472a67..29d876346a7 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -10,10 +10,13 @@ from pytradfri import Gateway, RequestError from pytradfri.api.aiocoap_api import APIFactory import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .const import CONF_GATEWAY_ID, CONF_IDENTITY, CONF_KEY, DOMAIN @@ -78,12 +81,10 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle homekit discovery.""" - await self.async_set_unique_id( - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] - ) + await self.async_set_unique_id(discovery_info.properties[ATTR_PROPERTIES_ID]) self._abort_if_unique_id_configured({CONF_HOST: discovery_info.host}) host = discovery_info.host @@ -96,7 +97,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): if not entry.unique_id: self.hass.config_entries.async_update_entry( entry, - unique_id=discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID], + unique_id=discovery_info.properties[ATTR_PROPERTIES_ID], ) return self.async_abort(reason="already_configured") diff --git a/homeassistant/components/twinkly/config_flow.py b/homeassistant/components/twinkly/config_flow.py index 53ba8f084c3..0f2f87302af 100644 --- a/homeassistant/components/twinkly/config_flow.py +++ b/homeassistant/components/twinkly/config_flow.py @@ -9,10 +9,10 @@ from aiohttp import ClientError from ttls.client import Twinkly from voluptuous import Required, Schema -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEV_ID, DEV_MODEL, DEV_NAME, DOMAIN @@ -58,7 +58,7 @@ class TwinklyConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery for twinkly.""" self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 63c8533aa2e..479055b84eb 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -18,7 +18,6 @@ from urllib.parse import urlparse from aiounifi.interfaces.sites import Sites import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntryState, @@ -36,6 +35,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_DESCRIPTION, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from . import UnifiConfigEntry from .const import ( @@ -212,12 +216,12 @@ class UnifiFlowHandler(ConfigFlow, domain=UNIFI_DOMAIN): return await self.async_step_user() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered UniFi device.""" parsed_url = urlparse(discovery_info.ssdp_location) - model_description = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_DESCRIPTION] - mac_address = format_mac(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]) + model_description = discovery_info.upnp[ATTR_UPNP_MODEL_DESCRIPTION] + mac_address = format_mac(discovery_info.upnp[ATTR_UPNP_SERIAL]) self.config = { CONF_HOST: parsed_url.hostname, diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 31950f8f7e4..22af2fb135d 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -14,7 +14,6 @@ from uiprotect.exceptions import ClientError, NotAuthorized from unifi_discovery import async_console_is_alive import voluptuous as vol -from homeassistant.components import dhcp, ssdp from homeassistant.config_entries import ( SOURCE_IGNORE, ConfigEntry, @@ -36,6 +35,8 @@ from homeassistant.helpers.aiohttp_client import ( async_create_clientsession, async_get_clientsession, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.loader import async_get_integration @@ -107,14 +108,14 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): self._discovered_device: dict[str, str] = {} async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" _LOGGER.debug("Starting discovery via: %s", discovery_info) return await self._async_discovery_handoff() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered UniFi device.""" _LOGGER.debug("Starting discovery via: %s", discovery_info) diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 26e2fafabbc..9e99b2631d4 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -8,9 +8,9 @@ import velbusaio.controller from velbusaio.exceptions import VelbusConnectionFailed import voluptuous as vol -from homeassistant.components import usb from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.util import slugify from .const import DOMAIN @@ -69,9 +69,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): errors=self._errors, ) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" await self.async_set_unique_id( f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}" diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index 6594e6ec9e4..36db8e92cc7 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -12,11 +12,11 @@ from PyViCare.PyViCareUtils import ( ) import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_HEATING_TYPE, @@ -109,7 +109,7 @@ class ViCareConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Invoke when a Viessmann MAC address is discovered on the network.""" formatted_mac = format_mac(discovery_info.macaddress) diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index d3921061d8e..572f093dfd3 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -11,7 +11,6 @@ from pyvizio import VizioAsync, async_guess_device_type from pyvizio.const import APP_HOME import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.config_entries import ( SOURCE_ZEROCONF, @@ -32,6 +31,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.network import is_ip_address from .const import ( @@ -257,7 +257,7 @@ class VizioConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="user", data_schema=schema, errors=errors) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" host = discovery_info.host diff --git a/homeassistant/components/volumio/config_flow.py b/homeassistant/components/volumio/config_flow.py index 7cc58556f3e..00b3ab911ae 100644 --- a/homeassistant/components/volumio/config_flow.py +++ b/homeassistant/components/volumio/config_flow.py @@ -8,12 +8,12 @@ from typing import Any from pyvolumio import CannotConnectError, Volumio import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_ID, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -97,7 +97,7 @@ class VolumioConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self._host = discovery_info.host diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index 3bf3bc82dc1..a0ee9f1ac7f 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -9,7 +9,6 @@ from urllib.parse import urlparse from aiowebostv import WebOsTvPairError import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -19,6 +18,11 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from . import async_control_connect from .const import CONF_SOURCES, DEFAULT_NAME, DOMAIN, WEBOSTV_EXCEPTIONS @@ -89,7 +93,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="pairing", errors=errors) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" assert discovery_info.ssdp_location @@ -97,10 +101,10 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): assert host self._host = host self._name = discovery_info.upnp.get( - ssdp.ATTR_UPNP_FRIENDLY_NAME, DEFAULT_NAME + ATTR_UPNP_FRIENDLY_NAME, DEFAULT_NAME ).replace("[LG]", "LG") - uuid = discovery_info.upnp[ssdp.ATTR_UPNP_UDN] + uuid = discovery_info.upnp[ATTR_UPNP_UDN] assert uuid uuid = uuid.removeprefix("uuid:") await self.async_set_unique_id(uuid) diff --git a/homeassistant/components/wilight/config_flow.py b/homeassistant/components/wilight/config_flow.py index 74663d61d8f..1036e5b1ead 100644 --- a/homeassistant/components/wilight/config_flow.py +++ b/homeassistant/components/wilight/config_flow.py @@ -5,9 +5,15 @@ from urllib.parse import urlparse import pywilight -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from .const import DOMAIN @@ -53,25 +59,25 @@ class WiLightFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=self._title, data=data) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered WiLight.""" # Filter out basic information if ( not discovery_info.ssdp_location - or ssdp.ATTR_UPNP_MANUFACTURER not in discovery_info.upnp - or ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp - or ssdp.ATTR_UPNP_MODEL_NAME not in discovery_info.upnp - or ssdp.ATTR_UPNP_MODEL_NUMBER not in discovery_info.upnp + or ATTR_UPNP_MANUFACTURER not in discovery_info.upnp + or ATTR_UPNP_SERIAL not in discovery_info.upnp + or ATTR_UPNP_MODEL_NAME not in discovery_info.upnp + or ATTR_UPNP_MODEL_NUMBER not in discovery_info.upnp ): return self.async_abort(reason="not_wilight_device") # Filter out non-WiLight devices - if discovery_info.upnp[ssdp.ATTR_UPNP_MANUFACTURER] != WILIGHT_MANUFACTURER: + if discovery_info.upnp[ATTR_UPNP_MANUFACTURER] != WILIGHT_MANUFACTURER: return self.async_abort(reason="not_wilight_device") host = urlparse(discovery_info.ssdp_location).hostname - serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] - model_name = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] + model_name = discovery_info.upnp[ATTR_UPNP_MODEL_NAME] if not self._wilight_update(host, serial_number, model_name): return self.async_abort(reason="not_wilight_device") diff --git a/homeassistant/components/wiz/config_flow.py b/homeassistant/components/wiz/config_flow.py index 71bc0a9aaa8..92b25389450 100644 --- a/homeassistant/components/wiz/config_flow.py +++ b/homeassistant/components/wiz/config_flow.py @@ -10,10 +10,11 @@ from pywizlight.discovery import DiscoveredBulb from pywizlight.exceptions import WizLightConnectionError, WizLightTimeOutError import voluptuous as vol -from homeassistant.components import dhcp, onboarding +from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.util.network import is_ip_address from .const import DEFAULT_NAME, DISCOVER_SCAN_TIMEOUT, DOMAIN, WIZ_CONNECT_EXCEPTIONS @@ -38,7 +39,7 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_devices: dict[str, DiscoveredBulb] = {} async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" self._discovered_device = DiscoveredBulb( diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 812a0500d1a..2e0b7b1c793 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -7,7 +7,7 @@ from typing import Any import voluptuous as vol from wled import WLED, Device, WLEDConnectionError -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -17,6 +17,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT, DOMAIN @@ -68,7 +69,7 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" # Abort quick if the mac address is provided by discovery info diff --git a/homeassistant/components/wyoming/config_flow.py b/homeassistant/components/wyoming/config_flow.py index ddf57cf0ed0..41e7b9cf1e6 100644 --- a/homeassistant/components/wyoming/config_flow.py +++ b/homeassistant/components/wyoming/config_flow.py @@ -8,10 +8,10 @@ from urllib.parse import urlparse import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN from .data import WyomingService @@ -117,7 +117,7 @@ class WyomingConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" _LOGGER.debug("Zeroconf discovery info: %s", discovery_info) diff --git a/homeassistant/components/xiaomi_aqara/config_flow.py b/homeassistant/components/xiaomi_aqara/config_flow.py index 6252e6849d0..e0484b80b7e 100644 --- a/homeassistant/components/xiaomi_aqara/config_flow.py +++ b/homeassistant/components/xiaomi_aqara/config_flow.py @@ -7,11 +7,11 @@ from typing import Any import voluptuous as vol from xiaomi_gateway import MULTICAST_PORT, XiaomiGateway, XiaomiGatewayDiscovery -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_PROTOCOL from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_INTERFACE, @@ -153,7 +153,7 @@ class XiaomiAqaraFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" name = discovery_info.name diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index b068f4a1e61..c3ebc48d743 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -11,7 +11,6 @@ from micloud import MiCloud from micloud.micloudexception import MiCloudAccessDenied import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -21,6 +20,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_CLOUD_COUNTRY, @@ -145,7 +145,7 @@ class XiaomiMiioFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_cloud() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" name = discovery_info.name diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 7a3a0a2f100..35892764bcb 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -11,7 +11,7 @@ import yeelight from yeelight.aio import AsyncBulb from yeelight.main import get_known_models -from homeassistant.components import dhcp, onboarding, ssdp, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ( ConfigEntry, ConfigEntryState, @@ -23,6 +23,9 @@ from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CON from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType from .const import ( @@ -69,21 +72,21 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_devices: dict[str, Any] = {} async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery from homekit.""" self._discovered_ip = discovery_info.host return await self._async_handle_discovery() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery from dhcp.""" self._discovered_ip = discovery_info.ip return await self._async_handle_discovery() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery from zeroconf.""" self._discovered_ip = discovery_info.host @@ -91,7 +94,7 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): return await self._async_handle_discovery_with_unique_id() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle discovery from ssdp.""" self._discovered_ip = urlparse(discovery_info.ssdp_headers["location"]).hostname From 7a442af9fa83c7a2713086209bdfc4bb57153e13 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Jan 2025 16:14:21 +0100 Subject: [PATCH 0458/2987] Use new ServiceInfo location in sonos (#135699) --- homeassistant/components/sonos/config_flow.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sonos/config_flow.py b/homeassistant/components/sonos/config_flow.py index a8ace6e35c5..66fe0f0d78c 100644 --- a/homeassistant/components/sonos/config_flow.py +++ b/homeassistant/components/sonos/config_flow.py @@ -3,10 +3,11 @@ from collections.abc import Awaitable import dataclasses -from homeassistant.components import ssdp, zeroconf +from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_flow import DiscoveryFlowHandler +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DATA_SONOS_DISCOVERY_MANAGER, DOMAIN, UPNP_ST from .helpers import hostname_to_uid @@ -25,7 +26,7 @@ class SonosDiscoveryFlowHandler(DiscoveryFlowHandler[Awaitable[bool]], domain=DO super().__init__(DOMAIN, "Sonos", _async_has_devices) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf.""" hostname = discovery_info.hostname From 5e648ebb5cd724fce7bde3003179c6f42215e1bb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Jan 2025 16:14:55 +0100 Subject: [PATCH 0459/2987] Use new ServiceInfo location in tplink (#135700) --- homeassistant/components/tplink/config_flow.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 9bc278f8948..9ca2fe80cf9 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -18,7 +18,7 @@ from kasa import ( ) import voluptuous as vol -from homeassistant.components import dhcp, ffmpeg, stream +from homeassistant.components import ffmpeg, stream from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, @@ -40,6 +40,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType from . import ( @@ -93,7 +94,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_device: Device | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" return await self._async_handle_discovery( From 082ef3f85fe3277543d6cf848b38b31be3121f4e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Jan 2025 16:15:51 +0100 Subject: [PATCH 0460/2987] Use new ServiceInfo location in rainforest_raven (#135697) --- homeassistant/components/rainforest_raven/config_flow.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/rainforest_raven/config_flow.py b/homeassistant/components/rainforest_raven/config_flow.py index 72d258dc1d3..f8e3dde446a 100644 --- a/homeassistant/components/rainforest_raven/config_flow.py +++ b/homeassistant/components/rainforest_raven/config_flow.py @@ -20,6 +20,7 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, SelectSelectorMode, ) +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import DEFAULT_NAME, DOMAIN @@ -30,7 +31,7 @@ def _format_id(value: str | int) -> str: return f"{value or 0:04X}" -def _generate_unique_id(info: ListPortInfo | usb.UsbServiceInfo) -> str: +def _generate_unique_id(info: ListPortInfo | UsbServiceInfo) -> str: """Generate unique id from usb attributes.""" return ( f"{_format_id(info.vid)}:{_format_id(info.pid)}_{info.serial_number}" @@ -98,9 +99,7 @@ class RainforestRavenConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.async_show_form(step_id="meters", data_schema=schema, errors=errors) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" device = discovery_info.device dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) From d3bedd693ab487f4e7dcb27a77c9c03e61253e45 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Jan 2025 16:17:09 +0100 Subject: [PATCH 0461/2987] Use new ServiceInfo location in rabbitair (#135696) --- homeassistant/components/rabbitair/config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rabbitair/config_flow.py b/homeassistant/components/rabbitair/config_flow.py index 1bee69219b0..f4487a73b58 100644 --- a/homeassistant/components/rabbitair/config_flow.py +++ b/homeassistant/components/rabbitair/config_flow.py @@ -14,6 +14,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -99,7 +100,7 @@ class RabbitAirConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" mac = dr.format_mac(discovery_info.properties["id"]) From 9d7c9177711667fd10992a89b1e98625eb660129 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Jan 2025 16:17:43 +0100 Subject: [PATCH 0462/2987] Use new ServiceInfo location in modem_callerid (#135695) --- homeassistant/components/modem_callerid/config_flow.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modem_callerid/config_flow.py b/homeassistant/components/modem_callerid/config_flow.py index 98e6708a34c..237fafa69d7 100644 --- a/homeassistant/components/modem_callerid/config_flow.py +++ b/homeassistant/components/modem_callerid/config_flow.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.components import usb from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_NAME +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import DEFAULT_NAME, DOMAIN, EXCEPTIONS @@ -30,9 +31,7 @@ class PhoneModemFlowHandler(ConfigFlow, domain=DOMAIN): """Set up flow instance.""" self._device: str | None = None - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" dev_path = discovery_info.device unique_id = f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}" From 241fc2af671925c2aba8cd48d9871fe580e0d00c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Jan 2025 16:35:27 +0100 Subject: [PATCH 0463/2987] Use new ServiceInfo location in insteon (#135694) --- homeassistant/components/insteon/config_flow.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index 143a9e2a5e2..54756397211 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -7,7 +7,7 @@ from typing import Any from pyinsteon import async_connect -from homeassistant.components import dhcp, usb +from homeassistant.components import usb from homeassistant.config_entries import ( DEFAULT_DISCOVERY_UNIQUE_ID, ConfigFlow, @@ -15,6 +15,8 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_NAME from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import CONF_HUB_VERSION, DOMAIN from .schemas import build_hub_schema, build_plm_manual_schema, build_plm_schema @@ -129,9 +131,7 @@ class InsteonFlowHandler(ConfigFlow, domain=DOMAIN): step_id=step_id, data_schema=data_schema, errors=errors ) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB discovery.""" self._device_path = discovery_info.device self._device_name = usb.human_readable_device_name( @@ -162,7 +162,7 @@ class InsteonFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle a DHCP discovery.""" self.discovered_conf = {CONF_HOST: discovery_info.ip} From 3622e8331b4a9aa8d87e80ca8847c095ed4690cc Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 15 Jan 2025 15:53:57 +0000 Subject: [PATCH 0464/2987] Update tplink quality_scale.yaml (#135705) --- homeassistant/components/tplink/quality_scale.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tplink/quality_scale.yaml b/homeassistant/components/tplink/quality_scale.yaml index c170cf8c169..ced9cbcc831 100644 --- a/homeassistant/components/tplink/quality_scale.yaml +++ b/homeassistant/components/tplink/quality_scale.yaml @@ -32,7 +32,7 @@ rules: parallel-updates: done test-coverage: done integration-owner: done - docs-installation-parameters: todo + docs-installation-parameters: done docs-configuration-parameters: status: exempt comment: The integration does not have any options configuration parameters. @@ -52,13 +52,13 @@ rules: dynamic-devices: todo discovery-update-info: done repair-issues: done - docs-use-cases: todo + docs-use-cases: done docs-supported-devices: done - docs-supported-functions: todo - docs-data-update: todo - docs-known-limitations: todo - docs-troubleshooting: todo - docs-examples: todo + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done # Platinum async-dependency: done From f36a10126c0cc9c35b3b4a42b9684aa4b76adec1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 15 Jan 2025 19:40:29 +0100 Subject: [PATCH 0465/2987] Add WS command backup/can_decrypt_on_download (#135662) * Add WS command backup/can_decrypt_on_download * Wrap errors * Add default messages to exceptions * Improve test coverage --- homeassistant/components/backup/manager.py | 54 ++++++++++- homeassistant/components/backup/util.py | 85 +++++++++++++++++- homeassistant/components/backup/websocket.py | 39 +++++++- .../backup/fixtures/test_backups/2bcb3113.tar | Bin 0 -> 10240 bytes .../backup/fixtures/test_backups/ed1608a9.tar | Bin 0 -> 10240 bytes .../backup/snapshots/test_websocket.ambr | 52 +++++++++++ tests/components/backup/test_websocket.py | 55 +++++++++++- 7 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 tests/components/backup/fixtures/test_backups/2bcb3113.tar create mode 100644 tests/components/backup/fixtures/test_backups/ed1608a9.tar diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 76e1c261e31..73bbfafdcf8 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -14,7 +14,7 @@ from pathlib import Path, PurePath import shutil import tarfile import time -from typing import TYPE_CHECKING, Any, Protocol, TypedDict +from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast import aiohttp from securetar import SecureTarFile, atomic_contents_add @@ -31,6 +31,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util +from . import util as backup_util from .agent import ( BackupAgent, BackupAgentError, @@ -48,7 +49,13 @@ from .const import ( ) from .models import AgentBackup, BackupManagerError, Folder from .store import BackupStore -from .util import make_backup_dir, read_backup, validate_password +from .util import ( + AsyncIteratorReader, + make_backup_dir, + read_backup, + validate_password, + validate_password_stream, +) @dataclass(frozen=True, kw_only=True, slots=True) @@ -248,6 +255,14 @@ class BackupReaderWriterError(HomeAssistantError): class IncorrectPasswordError(BackupReaderWriterError): """Raised when the password is incorrect.""" + _message = "The password provided is incorrect." + + +class DecryptOnDowloadNotSupported(BackupManagerError): + """Raised when on-the-fly decryption is not supported.""" + + _message = "On-the-fly decryption is not supported for this backup." + class BackupManager: """Define the format that backup managers can have.""" @@ -990,6 +1005,39 @@ class BackupManager: translation_placeholders={"failed_agents": ", ".join(agent_errors)}, ) + async def async_can_decrypt_on_download( + self, + backup_id: str, + *, + agent_id: str, + password: str | None, + ) -> None: + """Check if we are able to decrypt the backup on download.""" + try: + agent = self.backup_agents[agent_id] + except KeyError as err: + raise BackupManagerError(f"Invalid agent selected: {agent_id}") from err + if not await agent.async_get_backup(backup_id): + raise BackupManagerError( + f"Backup {backup_id} not found in agent {agent_id}" + ) + reader: IO[bytes] + if agent_id in self.local_backup_agents: + local_agent = self.local_backup_agents[agent_id] + path = local_agent.get_backup_path(backup_id) + reader = await self.hass.async_add_executor_job(open, path.as_posix(), "rb") + else: + backup_stream = await agent.async_download_backup(backup_id) + reader = cast(IO[bytes], AsyncIteratorReader(self.hass, backup_stream)) + try: + validate_password_stream(reader, password) + except backup_util.IncorrectPassword as err: + raise IncorrectPasswordError from err + except backup_util.UnsuppertedSecureTarVersion as err: + raise DecryptOnDowloadNotSupported from err + except backup_util.DecryptError as err: + raise BackupManagerError(str(err)) from err + class KnownBackups: """Track known backups.""" @@ -1372,7 +1420,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): validate_password, path, password ) if not password_valid: - raise IncorrectPasswordError("The password provided is incorrect.") + raise IncorrectPasswordError def _write_restore_file() -> None: """Write the restore file.""" diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index 930625c52ca..ae0244591d8 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -3,13 +3,14 @@ from __future__ import annotations import asyncio +from collections.abc import AsyncIterator from pathlib import Path from queue import SimpleQueue import tarfile -from typing import cast +from typing import IO, cast import aiohttp -from securetar import SecureTarFile +from securetar import VERSION_HEADER, SecureTarFile, SecureTarReadError from homeassistant.backup_restore import password_to_key from homeassistant.core import HomeAssistant @@ -19,6 +20,22 @@ from .const import BUF_SIZE, LOGGER from .models import AddonInfo, AgentBackup, Folder +class DecryptError(Exception): + """Error during decryption.""" + + +class UnsuppertedSecureTarVersion(DecryptError): + """Unsupported securetar version.""" + + +class IncorrectPassword(DecryptError): + """Invalid password or corrupted backup.""" + + +class BackupEmpty(DecryptError): + """No tar files found in the backup.""" + + def make_backup_dir(path: Path) -> None: """Create a backup directory if it does not exist.""" path.mkdir(exist_ok=True) @@ -106,6 +123,70 @@ def validate_password(path: Path, password: str | None) -> bool: return False +class AsyncIteratorReader: + """Wrap an AsyncIterator.""" + + def __init__(self, hass: HomeAssistant, stream: AsyncIterator[bytes]) -> None: + """Initialize the wrapper.""" + self._hass = hass + self._stream = stream + self._buffer: bytes | None = None + self._pos: int = 0 + + async def _next(self) -> bytes | None: + """Get the next chunk from the iterator.""" + return await anext(self._stream, None) + + def read(self, n: int = -1, /) -> bytes: + """Read data from the iterator.""" + result = bytearray() + while n < 0 or len(result) < n: + if not self._buffer: + self._buffer = asyncio.run_coroutine_threadsafe( + self._next(), self._hass.loop + ).result() + self._pos = 0 + if not self._buffer: + # The stream is exhausted + break + chunk = self._buffer[self._pos : self._pos + n] + result.extend(chunk) + n -= len(chunk) + self._pos += len(chunk) + if self._pos == len(self._buffer): + self._buffer = None + return bytes(result) + + +def validate_password_stream( + input_stream: IO[bytes], + password: str | None, +) -> None: + """Decrypt a backup.""" + with ( + tarfile.open(fileobj=input_stream, mode="r|", bufsize=BUF_SIZE) as input_tar, + ): + for obj in input_tar: + if not obj.name.endswith((".tar", ".tgz", ".tar.gz")): + continue + if obj.pax_headers.get(VERSION_HEADER) != "2.0": + raise UnsuppertedSecureTarVersion + istf = SecureTarFile( + None, # Not used + gzip=False, + key=password_to_key(password) if password is not None else None, + mode="r", + fileobj=input_tar.extractfile(obj), + ) + with istf.decrypt(obj) as decrypted: + try: + decrypted.read(1) # Read a single byte to trigger the decryption + except SecureTarReadError as err: + raise IncorrectPassword from err + return + raise BackupEmpty + + async def receive_file( hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path ) -> None: diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 0139b7fdb77..1b8433e2f24 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -9,7 +9,11 @@ from homeassistant.core import HomeAssistant, callback from .config import ScheduleState from .const import DATA_MANAGER, LOGGER -from .manager import IncorrectPasswordError, ManagerStateEvent +from .manager import ( + DecryptOnDowloadNotSupported, + IncorrectPasswordError, + ManagerStateEvent, +) from .models import Folder @@ -24,6 +28,7 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> websocket_api.async_register_command(hass, handle_details) websocket_api.async_register_command(hass, handle_info) + websocket_api.async_register_command(hass, handle_can_decrypt_on_download) websocket_api.async_register_command(hass, handle_create) websocket_api.async_register_command(hass, handle_create_with_automatic_settings) websocket_api.async_register_command(hass, handle_delete) @@ -147,6 +152,38 @@ async def handle_restore( connection.send_result(msg["id"]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "backup/can_decrypt_on_download", + vol.Required("backup_id"): str, + vol.Required("agent_id"): str, + vol.Required("password"): str, + } +) +@websocket_api.async_response +async def handle_can_decrypt_on_download( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Check if the supplied password is correct.""" + try: + await hass.data[DATA_MANAGER].async_can_decrypt_on_download( + msg["backup_id"], + agent_id=msg["agent_id"], + password=msg.get("password"), + ) + except IncorrectPasswordError: + connection.send_error(msg["id"], "password_incorrect", "Incorrect password") + except DecryptOnDowloadNotSupported: + connection.send_error( + msg["id"], "decrypt_not_supported", "Decrypt on download not supported" + ) + else: + connection.send_result(msg["id"]) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/tests/components/backup/fixtures/test_backups/2bcb3113.tar b/tests/components/backup/fixtures/test_backups/2bcb3113.tar new file mode 100644 index 0000000000000000000000000000000000000000..8a6556634f3e3cb52618ec5b9b5d8eb6675dfa4f GIT binary patch literal 10240 zcmdPXPfASAE-lc@D$dVipbanp0y7g61`rJd=(K^UFbrYKn{loXYw>L@6sB$lKCc}hkGMy9$3hPs9(A%><_ z2F6xK7J8<}rj}-gx&}bLff7({YDGy=B2aO)Qf3}7Ec236<1;}QcK8>bkh zn46>+C0Zt?niw0Ur5U9qB_<}CnSeBxXO?8dCzh7v=O&h9CdU`2mXu`Xr56LWrzPeT zr`7_MWaQ_jCKeZGg3K-f+ml+6oKu>T8V_`3Vp3u;$fZzsm!%dJXXfXDYzH}A&q&We zFD13iKnY|}USclTQ_jFB&(BqWqImy`2&=})tgM|?lT`Cd=hRi+Qi)f0y5knO#Zu(0Nqx*xzO#vUp3U&gKdosL zs-(x1cH;K3;u^6C>k7Au4Ml5~uI8B>rs^6T@ued2X=m~`%eewKlU2hL-#pl~mY{gpa-GiJ@F;vDrnZ~j9u4!adHegCs685}+Iny)ra-|s(pBQB=f989!_jb74yT9K~gsr)JtyA+ttL}o_ng$jl zmF+g~TBPg~I-g6g-dDThpzBTh{M*^bk1|&DzFOXsWiw6I+wiMru+Z-vlQlwH{;hoy zu;Ig*eYN^QdVR8!W+6o_QOwc$e`L4+vDN=3z~a~3(s;D~Cn^UGZAGB^--yNy05a-- zL(|dzKV1@5rttR@nHzS*ZONM1?qkWpe8N3!YFSmL%@hWCIit{Bu1e>h`FYRSRL%9> za>GRD!$IPl0VdDS&-$xT6CrbC_Mb@CMHW{w@1EMTpfe(tn>#J*)85TnZ{?k4_dY45 za#Nj8aU#o}!d`F7GVkmEM1vv>7_6+R8YXSe(s#)H4`}*xS7qTp3sW$E3vD4 z+~97`@$zxzzl*w>Vp7*l6rZ|X*d{FUW{2K3nYz`EIURd4Sr-HaZj#t_Er74{X3&?f zi8fnI8ts%V8B_EXJ*rQ?w@;Qn{AovDy0m%x!tU#P_pGd6bMTl1gGcV;+zUB3*!g;5 zc?BDix-7WU4j*ju(!V!#p=VcDw4VDtky};2rWKu>H1UPPzWh^rCfV02ufMBmwa9sO z{e%gRg$y?JwU_;I%@5MMrE**=XxF0)%IyuWB^N1qWi2cb4}E&mH8Wf-I7okPkX`Mn zy~Tx@RsxakT>i^aOWHQJU$zg``Mp5z^q({f=GDDV=G?oG@;yMOt77(-3{TsYc}pKn zID6o8L0#}$=E$?AH=@7S2u{g+E?o9X{7d2WcWu6>!{*rZY)n`iZ4+9wx-#eY)d#IE zlMmaxbnQM6Q4$|H?~@Gw$yNWXE*VVT$izr$q8S|ZzcFGg05nbj9RD*k9PR&;8kgj# zH!{cU|C<;XSsGi8R{!LL=TOrFs{f4#WBm`z|8yCXAK~MFC;r5r%XoZ~W0G|!d&`c} zhtn6#o*q)4J4ZO>z3%^ADTzUfU%GRD{L7r9xLiMdLKoM&7mEUg%3mmEEY6kMxzTr( z|H>ErW;W%nf(}ZniX&sY?N_eb6Z-zlMyXUCj}^!CPThHSv2(`OO*bc&8mRSeo&P<4 zp7XT7SJrOv`Pw>7$4Yd;JCX5e%9^R>)V#(O}km3XK-nOz@1gM<{xkjIT>;1hOqjt!hZ@|RCadCXmD*?{VH{d zg;4C}`$~JtS08<}@?GJXH2I%9C#EeAe^j?pTS&NmpSxLD@om<_inj~8qx=^C7Z>E5 zzwWry86Nqm3te9`z2q-H=<(lvyW-pc*Ed(*{qvjtd9U~vJLi((*cBC1cU|7i5G|DV zJKNBm>wNK)l3SrSHs*0l2dI5|~C)wtQmzs^u={T4T@~ zzC7sW`m-Gyr|Ol?^RO?kjJqjk^6T-1qc{Bb{qmnw=U6Sz<#YR!<1UHdEnA;V`Sk4S~@R7!85Z5Eu=C(GVC7fzc2c4FURv002Ddqo4o) literal 0 HcmV?d00001 diff --git a/tests/components/backup/fixtures/test_backups/ed1608a9.tar b/tests/components/backup/fixtures/test_backups/ed1608a9.tar new file mode 100644 index 0000000000000000000000000000000000000000..fc928b16d1b1c3c480ea3600ef8f69d7f87dbc4c GIT binary patch literal 10240 zcmeHKX;@QN8jkv~$Ql-bfQyNK`V_ zmIhh@SRWu_u}F+i0As;%L=G3@lFLK_IkHql8iPV5)5&BO#G+GJ5Nr!06fzVX3Y2bx(LP4h!Q8 zctV6Je4Y+8Jc=#LiLgK#U<2FAkP8p-m$LC;fsg}-%ZC2BIu!rEQa~hYlC_g39OH_> zEJUz8sFzg#@4El>8~{Lw{@eS~`GDykq~ZuM%LC`dtgpHL$z%eiI%bpr!Hz)^KOE+8 zg_xoPQBEwfl>*wRK(yxrekt*-n6OkJ8$yJT6M<|6>13KMnGVvZBnp6i(#a|>axCxt zX&GSh1PDpC_3YSX!zk1LfAW7ib~b<%It8QybTTG<00aO!DEI$m+mPoH7y4F@LO2Jh z^7XFV{s-YkrE#fAnSXJ$7$s7}TfcX9n7*hLaQ0X>j7M}TRhx5)(QVGbwz{d?rR=J` z8`f&DPo=T;E8!Zl3*85eyF`I4JEaM?4=If9JD*U#tq|&vDtEBSUfoBu??E@U>visO z3srh+irYraiWJ>q`$huM&`V9C<}&l)km_&1`$cyucS4$0h_Gsl*-};)j?=UMeM@%YfgOY2&?HQE=_{%Y-4m)=3AO6XE*WERiBXvSEX{*P! zb6N)IO^2gxw;Tt3R6k}|<^pF^eMbA$dh4xY@M-UT&ehi(*vu%4#3@bAOslzT)k~*Q zrg7n7M(rB0Nm@T@;*fN4oQ_w0a!+Z%rQIyla3x{PPTVQ38C;>EteF?5#utT z4D6@%d+7UAoDjd|LJZJ8xmWwJllRQgAe@)Yw1tmhK{o+Ek@XfX$0G5u$kZo)&Z1s9 zXoK&m8`H-AMO7jK*sKKa{qrR zG5!}O$^0K$<_o|}_&-P`%l-dVltXdiDyTAzH(#pGnL36mk1A%ii{j@Gn=2TP3iPXy z5@OJ}1i!E`o8C23qW!GeH2o@(r7qg+BK|J3sn*b3(kyk#>BT+xR{0I&B;z1*1WnY~ z^wg_ea3t?cfby*^B5RYb9$$T8OBCNC|9)eG6{Kb<>alm5F1QqGe9yj81+`Zp#Y9@> zXSz?V>UnTE%`S12=QPw?sZ-NlYIf?*c&F{0-mJBOMo@7f2knnvjpB%b9Q@D!+ovX{o7iz zzCBoRbx%@u-?MU;oP;psSfhU{UpdQT;W4qt(<(P9Wgn>9c0VTAzO=9U(+TrU8w?E3 zT?pLdtaaFCY3dq{I$Ad7uTB5%_(-Ie{hdC=boW%NNlTJZ)2#lA z1=Gjg382moVb_~H3$1@5v|HX*_M19U#n|-Y;Vent;^rTsGi>|B+OBRxl^YrBKeCQ{ z-2PRVxI@e4_tuEq|K^dAGIUs;%8wWIt z^x~Z^=ql(wOs?Kj$PCT-@U9`fSnG=~*9c>!QRmGLITk4FJ^Ws=_wXkLMRT>Qn^avx zENy3J%S^%Sx^;V$l0L*uRmV-Hbatvgxjyd!6_pK(oUR&7)Fk;;x7Q;6pH_C;iN6Ux z&GP(f)wG%_(Dam{_!*HEEa}d3zXuI8xEKlsMh9B9Grt~W;QOZ{+KSN3FHdU(rG||^ z?f>z-+LLu%J(|5fk>sw$ECXGd>wBkwgsPlb1O3>vZRiy z6}M8QoqyNFOGuI&xVGWp0ba}4g@s4)_AEUW*4!a41n2Jk2B$>2F{EIIHgkT)7=*O> zAJo--r8jb=xBShUPcvMy8ZWwa^;5!AB-9D#p~jF`Q6us-K+b@i0XYM5 N2ILIL8TkKT;2*(l$Swc? literal 0 HcmV?d00001 diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 98b2f764d43..ac4e77fca41 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -175,6 +175,58 @@ 'type': 'result', }) # --- +# name: test_can_decrypt_on_download[backup.local-2bcb3113-hunter2] + dict({ + 'error': dict({ + 'code': 'decrypt_not_supported', + 'message': 'Decrypt on download not supported', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download[backup.local-ed1608a9-hunter2] + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download[backup.local-ed1608a9-wrong_password] + dict({ + 'error': dict({ + 'code': 'password_incorrect', + 'message': 'Incorrect password', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download[backup.local-no_such_backup-hunter2] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Backup no_such_backup not found in agent backup.local', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download[no_such_agent-ed1608a9-hunter2] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Invalid agent selected: no_such_agent', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- # name: test_config_info[None] dict({ 'id': 1, diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index e95481373d6..7820408f265 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -36,7 +36,7 @@ from .common import ( setup_backup_platform, ) -from tests.common import async_fire_time_changed, async_mock_service +from tests.common import async_fire_time_changed, async_mock_service, get_fixture_path from tests.typing import WebSocketGenerator BACKUP_CALL = call( @@ -2554,3 +2554,56 @@ async def test_subscribe_event( CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS) ) assert await client.receive_json() == snapshot + + +@pytest.fixture +def mock_backups() -> Generator[None]: + """Fixture to setup test backups.""" + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.backup import backup as core_backup + + class CoreLocalBackupAgent(core_backup.CoreLocalBackupAgent): + def __init__(self, hass: HomeAssistant) -> None: + super().__init__(hass) + self._backup_dir = get_fixture_path("test_backups", DOMAIN) + + with patch.object(core_backup, "CoreLocalBackupAgent", CoreLocalBackupAgent): + yield + + +@pytest.mark.parametrize( + ("agent_id", "backup_id", "password"), + [ + # Invalid agent or backup + ("no_such_agent", "ed1608a9", "hunter2"), + ("backup.local", "no_such_backup", "hunter2"), + # Legacy backup, which can't be streamed + ("backup.local", "2bcb3113", "hunter2"), + # New backup, which can be streamed, try with correct and wrong password + ("backup.local", "ed1608a9", "hunter2"), + ("backup.local", "ed1608a9", "wrong_password"), + ], +) +@pytest.mark.usefixtures("mock_backups") +async def test_can_decrypt_on_download( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + agent_id: str, + backup_id: str, + password: str, +) -> None: + """Test can decrypt on download.""" + await setup_backup_integration(hass, with_hassio=False) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/can_decrypt_on_download", + "backup_id": backup_id, + "agent_id": agent_id, + "password": password, + } + ) + assert await client.receive_json() == snapshot From 146d6bbc683f490418f3a7265db80895a5ea6843 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Wed, 15 Jan 2025 20:29:29 +0100 Subject: [PATCH 0466/2987] Bump eheimdigital to 1.0.4 (#135722) --- homeassistant/components/eheimdigital/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json index 159aecd6b6c..6ca85c74a43 100644 --- a/homeassistant/components/eheimdigital/manifest.json +++ b/homeassistant/components/eheimdigital/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["eheimdigital"], "quality_scale": "bronze", - "requirements": ["eheimdigital==1.0.3"], + "requirements": ["eheimdigital==1.0.4"], "zeroconf": [ { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } ] diff --git a/requirements_all.txt b/requirements_all.txt index 7158a43fd88..bfccea81e04 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -815,7 +815,7 @@ ebusdpy==0.0.17 ecoaliface==0.4.0 # homeassistant.components.eheimdigital -eheimdigital==1.0.3 +eheimdigital==1.0.4 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d2dbd17dda6..034b4d3f6cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ eagle100==0.1.1 easyenergy==2.1.2 # homeassistant.components.eheimdigital -eheimdigital==1.0.3 +eheimdigital==1.0.4 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 From c6cab3259ce48edffe32cef5f6893b1b7bd9a2c5 Mon Sep 17 00:00:00 2001 From: Ik-12 <78494704+Ik-12@users.noreply.github.com> Date: Wed, 15 Jan 2025 21:37:33 +0200 Subject: [PATCH 0467/2987] Create switches for controlling policy-based routes (#134473) Create switches for controlling policy-based routes (aka "traffic routes" in the Unifi API). --- .../components/unifi/hub/entity_loader.py | 6 +- homeassistant/components/unifi/icons.json | 3 + homeassistant/components/unifi/switch.py | 25 +++++++ tests/components/unifi/conftest.py | 8 +++ tests/components/unifi/test_switch.py | 72 +++++++++++++++++++ 5 files changed, 113 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py index f11ddefec98..64403152b0c 100644 --- a/homeassistant/components/unifi/hub/entity_loader.py +++ b/homeassistant/components/unifi/hub/entity_loader.py @@ -47,9 +47,13 @@ class UnifiEntityLoader: hub.api.sites.update, hub.api.system_information.update, hub.api.traffic_rules.update, + hub.api.traffic_routes.update, hub.api.wlans.update, ) - self.polling_api_updaters = (hub.api.traffic_rules.update,) + self.polling_api_updaters = ( + hub.api.traffic_rules.update, + hub.api.traffic_routes.update, + ) self.wireless_clients = hub.hass.data[UNIFI_WIRELESS_CLIENTS] self._dataUpdateCoordinator = DataUpdateCoordinator( diff --git a/homeassistant/components/unifi/icons.json b/homeassistant/components/unifi/icons.json index 76990c1c4a1..6874bb5ae03 100644 --- a/homeassistant/components/unifi/icons.json +++ b/homeassistant/components/unifi/icons.json @@ -61,6 +61,9 @@ "traffic_rule_control": { "default": "mdi:security-network" }, + "traffic_route_control": { + "default": "mdi:routes" + }, "poe_port_control": { "default": "mdi:ethernet", "state": { diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 01843a8a95b..7741e57c82c 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -20,6 +20,7 @@ from aiounifi.interfaces.dpi_restriction_groups import DPIRestrictionGroups from aiounifi.interfaces.outlets import Outlets from aiounifi.interfaces.port_forwarding import PortForwarding from aiounifi.interfaces.ports import Ports +from aiounifi.interfaces.traffic_routes import TrafficRoutes from aiounifi.interfaces.traffic_rules import TrafficRules from aiounifi.interfaces.wlans import Wlans from aiounifi.models.api import ApiItemT @@ -31,6 +32,7 @@ from aiounifi.models.event import Event, EventKey from aiounifi.models.outlet import Outlet from aiounifi.models.port import Port from aiounifi.models.port_forward import PortForward, PortForwardEnableRequest +from aiounifi.models.traffic_route import TrafficRoute, TrafficRouteSaveRequest from aiounifi.models.traffic_rule import TrafficRule, TrafficRuleEnableRequest from aiounifi.models.wlan import Wlan, WlanEnableRequest @@ -170,6 +172,16 @@ async def async_traffic_rule_control_fn( await hub.api.traffic_rules.update() +async def async_traffic_route_control_fn( + hub: UnifiHub, obj_id: str, target: bool +) -> None: + """Control traffic route state.""" + traffic_route = hub.api.traffic_routes[obj_id].raw + await hub.api.request(TrafficRouteSaveRequest.create(traffic_route, target)) + # Update the traffic routes so the UI is updated appropriately + await hub.api.traffic_routes.update() + + async def async_wlan_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> None: """Control outlet relay.""" await hub.api.request(WlanEnableRequest.create(obj_id, target)) @@ -263,6 +275,19 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( object_fn=lambda api, obj_id: api.traffic_rules[obj_id], unique_id_fn=lambda hub, obj_id: f"traffic_rule-{obj_id}", ), + UnifiSwitchEntityDescription[TrafficRoutes, TrafficRoute]( + key="Traffic route control", + translation_key="traffic_route_control", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + api_handler_fn=lambda api: api.traffic_routes, + control_fn=async_traffic_route_control_fn, + device_info_fn=async_unifi_network_device_info_fn, + is_on_fn=lambda hub, traffic_route: traffic_route.enabled, + name_fn=lambda traffic_route: traffic_route.description, + object_fn=lambda api, obj_id: api.traffic_routes[obj_id], + unique_id_fn=lambda hub, obj_id: f"traffic_route-{obj_id}", + ), UnifiSwitchEntityDescription[Ports, Port]( key="PoE port control", translation_key="poe_port_control", diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index 798b613b18d..702f8629219 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -174,6 +174,7 @@ def fixture_request( dpi_group_payload: list[dict[str, Any]], port_forward_payload: list[dict[str, Any]], traffic_rule_payload: list[dict[str, Any]], + traffic_route_payload: list[dict[str, Any]], site_payload: list[dict[str, Any]], system_information_payload: list[dict[str, Any]], wlan_payload: list[dict[str, Any]], @@ -214,6 +215,7 @@ def fixture_request( mock_get_request(f"/api/s/{site_id}/stat/sysinfo", system_information_payload) mock_get_request(f"/api/s/{site_id}/rest/wlanconf", wlan_payload) mock_get_request(f"/v2/api/site/{site_id}/trafficrules", traffic_rule_payload) + mock_get_request(f"/v2/api/site/{site_id}/trafficroutes", traffic_route_payload) return __mock_requests @@ -291,6 +293,12 @@ def traffic_rule_payload_data() -> list[dict[str, Any]]: return [] +@pytest.fixture(name="traffic_route_payload") +def traffic_route_payload_data() -> list[dict[str, Any]]: + """Traffic route data.""" + return [] + + @pytest.fixture(name="wlan_payload") def fixture_wlan_data() -> list[dict[str, Any]]: """WLAN data.""" diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index cb5dcdac428..e4765d1181e 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -809,6 +809,24 @@ TRAFFIC_RULE = { "target_devices": [{"client_mac": CLIENT_1["mac"], "type": "CLIENT"}], } +TRAFFIC_ROUTE = { + "_id": "676f8dbb8f1d54503bba19ab", + "description": "Test traffic route", + "domains": [{"domain": "youtube.com", "port_ranges": [], "ports": []}], + "enabled": True, + "ip_addresses": [], + "ip_ranges": [], + "kill_switch_enabled": True, + "matching_target": "DOMAIN", + "network_id": "676f8d288f1d54503bba1987", + "next_hop": "", + "regions": [], + "target_devices": [ + {"network_id": "6060b00f45de3905133cea14", "type": "NETWORK"}, + {"network_id": "6060ae6045de3905133cea0a", "type": "NETWORK"}, + ], +} + @pytest.mark.parametrize( "config_entry_options", [{CONF_BLOCK_CLIENT: [BLOCKED["mac"]]}] @@ -1154,6 +1172,60 @@ async def test_traffic_rules( assert aioclient_mock.mock_calls[call_count][2] == expected_enable_call +@pytest.mark.parametrize(("traffic_route_payload"), [([TRAFFIC_ROUTE])]) +async def test_traffic_routes( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, + traffic_route_payload: list[dict[str, Any]], +) -> None: + """Test control of UniFi traffic routes.""" + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + + # Validate state object + assert hass.states.get("switch.unifi_network_test_traffic_route").state == STATE_ON + + traffic_route = deepcopy(traffic_route_payload[0]) + + # Disable traffic route + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/v2/api/site/{config_entry_setup.data[CONF_SITE_ID]}" + f"/trafficroutes/{traffic_route['_id']}", + ) + + call_count = aioclient_mock.call_count + + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": "switch.unifi_network_test_traffic_route"}, + blocking=True, + ) + # Updating the value for traffic routes will make another call to retrieve the values + assert aioclient_mock.call_count == call_count + 2 + expected_disable_call = deepcopy(traffic_route) + expected_disable_call["enabled"] = False + + assert aioclient_mock.mock_calls[call_count][2] == expected_disable_call + + call_count = aioclient_mock.call_count + + # Enable traffic route + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_on", + {"entity_id": "switch.unifi_network_test_traffic_route"}, + blocking=True, + ) + + expected_enable_call = deepcopy(traffic_route) + expected_enable_call["enabled"] = True + + assert aioclient_mock.call_count == call_count + 2 + assert aioclient_mock.mock_calls[call_count][2] == expected_enable_call + + @pytest.mark.parametrize( ("device_payload", "entity_id", "outlet_index", "expected_switches"), [ From 51e3bf42f2068a9d0b29fa46ea17180170a4da6f Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 15 Jan 2025 19:45:06 +0000 Subject: [PATCH 0468/2987] Add dynamic child device handling to tplink integration (#135229) Add dynamic child device handling to tplink integration. For child devices that could be added/removed to hubs. --- homeassistant/components/tplink/__init__.py | 16 +- .../components/tplink/binary_sensor.py | 35 ++-- homeassistant/components/tplink/button.py | 31 ++-- .../components/tplink/coordinator.py | 51 +++++- homeassistant/components/tplink/entity.py | 88 +++++++--- homeassistant/components/tplink/number.py | 32 ++-- homeassistant/components/tplink/select.py | 37 ++-- homeassistant/components/tplink/sensor.py | 31 ++-- homeassistant/components/tplink/switch.py | 34 ++-- tests/components/tplink/test_init.py | 160 +++++++++++++++++- 10 files changed, 403 insertions(+), 112 deletions(-) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 43f5a7da5fd..31bdcc5481c 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -18,7 +18,6 @@ from kasa import ( KasaException, ) from kasa.httpclient import get_cookie_jar -from kasa.iot import IotStrip from homeassistant import config_entries from homeassistant.components import network @@ -235,17 +234,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo parent_coordinator = TPLinkDataUpdateCoordinator( hass, device, timedelta(seconds=5), entry ) - child_coordinators: list[TPLinkDataUpdateCoordinator] = [] - - # 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(device, IotStrip): - child_coordinators = [ - # The child coordinators only update energy data so we can - # set a longer update interval to avoid flooding the device - TPLinkDataUpdateCoordinator(hass, child, timedelta(seconds=60), entry) - for child in device.children - ] camera_creds: Credentials | None = None if camera_creds_dict := entry.data.get(CONF_CAMERA_CREDENTIALS): @@ -254,9 +242,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo ) live_view = entry.data.get(CONF_LIVE_VIEW) - entry.runtime_data = TPLinkData( - parent_coordinator, child_coordinators, camera_creds, live_view - ) + entry.runtime_data = TPLinkData(parent_coordinator, camera_creds, live_view) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py index e3e27d2d1a4..6153ec31de1 100644 --- a/homeassistant/components/tplink/binary_sensor.py +++ b/homeassistant/components/tplink/binary_sensor.py @@ -8,6 +8,7 @@ from typing import Final, cast from kasa import Feature from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, @@ -16,6 +17,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry +from .deprecate import async_cleanup_deprecated from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription @@ -73,19 +75,30 @@ async def async_setup_entry( """Set up sensors.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.BinarySensor, - entity_class=TPLinkBinarySensorEntity, - descriptions=BINARYSENSOR_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) - async_add_entities(entities) + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.BinarySensor, + entity_class=TPLinkBinarySensorEntity, + descriptions=BINARYSENSOR_DESCRIPTIONS_MAP, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_cleanup_deprecated( + hass, BINARY_SENSOR_DOMAIN, config_entry.entry_id, entities + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkBinarySensorEntity(CoordinatedTPLinkFeatureEntity, BinarySensorEntity): diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py index d8a7c8f1281..990f0a608d3 100644 --- a/homeassistant/components/tplink/button.py +++ b/homeassistant/components/tplink/button.py @@ -83,20 +83,27 @@ async def async_setup_entry( """Set up buttons.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device + known_child_device_ids: set[str] = set() + first_check = True - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.Action, - entity_class=TPLinkButtonEntity, - descriptions=BUTTON_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) - async_cleanup_deprecated(hass, BUTTON_DOMAIN, config_entry.entry_id, entities) - async_add_entities(entities) + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Action, + entity_class=TPLinkButtonEntity, + descriptions=BUTTON_DESCRIPTIONS_MAP, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_cleanup_deprecated(hass, BUTTON_DOMAIN, config_entry.entry_id, entities) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkButtonEntity(CoordinatedTPLinkFeatureEntity, ButtonEntity): diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index 337cad47673..186840e8faf 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -7,10 +7,12 @@ from datetime import timedelta import logging from kasa import AuthenticationError, Credentials, Device, KasaException +from kasa.iot import IotStrip from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -24,7 +26,6 @@ class TPLinkData: """Data for the tplink integration.""" parent_coordinator: TPLinkDataUpdateCoordinator - children_coordinators: list[TPLinkDataUpdateCoordinator] camera_credentials: Credentials | None live_view: bool | None @@ -60,6 +61,9 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False ), ) + self._previous_child_device_ids = {child.device_id for child in device.children} + self.removed_child_device_ids: set[str] = set() + self._child_coordinators: dict[str, TPLinkDataUpdateCoordinator] = {} async def _async_update_data(self) -> None: """Fetch all device and sensor data from api.""" @@ -83,3 +87,48 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): "exc": str(ex), }, ) from ex + + await self._process_child_devices() + + async def _process_child_devices(self) -> None: + """Process child devices and remove stale devices.""" + current_child_device_ids = {child.device_id for child in self.device.children} + if ( + stale_device_ids := self._previous_child_device_ids + - current_child_device_ids + ): + device_registry = dr.async_get(self.hass) + for device_id in stale_device_ids: + device = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + if device: + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + child_coordinator = self._child_coordinators.pop(device_id, None) + if child_coordinator: + await child_coordinator.async_shutdown() + + self._previous_child_device_ids = current_child_device_ids + self.removed_child_device_ids = stale_device_ids + + def get_child_coordinator( + self, + child: Device, + ) -> 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): + 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 + ) + self._child_coordinators[child.device_id] = child_coordinator + return child_coordinator + + return self diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 01342339bef..178c8bfdd3d 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -434,7 +434,8 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): feature_type: Feature.Type, entity_class: type[_E], descriptions: Mapping[str, _D], - child_coordinators: list[TPLinkDataUpdateCoordinator] | None = None, + known_child_device_ids: set[str], + first_check: bool, ) -> list[_E]: """Create entities for device and its children. @@ -442,36 +443,69 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): """ entities: list[_E] = [] # Add parent entities before children so via_device id works. - entities.extend( - cls._entities_for_device( + # Only add the parent entities the first time + if first_check: + entities.extend( + cls._entities_for_device( + hass, + device, + coordinator=coordinator, + feature_type=feature_type, + entity_class=entity_class, + descriptions=descriptions, + ) + ) + + # Remove any device ids removed via the coordinator so they can be re-added + for removed_child_id in coordinator.removed_child_device_ids: + _LOGGER.debug( + "Removing %s from known %s child ids for device %s" + "as it has been removed by the coordinator", + removed_child_id, + entity_class.__name__, + device.host, + ) + known_child_device_ids.discard(removed_child_id) + + current_child_devices = {child.device_id: child for child in device.children} + current_child_device_ids = set(current_child_devices.keys()) + new_child_device_ids = current_child_device_ids - known_child_device_ids + children = [] + + if new_child_device_ids: + children = [ + child + for child_id, child in current_child_devices.items() + if child_id in new_child_device_ids + ] + known_child_device_ids.update(new_child_device_ids) + + if children: + _LOGGER.debug( + "Getting %s entities for %s child devices on device %s", + entity_class.__name__, + len(children), + device.host, + ) + for child in children: + child_coordinator = coordinator.get_child_coordinator(child) + + child_entities = cls._entities_for_device( hass, - device, - coordinator=coordinator, + child, + coordinator=child_coordinator, feature_type=feature_type, entity_class=entity_class, descriptions=descriptions, + parent=device, ) - ) - if device.children: - _LOGGER.debug("Initializing device with %s children", len(device.children)) - for idx, child in enumerate(device.children): - # HS300 does not like too many concurrent requests and its - # emeter data requires a request for each socket, so we receive - # separate coordinators. - if child_coordinators: - child_coordinator = child_coordinators[idx] - else: - child_coordinator = coordinator - entities.extend( - cls._entities_for_device( - hass, - child, - coordinator=child_coordinator, - feature_type=feature_type, - entity_class=entity_class, - descriptions=descriptions, - parent=device, - ) - ) + _LOGGER.debug( + "Device %s, found %s child %s entities for child id %s", + device.host, + len(entities), + entity_class.__name__, + child.device_id, + ) + entities.extend(child_entities) return entities diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py index 7bd56067f20..97152ef4da8 100644 --- a/homeassistant/components/tplink/number.py +++ b/homeassistant/components/tplink/number.py @@ -9,6 +9,7 @@ from typing import Final, cast from kasa import Device, Feature from homeassistant.components.number import ( + DOMAIN as NUMBER_DOMAIN, NumberEntity, NumberEntityDescription, NumberMode, @@ -17,6 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry +from .deprecate import async_cleanup_deprecated from .entity import ( CoordinatedTPLinkFeatureEntity, TPLinkDataUpdateCoordinator, @@ -77,19 +79,27 @@ async def async_setup_entry( """Set up number entities.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.Number, - entity_class=TPLinkNumberEntity, - descriptions=NUMBER_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) + known_child_device_ids: set[str] = set() + first_check = True - async_add_entities(entities) + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Number, + entity_class=TPLinkNumberEntity, + descriptions=NUMBER_DESCRIPTIONS_MAP, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_cleanup_deprecated(hass, NUMBER_DOMAIN, config_entry.entry_id, entities) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkNumberEntity(CoordinatedTPLinkFeatureEntity, NumberEntity): diff --git a/homeassistant/components/tplink/select.py b/homeassistant/components/tplink/select.py index c41b4b5f54c..a443546fdaa 100644 --- a/homeassistant/components/tplink/select.py +++ b/homeassistant/components/tplink/select.py @@ -7,11 +7,16 @@ from typing import Final, cast from kasa import Device, Feature -from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SelectEntity, + SelectEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry +from .deprecate import async_cleanup_deprecated from .entity import ( CoordinatedTPLinkFeatureEntity, TPLinkDataUpdateCoordinator, @@ -54,19 +59,27 @@ async def async_setup_entry( """Set up select entities.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device + known_child_device_ids: set[str] = set() + first_check = True - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.Choice, - entity_class=TPLinkSelectEntity, - descriptions=SELECT_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) - async_add_entities(entities) + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Choice, + entity_class=TPLinkSelectEntity, + descriptions=SELECT_DESCRIPTIONS_MAP, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_cleanup_deprecated(hass, SELECT_DOMAIN, config_entry.entry_id, entities) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkSelectEntity(CoordinatedTPLinkFeatureEntity, SelectEntity): diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 59e29d7a010..0898a3379d1 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -129,20 +129,27 @@ async def async_setup_entry( """Set up sensors.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device + known_child_device_ids: set[str] = set() + first_check = True - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.Sensor, - entity_class=TPLinkSensorEntity, - descriptions=SENSOR_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) - async_cleanup_deprecated(hass, SENSOR_DOMAIN, config_entry.entry_id, entities) - async_add_entities(entities) + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Sensor, + entity_class=TPLinkSensorEntity, + descriptions=SENSOR_DESCRIPTIONS_MAP, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_cleanup_deprecated(hass, SENSOR_DOMAIN, config_entry.entry_id, entities) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity): diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 86efa39b7be..92ecd7992de 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -8,11 +8,16 @@ from typing import Any, cast from kasa import Feature -from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry +from .deprecate import async_cleanup_deprecated from .entity import ( CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription, @@ -84,17 +89,26 @@ async def async_setup_entry( data = config_entry.runtime_data parent_coordinator = data.parent_coordinator device = parent_coordinator.device + known_child_device_ids: set[str] = set() + first_check = True - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Switch, - entity_class=TPLinkSwitch, - descriptions=SWITCH_DESCRIPTIONS_MAP, - ) + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Switch, + entity_class=TPLinkSwitch, + descriptions=SWITCH_DESCRIPTIONS_MAP, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_cleanup_deprecated(hass, SWITCH_DOMAIN, config_entry.entry_id, entities) + async_add_entities(entities) - async_add_entities(entities) + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkSwitch(CoordinatedTPLinkFeatureEntity, SwitchEntity): diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 7dbd723aa2d..1fbd79c16c2 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -8,7 +8,16 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from freezegun.api import FrozenDateTimeFactory -from kasa import AuthenticationError, DeviceConfig, Feature, KasaException, Module +from kasa import ( + AuthenticationError, + Device, + DeviceConfig, + DeviceType, + Feature, + KasaException, + Module, +) +from kasa.iot import IotStrip import pytest from homeassistant import setup @@ -827,3 +836,152 @@ async def test_migrate_remove_device_config( assert entry.data == expected_entry_data assert "Migration to version 1.5 complete" in caplog.text + + +@pytest.mark.parametrize( + ("device_type"), + [ + (Device), + (IotStrip), + ], +) +@pytest.mark.parametrize( + ("platform", "feature_id", "translated_name"), + [ + pytest.param("switch", "led", "led", id="switch"), + pytest.param( + "sensor", "current_consumption", "current_consumption", id="sensor" + ), + pytest.param("binary_sensor", "overheated", "overheated", id="binary_sensor"), + pytest.param("number", "smooth_transition_on", "smooth_on", id="number"), + pytest.param("select", "light_preset", "light_preset", id="select"), + pytest.param("button", "reboot", "restart", id="button"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_automatic_device_addition_and_removal( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect: AsyncMock, + mock_discovery: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, + platform: str, + feature_id: str, + translated_name: str, + device_type: type, +) -> None: + """Test for automatic device addition and removal.""" + + children = { + f"child{index}": _mocked_device( + alias=f"child {index}", + features=[feature_id], + device_type=DeviceType.StripSocket, + device_id=f"child{index}", + ) + for index in range(1, 5) + } + + mock_device = _mocked_device( + alias="hub", + children=[children["child1"], children["child2"]], + features=[feature_id], + device_type=DeviceType.Hub, + spec=device_type, + device_id="hub_parent", + ) + + with override_side_effect(mock_connect["connect"], lambda *_, **__: mock_device): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + for child_id in (1, 2): + entity_id = f"{platform}.child_{child_id}_{translated_name}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + parent_device = device_registry.async_get_device( + identifiers={(DOMAIN, "hub_parent")} + ) + assert parent_device + + for device_id in ("child1", "child2"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id + + # Remove one of the devices + mock_device.children = [children["child1"]] + freezer.tick(5) + async_fire_time_changed(hass) + + entity_id = f"{platform}.child_2_{translated_name}" + state = hass.states.get(entity_id) + assert state is None + assert entity_registry.async_get(entity_id) is None + + assert device_registry.async_get_device(identifiers={(DOMAIN, "child2")}) is None + + # Re-dd the previously removed child device + mock_device.children = [ + children["child1"], + children["child2"], + ] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 2): + entity_id = f"{platform}.child_{child_id}_{translated_name}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child2"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id + + # Add child devices + mock_device.children = [children["child1"], children["child3"], children["child4"]] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 3, 4): + entity_id = f"{platform}.child_{child_id}_{translated_name}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child3", "child4"): + assert device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + + # Add the previously removed child device + mock_device.children = [ + children["child1"], + children["child2"], + children["child3"], + children["child4"], + ] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 2, 3, 4): + entity_id = f"{platform}.child_{child_id}_{translated_name}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child2", "child3", "child4"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id From be06ef46c12db8ae4d3abd5945b6ae0d20394cdf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Jan 2025 21:22:05 +0100 Subject: [PATCH 0469/2987] Use new ServiceInfo location in wmspro (#135702) * Use new ServiceInfo location in wmspro * Fix self.source --- homeassistant/components/wmspro/config_flow.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/wmspro/config_flow.py b/homeassistant/components/wmspro/config_flow.py index 4b51bba3990..94deed11c08 100644 --- a/homeassistant/components/wmspro/config_flow.py +++ b/homeassistant/components/wmspro/config_flow.py @@ -10,8 +10,7 @@ import aiohttp import voluptuous as vol from wmspro.webcontrol import WebControlPro -from homeassistant.components import dhcp -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_DHCP, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac @@ -34,7 +33,7 @@ class WebControlProConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle the DHCP discovery step.""" unique_id = format_mac(discovery_info.macaddress) @@ -95,7 +94,7 @@ class WebControlProConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="already_configured") return self.async_create_entry(title=host, data=user_input) - if self.source == dhcp.DOMAIN: + if self.source == SOURCE_DHCP: discovery_info: DhcpServiceInfo = self.init_data data_values = {CONF_HOST: discovery_info.ip} else: From e736ca72f03c6a6ebd21cf56844022c9d9107c71 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Jan 2025 13:33:58 -1000 Subject: [PATCH 0470/2987] Handle invalid HS color values in HomeKit Bridge (#135739) --- .../components/homekit/type_lights.py | 6 +- tests/components/homekit/test_type_lights.py | 267 ++++++++++++++++++ 2 files changed, 272 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index cde80178c5e..eec35fcc82e 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -282,7 +282,11 @@ class Light(HomeAccessory): hue, saturation = color_temperature_to_hs(color_temp) elif color_mode == ColorMode.WHITE: hue, saturation = 0, 0 - elif hue_sat := attributes.get(ATTR_HS_COLOR): + elif ( + (hue_sat := attributes.get(ATTR_HS_COLOR)) + and isinstance(hue_sat, (list, tuple)) + and len(hue_sat) == 2 + ): hue, saturation = hue_sat else: hue = None diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index fb059b93a13..53a661c1c83 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -1,6 +1,7 @@ """Test different accessory types: Lights.""" from datetime import timedelta +import sys from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE import pytest @@ -540,6 +541,272 @@ async def test_light_color_temperature_and_rgb_color( assert acc.char_saturation.value == 100 +async def test_light_invalid_hs_color( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test light that starts out with an invalid hs color.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_HS_COLOR: 260, + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 0 + assert acc.char_saturation.value == 75 + + assert hasattr(acc, "char_color_temp") + + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 4464}) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 224 + assert acc.char_hue.value == 27 + assert acc.char_saturation.value == 27 + + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 2840}) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 352 + assert acc.char_hue.value == 28 + assert acc.char_saturation.value == 61 + + char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] + char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] + char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] + char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] + char_color_temp_iid = acc.char_color_temp.to_HAP()[HAP_REPR_IID] + + # Set from HomeKit + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_iid, + HAP_REPR_VALUE: 20, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 250, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 50, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 50, + }, + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[0] + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 + assert call_turn_on[0].data[ATTR_COLOR_TEMP_KELVIN] == 4000 + + assert len(events) == 1 + assert ( + events[-1].data[ATTR_VALUE] + == f"Set state to 1, brightness at 20{PERCENTAGE}, color temperature at 250" + ) + + # Only set Hue + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 30, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[1] + assert call_turn_on[1].data[ATTR_HS_COLOR] == (30, 50) + + assert events[-1].data[ATTR_VALUE] == "set color at (30, 50)" + + # Only set Saturation + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 20, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[2] + assert call_turn_on[2].data[ATTR_HS_COLOR] == (30, 20) + + assert events[-1].data[ATTR_VALUE] == "set color at (30, 20)" + + # Generate a conflict by setting hue and then color temp + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 80, + } + ] + }, + "mock_addr", + ) + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 320, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[3] + assert call_turn_on[3].data[ATTR_COLOR_TEMP_KELVIN] == 3125 + assert events[-1].data[ATTR_VALUE] == "color temperature at 320" + + # Generate a conflict by setting color temp then saturation + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 404, + } + ] + }, + "mock_addr", + ) + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 35, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[4] + assert call_turn_on[4].data[ATTR_HS_COLOR] == (80, 35) + assert events[-1].data[ATTR_VALUE] == "set color at (80, 35)" + + # Set from HASS + hass.states.async_set(entity_id, STATE_ON, {ATTR_HS_COLOR: (100, 100)}) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 404 + assert acc.char_hue.value == 100 + assert acc.char_saturation.value == 100 + + +async def test_light_invalid_values( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test light with a variety of invalid values.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_HS_COLOR: (-1, -1), + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 0 + assert acc.char_saturation.value == 0 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: -1, + }, + ) + await hass.async_block_till_done() + acc.run() + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 16 + assert acc.char_saturation.value == 100 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: sys.maxsize, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: 2000, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 500 + assert acc.char_hue.value == 31 + assert acc.char_saturation.value == 95 + + @pytest.mark.parametrize( "supported_color_modes", [[ColorMode.HS], [ColorMode.RGB], [ColorMode.XY]] ) From 79ee2e954bd716f78a8a3190f78ade304d3170f0 Mon Sep 17 00:00:00 2001 From: Jamin Date: Wed, 15 Jan 2025 19:59:58 -0600 Subject: [PATCH 0471/2987] Use SIP URI for VoIP device identifier (#135603) * Use SIP URI for VoIP device identifier Use the SIP URI instead of just host/IP address to identify VoIP devices. This will allow calls initiating from Home Assistant to the device as well as allows devices connecting through a PBX to be uniquely identified. * Add tests --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/voip/devices.py | 25 +++++++--- tests/components/voip/test_binary_sensor.py | 14 +++--- tests/components/voip/test_devices.py | 51 ++++++++++++++++++--- tests/components/voip/test_select.py | 4 +- tests/components/voip/test_switch.py | 14 +++--- 5 files changed, 79 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/voip/devices.py b/homeassistant/components/voip/devices.py index 613d05fc614..163cb445340 100644 --- a/homeassistant/components/voip/devices.py +++ b/homeassistant/components/voip/devices.py @@ -136,16 +136,23 @@ class VoIPDevices: fw_version = None dev_reg = dr.async_get(self.hass) - voip_id = call_info.caller_ip + if call_info.caller_endpoint is None: + raise RuntimeError("Could not identify VOIP caller") + voip_id = call_info.caller_endpoint.uri voip_device = self.devices.get(voip_id) - if voip_device is not None: - device = dev_reg.async_get(voip_device.device_id) - if device and fw_version and device.sw_version != fw_version: - dev_reg.async_update_device(device.id, sw_version=fw_version) - - return voip_device + if voip_device is None: + # If we couldn't find the device based on SIP URI, see if we can + # find an old device based on just the host/IP and migrate it + voip_device = self.devices.get(call_info.caller_endpoint.host) + if voip_device is not None: + voip_device.voip_id = voip_id + self.devices[voip_id] = voip_device + dev_reg.async_update_device( + voip_device.device_id, new_identifiers={(DOMAIN, voip_id)} + ) + # Update device with latest info device = dev_reg.async_get_or_create( config_entry_id=self.config_entry.entry_id, identifiers={(DOMAIN, voip_id)}, @@ -155,6 +162,10 @@ class VoIPDevices: sw_version=fw_version, configuration_url=f"http://{call_info.caller_ip}", ) + + if voip_device is not None: + return voip_device + voip_device = self.devices[voip_id] = VoIPDevice( voip_id=voip_id, device_id=device.id, diff --git a/tests/components/voip/test_binary_sensor.py b/tests/components/voip/test_binary_sensor.py index 44ac8e4d77f..55d8ac4473c 100644 --- a/tests/components/voip/test_binary_sensor.py +++ b/tests/components/voip/test_binary_sensor.py @@ -22,18 +22,18 @@ async def test_call_in_progress( voip_device: VoIPDevice, ) -> None: """Test call in progress.""" - state = hass.states.get("binary_sensor.192_168_1_210_call_in_progress") + state = hass.states.get("binary_sensor.sip_192_168_1_210_5060_call_in_progress") assert state is not None assert state.state == "off" voip_device.set_is_active(True) - state = hass.states.get("binary_sensor.192_168_1_210_call_in_progress") + state = hass.states.get("binary_sensor.sip_192_168_1_210_5060_call_in_progress") assert state.state == "on" voip_device.set_is_active(False) - state = hass.states.get("binary_sensor.192_168_1_210_call_in_progress") + state = hass.states.get("binary_sensor.sip_192_168_1_210_5060_call_in_progress") assert state.state == "off" @@ -45,9 +45,9 @@ async def test_assist_in_progress_disabled_by_default( ) -> None: """Test assist in progress binary sensor is added disabled.""" - assert not hass.states.get("binary_sensor.192_168_1_210_call_in_progress") + assert not hass.states.get("binary_sensor.sip_192_168_1_210_5060_call_in_progress") entity_entry = entity_registry.async_get( - "binary_sensor.192_168_1_210_call_in_progress" + "binary_sensor.sip_192_168_1_210_5060_call_in_progress" ) assert entity_entry assert entity_entry.disabled @@ -63,7 +63,7 @@ async def test_assist_in_progress_issue( ) -> None: """Test assist in progress binary sensor.""" - call_in_progress_entity_id = "binary_sensor.192_168_1_210_call_in_progress" + call_in_progress_entity_id = "binary_sensor.sip_192_168_1_210_5060_call_in_progress" state = hass.states.get(call_in_progress_entity_id) assert state is not None @@ -96,7 +96,7 @@ async def test_assist_in_progress_repair_flow( ) -> None: """Test assist in progress binary sensor deprecation issue flow.""" - call_in_progress_entity_id = "binary_sensor.192_168_1_210_call_in_progress" + call_in_progress_entity_id = "binary_sensor.sip_192_168_1_210_5060_call_in_progress" state = hass.states.get(call_in_progress_entity_id) assert state is not None diff --git a/tests/components/voip/test_devices.py b/tests/components/voip/test_devices.py index 55359b8407d..d16ac76d290 100644 --- a/tests/components/voip/test_devices.py +++ b/tests/components/voip/test_devices.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pytest from voip_utils import CallInfo from homeassistant.components.voip import DOMAIN @@ -9,6 +10,8 @@ from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from tests.common import MockConfigEntry + async def test_device_registry_info( hass: HomeAssistant, @@ -21,10 +24,10 @@ async def test_device_registry_info( assert not voip_device.async_allow_call(hass) device = device_registry.async_get_device( - identifiers={(DOMAIN, call_info.caller_ip)} + identifiers={(DOMAIN, call_info.caller_endpoint.uri)} ) assert device is not None - assert device.name == call_info.caller_ip + assert device.name == call_info.caller_endpoint.uri assert device.manufacturer == "Grandstream" assert device.model == "HT801" assert device.sw_version == "1.0.17.5" @@ -36,7 +39,7 @@ async def test_device_registry_info( assert not voip_device.async_allow_call(hass) device = device_registry.async_get_device( - identifiers={(DOMAIN, call_info.caller_ip)} + identifiers={(DOMAIN, call_info.caller_endpoint.uri)} ) assert device.sw_version == "2.0.0.0" @@ -53,7 +56,7 @@ async def test_device_registry_info_from_unknown_phone( assert not voip_device.async_allow_call(hass) device = device_registry.async_get_device( - identifiers={(DOMAIN, call_info.caller_ip)} + identifiers={(DOMAIN, call_info.caller_endpoint.uri)} ) assert device.manufacturer is None assert device.model == "Unknown" @@ -68,11 +71,47 @@ async def test_remove_device_registry_entry( ) -> None: """Test removing a device registry entry.""" assert voip_device.voip_id in voip_devices.devices - assert hass.states.get("switch.192_168_1_210_allow_calls") is not None + assert hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") is not None device_registry.async_remove_device(voip_device.device_id) await hass.async_block_till_done() await hass.async_block_till_done() - assert hass.states.get("switch.192_168_1_210_allow_calls") is None + assert hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") is None assert voip_device.voip_id not in voip_devices.devices + + +@pytest.fixture +async def legacy_dev_reg_entry( + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + call_info: CallInfo, +) -> None: + """Fixture to run before we set up the VoIP integration via fixture.""" + return device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, call_info.caller_ip)}, + ) + + +async def test_device_registry_migation( + hass: HomeAssistant, + legacy_dev_reg_entry: dr.DeviceEntry, + voip_devices: VoIPDevices, + call_info: CallInfo, + device_registry: dr.DeviceRegistry, +) -> None: + """Test info in device registry migrates old devices.""" + voip_device = voip_devices.async_get_or_create(call_info) + assert voip_device.voip_id == call_info.caller_endpoint.uri + + device = device_registry.async_get_device( + identifiers={(DOMAIN, call_info.caller_endpoint.uri)} + ) + assert device is not None + assert device.id == legacy_dev_reg_entry.id + assert device.identifiers == {(DOMAIN, call_info.caller_endpoint.uri)} + assert device.name == call_info.caller_endpoint.uri + assert device.manufacturer == "Grandstream" + assert device.model == "HT801" + assert device.sw_version == "1.0.17.5" diff --git a/tests/components/voip/test_select.py b/tests/components/voip/test_select.py index 78bb8d6c6b4..1b45c739535 100644 --- a/tests/components/voip/test_select.py +++ b/tests/components/voip/test_select.py @@ -15,7 +15,7 @@ async def test_pipeline_select( Functionality is tested in assist_pipeline/test_select.py. This test is only to ensure it is set up. """ - state = hass.states.get("select.192_168_1_210_assistant") + state = hass.states.get("select.sip_192_168_1_210_5060_assistant") assert state is not None assert state.state == "preferred" @@ -30,6 +30,6 @@ async def test_vad_sensitivity_select( Functionality is tested in assist_pipeline/test_select.py. This test is only to ensure it is set up. """ - state = hass.states.get("select.192_168_1_210_finished_speaking_detection") + state = hass.states.get("select.sip_192_168_1_210_5060_finished_speaking_detection") assert state is not None assert state.state == "default" diff --git a/tests/components/voip/test_switch.py b/tests/components/voip/test_switch.py index 8b3cd03f2ac..ac331ed01a7 100644 --- a/tests/components/voip/test_switch.py +++ b/tests/components/voip/test_switch.py @@ -13,41 +13,41 @@ async def test_allow_call( """Test allow call.""" assert not voip_device.async_allow_call(hass) - state = hass.states.get("switch.192_168_1_210_allow_calls") + state = hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") assert state is not None assert state.state == "off" await hass.config_entries.async_reload(config_entry.entry_id) - state = hass.states.get("switch.192_168_1_210_allow_calls") + state = hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") assert state.state == "off" await hass.services.async_call( "switch", "turn_on", - {"entity_id": "switch.192_168_1_210_allow_calls"}, + {"entity_id": "switch.sip_192_168_1_210_5060_allow_calls"}, blocking=True, ) assert voip_device.async_allow_call(hass) - state = hass.states.get("switch.192_168_1_210_allow_calls") + state = hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") assert state.state == "on" await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("switch.192_168_1_210_allow_calls") + state = hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") assert state.state == "on" await hass.services.async_call( "switch", "turn_off", - {"entity_id": "switch.192_168_1_210_allow_calls"}, + {"entity_id": "switch.sip_192_168_1_210_5060_allow_calls"}, blocking=True, ) assert not voip_device.async_allow_call(hass) - state = hass.states.get("switch.192_168_1_210_allow_calls") + state = hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") assert state.state == "off" From e886c9e0546df06308773545e807b5b4e7d33d15 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 16 Jan 2025 14:28:15 +1000 Subject: [PATCH 0472/2987] Slow down polling for Tesla Fleet (#135747) Slow down polling --- homeassistant/components/tesla_fleet/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 42b93352a6f..c122f876d15 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -21,7 +21,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import LOGGER, TeslaFleetState -VEHICLE_INTERVAL_SECONDS = 90 +VEHICLE_INTERVAL_SECONDS = 300 VEHICLE_INTERVAL = timedelta(seconds=VEHICLE_INTERVAL_SECONDS) VEHICLE_WAIT = timedelta(minutes=15) @@ -73,7 +73,7 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.data = flatten(product) self.updated_once = False self.last_active = datetime.now() - self.rate = RateCalculator(200, 86400, VEHICLE_INTERVAL_SECONDS, 3600, 5) + 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.""" From a8645ea4edd08aba570d6fbe7f19402a2e7d274c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 16 Jan 2025 08:24:37 +0100 Subject: [PATCH 0473/2987] Ensure entity platform in bluetooth tests (#135716) --- tests/components/bluetooth/test_passive_update_processor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index d7a7a8ba08c..e9274965e3c 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -1808,6 +1808,7 @@ async def test_naming(hass: HomeAssistant) -> None: sensor_entity: PassiveBluetoothProcessorEntity = sensor_entities[0] sensor_entity.hass = hass + sensor_entity.platform = MockEntityPlatform(hass) assert sensor_entity.available is True assert sensor_entity.name is UNDEFINED assert sensor_entity.device_class is SensorDeviceClass.TEMPERATURE From 77a351f992b4d5ad4e13f3365d1ca64ec330f7a8 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 16 Jan 2025 08:41:59 +0100 Subject: [PATCH 0474/2987] Add receive backup tests (#135680) * Clean up test_receive_backup_busy_manager * Test receive backup agent error * Test file write error during backup receive * Test read tar error during backup receive * Test non agent upload error during backup receive * Test file read error during backup receive --- tests/components/backup/test_manager.py | 774 +++++++++++++++++++++++- 1 file changed, 755 insertions(+), 19 deletions(-) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 144646301cd..70b95b1bb7f 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -8,8 +8,18 @@ from dataclasses import replace from io import StringIO import json from pathlib import Path +import tarfile from typing import Any -from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, mock_open, patch +from unittest.mock import ( + ANY, + DEFAULT, + AsyncMock, + MagicMock, + Mock, + call, + mock_open, + patch, +) import pytest @@ -33,6 +43,8 @@ from homeassistant.components.backup.manager import ( CreateBackupStage, CreateBackupState, NewBackup, + ReceiveBackupStage, + ReceiveBackupState, WrittenBackup, ) from homeassistant.core import HomeAssistant @@ -1429,8 +1441,12 @@ async def test_receive_backup_busy_manager( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, + create_backup: AsyncMock, ) -> None: """Test receive backup with a 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() client = await hass_client() @@ -1445,24 +1461,18 @@ async def test_receive_backup_busy_manager( result = await ws_client.receive_json() assert result["success"] is True - new_backup = NewBackup(backup_job_id="time-123") - backup_task: asyncio.Future[WrittenBackup] = asyncio.Future() - with patch( - "homeassistant.components.backup.manager.CoreBackupReaderWriter.async_create_backup", - return_value=(new_backup, backup_task), - ) as create_backup: - await ws_client.send_json_auto_id( - {"type": "backup/generate", "agent_ids": ["backup.local"]} - ) - result = await ws_client.receive_json() - assert result["event"] == { - "manager_state": "create_backup", - "stage": None, - "state": "in_progress", - } - result = await ws_client.receive_json() - assert result["success"] is True - assert result["result"] == {"backup_job_id": "time-123"} + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["backup.local"]} + ) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == {"backup_job_id": "time-123"} assert create_backup.call_count == 1 @@ -1488,6 +1498,732 @@ async def test_receive_backup_busy_manager( await hass.async_block_till_done() +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize("exception", [BackupAgentError("Boom!"), Exception("Boom!")]) +async def test_receive_backup_agent_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + hass_storage: dict[str, Any], + 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 + backups_info: list[dict[str, Any]] = [ + { + "addons": [ + { + "name": "Test", + "slug": "test", + "version": "1.0.0", + }, + ], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup1", + "database_included": True, + "date": "1970-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 0, + "with_automatic_settings": True, + }, + { + "addons": [], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup2", + "database_included": False, + "date": "1980-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test 2", + "protected": False, + "size": 1, + "with_automatic_settings": None, + }, + { + "addons": [ + { + "name": "Test", + "slug": "test", + "version": "1.0.0", + }, + ], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup3", + "database_included": True, + "date": "1970-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 0, + "with_automatic_settings": True, + }, + ] + remote_agent = BackupAgentTest("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, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": backups_info, + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + } + + await ws_client.send_json_auto_id( + {"type": "backup/config/update", "retention": {"copies": 1, "days": None}} + ) + result = await ws_client.receive_json() + assert result["success"] + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + 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")) + + 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( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + patch("pathlib.Path.unlink") as unlink_mock, + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.COMPLETED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": backups_info, + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + } + + await hass.async_block_till_done() + assert hass_storage[DOMAIN]["data"]["backups"] == [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ] + + assert resp.status == 201 + assert open_mock.call_count == 1 + assert move_mock.call_count == 0 + assert unlink_mock.call_count == 1 + assert delete_backup.call_count == 0 + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize("exception", [asyncio.CancelledError("Boom!")]) +async def test_receive_backup_non_agent_upload_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + hass_storage: dict[str, Any], + exception: Exception, +) -> None: + """Test non agent upload error during backup receive.""" + hass_storage[DOMAIN] = { + "data": {}, + "key": DOMAIN, + "version": 1, + } + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + + 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, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + with ( + patch.object(remote_agent, "async_upload_backup", side_effect=exception), + patch("pathlib.Path.open", open_mock), + patch("shutil.move") as move_mock, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + patch("pathlib.Path.unlink") as unlink_mock, + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert not hass_storage[DOMAIN]["data"] + assert resp.status == 500 + assert open_mock.call_count == 1 + assert move_mock.call_count == 0 + assert unlink_mock.call_count == 0 + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "open_call_count", + "open_exception", + "write_call_count", + "write_exception", + "close_call_count", + "close_exception", + ), + [ + (1, OSError("Boom!"), 0, None, 0, None), + (1, None, 1, OSError("Boom!"), 1, None), + (1, None, 1, None, 1, OSError("Boom!")), + ], +) +async def test_receive_backup_file_write_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + open_call_count: int, + open_exception: Exception | None, + write_call_count: int, + write_exception: Exception | None, + close_call_count: int, + close_exception: Exception | None, +) -> None: + """Test file write error during backup receive.""" + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + 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, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + open_mock.side_effect = open_exception + open_mock.return_value.write.side_effect = write_exception + open_mock.return_value.close.side_effect = close_exception + + with ( + patch("pathlib.Path.open", open_mock), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert resp.status == 500 + assert open_mock.call_count == open_call_count + assert open_mock.return_value.write.call_count == write_call_count + assert open_mock.return_value.close.call_count == close_call_count + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + "exception", + [ + OSError("Boom!"), + tarfile.TarError("Boom!"), + json.JSONDecodeError("Boom!", "test", 1), + KeyError("Boom!"), + ], +) +async def test_receive_backup_read_tar_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + exception: Exception, +) -> None: + """Test read tar error during backup receive.""" + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + 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, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + with ( + patch("pathlib.Path.open", open_mock), + patch( + "homeassistant.components.backup.manager.read_backup", + side_effect=exception, + ) as read_backup, + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert resp.status == 500 + assert read_backup.call_count == 1 + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "open_call_count", + "open_exception", + "read_call_count", + "read_exception", + "close_call_count", + "close_exception", + "unlink_call_count", + "unlink_exception", + "final_state", + "response_status", + ), + [ + ( + 2, + [DEFAULT, OSError("Boom!")], + 0, + None, + 1, + [DEFAULT, DEFAULT], + 1, + None, + ReceiveBackupState.COMPLETED, + 201, + ), + ( + 2, + [DEFAULT, DEFAULT], + 1, + OSError("Boom!"), + 2, + [DEFAULT, DEFAULT], + 1, + None, + ReceiveBackupState.COMPLETED, + 201, + ), + ( + 2, + [DEFAULT, DEFAULT], + 1, + None, + 2, + [DEFAULT, OSError("Boom!")], + 1, + None, + ReceiveBackupState.COMPLETED, + 201, + ), + ( + 2, + [DEFAULT, DEFAULT], + 1, + None, + 2, + [DEFAULT, DEFAULT], + 1, + OSError("Boom!"), + ReceiveBackupState.FAILED, + 500, + ), + ], +) +async def test_receive_backup_file_read_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + open_call_count: int, + open_exception: list[Exception | None], + read_call_count: int, + read_exception: Exception | None, + close_call_count: int, + close_exception: list[Exception | None], + unlink_call_count: int, + unlink_exception: Exception | None, + final_state: ReceiveBackupState, + response_status: int, +) -> None: + """Test file read error during backup receive.""" + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + 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, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + open_mock.side_effect = open_exception + open_mock.return_value.read.side_effect = read_exception + open_mock.return_value.close.side_effect = close_exception + + with ( + patch("pathlib.Path.open", open_mock), + patch("pathlib.Path.unlink", side_effect=unlink_exception) as unlink_mock, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": final_state, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert resp.status == response_status + assert open_mock.call_count == open_call_count + assert open_mock.return_value.read.call_count == read_call_count + assert open_mock.return_value.close.call_count == close_call_count + assert unlink_mock.call_count == unlink_call_count + + @pytest.mark.parametrize( ("agent_id", "password", "restore_database", "restore_homeassistant", "dir"), [ From 137666982d9d16250b9470444318504ff623b971 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 16 Jan 2025 09:18:23 +0100 Subject: [PATCH 0475/2987] Reword action descriptions to match Home Assistant style (#135733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Reword action descriptions to match Home Assistant style This commit changes the two action descriptions of the Husqvarna Automower integration to use the descriptive language that is standard in Home Assistant. This helps in fixing or preventing wrong (machine) translations. This is done using the wording from the online documentation by using "Lets the mower … ", moving the actual result more into focus. * Re-add "either" to first description --- homeassistant/components/husqvarna_automower/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index d4c91e29f7d..9bd0bb06b3e 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -322,7 +322,7 @@ "services": { "override_schedule": { "name": "Override schedule", - "description": "Override the schedule to either mow or park for a duration of time.", + "description": "Lets the mower either mow or park for a given duration, overriding all schedules.", "fields": { "duration": { "name": "Duration", @@ -336,7 +336,7 @@ }, "override_schedule_work_area": { "name": "Override schedule work area", - "description": "Override the schedule of the mower for a duration of time in the selected work area.", + "description": "Lets the mower mow for a given duration in a specified work area, overriding all schedules.", "fields": { "duration": { "name": "[%key:component::husqvarna_automower::services::override_schedule::fields::duration::name%]", From b5a7d0258a29011c846c5575bb4f5f9274e11568 Mon Sep 17 00:00:00 2001 From: dotvav Date: Thu, 16 Jan 2025 09:19:37 +0100 Subject: [PATCH 0476/2987] Palazzetti integration: Update integration quality scale (#135752) Update integration quality scale --- homeassistant/components/palazzetti/quality_scale.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/palazzetti/quality_scale.yaml b/homeassistant/components/palazzetti/quality_scale.yaml index 5d57313bfde..ff8461ad193 100644 --- a/homeassistant/components/palazzetti/quality_scale.yaml +++ b/homeassistant/components/palazzetti/quality_scale.yaml @@ -16,7 +16,7 @@ rules: This integration does not register actions. docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: exempt comment: | @@ -53,7 +53,7 @@ rules: docs-data-update: todo docs-examples: done docs-known-limitations: done - docs-supported-devices: todo + docs-supported-devices: done docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done From c89d60fb5d8302db16e7df8c15d51d8827ae492f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 16 Jan 2025 09:21:49 +0100 Subject: [PATCH 0477/2987] Ensure entity platform in light tests (#135724) --- tests/components/light/test_init.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 776995ee523..c947de5923b 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -2654,7 +2654,7 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> (light.ColorMode.ONOFF, {light.ColorMode.ONOFF}, False), ], ) -def test_report_no_color_mode( +async def test_report_no_color_mode( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, color_mode: str, @@ -2670,6 +2670,8 @@ def test_report_no_color_mode( _attr_supported_color_modes = supported_color_modes entity = MockLightEntityEntity() + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([entity]) entity._async_calculate_state() expected_warning = "does not report a color mode" assert (expected_warning in caplog.text) is warning_expected @@ -2682,7 +2684,7 @@ def test_report_no_color_mode( (light.ColorMode.ONOFF, {light.ColorMode.ONOFF}, False), ], ) -def test_report_no_color_modes( +async def test_report_no_color_modes( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, color_mode: str, @@ -2698,6 +2700,8 @@ def test_report_no_color_modes( _attr_supported_color_modes = supported_color_modes entity = MockLightEntityEntity() + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([entity]) entity._async_calculate_state() expected_warning = "does not set supported color modes" assert (expected_warning in caplog.text) is warning_expected @@ -2728,7 +2732,7 @@ def test_report_no_color_modes( (light.ColorMode.HS, {light.ColorMode.BRIGHTNESS}, "effect", True), ], ) -def test_report_invalid_color_mode( +async def test_report_invalid_color_mode( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, color_mode: str, @@ -2746,6 +2750,8 @@ def test_report_invalid_color_mode( _attr_supported_color_modes = supported_color_modes entity = MockLightEntityEntity() + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([entity]) entity._async_calculate_state() expected_warning = f"set to unsupported color mode {color_mode}" assert (expected_warning in caplog.text) is warning_expected From 016a27469882c1113d7cd396eaea97ab8d8e939e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Jan 2025 22:48:29 -1000 Subject: [PATCH 0478/2987] Bump govee-ble to 0.41.0 (#135750) Adds support for the H5130 pressure/presence sensor changelog: https://github.com/Bluetooth-Devices/govee-ble/compare/v0.40.0...v0.41.0 --- homeassistant/components/govee_ble/binary_sensor.py | 4 ++++ homeassistant/components/govee_ble/manifest.json | 6 +++++- homeassistant/generated/bluetooth.py | 5 +++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/govee_ble/binary_sensor.py b/homeassistant/components/govee_ble/binary_sensor.py index bd92093c29c..7b7a1fb5a50 100644 --- a/homeassistant/components/govee_ble/binary_sensor.py +++ b/homeassistant/components/govee_ble/binary_sensor.py @@ -39,6 +39,10 @@ BINARY_SENSOR_DESCRIPTIONS = { key=GoveeBLEBinarySensorDeviceClass.OCCUPANCY, device_class=BinarySensorDeviceClass.OCCUPANCY, ), + GoveeBLEBinarySensorDeviceClass.PRESENCE: BinarySensorEntityDescription( + key=GoveeBLEBinarySensorDeviceClass.PRESENCE, + device_class=BinarySensorDeviceClass.PRESENCE, + ), } diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index 39a66ad36a7..484822efda6 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -42,6 +42,10 @@ "local_name": "GVH5127*", "connectable": false }, + { + "local_name": "GVH5130*", + "connectable": false + }, { "manufacturer_id": 1, "service_uuid": "0000ec88-0000-1000-8000-00805f9b34fb", @@ -127,5 +131,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.40.0"] + "requirements": ["govee-ble==0.41.0"] } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index e592d14405b..b4e6660275c 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -192,6 +192,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "govee_ble", "local_name": "GVH5127*", }, + { + "connectable": False, + "domain": "govee_ble", + "local_name": "GVH5130*", + }, { "connectable": False, "domain": "govee_ble", diff --git a/requirements_all.txt b/requirements_all.txt index bfccea81e04..78525134731 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1046,7 +1046,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.40.0 +govee-ble==0.41.0 # homeassistant.components.govee_light_local govee-local-api==1.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 034b4d3f6cc..b2413d5ed95 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -896,7 +896,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.40.0 +govee-ble==0.41.0 # homeassistant.components.govee_light_local govee-local-api==1.5.3 From edddd6edfbc418a39c7f671e1c7b9963e9430301 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 16 Jan 2025 04:08:38 -0500 Subject: [PATCH 0479/2987] Reduce USB rescan cooldown from 1 minute to 10 seconds (#135712) * Reduce USB rescan cooldown from 1 minute to 1 second * Increase cooldown to 10s as a middle ground --- homeassistant/components/usb/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index c502c81dae6..7eaac7d71e8 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -46,7 +46,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -REQUEST_SCAN_COOLDOWN = 60 # 1 minute cooldown +REQUEST_SCAN_COOLDOWN = 10 # 10 second cooldown __all__ = [ "async_is_plugged_in", From f3b73173732348b9468faf28fd657eb35f841ca9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Jan 2025 10:55:14 +0100 Subject: [PATCH 0480/2987] Use new ServiceInfo location in homeassistant_sky_connect (#135693) --- .../components/homeassistant_sky_connect/config_flow.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 2fbf8bcb6bc..ffd6c6bd004 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -19,6 +19,7 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import callback +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import DOCS_WEB_FLASHER_URL, DOMAIN, HardwareVariant from .util import get_hardware_variant, get_usb_service_info @@ -69,7 +70,7 @@ class HomeAssistantSkyConnectConfigFlow( """Initialize the config flow.""" super().__init__(*args, **kwargs) - self._usb_info: usb.UsbServiceInfo | None = None + self._usb_info: UsbServiceInfo | None = None self._hw_variant: HardwareVariant | None = None @staticmethod @@ -85,9 +86,7 @@ class HomeAssistantSkyConnectConfigFlow( return HomeAssistantSkyConnectOptionsFlowHandler(config_entry) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle usb discovery.""" device = discovery_info.device vid = discovery_info.vid From 1172887c80ce4e12df811cc39386bca5897fb159 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Jan 2025 10:55:48 +0100 Subject: [PATCH 0481/2987] Use new ServiceInfo location in zwave_js (#135704) --- homeassistant/components/zwave_js/config_flow.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 71fe4472f23..44adf6a12ab 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -38,6 +38,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType @@ -405,9 +406,7 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): }, ) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" if not is_hassio(self.hass): return self.async_abort(reason="discovery_requires_supervisor") From 6fdccda2256f92c824a98712ef102b4a77140126 Mon Sep 17 00:00:00 2001 From: Tyron Date: Thu, 16 Jan 2025 06:27:19 -0500 Subject: [PATCH 0482/2987] Return Chat IDs on Telegram Bot (#131274) Co-authored-by: Joost Lekkerkerker --- .../components/telegram_bot/__init__.py | 100 +++++++++++--- tests/components/telegram_bot/conftest.py | 8 ++ .../telegram_bot/test_telegram_bot.py | 130 +++++++++++++++++- 3 files changed, 211 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index b9a032d7f28..4fdb87f9fa6 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -36,7 +36,13 @@ from homeassistant.const import ( HTTP_BEARER_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) -from homeassistant.core import Context, HomeAssistant, ServiceCall +from homeassistant.core import ( + Context, + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_loaded_integration @@ -398,15 +404,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, bot, p_config.get(CONF_ALLOWED_CHAT_IDS), p_config.get(ATTR_PARSER) ) - async def async_send_telegram_message(service: ServiceCall) -> None: + async def async_send_telegram_message(service: ServiceCall) -> ServiceResponse: """Handle sending Telegram Bot message service calls.""" msgtype = service.service kwargs = dict(service.data) _LOGGER.debug("New telegram message %s: %s", msgtype, kwargs) + messages = None if msgtype == SERVICE_SEND_MESSAGE: - await notify_service.send_message(context=service.context, **kwargs) + messages = await notify_service.send_message( + context=service.context, **kwargs + ) elif msgtype in [ SERVICE_SEND_PHOTO, SERVICE_SEND_ANIMATION, @@ -414,13 +423,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: SERVICE_SEND_VOICE, SERVICE_SEND_DOCUMENT, ]: - await notify_service.send_file(msgtype, context=service.context, **kwargs) + messages = await notify_service.send_file( + msgtype, context=service.context, **kwargs + ) elif msgtype == SERVICE_SEND_STICKER: - await notify_service.send_sticker(context=service.context, **kwargs) + messages = await notify_service.send_sticker( + context=service.context, **kwargs + ) elif msgtype == SERVICE_SEND_LOCATION: - await notify_service.send_location(context=service.context, **kwargs) + messages = await notify_service.send_location( + context=service.context, **kwargs + ) elif msgtype == SERVICE_SEND_POLL: - await notify_service.send_poll(context=service.context, **kwargs) + messages = await notify_service.send_poll(context=service.context, **kwargs) elif msgtype == SERVICE_ANSWER_CALLBACK_QUERY: await notify_service.answer_callback_query( context=service.context, **kwargs @@ -432,10 +447,37 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: msgtype, context=service.context, **kwargs ) + if service.return_response and messages: + return { + "chats": [ + {"chat_id": cid, "message_id": mid} for cid, mid in messages.items() + ] + } + return None + # Register notification services for service_notif, schema in SERVICE_MAP.items(): + supports_response = SupportsResponse.NONE + + if service_notif in [ + SERVICE_SEND_MESSAGE, + SERVICE_SEND_PHOTO, + SERVICE_SEND_ANIMATION, + SERVICE_SEND_VIDEO, + SERVICE_SEND_VOICE, + SERVICE_SEND_DOCUMENT, + SERVICE_SEND_STICKER, + SERVICE_SEND_LOCATION, + SERVICE_SEND_POLL, + ]: + supports_response = SupportsResponse.OPTIONAL + hass.services.async_register( - DOMAIN, service_notif, async_send_telegram_message, schema=schema + DOMAIN, + service_notif, + async_send_telegram_message, + schema=schema, + supports_response=supports_response, ) return True @@ -694,9 +736,10 @@ class TelegramNotificationService: title = kwargs.get(ATTR_TITLE) text = f"{title}\n{message}" if title else message params = self._get_msg_kwargs(kwargs) + msg_ids = {} for chat_id in self._get_target_chat_ids(target): _LOGGER.debug("Send message in chat ID %s with params: %s", chat_id, params) - await self._send_msg( + msg = await self._send_msg( self.bot.send_message, "Error sending message", params[ATTR_MESSAGE_TAG], @@ -711,6 +754,8 @@ class TelegramNotificationService: message_thread_id=params[ATTR_MESSAGE_THREAD_ID], context=context, ) + msg_ids[chat_id] = msg.id + return msg_ids async def delete_message(self, chat_id=None, context=None, **kwargs): """Delete a previously sent message.""" @@ -829,12 +874,13 @@ class TelegramNotificationService: ), ) + msg_ids = {} if file_content: for chat_id in self._get_target_chat_ids(target): _LOGGER.debug("Sending file to chat ID %s", chat_id) if file_type == SERVICE_SEND_PHOTO: - await self._send_msg( + msg = await self._send_msg( self.bot.send_photo, "Error sending photo", params[ATTR_MESSAGE_TAG], @@ -851,7 +897,7 @@ class TelegramNotificationService: ) elif file_type == SERVICE_SEND_STICKER: - await self._send_msg( + msg = await self._send_msg( self.bot.send_sticker, "Error sending sticker", params[ATTR_MESSAGE_TAG], @@ -866,7 +912,7 @@ class TelegramNotificationService: ) elif file_type == SERVICE_SEND_VIDEO: - await self._send_msg( + msg = await self._send_msg( self.bot.send_video, "Error sending video", params[ATTR_MESSAGE_TAG], @@ -882,7 +928,7 @@ class TelegramNotificationService: context=context, ) elif file_type == SERVICE_SEND_DOCUMENT: - await self._send_msg( + msg = await self._send_msg( self.bot.send_document, "Error sending document", params[ATTR_MESSAGE_TAG], @@ -898,7 +944,7 @@ class TelegramNotificationService: context=context, ) elif file_type == SERVICE_SEND_VOICE: - await self._send_msg( + msg = await self._send_msg( self.bot.send_voice, "Error sending voice", params[ATTR_MESSAGE_TAG], @@ -913,7 +959,7 @@ class TelegramNotificationService: context=context, ) elif file_type == SERVICE_SEND_ANIMATION: - await self._send_msg( + msg = await self._send_msg( self.bot.send_animation, "Error sending animation", params[ATTR_MESSAGE_TAG], @@ -929,17 +975,22 @@ class TelegramNotificationService: context=context, ) + msg_ids[chat_id] = msg.id file_content.seek(0) else: _LOGGER.error("Can't send file with kwargs: %s", kwargs) - async def send_sticker(self, target=None, context=None, **kwargs): + return msg_ids + + async def send_sticker(self, target=None, context=None, **kwargs) -> dict: """Send a sticker from a telegram sticker pack.""" params = self._get_msg_kwargs(kwargs) stickerid = kwargs.get(ATTR_STICKER_ID) + + msg_ids = {} if stickerid: for chat_id in self._get_target_chat_ids(target): - await self._send_msg( + msg = await self._send_msg( self.bot.send_sticker, "Error sending sticker", params[ATTR_MESSAGE_TAG], @@ -952,8 +1003,9 @@ class TelegramNotificationService: message_thread_id=params[ATTR_MESSAGE_THREAD_ID], context=context, ) - else: - await self.send_file(SERVICE_SEND_STICKER, target, **kwargs) + msg_ids[chat_id] = msg.id + return msg_ids + return await self.send_file(SERVICE_SEND_STICKER, target, **kwargs) async def send_location( self, latitude, longitude, target=None, context=None, **kwargs @@ -962,11 +1014,12 @@ class TelegramNotificationService: latitude = float(latitude) longitude = float(longitude) params = self._get_msg_kwargs(kwargs) + msg_ids = {} for chat_id in self._get_target_chat_ids(target): _LOGGER.debug( "Send location %s/%s to chat ID %s", latitude, longitude, chat_id ) - await self._send_msg( + msg = await self._send_msg( self.bot.send_location, "Error sending location", params[ATTR_MESSAGE_TAG], @@ -979,6 +1032,8 @@ class TelegramNotificationService: message_thread_id=params[ATTR_MESSAGE_THREAD_ID], context=context, ) + msg_ids[chat_id] = msg.id + return msg_ids async def send_poll( self, @@ -993,9 +1048,10 @@ class TelegramNotificationService: """Send a poll.""" params = self._get_msg_kwargs(kwargs) openperiod = kwargs.get(ATTR_OPEN_PERIOD) + msg_ids = {} for chat_id in self._get_target_chat_ids(target): _LOGGER.debug("Send poll '%s' to chat ID %s", question, chat_id) - await self._send_msg( + msg = await self._send_msg( self.bot.send_poll, "Error sending poll", params[ATTR_MESSAGE_TAG], @@ -1011,6 +1067,8 @@ class TelegramNotificationService: message_thread_id=params[ATTR_MESSAGE_THREAD_ID], context=context, ) + msg_ids[chat_id] = msg.id + return msg_ids async def leave_chat(self, chat_id=None, context=None): """Remove bot from chat.""" diff --git a/tests/components/telegram_bot/conftest.py b/tests/components/telegram_bot/conftest.py index 93137c3815e..f15db7eba2b 100644 --- a/tests/components/telegram_bot/conftest.py +++ b/tests/components/telegram_bot/conftest.py @@ -105,6 +105,14 @@ def mock_external_calls() -> Generator[None]: patch.object(BotMock, "get_me", return_value=test_user), patch.object(BotMock, "bot", test_user), patch.object(BotMock, "send_message", return_value=message), + patch.object(BotMock, "send_photo", return_value=message), + patch.object(BotMock, "send_sticker", return_value=message), + patch.object(BotMock, "send_video", return_value=message), + patch.object(BotMock, "send_document", return_value=message), + patch.object(BotMock, "send_voice", return_value=message), + patch.object(BotMock, "send_animation", return_value=message), + patch.object(BotMock, "send_location", return_value=message), + patch.object(BotMock, "send_poll", return_value=message), patch("telegram.ext.Updater._bootstrap"), ): yield diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index bdf6ba72fcc..be6b5b31325 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -1,17 +1,33 @@ """Tests for the telegram_bot component.""" +import base64 +import io from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, mock_open, patch import pytest from telegram import Update from telegram.error import NetworkError, RetryAfter, TelegramError, TimedOut from homeassistant.components.telegram_bot import ( + ATTR_FILE, + ATTR_LATITUDE, + ATTR_LONGITUDE, ATTR_MESSAGE, ATTR_MESSAGE_THREAD_ID, + ATTR_OPTIONS, + ATTR_QUESTION, + ATTR_STICKER_ID, DOMAIN, + SERVICE_SEND_ANIMATION, + SERVICE_SEND_DOCUMENT, + SERVICE_SEND_LOCATION, SERVICE_SEND_MESSAGE, + SERVICE_SEND_PHOTO, + SERVICE_SEND_POLL, + SERVICE_SEND_STICKER, + SERVICE_SEND_VIDEO, + SERVICE_SEND_VOICE, ) from homeassistant.components.telegram_bot.webhooks import TELEGRAM_WEBHOOK_URL from homeassistant.const import EVENT_HOMEASSISTANT_START @@ -32,23 +48,125 @@ async def test_polling_platform_init(hass: HomeAssistant, polling_platform) -> N assert hass.services.has_service(DOMAIN, SERVICE_SEND_MESSAGE) is True -async def test_send_message(hass: HomeAssistant, webhook_platform) -> None: - """Test the send_message service.""" +@pytest.mark.parametrize( + ("service", "input"), + [ + ( + SERVICE_SEND_MESSAGE, + {ATTR_MESSAGE: "test_message", ATTR_MESSAGE_THREAD_ID: "123"}, + ), + ( + SERVICE_SEND_STICKER, + { + ATTR_STICKER_ID: "1", + ATTR_MESSAGE_THREAD_ID: "123", + }, + ), + ( + SERVICE_SEND_POLL, + { + ATTR_QUESTION: "Question", + ATTR_OPTIONS: ["Yes", "No"], + }, + ), + ( + SERVICE_SEND_LOCATION, + { + ATTR_MESSAGE: "test_message", + ATTR_MESSAGE_THREAD_ID: "123", + ATTR_LONGITUDE: "1.123", + ATTR_LATITUDE: "1.123", + }, + ), + ], +) +async def test_send_message( + hass: HomeAssistant, webhook_platform, service: str, input: dict[str] +) -> None: + """Test the send_message service. Tests any service that does not require files to be sent.""" context = Context() events = async_capture_events(hass, "telegram_sent") - await hass.services.async_call( + response = await hass.services.async_call( DOMAIN, - SERVICE_SEND_MESSAGE, - {ATTR_MESSAGE: "test_message", ATTR_MESSAGE_THREAD_ID: "123"}, + service, + input, blocking=True, context=context, + return_response=True, ) await hass.async_block_till_done() assert len(events) == 1 assert events[0].context == context + assert len(response["chats"]) == 1 + assert (response["chats"][0]["message_id"]) == 12345 + + +@patch( + "builtins.open", + mock_open( + read_data=base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=" + ) + ), + create=True, +) +def _read_file_as_bytesio_mock(file_path): + """Convert file to BytesIO for testing.""" + _file = None + + with open(file_path, encoding="utf8") as file_handler: + _file = io.BytesIO(file_handler.read()) + + _file.name = "dummy" + _file.seek(0) + + return _file + + +@pytest.mark.parametrize( + "service", + [ + SERVICE_SEND_PHOTO, + SERVICE_SEND_ANIMATION, + SERVICE_SEND_VIDEO, + SERVICE_SEND_VOICE, + SERVICE_SEND_DOCUMENT, + ], +) +async def test_send_file(hass: HomeAssistant, webhook_platform, service: str) -> None: + """Test the send_file service (photo, animation, video, document...).""" + context = Context() + events = async_capture_events(hass, "telegram_sent") + + hass.config.allowlist_external_dirs.add("/media/") + + # Mock the file handler read with our base64 encoded dummy file + with patch( + "homeassistant.components.telegram_bot._read_file_as_bytesio", + _read_file_as_bytesio_mock, + ): + response = await hass.services.async_call( + DOMAIN, + service, + { + ATTR_FILE: "/media/dummy", + ATTR_MESSAGE_THREAD_ID: "123", + }, + blocking=True, + context=context, + return_response=True, + ) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].context == context + + assert len(response["chats"]) == 1 + assert (response["chats"][0]["message_id"]) == 12345 + async def test_send_message_thread(hass: HomeAssistant, webhook_platform) -> None: """Test the send_message service for threads.""" From 9db6be11f7d97141fcb18174e2e082e453584098 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Jan 2025 12:36:12 +0100 Subject: [PATCH 0483/2987] Support decrypting backups when downloading (#135728) * Support decrypting backups when downloading * Close stream * Use test helper * Wait for worker to finish * Simplify * Update backup.json * Simplify * Revert change from the future --- homeassistant/components/backup/http.py | 76 ++++++++++- homeassistant/components/backup/manager.py | 4 +- homeassistant/components/backup/util.py | 110 ++++++++++++++- tests/components/backup/conftest.py | 18 +++ tests/components/backup/test_http.py | 147 ++++++++++++++++++++- tests/components/backup/test_websocket.py | 17 +-- 6 files changed, 341 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index 73a8c8eb602..b909b2728a7 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -4,18 +4,23 @@ from __future__ import annotations import asyncio from http import HTTPStatus -from typing import cast +import threading +from typing import IO, cast from aiohttp import BodyPartReader from aiohttp.hdrs import CONTENT_DISPOSITION from aiohttp.web import FileResponse, Request, Response, StreamResponse +from multidict import istr from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.util import slugify +from . import util +from .agent import BackupAgent from .const import DATA_MANAGER +from .manager import BackupManager @callback @@ -43,8 +48,13 @@ class DownloadBackupView(HomeAssistantView): agent_id = request.query.getone("agent_id") except KeyError: return Response(status=HTTPStatus.BAD_REQUEST) + try: + password = request.query.getone("password") + except KeyError: + password = None - manager = request.app[KEY_HASS].data[DATA_MANAGER] + hass = request.app[KEY_HASS] + manager = hass.data[DATA_MANAGER] if agent_id not in manager.backup_agents: return Response(status=HTTPStatus.BAD_REQUEST) agent = manager.backup_agents[agent_id] @@ -58,6 +68,24 @@ class DownloadBackupView(HomeAssistantView): headers = { CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" } + + if not password: + return await self._send_backup_no_password( + request, headers, backup_id, agent_id, agent, manager + ) + return await self._send_backup_with_password( + hass, request, headers, backup_id, agent_id, password, agent, manager + ) + + async def _send_backup_no_password( + self, + request: Request, + headers: dict[istr, str], + backup_id: str, + agent_id: str, + agent: BackupAgent, + manager: BackupManager, + ) -> StreamResponse | FileResponse | Response: if agent_id in manager.local_backup_agents: local_agent = manager.local_backup_agents[agent_id] path = local_agent.get_backup_path(backup_id) @@ -70,6 +98,50 @@ class DownloadBackupView(HomeAssistantView): await response.write(chunk) return response + async def _send_backup_with_password( + self, + hass: HomeAssistant, + request: Request, + headers: dict[istr, str], + backup_id: str, + agent_id: str, + password: str, + agent: BackupAgent, + manager: BackupManager, + ) -> StreamResponse | FileResponse | Response: + reader: IO[bytes] + if agent_id in manager.local_backup_agents: + local_agent = manager.local_backup_agents[agent_id] + path = local_agent.get_backup_path(backup_id) + try: + reader = await hass.async_add_executor_job(open, path.as_posix(), "rb") + except FileNotFoundError: + return Response(status=HTTPStatus.NOT_FOUND) + else: + stream = await agent.async_download_backup(backup_id) + reader = cast(IO[bytes], util.AsyncIteratorReader(hass, stream)) + + worker_done_event = asyncio.Event() + + def on_done() -> None: + """Call by the worker thread when it's done.""" + hass.loop.call_soon_threadsafe(worker_done_event.set) + + stream = util.AsyncIteratorWriter(hass) + worker = threading.Thread( + target=util.decrypt_backup, args=[reader, stream, password, on_done] + ) + try: + worker.start() + response = StreamResponse(status=HTTPStatus.OK, headers=headers) + await response.prepare(request) + async for chunk in stream: + await response.write(chunk) + return response + finally: + reader.close() + await worker_done_event.wait() + class UploadBackupView(HomeAssistantView): """Generate backup view.""" diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 73bbfafdcf8..58600d0a4c0 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1033,10 +1033,12 @@ class BackupManager: validate_password_stream(reader, password) except backup_util.IncorrectPassword as err: raise IncorrectPasswordError from err - except backup_util.UnsuppertedSecureTarVersion as err: + except backup_util.UnsupportedSecureTarVersion as err: raise DecryptOnDowloadNotSupported from err except backup_util.DecryptError as err: raise BackupManagerError(str(err)) from err + finally: + reader.close() class KnownBackups: diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index ae0244591d8..55f3c3c05c7 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -3,14 +3,23 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncIterator -from pathlib import Path +from collections.abc import AsyncIterator, Callable +import copy +from io import BytesIO +import json +from pathlib import Path, PurePath from queue import SimpleQueue import tarfile -from typing import IO, cast +from typing import IO, Self, cast import aiohttp -from securetar import VERSION_HEADER, SecureTarFile, SecureTarReadError +from securetar import ( + PLAINTEXT_SIZE_HEADER, + VERSION_HEADER, + SecureTarError, + SecureTarFile, + SecureTarReadError, +) from homeassistant.backup_restore import password_to_key from homeassistant.core import HomeAssistant @@ -24,7 +33,7 @@ class DecryptError(Exception): """Error during decryption.""" -class UnsuppertedSecureTarVersion(DecryptError): +class UnsupportedSecureTarVersion(DecryptError): """Unsupported securetar version.""" @@ -157,6 +166,33 @@ class AsyncIteratorReader: self._buffer = None return bytes(result) + def close(self) -> None: + """Close the iterator.""" + + +class AsyncIteratorWriter: + """Wrap an AsyncIterator.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the wrapper.""" + self._hass = hass + self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1) + + def __aiter__(self) -> Self: + """Return the iterator.""" + return self + + async def __anext__(self) -> bytes: + """Get the next chunk from the iterator.""" + if data := await self._queue.get(): + return data + raise StopAsyncIteration + + def write(self, s: bytes, /) -> int: + """Write data to the iterator.""" + asyncio.run_coroutine_threadsafe(self._queue.put(s), self._hass.loop).result() + return len(s) + def validate_password_stream( input_stream: IO[bytes], @@ -170,7 +206,7 @@ def validate_password_stream( if not obj.name.endswith((".tar", ".tgz", ".tar.gz")): continue if obj.pax_headers.get(VERSION_HEADER) != "2.0": - raise UnsuppertedSecureTarVersion + raise UnsupportedSecureTarVersion istf = SecureTarFile( None, # Not used gzip=False, @@ -187,6 +223,68 @@ def validate_password_stream( raise BackupEmpty +def decrypt_backup( + input_stream: IO[bytes], + output_stream: IO[bytes], + password: str | None, + on_done: Callable[[], None], +) -> None: + """Decrypt a backup.""" + try: + with ( + tarfile.open( + fileobj=input_stream, mode="r|", bufsize=BUF_SIZE + ) as input_tar, + tarfile.open( + fileobj=output_stream, mode="w|", bufsize=BUF_SIZE + ) as output_tar, + ): + _decrypt_backup(input_tar, output_tar, password) + except (DecryptError, SecureTarError, tarfile.TarError) as err: + LOGGER.warning("Error decrypting backup: %s", err) + finally: + output_stream.write(b"") # Write an empty chunk to signal the end of the stream + on_done() + + +def _decrypt_backup( + input_tar: tarfile.TarFile, + output_tar: tarfile.TarFile, + password: str | None, +) -> None: + """Decrypt a backup.""" + for obj in input_tar: + # We compare with PurePath to avoid issues with different path separators, + # for example when backup.json is added as "./backup.json" + if PurePath(obj.name) == PurePath("backup.json"): + # Rewrite the backup.json file to indicate that the backup is decrypted + if not (reader := input_tar.extractfile(obj)): + raise DecryptError + metadata = json_loads_object(reader.read()) + metadata["protected"] = False + updated_metadata_b = json.dumps(metadata).encode() + metadata_obj = copy.deepcopy(obj) + metadata_obj.size = len(updated_metadata_b) + output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b)) + continue + if not obj.name.endswith((".tar", ".tgz", ".tar.gz")): + output_tar.addfile(obj, input_tar.extractfile(obj)) + continue + if obj.pax_headers.get(VERSION_HEADER) != "2.0": + raise UnsupportedSecureTarVersion + decrypted_obj = copy.deepcopy(obj) + decrypted_obj.size = int(obj.pax_headers[PLAINTEXT_SIZE_HEADER]) + istf = SecureTarFile( + None, # Not used + gzip=False, + key=password_to_key(password) if password is not None else None, + mode="r", + fileobj=input_tar.extractfile(obj), + ) + with istf.decrypt(obj) as decrypted: + output_tar.addfile(decrypted_obj, decrypted) + + async def receive_file( hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path ) -> None: diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index ee855fb70f2..29a6b27db56 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -9,11 +9,14 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from homeassistant.components.backup import DOMAIN from homeassistant.components.backup.manager import NewBackup, WrittenBackup from homeassistant.core import HomeAssistant from .common import TEST_BACKUP_PATH_ABC123 +from tests.common import get_fixture_path + @pytest.fixture(name="mocked_json_bytes") def mocked_json_bytes_fixture() -> Generator[Mock]: @@ -113,3 +116,18 @@ def mock_backup_generation_fixture( ), ): yield + + +@pytest.fixture +def mock_backups() -> Generator[None]: + """Fixture to setup test backups.""" + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.backup import backup as core_backup + + class CoreLocalBackupAgent(core_backup.CoreLocalBackupAgent): + def __init__(self, hass: HomeAssistant) -> None: + super().__init__(hass) + self._backup_dir = get_fixture_path("test_backups", DOMAIN) + + with patch.object(core_backup, "CoreLocalBackupAgent", CoreLocalBackupAgent): + yield diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index c071a0d8386..693434631b9 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -1,18 +1,23 @@ """Tests for the Backup integration.""" import asyncio -from io import StringIO +from collections.abc import AsyncIterator, Iterable +from io import BytesIO, StringIO +import json +import tarfile +from typing import Any from unittest.mock import patch from aiohttp import web import pytest -from homeassistant.components.backup.const import DATA_MANAGER +from homeassistant.components.backup import AddonInfo, AgentBackup, Folder +from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.core import HomeAssistant from .common import TEST_BACKUP_ABC123, BackupAgentTest, setup_backup_integration -from tests.common import MockUser +from tests.common import MockUser, get_fixture_path from tests.typing import ClientSessionGenerator @@ -45,8 +50,9 @@ async def test_downloading_remote_backup( hass_client: ClientSessionGenerator, ) -> None: """Test downloading a remote backup.""" - await setup_backup_integration(hass) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + await setup_backup_integration( + hass, backups={"test.test": [TEST_BACKUP_ABC123]}, remote_agents=["test"] + ) client = await hass_client() @@ -54,11 +60,140 @@ async def test_downloading_remote_backup( 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=domain.test") + 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( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test downloading a local backup file.""" + await setup_backup_integration(hass) + client = await hass_client() + + with patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", + return_value=TEST_BACKUP_ABC123, + ): + resp = await client.get( + "/api/backup/download/abc123?agent_id=backup.local&password=blah" + ) + assert resp.status == 404 + + +@pytest.mark.usefixtures("mock_backups") +async def test_downloading_local_encrypted_backup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test downloading a local backup file.""" + await setup_backup_integration(hass) + await _test_downloading_encrypted_backup(hass_client, "backup.local") + + +async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: + """Convert an iterable to an async iterator.""" + for i in iterable: + yield i + + +@patch.object(BackupAgentTest, "async_download_backup") +async def test_downloading_remote_encrypted_backup( + download_mock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test downloading a local backup file.""" + backup_path = get_fixture_path("test_backups/ed1608a9.tar", DOMAIN) + await setup_backup_integration(hass) + hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest( + "test", + [ + AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="ed1608a9", + 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 download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: + return aiter_from_iter((backup_path.read_bytes(),)) + + download_mock.side_effect = download_backup + await _test_downloading_encrypted_backup(hass_client, "domain.test") + + +async def _test_downloading_encrypted_backup( + hass_client: ClientSessionGenerator, + agent_id: str, +) -> None: + """Test downloading an encrypted backup file.""" + # Try downloading without supplying a password + client = await hass_client() + resp = await client.get(f"/api/backup/download/ed1608a9?agent_id={agent_id}") + assert resp.status == 200 + backup = await resp.read() + # We expect a valid outer tar file, but the inner tar file is encrypted and + # can't be read + with tarfile.open(fileobj=BytesIO(backup), mode="r") as outer_tar: + enc_metadata = json.loads(outer_tar.extractfile("./backup.json").read()) + assert enc_metadata["protected"] is True + with ( + outer_tar.extractfile("core.tar.gz") as inner_tar_file, + pytest.raises(tarfile.ReadError, match="file could not be opened"), + ): + # pylint: disable-next=consider-using-with + tarfile.open(fileobj=inner_tar_file, mode="r") + + # Download with the wrong password + resp = await client.get( + f"/api/backup/download/ed1608a9?agent_id={agent_id}&password=wrong" + ) + assert resp.status == 200 + backup = await resp.read() + # We expect a truncated outer tar file + with ( + tarfile.open(fileobj=BytesIO(backup), mode="r") as outer_tar, + pytest.raises(tarfile.ReadError, match="unexpected end of data"), + ): + outer_tar.getnames() + + # Finally download with the correct password + resp = await client.get( + f"/api/backup/download/ed1608a9?agent_id={agent_id}&password=hunter2" + ) + assert resp.status == 200 + backup = await resp.read() + # We expect a valid outer tar file, the inner tar file is decrypted and can be read + with ( + tarfile.open(fileobj=BytesIO(backup), mode="r") as outer_tar, + ): + dec_metadata = json.loads(outer_tar.extractfile("./backup.json").read()) + assert dec_metadata == enc_metadata | {"protected": False} + with ( + outer_tar.extractfile("core.tar.gz") as inner_tar_file, + tarfile.open(fileobj=inner_tar_file, mode="r") as inner_tar, + ): + assert inner_tar.getnames() == [ + ".", + "README.md", + "test_symlink", + "test1", + "test1/script.sh", + ] + + async def test_downloading_backup_not_found( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 7820408f265..2aa6eca3b95 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -36,7 +36,7 @@ from .common import ( setup_backup_platform, ) -from tests.common import async_fire_time_changed, async_mock_service, get_fixture_path +from tests.common import async_fire_time_changed, async_mock_service from tests.typing import WebSocketGenerator BACKUP_CALL = call( @@ -2556,21 +2556,6 @@ async def test_subscribe_event( assert await client.receive_json() == snapshot -@pytest.fixture -def mock_backups() -> Generator[None]: - """Fixture to setup test backups.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.backup import backup as core_backup - - class CoreLocalBackupAgent(core_backup.CoreLocalBackupAgent): - def __init__(self, hass: HomeAssistant) -> None: - super().__init__(hass) - self._backup_dir = get_fixture_path("test_backups", DOMAIN) - - with patch.object(core_backup, "CoreLocalBackupAgent", CoreLocalBackupAgent): - yield - - @pytest.mark.parametrize( ("agent_id", "backup_id", "password"), [ From 9a1b965c7fe95efedd0227935a6e9e88ac23d3be Mon Sep 17 00:00:00 2001 From: Simone Rescio Date: Thu, 16 Jan 2025 12:39:37 +0100 Subject: [PATCH 0484/2987] Fix rmtree in translation script on MacOS (#129352) --- script/translations/develop.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/script/translations/develop.py b/script/translations/develop.py index 9e3a2ded046..00ac7bf98ac 100644 --- a/script/translations/develop.py +++ b/script/translations/develop.py @@ -4,7 +4,6 @@ import argparse import json from pathlib import Path import re -from shutil import rmtree import sys from . import download, upload @@ -83,9 +82,10 @@ def run_single(translations, flattened_translations, integration): ) if download.DOWNLOAD_DIR.is_dir(): - rmtree(str(download.DOWNLOAD_DIR)) - - download.DOWNLOAD_DIR.mkdir(parents=True) + for lang_file in download.DOWNLOAD_DIR.glob("*.json"): + lang_file.unlink() + else: + download.DOWNLOAD_DIR.mkdir(parents=True) (download.DOWNLOAD_DIR / "en.json").write_text( json.dumps({"component": {integration: translations["component"][integration]}}) From 421f9aa638cce03516ffcdf4e34118bedbb587a3 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 16 Jan 2025 12:49:27 +0100 Subject: [PATCH 0485/2987] Avoid using the backup manager in restore tests (#135757) * Fix typing * Refactor test restore backup * Refactor test restore backup wrong password * Refactor test restore backup wrong parameters * Update manager state after rebase * Remove not needed patch --- tests/components/backup/conftest.py | 2 +- tests/components/backup/test_manager.py | 271 ++++++++++++++++-------- 2 files changed, 185 insertions(+), 88 deletions(-) diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index 29a6b27db56..7831efeff9a 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -74,7 +74,7 @@ def mock_create_backup() -> Generator[AsyncMock]: mock_written_backup.backup.backup_id = "abc123" mock_written_backup.open_stream = AsyncMock() mock_written_backup.release_stream = AsyncMock() - fut = Future() + fut: Future[MagicMock] = Future() fut.set_result(mock_written_backup) with patch( "homeassistant.components.backup.CoreBackupReaderWriter.async_create_backup" diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 70b95b1bb7f..fa98220f810 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -45,6 +45,7 @@ from homeassistant.components.backup.manager import ( NewBackup, ReceiveBackupStage, ReceiveBackupState, + RestoreBackupState, WrittenBackup, ) from homeassistant.core import HomeAssistant @@ -682,7 +683,7 @@ async def test_create_backup_success_clears_issue( assert set(issue_registry.issues) == issues_after_create_backup -async def delayed_boom(*args, **kwargs) -> None: +async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: """Raise an exception after a delay.""" async def delayed_boom() -> None: @@ -2224,42 +2225,47 @@ async def test_receive_backup_file_read_error( assert unlink_mock.call_count == unlink_call_count +@pytest.mark.usefixtures("path_glob") @pytest.mark.parametrize( - ("agent_id", "password", "restore_database", "restore_homeassistant", "dir"), + ("agent_id", "password_param", "restore_database", "restore_homeassistant", "dir"), [ - (LOCAL_AGENT_ID, None, True, False, "backups"), - (LOCAL_AGENT_ID, "abc123", False, True, "backups"), - ("test.remote", None, True, True, "tmp_backups"), + (LOCAL_AGENT_ID, {}, True, False, "backups"), + (LOCAL_AGENT_ID, {"password": "abc123"}, False, True, "backups"), + ("test.remote", {}, True, True, "tmp_backups"), ], ) -async def test_async_trigger_restore( +async def test_restore_backup( hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, agent_id: str, - password: str | None, + password_param: dict[str, str], restore_database: bool, restore_homeassistant: bool, dir: str, ) -> None: - """Test trigger restore.""" - manager = BackupManager(hass, CoreBackupReaderWriter(hass)) - hass.data[DATA_MANAGER] = manager - - await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) + """Test restore backup.""" + password = password_param.get("password") + remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) + 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=[BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])] - ), + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), spec_set=BackupAgentPlatformProtocol, ), ) - await manager.load_platforms() - local_agent = manager.backup_agents[LOCAL_AGENT_ID] - local_agent._backups = {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123} - local_agent._loaded_backups = True + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True with ( patch("pathlib.Path.exists", return_value=True), @@ -2269,90 +2275,152 @@ async def test_async_trigger_restore( patch( "homeassistant.components.backup.manager.validate_password" ) as validate_password_mock, - patch.object(BackupAgentTest, "async_download_backup") as download_mock, + patch.object(remote_agent, "async_download_backup") as download_mock, + patch( + "homeassistant.components.backup.backup.read_backup", + return_value=TEST_BACKUP_ABC123, + ), ): download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) - await manager.async_restore_backup( - TEST_BACKUP_ABC123.backup_id, - agent_id=agent_id, - password=password, - restore_addons=None, - restore_database=restore_database, - restore_folders=None, - restore_homeassistant=restore_homeassistant, - ) - backup_path = f"{hass.config.path()}/{dir}/abc123.tar" - expected_restore_file = json.dumps( + await ws_client.send_json_auto_id( { - "path": backup_path, - "password": password, - "remove_after_restore": agent_id != LOCAL_AGENT_ID, + "type": "backup/restore", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": agent_id, "restore_database": restore_database, "restore_homeassistant": restore_homeassistant, } + | password_param ) - validate_password_mock.assert_called_once_with(Path(backup_path), password) - assert mocked_write_text.call_args[0][0] == expected_restore_file - assert mocked_service_call.called + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.COMPLETED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + backup_path = f"{hass.config.path()}/{dir}/abc123.tar" + expected_restore_file = json.dumps( + { + "path": backup_path, + "password": password, + "remove_after_restore": agent_id != LOCAL_AGENT_ID, + "restore_database": restore_database, + "restore_homeassistant": restore_homeassistant, + } + ) + validate_password_mock.assert_called_once_with(Path(backup_path), password) + assert mocked_write_text.call_args[0][0] == expected_restore_file + assert mocked_service_call.called -async def test_async_trigger_restore_wrong_password(hass: HomeAssistant) -> None: - """Test trigger restore.""" +@pytest.mark.usefixtures("path_glob") +@pytest.mark.parametrize( + ("agent_id", "dir"), [(LOCAL_AGENT_ID, "backups"), ("test.remote", "tmp_backups")] +) +async def test_restore_backup_wrong_password( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + agent_id: str, + dir: str, +) -> None: + """Test restore backup wrong password.""" password = "hunter2" - manager = BackupManager(hass, CoreBackupReaderWriter(hass)) - hass.data[DATA_MANAGER] = manager - - await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) + remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) + 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=[BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])] - ), + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), spec_set=BackupAgentPlatformProtocol, ), ) - await manager.load_platforms() - local_agent = manager.backup_agents[LOCAL_AGENT_ID] - local_agent._backups = {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123} - local_agent._loaded_backups = True + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True with ( patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.open"), patch("pathlib.Path.write_text") as mocked_write_text, patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, 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", + return_value=TEST_BACKUP_ABC123, + ), ): + download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) validate_password_mock.return_value = False - with pytest.raises( - HomeAssistantError, match="The password provided is incorrect." - ): - await manager.async_restore_backup( - TEST_BACKUP_ABC123.backup_id, - agent_id=LOCAL_AGENT_ID, - password=password, - restore_addons=None, - restore_database=True, - restore_folders=None, - restore_homeassistant=True, - ) + await ws_client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": agent_id, + "password": password, + } + ) - backup_path = f"{hass.config.path()}/backups/abc123.tar" - validate_password_mock.assert_called_once_with(Path(backup_path), password) - mocked_write_text.assert_not_called() - mocked_service_call.assert_not_called() + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert not result["success"] + assert result["error"]["code"] == "password_incorrect" + + backup_path = f"{hass.config.path()}/{dir}/abc123.tar" + validate_password_mock.assert_called_once_with(Path(backup_path), password) + mocked_write_text.assert_not_called() + mocked_service_call.assert_not_called() +@pytest.mark.usefixtures("path_glob") @pytest.mark.parametrize( ("parameters", "expected_error"), [ ( {"backup_id": TEST_BACKUP_DEF456.backup_id}, - "Backup def456 not found", + f"Backup def456 not found in agent {LOCAL_AGENT_ID}", ), ( {"restore_addons": ["blah"]}, @@ -2368,36 +2436,65 @@ async def test_async_trigger_restore_wrong_password(hass: HomeAssistant) -> None ), ], ) -async def test_async_trigger_restore_wrong_parameters( - hass: HomeAssistant, parameters: dict[str, Any], expected_error: str +async def test_restore_backup_wrong_parameters( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + parameters: dict[str, Any], + expected_error: str, ) -> None: - """Test trigger restore.""" - manager = BackupManager(hass, CoreBackupReaderWriter(hass)) + """Test restore backup wrong parameters.""" + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() - await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) - await manager.load_platforms() + ws_client = await hass_ws_client(hass) - local_agent = manager.backup_agents[LOCAL_AGENT_ID] - local_agent._backups = {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123} - local_agent._loaded_backups = True + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) - default_parameters = { - "agent_id": LOCAL_AGENT_ID, - "backup_id": TEST_BACKUP_ABC123.backup_id, - "password": None, - "restore_addons": None, - "restore_database": True, - "restore_folders": None, - "restore_homeassistant": True, - } + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True with ( patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.write_text") as mocked_write_text, patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, - pytest.raises(HomeAssistantError, match=expected_error), + patch( + "homeassistant.components.backup.backup.read_backup", + return_value=TEST_BACKUP_ABC123, + ), ): - await manager.async_restore_backup(**(default_parameters | parameters)) + await ws_client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": LOCAL_AGENT_ID, + } + | parameters + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert not result["success"] + assert result["error"]["code"] == "home_assistant_error" + assert result["error"]["message"] == expected_error mocked_write_text.assert_not_called() mocked_service_call.assert_not_called() From 3638d25f6a3a4dd893e7cff6cbf482b0c26f0b6d Mon Sep 17 00:00:00 2001 From: DrDonoso <34934002+DrDonoso@users.noreply.github.com> Date: Thu, 16 Jan 2025 13:03:42 +0100 Subject: [PATCH 0486/2987] Add message_thread_id to telegram_text and telegram_command events (#130738) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/telegram_bot/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 4fdb87f9fa6..f744265e1c2 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -1128,6 +1128,7 @@ class BaseTelegramBotEntity: ATTR_MSGID: message.message_id, ATTR_CHAT_ID: message.chat.id, ATTR_DATE: message.date, + ATTR_MESSAGE_THREAD_ID: message.message_thread_id, } if filters.COMMAND.filter(message): # This is a command message - set event type to command and split data into command and args From fc39b6792cb2de7e68fa5e571846e310a22d3add Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 16 Jan 2025 13:06:33 +0100 Subject: [PATCH 0487/2987] Enable RUF100 (#135760) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index acf9c4b5ee6..0bb5ad7ea8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -759,7 +759,7 @@ select = [ "RUF017", # Avoid quadratic list summation "RUF018", # Avoid assignment expressions in assert statements "RUF019", # Unnecessary key check before dictionary access - # "RUF100", # Unused `noqa` directive; temporarily every now and then to clean them up + "RUF100", # Unused `noqa` directive "S102", # Use of exec detected "S103", # bad-file-permissions "S108", # hardcoded-temp-file From 1cff45b8b7f0715570f98be817ca4d02358f27ef Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Jan 2025 13:20:46 +0100 Subject: [PATCH 0488/2987] Use new ServiceInfo location in apple_tv (#135688) --- homeassistant/components/apple_tv/config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 5cb92ed892a..5c317755d05 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -34,6 +34,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, SchemaOptionsFlowHandler, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN @@ -204,7 +205,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle device found via zeroconf.""" if discovery_info.ip_address.version == 6: From 6cbe18ebbd73b7bafd8209ee732d1d99059b61a6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Jan 2025 13:26:52 +0100 Subject: [PATCH 0489/2987] Bump securetar to 2025.1.3 (#135762) * Bump securetar to 2025.1.3 * Remove outdated fixture --- homeassistant/components/backup/manifest.json | 2 +- homeassistant/components/backup/util.py | 20 ++++++------------ homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../{ed1608a9.tar => c0cb53bd.tar} | Bin 10240 -> 10240 bytes .../backup/snapshots/test_websocket.ambr | 6 +++--- tests/components/backup/test_http.py | 10 ++++----- tests/components/backup/test_websocket.py | 6 +++--- 11 files changed, 24 insertions(+), 30 deletions(-) rename tests/components/backup/fixtures/test_backups/{ed1608a9.tar => c0cb53bd.tar} (66%) diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index b1b6e6f70c6..ffaed260c88 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["cronsim==2.6", "securetar==2025.1.2"] + "requirements": ["cronsim==2.6", "securetar==2025.1.3"] } diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index 55f3c3c05c7..ac1525b7d69 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -13,13 +13,7 @@ import tarfile from typing import IO, Self, cast import aiohttp -from securetar import ( - PLAINTEXT_SIZE_HEADER, - VERSION_HEADER, - SecureTarError, - SecureTarFile, - SecureTarReadError, -) +from securetar import SecureTarError, SecureTarFile, SecureTarReadError from homeassistant.backup_restore import password_to_key from homeassistant.core import HomeAssistant @@ -205,8 +199,6 @@ def validate_password_stream( for obj in input_tar: if not obj.name.endswith((".tar", ".tgz", ".tar.gz")): continue - if obj.pax_headers.get(VERSION_HEADER) != "2.0": - raise UnsupportedSecureTarVersion istf = SecureTarFile( None, # Not used gzip=False, @@ -215,6 +207,8 @@ def validate_password_stream( fileobj=input_tar.extractfile(obj), ) with istf.decrypt(obj) as decrypted: + if istf.securetar_header.plaintext_size is None: + raise UnsupportedSecureTarVersion try: decrypted.read(1) # Read a single byte to trigger the decryption except SecureTarReadError as err: @@ -270,10 +264,6 @@ def _decrypt_backup( if not obj.name.endswith((".tar", ".tgz", ".tar.gz")): output_tar.addfile(obj, input_tar.extractfile(obj)) continue - if obj.pax_headers.get(VERSION_HEADER) != "2.0": - raise UnsupportedSecureTarVersion - decrypted_obj = copy.deepcopy(obj) - decrypted_obj.size = int(obj.pax_headers[PLAINTEXT_SIZE_HEADER]) istf = SecureTarFile( None, # Not used gzip=False, @@ -282,6 +272,10 @@ def _decrypt_backup( fileobj=input_tar.extractfile(obj), ) with istf.decrypt(obj) as decrypted: + if (plaintext_size := istf.securetar_header.plaintext_size) is None: + raise UnsupportedSecureTarVersion + decrypted_obj = copy.deepcopy(obj) + decrypted_obj.size = plaintext_size output_tar.addfile(decrypted_obj, decrypted) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6b994679780..6f7fe970cc9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -60,7 +60,7 @@ PyTurboJPEG==1.7.5 pyudev==0.24.1 PyYAML==6.0.2 requests==2.32.3 -securetar==2025.1.2 +securetar==2025.1.3 SQLAlchemy==2.0.36 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' diff --git a/pyproject.toml b/pyproject.toml index 0bb5ad7ea8d..406fbe6cf25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ dependencies = [ "python-slugify==8.0.4", "PyYAML==6.0.2", "requests==2.32.3", - "securetar==2025.1.2", + "securetar==2025.1.3", "SQLAlchemy==2.0.36", "standard-aifc==3.13.0;python_version>='3.13'", "standard-telnetlib==3.13.0;python_version>='3.13'", diff --git a/requirements.txt b/requirements.txt index 7f12eb14274..52e1b412803 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,7 +38,7 @@ psutil-home-assistant==0.0.1 python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 -securetar==2025.1.2 +securetar==2025.1.3 SQLAlchemy==2.0.36 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' diff --git a/requirements_all.txt b/requirements_all.txt index 78525134731..d6d9b583d94 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2665,7 +2665,7 @@ screenlogicpy==0.10.0 scsgate==0.1.0 # homeassistant.components.backup -securetar==2025.1.2 +securetar==2025.1.3 # homeassistant.components.sendgrid sendgrid==6.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b2413d5ed95..4a8dfe35756 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2147,7 +2147,7 @@ sanix==1.0.6 screenlogicpy==0.10.0 # homeassistant.components.backup -securetar==2025.1.2 +securetar==2025.1.3 # homeassistant.components.emulated_kasa # homeassistant.components.sense diff --git a/tests/components/backup/fixtures/test_backups/ed1608a9.tar b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar similarity index 66% rename from tests/components/backup/fixtures/test_backups/ed1608a9.tar rename to tests/components/backup/fixtures/test_backups/c0cb53bd.tar index fc928b16d1b1c3c480ea3600ef8f69d7f87dbc4c..f3b2845d5eb19b9708ae6d4fd68f7d220fb39c45 100644 GIT binary patch delta 1801 zcmbW%X;{(+90zdFN^>&hC=b+7Qz)-L{6Vg=bVxZ@GL>d|ix(nZmynu}60&01w) zYSE_4n36|cv2vMO%{)q7N**OEdvu$cN3K23uIE`VwtauEes8|7KF>FUmBG61psmXR zIAJ6(EWDbe?FBUfKq3VAD-Zw=5by*DCan+-4KIX3SIh_$3_qF6kJySQgJb|tAy7a^ z3P1%wGC{BU7J3X?pnJra2te>eGLe8MlAKBJ8u9NAfM9?>TEspPoc&1=zM)B4kiP|5 z`p45m)Wo;b*P!l;W@NM|63cxv>3e06ck*T?+p_$aLylXnC32l}9m!rZ z`g32SLBGTs43?OTa{A$hx1I|o_0QQQ@gKV!6ZLK1Z;w9qo4cv}JXY4M}@Ckos!B;M25XDJmw z$n^AxV`gCDrI{aUZI-5n++I}--%szzF_f9@Pi{P7uI@&{2~4`4u6`-8p#&Nyl4fkt_o>8q?2 zYDNjJmX1&j55#xceN#q#KA(wG>trc;a+^rn8C0*u+N(^Q*ppIl~pfPy5tLVCS{n}S`75nx({L#-0==X3L~Qs4uV6q<5Vim(g; zx$jRU1|7ep3%aj~A#p7j$gzjQSfO=w6n;HD@8CqO6up-UX&%)oHrw^p30V5298*I0 zF6&-KYO$9LFx+Kgnny!hxHshMF?~lUhCshV~`XW*Ob*JR*L!dojN0XOJ@4n9Y+Lg;G%o^0qi%^xXEDZ3_RMSDs5O>Qy+Hy3q|Hkf+g>BlvDXd)gPm z)@X6KKSdkmHsb41v@EgyRCl=E*ZzS*2$SYrxdKX6CnMX9rcU|0r|B6BoK~Zwm#{^|tazBAL3=0Op=3 z7#q_2Vzn2tXUBYa>-~mf=U>+i!~*8w|x%41>Qot-Pe7t_|}T1-nz zu_fKH-z}*E#hxyPLglCxPq^ddB`tXH;WGQ9KmBrVt#(~;QM0S8QL0)JdRqp$P5fX6 zxgy>@ePQr?-S;_erAz64V*1f%s=F(FG}mjIe5CR^1@>0XJt+? zy|M0hQerQ<>5)~nIw=+kqtj1T*H;#F8&JI~*jXrqIKiG1aPc~-u);%qVYL_+(w?kY zOxJO+(nh!Pr3=GAfbnRNO`-B-(MJ?UQ$Oj^QQH%2qh+i9kY;$mr9;>EiRu)?Un2TX zd~+WDy$@C$#;=)8gQ4Q)2q`{?qhALW-UolhIq zJ{iGbR(EkR3=Z_BIk@Pw--YX30ZuLy;HQP=y$^@>PCkr%bM=%_fFj3-4*TWc<90?9 z8i9Sx&AWH(fXfq!%hiv^Tl7!c#;oZ-;20e@j^| delta 1886 zcmc)ITR7VX9|!QHhBSi2xfG$SRTcRsBB(<&o09g`RMnw%O!7yZiFy@nX-^r1PL=T)fS1-*=UJvrFE#xJF9v!>Qr4Xx@W!E{eBnUtKai{zZai8Y96)CK@A2n z0B5XAw5x=mb_%)=z!4$XzYl^1usDDKL1&espo~%|^i41xkB(%4^b6@=MszwRH8Gl* zoB?KKM6#IKpobIS0>?SO|Bwl$vzRH#9ykmZj&ngLWiXRK4}gewcEu9`X9C6v`!DCRY;88gh!b zW&iQDo3kb)_W$bbsk)1bxO1f<8VTx)w0bKAtYh9Wt_DRu%GlO32lwm5-YjN>XhOOx z_>?L05l&>^2_^gKMfJI}ZS1CFe4JbflgHu%M=$Fw;vVaD>OW8B!^g#f!C9L+4ZrM( z*+@1k{+^W6+h{uz*ZvdmqVAdK1WxyBZ?AB9t54>&8k=uWI|Z@Sf*|#Usd9u_Y(Gn1 z*~-4l*l8L1f0}S`%tVi79@cRB!BXu&?-n9cQ5sf&bR(Z=+3H=s?3M6p&p>FW_~U2v z=CWA}ktC`>4EU6a6v+EomY&B?hRpmCbdvqt(0;Zf!$#Tff2;*%!g}nPH~7SBDp>0a zntc`aPEqjOBx1bNF$-Dz$+yYIx{4#T#x#iL@|p@kL3JusEV?;=w^U-tvnZZqS$J~q zE)bDgTZ@_svdkvlbPsyojW^5_PeFBHy45-Q)E_@R@Q@mz2yveDHx6#T#`}m&wvN!d zaYiqf6ttEX1qpOf*#(={j-ruE10+&gT@f5HY3@b6<2ead zZ@%a*V#8VPa7ZLp^9607BComkrm_K zFwy?ilI`JxCZ;W2k%zrexi0gkbG|*BBMe`4loQMaXIEz&c=tO_-JUjV=3$O@(?+Y@ zv&EGm$2B`OsN#()7Dwq%I~%zpCyrBKZg!zId}2V~ro8V^&Hj>KuFj?fx<8xHD4`UQ z9hU7eX3}+I=#J$^5F60{E#aY*%6I$&Ewg_L3tPR`N;~{}Zny9}avL6z7a+J%zhYjaUAUMCnX7bP2oZiP4RULM(dCG}eGOxLRr8!yQ_vp3^ znX>LXL}AL!f-{T3uG7%Z&{Lnn12ROX0(YO}MWn6UR&>?Q@9YkB@6T>Te5{$2s?o?v ztv9>aLmxcpAXILumd|4f9~I7DKk(mPnD0!S@LYQIdXGZ4&gc@U%Rt@WWkGuYpB`WN z`Eye?QGojHd&7)u<%Kqzi(a+rXL3Ig1kHR|Tem6ME7kIivv*xzZ?ps|I;;XTxt~K; z+jEu+hljPd9&Y>N>KbP_qydv9F*mGTCIQ305{c-vExvi9xMzmNLuy%X;OpMeO>*xSQe$WcYB&nA*a;e`e^KdDHHL9`vlz z5q;FS$@II-s#u9p0`H3kgO*UQC;5Xhw?6cs$EDSGB%^Znz-6h=Ednzz!gE6hUy{$I z^v!qeyt?GhFoH8SF9w1TO3*%Lz4B08?V9si+nPCm(@XuVqvHpo*$3lIA3lCvN-poY a>o+p#lvpSvEO}4Sn&ED None: """Test downloading a local backup file.""" - backup_path = get_fixture_path("test_backups/ed1608a9.tar", DOMAIN) + backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) await setup_backup_integration(hass) hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest( "test", [ AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], - backup_id="ed1608a9", + backup_id="c0cb53bd", database_included=True, date="1970-01-01T00:00:00Z", extra_metadata={}, @@ -141,7 +141,7 @@ async def _test_downloading_encrypted_backup( """Test downloading an encrypted backup file.""" # Try downloading without supplying a password client = await hass_client() - resp = await client.get(f"/api/backup/download/ed1608a9?agent_id={agent_id}") + resp = await client.get(f"/api/backup/download/c0cb53bd?agent_id={agent_id}") assert resp.status == 200 backup = await resp.read() # We expect a valid outer tar file, but the inner tar file is encrypted and @@ -158,7 +158,7 @@ async def _test_downloading_encrypted_backup( # Download with the wrong password resp = await client.get( - f"/api/backup/download/ed1608a9?agent_id={agent_id}&password=wrong" + f"/api/backup/download/c0cb53bd?agent_id={agent_id}&password=wrong" ) assert resp.status == 200 backup = await resp.read() @@ -171,7 +171,7 @@ async def _test_downloading_encrypted_backup( # Finally download with the correct password resp = await client.get( - f"/api/backup/download/ed1608a9?agent_id={agent_id}&password=hunter2" + f"/api/backup/download/c0cb53bd?agent_id={agent_id}&password=hunter2" ) assert resp.status == 200 backup = await resp.read() diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 2aa6eca3b95..fe6c0c1f679 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -2560,13 +2560,13 @@ async def test_subscribe_event( ("agent_id", "backup_id", "password"), [ # Invalid agent or backup - ("no_such_agent", "ed1608a9", "hunter2"), + ("no_such_agent", "c0cb53bd", "hunter2"), ("backup.local", "no_such_backup", "hunter2"), # Legacy backup, which can't be streamed ("backup.local", "2bcb3113", "hunter2"), # New backup, which can be streamed, try with correct and wrong password - ("backup.local", "ed1608a9", "hunter2"), - ("backup.local", "ed1608a9", "wrong_password"), + ("backup.local", "c0cb53bd", "hunter2"), + ("backup.local", "c0cb53bd", "wrong_password"), ], ) @pytest.mark.usefixtures("mock_backups") From a67bc12bb898a08fb7ffa8a52d52598e2043c2b2 Mon Sep 17 00:00:00 2001 From: Max Cabrajac <38819272+maxcabrajac@users.noreply.github.com> Date: Thu, 16 Jan 2025 09:34:30 -0300 Subject: [PATCH 0490/2987] Change AdGuard Home URL field validator to accept paths (#127957) --- homeassistant/components/adguard/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index 9e531c683da..f8ddeba6767 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -34,9 +34,12 @@ from .const import ( SERVICE_REMOVE_URL, ) -SERVICE_URL_SCHEMA = vol.Schema({vol.Required(CONF_URL): cv.url}) +SERVICE_URL_SCHEMA = vol.Schema({vol.Required(CONF_URL): vol.Any(cv.url, cv.path)}) SERVICE_ADD_URL_SCHEMA = vol.Schema( - {vol.Required(CONF_NAME): cv.string, vol.Required(CONF_URL): cv.url} + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_URL): vol.Any(cv.url, cv.path), + } ) SERVICE_REFRESH_SCHEMA = vol.Schema( {vol.Optional(CONF_FORCE, default=False): cv.boolean} From 9d7706c9beba27da661bb52ed4b12b42650076b8 Mon Sep 17 00:00:00 2001 From: Matthew FitzGerald-Chamberlain Date: Thu, 16 Jan 2025 06:37:44 -0600 Subject: [PATCH 0491/2987] Aprilaire - Fix humidifier showing when it is not available (#133984) --- homeassistant/components/aprilaire/humidifier.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/aprilaire/humidifier.py b/homeassistant/components/aprilaire/humidifier.py index 254cc0ac789..8a173e5e95e 100644 --- a/homeassistant/components/aprilaire/humidifier.py +++ b/homeassistant/components/aprilaire/humidifier.py @@ -50,7 +50,7 @@ async def async_setup_entry( descriptions: list[AprilaireHumidifierDescription] = [] - if coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) in (0, 1, 2): + if coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) in (1, 2): descriptions.append( AprilaireHumidifierDescription( key="humidifier", @@ -67,7 +67,7 @@ async def async_setup_entry( ) ) - if coordinator.data.get(Attribute.DEHUMIDIFICATION_AVAILABLE) in (0, 1): + if coordinator.data.get(Attribute.DEHUMIDIFICATION_AVAILABLE) == 1: descriptions.append( AprilaireHumidifierDescription( key="dehumidifier", From 40a3e19ce5e317dd84a5c52b82c7e73b91c95bca Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Thu, 16 Jan 2025 13:38:40 +0100 Subject: [PATCH 0492/2987] Add further ventilation-related sensors to ViCare (#131496) --- homeassistant/components/vicare/icons.json | 3 + homeassistant/components/vicare/sensor.py | 27 +- homeassistant/components/vicare/strings.json | 21 + .../vicare/snapshots/test_sensor.ambr | 978 ++++++++++-------- tests/components/vicare/test_sensor.py | 20 +- 5 files changed, 620 insertions(+), 429 deletions(-) diff --git a/homeassistant/components/vicare/icons.json b/homeassistant/components/vicare/icons.json index 9d0f27a863c..52148b1fa32 100644 --- a/homeassistant/components/vicare/icons.json +++ b/homeassistant/components/vicare/icons.json @@ -84,6 +84,9 @@ }, "compressor_phase": { "default": "mdi:information" + }, + "ventilation_level": { + "default": "mdi:fan" } } }, diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 3386c849f74..1dade9ddda7 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -796,7 +796,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( translation_key="photovoltaic_status", device_class=SensorDeviceClass.ENUM, options=["ready", "production"], - value_getter=lambda api: _filter_pv_states(api.getPhotovoltaicStatus()), + value_getter=lambda api: _filter_states(api.getPhotovoltaicStatus()), ), ViCareSensorEntityDescription( key="room_temperature", @@ -812,6 +812,29 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, value_getter=lambda api: api.getHumidity(), ), + ViCareSensorEntityDescription( + key="ventilation_level", + translation_key="ventilation_level", + value_getter=lambda api: _filter_states(api.getVentilationLevel().lower()), + device_class=SensorDeviceClass.ENUM, + options=["standby", "levelone", "leveltwo", "levelthree", "levelfour"], + ), + ViCareSensorEntityDescription( + key="ventilation_reason", + translation_key="ventilation_reason", + value_getter=lambda api: api.getVentilationReason().lower(), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=[ + "standby", + "permanent", + "schedule", + "sensordriven", + "silent", + "forcedlevelfour", + ], + ), ) CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( @@ -920,7 +943,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ) -def _filter_pv_states(state: str) -> str | None: +def _filter_states(state: str) -> str | None: return None if state in ("nothing", "unknown") else state diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 4934507e41c..074c994d4a5 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -434,6 +434,27 @@ }, "compressor_phase": { "name": "Compressor phase" + }, + "ventilation_level": { + "name": "Ventilation level", + "state": { + "standby": "[%key:common::state::standby%]", + "levelone": "1", + "leveltwo": "2", + "levelthree": "3", + "levelfour": "4" + } + }, + "ventilation_reason": { + "name": "Ventilation reason", + "state": { + "standby": "[%key:common::state::standby%]", + "permanent": "Permanent", + "schedule": "Schedule", + "sensordriven": "Sensor-driven", + "silent": "Silent", + "forcedlevelfour": "Boost" + } } }, "water_heater": { diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index 793f3e87611..88c3c945253 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[sensor.model0_boiler_temperature-entry] +# name: test_all_heating_entities[sensor.model0_boiler_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_boiler_temperature-state] +# name: test_all_heating_entities[sensor.model0_boiler_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -50,7 +50,7 @@ 'state': '63', }) # --- -# name: test_all_entities[sensor.model0_burner_hours-entry] +# name: test_all_heating_entities[sensor.model0_burner_hours-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -85,7 +85,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_burner_hours-state] +# name: test_all_heating_entities[sensor.model0_burner_hours-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Burner hours', @@ -100,7 +100,7 @@ 'state': '18726.3', }) # --- -# name: test_all_entities[sensor.model0_burner_modulation-entry] +# name: test_all_heating_entities[sensor.model0_burner_modulation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -135,7 +135,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_all_entities[sensor.model0_burner_modulation-state] +# name: test_all_heating_entities[sensor.model0_burner_modulation-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Burner modulation', @@ -150,7 +150,7 @@ 'state': '0', }) # --- -# name: test_all_entities[sensor.model0_burner_starts-entry] +# name: test_all_heating_entities[sensor.model0_burner_starts-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -185,7 +185,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_burner_starts-state] +# name: test_all_heating_entities[sensor.model0_burner_starts-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Burner starts', @@ -199,7 +199,7 @@ 'state': '14315', }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_month-entry] +# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_this_month-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -234,7 +234,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_month-state] +# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_this_month-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption this month', @@ -248,7 +248,7 @@ 'state': '805', }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_week-entry] +# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_this_week-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -283,7 +283,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_week-state] +# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_this_week-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption this week', @@ -297,7 +297,7 @@ 'state': '84', }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_year-entry] +# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_this_year-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -332,7 +332,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_year-state] +# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_this_year-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption this year', @@ -346,7 +346,7 @@ 'state': '8203', }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_today-entry] +# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_today-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -381,7 +381,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_today-state] +# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption today', @@ -395,7 +395,7 @@ 'state': '22', }) # --- -# name: test_all_entities[sensor.model0_dhw_max_temperature-entry] +# name: test_all_heating_entities[sensor.model0_dhw_max_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -430,7 +430,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_dhw_max_temperature-state] +# name: test_all_heating_entities[sensor.model0_dhw_max_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -446,7 +446,7 @@ 'state': '60', }) # --- -# name: test_all_entities[sensor.model0_dhw_min_temperature-entry] +# name: test_all_heating_entities[sensor.model0_dhw_min_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -481,7 +481,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_dhw_min_temperature-state] +# name: test_all_heating_entities[sensor.model0_dhw_min_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -497,407 +497,7 @@ 'state': '10', }) # --- -# name: test_all_entities[sensor.model0_energy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_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': 'Energy', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'power consumption this month', - 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this month', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.model0_energy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'model0 Energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.model0_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '7.843', - }) -# --- -# name: test_all_entities[sensor.model0_electricity_consumption_this_year-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_electricity_consumption_this_year', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Electricity consumption this year', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'power_consumption_this_year', - 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this year', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.model0_electricity_consumption_this_year-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'model0 Electricity consumption this year', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.model0_electricity_consumption_this_year', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '207.106', - }) -# --- -# name: test_all_entities[sensor.model0_electricity_consumption_today-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_electricity_consumption_today', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Electricity consumption today', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'power_consumption_today', - 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption today', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.model0_electricity_consumption_today-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'model0 Electricity consumption today', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.model0_electricity_consumption_today', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.219', - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_month-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_heating_gas_consumption_this_month', - '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': 'Heating gas consumption this month', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'gas_consumption_heating_this_month', - 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_month', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_month-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'model0 Heating gas consumption this month', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.model0_heating_gas_consumption_this_month', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_week-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_heating_gas_consumption_this_week', - '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': 'Heating gas consumption this week', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'gas_consumption_heating_this_week', - 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_week', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_week-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'model0 Heating gas consumption this week', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.model0_heating_gas_consumption_this_week', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_year-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_heating_gas_consumption_this_year', - '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': 'Heating gas consumption this year', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'gas_consumption_heating_this_year', - 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_year', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_year-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'model0 Heating gas consumption this year', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.model0_heating_gas_consumption_this_year', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '30946', - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_today-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_heating_gas_consumption_today', - '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': 'Heating gas consumption today', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'gas_consumption_heating_today', - 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_today', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_today-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'model0 Heating gas consumption today', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.model0_heating_gas_consumption_today', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- -# name: test_all_entities[sensor.model0_outside_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_outside_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': 'Outside temperature', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'outside_temperature', - 'unique_id': 'gateway0_deviceSerialVitodens300W-outside_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.model0_outside_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'model0 Outside temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.model0_outside_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '20.8', - }) -# --- -# name: test_all_entities[sensor.model0_electricity_consumption_this_week-entry] +# name: test_all_heating_entities[sensor.model0_electricity_consumption_this_week-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -932,7 +532,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_electricity_consumption_this_week-state] +# name: test_all_heating_entities[sensor.model0_electricity_consumption_this_week-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -948,7 +548,407 @@ 'state': '0.829', }) # --- -# name: test_all_entities[sensor.model0_supply_temperature-entry] +# name: test_all_heating_entities[sensor.model0_electricity_consumption_this_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_electricity_consumption_this_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumption this year', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_consumption_this_year', + 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this year', + 'unit_of_measurement': , + }) +# --- +# name: test_all_heating_entities[sensor.model0_electricity_consumption_this_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Electricity consumption this year', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_electricity_consumption_this_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '207.106', + }) +# --- +# name: test_all_heating_entities[sensor.model0_electricity_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_electricity_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumption today', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_consumption_today', + 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_heating_entities[sensor.model0_electricity_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Electricity consumption today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_electricity_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.219', + }) +# --- +# name: test_all_heating_entities[sensor.model0_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_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': 'Energy', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power consumption this month', + 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this month', + 'unit_of_measurement': , + }) +# --- +# name: test_all_heating_entities[sensor.model0_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.843', + }) +# --- +# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_this_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_gas_consumption_this_month', + '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': 'Heating gas consumption this month', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_consumption_heating_this_month', + 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_month', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_this_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Heating gas consumption this month', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_gas_consumption_this_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_this_week-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_gas_consumption_this_week', + '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': 'Heating gas consumption this week', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_consumption_heating_this_week', + 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_week', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_this_week-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Heating gas consumption this week', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_gas_consumption_this_week', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_this_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_gas_consumption_this_year', + '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': 'Heating gas consumption this year', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_consumption_heating_this_year', + 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_year', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_this_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Heating gas consumption this year', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_gas_consumption_this_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30946', + }) +# --- +# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_gas_consumption_today', + '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': 'Heating gas consumption today', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_consumption_heating_today', + 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_today', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Heating gas consumption today', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_gas_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_heating_entities[sensor.model0_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_outside_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': 'Outside temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'gateway0_deviceSerialVitodens300W-outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_heating_entities[sensor.model0_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.8', + }) +# --- +# name: test_all_heating_entities[sensor.model0_supply_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -983,7 +983,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_supply_temperature-state] +# name: test_all_heating_entities[sensor.model0_supply_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -999,7 +999,7 @@ 'state': '63', }) # --- -# name: test_all_entities[sensor.model0_supply_temperature_2-entry] +# name: test_all_heating_entities[sensor.model0_supply_temperature_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1034,7 +1034,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_supply_temperature_2-state] +# name: test_all_heating_entities[sensor.model0_supply_temperature_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -1050,6 +1050,132 @@ 'state': '25.5', }) # --- +# name: test_all_ventilation_entities[sensor.model0_ventilation_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'standby', + 'levelone', + 'leveltwo', + 'levelthree', + 'levelfour', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_ventilation_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': 'Ventilation level', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ventilation_level', + 'unique_id': 'gateway0_deviceSerialViAir300F-ventilation_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_ventilation_entities[sensor.model0_ventilation_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'model0 Ventilation level', + 'options': list([ + 'standby', + 'levelone', + 'leveltwo', + 'levelthree', + 'levelfour', + ]), + }), + 'context': , + 'entity_id': 'sensor.model0_ventilation_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'levelone', + }) +# --- +# name: test_all_ventilation_entities[sensor.model0_ventilation_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'standby', + 'permanent', + 'schedule', + 'sensordriven', + 'silent', + 'forcedlevelfour', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_ventilation_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ventilation reason', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ventilation_reason', + 'unique_id': 'gateway0_deviceSerialViAir300F-ventilation_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_ventilation_entities[sensor.model0_ventilation_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'model0 Ventilation reason', + 'options': list([ + 'standby', + 'permanent', + 'schedule', + 'sensordriven', + 'silent', + 'forcedlevelfour', + ]), + }), + 'context': , + 'entity_id': 'sensor.model0_ventilation_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'permanent', + }) +# --- # name: test_room_sensors[sensor.model0_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/vicare/test_sensor.py b/tests/components/vicare/test_sensor.py index afd3232478a..2356b92f7a7 100644 --- a/tests/components/vicare/test_sensor.py +++ b/tests/components/vicare/test_sensor.py @@ -16,7 +16,7 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_all_entities( +async def test_all_heating_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, mock_config_entry: MockConfigEntry, @@ -35,6 +35,24 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_ventilation_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + fixtures: list[Fixture] = [Fixture({"type:ventilation"}, "vicare/ViAir300F.json")] + with ( + patch(f"{MODULE}.vicare_login", return_value=MockPyViCare(fixtures)), + patch(f"{MODULE}.PLATFORMS", [Platform.SENSOR]), + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_room_sensors( hass: HomeAssistant, From 27c2f2333eca43bacf76340a96abda286ae315b6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Jan 2025 13:40:13 +0100 Subject: [PATCH 0493/2987] Use new ServiceInfo location in esphome (#135692) --- homeassistant/components/esphome/config_flow.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index cb892b314cd..695131b19f7 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -20,7 +20,7 @@ from aioesphomeapi import ( import aiohttp import voluptuous as vol -from homeassistant.components import dhcp, zeroconf +from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, @@ -31,8 +31,10 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.mqtt import MqttServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.json import json_loads_object from .const import ( @@ -223,7 +225,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" mac_address: str | None = discovery_info.properties.get("mac") @@ -293,7 +295,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" await self.async_set_unique_id(format_mac(discovery_info.macaddress)) From 476935050ae0b620eb8b0cb0ef30402d63e5ddfc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Jan 2025 13:41:09 +0100 Subject: [PATCH 0494/2987] Use new ServiceInfo location in dlna_dmr (#135691) --- .../components/dlna_dmr/config_flow.py | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index 75f50192500..ede9119c50d 100644 --- a/homeassistant/components/dlna_dmr/config_flow.py +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -27,6 +27,14 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_MAC, CONF_TYPE, from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import IntegrationError from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERVICE_LIST, + SsdpServiceInfo, +) from homeassistant.helpers.typing import VolDictType from .const import ( @@ -60,7 +68,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize flow.""" - self._discoveries: dict[str, ssdp.SsdpServiceInfo] = {} + self._discoveries: dict[str, SsdpServiceInfo] = {} self._location: str | None = None self._udn: str | None = None self._device_type: str | None = None @@ -98,7 +106,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_manual() self._discoveries = { - discovery.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + discovery.upnp.get(ATTR_UPNP_FRIENDLY_NAME) or cast(str, urlparse(discovery.ssdp_location).hostname): discovery for discovery in discoveries } @@ -131,7 +139,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by SSDP discovery.""" if LOGGER.isEnabledFor(logging.DEBUG): @@ -267,7 +275,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=title, data=data, options=self._options) async def _async_set_info_from_discovery( - self, discovery_info: ssdp.SsdpServiceInfo, abort_if_configured: bool = True + self, discovery_info: SsdpServiceInfo, abort_if_configured: bool = True ) -> None: """Set information required for a config entry from the SSDP discovery.""" LOGGER.debug( @@ -285,7 +293,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN): self._device_type = discovery_info.ssdp_nt or discovery_info.ssdp_st self._name = ( - discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) or urlparse(self._location).hostname or DEFAULT_NAME ) @@ -301,12 +309,12 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN): updates[CONF_MAC] = self._mac self._abort_if_unique_id_configured(updates=updates, reload_on_update=False) - async def _async_get_discoveries(self) -> list[ssdp.SsdpServiceInfo]: + async def _async_get_discoveries(self) -> list[SsdpServiceInfo]: """Get list of unconfigured DLNA devices discovered by SSDP.""" LOGGER.debug("_get_discoveries") # Get all compatible devices from ssdp's cache - discoveries: list[ssdp.SsdpServiceInfo] = [] + discoveries: list[SsdpServiceInfo] = [] for udn_st in DmrDevice.DEVICE_TYPES: st_discoveries = await ssdp.async_get_discovery_info_by_st( self.hass, udn_st @@ -386,7 +394,7 @@ class DlnaDmrOptionsFlowHandler(OptionsFlow): ) -def _is_ignored_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: +def _is_ignored_device(discovery_info: SsdpServiceInfo) -> bool: """Return True if this device should be ignored for discovery. These devices are supported better by other integrations, so don't bug @@ -402,17 +410,14 @@ def _is_ignored_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: return True # Is the root device not a DMR? - if ( - discovery_info.upnp.get(ssdp.ATTR_UPNP_DEVICE_TYPE) - not in DmrDevice.DEVICE_TYPES - ): + if discovery_info.upnp.get(ATTR_UPNP_DEVICE_TYPE) not in DmrDevice.DEVICE_TYPES: return True # Special cases for devices with other discovery methods (e.g. mDNS), or # that advertise multiple unrelated (sent in separate discovery packets) # UPnP devices. - manufacturer = (discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) or "").lower() - model = (discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) or "").lower() + manufacturer = (discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER) or "").lower() + model = (discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or "").lower() if manufacturer.startswith("xbmc") or model == "kodi": # kodi @@ -432,14 +437,14 @@ def _is_ignored_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: return False -def _is_dmr_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: +def _is_dmr_device(discovery_info: SsdpServiceInfo) -> bool: """Determine if discovery is a complete DLNA DMR device. Use the discovery_info instead of DmrDevice.is_profile_device to avoid contacting the device again. """ # Abort if the device doesn't support all services required for a DmrDevice. - discovery_service_list = discovery_info.upnp.get(ssdp.ATTR_UPNP_SERVICE_LIST) + discovery_service_list = discovery_info.upnp.get(ATTR_UPNP_SERVICE_LIST) if not discovery_service_list: return False From 9f7a38f189027bc4d495a0ce127c41519ed4930a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 16 Jan 2025 13:48:24 +0100 Subject: [PATCH 0495/2987] Enable RUF022 (#135767) --- homeassistant/auth/permissions/__init__.py | 8 ++--- .../application_credentials/__init__.py | 2 +- .../components/assist_pipeline/__init__.py | 24 ++++++------- .../components/assist_satellite/__init__.py | 2 +- homeassistant/components/backup/__init__.py | 2 +- .../components/bluetooth/__init__.py | 36 +++++++++---------- homeassistant/components/event/__init__.py | 2 +- homeassistant/components/intent/__init__.py | 8 ++--- .../components/media_source/__init__.py | 20 +++++------ homeassistant/components/mqtt/__init__.py | 6 ++-- homeassistant/components/number/__init__.py | 2 +- homeassistant/components/ollama/__init__.py | 10 +++--- homeassistant/components/repairs/__init__.py | 4 +-- homeassistant/components/select/__init__.py | 6 ++-- homeassistant/components/sensor/__init__.py | 2 +- homeassistant/components/spotify/__init__.py | 4 +-- homeassistant/components/stream/__init__.py | 2 +- homeassistant/components/stt/__init__.py | 10 +++--- homeassistant/components/tts/__init__.py | 26 +++++++------- homeassistant/components/update/__init__.py | 2 +- homeassistant/components/usb/__init__.py | 2 +- homeassistant/components/voip/__init__.py | 2 +- .../components/wake_word/__init__.py | 6 ++-- homeassistant/util/ulid.py | 12 +++---- homeassistant/util/yaml/__init__.py | 10 +++--- pyproject.toml | 1 + tests/components/bluetooth/__init__.py | 8 ++--- 27 files changed, 110 insertions(+), 109 deletions(-) diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index 9c2c7e500ca..6498483a19a 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -17,12 +17,12 @@ POLICY_SCHEMA = vol.Schema({vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA}) __all__ = [ "POLICY_SCHEMA", - "merge_policies", - "PermissionLookup", - "PolicyType", "AbstractPermissions", - "PolicyPermissions", "OwnerPermissions", + "PermissionLookup", + "PolicyPermissions", + "PolicyType", + "merge_policies", ] diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 50b272cc1fa..58146818624 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -38,7 +38,7 @@ from homeassistant.loader import ( from homeassistant.util import slugify from homeassistant.util.hass_dict import HassKey -__all__ = ["ClientCredential", "AuthorizationServer", "async_import_client_credential"] +__all__ = ["AuthorizationServer", "ClientCredential", "async_import_client_credential"] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 851c873bb12..cc7ecc1c426 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -46,24 +46,24 @@ from .websocket_api import async_register_websocket_api __all__ = ( "DOMAIN", - "async_create_default_pipeline", - "async_get_pipelines", - "async_migrate_engine", - "async_setup", - "async_pipeline_from_audio_stream", - "async_update_pipeline", + "EVENT_RECORDING", + "OPTION_PREFERRED", + "SAMPLES_PER_CHUNK", + "SAMPLE_CHANNELS", + "SAMPLE_RATE", + "SAMPLE_WIDTH", "AudioSettings", "Pipeline", "PipelineEvent", "PipelineEventType", "PipelineNotFound", "WakeWordSettings", - "EVENT_RECORDING", - "OPTION_PREFERRED", - "SAMPLES_PER_CHUNK", - "SAMPLE_RATE", - "SAMPLE_WIDTH", - "SAMPLE_CHANNELS", + "async_create_default_pipeline", + "async_get_pipelines", + "async_migrate_engine", + "async_pipeline_from_audio_stream", + "async_setup", + "async_update_pipeline", ) CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index dd940e8cdbe..47b0123a244 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -30,8 +30,8 @@ from .websocket_api import async_register_websocket_api __all__ = [ "DOMAIN", "AssistSatelliteAnnouncement", - "AssistSatelliteEntity", "AssistSatelliteConfiguration", + "AssistSatelliteEntity", "AssistSatelliteEntityDescription", "AssistSatelliteEntityFeature", "AssistSatelliteWakeWord", diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 00b226a9fee..f3fe2246ad1 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -35,7 +35,6 @@ from .websocket import async_register_websocket_handlers __all__ = [ "AddonInfo", "AgentBackup", - "ManagerBackup", "BackupAgent", "BackupAgentError", "BackupAgentPlatformProtocol", @@ -46,6 +45,7 @@ __all__ = [ "Folder", "IncorrectPasswordError", "LocalBackupAgent", + "ManagerBackup", "NewBackup", "WrittenBackup", ] diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index ef89bef7ca1..8da4e9c61e0 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -93,9 +93,24 @@ if TYPE_CHECKING: from homeassistant.helpers.typing import ConfigType __all__ = [ + "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", + "MONOTONIC_TIME", + "SOURCE_LOCAL", + "BaseHaRemoteScanner", + "BaseHaScanner", + "BluetoothCallback", + "BluetoothCallbackMatcher", + "BluetoothChange", + "BluetoothScannerDevice", + "BluetoothScanningMode", + "BluetoothServiceInfo", + "BluetoothServiceInfoBleak", + "HaBluetoothConnector", + "HomeAssistantRemoteScanner", "async_address_present", "async_ble_device_from_address", "async_discovered_service_info", + "async_get_advertisement_callback", "async_get_fallback_availability_interval", "async_get_learned_advertising_interval", "async_get_scanner", @@ -104,27 +119,12 @@ __all__ = [ "async_rediscover_address", "async_register_callback", "async_register_scanner", - "async_set_fallback_availability_interval", - "async_track_unavailable", + "async_remove_scanner", "async_scanner_by_source", "async_scanner_count", "async_scanner_devices_by_address", - "async_get_advertisement_callback", - "async_remove_scanner", - "BaseHaScanner", - "HomeAssistantRemoteScanner", - "BluetoothCallbackMatcher", - "BluetoothChange", - "BluetoothServiceInfo", - "BluetoothServiceInfoBleak", - "BluetoothScanningMode", - "BluetoothCallback", - "BluetoothScannerDevice", - "HaBluetoothConnector", - "BaseHaRemoteScanner", - "SOURCE_LOCAL", - "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", - "MONOTONIC_TIME", + "async_set_fallback_availability_interval", + "async_track_unavailable", ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index c4a8fb2d0af..5bdf107f0c3 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -42,8 +42,8 @@ __all__ = [ "ATTR_EVENT_TYPE", "ATTR_EVENT_TYPES", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", "EventDeviceClass", "EventEntity", "EventEntityDescription", diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 71ef40ad369..a1451f8fcca 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -65,11 +65,11 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) __all__ = [ - "async_register_timer_handler", - "async_device_supports_timers", - "TimerInfo", - "TimerEventType", "DOMAIN", + "TimerEventType", + "TimerInfo", + "async_device_supports_timers", + "async_register_timer_handler", ] ONOFF_DEVICE_CLASSES = { diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 3ea8f581245..5c6165a3477 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -38,18 +38,18 @@ from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia __all__ = [ "DOMAIN", - "is_media_source_id", - "generate_media_source_id", - "async_browse_media", - "async_resolve_media", - "BrowseMediaSource", - "PlayMedia", - "MediaSourceItem", - "Unresolvable", - "MediaSource", - "MediaSourceError", "MEDIA_CLASS_MAP", "MEDIA_MIME_TYPES", + "BrowseMediaSource", + "MediaSource", + "MediaSourceError", + "MediaSourceItem", + "PlayMedia", + "Unresolvable", + "async_browse_media", + "async_resolve_media", + "generate_media_source_id", + "is_media_source_id", ] diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index b494b636916..8b16e9fa53d 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -143,21 +143,21 @@ __all__ = [ "DOMAIN", "ENTITY_PLATFORMS", "ENTRY_OPTION_FIELDS", - "EntitySubscription", "MQTT", "MQTT_BASE_SCHEMA", "MQTT_CONNECTION_STATE", "MQTT_RO_SCHEMA", "MQTT_RW_SCHEMA", + "SERVICE_RELOAD", + "TEMPLATE_ERRORS", + "EntitySubscription", "MqttCommandTemplate", "MqttData", "MqttValueTemplate", "PayloadSentinel", "PublishPayloadType", "ReceiveMessage", - "SERVICE_RELOAD", "SetupPhases", - "TEMPLATE_ERRORS", "async_check_config_schema", "async_create_certificate_temp_files", "async_forward_entry_setup_and_setup_discovery", diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 9f4aef08aa9..2f5ebcdb44c 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -68,8 +68,8 @@ __all__ = [ "DEFAULT_MIN_VALUE", "DEFAULT_STEP", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", "NumberDeviceClass", "NumberEntity", "NumberEntityDescription", diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 3bcba567803..6983db73cf4 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -28,12 +28,12 @@ from .const import ( _LOGGER = logging.getLogger(__name__) __all__ = [ - "CONF_URL", - "CONF_PROMPT", - "CONF_MODEL", - "CONF_MAX_HISTORY", - "CONF_NUM_CTX", "CONF_KEEP_ALIVE", + "CONF_MAX_HISTORY", + "CONF_MODEL", + "CONF_NUM_CTX", + "CONF_PROMPT", + "CONF_URL", "DOMAIN", ] diff --git a/homeassistant/components/repairs/__init__.py b/homeassistant/components/repairs/__init__.py index 8d3fc429ce0..8ee09c9ed3d 100644 --- a/homeassistant/components/repairs/__init__.py +++ b/homeassistant/components/repairs/__init__.py @@ -12,11 +12,11 @@ from .issue_handler import ConfirmRepairFlow, RepairsFlowManager from .models import RepairsFlow __all__ = [ - "ConfirmRepairFlow", "DOMAIN", - "repairs_flow_manager", + "ConfirmRepairFlow", "RepairsFlow", "RepairsFlowManager", + "repairs_flow_manager", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 3834dc4a0c7..592b746198e 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -45,15 +45,15 @@ __all__ = [ "ATTR_OPTION", "ATTR_OPTIONS", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", - "SelectEntity", - "SelectEntityDescription", + "PLATFORM_SCHEMA_BASE", "SERVICE_SELECT_FIRST", "SERVICE_SELECT_LAST", "SERVICE_SELECT_NEXT", "SERVICE_SELECT_OPTION", "SERVICE_SELECT_PREVIOUS", + "SelectEntity", + "SelectEntityDescription", ] # mypy: disallow-any-generics diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 2933d779b4b..37df50b2099 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -67,8 +67,8 @@ __all__ = [ "CONF_STATE_CLASS", "DEVICE_CLASS_STATE_CLASSES", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", "RestoreSensor", "SensorDeviceClass", "SensorEntity", diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py index 37580ac432d..663b3f30caa 100644 --- a/homeassistant/components/spotify/__init__.py +++ b/homeassistant/components/spotify/__init__.py @@ -32,11 +32,11 @@ from .util import ( PLATFORMS = [Platform.MEDIA_PLAYER] __all__ = [ - "async_browse_media", "DOMAIN", - "spotify_uri_from_media_browser_url", + "async_browse_media", "is_spotify_media_type", "resolve_spotify_media_type", + "spotify_uri_from_media_browser_url", ] diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 8692a2acaad..2772fc2d30e 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -90,11 +90,11 @@ __all__ = [ "OUTPUT_FORMATS", "RTSP_TRANSPORTS", "SOURCE_TIMEOUT", + "Orientation", "Stream", "StreamClientError", "StreamOpenClientError", "create_stream", - "Orientation", ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index d3c85aba1e7..25ed29d3071 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -49,20 +49,20 @@ from .legacy import ( from .models import SpeechMetadata, SpeechResult __all__ = [ - "async_get_provider", - "async_get_speech_to_text_engine", - "async_get_speech_to_text_entity", + "DOMAIN", "AudioBitRates", "AudioChannels", "AudioCodecs", "AudioFormats", "AudioSampleRates", - "DOMAIN", "Provider", - "SpeechToTextEntity", "SpeechMetadata", "SpeechResult", "SpeechResultState", + "SpeechToTextEntity", + "async_get_provider", + "async_get_speech_to_text_engine", + "async_get_speech_to_text_entity", ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index e7d1091719b..80c175ccfe4 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -73,23 +73,23 @@ from .media_source import generate_media_source_id, media_source_id_to_kwargs from .models import Voice __all__ = [ + "ATTR_AUDIO_OUTPUT", + "ATTR_PREFERRED_FORMAT", + "ATTR_PREFERRED_SAMPLE_BYTES", + "ATTR_PREFERRED_SAMPLE_CHANNELS", + "ATTR_PREFERRED_SAMPLE_RATE", + "CONF_LANG", + "DEFAULT_CACHE_DIR", + "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", + "Provider", + "SampleFormat", + "TtsAudioType", + "Voice", "async_default_engine", "async_get_media_source_audio", "async_support_options", - "ATTR_AUDIO_OUTPUT", - "ATTR_PREFERRED_FORMAT", - "ATTR_PREFERRED_SAMPLE_RATE", - "ATTR_PREFERRED_SAMPLE_CHANNELS", - "ATTR_PREFERRED_SAMPLE_BYTES", - "CONF_LANG", - "DEFAULT_CACHE_DIR", "generate_media_source_id", - "PLATFORM_SCHEMA_BASE", - "PLATFORM_SCHEMA", - "SampleFormat", - "Provider", - "TtsAudioType", - "Voice", ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 8ef9f44237f..a2ecd494920 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -68,8 +68,8 @@ __all__ = [ "ATTR_VERSION", "DEVICE_CLASSES_SCHEMA", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", "SERVICE_INSTALL", "SERVICE_SKIP", "UpdateDeviceClass", diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 7eaac7d71e8..a4bbe2dcf78 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -49,9 +49,9 @@ _LOGGER = logging.getLogger(__name__) REQUEST_SCAN_COOLDOWN = 10 # 10 second cooldown __all__ = [ + "USBCallbackMatcher", "async_is_plugged_in", "async_register_scan_request_callback", - "USBCallbackMatcher", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/voip/__init__.py b/homeassistant/components/voip/__init__.py index cee0cbb0766..96e758e91f4 100644 --- a/homeassistant/components/voip/__init__.py +++ b/homeassistant/components/voip/__init__.py @@ -30,9 +30,9 @@ _IP_WILDCARD = "0.0.0.0" __all__ = [ "DOMAIN", + "async_remove_config_entry_device", "async_setup_entry", "async_unload_entry", - "async_remove_config_entry_device", ] diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index 8b3a5bbf331..65556668bac 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -25,12 +25,12 @@ from .const import DOMAIN from .models import DetectionResult, WakeWord __all__ = [ - "async_default_entity", - "async_get_wake_word_detection_entity", - "DetectionResult", "DOMAIN", + "DetectionResult", "WakeWord", "WakeWordDetectionEntity", + "async_default_entity", + "async_get_wake_word_detection_entity", ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/util/ulid.py b/homeassistant/util/ulid.py index f4895f9d963..ba0c466969c 100644 --- a/homeassistant/util/ulid.py +++ b/homeassistant/util/ulid.py @@ -13,14 +13,14 @@ from ulid_transform import ( ) __all__ = [ - "ulid", - "ulid_hex", - "ulid_at_time", - "ulid_to_bytes", "bytes_to_ulid", - "ulid_now", - "ulid_to_bytes_or_none", "bytes_to_ulid_or_none", + "ulid", + "ulid_at_time", + "ulid_hex", + "ulid_now", + "ulid_to_bytes", + "ulid_to_bytes_or_none", ] diff --git a/homeassistant/util/yaml/__init__.py b/homeassistant/util/yaml/__init__.py index cf90b223cb6..3b1f5c4cc0a 100644 --- a/homeassistant/util/yaml/__init__.py +++ b/homeassistant/util/yaml/__init__.py @@ -16,15 +16,15 @@ from .objects import Input __all__ = [ "SECRET_YAML", "Input", - "dump", - "save_yaml", "Secrets", + "UndefinedSubstitution", "YamlTypeError", + "dump", + "extract_inputs", "load_yaml", "load_yaml_dict", - "secret_yaml", "parse_yaml", - "UndefinedSubstitution", - "extract_inputs", + "save_yaml", + "secret_yaml", "substitute", ] diff --git a/pyproject.toml b/pyproject.toml index 406fbe6cf25..26224fc3b63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -759,6 +759,7 @@ select = [ "RUF017", # Avoid quadratic list summation "RUF018", # Avoid assignment expressions in assert statements "RUF019", # Unnecessary key check before dictionary access + "RUF022", # Sort __all__ "RUF100", # Unused `noqa` directive "S102", # Use of exec detected "S103", # bad-file-permissions diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 8794d808718..a9213de34ff 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -25,17 +25,17 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry __all__ = ( + "MockBleakClient", + "generate_advertisement_data", + "generate_ble_device", "inject_advertisement", "inject_advertisement_with_source", "inject_advertisement_with_time_and_source", "inject_advertisement_with_time_and_source_connectable", "inject_bluetooth_service_info", "patch_all_discovered_devices", - "patch_discovered_devices", - "generate_advertisement_data", - "generate_ble_device", - "MockBleakClient", "patch_bluetooth_time", + "patch_discovered_devices", ) ADVERTISEMENT_DATA_DEFAULTS = { From 5cf56207fe0345d4965e8fcece99b00e9ff0050d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 16 Jan 2025 08:25:26 -0500 Subject: [PATCH 0496/2987] Add temperature and humidity entities to area registry (#135423) * Add temperature and humidity entities to area registry * Fix service test * Add validation * ABC * More ABC * More ABC 2 * Fix tests * ABC 3 * ABC 4 --- .../components/config/area_registry.py | 4 + homeassistant/helpers/area_registry.py | 78 +++++++++++- tests/components/config/test_area_registry.py | 68 +++++++++-- tests/helpers/test_area_registry.py | 113 +++++++++++++++++- tests/helpers/test_service.py | 8 ++ 5 files changed, 258 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index c8cc9242ea4..b2a590928c1 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -41,10 +41,12 @@ def websocket_list_areas( vol.Required("type"): "config/area_registry/create", vol.Optional("aliases"): list, vol.Optional("floor_id"): str, + vol.Optional("humidity_entity_id"): vol.Any(str, None), vol.Optional("icon"): str, vol.Optional("labels"): [str], vol.Required("name"): str, vol.Optional("picture"): vol.Any(str, None), + vol.Optional("temperature_entity_id"): vol.Any(str, None), } ) @websocket_api.require_admin @@ -107,10 +109,12 @@ def websocket_delete_area( vol.Optional("aliases"): list, vol.Required("area_id"): str, vol.Optional("floor_id"): vol.Any(str, None), + vol.Optional("humidity_entity_id"): vol.Any(str, None), vol.Optional("icon"): vol.Any(str, None), vol.Optional("labels"): [str], vol.Optional("name"): str, vol.Optional("picture"): vol.Any(str, None), + vol.Optional("temperature_entity_id"): vol.Any(str, None), } ) @websocket_api.require_admin diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index f74296a9fb1..9c75af7262d 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, field from datetime import datetime from typing import TYPE_CHECKING, Any, Literal, TypedDict +from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant, callback from homeassistant.util.dt import utc_from_timestamp, utcnow from homeassistant.util.event_type import EventType @@ -38,7 +39,7 @@ EVENT_AREA_REGISTRY_UPDATED: EventType[EventAreaRegistryUpdatedData] = EventType ) STORAGE_KEY = "core.area_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 7 +STORAGE_VERSION_MINOR = 8 class _AreaStoreData(TypedDict): @@ -46,11 +47,13 @@ class _AreaStoreData(TypedDict): aliases: list[str] floor_id: str | None + humidity_entity_id: str | None icon: str | None id: str labels: list[str] name: str picture: str | None + temperature_entity_id: str | None created_at: str modified_at: str @@ -74,10 +77,12 @@ class AreaEntry(NormalizedNameBaseRegistryEntry): aliases: set[str] floor_id: str | None + humidity_entity_id: str | None icon: str | None id: str labels: set[str] = field(default_factory=set) picture: str | None + temperature_entity_id: str | None _cache: dict[str, Any] = field(default_factory=dict, compare=False, init=False) @under_cached_property @@ -89,10 +94,12 @@ class AreaEntry(NormalizedNameBaseRegistryEntry): "aliases": list(self.aliases), "area_id": self.id, "floor_id": self.floor_id, + "humidity_entity_id": self.humidity_entity_id, "icon": self.icon, "labels": list(self.labels), "name": self.name, "picture": self.picture, + "temperature_entity_id": self.temperature_entity_id, "created_at": self.created_at.timestamp(), "modified_at": self.modified_at.timestamp(), } @@ -138,11 +145,17 @@ class AreaRegistryStore(Store[AreasRegistryStoreData]): area["labels"] = [] if old_minor_version < 7: - # Version 1.7 adds created_at and modiefied_at + # Version 1.7 adds created_at and modified_at created_at = utc_from_timestamp(0).isoformat() for area in old_data["areas"]: area["created_at"] = area["modified_at"] = created_at + if old_minor_version < 8: + # Version 1.8 adds humidity_entity_id and temperature_entity_id + for area in old_data["areas"]: + area["humidity_entity_id"] = None + area["temperature_entity_id"] = None + if old_major_version > 1: raise NotImplementedError return old_data # type: ignore[return-value] @@ -242,11 +255,14 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): *, aliases: set[str] | None = None, floor_id: str | None = None, + humidity_entity_id: str | None = None, icon: str | None = None, labels: set[str] | None = None, picture: str | None = None, + temperature_entity_id: str | None = None, ) -> AreaEntry: """Create a new area.""" + self.hass.verify_event_loop_thread("area_registry.async_create") if area := self.async_get_area_by_name(name): @@ -254,14 +270,22 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): f"The name {name} ({area.normalized_name}) is already in use" ) + if humidity_entity_id is not None: + _validate_humidity_entity(self.hass, humidity_entity_id) + + if temperature_entity_id is not None: + _validate_temperature_entity(self.hass, temperature_entity_id) + area = AreaEntry( aliases=aliases or set(), floor_id=floor_id, + humidity_entity_id=humidity_entity_id, icon=icon, id=self._generate_id(name), labels=labels or set(), name=name, picture=picture, + temperature_entity_id=temperature_entity_id, ) area_id = area.id self.areas[area_id] = area @@ -298,20 +322,24 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): *, aliases: set[str] | UndefinedType = UNDEFINED, floor_id: str | None | UndefinedType = UNDEFINED, + humidity_entity_id: str | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, labels: set[str] | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED, + temperature_entity_id: str | None | UndefinedType = UNDEFINED, ) -> AreaEntry: """Update name of area.""" updated = self._async_update( area_id, aliases=aliases, floor_id=floor_id, + humidity_entity_id=humidity_entity_id, icon=icon, labels=labels, name=name, picture=picture, + temperature_entity_id=temperature_entity_id, ) # Since updated may be the old or the new and we always fire # an event even if nothing has changed we cannot use async_fire_internal @@ -330,10 +358,12 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): *, aliases: set[str] | UndefinedType = UNDEFINED, floor_id: str | None | UndefinedType = UNDEFINED, + humidity_entity_id: str | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, labels: set[str] | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED, + temperature_entity_id: str | None | UndefinedType = UNDEFINED, ) -> AreaEntry: """Update name of area.""" old = self.areas[area_id] @@ -342,14 +372,22 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): attr_name: value for attr_name, value in ( ("aliases", aliases), + ("floor_id", floor_id), + ("humidity_entity_id", humidity_entity_id), ("icon", icon), ("labels", labels), ("picture", picture), - ("floor_id", floor_id), + ("temperature_entity_id", temperature_entity_id), ) if value is not UNDEFINED and value != getattr(old, attr_name) } + if "humidity_entity_id" in new_values and humidity_entity_id is not None: + _validate_humidity_entity(self.hass, new_values["humidity_entity_id"]) + + if "temperature_entity_id" in new_values and temperature_entity_id is not None: + _validate_temperature_entity(self.hass, new_values["temperature_entity_id"]) + if name is not UNDEFINED and name != old.name: new_values["name"] = name @@ -378,11 +416,13 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): areas[area["id"]] = AreaEntry( aliases=set(area["aliases"]), floor_id=area["floor_id"], + humidity_entity_id=area["humidity_entity_id"], icon=area["icon"], id=area["id"], labels=set(area["labels"]), name=area["name"], picture=area["picture"], + temperature_entity_id=area["temperature_entity_id"], created_at=datetime.fromisoformat(area["created_at"]), modified_at=datetime.fromisoformat(area["modified_at"]), ) @@ -398,11 +438,13 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): { "aliases": list(entry.aliases), "floor_id": entry.floor_id, + "humidity_entity_id": entry.humidity_entity_id, "icon": entry.icon, "id": entry.id, "labels": list(entry.labels), "name": entry.name, "picture": entry.picture, + "temperature_entity_id": entry.temperature_entity_id, "created_at": entry.created_at.isoformat(), "modified_at": entry.modified_at.isoformat(), } @@ -477,3 +519,33 @@ def async_entries_for_floor(registry: AreaRegistry, floor_id: str) -> list[AreaE def async_entries_for_label(registry: AreaRegistry, label_id: str) -> list[AreaEntry]: """Return entries that match a label.""" return registry.areas.get_areas_for_label(label_id) + + +def _validate_temperature_entity(hass: HomeAssistant, entity_id: str) -> None: + """Validate temperature entity.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components.sensor import SensorDeviceClass + + if not (state := hass.states.get(entity_id)): + raise ValueError(f"Entity {entity_id} does not exist") + + if ( + state.domain != "sensor" + or state.attributes.get(ATTR_DEVICE_CLASS) != SensorDeviceClass.TEMPERATURE + ): + raise ValueError(f"Entity {entity_id} is not a temperature sensor") + + +def _validate_humidity_entity(hass: HomeAssistant, entity_id: str) -> None: + """Validate humidity entity.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components.sensor import SensorDeviceClass + + if not (state := hass.states.get(entity_id)): + raise ValueError(f"Entity {entity_id} does not exist") + + if ( + state.domain != "sensor" + or state.attributes.get(ATTR_DEVICE_CLASS) != SensorDeviceClass.HUMIDITY + ): + raise ValueError(f"Entity {entity_id} is not a humidity sensor") diff --git a/tests/components/config/test_area_registry.py b/tests/components/config/test_area_registry.py index 03a8272e586..81c696bc6a7 100644 --- a/tests/components/config/test_area_registry.py +++ b/tests/components/config/test_area_registry.py @@ -7,6 +7,13 @@ import pytest from pytest_unordered import unordered from homeassistant.components.config import area_registry +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar from homeassistant.util.dt import utcnow @@ -24,10 +31,32 @@ async def client_fixture( return await hass_ws_client(hass) +@pytest.fixture +async def mock_temperature_humidity_entity(hass: HomeAssistant) -> None: + """Mock temperature and humidity sensors.""" + hass.states.async_set( + "sensor.mock_temperature", + "20", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + hass.states.async_set( + "sensor.mock_humidity", + "50", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + }, + ) + + async def test_list_areas( client: MockHAClientWebSocket, area_registry: ar.AreaRegistry, freezer: FrozenDateTimeFactory, + mock_temperature_humidity_entity: None, ) -> None: """Test list entries.""" created_area1 = datetime.fromisoformat("2024-07-16T13:30:00.900075+00:00") @@ -39,10 +68,12 @@ async def test_list_areas( area2 = area_registry.async_create( "mock 2", aliases={"alias_1", "alias_2"}, - icon="mdi:garage", - picture="/image/example.png", floor_id="first_floor", + humidity_entity_id="sensor.mock_humidity", + icon="mdi:garage", labels={"label_1", "label_2"}, + picture="/image/example.png", + temperature_entity_id="sensor.mock_temperature", ) await client.send_json_auto_id({"type": "config/area_registry/list"}) @@ -52,24 +83,28 @@ async def test_list_areas( { "aliases": [], "area_id": area1.id, + "created_at": created_area1.timestamp(), "floor_id": None, + "humidity_entity_id": None, "icon": None, "labels": [], + "modified_at": created_area1.timestamp(), "name": "mock 1", "picture": None, - "created_at": created_area1.timestamp(), - "modified_at": created_area1.timestamp(), + "temperature_entity_id": None, }, { "aliases": unordered(["alias_1", "alias_2"]), "area_id": area2.id, + "created_at": created_area2.timestamp(), "floor_id": "first_floor", + "humidity_entity_id": "sensor.mock_humidity", "icon": "mdi:garage", "labels": unordered(["label_1", "label_2"]), + "modified_at": created_area2.timestamp(), "name": "mock 2", "picture": "/image/example.png", - "created_at": created_area2.timestamp(), - "modified_at": created_area2.timestamp(), + "temperature_entity_id": "sensor.mock_temperature", }, ] @@ -78,6 +113,7 @@ async def test_create_area( client: MockHAClientWebSocket, area_registry: ar.AreaRegistry, freezer: FrozenDateTimeFactory, + mock_temperature_humidity_entity: None, ) -> None: """Test create entry.""" # Create area with only mandatory parameters @@ -97,6 +133,8 @@ async def test_create_area( "picture": None, "created_at": utcnow().timestamp(), "modified_at": utcnow().timestamp(), + "temperature_entity_id": None, + "humidity_entity_id": None, } assert len(area_registry.areas) == 1 @@ -109,12 +147,15 @@ async def test_create_area( "labels": ["label_1", "label_2"], "name": "mock 2", "picture": "/image/example.png", + "temperature_entity_id": "sensor.mock_temperature", + "humidity_entity_id": "sensor.mock_humidity", "type": "config/area_registry/create", } ) msg = await client.receive_json() + assert msg["success"] assert msg["result"] == { "aliases": unordered(["alias_1", "alias_2"]), "area_id": ANY, @@ -125,6 +166,8 @@ async def test_create_area( "picture": "/image/example.png", "created_at": utcnow().timestamp(), "modified_at": utcnow().timestamp(), + "temperature_entity_id": "sensor.mock_temperature", + "humidity_entity_id": "sensor.mock_humidity", } assert len(area_registry.areas) == 2 @@ -185,6 +228,7 @@ async def test_update_area( client: MockHAClientWebSocket, area_registry: ar.AreaRegistry, freezer: FrozenDateTimeFactory, + mock_temperature_humidity_entity: None, ) -> None: """Test update entry.""" created_at = datetime.fromisoformat("2024-07-16T13:30:00.900075+00:00") @@ -195,14 +239,16 @@ async def test_update_area( await client.send_json_auto_id( { + "type": "config/area_registry/update", "aliases": ["alias_1", "alias_2"], "area_id": area.id, "floor_id": "first_floor", + "humidity_entity_id": "sensor.mock_humidity", "icon": "mdi:garage", "labels": ["label_1", "label_2"], "name": "mock 2", "picture": "/image/example.png", - "type": "config/area_registry/update", + "temperature_entity_id": "sensor.mock_temperature", } ) @@ -212,10 +258,12 @@ async def test_update_area( "aliases": unordered(["alias_1", "alias_2"]), "area_id": area.id, "floor_id": "first_floor", + "humidity_entity_id": "sensor.mock_humidity", "icon": "mdi:garage", "labels": unordered(["label_1", "label_2"]), "name": "mock 2", "picture": "/image/example.png", + "temperature_entity_id": "sensor.mock_temperature", "created_at": created_at.timestamp(), "modified_at": modified_at.timestamp(), } @@ -226,13 +274,15 @@ async def test_update_area( await client.send_json_auto_id( { + "type": "config/area_registry/update", "aliases": ["alias_1", "alias_1"], "area_id": area.id, "floor_id": None, + "humidity_entity_id": None, "icon": None, "labels": [], "picture": None, - "type": "config/area_registry/update", + "temperature_entity_id": None, } ) @@ -246,6 +296,8 @@ async def test_update_area( "labels": [], "name": "mock 2", "picture": None, + "temperature_entity_id": None, + "humidity_entity_id": None, "created_at": created_at.timestamp(), "modified_at": modified_at.timestamp(), } diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index 74f55c86a6c..c69f039027e 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -7,6 +7,13 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import ( area_registry as ar, @@ -18,6 +25,27 @@ from homeassistant.util.dt import utcnow from tests.common import ANY, async_capture_events, flush_store +@pytest.fixture +async def mock_temperature_humidity_entity(hass: HomeAssistant) -> None: + """Mock temperature and humidity sensors.""" + hass.states.async_set( + "sensor.mock_temperature", + "20", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + hass.states.async_set( + "sensor.mock_humidity", + "50", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + }, + ) + + async def test_list_areas(area_registry: ar.AreaRegistry) -> None: """Make sure that we can read areas.""" area_registry.async_create("mock") @@ -31,6 +59,7 @@ async def test_create_area( hass: HomeAssistant, freezer: FrozenDateTimeFactory, area_registry: ar.AreaRegistry, + mock_temperature_humidity_entity: None, ) -> None: """Make sure that we can create an area.""" update_events = async_capture_events(hass, ar.EVENT_AREA_REGISTRY_UPDATED) @@ -48,6 +77,8 @@ async def test_create_area( picture=None, created_at=utcnow(), modified_at=utcnow(), + temperature_entity_id=None, + humidity_entity_id=None, ) assert len(area_registry.areas) == 1 @@ -67,6 +98,8 @@ async def test_create_area( aliases={"alias_1", "alias_2"}, labels={"label1", "label2"}, picture="/image/example.png", + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", ) assert area2 == ar.AreaEntry( @@ -79,6 +112,8 @@ async def test_create_area( picture="/image/example.png", created_at=utcnow(), modified_at=utcnow(), + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", ) assert len(area_registry.areas) == 2 assert area.created_at != area2.created_at @@ -164,6 +199,7 @@ async def test_update_area( floor_registry: fr.FloorRegistry, label_registry: lr.LabelRegistry, freezer: FrozenDateTimeFactory, + mock_temperature_humidity_entity: None, ) -> None: """Make sure that we can read areas.""" created_at = datetime.fromisoformat("2024-01-01T01:00:00+00:00") @@ -184,6 +220,8 @@ async def test_update_area( labels={"label1", "label2"}, name="mock1", picture="/image/example.png", + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", ) assert updated_area != area @@ -197,6 +235,8 @@ async def test_update_area( picture="/image/example.png", created_at=created_at, modified_at=modified_at, + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", ) assert len(area_registry.areas) == 1 @@ -274,6 +314,55 @@ async def test_update_area_with_normalized_name_already_in_use( assert len(area_registry.areas) == 2 +@pytest.mark.parametrize( + ("create_kwargs", "error_message"), + [ + ( + {"temperature_entity_id": "sensor.invalid"}, + "Entity sensor.invalid does not exist", + ), + ( + {"temperature_entity_id": "light.kitchen"}, + "Entity light.kitchen is not a temperature sensor", + ), + ( + {"temperature_entity_id": "sensor.random"}, + "Entity sensor.random is not a temperature sensor", + ), + ( + {"humidity_entity_id": "sensor.invalid"}, + "Entity sensor.invalid does not exist", + ), + ( + {"humidity_entity_id": "light.kitchen"}, + "Entity light.kitchen is not a humidity sensor", + ), + ( + {"humidity_entity_id": "sensor.random"}, + "Entity sensor.random is not a humidity sensor", + ), + ], +) +async def test_update_area_entity_validation( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + mock_temperature_humidity_entity: None, + create_kwargs: dict[str, Any], + error_message: str, +) -> None: + """Make sure that we can't update an area with an invalid entity.""" + area = area_registry.async_create("mock") + hass.states.async_set("light.kitchen", "on", {}) + hass.states.async_set("sensor.random", "3", {}) + + with pytest.raises(ValueError) as e_info: + area_registry.async_update(area.id, **create_kwargs) + assert str(e_info.value) == error_message + + assert area.temperature_entity_id is None + assert area.humidity_entity_id is None + + async def test_load_area(hass: HomeAssistant, area_registry: ar.AreaRegistry) -> None: """Make sure that we can load/save data correctly.""" area1 = area_registry.async_create("mock1") @@ -298,6 +387,8 @@ async def test_loading_area_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test loading stored areas on start.""" + created_at = datetime.fromisoformat("2024-01-01T01:00:00+00:00") + modified_at = datetime.fromisoformat("2024-02-01T01:00:00+00:00") hass_storage[ar.STORAGE_KEY] = { "version": ar.STORAGE_VERSION_MAJOR, "minor_version": ar.STORAGE_VERSION_MINOR, @@ -311,8 +402,10 @@ async def test_loading_area_from_storage( "labels": ["mock-label1", "mock-label2"], "name": "mock", "picture": "blah", - "created_at": utcnow().isoformat(), - "modified_at": utcnow().isoformat(), + "created_at": created_at.isoformat(), + "modified_at": modified_at.isoformat(), + "temperature_entity_id": "sensor.mock_temperature", + "humidity_entity_id": "sensor.mock_humidity", } ] }, @@ -322,6 +415,20 @@ async def test_loading_area_from_storage( registry = ar.async_get(hass) assert len(registry.areas) == 1 + area = registry.areas["12345A"] + assert area == ar.AreaEntry( + aliases={"alias_1", "alias_2"}, + floor_id="first_floor", + icon="mdi:garage", + id="12345A", + labels={"mock-label1", "mock-label2"}, + name="mock", + picture="blah", + created_at=created_at, + modified_at=modified_at, + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", + ) @pytest.mark.parametrize("load_registries", [False]) @@ -359,6 +466,8 @@ async def test_migration_from_1_1( "picture": None, "created_at": "1970-01-01T00:00:00+00:00", "modified_at": "1970-01-01T00:00:00+00:00", + "temperature_entity_id": None, + "humidity_entity_id": None, } ] }, diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 6d03e09cdf7..f802d6ffa5a 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -122,6 +122,8 @@ def floor_area_mock(hass: HomeAssistant) -> None: floor_id="test-floor", icon=None, picture=None, + temperature_entity_id=None, + humidity_entity_id=None, ) area_in_floor_a = ar.AreaEntry( id="area-a", @@ -130,6 +132,8 @@ def floor_area_mock(hass: HomeAssistant) -> None: floor_id="floor-a", icon=None, picture=None, + temperature_entity_id=None, + humidity_entity_id=None, ) mock_area_registry( hass, @@ -284,6 +288,8 @@ def label_mock(hass: HomeAssistant) -> None: icon=None, labels={"label_area"}, picture=None, + temperature_entity_id=None, + humidity_entity_id=None, ) area_without_labels = ar.AreaEntry( id="area-no-labels", @@ -293,6 +299,8 @@ def label_mock(hass: HomeAssistant) -> None: icon=None, labels=set(), picture=None, + temperature_entity_id=None, + humidity_entity_id=None, ) mock_area_registry( hass, From eb98f110d3596e4b45443710924e9528e57d0f0a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 16 Jan 2025 14:41:24 +0100 Subject: [PATCH 0497/2987] Fix Vicare patch (#135773) --- tests/components/vicare/test_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/vicare/test_sensor.py b/tests/components/vicare/test_sensor.py index 2356b92f7a7..9b8b69f29db 100644 --- a/tests/components/vicare/test_sensor.py +++ b/tests/components/vicare/test_sensor.py @@ -45,7 +45,7 @@ async def test_all_ventilation_entities( """Test all entities.""" fixtures: list[Fixture] = [Fixture({"type:ventilation"}, "vicare/ViAir300F.json")] with ( - patch(f"{MODULE}.vicare_login", return_value=MockPyViCare(fixtures)), + patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.SENSOR]), ): await setup_integration(hass, mock_config_entry) From 2e189480a59634143df0a7b0b907add323a31bdb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Jan 2025 15:07:13 +0100 Subject: [PATCH 0498/2987] Improve backup decrypt exceptions (#135765) --- homeassistant/components/backup/util.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index ac1525b7d69..e5acf974012 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -17,27 +17,36 @@ from securetar import SecureTarError, SecureTarFile, SecureTarReadError from homeassistant.backup_restore import password_to_key from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.util.json import JsonObjectType, json_loads_object from .const import BUF_SIZE, LOGGER from .models import AddonInfo, AgentBackup, Folder -class DecryptError(Exception): +class DecryptError(HomeAssistantError): """Error during decryption.""" + _message = "Unexpected error during decryption." + class UnsupportedSecureTarVersion(DecryptError): """Unsupported securetar version.""" + _message = "Unsupported securetar version." + class IncorrectPassword(DecryptError): """Invalid password or corrupted backup.""" + _message = "Invalid password or corrupted backup." + class BackupEmpty(DecryptError): """No tar files found in the backup.""" + _message = "No tar files found in the backup." + def make_backup_dir(path: Path) -> None: """Create a backup directory if it does not exist.""" From 5ca68cb273f26db8ee8d95c938e4161f8fb9264c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 16 Jan 2025 15:24:40 +0100 Subject: [PATCH 0499/2987] Improve incomfort coordinator logging (#135777) --- homeassistant/components/incomfort/coordinator.py | 4 ++-- tests/components/incomfort/test_init.py | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/incomfort/coordinator.py b/homeassistant/components/incomfort/coordinator.py index a5c8da0c208..20cc8e7cc69 100644 --- a/homeassistant/components/incomfort/coordinator.py +++ b/homeassistant/components/incomfort/coordinator.py @@ -66,10 +66,10 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]): for heater in self.incomfort_data.heaters: await heater.update() except TimeoutError as exc: - raise UpdateFailed from exc + raise UpdateFailed("Timeout error") from exc except IncomfortError as exc: if isinstance(exc.message, ClientResponseError): if exc.message.status == 401: raise ConfigEntryError("Incorrect credentials") from exc - raise UpdateFailed from exc + raise UpdateFailed(exc.message) from exc return self.incomfort_data diff --git a/tests/components/incomfort/test_init.py b/tests/components/incomfort/test_init.py index 0390a47a616..504ab02ea81 100644 --- a/tests/components/incomfort/test_init.py +++ b/tests/components/incomfort/test_init.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import MagicMock, patch -from aiohttp import ClientResponseError +from aiohttp import ClientResponseError, RequestInfo from freezegun.api import FrozenDateTimeFactory from incomfortclient import IncomfortError import pytest @@ -63,7 +63,18 @@ async def test_coordinator_updates( "exc", [ IncomfortError(ClientResponseError(None, None, status=401)), - IncomfortError(ClientResponseError(None, None, status=500)), + IncomfortError( + ClientResponseError( + RequestInfo( + url="http://example.com", + method="GET", + headers=[], + real_url="http://example.com", + ), + None, + status=500, + ) + ), IncomfortError(ValueError("some_error")), TimeoutError, ], From 55bde60f1a5f3d7fd9e06b34a420224c734ac131 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Thu, 16 Jan 2025 15:44:59 +0100 Subject: [PATCH 0500/2987] Move HomeWizard config options to class (#135778) --- .../components/homewizard/config_flow.py | 53 +++++++++---------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index 71ff9df5443..fe78385381c 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, NamedTuple +from typing import Any from homewizard_energy import HomeWizardEnergyV1 from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError @@ -29,21 +29,15 @@ from .const import ( ) -class DiscoveryData(NamedTuple): - """User metadata.""" - - ip: str - product_name: str - product_type: str - serial: str - - class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for P1 meter.""" VERSION = 1 - discovery: DiscoveryData + ip_address: str | None = None + product_name: str | None = None + product_type: str | None = None + serial: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -95,16 +89,12 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): if (discovery_info.properties[CONF_PATH]) != "/api/v1": return self.async_abort(reason="unsupported_api_version") - self.discovery = DiscoveryData( - ip=discovery_info.host, - product_type=discovery_info.properties[CONF_PRODUCT_TYPE], - product_name=discovery_info.properties[CONF_PRODUCT_NAME], - serial=discovery_info.properties[CONF_SERIAL], - ) + self.ip_address = discovery_info.host + self.product_type = discovery_info.properties[CONF_PRODUCT_TYPE] + self.product_name = discovery_info.properties[CONF_PRODUCT_NAME] + self.serial = discovery_info.properties[CONF_SERIAL] - await self.async_set_unique_id( - f"{self.discovery.product_type}_{self.discovery.serial}" - ) + await self.async_set_unique_id(f"{self.product_type}_{self.serial}") self._abort_if_unique_id_configured( updates={CONF_IP_ADDRESS: discovery_info.host} ) @@ -141,34 +131,39 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm discovery.""" + assert self.ip_address + assert self.product_name + assert self.product_type + assert self.serial + errors: dict[str, str] | None = None if user_input is not None or not onboarding.async_is_onboarded(self.hass): try: - await self._async_try_connect(self.discovery.ip) + await self._async_try_connect(self.ip_address) except RecoverableError as ex: LOGGER.error(ex) errors = {"base": ex.error_code} else: return self.async_create_entry( - title=self.discovery.product_name, - data={CONF_IP_ADDRESS: self.discovery.ip}, + title=self.product_name, + data={CONF_IP_ADDRESS: self.ip_address}, ) self._set_confirm_only() # We won't be adding mac/serial to the title for devices # that users generally don't have multiple of. - name = self.discovery.product_name - if self.discovery.product_type not in ["HWE-P1", "HWE-WTR"]: - name = f"{name} ({self.discovery.serial})" + name = self.product_name + if self.product_type not in ["HWE-P1", "HWE-WTR"]: + name = f"{name} ({self.serial})" self.context["title_placeholders"] = {"name": name} return self.async_show_form( step_id="discovery_confirm", description_placeholders={ - CONF_PRODUCT_TYPE: self.discovery.product_type, - CONF_SERIAL: self.discovery.serial, - CONF_IP_ADDRESS: self.discovery.ip, + CONF_PRODUCT_TYPE: self.product_type, + CONF_SERIAL: self.serial, + CONF_IP_ADDRESS: self.ip_address, }, errors=errors, ) From d908d2ab55408b54180eb61ef639aea3f6f9a2b1 Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Thu, 16 Jan 2025 16:44:09 +0100 Subject: [PATCH 0501/2987] Bump youless-api to 2.2.0 (#135781) Bump version youless 2.2.0 --- homeassistant/components/youless/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/youless/manifest.json b/homeassistant/components/youless/manifest.json index 1ccc8cda0ff..9a51e0fe0d1 100644 --- a/homeassistant/components/youless/manifest.json +++ b/homeassistant/components/youless/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/youless", "iot_class": "local_polling", "loggers": ["youless_api"], - "requirements": ["youless-api==2.1.2"] + "requirements": ["youless-api==2.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d6d9b583d94..c3011f92ada 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3100,7 +3100,7 @@ yeelightsunflower==0.0.10 yolink-api==0.4.7 # homeassistant.components.youless -youless-api==2.1.2 +youless-api==2.2.0 # homeassistant.components.youtube youtubeaio==1.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a8dfe35756..d0e736ec701 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2495,7 +2495,7 @@ yeelight==0.7.14 yolink-api==0.4.7 # homeassistant.components.youless -youless-api==2.1.2 +youless-api==2.2.0 # homeassistant.components.youtube youtubeaio==1.1.5 From e188d9a00c834a6c1468c218a5c681b7a5cd862a Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Thu, 16 Jan 2025 19:06:18 +0100 Subject: [PATCH 0502/2987] Fix Bang & Olufsen event testing (#135707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- tests/components/bang_olufsen/const.py | 12 ++++---- .../bang_olufsen/snapshots/test_event.ambr | 21 ++++++++++++++ .../snapshots/test_media_player.ambr | 24 +++++++-------- tests/components/bang_olufsen/test_event.py | 29 +++++++++++-------- 4 files changed, 56 insertions(+), 30 deletions(-) create mode 100644 tests/components/bang_olufsen/snapshots/test_event.ambr diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index dde51351b39..c21afb4a130 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -42,18 +42,18 @@ TEST_MODEL_CORE = "Beoconnect Core" TEST_MODEL_THEATRE = "Beosound Theatre" TEST_MODEL_LEVEL = "Beosound Level" TEST_SERIAL_NUMBER = "11111111" -TEST_SERIAL_NUMBER_2 = "22222222" TEST_NAME = f"{TEST_MODEL_BALANCE}-{TEST_SERIAL_NUMBER}" -TEST_NAME_2 = f"{TEST_MODEL_BALANCE}-{TEST_SERIAL_NUMBER_2}" TEST_FRIENDLY_NAME = "Living room Balance" TEST_TYPE_NUMBER = "1111" TEST_ITEM_NUMBER = "1111111" TEST_JID_1 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER}@products.bang-olufsen.com" TEST_MEDIA_PLAYER_ENTITY_ID = "media_player.beosound_balance_11111111" -TEST_FRIENDLY_NAME_2 = "Laundry room Balance" -TEST_JID_2 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.22222222@products.bang-olufsen.com" -TEST_MEDIA_PLAYER_ENTITY_ID_2 = "media_player.beosound_balance_22222222" +TEST_FRIENDLY_NAME_2 = "Laundry room Core" +TEST_SERIAL_NUMBER_2 = "22222222" +TEST_NAME_2 = f"{TEST_MODEL_CORE}-{TEST_SERIAL_NUMBER_2}" +TEST_JID_2 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER_2}@products.bang-olufsen.com" +TEST_MEDIA_PLAYER_ENTITY_ID_2 = "media_player.beoconnect_core_22222222" TEST_HOST_2 = "192.168.0.2" TEST_FRIENDLY_NAME_3 = "Lego room Balance" @@ -84,7 +84,7 @@ TEST_DATA_CREATE_ENTRY = { CONF_NAME: TEST_NAME, } TEST_DATA_CREATE_ENTRY_2 = { - CONF_HOST: TEST_HOST, + CONF_HOST: TEST_HOST_2, CONF_MODEL: TEST_MODEL_CORE, CONF_BEOLINK_JID: TEST_JID_2, CONF_NAME: TEST_NAME_2, diff --git a/tests/components/bang_olufsen/snapshots/test_event.ambr b/tests/components/bang_olufsen/snapshots/test_event.ambr new file mode 100644 index 00000000000..3b748d3a27a --- /dev/null +++ b/tests/components/bang_olufsen/snapshots/test_event.ambr @@ -0,0 +1,21 @@ +# serializer version: 1 +# name: test_button_event_creation + list([ + 'event.beosound_balance_11111111_bluetooth', + 'event.beosound_balance_11111111_microphone', + 'event.beosound_balance_11111111_next', + 'event.beosound_balance_11111111_play_pause', + 'event.beosound_balance_11111111_favourite_1', + 'event.beosound_balance_11111111_favourite_2', + 'event.beosound_balance_11111111_favourite_3', + 'event.beosound_balance_11111111_favourite_4', + 'event.beosound_balance_11111111_previous', + 'event.beosound_balance_11111111_volume', + 'media_player.beosound_balance_11111111', + ]) +# --- +# name: test_button_event_creation_beoconnect_core + list([ + 'media_player.beoconnect_core_22222222', + ]) +# --- diff --git a/tests/components/bang_olufsen/snapshots/test_media_player.ambr b/tests/components/bang_olufsen/snapshots/test_media_player.ambr index 327b7ecfacf..be7989a2cb9 100644 --- a/tests/components/bang_olufsen/snapshots/test_media_player.ambr +++ b/tests/components/bang_olufsen/snapshots/test_media_player.ambr @@ -642,7 +642,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -661,7 +661,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , @@ -737,7 +737,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -756,7 +756,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , @@ -831,7 +831,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -850,7 +850,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , @@ -924,7 +924,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -943,7 +943,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1003,7 +1003,7 @@ 'attributes': ReadOnlyDict({ 'beolink': dict({ 'leader': dict({ - 'Laundry room Balance': '1111.1111111.22222222@products.bang-olufsen.com', + 'Laundry room Core': '1111.1111111.22222222@products.bang-olufsen.com', }), 'peers': dict({ 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', @@ -1017,7 +1017,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'media_player.beosound_balance_11111111', ]), 'media_content_type': , @@ -1062,7 +1062,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -1081,7 +1081,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/bang_olufsen/test_event.py b/tests/components/bang_olufsen/test_event.py index d58e5d2219b..855dab40db1 100644 --- a/tests/components/bang_olufsen/test_event.py +++ b/tests/components/bang_olufsen/test_event.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock from inflection import underscore from mozart_api.models import ButtonEvent +from syrupy.assertion import SnapshotAssertion from homeassistant.components.bang_olufsen.const import ( DEVICE_BUTTON_EVENTS, @@ -25,6 +26,7 @@ async def test_button_event_creation( mock_config_entry: MockConfigEntry, mock_mozart_client: AsyncMock, entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test button event entities are created.""" @@ -35,14 +37,21 @@ async def test_button_event_creation( # Add Button Event entity ids entity_ids = [ f"event.beosound_balance_11111111_{underscore(button_type)}".replace( - "preset", "preset_" + "preset", "favourite_" ) for button_type in DEVICE_BUTTONS ] # Check that the entities are available for entity_id in entity_ids: - entity_registry.async_get(entity_id) + assert entity_registry.async_get(entity_id) + + # Check number of entities + # The media_player entity and all of the button event entities should be the only available + entity_ids_available = list(entity_registry.entities.keys()) + assert len(entity_ids_available) == 1 + len(entity_ids) + + assert entity_ids_available == snapshot async def test_button_event_creation_beoconnect_core( @@ -50,6 +59,7 @@ async def test_button_event_creation_beoconnect_core( mock_config_entry_core: MockConfigEntry, mock_mozart_client: AsyncMock, entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test button event entities are not created when using a Beoconnect Core.""" @@ -57,17 +67,12 @@ async def test_button_event_creation_beoconnect_core( mock_config_entry_core.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry_core.entry_id) - # Add Button Event entity ids - entity_ids = [ - f"event.beosound_balance_11111111_{underscore(button_type)}".replace( - "preset", "preset_" - ) - for button_type in DEVICE_BUTTONS - ] + # Check number of entities + # The media_player entity should be the only available + entity_ids_available = list(entity_registry.entities.keys()) + assert len(entity_ids_available) == 1 - # Check that the entities are unavailable - for entity_id in entity_ids: - assert not entity_registry.async_get(entity_id) + assert entity_ids_available == snapshot async def test_button( From 93b3d76ee2b91204ec4d26a91038b7911102eee4 Mon Sep 17 00:00:00 2001 From: Steve HOLWEG Date: Thu, 16 Jan 2025 19:34:30 +0100 Subject: [PATCH 0503/2987] Add button to move netatmo cover to preferred position (#134722) --- homeassistant/components/netatmo/button.py | 73 ++++++++++++++ homeassistant/components/netatmo/const.py | 2 + .../components/netatmo/data_handler.py | 6 +- homeassistant/components/netatmo/icons.json | 5 + homeassistant/components/netatmo/strings.json | 5 + .../netatmo/snapshots/test_button.ambr | 95 +++++++++++++++++++ tests/components/netatmo/test_button.py | 72 ++++++++++++++ 7 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/netatmo/button.py create mode 100644 tests/components/netatmo/snapshots/test_button.ambr create mode 100644 tests/components/netatmo/test_button.py diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py new file mode 100644 index 00000000000..7b2899c84aa --- /dev/null +++ b/homeassistant/components/netatmo/button.py @@ -0,0 +1,73 @@ +"""Support for Netatmo/Bubendorff button.""" + +from __future__ import annotations + +import logging + +from pyatmo import modules as NaModules + +from homeassistant.components.button import ButtonEntity +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 AddEntitiesCallback + +from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON +from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .entity import NetatmoModuleEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Netatmo button platform.""" + + @callback + def _create_entity(netatmo_device: NetatmoDevice) -> None: + entity = NetatmoCoverPreferredPositionButton(netatmo_device) + _LOGGER.debug("Adding button %s", entity) + async_add_entities([entity]) + + entry.async_on_unload( + async_dispatcher_connect(hass, NETATMO_CREATE_BUTTON, _create_entity) + ) + + +class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity): + """Representation of a Netatmo cover preferred position button device.""" + + _attr_configuration_url = CONF_URL_CONTROL + _attr_entity_registry_enabled_default = False + _attr_translation_key = "preferred_position" + device: NaModules.Shutter + + def __init__(self, netatmo_device: NetatmoDevice) -> None: + """Initialize the Netatmo device.""" + super().__init__(netatmo_device) + + self._publishers.extend( + [ + { + "name": HOME, + "home_id": self.home.entity_id, + SIGNAL_NAME: f"{HOME}-{self.home.entity_id}", + }, + ] + ) + self._attr_unique_id = ( + f"{self.device.entity_id}-{self.device_type}-preferred_position" + ) + + @callback + def async_update_callback(self) -> None: + """Update the entity's state.""" + # No state to update for button + + async def async_press(self) -> None: + """Handle button press to move the cover to a preferred position.""" + _LOGGER.debug("Moving %s to a preferred position", self.device.entity_id) + await self.device.async_move_to_preferred_position() diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 74f2ebc84b2..d69a62f37f9 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -10,6 +10,7 @@ DEFAULT_ATTRIBUTION = f"Data provided by {MANUFACTURER}" PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CAMERA, Platform.CLIMATE, Platform.COVER, @@ -45,6 +46,7 @@ NETATMO_CREATE_CAMERA = "netatmo_create_camera" NETATMO_CREATE_CAMERA_LIGHT = "netatmo_create_camera_light" NETATMO_CREATE_CLIMATE = "netatmo_create_climate" NETATMO_CREATE_COVER = "netatmo_create_cover" +NETATMO_CREATE_BUTTON = "netatmo_create_button" NETATMO_CREATE_FAN = "netatmo_create_fan" NETATMO_CREATE_LIGHT = "netatmo_create_light" NETATMO_CREATE_ROOM_SENSOR = "netatmo_create_room_sensor" diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 3a28c3b8336..283ccc3740e 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -33,6 +33,7 @@ from .const import ( DOMAIN, MANUFACTURER, NETATMO_CREATE_BATTERY, + NETATMO_CREATE_BUTTON, NETATMO_CREATE_CAMERA, NETATMO_CREATE_CAMERA_LIGHT, NETATMO_CREATE_CLIMATE, @@ -350,7 +351,10 @@ class NetatmoDataHandler: NETATMO_CREATE_CAMERA_LIGHT, ], NetatmoDeviceCategory.dimmer: [NETATMO_CREATE_LIGHT], - NetatmoDeviceCategory.shutter: [NETATMO_CREATE_COVER], + NetatmoDeviceCategory.shutter: [ + NETATMO_CREATE_COVER, + NETATMO_CREATE_BUTTON, + ], NetatmoDeviceCategory.switch: [ NETATMO_CREATE_LIGHT, NETATMO_CREATE_SWITCH, diff --git a/homeassistant/components/netatmo/icons.json b/homeassistant/components/netatmo/icons.json index 9f712e08f33..099c6aa1784 100644 --- a/homeassistant/components/netatmo/icons.json +++ b/homeassistant/components/netatmo/icons.json @@ -13,6 +13,11 @@ } } }, + "button": { + "preferred_position": { + "default": "mdi:window-shutter-auto" + } + }, "sensor": { "temp_trend": { "default": "mdi:trending-up" diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 6b91aa204b2..23b800e460d 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -181,6 +181,11 @@ } } }, + "button": { + "preferred_position": { + "name": "Preferred position" + } + }, "sensor": { "temp_trend": { "name": "Temperature trend" diff --git a/tests/components/netatmo/snapshots/test_button.ambr b/tests/components/netatmo/snapshots/test_button.ambr new file mode 100644 index 00000000000..6ad1b9e78ba --- /dev/null +++ b/tests/components/netatmo/snapshots/test_button.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_entity[button.bubendorff_blind_preferred_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.bubendorff_blind_preferred_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': 'Preferred position', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'preferred_position', + 'unique_id': '0009999993-DeviceType.NBO-preferred_position', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[button.bubendorff_blind_preferred_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Bubendorff blind Preferred position', + }), + 'context': , + 'entity_id': 'button.bubendorff_blind_preferred_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity[button.entrance_blinds_preferred_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.entrance_blinds_preferred_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': 'Preferred position', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'preferred_position', + 'unique_id': '0009999992-DeviceType.NBR-preferred_position', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[button.entrance_blinds_preferred_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Entrance Blinds Preferred position', + }), + 'context': , + 'entity_id': 'button.entrance_blinds_preferred_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/netatmo/test_button.py b/tests/components/netatmo/test_button.py new file mode 100644 index 00000000000..681e42af051 --- /dev/null +++ b/tests/components/netatmo/test_button.py @@ -0,0 +1,72 @@ +"""The tests for Netatmo button.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from .common import selected_platforms, snapshot_platform_entities + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.BUTTON, + entity_registry, + snapshot, + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button_setup_and_services( + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock +) -> None: + """Test setup and services.""" + with selected_platforms([Platform.BUTTON]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + button_entity = "button.entrance_blinds_preferred_position" + + assert hass.states.get(button_entity).state == STATE_UNKNOWN + + # Test button press + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: button_entity}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once_with( + { + "modules": [ + { + "id": "0009999992", + "target_position": -2, + "bridge": "12:34:56:30:d5:d4", + } + ] + } + ) + + assert (state := hass.states.get(button_entity)) + assert state.state != STATE_UNKNOWN From 6e255060c630dd37dc39c6ed9a2b01c334faa48e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jan 2025 09:52:52 -1000 Subject: [PATCH 0504/2987] Add Bluetooth config entries for remote scanners (#135543) --- .../components/bluetooth/__init__.py | 29 +++++++ homeassistant/components/bluetooth/api.py | 7 +- .../components/bluetooth/config_flow.py | 52 +++++++++++- homeassistant/components/bluetooth/const.py | 6 ++ homeassistant/components/bluetooth/manager.py | 48 ++++++++++- .../components/bluetooth/strings.json | 3 + homeassistant/components/esphome/bluetooth.py | 9 +- .../components/shelly/bluetooth/__init__.py | 8 +- tests/components/bluetooth/__init__.py | 25 ++++++ .../components/bluetooth/test_base_scanner.py | 81 +++++++++++------- .../components/bluetooth/test_config_flow.py | 83 +++++++++++++++++++ tests/components/bluetooth/test_init.py | 37 +++++++++ 12 files changed, 353 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 8da4e9c61e0..63d66905938 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -78,6 +78,9 @@ from .const import ( CONF_ADAPTER, CONF_DETAILS, CONF_PASSIVE, + CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DOMAIN, + CONF_SOURCE_MODEL, DOMAIN, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, @@ -315,6 +318,32 @@ async def async_update_device( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry for a bluetooth scanner.""" + if source_entry_id := entry.data.get(CONF_SOURCE_CONFIG_ENTRY_ID): + if not (source_entry := hass.config_entries.async_get_entry(source_entry_id)): + # Cleanup the orphaned entry using a call_soon to ensure + # we can return before the entry is removed + hass.loop.call_soon( + hass_callback( + lambda: hass.async_create_task( + hass.config_entries.async_remove(entry.entry_id), + "remove orphaned bluetooth entry {entry.entry_id}", + ) + ) + ) + address = entry.unique_id + assert address is not None + assert source_entry is not None + await async_update_device( + hass, + entry, + source_entry.title, + AdapterDetails( + address=address, + product=entry.data.get(CONF_SOURCE_MODEL), + manufacturer=entry.data[CONF_SOURCE_DOMAIN], + ), + ) + return True manager = _get_manager(hass) address = entry.unique_id assert address is not None diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 9fd16ef1f43..9db570c4cba 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -178,9 +178,14 @@ def async_register_scanner( hass: HomeAssistant, scanner: BaseHaScanner, connection_slots: int | None = None, + source_domain: str | None = None, + source_model: str | None = None, + source_config_entry_id: str | None = None, ) -> CALLBACK_TYPE: """Register a BleakScanner.""" - return _get_manager(hass).async_register_scanner(scanner, connection_slots) + return _get_manager(hass).async_register_hass_scanner( + scanner, connection_slots, source_domain, source_model, source_config_entry_id + ) @hass_callback diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index 37eefd2f265..5bfe5e7089c 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -18,7 +18,12 @@ from habluetooth import get_manager import voluptuous as vol from homeassistant.components import onboarding -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.core import callback from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, @@ -26,7 +31,16 @@ from homeassistant.helpers.schema_config_entry_flow import ( ) from homeassistant.helpers.typing import DiscoveryInfoType -from .const import CONF_ADAPTER, CONF_DETAILS, CONF_PASSIVE, DOMAIN +from .const import ( + CONF_ADAPTER, + CONF_DETAILS, + CONF_PASSIVE, + CONF_SOURCE, + CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DOMAIN, + CONF_SOURCE_MODEL, + DOMAIN, +) from .util import adapter_title OPTIONS_SCHEMA = vol.Schema( @@ -63,6 +77,8 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): self, discovery_info: DiscoveryInfoType ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" + if discovery_info and CONF_SOURCE in discovery_info: + return await self.async_step_external_scanner(discovery_info) self._adapter = cast(str, discovery_info[CONF_ADAPTER]) self._details = cast(AdapterDetails, discovery_info[CONF_DETAILS]) await self.async_set_unique_id(self._details[ADAPTER_ADDRESS]) @@ -167,6 +183,24 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): ), ) + async def async_step_external_scanner( + self, user_input: dict[str, Any] + ) -> ConfigFlowResult: + """Handle a flow initialized by an external scanner.""" + source = user_input[CONF_SOURCE] + await self.async_set_unique_id(source) + data = { + CONF_SOURCE: source, + CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL], + CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN], + CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID], + } + self._abort_if_unique_id_configured(updates=data) + manager = get_manager() + scanner = manager.async_scanner_by_source(source) + assert scanner is not None + return self.async_create_entry(title=scanner.name, data=data) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -177,8 +211,10 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> SchemaOptionsFlowHandler: + ) -> SchemaOptionsFlowHandler | RemoteAdapterOptionsFlowHandler: """Get the options flow for this handler.""" + if CONF_SOURCE in config_entry.data: + return RemoteAdapterOptionsFlowHandler() return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) @classmethod @@ -186,3 +222,13 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: """Return options flow support for this handler.""" return bool((manager := get_manager()) and manager.supports_passive_scan) + + +class RemoteAdapterOptionsFlowHandler(OptionsFlow): + """Handle a option flow for remote adapters.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle options flow.""" + return self.async_abort(reason="remote_adapters_not_supported") diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index a3238befbb8..d4b187d4605 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -18,6 +18,12 @@ CONF_DETAILS = "details" CONF_PASSIVE = "passive" +CONF_SOURCE: Final = "source" +CONF_SOURCE_DOMAIN: Final = "source_domain" +CONF_SOURCE_MODEL: Final = "source_model" +CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id" + + SOURCE_LOCAL: Final = "local" DATA_MANAGER: Final = "bluetooth_manager" diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 7ec5427af2b..d8b3eef7685 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -22,7 +22,13 @@ from homeassistant.core import ( from homeassistant.helpers import discovery_flow from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import DOMAIN +from .const import ( + CONF_SOURCE, + CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DOMAIN, + CONF_SOURCE_MODEL, + DOMAIN, +) from .match import ( ADDRESS, CALLBACK, @@ -240,6 +246,39 @@ class HomeAssistantBluetoothManager(BluetoothManager): unregister() self._async_save_scanner_history(scanner) + @hass_callback + def async_register_hass_scanner( + self, + scanner: BaseHaScanner, + connection_slots: int | None = None, + source_domain: str | None = None, + source_model: str | None = None, + source_config_entry_id: str | None = None, + ) -> CALLBACK_TYPE: + """Register a scanner.""" + cancel = self.async_register_scanner(scanner, connection_slots) + if ( + isinstance(scanner, BaseHaRemoteScanner) + and source_domain + and source_config_entry_id + and not self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, scanner.source + ) + ): + self.hass.async_create_task( + self.hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: source_domain, + CONF_SOURCE_MODEL: source_model, + CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id, + }, + ) + ) + return cancel + def async_register_scanner( self, scanner: BaseHaScanner, @@ -257,6 +296,13 @@ class HomeAssistantBluetoothManager(BluetoothManager): def async_remove_scanner(self, source: str) -> None: """Remove a scanner.""" self.storage.async_remove_advertisement_history(source) + if entry := self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, source + ): + self.hass.async_create_task( + self.hass.config_entries.async_remove(entry.entry_id), + f"Removing {source} Bluetooth config entry", + ) @hass_callback def _handle_config_entry_removed( diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index c28bd3cc65e..1b8231c66ca 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -33,6 +33,9 @@ "passive": "Passive scanning" } } + }, + "abort": { + "remote_adapters_not_supported": "Bluetooth configuration for remote adapters is not supported." } } } diff --git a/homeassistant/components/esphome/bluetooth.py b/homeassistant/components/esphome/bluetooth.py index 004bea1835d..da342913d3d 100644 --- a/homeassistant/components/esphome/bluetooth.py +++ b/homeassistant/components/esphome/bluetooth.py @@ -11,6 +11,7 @@ from bleak_esphome import connect_scanner from homeassistant.components.bluetooth import async_register_scanner from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback +from .const import DOMAIN from .entry_data import RuntimeEntryData @@ -38,7 +39,13 @@ def async_connect_scanner( return partial( _async_unload, [ - async_register_scanner(hass, scanner), + async_register_scanner( + hass, + scanner, + source_domain=DOMAIN, + source_model=device_info.model, + source_config_entry_id=entry_data.entry_id, + ), scanner.async_setup(), ], ) diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index f2b71d19d61..5200ec9b913 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -28,7 +28,13 @@ async def async_connect_scanner( source = format_mac(coordinator.mac).upper() scanner = create_scanner(source, entry.title) unload_callbacks = [ - async_register_scanner(hass, scanner), + async_register_scanner( + hass, + scanner, + source_domain=entry.domain, + source_model=coordinator.model, + source_config_entry_id=entry.entry_id, + ), scanner.async_setup(), coordinator.async_subscribe_events(scanner.async_on_event), ] diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index a9213de34ff..c672de7424b 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -14,7 +14,9 @@ from habluetooth import BaseHaScanner, BluetoothManager, get_manager from homeassistant.components.bluetooth import ( DOMAIN, + MONOTONIC_TIME, SOURCE_LOCAL, + BaseHaRemoteScanner, BluetoothServiceInfo, BluetoothServiceInfoBleak, async_get_advertisement_callback, @@ -324,3 +326,26 @@ class FakeScanner(FakeScannerMixin, BaseHaScanner): ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a list of discovered devices and their advertisement data.""" return {} + + +class FakeRemoteScanner(BaseHaRemoteScanner): + """Fake remote scanner.""" + + def inject_advertisement( + self, + device: BLEDevice, + advertisement_data: AdvertisementData, + now: float | None = None, + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + now or MONOTONIC_TIME(), + ) diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index abfbbaa15ab..fda035b9061 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -7,16 +7,12 @@ import time from typing import Any from unittest.mock import patch -from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData - # pylint: disable-next=no-name-in-module from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( - MONOTONIC_TIME, BaseHaRemoteScanner, HaBluetoothConnector, storage, @@ -28,12 +24,14 @@ from homeassistant.components.bluetooth.const import ( SCANNER_WATCHDOG_TIMEOUT, UNAVAILABLE_TRACK_SECONDS, ) +from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads from . import ( + FakeRemoteScanner as FakeScanner, MockBleakClient, _get_manager, generate_advertisement_data, @@ -41,30 +39,7 @@ from . import ( patch_bluetooth_time, ) -from tests.common import async_fire_time_changed, load_fixture - - -class FakeScanner(BaseHaRemoteScanner): - """Fake scanner.""" - - def inject_advertisement( - self, - device: BLEDevice, - advertisement_data: AdvertisementData, - now: float | None = None, - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - now or MONOTONIC_TIME(), - ) +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture @pytest.mark.parametrize("name_2", [None, "w"]) @@ -545,3 +520,53 @@ async def test_scanner_stops_responding(hass: HomeAssistant) -> None: cancel() unsetup() + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_remote_scanner_bluetooth_config_entry(hass: HomeAssistant) -> None: + """Test the remote scanner gets a bluetooth config entry.""" + manager: HomeAssistantBluetoothManager = _get_manager() + + switchbot_device = generate_ble_device( + "44:44:33:11:23:45", + "wohand", + {}, + rssi=-100, + ) + switchbot_device_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=[], + manufacturer_data={1: b"\x01"}, + rssi=-100, + ) + + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeScanner("esp32", "esp32", connector, True) + unsetup = scanner.async_setup() + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + cancel = manager.async_register_hass_scanner( + scanner, + source_domain="test", + source_model="test", + source_config_entry_id=entry.entry_id, + ) + await hass.async_block_till_done() + + scanner.inject_advertisement(switchbot_device, switchbot_device_adv) + assert len(scanner.discovered_devices) == 1 + + cancel() + unsetup() + + assert hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) + + manager.async_remove_scanner(scanner.source) + await hass.async_block_till_done() + assert not hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 0a0cb3fa8e0..abb3a5e2393 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -6,16 +6,23 @@ from bluetooth_adapters import DEFAULT_ADDRESS, AdapterDetails import pytest from homeassistant import config_entries +from homeassistant.components.bluetooth import HaBluetoothConnector from homeassistant.components.bluetooth.const import ( CONF_ADAPTER, CONF_DETAILS, CONF_PASSIVE, + CONF_SOURCE, + CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DOMAIN, + CONF_SOURCE_MODEL, DOMAIN, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component +from . import FakeRemoteScanner, MockBleakClient, _get_manager + from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -450,6 +457,36 @@ async def test_options_flow_enabled_linux( await hass.config_entries.async_unload(entry.entry_id) +@pytest.mark.usefixtures( + "one_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters" +) +async def test_options_flow_remote_adapter(hass: HomeAssistant) -> None: + """Test options are not available for remote adapters.""" + source_entry = MockConfigEntry( + domain="test", + ) + source_entry.add_to_hass(hass) + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOURCE: "BB:BB:BB:BB:BB:BB", + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: source_entry.entry_id, + }, + options={}, + unique_id="BB:BB:BB:BB:BB:BB", + ) + 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.ABORT + assert result["reason"] == "remote_adapters_not_supported" + + @pytest.mark.usefixtures("one_adapter") async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> None: """Test we give a hint that the adapter is ignored.""" @@ -467,3 +504,49 @@ async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_adapters" assert result["description_placeholders"] == {"ignored_adapters": "1"} + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_step_integration_discovery_remote_adapter( + hass: HomeAssistant, +) -> None: + """Test remote adapter configuration via integration discovery.""" + entry = MockConfigEntry(domain="test") + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeRemoteScanner("esp32", "esp32", connector, True) + manager = _get_manager() + cancel_scanner = manager.async_register_scanner(scanner) + + entry.add_to_hass(hass) + with ( + patch("homeassistant.components.bluetooth.async_setup", return_value=True), + patch( + "homeassistant.components.bluetooth.async_setup_entry", return_value=True + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "esp32" + assert result["data"] == { + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + } + assert len(mock_setup_entry.mock_calls) == 1 + await hass.async_block_till_done() + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + cancel_scanner() + await hass.async_block_till_done() diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 9ad2c0e6caa..2c8c9e70e7f 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -18,6 +18,7 @@ from homeassistant.components.bluetooth import ( BluetoothChange, BluetoothScanningMode, BluetoothServiceInfo, + HaBluetoothConnector, async_process_advertisements, async_rediscover_address, async_track_unavailable, @@ -25,6 +26,10 @@ from homeassistant.components.bluetooth import ( from homeassistant.components.bluetooth.const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, CONF_PASSIVE, + CONF_SOURCE, + CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DOMAIN, + CONF_SOURCE_MODEL, DOMAIN, LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, SOURCE_LOCAL, @@ -47,7 +52,9 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import ( + FakeRemoteScanner, FakeScanner, + MockBleakClient, _get_manager, async_setup_with_default_adapter, async_setup_with_one_adapter, @@ -3263,3 +3270,33 @@ async def test_title_updated_if_mac_address( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.title == "ACME Bluetooth Adapter 5.0 (00:00:00:00:00:01)" + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_cleanup_orphened_remote_scanner_config_entry( + hass: HomeAssistant, +) -> None: + """Test the remote scanner config entries get cleaned up when orphened.""" + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeRemoteScanner("esp32", "esp32", connector, True) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: "no_longer_exists", + }, + unique_id=scanner.source, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Orphened remote scanner config entry should be cleaned up + assert not hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) From 762bc7b8d12779f47bc95f9055ca5d26da6e50d7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 16 Jan 2025 15:41:53 -0500 Subject: [PATCH 0505/2987] Add broadcast intent (#135337) --- .../components/assist_satellite/intent.py | 69 +++++++++++ homeassistant/helpers/intent.py | 1 + tests/components/assist_satellite/conftest.py | 36 +++++- .../assist_satellite/test_entity.py | 2 +- .../assist_satellite/test_intent.py | 110 ++++++++++++++++++ 5 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/assist_satellite/intent.py create mode 100644 tests/components/assist_satellite/test_intent.py diff --git a/homeassistant/components/assist_satellite/intent.py b/homeassistant/components/assist_satellite/intent.py new file mode 100644 index 00000000000..75396cf138f --- /dev/null +++ b/homeassistant/components/assist_satellite/intent.py @@ -0,0 +1,69 @@ +"""Assist Satellite intents.""" + +import voluptuous as vol + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er, intent + +from .const import DOMAIN, AssistSatelliteEntityFeature + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the intents.""" + intent.async_register(hass, BroadcastIntentHandler()) + + +class BroadcastIntentHandler(intent.IntentHandler): + """Broadcast a message.""" + + intent_type = intent.INTENT_BROADCAST + description = "Broadcast a message through the home" + + @property + def slot_schema(self) -> dict | None: + """Return a slot schema.""" + return {vol.Required("message"): str} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Broadcast a message.""" + hass = intent_obj.hass + ent_reg = er.async_get(hass) + + # Find all assist satellite entities that are not the one invoking the intent + entities = { + entity: entry + for entity in hass.states.async_entity_ids(DOMAIN) + if (entry := ent_reg.async_get(entity)) + and entry.supported_features & AssistSatelliteEntityFeature.ANNOUNCE + } + + if intent_obj.device_id: + entities = { + entity: entry + for entity, entry in entities.items() + if entry.device_id != intent_obj.device_id + } + + await hass.services.async_call( + DOMAIN, + "announce", + {"message": intent_obj.slots["message"]["value"]}, + blocking=True, + context=intent_obj.context, + target={"entity_id": list(entities)}, + ) + + response = intent_obj.create_response() + response.async_set_speech("Done") + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_results( + success_results=[ + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.ENTITY, + id=entity, + name=state.name if (state := hass.states.get(entity)) else entity, + ) + for entity in entities + ] + ) + return response diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 468539f5a9d..5fa0da96dc1 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -58,6 +58,7 @@ INTENT_TIMER_STATUS = "HassTimerStatus" INTENT_GET_CURRENT_DATE = "HassGetCurrentDate" INTENT_GET_CURRENT_TIME = "HassGetCurrentTime" INTENT_RESPOND = "HassRespond" +INTENT_BROADCAST = "HassBroadcast" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) diff --git a/tests/components/assist_satellite/conftest.py b/tests/components/assist_satellite/conftest.py index 9e9bfd959e6..d75cbd072e0 100644 --- a/tests/components/assist_satellite/conftest.py +++ b/tests/components/assist_satellite/conftest.py @@ -16,7 +16,9 @@ from homeassistant.components.assist_satellite import ( ) from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo from homeassistant.setup import async_setup_component +from homeassistant.util.ulid import ulid_hex from tests.common import ( MockConfigEntry, @@ -38,11 +40,17 @@ def mock_tts(mock_tts_cache_dir: pathlib.Path) -> None: class MockAssistSatellite(AssistSatelliteEntity): """Mock Assist Satellite Entity.""" - _attr_name = "Test Entity" - _attr_supported_features = AssistSatelliteEntityFeature.ANNOUNCE - - def __init__(self) -> None: + def __init__(self, name: str, features: AssistSatelliteEntityFeature) -> None: """Initialize the mock entity.""" + self._attr_unique_id = ulid_hex() + self._attr_device_info = DeviceInfo( + { + "name": name, + "identifiers": {(TEST_DOMAIN, self._attr_unique_id)}, + } + ) + self._attr_name = name + self._attr_supported_features = features self.events = [] self.announcements: list[AssistSatelliteAnnouncement] = [] self.config = AssistSatelliteConfiguration( @@ -83,7 +91,19 @@ class MockAssistSatellite(AssistSatelliteEntity): @pytest.fixture def entity() -> MockAssistSatellite: """Mock Assist Satellite Entity.""" - return MockAssistSatellite() + return MockAssistSatellite("Test Entity", AssistSatelliteEntityFeature.ANNOUNCE) + + +@pytest.fixture +def entity2() -> MockAssistSatellite: + """Mock a second Assist Satellite Entity.""" + return MockAssistSatellite("Test Entity 2", AssistSatelliteEntityFeature.ANNOUNCE) + + +@pytest.fixture +def entity_no_features() -> MockAssistSatellite: + """Mock a third Assist Satellite Entity.""" + return MockAssistSatellite("Test Entity No features", 0) @pytest.fixture @@ -99,6 +119,8 @@ async def init_components( hass: HomeAssistant, config_entry: ConfigEntry, entity: MockAssistSatellite, + entity2: MockAssistSatellite, + entity_no_features: MockAssistSatellite, ) -> None: """Initialize components.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -125,7 +147,9 @@ async def init_components( async_unload_entry=async_unload_entry_init, ), ) - setup_test_component_platform(hass, AS_DOMAIN, [entity], from_config_entry=True) + setup_test_component_platform( + hass, AS_DOMAIN, [entity, entity2, entity_no_features], from_config_entry=True + ) mock_platform(hass, f"{TEST_DOMAIN}.config_flow", Mock()) with mock_config_flow(TEST_DOMAIN, ConfigFlow): diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 884ba36782c..0961c7dfbca 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -63,7 +63,7 @@ async def test_entity_state( ) assert kwargs["stt_stream"] is audio_stream assert kwargs["pipeline_id"] is None - assert kwargs["device_id"] is None + assert kwargs["device_id"] is entity.device_entry.id assert kwargs["tts_audio_output"] is None assert kwargs["wake_word_phrase"] is None assert kwargs["audio_settings"] == AudioSettings( diff --git a/tests/components/assist_satellite/test_intent.py b/tests/components/assist_satellite/test_intent.py new file mode 100644 index 00000000000..27107c7d2e9 --- /dev/null +++ b/tests/components/assist_satellite/test_intent.py @@ -0,0 +1,110 @@ +"""Test assist satellite intents.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.media_source import PlayMedia +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent + +from .conftest import MockAssistSatellite + + +@pytest.fixture +def mock_tts(): + """Mock TTS service.""" + with ( + patch( + "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + return_value="media-source://bla", + ), + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=PlayMedia( + url="https://www.home-assistant.io/resolved.mp3", + mime_type="audio/mp3", + ), + ), + ): + yield + + +async def test_broadcast_intent( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + entity2: MockAssistSatellite, + entity_no_features: MockAssistSatellite, + mock_tts: None, +) -> None: + """Test we can invoke a broadcast intent.""" + + result = await intent.async_handle( + hass, "test", intent.INTENT_BROADCAST, {"message": {"value": "Hello"}} + ) + + assert result.as_dict() == { + "card": {}, + "data": { + "failed": [], + "success": [ + { + "id": "assist_satellite.test_entity", + "name": "Test Entity", + "type": intent.IntentResponseTargetType.ENTITY, + }, + { + "id": "assist_satellite.test_entity_2", + "name": "Test Entity 2", + "type": intent.IntentResponseTargetType.ENTITY, + }, + ], + "targets": [], + }, + "language": "en", + "response_type": "action_done", + "speech": { + "plain": { + "extra_data": None, + "speech": "Done", + } + }, + } + assert len(entity.announcements) == 1 + assert len(entity2.announcements) == 1 + assert len(entity_no_features.announcements) == 0 + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_BROADCAST, + {"message": {"value": "Hello"}}, + device_id=entity.device_entry.id, + ) + # Broadcast doesn't targets device that triggered it. + assert result.as_dict() == { + "card": {}, + "data": { + "failed": [], + "success": [ + { + "id": "assist_satellite.test_entity_2", + "name": "Test Entity 2", + "type": intent.IntentResponseTargetType.ENTITY, + }, + ], + "targets": [], + }, + "language": "en", + "response_type": "action_done", + "speech": { + "plain": { + "extra_data": None, + "speech": "Done", + } + }, + } + assert len(entity.announcements) == 1 + assert len(entity2.announcements) == 2 From 9331b1572c5e0b1a05791817f2847d94306eabb2 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 16 Jan 2025 16:14:53 -0500 Subject: [PATCH 0506/2987] Implement a polling fallback for USB monitor (#130918) --- homeassistant/components/usb/__init__.py | 49 ++++++++++++--- tests/components/usb/test_init.py | 79 +++++++++++++++++++----- 2 files changed, 103 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index a4bbe2dcf78..b688d821db4 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Coroutine, Sequence import dataclasses +from datetime import datetime, timedelta import fnmatch from functools import partial import logging @@ -33,6 +34,7 @@ from homeassistant.helpers.deprecation import ( check_if_deprecated_constant, dir_with_deprecated_constants, ) +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.service_info.usb import UsbServiceInfo as _UsbServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.loader import USBMatcher, async_get_usb @@ -46,6 +48,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +POLLING_MONITOR_SCAN_PERIOD = timedelta(seconds=5) REQUEST_SCAN_COOLDOWN = 10 # 10 second cooldown __all__ = [ @@ -229,7 +232,9 @@ class USBDiscovery: async def async_setup(self) -> None: """Set up USB Discovery.""" - await self._async_start_monitor() + if await self._async_supports_monitoring(): + await self._async_start_monitor() + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) @@ -243,26 +248,54 @@ class USBDiscovery: if self._request_debouncer: self._request_debouncer.async_shutdown() - async def _async_start_monitor(self) -> None: - """Start monitoring hardware with pyudev.""" - if not sys.platform.startswith("linux"): - return + async def _async_supports_monitoring(self) -> bool: info = await system_info.async_get_system_info(self.hass) - if info.get("docker"): - return + return not info.get("docker") + + async def _async_start_monitor(self) -> None: + """Start monitoring hardware.""" + if not await self._async_start_monitor_udev(): + _LOGGER.info( + "Falling back to periodic filesystem polling for development, libudev " + "is not present" + ) + self._async_start_monitor_polling() + + @hass_callback + def _async_start_monitor_polling(self) -> None: + """Start monitoring hardware with polling (for development only!).""" + + async def _scan(event_time: datetime) -> None: + await self._async_scan_serial() + + stop_callback = async_track_time_interval( + self.hass, _scan, POLLING_MONITOR_SCAN_PERIOD + ) + + @hass_callback + def _stop_polling(event: Event) -> None: + stop_callback() + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling) + + async def _async_start_monitor_udev(self) -> bool: + """Start monitoring hardware with pyudev. Returns True if successful.""" + if not sys.platform.startswith("linux"): + return False if not ( observer := await self.hass.async_add_executor_job( self._get_monitor_observer ) ): - return + return False def _stop_observer(event: Event) -> None: observer.stop() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_observer) self.observer_active = True + return True def _get_monitor_observer(self) -> MonitorObserver | None: """Get the monitor observer. diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 2f6dc72b4f8..f4002c81e40 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -1,7 +1,8 @@ """Tests for the USB Discovery integration.""" +import asyncio +from datetime import timedelta import os -import sys from typing import Any from unittest.mock import MagicMock, Mock, call, patch, sentinel @@ -59,10 +60,6 @@ def mock_venv(): yield -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) async def test_observer_discovery( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv ) -> None: @@ -93,6 +90,7 @@ async def test_observer_discovery( return mock_observer with ( + patch("sys.platform", "linux"), patch("pyudev.Context"), patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer), patch("pyudev.Monitor.filter_by"), @@ -115,10 +113,65 @@ async def test_observer_discovery( assert mock_observer.mock_calls == [call.start(), call.__bool__(), call.stop()] -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) +async def test_polling_discovery( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv +) -> None: + """Test that polling can discover a device without raising an exception.""" + new_usb = [{"domain": "test1", "vid": "3039"}] + mock_comports_found_device = asyncio.Event() + + def get_comports() -> list: + nonlocal mock_comports + + # Only "find" a device after a few invocations + if len(mock_comports.mock_calls) < 5: + return [] + + mock_comports_found_device.set() + return [ + MagicMock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with ( + patch("sys.platform", "linux"), + patch( + "homeassistant.components.usb.USBDiscovery._get_monitor_observer", + return_value=None, + ), + patch( + "homeassistant.components.usb.POLLING_MONITOR_SCAN_PERIOD", + timedelta(seconds=0.01), + ), + patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), + patch( + "homeassistant.components.usb.comports", side_effect=get_comports + ) as mock_comports, + patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + # Wait until a new device is discovered after a few polling attempts + assert len(mock_config_flow.mock_calls) == 0 + await mock_comports_found_device.wait() + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + async def test_removal_by_observer_before_started( hass: HomeAssistant, operating_system ) -> None: @@ -671,10 +724,6 @@ async def test_non_matching_discovered_by_scanner_after_started( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) async def test_observer_on_wsl_fallback_without_throwing_exception( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv ) -> None: @@ -713,10 +762,6 @@ async def test_observer_on_wsl_fallback_without_throwing_exception( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) async def test_not_discovered_by_observer_before_started_on_docker( hass: HomeAssistant, docker ) -> None: From 1b520e37e2095f570f9f16dfb853dc40bb3b59d3 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Fri, 17 Jan 2025 08:33:54 +1100 Subject: [PATCH 0507/2987] Update aiolifx-themes to 0.6.4 (#135805) * Restore support for Python 3.12 Signed-off-by: Avi Miller * Bump aiolifx-themes to 0.6.4 Signed-off-by: Avi Miller --------- Signed-off-by: Avi Miller --- homeassistant/components/lifx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 205435a7b2e..8d460c25322 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -53,6 +53,6 @@ "requirements": [ "aiolifx==1.1.2", "aiolifx-effects==0.3.2", - "aiolifx-themes==0.6.2" + "aiolifx-themes==0.6.4" ] } diff --git a/requirements_all.txt b/requirements_all.txt index c3011f92ada..10ba3590beb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -285,7 +285,7 @@ aiokef==0.2.16 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.6.2 +aiolifx-themes==0.6.4 # homeassistant.components.lifx aiolifx==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0e736ec701..7bb1ef2203a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -267,7 +267,7 @@ aiokafka==0.10.0 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.6.2 +aiolifx-themes==0.6.4 # homeassistant.components.lifx aiolifx==1.1.2 From eb651a8a71418288d199e479c6eee11d7e98acaa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jan 2025 11:37:12 -1000 Subject: [PATCH 0508/2987] Bump govee-ble to 0.42.0 (#135801) --- homeassistant/components/govee_ble/event.py | 3 +-- homeassistant/components/govee_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/govee_ble/event.py b/homeassistant/components/govee_ble/event.py index 55275477164..5e5aa6354be 100644 --- a/homeassistant/components/govee_ble/event.py +++ b/homeassistant/components/govee_ble/event.py @@ -102,8 +102,7 @@ async def async_setup_entry( descriptions = [MOTION_DESCRIPTION] elif sensor_type is SensorType.VIBRATION: descriptions = [VIBRATION_DESCRIPTION] - elif sensor_type is SensorType.BUTTON: - button_count = model_info.button_count + elif button_count := model_info.button_count: descriptions = BUTTON_DESCRIPTIONS[0:button_count] else: return diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index 484822efda6..5a123de7066 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -131,5 +131,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.41.0"] + "requirements": ["govee-ble==0.42.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 10ba3590beb..5c183fcecd2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1046,7 +1046,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.41.0 +govee-ble==0.42.0 # homeassistant.components.govee_light_local govee-local-api==1.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7bb1ef2203a..6be23b85746 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -896,7 +896,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.41.0 +govee-ble==0.42.0 # homeassistant.components.govee_light_local govee-local-api==1.5.3 From 9b66ba61a891fa1d7841bd8695023543c9d8732b Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 16 Jan 2025 16:53:15 -0500 Subject: [PATCH 0509/2987] USB device add/remove callbacks (#131224) --- homeassistant/components/usb/__init__.py | 91 +++++++++++-- homeassistant/components/usb/models.py | 2 +- tests/components/usb/test_init.py | 166 ++++++++++++++++++++++- 3 files changed, 243 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index b688d821db4..ec65143b984 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Coroutine, Sequence +from collections.abc import Callable, Coroutine, Sequence import dataclasses from datetime import datetime, timedelta import fnmatch @@ -48,12 +48,15 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +PORT_EVENT_CALLBACK_TYPE = Callable[[set[USBDevice], set[USBDevice]], None] + POLLING_MONITOR_SCAN_PERIOD = timedelta(seconds=5) REQUEST_SCAN_COOLDOWN = 10 # 10 second cooldown __all__ = [ "USBCallbackMatcher", "async_is_plugged_in", + "async_register_port_event_callback", "async_register_scan_request_callback", ] @@ -85,6 +88,15 @@ def async_register_initial_scan_callback( return discovery.async_register_initial_scan_callback(callback) +@hass_callback +def async_register_port_event_callback( + hass: HomeAssistant, callback: PORT_EVENT_CALLBACK_TYPE +) -> CALLBACK_TYPE: + """Register to receive a callback when a USB device is connected or disconnected.""" + discovery: USBDiscovery = hass.data[DOMAIN] + return discovery.async_register_port_event_callback(callback) + + @hass_callback def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> bool: """Return True is a USB device is present.""" @@ -108,8 +120,25 @@ def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> boo usb_discovery: USBDiscovery = hass.data[DOMAIN] return any( - _is_matching(USBDevice(*device_tuple), matcher) - for device_tuple in usb_discovery.seen + _is_matching( + USBDevice( + device=device, + vid=vid, + pid=pid, + serial_number=serial_number, + manufacturer=manufacturer, + description=description, + ), + matcher, + ) + for ( + device, + vid, + pid, + serial_number, + manufacturer, + description, + ) in usb_discovery.seen ) @@ -229,6 +258,8 @@ class USBDiscovery: self._request_callbacks: list[CALLBACK_TYPE] = [] self.initial_scan_done = False self._initial_scan_callbacks: list[CALLBACK_TYPE] = [] + self._port_event_callbacks: set[PORT_EVENT_CALLBACK_TYPE] = set() + self._last_processed_devices: set[USBDevice] = set() async def async_setup(self) -> None: """Set up USB Discovery.""" @@ -324,20 +355,23 @@ class USBDiscovery: return None observer = MonitorObserver( - monitor, callback=self._device_discovered, name="usb-observer" + monitor, callback=self._device_event, name="usb-observer" ) observer.start() return observer - def _device_discovered(self, device: Device) -> None: - """Call when the observer discovers a new usb tty device.""" - if device.action != "add": + def _device_event(self, device: Device) -> None: + """Call when the observer receives a USB device event.""" + if device.action not in ("add", "remove"): return - _LOGGER.debug( - "Discovered Device at path: %s, triggering scan serial", - device.device_path, + + _LOGGER.info( + "Received a udev device event %r for %s, triggering scan", + device.action, + device.device_node, ) + self.hass.create_task(self._async_scan()) @hass_callback @@ -374,6 +408,20 @@ class USBDiscovery: return _async_remove_callback + @hass_callback + def async_register_port_event_callback( + self, + callback: PORT_EVENT_CALLBACK_TYPE, + ) -> CALLBACK_TYPE: + """Register a port event callback.""" + self._port_event_callbacks.add(callback) + + @hass_callback + def _async_remove_callback() -> None: + self._port_event_callbacks.discard(callback) + + return _async_remove_callback + async def _async_process_discovered_usb_device(self, device: USBDevice) -> None: """Process a USB discovery.""" _LOGGER.debug("Discovered USB Device: %s", device) @@ -418,11 +466,11 @@ class USBDiscovery: async def _async_process_ports(self, ports: Sequence[ListPortInfo]) -> None: """Process each discovered port.""" - usb_devices = [ + usb_devices = { usb_device_from_port(port) for port in ports if port.vid is not None or port.pid is not None - ] + } # CP2102N chips create *two* serial ports on macOS: `/dev/cu.usbserial-` and # `/dev/cu.SLAB_USBtoUART*`. The former does not work and we should ignore them. @@ -433,7 +481,7 @@ class USBDiscovery: if dev.device.startswith("/dev/cu.SLAB_USBtoUART") } - usb_devices = [ + usb_devices = { dev for dev in usb_devices if dev.serial_number not in silabs_serials @@ -441,7 +489,22 @@ class USBDiscovery: dev.serial_number in silabs_serials and dev.device.startswith("/dev/cu.SLAB_USBtoUART") ) - ] + } + + added_devices = usb_devices - self._last_processed_devices + removed_devices = self._last_processed_devices - usb_devices + self._last_processed_devices = usb_devices + + _LOGGER.debug( + "Added devices: %r, removed devices: %r", added_devices, removed_devices + ) + + if added_devices or removed_devices: + for callback in self._port_event_callbacks.copy(): + try: + callback(added_devices, removed_devices) + except Exception: + _LOGGER.exception("Error in USB port event callback") for usb_device in usb_devices: await self._async_process_discovered_usb_device(usb_device) diff --git a/homeassistant/components/usb/models.py b/homeassistant/components/usb/models.py index efc5b11c26e..11eccd9cd9b 100644 --- a/homeassistant/components/usb/models.py +++ b/homeassistant/components/usb/models.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import dataclass -@dataclass +@dataclass(slots=True, frozen=True, kw_only=True) class USBDevice: """A usb device.""" diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index f4002c81e40..8f8ed672374 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta +import logging import os from typing import Any from unittest.mock import MagicMock, Mock, call, patch, sentinel @@ -9,6 +10,7 @@ from unittest.mock import MagicMock, Mock, call, patch, sentinel import pytest from homeassistant.components import usb +from homeassistant.components.usb.utils import usb_device_from_port from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers.service_info.usb import UsbServiceInfo @@ -80,7 +82,7 @@ async def test_observer_discovery( async def _mock_monitor_observer_callback(callback): await hass.async_add_executor_job( - callback, MagicMock(action="create", device_path="/dev/new") + callback, MagicMock(action="add", device_path="/dev/new") ) def _create_mock_monitor_observer(monitor, callback, name): @@ -1235,3 +1237,165 @@ def test_deprecated_constants( replacement, "2026.2", ) + + +@patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) +async def test_register_port_event_callback( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the registration of a port event callback.""" + + port1 = Mock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + + port2 = Mock( + device=conbee_device.device, + vid=12346, + pid=12346, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_device.description, + ) + + port1_usb = usb_device_from_port(port1) + port2_usb = usb_device_from_port(port2) + + ws_client = await hass_ws_client(hass) + + mock_callback1 = Mock() + mock_callback2 = Mock() + + # Start off with no ports + with ( + patch("pyudev.Context", side_effect=ImportError), + patch("homeassistant.components.usb.comports", return_value=[]), + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + + _cancel1 = usb.async_register_port_event_callback(hass, mock_callback1) + cancel2 = usb.async_register_port_event_callback(hass, mock_callback2) + + assert mock_callback1.mock_calls == [] + assert mock_callback2.mock_calls == [] + + # Add two new ports + with patch("homeassistant.components.usb.comports", return_value=[port1, port2]): + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + + assert mock_callback1.mock_calls == [call({port1_usb, port2_usb}, set())] + assert mock_callback2.mock_calls == [call({port1_usb, port2_usb}, set())] + + # Cancel the second callback + cancel2() + cancel2() + + mock_callback1.reset_mock() + mock_callback2.reset_mock() + + # Remove port 2 + with patch("homeassistant.components.usb.comports", return_value=[port1]): + await ws_client.send_json({"id": 2, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert mock_callback1.mock_calls == [call(set(), {port2_usb})] + assert mock_callback2.mock_calls == [] # The second callback was unregistered + + mock_callback1.reset_mock() + mock_callback2.reset_mock() + + # Keep port 2 removed + with patch("homeassistant.components.usb.comports", return_value=[port1]): + await ws_client.send_json({"id": 3, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + # Nothing changed so no callback is called + assert mock_callback1.mock_calls == [] + assert mock_callback2.mock_calls == [] + + # Unplug one and plug in the other + with patch("homeassistant.components.usb.comports", return_value=[port2]): + await ws_client.send_json({"id": 4, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert mock_callback1.mock_calls == [call({port2_usb}, {port1_usb})] + assert mock_callback2.mock_calls == [] + + +@patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) +async def test_register_port_event_callback_failure( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test port event callback failure handling.""" + + port1 = Mock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + + port2 = Mock( + device=conbee_device.device, + vid=12346, + pid=12346, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_device.description, + ) + + port1_usb = usb_device_from_port(port1) + port2_usb = usb_device_from_port(port2) + + ws_client = await hass_ws_client(hass) + + mock_callback1 = Mock(side_effect=RuntimeError("Failure 1")) + mock_callback2 = Mock(side_effect=RuntimeError("Failure 2")) + + # Start off with no ports + with ( + patch("pyudev.Context", side_effect=ImportError), + patch("homeassistant.components.usb.comports", return_value=[]), + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + + usb.async_register_port_event_callback(hass, mock_callback1) + usb.async_register_port_event_callback(hass, mock_callback2) + + assert mock_callback1.mock_calls == [] + assert mock_callback2.mock_calls == [] + + # Add two new ports + with ( + patch("homeassistant.components.usb.comports", return_value=[port1, port2]), + caplog.at_level(logging.ERROR, logger="homeassistant.components.usb"), + ): + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + # Both were called even though they raised exceptions + assert mock_callback1.mock_calls == [call({port1_usb, port2_usb}, set())] + assert mock_callback2.mock_calls == [call({port1_usb, port2_usb}, set())] + + assert caplog.text.count("Error in USB port event callback") == 2 + assert "Failure 1" in caplog.text + assert "Failure 2" in caplog.text From 60d51bf4ad7c6813c03f02b341eb3b60ae635cad Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 16 Jan 2025 23:03:48 +0100 Subject: [PATCH 0510/2987] Assign entity_category for incomfort entities (#135807) --- .../components/incomfort/binary_sensor.py | 2 + homeassistant/components/incomfort/climate.py | 3 +- homeassistant/components/incomfort/sensor.py | 3 +- .../components/incomfort/water_heater.py | 3 +- .../snapshots/test_binary_sensor.ambr | 40 +++++++++---------- .../incomfort/snapshots/test_climate.ambr | 8 ++-- .../incomfort/snapshots/test_sensor.ambr | 6 +-- .../snapshots/test_water_heater.ambr | 2 +- 8 files changed, 36 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/incomfort/binary_sensor.py b/homeassistant/components/incomfort/binary_sensor.py index 45da990d44f..2696491422b 100644 --- a/homeassistant/components/incomfort/binary_sensor.py +++ b/homeassistant/components/incomfort/binary_sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -29,6 +30,7 @@ class IncomfortBinarySensorEntityDescription(BinarySensorEntityDescription): value_key: str extra_state_attributes_fn: Callable[[dict[str, Any]], dict[str, Any]] | None = None + entity_category: EntityCategory = EntityCategory.DIAGNOSTIC SENSOR_TYPES: tuple[IncomfortBinarySensorEntityDescription, ...] = ( diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index 545666a826f..756e14fc545 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -12,7 +12,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -44,6 +44,7 @@ async def async_setup_entry( class InComfortClimate(IncomfortEntity, ClimateEntity): """Representation of an InComfort/InTouch climate device.""" + _attr_entity_category = EntityCategory.CONFIG _attr_min_temp = 5.0 _attr_max_temp = 30.0 _attr_name = None diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index e3cc52fb3a7..793a2f0450c 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfPressure, UnitOfTemperature +from homeassistant.const import EntityCategory, UnitOfPressure, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -31,6 +31,7 @@ class IncomfortSensorEntityDescription(SensorEntityDescription): value_key: str extra_key: str | None = None + entity_category = EntityCategory.DIAGNOSTIC SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py index 5b676b3b7ff..0ab4a6a06b8 100644 --- a/homeassistant/components/incomfort/water_heater.py +++ b/homeassistant/components/incomfort/water_heater.py @@ -8,7 +8,7 @@ from typing import Any from incomfortclient import Heater as InComfortHeater from homeassistant.components.water_heater import WaterHeaterEntity -from homeassistant.const import UnitOfTemperature +from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -37,6 +37,7 @@ async def async_setup_entry( class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity): """Representation of an InComfort/Intouch water_heater device.""" + _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_min_temp = 30.0 _attr_max_temp = 80.0 _attr_name = None diff --git a/tests/components/incomfort/snapshots/test_binary_sensor.ambr b/tests/components/incomfort/snapshots/test_binary_sensor.ambr index 2f2319b6a44..fe0d8edd0f0 100644 --- a/tests/components/incomfort/snapshots/test_binary_sensor.ambr +++ b/tests/components/incomfort/snapshots/test_binary_sensor.ambr @@ -10,7 +10,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -57,7 +57,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -105,7 +105,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -152,7 +152,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, @@ -199,7 +199,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -246,7 +246,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -294,7 +294,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -341,7 +341,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, @@ -388,7 +388,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -435,7 +435,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -483,7 +483,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -530,7 +530,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, @@ -577,7 +577,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -624,7 +624,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -672,7 +672,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -719,7 +719,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, @@ -766,7 +766,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -813,7 +813,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -861,7 +861,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -908,7 +908,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/incomfort/snapshots/test_climate.ambr b/tests/components/incomfort/snapshots/test_climate.ambr index bd940bbc2ce..e0e8b9562dd 100644 --- a/tests/components/incomfort/snapshots/test_climate.ambr +++ b/tests/components/incomfort/snapshots/test_climate.ambr @@ -16,7 +16,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': None, + 'entity_category': , 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, @@ -82,7 +82,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': None, + 'entity_category': , 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, @@ -148,7 +148,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': None, + 'entity_category': , 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, @@ -214,7 +214,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': None, + 'entity_category': , 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/incomfort/snapshots/test_sensor.ambr b/tests/components/incomfort/snapshots/test_sensor.ambr index 8c9ea60f455..a69a64d964e 100644 --- a/tests/components/incomfort/snapshots/test_sensor.ambr +++ b/tests/components/incomfort/snapshots/test_sensor.ambr @@ -12,7 +12,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.boiler_pressure', 'has_entity_name': True, 'hidden_by': None, @@ -63,7 +63,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.boiler_tap_temperature', 'has_entity_name': True, 'hidden_by': None, @@ -115,7 +115,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.boiler_temperature', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/incomfort/snapshots/test_water_heater.ambr b/tests/components/incomfort/snapshots/test_water_heater.ambr index 06b0d0c1e52..d2cd955a9fc 100644 --- a/tests/components/incomfort/snapshots/test_water_heater.ambr +++ b/tests/components/incomfort/snapshots/test_water_heater.ambr @@ -13,7 +13,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'water_heater', - 'entity_category': None, + 'entity_category': , 'entity_id': 'water_heater.boiler', 'has_entity_name': True, 'hidden_by': None, From b446eaf2d03302fb3bcf7e106dde9ef741edf1f3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 16 Jan 2025 23:04:57 +0100 Subject: [PATCH 0511/2987] Improve incomfort test coverage (#135806) --- tests/components/incomfort/conftest.py | 19 +++++++- tests/components/incomfort/test_climate.py | 53 +++++++++++++++++++++- tests/components/incomfort/test_init.py | 53 +++++++++++++++++++++- 3 files changed, 121 insertions(+), 4 deletions(-) diff --git a/tests/components/incomfort/conftest.py b/tests/components/incomfort/conftest.py index a6acd79764c..a450b7e26d3 100644 --- a/tests/components/incomfort/conftest.py +++ b/tests/components/incomfort/conftest.py @@ -20,7 +20,7 @@ MOCK_CONFIG = { } MOCK_HEATER_STATUS = { - "display_code": DisplayCode(126), + "display_code": DisplayCode.STANDBY, "display_text": "standby", "fault_code": None, "is_burning": False, @@ -36,6 +36,23 @@ MOCK_HEATER_STATUS = { "rfstatus_cntr": 0, } +MOCK_HEATER_STATUS_HEATING = { + "display_code": DisplayCode.OPENTHERM, + "display_text": "opentherm", + "fault_code": None, + "is_burning": True, + "is_failed": False, + "is_pumping": True, + "is_tapping": False, + "heater_temp": 35.34, + "tap_temp": 30.21, + "pressure": 1.86, + "serial_no": "c0ffeec0ffee", + "nodenr": 249, + "rf_message_rssi": 30, + "rfstatus_cntr": 0, +} + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: diff --git a/tests/components/incomfort/test_climate.py b/tests/components/incomfort/test_climate.py index 06aa8fc056e..dbcf14e3bd7 100644 --- a/tests/components/incomfort/test_climate.py +++ b/tests/components/incomfort/test_climate.py @@ -1,15 +1,19 @@ """Climate sensor tests for Intergas InComfort integration.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from syrupy import SnapshotAssertion +from homeassistant.components import climate +from homeassistant.components.incomfort.coordinator import InComfortData from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .conftest import MOCK_HEATER_STATUS, MOCK_HEATER_STATUS_HEATING + from tests.common import snapshot_platform @@ -42,3 +46,48 @@ async def test_setup_platform( """ await hass.config_entries.async_setup(mock_config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("mock_heater_status", "hvac_action"), + [ + (MOCK_HEATER_STATUS.copy(), climate.HVACAction.IDLE), + (MOCK_HEATER_STATUS_HEATING.copy(), climate.HVACAction.HEATING), + ], + ids=["idle", "heating"], +) +async def test_hvac_state( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: ConfigEntry, + hvac_action: climate.HVACAction, +) -> None: + """Test the HVAC state of the thermostat.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + state = hass.states.get("climate.thermostat_1") + assert state is not None + assert state.attributes["hvac_action"] is hvac_action + + +async def test_target_temp( + hass: HomeAssistant, mock_incomfort: MagicMock, mock_config_entry: ConfigEntry +) -> None: + """Test changing the target temperature.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + state = hass.states.get("climate.thermostat_1") + assert state is not None + + incomfort_data: InComfortData = mock_config_entry.runtime_data.incomfort_data + + with patch.object( + incomfort_data.heaters[0].rooms[0], "set_override", AsyncMock() + ) as mock_set_override: + await hass.services.async_call( + climate.DOMAIN, + climate.SERVICE_SET_TEMPERATURE, + service_data={ + ATTR_ENTITY_ID: "climate.thermostat_1", + ATTR_TEMPERATURE: 19.0, + }, + ) + mock_set_override.assert_called_once_with(19.0) diff --git a/tests/components/incomfort/test_init.py b/tests/components/incomfort/test_init.py index 504ab02ea81..7557e36219c 100644 --- a/tests/components/incomfort/test_init.py +++ b/tests/components/incomfort/test_init.py @@ -1,13 +1,14 @@ """Tests for Intergas InComfort integration.""" from datetime import timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientResponseError, RequestInfo from freezegun.api import FrozenDateTimeFactory from incomfortclient import IncomfortError import pytest +from homeassistant.components.incomfort import InvalidHeaterList from homeassistant.components.incomfort.coordinator import UPDATE_INTERVAL from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE @@ -102,3 +103,53 @@ async def test_coordinator_update_fails( state = hass.states.get("sensor.boiler_pressure") assert state is not None assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("exc", "config_entry_state"), + [ + ( + IncomfortError(ClientResponseError(None, None, status=401)), + ConfigEntryState.SETUP_ERROR, + ), + ( + IncomfortError(ClientResponseError(None, None, status=404)), + ConfigEntryState.SETUP_ERROR, + ), + (InvalidHeaterList, ConfigEntryState.SETUP_RETRY), + ( + IncomfortError( + ClientResponseError( + RequestInfo( + url="http://example.com", + method="GET", + headers=[], + real_url="http://example.com", + ), + None, + status=500, + ) + ), + ConfigEntryState.SETUP_RETRY, + ), + (IncomfortError(ValueError("some_error")), ConfigEntryState.SETUP_RETRY), + (TimeoutError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_entry_setup_fails( + hass: HomeAssistant, + mock_incomfort: MagicMock, + freezer: FrozenDateTimeFactory, + mock_config_entry: ConfigEntry, + exc: Exception, + config_entry_state: ConfigEntryState, +) -> None: + """Test the incomfort coordinator entry setup fails.""" + with patch( + "homeassistant.components.incomfort.async_connect_gateway", + AsyncMock(side_effect=exc), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + state = hass.states.get("sensor.boiler_pressure") + assert state is None + assert mock_config_entry.state is config_entry_state From bb505baae7ddc919a1c68158fe803c69de0ce9af Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 16 Jan 2025 23:06:20 +0100 Subject: [PATCH 0512/2987] Ensure entity platform in core config tests (#135729) --- tests/test_core_config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_core_config.py b/tests/test_core_config.py index 6c7e188df6d..2723c8e7196 100644 --- a/tests/test_core_config.py +++ b/tests/test_core_config.py @@ -46,7 +46,7 @@ from homeassistant.util.unit_system import ( UnitSystem, ) -from .common import MockUser, async_capture_events +from .common import MockEntityPlatform, MockUser, async_capture_events def test_core_config_schema() -> None: @@ -222,6 +222,7 @@ async def _compute_state(hass: HomeAssistant, config: dict[str, Any]) -> State | entity = Entity() entity.entity_id = "test.test" entity.hass = hass + entity.platform = MockEntityPlatform(hass) entity.schedule_update_ha_state() await hass.async_block_till_done() From ef34a33a7b28f28969fffceb977adaf59b7de1f8 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 17 Jan 2025 00:07:43 +0200 Subject: [PATCH 0513/2987] Remove misleading "Current" in NUT power sensor names (#135800) --- homeassistant/components/nut/strings.json | 28 +++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index ec5905fc16c..83b8d340dc1 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -113,15 +113,15 @@ "input_bypass_l3_n_voltage": { "name": "Input bypass L3-N voltage" }, "input_bypass_frequency": { "name": "Input bypass frequency" }, "input_bypass_phases": { "name": "Input bypass phases" }, - "input_bypass_realpower": { "name": "Current input bypass real power" }, + "input_bypass_realpower": { "name": "Input bypass real power" }, "input_bypass_l1_realpower": { - "name": "Current input bypass L1 real power" + "name": "Input bypass L1 real power" }, "input_bypass_l2_realpower": { - "name": "Current input bypass L2 real power" + "name": "Input bypass L2 real power" }, "input_bypass_l3_realpower": { - "name": "Current input bypass L3 real power" + "name": "Input bypass L3 real power" }, "input_current": { "name": "Input current" }, "input_l1_current": { "name": "Input L1 current" }, @@ -134,10 +134,10 @@ "input_l2_frequency": { "name": "Input L2 line frequency" }, "input_l3_frequency": { "name": "Input L3 line frequency" }, "input_phases": { "name": "Input phases" }, - "input_realpower": { "name": "Current input real power" }, - "input_l1_realpower": { "name": "Current input L1 real power" }, - "input_l2_realpower": { "name": "Current input L2 real power" }, - "input_l3_realpower": { "name": "Current input L3 real power" }, + "input_realpower": { "name": "Input real power" }, + "input_l1_realpower": { "name": "Input L1 real power" }, + "input_l2_realpower": { "name": "Input L2 real power" }, + "input_l3_realpower": { "name": "Input L3 real power" }, "input_sensitivity": { "name": "Input power sensitivity" }, "input_transfer_high": { "name": "High voltage transfer" }, "input_transfer_low": { "name": "Low voltage transfer" }, @@ -160,11 +160,11 @@ "output_l1_power_percent": { "name": "Output L1 power usage" }, "output_l3_power_percent": { "name": "Output L3 power usage" }, "output_power_nominal": { "name": "Nominal output power" }, - "output_realpower": { "name": "Current output real power" }, + "output_realpower": { "name": "Output real power" }, "output_realpower_nominal": { "name": "Nominal output real power" }, - "output_l1_realpower": { "name": "Current output L1 real power" }, - "output_l2_realpower": { "name": "Current output L2 real power" }, - "output_l3_realpower": { "name": "Current output L3 real power" }, + "output_l1_realpower": { "name": "Output L1 real power" }, + "output_l2_realpower": { "name": "Output L2 real power" }, + "output_l3_realpower": { "name": "Output L3 real power" }, "output_voltage": { "name": "Output voltage" }, "output_voltage_nominal": { "name": "Nominal output voltage" }, "output_l1_n_voltage": { "name": "Output L1-N voltage" }, @@ -183,9 +183,9 @@ "ups_id": { "name": "System identifier" }, "ups_load": { "name": "Load" }, "ups_load_high": { "name": "Overload setting" }, - "ups_power": { "name": "Current apparent power" }, + "ups_power": { "name": "Apparent power" }, "ups_power_nominal": { "name": "Nominal power" }, - "ups_realpower": { "name": "Current real power" }, + "ups_realpower": { "name": "Real power" }, "ups_realpower_nominal": { "name": "Nominal real power" }, "ups_shutdown": { "name": "Shutdown ability" }, "ups_start_auto": { "name": "Start on ac" }, From 1fee0a5aa247f30a7954f7a67e6dceef69d38a03 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Jan 2025 23:14:19 +0100 Subject: [PATCH 0514/2987] Improve backup store in tests (#135798) --- .../backup/snapshots/test_websocket.ambr | 2 +- tests/components/backup/test_manager.py | 14 +- tests/components/backup/test_websocket.py | 178 ++++++++++-------- 3 files changed, 105 insertions(+), 89 deletions(-) diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 41e6c574227..06bfa89369a 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -227,7 +227,7 @@ 'type': 'result', }) # --- -# name: test_config_info[None] +# name: test_config_info[storage_data0] dict({ 'id': 1, 'result': dict({ diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index fa98220f810..eef9e069e0f 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -848,11 +848,6 @@ async def test_async_initiate_backup_non_agent_upload_error( exception: Exception, ) -> None: """Test an unknown or writer upload error during backup generation.""" - hass_storage[DOMAIN] = { - "data": {}, - "key": DOMAIN, - "version": 1, - } agent_ids = [LOCAL_AGENT_ID, "test.remote"] local_agent = local_backup_platform.CoreLocalBackupAgent(hass) remote_agent = BackupAgentTest("remote", backups=[]) @@ -944,7 +939,7 @@ async def test_async_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == {"manager_state": BackupManagerState.IDLE} - assert not hass_storage[DOMAIN]["data"] + assert DOMAIN not in hass_storage @pytest.mark.usefixtures("mock_backup_generation") @@ -1724,11 +1719,6 @@ async def test_receive_backup_non_agent_upload_error( exception: Exception, ) -> None: """Test non agent upload error during backup receive.""" - hass_storage[DOMAIN] = { - "data": {}, - "key": DOMAIN, - "version": 1, - } local_agent = local_backup_platform.CoreLocalBackupAgent(hass) remote_agent = BackupAgentTest("remote", backups=[]) @@ -1814,7 +1804,7 @@ async def test_receive_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == {"manager_state": BackupManagerState.IDLE} - assert not hass_storage[DOMAIN]["data"] + assert DOMAIN not in hass_storage assert resp.status == 500 assert open_mock.call_count == 1 assert move_mock.call_count == 0 diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index fe6c0c1f679..00185f8ed07 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -906,95 +906,125 @@ async def test_agents_info( @pytest.mark.parametrize( "storage_data", [ - None, + {}, { - "backups": {}, - "config": { - "create_backup": { - "agent_ids": ["test-agent"], - "include_addons": ["test-addon"], - "include_all_addons": True, - "include_database": True, - "include_folders": ["media"], - "name": "test-name", - "password": "test-password", + "backup": { + "data": { + "backups": {}, + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["test-addon"], + "include_all_addons": True, + "include_database": True, + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + "retention": {"copies": 3, "days": 7}, + "last_attempted_automatic_backup": "2024-10-26T04:45:00+01:00", + "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", + "schedule": {"state": "daily"}, + }, }, - "retention": {"copies": 3, "days": 7}, - "last_attempted_automatic_backup": "2024-10-26T04:45:00+01:00", - "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", - "schedule": {"state": "daily"}, + "key": DOMAIN, + "version": 1, }, }, { - "backups": {}, - "config": { - "create_backup": { - "agent_ids": ["test-agent"], - "include_addons": None, - "include_all_addons": False, - "include_database": False, - "include_folders": None, - "name": None, - "password": None, + "backup": { + "data": { + "backups": {}, + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": 3, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": {"state": "never"}, + }, }, - "retention": {"copies": 3, "days": None}, - "last_attempted_automatic_backup": None, - "last_completed_automatic_backup": None, - "schedule": {"state": "never"}, + "key": DOMAIN, + "version": 1, }, }, { - "backups": {}, - "config": { - "create_backup": { - "agent_ids": ["test-agent"], - "include_addons": None, - "include_all_addons": False, - "include_database": False, - "include_folders": None, - "name": None, - "password": None, + "backup": { + "data": { + "backups": {}, + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": 7}, + "last_attempted_automatic_backup": "2024-10-27T04:45:00+01:00", + "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", + "schedule": {"state": "never"}, + }, }, - "retention": {"copies": None, "days": 7}, - "last_attempted_automatic_backup": "2024-10-27T04:45:00+01:00", - "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", - "schedule": {"state": "never"}, + "key": DOMAIN, + "version": 1, }, }, { - "backups": {}, - "config": { - "create_backup": { - "agent_ids": ["test-agent"], - "include_addons": None, - "include_all_addons": False, - "include_database": False, - "include_folders": None, - "name": None, - "password": None, + "backup": { + "data": { + "backups": {}, + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": {"state": "mon"}, + }, }, - "retention": {"copies": None, "days": None}, - "last_attempted_automatic_backup": None, - "last_completed_automatic_backup": None, - "schedule": {"state": "mon"}, + "key": DOMAIN, + "version": 1, }, }, { - "backups": {}, - "config": { - "create_backup": { - "agent_ids": ["test-agent"], - "include_addons": None, - "include_all_addons": False, - "include_database": False, - "include_folders": None, - "name": None, - "password": None, + "backup": { + "data": { + "backups": {}, + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": {"state": "sat"}, + }, }, - "retention": {"copies": None, "days": None}, - "last_attempted_automatic_backup": None, - "last_completed_automatic_backup": None, - "schedule": {"state": "sat"}, + "key": DOMAIN, + "version": 1, }, }, ], @@ -1007,11 +1037,7 @@ async def test_config_info( storage_data: dict[str, Any] | None, ) -> None: """Test getting backup config info.""" - hass_storage[DOMAIN] = { - "data": storage_data, - "key": DOMAIN, - "version": 1, - } + hass_storage.update(storage_data) await setup_backup_integration(hass) await hass.async_block_till_done() From 99f24ca59c111c26f273affa3ef88455bde5460e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 16 Jan 2025 23:15:07 +0100 Subject: [PATCH 0515/2987] Fix service description to match HA style, fix casing (#135797) --- homeassistant/components/sensibo/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index adebd268ccd..0461df40825 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -512,7 +512,7 @@ }, "get_device_capabilities": { "name": "Get device mode capabilities", - "description": "Retrieve the device capabilities for a specific device according to api requirements.", + "description": "Retrieves the device capabilities for a specific device according to API requirements.", "fields": { "hvac_mode": { "name": "[%key:component::climate::services::set_hvac_mode::fields::hvac_mode::name%]", @@ -576,7 +576,7 @@ "message": "Could not perform action for {name} with error {error}" }, "climate_react_not_available": { - "message": "Use Sensibo Enable Climate React action once to enable switch or the Sensibo app" + "message": "Use Sensibo 'Enable climate react' action once to enable switch or the Sensibo app" }, "auth_error": { "message": "Authentication failed, please update your API key" From 46c5591336e5fc09fe681c6dc9e9ddd902ee7801 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 16 Jan 2025 23:17:42 +0100 Subject: [PATCH 0516/2987] SMA add serial number in DeviceInfo (#135809) SSIA --- homeassistant/components/sma/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/sma/__init__.py b/homeassistant/components/sma/__init__.py index 37fb4d72284..6aae74922e4 100644 --- a/homeassistant/components/sma/__init__.py +++ b/homeassistant/components/sma/__init__.py @@ -72,6 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model=sma_device_info["type"], name=sma_device_info["name"], sw_version=sma_device_info["sw_version"], + serial_number=sma_device_info["serial"], ) # Define the coordinator From a3d24f2472df79fef031a966783419f6976668f7 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 16 Jan 2025 23:18:54 +0100 Subject: [PATCH 0517/2987] Fix spelling of "API" and use consistent term "API token" (#135795) --- homeassistant/components/blue_current/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 3ba6349b714..0154c794c33 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -5,7 +5,7 @@ "data": { "api_token": "[%key:common::config_flow::data::api_token%]" }, - "description": "Enter your Blue Current api token", + "description": "Enter your Blue Current API token", "title": "Authentication" } }, @@ -19,7 +19,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "wrong_account": "Wrong account: Please authenticate with the api key for {email}." + "wrong_account": "Wrong account: Please authenticate with the API token for {email}." } }, "entity": { From 3e4d92f6a7c3afd8aa0f5d511cf1e54b9577a7a1 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Thu, 16 Jan 2025 23:19:41 +0100 Subject: [PATCH 0518/2987] Bump eheimdigital to 1.0.5 (#135802) --- homeassistant/components/eheimdigital/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json index 6ca85c74a43..7747ca4f95d 100644 --- a/homeassistant/components/eheimdigital/manifest.json +++ b/homeassistant/components/eheimdigital/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["eheimdigital"], "quality_scale": "bronze", - "requirements": ["eheimdigital==1.0.4"], + "requirements": ["eheimdigital==1.0.5"], "zeroconf": [ { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } ] diff --git a/requirements_all.txt b/requirements_all.txt index 5c183fcecd2..4e43ba76c9f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -815,7 +815,7 @@ ebusdpy==0.0.17 ecoaliface==0.4.0 # homeassistant.components.eheimdigital -eheimdigital==1.0.4 +eheimdigital==1.0.5 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6be23b85746..58b50ba79ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ eagle100==0.1.1 easyenergy==2.1.2 # homeassistant.components.eheimdigital -eheimdigital==1.0.4 +eheimdigital==1.0.5 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 From 59429dea39f7f8811395b311ae6695f0051a8fb6 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 16 Jan 2025 23:20:36 +0100 Subject: [PATCH 0519/2987] Bump SMA to 0.7.5 (#135799) --- homeassistant/components/sma/manifest.json | 2 +- homeassistant/components/sma/sensor.py | 6 ++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index 070320fa976..a419f0fef6f 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sma", "iot_class": "local_polling", "loggers": ["pysma"], - "requirements": ["pysma==0.7.3"] + "requirements": ["pysma==0.7.5"] } diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 302c4f6b197..863f15a9a17 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -48,6 +48,12 @@ SENSOR_ENTITIES: dict[str, SensorEntityDescription] = { entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), + "operating_status": SensorEntityDescription( + key="operating_status", + name="Operating Status", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), "inverter_condition": SensorEntityDescription( key="inverter_condition", name="Inverter Condition", diff --git a/requirements_all.txt b/requirements_all.txt index 4e43ba76c9f..4fb8906837b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2285,7 +2285,7 @@ pysignalclirestapi==0.3.24 pyskyqhub==0.1.4 # homeassistant.components.sma -pysma==0.7.3 +pysma==0.7.5 # homeassistant.components.smappee pysmappee==0.2.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 58b50ba79ed..ad50df99298 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1857,7 +1857,7 @@ pysiaalarm==3.1.1 pysignalclirestapi==0.3.24 # homeassistant.components.sma -pysma==0.7.3 +pysma==0.7.5 # homeassistant.components.smappee pysmappee==0.2.29 From e433c2250cf6554adb67b5aece3d549015609844 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 16 Jan 2025 23:22:28 +0100 Subject: [PATCH 0520/2987] Several strings fixes in the emoncms integration (#135792) --- homeassistant/components/emoncms/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json index 5769e825944..77216a3fb2f 100644 --- a/homeassistant/components/emoncms/strings.json +++ b/homeassistant/components/emoncms/strings.json @@ -10,8 +10,8 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "url": "Server url starting with the protocol (http or https)", - "api_key": "Your 32 bits api key" + "url": "Server URL starting with the protocol (http or https)", + "api_key": "Your 32 bits API key" } }, "choose_feeds": { @@ -93,7 +93,7 @@ }, "migrate_database": { "title": "Upgrade your emoncms version", - "description": "Your [emoncms]({url}) does not ship a unique identifier.\n\n Please upgrade to at least version 11.5.7 and migrate your emoncms database.\n\n More info on [emoncms documentation]({doc_url})" + "description": "Your [emoncms]({url}) does not ship a unique identifier.\n\nPlease upgrade to at least version 11.5.7 and migrate your emoncms database.\n\nMore info in the [emoncms documentation]({doc_url})" } } } From 619917c679b0211231a179299a7f8c684cf63c23 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 16 Jan 2025 23:26:18 +0100 Subject: [PATCH 0521/2987] Ensure entity platform in media_player tests (#135788) --- tests/components/media_player/test_init.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index de267f2719e..9db2621f84f 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -21,7 +21,11 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import help_test_all, import_and_test_deprecated_constant_enum +from tests.common import ( + MockEntityPlatform, + help_test_all, + import_and_test_deprecated_constant_enum, +) from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -116,19 +120,27 @@ def test_deprecated_constants_const( "grouping", ], ) -def test_support_properties(property_suffix: str) -> None: +def test_support_properties(hass: HomeAssistant, property_suffix: str) -> None: """Test support_*** properties explicitly.""" all_features = media_player.MediaPlayerEntityFeature(653887) feature = media_player.MediaPlayerEntityFeature[property_suffix.upper()] entity1 = MediaPlayerEntity() + entity1.hass = hass + entity1.platform = MockEntityPlatform(hass) entity1._attr_supported_features = media_player.MediaPlayerEntityFeature(0) entity2 = MediaPlayerEntity() + entity2.hass = hass + entity2.platform = MockEntityPlatform(hass) entity2._attr_supported_features = all_features entity3 = MediaPlayerEntity() + entity3.hass = hass + entity3.platform = MockEntityPlatform(hass) entity3._attr_supported_features = feature entity4 = MediaPlayerEntity() + entity4.hass = hass + entity4.platform = MockEntityPlatform(hass) entity4._attr_supported_features = all_features - feature assert getattr(entity1, f"support_{property_suffix}") is False @@ -448,7 +460,9 @@ async def test_get_async_get_browse_image_quoting( mock_browse_image.assert_called_with("album", media_content_id, None) -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: +def test_deprecated_supported_features_ints( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test deprecated supported features ints.""" class MockMediaPlayerEntity(MediaPlayerEntity): @@ -458,6 +472,8 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> return 1 entity = MockMediaPlayerEntity() + entity.hass = hass + entity.platform = MockEntityPlatform(hass) assert entity.supported_features_compat is MediaPlayerEntityFeature(1) assert "MockMediaPlayerEntity" in caplog.text assert "is using deprecated supported features values" in caplog.text From 88c3be4ecfb228a414636439d186751538328b7a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 16 Jan 2025 23:26:50 +0100 Subject: [PATCH 0522/2987] Ensure entity platform in light tests (#135787) --- tests/components/light/test_init.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index c947de5923b..6d0337f37a5 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -2626,7 +2626,9 @@ def test_filter_supported_color_modes() -> None: assert light.filter_supported_color_modes(supported) == {light.ColorMode.BRIGHTNESS} -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: +def test_deprecated_supported_features_ints( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test deprecated supported features ints.""" class MockLightEntityEntity(light.LightEntity): @@ -2636,6 +2638,8 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> return 1 entity = MockLightEntityEntity() + entity.hass = hass + entity.platform = MockEntityPlatform(hass, domain="test", platform_name="test") assert entity.supported_features_compat is light.LightEntityFeature(1) assert "MockLightEntityEntity" in caplog.text assert "is using deprecated supported features values" in caplog.text From e5164496cf9ddc2e6cac391b18a6c36eff0d01f5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 16 Jan 2025 23:27:54 +0100 Subject: [PATCH 0523/2987] Ensure entity platform in vacuum tests (#135786) --- tests/components/vacuum/test_init.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 8babd9fa265..8ae054b5646 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -33,6 +33,7 @@ from .common import async_start from tests.common import ( MockConfigEntry, MockEntity, + MockEntityPlatform, MockModule, help_test_all, import_and_test_deprecated_constant_enum, @@ -288,6 +289,8 @@ async def test_supported_features_compat(hass: HomeAssistant) -> None: _attr_fan_speed_list = ["silent", "normal", "pet hair"] entity = _LegacyConstantsStateVacuum() + entity.hass = hass + entity.platform = MockEntityPlatform(hass) assert isinstance(entity.supported_features, int) assert entity.supported_features == int(features) assert entity.supported_features_compat is ( From e6c696933fea8a56beefa9fd611af1d44c833ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Konrad=20Vit=C3=A9?= Date: Thu, 16 Jan 2025 23:31:16 +0100 Subject: [PATCH 0524/2987] Fix DiscoveryFlowHandler when discovery_function returns bool (#133563) Co-authored-by: J. Nick Koston --- homeassistant/helpers/config_entry_flow.py | 8 ++- tests/helpers/test_config_entry_flow.py | 65 +++++++++++++++++++--- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 57cf5d9c1bc..45e2e7cf35f 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -67,9 +67,11 @@ class DiscoveryFlowHandler[_R: Awaitable[bool] | bool](config_entries.ConfigFlow in_progress = self._async_in_progress() if not (has_devices := bool(in_progress)): - has_devices = await cast( - "asyncio.Future[bool]", self._discovery_function(self.hass) - ) + discovery_result = self._discovery_function(self.hass) + if isinstance(discovery_result, bool): + has_devices = discovery_result + else: + has_devices = await cast("asyncio.Future[bool]", discovery_result) if not has_devices: return self.async_abort(reason="no_devices_found") diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 13e28bb8840..172aa393538 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -1,6 +1,8 @@ """Tests for the Config Entry Flow helper.""" -from collections.abc import Generator +import asyncio +from collections.abc import Callable, Generator +from contextlib import contextmanager from unittest.mock import Mock, PropertyMock, patch import pytest @@ -13,22 +15,44 @@ from homeassistant.helpers import config_entry_flow from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform +@contextmanager +def _make_discovery_flow_conf( + has_discovered_devices: Callable[[], asyncio.Future[bool] | bool], +) -> Generator[None]: + with patch.dict(config_entries.HANDLERS): + config_entry_flow.register_discovery_flow( + "test", "Test", has_discovered_devices + ) + yield + + @pytest.fixture -def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: - """Register a handler.""" +def async_discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: + """Register a handler with an async discovery function.""" handler_conf = {"discovered": False} async def has_discovered_devices(hass: HomeAssistant) -> bool: """Mock if we have discovered devices.""" return handler_conf["discovered"] - with patch.dict(config_entries.HANDLERS): - config_entry_flow.register_discovery_flow( - "test", "Test", has_discovered_devices - ) + with _make_discovery_flow_conf(has_discovered_devices): yield handler_conf +@pytest.fixture +def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: + """Register a handler with a async friendly callback function.""" + handler_conf = {"discovered": False} + + def has_discovered_devices(hass: HomeAssistant) -> bool: + """Mock if we have discovered devices.""" + return handler_conf["discovered"] + + with _make_discovery_flow_conf(has_discovered_devices): + yield handler_conf + handler_conf = {"discovered": False} + + @pytest.fixture def webhook_flow_conf(hass: HomeAssistant) -> Generator[None]: """Register a handler.""" @@ -95,6 +119,33 @@ async def test_user_has_confirmation( assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY +async def test_user_has_confirmation_async_discovery_flow( + hass: HomeAssistant, async_discovery_flow_conf: dict[str, bool] +) -> None: + """Test user requires confirmation to setup with an async has_discovered_devices.""" + async_discovery_flow_conf["discovered"] = True + mock_platform(hass, "test.config_flow", None) + + result = await hass.config_entries.flow.async_init( + "test", context={"source": config_entries.SOURCE_USER}, data={} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "confirm" + + progress = hass.config_entries.flow.async_progress() + assert len(progress) == 1 + assert progress[0]["flow_id"] == result["flow_id"] + assert progress[0]["context"] == { + "confirm_only": True, + "source": config_entries.SOURCE_USER, + "unique_id": "test", + } + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + @pytest.mark.parametrize( "source", [ From b0d3aa1c347302236a07f55a5908248cac891f1e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 16 Jan 2025 22:42:03 +0000 Subject: [PATCH 0525/2987] Bump `imgw_pib` to version 1.0.9 and remove hydrological detail entities (#134668) --- homeassistant/components/imgw_pib/__init__.py | 18 ++- .../components/imgw_pib/binary_sensor.py | 82 -------------- homeassistant/components/imgw_pib/icons.json | 20 ---- .../components/imgw_pib/manifest.json | 2 +- homeassistant/components/imgw_pib/sensor.py | 33 ++---- .../components/imgw_pib/strings.json | 14 --- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/imgw_pib/conftest.py | 8 +- .../snapshots/test_binary_sensor.ambr | 97 ---------------- .../imgw_pib/snapshots/test_diagnostics.ambr | 8 +- .../imgw_pib/snapshots/test_sensor.ambr | 104 ------------------ .../components/imgw_pib/test_binary_sensor.py | 65 ----------- tests/components/imgw_pib/test_init.py | 24 ++++ tests/components/imgw_pib/test_sensor.py | 25 ++++- 15 files changed, 86 insertions(+), 418 deletions(-) delete mode 100644 homeassistant/components/imgw_pib/binary_sensor.py delete mode 100644 tests/components/imgw_pib/snapshots/test_binary_sensor.ambr delete mode 100644 tests/components/imgw_pib/test_binary_sensor.py diff --git a/homeassistant/components/imgw_pib/__init__.py b/homeassistant/components/imgw_pib/__init__.py index caf4e058e06..eb12e1a2bb4 100644 --- a/homeassistant/components/imgw_pib/__init__.py +++ b/homeassistant/components/imgw_pib/__init__.py @@ -9,16 +9,18 @@ from aiohttp import ClientError from imgw_pib import ImgwPib from imgw_pib.exceptions import ApiError +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_PLATFORM from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_STATION_ID +from .const import CONF_STATION_ID, DOMAIN from .coordinator import ImgwPibDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -42,7 +44,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> b try: imgwpib = await ImgwPib.create( - client_session, hydrological_station_id=station_id + client_session, + hydrological_station_id=station_id, + hydrological_details=False, ) except (ClientError, TimeoutError, ApiError) as err: raise ConfigEntryNotReady from err @@ -50,6 +54,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> b coordinator = ImgwPibDataUpdateCoordinator(hass, imgwpib, station_id) await coordinator.async_config_entry_first_refresh() + # Remove binary_sensor entities for which the endpoint has been blocked by IMGW-PIB API + entity_reg = er.async_get(hass) + for key in ("flood_warning", "flood_alarm"): + if entity_id := entity_reg.async_get_entity_id( + BINARY_SENSOR_PLATFORM, DOMAIN, f"{coordinator.station_id}_{key}" + ): + entity_reg.async_remove(entity_id) + entry.runtime_data = ImgwPibData(coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/imgw_pib/binary_sensor.py b/homeassistant/components/imgw_pib/binary_sensor.py deleted file mode 100644 index 1c4cc738f8f..00000000000 --- a/homeassistant/components/imgw_pib/binary_sensor.py +++ /dev/null @@ -1,82 +0,0 @@ -"""IMGW-PIB binary sensor platform.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass - -from imgw_pib.model import HydrologicalData - -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, - BinarySensorEntityDescription, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import ImgwPibConfigEntry -from .coordinator import ImgwPibDataUpdateCoordinator -from .entity import ImgwPibEntity - -PARALLEL_UPDATES = 1 - - -@dataclass(frozen=True, kw_only=True) -class ImgwPibBinarySensorEntityDescription(BinarySensorEntityDescription): - """IMGW-PIB sensor entity description.""" - - value: Callable[[HydrologicalData], bool | None] - - -BINARY_SENSOR_TYPES: tuple[ImgwPibBinarySensorEntityDescription, ...] = ( - ImgwPibBinarySensorEntityDescription( - key="flood_warning", - translation_key="flood_warning", - device_class=BinarySensorDeviceClass.SAFETY, - value=lambda data: data.flood_warning, - ), - ImgwPibBinarySensorEntityDescription( - key="flood_alarm", - translation_key="flood_alarm", - device_class=BinarySensorDeviceClass.SAFETY, - value=lambda data: data.flood_alarm, - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ImgwPibConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Add a IMGW-PIB binary sensor entity from a config_entry.""" - coordinator = entry.runtime_data.coordinator - - async_add_entities( - ImgwPibBinarySensorEntity(coordinator, description) - for description in BINARY_SENSOR_TYPES - if getattr(coordinator.data, description.key) is not None - ) - - -class ImgwPibBinarySensorEntity(ImgwPibEntity, BinarySensorEntity): - """Define IMGW-PIB binary sensor entity.""" - - entity_description: ImgwPibBinarySensorEntityDescription - - def __init__( - self, - coordinator: ImgwPibDataUpdateCoordinator, - description: ImgwPibBinarySensorEntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinator) - - self._attr_unique_id = f"{coordinator.station_id}_{description.key}" - self.entity_description = description - - @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - return self.entity_description.value(self.coordinator.data) diff --git a/homeassistant/components/imgw_pib/icons.json b/homeassistant/components/imgw_pib/icons.json index bf8608ae21b..29aa19a4b56 100644 --- a/homeassistant/components/imgw_pib/icons.json +++ b/homeassistant/components/imgw_pib/icons.json @@ -1,26 +1,6 @@ { "entity": { - "binary_sensor": { - "flood_warning": { - "default": "mdi:check-circle", - "state": { - "on": "mdi:home-flood" - } - }, - "flood_alarm": { - "default": "mdi:check-circle", - "state": { - "on": "mdi:home-flood" - } - } - }, "sensor": { - "flood_warning_level": { - "default": "mdi:alert-outline" - }, - "flood_alarm_level": { - "default": "mdi:alert" - }, "water_level": { "default": "mdi:waves" }, diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index ce3bc14d37b..0ecc1b4b7d0 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", - "requirements": ["imgw_pib==1.0.7"] + "requirements": ["imgw_pib==1.0.9"] } diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index f000222b31b..15043af2015 100644 --- a/homeassistant/components/imgw_pib/sensor.py +++ b/homeassistant/components/imgw_pib/sensor.py @@ -8,17 +8,20 @@ from dataclasses import dataclass from imgw_pib.model import HydrologicalData from homeassistant.components.sensor import ( + DOMAIN as SENSOR_PLATFORM, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import EntityCategory, UnitOfLength, UnitOfTemperature +from homeassistant.const import UnitOfLength, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import ImgwPibConfigEntry +from .const import DOMAIN from .coordinator import ImgwPibDataUpdateCoordinator from .entity import ImgwPibEntity @@ -33,26 +36,6 @@ class ImgwPibSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES: tuple[ImgwPibSensorEntityDescription, ...] = ( - ImgwPibSensorEntityDescription( - key="flood_alarm_level", - translation_key="flood_alarm_level", - native_unit_of_measurement=UnitOfLength.CENTIMETERS, - device_class=SensorDeviceClass.DISTANCE, - entity_category=EntityCategory.DIAGNOSTIC, - suggested_display_precision=0, - entity_registry_enabled_default=False, - value=lambda data: data.flood_alarm_level.value, - ), - ImgwPibSensorEntityDescription( - key="flood_warning_level", - translation_key="flood_warning_level", - native_unit_of_measurement=UnitOfLength.CENTIMETERS, - device_class=SensorDeviceClass.DISTANCE, - entity_category=EntityCategory.DIAGNOSTIC, - suggested_display_precision=0, - entity_registry_enabled_default=False, - value=lambda data: data.flood_warning_level.value, - ), ImgwPibSensorEntityDescription( key="water_level", translation_key="water_level", @@ -82,6 +65,14 @@ async def async_setup_entry( """Add a IMGW-PIB sensor entity from a config_entry.""" coordinator = entry.runtime_data.coordinator + # Remove entities for which the endpoint has been blocked by IMGW-PIB API + entity_reg = er.async_get(hass) + for key in ("flood_warning_level", "flood_alarm_level"): + if entity_id := entity_reg.async_get_entity_id( + SENSOR_PLATFORM, DOMAIN, f"{coordinator.station_id}_{key}" + ): + entity_reg.async_remove(entity_id) + async_add_entities( ImgwPibSensorEntity(coordinator, description) for description in SENSOR_TYPES diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index 6bc337d5720..9a17dcf7087 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -17,21 +17,7 @@ } }, "entity": { - "binary_sensor": { - "flood_alarm": { - "name": "Flood alarm" - }, - "flood_warning": { - "name": "Flood warning" - } - }, "sensor": { - "flood_alarm_level": { - "name": "Flood alarm level" - }, - "flood_warning_level": { - "name": "Flood warning level" - }, "water_level": { "name": "Water level" }, diff --git a/requirements_all.txt b/requirements_all.txt index 4fb8906837b..4fb97d32ab3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1205,7 +1205,7 @@ igloohome-api==0.0.6 ihcsdk==2.8.5 # homeassistant.components.imgw_pib -imgw_pib==1.0.7 +imgw_pib==1.0.9 # homeassistant.components.incomfort incomfort-client==0.6.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ad50df99298..fc7af7389ae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1019,7 +1019,7 @@ ifaddr==0.2.0 igloohome-api==0.0.6 # homeassistant.components.imgw_pib -imgw_pib==1.0.7 +imgw_pib==1.0.9 # homeassistant.components.incomfort incomfort-client==0.6.4 diff --git a/tests/components/imgw_pib/conftest.py b/tests/components/imgw_pib/conftest.py index 6f23ed3ee80..a10b9b54532 100644 --- a/tests/components/imgw_pib/conftest.py +++ b/tests/components/imgw_pib/conftest.py @@ -16,11 +16,11 @@ HYDROLOGICAL_DATA = HydrologicalData( river="River Name", station_id="123", water_level=SensorData(name="Water Level", value=526.0), - flood_alarm_level=SensorData(name="Flood Alarm Level", value=630.0), - flood_warning_level=SensorData(name="Flood Warning Level", value=590.0), + flood_alarm_level=SensorData(name="Flood Alarm Level", value=None), + flood_warning_level=SensorData(name="Flood Warning Level", value=None), water_temperature=SensorData(name="Water Temperature", value=10.8), - flood_alarm=False, - flood_warning=False, + flood_alarm=None, + flood_warning=None, water_level_measurement_date=datetime(2024, 4, 27, 10, 0, tzinfo=UTC), water_temperature_measurement_date=datetime(2024, 4, 27, 10, 10, tzinfo=UTC), ) diff --git a/tests/components/imgw_pib/snapshots/test_binary_sensor.ambr b/tests/components/imgw_pib/snapshots/test_binary_sensor.ambr deleted file mode 100644 index c5ae6880022..00000000000 --- a/tests/components/imgw_pib/snapshots/test_binary_sensor.ambr +++ /dev/null @@ -1,97 +0,0 @@ -# serializer version: 1 -# name: test_binary_sensor[binary_sensor.river_name_station_name_flood_alarm-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.river_name_station_name_flood_alarm', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Flood alarm', - 'platform': 'imgw_pib', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'flood_alarm', - 'unique_id': '123_flood_alarm', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.river_name_station_name_flood_alarm-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by IMGW-PIB', - 'device_class': 'safety', - 'friendly_name': 'River Name (Station Name) Flood alarm', - }), - 'context': , - 'entity_id': 'binary_sensor.river_name_station_name_flood_alarm', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensor[binary_sensor.river_name_station_name_flood_warning-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.river_name_station_name_flood_warning', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Flood warning', - 'platform': 'imgw_pib', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'flood_warning', - 'unique_id': '123_flood_warning', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.river_name_station_name_flood_warning-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by IMGW-PIB', - 'device_class': 'safety', - 'friendly_name': 'River Name (Station Name) Flood warning', - }), - 'context': , - 'entity_id': 'binary_sensor.river_name_station_name_flood_warning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index 494980ba4ce..a98f60a2b3e 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -20,17 +20,17 @@ 'version': 1, }), 'hydrological_data': dict({ - 'flood_alarm': False, + 'flood_alarm': None, 'flood_alarm_level': dict({ 'name': 'Flood Alarm Level', 'unit': None, - 'value': 630.0, + 'value': None, }), - 'flood_warning': False, + 'flood_warning': None, 'flood_warning_level': dict({ 'name': 'Flood Warning Level', 'unit': None, - 'value': 590.0, + 'value': None, }), 'river': 'River Name', 'station': 'Station Name', diff --git a/tests/components/imgw_pib/snapshots/test_sensor.ambr b/tests/components/imgw_pib/snapshots/test_sensor.ambr index 6c69b890842..c7779f5d850 100644 --- a/tests/components/imgw_pib/snapshots/test_sensor.ambr +++ b/tests/components/imgw_pib/snapshots/test_sensor.ambr @@ -1,108 +1,4 @@ # serializer version: 1 -# name: test_sensor[sensor.river_name_station_name_flood_alarm_level-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.river_name_station_name_flood_alarm_level', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Flood alarm level', - 'platform': 'imgw_pib', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'flood_alarm_level', - 'unique_id': '123_flood_alarm_level', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor[sensor.river_name_station_name_flood_alarm_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by IMGW-PIB', - 'device_class': 'distance', - 'friendly_name': 'River Name (Station Name) Flood alarm level', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.river_name_station_name_flood_alarm_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '630.0', - }) -# --- -# name: test_sensor[sensor.river_name_station_name_flood_warning_level-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.river_name_station_name_flood_warning_level', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Flood warning level', - 'platform': 'imgw_pib', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'flood_warning_level', - 'unique_id': '123_flood_warning_level', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor[sensor.river_name_station_name_flood_warning_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by IMGW-PIB', - 'device_class': 'distance', - 'friendly_name': 'River Name (Station Name) Flood warning level', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.river_name_station_name_flood_warning_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '590.0', - }) -# --- # name: test_sensor[sensor.river_name_station_name_water_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/imgw_pib/test_binary_sensor.py b/tests/components/imgw_pib/test_binary_sensor.py deleted file mode 100644 index 185d4b18575..00000000000 --- a/tests/components/imgw_pib/test_binary_sensor.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Test the IMGW-PIB binary sensor platform.""" - -from unittest.mock import AsyncMock, patch - -from freezegun.api import FrozenDateTimeFactory -from imgw_pib import ApiError -from syrupy import SnapshotAssertion - -from homeassistant.components.imgw_pib.const import UPDATE_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from . import init_integration - -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform - -ENTITY_ID = "binary_sensor.river_name_station_name_flood_alarm" - - -async def test_binary_sensor( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, - mock_imgw_pib_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test states of the binary sensor.""" - with patch("homeassistant.components.imgw_pib.PLATFORMS", [Platform.BINARY_SENSOR]): - await init_integration(hass, mock_config_entry) - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - -async def test_availability( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_imgw_pib_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure that we mark the entities unavailable correctly when service is offline.""" - await init_integration(hass, mock_config_entry) - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "off" - - mock_imgw_pib_client.get_hydrological_data.side_effect = ApiError("API Error") - freezer.tick(UPDATE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_UNAVAILABLE - - mock_imgw_pib_client.get_hydrological_data.side_effect = None - freezer.tick(UPDATE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "off" diff --git a/tests/components/imgw_pib/test_init.py b/tests/components/imgw_pib/test_init.py index e1b7cda7c88..834fd505497 100644 --- a/tests/components/imgw_pib/test_init.py +++ b/tests/components/imgw_pib/test_init.py @@ -4,9 +4,11 @@ from unittest.mock import AsyncMock, patch from imgw_pib import ApiError +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_PLATFORM from homeassistant.components.imgw_pib.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import init_integration @@ -43,3 +45,25 @@ async def test_unload_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) + + +async def test_remove_binary_sensor_entity( + hass: HomeAssistant, + mock_imgw_pib_client: AsyncMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing a binary_sensor entity.""" + entity_id = "binary_sensor.river_name_station_name_flood_alarm" + + entity_registry.async_get_or_create( + BINARY_SENSOR_PLATFORM, + DOMAIN, + "123_flood_alarm", + suggested_object_id=entity_id.rsplit(".", maxsplit=1)[-1], + config_entry=mock_config_entry, + ) + + await init_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id) is None diff --git a/tests/components/imgw_pib/test_sensor.py b/tests/components/imgw_pib/test_sensor.py index 276c021fad5..c38c8e6773e 100644 --- a/tests/components/imgw_pib/test_sensor.py +++ b/tests/components/imgw_pib/test_sensor.py @@ -7,7 +7,8 @@ from imgw_pib import ApiError import pytest from syrupy import SnapshotAssertion -from homeassistant.components.imgw_pib.const import UPDATE_INTERVAL +from homeassistant.components.imgw_pib.const import DOMAIN, UPDATE_INTERVAL +from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -65,3 +66,25 @@ async def test_availability( assert state assert state.state != STATE_UNAVAILABLE assert state.state == "526.0" + + +async def test_remove_entity( + hass: HomeAssistant, + mock_imgw_pib_client: AsyncMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing entity.""" + entity_id = "sensor.river_name_station_name_flood_alarm_level" + + entity_registry.async_get_or_create( + SENSOR_PLATFORM, + DOMAIN, + "123_flood_alarm_level", + suggested_object_id=entity_id.rsplit(".", maxsplit=1)[-1], + config_entry=mock_config_entry, + ) + + await init_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id) is None From 8b12f5270e61e469694a5a5224be680988655122 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 16 Jan 2025 23:43:14 +0100 Subject: [PATCH 0526/2987] Enable more RUF rules (#135770) Co-authored-by: Shay Levy --- pyproject.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 26224fc3b63..6a22a51a2b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -754,13 +754,23 @@ select = [ "RSE", # flake8-raise "RUF005", # Consider iterable unpacking instead of concatenation "RUF006", # Store a reference to the return value of asyncio.create_task + "RUF007", # Prefer itertools.pairwise() over zip() when iterating over successive pairs + "RUF008", # Do not use mutable default values for dataclass attributes "RUF010", # Use explicit conversion flag "RUF013", # PEP 484 prohibits implicit Optional + "RUF016", # Slice in indexed access to type {value_type} uses type {index_type} instead of an integer "RUF017", # Avoid quadratic list summation "RUF018", # Avoid assignment expressions in assert statements "RUF019", # Unnecessary key check before dictionary access + "RUF020", # {never_like} | T is equivalent to T "RUF022", # Sort __all__ + "RUF024", # Do not pass mutable objects as values to dict.fromkeys + "RUF026", # default_factory is a positional-only argument to defaultdict + "RUF030", # print() call in assert statement is likely unintentional + "RUF033", # __post_init__ method with argument defaults + "RUF034", # Useless if-else condition "RUF100", # Unused `noqa` directive + "RUF200", # Failed to parse pyproject.toml: {message} "S102", # Use of exec detected "S103", # bad-file-permissions "S108", # hardcoded-temp-file From 632c166201d06bdc8b59d37635bb507869b8ccb0 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 16 Jan 2025 23:48:40 +0100 Subject: [PATCH 0527/2987] SMA update code owners (#135812) Update code owners --- CODEOWNERS | 4 ++-- homeassistant/components/sma/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index bf8b1502a10..cbf471b1680 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1382,8 +1382,8 @@ build.json @home-assistant/supervisor /tests/components/slide_local/ @dontinelli /homeassistant/components/slimproto/ @marcelveldt /tests/components/slimproto/ @marcelveldt -/homeassistant/components/sma/ @kellerza @rklomp -/tests/components/sma/ @kellerza @rklomp +/homeassistant/components/sma/ @kellerza @rklomp @erwindouna +/tests/components/sma/ @kellerza @rklomp @erwindouna /homeassistant/components/smappee/ @bsmappee /tests/components/smappee/ @bsmappee /homeassistant/components/smart_meter_texas/ @grahamwetzler diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index a419f0fef6f..8024aad82d6 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -1,7 +1,7 @@ { "domain": "sma", "name": "SMA Solar", - "codeowners": ["@kellerza", "@rklomp"], + "codeowners": ["@kellerza", "@rklomp", "@erwindouna"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sma", "iot_class": "local_polling", From 02ec1d1b71b6793f0c572db3ac2e6b9c7d951931 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Fri, 17 Jan 2025 11:41:09 +1100 Subject: [PATCH 0528/2987] New paint_theme service added to the LIFX integration (#135667) * New paint_theme service added to the LIFX integration Signed-off-by: Avi Miller Co-authored-by: J. Nick Koston * Move effect selection into a dispatch table Signed-off-by: Avi Miller --------- Signed-off-by: Avi Miller Co-authored-by: J. Nick Koston --- homeassistant/components/lifx/icons.json | 3 + homeassistant/components/lifx/manager.py | 369 +++++++++++++------- homeassistant/components/lifx/services.yaml | 110 +++++- homeassistant/components/lifx/strings.json | 24 +- tests/components/lifx/test_light.py | 99 ++++++ 5 files changed, 468 insertions(+), 137 deletions(-) diff --git a/homeassistant/components/lifx/icons.json b/homeassistant/components/lifx/icons.json index 58a7c89e266..c37d7641717 100644 --- a/homeassistant/components/lifx/icons.json +++ b/homeassistant/components/lifx/icons.json @@ -26,6 +26,9 @@ }, "effect_stop": { "service": "mdi:stop" + }, + "paint_theme": { + "service": "mdi:palette" } } } diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 27e62717e96..3d1b61c4b58 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -8,6 +8,7 @@ from datetime import timedelta from typing import Any import aiolifx_effects +from aiolifx_themes.painter import ThemePainter from aiolifx_themes.themes import Theme, ThemeLibrary import voluptuous as vol @@ -42,6 +43,7 @@ SERVICE_EFFECT_MOVE = "effect_move" SERVICE_EFFECT_PULSE = "effect_pulse" SERVICE_EFFECT_SKY = "effect_sky" SERVICE_EFFECT_STOP = "effect_stop" +SERVICE_PAINT_THEME = "paint_theme" ATTR_CHANGE = "change" ATTR_CLOUD_SATURATION_MIN = "cloud_saturation_min" @@ -83,6 +85,8 @@ EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX = 180 EFFECT_SKY_SKY_TYPES = ["Sunrise", "Sunset", "Clouds"] +PAINT_THEME_DEFAULT_TRANSITION = 1 + PULSE_MODE_BLINK = "blink" PULSE_MODE_BREATHE = "breathe" PULSE_MODE_PING = "ping" @@ -201,6 +205,18 @@ LIFX_EFFECT_SKY_SCHEMA = cv.make_entity_service_schema( } ) +LIFX_PAINT_THEME_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + ATTR_TRANSITION: vol.All(vol.Coerce(int), vol.Clamp(min=1, max=3600)), + vol.Exclusive(ATTR_THEME, COLOR_GROUP): vol.Optional( + vol.In(ThemeLibrary().themes) + ), + vol.Exclusive(ATTR_PALETTE, COLOR_GROUP): vol.All( + cv.ensure_list, [HSBK_SCHEMA] + ), + } +) SERVICES = ( SERVICE_EFFECT_COLORLOOP, @@ -210,6 +226,7 @@ SERVICES = ( SERVICE_EFFECT_PULSE, SERVICE_EFFECT_SKY, SERVICE_EFFECT_STOP, + SERVICE_PAINT_THEME, ) @@ -302,6 +319,222 @@ class LIFXManager: schema=LIFX_EFFECT_STOP_SCHEMA, ) + self.hass.services.async_register( + DOMAIN, + SERVICE_PAINT_THEME, + service_handler, + schema=LIFX_PAINT_THEME_SCHEMA, + ) + + @staticmethod + def build_theme(theme_name: str = "exciting", palette: list | None = None) -> Theme: + """Either return the predefined theme or build one from the palette.""" + if palette is not None: + theme = Theme() + for hsbk in palette: + theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) + else: + theme = ThemeLibrary().get_theme(theme_name) + + return theme + + async def _start_effect_flame( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the firmware-based Flame effect.""" + + await asyncio.gather( + *( + coordinator.async_set_matrix_effect( + effect=EFFECT_FLAME, + speed=kwargs.get(ATTR_SPEED, EFFECT_FLAME_DEFAULT_SPEED), + power_on=kwargs.get(ATTR_POWER_ON, True), + ) + for coordinator in coordinators + ) + ) + + async def _start_paint_theme( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Paint a theme across one or more LIFX bulbs.""" + theme_name = kwargs.get(ATTR_THEME, "exciting") + palette = kwargs.get(ATTR_PALETTE) + + theme = self.build_theme(theme_name, palette) + + await ThemePainter(self.hass.loop).paint( + theme, + bulbs, + duration=kwargs.get(ATTR_TRANSITION, PAINT_THEME_DEFAULT_TRANSITION), + power_on=kwargs.get(ATTR_POWER_ON, True), + ) + + async def _start_effect_morph( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the firmware-based Morph effect.""" + theme_name = kwargs.get(ATTR_THEME, "exciting") + palette = kwargs.get(ATTR_PALETTE) + + theme = self.build_theme(theme_name, palette) + + await asyncio.gather( + *( + coordinator.async_set_matrix_effect( + effect=EFFECT_MORPH, + speed=kwargs.get(ATTR_SPEED, EFFECT_MORPH_DEFAULT_SPEED), + palette=theme.colors, + power_on=kwargs.get(ATTR_POWER_ON, True), + ) + for coordinator in coordinators + ) + ) + + async def _start_effect_move( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the firmware-based Move effect.""" + await asyncio.gather( + *( + coordinator.async_set_multizone_effect( + effect=EFFECT_MOVE, + speed=kwargs.get(ATTR_SPEED, EFFECT_MOVE_DEFAULT_SPEED), + direction=kwargs.get(ATTR_DIRECTION, EFFECT_MOVE_DEFAULT_DIRECTION), + theme_name=kwargs.get(ATTR_THEME), + power_on=kwargs.get(ATTR_POWER_ON, False), + ) + for coordinator in coordinators + ) + ) + + async def _start_effect_pulse( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the software-based Pulse effect.""" + effect = aiolifx_effects.EffectPulse( + power_on=bool(kwargs.get(ATTR_POWER_ON)), + period=kwargs.get(ATTR_PERIOD), + cycles=kwargs.get(ATTR_CYCLES), + mode=kwargs.get(ATTR_MODE), + hsbk=find_hsbk(self.hass, **kwargs), + ) + await self.effects_conductor.start(effect, bulbs) + + async def _start_effect_colorloop( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the software based Color Loop effect.""" + brightness = None + saturation_max = None + saturation_min = None + + if ATTR_BRIGHTNESS in kwargs: + brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS]) + elif ATTR_BRIGHTNESS_PCT in kwargs: + brightness = convert_8_to_16(round(255 * kwargs[ATTR_BRIGHTNESS_PCT] / 100)) + + if ATTR_SATURATION_MAX in kwargs: + saturation_max = int(kwargs[ATTR_SATURATION_MAX] / 100 * 65535) + + if ATTR_SATURATION_MIN in kwargs: + saturation_min = int(kwargs[ATTR_SATURATION_MIN] / 100 * 65535) + + effect = aiolifx_effects.EffectColorloop( + power_on=bool(kwargs.get(ATTR_POWER_ON)), + period=kwargs.get(ATTR_PERIOD), + change=kwargs.get(ATTR_CHANGE), + spread=kwargs.get(ATTR_SPREAD), + transition=kwargs.get(ATTR_TRANSITION), + brightness=brightness, + saturation_max=saturation_max, + saturation_min=saturation_min, + ) + await self.effects_conductor.start(effect, bulbs) + + async def _start_effect_sky( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the firmware-based Sky effect.""" + palette = kwargs.get(ATTR_PALETTE) + if palette is not None: + theme = Theme() + for hsbk in palette: + theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) + + speed = kwargs.get(ATTR_SPEED, EFFECT_SKY_DEFAULT_SPEED) + sky_type = kwargs.get(ATTR_SKY_TYPE, EFFECT_SKY_DEFAULT_SKY_TYPE) + + cloud_saturation_min = kwargs.get( + ATTR_CLOUD_SATURATION_MIN, + EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MIN, + ) + cloud_saturation_max = kwargs.get( + ATTR_CLOUD_SATURATION_MAX, + EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX, + ) + + await asyncio.gather( + *( + coordinator.async_set_matrix_effect( + effect=EFFECT_SKY, + speed=speed, + sky_type=sky_type, + cloud_saturation_min=cloud_saturation_min, + cloud_saturation_max=cloud_saturation_max, + palette=theme.colors, + ) + for coordinator in coordinators + ) + ) + + async def _start_effect_stop( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Stop any running software or firmware effect.""" + await self.effects_conductor.stop(bulbs) + + for coordinator in coordinators: + await coordinator.async_set_matrix_effect(effect=EFFECT_OFF, power_on=False) + await coordinator.async_set_multizone_effect( + effect=EFFECT_OFF, power_on=False + ) + + _effect_dispatch = { + SERVICE_EFFECT_COLORLOOP: _start_effect_colorloop, + SERVICE_EFFECT_FLAME: _start_effect_flame, + SERVICE_EFFECT_MORPH: _start_effect_morph, + SERVICE_EFFECT_MOVE: _start_effect_move, + SERVICE_EFFECT_PULSE: _start_effect_pulse, + SERVICE_EFFECT_SKY: _start_effect_sky, + SERVICE_EFFECT_STOP: _start_effect_stop, + SERVICE_PAINT_THEME: _start_paint_theme, + } + async def start_effect( self, entity_ids: set[str], service: str, **kwargs: Any ) -> None: @@ -318,137 +551,5 @@ class LIFXManager: coordinators.append(coordinator) bulbs.append(coordinator.device) - if service == SERVICE_EFFECT_FLAME: - await asyncio.gather( - *( - coordinator.async_set_matrix_effect( - effect=EFFECT_FLAME, - speed=kwargs.get(ATTR_SPEED, EFFECT_FLAME_DEFAULT_SPEED), - power_on=kwargs.get(ATTR_POWER_ON, True), - ) - for coordinator in coordinators - ) - ) - - elif service == SERVICE_EFFECT_MORPH: - theme_name = kwargs.get(ATTR_THEME, "exciting") - palette = kwargs.get(ATTR_PALETTE) - - if palette is not None: - theme = Theme() - for hsbk in palette: - theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) - else: - theme = ThemeLibrary().get_theme(theme_name) - - await asyncio.gather( - *( - coordinator.async_set_matrix_effect( - effect=EFFECT_MORPH, - speed=kwargs.get(ATTR_SPEED, EFFECT_MORPH_DEFAULT_SPEED), - palette=theme.colors, - power_on=kwargs.get(ATTR_POWER_ON, True), - ) - for coordinator in coordinators - ) - ) - - elif service == SERVICE_EFFECT_MOVE: - await asyncio.gather( - *( - coordinator.async_set_multizone_effect( - effect=EFFECT_MOVE, - speed=kwargs.get(ATTR_SPEED, EFFECT_MOVE_DEFAULT_SPEED), - direction=kwargs.get( - ATTR_DIRECTION, EFFECT_MOVE_DEFAULT_DIRECTION - ), - theme_name=kwargs.get(ATTR_THEME), - power_on=kwargs.get(ATTR_POWER_ON, False), - ) - for coordinator in coordinators - ) - ) - - elif service == SERVICE_EFFECT_PULSE: - effect = aiolifx_effects.EffectPulse( - power_on=kwargs.get(ATTR_POWER_ON), - period=kwargs.get(ATTR_PERIOD), - cycles=kwargs.get(ATTR_CYCLES), - mode=kwargs.get(ATTR_MODE), - hsbk=find_hsbk(self.hass, **kwargs), - ) - await self.effects_conductor.start(effect, bulbs) - - elif service == SERVICE_EFFECT_COLORLOOP: - brightness = None - saturation_max = None - saturation_min = None - - if ATTR_BRIGHTNESS in kwargs: - brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS]) - elif ATTR_BRIGHTNESS_PCT in kwargs: - brightness = convert_8_to_16( - round(255 * kwargs[ATTR_BRIGHTNESS_PCT] / 100) - ) - - if ATTR_SATURATION_MAX in kwargs: - saturation_max = int(kwargs[ATTR_SATURATION_MAX] / 100 * 65535) - - if ATTR_SATURATION_MIN in kwargs: - saturation_min = int(kwargs[ATTR_SATURATION_MIN] / 100 * 65535) - - effect = aiolifx_effects.EffectColorloop( - power_on=kwargs.get(ATTR_POWER_ON), - period=kwargs.get(ATTR_PERIOD), - change=kwargs.get(ATTR_CHANGE), - spread=kwargs.get(ATTR_SPREAD), - transition=kwargs.get(ATTR_TRANSITION), - brightness=brightness, - saturation_max=saturation_max, - saturation_min=saturation_min, - ) - await self.effects_conductor.start(effect, bulbs) - - elif service == SERVICE_EFFECT_SKY: - palette = kwargs.get(ATTR_PALETTE) - if palette is not None: - theme = Theme() - for hsbk in palette: - theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) - - speed = kwargs.get(ATTR_SPEED, EFFECT_SKY_DEFAULT_SPEED) - sky_type = kwargs.get(ATTR_SKY_TYPE, EFFECT_SKY_DEFAULT_SKY_TYPE) - - cloud_saturation_min = kwargs.get( - ATTR_CLOUD_SATURATION_MIN, - EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MIN, - ) - cloud_saturation_max = kwargs.get( - ATTR_CLOUD_SATURATION_MAX, - EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX, - ) - - await asyncio.gather( - *( - coordinator.async_set_matrix_effect( - effect=EFFECT_SKY, - speed=speed, - sky_type=sky_type, - cloud_saturation_min=cloud_saturation_min, - cloud_saturation_max=cloud_saturation_max, - palette=theme.colors, - ) - for coordinator in coordinators - ) - ) - - elif service == SERVICE_EFFECT_STOP: - await self.effects_conductor.stop(bulbs) - - for coordinator in coordinators: - await coordinator.async_set_matrix_effect( - effect=EFFECT_OFF, power_on=False - ) - await coordinator.async_set_multizone_effect( - effect=EFFECT_OFF, power_on=False - ) + if start_effect_func := self._effect_dispatch.get(service): + await start_effect_func(self, bulbs, coordinators, **kwargs) diff --git a/homeassistant/components/lifx/services.yaml b/homeassistant/components/lifx/services.yaml index c2eb2e249cb..ac4fbfc15af 100644 --- a/homeassistant/components/lifx/services.yaml +++ b/homeassistant/components/lifx/services.yaml @@ -186,28 +186,46 @@ effect_move: options: - "autumn" - "blissful" + - "bias_lighting" + - "calaveras" - "cheerful" + - "christmas" - "dream" - "energizing" - "epic" + - "evening" - "exciting" + - "fantasy" - "focusing" + - "gentle" - "halloween" - "hanukkah" - "holly" - - "independence_day" + - "hygge" + - "independence" - "intense" + - "love" + - "kwanzaa" - "mellow" + - "party" - "peaceful" - "powerful" + - "proud" + - "pumpkin" - "relaxing" + - "romance" - "santa" - "serene" + - "shamrock" - "soothing" + - "spacey" - "sports" - "spring" + - "stardust" + - "thanksgiving" - "tranquil" - "warming" + - "zombie" power_on: default: true selector: @@ -255,28 +273,46 @@ effect_morph: options: - "autumn" - "blissful" + - "bias_lighting" + - "calaveras" - "cheerful" + - "christmas" - "dream" - "energizing" - "epic" + - "evening" - "exciting" + - "fantasy" - "focusing" + - "gentle" - "halloween" - "hanukkah" - "holly" - - "independence_day" + - "hygge" + - "independence" - "intense" + - "love" + - "kwanzaa" - "mellow" + - "party" - "peaceful" - "powerful" + - "proud" + - "pumpkin" - "relaxing" + - "romance" - "santa" - "serene" + - "shamrock" - "soothing" + - "spacey" - "sports" - "spring" + - "stardust" + - "thanksgiving" - "tranquil" - "warming" + - "zombie" power_on: default: true selector: @@ -338,3 +374,73 @@ effect_stop: entity: integration: lifx domain: light +paint_theme: + target: + entity: + integration: lifx + domain: light + fields: + palette: + example: + - "[[0, 100, 100, 3500], [60, 100, 100, 3500]]" + selector: + object: + theme: + example: exciting + default: exciting + selector: + select: + mode: dropdown + options: + - "autumn" + - "blissful" + - "bias_lighting" + - "calaveras" + - "cheerful" + - "christmas" + - "dream" + - "energizing" + - "epic" + - "evening" + - "exciting" + - "fantasy" + - "focusing" + - "gentle" + - "halloween" + - "hanukkah" + - "holly" + - "hygge" + - "independence" + - "intense" + - "love" + - "kwanzaa" + - "mellow" + - "party" + - "peaceful" + - "powerful" + - "proud" + - "pumpkin" + - "relaxing" + - "romance" + - "santa" + - "serene" + - "shamrock" + - "soothing" + - "spacey" + - "sports" + - "spring" + - "stardust" + - "thanksgiving" + - "tranquil" + - "warming" + - "zombie" + transition: + selector: + number: + min: 0 + max: 3600 + unit_of_measurement: seconds + power_on: + default: true + selector: + boolean: diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index 19d86e57f09..39102d904d5 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -209,7 +209,7 @@ }, "palette": { "name": "Palette", - "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-900) values to use for this effect. Overrides the theme attribute." + "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to use for this effect. Overrides the theme attribute." }, "theme": { "name": "[%key:component::lifx::entity::select::theme::name%]", @@ -254,6 +254,28 @@ "effect_stop": { "name": "Stop effect", "description": "Stops a running effect." + }, + "paint_theme": { + "name": "Paint Theme", + "description": "Paint either a provided theme or custom palette across one or more LIFX lights.", + "fields": { + "palette": { + "name": "Palette", + "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to paint across the target lights. Overrides the theme attribute." + }, + "theme": { + "name": "[%key:component::lifx::entity::select::theme::name%]", + "description": "Predefined color theme to paint. Overridden by the palette attribute." + }, + "transition": { + "name": "Transition", + "description": "Duration in seconds to paint the theme." + }, + "power_on": { + "name": "Power on", + "description": "Powered off lights will be turned on before painting the theme." + } + } } } } diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index ffe819fa2cb..58843d63f9a 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -25,6 +25,7 @@ from homeassistant.components.lifx.manager import ( SERVICE_EFFECT_MORPH, SERVICE_EFFECT_MOVE, SERVICE_EFFECT_SKY, + SERVICE_PAINT_THEME, ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -1045,6 +1046,104 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: bulb.set_power.reset_mock() +@pytest.mark.usefixtures("mock_discovery") +async def test_paint_theme_service(hass: HomeAssistant) -> None: + """Test the firmware flame and morph effects on a matrix device.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.power_level = 0 + bulb.color = [65535, 65535, 65535, 65535] + with ( + _patch_discovery(device=bulb), + _patch_config_flow_try_connect(device=bulb), + _patch_device(device=bulb), + ): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + SERVICE_PAINT_THEME, + {ATTR_ENTITY_ID: entity_id, ATTR_TRANSITION: 4, ATTR_THEME: "autumn"}, + blocking=True, + ) + + bulb.power_level = 65535 + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_color.calls) == 1 + call_dict = bulb.set_color.calls[0][1] + call_dict.pop("callb") + assert call_dict["value"] in [ + (5643, 65535, 32768, 3500), + (15109, 65535, 32768, 3500), + (8920, 65535, 32768, 3500), + (10558, 65535, 32768, 3500), + ] + assert call_dict["duration"] == 4000 + bulb.set_color.reset_mock() + bulb.set_power.reset_mock() + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + SERVICE_PAINT_THEME, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TRANSITION: 6, + ATTR_PALETTE: [ + (0, 100, 255, 3500), + (60, 100, 255, 3500), + (120, 100, 255, 3500), + (180, 100, 255, 3500), + (240, 100, 255, 3500), + (300, 100, 255, 3500), + ], + }, + blocking=True, + ) + + bulb.power_level = 65535 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_color.calls) == 1 + call_dict = bulb.set_color.calls[0][1] + call_dict.pop("callb") + hue = round(call_dict["value"][0] / 65535 * 360) + sat = round(call_dict["value"][1] / 65535 * 100) + bri = call_dict["value"][2] >> 8 + kel = call_dict["value"][3] + assert (hue, sat, bri, kel) in [ + (0, 100, 255, 3500), + (60, 100, 255, 3500), + (120, 100, 255, 3500), + (180, 100, 255, 3500), + (240, 100, 255, 3500), + (300, 100, 255, 3500), + ] + assert call_dict["duration"] == 6000 + + bulb.set_color.reset_mock() + bulb.set_power.reset_mock() + + async def test_color_light_with_temp( hass: HomeAssistant, mock_effect_conductor ) -> None: From daac986e006bee45d602cde459bb3d0b9e5c4e65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jan 2025 15:10:01 -1000 Subject: [PATCH 0529/2987] Bump dbus-fast to 2.29.0 (#135804) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index e08b91cfc7f..4ed6b4b3821 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bluetooth-adapters==0.20.2", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.20.0", - "dbus-fast==2.28.0", + "dbus-fast==2.29.0", "habluetooth==3.8.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6f7fe970cc9..acc2040a2ff 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.0 -dbus-fast==2.28.0 +dbus-fast==2.29.0 fnv-hash-fast==1.1.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 4fb97d32ab3..a6ebc1f2613 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -732,7 +732,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.28.0 +dbus-fast==2.29.0 # homeassistant.components.debugpy debugpy==1.8.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fc7af7389ae..3c6ee8fa5d2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -628,7 +628,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.28.0 +dbus-fast==2.29.0 # homeassistant.components.debugpy debugpy==1.8.11 From c2b6c4b4fcd8d37bbbdce24b0612fb56952328eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jan 2025 19:39:48 -1000 Subject: [PATCH 0530/2987] Small cleanups to lifx services to reduce code (#135817) --- homeassistant/components/lifx/manager.py | 92 ++++++------------------ 1 file changed, 20 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 3d1b61c4b58..16c39c25219 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -218,16 +218,16 @@ LIFX_PAINT_THEME_SCHEMA = cv.make_entity_service_schema( } ) -SERVICES = ( - SERVICE_EFFECT_COLORLOOP, - SERVICE_EFFECT_FLAME, - SERVICE_EFFECT_MORPH, - SERVICE_EFFECT_MOVE, - SERVICE_EFFECT_PULSE, - SERVICE_EFFECT_SKY, - SERVICE_EFFECT_STOP, - SERVICE_PAINT_THEME, -) +SERVICES_SCHEMA = { + SERVICE_EFFECT_COLORLOOP: LIFX_EFFECT_COLORLOOP_SCHEMA, + SERVICE_EFFECT_FLAME: LIFX_EFFECT_FLAME_SCHEMA, + SERVICE_EFFECT_MORPH: LIFX_EFFECT_MORPH_SCHEMA, + SERVICE_EFFECT_MOVE: LIFX_EFFECT_MOVE_SCHEMA, + SERVICE_EFFECT_PULSE: LIFX_EFFECT_PULSE_SCHEMA, + SERVICE_EFFECT_SKY: LIFX_EFFECT_SKY_SCHEMA, + SERVICE_EFFECT_STOP: LIFX_EFFECT_STOP_SCHEMA, + SERVICE_PAINT_THEME: LIFX_PAINT_THEME_SCHEMA, +} class LIFXManager: @@ -242,7 +242,7 @@ class LIFXManager: @callback def async_unload(self) -> None: """Release resources.""" - for service in SERVICES: + for service in SERVICES_SCHEMA: self.hass.services.async_remove(DOMAIN, service) @callback @@ -270,72 +270,20 @@ class LIFXManager: if all_referenced: await self.start_effect(all_referenced, service.service, **service.data) - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_PULSE, - service_handler, - schema=LIFX_EFFECT_PULSE_SCHEMA, - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_COLORLOOP, - service_handler, - schema=LIFX_EFFECT_COLORLOOP_SCHEMA, - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_FLAME, - service_handler, - schema=LIFX_EFFECT_FLAME_SCHEMA, - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_MORPH, - service_handler, - schema=LIFX_EFFECT_MORPH_SCHEMA, - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_MOVE, - service_handler, - schema=LIFX_EFFECT_MOVE_SCHEMA, - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_SKY, - service_handler, - schema=LIFX_EFFECT_SKY_SCHEMA, - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_STOP, - service_handler, - schema=LIFX_EFFECT_STOP_SCHEMA, - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_PAINT_THEME, - service_handler, - schema=LIFX_PAINT_THEME_SCHEMA, - ) + for service, schema in SERVICES_SCHEMA.items(): + self.hass.services.async_register( + DOMAIN, service, service_handler, schema=schema + ) @staticmethod def build_theme(theme_name: str = "exciting", palette: list | None = None) -> Theme: """Either return the predefined theme or build one from the palette.""" - if palette is not None: - theme = Theme() - for hsbk in palette: - theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) - else: - theme = ThemeLibrary().get_theme(theme_name) + if palette is None: + return ThemeLibrary().get_theme(theme_name) + theme = Theme() + for hsbk in palette: + theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) return theme async def _start_effect_flame( From a39137c3fc87aa12b26fac1a88e6fd55e8337954 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jan 2025 21:29:44 -1000 Subject: [PATCH 0531/2987] Bump zeroconf to 0.140.1 (#135815) --- 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 663f196dd1d..b301c1ad191 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.139.0"] + "requirements": ["zeroconf==0.140.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index acc2040a2ff..fa75884bcba 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.139.0 +zeroconf==0.140.1 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 6a22a51a2b4..8cd777c3c67 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.139.0" + "zeroconf==0.140.1" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 52e1b412803..9f30ea84ad7 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.139.0 +zeroconf==0.140.1 diff --git a/requirements_all.txt b/requirements_all.txt index a6ebc1f2613..bc97607ef93 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3118,7 +3118,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.139.0 +zeroconf==0.140.1 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c6ee8fa5d2..215452c79a9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2507,7 +2507,7 @@ yt-dlp[default]==2024.12.23 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.139.0 +zeroconf==0.140.1 # homeassistant.components.zeversolar zeversolar==0.3.2 From f3683f0b5e530a7d54261054d9d96fb011be50e7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 17 Jan 2025 08:34:47 +0100 Subject: [PATCH 0532/2987] Ensure entity platform in blackbird tests (#135715) --- .../components/blackbird/test_media_player.py | 40 ++++++++----------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/tests/components/blackbird/test_media_player.py b/tests/components/blackbird/test_media_player.py index db92dddcc77..5de41a1fb1e 100644 --- a/tests/components/blackbird/test_media_player.py +++ b/tests/components/blackbird/test_media_player.py @@ -10,7 +10,6 @@ from homeassistant.components.blackbird.const import DOMAIN, SERVICE_SETALLZONES from homeassistant.components.blackbird.media_player import ( DATA_BLACKBIRD, PLATFORM_SCHEMA, - setup_platform, ) from homeassistant.components.media_player import ( MediaPlayerEntity, @@ -18,6 +17,9 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockEntityPlatform class AttrDict(dict): @@ -181,21 +183,21 @@ async def setup_blackbird(hass: HomeAssistant, mock_blackbird: MockBlackbird) -> "homeassistant.components.blackbird.media_player.get_blackbird", return_value=mock_blackbird, ): - await hass.async_add_executor_job( - setup_platform, + await async_setup_component( hass, + "media_player", { - "platform": "blackbird", - "port": "/dev/ttyUSB0", - "zones": {3: {"name": "Zone name"}}, - "sources": { - 1: {"name": "one"}, - 3: {"name": "three"}, - 2: {"name": "two"}, - }, + "media_player": { + "platform": "blackbird", + "port": "/dev/ttyUSB0", + "zones": {3: {"name": "Zone name"}}, + "sources": { + 1: {"name": "one"}, + 3: {"name": "three"}, + 2: {"name": "two"}, + }, + } }, - lambda *args, **kwargs: None, - {}, ) await hass.async_block_till_done() @@ -207,6 +209,7 @@ def media_player_entity( """Return the media player entity.""" media_player = hass.data[DATA_BLACKBIRD]["/dev/ttyUSB0-3"] media_player.hass = hass + media_player.platform = MockEntityPlatform(hass) media_player.entity_id = "media_player.zone_3" return media_player @@ -271,10 +274,6 @@ async def test_update( hass: HomeAssistant, media_player_entity: MediaPlayerEntity ) -> None: """Test updating values from blackbird.""" - assert media_player_entity.state is None - assert media_player_entity.source is None - - await hass.async_add_executor_job(media_player_entity.update) assert media_player_entity.state == STATE_ON assert media_player_entity.source == "one" @@ -291,9 +290,6 @@ async def test_state( mock_blackbird: MockBlackbird, ) -> None: """Test state property.""" - assert media_player_entity.state is None - - await hass.async_add_executor_job(media_player_entity.update) assert media_player_entity.state == STATE_ON mock_blackbird.zones[3].power = False @@ -315,8 +311,6 @@ async def test_source( hass: HomeAssistant, media_player_entity: MediaPlayerEntity ) -> None: """Test source property.""" - assert media_player_entity.source is None - await hass.async_add_executor_job(media_player_entity.update) assert media_player_entity.source == "one" @@ -324,8 +318,6 @@ async def test_media_title( hass: HomeAssistant, media_player_entity: MediaPlayerEntity ) -> None: """Test media title property.""" - assert media_player_entity.media_title is None - await hass.async_add_executor_job(media_player_entity.update) assert media_player_entity.media_title == "one" From 566f514a75c37319cc607b47bba36770bea11efc Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Fri, 17 Jan 2025 02:41:10 -0500 Subject: [PATCH 0533/2987] Allow is_state_attr to check attributes for None (#132879) --- homeassistant/helpers/template.py | 8 ++++++-- tests/helpers/test_template.py | 18 +++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index fd2c11e4eb9..e8c169e92d8 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1879,8 +1879,12 @@ def is_state(hass: HomeAssistant, entity_id: str, state: str | list[str]) -> boo def is_state_attr(hass: HomeAssistant, entity_id: str, name: str, value: Any) -> bool: """Test if a state's attribute is a specific value.""" - attr = state_attr(hass, entity_id, name) - return attr is not None and attr == value + if (state_obj := _get_state(hass, entity_id)) is not None: + attr = state_obj.attributes.get(name, _SENTINEL) + if attr is _SENTINEL: + return False + return bool(attr == value) + return False def state_attr(hass: HomeAssistant, entity_id: str, name: str) -> Any: diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 628aea20900..ab0f126eaa9 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1970,7 +1970,7 @@ def test_is_state(hass: HomeAssistant) -> None: def test_is_state_attr(hass: HomeAssistant) -> None: """Test is_state_attr method.""" - hass.states.async_set("test.object", "available", {"mode": "on"}) + hass.states.async_set("test.object", "available", {"mode": "on", "exists": None}) tpl = template.Template( """ {% if is_state_attr("test.object", "mode", "on") %}yes{% else %}no{% endif %} @@ -2003,6 +2003,22 @@ def test_is_state_attr(hass: HomeAssistant) -> None: ) assert tpl.async_render() == "test.object" + tpl = template.Template( + """ +{% if is_state_attr("test.object", "exists", None) %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "yes" + + tpl = template.Template( + """ +{% if is_state_attr("test.object", "noexist", None) %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "no" + def test_state_attr(hass: HomeAssistant) -> None: """Test state_attr method.""" From 0f8785d8bc13e2d26769847ef45a418567b594e0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 17 Jan 2025 08:44:40 +0100 Subject: [PATCH 0534/2987] Ensure entity platform in alert tests (#135714) --- tests/components/alert/test_init.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/alert/test_init.py b/tests/components/alert/test_init.py index 263fb69c883..27997a093e5 100644 --- a/tests/components/alert/test_init.py +++ b/tests/components/alert/test_init.py @@ -30,7 +30,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component -from tests.common import async_mock_service +from tests.common import MockEntityPlatform, async_mock_service NAME = "alert_test" DONE_MESSAGE = "alert_gone" @@ -338,6 +338,7 @@ async def test_skipfirst(hass: HomeAssistant, mock_notifier: list[ServiceCall]) async def test_done_message_state_tracker_reset_on_cancel(hass: HomeAssistant) -> None: """Test that the done message is reset when canceled.""" entity = alert.AlertEntity(hass, *TEST_NOACK) + entity.platform = MockEntityPlatform(hass) entity._cancel = lambda *args: None assert entity._send_done_message is False entity._send_done_message = True From 8e39c6575957ee3fa6eb3780eca86f5c2703f446 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 17 Jan 2025 09:10:09 +0100 Subject: [PATCH 0535/2987] Ensure entity platform in universal tests (#135727) --- .../components/universal/test_media_player.py | 36 ++++++------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 5ebfd2c13ad..5be9cb3fe02 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -27,7 +27,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component -from tests.common import async_mock_service, get_fixture_path +from tests.common import MockEntityPlatform, async_mock_service, get_fixture_path CONFIG_CHILDREN_ONLY = { "name": "test", @@ -74,6 +74,7 @@ class MockMediaPlayer(media_player.MediaPlayerEntity): self._shuffle = False self._sound_mode = None self._repeat = None + self.platform = MockEntityPlatform(hass) self.service_calls = { "turn_on": async_mock_service( @@ -361,26 +362,10 @@ async def test_config_bad_key(hass: HomeAssistant) -> None: async def test_platform_setup(hass: HomeAssistant) -> None: """Test platform setup.""" config = {"name": "test", "platform": "universal"} - bad_config = {"platform": "universal"} - entities = [] - - def add_entities(new_entities): - """Add devices to list.""" - entities.extend(new_entities) - - setup_ok = True - try: - await universal.async_setup_platform( - hass, validate_config(bad_config), add_entities - ) - except MultipleInvalid: - setup_ok = False - assert not setup_ok - assert len(entities) == 0 - - await universal.async_setup_platform(hass, validate_config(config), add_entities) - assert len(entities) == 1 - assert entities[0].name == "test" + assert await async_setup_component(hass, "media_player", {"media_player": config}) + await hass.async_block_till_done() + assert hass.states.async_all() != [] + assert hass.states.get("media_player.test") is not None async def test_master_state(hass: HomeAssistant) -> None: @@ -461,11 +446,10 @@ async def test_active_child_state(hass: HomeAssistant, mock_states) -> None: async def test_name(hass: HomeAssistant) -> None: """Test name property.""" - config = validate_config(CONFIG_CHILDREN_ONLY) - - ump = universal.UniversalMediaPlayer(hass, config) - - assert config["name"] == ump.name + assert await async_setup_component( + hass, "media_player", {"media_player": CONFIG_CHILDREN_ONLY} + ) + assert hass.states.get("media_player.test") is not None async def test_polling(hass: HomeAssistant) -> None: From cd88913daf42b0627420e9397b5521e354abd3ec Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 17 Jan 2025 09:10:29 +0100 Subject: [PATCH 0536/2987] Ensure entity platform in mochad tests (#135725) --- tests/components/mochad/test_switch.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/components/mochad/test_switch.py b/tests/components/mochad/test_switch.py index 9fea3b5c14c..d7875246fac 100644 --- a/tests/components/mochad/test_switch.py +++ b/tests/components/mochad/test_switch.py @@ -9,6 +9,8 @@ from homeassistant.components.mochad import switch as mochad from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockEntityPlatform + @pytest.fixture(autouse=True) def pymochad_mock(): @@ -25,7 +27,9 @@ def switch_mock(hass: HomeAssistant) -> mochad.MochadSwitch: """Mock switch.""" controller_mock = mock.MagicMock() dev_dict = {"address": "a1", "name": "fake_switch"} - return mochad.MochadSwitch(hass, controller_mock, dev_dict) + entity = mochad.MochadSwitch(hass, controller_mock, dev_dict) + entity.platform = MockEntityPlatform(hass) + return entity async def test_setup_adds_proper_devices(hass: HomeAssistant) -> None: From 7430238c0aa50b626398d7cdc07cddd6f86f2961 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 17 Jan 2025 09:10:47 +0100 Subject: [PATCH 0537/2987] Ensure entity platform in kira tests (#135723) --- tests/components/kira/test_remote.py | 4 ++++ tests/components/kira/test_sensor.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/tests/components/kira/test_remote.py b/tests/components/kira/test_remote.py index ff3b28617d3..ad443fa3154 100644 --- a/tests/components/kira/test_remote.py +++ b/tests/components/kira/test_remote.py @@ -5,6 +5,8 @@ from unittest.mock import MagicMock from homeassistant.components.kira import remote as kira from homeassistant.core import HomeAssistant +from tests.common import MockEntityPlatform + SERVICE_SEND_COMMAND = "send_command" TEST_CONFIG = {kira.DOMAIN: {"devices": [{"host": "127.0.0.1", "port": 17324}]}} @@ -28,6 +30,8 @@ def test_service_call(hass: HomeAssistant) -> None: kira.setup_platform(hass, TEST_CONFIG, add_entities, DISCOVERY_INFO) assert len(DEVICES) == 1 remote = DEVICES[0] + remote.hass = hass + remote.platform = MockEntityPlatform(hass) assert remote.name == "kira" diff --git a/tests/components/kira/test_sensor.py b/tests/components/kira/test_sensor.py index fe0fc95a918..3bd46f18765 100644 --- a/tests/components/kira/test_sensor.py +++ b/tests/components/kira/test_sensor.py @@ -5,6 +5,8 @@ from unittest.mock import MagicMock, patch from homeassistant.components.kira import sensor as kira from homeassistant.core import HomeAssistant +from tests.common import MockEntityPlatform + TEST_CONFIG = {kira.DOMAIN: {"sensors": [{"host": "127.0.0.1", "port": 17324}]}} DISCOVERY_INFO = {"name": "kira", "device": "kira"} @@ -29,6 +31,8 @@ def test_kira_sensor_callback( kira.setup_platform(hass, TEST_CONFIG, add_entities, DISCOVERY_INFO) assert len(DEVICES) == 1 sensor = DEVICES[0] + sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) assert sensor.name == "kira" From 6aed2dcc0f8999334750db7c789ce79e9c637f28 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 17 Jan 2025 09:11:07 +0100 Subject: [PATCH 0538/2987] Ensure entity platform in homeassistant tests (#135721) --- tests/components/homeassistant/test_init.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 56eeb4177b1..0aed3dc929e 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -38,6 +38,7 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, + MockEntityPlatform, MockUser, async_capture_events, async_mock_service, @@ -90,6 +91,8 @@ async def test_reload_core_conf(hass: HomeAssistant) -> None: ent = entity.Entity() ent.entity_id = "test.entity" ent.hass = hass + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([ent]) ent.async_write_ha_state() state = hass.states.get("test.entity") From 46b17b539cca152b6e6f8b7478ce90500a4a20fd Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 17 Jan 2025 09:12:52 +0100 Subject: [PATCH 0539/2987] Use new syntax for TypeVar defaults (#135780) --- homeassistant/auth/mfa_modules/__init__.py | 12 ++++-------- homeassistant/auth/providers/__init__.py | 7 ++----- .../bluetooth/passive_update_coordinator.py | 12 ++++-------- homeassistant/components/broadlink/device.py | 5 +---- homeassistant/components/reolink/util.py | 14 +++++--------- homeassistant/config_entries.py | 6 ++---- homeassistant/helpers/collection.py | 8 +++----- homeassistant/helpers/entity_component.py | 6 ++---- homeassistant/helpers/update_coordinator.py | 11 +++++------ homeassistant/util/event_type.py | 6 ++---- homeassistant/util/event_type.pyi | 4 +++- tests/common.py | 6 ++---- 12 files changed, 35 insertions(+), 62 deletions(-) diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py index d0cb190ec6e..0edc187e24d 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging import types -from typing import Any, Generic, TypeVar +from typing import Any import voluptuous as vol from voluptuous.humanize import humanize_error @@ -34,12 +34,6 @@ DATA_REQS: HassKey[set[str]] = HassKey("mfa_auth_module_reqs_processed") _LOGGER = logging.getLogger(__name__) -_MultiFactorAuthModuleT = TypeVar( - "_MultiFactorAuthModuleT", - bound="MultiFactorAuthModule", - default="MultiFactorAuthModule", -) - class MultiFactorAuthModule: """Multi-factor Auth Module of validation function.""" @@ -101,7 +95,9 @@ class MultiFactorAuthModule: raise NotImplementedError -class SetupFlow(data_entry_flow.FlowHandler, Generic[_MultiFactorAuthModuleT]): +class SetupFlow[_MultiFactorAuthModuleT: MultiFactorAuthModule = MultiFactorAuthModule]( + data_entry_flow.FlowHandler +): """Handler for the setup flow.""" def __init__( diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 36faf0e5e9c..1155e77b407 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Mapping import logging import types -from typing import Any, Generic, TypeVar +from typing import Any import voluptuous as vol from voluptuous.humanize import humanize_error @@ -46,8 +46,6 @@ AUTH_PROVIDER_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -_AuthProviderT = TypeVar("_AuthProviderT", bound="AuthProvider", default="AuthProvider") - class AuthProvider: """Provider of user authentication.""" @@ -194,9 +192,8 @@ async def load_auth_provider_module( return module -class LoginFlow( +class LoginFlow[_AuthProviderT: AuthProvider = AuthProvider]( FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]], - Generic[_AuthProviderT], ): """Handler for the login flow.""" diff --git a/homeassistant/components/bluetooth/passive_update_coordinator.py b/homeassistant/components/bluetooth/passive_update_coordinator.py index c20f55abcee..ccff85e5027 100644 --- a/homeassistant/components/bluetooth/passive_update_coordinator.py +++ b/homeassistant/components/bluetooth/passive_update_coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.update_coordinator import ( @@ -18,12 +18,6 @@ if TYPE_CHECKING: from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak -_PassiveBluetoothDataUpdateCoordinatorT = TypeVar( - "_PassiveBluetoothDataUpdateCoordinatorT", - bound="PassiveBluetoothDataUpdateCoordinator", - default="PassiveBluetoothDataUpdateCoordinator", -) - class PassiveBluetoothDataUpdateCoordinator( BasePassiveBluetoothCoordinator, BaseDataUpdateCoordinatorProtocol @@ -96,7 +90,9 @@ class PassiveBluetoothDataUpdateCoordinator( self.async_update_listeners() -class PassiveBluetoothCoordinatorEntity( # pylint: disable=hass-enforce-class-module +class PassiveBluetoothCoordinatorEntity[ + _PassiveBluetoothDataUpdateCoordinatorT: PassiveBluetoothDataUpdateCoordinator = PassiveBluetoothDataUpdateCoordinator +]( # pylint: disable=hass-enforce-class-module BaseCoordinatorEntity[_PassiveBluetoothDataUpdateCoordinatorT] ): """A class for entities using DataUpdateCoordinator.""" diff --git a/homeassistant/components/broadlink/device.py b/homeassistant/components/broadlink/device.py index ac90dd9af79..082af07ebbd 100644 --- a/homeassistant/components/broadlink/device.py +++ b/homeassistant/components/broadlink/device.py @@ -3,7 +3,6 @@ from contextlib import suppress from functools import partial import logging -from typing import Generic, TypeVar import broadlink as blk from broadlink.exceptions import ( @@ -30,8 +29,6 @@ from homeassistant.helpers import device_registry as dr from .const import DEFAULT_PORT, DOMAIN, DOMAINS_AND_TYPES from .updater import BroadlinkUpdateManager, get_update_manager -_ApiT = TypeVar("_ApiT", bound=blk.Device, default=blk.Device) - _LOGGER = logging.getLogger(__name__) @@ -40,7 +37,7 @@ def get_domains(device_type: str) -> set[Platform]: return {d for d, t in DOMAINS_AND_TYPES.items() if device_type in t} -class BroadlinkDevice(Generic[_ApiT]): +class BroadlinkDevice[_ApiT: blk.Device = blk.Device]: """Manages a Broadlink device.""" api: _ApiT diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index bf7018dfba2..e43391f19fb 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from typing import Any, ParamSpec, TypeVar +from typing import Any from reolink_aio.exceptions import ( ApiError, @@ -87,17 +87,13 @@ def get_device_uid_and_ch( return (device_uid, ch, is_chime) -T = TypeVar("T") -P = ParamSpec("P") - - # Decorators -def raise_translated_error( - func: Callable[P, Awaitable[T]], -) -> Callable[P, Coroutine[Any, Any, T]]: +def raise_translated_error[**P, R]( + func: Callable[P, Awaitable[R]], +) -> Callable[P, Coroutine[Any, Any, R]]: """Wrap a reolink-aio function to translate any potential errors.""" - async def decorator_raise_translated_error(*args: P.args, **kwargs: P.kwargs) -> T: + async def decorator_raise_translated_error(*args: P.args, **kwargs: P.kwargs) -> R: """Try a reolink-aio function and translate any potential errors.""" try: return await func(*args, **kwargs) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 930b3242aad..5a0f99df5ee 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -22,7 +22,7 @@ from functools import cache import logging from random import randint from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, cast from async_interrupt import interrupt from propcache import cached_property @@ -136,8 +136,6 @@ DISCOVERY_COOLDOWN = 1 ISSUE_UNIQUE_ID_COLLISION = "config_entry_unique_id_collision" UNIQUE_ID_COLLISION_TITLE_LIMIT = 5 -_DataT = TypeVar("_DataT", default=Any) - class ConfigEntryState(Enum): """Config entry state.""" @@ -312,7 +310,7 @@ def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> N ) -class ConfigEntry(Generic[_DataT]): +class ConfigEntry[_DataT = Any]: """Hold a configuration entry.""" entry_id: str diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 1b01f1c3f5b..08b58aedde4 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -11,7 +11,7 @@ from hashlib import md5 from itertools import groupby import logging from operator import attrgetter -from typing import Any, Generic, TypedDict, TypeVar +from typing import Any, TypedDict import voluptuous as vol from voluptuous.humanize import humanize_error @@ -36,8 +36,6 @@ CHANGE_ADDED = "added" CHANGE_UPDATED = "updated" CHANGE_REMOVED = "removed" -_EntityT = TypeVar("_EntityT", bound=Entity, default=Entity) - @dataclass(slots=True) class CollectionChange: @@ -447,7 +445,7 @@ _GROUP_BY_KEY = attrgetter("change_type") @dataclass(slots=True, frozen=True) -class _CollectionLifeCycle(Generic[_EntityT]): +class _CollectionLifeCycle[_EntityT: Entity = Entity]: """Life cycle for a collection of entities.""" domain: str @@ -522,7 +520,7 @@ class _CollectionLifeCycle(Generic[_EntityT]): @callback -def sync_entity_lifecycle( +def sync_entity_lifecycle[_EntityT: Entity = Entity]( hass: HomeAssistant, domain: str, platform: str, diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index de20a257a9f..02508e9ee9e 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -7,7 +7,7 @@ from collections.abc import Callable, Iterable from datetime import timedelta import logging from types import ModuleType -from typing import Any, Generic, TypeVar +from typing import Any from homeassistant import config as conf_util from homeassistant.config_entries import ConfigEntry @@ -37,8 +37,6 @@ from .typing import ConfigType, DiscoveryInfoType, VolDictType, VolSchemaType DEFAULT_SCAN_INTERVAL = timedelta(seconds=15) DATA_INSTANCES = "entity_components" -_EntityT = TypeVar("_EntityT", bound=entity.Entity, default=entity.Entity) - @bind_hass async def async_update_entity(hass: HomeAssistant, entity_id: str) -> None: @@ -62,7 +60,7 @@ async def async_update_entity(hass: HomeAssistant, entity_id: str) -> None: await entity_obj.async_update_ha_state(True) -class EntityComponent(Generic[_EntityT]): +class EntityComponent[_EntityT: entity.Entity = entity.Entity]: """The EntityComponent manages platforms that manage entities. An example of an entity component is 'light', which manages platforms such diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 039fbac5787..8acd43970f9 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -36,11 +36,6 @@ REQUEST_REFRESH_DEFAULT_COOLDOWN = 10 REQUEST_REFRESH_DEFAULT_IMMEDIATE = True _DataT = TypeVar("_DataT", default=dict[str, Any]) -_DataUpdateCoordinatorT = TypeVar( - "_DataUpdateCoordinatorT", - bound="DataUpdateCoordinator[Any]", - default="DataUpdateCoordinator[dict[str, Any]]", -) class UpdateFailed(HomeAssistantError): @@ -564,7 +559,11 @@ class BaseCoordinatorEntity[ """ -class CoordinatorEntity(BaseCoordinatorEntity[_DataUpdateCoordinatorT]): +class CoordinatorEntity[ + _DataUpdateCoordinatorT: DataUpdateCoordinator[Any] = DataUpdateCoordinator[ + dict[str, Any] + ] +](BaseCoordinatorEntity[_DataUpdateCoordinatorT]): """A class for entities using DataUpdateCoordinator.""" def __init__( diff --git a/homeassistant/util/event_type.py b/homeassistant/util/event_type.py index 7755a45d3b9..e0083057272 100644 --- a/homeassistant/util/event_type.py +++ b/homeassistant/util/event_type.py @@ -6,12 +6,10 @@ Custom for type checking. See stub file. from __future__ import annotations from collections.abc import Mapping -from typing import Any, Generic, TypeVar - -_DataT = TypeVar("_DataT", bound=Mapping[str, Any], default=Mapping[str, Any]) +from typing import Any -class EventType(str, Generic[_DataT]): +class EventType[_DataT: Mapping[str, Any] = Mapping[str, Any]](str): """Custom type for Event.event_type. At runtime this is a generic subclass of str. diff --git a/homeassistant/util/event_type.pyi b/homeassistant/util/event_type.pyi index d3adb8a1c54..f9cb140440f 100644 --- a/homeassistant/util/event_type.pyi +++ b/homeassistant/util/event_type.pyi @@ -8,7 +8,9 @@ __all__ = [ "EventType", ] -_DataT = TypeVar("_DataT", bound=Mapping[str, Any], default=Mapping[str, Any]) +_DataT = TypeVar( # needs to be invariant + "_DataT", bound=Mapping[str, Any], default=Mapping[str, Any] +) class EventType(Generic[_DataT]): """Custom type for Event.event_type. At runtime delegated to str. diff --git a/tests/common.py b/tests/common.py index d9315ef074f..cb4706a97b8 100644 --- a/tests/common.py +++ b/tests/common.py @@ -25,7 +25,7 @@ import os import pathlib import time from types import FrameType, ModuleType -from typing import Any, Literal, NoReturn, TypeVar +from typing import Any, Literal, NoReturn from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 @@ -113,8 +113,6 @@ from .testing_config.custom_components.test_constant_deprecation import ( import_deprecated_constant, ) -_DataT = TypeVar("_DataT", bound=Mapping[str, Any], default=dict[str, Any]) - _LOGGER = logging.getLogger(__name__) INSTANCES = [] CLIENT_ID = "https://example.com/app" @@ -1544,7 +1542,7 @@ def mock_platform( module_cache[platform_path] = module or Mock() -def async_capture_events( +def async_capture_events[_DataT: Mapping[str, Any] = dict[str, Any]]( hass: HomeAssistant, event_name: EventType[_DataT] | str ) -> list[Event[_DataT]]: """Create a helper that captures events.""" From d62a66eaf23347816159e0873a670926b19d48dd Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 17 Jan 2025 09:14:08 +0100 Subject: [PATCH 0540/2987] Ensure entity platform in google_wifi tests (#135720) --- tests/components/google_wifi/test_sensor.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py index af870a2136d..18d96e3a1c0 100644 --- a/tests/components/google_wifi/test_sensor.py +++ b/tests/components/google_wifi/test_sensor.py @@ -12,7 +12,11 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import assert_setup_component, async_fire_time_changed +from tests.common import ( + MockEntityPlatform, + assert_setup_component, + async_fire_time_changed, +) NAME = "foo" @@ -111,11 +115,12 @@ def fake_delay(hass: HomeAssistant, ha_delay: int) -> None: async_fire_time_changed(hass, shifted_time) -def test_name(requests_mock: requests_mock.Mocker) -> None: +def test_name(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> None: """Test the name.""" api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) for value in sensor_dict.values(): sensor = value["sensor"] + sensor.platform = MockEntityPlatform(hass) test_name = value["name"] assert test_name == sensor.name From 21256cab859320e7ae2eafe26628cdd1cbd4585d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 17 Jan 2025 09:14:25 +0100 Subject: [PATCH 0541/2987] Ensure entity platform in google_assistant tests (#135719) --- .../components/google_assistant/test_smart_home.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index a1c2ba1b3d4..3b43728988b 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -51,7 +51,7 @@ from homeassistant.setup import async_setup_component from . import BASIC_CONFIG, MockConfig -from tests.common import MockConfigEntry, async_capture_events +from tests.common import MockConfigEntry, MockEntityPlatform, async_capture_events REQ_ID = "ff36a3cc-ec34-11e6-b1a0-64510650abcf" @@ -156,6 +156,7 @@ async def test_sync_message(hass: HomeAssistant, registries) -> None: effect=LIGHT_EFFECT_LIST[0], ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = "light.demo_light" light._attr_device_info = None light._attr_name = "Demo Light" @@ -301,6 +302,7 @@ async def test_sync_in_area(area_on_device, hass: HomeAssistant, registries) -> effect=LIGHT_EFFECT_LIST[0], ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = entity.entity_id light._attr_device_info = None light._attr_name = "Demo Light" @@ -396,6 +398,7 @@ async def test_query_message(hass: HomeAssistant) -> None: effect=LIGHT_EFFECT_LIST[0], ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = "light.demo_light" light._attr_device_info = None light._attr_name = "Demo Light" @@ -405,6 +408,7 @@ async def test_query_message(hass: HomeAssistant) -> None: None, "Another Light", state=True, hs_color=(180, 75), ct=2500, brightness=78 ) light2.hass = hass + light2.platform = MockEntityPlatform(hass) light2.entity_id = "light.another_light" light2._attr_device_info = None light2._attr_name = "Another Light" @@ -412,6 +416,7 @@ async def test_query_message(hass: HomeAssistant) -> None: light3 = DemoLight(None, "Color temp Light", state=True, ct=2500, brightness=200) light3.hass = hass + light3.platform = MockEntityPlatform(hass) light3.entity_id = "light.color_temp_light" light3._attr_device_info = None light3._attr_name = "Color temp Light" @@ -899,6 +904,7 @@ async def test_unavailable_state_does_sync(hass: HomeAssistant) -> None: effect=LIGHT_EFFECT_LIST[0], ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = "light.demo_light" light._available = False light._attr_device_info = None @@ -996,6 +1002,7 @@ async def test_device_class_switch( device_class=device_class, ) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) sensor.entity_id = "switch.demo_sensor" sensor._attr_device_info = None sensor._attr_name = "Demo Sensor" @@ -1046,6 +1053,7 @@ async def test_device_class_binary_sensor( None, "Demo Sensor", state=False, device_class=device_class ) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) sensor.entity_id = "binary_sensor.demo_sensor" sensor._attr_device_info = None sensor._attr_name = "Demo Sensor" @@ -1100,6 +1108,7 @@ async def test_device_class_cover( """Test that a cover entity syncs to the correct device type.""" sensor = DemoCover(None, hass, "Demo Sensor", device_class=device_class) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) sensor.entity_id = "cover.demo_sensor" sensor._attr_device_info = None sensor._attr_name = "Demo Sensor" @@ -1150,6 +1159,7 @@ async def test_device_media_player( """Test that a binary entity syncs to the correct device type.""" sensor = AbstractDemoPlayer("Demo", device_class=device_class) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) sensor.entity_id = "media_player.demo" sensor.async_write_ha_state() @@ -1441,6 +1451,7 @@ async def test_sync_message_recovery( hs_color=(180, 75), ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = "light.demo_light" light._attr_device_info = None light._attr_name = "Demo Light" From cde3ba5504d8043874683dc01c0d2424a33ccc19 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 17 Jan 2025 09:14:40 +0100 Subject: [PATCH 0542/2987] Ensure entity platform in dsmr_reader tests (#135718) --- tests/components/dsmr_reader/test_definitions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/dsmr_reader/test_definitions.py b/tests/components/dsmr_reader/test_definitions.py index 2ddd8395e78..86805fb456f 100644 --- a/tests/components/dsmr_reader/test_definitions.py +++ b/tests/components/dsmr_reader/test_definitions.py @@ -12,7 +12,7 @@ from homeassistant.components.dsmr_reader.sensor import DSMRSensor from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.common import MockConfigEntry, MockEntityPlatform, async_fire_mqtt_message @pytest.mark.parametrize( @@ -93,6 +93,7 @@ async def test_entity_dsmr_transform(hass: HomeAssistant) -> None: ) sensor = DSMRSensor(description, config_entry) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) await sensor.async_added_to_hass() # Test dsmr version, if it's a digit From bd91cc4bdc8cb35945986cdade02914cd51a457f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 17 Jan 2025 09:15:20 +0100 Subject: [PATCH 0543/2987] Use new ServiceInfo location in bosch_shc (#135689) --- homeassistant/components/bosch_shc/config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bosch_shc/config_flow.py b/homeassistant/components/bosch_shc/config_flow.py index 58601152da5..c234000674d 100644 --- a/homeassistant/components/bosch_shc/config_flow.py +++ b/homeassistant/components/bosch_shc/config_flow.py @@ -20,6 +20,7 @@ from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_HOSTNAME, @@ -217,7 +218,7 @@ class BoschSHCConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" if not discovery_info.name.startswith("Bosch SHC"): From 76cdfe861c8714a4d0397448e2303104e46be56e Mon Sep 17 00:00:00 2001 From: Redge Date: Fri, 17 Jan 2025 09:16:45 +0100 Subject: [PATCH 0544/2987] Add 'silent' to HTML5_SHOWNOTIFICATION_PARAMETERS (#135709) --- homeassistant/components/html5/notify.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index 48cc0598479..e9ebdb9da67 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -154,6 +154,7 @@ HTML5_SHOWNOTIFICATION_PARAMETERS = ( "tag", "timestamp", "vibrate", + "silent", ) From 5f9457ab6ec3fca57606e379e2b5b242d22bc5cb Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 17 Jan 2025 09:23:04 +0100 Subject: [PATCH 0545/2987] Update quality scale docs-installation-parameters rule for IronOS integration (#133318) --- homeassistant/components/iron_os/quality_scale.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/iron_os/quality_scale.yaml b/homeassistant/components/iron_os/quality_scale.yaml index fd89b80d782..99fe33c4475 100644 --- a/homeassistant/components/iron_os/quality_scale.yaml +++ b/homeassistant/components/iron_os/quality_scale.yaml @@ -31,9 +31,7 @@ rules: docs-configuration-parameters: status: exempt comment: Integration has no options flow - docs-installation-parameters: - status: todo - comment: Needs bluetooth address as parameter + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done From c215aee9405fc6c3bed4d688ad9f16941b4359f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jan 2025 22:30:14 -1000 Subject: [PATCH 0546/2987] Reduce duplicate code in the Bluetooth WebSocket API (#135808) --- .../components/bluetooth/websocket_api.py | 39 +++++++------------ 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/bluetooth/websocket_api.py b/homeassistant/components/bluetooth/websocket_api.py index b295fb2ac63..45445a7a00f 100644 --- a/homeassistant/components/bluetooth/websocket_api.py +++ b/homeassistant/components/bluetooth/websocket_api.py @@ -106,36 +106,23 @@ class _AdvertisementSubscription: self._async_added(self.pending_service_infos) self.pending_service_infos.clear() - def _async_added(self, service_infos: Iterable[BluetoothServiceInfoBleak]) -> None: + def _async_event_message(self, message: dict[str, Any]) -> None: self.connection.send_message( - json_bytes( - websocket_api.event_message( - self.ws_msg_id, - { - "add": [ - serialize_service_info(service_info, self.time_diff) - for service_info in service_infos - ] - }, - ) - ) + json_bytes(websocket_api.event_message(self.ws_msg_id, message)) + ) + + def _async_added(self, service_infos: Iterable[BluetoothServiceInfoBleak]) -> None: + self._async_event_message( + { + "add": [ + serialize_service_info(service_info, self.time_diff) + for service_info in service_infos + ] + } ) def _async_removed(self, address: str) -> None: - self.connection.send_message( - json_bytes( - websocket_api.event_message( - self.ws_msg_id, - { - "remove": [ - { - "address": address, - } - ] - }, - ) - ) - ) + self._async_event_message({"remove": [{"address": address}]}) @callback def _async_on_advertisement( From b1d899475183e4160165c6b67029cae50ec37c72 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Fri, 17 Jan 2025 09:49:58 +0100 Subject: [PATCH 0547/2987] Add BMW quality scale details (#132017) Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> Co-authored-by: rikroe --- .../bmw_connected_drive/quality_scale.yaml | 107 ++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/bmw_connected_drive/quality_scale.yaml diff --git a/homeassistant/components/bmw_connected_drive/quality_scale.yaml b/homeassistant/components/bmw_connected_drive/quality_scale.yaml new file mode 100644 index 00000000000..bc3bd517662 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/quality_scale.yaml @@ -0,0 +1,107 @@ +# + in comment indicates requirement for quality scale +# - in comment indicates issue to be fixed, not impacting quality scale +rules: + # Bronze + action-setup: + status: exempt + comment: | + Does not have custom services + appropriate-polling: done + brands: done + common-modules: + status: done + comment: | + - 2 states writes in async_added_to_hass() required for platforms that redefine _handle_coordinator_update() + config-flow-test-coverage: + status: todo + comment: | + - test_show_form doesn't really add anything + - Patch bimmer_connected imports with homeassistant.components.bmw_connected_drive.bimmer_connected imports + + Ensure that configs flows end in CREATE_ENTRY or ABORT + - Parameterize test_authentication_error, test_api_error and test_connection_error + + test_full_user_flow_implementation doesn't assert unique id of created entry + + test that aborts when a mocked config entry already exists + + don't test on internals (e.g. `coordinator.last_update_success`) but rather on the resulting state (change) + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + Does not have custom services + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + This integration doesn't have any events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + Does not have custom services + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: + status: done + comment: | + - Use constants in tests where possible + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: This integration doesn't use discovery. + discovery: + status: exempt + comment: This integration doesn't use discovery. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: todo + comment: > + To be discussed. + We cannot regularly get new devices/vehicles due to API quota limitations. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + Other than reauthentication, this integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: todo + comment: > + To be discussed. + We cannot regularly check for stale devices/vehicles due to API quota limitations. + # Platinum + async-dependency: done + inject-websession: + status: todo + comment: > + To be discussed. + The library requires a custom client for API authentication, with custom auth lifecycle and user agents. + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index d1e1a43b48e..4de646e10e9 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -218,7 +218,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "bluetooth_adapters", "bluetooth_le_tracker", "bluetooth_tracker", - "bmw_connected_drive", "bond", "bosch_shc", "braviatv", From 514b74096a61b9b654abe6ad8839911d1be4277b Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Fri, 17 Jan 2025 09:58:46 +0100 Subject: [PATCH 0548/2987] Improve BMW test quality (#133704) --- .../bmw_connected_drive/__init__.py | 7 + .../bmw_connected_drive/test_config_flow.py | 208 ++++++++++-------- .../bmw_connected_drive/test_coordinator.py | 193 +++++++++++----- .../bmw_connected_drive/test_init.py | 6 +- 4 files changed, 263 insertions(+), 151 deletions(-) diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py index c437e1d3669..2cd65364604 100644 --- a/tests/components/bmw_connected_drive/__init__.py +++ b/tests/components/bmw_connected_drive/__init__.py @@ -53,6 +53,13 @@ REMOTE_SERVICE_EXC_TRANSLATION = ( "Error executing remote service on vehicle. HTTPStatusError: 502 Bad Gateway" ) +BIMMER_CONNECTED_LOGIN_PATCH = ( + "homeassistant.components.bmw_connected_drive.config_flow.MyBMWAuthentication.login" +) +BIMMER_CONNECTED_VEHICLE_PATCH = ( + "homeassistant.components.bmw_connected_drive.coordinator.MyBMWAccount.get_vehicles" +) + async def setup_mocked_integration(hass: HomeAssistant) -> MockConfigEntry: """Mock a fully setup config entry and all components based on fixtures.""" diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index 9c124261392..2d4b1390ccc 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -15,11 +15,13 @@ from homeassistant.components.bmw_connected_drive.const import ( CONF_READ_ONLY, CONF_REFRESH_TOKEN, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( + BIMMER_CONNECTED_LOGIN_PATCH, + BIMMER_CONNECTED_VEHICLE_PATCH, FIXTURE_CAPTCHA_INPUT, FIXTURE_CONFIG_ENTRY, FIXTURE_GCID, @@ -40,97 +42,11 @@ def login_sideeffect(self: MyBMWAuthentication): self.gcid = FIXTURE_GCID -async def test_show_form(hass: HomeAssistant) -> None: - """Test that the form is served with no input.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - -async def test_authentication_error(hass: HomeAssistant) -> None: - """Test we show user form on MyBMW authentication error.""" - - with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=MyBMWAuthError("Login failed"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA), - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} - - -async def test_connection_error(hass: HomeAssistant) -> None: - """Test we show user form on MyBMW API error.""" - - with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=RequestError("Connection reset"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA), - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_api_error(hass: HomeAssistant) -> None: - """Test we show user form on general connection error.""" - - with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=MyBMWAPIError("400 Bad Request"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA), - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} - - -@pytest.mark.usefixtures("bmw_fixture") -async def test_captcha_flow_missing_error(hass: HomeAssistant) -> None: - """Test the external flow with captcha failing once and succeeding the second time.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=deepcopy(FIXTURE_USER_INPUT), - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "captcha" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_CAPTCHA_TOKEN: " "} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "missing_captcha"} - - async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: """Test registering an integration and finishing flow works.""" with ( patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", + BIMMER_CONNECTED_LOGIN_PATCH, side_effect=login_sideeffect, autospec=True, ), @@ -155,15 +71,125 @@ async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME] assert result["data"] == FIXTURE_COMPLETE_ENTRY + assert ( + result["result"].unique_id + == f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_USERNAME]}" + ) assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (MyBMWAuthError("Login failed"), "invalid_auth"), + (RequestError("Connection reset"), "cannot_connect"), + (MyBMWAPIError("400 Bad Request"), "cannot_connect"), + ], +) +async def test_error_display_with_successful_login( + hass: HomeAssistant, side_effect: Exception, error: str +) -> None: + """Test we show user form on MyBMW authentication error and are still able to succeed.""" + + with patch( + BIMMER_CONNECTED_LOGIN_PATCH, + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + with ( + patch( + BIMMER_CONNECTED_LOGIN_PATCH, + side_effect=login_sideeffect, + autospec=True, + ), + patch( + "homeassistant.components.bmw_connected_drive.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + deepcopy(FIXTURE_USER_INPUT), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "captcha" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_CAPTCHA_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME] + assert result["data"] == FIXTURE_COMPLETE_ENTRY + assert ( + result["result"].unique_id + == f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_USERNAME]}" + ) + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_unique_id_existing(hass: HomeAssistant) -> None: + """Test registering an integration and when the unique id already exists.""" + + mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) + mock_config_entry.add_to_hass(hass) + + with ( + patch( + BIMMER_CONNECTED_LOGIN_PATCH, + side_effect=login_sideeffect, + autospec=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=deepcopy(FIXTURE_USER_INPUT), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("bmw_fixture") +async def test_captcha_flow_missing_error(hass: HomeAssistant) -> None: + """Test the external flow with captcha failing once and succeeding the second time.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=deepcopy(FIXTURE_USER_INPUT), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "captcha" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_CAPTCHA_TOKEN: " "} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "missing_captcha"} + + async def test_options_flow_implementation(hass: HomeAssistant) -> None: """Test config flow options.""" with ( patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, return_value=[], ), patch( @@ -200,7 +226,7 @@ async def test_reauth(hass: HomeAssistant) -> None: """Test the reauth form.""" with ( patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", + BIMMER_CONNECTED_LOGIN_PATCH, side_effect=login_sideeffect, autospec=True, ), @@ -249,7 +275,7 @@ async def test_reauth(hass: HomeAssistant) -> None: async def test_reconfigure(hass: HomeAssistant) -> None: """Test the reconfiguration form.""" with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", + BIMMER_CONNECTED_LOGIN_PATCH, side_effect=login_sideeffect, autospec=True, ): diff --git a/tests/components/bmw_connected_drive/test_coordinator.py b/tests/components/bmw_connected_drive/test_coordinator.py index beb3d74d572..2e317ec1334 100644 --- a/tests/components/bmw_connected_drive/test_coordinator.py +++ b/tests/components/bmw_connected_drive/test_coordinator.py @@ -1,7 +1,6 @@ -"""Test BMW coordinator.""" +"""Test BMW coordinator for general availability/unavailability of entities and raising issues.""" from copy import deepcopy -from datetime import timedelta from unittest.mock import patch from bimmer_connected.models import ( @@ -13,27 +12,56 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.components.bmw_connected_drive.const import ( + CONF_REFRESH_TOKEN, + SCAN_INTERVALS, +) from homeassistant.const import CONF_REGION from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.update_coordinator import UpdateFailed -from . import FIXTURE_CONFIG_ENTRY +from . import BIMMER_CONNECTED_VEHICLE_PATCH, FIXTURE_CONFIG_ENTRY from tests.common import MockConfigEntry, async_fire_time_changed +FIXTURE_ENTITY_STATES = { + "binary_sensor.m340i_xdrive_door_lock_state": "off", + "lock.m340i_xdrive_lock": "locked", + "lock.i3_rex_lock": "unlocked", + "number.ix_xdrive50_target_soc": "80", + "sensor.ix_xdrive50_rear_left_tire_pressure": "2.61", + "sensor.ix_xdrive50_rear_right_tire_pressure": "2.69", +} +FIXTURE_DEFAULT_REGION = FIXTURE_CONFIG_ENTRY["data"][CONF_REGION] + @pytest.mark.usefixtures("bmw_fixture") -async def test_update_success(hass: HomeAssistant) -> None: - """Test the reauth form.""" - config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) +async def test_config_entry_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if the coordinator updates the refresh token in config entry.""" + config_entry_fixure = deepcopy(FIXTURE_CONFIG_ENTRY) + config_entry_fixure["data"][CONF_REFRESH_TOKEN] = "old_token" + config_entry = MockConfigEntry(**config_entry_fixure) config_entry.add_to_hass(hass) + assert ( + hass.config_entries.async_get_entry(config_entry.entry_id).data[ + CONF_REFRESH_TOKEN + ] + == "old_token" + ) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.runtime_data.last_update_success is True + assert ( + hass.config_entries.async_get_entry(config_entry.entry_id).data[ + CONF_REFRESH_TOKEN + ] + == "another_token_string" + ) @pytest.mark.usefixtures("bmw_fixture") @@ -41,125 +69,176 @@ async def test_update_failed( hass: HomeAssistant, freezer: FrozenDateTimeFactory, ) -> None: - """Test the reauth form.""" + """Test a failing API call.""" config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - coordinator = config_entry.runtime_data - - assert coordinator.last_update_success is True - - freezer.tick(timedelta(minutes=5, seconds=1)) + # Test if entities show data correctly + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state + # On API error, entities should be unavailable + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, side_effect=MyBMWAPIError("Test error"), ): async_fire_time_changed(hass) await hass.async_block_till_done() - assert coordinator.last_update_success is False - assert isinstance(coordinator.last_exception, UpdateFailed) is True + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" + + # And should recover on next update + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state @pytest.mark.usefixtures("bmw_fixture") -async def test_update_reauth( +async def test_auth_failed_as_update_failed( hass: HomeAssistant, freezer: FrozenDateTimeFactory, + issue_registry: ir.IssueRegistry, ) -> None: - """Test the reauth form.""" + """Test a single auth failure not initializing reauth flow.""" config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - coordinator = config_entry.runtime_data + # Test if entities show data correctly + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state - assert coordinator.last_update_success is True - - freezer.tick(timedelta(minutes=5, seconds=1)) + # Due to flaky API, we allow one retry on AuthError and raise as UpdateFailed + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, side_effect=MyBMWAuthError("Test error"), ): async_fire_time_changed(hass) await hass.async_block_till_done() - assert coordinator.last_update_success is False - assert isinstance(coordinator.last_exception, UpdateFailed) is True + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" - freezer.tick(timedelta(minutes=5, seconds=1)) - with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", - side_effect=MyBMWAuthError("Test error"), - ): - async_fire_time_changed(hass) - await hass.async_block_till_done() + # And should recover on next update + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) + async_fire_time_changed(hass) + await hass.async_block_till_done() - assert coordinator.last_update_success is False - assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state + + # Verify that no issues are raised and no reauth flow is initialized + assert len(issue_registry.issues) == 0 + assert len(hass.config_entries.flow.async_progress_by_handler(BMW_DOMAIN)) == 0 @pytest.mark.usefixtures("bmw_fixture") -async def test_init_reauth( +async def test_auth_failed_init_reauth( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, issue_registry: ir.IssueRegistry, ) -> None: - """Test the reauth form.""" + """Test a two subsequent auth failures initializing reauth flow.""" config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Test if entities show data correctly + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state assert len(issue_registry.issues) == 0 + # Due to flaky API, we allow one retry on AuthError and raise as UpdateFailed + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, side_effect=MyBMWAuthError("Test error"), ): - await hass.config_entries.async_setup(config_entry.entry_id) + async_fire_time_changed(hass) await hass.async_block_till_done() + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" + assert len(issue_registry.issues) == 0 + + # On second failure, we should initialize reauth flow + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) + with patch( + BIMMER_CONNECTED_VEHICLE_PATCH, + side_effect=MyBMWAuthError("Test error"), + ): + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" + assert len(issue_registry.issues) == 1 + reauth_issue = issue_registry.async_get_issue( HOMEASSISTANT_DOMAIN, f"config_entry_reauth_{BMW_DOMAIN}_{config_entry.entry_id}", ) assert reauth_issue.active is True + # Check if reauth flow is initialized correctly + flow = hass.config_entries.flow.async_get(reauth_issue.data["flow_id"]) + assert flow["handler"] == BMW_DOMAIN + assert flow["context"]["source"] == "reauth" + assert flow["context"]["unique_id"] == config_entry.unique_id + @pytest.mark.usefixtures("bmw_fixture") async def test_captcha_reauth( hass: HomeAssistant, freezer: FrozenDateTimeFactory, + issue_registry: ir.IssueRegistry, ) -> None: - """Test the reauth form.""" - TEST_REGION = "north_america" - - config_entry_fixure = deepcopy(FIXTURE_CONFIG_ENTRY) - config_entry_fixure["data"][CONF_REGION] = TEST_REGION - config_entry = MockConfigEntry(**config_entry_fixure) + """Test a CaptchaError initializing reauth flow.""" + config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - coordinator = config_entry.runtime_data + # Test if entities show data correctly + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state - assert coordinator.last_update_success is True - - freezer.tick(timedelta(minutes=10, seconds=1)) + # If library decides a captcha is needed, we should initialize reauth flow + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", - side_effect=MyBMWCaptchaMissingError( - "Missing hCaptcha token for North America login" - ), + BIMMER_CONNECTED_VEHICLE_PATCH, + side_effect=MyBMWCaptchaMissingError("Missing hCaptcha token"), ): async_fire_time_changed(hass) await hass.async_block_till_done() - assert coordinator.last_update_success is False - assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True - assert coordinator.last_exception.translation_key == "missing_captcha" + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" + assert len(issue_registry.issues) == 1 + + reauth_issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, + f"config_entry_reauth_{BMW_DOMAIN}_{config_entry.entry_id}", + ) + assert reauth_issue.active is True + + # Check if reauth flow is initialized correctly + flow = hass.config_entries.flow.async_get(reauth_issue.data["flow_id"]) + assert flow["handler"] == BMW_DOMAIN + assert flow["context"]["source"] == "reauth" + assert flow["context"]["unique_id"] == config_entry.unique_id diff --git a/tests/components/bmw_connected_drive/test_init.py b/tests/components/bmw_connected_drive/test_init.py index 8507cacc376..d0624825cb5 100644 --- a/tests/components/bmw_connected_drive/test_init.py +++ b/tests/components/bmw_connected_drive/test_init.py @@ -14,7 +14,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import FIXTURE_CONFIG_ENTRY +from . import BIMMER_CONNECTED_VEHICLE_PATCH, FIXTURE_CONFIG_ENTRY from tests.common import MockConfigEntry @@ -156,7 +156,7 @@ async def test_migrate_unique_ids( assert entity.unique_id == old_unique_id with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, return_value=[], ): assert await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -212,7 +212,7 @@ async def test_dont_migrate_unique_ids( assert entity.unique_id == old_unique_id with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, return_value=[], ): assert await hass.config_entries.async_setup(mock_config_entry.entry_id) From 5e0bbf65e4c433d9685f99a5ff4fda839985791a Mon Sep 17 00:00:00 2001 From: dcmeglio <21957250+dcmeglio@users.noreply.github.com> Date: Fri, 17 Jan 2025 04:14:41 -0500 Subject: [PATCH 0549/2987] Gracefully handle webhook unsubscription if error occurs while contacting Withings (#134271) --- homeassistant/components/withings/__init__.py | 6 ++- tests/components/withings/test_init.py | 54 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 1c196bd4b92..59c3ed8433f 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -16,6 +16,7 @@ from aiohttp import ClientError from aiohttp.hdrs import METH_POST from aiohttp.web import Request, Response from aiowithings import NotificationCategory, WithingsClient +from aiowithings.exceptions import WithingsError from aiowithings.util import to_enum from yarl import URL @@ -223,10 +224,13 @@ class WithingsWebhookManager: "Unregister Withings webhook (%s)", self.entry.data[CONF_WEBHOOK_ID] ) webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) - await async_unsubscribe_webhooks(self.withings_data.client) for coordinator in self.withings_data.coordinators: coordinator.webhook_subscription_listener(False) self._webhooks_registered = False + try: + await async_unsubscribe_webhooks(self.withings_data.client) + except WithingsError as ex: + LOGGER.warning("Failed to unsubscribe from Withings webhook: %s", ex) async def register_webhook( self, diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index e07e1f90cb4..d88af39488b 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -10,6 +10,7 @@ from aiohttp.hdrs import METH_HEAD from aiowithings import ( NotificationCategory, WithingsAuthenticationFailedError, + WithingsConnectionError, WithingsUnauthorizedError, ) from freezegun.api import FrozenDateTimeFactory @@ -532,6 +533,59 @@ async def test_cloud_disconnect_retry( assert mock_async_active_subscription.call_count == 4 +async def test_internet_timeout_then_restore( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we can recover from internet disconnects.""" + await mock_cloud(hass) + await hass.async_block_till_done() + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch.object(cloud, "async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), + patch( + "homeassistant.components.withings.async_get_config_entry_implementation", + ), + patch( + "homeassistant.components.cloud.async_delete_cloudhook", + ), + patch( + "homeassistant.components.withings.webhook_generate_url", + ), + ): + await setup_integration(hass, webhook_config_entry) + await prepare_webhook_setup(hass, freezer) + + assert cloud.async_active_subscription(hass) is True + assert cloud.async_is_connected(hass) is True + assert withings.revoke_notification_configurations.call_count == 3 + assert withings.subscribe_notification.call_count == 6 + + await hass.async_block_till_done() + + withings.list_notification_configurations.side_effect = WithingsConnectionError + + async_mock_cloud_connection_status(hass, False) + await hass.async_block_till_done() + + assert withings.revoke_notification_configurations.call_count == 3 + withings.list_notification_configurations.side_effect = None + + async_mock_cloud_connection_status(hass, True) + await hass.async_block_till_done() + + assert withings.subscribe_notification.call_count == 12 + + @pytest.mark.parametrize( ("body", "expected_code"), [ From 85b4be2f16a1566508041d939aeb46ca64780d19 Mon Sep 17 00:00:00 2001 From: Simon <80467011+sorgfresser@users.noreply.github.com> Date: Fri, 17 Jan 2025 09:18:07 +0000 Subject: [PATCH 0550/2987] Add model option to speak action for ElevenLabs (#133902) --- homeassistant/components/elevenlabs/const.py | 2 ++ homeassistant/components/elevenlabs/tts.py | 6 ++-- tests/components/elevenlabs/test_tts.py | 37 ++++++++++++++++++-- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/elevenlabs/const.py b/homeassistant/components/elevenlabs/const.py index 040d38d272c..1de92f95e43 100644 --- a/homeassistant/components/elevenlabs/const.py +++ b/homeassistant/components/elevenlabs/const.py @@ -1,5 +1,7 @@ """Constants for the ElevenLabs text-to-speech integration.""" +ATTR_MODEL = "model" + CONF_VOICE = "voice" CONF_MODEL = "model" CONF_CONFIGURE_VOICE = "configure_voice" diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index b89e966593f..008cd106615 100644 --- a/homeassistant/components/elevenlabs/tts.py +++ b/homeassistant/components/elevenlabs/tts.py @@ -24,6 +24,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ElevenLabsConfigEntry from .const import ( + ATTR_MODEL, CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, @@ -85,7 +86,7 @@ async def async_setup_entry( class ElevenLabsTTSEntity(TextToSpeechEntity): """The ElevenLabs API entity.""" - _attr_supported_options = [ATTR_VOICE] + _attr_supported_options = [ATTR_VOICE, ATTR_MODEL] _attr_entity_category = EntityCategory.CONFIG def __init__( @@ -141,13 +142,14 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): _LOGGER.debug("Getting TTS audio for %s", message) _LOGGER.debug("Options: %s", options) voice_id = options.get(ATTR_VOICE, self._default_voice_id) + model = options.get(ATTR_MODEL, self._model.model_id) try: audio = await self._client.generate( text=message, voice=voice_id, optimize_streaming_latency=self._latency, voice_settings=self._voice_settings, - model=self._model.model_id, + model=model, ) bytes_combined = b"".join([byte_seg async for byte_seg in audio]) except ApiError as exc: diff --git a/tests/components/elevenlabs/test_tts.py b/tests/components/elevenlabs/test_tts.py index 7151aab10f2..c4234cb38ae 100644 --- a/tests/components/elevenlabs/test_tts.py +++ b/tests/components/elevenlabs/test_tts.py @@ -13,6 +13,7 @@ import pytest from homeassistant.components import tts from homeassistant.components.elevenlabs.const import ( + ATTR_MODEL, CONF_MODEL, CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, @@ -163,6 +164,16 @@ async def mock_config_entry_setup( @pytest.mark.parametrize( ("setup", "tts_service", "service_data"), [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.mock_title", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {}, + }, + ), ( "mock_config_entry_setup", "speak", @@ -173,6 +184,26 @@ async def mock_config_entry_setup( tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, }, ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.mock_title", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {ATTR_MODEL: "model2"}, + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.mock_title", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2", ATTR_MODEL: "model2"}, + }, + ), ], indirect=["setup"], ) @@ -206,11 +237,13 @@ async def test_tts_service_speak( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == HTTPStatus.OK ) + voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE, "voice1") + model_id = service_data[tts.ATTR_OPTIONS].get(ATTR_MODEL, "model1") tts_entity._client.generate.assert_called_once_with( text="There is a person at the front door.", - voice="voice2", - model="model1", + voice=voice_id, + model=model_id, voice_settings=tts_entity._voice_settings, optimize_streaming_latency=tts_entity._latency, ) From b4f4b06f29acba9643b8a84ff2fd203a644c1213 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 17 Jan 2025 10:20:45 +0100 Subject: [PATCH 0551/2987] Enable RUF021 (#135832) --- homeassistant/components/alexa/entities.py | 33 +++++++++++-------- .../components/alexa/state_report.py | 5 ++- homeassistant/components/bond/entity.py | 7 ++-- homeassistant/components/bthome/logbook.py | 2 +- .../components/caldav/coordinator.py | 12 +++---- homeassistant/components/coinbase/__init__.py | 3 +- .../components/config/entity_registry.py | 5 ++- homeassistant/components/energy/sensor.py | 11 +++---- .../components/geo_location/trigger.py | 9 ++--- homeassistant/components/glances/sensor.py | 6 ++-- homeassistant/components/google_mail/api.py | 6 ++-- homeassistant/components/group/entity.py | 6 ++-- homeassistant/components/history/__init__.py | 10 +++--- .../components/history/websocket_api.py | 10 +++--- .../components/homekit/type_covers.py | 7 ++-- homeassistant/components/homekit/util.py | 3 +- homeassistant/components/lidarr/sensor.py | 6 ++-- .../components/melcloud/config_flow.py | 8 ++--- homeassistant/components/mqtt/config_flow.py | 29 ++++++++-------- homeassistant/components/myuplink/helpers.py | 6 ++-- homeassistant/components/netatmo/__init__.py | 3 +- .../components/octoprint/coordinator.py | 2 +- homeassistant/components/overkiz/sensor.py | 3 +- .../domestic_hot_water_production.py | 6 ++-- .../components/python_script/__init__.py | 21 ++++-------- homeassistant/components/rfxtrx/switch.py | 3 +- .../components/rmvtransport/sensor.py | 8 ++--- homeassistant/components/shelly/switch.py | 5 ++- homeassistant/components/siren/__init__.py | 6 ++-- .../components/snmp/device_tracker.py | 2 +- homeassistant/components/snmp/switch.py | 2 +- .../components/squeezebox/__init__.py | 5 ++- homeassistant/components/ssdp/__init__.py | 2 +- homeassistant/components/tts/__init__.py | 6 ++-- homeassistant/components/tuya/vacuum.py | 5 ++- homeassistant/components/unifi/services.py | 9 ++--- homeassistant/components/whirlpool/sensor.py | 5 ++- homeassistant/components/zone/trigger.py | 16 +++------ homeassistant/components/zwave_js/light.py | 2 +- homeassistant/helpers/condition.py | 6 ++-- homeassistant/helpers/event.py | 3 +- homeassistant/helpers/script.py | 6 ++-- homeassistant/helpers/template.py | 11 ++++--- homeassistant/helpers/update_coordinator.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 16 +++++---- pylint/plugins/hass_imports.py | 5 ++- pyproject.toml | 1 + script/translations/deduplicate.py | 6 ++-- .../google_assistant/test_helpers.py | 2 +- tests/components/imap/test_init.py | 14 +++----- tests/components/matrix/test_commands.py | 5 ++- tests/components/onewire/__init__.py | 12 +++---- 52 files changed, 166 insertions(+), 218 deletions(-) diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 8c139d66369..6a0b1830b7e 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -474,25 +474,30 @@ class ClimateCapabilities(AlexaEntity): # If we support two modes, one being off, we allow turning on too. supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if ( - self.entity.domain == climate.DOMAIN - and climate.HVACMode.OFF - in (self.entity.attributes.get(climate.ATTR_HVAC_MODES) or []) - or self.entity.domain == climate.DOMAIN - and ( - supported_features - & ( - climate.ClimateEntityFeature.TURN_ON - | climate.ClimateEntityFeature.TURN_OFF + ( + self.entity.domain == climate.DOMAIN + and climate.HVACMode.OFF + in (self.entity.attributes.get(climate.ATTR_HVAC_MODES) or []) + ) + or ( + self.entity.domain == climate.DOMAIN + and ( + supported_features + & ( + climate.ClimateEntityFeature.TURN_ON + | climate.ClimateEntityFeature.TURN_OFF + ) ) ) - or self.entity.domain == water_heater.DOMAIN - and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF) + or ( + self.entity.domain == water_heater.DOMAIN + and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF) + ) ): yield AlexaPowerController(self.entity) - if ( - self.entity.domain == climate.DOMAIN - or self.entity.domain == water_heater.DOMAIN + if self.entity.domain == climate.DOMAIN or ( + self.entity.domain == water_heater.DOMAIN and ( supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 3eb761dacde..03b6a22007c 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -317,9 +317,8 @@ async def async_enable_proactive_mode( if should_doorbell: old_state = data["old_state"] - if ( - new_state.domain == event.DOMAIN - or new_state.state == STATE_ON + if new_state.domain == event.DOMAIN or ( + new_state.state == STATE_ON and (old_state is None or old_state.state != STATE_ON) ): await async_send_doorbell_event_message( diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 81f96b1772c..2ae1df5fd68 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -115,11 +115,8 @@ class BondEntity(Entity): def _async_update_if_bpup_not_alive(self, now: datetime) -> None: """Fetch via the API if BPUP is not alive.""" self._async_schedule_bpup_alive_or_poll() - if ( - self.hass.is_stopping - or self._bpup_subs.alive - and self._initialized - and self.available + if self.hass.is_stopping or ( + self._bpup_subs.alive and self._initialized and self.available ): return if self._update_lock.locked(): diff --git a/homeassistant/components/bthome/logbook.py b/homeassistant/components/bthome/logbook.py index 32e90118dea..1c41d5553da 100644 --- a/homeassistant/components/bthome/logbook.py +++ b/homeassistant/components/bthome/logbook.py @@ -26,7 +26,7 @@ def async_describe_events( """Describe bthome logbook event.""" data = event.data device = dev_reg.async_get(data["device_id"]) - name = device and device.name or f"BTHome {data['address']}" + name = (device and device.name) or f"BTHome {data['address']}" if properties := data["event_properties"]: message = f"{data['event_class']} {data['event_type']}: {properties}" else: diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py index eb09e3f5452..c6bbd15bdff 100644 --- a/homeassistant/components/caldav/coordinator.py +++ b/homeassistant/components/caldav/coordinator.py @@ -186,12 +186,12 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): pattern = re.compile(search) return ( - hasattr(vevent, "summary") - and pattern.match(vevent.summary.value) - or hasattr(vevent, "location") - and pattern.match(vevent.location.value) - or hasattr(vevent, "description") - and pattern.match(vevent.description.value) + (hasattr(vevent, "summary") and pattern.match(vevent.summary.value)) + or (hasattr(vevent, "location") and pattern.match(vevent.location.value)) + or ( + hasattr(vevent, "description") + and pattern.match(vevent.description.value) + ) ) @staticmethod diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index f5fd8fa1dc3..6aa33a7c14d 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -101,7 +101,8 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non if ( "xe" in entity.unique_id and currency not in config_entry.options.get(CONF_EXCHANGE_RATES, []) - or "wallet" in entity.unique_id + ) or ( + "wallet" in entity.unique_id and currency not in config_entry.options.get(CONF_CURRENCIES, []) ): registry.async_remove(entity.entity_id) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index aed04943975..b987f249a33 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -279,9 +279,8 @@ def websocket_update_entity( result: dict[str, Any] = {"entity_entry": entity_entry.extended_dict} if "disabled_by" in changes and changes["disabled_by"] is None: # Enabling an entity requires a config entry reload, or HA restart - if ( - not (config_entry_id := entity_entry.config_entry_id) - or (config_entry := hass.config_entries.async_get_entry(config_entry_id)) + if not (config_entry_id := entity_entry.config_entry_id) or ( + (config_entry := hass.config_entries.async_get_entry(config_entry_id)) and not config_entry.supports_unload ): result["require_restart"] = True diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 147d8f3e26a..199d18d6b07 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -362,12 +362,11 @@ class EnergyCostSensor(SensorEntity): return if ( - ( - state_class != SensorStateClass.TOTAL_INCREASING - and energy_state.attributes.get(ATTR_LAST_RESET) - != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET) - ) - or state_class == SensorStateClass.TOTAL_INCREASING + state_class != SensorStateClass.TOTAL_INCREASING + and energy_state.attributes.get(ATTR_LAST_RESET) + != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET) + ) or ( + state_class == SensorStateClass.TOTAL_INCREASING and reset_detected( self.hass, cast(str, self._config[self._adapter.stat_energy_key]), diff --git a/homeassistant/components/geo_location/trigger.py b/homeassistant/components/geo_location/trigger.py index 96244e08d1b..5f0d6e92ee1 100644 --- a/homeassistant/components/geo_location/trigger.py +++ b/homeassistant/components/geo_location/trigger.py @@ -83,13 +83,8 @@ async def async_attach_trigger( ) to_match = condition.zone(hass, zone_state, to_state) if to_state else False - if ( - trigger_event == EVENT_ENTER - and not from_match - and to_match - or trigger_event == EVENT_LEAVE - and from_match - and not to_match + if (trigger_event == EVENT_ENTER and not from_match and to_match) or ( + trigger_event == EVENT_LEAVE and from_match and not to_match ): hass.async_run_hass_job( job, diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 59eba69d60a..0741926296e 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -375,6 +375,8 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit self._data_valid = self._attr_native_value is not None and ( not self._numeric_state_expected or isinstance(self._attr_native_value, (int, float)) - or isinstance(self._attr_native_value, str) - and self._attr_native_value.isnumeric() + or ( + isinstance(self._attr_native_value, str) + and self._attr_native_value.isnumeric() + ) ) diff --git a/homeassistant/components/google_mail/api.py b/homeassistant/components/google_mail/api.py index 485d640a04d..3e455f645ad 100644 --- a/homeassistant/components/google_mail/api.py +++ b/homeassistant/components/google_mail/api.py @@ -49,10 +49,8 @@ class AsyncConfigEntryAuth: "OAuth session is not valid, reauth required" ) from ex raise ConfigEntryNotReady from ex - if ( - isinstance(ex, RefreshError) - or hasattr(ex, "status") - and ex.status == 400 + if isinstance(ex, RefreshError) or ( + hasattr(ex, "status") and ex.status == 400 ): self.oauth_session.config_entry.async_start_reauth( self.oauth_session.hass diff --git a/homeassistant/components/group/entity.py b/homeassistant/components/group/entity.py index 03a8be4bed5..40db70a2eb3 100644 --- a/homeassistant/components/group/entity.py +++ b/homeassistant/components/group/entity.py @@ -440,10 +440,8 @@ class Group(Entity): if not self._on_off: return - if ( - tr_state is None - or self._assumed_state - and not tr_state.attributes.get(ATTR_ASSUMED_STATE) + if tr_state is None or ( + self._assumed_state and not tr_state.attributes.get(ATTR_ASSUMED_STATE) ): self._assumed_state = self.mode(self._assumed.values()) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 7241e1fac9a..ba4614bbc35 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -111,10 +111,12 @@ class HistoryPeriodView(HomeAssistantView): # end_time. If it's false, we know there are no states in the # database up until end_time. (end_time and not has_states_before(hass, end_time)) - or not include_start_time_state - and entity_ids - and not entities_may_have_state_changes_after( - hass, entity_ids, start_time, no_attributes + or ( + not include_start_time_state + and entity_ids + and not entities_may_have_state_changes_after( + hass, entity_ids, start_time, no_attributes + ) ) ): return self.json([]) diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 35f8ed5f1ac..e6c91453213 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -146,10 +146,12 @@ async def ws_get_history_during_period( # end_time. If it's false, we know there are no states in the # database up until end_time. (end_time and not has_states_before(hass, end_time)) - or not include_start_time_state - and entity_ids - and not entities_may_have_state_changes_after( - hass, entity_ids, start_time, no_attributes + or ( + not include_start_time_state + and entity_ids + and not entities_may_have_state_changes_after( + hass, entity_ids, start_time, no_attributes + ) ) ): connection.send_result(msg["id"], {}) diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 6752633f3d2..651033682cf 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -409,11 +409,8 @@ class WindowCoveringBasic(OpeningDeviceBase, HomeAccessory): """Move cover to value if call came from HomeKit.""" _LOGGER.debug("%s: Set position to %d", self.entity_id, value) - if ( - self._supports_stop - and value > 70 - or not self._supports_stop - and value >= 50 + if (self._supports_stop and value > 70) or ( + not self._supports_stop and value >= 50 ): service, position = (SERVICE_OPEN_COVER, 100) elif value < 30 or not self._supports_stop: diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index d339aa6aded..cd659654617 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -643,7 +643,8 @@ def state_needs_accessory_mode(state: State) -> bool: state.domain == MEDIA_PLAYER_DOMAIN and state.attributes.get(ATTR_DEVICE_CLASS) in (MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.RECEIVER) - or state.domain == REMOTE_DOMAIN + ) or ( + state.domain == REMOTE_DOMAIN and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & RemoteEntityFeature.ACTIVITY ) diff --git a/homeassistant/components/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py index b02361e65ca..805fcce53ad 100644 --- a/homeassistant/components/lidarr/sensor.py +++ b/homeassistant/components/lidarr/sensor.py @@ -160,10 +160,8 @@ class LidarrSensor(LidarrEntity[T], SensorEntity): def queue_str(item: LidarrQueueItem) -> str: """Return string description of queue item.""" - if ( - item.sizeleft > 0 - and item.timeleft == "00:00:00" - or not hasattr(item, "trackedDownloadState") + if (item.sizeleft > 0 and item.timeleft == "00:00:00") or not hasattr( + item, "trackedDownloadState" ): return "stopped" return item.trackedDownloadState diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index b604ee5016e..d2c9d67f29a 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -126,9 +126,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN, ) - or isinstance(err, AttributeError) - and err.name == "get" - ): + ) or (isinstance(err, AttributeError) and err.name == "get"): errors["base"] = "invalid_auth" else: errors["base"] = "cannot_connect" @@ -165,9 +163,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN, ) - or isinstance(err, AttributeError) - and err.name == "get" - ): + ) or (isinstance(err, AttributeError) and err.name == "get"): errors["base"] = "invalid_auth" else: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index f07777742ee..a4d400dfea2 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -768,11 +768,8 @@ async def async_get_broker_settings( validated_user_input.update(user_input) client_certificate_id: str | None = user_input.get(CONF_CLIENT_CERT) client_key_id: str | None = user_input.get(CONF_CLIENT_KEY) - if ( - client_certificate_id - and not client_key_id - or not client_certificate_id - and client_key_id + if (client_certificate_id and not client_key_id) or ( + not client_certificate_id and client_key_id ): errors["base"] = "invalid_inclusion" return False @@ -782,14 +779,20 @@ async def async_get_broker_settings( # Return to form for file upload CA cert or client cert and key if ( - not client_certificate - and user_input.get(SET_CLIENT_CERT) - and not client_certificate_id - or not certificate - and user_input.get(SET_CA_CERT, "off") == "custom" - and not certificate_id - or user_input.get(CONF_TRANSPORT) == TRANSPORT_WEBSOCKETS - and CONF_WS_PATH not in user_input + ( + not client_certificate + and user_input.get(SET_CLIENT_CERT) + and not client_certificate_id + ) + or ( + not certificate + and user_input.get(SET_CA_CERT, "off") == "custom" + and not certificate_id + ) + or ( + user_input.get(CONF_TRANSPORT) == TRANSPORT_WEBSOCKETS + and CONF_WS_PATH not in user_input + ) ): return False diff --git a/homeassistant/components/myuplink/helpers.py b/homeassistant/components/myuplink/helpers.py index bd875d8a872..5751d574e04 100644 --- a/homeassistant/components/myuplink/helpers.py +++ b/homeassistant/components/myuplink/helpers.py @@ -26,10 +26,8 @@ def find_matching_platform( if len(device_point.enum_values) > 0 and device_point.writable: return Platform.SELECT - if ( - description - and description.native_unit_of_measurement == "DM" - or (device_point.raw["maxValue"] and device_point.raw["minValue"]) + if (description and description.native_unit_of_measurement == "DM") or ( + device_point.raw["maxValue"] and device_point.raw["minValue"] ): if device_point.writable: return Platform.NUMBER diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index 6f14c9c76bb..9c92724c543 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -257,7 +257,6 @@ async def async_remove_config_entry_device( return not any( identifier for identifier in device_entry.identifiers - if identifier[0] == DOMAIN - and identifier[1] in modules + if (identifier[0] == DOMAIN and identifier[1] in modules) or identifier[1] in rooms ) diff --git a/homeassistant/components/octoprint/coordinator.py b/homeassistant/components/octoprint/coordinator.py index ff00b6c3420..c6d7373a002 100644 --- a/homeassistant/components/octoprint/coordinator.py +++ b/homeassistant/components/octoprint/coordinator.py @@ -80,7 +80,7 @@ class OctoprintDataUpdateCoordinator(DataUpdateCoordinator): """Device info.""" unique_id = cast(str, self.config_entry.unique_id) configuration_url = URL.build( - scheme=self.config_entry.data[CONF_SSL] and "https" or "http", + scheme=(self.config_entry.data[CONF_SSL] and "https") or "http", host=self.config_entry.data[CONF_HOST], port=self.config_entry.data[CONF_PORT], path=self.config_entry.data[CONF_PATH], diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 84d25b01d24..81a9ab41d2d 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -534,8 +534,7 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity): # This is probably incorrect and should be fixed in a follow up PR. # To ensure measurement sensors do not get an `unknown` state on # a falsy value (e.g. 0 or 0.0) we also check the state_class. - or self.state_class != SensorStateClass.MEASUREMENT - and not state.value + or (self.state_class != SensorStateClass.MEASUREMENT and not state.value) ): return None diff --git a/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py b/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py index abd3f40adc2..f5a9e3d4a7e 100644 --- a/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py +++ b/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py @@ -64,10 +64,8 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity): for param, mode in OVERKIZ_TO_OPERATION_MODE.items(): # Filter only for mode allowed by this device # or allow all if no mode definition found - if ( - not state_mode_definition - or state_mode_definition.values - and param in state_mode_definition.values + if not state_mode_definition or ( + state_mode_definition.values and param in state_mode_definition.values ): self.operation_mode_to_overkiz[mode] = param self._attr_operation_list.append(param) diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index f9e6a994406..dbd1a5dce4b 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -239,20 +239,13 @@ def execute( if name.startswith("async_"): raise ScriptError("Not allowed to access async methods") if ( - obj is hass - and name not in ALLOWED_HASS - or obj is hass.bus - and name not in ALLOWED_EVENTBUS - or obj is hass.states - and name not in ALLOWED_STATEMACHINE - or obj is hass.services - and name not in ALLOWED_SERVICEREGISTRY - or obj is dt_util - and name not in ALLOWED_DT_UTIL - or obj is datetime - and name not in ALLOWED_DATETIME - or isinstance(obj, TimeWrapper) - and name not in ALLOWED_TIME + (obj is hass and name not in ALLOWED_HASS) + or (obj is hass.bus and name not in ALLOWED_EVENTBUS) + or (obj is hass.states and name not in ALLOWED_STATEMACHINE) + or (obj is hass.services and name not in ALLOWED_SERVICEREGISTRY) + or (obj is dt_util and name not in ALLOWED_DT_UTIL) + or (obj is datetime and name not in ALLOWED_DATETIME) + or (isinstance(obj, TimeWrapper) and name not in ALLOWED_TIME) ): raise ScriptError(f"Not allowed to access {obj.__class__.__name__}.{name}") diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index 1464cccb5c4..cd17e71f4f0 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -35,8 +35,7 @@ def supported(event: rfxtrxmod.RFXtrxEvent) -> bool: isinstance(event.device, rfxtrxmod.LightingDevice) and not event.device.known_to_be_dimmable and not event.device.known_to_be_rollershutter - or isinstance(event.device, rfxtrxmod.RfyDevice) - ) + ) or isinstance(event.device, rfxtrxmod.RfyDevice) async def async_setup_entry( diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index 8fd437e7e1d..ac6c66bb6d2 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -271,11 +271,9 @@ class RMVDepartureData: if not dest_found: continue - if ( - self._lines - and journey["number"] not in self._lines - or journey["minutes"] < self._time_offset - ): + if (self._lines and journey["number"] not in self._lines) or journey[ + "minutes" + ] < self._time_offset: continue for attr in ("direction", "departure_time", "product", "minutes"): diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 134704cb0ff..8a33dae0938 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -120,9 +120,8 @@ def async_setup_block_entry( relay_blocks = [] assert coordinator.device.blocks for block in coordinator.device.blocks: - if ( - block.type != "relay" - or block.channel is not None + if block.type != "relay" or ( + block.channel is not None and is_block_channel_type_light( coordinator.device.settings, int(block.channel) ) diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index 9ce6898fd93..02b49f5732e 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -68,10 +68,8 @@ def process_turn_on_params( isinstance(siren.available_tones, dict) and tone in siren.available_tones.values() ) - if ( - not siren.available_tones - or tone not in siren.available_tones - and not is_tone_dict_value + if not siren.available_tones or ( + tone not in siren.available_tones and not is_tone_dict_value ): raise ValueError( f"Invalid tone specified for entity {siren.entity_id}: {tone}, " diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index 3c4a0a0725c..4c2b2b25ad8 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -172,7 +172,7 @@ class SnmpScanner(DeviceScanner): _LOGGER.error( "SNMP error: %s at %s", errstatus.prettyPrint(), - errindex and res[int(errindex) - 1][0] or "?", + (errindex and res[int(errindex) - 1][0]) or "?", ) return None diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index 92e27daed6c..2f9f8b0bfb7 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -264,7 +264,7 @@ class SnmpSwitch(SwitchEntity): _LOGGER.error( "SNMP error: %s at %s", errstatus.prettyPrint(), - errindex and restable[-1][int(errindex) - 1] or "?", + (errindex and restable[-1][int(errindex) - 1]) or "?", ) else: for resrow in restable: diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index f466f3bcb62..f94ea118c6a 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -105,9 +105,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - lms.name = ( (STATUS_QUERY_LIBRARYNAME in status and status[STATUS_QUERY_LIBRARYNAME]) and status[STATUS_QUERY_LIBRARYNAME] - or host - ) - version = STATUS_QUERY_VERSION in status and status[STATUS_QUERY_VERSION] or None + ) or host + version = (STATUS_QUERY_VERSION in status and status[STATUS_QUERY_VERSION]) or None # mac can be missing mac_connect = ( {(CONNECTION_NETWORK_MAC, format_mac(status[STATUS_QUERY_MAC]))} diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 637974853f6..c5fb349ddbb 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -273,7 +273,7 @@ async def async_build_source_set(hass: HomeAssistant) -> set[IPv4Address | IPv6A for source_ip in await network.async_get_enabled_source_ips(hass) if not source_ip.is_loopback and not source_ip.is_global - and (source_ip.version == 6 and source_ip.scope_id or source_ip.version == 4) + and ((source_ip.version == 6 and source_ip.scope_id) or source_ip.version == 4) } diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 80c175ccfe4..0213fd17864 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -1052,10 +1052,8 @@ class TextToSpeechUrlView(HomeAssistantView): data = await request.json() except ValueError: return self.json_message("Invalid JSON specified", HTTPStatus.BAD_REQUEST) - if ( - not data.get("engine_id") - and not data.get(ATTR_PLATFORM) - or not data.get(ATTR_MESSAGE) + if (not data.get("engine_id") and not data.get(ATTR_PLATFORM)) or not data.get( + ATTR_MESSAGE ): return self.json_message( "Must specify platform and message", HTTPStatus.BAD_REQUEST diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index 738492102a1..bab9ac309ec 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -89,9 +89,8 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): if self.find_dpcode(DPCode.PAUSE, prefer_function=True): self._attr_supported_features |= VacuumEntityFeature.PAUSE - if ( - self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True) - or ( + if self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True) or ( + ( enum_type := self.find_dpcode( DPCode.MODE, dptype=DPType.ENUM, prefer_function=True ) diff --git a/homeassistant/components/unifi/services.py b/homeassistant/components/unifi/services.py index ce726a0f5d0..fc63c092d56 100644 --- a/homeassistant/components/unifi/services.py +++ b/homeassistant/components/unifi/services.py @@ -69,8 +69,7 @@ async def async_reconnect_client(hass: HomeAssistant, data: Mapping[str, Any]) - for config_entry in hass.config_entries.async_entries(UNIFI_DOMAIN): if config_entry.state is not ConfigEntryState.LOADED or ( - (hub := config_entry.runtime_data) - and not hub.available + ((hub := config_entry.runtime_data) and not hub.available) or (client := hub.api.clients.get(mac)) is None or client.is_wired ): @@ -87,10 +86,8 @@ async def async_remove_clients(hass: HomeAssistant, data: Mapping[str, Any]) -> - Neither IP, hostname nor name is configured. """ for config_entry in hass.config_entries.async_entries(UNIFI_DOMAIN): - if ( - config_entry.state is not ConfigEntryState.LOADED - or (hub := config_entry.runtime_data) - and not hub.available + if config_entry.state is not ConfigEntryState.LOADED or ( + (hub := config_entry.runtime_data) and not hub.available ): continue diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index b84518cedf1..9180164c272 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -291,9 +291,8 @@ class WasherDryerTimeClass(RestoreSensor): seconds=int(self._wd.get_attribute("Cavity_TimeStatusEstTimeRemaining")) ) - if ( - self._attr_native_value is None - or isinstance(self._attr_native_value, datetime) + if self._attr_native_value is None or ( + isinstance(self._attr_native_value, datetime) and abs(new_timestamp - self._attr_native_value) > timedelta(seconds=60) ): self._attr_native_value = new_timestamp diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index aa4aefe6d95..af4999e5438 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -85,11 +85,8 @@ async def async_attach_trigger( from_s = zone_event.data["old_state"] to_s = zone_event.data["new_state"] - if ( - from_s - and not location.has_location(from_s) - or to_s - and not location.has_location(to_s) + if (from_s and not location.has_location(from_s)) or ( + to_s and not location.has_location(to_s) ): return @@ -107,13 +104,8 @@ async def async_attach_trigger( from_match = condition.zone(hass, zone_state, from_s) if from_s else False to_match = condition.zone(hass, zone_state, to_s) if to_s else False - if ( - event == EVENT_ENTER - and not from_match - and to_match - or event == EVENT_LEAVE - and from_match - and not to_match + if (event == EVENT_ENTER and not from_match and to_match) or ( + event == EVENT_LEAVE and from_match and not to_match ): description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[ATTR_FRIENDLY_NAME]}" hass.async_run_hass_job( diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index e6cfc6c8b29..639d2fbcd7a 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -458,7 +458,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): if warm_white and cool_white: self._supports_color_temp = True # only one white channel (warm white or cool white) = rgbw support - elif red and green and blue and warm_white or cool_white: + elif (red and green and blue and warm_white) or cool_white: self._supports_rgbw = True @callback diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 5952e28a1eb..695af80bc1c 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -884,10 +884,8 @@ def time( condition_trace_update_result(weekday=weekday, now_weekday=now_weekday) if ( - isinstance(weekday, str) - and weekday != now_weekday - or now_weekday not in weekday - ): + isinstance(weekday, str) and weekday != now_weekday + ) or now_weekday not in weekday: return False return True diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 72a4ef3c050..b363bc21e86 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -951,8 +951,7 @@ def async_track_template( if ( not isinstance(last_result, TemplateError) and result_as_boolean(last_result) - or not result_as_boolean(result) - ): + ) or not result_as_boolean(result): return hass.async_run_hass_job( diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index f3f798e1d6b..1fd0e08988c 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -756,10 +756,8 @@ class _ScriptRun: ) running_script = ( - params[CONF_DOMAIN] == "automation" - and params[CONF_SERVICE] == "trigger" - or params[CONF_DOMAIN] in ("python_script", "script") - ) + params[CONF_DOMAIN] == "automation" and params[CONF_SERVICE] == "trigger" + ) or params[CONF_DOMAIN] in ("python_script", "script") trace_set_result(params=params, running_script=running_script) response_data = await self._async_run_long_action( self._hass.async_create_task_internal( diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index e8c169e92d8..4625c3000ba 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -601,7 +601,7 @@ class Template: or filter depending on hass or the state machine. """ if self.is_static: - if not parse_result or self.hass and self.hass.config.legacy_templates: + if not parse_result or (self.hass and self.hass.config.legacy_templates): return self.template return self._parse_result(self.template) assert self.hass is not None, "hass variable not set on template" @@ -630,7 +630,7 @@ class Template: self._renders += 1 if self.is_static: - if not parse_result or self.hass and self.hass.config.legacy_templates: + if not parse_result or (self.hass and self.hass.config.legacy_templates): return self.template return self._parse_result(self.template) @@ -651,7 +651,7 @@ class Template: render_result = render_result.strip() - if not parse_result or self.hass and self.hass.config.legacy_templates: + if not parse_result or (self.hass and self.hass.config.legacy_templates): return render_result return self._parse_result(render_result) @@ -826,7 +826,7 @@ class Template: ) return value if error_value is _SENTINEL else error_value - if not parse_result or self.hass and self.hass.config.legacy_templates: + if not parse_result or (self.hass and self.hass.config.legacy_templates): return render_result return self._parse_result(render_result) @@ -1873,7 +1873,8 @@ def is_state(hass: HomeAssistant, entity_id: str, state: str | list[str]) -> boo """Test if a state is a specific value.""" state_obj = _get_state(hass, entity_id) return state_obj is not None and ( - state_obj.state == state or isinstance(state, list) and state_obj.state in state + state_obj.state == state + or (isinstance(state, list) and state_obj.state in state) ) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 8acd43970f9..62dcb2622e7 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -359,7 +359,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self._async_unsub_refresh() self._debounced_refresh.async_cancel() - if self._shutdown_requested or scheduled and self.hass.is_stopping: + if self._shutdown_requested or (scheduled and self.hass.is_stopping): return if log_timing := self.logger.isEnabledFor(logging.DEBUG): diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index d66845583d1..d06d078ae8b 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -55,10 +55,14 @@ class TypeHintMatch: """Confirm if function should be checked.""" return ( self.function_name == node.name - or self.has_async_counterpart - and node.name == f"async_{self.function_name}" - or self.function_name.endswith("*") - and node.name.startswith(self.function_name[:-1]) + or ( + self.has_async_counterpart + and node.name == f"async_{self.function_name}" + ) + or ( + self.function_name.endswith("*") + and node.name.startswith(self.function_name[:-1]) + ) ) @@ -2998,8 +3002,8 @@ def _is_valid_type( isinstance(node, nodes.Subscript) and isinstance(node.value, nodes.Name) and node.value.name in _KNOWN_GENERIC_TYPES - or isinstance(node, nodes.Name) - and node.name.endswith(_KNOWN_GENERIC_TYPES_TUPLE) + ) or ( + isinstance(node, nodes.Name) and node.name.endswith(_KNOWN_GENERIC_TYPES_TUPLE) ): return True diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index 194f99ae700..2fe70fad10d 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -268,9 +268,8 @@ class HassImportsFormatChecker(BaseChecker): self, current_package: str, node: nodes.ImportFrom ) -> None: """Check for improper 'from ._ import _' invocations.""" - if ( - node.level <= 1 - or not current_package.startswith("homeassistant.components.") + if node.level <= 1 or ( + not current_package.startswith("homeassistant.components.") and not current_package.startswith("tests.components.") ): return diff --git a/pyproject.toml b/pyproject.toml index 8cd777c3c67..0623d681df7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -763,6 +763,7 @@ select = [ "RUF018", # Avoid assignment expressions in assert statements "RUF019", # Unnecessary key check before dictionary access "RUF020", # {never_like} | T is equivalent to T + "RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear "RUF022", # Sort __all__ "RUF024", # Do not pass mutable objects as values to dict.fromkeys "RUF026", # default_factory is a positional-only argument to defaultdict diff --git a/script/translations/deduplicate.py b/script/translations/deduplicate.py index f92f90115ce..ac608a1aa0e 100644 --- a/script/translations/deduplicate.py +++ b/script/translations/deduplicate.py @@ -70,8 +70,10 @@ def run(): # If we want to only add references to own integrations # but not include entity integrations if ( - args.limit_reference - and (key_integration != key_to_reference_integration and not is_common) + ( + args.limit_reference + and (key_integration != key_to_reference_integration and not is_common) + ) # Do not create self-references in entity integrations or key_integration in Platform.__members__.values() ): diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 0e6876cc901..a5451e5332d 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -316,7 +316,7 @@ async def test_sync_notifications(agents) -> None: config, "async_sync_notification", return_value=HTTPStatus.NO_CONTENT ) as mock: await config.async_sync_notification_all("1234", {}) - assert not agents or bool(mock.mock_calls) and agents + assert not agents or (bool(mock.mock_calls) and agents) @pytest.mark.parametrize( diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index d4281b9e513..b86855bd78f 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -171,11 +171,8 @@ async def test_receiving_message_successfully( assert data["subject"] == "Test subject" assert data["uid"] == "1" assert "Test body" in data["text"] - assert ( - valid_date - and isinstance(data["date"], datetime) - or not valid_date - and data["date"] is None + assert (valid_date and isinstance(data["date"], datetime)) or ( + not valid_date and data["date"] is None ) @@ -581,11 +578,8 @@ async def test_reset_last_message( assert data["subject"] == "Test subject" assert data["text"] assert data["initial"] - assert ( - valid_date - and isinstance(data["date"], datetime) - or not valid_date - and data["date"] is None + assert (valid_date and isinstance(data["date"], datetime)) or ( + not valid_date and data["date"] is None ) # Simulate an update where no messages are found (needed for pushed coordinator) diff --git a/tests/components/matrix/test_commands.py b/tests/components/matrix/test_commands.py index dabee74fdc3..ea0805b920a 100644 --- a/tests/components/matrix/test_commands.py +++ b/tests/components/matrix/test_commands.py @@ -42,9 +42,8 @@ class CommandTestParameters: Commands that are named with 'Subset' are expected not to be read from Room A. """ - if ( - self.expected_event_data_extra is None - or "Subset" in self.expected_event_data_extra["command"] + if self.expected_event_data_extra is None or ( + "Subset" in self.expected_event_data_extra["command"] and self.room_id not in SUBSET_ROOMS ): return None diff --git a/tests/components/onewire/__init__.py b/tests/components/onewire/__init__.py index ac7e917d10a..9c025fe33af 100644 --- a/tests/components/onewire/__init__.py +++ b/tests/components/onewire/__init__.py @@ -25,10 +25,8 @@ def setup_owproxy_mock_devices(owproxy: MagicMock, device_ids: list[str]) -> Non if (side_effect := dir_side_effect.get(path)) is None: raise NotImplementedError(f"Unexpected _dir call: {path}") result = side_effect.pop(0) - if ( - isinstance(result, Exception) - or isinstance(result, type) - and issubclass(result, Exception) + if isinstance(result, Exception) or ( + isinstance(result, type) and issubclass(result, Exception) ): raise result return result @@ -39,10 +37,8 @@ def setup_owproxy_mock_devices(owproxy: MagicMock, device_ids: list[str]) -> Non if len(side_effect) == 0: raise ProtocolError(f"Missing injected value for: {path}") result = side_effect.pop(0) - if ( - isinstance(result, Exception) - or isinstance(result, type) - and issubclass(result, Exception) + if isinstance(result, Exception) or ( + isinstance(result, type) and issubclass(result, Exception) ): raise result return result From 689d7d3cd9586667bad10e18c8f66c09e53777e7 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 17 Jan 2025 19:34:35 +1000 Subject: [PATCH 0552/2987] Add Energy History to Tesla Fleet (#126878) Co-authored-by: Brett Adams Co-authored-by: Joost Lekkerkerker Co-authored-by: JEMcats Co-authored-by: JEMcats --- .../components/tesla_fleet/__init__.py | 4 + homeassistant/components/tesla_fleet/const.py | 24 + .../components/tesla_fleet/coordinator.py | 62 +- .../components/tesla_fleet/entity.py | 18 + .../components/tesla_fleet/icons.json | 63 + .../components/tesla_fleet/models.py | 2 + .../components/tesla_fleet/sensor.py | 45 +- .../components/tesla_fleet/strings.json | 66 + tests/components/tesla_fleet/conftest.py | 11 + tests/components/tesla_fleet/const.py | 1 + .../tesla_fleet/fixtures/energy_history.json | 45 + .../tesla_fleet/snapshots/test_sensor.ambr | 1533 +++++++++++++++++ tests/components/tesla_fleet/test_init.py | 49 + 13 files changed, 1919 insertions(+), 4 deletions(-) create mode 100644 tests/components/tesla_fleet/fixtures/energy_history.json diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index ff50a99748e..945c6351cfc 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -36,6 +36,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from .const import DOMAIN, LOGGER, MODELS from .coordinator import ( + TeslaFleetEnergySiteHistoryCoordinator, TeslaFleetEnergySiteInfoCoordinator, TeslaFleetEnergySiteLiveCoordinator, TeslaFleetVehicleDataCoordinator, @@ -176,9 +177,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - api = EnergySpecific(tesla.energy, site_id) live_coordinator = TeslaFleetEnergySiteLiveCoordinator(hass, api) + history_coordinator = TeslaFleetEnergySiteHistoryCoordinator(hass, api) info_coordinator = TeslaFleetEnergySiteInfoCoordinator(hass, api, product) await live_coordinator.async_config_entry_first_refresh() + await history_coordinator.async_config_entry_first_refresh() await info_coordinator.async_config_entry_first_refresh() # Create energy site model @@ -211,6 +214,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - TeslaFleetEnergyData( api=api, live_coordinator=live_coordinator, + history_coordinator=history_coordinator, info_coordinator=info_coordinator, id=site_id, device=device, diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py index 9b3baf49bfb..5d2dc84c49e 100644 --- a/homeassistant/components/tesla_fleet/const.py +++ b/homeassistant/components/tesla_fleet/const.py @@ -37,6 +37,30 @@ MODELS = { "T": "Tesla Semi", } +ENERGY_HISTORY_FIELDS = [ + "solar_energy_exported", + "generator_energy_exported", + "grid_energy_imported", + "grid_services_energy_imported", + "grid_services_energy_exported", + "grid_energy_exported_from_solar", + "grid_energy_exported_from_generator", + "grid_energy_exported_from_battery", + "battery_energy_exported", + "battery_energy_imported_from_grid", + "battery_energy_imported_from_solar", + "battery_energy_imported_from_generator", + "consumer_energy_imported_from_grid", + "consumer_energy_imported_from_solar", + "consumer_energy_imported_from_battery", + "consumer_energy_imported_from_generator", + "total_home_usage", + "total_battery_charge", + "total_battery_discharge", + "total_solar_generation", + "total_grid_energy_exported", +] + class TeslaFleetState(StrEnum): """Teslemetry Vehicle States.""" diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index c122f876d15..4d99319d49f 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -1,10 +1,12 @@ """Tesla Fleet Data Coordinator.""" from datetime import datetime, timedelta +from random import randint +from time import time from typing import Any from tesla_fleet_api import EnergySpecific, VehicleSpecific -from tesla_fleet_api.const import VehicleDataEndpoint +from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint from tesla_fleet_api.exceptions import ( InvalidToken, LoginRequired, @@ -19,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import LOGGER, TeslaFleetState +from .const import ENERGY_HISTORY_FIELDS, LOGGER, TeslaFleetState VEHICLE_INTERVAL_SECONDS = 300 VEHICLE_INTERVAL = timedelta(seconds=VEHICLE_INTERVAL_SECONDS) @@ -27,6 +29,7 @@ VEHICLE_WAIT = timedelta(minutes=15) ENERGY_INTERVAL_SECONDS = 60 ENERGY_INTERVAL = timedelta(seconds=ENERGY_INTERVAL_SECONDS) +ENERGY_HISTORY_INTERVAL = timedelta(minutes=5) ENDPOINTS = [ VehicleDataEndpoint.CHARGE_STATE, @@ -182,6 +185,61 @@ class TeslaFleetEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) return data +class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching energy site history import and export from the Tesla Fleet API.""" + + def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + """Initialize Tesla Fleet Energy Site History coordinator.""" + super().__init__( + hass, + LOGGER, + name=f"Tesla Fleet Energy History {api.energy_site_id}", + update_interval=timedelta(seconds=300), + ) + self.api = api + self.data = {} + self.updated_once = False + + async def async_config_entry_first_refresh(self) -> None: + """Set up the data coordinator.""" + await super().async_config_entry_first_refresh() + + # Calculate seconds until next 5 minute period plus a random delay + delta = randint(310, 330) - (int(time()) % 300) + self.logger.debug("Scheduling next %s refresh in %s seconds", self.name, delta) + self.update_interval = timedelta(seconds=delta) + self._schedule_refresh() + self.update_interval = ENERGY_HISTORY_INTERVAL + + async def _async_update_data(self) -> dict[str, Any]: + """Update energy site history data using Tesla Fleet API.""" + + try: + data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"] + except RateLimited as e: + LOGGER.warning( + "%s rate limited, will retry in %s seconds", + 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 + self.updated_once = True + + # Add all time periods together + output = {key: 0 for key in ENERGY_HISTORY_FIELDS} + for period in data.get("time_series", []): + for key in ENERGY_HISTORY_FIELDS: + output[key] += period.get(key, 0) + + return output + + class TeslaFleetEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site info from the TeslaFleet API.""" diff --git a/homeassistant/components/tesla_fleet/entity.py b/homeassistant/components/tesla_fleet/entity.py index 0ee41b5e322..0260acf368e 100644 --- a/homeassistant/components/tesla_fleet/entity.py +++ b/homeassistant/components/tesla_fleet/entity.py @@ -12,6 +12,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import ( + TeslaFleetEnergySiteHistoryCoordinator, TeslaFleetEnergySiteInfoCoordinator, TeslaFleetEnergySiteLiveCoordinator, TeslaFleetVehicleDataCoordinator, @@ -24,6 +25,7 @@ class TeslaFleetEntity( CoordinatorEntity[ TeslaFleetVehicleDataCoordinator | TeslaFleetEnergySiteLiveCoordinator + | TeslaFleetEnergySiteHistoryCoordinator | TeslaFleetEnergySiteInfoCoordinator ] ): @@ -37,6 +39,7 @@ class TeslaFleetEntity( self, coordinator: TeslaFleetVehicleDataCoordinator | TeslaFleetEnergySiteLiveCoordinator + | TeslaFleetEnergySiteHistoryCoordinator | TeslaFleetEnergySiteInfoCoordinator, api: VehicleSpecific | EnergySpecific, key: str, @@ -139,6 +142,21 @@ class TeslaFleetEnergyLiveEntity(TeslaFleetEntity): super().__init__(data.live_coordinator, data.api, key) +class TeslaFleetEnergyHistoryEntity(TeslaFleetEntity): + """Parent class for TeslaFleet Energy Site History entities.""" + + def __init__( + self, + data: TeslaFleetEnergyData, + key: str, + ) -> None: + """Initialize common aspects of a Tesla Fleet Energy Site History entity.""" + self._attr_unique_id = f"{data.id}-{key}" + self._attr_device_info = data.device + + super().__init__(data.history_coordinator, data.api, key) + + class TeslaFleetEnergyInfoEntity(TeslaFleetEntity): """Parent class for TeslaFleet Energy Site Info entities.""" diff --git a/homeassistant/components/tesla_fleet/icons.json b/homeassistant/components/tesla_fleet/icons.json index 449dda93c62..c806138c219 100644 --- a/homeassistant/components/tesla_fleet/icons.json +++ b/homeassistant/components/tesla_fleet/icons.json @@ -232,6 +232,69 @@ "island_status_unknown": "mdi:help-circle", "off_grid_intentional": "mdi:account-cancel" } + }, + "total_home_usage": { + "default": "mdi:home-lightning-bolt" + }, + "total_battery_charge": { + "default": "mdi:battery-arrow-up" + }, + "total_battery_discharge": { + "default": "mdi:battery-arrow-down" + }, + "total_solar_production": { + "default": "mdi:solar-power-variant" + }, + "grid_energy_imported": { + "default": "mdi:transmission-tower-import" + }, + "total_grid_energy_exported": { + "default": "mdi:transmission-tower-export" + }, + "solar_energy_exported": { + "default": "mdi:solar-power-variant" + }, + "generator_energy_exported": { + "default": "mdi:generator-stationary" + }, + "grid_services_energy_imported": { + "default": "mdi:transmission-tower-import" + }, + "grid_services_energy_exported": { + "default": "mdi:transmission-tower-export" + }, + "grid_energy_exported_from_solar": { + "default": "mdi:solar-power" + }, + "grid_energy_exported_from_generator": { + "default": "mdi:generator-stationary" + }, + "grid_energy_exported_from_battery": { + "default": "mdi:battery-arrow-down" + }, + "battery_energy_exported": { + "default": "mdi:battery-arrow-down" + }, + "battery_energy_imported_from_grid": { + "default": "mdi:transmission-tower-import" + }, + "battery_energy_imported_from_solar": { + "default": "mdi:solar-power" + }, + "battery_energy_imported_from_generator": { + "default": "mdi:generator-stationary" + }, + "consumer_energy_imported_from_grid": { + "default": "mdi:transmission-tower-import" + }, + "consumer_energy_imported_from_solar": { + "default": "mdi:solar-power" + }, + "consumer_energy_imported_from_battery": { + "default": "mdi:home-battery" + }, + "consumer_energy_imported_from_generator": { + "default": "mdi:generator-stationary" } }, "switch": { diff --git a/homeassistant/components/tesla_fleet/models.py b/homeassistant/components/tesla_fleet/models.py index ae945dd96bf..469ebdca914 100644 --- a/homeassistant/components/tesla_fleet/models.py +++ b/homeassistant/components/tesla_fleet/models.py @@ -11,6 +11,7 @@ from tesla_fleet_api.const import Scope from homeassistant.helpers.device_registry import DeviceInfo from .coordinator import ( + TeslaFleetEnergySiteHistoryCoordinator, TeslaFleetEnergySiteInfoCoordinator, TeslaFleetEnergySiteLiveCoordinator, TeslaFleetVehicleDataCoordinator, @@ -44,6 +45,7 @@ class TeslaFleetEnergyData: api: EnergySpecific live_coordinator: TeslaFleetEnergySiteLiveCoordinator + history_coordinator: TeslaFleetEnergySiteHistoryCoordinator info_coordinator: TeslaFleetEnergySiteInfoCoordinator id: int device: DeviceInfo diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py index b4e7b51faba..3e05e7e723b 100644 --- a/homeassistant/components/tesla_fleet/sensor.py +++ b/homeassistant/components/tesla_fleet/sensor.py @@ -35,8 +35,9 @@ from homeassistant.util import dt as dt_util from homeassistant.util.variance import ignore_variance from . import TeslaFleetConfigEntry -from .const import TeslaFleetState +from .const import ENERGY_HISTORY_FIELDS, TeslaFleetState from .entity import ( + TeslaFleetEnergyHistoryEntity, TeslaFleetEnergyInfoEntity, TeslaFleetEnergyLiveEntity, TeslaFleetVehicleEntity, @@ -415,6 +416,21 @@ WALL_CONNECTOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( ), ) +ENERGY_HISTORY_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = tuple( + SensorEntityDescription( + key=key, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=( + key.startswith("total") or key == "grid_energy_imported" + ), + ) + for key in ENERGY_HISTORY_FIELDS +) + ENERGY_INFO_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="vpp_backup_reserve_percent", @@ -450,6 +466,13 @@ async def async_setup_entry( for description in ENERGY_LIVE_DESCRIPTIONS if description.key in energysite.live_coordinator.data ), + ( # Add energy site history + TeslaFleetEnergyHistorySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_HISTORY_DESCRIPTIONS + if energysite.info_coordinator.data.get("components_battery") + or energysite.info_coordinator.data.get("components_solar") + ), ( # Add wall connectors TeslaFleetWallConnectorSensorEntity(energysite, wc["din"], description) for energysite in entry.runtime_data.energysites @@ -540,7 +563,25 @@ class TeslaFleetEnergyLiveSensorEntity(TeslaFleetEnergyLiveEntity, SensorEntity) def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_available = not self.is_none + self._attr_native_value = self._value + + +class TeslaFleetEnergyHistorySensorEntity(TeslaFleetEnergyHistoryEntity, SensorEntity): + """Base class for Tesla Fleet energy site metric sensors.""" + + entity_description: SensorEntityDescription + + def __init__( + self, + data: TeslaFleetEnergyData, + description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the sensor.""" self._attr_native_value = self._value diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index fe5cd06c1ef..c438bfff50f 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -424,6 +424,9 @@ "off_grid_intentional": "Disconnected intentionally" } }, + "storm_mode_active": { + "name": "Storm Watch active" + }, "vehicle_state_tpms_pressure_fl": { "name": "Tire pressure front left" }, @@ -453,6 +456,69 @@ }, "wall_connector_state": { "name": "State code" + }, + "solar_energy_exported": { + "name": "Solar exported" + }, + "generator_energy_exported": { + "name": "Generator exported" + }, + "grid_energy_imported": { + "name": "Grid imported" + }, + "grid_services_energy_imported": { + "name": "Grid services imported" + }, + "grid_services_energy_exported": { + "name": "Grid services exported" + }, + "grid_energy_exported_from_solar": { + "name": "Grid exported from solar" + }, + "grid_energy_exported_from_generator": { + "name": "Grid exported from generator" + }, + "grid_energy_exported_from_battery": { + "name": "Grid exported from battery" + }, + "battery_energy_exported": { + "name": "Battery exported" + }, + "battery_energy_imported_from_grid": { + "name": "Battery imported from grid" + }, + "battery_energy_imported_from_solar": { + "name": "Battery imported from solar" + }, + "battery_energy_imported_from_generator": { + "name": "Battery imported from generator" + }, + "consumer_energy_imported_from_grid": { + "name": "Consumer imported from grid" + }, + "consumer_energy_imported_from_solar": { + "name": "Consumer imported from solar" + }, + "consumer_energy_imported_from_battery": { + "name": "Consumer imported from battery" + }, + "consumer_energy_imported_from_generator": { + "name": "Consumer imported from generator" + }, + "total_home_usage": { + "name": "Home usage" + }, + "total_battery_charge": { + "name": "Battery charged" + }, + "total_battery_discharge": { + "name": "Battery discharged" + }, + "total_solar_generation": { + "name": "Solar generated" + }, + "total_grid_energy_exported": { + "name": "Grid exported" } }, "switch": { diff --git a/tests/components/tesla_fleet/conftest.py b/tests/components/tesla_fleet/conftest.py index 2396e2a88f3..06d2b54c936 100644 --- a/tests/components/tesla_fleet/conftest.py +++ b/tests/components/tesla_fleet/conftest.py @@ -15,6 +15,7 @@ from homeassistant.components.tesla_fleet.const import DOMAIN, SCOPES from .const import ( COMMAND_OK, + ENERGY_HISTORY, LIVE_STATUS, PRODUCTS, SITE_INFO, @@ -177,6 +178,16 @@ def mock_request(): yield mock_request +@pytest.fixture(autouse=True) +def mock_energy_history(): + """Mock Teslemetry Energy Specific site_info method.""" + with patch( + "homeassistant.components.teslemetry.EnergySpecific.energy_history", + return_value=ENERGY_HISTORY, + ) as mock_live_status: + yield mock_live_status + + @pytest.fixture(autouse=True) def mock_signed_command() -> Generator[AsyncMock]: """Mock Tesla Fleet Api signed_command method.""" diff --git a/tests/components/tesla_fleet/const.py b/tests/components/tesla_fleet/const.py index 76b4ae20092..d584e7b93d5 100644 --- a/tests/components/tesla_fleet/const.py +++ b/tests/components/tesla_fleet/const.py @@ -11,6 +11,7 @@ PRODUCTS = load_json_object_fixture("products.json", DOMAIN) VEHICLE_DATA = load_json_object_fixture("vehicle_data.json", DOMAIN) VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) +ENERGY_HISTORY = load_json_object_fixture("energy_history.json", DOMAIN) SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) COMMAND_OK = {"response": {"result": True, "reason": ""}} diff --git a/tests/components/tesla_fleet/fixtures/energy_history.json b/tests/components/tesla_fleet/fixtures/energy_history.json new file mode 100644 index 00000000000..befe12cc903 --- /dev/null +++ b/tests/components/tesla_fleet/fixtures/energy_history.json @@ -0,0 +1,45 @@ +{ + "response": { + "period": "day", + "time_series": [ + { + "timestamp": "2023-06-01T01:00:00-07:00", + "solar_energy_exported": 70940, + "generator_energy_exported": 0, + "grid_energy_imported": 521, + "grid_services_energy_imported": 17.53125, + "grid_services_energy_exported": 3.80859375, + "grid_energy_exported_from_solar": 43660, + "grid_energy_exported_from_generator": 0, + "grid_energy_exported_from_battery": 19, + "battery_energy_exported": 10030, + "battery_energy_imported_from_grid": 80, + "battery_energy_imported_from_solar": 16800, + "battery_energy_imported_from_generator": 0, + "consumer_energy_imported_from_grid": 441, + "consumer_energy_imported_from_solar": 10480, + "consumer_energy_imported_from_battery": 10011, + "consumer_energy_imported_from_generator": 0 + }, + { + "timestamp": "2023-06-01T01:05:00-07:00", + "solar_energy_exported": 140940, + "generator_energy_exported": 1, + "grid_energy_imported": 1021, + "grid_services_energy_imported": 27.53125, + "grid_services_energy_exported": 6.80859375, + "grid_energy_exported_from_solar": 83660, + "grid_energy_exported_from_generator": 0, + "grid_energy_exported_from_battery": 29, + "battery_energy_exported": 20030, + "battery_energy_imported_from_grid": 0, + "battery_energy_imported_from_solar": 26800, + "battery_energy_imported_from_generator": 0, + "consumer_energy_imported_from_grid": 841, + "consumer_energy_imported_from_solar": 20480, + "consumer_energy_imported_from_battery": 20011, + "consumer_energy_imported_from_generator": 0 + } + ] + } +} diff --git a/tests/components/tesla_fleet/snapshots/test_sensor.ambr b/tests/components/tesla_fleet/snapshots/test_sensor.ambr index 2c3780749ca..d6b646d7794 100644 --- a/tests/components/tesla_fleet/snapshots/test_sensor.ambr +++ b/tests/components/tesla_fleet/snapshots/test_sensor.ambr @@ -1,4 +1,442 @@ # serializer version: 1 +# name: test_sensors[sensor.energy_site_battery_charged-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_charged', + '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': 'Battery charged', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_battery_charge', + 'unique_id': '123456-total_battery_charge', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_charged-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery charged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_charged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_charged-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery charged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_charged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_discharged-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_discharged', + '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': 'Battery discharged', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_battery_discharge', + 'unique_id': '123456-total_battery_discharge', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_discharged-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery discharged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_discharged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_discharged-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery discharged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_discharged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_exported', + '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': 'Battery exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_exported', + 'unique_id': '123456-battery_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.06', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.06', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_generator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_imported_from_generator', + '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': 'Battery imported from generator', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_imported_from_generator', + 'unique_id': '123456-battery_energy_imported_from_generator', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_generator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_generator-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_imported_from_grid', + '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': 'Battery imported from grid', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_imported_from_grid', + 'unique_id': '123456-battery_energy_imported_from_grid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.08', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_grid-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.08', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_solar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_imported_from_solar', + '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': 'Battery imported from solar', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_imported_from_solar', + 'unique_id': '123456-battery_energy_imported_from_solar', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_solar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.6', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_solar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.6', + }) +# --- # name: test_sensors[sensor.energy_site_battery_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -72,6 +510,298 @@ 'state': '5.06', }) # --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_battery', + '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': 'Consumer imported from battery', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_battery', + 'unique_id': '123456-consumer_energy_imported_from_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.022', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_battery-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.022', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_generator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_generator', + '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': 'Consumer imported from generator', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_generator', + 'unique_id': '123456-consumer_energy_imported_from_generator', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_generator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_generator-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_grid', + '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': 'Consumer imported from grid', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_grid', + 'unique_id': '123456-consumer_energy_imported_from_grid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.282', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_grid-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.282', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_solar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_solar', + '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': 'Consumer imported from solar', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_solar', + 'unique_id': '123456-consumer_energy_imported_from_solar', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_solar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.96', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_solar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.96', + }) +# --- # name: test_sensors[sensor.energy_site_energy_left-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -145,6 +875,79 @@ 'state': '38.8964736842105', }) # --- +# name: test_sensors[sensor.energy_site_generator_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_generator_exported', + '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': 'Generator exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_energy_exported', + 'unique_id': '123456-generator_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_generator_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Generator exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_generator_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001', + }) +# --- +# name: test_sensors[sensor.energy_site_generator_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Generator exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_generator_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001', + }) +# --- # name: test_sensors[sensor.energy_site_generator_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -218,6 +1021,371 @@ 'state': '0.0', }) # --- +# name: test_sensors[sensor.energy_site_grid_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported', + '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': 'Grid exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_grid_energy_exported', + 'unique_id': '123456-total_grid_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported_from_battery', + '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': 'Grid exported from battery', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_energy_exported_from_battery', + 'unique_id': '123456-grid_energy_exported_from_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.048', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_battery-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.048', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_generator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported_from_generator', + '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': 'Grid exported from generator', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_energy_exported_from_generator', + 'unique_id': '123456-grid_energy_exported_from_generator', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_generator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_generator-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_solar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported_from_solar', + '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': 'Grid exported from solar', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_energy_exported_from_solar', + 'unique_id': '123456-grid_energy_exported_from_solar', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_solar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '127.32', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_solar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '127.32', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_imported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_imported', + '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': 'Grid imported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_energy_imported', + 'unique_id': '123456-grid_energy_imported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_imported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid imported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.542', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_imported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid imported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.542', + }) +# --- # name: test_sensors[sensor.energy_site_grid_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -291,6 +1459,152 @@ 'state': '0.0', }) # --- +# name: test_sensors[sensor.energy_site_grid_services_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_services_exported', + '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': 'Grid services exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_services_energy_exported', + 'unique_id': '123456-grid_services_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_services_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0106171875', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_services_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0106171875', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_imported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_services_imported', + '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': 'Grid services imported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_services_energy_imported', + 'unique_id': '123456-grid_services_energy_imported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_imported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services imported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_services_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0450625', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_imported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services imported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_services_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0450625', + }) +# --- # name: test_sensors[sensor.energy_site_grid_services_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -447,6 +1761,79 @@ 'state': 'on_grid', }) # --- +# name: test_sensors[sensor.energy_site_home_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_home_usage', + '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': 'Home usage', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_home_usage', + 'unique_id': '123456-total_home_usage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_home_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Home usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_home_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_home_usage-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Home usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_home_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[sensor.energy_site_load_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -590,6 +1977,152 @@ 'state': '95.5053740373966', }) # --- +# name: test_sensors[sensor.energy_site_solar_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_solar_exported', + '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': 'Solar exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_energy_exported', + 'unique_id': '123456-solar_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_solar_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '211.88', + }) +# --- +# name: test_sensors[sensor.energy_site_solar_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '211.88', + }) +# --- +# name: test_sensors[sensor.energy_site_solar_generated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_solar_generated', + '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': 'Solar generated', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_solar_generation', + 'unique_id': '123456-total_solar_generation', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_solar_generated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar generated', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_generated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_solar_generated-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar generated', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_generated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[sensor.energy_site_solar_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index 7e97096e4e8..2162226efb0 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -21,6 +21,7 @@ from tesla_fleet_api.exceptions import ( from homeassistant.components.tesla_fleet.const import AUTHORIZE_URL from homeassistant.components.tesla_fleet.coordinator import ( + ENERGY_HISTORY_INTERVAL, ENERGY_INTERVAL, ENERGY_INTERVAL_SECONDS, VEHICLE_INTERVAL, @@ -317,6 +318,21 @@ async def test_energy_site_refresh_error( assert normal_config_entry.state is state +# Test Energy History Coordinator +@pytest.mark.parametrize(("side_effect", "state"), ERRORS) +async def test_energy_history_refresh_error( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_energy_history: AsyncMock, + side_effect: TeslaFleetError, + state: ConfigEntryState, +) -> None: + """Test coordinator refresh with an error.""" + mock_energy_history.side_effect = side_effect + await setup_platform(hass, normal_config_entry) + assert normal_config_entry.state is state + + async def test_energy_live_refresh_ratelimited( hass: HomeAssistant, normal_config_entry: MockConfigEntry, @@ -379,6 +395,39 @@ async def test_energy_info_refresh_ratelimited( assert mock_site_info.call_count == 3 +async def test_energy_history_refresh_ratelimited( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_energy_history: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator refresh handles 429.""" + + await setup_platform(hass, normal_config_entry) + + mock_energy_history.side_effect = RateLimited( + {"after": int(ENERGY_HISTORY_INTERVAL.total_seconds() + 10)} + ) + freezer.tick(ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_energy_history.call_count == 2 + + freezer.tick(ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Should not call for another 10 seconds + assert mock_energy_history.call_count == 2 + + freezer.tick(ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_energy_history.call_count == 3 + + async def test_init_region_issue( hass: HomeAssistant, normal_config_entry: MockConfigEntry, From 99d250f22271c1d99a9c0e7a7463b8101ec1029f Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Fri, 17 Jan 2025 11:15:42 +0100 Subject: [PATCH 0553/2987] Set target value on LCN regulator lock (#133870) --- homeassistant/components/lcn/climate.py | 8 +++++++- homeassistant/components/lcn/const.py | 1 + homeassistant/components/lcn/manifest.json | 2 +- homeassistant/components/lcn/schemas.py | 2 ++ requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- tests/components/lcn/test_climate.py | 4 ++-- 7 files changed, 17 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index 360b732c02e..1dff15c4f22 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -32,6 +32,7 @@ from .const import ( CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_SETPOINT, + CONF_TARGET_VALUE_LOCKED, DOMAIN, ) from .entity import LcnEntity @@ -93,6 +94,9 @@ class LcnClimate(LcnEntity, ClimateEntity): self.regulator_id = pypck.lcn_defs.Var.to_set_point_id(self.setpoint) self.is_lockable = config[CONF_DOMAIN_DATA][CONF_LOCKABLE] + self.target_value_locked = config[CONF_DOMAIN_DATA].get( + CONF_TARGET_VALUE_LOCKED, -1 + ) self._max_temp = config[CONF_DOMAIN_DATA][CONF_MAX_TEMP] self._min_temp = config[CONF_DOMAIN_DATA][CONF_MIN_TEMP] @@ -171,7 +175,9 @@ class LcnClimate(LcnEntity, ClimateEntity): self._is_on = True self.async_write_ha_state() elif hvac_mode == HVACMode.OFF: - if not await self.device_connection.lock_regulator(self.regulator_id, True): + if not await self.device_connection.lock_regulator( + self.regulator_id, True, self.target_value_locked + ): return self._is_on = False self._target_temperature = None diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index cee9da9be43..b443e05def7 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -35,6 +35,7 @@ CONF_DIMMABLE = "dimmable" CONF_TRANSITION = "transition" CONF_MOTOR = "motor" CONF_LOCKABLE = "lockable" +CONF_TARGET_VALUE_LOCKED = "target_value_locked" CONF_VARIABLE = "variable" CONF_VALUE = "value" CONF_RELVARREF = "value_reference" diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index f5eb1654588..2ac183dcc97 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.8.1", "lcn-frontend==0.2.2"] + "requirements": ["pypck==0.8.3", "lcn-frontend==0.2.3"] } diff --git a/homeassistant/components/lcn/schemas.py b/homeassistant/components/lcn/schemas.py index c9c91b9843d..809701c680a 100644 --- a/homeassistant/components/lcn/schemas.py +++ b/homeassistant/components/lcn/schemas.py @@ -24,6 +24,7 @@ from .const import ( CONF_REGISTER, CONF_REVERSE_TIME, CONF_SETPOINT, + CONF_TARGET_VALUE_LOCKED, CONF_TRANSITION, KEYS, LED_PORTS, @@ -58,6 +59,7 @@ DOMAIN_DATA_CLIMATE: VolDictType = { vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float), vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): vol.Coerce(float), vol.Optional(CONF_LOCKABLE, default=False): vol.Coerce(bool), + vol.Optional(CONF_TARGET_VALUE_LOCKED, default=-1): vol.Coerce(float), vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=UnitOfTemperature.CELSIUS): vol.In( UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT ), diff --git a/requirements_all.txt b/requirements_all.txt index bc97607ef93..56d6c64ca53 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1287,7 +1287,7 @@ lakeside==0.13 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.2 +lcn-frontend==0.2.3 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 @@ -2195,7 +2195,7 @@ pypalazzetti==0.1.19 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.8.1 +pypck==0.8.3 # homeassistant.components.pjlink pypjlink2==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 215452c79a9..436238fa296 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1086,7 +1086,7 @@ lacrosse-view==1.0.3 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.2 +lcn-frontend==0.2.3 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 @@ -1788,7 +1788,7 @@ pyownet==0.10.0.post1 pypalazzetti==0.1.19 # homeassistant.components.lcn -pypck==0.8.1 +pypck==0.8.3 # homeassistant.components.pjlink pypjlink2==1.2.1 diff --git a/tests/components/lcn/test_climate.py b/tests/components/lcn/test_climate.py index 7ba263bd597..7bac7cc9e81 100644 --- a/tests/components/lcn/test_climate.py +++ b/tests/components/lcn/test_climate.py @@ -107,7 +107,7 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> blocking=True, ) - lock_regulator.assert_awaited_with(0, True) + lock_regulator.assert_awaited_with(0, True, -1) state = hass.states.get("climate.climate1") assert state is not None @@ -124,7 +124,7 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> blocking=True, ) - lock_regulator.assert_awaited_with(0, True) + lock_regulator.assert_awaited_with(0, True, -1) state = hass.states.get("climate.climate1") assert state is not None From 13a7ad759cd51c0ca9059bee7490ed7d030ab651 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Fri, 17 Jan 2025 06:03:52 -0500 Subject: [PATCH 0554/2987] Add media position & seek to Russound RIO (#134372) --- .../components/russound_rio/media_player.py | 22 +++++++++++++++++++ tests/components/russound_rio/conftest.py | 1 + .../russound_rio/test_media_player.py | 21 ++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index 62981262e32..346f4903f6a 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -2,6 +2,7 @@ from __future__ import annotations +import datetime as dt import logging from typing import TYPE_CHECKING @@ -57,6 +58,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SEEK ) def __init__( @@ -138,6 +140,21 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): """Image url of current playing media.""" return self._source.cover_art_url + @property + def media_duration(self) -> int | None: + """Duration of the current media.""" + return self._source.track_time + + @property + def media_position(self) -> int | None: + """Position of the current media.""" + return self._source.play_time + + @property + def media_position_updated_at(self) -> dt.datetime: + """Last time the media position was updated.""" + return self._source.position_last_updated + @property def volume_level(self) -> float: """Volume level of the media player (0..1). @@ -199,3 +216,8 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): if mute != self.is_volume_muted: await self._zone.toggle_mute() + + @command + async def async_media_seek(self, position: float) -> None: + """Seek to a position in the current media.""" + await self._zone.set_seek_time(int(position)) diff --git a/tests/components/russound_rio/conftest.py b/tests/components/russound_rio/conftest.py index bf6884e09fb..2516bd81650 100644 --- a/tests/components/russound_rio/conftest.py +++ b/tests/components/russound_rio/conftest.py @@ -78,6 +78,7 @@ def mock_russound_client() -> Generator[AsyncMock]: zone.mute = AsyncMock() zone.unmute = AsyncMock() zone.toggle_mute = AsyncMock() + zone.set_seek_time = AsyncMock() client.controllers = { 1: Controller( diff --git a/tests/components/russound_rio/test_media_player.py b/tests/components/russound_rio/test_media_player.py index 5a6420da000..d0c18a9b1e7 100644 --- a/tests/components/russound_rio/test_media_player.py +++ b/tests/components/russound_rio/test_media_player.py @@ -9,6 +9,7 @@ import pytest from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, + ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MP_DOMAIN, @@ -16,6 +17,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import ( ATTR_ENTITY_ID, + SERVICE_MEDIA_SEEK, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, @@ -232,3 +234,22 @@ async def test_power_service( await hass.services.async_call(MP_DOMAIN, SERVICE_TURN_OFF, data, blocking=True) mock_russound_client.controllers[1].zones[1].zone_off.assert_called_once() + + +async def test_media_seek( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, +) -> None: + """Test media seek service.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_MEDIA_SEEK, + {ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, ATTR_MEDIA_SEEK_POSITION: 100}, + ) + + mock_russound_client.controllers[1].zones[1].set_seek_time.assert_called_once_with( + 100 + ) From 23e04ced9c01693cae14783551283f3d971330b9 Mon Sep 17 00:00:00 2001 From: Renier Moorcroft <66512715+RenierM26@users.noreply.github.com> Date: Fri, 17 Jan 2025 13:27:44 +0200 Subject: [PATCH 0555/2987] Image entity key error when camera is ignored in EZVIZ (#134343) --- homeassistant/components/ezviz/image.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index 0fbc5cc6a68..73c09244222 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -8,7 +8,7 @@ from pyezviz.exceptions import PyEzvizError from pyezviz.utils import decrypt_image from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -57,7 +57,9 @@ class EzvizLastMotion(EzvizEntity, ImageEntity): ) camera = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) self.alarm_image_password = ( - camera.data[CONF_PASSWORD] if camera is not None else None + camera.data[CONF_PASSWORD] + if camera and camera.source != SOURCE_IGNORE + else None ) async def _async_load_image_from_url(self, url: str) -> Image | None: From ef8b8fbbaaf3f5909a9de10e8d7a335e66a1ab94 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 17 Jan 2025 12:28:27 +0100 Subject: [PATCH 0556/2987] Enable RUF023 (#135830) --- homeassistant/components/bluetooth/manager.py | 6 ++-- homeassistant/components/bluetooth/match.py | 10 +++--- homeassistant/components/esphome/manager.py | 10 +++--- .../components/google_assistant/helpers.py | 2 +- homeassistant/components/http/web_runner.py | 2 +- .../components/recorder/models/legacy.py | 8 ++--- .../components/system_log/__init__.py | 10 +++--- .../components/websocket_api/connection.py | 20 +++++------ .../components/websocket_api/http.py | 18 +++++----- homeassistant/core.py | 32 ++++++++--------- homeassistant/helpers/icon.py | 2 +- homeassistant/helpers/intent.py | 14 ++++---- homeassistant/helpers/service.py | 2 +- homeassistant/helpers/template.py | 34 +++++++++---------- homeassistant/helpers/trace.py | 4 +-- homeassistant/helpers/translation.py | 2 +- pyproject.toml | 1 + tests/components/recorder/db_schema_16.py | 4 +-- tests/components/recorder/db_schema_18.py | 4 +-- tests/components/recorder/db_schema_22.py | 4 +-- tests/components/recorder/db_schema_23.py | 4 +-- .../db_schema_23_with_newer_columns.py | 4 +-- tests/components/recorder/db_schema_25.py | 6 ++-- tests/components/recorder/db_schema_28.py | 6 ++-- 24 files changed, 105 insertions(+), 104 deletions(-) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index d8b3eef7685..09be8f960e9 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -50,11 +50,11 @@ class HomeAssistantBluetoothManager(BluetoothManager): """Manage Bluetooth for Home Assistant.""" __slots__ = ( - "hass", - "storage", - "_integration_matcher", "_callback_index", "_cancel_logging_listener", + "_integration_matcher", + "hass", + "storage", ) def __init__( diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index ee62420b692..6307d3ca93b 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -92,7 +92,7 @@ def seen_all_fields( class IntegrationMatcher: """Integration matcher for the bluetooth integration.""" - __slots__ = ("_integration_matchers", "_matched", "_matched_connectable", "_index") + __slots__ = ("_index", "_integration_matchers", "_matched", "_matched_connectable") def __init__(self, integration_matchers: list[BluetoothMatcher]) -> None: """Initialize the matcher.""" @@ -164,12 +164,12 @@ class BluetoothMatcherIndexBase[ __slots__ = ( "local_name", - "service_uuid", - "service_data_uuid", "manufacturer_id", - "service_uuid_set", - "service_data_uuid_set", "manufacturer_id_set", + "service_data_uuid", + "service_data_uuid_set", + "service_uuid", + "service_uuid_set", ) def __init__(self) -> None: diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 7fcd859142a..b382622281e 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -134,16 +134,16 @@ class ESPHomeManager: """Class to manage an ESPHome connection.""" __slots__ = ( - "hass", - "host", - "password", - "entry", "cli", "device_id", "domain_data", + "entry", + "entry_data", + "hass", + "host", + "password", "reconnect_logic", "zeroconf_instance", - "entry_data", ) def __init__( diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 76869487ee3..4309a99c0ca 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -521,7 +521,7 @@ def supported_traits_for_state(state: State) -> list[type[trait._Trait]]: class GoogleEntity: """Adaptation of Entity expressed in Google's terms.""" - __slots__ = ("hass", "config", "state", "entity_id", "_traits") + __slots__ = ("_traits", "config", "entity_id", "hass", "state") def __init__( self, hass: HomeAssistant, config: AbstractConfig, state: State diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py index 4ca39eaab0c..f633433c9e4 100644 --- a/homeassistant/components/http/web_runner.py +++ b/homeassistant/components/http/web_runner.py @@ -22,7 +22,7 @@ class HomeAssistantTCPSite(web.BaseSite): is merged. """ - __slots__ = ("_host", "_port", "_reuse_address", "_reuse_port", "_hosturl") + __slots__ = ("_host", "_hosturl", "_port", "_reuse_address", "_reuse_port") def __init__( self, diff --git a/homeassistant/components/recorder/models/legacy.py b/homeassistant/components/recorder/models/legacy.py index a469aa49ab2..b5e67ff050b 100644 --- a/homeassistant/components/recorder/models/legacy.py +++ b/homeassistant/components/recorder/models/legacy.py @@ -24,12 +24,12 @@ class LegacyLazyState(State): """A lazy version of core State after schema 31.""" __slots__ = [ - "_row", "_attributes", - "_last_changed_ts", - "_last_updated_ts", - "_last_reported_ts", "_context", + "_last_changed_ts", + "_last_reported_ts", + "_last_updated_ts", + "_row", "attr_cache", ] diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 22950aa9f1e..191a2b5feb8 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -163,16 +163,16 @@ class LogEntry: """Store HA log entries.""" __slots__ = ( + "count", + "exception", "first_occurred", - "timestamp", - "name", + "key", "level", "message", - "exception", + "name", "root_cause", "source", - "count", - "key", + "timestamp", ) def __init__( diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 817444a970b..12473c86255 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -40,17 +40,17 @@ class ActiveConnection: """Handle an active websocket client connection.""" __slots__ = ( - "logger", - "hass", - "send_message", - "user", - "refresh_token_id", - "subscriptions", - "last_id", - "can_coalesce", - "supported_features", - "handlers", "binary_handlers", + "can_coalesce", + "handlers", + "hass", + "last_id", + "logger", + "refresh_token_id", + "send_message", + "subscriptions", + "supported_features", + "user", ) def __init__( diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index b718d8e28c8..8bfa9480ff4 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -70,20 +70,20 @@ class WebSocketHandler: """Handle an active websocket client connection.""" __slots__ = ( - "_hass", - "_loop", - "_request", - "_wsock", - "_handle_task", - "_writer_task", - "_closing", "_authenticated", - "_logger", - "_peak_checker_unsub", + "_closing", "_connection", + "_handle_task", + "_hass", + "_logger", + "_loop", "_message_queue", + "_peak_checker_unsub", "_ready_future", "_release_ready_queue_size", + "_request", + "_writer_task", + "_wsock", ) def __init__(self, hass: HomeAssistant, request: web.Request) -> None: diff --git a/homeassistant/core.py b/homeassistant/core.py index 58f96ed0ad2..74bcd844823 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -332,7 +332,7 @@ class HassJob[**_P, _R_co]: we run the job. """ - __slots__ = ("target", "name", "_cancel_on_shutdown", "_cache") + __slots__ = ("_cache", "_cancel_on_shutdown", "name", "target") def __init__( self, @@ -1246,7 +1246,7 @@ class HomeAssistant: class Context: """The context that triggered something.""" - __slots__ = ("id", "user_id", "parent_id", "origin_event", "_cache") + __slots__ = ("_cache", "id", "origin_event", "parent_id", "user_id") def __init__( self, @@ -1321,12 +1321,12 @@ class Event(Generic[_DataT]): """Representation of an event within the bus.""" __slots__ = ( - "event_type", + "_cache", + "context", "data", + "event_type", "origin", "time_fired_timestamp", - "context", - "_cache", ) def __init__( @@ -1767,18 +1767,18 @@ class State: """ __slots__ = ( - "entity_id", - "state", + "_cache", "attributes", + "context", + "domain", + "entity_id", "last_changed", "last_reported", "last_updated", - "context", - "state_info", - "domain", - "object_id", "last_updated_timestamp", - "_cache", + "object_id", + "state", + "state_info", ) def __init__( @@ -2066,7 +2066,7 @@ class States(UserDict[str, State]): class StateMachine: """Helper class that tracks the state of different entities.""" - __slots__ = ("_states", "_states_data", "_reservations", "_bus", "_loop") + __slots__ = ("_bus", "_loop", "_reservations", "_states", "_states_data") def __init__(self, bus: EventBus, loop: asyncio.events.AbstractEventLoop) -> None: """Initialize state machine.""" @@ -2404,7 +2404,7 @@ class SupportsResponse(enum.StrEnum): class Service: """Representation of a callable service.""" - __slots__ = ["job", "schema", "domain", "service", "supports_response"] + __slots__ = ["domain", "job", "schema", "service", "supports_response"] def __init__( self, @@ -2431,7 +2431,7 @@ class Service: class ServiceCall: """Representation of a call to a service.""" - __slots__ = ("hass", "domain", "service", "data", "context", "return_response") + __slots__ = ("context", "data", "domain", "hass", "return_response", "service") def __init__( self, @@ -2464,7 +2464,7 @@ class ServiceCall: class ServiceRegistry: """Offer the services over the eventbus.""" - __slots__ = ("_services", "_hass") + __slots__ = ("_hass", "_services") def __init__(self, hass: HomeAssistant) -> None: """Initialize a service registry.""" diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py index ce8205eb915..a8c1b0b2186 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -78,7 +78,7 @@ async def _async_get_component_icons( class _IconsCache: """Cache for icons.""" - __slots__ = ("_hass", "_loaded", "_cache", "_lock") + __slots__ = ("_cache", "_hass", "_loaded", "_lock") def __init__(self, hass: HomeAssistant) -> None: """Initialize the cache.""" diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 5fa0da96dc1..2874269892c 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -1202,17 +1202,17 @@ class Intent: """Hold the intent.""" __slots__ = [ + "assistant", + "category", + "context", + "conversation_agent_id", + "device_id", "hass", - "platform", "intent_type", + "language", + "platform", "slots", "text_input", - "context", - "language", - "category", - "assistant", - "device_id", - "conversation_agent_id", ] def __init__( diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 8e9754ccb4d..255739c0059 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -225,7 +225,7 @@ class ServiceParams(TypedDict): class ServiceTargetSelector: """Class to hold a target selector for a service.""" - __slots__ = ("entity_ids", "device_ids", "area_ids", "floor_ids", "label_ids") + __slots__ = ("area_ids", "device_ids", "entity_ids", "floor_ids", "label_ids") def __init__(self, service_call: ServiceCall) -> None: """Extract ids from service call data.""" diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 4625c3000ba..21d49df2a67 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -386,19 +386,19 @@ class RenderInfo: """Holds information about a template render.""" __slots__ = ( - "template", - "filter_lifecycle", - "filter", "_result", - "is_static", - "exception", "all_states", "all_states_lifecycle", "domains", "domains_lifecycle", "entities", - "rate_limit", + "exception", + "filter", + "filter_lifecycle", "has_time", + "is_static", + "rate_limit", + "template", ) def __init__(self, template: Template) -> None: @@ -507,17 +507,17 @@ class Template: __slots__ = ( "__weakref__", - "template", + "_compiled", + "_compiled_code", + "_exc_info", + "_hash_cache", + "_limited", + "_log_fn", + "_renders", + "_strict", "hass", "is_static", - "_compiled_code", - "_compiled", - "_exc_info", - "_limited", - "_strict", - "_log_fn", - "_hash_cache", - "_renders", + "template", ) def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: @@ -991,7 +991,7 @@ class StateTranslated: class DomainStates: """Class to expose a specific HA domain as attributes.""" - __slots__ = ("_hass", "_domain") + __slots__ = ("_domain", "_hass") __setitem__ = _readonly __delitem__ = _readonly @@ -1035,7 +1035,7 @@ class DomainStates: class TemplateStateBase(State): """Class to represent a state object in a template.""" - __slots__ = ("_hass", "_collect", "_entity_id", "_state") + __slots__ = ("_collect", "_entity_id", "_hass", "_state") _state: State diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index 431a7a7d1f8..d191d474480 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -23,11 +23,11 @@ class TraceElement: "_child_run_id", "_error", "_last_variables", - "path", "_result", - "reuse_by_child", "_timestamp", "_variables", + "path", + "reuse_by_child", ) def __init__(self, variables: TemplateVarsType, path: str) -> None: diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 01c47aa8d0d..fdfefc9bff4 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -147,7 +147,7 @@ class _TranslationsCacheData: class _TranslationCache: """Cache for flattened translations.""" - __slots__ = ("hass", "cache_data", "lock") + __slots__ = ("cache_data", "hass", "lock") def __init__(self, hass: HomeAssistant) -> None: """Initialize the cache.""" diff --git a/pyproject.toml b/pyproject.toml index 0623d681df7..84233aba242 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -765,6 +765,7 @@ select = [ "RUF020", # {never_like} | T is equivalent to T "RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear "RUF022", # Sort __all__ + "RUF023", # Sort __slots__ "RUF024", # Do not pass mutable objects as values to dict.fromkeys "RUF026", # default_factory is a positional-only argument to defaultdict "RUF030", # print() call in assert statement is likely unintentional diff --git a/tests/components/recorder/db_schema_16.py b/tests/components/recorder/db_schema_16.py index d7ca35c9341..522bd6ea367 100644 --- a/tests/components/recorder/db_schema_16.py +++ b/tests/components/recorder/db_schema_16.py @@ -347,11 +347,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_18.py b/tests/components/recorder/db_schema_18.py index adb71dffb9e..026227f68a0 100644 --- a/tests/components/recorder/db_schema_18.py +++ b/tests/components/recorder/db_schema_18.py @@ -360,11 +360,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_22.py b/tests/components/recorder/db_schema_22.py index c0d607b12a7..770d25c9cf2 100644 --- a/tests/components/recorder/db_schema_22.py +++ b/tests/components/recorder/db_schema_22.py @@ -479,11 +479,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_23.py b/tests/components/recorder/db_schema_23.py index f60b7b49df4..8cf3e16e5a8 100644 --- a/tests/components/recorder/db_schema_23.py +++ b/tests/components/recorder/db_schema_23.py @@ -469,11 +469,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_23_with_newer_columns.py b/tests/components/recorder/db_schema_23_with_newer_columns.py index 4cc1074de41..2ba62ba78f5 100644 --- a/tests/components/recorder/db_schema_23_with_newer_columns.py +++ b/tests/components/recorder/db_schema_23_with_newer_columns.py @@ -593,11 +593,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_25.py b/tests/components/recorder/db_schema_25.py index d989cacb76a..3b7c4a300c2 100644 --- a/tests/components/recorder/db_schema_25.py +++ b/tests/components/recorder/db_schema_25.py @@ -529,12 +529,12 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", + "_attr_cache", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", - "_attr_cache", + "_row", ] def __init__( # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_28.py b/tests/components/recorder/db_schema_28.py index 8c984b61f6c..4d7f893de25 100644 --- a/tests/components/recorder/db_schema_28.py +++ b/tests/components/recorder/db_schema_28.py @@ -694,12 +694,12 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", + "_attr_cache", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", - "_attr_cache", + "_row", ] def __init__( # pylint: disable=super-init-not-called From c651e2b3c37466df9e9f3b00bc471eda0c2ff82c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 17 Jan 2025 13:01:07 +0100 Subject: [PATCH 0557/2987] Enable RUF101 (#135835) --- homeassistant/components/plex/server.py | 2 +- homeassistant/util/loop.py | 2 +- pyproject.toml | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index eab1d086d4c..7f9c2545032 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -203,7 +203,7 @@ class PlexServer: config_entry_update_needed = True else: # pylint: disable-next=raise-missing-from - raise Unauthorized( # noqa: TRY200 + raise Unauthorized( # noqa: B904 "New certificate cannot be validated" " with provided token" ) diff --git a/homeassistant/util/loop.py b/homeassistant/util/loop.py index 6ee554a3ef3..bebd399a5cd 100644 --- a/homeassistant/util/loop.py +++ b/homeassistant/util/loop.py @@ -93,7 +93,7 @@ def raise_for_blocking_call( return if found_frame is None: - raise RuntimeError( # noqa: TRY200 + raise RuntimeError( # noqa: B904 f"Caught blocking call to {func.__name__} " f"with args {mapped_args.get('args')} " f"in {offender_filename}, line {offender_lineno}: {offender_line} " diff --git a/pyproject.toml b/pyproject.toml index 84233aba242..7d6fb154375 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -772,6 +772,7 @@ select = [ "RUF033", # __post_init__ method with argument defaults "RUF034", # Useless if-else condition "RUF100", # Unused `noqa` directive + "RUF101", # noqa directives that use redirected rule codes "RUF200", # Failed to parse pyproject.toml: {message} "S102", # Use of exec detected "S103", # bad-file-permissions From 734d1898cf9ed7fd3dbf7e6048ad459806e653c8 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Fri, 17 Jan 2025 14:51:18 +0000 Subject: [PATCH 0558/2987] Homee: fix cover if it has no up/down attribute (#135563) --- homeassistant/components/homee/cover.py | 32 +++++++++++++----------- homeassistant/components/homee/entity.py | 12 +++------ 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py index c6546596fa7..b594b23cc59 100644 --- a/homeassistant/components/homee/cover.py +++ b/homeassistant/components/homee/cover.py @@ -29,7 +29,7 @@ OPEN_CLOSE_ATTRIBUTES = [ POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION] -def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute: +def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None: """Return the attribute used for opening/closing the cover.""" # We assume, that no device has UP_DOWN and OPEN_CLOSE, but only one of them. if (open_close := node.get_attribute_by_type(AttributeType.UP_DOWN)) is None: @@ -39,12 +39,12 @@ def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute: def get_cover_features( - node: HomeeNode, open_close_attribute: HomeeAttribute + node: HomeeNode, open_close_attribute: HomeeAttribute | None ) -> CoverEntityFeature: """Determine the supported cover features of a homee node based on the available attributes.""" features = CoverEntityFeature(0) - if open_close_attribute.editable: + if (open_close_attribute is not None) and open_close_attribute.editable: features |= ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP ) @@ -111,8 +111,11 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): node, self._open_close_attribute ) self._attr_device_class = get_device_class(node) - - self._attr_unique_id = f"{self._attr_unique_id}-{self._open_close_attribute.id}" + self._attr_unique_id = ( + f"{self._attr_unique_id}-{self._open_close_attribute.id}" + if self._open_close_attribute is not None + else f"{self._attr_unique_id}-0" + ) @property def current_cover_position(self) -> int | None: @@ -194,6 +197,7 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" + assert self._open_close_attribute is not None if not self._open_close_attribute.is_reversed: await self.async_set_value(self._open_close_attribute, 0) else: @@ -201,6 +205,7 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" + assert self._open_close_attribute is not None if not self._open_close_attribute.is_reversed: await self.async_set_value(self._open_close_attribute, 1) else: @@ -217,11 +222,12 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): homee_max = attribute.maximum homee_position = (position / 100) * (homee_max - homee_min) + homee_min - await self.async_set_value(AttributeType.POSITION, homee_position) + await self.async_set_value(attribute, homee_position) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - await self.async_set_value(self._open_close_attribute, 2) + if self._open_close_attribute is not None: + await self.async_set_value(self._open_close_attribute, 2) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" @@ -229,9 +235,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): AttributeType.SLAT_ROTATION_IMPULSE ) if not slat_attribute.is_reversed: - await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 2) + await self.async_set_value(slat_attribute, 2) else: - await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 1) + await self.async_set_value(slat_attribute, 1) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" @@ -239,9 +245,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): AttributeType.SLAT_ROTATION_IMPULSE ) if not slat_attribute.is_reversed: - await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 1) + await self.async_set_value(slat_attribute, 1) else: - await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 2) + await self.async_set_value(slat_attribute, 2) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" @@ -256,6 +262,4 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): homee_max = attribute.maximum homee_position = (position / 100) * (homee_max - homee_min) + homee_min - await self.async_set_value( - AttributeType.SHUTTER_SLAT_POSITION, homee_position - ) + await self.async_set_value(attribute, homee_position) diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py index c3c2d860cc0..91b23b5a2c2 100644 --- a/homeassistant/components/homee/entity.py +++ b/homeassistant/components/homee/entity.py @@ -1,7 +1,7 @@ """Base Entities for Homee integration.""" from pyHomee.const import AttributeType, NodeProfile, NodeState -from pyHomee.model import HomeeNode +from pyHomee.model import HomeeAttribute, HomeeNode from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -69,16 +69,10 @@ class HomeeNodeEntity(Entity): """Check if an attribute of the given type exists.""" return attribute_type in self._node.attribute_map - async def async_set_value(self, attribute_type: int, value: float) -> None: - """Set an attribute value on the homee node.""" - await self.async_set_value_by_id( - self._node.get_attribute_by_type(attribute_type).id, value - ) - - async def async_set_value_by_id(self, attribute_id: int, value: float) -> None: + async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None: """Set an attribute value on the homee node.""" homee = self._entry.runtime_data - await homee.set_value(self._node.id, attribute_id, value) + await homee.set_value(attribute.node_id, attribute.id, value) def _on_node_updated(self, node: HomeeNode) -> None: self.schedule_update_ha_state() From 7b413b5faf1fdd1417e221ae567e927f7b17ad5d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 17 Jan 2025 16:56:14 +0100 Subject: [PATCH 0559/2987] Clarify action descriptions regarding Lost device sound and state (#134277) --- homeassistant/components/icloud/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/icloud/strings.json b/homeassistant/components/icloud/strings.json index 22c711e919a..adc96043d66 100644 --- a/homeassistant/components/icloud/strings.json +++ b/homeassistant/components/icloud/strings.json @@ -56,7 +56,7 @@ }, "play_sound": { "name": "Play sound", - "description": "Plays sound on an Apple device.", + "description": "Plays the Lost device sound on an Apple device.", "fields": { "account": { "name": "Account", @@ -64,7 +64,7 @@ }, "device_name": { "name": "Device name", - "description": "The name of the Apple device to play a sound." + "description": "The name of the Apple device to play the sound." } } }, @@ -92,7 +92,7 @@ }, "lost_device": { "name": "Lost device", - "description": "Makes an Apple device in lost state.", + "description": "Puts an Apple device in lost state.", "fields": { "account": { "name": "Account", From a2afc1b6708a3c390a7ea92797ba9fdbf7568be7 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Fri, 17 Jan 2025 17:12:09 +0100 Subject: [PATCH 0560/2987] Plugwise test-code improvements (#134193) --- tests/components/plugwise/conftest.py | 181 +++++------------- .../components/plugwise/test_binary_sensor.py | 50 ++--- tests/components/plugwise/test_climate.py | 38 ++-- tests/components/plugwise/test_init.py | 12 +- tests/components/plugwise/test_number.py | 20 +- tests/components/plugwise/test_select.py | 10 +- tests/components/plugwise/test_sensor.py | 23 ++- 7 files changed, 140 insertions(+), 194 deletions(-) diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index e0ada8ea849..92ed42aa03a 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -30,6 +30,24 @@ def _read_json(environment: str, call: str) -> dict[str, Any]: return json.loads(fixture) +@pytest.fixture +def chosen_env(request: pytest.FixtureRequest) -> str: + """Pass the chosen_env string. + + Used with fixtures that require parametrization of the user-data fixture. + """ + return request.param + + +@pytest.fixture +def gateway_id(request: pytest.FixtureRequest) -> str: + """Pass the gateway_id string. + + Used with fixtures that require parametrization of the gateway_id. + """ + return request.param + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -76,7 +94,7 @@ def mock_smile_config_flow() -> Generator[MagicMock]: def mock_smile_adam() -> Generator[MagicMock]: """Create a Mock Adam environment for testing exceptions.""" chosen_env = "m_adam_multiple_devices_per_zone" - + all_data = _read_json(chosen_env, "all_data") with ( patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True @@ -97,7 +115,6 @@ def mock_smile_adam() -> Generator[MagicMock]: smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" smile.connect.return_value = Version("3.0.15") - all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( all_data["devices"], all_data["gateway"] ) @@ -106,15 +123,18 @@ def mock_smile_adam() -> Generator[MagicMock]: @pytest.fixture -def mock_smile_adam_2() -> Generator[MagicMock]: - """Create a 2nd Mock Adam environment for testing exceptions.""" - chosen_env = "m_adam_heating" - +def mock_smile_adam_heat_cool(chosen_env: str) -> Generator[MagicMock]: + """Create a special base Mock Adam type for testing with different datasets.""" + all_data = _read_json(chosen_env, "all_data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value + smile.async_update.return_value = PlugwiseData( + all_data["devices"], all_data["gateway"] + ) + smile.connect.return_value = Version("3.6.4") smile.gateway_id = "da224107914542988a88561b4452b0f6" smile.heater_id = "056ee145a816487eaa69243c3280f8bf" smile.smile_version = "3.6.4" @@ -123,47 +143,15 @@ def mock_smile_adam_2() -> Generator[MagicMock]: smile.smile_model = "Gateway" smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" - smile.connect.return_value = Version("3.6.4") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) yield smile @pytest.fixture -def mock_smile_adam_3() -> Generator[MagicMock]: - """Create a 3rd Mock Adam environment for testing exceptions.""" - chosen_env = "m_adam_cooling" - - with patch( - "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value - - smile.gateway_id = "da224107914542988a88561b4452b0f6" - smile.heater_id = "056ee145a816487eaa69243c3280f8bf" - smile.smile_version = "3.6.4" - smile.smile_type = "thermostat" - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_open_therm" - smile.smile_name = "Adam" - smile.connect.return_value = Version("3.6.4") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) - - yield smile - - -@pytest.fixture -def mock_smile_adam_4() -> Generator[MagicMock]: - """Create a 4th Mock Adam environment for testing exceptions.""" +def mock_smile_adam_jip() -> Generator[MagicMock]: + """Create a Mock adam-jip type for testing exceptions.""" chosen_env = "m_adam_jip" - + all_data = _read_json(chosen_env, "all_data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: @@ -178,7 +166,6 @@ def mock_smile_adam_4() -> Generator[MagicMock]: smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" smile.connect.return_value = Version("3.2.8") - all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( all_data["devices"], all_data["gateway"] ) @@ -187,14 +174,18 @@ def mock_smile_adam_4() -> Generator[MagicMock]: @pytest.fixture -def mock_smile_anna() -> Generator[MagicMock]: - """Create a Mock Anna environment for testing exceptions.""" - chosen_env = "anna_heatpump_heating" +def mock_smile_anna(chosen_env: str) -> Generator[MagicMock]: + """Create a Mock Anna type for testing.""" + all_data = _read_json(chosen_env, "all_data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value + smile.async_update.return_value = PlugwiseData( + all_data["devices"], all_data["gateway"] + ) + smile.connect.return_value = Version("4.0.15") smile.gateway_id = "015ae9ea3f964e668e490fa39da3870b" smile.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927" smile.smile_version = "4.0.15" @@ -203,115 +194,31 @@ def mock_smile_anna() -> Generator[MagicMock]: smile.smile_model = "Gateway" smile.smile_model_id = "smile_thermo" smile.smile_name = "Smile Anna" - smile.connect.return_value = Version("4.0.15") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) yield smile @pytest.fixture -def mock_smile_anna_2() -> Generator[MagicMock]: - """Create a 2nd Mock Anna environment for testing exceptions.""" - chosen_env = "m_anna_heatpump_cooling" +def mock_smile_p1(chosen_env: str, gateway_id: str) -> Generator[MagicMock]: + """Create a base Mock P1 type for testing with different datasets and gateway-ids.""" + all_data = _read_json(chosen_env, "all_data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value - smile.gateway_id = "015ae9ea3f964e668e490fa39da3870b" - smile.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927" - smile.smile_version = "4.0.15" - smile.smile_type = "thermostat" - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_thermo" - smile.smile_name = "Smile Anna" - smile.connect.return_value = Version("4.0.15") - all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( all_data["devices"], all_data["gateway"] ) - - yield smile - - -@pytest.fixture -def mock_smile_anna_3() -> Generator[MagicMock]: - """Create a 3rd Mock Anna environment for testing exceptions.""" - chosen_env = "m_anna_heatpump_idle" - with patch( - "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value - - smile.gateway_id = "015ae9ea3f964e668e490fa39da3870b" - smile.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927" - smile.smile_version = "4.0.15" - smile.smile_type = "thermostat" - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_thermo" - smile.smile_name = "Smile Anna" - smile.connect.return_value = Version("4.0.15") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) - - yield smile - - -@pytest.fixture -def mock_smile_p1() -> Generator[MagicMock]: - """Create a Mock P1 DSMR environment for testing exceptions.""" - chosen_env = "p1v4_442_single" - with patch( - "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value - - smile.gateway_id = "a455b61e52394b2db5081ce025a430f3" + smile.connect.return_value = Version("4.4.2") + smile.gateway_id = gateway_id smile.heater_id = None - smile.smile_version = "4.4.2" - smile.smile_type = "power" smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = "smile" smile.smile_name = "Smile P1" - smile.connect.return_value = Version("4.4.2") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) - - yield smile - - -@pytest.fixture -def mock_smile_p1_2() -> Generator[MagicMock]: - """Create a Mock P1 3-phase DSMR environment for testing exceptions.""" - chosen_env = "p1v4_442_triple" - with patch( - "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value - - smile.gateway_id = "03e65b16e4b247a29ae0d75a78cb492e" - smile.heater_id = None - smile.smile_version = "4.4.2" smile.smile_type = "power" - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile" - smile.smile_name = "Smile P1" - smile.connect.return_value = Version("4.4.2") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.smile_version = "4.4.2" yield smile @@ -320,6 +227,7 @@ def mock_smile_p1_2() -> Generator[MagicMock]: def mock_smile_legacy_anna() -> Generator[MagicMock]: """Create a Mock legacy Anna environment for testing exceptions.""" chosen_env = "legacy_anna" + all_data = _read_json(chosen_env, "all_data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: @@ -334,7 +242,6 @@ def mock_smile_legacy_anna() -> Generator[MagicMock]: smile.smile_model_id = None smile.smile_name = "Smile Anna" smile.connect.return_value = Version("1.8.22") - all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( all_data["devices"], all_data["gateway"] ) @@ -346,6 +253,7 @@ def mock_smile_legacy_anna() -> Generator[MagicMock]: def mock_stretch() -> Generator[MagicMock]: """Create a Mock Stretch environment for testing exceptions.""" chosen_env = "stretch_v31" + all_data = _read_json(chosen_env, "all_data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: @@ -360,7 +268,6 @@ def mock_stretch() -> Generator[MagicMock]: smile.smile_model_id = None smile.smile_name = "Stretch" smile.connect.return_value = Version("3.1.11") - all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( all_data["devices"], all_data["gateway"] ) diff --git a/tests/components/plugwise/test_binary_sensor.py b/tests/components/plugwise/test_binary_sensor.py index 5c0e3fbdd2e..554326a72b1 100644 --- a/tests/components/plugwise/test_binary_sensor.py +++ b/tests/components/plugwise/test_binary_sensor.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +import pytest + from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity @@ -9,32 +11,30 @@ from homeassistant.helpers.entity_component import async_update_entity from tests.common import MockConfigEntry +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize( + ("entity_id", "expected_state"), + [ + ("binary_sensor.opentherm_secondary_boiler_state", STATE_OFF), + ("binary_sensor.opentherm_dhw_state", STATE_OFF), + ("binary_sensor.opentherm_heating", STATE_ON), + ("binary_sensor.opentherm_cooling_enabled", STATE_OFF), + ("binary_sensor.opentherm_compressor_state", STATE_ON), + ], +) async def test_anna_climate_binary_sensor_entities( - hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_anna: MagicMock, + init_integration: MockConfigEntry, + entity_id: str, + expected_state: str, ) -> None: """Test creation of climate related binary_sensor entities.""" - - state = hass.states.get("binary_sensor.opentherm_secondary_boiler_state") - assert state - assert state.state == STATE_OFF - - state = hass.states.get("binary_sensor.opentherm_dhw_state") - assert state - assert state.state == STATE_OFF - - state = hass.states.get("binary_sensor.opentherm_heating") - assert state - assert state.state == STATE_ON - - state = hass.states.get("binary_sensor.opentherm_cooling_enabled") - assert state - assert state.state == STATE_OFF - - state = hass.states.get("binary_sensor.opentherm_compressor_state") - assert state - assert state.state == STATE_ON + state = hass.states.get(entity_id) + assert state.state == expected_state +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_anna_climate_binary_sensor_change( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -66,8 +66,12 @@ async def test_adam_climate_binary_sensor_change( assert not state.attributes.get("other_msg") -async def test_p1_v4_binary_sensor_entity( - hass: HomeAssistant, mock_smile_p1_2: MagicMock, init_integration: MockConfigEntry +@pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True +) +async def test_p1_binary_sensor_entity( + hass: HomeAssistant, mock_smile_p1: MagicMock, init_integration: MockConfigEntry ) -> None: """Test of a Smile P1 related plugwise-notification binary_sensor.""" state = hass.states.get("binary_sensor.smile_p1_plugwise_notification") diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 8368af8e5cc..ab6bd3d4f29 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -79,8 +79,11 @@ async def test_adam_climate_entity_attributes( assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) async def test_adam_2_climate_entity_attributes( - hass: HomeAssistant, mock_smile_adam_2: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + init_integration: MockConfigEntry, ) -> None: """Test creation of adam climate device environment.""" state = hass.states.get("climate.living_room") @@ -104,9 +107,10 @@ async def test_adam_2_climate_entity_attributes( ] +@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) async def test_adam_3_climate_entity_attributes( hass: HomeAssistant, - mock_smile_adam_3: MagicMock, + mock_smile_adam_heat_cool: MagicMock, init_integration: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: @@ -120,7 +124,7 @@ async def test_adam_3_climate_entity_attributes( HVACMode.AUTO, HVACMode.COOL, ] - data = mock_smile_adam_3.async_update.return_value + data = mock_smile_adam_heat_cool.async_update.return_value data.devices["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = ( "heating" ) @@ -148,7 +152,7 @@ async def test_adam_3_climate_entity_attributes( HVACMode.HEAT, ] - data = mock_smile_adam_3.async_update.return_value + data = mock_smile_adam_heat_cool.async_update.return_value data.devices["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = ( "cooling" ) @@ -266,7 +270,7 @@ async def test_adam_climate_entity_climate_changes( async def test_adam_climate_off_mode_change( hass: HomeAssistant, - mock_smile_adam_4: MagicMock, + mock_smile_adam_jip: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test handling of user requests in adam climate device environment.""" @@ -282,9 +286,9 @@ async def test_adam_climate_off_mode_change( }, blocking=True, ) - assert mock_smile_adam_4.set_schedule_state.call_count == 1 - assert mock_smile_adam_4.set_regulation_mode.call_count == 1 - mock_smile_adam_4.set_regulation_mode.assert_called_with("heating") + assert mock_smile_adam_jip.set_schedule_state.call_count == 1 + assert mock_smile_adam_jip.set_regulation_mode.call_count == 1 + mock_smile_adam_jip.set_regulation_mode.assert_called_with("heating") state = hass.states.get("climate.kinderkamer") assert state @@ -298,9 +302,9 @@ async def test_adam_climate_off_mode_change( }, blocking=True, ) - assert mock_smile_adam_4.set_schedule_state.call_count == 1 - assert mock_smile_adam_4.set_regulation_mode.call_count == 2 - mock_smile_adam_4.set_regulation_mode.assert_called_with("off") + assert mock_smile_adam_jip.set_schedule_state.call_count == 1 + assert mock_smile_adam_jip.set_regulation_mode.call_count == 2 + mock_smile_adam_jip.set_regulation_mode.assert_called_with("off") state = hass.states.get("climate.logeerkamer") assert state @@ -314,10 +318,11 @@ async def test_adam_climate_off_mode_change( }, blocking=True, ) - assert mock_smile_adam_4.set_schedule_state.call_count == 1 - assert mock_smile_adam_4.set_regulation_mode.call_count == 2 + assert mock_smile_adam_jip.set_schedule_state.call_count == 1 + assert mock_smile_adam_jip.set_regulation_mode.call_count == 2 +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_anna_climate_entity_attributes( hass: HomeAssistant, mock_smile_anna: MagicMock, @@ -343,9 +348,10 @@ async def test_anna_climate_entity_attributes( assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 +@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_cooling"], indirect=True) async def test_anna_2_climate_entity_attributes( hass: HomeAssistant, - mock_smile_anna_2: MagicMock, + mock_smile_anna: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test creation of anna climate device environment.""" @@ -362,9 +368,10 @@ async def test_anna_2_climate_entity_attributes( assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20.5 +@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_idle"], indirect=True) async def test_anna_3_climate_entity_attributes( hass: HomeAssistant, - mock_smile_anna_3: MagicMock, + mock_smile_anna: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test creation of anna climate device environment.""" @@ -378,6 +385,7 @@ async def test_anna_3_climate_entity_attributes( ] +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_anna_climate_entity_climate_changes( hass: HomeAssistant, mock_smile_anna: MagicMock, diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py index 014003d29d0..874c4b61a47 100644 --- a/tests/components/plugwise/test_init.py +++ b/tests/components/plugwise/test_init.py @@ -61,6 +61,7 @@ TOM = { } +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -80,6 +81,7 @@ async def test_load_unload_config_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize( ("side_effect", "entry_state"), [ @@ -109,6 +111,10 @@ async def test_gateway_config_entry_not_ready( assert mock_config_entry.state is entry_state +@pytest.mark.parametrize("chosen_env", ["p1v4_442_single"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["a455b61e52394b2db5081ce025a430f3"], indirect=True +) async def test_device_in_dr( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -131,6 +137,7 @@ async def test_device_in_dr( assert device_entry.sw_version == "4.4.2" +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize( ("entitydata", "old_unique_id", "new_unique_id"), [ @@ -224,16 +231,17 @@ async def test_migrate_unique_id_relay( assert entity_migrated.unique_id == new_unique_id +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) async def test_update_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smile_adam_2: MagicMock, + mock_smile_adam_heat_cool: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test a clean-up of the device_registry.""" - data = mock_smile_adam_2.async_update.return_value + data = mock_smile_adam_heat_cool.async_update.return_value mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index fdceb042669..c5361433388 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -16,6 +16,7 @@ from homeassistant.exceptions import ServiceValidationError from tests.common import MockConfigEntry +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_anna_number_entities( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -25,6 +26,7 @@ async def test_anna_number_entities( assert float(state.state) == 60.0 +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_anna_max_boiler_temp_change( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -45,19 +47,17 @@ async def test_anna_max_boiler_temp_change( ) -async def test_adam_number_entities( - hass: HomeAssistant, mock_smile_adam_2: MagicMock, init_integration: MockConfigEntry +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +async def test_adam_dhw_setpoint_change( + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + init_integration: MockConfigEntry, ) -> None: - """Test creation of a number.""" + """Test changing of number entities.""" state = hass.states.get("number.opentherm_domestic_hot_water_setpoint") assert state assert float(state.state) == 60.0 - -async def test_adam_dhw_setpoint_change( - hass: HomeAssistant, mock_smile_adam_2: MagicMock, init_integration: MockConfigEntry -) -> None: - """Test changing of number entities.""" await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -68,8 +68,8 @@ async def test_adam_dhw_setpoint_change( blocking=True, ) - assert mock_smile_adam_2.set_number.call_count == 1 - mock_smile_adam_2.set_number.assert_called_with( + assert mock_smile_adam_heat_cool.set_number.call_count == 1 + mock_smile_adam_heat_cool.set_number.assert_called_with( "056ee145a816487eaa69243c3280f8bf", "max_dhw_temperature", 55.0 ) diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index 8891a88bb91..f06d07767f3 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -50,8 +50,11 @@ async def test_adam_change_select_entity( ) +@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) async def test_adam_select_regulation_mode( - hass: HomeAssistant, mock_smile_adam_3: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + init_integration: MockConfigEntry, ) -> None: """Test a regulation_mode select. @@ -73,8 +76,8 @@ async def test_adam_select_regulation_mode( }, blocking=True, ) - assert mock_smile_adam_3.set_select.call_count == 1 - mock_smile_adam_3.set_select.assert_called_with( + assert mock_smile_adam_heat_cool.set_select.call_count == 1 + mock_smile_adam_heat_cool.set_select.assert_called_with( "select_regulation_mode", "bc93488efab249e5bc54fd7e175a6f91", "heating", @@ -91,6 +94,7 @@ async def test_legacy_anna_select_entities( assert not hass.states.get("select.anna_thermostat_schedule") +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_adam_select_unavailable_regulation_mode( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py index f10f3f00933..b3243d6b127 100644 --- a/tests/components/plugwise/test_sensor.py +++ b/tests/components/plugwise/test_sensor.py @@ -41,7 +41,9 @@ async def test_adam_climate_sensor_entities( async def test_adam_climate_sensor_entity_2( - hass: HomeAssistant, mock_smile_adam_4: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_adam_jip: MagicMock, + init_integration: MockConfigEntry, ) -> None: """Test creation of climate related sensor entities.""" state = hass.states.get("sensor.woonkamer_humidity") @@ -52,7 +54,7 @@ async def test_adam_climate_sensor_entity_2( async def test_unique_id_migration_humidity( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_smile_adam_4: MagicMock, + mock_smile_adam_jip: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test unique ID migration of -relative_humidity to -humidity.""" @@ -92,6 +94,7 @@ async def test_unique_id_migration_humidity( assert entity_entry.unique_id == "f61f1a2535f54f52ad006a3d18e459ca-battery" +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_anna_as_smt_climate_sensor_entities( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -113,6 +116,10 @@ async def test_anna_as_smt_climate_sensor_entities( assert float(state.state) == 86.0 +@pytest.mark.parametrize("chosen_env", ["p1v4_442_single"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["a455b61e52394b2db5081ce025a430f3"], indirect=True +) async def test_p1_dsmr_sensor_entities( hass: HomeAssistant, mock_smile_p1: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -137,11 +144,15 @@ async def test_p1_dsmr_sensor_entities( assert not state +@pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True +) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_p1_3ph_dsmr_sensor_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_smile_p1_2: MagicMock, + mock_smile_p1: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test creation of power related sensor entities.""" @@ -163,10 +174,14 @@ async def test_p1_3ph_dsmr_sensor_entities( assert float(state.state) == 233.2 +@pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True +) async def test_p1_3ph_dsmr_sensor_disabled_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_smile_p1_2: MagicMock, + mock_smile_p1: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test disabled power related sensor entities intent.""" From 829d3bf62164d9a00b52ffba1301299b0bd03bd7 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 17 Jan 2025 17:13:25 +0100 Subject: [PATCH 0561/2987] Add support for EvoHomeController in Overkiz (#133777) --- .../components/overkiz/climate/__init__.py | 2 + .../overkiz/climate/evo_home_controller.py | 101 ++++++++++++++++++ homeassistant/components/overkiz/const.py | 1 + 3 files changed, 104 insertions(+) create mode 100644 homeassistant/components/overkiz/climate/evo_home_controller.py diff --git a/homeassistant/components/overkiz/climate/__init__.py b/homeassistant/components/overkiz/climate/__init__.py index 1398bb7c25a..3276a1979cc 100644 --- a/homeassistant/components/overkiz/climate/__init__.py +++ b/homeassistant/components/overkiz/climate/__init__.py @@ -25,6 +25,7 @@ from .atlantic_pass_apc_heat_pump_main_component import ( from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl from .atlantic_pass_apc_zone_control_zone import AtlanticPassAPCZoneControlZone +from .evo_home_controller import EvoHomeController from .hitachi_air_to_air_heat_pump_hlrrwifi import HitachiAirToAirHeatPumpHLRRWIFI from .hitachi_air_to_air_heat_pump_ovp import HitachiAirToAirHeatPumpOVP from .hitachi_air_to_water_heating_zone import HitachiAirToWaterHeatingZone @@ -53,6 +54,7 @@ WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: AtlanticPassAPCHeatingZone, UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl, UIWidget.HITACHI_AIR_TO_WATER_HEATING_ZONE: HitachiAirToWaterHeatingZone, + UIWidget.EVO_HOME_CONTROLLER: EvoHomeController, UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface, UIWidget.SOMFY_THERMOSTAT: SomfyThermostat, UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface, diff --git a/homeassistant/components/overkiz/climate/evo_home_controller.py b/homeassistant/components/overkiz/climate/evo_home_controller.py new file mode 100644 index 00000000000..272acbb13b9 --- /dev/null +++ b/homeassistant/components/overkiz/climate/evo_home_controller.py @@ -0,0 +1,101 @@ +"""Support for EvoHomeController.""" + +from datetime import timedelta + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ( + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import UnitOfTemperature +import homeassistant.util.dt as dt_util + +from ..entity import OverkizDataUpdateCoordinator, OverkizEntity + +PRESET_DAY_OFF = "day-off" +PRESET_HOLIDAYS = "holidays" + +OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = { + OverkizCommandParam.AUTO: HVACMode.AUTO, + OverkizCommandParam.OFF: HVACMode.OFF, +} +HVAC_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODES.items()} + +OVERKIZ_TO_PRESET_MODES: dict[str, str] = { + OverkizCommandParam.DAY_OFF: PRESET_DAY_OFF, + OverkizCommandParam.HOLIDAYS: PRESET_HOLIDAYS, +} +PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()} + + +class EvoHomeController(OverkizEntity, ClimateEntity): + """Representation of EvoHomeController device.""" + + _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] + _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TURN_OFF + ) + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + + if self._attr_device_info: + self._attr_device_info["manufacturer"] = "EvoHome" + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode.""" + if state := self.device.states.get(OverkizState.RAMSES_RAMSES_OPERATING_MODE): + operating_mode = state.value_as_str + + if operating_mode in OVERKIZ_TO_HVAC_MODES: + return OVERKIZ_TO_HVAC_MODES[operating_mode] + + if operating_mode in OVERKIZ_TO_PRESET_MODES: + return HVACMode.OFF + + return HVACMode.OFF + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + await self.executor.async_execute_command( + OverkizCommand.SET_OPERATING_MODE, HVAC_MODES_TO_OVERKIZ[hvac_mode] + ) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp.""" + if ( + state := self.device.states[OverkizState.RAMSES_RAMSES_OPERATING_MODE] + ) and state.value_as_str in OVERKIZ_TO_PRESET_MODES: + return OVERKIZ_TO_PRESET_MODES[state.value_as_str] + + return PRESET_NONE + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode == PRESET_DAY_OFF: + today_end_of_day = dt_util.now().replace( + hour=0, minute=0, second=0, microsecond=0 + ) + timedelta(days=1) + time_interval = today_end_of_day + + if preset_mode == PRESET_HOLIDAYS: + one_week_from_now = dt_util.now().replace( + hour=0, minute=0, second=0, microsecond=0 + ) + timedelta(days=7) + time_interval = one_week_from_now + + await self.executor.async_execute_command( + OverkizCommand.SET_OPERATING_MODE, + PRESET_MODES_TO_OVERKIZ[preset_mode], + time_interval.strftime("%Y/%m/%d %H:%M"), + ) diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index 41b567500a9..7f5f4ad85bd 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -102,6 +102,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_PRODUCTION: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported) + UIWidget.EVO_HOME_CONTROLLER: Platform.CLIMATE, # widgetName, uiClass is EvoHome (not supported) UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.HITACHI_AIR_TO_WATER_HEATING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.HITACHI_DHW: Platform.WATER_HEATER, # widgetName, uiClass is HitachiHeatingSystem (not supported) From fb309a3f98b03239a02ea2a622757a92b128b96b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 17 Jan 2025 17:18:38 +0100 Subject: [PATCH 0562/2987] Fix description of "x10_all_units_off" action (#135000) --- homeassistant/components/insteon/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index 4df997ac939..4a8aadb70db 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -164,7 +164,7 @@ }, "x10_all_units_off": { "name": "X10 all units off", - "description": "[%key:component::insteon::services::add_all_link::description%]", + "description": "Sends X10 'All units off' command.", "fields": { "housecode": { "name": "Housecode", @@ -174,7 +174,7 @@ }, "x10_all_lights_on": { "name": "X10 all lights on", - "description": "Sends X10 All Lights On command.", + "description": "Sends X10 'All lights on' command.", "fields": { "housecode": { "name": "[%key:component::insteon::services::x10_all_units_off::fields::housecode::name%]", @@ -184,7 +184,7 @@ }, "x10_all_lights_off": { "name": "X10 all lights off", - "description": "Sends X10 All Lights Off command.", + "description": "Sends X10 'All lights off' command.", "fields": { "housecode": { "name": "[%key:component::insteon::services::x10_all_units_off::fields::housecode::name%]", From 9e0df89bee6bfb0033849e32c59d9297d07410bc Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 17 Jan 2025 08:33:48 -0800 Subject: [PATCH 0563/2987] Log errors in opower (#135497) --- .../components/opower/coordinator.py | 51 ++++++++++++++----- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 629dce0823c..36e23c4098e 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -5,9 +5,11 @@ import logging from types import MappingProxyType from typing import Any, cast +import aiohttp from opower import ( Account, AggregateType, + CannotConnect, CostRead, Forecast, InvalidAuth, @@ -27,7 +29,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, Unit from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN @@ -80,8 +82,16 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): # assume previous session has expired and re-login. await self.api.async_login() except InvalidAuth as err: + _LOGGER.error("Error during login: %s", err) raise ConfigEntryAuthFailed from err - forecasts: list[Forecast] = await self.api.async_get_forecast() + except CannotConnect as err: + _LOGGER.error("Error during login: %s", err) + raise UpdateFailed(f"Error during login: {err}") from err + try: + forecasts: list[Forecast] = await self.api.async_get_forecast() + except aiohttp.ClientError as err: + _LOGGER.error("Error getting forecasts: %s", err) + raise _LOGGER.debug("Updating sensor data with: %s", forecasts) # Because Opower provides historical usage/cost with a delay of a couple of days # we need to insert data into statistics. @@ -90,7 +100,12 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): async def _insert_statistics(self) -> None: """Insert Opower statistics.""" - for account in await self.api.async_get_accounts(): + try: + accounts = await self.api.async_get_accounts() + except aiohttp.ClientError as err: + _LOGGER.error("Error getting forecasts: %s", err) + raise + for account in accounts: id_prefix = "_".join( ( self.api.utility.subdomain(), @@ -252,9 +267,13 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): start = datetime.fromtimestamp(start_time, tz=tz) - timedelta(days=30) end = dt_util.now(tz) _LOGGER.debug("Getting monthly cost reads: %s - %s", start, end) - cost_reads = await self.api.async_get_cost_reads( - account, AggregateType.BILL, start, end - ) + try: + cost_reads = await self.api.async_get_cost_reads( + account, AggregateType.BILL, start, end + ) + except aiohttp.ClientError as err: + _LOGGER.error("Error getting monthly cost reads: %s", err) + raise _LOGGER.debug("Got %s monthly cost reads", len(cost_reads)) if account.read_resolution == ReadResolution.BILLING: return cost_reads @@ -267,9 +286,13 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): assert start start = max(start, end - timedelta(days=3 * 365)) _LOGGER.debug("Getting daily cost reads: %s - %s", start, end) - daily_cost_reads = await self.api.async_get_cost_reads( - account, AggregateType.DAY, start, end - ) + try: + daily_cost_reads = await self.api.async_get_cost_reads( + account, AggregateType.DAY, start, end + ) + except aiohttp.ClientError as err: + _LOGGER.error("Error getting daily cost reads: %s", err) + raise _LOGGER.debug("Got %s daily cost reads", len(daily_cost_reads)) _update_with_finer_cost_reads(cost_reads, daily_cost_reads) if account.read_resolution == ReadResolution.DAY: @@ -281,9 +304,13 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): assert start start = max(start, end - timedelta(days=2 * 30)) _LOGGER.debug("Getting hourly cost reads: %s - %s", start, end) - hourly_cost_reads = await self.api.async_get_cost_reads( - account, AggregateType.HOUR, start, end - ) + try: + hourly_cost_reads = await self.api.async_get_cost_reads( + account, AggregateType.HOUR, start, end + ) + except aiohttp.ClientError as err: + _LOGGER.error("Error getting hourly cost reads: %s", err) + raise _LOGGER.debug("Got %s hourly cost reads", len(hourly_cost_reads)) _update_with_finer_cost_reads(cost_reads, hourly_cost_reads) _LOGGER.debug("Got %s cost reads", len(cost_reads)) From 24bb623567704651576d9020094673669f29aa71 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 18 Jan 2025 02:38:03 +1000 Subject: [PATCH 0564/2987] Add streaming to Teslemetry cover platform (#135660) --- .../components/teslemetry/__init__.py | 9 +- homeassistant/components/teslemetry/cover.py | 388 ++++++++++++++---- homeassistant/components/teslemetry/entity.py | 67 +-- homeassistant/components/teslemetry/models.py | 3 +- tests/components/teslemetry/conftest.py | 9 + .../teslemetry/fixtures/metadata.json | 2 +- .../teslemetry/fixtures/vehicle_data.json | 2 +- .../teslemetry/fixtures/vehicle_data_alt.json | 2 +- .../teslemetry/snapshots/test_cover.ambr | 84 ++-- .../snapshots/test_diagnostics.ambr | 2 +- .../teslemetry/snapshots/test_update.ambr | 8 +- tests/components/teslemetry/test_cover.py | 128 ++++++ 12 files changed, 540 insertions(+), 164 deletions(-) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 285aff1d0cf..b9cbc64dcd9 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -128,6 +128,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - {"vin": vin}, ) firmware = vehicle_metadata[vin].get("firmware", "Unknown") + stream_vehicle = stream.get_vehicle(vin) vehicles.append( TeslemetryVehicleData( @@ -135,6 +136,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - config_entry=entry, coordinator=coordinator, stream=stream, + stream_vehicle=stream_vehicle, vin=vin, firmware=firmware, device=device, @@ -285,8 +287,9 @@ async def async_setup_stream( ): """Set up the stream for a vehicle.""" - vehicle_stream = vehicle.stream.get_vehicle(vehicle.vin) - await vehicle_stream.get_config() + await vehicle.stream_vehicle.get_config() entry.async_create_background_task( - hass, vehicle_stream.prefer_typed(True), f"Prefer typed for {vehicle.vin}" + hass, + vehicle.stream_vehicle.prefer_typed(True), + f"Prefer typed for {vehicle.vin}", ) diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index d14ef385b9c..c4fbae7b0bc 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -2,9 +2,12 @@ from __future__ import annotations +from itertools import chain from typing import Any from tesla_fleet_api.const import Scope, SunRoofCommand, Trunk, WindowCommand +from teslemetry_stream import Signal +from teslemetry_stream.const import WindowState from homeassistant.components.cover import ( CoverDeviceClass, @@ -13,9 +16,14 @@ from homeassistant.components.cover import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryVehicleEntity +from .entity import ( + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_vehicle_command from .models import TeslemetryVehicleData @@ -33,30 +41,95 @@ async def async_setup_entry( """Set up the Teslemetry cover platform from a config entry.""" async_add_entities( - klass(vehicle, entry.runtime_data.scopes) - for (klass) in ( - TeslemetryWindowEntity, - TeslemetryChargePortEntity, - TeslemetryFrontTrunkEntity, - TeslemetryRearTrunkEntity, - TeslemetrySunroofEntity, + chain( + ( + TeslemetryPollingWindowEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingWindowEntity(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryPollingChargePortEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + else TeslemetryStreamingChargePortEntity( + vehicle, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryPollingFrontTrunkEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingFrontTrunkEntity( + vehicle, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryPollingRearTrunkEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingRearTrunkEntity( + vehicle, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetrySunroofEntity(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + if vehicle.coordinator.data.get("vehicle_config_sun_roof_installed") + ), ) - for vehicle in entry.runtime_data.vehicles ) -class TeslemetryWindowEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for the windows.""" +class CoverRestoreEntity(RestoreEntity, CoverEntity): + """Restore class for cover entities.""" + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + if state.state == "open": + self._attr_is_closed = False + elif state.state == "closed": + self._attr_is_closed = True + + +class TeslemetryWindowEntity(TeslemetryRootEntity, CoverEntity): + """Base class for window cover entities.""" _attr_device_class = CoverDeviceClass.WINDOW + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + async def async_open_cover(self, **kwargs: Any) -> None: + """Vent windows.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command( + self.api.window_control(command=WindowCommand.VENT) + ) + self._attr_is_closed = False + self.async_write_ha_state() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close windows.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command( + self.api.window_control(command=WindowCommand.CLOSE) + ) + self._attr_is_closed = True + self.async_write_ha_state() + + +class TeslemetryPollingWindowEntity( + TeslemetryVehicleEntity, TeslemetryWindowEntity, CoverEntity +): + """Polling cover entity for windows.""" def __init__(self, data: TeslemetryVehicleData, scopes: list[Scope]) -> None: """Initialize the cover.""" super().__init__(data, "windows") self.scoped = Scope.VEHICLE_CMDS in scopes - self._attr_supported_features = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - ) if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) @@ -67,38 +140,108 @@ class TeslemetryWindowEntity(TeslemetryVehicleEntity, CoverEntity): rd = self.get("vehicle_state_rd_window") rp = self.get("vehicle_state_rp_window") - # Any open set to open if OPEN in (fd, fp, rd, rp): self._attr_is_closed = False - # All closed set to closed - elif CLOSED == fd == fp == rd == rp: + elif None in (fd, fp, rd, rp): + self._attr_is_closed = None + else: self._attr_is_closed = True - async def async_open_cover(self, **kwargs: Any) -> None: - """Vent windows.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command( - self.api.window_control(command=WindowCommand.VENT) + +class TeslemetryStreamingWindowEntity( + TeslemetryVehicleStreamEntity, TeslemetryWindowEntity, CoverRestoreEntity +): + """Streaming cover entity for windows.""" + + fd: bool | None = None + fp: bool | None = None + rd: bool | None = None + rp: bool | None = None + + def __init__(self, data: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the cover.""" + super().__init__( + data, + "windows", ) + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.stream.async_add_listener( + self._handle_stream_update, + {"vin": self.vin, "data": {self.streaming_key: None}}, + ) + ) + for signal in ( + Signal.FD_WINDOW, + Signal.FP_WINDOW, + Signal.RD_WINDOW, + Signal.RP_WINDOW, + ): + self.vehicle.config_entry.async_create_background_task( + self.hass, + self.add_field(signal), + f"Adding field {signal} to {self.vehicle.vin}", + ) + + def _handle_stream_update(self, data) -> None: + """Update the entity attributes.""" + + if value := data.get(Signal.FD_WINDOW): + self.fd = WindowState.get(value) == "closed" + if value := data.get(Signal.FP_WINDOW): + self.fp = WindowState.get(value) == "closed" + if value := data.get(Signal.RD_WINDOW): + self.rd = WindowState.get(value) == "closed" + if value := data.get(Signal.RP_WINDOW): + self.rp = WindowState.get(value) == "closed" + + if False in (self.fd, self.fp, self.rd, self.rp): + self._attr_is_closed = False + elif None in (self.fd, self.fp, self.rd, self.rp): + self._attr_is_closed = None + else: + self._attr_is_closed = True + + self.async_write_ha_state() + + +class TeslemetryChargePortEntity( + TeslemetryRootEntity, + CoverEntity, +): + """Base class for for charge port cover entities.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open charge port.""" + self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS) + + await handle_vehicle_command(self.api.charge_port_door_open()) self._attr_is_closed = False self.async_write_ha_state() async def async_close_cover(self, **kwargs: Any) -> None: - """Close windows.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command( - self.api.window_control(command=WindowCommand.CLOSE) - ) + """Close charge port.""" + self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS) + + await handle_vehicle_command(self.api.charge_port_door_close()) self._attr_is_closed = True self.async_write_ha_state() -class TeslemetryChargePortEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for the charge port.""" - - _attr_device_class = CoverDeviceClass.DOOR +class TeslemetryPollingChargePortEntity( + TeslemetryVehicleEntity, TeslemetryChargePortEntity +): + """Polling cover entity for the charge port.""" def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: """Initialize the cover.""" @@ -117,75 +260,123 @@ class TeslemetryChargePortEntity(TeslemetryVehicleEntity, CoverEntity): """Update the entity attributes.""" self._attr_is_closed = not self._value - async def async_open_cover(self, **kwargs: Any) -> None: - """Open charge port.""" - self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.charge_port_door_open()) - self._attr_is_closed = False - self.async_write_ha_state() - async def async_close_cover(self, **kwargs: Any) -> None: - """Close charge port.""" - self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.charge_port_door_close()) - self._attr_is_closed = True - self.async_write_ha_state() - - -class TeslemetryFrontTrunkEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for the front trunk.""" - - _attr_device_class = CoverDeviceClass.DOOR +class TeslemetryStreamingChargePortEntity( + TeslemetryVehicleStreamEntity, TeslemetryChargePortEntity, CoverRestoreEntity +): + """Streaming cover entity for the charge port.""" def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: - """Initialize the cover.""" - super().__init__(vehicle, "vehicle_state_ft") - - self.scoped = Scope.VEHICLE_CMDS in scopes - self._attr_supported_features = CoverEntityFeature.OPEN + """Initialize the sensor.""" + super().__init__( + vehicle, + "charge_state_charge_port_door_open", + ) + self.scoped = any( + scope in scopes + for scope in (Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS) + ) if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) + self._attr_is_closed = None - def _async_update_attrs(self) -> None: - """Update the entity attributes.""" - self._attr_is_closed = self._value == CLOSED + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.vehicle.stream_vehicle.listen_ChargePortDoorOpen( + self._async_value_from_stream + ) + ) + self.vehicle.config_entry.async_create_background_task( + self.hass, + self.add_field(Signal.CHARGE_PORT_DOOR_OPEN), + f"Adding field {Signal.CHARGE_PORT_DOOR_OPEN} to {self.vehicle.vin}", + ) + + def _async_value_from_stream(self, value: bool | None) -> None: + """Update the value of the entity.""" + self._attr_is_closed = None if value is None else not value + self.async_write_ha_state() + + +class TeslemetryFrontTrunkEntity(TeslemetryRootEntity, CoverEntity): + """Base class for the front trunk cover entities.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN async def async_open_cover(self, **kwargs: Any) -> None: """Open front trunk.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.actuate_trunk(Trunk.FRONT)) self._attr_is_closed = False self.async_write_ha_state() + # In the future this could be extended to add aftermarket close support through a option flow -class TeslemetryRearTrunkEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for the rear trunk.""" - _attr_device_class = CoverDeviceClass.DOOR +class TeslemetryPollingFrontTrunkEntity( + TeslemetryVehicleEntity, TeslemetryFrontTrunkEntity +): + """Polling cover entity for the front trunk.""" def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: """Initialize the cover.""" - super().__init__(vehicle, "vehicle_state_rt") - self.scoped = Scope.VEHICLE_CMDS in scopes - self._attr_supported_features = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - ) if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) + super().__init__(vehicle, "vehicle_state_ft") def _async_update_attrs(self) -> None: """Update the entity attributes.""" self._attr_is_closed = self._value == CLOSED + +class TeslemetryStreamingFrontTrunkEntity( + TeslemetryVehicleStreamEntity, TeslemetryFrontTrunkEntity, CoverRestoreEntity +): + """Streaming cover entity for the front trunk.""" + + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the sensor.""" + super().__init__(vehicle, "vehicle_state_ft") + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.vehicle.stream_vehicle.listen_TrunkFront(self._async_value_from_stream) + ) + self.vehicle.config_entry.async_create_background_task( + self.hass, + self.add_field(Signal.DOOR_STATE), + f"Adding field {Signal.DOOR_STATE} to {self.vehicle.vin}", + ) + + def _async_value_from_stream(self, value: bool | None) -> None: + """Update the entity attributes.""" + + self._attr_is_closed = None if value is None else not value + self.async_write_ha_state() + + +class TeslemetryRearTrunkEntity(TeslemetryRootEntity, CoverEntity): + """Cover entity for the rear trunk.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + async def async_open_cover(self, **kwargs: Any) -> None: """Open rear trunk.""" if self.is_closed is not False: self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.actuate_trunk(Trunk.REAR)) self._attr_is_closed = False self.async_write_ha_state() @@ -194,12 +385,60 @@ class TeslemetryRearTrunkEntity(TeslemetryVehicleEntity, CoverEntity): """Close rear trunk.""" if self.is_closed is not True: self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.actuate_trunk(Trunk.REAR)) self._attr_is_closed = True self.async_write_ha_state() +class TeslemetryPollingRearTrunkEntity( + TeslemetryVehicleEntity, TeslemetryRearTrunkEntity +): + """Base class for the rear trunk cover entities.""" + + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the sensor.""" + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + super().__init__(vehicle, "vehicle_state_rt") + + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + self._attr_is_closed = self._value == CLOSED + + +class TeslemetryStreamingRearTrunkEntity( + TeslemetryVehicleStreamEntity, TeslemetryRearTrunkEntity, CoverRestoreEntity +): + """Polling cover entity for the rear trunk.""" + + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the cover.""" + super().__init__(vehicle, "vehicle_state_rt") + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.vehicle.stream_vehicle.listen_TrunkRear(self._async_value_from_stream) + ) + self.vehicle.config_entry.async_create_background_task( + self.hass, + self.add_field(Signal.DOOR_STATE), + f"Adding field {Signal.DOOR_STATE} to {self.vehicle.vin}", + ) + + def _async_value_from_stream(self, value: bool | None) -> None: + """Update the entity attributes.""" + + self._attr_is_closed = None if value is None else not value + + class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): """Cover entity for the sunroof.""" @@ -210,7 +449,7 @@ class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): _attr_entity_registry_enabled_default = False def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: - """Initialize the sensor.""" + """Initialize the cover.""" super().__init__(vehicle, "vehicle_state_sun_roof_state") self.scoped = Scope.VEHICLE_CMDS in scopes @@ -232,7 +471,6 @@ class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open sunroof.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.VENT)) self._attr_is_closed = False self.async_write_ha_state() @@ -240,7 +478,6 @@ class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close sunroof.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.CLOSE)) self._attr_is_closed = True self.async_write_ha_state() @@ -248,7 +485,6 @@ class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Close sunroof.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.STOP)) self._attr_is_closed = False self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index 5178c543f1a..df8406e0ced 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -3,6 +3,7 @@ from abc import abstractmethod from typing import Any +from propcache import cached_property from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope from teslemetry_stream import Signal @@ -23,18 +24,33 @@ from .helpers import wake_up_vehicle from .models import TeslemetryEnergyData, TeslemetryVehicleData +class TeslemetryRootEntity(Entity): + """Parent class for all Teslemetry entities.""" + + _attr_has_entity_name = True + scoped: bool + api: VehicleSpecific | EnergySpecific + + def raise_for_scope(self, scope: Scope): + """Raise an error if a scope is not available.""" + if not self.scoped: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_scope", + translation_placeholders={"scope": scope}, + ) + + class TeslemetryEntity( + TeslemetryRootEntity, CoordinatorEntity[ TeslemetryVehicleDataCoordinator | TeslemetryEnergyHistoryCoordinator | TeslemetryEnergySiteLiveCoordinator | TeslemetryEnergySiteInfoCoordinator - ] + ], ): - """Parent class for all Teslemetry entities.""" - - _attr_has_entity_name = True - scoped: bool + """Parent class for all Teslemetry Coordinator entities.""" def __init__( self, @@ -84,15 +100,6 @@ class TeslemetryEntity( def _async_update_attrs(self) -> None: """Update the attributes of the entity.""" - def raise_for_scope(self, scope: Scope): - """Raise an error if a scope is not available.""" - if not self.scoped: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="missing_scope", - translation_placeholders={"scope": scope}, - ) - class TeslemetryVehicleEntity(TeslemetryEntity): """Parent class for Teslemetry Vehicle entities.""" @@ -239,13 +246,11 @@ class TeslemetryWallConnectorEntity(TeslemetryEntity): ) -class TeslemetryVehicleStreamEntity(Entity): +class TeslemetryVehicleStreamEntity(TeslemetryRootEntity): """Parent class for Teslemetry Vehicle Stream entities.""" - _attr_has_entity_name = True - def __init__( - self, data: TeslemetryVehicleData, key: str, streaming_key: Signal + self, data: TeslemetryVehicleData, key: str, streaming_key: Signal | None = None ) -> None: """Initialize common aspects of a Teslemetry entity.""" self.streaming_key = streaming_key @@ -263,17 +268,18 @@ class TeslemetryVehicleStreamEntity(Entity): async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() - self.async_on_remove( - self.stream.async_add_listener( - self._handle_stream_update, - {"vin": self.vin, "data": {self.streaming_key: None}}, + if self.streaming_key: + self.async_on_remove( + self.stream.async_add_listener( + self._handle_stream_update, + {"vin": self.vin, "data": {self.streaming_key: None}}, + ) + ) + self.vehicle.config_entry.async_create_background_task( + self.hass, + self.add_field(self.streaming_key), + f"Adding field {self.streaming_key.value} to {self.vehicle.vin}", ) - ) - self.vehicle.config_entry.async_create_background_task( - self.hass, - self.add_field(self.streaming_key), - f"Adding field {self.streaming_key.value} to {self.vehicle.vin}", - ) def _handle_stream_update(self, data: dict[str, Any]) -> None: """Handle updated data from the stream.""" @@ -283,3 +289,8 @@ class TeslemetryVehicleStreamEntity(Entity): def _async_value_from_stream(self, value: Any) -> None: """Update the entity with the latest value from the stream.""" raise NotImplementedError + + @cached_property + def available(self) -> bool: + """Return True if entity is available.""" + return self.stream.connected diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index 547bda4be9b..5b78386c68a 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope -from teslemetry_stream import TeslemetryStream +from teslemetry_stream import TeslemetryStream, TeslemetryStreamVehicle from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceInfo @@ -38,6 +38,7 @@ class TeslemetryVehicleData: config_entry: ConfigEntry coordinator: TeslemetryVehicleDataCoordinator stream: TeslemetryStream + stream_vehicle: TeslemetryStreamVehicle vin: str firmware: str device: DeviceInfo diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index 960e30bce88..e89bab9eff1 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -49,6 +49,15 @@ def mock_vehicle_data() -> Generator[AsyncMock]: yield mock_vehicle_data +@pytest.fixture +def mock_legacy(): + """Mock Tesla Fleet Api products method.""" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.pre2021", return_value=True + ) as mock_pre2021: + yield mock_pre2021 + + @pytest.fixture(autouse=True) def mock_wake_up(): """Mock Tesla Fleet API Vehicle Specific wake_up method.""" diff --git a/tests/components/teslemetry/fixtures/metadata.json b/tests/components/teslemetry/fixtures/metadata.json index 48b9034da00..60282afc934 100644 --- a/tests/components/teslemetry/fixtures/metadata.json +++ b/tests/components/teslemetry/fixtures/metadata.json @@ -16,7 +16,7 @@ "access": true, "polling": true, "proxy": true, - "firmware": "2024.38.7" + "firmware": "2024.44.25" } } } diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json index fcfa0707b2c..0cd238c4e52 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data.json +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -192,7 +192,7 @@ "api_version": 71, "autopark_state_v2": "unavailable", "calendar_supported": true, - "car_version": "2023.44.30.8 06f534d46010", + "car_version": "2024.44.25 06f534d46010", "center_display_state": 0, "dashcam_clip_save_available": true, "dashcam_state": "Recording", diff --git a/tests/components/teslemetry/fixtures/vehicle_data_alt.json b/tests/components/teslemetry/fixtures/vehicle_data_alt.json index 5ef5ea92a74..25b3878f4dd 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data_alt.json +++ b/tests/components/teslemetry/fixtures/vehicle_data_alt.json @@ -190,7 +190,7 @@ "api_version": 71, "autopark_state_v2": "unavailable", "calendar_supported": true, - "car_version": "2023.44.30.8 06f534d46010", + "car_version": "2024.44.25 06f534d46010", "center_display_state": 0, "dashcam_clip_save_available": true, "dashcam_state": "Recording", diff --git a/tests/components/teslemetry/snapshots/test_cover.ambr b/tests/components/teslemetry/snapshots/test_cover.ambr index 24e1b02a5f8..8364f2a6a6e 100644 --- a/tests/components/teslemetry/snapshots/test_cover.ambr +++ b/tests/components/teslemetry/snapshots/test_cover.ambr @@ -335,54 +335,6 @@ 'state': 'open', }) # --- -# name: test_cover_alt[cover.test_sunroof-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'cover', - 'entity_category': None, - 'entity_id': 'cover.test_sunroof', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Sunroof', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': 'vehicle_state_sun_roof_state', - 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_sun_roof_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_cover_alt[cover.test_sunroof-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'window', - 'friendly_name': 'Test Sunroof', - 'supported_features': , - }), - 'context': , - 'entity_id': 'cover.test_sunroof', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_cover_alt[cover.test_trunk-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -719,3 +671,39 @@ 'state': 'closed', }) # --- +# name: test_cover_streaming[cover.test_charge_port_door-closed] + 'closed' +# --- +# name: test_cover_streaming[cover.test_charge_port_door-open] + 'closed' +# --- +# name: test_cover_streaming[cover.test_charge_port_door-unknown] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_frunk-closed] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_frunk-open] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_frunk-unknown] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_trunk-closed] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_trunk-open] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_trunk-unknown] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_windows-closed] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_windows-open] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_windows-unknown] + 'unknown' +# --- diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index 3b96d6f70c0..16cabfddd09 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -352,7 +352,7 @@ 'vehicle_state_api_version': 71, 'vehicle_state_autopark_state_v2': 'unavailable', 'vehicle_state_calendar_supported': True, - 'vehicle_state_car_version': '2023.44.30.8 06f534d46010', + 'vehicle_state_car_version': '2024.44.25 06f534d46010', 'vehicle_state_center_display_state': 0, 'vehicle_state_dashcam_clip_save_available': True, 'vehicle_state_dashcam_state': 'Recording', diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr index 0777f4ccdb9..2411d047135 100644 --- a/tests/components/teslemetry/snapshots/test_update.ambr +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -40,7 +40,7 @@ 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', 'friendly_name': 'Test Update', 'in_progress': False, - 'installed_version': '2023.44.30.8', + 'installed_version': '2024.44.25', 'latest_version': '2024.12.0.0', 'release_summary': None, 'release_url': None, @@ -54,7 +54,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_update_alt[update.test_update-entry] @@ -98,8 +98,8 @@ 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', 'friendly_name': 'Test Update', 'in_progress': False, - 'installed_version': '2023.44.30.8', - 'latest_version': '2023.44.30.8', + 'installed_version': '2024.44.25', + 'latest_version': '2024.44.25', 'release_summary': None, 'release_url': None, 'skipped_version': None, diff --git a/tests/components/teslemetry/test_cover.py b/tests/components/teslemetry/test_cover.py index 7dbdcfa5747..14af1e732fe 100644 --- a/tests/components/teslemetry/test_cover.py +++ b/tests/components/teslemetry/test_cover.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, @@ -25,6 +26,7 @@ async def test_cover( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct.""" @@ -38,6 +40,7 @@ async def test_cover_alt( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct with alternate values.""" @@ -52,6 +55,7 @@ async def test_cover_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct without scopes.""" @@ -215,3 +219,127 @@ async def test_cover_services( state = hass.states.get(entity_id) assert state assert state.state == CoverState.CLOSED + + +async def test_cover_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the binary sensor entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.COVER]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.FD_WINDOW: "WindowStateClosed", + Signal.FP_WINDOW: "WindowStateClosed", + Signal.RD_WINDOW: "WindowStateClosed", + Signal.RP_WINDOW: "WindowStateClosed", + Signal.CHARGE_PORT_DOOR_OPEN: False, + Signal.DOOR_STATE: { + "DoorState": { + "DriverFront": False, + "DriverRear": False, + "PassengerFront": False, + "PassengerRear": False, + "TrunkFront": False, + "TrunkRear": False, + } + }, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Reload the entry + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "cover.test_windows", + "cover.test_charge_port_door", + "cover.test_frunk", + "cover.test_trunk", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-closed") + + # Send some alternative data with everything open + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.FD_WINDOW: "WindowStateOpened", + Signal.FP_WINDOW: "WindowStateOpened", + Signal.RD_WINDOW: "WindowStateOpened", + Signal.RP_WINDOW: "WindowStateOpened", + Signal.CHARGE_PORT_DOOR_OPEN: False, + Signal.DOOR_STATE: { + "DoorState": { + "DriverFront": True, + "DriverRear": True, + "PassengerFront": True, + "PassengerRear": True, + "TrunkFront": True, + "TrunkRear": True, + } + }, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Assert the entities get new values + for entity_id in ( + "cover.test_windows", + "cover.test_charge_port_door", + "cover.test_frunk", + "cover.test_trunk", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-open") + + # Send some alternative data with everything unknown + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.FD_WINDOW: "WindowStateUnknown", + Signal.FP_WINDOW: "WindowStateUnknown", + Signal.RD_WINDOW: "WindowStateUnknown", + Signal.RP_WINDOW: "WindowStateUnknown", + Signal.CHARGE_PORT_DOOR_OPEN: None, + Signal.DOOR_STATE: { + "DoorState": { + "DriverFront": None, + "DriverRear": None, + "PassengerFront": None, + "PassengerRear": None, + "TrunkFront": None, + "TrunkRear": None, + } + }, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Assert the entities get UNKNOWN values + for entity_id in ( + "cover.test_windows", + "cover.test_charge_port_door", + "cover.test_frunk", + "cover.test_trunk", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-unknown") From 44b577cadba104952404505dc08668ccef596aa4 Mon Sep 17 00:00:00 2001 From: jesperraemaekers <146726232+jesperraemaekers@users.noreply.github.com> Date: Fri, 17 Jan 2025 17:43:15 +0100 Subject: [PATCH 0565/2987] Bump Weheat to 2025.1.15 (#135626) --- homeassistant/components/weheat/__init__.py | 3 ++- homeassistant/components/weheat/config_flow.py | 5 ++++- homeassistant/components/weheat/coordinator.py | 5 ++++- homeassistant/components/weheat/manifest.json | 2 +- homeassistant/components/weheat/quality_scale.yaml | 7 ++----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 15 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/weheat/__init__.py b/homeassistant/components/weheat/__init__.py index c14bbbcb028..d8d8616c867 100644 --- a/homeassistant/components/weheat/__init__.py +++ b/homeassistant/components/weheat/__init__.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, @@ -49,7 +50,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bo # fetch a list of the heat pumps the entry can access try: discovered_heat_pumps = await HeatPumpDiscovery.async_discover_active( - API_URL, token + API_URL, token, async_get_clientsession(hass) ) except UnauthorizedException as error: raise ConfigEntryAuthFailed from error diff --git a/homeassistant/components/weheat/config_flow.py b/homeassistant/components/weheat/config_flow.py index 318a02ee47f..2911ebdd49b 100644 --- a/homeassistant/components/weheat/config_flow.py +++ b/homeassistant/components/weheat/config_flow.py @@ -8,6 +8,7 @@ from weheat.abstractions.user import async_get_user_id_from_token from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler from .const import API_URL, DOMAIN, ENTRY_TITLE, OAUTH2_SCOPES @@ -34,7 +35,9 @@ class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): """Override the create entry method to change to the step to find the heat pumps.""" # get the user id and use that as unique id for this entry user_id = await async_get_user_id_from_token( - API_URL, data[CONF_TOKEN][CONF_ACCESS_TOKEN] + API_URL, + data[CONF_TOKEN][CONF_ACCESS_TOKEN], + async_get_clientsession(self.hass), ) await self.async_set_unique_id(user_id) if self.source != SOURCE_REAUTH: diff --git a/homeassistant/components/weheat/coordinator.py b/homeassistant/components/weheat/coordinator.py index 94d897351eb..4a85380e4a3 100644 --- a/homeassistant/components/weheat/coordinator.py +++ b/homeassistant/components/weheat/coordinator.py @@ -16,6 +16,7 @@ from weheat.exceptions import ( from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -47,7 +48,9 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): update_interval=timedelta(seconds=UPDATE_INTERVAL), ) self.heat_pump_info = heat_pump - self._heat_pump_data = HeatPump(API_URL, heat_pump.uuid) + self._heat_pump_data = HeatPump( + API_URL, heat_pump.uuid, async_get_clientsession(hass) + ) self.session = session diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index c81fe570691..1d60f66afba 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", "iot_class": "cloud_polling", - "requirements": ["weheat==2025.1.14"] + "requirements": ["weheat==2025.1.15"] } diff --git a/homeassistant/components/weheat/quality_scale.yaml b/homeassistant/components/weheat/quality_scale.yaml index aa5606ffe2a..705efce4421 100644 --- a/homeassistant/components/weheat/quality_scale.yaml +++ b/homeassistant/components/weheat/quality_scale.yaml @@ -88,9 +88,6 @@ rules: While unlikely to happen. Check if it is easily integrated. # Platinum - async-dependency: - status: todo - comment: | - Dependency uses asyncio.to_thread, but this is not real async. - inject-websession: todo + async-dependency: done + inject-websession: done strict-typing: todo diff --git a/requirements_all.txt b/requirements_all.txt index 56d6c64ca53..231cea8aba6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3033,7 +3033,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.1.14 +weheat==2025.1.15 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 436238fa296..8a669423170 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2437,7 +2437,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.1.14 +weheat==2025.1.15 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.11 From 2a514ebc3f0c583cc83e945cd0b21cfec83e6d13 Mon Sep 17 00:00:00 2001 From: Max R Date: Fri, 17 Jan 2025 11:43:47 -0500 Subject: [PATCH 0566/2987] Update yolink "play on speaker hub" action to allow optional values (to match YoLink API) (#133099) --- homeassistant/components/yolink/services.py | 29 +++++++++++-------- homeassistant/components/yolink/services.yaml | 3 -- homeassistant/components/yolink/strings.json | 2 +- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/yolink/services.py b/homeassistant/components/yolink/services.py index a011d493dc9..8d622de70e7 100644 --- a/homeassistant/components/yolink/services.py +++ b/homeassistant/components/yolink/services.py @@ -19,6 +19,11 @@ from .const import ( SERVICE_PLAY_ON_SPEAKER_HUB = "play_on_speaker_hub" +_SPEAKER_HUB_PLAY_CALL_OPTIONAL_ATTRS = ( + (ATTR_VOLUME, lambda x: x), + (ATTR_TONE, lambda x: x.capitalize()), +) + def async_register_services(hass: HomeAssistant) -> None: """Register services for YoLink integration.""" @@ -46,16 +51,16 @@ def async_register_services(hass: HomeAssistant) -> None: identifier[1] ) ) is not None: - tone_param = service_data[ATTR_TONE].capitalize() - play_request = ClientRequest( - "playAudio", - { - ATTR_TONE: tone_param, - ATTR_TEXT_MESSAGE: service_data[ATTR_TEXT_MESSAGE], - ATTR_VOLUME: service_data[ATTR_VOLUME], - ATTR_REPEAT: service_data[ATTR_REPEAT], - }, - ) + params = { + ATTR_TEXT_MESSAGE: service_data[ATTR_TEXT_MESSAGE], + ATTR_REPEAT: service_data[ATTR_REPEAT], + } + + for attr, transform in _SPEAKER_HUB_PLAY_CALL_OPTIONAL_ATTRS: + if attr in service_data: + params[attr] = transform(service_data[attr]) + + play_request = ClientRequest("playAudio", params) await device_coordinator.device.call_device(play_request) hass.services.async_register( @@ -64,9 +69,9 @@ def async_register_services(hass: HomeAssistant) -> None: schema=vol.Schema( { vol.Required(ATTR_TARGET_DEVICE): cv.string, - vol.Required(ATTR_TONE): cv.string, + vol.Optional(ATTR_TONE): cv.string, vol.Required(ATTR_TEXT_MESSAGE): cv.string, - vol.Required(ATTR_VOLUME): vol.All( + vol.Optional(ATTR_VOLUME): vol.All( vol.Coerce(int), vol.Range(min=0, max=15) ), vol.Optional(ATTR_REPEAT, default=0): vol.All( diff --git a/homeassistant/components/yolink/services.yaml b/homeassistant/components/yolink/services.yaml index 5f7a3ec3122..7375962070e 100644 --- a/homeassistant/components/yolink/services.yaml +++ b/homeassistant/components/yolink/services.yaml @@ -14,7 +14,6 @@ play_on_speaker_hub: selector: text: tone: - required: true default: "tip" selector: select: @@ -25,7 +24,6 @@ play_on_speaker_hub: - "tip" translation_key: speaker_tone volume: - required: true default: 8 selector: number: @@ -33,7 +31,6 @@ play_on_speaker_hub: max: 15 step: 1 repeat: - required: true default: 0 selector: number: diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 2f9a9454502..cbb092405d7 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -115,7 +115,7 @@ }, "volume": { "name": "Volume", - "description": "Speaker volume during playback." + "description": "Override the speaker volume during playback of this message only." }, "repeat": { "name": "Repeat", From 4a64c797d48422bd74256561ffa6bfae2e2b58ff Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Fri, 17 Jan 2025 17:54:15 +0100 Subject: [PATCH 0567/2987] Add doorbell event to homematicip_cloud (#133269) --- .../components/homematicip_cloud/const.py | 1 + .../components/homematicip_cloud/event.py | 94 ++++++++++++++ .../fixtures/homematicip_cloud.json | 119 ++++++++++++++++++ .../homematicip_cloud/test_device.py | 2 +- .../homematicip_cloud/test_event.py | 37 ++++++ 5 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/homematicip_cloud/event.py create mode 100644 tests/components/homematicip_cloud/test_event.py diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index bba67e10d4c..2b72794b323 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -14,6 +14,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.CLIMATE, Platform.COVER, + Platform.EVENT, Platform.LIGHT, Platform.LOCK, Platform.SENSOR, diff --git a/homeassistant/components/homematicip_cloud/event.py b/homeassistant/components/homematicip_cloud/event.py new file mode 100644 index 00000000000..8fb558b2b34 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/event.py @@ -0,0 +1,94 @@ +"""Support for HomematicIP Cloud events.""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from homematicip.aio.device import Device + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import HomematicipGenericEntity +from .hap import HomematicipHAP + + +@dataclass(frozen=True, kw_only=True) +class HmipEventEntityDescription(EventEntityDescription): + """Description of a HomematicIP Cloud event.""" + + +EVENT_DESCRIPTIONS = { + "doorbell": HmipEventEntityDescription( + key="doorbell", + translation_key="doorbell", + device_class=EventDeviceClass.DOORBELL, + event_types=["ring"], + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the HomematicIP cover from a config entry.""" + hap = hass.data[DOMAIN][config_entry.unique_id] + + async_add_entities( + HomematicipDoorBellEvent( + hap, + device, + channel.index, + EVENT_DESCRIPTIONS["doorbell"], + ) + for device in hap.home.devices + for channel in device.functionalChannels + if channel.channelRole == "DOOR_BELL_INPUT" + ) + + +class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity): + """Event class for HomematicIP doorbell events.""" + + _attr_device_class = EventDeviceClass.DOORBELL + + def __init__( + self, + hap: HomematicipHAP, + device: Device, + channel: int, + description: HmipEventEntityDescription, + ) -> None: + """Initialize the event.""" + super().__init__( + hap, + device, + post=description.key, + channel=channel, + is_multi_channel=False, + ) + + self.entity_description = description + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + self.functional_channel.add_on_channel_event_handler(self._async_handle_event) + + @callback + def _async_handle_event(self, *args, **kwargs) -> None: + """Handle the event fired by the functional channel.""" + event_types = self.entity_description.event_types + if TYPE_CHECKING: + assert event_types is not None + + self._trigger_event(event_type=event_types[0]) + self.async_write_ha_state() diff --git a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json index 7a3d3f06b09..ff57cd168c9 100644 --- a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json +++ b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json @@ -8177,6 +8177,125 @@ "serializedGlobalTradeItemNumber": "3014F7110000000000ESIIE3", "type": "ENERGY_SENSORS_INTERFACE", "updateState": "UP_TO_DATE" + }, + "3014F7110000000000DSDPCB": { + "availableFirmwareVersion": "1.0.6", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "firmwareVersion": "1.0.6", + "firmwareVersionInteger": 65542, + "functionalChannels": { + "0": { + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "controlsMountingOrientation": null, + "deviceCommunicationError": null, + "deviceDriveError": null, + "deviceDriveModeError": null, + "deviceId": "3014F7110000000000DSDPCB", + "deviceOperationMode": null, + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "displayContrast": null, + "dutyCycle": false, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": ["00000000-0000-0000-0000-000000000042"], + "index": 0, + "label": "", + "lockJammed": null, + "lowBat": false, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": null, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -58, + "rssiPeerValue": null, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureBusConfigMismatch": false, + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceCommunicationError": false, + "IFeatureDeviceDriveError": false, + "IFeatureDeviceDriveModeError": false, + "IFeatureDeviceIdentify": false, + "IFeatureDeviceOverheated": false, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceParticulateMatterSensorCommunicationError": false, + "IFeatureDeviceParticulateMatterSensorError": false, + "IFeatureDevicePowerFailure": false, + "IFeatureDeviceTemperatureHumiditySensorCommunicationError": false, + "IFeatureDeviceTemperatureHumiditySensorError": false, + "IFeatureDeviceTemperatureOutOfRange": false, + "IFeatureDeviceUndervoltage": false, + "IFeatureMulticastRouter": false, + "IFeaturePowerShortCircuit": false, + "IFeatureProfilePeriodLimit": false, + "IFeatureRssiValue": true, + "IFeatureShortCircuitDataLine": false, + "IOptionalFeatureDeviceErrorLockJammed": false, + "IOptionalFeatureDeviceOperationMode": false, + "IOptionalFeatureDisplayContrast": false, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureLowBat": true, + "IOptionalFeatureMountingOrientation": false + }, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "unreach": false + }, + "1": { + "actionParameter": "NOT_CUSTOMISABLE", + "binaryBehaviorType": "NORMALLY_CLOSE", + "channelRole": "DOOR_BELL_INPUT", + "corrosionPreventionActive": false, + "deviceId": "3014F7110000000000DSDPCB", + "doorBellSensorEventTimestamp": 1673006015756, + "eventDelay": 0, + "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000056"], + "index": 1, + "label": "", + "multiModeInputMode": "KEY_BEHAVIOR", + "supportedOptionalFeatures": { + "IFeatureAccessAuthorizationSensorChannel": false, + "IFeatureGarageGroupSensorChannel": true, + "IFeatureLightGroupSensorChannel": false, + "IFeatureShadingGroupSensorChannel": false, + "IOptionalFeatureDoorBellSensorEventTimestamp": true, + "IOptionalFeatureEventDelay": false, + "IOptionalFeatureLongPressSupported": false, + "IOptionalFeatureWindowState": false + }, + "windowState": "CLOSED" + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F7110000000000DSDPCB", + "label": "dsdpcb_klingel", + "lastStatusUpdate": 1673006015756, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manufacturerCode": 1, + "modelId": 410, + "modelType": "HmIP-DSD-PCB", + "oem": "eQ-3", + "permanentlyReachable": false, + "serializedGlobalTradeItemNumber": "3014F7110000000000DSDPCB", + "type": "DOOR_BELL_CONTACT_INTERFACE", + "updateState": "UP_TO_DATE" } }, "groups": { diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 5b4993f7314..5ec37d8d8f5 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -28,7 +28,7 @@ async def test_hmip_load_all_supported_devices( test_devices=None, test_groups=None ) - assert len(mock_hap.hmip_device_by_entity_id) == 308 + assert len(mock_hap.hmip_device_by_entity_id) == 310 async def test_hmip_remove_device( diff --git a/tests/components/homematicip_cloud/test_event.py b/tests/components/homematicip_cloud/test_event.py new file mode 100644 index 00000000000..de615b35808 --- /dev/null +++ b/tests/components/homematicip_cloud/test_event.py @@ -0,0 +1,37 @@ +"""Tests for the HomematicIP Cloud event.""" + +from homematicip.base.channel_event import ChannelEvent + +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .helper import HomeFactory, get_and_check_entity_basics + + +async def test_door_bell_event( + hass: HomeAssistant, + default_mock_hap_factory: HomeFactory, +) -> None: + """Test of door bell event of HmIP-DSD-PCB.""" + entity_id = "event.dsdpcb_klingel_doorbell" + entity_name = "dsdpcb_klingel doorbell" + device_model = "HmIP-DSD-PCB" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["dsdpcb_klingel"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + ch = hmip_device.functionalChannels[1] + channel_event = ChannelEvent( + channelEventType="DOOR_BELL_SENSOR_EVENT", channelIndex=1, deviceId=ch.device.id + ) + + assert ha_state.state == STATE_UNKNOWN + + ch.fire_channel_event(channel_event) + + ha_state = hass.states.get(entity_id) + assert ha_state.state != STATE_UNKNOWN From c7de3112fba71a149b3b202aa080c3407a0d0ca8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 17 Jan 2025 18:02:33 +0100 Subject: [PATCH 0568/2987] Fix several issues in a string of IHC integration (#135618) --- homeassistant/components/ihc/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ihc/strings.json b/homeassistant/components/ihc/strings.json index af2152a88bb..04daef83c9d 100644 --- a/homeassistant/components/ihc/strings.json +++ b/homeassistant/components/ihc/strings.json @@ -6,7 +6,7 @@ "fields": { "controller_id": { "name": "Controller ID", - "description": "If you have multiple controller, this is the index of you controller\nstarting with 0.\n." + "description": "If you have multiple controllers, this is the index of your controller, starting with 0." }, "ihc_id": { "name": "IHC ID", From ea7e53d10d5e83aa0f9f7d482d05425fc200e77f Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Fri, 17 Jan 2025 18:08:26 +0100 Subject: [PATCH 0569/2987] Add zeroconf dependency to devolo Home Network manifest (#135708) --- homeassistant/components/devolo_home_network/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json index d10e14f9081..9b1e181d7c0 100644 --- a/homeassistant/components/devolo_home_network/manifest.json +++ b/homeassistant/components/devolo_home_network/manifest.json @@ -3,6 +3,7 @@ "name": "devolo Home Network", "codeowners": ["@2Fake", "@Shutgun"], "config_flow": true, + "dependencies": ["zeroconf"], "documentation": "https://www.home-assistant.io/integrations/devolo_home_network", "integration_type": "device", "iot_class": "local_polling", From ca5aca4ab90ccdbc056b0f9171fa6d4755631733 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 17 Jan 2025 18:08:48 +0100 Subject: [PATCH 0570/2987] Fix "set" / "sets" in action names and descriptions, spelling of "dB" (#135659) --- homeassistant/components/kef/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/kef/strings.json b/homeassistant/components/kef/strings.json index c8aa644333a..56fdeaa6704 100644 --- a/homeassistant/components/kef/strings.json +++ b/homeassistant/components/kef/strings.json @@ -39,7 +39,7 @@ "description": "Sets the \"Desk mode\" slider of the speaker in dB.", "fields": { "db_value": { - "name": "DB value", + "name": "dB value", "description": "Value of the slider." } } @@ -75,8 +75,8 @@ } }, "set_low_hz": { - "name": "Sets low Hertz", - "description": "Set the \"Sub out low-pass frequency\" slider of the speaker in Hz.", + "name": "Set low Hertz", + "description": "Sets the \"Sub out low-pass frequency\" slider of the speaker in Hz.", "fields": { "hz_value": { "name": "[%key:component::kef::services::set_high_hz::fields::hz_value::name%]", @@ -85,8 +85,8 @@ } }, "set_sub_db": { - "name": "Sets subwoofer dB", - "description": "Set the \"Sub gain\" slider of the speaker in dB.", + "name": "Set subwoofer dB", + "description": "Sets the \"Sub gain\" slider of the speaker in dB.", "fields": { "db_value": { "name": "[%key:component::kef::services::set_desk_db::fields::db_value::name%]", From a8cb618f96bc1397a58e804141e9a5accb5e78e4 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Fri, 17 Jan 2025 18:09:19 +0100 Subject: [PATCH 0571/2987] Add missing data_descriptions to strings.json for LCN (#135674) --- homeassistant/components/lcn/strings.json | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index 47696719b73..5e69d6810ae 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -30,8 +30,14 @@ "acknowledge": "Request acknowledgement from modules" }, "data_description": { - "dim_mode": "The number of steps used for dimming outputs.", - "acknowledge": "Retry sendig commands if no response is received (increases bus traffic)." + "host": "Name of the LCN integration entry.", + "ip_address": "IP address or hostname of the PCHK server.", + "port": "Port used by the PCHK server.", + "username": "Username for authorization on the PCHK server.", + "password": "Password for authorization on the PCHK server.", + "sk_num_tries": "If you have a segment coupler in your LCN installation, increase this number to at least 3, so all segment couplers are identified correctly.", + "dim_mode": "The number of steps used for dimming outputs of all LCN modules.", + "acknowledge": "Retry sendig commands if no expected response is received from modules (increases bus traffic)." } }, "reconfigure": { @@ -47,6 +53,11 @@ "acknowledge": "[%key:component::lcn::config::step::user::data::acknowledge%]" }, "data_description": { + "ip_address": "[%key:component::lcn::config::step::user::data_description::ip_address%]", + "port": "[%key:component::lcn::config::step::user::data_description::port%]", + "username": "[%key:component::lcn::config::step::user::data_description::username%]", + "password": "[%key:component::lcn::config::step::user::data_description::password%]", + "sk_num_tries": "[%key:component::lcn::config::step::user::data_description::sk_num_tries%]", "dim_mode": "[%key:component::lcn::config::step::user::data_description::dim_mode%]", "acknowledge": "[%key:component::lcn::config::step::user::data_description::acknowledge%]" } From 54e4e8a7bb24bf2ce59fb5c5d133b3a0e7173efe Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Fri, 17 Jan 2025 11:49:33 -0600 Subject: [PATCH 0572/2987] Fix humidifier on off status update (#135743) --- homeassistant/components/vesync/humidifier.py | 4 ++ tests/components/vesync/test_humidifier.py | 70 ++++++++++++++----- 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index 794dbb33e1c..aef92f73ea5 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -149,12 +149,16 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): if not success: raise HomeAssistantError("An error occurred while turning on.") + self.schedule_update_ha_state() + def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" success = self.device.turn_off() if not success: raise HomeAssistantError("An error occurred while turning off.") + self.schedule_update_ha_state() + @property def is_on(self) -> bool: """Return True if device is on.""" diff --git a/tests/components/vesync/test_humidifier.py b/tests/components/vesync/test_humidifier.py index e3ab42993db..3b89ba8e742 100644 --- a/tests/components/vesync/test_humidifier.py +++ b/tests/components/vesync/test_humidifier.py @@ -108,41 +108,73 @@ async def test_set_target_humidity( @pytest.mark.parametrize( - ("turn_on", "api_response", "expectation"), - [ - (False, False, pytest.raises(HomeAssistantError)), - (False, True, NoException), - (True, False, pytest.raises(HomeAssistantError)), - (True, True, NoException), - ], + ("api_response", "expectation"), + [(False, pytest.raises(HomeAssistantError)), (True, NoException)], ) -async def test_turn_on_off( +async def test_turn_on( hass: HomeAssistant, humidifier_config_entry: MockConfigEntry, - turn_on: bool, api_response: bool, expectation, ) -> None: - """Test turn_on/off methods.""" + """Test turn_on method.""" - # turn_on/turn_off returns False indicating failure in which case humidifier.turn_on/turn_off + # turn_on returns False indicating failure in which case humidifier.turn_on # raises HomeAssistantError. with ( expectation, patch( - f"pyvesync.vesyncfan.VeSyncHumid200300S.{'turn_on' if turn_on else 'turn_off'}", - return_value=api_response, + "pyvesync.vesyncfan.VeSyncHumid200300S.turn_on", return_value=api_response ) as method_mock, ): - await hass.services.async_call( - HUMIDIFIER_DOMAIN, - SERVICE_TURN_ON if turn_on else SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER}, - blocking=True, - ) + with patch( + "homeassistant.components.vesync.humidifier.VeSyncHumidifierHA.schedule_update_ha_state" + ) as update_mock: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER}, + blocking=True, + ) await hass.async_block_till_done() method_mock.assert_called_once() + update_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(False, pytest.raises(HomeAssistantError)), (True, NoException)], +) +async def test_turn_off( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + api_response: bool, + expectation, +) -> None: + """Test turn_off method.""" + + # turn_off returns False indicating failure in which case humidifier.turn_off + # raises HomeAssistantError. + with ( + expectation, + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.turn_off", return_value=api_response + ) as method_mock, + ): + with patch( + "homeassistant.components.vesync.humidifier.VeSyncHumidifierHA.schedule_update_ha_state" + ) as update_mock: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + update_mock.assert_called_once() async def test_set_mode_invalid( From 14f3868c26b548525df2657369d6b1072dbb14a9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 17 Jan 2025 19:08:32 +0100 Subject: [PATCH 0573/2987] Fix flaky test in acmeda (#135846) --- tests/components/acmeda/conftest.py | 10 ++++++++++ tests/components/acmeda/test_config_flow.py | 17 ++++------------- tests/components/acmeda/test_cover.py | 3 +++ tests/components/acmeda/test_sensor.py | 3 +++ 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/tests/components/acmeda/conftest.py b/tests/components/acmeda/conftest.py index 2c980351c09..4a803711959 100644 --- a/tests/components/acmeda/conftest.py +++ b/tests/components/acmeda/conftest.py @@ -1,5 +1,8 @@ """Define fixtures available for all Acmeda tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + import pytest from homeassistant.components.acmeda.const import DOMAIN @@ -18,3 +21,10 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: ) mock_config_entry.add_to_hass(hass) return mock_config_entry + + +@pytest.fixture +def mock_hub_run() -> Generator[AsyncMock]: + """Mock the hub run method.""" + with patch("homeassistant.components.acmeda.hub.aiopulse.Hub.run") as mock_run: + yield mock_run diff --git a/tests/components/acmeda/test_config_flow.py b/tests/components/acmeda/test_config_flow.py index 5227d283f25..9fb0d7e645b 100644 --- a/tests/components/acmeda/test_config_flow.py +++ b/tests/components/acmeda/test_config_flow.py @@ -28,13 +28,6 @@ def mock_hub_discover(): yield mock_discover -@pytest.fixture -def mock_hub_run(): - """Mock the hub run method.""" - with patch("aiopulse.Hub.run") as mock_run: - yield mock_run - - async def async_generator(items): """Async yields items provided in a list.""" for item in items: @@ -56,9 +49,8 @@ async def test_show_form_no_hubs(hass: HomeAssistant, mock_hub_discover) -> None assert len(mock_hub_discover.mock_calls) == 1 -async def test_show_form_one_hub( - hass: HomeAssistant, mock_hub_discover, mock_hub_run -) -> None: +@pytest.mark.usesfixtures("mock_hub_run") +async def test_show_form_one_hub(hass: HomeAssistant, mock_hub_discover) -> None: """Test that a config is created when one hub discovered.""" dummy_hub_1 = aiopulse.Hub(DUMMY_HOST1) @@ -102,9 +94,8 @@ async def test_show_form_two_hubs(hass: HomeAssistant, mock_hub_discover) -> Non assert len(mock_hub_discover.mock_calls) == 1 -async def test_create_second_entry( - hass: HomeAssistant, mock_hub_run, mock_hub_discover -) -> None: +@pytest.mark.usesfixtures("mock_hub_run") +async def test_create_second_entry(hass: HomeAssistant, mock_hub_discover) -> None: """Test that a config is created when a second hub is discovered.""" dummy_hub_1 = aiopulse.Hub(DUMMY_HOST1) diff --git a/tests/components/acmeda/test_cover.py b/tests/components/acmeda/test_cover.py index 0d908ecc915..f4acc270065 100644 --- a/tests/components/acmeda/test_cover.py +++ b/tests/components/acmeda/test_cover.py @@ -1,5 +1,7 @@ """Define tests for the Acmeda config flow.""" +import pytest + from homeassistant.components.acmeda.const import DOMAIN from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.core import HomeAssistant @@ -8,6 +10,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry +@pytest.mark.usesfixtures("mock_hub_run") async def test_cover_id_migration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/acmeda/test_sensor.py b/tests/components/acmeda/test_sensor.py index 3d7090ce7dd..a7e60ec7187 100644 --- a/tests/components/acmeda/test_sensor.py +++ b/tests/components/acmeda/test_sensor.py @@ -1,5 +1,7 @@ """Define tests for the Acmeda config flow.""" +import pytest + from homeassistant.components.acmeda.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -8,6 +10,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry +@pytest.mark.usesfixtures("mock_hub_run") async def test_sensor_id_migration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 028a0d4eece6422e19d3eacf463737015ddaff87 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 17 Jan 2025 19:10:56 +0100 Subject: [PATCH 0574/2987] Remove call to get_serial_by_id in homeassistant_sky_connect (#135751) --- .../components/homeassistant_sky_connect/config_flow.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index ffd6c6bd004..b3b4f68ba96 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -144,11 +144,8 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler( self, ) -> silabs_multiprotocol_addon.SerialPortSettings: """Return the radio serial port settings.""" - usb_dev = self.config_entry.data["device"] - # The call to get_serial_by_id can be removed in HA Core 2024.1 - dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, usb_dev) return silabs_multiprotocol_addon.SerialPortSettings( - device=dev_path, + device=self.config_entry.data["device"], baudrate="115200", flow_control=True, ) From 235fda55fe97c14b1146262beb61a2a97ff05ea2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jan 2025 19:18:13 +0100 Subject: [PATCH 0575/2987] Validate config entry when adding or updating entity registry entry (#135067) --- homeassistant/helpers/entity_registry.py | 8 +++ tests/components/asuswrt/test_sensor.py | 3 +- tests/components/google/test_calendar.py | 2 + tests/components/honeywell/test_climate.py | 1 + tests/components/hue/test_migration.py | 2 + .../test_config_flow.py | 2 +- tests/components/ring/test_init.py | 2 +- .../rituals_perfume_genie/test_init.py | 1 + tests/components/stookwijzer/test_init.py | 1 + .../utility_meter/test_config_flow.py | 1 + tests/helpers/test_entity_platform.py | 3 ++ tests/helpers/test_entity_registry.py | 51 +++++++++++++++++-- tests/helpers/test_template.py | 1 + 13 files changed, 71 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index a810eb89558..3e8c57562b2 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -648,6 +648,7 @@ def _validate_item( domain: str, platform: str, *, + config_entry_id: str | None | UndefinedType = None, device_id: str | None | UndefinedType = None, disabled_by: RegistryEntryDisabler | None | UndefinedType = None, entity_category: EntityCategory | None | UndefinedType = None, @@ -672,6 +673,11 @@ def _validate_item( unique_id, report_issue, ) + if config_entry_id and config_entry_id is not UNDEFINED: + if not hass.config_entries.async_get_entry(config_entry_id): + raise ValueError( + f"Can't link entity to unknown config entry {config_entry_id}" + ) if device_id and device_id is not UNDEFINED: device_registry = dr.async_get(hass) if not device_registry.async_get(device_id): @@ -864,6 +870,7 @@ class EntityRegistry(BaseRegistry): self.hass, domain, platform, + config_entry_id=config_entry_id, device_id=device_id, disabled_by=disabled_by, entity_category=entity_category, @@ -1096,6 +1103,7 @@ class EntityRegistry(BaseRegistry): self.hass, old.domain, old.platform, + config_entry_id=config_entry_id, device_id=device_id, disabled_by=disabled_by, entity_category=entity_category, diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index 0036c40a6f2..929500f0bb7 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -82,6 +82,7 @@ def _setup_entry(hass: HomeAssistant, config, sensors, unique_id=None): options={CONF_CONSIDER_HOME: 60}, unique_id=unique_id, ) + config_entry.add_to_hass(hass) # init variable obj_prefix = slugify(HOST) @@ -131,8 +132,6 @@ async def _test_sensors( disabled_by=None, ) - config_entry.add_to_hass(hass) - # initial devices setup assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 6ce95a2bc17..305f30d99d4 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -751,6 +751,7 @@ async def test_unique_id_migration( old_unique_id, ) -> None: """Test that old unique id format is migrated to the new format that supports multiple accounts.""" + config_entry.add_to_hass(hass) # Create an entity using the old unique id format entity_registry.async_get_or_create( DOMAIN, @@ -805,6 +806,7 @@ async def test_invalid_unique_id_cleanup( mock_calendars_yaml, ) -> None: """Test that old unique id format that is not actually unique is removed.""" + config_entry.add_to_hass(hass) # Create an entity using the old unique id format entity_registry.async_get_or_create( DOMAIN, diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 57cdfaa9a23..7411a40e74a 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -1200,6 +1200,7 @@ async def test_unique_id( entity_registry: er.EntityRegistry, ) -> None: """Test unique id convert to string.""" + config_entry.add_to_hass(hass) entity_registry.async_get_or_create( Platform.CLIMATE, DOMAIN, diff --git a/tests/components/hue/test_migration.py b/tests/components/hue/test_migration.py index 388e2f68f99..7b00630f573 100644 --- a/tests/components/hue/test_migration.py +++ b/tests/components/hue/test_migration.py @@ -166,6 +166,7 @@ async def test_group_entity_migration_with_v1_id( ) -> None: """Test if entity schema for grouped_lights migrates from v1 to v2.""" config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 + config_entry.add_to_hass(hass) # create (deviceless) entity with V1 schema in registry # using the legacy style group id as unique id @@ -201,6 +202,7 @@ async def test_group_entity_migration_with_v2_group_id( ) -> None: """Test if entity schema for grouped_lights migrates from v1 to v2.""" config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 + config_entry.add_to_hass(hass) # create (deviceless) entity with V1 schema in registry # using the V2 group id as unique id diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py index 42589bb10e0..9952e838600 100644 --- a/tests/components/hunterdouglas_powerview/test_config_flow.py +++ b/tests/components/hunterdouglas_powerview/test_config_flow.py @@ -368,6 +368,7 @@ async def test_migrate_entry( version=1, minor_version=1, ) + entry.add_to_hass(hass) # Add entries with int unique_id entity_registry.async_get_or_create( @@ -387,7 +388,6 @@ async def test_migrate_entry( assert entry.version == 1 assert entry.minor_version == 1 - entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 27d4813f02d..7c3b93e5114 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -444,6 +444,7 @@ async def test_no_listen_start( version=1, data={"username": "foo", "token": {}}, ) + mock_entry.add_to_hass(hass) # Create a binary sensor entity so it is not ignored by the deprecation check # and the listener will start entity_registry.async_get_or_create( @@ -457,7 +458,6 @@ async def test_no_listen_start( mock_ring_event_listener_class.return_value.started = False - mock_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/rituals_perfume_genie/test_init.py b/tests/components/rituals_perfume_genie/test_init.py index 435e762a646..d4d7376a564 100644 --- a/tests/components/rituals_perfume_genie/test_init.py +++ b/tests/components/rituals_perfume_genie/test_init.py @@ -46,6 +46,7 @@ async def test_entity_id_migration( ) -> None: """Test the migration of unique IDs on config entry setup.""" config_entry = mock_config_entry(unique_id="binary_sensor_test_diffuser_v1") + config_entry.add_to_hass(hass) # Pre-create old style unique IDs charging = entity_registry.async_get_or_create( diff --git a/tests/components/stookwijzer/test_init.py b/tests/components/stookwijzer/test_init.py index 0df9b55d1a9..ddefb6be772 100644 --- a/tests/components/stookwijzer/test_init.py +++ b/tests/components/stookwijzer/test_init.py @@ -100,6 +100,7 @@ async def test_entity_entry_migration( entity_registry: er.EntityRegistry, ) -> None: """Test successful migration of entry data.""" + mock_config_entry.add_to_hass(hass) entity = entity_registry.async_get_or_create( suggested_object_id="advice", disabled_by=None, diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index 560566d7c49..4901e069aee 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -374,6 +374,7 @@ async def test_change_device_source( # Configure source entity 3 (without a device) source_config_entry_3 = MockConfigEntry() + source_config_entry_3.add_to_hass(hass) source_entity_3 = entity_registry.async_get_or_create( "sensor", "test", diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index e80006dff84..7c9244583e9 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -869,6 +869,7 @@ async def test_setup_entry( platform = MockPlatform(async_setup_entry=async_setup_entry) config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) entity_platform = MockEntityPlatform( hass, platform_name=config_entry.domain, platform=platform ) @@ -1886,6 +1887,7 @@ async def test_setup_entry_with_entities_that_block_forever( platform = MockPlatform(async_setup_entry=async_setup_entry) config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) platform = MockEntityPlatform( hass, platform_name=config_entry.domain, platform=platform ) @@ -1934,6 +1936,7 @@ async def test_cancellation_is_not_blocked( platform = MockPlatform(async_setup_entry=async_setup_entry) config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) platform = MockEntityPlatform( hass, platform_name=config_entry.domain, platform=platform ) diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 682f7843453..19289b09f95 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -616,11 +616,13 @@ async def test_updating_config_entry_id( """Test that we update config entry id in registry.""" update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) mock_config_1 = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config_1.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config_1 ) mock_config_2 = MockConfigEntry(domain="light", entry_id="mock-id-2") + mock_config_2.add_to_hass(hass) entry2 = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config_2 ) @@ -647,6 +649,7 @@ async def test_removing_config_entry_id( """Test that we update config entry id in registry.""" update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config @@ -670,11 +673,14 @@ async def test_removing_config_entry_id( async def test_deleted_entity_removing_config_entry_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, ) -> None: """Test that we update config entry id in registry on deleted entity.""" mock_config1 = MockConfigEntry(domain="light", entry_id="mock-id-1") mock_config2 = MockConfigEntry(domain="light", entry_id="mock-id-2") + mock_config1.add_to_hass(hass) + mock_config2.add_to_hass(hass) entry1 = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config1 @@ -979,9 +985,12 @@ async def test_migration_1_11( } -async def test_update_entity_unique_id(entity_registry: er.EntityRegistry) -> None: +async def test_update_entity_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity's unique_id is updated.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config @@ -1007,10 +1016,12 @@ async def test_update_entity_unique_id(entity_registry: er.EntityRegistry) -> No async def test_update_entity_unique_id_conflict( + hass: HomeAssistant, entity_registry: er.EntityRegistry, ) -> None: """Test migration raises when unique_id already in use.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config ) @@ -1099,9 +1110,12 @@ async def test_update_entity_entity_id_entity_id( assert entity_registry.async_get(state_entity_id) is None -async def test_update_entity(entity_registry: er.EntityRegistry) -> None: +async def test_update_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test updating entity.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config ) @@ -1126,9 +1140,12 @@ async def test_update_entity(entity_registry: er.EntityRegistry) -> None: entry = updated_entry -async def test_update_entity_options(entity_registry: er.EntityRegistry) -> None: +async def test_update_entity_options( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test updating entity.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config ) @@ -1181,6 +1198,7 @@ async def test_disabled_by(entity_registry: er.EntityRegistry) -> None: async def test_disabled_by_config_entry_pref( + hass: HomeAssistant, entity_registry: er.EntityRegistry, ) -> None: """Test config entry preference setting disabled_by.""" @@ -1189,6 +1207,7 @@ async def test_disabled_by_config_entry_pref( entry_id="mock-id-1", pref_disable_new_entities=True, ) + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "AAAA", config_entry=mock_config ) @@ -1761,6 +1780,25 @@ def test_entity_registry_items() -> None: assert entities.get_entry(entry2.id) is None +async def test_config_entry_does_not_exist(entity_registry: er.EntityRegistry) -> None: + """Test adding an entity linked to an unknown config entry.""" + mock_config = MockConfigEntry( + domain="light", + entry_id="mock-id-1", + pref_disable_new_entities=True, + ) + with pytest.raises(ValueError): + entity_registry.async_get_or_create( + "light", "hue", "1234", config_entry=mock_config + ) + + entity_id = entity_registry.async_get_or_create("light", "hue", "1234").entity_id + with pytest.raises(ValueError): + entity_registry.async_update_entity( + entity_id, config_entry_id=mock_config.entry_id + ) + + async def test_device_does_not_exist(entity_registry: er.EntityRegistry) -> None: """Test adding an entity linked to an unknown device.""" with pytest.raises(ValueError): @@ -1848,6 +1886,7 @@ def test_migrate_entity_to_new_platform( ) -> None: """Test migrate_entity_to_new_platform.""" orig_config_entry = MockConfigEntry(domain="light") + orig_config_entry.add_to_hass(hass) orig_unique_id = "5678" orig_entry = entity_registry.async_get_or_create( @@ -1870,6 +1909,7 @@ def test_migrate_entity_to_new_platform( ) new_config_entry = MockConfigEntry(domain="light") + new_config_entry.add_to_hass(hass) new_unique_id = "1234" assert entity_registry.async_update_entity_platform( @@ -1924,6 +1964,7 @@ async def test_restore_entity( """Make sure entity registry id is stable and entity_id is reused if possible.""" update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry ) @@ -2018,6 +2059,8 @@ async def test_async_migrate_entry_delete_self( """Test async_migrate_entry.""" config_entry1 = MockConfigEntry(domain="test1") config_entry2 = MockConfigEntry(domain="test2") + config_entry1.add_to_hass(hass) + config_entry2.add_to_hass(hass) entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry1, original_name="Entry 1" ) @@ -2053,6 +2096,8 @@ async def test_async_migrate_entry_delete_other( """Test async_migrate_entry.""" config_entry1 = MockConfigEntry(domain="test1") config_entry2 = MockConfigEntry(domain="test2") + config_entry1.add_to_hass(hass) + config_entry2.add_to_hass(hass) entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry1, original_name="Entry 1" ) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index ab0f126eaa9..37e886dddce 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -2126,6 +2126,7 @@ async def test_state_translated( hass.states.async_set("domain.is_unknown", "unknown", attributes={}) config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) entity_registry.async_get_or_create( "light", "hue", From 2ec971ad9d31bb1eadb159ddcbcab440a28a4bd0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 17 Jan 2025 19:21:13 +0100 Subject: [PATCH 0576/2987] Remove not needed name from config flow in SMHI (#134841) --- homeassistant/components/smhi/__init__.py | 12 +++++-- homeassistant/components/smhi/config_flow.py | 8 ++--- homeassistant/components/smhi/weather.py | 9 ++---- tests/components/smhi/__init__.py | 3 +- tests/components/smhi/test_config_flow.py | 8 ++--- tests/components/smhi/test_init.py | 34 +++++++++----------- tests/components/smhi/test_weather.py | 18 +++++------ 7 files changed, 41 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index 94bdfcc4559..59b32948879 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -32,6 +32,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" + + if entry.version > 3: + # Downgrade from future version + return False + if entry.version == 1: new_data = { CONF_NAME: entry.data[CONF_NAME], @@ -40,8 +45,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONF_LONGITUDE: entry.data[CONF_LONGITUDE], }, } + hass.config_entries.async_update_entry(entry, data=new_data, version=2) - if not hass.config_entries.async_update_entry(entry, data=new_data, version=2): - return False + if entry.version == 2: + new_data = entry.data.copy() + new_data.pop(CONF_NAME) + hass.config_entries.async_update_entry(entry, data=new_data, version=3) return True diff --git a/homeassistant/components/smhi/config_flow.py b/homeassistant/components/smhi/config_flow.py index 2992b176f24..2521df3a333 100644 --- a/homeassistant/components/smhi/config_flow.py +++ b/homeassistant/components/smhi/config_flow.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers import ( aiohttp_client, @@ -38,7 +38,7 @@ async def async_check_location( class SmhiFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for SMHI component.""" - VERSION = 2 + VERSION = 3 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -58,10 +58,6 @@ class SmhiFlowHandler(ConfigFlow, domain=DOMAIN): ): name = HOME_LOCATION_NAME - user_input[CONF_NAME] = ( - HOME_LOCATION_NAME if name == HOME_LOCATION_NAME else DEFAULT_NAME - ) - await self.async_set_unique_id(f"{lat}-{lon}") self._abort_if_unique_id_configured() return self.async_create_entry(title=name, data=user_input) diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index 3d5642a2784..d43ca4465ae 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -48,7 +48,6 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, - CONF_NAME, UnitOfLength, UnitOfPrecipitationDepth, UnitOfPressure, @@ -60,7 +59,7 @@ from homeassistant.helpers import aiohttp_client, sun from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.util import Throttle, dt as dt_util, slugify +from homeassistant.util import Throttle, dt as dt_util from .const import ATTR_SMHI_THUNDER_PROBABILITY, DOMAIN, ENTITY_ID_SENSOR_FORMAT @@ -103,17 +102,15 @@ async def async_setup_entry( ) -> None: """Add a weather entity from map location.""" location = config_entry.data - name = slugify(location[CONF_NAME]) session = aiohttp_client.async_get_clientsession(hass) entity = SmhiWeather( - location[CONF_NAME], location[CONF_LOCATION][CONF_LATITUDE], location[CONF_LOCATION][CONF_LONGITUDE], session=session, ) - entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(name) + entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(config_entry.title) async_add_entities([entity], True) @@ -136,7 +133,6 @@ class SmhiWeather(WeatherEntity): def __init__( self, - name: str, latitude: str, longitude: str, session: aiohttp.ClientSession, @@ -152,7 +148,6 @@ class SmhiWeather(WeatherEntity): identifiers={(DOMAIN, f"{latitude}, {longitude}")}, manufacturer="SMHI", model="v2", - name=name, configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html", ) diff --git a/tests/components/smhi/__init__.py b/tests/components/smhi/__init__.py index a0bbf854699..0e65d288737 100644 --- a/tests/components/smhi/__init__.py +++ b/tests/components/smhi/__init__.py @@ -2,7 +2,6 @@ ENTITY_ID = "weather.smhi_test" TEST_CONFIG = { - "name": "test", "location": { "longitude": "17.84197", "latitude": "59.32624", @@ -11,5 +10,5 @@ TEST_CONFIG = { TEST_CONFIG_MIGRATE = { "name": "test", "longitude": "17.84197", - "latitude": "17.84197", + "latitude": "59.32624", } diff --git a/tests/components/smhi/test_config_flow.py b/tests/components/smhi/test_config_flow.py index 4195d1e5d52..362adebe416 100644 --- a/tests/components/smhi/test_config_flow.py +++ b/tests/components/smhi/test_config_flow.py @@ -57,7 +57,6 @@ async def test_form(hass: HomeAssistant) -> None: "latitude": 0.0, "longitude": 0.0, }, - "name": "Home", } assert len(mock_setup_entry.mock_calls) == 1 @@ -93,7 +92,6 @@ async def test_form(hass: HomeAssistant) -> None: "latitude": 1.0, "longitude": 1.0, }, - "name": "Weather", } @@ -150,7 +148,6 @@ async def test_form_invalid_coordinates(hass: HomeAssistant) -> None: "latitude": 2.0, "longitude": 2.0, }, - "name": "Weather", } @@ -201,8 +198,8 @@ async def test_reconfigure_flow( domain=DOMAIN, title="Home", unique_id="57.2898-13.6304", - data={"location": {"latitude": 57.2898, "longitude": 13.6304}, "name": "Home"}, - version=2, + data={"location": {"latitude": 57.2898, "longitude": 13.6304}}, + version=3, ) entry.add_to_hass(hass) @@ -269,7 +266,6 @@ async def test_reconfigure_flow( "latitude": 58.2898, "longitude": 14.6304, }, - "name": "Home", } entity = entity_registry.async_get(entity.entity_id) assert entity diff --git a/tests/components/smhi/test_init.py b/tests/components/smhi/test_init.py index cfb386c8f6f..d00742d4900 100644 --- a/tests/components/smhi/test_init.py +++ b/tests/components/smhi/test_init.py @@ -1,10 +1,9 @@ """Test SMHI component setup process.""" -from unittest.mock import patch - from smhi.smhi_lib import APIURL_TEMPLATE from homeassistant.components.smhi.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -22,7 +21,7 @@ async def test_setup_entry( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain=DOMAIN, title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -40,7 +39,7 @@ async def test_remove_entry( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain=DOMAIN, title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -77,7 +76,7 @@ async def test_migrate_entry( original_name="Weather", platform="smhi", supported_features=0, - unique_id="17.84197, 17.84197", + unique_id="59.32624, 17.84197", ) await hass.config_entries.async_setup(entry.entry_id) @@ -86,30 +85,27 @@ async def test_migrate_entry( state = hass.states.get(entity.entity_id) assert state - assert entry.version == 2 - assert entry.unique_id == "17.84197-17.84197" + assert entry.version == 3 + assert entry.unique_id == "59.32624-17.84197" + assert entry.data == TEST_CONFIG entity_get = entity_registry.async_get(entity.entity_id) - assert entity_get.unique_id == "17.84197, 17.84197" + assert entity_get.unique_id == "59.32624, 17.84197" -async def test_migrate_entry_failed( +async def test_migrate_from_future_version( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: - """Test migrate entry data that fails.""" + """Test migrate entry not possible from future version.""" uri = APIURL_TEMPLATE.format( TEST_CONFIG_MIGRATE["longitude"], TEST_CONFIG_MIGRATE["latitude"] ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG_MIGRATE) + entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG_MIGRATE, version=4) entry.add_to_hass(hass) - assert entry.version == 1 + assert entry.version == 4 - with patch( - "homeassistant.config_entries.ConfigEntries.async_update_entry", - return_value=False, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert entry.version == 1 + assert entry.state == ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index 1870d7b498a..cc6902710bd 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -49,7 +49,7 @@ async def test_setup_hass( ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -80,7 +80,7 @@ async def test_clear_night( ) aioclient_mock.get(uri, text=api_response_night) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -105,7 +105,7 @@ async def test_clear_night( async def test_properties_no_data(hass: HomeAssistant) -> None: """Test properties when no API data available.""" - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) with patch( @@ -193,7 +193,7 @@ async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: testdata = [data, data2, data3] - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) with ( @@ -232,7 +232,7 @@ async def test_refresh_weather_forecast_retry( hass: HomeAssistant, error: Exception ) -> None: """Test the refresh weather forecast function.""" - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) now = dt_util.utcnow() @@ -357,7 +357,7 @@ async def test_custom_speed_unit( ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -394,7 +394,7 @@ async def test_forecast_services( ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -458,7 +458,7 @@ async def test_forecast_services_lack_of_data( ) aioclient_mock.get(uri, text=api_response_lack_data) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -503,7 +503,7 @@ async def test_forecast_service( ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) From abc256fb3e1163859e77be5d478912b0205ea21b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 17 Jan 2025 19:22:48 +0100 Subject: [PATCH 0577/2987] Add overload for async singleton call with HassKey (#134059) --- homeassistant/components/esphome/dashboard.py | 9 ++- homeassistant/helpers/singleton.py | 70 ++++++++++++++++--- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index b0a37aefd0d..334c16e5730 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -12,6 +12,7 @@ from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN from .coordinator import ESPHomeDashboardCoordinator @@ -19,7 +20,9 @@ from .coordinator import ESPHomeDashboardCoordinator _LOGGER = logging.getLogger(__name__) -KEY_DASHBOARD_MANAGER = "esphome_dashboard_manager" +KEY_DASHBOARD_MANAGER: HassKey[ESPHomeDashboardManager] = HassKey( + "esphome_dashboard_manager" +) STORAGE_KEY = "esphome.dashboard" STORAGE_VERSION = 1 @@ -33,7 +36,7 @@ async def async_setup(hass: HomeAssistant) -> None: await async_get_or_create_dashboard_manager(hass) -@singleton(KEY_DASHBOARD_MANAGER) +@singleton(KEY_DASHBOARD_MANAGER, async_=True) async def async_get_or_create_dashboard_manager( hass: HomeAssistant, ) -> ESPHomeDashboardManager: @@ -140,7 +143,7 @@ def async_get_dashboard(hass: HomeAssistant) -> ESPHomeDashboardCoordinator | No where manager can be an asyncio.Event instead of the actual manager because the singleton decorator is not yet done. """ - manager: ESPHomeDashboardManager | None = hass.data.get(KEY_DASHBOARD_MANAGER) + manager = hass.data.get(KEY_DASHBOARD_MANAGER) return manager.async_get() if manager else None diff --git a/homeassistant/helpers/singleton.py b/homeassistant/helpers/singleton.py index 20e4ee82162..075fc50b49a 100644 --- a/homeassistant/helpers/singleton.py +++ b/homeassistant/helpers/singleton.py @@ -3,15 +3,22 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Callable, Coroutine import functools -from typing import Any, cast, overload +from typing import Any, Literal, assert_type, cast, overload from homeassistant.core import HomeAssistant from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey type _FuncType[_T] = Callable[[HomeAssistant], _T] +type _Coro[_T] = Coroutine[Any, Any, _T] + + +@overload +def singleton[_T]( + data_key: HassKey[_T], *, async_: Literal[True] +) -> Callable[[_FuncType[_Coro[_T]]], _FuncType[_Coro[_T]]]: ... @overload @@ -24,29 +31,37 @@ def singleton[_T]( def singleton[_T](data_key: str) -> Callable[[_FuncType[_T]], _FuncType[_T]]: ... -def singleton[_T](data_key: Any) -> Callable[[_FuncType[_T]], _FuncType[_T]]: +def singleton[_S, _T, _U]( + data_key: Any, *, async_: bool = False +) -> Callable[[_FuncType[_S]], _FuncType[_S]]: """Decorate a function that should be called once per instance. Result will be cached and simultaneous calls will be handled. """ - def wrapper(func: _FuncType[_T]) -> _FuncType[_T]: + @overload + def wrapper(func: _FuncType[_Coro[_T]]) -> _FuncType[_Coro[_T]]: ... + + @overload + def wrapper(func: _FuncType[_U]) -> _FuncType[_U]: ... + + def wrapper(func: _FuncType[_Coro[_T] | _U]) -> _FuncType[_Coro[_T] | _U]: """Wrap a function with caching logic.""" if not asyncio.iscoroutinefunction(func): @functools.lru_cache(maxsize=1) @bind_hass @functools.wraps(func) - def wrapped(hass: HomeAssistant) -> _T: + def wrapped(hass: HomeAssistant) -> _U: if data_key not in hass.data: hass.data[data_key] = func(hass) - return cast(_T, hass.data[data_key]) + return cast(_U, hass.data[data_key]) return wrapped @bind_hass @functools.wraps(func) - async def async_wrapped(hass: HomeAssistant) -> Any: + async def async_wrapped(hass: HomeAssistant) -> _T: if data_key not in hass.data: evt = hass.data[data_key] = asyncio.Event() result = await func(hass) @@ -62,6 +77,45 @@ def singleton[_T](data_key: Any) -> Callable[[_FuncType[_T]], _FuncType[_T]]: return cast(_T, obj_or_evt) - return async_wrapped # type: ignore[return-value] + return async_wrapped return wrapper + + +async def _test_singleton_typing(hass: HomeAssistant) -> None: + """Test singleton overloads work as intended. + + This is tested during the mypy run. Do not move it to 'tests'! + """ + # Test HassKey + key = HassKey[int]("key") + + @singleton(key) + def func(hass: HomeAssistant) -> int: + return 2 + + @singleton(key, async_=True) + async def async_func(hass: HomeAssistant) -> int: + return 2 + + assert_type(func(hass), int) + assert_type(await async_func(hass), int) + + # Test invalid use of 'async_' with sync function + @singleton(key, async_=True) # type: ignore[arg-type] + def func_error(hass: HomeAssistant) -> int: + return 2 + + # Test string key + other_key = "key" + + @singleton(other_key) + def func2(hass: HomeAssistant) -> str: + return "" + + @singleton(other_key) + async def async_func2(hass: HomeAssistant) -> str: + return "" + + assert_type(func2(hass), str) + assert_type(await async_func2(hass), str) From 5ea54130644fb904f40655066ed754cceaeaa499 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Fri, 17 Jan 2025 20:49:01 +0100 Subject: [PATCH 0578/2987] Remove device_class from NFC and fingerprint event descriptions (#135867) --- homeassistant/components/unifiprotect/event.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py index c8bce183e34..78fdf7746de 100644 --- a/homeassistant/components/unifiprotect/event.py +++ b/homeassistant/components/unifiprotect/event.py @@ -181,7 +181,6 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = ( ProtectEventEntityDescription( key="nfc", translation_key="nfc", - device_class=EventDeviceClass.DOORBELL, icon="mdi:nfc", ufp_required_field="feature_flags.support_nfc", ufp_event_obj="last_nfc_card_scanned_event", @@ -191,7 +190,6 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = ( ProtectEventEntityDescription( key="fingerprint", translation_key="fingerprint", - device_class=EventDeviceClass.DOORBELL, icon="mdi:fingerprint", ufp_required_field="feature_flags.has_fingerprint_sensor", ufp_event_obj="last_fingerprint_identified_event", From c601170b1d901c4a577f49aa61c6d2e91a1c26ab Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 17 Jan 2025 21:01:05 +0100 Subject: [PATCH 0579/2987] Use new ServiceInfo location in devolo_home_network (#135690) --- homeassistant/components/devolo_home_network/config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/devolo_home_network/config_flow.py b/homeassistant/components/devolo_home_network/config_flow.py index 7c8dccd1a7b..bd2f23d602f 100644 --- a/homeassistant/components/devolo_home_network/config_flow.py +++ b/homeassistant/components/devolo_home_network/config_flow.py @@ -15,6 +15,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import DevoloHomeNetworkConfigEntry from .const import DOMAIN, PRODUCT, SERIAL_NUMBER, TITLE @@ -81,7 +82,7 @@ class DevoloHomeNetworkConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" if discovery_info.properties["MT"] in ["2600", "2601"]: From 9868138fc433db6f682005e226c7529021da4a16 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jan 2025 11:53:29 -1000 Subject: [PATCH 0580/2987] Bump aioesphomeapi to 28.0.1 (#135869) --- 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 b04fa4db428..f56f8342df6 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==28.0.0", + "aioesphomeapi==28.0.1", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.0.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 231cea8aba6..b438a5ecf06 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==28.0.0 +aioesphomeapi==28.0.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a669423170..4bb072f4611 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==28.0.0 +aioesphomeapi==28.0.1 # homeassistant.components.flo aioflo==2021.11.0 From 2b0e383b2e8dbd2b3d833fdd5fbf6145dfd0278e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 17 Jan 2025 22:56:59 +0100 Subject: [PATCH 0581/2987] Use new ServiceInfo location in zha (#135703) --- homeassistant/components/zha/config_flow.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 5cb67489423..d41ae7dbfee 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -14,7 +14,7 @@ from zha.application.const import RadioType import zigpy.backups from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH -from homeassistant.components import onboarding, usb, zeroconf +from homeassistant.components import onboarding, usb from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import AddonError, AddonState from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon @@ -35,6 +35,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.selector import FileSelector, FileSelectorConfig +from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util import dt as dt_util from .const import CONF_BAUDRATE, CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN @@ -586,9 +588,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): description_placeholders={CONF_NAME: self._title}, ) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle usb discovery.""" vid = discovery_info.vid pid = discovery_info.pid @@ -623,7 +623,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): return await self.async_step_confirm() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" @@ -649,7 +649,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): fallback_title = name.split("._", 1)[0] title = discovery_info.properties.get("name", fallback_title) - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=discovery_info.ip_address, ip_addresses=discovery_info.ip_addresses, port=port, From a08e42399dc8d38be4883148e933d04c7225cb85 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jan 2025 12:04:53 -1000 Subject: [PATCH 0582/2987] Bump fnv-hash-fast to 1.2.2 (#135872) --- homeassistant/components/homekit/manifest.json | 2 +- homeassistant/components/recorder/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, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 74b1f96b26e..d7ea293b5dc 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -10,7 +10,7 @@ "loggers": ["pyhap"], "requirements": [ "HAP-python==4.9.2", - "fnv-hash-fast==1.1.0", + "fnv-hash-fast==1.2.2", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index a0f559fcc13..d3b6e52ad11 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -8,7 +8,7 @@ "quality_scale": "internal", "requirements": [ "SQLAlchemy==2.0.36", - "fnv-hash-fast==1.1.0", + "fnv-hash-fast==1.2.2", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index fa75884bcba..a28b42e6a07 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.0 dbus-fast==2.29.0 -fnv-hash-fast==1.1.0 +fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.8.0 diff --git a/pyproject.toml b/pyproject.toml index 7d6fb154375..a1a60728920 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "certifi>=2021.5.30", "ciso8601==2.3.2", "cronsim==2.6", - "fnv-hash-fast==1.1.0", + "fnv-hash-fast==1.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration "hass-nabucasa==0.88.1", diff --git a/requirements.txt b/requirements.txt index 9f30ea84ad7..82c8736cf84 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ bcrypt==4.2.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 -fnv-hash-fast==1.1.0 +fnv-hash-fast==1.2.2 hass-nabucasa==0.88.1 httpx==0.27.2 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index b438a5ecf06..cd747aac16f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -934,7 +934,7 @@ flux-led==1.1.0 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.1.0 +fnv-hash-fast==1.2.2 # homeassistant.components.foobot foobot_async==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4bb072f4611..3696fd9dfc1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -793,7 +793,7 @@ flux-led==1.1.0 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.1.0 +fnv-hash-fast==1.2.2 # homeassistant.components.foobot foobot_async==1.0.0 From b98e1a1d2f6aeb1d9ebd2771ba1169894fa04c67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jan 2025 12:05:41 -1000 Subject: [PATCH 0583/2987] Bump habluetooth to 3.9.0 (#135877) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 4ed6b4b3821..1374520881a 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.20.0", "dbus-fast==2.29.0", - "habluetooth==3.8.0" + "habluetooth==3.9.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a28b42e6a07..ab5b4ffa4b0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ dbus-fast==2.29.0 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.8.0 +habluetooth==3.9.0 hass-nabucasa==0.88.1 hassil==2.1.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index cd747aac16f..edc48e9366c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1097,7 +1097,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.8.0 +habluetooth==3.9.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3696fd9dfc1..4fcb42297b9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -938,7 +938,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.8.0 +habluetooth==3.9.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 From 51d277fc0cb6a2ea1347b2cc3104cb128f329464 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jan 2025 12:06:01 -1000 Subject: [PATCH 0584/2987] Bump bluetooth-data-tools to 1.22.0 (#135879) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 1374520881a..5403421ae21 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.6.0", "bluetooth-adapters==0.20.2", "bluetooth-auto-recovery==1.4.2", - "bluetooth-data-tools==1.20.0", + "bluetooth-data-tools==1.22.0", "dbus-fast==2.29.0", "habluetooth==3.9.0" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index d3e21eeae90..2e64a590eaf 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.20.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.22.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 5aefad4d429..24e986000bb 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -38,5 +38,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.20.0", "led-ble==1.1.1"] + "requirements": ["bluetooth-data-tools==1.22.0", "led-ble==1.1.1"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 6759cdda0f0..2ab736b02d3 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.20.0"] + "requirements": ["bluetooth-data-tools==1.22.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ab5b4ffa4b0..d2af5f7160c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ bleak-retry-connector==3.6.0 bleak==0.22.3 bluetooth-adapters==0.20.2 bluetooth-auto-recovery==1.4.2 -bluetooth-data-tools==1.20.0 +bluetooth-data-tools==1.22.0 cached-ipaddress==0.8.0 certifi>=2021.5.30 ciso8601==2.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index edc48e9366c..09ed8a9ae74 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -628,7 +628,7 @@ bluetooth-auto-recovery==1.4.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.20.0 +bluetooth-data-tools==1.22.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4fcb42297b9..a178009d301 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -552,7 +552,7 @@ bluetooth-auto-recovery==1.4.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.20.0 +bluetooth-data-tools==1.22.0 # homeassistant.components.bond bond-async==0.2.1 From 174f3ca755d53ed0bd4fa9c1a7711c92c0ad5e65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jan 2025 12:06:28 -1000 Subject: [PATCH 0585/2987] Bump ulid-transform to 1.2.0 (#135882) --- 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 d2af5f7160c..61c098032a9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -65,7 +65,7 @@ SQLAlchemy==2.0.36 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 -ulid-transform==1.0.2 +ulid-transform==1.2.0 urllib3>=1.26.5,<2 uv==0.5.18 voluptuous-openapi==0.0.6 diff --git a/pyproject.toml b/pyproject.toml index a1a60728920..a98957df068 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dependencies = [ "standard-aifc==3.13.0;python_version>='3.13'", "standard-telnetlib==3.13.0;python_version>='3.13'", "typing-extensions>=4.12.2,<5.0", - "ulid-transform==1.0.2", + "ulid-transform==1.2.0", # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 diff --git a/requirements.txt b/requirements.txt index 82c8736cf84..6b934c101e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,7 +43,7 @@ SQLAlchemy==2.0.36 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 -ulid-transform==1.0.2 +ulid-transform==1.2.0 urllib3>=1.26.5,<2 uv==0.5.18 voluptuous==0.15.2 From fc1b6292cd20f6d40f2d9e2c67e4357315578133 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jan 2025 13:05:18 -1000 Subject: [PATCH 0586/2987] Bump dbus-fast to 2.30.2 (#135874) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 5403421ae21..8d596648e52 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bluetooth-adapters==0.20.2", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", - "dbus-fast==2.29.0", + "dbus-fast==2.30.2", "habluetooth==3.9.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 61c098032a9..e55fdef7e8d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.0 -dbus-fast==2.29.0 +dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 09ed8a9ae74..3b87d3d194b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -732,7 +732,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.29.0 +dbus-fast==2.30.2 # homeassistant.components.debugpy debugpy==1.8.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a178009d301..3878f867205 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -628,7 +628,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.29.0 +dbus-fast==2.30.2 # homeassistant.components.debugpy debugpy==1.8.11 From 43fe4ebbbe9fcb96346ae2b5126f4e039b18f418 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jan 2025 14:08:17 -1000 Subject: [PATCH 0587/2987] Prevent HomeKit from going unavailable when min/max is reversed (#135892) --- .../components/homekit/type_lights.py | 7 +- .../components/homekit/type_thermostats.py | 12 +- homeassistant/components/homekit/util.py | 11 ++ tests/components/homekit/test_type_lights.py | 150 ++++++++++++++++++ .../homekit/test_type_thermostats.py | 56 ++++++- 5 files changed, 225 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index eec35fcc82e..212b3228154 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -52,6 +52,7 @@ from .const import ( PROP_MIN_VALUE, SERV_LIGHTBULB, ) +from .util import get_min_max _LOGGER = logging.getLogger(__name__) @@ -120,12 +121,14 @@ class Light(HomeAccessory): self.char_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100) if CHAR_COLOR_TEMPERATURE in self.chars: - self.min_mireds = color_temperature_kelvin_to_mired( + min_mireds = color_temperature_kelvin_to_mired( attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN, DEFAULT_MAX_COLOR_TEMP) ) - self.max_mireds = color_temperature_kelvin_to_mired( + max_mireds = color_temperature_kelvin_to_mired( attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN, DEFAULT_MIN_COLOR_TEMP) ) + # Ensure min is less than max + self.min_mireds, self.max_mireds = get_min_max(min_mireds, max_mireds) if not self.color_temp_supported and not self.rgbww_supported: self.max_mireds = self.min_mireds self.char_color_temp = serv_light.configure_char( diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 91bab2d470a..4dda495ce77 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -14,6 +14,7 @@ from homeassistant.components.climate import ( ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, + ATTR_MAX_HUMIDITY, ATTR_MAX_TEMP, ATTR_MIN_HUMIDITY, ATTR_MIN_TEMP, @@ -21,6 +22,7 @@ from homeassistant.components.climate import ( ATTR_SWING_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + DEFAULT_MAX_HUMIDITY, DEFAULT_MAX_TEMP, DEFAULT_MIN_HUMIDITY, DEFAULT_MIN_TEMP, @@ -90,7 +92,7 @@ from .const import ( SERV_FANV2, SERV_THERMOSTAT, ) -from .util import temperature_to_homekit, temperature_to_states +from .util import get_min_max, temperature_to_homekit, temperature_to_states _LOGGER = logging.getLogger(__name__) @@ -208,7 +210,10 @@ class Thermostat(HomeAccessory): self.fan_chars: list[str] = [] attributes = state.attributes - min_humidity = attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) + min_humidity, _ = get_min_max( + attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY), + attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY), + ) features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) if features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: @@ -839,6 +844,9 @@ def _get_temperature_range_from_state( else: max_temp = default_max + # Handle reversed temperature range + min_temp, max_temp = get_min_max(min_temp, max_temp) + # Homekit only supports 10-38, overwriting # the max to appears to work, but less than 0 causes # a crash on the home app diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index cd659654617..a0dfcea7616 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -656,3 +656,14 @@ def state_changed_event_is_same_state(event: Event[EventStateChangedData]) -> bo old_state = event_data["old_state"] new_state = event_data["new_state"] return bool(new_state and old_state and new_state.state == old_state.state) + + +def get_min_max(value1: float, value2: float) -> tuple[float, float]: + """Return the minimum and maximum of two values. + + HomeKit will go unavailable if the min and max are reversed + so we make sure the min is always the min and the max is always the max + as any mistakes made in integrations will cause the entire + bridge to go unavailable. + """ + return min(value1, value2), max(value1, value2) diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 53a661c1c83..c1870cecd9c 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -807,6 +807,156 @@ async def test_light_invalid_values( assert acc.char_saturation.value == 95 +async def test_light_out_of_range_color_temp(hass: HomeAssistant, hk_driver) -> None: + """Test light with an out of range color temp.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_COLOR_TEMP_KELVIN: 2000, + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_HS_COLOR: (-1, -1), + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 333 + assert acc.char_color_temp.properties[PROP_MAX_VALUE] == 333 + assert acc.char_color_temp.properties[PROP_MIN_VALUE] == 250 + assert acc.char_hue.value == 31 + assert acc.char_saturation.value == 95 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: -1, + }, + ) + await hass.async_block_till_done() + acc.run() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 16 + assert acc.char_saturation.value == 100 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: sys.maxsize, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: 2000, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + +async def test_reversed_color_temp_min_max(hass: HomeAssistant, hk_driver) -> None: + """Test light with a reversed color temp min max.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_COLOR_TEMP_KELVIN: 2000, + ATTR_MAX_COLOR_TEMP_KELVIN: 3000, + ATTR_MIN_COLOR_TEMP_KELVIN: 4000, + ATTR_HS_COLOR: (-1, -1), + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 333 + assert acc.char_color_temp.properties[PROP_MAX_VALUE] == 333 + assert acc.char_color_temp.properties[PROP_MIN_VALUE] == 250 + assert acc.char_hue.value == 31 + assert acc.char_saturation.value == 95 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: -1, + }, + ) + await hass.async_block_till_done() + acc.run() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 16 + assert acc.char_saturation.value == 100 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: sys.maxsize, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: 2000, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + @pytest.mark.parametrize( "supported_color_modes", [[ColorMode.HS], [ColorMode.RGB], [ColorMode.XY]] ) diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index e99db8f6234..fc4cfa78ca4 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -26,6 +26,7 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_STEP, DEFAULT_MAX_TEMP, DEFAULT_MIN_HUMIDITY, + DEFAULT_MIN_TEMP, DOMAIN as DOMAIN_CLIMATE, FAN_AUTO, FAN_HIGH, @@ -2009,8 +2010,8 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], - ATTR_MAX_TEMP: 50, - ATTR_MIN_TEMP: 100, + ATTR_MAX_TEMP: 100, + ATTR_MIN_TEMP: 50, } hass.states.async_set( entity_id, @@ -2024,14 +2025,14 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No acc.run() await hass.async_block_till_done() - assert acc.char_cooling_thresh_temp.value == 100 - assert acc.char_heating_thresh_temp.value == 100 + assert acc.char_cooling_thresh_temp.value == 50 + assert acc.char_heating_thresh_temp.value == 50 assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == 100 - assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 100 + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 50 assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == 100 - assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 100 + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 50 assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 assert acc.char_target_heat_cool.value == 3 @@ -2048,7 +2049,7 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No }, ) await hass.async_block_till_done() - assert acc.char_heating_thresh_temp.value == 100.0 + assert acc.char_heating_thresh_temp.value == 50.0 assert acc.char_cooling_thresh_temp.value == 100.0 assert acc.char_current_heat_cool.value == 1 assert acc.char_target_heat_cool.value == 3 @@ -2633,3 +2634,44 @@ async def test_thermostat_handles_unknown_state(hass: HomeAssistant, hk_driver) assert call_set_hvac_mode assert call_set_hvac_mode[1].data[ATTR_ENTITY_ID] == entity_id assert call_set_hvac_mode[1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_thermostat_reversed_min_max(hass: HomeAssistant, hk_driver) -> None: + """Test reversed min/max temperatures.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.HEAT_COOL, + HVACMode.FAN_ONLY, + HVACMode.COOL, + HVACMode.OFF, + HVACMode.AUTO, + ], + ATTR_MAX_TEMP: DEFAULT_MAX_TEMP, + ATTR_MIN_TEMP: DEFAULT_MIN_TEMP, + } + # support_auto = True + hass.states.async_set( + entity_id, + HVACMode.OFF, + base_attrs, + ) + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_heating_thresh_temp.value == 19.0 + + assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 + assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 From 089c9c41baf7c244dc361254e61710616ce20c49 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Sat, 18 Jan 2025 01:23:25 +0100 Subject: [PATCH 0588/2987] Add BThome hold press event (#135871) * add hold_press * add hold_press * add hold_press * add hold_press --- homeassistant/components/bthome/device_trigger.py | 1 + homeassistant/components/bthome/event.py | 1 + homeassistant/components/bthome/strings.json | 4 +++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bthome/device_trigger.py b/homeassistant/components/bthome/device_trigger.py index d60089a9bf5..6d194714c64 100644 --- a/homeassistant/components/bthome/device_trigger.py +++ b/homeassistant/components/bthome/device_trigger.py @@ -42,6 +42,7 @@ EVENT_TYPES_BY_EVENT_CLASS = { "long_press", "long_double_press", "long_triple_press", + "hold_press", }, EVENT_CLASS_DIMMER: {"rotate_left", "rotate_right"}, } diff --git a/homeassistant/components/bthome/event.py b/homeassistant/components/bthome/event.py index 128d1e8388f..a6ee79f4e05 100644 --- a/homeassistant/components/bthome/event.py +++ b/homeassistant/components/bthome/event.py @@ -36,6 +36,7 @@ DESCRIPTIONS_BY_EVENT_CLASS = { "long_press", "long_double_press", "long_triple_press", + "hold_press", ], device_class=EventDeviceClass.BUTTON, ), diff --git a/homeassistant/components/bthome/strings.json b/homeassistant/components/bthome/strings.json index c64028229b3..daf969ba80f 100644 --- a/homeassistant/components/bthome/strings.json +++ b/homeassistant/components/bthome/strings.json @@ -37,6 +37,7 @@ "long_press": "Long Press", "long_double_press": "Long Double Press", "long_triple_press": "Long Triple Press", + "hold_press": "Hold Press", "rotate_right": "Rotate Right", "rotate_left": "Rotate Left" }, @@ -56,7 +57,8 @@ "triple_press": "Triple press", "long_press": "Long press", "long_double_press": "Long double press", - "long_triple_press": "Long triple press" + "long_triple_press": "Long triple press", + "hold_press": "Hold press" } } } From bbe897745efa88f227552ace8d73cd7d65557bfd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jan 2025 19:30:21 -1000 Subject: [PATCH 0589/2987] Bump onvif-zeep-async to 3.2.2 (#135898) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index c4f030ebe9f..281f0fb60ee 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==3.1.13", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==3.2.2", "WSDiscovery==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3b87d3d194b..9d7c04d3350 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1552,7 +1552,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onvif -onvif-zeep-async==3.1.13 +onvif-zeep-async==3.2.2 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3878f867205..00037644b25 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1300,7 +1300,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onvif -onvif-zeep-async==3.1.13 +onvif-zeep-async==3.2.2 # homeassistant.components.opengarage open-garage==0.2.0 From f724ae9a01433ffda9eb74c6fcc1c35243ab6698 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Sat, 18 Jan 2025 02:33:49 -0500 Subject: [PATCH 0590/2987] Record IQS for Russound RNET (#134692) --- CODEOWNERS | 1 + .../components/russound_rnet/manifest.json | 2 +- .../russound_rnet/quality_scale.yaml | 95 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 4 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/russound_rnet/quality_scale.yaml diff --git a/CODEOWNERS b/CODEOWNERS index cbf471b1680..09032e379fd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1289,6 +1289,7 @@ build.json @home-assistant/supervisor /tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 /homeassistant/components/russound_rio/ @noahhusby /tests/components/russound_rio/ @noahhusby +/homeassistant/components/russound_rnet/ @noahhusby /homeassistant/components/ruuvi_gateway/ @akx /tests/components/ruuvi_gateway/ @akx /homeassistant/components/ruuvitag_ble/ @akx diff --git a/homeassistant/components/russound_rnet/manifest.json b/homeassistant/components/russound_rnet/manifest.json index 27fbfbca57f..58925b4b1ff 100644 --- a/homeassistant/components/russound_rnet/manifest.json +++ b/homeassistant/components/russound_rnet/manifest.json @@ -1,7 +1,7 @@ { "domain": "russound_rnet", "name": "Russound RNET", - "codeowners": [], + "codeowners": ["@noahhusby"], "documentation": "https://www.home-assistant.io/integrations/russound_rnet", "iot_class": "local_polling", "loggers": ["russound"], diff --git a/homeassistant/components/russound_rnet/quality_scale.yaml b/homeassistant/components/russound_rnet/quality_scale.yaml new file mode 100644 index 00000000000..b82ef6f4643 --- /dev/null +++ b/homeassistant/components/russound_rnet/quality_scale.yaml @@ -0,0 +1,95 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: todo + brands: done + common-modules: todo + config-flow-test-coverage: todo + config-flow: todo + dependency-transparency: + status: todo + comment: | + CI pipeline for publishing is not on GH repo. + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: todo + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: todo + entity-unique-id: todo + has-entity-name: todo + runtime-data: todo + test-before-configure: todo + test-before-setup: todo + unique-config-entry: todo + + # Silver + action-exceptions: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: todo + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + The device does not support discovery. + discovery: + status: exempt + comment: | + The device does not support discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration is not a hub and only represents a single device. + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: + status: exempt + comment: | + There are no entities to translate. + exception-translations: todo + icon-translations: + status: exempt + comment: | + There are no entities that require icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. An issue will be implemented for yaml import once a config flow is added. + stale-devices: + status: exempt + comment: | + This integration is not a hub and only represents a single device. + + # Platinum + async-dependency: todo + inject-websession: + status: exempt + comment: | + This integration uses telnet or serial exclusively and does not make http calls. + strict-typing: todo diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 4de646e10e9..7ca7110c49b 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -874,7 +874,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "rtorrent", "rtsp_to_webrtc", "ruckus_unleashed", - "russound_rnet", "ruuvi_gateway", "ruuvitag_ble", "rympro", From 06d8bc658ffc3898524fe3efc69ec1cd8ff97f93 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 18 Jan 2025 01:39:40 -0800 Subject: [PATCH 0591/2987] Fix typo in Opower log message (#135909) --- homeassistant/components/opower/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 36e23c4098e..f6f3524d630 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -103,7 +103,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): try: accounts = await self.api.async_get_accounts() except aiohttp.ClientError as err: - _LOGGER.error("Error getting forecasts: %s", err) + _LOGGER.error("Error getting accounts: %s", err) raise for account in accounts: id_prefix = "_".join( From c56eee3639ac02d447bd3d26721c537a41ae9013 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 18 Jan 2025 11:10:52 +0100 Subject: [PATCH 0592/2987] Fix bmw_connected_drive tests (#135911) --- tests/components/bmw_connected_drive/test_select.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py index 53c39f572f2..878edefac27 100644 --- a/tests/components/bmw_connected_drive/test_select.py +++ b/tests/components/bmw_connected_drive/test_select.py @@ -138,13 +138,6 @@ async def test_service_call_invalid_input( HomeAssistantError, REMOTE_SERVICE_EXC_TRANSLATION, ), - ( - ServiceValidationError( - "Option 17 is not valid for entity select.i4_edrive40_ac_charging_limit" - ), - ServiceValidationError, - "Option 17 is not valid for entity select.i4_edrive40_ac_charging_limit", - ), ], ) async def test_service_call_fail( From f01598aaddc96721c1746173554f2b329dbb1f46 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 18 Jan 2025 02:14:31 -0800 Subject: [PATCH 0593/2987] Use runtime_data in Opower (#135910) * Use runtime_data in Opower * Fix async_unload_entry * Fix async_unload_entry * fix --- homeassistant/components/opower/__init__.py | 15 +++++++-------- homeassistant/components/opower/sensor.py | 8 +++++--- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/opower/__init__.py b/homeassistant/components/opower/__init__.py index 1a34d0547aa..136a1a4e57a 100644 --- a/homeassistant/components/opower/__init__.py +++ b/homeassistant/components/opower/__init__.py @@ -6,27 +6,26 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN from .coordinator import OpowerCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type OpowerConfigEntry = ConfigEntry[OpowerCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: OpowerConfigEntry) -> bool: """Set up Opower from a config entry.""" coordinator = OpowerCoordinator(hass, entry.data) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = 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: OpowerConfigEntry) -> 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(entry, PLATFORMS) diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 05a22dfbf1b..7f8eb22d1e6 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -21,6 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import OpowerConfigEntry from .const import DOMAIN from .coordinator import OpowerCoordinator @@ -183,11 +183,13 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: OpowerConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Opower sensor.""" - coordinator: OpowerCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities: list[OpowerSensor] = [] forecasts = coordinator.data.values() for forecast in forecasts: From 76d9bcbdfb5e9cfed35e3547e08e0628904ae2c0 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 18 Jan 2025 11:17:58 +0100 Subject: [PATCH 0594/2987] Set parallel-updates in Habitica quality scale record (#135901) --- homeassistant/components/habitica/binary_sensor.py | 2 ++ homeassistant/components/habitica/calendar.py | 2 ++ homeassistant/components/habitica/image.py | 2 ++ homeassistant/components/habitica/quality_scale.yaml | 2 +- homeassistant/components/habitica/sensor.py | 3 +++ 5 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/habitica/binary_sensor.py b/homeassistant/components/habitica/binary_sensor.py index bf42348e2b8..5e3040e0606 100644 --- a/homeassistant/components/habitica/binary_sensor.py +++ b/homeassistant/components/habitica/binary_sensor.py @@ -19,6 +19,8 @@ from .const import ASSETS_URL from .entity import HabiticaBase from .types import HabiticaConfigEntry +PARALLEL_UPDATES = 1 + @dataclass(kw_only=True, frozen=True) class HabiticaBinarySensorEntityDescription(BinarySensorEntityDescription): diff --git a/homeassistant/components/habitica/calendar.py b/homeassistant/components/habitica/calendar.py index e890dfa9123..4a9b1579d3a 100644 --- a/homeassistant/components/habitica/calendar.py +++ b/homeassistant/components/habitica/calendar.py @@ -26,6 +26,8 @@ from .coordinator import HabiticaDataUpdateCoordinator from .entity import HabiticaBase from .util import build_rrule, get_recurrence_rule +PARALLEL_UPDATES = 1 + class HabiticaCalendar(StrEnum): """Habitica calendars.""" diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py index 27b406c475c..f1dbbc64d41 100644 --- a/homeassistant/components/habitica/image.py +++ b/homeassistant/components/habitica/image.py @@ -16,6 +16,8 @@ from . import HabiticaConfigEntry from .coordinator import HabiticaDataUpdateCoordinator from .entity import HabiticaBase +PARALLEL_UPDATES = 1 + class HabiticaImageEntity(StrEnum): """Image entities.""" diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml index f1023e3d0dc..195a36aa547 100644 --- a/homeassistant/components/habitica/quality_scale.yaml +++ b/homeassistant/components/habitica/quality_scale.yaml @@ -33,7 +33,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: done test-coverage: done diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index f969b1344d9..57c391f5c12 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -52,6 +52,9 @@ SVG_CLASS = { } +PARALLEL_UPDATES = 1 + + @dataclass(kw_only=True, frozen=True) class HabiticaSensorEntityDescription(SensorEntityDescription): """Habitica Sensor Description.""" From 88f16807a02c7bbc6778b82c2689d69e2bad3a01 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 18 Jan 2025 21:38:20 +1000 Subject: [PATCH 0595/2987] Bump Teslemetry Stream to 0.6.6 (#135905) bump66 --- homeassistant/components/teslemetry/cover.py | 15 --------------- homeassistant/components/teslemetry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 3 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index c4fbae7b0bc..4cc15b6feb8 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -288,11 +288,6 @@ class TeslemetryStreamingChargePortEntity( self._async_value_from_stream ) ) - self.vehicle.config_entry.async_create_background_task( - self.hass, - self.add_field(Signal.CHARGE_PORT_DOOR_OPEN), - f"Adding field {Signal.CHARGE_PORT_DOOR_OPEN} to {self.vehicle.vin}", - ) def _async_value_from_stream(self, value: bool | None) -> None: """Update the value of the entity.""" @@ -353,11 +348,6 @@ class TeslemetryStreamingFrontTrunkEntity( self.async_on_remove( self.vehicle.stream_vehicle.listen_TrunkFront(self._async_value_from_stream) ) - self.vehicle.config_entry.async_create_background_task( - self.hass, - self.add_field(Signal.DOOR_STATE), - f"Adding field {Signal.DOOR_STATE} to {self.vehicle.vin}", - ) def _async_value_from_stream(self, value: bool | None) -> None: """Update the entity attributes.""" @@ -427,11 +417,6 @@ class TeslemetryStreamingRearTrunkEntity( self.async_on_remove( self.vehicle.stream_vehicle.listen_TrunkRear(self._async_value_from_stream) ) - self.vehicle.config_entry.async_create_background_task( - self.hass, - self.add_field(Signal.DOOR_STATE), - f"Adding field {Signal.DOOR_STATE} to {self.vehicle.vin}", - ) def _async_value_from_stream(self, value: bool | None) -> None: """Update the entity attributes.""" diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 35f974bd95c..7a3d0905ea1 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.2", "teslemetry-stream==0.6.3"] + "requirements": ["tesla-fleet-api==0.9.2", "teslemetry-stream==0.6.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9d7c04d3350..2348a0ec994 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2856,7 +2856,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.3 +teslemetry-stream==0.6.6 # homeassistant.components.tessie tessie-api==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00037644b25..c40c8b95635 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2296,7 +2296,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.3 +teslemetry-stream==0.6.6 # homeassistant.components.tessie tessie-api==0.1.1 From f5dd3ef5301e2b9103cb1d6bad67e5fdc432d976 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 18 Jan 2025 12:59:23 +0100 Subject: [PATCH 0596/2987] Increase test coverage in Habitica integration (#135896) Add tests to Habitica integration --- .../components/habitica/quality_scale.yaml | 4 +- tests/components/habitica/test_config_flow.py | 62 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml index 195a36aa547..ba139ea241b 100644 --- a/homeassistant/components/habitica/quality_scale.yaml +++ b/homeassistant/components/habitica/quality_scale.yaml @@ -4,9 +4,7 @@ rules: appropriate-polling: done brands: done common-modules: done - config-flow-test-coverage: - status: todo - comment: test already_configured, tests should finish with create_entry or abort, assert unique_id + config-flow-test-coverage: done config-flow: done dependency-transparency: todo docs-actions: done diff --git a/tests/components/habitica/test_config_flow.py b/tests/components/habitica/test_config_flow.py index bd3287d3ea1..bacd1ee3ac9 100644 --- a/tests/components/habitica/test_config_flow.py +++ b/tests/components/habitica/test_config_flow.py @@ -147,6 +147,35 @@ async def test_form_login_errors( assert result["result"].unique_id == TEST_API_USER +@pytest.mark.usefixtures("habitica") +async def test_form__already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort form login when entry is already configured.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "advanced"} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_ADVANCED_STEP, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures("habitica") async def test_form_advanced(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" @@ -387,3 +416,36 @@ async def test_flow_reauth_errors( assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("habitica") +async def test_flow_reauth_unique_id_mismatch(hass: HomeAssistant) -> None: + """Test reauth flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + title="test-user", + data={ + CONF_URL: DEFAULT_URL, + CONF_API_USER: "371fcad5-0f9c-4211-931c-034a5d2a6213", + CONF_API_KEY: "cd0e5985-17de-4b4f-849e-5d506c5e4382", + }, + unique_id="371fcad5-0f9c-4211-931c-034a5d2a6213", + ) + + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_REAUTH_LOGIN, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + assert len(hass.config_entries.async_entries()) == 1 From 81b7d01a7d9a589fe43d76328b5cc84b897d8b64 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sat, 18 Jan 2025 05:01:09 -0700 Subject: [PATCH 0597/2987] Bump pylitterbot to 2024.0.0 (#135891) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/litterrobot/common.py | 8 ++++++++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 88396f9f9c1..4f1deb9a567 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], - "requirements": ["pylitterbot==2023.5.0"] + "requirements": ["pylitterbot==2024.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2348a0ec994..31bff76a39e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2079,7 +2079,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2023.5.0 +pylitterbot==2024.0.0 # homeassistant.components.lutron_caseta pylutron-caseta==0.23.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c40c8b95635..fb91ae58633 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1693,7 +1693,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2023.5.0 +pylitterbot==2024.0.0 # homeassistant.components.lutron_caseta pylutron-caseta==0.23.0 diff --git a/tests/components/litterrobot/common.py b/tests/components/litterrobot/common.py index 8849392b3dd..b29fa753801 100644 --- a/tests/components/litterrobot/common.py +++ b/tests/components/litterrobot/common.py @@ -90,6 +90,14 @@ ROBOT_4_DATA = { "isUSBPowerOn": True, "USBFaultStatus": "CLEAR", "isDFIPartialFull": True, + "isLaserDirty": False, + "surfaceType": "TILE", + "hopperStatus": None, + "scoopsSavedCount": 3769, + "isHopperRemoved": None, + "optimalLitterLevel": 450, + "litterLevelPercentage": 0.7, + "litterLevelState": "OPTIMAL", } FEEDER_ROBOT_DATA = { "id": 1, From f878465a9aacff0af8248e225727891e7c184669 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 18 Jan 2025 13:07:28 +0100 Subject: [PATCH 0598/2987] Fix imgw_pib tests (#135913) --- tests/components/imgw_pib/test_init.py | 3 +-- tests/components/imgw_pib/test_sensor.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/components/imgw_pib/test_init.py b/tests/components/imgw_pib/test_init.py index 834fd505497..6ee3528422d 100644 --- a/tests/components/imgw_pib/test_init.py +++ b/tests/components/imgw_pib/test_init.py @@ -55,6 +55,7 @@ async def test_remove_binary_sensor_entity( ) -> None: """Test removing a binary_sensor entity.""" entity_id = "binary_sensor.river_name_station_name_flood_alarm" + await init_integration(hass, mock_config_entry) entity_registry.async_get_or_create( BINARY_SENSOR_PLATFORM, @@ -64,6 +65,4 @@ async def test_remove_binary_sensor_entity( config_entry=mock_config_entry, ) - await init_integration(hass, mock_config_entry) - assert hass.states.get(entity_id) is None diff --git a/tests/components/imgw_pib/test_sensor.py b/tests/components/imgw_pib/test_sensor.py index c38c8e6773e..aebe4d2f86e 100644 --- a/tests/components/imgw_pib/test_sensor.py +++ b/tests/components/imgw_pib/test_sensor.py @@ -76,6 +76,7 @@ async def test_remove_entity( ) -> None: """Test removing entity.""" entity_id = "sensor.river_name_station_name_flood_alarm_level" + await init_integration(hass, mock_config_entry) entity_registry.async_get_or_create( SENSOR_PLATFORM, @@ -85,6 +86,4 @@ async def test_remove_entity( config_entry=mock_config_entry, ) - await init_integration(hass, mock_config_entry) - assert hass.states.get(entity_id) is None From d349c47694aaef07e8b065c46ff1c7125423ec59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Sat, 18 Jan 2025 13:11:35 +0100 Subject: [PATCH 0599/2987] Add reauth flow to LetPot integration (#135734) --- homeassistant/components/letpot/__init__.py | 6 +- .../components/letpot/config_flow.py | 87 ++++++--- .../components/letpot/coordinator.py | 4 +- .../components/letpot/quality_scale.yaml | 2 +- homeassistant/components/letpot/strings.json | 12 +- tests/components/letpot/test_config_flow.py | 170 ++++++++++++++++++ 6 files changed, 252 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py index 82fc05c6b0f..905887463d7 100644 --- a/homeassistant/components/letpot/__init__.py +++ b/homeassistant/components/letpot/__init__.py @@ -12,7 +12,7 @@ from letpot.models import AuthenticationInfo from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( @@ -57,12 +57,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bo }, ) except LetPotAuthenticationException as exc: - raise ConfigEntryError from exc + raise ConfigEntryAuthFailed from exc try: devices = await client.get_devices() except LetPotAuthenticationException as exc: - raise ConfigEntryError from exc + raise ConfigEntryAuthFailed from exc except LetPotException as exc: raise ConfigEntryNotReady from exc diff --git a/homeassistant/components/letpot/config_flow.py b/homeassistant/components/letpot/config_flow.py index fac78e440db..bc710cd6aef 100644 --- a/homeassistant/components/letpot/config_flow.py +++ b/homeassistant/components/letpot/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -42,6 +43,13 @@ STEP_USER_DATA_SCHEMA = vol.Schema( ), } ) +STEP_REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD), + ), + } +) class LetPotConfigFlow(ConfigFlow, domain=DOMAIN): @@ -51,18 +59,28 @@ class LetPotConfigFlow(ConfigFlow, domain=DOMAIN): async def _async_validate_credentials( self, email: str, password: str - ) -> dict[str, Any]: + ) -> tuple[dict[str, str], dict[str, Any] | None]: + """Try logging in to the LetPot account and returns credential info.""" websession = async_get_clientsession(self.hass) client = LetPotClient(websession) - auth = await client.login(email, password) - return { - CONF_ACCESS_TOKEN: auth.access_token, - CONF_ACCESS_TOKEN_EXPIRES: auth.access_token_expires, - CONF_REFRESH_TOKEN: auth.refresh_token, - CONF_REFRESH_TOKEN_EXPIRES: auth.refresh_token_expires, - CONF_USER_ID: auth.user_id, - CONF_EMAIL: auth.email, - } + try: + auth = await client.login(email, password) + except LetPotConnectionException: + return {"base": "cannot_connect"}, None + except LetPotAuthenticationException: + return {"base": "invalid_auth"}, None + except Exception: + _LOGGER.exception("Unexpected exception") + return {"base": "unknown"}, None + else: + return {}, { + CONF_ACCESS_TOKEN: auth.access_token, + CONF_ACCESS_TOKEN_EXPIRES: auth.access_token_expires, + CONF_REFRESH_TOKEN: auth.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: auth.refresh_token_expires, + CONF_USER_ID: auth.user_id, + CONF_EMAIL: auth.email, + } async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -70,18 +88,10 @@ class LetPotConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" errors: dict[str, str] = {} if user_input is not None: - try: - data_dict = await self._async_validate_credentials( - user_input[CONF_EMAIL], user_input[CONF_PASSWORD] - ) - except LetPotConnectionException: - errors["base"] = "cannot_connect" - except LetPotAuthenticationException: - errors["base"] = "invalid_auth" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + errors, data_dict = await self._async_validate_credentials( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + if not errors and data_dict is not None: await self.async_set_unique_id(data_dict[CONF_USER_ID]) self._abort_if_unique_id_configured() return self.async_create_entry( @@ -90,3 +100,36 @@ class LetPotConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + if user_input is not None: + errors, data_dict = await self._async_validate_credentials( + reauth_entry.data[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + if not errors and data_dict is not None: + await self.async_set_unique_id(data_dict[CONF_USER_ID]) + if reauth_entry.unique_id != data_dict[CONF_USER_ID]: + # Abort if the received account is different and already added + self._abort_if_unique_id_configured() + return self.async_update_reload_and_abort( + reauth_entry, + unique_id=self.unique_id, + data_updates=data_dict, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_SCHEMA, + description_placeholders={"email": reauth_entry.title}, + errors=errors, + ) diff --git a/homeassistant/components/letpot/coordinator.py b/homeassistant/components/letpot/coordinator.py index 4be2fc79253..a2a35d566c6 100644 --- a/homeassistant/components/letpot/coordinator.py +++ b/homeassistant/components/letpot/coordinator.py @@ -11,7 +11,7 @@ from letpot.exceptions import LetPotAuthenticationException, LetPotException from letpot.models import AuthenticationInfo, LetPotDevice, LetPotDeviceStatus from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import REQUEST_UPDATE_TIMEOUT @@ -52,7 +52,7 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): try: await self.device_client.subscribe(self._handle_status_update) except LetPotAuthenticationException as exc: - raise ConfigEntryError from exc + raise ConfigEntryAuthFailed from exc async def _async_update_data(self) -> LetPotDeviceStatus: """Request an update from the device and wait for a status update or timeout.""" diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index 6d6848c5d52..74b948ffbf7 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -43,7 +43,7 @@ rules: integration-owner: done log-when-unavailable: todo parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: todo # Gold diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json index 2f7dec6f295..93913c2bc4d 100644 --- a/homeassistant/components/letpot/strings.json +++ b/homeassistant/components/letpot/strings.json @@ -10,6 +10,15 @@ "email": "The email address of your LetPot account.", "password": "The password of your LetPot account." } + }, + "reauth_confirm": { + "description": "The LetPot integration needs to re-authenticate your account {email}.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::letpot::config::step::user::data_description::password%]" + } } }, "error": { @@ -18,7 +27,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/tests/components/letpot/test_config_flow.py b/tests/components/letpot/test_config_flow.py index c587d31a625..0ec1bd95d91 100644 --- a/tests/components/letpot/test_config_flow.py +++ b/tests/components/letpot/test_config_flow.py @@ -1,5 +1,6 @@ """Test the LetPot config flow.""" +import dataclasses from typing import Any from unittest.mock import AsyncMock, patch @@ -145,3 +146,172 @@ async def test_flow_duplicate( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_reauth_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reauth flow with success.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + updated_auth = dataclasses.replace( + AUTHENTICATION, + access_token="new_access_token", + refresh_token="new_refresh_token", + ) + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=updated_auth, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_ACCESS_TOKEN: "new_access_token", + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: "new_refresh_token", + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: AUTHENTICATION.user_id, + CONF_EMAIL: AUTHENTICATION.email, + } + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (LetPotAuthenticationException, "invalid_auth"), + (LetPotConnectionException, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_reauth_exceptions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reauth flow with exception during login and recovery.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + # Retry to show recovery. + updated_auth = dataclasses.replace( + AUTHENTICATION, + access_token="new_access_token", + refresh_token="new_refresh_token", + ) + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=updated_auth, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_ACCESS_TOKEN: "new_access_token", + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: "new_refresh_token", + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: AUTHENTICATION.user_id, + CONF_EMAIL: AUTHENTICATION.email, + } + assert len(hass.config_entries.async_entries()) == 1 + + +async def test_reauth_different_user_id_new( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reauth flow with different, new user ID updating the existing entry.""" + mock_config_entry.add_to_hass(hass) + config_entries = hass.config_entries.async_entries() + assert len(config_entries) == 1 + assert config_entries[0].unique_id == AUTHENTICATION.user_id + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + updated_auth = dataclasses.replace(AUTHENTICATION, user_id="new_user_id") + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=updated_auth, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_ACCESS_TOKEN: AUTHENTICATION.access_token, + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: AUTHENTICATION.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: "new_user_id", + CONF_EMAIL: AUTHENTICATION.email, + } + config_entries = hass.config_entries.async_entries() + assert len(config_entries) == 1 + assert config_entries[0].unique_id == "new_user_id" + + +async def test_reauth_different_user_id_existing( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reauth flow with different, existing user ID aborting.""" + mock_config_entry.add_to_hass(hass) + mock_other = MockConfigEntry( + domain=DOMAIN, title="email2@example.com", data={}, unique_id="other_user_id" + ) + mock_other.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + updated_auth = dataclasses.replace(AUTHENTICATION, user_id="other_user_id") + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=updated_auth, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(hass.config_entries.async_entries()) == 2 From f0c6b4752280f26049719d8ef4560d9639443965 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 18 Jan 2025 12:31:17 +0000 Subject: [PATCH 0600/2987] Increase test coverage for IMGW-PIB (#135915) --- tests/components/imgw_pib/test_init.py | 5 ++++- tests/components/imgw_pib/test_sensor.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/components/imgw_pib/test_init.py b/tests/components/imgw_pib/test_init.py index 6ee3528422d..e352c643676 100644 --- a/tests/components/imgw_pib/test_init.py +++ b/tests/components/imgw_pib/test_init.py @@ -55,7 +55,7 @@ async def test_remove_binary_sensor_entity( ) -> None: """Test removing a binary_sensor entity.""" entity_id = "binary_sensor.river_name_station_name_flood_alarm" - await init_integration(hass, mock_config_entry) + mock_config_entry.add_to_hass(hass) entity_registry.async_get_or_create( BINARY_SENSOR_PLATFORM, @@ -65,4 +65,7 @@ async def test_remove_binary_sensor_entity( config_entry=mock_config_entry, ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get(entity_id) is None diff --git a/tests/components/imgw_pib/test_sensor.py b/tests/components/imgw_pib/test_sensor.py index aebe4d2f86e..a1920f38006 100644 --- a/tests/components/imgw_pib/test_sensor.py +++ b/tests/components/imgw_pib/test_sensor.py @@ -76,7 +76,7 @@ async def test_remove_entity( ) -> None: """Test removing entity.""" entity_id = "sensor.river_name_station_name_flood_alarm_level" - await init_integration(hass, mock_config_entry) + mock_config_entry.add_to_hass(hass) entity_registry.async_get_or_create( SENSOR_PLATFORM, @@ -86,4 +86,7 @@ async def test_remove_entity( config_entry=mock_config_entry, ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get(entity_id) is None From 5a7b6cd7a05773c9216313439c23c78980dc04a7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 18 Jan 2025 14:47:53 +0100 Subject: [PATCH 0601/2987] Remove asserting name in tts test (no entity platform) (#135726) * Ensure entity platform in tts tests * Correct placement * Remove name test * Remove hass --- tests/components/tts/test_init.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 0b01a24720d..2159d92ae4b 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -22,7 +22,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.typing import UNDEFINED from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -64,7 +63,6 @@ async def test_default_entity_attributes() -> None: entity = DefaultEntity() assert entity.hass is None - assert entity.name is UNDEFINED assert entity.default_language == DEFAULT_LANG assert entity.supported_languages == SUPPORT_LANGUAGES assert entity.supported_options is None From 595f49ee9f16433deda21d1987404117390d7431 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 18 Jan 2025 16:35:35 +0100 Subject: [PATCH 0602/2987] Set strict-typing in Habitica quality scale record (#135899) * Set strict-typing in Habitica quality scale record * cast --- .strict-typing | 1 + homeassistant/components/habitica/__init__.py | 3 +-- homeassistant/components/habitica/calendar.py | 6 ++++-- homeassistant/components/habitica/quality_scale.yaml | 2 +- mypy.ini | 10 ++++++++++ 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.strict-typing b/.strict-typing index b83bb0f6a95..46b14f22660 100644 --- a/.strict-typing +++ b/.strict-typing @@ -224,6 +224,7 @@ homeassistant.components.gpsd.* homeassistant.components.greeneye_monitor.* homeassistant.components.group.* homeassistant.components.guardian.* +homeassistant.components.habitica.* homeassistant.components.hardkernel.* homeassistant.components.hardware.* homeassistant.components.here_travel_time.* diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index 9a9d689bedc..1972e89c58a 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -2,7 +2,6 @@ from habiticalib import Habitica -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -61,6 +60,6 @@ async def async_setup_entry( return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: HabiticaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/habitica/calendar.py b/homeassistant/components/habitica/calendar.py index 4a9b1579d3a..f33f3c3c12f 100644 --- a/homeassistant/components/habitica/calendar.py +++ b/homeassistant/components/habitica/calendar.py @@ -6,7 +6,7 @@ from abc import abstractmethod from dataclasses import asdict from datetime import date, datetime, timedelta from enum import StrEnum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from uuid import UUID from dateutil.rrule import rrule @@ -95,9 +95,11 @@ class HabiticaCalendarEntity(HabiticaBase, CalendarEntity): ) -> list[datetime]: """Calculate recurrence dates based on start_date and end_date.""" if end_date: - return recurrences.between( + recurrence_dates = recurrences.between( start_date, end_date - timedelta(days=1), inc=True ) + + return cast(list[datetime], recurrence_dates) # if no end_date is given, return only the next recurrence return [recurrences.after(start_date, inc=True)] diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml index ba139ea241b..0f8ede06d2e 100644 --- a/homeassistant/components/habitica/quality_scale.yaml +++ b/homeassistant/components/habitica/quality_scale.yaml @@ -79,4 +79,4 @@ rules: # Platinum async-dependency: todo inject-websession: done - strict-typing: todo + strict-typing: done diff --git a/mypy.ini b/mypy.ini index 6a9bb29c360..e4056203875 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1996,6 +1996,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.habitica.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.hardkernel.*] check_untyped_defs = true disallow_incomplete_defs = true From dedcef7230fbe04be35a56958e2070bd39e35b07 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 18 Jan 2025 17:08:07 +0100 Subject: [PATCH 0603/2987] Fix acmeda pytest usefixtures spelling (#135919) --- tests/components/acmeda/test_config_flow.py | 4 ++-- tests/components/acmeda/test_cover.py | 2 +- tests/components/acmeda/test_sensor.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/acmeda/test_config_flow.py b/tests/components/acmeda/test_config_flow.py index 9fb0d7e645b..7b92c1aac3b 100644 --- a/tests/components/acmeda/test_config_flow.py +++ b/tests/components/acmeda/test_config_flow.py @@ -49,7 +49,7 @@ async def test_show_form_no_hubs(hass: HomeAssistant, mock_hub_discover) -> None assert len(mock_hub_discover.mock_calls) == 1 -@pytest.mark.usesfixtures("mock_hub_run") +@pytest.mark.usefixtures("mock_hub_run") async def test_show_form_one_hub(hass: HomeAssistant, mock_hub_discover) -> None: """Test that a config is created when one hub discovered.""" @@ -94,7 +94,7 @@ async def test_show_form_two_hubs(hass: HomeAssistant, mock_hub_discover) -> Non assert len(mock_hub_discover.mock_calls) == 1 -@pytest.mark.usesfixtures("mock_hub_run") +@pytest.mark.usefixtures("mock_hub_run") async def test_create_second_entry(hass: HomeAssistant, mock_hub_discover) -> None: """Test that a config is created when a second hub is discovered.""" diff --git a/tests/components/acmeda/test_cover.py b/tests/components/acmeda/test_cover.py index f4acc270065..d5b6997ee33 100644 --- a/tests/components/acmeda/test_cover.py +++ b/tests/components/acmeda/test_cover.py @@ -10,7 +10,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry -@pytest.mark.usesfixtures("mock_hub_run") +@pytest.mark.usefixtures("mock_hub_run") async def test_cover_id_migration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/acmeda/test_sensor.py b/tests/components/acmeda/test_sensor.py index a7e60ec7187..12195d3aec4 100644 --- a/tests/components/acmeda/test_sensor.py +++ b/tests/components/acmeda/test_sensor.py @@ -10,7 +10,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry -@pytest.mark.usesfixtures("mock_hub_run") +@pytest.mark.usefixtures("mock_hub_run") async def test_sensor_id_migration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 0c9fd7c48278211fa7d753e56541242e8b775481 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 18 Jan 2025 17:43:35 +0100 Subject: [PATCH 0604/2987] Fix DeprecationWarnings in mcp_server (#135927) * Fix DeprecationWarnings in mcp_server * Spelling --- homeassistant/components/mcp_server/http.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index da706d4a73b..433d978cef7 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -1,4 +1,4 @@ -"""Model Context Protocol transport portocol for Server Sent Events (SSE). +"""Model Context Protocol transport protocol for Server Sent Events (SSE). This registers HTTP endpoints that supports SSE as a transport layer for the Model Context Protocol. There are two HTTP endpoints: @@ -62,9 +62,9 @@ def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry: if config_entry.state == ConfigEntryState.LOADED ] if not config_entries: - raise HTTPNotFound(body="Model Context Protocol server is not configured") + raise HTTPNotFound(text="Model Context Protocol server is not configured") if len(config_entries) > 1: - raise HTTPNotFound(body="Found multiple Model Context Protocol configurations") + raise HTTPNotFound(text="Found multiple Model Context Protocol configurations") return config_entries[0] @@ -147,7 +147,7 @@ class ModelContextProtocolMessagesView(HomeAssistantView): """Process incoming messages for the Model Context Protocol. The request passes a session ID which is used to identify the original - SSE connection. This view parses incoming messagess from the transport + SSE connection. This view parses incoming messages from the transport layer then writes them to the MCP server stream for the session. """ hass = request.app[KEY_HASS] @@ -156,14 +156,14 @@ class ModelContextProtocolMessagesView(HomeAssistantView): session_manager = config_entry.runtime_data if (session := session_manager.get(session_id)) is None: _LOGGER.info("Could not find session ID: '%s'", session_id) - raise HTTPNotFound(body=f"Could not find session ID '{session_id}'") + raise HTTPNotFound(text=f"Could not find session ID '{session_id}'") json_data = await request.json() try: message = types.JSONRPCMessage.model_validate(json_data) except ValueError as err: _LOGGER.info("Failed to parse message: %s", err) - raise HTTPBadRequest(body="Could not parse message") from err + raise HTTPBadRequest(text="Could not parse message") from err _LOGGER.debug("Received client message: %s", message) await session.read_stream_writer.send(message) From b39c2719d78ad99e8416d62e2f982725f45d7dc5 Mon Sep 17 00:00:00 2001 From: "Glenn Vandeuren (aka Iondependent)" Date: Sat, 18 Jan 2025 17:47:20 +0100 Subject: [PATCH 0605/2987] Update NHC lib to v0.3.4 (#135923) Update NHC to v0.3.4 --- homeassistant/components/niko_home_control/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index d252a11b38e..a75b0d72dca 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/niko_home_control", "iot_class": "local_push", "loggers": ["nikohomecontrol"], - "requirements": ["nhc==0.3.2"] + "requirements": ["nhc==0.3.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 31bff76a39e..4804e883b2b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1482,7 +1482,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.3.2 +nhc==0.3.4 # homeassistant.components.nibe_heatpump nibe==2.14.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb91ae58633..c1331477997 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1245,7 +1245,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.3.2 +nhc==0.3.4 # homeassistant.components.nibe_heatpump nibe==2.14.0 From fe8a93d62f9aa66f1aa0c3d956025c29a4d08310 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 18 Jan 2025 18:41:24 +0100 Subject: [PATCH 0606/2987] Add reauthentication to SmartThings (#135673) * Add reauthentication to SmartThings * Add reauthentication to SmartThings * Add reauthentication to SmartThings * Add reauthentication to SmartThings --- .../components/smartthings/__init__.py | 38 ++++------- .../components/smartthings/config_flow.py | 43 +++++++++++- .../components/smartthings/smartapp.py | 66 ++++++++++++++----- .../components/smartthings/strings.json | 15 ++++- .../smartthings/test_config_flow.py | 54 +++++++++++++++ tests/components/smartthings/test_init.py | 12 +--- 6 files changed, 173 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index bcc752ff173..2914851ccbf 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -10,12 +10,16 @@ import logging from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError from pysmartapp.event import EVENT_TYPE_DEVICE -from pysmartthings import Attribute, Capability, SmartThings +from pysmartthings import APIInvalidGrant, Attribute, Capability, SmartThings from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -106,7 +110,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # to import the modules. await async_get_loaded_integration(hass, DOMAIN).async_get_platforms(PLATFORMS) - remove_entry = False try: # See if the app is already setup. This occurs when there are # installs in multiple SmartThings locations (valid use-case) @@ -175,34 +178,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: broker.connect() hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker + except APIInvalidGrant as ex: + raise ConfigEntryAuthFailed from ex except ClientResponseError as ex: if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): - _LOGGER.exception( - ( - "Unable to setup configuration entry '%s' - please reconfigure the" - " integration" - ), - entry.title, - ) - remove_entry = True - else: - _LOGGER.debug(ex, exc_info=True) - raise ConfigEntryNotReady from ex + raise ConfigEntryError( + "The access token is no longer valid. Please remove the integration and set up again." + ) from ex + _LOGGER.debug(ex, exc_info=True) + raise ConfigEntryNotReady from ex except (ClientConnectionError, RuntimeWarning) as ex: _LOGGER.debug(ex, exc_info=True) raise ConfigEntryNotReady from ex - if remove_entry: - hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) - # only create new flow if there isn't a pending one for SmartThings. - if not hass.config_entries.flow.async_progress_by_handler(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) - ) - return False - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 081f833787e..7b49854740a 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -1,5 +1,6 @@ """Config flow to configure SmartThings.""" +from collections.abc import Mapping from http import HTTPStatus import logging from typing import Any @@ -9,7 +10,7 @@ from pysmartthings import APIResponseError, AppOAuth, SmartThings from pysmartthings.installedapp import format_install_url import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -213,7 +214,10 @@ class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN): url = format_install_url(self.app_id, self.location_id) return self.async_external_step(step_id="authorize", url=url) - return self.async_external_step_done(next_step_id="install") + next_step_id = "install" + if self.source == SOURCE_REAUTH: + next_step_id = "update" + return self.async_external_step_done(next_step_id=next_step_id) def _show_step_pat(self, errors): if self.access_token is None: @@ -240,6 +244,41 @@ class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN): }, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + self.app_id = self._get_reauth_entry().data[CONF_APP_ID] + self.location_id = self._get_reauth_entry().data[CONF_LOCATION_ID] + self._set_confirm_only() + return await self.async_step_authorize() + + async def async_step_update( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + return await self.async_step_update_confirm() + + async def async_step_update_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + if user_input is None: + self._set_confirm_only() + return self.async_show_form(step_id="update_confirm") + entry = self._get_reauth_entry() + return self.async_update_reload_and_abort( + entry, data_updates={CONF_REFRESH_TOKEN: self.refresh_token} + ) + async def async_step_install( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 6b0da00b132..76b6804075f 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -1,5 +1,7 @@ """SmartApp functionality to receive cloud-push notifications.""" +from __future__ import annotations + import asyncio import functools import logging @@ -27,6 +29,7 @@ from pysmartthings import ( ) from homeassistant.components import cloud, webhook +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -400,7 +403,7 @@ async def smartapp_sync_subscriptions( _LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id) -async def _continue_flow( +async def _find_and_continue_flow( hass: HomeAssistant, app_id: str, location_id: str, @@ -418,24 +421,34 @@ async def _continue_flow( None, ) if flow is not None: - await hass.config_entries.flow.async_configure( - flow["flow_id"], - { - CONF_INSTALLED_APP_ID: installed_app_id, - CONF_REFRESH_TOKEN: refresh_token, - }, - ) - _LOGGER.debug( - "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", - flow["flow_id"], - installed_app_id, - app_id, - ) + await _continue_flow(hass, app_id, installed_app_id, refresh_token, flow) + + +async def _continue_flow( + hass: HomeAssistant, + app_id: str, + installed_app_id: str, + refresh_token: str, + flow: ConfigFlowResult, +) -> None: + await hass.config_entries.flow.async_configure( + flow["flow_id"], + { + CONF_INSTALLED_APP_ID: installed_app_id, + CONF_REFRESH_TOKEN: refresh_token, + }, + ) + _LOGGER.debug( + "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", + flow["flow_id"], + installed_app_id, + app_id, + ) async def smartapp_install(hass: HomeAssistant, req, resp, app): """Handle a SmartApp installation and continue the config flow.""" - await _continue_flow( + await _find_and_continue_flow( hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token ) _LOGGER.debug( @@ -447,6 +460,27 @@ async def smartapp_install(hass: HomeAssistant, req, resp, app): async def smartapp_update(hass: HomeAssistant, req, resp, app): """Handle a SmartApp update and either update the entry or continue the flow.""" + unique_id = format_unique_id(app.app_id, req.location_id) + flow = next( + ( + flow + for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) + if flow["context"].get("unique_id") == unique_id + and flow["step_id"] == "authorize" + ), + None, + ) + if flow is not None: + await _continue_flow( + hass, app.app_id, req.installed_app_id, req.refresh_token, flow + ) + _LOGGER.debug( + "Continued reauth flow '%s' for SmartApp '%s' under parent app '%s'", + flow["flow_id"], + req.installed_app_id, + app.app_id, + ) + return entry = next( ( entry @@ -466,7 +500,7 @@ async def smartapp_update(hass: HomeAssistant, req, resp, app): app.app_id, ) - await _continue_flow( + await _find_and_continue_flow( hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token ) _LOGGER.debug( diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index de94e5adfcd..31a552be149 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -7,7 +7,7 @@ }, "pat": { "title": "Enter Personal Access Token", - "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.", + "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.\n\n**Please note that all Personal Access Tokens created after 30 December 2024 are only valid for 24 hours, after which the integration will stop working. We are working on a fix.**", "data": { "access_token": "[%key:common::config_flow::data::access_token%]" } @@ -17,11 +17,20 @@ "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.", "data": { "location_id": "[%key:common::config_flow::data::location%]" } }, - "authorize": { "title": "Authorize Home Assistant" } + "authorize": { "title": "Authorize Home Assistant" }, + "reauth_confirm": { + "title": "Reauthorize Home Assistant", + "description": "You are about to reauthorize Home Assistant with SmartThings. This will require you to log in and authorize the integration again." + }, + "update_confirm": { + "title": "Finish reauthentication", + "description": "You have almost successfully reauthorized Home Assistant with SmartThings. Please press the button down below to finish the process." + } }, "abort": { "invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.", - "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant." + "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant.", + "reauth_successful": "Home Assistant has been successfully reauthorized with SmartThings." }, "error": { "token_invalid_format": "The token must be in the UID/GUID format", diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 3621e58bc3d..05ddc3a71de 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -14,6 +14,7 @@ from homeassistant.components.smartthings.const import ( CONF_APP_ID, CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, + CONF_REFRESH_TOKEN, DOMAIN, ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET @@ -757,3 +758,56 @@ async def test_no_available_locations_aborts( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_available_locations" + + +async def test_reauth( + hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock +) -> None: + """Test reauth flow.""" + token = str(uuid4()) + installed_app_id = str(uuid4()) + refresh_token = str(uuid4()) + smartthings_mock.apps.return_value = [] + smartthings_mock.create_app.return_value = (app, app_oauth_client) + smartthings_mock.locations.return_value = [location] + request = Mock() + request.installed_app_id = installed_app_id + request.auth_token = token + request.location_id = location.location_id + request.refresh_token = refresh_token + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_APP_ID: app.app_id, + CONF_CLIENT_ID: app_oauth_client.client_id, + CONF_CLIENT_SECRET: app_oauth_client.client_secret, + CONF_LOCATION_ID: location.location_id, + CONF_INSTALLED_APP_ID: installed_app_id, + CONF_ACCESS_TOKEN: token, + CONF_REFRESH_TOKEN: "abc", + }, + unique_id=smartapp.format_unique_id(app.app_id, location.location_id), + ) + entry.add_to_hass(hass) + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["step_id"] == "authorize" + assert result["url"] == format_install_url(app.app_id, location.location_id) + + await smartapp.smartapp_update(hass, request, None, app) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "update_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert entry.data[CONF_REFRESH_TOKEN] == refresh_token diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index e518f84aecb..83372b58228 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -23,6 +23,7 @@ from homeassistant.components.smartthings.const import ( PLATFORMS, SIGNAL_SMARTTHINGS_UPDATE, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config from homeassistant.exceptions import ConfigEntryNotReady @@ -68,17 +69,10 @@ async def test_unrecoverable_api_errors_create_new_flow( ) # Assert setup returns false - result = await smartthings.async_setup_entry(hass, config_entry) + result = await hass.config_entries.async_setup(config_entry.entry_id) assert not result - # Assert entry was removed and new flow created - await hass.async_block_till_done() - assert not hass.config_entries.async_entries(DOMAIN) - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["handler"] == "smartthings" - assert flows[0]["context"] == {"source": config_entries.SOURCE_IMPORT} - hass.config_entries.flow.async_abort(flows[0]["flow_id"]) + assert config_entry.state == ConfigEntryState.SETUP_ERROR async def test_recoverable_api_errors_raise_not_ready( From 24c50e0988ed59b2470d93278841ba4df07d76b6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 18 Jan 2025 19:04:01 +0100 Subject: [PATCH 0607/2987] Fix aiodns DeprecationWarning in tests (#135921) --- tests/conftest.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 83409792f5a..3195e6918b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,6 +33,7 @@ import bcrypt import freezegun import multidict import pytest +import pytest_asyncio import pytest_socket import requests_mock import respx @@ -1233,8 +1234,8 @@ def disable_translations_once( translations_once.start() -@pytest.fixture(autouse=True, scope="session") -def mock_zeroconf_resolver() -> Generator[_patch]: +@pytest_asyncio.fixture(autouse=True, scope="session", loop_scope="session") +async def mock_zeroconf_resolver() -> AsyncGenerator[_patch]: """Mock out the zeroconf resolver.""" patcher = patch( "homeassistant.helpers.aiohttp_client._async_make_resolver", From 19e5b091c5ab0fcda576d915d8200aad1f1d105e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 18 Jan 2025 20:52:13 +0100 Subject: [PATCH 0608/2987] Use HassKey for assist_pipeline singleton (#135875) --- .../components/assist_pipeline/pipeline.py | 21 +++++++++++-------- .../components/assist_pipeline/select.py | 12 +++++------ .../assist_pipeline/websocket_api.py | 11 +++++----- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 8979cae068e..a11b5a657de 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -50,6 +50,7 @@ from homeassistant.util import ( language as language_util, ulid as ulid_util, ) +from homeassistant.util.hass_dict import HassKey from homeassistant.util.limited_size_dict import LimitedSizeDict from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadSpeexEnhancer @@ -91,6 +92,8 @@ ENGINE_LANGUAGE_PAIRS = ( ("tts_engine", "tts_language"), ) +KEY_ASSIST_PIPELINE: HassKey[PipelineData] = HassKey(DOMAIN) + def validate_language(data: dict[str, Any]) -> Any: """Validate language settings.""" @@ -248,7 +251,7 @@ async def async_create_default_pipeline( The default pipeline will use the homeassistant conversation agent and the specified stt / tts engines. """ - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] pipeline_store = pipeline_data.pipeline_store pipeline_settings = _async_resolve_default_pipeline_settings( hass, @@ -283,7 +286,7 @@ def _async_get_pipeline_from_conversation_entity( @callback def async_get_pipeline(hass: HomeAssistant, pipeline_id: str | None = None) -> Pipeline: """Get a pipeline by id or the preferred pipeline.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] if pipeline_id is None: # A pipeline was not specified, use the preferred one @@ -306,7 +309,7 @@ def async_get_pipeline(hass: HomeAssistant, pipeline_id: str | None = None) -> P @callback def async_get_pipelines(hass: HomeAssistant) -> list[Pipeline]: """Get all pipelines.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] return list(pipeline_data.pipeline_store.data.values()) @@ -329,7 +332,7 @@ async def async_update_pipeline( prefer_local_intents: bool | UndefinedType = UNDEFINED, ) -> None: """Update a pipeline.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] updates: dict[str, Any] = pipeline.to_json() updates.pop("id") @@ -587,7 +590,7 @@ class PipelineRun: ): raise InvalidPipelineStagesError(self.start_stage, self.end_stage) - pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE] if self.pipeline.id not in pipeline_data.pipeline_debug: pipeline_data.pipeline_debug[self.pipeline.id] = LimitedSizeDict( size_limit=STORED_PIPELINE_RUNS @@ -615,7 +618,7 @@ class PipelineRun: def process_event(self, event: PipelineEvent) -> None: """Log an event and call listener.""" self.event_callback(event) - pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE] if self.id not in pipeline_data.pipeline_debug[self.pipeline.id]: # This run has been evicted from the logged pipeline runs already return @@ -650,7 +653,7 @@ class PipelineRun: ) ) - pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE] pipeline_data.pipeline_runs.remove_run(self) async def prepare_wake_word_detection(self) -> None: @@ -1227,7 +1230,7 @@ class PipelineRun: return # Forward to device audio capture - pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE] audio_queue = pipeline_data.device_audio_queues.get(self._device_id) if audio_queue is None: return @@ -1884,7 +1887,7 @@ class PipelineStore(Store[SerializedPipelineStorageCollection]): return old_data -@singleton(DOMAIN) +@singleton(KEY_ASSIST_PIPELINE, async_=True) async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData: """Set up the pipeline storage collection.""" pipeline_store = PipelineStorageCollection( diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py index c7e4846aad7..a590f30fc7a 100644 --- a/homeassistant/components/assist_pipeline/select.py +++ b/homeassistant/components/assist_pipeline/select.py @@ -9,8 +9,8 @@ from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import collection, entity_registry as er, restore_state -from .const import DOMAIN, OPTION_PREFERRED -from .pipeline import AssistDevice, PipelineData, PipelineStorageCollection +from .const import OPTION_PREFERRED +from .pipeline import KEY_ASSIST_PIPELINE, AssistDevice from .vad import VadSensitivity @@ -30,7 +30,7 @@ def get_chosen_pipeline( if state is None or state.state == OPTION_PREFERRED: return None - pipeline_store: PipelineStorageCollection = hass.data[DOMAIN].pipeline_store + pipeline_store = hass.data[KEY_ASSIST_PIPELINE].pipeline_store return next( (item.id for item in pipeline_store.async_items() if item.name == state.state), None, @@ -80,7 +80,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): """When entity is added to Home Assistant.""" await super().async_added_to_hass() - pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE] pipeline_store = pipeline_data.pipeline_store self.async_on_remove( pipeline_store.async_add_change_set_listener(self._pipelines_updated) @@ -116,9 +116,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): @callback def _update_options(self) -> None: """Handle pipeline update.""" - pipeline_store: PipelineStorageCollection = self.hass.data[ - DOMAIN - ].pipeline_store + pipeline_store = self.hass.data[KEY_ASSIST_PIPELINE].pipeline_store options = [OPTION_PREFERRED] options.extend(sorted(item.name for item in pipeline_store.async_items())) self._attr_options = options diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index d61580f4a14..e8da8e56fd6 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -21,7 +21,6 @@ from homeassistant.util import language as language_util from .const import ( DEFAULT_PIPELINE_TIMEOUT, DEFAULT_WAKE_WORD_TIMEOUT, - DOMAIN, EVENT_RECORDING, SAMPLE_CHANNELS, SAMPLE_RATE, @@ -29,9 +28,9 @@ from .const import ( ) from .error import PipelineNotFound from .pipeline import ( + KEY_ASSIST_PIPELINE, AudioSettings, DeviceAudioQueue, - PipelineData, PipelineError, PipelineEvent, PipelineEventType, @@ -283,7 +282,7 @@ def websocket_list_runs( msg: dict[str, Any], ) -> None: """List pipeline runs for which debug data is available.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] pipeline_id = msg["pipeline_id"] if pipeline_id not in pipeline_data.pipeline_debug: @@ -319,7 +318,7 @@ def websocket_list_devices( msg: dict[str, Any], ) -> None: """List assist devices.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] ent_reg = er.async_get(hass) connection.send_result( msg["id"], @@ -350,7 +349,7 @@ def websocket_get_run( msg: dict[str, Any], ) -> None: """Get debug data for a pipeline run.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] pipeline_id = msg["pipeline_id"] pipeline_run_id = msg["pipeline_run_id"] @@ -455,7 +454,7 @@ async def websocket_device_capture( msg: dict[str, Any], ) -> None: """Capture raw audio from a satellite device and forward to client.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] device_id = msg["device_id"] # Number of seconds to record audio in wall clock time From b32c401c247b10ef1898b46a346fbcdf0d0a7403 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 18 Jan 2025 21:54:44 +0100 Subject: [PATCH 0609/2987] Fix inconsistently spelled occurrences of "ID" in telegram_bot integration (#135928) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Make all occurrences of "ID" in telegram_bot consistent - change all remaining occurrences of "id" or "Id" to the correct spelling "ID" - change "chat_id" to the UI-friedly "chat ID" - use "ID of the chat …" in descriptions, matching "ID of the message …" - fix the edit_replymarkup action's description to also use "Edits …", matching all other descriptions with "Sends …" or "Edits …" * Use translatable descriptions for the Timeout fields Uses the description from the online documentation that can be translated while the current ones use the action name which makes it difficult to handle in other languages. --- .../components/telegram_bot/strings.json | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 1a02543d4ab..714e7b74db0 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -14,7 +14,7 @@ }, "target": { "name": "Target", - "description": "An array of pre-authorized chat_ids to send the notification to. If not present, first allowed chat_id is the default." + "description": "An array of pre-authorized chat IDs to send the notification to. If not present, first allowed chat ID is the default." }, "parse_mode": { "name": "Parse mode", @@ -30,7 +30,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send message. Will help with timeout errors (poor internet connection, etc)s." + "description": "Timeout for sending the message in seconds. Will help with timeout errors (poor Internet connection, etc)." }, "keyboard": { "name": "Keyboard", @@ -45,11 +45,11 @@ "description": "Tag for sent message." }, "reply_to_message_id": { - "name": "Reply to message id", + "name": "Reply to message ID", "description": "Mark the message as a reply to a previous message." }, "message_thread_id": { - "name": "Message thread id", + "name": "Message thread ID", "description": "Unique identifier for the target message thread (topic) of the forum; for forum supergroups only." } } @@ -84,7 +84,7 @@ }, "target": { "name": "Target", - "description": "An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default." + "description": "An array of pre-authorized chat IDs to send the document to. If not present, first allowed chat ID is the default." }, "parse_mode": { "name": "[%key:component::telegram_bot::services::send_message::fields::parse_mode::name%]", @@ -100,7 +100,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send photo." + "description": "Timeout for sending the photo in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -166,7 +166,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send sticker." + "description": "Timeout for sending the sticker in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -306,7 +306,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send video." + "description": "Timeout for sending the video in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -372,7 +372,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send voice." + "description": "Timeout for sending the voice in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -442,7 +442,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send document." + "description": "Timeout for sending the document in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -480,7 +480,7 @@ }, "target": { "name": "Target", - "description": "An array of pre-authorized chat_ids to send the location to. If not present, first allowed chat_id is the default." + "description": "An array of pre-authorized chat IDs to send the location to. If not present, first allowed chat ID is the default." }, "disable_notification": { "name": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::name%]", @@ -546,7 +546,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send poll." + "description": "Timeout for sending the poll in seconds." }, "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", @@ -568,11 +568,11 @@ "fields": { "message_id": { "name": "Message ID", - "description": "Id of the message to edit." + "description": "ID of the message to edit." }, "chat_id": { "name": "Chat ID", - "description": "The chat_id where to edit the message." + "description": "ID of the chat where to edit the message." }, "message": { "name": "Message", @@ -606,7 +606,7 @@ }, "chat_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", - "description": "The chat_id where to edit the caption." + "description": "ID of the chat where to edit the caption." }, "caption": { "name": "[%key:component::telegram_bot::services::send_photo::fields::caption::name%]", @@ -620,7 +620,7 @@ }, "edit_replymarkup": { "name": "Edit reply markup", - "description": "Edit the inline keyboard of a previously sent message.", + "description": "Edits the inline keyboard of a previously sent message.", "fields": { "message_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::message_id::name%]", @@ -628,7 +628,7 @@ }, "chat_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", - "description": "The chat_id where to edit the reply_markup." + "description": "ID of the chat where to edit the reply markup." }, "inline_keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", @@ -646,7 +646,7 @@ }, "callback_query_id": { "name": "Callback query ID", - "description": "Unique id of the callback response." + "description": "Unique ID of the callback response." }, "show_alert": { "name": "Show alert", @@ -654,7 +654,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for sending the answer." + "description": "Timeout for sending the answer in seconds." } } }, @@ -664,11 +664,11 @@ "fields": { "message_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::message_id::name%]", - "description": "Id of the message to delete." + "description": "ID of the message to delete." }, "chat_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", - "description": "The chat_id where to delete the message." + "description": "ID of the chat where to delete the message." } } } From 37c3a9546c10e53886ff8bd351a9ae9e3c140e86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sat, 18 Jan 2025 21:57:54 +0100 Subject: [PATCH 0610/2987] Update aioairzone to v0.9.9 (#135866) Co-authored-by: J. Nick Koston --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airzone/snapshots/test_diagnostics.ambr | 1 + tests/components/airzone/util.py | 2 ++ 5 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 01fde7eb2fb..95ed9d200f4 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.9.7"] + "requirements": ["aioairzone==0.9.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4804e883b2b..0745febf94e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairq==0.4.3 aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.7 +aioairzone==0.9.9 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c1331477997..9cb0bf97942 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairq==0.4.3 aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.7 +aioairzone==0.9.9 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index fb4f6530b1e..bb44a0abeb1 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -140,6 +140,7 @@ 'heatStages': 1, 'heatangle': 0, 'humidity': 40, + 'master_zoneID': None, 'maxTemp': 30, 'minTemp': 15, 'mode': 3, diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 278663b7a97..b51dfb890e4 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -28,6 +28,7 @@ from aioairzone.const import ( API_HEAT_STAGES, API_HUMIDITY, API_MAC, + API_MASTER_ZONE_ID, API_MAX_TEMP, API_MIN_TEMP, API_MODE, @@ -214,6 +215,7 @@ HVAC_MOCK = { API_FLOOR_DEMAND: 0, API_HEAT_ANGLE: 0, API_COLD_ANGLE: 0, + API_MASTER_ZONE_ID: None, }, ] }, From 659450dac9fc061dc5e7f664ec1b3a9557487a89 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 18 Jan 2025 22:33:41 +0100 Subject: [PATCH 0611/2987] Update knx-frontend to 2025.1.18.164225 (#135941) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 8d18f11c798..73a61be68ee 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "requirements": [ "xknx==3.4.0", "xknxproject==3.8.1", - "knx-frontend==2024.12.26.233449" + "knx-frontend==2025.1.18.164225" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 0745febf94e..20d4bce6440 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1269,7 +1269,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2024.12.26.233449 +knx-frontend==2025.1.18.164225 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9cb0bf97942..8aeac80734c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1071,7 +1071,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2024.12.26.233449 +knx-frontend==2025.1.18.164225 # homeassistant.components.konnected konnected==1.2.0 From 09ae388f4e947f508cedda70b806a839326ea4af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Jan 2025 12:02:18 -1000 Subject: [PATCH 0612/2987] Bump bleak-retry-connector to 3.7.0 (#135939) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 8d596648e52..b6cb08c2009 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,7 +15,7 @@ "quality_scale": "internal", "requirements": [ "bleak==0.22.3", - "bleak-retry-connector==3.6.0", + "bleak-retry-connector==3.7.0", "bluetooth-adapters==0.20.2", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e55fdef7e8d..f9e7cc6a8e3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -18,7 +18,7 @@ audioop-lts==0.2.1;python_version>='3.13' av==13.1.0 awesomeversion==24.6.0 bcrypt==4.2.0 -bleak-retry-connector==3.6.0 +bleak-retry-connector==3.7.0 bleak==0.22.3 bluetooth-adapters==0.20.2 bluetooth-auto-recovery==1.4.2 diff --git a/requirements_all.txt b/requirements_all.txt index 20d4bce6440..3a433039092 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -594,7 +594,7 @@ bizkaibus==0.1.1 bleak-esphome==2.0.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.6.0 +bleak-retry-connector==3.7.0 # homeassistant.components.bluetooth bleak==0.22.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8aeac80734c..d7180bf6f3d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -525,7 +525,7 @@ bimmer-connected[china]==0.17.2 bleak-esphome==2.0.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.6.0 +bleak-retry-connector==3.7.0 # homeassistant.components.bluetooth bleak==0.22.3 From 8a3ef101e63fd3a722cdb5f57f11ee8b0f3d4d09 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 19 Jan 2025 00:43:07 +0100 Subject: [PATCH 0613/2987] Replace additional deprecated USBServiceInfo imports (#135953) --- .../homeassistant_sky_connect/util.py | 6 ++--- homeassistant/components/zha/radio_manager.py | 3 ++- .../test_config_flow.py | 12 +++++----- tests/components/insteon/test_config_flow.py | 7 +++--- .../modem_callerid/test_config_flow.py | 3 ++- tests/components/rainforest_raven/const.py | 3 ++- tests/components/velbus/test_config_flow.py | 4 ++-- tests/components/zha/test_config_flow.py | 23 ++++++++++--------- tests/components/zwave_js/test_config_flow.py | 8 +++---- 9 files changed, 37 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/homeassistant_sky_connect/util.py b/homeassistant/components/homeassistant_sky_connect/util.py index f8c5d004d0e..c463c1b9275 100644 --- a/homeassistant/components/homeassistant_sky_connect/util.py +++ b/homeassistant/components/homeassistant_sky_connect/util.py @@ -4,17 +4,17 @@ from __future__ import annotations import logging -from homeassistant.components import usb from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import HardwareVariant _LOGGER = logging.getLogger(__name__) -def get_usb_service_info(config_entry: ConfigEntry) -> usb.UsbServiceInfo: +def get_usb_service_info(config_entry: ConfigEntry) -> UsbServiceInfo: """Return UsbServiceInfo.""" - return usb.UsbServiceInfo( + return UsbServiceInfo( device=config_entry.data["device"], vid=config_entry.data["vid"], pid=config_entry.data["pid"], diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 82c30b7678a..aaf156290a7 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -29,6 +29,7 @@ from zigpy.exceptions import NetworkNotFormed from homeassistant import config_entries from homeassistant.components import usb from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.usb import UsbServiceInfo from . import repairs from .const import ( @@ -86,7 +87,7 @@ HARDWARE_MIGRATION_SCHEMA = vol.Schema( vol.Required("old_discovery_info"): vol.Schema( { vol.Exclusive("hw", "discovery"): HARDWARE_DISCOVERY_SCHEMA, - vol.Exclusive("usb", "discovery"): usb.UsbServiceInfo, + vol.Exclusive("usb", "discovery"): UsbServiceInfo, } ), } diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 055b6347267..904fcac321c 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -4,7 +4,6 @@ from unittest.mock import Mock, patch import pytest -from homeassistant.components import usb from homeassistant.components.hassio import AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_ZIGBEE, @@ -17,10 +16,11 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon from homeassistant.components.homeassistant_sky_connect.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.usb import UsbServiceInfo from tests.common import MockConfigEntry -USB_DATA_SKY = usb.UsbServiceInfo( +USB_DATA_SKY = UsbServiceInfo( device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", vid="10C4", pid="EA60", @@ -29,7 +29,7 @@ USB_DATA_SKY = usb.UsbServiceInfo( description="SkyConnect v1.0", ) -USB_DATA_ZBT1 = usb.UsbServiceInfo( +USB_DATA_ZBT1 = UsbServiceInfo( device="/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0", vid="10C4", pid="EA60", @@ -47,7 +47,7 @@ USB_DATA_ZBT1 = usb.UsbServiceInfo( ], ) async def test_config_flow( - usb_data: usb.UsbServiceInfo, model: str, hass: HomeAssistant + usb_data: UsbServiceInfo, model: str, hass: HomeAssistant ) -> None: """Test the config flow for SkyConnect.""" result = await hass.config_entries.flow.async_init( @@ -102,7 +102,7 @@ async def test_config_flow( ], ) async def test_options_flow( - usb_data: usb.UsbServiceInfo, model: str, hass: HomeAssistant + usb_data: UsbServiceInfo, model: str, hass: HomeAssistant ) -> None: """Test the options flow for SkyConnect.""" config_entry = MockConfigEntry( @@ -168,7 +168,7 @@ async def test_options_flow( ], ) async def test_options_flow_multipan_uninstall( - usb_data: usb.UsbServiceInfo, model: str, hass: HomeAssistant + usb_data: UsbServiceInfo, model: str, hass: HomeAssistant ) -> None: """Test options flow for when multi-PAN firmware is installed.""" config_entry = MockConfigEntry( diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index 31d38a603f1..9643a6b493e 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -8,7 +8,7 @@ import pytest from voluptuous_serialize import convert from homeassistant import config_entries -from homeassistant.components import dhcp, usb +from homeassistant.components import dhcp from homeassistant.components.insteon.config_flow import ( STEP_HUB_V1, STEP_HUB_V2, @@ -20,6 +20,7 @@ from homeassistant.config_entries import ConfigEntryState, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import ( MOCK_DEVICE, @@ -270,7 +271,7 @@ async def test_failed_connection_hub(hass: HomeAssistant) -> None: async def test_discovery_via_usb(hass: HomeAssistant) -> None: """Test usb flow.""" - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyINSTEON", pid="AAAA", vid="AAAA", @@ -302,7 +303,7 @@ async def test_discovery_via_usb_already_setup(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE: "/dev/ttyUSB1"}} ).add_to_hass(hass) - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyINSTEON", pid="AAAA", vid="AAAA", diff --git a/tests/components/modem_callerid/test_config_flow.py b/tests/components/modem_callerid/test_config_flow.py index 2ae4d6659e7..280deadc733 100644 --- a/tests/components/modem_callerid/test_config_flow.py +++ b/tests/components/modem_callerid/test_config_flow.py @@ -10,10 +10,11 @@ from homeassistant.config_entries import SOURCE_USB, SOURCE_USER from homeassistant.const import CONF_DEVICE, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.usb import UsbServiceInfo from . import com_port, patch_config_flow_modem -DISCOVERY_INFO = usb.UsbServiceInfo( +DISCOVERY_INFO = UsbServiceInfo( device=phone_modem.DEFAULT_PORT, pid="1340", vid="0572", diff --git a/tests/components/rainforest_raven/const.py b/tests/components/rainforest_raven/const.py index 7e75440c30d..320299d2e60 100644 --- a/tests/components/rainforest_raven/const.py +++ b/tests/components/rainforest_raven/const.py @@ -13,8 +13,9 @@ from aioraven.data import ( from iso4217 import Currency from homeassistant.components import usb +from homeassistant.helpers.service_info.usb import UsbServiceInfo -DISCOVERY_INFO = usb.UsbServiceInfo( +DISCOVERY_INFO = UsbServiceInfo( device="/dev/ttyACM0", pid="0x0003", vid="0x04B4", diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index 5e81a3f8a36..04b6a51043f 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -7,18 +7,18 @@ import pytest import serial.tools.list_ports from velbusaio.exceptions import VelbusConnectionFailed -from homeassistant.components import usb from homeassistant.components.velbus.const import DOMAIN from homeassistant.config_entries import SOURCE_USB, SOURCE_USER from homeassistant.const import CONF_NAME, CONF_PORT, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import PORT_SERIAL, PORT_TCP from tests.common import MockConfigEntry -DISCOVERY_INFO = usb.UsbServiceInfo( +DISCOVERY_INFO = UsbServiceInfo( device=PORT_SERIAL, pid="10CF", vid="0B1B", diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 87433ef3911..f630ece771f 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -20,7 +20,7 @@ from zigpy.exceptions import NetworkNotFormed import zigpy.types from homeassistant import config_entries -from homeassistant.components import ssdp, usb, zeroconf +from homeassistant.components import ssdp, zeroconf from homeassistant.components.hassio import AddonError, AddonState from homeassistant.components.zha import config_flow, radio_manager from homeassistant.components.zha.const import ( @@ -46,6 +46,7 @@ from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL, ) +from homeassistant.helpers.service_info.usb import UsbServiceInfo from tests.common import MockConfigEntry @@ -432,7 +433,7 @@ async def test_legacy_zeroconf_discovery_confirm_final_abort_if_entries( @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) async def test_discovery_via_usb(hass: HomeAssistant) -> None: """Test usb flow -- radio detected.""" - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -478,7 +479,7 @@ async def test_discovery_via_usb(hass: HomeAssistant) -> None: @patch(f"zigpy_zigate.{PROBE_FUNCTION_PATH}", return_value=True) async def test_zigate_discovery_via_usb(probe_mock, hass: HomeAssistant) -> None: """Test zigate usb flow -- radio detected.""" - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="0403", vid="6015", @@ -531,7 +532,7 @@ async def test_zigate_discovery_via_usb(probe_mock, hass: HomeAssistant) -> None ) async def test_discovery_via_usb_no_radio(hass: HomeAssistant) -> None: """Test usb flow -- no radio detected.""" - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/null", pid="AAAA", vid="AAAA", @@ -564,7 +565,7 @@ async def test_discovery_via_usb_already_setup(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} ).add_to_hass(hass) - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -598,7 +599,7 @@ async def test_discovery_via_usb_path_does_not_change(hass: HomeAssistant) -> No ) entry.add_to_hass(hass) - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -637,7 +638,7 @@ async def test_discovery_via_usb_deconz_already_discovered(hass: HomeAssistant) context={"source": SOURCE_SSDP}, ) await hass.async_block_till_done() - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -659,7 +660,7 @@ async def test_discovery_via_usb_deconz_already_setup(hass: HomeAssistant) -> No """Test usb flow -- deconz setup.""" MockConfigEntry(domain="deconz", data={}).add_to_hass(hass) await hass.async_block_till_done() - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -683,7 +684,7 @@ async def test_discovery_via_usb_deconz_ignored(hass: HomeAssistant) -> None: domain="deconz", source=config_entries.SOURCE_IGNORE, data={} ).add_to_hass(hass) await hass.async_block_till_done() - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -711,7 +712,7 @@ async def test_discovery_via_usb_zha_ignored_updates(hass: HomeAssistant) -> Non ) entry.add_to_hass(hass) await hass.async_block_till_done() - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -1199,7 +1200,7 @@ async def test_onboarding_auto_formation_new_hardware( """Test auto network formation with new hardware during onboarding.""" mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed()) mock_app.get_device = MagicMock(return_value=MagicMock(spec=zigpy.device.Device)) - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 75bce869a74..e7239c23de6 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -16,12 +16,12 @@ from serial.tools.list_ports_common import ListPortInfo from zwave_js_server.version import VersionInfo from homeassistant import config_entries -from homeassistant.components import usb from homeassistant.components.zwave_js.config_flow import SERVER_VERSION_TIMEOUT, TITLE from homeassistant.components.zwave_js.const import ADDON_SLUG, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -33,7 +33,7 @@ ADDON_DISCOVERY_INFO = { } -USB_DISCOVERY_INFO = usb.UsbServiceInfo( +USB_DISCOVERY_INFO = UsbServiceInfo( device="/dev/zwave", pid="AAAA", vid="AAAA", @@ -42,7 +42,7 @@ USB_DISCOVERY_INFO = usb.UsbServiceInfo( manufacturer="test", ) -NORTEK_ZIGBEE_DISCOVERY_INFO = usb.UsbServiceInfo( +NORTEK_ZIGBEE_DISCOVERY_INFO = UsbServiceInfo( device="/dev/zigbee", pid="8A2A", vid="10C4", @@ -51,7 +51,7 @@ NORTEK_ZIGBEE_DISCOVERY_INFO = usb.UsbServiceInfo( manufacturer="nortek", ) -CP2652_ZIGBEE_DISCOVERY_INFO = usb.UsbServiceInfo( +CP2652_ZIGBEE_DISCOVERY_INFO = UsbServiceInfo( device="/dev/zigbee", pid="EA60", vid="10C4", From 6690b121c039e6a018c99cb8b960760a3af962ab Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 19 Jan 2025 00:47:30 +0100 Subject: [PATCH 0614/2987] Fix unicode chars in zha tests (#135954) --- tests/components/zha/test_config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index f630ece771f..75960c4e73d 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -179,7 +179,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "network": "ethernet", "board": "esp32-poe", "platform": "ESP32", - "maс": "8c4b14c33c24", + "mac": "8c4b14c33c24", "version": "2023.12.8", }, ), @@ -202,7 +202,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "network": "ethernet", "board": "esp32-poe", "platform": "ESP32", - "maс": "8c4b14c33c24", + "mac": "8c4b14c33c24", "version": "2023.12.8", }, ), From 640da1cc676ce1c0b023af93df93a51f6fdfc323 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Jan 2025 13:53:59 -1000 Subject: [PATCH 0615/2987] Bump aiooui to 0.1.8 (#135945) --- homeassistant/components/nmap_tracker/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json index 72e5764fed7..d14ab61348d 100644 --- a/homeassistant/components/nmap_tracker/manifest.json +++ b/homeassistant/components/nmap_tracker/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/nmap_tracker", "iot_class": "local_polling", "loggers": ["nmap"], - "requirements": ["netmap==0.7.0.2", "getmac==0.9.5", "aiooui==0.1.7"] + "requirements": ["netmap==0.7.0.2", "getmac==0.9.5", "aiooui==0.1.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3a433039092..91fcca471e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -321,7 +321,7 @@ aiooncue==0.3.7 aioopenexchangerates==0.6.8 # homeassistant.components.nmap_tracker -aiooui==0.1.7 +aiooui==0.1.8 # homeassistant.components.pegel_online aiopegelonline==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d7180bf6f3d..1b9a75f01f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -303,7 +303,7 @@ aiooncue==0.3.7 aioopenexchangerates==0.6.8 # homeassistant.components.nmap_tracker -aiooui==0.1.7 +aiooui==0.1.8 # homeassistant.components.pegel_online aiopegelonline==0.1.1 From 725d835fab164be4ae7460a3247bc6c616b62026 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Jan 2025 15:01:55 -1000 Subject: [PATCH 0616/2987] Bump aiooui to 0.1.9 (#135956) --- homeassistant/components/nmap_tracker/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json index d14ab61348d..06f94e0566f 100644 --- a/homeassistant/components/nmap_tracker/manifest.json +++ b/homeassistant/components/nmap_tracker/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/nmap_tracker", "iot_class": "local_polling", "loggers": ["nmap"], - "requirements": ["netmap==0.7.0.2", "getmac==0.9.5", "aiooui==0.1.8"] + "requirements": ["netmap==0.7.0.2", "getmac==0.9.5", "aiooui==0.1.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 91fcca471e3..776cc8e97c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -321,7 +321,7 @@ aiooncue==0.3.7 aioopenexchangerates==0.6.8 # homeassistant.components.nmap_tracker -aiooui==0.1.8 +aiooui==0.1.9 # homeassistant.components.pegel_online aiopegelonline==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1b9a75f01f7..955eaed30a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -303,7 +303,7 @@ aiooncue==0.3.7 aioopenexchangerates==0.6.8 # homeassistant.components.nmap_tracker -aiooui==0.1.8 +aiooui==0.1.9 # homeassistant.components.pegel_online aiopegelonline==0.1.1 From fe4e001fa5d4b8ede9a1120acdf0c5f8b47194cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Jan 2025 15:10:15 -1000 Subject: [PATCH 0617/2987] Bump bluetooth-adapters to 0.21.0 (#135957) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index b6cb08c2009..eed276ec6e6 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,7 +16,7 @@ "requirements": [ "bleak==0.22.3", "bleak-retry-connector==3.7.0", - "bluetooth-adapters==0.20.2", + "bluetooth-adapters==0.21.0", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f9e7cc6a8e3..6aa729a89e8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -20,7 +20,7 @@ awesomeversion==24.6.0 bcrypt==4.2.0 bleak-retry-connector==3.7.0 bleak==0.22.3 -bluetooth-adapters==0.20.2 +bluetooth-adapters==0.21.0 bluetooth-auto-recovery==1.4.2 bluetooth-data-tools==1.22.0 cached-ipaddress==0.8.0 diff --git a/requirements_all.txt b/requirements_all.txt index 776cc8e97c2..cf65cb47254 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -619,7 +619,7 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.20.2 +bluetooth-adapters==0.21.0 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 955eaed30a7..bd864b11869 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -543,7 +543,7 @@ bluecurrent-api==1.2.3 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.20.2 +bluetooth-adapters==0.21.0 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 From 32d7a23bff1de7c2a311d4f45857fd0d63d95cf2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 19 Jan 2025 02:13:28 +0100 Subject: [PATCH 0618/2987] Fix duplicated "effect" in Speed field descriptions of flux_led (#135948) --- homeassistant/components/flux_led/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/flux_led/strings.json b/homeassistant/components/flux_led/strings.json index aa56708c645..8f4517ff722 100644 --- a/homeassistant/components/flux_led/strings.json +++ b/homeassistant/components/flux_led/strings.json @@ -101,7 +101,7 @@ }, "speed_pct": { "name": "Speed", - "description": "Effect speed for the custom effect (0-100)." + "description": "The speed of the effect in % (0-100, default 50)." }, "transition": { "name": "Transition", From 754de6f998e213686a04a056fa4c3357467fe37e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 18 Jan 2025 22:33:03 -0500 Subject: [PATCH 0619/2987] Add shared history for conversation agents (#135903) * Add shared history for conversation agents * Remove unused code * Add support for native history items * Store all assistant responses as assistant in history * Add history support to DefaultAgent.async_handle_intents * Make local fallback work * Add default agent history * Add history cleanup * Add tests * ChatHistory -> ChatSession * Address comments * Update snapshots --- .../components/assist_pipeline/pipeline.py | 49 ++- .../components/conversation/__init__.py | 5 + .../components/conversation/default_agent.py | 73 ++-- .../components/conversation/session.py | 327 ++++++++++++++++++ .../openai_conversation/conversation.py | 254 +++++--------- homeassistant/helpers/llm.py | 10 +- .../assist_pipeline/snapshots/test_init.ambr | 14 +- .../snapshots/test_websocket.ambr | 14 +- .../snapshots/test_default_agent.ambr | 38 +- .../conversation/snapshots/test_http.ambr | 24 +- .../conversation/snapshots/test_init.ambr | 26 +- tests/components/conversation/test_session.py | 171 +++++++++ .../snapshots/test_conversation.ambr | 2 +- .../openai_conversation/test_conversation.py | 6 +- tests/syrupy.py | 7 + 15 files changed, 744 insertions(+), 276 deletions(-) create mode 100644 homeassistant/components/conversation/session.py create mode 100644 tests/components/conversation/test_session.py diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index a11b5a657de..9353bbe0007 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1065,7 +1065,8 @@ class PipelineRun: ) processed_locally = self.intent_agent == conversation.HOME_ASSISTANT_AGENT - conversation_result: conversation.ConversationResult | None = None + agent_id = user_input.agent_id + intent_response: intent.IntentResponse | None = None if user_input.agent_id != conversation.HOME_ASSISTANT_AGENT: # Sentence triggers override conversation agent if ( @@ -1075,14 +1076,12 @@ class PipelineRun: ) ) is not None: # Sentence trigger matched - trigger_response = intent.IntentResponse( + agent_id = "sentence_trigger" + intent_response = intent.IntentResponse( self.pipeline.conversation_language ) - trigger_response.async_set_speech(trigger_response_text) - conversation_result = conversation.ConversationResult( - response=trigger_response, - conversation_id=user_input.conversation_id, - ) + intent_response.async_set_speech(trigger_response_text) + # Try local intents first, if preferred. elif self.pipeline.prefer_local_intents and ( intent_response := await conversation.async_handle_intents( @@ -1090,13 +1089,31 @@ class PipelineRun: ) ): # Local intent matched - conversation_result = conversation.ConversationResult( - response=intent_response, - conversation_id=user_input.conversation_id, - ) + agent_id = conversation.HOME_ASSISTANT_AGENT processed_locally = True - if conversation_result is None: + # It was already handled, create response and add to chat history + if intent_response is not None: + async with conversation.async_get_chat_session( + self.hass, user_input + ) as chat_session: + speech: str = intent_response.speech.get("plain", {}).get( + "speech", "" + ) + chat_session.async_add_message( + conversation.ChatMessage( + role="assistant", + agent_id=agent_id, + content=speech, + native=intent_response, + ) + ) + conversation_result = conversation.ConversationResult( + response=intent_response, + conversation_id=chat_session.conversation_id, + ) + + else: # Fall back to pipeline conversation agent conversation_result = await conversation.async_converse( hass=self.hass, @@ -1107,6 +1124,10 @@ class PipelineRun: language=user_input.language, agent_id=user_input.agent_id, ) + speech = conversation_result.response.speech.get("plain", {}).get( + "speech", "" + ) + except Exception as src_error: _LOGGER.exception("Unexpected error during intent recognition") raise IntentRecognitionError( @@ -1126,10 +1147,6 @@ class PipelineRun: ) ) - speech: str = conversation_result.response.speech.get("plain", {}).get( - "speech", "" - ) - return speech async def prepare_text_to_speech(self) -> None: diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 898b7b2cf4f..9c1db128f15 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -48,20 +48,25 @@ from .default_agent import DefaultAgent, async_setup_default_agent from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http from .models import AbstractConversationAgent, ConversationInput, ConversationResult +from .session import ChatMessage, ChatSession, ConverseError, async_get_chat_session from .trace import ConversationTraceEventType, async_conversation_trace_append __all__ = [ "DOMAIN", "HOME_ASSISTANT_AGENT", "OLD_HOME_ASSISTANT_AGENT", + "ChatMessage", + "ChatSession", "ConversationEntity", "ConversationEntityFeature", "ConversationInput", "ConversationResult", "ConversationTraceEventType", + "ConverseError", "async_conversation_trace_append", "async_converse", "async_get_agent_info", + "async_get_chat_session", "async_set_agent", "async_setup", "async_unset_agent", diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 66ffb25fa1a..d4773d50c4b 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -62,6 +62,7 @@ from .const import ( ) from .entity import ConversationEntity from .models import ConversationInput, ConversationResult +from .session import ChatMessage, async_get_chat_session from .trace import ConversationTraceEventType, async_conversation_trace_append _LOGGER = logging.getLogger(__name__) @@ -346,35 +347,52 @@ class DefaultAgent(ConversationEntity): async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" + response: intent.IntentResponse | None = None + async with async_get_chat_session(self.hass, user_input) as chat_session: + # Check if a trigger matched + if trigger_result := await self.async_recognize_sentence_trigger( + user_input + ): + # Process callbacks and get response + response_text = await self._handle_trigger_result( + trigger_result, user_input + ) - # Check if a trigger matched - if trigger_result := await self.async_recognize_sentence_trigger(user_input): - # Process callbacks and get response - response_text = await self._handle_trigger_result( - trigger_result, user_input + # Convert to conversation result + response = intent.IntentResponse( + language=user_input.language or self.hass.config.language + ) + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_speech(response_text) + + if response is None: + # Match intents + intent_result = await self.async_recognize_intent(user_input) + response = await self._async_process_intent_result( + intent_result, user_input + ) + + speech: str = response.speech.get("plain", {}).get("speech", "") + chat_session.async_add_message( + ChatMessage( + role="assistant", + agent_id=user_input.agent_id, + content=speech, + native=response, + ) ) - # Convert to conversation result - response = intent.IntentResponse( - language=user_input.language or self.hass.config.language + return ConversationResult( + response=response, conversation_id=chat_session.conversation_id ) - response.response_type = intent.IntentResponseType.ACTION_DONE - response.async_set_speech(response_text) - - return ConversationResult(response=response) - - # Match intents - intent_result = await self.async_recognize_intent(user_input) - return await self._async_process_intent_result(intent_result, user_input) async def _async_process_intent_result( self, result: RecognizeResult | None, user_input: ConversationInput, - ) -> ConversationResult: + ) -> intent.IntentResponse: """Process user input with intents.""" language = user_input.language or self.hass.config.language - conversation_id = None # Not supported # Intent match or failure lang_intents = await self.async_get_or_load_intents(language) @@ -386,7 +404,6 @@ class DefaultAgent(ConversationEntity): language, intent.IntentResponseErrorCode.NO_INTENT_MATCH, self._get_error_text(ErrorKey.NO_INTENT, lang_intents), - conversation_id, ) if result.unmatched_entities: @@ -408,7 +425,6 @@ class DefaultAgent(ConversationEntity): self._get_error_text( error_response_type, lang_intents, **error_response_args ), - conversation_id, ) # Will never happen because result will be None when no intents are @@ -461,7 +477,6 @@ class DefaultAgent(ConversationEntity): self._get_error_text( error_response_type, lang_intents, **error_response_args ), - conversation_id, ) except intent.IntentHandleError as err: # Intent was valid and entities matched constraints, but an error @@ -473,7 +488,6 @@ class DefaultAgent(ConversationEntity): self._get_error_text( err.response_key or ErrorKey.HANDLE_ERROR, lang_intents ), - conversation_id, ) except intent.IntentUnexpectedError: _LOGGER.exception("Unexpected intent error") @@ -481,7 +495,6 @@ class DefaultAgent(ConversationEntity): language, intent.IntentResponseErrorCode.UNKNOWN, self._get_error_text(ErrorKey.HANDLE_ERROR, lang_intents), - conversation_id, ) if ( @@ -500,9 +513,7 @@ class DefaultAgent(ConversationEntity): ) intent_response.async_set_speech(speech) - return ConversationResult( - response=intent_response, conversation_id=conversation_id - ) + return intent_response def _recognize( self, @@ -1346,22 +1357,18 @@ class DefaultAgent(ConversationEntity): # No error message on failed match return None - conversation_result = await self._async_process_intent_result( - result, user_input - ) - return conversation_result.response + return await self._async_process_intent_result(result, user_input) def _make_error_result( language: str, error_code: intent.IntentResponseErrorCode, response_text: str, - conversation_id: str | None = None, -) -> ConversationResult: +) -> intent.IntentResponse: """Create conversation result with error code and text.""" response = intent.IntentResponse(language=language) response.async_set_error(error_code, response_text) - return ConversationResult(response, conversation_id) + return response def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str, Any]]: diff --git a/homeassistant/components/conversation/session.py b/homeassistant/components/conversation/session.py new file mode 100644 index 00000000000..f9db80afa63 --- /dev/null +++ b/homeassistant/components/conversation/session.py @@ -0,0 +1,327 @@ +"""Conversation history.""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field, replace +from datetime import datetime, timedelta +import logging +from typing import Generic, Literal, TypeVar + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + HassJob, + HassJobType, + HomeAssistant, + callback, +) +from homeassistant.exceptions import HomeAssistantError, TemplateError +from homeassistant.helpers import intent, llm, template +from homeassistant.helpers.event import async_call_later +from homeassistant.util import dt as dt_util, ulid +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN +from .models import ConversationInput, ConversationResult + +DATA_CHAT_HISTORY: HassKey["dict[str, ChatSession]"] = HassKey( + "conversation_chat_session" +) +DATA_CHAT_HISTORY_CLEANUP: HassKey["SessionCleanup"] = HassKey( + "conversation_chat_session_cleanup" +) + +LOGGER = logging.getLogger(__name__) +CONVERSATION_TIMEOUT = timedelta(minutes=5) +_NativeT = TypeVar("_NativeT") + + +class SessionCleanup: + """Helper to clean up the history.""" + + unsub: CALLBACK_TYPE | None = None + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the history cleanup.""" + self.hass = hass + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._on_hass_stop) + self.cleanup_job = HassJob( + self._cleanup, "conversation_history_cleanup", job_type=HassJobType.Callback + ) + + @callback + def schedule(self) -> None: + """Schedule the cleanup.""" + if self.unsub: + return + self.unsub = async_call_later( + self.hass, + CONVERSATION_TIMEOUT.total_seconds() + 1, + self.cleanup_job, + ) + + @callback + def _on_hass_stop(self, event: Event) -> None: + """Cancel the cleanup on shutdown.""" + if self.unsub: + self.unsub() + self.unsub = None + + @callback + def _cleanup(self, now: datetime) -> None: + """Clean up the history and schedule follow-up if necessary.""" + self.unsub = None + all_history = self.hass.data[DATA_CHAT_HISTORY] + + # We mutate original object because current commands could be + # yielding history based on it. + for conversation_id, history in list(all_history.items()): + if history.last_updated + CONVERSATION_TIMEOUT < now: + del all_history[conversation_id] + + # Still conversations left, check again in timeout time. + if all_history: + self.schedule() + + +@asynccontextmanager +async def async_get_chat_session( + hass: HomeAssistant, + user_input: ConversationInput, +) -> AsyncGenerator["ChatSession"]: + """Return chat session.""" + all_history = hass.data.get(DATA_CHAT_HISTORY) + if all_history is None: + all_history = {} + hass.data[DATA_CHAT_HISTORY] = all_history + hass.data[DATA_CHAT_HISTORY_CLEANUP] = SessionCleanup(hass) + + history: ChatSession | None = None + + if user_input.conversation_id is None: + conversation_id = ulid.ulid_now() + + elif history := all_history.get(user_input.conversation_id): + conversation_id = user_input.conversation_id + + else: + # Conversation IDs are ULIDs. We generate a new one if not provided. + # If an old OLID is passed in, we will generate a new one to indicate + # a new conversation was started. If the user picks their own, they + # want to track a conversation and we respect it. + try: + ulid.ulid_to_bytes(user_input.conversation_id) + conversation_id = ulid.ulid_now() + except ValueError: + conversation_id = user_input.conversation_id + + if history: + history = replace(history, messages=history.messages.copy()) + else: + history = ChatSession(hass, conversation_id) + + message: ChatMessage = ChatMessage( + role="user", + agent_id=user_input.agent_id, + content=user_input.text, + ) + history.async_add_message(message) + + yield history + + if history.messages[-1] is message: + LOGGER.debug( + "History opened but no assistant message was added, ignoring update" + ) + return + + history.last_updated = dt_util.utcnow() + all_history[conversation_id] = history + hass.data[DATA_CHAT_HISTORY_CLEANUP].schedule() + + +class ConverseError(HomeAssistantError): + """Error during initialization of conversation. + + Will not be stored in the history. + """ + + def __init__( + self, message: str, conversation_id: str, response: intent.IntentResponse + ) -> None: + """Initialize the error.""" + super().__init__(message) + self.conversation_id = conversation_id + self.response = response + + def as_converstation_result(self) -> ConversationResult: + """Return the error as a conversation result.""" + return ConversationResult( + response=self.response, + conversation_id=self.conversation_id, + ) + + +@dataclass +class ChatMessage(Generic[_NativeT]): + """Base class for chat messages. + + When role is native, the content is to be ignored and message + is only meant for storing the native object. + """ + + role: Literal["system", "assistant", "user", "native"] + agent_id: str | None + content: str + native: _NativeT | None = field(default=None) + + # Validate in post-init that if role is native, there is no content and a native object exists + def __post_init__(self) -> None: + """Validate native message.""" + if self.role == "native" and self.native is None: + raise ValueError("Native message must have a native object") + + +@dataclass +class ChatSession(Generic[_NativeT]): + """Class holding all information for a specific conversation.""" + + hass: HomeAssistant + conversation_id: str + user_name: str | None = None + messages: list[ChatMessage[_NativeT]] = field( + default_factory=lambda: [ChatMessage(role="system", agent_id=None, content="")] + ) + extra_system_prompt: str | None = None + llm_api: llm.APIInstance | None = None + last_updated: datetime = field(default_factory=dt_util.utcnow) + + @callback + def async_add_message(self, message: ChatMessage[_NativeT]) -> None: + """Process intent.""" + if message.role == "system": + raise ValueError("Cannot add system messages to history") + if message.role != "native" and self.messages[-1].role == message.role: + raise ValueError("Cannot add two assistant or user messages in a row") + + self.messages.append(message) + + @callback + def async_get_messages(self, agent_id: str | None) -> list[ChatMessage[_NativeT]]: + """Get messages for a specific agent ID. + + This will filter out any native message tied to other agent IDs. + It can still include assistant/user messages generated by other agents. + """ + return [ + message + for message in self.messages + if message.role != "native" or message.agent_id == agent_id + ] + + async def async_process_llm_message( + self, + conversing_domain: str, + user_input: ConversationInput, + user_llm_hass_api: str | None = None, + user_llm_prompt: str | None = None, + ) -> None: + """Process an incoming message for an LLM.""" + llm_context = llm.LLMContext( + platform=conversing_domain, + context=user_input.context, + user_prompt=user_input.text, + language=user_input.language, + assistant=DOMAIN, + device_id=user_input.device_id, + ) + + llm_api: llm.APIInstance | None = None + + if user_llm_hass_api: + try: + llm_api = await llm.async_get_api( + self.hass, + user_llm_hass_api, + llm_context, + ) + except HomeAssistantError as err: + LOGGER.error( + "Error getting LLM API %s for %s: %s", + user_llm_hass_api, + conversing_domain, + err, + ) + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + "Error preparing LLM API", + ) + raise ConverseError( + f"Error getting LLM API {user_llm_hass_api}", + conversation_id=self.conversation_id, + response=intent_response, + ) from err + + user_name: str | None = None + + if ( + user_input.context + and user_input.context.user_id + and ( + user := await self.hass.auth.async_get_user(user_input.context.user_id) + ) + ): + user_name = user.name + + try: + prompt_parts = [ + template.Template( + llm.BASE_PROMPT + + (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT), + self.hass, + ).async_render( + { + "ha_name": self.hass.config.location_name, + "user_name": user_name, + "llm_context": llm_context, + }, + parse_result=False, + ) + ] + + except TemplateError as err: + LOGGER.error("Error rendering prompt: %s", err) + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + "Sorry, I had a problem with my template", + ) + raise ConverseError( + "Error rendering prompt", + conversation_id=self.conversation_id, + response=intent_response, + ) from err + + if llm_api: + prompt_parts.append(llm_api.api_prompt) + + extra_system_prompt = ( + # Take new system prompt if one was given + user_input.extra_system_prompt or self.extra_system_prompt + ) + + if extra_system_prompt: + prompt_parts.append(extra_system_prompt) + + prompt = "\n".join(prompt_parts) + + self.llm_api = llm_api + self.user_name = user_name + self.extra_system_prompt = extra_system_prompt + self.messages[0] = ChatMessage( + role="system", + agent_id=user_input.agent_id, + content=prompt, + ) diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index b3f31ae9b47..9a6b61e4c43 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -1,9 +1,8 @@ """Conversation support for OpenAI.""" from collections.abc import Callable -from dataclasses import dataclass, field import json -from typing import Any, Literal +from typing import Any, Literal, cast import openai from openai._types import NOT_GIVEN @@ -12,10 +11,8 @@ from openai.types.chat import ( ChatCompletionMessage, ChatCompletionMessageParam, ChatCompletionMessageToolCallParam, - ChatCompletionSystemMessageParam, ChatCompletionToolMessageParam, ChatCompletionToolParam, - ChatCompletionUserMessageParam, ) from openai.types.chat.chat_completion_message_tool_call_param import Function from openai.types.shared_params import FunctionDefinition @@ -27,10 +24,9 @@ from homeassistant.components.conversation import trace from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.helpers import device_registry as dr, intent, llm, template +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid from . import OpenAIConfigEntry from .const import ( @@ -74,12 +70,28 @@ def _format_tool( return ChatCompletionToolParam(type="function", function=tool_spec) -@dataclass -class ChatHistory: - """Class holding the chat history.""" - - extra_system_prompt: str | None = None - messages: list[ChatCompletionMessageParam] = field(default_factory=list) +def _message_convert(message: ChatCompletionMessage) -> ChatCompletionMessageParam: + """Convert from class to TypedDict.""" + tool_calls: list[ChatCompletionMessageToolCallParam] = [] + if message.tool_calls: + tool_calls = [ + ChatCompletionMessageToolCallParam( + id=tool_call.id, + function=Function( + arguments=tool_call.function.arguments, + name=tool_call.function.name, + ), + type=tool_call.type, + ) + for tool_call in message.tool_calls + ] + param = ChatCompletionAssistantMessageParam( + role=message.role, + content=message.content, + ) + if tool_calls: + param["tool_calls"] = tool_calls + return param class OpenAIConversationEntity( @@ -93,7 +105,6 @@ class OpenAIConversationEntity( def __init__(self, entry: OpenAIConfigEntry) -> None: """Initialize the agent.""" self.entry = entry - self.history: dict[str, ChatHistory] = {} self._attr_unique_id = entry.entry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, @@ -132,127 +143,56 @@ class OpenAIConversationEntity( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: """Process a sentence.""" + async with conversation.async_get_chat_session( + self.hass, user_input + ) as session: + return await self._async_call_api(user_input, session) + + async def _async_call_api( + self, + user_input: conversation.ConversationInput, + session: conversation.ChatSession[ChatCompletionMessageParam], + ) -> conversation.ConversationResult: + """Call the API.""" options = self.entry.options - intent_response = intent.IntentResponse(language=user_input.language) - llm_api: llm.APIInstance | None = None - tools: list[ChatCompletionToolParam] | None = None - user_name: str | None = None - llm_context = llm.LLMContext( - platform=DOMAIN, - context=user_input.context, - user_prompt=user_input.text, - language=user_input.language, - assistant=conversation.DOMAIN, - device_id=user_input.device_id, - ) - - if options.get(CONF_LLM_HASS_API): - try: - llm_api = await llm.async_get_api( - self.hass, - options[CONF_LLM_HASS_API], - llm_context, - ) - except HomeAssistantError as err: - LOGGER.error("Error getting LLM API: %s", err) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Error preparing LLM API", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=user_input.conversation_id - ) - tools = [ - _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools - ] - - history: ChatHistory | None = None - - if user_input.conversation_id is None: - conversation_id = ulid.ulid_now() - - elif user_input.conversation_id in self.history: - conversation_id = user_input.conversation_id - history = self.history.get(conversation_id) - - else: - # Conversation IDs are ULIDs. We generate a new one if not provided. - # If an old OLID is passed in, we will generate a new one to indicate - # a new conversation was started. If the user picks their own, they - # want to track a conversation and we respect it. - try: - ulid.ulid_to_bytes(user_input.conversation_id) - conversation_id = ulid.ulid_now() - except ValueError: - conversation_id = user_input.conversation_id - - if history is None: - history = ChatHistory(user_input.extra_system_prompt) - - if ( - user_input.context - and user_input.context.user_id - and ( - user := await self.hass.auth.async_get_user(user_input.context.user_id) - ) - ): - user_name = user.name try: - prompt_parts = [ - template.Template( - llm.BASE_PROMPT - + options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT), - self.hass, - ).async_render( - { - "ha_name": self.hass.config.location_name, - "user_name": user_name, - "llm_context": llm_context, - }, - parse_result=False, - ) + await session.async_process_llm_message( + DOMAIN, + user_input, + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), + ) + except conversation.ConverseError as err: + return err.as_converstation_result() + + tools: list[ChatCompletionToolParam] | None = None + if session.llm_api: + tools = [ + _format_tool(tool, session.llm_api.custom_serializer) + for tool in session.llm_api.tools ] - except TemplateError as err: - LOGGER.error("Error rendering prompt: %s", err) - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Sorry, I had a problem with my template", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id - ) + messages: list[ChatCompletionMessageParam] = [] + for message in session.async_get_messages(user_input.agent_id): + if message.native is not None and message.agent_id == user_input.agent_id: + messages.append(message.native) + else: + messages.append( + cast( + ChatCompletionMessageParam, + {"role": message.role, "content": message.content}, + ) + ) - if llm_api: - prompt_parts.append(llm_api.api_prompt) - - extra_system_prompt = ( - # Take new system prompt if one was given - user_input.extra_system_prompt or history.extra_system_prompt - ) - - if extra_system_prompt: - prompt_parts.append(extra_system_prompt) - - prompt = "\n".join(prompt_parts) - - # Create a copy of the variable because we attach it to the trace - history = ChatHistory( - extra_system_prompt, - [ - ChatCompletionSystemMessageParam(role="system", content=prompt), - *history.messages[1:], - ChatCompletionUserMessageParam(role="user", content=user_input.text), - ], - ) - - LOGGER.debug("Prompt: %s", history.messages) + LOGGER.debug("Prompt: %s", messages) LOGGER.debug("Tools: %s", tools) trace.async_conversation_trace_append( trace.ConversationTraceEventType.AGENT_DETAIL, - {"messages": history.messages, "tools": llm_api.tools if llm_api else None}, + { + "messages": session.messages, + "tools": session.llm_api.tools if session.llm_api else None, + }, ) client = self.entry.runtime_data @@ -262,12 +202,12 @@ class OpenAIConversationEntity( try: result = await client.chat.completions.create( model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), - messages=history.messages, + messages=messages, tools=tools or NOT_GIVEN, max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P), temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), - user=conversation_id, + user=session.conversation_id, ) except openai.OpenAIError as err: LOGGER.error("Error talking to OpenAI: %s", err) @@ -277,44 +217,26 @@ class OpenAIConversationEntity( "Sorry, I had a problem talking to OpenAI", ) return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id + response=intent_response, conversation_id=session.conversation_id ) LOGGER.debug("Response %s", result) response = result.choices[0].message + messages.append(_message_convert(response)) - def message_convert( - message: ChatCompletionMessage, - ) -> ChatCompletionMessageParam: - """Convert from class to TypedDict.""" - tool_calls: list[ChatCompletionMessageToolCallParam] = [] - if message.tool_calls: - tool_calls = [ - ChatCompletionMessageToolCallParam( - id=tool_call.id, - function=Function( - arguments=tool_call.function.arguments, - name=tool_call.function.name, - ), - type=tool_call.type, - ) - for tool_call in message.tool_calls - ] - param = ChatCompletionAssistantMessageParam( - role=message.role, - content=message.content, - ) - if tool_calls: - param["tool_calls"] = tool_calls - return param + session.async_add_message( + conversation.ChatMessage( + role=response.role, + agent_id=user_input.agent_id, + content=response.content or "", + native=messages[-1], + ), + ) - history.messages.append(message_convert(response)) - tool_calls = response.tool_calls - - if not tool_calls or not llm_api: + if not response.tool_calls or not session.llm_api: break - for tool_call in tool_calls: + for tool_call in response.tool_calls: tool_input = llm.ToolInput( tool_name=tool_call.function.name, tool_args=json.loads(tool_call.function.arguments), @@ -324,27 +246,33 @@ class OpenAIConversationEntity( ) try: - tool_response = await llm_api.async_call_tool(tool_input) + tool_response = await session.llm_api.async_call_tool(tool_input) except (HomeAssistantError, vol.Invalid) as e: tool_response = {"error": type(e).__name__} if str(e): tool_response["error_text"] = str(e) LOGGER.debug("Tool response: %s", tool_response) - history.messages.append( + messages.append( ChatCompletionToolMessageParam( role="tool", tool_call_id=tool_call.id, content=json.dumps(tool_response), ) ) - - self.history[conversation_id] = history + session.async_add_message( + conversation.ChatMessage( + role="native", + agent_id=user_input.agent_id, + content="", + native=messages[-1], + ) + ) intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_speech(response.content or "") return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id + response=intent_response, conversation_id=session.conversation_id ) async def _async_entry_update_listener( diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index cb303f4aa65..f66794165f0 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -15,10 +15,6 @@ import voluptuous as vol from voluptuous_openapi import UNSUPPORTED, convert from homeassistant.components.climate import INTENT_GET_TEMPERATURE -from homeassistant.components.conversation import ( - ConversationTraceEventType, - async_conversation_trace_append, -) from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.intent import async_device_supports_timers @@ -171,6 +167,12 @@ class APIInstance: async def async_call_tool(self, tool_input: ToolInput) -> JsonObjectType: """Call a LLM tool, validate args and return the response.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components.conversation import ( + ConversationTraceEventType, + async_conversation_trace_append, + ) + async_conversation_trace_append( ConversationTraceEventType.TOOL_CALL, {"tool_name": tool_input.tool_name, "tool_args": tool_input.tool_args}, diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 171014fdc4a..526e1bff151 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -44,7 +44,7 @@ dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -135,7 +135,7 @@ dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -226,7 +226,7 @@ dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -341,7 +341,7 @@ dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -446,7 +446,7 @@ dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -497,7 +497,7 @@ dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -548,7 +548,7 @@ dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 41747a50eb6..917a9b654d5 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -42,7 +42,7 @@ # name: test_audio_pipeline.4 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -125,7 +125,7 @@ # name: test_audio_pipeline_debug.4 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -220,7 +220,7 @@ # name: test_audio_pipeline_with_enhancements.4 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -325,7 +325,7 @@ # name: test_audio_pipeline_with_wake_word_no_timeout.6 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -585,7 +585,7 @@ # name: test_pipeline_empty_tts_output.2 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -698,7 +698,7 @@ # name: test_text_only_pipeline[extra_msg0].2 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -744,7 +744,7 @@ # name: test_text_only_pipeline[extra_msg1].2 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), diff --git a/tests/components/conversation/snapshots/test_default_agent.ambr b/tests/components/conversation/snapshots/test_default_agent.ambr index f1e220b10b2..c2b16ea2912 100644 --- a/tests/components/conversation/snapshots/test_default_agent.ambr +++ b/tests/components/conversation/snapshots/test_default_agent.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_custom_sentences dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -26,7 +26,7 @@ # --- # name: test_custom_sentences.1 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -51,7 +51,7 @@ # --- # name: test_custom_sentences_config dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -76,7 +76,7 @@ # --- # name: test_intent_alias_added_removed dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -106,7 +106,7 @@ # --- # name: test_intent_alias_added_removed.1 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -136,7 +136,7 @@ # --- # name: test_intent_alias_added_removed.2 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -156,7 +156,7 @@ # --- # name: test_intent_conversion_not_expose_new dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -176,7 +176,7 @@ # --- # name: test_intent_conversion_not_expose_new.1 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -206,7 +206,7 @@ # --- # name: test_intent_entity_added_removed dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -236,7 +236,7 @@ # --- # name: test_intent_entity_added_removed.1 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -266,7 +266,7 @@ # --- # name: test_intent_entity_added_removed.2 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -296,7 +296,7 @@ # --- # name: test_intent_entity_added_removed.3 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -316,7 +316,7 @@ # --- # name: test_intent_entity_exposed dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -346,7 +346,7 @@ # --- # name: test_intent_entity_fail_if_unexposed dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -366,7 +366,7 @@ # --- # name: test_intent_entity_remove_custom_name dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -386,7 +386,7 @@ # --- # name: test_intent_entity_remove_custom_name.1 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -416,7 +416,7 @@ # --- # name: test_intent_entity_remove_custom_name.2 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -436,7 +436,7 @@ # --- # name: test_intent_entity_renamed dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -466,7 +466,7 @@ # --- # name: test_intent_entity_renamed.1 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 0de575790db..1102a41e6c3 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -201,7 +201,7 @@ # --- # name: test_http_api_handle_failure dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -221,7 +221,7 @@ # --- # name: test_http_api_no_match dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -241,7 +241,7 @@ # --- # name: test_http_api_unexpected_failure dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -261,7 +261,7 @@ # --- # name: test_http_processing_intent[None] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -291,7 +291,7 @@ # --- # name: test_http_processing_intent[conversation.home_assistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -321,7 +321,7 @@ # --- # name: test_http_processing_intent[homeassistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -351,7 +351,7 @@ # --- # name: test_ws_api[payload0] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -371,7 +371,7 @@ # --- # name: test_ws_api[payload1] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -391,7 +391,7 @@ # --- # name: test_ws_api[payload2] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -411,7 +411,7 @@ # --- # name: test_ws_api[payload3] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -431,7 +431,7 @@ # --- # name: test_ws_api[payload4] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -451,7 +451,7 @@ # --- # name: test_ws_api[payload5] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 0327be064d4..911c7043a6d 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_custom_agent dict({ - 'conversation_id': 'test-conv-id', + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -44,7 +44,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-None] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -74,7 +74,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-conversation.home_assistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -104,7 +104,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-homeassistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -134,7 +134,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-None] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -164,7 +164,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-conversation.home_assistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -194,7 +194,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-homeassistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -224,7 +224,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-None] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -254,7 +254,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-conversation.home_assistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -284,7 +284,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-homeassistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -314,7 +314,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-None] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -344,7 +344,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-conversation.home_assistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -374,7 +374,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-homeassistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), diff --git a/tests/components/conversation/test_session.py b/tests/components/conversation/test_session.py new file mode 100644 index 00000000000..45cb517528d --- /dev/null +++ b/tests/components/conversation/test_session.py @@ -0,0 +1,171 @@ +"""Test the conversation session.""" + +from collections.abc import Generator +from datetime import timedelta +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.conversation import ConversationInput, session +from homeassistant.core import Context, HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed + + +@pytest.fixture +def mock_conversation_input(hass: HomeAssistant) -> ConversationInput: + """Return a conversation input instance.""" + return ConversationInput( + text="Hello", + context=Context(), + conversation_id=None, + agent_id="mock-agent-id", + device_id=None, + language="en", + ) + + +@pytest.fixture +def mock_ulid() -> Generator[Mock]: + """Mock the ulid library.""" + with patch("homeassistant.util.ulid.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-ulid" + yield mock_ulid_now + + +@pytest.mark.parametrize( + ("start_id", "given_id"), + [ + (None, "mock-ulid"), + # This ULID is not known as a session + ("01JHXE0952TSJCFJZ869AW6HMD", "mock-ulid"), + ("not-a-ulid", "not-a-ulid"), + ], +) +async def test_conversation_id( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, + mock_ulid: Mock, + start_id: str | None, + given_id: str, +) -> None: + """Test conversation ID generation.""" + mock_conversation_input.conversation_id = start_id + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + assert chat_session.conversation_id == given_id + + +async def test_cleanup( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Mock cleanup of the conversation session.""" + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + assert len(chat_session.messages) == 2 + conversation_id = chat_session.conversation_id + + # Generate session entry. + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + # Because we didn't add a message to the session in the last block, + # the conversation was not be persisted and we get a new ID + assert chat_session.conversation_id != conversation_id + conversation_id = chat_session.conversation_id + chat_session.async_add_message( + session.ChatMessage( + role="assistant", + agent_id="mock-agent-id", + content="Hey!", + ) + ) + assert len(chat_session.messages) == 3 + + # Reuse conversation ID to ensure we can chat with same session + mock_conversation_input.conversation_id = conversation_id + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + assert len(chat_session.messages) == 4 + assert chat_session.conversation_id == conversation_id + + async_fire_time_changed( + hass, dt_util.utcnow() + session.CONVERSATION_TIMEOUT + timedelta(seconds=1) + ) + + # It should be cleaned up now and we start a new conversation + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + assert chat_session.conversation_id != conversation_id + assert len(chat_session.messages) == 2 + + +async def test_message_filtering( + hass: HomeAssistant, mock_conversation_input: ConversationInput +) -> None: + """Test filtering of messages.""" + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + messages = chat_session.async_get_messages(agent_id=None) + assert len(messages) == 2 + assert messages[0] == session.ChatMessage( + role="system", + agent_id=None, + content="", + ) + assert messages[1] == session.ChatMessage( + role="user", + agent_id=mock_conversation_input.agent_id, + content=mock_conversation_input.text, + ) + # Cannot add a second user message in a row + with pytest.raises(ValueError): + chat_session.async_add_message( + session.ChatMessage( + role="user", + agent_id="mock-agent-id", + content="Hey!", + ) + ) + + chat_session.async_add_message( + session.ChatMessage( + role="assistant", + agent_id="mock-agent-id", + content="Hey!", + native="assistant-reply-native", + ) + ) + # Different agent, will be filtered out. + chat_session.async_add_message( + session.ChatMessage( + role="native", agent_id="another-mock-agent-id", content="", native=1 + ) + ) + chat_session.async_add_message( + session.ChatMessage( + role="native", agent_id="mock-agent-id", content="", native=1 + ) + ) + + assert len(chat_session.messages) == 5 + + messages = chat_session.async_get_messages(agent_id="mock-agent-id") + assert len(messages) == 4 + + assert messages[2] == session.ChatMessage( + role="assistant", + agent_id="mock-agent-id", + content="Hey!", + native="assistant-reply-native", + ) + assert messages[3] == session.ChatMessage( + role="native", agent_id="mock-agent-id", content="", native=1 + ) diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index eaa3a9de64c..4ef8b8655ee 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_unknown_hass_api dict({ - 'conversation_id': None, + 'conversation_id': 'my-conversation-id', 'response': IntentResponse( card=dict({ }), diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 774f60ed666..b89ddcd8921 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -625,7 +625,11 @@ async def test_unknown_hass_api( await hass.async_block_till_done() result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + hass, + "hello", + "my-conversation-id", + Context(), + agent_id=mock_config_entry.entry_id, ) assert result == snapshot diff --git a/tests/syrupy.py b/tests/syrupy.py index 8812b3c3880..5b1e5faa23d 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -109,6 +109,8 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): serializable_data = cls._serializable_issue_registry_entry(data) elif isinstance(data, dict) and "flow_id" in data and "handler" in data: serializable_data = cls._serializable_flow_result(data) + elif isinstance(data, dict) and set(data) == {"conversation_id", "response"}: + serializable_data = cls._serializable_conversation_result(data) elif isinstance(data, vol.Schema): serializable_data = voluptuous_serialize.convert(data) elif isinstance(data, ConfigEntry): @@ -200,6 +202,11 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): """Prepare a Home Assistant flow result for serialization.""" return FlowResultSnapshot(data | {"flow_id": ANY}) + @classmethod + def _serializable_conversation_result(cls, data: dict) -> SerializableData: + """Prepare a Home Assistant conversation result for serialization.""" + return data | {"conversation_id": ANY} + @classmethod def _serializable_issue_registry_entry( cls, data: ir.IssueEntry From 02347d5d366c311ee685ca2ab4f79682c8f60fa8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 19 Jan 2025 11:13:37 +0100 Subject: [PATCH 0620/2987] Improve backup store in tests (#135974) --- tests/components/backup/test_websocket.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 00185f8ed07..7498fbe2a67 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -53,7 +53,7 @@ BACKUP_CALL = call( ) DEFAULT_STORAGE_DATA: dict[str, Any] = { - "backups": {}, + "backups": [], "config": { "create_backup": { "agent_ids": [], @@ -910,7 +910,7 @@ async def test_agents_info( { "backup": { "data": { - "backups": {}, + "backups": [], "config": { "create_backup": { "agent_ids": ["test-agent"], @@ -934,7 +934,7 @@ async def test_agents_info( { "backup": { "data": { - "backups": {}, + "backups": [], "config": { "create_backup": { "agent_ids": ["test-agent"], @@ -958,7 +958,7 @@ async def test_agents_info( { "backup": { "data": { - "backups": {}, + "backups": [], "config": { "create_backup": { "agent_ids": ["test-agent"], @@ -982,7 +982,7 @@ async def test_agents_info( { "backup": { "data": { - "backups": {}, + "backups": [], "config": { "create_backup": { "agent_ids": ["test-agent"], @@ -1006,7 +1006,7 @@ async def test_agents_info( { "backup": { "data": { - "backups": {}, + "backups": [], "config": { "create_backup": { "agent_ids": ["test-agent"], @@ -1392,7 +1392,7 @@ async def test_config_schedule_logic( """Test config schedule logic.""" client = await hass_ws_client(hass) storage_data = { - "backups": {}, + "backups": [], "config": { "create_backup": { "agent_ids": ["test.test-agent"], @@ -1838,7 +1838,7 @@ async def test_config_retention_copies_logic( """Test config backup retention copies logic.""" client = await hass_ws_client(hass) storage_data = { - "backups": {}, + "backups": [], "config": { "create_backup": { "agent_ids": ["test-agent"], @@ -2095,7 +2095,7 @@ async def test_config_retention_copies_logic_manual_backup( """Test config backup retention copies logic for manual backup.""" client = await hass_ws_client(hass) storage_data = { - "backups": {}, + "backups": [], "config": { "create_backup": { "agent_ids": ["test-agent"], @@ -2515,7 +2515,7 @@ async def test_config_retention_days_logic( """Test config backup retention logic.""" client = await hass_ws_client(hass) storage_data = { - "backups": {}, + "backups": [], "config": { "create_backup": { "agent_ids": ["test-agent"], From 85bea5b70eedb03ad5810466571e0ef580b22f43 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Sun, 19 Jan 2025 03:43:16 -0700 Subject: [PATCH 0621/2987] Vesync switch humidifier to property (#135949) --- homeassistant/components/vesync/humidifier.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index aef92f73ea5..9c54afdfb82 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -120,12 +120,12 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): @property def target_humidity(self) -> int: """Return the humidity we try to reach.""" - return self.device.config["auto_target_humidity"] + return self.device.auto_humidity @property def mode(self) -> str | None: """Get the current preset mode.""" - return _get_ha_mode(self.device.details["mode"]) + return _get_ha_mode(self.device.mode) def set_humidity(self, humidity: int) -> None: """Set the target humidity of the device.""" From 0d968267a291bdd2d3c6cb07bde54e0a263acae5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jan 2025 00:55:13 -1000 Subject: [PATCH 0622/2987] Improve remote Bluetooth scanner manufacturer data (#135961) Co-authored-by: Joostlek --- .../components/bluetooth/__init__.py | 17 +++++++---- .../components/bluetooth/test_base_scanner.py | 30 +++++++++++++++++-- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 63d66905938..5edec1ccc23 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -22,6 +22,7 @@ from bluetooth_adapters import ( adapter_model, adapter_unique_name, get_adapters, + get_manufacturer_from_mac, ) from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME from habluetooth import ( @@ -333,15 +334,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: address = entry.unique_id assert address is not None assert source_entry is not None + source_domain = entry.data[CONF_SOURCE_DOMAIN] + if mac_manufacturer := await get_manufacturer_from_mac(address): + manufacturer = f"{mac_manufacturer} ({source_domain})" + else: + manufacturer = source_domain + details = AdapterDetails( + address=address, + product=entry.data.get(CONF_SOURCE_MODEL), + manufacturer=manufacturer, + ) await async_update_device( hass, entry, source_entry.title, - AdapterDetails( - address=address, - product=entry.data.get(CONF_SOURCE_MODEL), - manufacturer=entry.data[CONF_SOURCE_DOMAIN], - ), + details, ) return True manager = _get_manager(hass) diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index fda035b9061..e3bdca256c0 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -25,7 +25,9 @@ from homeassistant.components.bluetooth.const import ( UNAVAILABLE_TRACK_SECONDS, ) from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads @@ -523,7 +525,19 @@ async def test_scanner_stops_responding(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("enable_bluetooth") -async def test_remote_scanner_bluetooth_config_entry(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("manufacturer", "source"), + [ + ("test", "test"), + ("Raspberry Pi Trading Ltd (test)", "28:CD:C1:11:23:45"), + ], +) +async def test_remote_scanner_bluetooth_config_entry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + manufacturer: str, + source: str, +) -> None: """Test the remote scanner gets a bluetooth config entry.""" manager: HomeAssistantBluetoothManager = _get_manager() @@ -543,8 +557,9 @@ async def test_remote_scanner_bluetooth_config_entry(hass: HomeAssistant) -> Non connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner("esp32", "esp32", connector, True) + scanner = FakeScanner(source, source, connector, True) unsetup = scanner.async_setup() + assert scanner.source == source entry = MockConfigEntry(domain="test") entry.add_to_hass(hass) cancel = manager.async_register_hass_scanner( @@ -561,9 +576,18 @@ async def test_remote_scanner_bluetooth_config_entry(hass: HomeAssistant) -> Non cancel() unsetup() - assert hass.config_entries.async_entry_for_domain_unique_id( + adapter_entry = hass.config_entries.async_entry_for_domain_unique_id( "bluetooth", scanner.source ) + assert adapter_entry is not None + assert adapter_entry.state is ConfigEntryState.LOADED + + dev = device_registry.async_get_device( + connections={(dr.CONNECTION_BLUETOOTH, scanner.source)} + ) + assert dev is not None + assert dev.config_entries == {adapter_entry.entry_id} + assert dev.manufacturer == manufacturer manager.async_remove_scanner(scanner.source) await hass.async_block_till_done() From f3222045ae07e6618a97d592ccc6aa419eb281ce Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 19 Jan 2025 11:56:34 +0100 Subject: [PATCH 0623/2987] Change 'device_id' to translatable 'device ID', fix typos in LCN (#135978) --- homeassistant/components/lcn/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index 5e69d6810ae..0bdd85a3678 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -17,7 +17,7 @@ "config": { "step": { "user": { - "title": "Setup LCN host", + "title": "Set up LCN host", "description": "Set up new connection to LCN host.", "data": { "host": "[%key:common::config_flow::data::name%]", @@ -76,15 +76,15 @@ "issues": { "deprecated_regulatorlock_sensor": { "title": "Deprecated LCN regulator lock binary sensor", - "description": "Your LCN regulator lock binary sensor entity `{entity}` is beeing used in automations or scripts. A regulator lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." + "description": "Your LCN regulator lock binary sensor entity `{entity}` is being used in automations or scripts. A regulator lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." }, "deprecated_keylock_sensor": { "title": "Deprecated LCN key lock binary sensor", - "description": "Your LCN key lock binary sensor entity `{entity}` is beeing used in automations or scripts. A key lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." + "description": "Your LCN key lock binary sensor entity `{entity}` is being used in automations or scripts. A key lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." }, "deprecated_address_parameter": { "title": "Deprecated 'address' parameter", - "description": "The 'address' parameter in the LCN action calls is deprecated. The 'device_id' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." + "description": "The 'address' parameter in the LCN action calls is deprecated. The 'device ID' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." } }, "services": { @@ -94,7 +94,7 @@ "fields": { "device_id": { "name": "[%key:common::config_flow::data::device%]", - "description": "The device_id of the LCN module or group." + "description": "The device ID of the LCN module or group." }, "address": { "name": "Address", From 33d552e3f7ea8ac55ff34a5426d8fb6bf83da34a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 19 Jan 2025 11:58:38 +0100 Subject: [PATCH 0624/2987] Add power switch only if it is available at Home Connect (#135930) --- homeassistant/components/home_connect/switch.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 305077bfb86..e1ea4c2b4ce 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -98,6 +98,12 @@ SWITCHES = ( ) +POWER_SWITCH_DESCRIPTION = SwitchEntityDescription( + key=BSH_POWER_STATE, + translation_key="power", +) + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -117,7 +123,8 @@ async def async_setup_entry( HomeConnectProgramSwitch(device, program) for program in programs ) - entities.append(HomeConnectPowerSwitch(device)) + if BSH_POWER_STATE in device.appliance.status: + entities.append(HomeConnectPowerSwitch(device)) entities.extend( HomeConnectSwitch(device, description) for description in SWITCHES @@ -310,7 +317,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): """Initialize the entity.""" super().__init__( device, - SwitchEntityDescription(key=BSH_POWER_STATE, translation_key="power"), + POWER_SWITCH_DESCRIPTION, ) if ( power_state := device.appliance.status.get(BSH_POWER_STATE, {}).get( From ac58494b5538be7c1e5ddfb71eac3f0c2c670c48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 19 Jan 2025 12:02:23 +0100 Subject: [PATCH 0625/2987] Improve program related sensors at Home Connect (#135929) --- .../components/home_connect/sensor.py | 84 ++++++++++++------- 1 file changed, 52 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 3ccf55bac6e..7b82ef8b676 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,13 +1,10 @@ """Provides a sensor for Home Connect.""" -import contextlib from dataclasses import dataclass from datetime import datetime, timedelta import logging from typing import cast -from homeconnect.api import HomeConnectError - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -22,6 +19,7 @@ import homeassistant.util.dt as dt_util from . import HomeConnectConfigEntry from .const import ( + APPLIANCES_WITH_PROGRAMS, ATTR_VALUE, BSH_DOOR_STATE, BSH_OPERATION_STATE, @@ -51,27 +49,35 @@ class HomeConnectSensorEntityDescription(SensorEntityDescription): default_value: str | None = None appliance_types: tuple[str, ...] | None = None - sign: int = 1 BSH_PROGRAM_SENSORS = ( HomeConnectSensorEntityDescription( key="BSH.Common.Option.RemainingProgramTime", device_class=SensorDeviceClass.TIMESTAMP, - sign=1, translation_key="program_finish_time", + appliance_types=( + "CoffeMaker", + "CookProcessor", + "Dishwasher", + "Dryer", + "Hood", + "Oven", + "Washer", + "WasherDryer", + ), ), HomeConnectSensorEntityDescription( key="BSH.Common.Option.Duration", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, - sign=1, + appliance_types=("Oven",), ), HomeConnectSensorEntityDescription( key="BSH.Common.Option.ProgramProgress", native_unit_of_measurement=PERCENTAGE, - sign=1, translation_key="program_progress", + appliance_types=APPLIANCES_WITH_PROGRAMS, ), ) @@ -269,11 +275,12 @@ async def async_setup_entry( if description.appliance_types and device.appliance.type in description.appliance_types ) - with contextlib.suppress(HomeConnectError): - if device.appliance.get_programs_available(): - entities.extend( - HomeConnectSensor(device, desc) for desc in BSH_PROGRAM_SENSORS - ) + entities.extend( + HomeConnectProgramSensor(device, desc) + for desc in BSH_PROGRAM_SENSORS + if desc.appliance_types + and device.appliance.type in desc.appliance_types + ) entities.extend( HomeConnectSensor(device, description) for description in SENSORS @@ -289,11 +296,6 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): entity_description: HomeConnectSensorEntityDescription - @property - def available(self) -> bool: - """Return true if the sensor is available.""" - return self._attr_native_value is not None - async def async_update(self) -> None: """Update the sensor's status.""" appliance_status = self.device.appliance.status @@ -311,30 +313,17 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): self._attr_native_value = None elif ( self._attr_native_value is not None - and self.entity_description.sign == 1 and isinstance(self._attr_native_value, datetime) and self._attr_native_value < dt_util.utcnow() ): # if the date is supposed to be in the future but we're # already past it, set state to None. self._attr_native_value = None - elif ( - BSH_OPERATION_STATE - in (appliance_status := self.device.appliance.status) - and ATTR_VALUE in appliance_status[BSH_OPERATION_STATE] - and appliance_status[BSH_OPERATION_STATE][ATTR_VALUE] - in [ - BSH_OPERATION_STATE_RUN, - BSH_OPERATION_STATE_PAUSE, - BSH_OPERATION_STATE_FINISHED, - ] - ): - seconds = self.entity_description.sign * float(status[ATTR_VALUE]) + else: + seconds = float(status[ATTR_VALUE]) self._attr_native_value = dt_util.utcnow() + timedelta( seconds=seconds ) - else: - self._attr_native_value = None case SensorDeviceClass.ENUM: # Value comes back as an enum, we only really care about the # last part, so split it off @@ -345,3 +334,34 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): case _: self._attr_native_value = status.get(ATTR_VALUE) _LOGGER.debug("Updated, new state: %s", self._attr_native_value) + + +class HomeConnectProgramSensor(HomeConnectSensor): + """Sensor class for Home Connect sensors that reports information related to the running program.""" + + program_running: bool = False + + @property + def available(self) -> bool: + """Return true if the sensor is available.""" + # These sensors are only available if the program is running, paused or finished. + # Otherwise, some sensors report erroneous values. + return super().available and self.program_running + + async def async_update(self) -> None: + """Update the sensor's status.""" + self.program_running = ( + BSH_OPERATION_STATE in (appliance_status := self.device.appliance.status) + and ATTR_VALUE in appliance_status[BSH_OPERATION_STATE] + and appliance_status[BSH_OPERATION_STATE][ATTR_VALUE] + in [ + BSH_OPERATION_STATE_RUN, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_FINISHED, + ] + ) + if self.program_running: + await super().async_update() + else: + # reset the value when the program is not running, paused or finished + self._attr_native_value = None From 5a91562d1dcdfdcfde80f69bc038d116e7805adb Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 19 Jan 2025 12:37:28 +0100 Subject: [PATCH 0626/2987] Fix grammar and plural handling in action descriptions (#135654) --- .../components/soundtouch/strings.json | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/soundtouch/strings.json b/homeassistant/components/soundtouch/strings.json index 9fc11f7788a..2544eeb14a9 100644 --- a/homeassistant/components/soundtouch/strings.json +++ b/homeassistant/components/soundtouch/strings.json @@ -27,8 +27,8 @@ "description": "Plays on all Bose SoundTouch devices.", "fields": { "master": { - "name": "Master", - "description": "Name of the master entity that will coordinate the grouping. Platform dependent. It is a shortcut for creating a multi-room zone with all devices." + "name": "Leader", + "description": "The media player entity that will coordinate the grouping. Platform dependent. It is a shortcut for creating a multi-room zone with all devices." } } }, @@ -37,40 +37,40 @@ "description": "Creates a SoundTouch multi-room zone.", "fields": { "master": { - "name": "Master", - "description": "Name of the master entity that will coordinate the multi-room zone. Platform dependent." + "name": "Leader", + "description": "The media player entity that will coordinate the multi-room zone. Platform dependent." }, "slaves": { - "name": "Slaves", - "description": "Name of slaves entities to add to the new zone." + "name": "Follower", + "description": "The media player entities to add to the new zone." } } }, "add_zone_slave": { - "name": "Add zone slave", - "description": "Adds a slave to a SoundTouch multi-room zone.", + "name": "Add zone follower", + "description": "Adds media players to a SoundTouch multi-room zone.", "fields": { "master": { - "name": "Master", - "description": "Name of the master entity that is coordinating the multi-room zone. Platform dependent." + "name": "[%key:component::soundtouch::services::create_zone::fields::master::name%]", + "description": "The media player entity that is coordinating the multi-room zone. Platform dependent." }, "slaves": { "name": "[%key:component::soundtouch::services::create_zone::fields::slaves::name%]", - "description": "Name of slaves entities to add to the existing zone." + "description": "The media player entities to add to the existing zone." } } }, "remove_zone_slave": { - "name": "Remove zone slave", - "description": "Removes a slave from the SoundTouch multi-room zone.", + "name": "Remove zone follower", + "description": "Removes media players from a SoundTouch multi-room zone.", "fields": { "master": { - "name": "Master", + "name": "[%key:component::soundtouch::services::create_zone::fields::master::name%]", "description": "[%key:component::soundtouch::services::add_zone_slave::fields::master::description%]" }, "slaves": { "name": "[%key:component::soundtouch::services::create_zone::fields::slaves::name%]", - "description": "Name of slaves entities to remove from the existing zone." + "description": "The media player entities to remove from the existing zone." } } } From 9f3b39a2d2dd373858247fdb8ffd297e1b75dd1c Mon Sep 17 00:00:00 2001 From: "Glenn Vandeuren (aka Iondependent)" Date: Sun, 19 Jan 2025 12:51:05 +0100 Subject: [PATCH 0627/2987] Round brightness in Niko Home Control (#135920) --- homeassistant/components/niko_home_control/light.py | 2 +- tests/components/niko_home_control/test_light.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index 69d4e71c755..80f47e56438 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -112,7 +112,7 @@ class NikoHomeControlLight(NikoHomeControlEntity, LightEntity): def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - self._action.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55) + self._action.turn_on(round(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55)) def turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" diff --git a/tests/components/niko_home_control/test_light.py b/tests/components/niko_home_control/test_light.py index a61cc5204f6..865e1303cb0 100644 --- a/tests/components/niko_home_control/test_light.py +++ b/tests/components/niko_home_control/test_light.py @@ -42,11 +42,11 @@ async def test_entities( @pytest.mark.parametrize( ("light_id", "data", "set_brightness"), [ - (0, {ATTR_ENTITY_ID: "light.light"}, 100.0), + (0, {ATTR_ENTITY_ID: "light.light"}, 100), ( 1, {ATTR_ENTITY_ID: "light.dimmable_light", ATTR_BRIGHTNESS: 50}, - 19.607843137254903, + 20, ), ], ) From acbb15a49693ad400c1fbe20d998c3d78daf3e3e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 19 Jan 2025 12:51:49 +0100 Subject: [PATCH 0628/2987] Set dependency-transparency and async-dependency in Habitica IQS (#135902) --- homeassistant/components/habitica/quality_scale.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml index 0f8ede06d2e..e279f924b72 100644 --- a/homeassistant/components/habitica/quality_scale.yaml +++ b/homeassistant/components/habitica/quality_scale.yaml @@ -6,7 +6,7 @@ rules: common-modules: done config-flow-test-coverage: done config-flow: done - dependency-transparency: todo + dependency-transparency: done docs-actions: done docs-high-level-description: done docs-installation-instructions: done @@ -77,6 +77,6 @@ rules: comment: Not applicable. Only one device per config entry. Removed together with the config entry. # Platinum - async-dependency: todo + async-dependency: done inject-websession: done strict-typing: done From af0f416497bc5fe4b09884b20615588f2d66cf7c Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sun, 19 Jan 2025 12:53:09 +0100 Subject: [PATCH 0629/2987] Fix KNX default state updater option (#135611) --- homeassistant/components/knx/__init__.py | 10 ++- homeassistant/components/knx/const.py | 2 +- homeassistant/components/knx/strings.json | 2 +- tests/components/knx/conftest.py | 2 +- tests/components/knx/test_init.py | 85 ++++++++++++++++++++++- 5 files changed, 94 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 7925628c079..fa3439b02f4 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -10,6 +10,7 @@ from typing import Final import voluptuous as vol from xknx import XKNX from xknx.core import XknxConnectionState +from xknx.core.state_updater import StateTrackerType, TrackerOptions from xknx.core.telegram_queue import TelegramQueue from xknx.dpt import DPTBase from xknx.exceptions import ConversionError, CouldNotParseTelegram, XKNXException @@ -273,11 +274,18 @@ class KNXModule: self.project = KNXProject(hass=hass, entry=entry) self.config_store = KNXConfigStore(hass=hass, config_entry=entry) + default_state_updater = ( + TrackerOptions(tracker_type=StateTrackerType.EXPIRE, update_interval_min=60) + if self.entry.data[CONF_KNX_STATE_UPDATER] + else TrackerOptions( + tracker_type=StateTrackerType.INIT, update_interval_min=60 + ) + ) self.xknx = XKNX( address_format=self.project.get_address_format(), connection_config=self.connection_config(), rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT], - state_updater=self.entry.data[CONF_KNX_STATE_UPDATER], + state_updater=default_state_updater, ) self.xknx.connection_manager.register_connection_state_changed_cb( self.connection_state_changed_cb diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index a946ded0359..3ef35479c4e 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -114,7 +114,7 @@ class KNXConfigEntryData(TypedDict, total=False): backbone_key: str | None # not required sync_latency_tolerance: int | None # not required # OptionsFlow only - state_updater: bool + state_updater: bool # default state updater: True -> expire 60; False -> init rate_limit: int # Integration only (not forwarded to xknx) telegram_log_size: int # not required diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index e7fbfcf5f2f..dadc8e84796 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -161,7 +161,7 @@ "telegram_log_size": "Telegram history limit" }, "data_description": { - "state_updater": "Set default for reading states from the KNX Bus. When disabled, Home Assistant will not actively retrieve entity states from the KNX Bus. Can be overridden by `sync_state` entity options.", + "state_updater": "Sets the default behavior for reading state addresses from the KNX Bus.\nWhen enabled, Home Assistant will monitor each group address and read it from the bus if no value has been received for one hour.\nWhen disabled, state addresses will only be read once after a bus connection is established.\nThis behavior can be overridden for individual entities using the `sync_state` option.", "rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: `0` or between `20` and `40`", "telegram_log_size": "Telegrams to keep in memory for KNX panel group monitor. Maximum: {telegram_log_size_max}" } diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 80d75769cdc..4e50836bb79 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -335,7 +335,7 @@ async def create_ui_entity( hass_ws_client: WebSocketGenerator, hass_storage: dict[str, Any], ) -> KnxEntityGenerator: - """Return a helper to create a KNX entities via WS. + """Return a helper to create KNX entities via WS. The KNX integration must be set up before using the helper. """ diff --git a/tests/components/knx/test_init.py b/tests/components/knx/test_init.py index d005487b8f2..75cd5d1eb21 100644 --- a/tests/components/knx/test_init.py +++ b/tests/components/knx/test_init.py @@ -1,7 +1,9 @@ """Test KNX init.""" +from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from xknx.io import ( DEFAULT_MCAST_GRP, @@ -11,7 +13,10 @@ from xknx.io import ( SecureConfig, ) -from homeassistant.components.knx.config_flow import DEFAULT_ROUTING_IA +from homeassistant.components.knx.config_flow import ( + DEFAULT_ENTRY_DATA, + DEFAULT_ROUTING_IA, +) from homeassistant.components.knx.const import ( CONF_KNX_AUTOMATIC, CONF_KNX_CONNECTION_TYPE, @@ -40,12 +45,13 @@ from homeassistant.components.knx.const import ( KNXConfigEntryData, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant +from . import KnxEntityGenerator from .conftest import KNXTestKit -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.parametrize( @@ -262,6 +268,79 @@ async def test_init_connection_handling( ) +async def _init_switch_and_wait_for_first_state_updater_run( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + freezer: FrozenDateTimeFactory, + config_entry_data: KNXConfigEntryData, +) -> None: + """Return a config entry with default data.""" + config_entry = MockConfigEntry( + title="KNX", domain=KNX_DOMAIN, data=config_entry_data + ) + knx.mock_config_entry = config_entry + await knx.setup_integration({}) + await create_ui_entity( + platform=Platform.SWITCH, + knx_data={ + "ga_switch": {"write": "1/1/1", "state": "2/2/2"}, + "respond_to_read": True, + "sync_state": True, # True uses xknx default state updater + "invert": False, + }, + ) + # created entity sends read-request to KNX bus on connection + await knx.assert_read("2/2/2") + await knx.receive_response("2/2/2", True) + + freezer.tick(timedelta(minutes=59)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + await knx.assert_no_telegram() + + freezer.tick(timedelta(minutes=1)) # 60 minutes passed + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + +async def test_default_state_updater_enabled( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test default state updater is applied to xknx device instances.""" + config_entry = DEFAULT_ENTRY_DATA | KNXConfigEntryData( + connection_type=CONF_KNX_AUTOMATIC, # missing in default data + state_updater=True, + ) + await _init_switch_and_wait_for_first_state_updater_run( + hass, knx, create_ui_entity, freezer, config_entry + ) + await knx.assert_read("2/2/2") + await knx.receive_response("2/2/2", True) + + +async def test_default_state_updater_disabled( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test default state updater is applied to xknx device instances.""" + config_entry = DEFAULT_ENTRY_DATA | KNXConfigEntryData( + connection_type=CONF_KNX_AUTOMATIC, # missing in default data + state_updater=False, + ) + await _init_switch_and_wait_for_first_state_updater_run( + hass, knx, create_ui_entity, freezer, config_entry + ) + await knx.assert_no_telegram() + + async def test_async_remove_entry( hass: HomeAssistant, knx: KNXTestKit, From 6292d6c0dccd9cfb74d8d144511ba59d2aca7a15 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 19 Jan 2025 22:20:40 +1000 Subject: [PATCH 0630/2987] Add streaming to device tracker platform in Teslemetry (#135962) Co-authored-by: Joost Lekkerkerker --- .../components/teslemetry/device_tracker.py | 181 +++++++++++++----- .../components/teslemetry/strings.json | 3 + .../snapshots/test_device_tracker.ambr | 18 ++ .../teslemetry/test_device_tracker.py | 61 ++++++ 4 files changed, 217 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index 2b0ffd88cc6..42c8fea8d09 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -2,18 +2,69 @@ from __future__ import annotations -from homeassistant.components.device_tracker.config_entry import TrackerEntity +from collections.abc import Callable +from dataclasses import dataclass + +from teslemetry_stream import TeslemetryStreamVehicle +from teslemetry_stream.const import TeslaLocation + +from homeassistant.components.device_tracker.config_entry import ( + TrackerEntity, + TrackerEntityDescription, +) from homeassistant.const import STATE_HOME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryVehicleEntity +from .entity import TeslemetryVehicleEntity, TeslemetryVehicleStreamEntity from .models import TeslemetryVehicleData PARALLEL_UPDATES = 0 +@dataclass(frozen=True, kw_only=True) +class TeslemetryDeviceTrackerEntityDescription(TrackerEntityDescription): + """Describe a Teslemetry device tracker entity.""" + + value_listener: Callable[ + [TeslemetryStreamVehicle, Callable[[TeslaLocation | None], None]], + Callable[[], None], + ] + name_listener: ( + Callable[ + [TeslemetryStreamVehicle, Callable[[str | None], None]], Callable[[], None] + ] + | None + ) = None + streaming_firmware: str + polling_prefix: str | None = None + + +DESCRIPTIONS: tuple[TeslemetryDeviceTrackerEntityDescription, ...] = ( + TeslemetryDeviceTrackerEntityDescription( + key="location", + polling_prefix="drive_state", + value_listener=lambda x, y: x.listen_Location(y), + streaming_firmware="2024.26", + ), + TeslemetryDeviceTrackerEntityDescription( + key="route", + polling_prefix="drive_state_active_route", + value_listener=lambda x, y: x.listen_DestinationLocation(y), + name_listener=lambda x, y: x.listen_DestinationName(y), + streaming_firmware="2024.26", + ), + TeslemetryDeviceTrackerEntityDescription( + key="origin", + value_listener=lambda x, y: x.listen_OriginLocation(y), + streaming_firmware="2024.26", + entity_registry_enabled_default=False, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, @@ -21,67 +72,105 @@ async def async_setup_entry( ) -> None: """Set up the Teslemetry device tracker platform from a config entry.""" - async_add_entities( - klass(vehicle) - for klass in ( - TeslemetryDeviceTrackerLocationEntity, - TeslemetryDeviceTrackerRouteEntity, - ) - for vehicle in entry.runtime_data.vehicles - ) + entities: list[ + TeslemetryPollingDeviceTrackerEntity | TeslemetryStreamingDeviceTrackerEntity + ] = [] + for vehicle in entry.runtime_data.vehicles: + for description in DESCRIPTIONS: + if vehicle.api.pre2021 or vehicle.firmware < description.streaming_firmware: + if description.polling_prefix: + entities.append( + TeslemetryPollingDeviceTrackerEntity(vehicle, description) + ) + else: + entities.append( + TeslemetryStreamingDeviceTrackerEntity(vehicle, description) + ) + + async_add_entities(entities) -class TeslemetryDeviceTrackerEntity(TeslemetryVehicleEntity, TrackerEntity): - """Base class for Teslemetry tracker entities.""" +class TeslemetryPollingDeviceTrackerEntity(TeslemetryVehicleEntity, TrackerEntity): + """Base class for Teslemetry Tracker Entities.""" - lat_key: str - lon_key: str + entity_description: TeslemetryDeviceTrackerEntityDescription def __init__( self, vehicle: TeslemetryVehicleData, + description: TeslemetryDeviceTrackerEntityDescription, ) -> None: """Initialize the device tracker.""" - super().__init__(vehicle, self.key) + self.entity_description = description + super().__init__(vehicle, description.key) def _async_update_attrs(self) -> None: - """Update the attributes of the device tracker.""" - + """Update the attributes of the entity.""" + self._attr_latitude = self.get( + f"{self.entity_description.polling_prefix}_latitude" + ) + self._attr_longitude = self.get( + f"{self.entity_description.polling_prefix}_longitude" + ) + self._attr_location_name = self.get( + f"{self.entity_description.polling_prefix}_destination" + ) + if self._attr_location_name == "Home": + self._attr_location_name = STATE_HOME self._attr_available = ( - self.get(self.lat_key, False) is not None - and self.get(self.lon_key, False) is not None + self._attr_latitude is not None and self._attr_longitude is not None ) - @property - def latitude(self) -> float | None: - """Return latitude value of the device.""" - return self.get(self.lat_key) - @property - def longitude(self) -> float | None: - """Return longitude value of the device.""" - return self.get(self.lon_key) +class TeslemetryStreamingDeviceTrackerEntity( + TeslemetryVehicleStreamEntity, TrackerEntity, RestoreEntity +): + """Base class for Teslemetry Tracker Entities.""" + entity_description: TeslemetryDeviceTrackerEntityDescription -class TeslemetryDeviceTrackerLocationEntity(TeslemetryDeviceTrackerEntity): - """Vehicle location device tracker class.""" + def __init__( + self, + vehicle: TeslemetryVehicleData, + description: TeslemetryDeviceTrackerEntityDescription, + ) -> None: + """Initialize the device tracker.""" + self.entity_description = description + super().__init__(vehicle, description.key) - key = "location" - lat_key = "drive_state_latitude" - lon_key = "drive_state_longitude" + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + self._attr_state = state.state + self._attr_latitude = state.attributes.get("latitude") + self._attr_longitude = state.attributes.get("longitude") + self._attr_location_name = state.attributes.get("location_name") + self.async_on_remove( + self.entity_description.value_listener( + self.vehicle.stream_vehicle, self._location_callback + ) + ) + if self.entity_description.name_listener: + self.async_on_remove( + self.entity_description.name_listener( + self.vehicle.stream_vehicle, self._name_callback + ) + ) + def _location_callback(self, location: TeslaLocation | None) -> None: + """Update the value of the entity.""" + if location is None: + self._attr_available = False + else: + self._attr_available = True + self._attr_latitude = location.latitude + self._attr_longitude = location.longitude + self.async_write_ha_state() -class TeslemetryDeviceTrackerRouteEntity(TeslemetryDeviceTrackerEntity): - """Vehicle navigation device tracker class.""" - - key = "route" - lat_key = "drive_state_active_route_latitude" - lon_key = "drive_state_active_route_longitude" - - @property - def location_name(self) -> str | None: - """Return a location name for the current location of the device.""" - location = self.get("drive_state_active_route_destination") - if location == "Home": - return STATE_HOME - return location + def _name_callback(self, name: str | None) -> None: + """Update the value of the entity.""" + self._attr_location_name = name + if self._attr_location_name == "Home": + self._attr_location_name = STATE_HOME + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index b40d1a83d7d..8dc8b053712 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -236,6 +236,9 @@ }, "route": { "name": "Route" + }, + "origin": { + "name": "Origin" } }, "lock": { diff --git a/tests/components/teslemetry/snapshots/test_device_tracker.ambr b/tests/components/teslemetry/snapshots/test_device_tracker.ambr index ac4c388873f..0bc371b2d2d 100644 --- a/tests/components/teslemetry/snapshots/test_device_tracker.ambr +++ b/tests/components/teslemetry/snapshots/test_device_tracker.ambr @@ -133,3 +133,21 @@ 'state': 'not_home', }) # --- +# name: test_device_tracker_streaming[device_tracker.test_location-restore] + 'not_home' +# --- +# name: test_device_tracker_streaming[device_tracker.test_location-state] + 'not_home' +# --- +# name: test_device_tracker_streaming[device_tracker.test_origin-restore] + 'unknown' +# --- +# name: test_device_tracker_streaming[device_tracker.test_origin-state] + 'unknown' +# --- +# name: test_device_tracker_streaming[device_tracker.test_route-restore] + 'not_home' +# --- +# name: test_device_tracker_streaming[device_tracker.test_route-state] + 'home' +# --- diff --git a/tests/components/teslemetry/test_device_tracker.py b/tests/components/teslemetry/test_device_tracker.py index d86c3ca8596..38a28092d33 100644 --- a/tests/components/teslemetry/test_device_tracker.py +++ b/tests/components/teslemetry/test_device_tracker.py @@ -2,7 +2,9 @@ from unittest.mock import AsyncMock +import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream.const import Signal from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -12,10 +14,12 @@ from . import assert_entities, assert_entities_alt, setup_platform from .const import VEHICLE_DATA_ALT +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_device_tracker( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the device tracker entities are correct.""" @@ -23,14 +27,71 @@ async def test_device_tracker( assert_entities(hass, entry.entry_id, entity_registry, snapshot) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_device_tracker_alt( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the device tracker entities are correct.""" mock_vehicle_data.return_value = VEHICLE_DATA_ALT entry = await setup_platform(hass, [Platform.DEVICE_TRACKER]) assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_device_tracker_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the device tracker entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.DEVICE_TRACKER]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.LOCATION: { + "latitude": 1.0, + "longitude": 2.0, + }, + Signal.DESTINATION_LOCATION: { + "latitude": 3.0, + "longitude": 4.0, + }, + Signal.DESTINATION_NAME: "Home", + Signal.ORIGIN_LOCATION: None, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "device_tracker.test_location", + "device_tracker.test_route", + "device_tracker.test_origin", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-state") + + # Reload the entry + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "device_tracker.test_location", + "device_tracker.test_route", + "device_tracker.test_origin", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-restore") From 4690aef8b81d4353f43843c521972a33d16d13a4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 19 Jan 2025 13:21:37 +0100 Subject: [PATCH 0631/2987] Further clarify the meaning of Sensibo's Climate React mode (#135833) Co-authored-by: G Johansson --- homeassistant/components/sensibo/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 0461df40825..c5ff0f135e6 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -485,8 +485,8 @@ } }, "enable_climate_react": { - "name": "Enable climate react", - "description": "Enables and configures climate react.", + "name": "Enable Climate React", + "description": "Enables and configures Climate React.", "fields": { "high_temperature_threshold": { "name": "Threshold high", @@ -576,7 +576,7 @@ "message": "Could not perform action for {name} with error {error}" }, "climate_react_not_available": { - "message": "Use Sensibo 'Enable climate react' action once to enable switch or the Sensibo app" + "message": "Use the Sensibo 'Enable Climate React' action once to enable the switch, or use the Sensibo app" }, "auth_error": { "message": "Authentication failed, please update your API key" From 3978c4cdb3ccb917bf4cf4a4b3049ea608d9da23 Mon Sep 17 00:00:00 2001 From: Manuel Stahl Date: Sun, 19 Jan 2025 13:21:59 +0100 Subject: [PATCH 0632/2987] Add type annotations to stiebel eltron component (#135228) --- .../components/stiebel_eltron/__init__.py | 5 ++- .../components/stiebel_eltron/climate.py | 40 +++++++++---------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py index a5e92312f3d..80c1dad3ee8 100644 --- a/homeassistant/components/stiebel_eltron/__init__.py +++ b/homeassistant/components/stiebel_eltron/__init__.py @@ -3,6 +3,7 @@ from datetime import timedelta import logging +from pymodbus.client import ModbusTcpClient from pystiebeleltron import pystiebeleltron import voluptuous as vol @@ -55,13 +56,13 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: class StiebelEltronData: """Get the latest data and update the states.""" - def __init__(self, name, modbus_client): + def __init__(self, name: str, modbus_client: ModbusTcpClient) -> None: """Init the STIEBEL ELTRON data object.""" self.api = pystiebeleltron.StiebelEltronAPI(modbus_client, 1) @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): + def update(self) -> None: """Update unit data.""" if not self.api.update(): _LOGGER.warning("Modbus read failed") diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py index 676f613f382..4d302a0f70d 100644 --- a/homeassistant/components/stiebel_eltron/climate.py +++ b/homeassistant/components/stiebel_eltron/climate.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as STE_DOMAIN +from . import DOMAIN as STE_DOMAIN, StiebelEltronData DEPENDENCIES = ["stiebel_eltron"] @@ -81,15 +81,15 @@ class StiebelEltron(ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, name, ste_data): + def __init__(self, name: str, ste_data: StiebelEltronData) -> None: """Initialize the unit.""" self._name = name - self._target_temperature = None - self._current_temperature = None - self._current_humidity = None - self._operation = None - self._filter_alarm = None - self._force_update = False + self._target_temperature: float | int | None = None + self._current_temperature: float | int | None = None + self._current_humidity: float | int | None = None + self._operation: str | None = None + self._filter_alarm: bool | None = None + self._force_update: bool = False self._ste_data = ste_data def update(self) -> None: @@ -108,59 +108,59 @@ class StiebelEltron(ClimateEntity): ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, bool | None]: """Return device specific state attributes.""" return {"filter_alarm": self._filter_alarm} @property - def name(self): + def name(self) -> str: """Return the name of the climate device.""" return self._name # Handle ClimateEntityFeature.TARGET_TEMPERATURE @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._current_temperature @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._target_temperature @property - def target_temperature_step(self): + def target_temperature_step(self) -> float: """Return the supported step of target temperature.""" return 0.1 @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" return 10.0 @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" return 30.0 @property - def current_humidity(self): + def current_humidity(self) -> float | None: """Return the current humidity.""" return float(f"{self._current_humidity:.1f}") @property def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" - return STE_TO_HA_HVAC.get(self._operation) + return STE_TO_HA_HVAC.get(self._operation) if self._operation else None @property - def preset_mode(self): + def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - return STE_TO_HA_PRESET.get(self._operation) + return STE_TO_HA_PRESET.get(self._operation) if self._operation else None @property - def preset_modes(self): + def preset_modes(self) -> list[str]: """Return a list of available preset modes.""" return SUPPORT_PRESET From a55bd593af40e74ed1f35349454290159fcb2322 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Sun, 19 Jan 2025 13:24:47 +0100 Subject: [PATCH 0633/2987] Rework enigma2 tests (#135475) --- .../components/enigma2/media_player.py | 1 + tests/components/enigma2/conftest.py | 81 ++--- .../enigma2/fixtures/device_about.json | 158 +++++++++ .../fixtures/device_about_without_mac.json | 158 +++++++++ .../fixtures/device_statusinfo_on.json | 20 ++ .../fixtures/device_statusinfo_standby.json | 16 + tests/components/enigma2/test_config_flow.py | 136 +++++--- tests/components/enigma2/test_init.py | 49 ++- tests/components/enigma2/test_media_player.py | 305 ++++++++++++++++++ 9 files changed, 808 insertions(+), 116 deletions(-) create mode 100644 tests/components/enigma2/fixtures/device_about.json create mode 100644 tests/components/enigma2/fixtures/device_about_without_mac.json create mode 100644 tests/components/enigma2/fixtures/device_statusinfo_on.json create mode 100644 tests/components/enigma2/fixtures/device_statusinfo_standby.json create mode 100644 tests/components/enigma2/test_media_player.py diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index ee0de15c3fb..1012997ff7f 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -56,6 +56,7 @@ class Enigma2Device(CoordinatorEntity[Enigma2UpdateCoordinator], MediaPlayerEnti | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.PLAY ) def __init__(self, coordinator: Enigma2UpdateCoordinator) -> None: diff --git a/tests/components/enigma2/conftest.py b/tests/components/enigma2/conftest.py index a53d1494e9a..a16ef69979b 100644 --- a/tests/components/enigma2/conftest.py +++ b/tests/components/enigma2/conftest.py @@ -1,6 +1,10 @@ """Test the Enigma2 config flow.""" -from openwebif.api import OpenWebIfServiceEvent, OpenWebIfStatus +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from openwebif.api import OpenWebIfDevice, OpenWebIfServiceEvent, OpenWebIfStatus +import pytest from homeassistant.components.enigma2.const import ( CONF_DEEP_STANDBY, @@ -10,6 +14,7 @@ from homeassistant.components.enigma2.const import ( DEFAULT_PORT, DEFAULT_SSL, DEFAULT_VERIFY_SSL, + DOMAIN, ) from homeassistant.const import ( CONF_HOST, @@ -20,6 +25,8 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) +from tests.common import MockConfigEntry, load_json_object_fixture + MAC_ADDRESS = "12:34:56:78:90:ab" TEST_REQUIRED = { @@ -45,42 +52,41 @@ EXPECTED_OPTIONS = { } -class MockDevice: - """A mock Enigma2 device.""" +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, data=TEST_REQUIRED, unique_id="12:34:56:78:90:ab" + ) - mac_address: str | None = "12:34:56:78:90:ab" - _base = "http://1.1.1.1" - def __init__(self) -> None: - """Initialize the mock Enigma2 device.""" - self.status = OpenWebIfStatus(currservice=OpenWebIfServiceEvent()) +@pytest.fixture +def openwebif_device_mock() -> Generator[AsyncMock]: + """Mock a OpenWebIf device.""" - async def _call_api(self, url: str) -> dict | None: - if url.endswith("/api/about"): - return { - "info": { - "ifaces": [ - { - "mac": self.mac_address, - } - ], - "model": "Mock Enigma2", - "brand": "Enigma2", - } - } - return None - - def get_version(self) -> str | None: - """Return the version.""" - return None - - async def get_about(self) -> dict: - """Get mock about endpoint.""" - return await self._call_api("/api/about") - - async def get_all_bouquets(self) -> dict: - """Get all bouquets.""" - return { + with ( + patch( + "homeassistant.components.enigma2.coordinator.OpenWebIfDevice", + spec=OpenWebIfDevice, + ) as openwebif_device_mock, + patch( + "homeassistant.components.enigma2.config_flow.OpenWebIfDevice", + new=openwebif_device_mock, + ), + ): + device = openwebif_device_mock.return_value + device.status = OpenWebIfStatus(currservice=OpenWebIfServiceEvent()) + device.turn_off_to_deep = False + device.sources = {"Test": "1"} + device.source_list = list(device.sources.keys()) + device.picon_url = "file:///" + device.get_about.return_value = load_json_object_fixture( + "device_about.json", DOMAIN + ) + device.get_status_info.return_value = load_json_object_fixture( + "device_statusinfo_on.json", DOMAIN + ) + device.get_all_bouquets.return_value = { "bouquets": [ [ '1:7:1:0:0:0:0:0:0:0:FROM BOUQUET "userbouquet.favourites.tv" ORDER BY bouquet', @@ -88,9 +94,4 @@ class MockDevice: ] ] } - - async def update(self) -> None: - """Mock update.""" - - async def close(self): - """Mock close.""" + yield device diff --git a/tests/components/enigma2/fixtures/device_about.json b/tests/components/enigma2/fixtures/device_about.json new file mode 100644 index 00000000000..5b992fa1bd5 --- /dev/null +++ b/tests/components/enigma2/fixtures/device_about.json @@ -0,0 +1,158 @@ +{ + "info": { + "brand": "GigaBlue", + "model": "UHD QUAD 4K", + "boxtype": "gbquad4k", + "machinebuild": "gb7252", + "lcd": 1, + "grabpip": 1, + "chipset": "bcm7252s", + "mem1": "906132 kB", + "mem2": "616396 kB", + "mem3": "616396 kB frei / 906132 kB insgesamt", + "uptime": "46d 15:47", + "webifver": "OWIF 2.2.0", + "imagedistro": "openatv", + "friendlyimagedistro": "openATV", + "oever": "OE-Alliance 5.5", + "imagever": "7.5.20241101", + "enigmaver": "2024-10-31", + "driverdate": "20200723", + "kernelver": "4.1.20", + "fp_version": 0, + "friendlychipsetdescription": "Chipsatz", + "friendlychipsettext": "Broadcom 7252s", + "tuners": [ + { + "name": "Tuner A", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner B", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner C", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner D", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner E", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner F", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner G", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner H", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner I", + "type": "GIGA DVB-T2/C NIM (TT2L10) (DVB-T2)", + "rec": "", + "live": "", + "stream": "" + } + ], + "ifaces": [ + { + "name": "eth0", + "friendlynic": "Broadcom Gigabit Ethernet", + "linkspeed": "1 GBit/s", + "mac": "12:34:56:78:90:ab", + "dhcp": true, + "ipv4method": "DHCP", + "ip": "192.168.1.100", + "mask": "255.255.255.0", + "v4prefix": 23, + "gw": "192.168.1.1", + "ipv6": "2003::2/64", + "ipmethod": "SL-AAC", + "firstpublic": "2003::2" + } + ], + "hdd": [ + { + "model": "ATA(ST2000LM015-2E81)", + "capacity": "1.8 TB", + "labelled_capacity": "2.0 TB", + "free": "22.5 GB", + "mount": "/media/hdd", + "friendlycapacity": "22.5 GB frei / 1.8 TB (2.0 TB) insgesamt" + } + ], + "shares": [ + { + "name": "NAS", + "method": "autofs", + "type": "SMBv2.0", + "mode": "r/w", + "path": "//192.168.1.2/NAS", + "host": "192.168.1.2", + "ipaddress": null, + "friendlyaddress": "192.168.1.2" + } + ], + "transcoding": true, + "EX": "", + "streams": [], + "timerpipzap": false, + "allow_duplicate": false, + "timermargins": true, + "textinputsupport": true + }, + "service": { + "result": false, + "name": "", + "namespace": "", + "aspect": 0, + "provider": "", + "width": 0, + "height": 0, + "apid": 0, + "vpid": 0, + "pcrpid": 0, + "pmtpid": 0, + "txtpid": "N/A", + "tsid": 0, + "onid": 0, + "sid": 0, + "ref": "", + "iswidescreen": false, + "bqref": "", + "bqname": "" + } +} diff --git a/tests/components/enigma2/fixtures/device_about_without_mac.json b/tests/components/enigma2/fixtures/device_about_without_mac.json new file mode 100644 index 00000000000..02a84edcac2 --- /dev/null +++ b/tests/components/enigma2/fixtures/device_about_without_mac.json @@ -0,0 +1,158 @@ +{ + "info": { + "brand": "GigaBlue", + "model": "UHD QUAD 4K", + "boxtype": "gbquad4k", + "machinebuild": "gb7252", + "lcd": 1, + "grabpip": 1, + "chipset": "bcm7252s", + "mem1": "906132 kB", + "mem2": "616396 kB", + "mem3": "616396 kB frei / 906132 kB insgesamt", + "uptime": "46d 15:47", + "webifver": "OWIF 2.2.0", + "imagedistro": "openatv", + "friendlyimagedistro": "openATV", + "oever": "OE-Alliance 5.5", + "imagever": "7.5.20241101", + "enigmaver": "2024-10-31", + "driverdate": "20200723", + "kernelver": "4.1.20", + "fp_version": 0, + "friendlychipsetdescription": "Chipsatz", + "friendlychipsettext": "Broadcom 7252s", + "tuners": [ + { + "name": "Tuner A", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner B", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner C", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner D", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner E", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner F", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner G", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner H", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner I", + "type": "GIGA DVB-T2/C NIM (TT2L10) (DVB-T2)", + "rec": "", + "live": "", + "stream": "" + } + ], + "ifaces": [ + { + "name": "eth0", + "friendlynic": "Broadcom Gigabit Ethernet", + "linkspeed": "1 GBit/s", + "mac": null, + "dhcp": true, + "ipv4method": "DHCP", + "ip": "192.168.1.100", + "mask": "255.255.255.0", + "v4prefix": 23, + "gw": "192.168.1.1", + "ipv6": "2003::2/64", + "ipmethod": "SL-AAC", + "firstpublic": "2003::2" + } + ], + "hdd": [ + { + "model": "ATA(ST2000LM015-2E81)", + "capacity": "1.8 TB", + "labelled_capacity": "2.0 TB", + "free": "22.5 GB", + "mount": "/media/hdd", + "friendlycapacity": "22.5 GB frei / 1.8 TB (2.0 TB) insgesamt" + } + ], + "shares": [ + { + "name": "NAS", + "method": "autofs", + "type": "SMBv2.0", + "mode": "r/w", + "path": "//192.168.1.2/NAS", + "host": "192.168.1.2", + "ipaddress": null, + "friendlyaddress": "192.168.1.2" + } + ], + "transcoding": true, + "EX": "", + "streams": [], + "timerpipzap": false, + "allow_duplicate": false, + "timermargins": true, + "textinputsupport": true + }, + "service": { + "result": false, + "name": "", + "namespace": "", + "aspect": 0, + "provider": "", + "width": 0, + "height": 0, + "apid": 0, + "vpid": 0, + "pcrpid": 0, + "pmtpid": 0, + "txtpid": "N/A", + "tsid": 0, + "onid": 0, + "sid": 0, + "ref": "", + "iswidescreen": false, + "bqref": "", + "bqname": "" + } +} diff --git a/tests/components/enigma2/fixtures/device_statusinfo_on.json b/tests/components/enigma2/fixtures/device_statusinfo_on.json new file mode 100644 index 00000000000..0c8701c7b74 --- /dev/null +++ b/tests/components/enigma2/fixtures/device_statusinfo_on.json @@ -0,0 +1,20 @@ +{ + "volume": 100, + "muted": false, + "transcoding": true, + "currservice_filename": "", + "currservice_id": 38835, + "currservice_name": "Flucht aus Saudi-Arabien", + "currservice_serviceref": "1:0:19:2BA2:3F2:1:C00000:0:0:0:", + "currservice_begin": "16:30", + "currservice_begin_timestamp": 1734622200, + "currservice_end": "17:15", + "currservice_end_timestamp": 1734624900, + "currservice_description": "Ein M\u00e4dchen k\u00e4mpft um die Freiheit", + "currservice_station": "ZDFinfo HD", + "currservice_fulldescription": "Flucht aus Saudi-Arabien\n16:30 - 17:15\n\nSaudi-Arabien / Australien 2019\nIm streng islamischen K\u00f6nigreich Saudi-Arabien haben Frauen immer noch wenig Rechte. Wenn sie ein selbstbestimmtes Leben f\u00fchren wollen, bleibt ihnen h\u00e4ufig nur die Flucht.\n\nDie 18-j\u00e4hrige Rahaf Mohammed al-Qunun ist eine solche junge Frau, die riskiert hat, dem m\u00e4nnlich gepr\u00e4gten Vormundschaftssystem zu entfliehen. Doch der saudische Staat und Rahafs Familie verfolgen die Abtr\u00fcnnige sogar bis ins Ausland.\nHD-Produktion", + "inStandby": "false", + "isRecording": "false", + "Streaming_list": "", + "isStreaming": "false" +} diff --git a/tests/components/enigma2/fixtures/device_statusinfo_standby.json b/tests/components/enigma2/fixtures/device_statusinfo_standby.json new file mode 100644 index 00000000000..18cc19f5901 --- /dev/null +++ b/tests/components/enigma2/fixtures/device_statusinfo_standby.json @@ -0,0 +1,16 @@ +{ + "volume": 100, + "muted": true, + "transcoding": true, + "currservice_filename": "", + "currservice_id": -1, + "currservice_name": "N/A", + "currservice_begin": "", + "currservice_end": "", + "currservice_description": "", + "currservice_fulldescription": "N/A", + "inStandby": "true", + "isRecording": "false", + "Streaming_list": "", + "isStreaming": "false" +} diff --git a/tests/components/enigma2/test_config_flow.py b/tests/components/enigma2/test_config_flow.py index 8d32da42baf..1445048f0c1 100644 --- a/tests/components/enigma2/test_config_flow.py +++ b/tests/components/enigma2/test_config_flow.py @@ -1,19 +1,19 @@ """Test the Enigma2 config flow.""" from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock from aiohttp.client_exceptions import ClientError from openwebif.error import InvalidAuthError import pytest -from homeassistant import config_entries from homeassistant.components.enigma2.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import TEST_FULL, TEST_REQUIRED, MockDevice +from .conftest import TEST_FULL, TEST_REQUIRED from tests.common import MockConfigEntry @@ -22,42 +22,35 @@ from tests.common import MockConfigEntry async def user_flow(hass: HomeAssistant) -> str: """Return a user-initiated flow after filling in host info.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] == FlowResultType.FORM assert result["errors"] is None return result["flow_id"] +@pytest.mark.usefixtures("openwebif_device_mock") @pytest.mark.parametrize( ("test_config"), [(TEST_FULL), (TEST_REQUIRED)], ) -async def test_form_user( - hass: HomeAssistant, user_flow: str, test_config: dict[str, Any] -) -> None: +async def test_form_user(hass: HomeAssistant, test_config: dict[str, Any]) -> None: """Test a successful user initiated flow.""" - with ( - patch( - "openwebif.api.OpenWebIfDevice.__new__", - return_value=MockDevice(), - ), - patch( - "homeassistant.components.enigma2.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure(user_flow, test_config) - await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], test_config + ) + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == test_config[CONF_HOST] assert result["data"] == test_config - assert len(mock_setup_entry.mock_calls) == 1 - @pytest.mark.parametrize( - ("exception", "error_type"), + ("side_effect", "error_value"), [ (InvalidAuthError, "invalid_auth"), (ClientError, "cannot_connect"), @@ -65,46 +58,87 @@ async def test_form_user( ], ) async def test_form_user_errors( - hass: HomeAssistant, user_flow, exception: Exception, error_type: str + hass: HomeAssistant, + openwebif_device_mock: AsyncMock, + side_effect: Exception, + error_value: str, ) -> None: """Test we handle errors.""" - with patch( - "homeassistant.components.enigma2.config_flow.OpenWebIfDevice.__new__", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure(user_flow, TEST_FULL) + + openwebif_device_mock.get_about.side_effect = side_effect + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_FULL + ) + await hass.async_block_till_done() assert result["type"] == FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER - assert result["errors"] == {"base": error_type} + assert result["step_id"] == SOURCE_USER + assert result["errors"] == {"base": error_value} + + openwebif_device_mock.get_about.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_FULL, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_FULL[CONF_HOST] + assert result["data"] == TEST_FULL + assert result["result"].unique_id == openwebif_device_mock.mac_address -async def test_options_flow(hass: HomeAssistant, user_flow: str) -> None: +@pytest.mark.usefixtures("openwebif_device_mock") +async def test_duplicate_host( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that a duplicate host aborts the config flow.""" + mock_config_entry.add_to_hass(hass) + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user" + result2 = await hass.config_entries.flow.async_configure( + result2["flow_id"], TEST_FULL + ) + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +@pytest.mark.usefixtures("openwebif_device_mock") +async def test_options_flow(hass: HomeAssistant) -> None: """Test the form options.""" - with patch( - "openwebif.api.OpenWebIfDevice.__new__", - return_value=MockDevice(), - ): - entry = MockConfigEntry(domain=DOMAIN, data=TEST_FULL, options={}, entry_id="1") - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = MockConfigEntry(domain=DOMAIN, data=TEST_FULL, options={}, entry_id="1") + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert entry.state is config_entries.ConfigEntryState.LOADED + assert entry.state is ConfigEntryState.LOADED - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={"source_bouquet": "Favourites (TV)"} - ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"source_bouquet": "Favourites (TV)"} + ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert entry.options == {"source_bouquet": "Favourites (TV)"} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert entry.options == {"source_bouquet": "Favourites (TV)"} - await hass.async_block_till_done() + await hass.async_block_till_done() - assert entry.state is config_entries.ConfigEntryState.LOADED + assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/enigma2/test_init.py b/tests/components/enigma2/test_init.py index d12f96d4b0f..a3f68cd0902 100644 --- a/tests/components/enigma2/test_init.py +++ b/tests/components/enigma2/test_init.py @@ -1,46 +1,45 @@ """Test the Enigma2 integration init.""" -from unittest.mock import patch +from unittest.mock import AsyncMock + +import pytest from homeassistant.components.enigma2.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .conftest import TEST_REQUIRED, MockDevice +from .conftest import TEST_REQUIRED -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture async def test_device_without_mac_address( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + openwebif_device_mock: AsyncMock, + device_registry: dr.DeviceRegistry, ) -> None: """Test that a device gets successfully registered when the device doesn't report a MAC address.""" - mock_device = MockDevice() - mock_device.mac_address = None - with patch( - "homeassistant.components.enigma2.coordinator.OpenWebIfDevice.__new__", - return_value=mock_device, - ): - entry = MockConfigEntry( - domain=DOMAIN, data=TEST_REQUIRED, title="name", unique_id="123456" - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert device_registry.async_get_device({(DOMAIN, entry.unique_id)}) is not None + openwebif_device_mock.get_about.return_value = load_json_object_fixture( + "device_about_without_mac.json", DOMAIN + ) + entry = MockConfigEntry( + domain=DOMAIN, data=TEST_REQUIRED, title="name", unique_id="123456" + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.unique_id == "123456" + assert device_registry.async_get_device({(DOMAIN, entry.unique_id)}) is not None +@pytest.mark.usefixtures("openwebif_device_mock") async def test_unload_entry(hass: HomeAssistant) -> None: """Test successful unload of entry.""" - with patch( - "homeassistant.components.enigma2.coordinator.OpenWebIfDevice.__new__", - return_value=MockDevice(), - ): - entry = MockConfigEntry(domain=DOMAIN, data=TEST_REQUIRED, title="name") - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = MockConfigEntry(domain=DOMAIN, data=TEST_REQUIRED, title="name") + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/enigma2/test_media_player.py b/tests/components/enigma2/test_media_player.py new file mode 100644 index 00000000000..dd1dcb66cb6 --- /dev/null +++ b/tests/components/enigma2/test_media_player.py @@ -0,0 +1,305 @@ +"""Tests for the media player module.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from openwebif.api import OpenWebIfServiceEvent, OpenWebIfStatus +from openwebif.enums import PowerState, RemoteControlCodes, SetVolumeOption +import pytest + +from homeassistant.components.enigma2.const import DOMAIN +from homeassistant.components.enigma2.media_player import ATTR_MEDIA_CURRENTLY_RECORDING +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + MediaPlayerState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, +) +from homeassistant.core import HomeAssistant + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, +) + + +@pytest.mark.parametrize( + ("deep_standby", "powerstate"), + [(False, PowerState.STANDBY), (True, PowerState.DEEP_STANDBY)], +) +async def test_turn_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + deep_standby: bool, + powerstate: PowerState, +) -> None: + """Test turning off the media player.""" + openwebif_device_mock.turn_off_to_deep = deep_standby + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "media_player.1_1_1_1"} + ) + + openwebif_device_mock.set_powerstate.assert_awaited_once_with(powerstate) + + +async def test_turn_on( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test turning on the media player.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "media_player.1_1_1_1"} + ) + + openwebif_device_mock.turn_on.assert_awaited_once() + + +async def test_set_volume_level( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test setting the volume of the media player.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.1_1_1_1", ATTR_MEDIA_VOLUME_LEVEL: 0.2}, + ) + + openwebif_device_mock.set_volume.assert_awaited_once_with(20) + + +async def test_volume_up( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test increasing the volume of the media player.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: "media_player.1_1_1_1"} + ) + + openwebif_device_mock.set_volume.assert_awaited_once_with(SetVolumeOption.UP) + + +async def test_volume_down( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test decreasing the volume of the media player.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_DOWN, + {ATTR_ENTITY_ID: "media_player.1_1_1_1"}, + ) + + openwebif_device_mock.set_volume.assert_awaited_once_with(SetVolumeOption.DOWN) + + +@pytest.mark.parametrize( + ("service", "remote_code"), + [ + (SERVICE_MEDIA_STOP, RemoteControlCodes.STOP), + (SERVICE_MEDIA_PLAY, RemoteControlCodes.PLAY), + (SERVICE_MEDIA_PAUSE, RemoteControlCodes.PAUSE), + (SERVICE_MEDIA_NEXT_TRACK, RemoteControlCodes.CHANNEL_UP), + (SERVICE_MEDIA_PREVIOUS_TRACK, RemoteControlCodes.CHANNEL_DOWN), + ], +) +async def test_remote_control_actions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + service: str, + remote_code: RemoteControlCodes, +) -> None: + """Test media stop.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: "media_player.1_1_1_1"}, + ) + + openwebif_device_mock.send_remote_control_action.assert_awaited_once_with( + remote_code + ) + + +@pytest.mark.parametrize("mute", [False, True]) +async def test_volume_mute( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + mute: bool, +) -> None: + """Test mute.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: "media_player.1_1_1_1", ATTR_MEDIA_VOLUME_MUTED: mute}, + ) + + openwebif_device_mock.toggle_mute.assert_awaited_once() + + +async def test_select_source( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test media previous track.""" + openwebif_device_mock.return_value.sources = {"Test": "1"} + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.1_1_1_1", ATTR_INPUT_SOURCE: "Test"}, + ) + + openwebif_device_mock.zap.assert_awaited_once_with("1") + + +async def test_update_data_standby( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test data handling.""" + + openwebif_device_mock.get_status_info.return_value = load_json_object_fixture( + "device_statusinfo_standby.json", DOMAIN + ) + openwebif_device_mock.status = OpenWebIfStatus( + currservice=OpenWebIfServiceEvent(), in_standby=True + ) + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + ATTR_MEDIA_CURRENTLY_RECORDING + not in hass.states.get("media_player.1_1_1_1").attributes + ) + assert hass.states.get("media_player.1_1_1_1").state == MediaPlayerState.OFF + + +async def test_update_volume( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test volume data handling.""" + + openwebif_device_mock.status = OpenWebIfStatus( + currservice=OpenWebIfServiceEvent(), in_standby=False, volume=100 + ) + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("media_player.1_1_1_1").attributes[ATTR_MEDIA_VOLUME_LEVEL] + > 0.99 + ) + + +async def test_update_volume_none( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test volume data handling.""" + + openwebif_device_mock.status = OpenWebIfStatus( + currservice=OpenWebIfServiceEvent(), in_standby=False, volume=None + ) + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + ATTR_MEDIA_VOLUME_LEVEL + not in hass.states.get("media_player.1_1_1_1").attributes + ) From 15d57692d9e67ce8f57742058ca815878daa2ce9 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sun, 19 Jan 2025 13:28:15 +0100 Subject: [PATCH 0634/2987] SMA add diagnostics (#135852) --- homeassistant/components/sma/diagnostics.py | 35 +++++++++++++++++++ .../sma/snapshots/test_diagnostics.ambr | 29 +++++++++++++++ tests/components/sma/test_diagnostics.py | 29 +++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 homeassistant/components/sma/diagnostics.py create mode 100644 tests/components/sma/snapshots/test_diagnostics.ambr create mode 100644 tests/components/sma/test_diagnostics.py diff --git a/homeassistant/components/sma/diagnostics.py b/homeassistant/components/sma/diagnostics.py new file mode 100644 index 00000000000..9c17cb0d2a9 --- /dev/null +++ b/homeassistant/components/sma/diagnostics.py @@ -0,0 +1,35 @@ +"""Diagnostics support for SMA.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +TO_REDACT = {CONF_PASSWORD} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for the config entry.""" + ent_reg = er.async_get(hass) + entities = [ + entity.entity_id + for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id) + ] + + entity_states = {entity: hass.states.get(entity) for entity in entities} + + entry_dict = entry.as_dict() + if "data" in entry_dict: + entry_dict["data"] = async_redact_data(entry_dict["data"], TO_REDACT) + + return { + "entry": entry_dict, + "entities": entity_states, + } diff --git a/tests/components/sma/snapshots/test_diagnostics.ambr b/tests/components/sma/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c7de3851b5f --- /dev/null +++ b/tests/components/sma/snapshots/test_diagnostics.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_get_config_entry_diagnostics + dict({ + 'entities': dict({ + }), + 'entry': dict({ + 'data': dict({ + 'group': 'user', + 'host': '1.1.1.1', + 'password': '**REDACTED**', + 'ssl': True, + 'verify_ssl': False, + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'sma', + 'minor_version': 2, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'import', + 'title': 'SMA Device Name', + 'unique_id': '123456789', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/sma/test_diagnostics.py b/tests/components/sma/test_diagnostics.py new file mode 100644 index 00000000000..6c1fe0dc5cb --- /dev/null +++ b/tests/components/sma/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Test the SMA diagnostics.""" + +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_get_config_entry_diagnostics( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if get_config_entry_diagnostics returns the correct data.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + assert diagnostics == snapshot( + exclude=props("created_at", "modified_at", "entry_id") + ) From 2f5545e7b8f4d5a27390b49eb6e2f35dcbc94105 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 19 Jan 2025 13:28:49 +0100 Subject: [PATCH 0635/2987] Fix name and descriptions of actions in EZVIZ integration etc. (#135858) --- homeassistant/components/ezviz/strings.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 58ac9dfde09..f1653661cdd 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -29,7 +29,7 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "Enter credentials to reauthenticate to ezviz cloud account", + "description": "Enter credentials to reauthenticate to EZVIZ cloud account", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -45,7 +45,7 @@ "abort": { "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "ezviz_cloud_account_missing": "Ezviz cloud account missing. Please reconfigure Ezviz cloud account", + "ezviz_cloud_account_missing": "EZVIZ cloud account missing. Please reconfigure EZVIZ cloud account", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, @@ -53,7 +53,7 @@ "step": { "init": { "data": { - "timeout": "Request Timeout (seconds)", + "timeout": "Request timeout (seconds)", "ffmpeg_arguments": "Arguments passed to ffmpeg for cameras" } } @@ -185,22 +185,22 @@ }, "services": { "set_alarm_detection_sensibility": { - "name": "Detection sensitivity", - "description": "Sets the detection sensibility level.", + "name": "Set detection sensibility", + "description": "Changes the sensibility level of the motion detection.", "fields": { "level": { - "name": "Sensitivity level", - "description": "Sensibility level (1-6) for type 0 (Normal camera) or (1-100) for type 3 (PIR sensor camera)." + "name": "Level", + "description": "Sensibility level. 1-6 for type 0 (normal camera), or 1-100 for type 3 (PIR sensor camera)." }, "type_value": { - "name": "Detection type", - "description": "Type of detection. Options : 0 - Camera or 3 - PIR Sensor Camera." + "name": "Type", + "description": "Detection type. 0 for normal camera, or 3 for PIR sensor camera." } } }, "wake_device": { "name": "Wake camera", - "description": "This can be used to wake the camera/device from hibernation." + "description": "Wakes a camera from sleep mode. Especially useful for battery cameras." } } } From 958b1e77595f160e2d68035fa2038958ffcc48ca Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 19 Jan 2025 13:29:21 +0100 Subject: [PATCH 0636/2987] Move integration setup to coordinator `_async_setup` in Bring (#135711) --- homeassistant/components/bring/__init__.py | 33 ++----------------- homeassistant/components/bring/coordinator.py | 31 ++++++++++------- tests/components/bring/conftest.py | 3 +- tests/components/bring/test_init.py | 19 ++++++----- tests/components/bring/test_util.py | 2 +- 5 files changed, 35 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py index 80b7a843cc0..0ee8e3b3155 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -4,20 +4,13 @@ from __future__ import annotations import logging -from bring_api import ( - Bring, - BringAuthException, - BringParseException, - BringRequestException, -) +from bring_api import Bring from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN from .coordinator import BringDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.TODO] @@ -30,30 +23,8 @@ type BringConfigEntry = ConfigEntry[BringDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool: """Set up Bring! from a config entry.""" - email = entry.data[CONF_EMAIL] - password = entry.data[CONF_PASSWORD] - session = async_get_clientsession(hass) - bring = Bring(session, email, password) - - try: - await bring.login() - except BringRequestException as e: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="setup_request_exception", - ) from e - except BringParseException as e: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="setup_parse_exception", - ) from e - except BringAuthException as e: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="setup_authentication_exception", - translation_placeholders={CONF_EMAIL: email}, - ) from e + bring = Bring(session, entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]) coordinator = BringDataUpdateCoordinator(hass, bring) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index a8d0a4ec322..d02237e84eb 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -16,7 +16,7 @@ from bring_api.types import BringItemsResponse, BringList, BringUserSettingsResp from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -51,7 +51,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): raise UpdateFailed("Unable to connect and retrieve data from bring") from e except BringParseException as e: raise UpdateFailed("Unable to parse response from bring") from e - except BringAuthException as e: + except BringAuthException: # try to recover by refreshing access token, otherwise # initiate reauth flow try: @@ -64,9 +64,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): translation_key="setup_authentication_exception", translation_placeholders={CONF_EMAIL: self.bring.mail}, ) from exc - raise UpdateFailed( - "Authentication failed but re-authentication was successful, trying again later" - ) from e + return self.data list_dict: dict[str, BringData] = {} for lst in lists_response["lists"]: @@ -88,13 +86,22 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): async def _async_setup(self) -> None: """Set up coordinator.""" - await self.async_refresh_user_settings() - - async def async_refresh_user_settings(self) -> None: - """Refresh user settings.""" try: + await self.bring.login() self.user_settings = await self.bring.get_all_user_settings() - except (BringAuthException, BringRequestException, BringParseException) as e: - raise UpdateFailed( - "Unable to connect and retrieve user settings from bring" + except BringRequestException as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_request_exception", + ) from e + except BringParseException as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_parse_exception", + ) from e + except BringAuthException as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_authentication_exception", + translation_placeholders={CONF_EMAIL: self.bring.mail}, ) from e diff --git a/tests/components/bring/conftest.py b/tests/components/bring/conftest.py index 62aa38d4e92..7d1b787ff0b 100644 --- a/tests/components/bring/conftest.py +++ b/tests/components/bring/conftest.py @@ -8,7 +8,7 @@ import uuid from bring_api.types import BringAuthResponse import pytest -from homeassistant.components.bring import DOMAIN +from homeassistant.components.bring.const import DOMAIN from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from tests.common import MockConfigEntry, load_json_object_fixture @@ -43,6 +43,7 @@ def mock_bring_client() -> Generator[AsyncMock]: ): client = mock_client.return_value client.uuid = UUID + client.mail = EMAIL client.login.return_value = cast(BringAuthResponse, {"name": "Bring"}) client.load_lists.return_value = load_json_object_fixture("lists.json", DOMAIN) client.get_list.return_value = load_json_object_fixture("items.json", DOMAIN) diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py index 659a4768600..8c215e024d5 100644 --- a/tests/components/bring/test_init.py +++ b/tests/components/bring/test_init.py @@ -3,15 +3,11 @@ from datetime import timedelta from unittest.mock import AsyncMock +from bring_api import BringAuthException, BringParseException, BringRequestException from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.bring import ( - BringAuthException, - BringParseException, - BringRequestException, - async_setup_entry, -) +from homeassistant.components.bring import async_setup_entry from homeassistant.components.bring.const import DOMAIN from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.core import HomeAssistant @@ -120,13 +116,20 @@ async def test_config_entry_not_ready( @pytest.mark.parametrize( - "exception", [None, BringAuthException, BringRequestException, BringParseException] + ("exception", "state"), + [ + (None, ConfigEntryState.LOADED), + (BringAuthException, ConfigEntryState.SETUP_ERROR), + (BringRequestException, ConfigEntryState.SETUP_RETRY), + (BringParseException, ConfigEntryState.SETUP_RETRY), + ], ) async def test_config_entry_not_ready_auth_error( hass: HomeAssistant, bring_config_entry: MockConfigEntry, mock_bring_client: AsyncMock, exception: Exception | None, + state: ConfigEntryState, ) -> None: """Test config entry not ready from authentication error.""" @@ -137,7 +140,7 @@ async def test_config_entry_not_ready_auth_error( await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() - assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY + assert bring_config_entry.state is state @pytest.mark.usefixtures("mock_bring_client") diff --git a/tests/components/bring/test_util.py b/tests/components/bring/test_util.py index 0d9ed0c5345..88379530362 100644 --- a/tests/components/bring/test_util.py +++ b/tests/components/bring/test_util.py @@ -5,7 +5,7 @@ from typing import cast from bring_api import BringUserSettingsResponse import pytest -from homeassistant.components.bring import DOMAIN +from homeassistant.components.bring.const import DOMAIN from homeassistant.components.bring.coordinator import BringData from homeassistant.components.bring.util import list_language, sum_attributes From 9d5fe77b716b8e3463950e8b7f38905ed6e97361 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 19 Jan 2025 13:34:22 +0100 Subject: [PATCH 0637/2987] Remove unnecessary "title" keys to use default setup flow instead (#135512) --- homeassistant/components/velux/strings.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index 1d0f86bfc6b..0cf578732fb 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -2,14 +2,12 @@ "config": { "step": { "user": { - "title": "Setup Velux", "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]" } }, "discovery_confirm": { - "title": "Setup Velux", "description": "Please enter the password for {name} ({host})", "data": { "password": "[%key:common::config_flow::data::password%]" From 654e111c23e02195886f617353f4cea7fd3bb242 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sun, 19 Jan 2025 13:39:38 +0100 Subject: [PATCH 0638/2987] Fix fan speed in auto mode in ViCare integration (#134256) --- homeassistant/components/vicare/fan.py | 9 +++++++-- homeassistant/components/vicare/sensor.py | 9 +++------ homeassistant/components/vicare/utils.py | 5 +++++ tests/components/vicare/test_utils.py | 23 +++++++++++++++++++++++ 4 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 tests/components/vicare/test_utils.py diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 69aa8396fea..fc18bdbd8da 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -28,7 +28,7 @@ from homeassistant.util.percentage import ( from .entity import ViCareEntity from .types import ViCareConfigEntry, ViCareDevice -from .utils import get_device_serial +from .utils import filter_state, get_device_serial _LOGGER = logging.getLogger(__name__) @@ -143,15 +143,20 @@ class ViCareFan(ViCareEntity, FanEntity): def update(self) -> None: """Update state of fan.""" + level: str | None = None try: with suppress(PyViCareNotSupportedFeatureError): self._attr_preset_mode = VentilationMode.from_vicare_mode( self._api.getActiveMode() ) with suppress(PyViCareNotSupportedFeatureError): + level = filter_state(self._api.getVentilationLevel()) + if level is not None and level in ORDERED_NAMED_FAN_SPEEDS: self._attr_percentage = ordered_list_item_to_percentage( - ORDERED_NAMED_FAN_SPEEDS, self._api.getActiveProgram() + ORDERED_NAMED_FAN_SPEEDS, VentilationProgram(level) ) + else: + self._attr_percentage = 0 except RequestConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") except ValueError: diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 1dade9ddda7..ba0191c5cd2 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -49,6 +49,7 @@ from .const import ( from .entity import ViCareEntity from .types import ViCareConfigEntry, ViCareDevice, ViCareRequiredKeysMixin from .utils import ( + filter_state, get_burners, get_circuits, get_compressors, @@ -796,7 +797,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( translation_key="photovoltaic_status", device_class=SensorDeviceClass.ENUM, options=["ready", "production"], - value_getter=lambda api: _filter_states(api.getPhotovoltaicStatus()), + value_getter=lambda api: filter_state(api.getPhotovoltaicStatus()), ), ViCareSensorEntityDescription( key="room_temperature", @@ -815,7 +816,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ViCareSensorEntityDescription( key="ventilation_level", translation_key="ventilation_level", - value_getter=lambda api: _filter_states(api.getVentilationLevel().lower()), + value_getter=lambda api: filter_state(api.getVentilationLevel().lower()), device_class=SensorDeviceClass.ENUM, options=["standby", "levelone", "leveltwo", "levelthree", "levelfour"], ), @@ -943,10 +944,6 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ) -def _filter_states(state: str) -> str | None: - return None if state in ("nothing", "unknown") else state - - def _build_entities( device_list: list[ViCareDevice], ) -> list[ViCareSensor]: diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index 120dad83113..a2c31df4259 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -128,3 +128,8 @@ def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceCompone except AttributeError as error: _LOGGER.debug("No compressors found: %s", error) return [] + + +def filter_state(state: str) -> str | None: + """Remove invalid states.""" + return None if state in ("nothing", "unknown") else state diff --git a/tests/components/vicare/test_utils.py b/tests/components/vicare/test_utils.py new file mode 100644 index 00000000000..13ca77f2792 --- /dev/null +++ b/tests/components/vicare/test_utils.py @@ -0,0 +1,23 @@ +"""Test ViCare utils.""" + +import pytest + +from homeassistant.components.vicare.utils import filter_state + + +@pytest.mark.parametrize( + ("state", "expected_result"), + [ + (None, None), + ("unknown", None), + ("nothing", None), + ("levelOne", "levelOne"), + ], +) +async def test_filter_state( + state: str | None, + expected_result: str | None, +) -> None: + """Test filter_state.""" + + assert filter_state(state) == expected_result From dfc4cdf7857d791491ab037f2e06443fefd026b2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 19 Jan 2025 13:43:35 +0100 Subject: [PATCH 0639/2987] Improve descriptions in list_notifications action, fix casing (#135838) --- homeassistant/components/flume/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/flume/strings.json b/homeassistant/components/flume/strings.json index 5f3021960b5..acdb8e35fe0 100644 --- a/homeassistant/components/flume/strings.json +++ b/homeassistant/components/flume/strings.json @@ -8,7 +8,7 @@ "step": { "user": { "description": "In order to access the Flume Personal API, you will need to request a 'Client ID' and 'Client Secret' at https://portal.flumetech.com/settings#token", - "title": "Connect to your Flume Account", + "title": "Connect to your Flume account", "data": { "username": "[%key:common::config_flow::data::username%]", "client_secret": "Client Secret", @@ -18,7 +18,7 @@ }, "reauth_confirm": { "description": "The password for {username} is no longer valid.", - "title": "Reauthenticate your Flume Account", + "title": "Reauthenticate your Flume account", "data": { "password": "[%key:common::config_flow::data::password%]" } @@ -65,11 +65,11 @@ "services": { "list_notifications": { "name": "List notifications", - "description": "Return user notifications.", + "description": "Returns a list of fetched user notifications.", "fields": { "config_entry": { "name": "Flume", - "description": "The flume config entry for which to return notifications." + "description": "The Flume config entry for which to return notifications." } } } From 41fe863b72090bdb52b009da21ddb6b53b79a7fb Mon Sep 17 00:00:00 2001 From: Mick Montorier-Aberman Date: Sun, 19 Jan 2025 14:22:21 +0100 Subject: [PATCH 0640/2987] Refactor SwitchBot Cloud make_device_data (#135698) --- .../components/switchbot_cloud/__init__.py | 133 ++++++++++-------- tests/components/switchbot_cloud/test_init.py | 2 +- 2 files changed, 78 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index e7313648e6a..e3cfc2172c7 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -8,7 +8,7 @@ from switchbot_api import CannotConnect, Device, InvalidAuth, Remote, SwitchBotA from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN @@ -43,77 +43,97 @@ class SwitchbotCloudData: devices: SwitchbotDevices -@callback -def prepare_device( +async def coordinator_for_device( hass: HomeAssistant, api: SwitchBotAPI, device: Device | Remote, coordinators_by_id: dict[str, SwitchBotCoordinator], -) -> tuple[Device | Remote, SwitchBotCoordinator]: +) -> SwitchBotCoordinator: """Instantiate coordinator and adds to list for gathering.""" coordinator = coordinators_by_id.setdefault( device.device_id, SwitchBotCoordinator(hass, api, device) ) - return (device, coordinator) + + if coordinator.data is None: + await coordinator.async_config_entry_first_refresh() + + return coordinator -@callback -def make_device_data( +async def make_switchbot_devices( hass: HomeAssistant, api: SwitchBotAPI, devices: list[Device | Remote], coordinators_by_id: dict[str, SwitchBotCoordinator], ) -> SwitchbotDevices: - """Make device data.""" + """Make SwitchBot devices.""" devices_data = SwitchbotDevices() - for device in devices: - if isinstance(device, Remote) and device.device_type.endswith( - "Air Conditioner" - ): - devices_data.climates.append( - prepare_device(hass, api, device, coordinators_by_id) - ) - if ( - isinstance(device, Device) - and ( - device.device_type.startswith("Plug") - or device.device_type in ["Relay Switch 1PM", "Relay Switch 1"] - ) - ) or isinstance(device, Remote): - devices_data.switches.append( - prepare_device(hass, api, device, coordinators_by_id) - ) - if isinstance(device, Device) and device.device_type in [ - "Meter", - "MeterPlus", - "WoIOSensor", - "Hub 2", - "MeterPro", - "MeterPro(CO2)", - "Relay Switch 1PM", - "Plug Mini (US)", - "Plug Mini (JP)", - ]: - devices_data.sensors.append( - prepare_device(hass, api, device, coordinators_by_id) - ) - if isinstance(device, Device) and device.device_type in [ - "K10+", - "K10+ Pro", - "Robot Vacuum Cleaner S1", - "Robot Vacuum Cleaner S1 Plus", - ]: - devices_data.vacuums.append( - prepare_device(hass, api, device, coordinators_by_id) - ) + await gather( + *[ + make_device_data(hass, api, device, devices_data, coordinators_by_id) + for device in devices + ] + ) - if isinstance(device, Device) and device.device_type.startswith("Smart Lock"): - devices_data.locks.append( - prepare_device(hass, api, device, coordinators_by_id) - ) return devices_data +async def make_device_data( + hass: HomeAssistant, + api: SwitchBotAPI, + device: Device | Remote, + devices_data: SwitchbotDevices, + coordinators_by_id: dict[str, SwitchBotCoordinator], +) -> None: + """Make device data.""" + if isinstance(device, Remote) and device.device_type.endswith("Air Conditioner"): + coordinator = await coordinator_for_device( + hass, api, device, coordinators_by_id + ) + devices_data.climates.append((device, coordinator)) + if ( + isinstance(device, Device) + and ( + device.device_type.startswith("Plug") + or device.device_type in ["Relay Switch 1PM", "Relay Switch 1"] + ) + ) or isinstance(device, Remote): + coordinator = await coordinator_for_device( + hass, api, device, coordinators_by_id + ) + devices_data.switches.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type in [ + "Meter", + "MeterPlus", + "WoIOSensor", + "Hub 2", + "MeterPro", + "MeterPro(CO2)", + "Relay Switch 1PM", + "Plug Mini (US)", + "Plug Mini (JP)", + ]: + devices_data.sensors.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type in [ + "K10+", + "K10+ Pro", + "Robot Vacuum Cleaner S1", + "Robot Vacuum Cleaner S1 Plus", + ]: + coordinator = await coordinator_for_device( + hass, api, device, coordinators_by_id + ) + devices_data.vacuums.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type.startswith("Smart Lock"): + coordinator = await coordinator_for_device( + hass, api, device, coordinators_by_id + ) + devices_data.locks.append((device, coordinator)) + + async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" token = config.data[CONF_API_TOKEN] @@ -131,12 +151,13 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: raise ConfigEntryNotReady from ex _LOGGER.debug("Devices: %s", devices) coordinators_by_id: dict[str, SwitchBotCoordinator] = {} + + switchbot_devices = await make_switchbot_devices( + hass, api, devices, coordinators_by_id + ) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][config.entry_id] = SwitchbotCloudData( - api=api, devices=make_device_data(hass, api, devices, coordinators_by_id) - ) - await gather( - *[coordinator.async_refresh() for coordinator in coordinators_by_id.values()] + api=api, devices=switchbot_devices ) await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) return True diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index 43431ae04c0..d5728faf369 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -116,7 +116,7 @@ async def test_setup_entry_fails_when_refreshing( mock_get_status.side_effect = CannotConnect entry = configure_integration(hass) await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.LOADED + assert entry.state is ConfigEntryState.SETUP_RETRY hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() From b17c36eeff4425ac38f95bca301e464600183245 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 19 Jan 2025 14:26:21 +0100 Subject: [PATCH 0641/2987] Add re-authentication flow to incomfort integration (#135861) --- .../components/incomfort/config_flow.py | 38 ++++++++++++++ .../components/incomfort/strings.json | 10 ++++ tests/components/incomfort/conftest.py | 3 +- .../components/incomfort/test_config_flow.py | 52 +++++++++++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py index ffaee2a38a4..bfc43faacf2 100644 --- a/homeassistant/components/incomfort/config_flow.py +++ b/homeassistant/components/incomfort/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from aiohttp import ClientResponseError @@ -43,6 +44,15 @@ CONFIG_SCHEMA = vol.Schema( } ) +REAUTH_SCHEMA = vol.Schema( + { + vol.Optional(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } +) + + OPTIONS_SCHEMA = vol.Schema( { vol.Optional(CONF_LEGACY_SETPOINT_STATUS, default=False): BooleanSelector( @@ -107,6 +117,34 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=CONFIG_SCHEMA, errors=errors ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication and confirmation.""" + errors: dict[str, str] | None = None + + if user_input: + password: str = user_input[CONF_PASSWORD] + + reauth_entry = self._get_reauth_entry() + errors = await async_try_connect_gateway( + self.hass, reauth_entry.data | {CONF_PASSWORD: password} + ) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, data_updates={CONF_PASSWORD: password} + ) + + return self.async_show_form( + step_id="reauth_confirm", data_schema=REAUTH_SCHEMA, errors=errors + ) + class InComfortOptionsFlowHandler(OptionsFlow): """Handle InComfort Lan2RF gateway options.""" diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 8687be19bb6..9fd31ae1c6f 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -13,6 +13,15 @@ "username": "The username to log into the gateway. This is `admin` in most cases.", "password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices." } + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Correct the gateway password." + }, + "description": "Re-authenticate to the gateway." } }, "abort": { @@ -21,6 +30,7 @@ "no_heaters": "No heaters found.", "not_found": "No Lan2RF gateway found.", "timeout_error": "Time out when connection to Lan2RF gateway.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "Unknown error when connection to Lan2RF gateway." }, "error": { diff --git a/tests/components/incomfort/conftest.py b/tests/components/incomfort/conftest.py index a450b7e26d3..3829c42d07f 100644 --- a/tests/components/incomfort/conftest.py +++ b/tests/components/incomfort/conftest.py @@ -8,7 +8,6 @@ from incomfortclient import DisplayCode import pytest from homeassistant.components.incomfort.const import DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -81,7 +80,7 @@ def mock_config_entry( hass: HomeAssistant, mock_entry_data: dict[str, Any], mock_entry_options: dict[str, Any], -) -> ConfigEntry: +) -> MockConfigEntry: """Mock a config entry setup for incomfort integration.""" entry = MockConfigEntry( domain=DOMAIN, data=mock_entry_data, options=mock_entry_options diff --git a/tests/components/incomfort/test_config_flow.py b/tests/components/incomfort/test_config_flow.py index ab24728874c..0c5ef2f31b1 100644 --- a/tests/components/incomfort/test_config_flow.py +++ b/tests/components/incomfort/test_config_flow.py @@ -116,6 +116,58 @@ async def test_form_validation( assert "errors" not in result +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the re-authentication flow succeeds.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_flow_failure( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the re-authentication flow fails.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch.object( + mock_incomfort(), + "heaters", + side_effect=IncomfortError(ClientResponseError(None, None, status=401)), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "incorrect-password"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_PASSWORD: "auth_error"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + @pytest.mark.parametrize( ("user_input", "legacy_setpoint_status"), [ From 439f22f5844e49ce1f89dbce44596074d7b2a712 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sun, 19 Jan 2025 08:07:00 -0600 Subject: [PATCH 0642/2987] Fix HEOS device information (#135940) --- homeassistant/components/heos/__init__.py | 18 +++++++-- homeassistant/components/heos/media_player.py | 10 +++-- .../components/heos/quality_scale.yaml | 6 +-- tests/components/heos/conftest.py | 4 +- tests/components/heos/test_init.py | 40 +++++++++++++++++++ tests/components/heos/test_media_player.py | 4 +- 6 files changed, 67 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 3b38e5c935a..1004ffd2738 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -81,6 +81,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=DOMAIN) + # Migrate non-string device identifiers. + device_registry = dr.async_get(hass) + for device in device_registry.devices.get_devices_for_config_entry_id( + entry.entry_id + ): + for domain, player_id in device.identifiers: + if domain == DOMAIN and not isinstance(player_id, str): + device_registry.async_update_device( + device.id, new_identifiers={(DOMAIN, str(player_id))} + ) + break + host = entry.data[CONF_HOST] credentials: Credentials | None = None if entry.options: @@ -221,13 +233,13 @@ class ControllerManager: # update device registry assert self._device_registry is not None entry = self._device_registry.async_get_device( - identifiers={(DOMAIN, old_id)} # type: ignore[arg-type] # Fix in the future + identifiers={(DOMAIN, str(old_id))} ) - new_identifiers = {(DOMAIN, new_id)} + new_identifiers = {(DOMAIN, str(new_id))} if entry: self._device_registry.async_update_device( entry.id, - new_identifiers=new_identifiers, # type: ignore[arg-type] # Fix in the future + new_identifiers=new_identifiers, ) _LOGGER.debug( "Updated device %s identifiers to %s", entry.id, new_identifiers diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 981a39f53dc..69aedaa4648 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -136,11 +136,15 @@ class HeosMediaPlayer(MediaPlayerEntity): self._source_manager = source_manager self._group_manager = group_manager self._attr_unique_id = str(player.player_id) + model_parts = player.model.split(maxsplit=1) + manufacturer = model_parts[0] if len(model_parts) == 2 else "HEOS" + model = model_parts[1] if len(model_parts) == 2 else player.model self._attr_device_info = DeviceInfo( - identifiers={(HEOS_DOMAIN, player.player_id)}, - manufacturer="HEOS", - model=player.model, + identifiers={(HEOS_DOMAIN, str(player.player_id))}, + manufacturer=manufacturer, + model=model, name=player.name, + serial_number=player.serial, # Only available for some models sw_version=player.version, ) diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index 4cd39434521..3135cca3f9d 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -50,11 +50,7 @@ rules: 4. Recommend using snapshot in test_state_attributes. 5. Find a way to avoid using internal dispatcher in test_updates_from_connection_event. # Gold - devices: - status: todo - comment: | - The integraiton creates devices, but needs to stringify the id for the device identifier and - also migrate the device. + devices: done diagnostics: todo discovery-update-info: status: todo diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 38d2f237907..4a11a3511d5 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -125,8 +125,8 @@ def player_fixture(quick_selects): player = HeosPlayer( player_id=i, name="Test Player" if i == 1 else f"Test Player {i}", - model="Test Model", - serial="", + model="HEOS Drive HS2" if i == 1 else "Speaker", + serial="123456", version="1.0.0", line_out=LineOutLevelType.VARIABLE, is_muted=False, diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index a8cd4bea1d2..f802529ac82 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -19,6 +19,7 @@ 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.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -217,3 +218,42 @@ async def test_update_sources_retry( while "Unable to update sources" not in caplog.text: await asyncio.sleep(0.1) assert controller.get_favorites.call_count == 2 + + +async def test_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test device information populates correctly.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + device = device_registry.async_get_device({(DOMAIN, "1")}) + assert device.manufacturer == "HEOS" + assert device.model == "Drive HS2" + assert device.name == "Test Player" + assert device.serial_number == "123456" + assert device.sw_version == "1.0.0" + device = device_registry.async_get_device({(DOMAIN, "2")}) + assert device.manufacturer == "HEOS" + assert device.model == "Speaker" + + +async def test_device_id_migration( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test that legacy non-string device identifiers are migrated to strings.""" + config_entry.add_to_hass(hass) + # Create a device with a legacy identifier + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, 1)} + ) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("Other", 1)} + ) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert device_registry.async_get_device({("Other", 1)}) is not None + assert device_registry.async_get_device({(DOMAIN, 1)}) is None + assert device_registry.async_get_device({(DOMAIN, "1")}) is not None diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index f2b54ecec81..e71614564f2 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -271,7 +271,7 @@ async def test_updates_from_players_changed_new_ids( event = asyncio.Event() # Assert device registry matches current id - assert device_registry.async_get_device(identifiers={(DOMAIN, 1)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "1")}) # Assert entity registry matches current id assert ( entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "1") @@ -292,7 +292,7 @@ async def test_updates_from_players_changed_new_ids( # Assert device registry identifiers were updated assert len(device_registry.devices) == 2 - assert device_registry.async_get_device(identifiers={(DOMAIN, 101)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "101")}) # Assert entity registry unique id was updated assert len(entity_registry.entities) == 2 assert ( From cf29ef91eeed8deadab2bb71d10ae89ca67c3ed4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 19 Jan 2025 15:15:21 +0100 Subject: [PATCH 0643/2987] Fix switchbot cloud library logger (#135987) --- homeassistant/components/switchbot_cloud/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index 6fc6d8030d2..99f909e91ab 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "integration_type": "hub", "iot_class": "cloud_polling", - "loggers": ["switchbot-api"], + "loggers": ["switchbot_api"], "requirements": ["switchbot-api==2.3.1"] } From 02bf8447b3c5ca58ab99c3521ddbe37ea40fe3bb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 19 Jan 2025 15:15:32 +0100 Subject: [PATCH 0644/2987] Fix unset coordinator in Switchbot cloud (#135985) --- homeassistant/components/switchbot_cloud/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index e3cfc2172c7..f14547326ba 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -114,6 +114,9 @@ async def make_device_data( "Plug Mini (US)", "Plug Mini (JP)", ]: + coordinator = await coordinator_for_device( + hass, api, device, coordinators_by_id + ) devices_data.sensors.append((device, coordinator)) if isinstance(device, Device) and device.device_type in [ From 3077a4cdeef3505ec6f20c48820ffeb7d68e402c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 19 Jan 2025 15:16:26 +0100 Subject: [PATCH 0645/2987] Add re-configure flow incomfort integration (#135887) * Add re-configure flow incomfort integration * End with abort flow in reconfigure failure flow * Apply parenthesis --- .../components/incomfort/config_flow.py | 29 +++++++++- .../components/incomfort/strings.json | 1 + .../components/incomfort/test_config_flow.py | 56 ++++++++++++++++++- 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py index bfc43faacf2..3db8e40f9f4 100644 --- a/homeassistant/components/incomfort/config_flow.py +++ b/homeassistant/components/incomfort/config_flow.py @@ -10,6 +10,7 @@ from incomfortclient import IncomfortError, InvalidHeaterList import voluptuous as vol from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, ConfigEntry, ConfigFlow, ConfigFlowResult, @@ -106,15 +107,31 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the initial step.""" errors: dict[str, str] | None = None + data_schema: vol.Schema = CONFIG_SCHEMA + if is_reconfigure := (self.source == SOURCE_RECONFIGURE): + reconfigure_entry = self._get_reconfigure_entry() + data_schema = self.add_suggested_values_to_schema( + data_schema, reconfigure_entry.data + ) if user_input is not None: - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) if ( - errors := await async_try_connect_gateway(self.hass, user_input) + errors := await async_try_connect_gateway( + self.hass, + (reconfigure_entry.data | user_input) + if is_reconfigure + else user_input, + ) ) is None: + if is_reconfigure: + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates=user_input + ) + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) return self.async_create_entry(title=TITLE, data=user_input) + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) return self.async_show_form( - step_id="user", data_schema=CONFIG_SCHEMA, errors=errors + step_id="user", data_schema=data_schema, errors=errors ) async def async_step_reauth( @@ -145,6 +162,12 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", data_schema=REAUTH_SCHEMA, errors=errors ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration flow.""" + return await self.async_step_user() + class InComfortOptionsFlowHandler(OptionsFlow): """Handle InComfort Lan2RF gateway options.""" diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 9fd31ae1c6f..2f2f526421a 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -31,6 +31,7 @@ "not_found": "No Lan2RF gateway found.", "timeout_error": "Time out when connection to Lan2RF gateway.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "Unknown error when connection to Lan2RF gateway." }, "error": { diff --git a/tests/components/incomfort/test_config_flow.py b/tests/components/incomfort/test_config_flow.py index 0c5ef2f31b1..9ab5a672d61 100644 --- a/tests/components/incomfort/test_config_flow.py +++ b/tests/components/incomfort/test_config_flow.py @@ -39,7 +39,9 @@ async def test_form( assert len(mock_setup_entry.mock_calls) == 1 -async def test_entry_already_configured(hass: HomeAssistant) -> None: +async def test_entry_already_configured( + hass: HomeAssistant, mock_incomfort: MagicMock +) -> None: """Test aborting if the entry is already configured.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) entry.add_to_hass(hass) @@ -168,6 +170,58 @@ async def test_reauth_flow_failure( assert result["reason"] == "reauth_successful" +async def test_reconfigure_flow_success( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the re-configure flow succeeds.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG | {CONF_PASSWORD: "new-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_flow_failure( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the re-configure flow fails.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch.object( + mock_incomfort(), + "heaters", + side_effect=IncomfortError(ClientResponseError(None, None, status=401)), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG | {CONF_PASSWORD: "wrong-password"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_PASSWORD: "auth_error"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG | {CONF_PASSWORD: "new-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + @pytest.mark.parametrize( ("user_input", "legacy_setpoint_status"), [ From 04eb86e5a0c14b5f71d8a31495601a989009badc Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 19 Jan 2025 15:30:03 +0100 Subject: [PATCH 0646/2987] Cleanup incomfort translation strings (#135991) --- .../components/incomfort/strings.json | 35 +++---------------- 1 file changed, 4 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 2f2f526421a..8bcfa4ce5e1 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -26,20 +26,15 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { "auth_error": "Invalid credentials.", "no_heaters": "No heaters found.", "not_found": "No Lan2RF gateway found.", "timeout_error": "Time out when connection to Lan2RF gateway.", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "Unknown error when connection to Lan2RF gateway." - }, - "error": { - "auth_error": "[%key:component::incomfort::config::abort::auth_error%]", - "no_heaters": "[%key:component::incomfort::config::abort::no_heaters%]", - "not_found": "[%key:component::incomfort::config::abort::not_found%]", - "timeout_error": "[%key:component::incomfort::config::abort::timeout_error%]", - "unknown": "[%key:component::incomfort::config::abort::unknown%]" } }, "options": { @@ -55,28 +50,6 @@ } } }, - "issues": { - "deprecated_yaml_import_issue_unknown": { - "title": "YAML import failed with unknown error", - "description": "Configuring {integration_title} using YAML is being removed but there was an unknown error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." - }, - "deprecated_yaml_import_issue_auth_error": { - "title": "YAML import failed due to an authentication error", - "description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." - }, - "deprecated_yaml_import_issue_no_heaters": { - "title": "YAML import failed because no heaters were found", - "description": "Configuring {integration_title} using YAML is being removed but no heaters were found while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." - }, - "deprecated_yaml_import_issue_not_found": { - "title": "YAML import failed because no gateway was found", - "description": "Configuring {integration_title} using YAML is being removed but no Lan2RF gateway was found while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." - }, - "deprecated_yaml_import_issue_timeout_error": { - "title": "YAML import failed because of timeout issues", - "description": "Configuring {integration_title} using YAML is being removed but there was a timeout while connecting to your {integration_title} while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." - } - }, "entity": { "binary_sensor": { "fault": { From 5ffae140af46ec27b544c7377caf18249c917dea Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 19 Jan 2025 19:27:36 +0100 Subject: [PATCH 0647/2987] Add diagnostics feature to incomfort integration (#136009) --- .../components/incomfort/diagnostics.py | 45 +++++++++++++++++++ .../incomfort/snapshots/test_diagnostics.ambr | 35 +++++++++++++++ .../components/incomfort/test_diagnostics.py | 24 ++++++++++ 3 files changed, 104 insertions(+) create mode 100644 homeassistant/components/incomfort/diagnostics.py create mode 100644 tests/components/incomfort/snapshots/test_diagnostics.ambr create mode 100644 tests/components/incomfort/test_diagnostics.py diff --git a/homeassistant/components/incomfort/diagnostics.py b/homeassistant/components/incomfort/diagnostics.py new file mode 100644 index 00000000000..1f21dfed8b3 --- /dev/null +++ b/homeassistant/components/incomfort/diagnostics.py @@ -0,0 +1,45 @@ +"""Diagnostics support for InComfort integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant, callback + +from . import InComfortConfigEntry + +REDACT_CONFIG = {CONF_PASSWORD} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: InComfortConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return _async_get_diagnostics(hass, entry) + + +@callback +def _async_get_diagnostics( + hass: HomeAssistant, + entry: InComfortConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + redacted_config = async_redact_data(entry.data | entry.options, REDACT_CONFIG) + coordinator = entry.runtime_data + + nr_heaters = len(coordinator.incomfort_data.heaters) + status: dict[str, Any] = { + f"heater_{n}": coordinator.incomfort_data.heaters[n].status + for n in range(nr_heaters) + } + for n in range(nr_heaters): + status[f"heater_{n}"]["rooms"] = { + n: dict(coordinator.incomfort_data.heaters[n].rooms[m].status) + for m in range(len(coordinator.incomfort_data.heaters[n].rooms)) + } + return { + "config": redacted_config, + "gateway": status, + } diff --git a/tests/components/incomfort/snapshots/test_diagnostics.ambr b/tests/components/incomfort/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..e7c99f37acd --- /dev/null +++ b/tests/components/incomfort/snapshots/test_diagnostics.ambr @@ -0,0 +1,35 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config': dict({ + 'host': '192.168.1.12', + 'password': '**REDACTED**', + 'username': 'admin', + }), + 'gateway': dict({ + 'heater_0': dict({ + 'display_code': 126, + 'display_text': 'standby', + 'fault_code': None, + 'heater_temp': 35.34, + 'is_burning': False, + 'is_failed': False, + 'is_pumping': False, + 'is_tapping': False, + 'nodenr': 249, + 'pressure': 1.86, + 'rf_message_rssi': 30, + 'rfstatus_cntr': 0, + 'rooms': dict({ + '0': dict({ + 'override': 18.0, + 'room_temp': 21.42, + 'setpoint': 18.0, + }), + }), + 'serial_no': 'c0ffeec0ffee', + 'tap_temp': 30.21, + }), + }), + }) +# --- diff --git a/tests/components/incomfort/test_diagnostics.py b/tests/components/incomfort/test_diagnostics.py new file mode 100644 index 00000000000..02493681705 --- /dev/null +++ b/tests/components/incomfort/test_diagnostics.py @@ -0,0 +1,24 @@ +"""Test diagnostics for the Intergas InComfort integration.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, SnapshotAssertion +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the incomfort integration diagnostics.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + snapshot.assert_match( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + ) From 889f699e5d1aeb46112def0ef7f7ab46810d5356 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 19 Jan 2025 19:28:19 +0100 Subject: [PATCH 0648/2987] Disable noisy diagnostic incomfort sensors by default (#135992) --- homeassistant/components/incomfort/binary_sensor.py | 4 ++++ homeassistant/components/incomfort/sensor.py | 3 +++ tests/components/incomfort/test_binary_sensor.py | 2 ++ tests/components/incomfort/test_init.py | 4 ++++ tests/components/incomfort/test_sensor.py | 2 ++ 5 files changed, 15 insertions(+) diff --git a/homeassistant/components/incomfort/binary_sensor.py b/homeassistant/components/incomfort/binary_sensor.py index 2696491422b..c4a23946bb2 100644 --- a/homeassistant/components/incomfort/binary_sensor.py +++ b/homeassistant/components/incomfort/binary_sensor.py @@ -42,24 +42,28 @@ SENSOR_TYPES: tuple[IncomfortBinarySensorEntityDescription, ...] = ( extra_state_attributes_fn=lambda status: { "fault_code": status["fault_code"] or "none", }, + entity_registry_enabled_default=False, ), IncomfortBinarySensorEntityDescription( key="is_pumping", translation_key="is_pumping", device_class=BinarySensorDeviceClass.RUNNING, value_key="is_pumping", + entity_registry_enabled_default=False, ), IncomfortBinarySensorEntityDescription( key="is_burning", translation_key="is_burning", device_class=BinarySensorDeviceClass.RUNNING, value_key="is_burning", + entity_registry_enabled_default=False, ), IncomfortBinarySensorEntityDescription( key="is_tapping", translation_key="is_tapping", device_class=BinarySensorDeviceClass.RUNNING, value_key="is_tapping", + entity_registry_enabled_default=False, ), ) diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index 793a2f0450c..e9697a0036f 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -41,6 +41,7 @@ SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, value_key="pressure", + entity_registry_enabled_default=False, ), IncomfortSensorEntityDescription( key="cv_temp", @@ -49,6 +50,7 @@ SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, extra_key="is_pumping", value_key="heater_temp", + entity_registry_enabled_default=False, ), IncomfortSensorEntityDescription( key="tap_temp", @@ -58,6 +60,7 @@ SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, extra_key="is_tapping", value_key="tap_temp", + entity_registry_enabled_default=False, ), ) diff --git a/tests/components/incomfort/test_binary_sensor.py b/tests/components/incomfort/test_binary_sensor.py index c724cf4b7b2..e90cc3ac391 100644 --- a/tests/components/incomfort/test_binary_sensor.py +++ b/tests/components/incomfort/test_binary_sensor.py @@ -17,6 +17,7 @@ from tests.common import snapshot_platform @patch("homeassistant.components.incomfort.PLATFORMS", [Platform.BINARY_SENSOR]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_setup_platform( hass: HomeAssistant, mock_incomfort: MagicMock, @@ -45,6 +46,7 @@ async def test_setup_platform( ids=["is_failed", "is_pumping", "is_burning", "is_tapping"], ) @patch("homeassistant.components.incomfort.PLATFORMS", [Platform.BINARY_SENSOR]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_setup_binary_sensors_alt( hass: HomeAssistant, mock_incomfort: MagicMock, diff --git a/tests/components/incomfort/test_init.py b/tests/components/incomfort/test_init.py index 7557e36219c..f603c3ce27b 100644 --- a/tests/components/incomfort/test_init.py +++ b/tests/components/incomfort/test_init.py @@ -18,6 +18,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import async_fire_time_changed +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_setup_platforms( hass: HomeAssistant, mock_incomfort: MagicMock, @@ -29,6 +30,7 @@ async def test_setup_platforms( assert mock_config_entry.state is ConfigEntryState.LOADED +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_coordinator_updates( hass: HomeAssistant, mock_incomfort: MagicMock, @@ -60,6 +62,7 @@ async def test_coordinator_updates( assert state.state == "1.84" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( "exc", [ @@ -105,6 +108,7 @@ async def test_coordinator_update_fails( assert state.state == STATE_UNAVAILABLE +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("exc", "config_entry_state"), [ diff --git a/tests/components/incomfort/test_sensor.py b/tests/components/incomfort/test_sensor.py index d01fd9b403e..df0db39a56c 100644 --- a/tests/components/incomfort/test_sensor.py +++ b/tests/components/incomfort/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch +import pytest from syrupy import SnapshotAssertion from homeassistant.config_entries import ConfigEntry @@ -12,6 +13,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @patch("homeassistant.components.incomfort.PLATFORMS", [Platform.SENSOR]) async def test_setup_platform( hass: HomeAssistant, From 3ee2dc9790a546f230b51a9501b5ac6656da14fa Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 19 Jan 2025 19:43:47 +0100 Subject: [PATCH 0649/2987] Make strings of create_scene action UI- and translation-friendly (#136004) --- homeassistant/components/scene/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/scene/strings.json b/homeassistant/components/scene/strings.json index 3fa750bf4ef..4c3e7ad43fe 100644 --- a/homeassistant/components/scene/strings.json +++ b/homeassistant/components/scene/strings.json @@ -38,12 +38,12 @@ "description": "The entity ID of the new scene." }, "entities": { - "name": "Entities state", - "description": "List of entities and their target state. If your entities are already in the target state right now, use `snapshot_entities` instead." + "name": "Entity states", + "description": "List of entities and their target state. If your entities are already in the target state right now, use 'Entities snapshot' instead." }, "snapshot_entities": { - "name": "Snapshot entities", - "description": "List of entities to be included in the snapshot. By taking a snapshot, you record the current state of those entities. If you do not want to use the current state of all your entities for this scene, you can combine the `snapshot_entities` with `entities`." + "name": "Entities snapshot", + "description": "List of entities to be included in the snapshot. By taking a snapshot, you record the current state of those entities. If you do not want to use the current state of all your entities for this scene, you can combine 'Entities snapshot' with 'Entity states'." } } }, @@ -54,7 +54,7 @@ }, "exceptions": { "entity_not_scene": { - "message": "{entity_id} is not a valid scene entity_id." + "message": "{entity_id} is not a valid entity ID of a scene." }, "entity_not_dynamically_created": { "message": "The scene {entity_id} is not created with action `scene.create`." From ccd7b1c21aeb228eebe55a80cee87f5bf8271d71 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 19 Jan 2025 19:51:04 +0100 Subject: [PATCH 0650/2987] Add incomfort heater serialnr to device info (#136012) --- homeassistant/components/incomfort/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/incomfort/entity.py b/homeassistant/components/incomfort/entity.py index 33037a78edf..dd662b411dd 100644 --- a/homeassistant/components/incomfort/entity.py +++ b/homeassistant/components/incomfort/entity.py @@ -26,4 +26,5 @@ class IncomfortBoilerEntity(IncomfortEntity): identifiers={(DOMAIN, heater.serial_no)}, manufacturer="Intergas", name="Boiler", + serial_number=heater.serial_no, ) From ec45cb4939cb74a7b4e43edbe646ace70e3b7d81 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 19 Jan 2025 19:51:55 +0100 Subject: [PATCH 0651/2987] Improve exception handling in Habitica integration (#135950) --- homeassistant/components/habitica/button.py | 10 +- .../components/habitica/coordinator.py | 34 +++- .../components/habitica/quality_scale.yaml | 4 +- homeassistant/components/habitica/services.py | 53 ++++++- .../components/habitica/strings.json | 4 +- homeassistant/components/habitica/todo.py | 56 ++++++- tests/components/habitica/conftest.py | 6 +- tests/components/habitica/test_button.py | 10 +- tests/components/habitica/test_init.py | 14 +- tests/components/habitica/test_services.py | 52 +++++- tests/components/habitica/test_switch.py | 2 + tests/components/habitica/test_todo.py | 148 +++++++++++++++--- 12 files changed, 337 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/habitica/button.py b/homeassistant/components/habitica/button.py index 14625b31c2b..450a5cdcf20 100644 --- a/homeassistant/components/habitica/button.py +++ b/homeassistant/components/habitica/button.py @@ -335,16 +335,24 @@ class HabiticaButton(HabiticaBase, ButtonEntity): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, ) from e except NotAuthorizedError as e: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="service_call_unallowed", ) from e - except (HabiticaException, ClientError) as e: + except HabiticaException as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": e.error.message}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e else: await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index 587f8148398..f97b98410bb 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -85,11 +85,19 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, ) from e - except (HabiticaException, ClientError) as e: + except HabiticaException as e: raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e if not self.config_entry.data.get(CONF_NAME): @@ -108,8 +116,18 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): except TooManyRequestsError: _LOGGER.debug("Rate limit exceeded, will try again later") return self.data - except (HabiticaException, ClientError) as e: - raise UpdateFailed(f"Unable to connect to Habitica: {e}") from e + except HabiticaException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e else: return HabiticaData(user=user, tasks=tasks + completed_todos) @@ -124,11 +142,19 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, ) from e - except (HabiticaException, ClientError) as e: + except HabiticaException as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": e.error.message}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e else: await self.async_request_refresh() diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml index e279f924b72..c4ad2c76110 100644 --- a/homeassistant/components/habitica/quality_scale.yaml +++ b/homeassistant/components/habitica/quality_scale.yaml @@ -64,9 +64,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: - status: todo - comment: translations for UpdateFailed missing + exception-translations: done icon-translations: done reconfiguration-flow: todo repair-issues: diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 5961c139003..a28aada85fa 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -224,6 +224,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 raise HomeAssistantError( translation_domain=DOMAIN, translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, ) from e except NotAuthorizedError as e: raise ServiceValidationError( @@ -243,10 +244,17 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 translation_key="skill_not_found", translation_placeholders={"skill": call.data[ATTR_SKILL]}, ) from e - except (HabiticaException, ClientError) as e: + except HabiticaException as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e else: await coordinator.async_request_refresh() @@ -274,6 +282,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 raise HomeAssistantError( translation_domain=DOMAIN, translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, ) from e except NotAuthorizedError as e: raise ServiceValidationError( @@ -283,9 +292,17 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 raise ServiceValidationError( translation_domain=DOMAIN, translation_key="quest_not_found" ) from e - except (HabiticaException, ClientError) as e: + except HabiticaException as e: raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="service_call_exception" + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e else: return asdict(response.data) @@ -335,6 +352,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 raise HomeAssistantError( translation_domain=DOMAIN, translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, ) from e except NotAuthorizedError as e: if task_value is not None: @@ -349,11 +367,19 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": e.error.message}, ) from e - except (HabiticaException, ClientError) as e: + except HabiticaException as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e else: await coordinator.async_request_refresh() @@ -382,10 +408,17 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 translation_domain=DOMAIN, translation_key="party_not_found", ) from e - except (ClientError, HabiticaException) as e: + except HabiticaException as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e try: target_id = next( @@ -411,6 +444,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 raise HomeAssistantError( translation_domain=DOMAIN, translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, ) from e except NotAuthorizedError as e: raise ServiceValidationError( @@ -418,10 +452,17 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 translation_key="item_not_found", translation_placeholders={"item": call.data[ATTR_ITEM]}, ) from e - except (HabiticaException, ClientError) as e: + except HabiticaException as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e else: return asdict(response.data) diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 4e1a0ac9f64..44487e7cb37 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -375,13 +375,13 @@ "message": "Unable to create new to-do `{name}` for Habitica, please try again" }, "setup_rate_limit_exception": { - "message": "Rate limit exceeded, try again later" + "message": "Rate limit exceeded, try again in {retry_after} seconds" }, "service_call_unallowed": { "message": "Unable to complete action, the required conditions are not met" }, "service_call_exception": { - "message": "Unable to connect to Habitica, try again later" + "message": "Unable to connect to Habitica: {reason}" }, "not_enough_mana": { "message": "Unable to cast skill, not enough mana. Your character has {mana}, but the skill costs {cost}." diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py index a14327f5378..c1786059300 100644 --- a/homeassistant/components/habitica/todo.py +++ b/homeassistant/components/habitica/todo.py @@ -3,11 +3,18 @@ from __future__ import annotations from enum import StrEnum +import logging from typing import TYPE_CHECKING from uuid import UUID from aiohttp import ClientError -from habiticalib import Direction, HabiticaException, Task, TaskType +from habiticalib import ( + Direction, + HabiticaException, + Task, + TaskType, + TooManyRequestsError, +) from homeassistant.components import persistent_notification from homeassistant.components.todo import ( @@ -17,7 +24,7 @@ from homeassistant.components.todo import ( TodoListEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util @@ -28,6 +35,8 @@ from .entity import HabiticaBase from .types import HabiticaConfigEntry from .util import next_due_date +_LOGGER = logging.getLogger(__name__) + PARALLEL_UPDATES = 1 @@ -72,7 +81,14 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): if len(uids) > 1 and self.entity_description.key is HabiticaTodoList.TODOS: try: await self.coordinator.habitica.delete_completed_todos() + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e except (HabiticaException, ClientError) as e: + _LOGGER.debug(str(e)) raise ServiceValidationError( translation_domain=DOMAIN, translation_key="delete_completed_todos_failed", @@ -81,7 +97,14 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): for task_id in uids: try: await self.coordinator.habitica.delete_task(UUID(task_id)) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e except (HabiticaException, ClientError) as e: + _LOGGER.debug(str(e)) raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"delete_{self.entity_description.key}_failed", @@ -108,7 +131,14 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): try: await self.coordinator.habitica.reorder_task(UUID(uid), pos) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e except (HabiticaException, ClientError) as e: + _LOGGER.debug(str(e)) raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"move_{self.entity_description.key}_item_failed", @@ -160,7 +190,14 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): try: await self.coordinator.habitica.update_task(UUID(item.uid), task) refresh_required = True + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e except (HabiticaException, ClientError) as e: + _LOGGER.debug(str(e)) raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"update_{self.entity_description.key}_item_failed", @@ -187,8 +224,14 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): refresh_required = True else: score_result = None - + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e except (HabiticaException, ClientError) as e: + _LOGGER.debug(str(e)) raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"score_{self.entity_description.key}_item_failed", @@ -260,7 +303,14 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity): date=item.due, ) ) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e except (HabiticaException, ClientError) as e: + _LOGGER.debug(str(e)) raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"create_{self.entity_description.key}_item_failed", diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index b1410f559db..daf1c669463 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -32,11 +32,13 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture -ERROR_RESPONSE = HabiticaErrorResponse(success=False, error="error", message="message") +ERROR_RESPONSE = HabiticaErrorResponse(success=False, error="error", message="reason") ERROR_NOT_AUTHORIZED = NotAuthorizedError(error=ERROR_RESPONSE, headers={}) ERROR_NOT_FOUND = NotFoundError(error=ERROR_RESPONSE, headers={}) ERROR_BAD_REQUEST = BadRequestError(error=ERROR_RESPONSE, headers={}) -ERROR_TOO_MANY_REQUESTS = TooManyRequestsError(error=ERROR_RESPONSE, headers={}) +ERROR_TOO_MANY_REQUESTS = TooManyRequestsError( + error=ERROR_RESPONSE, headers={"retry-after": 5} +) @pytest.fixture(name="config_entry") diff --git a/tests/components/habitica/test_button.py b/tests/components/habitica/test_button.py index adce8dce080..dc1a155b541 100644 --- a/tests/components/habitica/test_button.py +++ b/tests/components/habitica/test_button.py @@ -4,6 +4,7 @@ from collections.abc import Generator from datetime import timedelta from unittest.mock import AsyncMock, patch +from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory from habiticalib import HabiticaUserResponse, Skill import pytest @@ -215,12 +216,12 @@ async def test_button_press( [ ( ERROR_TOO_MANY_REQUESTS, - "Rate limit exceeded, try again later", + "Rate limit exceeded, try again in 5 seconds", HomeAssistantError, ), ( ERROR_BAD_REQUEST, - "Unable to connect to Habitica, try again later", + "Unable to connect to Habitica: reason", HomeAssistantError, ), ( @@ -228,6 +229,11 @@ async def test_button_press( "Unable to complete action, the required conditions are not met", ServiceValidationError, ), + ( + ClientError, + "Unable to connect to Habitica: ", + HomeAssistantError, + ), ], ) async def test_button_press_exceptions( diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index ed2efd89f30..e953ec254d6 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -4,6 +4,7 @@ import datetime import logging from unittest.mock import AsyncMock +from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory import pytest @@ -85,11 +86,12 @@ async def test_service_call( @pytest.mark.parametrize( ("exception"), - [ - ERROR_BAD_REQUEST, - ERROR_TOO_MANY_REQUESTS, + [ERROR_BAD_REQUEST, ERROR_TOO_MANY_REQUESTS, ClientError], + ids=[ + "BadRequestError", + "TooManyRequestsError", + "ClientError", ], - ids=["BadRequestError", "TooManyRequestsError"], ) async def test_config_entry_not_ready( hass: HomeAssistant, @@ -131,14 +133,16 @@ async def test_config_entry_auth_failed( assert flow["context"].get("entry_id") == config_entry.entry_id +@pytest.mark.parametrize("exception", [ERROR_NOT_FOUND, ClientError]) async def test_coordinator_update_failed( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, + exception: Exception, ) -> None: """Test coordinator update failed.""" - habitica.get_tasks.side_effect = ERROR_NOT_FOUND + habitica.get_tasks.side_effect = exception config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 3ada16b9735..5fca1884bdf 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -5,6 +5,7 @@ from typing import Any from unittest.mock import AsyncMock, patch from uuid import UUID +from aiohttp import ClientError from habiticalib import Direction, Skill import pytest from syrupy.assertion import SnapshotAssertion @@ -46,8 +47,8 @@ from .conftest import ( from tests.common import MockConfigEntry -REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica, try again later" -RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again later" +REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica: reason" +RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again in 5 seconds" @pytest.fixture(autouse=True) @@ -235,6 +236,15 @@ async def test_cast_skill( HomeAssistantError, REQUEST_EXCEPTION_MSG, ), + ( + { + ATTR_TASK: "Rechnungen bezahlen", + ATTR_SKILL: "smash", + }, + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), ], ) async def test_cast_skill_exceptions( @@ -360,6 +370,11 @@ async def test_handle_quests( HomeAssistantError, REQUEST_EXCEPTION_MSG, ), + ( + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), ], ) @pytest.mark.parametrize( @@ -520,6 +535,15 @@ async def test_score_task( HomeAssistantError, REQUEST_EXCEPTION_MSG, ), + ( + { + ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", + ATTR_DIRECTION: "up", + }, + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), ( { ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", @@ -722,7 +746,7 @@ async def test_transformation( ERROR_BAD_REQUEST, None, HomeAssistantError, - "Unable to connect to Habitica, try again later", + REQUEST_EXCEPTION_MSG, ), ( { @@ -752,7 +776,27 @@ async def test_transformation( None, ERROR_BAD_REQUEST, HomeAssistantError, - "Unable to connect to Habitica, try again later", + REQUEST_EXCEPTION_MSG, + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + None, + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + ClientError, + None, + HomeAssistantError, + "Unable to connect to Habitica: ", ), ], ) diff --git a/tests/components/habitica/test_switch.py b/tests/components/habitica/test_switch.py index c259f53f183..1799788a48e 100644 --- a/tests/components/habitica/test_switch.py +++ b/tests/components/habitica/test_switch.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from aiohttp import ClientError import pytest from syrupy.assertion import SnapshotAssertion @@ -96,6 +97,7 @@ async def test_turn_on_off_toggle( [ (ERROR_TOO_MANY_REQUESTS, HomeAssistantError), (ERROR_BAD_REQUEST, HomeAssistantError), + (ClientError, HomeAssistantError), ], ) async def test_turn_on_off_toggle_exceptions( diff --git a/tests/components/habitica/test_todo.py b/tests/components/habitica/test_todo.py index 6453510a97f..8f20b3e685a 100644 --- a/tests/components/habitica/test_todo.py +++ b/tests/components/habitica/test_todo.py @@ -23,10 +23,10 @@ from homeassistant.components.todo import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er -from .conftest import ERROR_NOT_FOUND +from .conftest import ERROR_NOT_FOUND, ERROR_TOO_MANY_REQUESTS from tests.common import ( MockConfigEntry, @@ -183,12 +183,30 @@ async def test_uncomplete_todo_item( ], ids=["completed", "needs_action"], ) +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + r"Unable to update the score for your Habitica to-do `.+`, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_complete_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, uid: str, status: str, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when completing/uncompleting an item on the todo list.""" @@ -198,10 +216,10 @@ async def test_complete_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - habitica.update_score.side_effect = ERROR_NOT_FOUND + habitica.update_score.side_effect = exception with pytest.raises( - expected_exception=ServiceValidationError, - match=r"Unable to update the score for your Habitica to-do `.+`, please try again", + expected_exception=expected_exception, + match=exc_msg, ): await hass.services.async_call( TODO_DOMAIN, @@ -311,10 +329,28 @@ async def test_update_todo_item( habitica.update_task.assert_awaited_once_with(*call_args) +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + "Unable to update the Habitica to-do `test-summary`, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_update_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when update item on the todo list.""" uid = "88de7cd9-af2b-49ce-9afd-bf941d87336b" @@ -324,11 +360,8 @@ async def test_update_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - habitica.update_task.side_effect = ERROR_NOT_FOUND - with pytest.raises( - expected_exception=ServiceValidationError, - match="Unable to update the Habitica to-do `test-summary`, please try again", - ): + habitica.update_task.side_effect = exception + with pytest.raises(expected_exception=expected_exception, match=exc_msg): await hass.services.async_call( TODO_DOMAIN, TodoServices.UPDATE_ITEM, @@ -378,10 +411,28 @@ async def test_add_todo_item( ) +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + "Unable to create new to-do `test-summary` for Habitica, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_add_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when adding a todo item to the todo list.""" @@ -391,10 +442,11 @@ async def test_add_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - habitica.create_task.side_effect = ERROR_NOT_FOUND + habitica.create_task.side_effect = exception with pytest.raises( - expected_exception=ServiceValidationError, - match="Unable to create new to-do `test-summary` for Habitica, please try again", + expected_exception=expected_exception, + # match="Unable to create new to-do `test-summary` for Habitica, please try again", + match=exc_msg, ): await hass.services.async_call( TODO_DOMAIN, @@ -434,10 +486,28 @@ async def test_delete_todo_item( habitica.delete_task.assert_awaited_once_with(UUID(uid)) +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + "Unable to delete item from Habitica to-do list, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_delete_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when deleting a todo item from the todo list.""" @@ -448,11 +518,11 @@ async def test_delete_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - habitica.delete_task.side_effect = ERROR_NOT_FOUND + habitica.delete_task.side_effect = exception with pytest.raises( - expected_exception=ServiceValidationError, - match="Unable to delete item from Habitica to-do list, please try again", + expected_exception=expected_exception, + match=exc_msg, ): await hass.services.async_call( TODO_DOMAIN, @@ -486,10 +556,28 @@ async def test_delete_completed_todo_items( habitica.delete_completed_todos.assert_awaited_once() +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + "Unable to delete completed to-do items from Habitica to-do list, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_delete_completed_todo_items_exception( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when deleting completed todo items from the todo list.""" config_entry.add_to_hass(hass) @@ -498,10 +586,10 @@ async def test_delete_completed_todo_items_exception( assert config_entry.state is ConfigEntryState.LOADED - habitica.delete_completed_todos.side_effect = ERROR_NOT_FOUND + habitica.delete_completed_todos.side_effect = exception with pytest.raises( - expected_exception=ServiceValidationError, - match="Unable to delete completed to-do items from Habitica to-do list, please try again", + expected_exception=expected_exception, + match=exc_msg, ): await hass.services.async_call( TODO_DOMAIN, @@ -575,11 +663,26 @@ async def test_move_todo_item( habitica.reorder_task.assert_awaited_once_with(UUID(uid), 0) +@pytest.mark.parametrize( + ("exception", "exc_msg"), + [ + ( + ERROR_NOT_FOUND, + "Unable to move the Habitica to-do to position 0, please try again", + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + ), + ], +) async def test_move_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, hass_ws_client: WebSocketGenerator, + exception: Exception, + exc_msg: str, ) -> None: """Test exception when moving todo item.""" @@ -590,7 +693,7 @@ async def test_move_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - habitica.reorder_task.side_effect = ERROR_NOT_FOUND + habitica.reorder_task.side_effect = exception client = await hass_ws_client() data = { @@ -605,10 +708,7 @@ async def test_move_todo_item_exception( habitica.reorder_task.assert_awaited_once_with(UUID(uid), 0) assert resp["success"] is False - assert ( - resp["error"]["message"] - == "Unable to move the Habitica to-do to position 0, please try again" - ) + assert resp["error"]["message"] == exc_msg @pytest.mark.parametrize( From 4612f4da193486c44259c6de05208c54488a80f6 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Sun, 19 Jan 2025 20:07:32 +0100 Subject: [PATCH 0652/2987] Fix velbus via devices (#135986) --- homeassistant/components/velbus/__init__.py | 15 +++++++++++++++ tests/components/velbus/conftest.py | 2 +- .../velbus/snapshots/test_diagnostics.ambr | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index ad1c35a124b..41b8730eeb0 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -58,6 +58,21 @@ async def velbus_scan_task( raise PlatformNotReady( f"Connection error while connecting to Velbus {entry_id}: {ex}" ) from ex + # create all modules + dev_reg = dr.async_get(hass) + for module in controller.get_modules().values(): + dev_reg.async_get_or_create( + config_entry_id=entry_id, + identifiers={ + (DOMAIN, str(module.get_addresses()[0])), + }, + manufacturer="Velleman", + model=module.get_type_name(), + model_id=str(module.get_type()), + name=f"{module.get_name()} ({module.get_type_name()})", + sw_version=module.get_sw_version(), + serial_number=module.get_serial(), + ) def _migrate_device_identifiers(hass: HomeAssistant, entry_id: str) -> None: diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index 5094b35d0aa..65418790280 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -96,7 +96,7 @@ def mock_module_subdevices() -> AsyncMock: """Mock a velbus module.""" module = AsyncMock(spec=Module) module.get_type_name.return_value = "VMB2BLE" - module.get_addresses.return_value = [99] + module.get_addresses.return_value = [88] module.get_name.return_value = "Kitchen" module.get_sw_version.return_value = "2.0.0" module.is_loaded.return_value = True diff --git a/tests/components/velbus/snapshots/test_diagnostics.ambr b/tests/components/velbus/snapshots/test_diagnostics.ambr index 3359cb78590..406a5f2d84e 100644 --- a/tests/components/velbus/snapshots/test_diagnostics.ambr +++ b/tests/components/velbus/snapshots/test_diagnostics.ambr @@ -79,7 +79,7 @@ }), dict({ 'address': list([ - 99, + 88, ]), 'channels': dict({ }), From 568a27000d6b17fafdb2f68f826a1523862ea19a Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 19 Jan 2025 20:09:05 +0100 Subject: [PATCH 0653/2987] Correct type for off delay in rfxtrx (#135994) --- homeassistant/components/rfxtrx/config_flow.py | 9 +++------ homeassistant/components/rfxtrx/strings.json | 1 - tests/components/rfxtrx/test_config_flow.py | 4 +--- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 866d9ecb1bb..6ce7d88f9f0 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -209,10 +209,7 @@ class RfxtrxOptionsFlow(OptionsFlow): except ValueError: errors[CONF_COMMAND_OFF] = "invalid_input_2262_off" - try: - off_delay = none_or_int(user_input.get(CONF_OFF_DELAY), 10) - except ValueError: - errors[CONF_OFF_DELAY] = "invalid_input_off_delay" + off_delay = user_input.get(CONF_OFF_DELAY) if not errors: devices = {} @@ -252,11 +249,11 @@ class RfxtrxOptionsFlow(OptionsFlow): vol.Optional( CONF_OFF_DELAY, description={"suggested_value": device_data[CONF_OFF_DELAY]}, - ): str, + ): int, } else: off_delay_schema = { - vol.Optional(CONF_OFF_DELAY): str, + vol.Optional(CONF_OFF_DELAY): int, } data_schema.update(off_delay_schema) diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index aeb4b2395d3..735ed6c4542 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -68,7 +68,6 @@ "invalid_event_code": "Invalid event code", "invalid_input_2262_on": "Invalid input for command on", "invalid_input_2262_off": "Invalid input for command off", - "invalid_input_off_delay": "Invalid input for off delay", "unknown": "[%key:common::config_flow::error::unknown%]" } }, diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 1e23bdaf982..5957319306b 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -726,7 +726,6 @@ async def test_options_add_and_configure_device( result["flow_id"], user_input={ "data_bits": 4, - "off_delay": "abcdef", "command_on": "xyz", "command_off": "xyz", }, @@ -735,7 +734,6 @@ async def test_options_add_and_configure_device( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "set_device_options" assert result["errors"] - assert result["errors"]["off_delay"] == "invalid_input_off_delay" assert result["errors"]["command_on"] == "invalid_input_2262_on" assert result["errors"]["command_off"] == "invalid_input_2262_off" @@ -745,7 +743,7 @@ async def test_options_add_and_configure_device( "data_bits": 4, "command_on": "0xE", "command_off": "0x7", - "off_delay": "9", + "off_delay": 9, }, ) From 3a078d5414613b7da30c40d22f48083641742fed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jan 2025 09:16:40 -1000 Subject: [PATCH 0654/2987] Handle invalid datetime in onvif (#136014) --- homeassistant/components/onvif/device.py | 26 +++++++++++++++--------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index f51b1b74686..f15f6637ab9 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -263,16 +263,22 @@ class ONVIFDevice: LOGGER.warning("%s: Could not retrieve date/time on this camera", self.name) return - cam_date = dt.datetime( - cdate.Date.Year, - cdate.Date.Month, - cdate.Date.Day, - cdate.Time.Hour, - cdate.Time.Minute, - cdate.Time.Second, - 0, - tzone, - ) + try: + cam_date = dt.datetime( + cdate.Date.Year, + cdate.Date.Month, + cdate.Date.Day, + cdate.Time.Hour, + cdate.Time.Minute, + cdate.Time.Second, + 0, + tzone, + ) + except ValueError as err: + LOGGER.warning( + "%s: Could not parse date/time from camera: %s", self.name, err + ) + return cam_date_utc = cam_date.astimezone(dt_util.UTC) From 57294fa4613c4b5e8b609cd7af7e0d56fbe70134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 19 Jan 2025 20:24:48 +0100 Subject: [PATCH 0655/2987] Do not base power switch state on appliance's operation state at Home Connect (#135932) --- .../components/home_connect/switch.py | 18 ------------- tests/components/home_connect/test_switch.py | 27 ------------------- 2 files changed, 45 deletions(-) diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index e1ea4c2b4ce..1bd02e03eb1 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -27,7 +27,6 @@ from .const import ( ATTR_VALUE, BSH_ACTIVE_PROGRAM, BSH_CHILD_LOCK_STATE, - BSH_OPERATION_STATE, BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, @@ -403,23 +402,6 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): == self.power_off_state ): self._attr_is_on = False - elif self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get( - ATTR_VALUE, None - ) in [ - "BSH.Common.EnumType.OperationState.Ready", - "BSH.Common.EnumType.OperationState.DelayedStart", - "BSH.Common.EnumType.OperationState.Run", - "BSH.Common.EnumType.OperationState.Pause", - "BSH.Common.EnumType.OperationState.ActionRequired", - "BSH.Common.EnumType.OperationState.Aborting", - "BSH.Common.EnumType.OperationState.Finished", - ]: - self._attr_is_on = True - elif ( - self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get(ATTR_VALUE) - == "BSH.Common.EnumType.OperationState.Inactive" - ): - self._attr_is_on = False else: self._attr_is_on = None _LOGGER.debug("Updated, new state: %s", self._attr_is_on) diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index a02cb553ece..9d54feeaa54 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -13,7 +13,6 @@ from homeassistant.components.home_connect.const import ( ATTR_CONSTRAINTS, BSH_ACTIVE_PROGRAM, BSH_CHILD_LOCK_STATE, - BSH_OPERATION_STATE, BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, @@ -376,32 +375,6 @@ async def test_ent_desc_switch_exception_handling( STATE_OFF, "Dishwasher", ), - ( - "switch.dishwasher_power", - { - BSH_POWER_STATE: {"value": ""}, - BSH_OPERATION_STATE: { - "value": "BSH.Common.EnumType.OperationState.Run" - }, - }, - [BSH_POWER_ON], - SERVICE_TURN_ON, - STATE_ON, - "Dishwasher", - ), - ( - "switch.dishwasher_power", - { - BSH_POWER_STATE: {"value": ""}, - BSH_OPERATION_STATE: { - "value": "BSH.Common.EnumType.OperationState.Inactive" - }, - }, - [BSH_POWER_ON], - SERVICE_TURN_ON, - STATE_OFF, - "Dishwasher", - ), ( "switch.dishwasher_power", {BSH_POWER_STATE: {"value": BSH_POWER_ON}}, From 8777dd906552bff11fce690cd26402d84aba06b1 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sun, 19 Jan 2025 14:31:30 -0500 Subject: [PATCH 0656/2987] Bump pydrawise to 2025.1.0 (#135998) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 50f803c07dc..de45eb061d5 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2024.12.0"] + "requirements": ["pydrawise==2025.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cf65cb47254..5d842f5842e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1890,7 +1890,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2024.12.0 +pydrawise==2025.1.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd864b11869..869f13bdacc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1540,7 +1540,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2024.12.0 +pydrawise==2025.1.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 From 0c68854fdfeed9facd8caab2fb9002b5493cf881 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 19 Jan 2025 14:32:59 -0500 Subject: [PATCH 0657/2987] Migrate tests from OpenAI to conversation integration (#135963) --- .../components/conversation/session.py | 6 +- .../openai_conversation/conversation.py | 4 +- .../conversation/snapshots/test_session.ambr | 41 +++ tests/components/conversation/test_session.py | 244 +++++++++++++++++ .../openai_conversation/test_conversation.py | 249 +----------------- 5 files changed, 292 insertions(+), 252 deletions(-) create mode 100644 tests/components/conversation/snapshots/test_session.ambr diff --git a/homeassistant/components/conversation/session.py b/homeassistant/components/conversation/session.py index f9db80afa63..ba9e0ad6292 100644 --- a/homeassistant/components/conversation/session.py +++ b/homeassistant/components/conversation/session.py @@ -155,7 +155,7 @@ class ConverseError(HomeAssistantError): self.conversation_id = conversation_id self.response = response - def as_converstation_result(self) -> ConversationResult: + def as_conversation_result(self) -> ConversationResult: """Return the error as a conversation result.""" return ConversationResult( response=self.response, @@ -220,14 +220,14 @@ class ChatSession(Generic[_NativeT]): if message.role != "native" or message.agent_id == agent_id ] - async def async_process_llm_message( + async def async_update_llm_data( self, conversing_domain: str, user_input: ConversationInput, user_llm_hass_api: str | None = None, user_llm_prompt: str | None = None, ) -> None: - """Process an incoming message for an LLM.""" + """Set the LLM system prompt.""" llm_context = llm.LLMContext( platform=conversing_domain, context=user_input.context, diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 9a6b61e4c43..c89574bf3bd 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -157,14 +157,14 @@ class OpenAIConversationEntity( options = self.entry.options try: - await session.async_process_llm_message( + await session.async_update_llm_data( DOMAIN, user_input, options.get(CONF_LLM_HASS_API), options.get(CONF_PROMPT), ) except conversation.ConverseError as err: - return err.as_converstation_result() + return err.as_conversation_result() tools: list[ChatCompletionToolParam] | None = None if session.llm_api: diff --git a/tests/components/conversation/snapshots/test_session.ambr b/tests/components/conversation/snapshots/test_session.ambr new file mode 100644 index 00000000000..4e94157c601 --- /dev/null +++ b/tests/components/conversation/snapshots/test_session.ambr @@ -0,0 +1,41 @@ +# serializer version: 1 +# name: test_template_error + dict({ + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'unknown', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Sorry, I had a problem with my template', + }), + }), + }), + }) +# --- +# name: test_unknown_llm_api + dict({ + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'unknown', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Error preparing LLM API', + }), + }), + }), + }) +# --- diff --git a/tests/components/conversation/test_session.py b/tests/components/conversation/test_session.py index 45cb517528d..feb6ca2a9e8 100644 --- a/tests/components/conversation/test_session.py +++ b/tests/components/conversation/test_session.py @@ -5,9 +5,11 @@ from datetime import timedelta from unittest.mock import Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.conversation import ConversationInput, session from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -94,10 +96,27 @@ async def test_cleanup( assert len(chat_session.messages) == 4 assert chat_session.conversation_id == conversation_id + # Set the last updated to be older than the timeout + hass.data[session.DATA_CHAT_HISTORY][conversation_id].last_updated = ( + dt_util.utcnow() + session.CONVERSATION_TIMEOUT + ) + async_fire_time_changed( hass, dt_util.utcnow() + session.CONVERSATION_TIMEOUT + timedelta(seconds=1) ) + # Should not be cleaned up, but it should have scheduled another cleanup + mock_conversation_input.conversation_id = conversation_id + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + assert len(chat_session.messages) == 4 + assert chat_session.conversation_id == conversation_id + + async_fire_time_changed( + hass, dt_util.utcnow() + session.CONVERSATION_TIMEOUT * 2 + timedelta(seconds=1) + ) + # It should be cleaned up now and we start a new conversation async with session.async_get_chat_session( hass, mock_conversation_input @@ -106,6 +125,47 @@ async def test_cleanup( assert len(chat_session.messages) == 2 +def test_chat_message() -> None: + """Test chat message.""" + with pytest.raises(ValueError): + session.ChatMessage(role="native", agent_id=None, content="", native=None) + + +async def test_add_message( + hass: HomeAssistant, mock_conversation_input: ConversationInput +) -> None: + """Test filtering of messages.""" + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + assert len(chat_session.messages) == 2 + + with pytest.raises(ValueError): + chat_session.async_add_message( + session.ChatMessage(role="system", agent_id=None, content="") + ) + + # No 2 user messages in a row + assert chat_session.messages[1].role == "user" + + with pytest.raises(ValueError): + chat_session.async_add_message( + session.ChatMessage(role="user", agent_id=None, content="") + ) + + # No 2 assistant messages in a row + chat_session.async_add_message( + session.ChatMessage(role="assistant", agent_id=None, content="") + ) + assert len(chat_session.messages) == 3 + assert chat_session.messages[-1].role == "assistant" + + with pytest.raises(ValueError): + chat_session.async_add_message( + session.ChatMessage(role="assistant", agent_id=None, content="") + ) + + async def test_message_filtering( hass: HomeAssistant, mock_conversation_input: ConversationInput ) -> None: @@ -169,3 +229,187 @@ async def test_message_filtering( assert messages[3] == session.ChatMessage( role="native", agent_id="mock-agent-id", content="", native=1 ) + + +async def test_llm_api( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test when we reference an LLM API.""" + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + + assert isinstance(chat_session.llm_api, llm.APIInstance) + assert chat_session.llm_api.api.id == "assist" + + +async def test_unknown_llm_api( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, + snapshot: SnapshotAssertion, +) -> None: + """Test when we reference an LLM API that does not exists.""" + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + with pytest.raises(session.ConverseError) as exc_info: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="unknown-api", + user_llm_prompt=None, + ) + + assert str(exc_info.value) == "Error getting LLM API unknown-api" + assert exc_info.value.as_conversation_result().as_dict() == snapshot + + +async def test_template_error( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, + snapshot: SnapshotAssertion, +) -> None: + """Test that template error handling works.""" + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + with pytest.raises(session.ConverseError) as exc_info: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt="{{ invalid_syntax", + ) + + assert str(exc_info.value) == "Error rendering prompt" + assert exc_info.value.as_conversation_result().as_dict() == snapshot + + +async def test_template_variables( + hass: HomeAssistant, mock_conversation_input: ConversationInput +) -> None: + """Test that template variables work.""" + mock_user = Mock() + mock_user.id = "12345" + mock_user.name = "Test User" + mock_conversation_input.context = Context(user_id=mock_user.id) + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + with patch( + "homeassistant.auth.AuthManager.async_get_user", return_value=mock_user + ): + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=( + "The instance name is {{ ha_name }}. " + "The user name is {{ user_name }}. " + "The user id is {{ llm_context.context.user_id }}." + "The calling platform is {{ llm_context.platform }}." + ), + ) + + assert chat_session.user_name == "Test User" + + assert "The instance name is test home." in chat_session.messages[0].content + assert "The user name is Test User." in chat_session.messages[0].content + assert "The user id is 12345." in chat_session.messages[0].content + assert "The calling platform is test." in chat_session.messages[0].content + + +async def test_extra_systen_prompt( + hass: HomeAssistant, mock_conversation_input: ConversationInput +) -> None: + """Test that extra system prompt works.""" + extra_system_prompt = "Garage door cover.garage_door has been left open for 30 minutes. We asked the user if they want to close it." + extra_system_prompt2 = ( + "User person.paulus came home. Asked him what he wants to do." + ) + mock_conversation_input.extra_system_prompt = extra_system_prompt + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + chat_session.async_add_message( + session.ChatMessage( + role="assistant", + agent_id="mock-agent-id", + content="Hey!", + ) + ) + + assert chat_session.extra_system_prompt == extra_system_prompt + assert chat_session.messages[0].content.endswith(extra_system_prompt) + + # Verify that follow-up conversations with no system prompt take previous one + mock_conversation_input.conversation_id = chat_session.conversation_id + mock_conversation_input.extra_system_prompt = None + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + + assert chat_session.extra_system_prompt == extra_system_prompt + assert chat_session.messages[0].content.endswith(extra_system_prompt) + + # Verify that we take new system prompts + mock_conversation_input.extra_system_prompt = extra_system_prompt2 + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + chat_session.async_add_message( + session.ChatMessage( + role="assistant", + agent_id="mock-agent-id", + content="Hey!", + ) + ) + + assert chat_session.extra_system_prompt == extra_system_prompt2 + assert chat_session.messages[0].content.endswith(extra_system_prompt2) + assert extra_system_prompt not in chat_session.messages[0].content + + # Verify that follow-up conversations with no system prompt take previous one + mock_conversation_input.extra_system_prompt = None + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + + assert chat_session.extra_system_prompt == extra_system_prompt2 + assert chat_session.messages[0].content.endswith(extra_system_prompt2) diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index b89ddcd8921..9ee19cd330c 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -1,6 +1,6 @@ """Tests for the OpenAI integration.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, patch from freezegun import freeze_time from httpx import Response @@ -12,7 +12,6 @@ from openai.types.chat.chat_completion_message_tool_call import ( Function, ) from openai.types.completion_usage import CompletionUsage -from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components import conversation @@ -22,7 +21,6 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent, llm from homeassistant.setup import async_setup_component -from homeassistant.util import ulid from tests.common import MockConfigEntry @@ -57,7 +55,7 @@ async def test_entity( async def test_error_handling( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component ) -> None: - """Test that the default prompt works.""" + """Test that we handle errors when calling completion API.""" with patch( "openai.resources.chat.completions.AsyncCompletions.create", new_callable=AsyncMock, @@ -73,183 +71,6 @@ async def test_error_handling( assert result.response.error_code == "unknown", result -async def test_template_error( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test that template error handling works.""" - hass.config_entries.async_update_entry( - mock_config_entry, - options={ - "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", - }, - ) - with ( - patch( - "openai.resources.models.AsyncModels.list", - ), - patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - ), - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id - ) - - assert result.response.response_type == intent.IntentResponseType.ERROR, result - assert result.response.error_code == "unknown", result - - -async def test_template_variables( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test that template variables work.""" - context = Context(user_id="12345") - mock_user = Mock() - mock_user.id = "12345" - mock_user.name = "Test User" - - hass.config_entries.async_update_entry( - mock_config_entry, - options={ - "prompt": ( - "The user name is {{ user_name }}. " - "The user id is {{ llm_context.context.user_id }}." - ), - }, - ) - with ( - patch( - "openai.resources.models.AsyncModels.list", - ), - patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - ) as mock_create, - patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - result = await conversation.async_converse( - hass, "hello", None, context, agent_id=mock_config_entry.entry_id - ) - - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( - result - ) - assert ( - "The user name is Test User." - in mock_create.mock_calls[0][2]["messages"][0]["content"] - ) - assert ( - "The user id is 12345." - in mock_create.mock_calls[0][2]["messages"][0]["content"] - ) - - -async def test_extra_systen_prompt( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test that template variables work.""" - extra_system_prompt = "Garage door cover.garage_door has been left open for 30 minutes. We asked the user if they want to close it." - extra_system_prompt2 = ( - "User person.paulus came home. Asked him what he wants to do." - ) - - with ( - patch( - "openai.resources.models.AsyncModels.list", - ), - patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - ) as mock_create, - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - result = await conversation.async_converse( - hass, - "hello", - None, - Context(), - agent_id=mock_config_entry.entry_id, - extra_system_prompt=extra_system_prompt, - ) - - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( - result - ) - assert mock_create.mock_calls[0][2]["messages"][0]["content"].endswith( - extra_system_prompt - ) - - conversation_id = result.conversation_id - - # Verify that follow-up conversations with no system prompt take previous one - with patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - ) as mock_create: - result = await conversation.async_converse( - hass, - "hello", - conversation_id, - Context(), - agent_id=mock_config_entry.entry_id, - extra_system_prompt=None, - ) - - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( - result - ) - assert mock_create.mock_calls[0][2]["messages"][0]["content"].endswith( - extra_system_prompt - ) - - # Verify that we take new system prompts - with patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - ) as mock_create: - result = await conversation.async_converse( - hass, - "hello", - conversation_id, - Context(), - agent_id=mock_config_entry.entry_id, - extra_system_prompt=extra_system_prompt2, - ) - - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( - result - ) - assert mock_create.mock_calls[0][2]["messages"][0]["content"].endswith( - extra_system_prompt2 - ) - - # Verify that follow-up conversations with no system prompt take previous one - with patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - ) as mock_create: - result = await conversation.async_converse( - hass, - "hello", - conversation_id, - Context(), - agent_id=mock_config_entry.entry_id, - ) - - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( - result - ) - assert mock_create.mock_calls[0][2]["messages"][0]["content"].endswith( - extra_system_prompt2 - ) - - async def test_conversation_agent( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -605,69 +426,3 @@ async def test_assist_api_tools_conversion( tools = mock_create.mock_calls[0][2]["tools"] assert tools - - -async def test_unknown_hass_api( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, - mock_init_component, -) -> None: - """Test when we reference an API that no longer exists.""" - hass.config_entries.async_update_entry( - mock_config_entry, - options={ - **mock_config_entry.options, - CONF_LLM_HASS_API: "non-existing", - }, - ) - - await hass.async_block_till_done() - - result = await conversation.async_converse( - hass, - "hello", - "my-conversation-id", - Context(), - agent_id=mock_config_entry.entry_id, - ) - - assert result == snapshot - - -@patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, -) -async def test_conversation_id( - mock_create, - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_init_component, -) -> None: - """Test conversation ID is honored.""" - result = await conversation.async_converse( - hass, "hello", None, None, agent_id=mock_config_entry.entry_id - ) - - conversation_id = result.conversation_id - - result = await conversation.async_converse( - hass, "hello", conversation_id, None, agent_id=mock_config_entry.entry_id - ) - - assert result.conversation_id == conversation_id - - unknown_id = ulid.ulid() - - result = await conversation.async_converse( - hass, "hello", unknown_id, None, agent_id=mock_config_entry.entry_id - ) - - assert result.conversation_id != unknown_id - - result = await conversation.async_converse( - hass, "hello", "koala", None, agent_id=mock_config_entry.entry_id - ) - - assert result.conversation_id == "koala" From 5329356f2093f1289da9e85b08bade8a56868819 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 19 Jan 2025 20:35:32 +0100 Subject: [PATCH 0658/2987] Update numpy to 2.2.2 (#135982) --- homeassistant/components/compensation/manifest.json | 2 +- homeassistant/components/iqvia/manifest.json | 2 +- homeassistant/components/stream/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/components/trend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index a7e714a80b8..cdf4dd1aaa4 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", "quality_scale": "legacy", - "requirements": ["numpy==2.2.1"] + "requirements": ["numpy==2.2.2"] } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 4a308d02b3d..a738036b3ee 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyiqvia"], - "requirements": ["numpy==2.2.1", "pyiqvia==2022.04.0"] + "requirements": ["numpy==2.2.2", "pyiqvia==2022.04.0"] } diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index e0321e306e3..a2fa18c4d98 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.2.1"] + "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.2.2"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 1cd856f31d0..81705e326f7 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,7 +10,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.6", - "numpy==2.2.1", + "numpy==2.2.2", "Pillow==11.1.0" ] } diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 69e8daa3ce7..16c7067c7ce 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -7,5 +7,5 @@ "integration_type": "helper", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==2.2.1"] + "requirements": ["numpy==2.2.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6aa729a89e8..909531c681d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -117,7 +117,7 @@ httpcore==1.0.5 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.2.1 +numpy==2.2.2 pandas~=2.2.3 # Constrain multidict to avoid typing issues diff --git a/requirements_all.txt b/requirements_all.txt index 5d842f5842e..d734dd579cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1519,7 +1519,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.2.1 +numpy==2.2.2 # homeassistant.components.nyt_games nyt_games==0.4.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 869f13bdacc..27a53bbd37b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1273,7 +1273,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.2.1 +numpy==2.2.2 # homeassistant.components.nyt_games nyt_games==0.4.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 48944f61592..e2b60e777a2 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -148,7 +148,7 @@ httpcore==1.0.5 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.2.1 +numpy==2.2.2 pandas~=2.2.3 # Constrain multidict to avoid typing issues From 2bedb2cadbfd590c9c94b5a93793c83ce286d55e Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 19 Jan 2025 20:43:47 +0100 Subject: [PATCH 0659/2987] Correct translation key for data bits in rfxtrx (#135990) --- homeassistant/components/rfxtrx/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index 735ed6c4542..db4efad5bb4 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -54,7 +54,7 @@ "data": { "off_delay": "Off delay", "off_delay_enabled": "Enable off delay", - "data_bit": "Number of data bits", + "data_bits": "Number of data bits", "command_on": "Data bits value for command on", "command_off": "Data bits value for command off", "venetian_blind_mode": "Venetian blind mode", From 2092456c7e5012e9baea647edcb0df0c31f45672 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Sun, 19 Jan 2025 21:03:30 +0100 Subject: [PATCH 0660/2987] Bumb python-homewizard-energy to 8.1.0 (#136016) --- homeassistant/components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index c65ba1d5357..fc060961d10 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==v8.0.0"], + "requirements": ["python-homewizard-energy==v8.1.0"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index d734dd579cd..724d47c5626 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2381,7 +2381,7 @@ python-gitlab==1.6.0 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.0.0 +python-homewizard-energy==v8.1.0 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 27a53bbd37b..201f431786f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1926,7 +1926,7 @@ python-fullykiosk==0.0.14 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.0.0 +python-homewizard-energy==v8.1.0 # homeassistant.components.izone python-izone==1.2.9 From 2900baac04589fd81bb3222b8ae04d72add9a519 Mon Sep 17 00:00:00 2001 From: Scott K Logan Date: Sun, 19 Jan 2025 14:05:34 -0600 Subject: [PATCH 0661/2987] Bump aioraven to 0.7.1 (#136017) --- homeassistant/components/rainforest_raven/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rainforest_raven/manifest.json b/homeassistant/components/rainforest_raven/manifest.json index 49bd11e8880..3a902377c2e 100644 --- a/homeassistant/components/rainforest_raven/manifest.json +++ b/homeassistant/components/rainforest_raven/manifest.json @@ -6,7 +6,7 @@ "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/rainforest_raven", "iot_class": "local_polling", - "requirements": ["aioraven==0.7.0"], + "requirements": ["aioraven==0.7.1"], "usb": [ { "vid": "0403", diff --git a/requirements_all.txt b/requirements_all.txt index 724d47c5626..cb2a03b0fbf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -347,7 +347,7 @@ aiopyarr==23.4.0 aioqsw==0.4.1 # homeassistant.components.rainforest_raven -aioraven==0.7.0 +aioraven==0.7.1 # homeassistant.components.recollect_waste aiorecollect==2023.09.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 201f431786f..047d7db7821 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -329,7 +329,7 @@ aiopyarr==23.4.0 aioqsw==0.4.1 # homeassistant.components.rainforest_raven -aioraven==0.7.0 +aioraven==0.7.1 # homeassistant.components.recollect_waste aiorecollect==2023.09.0 From a69786f64f11336fa835329d37c88ae2115fb277 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 19 Jan 2025 21:07:05 +0100 Subject: [PATCH 0662/2987] Set friendly name for PT2262 sensors to masked name (#135988) --- homeassistant/components/rfxtrx/entity.py | 4 ++-- tests/components/rfxtrx/test_binary_sensor.py | 16 ++++++------- tests/components/rfxtrx/test_config_flow.py | 4 ++-- tests/components/rfxtrx/test_sensor.py | 8 +++---- tests/components/rfxtrx/test_switch.py | 24 +++++++++---------- 5 files changed, 28 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/rfxtrx/entity.py b/homeassistant/components/rfxtrx/entity.py index b5752e366bc..f0cc193023c 100644 --- a/homeassistant/components/rfxtrx/entity.py +++ b/homeassistant/components/rfxtrx/entity.py @@ -46,7 +46,7 @@ class RfxtrxEntity(RestoreEntity): self._attr_device_info = DeviceInfo( identifiers=_get_identifiers_from_device_tuple(device_id), model=device.type_string, - name=f"{device.type_string} {device.id_string}", + name=f"{device.type_string} {device_id.id_string}", ) self._attr_unique_id = "_".join(x for x in device_id) self._device = device @@ -54,7 +54,7 @@ class RfxtrxEntity(RestoreEntity): self._device_id = device_id # If id_string is 213c7f2:1, the group_id is 213c7f2, and the device will respond to # group events regardless of their group indices. - (self._group_id, _, _) = cast(str, device.id_string).partition(":") + (self._group_id, _, _) = device_id.id_string.partition(":") async def async_added_to_hass(self) -> None: """Restore RFXtrx device state (ON/OFF).""" diff --git a/tests/components/rfxtrx/test_binary_sensor.py b/tests/components/rfxtrx/test_binary_sensor.py index 8f212b6e976..79736d418b5 100644 --- a/tests/components/rfxtrx/test_binary_sensor.py +++ b/tests/components/rfxtrx/test_binary_sensor.py @@ -58,17 +58,17 @@ async def test_one_pt2262(hass: HomeAssistant, rfxtrx) -> None: await hass.async_block_till_done() await hass.async_start() - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" await rfxtrx.signal("0913000022670e013970") - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state.state == "on" await rfxtrx.signal("09130000226707013d70") - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state.state == "off" @@ -85,10 +85,10 @@ async def test_pt2262_unconfigured(hass: HomeAssistant, rfxtrx) -> None: await hass.async_block_till_done() await hass.async_start() - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226707") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226707" state = hass.states.get("binary_sensor.pt2262_226707") assert state @@ -318,7 +318,7 @@ async def test_pt2262_duplicate_id(hass: HomeAssistant, rfxtrx) -> None: await hass.async_block_till_done() await hass.async_start() - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 5957319306b..6fd4fc14bc5 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -756,10 +756,10 @@ async def test_options_add_and_configure_device( assert entry.data["devices"]["0913000022670e013970"] assert entry.data["devices"]["0913000022670e013970"]["off_delay"] == 9 - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) diff --git a/tests/components/rfxtrx/test_sensor.py b/tests/components/rfxtrx/test_sensor.py index 4336798768f..f17fd8743f1 100644 --- a/tests/components/rfxtrx/test_sensor.py +++ b/tests/components/rfxtrx/test_sensor.py @@ -330,10 +330,10 @@ async def test_rssi_sensor(hass: HomeAssistant, rfxtrx) -> None: await hass.async_block_till_done() await hass.async_start() - state = hass.states.get("sensor.pt2262_22670e_signal_strength") + state = hass.states.get("sensor.pt2262_226700_signal_strength") assert state assert state.state == "unknown" - assert state.attributes.get("friendly_name") == "PT2262 22670e Signal strength" + assert state.attributes.get("friendly_name") == "PT2262 226700 Signal strength" assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SIGNAL_STRENGTH_DECIBELS_MILLIWATT @@ -351,7 +351,7 @@ async def test_rssi_sensor(hass: HomeAssistant, rfxtrx) -> None: await rfxtrx.signal("0913000022670e013b70") await rfxtrx.signal("0b1100cd0213c7f230010f71") - state = hass.states.get("sensor.pt2262_22670e_signal_strength") + state = hass.states.get("sensor.pt2262_226700_signal_strength") assert state assert state.state == "-64" @@ -362,7 +362,7 @@ async def test_rssi_sensor(hass: HomeAssistant, rfxtrx) -> None: await rfxtrx.signal("0913000022670e013b60") await rfxtrx.signal("0b1100cd0213c7f230010f61") - state = hass.states.get("sensor.pt2262_22670e_signal_strength") + state = hass.states.get("sensor.pt2262_226700_signal_strength") assert state assert state.state == "-72" diff --git a/tests/components/rfxtrx/test_switch.py b/tests/components/rfxtrx/test_switch.py index 7acc008cc8a..964c5ccb2e6 100644 --- a/tests/components/rfxtrx/test_switch.py +++ b/tests/components/rfxtrx/test_switch.py @@ -70,23 +70,23 @@ async def test_one_pt2262_switch(hass: HomeAssistant, rfxtrx) -> None: await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("switch.pt2262_22670e") + state = hass.states.get("switch.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" await hass.services.async_call( - "switch", "turn_on", {"entity_id": "switch.pt2262_22670e"}, blocking=True + "switch", "turn_on", {"entity_id": "switch.pt2262_226700"}, blocking=True ) - state = hass.states.get("switch.pt2262_22670e") + state = hass.states.get("switch.pt2262_226700") assert state.state == "on" await hass.services.async_call( - "switch", "turn_off", {"entity_id": "switch.pt2262_22670e"}, blocking=True + "switch", "turn_off", {"entity_id": "switch.pt2262_226700"}, blocking=True ) - state = hass.states.get("switch.pt2262_22670e") + state = hass.states.get("switch.pt2262_226700") assert state.state == "off" assert rfxtrx.transport.send.mock_calls == [ @@ -220,26 +220,26 @@ async def test_pt2262_switch_events(hass: HomeAssistant, rfxtrx) -> None: await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("switch.pt2262_22670e") + state = hass.states.get("switch.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" # "Command: 0xE" await rfxtrx.signal("0913000022670e013970") - assert hass.states.get("switch.pt2262_22670e").state == "on" + assert hass.states.get("switch.pt2262_226700").state == "on" # "Command: 0x0" await rfxtrx.signal("09130000226700013970") - assert hass.states.get("switch.pt2262_22670e").state == "on" + assert hass.states.get("switch.pt2262_226700").state == "on" # "Command: 0x7" await rfxtrx.signal("09130000226707013d70") - assert hass.states.get("switch.pt2262_22670e").state == "off" + assert hass.states.get("switch.pt2262_226700").state == "off" # "Command: 0x1" await rfxtrx.signal("09130000226701013d70") - assert hass.states.get("switch.pt2262_22670e").state == "off" + assert hass.states.get("switch.pt2262_226700").state == "off" async def test_discover_switch(hass: HomeAssistant, rfxtrx_automatic) -> None: From a2d76cac5adb5e011bb2df0ff1afb8a9f643720b Mon Sep 17 00:00:00 2001 From: jsuar Date: Sun, 19 Jan 2025 15:09:04 -0500 Subject: [PATCH 0663/2987] Fix Slack file upload (#135818) * pgrade Slack integration to use AsyncWebClient and support files_upload_v2 - Replaced deprecated WebClient with AsyncWebClient throughout the integration. - Removed the unsupported `run_async` parameter. - Added a helper function to resolve channel names to channel IDs. - Updated `_async_send_local_file_message` and `_async_send_remote_file_message` to handle Slack's new API requirements, including per-channel uploads. - Updated dependency from slackclient==2.5.0 to slack-sdk>=3.0.0. - Improved error handling and logging for channel resolution and file uploads. * Fix test to use AsyncWebClient for Slack authentication flow * Fix Slack authentication URL by removing the www subdomain * Refactor Slack file upload functionality and add utility for file uploads --- homeassistant/components/slack/__init__.py | 7 +- homeassistant/components/slack/config_flow.py | 6 +- homeassistant/components/slack/entity.py | 11 +- homeassistant/components/slack/manifest.json | 2 +- homeassistant/components/slack/notify.py | 126 ++++++++++++------ homeassistant/components/slack/sensor.py | 4 +- homeassistant/components/slack/utils.py | 62 +++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/slack/__init__.py | 2 +- tests/components/slack/test_config_flow.py | 2 +- 11 files changed, 166 insertions(+), 60 deletions(-) create mode 100644 homeassistant/components/slack/utils.py diff --git a/homeassistant/components/slack/__init__.py b/homeassistant/components/slack/__init__.py index 6fce38e4774..aa67739016d 100644 --- a/homeassistant/components/slack/__init__.py +++ b/homeassistant/components/slack/__init__.py @@ -5,8 +5,8 @@ from __future__ import annotations import logging from aiohttp.client_exceptions import ClientError -from slack import WebClient from slack.errors import SlackApiError +from slack_sdk.web.async_client import AsyncWebClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform @@ -40,7 +40,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Slack from a config entry.""" session = aiohttp_client.async_get_clientsession(hass) - slack = WebClient(token=entry.data[CONF_API_KEY], run_async=True, session=session) + slack = AsyncWebClient( + token=entry.data[CONF_API_KEY], session=session + ) # No run_async try: res = await slack.auth_test() @@ -49,6 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Invalid API key") return False raise ConfigEntryNotReady("Error while setting up integration") from ex + data = { DATA_CLIENT: slack, ATTR_URL: res[ATTR_URL], diff --git a/homeassistant/components/slack/config_flow.py b/homeassistant/components/slack/config_flow.py index 7f6d7288606..fcdc2e8b362 100644 --- a/homeassistant/components/slack/config_flow.py +++ b/homeassistant/components/slack/config_flow.py @@ -4,8 +4,8 @@ from __future__ import annotations import logging -from slack import WebClient from slack.errors import SlackApiError +from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -57,10 +57,10 @@ class SlackFlowHandler(ConfigFlow, domain=DOMAIN): async def _async_try_connect( self, token: str - ) -> tuple[str, None] | tuple[None, dict[str, str]]: + ) -> tuple[str, None] | tuple[None, AsyncSlackResponse]: """Try connecting to Slack.""" session = aiohttp_client.async_get_clientsession(self.hass) - client = WebClient(token=token, run_async=True, session=session) + client = AsyncWebClient(token=token, session=session) # No run_async try: info = await client.auth_test() diff --git a/homeassistant/components/slack/entity.py b/homeassistant/components/slack/entity.py index 7147186ee9b..30218360054 100644 --- a/homeassistant/components/slack/entity.py +++ b/homeassistant/components/slack/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from slack import WebClient +from slack_sdk.web.async_client import AsyncWebClient from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -14,21 +14,18 @@ from .const import ATTR_URL, ATTR_USER_ID, DATA_CLIENT, DEFAULT_NAME, DOMAIN class SlackEntity(Entity): """Representation of a Slack entity.""" - _attr_attribution = "Data provided by Slack" - _attr_has_entity_name = True - def __init__( self, - data: dict[str, str | WebClient], + data: dict[str, AsyncWebClient], description: EntityDescription, entry: ConfigEntry, ) -> None: """Initialize a Slack entity.""" - self._client = data[DATA_CLIENT] + self._client: AsyncWebClient = data[DATA_CLIENT] self.entity_description = description self._attr_unique_id = f"{data[ATTR_USER_ID]}_{description.key}" self._attr_device_info = DeviceInfo( - configuration_url=data[ATTR_URL], + configuration_url=str(data[ATTR_URL]), entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry.entry_id)}, manufacturer=DEFAULT_NAME, diff --git a/homeassistant/components/slack/manifest.json b/homeassistant/components/slack/manifest.json index 1b35db6f061..3b2322283fe 100644 --- a/homeassistant/components/slack/manifest.json +++ b/homeassistant/components/slack/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_push", "loggers": ["slack"], - "requirements": ["slackclient==2.5.0"] + "requirements": ["slack_sdk==3.33.4"] } diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index 28f9dd203ff..16dd212301a 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -5,13 +5,13 @@ from __future__ import annotations import asyncio import logging import os -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from urllib.parse import urlparse -from aiohttp import BasicAuth, FormData +from aiohttp import BasicAuth from aiohttp.client_exceptions import ClientError -from slack import WebClient from slack.errors import SlackApiError +from slack_sdk.web.async_client import AsyncWebClient import voluptuous as vol from homeassistant.components.notify import ( @@ -38,6 +38,7 @@ from .const import ( DATA_CLIENT, SLACK_DATA, ) +from .utils import upload_file_to_slack _LOGGER = logging.getLogger(__name__) @@ -136,7 +137,7 @@ class SlackNotificationService(BaseNotificationService): def __init__( self, hass: HomeAssistant, - client: WebClient, + client: AsyncWebClient, config: dict[str, str], ) -> None: """Initialize.""" @@ -160,17 +161,23 @@ class SlackNotificationService(BaseNotificationService): parsed_url = urlparse(path) filename = os.path.basename(parsed_url.path) - try: - await self._client.files_upload( - channels=",".join(targets), - file=path, - filename=filename, - initial_comment=message, - title=title or filename, - thread_ts=thread_ts or "", - ) - except (SlackApiError, ClientError) as err: - _LOGGER.error("Error while uploading file-based message: %r", err) + channel_ids = [await self._async_get_channel_id(target) for target in targets] + channel_ids = [cid for cid in channel_ids if cid] # Remove None values + + if not channel_ids: + _LOGGER.error("No valid channel IDs resolved for targets: %s", targets) + return + + await upload_file_to_slack( + client=self._client, + channel_ids=channel_ids, + file_content=None, + file_path=path, + filename=filename, + title=title, + message=message, + thread_ts=thread_ts, + ) async def _async_send_remote_file_message( self, @@ -183,12 +190,7 @@ class SlackNotificationService(BaseNotificationService): username: str | None = None, password: str | None = None, ) -> None: - """Upload a remote file (with message) to Slack. - - Note that we bypass the python-slackclient WebClient and use aiohttp directly, - as the former would require us to download the entire remote file into memory - first before uploading it to Slack. - """ + """Upload a remote file (with message) to Slack.""" if not self._hass.config.is_allowed_external_url(url): _LOGGER.error("URL is not allowed: %s", url) return @@ -196,36 +198,35 @@ class SlackNotificationService(BaseNotificationService): filename = _async_get_filename_from_url(url) session = aiohttp_client.async_get_clientsession(self._hass) + # Fetch the remote file kwargs: AuthDictT = {} - if username and password is not None: + if username and password: kwargs = {"auth": BasicAuth(username, password=password)} - resp = await session.request("get", url, **kwargs) - try: - resp.raise_for_status() + async with session.get(url, **kwargs) as resp: + resp.raise_for_status() + file_content = await resp.read() except ClientError as err: _LOGGER.error("Error while retrieving %s: %r", url, err) return - form_data: FormDataT = { - "channels": ",".join(targets), - "filename": filename, - "initial_comment": message, - "title": title or filename, - "token": self._client.token, - } + channel_ids = [await self._async_get_channel_id(target) for target in targets] + channel_ids = [cid for cid in channel_ids if cid] # Remove None values - if thread_ts: - form_data["thread_ts"] = thread_ts + if not channel_ids: + _LOGGER.error("No valid channel IDs resolved for targets: %s", targets) + return - data = FormData(form_data, charset="utf-8") - data.add_field("file", resp.content, filename=filename) - - try: - await session.post("https://slack.com/api/files.upload", data=data) - except ClientError as err: - _LOGGER.error("Error while uploading file message: %r", err) + await upload_file_to_slack( + client=self._client, + channel_ids=channel_ids, + file_content=file_content, + filename=filename, + title=title, + message=message, + thread_ts=thread_ts, + ) async def _async_send_text_only_message( self, @@ -327,3 +328,46 @@ class SlackNotificationService(BaseNotificationService): title, thread_ts=data.get(ATTR_THREAD_TS), ) + + async def _async_get_channel_id(self, channel_name: str) -> str | None: + """Get the Slack channel ID from the channel name. + + This method retrieves the channel ID for a given Slack channel name by + querying the Slack API. It handles both public and private channels. + Including this so users can provide channel names instead of IDs. + + Args: + channel_name (str): The name of the Slack channel. + + Returns: + str | None: The ID of the Slack channel if found, otherwise None. + + Raises: + SlackApiError: If there is an error while communicating with the Slack API. + + """ + try: + # Remove # if present + channel_name = channel_name.lstrip("#") + + # Get channel list + # Multiple types is not working. Tested here: https://api.slack.com/methods/conversations.list/test + # response = await self._client.conversations_list(types="public_channel,private_channel") + # + # Workaround for the types parameter not working + channels = [] + for channel_type in ("public_channel", "private_channel"): + response = await self._client.conversations_list(types=channel_type) + channels.extend(response["channels"]) + + # Find channel ID + for channel in channels: + if channel["name"] == channel_name: + return cast(str, channel["id"]) + + _LOGGER.error("Channel %s not found", channel_name) + + except SlackApiError as err: + _LOGGER.error("Error getting channel ID: %r", err) + + return None diff --git a/homeassistant/components/slack/sensor.py b/homeassistant/components/slack/sensor.py index 9e3beaadd8b..d53555ba82a 100644 --- a/homeassistant/components/slack/sensor.py +++ b/homeassistant/components/slack/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from slack import WebClient +from slack_sdk.web.async_client import AsyncWebClient from homeassistant.components.sensor import ( SensorDeviceClass, @@ -43,7 +43,7 @@ async def async_setup_entry( class SlackSensorEntity(SlackEntity, SensorEntity): """Representation of a Slack sensor.""" - _client: WebClient + _client: AsyncWebClient async def async_update(self) -> None: """Get the latest status.""" diff --git a/homeassistant/components/slack/utils.py b/homeassistant/components/slack/utils.py new file mode 100644 index 00000000000..7619d7d265f --- /dev/null +++ b/homeassistant/components/slack/utils.py @@ -0,0 +1,62 @@ +"""Utils for the Slack integration.""" + +import logging + +import aiofiles +from slack_sdk.errors import SlackApiError +from slack_sdk.web.async_client import AsyncWebClient + +_LOGGER = logging.getLogger(__name__) + + +async def upload_file_to_slack( + client: AsyncWebClient, + channel_ids: list[str | None], + file_content: bytes | str | None, + filename: str, + title: str | None, + message: str, + thread_ts: str | None, + file_path: str | None = None, # Allow passing a file path +) -> None: + """Upload a file to Slack for the specified channel IDs. + + Args: + client (AsyncWebClient): The Slack WebClient instance. + channel_ids (list[str | None]): List of channel IDs to upload the file to. + file_content (Union[bytes, str, None]): Content of the file (local or remote). If None, file_path is used. + filename (str): The file's name. + title (str | None): Title of the file in Slack. + message (str): Initial comment to accompany the file. + thread_ts (str | None): Thread timestamp for threading messages. + file_path (str | None): Path to the local file to be read if file_content is None. + + Raises: + SlackApiError: If the Slack API call fails. + OSError: If there is an error reading the file. + + """ + if file_content is None and file_path: + # Read file asynchronously if file_content is not provided + try: + async with aiofiles.open(file_path, "rb") as file: + file_content = await file.read() + except OSError as os_err: + _LOGGER.error("Error reading file %s: %r", file_path, os_err) + return + + for channel_id in channel_ids: + try: + await client.files_upload_v2( + channel=channel_id, + file=file_content, + filename=filename, + title=title or filename, + initial_comment=message, + thread_ts=thread_ts or "", + ) + _LOGGER.info("Successfully uploaded file to channel %s", channel_id) + except SlackApiError as err: + _LOGGER.error( + "Error while uploading file to channel %s: %r", channel_id, err + ) diff --git a/requirements_all.txt b/requirements_all.txt index cb2a03b0fbf..e830aecc277 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2720,7 +2720,7 @@ sisyphus-control==3.1.4 skyboxremote==0.0.6 # homeassistant.components.slack -slackclient==2.5.0 +slack_sdk==3.33.4 # homeassistant.components.xmpp slixmpp==1.8.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 047d7db7821..e31db056291 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2190,7 +2190,7 @@ simplisafe-python==2024.01.0 skyboxremote==0.0.6 # homeassistant.components.slack -slackclient==2.5.0 +slack_sdk==3.33.4 # homeassistant.components.smart_meter_texas smart-meter-texas==0.5.5 diff --git a/tests/components/slack/__init__.py b/tests/components/slack/__init__.py index acb52a11a6c..507e96294ff 100644 --- a/tests/components/slack/__init__.py +++ b/tests/components/slack/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker -AUTH_URL = "https://www.slack.com/api/auth.test" +AUTH_URL = "https://slack.com/api/auth.test" TOKEN = "abc123" TEAM_NAME = "Test Team" diff --git a/tests/components/slack/test_config_flow.py b/tests/components/slack/test_config_flow.py index 565b5ec2149..6d0953da5e9 100644 --- a/tests/components/slack/test_config_flow.py +++ b/tests/components/slack/test_config_flow.py @@ -81,7 +81,7 @@ async def test_flow_user_cannot_connect( async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: """Test user initialized flow with unreachable server.""" with patch( - "homeassistant.components.slack.config_flow.WebClient.auth_test" + "homeassistant.components.slack.config_flow.AsyncWebClient.auth_test" ) as mock: mock.side_effect = Exception result = await hass.config_entries.flow.async_init( From 77221f53b324b82aa8b3e8561e389da415a58611 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 19 Jan 2025 21:27:01 +0100 Subject: [PATCH 0664/2987] Fix sentence-casing in PurpleAir integration strings (#135981) --- homeassistant/components/purpleair/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/purpleair/strings.json b/homeassistant/components/purpleair/strings.json index b082e088ba2..006093f3545 100644 --- a/homeassistant/components/purpleair/strings.json +++ b/homeassistant/components/purpleair/strings.json @@ -6,7 +6,7 @@ "data": { "latitude": "[%key:common::config_flow::data::latitude%]", "longitude": "[%key:common::config_flow::data::longitude%]", - "distance": "Search Radius" + "distance": "Search radius" }, "data_description": { "latitude": "The latitude around which to search for sensors", @@ -53,7 +53,7 @@ "options": { "step": { "add_sensor": { - "title": "Add Sensor", + "title": "Add sensor", "description": "[%key:component::purpleair::config::step::by_coordinates::description%]", "data": { "latitude": "[%key:common::config_flow::data::latitude%]", @@ -67,7 +67,7 @@ } }, "choose_sensor": { - "title": "Choose Sensor to Add", + "title": "Choose sensor to add", "description": "[%key:component::purpleair::config::step::choose_sensor::description%]", "data": { "sensor_index": "[%key:component::purpleair::config::step::choose_sensor::data::sensor_index%]" @@ -84,9 +84,9 @@ } }, "remove_sensor": { - "title": "Remove Sensor", + "title": "Remove sensor", "data": { - "sensor_device_id": "Sensor Name" + "sensor_device_id": "Sensor name" }, "data_description": { "sensor_device_id": "The sensor to remove" From f5d35bca72b1adbd8c2ee680adc32c36aed19537 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 19 Jan 2025 21:28:08 +0100 Subject: [PATCH 0665/2987] Implement cloudhooks for Overseerr (#134680) --- .../components/overseerr/__init__.py | 63 +++++- .../components/overseerr/manifest.json | 1 + tests/components/overseerr/conftest.py | 37 ++++ tests/components/overseerr/test_init.py | 199 +++++++++++++++++- 4 files changed, 289 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/overseerr/__init__.py b/homeassistant/components/overseerr/__init__.py index 704bf99c147..e4ac712e053 100644 --- a/homeassistant/components/overseerr/__init__.py +++ b/homeassistant/components/overseerr/__init__.py @@ -3,12 +3,14 @@ from __future__ import annotations import json +from typing import cast from aiohttp.hdrs import METH_POST from aiohttp.web_request import Request from aiohttp.web_response import Response from python_overseerr import OverseerrConnectionError +from homeassistant.components import cloud from homeassistant.components.webhook import ( async_generate_url, async_register, @@ -26,6 +28,7 @@ from .coordinator import OverseerrConfigEntry, OverseerrCoordinator from .services import setup_services PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR] +CONF_CLOUDHOOK_URL = "cloudhook_url" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -64,6 +67,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +async def async_remove_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) -> None: + """Cleanup when entry is removed.""" + if cloud.async_active_subscription(hass): + try: + LOGGER.debug( + "Removing Overseerr cloudhook (%s)", entry.data[CONF_WEBHOOK_ID] + ) + await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID]) + except cloud.CloudNotAvailable: + pass + + class OverseerrWebhookManager: """Overseerr webhook manager.""" @@ -86,6 +101,8 @@ class OverseerrWebhookManager: for url in urls: if url not in res: res.append(url) + if CONF_CLOUDHOOK_URL in self.entry.data: + res.append(self.entry.data[CONF_CLOUDHOOK_URL]) return res async def register_webhook(self) -> None: @@ -101,16 +118,18 @@ class OverseerrWebhookManager: if not await self.check_need_change(): return for url in self.webhook_urls: - if await self.client.test_webhook_notification_config(url, JSON_PAYLOAD): - LOGGER.debug("Setting Overseerr webhook to %s", url) - await self.client.set_webhook_notification_config( - enabled=True, - types=REGISTERED_NOTIFICATIONS, - webhook_url=url, - json_payload=JSON_PAYLOAD, - ) + if await self.test_and_set_webhook(url): return - LOGGER.error("Failed to set Overseerr webhook") + LOGGER.info("Failed to register Overseerr webhook") + if ( + cloud.async_active_subscription(self.hass) + and CONF_CLOUDHOOK_URL not in self.entry.data + ): + LOGGER.info("Trying to register a cloudhook URL") + url = await _async_cloudhook_generate_url(self.hass, self.entry) + if await self.test_and_set_webhook(url): + return + LOGGER.error("Failed to register Overseerr cloudhook") async def check_need_change(self) -> bool: """Check if webhook needs to be changed.""" @@ -122,6 +141,19 @@ class OverseerrWebhookManager: or current_config.types != REGISTERED_NOTIFICATIONS ) + async def test_and_set_webhook(self, url: str) -> bool: + """Test and set webhook.""" + if await self.client.test_webhook_notification_config(url, JSON_PAYLOAD): + LOGGER.debug("Setting Overseerr webhook to %s", url) + await self.client.set_webhook_notification_config( + enabled=True, + types=REGISTERED_NOTIFICATIONS, + webhook_url=url, + json_payload=JSON_PAYLOAD, + ) + return True + return False + async def handle_webhook( self, hass: HomeAssistant, webhook_id: str, request: Request ) -> Response: @@ -136,3 +168,16 @@ class OverseerrWebhookManager: async def unregister_webhook(self) -> None: """Unregister webhook.""" async_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) + + +async def _async_cloudhook_generate_url( + hass: HomeAssistant, entry: OverseerrConfigEntry +) -> str: + """Generate the full URL for a webhook_id.""" + if CONF_CLOUDHOOK_URL not in entry.data: + webhook_id = entry.data[CONF_WEBHOOK_ID] + webhook_url = await cloud.async_create_cloudhook(hass, webhook_id) + data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url} + hass.config_entries.async_update_entry(entry, data=data) + return webhook_url + return cast(str, entry.data[CONF_CLOUDHOOK_URL]) diff --git a/homeassistant/components/overseerr/manifest.json b/homeassistant/components/overseerr/manifest.json index ddcf9ccce5e..26dfd6d73e3 100644 --- a/homeassistant/components/overseerr/manifest.json +++ b/homeassistant/components/overseerr/manifest.json @@ -1,6 +1,7 @@ { "domain": "overseerr", "name": "Overseerr", + "after_dependencies": ["cloud"], "codeowners": ["@joostlek"], "config_flow": true, "dependencies": ["http", "webhook"], diff --git a/tests/components/overseerr/conftest.py b/tests/components/overseerr/conftest.py index b05d1d0cb87..9ae6be407ec 100644 --- a/tests/components/overseerr/conftest.py +++ b/tests/components/overseerr/conftest.py @@ -7,6 +7,7 @@ import pytest from python_overseerr import MovieDetails, RequestCount, RequestResponse from python_overseerr.models import TVDetails, WebhookNotificationConfig +from homeassistant.components.overseerr import CONF_CLOUDHOOK_URL from homeassistant.components.overseerr.const import DOMAIN from homeassistant.const import ( CONF_API_KEY, @@ -66,6 +67,24 @@ def mock_overseerr_client() -> Generator[AsyncMock]: yield client +@pytest.fixture +def mock_overseerr_client_needs_change( + mock_overseerr_client: AsyncMock, +) -> Generator[AsyncMock]: + """Mock an Overseerr client.""" + mock_overseerr_client.get_webhook_notification_config.return_value.types = 0 + return mock_overseerr_client + + +@pytest.fixture +def mock_overseerr_client_cloudhook( + mock_overseerr_client: AsyncMock, +) -> Generator[AsyncMock]: + """Mock an Overseerr client.""" + mock_overseerr_client.get_webhook_notification_config.return_value.options.webhook_url = "https://hooks.nabu.casa/ABCD" + return mock_overseerr_client + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Mock a config entry.""" @@ -81,3 +100,21 @@ def mock_config_entry() -> MockConfigEntry: }, entry_id="01JG00V55WEVTJ0CJHM0GAD7PC", ) + + +@pytest.fixture +def mock_cloudhook_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Overseerr", + data={ + CONF_HOST: "overseerr.test", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: "test-key", + CONF_WEBHOOK_ID: WEBHOOK_ID, + CONF_CLOUDHOOK_URL: "https://hooks.nabu.casa/ABCD", + }, + entry_id="01JG00V55WEVTJ0CJHM0GAD7PC", + ) diff --git a/tests/components/overseerr/test_init.py b/tests/components/overseerr/test_init.py index 0962cd2c2f1..4c6897ed316 100644 --- a/tests/components/overseerr/test_init.py +++ b/tests/components/overseerr/test_init.py @@ -1,13 +1,18 @@ """Tests for the Overseerr integration.""" from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest from python_overseerr.models import WebhookNotificationOptions from syrupy import SnapshotAssertion -from homeassistant.components.overseerr import JSON_PAYLOAD, REGISTERED_NOTIFICATIONS +from homeassistant.components import cloud +from homeassistant.components.overseerr import ( + CONF_CLOUDHOOK_URL, + JSON_PAYLOAD, + REGISTERED_NOTIFICATIONS, +) from homeassistant.components.overseerr.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -15,6 +20,7 @@ from homeassistant.helpers import device_registry as dr from . import setup_integration from tests.common import MockConfigEntry +from tests.components.cloud import mock_cloud async def test_device_info( @@ -150,3 +156,192 @@ async def test_prefer_internal_ip( mock_overseerr_client.test_webhook_notification_config.call_args_list[1][0][0] == "https://www.example.com/api/webhook/test-webhook-id" ) + + +async def test_cloudhook_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test if set up with active cloud subscription and cloud hook.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + mock_overseerr_client_needs_change.test_webhook_notification_config.side_effect = [ + False, + True, + ] + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, + patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ) as fake_delete_cloudhook, + ): + await setup_integration(hass, mock_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert ( + mock_config_entry.data[CONF_CLOUDHOOK_URL] == "https://hooks.nabu.casa/ABCD" + ) + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 2 + ) + + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_called() + + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + fake_delete_cloudhook.assert_called_once() + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + + +async def test_cloudhook_consistent( + hass: HomeAssistant, + mock_cloudhook_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test if we keep the cloudhook if it is already set up.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + mock_overseerr_client_needs_change.test_webhook_notification_config.side_effect = [ + False, + True, + ] + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, + ): + await setup_integration(hass, mock_cloudhook_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert ( + mock_cloudhook_config_entry.data[CONF_CLOUDHOOK_URL] + == "https://hooks.nabu.casa/ABCD" + ) + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 2 + ) + + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_not_called() + + +async def test_cloudhook_needs_no_change( + hass: HomeAssistant, + mock_cloudhook_config_entry: MockConfigEntry, + mock_overseerr_client_cloudhook: AsyncMock, +) -> None: + """Test if we keep the cloudhook if it is already set up.""" + + await setup_integration(hass, mock_cloudhook_config_entry) + + assert ( + len(mock_overseerr_client_cloudhook.test_webhook_notification_config.mock_calls) + == 0 + ) + + +async def test_cloudhook_not_needed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test if we prefer local webhook over cloudhook.""" + + await hass.async_block_till_done() + + with ( + patch.object(cloud, "async_active_subscription", return_value=True), + ): + await setup_integration(hass, mock_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert CONF_CLOUDHOOK_URL not in mock_config_entry.data + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 1 + ) + assert ( + mock_overseerr_client_needs_change.test_webhook_notification_config.call_args_list[ + 0 + ][0][0] + == "http://10.10.10.10:8123/api/webhook/test-webhook-id" + ) + + +async def test_cloudhook_not_connecting( + hass: HomeAssistant, + mock_cloudhook_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test the cloudhook is not registered if Overseerr cannot connect to it.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + mock_overseerr_client_needs_change.test_webhook_notification_config.return_value = ( + False + ) + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, + ): + await setup_integration(hass, mock_cloudhook_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert ( + mock_cloudhook_config_entry.data[CONF_CLOUDHOOK_URL] + == "https://hooks.nabu.casa/ABCD" + ) + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 2 + ) + + mock_overseerr_client_needs_change.set_webhook_notification_config.assert_not_called() + + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_not_called() From 53f80e975958f8a9dea15c47c007ecdb037c1c8e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 19 Jan 2025 21:28:50 +0100 Subject: [PATCH 0666/2987] Ensure entity platform in camera tests (#135918) --- tests/components/camera/test_init.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 32520fcad23..5a26e3b44f6 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -41,6 +41,7 @@ from homeassistant.util import dt as dt_util from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, mock_turbo_jpeg from tests.common import ( + MockEntityPlatform, async_fire_time_changed, help_test_all, import_and_test_deprecated_constant_enum, @@ -826,7 +827,9 @@ def test_deprecated_state_constants( import_and_test_deprecated_constant_enum(caplog, module, enum, "STATE_", "2025.10") -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: +def test_deprecated_supported_features_ints( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test deprecated supported features ints.""" class MockCamera(camera.Camera): @@ -836,6 +839,8 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> return 1 entity = MockCamera() + entity.hass = hass + entity.platform = MockEntityPlatform(hass) assert entity.supported_features_compat is camera.CameraEntityFeature(1) assert "MockCamera" in caplog.text assert "is using deprecated supported features values" in caplog.text From 2295e3779a484759d82ef1a1277fcc90db449418 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 19 Jan 2025 21:29:28 +0100 Subject: [PATCH 0667/2987] Ensure entity platform in cover tests (#135917) --- tests/components/cover/test_init.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index 646c44e4ac2..f1997066638 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -13,7 +13,11 @@ from homeassistant.setup import async_setup_component from .common import MockCover -from tests.common import help_test_all, setup_test_component_platform +from tests.common import ( + MockEntityPlatform, + help_test_all, + setup_test_component_platform, +) async def test_services( @@ -157,13 +161,17 @@ def test_all() -> None: help_test_all(cover) -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: +def test_deprecated_supported_features_ints( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test deprecated supported features ints.""" class MockCoverEntity(cover.CoverEntity): _attr_supported_features = 1 entity = MockCoverEntity() + entity.hass = hass + entity.platform = MockEntityPlatform(hass) assert entity.supported_features is cover.CoverEntityFeature(1) assert "MockCoverEntity" in caplog.text assert "is using deprecated supported features values" in caplog.text From a98bb96325cf50d4ca77b68573b53c253ff673e1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 19 Jan 2025 21:33:15 +0100 Subject: [PATCH 0668/2987] Add reconfigure flow to Trafikverket Train (#136000) --- .../trafikverket_train/config_flow.py | 46 ++- .../trafikverket_train/strings.json | 9 +- .../trafikverket_train/test_config_flow.py | 332 +++++++++++++++++- 3 files changed, 380 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index 4b01f7ba4d4..da1fb0f7622 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -16,6 +16,7 @@ from pytrafikverket import ( import voluptuous as vol from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, ConfigEntry, ConfigFlow, ConfigFlowResult, @@ -146,6 +147,18 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + return await self.async_step_initial(user_input) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + return await self.async_step_initial(user_input) + + async def async_step_initial( + self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the user step.""" errors: dict[str, str] = {} @@ -185,6 +198,22 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): CONF_FILTER_PRODUCT: filter_product, } ) + + if self.source == SOURCE_RECONFIGURE: + reconfigure_entry = self._get_reconfigure_entry() + return self.async_update_reload_and_abort( + reconfigure_entry, + title=name, + data={ + CONF_API_KEY: api_key, + CONF_NAME: name, + CONF_FROM: self._from_stations[0].signature, + CONF_TO: self._to_stations[0].signature, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + }, + options={CONF_FILTER_PRODUCT: filter_product}, + ) return self.async_create_entry( title=name, data={ @@ -201,7 +230,7 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_select_stations() return self.async_show_form( - step_id="user", + step_id="initial", data_schema=self.add_suggested_values_to_schema( DATA_SCHEMA, user_input or {} ), @@ -238,6 +267,21 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): CONF_FILTER_PRODUCT: filter_product, } ) + if self.source == SOURCE_RECONFIGURE: + reconfigure_entry = self._get_reconfigure_entry() + return self.async_update_reload_and_abort( + reconfigure_entry, + title=name, + data={ + CONF_API_KEY: api_key, + CONF_NAME: name, + CONF_FROM: train_from, + CONF_TO: train_to, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + }, + options={CONF_FILTER_PRODUCT: filter_product}, + ) return self.async_create_entry( title=name, data={ diff --git a/homeassistant/components/trafikverket_train/strings.json b/homeassistant/components/trafikverket_train/strings.json index 3cecda1ded9..02155e46c2f 100644 --- a/homeassistant/components/trafikverket_train/strings.json +++ b/homeassistant/components/trafikverket_train/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -13,7 +14,7 @@ "incorrect_api_key": "Invalid API key for selected account" }, "step": { - "user": { + "initial": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "to": "To station", @@ -45,10 +46,10 @@ "step": { "init": { "data": { - "filter_product": "[%key:component::trafikverket_train::config::step::user::data::filter_product%]" + "filter_product": "[%key:component::trafikverket_train::config::step::initial::data::filter_product%]" }, "data_description": { - "filter_product": "[%key:component::trafikverket_train::config::step::user::data_description::filter_product%]" + "filter_product": "[%key:component::trafikverket_train::config::step::initial::data_description::filter_product%]" } } } diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index d001215ec39..241831b5553 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -25,6 +25,8 @@ from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import ENTRY_CONFIG, OPTIONS_CONFIG + from tests.common import MockConfigEntry @@ -37,6 +39,7 @@ async def test_form( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" assert result["errors"] == {} with ( @@ -218,7 +221,7 @@ async def test_flow_fails( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER + assert result["step_id"] == "initial" with ( patch( @@ -260,7 +263,7 @@ async def test_flow_fails_departures( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER + assert result["step_id"] == "initial" with ( patch( @@ -574,3 +577,328 @@ async def test_options_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {"filter_product": None} + + +async def test_reconfigure_flow( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: + """Test reconfigure flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: ["mon", "fri"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_multiple_stations( + hass: HomeAssistant, get_multiple_train_stations: list[StationInfoModel] +) -> None: + """Test we can reconfigure with multiple stations.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: ["mon", "fri"], + }, + ) + await hass.async_block_till_done() + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_FROM: "Csu", + CONF_TO: "Ups", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_entry_already_exist( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: + """Test flow aborts when entry already exist in a reconfigure flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "1234567890", + CONF_NAME: "Stockholm C to Uppsala C at 10:00", + CONF_FROM: "Cst", + CONF_TO: "U", + CONF_TIME: "10:00", + CONF_WEEKDAY: WEEKDAYS, + CONF_FILTER_PRODUCT: None, + }, + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + + config_entry2 = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry2.add_to_hass(hass) + result = await config_entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: WEEKDAYS, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "p_error"), + [ + ( + InvalidAuthentication, + {"base": "invalid_auth"}, + ), + ( + NoTrainStationFound, + {"from": "invalid_station", "to": "invalid_station"}, + ), + ( + Exception, + {"base": "cannot_connect"}, + ), + ], +) +async def test_reconfigure_flow_fails( + hass: HomeAssistant, + side_effect: Exception, + p_error: dict[str, str], + get_train_stations: list[StationInfoModel], +) -> None: + """Test config flow errors.""" + config_entry2 = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry2.add_to_hass(hass) + result = await config_entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" + + with ( + patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=side_effect(), + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + }, + ) + + assert result["errors"] == p_error + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.parametrize( + ("side_effect", "p_error"), + [ + ( + NoTrainStationFound, + {"from": "invalid_station", "to": "invalid_station"}, + ), + ( + UnknownError, + {"base": "cannot_connect"}, + ), + ], +) +async def test_reconfigure_flow_fails_departures( + hass: HomeAssistant, + side_effect: Exception, + p_error: dict[str, str], + get_train_stations: list[StationInfoModel], +) -> None: + """Test config flow errors.""" + config_entry2 = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry2.add_to_hass(hass) + result = await config_entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" + + with ( + patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=side_effect(), + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + }, + ) + + assert result["errors"] == p_error + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" From 38c709aa1b3f71499a00f45be942a8d8f68b90df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jan 2025 11:12:08 -1000 Subject: [PATCH 0669/2987] Bump onvif-zeep-async to 3.2.3 (#136022) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 281f0fb60ee..9d27314593c 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==3.2.2", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==3.2.3", "WSDiscovery==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e830aecc277..2720c8fedc2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1552,7 +1552,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onvif -onvif-zeep-async==3.2.2 +onvif-zeep-async==3.2.3 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e31db056291..66ae731c331 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1300,7 +1300,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onvif -onvif-zeep-async==3.2.2 +onvif-zeep-async==3.2.3 # homeassistant.components.opengarage open-garage==0.2.0 From fd0b57a357538496f7d7a2540e2c42ca9a83b48c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 19 Jan 2025 22:57:51 +0100 Subject: [PATCH 0670/2987] Bump docker/build-push-action from 6.11.0 to 6.12.0 (#135749) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 5394a09d9bc..6c53304a9ee 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -509,7 +509,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@b32b51a8eda65d6793cd0494a773d4f6bcef32dc # v6.11.0 + uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -522,7 +522,7 @@ jobs: - name: Push Docker image if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' id: push - uses: docker/build-push-action@b32b51a8eda65d6793cd0494a773d4f6bcef32dc # v6.11.0 + uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile From bf565833854663331c26ea26d6262775b1dcaaa3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jan 2025 16:50:30 -1000 Subject: [PATCH 0671/2987] Bump thermopro-ble to 0.10.1 (#136041) --- homeassistant/components/thermopro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index 51348afb0a4..2c066d785ca 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.10.0"] + "requirements": ["thermopro-ble==0.10.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2720c8fedc2..187981a6d3b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2868,7 +2868,7 @@ tessie-api==0.1.1 thermobeacon-ble==0.7.0 # homeassistant.components.thermopro -thermopro-ble==0.10.0 +thermopro-ble==0.10.1 # homeassistant.components.thingspeak thingspeak==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 66ae731c331..12a546c214d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2305,7 +2305,7 @@ tessie-api==0.1.1 thermobeacon-ble==0.7.0 # homeassistant.components.thermopro -thermopro-ble==0.10.0 +thermopro-ble==0.10.1 # homeassistant.components.lg_thinq thinqconnect==1.0.2 From be2c592b1726dd1603a931d734e9191fce43e599 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jan 2025 21:01:44 -1000 Subject: [PATCH 0672/2987] Bump habluetooth to 3.9.2 (#136042) --- homeassistant/components/bluetooth/manifest.json | 5 +++-- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index eed276ec6e6..b5aa6cfa12f 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -10,7 +10,8 @@ "btsocket", "bleak_retry_connector", "bluetooth_adapters", - "bluetooth_auto_recovery" + "bluetooth_auto_recovery", + "habluetooth" ], "quality_scale": "internal", "requirements": [ @@ -20,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.9.0" + "habluetooth==3.9.2" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 909531c681d..133c5bb76ed 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.9.0 +habluetooth==3.9.2 hass-nabucasa==0.88.1 hassil==2.1.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 187981a6d3b..9b8e16d3052 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1097,7 +1097,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.9.0 +habluetooth==3.9.2 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 12a546c214d..48b1e15e656 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -938,7 +938,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.9.0 +habluetooth==3.9.2 # homeassistant.components.cloud hass-nabucasa==0.88.1 From 53ad02a1eb3c7c2e5ada10e0a81cdbe0e3cd1153 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 20 Jan 2025 08:05:33 +0100 Subject: [PATCH 0673/2987] Enable RUF032 (#135836) --- homeassistant/components/derivative/sensor.py | 2 +- pyproject.toml | 1 + tests/components/dsmr/test_diagnostics.py | 2 +- tests/components/dsmr/test_mbus_migration.py | 8 +-- tests/components/dsmr/test_sensor.py | 54 +++++++++---------- tests/components/sensor/test_init.py | 2 +- 6 files changed, 35 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 77ce5169d8d..988da5e938b 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -272,7 +272,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): if elapsed_time > self._time_window: derivative = new_derivative else: - derivative = Decimal(0.00) + derivative = Decimal("0.00") for start, end, value in self._state_list: weight = calculate_weight(start, end, new_state.last_updated) derivative = derivative + (value * Decimal(weight)) diff --git a/pyproject.toml b/pyproject.toml index a98957df068..73795f4ccd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -769,6 +769,7 @@ select = [ "RUF024", # Do not pass mutable objects as values to dict.fromkeys "RUF026", # default_factory is a positional-only argument to defaultdict "RUF030", # print() call in assert statement is likely unintentional + "RUF032", # Decimal() called with float literal argument "RUF033", # __post_init__ method with argument defaults "RUF034", # Useless if-else condition "RUF100", # Unused `noqa` directive diff --git a/tests/components/dsmr/test_diagnostics.py b/tests/components/dsmr/test_diagnostics.py index 8fc996f6e34..9bcde251f6f 100644 --- a/tests/components/dsmr/test_diagnostics.py +++ b/tests/components/dsmr/test_diagnostics.py @@ -58,7 +58,7 @@ async def test_diagnostics( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m³"}, + {"value": Decimal("745.695"), "unit": "m³"}, ], ), "GAS_METER_READING", diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py index 8c090690beb..d590666b060 100644 --- a/tests/components/dsmr/test_mbus_migration.py +++ b/tests/components/dsmr/test_mbus_migration.py @@ -86,7 +86,7 @@ async def test_migrate_gas_to_mbus( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -197,7 +197,7 @@ async def test_migrate_hourly_gas_to_mbus( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1722749707)}, - {"value": Decimal(778.963), "unit": "m3"}, + {"value": Decimal("778.963"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -308,7 +308,7 @@ async def test_migrate_gas_with_devid_to_mbus( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -433,7 +433,7 @@ async def test_migrate_gas_to_mbus_exists( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 4a2951f4ed8..fbe14b38aa3 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -89,7 +89,7 @@ async def test_default_setup( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": UnitOfVolume.CUBIC_METERS}, + {"value": Decimal("745.695"), "unit": UnitOfVolume.CUBIC_METERS}, ], ), "GAS_METER_READING", @@ -152,7 +152,7 @@ async def test_default_setup( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642214)}, - {"value": Decimal(745.701), "unit": UnitOfVolume.CUBIC_METERS}, + {"value": Decimal("745.701"), "unit": UnitOfVolume.CUBIC_METERS}, ], ), "GAS_METER_READING", @@ -279,7 +279,7 @@ async def test_v4_meter( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "HOURLY_GAS_METER_READING", @@ -336,9 +336,9 @@ async def test_v4_meter( @pytest.mark.parametrize( ("value", "state"), [ - (Decimal(745.690), "745.69"), - (Decimal(745.695), "745.695"), - (Decimal(0.000), STATE_UNKNOWN), + (Decimal("745.690"), "745.69"), + (Decimal("745.695"), "745.695"), + (Decimal("0.000"), STATE_UNKNOWN), ], ) async def test_v5_meter( @@ -440,7 +440,7 @@ async def test_luxembourg_meter( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "HOURLY_GAS_METER_READING", @@ -449,7 +449,7 @@ async def test_luxembourg_meter( ELECTRICITY_IMPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(123.456), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("123.456"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_IMPORTED_TOTAL", ) @@ -457,7 +457,7 @@ async def test_luxembourg_meter( ELECTRICITY_EXPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(654.321), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("654.321"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_EXPORTED_TOTAL", ) @@ -533,7 +533,7 @@ async def test_belgian_meter( BELGIUM_CURRENT_AVERAGE_DEMAND, CosemObject( (0, 0), - [{"value": Decimal(1.75), "unit": "kW"}], + [{"value": Decimal("1.75"), "unit": "kW"}], ), "BELGIUM_CURRENT_AVERAGE_DEMAND", ) @@ -543,7 +543,7 @@ async def test_belgian_meter( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642218)}, - {"value": Decimal(4.11), "unit": "kW"}, + {"value": Decimal("4.11"), "unit": "kW"}, ], ), "BELGIUM_MAXIMUM_DEMAND_MONTH", @@ -567,7 +567,7 @@ async def test_belgian_meter( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -591,7 +591,7 @@ async def test_belgian_meter( (0, 2), [ {"value": datetime.datetime.fromtimestamp(1551642214)}, - {"value": Decimal(678.695), "unit": "m3"}, + {"value": Decimal("678.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -615,7 +615,7 @@ async def test_belgian_meter( (0, 3), [ {"value": datetime.datetime.fromtimestamp(1551642215)}, - {"value": Decimal(12.12), "unit": "m3"}, + {"value": Decimal("12.12"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -639,7 +639,7 @@ async def test_belgian_meter( (0, 4), [ {"value": datetime.datetime.fromtimestamp(1551642216)}, - {"value": Decimal(13.13), "unit": "m3"}, + {"value": Decimal("13.13"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -782,7 +782,7 @@ async def test_belgian_meter_alt( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642215)}, - {"value": Decimal(123.456), "unit": "m3"}, + {"value": Decimal("123.456"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -806,7 +806,7 @@ async def test_belgian_meter_alt( (0, 2), [ {"value": datetime.datetime.fromtimestamp(1551642216)}, - {"value": Decimal(678.901), "unit": "m3"}, + {"value": Decimal("678.901"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -830,7 +830,7 @@ async def test_belgian_meter_alt( (0, 3), [ {"value": datetime.datetime.fromtimestamp(1551642217)}, - {"value": Decimal(12.12), "unit": "m3"}, + {"value": Decimal("12.12"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -854,7 +854,7 @@ async def test_belgian_meter_alt( (0, 4), [ {"value": datetime.datetime.fromtimestamp(1551642218)}, - {"value": Decimal(13.13), "unit": "m3"}, + {"value": Decimal("13.13"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -1001,7 +1001,7 @@ async def test_belgian_meter_mbus( (0, 3), [ {"value": datetime.datetime.fromtimestamp(1551642217)}, - {"value": Decimal(12.12), "unit": "m3"}, + {"value": Decimal("12.12"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -1017,7 +1017,7 @@ async def test_belgian_meter_mbus( (0, 4), [ {"value": datetime.datetime.fromtimestamp(1551642218)}, - {"value": Decimal(13.13), "unit": "m3"}, + {"value": Decimal("13.13"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -1154,7 +1154,7 @@ async def test_swedish_meter( ELECTRICITY_IMPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(123.456), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("123.456"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_IMPORTED_TOTAL", ) @@ -1162,7 +1162,7 @@ async def test_swedish_meter( ELECTRICITY_EXPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(654.321), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("654.321"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_EXPORTED_TOTAL", ) @@ -1229,7 +1229,7 @@ async def test_easymeter( ELECTRICITY_IMPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(54184.6316), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("54184.6316"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_IMPORTED_TOTAL", ) @@ -1237,7 +1237,7 @@ async def test_easymeter( ELECTRICITY_EXPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(19981.1069), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("19981.1069"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_EXPORTED_TOTAL", ) @@ -1489,7 +1489,7 @@ async def test_gas_meter_providing_energy_reading( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(123.456), "unit": UnitOfEnergy.GIGA_JOULE}, + {"value": Decimal("123.456"), "unit": UnitOfEnergy.GIGA_JOULE}, ], ), "GAS_METER_READING", @@ -1549,7 +1549,7 @@ async def test_heat_meter_mbus( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "GJ"}, + {"value": Decimal("745.695"), "unit": "GJ"}, ], ), "MBUS_METER_READING", diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 58c61715c72..604cd91770e 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -2144,7 +2144,7 @@ async def test_non_numeric_validation_raise( (13, "13"), (17.50, "17.5"), ("1e-05", "1e-05"), - (Decimal(18.50), "18.5"), + (Decimal("18.50"), "18.50"), ("19.70", "19.70"), (None, STATE_UNKNOWN), ], From 85f10cf60a24d8263edce6520dcebc3bf3375cf8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 20 Jan 2025 02:06:06 -0500 Subject: [PATCH 0674/2987] Use LLM fallback when local matching matches intent but not targets (#136045) LLM fallback to be used when local matching matches intent but finds no targets --- .../components/conversation/default_agent.py | 15 +++++- .../conversation/test_default_agent.py | 48 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index d4773d50c4b..1d79709adf8 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -1350,14 +1350,25 @@ class DefaultAgent(ConversationEntity): """Try to match sentence against registered intents and return response. Only performs strict matching with exposed entities and exact wording. - Returns None if no match occurred. + Returns None if no match or a matching error occurred. """ result = await self.async_recognize_intent(user_input, strict_intents_only=True) if not isinstance(result, RecognizeResult): # No error message on failed match return None - return await self._async_process_intent_result(result, user_input) + response = await self._async_process_intent_result(result, user_input) + if ( + response.response_type == intent.IntentResponseType.ERROR + and response.error_code + not in ( + intent.IntentResponseErrorCode.FAILED_TO_HANDLE, + intent.IntentResponseErrorCode.UNKNOWN, + ) + ): + # We ignore no matching errors + return None + return response def _make_error_result( diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 2cca9858c93..80a056a6ea0 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -3104,3 +3104,51 @@ async def test_turn_on_off( ) assert len(off_calls) == 1 assert off_calls[0].data.get("entity_id") == [entity_id] + + +@pytest.mark.parametrize( + ("error_code", "return_response"), + [ + (intent.IntentResponseErrorCode.NO_INTENT_MATCH, False), + (intent.IntentResponseErrorCode.NO_VALID_TARGETS, False), + (intent.IntentResponseErrorCode.FAILED_TO_HANDLE, True), + (intent.IntentResponseErrorCode.UNKNOWN, True), + ], +) +@pytest.mark.usefixtures("init_components") +async def test_handle_intents_with_response_errors( + hass: HomeAssistant, + init_components: None, + area_registry: ar.AreaRegistry, + error_code: intent.IntentResponseErrorCode, + return_response: bool, +) -> None: + """Test that handle_intents does not return response errors.""" + assert await async_setup_component(hass, "climate", {}) + area_registry.async_create("living room") + + agent: default_agent.DefaultAgent = hass.data[DATA_DEFAULT_ENTITY] + + user_input = ConversationInput( + text="What is the temperature in the living room?", + context=Context(), + conversation_id=None, + device_id=None, + language=hass.config.language, + agent_id=None, + ) + + with patch( + "homeassistant.components.conversation.default_agent.DefaultAgent._async_process_intent_result", + return_value=default_agent._make_error_result( + user_input.language, error_code, "Mock error message" + ), + ) as mock_process: + response = await agent.async_handle_intents(user_input) + + assert len(mock_process.mock_calls) == 1 + + if return_response: + assert response is not None and response.error_code == error_code + else: + assert response is None From 9e37c0dc8f96a402b5880cff28b8ccb0622c2715 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 20 Jan 2025 08:12:42 +0100 Subject: [PATCH 0675/2987] Add diagnostics platform to IronOS integration (#136040) --- .../components/iron_os/diagnostics.py | 25 ++++++++++++++++ .../components/iron_os/quality_scale.yaml | 2 +- .../iron_os/snapshots/test_diagnostics.ambr | 18 ++++++++++++ tests/components/iron_os/test_diagnostics.py | 29 +++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/iron_os/diagnostics.py create mode 100644 tests/components/iron_os/snapshots/test_diagnostics.ambr create mode 100644 tests/components/iron_os/test_diagnostics.py diff --git a/homeassistant/components/iron_os/diagnostics.py b/homeassistant/components/iron_os/diagnostics.py new file mode 100644 index 00000000000..e9545c24dec --- /dev/null +++ b/homeassistant/components/iron_os/diagnostics.py @@ -0,0 +1,25 @@ +"""Diagnostics platform for IronOS integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant + +from . import IronOSConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: IronOSConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "config_entry_data": { + CONF_ADDRESS: config_entry.unique_id, + }, + "device_info": config_entry.runtime_data.live_data.device_info, + "live_data": config_entry.runtime_data.live_data.data, + "settings_data": config_entry.runtime_data.settings.data, + } diff --git a/homeassistant/components/iron_os/quality_scale.yaml b/homeassistant/components/iron_os/quality_scale.yaml index 99fe33c4475..c80b8b5adfe 100644 --- a/homeassistant/components/iron_os/quality_scale.yaml +++ b/homeassistant/components/iron_os/quality_scale.yaml @@ -43,7 +43,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: Device is not connected to an ip network. Other information from discovery is immutable and does not require updating. diff --git a/tests/components/iron_os/snapshots/test_diagnostics.ambr b/tests/components/iron_os/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f8db1262254 --- /dev/null +++ b/tests/components/iron_os/snapshots/test_diagnostics.ambr @@ -0,0 +1,18 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry_data': dict({ + 'address': 'c0:ff:ee:c0:ff:ee', + }), + 'device_info': dict({ + '__type': "", + 'repr': "DeviceInfoResponse(build='v2.22', device_id='c0ffeeC0', address='c0:ff:ee:c0:ff:ee', device_sn='0000c0ffeec0ffee', name='Pinecil-C0FFEEE', is_synced=False)", + }), + 'live_data': dict({ + '__type': "", + 'repr': 'LiveDataResponse(live_temp=298, setpoint_temp=300, dc_voltage=20.6, handle_temp=36.3, pwm_level=41, power_src=, tip_resistance=6.2, uptime=1671, movement_time=10000, max_tip_temp_ability=460, tip_voltage=2212, hall_sensor=0, operating_mode=, estimated_power=24.8)', + }), + 'settings_data': dict({ + }), + }) +# --- diff --git a/tests/components/iron_os/test_diagnostics.py b/tests/components/iron_os/test_diagnostics.py new file mode 100644 index 00000000000..05f627e6bc6 --- /dev/null +++ b/tests/components/iron_os/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for IronOS diagnostics.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "mock_pynecil", "ble_device" +) +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From ff80a7c5bccc9282d91e67566e14ec4a957b8bd1 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 20 Jan 2025 09:25:45 +0100 Subject: [PATCH 0676/2987] Add reconfiguration flow to Habitica (#136038) --- .../components/habitica/config_flow.py | 60 +++++++++++++ homeassistant/components/habitica/const.py | 1 + .../components/habitica/quality_scale.yaml | 2 +- .../components/habitica/strings.json | 27 +++++- tests/components/habitica/test_config_flow.py | 84 +++++++++++++++++++ 5 files changed, 172 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py index 0c7ce1fdfdb..7a7f369cb09 100644 --- a/homeassistant/components/habitica/config_flow.py +++ b/homeassistant/components/habitica/config_flow.py @@ -40,6 +40,7 @@ from .const import ( DOMAIN, FORGOT_PASSWORD_URL, HABITICANS_URL, + SECTION_DANGER_ZONE, SECTION_REAUTH_API_KEY, SECTION_REAUTH_LOGIN, SIGN_UP_URL, @@ -105,6 +106,21 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema( } ) +STEP_RECONF_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required(SECTION_DANGER_ZONE): data_entry_flow.section( + vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_VERIFY_SSL): bool, + }, + ), + {"collapsed": True}, + ), + } +) + _LOGGER = logging.getLogger(__name__) @@ -260,6 +276,50 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconf_entry = self._get_reconfigure_entry() + suggested_values = { + CONF_API_KEY: reconf_entry.data[CONF_API_KEY], + SECTION_DANGER_ZONE: { + CONF_URL: reconf_entry.data[CONF_URL], + CONF_VERIFY_SSL: reconf_entry.data.get(CONF_VERIFY_SSL, True), + }, + } + + if user_input: + errors, user = await self.validate_api_key( + { + **reconf_entry.data, + **user_input, + **user_input[SECTION_DANGER_ZONE], + } + ) + if not errors and user is not None: + return self.async_update_reload_and_abort( + reconf_entry, + data_updates={ + CONF_API_KEY: user_input[CONF_API_KEY], + **user_input[SECTION_DANGER_ZONE], + }, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_RECONF_DATA_SCHEMA, + suggested_values=user_input or suggested_values, + ), + errors=errors, + description_placeholders={ + "site_data": SITE_DATA_URL, + "habiticans": HABITICANS_URL, + }, + ) + async def validate_login( self, user_input: Mapping[str, Any] ) -> tuple[dict[str, str], LoginData | None, UserData | None]: diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 47191e92775..5eb616142e5 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -56,3 +56,4 @@ X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" SECTION_REAUTH_LOGIN = "reauth_login" SECTION_REAUTH_API_KEY = "reauth_api_key" +SECTION_DANGER_ZONE = "danger_zone" diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml index c4ad2c76110..9eadba496f2 100644 --- a/homeassistant/components/habitica/quality_scale.yaml +++ b/homeassistant/components/habitica/quality_scale.yaml @@ -66,7 +66,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: done comment: Used to inform of deprecated entities and actions. diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 44487e7cb37..4d353cec40e 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -13,7 +13,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "unique_id_mismatch": "Hmm, those login details are correct, but they're not for this adventurer. Got another account to try?", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -85,6 +86,30 @@ } } } + }, + "reconfigure": { + "title": "Update Habitica configuration", + "description": "![Habiticans]({habiticans})\n\nEnter your new API token below. You can find it in Habitica under [**Settings -> Site Data**]({site_data})", + "data": { + "api_key": "[%key:component::habitica::config::step::advanced::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::habitica::config::step::advanced::data_description::api_key%]" + }, + "sections": { + "danger_zone": { + "name": "Critical configuration options", + "description": "These settings impact core functionality. Modifications are unnecessary if connected to the official Habitica instance and may disrupt the integration. Proceed with caution.", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "url": "URL of the Habitica instance", + "verify_ssl": "[%key:component::habitica::config::step::advanced::data_description::verify_ssl%]" + } + } + } } } }, diff --git a/tests/components/habitica/test_config_flow.py b/tests/components/habitica/test_config_flow.py index bacd1ee3ac9..07678b031bc 100644 --- a/tests/components/habitica/test_config_flow.py +++ b/tests/components/habitica/test_config_flow.py @@ -9,6 +9,7 @@ from homeassistant.components.habitica.const import ( CONF_API_USER, DEFAULT_URL, DOMAIN, + SECTION_DANGER_ZONE, SECTION_REAUTH_API_KEY, SECTION_REAUTH_LOGIN, ) @@ -54,6 +55,13 @@ USER_INPUT_REAUTH_API_KEY = { SECTION_REAUTH_LOGIN: {}, SECTION_REAUTH_API_KEY: {CONF_API_KEY: "cd0e5985-17de-4b4f-849e-5d506c5e4382"}, } +USER_INPUT_RECONFIGURE = { + CONF_API_KEY: "cd0e5985-17de-4b4f-849e-5d506c5e4382", + SECTION_DANGER_ZONE: { + CONF_URL: DEFAULT_URL, + CONF_VERIFY_SSL: True, + }, +} @pytest.mark.usefixtures("habitica") @@ -449,3 +457,79 @@ async def test_flow_reauth_unique_id_mismatch(hass: HomeAssistant) -> None: assert result["reason"] == "unique_id_mismatch" assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("habitica") +async def test_flow_reconfigure( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_RECONFIGURE, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" + assert config_entry.data[CONF_URL] == DEFAULT_URL + assert config_entry.data[CONF_VERIFY_SSL] is True + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (ERROR_NOT_AUTHORIZED, "invalid_auth"), + (ERROR_BAD_REQUEST, "cannot_connect"), + (KeyError, "unknown"), + ], +) +async def test_flow_reconfigure_errors( + hass: HomeAssistant, + habitica: AsyncMock, + config_entry: MockConfigEntry, + raise_error: Exception, + text_error: str, +) -> None: + """Test reconfigure flow errors.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + habitica.get_user.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_RECONFIGURE, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + habitica.get_user.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT_RECONFIGURE, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" + assert config_entry.data[CONF_URL] == DEFAULT_URL + assert config_entry.data[CONF_VERIFY_SSL] is True + + assert len(hass.config_entries.async_entries()) == 1 From 877e44e3c9016acc712f1215a294c5314c89c3a8 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 20 Jan 2025 09:37:32 +0100 Subject: [PATCH 0677/2987] Remove redundant device update code (#134100) Remove redundant device update steps --- homeassistant/helpers/device_registry.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 981430f192d..2890f607d59 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -958,16 +958,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new_values["config_entries"] = config_entries old_values["config_entries"] = old.config_entries - for attr_name, setvalue in ( - ("connections", merge_connections), - ("identifiers", merge_identifiers), - ): - old_value = getattr(old, attr_name) - # If not undefined, check if `value` contains new items. - if setvalue is not UNDEFINED and not setvalue.issubset(old_value): - new_values[attr_name] = old_value | setvalue - old_values[attr_name] = old_value - if merge_connections is not UNDEFINED: normalized_connections = self._validate_connections( device_id, From f7f6c1163dd4c93a83eeaf8dfbcd12625b079670 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Jan 2025 11:40:00 +0100 Subject: [PATCH 0678/2987] Use new SsdpServiceInfo location in remaining components (#136053) --- homeassistant/components/dlna_dmr/media_player.py | 3 ++- homeassistant/components/dlna_dms/dms.py | 3 ++- homeassistant/components/sonos/__init__.py | 5 +++-- homeassistant/components/upnp/__init__.py | 5 +++-- homeassistant/components/yeelight/scanner.py | 5 +++-- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 443c2101302..563ed209b7d 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -33,6 +33,7 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_MAC, CONF_TYPE, CONF_URL from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( CONF_BROWSE_UNFILTERED, @@ -246,7 +247,7 @@ class DlnaDmrEntity(MediaPlayerEntity): await self._device_disconnect() async def async_ssdp_callback( - self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange + self, info: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: """Handle notification from SSDP of device state change.""" _LOGGER.debug( diff --git a/homeassistant/components/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index 8f475d53280..1d0b27696f7 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -29,6 +29,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( CONF_SOURCE_ID, @@ -220,7 +221,7 @@ class DmsDeviceSource: await self.device_disconnect() async def async_ssdp_callback( - self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange + self, info: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: """Handle notification from SSDP of device state change.""" LOGGER.debug( diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 82e4a5ebfba..98bff8d2934 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -34,6 +34,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import create_eager_task @@ -500,7 +501,7 @@ class SonosDiscoveryManager: @callback def _async_ssdp_discovered_player( - self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange + self, info: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: uid = info.upnp[ssdp.ATTR_UPNP_UDN] if not uid.startswith("uuid:RINCON_"): @@ -529,7 +530,7 @@ class SonosDiscoveryManager: def async_discovered_player( self, source: str, - info: ssdp.SsdpServiceInfo, + info: SsdpServiceInfo, discovered_ip: str, uid: str, boot_seqnum: str | int | None, diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 214521ee9c0..aacb7538b61 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -13,6 +13,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( CONFIG_ENTRY_FORCE_POLL, @@ -49,10 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool # Register device discovered-callback. device_discovered_event = asyncio.Event() - discovery_info: ssdp.SsdpServiceInfo | None = None + discovery_info: SsdpServiceInfo | None = None async def device_discovered( - headers: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange + headers: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: if change == ssdp.SsdpChange.BYEBYE: return diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py index 7e908396ff3..75156ab019b 100644 --- a/homeassistant/components/yeelight/scanner.py +++ b/homeassistant/components/yeelight/scanner.py @@ -16,10 +16,11 @@ from async_upnp_client.search import SsdpSearchListener from async_upnp_client.utils import CaseInsensitiveDict from homeassistant import config_entries -from homeassistant.components import network, ssdp +from homeassistant.components import network from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import discovery_flow from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.util.async_ import create_eager_task from .const import ( @@ -171,7 +172,7 @@ class YeelightScanner: self._hass, DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="", ssdp_st=SSDP_ST, ssdp_headers=response, From e27a2595411ad161e530cb14a6f9c228eb507ee5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 20 Jan 2025 12:50:15 +0100 Subject: [PATCH 0679/2987] Bump yt-dlp to 2025.01.15 (#136072) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 144904fe58c..becca8e6da8 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2024.12.23"], + "requirements": ["yt-dlp[default]==2025.01.15"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 9b8e16d3052..b00e87f1c7d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3106,7 +3106,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.12.23 +yt-dlp[default]==2025.01.15 # homeassistant.components.zabbix zabbix-utils==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 48b1e15e656..1399401da56 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2501,7 +2501,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.12.23 +yt-dlp[default]==2025.01.15 # homeassistant.components.zamg zamg==0.3.6 From 9e40b7f7f45909913090ace2ce4a6b27f4244f3b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 20 Jan 2025 12:50:53 +0100 Subject: [PATCH 0680/2987] Fix casing of "client" and "ID" in transmission integration (#136071) --- homeassistant/components/transmission/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index aabc5827a88..0fe1953d31e 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up Transmission Client", + "title": "Set up Transmission client", "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", @@ -96,7 +96,7 @@ "fields": { "entry_id": { "name": "Transmission entry", - "description": "Config entry id." + "description": "ID of the config entry to use." }, "torrent": { "name": "Torrent", From 8020bec47bff5c6e5f08eb1e4dafaeaa85238bd9 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 20 Jan 2025 12:55:09 +0100 Subject: [PATCH 0681/2987] Bump deebot-client to 11.0.0 (#136073) --- 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 67d18c4784c..157d5b4a5ea 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==10.1.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==11.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b00e87f1c7d..bfa166541b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -744,7 +744,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==10.1.0 +deebot-client==11.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1399401da56..3977b191d72 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -634,7 +634,7 @@ dbus-fast==2.30.2 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==10.1.0 +deebot-client==11.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 43da828a5186f802410d07bd6ac99e48bd69d50a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Jan 2025 12:57:46 +0100 Subject: [PATCH 0682/2987] Make the time for automated backups configurable (#135825) * Make the time for automated backups configurable * Store time as a string, use None to indicate default time * Don't add jitter if the time is set by user * Include time of next automatic backup in response to backup/info * Update tests * Rename recurrence to state * Include scheduled backup time in backup/config/info response * Address review comments * Update cloud test * Add test for store migration * Address review comments --- homeassistant/components/backup/config.py | 65 ++++- homeassistant/components/backup/store.py | 37 ++- homeassistant/components/backup/websocket.py | 14 +- .../backup/snapshots/test_backup.ambr | 5 + .../backup/snapshots/test_store.ambr | 40 +++ .../backup/snapshots/test_websocket.ambr | 254 ++++++++++++++++- tests/components/backup/test_manager.py | 12 + tests/components/backup/test_store.py | 54 ++++ tests/components/backup/test_websocket.py | 269 ++++++++++++------ tests/components/cloud/test_backup.py | 1 + 10 files changed, 629 insertions(+), 122 deletions(-) create mode 100644 tests/components/backup/snapshots/test_store.ambr create mode 100644 tests/components/backup/test_store.py diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 7c40792aec5..997813eca21 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable from dataclasses import dataclass, field, replace +import datetime as dt from datetime import datetime, timedelta from enum import StrEnum import random @@ -23,11 +24,13 @@ from .models import BackupManagerError, Folder if TYPE_CHECKING: from .manager import BackupManager, ManagerBackup -# The time of the automatic backup event should be compatible with -# the time of the recorder's nightly job which runs at 04:12. -# Run the backup at 04:45. -CRON_PATTERN_DAILY = "45 4 * * *" -CRON_PATTERN_WEEKLY = "45 4 * * {}" +CRON_PATTERN_DAILY = "{m} {h} * * *" +CRON_PATTERN_WEEKLY = "{m} {h} * * {d}" + +# The default time for automatic backups to run is at 04:45. +# This time is chosen to be compatible with the time of the recorder's +# nightly job which runs at 04:12. +DEFAULT_BACKUP_TIME = dt.time(4, 45) # Randomize the start time of the backup by up to 60 minutes to avoid # all backups running at the same time. @@ -74,6 +77,11 @@ class BackupConfigData: else: last_completed = None + if time_str := data["schedule"]["time"]: + time = dt_util.parse_time(time_str) + else: + time = None + return cls( create_backup=CreateBackupConfig( agent_ids=data["create_backup"]["agent_ids"], @@ -90,7 +98,9 @@ class BackupConfigData: copies=retention["copies"], days=retention["days"], ), - schedule=BackupSchedule(state=ScheduleState(data["schedule"]["state"])), + schedule=BackupSchedule( + state=ScheduleState(data["schedule"]["state"]), time=time + ), ) def to_dict(self) -> StoredBackupConfig: @@ -137,7 +147,7 @@ class BackupConfig: *, create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED, retention: RetentionParametersDict | UndefinedType = UNDEFINED, - schedule: ScheduleState | UndefinedType = UNDEFINED, + schedule: ScheduleParametersDict | UndefinedType = UNDEFINED, ) -> None: """Update config.""" if create_backup is not UNDEFINED: @@ -148,7 +158,7 @@ class BackupConfig: self.data.retention = new_retention self.data.retention.apply(self._manager) if schedule is not UNDEFINED: - new_schedule = BackupSchedule(state=schedule) + new_schedule = BackupSchedule(**schedule) if new_schedule.to_dict() != self.data.schedule.to_dict(): self.data.schedule = new_schedule self.data.schedule.apply(self._manager) @@ -243,10 +253,18 @@ class StoredBackupSchedule(TypedDict): """Represent the stored backup schedule configuration.""" state: ScheduleState + time: str | None + + +class ScheduleParametersDict(TypedDict, total=False): + """Represent parameters for backup schedule.""" + + state: ScheduleState + time: dt.time | None class ScheduleState(StrEnum): - """Represent the schedule state.""" + """Represent the schedule recurrence.""" NEVER = "never" DAILY = "daily" @@ -264,7 +282,9 @@ class BackupSchedule: """Represent the backup schedule.""" state: ScheduleState = ScheduleState.NEVER + time: dt.time | None = None cron_event: CronSim | None = field(init=False, default=None) + next_automatic_backup: datetime | None = field(init=False, default=None) @callback def apply( @@ -279,11 +299,17 @@ class BackupSchedule: self._unschedule_next(manager) return + time = self.time if self.time is not None else DEFAULT_BACKUP_TIME if self.state is ScheduleState.DAILY: - self._schedule_next(CRON_PATTERN_DAILY, manager) + self._schedule_next( + CRON_PATTERN_DAILY.format(m=time.minute, h=time.hour), + manager, + ) else: self._schedule_next( - CRON_PATTERN_WEEKLY.format(self.state.value), + CRON_PATTERN_WEEKLY.format( + m=time.minute, h=time.hour, d=self.state.value + ), manager, ) @@ -304,7 +330,10 @@ class BackupSchedule: if next_time < now: # schedule a backup at next daily time once # if we missed the last scheduled backup - cron_event = CronSim(CRON_PATTERN_DAILY, now) + time = self.time if self.time is not None else DEFAULT_BACKUP_TIME + cron_event = CronSim( + CRON_PATTERN_DAILY.format(m=time.minute, h=time.hour), now + ) next_time = next(cron_event) # reseed the cron event attribute # add a day to the next time to avoid scheduling at the same time again @@ -334,19 +363,27 @@ class BackupSchedule: except Exception: # noqa: BLE001 LOGGER.exception("Unexpected error creating automatic backup") - next_time += timedelta(seconds=random.randint(0, BACKUP_START_TIME_JITTER)) + if self.time is None: + # randomize the start time of the backup by up to 60 minutes if the time is + # not set to avoid all backups running at the same time + next_time += timedelta(seconds=random.randint(0, BACKUP_START_TIME_JITTER)) LOGGER.debug("Scheduling next automatic backup at %s", next_time) + self.next_automatic_backup = next_time manager.remove_next_backup_event = async_track_point_in_time( manager.hass, _create_backup, next_time ) def to_dict(self) -> StoredBackupSchedule: """Convert backup schedule to a dict.""" - return StoredBackupSchedule(state=self.state) + return StoredBackupSchedule( + state=self.state, + time=self.time.isoformat() if self.time else None, + ) @callback def _unschedule_next(self, manager: BackupManager) -> None: """Unschedule the next backup.""" + self.next_automatic_backup = None if (remove_next_event := manager.remove_next_backup_event) is not None: remove_next_event() manager.remove_next_backup_event = None diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index ddabead24f9..205bdf80375 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING, Any, TypedDict from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store @@ -16,6 +16,7 @@ if TYPE_CHECKING: STORE_DELAY_SAVE = 30 STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 +STORAGE_VERSION_MINOR = 2 class StoredBackupData(TypedDict): @@ -25,14 +26,44 @@ class StoredBackupData(TypedDict): config: StoredBackupConfig +class _BackupStore(Store[StoredBackupData]): + """Class to help storing backup data.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize storage class.""" + super().__init__( + hass, + STORAGE_VERSION, + STORAGE_KEY, + minor_version=STORAGE_VERSION_MINOR, + ) + + async def _async_migrate_func( + self, + old_major_version: int, + old_minor_version: int, + old_data: dict[str, Any], + ) -> dict[str, Any]: + """Migrate to the new version.""" + data = old_data + if old_major_version == 1: + if old_minor_version < 2: + # Version 1.2 adds configurable backup time + data["config"]["schedule"]["time"] = None + + if old_major_version > 1: + raise NotImplementedError + return data + + class BackupStore: """Store backup config.""" def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None: - """Initialize the backup manager.""" + """Initialize the backup store.""" self._hass = hass self._manager = manager - self._store: Store[StoredBackupData] = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._store = _BackupStore(hass) async def load(self) -> StoredBackupData | None: """Load the store.""" diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 1b8433e2f24..235d53952c1 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -6,6 +6,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from .config import ScheduleState from .const import DATA_MANAGER, LOGGER @@ -59,6 +60,7 @@ async def handle_info( "backups": [backup.as_frontend_json() for backup in backups.values()], "last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup, "last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup, + "next_automatic_backup": manager.config.data.schedule.next_automatic_backup, }, ) @@ -321,7 +323,10 @@ async def handle_config_info( connection.send_result( msg["id"], { - "config": manager.config.data.to_dict(), + "config": manager.config.data.to_dict() + | { + "next_automatic_backup": manager.config.data.schedule.next_automatic_backup + }, }, ) @@ -351,7 +356,12 @@ async def handle_config_info( vol.Optional("days"): vol.Any(int, None), }, ), - vol.Optional("schedule"): vol.All(str, vol.Coerce(ScheduleState)), + vol.Optional("schedule"): vol.Schema( + { + vol.Optional("state"): vol.All(str, vol.Coerce(ScheduleState)), + vol.Optional("time"): vol.Any(cv.time, None), + } + ), } ) @websocket_api.async_response diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index f21de9d9fad..f1208877690 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -83,6 +83,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -112,6 +113,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -141,6 +143,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -170,6 +173,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -199,6 +203,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr new file mode 100644 index 00000000000..fb5d0c276b5 --- /dev/null +++ b/tests/components/backup/snapshots/test_store.ambr @@ -0,0 +1,40 @@ +# serializer version: 1 +# name: test_store_migration + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 06bfa89369a..8b0ab1317c3 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -244,12 +244,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -279,12 +281,14 @@ }), 'last_attempted_automatic_backup': '2024-10-26T04:45:00+01:00', 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00', + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': 3, 'days': 7, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -310,12 +314,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': 3, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -341,12 +347,14 @@ }), 'last_attempted_automatic_backup': '2024-10-27T04:45:00+01:00', 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00', + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': 7, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -372,12 +380,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-18T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'mon', + 'time': None, }), }), }), @@ -403,12 +413,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-16T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'sat', + 'time': None, }), }), }), @@ -433,12 +445,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -464,12 +478,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': 7, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -502,11 +518,12 @@ }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -527,12 +544,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -558,12 +577,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': 7, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -596,11 +617,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -621,12 +643,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -652,12 +676,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T06:00:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'daily', + 'time': '06:00:00', }), }), }), @@ -690,11 +716,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': '06:00:00', }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -715,12 +742,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -746,12 +775,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-18T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'mon', + 'time': None, }), }), }), @@ -784,11 +815,12 @@ }), 'schedule': dict({ 'state': 'mon', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -809,12 +841,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -840,12 +874,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -878,11 +914,12 @@ }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -903,12 +940,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -938,12 +977,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -980,11 +1021,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -1005,12 +1047,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1036,12 +1080,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': 3, 'days': 7, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -1074,11 +1120,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -1099,12 +1146,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1130,12 +1179,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -1168,11 +1219,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -1193,12 +1245,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1224,12 +1278,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': 3, 'days': None, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -1262,11 +1318,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -1287,12 +1344,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1318,12 +1377,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': 7, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -1356,11 +1417,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -1381,12 +1443,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1412,12 +1476,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': 3, 'days': None, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -1450,11 +1516,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -1475,12 +1542,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1505,12 +1574,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1535,12 +1606,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1565,12 +1638,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1595,12 +1670,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1625,12 +1702,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1655,12 +1734,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1685,12 +1766,142 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command4] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command4].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command5] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command5].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + 'time': None, }), }), }), @@ -1708,6 +1919,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1734,6 +1946,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1776,6 +1989,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1802,6 +2016,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1844,6 +2059,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1897,6 +2113,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1934,6 +2151,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1982,6 +2200,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2025,6 +2244,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2078,6 +2298,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2132,6 +2353,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2187,6 +2409,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2240,6 +2463,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2293,6 +2517,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2346,6 +2571,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2400,6 +2626,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2844,6 +3071,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2886,6 +3114,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2929,6 +3158,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2993,6 +3223,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -3036,6 +3267,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index eef9e069e0f..224f87bea47 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -274,6 +274,7 @@ async def test_async_initiate_backup( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -519,6 +520,7 @@ async def test_async_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id( @@ -613,6 +615,7 @@ async def test_async_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await hass.async_block_till_done() @@ -880,6 +883,7 @@ async def test_async_initiate_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -990,6 +994,7 @@ async def test_async_initiate_backup_with_task_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1094,6 +1099,7 @@ async def test_initiate_backup_file_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1614,6 +1620,7 @@ async def test_receive_backup_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id( @@ -1691,6 +1698,7 @@ async def test_receive_backup_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await hass.async_block_till_done() @@ -1751,6 +1759,7 @@ async def test_receive_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1871,6 +1880,7 @@ async def test_receive_backup_file_write_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1979,6 +1989,7 @@ async def test_receive_backup_read_tar_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -2146,6 +2157,7 @@ async def test_receive_backup_file_read_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py new file mode 100644 index 00000000000..d240e21531d --- /dev/null +++ b/tests/components/backup/test_store.py @@ -0,0 +1,54 @@ +"""Tests for the Backup integration.""" + +from typing import Any + +from syrupy import SnapshotAssertion + +from homeassistant.components.backup.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .common import setup_backup_integration + + +async def test_store_migration( + hass: HomeAssistant, + hass_storage: dict[str, Any], + snapshot: SnapshotAssertion, +) -> None: + """Test migrating the backup store.""" + hass_storage[DOMAIN] = { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "state": "never", + }, + }, + }, + "key": DOMAIN, + "version": 1, + } + await setup_backup_integration(hass) + await hass.async_block_till_done() + + assert hass_storage[DOMAIN] == snapshot diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 7498fbe2a67..29ce4dc485e 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -14,6 +14,7 @@ from homeassistant.components.backup import ( BackupAgentPlatformProtocol, BackupReaderWriterError, Folder, + store, ) from homeassistant.components.backup.agent import BackupAgentUnreachableError from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN @@ -70,9 +71,7 @@ DEFAULT_STORAGE_DATA: dict[str, Any] = { "copies": None, "days": None, }, - "schedule": { - "state": "never", - }, + "schedule": {"state": "never", "time": None}, }, } @@ -305,7 +304,8 @@ async def test_delete_with_errors( hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "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]} @@ -924,11 +924,12 @@ async def test_agents_info( "retention": {"copies": 3, "days": 7}, "last_attempted_automatic_backup": "2024-10-26T04:45:00+01:00", "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", - "schedule": {"state": "daily"}, + "schedule": {"state": "daily", "time": None}, }, }, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, }, }, { @@ -948,11 +949,12 @@ async def test_agents_info( "retention": {"copies": 3, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "schedule": {"state": "never"}, + "schedule": {"state": "never", "time": None}, }, }, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, }, }, { @@ -972,11 +974,12 @@ async def test_agents_info( "retention": {"copies": None, "days": 7}, "last_attempted_automatic_backup": "2024-10-27T04:45:00+01:00", "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", - "schedule": {"state": "never"}, + "schedule": {"state": "never", "time": None}, }, }, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, }, }, { @@ -996,11 +999,12 @@ async def test_agents_info( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "schedule": {"state": "mon"}, + "schedule": {"state": "mon", "time": None}, }, }, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, }, }, { @@ -1020,30 +1024,35 @@ async def test_agents_info( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "schedule": {"state": "sat"}, + "schedule": {"state": "sat", "time": None}, }, }, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, }, }, ], ) +@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) async def test_config_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, hass_storage: dict[str, Any], storage_data: dict[str, Any] | None, ) -> None: """Test getting backup config info.""" + 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") + hass_storage.update(storage_data) await setup_backup_integration(hass) await hass.async_block_till_done() - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/config/info"}) assert await client.receive_json() == snapshot @@ -1060,17 +1069,17 @@ async def test_config_info( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": "daily", + "schedule": {"state": "daily", "time": "06:00"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": "mon", + "schedule": {"state": "mon"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": "never", + "schedule": {"state": "never"}, }, { "type": "backup/config/update", @@ -1081,59 +1090,63 @@ async def test_config_info( "name": "test-name", "password": "test-password", }, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": 3, "days": 7}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 7}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": 3}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"days": 7}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, ], ) +@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) async def test_config_update( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, command: dict[str, Any], hass_storage: dict[str, Any], ) -> None: """Test updating the backup config.""" + 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") + await setup_backup_integration(hass) await hass.async_block_till_done() - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/config/info"}) assert await client.receive_json() == snapshot @@ -1146,6 +1159,11 @@ async def test_config_update( assert await client.receive_json() == snapshot await hass.async_block_till_done() + # Trigger store write + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass_storage[DOMAIN] == snapshot @@ -1156,7 +1174,17 @@ async def test_config_update( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": "someday", + "schedule": "blah", + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"state": "someday"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"time": "early"}, }, { "type": "backup/config/update", @@ -1205,6 +1233,7 @@ async def test_config_update_errors( "time_2", "attempted_backup_time", "completed_backup_time", + "scheduled_backup_time", "backup_calls_1", "backup_calls_2", "call_args", @@ -1215,10 +1244,11 @@ async def test_config_update_errors( # No config update [], "2024-11-11T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", 1, 2, BACKUP_CALL, @@ -1230,14 +1260,15 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", + "schedule": {"state": "daily"}, } ], "2024-11-11T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", 1, 2, BACKUP_CALL, @@ -1248,14 +1279,15 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "mon", + "schedule": {"state": "mon"}, } ], "2024-11-11T04:45:00+01:00", - "2024-11-18T04:45:00+01:00", - "2024-11-25T04:45:00+01:00", - "2024-11-18T04:45:00+01:00", - "2024-11-18T04:45:00+01:00", + "2024-11-18T04:55:00+01:00", + "2024-11-25T04:55:00+01:00", + "2024-11-18T04:55:00+01:00", + "2024-11-18T04:55:00+01:00", + "2024-11-18T04:55:00+01:00", 1, 2, BACKUP_CALL, @@ -1266,7 +1298,45 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "never", + "schedule": {"state": "mon", "time": "03:45"}, + } + ], + "2024-11-11T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + "2024-11-25T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + 1, + 2, + BACKUP_CALL, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"state": "daily", "time": "03:45"}, + } + ], + "2024-11-11T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + "2024-11-13T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + 1, + 2, + BACKUP_CALL, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"state": "never"}, } ], "2024-11-11T04:45:00+01:00", @@ -1274,6 +1344,7 @@ async def test_config_update_errors( "2034-11-11T13:00:00+01:00", "2024-11-11T04:45:00+01:00", "2024-11-11T04:45:00+01:00", + None, 0, 0, None, @@ -1284,14 +1355,15 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", + "schedule": {"state": "daily"}, } ], "2024-10-26T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", 1, 2, BACKUP_CALL, @@ -1302,14 +1374,15 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "mon", + "schedule": {"state": "mon"}, } ], "2024-10-26T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", # missed event uses daily schedule once - "2024-11-12T04:45:00+01:00", # missed event uses daily schedule once + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", # missed event uses daily schedule once + "2024-11-12T04:55:00+01:00", # missed event uses daily schedule once + "2024-11-12T04:55:00+01:00", 1, 1, BACKUP_CALL, @@ -1320,7 +1393,7 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "never", + "schedule": {"state": "never"}, } ], "2024-10-26T04:45:00+01:00", @@ -1328,6 +1401,7 @@ async def test_config_update_errors( "2034-11-12T12:00:00+01:00", "2024-10-26T04:45:00+01:00", "2024-10-26T04:45:00+01:00", + None, 0, 0, None, @@ -1338,14 +1412,15 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", + "schedule": {"state": "daily"}, } ], "2024-11-11T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", # attempted to create backup but failed + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", # attempted to create backup but failed "2024-11-11T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", 1, 2, BACKUP_CALL, @@ -1356,14 +1431,15 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", + "schedule": {"state": "daily"}, } ], "2024-11-11T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", # attempted to create backup but failed + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", # attempted to create backup but failed "2024-11-11T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", 1, 2, BACKUP_CALL, @@ -1371,7 +1447,7 @@ async def test_config_update_errors( ), ], ) -@patch("homeassistant.components.backup.config.BACKUP_START_TIME_JITTER", 0) +@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) async def test_config_schedule_logic( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1384,6 +1460,7 @@ async def test_config_schedule_logic( time_2: str, attempted_backup_time: str, completed_backup_time: str, + scheduled_backup_time: str, backup_calls_1: int, backup_calls_2: int, call_args: Any, @@ -1406,13 +1483,14 @@ async def test_config_schedule_logic( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": last_completed_automatic_backup, "last_completed_automatic_backup": last_completed_automatic_backup, - "schedule": {"state": "daily"}, + "schedule": {"state": "daily", "time": None}, }, } hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } create_backup.side_effect = create_backup_side_effect await hass.config.async_set_time_zone("Europe/Amsterdam") @@ -1426,6 +1504,10 @@ async def test_config_schedule_logic( result = await client.receive_json() assert result["success"] + await client.send_json_auto_id({"type": "backup/info"}) + result = await client.receive_json() + assert result["result"]["next_automatic_backup"] == scheduled_backup_time + freezer.move_to(time_1) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -1471,7 +1553,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": None, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1510,7 +1592,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1549,7 +1631,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1578,7 +1660,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1622,7 +1704,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1666,7 +1748,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1705,7 +1787,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1744,7 +1826,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 0, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1788,7 +1870,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 0, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1852,13 +1934,14 @@ async def test_config_retention_copies_logic( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": last_backup_time, - "schedule": {"state": "daily"}, + "schedule": {"state": "daily", "time": None}, }, } hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) delete_backup.return_value = delete_backup_agent_errors @@ -1922,7 +2005,7 @@ async def test_config_retention_copies_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": None, "days": None}, - "schedule": "never", + "schedule": {"state": "never"}, }, { "backup-1": MagicMock( @@ -1958,7 +2041,7 @@ async def test_config_retention_copies_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "never", + "schedule": {"state": "never"}, }, { "backup-1": MagicMock( @@ -1994,7 +2077,7 @@ async def test_config_retention_copies_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "never", + "schedule": {"state": "never"}, }, { "backup-1": MagicMock( @@ -2035,7 +2118,7 @@ async def test_config_retention_copies_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": "never", + "schedule": {"state": "never"}, }, { "backup-1": MagicMock( @@ -2109,13 +2192,14 @@ async def test_config_retention_copies_logic_manual_backup( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "schedule": {"state": "daily"}, + "schedule": {"state": "daily", "time": None}, }, } hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) delete_backup.return_value = delete_backup_agent_errors @@ -2236,7 +2320,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2272,7 +2356,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2308,7 +2392,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 3}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2344,7 +2428,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2385,7 +2469,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2421,7 +2505,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2457,7 +2541,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 0}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2529,13 +2613,14 @@ async def test_config_retention_days_logic( "retention": {"copies": None, "days": stored_retained_days}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": last_backup_time, - "schedule": {"state": "never"}, + "schedule": {"state": "never", "time": None}, }, } hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) delete_backup.return_value = delete_backup_agent_errors diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index fc8c7f27e56..112e71ec2db 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -204,6 +204,7 @@ async def test_agents_list_backups_fail_cloud( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } From 760168de832779e5cdbdab34d1485167ee946644 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Jan 2025 12:58:17 +0100 Subject: [PATCH 0683/2987] Allow backup writer to update progress during restore (#135975) * Allow backup writer to update progress during restore * Clarify comment --- homeassistant/components/backup/__init__.py | 2 ++ homeassistant/components/backup/manager.py | 13 ++++++++++--- homeassistant/components/hassio/backup.py | 10 ++++++---- tests/components/backup/test_manager.py | 9 +++++++++ 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index f3fe2246ad1..93cadcfb2f3 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -27,6 +27,7 @@ from .manager import ( IncorrectPasswordError, ManagerBackup, NewBackup, + RestoreBackupEvent, WrittenBackup, ) from .models import AddonInfo, AgentBackup, Folder @@ -47,6 +48,7 @@ __all__ = [ "LocalBackupAgent", "ManagerBackup", "NewBackup", + "RestoreBackupEvent", "WrittenBackup", ] diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 58600d0a4c0..32979194980 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -147,6 +147,7 @@ class RestoreBackupState(StrEnum): """Receive backup state enum.""" COMPLETED = "completed" + CORE_RESTART = "core_restart" FAILED = "failed" IN_PROGRESS = "in_progress" @@ -217,7 +218,7 @@ class BackupReaderWriter(abc.ABC): include_database: bool, include_folders: list[Folder] | None, include_homeassistant: bool, - on_progress: Callable[[ManagerStateEvent], None], + on_progress: Callable[[CreateBackupEvent], None], password: str | None, ) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]: """Create a backup.""" @@ -238,6 +239,7 @@ class BackupReaderWriter(abc.ABC): backup_id: str, *, agent_id: str, + on_progress: Callable[[RestoreBackupEvent], None], open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], password: str | None, restore_addons: list[str] | None, @@ -941,6 +943,7 @@ class BackupManager: backup_id=backup_id, open_stream=open_backup, agent_id=agent_id, + on_progress=self.async_on_backup_event, password=password, restore_addons=restore_addons, restore_database=restore_database, @@ -1130,7 +1133,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): include_database: bool, include_folders: list[Folder] | None, include_homeassistant: bool, - on_progress: Callable[[ManagerStateEvent], None], + on_progress: Callable[[CreateBackupEvent], None], password: str | None, ) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]: """Initiate generating a backup.""" @@ -1170,7 +1173,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): date_str: str, extra_metadata: dict[str, bool | str], include_database: bool, - on_progress: Callable[[ManagerStateEvent], None], + on_progress: Callable[[CreateBackupEvent], None], password: str | None, ) -> WrittenBackup: """Generate a backup.""" @@ -1378,6 +1381,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], *, agent_id: str, + on_progress: Callable[[RestoreBackupEvent], None], password: str | None, restore_addons: list[str] | None, restore_database: bool, @@ -1440,6 +1444,9 @@ class CoreBackupReaderWriter(BackupReaderWriter): ) await self._hass.async_add_executor_job(_write_restore_file) + on_progress( + RestoreBackupEvent(stage=None, state=RestoreBackupState.CORE_RESTART) + ) await self._hass.services.async_call("homeassistant", "restart", blocking=True) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 537588e856a..76196cfe9e8 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -29,6 +29,7 @@ from homeassistant.components.backup import ( Folder, IncorrectPasswordError, NewBackup, + RestoreBackupEvent, WrittenBackup, ) from homeassistant.core import HomeAssistant, callback @@ -275,7 +276,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): backup_id: str | None = None @callback - def on_progress(data: Mapping[str, Any]) -> None: + def on_job_progress(data: Mapping[str, Any]) -> None: """Handle backup progress.""" nonlocal backup_id if data.get("done") is True: @@ -283,7 +284,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): backup_complete.set() try: - unsub = self._async_listen_job_events(backup.job_id, on_progress) + unsub = self._async_listen_job_events(backup.job_id, on_job_progress) await backup_complete.wait() finally: unsub() @@ -374,6 +375,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): backup_id: str, *, agent_id: str, + on_progress: Callable[[RestoreBackupEvent], None], open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], password: str | None, restore_addons: list[str] | None, @@ -437,13 +439,13 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): restore_complete = asyncio.Event() @callback - def on_progress(data: Mapping[str, Any]) -> None: + def on_job_progress(data: Mapping[str, Any]) -> None: """Handle backup progress.""" if data.get("done") is True: restore_complete.set() try: - unsub = self._async_listen_job_events(job.job_id, on_progress) + unsub = self._async_listen_job_events(job.job_id, on_job_progress) await restore_complete.wait() finally: unsub() diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 224f87bea47..4c7eaf634b3 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -2302,6 +2302,15 @@ async def test_restore_backup( "state": RestoreBackupState.IN_PROGRESS, } + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.CORE_RESTART, + } + + # Note: The core restart is not tested here, in reality the following events + # are not sent because the core restart closes the WS connection. result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, From 64500e837faead5fe25cdd40008e6cb1106ab1f7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Jan 2025 13:09:34 +0100 Subject: [PATCH 0684/2987] Use new ServiceInfo location in component tests (part 1) (#136057) --- tests/components/samsungtv/const.py | 6 +-- .../components/samsungtv/test_config_flow.py | 19 ++++---- .../screenlogic/test_config_flow.py | 4 +- tests/components/shelly/test_config_flow.py | 17 ++++--- tests/components/smappee/test_config_flow.py | 18 +++---- tests/components/smlight/test_config_flow.py | 6 +-- .../somfy_mylink/test_config_flow.py | 8 ++-- tests/components/songpal/test_config_flow.py | 12 +++-- tests/components/sonos/conftest.py | 12 +++-- tests/components/sonos/test_config_flow.py | 15 +++--- tests/components/sonos/test_init.py | 5 +- tests/components/spotify/test_config_flow.py | 4 +- .../components/squeezebox/test_config_flow.py | 8 ++-- tests/components/ssdp/test_init.py | 48 +++++++++---------- tests/components/steamist/test_config_flow.py | 6 +-- tests/components/syncthru/test_config_flow.py | 21 +++++--- .../synology_dsm/test_config_flow.py | 35 ++++++++------ tests/components/system_bridge/__init__.py | 6 +-- tests/components/tado/test_config_flow.py | 13 +++-- tests/components/tailwind/test_config_flow.py | 10 ++-- tests/components/technove/test_config_flow.py | 12 ++--- .../tesla_wall_connector/test_config_flow.py | 8 ++-- tests/components/thread/test_config_flow.py | 5 +- tests/components/tolo/test_config_flow.py | 4 +- tests/components/tplink/test_config_flow.py | 17 +++---- tests/components/tradfri/test_config_flow.py | 25 +++++----- tests/components/twinkly/test_config_flow.py | 8 ++-- tests/components/unifi/test_config_flow.py | 10 ++-- .../unifiprotect/test_config_flow.py | 9 ++-- tests/components/upnp/conftest.py | 37 ++++++++------ tests/components/upnp/test_config_flow.py | 18 ++++--- tests/components/upnp/test_init.py | 5 +- tests/components/verisure/test_config_flow.py | 4 +- tests/components/vicare/test_config_flow.py | 4 +- tests/components/vizio/const.py | 4 +- tests/components/volumio/test_config_flow.py | 4 +- tests/components/webostv/test_config_flow.py | 16 ++++--- tests/components/wilight/__init__.py | 8 ++-- tests/components/wiz/test_config_flow.py | 4 +- tests/components/wled/test_config_flow.py | 12 ++--- .../xiaomi_aqara/test_config_flow.py | 8 ++-- .../xiaomi_miio/test_config_flow.py | 12 ++--- .../yamaha_musiccast/test_config_flow.py | 26 +++++----- tests/components/yeelight/__init__.py | 4 +- tests/components/yeelight/test_config_flow.py | 37 +++++++------- tests/components/zha/test_config_flow.py | 25 +++++----- tests/components/zwave_me/test_config_flow.py | 4 +- 47 files changed, 332 insertions(+), 271 deletions(-) diff --git a/tests/components/samsungtv/const.py b/tests/components/samsungtv/const.py index 5976c28c6ce..c1a9da4e284 100644 --- a/tests/components/samsungtv/const.py +++ b/tests/components/samsungtv/const.py @@ -2,7 +2,6 @@ from samsungtvws.event import ED_INSTALLED_APP_EVENT -from homeassistant.components import ssdp from homeassistant.components.samsungtv.const import ( CONF_SESSION_ID, METHOD_LEGACY, @@ -23,6 +22,7 @@ from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_MANUFACTURER, ATTR_UPNP_MODEL_NAME, ATTR_UPNP_UDN, + SsdpServiceInfo, ) MOCK_CONFIG = { @@ -61,7 +61,7 @@ MOCK_ENTRY_WS_WITH_MAC = { CONF_TOKEN: "123456789", } -MOCK_SSDP_DATA_RENDERING_CONTROL_ST = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_RENDERING_CONTROL_ST = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="urn:schemas-upnp-org:service:RenderingControl:1", ssdp_location="https://fake_host:12345/test", @@ -72,7 +72,7 @@ MOCK_SSDP_DATA_RENDERING_CONTROL_ST = ssdp.SsdpServiceInfo( ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172de", }, ) -MOCK_SSDP_DATA_MAIN_TV_AGENT_ST = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_MAIN_TV_AGENT_ST = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="urn:samsung.com:service:MainTVAgent2:1", ssdp_location="https://fake_host:12345/tv_agent", diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index f4a8badc2d9..576a5f6d534 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -17,7 +17,6 @@ from websockets import frames from websockets.exceptions import ConnectionClosedError, WebSocketException from homeassistant import config_entries -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.components.samsungtv.config_flow import SamsungTVConfigFlow from homeassistant.components.samsungtv.const import ( CONF_MANUFACTURER, @@ -47,6 +46,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import BaseServiceInfo, FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_MANUFACTURER, @@ -54,6 +54,7 @@ from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_UDN, SsdpServiceInfo, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component from .const import ( @@ -83,7 +84,7 @@ MOCK_IMPORT_WSDATA = { CONF_PORT: 8002, } MOCK_USER_DATA = {CONF_HOST: "fake_host", CONF_NAME: "fake_name"} -MOCK_SSDP_DATA = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://fake_host:12345/test", @@ -94,7 +95,7 @@ MOCK_SSDP_DATA = ssdp.SsdpServiceInfo( ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172de", }, ) -MOCK_SSDP_DATA_NO_MANUFACTURER = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_NO_MANUFACTURER = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://fake_host:12345/test", @@ -104,7 +105,7 @@ MOCK_SSDP_DATA_NO_MANUFACTURER = ssdp.SsdpServiceInfo( }, ) -MOCK_SSDP_DATA_NOPREFIX = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_NOPREFIX = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://fake2_host:12345/test", @@ -115,7 +116,7 @@ MOCK_SSDP_DATA_NOPREFIX = ssdp.SsdpServiceInfo( ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172df", }, ) -MOCK_SSDP_DATA_WRONGMODEL = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_WRONGMODEL = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://fake2_host:12345/test", @@ -126,11 +127,11 @@ MOCK_SSDP_DATA_WRONGMODEL = ssdp.SsdpServiceInfo( ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172df", }, ) -MOCK_DHCP_DATA = dhcp.DhcpServiceInfo( +MOCK_DHCP_DATA = DhcpServiceInfo( ip="fake_host", macaddress="aabbccddeeff", hostname="fake_hostname" ) EXISTING_IP = "192.168.40.221" -MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1704,7 +1705,7 @@ async def test_update_legacy_missing_mac_from_dhcp( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=EXISTING_IP, macaddress="aabbccddeeff", hostname="fake_hostname" ), ) @@ -1741,7 +1742,7 @@ async def test_update_legacy_missing_mac_from_dhcp_no_unique_id( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=EXISTING_IP, macaddress="aabbccddeeff", hostname="fake_hostname" ), ) diff --git a/tests/components/screenlogic/test_config_flow.py b/tests/components/screenlogic/test_config_flow.py index 8ca6bd4cb90..5ce777a47fa 100644 --- a/tests/components/screenlogic/test_config_flow.py +++ b/tests/components/screenlogic/test_config_flow.py @@ -12,7 +12,6 @@ from screenlogicpy.const.common import ( ) from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.screenlogic.config_flow import ( GATEWAY_MANUAL_ENTRY, GATEWAY_SELECT_KEY, @@ -25,6 +24,7 @@ from homeassistant.components.screenlogic.const import ( from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -135,7 +135,7 @@ async def test_dhcp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="Pentair: 01-01-01", ip="1.1.1.1", macaddress="aabbccddeeff", diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index d9945706182..b5f87a874c3 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -15,7 +15,6 @@ from aioshelly.exceptions import ( import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.shelly import MacAddressMismatchError, config_flow from homeassistant.components.shelly.const import ( CONF_BLE_SCANNER_MODE, @@ -25,6 +24,10 @@ from homeassistant.components.shelly.const import ( from homeassistant.components.shelly.coordinator import ENTRY_RELOAD_COOLDOWN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -33,22 +36,22 @@ from . import init_integration from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="shelly1pm-12345", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "shelly1pm-12345"}, + properties={ATTR_PROPERTIES_ID: "shelly1pm-12345"}, type="mock_type", ) -DISCOVERY_INFO_WITH_MAC = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO_WITH_MAC = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="shelly1pm-AABBCCDDEEFF", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "shelly1pm-AABBCCDDEEFF"}, + properties={ATTR_PROPERTIES_ID: "shelly1pm-AABBCCDDEEFF"}, type="mock_type", ) @@ -1459,13 +1462,13 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", name="shelly1pm-12345", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "shelly1pm-12345"}, + properties={ATTR_PROPERTIES_ID: "shelly1pm-12345"}, type="mock_type", ), ) diff --git a/tests/components/smappee/test_config_flow.py b/tests/components/smappee/test_config_flow.py index c06ab551ef6..205c700a402 100644 --- a/tests/components/smappee/test_config_flow.py +++ b/tests/components/smappee/test_config_flow.py @@ -7,7 +7,6 @@ from unittest.mock import patch import pytest from homeassistant import setup -from homeassistant.components import zeroconf from homeassistant.components.smappee.const import ( CONF_SERIALNUMBER, DOMAIN, @@ -20,6 +19,7 @@ from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -63,7 +63,7 @@ async def test_show_zeroconf_connection_error_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -95,7 +95,7 @@ async def test_show_zeroconf_connection_error_form_next_generation( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -179,7 +179,7 @@ async def test_zeroconf_wrong_mdns(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -305,7 +305,7 @@ async def test_zeroconf_device_exists_abort(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -355,7 +355,7 @@ async def test_zeroconf_abort_if_cloud_device_exists(hass: HomeAssistant) -> Non result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -377,7 +377,7 @@ async def test_zeroconf_confirm_abort_if_cloud_device_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -504,7 +504,7 @@ async def test_full_zeroconf_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -589,7 +589,7 @@ async def test_full_zeroconf_flow_next_generation(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index 2fd39f75704..146f8e268a4 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -6,18 +6,18 @@ from unittest.mock import AsyncMock, MagicMock from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError import pytest -from homeassistant.components import zeroconf from homeassistant.components.smlight.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME from tests.common import MockConfigEntry -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="slzb-06.local.", @@ -27,7 +27,7 @@ DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( type="mock_type", ) -DISCOVERY_INFO_LEGACY = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO_LEGACY = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="slzb-06.local.", diff --git a/tests/components/somfy_mylink/test_config_flow.py b/tests/components/somfy_mylink/test_config_flow.py index 9084d988ec9..b7007f27fa9 100644 --- a/tests/components/somfy_mylink/test_config_flow.py +++ b/tests/components/somfy_mylink/test_config_flow.py @@ -5,7 +5,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.somfy_mylink.const import ( CONF_REVERSED_TARGET_IDS, CONF_SYSTEM_ID, @@ -14,6 +13,7 @@ from homeassistant.components.somfy_mylink.const import ( from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -263,7 +263,7 @@ async def test_form_user_already_configured_from_dhcp(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="somfy_eeff", @@ -287,7 +287,7 @@ async def test_already_configured_with_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="somfy_eeff", @@ -302,7 +302,7 @@ async def test_dhcp_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="somfy_eeff", diff --git a/tests/components/songpal/test_config_flow.py b/tests/components/songpal/test_config_flow.py index 5215e9b3c0e..0ae2ab596db 100644 --- a/tests/components/songpal/test_config_flow.py +++ b/tests/components/songpal/test_config_flow.py @@ -4,7 +4,6 @@ import copy import dataclasses from unittest.mock import patch -from homeassistant.components import ssdp from homeassistant.components.songpal.const import CONF_ENDPOINT, DOMAIN from homeassistant.config_entries import ( SOURCE_IMPORT, @@ -15,6 +14,11 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from . import ( CONF_DATA, @@ -30,13 +34,13 @@ from tests.common import MockConfigEntry UDN = "uuid:1234" -SSDP_DATA = ssdp.SsdpServiceInfo( +SSDP_DATA = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{HOST}:52323/dmr.xml", upnp={ - ssdp.ATTR_UPNP_UDN: UDN, - ssdp.ATTR_UPNP_FRIENDLY_NAME: FRIENDLY_NAME, + ATTR_UPNP_UDN: UDN, + ATTR_UPNP_FRIENDLY_NAME: FRIENDLY_NAME, "X_ScalarWebAPI_DeviceInfo": { "X_ScalarWebAPI_BaseURL": ENDPOINT, "X_ScalarWebAPI_ServiceList": { diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 04b35e2c021..0f56794b9f2 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -18,11 +18,13 @@ from soco.data_structures import ( ) from soco.events_base import Event as SonosEvent -from homeassistant.components import ssdp, zeroconf +from homeassistant.components import ssdp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sonos import DOMAIN from homeassistant.const import CONF_HOSTS from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_fixture, load_json_value_fixture @@ -108,7 +110,7 @@ class SonosMockEvent: @pytest.fixture def zeroconf_payload(): """Return a default zeroconf payload.""" - return zeroconf.ZeroconfServiceInfo( + return ZeroconfServiceInfo( ip_address=ip_address("192.168.4.2"), ip_addresses=[ip_address("192.168.4.2")], hostname="Sonos-aaa", @@ -335,17 +337,17 @@ def discover_fixture(soco): def do_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_location=f"http://{soco.ip_address}/", ssdp_st="urn:schemas-upnp-org:device:ZonePlayer:1", ssdp_usn=f"uuid:{soco.uid}_MR::urn:schemas-upnp-org:service:GroupRenderingControl:1", upnp={ - ssdp.ATTR_UPNP_UDN: f"uuid:{soco.uid}", + ATTR_UPNP_UDN: f"uuid:{soco.uid}", }, ), ssdp.SsdpChange.ALIVE, diff --git a/tests/components/sonos/test_config_flow.py b/tests/components/sonos/test_config_flow.py index 141013dec20..70605092da1 100644 --- a/tests/components/sonos/test_config_flow.py +++ b/tests/components/sonos/test_config_flow.py @@ -6,17 +6,18 @@ from ipaddress import ip_address from unittest.mock import MagicMock, patch from homeassistant import config_entries -from homeassistant.components import ssdp, zeroconf from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER, DOMAIN from homeassistant.const import CONF_HOSTS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component async def test_user_form( - hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo ) -> None: """Test we get the user initiated form.""" @@ -84,7 +85,7 @@ async def test_user_form_already_created(hass: HomeAssistant) -> None: async def test_zeroconf_form( - hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo ) -> None: """Test we pass Zeroconf discoveries to the manager.""" @@ -128,12 +129,12 @@ async def test_ssdp_discovery(hass: HomeAssistant, soco) -> None: await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_location=f"http://{soco.ip_address}/", ssdp_st="urn:schemas-upnp-org:device:ZonePlayer:1", ssdp_usn=f"uuid:{soco.uid}_MR::urn:schemas-upnp-org:service:GroupRenderingControl:1", upnp={ - ssdp.ATTR_UPNP_UDN: f"uuid:{soco.uid}", + ATTR_UPNP_UDN: f"uuid:{soco.uid}", }, ), ) @@ -173,7 +174,7 @@ async def test_zeroconf_sonos_v1(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.107"), ip_addresses=[ip_address("192.168.1.107")], port=1443, @@ -221,7 +222,7 @@ async def test_zeroconf_sonos_v1(hass: HomeAssistant) -> None: async def test_zeroconf_form_not_sonos( - hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo ) -> None: """Test we abort on non-sonos devices.""" mock_manager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] = MagicMock() diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 36a6571f3b0..3fc8da6a952 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -8,7 +8,7 @@ from unittest.mock import Mock, patch import pytest from homeassistant import config_entries -from homeassistant.components import sonos, zeroconf +from homeassistant.components import sonos from homeassistant.components.sonos import SonosDiscoveryManager from homeassistant.components.sonos.const import ( DATA_SONOS_DISCOVERY_MANAGER, @@ -19,6 +19,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -28,7 +29,7 @@ from tests.common import async_fire_time_changed async def test_creating_entry_sets_up_media_player( - hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo ) -> None: """Test setting up Sonos loads the media player.""" diff --git a/tests/components/spotify/test_config_flow.py b/tests/components/spotify/test_config_flow.py index cb942a63568..24c0e1d41d9 100644 --- a/tests/components/spotify/test_config_flow.py +++ b/tests/components/spotify/test_config_flow.py @@ -7,18 +7,18 @@ from unittest.mock import MagicMock, patch import pytest from spotifyaio import SpotifyConnectionError -from homeassistant.components import zeroconf from homeassistant.components.spotify.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -BLANK_ZEROCONF_INFO = zeroconf.ZeroconfServiceInfo( +BLANK_ZEROCONF_INFO = ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index 455d4c962b0..c5efe66152f 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -6,11 +6,11 @@ from unittest.mock import patch from pysqueezebox import Server from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.squeezebox.const import CONF_HTTPS, 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 +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -333,7 +333,7 @@ async def test_dhcp_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="any", @@ -355,7 +355,7 @@ async def test_dhcp_discovery_no_server_found(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="any", @@ -374,7 +374,7 @@ async def test_dhcp_discovery_existing_player(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="any", diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index a4ad1274fa6..839509e756b 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -94,7 +94,7 @@ async def test_ssdp_flow_dispatched_on_st( "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), "source": config_entries.SOURCE_SSDP, } - mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + mock_call_data: SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" assert mock_call_data.ssdp_location == "http://1.1.1.1" assert mock_call_data.ssdp_usn == "uuid:mock-udn::mock-st" @@ -103,7 +103,7 @@ async def test_ssdp_flow_dispatched_on_st( assert mock_call_data.ssdp_udn == ANY assert mock_call_data.ssdp_headers["_timestamp"] == ANY assert mock_call_data.x_homeassistant_matching_domains == {"mock-domain"} - assert mock_call_data.upnp == {ssdp.ATTR_UPNP_UDN: "uuid:mock-udn"} + assert mock_call_data.upnp == {ATTR_UPNP_UDN: "uuid:mock-udn"} assert "Failed to fetch ssdp data" not in caplog.text @@ -138,7 +138,7 @@ async def test_ssdp_flow_dispatched_on_manufacturer_url( "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), "source": config_entries.SOURCE_SSDP, } - mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + mock_call_data: SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" assert mock_call_data.ssdp_location == "http://1.1.1.1" assert mock_call_data.ssdp_usn == "uuid:mock-udn::mock-st" @@ -147,7 +147,7 @@ async def test_ssdp_flow_dispatched_on_manufacturer_url( assert mock_call_data.ssdp_udn == ANY assert mock_call_data.ssdp_headers["_timestamp"] == ANY assert mock_call_data.x_homeassistant_matching_domains == {"mock-domain"} - assert mock_call_data.upnp == {ssdp.ATTR_UPNP_UDN: "uuid:mock-udn"} + assert mock_call_data.upnp == {ATTR_UPNP_UDN: "uuid:mock-udn"} assert "Failed to fetch ssdp data" not in caplog.text @@ -247,8 +247,8 @@ async def test_scan_match_upnp_devicedesc_devicetype( return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_MANUFACTURER: "Paulus", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_MANUFACTURER: "Paulus", } ] }, @@ -290,8 +290,8 @@ async def test_scan_not_all_present( return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_MANUFACTURER: "Not-Paulus", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_MANUFACTURER: "Not-Paulus", } ] }, @@ -475,8 +475,8 @@ async def test_discovery_from_advertisement_sets_ssdp_st( assert discovery_info.ssdp_headers["nts"] == "ssdp:alive" assert discovery_info.ssdp_headers["_timestamp"] == ANY assert discovery_info.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:mock-udn", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:mock-udn", } @@ -575,7 +575,7 @@ async def test_scan_with_registered_callback( assert async_match_any_callback.call_count == 1 assert async_not_matching_integration_callback.call_count == 0 assert async_integration_callback.call_args[0][1] == ssdp.SsdpChange.ALIVE - mock_call_data: ssdp.SsdpServiceInfo = async_integration_callback.call_args[0][0] + mock_call_data: SsdpServiceInfo = async_integration_callback.call_args[0][0] assert mock_call_data.ssdp_ext == "" assert mock_call_data.ssdp_location == "http://1.1.1.1" assert mock_call_data.ssdp_server == "mock-server" @@ -588,8 +588,8 @@ async def test_scan_with_registered_callback( assert mock_call_data.ssdp_headers["_timestamp"] == ANY assert mock_call_data.x_homeassistant_matching_domains == set() assert mock_call_data.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } assert "Exception in SSDP callback" in caplog.text @@ -647,8 +647,8 @@ async def test_getting_existing_headers( assert discovery_info_by_st.ssdp_udn == ANY assert discovery_info_by_st.ssdp_headers["_timestamp"] == ANY assert discovery_info_by_st.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } discovery_info_by_udn = await ssdp.async_get_discovery_info_by_udn( @@ -666,8 +666,8 @@ async def test_getting_existing_headers( assert discovery_info_by_udn.ssdp_udn == ANY assert discovery_info_by_udn.ssdp_headers["_timestamp"] == ANY assert discovery_info_by_udn.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } discovery_info_by_udn_st = await ssdp.async_get_discovery_info_by_udn_st( @@ -684,8 +684,8 @@ async def test_getting_existing_headers( assert discovery_info_by_udn_st.ssdp_udn == ANY assert discovery_info_by_udn_st.ssdp_headers["_timestamp"] == ANY assert discovery_info_by_udn_st.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } assert ( @@ -733,7 +733,7 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", + ATTR_UPNP_DEVICE_TYPE: "ABC", } ] }, @@ -758,7 +758,7 @@ async def test_async_detect_interfaces_setting_empty_route( return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", + ATTR_UPNP_DEVICE_TYPE: "ABC", } ] }, @@ -807,7 +807,7 @@ async def test_bind_failure_skips_adapter( return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", + ATTR_UPNP_DEVICE_TYPE: "ABC", } ] }, @@ -1019,7 +1019,7 @@ async def test_ssdp_rediscover( assert len(mock_flow_init.mock_calls) == 1 assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" assert mock_flow_init.mock_calls[0][2]["context"] == expected_context - mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + mock_call_data: SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" assert mock_call_data.ssdp_location == "http://1.1.1.1" @@ -1106,7 +1106,7 @@ async def test_ssdp_rediscover_no_match( assert len(mock_flow_init.mock_calls) == 1 assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" assert mock_flow_init.mock_calls[0][2]["context"] == expected_context - mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + mock_call_data: SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" assert mock_call_data.ssdp_location == "http://1.1.1.1" diff --git a/tests/components/steamist/test_config_flow.py b/tests/components/steamist/test_config_flow.py index 40578113bb3..5e963f77a2b 100644 --- a/tests/components/steamist/test_config_flow.py +++ b/tests/components/steamist/test_config_flow.py @@ -5,11 +5,11 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.steamist.const import DOMAIN from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( DEFAULT_ENTRY_DATA, @@ -30,7 +30,7 @@ from tests.common import MockConfigEntry MODULE = "homeassistant.components.steamist" -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname=DEVICE_HOSTNAME, ip=DEVICE_IP_ADDRESS, macaddress=DEVICE_MAC_ADDRESS.lower().replace(":", ""), @@ -238,7 +238,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="any", ip=DEVICE_IP_ADDRESS, macaddress="000000000000", diff --git a/tests/components/syncthru/test_config_flow.py b/tests/components/syncthru/test_config_flow.py index b79e63e1ce7..727b95563cc 100644 --- a/tests/components/syncthru/test_config_flow.py +++ b/tests/components/syncthru/test_config_flow.py @@ -6,12 +6,19 @@ from unittest.mock import patch from pysyncthru import SyncThruAPINotSupported from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.syncthru.config_flow import SyncThru from homeassistant.components.syncthru.const import DOMAIN from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -138,16 +145,16 @@ async def test_ssdp(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.2:5200/Printer.xml", upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:Printer:1", - ssdp.ATTR_UPNP_MANUFACTURER: "Samsung Electronics", - ssdp.ATTR_UPNP_PRESENTATION_URL: url, - ssdp.ATTR_UPNP_SERIAL: "00000000", - ssdp.ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:Printer:1", + ATTR_UPNP_MANUFACTURER: "Samsung Electronics", + ATTR_UPNP_PRESENTATION_URL: url, + ATTR_UPNP_SERIAL: "00000000", + ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", }, ), ) diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index e5494b7179f..3ef47292a9b 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -13,7 +13,6 @@ from synology_dsm.exceptions import ( ) from syrupy import SnapshotAssertion -from homeassistant.components import ssdp, zeroconf from homeassistant.components.synology_dsm.config_flow import CONF_OTP_CODE from homeassistant.components.synology_dsm.const import ( CONF_SNAPSHOT_QUALITY, @@ -34,6 +33,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .consts import ( DEVICE_TOKEN, @@ -418,13 +423,13 @@ async def test_form_ssdp( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.5:5000", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX99", # MAC address, but SSDP does not have `-` + ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ATTR_UPNP_SERIAL: "001132XXXX99", # MAC address, but SSDP does not have `-` }, ), ) @@ -465,13 +470,13 @@ async def test_reconfig_ssdp(hass: HomeAssistant, service: MagicMock) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.5:5000", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` }, ), ) @@ -508,13 +513,13 @@ async def test_skip_reconfig_ssdp( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{new_host}:5000", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` }, ), ) @@ -541,13 +546,13 @@ async def test_existing_ssdp(hass: HomeAssistant, service: MagicMock) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.5:5000", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` }, ), ) @@ -606,7 +611,7 @@ async def test_discovered_via_zeroconf( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], port=5000, @@ -645,7 +650,7 @@ async def test_discovered_via_zeroconf_missing_mac( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], port=5000, diff --git a/tests/components/system_bridge/__init__.py b/tests/components/system_bridge/__init__.py index 0606ce8e258..89bd1b652ba 100644 --- a/tests/components/system_bridge/__init__.py +++ b/tests/components/system_bridge/__init__.py @@ -15,9 +15,9 @@ from systembridgemodels.fixtures.modules.processes import FIXTURE_PROCESSES from systembridgemodels.fixtures.modules.system import FIXTURE_SYSTEM from systembridgemodels.modules import Module, ModulesData -from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -44,7 +44,7 @@ FIXTURE_ZEROCONF_INPUT = { CONF_PORT: "9170", } -FIXTURE_ZEROCONF = zeroconf.ZeroconfServiceInfo( +FIXTURE_ZEROCONF = ZeroconfServiceInfo( ip_address=ip_address(FIXTURE_USER_INPUT[CONF_HOST]), ip_addresses=[ip_address(FIXTURE_USER_INPUT[CONF_HOST])], port=9170, @@ -62,7 +62,7 @@ FIXTURE_ZEROCONF = zeroconf.ZeroconfServiceInfo( }, ) -FIXTURE_ZEROCONF_BAD = zeroconf.ZeroconfServiceInfo( +FIXTURE_ZEROCONF_BAD = ZeroconfServiceInfo( ip_address=ip_address(FIXTURE_USER_INPUT[CONF_HOST]), ip_addresses=[ip_address(FIXTURE_USER_INPUT[CONF_HOST])], port=9170, diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index b4a5b196a39..19acb0aecbd 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -9,7 +9,6 @@ import pytest import requests from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.tado.config_flow import NoHomes from homeassistant.components.tado.const import ( CONF_FALLBACK, @@ -19,6 +18,10 @@ from homeassistant.components.tado.const import ( from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry @@ -235,13 +238,13 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) @@ -262,13 +265,13 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) diff --git a/tests/components/tailwind/test_config_flow.py b/tests/components/tailwind/test_config_flow.py index 5619ea7e400..2e8a8e7a727 100644 --- a/tests/components/tailwind/test_config_flow.py +++ b/tests/components/tailwind/test_config_flow.py @@ -11,13 +11,13 @@ from gotailwind import ( import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import zeroconf from homeassistant.components.tailwind.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -156,7 +156,7 @@ async def test_zeroconf_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -208,7 +208,7 @@ async def test_zeroconf_flow_abort_incompatible_properties( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -243,7 +243,7 @@ async def test_zeroconf_flow_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -303,7 +303,7 @@ async def test_zeroconf_flow_not_discovered_again( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, diff --git a/tests/components/technove/test_config_flow.py b/tests/components/technove/test_config_flow.py index 81e0b32b55b..99a8e231f73 100644 --- a/tests/components/technove/test_config_flow.py +++ b/tests/components/technove/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import AsyncMock, MagicMock import pytest from technove import TechnoVEConnectionError -from homeassistant.components import zeroconf from homeassistant.components.technove.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -112,7 +112,7 @@ async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -153,7 +153,7 @@ async def test_zeroconf_during_onboarding( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -184,7 +184,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -225,7 +225,7 @@ async def test_zeroconf_without_mac_station_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -250,7 +250,7 @@ async def test_zeroconf_with_mac_station_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", diff --git a/tests/components/tesla_wall_connector/test_config_flow.py b/tests/components/tesla_wall_connector/test_config_flow.py index a0c28262658..fc1f4199515 100644 --- a/tests/components/tesla_wall_connector/test_config_flow.py +++ b/tests/components/tesla_wall_connector/test_config_flow.py @@ -5,11 +5,11 @@ from unittest.mock import patch from tesla_wall_connector.exceptions import WallConnectorConnectionError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.tesla_wall_connector.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -113,7 +113,7 @@ async def test_dhcp_can_finish( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="teslawallconnector_abc", ip="1.2.3.4", macaddress="aadc44271212", @@ -146,7 +146,7 @@ async def test_dhcp_already_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="teslawallconnector_aabbcc", ip="1.2.3.4", macaddress="aabbccddeeff", @@ -170,7 +170,7 @@ async def test_dhcp_error_from_wall_connector( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="teslawallconnector_aabbcc", ip="1.2.3.4", macaddress="aabbccddeeff", diff --git a/tests/components/thread/test_config_flow.py b/tests/components/thread/test_config_flow.py index c31a1937d45..7feefdafedf 100644 --- a/tests/components/thread/test_config_flow.py +++ b/tests/components/thread/test_config_flow.py @@ -3,11 +3,12 @@ from ipaddress import ip_address from unittest.mock import patch -from homeassistant.components import thread, zeroconf +from homeassistant.components import thread from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -TEST_ZEROCONF_RECORD = zeroconf.ZeroconfServiceInfo( +TEST_ZEROCONF_RECORD = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="HomeAssistant OpenThreadBorderRouter #0BBF", diff --git a/tests/components/tolo/test_config_flow.py b/tests/components/tolo/test_config_flow.py index 73382944cf0..e918edf70a4 100644 --- a/tests/components/tolo/test_config_flow.py +++ b/tests/components/tolo/test_config_flow.py @@ -5,14 +5,14 @@ from unittest.mock import Mock, patch import pytest from tololib import ToloCommunicationError -from homeassistant.components import dhcp from homeassistant.components.tolo.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -MOCK_DHCP_DATA = dhcp.DhcpServiceInfo( +MOCK_DHCP_DATA = DhcpServiceInfo( ip="127.0.0.2", macaddress="001122334455", hostname="mock_hostname" ) diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index 08903e29a71..b093847869e 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -7,7 +7,7 @@ from kasa import Module, TimeoutError import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, stream +from homeassistant.components import stream from homeassistant.components.tplink import ( DOMAIN, AuthenticationError, @@ -36,6 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import _mocked_device, _patch_connect, _patch_discovery, _patch_single_discovery from .conftest import override_side_effect @@ -1291,7 +1292,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ) @@ -1305,7 +1306,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress="000000000000", hostname="mock_hostname" ), ) @@ -1321,7 +1322,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="000000000001", hostname="mock_hostname" ), ) @@ -1335,7 +1336,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ), @@ -1389,7 +1390,7 @@ async def test_discovered_by_dhcp_or_discovery( [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ), @@ -1606,7 +1607,7 @@ async def test_dhcp_discovery_with_ip_change( discovery_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS2, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ) @@ -1631,7 +1632,7 @@ async def test_dhcp_discovery_discover_fail( discovery_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS2, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ) diff --git a/tests/components/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py index b6f38b1d83d..6d6215a21ab 100644 --- a/tests/components/tradfri/test_config_flow.py +++ b/tests/components/tradfri/test_config_flow.py @@ -6,10 +6,13 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.tradfri import config_flow from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from . import TRADFRI_PATH @@ -115,13 +118,13 @@ async def test_discovery_connection( flow = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.123"), ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) @@ -150,13 +153,13 @@ async def test_discovery_duplicate_aborted(hass: HomeAssistant) -> None: flow = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.124"), ip_addresses=[ip_address("123.123.123.124")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) @@ -174,13 +177,13 @@ async def test_duplicate_discovery( result = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.123"), ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) @@ -190,13 +193,13 @@ async def test_duplicate_discovery( result2 = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.123"), ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) @@ -215,13 +218,13 @@ async def test_discovery_updates_unique_id(hass: HomeAssistant) -> None: flow = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.123"), ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) diff --git a/tests/components/twinkly/test_config_flow.py b/tests/components/twinkly/test_config_flow.py index 2b61b26fe0c..352c5249b0b 100644 --- a/tests/components/twinkly/test_config_flow.py +++ b/tests/components/twinkly/test_config_flow.py @@ -4,12 +4,12 @@ from unittest.mock import AsyncMock import pytest -from homeassistant.components import dhcp from homeassistant.components.twinkly.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import TEST_MAC, TEST_MODEL, TEST_NAME @@ -95,7 +95,7 @@ async def test_dhcp_full_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="Twinkly_XYZ", ip="1.2.3.4", macaddress="002d133baabb", @@ -127,7 +127,7 @@ async def test_dhcp_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="Twinkly_XYZ", ip="1.2.3.4", macaddress="002d133baabb", @@ -146,7 +146,7 @@ async def test_user_flow_works_discovery(hass: HomeAssistant) -> None: await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="Twinkly_XYZ", ip="1.2.3.4", macaddress="002d133baabb", diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index 71b196550da..9d85dedbc9a 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -7,7 +7,6 @@ import aiounifi import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.unifi.config_flow import _async_discover_unifi from homeassistant.components.unifi.const import ( CONF_ALLOW_BANDWIDTH_SENSORS, @@ -33,6 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .conftest import ConfigEntryFactoryType @@ -482,7 +482,7 @@ async def test_form_ssdp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.208.1:41417/rootDesc.xml", @@ -522,7 +522,7 @@ async def test_form_ssdp_aborts_if_host_already_exists(hass: HomeAssistant) -> N result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/rootDesc.xml", @@ -544,7 +544,7 @@ async def test_form_ssdp_aborts_if_serial_already_exists(hass: HomeAssistant) -> result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/rootDesc.xml", @@ -570,7 +570,7 @@ async def test_form_ssdp_gets_form_with_ignored_entry(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/rootDesc.xml", diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 8bfdc004092..0eae2a48fea 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -11,7 +11,6 @@ from uiprotect import NotAuthorized, NvrError, ProtectApiClient from uiprotect.data import NVR, Bootstrap, CloudAccount from homeassistant import config_entries -from homeassistant.components import dhcp, ssdp from homeassistant.components.unifiprotect.const import ( CONF_ALL_UPDATES, CONF_DISABLE_RTSP, @@ -23,6 +22,8 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from . import ( DEVICE_HOSTNAME, @@ -37,13 +38,13 @@ from .conftest import MAC_ADDR from tests.common import MockConfigEntry -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname=DEVICE_HOSTNAME, ip=DEVICE_IP_ADDRESS, macaddress=DEVICE_MAC_ADDRESS.lower().replace(":", ""), ) SSDP_DISCOVERY = ( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{DEVICE_IP_ADDRESS}:41417/rootDesc.xml", @@ -338,7 +339,7 @@ async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) - ], ) async def test_discovered_by_ssdp_or_dhcp( - hass: HomeAssistant, source: str, data: dhcp.DhcpServiceInfo | ssdp.SsdpServiceInfo + hass: HomeAssistant, source: str, data: DhcpServiceInfo | SsdpServiceInfo ) -> None: """Test we handoff to unifi-discovery when discovered via ssdp or dhcp.""" diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index 5576128eae5..300d925b82b 100644 --- a/tests/components/upnp/conftest.py +++ b/tests/components/upnp/conftest.py @@ -25,6 +25,15 @@ from homeassistant.components.upnp.const import ( DOMAIN, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -36,7 +45,7 @@ TEST_LOCATION6 = "http://[fe80::1%2]/desc.xml" TEST_HOST = urlparse(TEST_LOCATION).hostname TEST_FRIENDLY_NAME = "mock-name" TEST_MAC_ADDRESS = "00:11:22:33:44:55" -TEST_DISCOVERY = ssdp.SsdpServiceInfo( +TEST_DISCOVERY = SsdpServiceInfo( ssdp_st=TEST_ST, ssdp_udn=TEST_UDN, ssdp_usn=TEST_USN, @@ -45,12 +54,12 @@ TEST_DISCOVERY = ssdp.SsdpServiceInfo( "_udn": TEST_UDN, "location": TEST_LOCATION, "usn": TEST_USN, - ssdp.ATTR_UPNP_DEVICE_TYPE: TEST_ST, - ssdp.ATTR_UPNP_FRIENDLY_NAME: TEST_FRIENDLY_NAME, - ssdp.ATTR_UPNP_MANUFACTURER: "mock-manufacturer", - ssdp.ATTR_UPNP_MODEL_NAME: "mock-model-name", - ssdp.ATTR_UPNP_SERIAL: "mock-serial", - ssdp.ATTR_UPNP_UDN: TEST_UDN, + ATTR_UPNP_DEVICE_TYPE: TEST_ST, + ATTR_UPNP_FRIENDLY_NAME: TEST_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER: "mock-manufacturer", + ATTR_UPNP_MODEL_NAME: "mock-model-name", + ATTR_UPNP_SERIAL: "mock-serial", + ATTR_UPNP_UDN: TEST_UDN, }, ssdp_headers={ "_host": TEST_HOST, @@ -75,13 +84,13 @@ def mock_igd_device(mock_async_create_device) -> IgdDevice: """Mock async_upnp_client device.""" mock_upnp_device = create_autospec(UpnpDevice, instance=True) mock_upnp_device.device_url = TEST_DISCOVERY.ssdp_location - mock_upnp_device.serial_number = TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_SERIAL] + mock_upnp_device.serial_number = TEST_DISCOVERY.upnp[ATTR_UPNP_SERIAL] mock_igd_device = create_autospec(IgdDevice) mock_igd_device.device_type = TEST_DISCOVERY.ssdp_st - mock_igd_device.name = TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] - mock_igd_device.manufacturer = TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_MANUFACTURER] - mock_igd_device.model_name = TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + mock_igd_device.name = TEST_DISCOVERY.upnp[ATTR_UPNP_FRIENDLY_NAME] + mock_igd_device.manufacturer = TEST_DISCOVERY.upnp[ATTR_UPNP_MANUFACTURER] + mock_igd_device.model_name = TEST_DISCOVERY.upnp[ATTR_UPNP_MODEL_NAME] mock_igd_device.udn = TEST_DISCOVERY.ssdp_udn mock_igd_device.device = mock_upnp_device @@ -179,7 +188,7 @@ async def ssdp_instant_discovery(): async def register_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: @@ -212,7 +221,7 @@ async def ssdp_instant_discovery_multi_location(): async def register_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: @@ -241,7 +250,7 @@ async def ssdp_no_discovery(): async def register_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index 8799e0faab3..fb650ac7a47 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -7,7 +7,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.upnp.const import ( CONFIG_ENTRY_FORCE_POLL, CONFIG_ENTRY_HOST, @@ -21,6 +20,11 @@ from homeassistant.components.upnp.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import ( TEST_DISCOVERY, @@ -109,14 +113,14 @@ async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn=TEST_USN, # ssdp_udn=TEST_UDN, # Not provided. ssdp_st=TEST_ST, ssdp_location=TEST_LOCATION, upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: ST_IGD_V1, - # ssdp.ATTR_UPNP_UDN: TEST_UDN, # Not provided. + ATTR_UPNP_DEVICE_TYPE: ST_IGD_V1, + # ATTR_UPNP_UDN: TEST_UDN, # Not provided. }, ), ) @@ -130,14 +134,14 @@ async def test_flow_ssdp_non_igd_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn=TEST_USN, ssdp_udn=TEST_UDN, ssdp_st=TEST_ST, ssdp_location=TEST_LOCATION, ssdp_all_locations=[TEST_LOCATION], upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:WFADevice:1", # Non-IGD + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:WFADevice:1", # Non-IGD }, ), ) @@ -449,7 +453,7 @@ async def test_flow_ssdp_with_mismatched_udn(hass: HomeAssistant) -> None: """Test config flow: discovered + configured through ssdp, where the UDN differs in the SSDP-discovery vs device description.""" # Discovered via step ssdp. test_discovery = copy.deepcopy(TEST_DISCOVERY) - test_discovery.upnp[ssdp.ATTR_UPNP_UDN] = "uuid:another_udn" + test_discovery.upnp[ATTR_UPNP_UDN] = "uuid:another_udn" result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/upnp/test_init.py b/tests/components/upnp/test_init.py index ff74ca87b12..ef799a1b8af 100644 --- a/tests/components/upnp/test_init.py +++ b/tests/components/upnp/test_init.py @@ -22,6 +22,7 @@ from homeassistant.components.upnp.const import ( DOMAIN, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo from .conftest import ( TEST_DISCOVERY, @@ -125,7 +126,7 @@ async def test_async_setup_udn_mismatch( ) -> None: """Test async_setup_entry for a device which reports a different UDN from SSDP-discovery and device description.""" test_discovery = copy.deepcopy(TEST_DISCOVERY) - test_discovery.upnp[ssdp.ATTR_UPNP_UDN] = "uuid:another_udn" + test_discovery.upnp[ATTR_UPNP_UDN] = "uuid:another_udn" entry = MockConfigEntry( domain=DOMAIN, @@ -146,7 +147,7 @@ async def test_async_setup_udn_mismatch( async def register_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: diff --git a/tests/components/verisure/test_config_flow.py b/tests/components/verisure/test_config_flow.py index e6dd11669d1..eb7e3eb1811 100644 --- a/tests/components/verisure/test_config_flow.py +++ b/tests/components/verisure/test_config_flow.py @@ -8,7 +8,6 @@ import pytest from verisure import Error as VerisureError, LoginError as VerisureLoginError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.verisure.const import ( CONF_GIID, CONF_LOCK_CODE_DIGITS, @@ -18,6 +17,7 @@ from homeassistant.components.verisure.const import ( from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -333,7 +333,7 @@ async def test_dhcp(hass: HomeAssistant) -> None: """Test that DHCP discovery works.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress="0123456789ab", hostname="mock_hostname" ), context={"source": config_entries.SOURCE_DHCP}, diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py index d44fd1b9fed..ce3b3c27f06 100644 --- a/tests/components/vicare/test_config_flow.py +++ b/tests/components/vicare/test_config_flow.py @@ -9,12 +9,12 @@ from PyViCare.PyViCareUtils import ( ) from syrupy.assertion import SnapshotAssertion -from homeassistant.components import dhcp from homeassistant.components.vicare.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import MOCK_MAC, MODULE @@ -28,7 +28,7 @@ VALID_CONFIG = { CONF_CLIENT_ID: "5678", } -DHCP_INFO = dhcp.DhcpServiceInfo( +DHCP_INFO = DhcpServiceInfo( ip="1.1.1.1", hostname="mock_hostname", macaddress=MOCK_MAC.lower().replace(":", ""), diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py index 51151ae8f42..5fbf61a58da 100644 --- a/tests/components/vizio/const.py +++ b/tests/components/vizio/const.py @@ -2,7 +2,6 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.components.media_player import ( DOMAIN as MP_DOMAIN, MediaPlayerDeviceClass, @@ -27,6 +26,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PIN, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util import slugify NAME = "Vizio" @@ -173,7 +173,7 @@ VIZIO_ZEROCONF_SERVICE_TYPE = "_viziocast._tcp.local." ZEROCONF_NAME = f"{NAME}.{VIZIO_ZEROCONF_SERVICE_TYPE}" ZEROCONF_HOST, ZEROCONF_PORT = HOST.split(":", maxsplit=2) -MOCK_ZEROCONF_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_SERVICE_INFO = ZeroconfServiceInfo( ip_address=ip_address(ZEROCONF_HOST), ip_addresses=[ip_address(ZEROCONF_HOST)], hostname="mock_hostname", diff --git a/tests/components/volumio/test_config_flow.py b/tests/components/volumio/test_config_flow.py index 9c3708f970c..85e9e250ab9 100644 --- a/tests/components/volumio/test_config_flow.py +++ b/tests/components/volumio/test_config_flow.py @@ -4,11 +4,11 @@ from ipaddress import ip_address from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.volumio.config_flow import CannotConnectError from homeassistant.components.volumio.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -21,7 +21,7 @@ TEST_CONNECTION = { } -TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( +TEST_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index c8ac54be4bd..38c78bd087a 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -4,12 +4,16 @@ from aiowebostv import WebOsTvPairError import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN, LIVE_TV_APP_ID from homeassistant.config_entries import SOURCE_SSDP from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from . import setup_webostv from .const import ( @@ -26,13 +30,13 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") MOCK_USER_CONFIG = {CONF_HOST: HOST} -MOCK_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{HOST}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: f"[LG] webOS TV {TV_MODEL}", - ssdp.ATTR_UPNP_UDN: f"uuid:{FAKE_UUID}", + ATTR_UPNP_FRIENDLY_NAME: f"[LG] webOS TV {TV_MODEL}", + ATTR_UPNP_UDN: f"uuid:{FAKE_UUID}", }, ) @@ -239,8 +243,8 @@ async def test_ssdp_in_progress(hass: HomeAssistant, client) -> None: async def test_form_abort_uuid_configured(hass: HomeAssistant, client) -> None: """Test abort if uuid is already configured, verify host update.""" - entry = await setup_webostv(hass, MOCK_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN][5:]) - assert entry.unique_id == MOCK_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN][5:] + entry = await setup_webostv(hass, MOCK_DISCOVERY_INFO.upnp[ATTR_UPNP_UDN][5:]) + assert entry.unique_id == MOCK_DISCOVERY_INFO.upnp[ATTR_UPNP_UDN][5:] assert entry.data[CONF_HOST] == HOST result = await hass.config_entries.flow.async_init( diff --git a/tests/components/wilight/__init__.py b/tests/components/wilight/__init__.py index 3710c6b9a9f..8b32d7b1633 100644 --- a/tests/components/wilight/__init__.py +++ b/tests/components/wilight/__init__.py @@ -2,7 +2,6 @@ from pywilight.const import DOMAIN -from homeassistant.components import ssdp from homeassistant.components.wilight.config_flow import ( CONF_MODEL_NAME, CONF_SERIAL_NUMBER, @@ -14,6 +13,7 @@ from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_MODEL_NAME, ATTR_UPNP_MODEL_NUMBER, ATTR_UPNP_SERIAL, + SsdpServiceInfo, ) from tests.common import MockConfigEntry @@ -34,7 +34,7 @@ UPNP_MAC_ADDRESS = "5C:CF:7F:8B:CA:56" UPNP_MANUFACTURER_NOT_WILIGHT = "Test" CONF_COMPONENTS = "components" -MOCK_SSDP_DISCOVERY_INFO_P_B = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO_P_B = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, @@ -46,7 +46,7 @@ MOCK_SSDP_DISCOVERY_INFO_P_B = ssdp.SsdpServiceInfo( }, ) -MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTURER = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTURER = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, @@ -58,7 +58,7 @@ MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTURER = ssdp.SsdpServiceInfo( }, ) -MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTURER = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTURER = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, diff --git a/tests/components/wiz/test_config_flow.py b/tests/components/wiz/test_config_flow.py index c60e080f6d4..ddf4a4f452a 100644 --- a/tests/components/wiz/test_config_flow.py +++ b/tests/components/wiz/test_config_flow.py @@ -6,13 +6,13 @@ import pytest from pywizlight.exceptions import WizLightConnectionError, WizLightTimeOutError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.wiz.config_flow import CONF_DEVICE from homeassistant.components.wiz.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( FAKE_DIMMABLE_BULB, @@ -32,7 +32,7 @@ from . import ( from tests.common import MockConfigEntry -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname="wiz_abcabc", ip=FAKE_IP, macaddress=FAKE_MAC, diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index a1cf515a24b..15db188af5e 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import AsyncMock, MagicMock import pytest from wled import WLEDConnectionError -from homeassistant.components import zeroconf from homeassistant.components.wled.const import CONF_KEEP_MAIN_LIGHT, DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -43,7 +43,7 @@ async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -87,7 +87,7 @@ async def test_zeroconf_during_onboarding( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -132,7 +132,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -175,7 +175,7 @@ async def test_zeroconf_without_mac_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -200,7 +200,7 @@ async def test_zeroconf_with_mac_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", diff --git a/tests/components/xiaomi_aqara/test_config_flow.py b/tests/components/xiaomi_aqara/test_config_flow.py index 141e245815e..eb5cf976cb8 100644 --- a/tests/components/xiaomi_aqara/test_config_flow.py +++ b/tests/components/xiaomi_aqara/test_config_flow.py @@ -7,11 +7,11 @@ from unittest.mock import Mock, patch import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.xiaomi_aqara import config_flow, const from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_PROTOCOL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo ZEROCONF_NAME = "name" ZEROCONF_PROP = "properties" @@ -409,7 +409,7 @@ async def test_zeroconf_success(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -456,7 +456,7 @@ async def test_zeroconf_missing_data(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -476,7 +476,7 @@ async def test_zeroconf_unknown_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index 146526c69a5..92fe53a8fc7 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -9,11 +9,11 @@ from miio import DeviceException import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.xiaomi_miio import const from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import TEST_MAC @@ -434,7 +434,7 @@ async def test_zeroconf_gateway_success(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -477,7 +477,7 @@ async def test_zeroconf_unknown_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -497,7 +497,7 @@ async def test_zeroconf_no_data(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=None, ip_addresses=[], hostname="mock_hostname", @@ -517,7 +517,7 @@ async def test_zeroconf_missing_data(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -801,7 +801,7 @@ async def zeroconf_device_success( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", diff --git a/tests/components/yamaha_musiccast/test_config_flow.py b/tests/components/yamaha_musiccast/test_config_flow.py index 7629d2401c2..51645dee49e 100644 --- a/tests/components/yamaha_musiccast/test_config_flow.py +++ b/tests/components/yamaha_musiccast/test_config_flow.py @@ -7,12 +7,16 @@ from aiomusiccast import MusicCastConnectionException import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.yamaha_musiccast.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -103,7 +107,7 @@ def mock_valid_discovery_information(): with patch( "homeassistant.components.ssdp.async_get_discovery_info_by_st", return_value=[ - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1:9000/MediaRenderer/desc.xml", @@ -265,13 +269,13 @@ async def test_ssdp_discovery_failed(hass: HomeAssistant, mock_ssdp_no_yamaha) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1/desc.xml", upnp={ - ssdp.ATTR_UPNP_MODEL_NAME: "MC20", - ssdp.ATTR_UPNP_SERIAL: "123456789", + ATTR_UPNP_MODEL_NAME: "MC20", + ATTR_UPNP_SERIAL: "123456789", }, ), ) @@ -287,13 +291,13 @@ async def test_ssdp_discovery_successful_add_device( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1/desc.xml", upnp={ - ssdp.ATTR_UPNP_MODEL_NAME: "MC20", - ssdp.ATTR_UPNP_SERIAL: "1234567890", + ATTR_UPNP_MODEL_NAME: "MC20", + ATTR_UPNP_SERIAL: "1234567890", }, ), ) @@ -329,13 +333,13 @@ async def test_ssdp_discovery_existing_device_update( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1/desc.xml", upnp={ - ssdp.ATTR_UPNP_MODEL_NAME: "MC20", - ssdp.ATTR_UPNP_SERIAL: "1234567890", + ATTR_UPNP_MODEL_NAME: "MC20", + ATTR_UPNP_SERIAL: "1234567890", }, ), ) diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index bdd8cdda312..f534b214b1c 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -9,7 +9,6 @@ from async_upnp_client.utils import CaseInsensitiveDict from yeelight import BulbException, BulbType from yeelight.main import _MODEL_SPECS -from homeassistant.components import zeroconf from homeassistant.components.yeelight import ( CONF_MODE_MUSIC, CONF_NIGHTLIGHT_SWITCH_TYPE, @@ -21,6 +20,7 @@ from homeassistant.components.yeelight import ( ) from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME from homeassistant.core import callback +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo FAIL_TO_BIND_IP = "1.2.3.4" @@ -43,7 +43,7 @@ CAPABILITIES = { ID_DECIMAL = f"{int(ID, 16):08d}" -ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], port=54321, diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 1acb553af3d..a3f83cc03aa 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.components.yeelight.config_flow import ( MODEL_UNKNOWN, CannotConnect, @@ -30,6 +29,12 @@ from homeassistant.components.yeelight.const import ( from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from . import ( CAPABILITIES, @@ -57,7 +62,7 @@ DEFAULT_CONFIG = { CONF_NIGHTLIGHT_SWITCH: DEFAULT_NIGHTLIGHT_SWITCH, } -SSDP_INFO = ssdp.SsdpServiceInfo( +SSDP_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={}, @@ -493,13 +498,13 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) @@ -525,7 +530,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress="aabbccddeeff", hostname="mock_hostname" ), ) @@ -543,7 +548,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress="000000000000", hostname="mock_hostname" ), ) @@ -560,7 +565,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="000000000001", hostname="mock_hostname" ), ) @@ -574,19 +579,19 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress="aabbccddeeff", hostname="mock_hostname" ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ), @@ -648,19 +653,19 @@ async def test_discovered_by_dhcp_or_homekit(hass: HomeAssistant, source, data) [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress="aabbccddeeff", hostname="mock_hostname" ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ), @@ -894,19 +899,19 @@ async def test_discovery_adds_missing_ip_id_only(hass: HomeAssistant) -> None: [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress="aabbccddeeff", hostname="mock_hostname" ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ), diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 75960c4e73d..573a04e9b57 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -20,7 +20,6 @@ from zigpy.exceptions import NetworkNotFormed import zigpy.types from homeassistant import config_entries -from homeassistant.components import ssdp, zeroconf from homeassistant.components.hassio import AddonError, AddonState from homeassistant.components.zha import config_flow, radio_manager from homeassistant.components.zha.const import ( @@ -45,8 +44,10 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL, + SsdpServiceInfo, ) from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -166,7 +167,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "tubeszb-cc2652-poe", "tubeszb-cc2652-poe", RadioType.znp, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tubeszb-cc2652-poe.local.", @@ -189,7 +190,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "tubeszb-efr32-poe", "tubeszb-efr32-poe", RadioType.ezsp, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tubeszb-efr32-poe.local.", @@ -212,7 +213,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "TubeZB", "tubeszb-cc2652-poe", RadioType.znp, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tubeszb-cc2652-poe.local.", @@ -233,7 +234,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "Some Zigbee Gateway (12345)", "aabbccddeeff", RadioType.znp, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="some-zigbee-gateway-12345.local.", @@ -252,7 +253,7 @@ async def test_zeroconf_discovery( entry_name: str, unique_id: str, radio_type: RadioType, - service_info: zeroconf.ZeroconfServiceInfo, + service_info: ZeroconfServiceInfo, hass: HomeAssistant, ) -> None: """Test zeroconf flow -- radio detected.""" @@ -294,7 +295,7 @@ async def test_legacy_zeroconf_discovery_zigate( setup_entry_mock, hass: HomeAssistant ) -> None: """Test zeroconf flow -- zigate radio detected.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="_zigate-zigbee-gateway.local.", @@ -343,7 +344,7 @@ async def test_legacy_zeroconf_discovery_zigate( async def test_zeroconf_discovery_bad_payload(hass: HomeAssistant) -> None: """Test zeroconf flow with a bad payload.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="some.hostname", @@ -371,7 +372,7 @@ async def test_legacy_zeroconf_discovery_ip_change_ignored(hass: HomeAssistant) ) entry.add_to_hass(hass) - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tubeszb-cc2652-poe.local.", @@ -401,7 +402,7 @@ async def test_legacy_zeroconf_discovery_confirm_final_abort_if_entries( hass: HomeAssistant, ) -> None: """Test discovery aborts if ZHA was set up after the confirmation dialog is shown.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tube._tube_zb_gw._tcp.local.", @@ -626,7 +627,7 @@ async def test_discovery_via_usb_deconz_already_discovered(hass: HomeAssistant) """Test usb flow -- deconz discovered.""" result = await hass.config_entries.flow.async_init( "deconz", - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:80/", @@ -736,7 +737,7 @@ async def test_discovery_via_usb_zha_ignored_updates(hass: HomeAssistant) -> Non @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) async def test_legacy_zeroconf_discovery_already_setup(hass: HomeAssistant) -> None: """Test zeroconf flow -- radio detected.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="_tube_zb_gw._tcp.local.", diff --git a/tests/components/zwave_me/test_config_flow.py b/tests/components/zwave_me/test_config_flow.py index a71df8751b6..f784d7db2db 100644 --- a/tests/components/zwave_me/test_config_flow.py +++ b/tests/components/zwave_me/test_config_flow.py @@ -4,14 +4,14 @@ from ipaddress import ip_address from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.zwave_me.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry -MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.14"), ip_addresses=[ip_address("192.168.1.14")], hostname="mock_hostname", From fe010289b4687b4b45dd51ffe43b045a85dca3c1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Jan 2025 13:13:45 +0100 Subject: [PATCH 0685/2987] Use new ServiceInfo location in component tests (part 2) (#136062) --- tests/components/insteon/test_config_flow.py | 4 +- .../intellifire/test_config_flow.py | 6 +- tests/components/ipp/__init__.py | 6 +- tests/components/isy994/test_config_flow.py | 55 ++++++++++--------- tests/components/kaleidescape/__init__.py | 4 +- tests/components/keenetic_ndms2/__init__.py | 12 ++-- .../keenetic_ndms2/test_config_flow.py | 14 +++-- tests/components/kodi/util.py | 6 +- .../components/konnected/test_config_flow.py | 21 +++---- tests/components/lifx/test_config_flow.py | 30 +++++----- tests/components/loqed/test_config_flow.py | 4 +- .../lutron_caseta/test_config_flow.py | 10 ++-- .../modern_forms/test_config_flow.py | 10 ++-- .../motion_blinds/test_config_flow.py | 8 +-- tests/components/motionmount/__init__.py | 6 +- tests/components/nam/test_config_flow.py | 4 +- tests/components/nanoleaf/test_config_flow.py | 16 ++++-- tests/components/nest/test_config_flow.py | 4 +- tests/components/netatmo/test_config_flow.py | 9 ++- tests/components/netgear/test_config_flow.py | 45 ++++++++------- tests/components/nuki/test_config_flow.py | 10 +--- tests/components/nut/test_config_flow.py | 4 +- tests/components/obihai/__init__.py | 4 +- .../components/octoprint/test_config_flow.py | 11 ++-- tests/components/onkyo/test_config_flow.py | 29 +++++----- tests/components/onvif/test_config_flow.py | 6 +- tests/components/overkiz/test_config_flow.py | 6 +- .../components/palazzetti/test_config_flow.py | 6 +- tests/components/peblar/test_config_flow.py | 12 ++-- tests/components/powerfox/test_config_flow.py | 4 +- .../components/powerwall/test_config_flow.py | 26 ++++----- .../pure_energie/test_config_flow.py | 6 +- tests/components/qnap_qsw/test_config_flow.py | 4 +- .../components/rabbitair/test_config_flow.py | 4 +- tests/components/rachio/test_config_flow.py | 17 +++--- .../components/radiotherm/test_config_flow.py | 8 +-- .../rainmachine/test_config_flow.py | 12 ++-- tests/components/reolink/test_config_flow.py | 10 ++-- tests/components/ring/test_config_flow.py | 10 +--- tests/components/roku/__init__.py | 12 ++-- tests/components/romy/test_config_flow.py | 9 ++- tests/components/roomba/test_config_flow.py | 33 +++++------ .../ruuvi_gateway/test_config_flow.py | 4 +- 43 files changed, 279 insertions(+), 242 deletions(-) diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index 9643a6b493e..33e71be6dc2 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -8,7 +8,6 @@ import pytest from voluptuous_serialize import convert from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.insteon.config_flow import ( STEP_HUB_V1, STEP_HUB_V2, @@ -20,6 +19,7 @@ from homeassistant.config_entries import ConfigEntryState, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import ( @@ -210,7 +210,7 @@ async def test_form_select_hub_v2(hass: HomeAssistant) -> None: async def test_form_discovery_dhcp(hass: HomeAssistant) -> None: """Test the discovery of the Hub via DHCP.""" - discovery_info = dhcp.DhcpServiceInfo("1.2.3.4", "", "aabbccddeeff") + discovery_info = DhcpServiceInfo("1.2.3.4", "", "aabbccddeeff") result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=discovery_info ) diff --git a/tests/components/intellifire/test_config_flow.py b/tests/components/intellifire/test_config_flow.py index f1465c4dcd4..96d0fa17e63 100644 --- a/tests/components/intellifire/test_config_flow.py +++ b/tests/components/intellifire/test_config_flow.py @@ -5,11 +5,11 @@ from unittest.mock import AsyncMock from intellifire4py.exceptions import LoginError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.intellifire.const import CONF_SERIAL, DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -166,7 +166,7 @@ async def test_dhcp_discovery_intellifire_device( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="zentrios-Test", @@ -196,7 +196,7 @@ async def test_dhcp_discovery_non_intellifire_device( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="zentrios-Evil", diff --git a/tests/components/ipp/__init__.py b/tests/components/ipp/__init__.py index ca374bd7e5e..89b54e22bbb 100644 --- a/tests/components/ipp/__init__.py +++ b/tests/components/ipp/__init__.py @@ -2,9 +2,9 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.components.ipp.const import CONF_BASE_PATH from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo ATTR_HOSTNAME = "hostname" ATTR_PROPERTIES = "properties" @@ -30,7 +30,7 @@ MOCK_USER_INPUT = { CONF_BASE_PATH: BASE_PATH, } -MOCK_ZEROCONF_IPP_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_IPP_SERVICE_INFO = ZeroconfServiceInfo( type=IPP_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{IPP_ZEROCONF_SERVICE_TYPE}", ip_address=ip_address(ZEROCONF_HOST), @@ -40,7 +40,7 @@ MOCK_ZEROCONF_IPP_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( properties={"rp": ZEROCONF_RP}, ) -MOCK_ZEROCONF_IPPS_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_IPPS_SERVICE_INFO = ZeroconfServiceInfo( type=IPPS_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{IPPS_ZEROCONF_SERVICE_TYPE}", ip_address=ip_address(ZEROCONF_HOST), diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index 2bc1fff222f..3a688f450d0 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -7,7 +7,6 @@ from pyisy import ISYConnectionError, ISYInvalidAuthError import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, ssdp from homeassistant.components.isy994.const import ( CONF_TLS_VER, DOMAIN, @@ -18,6 +17,12 @@ from homeassistant.config_entries import SOURCE_DHCP, SOURCE_IGNORE, SOURCE_SSDP from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -255,13 +260,13 @@ async def test_form_ssdp_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -274,13 +279,13 @@ async def test_form_ssdp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -322,13 +327,13 @@ async def test_form_ssdp_existing_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://3.3.3.3{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -353,13 +358,13 @@ async def test_form_ssdp_existing_entry_with_no_port(hass: HomeAssistant) -> Non result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://3.3.3.3/{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -386,13 +391,13 @@ async def test_form_ssdp_existing_entry_with_alternate_port( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://3.3.3.3:1443/{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -417,13 +422,13 @@ async def test_form_ssdp_existing_entry_no_port_https(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"https://3.3.3.3/{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -440,7 +445,7 @@ async def test_form_dhcp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="isy994-ems", macaddress=MOCK_MAC, @@ -476,7 +481,7 @@ async def test_form_dhcp_with_polisy(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="polisy", macaddress=MOCK_POLISY_MAC, @@ -516,7 +521,7 @@ async def test_form_dhcp_with_eisy(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="eisy", macaddress=MOCK_MAC, @@ -564,7 +569,7 @@ async def test_form_dhcp_existing_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="isy994-ems", macaddress=MOCK_MAC, @@ -594,7 +599,7 @@ async def test_form_dhcp_existing_entry_preserves_port(hass: HomeAssistant) -> N result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="isy994-ems", macaddress=MOCK_MAC, @@ -620,7 +625,7 @@ async def test_form_dhcp_existing_ignored_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="isy994-ems", macaddress=MOCK_MAC, diff --git a/tests/components/kaleidescape/__init__.py b/tests/components/kaleidescape/__init__.py index a888d882d63..6700218f8d4 100644 --- a/tests/components/kaleidescape/__init__.py +++ b/tests/components/kaleidescape/__init__.py @@ -1,16 +1,16 @@ """Tests for Kaleidescape integration.""" -from homeassistant.components import ssdp from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL, + SsdpServiceInfo, ) MOCK_HOST = "127.0.0.1" MOCK_SERIAL = "123456" MOCK_NAME = "Theater" -MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{MOCK_HOST}", diff --git a/tests/components/keenetic_ndms2/__init__.py b/tests/components/keenetic_ndms2/__init__.py index 8ca91d00386..dc0c89e8ea6 100644 --- a/tests/components/keenetic_ndms2/__init__.py +++ b/tests/components/keenetic_ndms2/__init__.py @@ -1,6 +1,5 @@ """Tests for the Keenetic NDMS2 component.""" -from homeassistant.components import ssdp from homeassistant.components.keenetic_ndms2 import const from homeassistant.const import ( CONF_HOST, @@ -9,6 +8,11 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_USERNAME, ) +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) MOCK_NAME = "Keenetic Ultra 2030" MOCK_IP = "0.0.0.0" @@ -30,12 +34,12 @@ MOCK_OPTIONS = { const.CONF_INTERFACES: ["Home", "VPS0"], } -MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_UDN: "uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_NAME, + ATTR_UPNP_UDN: "uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + ATTR_UPNP_FRIENDLY_NAME: MOCK_NAME, }, ) diff --git a/tests/components/keenetic_ndms2/test_config_flow.py b/tests/components/keenetic_ndms2/test_config_flow.py index 18bacc3a32c..7ddcdf38ed6 100644 --- a/tests/components/keenetic_ndms2/test_config_flow.py +++ b/tests/components/keenetic_ndms2/test_config_flow.py @@ -8,11 +8,15 @@ from ndms2_client.client import InterfaceInfo, RouterInfo import pytest from homeassistant import config_entries -from homeassistant.components import keenetic_ndms2 as keenetic, ssdp +from homeassistant.components import keenetic_ndms2 as keenetic from homeassistant.components.keenetic_ndms2 import const from homeassistant.const import CONF_HOST, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, +) from . import MOCK_DATA, MOCK_NAME, MOCK_OPTIONS, MOCK_SSDP_DISCOVERY_INFO @@ -200,7 +204,7 @@ async def test_ssdp_ignored(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=keenetic.DOMAIN, source=config_entries.SOURCE_IGNORE, - unique_id=MOCK_SSDP_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN], + unique_id=MOCK_SSDP_DISCOVERY_INFO.upnp[ATTR_UPNP_UDN], ) entry.add_to_hass(hass) @@ -222,7 +226,7 @@ async def test_ssdp_update_host(hass: HomeAssistant) -> None: domain=keenetic.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS, - unique_id=MOCK_SSDP_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN], + unique_id=MOCK_SSDP_DISCOVERY_INFO.upnp[ATTR_UPNP_UDN], ) entry.add_to_hass(hass) @@ -247,7 +251,7 @@ async def test_ssdp_reject_no_udn(hass: HomeAssistant) -> None: discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) discovery_info.upnp = {**discovery_info.upnp} - discovery_info.upnp.pop(ssdp.ATTR_UPNP_UDN) + discovery_info.upnp.pop(ATTR_UPNP_UDN) result = await hass.config_entries.flow.async_init( keenetic.DOMAIN, @@ -264,7 +268,7 @@ async def test_ssdp_reject_non_keenetic(hass: HomeAssistant) -> None: discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) discovery_info.upnp = {**discovery_info.upnp} - discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] = "Suspicious device" + discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] = "Suspicious device" result = await hass.config_entries.flow.async_init( keenetic.DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_SSDP}, diff --git a/tests/components/kodi/util.py b/tests/components/kodi/util.py index e56ba03b7e5..cc8acbaebf6 100644 --- a/tests/components/kodi/util.py +++ b/tests/components/kodi/util.py @@ -2,8 +2,8 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.components.kodi.const import DEFAULT_SSL +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo TEST_HOST = { "host": "1.1.1.1", @@ -17,7 +17,7 @@ TEST_CREDENTIALS = {"username": "username", "password": "password"} TEST_WS_PORT = {"ws_port": 9090} UUID = "11111111-1111-1111-1111-111111111111" -TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( +TEST_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], port=8080, @@ -28,7 +28,7 @@ TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( ) -TEST_DISCOVERY_WO_UUID = zeroconf.ZeroconfServiceInfo( +TEST_DISCOVERY_WO_UUID = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], port=8080, diff --git a/tests/components/konnected/test_config_flow.py b/tests/components/konnected/test_config_flow.py index 5865616c544..c9fa70de256 100644 --- a/tests/components/konnected/test_config_flow.py +++ b/tests/components/konnected/test_config_flow.py @@ -5,10 +5,11 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import konnected, ssdp +from homeassistant.components import konnected from homeassistant.components.konnected import config_flow from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry @@ -116,7 +117,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -141,7 +142,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -160,7 +161,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -175,7 +176,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -193,7 +194,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -217,7 +218,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -343,7 +344,7 @@ async def test_import_ssdp_host_user_finish(hass: HomeAssistant, mock_panel) -> ssdp_result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://0.0.0.0:1234/Device.xml", @@ -390,7 +391,7 @@ async def test_ssdp_already_configured(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://0.0.0.0:1234/Device.xml", @@ -470,7 +471,7 @@ async def test_ssdp_host_update(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.1.1.1:1234/Device.xml", diff --git a/tests/components/lifx/test_config_flow.py b/tests/components/lifx/test_config_flow.py index d1a6920f84a..e2a35bcb1b1 100644 --- a/tests/components/lifx/test_config_flow.py +++ b/tests/components/lifx/test_config_flow.py @@ -8,7 +8,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.lifx import DOMAIN from homeassistant.components.lifx.config_flow import LifXConfigFlow from homeassistant.components.lifx.const import CONF_SERIAL @@ -16,6 +15,11 @@ from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.setup import async_setup_component from . import ( @@ -362,7 +366,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL ), ) @@ -385,7 +389,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress="000000000000", hostname="mock_hostname" ), ) @@ -402,7 +406,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="000000000001", hostname="mock_hostname" ), ) @@ -416,19 +420,19 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "any"}, + properties={ATTR_PROPERTIES_ID: "any"}, type="mock_type", ), ), @@ -476,19 +480,19 @@ async def test_discovered_by_dhcp_or_discovery( [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "any"}, + properties={ATTR_PROPERTIES_ID: "any"}, type="mock_type", ), ), @@ -520,19 +524,19 @@ async def test_discovered_by_dhcp_or_discovery_failed_to_get_device( [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "any"}, + properties={ATTR_PROPERTIES_ID: "any"}, type="mock_type", ), ), diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py index d59ed60796b..6f7da09fa0d 100644 --- a/tests/components/loqed/test_config_flow.py +++ b/tests/components/loqed/test_config_flow.py @@ -8,16 +8,16 @@ import aiohttp from loqedAPI import loqed from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.loqed.const import DOMAIN from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMocker -zeroconf_data = zeroconf.ZeroconfServiceInfo( +zeroconf_data = ZeroconfServiceInfo( ip_address=ip_address("192.168.12.34"), ip_addresses=[ip_address("192.168.12.34")], hostname="LOQED-ffeeddccbbaa.local", diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index b2edaa07155..cc80bc08817 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -10,7 +10,6 @@ from pylutron_caseta.smartbridge import Smartbridge import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.lutron_caseta import DOMAIN import homeassistant.components.lutron_caseta.config_flow as CasetaConfigFlow from homeassistant.components.lutron_caseta.const import ( @@ -23,6 +22,7 @@ from homeassistant.components.lutron_caseta.const import ( from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import ENTRY_MOCK_DATA, MockBridge @@ -421,7 +421,7 @@ async def test_zeroconf_host_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", @@ -449,7 +449,7 @@ async def test_zeroconf_lutron_id_already_configured(hass: HomeAssistant) -> Non result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", @@ -472,7 +472,7 @@ async def test_zeroconf_not_lutron_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="notlutron-abc.local.", @@ -500,7 +500,7 @@ async def test_zeroconf(hass: HomeAssistant, source, tmp_path: Path) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", diff --git a/tests/components/modern_forms/test_config_flow.py b/tests/components/modern_forms/test_config_flow.py index 5b10d4d729e..4ec5e92cd72 100644 --- a/tests/components/modern_forms/test_config_flow.py +++ b/tests/components/modern_forms/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import MagicMock, patch import aiohttp from aiomodernforms import ModernFormsConnectionError -from homeassistant.components import zeroconf from homeassistant.components.modern_forms.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import init_integration @@ -66,7 +66,7 @@ async def test_full_zeroconf_flow_implementation( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -138,7 +138,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -170,7 +170,7 @@ async def test_zeroconf_confirm_connection_error( CONF_HOST: "example.com", CONF_NAME: "test", }, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.com.", @@ -220,7 +220,7 @@ async def test_zeroconf_with_mac_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", diff --git a/tests/components/motion_blinds/test_config_flow.py b/tests/components/motion_blinds/test_config_flow.py index 77171b06ad6..821e4fa0278 100644 --- a/tests/components/motion_blinds/test_config_flow.py +++ b/tests/components/motion_blinds/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import Mock, patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.motion_blinds import const from homeassistant.components.motion_blinds.config_flow import DEFAULT_GATEWAY_NAME from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -346,7 +346,7 @@ async def test_config_flow_invalid_interface(hass: HomeAssistant) -> None: async def test_dhcp_flow(hass: HomeAssistant) -> None: """Successful flow from DHCP discovery.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST, hostname="MOTION_abcdef", macaddress=DHCP_FORMATTED_MAC, @@ -380,7 +380,7 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: async def test_dhcp_flow_abort(hass: HomeAssistant) -> None: """Test that DHCP discovery aborts if not Motionblinds.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST, hostname="MOTION_abcdef", macaddress=DHCP_FORMATTED_MAC, @@ -400,7 +400,7 @@ async def test_dhcp_flow_abort(hass: HomeAssistant) -> None: async def test_dhcp_flow_abort_invalid_response(hass: HomeAssistant) -> None: """Test that DHCP discovery aborts if device responded with invalid data.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST, hostname="MOTION_abcdef", macaddress=DHCP_FORMATTED_MAC, diff --git a/tests/components/motionmount/__init__.py b/tests/components/motionmount/__init__.py index da6fbae32a3..ed7dae26663 100644 --- a/tests/components/motionmount/__init__.py +++ b/tests/components/motionmount/__init__.py @@ -2,8 +2,8 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo HOST = "192.168.1.31" PORT = 23 @@ -21,7 +21,7 @@ MOCK_USER_INPUT = { CONF_PORT: PORT, } -MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = ZeroconfServiceInfo( type=TVM_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", ip_address=ip_address(ZEROCONF_HOST), @@ -31,7 +31,7 @@ MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = zeroconf.ZeroconfServiceInfo( properties={"txtvers": "1", "model": "TVM 7675"}, ) -MOCK_ZEROCONF_TVM_SERVICE_INFO_V2 = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_TVM_SERVICE_INFO_V2 = ZeroconfServiceInfo( type=TVM_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", ip_address=ip_address(ZEROCONF_HOST), diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index 6c11399c888..80c6e86f420 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -6,16 +6,16 @@ from unittest.mock import patch from nettigo_air_monitor import ApiError, AuthFailedError, CannotGetMacError import pytest -from homeassistant.components import zeroconf from homeassistant.components.nam.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("10.10.2.3"), ip_addresses=[ip_address("10.10.2.3")], hostname="mock_hostname", diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py index 97a314b0bf4..ba89405bc97 100644 --- a/tests/components/nanoleaf/test_config_flow.py +++ b/tests/components/nanoleaf/test_config_flow.py @@ -9,11 +9,15 @@ from aionanoleaf import InvalidToken, Unauthorized, Unavailable import pytest from homeassistant import config_entries -from homeassistant.components import ssdp, zeroconf from homeassistant.components.nanoleaf.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry @@ -248,13 +252,13 @@ async def test_discovery_link_unavailable( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=f"{TEST_NAME}.{type_in_discovery_info}", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: TEST_DEVICE_ID}, + properties={ATTR_PROPERTIES_ID: TEST_DEVICE_ID}, type=type_in_discovery_info, ), ) @@ -384,13 +388,13 @@ async def test_import_discovery_integration( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=f"{TEST_NAME}.{type_in_discovery}", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: TEST_DEVICE_ID}, + properties={ATTR_PROPERTIES_ID: TEST_DEVICE_ID}, type=type_in_discovery, ), ) @@ -432,7 +436,7 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={}, diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow.py index 3d28c1abf23..f08eeb82a1d 100644 --- a/tests/components/nest/test_config_flow.py +++ b/tests/components/nest/test_config_flow.py @@ -10,12 +10,12 @@ from google_nest_sdm.exceptions import AuthException import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.nest.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .common import ( CLIENT_ID, @@ -36,7 +36,7 @@ WEB_REDIRECT_URL = "https://example.com/auth/external/callback" APP_REDIRECT_URL = "urn:ietf:wg:oauth:2.0:oob" RAND_SUBSCRIBER_SUFFIX = "ABCDEF" -FAKE_DHCP_DATA = dhcp.DhcpServiceInfo( +FAKE_DHCP_DATA = DhcpServiceInfo( ip="127.0.0.2", macaddress="001122334455", hostname="fake_hostname" ) diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index 436f75b12ec..f5714d69a98 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -7,7 +7,6 @@ from pyatmo.const import ALL_SCOPES import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.netatmo import config_flow from homeassistant.components.netatmo.const import ( CONF_NEW_AREA, @@ -20,6 +19,10 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .conftest import CLIENT_ID @@ -46,13 +49,13 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( "netatmo", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) diff --git a/tests/components/netgear/test_config_flow.py b/tests/components/netgear/test_config_flow.py index 724a0568580..3c83bb57c43 100644 --- a/tests/components/netgear/test_config_flow.py +++ b/tests/components/netgear/test_config_flow.py @@ -5,7 +5,6 @@ from unittest.mock import Mock, patch from pynetgear import DEFAULT_USER import pytest -from homeassistant.components import ssdp from homeassistant.components.netgear.const import ( CONF_CONSIDER_HOME, DOMAIN, @@ -23,6 +22,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -208,14 +213,14 @@ async def test_ssdp_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URL_SLL, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", - ssdp.ATTR_UPNP_PRESENTATION_URL: URL, - ssdp.ATTR_UPNP_SERIAL: SERIAL, + ATTR_UPNP_MODEL_NUMBER: "RBR20", + ATTR_UPNP_PRESENTATION_URL: URL, + ATTR_UPNP_SERIAL: SERIAL, }, ), ) @@ -228,13 +233,13 @@ async def test_ssdp_no_serial(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URL, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", - ssdp.ATTR_UPNP_PRESENTATION_URL: URL, + ATTR_UPNP_MODEL_NUMBER: "RBR20", + ATTR_UPNP_PRESENTATION_URL: URL, }, ), ) @@ -253,14 +258,14 @@ async def test_ssdp_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URLipv6, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", - ssdp.ATTR_UPNP_PRESENTATION_URL: URL, - ssdp.ATTR_UPNP_SERIAL: SERIAL, + ATTR_UPNP_MODEL_NUMBER: "RBR20", + ATTR_UPNP_PRESENTATION_URL: URL, + ATTR_UPNP_SERIAL: SERIAL, }, ), ) @@ -273,14 +278,14 @@ async def test_ssdp(hass: HomeAssistant, service) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URL, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", - ssdp.ATTR_UPNP_PRESENTATION_URL: URL, - ssdp.ATTR_UPNP_SERIAL: SERIAL, + ATTR_UPNP_MODEL_NUMBER: "RBR20", + ATTR_UPNP_PRESENTATION_URL: URL, + ATTR_UPNP_SERIAL: SERIAL, }, ), ) @@ -305,14 +310,14 @@ async def test_ssdp_port_5555(hass: HomeAssistant, service) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URL_SLL, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: MODELS_PORT_5555[0], - ssdp.ATTR_UPNP_PRESENTATION_URL: URL_SSL, - ssdp.ATTR_UPNP_SERIAL: SERIAL, + ATTR_UPNP_MODEL_NUMBER: MODELS_PORT_5555[0], + ATTR_UPNP_PRESENTATION_URL: URL_SSL, + ATTR_UPNP_SERIAL: SERIAL, }, ), ) diff --git a/tests/components/nuki/test_config_flow.py b/tests/components/nuki/test_config_flow.py index d4ddc261f1e..efc6c6c5abc 100644 --- a/tests/components/nuki/test_config_flow.py +++ b/tests/components/nuki/test_config_flow.py @@ -6,11 +6,11 @@ from pynuki.bridge import InvalidCredentialsException from requests.exceptions import RequestException from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.nuki.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .mock import DHCP_FORMATTED_MAC, HOST, MOCK_INFO, NAME, setup_nuki_integration @@ -151,9 +151,7 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: """Test that DHCP discovery for new bridge works.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( - hostname=NAME, ip=HOST, macaddress=DHCP_FORMATTED_MAC - ), + data=DhcpServiceInfo(hostname=NAME, ip=HOST, macaddress=DHCP_FORMATTED_MAC), context={"source": config_entries.SOURCE_DHCP}, ) @@ -196,9 +194,7 @@ async def test_dhcp_flow_already_configured(hass: HomeAssistant) -> None: await setup_nuki_integration(hass) result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( - hostname=NAME, ip=HOST, macaddress=DHCP_FORMATTED_MAC - ), + data=DhcpServiceInfo(hostname=NAME, ip=HOST, macaddress=DHCP_FORMATTED_MAC), context={"source": config_entries.SOURCE_DHCP}, ) diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index 537b6aba5ac..ed9c87f2f90 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch from aionut import NUTError, NUTLoginError from homeassistant import config_entries, setup -from homeassistant.components import zeroconf from homeassistant.components.nut.const import DOMAIN from homeassistant.const import ( CONF_ALIAS, @@ -20,6 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .util import _get_mock_nutclient @@ -38,7 +38,7 @@ async def test_form_zeroconf(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", diff --git a/tests/components/obihai/__init__.py b/tests/components/obihai/__init__.py index b88f0a5c874..7b483514dcf 100644 --- a/tests/components/obihai/__init__.py +++ b/tests/components/obihai/__init__.py @@ -1,7 +1,7 @@ """Tests for the Obihai Integration.""" -from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo USER_INPUT = { CONF_HOST: "10.10.10.30", @@ -9,7 +9,7 @@ USER_INPUT = { CONF_USERNAME: "admin", } -DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( +DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="obi200", ip="192.168.1.100", macaddress="9cadef000000", diff --git a/tests/components/octoprint/test_config_flow.py b/tests/components/octoprint/test_config_flow.py index e0696486718..d7d7e43e99c 100644 --- a/tests/components/octoprint/test_config_flow.py +++ b/tests/components/octoprint/test_config_flow.py @@ -6,10 +6,11 @@ from unittest.mock import patch from pyoctoprintapi import ApiError, DiscoverySettings from homeassistant import config_entries -from homeassistant.components import ssdp, zeroconf from homeassistant.components.octoprint.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -183,7 +184,7 @@ async def test_show_zerconf_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -252,7 +253,7 @@ async def test_show_ssdp_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -525,7 +526,7 @@ async def test_duplicate_zerconf_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -551,7 +552,7 @@ async def test_duplicate_ssdp_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index f619127d9b9..865bc1a6bbf 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.onkyo import InputSource from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow from homeassistant.components.onkyo.const import ( @@ -18,6 +17,10 @@ from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType, InvalidData +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + SsdpServiceInfo, +) from . import ( create_config_entry_from_info, @@ -95,9 +98,9 @@ async def test_ssdp_discovery_already_configured( ) config_entry.add_to_hass(hass) - discovery_info = ssdp.SsdpServiceInfo( + discovery_info = SsdpServiceInfo( ssdp_location="http://192.168.1.100:8080", - upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", ssdp_st="mock_st", @@ -232,9 +235,9 @@ async def test_ssdp_discovery_success( hass: HomeAssistant, default_mock_discovery ) -> None: """Test SSDP discovery with valid host.""" - discovery_info = ssdp.SsdpServiceInfo( + discovery_info = SsdpServiceInfo( ssdp_location="http://192.168.1.100:8080", - upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", ssdp_st="mock_st", @@ -261,9 +264,9 @@ async def test_ssdp_discovery_success( async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None: """Test SSDP discovery with host info error.""" - discovery_info = ssdp.SsdpServiceInfo( + discovery_info = SsdpServiceInfo( ssdp_location="http://192.168.1.100:8080", - upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_st="mock_st", ) @@ -286,9 +289,9 @@ async def test_ssdp_discovery_host_none_info( hass: HomeAssistant, stub_mock_discovery ) -> None: """Test SSDP discovery with host info error.""" - discovery_info = ssdp.SsdpServiceInfo( + discovery_info = SsdpServiceInfo( ssdp_location="http://192.168.1.100:8080", - upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_st="mock_st", ) @@ -307,9 +310,9 @@ async def test_ssdp_discovery_no_location( hass: HomeAssistant, default_mock_discovery ) -> None: """Test SSDP discovery with no location.""" - discovery_info = ssdp.SsdpServiceInfo( + discovery_info = SsdpServiceInfo( ssdp_location=None, - upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_st="mock_st", ) @@ -328,9 +331,9 @@ async def test_ssdp_discovery_no_host( hass: HomeAssistant, default_mock_discovery ) -> None: """Test SSDP discovery with no host.""" - discovery_info = ssdp.SsdpServiceInfo( + discovery_info = SsdpServiceInfo( ssdp_location="http://", - upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_st="mock_st", ) diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index 5c01fb2d200..0bad7050fd9 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -6,13 +6,13 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.onvif import DOMAIN, config_flow from homeassistant.config_entries import SOURCE_DHCP from homeassistant.const import CONF_HOST, CONF_NAME, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( HOST, @@ -44,10 +44,10 @@ DISCOVERY = [ "MAC": "ff:ee:dd:cc:bb:aa", }, ] -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname="any", ip="5.6.7.8", macaddress=MAC.lower().replace(":", "") ) -DHCP_DISCOVERY_SAME_IP = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY_SAME_IP = DhcpServiceInfo( hostname="any", ip="1.2.3.4", macaddress=MAC.lower().replace(":", "") ) diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index 6fe584a511c..711cc6c1d86 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -17,10 +17,10 @@ from pyoverkiz.exceptions import ( import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.overkiz.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -742,7 +742,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No """Test that DHCP discovery for new bridge works.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="gateway-1234-5678-9123", ip="192.168.1.4", macaddress="f8811a000000", @@ -801,7 +801,7 @@ async def test_dhcp_flow_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="gateway-1234-5678-9123", ip="192.168.1.4", macaddress="f8811a000000", diff --git a/tests/components/palazzetti/test_config_flow.py b/tests/components/palazzetti/test_config_flow.py index 03c56c33d0c..8550f1a3de0 100644 --- a/tests/components/palazzetti/test_config_flow.py +++ b/tests/components/palazzetti/test_config_flow.py @@ -4,12 +4,12 @@ from unittest.mock import AsyncMock from pypalazzetti.exceptions import CommunicationError -from homeassistant.components import dhcp from homeassistant.components.palazzetti.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -101,7 +101,7 @@ async def test_dhcp_flow( """Test the DHCP flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="connbox1234", ip="192.168.1.1", macaddress="11:22:33:44:55:66" ), context={"source": SOURCE_DHCP}, @@ -130,7 +130,7 @@ async def test_dhcp_flow_error( result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="connbox1234", ip="192.168.1.1", macaddress="11:22:33:44:55:66" ), context={"source": SOURCE_DHCP}, diff --git a/tests/components/peblar/test_config_flow.py b/tests/components/peblar/test_config_flow.py index a97e8d3b564..9f0806f0591 100644 --- a/tests/components/peblar/test_config_flow.py +++ b/tests/components/peblar/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import MagicMock from peblar import PeblarAuthenticationError, PeblarConnectionError import pytest -from homeassistant.components import zeroconf from homeassistant.components.peblar.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -232,7 +232,7 @@ async def test_zeroconf_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -273,7 +273,7 @@ async def test_zeroconf_flow_abort_no_serial(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -308,7 +308,7 @@ async def test_zeroconf_flow_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -362,7 +362,7 @@ async def test_zeroconf_flow_not_discovered_again( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -389,7 +389,7 @@ async def test_user_flow_with_zeroconf_in_progress(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, diff --git a/tests/components/powerfox/test_config_flow.py b/tests/components/powerfox/test_config_flow.py index a38f316faf3..377ae9c622c 100644 --- a/tests/components/powerfox/test_config_flow.py +++ b/tests/components/powerfox/test_config_flow.py @@ -5,18 +5,18 @@ from unittest.mock import AsyncMock, patch from powerfox import PowerfoxAuthenticationError, PowerfoxConnectionError import pytest -from homeassistant.components import zeroconf from homeassistant.components.powerfox.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import MOCK_DIRECT_HOST from tests.common import MockConfigEntry -MOCK_ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=MOCK_DIRECT_HOST, ip_addresses=[MOCK_DIRECT_HOST], hostname="powerfox.local", diff --git a/tests/components/powerwall/test_config_flow.py b/tests/components/powerwall/test_config_flow.py index 1ff1470f81c..cd4f1250aa4 100644 --- a/tests/components/powerwall/test_config_flow.py +++ b/tests/components/powerwall/test_config_flow.py @@ -11,12 +11,12 @@ from tesla_powerwall import ( ) from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.powerwall.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo import homeassistant.util.dt as dt_util from .mocks import ( @@ -161,7 +161,7 @@ async def test_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="any", @@ -188,7 +188,7 @@ async def test_already_configured_with_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="00GGX", @@ -230,7 +230,7 @@ async def test_dhcp_discovery_manual_configure(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="any", @@ -272,7 +272,7 @@ async def test_dhcp_discovery_auto_configure(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="00GGX", @@ -316,7 +316,7 @@ async def test_dhcp_discovery_cannot_connect(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="00GGX", @@ -394,7 +394,7 @@ async def test_dhcp_discovery_update_ip_address(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -431,7 +431,7 @@ async def test_dhcp_discovery_does_not_update_ip_when_auth_fails( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -468,7 +468,7 @@ async def test_dhcp_discovery_does_not_update_ip_when_auth_successful( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -503,7 +503,7 @@ async def test_dhcp_discovery_updates_unique_id(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -542,7 +542,7 @@ async def test_dhcp_discovery_updates_unique_id_when_entry_is_failed( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -581,7 +581,7 @@ async def test_discovered_wifi_does_not_update_ip_if_is_still_online( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -630,7 +630,7 @@ async def test_discovered_wifi_does_not_update_ip_online_but_access_denied( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), diff --git a/tests/components/pure_energie/test_config_flow.py b/tests/components/pure_energie/test_config_flow.py index 4305dab2236..96704e900dd 100644 --- a/tests/components/pure_energie/test_config_flow.py +++ b/tests/components/pure_energie/test_config_flow.py @@ -5,12 +5,12 @@ from unittest.mock import MagicMock from gridnet import GridNetConnectionError -from homeassistant.components import zeroconf from homeassistant.components.pure_energie.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo async def test_full_user_flow_implementation( @@ -48,7 +48,7 @@ async def test_full_zeroconf_flow_implementationn( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -104,7 +104,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", diff --git a/tests/components/qnap_qsw/test_config_flow.py b/tests/components/qnap_qsw/test_config_flow.py index 94e80d3cd16..f09cf7493b5 100644 --- a/tests/components/qnap_qsw/test_config_flow.py +++ b/tests/components/qnap_qsw/test_config_flow.py @@ -6,19 +6,19 @@ from aioqsw.const import API_MAC_ADDR, API_PRODUCT, API_RESULT from aioqsw.exceptions import LoginError, QswError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.qnap_qsw.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .util import CONFIG, LIVE_MOCK, SYSTEM_BOARD_MOCK, USERS_LOGIN_MOCK from tests.common import MockConfigEntry -DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( +DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="qsw-m408-4c", ip="192.168.1.200", macaddress="245ebe000000", diff --git a/tests/components/rabbitair/test_config_flow.py b/tests/components/rabbitair/test_config_flow.py index 7f9479339a5..db4f4de6c49 100644 --- a/tests/components/rabbitair/test_config_flow.py +++ b/tests/components/rabbitair/test_config_flow.py @@ -10,12 +10,12 @@ import pytest from rabbitair import Mode, Model, Speed from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.rabbitair.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo TEST_HOST = "1.1.1.1" TEST_NAME = "abcdef1234_123456789012345678" @@ -26,7 +26,7 @@ TEST_HARDWARE = "1.0.0.4" TEST_UNIQUE_ID = format_mac(TEST_MAC) TEST_TITLE = "Rabbit Air" -ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], port=9009, diff --git a/tests/components/rachio/test_config_flow.py b/tests/components/rachio/test_config_flow.py index 586b31b092f..6448d46a8a1 100644 --- a/tests/components/rachio/test_config_flow.py +++ b/tests/components/rachio/test_config_flow.py @@ -4,7 +4,6 @@ from ipaddress import ip_address from unittest.mock import MagicMock, patch from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.rachio.const import ( CONF_CUSTOM_URL, CONF_MANUAL_RUN_MINS, @@ -13,6 +12,10 @@ from homeassistant.components.rachio.const import ( from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry @@ -120,13 +123,13 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) @@ -145,13 +148,13 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) @@ -171,13 +174,13 @@ async def test_form_homekit_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) diff --git a/tests/components/radiotherm/test_config_flow.py b/tests/components/radiotherm/test_config_flow.py index a188f8fcb70..a84f3870357 100644 --- a/tests/components/radiotherm/test_config_flow.py +++ b/tests/components/radiotherm/test_config_flow.py @@ -6,11 +6,11 @@ from radiotherm import CommonThermostat from radiotherm.validate import RadiothermTstatError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.radiotherm.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -112,7 +112,7 @@ async def test_dhcp_can_confirm(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="radiotherm", ip="1.2.3.4", macaddress="aabbccddeeff", @@ -156,7 +156,7 @@ async def test_dhcp_fails_to_connect(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="radiotherm", ip="1.2.3.4", macaddress="aabbccddeeff", @@ -185,7 +185,7 @@ async def test_dhcp_already_exists(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="radiotherm", ip="1.2.3.4", macaddress="aabbccddeeff", diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 5838dcc35c8..cd8b2cb39c8 100644 --- a/tests/components/rainmachine/test_config_flow.py +++ b/tests/components/rainmachine/test_config_flow.py @@ -7,7 +7,6 @@ import pytest from regenmaschine.errors import RainMachineError from homeassistant import config_entries, setup -from homeassistant.components import zeroconf from homeassistant.components.rainmachine import ( CONF_ALLOW_INACTIVE_ZONES_TO_RUN, CONF_DEFAULT_ZONE_RUN_TIME, @@ -18,6 +17,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo async def test_duplicate_error(hass: HomeAssistant, config, config_entry) -> None: @@ -168,7 +168,7 @@ async def test_step_homekit_zeroconf_ip_already_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", @@ -196,7 +196,7 @@ async def test_step_homekit_zeroconf_ip_change( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.2"), ip_addresses=[ip_address("192.168.1.2")], hostname="mock_hostname", @@ -225,7 +225,7 @@ async def test_step_homekit_zeroconf_new_controller_when_some_exist( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", @@ -279,7 +279,7 @@ async def test_discovery_by_homekit_and_zeroconf_same_time( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", @@ -299,7 +299,7 @@ async def test_discovery_by_homekit_and_zeroconf_same_time( result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 59342934c1c..5950fc49966 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -15,7 +15,6 @@ from reolink_aio.exceptions import ( ) from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN @@ -32,6 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .conftest import ( DHCP_FORMATTED_MAC, @@ -381,7 +381,7 @@ async def test_reauth_abort_unique_id_mismatch( async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Successful flow from DHCP discovery.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST, hostname="Reolink", macaddress=DHCP_FORMATTED_MAC, @@ -451,7 +451,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( async_fire_time_changed(hass) await hass.async_block_till_done() - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST2, hostname="Reolink", macaddress=DHCP_FORMATTED_MAC, @@ -548,7 +548,7 @@ async def test_dhcp_ip_update( async_fire_time_changed(hass) await hass.async_block_till_done() - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST2, hostname="Reolink", macaddress=DHCP_FORMATTED_MAC, @@ -620,7 +620,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST2, hostname="Reolink", macaddress=DHCP_FORMATTED_MAC, diff --git a/tests/components/ring/test_config_flow.py b/tests/components/ring/test_config_flow.py index 409cdac55aa..778bad67d77 100644 --- a/tests/components/ring/test_config_flow.py +++ b/tests/components/ring/test_config_flow.py @@ -6,12 +6,12 @@ import pytest import ring_doorbell from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.ring import DOMAIN from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .conftest import MOCK_HARDWARE_ID @@ -269,9 +269,7 @@ async def test_dhcp_discovery( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip=ip_address, macaddress=mac_address, hostname=hostname - ), + data=DhcpServiceInfo(ip=ip_address, macaddress=mac_address, hostname=hostname), ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} @@ -302,9 +300,7 @@ async def test_dhcp_discovery( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip=ip_address, macaddress=mac_address, hostname=hostname - ), + data=DhcpServiceInfo(ip=ip_address, macaddress=mac_address, hostname=hostname), ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/roku/__init__.py b/tests/components/roku/__init__.py index 36b09587d63..3165e5c4ba0 100644 --- a/tests/components/roku/__init__.py +++ b/tests/components/roku/__init__.py @@ -2,10 +2,14 @@ from ipaddress import ip_address -from homeassistant.components import ssdp, zeroconf from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, ) NAME = "Roku 3" @@ -16,7 +20,7 @@ SSDP_LOCATION = "http://192.168.1.160/" UPNP_FRIENDLY_NAME = "My Roku 3" UPNP_SERIAL = "1GU48T017973" -MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, @@ -28,14 +32,14 @@ MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( HOMEKIT_HOST = "192.168.1.161" -MOCK_HOMEKIT_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_HOMEKIT_DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address(HOMEKIT_HOST), ip_addresses=[ip_address(HOMEKIT_HOST)], hostname="mock_hostname", name="onn._hap._tcp.local.", port=None, properties={ - zeroconf.ATTR_PROPERTIES_ID: "2d:97:da:ee:dc:99", + ATTR_PROPERTIES_ID: "2d:97:da:ee:dc:99", }, type="mock_type", ) diff --git a/tests/components/romy/test_config_flow.py b/tests/components/romy/test_config_flow.py index a29f899ee9d..55d54f3a80b 100644 --- a/tests/components/romy/test_config_flow.py +++ b/tests/components/romy/test_config_flow.py @@ -6,11 +6,14 @@ from unittest.mock import Mock, PropertyMock, patch from romy import RomyRobot from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.romy.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) def _create_mocked_romy( @@ -164,14 +167,14 @@ async def test_show_user_form_robot_reachable_again(hass: HomeAssistant) -> None assert result2["type"] is FlowResultType.CREATE_ENTRY -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=8080, hostname="aicu-aicgsbksisfapcjqmqjq.local", type="mock_type", name="myROMY", - properties={zeroconf.ATTR_PROPERTIES_ID: "aicu-aicgsbksisfapcjqmqjqZERO"}, + properties={ATTR_PROPERTIES_ID: "aicu-aicgsbksisfapcjqmqjqZERO"}, ) diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index dedccc14249..5b6766f7eb9 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import MagicMock, PropertyMock, patch import pytest from roombapy import RoombaConnectionError, RoombaInfo -from homeassistant.components import dhcp, zeroconf from homeassistant.components.roomba import config_flow from homeassistant.components.roomba.const import ( CONF_BLID, @@ -23,6 +22,8 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -32,7 +33,7 @@ VALID_CONFIG = {CONF_HOST: MOCK_IP, CONF_BLID: "BLID", CONF_PASSWORD: "password" DISCOVERY_DEVICES = [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=MOCK_IP, macaddress="501479ddeeff", hostname="irobot-blid", @@ -40,7 +41,7 @@ DISCOVERY_DEVICES = [ ), ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=MOCK_IP, macaddress="80a589ddeeff", hostname="roomba-blid", @@ -48,7 +49,7 @@ DISCOVERY_DEVICES = [ ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(MOCK_IP), ip_addresses=[ip_address(MOCK_IP)], hostname="irobot-blid.local.", @@ -60,7 +61,7 @@ DISCOVERY_DEVICES = [ ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(MOCK_IP), ip_addresses=[ip_address(MOCK_IP)], hostname="roomba-blid.local.", @@ -74,12 +75,12 @@ DISCOVERY_DEVICES = [ DHCP_DISCOVERY_DEVICES_WITHOUT_MATCHING_IP = [ - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip="4.4.4.4", macaddress="50:14:79:DD:EE:FF", hostname="irobot-blid", ), - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip="5.5.5.5", macaddress="80:A5:89:DD:EE:FF", hostname="roomba-blid", @@ -692,7 +693,7 @@ async def test_form_user_discovery_and_password_fetch_gets_connection_refused( @pytest.mark.parametrize("discovery_data", DISCOVERY_DEVICES) async def test_dhcp_discovery_and_roomba_discovery_finds( hass: HomeAssistant, - discovery_data: tuple[str, dhcp.DhcpServiceInfo | zeroconf.ZeroconfServiceInfo], + discovery_data: tuple[str, DhcpServiceInfo | ZeroconfServiceInfo], ) -> None: """Test we can process the discovery from dhcp and roomba discovery matches the device.""" @@ -910,7 +911,7 @@ async def test_dhcp_discovery_with_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blid", @@ -933,7 +934,7 @@ async def test_dhcp_discovery_already_configured_host(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blid", @@ -959,7 +960,7 @@ async def test_dhcp_discovery_already_configured_blid(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blid", @@ -985,7 +986,7 @@ async def test_dhcp_discovery_not_irobot(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="Notirobot-blid", @@ -1006,7 +1007,7 @@ async def test_dhcp_discovery_partial_hostname(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blid", @@ -1023,7 +1024,7 @@ async def test_dhcp_discovery_partial_hostname(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blidthatislonger", @@ -1044,7 +1045,7 @@ async def test_dhcp_discovery_partial_hostname(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-bl", @@ -1082,7 +1083,7 @@ async def test_dhcp_discovery_when_user_flow_in_progress(hass: HomeAssistant) -> result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blidthatislonger", diff --git a/tests/components/ruuvi_gateway/test_config_flow.py b/tests/components/ruuvi_gateway/test_config_flow.py index c4ecf929f94..14f74a7add7 100644 --- a/tests/components/ruuvi_gateway/test_config_flow.py +++ b/tests/components/ruuvi_gateway/test_config_flow.py @@ -6,10 +6,10 @@ from aioruuvigateway.excs import CannotConnect, InvalidAuth import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.ruuvi_gateway.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .consts import ( BASE_DATA, @@ -32,7 +32,7 @@ DHCP_DATA = {**BASE_DATA, "host": DHCP_IP} BASE_DATA, ), ( - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname="RuuviGateway1234", ip=DHCP_IP, macaddress="1234567890ab", From af40b6524efffea364eb17d649ff9aa219de7db4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Jan 2025 13:16:59 +0100 Subject: [PATCH 0686/2987] Use new ServiceInfo location in component tests (part 3) (#136064) --- tests/components/daikin/test_config_flow.py | 4 +- tests/components/deconz/test_config_flow.py | 10 ++-- tests/components/deconz/test_hub.py | 4 +- tests/components/denonavr/test_config_flow.py | 35 +++++++----- tests/components/devialet/__init__.py | 4 +- tests/components/devolo_home_control/const.py | 8 +-- tests/components/directv/__init__.py | 4 +- tests/components/dlink/conftest.py | 6 +- tests/components/dlink/test_config_flow.py | 4 +- tests/components/dlna_dmr/test_config_flow.py | 56 ++++++++++--------- .../components/dlna_dmr/test_media_player.py | 45 +++++++-------- tests/components/dlna_dms/test_config_flow.py | 48 +++++++++------- .../dlna_dms/test_device_availability.py | 43 +++++++------- .../dlna_dms/test_dms_device_source.py | 3 +- tests/components/doorbird/test_config_flow.py | 14 ++--- tests/components/elgato/test_config_flow.py | 12 ++-- tests/components/elkm1/test_config_flow.py | 6 +- tests/components/elmax/test_config_flow.py | 8 +-- tests/components/emonitor/test_config_flow.py | 4 +- .../enphase_envoy/test_config_flow.py | 20 +++---- tests/components/esphome/test_config_flow.py | 27 ++++----- tests/components/esphome/test_manager.py | 4 +- tests/components/flux_led/__init__.py | 4 +- tests/components/flux_led/test_config_flow.py | 4 +- .../forked_daapd/test_config_flow.py | 14 ++--- tests/components/freebox/test_config_flow.py | 4 +- tests/components/fritz/const.py | 4 +- tests/components/fritz/test_config_flow.py | 14 +++-- tests/components/fritzbox/test_config_flow.py | 10 ++-- .../frontier_silicon/test_config_flow.py | 6 +- tests/components/goalzero/__init__.py | 4 +- .../components/gogogate2/test_config_flow.py | 32 ++++++----- tests/components/guardian/test_config_flow.py | 15 ++--- tests/components/harmony/test_config_flow.py | 8 +-- tests/components/heos/conftest.py | 43 ++++++++------ tests/components/heos/test_config_flow.py | 9 +-- .../homekit_controller/test_config_flow.py | 27 +++++---- .../components/homewizard/test_config_flow.py | 23 ++++---- .../components/huawei_lte/test_config_flow.py | 37 +++++++----- tests/components/hue/test_config_flow.py | 25 +++++---- .../hunterdouglas_powerview/const.py | 24 ++++---- .../test_config_flow.py | 11 ++-- tests/components/hyperion/test_config_flow.py | 4 +- 43 files changed, 377 insertions(+), 314 deletions(-) diff --git a/tests/components/daikin/test_config_flow.py b/tests/components/daikin/test_config_flow.py index 5c432e111dd..612ae7ab649 100644 --- a/tests/components/daikin/test_config_flow.py +++ b/tests/components/daikin/test_config_flow.py @@ -7,12 +7,12 @@ from aiohttp import ClientError, web_exceptions from pydaikin.exceptions import DaikinException import pytest -from homeassistant.components import zeroconf from homeassistant.components.daikin.const import KEY_MAC from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -121,7 +121,7 @@ async def test_api_password_abort(hass: HomeAssistant) -> None: [ ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(HOST), ip_addresses=[ip_address(HOST)], hostname="mock_hostname", diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index c595cc4e311..fe5fe022427 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pydeconz import pytest -from homeassistant.components import ssdp from homeassistant.components.deconz.config_flow import ( CONF_MANUAL_INPUT, CONF_SERIAL, @@ -28,6 +27,7 @@ from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL, + SsdpServiceInfo, ) from .conftest import API_KEY, BRIDGE_ID @@ -438,7 +438,7 @@ async def test_flow_ssdp_discovery( """Test that config flow for one discovered bridge works.""" result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:80/", @@ -486,7 +486,7 @@ async def test_ssdp_discovery_update_configuration( ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://2.3.4.5:80/", @@ -512,7 +512,7 @@ async def test_ssdp_discovery_dont_update_configuration( result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:80/", @@ -536,7 +536,7 @@ async def test_ssdp_discovery_dont_update_existing_hassio_configuration( """Test to ensure the SSDP discovery does not update an Hass.io entry.""" result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:80/", diff --git a/tests/components/deconz/test_hub.py b/tests/components/deconz/test_hub.py index 7fe89aaf550..1b000828b85 100644 --- a/tests/components/deconz/test_hub.py +++ b/tests/components/deconz/test_hub.py @@ -6,7 +6,6 @@ from pydeconz.websocket import State import pytest from syrupy import SnapshotAssertion -from homeassistant.components import ssdp from homeassistant.components.deconz.config_flow import DECONZ_MANUFACTURERURL from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN from homeassistant.config_entries import SOURCE_SSDP @@ -17,6 +16,7 @@ from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL, ATTR_UPNP_UDN, + SsdpServiceInfo, ) from .conftest import BRIDGE_ID @@ -81,7 +81,7 @@ async def test_update_address( ): await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_st="mock_st", ssdp_usn="mock_usn", ssdp_location="http://2.3.4.5:80/", diff --git a/tests/components/denonavr/test_config_flow.py b/tests/components/denonavr/test_config_flow.py index 324b795052c..92fe381ac4d 100644 --- a/tests/components/denonavr/test_config_flow.py +++ b/tests/components/denonavr/test_config_flow.py @@ -5,7 +5,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.denonavr.config_flow import ( CONF_MANUFACTURER, CONF_SERIAL_NUMBER, @@ -21,6 +20,12 @@ from homeassistant.components.denonavr.config_flow import ( from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -313,14 +318,14 @@ async def test_config_flow_ssdp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=TEST_SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, - ssdp.ATTR_UPNP_MODEL_NAME: TEST_MODEL, - ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, + ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, + ATTR_UPNP_MODEL_NAME: TEST_MODEL, + ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, }, ), ) @@ -353,14 +358,14 @@ async def test_config_flow_ssdp_not_denon(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=TEST_SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: "NotSupported", - ssdp.ATTR_UPNP_MODEL_NAME: TEST_MODEL, - ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, + ATTR_UPNP_MANUFACTURER: "NotSupported", + ATTR_UPNP_MODEL_NAME: TEST_MODEL, + ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, }, ), ) @@ -377,12 +382,12 @@ async def test_config_flow_ssdp_missing_info(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=TEST_SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, + ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, }, ), ) @@ -399,14 +404,14 @@ async def test_config_flow_ssdp_ignored_model(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=TEST_SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, - ssdp.ATTR_UPNP_MODEL_NAME: TEST_IGNORED_MODEL, - ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, + ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, + ATTR_UPNP_MODEL_NAME: TEST_IGNORED_MODEL, + ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, }, ), ) diff --git a/tests/components/devialet/__init__.py b/tests/components/devialet/__init__.py index 28ab6229c44..08ccaffa92d 100644 --- a/tests/components/devialet/__init__.py +++ b/tests/components/devialet/__init__.py @@ -5,10 +5,10 @@ from ipaddress import ip_address from aiohttp import ClientError as ServerTimeoutError from devialet.const import UrlSuffix -from homeassistant.components import zeroconf from homeassistant.components.devialet.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -25,7 +25,7 @@ CONF_DATA = { MOCK_CONFIG = {DOMAIN: [{CONF_HOST: HOST}]} MOCK_USER_INPUT = {CONF_HOST: HOST} -MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address(HOST), ip_addresses=[ip_address(HOST)], hostname="PhantomISilver-L00P00000AB11.local.", diff --git a/tests/components/devolo_home_control/const.py b/tests/components/devolo_home_control/const.py index 3351e42c988..06e7a8bcd9c 100644 --- a/tests/components/devolo_home_control/const.py +++ b/tests/components/devolo_home_control/const.py @@ -2,9 +2,9 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.1"), ip_addresses=[ip_address("192.168.0.1")], port=14791, @@ -22,7 +22,7 @@ DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( }, ) -DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.1"), ip_addresses=[ip_address("192.168.0.1")], hostname="mock_hostname", @@ -32,7 +32,7 @@ DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = zeroconf.ZeroconfServiceInfo( type="mock_type", ) -DISCOVERY_INFO_WRONG_DEVICE = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO_WRONG_DEVICE = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.1"), ip_addresses=[ip_address("192.168.0.1")], hostname="mock_hostname", diff --git a/tests/components/directv/__init__.py b/tests/components/directv/__init__.py index ae22e280000..48a334611d3 100644 --- a/tests/components/directv/__init__.py +++ b/tests/components/directv/__init__.py @@ -2,10 +2,10 @@ from http import HTTPStatus -from homeassistant.components import ssdp from homeassistant.components.directv.const import CONF_RECEIVER_ID, DOMAIN from homeassistant.const import CONF_HOST, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -16,7 +16,7 @@ SSDP_LOCATION = "http://127.0.0.1/" UPNP_SERIAL = "RID-028877455858" MOCK_CONFIG = {DOMAIN: [{CONF_HOST: HOST}]} -MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, diff --git a/tests/components/dlink/conftest.py b/tests/components/dlink/conftest.py index c56b93c4d3d..d59e06ef444 100644 --- a/tests/components/dlink/conftest.py +++ b/tests/components/dlink/conftest.py @@ -6,11 +6,11 @@ from unittest.mock import MagicMock, patch import pytest -from homeassistant.components import dhcp from homeassistant.components.dlink.const import CONF_USE_LEGACY_PROTOCOL, DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -29,13 +29,13 @@ CONF_DHCP_DATA = { CONF_DATA = CONF_DHCP_DATA | {CONF_HOST: HOST} -CONF_DHCP_FLOW = dhcp.DhcpServiceInfo( +CONF_DHCP_FLOW = DhcpServiceInfo( ip=HOST, macaddress=DHCP_FORMATTED_MAC, hostname="dsp-w215", ) -CONF_DHCP_FLOW_NEW_IP = dhcp.DhcpServiceInfo( +CONF_DHCP_FLOW_NEW_IP = DhcpServiceInfo( ip="5.6.7.8", macaddress=DHCP_FORMATTED_MAC, hostname="dsp-w215", diff --git a/tests/components/dlink/test_config_flow.py b/tests/components/dlink/test_config_flow.py index b6f025bb5b0..0449f68263c 100644 --- a/tests/components/dlink/test_config_flow.py +++ b/tests/components/dlink/test_config_flow.py @@ -2,12 +2,12 @@ from unittest.mock import MagicMock, patch -from homeassistant.components import dhcp from homeassistant.components.dlink.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .conftest import ( CONF_DATA, @@ -160,7 +160,7 @@ async def test_dhcp_unique_id_assignment( hass: HomeAssistant, mocked_plug: MagicMock ) -> None: """Test dhcp initialized flow with no unique id for matching entry.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip="2.3.4.5", macaddress="11:22:33:44:55:66", hostname="dsp-w215", diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py index cb32001e1e5..e02baceb380 100644 --- a/tests/components/dlna_dmr/test_config_flow.py +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -12,7 +12,6 @@ from async_upnp_client.exceptions import UpnpError import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.dlna_dmr.const import ( CONF_BROWSE_UNFILTERED, CONF_CALLBACK_URL_OVERRIDE, @@ -23,6 +22,15 @@ from homeassistant.components.dlna_dmr.const import ( from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_MAC, CONF_TYPE, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERVICE_LIST, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import ( MOCK_DEVICE_HOST_ADDR, @@ -48,17 +56,17 @@ CHANGED_DEVICE_UDN = "uuid:7cc6da13-7f5d-4ace-9729-badbadbadbad" MOCK_ROOT_DEVICE_UDN = "ROOT_DEVICE" -MOCK_DISCOVERY = ssdp.SsdpServiceInfo( +MOCK_DISCOVERY = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_location=MOCK_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, ssdp_st=MOCK_DEVICE_TYPE, ssdp_headers={"_host": MOCK_DEVICE_HOST_ADDR}, upnp={ - ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, - ssdp.ATTR_UPNP_SERVICE_LIST: { + ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_SERVICE_LIST: { "service": [ { "SCPDURL": "/AVTransport/scpd.xml", @@ -358,15 +366,15 @@ async def test_ssdp_flow_existing( result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=NEW_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, upnp={ - ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, }, ), ) @@ -492,15 +500,15 @@ async def test_ssdp_flow_upnp_udn( result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_location=NEW_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, ssdp_st=MOCK_DEVICE_TYPE, upnp={ - ssdp.ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, }, ), ) @@ -514,7 +522,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # No service list at all discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - del discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] + del discovery.upnp[ATTR_UPNP_SERVICE_LIST] result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -526,7 +534,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # Service list does not contain services discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = discovery.upnp.copy() - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"} + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"} result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -538,10 +546,10 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # AVTransport service is missing discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = { + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = { "service": [ service - for service in discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]["service"] + for service in discovery.upnp[ATTR_UPNP_SERVICE_LIST]["service"] if service.get("serviceId") != "urn:upnp-org:serviceId:AVTransport" ] } @@ -560,10 +568,10 @@ async def test_ssdp_single_service(hass: HomeAssistant) -> None: """ discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = discovery.upnp.copy() - service_list = discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST].copy() + service_list = discovery.upnp[ATTR_UPNP_SERVICE_LIST].copy() # Turn mock's list of service dicts into a single dict service_list["service"] = service_list["service"][0] - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = service_list + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = service_list result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, @@ -589,9 +597,7 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_DEVICE_TYPE] = ( - "urn:schemas-upnp-org:device:ZonePlayer:1" - ) + discovery.upnp[ATTR_UPNP_DEVICE_TYPE] = "urn:schemas-upnp-org:device:ZonePlayer:1" result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -608,8 +614,8 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: ): discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_MANUFACTURER] = manufacturer - discovery.upnp[ssdp.ATTR_UPNP_MODEL_NAME] = model + discovery.upnp[ATTR_UPNP_MANUFACTURER] = manufacturer + discovery.upnp[ATTR_UPNP_MODEL_NAME] = model result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index 3d8f9da8ed9..a92f7807912 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -47,6 +47,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.setup import async_setup_component from .conftest import ( @@ -1413,7 +1414,7 @@ async def test_become_available( # Send an SSDP notification from the now alive device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1484,7 +1485,7 @@ async def test_alive_but_gone( # Send an SSDP notification from the still missing device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1506,7 +1507,7 @@ async def test_alive_but_gone( # Send the same SSDP notification, expecting no extra connection attempts domain_data_mock.upnp_factory.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1525,7 +1526,7 @@ async def test_alive_but_gone( # Send an SSDP notification with a new BOOTID, indicating the device has rebooted domain_data_mock.upnp_factory.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1546,7 +1547,7 @@ async def test_alive_but_gone( # should result in a reconnect attempt even with same BOOTID. domain_data_mock.upnp_factory.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_st=MOCK_DEVICE_TYPE, upnp={}, @@ -1554,7 +1555,7 @@ async def test_alive_but_gone( ssdp.SsdpChange.BYEBYE, ) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1597,7 +1598,7 @@ async def test_multiple_ssdp_alive( # Send two SSDP notifications with the new device URL ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1606,7 +1607,7 @@ async def test_multiple_ssdp_alive( ssdp.SsdpChange.ALIVE, ) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1637,7 +1638,7 @@ async def test_ssdp_byebye( # First byebye will cause a disconnect ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, @@ -1656,7 +1657,7 @@ async def test_ssdp_byebye( # Second byebye will do nothing await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, @@ -1689,7 +1690,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -1702,7 +1703,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with next boot ID await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -1727,7 +1728,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with same next boot ID, again await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -1752,7 +1753,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with bad next boot ID await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -1777,7 +1778,7 @@ async def test_ssdp_update_seen_bootid( # Send a new SSDP alive with the new boot ID, device should not reconnect await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, @@ -1816,7 +1817,7 @@ async def test_ssdp_update_missed_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -1829,7 +1830,7 @@ async def test_ssdp_update_missed_bootid( # Send SSDP update with skipped boot ID (not previously seen) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -1854,7 +1855,7 @@ async def test_ssdp_update_missed_bootid( # Send a new SSDP alive with the new boot ID, device should reconnect await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "3"}, @@ -1893,7 +1894,7 @@ async def test_ssdp_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -1913,7 +1914,7 @@ async def test_ssdp_bootid( # Send SSDP alive with same boot ID, nothing should happen await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -1933,7 +1934,7 @@ async def test_ssdp_bootid( # Send a new SSDP alive with an incremented boot ID, device should be dis/reconnected await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, @@ -2354,7 +2355,7 @@ async def test_connections_restored( # Send an SSDP notification from the now alive device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, diff --git a/tests/components/dlna_dms/test_config_flow.py b/tests/components/dlna_dms/test_config_flow.py index 14da36a0381..76890f328e4 100644 --- a/tests/components/dlna_dms/test_config_flow.py +++ b/tests/components/dlna_dms/test_config_flow.py @@ -12,11 +12,17 @@ from async_upnp_client.exceptions import UpnpError import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.dlna_dms.const import CONF_SOURCE_ID, DOMAIN from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERVICE_LIST, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import ( MOCK_DEVICE_HOST, @@ -35,16 +41,16 @@ WRONG_DEVICE_TYPE: Final = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" MOCK_ROOT_DEVICE_UDN: Final = "ROOT_DEVICE" -MOCK_DISCOVERY: Final = ssdp.SsdpServiceInfo( +MOCK_DISCOVERY: Final = SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, ssdp_st=MOCK_DEVICE_TYPE, upnp={ - ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, - ssdp.ATTR_UPNP_SERVICE_LIST: { + ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_SERVICE_LIST: { "service": [ { "SCPDURL": "/ContentDirectory/scpd.xml", @@ -195,15 +201,15 @@ async def test_ssdp_flow_existing( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_st="mock_st", ssdp_location=NEW_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, upnp={ - ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, }, ), ) @@ -279,7 +285,7 @@ async def test_duplicate_name( ssdp_udn=new_device_udn, ) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_UDN] = new_device_udn + discovery.upnp[ATTR_UPNP_UDN] = new_device_udn result = await hass.config_entries.flow.async_init( DOMAIN, @@ -312,15 +318,15 @@ async def test_ssdp_flow_upnp_udn( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, ssdp_st=MOCK_DEVICE_TYPE, upnp={ - ssdp.ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, }, ), ) @@ -334,7 +340,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # No service list at all discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - del discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] + del discovery.upnp[ATTR_UPNP_SERVICE_LIST] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -346,7 +352,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # Service list does not contain services discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"} + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"} result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -358,10 +364,10 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # ContentDirectory service is missing discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = { + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = { "service": [ service - for service in discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]["service"] + for service in discovery.upnp[ATTR_UPNP_SERVICE_LIST]["service"] if service.get("serviceId") != "urn:upnp-org:serviceId:ContentDirectory" ] } @@ -380,10 +386,10 @@ async def test_ssdp_single_service(hass: HomeAssistant) -> None: """ discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - service_list = dict(discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]) + service_list = dict(discovery.upnp[ATTR_UPNP_SERVICE_LIST]) # Turn mock's list of service dicts into a single dict service_list["service"] = service_list["service"][0] - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = service_list + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = service_list result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/dlna_dms/test_device_availability.py b/tests/components/dlna_dms/test_device_availability.py index 1be68f91733..01976c16247 100644 --- a/tests/components/dlna_dms/test_device_availability.py +++ b/tests/components/dlna_dms/test_device_availability.py @@ -18,6 +18,7 @@ from homeassistant.components.dlna_dms.dms import get_domain_data from homeassistant.components.media_player import BrowseError from homeassistant.components.media_source import Unresolvable from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .conftest import ( MOCK_DEVICE_LOCATION, @@ -179,7 +180,7 @@ async def test_become_available( # Send an SSDP notification from the now alive device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -207,7 +208,7 @@ async def test_alive_but_gone( # Send an SSDP notification from the still missing device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -227,7 +228,7 @@ async def test_alive_but_gone( # Send the same SSDP notification, expecting no extra connection attempts upnp_factory_mock.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -244,7 +245,7 @@ async def test_alive_but_gone( # Send an SSDP notification with a new BOOTID, indicating the device has rebooted upnp_factory_mock.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -263,7 +264,7 @@ async def test_alive_but_gone( # should result in a reconnect attempt even with same BOOTID. upnp_factory_mock.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_st=MOCK_DEVICE_TYPE, upnp={}, @@ -271,7 +272,7 @@ async def test_alive_but_gone( ssdp.SsdpChange.BYEBYE, ) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -310,7 +311,7 @@ async def test_multiple_ssdp_alive( # Send two SSDP notifications with the new device URL ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -319,7 +320,7 @@ async def test_multiple_ssdp_alive( ssdp.SsdpChange.ALIVE, ) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -345,7 +346,7 @@ async def test_ssdp_byebye( # First byebye will cause a disconnect ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, @@ -360,7 +361,7 @@ async def test_ssdp_byebye( # Second byebye will do nothing await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, @@ -388,7 +389,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -405,7 +406,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with next boot ID await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -426,7 +427,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with same next boot ID, again await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -447,7 +448,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with bad next boot ID await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -468,7 +469,7 @@ async def test_ssdp_update_seen_bootid( # Send a new SSDP alive with the new boot ID, device should not reconnect await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, @@ -500,7 +501,7 @@ async def test_ssdp_update_missed_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -517,7 +518,7 @@ async def test_ssdp_update_missed_bootid( # Send SSDP update with skipped boot ID (not previously seen) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -538,7 +539,7 @@ async def test_ssdp_update_missed_bootid( # Send a new SSDP alive with the new boot ID, device should reconnect await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "3"}, @@ -570,7 +571,7 @@ async def test_ssdp_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -586,7 +587,7 @@ async def test_ssdp_bootid( # Send SSDP alive with same boot ID, nothing should happen await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -602,7 +603,7 @@ async def test_ssdp_bootid( # Send a new SSDP alive with an incremented boot ID, device should be dis/reconnected await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, diff --git a/tests/components/dlna_dms/test_dms_device_source.py b/tests/components/dlna_dms/test_dms_device_source.py index 7907d40c415..5576066f781 100644 --- a/tests/components/dlna_dms/test_dms_device_source.py +++ b/tests/components/dlna_dms/test_dms_device_source.py @@ -16,6 +16,7 @@ from homeassistant.components.dlna_dms.dms import DidlPlayMedia from homeassistant.components.media_player import BrowseError from homeassistant.components.media_source import BrowseMediaSource, Unresolvable from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .conftest import ( MOCK_DEVICE_BASE_URL, @@ -68,7 +69,7 @@ async def test_catch_request_error_unavailable( # DmsDevice notifies of disconnect via SSDP ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, diff --git a/tests/components/doorbird/test_config_flow.py b/tests/components/doorbird/test_config_flow.py index 3abdd2b87a3..98b2189dfd9 100644 --- a/tests/components/doorbird/test_config_flow.py +++ b/tests/components/doorbird/test_config_flow.py @@ -8,7 +8,6 @@ from doorbirdpy import DoorBird import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.doorbird.const import ( CONF_EVENTS, DEFAULT_DOORBELL_EVENT, @@ -18,6 +17,7 @@ from homeassistant.components.doorbird.const import ( from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import ( VALID_CONFIG, @@ -74,7 +74,7 @@ async def test_form_zeroconf_wrong_oui(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.8"), ip_addresses=[ip_address("192.168.1.8")], hostname="mock_hostname", @@ -94,7 +94,7 @@ async def test_form_zeroconf_link_local_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("169.254.103.61"), ip_addresses=[ip_address("169.254.103.61")], hostname="mock_hostname", @@ -121,7 +121,7 @@ async def test_form_zeroconf_ipv4_address(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("4.4.4.4"), ip_addresses=[ip_address("4.4.4.4")], hostname="mock_hostname", @@ -142,7 +142,7 @@ async def test_form_zeroconf_non_ipv4_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", @@ -164,7 +164,7 @@ async def test_form_zeroconf_correct_oui( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", @@ -230,7 +230,7 @@ async def test_form_zeroconf_correct_oui_wrong_device( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index 00763f60458..c647d36902a 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import AsyncMock, MagicMock from elgato import ElgatoConnectionError import pytest -from homeassistant.components import zeroconf from homeassistant.components.elgato.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -57,7 +57,7 @@ async def test_full_zeroconf_flow_implementation( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -141,7 +141,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -181,7 +181,7 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -202,7 +202,7 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -230,7 +230,7 @@ async def test_zeroconf_during_onboarding( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", diff --git a/tests/components/elkm1/test_config_flow.py b/tests/components/elkm1/test_config_flow.py index e56bb5f4699..5355013bf94 100644 --- a/tests/components/elkm1/test_config_flow.py +++ b/tests/components/elkm1/test_config_flow.py @@ -7,12 +7,12 @@ from elkm1_lib.discovery import ElkSystem import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.elkm1.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( ELK_DISCOVERY, @@ -27,7 +27,7 @@ from . import ( from tests.common import MockConfigEntry -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( MOCK_IP_ADDRESS, "", dr.format_mac(MOCK_MAC).replace(":", "") ) ELK_DISCOVERY_INFO = asdict(ELK_DISCOVERY) @@ -1141,7 +1141,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="any", ip=MOCK_IP_ADDRESS, macaddress="00:00:00:00:00:00", diff --git a/tests/components/elmax/test_config_flow.py b/tests/components/elmax/test_config_flow.py index 7a4d9755fa5..be89ee4d5d6 100644 --- a/tests/components/elmax/test_config_flow.py +++ b/tests/components/elmax/test_config_flow.py @@ -5,7 +5,6 @@ from unittest.mock import patch from elmax_api.exceptions import ElmaxBadLoginError, ElmaxBadPinError, ElmaxNetworkError from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.elmax.const import ( CONF_ELMAX_MODE, CONF_ELMAX_MODE_CLOUD, @@ -23,6 +22,7 @@ from homeassistant.components.elmax.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import ( MOCK_DIRECT_CERT, @@ -40,7 +40,7 @@ from . import ( from tests.common import MockConfigEntry -MOCK_ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=MOCK_DIRECT_HOST, ip_addresses=[MOCK_DIRECT_HOST], hostname="VideoBox.local", @@ -54,7 +54,7 @@ MOCK_ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( }, type="_elmax-ssl._tcp", ) -MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = ZeroconfServiceInfo( ip_address=MOCK_DIRECT_HOST_CHANGED, ip_addresses=[MOCK_DIRECT_HOST_CHANGED], hostname="VideoBox.local", @@ -68,7 +68,7 @@ MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = zeroconf.ZeroconfServiceInfo( }, type="_elmax-ssl._tcp", ) -MOCK_ZEROCONF_DISCOVERY_INFO_NOT_SUPPORTED = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DISCOVERY_INFO_NOT_SUPPORTED = ZeroconfServiceInfo( ip_address=MOCK_DIRECT_HOST, ip_addresses=[MOCK_DIRECT_HOST], hostname="VideoBox.local", diff --git a/tests/components/emonitor/test_config_flow.py b/tests/components/emonitor/test_config_flow.py index e77ebcc08b0..3e5f4004d1a 100644 --- a/tests/components/emonitor/test_config_flow.py +++ b/tests/components/emonitor/test_config_flow.py @@ -6,15 +6,15 @@ from aioemonitor.monitor import EmonitorNetwork, EmonitorStatus import aiohttp from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.emonitor.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry -DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( +DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="emonitor", ip="1.2.3.4", macaddress="aabbccddeeff", diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index c78e847e4a2..a3da14b3835 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -7,7 +7,6 @@ from unittest.mock import AsyncMock from pyenphase import EnvoyAuthenticationError, EnvoyError import pytest -from homeassistant.components import zeroconf from homeassistant.components.enphase_envoy.const import ( DOMAIN, OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, @@ -19,6 +18,7 @@ from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import setup_integration @@ -163,7 +163,7 @@ async def test_zeroconf( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", @@ -273,7 +273,7 @@ async def test_zeroconf_serial_already_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("4.4.4.4"), ip_addresses=[ip_address("4.4.4.4")], hostname="mock_hostname", @@ -301,7 +301,7 @@ async def test_zeroconf_serial_already_exists_ignores_ipv6( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", @@ -330,7 +330,7 @@ async def test_zeroconf_host_already_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", @@ -363,7 +363,7 @@ async def test_zero_conf_while_form( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", @@ -396,7 +396,7 @@ async def test_zero_conf_second_envoy_while_form( result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("4.4.4.4"), ip_addresses=[ip_address("4.4.4.4")], hostname="mock_hostname", @@ -455,7 +455,7 @@ async def test_zero_conf_old_blank_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1"), ip_address("1.1.1.2")], hostname="mock_hostname", @@ -496,7 +496,7 @@ async def test_zero_conf_old_blank_entry_standard_title( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1"), ip_address("1.1.1.2")], hostname="mock_hostname", @@ -537,7 +537,7 @@ async def test_zero_conf_old_blank_entry_user_title( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1"), ip_address("1.1.1.2")], hostname="mock_hostname", diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 0a389969c78..65dab4c516f 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -18,7 +18,6 @@ import aiohttp import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.esphome import dashboard from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, @@ -30,8 +29,10 @@ from homeassistant.components.esphome.const import ( from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.mqtt import MqttServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import VALID_NOISE_PSK @@ -126,7 +127,7 @@ async def test_user_sets_unique_id( hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test that the user flow sets the unique id.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -205,7 +206,7 @@ async def test_user_causes_zeroconf_to_abort( hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test that the user flow sets the unique id and aborts the zeroconf flow.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -568,7 +569,7 @@ async def test_discovery_initiation( hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test discovery importing works.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test.local.", @@ -601,7 +602,7 @@ async def test_discovery_no_mac( hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test discovery aborted if old ESPHome without mac in zeroconf.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -629,7 +630,7 @@ async def test_discovery_already_configured( entry.add_to_hass(hass) - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -650,7 +651,7 @@ async def test_discovery_duplicate_data( hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None ) -> None: """Test discovery aborts if same mDNS packet arrives.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test.local.", @@ -685,7 +686,7 @@ async def test_discovery_updates_unique_id( entry.add_to_hass(hass) - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -1056,7 +1057,7 @@ async def test_discovery_dhcp_updates_host( ) entry.add_to_hass(hass) - service_info = dhcp.DhcpServiceInfo( + service_info = DhcpServiceInfo( ip="192.168.43.184", hostname="test8266", macaddress="1122334455aa", @@ -1083,7 +1084,7 @@ async def test_discovery_dhcp_no_changes( mock_client.device_info = AsyncMock(return_value=DeviceInfo(name="test8266")) - service_info = dhcp.DhcpServiceInfo( + service_info = DhcpServiceInfo( ip="192.168.43.183", hostname="test8266", macaddress="000000000000", @@ -1132,7 +1133,7 @@ async def test_zeroconf_encryption_key_via_dashboard( mock_setup_entry: None, ) -> None: """Test encryption key retrieved from dashboard.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -1198,7 +1199,7 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( mock_setup_entry: None, ) -> None: """Test encryption key retrieved from dashboard with api_encryption property set.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -1264,7 +1265,7 @@ async def test_zeroconf_no_encryption_key_via_dashboard( mock_setup_entry: None, ) -> None: """Test encryption key not retrieved from dashboard.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 4b322c8744e..6fbd3726f64 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -21,7 +21,6 @@ from aioesphomeapi import ( import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, @@ -37,6 +36,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.setup import async_setup_component from .conftest import MockESPHomeDevice @@ -598,7 +598,7 @@ async def test_connection_aborted_wrong_device( mock_client.disconnect = AsyncMock() caplog.clear() # Make sure discovery triggers a reconnect - service_info = dhcp.DhcpServiceInfo( + service_info = DhcpServiceInfo( ip="192.168.43.184", hostname="test", macaddress="1122334455aa", diff --git a/tests/components/flux_led/__init__.py b/tests/components/flux_led/__init__.py index d1cb892d548..c8bd0bb192c 100644 --- a/tests/components/flux_led/__init__.py +++ b/tests/components/flux_led/__init__.py @@ -24,12 +24,12 @@ from flux_led.protocol import ( ) from flux_led.scanner import FluxLEDDiscovery -from homeassistant.components import dhcp from homeassistant.components.flux_led.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -49,7 +49,7 @@ SHORT_MAC_ADDRESS = "DDEEFF" DEFAULT_ENTRY_TITLE = f"{MODEL_DESCRIPTION} {SHORT_MAC_ADDRESS}" -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname=MODEL, ip=IP_ADDRESS, macaddress=format_mac(MAC_ADDRESS).replace(":", ""), diff --git a/tests/components/flux_led/test_config_flow.py b/tests/components/flux_led/test_config_flow.py index 4332cb69f02..f486d27244e 100644 --- a/tests/components/flux_led/test_config_flow.py +++ b/tests/components/flux_led/test_config_flow.py @@ -7,7 +7,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.flux_led.config_flow import FluxLedConfigFlow from homeassistant.components.flux_led.const import ( CONF_CUSTOM_EFFECT_COLORS, @@ -27,6 +26,7 @@ from homeassistant.components.flux_led.const import ( from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( DEFAULT_ENTRY_TITLE, @@ -424,7 +424,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="any", ip=IP_ADDRESS, macaddress="000000000000", diff --git a/tests/components/forked_daapd/test_config_flow.py b/tests/components/forked_daapd/test_config_flow.py index 076fffef59b..8bf5de31da2 100644 --- a/tests/components/forked_daapd/test_config_flow.py +++ b/tests/components/forked_daapd/test_config_flow.py @@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from homeassistant.components import zeroconf from homeassistant.components.forked_daapd.const import ( CONF_LIBRESPOT_JAVA_PORT, CONF_MAX_PLAYLISTS, @@ -19,6 +18,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -109,7 +109,7 @@ async def test_zeroconf_updates_title( MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "different host"}).add_to_hass(hass) config_entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 2 - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.1"), ip_addresses=[ip_address("192.168.1.1")], hostname="mock_hostname", @@ -146,7 +146,7 @@ async def test_config_flow_no_websocket( async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: """Test that an invalid zeroconf entry doesn't work.""" # test with no discovery properties - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -161,7 +161,7 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_forked_daapd" # test with forked-daapd version < 27 - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -176,7 +176,7 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_forked_daapd" # test with verbose mtd-version from Firefly - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -191,7 +191,7 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_forked_daapd" # test with svn mtd-version from Firefly - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -209,7 +209,7 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: async def test_config_flow_zeroconf_valid(hass: HomeAssistant) -> None: """Test that a valid zeroconf entry works.""" - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.1"), ip_addresses=[ip_address("192.168.1.1")], hostname="mock_hostname", diff --git a/tests/components/freebox/test_config_flow.py b/tests/components/freebox/test_config_flow.py index ca9e9c12937..50dd2f8c14e 100644 --- a/tests/components/freebox/test_config_flow.py +++ b/tests/components/freebox/test_config_flow.py @@ -9,18 +9,18 @@ from freebox_api.exceptions import ( InvalidTokenError, ) -from homeassistant.components import zeroconf from homeassistant.components.freebox.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import MOCK_HOST, MOCK_PORT from tests.common import MockConfigEntry -MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.254"), ip_addresses=[ip_address("192.168.0.254")], port=80, diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py index f9271e75169..1e292ed22bb 100644 --- a/tests/components/fritz/const.py +++ b/tests/components/fritz/const.py @@ -1,6 +1,5 @@ """Common stuff for Fritz!Tools tests.""" -from homeassistant.components import ssdp from homeassistant.components.fritz.const import DOMAIN from homeassistant.const import ( CONF_DEVICES, @@ -13,6 +12,7 @@ from homeassistant.const import ( from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN, + SsdpServiceInfo, ) ATTR_HOST = "host" @@ -944,7 +944,7 @@ MOCK_DEVICE_INFO = { ATTR_HOST: MOCK_HOST, ATTR_NEW_SERIAL_NUMBER: MOCK_SERIAL_NUMBER, } -MOCK_SSDP_DATA = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"https://{MOCK_IPS['fritz.box']}:12345/test", diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index 84f1b240b88..f4c4229af74 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -10,7 +10,6 @@ from fritzconnection.core.exceptions import ( ) import pytest -from homeassistant.components import ssdp from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, @@ -33,6 +32,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import ( MOCK_FIRMWARE_INFO, @@ -644,7 +648,7 @@ async def test_ssdp_already_in_progress_host( MOCK_NO_UNIQUE_ID = dataclasses.replace(MOCK_SSDP_DATA) MOCK_NO_UNIQUE_ID.upnp = MOCK_NO_UNIQUE_ID.upnp.copy() - del MOCK_NO_UNIQUE_ID.upnp[ssdp.ATTR_UPNP_UDN] + del MOCK_NO_UNIQUE_ID.upnp[ATTR_UPNP_UDN] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_NO_UNIQUE_ID ) @@ -745,13 +749,13 @@ async def test_ssdp_ipv6_link_local(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://[fe80::1ff:fe23:4567:890a]:12345/test", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "fake_name", - ssdp.ATTR_UPNP_UDN: "uuid:only-a-test", + ATTR_UPNP_FRIENDLY_NAME: "fake_name", + ATTR_UPNP_UDN: "uuid:only-a-test", }, ), ) diff --git a/tests/components/fritzbox/test_config_flow.py b/tests/components/fritzbox/test_config_flow.py index 1387d5a9c1b..0c8a7996898 100644 --- a/tests/components/fritzbox/test_config_flow.py +++ b/tests/components/fritzbox/test_config_flow.py @@ -9,7 +9,6 @@ from pyfritzhome import LoginError import pytest from requests.exceptions import HTTPError -from homeassistant.components import ssdp from homeassistant.components.fritzbox.const import DOMAIN from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME @@ -18,6 +17,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN, + SsdpServiceInfo, ) from .const import CONF_FAKE_NAME, MOCK_CONFIG @@ -26,7 +26,7 @@ from tests.common import MockConfigEntry MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] MOCK_SSDP_DATA = { - "ip4_valid": ssdp.SsdpServiceInfo( + "ip4_valid": SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://10.0.0.1:12345/test", @@ -35,7 +35,7 @@ MOCK_SSDP_DATA = { ATTR_UPNP_UDN: "uuid:only-a-test", }, ), - "ip6_valid": ssdp.SsdpServiceInfo( + "ip6_valid": SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://[1234::1]:12345/test", @@ -44,7 +44,7 @@ MOCK_SSDP_DATA = { ATTR_UPNP_UDN: "uuid:only-a-test", }, ), - "ip6_invalid": ssdp.SsdpServiceInfo( + "ip6_invalid": SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://[fe80::1%1]:12345/test", @@ -267,7 +267,7 @@ async def test_reconfigure_failed(hass: HomeAssistant, fritz: Mock) -> None: async def test_ssdp( hass: HomeAssistant, fritz: Mock, - test_data: ssdp.SsdpServiceInfo, + test_data: SsdpServiceInfo, expected_result: str, ) -> None: """Test starting a flow from discovery.""" diff --git a/tests/components/frontier_silicon/test_config_flow.py b/tests/components/frontier_silicon/test_config_flow.py index c92cf897fe6..f60e9ad557e 100644 --- a/tests/components/frontier_silicon/test_config_flow.py +++ b/tests/components/frontier_silicon/test_config_flow.py @@ -6,7 +6,6 @@ from afsapi import ConnectionError, InvalidPinException, NotImplementedException import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.frontier_silicon.const import ( CONF_WEBFSAPI_URL, DEFAULT_PIN, @@ -15,13 +14,14 @@ from homeassistant.components.frontier_silicon.const import ( from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") -MOCK_DISCOVERY = ssdp.SsdpServiceInfo( +MOCK_DISCOVERY = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_udn="uuid:3dcc7100-f76c-11dd-87af-00226124ca30", ssdp_st="mock_st", @@ -30,7 +30,7 @@ MOCK_DISCOVERY = ssdp.SsdpServiceInfo( upnp={"SPEAKER-NAME": "Speaker Name"}, ) -INVALID_MOCK_DISCOVERY = ssdp.SsdpServiceInfo( +INVALID_MOCK_DISCOVERY = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_udn="uuid:3dcc7100-f76c-11dd-87af-00226124ca30", ssdp_st="mock_st", diff --git a/tests/components/goalzero/__init__.py b/tests/components/goalzero/__init__.py index 30a7c92510e..7d86f638fc2 100644 --- a/tests/components/goalzero/__init__.py +++ b/tests/components/goalzero/__init__.py @@ -2,11 +2,11 @@ from unittest.mock import AsyncMock, patch -from homeassistant.components import dhcp from homeassistant.components.goalzero.const import DEFAULT_NAME, DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -19,7 +19,7 @@ CONF_DATA = { CONF_NAME: DEFAULT_NAME, } -CONF_DHCP_FLOW = dhcp.DhcpServiceInfo( +CONF_DHCP_FLOW = DhcpServiceInfo( ip=HOST, macaddress=format_mac("AA:BB:CC:DD:EE:FF").replace(":", ""), hostname="yeti", diff --git a/tests/components/gogogate2/test_config_flow.py b/tests/components/gogogate2/test_config_flow.py index 25fb5922506..1e7e48437cd 100644 --- a/tests/components/gogogate2/test_config_flow.py +++ b/tests/components/gogogate2/test_config_flow.py @@ -8,7 +8,6 @@ from ismartgate.common import ApiError from ismartgate.const import GogoGate2ApiErrorCode from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.gogogate2.const import ( DEVICE_TYPE_GOGOGATE2, DEVICE_TYPE_ISMARTGATE, @@ -23,6 +22,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from . import _mocked_ismartgate_closed_door_response @@ -105,13 +109,13 @@ async def test_form_homekit_unique_id_already_setup(hass: HomeAssistant) -> None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -133,13 +137,13 @@ async def test_form_homekit_unique_id_already_setup(hass: HomeAssistant) -> None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -158,13 +162,13 @@ async def test_form_homekit_ip_address_already_setup(hass: HomeAssistant) -> Non result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -177,13 +181,13 @@ async def test_form_homekit_ip_address(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -213,7 +217,7 @@ async def test_discovered_dhcp( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress=MOCK_MAC_ADDR, hostname="mock_hostname" ), ) @@ -260,13 +264,13 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -276,7 +280,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress=MOCK_MAC_ADDR, hostname="mock_hostname" ), ) @@ -286,7 +290,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress="00:00:00:00:00:00", hostname="mock_hostname" ), ) diff --git a/tests/components/guardian/test_config_flow.py b/tests/components/guardian/test_config_flow.py index 6c06171a45f..5f0d54aaa0d 100644 --- a/tests/components/guardian/test_config_flow.py +++ b/tests/components/guardian/test_config_flow.py @@ -7,7 +7,6 @@ from unittest.mock import patch from aioguardian.errors import GuardianError import pytest -from homeassistant.components import dhcp, zeroconf from homeassistant.components.guardian import CONF_UID, DOMAIN from homeassistant.components.guardian.config_flow import ( async_get_pin_from_discovery_hostname, @@ -17,6 +16,8 @@ from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, SOURCE_ZEROCO from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -82,7 +83,7 @@ async def test_step_user(hass: HomeAssistant, config: dict[str, Any]) -> None: @pytest.mark.usefixtures("setup_guardian") async def test_step_zeroconf(hass: HomeAssistant) -> None: """Test the zeroconf step.""" - zeroconf_data = zeroconf.ZeroconfServiceInfo( + zeroconf_data = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], port=7777, @@ -112,7 +113,7 @@ async def test_step_zeroconf(hass: HomeAssistant) -> None: async def test_step_zeroconf_already_in_progress(hass: HomeAssistant) -> None: """Test the zeroconf step aborting because it's already in progress.""" - zeroconf_data = zeroconf.ZeroconfServiceInfo( + zeroconf_data = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], port=7777, @@ -138,7 +139,7 @@ async def test_step_zeroconf_already_in_progress(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_guardian") async def test_step_dhcp(hass: HomeAssistant) -> None: """Test the dhcp step.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip="192.168.1.100", hostname="GVC1-ABCD.local.", macaddress="aabbccddeeff", @@ -164,7 +165,7 @@ async def test_step_dhcp(hass: HomeAssistant) -> None: async def test_step_dhcp_already_in_progress(hass: HomeAssistant) -> None: """Test the zeroconf step aborting because it's already in progress.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip="192.168.1.100", hostname="GVC1-ABCD.local.", macaddress="aabbccddeeff", @@ -193,7 +194,7 @@ async def test_step_dhcp_already_setup_match_mac(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="192.168.1.100", hostname="GVC1-ABCD.local.", macaddress="aabbccddabcd", @@ -215,7 +216,7 @@ async def test_step_dhcp_already_setup_match_ip(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="192.168.1.100", hostname="GVC1-ABCD.local.", macaddress="aabbccddabcd", diff --git a/tests/components/harmony/test_config_flow.py b/tests/components/harmony/test_config_flow.py index d87bfd32326..2233ad194f5 100644 --- a/tests/components/harmony/test_config_flow.py +++ b/tests/components/harmony/test_config_flow.py @@ -5,12 +5,12 @@ from unittest.mock import AsyncMock, MagicMock, patch import aiohttp from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.harmony.config_flow import CannotConnect from homeassistant.components.harmony.const import DOMAIN, PREVIOUS_ACTIVE_ACTIVITY from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry @@ -65,7 +65,7 @@ async def test_form_ssdp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.12:8088/description", @@ -120,7 +120,7 @@ async def test_form_ssdp_fails_to_get_remote_id(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.12:8088/description", @@ -159,7 +159,7 @@ async def test_form_ssdp_aborts_before_checking_remoteid_if_host_known( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://2.2.2.2:8088/description", diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 4a11a3511d5..1348923927b 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -24,7 +24,6 @@ from pyheos import ( import pytest import pytest_asyncio -from homeassistant.components import ssdp from homeassistant.components.heos import ( CONF_PASSWORD, DOMAIN, @@ -34,6 +33,16 @@ from homeassistant.components.heos import ( SourceManager, ) from homeassistant.const import CONF_HOST, CONF_USERNAME +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -236,18 +245,18 @@ async def dispatcher_fixture() -> Dispatcher: @pytest.fixture(name="discovery_data") def discovery_data_fixture() -> dict: """Return mock discovery data for testing.""" - return ssdp.SsdpServiceInfo( + return SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1:60006/upnp/desc/aios_device/aios_device.xml", upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-denon-com:device:AiosDevice:1", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Office", - ssdp.ATTR_UPNP_MANUFACTURER: "Denon", - ssdp.ATTR_UPNP_MODEL_NAME: "HEOS Drive", - ssdp.ATTR_UPNP_MODEL_NUMBER: "DWSA-10 4.0", - ssdp.ATTR_UPNP_SERIAL: None, - ssdp.ATTR_UPNP_UDN: "uuid:e61de70c-2250-1c22-0080-0005cdf512be", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-denon-com:device:AiosDevice:1", + ATTR_UPNP_FRIENDLY_NAME: "Office", + ATTR_UPNP_MANUFACTURER: "Denon", + ATTR_UPNP_MODEL_NAME: "HEOS Drive", + ATTR_UPNP_MODEL_NUMBER: "DWSA-10 4.0", + ATTR_UPNP_SERIAL: None, + ATTR_UPNP_UDN: "uuid:e61de70c-2250-1c22-0080-0005cdf512be", }, ) @@ -255,18 +264,18 @@ def discovery_data_fixture() -> dict: @pytest.fixture(name="discovery_data_bedroom") def discovery_data_fixture_bedroom() -> dict: """Return mock discovery data for testing.""" - return ssdp.SsdpServiceInfo( + return SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.2:60006/upnp/desc/aios_device/aios_device.xml", upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-denon-com:device:AiosDevice:1", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Bedroom", - ssdp.ATTR_UPNP_MANUFACTURER: "Denon", - ssdp.ATTR_UPNP_MODEL_NAME: "HEOS Drive", - ssdp.ATTR_UPNP_MODEL_NUMBER: "DWSA-10 4.0", - ssdp.ATTR_UPNP_SERIAL: None, - ssdp.ATTR_UPNP_UDN: "uuid:e61de70c-2250-1c22-0080-0005cdf512be", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-denon-com:device:AiosDevice:1", + ATTR_UPNP_FRIENDLY_NAME: "Bedroom", + ATTR_UPNP_MANUFACTURER: "Denon", + ATTR_UPNP_MODEL_NAME: "HEOS Drive", + ATTR_UPNP_MODEL_NUMBER: "DWSA-10 4.0", + ATTR_UPNP_SERIAL: None, + ATTR_UPNP_UDN: "uuid:e61de70c-2250-1c22-0080-0005cdf512be", }, ) diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 0a1da2d986f..217c7393e14 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -3,12 +3,13 @@ from pyheos import CommandAuthenticationError, CommandFailedError, HeosError import pytest -from homeassistant.components import heos, ssdp +from homeassistant.components import heos from homeassistant.components.heos.const import DOMAIN from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry @@ -86,8 +87,8 @@ async def test_create_entry_when_friendly_name_valid( async def test_discovery_shows_create_form( hass: HomeAssistant, controller, - discovery_data: ssdp.SsdpServiceInfo, - discovery_data_bedroom: ssdp.SsdpServiceInfo, + discovery_data: SsdpServiceInfo, + discovery_data_bedroom: SsdpServiceInfo, ) -> None: """Test discovery shows form to confirm setup.""" @@ -112,7 +113,7 @@ async def test_discovery_shows_create_form( async def test_discovery_flow_aborts_already_setup( - hass: HomeAssistant, controller, discovery_data: ssdp.SsdpServiceInfo, config_entry + hass: HomeAssistant, controller, discovery_data: SsdpServiceInfo, config_entry ) -> None: """Test discovery flow aborts when entry already setup.""" config_entry.add_to_hass(hass) diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index 4fb0a80cd26..424f93f7142 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -15,7 +15,6 @@ from bleak.exc import BleakError import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.homekit_controller import config_flow from homeassistant.components.homekit_controller.const import KNOWN_DEVICES from homeassistant.components.homekit_controller.storage import async_get_entity_storage @@ -23,6 +22,10 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry @@ -176,9 +179,9 @@ def get_flow_context( def get_device_discovery_info( device, upper_case_props=False, missing_csharp=False, paired=False -) -> zeroconf.ZeroconfServiceInfo: +) -> ZeroconfServiceInfo: """Turn a aiohomekit format zeroconf entry into a homeassistant one.""" - result = zeroconf.ZeroconfServiceInfo( + result = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname=device.description.name, @@ -187,7 +190,7 @@ def get_device_discovery_info( properties={ "md": device.description.model, "pv": "1.0", - zeroconf.ATTR_PROPERTIES_ID: device.description.id, + ATTR_PROPERTIES_ID: device.description.id, "c#": device.description.config_num, "s#": device.description.state_num, "ff": "0", @@ -330,7 +333,7 @@ async def test_id_missing(hass: HomeAssistant, controller) -> None: discovery_info = get_device_discovery_info(device) # Remove id from device - del discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] + del discovery_info.properties[ATTR_PROPERTIES_ID] # Device is discovered result = await hass.config_entries.flow.async_init( @@ -346,7 +349,7 @@ async def test_discovery_ignored_model(hass: HomeAssistant, controller) -> None: """Already paired.""" device = setup_mock_accessory(controller) discovery_info = get_device_discovery_info(device) - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" discovery_info.properties["md"] = "HHKBridge1,1" # Device is discovered @@ -375,7 +378,7 @@ async def test_discovery_ignored_hk_bridge( connections={(dr.CONNECTION_NETWORK_MAC, formatted_mac)}, ) - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -403,7 +406,7 @@ async def test_discovery_does_not_ignore_non_homekit( connections={(dr.CONNECTION_NETWORK_MAC, formatted_mac)}, ) - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -582,7 +585,7 @@ async def test_discovery_already_configured_update_csharp( # Set device as already paired discovery_info.properties["sf"] = 0x00 discovery_info.properties["c#"] = 99999 - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -967,7 +970,7 @@ async def test_discovery_dismiss_existing_flow_on_paired( # Set device as already not paired discovery_info.properties["sf"] = 0x01 discovery_info.properties["c#"] = 99999 - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -1201,7 +1204,7 @@ async def test_discovery_updates_ip_when_config_entry_set_up( # Set device as already paired discovery_info.properties["sf"] = 0x00 - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -1239,7 +1242,7 @@ async def test_discovery_updates_ip_config_entry_not_set_up( # Set device as already paired discovery_info.properties["sf"] = 0x00 - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" # Device is discovered result = await hass.config_entries.flow.async_init( diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 984fda8e7a4..b2ae7bd45e0 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -8,11 +8,12 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.homewizard.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -54,7 +55,7 @@ async def test_discovery_flow_works( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -100,7 +101,7 @@ async def test_discovery_flow_during_onboarding( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -137,7 +138,7 @@ async def test_discovery_flow_during_onboarding_disabled_api( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -181,7 +182,7 @@ async def test_discovery_disabled_api( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -216,7 +217,7 @@ async def test_discovery_missing_data_in_service_info(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -242,7 +243,7 @@ async def test_discovery_invalid_api(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -274,7 +275,7 @@ async def test_dhcp_discovery_updates_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.0.0.127", hostname="HW-p1meter-aabbcc", macaddress="5c2fafabcdef", @@ -304,7 +305,7 @@ async def test_dhcp_discovery_updates_entry_fails( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.0.0.127", hostname="HW-p1meter-aabbcc", macaddress="5c2fafabcdef", @@ -326,7 +327,7 @@ async def test_dhcp_discovery_ignores_unknown( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="127.0.0.1", hostname="HW-p1meter-aabbcc", macaddress="5c2fafabcdef", @@ -350,7 +351,7 @@ async def test_discovery_flow_updates_new_ip( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.0.0.127"), ip_addresses=[ip_address("1.0.0.127")], port=80, diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index a9a147eb17e..f75b0e7f2b0 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -13,7 +13,6 @@ import requests_mock from requests_mock import ANY from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.huawei_lte.const import CONF_UNAUTHENTICATED_MODE, DOMAIN from homeassistant.const import ( CONF_NAME, @@ -25,6 +24,18 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -267,8 +278,8 @@ async def test_success(hass: HomeAssistant, login_requests_mock, scheme: str) -> "text": "Mock device", }, { - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", - ssdp.ATTR_UPNP_SERIAL: "00000000", + ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", + ATTR_UPNP_SERIAL: "00000000", }, { "type": FlowResultType.FORM, @@ -283,8 +294,8 @@ async def test_success(hass: HomeAssistant, login_requests_mock, scheme: str) -> "text": "100002", }, { - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", - # No ssdp.ATTR_UPNP_SERIAL + ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", + # No ATTR_UPNP_SERIAL }, { "type": FlowResultType.FORM, @@ -322,18 +333,18 @@ async def test_ssdp( result = await hass.config_entries.flow.async_init( DOMAIN, context=context, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="upnp:rootdevice", ssdp_location=f"{url}:60957/rootDesc.xml", upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:InternetGatewayDevice:1", - ssdp.ATTR_UPNP_MANUFACTURER: "Huawei", - ssdp.ATTR_UPNP_MANUFACTURER_URL: "http://www.huawei.com/", - ssdp.ATTR_UPNP_MODEL_NAME: "Huawei router", - ssdp.ATTR_UPNP_MODEL_NUMBER: "12345678", - ssdp.ATTR_UPNP_PRESENTATION_URL: url, - ssdp.ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:InternetGatewayDevice:1", + ATTR_UPNP_MANUFACTURER: "Huawei", + ATTR_UPNP_MANUFACTURER_URL: "http://www.huawei.com/", + ATTR_UPNP_MODEL_NAME: "Huawei router", + ATTR_UPNP_MODEL_NUMBER: "12345678", + ATTR_UPNP_PRESENTATION_URL: url, + ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", **upnp_data, }, ), diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 692bd1405cf..e4bdda422d1 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -9,12 +9,15 @@ import pytest import voluptuous as vol from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.hue import config_flow, const from homeassistant.components.hue.errors import CannotConnect from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker, ClientError @@ -424,13 +427,13 @@ async def test_bridge_homekit( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("0.0.0.0"), ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) @@ -474,13 +477,13 @@ async def test_bridge_homekit_already_configured( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("0.0.0.0"), ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) @@ -578,7 +581,7 @@ async def test_bridge_zeroconf( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.217"), ip_addresses=[ip_address("192.168.1.217")], port=443, @@ -614,7 +617,7 @@ async def test_bridge_zeroconf_already_exists( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.217"), ip_addresses=[ip_address("192.168.1.217")], port=443, @@ -639,7 +642,7 @@ async def test_bridge_zeroconf_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::eeb5:faff:fe84:b17d"), ip_addresses=[ip_address("fd00::eeb5:faff:fe84:b17d")], port=443, @@ -687,7 +690,7 @@ async def test_bridge_connection_failed( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=443, @@ -708,13 +711,13 @@ async def test_bridge_connection_failed( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("0.0.0.0"), ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) diff --git a/tests/components/hunterdouglas_powerview/const.py b/tests/components/hunterdouglas_powerview/const.py index 65b03fd5ec2..2c122ae10f2 100644 --- a/tests/components/hunterdouglas_powerview/const.py +++ b/tests/components/hunterdouglas_powerview/const.py @@ -3,32 +3,36 @@ from ipaddress import IPv4Address from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) MOCK_MAC = "AA::BB::CC::DD::EE::FF" MOCK_SERIAL = "A1B2C3D4E5G6H7" -HOMEKIT_DISCOVERY_GEN2 = zeroconf.ZeroconfServiceInfo( +HOMEKIT_DISCOVERY_GEN2 = ZeroconfServiceInfo( ip_address="1.2.3.4", ip_addresses=[IPv4Address("1.2.3.4")], hostname="mock_hostname", name="Powerview Generation 2._hap._tcp.local.", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC}, type="mock_type", ) -HOMEKIT_DISCOVERY_GEN3 = zeroconf.ZeroconfServiceInfo( +HOMEKIT_DISCOVERY_GEN3 = ZeroconfServiceInfo( ip_address="1.2.3.4", ip_addresses=[IPv4Address("1.2.3.4")], hostname="mock_hostname", name="Powerview Generation 3._hap._tcp.local.", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC}, type="mock_type", ) -ZEROCONF_DISCOVERY_GEN2 = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DISCOVERY_GEN2 = ZeroconfServiceInfo( ip_address="1.2.3.4", ip_addresses=[IPv4Address("1.2.3.4")], hostname="mock_hostname", @@ -38,7 +42,7 @@ ZEROCONF_DISCOVERY_GEN2 = zeroconf.ZeroconfServiceInfo( type="mock_type", ) -ZEROCONF_DISCOVERY_GEN3 = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DISCOVERY_GEN3 = ZeroconfServiceInfo( ip_address="1.2.3.4", ip_addresses=[IPv4Address("1.2.3.4")], hostname="mock_hostname", @@ -48,19 +52,19 @@ ZEROCONF_DISCOVERY_GEN3 = zeroconf.ZeroconfServiceInfo( type="mock_type", ) -DHCP_DISCOVERY_GEN2 = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY_GEN2 = DhcpServiceInfo( hostname="Powerview Generation 2", ip="1.2.3.4", macaddress="aabbccddeeff", ) -DHCP_DISCOVERY_GEN2_NO_NAME = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY_GEN2_NO_NAME = DhcpServiceInfo( hostname="", ip="1.2.3.4", macaddress="aabbccddeeff", ) -DHCP_DISCOVERY_GEN3 = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY_GEN3 = DhcpServiceInfo( hostname="Powerview Generation 3", ip="1.2.3.4", macaddress="aabbccddeeff", diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py index 9952e838600..cf159c23bae 100644 --- a/tests/components/hunterdouglas_powerview/test_config_flow.py +++ b/tests/components/hunterdouglas_powerview/test_config_flow.py @@ -5,12 +5,13 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.hunterdouglas_powerview.const import DOMAIN from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.entity_registry as er +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DHCP_DATA, DISCOVERY_DATA, HOMEKIT_DATA, MOCK_SERIAL @@ -65,7 +66,7 @@ async def test_form_homekit_and_dhcp_cannot_connect( hass: HomeAssistant, mock_setup_entry: MagicMock, source: str, - discovery_info: dhcp.DhcpServiceInfo, + discovery_info: DhcpServiceInfo, api_version: int, ) -> None: """Test we get the form with homekit and dhcp source.""" @@ -112,7 +113,7 @@ async def test_form_homekit_and_dhcp( hass: HomeAssistant, mock_setup_entry: MagicMock, source: str, - discovery_info: dhcp.DhcpServiceInfo | zeroconf.ZeroconfServiceInfo, + discovery_info: DhcpServiceInfo | ZeroconfServiceInfo, api_version: int, ) -> None: """Test we get the form with homekit and dhcp source.""" @@ -166,10 +167,10 @@ async def test_discovered_by_homekit_and_dhcp( hass: HomeAssistant, mock_setup_entry: MagicMock, homekit_source: str, - homekit_discovery: zeroconf.ZeroconfServiceInfo, + homekit_discovery: ZeroconfServiceInfo, api_version: int, dhcp_source: str, - dhcp_discovery: dhcp.DhcpServiceInfo, + dhcp_discovery: DhcpServiceInfo, dhcp_api_version: int, ) -> None: """Test we get the form with homekit and abort for dhcp source when we get both.""" diff --git a/tests/components/hyperion/test_config_flow.py b/tests/components/hyperion/test_config_flow.py index 4109fe0f653..ac7e6c25b0d 100644 --- a/tests/components/hyperion/test_config_flow.py +++ b/tests/components/hyperion/test_config_flow.py @@ -10,7 +10,6 @@ from unittest.mock import AsyncMock, Mock, patch from hyperion import const -from homeassistant.components import ssdp from homeassistant.components.hyperion.const import ( CONF_AUTH_ID, CONF_CREATE_TOKEN, @@ -30,6 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from . import ( TEST_AUTH_REQUIRED_RESP, @@ -67,7 +67,7 @@ TEST_REQUEST_TOKEN_FAIL = { "error": "Token request timeout or denied", } -TEST_SSDP_SERVICE_INFO = ssdp.SsdpServiceInfo( +TEST_SSDP_SERVICE_INFO = SsdpServiceInfo( ssdp_st="upnp:rootdevice", ssdp_location=f"http://{TEST_HOST}:{TEST_PORT_UI}/description.xml", ssdp_usn=f"uuid:{TEST_SYSINFO_ID}", From c5efad3a2d5cff7ccf8086e1746e31bfc70ce76c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Jan 2025 13:19:17 +0100 Subject: [PATCH 0687/2987] Use new ServiceInfo location in component tests (part 4) (#136065) --- tests/components/airzone/test_config_flow.py | 4 +-- .../androidtv_remote/test_config_flow.py | 16 ++++----- tests/components/apple_tv/test_config_flow.py | 32 ++++++++--------- .../components/arcam_fmj/test_config_flow.py | 29 ++++++++++------ tests/components/awair/const.py | 4 +-- tests/components/axis/test_config_flow.py | 34 ++++++++++--------- tests/components/axis/test_hub.py | 5 +-- tests/components/baf/test_config_flow.py | 10 +++--- tests/components/blebox/test_config_flow.py | 10 +++--- tests/components/bond/test_config_flow.py | 22 ++++++------ .../components/bosch_shc/test_config_flow.py | 6 ++-- tests/components/braviatv/test_config_flow.py | 23 ++++++++----- .../components/broadlink/test_config_flow.py | 16 ++++----- tests/components/brother/test_config_flow.py | 12 +++---- 14 files changed, 120 insertions(+), 103 deletions(-) diff --git a/tests/components/airzone/test_config_flow.py b/tests/components/airzone/test_config_flow.py index 072699c7a26..9bc0a8cedbd 100644 --- a/tests/components/airzone/test_config_flow.py +++ b/tests/components/airzone/test_config_flow.py @@ -12,7 +12,6 @@ from aioairzone.exceptions import ( ) from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.airzone.config_flow import short_mac from homeassistant.components.airzone.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState @@ -20,6 +19,7 @@ from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .util import ( CONFIG, @@ -32,7 +32,7 @@ from .util import ( from tests.common import MockConfigEntry -DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( +DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="airzone", ip="192.168.1.100", macaddress=dr.format_mac("E84F25000000").replace(":", ""), diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index 02e15bca415..0968ea5acff 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.androidtv_remote.config_flow import ( APPS_NEW_ID, CONF_APP_DELETE, @@ -22,6 +21,7 @@ from homeassistant.components.androidtv_remote.const import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -444,7 +444,7 @@ async def test_zeroconf_flow_success( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -522,7 +522,7 @@ async def test_zeroconf_flow_cannot_connect( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -573,7 +573,7 @@ async def test_zeroconf_flow_pairing_invalid_auth( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -657,7 +657,7 @@ async def test_zeroconf_flow_already_configured_host_changed_reloads_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -710,7 +710,7 @@ async def test_zeroconf_flow_already_configured_host_not_changed_no_reload_entry result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -743,7 +743,7 @@ async def test_zeroconf_flow_abort_if_mac_is_missing( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -787,7 +787,7 @@ async def test_zeroconf_flow_already_configured_zeroconf_has_multiple_invalid_ip result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.5"), ip_addresses=[ip_address("1.2.3.5"), ip_address(host)], port=6466, diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index 4567bd32582..a13eb3c605b 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -9,7 +9,6 @@ from pyatv.const import PairingRequirement, Protocol import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.apple_tv import CONF_ADDRESS, config_flow from homeassistant.components.apple_tv.const import ( CONF_IDENTIFIERS, @@ -19,12 +18,13 @@ from homeassistant.components.apple_tv.const import ( from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .common import airplay_service, create_conf, mrp_service, raop_service from tests.common import MockConfigEntry -DMAP_SERVICE = zeroconf.ZeroconfServiceInfo( +DMAP_SERVICE = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -35,7 +35,7 @@ DMAP_SERVICE = zeroconf.ZeroconfServiceInfo( ) -RAOP_SERVICE = zeroconf.ZeroconfServiceInfo( +RAOP_SERVICE = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -566,7 +566,7 @@ async def test_zeroconf_unsupported_service_aborts(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -586,7 +586,7 @@ async def test_zeroconf_add_mrp_device(hass: HomeAssistant) -> None: unrelated_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -601,7 +601,7 @@ async def test_zeroconf_add_mrp_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -883,7 +883,7 @@ async def test_zeroconf_abort_if_other_in_progress( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -906,7 +906,7 @@ async def test_zeroconf_abort_if_other_in_progress( result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -933,7 +933,7 @@ async def test_zeroconf_missing_device_during_protocol_resolve( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -955,7 +955,7 @@ async def test_zeroconf_missing_device_during_protocol_resolve( await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -992,7 +992,7 @@ async def test_zeroconf_additional_protocol_resolve_failure( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1014,7 +1014,7 @@ async def test_zeroconf_additional_protocol_resolve_failure( await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1053,7 +1053,7 @@ async def test_zeroconf_pair_additionally_found_protocols( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1096,7 +1096,7 @@ async def test_zeroconf_pair_additionally_found_protocols( await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1158,7 +1158,7 @@ async def test_zeroconf_mismatch(hass: HomeAssistant, mock_scan: AsyncMock) -> N result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1242,7 +1242,7 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", diff --git a/tests/components/arcam_fmj/test_config_flow.py b/tests/components/arcam_fmj/test_config_flow.py index 60c68c5e102..1a578fc613d 100644 --- a/tests/components/arcam_fmj/test_config_flow.py +++ b/tests/components/arcam_fmj/test_config_flow.py @@ -7,12 +7,21 @@ from unittest.mock import AsyncMock, MagicMock, patch from arcam.fmj.client import ConnectionFailed import pytest -from homeassistant.components import ssdp from homeassistant.components.arcam_fmj.const import DOMAIN from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import ( MOCK_CONFIG_ENTRY, @@ -36,18 +45,18 @@ MOCK_UPNP_DEVICE = f""" MOCK_UPNP_LOCATION = f"http://{MOCK_HOST}:8080/dd.xml" -MOCK_DISCOVER = ssdp.SsdpServiceInfo( +MOCK_DISCOVER = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{MOCK_HOST}:8080/dd.xml", upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: "ARCAM", - ssdp.ATTR_UPNP_MODEL_NAME: " ", - ssdp.ATTR_UPNP_MODEL_NUMBER: "AVR450, AVR750", - ssdp.ATTR_UPNP_FRIENDLY_NAME: f"Arcam media client {MOCK_UUID}", - ssdp.ATTR_UPNP_SERIAL: "12343", - ssdp.ATTR_UPNP_UDN: MOCK_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:MediaRenderer:1", + ATTR_UPNP_MANUFACTURER: "ARCAM", + ATTR_UPNP_MODEL_NAME: " ", + ATTR_UPNP_MODEL_NUMBER: "AVR450, AVR750", + ATTR_UPNP_FRIENDLY_NAME: f"Arcam media client {MOCK_UUID}", + ATTR_UPNP_SERIAL: "12343", + ATTR_UPNP_UDN: MOCK_UDN, + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:MediaRenderer:1", }, ) @@ -115,7 +124,7 @@ async def test_ssdp_unable_to_connect( async def test_ssdp_invalid_id(hass: HomeAssistant) -> None: """Test a ssdp with invalid UDN.""" discover = replace( - MOCK_DISCOVER, upnp=MOCK_DISCOVER.upnp | {ssdp.ATTR_UPNP_UDN: "invalid"} + MOCK_DISCOVER, upnp=MOCK_DISCOVER.upnp | {ATTR_UPNP_UDN: "invalid"} ) result = await hass.config_entries.flow.async_init( diff --git a/tests/components/awair/const.py b/tests/components/awair/const.py index f24eaeb971d..6dc9118f511 100644 --- a/tests/components/awair/const.py +++ b/tests/components/awair/const.py @@ -2,15 +2,15 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo AWAIR_UUID = "awair_24947" CLOUD_CONFIG = {CONF_ACCESS_TOKEN: "12345"} LOCAL_CONFIG = {CONF_HOST: "192.0.2.5"} CLOUD_UNIQUE_ID = "foo@bar.com" LOCAL_UNIQUE_ID = "00:B0:D0:63:C2:26" -ZEROCONF_DISCOVERY = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address("192.0.2.5"), ip_addresses=[ip_address("192.0.2.5")], hostname="mock_hostname", diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index 52dd9c2f8ad..c7c3097aaaa 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pytest -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.components.axis import config_flow from homeassistant.components.axis.const import ( CONF_STREAM_PROFILE, @@ -33,6 +32,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import BaseServiceInfo, FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DEFAULT_HOST, MAC, MODEL, NAME @@ -268,7 +270,7 @@ async def test_reconfiguration_flow_update_configuration( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname=f"axis-{MAC}", ip=DEFAULT_HOST, macaddress=DHCP_FORMATTED_MAC, @@ -276,7 +278,7 @@ async def test_reconfiguration_flow_update_configuration( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -312,7 +314,7 @@ async def test_reconfiguration_flow_update_configuration( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(DEFAULT_HOST), ip_addresses=[ip_address(DEFAULT_HOST)], port=80, @@ -376,7 +378,7 @@ async def test_discovery_flow( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname=f"axis-{MAC}", ip=DEFAULT_HOST, macaddress=DHCP_FORMATTED_MAC, @@ -384,7 +386,7 @@ async def test_discovery_flow( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -396,7 +398,7 @@ async def test_discovery_flow( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(DEFAULT_HOST), ip_addresses=[ip_address(DEFAULT_HOST)], hostname="mock_hostname", @@ -431,7 +433,7 @@ async def test_discovered_device_already_configured( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname=f"axis-{MAC}", ip="2.3.4.5", macaddress=DHCP_FORMATTED_MAC, @@ -440,7 +442,7 @@ async def test_discovered_device_already_configured( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -453,7 +455,7 @@ async def test_discovered_device_already_configured( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("2.3.4.5"), ip_addresses=[ip_address("2.3.4.5")], hostname="mock_hostname", @@ -507,7 +509,7 @@ async def test_discovery_flow_updated_configuration( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname="", ip="", macaddress=dr.format_mac("01234567890").replace(":", ""), @@ -515,7 +517,7 @@ async def test_discovery_flow_updated_configuration( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -527,7 +529,7 @@ async def test_discovery_flow_updated_configuration( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=None, ip_addresses=[], hostname="mock_hostname", @@ -556,7 +558,7 @@ async def test_discovery_flow_ignore_non_axis_device( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname=f"axis-{MAC}", ip="169.254.3.4", macaddress=DHCP_FORMATTED_MAC, @@ -564,7 +566,7 @@ async def test_discovery_flow_ignore_non_axis_device( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -576,7 +578,7 @@ async def test_discovery_flow_ignore_non_axis_device( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("169.254.3.4"), ip_addresses=[ip_address("169.254.3.4")], hostname="mock_hostname", diff --git a/tests/components/axis/test_hub.py b/tests/components/axis/test_hub.py index 74cdb0164cd..b2f2d15d989 100644 --- a/tests/components/axis/test_hub.py +++ b/tests/components/axis/test_hub.py @@ -11,13 +11,14 @@ import axis as axislib import pytest from syrupy import SnapshotAssertion -from homeassistant.components import axis, zeroconf +from homeassistant.components import axis from homeassistant.components.axis.const import DOMAIN as AXIS_DOMAIN from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import RtspEventMock, RtspStateType from .const import ( @@ -93,7 +94,7 @@ async def test_update_address( mock_requests("2.3.4.5") await hass.config_entries.flow.async_init( AXIS_DOMAIN, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("2.3.4.5"), ip_addresses=[ip_address("2.3.4.5")], hostname="mock_hostname", diff --git a/tests/components/baf/test_config_flow.py b/tests/components/baf/test_config_flow.py index 765801d22cf..14810f67ce3 100644 --- a/tests/components/baf/test_config_flow.py +++ b/tests/components/baf/test_config_flow.py @@ -4,11 +4,11 @@ from ipaddress import ip_address from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.baf.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import MOCK_NAME, MOCK_UUID, MockBAFDevice @@ -90,7 +90,7 @@ async def test_zeroconf_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -128,7 +128,7 @@ async def test_zeroconf_updates_existing_ip(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -148,7 +148,7 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", @@ -167,7 +167,7 @@ async def test_user_flow_is_not_blocked_by_discovery(hass: HomeAssistant) -> Non discovery_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", diff --git a/tests/components/blebox/test_config_flow.py b/tests/components/blebox/test_config_flow.py index 612c4f09424..4b0c1b23e79 100644 --- a/tests/components/blebox/test_config_flow.py +++ b/tests/components/blebox/test_config_flow.py @@ -7,12 +7,12 @@ import blebox_uniapi import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.blebox import config_flow from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component from .conftest import mock_config, mock_feature, mock_only_feature, setup_product_mock @@ -227,7 +227,7 @@ async def test_flow_with_zeroconf(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("172.100.123.4"), ip_addresses=[ip_address("172.100.123.4")], port=80, @@ -267,7 +267,7 @@ async def test_flow_with_zeroconf_when_already_configured(hass: HomeAssistant) - result2 = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("172.100.123.4"), ip_addresses=[ip_address("172.100.123.4")], port=80, @@ -291,7 +291,7 @@ async def test_flow_with_zeroconf_when_device_unsupported(hass: HomeAssistant) - result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("172.100.123.4"), ip_addresses=[ip_address("172.100.123.4")], port=80, @@ -317,7 +317,7 @@ async def test_flow_with_zeroconf_when_device_response_unsupported( result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("172.100.123.4"), ip_addresses=[ip_address("172.100.123.4")], port=80, diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index d61ed4844a1..73aece4af6b 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -10,12 +10,12 @@ from unittest.mock import MagicMock, Mock, patch from aiohttp import ClientConnectionError, ClientResponseError from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.bond.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .common import ( patch_bond_bridge, @@ -219,7 +219,7 @@ async def test_zeroconf_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -260,7 +260,7 @@ async def test_zeroconf_form_token_unavailable(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -302,7 +302,7 @@ async def test_zeroconf_form_token_times_out(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -349,7 +349,7 @@ async def test_zeroconf_form_with_token_available(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -393,7 +393,7 @@ async def test_zeroconf_form_with_token_available_name_unavailable( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -437,7 +437,7 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -475,7 +475,7 @@ async def test_zeroconf_in_setup_retry_state(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -522,7 +522,7 @@ async def test_zeroconf_already_configured_refresh_token(hass: HomeAssistant) -> result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -561,7 +561,7 @@ async def test_zeroconf_already_configured_no_reload_same_host( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.3"), ip_addresses=[ip_address("127.0.0.3")], hostname="mock_hostname", @@ -583,7 +583,7 @@ async def test_zeroconf_form_unexpected_error(hass: HomeAssistant) -> None: await _help_test_form_unexpected_error( hass, source=config_entries.SOURCE_ZEROCONF, - initial_input=zeroconf.ZeroconfServiceInfo( + initial_input=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", diff --git a/tests/components/bosch_shc/test_config_flow.py b/tests/components/bosch_shc/test_config_flow.py index 63f7169b026..06fd5b9102c 100644 --- a/tests/components/bosch_shc/test_config_flow.py +++ b/tests/components/bosch_shc/test_config_flow.py @@ -13,11 +13,11 @@ from boschshcpy.information import SHCInformation import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.bosch_shc.config_flow import write_tls_asset from homeassistant.components.bosch_shc.const import CONF_SHC_CERT, CONF_SHC_KEY, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -25,7 +25,7 @@ MOCK_SETTINGS = { "name": "Test name", "device": {"mac": "test-mac", "hostname": "test-host"}, } -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="shc012345.local.", @@ -615,7 +615,7 @@ async def test_zeroconf_not_bosch_shc(hass: HomeAssistant) -> None: """Test we filter out non-bosch_shc devices.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index 7a4f93f7f16..497e88053f5 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -10,7 +10,6 @@ from pybravia import ( ) import pytest -from homeassistant.components import ssdp from homeassistant.components.braviatv.const import ( CONF_NICKNAME, CONF_USE_PSK, @@ -22,6 +21,12 @@ from homeassistant.const import CONF_CLIENT_ID, CONF_HOST, CONF_MAC, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import instance_id +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -46,14 +51,14 @@ BRAVIA_SOURCES = [ {"title": "AV/Component", "uri": "extInput:component?port=1"}, ] -BRAVIA_SSDP = ssdp.SsdpServiceInfo( +BRAVIA_SSDP = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://bravia-host:52323/dmr.xml", upnp={ - ssdp.ATTR_UPNP_UDN: "uuid:1234", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Living TV", - ssdp.ATTR_UPNP_MODEL_NAME: "KE-55XH9096", + ATTR_UPNP_UDN: "uuid:1234", + ATTR_UPNP_FRIENDLY_NAME: "Living TV", + ATTR_UPNP_MODEL_NAME: "KE-55XH9096", "X_ScalarWebAPI_DeviceInfo": { "X_ScalarWebAPI_ServiceList": { "X_ScalarWebAPI_ServiceType": [ @@ -68,14 +73,14 @@ BRAVIA_SSDP = ssdp.SsdpServiceInfo( }, ) -FAKE_BRAVIA_SSDP = ssdp.SsdpServiceInfo( +FAKE_BRAVIA_SSDP = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://soundbar-host:52323/dmr.xml", upnp={ - ssdp.ATTR_UPNP_UDN: "uuid:1234", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Sony Audio Device", - ssdp.ATTR_UPNP_MODEL_NAME: "HT-S700RF", + ATTR_UPNP_UDN: "uuid:1234", + ATTR_UPNP_FRIENDLY_NAME: "Sony Audio Device", + ATTR_UPNP_MODEL_NAME: "HT-S700RF", "X_ScalarWebAPI_DeviceInfo": { "X_ScalarWebAPI_ServiceList": { "X_ScalarWebAPI_ServiceType": ["guide", "system", "audio", "avContent"], diff --git a/tests/components/broadlink/test_config_flow.py b/tests/components/broadlink/test_config_flow.py index f31cb380631..14e41bbff19 100644 --- a/tests/components/broadlink/test_config_flow.py +++ b/tests/components/broadlink/test_config_flow.py @@ -8,10 +8,10 @@ import broadlink.exceptions as blke import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.broadlink.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import get_device @@ -828,7 +828,7 @@ async def test_dhcp_can_finish(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress=device.mac, @@ -862,7 +862,7 @@ async def test_dhcp_fails_to_connect(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress="34ea34b43b5a", @@ -881,7 +881,7 @@ async def test_dhcp_unreachable(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress="34ea34b43b5a", @@ -900,7 +900,7 @@ async def test_dhcp_connect_unknown_error(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress="34ea34b43b5a", @@ -922,7 +922,7 @@ async def test_dhcp_device_not_supported(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip=device.host, macaddress=device.mac, @@ -946,7 +946,7 @@ async def test_dhcp_already_exists(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress="34ea34b43b5a", @@ -971,7 +971,7 @@ async def test_dhcp_updates_host(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="4.5.6.7", macaddress="34ea34b43b5a", diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py index 929e2f083e9..945f5549bbe 100644 --- a/tests/components/brother/test_config_flow.py +++ b/tests/components/brother/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import AsyncMock, patch from brother import SnmpError, UnsupportedModelError import pytest -from homeassistant.components import zeroconf from homeassistant.components.brother.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import init_integration @@ -121,7 +121,7 @@ async def test_zeroconf_exception( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -145,7 +145,7 @@ async def test_zeroconf_unsupported_model(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -171,7 +171,7 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -200,7 +200,7 @@ async def test_zeroconf_no_probe_existing_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -224,7 +224,7 @@ async def test_zeroconf_confirm_create_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", From 077fbb91c065f07704b6c59295e6143dfecd7bde Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 20 Jan 2025 13:28:30 +0100 Subject: [PATCH 0688/2987] Improve user interface strings in opentherm_gw (#136078) --- 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 77c7e3ab40a..405af126c03 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -15,7 +15,7 @@ }, "error": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "id_exists": "Gateway id already exists", + "id_exists": "Gateway ID already exists", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" } @@ -379,7 +379,7 @@ "fields": { "gateway_id": { "name": "Gateway ID", - "description": "The gateway_id of the OpenTherm Gateway." + "description": "The ID of the OpenTherm Gateway." } } }, From 33429043301cf815d8fef8fe3b70959fbb7b0046 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Jan 2025 14:04:58 +0100 Subject: [PATCH 0689/2987] Use new ServiceInfo location in core tests (#136067) --- tests/test_config_entries.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index ee0df28a6e4..39860dc67c2 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -16,7 +16,6 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries, data_entry_flow, loader -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, @@ -41,6 +40,7 @@ from homeassistant.helpers import entity_registry as er, frame, issue_registry a from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import json_dumps +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -2645,9 +2645,7 @@ async def test_unique_id_from_discovery_in_setup_retry( VERSION = 1 - async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo - ) -> FlowResult: + async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: """Test dhcp step.""" await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() @@ -2683,7 +2681,7 @@ async def test_unique_id_from_discovery_in_setup_retry( discovery_result = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="any", ip=host, macaddress=unique_id, From ea82c4974ecdbee5dc40f7a4950e372a02be6007 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 20 Jan 2025 14:53:41 +0100 Subject: [PATCH 0690/2987] Fix spelling of "ID" in hyperion user strings (#136082) --- homeassistant/components/hyperion/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hyperion/strings.json b/homeassistant/components/hyperion/strings.json index 01682648277..ea7bc9e39fa 100644 --- a/homeassistant/components/hyperion/strings.json +++ b/homeassistant/components/hyperion/strings.json @@ -18,7 +18,7 @@ } }, "create_token": { - "description": "Choose **Submit** below to request a new authentication token. You will be redirected to the Hyperion UI to approve the request. Please verify the shown id is \"{auth_id}\"", + "description": "Choose **Submit** below to request a new authentication token. You will be redirected to the Hyperion UI to approve the request. Please verify the shown ID is \"{auth_id}\"", "title": "Automatically create new authentication token" }, "create_token_external": { @@ -40,7 +40,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "auth_new_token_not_granted_error": "Newly created token was not approved on Hyperion UI", "auth_new_token_not_work_error": "Failed to authenticate using newly created token", - "no_id": "The Hyperion Ambilight instance did not report its id", + "no_id": "The Hyperion Ambilight instance did not report its ID", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, From 9730ac4e7247afe11f7072793435bbbaa6c399de Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 20 Jan 2025 14:58:53 +0100 Subject: [PATCH 0691/2987] Replace `targets` key with UI name 'Targets' in media_player.join action (#136063) --- homeassistant/components/media_player/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 1c9ba929b38..be06ae22cdc 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -310,7 +310,7 @@ "fields": { "group_members": { "name": "Group members", - "description": "The players which will be synced with the playback specified in `target`." + "description": "The players which will be synced with the playback specified in 'Targets'." } } }, From 63d294e58ed30b1741862379e9e954dc88c65c5e Mon Sep 17 00:00:00 2001 From: Paul Donohue Date: Mon, 20 Jan 2025 09:00:32 -0500 Subject: [PATCH 0692/2987] Prevent pylint out-of-memory failures (#136020) --- .pre-commit-config.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c59e6ebb147..805e3ac4dbd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -61,13 +61,14 @@ repos: name: mypy entry: script/run-in-env.sh mypy language: script - types_or: [python, pyi] require_serial: true + types_or: [python, pyi] files: ^(homeassistant|pylint)/.+\.(py|pyi)$ - id: pylint name: pylint - entry: script/run-in-env.sh pylint -j 0 --ignore-missing-annotations=y + entry: script/run-in-env.sh pylint --ignore-missing-annotations=y language: script + require_serial: true types_or: [python, pyi] files: ^(homeassistant|tests)/.+\.(py|pyi)$ - id: gen_requirements_all From 3e716a1308b274387dc622d6239212982b13d9c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 20 Jan 2025 14:19:17 +0000 Subject: [PATCH 0693/2987] Use fixtures for Network component tests (#135220) --- tests/components/network/__init__.py | 3 + tests/components/network/conftest.py | 59 ++++++- tests/components/network/test_init.py | 234 ++++++-------------------- 3 files changed, 115 insertions(+), 181 deletions(-) diff --git a/tests/components/network/__init__.py b/tests/components/network/__init__.py index f3ccacbd064..bbac7ca2f7c 100644 --- a/tests/components/network/__init__.py +++ b/tests/components/network/__init__.py @@ -1 +1,4 @@ """Tests for the Network Configuration integration.""" + +NO_LOOPBACK_IPADDR = "192.168.1.5" +LOOPBACK_IPADDR = "127.0.0.1" diff --git a/tests/components/network/conftest.py b/tests/components/network/conftest.py index d5fbb95a814..db2f268e968 100644 --- a/tests/components/network/conftest.py +++ b/tests/components/network/conftest.py @@ -1,14 +1,51 @@ """Tests for the Network Configuration integration.""" from collections.abc import Generator -from unittest.mock import _patch +from unittest.mock import MagicMock, Mock, _patch, patch +import ifaddr import pytest +from . import LOOPBACK_IPADDR, NO_LOOPBACK_IPADDR + + +def _generate_mock_adapters(): + mock_lo0 = Mock(spec=ifaddr.Adapter) + mock_lo0.nice_name = "lo0" + mock_lo0.ips = [ifaddr.IP(LOOPBACK_IPADDR, 8, "lo0")] + mock_lo0.index = 0 + mock_eth0 = Mock(spec=ifaddr.Adapter) + mock_eth0.nice_name = "eth0" + mock_eth0.ips = [ifaddr.IP(("2001:db8::", 1, 1), 8, "eth0")] + mock_eth0.index = 1 + mock_eth1 = Mock(spec=ifaddr.Adapter) + mock_eth1.nice_name = "eth1" + mock_eth1.ips = [ifaddr.IP(NO_LOOPBACK_IPADDR, 23, "eth1")] + mock_eth1.index = 2 + mock_vtun0 = Mock(spec=ifaddr.Adapter) + mock_vtun0.nice_name = "vtun0" + mock_vtun0.ips = [ifaddr.IP("169.254.3.2", 16, "vtun0")] + mock_vtun0.index = 3 + return [mock_eth0, mock_lo0, mock_eth1, mock_vtun0] + + +def _mock_socket(sockname: list[str]) -> Generator[None]: + """Mock the network socket.""" + with patch( + "homeassistant.components.network.util.socket.socket", + return_value=MagicMock(getsockname=Mock(return_value=sockname)), + ): + yield + @pytest.fixture(autouse=True) -def mock_network(): +def mock_network() -> Generator[None]: """Override mock of network util's async_get_adapters.""" + with patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ): + yield @pytest.fixture(autouse=True) @@ -19,3 +56,21 @@ def override_mock_get_source_ip( mock_get_source_ip.stop() yield mock_get_source_ip.start() + + +@pytest.fixture +def mock_socket(request: pytest.FixtureRequest) -> Generator[None]: + """Mock the network socket.""" + yield from _mock_socket(request.param) + + +@pytest.fixture +def mock_socket_loopback() -> Generator[None]: + """Mock the network socket with loopback address.""" + yield from _mock_socket([LOOPBACK_IPADDR]) + + +@pytest.fixture +def mock_socket_no_loopback() -> Generator[None]: + """Mock the network socket with loopback address.""" + yield from _mock_socket([NO_LOOPBACK_IPADDR]) diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index dca31106dba..a2352e6af9e 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -4,7 +4,6 @@ from ipaddress import IPv4Address from typing import Any from unittest.mock import MagicMock, Mock, patch -import ifaddr import pytest from homeassistant.components import network @@ -20,17 +19,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +from . import LOOPBACK_IPADDR, NO_LOOPBACK_IPADDR + from tests.typing import WebSocketGenerator -_NO_LOOPBACK_IPADDR = "192.168.1.5" -_LOOPBACK_IPADDR = "127.0.0.1" - - -def _mock_socket(sockname): - mock_socket = MagicMock() - mock_socket.getsockname = Mock(return_value=sockname) - return mock_socket - def _mock_cond_socket(sockname): class CondMockSock(MagicMock): @@ -54,42 +46,13 @@ def _mock_socket_exception(exc): return mock_socket -def _generate_mock_adapters(): - mock_lo0 = Mock(spec=ifaddr.Adapter) - mock_lo0.nice_name = "lo0" - mock_lo0.ips = [ifaddr.IP("127.0.0.1", 8, "lo0")] - mock_lo0.index = 0 - mock_eth0 = Mock(spec=ifaddr.Adapter) - mock_eth0.nice_name = "eth0" - mock_eth0.ips = [ifaddr.IP(("2001:db8::", 1, 1), 8, "eth0")] - mock_eth0.index = 1 - mock_eth1 = Mock(spec=ifaddr.Adapter) - mock_eth1.nice_name = "eth1" - mock_eth1.ips = [ifaddr.IP("192.168.1.5", 23, "eth1")] - mock_eth1.index = 2 - mock_vtun0 = Mock(spec=ifaddr.Adapter) - mock_vtun0.nice_name = "vtun0" - mock_vtun0.ips = [ifaddr.IP("169.254.3.2", 16, "vtun0")] - mock_vtun0.index = 3 - return [mock_eth0, mock_lo0, mock_eth1, mock_vtun0] - - +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_detect_interfaces_setting_non_loopback_route( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test without default interface config and the route returns a non-loopback address.""" - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_NO_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == [] @@ -141,22 +104,13 @@ async def test_async_detect_interfaces_setting_non_loopback_route( ] +@pytest.mark.usefixtures("mock_socket_loopback") async def test_async_detect_interfaces_setting_loopback_route( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test without default interface config and the route returns a loopback address.""" - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == [] @@ -207,22 +161,14 @@ async def test_async_detect_interfaces_setting_loopback_route( ] +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") async def test_async_detect_interfaces_setting_empty_route( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test without default interface config and the route returns nothing.""" - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == [] @@ -277,15 +223,9 @@ async def test_async_detect_interfaces_setting_exception( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test without default interface config and the route throws an exception.""" - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket_exception(AttributeError), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), + with patch( + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket_exception(AttributeError), ): assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -339,6 +279,7 @@ async def test_async_detect_interfaces_setting_exception( ] +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_interfaces_configured_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -348,18 +289,9 @@ async def test_interfaces_configured_from_storage( "key": STORAGE_KEY, "data": {ATTR_CONFIGURED_ADAPTERS: ["eth0", "eth1", "vtun0"]}, } - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_NO_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == ["eth0", "eth1", "vtun0"] @@ -422,15 +354,9 @@ async def test_interfaces_configured_from_storage_websocket_update( "key": STORAGE_KEY, "data": {ATTR_CONFIGURED_ADAPTERS: ["eth0", "eth1", "vtun0"]}, } - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_NO_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), + with patch( + "homeassistant.components.network.util.socket.socket", + return_value=MagicMock(getsockname=Mock(return_value=[NO_LOOPBACK_IPADDR])), ): assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -546,6 +472,7 @@ async def test_interfaces_configured_from_storage_websocket_update( ] +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_get_source_ip_matching_interface( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -556,22 +483,13 @@ async def test_async_get_source_ip_matching_interface( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket(["192.168.1.5"]), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() - assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "192.168.1.5" + assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == NO_LOOPBACK_IPADDR +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_get_source_ip_interface_not_match( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -582,22 +500,14 @@ async def test_async_get_source_ip_interface_not_match( "data": {ATTR_CONFIGURED_ADAPTERS: ["vtun0"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket(["192.168.1.5"]), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() - assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "169.254.3.2" + assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "169.254.3.2" +@pytest.mark.parametrize("mock_socket", [[None]], indirect=True) +@pytest.mark.usefixtures("mock_socket") async def test_async_get_source_ip_cannot_determine_target( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -608,22 +518,13 @@ async def test_async_get_source_ip_cannot_determine_target( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([None]), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() - assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "192.168.1.5" + assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == NO_LOOPBACK_IPADDR +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_get_ipv4_broadcast_addresses_default( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -634,24 +535,15 @@ async def test_async_get_ipv4_broadcast_addresses_default( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket(["192.168.1.5"]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() assert await network.async_get_ipv4_broadcast_addresses(hass) == { IPv4Address("255.255.255.255") } +@pytest.mark.usefixtures("mock_socket_loopback") async def test_async_get_ipv4_broadcast_addresses_multiple( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -662,18 +554,8 @@ async def test_async_get_ipv4_broadcast_addresses_multiple( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1", "vtun0"]}, } - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() assert await network.async_get_ipv4_broadcast_addresses(hass) == { IPv4Address("255.255.255.255"), @@ -682,6 +564,7 @@ async def test_async_get_ipv4_broadcast_addresses_multiple( } +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_get_source_ip_no_enabled_addresses( hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture ) -> None: @@ -692,24 +575,23 @@ async def test_async_get_source_ip_no_enabled_addresses( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=[], - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket(["192.168.1.5"]), - ), + with patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[], ): assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() - assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "192.168.1.5" + assert ( + await network.async_get_source_ip(hass, MDNS_TARGET_IP) + == NO_LOOPBACK_IPADDR + ) assert "source address detection may be inaccurate" in caplog.text +@pytest.mark.parametrize("mock_socket", [[None]], indirect=True) +@pytest.mark.usefixtures("mock_socket") async def test_async_get_source_ip_cannot_be_determined_and_no_enabled_addresses( hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture ) -> None: @@ -720,15 +602,9 @@ async def test_async_get_source_ip_cannot_be_determined_and_no_enabled_addresses "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=[], - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([None]), - ), + with patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[], ): assert not await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -753,7 +629,7 @@ async def test_async_get_source_ip_no_ip_loopback( ), patch( "homeassistant.components.network.util.socket.socket", - return_value=_mock_cond_socket(_LOOPBACK_IPADDR), + return_value=_mock_cond_socket(LOOPBACK_IPADDR), ), ): assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) From a7d5e52ffe2c988914ca8e0a2f8cc901ef05496a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Jan 2025 15:21:34 +0100 Subject: [PATCH 0694/2987] Always include SSL folder in backups (#136080) --- homeassistant/components/hassio/backup.py | 11 ++++++----- tests/components/hassio/test_backup.py | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 76196cfe9e8..2ebd3f6aab4 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -228,11 +228,12 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): include_addons_set = supervisor_backups.AddonSet.ALL elif include_addons: include_addons_set = set(include_addons) - include_folders_set = ( - {supervisor_backups.Folder(folder) for folder in include_folders} - if include_folders - else None - ) + include_folders_set = { + supervisor_backups.Folder(folder) for folder in include_folders or [] + } + # Always include SSL if Home Assistant is included + if include_homeassistant: + include_folders_set.add(supervisor_backups.Folder.SSL) hassio_agents: list[SupervisorBackupAgent] = [ cast(SupervisorBackupAgent, manager.backup_agents[agent_id]) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 10a804d983f..40ab253b7e6 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -673,7 +673,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( "instance_id": ANY, "with_automatic_settings": False, }, - folders=None, + folders={"ssl"}, homeassistant_exclude_database=False, homeassistant=True, location=[None], @@ -704,7 +704,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( ), ( {"include_folders": ["media", "share"]}, - replace(DEFAULT_BACKUP_OPTIONS, folders={"media", "share"}), + replace(DEFAULT_BACKUP_OPTIONS, folders={"media", "share", "ssl"}), ), ( { From 29b7d5c2e40974e429c83f3f51df1375de054cc6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 20 Jan 2025 15:32:18 +0100 Subject: [PATCH 0695/2987] Improve conversation typing (#136084) --- homeassistant/components/conversation/session.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/conversation/session.py b/homeassistant/components/conversation/session.py index ba9e0ad6292..426b11ea24b 100644 --- a/homeassistant/components/conversation/session.py +++ b/homeassistant/components/conversation/session.py @@ -1,11 +1,13 @@ """Conversation history.""" +from __future__ import annotations + from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from dataclasses import dataclass, field, replace from datetime import datetime, timedelta import logging -from typing import Generic, Literal, TypeVar +from typing import Literal from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import ( @@ -25,16 +27,15 @@ from homeassistant.util.hass_dict import HassKey from .const import DOMAIN from .models import ConversationInput, ConversationResult -DATA_CHAT_HISTORY: HassKey["dict[str, ChatSession]"] = HassKey( +DATA_CHAT_HISTORY: HassKey[dict[str, ChatSession]] = HassKey( "conversation_chat_session" ) -DATA_CHAT_HISTORY_CLEANUP: HassKey["SessionCleanup"] = HassKey( +DATA_CHAT_HISTORY_CLEANUP: HassKey[SessionCleanup] = HassKey( "conversation_chat_session_cleanup" ) LOGGER = logging.getLogger(__name__) CONVERSATION_TIMEOUT = timedelta(minutes=5) -_NativeT = TypeVar("_NativeT") class SessionCleanup: @@ -89,7 +90,7 @@ class SessionCleanup: async def async_get_chat_session( hass: HomeAssistant, user_input: ConversationInput, -) -> AsyncGenerator["ChatSession"]: +) -> AsyncGenerator[ChatSession]: """Return chat session.""" all_history = hass.data.get(DATA_CHAT_HISTORY) if all_history is None: @@ -164,7 +165,7 @@ class ConverseError(HomeAssistantError): @dataclass -class ChatMessage(Generic[_NativeT]): +class ChatMessage[_NativeT]: """Base class for chat messages. When role is native, the content is to be ignored and message @@ -184,7 +185,7 @@ class ChatMessage(Generic[_NativeT]): @dataclass -class ChatSession(Generic[_NativeT]): +class ChatSession[_NativeT]: """Class holding all information for a specific conversation.""" hass: HomeAssistant From 3630c8b8ed8924e3f0b55033b11987ef268ae0ee Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 20 Jan 2025 16:25:06 +0100 Subject: [PATCH 0696/2987] Set configuration url to overseerr instance (#136085) --- homeassistant/components/overseerr/coordinator.py | 11 ++++++++--- homeassistant/components/overseerr/entity.py | 1 + tests/components/overseerr/snapshots/test_init.ambr | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/overseerr/coordinator.py b/homeassistant/components/overseerr/coordinator.py index 79ad738c037..c8512d764f4 100644 --- a/homeassistant/components/overseerr/coordinator.py +++ b/homeassistant/components/overseerr/coordinator.py @@ -4,6 +4,7 @@ from datetime import timedelta from python_overseerr import OverseerrClient, RequestCount from python_overseerr.exceptions import OverseerrConnectionError +from yarl import URL from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL @@ -30,13 +31,17 @@ class OverseerrCoordinator(DataUpdateCoordinator[RequestCount]): config_entry=entry, update_interval=timedelta(minutes=5), ) + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + ssl = entry.data[CONF_SSL] self.client = OverseerrClient( - entry.data[CONF_HOST], - entry.data[CONF_PORT], + host, + port, entry.data[CONF_API_KEY], - ssl=entry.data[CONF_SSL], + ssl=ssl, session=async_get_clientsession(hass), ) + self.url = URL.build(host=host, port=port, scheme="https" if ssl else "http") async def _async_update_data(self) -> RequestCount: """Fetch data from API endpoint.""" diff --git a/homeassistant/components/overseerr/entity.py b/homeassistant/components/overseerr/entity.py index 6e835347736..937ad52f7ec 100644 --- a/homeassistant/components/overseerr/entity.py +++ b/homeassistant/components/overseerr/entity.py @@ -18,5 +18,6 @@ class OverseerrEntity(CoordinatorEntity[OverseerrCoordinator]): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, entry_type=DeviceEntryType.SERVICE, + configuration_url=coordinator.url, ) self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{key}" diff --git a/tests/components/overseerr/snapshots/test_init.ambr b/tests/components/overseerr/snapshots/test_init.ambr index 749a1aa445c..21b4b215ac5 100644 --- a/tests/components/overseerr/snapshots/test_init.ambr +++ b/tests/components/overseerr/snapshots/test_init.ambr @@ -3,7 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , - 'configuration_url': None, + 'configuration_url': 'http://overseerr.test', 'connections': set({ }), 'disabled_by': None, From c687a6f66910f243ed5a16446ce0508ee020e53c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Konrad=20Vit=C3=A9?= Date: Thu, 16 Jan 2025 23:31:16 +0100 Subject: [PATCH 0697/2987] Fix DiscoveryFlowHandler when discovery_function returns bool (#133563) Co-authored-by: J. Nick Koston --- homeassistant/helpers/config_entry_flow.py | 8 ++- tests/helpers/test_config_entry_flow.py | 65 +++++++++++++++++++--- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index b047e1aef81..60f2cd6e1a1 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -67,9 +67,11 @@ class DiscoveryFlowHandler[_R: Awaitable[bool] | bool](config_entries.ConfigFlow in_progress = self._async_in_progress() if not (has_devices := bool(in_progress)): - has_devices = await cast( - "asyncio.Future[bool]", self._discovery_function(self.hass) - ) + discovery_result = self._discovery_function(self.hass) + if isinstance(discovery_result, bool): + has_devices = discovery_result + else: + has_devices = await cast("asyncio.Future[bool]", discovery_result) if not has_devices: return self.async_abort(reason="no_devices_found") diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 13e28bb8840..172aa393538 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -1,6 +1,8 @@ """Tests for the Config Entry Flow helper.""" -from collections.abc import Generator +import asyncio +from collections.abc import Callable, Generator +from contextlib import contextmanager from unittest.mock import Mock, PropertyMock, patch import pytest @@ -13,22 +15,44 @@ from homeassistant.helpers import config_entry_flow from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform +@contextmanager +def _make_discovery_flow_conf( + has_discovered_devices: Callable[[], asyncio.Future[bool] | bool], +) -> Generator[None]: + with patch.dict(config_entries.HANDLERS): + config_entry_flow.register_discovery_flow( + "test", "Test", has_discovered_devices + ) + yield + + @pytest.fixture -def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: - """Register a handler.""" +def async_discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: + """Register a handler with an async discovery function.""" handler_conf = {"discovered": False} async def has_discovered_devices(hass: HomeAssistant) -> bool: """Mock if we have discovered devices.""" return handler_conf["discovered"] - with patch.dict(config_entries.HANDLERS): - config_entry_flow.register_discovery_flow( - "test", "Test", has_discovered_devices - ) + with _make_discovery_flow_conf(has_discovered_devices): yield handler_conf +@pytest.fixture +def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: + """Register a handler with a async friendly callback function.""" + handler_conf = {"discovered": False} + + def has_discovered_devices(hass: HomeAssistant) -> bool: + """Mock if we have discovered devices.""" + return handler_conf["discovered"] + + with _make_discovery_flow_conf(has_discovered_devices): + yield handler_conf + handler_conf = {"discovered": False} + + @pytest.fixture def webhook_flow_conf(hass: HomeAssistant) -> Generator[None]: """Register a handler.""" @@ -95,6 +119,33 @@ async def test_user_has_confirmation( assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY +async def test_user_has_confirmation_async_discovery_flow( + hass: HomeAssistant, async_discovery_flow_conf: dict[str, bool] +) -> None: + """Test user requires confirmation to setup with an async has_discovered_devices.""" + async_discovery_flow_conf["discovered"] = True + mock_platform(hass, "test.config_flow", None) + + result = await hass.config_entries.flow.async_init( + "test", context={"source": config_entries.SOURCE_USER}, data={} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "confirm" + + progress = hass.config_entries.flow.async_progress() + assert len(progress) == 1 + assert progress[0]["flow_id"] == result["flow_id"] + assert progress[0]["context"] == { + "confirm_only": True, + "source": config_entries.SOURCE_USER, + "unique_id": "test", + } + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + @pytest.mark.parametrize( "source", [ From 9680abf51e9b5587aff1356fb91ef7a21224b91d Mon Sep 17 00:00:00 2001 From: Matthew FitzGerald-Chamberlain Date: Thu, 16 Jan 2025 06:37:44 -0600 Subject: [PATCH 0698/2987] Aprilaire - Fix humidifier showing when it is not available (#133984) --- homeassistant/components/aprilaire/humidifier.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/aprilaire/humidifier.py b/homeassistant/components/aprilaire/humidifier.py index 254cc0ac789..8a173e5e95e 100644 --- a/homeassistant/components/aprilaire/humidifier.py +++ b/homeassistant/components/aprilaire/humidifier.py @@ -50,7 +50,7 @@ async def async_setup_entry( descriptions: list[AprilaireHumidifierDescription] = [] - if coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) in (0, 1, 2): + if coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) in (1, 2): descriptions.append( AprilaireHumidifierDescription( key="humidifier", @@ -67,7 +67,7 @@ async def async_setup_entry( ) ) - if coordinator.data.get(Attribute.DEHUMIDIFICATION_AVAILABLE) in (0, 1): + if coordinator.data.get(Attribute.DEHUMIDIFICATION_AVAILABLE) == 1: descriptions.append( AprilaireHumidifierDescription( key="dehumidifier", From 8865fc0c33a7371c8bbb5ab4587ff00c2fc7feb4 Mon Sep 17 00:00:00 2001 From: dcmeglio <21957250+dcmeglio@users.noreply.github.com> Date: Fri, 17 Jan 2025 04:14:41 -0500 Subject: [PATCH 0699/2987] Gracefully handle webhook unsubscription if error occurs while contacting Withings (#134271) --- homeassistant/components/withings/__init__.py | 6 ++- tests/components/withings/test_init.py | 54 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 1c196bd4b92..59c3ed8433f 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -16,6 +16,7 @@ from aiohttp import ClientError from aiohttp.hdrs import METH_POST from aiohttp.web import Request, Response from aiowithings import NotificationCategory, WithingsClient +from aiowithings.exceptions import WithingsError from aiowithings.util import to_enum from yarl import URL @@ -223,10 +224,13 @@ class WithingsWebhookManager: "Unregister Withings webhook (%s)", self.entry.data[CONF_WEBHOOK_ID] ) webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) - await async_unsubscribe_webhooks(self.withings_data.client) for coordinator in self.withings_data.coordinators: coordinator.webhook_subscription_listener(False) self._webhooks_registered = False + try: + await async_unsubscribe_webhooks(self.withings_data.client) + except WithingsError as ex: + LOGGER.warning("Failed to unsubscribe from Withings webhook: %s", ex) async def register_webhook( self, diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index e07e1f90cb4..d88af39488b 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -10,6 +10,7 @@ from aiohttp.hdrs import METH_HEAD from aiowithings import ( NotificationCategory, WithingsAuthenticationFailedError, + WithingsConnectionError, WithingsUnauthorizedError, ) from freezegun.api import FrozenDateTimeFactory @@ -532,6 +533,59 @@ async def test_cloud_disconnect_retry( assert mock_async_active_subscription.call_count == 4 +async def test_internet_timeout_then_restore( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we can recover from internet disconnects.""" + await mock_cloud(hass) + await hass.async_block_till_done() + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch.object(cloud, "async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), + patch( + "homeassistant.components.withings.async_get_config_entry_implementation", + ), + patch( + "homeassistant.components.cloud.async_delete_cloudhook", + ), + patch( + "homeassistant.components.withings.webhook_generate_url", + ), + ): + await setup_integration(hass, webhook_config_entry) + await prepare_webhook_setup(hass, freezer) + + assert cloud.async_active_subscription(hass) is True + assert cloud.async_is_connected(hass) is True + assert withings.revoke_notification_configurations.call_count == 3 + assert withings.subscribe_notification.call_count == 6 + + await hass.async_block_till_done() + + withings.list_notification_configurations.side_effect = WithingsConnectionError + + async_mock_cloud_connection_status(hass, False) + await hass.async_block_till_done() + + assert withings.revoke_notification_configurations.call_count == 3 + withings.list_notification_configurations.side_effect = None + + async_mock_cloud_connection_status(hass, True) + await hass.async_block_till_done() + + assert withings.subscribe_notification.call_count == 12 + + @pytest.mark.parametrize( ("body", "expected_code"), [ From 93c5915faa8dc5eff4c4166b2c7de424475db9a1 Mon Sep 17 00:00:00 2001 From: Renier Moorcroft <66512715+RenierM26@users.noreply.github.com> Date: Fri, 17 Jan 2025 13:27:44 +0200 Subject: [PATCH 0700/2987] Image entity key error when camera is ignored in EZVIZ (#134343) --- homeassistant/components/ezviz/image.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index 0fbc5cc6a68..73c09244222 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -8,7 +8,7 @@ from pyezviz.exceptions import PyEzvizError from pyezviz.utils import decrypt_image from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -57,7 +57,9 @@ class EzvizLastMotion(EzvizEntity, ImageEntity): ) camera = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) self.alarm_image_password = ( - camera.data[CONF_PASSWORD] if camera is not None else None + camera.data[CONF_PASSWORD] + if camera and camera.source != SOURCE_IGNORE + else None ) async def _async_load_image_from_url(self, url: str) -> Image | None: From 48c23c2e79b9c166f8d263de5da46a77165c9710 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 8 Jan 2025 06:54:39 +1000 Subject: [PATCH 0701/2987] Bump pyaussiebb to 0.1.5 (#134943) Bump --- homeassistant/components/aussie_broadband/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aussie_broadband/manifest.json b/homeassistant/components/aussie_broadband/manifest.json index 456b8962461..ea402f03b0e 100644 --- a/homeassistant/components/aussie_broadband/manifest.json +++ b/homeassistant/components/aussie_broadband/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aussie_broadband", "iot_class": "cloud_polling", "loggers": ["aussiebb"], - "requirements": ["pyaussiebb==0.1.4"] + "requirements": ["pyaussiebb==0.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index f11d3b691e3..3a2a8f7ef65 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1794,7 +1794,7 @@ pyatmo==8.1.0 pyatv==0.16.0 # homeassistant.components.aussie_broadband -pyaussiebb==0.1.4 +pyaussiebb==0.1.5 # homeassistant.components.balboa pybalboa==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 11260d55e59..90b14b16c42 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1474,7 +1474,7 @@ pyatmo==8.1.0 pyatv==0.16.0 # homeassistant.components.aussie_broadband -pyaussiebb==0.1.4 +pyaussiebb==0.1.5 # homeassistant.components.balboa pybalboa==1.0.2 From 56f54cdccf6650f6302aff482a1ac920bfb01660 Mon Sep 17 00:00:00 2001 From: adam-the-hero <132444842+adam-the-hero@users.noreply.github.com> Date: Tue, 14 Jan 2025 14:40:01 +0100 Subject: [PATCH 0702/2987] Fix Watergate Power supply mode description and MQTT/Wifi uptimes (#135085) --- homeassistant/components/watergate/sensor.py | 10 +++++++--- tests/components/watergate/snapshots/test_sensor.ambr | 4 ++-- tests/components/watergate/test_sensor.py | 4 ++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/watergate/sensor.py b/homeassistant/components/watergate/sensor.py index 638bf297415..6782a93541b 100644 --- a/homeassistant/components/watergate/sensor.py +++ b/homeassistant/components/watergate/sensor.py @@ -90,7 +90,7 @@ DESCRIPTIONS: list[WatergateSensorEntityDescription] = [ WatergateSensorEntityDescription( value_fn=lambda data: ( dt_util.as_utc( - dt_util.now() - timedelta(microseconds=data.networking.wifi_uptime) + dt_util.now() - timedelta(milliseconds=data.networking.wifi_uptime) ) if data.networking else None @@ -104,7 +104,7 @@ DESCRIPTIONS: list[WatergateSensorEntityDescription] = [ WatergateSensorEntityDescription( value_fn=lambda data: ( dt_util.as_utc( - dt_util.now() - timedelta(microseconds=data.networking.mqtt_uptime) + dt_util.now() - timedelta(milliseconds=data.networking.mqtt_uptime) ) if data.networking else None @@ -158,7 +158,11 @@ DESCRIPTIONS: list[WatergateSensorEntityDescription] = [ ), WatergateSensorEntityDescription( value_fn=lambda data: ( - PowerSupplyMode(data.state.power_supply.replace("+", "_")) + PowerSupplyMode( + data.state.power_supply.replace("+", "_").replace( + "external_battery", "battery_external" + ) + ) if data.state else None ), diff --git a/tests/components/watergate/snapshots/test_sensor.ambr b/tests/components/watergate/snapshots/test_sensor.ambr index 479a879a583..a58c7c0eab8 100644 --- a/tests/components/watergate/snapshots/test_sensor.ambr +++ b/tests/components/watergate/snapshots/test_sensor.ambr @@ -43,7 +43,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2021-01-09T11:59:59+00:00', + 'state': '2021-01-09T11:59:58+00:00', }) # --- # name: test_sensor[sensor.sonic_power_supply_mode-entry] @@ -501,6 +501,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2021-01-09T11:59:59+00:00', + 'state': '2021-01-09T11:59:57+00:00', }) # --- diff --git a/tests/components/watergate/test_sensor.py b/tests/components/watergate/test_sensor.py index 58632c7548b..78e375857ed 100644 --- a/tests/components/watergate/test_sensor.py +++ b/tests/components/watergate/test_sensor.py @@ -140,11 +140,11 @@ async def test_power_supply_webhook( power_supply_change_data = { "type": "power-supply-changed", - "data": {"supply": "external"}, + "data": {"supply": "external_battery"}, } client = await hass_client_no_auth() await client.post(f"/api/webhook/{MOCK_WEBHOOK_ID}", json=power_supply_change_data) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "external" + assert hass.states.get(entity_id).state == "battery_external" From 0660eae6f4a6cc415763924766e32c770e5196a3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jan 2025 11:09:49 +0100 Subject: [PATCH 0703/2987] Fix missing comma in ollama MODEL_NAMES (#135262) --- homeassistant/components/ollama/const.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py index 69c0a3d6296..857f0bff34a 100644 --- a/homeassistant/components/ollama/const.py +++ b/homeassistant/components/ollama/const.py @@ -61,7 +61,8 @@ MODEL_NAMES = [ # https://ollama.com/library "goliath", "granite-code", "granite3-dense", - "granite3-guardian" "granite3-moe", + "granite3-guardian", + "granite3-moe", "hermes3", "internlm2", "llama-guard3", From 5356ffa539800e34da968675f93a3efb7f68312c Mon Sep 17 00:00:00 2001 From: Quentame Date: Fri, 10 Jan 2025 20:47:48 +0100 Subject: [PATCH 0704/2987] Bump Freebox to 1.2.2 (#135313) --- homeassistant/components/freebox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json index 46422cee105..0cfe37c7a31 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/freebox", "iot_class": "local_polling", "loggers": ["freebox_api"], - "requirements": ["freebox-api==1.2.1"], + "requirements": ["freebox-api==1.2.2"], "zeroconf": ["_fbx-api._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 3a2a8f7ef65..fc495b862b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -940,7 +940,7 @@ forecast-solar==4.0.0 fortiosapi==1.0.5 # homeassistant.components.freebox -freebox-api==1.2.1 +freebox-api==1.2.2 # homeassistant.components.free_mobile freesms==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 90b14b16c42..2501dd01dd8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -796,7 +796,7 @@ foobot_async==1.0.0 forecast-solar==4.0.0 # homeassistant.components.freebox -freebox-api==1.2.1 +freebox-api==1.2.2 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor From 1d22fa9b45225f0a48baf4743acf7911836b4183 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 11 Jan 2025 23:15:49 +0100 Subject: [PATCH 0705/2987] Actually use translated entity names in Lametric (#135381) --- homeassistant/components/lametric/number.py | 3 +-- homeassistant/components/lametric/strings.json | 8 ++++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index a1d922c2d80..0d299a2e93a 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -33,7 +33,6 @@ NUMBERS = [ LaMetricNumberEntityDescription( key="brightness", translation_key="brightness", - name="Brightness", entity_category=EntityCategory.CONFIG, native_step=1, native_min_value=0, @@ -45,11 +44,11 @@ NUMBERS = [ LaMetricNumberEntityDescription( key="volume", translation_key="volume", - name="Volume", entity_category=EntityCategory.CONFIG, native_step=1, native_min_value=0, native_max_value=100, + native_unit_of_measurement=PERCENTAGE, has_fn=lambda device: bool(device.audio and device.audio.available), value_fn=lambda device: device.audio.volume if device.audio else 0, set_value_fn=lambda api, volume: api.audio(volume=int(volume)), diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index 0fd6f5a12dc..01e7823c76b 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -66,6 +66,14 @@ "name": "Dismiss all notifications" } }, + "number": { + "brightness": { + "name": "Brightness" + }, + "volume": { + "name": "Volume" + } + }, "sensor": { "rssi": { "name": "Wi-Fi signal" From ed4c54a700fba481c6ae565c11251a2b388c2bdd Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 12 Jan 2025 14:36:23 +0100 Subject: [PATCH 0706/2987] Fix descriptions of send_message action of Bring! integration (#135446) * Make "Urgent message" selector consistent, use "Bring!" as name - Replace one occurrence of "bring" with the brand name "Bring!" - Change description of action to third-person singular for consistency in Home Assistant - Make all occurrences of the selector "Urgent message" consistent (in sentence case) so they all get consistent translations, too - Change one related error message to refer to the UI name of the required "Article" field * Changed ` to ' to avoid Regex problems * Reverted change to notify_missing_argument_item Reverted to avoid failing test * Reverted change to "bring" * Add "is" to description of "Article" Co-authored-by: Jan Bouwhuis --------- Co-authored-by: Jan Bouwhuis --- homeassistant/components/bring/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index 7331f68a161..e65f9607afb 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -111,7 +111,7 @@ "services": { "send_message": { "name": "[%key:component::notify::services::notify::name%]", - "description": "Send a mobile push notification to members of a shared Bring! list.", + "description": "Sends a mobile push notification to members of a shared Bring! list.", "fields": { "entity_id": { "name": "List", @@ -122,8 +122,8 @@ "description": "Type of push notification to send to list members." }, "item": { - "name": "Article (Required if message type `Urgent Message` selected)", - "description": "Article name to include in an urgent message e.g. `Urgent Message - Please buy Cilantro urgently`" + "name": "Article (Required if notification type `Urgent message` is selected)", + "description": "Article name to include in an urgent message e.g. `Urgent message - Please buy Cilantro urgently`" } } } @@ -134,7 +134,7 @@ "going_shopping": "I'm going shopping! - Last chance to make changes", "changed_list": "List updated - Take a look at the articles", "shopping_done": "Shopping done - The fridge is well stocked", - "urgent_message": "Urgent Message - Please buy `Article name` urgently" + "urgent_message": "Urgent message - Please buy `Article` urgently" } } } From 2b636423d92773f272a1ce32c280043bb096b832 Mon Sep 17 00:00:00 2001 From: Ravaka Razafimanantsoa <3774520+SeraphicRav@users.noreply.github.com> Date: Mon, 13 Jan 2025 01:29:01 +0900 Subject: [PATCH 0707/2987] Bump switchbot-api to 2.3.1 (#135451) --- homeassistant/components/switchbot_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index eb08d2183b1..6fc6d8030d2 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["switchbot-api"], - "requirements": ["switchbot-api==2.2.1"] + "requirements": ["switchbot-api==2.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index fc495b862b0..c50fe2da49a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2782,7 +2782,7 @@ surepy==0.9.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.2.1 +switchbot-api==2.3.1 # homeassistant.components.synology_srm synology-srm==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2501dd01dd8..ce88b4112ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2237,7 +2237,7 @@ sunweg==3.0.2 surepy==0.9.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.2.1 +switchbot-api==2.3.1 # homeassistant.components.system_bridge systembridgeconnector==4.1.5 From 75a1a46a49cdd284a7b740288a00ae2532b3fbe9 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 13 Jan 2025 12:11:01 +0100 Subject: [PATCH 0708/2987] Fix incorrect cast in HitachiAirToWaterHeatingZone in Overkiz (#135468) --- .../overkiz/climate/hitachi_air_to_water_heating_zone.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py index 8410e50873d..c5465128bba 100644 --- a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py +++ b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py @@ -119,5 +119,5 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity): temperature = cast(float, kwargs.get(ATTR_TEMPERATURE)) await self.executor.async_execute_command( - OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1, int(temperature) + OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1, float(temperature) ) From d77ec8ffbed9dbc2bfb48e8fe4ac0659ecdf0f33 Mon Sep 17 00:00:00 2001 From: Khole <29937485+KJonline@users.noreply.github.com> Date: Mon, 13 Jan 2025 07:50:25 +0000 Subject: [PATCH 0709/2987] Replace pyhiveapi with pyhive-integration (#135482) --- 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 870223f8fe6..f68478516ab 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -9,5 +9,5 @@ }, "iot_class": "cloud_polling", "loggers": ["apyhiveapi"], - "requirements": ["pyhiveapi==0.5.16"] + "requirements": ["pyhive-integration==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index c50fe2da49a..cb94705451a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1965,7 +1965,7 @@ pyhaversion==22.8.0 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.5.16 +pyhive-integration==1.0.1 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce88b4112ab..e9a393797fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1594,7 +1594,7 @@ pyhaversion==22.8.0 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.5.16 +pyhive-integration==1.0.1 # homeassistant.components.homematic pyhomematic==0.1.77 From bef545259e10c6dd49da994a1f3108c0069bcc9a Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 13 Jan 2025 11:40:53 +0100 Subject: [PATCH 0710/2987] Fix referenced objects in script sequences (#135499) --- homeassistant/helpers/script.py | 9 +++++++ tests/helpers/test_script.py | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index a67ef60c799..5e866cddc79 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1589,6 +1589,9 @@ class Script: target, referenced, script[CONF_SEQUENCE] ) + elif action == cv.SCRIPT_ACTION_SEQUENCE: + Script._find_referenced_target(target, referenced, step[CONF_SEQUENCE]) + @cached_property def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" @@ -1636,6 +1639,9 @@ class Script: for script in step[CONF_PARALLEL]: Script._find_referenced_devices(referenced, script[CONF_SEQUENCE]) + elif action == cv.SCRIPT_ACTION_SEQUENCE: + Script._find_referenced_devices(referenced, step[CONF_SEQUENCE]) + @cached_property def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" @@ -1684,6 +1690,9 @@ class Script: for script in step[CONF_PARALLEL]: Script._find_referenced_entities(referenced, script[CONF_SEQUENCE]) + elif action == cv.SCRIPT_ACTION_SEQUENCE: + Script._find_referenced_entities(referenced, step[CONF_SEQUENCE]) + def run( self, variables: _VarsType | None = None, context: Context | None = None ) -> None: diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index c438e333ae6..d7c00e90bd6 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -4118,6 +4118,14 @@ async def test_referenced_labels(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"label_id": "label_sequence"}, + } + ], + }, ] ), "Test Name", @@ -4135,6 +4143,7 @@ async def test_referenced_labels(hass: HomeAssistant) -> None: "label_if_then", "label_if_else", "label_parallel", + "label_sequence", } # Test we cache results. assert script_obj.referenced_labels is script_obj.referenced_labels @@ -4220,6 +4229,14 @@ async def test_referenced_floors(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"floor_id": "floor_sequence"}, + } + ], + }, ] ), "Test Name", @@ -4236,6 +4253,7 @@ async def test_referenced_floors(hass: HomeAssistant) -> None: "floor_if_then", "floor_if_else", "floor_parallel", + "floor_sequence", } # Test we cache results. assert script_obj.referenced_floors is script_obj.referenced_floors @@ -4321,6 +4339,14 @@ async def test_referenced_areas(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"area_id": "area_sequence"}, + } + ], + }, ] ), "Test Name", @@ -4337,6 +4363,7 @@ async def test_referenced_areas(hass: HomeAssistant) -> None: "area_if_then", "area_if_else", "area_parallel", + "area_sequence", # 'area_service_template', # no area extraction from template } # Test we cache results. @@ -4437,6 +4464,14 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"entity_id": "light.sequence"}, + } + ], + }, ] ), "Test Name", @@ -4456,6 +4491,7 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: "light.if_then", "light.if_else", "light.parallel", + "light.sequence", # "light.service_template", # no entity extraction from template "scene.hello", "sensor.condition", @@ -4554,6 +4590,14 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "target": {"device_id": "sequence-device"}, + } + ], + }, ] ), "Test Name", @@ -4575,6 +4619,7 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: "if-then", "if-else", "parallel-device", + "sequence-device", } # Test we cache results. assert script_obj.referenced_devices is script_obj.referenced_devices From 0e37e0492862ebe1f06a45c0a62702e852dc0c08 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 13 Jan 2025 14:17:12 -0600 Subject: [PATCH 0711/2987] Use STT/TTS languages for LLM fallback (#135533) --- .../components/assist_pipeline/pipeline.py | 15 +- .../assist_pipeline/snapshots/test_init.ambr | 102 ++++++++++++ tests/components/assist_pipeline/test_init.py | 154 +++++++++++++++++- 3 files changed, 265 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 7dda24c4023..d6a0d77ec55 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1017,9 +1017,18 @@ class PipelineRun: raise RuntimeError("Recognize intent was not prepared") if self.pipeline.conversation_language == MATCH_ALL: - # LLMs support all languages ('*') so use pipeline language for - # intent fallback. - input_language = self.pipeline.language + # LLMs support all languages ('*') so use languages from the + # pipeline for intent fallback. + # + # We prioritize the STT and TTS languages because they may be more + # specific, such as "zh-CN" instead of just "zh". This is necessary + # for languages whose intents are split out by region when + # preferring local intent matching. + input_language = ( + self.pipeline.stt_language + or self.pipeline.tts_language + or self.pipeline.language + ) else: input_language = self.pipeline.conversation_language diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index f63a28efbb7..171014fdc4a 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -474,6 +474,108 @@ }), ]) # --- +# name: test_stt_language_used_instead_of_conversation_language + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'conversation.home_assistant', + 'intent_input': 'test input', + 'language': 'en-US', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + }), + }), + }), + 'processed_locally': True, + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_tts_language_used_instead_of_conversation_language + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'conversation.home_assistant', + 'intent_input': 'test input', + 'language': 'en-us', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + }), + }), + }), + 'processed_locally': True, + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- # name: test_wake_word_detection_aborted list([ dict({ diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index d4cce4e2e98..a2cb9ef382a 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -1102,13 +1102,13 @@ async def test_prefer_local_intents( ) -async def test_pipeline_language_used_instead_of_conversation_language( +async def test_stt_language_used_instead_of_conversation_language( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components, snapshot: SnapshotAssertion, ) -> None: - """Test that the pipeline language is used when the conversation language is '*' (all languages).""" + """Test that the STT language is used first when the conversation language is '*' (all languages).""" client = await hass_ws_client(hass) events: list[assist_pipeline.PipelineEvent] = [] @@ -1165,7 +1165,155 @@ async def test_pipeline_language_used_instead_of_conversation_language( assert intent_start is not None - # Pipeline language (en) should be used instead of '*' + # STT language (en-US) should be used instead of '*' + assert intent_start.data.get("language") == pipeline.stt_language + + # Check input to async_converse + mock_async_converse.assert_called_once() + assert ( + mock_async_converse.call_args_list[0].kwargs.get("language") + == pipeline.stt_language + ) + + +async def test_tts_language_used_instead_of_conversation_language( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test that the TTS language is used after STT when the conversation language is '*' (all languages).""" + client = await hass_ws_client(hass) + + events: list[assist_pipeline.PipelineEvent] = [] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": MATCH_ALL, + "language": "en", + "name": "test_name", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": "en-us", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test input", + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + intent.IntentResponse(pipeline.language) + ), + ) as mock_async_converse: + await pipeline_input.execute() + + # Check intent start event + assert process_events(events) == snapshot + intent_start: assist_pipeline.PipelineEvent | None = None + for event in events: + if event.type == assist_pipeline.PipelineEventType.INTENT_START: + intent_start = event + break + + assert intent_start is not None + + # STT language (en-US) should be used instead of '*' + assert intent_start.data.get("language") == pipeline.tts_language + + # Check input to async_converse + mock_async_converse.assert_called_once() + assert ( + mock_async_converse.call_args_list[0].kwargs.get("language") + == pipeline.tts_language + ) + + +async def test_pipeline_language_used_instead_of_conversation_language( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test that the pipeline language is used last when the conversation language is '*' (all languages).""" + client = await hass_ws_client(hass) + + events: list[assist_pipeline.PipelineEvent] = [] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": MATCH_ALL, + "language": "en", + "name": "test_name", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "wake_word_entity": None, + "wake_word_id": None, + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test input", + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + intent.IntentResponse(pipeline.language) + ), + ) as mock_async_converse: + await pipeline_input.execute() + + # Check intent start event + assert process_events(events) == snapshot + intent_start: assist_pipeline.PipelineEvent | None = None + for event in events: + if event.type == assist_pipeline.PipelineEventType.INTENT_START: + intent_start = event + break + + assert intent_start is not None + + # STT language (en-US) should be used instead of '*' assert intent_start.data.get("language") == pipeline.language # Check input to async_converse From c6cde1361554202010b2d6f2add834b6f42f6605 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 14 Jan 2025 12:56:31 +0100 Subject: [PATCH 0712/2987] Bump demetriek to 1.2.0 (#135580) --- homeassistant/components/lametric/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/lametric/snapshots/test_diagnostics.ambr | 8 ++++++++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lametric/manifest.json b/homeassistant/components/lametric/manifest.json index f66ffb0c6ae..4c4359d0ddb 100644 --- a/homeassistant/components/lametric/manifest.json +++ b/homeassistant/components/lametric/manifest.json @@ -13,7 +13,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["demetriek"], - "requirements": ["demetriek==1.1.1"], + "requirements": ["demetriek==1.2.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:LaMetric:1" diff --git a/requirements_all.txt b/requirements_all.txt index cb94705451a..1c964cff270 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -749,7 +749,7 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==1.1.1 +demetriek==1.2.0 # homeassistant.components.denonavr denonavr==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e9a393797fb..efafd8f8f9c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -639,7 +639,7 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==1.1.1 +demetriek==1.2.0 # homeassistant.components.denonavr denonavr==1.0.1 diff --git a/tests/components/lametric/snapshots/test_diagnostics.ambr b/tests/components/lametric/snapshots/test_diagnostics.ambr index 8b8f98b5806..d8f21424216 100644 --- a/tests/components/lametric/snapshots/test_diagnostics.ambr +++ b/tests/components/lametric/snapshots/test_diagnostics.ambr @@ -24,7 +24,15 @@ 'device_id': '**REDACTED**', 'display': dict({ 'brightness': 100, + 'brightness_limit': dict({ + 'range_max': 100, + 'range_min': 2, + }), 'brightness_mode': 'auto', + 'brightness_range': dict({ + 'range_max': 100, + 'range_min': 0, + }), 'display_type': 'mixed', 'height': 8, 'on': None, From 0bd03346e87cc8d230cf9562364e4c52f671cc2f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 14 Jan 2025 14:02:17 +0100 Subject: [PATCH 0713/2987] Use device supplied ranges in LaMetric (#135590) --- homeassistant/components/lametric/number.py | 23 +++++-- .../lametric/fixtures/computer_powered.json | 68 +++++++++++++++++++ tests/components/lametric/test_number.py | 15 +++- 3 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 tests/components/lametric/fixtures/computer_powered.json diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index 0d299a2e93a..ccfd48a3abf 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from demetriek import Device, LaMetricDevice +from demetriek import Device, LaMetricDevice, Range from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry @@ -25,6 +25,7 @@ class LaMetricNumberEntityDescription(NumberEntityDescription): """Class describing LaMetric number entities.""" value_fn: Callable[[Device], int | None] + range_fn: Callable[[Device], Range | None] has_fn: Callable[[Device], bool] = lambda device: True set_value_fn: Callable[[LaMetricDevice, float], Awaitable[Any]] @@ -35,8 +36,7 @@ NUMBERS = [ translation_key="brightness", entity_category=EntityCategory.CONFIG, native_step=1, - native_min_value=0, - native_max_value=100, + range_fn=lambda device: device.display.brightness_limit, native_unit_of_measurement=PERCENTAGE, value_fn=lambda device: device.display.brightness, set_value_fn=lambda device, bri: device.display(brightness=int(bri)), @@ -46,8 +46,7 @@ NUMBERS = [ translation_key="volume", entity_category=EntityCategory.CONFIG, native_step=1, - native_min_value=0, - native_max_value=100, + range_fn=lambda device: device.audio.volume_range if device.audio else None, native_unit_of_measurement=PERCENTAGE, has_fn=lambda device: bool(device.audio and device.audio.available), value_fn=lambda device: device.audio.volume if device.audio else 0, @@ -92,6 +91,20 @@ class LaMetricNumberEntity(LaMetricEntity, NumberEntity): """Return the number value.""" return self.entity_description.value_fn(self.coordinator.data) + @property + def native_min_value(self) -> int: + """Return the min range.""" + if limits := self.entity_description.range_fn(self.coordinator.data): + return limits.range_min + return 0 + + @property + def native_max_value(self) -> int: + """Return the max range.""" + if limits := self.entity_description.range_fn(self.coordinator.data): + return limits.range_max + return 100 + @lametric_exception_handler async def async_set_native_value(self, value: float) -> None: """Change to new number value.""" diff --git a/tests/components/lametric/fixtures/computer_powered.json b/tests/components/lametric/fixtures/computer_powered.json new file mode 100644 index 00000000000..0465dd4dd3a --- /dev/null +++ b/tests/components/lametric/fixtures/computer_powered.json @@ -0,0 +1,68 @@ +{ + "audio": { + "available": true, + "volume": 53, + "volume_limit": { + "max": 100, + "min": 0 + }, + "volume_range": { + "max": 100, + "min": 0 + } + }, + "bluetooth": { + "active": false, + "address": "40:F4:C9:AA:AA:AA", + "available": true, + "discoverable": true, + "mac": "40:F4:C9:AA:AA:AA", + "name": "LM8367", + "pairable": false + }, + "display": { + "brightness": 75, + "brightness_limit": { + "max": 76, + "min": 2 + }, + "brightness_mode": "manual", + "brightness_range": { + "max": 100, + "min": 0 + }, + "height": 8, + "on": true, + "screensaver": { + "enabled": true, + "modes": { + "time_based": { + "enabled": false + }, + "when_dark": { + "enabled": true + } + }, + "widget": "1_com.lametric.clock" + }, + "type": "mixed", + "width": 37 + }, + "id": "67790", + "mode": "manual", + "model": "sa8", + "name": "TIME", + "os_version": "3.1.3", + "serial_number": "SA840700836700W00BAA", + "wifi": { + "active": true, + "mac": "40:F4:C9:AA:AA:AA", + "available": true, + "encryption": "WPA", + "ssid": "My wifi", + "ip": "10.0.0.99", + "mode": "dhcp", + "netmask": "255.255.255.0", + "rssi": 78 + } +} diff --git a/tests/components/lametric/test_number.py b/tests/components/lametric/test_number.py index 681abf850d2..811078289c1 100644 --- a/tests/components/lametric/test_number.py +++ b/tests/components/lametric/test_number.py @@ -42,7 +42,7 @@ async def test_brightness( assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's LaMetric Brightness" assert state.attributes.get(ATTR_MAX) == 100 - assert state.attributes.get(ATTR_MIN) == 0 + assert state.attributes.get(ATTR_MIN) == 2 assert state.attributes.get(ATTR_STEP) == 1 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "100" @@ -183,3 +183,16 @@ async def test_number_connection_error( state = hass.states.get("number.frenck_s_lametric_volume") assert state assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("device_fixture", ["computer_powered"]) +async def test_computer_powered_devices( + hass: HomeAssistant, + mock_lametric: MagicMock, +) -> None: + """Test Brightness is properly limited for computer powered devices.""" + state = hass.states.get("number.time_brightness") + assert state + assert state.state == "75" + assert state.attributes[ATTR_MIN] == 2 + assert state.attributes[ATTR_MAX] == 76 From 44046c5f83915c3d6be0208dfeeb2cf4278f1d12 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Tue, 14 Jan 2025 14:14:41 -0500 Subject: [PATCH 0714/2987] Bump elkm1-lib to 2.2.11 (#135616) --- homeassistant/components/elkm1/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index 7822307e12e..12c22e23ff0 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/elkm1", "iot_class": "local_push", "loggers": ["elkm1_lib"], - "requirements": ["elkm1-lib==2.2.10"] + "requirements": ["elkm1-lib==2.2.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1c964cff270..5ceaf313552 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -824,7 +824,7 @@ elgato==5.1.2 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.10 +elkm1-lib==2.2.11 # homeassistant.components.elmax elmax-api==0.0.6.4rc0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index efafd8f8f9c..8611a1ec107 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -699,7 +699,7 @@ elevenlabs==1.9.0 elgato==5.1.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.10 +elkm1-lib==2.2.11 # homeassistant.components.elmax elmax-api==0.0.6.4rc0 From cc0989b50ec58574e00ad84fd1c468b5ee81098d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 15 Jan 2025 10:13:27 +0100 Subject: [PATCH 0715/2987] Fix mqtt number state validation (#135621) --- homeassistant/components/mqtt/number.py | 6 +- tests/components/mqtt/test_number.py | 96 +++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index a9bf1829b63..9b47a3ad23a 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -179,14 +179,14 @@ class MqttNumber(MqttEntity, RestoreNumber): return if num_value is not None and ( - num_value < self.min_value or num_value > self.max_value + num_value < self.native_min_value or num_value > self.native_max_value ): _LOGGER.error( "Invalid value for %s: %s (range %s - %s)", self.entity_id, num_value, - self.min_value, - self.max_value, + self.native_min_value, + self.native_max_value, ) return diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index 48aaa11f672..7bdd39e81a7 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -29,6 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, State +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .test_common import ( help_custom_config, @@ -157,6 +158,101 @@ async def test_run_number_setup( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "min": 15, + "max": 28, + "device_class": "temperature", + "unit_of_measurement": UnitOfTemperature.CELSIUS.value, + } + } + } + ], +) +async def test_native_value_validation( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test state validation and native value conversion.""" + mqtt_mock = await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test/state_number", "23.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 15 + assert state.attributes.get(ATTR_MAX) == 28 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.CELSIUS.value + ) + assert state.state == "23.5" + + # Test out of range validation + async_fire_mqtt_message(hass, "test/state_number", "29.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 15 + assert state.attributes.get(ATTR_MAX) == 28 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.CELSIUS.value + ) + assert state.state == "23.5" + assert ( + "Invalid value for number.test_number: 29.5 (range 15.0 - 28.0)" in caplog.text + ) + caplog.clear() + + # Check if validation still works when changing unit system + hass.config.units = US_CUSTOMARY_SYSTEM + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "test/state_number", "24.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 59.0 + assert state.attributes.get(ATTR_MAX) == 82.4 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.FAHRENHEIT.value + ) + assert state.state == "76.1" + + # Test out of range validation again + async_fire_mqtt_message(hass, "test/state_number", "29.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 59.0 + assert state.attributes.get(ATTR_MAX) == 82.4 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.FAHRENHEIT.value + ) + assert state.state == "76.1" + assert ( + "Invalid value for number.test_number: 29.5 (range 15.0 - 28.0)" in caplog.text + ) + caplog.clear() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number", ATTR_VALUE: 68}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with("test/cmd_number", "20", 0, False) + mqtt_mock.async_publish.reset_mock() + + @pytest.mark.parametrize( "hass_config", [ From 83ab6b8ea289ce055d1b3787b1fe9f9343d22e04 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 18 Jan 2025 18:41:24 +0100 Subject: [PATCH 0716/2987] Add reauthentication to SmartThings (#135673) * Add reauthentication to SmartThings * Add reauthentication to SmartThings * Add reauthentication to SmartThings * Add reauthentication to SmartThings --- .../components/smartthings/__init__.py | 38 ++++------- .../components/smartthings/config_flow.py | 43 +++++++++++- .../components/smartthings/smartapp.py | 66 ++++++++++++++----- .../components/smartthings/strings.json | 15 ++++- .../smartthings/test_config_flow.py | 54 +++++++++++++++ tests/components/smartthings/test_init.py | 12 +--- 6 files changed, 173 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index bcc752ff173..2914851ccbf 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -10,12 +10,16 @@ import logging from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError from pysmartapp.event import EVENT_TYPE_DEVICE -from pysmartthings import Attribute, Capability, SmartThings +from pysmartthings import APIInvalidGrant, Attribute, Capability, SmartThings from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -106,7 +110,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # to import the modules. await async_get_loaded_integration(hass, DOMAIN).async_get_platforms(PLATFORMS) - remove_entry = False try: # See if the app is already setup. This occurs when there are # installs in multiple SmartThings locations (valid use-case) @@ -175,34 +178,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: broker.connect() hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker + except APIInvalidGrant as ex: + raise ConfigEntryAuthFailed from ex except ClientResponseError as ex: if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): - _LOGGER.exception( - ( - "Unable to setup configuration entry '%s' - please reconfigure the" - " integration" - ), - entry.title, - ) - remove_entry = True - else: - _LOGGER.debug(ex, exc_info=True) - raise ConfigEntryNotReady from ex + raise ConfigEntryError( + "The access token is no longer valid. Please remove the integration and set up again." + ) from ex + _LOGGER.debug(ex, exc_info=True) + raise ConfigEntryNotReady from ex except (ClientConnectionError, RuntimeWarning) as ex: _LOGGER.debug(ex, exc_info=True) raise ConfigEntryNotReady from ex - if remove_entry: - hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) - # only create new flow if there isn't a pending one for SmartThings. - if not hass.config_entries.flow.async_progress_by_handler(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) - ) - return False - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 081f833787e..7b49854740a 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -1,5 +1,6 @@ """Config flow to configure SmartThings.""" +from collections.abc import Mapping from http import HTTPStatus import logging from typing import Any @@ -9,7 +10,7 @@ from pysmartthings import APIResponseError, AppOAuth, SmartThings from pysmartthings.installedapp import format_install_url import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -213,7 +214,10 @@ class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN): url = format_install_url(self.app_id, self.location_id) return self.async_external_step(step_id="authorize", url=url) - return self.async_external_step_done(next_step_id="install") + next_step_id = "install" + if self.source == SOURCE_REAUTH: + next_step_id = "update" + return self.async_external_step_done(next_step_id=next_step_id) def _show_step_pat(self, errors): if self.access_token is None: @@ -240,6 +244,41 @@ class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN): }, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + self.app_id = self._get_reauth_entry().data[CONF_APP_ID] + self.location_id = self._get_reauth_entry().data[CONF_LOCATION_ID] + self._set_confirm_only() + return await self.async_step_authorize() + + async def async_step_update( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + return await self.async_step_update_confirm() + + async def async_step_update_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + if user_input is None: + self._set_confirm_only() + return self.async_show_form(step_id="update_confirm") + entry = self._get_reauth_entry() + return self.async_update_reload_and_abort( + entry, data_updates={CONF_REFRESH_TOKEN: self.refresh_token} + ) + async def async_step_install( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 6b0da00b132..76b6804075f 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -1,5 +1,7 @@ """SmartApp functionality to receive cloud-push notifications.""" +from __future__ import annotations + import asyncio import functools import logging @@ -27,6 +29,7 @@ from pysmartthings import ( ) from homeassistant.components import cloud, webhook +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -400,7 +403,7 @@ async def smartapp_sync_subscriptions( _LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id) -async def _continue_flow( +async def _find_and_continue_flow( hass: HomeAssistant, app_id: str, location_id: str, @@ -418,24 +421,34 @@ async def _continue_flow( None, ) if flow is not None: - await hass.config_entries.flow.async_configure( - flow["flow_id"], - { - CONF_INSTALLED_APP_ID: installed_app_id, - CONF_REFRESH_TOKEN: refresh_token, - }, - ) - _LOGGER.debug( - "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", - flow["flow_id"], - installed_app_id, - app_id, - ) + await _continue_flow(hass, app_id, installed_app_id, refresh_token, flow) + + +async def _continue_flow( + hass: HomeAssistant, + app_id: str, + installed_app_id: str, + refresh_token: str, + flow: ConfigFlowResult, +) -> None: + await hass.config_entries.flow.async_configure( + flow["flow_id"], + { + CONF_INSTALLED_APP_ID: installed_app_id, + CONF_REFRESH_TOKEN: refresh_token, + }, + ) + _LOGGER.debug( + "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", + flow["flow_id"], + installed_app_id, + app_id, + ) async def smartapp_install(hass: HomeAssistant, req, resp, app): """Handle a SmartApp installation and continue the config flow.""" - await _continue_flow( + await _find_and_continue_flow( hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token ) _LOGGER.debug( @@ -447,6 +460,27 @@ async def smartapp_install(hass: HomeAssistant, req, resp, app): async def smartapp_update(hass: HomeAssistant, req, resp, app): """Handle a SmartApp update and either update the entry or continue the flow.""" + unique_id = format_unique_id(app.app_id, req.location_id) + flow = next( + ( + flow + for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) + if flow["context"].get("unique_id") == unique_id + and flow["step_id"] == "authorize" + ), + None, + ) + if flow is not None: + await _continue_flow( + hass, app.app_id, req.installed_app_id, req.refresh_token, flow + ) + _LOGGER.debug( + "Continued reauth flow '%s' for SmartApp '%s' under parent app '%s'", + flow["flow_id"], + req.installed_app_id, + app.app_id, + ) + return entry = next( ( entry @@ -466,7 +500,7 @@ async def smartapp_update(hass: HomeAssistant, req, resp, app): app.app_id, ) - await _continue_flow( + await _find_and_continue_flow( hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token ) _LOGGER.debug( diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index de94e5adfcd..31a552be149 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -7,7 +7,7 @@ }, "pat": { "title": "Enter Personal Access Token", - "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.", + "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.\n\n**Please note that all Personal Access Tokens created after 30 December 2024 are only valid for 24 hours, after which the integration will stop working. We are working on a fix.**", "data": { "access_token": "[%key:common::config_flow::data::access_token%]" } @@ -17,11 +17,20 @@ "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.", "data": { "location_id": "[%key:common::config_flow::data::location%]" } }, - "authorize": { "title": "Authorize Home Assistant" } + "authorize": { "title": "Authorize Home Assistant" }, + "reauth_confirm": { + "title": "Reauthorize Home Assistant", + "description": "You are about to reauthorize Home Assistant with SmartThings. This will require you to log in and authorize the integration again." + }, + "update_confirm": { + "title": "Finish reauthentication", + "description": "You have almost successfully reauthorized Home Assistant with SmartThings. Please press the button down below to finish the process." + } }, "abort": { "invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.", - "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant." + "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant.", + "reauth_successful": "Home Assistant has been successfully reauthorized with SmartThings." }, "error": { "token_invalid_format": "The token must be in the UID/GUID format", diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 3621e58bc3d..05ddc3a71de 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -14,6 +14,7 @@ from homeassistant.components.smartthings.const import ( CONF_APP_ID, CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, + CONF_REFRESH_TOKEN, DOMAIN, ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET @@ -757,3 +758,56 @@ async def test_no_available_locations_aborts( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_available_locations" + + +async def test_reauth( + hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock +) -> None: + """Test reauth flow.""" + token = str(uuid4()) + installed_app_id = str(uuid4()) + refresh_token = str(uuid4()) + smartthings_mock.apps.return_value = [] + smartthings_mock.create_app.return_value = (app, app_oauth_client) + smartthings_mock.locations.return_value = [location] + request = Mock() + request.installed_app_id = installed_app_id + request.auth_token = token + request.location_id = location.location_id + request.refresh_token = refresh_token + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_APP_ID: app.app_id, + CONF_CLIENT_ID: app_oauth_client.client_id, + CONF_CLIENT_SECRET: app_oauth_client.client_secret, + CONF_LOCATION_ID: location.location_id, + CONF_INSTALLED_APP_ID: installed_app_id, + CONF_ACCESS_TOKEN: token, + CONF_REFRESH_TOKEN: "abc", + }, + unique_id=smartapp.format_unique_id(app.app_id, location.location_id), + ) + entry.add_to_hass(hass) + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["step_id"] == "authorize" + assert result["url"] == format_install_url(app.app_id, location.location_id) + + await smartapp.smartapp_update(hass, request, None, app) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "update_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert entry.data[CONF_REFRESH_TOKEN] == refresh_token diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index e518f84aecb..83372b58228 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -23,6 +23,7 @@ from homeassistant.components.smartthings.const import ( PLATFORMS, SIGNAL_SMARTTHINGS_UPDATE, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config from homeassistant.exceptions import ConfigEntryNotReady @@ -68,17 +69,10 @@ async def test_unrecoverable_api_errors_create_new_flow( ) # Assert setup returns false - result = await smartthings.async_setup_entry(hass, config_entry) + result = await hass.config_entries.async_setup(config_entry.entry_id) assert not result - # Assert entry was removed and new flow created - await hass.async_block_till_done() - assert not hass.config_entries.async_entries(DOMAIN) - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["handler"] == "smartthings" - assert flows[0]["context"] == {"source": config_entries.SOURCE_IMPORT} - hass.config_entries.flow.async_abort(flows[0]["flow_id"]) + assert config_entry.state == ConfigEntryState.SETUP_ERROR async def test_recoverable_api_errors_raise_not_ready( From 4f5235cbd4c77f751c0fe81d8831b0f193f6285a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Jan 2025 13:33:58 -1000 Subject: [PATCH 0717/2987] Handle invalid HS color values in HomeKit Bridge (#135739) --- .../components/homekit/type_lights.py | 6 +- tests/components/homekit/test_type_lights.py | 267 ++++++++++++++++++ 2 files changed, 272 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index cde80178c5e..eec35fcc82e 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -282,7 +282,11 @@ class Light(HomeAccessory): hue, saturation = color_temperature_to_hs(color_temp) elif color_mode == ColorMode.WHITE: hue, saturation = 0, 0 - elif hue_sat := attributes.get(ATTR_HS_COLOR): + elif ( + (hue_sat := attributes.get(ATTR_HS_COLOR)) + and isinstance(hue_sat, (list, tuple)) + and len(hue_sat) == 2 + ): hue, saturation = hue_sat else: hue = None diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index fb059b93a13..53a661c1c83 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -1,6 +1,7 @@ """Test different accessory types: Lights.""" from datetime import timedelta +import sys from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE import pytest @@ -540,6 +541,272 @@ async def test_light_color_temperature_and_rgb_color( assert acc.char_saturation.value == 100 +async def test_light_invalid_hs_color( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test light that starts out with an invalid hs color.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_HS_COLOR: 260, + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 0 + assert acc.char_saturation.value == 75 + + assert hasattr(acc, "char_color_temp") + + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 4464}) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 224 + assert acc.char_hue.value == 27 + assert acc.char_saturation.value == 27 + + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 2840}) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 352 + assert acc.char_hue.value == 28 + assert acc.char_saturation.value == 61 + + char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] + char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] + char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] + char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] + char_color_temp_iid = acc.char_color_temp.to_HAP()[HAP_REPR_IID] + + # Set from HomeKit + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_iid, + HAP_REPR_VALUE: 20, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 250, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 50, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 50, + }, + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[0] + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 + assert call_turn_on[0].data[ATTR_COLOR_TEMP_KELVIN] == 4000 + + assert len(events) == 1 + assert ( + events[-1].data[ATTR_VALUE] + == f"Set state to 1, brightness at 20{PERCENTAGE}, color temperature at 250" + ) + + # Only set Hue + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 30, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[1] + assert call_turn_on[1].data[ATTR_HS_COLOR] == (30, 50) + + assert events[-1].data[ATTR_VALUE] == "set color at (30, 50)" + + # Only set Saturation + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 20, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[2] + assert call_turn_on[2].data[ATTR_HS_COLOR] == (30, 20) + + assert events[-1].data[ATTR_VALUE] == "set color at (30, 20)" + + # Generate a conflict by setting hue and then color temp + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 80, + } + ] + }, + "mock_addr", + ) + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 320, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[3] + assert call_turn_on[3].data[ATTR_COLOR_TEMP_KELVIN] == 3125 + assert events[-1].data[ATTR_VALUE] == "color temperature at 320" + + # Generate a conflict by setting color temp then saturation + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 404, + } + ] + }, + "mock_addr", + ) + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 35, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[4] + assert call_turn_on[4].data[ATTR_HS_COLOR] == (80, 35) + assert events[-1].data[ATTR_VALUE] == "set color at (80, 35)" + + # Set from HASS + hass.states.async_set(entity_id, STATE_ON, {ATTR_HS_COLOR: (100, 100)}) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 404 + assert acc.char_hue.value == 100 + assert acc.char_saturation.value == 100 + + +async def test_light_invalid_values( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test light with a variety of invalid values.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_HS_COLOR: (-1, -1), + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 0 + assert acc.char_saturation.value == 0 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: -1, + }, + ) + await hass.async_block_till_done() + acc.run() + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 16 + assert acc.char_saturation.value == 100 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: sys.maxsize, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: 2000, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 500 + assert acc.char_hue.value == 31 + assert acc.char_saturation.value == 95 + + @pytest.mark.parametrize( "supported_color_modes", [[ColorMode.HS], [ColorMode.RGB], [ColorMode.XY]] ) From 480045887ace56cdac7fe1df9717946588e5d2cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sat, 18 Jan 2025 21:57:54 +0100 Subject: [PATCH 0718/2987] Update aioairzone to v0.9.9 (#135866) Co-authored-by: J. Nick Koston --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airzone/snapshots/test_diagnostics.ambr | 1 + tests/components/airzone/util.py | 2 ++ 5 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 01fde7eb2fb..95ed9d200f4 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.9.7"] + "requirements": ["aioairzone==0.9.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5ceaf313552..6d873c14ece 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairq==0.4.3 aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.7 +aioairzone==0.9.9 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8611a1ec107..ab79c651eb3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairq==0.4.3 aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.7 +aioairzone==0.9.9 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index fb4f6530b1e..bb44a0abeb1 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -140,6 +140,7 @@ 'heatStages': 1, 'heatangle': 0, 'humidity': 40, + 'master_zoneID': None, 'maxTemp': 30, 'minTemp': 15, 'mode': 3, diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 278663b7a97..b51dfb890e4 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -28,6 +28,7 @@ from aioairzone.const import ( API_HEAT_STAGES, API_HUMIDITY, API_MAC, + API_MASTER_ZONE_ID, API_MAX_TEMP, API_MIN_TEMP, API_MODE, @@ -214,6 +215,7 @@ HVAC_MOCK = { API_FLOOR_DEMAND: 0, API_HEAT_ANGLE: 0, API_COLD_ANGLE: 0, + API_MASTER_ZONE_ID: None, }, ] }, From a42c2b2986399f2830b28678a682e62e7503a406 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Fri, 17 Jan 2025 20:49:01 +0100 Subject: [PATCH 0719/2987] Remove device_class from NFC and fingerprint event descriptions (#135867) --- homeassistant/components/unifiprotect/event.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py index c8bce183e34..78fdf7746de 100644 --- a/homeassistant/components/unifiprotect/event.py +++ b/homeassistant/components/unifiprotect/event.py @@ -181,7 +181,6 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = ( ProtectEventEntityDescription( key="nfc", translation_key="nfc", - device_class=EventDeviceClass.DOORBELL, icon="mdi:nfc", ufp_required_field="feature_flags.support_nfc", ufp_event_obj="last_nfc_card_scanned_event", @@ -191,7 +190,6 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = ( ProtectEventEntityDescription( key="fingerprint", translation_key="fingerprint", - device_class=EventDeviceClass.DOORBELL, icon="mdi:fingerprint", ufp_required_field="feature_flags.has_fingerprint_sensor", ufp_event_obj="last_fingerprint_identified_event", From 84b3db16749b3d91b36b258efbb2c43dd3ffa1b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jan 2025 14:08:17 -1000 Subject: [PATCH 0720/2987] Prevent HomeKit from going unavailable when min/max is reversed (#135892) --- .../components/homekit/type_lights.py | 7 +- .../components/homekit/type_thermostats.py | 12 +- homeassistant/components/homekit/util.py | 11 ++ tests/components/homekit/test_type_lights.py | 150 ++++++++++++++++++ .../homekit/test_type_thermostats.py | 56 ++++++- 5 files changed, 225 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index eec35fcc82e..212b3228154 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -52,6 +52,7 @@ from .const import ( PROP_MIN_VALUE, SERV_LIGHTBULB, ) +from .util import get_min_max _LOGGER = logging.getLogger(__name__) @@ -120,12 +121,14 @@ class Light(HomeAccessory): self.char_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100) if CHAR_COLOR_TEMPERATURE in self.chars: - self.min_mireds = color_temperature_kelvin_to_mired( + min_mireds = color_temperature_kelvin_to_mired( attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN, DEFAULT_MAX_COLOR_TEMP) ) - self.max_mireds = color_temperature_kelvin_to_mired( + max_mireds = color_temperature_kelvin_to_mired( attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN, DEFAULT_MIN_COLOR_TEMP) ) + # Ensure min is less than max + self.min_mireds, self.max_mireds = get_min_max(min_mireds, max_mireds) if not self.color_temp_supported and not self.rgbww_supported: self.max_mireds = self.min_mireds self.char_color_temp = serv_light.configure_char( diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 91bab2d470a..4dda495ce77 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -14,6 +14,7 @@ from homeassistant.components.climate import ( ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, + ATTR_MAX_HUMIDITY, ATTR_MAX_TEMP, ATTR_MIN_HUMIDITY, ATTR_MIN_TEMP, @@ -21,6 +22,7 @@ from homeassistant.components.climate import ( ATTR_SWING_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + DEFAULT_MAX_HUMIDITY, DEFAULT_MAX_TEMP, DEFAULT_MIN_HUMIDITY, DEFAULT_MIN_TEMP, @@ -90,7 +92,7 @@ from .const import ( SERV_FANV2, SERV_THERMOSTAT, ) -from .util import temperature_to_homekit, temperature_to_states +from .util import get_min_max, temperature_to_homekit, temperature_to_states _LOGGER = logging.getLogger(__name__) @@ -208,7 +210,10 @@ class Thermostat(HomeAccessory): self.fan_chars: list[str] = [] attributes = state.attributes - min_humidity = attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) + min_humidity, _ = get_min_max( + attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY), + attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY), + ) features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) if features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: @@ -839,6 +844,9 @@ def _get_temperature_range_from_state( else: max_temp = default_max + # Handle reversed temperature range + min_temp, max_temp = get_min_max(min_temp, max_temp) + # Homekit only supports 10-38, overwriting # the max to appears to work, but less than 0 causes # a crash on the home app diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index d339aa6aded..443b8b8a310 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -655,3 +655,14 @@ def state_changed_event_is_same_state(event: Event[EventStateChangedData]) -> bo old_state = event_data["old_state"] new_state = event_data["new_state"] return bool(new_state and old_state and new_state.state == old_state.state) + + +def get_min_max(value1: float, value2: float) -> tuple[float, float]: + """Return the minimum and maximum of two values. + + HomeKit will go unavailable if the min and max are reversed + so we make sure the min is always the min and the max is always the max + as any mistakes made in integrations will cause the entire + bridge to go unavailable. + """ + return min(value1, value2), max(value1, value2) diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 53a661c1c83..c1870cecd9c 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -807,6 +807,156 @@ async def test_light_invalid_values( assert acc.char_saturation.value == 95 +async def test_light_out_of_range_color_temp(hass: HomeAssistant, hk_driver) -> None: + """Test light with an out of range color temp.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_COLOR_TEMP_KELVIN: 2000, + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_HS_COLOR: (-1, -1), + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 333 + assert acc.char_color_temp.properties[PROP_MAX_VALUE] == 333 + assert acc.char_color_temp.properties[PROP_MIN_VALUE] == 250 + assert acc.char_hue.value == 31 + assert acc.char_saturation.value == 95 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: -1, + }, + ) + await hass.async_block_till_done() + acc.run() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 16 + assert acc.char_saturation.value == 100 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: sys.maxsize, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: 2000, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + +async def test_reversed_color_temp_min_max(hass: HomeAssistant, hk_driver) -> None: + """Test light with a reversed color temp min max.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_COLOR_TEMP_KELVIN: 2000, + ATTR_MAX_COLOR_TEMP_KELVIN: 3000, + ATTR_MIN_COLOR_TEMP_KELVIN: 4000, + ATTR_HS_COLOR: (-1, -1), + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 333 + assert acc.char_color_temp.properties[PROP_MAX_VALUE] == 333 + assert acc.char_color_temp.properties[PROP_MIN_VALUE] == 250 + assert acc.char_hue.value == 31 + assert acc.char_saturation.value == 95 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: -1, + }, + ) + await hass.async_block_till_done() + acc.run() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 16 + assert acc.char_saturation.value == 100 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: sys.maxsize, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: 2000, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + @pytest.mark.parametrize( "supported_color_modes", [[ColorMode.HS], [ColorMode.RGB], [ColorMode.XY]] ) diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index e99db8f6234..fc4cfa78ca4 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -26,6 +26,7 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_STEP, DEFAULT_MAX_TEMP, DEFAULT_MIN_HUMIDITY, + DEFAULT_MIN_TEMP, DOMAIN as DOMAIN_CLIMATE, FAN_AUTO, FAN_HIGH, @@ -2009,8 +2010,8 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], - ATTR_MAX_TEMP: 50, - ATTR_MIN_TEMP: 100, + ATTR_MAX_TEMP: 100, + ATTR_MIN_TEMP: 50, } hass.states.async_set( entity_id, @@ -2024,14 +2025,14 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No acc.run() await hass.async_block_till_done() - assert acc.char_cooling_thresh_temp.value == 100 - assert acc.char_heating_thresh_temp.value == 100 + assert acc.char_cooling_thresh_temp.value == 50 + assert acc.char_heating_thresh_temp.value == 50 assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == 100 - assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 100 + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 50 assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == 100 - assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 100 + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 50 assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 assert acc.char_target_heat_cool.value == 3 @@ -2048,7 +2049,7 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No }, ) await hass.async_block_till_done() - assert acc.char_heating_thresh_temp.value == 100.0 + assert acc.char_heating_thresh_temp.value == 50.0 assert acc.char_cooling_thresh_temp.value == 100.0 assert acc.char_current_heat_cool.value == 1 assert acc.char_target_heat_cool.value == 3 @@ -2633,3 +2634,44 @@ async def test_thermostat_handles_unknown_state(hass: HomeAssistant, hk_driver) assert call_set_hvac_mode assert call_set_hvac_mode[1].data[ATTR_ENTITY_ID] == entity_id assert call_set_hvac_mode[1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_thermostat_reversed_min_max(hass: HomeAssistant, hk_driver) -> None: + """Test reversed min/max temperatures.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.HEAT_COOL, + HVACMode.FAN_ONLY, + HVACMode.COOL, + HVACMode.OFF, + HVACMode.AUTO, + ], + ATTR_MAX_TEMP: DEFAULT_MAX_TEMP, + ATTR_MIN_TEMP: DEFAULT_MIN_TEMP, + } + # support_auto = True + hass.states.async_set( + entity_id, + HVACMode.OFF, + base_attrs, + ) + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_heating_thresh_temp.value == 19.0 + + assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 + assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 From 11205f1c9d0d2aa16850c8718bd83c2dd377eee7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jan 2025 19:30:21 -1000 Subject: [PATCH 0721/2987] Bump onvif-zeep-async to 3.2.2 (#135898) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 02ef16b6787..e56d81d3237 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==3.1.13", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==3.2.2", "WSDiscovery==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6d873c14ece..8f811525251 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1537,7 +1537,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onvif -onvif-zeep-async==3.1.13 +onvif-zeep-async==3.2.2 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab79c651eb3..75bc27b97ee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1285,7 +1285,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onvif -onvif-zeep-async==3.1.13 +onvif-zeep-async==3.2.2 # homeassistant.components.opengarage open-garage==0.2.0 From 1bf180449209e9a5eef7d201832e5b7cc9b883af Mon Sep 17 00:00:00 2001 From: "Glenn Vandeuren (aka Iondependent)" Date: Sun, 19 Jan 2025 12:51:05 +0100 Subject: [PATCH 0722/2987] Round brightness in Niko Home Control (#135920) --- homeassistant/components/niko_home_control/light.py | 2 +- tests/components/niko_home_control/test_light.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index 69d4e71c755..80f47e56438 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -112,7 +112,7 @@ class NikoHomeControlLight(NikoHomeControlEntity, LightEntity): def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - self._action.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55) + self._action.turn_on(round(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55)) def turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" diff --git a/tests/components/niko_home_control/test_light.py b/tests/components/niko_home_control/test_light.py index a61cc5204f6..865e1303cb0 100644 --- a/tests/components/niko_home_control/test_light.py +++ b/tests/components/niko_home_control/test_light.py @@ -42,11 +42,11 @@ async def test_entities( @pytest.mark.parametrize( ("light_id", "data", "set_brightness"), [ - (0, {ATTR_ENTITY_ID: "light.light"}, 100.0), + (0, {ATTR_ENTITY_ID: "light.light"}, 100), ( 1, {ATTR_ENTITY_ID: "light.dimmable_light", ATTR_BRIGHTNESS: 50}, - 19.607843137254903, + 20, ), ], ) From 6da6de6a357839c883e6659d751244fa2fc61e9e Mon Sep 17 00:00:00 2001 From: "Glenn Vandeuren (aka Iondependent)" Date: Sat, 18 Jan 2025 17:47:20 +0100 Subject: [PATCH 0723/2987] Update NHC lib to v0.3.4 (#135923) Update NHC to v0.3.4 --- homeassistant/components/niko_home_control/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index d252a11b38e..a75b0d72dca 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/niko_home_control", "iot_class": "local_push", "loggers": ["nikohomecontrol"], - "requirements": ["nhc==0.3.2"] + "requirements": ["nhc==0.3.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8f811525251..e88bef0fe8a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1467,7 +1467,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.3.2 +nhc==0.3.4 # homeassistant.components.nibe_heatpump nibe==2.14.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 75bc27b97ee..cb4a016ecf8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1230,7 +1230,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.3.2 +nhc==0.3.4 # homeassistant.components.nibe_heatpump nibe==2.14.0 From ca891bfc3e7e396e5cf8e469f1068100fdde8a3b Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 18 Jan 2025 22:33:41 +0100 Subject: [PATCH 0724/2987] Update knx-frontend to 2025.1.18.164225 (#135941) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 8d18f11c798..73a61be68ee 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "requirements": [ "xknx==3.4.0", "xknxproject==3.8.1", - "knx-frontend==2024.12.26.233449" + "knx-frontend==2025.1.18.164225" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index e88bef0fe8a..69bd83ed611 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1260,7 +1260,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2024.12.26.233449 +knx-frontend==2025.1.18.164225 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb4a016ecf8..763b1f4c090 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1062,7 +1062,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2024.12.26.233449 +knx-frontend==2025.1.18.164225 # homeassistant.components.konnected konnected==1.2.0 From f8eb42a094dea20e5783b6f08bd94d2cff867f2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Jan 2025 13:53:59 -1000 Subject: [PATCH 0725/2987] Bump aiooui to 0.1.8 (#135945) --- homeassistant/components/nmap_tracker/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json index 5b2dab50812..f2d52f88962 100644 --- a/homeassistant/components/nmap_tracker/manifest.json +++ b/homeassistant/components/nmap_tracker/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/nmap_tracker", "iot_class": "local_polling", "loggers": ["nmap"], - "requirements": ["netmap==0.7.0.2", "getmac==0.9.4", "aiooui==0.1.7"] + "requirements": ["netmap==0.7.0.2", "getmac==0.9.4", "aiooui==0.1.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 69bd83ed611..ea414f35f8b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -318,7 +318,7 @@ aiooncue==0.3.7 aioopenexchangerates==0.6.8 # homeassistant.components.nmap_tracker -aiooui==0.1.7 +aiooui==0.1.8 # homeassistant.components.pegel_online aiopegelonline==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 763b1f4c090..883e9125d45 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -300,7 +300,7 @@ aiooncue==0.3.7 aioopenexchangerates==0.6.8 # homeassistant.components.nmap_tracker -aiooui==0.1.7 +aiooui==0.1.8 # homeassistant.components.pegel_online aiopegelonline==0.1.1 From 670371ff38ba81bf50ca14eceb238ed6a16077ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Jan 2025 15:01:55 -1000 Subject: [PATCH 0726/2987] Bump aiooui to 0.1.9 (#135956) --- homeassistant/components/nmap_tracker/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json index f2d52f88962..ebee6b116e6 100644 --- a/homeassistant/components/nmap_tracker/manifest.json +++ b/homeassistant/components/nmap_tracker/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/nmap_tracker", "iot_class": "local_polling", "loggers": ["nmap"], - "requirements": ["netmap==0.7.0.2", "getmac==0.9.4", "aiooui==0.1.8"] + "requirements": ["netmap==0.7.0.2", "getmac==0.9.4", "aiooui==0.1.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index ea414f35f8b..5328a9db27d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -318,7 +318,7 @@ aiooncue==0.3.7 aioopenexchangerates==0.6.8 # homeassistant.components.nmap_tracker -aiooui==0.1.8 +aiooui==0.1.9 # homeassistant.components.pegel_online aiopegelonline==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 883e9125d45..567a4753e4b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -300,7 +300,7 @@ aiooncue==0.3.7 aioopenexchangerates==0.6.8 # homeassistant.components.nmap_tracker -aiooui==0.1.8 +aiooui==0.1.9 # homeassistant.components.pegel_online aiopegelonline==0.1.1 From 8101fee9bba264ce0b924d69af098b1d189c30b3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 19 Jan 2025 15:15:21 +0100 Subject: [PATCH 0727/2987] Fix switchbot cloud library logger (#135987) --- homeassistant/components/switchbot_cloud/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index 6fc6d8030d2..99f909e91ab 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "integration_type": "hub", "iot_class": "cloud_polling", - "loggers": ["switchbot-api"], + "loggers": ["switchbot_api"], "requirements": ["switchbot-api==2.3.1"] } From b1445e59264f4a1d05418267aad8cfa58b52074a Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 19 Jan 2025 20:09:05 +0100 Subject: [PATCH 0728/2987] Correct type for off delay in rfxtrx (#135994) --- homeassistant/components/rfxtrx/config_flow.py | 9 +++------ homeassistant/components/rfxtrx/strings.json | 1 - tests/components/rfxtrx/test_config_flow.py | 4 +--- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 866d9ecb1bb..6ce7d88f9f0 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -209,10 +209,7 @@ class RfxtrxOptionsFlow(OptionsFlow): except ValueError: errors[CONF_COMMAND_OFF] = "invalid_input_2262_off" - try: - off_delay = none_or_int(user_input.get(CONF_OFF_DELAY), 10) - except ValueError: - errors[CONF_OFF_DELAY] = "invalid_input_off_delay" + off_delay = user_input.get(CONF_OFF_DELAY) if not errors: devices = {} @@ -252,11 +249,11 @@ class RfxtrxOptionsFlow(OptionsFlow): vol.Optional( CONF_OFF_DELAY, description={"suggested_value": device_data[CONF_OFF_DELAY]}, - ): str, + ): int, } else: off_delay_schema = { - vol.Optional(CONF_OFF_DELAY): str, + vol.Optional(CONF_OFF_DELAY): int, } data_schema.update(off_delay_schema) diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index aeb4b2395d3..735ed6c4542 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -68,7 +68,6 @@ "invalid_event_code": "Invalid event code", "invalid_input_2262_on": "Invalid input for command on", "invalid_input_2262_off": "Invalid input for command off", - "invalid_input_off_delay": "Invalid input for off delay", "unknown": "[%key:common::config_flow::error::unknown%]" } }, diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 1e23bdaf982..5957319306b 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -726,7 +726,6 @@ async def test_options_add_and_configure_device( result["flow_id"], user_input={ "data_bits": 4, - "off_delay": "abcdef", "command_on": "xyz", "command_off": "xyz", }, @@ -735,7 +734,6 @@ async def test_options_add_and_configure_device( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "set_device_options" assert result["errors"] - assert result["errors"]["off_delay"] == "invalid_input_off_delay" assert result["errors"]["command_on"] == "invalid_input_2262_on" assert result["errors"]["command_off"] == "invalid_input_2262_off" @@ -745,7 +743,7 @@ async def test_options_add_and_configure_device( "data_bits": 4, "command_on": "0xE", "command_off": "0x7", - "off_delay": "9", + "off_delay": 9, }, ) From 5d1e2d17dafd56ace0307d097aaf007f33092aa5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jan 2025 09:16:40 -1000 Subject: [PATCH 0729/2987] Handle invalid datetime in onvif (#136014) --- homeassistant/components/onvif/device.py | 26 +++++++++++++++--------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index f51b1b74686..f15f6637ab9 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -263,16 +263,22 @@ class ONVIFDevice: LOGGER.warning("%s: Could not retrieve date/time on this camera", self.name) return - cam_date = dt.datetime( - cdate.Date.Year, - cdate.Date.Month, - cdate.Date.Day, - cdate.Time.Hour, - cdate.Time.Minute, - cdate.Time.Second, - 0, - tzone, - ) + try: + cam_date = dt.datetime( + cdate.Date.Year, + cdate.Date.Month, + cdate.Date.Day, + cdate.Time.Hour, + cdate.Time.Minute, + cdate.Time.Second, + 0, + tzone, + ) + except ValueError as err: + LOGGER.warning( + "%s: Could not parse date/time from camera: %s", self.name, err + ) + return cam_date_utc = cam_date.astimezone(dt_util.UTC) From 3922b8eb80993a3109afccbd2cb44532372ffbc8 Mon Sep 17 00:00:00 2001 From: Scott K Logan Date: Sun, 19 Jan 2025 14:05:34 -0600 Subject: [PATCH 0730/2987] Bump aioraven to 0.7.1 (#136017) --- homeassistant/components/rainforest_raven/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rainforest_raven/manifest.json b/homeassistant/components/rainforest_raven/manifest.json index 49bd11e8880..3a902377c2e 100644 --- a/homeassistant/components/rainforest_raven/manifest.json +++ b/homeassistant/components/rainforest_raven/manifest.json @@ -6,7 +6,7 @@ "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/rainforest_raven", "iot_class": "local_polling", - "requirements": ["aioraven==0.7.0"], + "requirements": ["aioraven==0.7.1"], "usb": [ { "vid": "0403", diff --git a/requirements_all.txt b/requirements_all.txt index 5328a9db27d..f1956cde722 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -344,7 +344,7 @@ aiopyarr==23.4.0 aioqsw==0.4.1 # homeassistant.components.rainforest_raven -aioraven==0.7.0 +aioraven==0.7.1 # homeassistant.components.recollect_waste aiorecollect==2023.09.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 567a4753e4b..879e47ee428 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -326,7 +326,7 @@ aiopyarr==23.4.0 aioqsw==0.4.1 # homeassistant.components.rainforest_raven -aioraven==0.7.0 +aioraven==0.7.1 # homeassistant.components.recollect_waste aiorecollect==2023.09.0 From b9b9322c913bfb036d80273f81ed1e075268f0d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jan 2025 11:12:08 -1000 Subject: [PATCH 0731/2987] Bump onvif-zeep-async to 3.2.3 (#136022) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index e56d81d3237..6aa005ba539 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==3.2.2", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==3.2.3", "WSDiscovery==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f1956cde722..70c0b5bafd8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1537,7 +1537,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onvif -onvif-zeep-async==3.2.2 +onvif-zeep-async==3.2.3 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 879e47ee428..02f1ccd7cb2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1285,7 +1285,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onvif -onvif-zeep-async==3.2.2 +onvif-zeep-async==3.2.3 # homeassistant.components.opengarage open-garage==0.2.0 From 4ed027b1cc30da4471adceb9758fcffd6ab26c09 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 20 Jan 2025 12:50:15 +0100 Subject: [PATCH 0732/2987] Bump yt-dlp to 2025.01.15 (#136072) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 144904fe58c..becca8e6da8 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2024.12.23"], + "requirements": ["yt-dlp[default]==2025.01.15"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 70c0b5bafd8..4c5536c72ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3082,7 +3082,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.12.23 +yt-dlp[default]==2025.01.15 # homeassistant.components.zabbix zabbix-utils==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 02f1ccd7cb2..d4272fe11bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2477,7 +2477,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.12.23 +yt-dlp[default]==2025.01.15 # homeassistant.components.zamg zamg==0.3.6 From 92b786e8cf955631ee6873d0696d129c4ead45b1 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 20 Jan 2025 12:55:09 +0100 Subject: [PATCH 0733/2987] Bump deebot-client to 11.0.0 (#136073) --- 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 67d18c4784c..157d5b4a5ea 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==10.1.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==11.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4c5536c72ed..28269469a78 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -738,7 +738,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==10.1.0 +deebot-client==11.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d4272fe11bc..265390966db 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -628,7 +628,7 @@ dbus-fast==2.24.3 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==10.1.0 +deebot-client==11.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 3c534a73f51df9f9c33444ffa3400f4937a9c87e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Jan 2025 15:21:34 +0100 Subject: [PATCH 0734/2987] Always include SSL folder in backups (#136080) --- homeassistant/components/hassio/backup.py | 11 ++++++----- tests/components/hassio/test_backup.py | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 537588e856a..23a0b5bd5d8 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -227,11 +227,12 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): include_addons_set = supervisor_backups.AddonSet.ALL elif include_addons: include_addons_set = set(include_addons) - include_folders_set = ( - {supervisor_backups.Folder(folder) for folder in include_folders} - if include_folders - else None - ) + include_folders_set = { + supervisor_backups.Folder(folder) for folder in include_folders or [] + } + # Always include SSL if Home Assistant is included + if include_homeassistant: + include_folders_set.add(supervisor_backups.Folder.SSL) hassio_agents: list[SupervisorBackupAgent] = [ cast(SupervisorBackupAgent, manager.backup_agents[agent_id]) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 10a804d983f..40ab253b7e6 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -673,7 +673,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( "instance_id": ANY, "with_automatic_settings": False, }, - folders=None, + folders={"ssl"}, homeassistant_exclude_database=False, homeassistant=True, location=[None], @@ -704,7 +704,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( ), ( {"include_folders": ["media", "share"]}, - replace(DEFAULT_BACKUP_OPTIONS, folders={"media", "share"}), + replace(DEFAULT_BACKUP_OPTIONS, folders={"media", "share", "ssl"}), ), ( { From d9e6549ad5dd740d680a6c02a138363dd96f3160 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 20 Jan 2025 16:03:47 +0000 Subject: [PATCH 0735/2987] Bump version to 2025.1.3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9f25ff3f80a..f5046b510f8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "2" +PATCH_VERSION: Final = "3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 4d88c5641fa..e24dbcd58e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.1.2" +version = "2025.1.3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 05c7cb5f322d9e0ef12278e22de13fcaf6ecb8bc Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 20 Jan 2025 17:21:17 +0100 Subject: [PATCH 0736/2987] Bump uv to 0.5.21 (#136086) --- 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 917b9ca19c4..171d08731a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv==0.5.18 +RUN pip3 install uv==0.5.21 WORKDIR /usr/src diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 133c5bb76ed..a804cb90cf3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -67,7 +67,7 @@ standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 ulid-transform==1.2.0 urllib3>=1.26.5,<2 -uv==0.5.18 +uv==0.5.21 voluptuous-openapi==0.0.6 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 73795f4ccd5..88a5410941f 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.18", + "uv==0.5.21", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.6", diff --git a/requirements.txt b/requirements.txt index 6b934c101e7..91a5d131b3b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,7 +45,7 @@ standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 ulid-transform==1.2.0 urllib3>=1.26.5,<2 -uv==0.5.18 +uv==0.5.21 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 a64859274d0..21b98d30f1e 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.18,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.5.21,source=/uv,target=/bin/uv \ # Uv creates a lock file in /tmp --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG From af02dbf0cb7c05182cda8d9b6bb42214a4931b6a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 20 Jan 2025 17:52:18 +0100 Subject: [PATCH 0737/2987] Update pylint to 3.3.3 and astroid to 3.3.8 (#136090) --- homeassistant/components/assist_pipeline/websocket_api.py | 1 - homeassistant/helpers/update_coordinator.py | 2 +- requirements_test.txt | 4 ++-- tests/components/bluetooth/conftest.py | 1 - 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index e8da8e56fd6..69f917fcf83 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -8,7 +8,6 @@ import logging import math from typing import Any, Final -# Suppressing disable=deprecated-module is needed for Python 3.11 import audioop # pylint: disable=deprecated-module import voluptuous as vol diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 62dcb2622e7..82663d25e1a 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -453,7 +453,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self.logger.debug( "Finished fetching %s data in %.3f seconds (success: %s)", self.name, - monotonic() - start, # pylint: disable=possibly-used-before-assignment + monotonic() - start, self.last_update_success, ) if not auth_failed and self._listeners and not self.hass.is_stopping: diff --git a/requirements_test.txt b/requirements_test.txt index b6d061577e5..029073f19a2 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,7 +7,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==3.3.6 +astroid==3.3.8 coverage==7.6.8 freezegun==1.5.1 license-expression==30.4.0 @@ -15,7 +15,7 @@ mock-open==1.4.0 mypy-dev==1.15.0a2 pre-commit==4.0.0 pydantic==2.10.4 -pylint==3.3.2 +pylint==3.3.3 pylint-per-file-ignores==1.3.2 pipdeptree==2.23.4 pytest-asyncio==0.24.0 diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 1be39bfaa94..6fa0b375e81 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -8,7 +8,6 @@ from dbus_fast.aio import message_bus import habluetooth.util as habluetooth_utils import pytest -# pylint: disable-next=no-name-in-module from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant From 63f14b94875e61dcdb5594ff56aba715c9c0603a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 20 Jan 2025 18:12:13 +0100 Subject: [PATCH 0738/2987] Fix Overseerr event types translations (#136096) --- .../components/overseerr/strings.json | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index c68963247ee..968b8c5b533 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -24,13 +24,17 @@ "event": { "last_media_event": { "name": "Last media event", - "state": { - "pending": "Pending", - "approved": "Approved", - "available": "Available", - "failed": "Failed", - "declined": "Declined", - "auto_approved": "Auto-approved" + "state_attributes": { + "event_type": { + "state": { + "pending": "Pending", + "approved": "Approved", + "available": "Available", + "failed": "Failed", + "declined": "Declined", + "auto_approved": "Auto-approved" + } + } } } }, From 3f8f206c53defb5dec2013bcaf7f7f7497e84f7c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 20 Jan 2025 18:13:33 +0100 Subject: [PATCH 0739/2987] Add diagnostics to Overseerr (#136094) --- .../components/overseerr/diagnostics.py | 26 ++++++++++ .../components/overseerr/quality_scale.yaml | 2 +- .../overseerr/snapshots/test_diagnostics.ambr | 31 ++++++++++++ .../components/overseerr/test_diagnostics.py | 47 +++++++++++++++++++ 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/overseerr/diagnostics.py create mode 100644 tests/components/overseerr/snapshots/test_diagnostics.ambr create mode 100644 tests/components/overseerr/test_diagnostics.py diff --git a/homeassistant/components/overseerr/diagnostics.py b/homeassistant/components/overseerr/diagnostics.py new file mode 100644 index 00000000000..d45e1441e23 --- /dev/null +++ b/homeassistant/components/overseerr/diagnostics.py @@ -0,0 +1,26 @@ +"""Diagnostics support for Overseerr.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import CONF_CLOUDHOOK_URL +from .coordinator import OverseerrConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: OverseerrConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + has_cloudhooks = CONF_CLOUDHOOK_URL in entry.data + + data = entry.runtime_data + + return { + "has_cloudhooks": has_cloudhooks, + "coordinator_data": asdict(data.data), + } diff --git a/homeassistant/components/overseerr/quality_scale.yaml b/homeassistant/components/overseerr/quality_scale.yaml index 144f5c1977c..d4295030fdc 100644 --- a/homeassistant/components/overseerr/quality_scale.yaml +++ b/homeassistant/components/overseerr/quality_scale.yaml @@ -41,7 +41,7 @@ rules: test-coverage: todo # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: | diff --git a/tests/components/overseerr/snapshots/test_diagnostics.ambr b/tests/components/overseerr/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..164257bb9f1 --- /dev/null +++ b/tests/components/overseerr/snapshots/test_diagnostics.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_diagnostics_polling_instance + dict({ + 'coordinator_data': dict({ + 'approved': 11, + 'available': 8, + 'declined': 0, + 'movie': 9, + 'pending': 0, + 'processing': 3, + 'total': 11, + 'tv': 2, + }), + 'has_cloudhooks': False, + }) +# --- +# name: test_diagnostics_webhook_instance + dict({ + 'coordinator_data': dict({ + 'approved': 11, + 'available': 8, + 'declined': 0, + 'movie': 9, + 'pending': 0, + 'processing': 3, + 'total': 11, + 'tv': 2, + }), + 'has_cloudhooks': True, + }) +# --- diff --git a/tests/components/overseerr/test_diagnostics.py b/tests/components/overseerr/test_diagnostics.py new file mode 100644 index 00000000000..28b97e9514f --- /dev/null +++ b/tests/components/overseerr/test_diagnostics.py @@ -0,0 +1,47 @@ +"""Tests for the diagnostics data provided by the Overseerr integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics_polling_instance( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, mock_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) + + +async def test_diagnostics_webhook_instance( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_overseerr_client_cloudhook: AsyncMock, + mock_cloudhook_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, mock_cloudhook_config_entry) + + assert ( + await get_diagnostics_for_config_entry( + hass, hass_client, mock_cloudhook_config_entry + ) + == snapshot + ) From 83b0d5a0b9146efc0c8b8b0835e75e4248642fd8 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Mon, 20 Jan 2025 18:14:50 +0100 Subject: [PATCH 0740/2987] Enable Ruff B024 (#136088) --- homeassistant/components/media_source/models.py | 3 +-- homeassistant/helpers/collection.py | 4 ++-- pyproject.toml | 1 + 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index 482ed0e855f..53bd8213262 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -2,7 +2,6 @@ from __future__ import annotations -from abc import ABC from dataclasses import dataclass from typing import Any, cast @@ -102,7 +101,7 @@ class MediaSourceItem: return cls(hass, domain, identifier, target_media_player) -class MediaSource(ABC): +class MediaSource: """Represents a source of media files.""" name: str | None = None diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 08b58aedde4..aef673cb500 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -2,7 +2,7 @@ from __future__ import annotations -from abc import ABC, abstractmethod +from abc import abstractmethod import asyncio from collections.abc import Awaitable, Callable, Coroutine, Iterable from dataclasses import dataclass @@ -126,7 +126,7 @@ class CollectionEntity(Entity): """Handle updated configuration.""" -class ObservableCollection[_ItemT](ABC): +class ObservableCollection[_ItemT]: """Base collection type that can be observed.""" def __init__(self, id_manager: IDManager | None) -> None: diff --git a/pyproject.toml b/pyproject.toml index 88a5410941f..d5674cf7571 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -719,6 +719,7 @@ select = [ "B017", # pytest.raises(BaseException) should be considered evil "B018", # Found useless attribute access. Either assign it to a variable or remove it. "B023", # Function definition does not bind loop variable {name} + "B024", # `{name}` is an abstract base class, but it has no abstract methods or properties "B026", # Star-arg unpacking after a keyword argument is strongly discouraged "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? "B904", # Use raise from to specify exception cause From cf3367171896a8272aefa2bde056467709fc3e7f Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 20 Jan 2025 18:41:49 +0100 Subject: [PATCH 0741/2987] Bump velbusaio to 2025.1.1 (#136089) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 7a2354a7283..960f127d16e 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2025.1.0"], + "requirements": ["velbus-aio==2025.1.1"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index bfa166541b1..516cfc40864 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2975,7 +2975,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.1.0 +velbus-aio==2025.1.1 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3977b191d72..d4ac1bfac47 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2391,7 +2391,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.1.0 +velbus-aio==2025.1.1 # homeassistant.components.venstar venstarcolortouch==0.19 From a84335ae6d68f7375b1e6db65f9b7cf9fa444bcc Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 20 Jan 2025 18:13:14 +0000 Subject: [PATCH 0742/2987] Enable dynamic child devices for tplink module entities (#135822) Add dynamic child device handling to tplink integration for module based entities. For child devices that could be added/removed to hubs. This address the module based platforms. #135229 addressed feature based platforms. --- homeassistant/components/tplink/camera.py | 82 +++--- homeassistant/components/tplink/climate.py | 57 ++++- homeassistant/components/tplink/entity.py | 236 +++++++++++++++--- homeassistant/components/tplink/fan.py | 76 ++++-- homeassistant/components/tplink/light.py | 145 +++++++---- homeassistant/components/tplink/siren.py | 57 ++++- .../tplink/snapshots/test_climate.ambr | 2 +- tests/components/tplink/test_camera.py | 8 +- tests/components/tplink/test_init.py | 181 +++++++++++++- 9 files changed, 667 insertions(+), 177 deletions(-) diff --git a/homeassistant/components/tplink/camera.py b/homeassistant/components/tplink/camera.py index e1db7254428..61a08887f5f 100644 --- a/homeassistant/components/tplink/camera.py +++ b/homeassistant/components/tplink/camera.py @@ -7,8 +7,7 @@ import time from aiohttp import web from haffmpeg.camera import CameraMjpeg -from kasa import Credentials, Device, Module, StreamResolution -from kasa.smartcam.modules import Camera as CameraModule +from kasa import Device, Module, StreamResolution from homeassistant.components import ffmpeg, stream from homeassistant.components.camera import ( @@ -24,10 +23,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry, legacy_device_id from .const import CONF_CAMERA_CREDENTIALS from .coordinator import TPLinkDataUpdateCoordinator -from .entity import CoordinatedTPLinkEntity, TPLinkModuleEntityDescription +from .entity import CoordinatedTPLinkModuleEntity, TPLinkModuleEntityDescription _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class TPLinkCameraEntityDescription( @@ -36,15 +39,18 @@ class TPLinkCameraEntityDescription( """Base class for camera entity description.""" -# Coordinator is used to centralize the data updates -# For actions the integration handles locking of concurrent device request -PARALLEL_UPDATES = 0 - CAMERA_DESCRIPTIONS: tuple[TPLinkCameraEntityDescription, ...] = ( TPLinkCameraEntityDescription( key="live_view", translation_key="live_view", available_fn=lambda dev: dev.is_on, + exists_fn=lambda dev, entry: ( + (rtd := entry.runtime_data) is not None + and rtd.live_view is True + and (cam_creds := rtd.camera_credentials) is not None + and (cm := dev.modules.get(Module.Camera)) is not None + and cm.stream_rtsp_url(cam_creds) is not None + ), ), ) @@ -58,26 +64,28 @@ async def async_setup_entry( data = config_entry.runtime_data parent_coordinator = data.parent_coordinator device = parent_coordinator.device - camera_credentials = data.camera_credentials - live_view = data.live_view - ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) - async_add_entities( - TPLinkCameraEntity( - device, - parent_coordinator, - description, - camera_module=camera_module, - parent=None, - ffmpeg_manager=ffmpeg_manager, - camera_credentials=camera_credentials, + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkCameraEntity, + descriptions=CAMERA_DESCRIPTIONS, + known_child_device_ids=known_child_device_ids, + first_check=first_check, ) - for description in CAMERA_DESCRIPTIONS - if (camera_module := device.modules.get(Module.Camera)) and live_view - ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) -class TPLinkCameraEntity(CoordinatedTPLinkEntity, Camera): +class TPLinkCameraEntity(CoordinatedTPLinkModuleEntity, Camera): """Representation of a TPLink camera.""" IMAGE_INTERVAL = 5 * 60 @@ -86,30 +94,30 @@ class TPLinkCameraEntity(CoordinatedTPLinkEntity, Camera): entity_description: TPLinkCameraEntityDescription + _ffmpeg_manager: ffmpeg.FFmpegManager + def __init__( self, device: Device, coordinator: TPLinkDataUpdateCoordinator, description: TPLinkCameraEntityDescription, *, - camera_module: CameraModule, parent: Device | None = None, - ffmpeg_manager: ffmpeg.FFmpegManager, - camera_credentials: Credentials | None, ) -> None: """Initialize a TPlink camera.""" - self.entity_description = description - self._camera_module = camera_module - self._video_url = camera_module.stream_rtsp_url( - camera_credentials, stream_resolution=StreamResolution.SD + super().__init__(device, coordinator, description=description, parent=parent) + Camera.__init__(self) + + self._camera_module = device.modules[Module.Camera] + self._camera_credentials = ( + coordinator.config_entry.runtime_data.camera_credentials + ) + self._video_url = self._camera_module.stream_rtsp_url( + self._camera_credentials, stream_resolution=StreamResolution.SD ) self._image: bytes | None = None - super().__init__(device, coordinator, parent=parent) - Camera.__init__(self) - self._ffmpeg_manager = ffmpeg_manager self._image_lock = asyncio.Lock() self._last_update: float = 0 - self._camera_credentials = camera_credentials self._can_stream = True self._http_mpeg_stream_running = False @@ -117,6 +125,12 @@ class TPLinkCameraEntity(CoordinatedTPLinkEntity, Camera): """Return unique ID for the entity.""" return f"{legacy_device_id(self._device)}-{self.entity_description.key}" + async def async_added_to_hass(self) -> None: + """Call update attributes after the device is added to the platform.""" + await super().async_added_to_hass() + + self._ffmpeg_manager = ffmpeg.get_ffmpeg_manager(self.hass) + @callback def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py index e8b7336f391..a7dd865e7bb 100644 --- a/homeassistant/components/tplink/climate.py +++ b/homeassistant/components/tplink/climate.py @@ -2,15 +2,17 @@ from __future__ import annotations +from dataclasses import dataclass import logging from typing import Any, cast -from kasa import Device, DeviceType +from kasa import Device from kasa.smart.modules.temperaturecontrol import ThermostatState from homeassistant.components.climate import ( ATTR_TEMPERATURE, ClimateEntity, + ClimateEntityDescription, ClimateEntityFeature, HVACAction, HVACMode, @@ -23,7 +25,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry from .const import DOMAIN, UNIT_MAPPING from .coordinator import TPLinkDataUpdateCoordinator -from .entity import CoordinatedTPLinkEntity, async_refresh_after +from .entity import ( + CoordinatedTPLinkModuleEntity, + TPLinkModuleEntityDescription, + async_refresh_after, +) # Coordinator is used to centralize the data updates # For actions the integration handles locking of concurrent device request @@ -40,6 +46,21 @@ STATE_TO_ACTION = { _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class TPLinkClimateEntityDescription( + ClimateEntityDescription, TPLinkModuleEntityDescription +): + """Base class for climate entity description.""" + + +CLIMATE_DESCRIPTIONS: tuple[TPLinkClimateEntityDescription, ...] = ( + TPLinkClimateEntityDescription( + key="climate", + exists_fn=lambda dev, _: dev.device_type is Device.Type.Thermostat, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, @@ -50,15 +71,27 @@ async def async_setup_entry( parent_coordinator = data.parent_coordinator device = parent_coordinator.device - # As there are no standalone thermostats, we just iterate over the children. - async_add_entities( - TPLinkClimateEntity(child, parent_coordinator, parent=device) - for child in device.children - if child.device_type is DeviceType.Thermostat - ) + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkClimateEntity, + descriptions=CLIMATE_DESCRIPTIONS, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) -class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity): +class TPLinkClimateEntity(CoordinatedTPLinkModuleEntity, ClimateEntity): """Representation of a TPLink thermostat.""" _attr_name = None @@ -70,16 +103,20 @@ class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_precision = PRECISION_TENTHS + entity_description: TPLinkClimateEntityDescription + # This disables the warning for async_turn_{on,off}, can be removed later. def __init__( self, device: Device, coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkClimateEntityDescription, *, parent: Device, ) -> None: """Initialize the climate entity.""" + super().__init__(device, coordinator, description, parent=parent) self._state_feature = device.features["state"] self._mode_feature = device.features["thermostat_mode"] self._temp_feature = device.features["temperature"] @@ -89,8 +126,6 @@ class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity): self._attr_max_temp = self._target_feature.maximum_value self._attr_temperature_unit = UNIT_MAPPING[cast(str, self._temp_feature.unit)] - super().__init__(device, coordinator, parent=parent) - @async_refresh_after async def async_set_temperature(self, **kwargs: Any) -> None: """Set target temperature.""" diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 178c8bfdd3d..e7c3600acc2 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Awaitable, Callable, Coroutine, Mapping +from collections.abc import Awaitable, Callable, Coroutine, Iterable, Mapping from dataclasses import dataclass, replace import logging from typing import Any, Concatenate @@ -35,7 +35,7 @@ from .const import ( DOMAIN, PRIMARY_STATE_ID, ) -from .coordinator import TPLinkDataUpdateCoordinator +from .coordinator import TPLinkConfigEntry, TPLinkDataUpdateCoordinator from .deprecate import DeprecatedInfo, async_check_create_deprecated _LOGGER = logging.getLogger(__name__) @@ -85,7 +85,7 @@ LEGACY_KEY_MAPPING = { @dataclass(frozen=True, kw_only=True) -class TPLinkFeatureEntityDescription(EntityDescription): +class TPLinkEntityDescription(EntityDescription): """Base class for a TPLink feature based entity description.""" deprecated_info: DeprecatedInfo | None = None @@ -93,11 +93,15 @@ class TPLinkFeatureEntityDescription(EntityDescription): @dataclass(frozen=True, kw_only=True) -class TPLinkModuleEntityDescription(EntityDescription): +class TPLinkFeatureEntityDescription(TPLinkEntityDescription): + """Base class for a TPLink feature based entity description.""" + + +@dataclass(frozen=True, kw_only=True) +class TPLinkModuleEntityDescription(TPLinkEntityDescription): """Base class for a TPLink module based entity description.""" - deprecated_info: DeprecatedInfo | None = None - available_fn: Callable[[Device], bool] = lambda _: True + exists_fn: Callable[[Device, TPLinkConfigEntry], bool] def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( @@ -151,13 +155,16 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB self, device: Device, coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkEntityDescription, *, feature: Feature | None = None, parent: Device | None = None, ) -> None: """Initialize the entity.""" super().__init__(coordinator) + self.entity_description = description self._device: Device = device + self._parent = parent self._feature = feature registry_device = device @@ -209,6 +216,10 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB hw_version=registry_device.hw_info["hw_ver"], ) + # child device entities will link via_device unless they were created + # above on the parent. Otherwise the mac connections is set which or + # for wall switches like the ks240 will mean the child and parent devices + # are treated as one device. if ( parent is not None and parent != registry_device @@ -222,12 +233,16 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB self._attr_unique_id = self._get_unique_id() - self._async_call_update_attrs() - def _get_unique_id(self) -> str: """Return unique ID for the entity.""" return legacy_device_id(self._device) + async def async_added_to_hass(self) -> None: + """Call update attributes after the device is added to the platform.""" + await super().async_added_to_hass() + + self._async_call_update_attrs() + @abstractmethod @callback def _async_update_attrs(self) -> bool: @@ -276,14 +291,19 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): self, device: Device, coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkFeatureEntityDescription, *, feature: Feature, - description: TPLinkFeatureEntityDescription, parent: Device | None = None, ) -> None: """Initialize the entity.""" - self.entity_description = description - super().__init__(device, coordinator, parent=parent, feature=feature) + super().__init__( + device, coordinator, description, parent=parent, feature=feature + ) + + # Update the feature attributes so the registered entity contains + # values like unit_of_measurement and suggested_display_precision + self._async_call_update_attrs() def _get_unique_id(self) -> str: """Return unique ID for the entity.""" @@ -456,29 +476,9 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): ) ) - # Remove any device ids removed via the coordinator so they can be re-added - for removed_child_id in coordinator.removed_child_device_ids: - _LOGGER.debug( - "Removing %s from known %s child ids for device %s" - "as it has been removed by the coordinator", - removed_child_id, - entity_class.__name__, - device.host, - ) - known_child_device_ids.discard(removed_child_id) - - current_child_devices = {child.device_id: child for child in device.children} - current_child_device_ids = set(current_child_devices.keys()) - new_child_device_ids = current_child_device_ids - known_child_device_ids - children = [] - - if new_child_device_ids: - children = [ - child - for child_id, child in current_child_devices.items() - if child_id in new_child_device_ids - ] - known_child_device_ids.update(new_child_device_ids) + children = _get_new_children( + device, coordinator, known_child_device_ids, entity_class.__name__ + ) if children: _LOGGER.debug( @@ -487,6 +487,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): len(children), device.host, ) + for child in children: child_coordinator = coordinator.get_child_coordinator(child) @@ -509,3 +510,170 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): entities.extend(child_entities) return entities + + +class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC): + """Common base class for all coordinated tplink module based entities.""" + + entity_description: TPLinkModuleEntityDescription + + def __init__( + self, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkModuleEntityDescription, + *, + parent: Device | None = None, + ) -> None: + """Initialize the entity.""" + super().__init__(device, coordinator, description, parent=parent) + + # Module based entities will usually be 1 per device so they will use + # the device name. If there are multiple module entities based entities + # the description should have a translation key. + # HA logic is to name entities based on the following logic: + # _attr_name > translation.name > description.name + if not description.translation_key: + if parent is None or parent.device_type is Device.Type.Hub: + self._attr_name = None + else: + self._attr_name = get_device_name(device) + + @classmethod + def _entities_for_device[ + _E: CoordinatedTPLinkModuleEntity, + _D: TPLinkModuleEntityDescription, + ]( + cls, + hass: HomeAssistant, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + *, + entity_class: type[_E], + descriptions: Iterable[_D], + parent: Device | None = None, + ) -> list[_E]: + """Return a list of entities to add.""" + entities: list[_E] = [ + entity_class( + device, + coordinator, + description=description, + parent=parent, + ) + for description in descriptions + if description.exists_fn(device, coordinator.config_entry) + ] + return entities + + @classmethod + def entities_for_device_and_its_children[ + _E: CoordinatedTPLinkModuleEntity, + _D: TPLinkModuleEntityDescription, + ]( + cls, + hass: HomeAssistant, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + *, + entity_class: type[_E], + descriptions: Iterable[_D], + known_child_device_ids: set[str], + first_check: bool, + ) -> list[_E]: + """Create entities for device and its children. + + This is a helper that calls *_entities_for_device* for the device and its children. + """ + entities: list[_E] = [] + + # Add parent entities before children so via_device id works. + # Only add the parent entities the first time + if first_check: + entities.extend( + cls._entities_for_device( + hass, + device, + coordinator=coordinator, + entity_class=entity_class, + descriptions=descriptions, + ) + ) + has_parent_entities = bool(entities) + + children = _get_new_children( + device, coordinator, known_child_device_ids, entity_class.__name__ + ) + + if children: + _LOGGER.debug( + "Getting %s entities for %s child devices on device %s", + entity_class.__name__, + len(children), + device.host, + ) + for child in children: + child_coordinator = coordinator.get_child_coordinator(child) + + child_entities: list[_E] = cls._entities_for_device( + hass, + child, + coordinator=child_coordinator, + entity_class=entity_class, + descriptions=descriptions, + parent=device, + ) + _LOGGER.debug( + "Device %s, found %s child %s entities for child id %s", + device.host, + len(entities), + entity_class.__name__, + child.device_id, + ) + entities.extend(child_entities) + + if first_check and entities and not has_parent_entities: + # Get or create the parent device for via_device. + # This is a timing factor in case this platform is loaded before + # other platforms that will have entities on the parent. Eventually + # those other platforms will update the parent with full DeviceInfo + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=coordinator.config_entry.entry_id, + identifiers={(DOMAIN, device.device_id)}, + ) + return entities + + +def _get_new_children( + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + known_child_device_ids: set[str], + entity_class_name: str, +) -> list[Device]: + """Get a list of children to check for entity creation.""" + # Remove any device ids removed via the coordinator so they can be re-added + for removed_child_id in coordinator.removed_child_device_ids: + _LOGGER.debug( + "Removing %s from known %s child ids for device %s" + "as it has been removed by the coordinator", + removed_child_id, + entity_class_name, + device.host, + ) + known_child_device_ids.discard(removed_child_id) + + current_child_devices = {child.device_id: child for child in device.children} + current_child_device_ids = set(current_child_devices.keys()) + new_child_device_ids = current_child_device_ids - known_child_device_ids + children = [] + + if new_child_device_ids: + children = [ + child + for child_id, child in current_child_devices.items() + if child_id in new_child_device_ids + ] + known_child_device_ids.update(new_child_device_ids) + return children + return [] diff --git a/homeassistant/components/tplink/fan.py b/homeassistant/components/tplink/fan.py index 92cf049c11a..cb17955fbcb 100644 --- a/homeassistant/components/tplink/fan.py +++ b/homeassistant/components/tplink/fan.py @@ -1,13 +1,17 @@ """Support for TPLink Fan devices.""" +from dataclasses import dataclass import logging import math from typing import Any from kasa import Device, Module -from kasa.interfaces import Fan as FanInterface -from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.components.fan import ( + FanEntity, + FanEntityDescription, + FanEntityFeature, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( @@ -18,7 +22,11 @@ from homeassistant.util.scaling import int_states_in_range from . import TPLinkConfigEntry from .coordinator import TPLinkDataUpdateCoordinator -from .entity import CoordinatedTPLinkEntity, async_refresh_after +from .entity import ( + CoordinatedTPLinkModuleEntity, + TPLinkModuleEntityDescription, + async_refresh_after, +) # Coordinator is used to centralize the data updates # For actions the integration handles locking of concurrent device request @@ -27,6 +35,19 @@ PARALLEL_UPDATES = 0 _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class TPLinkFanEntityDescription(FanEntityDescription, TPLinkModuleEntityDescription): + """Base class for fan entity description.""" + + +FAN_DESCRIPTIONS: tuple[TPLinkFanEntityDescription, ...] = ( + TPLinkFanEntityDescription( + key="fan", + exists_fn=lambda dev, _: Module.Fan in dev.modules, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, @@ -36,30 +57,31 @@ async def async_setup_entry( data = config_entry.runtime_data parent_coordinator = data.parent_coordinator device = parent_coordinator.device - entities: list[CoordinatedTPLinkEntity] = [] - if Module.Fan in device.modules: - entities.append( - TPLinkFanEntity( - device, parent_coordinator, fan_module=device.modules[Module.Fan] - ) + + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkFanEntity, + descriptions=FAN_DESCRIPTIONS, + known_child_device_ids=known_child_device_ids, + first_check=first_check, ) - entities.extend( - TPLinkFanEntity( - child, - parent_coordinator, - fan_module=child.modules[Module.Fan], - parent=device, - ) - for child in device.children - if Module.Fan in child.modules - ) - async_add_entities(entities) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) SPEED_RANGE = (1, 4) # off is not included -class TPLinkFanEntity(CoordinatedTPLinkEntity, FanEntity): +class TPLinkFanEntity(CoordinatedTPLinkModuleEntity, FanEntity): """Representation of a fan for a TPLink Fan device.""" _attr_speed_count = int_states_in_range(SPEED_RANGE) @@ -69,19 +91,19 @@ class TPLinkFanEntity(CoordinatedTPLinkEntity, FanEntity): | FanEntityFeature.TURN_ON ) + entity_description: TPLinkFanEntityDescription + def __init__( self, device: Device, coordinator: TPLinkDataUpdateCoordinator, - fan_module: FanInterface, + description: TPLinkFanEntityDescription, + *, parent: Device | None = None, ) -> None: """Initialize the fan.""" - self.fan_module = fan_module - # If _attr_name is None the entity name will be the device name - self._attr_name = None if parent is None else device.alias - - super().__init__(device, coordinator, parent=parent) + super().__init__(device, coordinator, description, parent=parent) + self.fan_module = device.modules[Module.Fan] @async_refresh_after async def async_turn_on( diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 731ee919c98..bc4d792b3f8 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -3,11 +3,12 @@ from __future__ import annotations from collections.abc import Sequence +from dataclasses import dataclass import logging from typing import Any from kasa import Device, DeviceType, KasaException, LightState, Module -from kasa.interfaces import Light, LightEffect +from kasa.interfaces import LightEffect from kasa.iot import IotDevice import voluptuous as vol @@ -20,12 +21,12 @@ from homeassistant.components.light import ( EFFECT_OFF, ColorMode, LightEntity, + LightEntityDescription, LightEntityFeature, filter_supported_color_modes, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType @@ -33,7 +34,11 @@ from homeassistant.helpers.typing import VolDictType from . import TPLinkConfigEntry, legacy_device_id from .const import DOMAIN from .coordinator import TPLinkDataUpdateCoordinator -from .entity import CoordinatedTPLinkEntity, async_refresh_after +from .entity import ( + CoordinatedTPLinkModuleEntity, + TPLinkModuleEntityDescription, + async_refresh_after, +) # Coordinator is used to centralize the data updates # For actions the integration handles locking of concurrent device request @@ -136,75 +141,93 @@ def _async_build_base_effect( } +@dataclass(frozen=True, kw_only=True) +class TPLinkLightEntityDescription( + LightEntityDescription, TPLinkModuleEntityDescription +): + """Base class for tplink light entity description.""" + + +LIGHT_DESCRIPTIONS: tuple[TPLinkLightEntityDescription, ...] = ( + TPLinkLightEntityDescription( + key="light", + exists_fn=lambda dev, _: Module.Light in dev.modules + and Module.LightEffect not in dev.modules, + ), +) + +LIGHT_EFFECT_DESCRIPTIONS: tuple[TPLinkLightEntityDescription, ...] = ( + TPLinkLightEntityDescription( + key="light_effect", + exists_fn=lambda dev, _: Module.Light in dev.modules + and Module.LightEffect in dev.modules, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up switches.""" + """Set up lights.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator device = parent_coordinator.device - entities: list[TPLinkLightEntity | TPLinkLightEffectEntity] = [] - if effect_module := device.modules.get(Module.LightEffect): - entities.append( - TPLinkLightEffectEntity( - device, - parent_coordinator, - light_module=device.modules[Module.Light], - effect_module=effect_module, + + known_child_device_ids_light: set[str] = set() + known_child_device_ids_light_effect: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkLightEntity, + descriptions=LIGHT_DESCRIPTIONS, + known_child_device_ids=known_child_device_ids_light, + first_check=first_check, + ) + entities.extend( + CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkLightEffectEntity, + descriptions=LIGHT_EFFECT_DESCRIPTIONS, + known_child_device_ids=known_child_device_ids_light_effect, + first_check=first_check, ) ) - if effect_module.has_custom_effects: - platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( - SERVICE_RANDOM_EFFECT, - RANDOM_EFFECT_DICT, - "async_set_random_effect", - ) - platform.async_register_entity_service( - SERVICE_SEQUENCE_EFFECT, - SEQUENCE_EFFECT_DICT, - "async_set_sequence_effect", - ) - elif Module.Light in device.modules: - entities.append( - TPLinkLightEntity( - device, parent_coordinator, light_module=device.modules[Module.Light] - ) - ) - entities.extend( - TPLinkLightEntity( - child, - parent_coordinator, - light_module=child.modules[Module.Light], - parent=device, - ) - for child in device.children - if Module.Light in child.modules - ) - async_add_entities(entities) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) -class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity): +class TPLinkLightEntity(CoordinatedTPLinkModuleEntity, LightEntity): """Representation of a TPLink Smart Bulb.""" _attr_supported_features = LightEntityFeature.TRANSITION _fixed_color_mode: ColorMode | None = None + entity_description: TPLinkLightEntityDescription + def __init__( self, device: Device, coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkLightEntityDescription, *, - light_module: Light, parent: Device | None = None, ) -> None: """Initialize the light.""" - self._parent = parent + super().__init__(device, coordinator, description, parent=parent) + + light_module = device.modules[Module.Light] self._light_module = light_module - # If _attr_name is None the entity name will be the device name - self._attr_name = None if parent is None else device.alias modes: set[ColorMode] = {ColorMode.ONOFF} if color_temp_feat := light_module.get_feature("color_temp"): modes.add(ColorMode.COLOR_TEMP) @@ -219,8 +242,6 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity): # If the light supports only a single color mode, set it now self._fixed_color_mode = next(iter(self._attr_supported_color_modes)) - super().__init__(device, coordinator, parent=parent) - def _get_unique_id(self) -> str: """Return unique ID for the entity.""" # For historical reasons the light platform uses the mac address as @@ -367,13 +388,33 @@ class TPLinkLightEffectEntity(TPLinkLightEntity): self, device: Device, coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkLightEntityDescription, *, - light_module: Light, - effect_module: LightEffect, + parent: Device | None = None, ) -> None: """Initialize the light strip.""" - self._effect_module = effect_module - super().__init__(device, coordinator, light_module=light_module) + super().__init__(device, coordinator, description, parent=parent) + + self._effect_module = device.modules[Module.LightEffect] + + async def async_added_to_hass(self) -> None: + """Call update attributes after the device is added to the platform.""" + await super().async_added_to_hass() + + self._register_effects_services() + + def _register_effects_services(self) -> None: + if self._effect_module.has_custom_effects: + self.platform.async_register_entity_service( + SERVICE_RANDOM_EFFECT, + RANDOM_EFFECT_DICT, + "async_set_random_effect", + ) + self.platform.async_register_entity_service( + SERVICE_SEQUENCE_EFFECT, + SEQUENCE_EFFECT_DICT, + "async_set_sequence_effect", + ) @callback def _async_update_attrs(self) -> bool: diff --git a/homeassistant/components/tplink/siren.py b/homeassistant/components/tplink/siren.py index bd1bfcead6d..0c15477ee78 100644 --- a/homeassistant/components/tplink/siren.py +++ b/homeassistant/components/tplink/siren.py @@ -2,24 +2,48 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any from kasa import Device, Module from kasa.smart.modules.alarm import Alarm -from homeassistant.components.siren import SirenEntity, SirenEntityFeature +from homeassistant.components.siren import ( + SirenEntity, + SirenEntityDescription, + SirenEntityFeature, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry from .coordinator import TPLinkDataUpdateCoordinator -from .entity import CoordinatedTPLinkEntity, async_refresh_after +from .entity import ( + CoordinatedTPLinkModuleEntity, + TPLinkModuleEntityDescription, + async_refresh_after, +) # Coordinator is used to centralize the data updates # For actions the integration handles locking of concurrent device request PARALLEL_UPDATES = 0 +@dataclass(frozen=True, kw_only=True) +class TPLinkSirenEntityDescription( + SirenEntityDescription, TPLinkModuleEntityDescription +): + """Base class for siren entity description.""" + + +SIREN_DESCRIPTIONS: tuple[TPLinkSirenEntityDescription, ...] = ( + TPLinkSirenEntityDescription( + key="siren", + exists_fn=lambda dev, _: Module.Alarm in dev.modules, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, @@ -30,24 +54,45 @@ async def async_setup_entry( parent_coordinator = data.parent_coordinator device = parent_coordinator.device - if Module.Alarm in device.modules: - async_add_entities([TPLinkSirenEntity(device, parent_coordinator)]) + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkSirenEntity, + descriptions=SIREN_DESCRIPTIONS, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) -class TPLinkSirenEntity(CoordinatedTPLinkEntity, SirenEntity): +class TPLinkSirenEntity(CoordinatedTPLinkModuleEntity, SirenEntity): """Representation of a tplink siren entity.""" _attr_name = None _attr_supported_features = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON + entity_description: TPLinkSirenEntityDescription + def __init__( self, device: Device, coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkSirenEntityDescription, + *, + parent: Device | None = None, ) -> None: """Initialize the siren entity.""" + super().__init__(device, coordinator, description, parent=parent) self._alarm_module: Alarm = device.modules[Module.Alarm] - super().__init__(device, coordinator) @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/tests/components/tplink/snapshots/test_climate.ambr b/tests/components/tplink/snapshots/test_climate.ambr index 6823c373b68..e0173e8f59e 100644 --- a/tests/components/tplink/snapshots/test_climate.ambr +++ b/tests/components/tplink/snapshots/test_climate.ambr @@ -91,6 +91,6 @@ 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.0', - 'via_device_id': None, + 'via_device_id': , }) # --- diff --git a/tests/components/tplink/test_camera.py b/tests/components/tplink/test_camera.py index ceb74e3a61a..4b062c4d0b2 100644 --- a/tests/components/tplink/test_camera.py +++ b/tests/components/tplink/test_camera.py @@ -123,7 +123,7 @@ async def test_handle_mjpeg_stream_not_supported( hass: HomeAssistant, mock_camera_config_entry: MockConfigEntry, ) -> None: - """Test handle_async_mjpeg_stream.""" + """Test no stream if stream_rtsp_url is None after creation.""" mock_device = _mocked_device( modules=[Module.Camera], alias="my_camera", @@ -132,17 +132,17 @@ async def test_handle_mjpeg_stream_not_supported( ) mock_camera = mock_device.modules[Module.Camera] - mock_camera.stream_rtsp_url.return_value = None + mock_camera.stream_rtsp_url.side_effect = ("foo", None) await setup_platform_for_device( hass, mock_camera_config_entry, Platform.CAMERA, mock_device ) mock_request = make_mocked_request("GET", "/", headers={"token": "x"}) - stream = await async_get_mjpeg_stream( + mjpeg_stream = await async_get_mjpeg_stream( hass, mock_request, "camera.my_camera_live_view" ) - assert stream is None + assert mjpeg_stream is None async def test_camera_image( diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 1fbd79c16c2..ef0ae3b6827 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -20,7 +20,6 @@ from kasa import ( from kasa.iot import IotStrip import pytest -from homeassistant import setup from homeassistant.components import tplink from homeassistant.components.tplink.const import ( CONF_AES_KEYS, @@ -68,7 +67,9 @@ from .const import ( DEVICE_ID, DEVICE_ID_MAC, IP_ADDRESS, + IP_ADDRESS3, MAC_ADDRESS, + MAC_ADDRESS3, MODEL, ) @@ -162,7 +163,7 @@ async def test_dimmer_switch_unique_id_fix_original_entity_still_exists( _patch_single_discovery(device=dimmer), _patch_connect(device=dimmer), ): - await setup.async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) migrated_dimmer_entity_reg = entity_registry.async_get_or_create( @@ -374,7 +375,7 @@ async def test_update_attrs_fails_in_init( assert entity state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE - assert "Unable to read data for MockLight None:" in caplog.text + assert f"Unable to read data for MockLight {entity_id}:" in caplog.text async def test_update_attrs_fails_on_update( @@ -839,7 +840,7 @@ async def test_migrate_remove_device_config( @pytest.mark.parametrize( - ("device_type"), + ("parent_device_type"), [ (Device), (IotStrip), @@ -859,7 +860,7 @@ async def test_migrate_remove_device_config( ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_automatic_device_addition_and_removal( +async def test_automatic_feature_device_addition_and_removal( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connect: AsyncMock, @@ -870,9 +871,9 @@ async def test_automatic_device_addition_and_removal( platform: str, feature_id: str, translated_name: str, - device_type: type, + parent_device_type: type, ) -> None: - """Test for automatic device addition and removal.""" + """Test for automatic device with features addition and removal.""" children = { f"child{index}": _mocked_device( @@ -889,7 +890,7 @@ async def test_automatic_device_addition_and_removal( children=[children["child1"], children["child2"]], features=[feature_id], device_type=DeviceType.Hub, - spec=device_type, + spec=parent_device_type, device_id="hub_parent", ) @@ -985,3 +986,167 @@ async def test_automatic_device_addition_and_removal( ) assert device_entry assert device_entry.via_device_id == parent_device.id + + +@pytest.mark.parametrize( + ("platform", "modules", "features", "translated_name", "child_device_type"), + [ + pytest.param( + "camera", [Module.Camera], [], "live_view", DeviceType.Camera, id="camera" + ), + pytest.param("fan", [Module.Fan], [], None, DeviceType.Fan, id="fan"), + pytest.param("siren", [Module.Alarm], [], None, DeviceType.Camera, id="siren"), + pytest.param("light", [Module.Light], [], None, DeviceType.Camera, id="light"), + pytest.param( + "light", + [Module.Light, Module.LightEffect], + [], + None, + DeviceType.Camera, + id="light_effect", + ), + pytest.param( + "climate", + [], + ["state", "thermostat_mode", "temperature", "target_temperature"], + None, + DeviceType.Thermostat, + id="climate", + ), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_automatic_module_device_addition_and_removal( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, + mock_connect: AsyncMock, + mock_discovery: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, + platform: str, + modules: list[str], + features: list[str], + translated_name: str | None, + child_device_type: DeviceType, +) -> None: + """Test for automatic device with modules addition and removal.""" + + children = { + f"child{index}": _mocked_device( + alias=f"child {index}", + modules=modules, + features=features, + device_type=child_device_type, + device_id=f"child{index}", + ) + for index in range(1, 5) + } + + mock_device = _mocked_device( + alias="hub", + children=[children["child1"], children["child2"]], + features=["ssid"], + device_type=DeviceType.Hub, + device_id="hub_parent", + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + ) + + with override_side_effect(mock_connect["connect"], lambda *_, **__: mock_device): + mock_camera_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_camera_config_entry.entry_id) + await hass.async_block_till_done() + + for child_id in (1, 2): + sub_id = f"_{translated_name}" if translated_name else "" + entity_id = f"{platform}.child_{child_id}{sub_id}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + parent_device = device_registry.async_get_device( + identifiers={(DOMAIN, "hub_parent")} + ) + assert parent_device + + for device_id in ("child1", "child2"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id + + # Remove one of the devices + mock_device.children = [children["child1"]] + freezer.tick(5) + async_fire_time_changed(hass) + + sub_id = f"_{translated_name}" if translated_name else "" + entity_id = f"{platform}.child_2{sub_id}" + state = hass.states.get(entity_id) + assert state is None + assert entity_registry.async_get(entity_id) is None + + assert device_registry.async_get_device(identifiers={(DOMAIN, "child2")}) is None + + # Re-dd the previously removed child device + mock_device.children = [ + children["child1"], + children["child2"], + ] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 2): + sub_id = f"_{translated_name}" if translated_name else "" + entity_id = f"{platform}.child_{child_id}{sub_id}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child2"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id + + # Add child devices + mock_device.children = [children["child1"], children["child3"], children["child4"]] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 3, 4): + sub_id = f"_{translated_name}" if translated_name else "" + entity_id = f"{platform}.child_{child_id}{sub_id}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child3", "child4"): + assert device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + + # Add the previously removed child device + mock_device.children = [ + children["child1"], + children["child2"], + children["child3"], + children["child4"], + ] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 2, 3, 4): + sub_id = f"_{translated_name}" if translated_name else "" + entity_id = f"{platform}.child_{child_id}{sub_id}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child2", "child3", "child4"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id From 8d99a546565b09e1b99d9f90a7d7df51b5a0aa3d Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 20 Jan 2025 20:31:45 +0200 Subject: [PATCH 0743/2987] Bump aiowebostv to 0.5.0 (#136097) --- homeassistant/components/webostv/config_flow.py | 13 ++++--------- homeassistant/components/webostv/helpers.py | 6 ++++-- homeassistant/components/webostv/manifest.json | 2 +- homeassistant/components/webostv/media_player.py | 14 +++++--------- .../components/webostv/quality_scale.yaml | 4 +--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/webostv/test_media_player.py | 6 +++--- 8 files changed, 20 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index a0ee9f1ac7f..6086fad8afd 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -9,12 +9,7 @@ from urllib.parse import urlparse from aiowebostv import WebOsTvPairError import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST from homeassistant.core import callback from homeassistant.helpers import config_validation as cv @@ -24,7 +19,7 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) -from . import async_control_connect +from . import WebOsTvConfigEntry, async_control_connect from .const import CONF_SOURCES, DEFAULT_NAME, DOMAIN, WEBOSTV_EXCEPTIONS from .helpers import async_get_sources @@ -49,7 +44,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: WebOsTvConfigEntry) -> OptionsFlow: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) @@ -186,7 +181,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(OptionsFlow): """Handle options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: WebOsTvConfigEntry) -> None: """Initialize options flow.""" self.host = config_entry.data[CONF_HOST] self.key = config_entry.data[CONF_CLIENT_SECRET] diff --git a/homeassistant/components/webostv/helpers.py b/homeassistant/components/webostv/helpers.py index 63724069f17..3aea860798a 100644 --- a/homeassistant/components/webostv/helpers.py +++ b/homeassistant/components/webostv/helpers.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry -from . import async_control_connect +from . import WebOsTvConfigEntry, async_control_connect from .const import DOMAIN, LIVE_TV_APP_ID, WEBOSTV_EXCEPTIONS @@ -56,7 +56,9 @@ def async_get_client_by_device_entry( Raises ValueError if client is not found. """ for config_entry_id in device.config_entries: - entry = hass.config_entries.async_get_entry(config_entry_id) + entry: WebOsTvConfigEntry | None = hass.config_entries.async_get_entry( + config_entry_id + ) if entry and entry.domain == DOMAIN: if entry.state is ConfigEntryState.LOADED: return entry.runtime_data diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 6c826c2f997..627bb83572c 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.4.2"], + "requirements": ["aiowebostv==0.5.0"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 719e3edbf4b..a03449a49b6 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -196,7 +196,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): self._attr_volume_level = None if self._client.volume is not None: - self._attr_volume_level = cast(float, self._client.volume / 100.0) + self._attr_volume_level = self._client.volume / 100.0 self._attr_source = self._current_source self._attr_source_list = sorted(self._source_list) @@ -240,13 +240,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): ) self._attr_assumed_state = True - if ( - self._client.is_on - and self._client.media_state is not None - and self._client.media_state.get("foregroundAppInfo") is not None - ): + if self._client.is_on and self._client.media_state: self._attr_assumed_state = False - for entry in self._client.media_state.get("foregroundAppInfo"): + for entry in self._client.media_state: if entry.get("playState") == "playing": self._attr_state = MediaPlayerState.PLAYING elif entry.get("playState") == "paused": @@ -254,7 +250,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): elif entry.get("playState") == "unloaded": self._attr_state = MediaPlayerState.IDLE - if self._client.system_info is not None or self.state != MediaPlayerState.OFF: + if self.state != MediaPlayerState.OFF: maj_v = self._client.software_info.get("major_ver") min_v = self._client.software_info.get("minor_ver") if maj_v and min_v: @@ -406,7 +402,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): """Play a piece of media.""" _LOGGER.debug("Call play media type <%s>, Id <%s>", media_type, media_id) - if media_type == MediaType.CHANNEL: + if media_type == MediaType.CHANNEL and self._client.channels: _LOGGER.debug("Searching channel") partial_match_channel_id = None perfect_match_channel_id = None diff --git a/homeassistant/components/webostv/quality_scale.yaml b/homeassistant/components/webostv/quality_scale.yaml index 3a31c20f256..1b3a3173ffa 100644 --- a/homeassistant/components/webostv/quality_scale.yaml +++ b/homeassistant/components/webostv/quality_scale.yaml @@ -77,6 +77,4 @@ rules: inject-websession: status: todo comment: need to check if it is needed for websockets or migrate to aiohttp - strict-typing: - status: todo - comment: aiowebostv is not fully typed + strict-typing: done diff --git a/requirements_all.txt b/requirements_all.txt index 516cfc40864..235e8c6ce86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -416,7 +416,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.4.2 +aiowebostv==0.5.0 # homeassistant.components.withings aiowithings==3.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d4ac1bfac47..e902b44f154 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -398,7 +398,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.4.2 +aiowebostv==0.5.0 # homeassistant.components.withings aiowithings==3.1.4 diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 7dea412f4fa..ab3feac1f2d 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -820,15 +820,15 @@ async def test_update_media_state(hass: HomeAssistant, client) -> None: """Test updating media state.""" await setup_webostv(hass) - client.media_state = {"foregroundAppInfo": [{"playState": "playing"}]} + client.media_state = [{"playState": "playing"}] await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PLAYING - client.media_state = {"foregroundAppInfo": [{"playState": "paused"}]} + client.media_state = [{"playState": "paused"}] await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PAUSED - client.media_state = {"foregroundAppInfo": [{"playState": "unloaded"}]} + client.media_state = [{"playState": "unloaded"}] await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.IDLE From 45e00eb13dc1d9742848add874ba7f05b0574f29 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Mon, 20 Jan 2025 12:51:26 -0600 Subject: [PATCH 0744/2987] Add integration_type to HEOS (#136105) --- homeassistant/components/heos/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 6a631861b1c..e3d2632e340 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@andrewsayre"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/heos", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyheos"], "requirements": ["pyheos==1.0.0"], From e7a635abc8b9dc02bd28379a8b7dcd3c8b5001a8 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 20 Jan 2025 19:53:04 +0100 Subject: [PATCH 0745/2987] Fix index in incomfort diagnostics generator (#136108) --- homeassistant/components/incomfort/diagnostics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/incomfort/diagnostics.py b/homeassistant/components/incomfort/diagnostics.py index 1f21dfed8b3..a2f89a94f58 100644 --- a/homeassistant/components/incomfort/diagnostics.py +++ b/homeassistant/components/incomfort/diagnostics.py @@ -36,7 +36,7 @@ def _async_get_diagnostics( } for n in range(nr_heaters): status[f"heater_{n}"]["rooms"] = { - n: dict(coordinator.incomfort_data.heaters[n].rooms[m].status) + m: dict(coordinator.incomfort_data.heaters[n].rooms[m].status) for m in range(len(coordinator.incomfort_data.heaters[n].rooms)) } return { From 4c008a5cb549a32e7aa4320ae42e94e5a6f5113a Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Mon, 20 Jan 2025 20:00:02 +0100 Subject: [PATCH 0746/2987] Fix upload service response for google_photos (#136106) --- .../components/google_photos/services.py | 8 ++- .../components/google_photos/test_services.py | 51 +++++++++++++++---- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py index f23a706b2e2..22d3cc7deb0 100644 --- a/homeassistant/components/google_photos/services.py +++ b/homeassistant/components/google_photos/services.py @@ -144,11 +144,9 @@ def async_register_services(hass: HomeAssistant) -> None: if call.return_response: return { "media_items": [ - { - "media_item_id": item_result.media_item.id - for item_result in upload_result.new_media_item_results - if item_result.media_item and item_result.media_item.id - } + {"media_item_id": item_result.media_item.id} + for item_result in upload_result.new_media_item_results + if item_result.media_item and item_result.media_item.id ], "album_id": album_id, } diff --git a/tests/components/google_photos/test_services.py b/tests/components/google_photos/test_services.py index 381fb1c431f..e02253be887 100644 --- a/tests/components/google_photos/test_services.py +++ b/tests/components/google_photos/test_services.py @@ -74,24 +74,55 @@ def mock_upload_file( yield +@pytest.mark.parametrize( + ("media_items_result", "service_response"), + [ + ( + CreateMediaItemsResult( + new_media_item_results=[ + NewMediaItemResult( + upload_token="some-upload-token", + status=Status(code=200), + media_item=MediaItem(id="new-media-item-id-1"), + ) + ] + ), + [{"media_item_id": "new-media-item-id-1"}], + ), + ( + CreateMediaItemsResult( + new_media_item_results=[ + NewMediaItemResult( + upload_token="some-upload-token", + status=Status(code=200), + media_item=MediaItem(id="new-media-item-id-1"), + ), + NewMediaItemResult( + upload_token="some-upload-token", + status=Status(code=200), + media_item=MediaItem(id="new-media-item-id-2"), + ), + ] + ), + [ + {"media_item_id": "new-media-item-id-1"}, + {"media_item_id": "new-media-item-id-2"}, + ], + ), + ], +) @pytest.mark.usefixtures("setup_integration") async def test_upload_service( hass: HomeAssistant, config_entry: MockConfigEntry, mock_api: Mock, + media_items_result: CreateMediaItemsResult, + service_response: list[dict[str, str]], ) -> None: """Test service call to upload content.""" assert hass.services.has_service(DOMAIN, "upload") - mock_api.create_media_items.return_value = CreateMediaItemsResult( - new_media_item_results=[ - NewMediaItemResult( - upload_token="some-upload-token", - status=Status(code=200), - media_item=MediaItem(id="new-media-item-id-1"), - ) - ] - ) + mock_api.create_media_items.return_value = media_items_result response = await hass.services.async_call( DOMAIN, @@ -106,7 +137,7 @@ async def test_upload_service( ) assert response == { - "media_items": [{"media_item_id": "new-media-item-id-1"}], + "media_items": service_response, "album_id": "album-media-id-1", } From d404d619d0a9077ce9770bbaee090b0d44cf39ee Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 20 Jan 2025 20:00:59 +0100 Subject: [PATCH 0747/2987] Add icon to overseerr (#136110) --- homeassistant/components/overseerr/icons.json | 5 +++++ homeassistant/components/overseerr/quality_scale.yaml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/overseerr/icons.json b/homeassistant/components/overseerr/icons.json index 2876eb5f882..af18836680b 100644 --- a/homeassistant/components/overseerr/icons.json +++ b/homeassistant/components/overseerr/icons.json @@ -22,6 +22,11 @@ "available_requests": { "default": "mdi:message-bulleted" } + }, + "event": { + "last_media_event": { + "default": "mdi:multimedia" + } } }, "services": { diff --git a/homeassistant/components/overseerr/quality_scale.yaml b/homeassistant/components/overseerr/quality_scale.yaml index d4295030fdc..dfb794476aa 100644 --- a/homeassistant/components/overseerr/quality_scale.yaml +++ b/homeassistant/components/overseerr/quality_scale.yaml @@ -66,7 +66,7 @@ rules: entity-disabled-by-default: todo entity-translations: done exception-translations: done - icon-translations: todo + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt From ad6d54dfd2c219f749c53dc02f5cbc5a67bb376e Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 20 Jan 2025 21:13:32 +0200 Subject: [PATCH 0748/2987] Bump ayla-iot-unofficial to 1.4.5 (#136099) --- homeassistant/components/fujitsu_fglair/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fujitsu_fglair/manifest.json b/homeassistant/components/fujitsu_fglair/manifest.json index ea08a2cfe02..330685f89fc 100644 --- a/homeassistant/components/fujitsu_fglair/manifest.json +++ b/homeassistant/components/fujitsu_fglair/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair", "iot_class": "cloud_polling", - "requirements": ["ayla-iot-unofficial==1.4.4"] + "requirements": ["ayla-iot-unofficial==1.4.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 235e8c6ce86..9f1b3c755f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -548,7 +548,7 @@ av==13.1.0 axis==64 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.4 +ayla-iot-unofficial==1.4.5 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e902b44f154..2a3169d166f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -497,7 +497,7 @@ av==13.1.0 axis==64 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.4 +ayla-iot-unofficial==1.4.5 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 From a4d2fe2d89656711f5ba5207b458717db48aaf50 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 20 Jan 2025 20:17:03 +0100 Subject: [PATCH 0749/2987] Bump python-overseerr to 0.6.0 (#136104) --- homeassistant/components/overseerr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overseerr/manifest.json b/homeassistant/components/overseerr/manifest.json index 26dfd6d73e3..46ac97073d6 100644 --- a/homeassistant/components/overseerr/manifest.json +++ b/homeassistant/components/overseerr/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["python-overseerr==0.5.0"] + "requirements": ["python-overseerr==0.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9f1b3c755f2..30c1ca8fe8c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2427,7 +2427,7 @@ python-opensky==1.0.1 python-otbr-api==2.7.0 # homeassistant.components.overseerr -python-overseerr==0.5.0 +python-overseerr==0.6.0 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2a3169d166f..901e09c9d65 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1963,7 +1963,7 @@ python-opensky==1.0.1 python-otbr-api==2.7.0 # homeassistant.components.overseerr -python-overseerr==0.5.0 +python-overseerr==0.6.0 # homeassistant.components.picnic python-picnic-api==1.1.0 From dde6dc0421ab691f99d9660b00af25e51d69bd5f Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Mon, 20 Jan 2025 13:29:57 -0600 Subject: [PATCH 0750/2987] Raise exceptions in HEOS service actions (#136049) * Raise errors instead of log * Correct docstring typo --- homeassistant/components/heos/__init__.py | 35 +- homeassistant/components/heos/media_player.py | 45 +- .../components/heos/quality_scale.yaml | 4 +- homeassistant/components/heos/strings.json | 6 + tests/components/heos/test_media_player.py | 868 ++++++++++-------- 5 files changed, 541 insertions(+), 417 deletions(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 1004ffd2738..a3e720a5f21 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -28,7 +28,11 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import ( + ConfigEntryNotReady, + HomeAssistantError, + ServiceValidationError, +) from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -306,7 +310,7 @@ class GroupManager: return group_info_by_entity_id async def async_join_players( - self, leader_id: int, leader_entity_id: str, member_entity_ids: list[str] + self, leader_id: int, member_entity_ids: list[str] ) -> None: """Create a group a group leader and member players.""" # Resolve HEOS player_id for each member entity_id @@ -320,26 +324,11 @@ class GroupManager: ) member_ids.append(member_id) - try: - await self.controller.create_group(leader_id, member_ids) - except HeosError as err: - _LOGGER.error( - "Failed to group %s with %s: %s", - leader_entity_id, - member_entity_ids, - err, - ) + await self.controller.create_group(leader_id, member_ids) - async def async_unjoin_player(self, player_id: int, player_entity_id: str): + async def async_unjoin_player(self, player_id: int): """Remove `player_entity_id` from any group.""" - try: - await self.controller.create_group(player_id, []) - except HeosError as err: - _LOGGER.error( - "Failed to ungroup %s: %s", - player_entity_id, - err, - ) + await self.controller.create_group(player_id, []) async def async_update_groups(self) -> None: """Update the group membership from the controller.""" @@ -449,7 +438,11 @@ class SourceManager: await player.play_input_source(input_source.media_id) return - _LOGGER.error("Unknown source: %s", source) + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unknown_source", + translation_placeholders={"source": source}, + ) def get_current_source(self, now_playing_media): """Determine current source from now playing media.""" diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 69aedaa4648..67a837b2888 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -29,6 +29,7 @@ from homeassistant.components.media_player import ( async_process_play_media_url, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -96,10 +97,10 @@ type _FuncType[**_P] = Callable[_P, Awaitable[Any]] type _ReturnFuncType[**_P] = Callable[_P, Coroutine[Any, Any, None]] -def log_command_error[**_P]( - command: str, +def catch_action_error[**_P]( + action: str, ) -> Callable[[_FuncType[_P]], _ReturnFuncType[_P]]: - """Return decorator that logs command failure.""" + """Return decorator that catches errors and raises HomeAssistantError.""" def decorator(func: _FuncType[_P]) -> _ReturnFuncType[_P]: @wraps(func) @@ -107,7 +108,11 @@ def log_command_error[**_P]( try: await func(*args, **kwargs) except (HeosError, ValueError) as ex: - _LOGGER.error("Unable to %s: %s", command, ex) + raise HomeAssistantError( + translation_domain=HEOS_DOMAIN, + translation_key="action_error", + translation_placeholders={"action": action, "error": str(ex)}, + ) from ex return wrapper @@ -174,49 +179,49 @@ class HeosMediaPlayer(MediaPlayerEntity): ) async_dispatcher_send(self.hass, SIGNAL_HEOS_PLAYER_ADDED) - @log_command_error("clear playlist") + @catch_action_error("clear playlist") async def async_clear_playlist(self) -> None: """Clear players playlist.""" await self._player.clear_queue() - @log_command_error("join_players") + @catch_action_error("join players") async def async_join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" await self._group_manager.async_join_players( - self._player.player_id, self.entity_id, group_members + self._player.player_id, group_members ) - @log_command_error("pause") + @catch_action_error("pause") async def async_media_pause(self) -> None: """Send pause command.""" await self._player.pause() - @log_command_error("play") + @catch_action_error("play") async def async_media_play(self) -> None: """Send play command.""" await self._player.play() - @log_command_error("move to previous track") + @catch_action_error("move to previous track") async def async_media_previous_track(self) -> None: """Send previous track command.""" await self._player.play_previous() - @log_command_error("move to next track") + @catch_action_error("move to next track") async def async_media_next_track(self) -> None: """Send next track command.""" await self._player.play_next() - @log_command_error("stop") + @catch_action_error("stop") async def async_media_stop(self) -> None: """Send stop command.""" await self._player.stop() - @log_command_error("set mute") + @catch_action_error("set mute") async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" await self._player.set_mute(mute) - @log_command_error("play media") + @catch_action_error("play media") async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: @@ -281,17 +286,17 @@ class HeosMediaPlayer(MediaPlayerEntity): raise ValueError(f"Unsupported media type '{media_type}'") - @log_command_error("select source") + @catch_action_error("select source") async def async_select_source(self, source: str) -> None: """Select input source.""" await self._source_manager.play_source(source, self._player) - @log_command_error("set shuffle") + @catch_action_error("set shuffle") async def async_set_shuffle(self, shuffle: bool) -> None: """Enable/disable shuffle mode.""" await self._player.set_play_mode(self._player.repeat, shuffle) - @log_command_error("set volume level") + @catch_action_error("set volume level") async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" await self._player.set_volume(int(volume * 100)) @@ -304,12 +309,10 @@ class HeosMediaPlayer(MediaPlayerEntity): ior, current_support, BASE_SUPPORTED_FEATURES ) - @log_command_error("unjoin_player") + @catch_action_error("unjoin player") async def async_unjoin_player(self) -> None: """Remove this player from any group.""" - await self._group_manager.async_unjoin_player( - self._player.player_id, self.entity_id - ) + await self._group_manager.async_unjoin_player(self._player.player_id) @property def available(self) -> bool: diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index 3135cca3f9d..2d73f4d29b8 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -23,9 +23,7 @@ rules: test-before-setup: done unique-config-entry: done # Silver - action-exceptions: - status: todo - comment: Actions currently only log and instead should raise exceptions. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 0506c37fa77..e99d8f7e7fb 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -91,8 +91,14 @@ } }, "exceptions": { + "action_error": { + "message": "Unable to {action}: {error}" + }, "integration_not_loaded": { "message": "The HEOS integration is not loaded" + }, + "unknown_source": { + "message": "Unknown source: {source}" } }, "issues": { diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index e71614564f2..ea00bc9217a 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -1,12 +1,16 @@ """Tests for the Heos Media Player platform.""" import asyncio +from collections.abc import Sequence +import re from typing import Any from pyheos import ( AddCriteriaType, CommandFailedError, + Heos, HeosError, + MediaItem, PlayState, SignalHeosEvent, SignalType, @@ -58,7 +62,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component @@ -326,219 +330,333 @@ async def test_updates_from_user_changed( async def test_clear_playlist( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the clear playlist service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_CLEAR_PLAYLIST, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert player.clear_queue.call_count == 1 + + +async def test_clear_playlist_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test error raised when clear playlist fails.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.clear_queue.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, match=re.escape("Unable to clear playlist: Failure (1)") + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.clear_queue.call_count == 1 - player.clear_queue.reset_mock() - player.clear_queue.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to clear playlist: Failure (1)" in caplog.text + assert player.clear_queue.call_count == 1 async def test_pause( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the pause service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert player.pause.call_count == 1 + + +async def test_pause_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the pause service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.pause.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, match=re.escape("Unable to pause: Failure (1)") + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PAUSE, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.pause.call_count == 1 - player.pause.reset_mock() - player.pause.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to pause: Failure (1)" in caplog.text + assert player.pause.call_count == 1 async def test_play( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the play service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert player.play.call_count == 1 + + +async def test_play_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the play service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.play.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, match=re.escape("Unable to play: Failure (1)") + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PLAY, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play.call_count == 1 - player.play.reset_mock() - player.play.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to play: Failure (1)" in caplog.text + assert player.play.call_count == 1 async def test_previous_track( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the previous track service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert player.play_previous.call_count == 1 + + +async def test_previous_track_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the previous track service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.play_previous.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to move to previous track: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play_previous.call_count == 1 - player.play_previous.reset_mock() - player.play_previous.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to move to previous track: Failure (1)" in caplog.text + assert player.play_previous.call_count == 1 async def test_next_track( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the next track service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert player.play_next.call_count == 1 + + +async def test_next_track_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the next track service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.play_next.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to move to next track: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play_next.call_count == 1 - player.play_next.reset_mock() - player.play_next.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to move to next track: Failure (1)" in caplog.text + assert player.play_next.call_count == 1 async def test_stop( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the stop service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_STOP, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert player.stop.call_count == 1 + + +async def test_stop_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the stop service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.stop.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to stop: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_STOP, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.stop.call_count == 1 - player.stop.reset_mock() - player.stop.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to stop: Failure (1)" in caplog.text + assert player.stop.call_count == 1 async def test_volume_mute( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the volume mute service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_MUTED: True}, + blocking=True, + ) + assert player.set_mute.call_count == 1 + + +async def test_volume_mute_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the volume mute service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.set_mute.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to set mute: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_MUTE, {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_MUTED: True}, blocking=True, ) - assert player.set_mute.call_count == 1 - player.set_mute.reset_mock() - player.set_mute.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to set mute: Failure (1)" in caplog.text + assert player.set_mute.call_count == 1 async def test_shuffle_set( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the shuffle set service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SHUFFLE_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_SHUFFLE: True}, + blocking=True, + ) + player.set_play_mode.assert_called_once_with(player.repeat, True) + + +async def test_shuffle_set_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the shuffle set service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.set_play_mode.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to set shuffle: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_SHUFFLE_SET, {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_SHUFFLE: True}, blocking=True, ) - player.set_play_mode.assert_called_once_with(player.repeat, True) - player.set_play_mode.reset_mock() - player.set_play_mode.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to set shuffle: Failure (1)" in caplog.text + player.set_play_mode.assert_called_once_with(player.repeat, True) async def test_volume_set( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the volume set service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, + blocking=True, + ) + player.set_volume.assert_called_once_with(100) + + +async def test_volume_set_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the volume set service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.set_volume.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to set volume level: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_SET, {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, blocking=True, ) - player.set_volume.assert_called_once_with(100) - player.set_volume.reset_mock() - player.set_volume.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to set volume level: Failure (1)" in caplog.text + player.set_volume.assert_called_once_with(100) async def test_select_favorite( @@ -594,26 +712,31 @@ async def test_select_radio_favorite( async def test_select_radio_favorite_command_error( hass: HomeAssistant, - config_entry, - config, - controller, - favorites, - caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, + controller: Heos, + favorites: dict[int, MediaItem], ) -> None: - """Tests command error logged when playing favorite.""" - await setup_platform(hass, config_entry, config) + """Tests command error raises when playing favorite.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] # Test set radio preset favorite = favorites[2] player.play_preset_station.side_effect = CommandFailedError(None, "Failure", 1) - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_SELECT_SOURCE, - {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, - blocking=True, - ) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to select source: Failure (1)"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_INPUT_SOURCE: favorite.name, + }, + blocking=True, + ) player.play_preset_station.assert_called_once_with(2) - assert "Unable to select source: Failure (1)" in caplog.text async def test_select_input_source( @@ -645,48 +768,51 @@ async def test_select_input_source( assert state.attributes[ATTR_INPUT_SOURCE] == input_source.name -async def test_select_input_unknown( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, +@pytest.mark.usefixtures("controller") +async def test_select_input_unknown_raises( + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: - """Tests selecting an unknown input.""" - await setup_platform(hass, config_entry, config) - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_SELECT_SOURCE, - {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: "Unknown"}, - blocking=True, - ) - assert "Unknown source: Unknown" in caplog.text + """Tests selecting an unknown input raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + with pytest.raises( + ServiceValidationError, + match=re.escape("Unknown source: Unknown"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: "Unknown"}, + blocking=True, + ) async def test_select_input_command_error( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, - input_sources, + config_entry: MockConfigEntry, + controller: Heos, + input_sources: Sequence[MediaItem], ) -> None: """Tests selecting an unknown input.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] input_source = input_sources[0] player.play_input_source.side_effect = CommandFailedError(None, "Failure", 1) - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_SELECT_SOURCE, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_INPUT_SOURCE: input_source.name, - }, - blocking=True, - ) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to select source: Failure (1)"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_INPUT_SOURCE: input_source.name, + }, + blocking=True, + ) player.play_input_source.assert_called_once_with(input_source.media_id) - assert "Unable to select source: Failure (1)" in caplog.text async def test_unload_config_entry( @@ -698,261 +824,253 @@ async def test_unload_config_entry( assert hass.states.get("media_player.test_player").state == STATE_UNAVAILABLE -async def test_play_media_url( +@pytest.mark.parametrize("media_type", [MediaType.URL, MediaType.MUSIC]) +async def test_play_media( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, + controller: Heos, + media_type: MediaType, ) -> None: """Test the play media service with type url.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] url = "http://news/podcast.mp3" - # First pass completes successfully, second pass raises command error - for _ in range(2): - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: MediaType.URL, - ATTR_MEDIA_CONTENT_ID: url, - }, - blocking=True, - ) - player.play_url.assert_called_once_with(url) - player.play_url.reset_mock() - player.play_url.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to play media: Failure (1)" in caplog.text + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: media_type, + ATTR_MEDIA_CONTENT_ID: url, + }, + blocking=True, + ) + player.play_url.assert_called_once_with(url) -async def test_play_media_music( +@pytest.mark.parametrize("media_type", [MediaType.URL, MediaType.MUSIC]) +async def test_play_media_error( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, + controller: Heos, + media_type: MediaType, ) -> None: - """Test the play media service with type music.""" - await setup_platform(hass, config_entry, config) + """Test the play media service with type url error raises.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] + player.play_url.side_effect = CommandFailedError(None, "Failure", 1) url = "http://news/podcast.mp3" - # First pass completes successfully, second pass raises command error - for _ in range(2): + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to play media: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_TYPE: media_type, ATTR_MEDIA_CONTENT_ID: url, }, blocking=True, ) - player.play_url.assert_called_once_with(url) - player.play_url.reset_mock() - player.play_url.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to play media: Failure (1)" in caplog.text + player.play_url.assert_called_once_with(url) +@pytest.mark.parametrize( + ("content_id", "expected_index"), [("1", 1), ("Quick Select 2", 2)] +) async def test_play_media_quick_select( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, - quick_selects, + config_entry: MockConfigEntry, + controller: Heos, + content_id: str, + expected_index: int, ) -> None: """Test the play media service with type quick_select.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - quick_select = list(quick_selects.items())[0] - index = quick_select[0] - name = quick_select[1] - # Play by index await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_CONTENT_TYPE: "quick_select", - ATTR_MEDIA_CONTENT_ID: str(index), - }, - blocking=True, - ) - player.play_quick_select.assert_called_once_with(index) - # Play by name - player.play_quick_select.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: "quick_select", - ATTR_MEDIA_CONTENT_ID: name, - }, - blocking=True, - ) - player.play_quick_select.assert_called_once_with(index) - # Invalid name - player.play_quick_select.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: "quick_select", - ATTR_MEDIA_CONTENT_ID: "Invalid", + ATTR_MEDIA_CONTENT_ID: content_id, }, blocking=True, ) + player.play_quick_select.assert_called_once_with(expected_index) + + +async def test_play_media_quick_select_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the play media service with invalid quick_select raises.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to play media: Invalid quick select 'Invalid'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "quick_select", + ATTR_MEDIA_CONTENT_ID: "Invalid", + }, + blocking=True, + ) assert player.play_quick_select.call_count == 0 - assert "Unable to play media: Invalid quick select 'Invalid'" in caplog.text +@pytest.mark.parametrize( + ("enqueue", "criteria"), + [ + (None, AddCriteriaType.REPLACE_AND_PLAY), + (True, AddCriteriaType.ADD_TO_END), + ("next", AddCriteriaType.PLAY_NEXT), + ], +) async def test_play_media_playlist( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, - playlists, + config_entry: MockConfigEntry, + controller: Heos, + playlists: Sequence[MediaItem], + enqueue: Any, + criteria: AddCriteriaType, ) -> None: """Test the play media service with type playlist.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] playlist = playlists[0] - # Play without enqueuing + service_data = { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, + ATTR_MEDIA_CONTENT_ID: playlist.name, + } + if enqueue is not None: + service_data[ATTR_MEDIA_ENQUEUE] = enqueue await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, - ATTR_MEDIA_CONTENT_ID: playlist.name, - }, - blocking=True, - ) - player.add_to_queue.assert_called_once_with( - playlist, AddCriteriaType.REPLACE_AND_PLAY - ) - # Play with enqueuing - player.add_to_queue.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, - ATTR_MEDIA_CONTENT_ID: playlist.name, - ATTR_MEDIA_ENQUEUE: True, - }, - blocking=True, - ) - player.add_to_queue.assert_called_once_with(playlist, AddCriteriaType.ADD_TO_END) - # Invalid name - player.add_to_queue.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, - ATTR_MEDIA_CONTENT_ID: "Invalid", - }, + service_data, blocking=True, ) + player.add_to_queue.assert_called_once_with(playlist, criteria) + + +async def test_play_media_playlist_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the play media service with an invalid playlist name.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to play media: Invalid playlist 'Invalid'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, + ATTR_MEDIA_CONTENT_ID: "Invalid", + }, + blocking=True, + ) assert player.add_to_queue.call_count == 0 - assert "Unable to play media: Invalid playlist 'Invalid'" in caplog.text +@pytest.mark.parametrize( + ("content_id", "expected_index"), [("1", 1), ("Classical MPR (Classical Music)", 2)] +) async def test_play_media_favorite( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, - favorites, + config_entry: MockConfigEntry, + controller: Heos, + content_id: str, + expected_index: int, ) -> None: """Test the play media service with type favorite.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - quick_select = list(favorites.items())[0] - index = quick_select[0] - name = quick_select[1].name - # Play by index await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_CONTENT_TYPE: "favorite", - ATTR_MEDIA_CONTENT_ID: str(index), - }, - blocking=True, - ) - player.play_preset_station.assert_called_once_with(index) - # Play by name - player.play_preset_station.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: "favorite", - ATTR_MEDIA_CONTENT_ID: name, - }, - blocking=True, - ) - player.play_preset_station.assert_called_once_with(index) - # Invalid name - player.play_preset_station.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: "favorite", - ATTR_MEDIA_CONTENT_ID: "Invalid", + ATTR_MEDIA_CONTENT_ID: content_id, }, blocking=True, ) + player.play_preset_station.assert_called_once_with(expected_index) + + +async def test_play_media_favorite_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the play media service with an invalid favorite raises.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to play media: Invalid favorite 'Invalid'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "favorite", + ATTR_MEDIA_CONTENT_ID: "Invalid", + }, + blocking=True, + ) assert player.play_preset_station.call_count == 0 - assert "Unable to play media: Invalid favorite 'Invalid'" in caplog.text +@pytest.mark.usefixtures("controller") async def test_play_media_invalid_type( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test the play media service with an invalid type.""" - await setup_platform(hass, config_entry, config) - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: "Other", - ATTR_MEDIA_CONTENT_ID: "", - }, - blocking=True, - ) - assert "Unable to play media: Unsupported media type 'Other'" in caplog.text + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to play media: Unsupported media type 'Other'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "Other", + ATTR_MEDIA_CONTENT_ID: "", + }, + blocking=True, + ) async def test_media_player_join_group( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test grouping of media players through the join service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_JOIN, @@ -968,19 +1086,28 @@ async def test_media_player_join_group( 2, ], ) - assert "Failed to group media_player.test_player with" not in caplog.text + +async def test_media_player_join_group_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test grouping of media players through the join service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) controller.create_group.side_effect = HeosError("error") - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_JOIN, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_GROUP_MEMBERS: ["media_player.test_player_2"], - }, - blocking=True, - ) - assert "Failed to group media_player.test_player with" in caplog.text + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to join players: error"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_GROUP_MEMBERS: ["media_player.test_player_2"], + }, + blocking=True, + ) async def test_media_player_group_members( @@ -1019,22 +1146,11 @@ async def test_media_player_group_members_error( async def test_media_player_unjoin_group( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: - """Test ungrouping of media players through the join service.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] - - player.heos.dispatcher.send( - SignalType.PLAYER_EVENT, - player.player_id, - const.EVENT_PLAYER_STATE_CHANGED, - ) - await hass.async_block_till_done() + """Test ungrouping of media players through the unjoin service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_UNJOIN, @@ -1044,30 +1160,38 @@ async def test_media_player_unjoin_group( blocking=True, ) controller.create_group.assert_called_once_with(1, []) - assert "Failed to ungroup media_player.test_player" not in caplog.text + +async def test_media_player_unjoin_group_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test ungrouping of media players through the unjoin service error raises.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) controller.create_group.side_effect = HeosError("error") - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_UNJOIN, - { - ATTR_ENTITY_ID: "media_player.test_player", - }, - blocking=True, - ) - assert "Failed to ungroup media_player.test_player" in caplog.text + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to unjoin player: error"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + }, + blocking=True, + ) async def test_media_player_group_fails_when_entity_removed( hass: HomeAssistant, - config_entry, - config, - controller, + config_entry: MockConfigEntry, + controller: Heos, entity_registry: er.EntityRegistry, - caplog: pytest.LogCaptureFixture, ) -> None: """Test grouping fails when entity removed.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) # Remove one of the players entity_registry.async_remove("media_player.test_player_2") From 24610e4b9f9d363c2b341ce5a9a3a70b8a5d35b8 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Mon, 20 Jan 2025 21:09:28 +0100 Subject: [PATCH 0751/2987] Enable Ruff B035 (#135883) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index d5674cf7571..5cc7727e136 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -722,6 +722,7 @@ select = [ "B024", # `{name}` is an abstract base class, but it has no abstract methods or properties "B026", # Star-arg unpacking after a keyword argument is strongly discouraged "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? + "B035", # Dictionary comprehension uses static key "B904", # Use raise from to specify exception cause "B905", # zip() without an explicit strict= parameter "BLE", From d7ec99de7dcc649291c50a25e692183419797e2b Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Mon, 20 Jan 2025 15:18:46 -0600 Subject: [PATCH 0752/2987] Remove yaml config fixture from HEOS tests (#136123) --- tests/components/heos/conftest.py | 10 +- tests/components/heos/test_config_flow.py | 43 ++++---- tests/components/heos/test_init.py | 69 ++++--------- tests/components/heos/test_media_player.py | 115 +++++++++++---------- tests/components/heos/test_services.py | 69 ++++++++----- 5 files changed, 155 insertions(+), 151 deletions(-) diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 1348923927b..f0014d07876 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -120,12 +120,6 @@ def controller_fixture( yield mock_heos -@pytest.fixture(name="config") -def config_fixture(): - """Create hass config fixture.""" - return {DOMAIN: {CONF_HOST: "127.0.0.1"}} - - @pytest.fixture(name="players") def player_fixture(quick_selects): """Create two mock HeosPlayers.""" @@ -309,12 +303,12 @@ def playlists_fixture() -> Sequence[MediaItem]: @pytest.fixture(name="change_data") -def change_data_fixture() -> dict: +def change_data_fixture() -> PlayerUpdateResult: """Create player change data for testing.""" return PlayerUpdateResult() @pytest.fixture(name="change_data_mapped_ids") -def change_data_mapped_ids_fixture() -> dict: +def change_data_mapped_ids_fixture() -> PlayerUpdateResult: """Create player change data for testing.""" return PlayerUpdateResult(updated_player_ids={1: 101}) diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 217c7393e14..21f3606f9bd 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -1,6 +1,6 @@ """Tests for the Heos config flow module.""" -from pyheos import CommandAuthenticationError, CommandFailedError, HeosError +from pyheos import CommandAuthenticationError, CommandFailedError, Heos, HeosError import pytest from homeassistant.components import heos @@ -14,7 +14,9 @@ from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry -async def test_flow_aborts_already_setup(hass: HomeAssistant, config_entry) -> None: +async def test_flow_aborts_already_setup( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test flow aborts when entry already setup.""" config_entry.add_to_hass(hass) @@ -36,7 +38,9 @@ async def test_no_host_shows_form(hass: HomeAssistant) -> None: assert result["errors"] == {} -async def test_cannot_connect_shows_error_form(hass: HomeAssistant, controller) -> None: +async def test_cannot_connect_shows_error_form( + hass: HomeAssistant, controller: Heos +) -> None: """Test form is shown with error when cannot connect.""" controller.connect.side_effect = HeosError() result = await hass.config_entries.flow.async_init( @@ -49,7 +53,9 @@ async def test_cannot_connect_shows_error_form(hass: HomeAssistant, controller) assert controller.disconnect.call_count == 1 -async def test_create_entry_when_host_valid(hass: HomeAssistant, controller) -> None: +async def test_create_entry_when_host_valid( + hass: HomeAssistant, controller: Heos +) -> None: """Test result type is create entry when host is valid.""" data = {CONF_HOST: "127.0.0.1"} @@ -65,7 +71,7 @@ async def test_create_entry_when_host_valid(hass: HomeAssistant, controller) -> async def test_create_entry_when_friendly_name_valid( - hass: HomeAssistant, controller + hass: HomeAssistant, controller: Heos ) -> None: """Test result type is create entry when friendly name is valid.""" hass.data[DOMAIN] = {"Office (127.0.0.1)": "127.0.0.1"} @@ -86,7 +92,6 @@ async def test_create_entry_when_friendly_name_valid( async def test_discovery_shows_create_form( hass: HomeAssistant, - controller, discovery_data: SsdpServiceInfo, discovery_data_bedroom: SsdpServiceInfo, ) -> None: @@ -113,7 +118,7 @@ async def test_discovery_shows_create_form( async def test_discovery_flow_aborts_already_setup( - hass: HomeAssistant, controller, discovery_data: SsdpServiceInfo, config_entry + hass: HomeAssistant, discovery_data: SsdpServiceInfo, config_entry: MockConfigEntry ) -> None: """Test discovery flow aborts when entry already setup.""" config_entry.add_to_hass(hass) @@ -127,7 +132,7 @@ async def test_discovery_flow_aborts_already_setup( async def test_reconfigure_validates_and_updates_config( - hass: HomeAssistant, config_entry, controller + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test reconfigure validates host and successfully updates.""" config_entry.add_to_hass(hass) @@ -157,7 +162,7 @@ async def test_reconfigure_validates_and_updates_config( async def test_reconfigure_cannot_connect_recovers( - hass: HomeAssistant, config_entry, controller + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test reconfigure cannot connect and recovers.""" controller.connect.side_effect = HeosError() @@ -209,8 +214,8 @@ async def test_reconfigure_cannot_connect_recovers( ) async def test_options_flow_signs_in( hass: HomeAssistant, - config_entry, - controller, + config_entry: MockConfigEntry, + controller: Heos, error: HeosError, expected_error_key: str, ) -> None: @@ -250,7 +255,7 @@ async def test_options_flow_signs_in( async def test_options_flow_signs_out( - hass: HomeAssistant, config_entry, controller + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test options flow signs-out when credentials cleared.""" config_entry.add_to_hass(hass) @@ -294,8 +299,8 @@ async def test_options_flow_signs_out( ) async def test_options_flow_missing_one_param_recovers( hass: HomeAssistant, - config_entry, - controller, + config_entry: MockConfigEntry, + controller: Heos, user_input: dict[str, str], expected_errors: dict[str, str], ) -> None: @@ -343,7 +348,7 @@ async def test_options_flow_missing_one_param_recovers( async def test_reauth_signs_in_aborts( hass: HomeAssistant, config_entry: MockConfigEntry, - controller, + controller: Heos, error: HeosError, expected_error_key: str, ) -> None: @@ -381,7 +386,9 @@ async def test_reauth_signs_in_aborts( assert result["type"] is FlowResultType.ABORT -async def test_reauth_signs_out(hass: HomeAssistant, config_entry, controller) -> None: +async def test_reauth_signs_out( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: """Test reauth flow signs-out when credentials cleared and aborts.""" config_entry.add_to_hass(hass) result = await config_entry.start_reauth_flow(hass) @@ -425,8 +432,8 @@ async def test_reauth_signs_out(hass: HomeAssistant, config_entry, controller) - ) async def test_reauth_flow_missing_one_param_recovers( hass: HomeAssistant, - config_entry, - controller, + config_entry: MockConfigEntry, + controller: Heos, user_input: dict[str, str], expected_errors: dict[str, str], ) -> None: diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index f802529ac82..1362722390a 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -4,7 +4,14 @@ import asyncio from typing import cast from unittest.mock import Mock, patch -from pyheos import CommandFailedError, HeosError, SignalHeosEvent, SignalType, const +from pyheos import ( + CommandFailedError, + Heos, + HeosError, + SignalHeosEvent, + SignalType, + const, +) import pytest from homeassistant.components.heos import ( @@ -20,37 +27,14 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -async def test_async_setup_returns_true( - hass: HomeAssistant, config_entry, config -) -> None: - """Test component setup from config.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0] == config_entry - - -async def test_async_setup_no_config_returns_true( - hass: HomeAssistant, config_entry -) -> None: - """Test component setup from entry only.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0] == config_entry - - async def test_async_setup_entry_loads_platforms( - hass: HomeAssistant, config_entry, controller, input_sources, favorites + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, ) -> None: """Test load connects to heos, retrieves players, and loads platforms.""" config_entry.add_to_hass(hass) @@ -69,17 +53,11 @@ async def test_async_setup_entry_loads_platforms( async def test_async_setup_entry_with_options_loads_platforms( - hass: HomeAssistant, - config_entry_options, - config, - controller, - input_sources, - favorites, + hass: HomeAssistant, config_entry_options: MockConfigEntry, controller: Heos ) -> None: """Test load connects to heos with options, retrieves players, and loads platforms.""" config_entry_options.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(config_entry_options.entry_id) # Assert options passed and methods called assert config_entry_options.state is ConfigEntryState.LOADED @@ -111,8 +89,7 @@ async def test_async_setup_entry_auth_failure_starts_reauth( controller.connect.side_effect = connect_send_auth_failure - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(config_entry_options.entry_id) # Assert entry loaded and reauth flow started assert controller.connect.call_count == 1 @@ -126,9 +103,8 @@ async def test_async_setup_entry_auth_failure_starts_reauth( async def test_async_setup_entry_not_signed_in_loads_platforms( hass: HomeAssistant, - config_entry, - controller, - input_sources, + config_entry: MockConfigEntry, + controller: Heos, caplog: pytest.LogCaptureFixture, ) -> None: """Test setup does not retrieve favorites when not logged in.""" @@ -153,7 +129,7 @@ async def test_async_setup_entry_not_signed_in_loads_platforms( async def test_async_setup_entry_connect_failure( - hass: HomeAssistant, config_entry, controller + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Connection failure raises ConfigEntryNotReady.""" config_entry.add_to_hass(hass) @@ -167,7 +143,7 @@ async def test_async_setup_entry_connect_failure( async def test_async_setup_entry_player_failure( - hass: HomeAssistant, config_entry, controller + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Failure to retrieve players/sources raises ConfigEntryNotReady.""" config_entry.add_to_hass(hass) @@ -180,7 +156,7 @@ async def test_async_setup_entry_player_failure( controller.disconnect.reset_mock() -async def test_unload_entry(hass: HomeAssistant, config_entry, controller) -> None: +async def test_unload_entry(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test entries are unloaded correctly.""" controller_manager = Mock(ControllerManager) config_entry.runtime_data = HeosRuntimeData(controller_manager, None, None, {}) @@ -197,14 +173,13 @@ async def test_unload_entry(hass: HomeAssistant, config_entry, controller) -> No async def test_update_sources_retry( hass: HomeAssistant, - config_entry, - config, - controller, + config_entry: MockConfigEntry, + controller: Heos, caplog: pytest.LogCaptureFixture, ) -> None: """Test update sources retries on failures to max attempts.""" config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, config) + assert await hass.config_entries.async_setup(config_entry.entry_id) controller.get_favorites.reset_mock() controller.get_input_sources.reset_mock() source_manager = config_entry.runtime_data.source_manager diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index ea00bc9217a..3dd5312d899 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -11,6 +11,7 @@ from pyheos import ( Heos, HeosError, MediaItem, + PlayerUpdateResult, PlayState, SignalHeosEvent, SignalType, @@ -65,25 +66,17 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -async def setup_platform( - hass: HomeAssistant, config_entry: MockConfigEntry, config: dict[str, Any] -) -> None: - """Set up the media player platform for testing.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - +@pytest.mark.usefixtures("controller") async def test_state_attributes( - hass: HomeAssistant, config_entry, config, controller + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Tests the state attributes.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) state = hass.states.get("media_player.test_player") assert state.state == STATE_IDLE assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.25 @@ -119,10 +112,11 @@ async def test_state_attributes( async def test_updates_from_signals( - hass: HomeAssistant, config_entry, config, controller, favorites + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Tests dispatched signals update player.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] # Test player does not update for other players @@ -161,13 +155,13 @@ async def test_updates_from_signals( async def test_updates_from_connection_event( hass: HomeAssistant, - config_entry, - config, - controller, + config_entry: MockConfigEntry, + controller: Heos, caplog: pytest.LogCaptureFixture, ) -> None: """Tests player updates from connection event after connection failure.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] event = asyncio.Event() @@ -208,10 +202,14 @@ async def test_updates_from_connection_event( async def test_updates_from_sources_updated( - hass: HomeAssistant, config_entry, config, controller, input_sources + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + input_sources: Sequence[MediaItem], ) -> None: """Tests player updates from changes in sources list.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] event = asyncio.Event() @@ -233,14 +231,13 @@ async def test_updates_from_sources_updated( async def test_updates_from_players_changed( hass: HomeAssistant, - config_entry, - config, - controller, - change_data, - caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, + controller: Heos, + change_data: PlayerUpdateResult, ) -> None: """Test player updates from changes to available players.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] event = asyncio.Event() @@ -263,14 +260,13 @@ async def test_updates_from_players_changed_new_ids( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, - config_entry, - config, - controller, - change_data_mapped_ids, - caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, + controller: Heos, + change_data_mapped_ids: PlayerUpdateResult, ) -> None: """Test player updates from changes to available players.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] event = asyncio.Event() @@ -306,10 +302,11 @@ async def test_updates_from_players_changed_new_ids( async def test_updates_from_user_changed( - hass: HomeAssistant, config_entry, config, controller + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Tests player updates from changes in user.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] event = asyncio.Event() @@ -660,10 +657,14 @@ async def test_volume_set_error( async def test_select_favorite( - hass: HomeAssistant, config_entry, config, controller, favorites + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + favorites: dict[int, MediaItem], ) -> None: """Tests selecting a music service favorite and state.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] # Test set music service preset favorite = favorites[1] @@ -685,10 +686,14 @@ async def test_select_favorite( async def test_select_radio_favorite( - hass: HomeAssistant, config_entry, config, controller, favorites + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + favorites: dict[int, MediaItem], ) -> None: """Tests selecting a radio favorite and state.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] # Test set radio preset favorite = favorites[2] @@ -740,10 +745,14 @@ async def test_select_radio_favorite_command_error( async def test_select_input_source( - hass: HomeAssistant, config_entry, config, controller, input_sources + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + input_sources: Sequence[MediaItem], ) -> None: """Tests selecting input source and state.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] # Test proper service called input_source = input_sources[0] @@ -815,12 +824,14 @@ async def test_select_input_command_error( player.play_input_source.assert_called_once_with(input_source.media_id) +@pytest.mark.usefixtures("controller") async def test_unload_config_entry( - hass: HomeAssistant, config_entry, config, controller + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test the player is set unavailable when the config entry is unloaded.""" - await setup_platform(hass, config_entry, config) - await hass.config_entries.async_unload(config_entry.entry_id) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_unload(config_entry.entry_id) assert hass.states.get("media_player.test_player").state == STATE_UNAVAILABLE @@ -1112,14 +1123,13 @@ async def test_media_player_join_group_error( async def test_media_player_group_members( hass: HomeAssistant, - config_entry, - config, - controller, + config_entry: MockConfigEntry, + controller: Heos, caplog: pytest.LogCaptureFixture, ) -> None: """Test group_members attribute.""" - await setup_platform(hass, config_entry, config) - await hass.async_block_till_done() + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player_entity = hass.states.get("media_player.test_player") assert player_entity.attributes[ATTR_GROUP_MEMBERS] == [ "media_player.test_player", @@ -1131,15 +1141,14 @@ async def test_media_player_group_members( async def test_media_player_group_members_error( hass: HomeAssistant, - config_entry, - config, - controller, + config_entry: MockConfigEntry, + controller: Heos, caplog: pytest.LogCaptureFixture, ) -> None: """Test error in HEOS API.""" controller.get_groups.side_effect = HeosError("error") - await setup_platform(hass, config_entry, config) - await hass.async_block_till_done() + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) assert "Unable to get HEOS group info" in caplog.text player_entity = hass.states.get("media_player.test_player") assert player_entity.attributes[ATTR_GROUP_MEMBERS] == [] diff --git a/tests/components/heos/test_services.py b/tests/components/heos/test_services.py index 175e072e8e7..92ecc1f179d 100644 --- a/tests/components/heos/test_services.py +++ b/tests/components/heos/test_services.py @@ -1,6 +1,6 @@ """Tests for the services module.""" -from pyheos import CommandAuthenticationError, HeosError +from pyheos import CommandAuthenticationError, Heos, HeosError import pytest from homeassistant.components.heos.const import ( @@ -12,21 +12,16 @@ from homeassistant.components.heos.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -async def setup_component(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: - """Set up the component for testing.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - -async def test_sign_in(hass: HomeAssistant, config_entry, controller) -> None: +async def test_sign_in( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: """Test the sign-in service.""" - await setup_component(hass, config_entry) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.services.async_call( DOMAIN, @@ -39,10 +34,15 @@ async def test_sign_in(hass: HomeAssistant, config_entry, controller) -> None: async def test_sign_in_failed( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + caplog: pytest.LogCaptureFixture, ) -> None: """Test sign-in service logs error when not connected.""" - await setup_component(hass, config_entry) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.sign_in.side_effect = CommandAuthenticationError( "", "Invalid credentials", 6 ) @@ -59,10 +59,15 @@ async def test_sign_in_failed( async def test_sign_in_unknown_error( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + caplog: pytest.LogCaptureFixture, ) -> None: """Test sign-in service logs error for failure.""" - await setup_component(hass, config_entry) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.sign_in.side_effect = HeosError() await hass.services.async_call( @@ -76,10 +81,14 @@ async def test_sign_in_unknown_error( assert "Unable to sign in" in caplog.text -async def test_sign_in_not_loaded_raises(hass: HomeAssistant, config_entry) -> None: +@pytest.mark.usefixtures("controller") +async def test_sign_in_not_loaded_raises( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test the sign-in service when entry not loaded raises exception.""" - await setup_component(hass, config_entry) - await hass.config_entries.async_unload(config_entry.entry_id) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_unload(config_entry.entry_id) with pytest.raises(HomeAssistantError, match="The HEOS integration is not loaded"): await hass.services.async_call( @@ -90,29 +99,39 @@ async def test_sign_in_not_loaded_raises(hass: HomeAssistant, config_entry) -> N ) -async def test_sign_out(hass: HomeAssistant, config_entry, controller) -> None: +async def test_sign_out( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: """Test the sign-out service.""" - await setup_component(hass, config_entry) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) assert controller.sign_out.call_count == 1 -async def test_sign_out_not_loaded_raises(hass: HomeAssistant, config_entry) -> None: +async def test_sign_out_not_loaded_raises( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test the sign-out service when entry not loaded raises exception.""" - await setup_component(hass, config_entry) - await hass.config_entries.async_unload(config_entry.entry_id) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_unload(config_entry.entry_id) with pytest.raises(HomeAssistantError, match="The HEOS integration is not loaded"): await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) async def test_sign_out_unknown_error( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the sign-out service.""" - await setup_component(hass, config_entry) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) controller.sign_out.side_effect = HeosError() await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) From 11d44e608baecaa93d831c0153c7232a8441d51e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 20 Jan 2025 22:11:20 +0000 Subject: [PATCH 0753/2987] Add additional entities for Shelly BLU TRV (#135244) * Add valve position sensor * Add valve position and external sensor temperature numbers * Fix method name * Better name * Add remove condition * Add calibration binary sensor * Add battery and signal strength sensors * Remove condition from ShellyRpcEntity * Typo * Add get_entity_class helper * Add tests * Use snapshots in tests --- .../components/shelly/binary_sensor.py | 53 ++++-- homeassistant/components/shelly/entity.py | 25 ++- homeassistant/components/shelly/icons.json | 6 + homeassistant/components/shelly/number.py | 153 +++++++++++++----- homeassistant/components/shelly/sensor.py | 116 +++++++++---- tests/components/shelly/conftest.py | 2 + .../shelly/snapshots/test_binary_sensor.ambr | 48 ++++++ .../shelly/snapshots/test_number.ambr | 113 +++++++++++++ .../shelly/snapshots/test_sensor.ambr | 153 ++++++++++++++++++ tests/components/shelly/test_binary_sensor.py | 22 ++- tests/components/shelly/test_number.py | 25 +++ tests/components/shelly/test_sensor.py | 21 +++ 12 files changed, 647 insertions(+), 90 deletions(-) create mode 100644 tests/components/shelly/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/shelly/snapshots/test_number.ambr create mode 100644 tests/components/shelly/snapshots/test_sensor.ambr diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 556274aa51a..108a8236733 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -15,11 +15,12 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import CONF_SLEEP_PERIOD -from .coordinator import ShellyConfigEntry +from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, RestEntityDescription, @@ -59,6 +60,36 @@ class RestBinarySensorDescription(RestEntityDescription, BinarySensorEntityDescr """Class to describe a REST binary sensor.""" +class RpcBinarySensor(ShellyRpcAttributeEntity, BinarySensorEntity): + """Represent a RPC binary sensor entity.""" + + entity_description: RpcBinarySensorDescription + + @property + def is_on(self) -> bool: + """Return true if RPC sensor state is on.""" + return bool(self.attribute_value) + + +class RpcBluTrvBinarySensor(RpcBinarySensor): + """Represent a RPC BluTrv binary sensor.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcBinarySensorDescription, + ) -> None: + """Initialize.""" + + super().__init__(coordinator, key, attribute, description) + ble_addr: str = coordinator.device.config[key]["addr"] + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)} + ) + + SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = { ("device", "overtemp"): BlockBinarySensorDescription( key="device|overtemp", @@ -232,6 +263,15 @@ RPC_SENSORS: Final = { sub_key="value", has_entity_name=True, ), + "calibration": RpcBinarySensorDescription( + key="blutrv", + sub_key="errors", + name="Calibration", + device_class=BinarySensorDeviceClass.PROBLEM, + value=lambda status, _: False if status is None else "not_calibrated" in status, + entity_category=EntityCategory.DIAGNOSTIC, + entity_class=RpcBluTrvBinarySensor, + ), } @@ -320,17 +360,6 @@ class RestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity): return bool(self.attribute_value) -class RpcBinarySensor(ShellyRpcAttributeEntity, BinarySensorEntity): - """Represent a RPC binary sensor entity.""" - - entity_description: RpcBinarySensorDescription - - @property - def is_on(self) -> bool: - """Return true if RPC sensor state is on.""" - return bool(self.attribute_value) - - class BlockSleepingBinarySensor( ShellySleepingBlockAttributeEntity, BinarySensorEntity, RestoreEntity ): diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index aea060e09e2..8c9044aeaff 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -196,10 +196,16 @@ def async_setup_rpc_attribute_entities( elif description.use_polling_coordinator: if not sleep_period: entities.append( - sensor_class(polling_coordinator, key, sensor_id, description) + get_entity_class(sensor_class, description)( + polling_coordinator, key, sensor_id, description + ) ) else: - entities.append(sensor_class(coordinator, key, sensor_id, description)) + entities.append( + get_entity_class(sensor_class, description)( + coordinator, key, sensor_id, description + ) + ) if not entities: return @@ -232,7 +238,9 @@ def async_restore_rpc_attribute_entities( if description := sensors.get(attribute): entities.append( - sensor_class(coordinator, key, attribute, description, entry) + get_entity_class(sensor_class, description)( + coordinator, key, attribute, description, entry + ) ) if not entities: @@ -293,6 +301,7 @@ class RpcEntityDescription(EntityDescription): supported: Callable = lambda _: False unit: Callable[[dict], str | None] | None = None options_fn: Callable[[dict], list[str]] | None = None + entity_class: Callable | None = None @dataclass(frozen=True) @@ -673,3 +682,13 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): "Entity %s comes from a sleeping device, update is not possible", self.entity_id, ) + + +def get_entity_class( + sensor_class: Callable, description: RpcEntityDescription +) -> Callable: + """Return entity class.""" + if description.entity_class is not None: + return description.entity_class + + return sensor_class diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json index 1baf61acf3b..f93abf6b854 100644 --- a/homeassistant/components/shelly/icons.json +++ b/homeassistant/components/shelly/icons.json @@ -12,6 +12,9 @@ } }, "number": { + "external_temperature": { + "default": "mdi:thermometer-check" + }, "valve_position": { "default": "mdi:pipe-valve" } @@ -29,6 +32,9 @@ "tilt": { "default": "mdi:angle-acute" }, + "valve_position": { + "default": "mdi:pipe-valve" + }, "valve_status": { "default": "mdi:valve" } diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 2aed38fb723..fb61c885423 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -18,9 +18,10 @@ from homeassistant.components.number import ( NumberMode, RestoreNumber, ) -from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry @@ -57,6 +58,74 @@ class RpcNumberDescription(RpcEntityDescription, NumberEntityDescription): min_fn: Callable[[dict], float] | None = None step_fn: Callable[[dict], float] | None = None mode_fn: Callable[[dict], NumberMode] | None = None + method: str + method_params_fn: Callable[[int, float], dict] + + +class RpcNumber(ShellyRpcAttributeEntity, NumberEntity): + """Represent a RPC number entity.""" + + entity_description: RpcNumberDescription + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcNumberDescription, + ) -> None: + """Initialize sensor.""" + super().__init__(coordinator, key, attribute, description) + + if description.max_fn is not None: + self._attr_native_max_value = description.max_fn( + coordinator.device.config[key] + ) + if description.min_fn is not None: + self._attr_native_min_value = description.min_fn( + coordinator.device.config[key] + ) + if description.step_fn is not None: + self._attr_native_step = description.step_fn(coordinator.device.config[key]) + if description.mode_fn is not None: + self._attr_mode = description.mode_fn(coordinator.device.config[key]) + + @property + def native_value(self) -> float | None: + """Return value of number.""" + if TYPE_CHECKING: + assert isinstance(self.attribute_value, float | None) + + return self.attribute_value + + async def async_set_native_value(self, value: float) -> None: + """Change the value.""" + if TYPE_CHECKING: + assert isinstance(self._id, int) + + await self.call_rpc( + self.entity_description.method, + self.entity_description.method_params_fn(self._id, value), + ) + + +class RpcBluTrvNumber(RpcNumber): + """Represent a RPC BluTrv number.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcNumberDescription, + ) -> None: + """Initialize.""" + + super().__init__(coordinator, key, attribute, description) + ble_addr: str = coordinator.device.config[key]["addr"] + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)} + ) NUMBERS: dict[tuple[str, str], BlockNumberDescription] = { @@ -78,6 +147,25 @@ NUMBERS: dict[tuple[str, str], BlockNumberDescription] = { RPC_NUMBERS: Final = { + "external_temperature": RpcNumberDescription( + key="blutrv", + sub_key="current_C", + translation_key="external_temperature", + name="External temperature", + native_min_value=-50, + native_max_value=50, + native_step=0.1, + mode=NumberMode.BOX, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + method="BluTRV.Call", + method_params_fn=lambda idx, value: { + "id": idx, + "method": "Trv.SetExternalTemperature", + "params": {"id": 0, "t_C": value}, + }, + entity_class=RpcBluTrvNumber, + ), "number": RpcNumberDescription( key="number", sub_key="value", @@ -92,6 +180,28 @@ RPC_NUMBERS: Final = { unit=lambda config: config["meta"]["ui"]["unit"] if config["meta"]["ui"]["unit"] else None, + method="Number.Set", + method_params_fn=lambda idx, value: {"id": idx, "value": value}, + ), + "valve_position": RpcNumberDescription( + key="blutrv", + sub_key="pos", + translation_key="valve_position", + name="Valve position", + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_unit_of_measurement=PERCENTAGE, + method="BluTRV.Call", + method_params_fn=lambda idx, value: { + "id": idx, + "method": "Trv.SetPosition", + "params": {"id": 0, "pos": value}, + }, + removal_condition=lambda config, _status, key: config[key].get("enable", True) + is True, + entity_class=RpcBluTrvNumber, ), } @@ -190,44 +300,3 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber): ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() - - -class RpcNumber(ShellyRpcAttributeEntity, NumberEntity): - """Represent a RPC number entity.""" - - entity_description: RpcNumberDescription - - def __init__( - self, - coordinator: ShellyRpcCoordinator, - key: str, - attribute: str, - description: RpcNumberDescription, - ) -> None: - """Initialize sensor.""" - super().__init__(coordinator, key, attribute, description) - - if description.max_fn is not None: - self._attr_native_max_value = description.max_fn( - coordinator.device.config[key] - ) - if description.min_fn is not None: - self._attr_native_min_value = description.min_fn( - coordinator.device.config[key] - ) - if description.step_fn is not None: - self._attr_native_step = description.step_fn(coordinator.device.config[key]) - if description.mode_fn is not None: - self._attr_mode = description.mode_fn(coordinator.device.config[key]) - - @property - def native_value(self) -> float | None: - """Return value of number.""" - if TYPE_CHECKING: - assert isinstance(self.attribute_value, float | None) - - return self.attribute_value - - async def async_set_native_value(self, value: float) -> None: - """Change the value.""" - await self.call_rpc("Number.Set", {"id": self._id, "value": value}) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 139a427f087..6d000556cf3 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -33,6 +33,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType @@ -76,6 +77,57 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): """Class to describe a REST sensor.""" +class RpcSensor(ShellyRpcAttributeEntity, SensorEntity): + """Represent a RPC sensor.""" + + entity_description: RpcSensorDescription + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcSensorDescription, + ) -> None: + """Initialize select.""" + super().__init__(coordinator, key, attribute, description) + + if self.option_map: + self._attr_options = list(self.option_map.values()) + + @property + def native_value(self) -> StateType: + """Return value of sensor.""" + attribute_value = self.attribute_value + + if not self.option_map: + return attribute_value + + if not isinstance(attribute_value, str): + return None + + return self.option_map[attribute_value] + + +class RpcBluTrvSensor(RpcSensor): + """Represent a RPC BluTrv sensor.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcSensorDescription, + ) -> None: + """Initialize.""" + + super().__init__(coordinator, key, attribute, description) + ble_addr: str = coordinator.device.config[key]["addr"] + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)} + ) + + SENSORS: dict[tuple[str, str], BlockSensorDescription] = { ("device", "battery"): BlockSensorDescription( key="device|battery", @@ -1222,6 +1274,38 @@ RPC_SENSORS: Final = { options_fn=lambda config: config["options"], device_class=SensorDeviceClass.ENUM, ), + "valve_position": RpcSensorDescription( + key="blutrv", + sub_key="pos", + name="Valve position", + translation_key="valve_position", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + removal_condition=lambda config, _status, key: config[key].get("enable", False) + is False, + entity_class=RpcBluTrvSensor, + ), + "blutrv_battery": RpcSensorDescription( + key="blutrv", + sub_key="battery", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_class=RpcBluTrvSensor, + ), + "blutrv_rssi": RpcSensorDescription( + key="blutrv", + sub_key="rssi", + name="Signal strength", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_class=RpcBluTrvSensor, + ), } @@ -1327,38 +1411,6 @@ class RestSensor(ShellyRestAttributeEntity, SensorEntity): return self.attribute_value -class RpcSensor(ShellyRpcAttributeEntity, SensorEntity): - """Represent a RPC sensor.""" - - entity_description: RpcSensorDescription - - def __init__( - self, - coordinator: ShellyRpcCoordinator, - key: str, - attribute: str, - description: RpcSensorDescription, - ) -> None: - """Initialize select.""" - super().__init__(coordinator, key, attribute, description) - - if self.option_map: - self._attr_options = list(self.option_map.values()) - - @property - def native_value(self) -> StateType: - """Return value of sensor.""" - attribute_value = self.attribute_value - - if not self.option_map: - return attribute_value - - if not isinstance(attribute_value, str): - return None - - return self.option_map[attribute_value] - - class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, RestoreSensor): """Represent a block sleeping sensor.""" diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 7bcc1c04c6a..85cd558e918 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -255,6 +255,8 @@ MOCK_BLU_TRV_REMOTE_STATUS = { "current_C": 15.2, "target_C": 17.1, "schedule_rev": 0, + "rssi": -60, + "battery": 100, "errors": [], }, } diff --git a/tests/components/shelly/snapshots/test_binary_sensor.ambr b/tests/components/shelly/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..8dcb7b00a42 --- /dev/null +++ b/tests/components/shelly/snapshots/test_binary_sensor.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_blu_trv_binary_sensor_entity[binary_sensor.trv_name_calibration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.trv_name_calibration', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'TRV-Name calibration', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-blutrv:200-calibration', + 'unit_of_measurement': None, + }) +# --- +# name: test_blu_trv_binary_sensor_entity[binary_sensor.trv_name_calibration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'TRV-Name calibration', + }), + 'context': , + 'entity_id': 'binary_sensor.trv_name_calibration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/shelly/snapshots/test_number.ambr b/tests/components/shelly/snapshots/test_number.ambr new file mode 100644 index 00000000000..965d44698c2 --- /dev/null +++ b/tests/components/shelly/snapshots/test_number.ambr @@ -0,0 +1,113 @@ +# serializer version: 1 +# name: test_blu_trv_number_entity[number.trv_name_external_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 50, + 'min': -50, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.trv_name_external_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TRV-Name external temperature', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'external_temperature', + 'unique_id': '123456789ABC-blutrv:200-external_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_blu_trv_number_entity[number.trv_name_external_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TRV-Name external temperature', + 'max': 50, + 'min': -50, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.trv_name_external_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.2', + }) +# --- +# name: test_blu_trv_number_entity[number.trv_name_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.trv_name_valve_position', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TRV-Name valve position', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': '123456789ABC-blutrv:200-valve_position', + 'unit_of_measurement': '%', + }) +# --- +# name: test_blu_trv_number_entity[number.trv_name_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TRV-Name valve position', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.trv_name_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/shelly/snapshots/test_sensor.ambr b/tests/components/shelly/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8ab767ca889 --- /dev/null +++ b/tests/components/shelly/snapshots/test_sensor.ambr @@ -0,0 +1,153 @@ +# serializer version: 1 +# name: test_blu_trv_sensor_entity[sensor.trv_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.trv_name_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'TRV-Name battery', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-blutrv:200-blutrv_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_blu_trv_sensor_entity[sensor.trv_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'TRV-Name battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.trv_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_blu_trv_sensor_entity[sensor.trv_name_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.trv_name_signal_strength', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'TRV-Name signal strength', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-blutrv:200-blutrv_rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_blu_trv_sensor_entity[sensor.trv_name_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'TRV-Name signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.trv_name_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-60', + }) +# --- +# name: test_blu_trv_sensor_entity[sensor.trv_name_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.trv_name_valve_position', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TRV-Name valve position', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': '123456789ABC-blutrv:200-valve_position', + 'unit_of_measurement': '%', + }) +# --- +# name: test_blu_trv_sensor_entity[sensor.trv_name_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TRV-Name valve position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.trv_name_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index fadfe28db3e..ed36a43a556 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -3,9 +3,10 @@ from copy import deepcopy from unittest.mock import Mock -from aioshelly.const import MODEL_MOTION +from aioshelly.const import MODEL_BLU_GATEWAY_GEN3, MODEL_MOTION from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.shelly.const import UPDATE_PERIOD_MULTIPLIER @@ -477,3 +478,22 @@ async def test_rpc_remove_virtual_binary_sensor_when_orphaned( entry = entity_registry.async_get(entity_id) assert not entry + + +async def test_blu_trv_binary_sensor_entity( + hass: HomeAssistant, + mock_blu_trv: Mock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test BLU TRV binary sensor entity.""" + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + for entity in ("calibration",): + entity_id = f"{BINARY_SENSOR_DOMAIN}.trv_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 6c1cc394b64..2a64ab839ea 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -3,8 +3,10 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock +from aioshelly.const import MODEL_BLU_GATEWAY_GEN3 from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.number import ( ATTR_MAX, @@ -390,3 +392,26 @@ async def test_rpc_remove_virtual_number_when_orphaned( entry = entity_registry.async_get(entity_id) assert not entry + + +async def test_blu_trv_number_entity( + hass: HomeAssistant, + mock_blu_trv: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test BLU TRV number entity.""" + # disable automatic temperature control in the device + monkeypatch.setitem(mock_blu_trv.config["blutrv:200"], "enable", False) + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + for entity in ("external_temperature", "valve_position"): + entity_id = f"{NUMBER_DOMAIN}.trv_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index c62f21d9c8f..0bbb374012f 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -3,8 +3,10 @@ from copy import deepcopy from unittest.mock import Mock +from aioshelly.const import MODEL_BLU_GATEWAY_GEN3 from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, @@ -1405,3 +1407,22 @@ async def test_rpc_voltmeter_value( entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "123456789ABC-voltmeter:100-voltmeter_value" + + +async def test_blu_trv_sensor_entity( + hass: HomeAssistant, + mock_blu_trv: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test BLU TRV sensor entity.""" + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + for entity in ("battery", "signal_strength", "valve_position"): + entity_id = f"{SENSOR_DOMAIN}.trv_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") From ba2c8646e908f74a20c24003f468f1a2b4bd8e6c Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Mon, 20 Jan 2025 23:58:10 +0100 Subject: [PATCH 0754/2987] Add scheduled envoy firmware checks to enphase_envoy coordinator (#136102) * Add scheduled envoy firmware checks to enphase_envoy coordinator * Set firmware scantime to 4 hours and split test in 2 --- .../components/enphase_envoy/__init__.py | 1 + .../components/enphase_envoy/coordinator.py | 49 ++++++++++- tests/components/enphase_envoy/test_init.py | 88 ++++++++++++++++++- 3 files changed, 135 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index cdbb7080674..ba4aedf5013 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -79,6 +79,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> """Unload a config entry.""" coordinator = entry.runtime_data coordinator.async_cancel_token_refresh() + coordinator.async_cancel_firmware_refresh() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index 67f43ca64a8..d92b998e731 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -25,6 +25,7 @@ SCAN_INTERVAL = timedelta(seconds=60) TOKEN_REFRESH_CHECK_INTERVAL = timedelta(days=1) STALE_TOKEN_THRESHOLD = timedelta(days=30).total_seconds() NOTIFICATION_ID = "enphase_envoy_notification" +FIRMWARE_REFRESH_INTERVAL = timedelta(hours=4) _LOGGER = logging.getLogger(__name__) @@ -50,6 +51,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._setup_complete = False self.envoy_firmware = "" self._cancel_token_refresh: CALLBACK_TYPE | None = None + self._cancel_firmware_refresh: CALLBACK_TYPE | None = None super().__init__( hass, _LOGGER, @@ -87,10 +89,48 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): return self._async_update_saved_token() + @callback + def _async_refresh_firmware(self, now: datetime.datetime) -> None: + """Proactively check for firmware changes in Envoy.""" + self.hass.async_create_background_task( + self._async_try_refresh_firmware(), "{name} firmware refresh" + ) + + async def _async_try_refresh_firmware(self) -> None: + """Check firmware in Envoy and reload config entry if changed.""" + # envoy.setup just reads firmware, serial and partnumber from /info + try: + await self.envoy.setup() + except EnvoyError as err: + # just try again next time + _LOGGER.debug("%s: Error reading firmware: %s", err, self.name) + return + if (current_firmware := self.envoy_firmware) and current_firmware != ( + new_firmware := self.envoy.firmware + ): + self.envoy_firmware = new_firmware + _LOGGER.warning( + "Envoy firmware changed from: %s to: %s, reloading config entry %s", + current_firmware, + new_firmware, + self.name, + ) + # reload the integration to get all established again + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + @callback def _async_mark_setup_complete(self) -> None: - """Mark setup as complete and setup token refresh if needed.""" + """Mark setup as complete and setup firmware checks and token refresh if needed.""" self._setup_complete = True + self.async_cancel_firmware_refresh() + self._cancel_firmware_refresh = async_track_time_interval( + self.hass, + self._async_refresh_firmware, + FIRMWARE_REFRESH_INTERVAL, + cancel_on_shutdown=True, + ) self.async_cancel_token_refresh() if not isinstance(self.envoy.auth, EnvoyTokenAuth): return @@ -204,3 +244,10 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): if self._cancel_token_refresh: self._cancel_token_refresh() self._cancel_token_refresh = None + + @callback + def async_cancel_firmware_refresh(self) -> None: + """Cancel firmware refresh.""" + if self._cancel_firmware_refresh: + self._cancel_firmware_refresh() + self._cancel_firmware_refresh = None diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index 10cf65a298d..620bd654aca 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -1,6 +1,8 @@ """Test Enphase Envoy runtime.""" -from unittest.mock import AsyncMock, patch +from datetime import timedelta +import logging +from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory from jwt import encode @@ -15,7 +17,10 @@ from homeassistant.components.enphase_envoy.const import ( OPTION_DISABLE_KEEP_ALIVE, Platform, ) -from homeassistant.components.enphase_envoy.coordinator import SCAN_INTERVAL +from homeassistant.components.enphase_envoy.coordinator import ( + FIRMWARE_REFRESH_INTERVAL, + SCAN_INTERVAL, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, @@ -377,3 +382,82 @@ async def test_option_change_reload( OPTION_DIAGNOSTICS_INCLUDE_FIXTURES: True, OPTION_DISABLE_KEEP_ALIVE: False, } + + +def mock_envoy_setup(mock_envoy: AsyncMock): + """Mock envoy.setup.""" + mock_envoy.firmware = "9.9.9999" + + +@patch( + "homeassistant.components.enphase_envoy.coordinator.SCAN_INTERVAL", + timedelta(days=1), +) +@respx.mock +async def test_coordinator_firmware_refresh( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test coordinator scheduled firmware check.""" + await setup_integration(hass, config_entry) + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + + # Move time to next firmware check moment + # SCAN_INTERVAL is patched to 1 day to disable it's firmware detection + mock_envoy.setup.reset_mock() + freezer.tick(FIRMWARE_REFRESH_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + mock_envoy.setup.assert_called_once_with() + mock_envoy.setup.reset_mock() + + envoy = config_entry.runtime_data.envoy + assert envoy.firmware == "7.6.175" + + caplog.set_level(logging.WARNING) + + with patch( + "homeassistant.components.enphase_envoy.Envoy.setup", + MagicMock(return_value=mock_envoy_setup(mock_envoy)), + ): + freezer.tick(FIRMWARE_REFRESH_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + "Envoy firmware changed from: 7.6.175 to: 9.9.9999, reloading config entry Envoy 1234" + in caplog.text + ) + envoy = config_entry.runtime_data.envoy + assert envoy.firmware == "9.9.9999" + + +@respx.mock +async def test_coordinator_firmware_refresh_with_envoy_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test coordinator scheduled firmware check.""" + await setup_integration(hass, config_entry) + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + + caplog.set_level(logging.DEBUG) + logging.getLogger("homeassistant.components.enphase_envoy.coordinator").setLevel( + logging.DEBUG + ) + + mock_envoy.setup.side_effect = EnvoyError + freezer.tick(FIRMWARE_REFRESH_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Error reading firmware:" in caplog.text From b8ed80328a4fbe19dfa8da7cc94c82bec748c0b0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 20 Jan 2025 23:59:12 +0100 Subject: [PATCH 0755/2987] Bump holidays to 0.65 (#136122) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 09943faf0a2..edf3ebe7f04 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.64", "babel==2.15.0"] + "requirements": ["holidays==0.65", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index bb5e6333b8b..4b9d072f747 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.64"] + "requirements": ["holidays==0.65"] } diff --git a/requirements_all.txt b/requirements_all.txt index 30c1ca8fe8c..af1c5a69653 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1137,7 +1137,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.64 +holidays==0.65 # homeassistant.components.frontend home-assistant-frontend==20250109.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 901e09c9d65..bd8d93e6e34 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -966,7 +966,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.64 +holidays==0.65 # homeassistant.components.frontend home-assistant-frontend==20250109.0 From 09ef4d9b053de1bb0c9ee4b325cae5db23370c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Tue, 21 Jan 2025 00:52:21 +0100 Subject: [PATCH 0756/2987] Bump letpot to 0.3.0 (#136133) --- homeassistant/components/letpot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/letpot/manifest.json b/homeassistant/components/letpot/manifest.json index f575279fa69..691584abc13 100644 --- a/homeassistant/components/letpot/manifest.json +++ b/homeassistant/components/letpot/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "quality_scale": "bronze", - "requirements": ["letpot==0.2.0"] + "requirements": ["letpot==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index af1c5a69653..10563486c58 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1302,7 +1302,7 @@ led-ble==1.1.1 lektricowifi==0.0.43 # homeassistant.components.letpot -letpot==0.2.0 +letpot==0.3.0 # homeassistant.components.foscam libpyfoscam==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd8d93e6e34..263875fa516 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1101,7 +1101,7 @@ led-ble==1.1.1 lektricowifi==0.0.43 # homeassistant.components.letpot -letpot==0.2.0 +letpot==0.3.0 # homeassistant.components.foscam libpyfoscam==1.2.2 From 0035c7b1fe12288976046d0deddb595181c680c9 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 20 Jan 2025 16:22:50 -0800 Subject: [PATCH 0757/2987] Add myself to Roborock codeowners (#136134) --- CODEOWNERS | 4 ++-- homeassistant/components/roborock/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 09032e379fd..3553297b851 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1269,8 +1269,8 @@ build.json @home-assistant/supervisor /tests/components/rituals_perfume_genie/ @milanmeu @frenck /homeassistant/components/rmvtransport/ @cgtobi /tests/components/rmvtransport/ @cgtobi -/homeassistant/components/roborock/ @Lash-L -/tests/components/roborock/ @Lash-L +/homeassistant/components/roborock/ @Lash-L @allenporter +/tests/components/roborock/ @Lash-L @allenporter /homeassistant/components/roku/ @ctalkington /tests/components/roku/ @ctalkington /homeassistant/components/romy/ @xeniter diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index bb89ecedbe3..d104ebff12a 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -1,7 +1,7 @@ { "domain": "roborock", "name": "Roborock", - "codeowners": ["@Lash-L"], + "codeowners": ["@Lash-L", "@allenporter"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", From 24e644180614b730ed61aac7c21a6d0794ae4a0f Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Tue, 21 Jan 2025 01:47:33 +0100 Subject: [PATCH 0758/2987] Add data descriptions for enphase_envoy config flows. (#136120) --- .../components/enphase_envoy/quality_scale.yaml | 4 +--- .../components/enphase_envoy/strings.json | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/enphase_envoy/quality_scale.yaml b/homeassistant/components/enphase_envoy/quality_scale.yaml index 9e5b3a5921e..6100c91fbb4 100644 --- a/homeassistant/components/enphase_envoy/quality_scale.yaml +++ b/homeassistant/components/enphase_envoy/quality_scale.yaml @@ -9,9 +9,7 @@ rules: brands: done common-modules: done config-flow-test-coverage: done - config-flow: - status: todo - comment: Even though redundant as explained in PR133726, add data-description fields for config-flow steps + config-flow: done dependency-transparency: done docs-actions: status: done diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 9747fa35a82..fac86501df6 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -10,7 +10,9 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your Enphase Envoy gateway." + "host": "The hostname or IP address of your Enphase Envoy gateway.", + "username": "Installer or Enphase Cloud username", + "password": "blank or Enphase Cloud password" } }, "reconfigure": { @@ -21,7 +23,9 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "[%key:component::enphase_envoy::config::step::user::data_description::host%]" + "host": "[%key:component::enphase_envoy::config::step::user::data_description::host%]", + "username": "[%key:component::enphase_envoy::config::step::user::data_description::username%]", + "password": "[%key:component::enphase_envoy::config::step::user::data_description::password%]" } }, "reauth_confirm": { @@ -29,6 +33,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::enphase_envoy::config::step::user::data_description::username%]", + "password": "[%key:component::enphase_envoy::config::step::user::data_description::password%]" } } }, @@ -51,6 +59,10 @@ "data": { "diagnostics_include_fixtures": "Include test fixture data in diagnostic report. Use when requested to provide test data for troubleshooting or development activies. With this option enabled the diagnostic report may take more time to download. When report is created best disable this option again.", "disable_keep_alive": "Always use a new connection when requesting data from the Envoy. May resolve communication issues with some Envoy firmwares." + }, + "data_description": { + "diagnostics_include_fixtures": "Include fixtures in diagnostics report", + "disable_keep_alive": "May resolve communication issues with some Envoy firmwares." } } } From ac59203279bfb39eacfe33e5d33e49e74fcdca7b Mon Sep 17 00:00:00 2001 From: cdnninja Date: Mon, 20 Jan 2025 18:25:53 -0700 Subject: [PATCH 0759/2987] Remove not needed warning in Z-Wave (#136006) * Remove unneeded logging * ruff correction --- homeassistant/components/zwave_js/helpers.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 5885527e01c..904a26acc78 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -154,16 +154,8 @@ async def async_enable_server_logging_if_needed( LOGGER.info("Enabling zwave-js-server logging") if (curr_server_log_level := driver.log_config.level) and ( LOG_LEVEL_MAP[curr_server_log_level] - ) > (lib_log_level := LIB_LOGGER.getEffectiveLevel()): + ) > LIB_LOGGER.getEffectiveLevel(): entry_data = entry.runtime_data - LOGGER.warning( - ( - "Server logging is set to %s and is currently less verbose " - "than library logging, setting server log level to %s to match" - ), - curr_server_log_level, - logging.getLevelName(lib_log_level), - ) entry_data[DATA_OLD_SERVER_LOG_LEVEL] = curr_server_log_level await driver.async_update_log_config(LogConfig(level=LogLevel.DEBUG)) await driver.client.enable_server_logging() From a73ab4145af9bc1029b88c790119e316975dc2af Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jan 2025 08:02:31 +0100 Subject: [PATCH 0760/2987] Bump actions/stale from 9.0.0 to 9.1.0 (#136145) --- .github/workflows/stale.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index b51550767b8..11c87266525 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: # - No PRs marked as no-stale # - No issues (-1) - name: 60 days stale PRs policy - uses: actions/stale@v9.0.0 + uses: actions/stale@v9.1.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 60 @@ -57,7 +57,7 @@ jobs: # - No issues marked as no-stale or help-wanted # - No PRs (-1) - name: 90 days stale issues - uses: actions/stale@v9.0.0 + uses: actions/stale@v9.1.0 with: repo-token: ${{ steps.token.outputs.token }} days-before-stale: 90 @@ -87,7 +87,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@v9.0.0 + uses: actions/stale@v9.1.0 with: repo-token: ${{ steps.token.outputs.token }} only-labels: "needs-more-information" From f6b444b24b9184ea4fd474c1b7acc1306b10b6dc Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 21 Jan 2025 17:06:18 +1000 Subject: [PATCH 0761/2987] Fix buttons in Teslemetry (#136142) --- homeassistant/components/teslemetry/button.py | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py index ecdcd016221..ceeda265795 100644 --- a/homeassistant/components/teslemetry/button.py +++ b/homeassistant/components/teslemetry/button.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TeslemetryConfigEntry from .entity import TeslemetryVehicleEntity -from .helpers import handle_vehicle_command +from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryVehicleData PARALLEL_UPDATES = 0 @@ -28,24 +28,31 @@ class TeslemetryButtonEntityDescription(ButtonEntityDescription): DESCRIPTIONS: tuple[TeslemetryButtonEntityDescription, ...] = ( - TeslemetryButtonEntityDescription(key="wake", func=lambda self: self.api.wake_up()), TeslemetryButtonEntityDescription( - key="flash_lights", func=lambda self: self.api.flash_lights() + key="wake", func=lambda self: handle_command(self.api.wake_up()) ), TeslemetryButtonEntityDescription( - key="honk", func=lambda self: self.api.honk_horn() + key="flash_lights", + func=lambda self: handle_vehicle_command(self.api.flash_lights()), ), TeslemetryButtonEntityDescription( - key="enable_keyless_driving", func=lambda self: self.api.remote_start_drive() + key="honk", func=lambda self: handle_vehicle_command(self.api.honk_horn()) ), TeslemetryButtonEntityDescription( - key="boombox", func=lambda self: self.api.remote_boombox(0) + key="enable_keyless_driving", + func=lambda self: handle_vehicle_command(self.api.remote_start_drive()), + ), + TeslemetryButtonEntityDescription( + key="boombox", + func=lambda self: handle_vehicle_command(self.api.remote_boombox(0)), ), TeslemetryButtonEntityDescription( key="homelink", - func=lambda self: self.api.trigger_homelink( - lat=self.coordinator.data["drive_state_latitude"], - lon=self.coordinator.data["drive_state_longitude"], + func=lambda self: handle_vehicle_command( + self.api.trigger_homelink( + lat=self.coordinator.data["drive_state_latitude"], + lon=self.coordinator.data["drive_state_longitude"], + ) ), ), ) @@ -85,4 +92,4 @@ class TeslemetryButtonEntity(TeslemetryVehicleEntity, ButtonEntity): async def async_press(self) -> None: """Press the button.""" - await handle_vehicle_command(self.entity_description.func(self)) + await self.entity_description.func(self) From 79a43b8a503f61258b91e10d97d681fdf172fce9 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 21 Jan 2025 01:26:34 -0600 Subject: [PATCH 0762/2987] Update HEOS tests to not patch internals (#136136) --- .../components/heos/quality_scale.yaml | 11 +-- .../heos/snapshots/test_media_player.ambr | 34 +++++++++ tests/components/heos/test_config_flow.py | 11 ++- tests/components/heos/test_init.py | 76 +++++++------------ tests/components/heos/test_media_player.py | 49 +++--------- 5 files changed, 78 insertions(+), 103 deletions(-) create mode 100644 tests/components/heos/snapshots/test_media_player.ambr diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index 2d73f4d29b8..3dd6953778b 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -38,15 +38,8 @@ rules: comment: Needs to be set to 0. The underlying library handles parallel updates. reauthentication-flow: done test-coverage: - status: todo - comment: | - 1. Integration has >95% coverage, however tests need to be updated to not patch internals. - 2. test_async_setup_entry_connect_failure and test_async_setup_entry_player_failure -> Instead of - calling async_setup_entry directly, rather use hass.config_entries.async_setup and then assert - the config_entry.state is what we expect. - 3. test_unload_entry -> We should use hass.config_entries.async_unload and assert the entry state - 4. Recommend using snapshot in test_state_attributes. - 5. Find a way to avoid using internal dispatcher in test_updates_from_connection_event. + status: done + comment: 99% test coverage # Gold devices: done diagnostics: todo diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..7ade53c92ee --- /dev/null +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_state_attributes + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'http://', + 'friendly_name': 'Test Player', + 'group_members': list([ + 'media_player.test_player', + 'media_player.test_player_2', + ]), + 'is_volume_muted': False, + 'media_album_id': 1, + 'media_album_name': 'Album', + 'media_artist': 'Artist', + 'media_content_id': '1', + 'media_content_type': , + 'media_queue_id': 1, + 'media_source_id': 1, + 'media_station': 'Station Name', + 'media_title': 'Song', + 'media_type': 'Station', + 'shuffle': False, + 'source_list': list([ + "Today's Hits Radio", + 'Classical MPR (Classical Music)', + 'HEOS Drive - Line In 1', + ]), + 'supported_features': , + 'volume_level': 0.25, + }), + 'entity_id': 'media_player.test_player', + 'state': 'idle', + }) +# --- diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 21f3606f9bd..b03d75e5798 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -3,7 +3,6 @@ from pyheos import CommandAuthenticationError, CommandFailedError, Heos, HeosError import pytest -from homeassistant.components import heos from homeassistant.components.heos.const import DOMAIN from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME @@ -44,7 +43,7 @@ async def test_cannot_connect_shows_error_form( """Test form is shown with error when cannot connect.""" controller.connect.side_effect = HeosError() result = await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "127.0.0.1"} + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "127.0.0.1"} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -60,7 +59,7 @@ async def test_create_entry_when_host_valid( data = {CONF_HOST: "127.0.0.1"} result = await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_USER}, data=data + DOMAIN, context={"source": SOURCE_USER}, data=data ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == DOMAIN @@ -78,7 +77,7 @@ async def test_create_entry_when_friendly_name_valid( data = {CONF_HOST: "Office (127.0.0.1)"} result = await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_USER}, data=data + DOMAIN, context={"source": SOURCE_USER}, data=data ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -99,7 +98,7 @@ async def test_discovery_shows_create_form( # Single discovered host shows form for user to finish setup. result = await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data ) assert hass.data[DOMAIN] == {"Office (127.0.0.1)": "127.0.0.1"} assert result["type"] is FlowResultType.FORM @@ -107,7 +106,7 @@ async def test_discovery_shows_create_form( # Subsequent discovered hosts append to discovered hosts and abort. result = await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data_bedroom + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data_bedroom ) assert hass.data[DOMAIN] == { "Office (127.0.0.1)": "127.0.0.1", diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 1362722390a..f06c5709f6d 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -2,30 +2,22 @@ import asyncio from typing import cast -from unittest.mock import Mock, patch from pyheos import ( CommandFailedError, Heos, HeosError, + HeosOptions, SignalHeosEvent, SignalType, const, ) import pytest -from homeassistant.components.heos import ( - ControllerManager, - HeosOptions, - HeosRuntimeData, - async_setup_entry, - async_unload_entry, -) from homeassistant.components.heos.const import 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.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -38,18 +30,14 @@ async def test_async_setup_entry_loads_platforms( ) -> None: """Test load connects to heos, retrieves players, and loads platforms.""" config_entry.add_to_hass(hass) - with patch.object( - hass.config_entries, "async_forward_entry_setups" - ) as forward_mock: - assert await async_setup_entry(hass, config_entry) - # Assert platforms loaded - await hass.async_block_till_done() - assert forward_mock.call_count == 1 - assert controller.connect.call_count == 1 - assert controller.get_players.call_count == 1 - assert controller.get_favorites.call_count == 1 - assert controller.get_input_sources.call_count == 1 - controller.disconnect.assert_not_called() + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state == ConfigEntryState.LOADED + assert hass.states.get("media_player.test_player") is not None + assert controller.connect.call_count == 1 + assert controller.get_players.call_count == 1 + assert controller.get_favorites.call_count == 1 + assert controller.get_input_sources.call_count == 1 + controller.disconnect.assert_not_called() async def test_async_setup_entry_with_options_loads_platforms( @@ -75,7 +63,7 @@ async def test_async_setup_entry_with_options_loads_platforms( async def test_async_setup_entry_auth_failure_starts_reauth( hass: HomeAssistant, config_entry_options: MockConfigEntry, - controller: Mock, + controller: Heos, ) -> None: """Test load with auth failure starts reauth, loads platforms.""" config_entry_options.add_to_hass(hass) @@ -110,18 +98,12 @@ async def test_async_setup_entry_not_signed_in_loads_platforms( """Test setup does not retrieve favorites when not logged in.""" config_entry.add_to_hass(hass) controller._signed_in_username = None - with patch.object( - hass.config_entries, "async_forward_entry_setups" - ) as forward_mock: - assert await async_setup_entry(hass, config_entry) - # Assert platforms loaded - await hass.async_block_till_done() - assert forward_mock.call_count == 1 - assert controller.connect.call_count == 1 - assert controller.get_players.call_count == 1 - assert controller.get_favorites.call_count == 0 - assert controller.get_input_sources.call_count == 1 - controller.disconnect.assert_not_called() + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert controller.connect.call_count == 1 + assert controller.get_players.call_count == 1 + assert controller.get_favorites.call_count == 0 + assert controller.get_input_sources.call_count == 1 + controller.disconnect.assert_not_called() assert ( "The HEOS System is not logged in: Enter credentials in the integration options to access favorites and streaming services" in caplog.text @@ -134,8 +116,8 @@ async def test_async_setup_entry_connect_failure( """Connection failure raises ConfigEntryNotReady.""" config_entry.add_to_hass(hass) controller.connect.side_effect = HeosError() - with pytest.raises(ConfigEntryNotReady): - await async_setup_entry(hass, config_entry) + assert not await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state == ConfigEntryState.SETUP_RETRY assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 controller.connect.reset_mock() @@ -148,27 +130,21 @@ async def test_async_setup_entry_player_failure( """Failure to retrieve players/sources raises ConfigEntryNotReady.""" config_entry.add_to_hass(hass) controller.get_players.side_effect = HeosError() - with pytest.raises(ConfigEntryNotReady): - await async_setup_entry(hass, config_entry) + assert not await hass.config_entries.async_setup(config_entry.entry_id) assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 controller.connect.reset_mock() controller.disconnect.reset_mock() -async def test_unload_entry(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def test_unload_entry( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: """Test entries are unloaded correctly.""" - controller_manager = Mock(ControllerManager) - config_entry.runtime_data = HeosRuntimeData(controller_manager, None, None, {}) - - with patch.object( - hass.config_entries, "async_forward_entry_unload", return_value=True - ) as unload: - assert await async_unload_entry(hass, config_entry) - await hass.async_block_till_done() - assert controller_manager.disconnect.call_count == 1 - assert unload.call_count == 1 - assert DOMAIN not in hass.data + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_unload(config_entry.entry_id) + assert controller.disconnect.call_count == 1 async def test_update_sources_retry( diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 3dd5312d899..98f701d423a 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -18,15 +18,14 @@ from pyheos import ( const, ) import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props -from homeassistant.components.heos import media_player from homeassistant.components.heos.const import DOMAIN, SIGNAL_HEOS_UPDATED from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, - ATTR_MEDIA_ALBUM_NAME, - ATTR_MEDIA_ARTIST, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_DURATION, @@ -34,7 +33,6 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_SHUFFLE, - ATTR_MEDIA_TITLE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MEDIA_PLAYER_DOMAIN, @@ -43,13 +41,10 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, SERVICE_UNJOIN, - MediaPlayerEntityFeature, MediaType, ) from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_SUPPORTED_FEATURES, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, @@ -72,42 +67,20 @@ from tests.common import MockConfigEntry @pytest.mark.usefixtures("controller") async def test_state_attributes( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, snapshot: SnapshotAssertion ) -> None: """Tests the state attributes.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) state = hass.states.get("media_player.test_player") - assert state.state == STATE_IDLE - assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.25 - assert not state.attributes[ATTR_MEDIA_VOLUME_MUTED] - assert state.attributes[ATTR_MEDIA_CONTENT_ID] == "1" - assert state.attributes[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert ATTR_MEDIA_DURATION not in state.attributes - assert ATTR_MEDIA_POSITION not in state.attributes - assert state.attributes[ATTR_MEDIA_TITLE] == "Song" - assert state.attributes[ATTR_MEDIA_ARTIST] == "Artist" - assert state.attributes[ATTR_MEDIA_ALBUM_NAME] == "Album" - assert not state.attributes[ATTR_MEDIA_SHUFFLE] - assert state.attributes["media_album_id"] == 1 - assert state.attributes["media_queue_id"] == 1 - assert state.attributes["media_source_id"] == 1 - assert state.attributes["media_station"] == "Station Name" - assert state.attributes["media_type"] == "Station" - assert state.attributes[ATTR_FRIENDLY_NAME] == "Test Player" - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == MediaPlayerEntityFeature.PLAY - | MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.STOP - | MediaPlayerEntityFeature.NEXT_TRACK - | MediaPlayerEntityFeature.PREVIOUS_TRACK - | media_player.BASE_SUPPORTED_FEATURES - ) - assert ATTR_INPUT_SOURCE not in state.attributes - assert ( - state.attributes[ATTR_INPUT_SOURCE_LIST] - == config_entry.runtime_data.source_manager.source_list + assert state == snapshot( + exclude=props( + "entity_picture_local", + "context", + "last_changed", + "last_reported", + "last_updated", + ) ) From fb4df00e3c48df29413251a130a5ac7b614ad65c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jan 2025 08:27:41 +0100 Subject: [PATCH 0763/2987] Add support for custom weekly backup schedule (#136079) * Add support for custom weekly backup schedule * Rename the new flag to custom_days * Make the store change backwards compatible * Improve comments --- homeassistant/components/backup/config.py | 55 +- homeassistant/components/backup/store.py | 8 +- homeassistant/components/backup/websocket.py | 17 +- .../backup/snapshots/test_store.ambr | 3 + .../backup/snapshots/test_websocket.ambr | 664 +++++++++++++++--- tests/components/backup/test_websocket.py | 244 +++++-- 6 files changed, 831 insertions(+), 160 deletions(-) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 997813eca21..da6a2b85ccb 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -81,6 +81,7 @@ class BackupConfigData: time = dt_util.parse_time(time_str) else: time = None + days = [Day(day) for day in data["schedule"]["days"]] return cls( create_backup=CreateBackupConfig( @@ -99,7 +100,10 @@ class BackupConfigData: days=retention["days"], ), schedule=BackupSchedule( - state=ScheduleState(data["schedule"]["state"]), time=time + days=days, + recurrence=ScheduleRecurrence(data["schedule"]["recurrence"]), + state=ScheduleState(data["schedule"]["state"]), + time=time, ), ) @@ -252,6 +256,8 @@ class RetentionParametersDict(TypedDict, total=False): class StoredBackupSchedule(TypedDict): """Represent the stored backup schedule configuration.""" + days: list[Day] + recurrence: ScheduleRecurrence state: ScheduleState time: str | None @@ -259,13 +265,38 @@ class StoredBackupSchedule(TypedDict): class ScheduleParametersDict(TypedDict, total=False): """Represent parameters for backup schedule.""" + days: list[Day] + recurrence: ScheduleRecurrence state: ScheduleState time: dt.time | None -class ScheduleState(StrEnum): +class Day(StrEnum): + """Represent the day(s) in a custom schedule recurrence.""" + + MONDAY = "mon" + TUESDAY = "tue" + WEDNESDAY = "wed" + THURSDAY = "thu" + FRIDAY = "fri" + SATURDAY = "sat" + SUNDAY = "sun" + + +class ScheduleRecurrence(StrEnum): """Represent the schedule recurrence.""" + NEVER = "never" + DAILY = "daily" + CUSTOM_DAYS = "custom_days" + + +class ScheduleState(StrEnum): + """Represent the schedule recurrence. + + This is deprecated and can be remove in HA Core 2025.8. + """ + NEVER = "never" DAILY = "daily" MONDAY = "mon" @@ -281,6 +312,10 @@ class ScheduleState(StrEnum): class BackupSchedule: """Represent the backup schedule.""" + days: list[Day] = field(default_factory=list) + recurrence: ScheduleRecurrence = ScheduleRecurrence.NEVER + # Although no longer used, state is kept for backwards compatibility. + # It can be removed in HA Core 2025.8. state: ScheduleState = ScheduleState.NEVER time: dt.time | None = None cron_event: CronSim | None = field(init=False, default=None) @@ -293,22 +328,26 @@ class BackupSchedule: ) -> None: """Apply a new schedule. - There are only three possible state types: never, daily, or weekly. + There are only three possible recurrence types: never, daily, or custom_days """ - if self.state is ScheduleState.NEVER: + if self.recurrence is ScheduleRecurrence.NEVER or ( + self.recurrence is ScheduleRecurrence.CUSTOM_DAYS and not self.days + ): self._unschedule_next(manager) return time = self.time if self.time is not None else DEFAULT_BACKUP_TIME - if self.state is ScheduleState.DAILY: + if self.recurrence is ScheduleRecurrence.DAILY: self._schedule_next( CRON_PATTERN_DAILY.format(m=time.minute, h=time.hour), manager, ) - else: + else: # ScheduleRecurrence.CUSTOM_DAYS self._schedule_next( CRON_PATTERN_WEEKLY.format( - m=time.minute, h=time.hour, d=self.state.value + m=time.minute, + h=time.hour, + d=",".join(day.value for day in self.days), ), manager, ) @@ -376,6 +415,8 @@ class BackupSchedule: def to_dict(self) -> StoredBackupSchedule: """Convert backup schedule to a dict.""" return StoredBackupSchedule( + days=self.days, + recurrence=self.recurrence, state=self.state, time=self.time.isoformat() if self.time else None, ) diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 205bdf80375..b8241bb771d 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -48,8 +48,14 @@ class _BackupStore(Store[StoredBackupData]): data = old_data if old_major_version == 1: if old_minor_version < 2: - # Version 1.2 adds configurable backup time + # Version 1.2 adds configurable backup time and custom days data["config"]["schedule"]["time"] = None + if (state := data["config"]["schedule"]["state"]) in ("daily", "never"): + data["config"]["schedule"]["days"] = [] + data["config"]["schedule"]["recurrence"] = state + else: + data["config"]["schedule"]["days"] = [state] + data["config"]["schedule"]["recurrence"] = "custom_days" if old_major_version > 1: raise NotImplementedError diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 235d53952c1..672dd5ebb13 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -8,7 +8,7 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from .config import ScheduleState +from .config import Day, ScheduleRecurrence from .const import DATA_MANAGER, LOGGER from .manager import ( DecryptOnDowloadNotSupported, @@ -320,13 +320,17 @@ async def handle_config_info( ) -> None: """Send the stored backup config.""" manager = hass.data[DATA_MANAGER] + config = manager.config.data.to_dict() + # Remove state from schedule, it's not needed in the frontend + # mypy doesn't like deleting from TypedDict, ignore it + del config["schedule"]["state"] # type: ignore[misc] connection.send_result( msg["id"], { - "config": manager.config.data.to_dict() + "config": config | { "next_automatic_backup": manager.config.data.schedule.next_automatic_backup - }, + } }, ) @@ -358,7 +362,12 @@ async def handle_config_info( ), vol.Optional("schedule"): vol.Schema( { - vol.Optional("state"): vol.All(str, vol.Coerce(ScheduleState)), + vol.Optional("days"): vol.Any( + vol.All([vol.Coerce(Day)], vol.Unique()), + ), + vol.Optional("recurrence"): vol.All( + str, vol.Coerce(ScheduleRecurrence) + ), vol.Optional("time"): vol.Any(cv.time, None), } ), diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index fb5d0c276b5..a66bbe43b85 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -28,6 +28,9 @@ 'days': None, }), 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', 'state': 'never', 'time': None, }), diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 8b0ab1317c3..2c88dc50577 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -250,7 +250,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -287,7 +289,16 @@ 'days': 7, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + 'mon', + 'tue', + 'wed', + 'thu', + 'fri', + 'sat', + 'sun', + ]), + 'recurrence': 'custom_days', 'time': None, }), }), @@ -320,7 +331,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -353,7 +366,9 @@ 'days': 7, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -386,7 +401,10 @@ 'days': None, }), 'schedule': dict({ - 'state': 'mon', + 'days': list([ + 'mon', + ]), + 'recurrence': 'custom_days', 'time': None, }), }), @@ -413,13 +431,52 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'next_automatic_backup': '2024-11-16T04:55:00+01:00', + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'sat', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_info[storage_data6] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', 'time': None, }), }), @@ -451,7 +508,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -484,7 +543,9 @@ 'days': 7, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -517,6 +578,9 @@ 'days': 7, }), 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', 'state': 'never', 'time': None, }), @@ -550,7 +614,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -579,11 +645,13 @@ 'last_completed_automatic_backup': None, 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ - 'copies': None, - 'days': 7, + 'copies': 3, + 'days': None, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'daily', 'time': None, }), }), @@ -611,12 +679,121 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_config_update[command11] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command11].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': 7, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'daily', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command11].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', 'time': None, }), }), @@ -649,7 +826,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -682,7 +861,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'daily', 'time': '06:00:00', }), }), @@ -715,7 +896,10 @@ 'days': None, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', 'time': '06:00:00', }), }), @@ -748,7 +932,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -781,7 +967,10 @@ 'days': None, }), 'schedule': dict({ - 'state': 'mon', + 'days': list([ + 'mon', + ]), + 'recurrence': 'custom_days', 'time': None, }), }), @@ -814,7 +1003,11 @@ 'days': None, }), 'schedule': dict({ - 'state': 'mon', + 'days': list([ + 'mon', + ]), + 'recurrence': 'custom_days', + 'state': 'never', 'time': None, }), }), @@ -847,7 +1040,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -880,7 +1075,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -913,6 +1110,9 @@ 'days': None, }), 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', 'state': 'never', 'time': None, }), @@ -946,7 +1146,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -964,26 +1166,26 @@ 'agent_ids': list([ 'test-agent', ]), - 'include_addons': list([ - 'test-addon', - ]), + 'include_addons': None, 'include_all_addons': False, 'include_database': True, - 'include_folders': list([ - 'media', - ]), - 'name': 'test-name', - 'password': 'test-password', + 'include_folders': None, + 'name': None, + 'password': None, }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', 'time': None, }), }), @@ -1002,16 +1204,12 @@ 'agent_ids': list([ 'test-agent', ]), - 'include_addons': list([ - 'test-addon', - ]), + 'include_addons': None, 'include_all_addons': False, 'include_database': True, - 'include_folders': list([ - 'media', - ]), - 'name': 'test-name', - 'password': 'test-password', + 'include_folders': None, + 'name': None, + 'password': None, }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, @@ -1020,7 +1218,12 @@ 'days': None, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'state': 'never', 'time': None, }), }), @@ -1053,7 +1256,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -1071,22 +1276,28 @@ 'agent_ids': list([ 'test-agent', ]), - 'include_addons': None, + 'include_addons': list([ + 'test-addon', + ]), 'include_all_addons': False, 'include_database': True, - 'include_folders': None, - 'name': None, - 'password': None, + 'include_folders': list([ + 'media', + ]), + 'name': 'test-name', + 'password': 'test-password', }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ - 'copies': 3, - 'days': 7, + 'copies': None, + 'days': None, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'daily', 'time': None, }), }), @@ -1105,21 +1316,28 @@ 'agent_ids': list([ 'test-agent', ]), - 'include_addons': None, + 'include_addons': list([ + 'test-addon', + ]), 'include_all_addons': False, 'include_database': True, - 'include_folders': None, - 'name': None, - 'password': None, + 'include_folders': list([ + 'media', + ]), + 'name': 'test-name', + 'password': 'test-password', }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'retention': dict({ - 'copies': 3, - 'days': 7, + 'copies': None, + 'days': None, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', 'time': None, }), }), @@ -1152,7 +1370,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -1181,11 +1401,13 @@ 'last_completed_automatic_backup': None, 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ - 'copies': None, - 'days': None, + 'copies': 3, + 'days': 7, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'daily', 'time': None, }), }), @@ -1214,11 +1436,14 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'retention': dict({ - 'copies': None, - 'days': None, + 'copies': 3, + 'days': 7, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', 'time': None, }), }), @@ -1251,7 +1476,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -1280,11 +1507,13 @@ 'last_completed_automatic_backup': None, 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ - 'copies': 3, + 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'daily', 'time': None, }), }), @@ -1313,11 +1542,14 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'retention': dict({ - 'copies': 3, + 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', 'time': None, }), }), @@ -1350,7 +1582,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -1379,11 +1613,13 @@ 'last_completed_automatic_backup': None, 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ - 'copies': None, - 'days': 7, + 'copies': 3, + 'days': None, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'daily', 'time': None, }), }), @@ -1412,11 +1648,14 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'retention': dict({ - 'copies': None, - 'days': 7, + 'copies': 3, + 'days': None, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', 'time': None, }), }), @@ -1449,7 +1688,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -1478,11 +1719,13 @@ 'last_completed_automatic_backup': None, 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ - 'copies': 3, - 'days': None, + 'copies': None, + 'days': 7, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'daily', 'time': None, }), }), @@ -1511,11 +1754,14 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'retention': dict({ - 'copies': 3, - 'days': None, + 'copies': None, + 'days': 7, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', 'time': None, }), }), @@ -1548,7 +1794,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -1580,7 +1828,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -1612,7 +1862,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -1644,7 +1896,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -1676,7 +1930,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -1708,7 +1964,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -1740,7 +1998,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -1772,7 +2032,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -1804,7 +2066,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -1836,7 +2100,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -1868,7 +2134,9 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), @@ -1900,7 +2168,213 @@ 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command6] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command6].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command7] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command7].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command8] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command8].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', 'time': None, }), }), diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 29ce4dc485e..44a470053a5 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -71,9 +71,10 @@ DEFAULT_STORAGE_DATA: dict[str, Any] = { "copies": None, "days": None, }, - "schedule": {"state": "never", "time": None}, + "schedule": {"days": [], "recurrence": "never", "state": "never", "time": None}, }, } +DAILY = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] @pytest.fixture @@ -924,7 +925,12 @@ async def test_agents_info( "retention": {"copies": 3, "days": 7}, "last_attempted_automatic_backup": "2024-10-26T04:45:00+01:00", "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", - "schedule": {"state": "daily", "time": None}, + "schedule": { + "days": DAILY, + "recurrence": "custom_days", + "state": "never", + "time": None, + }, }, }, "key": DOMAIN, @@ -949,7 +955,12 @@ async def test_agents_info( "retention": {"copies": 3, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "schedule": {"state": "never", "time": None}, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, }, }, "key": DOMAIN, @@ -974,7 +985,12 @@ async def test_agents_info( "retention": {"copies": None, "days": 7}, "last_attempted_automatic_backup": "2024-10-27T04:45:00+01:00", "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", - "schedule": {"state": "never", "time": None}, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, }, }, "key": DOMAIN, @@ -999,7 +1015,12 @@ async def test_agents_info( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "schedule": {"state": "mon", "time": None}, + "schedule": { + "days": ["mon"], + "recurrence": "custom_days", + "state": "never", + "time": None, + }, }, }, "key": DOMAIN, @@ -1024,7 +1045,42 @@ async def test_agents_info( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "schedule": {"state": "sat", "time": None}, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": ["mon", "sun"], + "recurrence": "custom_days", + "state": "never", + "time": None, + }, }, }, "key": DOMAIN, @@ -1069,17 +1125,22 @@ async def test_config_info( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": {"state": "daily", "time": "06:00"}, + "schedule": {"recurrence": "daily", "time": "06:00"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": {"state": "mon"}, + "schedule": {"days": ["mon"], "recurrence": "custom_days"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": {"state": "never"}, + "schedule": {"recurrence": "never"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"days": ["mon", "sun"], "recurrence": "custom_days"}, }, { "type": "backup/config/update", @@ -1090,43 +1151,43 @@ async def test_config_info( "name": "test-name", "password": "test-password", }, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": 3, "days": 7}, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": None}, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 7}, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": 3}, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"days": 7}, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, }, ], ) @@ -1174,17 +1235,32 @@ async def test_config_update( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": "blah", + "recurrence": "blah", }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": {"state": "someday"}, + "recurrence": "never", }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": {"time": "early"}, + "recurrence": {"state": "someday"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "recurrence": {"time": "early"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "recurrence": {"days": "mon"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "recurrence": {"days": ["fun"]}, }, { "type": "backup/config/update", @@ -1260,7 +1336,7 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, } ], "2024-11-11T04:45:00+01:00", @@ -1279,7 +1355,7 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": {"state": "mon"}, + "schedule": {"days": ["mon"], "recurrence": "custom_days"}, } ], "2024-11-11T04:45:00+01:00", @@ -1298,7 +1374,11 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": {"state": "mon", "time": "03:45"}, + "schedule": { + "days": ["mon"], + "recurrence": "custom_days", + "time": "03:45", + }, } ], "2024-11-11T03:45:00+01:00", @@ -1317,7 +1397,7 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": {"state": "daily", "time": "03:45"}, + "schedule": {"recurrence": "daily", "time": "03:45"}, } ], "2024-11-11T03:45:00+01:00", @@ -1336,7 +1416,26 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": {"state": "never"}, + "schedule": {"days": ["wed", "fri"], "recurrence": "custom_days"}, + } + ], + "2024-11-11T04:45:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-15T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + 1, + 2, + BACKUP_CALL, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"recurrence": "never"}, } ], "2024-11-11T04:45:00+01:00", @@ -1355,7 +1454,26 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": {"state": "daily"}, + "schedule": {"days": [], "recurrence": "custom_days"}, + } + ], + "2024-11-11T04:45:00+01:00", + "2034-11-11T12:00:00+01:00", # ten years later and still no backups + "2034-11-11T13:00:00+01:00", + "2024-11-11T04:45:00+01:00", + "2024-11-11T04:45:00+01:00", + None, + 0, + 0, + None, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"recurrence": "daily"}, } ], "2024-10-26T04:45:00+01:00", @@ -1374,7 +1492,7 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": {"state": "mon"}, + "schedule": {"days": ["mon"], "recurrence": "custom_days"}, } ], "2024-10-26T04:45:00+01:00", @@ -1393,7 +1511,7 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": {"state": "never"}, + "schedule": {"recurrence": "never"}, } ], "2024-10-26T04:45:00+01:00", @@ -1412,7 +1530,7 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, } ], "2024-11-11T04:45:00+01:00", @@ -1431,7 +1549,7 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, } ], "2024-11-11T04:45:00+01:00", @@ -1483,7 +1601,12 @@ async def test_config_schedule_logic( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": last_completed_automatic_backup, "last_completed_automatic_backup": last_completed_automatic_backup, - "schedule": {"state": "daily", "time": None}, + "schedule": { + "days": [], + "recurrence": "daily", + "state": "never", + "time": None, + }, }, } hass_storage[DOMAIN] = { @@ -1553,7 +1676,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": None, "days": None}, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, }, { "backup-1": MagicMock( @@ -1592,7 +1715,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, }, { "backup-1": MagicMock( @@ -1631,7 +1754,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, }, { "backup-1": MagicMock( @@ -1660,7 +1783,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, }, { "backup-1": MagicMock( @@ -1704,7 +1827,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, }, { "backup-1": MagicMock( @@ -1748,7 +1871,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, }, { "backup-1": MagicMock( @@ -1787,7 +1910,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, }, { "backup-1": MagicMock( @@ -1826,7 +1949,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 0, "days": None}, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, }, { "backup-1": MagicMock( @@ -1870,7 +1993,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 0, "days": None}, - "schedule": {"state": "daily"}, + "schedule": {"recurrence": "daily"}, }, { "backup-1": MagicMock( @@ -1934,7 +2057,12 @@ async def test_config_retention_copies_logic( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": last_backup_time, - "schedule": {"state": "daily", "time": None}, + "schedule": { + "days": [], + "recurrence": "daily", + "state": "never", + "time": None, + }, }, } hass_storage[DOMAIN] = { @@ -2005,7 +2133,7 @@ async def test_config_retention_copies_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": None, "days": None}, - "schedule": {"state": "never"}, + "schedule": {"recurrence": "never"}, }, { "backup-1": MagicMock( @@ -2041,7 +2169,7 @@ async def test_config_retention_copies_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": {"state": "never"}, + "schedule": {"recurrence": "never"}, }, { "backup-1": MagicMock( @@ -2077,7 +2205,7 @@ async def test_config_retention_copies_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": {"state": "never"}, + "schedule": {"recurrence": "never"}, }, { "backup-1": MagicMock( @@ -2118,7 +2246,7 @@ async def test_config_retention_copies_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": {"state": "never"}, + "schedule": {"recurrence": "never"}, }, { "backup-1": MagicMock( @@ -2192,7 +2320,12 @@ async def test_config_retention_copies_logic_manual_backup( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "schedule": {"state": "daily", "time": None}, + "schedule": { + "days": [], + "recurrence": "daily", + "state": "never", + "time": None, + }, }, } hass_storage[DOMAIN] = { @@ -2320,7 +2453,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": {"state": "never"}, + "schedule": {"recurrence": "never"}, } ], { @@ -2356,7 +2489,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": {"state": "never"}, + "schedule": {"recurrence": "never"}, } ], { @@ -2392,7 +2525,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 3}, - "schedule": {"state": "never"}, + "schedule": {"recurrence": "never"}, } ], { @@ -2428,7 +2561,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": {"state": "never"}, + "schedule": {"recurrence": "never"}, } ], { @@ -2469,7 +2602,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": {"state": "never"}, + "schedule": {"recurrence": "never"}, } ], { @@ -2505,7 +2638,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": {"state": "never"}, + "schedule": {"recurrence": "never"}, } ], { @@ -2541,7 +2674,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 0}, - "schedule": {"state": "never"}, + "schedule": {"recurrence": "never"}, } ], { @@ -2613,7 +2746,12 @@ async def test_config_retention_days_logic( "retention": {"copies": None, "days": stored_retained_days}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": last_backup_time, - "schedule": {"state": "never", "time": None}, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, }, } hass_storage[DOMAIN] = { From 0254be78d6395c2da3c6e6c176d90b90f94d4f44 Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Tue, 21 Jan 2025 08:46:32 +0100 Subject: [PATCH 0764/2987] Bump Devialet to 1.5.7 (#136114) --- .../components/devialet/manifest.json | 2 +- .../components/devialet/media_player.py | 4 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../devialet/fixtures/general_info.json | 12 ++++++--- .../devialet/fixtures/source_state.json | 2 +- .../devialet/fixtures/system_info.json | 27 +++++++++++++++++-- tests/components/devialet/test_diagnostics.py | 4 ++- .../components/devialet/test_media_player.py | 4 +-- 9 files changed, 44 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/devialet/manifest.json b/homeassistant/components/devialet/manifest.json index dd30f91c835..f101a325dab 100644 --- a/homeassistant/components/devialet/manifest.json +++ b/homeassistant/components/devialet/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/devialet", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["devialet==1.4.5"], + "requirements": ["devialet==1.5.7"], "zeroconf": ["_devialet-http._tcp.local."] } diff --git a/homeassistant/components/devialet/media_player.py b/homeassistant/components/devialet/media_player.py index d490e348b9c..8789516650a 100644 --- a/homeassistant/components/devialet/media_player.py +++ b/homeassistant/components/devialet/media_player.py @@ -122,10 +122,10 @@ class DevialetMediaPlayerEntity( if self.coordinator.client.source_state is None: return features - if not self.coordinator.client.available_options: + if not self.coordinator.client.available_operations: return features - for option in self.coordinator.client.available_options: + for option in self.coordinator.client.available_operations: features |= DEVIALET_TO_HA_FEATURE_MAP.get(option, 0) return features diff --git a/requirements_all.txt b/requirements_all.txt index 10563486c58..473687aae9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -761,7 +761,7 @@ demetriek==1.2.0 denonavr==1.0.1 # homeassistant.components.devialet -devialet==1.4.5 +devialet==1.5.7 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 263875fa516..ac414f5f52a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -651,7 +651,7 @@ demetriek==1.2.0 denonavr==1.0.1 # homeassistant.components.devialet -devialet==1.4.5 +devialet==1.5.7 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.3 diff --git a/tests/components/devialet/fixtures/general_info.json b/tests/components/devialet/fixtures/general_info.json index 6ff1a724f08..3efe8ae2080 100644 --- a/tests/components/devialet/fixtures/general_info.json +++ b/tests/components/devialet/fixtures/general_info.json @@ -1,18 +1,22 @@ { + "availableFeatures": ["powerManagement"], "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", "deviceName": "Livingroom", "firmwareFamily": "DOS", "groupId": "12345678-901a-2b3c-def4-567g89h0i12j", + "installationId": "abc1eca1-1234-5251-a123-12ac1a3e9fe8", "ipControlVersion": "1", + "isSystemLeader": true, "model": "Phantom I Silver", + "modelFamily": "Phantom I", + "powerRating": "105 dB", "release": { "buildType": "release", - "canonicalVersion": "2.16.1.49152", - "version": "2.16.1" + "canonicalVersion": "2.17.6.49152", + "version": "2.17.6" }, "role": "FrontLeft", "serial": "L00P00000AB11", - "standbyEntryDelay": 0, - "standbyState": "Unknown", + "setupState": "ongoing", "systemId": "a12b345c-67d8-90e1-12f4-g5hij67890kl" } diff --git a/tests/components/devialet/fixtures/source_state.json b/tests/components/devialet/fixtures/source_state.json index d389675ac98..f24575cd264 100644 --- a/tests/components/devialet/fixtures/source_state.json +++ b/tests/components/devialet/fixtures/source_state.json @@ -1,5 +1,5 @@ { - "availableOptions": ["play", "pause", "previous", "next", "seek"], + "availableOperations": ["play", "pause", "previous", "next", "seek"], "metadata": { "album": "1 (Remastered)", "artist": "The Beatles", diff --git a/tests/components/devialet/fixtures/system_info.json b/tests/components/devialet/fixtures/system_info.json index f496e5557d2..a43b4e83901 100644 --- a/tests/components/devialet/fixtures/system_info.json +++ b/tests/components/devialet/fixtures/system_info.json @@ -1,6 +1,29 @@ { - "availableFeatures": ["nightMode", "equalizer", "balance"], + "availableFeatures": [ + "nightMode", + "equalizer", + "balance", + "preferredInterfaceControl" + ], + "devices": [ + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "isSystemLeader": true, + "name": "Livingroom", + "role": null, + "serial": "L05P00066EW14" + }, + { + "deviceId": "1zzyyxx2-3456-67g8-9h0i-1zz23456lz78", + "isSystemLeader": false, + "name": "Phantom I Silver-123a", + "role": null, + "serial": "M05P00066EW15" + } + ], "groupId": "12345678-901a-2b3c-def4-567g89h0i12j", + "multiroomFamily": "sync33", "systemId": "a12b345c-67d8-90e1-12f4-g5hij67890kl", - "systemName": "Devialet" + "systemName": "Devialet", + "systemType": "stereo" } diff --git a/tests/components/devialet/test_diagnostics.py b/tests/components/devialet/test_diagnostics.py index 97c23efe713..6bf643ce682 100644 --- a/tests/components/devialet/test_diagnostics.py +++ b/tests/components/devialet/test_diagnostics.py @@ -31,11 +31,13 @@ async def test_diagnostics( "source_list": [ "Airplay", "Bluetooth", - "Online", "Optical left", "Optical right", "Raat", "Spotify Connect", + "UPnP", ], "source": "spotifyconnect", + "upnp_device_type": "Not available", + "upnp_device_url": "Not available", } diff --git a/tests/components/devialet/test_media_player.py b/tests/components/devialet/test_media_player.py index 4e8f9b1dc03..6ca3d23218f 100644 --- a/tests/components/devialet/test_media_player.py +++ b/tests/components/devialet/test_media_player.py @@ -96,7 +96,7 @@ SERVICE_TO_DATA = { ], SERVICE_SELECT_SOURCE: [ {ATTR_INPUT_SOURCE: "Optical left"}, - {ATTR_INPUT_SOURCE: "Online"}, + {ATTR_INPUT_SOURCE: "UPnP"}, ], } @@ -203,7 +203,7 @@ async def test_media_player_playing( ) with patch.object( - DevialetApi, "available_options", new_callable=PropertyMock + DevialetApi, "available_operations", new_callable=PropertyMock ) as mock: mock.return_value = None await hass.config_entries.async_reload(entry.entry_id) From 364556a7ddd3fecc2413a88af85c631f6f67eca8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Jan 2025 09:28:17 +0100 Subject: [PATCH 0765/2987] Prefer from...import...as over import...as in core tests (#136146) --- tests/common.py | 3 +-- tests/helpers/test_aiohttp_client.py | 2 +- tests/helpers/test_check_config.py | 2 +- tests/helpers/test_condition.py | 2 +- tests/helpers/test_entity_component.py | 2 +- tests/helpers/test_entity_platform.py | 2 +- tests/helpers/test_event.py | 4 ++-- tests/helpers/test_httpx_client.py | 2 +- tests/helpers/test_script.py | 2 +- tests/helpers/test_service.py | 2 +- tests/helpers/test_sun.py | 2 +- tests/helpers/test_template.py | 2 +- tests/test_bootstrap.py | 3 +-- tests/test_config.py | 3 +-- tests/test_config_entries.py | 2 +- tests/test_core.py | 4 ++-- tests/util/test_color.py | 2 +- tests/util/test_dt.py | 2 +- tests/util/test_init.py | 2 +- tests/util/test_location.py | 2 +- tests/util/test_logging.py | 2 +- tests/util/test_network.py | 2 +- tests/util/test_ulid.py | 2 +- tests/util/test_uuid.py | 2 +- 24 files changed, 26 insertions(+), 29 deletions(-) diff --git a/tests/common.py b/tests/common.py index cb4706a97b8..0315ee6d845 100644 --- a/tests/common.py +++ b/tests/common.py @@ -89,12 +89,12 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import JSONEncoder, _orjson_default_encoder, json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util, ulid as ulid_util from homeassistant.util.async_ import ( _SHUTDOWN_RUN_CALLBACK_THREADSAFE, get_scheduled_timer_handles, run_callback_threadsafe, ) -import homeassistant.util.dt as dt_util from homeassistant.util.event_type import EventType from homeassistant.util.json import ( JsonArrayType, @@ -105,7 +105,6 @@ from homeassistant.util.json import ( json_loads_object, ) from homeassistant.util.signal_type import SignalType -import homeassistant.util.ulid as ulid_util from homeassistant.util.unit_system import METRIC_SYSTEM from homeassistant.util.yaml import load_yaml_dict, loader as yaml_loader diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 3fb83ae5781..13cb25bc516 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -21,7 +21,7 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.aiohttp_client as client +from homeassistant.helpers import aiohttp_client as client from homeassistant.util.color import RGBColor from homeassistant.util.ssl import SSLCipherList diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index de7edf42dc2..fc2df8552e7 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -9,12 +9,12 @@ import voluptuous as vol from homeassistant.config import YAML_CONFIG_FILE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.check_config import ( CheckConfigError, HomeAssistantConfig, async_check_ha_config_file, ) -import homeassistant.helpers.config_validation as cv from homeassistant.requirements import RequirementsNotFound from tests.common import ( diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 1ec78b20535..b8c8c8a18c8 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -30,7 +30,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.typing import WebSocketGenerator diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 940bd3e37fd..20c243d0701 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -28,7 +28,7 @@ from homeassistant.helpers.entity_component import EntityComponent, async_update from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 7c9244583e9..eb076eb9f25 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -38,7 +38,7 @@ from homeassistant.helpers.entity_component import ( ) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index a0014587cd0..a8691771580 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -13,8 +13,8 @@ from freezegun.api import FrozenDateTimeFactory import jinja2 import pytest +from homeassistant import core as ha from homeassistant.const import MATCH_ALL -import homeassistant.core as ha from homeassistant.core import ( Event, EventStateChangedData, @@ -52,7 +52,7 @@ from homeassistant.helpers.event import ( ) from homeassistant.helpers.template import Template, result_as_boolean from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_fire_time_changed_exact diff --git a/tests/helpers/test_httpx_client.py b/tests/helpers/test_httpx_client.py index 684778fe1b1..4b9f2fa2bf6 100644 --- a/tests/helpers/test_httpx_client.py +++ b/tests/helpers/test_httpx_client.py @@ -7,7 +7,7 @@ import pytest from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE from homeassistant.core import HomeAssistant -import homeassistant.helpers.httpx_client as client +from homeassistant.helpers import httpx_client as client from tests.common import MockModule, extract_stack_to_frame, mock_integration diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index d7c00e90bd6..f3cbb982ad0 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -44,7 +44,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import UNDEFINED from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index f802d6ffa5a..142f7a23f81 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -36,11 +36,11 @@ from homeassistant.core import ( ) from homeassistant.helpers import ( area_registry as ar, + config_validation as cv, device_registry as dr, entity_registry as er, service, ) -import homeassistant.helpers.config_validation as cv from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component from homeassistant.util.yaml.loader import parse_yaml diff --git a/tests/helpers/test_sun.py b/tests/helpers/test_sun.py index 54c26997422..973d68b1f5c 100644 --- a/tests/helpers/test_sun.py +++ b/tests/helpers/test_sun.py @@ -10,7 +10,7 @@ import pytest from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET from homeassistant.core import HomeAssistant from homeassistant.helpers import sun -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util def test_next_events(hass: HomeAssistant) -> None: diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 37e886dddce..b3a30806cbd 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -50,7 +50,7 @@ from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import TemplateVarsType from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.unit_system import UnitSystem diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index c1c532c94b5..5adfe4fc40b 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -12,8 +12,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from homeassistant import bootstrap, loader, runner -import homeassistant.config as config_util +from homeassistant import bootstrap, config as config_util, loader, runner from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( BASE_PLATFORMS, diff --git a/tests/test_config.py b/tests/test_config.py index c8c5b081119..569af3238d0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -15,8 +15,7 @@ from syrupy.assertion import SnapshotAssertion import voluptuous as vol import yaml -from homeassistant import loader -import homeassistant.config as config_util +from homeassistant import config as config_util, loader from homeassistant.const import CONF_PACKAGES, __version__ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import ConfigValidationError, HomeAssistantError diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 39860dc67c2..3ea1a16e898 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -45,8 +45,8 @@ from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import create_eager_task -import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads from .common import ( diff --git a/tests/test_core.py b/tests/test_core.py index 60b907d57ca..ceab3ce327c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -20,6 +20,7 @@ import pytest from pytest_unordered import unordered import voluptuous as vol +from homeassistant import core as ha from homeassistant.const import ( ATTR_FRIENDLY_NAME, EVENT_CALL_SERVICE, @@ -35,7 +36,6 @@ from homeassistant.const import ( EVENT_STATE_REPORTED, MATCH_ALL, ) -import homeassistant.core as ha from homeassistant.core import ( CoreState, HassJob, @@ -59,8 +59,8 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers.json import json_dumps from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import create_eager_task -import homeassistant.util.dt as dt_util from homeassistant.util.read_only_dict import ReadOnlyDict from .common import ( diff --git a/tests/util/test_color.py b/tests/util/test_color.py index 165552b8792..bcaf392b513 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -6,7 +6,7 @@ import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util GAMUT = color_util.GamutType( color_util.XYPoint(0.704, 0.296), diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 347e92d6056..96ba8d0a325 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -6,7 +6,7 @@ from datetime import UTC, datetime, timedelta import pytest -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util DEFAULT_TIME_ZONE = dt_util.get_default_time_zone() TEST_TIME_ZONE = "America/Los_Angeles" diff --git a/tests/util/test_init.py b/tests/util/test_init.py index 759f0d6e5ea..111b086b48b 100644 --- a/tests/util/test_init.py +++ b/tests/util/test_init.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant import util -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util def test_raise_if_invalid_filename() -> None: diff --git a/tests/util/test_location.py b/tests/util/test_location.py index 3af3ad2765a..ecb54eeeaa9 100644 --- a/tests/util/test_location.py +++ b/tests/util/test_location.py @@ -7,7 +7,7 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.util.location as location_util +from homeassistant.util import location as location_util from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMocker diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index 795444c89bd..e5b85f35693 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -14,7 +14,7 @@ from homeassistant.core import ( is_callback, is_callback_check_partial, ) -import homeassistant.util.logging as logging_util +from homeassistant.util import logging as logging_util async def test_logging_with_queue_handler() -> None: diff --git a/tests/util/test_network.py b/tests/util/test_network.py index 4bb6f94e684..c234a517640 100644 --- a/tests/util/test_network.py +++ b/tests/util/test_network.py @@ -2,7 +2,7 @@ from ipaddress import ip_address -import homeassistant.util.network as network_util +from homeassistant.util import network as network_util def test_is_loopback() -> None: diff --git a/tests/util/test_ulid.py b/tests/util/test_ulid.py index dc0f21ce3c7..6f9911fe557 100644 --- a/tests/util/test_ulid.py +++ b/tests/util/test_ulid.py @@ -2,7 +2,7 @@ import uuid -import homeassistant.util.ulid as ulid_util +from homeassistant.util import ulid as ulid_util async def test_ulid_util_uuid_hex() -> None: diff --git a/tests/util/test_uuid.py b/tests/util/test_uuid.py index e5a1022ef1d..9d78b634149 100644 --- a/tests/util/test_uuid.py +++ b/tests/util/test_uuid.py @@ -2,7 +2,7 @@ import uuid -import homeassistant.util.uuid as uuid_util +from homeassistant.util import uuid as uuid_util async def test_uuid_util_random_uuid_hex() -> None: From f422ad22c41de7ee890fc8ea1c5f3688b3ded4c8 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 21 Jan 2025 10:15:32 +0100 Subject: [PATCH 0766/2987] Add value is not to Matter discovery schema logic (#136157) --- homeassistant/components/matter/discovery.py | 9 +++++++++ homeassistant/components/matter/models.py | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 3b9fb0b8a94..de03d250836 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -130,6 +130,15 @@ def async_discover_entities( ): continue + # check for value that may not be present + if schema.value_is_not is not None and ( + schema.value_is_not == primary_value + or ( + isinstance(primary_value, list) and schema.value_is_not in primary_value + ) + ): + continue + # check for required value in cluster featuremap if schema.featuremap_contains is not None and ( not bool( diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index a00963c825a..f1fd7ca9fa3 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -116,6 +116,11 @@ class MatterDiscoverySchema: # NOTE: only works for list values value_contains: Any | None = None + # [optional] the primary attribute value must NOT have this value + # for example to filter out invalid values (such as empty string instead of null) + # in case of a list value, the list may not contain this value + value_is_not: Any | None = None + # [optional] the primary attribute's cluster featuremap must contain this value # for example for the DoorSensor on a DoorLock Cluster featuremap_contains: int | None = None From 57b17472d73b6bb57ed4b595ee22c98c999c37fc Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 21 Jan 2025 10:47:15 +0100 Subject: [PATCH 0767/2987] Clean up entity registry imports in Shelly tests (#136159) --- tests/components/shelly/test_binary_sensor.py | 9 ++++----- tests/components/shelly/test_switch.py | 9 ++++----- tests/components/shelly/test_valve.py | 4 ++-- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index ed36a43a556..bff6d199d0e 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI from homeassistant.components.shelly.const import UPDATE_PERIOD_MULTIPLIER from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -388,7 +387,7 @@ async def test_rpc_restored_sleeping_binary_sensor_no_last_state( ) async def test_rpc_device_virtual_binary_sensor( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, name: str | None, @@ -423,7 +422,7 @@ async def test_rpc_device_virtual_binary_sensor( async def test_rpc_remove_virtual_binary_sensor_when_mode_toggle( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, device_registry: DeviceRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, @@ -457,7 +456,7 @@ async def test_rpc_remove_virtual_binary_sensor_when_mode_toggle( async def test_rpc_remove_virtual_binary_sensor_when_orphaned( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, device_registry: DeviceRegistry, mock_rpc_device: Mock, ) -> None: @@ -483,7 +482,7 @@ async def test_rpc_remove_virtual_binary_sensor_when_orphaned( async def test_blu_trv_binary_sensor_entity( hass: HomeAssistant, mock_blu_trv: Mock, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test BLU TRV binary sensor entity.""" diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 5c7933afd7e..5aae9dfffc9 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -25,7 +25,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -444,7 +443,7 @@ async def test_wall_display_relay_mode( ) async def test_rpc_device_virtual_switch( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, name: str | None, @@ -517,7 +516,7 @@ async def test_rpc_device_virtual_binary_sensor( async def test_rpc_remove_virtual_switch_when_mode_label( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, device_registry: DeviceRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, @@ -551,7 +550,7 @@ async def test_rpc_remove_virtual_switch_when_mode_label( async def test_rpc_remove_virtual_switch_when_orphaned( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, device_registry: DeviceRegistry, mock_rpc_device: Mock, ) -> None: @@ -577,7 +576,7 @@ async def test_rpc_remove_virtual_switch_when_orphaned( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_rpc_device_script_switch( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/components/shelly/test_valve.py b/tests/components/shelly/test_valve.py index b35ce98b664..9dc8597120a 100644 --- a/tests/components/shelly/test_valve.py +++ b/tests/components/shelly/test_valve.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN, ValveState from homeassistant.const import ATTR_ENTITY_ID, SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry from . import init_integration @@ -17,7 +17,7 @@ GAS_VALVE_BLOCK_ID = 6 async def test_block_device_gas_valve( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: From 40eb8b91cc2c947226983811cc8b24059062f1c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 21 Jan 2025 08:58:22 -0100 Subject: [PATCH 0768/2987] Adjust to recommended propcache.api import paths (#136150) --- homeassistant/auth/models.py | 2 +- homeassistant/components/airgradient/update.py | 2 +- .../components/alarm_control_panel/__init__.py | 2 +- homeassistant/components/automation/__init__.py | 2 +- homeassistant/components/backup/agent.py | 2 +- homeassistant/components/binary_sensor/__init__.py | 2 +- homeassistant/components/button/__init__.py | 2 +- homeassistant/components/camera/__init__.py | 2 +- homeassistant/components/climate/__init__.py | 2 +- homeassistant/components/cover/__init__.py | 2 +- homeassistant/components/date/__init__.py | 2 +- homeassistant/components/datetime/__init__.py | 2 +- .../components/device_tracker/config_entry.py | 2 +- homeassistant/components/device_tracker/legacy.py | 2 +- homeassistant/components/dlna_dms/dms.py | 2 +- homeassistant/components/doorbird/device.py | 2 +- homeassistant/components/event/__init__.py | 2 +- homeassistant/components/fan/__init__.py | 2 +- homeassistant/components/ffmpeg/__init__.py | 2 +- homeassistant/components/fints/sensor.py | 2 +- homeassistant/components/frontend/__init__.py | 2 +- homeassistant/components/geo_location/__init__.py | 2 +- homeassistant/components/homekit_controller/climate.py | 2 +- homeassistant/components/homekit_controller/cover.py | 2 +- homeassistant/components/homekit_controller/fan.py | 2 +- .../components/homekit_controller/humidifier.py | 2 +- homeassistant/components/homekit_controller/light.py | 2 +- homeassistant/components/humidifier/__init__.py | 2 +- homeassistant/components/image/__init__.py | 2 +- homeassistant/components/intent/timers.py | 2 +- homeassistant/components/knx/light.py | 2 +- homeassistant/components/lawn_mower/__init__.py | 2 +- homeassistant/components/lifx/coordinator.py | 2 +- homeassistant/components/light/__init__.py | 2 +- homeassistant/components/lock/__init__.py | 2 +- homeassistant/components/logbook/models.py | 2 +- homeassistant/components/matter/entity.py | 2 +- homeassistant/components/media_player/__init__.py | 2 +- homeassistant/components/nibe_heatpump/coordinator.py | 2 +- homeassistant/components/notify/__init__.py | 2 +- homeassistant/components/number/__init__.py | 2 +- .../climate/atlantic_pass_apc_zone_control_zone.py | 2 +- homeassistant/components/recorder/core.py | 2 +- homeassistant/components/recorder/models/state.py | 2 +- homeassistant/components/remote/__init__.py | 2 +- homeassistant/components/roborock/coordinator.py | 2 +- homeassistant/components/script/__init__.py | 2 +- homeassistant/components/select/__init__.py | 2 +- homeassistant/components/sensor/__init__.py | 2 +- homeassistant/components/shelly/coordinator.py | 2 +- homeassistant/components/siren/__init__.py | 2 +- homeassistant/components/switch/__init__.py | 2 +- homeassistant/components/template/template_entity.py | 2 +- homeassistant/components/teslemetry/entity.py | 2 +- homeassistant/components/teslemetry/sensor.py | 2 +- homeassistant/components/text/__init__.py | 2 +- homeassistant/components/thread/dataset_store.py | 2 +- homeassistant/components/time/__init__.py | 2 +- homeassistant/components/todo/__init__.py | 2 +- homeassistant/components/tts/__init__.py | 2 +- homeassistant/components/unifi/device_tracker.py | 2 +- homeassistant/components/update/__init__.py | 2 +- homeassistant/components/vacuum/__init__.py | 2 +- homeassistant/components/water_heater/__init__.py | 2 +- homeassistant/components/weather/__init__.py | 2 +- homeassistant/components/zha/entity.py | 2 +- homeassistant/config_entries.py | 2 +- homeassistant/core.py | 2 +- homeassistant/helpers/area_registry.py | 4 ++-- homeassistant/helpers/device_registry.py | 4 ++-- homeassistant/helpers/entity.py | 2 +- homeassistant/helpers/entity_registry.py | 4 ++-- homeassistant/helpers/frame.py | 2 +- homeassistant/helpers/intent.py | 2 +- homeassistant/helpers/script.py | 2 +- homeassistant/helpers/storage.py | 2 +- homeassistant/helpers/template.py | 2 +- homeassistant/helpers/update_coordinator.py | 2 +- homeassistant/loader.py | 2 +- homeassistant/util/yaml/loader.py | 2 +- pylint/plugins/hass_imports.py | 10 ++++++++-- pyproject.toml | 2 +- tests/helpers/test_entity.py | 2 +- 83 files changed, 93 insertions(+), 87 deletions(-) diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index 6f45dab2b36..7dcccbb1a1e 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -11,7 +11,7 @@ import uuid import attr from attr import Attribute from attr.setters import validate -from propcache import cached_property +from propcache.api import cached_property from homeassistant.const import __version__ from homeassistant.data_entry_flow import FlowContext, FlowResult diff --git a/homeassistant/components/airgradient/update.py b/homeassistant/components/airgradient/update.py index 47e71cb4e65..7c040524243 100644 --- a/homeassistant/components/airgradient/update.py +++ b/homeassistant/components/airgradient/update.py @@ -2,7 +2,7 @@ from datetime import timedelta -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components.update import UpdateDeviceClass, UpdateEntity from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 4c5e201df8f..80a676a40fa 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -7,7 +7,7 @@ from datetime import timedelta import logging from typing import TYPE_CHECKING, Any, Final, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 955a6215096..4e6b098ef1e 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -9,7 +9,7 @@ from dataclasses import dataclass import logging from typing import Any, Protocol, cast -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components import websocket_api diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index 44bc9b298e8..cb03327e941 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -7,7 +7,7 @@ from collections.abc import AsyncIterator, Callable, Coroutine from pathlib import Path from typing import Any, Protocol -from propcache import cached_property +from propcache.api import cached_property from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index f31c3d102b0..7b0c121ac6b 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -7,7 +7,7 @@ from enum import StrEnum import logging from typing import Literal, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 14dc09ca33e..c6b90945329 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -7,7 +7,7 @@ from enum import StrEnum import logging from typing import final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 16b9fb06dbb..556f8d75fc4 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -18,7 +18,7 @@ from typing import Any, Final, final from aiohttp import hdrs, web import attr -from propcache import cached_property, under_cached_property +from propcache.api import cached_property, under_cached_property import voluptuous as vol from webrtc_models import RTCIceCandidateInit, RTCIceServer diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index ca85979f19a..af64b06ebe6 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -7,7 +7,7 @@ import functools as ft import logging from typing import Any, Literal, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index c4795e0e7d9..85069b425e3 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -9,7 +9,7 @@ import functools as ft import logging from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/date/__init__.py b/homeassistant/components/date/__init__.py index 622ec574542..43ce6a9b4c1 100644 --- a/homeassistant/components/date/__init__.py +++ b/homeassistant/components/date/__init__.py @@ -6,7 +6,7 @@ from datetime import date, timedelta import logging from typing import final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index 8aef34ddcbd..53f85992abc 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -6,7 +6,7 @@ from datetime import UTC, datetime, timedelta import logging from typing import final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 50fc3d2d936..db33d5038fc 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from typing import final -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components import zone from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index b1520866bb5..f2f782d3d97 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -10,7 +10,7 @@ from types import ModuleType from typing import Any, Final, Protocol, final import attr -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant import util diff --git a/homeassistant/components/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index 1d0b27696f7..89c53bc2564 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -16,7 +16,7 @@ from async_upnp_client.const import NotificationSubType from async_upnp_client.exceptions import UpnpActionError, UpnpConnectionError, UpnpError from async_upnp_client.profiles.dlna import ContentDirectoryErrorCode, DmsDevice from didl_lite import didl_lite -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components import ssdp from homeassistant.components.media_player import BrowseError, MediaClass diff --git a/homeassistant/components/doorbird/device.py b/homeassistant/components/doorbird/device.py index eae5bb6804f..f57e7595dbc 100644 --- a/homeassistant/components/doorbird/device.py +++ b/homeassistant/components/doorbird/device.py @@ -15,7 +15,7 @@ from doorbirdpy import ( DoorBirdScheduleEntryOutput, DoorBirdScheduleEntrySchedule, ) -from propcache import cached_property +from propcache.api import cached_property from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index 5bdf107f0c3..4ed5a0f1378 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -8,7 +8,7 @@ from enum import StrEnum import logging from typing import Any, Self, final -from propcache import cached_property +from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 863ae705603..b9e20e8dc91 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -9,7 +9,7 @@ import logging import math from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index 99803e9636c..6957702523f 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -7,7 +7,7 @@ import re from haffmpeg.core import HAFFmpeg from haffmpeg.tools import IMAGE_JPEG, FFVersion, ImageFrame -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index a1cd565153f..c85f08ba3d0 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -9,7 +9,7 @@ from typing import Any from fints.client import FinTS3PinTanClient from fints.models import SEPAAccount -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components.sensor import ( diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index c1098ac19d3..050d57fc358 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -11,7 +11,7 @@ from typing import Any, TypedDict from aiohttp import hdrs, web, web_urldispatcher import jinja2 -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from yarl import URL diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py index 877471f002a..06b0320c805 100644 --- a/homeassistant/components/geo_location/__init__.py +++ b/homeassistant/components/geo_location/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index ba5237e6e2d..cbf4ad61c2f 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -17,7 +17,7 @@ from aiohomekit.model.characteristics import ( ) from aiohomekit.model.services import Service, ServicesTypes from aiohomekit.utils import clamp_enum_to_char -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components.climate import ( ATTR_HVAC_MODE, diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index d7480a40a93..4fff32002e2 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -6,7 +6,7 @@ from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components.cover import ( ATTR_POSITION, diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index 2ae534099ae..b7f1842392b 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -6,7 +6,7 @@ from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components.fan import ( DIRECTION_FORWARD, diff --git a/homeassistant/components/homekit_controller/humidifier.py b/homeassistant/components/homekit_controller/humidifier.py index f82baab5df7..b2b0e0b1026 100644 --- a/homeassistant/components/homekit_controller/humidifier.py +++ b/homeassistant/components/homekit_controller/humidifier.py @@ -6,7 +6,7 @@ from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components.humidifier import ( DEFAULT_MAX_HUMIDITY, diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index 26f10768aa0..b306c440d7b 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -6,7 +6,7 @@ from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components.light import ( ATTR_BRIGHTNESS, diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 8c892dca327..de9384edda6 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -7,7 +7,7 @@ from enum import StrEnum import logging from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index dbb5962eabf..1cf2de278d1 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -14,7 +14,7 @@ from typing import Final, final from aiohttp import hdrs, web import httpx -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py index 84b96492241..ece416d7ef1 100644 --- a/homeassistant/components/intent/timers.py +++ b/homeassistant/components/intent/timers.py @@ -10,7 +10,7 @@ import logging import time from typing import Any -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 8e64b46c890..6115f8be128 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any, cast -from propcache import cached_property +from propcache.api import cached_property from xknx import XKNX from xknx.devices.light import ColorTemperatureType, Light as XknxLight, XYYColor diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py index a8c52b72a81..0680bfc9d71 100644 --- a/homeassistant/components/lawn_mower/__init__.py +++ b/homeassistant/components/lawn_mower/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import final -from propcache import cached_property +from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index 5558828a143..eaaff7e6540 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -21,7 +21,7 @@ from aiolifx.aiolifx import ( from aiolifx.connection import LIFXConnection from aiolifx_themes.themes import ThemeLibrary, ThemePainter from awesomeversion import AwesomeVersion -from propcache import cached_property +from propcache.api import cached_property from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS, diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 412ee1e6c16..65a89b7d688 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -10,7 +10,7 @@ import logging import os from typing import Any, Final, Self, cast, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 39d5d3c350d..60eb29240cd 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -9,7 +9,7 @@ import logging import re from typing import TYPE_CHECKING, Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index c33325d7dcb..40b904c1279 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast -from propcache import cached_property +from propcache.api import cached_property from sqlalchemy.engine.row import Row from homeassistant.components.recorder.filters import Filters diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 50a0f2b1fee..61c62d8b564 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -11,7 +11,7 @@ from chip.clusters import Objects as clusters from chip.clusters.Objects import ClusterAttributeDescriptor, NullValue from matter_server.common.helpers.util import create_attribute_path from matter_server.common.models import EventType, ServerInfoMessage -from propcache import cached_property +from propcache.api import cached_property from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index b82cab401c5..e109b0418c9 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -21,7 +21,7 @@ import aiohttp from aiohttp import web from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE from aiohttp.typedefs import LooseHeaders -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from yarl import URL diff --git a/homeassistant/components/nibe_heatpump/coordinator.py b/homeassistant/components/nibe_heatpump/coordinator.py index ed6d18f7888..faaac5f165a 100644 --- a/homeassistant/components/nibe_heatpump/coordinator.py +++ b/homeassistant/components/nibe_heatpump/coordinator.py @@ -12,7 +12,7 @@ from nibe.coil import Coil, CoilData from nibe.connection import Connection from nibe.exceptions import CoilNotFoundException, ReadException from nibe.heatpump import HeatPump, Series -from propcache import cached_property +from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 0b7a25ced3e..7f41817a683 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -8,7 +8,7 @@ from functools import partial import logging from typing import Any, final, override -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol import homeassistant.components.persistent_notification as pn diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 2f5ebcdb44c..3e9d3448af2 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -10,7 +10,7 @@ import logging from math import ceil, floor from typing import TYPE_CHECKING, Any, Self, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py index 5ba9dabe038..eff1d5fa130 100644 --- a/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py +++ b/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py @@ -5,7 +5,7 @@ from __future__ import annotations from asyncio import sleep from typing import Any, cast -from propcache import cached_property +from propcache.api import cached_property from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState from homeassistant.components.climate import ( diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 5a405061a94..fc8b136f38a 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -14,7 +14,7 @@ import threading import time from typing import TYPE_CHECKING, Any, cast -from propcache import cached_property +from propcache.api import cached_property import psutil_home_assistant as ha_psutil from sqlalchemy import create_engine, event as sqlalchemy_event, exc, select, update from sqlalchemy.engine import Engine diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index d73c204079d..1ceaee633ae 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -6,7 +6,7 @@ from datetime import datetime import logging from typing import TYPE_CHECKING, Any -from propcache import cached_property +from propcache.api import cached_property from sqlalchemy.engine.row import Row from homeassistant.const import ( diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 36e482f0a29..f7d87fbf021 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -9,7 +9,7 @@ import functools as ft import logging from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 443e50642f2..d34ba49da52 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import timedelta import logging -from propcache import cached_property +from propcache.api import cached_property from roborock import HomeDataRoom from roborock.code_mappings import RoborockCategory from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index c0d79c446bb..14104ad0219 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -8,7 +8,7 @@ from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any, cast -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components import websocket_api diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 592b746198e..4196106edd2 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 37df50b2099..89f39d4fb8c 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -12,7 +12,7 @@ import logging from math import ceil, floor, isfinite, log10 from typing import Any, Final, Self, cast, final, override -from propcache import cached_property +from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( # noqa: F401 diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index e6129b5559a..d5071c4e849 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -18,7 +18,7 @@ from aioshelly.exceptions import ( RpcCallError, ) from aioshelly.rpc_device import RpcDevice, RpcUpdateType -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components.bluetooth import async_remove_scanner from homeassistant.config_entries import ConfigEntry, ConfigEntryState diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index 02b49f5732e..65d7848c618 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import Any, TypedDict, cast, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 61ee2908009..3c173cf5b2a 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta from enum import StrEnum import logging -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index f5b84b1ad7a..d025f052732 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -8,7 +8,7 @@ import itertools import logging from typing import Any, cast -from propcache import under_cached_property +from propcache.api import under_cached_property import voluptuous as vol from homeassistant.components.blueprint import CONF_USE_BLUEPRINT diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index df8406e0ced..82d3db123c3 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -3,7 +3,7 @@ from abc import abstractmethod from typing import Any -from propcache import cached_property +from propcache.api import cached_property from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope from teslemetry_stream import Signal diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 524d8579703..0fb0a6ee0e0 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta -from propcache import cached_property +from propcache.api import cached_property from teslemetry_stream import Signal from homeassistant.components.sensor import ( diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index d0f5ac7d3b7..27af7e3fe59 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -9,7 +9,7 @@ import logging import re from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index fc95e524181..1b4ae7ba01f 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -8,7 +8,7 @@ from datetime import datetime import logging from typing import Any, cast -from propcache import cached_property +from propcache.api import cached_property from python_otbr_api import tlv_parser from python_otbr_api.tlv_parser import MeshcopTLVType diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py index 473472356d4..60e55c214fe 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -6,7 +6,7 @@ from datetime import time, timedelta import logging from typing import final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index e4bc549a16b..937187c1c6f 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -8,7 +8,7 @@ import datetime import logging from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components import frontend, websocket_api diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 0213fd17864..bbe4d334def 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -21,7 +21,7 @@ from typing import Any, Final, TypedDict, final from aiohttp import web import mutagen from mutagen.id3 import ID3, TextFrame as ID3Text -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components import ffmpeg, websocket_api diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 735f76a73bf..2ac47e67913 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -16,7 +16,7 @@ from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client from aiounifi.models.device import Device from aiounifi.models.event import Event, EventKey -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index a2ecd494920..0ff8c448197 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -9,7 +9,7 @@ import logging from typing import Any, Final, final from awesomeversion import AwesomeVersion, AwesomeVersionCompareException -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components import websocket_api diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 0cafda82786..3b1eee8509c 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -9,7 +9,7 @@ from functools import partial import logging from typing import TYPE_CHECKING, Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index 60be340a253..3e1387cb714 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -8,7 +8,7 @@ import functools as ft import logging from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 50d90c59d37..e9436922a4b 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -20,7 +20,7 @@ from typing import ( final, ) -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 77ba048312a..499721722fa 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -8,7 +8,7 @@ from functools import partial import logging from typing import Any -from propcache import cached_property +from propcache.api import cached_property from zha.mixins import LogMixin from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME, EntityCategory diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 5a0f99df5ee..620e4bc8197 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -25,7 +25,7 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Any, Self, cast from async_interrupt import interrupt -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from . import data_entry_flow, loader diff --git a/homeassistant/core.py b/homeassistant/core.py index 74bcd844823..46ae499e2ca 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -41,7 +41,7 @@ from typing import ( overload, ) -from propcache import cached_property, under_cached_property +from propcache.api import cached_property, under_cached_property import voluptuous as vol from . import util diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 9c75af7262d..5601ce4032d 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -28,9 +28,9 @@ from .typing import UNDEFINED, UndefinedType if TYPE_CHECKING: # mypy cannot workout _cache Protocol with dataclasses - from propcache import cached_property as under_cached_property + from propcache.api import cached_property as under_cached_property else: - from propcache import under_cached_property + from propcache.api import under_cached_property DATA_REGISTRY: HassKey[AreaRegistry] = HassKey("area_registry") diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 2890f607d59..685509cb29d 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -40,13 +40,13 @@ from .typing import UNDEFINED, UndefinedType if TYPE_CHECKING: # mypy cannot workout _cache Protocol with attrs - from propcache import cached_property as under_cached_property + from propcache.api import cached_property as under_cached_property from homeassistant.config_entries import ConfigEntry from . import entity_registry else: - from propcache import under_cached_property + from propcache.api import under_cached_property _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 9e8fe40c6b0..2b9f2d7069e 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -18,7 +18,7 @@ import time from types import FunctionType from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, TypedDict, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 3e8c57562b2..7300b148c77 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -65,11 +65,11 @@ from .typing import UNDEFINED, UndefinedType if TYPE_CHECKING: # mypy cannot workout _cache Protocol with attrs - from propcache import cached_property as under_cached_property + from propcache.api import cached_property as under_cached_property from homeassistant.config_entries import ConfigEntry else: - from propcache import under_cached_property + from propcache.api import under_cached_property DATA_REGISTRY: HassKey[EntityRegistry] = HassKey("entity_registry") EVENT_ENTITY_REGISTRY_UPDATED: EventType[EventEntityRegistryUpdatedData] = EventType( diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 6d03ae4ffd2..f33f8407e47 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -13,7 +13,7 @@ import sys from types import FrameType from typing import Any, cast -from propcache import cached_property +from propcache.api import cached_property from homeassistant.core import HomeAssistant, async_get_hass_or_none from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 2874269892c..649819a5f06 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -12,7 +12,7 @@ from itertools import groupby import logging from typing import Any -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components.homeassistant.exposed_entities import async_should_expose diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 1fd0e08988c..bd3babc8793 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -16,7 +16,7 @@ from types import MappingProxyType from typing import Any, Literal, TypedDict, cast, overload import async_interrupt -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant import exceptions diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 080599f54d8..ac1fe3bb29d 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -13,7 +13,7 @@ import os from pathlib import Path from typing import Any -from propcache import cached_property +from propcache.api import cached_property from homeassistant.const import ( EVENT_HOMEASSISTANT_FINAL_WRITE, diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 21d49df2a67..7bddfdc2f68 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -44,7 +44,7 @@ from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace from lru import LRU import orjson -from propcache import under_cached_property +from propcache.api import under_cached_property import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 82663d25e1a..943eadff19a 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -13,7 +13,7 @@ from typing import Any, Generic, Protocol, TypeVar import urllib.error import aiohttp -from propcache import cached_property +from propcache.api import cached_property import requests from homeassistant import config_entries diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 39dbe20c7c6..92b588dbe15 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -25,7 +25,7 @@ from awesomeversion import ( AwesomeVersionException, AwesomeVersionStrategy, ) -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from . import generated diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 39d38a8f47d..3911d62040b 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -22,7 +22,7 @@ except ImportError: SafeLoader as FastestAvailableSafeLoader, ) -from propcache import cached_property +from propcache.api import cached_property from homeassistant.exceptions import HomeAssistantError diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index 2fe70fad10d..0d6582535f7 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -21,7 +21,7 @@ class ObsoleteImportMatch: _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { "functools": [ ObsoleteImportMatch( - reason="replaced by propcache.cached_property", + reason="replaced by propcache.api.cached_property", constant=re.compile(r"^cached_property$"), ), ], @@ -33,7 +33,7 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { ], "homeassistant.backports.functools": [ ObsoleteImportMatch( - reason="replaced by propcache.cached_property", + reason="replaced by propcache.api.cached_property", constant=re.compile(r"^cached_property$"), ), ], @@ -129,6 +129,12 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { constant=re.compile(r"^IMPERIAL_SYSTEM$"), ), ], + "propcache": [ + ObsoleteImportMatch( + reason="importing from propcache.api recommended", + constant=re.compile(r"^(under_)?cached_property$"), + ), + ], } _IGNORE_ROOT_IMPORT = ( diff --git a/pyproject.toml b/pyproject.toml index 5cc7727e136..05b00305b1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -951,4 +951,4 @@ split-on-trailing-comma = false max-complexity = 25 [tool.ruff.lint.pydocstyle] -property-decorators = ["propcache.cached_property"] +property-decorators = ["propcache.api.cached_property"] diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 2bf441f70fd..5e8c9fc88f7 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -11,7 +11,7 @@ from typing import Any from unittest.mock import MagicMock, PropertyMock, patch from freezegun.api import FrozenDateTimeFactory -from propcache import cached_property +from propcache.api import cached_property import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol From e4219b617ca6d2c09ddb1226ffc04a476b3e46f6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 21 Jan 2025 11:28:00 +0100 Subject: [PATCH 0769/2987] Capitalize "Homematic" brand name and 2 more user string fixes (#136113) Capitalize "Homematic" brand name and more user string fixes --- homeassistant/components/homematic/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homematic/strings.json b/homeassistant/components/homematic/strings.json index 48ebbe5d345..d962a218a4f 100644 --- a/homeassistant/components/homematic/strings.json +++ b/homeassistant/components/homematic/strings.json @@ -6,7 +6,7 @@ "fields": { "address": { "name": "Address", - "description": "Address of homematic device or BidCoS-RF for virtual remote." + "description": "Address of Homematic device or BidCoS-RF for virtual remote." }, "channel": { "name": "Channel", @@ -28,7 +28,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Name(s) of homematic central to set value." + "description": "Name(s) of Homematic central to set value." }, "name": { "name": "[%key:common::config_flow::data::name%]", @@ -72,11 +72,11 @@ }, "reconnect": { "name": "Reconnect", - "description": "Reconnects to all Homematic Hubs." + "description": "Reconnects to all Homematic hubs." }, "set_install_mode": { "name": "Set install mode", - "description": "Set a RPC XML interface into installation mode.", + "description": "Sets a RPC XML interface into installation mode.", "fields": { "interface": { "name": "Interface", @@ -92,7 +92,7 @@ }, "address": { "name": "Address", - "description": "Address of homematic device or BidCoS-RF to learn." + "description": "Address of Homematic device or BidCoS-RF to learn." } } }, From e822f5de6e14212993ae19b8d0dac5c850fce1ea Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Tue, 21 Jan 2025 12:43:17 +0100 Subject: [PATCH 0770/2987] Fix typo in enphase_envoy data description (#136164) --- homeassistant/components/enphase_envoy/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index fac86501df6..589dc52f71d 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -12,7 +12,7 @@ "data_description": { "host": "The hostname or IP address of your Enphase Envoy gateway.", "username": "Installer or Enphase Cloud username", - "password": "blank or Enphase Cloud password" + "password": "Blank or Enphase Cloud password" } }, "reconfigure": { From 33a2fa2c85f77dd752bbea56a997730351edbe11 Mon Sep 17 00:00:00 2001 From: Mick Montorier-Aberman Date: Tue, 21 Jan 2025 13:08:38 +0100 Subject: [PATCH 0771/2987] Add support for Bot in SwitchBot Cloud (#135606) --- .../components/switchbot_cloud/__init__.py | 12 ++++ .../components/switchbot_cloud/button.py | 41 +++++++++++ .../components/switchbot_cloud/switch.py | 2 + .../components/switchbot_cloud/test_button.py | 71 +++++++++++++++++++ .../components/switchbot_cloud/test_switch.py | 62 +++++++++++++++- 5 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/switchbot_cloud/button.py create mode 100644 tests/components/switchbot_cloud/test_button.py diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index f14547326ba..d7812158260 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -16,6 +16,7 @@ from .coordinator import SwitchBotCoordinator _LOGGER = getLogger(__name__) PLATFORMS: list[Platform] = [ + Platform.BUTTON, Platform.CLIMATE, Platform.LOCK, Platform.SENSOR, @@ -28,6 +29,7 @@ PLATFORMS: list[Platform] = [ class SwitchbotDevices: """Switchbot devices data.""" + buttons: list[Device] = field(default_factory=list) climates: list[Remote] = field(default_factory=list) switches: list[Device | Remote] = field(default_factory=list) sensors: list[Device] = field(default_factory=list) @@ -136,6 +138,16 @@ async def make_device_data( ) devices_data.locks.append((device, coordinator)) + if isinstance(device, Device) and device.device_type in ["Bot"]: + coordinator = await coordinator_for_device( + hass, api, device, coordinators_by_id + ) + if coordinator.data is not None: + if coordinator.data.get("deviceMode") == "pressMode": + devices_data.buttons.append((device, coordinator)) + else: + devices_data.switches.append((device, coordinator)) + async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" diff --git a/homeassistant/components/switchbot_cloud/button.py b/homeassistant/components/switchbot_cloud/button.py new file mode 100644 index 00000000000..a6eb1a134a5 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/button.py @@ -0,0 +1,41 @@ +"""Support for the Switchbot Bot as a Button.""" + +from typing import Any + +from switchbot_api import BotCommands + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SwitchbotCloudData +from .const import DOMAIN +from .entity import SwitchBotCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + async_add_entities( + SwitchBotCloudBot(data.api, device, coordinator) + for device, coordinator in data.devices.buttons + ) + + +class SwitchBotCloudBot(SwitchBotCloudEntity, ButtonEntity): + """Representation of a SwitchBot Bot.""" + + _attr_name = None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + async def async_press(self, **kwargs: Any) -> None: + """Bot press command.""" + await self.send_api_command(BotCommands.PRESS) diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py index 0781c91bc35..22d033625f9 100644 --- a/homeassistant/components/switchbot_cloud/switch.py +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -90,4 +90,6 @@ def _async_make_entity( "Relay Switch 1", ]: return SwitchBotCloudRelaySwitchSwitch(api, device, coordinator) + if "Bot" in device.device_type: + return SwitchBotCloudSwitch(api, device, coordinator) raise NotImplementedError(f"Unsupported device type: {device.device_type}") diff --git a/tests/components/switchbot_cloud/test_button.py b/tests/components/switchbot_cloud/test_button.py new file mode 100644 index 00000000000..df5b7569100 --- /dev/null +++ b/tests/components/switchbot_cloud/test_button.py @@ -0,0 +1,71 @@ +"""Test for the switchbot_cloud bot as a button.""" + +from unittest.mock import patch + +from switchbot_api import BotCommands, Device + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.switchbot_cloud import SwitchBotAPI +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +async def test_pressmode_bot( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test press.""" + mock_list_devices.return_value = [ + Device( + deviceId="bot-id-1", + deviceName="bot-1", + deviceType="Bot", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"deviceMode": "pressMode"} + + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + entity_id = "button.bot_1" + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called_once_with( + "bot-id-1", BotCommands.PRESS, "command", "default" + ) + + assert hass.states.get(entity_id).state != STATE_UNKNOWN + + +async def test_switchmode_bot_no_button_entity( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test a switchMode bot isn't added as a button.""" + mock_list_devices.return_value = [ + Device( + deviceId="bot-id-1", + deviceName="bot-1", + deviceType="Bot", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"deviceMode": "switchMode"} + + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert not hass.states.async_entity_ids(BUTTON_DOMAIN) diff --git a/tests/components/switchbot_cloud/test_switch.py b/tests/components/switchbot_cloud/test_switch.py index d4ef2c84549..b1c6fb81b96 100644 --- a/tests/components/switchbot_cloud/test_switch.py +++ b/tests/components/switchbot_cloud/test_switch.py @@ -1,4 +1,4 @@ -"""Test for the switchbot_cloud relay switch.""" +"""Test for the switchbot_cloud relay switch & bot.""" from unittest.mock import patch @@ -54,3 +54,63 @@ async def test_relay_switch( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) assert hass.states.get(entity_id).state == STATE_OFF + + +async def test_switchmode_bot( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test turn on and turn off.""" + mock_list_devices.return_value = [ + Device( + deviceId="bot-id-1", + deviceName="bot-1", + deviceType="Bot", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"deviceMode": "switchMode", "power": "off"} + + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + entity_id = "switch.bot_1" + assert hass.states.get(entity_id).state == STATE_OFF + + with patch.object(SwitchBotAPI, "send_command"): + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_ON + + with patch.object(SwitchBotAPI, "send_command"): + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_OFF + + +async def test_pressmode_bot_no_switch_entity( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test a pressMode bot isn't added as a switch.""" + mock_list_devices.return_value = [ + Device( + deviceId="bot-id-1", + deviceName="bot-1", + deviceType="Bot", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"deviceMode": "pressMode"} + + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert not hass.states.async_entity_ids(SWITCH_DOMAIN) From a60d2b69e3d0fdbfd870a0086a34dd613faef70a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jan 2025 13:40:54 +0100 Subject: [PATCH 0772/2987] Add service backup.create_automatic (#136152) --- homeassistant/components/backup/__init__.py | 18 ++++ homeassistant/components/backup/icons.json | 3 + homeassistant/components/backup/services.yaml | 1 + homeassistant/components/backup/strings.json | 4 + tests/components/backup/test_init.py | 92 ++++++++++++++++++- 5 files changed, 117 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 93cadcfb2f3..8d25a0c25cb 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -88,8 +88,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: password=None, ) + async def async_handle_create_automatic_service(call: ServiceCall) -> None: + """Service handler for creating automatic backups.""" + config_data = backup_manager.config.data + await backup_manager.async_create_backup( + agent_ids=config_data.create_backup.agent_ids, + include_addons=config_data.create_backup.include_addons, + include_all_addons=config_data.create_backup.include_all_addons, + include_database=config_data.create_backup.include_database, + include_folders=config_data.create_backup.include_folders, + include_homeassistant=True, # always include HA + name=config_data.create_backup.name, + password=config_data.create_backup.password, + with_automatic_settings=True, + ) + if not with_hassio: hass.services.async_register(DOMAIN, "create", async_handle_create_service) + hass.services.async_register( + DOMAIN, "create_automatic", async_handle_create_automatic_service + ) async_register_http_views(hass) diff --git a/homeassistant/components/backup/icons.json b/homeassistant/components/backup/icons.json index bd5ff4a81ee..8a412f66edc 100644 --- a/homeassistant/components/backup/icons.json +++ b/homeassistant/components/backup/icons.json @@ -2,6 +2,9 @@ "services": { "create": { "service": "mdi:cloud-upload" + }, + "create_automatic": { + "service": "mdi:cloud-upload" } } } diff --git a/homeassistant/components/backup/services.yaml b/homeassistant/components/backup/services.yaml index 900aa39dd6e..70900f93bff 100644 --- a/homeassistant/components/backup/services.yaml +++ b/homeassistant/components/backup/services.yaml @@ -1 +1,2 @@ create: +create_automatic: diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json index 43ae57cc781..32d76ded049 100644 --- a/homeassistant/components/backup/strings.json +++ b/homeassistant/components/backup/strings.json @@ -13,6 +13,10 @@ "create": { "name": "Create backup", "description": "Creates a new backup." + }, + "create_automatic": { + "name": "Create automatic backup", + "description": "Creates a new backup with automatic backup settings." } } } diff --git a/tests/components/backup/test_init.py b/tests/components/backup/test_init.py index 16a49af9647..925e2cb9b7a 100644 --- a/tests/components/backup/test_init.py +++ b/tests/components/backup/test_init.py @@ -11,6 +11,8 @@ from homeassistant.exceptions import ServiceNotFound from .common import setup_backup_integration +from tests.typing import WebSocketGenerator + @pytest.mark.usefixtures("supervisor_client") async def test_setup_with_hassio( @@ -45,7 +47,16 @@ async def test_create_service( service_data=service_data, ) - assert generate_backup.called + generate_backup.assert_called_once_with( + agent_ids=["backup.local"], + include_addons=None, + include_all_addons=False, + include_database=True, + include_folders=None, + include_homeassistant=True, + name=None, + password=None, + ) async def test_create_service_with_hassio(hass: HomeAssistant) -> None: @@ -54,3 +65,82 @@ async def test_create_service_with_hassio(hass: HomeAssistant) -> None: with pytest.raises(ServiceNotFound): await hass.services.async_call(DOMAIN, "create", blocking=True) + + +@pytest.mark.parametrize( + ("commands", "expected_kwargs"), + [ + ( + [], + { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": None, + "password": None, + "with_automatic_settings": True, + }, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "name": "cool_backup", + "password": "hunter2", + }, + }, + ], + { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "include_homeassistant": True, + "name": "cool_backup", + "password": "hunter2", + "with_automatic_settings": True, + }, + ), + ], +) +@pytest.mark.parametrize("service_data", [None, {}]) +@pytest.mark.parametrize("with_hassio", [True, False]) +@pytest.mark.usefixtures("supervisor_client") +async def test_create_automatic_service( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + commands: list[dict[str, Any]], + expected_kwargs: dict[str, Any], + service_data: dict[str, Any] | None, + with_hassio: bool, +) -> None: + """Test generate backup.""" + await setup_backup_integration(hass, with_hassio=with_hassio) + + client = await hass_ws_client(hass) + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as generate_backup: + await hass.services.async_call( + DOMAIN, + "create_automatic", + blocking=True, + service_data=service_data, + ) + + generate_backup.assert_called_once_with(**expected_kwargs) From 5b49ba563e2f7a92a6c807c0e6aa9dcde970906f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 21 Jan 2025 08:33:37 -0500 Subject: [PATCH 0773/2987] Satellite announcement to track original media id (#136141) --- .../components/assist_satellite/entity.py | 14 ++++++++++++-- .../assist_satellite/test_entity.py | 19 +++++++++++++++---- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 8be136653ba..e9a5d22c0d0 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -96,7 +96,11 @@ class AssistSatelliteAnnouncement: media_id: str """Media ID to be played.""" + original_media_id: str + """The raw media ID before processing.""" + media_id_source: Literal["url", "media_id", "tts"] + """Source of the media ID.""" class AssistSatelliteEntity(entity.Entity): @@ -396,7 +400,10 @@ class AssistSatelliteEntity(entity.Entity): """Resolve the media ID.""" media_id_source: Literal["url", "media_id", "tts"] | None = None - if not media_id: + if media_id: + original_media_id = media_id + + else: media_id_source = "tts" # Synthesize audio and get URL pipeline_id = self._resolve_pipeline() @@ -416,6 +423,7 @@ class AssistSatelliteEntity(entity.Entity): language=pipeline.tts_language, options=tts_options, ) + original_media_id = media_id if media_source.is_media_source_id(media_id): if not media_id_source: @@ -433,4 +441,6 @@ class AssistSatelliteEntity(entity.Entity): # Resolve to full URL media_id = async_process_play_media_url(self.hass, media_id) - return AssistSatelliteAnnouncement(message, media_id, media_id_source) + return AssistSatelliteAnnouncement( + message, media_id, original_media_id, media_id_source + ) diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 0961c7dfbca..c3464beac97 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -163,21 +163,32 @@ async def test_new_pipeline_cancels_pipeline( ( {"message": "Hello"}, AssistSatelliteAnnouncement( - "Hello", "https://www.home-assistant.io/resolved.mp3", "tts" + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://bla", + media_id_source="tts", ), ), ( { "message": "Hello", - "media_id": "media-source://bla", + "media_id": "media-source://given", }, AssistSatelliteAnnouncement( - "Hello", "https://www.home-assistant.io/resolved.mp3", "media_id" + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://given", + media_id_source="media_id", ), ), ( {"media_id": "http://example.com/bla.mp3"}, - AssistSatelliteAnnouncement("", "http://example.com/bla.mp3", "url"), + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/bla.mp3", + original_media_id="http://example.com/bla.mp3", + media_id_source="url", + ), ), ], ) From a2cbaef26499aa57908618c3a2e4d85225afe2d4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jan 2025 14:37:44 +0100 Subject: [PATCH 0774/2987] Prepare backup store to read version 2 (#136149) --- homeassistant/components/backup/config.py | 2 +- homeassistant/components/backup/store.py | 4 +- .../backup/snapshots/test_store.ambr | 130 ++++++++++++++++- tests/components/backup/test_store.py | 133 +++++++++++++----- 4 files changed, 234 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index da6a2b85ccb..bcfa95463d1 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -102,7 +102,7 @@ class BackupConfigData: schedule=BackupSchedule( days=days, recurrence=ScheduleRecurrence(data["schedule"]["recurrence"]), - state=ScheduleState(data["schedule"]["state"]), + state=ScheduleState(data["schedule"].get("state", ScheduleState.NEVER)), time=time, ), ) diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index b8241bb771d..0e1c49426c5 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -57,7 +57,9 @@ class _BackupStore(Store[StoredBackupData]): data["config"]["schedule"]["days"] = [state] data["config"]["schedule"]["recurrence"] = "custom_days" - if old_major_version > 1: + # Note: We allow reading data with major version 2. + # Reject if major version is higher than 2. + if old_major_version > 2: raise NotImplementedError return data diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index a66bbe43b85..45af91645ad 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_store_migration +# name: test_store_migration[store_data0] dict({ 'data': dict({ 'backups': list([ @@ -41,3 +41,131 @@ 'version': 1, }) # --- +# name: test_store_migration[store_data0].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data1] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + 'something_from_the_future': 'value', + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data1].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py index d240e21531d..cc84b66340c 100644 --- a/tests/components/backup/test_store.py +++ b/tests/components/backup/test_store.py @@ -1,7 +1,10 @@ """Tests for the Backup integration.""" +from collections.abc import Generator from typing import Any +from unittest.mock import patch +import pytest from syrupy import SnapshotAssertion from homeassistant.components.backup.const import DOMAIN @@ -9,46 +12,112 @@ from homeassistant.core import HomeAssistant from .common import setup_backup_integration +from tests.typing import WebSocketGenerator + +@pytest.fixture(autouse=True) +def mock_delay_save() -> Generator[None]: + """Mock the delay save constant.""" + with patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0): + yield + + +@pytest.mark.parametrize( + "store_data", + [ + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "state": "never", + }, + }, + }, + "key": DOMAIN, + "version": 1, + }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "days": [], + "recurrence": "never", + "time": None, + }, + "something_from_the_future": "value", + }, + }, + "key": DOMAIN, + "version": 2, + }, + ], +) async def test_store_migration( hass: HomeAssistant, hass_storage: dict[str, Any], + hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion, + store_data: dict[str, Any], ) -> None: """Test migrating the backup store.""" - hass_storage[DOMAIN] = { - "data": { - "backups": [ - { - "backup_id": "abc123", - "failed_agent_ids": ["test.remote"], - } - ], - "config": { - "create_backup": { - "agent_ids": [], - "include_addons": None, - "include_all_addons": False, - "include_database": True, - "include_folders": None, - "name": None, - "password": None, - }, - "last_attempted_automatic_backup": None, - "last_completed_automatic_backup": None, - "retention": { - "copies": None, - "days": None, - }, - "schedule": { - "state": "never", - }, - }, - }, - "key": DOMAIN, - "version": 1, - } + hass_storage[DOMAIN] = store_data await setup_backup_integration(hass) await hass.async_block_till_done() + # Check migrated data + assert hass_storage[DOMAIN] == snapshot + + # Update settings, then check saved data + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + } + ) + result = await client.receive_json() + assert result["success"] + await hass.async_block_till_done() assert hass_storage[DOMAIN] == snapshot From 032940f1a970292cab3627fd98ee0eff875aa9cd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jan 2025 14:41:37 +0100 Subject: [PATCH 0775/2987] Gate update.install backup parameter by supported feature (#136169) --- homeassistant/components/update/services.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/update/services.yaml b/homeassistant/components/update/services.yaml index 036af10150a..45e30fee09e 100644 --- a/homeassistant/components/update/services.yaml +++ b/homeassistant/components/update/services.yaml @@ -9,6 +9,9 @@ install: selector: text: backup: + filter: + supported_features: + - update.UpdateEntityFeature.BACKUP required: false selector: boolean: From fb96ef99d03368875026c2a226bcb94734dd6ffe Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Tue, 21 Jan 2025 14:02:42 +0000 Subject: [PATCH 0776/2987] Homee sensor (#135447) Co-authored-by: Joostlek --- homeassistant/components/homee/__init__.py | 2 +- homeassistant/components/homee/const.py | 56 ++++ homeassistant/components/homee/entity.py | 71 ++++- homeassistant/components/homee/helpers.py | 10 +- homeassistant/components/homee/icons.json | 12 + homeassistant/components/homee/sensor.py | 303 ++++++++++++++++++++ homeassistant/components/homee/strings.json | 79 +++++ 7 files changed, 525 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/homee/icons.json create mode 100644 homeassistant/components/homee/sensor.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index ed5dd69767f..1ec09e09694 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -14,7 +14,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.COVER] +PLATFORMS = [Platform.COVER, Platform.SENSOR] type HomeeConfigEntry = ConfigEntry[Homee] diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index c96165ead81..8595f042af8 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -1,4 +1,60 @@ """Constants for the homee integration.""" +from homeassistant.const import ( + LIGHT_LUX, + PERCENTAGE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfSpeed, + UnitOfTemperature, + UnitOfTime, + UnitOfVolume, +) + # General DOMAIN = "homee" + +# Sensor mappings +HOMEE_UNIT_TO_HA_UNIT = { + "": None, + "n/a": None, + "text": None, + "%": PERCENTAGE, + "lx": LIGHT_LUX, + "klx": LIGHT_LUX, + "A": UnitOfElectricCurrent.AMPERE, + "V": UnitOfElectricPotential.VOLT, + "kWh": UnitOfEnergy.KILO_WATT_HOUR, + "W": UnitOfPower.WATT, + "m/s": UnitOfSpeed.METERS_PER_SECOND, + "km/h": UnitOfSpeed.KILOMETERS_PER_HOUR, + "°F": UnitOfTemperature.FAHRENHEIT, + "°C": UnitOfTemperature.CELSIUS, + "K": UnitOfTemperature.KELVIN, + "s": UnitOfTime.SECONDS, + "min": UnitOfTime.MINUTES, + "h": UnitOfTime.HOURS, + "L": UnitOfVolume.LITERS, +} +OPEN_CLOSE_MAP = { + 0.0: "open", + 1.0: "closed", + 2.0: "partial", + 3.0: "opening", + 4.0: "closing", +} +OPEN_CLOSE_MAP_REVERSED = { + 0.0: "closed", + 1.0: "open", + 2.0: "partial", + 3.0: "cosing", + 4.0: "opening", +} +WINDOW_MAP = { + 0.0: "closed", + 1.0: "open", + 2.0: "tilted", +} +WINDOW_MAP_REVERSED = {0.0: "open", 1.0: "closed", 2.0: "tilted"} diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py index 91b23b5a2c2..2af01358752 100644 --- a/homeassistant/components/homee/entity.py +++ b/homeassistant/components/homee/entity.py @@ -1,6 +1,6 @@ """Base Entities for Homee integration.""" -from pyHomee.const import AttributeType, NodeProfile, NodeState +from pyHomee.const import AttributeState, AttributeType, NodeProfile, NodeState from pyHomee.model import HomeeAttribute, HomeeNode from homeassistant.helpers.device_registry import DeviceInfo @@ -11,6 +11,56 @@ from .const import DOMAIN from .helpers import get_name_for_enum +class HomeeEntity(Entity): + """Represents a Homee entity consisting of a single HomeeAttribute.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, attribute: HomeeAttribute, entry: HomeeConfigEntry) -> None: + """Initialize the wrapper using a HomeeAttribute and target entity.""" + self._attribute = attribute + self._attr_unique_id = ( + f"{entry.runtime_data.settings.uid}-{attribute.node_id}-{attribute.id}" + ) + self._entry = entry + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, f"{entry.runtime_data.settings.uid}-{attribute.node_id}") + } + ) + + self._host_connected = entry.runtime_data.connected + + async def async_added_to_hass(self) -> None: + """Add the homee attribute entity to home assistant.""" + self.async_on_remove( + self._attribute.add_on_changed_listener(self._on_node_updated) + ) + self.async_on_remove( + await self._entry.runtime_data.add_connection_listener( + self._on_connection_changed + ) + ) + + @property + def available(self) -> bool: + """Return the availability of the underlying node.""" + return (self._attribute.state == AttributeState.NORMAL) and self._host_connected + + async def async_update(self) -> None: + """Update entity from homee.""" + homee = self._entry.runtime_data + await homee.update_attribute(self._attribute.node_id, self._attribute.id) + + def _on_node_updated(self, attribute: HomeeAttribute) -> None: + self.schedule_update_ha_state() + + async def _on_connection_changed(self, connected: bool) -> None: + self._host_connected = connected + self.schedule_update_ha_state() + + class HomeeNodeEntity(Entity): """Representation of an Entity that uses more than one HomeeAttribute.""" @@ -20,7 +70,7 @@ class HomeeNodeEntity(Entity): def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: """Initialize the wrapper using a HomeeNode and target entity.""" self._node = node - self._attr_unique_id = f"{entry.runtime_data.settings.uid}-{node.id}" + self._attr_unique_id = f"{entry.unique_id}-{node.id}" self._entry = entry self._attr_device_info = DeviceInfo( @@ -41,6 +91,23 @@ class HomeeNodeEntity(Entity): ) ) + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + # Homee hub has id -1, but is identified only by the UID. + if self._node.id == -1: + return DeviceInfo( + identifiers={(DOMAIN, self._entry.runtime_data.settings.uid)}, + ) + + return DeviceInfo( + identifiers={(DOMAIN, f"{self._entry.unique_id}-{self._node.id}")}, + name=self._node.name, + model=get_name_for_enum(NodeProfile, self._node.profile), + sw_version=self._get_software_version(), + via_device=(DOMAIN, self._entry.runtime_data.settings.uid), + ) + @property def available(self) -> bool: """Return the availability of the underlying node.""" diff --git a/homeassistant/components/homee/helpers.py b/homeassistant/components/homee/helpers.py index 30826d7f47c..b73b1ae2bc9 100644 --- a/homeassistant/components/homee/helpers.py +++ b/homeassistant/components/homee/helpers.py @@ -1,16 +1,16 @@ """Helper functions for the homee custom component.""" +from enum import IntEnum import logging _LOGGER = logging.getLogger(__name__) -def get_name_for_enum(att_class, att_id) -> str: +def get_name_for_enum(att_class: type[IntEnum], att_id: int) -> str | None: """Return the enum item name for a given integer.""" try: - attribute_name = att_class(att_id).name + item = att_class(att_id) except ValueError: _LOGGER.warning("Value %s does not exist in %s", att_id, att_class.__name__) - return "Unknown" - - return attribute_name + return None + return item.name.lower() diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json new file mode 100644 index 00000000000..3b1ee17b89c --- /dev/null +++ b/homeassistant/components/homee/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "link_quality": { + "default": "mdi:signal" + }, + "window_position": { + "default": "mdi:window-closed" + } + } + } +} diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py new file mode 100644 index 00000000000..75b11811460 --- /dev/null +++ b/homeassistant/components/homee/sensor.py @@ -0,0 +1,303 @@ +"""The homee sensor platform.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from pyHomee.const import AttributeType, NodeState +from pyHomee.model import HomeeAttribute, HomeeNode + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeeConfigEntry +from .const import ( + HOMEE_UNIT_TO_HA_UNIT, + OPEN_CLOSE_MAP, + OPEN_CLOSE_MAP_REVERSED, + WINDOW_MAP, + WINDOW_MAP_REVERSED, +) +from .entity import HomeeEntity, HomeeNodeEntity +from .helpers import get_name_for_enum + + +def get_open_close_value(attribute: HomeeAttribute) -> str | None: + """Return the open/close value.""" + vals = OPEN_CLOSE_MAP if not attribute.is_reversed else OPEN_CLOSE_MAP_REVERSED + return vals.get(attribute.current_value) + + +def get_window_value(attribute: HomeeAttribute) -> str | None: + """Return the states of a window open sensor.""" + vals = WINDOW_MAP if not attribute.is_reversed else WINDOW_MAP_REVERSED + return vals.get(attribute.current_value) + + +@dataclass(frozen=True, kw_only=True) +class HomeeSensorEntityDescription(SensorEntityDescription): + """A class that describes Homee sensor entities.""" + + value_fn: Callable[[HomeeAttribute], str | float | None] = ( + lambda value: value.current_value + ) + native_unit_of_measurement_fn: Callable[[str], str | None] = ( + lambda homee_unit: HOMEE_UNIT_TO_HA_UNIT[homee_unit] + ) + + +SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = { + AttributeType.ACCUMULATED_ENERGY_USE: HomeeSensorEntityDescription( + key="energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + AttributeType.BATTERY_LEVEL: HomeeSensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.BRIGHTNESS: HomeeSensorEntityDescription( + key="brightness", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=( + lambda attribute: attribute.current_value * 1000 + if attribute.unit == "klx" + else attribute.current_value + ), + ), + AttributeType.CURRENT: HomeeSensorEntityDescription( + key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.CURRENT_ENERGY_USE: HomeeSensorEntityDescription( + key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.CURRENT_VALVE_POSITION: HomeeSensorEntityDescription( + key="valve_position", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.DAWN: HomeeSensorEntityDescription( + key="dawn", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.DEVICE_TEMPERATURE: HomeeSensorEntityDescription( + key="device_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.LEVEL: HomeeSensorEntityDescription( + key="level", + device_class=SensorDeviceClass.VOLUME, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.LINK_QUALITY: HomeeSensorEntityDescription( + key="link_quality", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.POSITION: HomeeSensorEntityDescription( + key="position", + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.RAIN_FALL_LAST_HOUR: HomeeSensorEntityDescription( + key="rainfall_hour", + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.RAIN_FALL_TODAY: HomeeSensorEntityDescription( + key="rainfall_day", + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.RELATIVE_HUMIDITY: HomeeSensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.TEMPERATURE: HomeeSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.TOTAL_ACCUMULATED_ENERGY_USE: HomeeSensorEntityDescription( + key="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + AttributeType.TOTAL_CURRENT: HomeeSensorEntityDescription( + key="total_current", + device_class=SensorDeviceClass.CURRENT, + ), + AttributeType.TOTAL_CURRENT_ENERGY_USE: HomeeSensorEntityDescription( + key="total_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.TOTAL_VOLTAGE: HomeeSensorEntityDescription( + key="total_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.UP_DOWN: HomeeSensorEntityDescription( + key="up_down", + device_class=SensorDeviceClass.ENUM, + options=[ + "open", + "closed", + "partial", + "opening", + "closing", + ], + value_fn=get_open_close_value, + ), + AttributeType.UV: HomeeSensorEntityDescription( + key="uv", + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.VOLTAGE: HomeeSensorEntityDescription( + key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.WIND_SPEED: HomeeSensorEntityDescription( + key="wind_speed", + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.WINDOW_POSITION: HomeeSensorEntityDescription( + key="window_position", + device_class=SensorDeviceClass.ENUM, + options=["closed", "open", "tilted"], + value_fn=get_window_value, + ), +} + + +@dataclass(frozen=True, kw_only=True) +class HomeeNodeSensorEntityDescription(SensorEntityDescription): + """Describes Homee node sensor entities.""" + + value_fn: Callable[[HomeeNode], str | None] + + +NODE_SENSOR_DESCRIPTIONS: tuple[HomeeNodeSensorEntityDescription, ...] = ( + HomeeNodeSensorEntityDescription( + key="state", + device_class=SensorDeviceClass.ENUM, + options=[ + "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", + ], + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda node: get_name_for_enum(NodeState, node.state), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddEntitiesCallback, +) -> None: + """Add the homee platform for the sensor components.""" + + devices: list[HomeeSensor | HomeeNodeSensor] = [] + for node in config_entry.runtime_data.nodes: + # Node properties that are sensors. + devices.extend( + HomeeNodeSensor(node, config_entry, description) + for description in NODE_SENSOR_DESCRIPTIONS + ) + + # Node attributes that are sensors. + devices.extend( + HomeeSensor(attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type]) + for attribute in node.attributes + if attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable + ) + + if devices: + async_add_devices(devices) + + +class HomeeSensor(HomeeEntity, SensorEntity): + """Representation of a homee sensor.""" + + entity_description: HomeeSensorEntityDescription + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: HomeeSensorEntityDescription, + ) -> None: + """Initialize a homee sensor entity.""" + super().__init__(attribute, entry) + self.entity_description = description + self._attr_translation_key = description.key + if attribute.instance > 0: + self._attr_translation_key = f"{description.translation_key}_instance" + self._attr_translation_placeholders = {"instance": str(attribute.instance)} + + @property + def native_value(self) -> float | str | None: + """Return the native value of the sensor.""" + return self.entity_description.value_fn(self._attribute) + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the native unit of the sensor.""" + return self.entity_description.native_unit_of_measurement_fn( + self._attribute.unit + ) + + +class HomeeNodeSensor(HomeeNodeEntity, SensorEntity): + """Represents a sensor based on a node's property.""" + + entity_description: HomeeNodeSensorEntityDescription + + def __init__( + self, + node: HomeeNode, + entry: HomeeConfigEntry, + description: HomeeNodeSensorEntityDescription, + ) -> None: + """Initialize a homee node sensor entity.""" + super().__init__(node, entry) + self.entity_description = description + self._attr_translation_key = f"node_{description.key}" + self._node = node + self._attr_unique_id = f"{self._attr_unique_id}-{description.key}" + + @property + def native_value(self) -> str | None: + """Return the sensors value.""" + return self.entity_description.value_fn(self._node) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 54f80ba2977..a657465126b 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -24,5 +24,84 @@ } } } + }, + "entity": { + "sensor": { + "brightness_instance": { + "name": "Illuminance {instance}" + }, + "current_instance": { + "name": "Current {instance}" + }, + "dawn": { + "name": "Dawn" + }, + "device_temperature": { + "name": "Device temperature" + }, + "energy_instance": { + "name": "Energy {instance}" + }, + "level": { + "name": "Level" + }, + "link_quality": { + "name": "Link quality" + }, + "node_state": { + "name": "Node state" + }, + "position": { + "name": "Position" + }, + "power_instance": { + "name": "Power {instance}" + }, + "rainfall_hour": { + "name": "Rainfall last hour" + }, + "rainfall_day": { + "name": "Rainfall today" + }, + "total_current": { + "name": "Total current" + }, + "total_energy": { + "name": "Total energy" + }, + "total_power": { + "name": "Total power" + }, + "total_voltage": { + "name": "Total voltage" + }, + "up_down": { + "name": "State", + "state": { + "open": "[%key:common::state::open%]", + "closed": "[%key:common::state::closed%]", + "partial": "Partially open", + "opening": "Opening", + "closing": "Closing" + } + }, + "uv": { + "name": "Ultraviolet" + }, + "valve_position": { + "name": "Valve position" + }, + "voltage_instance": { + "name": "Voltage {instance}" + }, + "window_position": { + "name": "Window position", + "state": { + "closed": "[%key:common::state::closed%]", + "open": "[%key:common::state::open%]", + "tilted": "Tilted" + } + } + } } } From b93907ab026dd90516f37dc2a6185271910f492e Mon Sep 17 00:00:00 2001 From: Huyuwei Date: Tue, 21 Jan 2025 22:12:09 +0800 Subject: [PATCH 0777/2987] Add data_description to switchbot translations (#136148) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/switchbot/strings.json | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 2a5ddaa0cba..fe44bc39e62 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -4,7 +4,10 @@ "step": { "user": { "data": { - "address": "Device address" + "address": "MAC address" + }, + "data_description": { + "address": "The Bluetooth MAC address of your SwitchBot device" } }, "confirm": { @@ -14,6 +17,9 @@ "description": "The {name} device requires a password", "data": { "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "The password required for the Bot device access" } }, "encrypted_key": { @@ -21,6 +27,10 @@ "data": { "key_id": "Key ID", "encryption_key": "Encryption key" + }, + "data_description": { + "key_id": "The ID of the encryption key", + "encryption_key": "The encryption key for the device" } }, "encrypted_auth": { @@ -28,6 +38,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "The username of your SwitchBot account", + "password": "The password of your SwitchBot account" } }, "encrypted_choose_method": { From 380c2ac600dbd73420b8a37f21e1295d7dddd19f Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Tue, 21 Jan 2025 15:47:42 +0100 Subject: [PATCH 0778/2987] Bumb python-homewizard-energy to 8.1.1 (#136170) --- homeassistant/components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index fc060961d10..4cc94d09d74 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==v8.1.0"], + "requirements": ["python-homewizard-energy==v8.1.1"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 473687aae9d..2a05e882e17 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2381,7 +2381,7 @@ python-gitlab==1.6.0 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.1.0 +python-homewizard-energy==v8.1.1 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac414f5f52a..0e7b4a92a93 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1926,7 +1926,7 @@ python-fullykiosk==0.0.14 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.1.0 +python-homewizard-energy==v8.1.1 # homeassistant.components.izone python-izone==1.2.9 From 3b79ded0b0f7bde2352284d3f1662b86b4658c75 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jan 2025 15:52:46 +0100 Subject: [PATCH 0779/2987] Use HassKey for hassio component data (#136172) --- homeassistant/components/hassio/__init__.py | 3 ++- homeassistant/components/hassio/const.py | 10 ++++++++++ homeassistant/components/hassio/coordinator.py | 5 +++-- homeassistant/components/hassio/handler.py | 16 ++++++++-------- homeassistant/components/hassio/websocket_api.py | 5 ++--- 5 files changed, 25 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index b95f520b9e0..d71b2b85f7b 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -90,6 +90,7 @@ from .const import ( ATTR_LOCATION, ATTR_PASSWORD, ATTR_SLUG, + DATA_COMPONENT, DATA_CORE_INFO, DATA_HOST_INFO, DATA_INFO, @@ -326,7 +327,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: host = os.environ["SUPERVISOR"] websession = async_get_clientsession(hass) - hass.data[DOMAIN] = hassio = HassIO(hass.loop, websession, host) + hass.data[DATA_COMPONENT] = hassio = HassIO(hass.loop, websession, host) supervisor_client = get_supervisor_client(hass) try: diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 82ce74832c2..d1cda51ec7b 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -1,7 +1,16 @@ """Hass.io const variables.""" +from __future__ import annotations + from datetime import timedelta from enum import StrEnum +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .handler import HassIO + DOMAIN = "hassio" @@ -64,6 +73,7 @@ UPDATE_KEY_SUPERVISOR = "supervisor" ADDONS_COORDINATOR = "hassio_addons_coordinator" +DATA_COMPONENT: HassKey[HassIO] = HassKey(DOMAIN) DATA_CORE_INFO = "hassio_core_info" DATA_CORE_STATS = "hassio_core_stats" DATA_HOST_INFO = "hassio_host_info" diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index cb1dda8aeed..2d39e740e63 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -35,6 +35,7 @@ from .const import ( DATA_ADDONS_CHANGELOGS, DATA_ADDONS_INFO, DATA_ADDONS_STATS, + DATA_COMPONENT, DATA_CORE_INFO, DATA_CORE_STATS, DATA_HOST_INFO, @@ -56,7 +57,7 @@ from .const import ( SUPERVISOR_CONTAINER, SupervisorEntityModel, ) -from .handler import HassIO, HassioAPIError, get_supervisor_client +from .handler import HassioAPIError, get_supervisor_client if TYPE_CHECKING: from .issues import SupervisorIssues @@ -310,7 +311,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False ), ) - self.hassio: HassIO = hass.data[DOMAIN] + self.hassio = hass.data[DATA_COMPONENT] self.data = {} self.entry_id = config_entry.entry_id self.dev_reg = dev_reg diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 254c392462c..752f535ca04 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.singleton import singleton from homeassistant.loader import bind_hass -from .const import ATTR_MESSAGE, ATTR_RESULT, DOMAIN, X_HASS_SOURCE +from .const import ATTR_MESSAGE, ATTR_RESULT, DATA_COMPONENT, X_HASS_SOURCE _LOGGER = logging.getLogger(__name__) @@ -72,7 +72,7 @@ async def async_update_diagnostics(hass: HomeAssistant, diagnostics: bool) -> bo The caller of the function should handle HassioAPIError. """ - hassio: HassIO = hass.data[DOMAIN] + hassio = hass.data[DATA_COMPONENT] return await hassio.update_diagnostics(diagnostics) @@ -85,7 +85,7 @@ async def async_create_backup( The caller of the function should handle HassioAPIError. """ - hassio: HassIO = hass.data[DOMAIN] + hassio = hass.data[DATA_COMPONENT] backup_type = "partial" if partial else "full" command = f"/backups/new/{backup_type}" return await hassio.send_command(command, payload=payload, timeout=None) @@ -94,7 +94,7 @@ async def async_create_backup( @api_data async def async_get_green_settings(hass: HomeAssistant) -> dict[str, bool]: """Return settings specific to Home Assistant Green.""" - hassio: HassIO = hass.data[DOMAIN] + hassio = hass.data[DATA_COMPONENT] return await hassio.send_command("/os/boards/green", method="get") @@ -106,7 +106,7 @@ async def async_set_green_settings( Returns an empty dict. """ - hassio: HassIO = hass.data[DOMAIN] + hassio = hass.data[DATA_COMPONENT] return await hassio.send_command( "/os/boards/green", method="post", payload=settings ) @@ -115,7 +115,7 @@ async def async_set_green_settings( @api_data async def async_get_yellow_settings(hass: HomeAssistant) -> dict[str, bool]: """Return settings specific to Home Assistant Yellow.""" - hassio: HassIO = hass.data[DOMAIN] + hassio = hass.data[DATA_COMPONENT] return await hassio.send_command("/os/boards/yellow", method="get") @@ -127,7 +127,7 @@ async def async_set_yellow_settings( Returns an empty dict. """ - hassio: HassIO = hass.data[DOMAIN] + hassio = hass.data[DATA_COMPONENT] return await hassio.send_command( "/os/boards/yellow", method="post", payload=settings ) @@ -333,7 +333,7 @@ class HassIO: @singleton(KEY_SUPERVISOR_CLIENT) def get_supervisor_client(hass: HomeAssistant) -> SupervisorClient: """Return supervisor client.""" - hassio: HassIO = hass.data[DOMAIN] + hassio = hass.data[DATA_COMPONENT] return SupervisorClient( str(hassio.base_url), os.environ.get("SUPERVISOR_TOKEN", ""), diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 954d9ee8a02..f9d1b40575b 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -25,7 +25,7 @@ from .const import ( ATTR_SESSION_DATA_USER_ID, ATTR_TIMEOUT, ATTR_WS_EVENT, - DOMAIN, + DATA_COMPONENT, EVENT_SUPERVISOR_EVENT, WS_ID, WS_TYPE, @@ -33,7 +33,6 @@ from .const import ( WS_TYPE_EVENT, WS_TYPE_SUBSCRIBE, ) -from .handler import HassIO SCHEMA_WEBSOCKET_EVENT = vol.Schema( {vol.Required(ATTR_WS_EVENT): cv.string}, @@ -113,7 +112,7 @@ async def websocket_supervisor_api( msg[ATTR_ENDPOINT] ): raise Unauthorized - supervisor: HassIO = hass.data[DOMAIN] + supervisor = hass.data[DATA_COMPONENT] command = msg[ATTR_ENDPOINT] payload = msg.get(ATTR_DATA, {}) From b11b36b5230889b25221bdb6b8b9045ad9bd1296 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Jan 2025 15:58:23 +0100 Subject: [PATCH 0780/2987] Add more util aliases to import conventions (#136153) --- .../components/anthropic/conversation.py | 8 +++---- .../components/blueprint/importer.py | 12 +++++----- homeassistant/components/blueprint/models.py | 12 ++++++---- .../components/blueprint/websocket_api.py | 6 ++--- homeassistant/components/citybikes/sensor.py | 6 ++--- homeassistant/components/config/core.py | 4 ++-- .../components/conversation/session.py | 8 +++---- .../dwd_weather_warnings/coordinator.py | 4 ++-- .../conversation.py | 4 ++-- homeassistant/components/http/ban.py | 4 ++-- homeassistant/components/intent/timers.py | 4 ++-- .../components/mcp_server/session.py | 4 ++-- .../components/ollama/conversation.py | 4 ++-- homeassistant/components/ps4/__init__.py | 6 +++-- homeassistant/components/ps4/config_flow.py | 6 ++--- homeassistant/components/webhook/__init__.py | 4 ++-- .../components/wyoming/conversation.py | 4 ++-- .../yamaha_musiccast/media_player.py | 4 ++-- homeassistant/helpers/llm.py | 4 ++-- homeassistant/helpers/location.py | 4 ++-- homeassistant/helpers/template.py | 4 ++-- pyproject.toml | 8 +++++++ .../components/anthropic/test_conversation.py | 4 ++-- tests/components/automation/test_blueprint.py | 4 ++-- tests/components/automation/test_init.py | 10 ++++---- .../blueprint/test_default_blueprints.py | 4 ++-- tests/components/config/test_automation.py | 4 ++-- tests/components/config/test_core.py | 4 ++-- tests/components/config/test_script.py | 4 ++-- tests/components/conftest.py | 4 ++-- tests/components/hue/test_light_v1.py | 12 +++++----- tests/components/ps4/test_config_flow.py | 10 ++++---- tests/components/ps4/test_init.py | 4 ++-- tests/components/script/test_blueprint.py | 4 ++-- tests/components/script/test_init.py | 4 ++-- tests/components/template/test_blueprint.py | 4 ++-- tests/conftest.py | 6 +++-- tests/helpers/test_selector.py | 4 ++-- tests/util/yaml/test_init.py | 24 +++++++++---------- tests/util/yaml/test_secrets.py | 14 ++++++----- 40 files changed, 134 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 20e555e9592..e45e849adf6 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -27,7 +27,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers import device_registry as dr, intent, llm, template from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid +from homeassistant.util import ulid as ulid_util from . import AnthropicConfigEntry from .const import ( @@ -164,7 +164,7 @@ class AnthropicConversationEntity( ] if user_input.conversation_id is None: - conversation_id = ulid.ulid_now() + conversation_id = ulid_util.ulid_now() messages = [] elif user_input.conversation_id in self.history: @@ -177,8 +177,8 @@ class AnthropicConversationEntity( # a new conversation was started. If the user picks their own, they # want to track a conversation and we respect it. try: - ulid.ulid_to_bytes(user_input.conversation_id) - conversation_id = ulid.ulid_now() + ulid_util.ulid_to_bytes(user_input.conversation_id) + conversation_id = ulid_util.ulid_now() except ValueError: conversation_id = user_input.conversation_id diff --git a/homeassistant/components/blueprint/importer.py b/homeassistant/components/blueprint/importer.py index 544f9554b9f..8582761bafb 100644 --- a/homeassistant/components/blueprint/importer.py +++ b/homeassistant/components/blueprint/importer.py @@ -13,7 +13,7 @@ import yarl from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client, config_validation as cv -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from .models import Blueprint from .schemas import BLUEPRINT_SCHEMA, is_blueprint_config @@ -115,7 +115,7 @@ def _extract_blueprint_from_community_topic( block_content = html.unescape(block_content.strip()) try: - data = yaml.parse_yaml(block_content) + data = yaml_util.parse_yaml(block_content) except HomeAssistantError: if block_syntax == "yaml": raise @@ -167,7 +167,7 @@ async def fetch_blueprint_from_github_url( resp = await session.get(import_url, raise_for_status=True) raw_yaml = await resp.text() - data = yaml.parse_yaml(raw_yaml) + data = yaml_util.parse_yaml(raw_yaml) assert isinstance(data, dict) blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA) @@ -204,7 +204,7 @@ async def fetch_blueprint_from_github_gist_url( continue content = info["content"] - data = yaml.parse_yaml(content) + data = yaml_util.parse_yaml(content) if not is_blueprint_config(data): continue @@ -235,7 +235,7 @@ async def fetch_blueprint_from_website_url( resp = await session.get(url, raise_for_status=True) raw_yaml = await resp.text() - data = yaml.parse_yaml(raw_yaml) + data = yaml_util.parse_yaml(raw_yaml) assert isinstance(data, dict) blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA) @@ -252,7 +252,7 @@ async def fetch_blueprint_from_generic_url( resp = await session.get(url, raise_for_status=True) raw_yaml = await resp.text() - data = yaml.parse_yaml(raw_yaml) + data = yaml_util.parse_yaml(raw_yaml) assert isinstance(data, dict) blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA) diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index f32c3f04989..88052100259 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from .const import ( BLUEPRINT_FOLDER, @@ -79,7 +79,7 @@ class Blueprint: self.domain = data_domain - missing = yaml.extract_inputs(data) - set(self.inputs) + missing = yaml_util.extract_inputs(data) - set(self.inputs) if missing: raise InvalidBlueprint( @@ -117,7 +117,7 @@ class Blueprint: def yaml(self) -> str: """Dump blueprint as YAML.""" - return yaml.dump(self.data) + return yaml_util.dump(self.data) @callback def validate(self) -> list[str] | None: @@ -179,7 +179,7 @@ class BlueprintInputs: @callback def async_substitute(self) -> dict: """Get the blueprint value with the inputs substituted.""" - processed = yaml.substitute(self.blueprint.data, self.inputs_with_default) + processed = yaml_util.substitute(self.blueprint.data, self.inputs_with_default) combined = {**processed, **self.config_with_inputs} # From config_with_inputs combined.pop(CONF_USE_BLUEPRINT) @@ -225,7 +225,9 @@ class DomainBlueprints: def _load_blueprint(self, blueprint_path: str) -> Blueprint: """Load a blueprint.""" try: - blueprint_data = yaml.load_yaml_dict(self.blueprint_folder / blueprint_path) + blueprint_data = yaml_util.load_yaml_dict( + self.blueprint_folder / blueprint_path + ) except FileNotFoundError as err: raise FailedToLoad( self.domain, diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py index 3be925c7c8f..0743d027d8d 100644 --- a/homeassistant/components/blueprint/websocket_api.py +++ b/homeassistant/components/blueprint/websocket_api.py @@ -13,7 +13,7 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from . import importer, models from .const import DOMAIN @@ -174,7 +174,7 @@ async def ws_save_blueprint( domain = msg["domain"] try: - yaml_data = cast(dict[str, Any], yaml.parse_yaml(msg["yaml"])) + yaml_data = cast(dict[str, Any], yaml_util.parse_yaml(msg["yaml"])) blueprint = models.Blueprint( yaml_data, expected_domain=domain, schema=BLUEPRINT_SCHEMA ) @@ -263,7 +263,7 @@ async def ws_substitute_blueprint( try: config = blueprint_inputs.async_substitute() - except yaml.UndefinedSubstitution as err: + except yaml_util.UndefinedSubstitution as err: connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err)) return diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index 5e4da231eef..6cd401989c8 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -34,7 +34,7 @@ from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import location +from homeassistant.util import location as location_util from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -193,7 +193,7 @@ async def async_setup_platform( devices = [] for station in network.stations: - dist = location.distance( + dist = location_util.distance( latitude, longitude, station[ATTR_LATITUDE], station[ATTR_LONGITUDE] ) station_id = station[ATTR_ID] @@ -236,7 +236,7 @@ class CityBikesNetworks: for network in self.networks: network_latitude = network[ATTR_LOCATION][ATTR_LATITUDE] network_longitude = network[ATTR_LOCATION][ATTR_LONGITUDE] - dist = location.distance( + dist = location_util.distance( latitude, longitude, network_latitude, network_longitude ) if minimum_dist is None or dist < minimum_dist: diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index 6f788b1c9f2..b40f533d1f8 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import async_update_suggested_units from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import check_config, config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util import location, unit_system +from homeassistant.util import location as location_util, unit_system @callback @@ -99,7 +99,7 @@ async def websocket_detect_config( ) -> None: """Detect core config.""" session = async_get_clientsession(hass) - location_info = await location.async_detect_location_info(session) + location_info = await location_util.async_detect_location_info(session) info: dict[str, Any] = {} diff --git a/homeassistant/components/conversation/session.py b/homeassistant/components/conversation/session.py index 426b11ea24b..48040e8ac9c 100644 --- a/homeassistant/components/conversation/session.py +++ b/homeassistant/components/conversation/session.py @@ -21,7 +21,7 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers import intent, llm, template from homeassistant.helpers.event import async_call_later -from homeassistant.util import dt as dt_util, ulid +from homeassistant.util import dt as dt_util, ulid as ulid_util from homeassistant.util.hass_dict import HassKey from .const import DOMAIN @@ -101,7 +101,7 @@ async def async_get_chat_session( history: ChatSession | None = None if user_input.conversation_id is None: - conversation_id = ulid.ulid_now() + conversation_id = ulid_util.ulid_now() elif history := all_history.get(user_input.conversation_id): conversation_id = user_input.conversation_id @@ -112,8 +112,8 @@ async def async_get_chat_session( # a new conversation was started. If the user picks their own, they # want to track a conversation and we respect it. try: - ulid.ulid_to_bytes(user_input.conversation_id) - conversation_id = ulid.ulid_now() + ulid_util.ulid_to_bytes(user_input.conversation_id) + conversation_id = ulid_util.ulid_now() except ValueError: conversation_id = user_input.conversation_id diff --git a/homeassistant/components/dwd_weather_warnings/coordinator.py b/homeassistant/components/dwd_weather_warnings/coordinator.py index 8cf3813a85d..be61304bc06 100644 --- a/homeassistant/components/dwd_weather_warnings/coordinator.py +++ b/homeassistant/components/dwd_weather_warnings/coordinator.py @@ -7,7 +7,7 @@ from dwdwfsapi import DwdWeatherWarningsAPI from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import location +from homeassistant.util import location as location_util from .const import ( CONF_REGION_DEVICE_TRACKER, @@ -58,7 +58,7 @@ class DwdWeatherWarningsCoordinator(DataUpdateCoordinator[None]): distance = None if self._previous_position is not None: - distance = location.distance( + distance = location_util.distance( self._previous_position[0], self._previous_position[1], position[0], diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index dad9c8a1920..81cc7ab8a73 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers import device_registry as dr, intent, llm, template from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid +from homeassistant.util import ulid as ulid_util from .const import ( CONF_CHAT_MODEL, @@ -204,7 +204,7 @@ class GoogleGenerativeAIConversationEntity( """Process a sentence.""" result = conversation.ConversationResult( response=intent.IntentResponse(language=user_input.language), - conversation_id=user_input.conversation_id or ulid.ulid_now(), + conversation_id=user_input.conversation_id or ulid_util.ulid_now(), ) assert result.conversation_id diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index c8fc8ffb11b..b5093999836 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -28,7 +28,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.hassio import get_supervisor_ip, is_hassio -from homeassistant.util import dt as dt_util, yaml +from homeassistant.util import dt as dt_util, yaml as yaml_util from .const import KEY_HASS from .view import HomeAssistantView @@ -244,7 +244,7 @@ class IpBanManager: str(ip_ban.ip_address): {ATTR_BANNED_AT: ip_ban.banned_at.isoformat()} } # Write in a single write call to avoid interleaved writes - out.write("\n" + yaml.dump(ip_)) + out.write("\n" + yaml_util.dump(ip_)) async def async_add_ban(self, remote_addr: IPv4Address | IPv6Address) -> None: """Add a new IP address to the banned list.""" diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py index ece416d7ef1..d641f8dc6b5 100644 --- a/homeassistant/components/intent/timers.py +++ b/homeassistant/components/intent/timers.py @@ -21,7 +21,7 @@ from homeassistant.helpers import ( device_registry as dr, intent, ) -from homeassistant.util import ulid +from homeassistant.util import ulid as ulid_util from .const import TIMER_DATA @@ -261,7 +261,7 @@ class TimerManager: if seconds is not None: total_seconds += seconds - timer_id = ulid.ulid_now() + timer_id = ulid_util.ulid_now() created_at = time.monotonic_ns() timer = TimerInfo( id=timer_id, diff --git a/homeassistant/components/mcp_server/session.py b/homeassistant/components/mcp_server/session.py index 6f6622de9f7..4c586fd32a0 100644 --- a/homeassistant/components/mcp_server/session.py +++ b/homeassistant/components/mcp_server/session.py @@ -13,7 +13,7 @@ import logging from anyio.streams.memory import MemoryObjectSendStream from mcp import types -from homeassistant.util import ulid +from homeassistant.util import ulid as ulid_util _LOGGER = logging.getLogger(__name__) @@ -39,7 +39,7 @@ class SessionManager: @asynccontextmanager async def create(self, session: Session) -> AsyncGenerator[str]: """Context manager to create a new session ID and close when done.""" - session_id = ulid.ulid_now() + session_id = ulid_util.ulid_now() _LOGGER.debug("Creating session: %s", session_id) self._sessions[session_id] = session try: diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index 1a91c790d27..c0fbfae6444 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers import intent, llm, template from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid +from homeassistant.util import ulid as ulid_util from .const import ( CONF_KEEP_ALIVE, @@ -141,7 +141,7 @@ class OllamaConversationEntity( settings = {**self.entry.data, **self.entry.options} client = self.hass.data[DOMAIN][self.entry.entry_id] - conversation_id = user_input.conversation_id or ulid.ulid_now() + conversation_id = user_input.conversation_id or ulid_util.ulid_now() model = settings[CONF_MODEL] intent_response = intent.IntentResponse(language=user_input.language) llm_api: llm.APIInstance | None = None diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index 0ada2885fa7..2ccf086071a 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -28,7 +28,7 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType -from homeassistant.util import location +from homeassistant.util import location as location_util from homeassistant.util.json import JsonObjectType, load_json_object from .config_flow import PlayStation4FlowHandler # noqa: F401 @@ -103,7 +103,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Migrate Version 1 -> Version 2: New region codes. if version == 1: - loc = await location.async_detect_location_info(async_get_clientsession(hass)) + loc = await location_util.async_detect_location_info( + async_get_clientsession(hass) + ) if loc: country = COUNTRYCODE_NAMES.get(loc.country_code) if country in COUNTRIES: diff --git a/homeassistant/components/ps4/config_flow.py b/homeassistant/components/ps4/config_flow.py index 877fb595fc0..4e3f8f08e39 100644 --- a/homeassistant/components/ps4/config_flow.py +++ b/homeassistant/components/ps4/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util import location +from homeassistant.util import location as location_util from .const import ( CONFIG_ENTRY_VERSION, @@ -54,7 +54,7 @@ class PlayStation4FlowHandler(ConfigFlow, domain=DOMAIN): self.region = None self.pin: str | None = None self.m_device = None - self.location: location.LocationInfo | None = None + self.location: location_util.LocationInfo | None = None self.device_list: list[str] = [] async def async_step_user( @@ -190,7 +190,7 @@ class PlayStation4FlowHandler(ConfigFlow, domain=DOMAIN): # Try to find region automatically. if not self.location: - self.location = await location.async_detect_location_info( + self.location = await location_util.async_detect_location_info( async_get_clientsession(self.hass) ) if self.location: diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 01c4212d99e..92ef59db908 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -21,7 +21,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.network import get_url, is_cloud_connection from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from homeassistant.util import network +from homeassistant.util import network as network_util from homeassistant.util.aiohttp import MockRequest, MockStreamReader, serialize_response _LOGGER = logging.getLogger(__name__) @@ -174,7 +174,7 @@ async def async_handle_webhook( _LOGGER.debug("Unable to parse remote ip %s", request.remote) return Response(status=HTTPStatus.OK) - is_local = network.is_local(request_remote) + is_local = network_util.is_local(request_remote) if not is_local: _LOGGER.warning("Received remote request for local webhook %s", webhook_id) diff --git a/homeassistant/components/wyoming/conversation.py b/homeassistant/components/wyoming/conversation.py index 9a17559c1f8..988d47925ac 100644 --- a/homeassistant/components/wyoming/conversation.py +++ b/homeassistant/components/wyoming/conversation.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid +from homeassistant.util import ulid as ulid_util from .const import DOMAIN from .data import WyomingService @@ -97,7 +97,7 @@ class WyomingConversationEntity( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: """Process a sentence.""" - conversation_id = user_input.conversation_id or ulid.ulid_now() + conversation_id = user_input.conversation_id or ulid_util.ulid_now() intent_response = intent.IntentResponse(language=user_input.language) try: diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index 4384cc34836..cff14f2b67d 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import uuid +from homeassistant.util import uuid as uuid_util from .const import ( ATTR_MAIN_SYNC, @@ -735,7 +735,7 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): group = ( self.coordinator.data.group_id if self.is_server - else uuid.random_uuid_hex().upper() + else uuid_util.random_uuid_hex().upper() ) ip_addresses = set() diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index f66794165f0..abad11bb36e 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -28,7 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, HomeAssistant, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import JsonObjectType @@ -370,7 +370,7 @@ class AssistAPI(API): prompt.append( "An overview of the areas and the devices in this smart home:" ) - prompt.append(yaml.dump(list(exposed_entities.values()))) + prompt.append(yaml_util.dump(list(exposed_entities.values()))) return "\n".join(prompt) diff --git a/homeassistant/helpers/location.py b/homeassistant/helpers/location.py index a12de4f9029..5264869d037 100644 --- a/homeassistant/helpers/location.py +++ b/homeassistant/helpers/location.py @@ -7,7 +7,7 @@ import logging from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant, State -from homeassistant.util import location as loc_util +from homeassistant.util import location as location_util _LOGGER = logging.getLogger(__name__) @@ -36,7 +36,7 @@ def closest(latitude: float, longitude: float, states: Iterable[State]) -> State return min( with_location, - key=lambda state: loc_util.distance( + key=lambda state: location_util.distance( state.attributes.get(ATTR_LATITUDE), state.attributes.get(ATTR_LONGITUDE), latitude, diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 7bddfdc2f68..fac03300bdc 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -74,7 +74,7 @@ from homeassistant.loader import bind_hass from homeassistant.util import ( convert, dt as dt_util, - location as loc_util, + location as location_util, slugify as slugify_util, ) from homeassistant.util.async_ import run_callback_threadsafe @@ -1858,7 +1858,7 @@ def distance(hass, *args): return hass.config.distance(*locations[0]) return hass.config.units.length( - loc_util.distance(*locations[0] + locations[1]), UnitOfLength.METERS + location_util.distance(*locations[0] + locations[1]), UnitOfLength.METERS ) diff --git a/pyproject.toml b/pyproject.toml index 05b00305b1c..c4a1c45671a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -912,7 +912,15 @@ voluptuous = "vol" "homeassistant.helpers.floor_registry" = "fr" "homeassistant.helpers.issue_registry" = "ir" "homeassistant.helpers.label_registry" = "lr" +"homeassistant.util.color" = "color_util" "homeassistant.util.dt" = "dt_util" +"homeassistant.util.json" = "json_util" +"homeassistant.util.location" = "location_util" +"homeassistant.util.logging" = "logging_util" +"homeassistant.util.network" = "network_util" +"homeassistant.util.ulid" = "ulid_util" +"homeassistant.util.uuid" = "uuid_util" +"homeassistant.util.yaml" = "yaml_util" [tool.ruff.lint.flake8-pytest-style] fixture-parentheses = false diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index ba290d95ed5..fa5bcb8137a 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -16,7 +16,7 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent, llm from homeassistant.setup import async_setup_component -from homeassistant.util import ulid +from homeassistant.util import ulid as ulid_util from tests.common import MockConfigEntry @@ -472,7 +472,7 @@ async def test_conversation_id( assert result.conversation_id == conversation_id - unknown_id = ulid.ulid() + unknown_id = ulid_util.ulid() result = await conversation.async_converse( hass, "hello", unknown_id, None, agent_id="conversation.claude" diff --git a/tests/components/automation/test_blueprint.py b/tests/components/automation/test_blueprint.py index 1095c625fb2..1e7c616efeb 100644 --- a/tests/components/automation/test_blueprint.py +++ b/tests/components/automation/test_blueprint.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util, yaml +from homeassistant.util import dt as dt_util, yaml as yaml_util from tests.common import MockConfigEntry, async_fire_time_changed, async_mock_service @@ -38,7 +38,7 @@ def patch_blueprint( return orig_load(self, path) return models.Blueprint( - yaml.load_yaml(data_path), + yaml_util.load_yaml(data_path), expected_domain=self.domain, path=path, schema=automation.config.AUTOMATION_BLUEPRINT_SCHEMA, diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 98d8bf0396e..6466e5e7f22 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -51,7 +51,7 @@ from homeassistant.helpers.script import ( _async_stop_scripts_at_shutdown, ) from homeassistant.setup import async_setup_component -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util import homeassistant.util.dt as dt_util from tests.common import ( @@ -1376,7 +1376,9 @@ async def test_reload_automation_when_blueprint_changes( # Reload the automations without any change, but with updated blueprint blueprint_path = automation.async_get_blueprints(hass).blueprint_folder - blueprint_config = yaml.load_yaml(blueprint_path / "test_event_service.yaml") + blueprint_config = yaml_util.load_yaml( + blueprint_path / "test_event_service.yaml" + ) blueprint_config["actions"] = [blueprint_config["actions"]] blueprint_config["actions"].append(blueprint_config["actions"][-1]) @@ -1387,7 +1389,7 @@ async def test_reload_automation_when_blueprint_changes( return_value=config, ), patch( - "homeassistant.components.blueprint.models.yaml.load_yaml_dict", + "homeassistant.components.blueprint.models.yaml_util.load_yaml_dict", autospec=True, return_value=blueprint_config, ), @@ -2691,7 +2693,7 @@ async def test_blueprint_automation_fails_substitution( """Test blueprint automation with bad inputs.""" with patch( "homeassistant.components.blueprint.models.BlueprintInputs.async_substitute", - side_effect=yaml.UndefinedSubstitution("blah"), + side_effect=yaml_util.UndefinedSubstitution("blah"), ): assert await async_setup_component( hass, diff --git a/tests/components/blueprint/test_default_blueprints.py b/tests/components/blueprint/test_default_blueprints.py index f69126a7f25..fbbd48eedd3 100644 --- a/tests/components/blueprint/test_default_blueprints.py +++ b/tests/components/blueprint/test_default_blueprints.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components.blueprint import BLUEPRINT_SCHEMA, models from homeassistant.components.blueprint.const import BLUEPRINT_FOLDER -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util DOMAINS = ["automation"] LOGGER = logging.getLogger(__name__) @@ -25,5 +25,5 @@ def test_default_blueprints(domain: str) -> None: for fil in items: LOGGER.info("Processing %s", fil) assert fil.name.endswith(".yaml") - data = yaml.load_yaml(fil) + data = yaml_util.load_yaml(fil) models.Blueprint(data, expected_domain=domain, schema=BLUEPRINT_SCHEMA) diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index 40a9c85a8d3..b20b0fb5699 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -13,7 +13,7 @@ from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from tests.typing import ClientSessionGenerator @@ -223,7 +223,7 @@ async def test_update_automation_config_with_blueprint_substitution_error( with patch( "homeassistant.components.blueprint.models.BlueprintInputs.async_substitute", - side_effect=yaml.UndefinedSubstitution("blah"), + side_effect=yaml_util.UndefinedSubstitution("blah"), ): resp = await client.post( "/api/config/automation/config/moon", diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index 4550f2e08e5..ee133d3dddd 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -10,7 +10,7 @@ from homeassistant.components.config import core from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util, location +from homeassistant.util import dt as dt_util, location as location_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from tests.common import MockUser @@ -238,7 +238,7 @@ async def test_detect_config_fail(hass: HomeAssistant, client) -> None: """Test detect config.""" with patch( "homeassistant.util.location.async_detect_location_info", - return_value=location.LocationInfo( + return_value=location_util.LocationInfo( ip=None, country_code=None, currency=None, diff --git a/tests/components/config/test_script.py b/tests/components/config/test_script.py index 88245eb567f..10d453b17f1 100644 --- a/tests/components/config/test_script.py +++ b/tests/components/config/test_script.py @@ -13,7 +13,7 @@ from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from tests.typing import ClientSessionGenerator @@ -226,7 +226,7 @@ async def test_update_script_config_with_blueprint_substitution_error( with patch( "homeassistant.components.blueprint.models.BlueprintInputs.async_substitute", - side_effect=yaml.UndefinedSubstitution("blah"), + side_effect=yaml_util.UndefinedSubstitution("blah"), ): resp = await client.post( "/api/config/script/config/moon", diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 490f8e3dabc..9e1ce8d7f43 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -41,7 +41,7 @@ from homeassistant.data_entry_flow import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.translation import async_get_translations -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from tests.common import QualityScaleStatus, get_quality_scale @@ -642,7 +642,7 @@ def ignore_translations() -> str | list[str]: def _get_integration_quality_scale(integration: str) -> dict[str, Any]: """Get the quality scale for an integration.""" try: - return yaml.load_yaml_dict( + return yaml_util.load_yaml_dict( f"homeassistant/components/{integration}/quality_scale.yaml" ).get("rules", {}) except FileNotFoundError: diff --git a/tests/components/hue/test_light_v1.py b/tests/components/hue/test_light_v1.py index c742124e4f0..a9fc1e5c70b 100644 --- a/tests/components/hue/test_light_v1.py +++ b/tests/components/hue/test_light_v1.py @@ -11,7 +11,7 @@ from homeassistant.components.light import ColorMode from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import color +from homeassistant.util import color as color_util from .conftest import create_config_entry @@ -167,10 +167,10 @@ LIGHT_RAW = { }, "swversion": "66009461", } -LIGHT_GAMUT = color.GamutType( - color.XYPoint(0.704, 0.296), - color.XYPoint(0.2151, 0.7106), - color.XYPoint(0.138, 0.08), +LIGHT_GAMUT = color_util.GamutType( + color_util.XYPoint(0.704, 0.296), + color_util.XYPoint(0.2151, 0.7106), + color_util.XYPoint(0.138, 0.08), ) LIGHT_GAMUT_TYPE = "A" @@ -770,7 +770,7 @@ def test_hs_color() -> None: rooms={}, ) - assert light.hs_color == color.color_xy_to_hs(0.4, 0.5, LIGHT_GAMUT) + assert light.hs_color == color_util.color_xy_to_hs(0.4, 0.5, LIGHT_GAMUT) async def test_group_features( diff --git a/tests/components/ps4/test_config_flow.py b/tests/components/ps4/test_config_flow.py index 4e0505a8644..a4e6b039a92 100644 --- a/tests/components/ps4/test_config_flow.py +++ b/tests/components/ps4/test_config_flow.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.util import location +from homeassistant.util import location as location_util from tests.common import MockConfigEntry @@ -64,7 +64,7 @@ MOCK_TCP_PORT = 997 MOCK_AUTO = {"Config Mode": "Auto Discover"} MOCK_MANUAL = {"Config Mode": "Manual Entry", CONF_IP_ADDRESS: MOCK_HOST} -MOCK_LOCATION = location.LocationInfo( +MOCK_LOCATION = location_util.LocationInfo( "0.0.0.0", "US", "USD", @@ -83,7 +83,8 @@ MOCK_LOCATION = location.LocationInfo( def location_info_fixture(): """Mock location info.""" with patch( - "homeassistant.components.ps4.config_flow.location.async_detect_location_info", + "homeassistant.components.ps4." + "config_flow.location_util.async_detect_location_info", return_value=MOCK_LOCATION, ): yield @@ -359,7 +360,8 @@ async def test_0_pin(hass: HomeAssistant) -> None: "pyps4_2ndscreen.Helper.has_devices", return_value=[{"host-ip": MOCK_HOST}] ), patch( - "homeassistant.components.ps4.config_flow.location.async_detect_location_info", + "homeassistant.components.ps4." + "config_flow.location_util.async_detect_location_info", return_value=MOCK_LOCATION, ), ): diff --git a/tests/components/ps4/test_init.py b/tests/components/ps4/test_init.py index 12edb7a9c6e..ede6b3b5147 100644 --- a/tests/components/ps4/test_init.py +++ b/tests/components/ps4/test_init.py @@ -31,7 +31,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from homeassistant.util import location +from homeassistant.util import location as location_util from tests.common import MockConfigEntry @@ -58,7 +58,7 @@ MOCK_ENTRY_ID = "SomeID" MOCK_CONFIG = MockConfigEntry(domain=DOMAIN, data=MOCK_DATA, entry_id=MOCK_ENTRY_ID) -MOCK_LOCATION = location.LocationInfo( +MOCK_LOCATION = location_util.LocationInfo( "0.0.0.0", "US", "USD", diff --git a/tests/components/script/test_blueprint.py b/tests/components/script/test_blueprint.py index 7f03a89c548..f65e5483ae4 100644 --- a/tests/components/script/test_blueprint.py +++ b/tests/components/script/test_blueprint.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, template from homeassistant.setup import async_setup_component -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from tests.common import MockConfigEntry, async_mock_service @@ -37,7 +37,7 @@ def patch_blueprint(blueprint_path: str, data_path: str) -> Iterator[None]: return orig_load(self, path) return Blueprint( - yaml.load_yaml(data_path), + yaml_util.load_yaml(data_path), expected_domain=self.domain, path=path, schema=BLUEPRINT_SCHEMA, diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index a5eda3757a9..248ada605cc 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -42,7 +42,7 @@ from homeassistant.helpers.script import ( ) from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.setup import async_setup_component -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util import homeassistant.util.dt as dt_util from tests.common import ( @@ -1722,7 +1722,7 @@ async def test_blueprint_script_fails_substitution( """Test blueprint script with bad inputs.""" with patch( "homeassistant.components.blueprint.models.BlueprintInputs.async_substitute", - side_effect=yaml.UndefinedSubstitution("blah"), + side_effect=yaml_util.UndefinedSubstitution("blah"), ): assert await async_setup_component( hass, diff --git a/tests/components/template/test_blueprint.py b/tests/components/template/test_blueprint.py index 1df9e738b06..cb4e83d934c 100644 --- a/tests/components/template/test_blueprint.py +++ b/tests/components/template/test_blueprint.py @@ -19,7 +19,7 @@ from homeassistant.components.template import DOMAIN, SERVICE_RELOAD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from tests.common import async_mock_service @@ -40,7 +40,7 @@ def patch_blueprint( return orig_load(self, path) return Blueprint( - yaml.load_yaml(data_path), + yaml_util.load_yaml(data_path), expected_domain=self.domain, path=path, schema=BLUEPRINT_SCHEMA, diff --git a/tests/conftest.py b/tests/conftest.py index 3195e6918b9..a64543337b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -90,7 +90,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.translation import _TranslationsCacheData from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util, location +from homeassistant.util import dt as dt_util, location as location_util from homeassistant.util.async_ import create_eager_task, get_scheduled_timer_handles from homeassistant.util.json import json_loads @@ -250,7 +250,9 @@ def check_real[**_P, _R](func: Callable[_P, Coroutine[Any, Any, _R]]): # Guard a few functions that would make network connections -location.async_detect_location_info = check_real(location.async_detect_location_info) +location_util.async_detect_location_info = check_real( + location_util.async_detect_location_info +) @pytest.fixture(name="caplog") diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index f73808a0625..d07bb7458e9 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -7,7 +7,7 @@ import pytest import voluptuous as vol from homeassistant.helpers import selector -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util FAKE_UUID = "a266a680b608c32770e6c45bfe6b8411" @@ -77,7 +77,7 @@ def _test_selector( "selector": {selector_type: selector_instance.config} } # Test serialized selector can be dumped to YAML - yaml.dump(selector_instance.serialize()) + yaml_util.dump(selector_instance.serialize()) @pytest.mark.parametrize( diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 12a7eca5f9d..0346e21044f 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -15,7 +15,7 @@ import yaml as pyyaml from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from homeassistant.util.yaml import loader as yaml_loader from tests.common import extract_stack_to_frame @@ -86,7 +86,7 @@ def test_unhashable_key() -> None: def test_no_key() -> None: """Test item without a key.""" with pytest.raises(HomeAssistantError): - yaml.load_yaml(YAML_CONFIG_FILE) + yaml_util.load_yaml(YAML_CONFIG_FILE) @pytest.mark.usefixtures("try_both_loaders") @@ -386,13 +386,13 @@ def test_load_yaml_encoding_error(mock_open: Mock) -> None: @pytest.mark.usefixtures("try_both_dumpers") def test_dump() -> None: """The that the dump method returns empty None values.""" - assert yaml.dump({"a": None, "b": "b"}) == "a:\nb: b\n" + assert yaml_util.dump({"a": None, "b": "b"}) == "a:\nb: b\n" @pytest.mark.usefixtures("try_both_dumpers") def test_dump_unicode() -> None: """The that the dump method returns empty None values.""" - assert yaml.dump({"a": None, "b": "привет"}) == "a:\nb: привет\n" + assert yaml_util.dump({"a": None, "b": "привет"}) == "a:\nb: привет\n" @pytest.mark.parametrize("hass_config_yaml", ['key: [1, "2", 3]']) @@ -400,7 +400,7 @@ def test_dump_unicode() -> None: def test_representing_yaml_loaded_data() -> None: """Test we can represent YAML loaded data.""" data = load_yaml_config_file(YAML_CONFIG_FILE) - assert yaml.dump(data) == "key:\n- 1\n- '2'\n- 3\n" + assert yaml_util.dump(data) == "key:\n- 1\n- '2'\n- 3\n" @pytest.mark.parametrize("hass_config_yaml", ["key: thing1\nkey: thing2"]) @@ -413,7 +413,7 @@ def test_duplicate_key(caplog: pytest.LogCaptureFixture) -> None: @pytest.mark.parametrize( "hass_config_yaml_files", - [{YAML_CONFIG_FILE: "key: !secret a", yaml.SECRET_YAML: "a: 1\nb: !secret a"}], + [{YAML_CONFIG_FILE: "key: !secret a", yaml_util.SECRET_YAML: "a: 1\nb: !secret a"}], ) @pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") def test_no_recursive_secrets() -> None: @@ -426,8 +426,8 @@ def test_no_recursive_secrets() -> None: def test_input_class() -> None: """Test input class.""" - yaml_input = yaml.Input("hello") - yaml_input2 = yaml.Input("hello") + yaml_input = yaml_util.Input("hello") + yaml_input2 = yaml_util.Input("hello") assert yaml_input.name == "hello" assert yaml_input == yaml_input2 @@ -438,8 +438,8 @@ def test_input_class() -> None: @pytest.mark.usefixtures("try_both_loaders", "try_both_dumpers") def test_input() -> None: """Test loading inputs.""" - data = {"hello": yaml.Input("test_name")} - assert yaml.parse_yaml(yaml.dump(data)) == data + data = {"hello": yaml_util.Input("test_name")} + assert yaml_util.parse_yaml(yaml_util.dump(data)) == data @pytest.mark.skipif( @@ -448,7 +448,7 @@ def test_input() -> None: ) def test_c_loader_is_available_in_ci() -> None: """Verify we are testing the C loader in the CI.""" - assert yaml.loader.HAS_C_LOADER is True + assert yaml_util.loader.HAS_C_LOADER is True @pytest.mark.usefixtures("try_both_loaders") @@ -552,7 +552,7 @@ def test_string_used_as_vol_schema() -> None: @pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") def test_load_yaml_dict(expected_data: Any) -> None: """Test item without a key.""" - assert yaml.load_yaml_dict(YAML_CONFIG_FILE) == expected_data + assert yaml_util.load_yaml_dict(YAML_CONFIG_FILE) == expected_data @pytest.mark.parametrize("hass_config_yaml", ["abc", "123", "[]"]) diff --git a/tests/util/yaml/test_secrets.py b/tests/util/yaml/test_secrets.py index 35b5ae319c4..4d89bfb8712 100644 --- a/tests/util/yaml/test_secrets.py +++ b/tests/util/yaml/test_secrets.py @@ -8,7 +8,7 @@ import pytest from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from homeassistant.util.yaml import loader as yaml_loader from tests.common import get_test_config_dir, patch_yaml_files @@ -63,7 +63,7 @@ def default_config(filepaths: dict[str, Path]) -> YamlFile: def default_secrets(filepaths: dict[str, Path]) -> YamlFile: """Return the default secrets file for testing.""" return YamlFile( - path=filepaths["config"] / yaml.SECRET_YAML, + path=filepaths["config"] / yaml_util.SECRET_YAML, contents=( "http_pw: pwhttp\n" "comp1_un: un1\n" @@ -112,7 +112,8 @@ def test_secret_overrides_parent( path=filepaths["sub_folder"] / "sub.yaml", contents=default_config.contents ) sub_secrets = YamlFile( - path=filepaths["sub_folder"] / yaml.SECRET_YAML, contents="http_pw: override" + path=filepaths["sub_folder"] / yaml_util.SECRET_YAML, + contents="http_pw: override", ) loaded_file = load_config_file( @@ -133,7 +134,7 @@ def test_secrets_from_unrelated_fails( contents="http:\n api_password: !secret test", ) unrelated_secrets = YamlFile( - path=filepaths["unrelated"] / yaml.SECRET_YAML, contents="test: failure" + path=filepaths["unrelated"] / yaml_util.SECRET_YAML, contents="test: failure" ) with pytest.raises(HomeAssistantError, match="Secret test not defined"): load_config_file( @@ -162,7 +163,8 @@ def test_bad_logger_value( path=filepaths["config"] / YAML_CONFIG_FILE, contents="api_password: !secret pw" ) secrets_file = YamlFile( - path=filepaths["config"] / yaml.SECRET_YAML, contents="logger: info\npw: abc" + path=filepaths["config"] / yaml_util.SECRET_YAML, + contents="logger: info\npw: abc", ) with caplog.at_level(logging.ERROR): load_config_file(config_file.path, [config_file, secrets_file]) @@ -178,7 +180,7 @@ def test_secrets_are_not_dict( ) -> None: """Did secrets handle non-dict file.""" non_dict_secrets = YamlFile( - path=filepaths["config"] / yaml.SECRET_YAML, + path=filepaths["config"] / yaml_util.SECRET_YAML, contents="- http_pw: pwhttp\n comp1_un: un1\n comp1_pw: pw1\n", ) with pytest.raises(HomeAssistantError, match="Secrets is not a dictionary"): From 9bf2996ea09cce8dd1e19574e97c3946ceef553d Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 21 Jan 2025 09:00:34 -0600 Subject: [PATCH 0781/2987] Update HEOS tests to not interact directly with integration internals (#136177) --- tests/components/heos/conftest.py | 76 +++++--------- tests/components/heos/test_config_flow.py | 6 ++ tests/components/heos/test_init.py | 10 +- tests/components/heos/test_media_player.py | 110 ++++++++------------- tests/components/heos/test_services.py | 1 - 5 files changed, 71 insertions(+), 132 deletions(-) diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index f0014d07876..3a69455772e 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -2,12 +2,11 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import AsyncIterator from unittest.mock import AsyncMock, Mock, patch from pyheos import ( CONTROLS_ALL, - Dispatcher, Heos, HeosGroup, HeosOptions, @@ -24,15 +23,8 @@ from pyheos import ( import pytest import pytest_asyncio -from homeassistant.components.heos import ( - CONF_PASSWORD, - DOMAIN, - ControllerManager, - GroupManager, - HeosRuntimeData, - SourceManager, -) -from homeassistant.const import CONF_HOST, CONF_USERNAME +from homeassistant.components.heos import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_DEVICE_TYPE, ATTR_UPNP_FRIENDLY_NAME, @@ -48,54 +40,39 @@ from tests.common import MockConfigEntry @pytest.fixture(name="config_entry") -def config_entry_fixture(heos_runtime_data): +def config_entry_fixture() -> MockConfigEntry: """Create a mock HEOS config entry.""" - entry = MockConfigEntry( + return MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, title="HEOS System (via 127.0.0.1)", unique_id=DOMAIN, ) - entry.runtime_data = heos_runtime_data - return entry @pytest.fixture(name="config_entry_options") -def config_entry_options_fixture(heos_runtime_data): +def config_entry_options_fixture() -> MockConfigEntry: """Create a mock HEOS config entry with options.""" - entry = MockConfigEntry( + return MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, title="HEOS System (via 127.0.0.1)", options={CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, unique_id=DOMAIN, ) - entry.runtime_data = heos_runtime_data - return entry -@pytest.fixture(name="heos_runtime_data") -def heos_runtime_data_fixture(controller_manager, players): - """Create a mock HeosRuntimeData fixture.""" - return HeosRuntimeData( - controller_manager, Mock(GroupManager), Mock(SourceManager), players - ) - - -@pytest.fixture(name="controller_manager") -def controller_manager_fixture(controller): - """Create a mock controller manager fixture.""" - mock_controller_manager = Mock(ControllerManager) - mock_controller_manager.controller = controller - return mock_controller_manager - - -@pytest.fixture(name="controller") -def controller_fixture( - players, favorites, input_sources, playlists, change_data, dispatcher, group -): +@pytest_asyncio.fixture(name="controller", autouse=True) +async def controller_fixture( + players: dict[int, HeosPlayer], + favorites: dict[int, MediaItem], + input_sources: list[MediaItem], + playlists: list[MediaItem], + change_data: PlayerUpdateResult, + group: dict[int, HeosGroup], +) -> AsyncIterator[Heos]: """Create a mock Heos controller fixture.""" - mock_heos = Heos(HeosOptions(host="127.0.0.1", dispatcher=dispatcher)) + mock_heos = Heos(HeosOptions(host="127.0.0.1")) for player in players.values(): player.heos = mock_heos mock_heos.connect = AsyncMock() @@ -121,7 +98,7 @@ def controller_fixture( @pytest.fixture(name="players") -def player_fixture(quick_selects): +def players_fixture(quick_selects: dict[int, str]) -> dict[int, HeosPlayer]: """Create two mock HeosPlayers.""" players = {} for i in (1, 2): @@ -179,12 +156,11 @@ def player_fixture(quick_selects): @pytest.fixture(name="group") -def group_fixture(): +def group_fixture() -> dict[int, HeosGroup]: """Create a HEOS group consisting of two players.""" group = HeosGroup( name="Group", group_id=999, lead_player_id=1, member_player_ids=[2] ) - return {group.group_id: group} @@ -215,7 +191,7 @@ def favorites_fixture() -> dict[int, MediaItem]: @pytest.fixture(name="input_sources") -def input_sources_fixture() -> Sequence[MediaItem]: +def input_sources_fixture() -> list[MediaItem]: """Create a set of input sources for testing.""" source = MediaItem( source_id=1, @@ -230,14 +206,8 @@ def input_sources_fixture() -> Sequence[MediaItem]: return [source] -@pytest_asyncio.fixture(name="dispatcher") -async def dispatcher_fixture() -> Dispatcher: - """Create a dispatcher for testing.""" - return Dispatcher() - - @pytest.fixture(name="discovery_data") -def discovery_data_fixture() -> dict: +def discovery_data_fixture() -> SsdpServiceInfo: """Return mock discovery data for testing.""" return SsdpServiceInfo( ssdp_usn="mock_usn", @@ -256,7 +226,7 @@ def discovery_data_fixture() -> dict: @pytest.fixture(name="discovery_data_bedroom") -def discovery_data_fixture_bedroom() -> dict: +def discovery_data_fixture_bedroom() -> SsdpServiceInfo: """Return mock discovery data for testing.""" return SsdpServiceInfo( ssdp_usn="mock_usn", @@ -288,7 +258,7 @@ def quick_selects_fixture() -> dict[int, str]: @pytest.fixture(name="playlists") -def playlists_fixture() -> Sequence[MediaItem]: +def playlists_fixture() -> list[MediaItem]: """Create favorites fixture.""" playlist = MediaItem( source_id=const.MUSIC_SOURCE_PLAYLISTS, diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index b03d75e5798..2f01e70e2d1 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -220,6 +220,7 @@ async def test_options_flow_signs_in( ) -> None: """Test options flow signs-in with entered credentials.""" config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) # Start the options flow. Entry has not current options. assert CONF_USERNAME not in config_entry.options @@ -258,6 +259,7 @@ async def test_options_flow_signs_out( ) -> None: """Test options flow signs-out when credentials cleared.""" config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) # Start the options flow. Entry has not current options. result = await hass.config_entries.options.async_init(config_entry.entry_id) @@ -305,6 +307,7 @@ async def test_options_flow_missing_one_param_recovers( ) -> None: """Test options flow signs-in after recovering from only username or password being entered.""" config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) # Start the options flow. Entry has not current options. assert CONF_USERNAME not in config_entry.options @@ -353,6 +356,7 @@ async def test_reauth_signs_in_aborts( ) -> None: """Test reauth flow signs-in with entered credentials and aborts.""" config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) result = await config_entry.start_reauth_flow(hass) assert result["step_id"] == "reauth_confirm" @@ -390,6 +394,7 @@ async def test_reauth_signs_out( ) -> None: """Test reauth flow signs-out when credentials cleared and aborts.""" config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) result = await config_entry.start_reauth_flow(hass) assert result["step_id"] == "reauth_confirm" @@ -438,6 +443,7 @@ async def test_reauth_flow_missing_one_param_recovers( ) -> None: """Test reauth flow signs-in after recovering from only username or password being entered.""" config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) # Start the options flow. Entry has not current options. result = await config_entry.start_reauth_flow(hass) diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index f06c5709f6d..cff73ad0394 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -1,6 +1,5 @@ """Tests for the init module.""" -import asyncio from typing import cast from pyheos import ( @@ -71,7 +70,7 @@ async def test_async_setup_entry_auth_failure_starts_reauth( # Simulates what happens when the controller can't sign-in during connection async def connect_send_auth_failure() -> None: controller._signed_in_username = None - controller.dispatcher.send( + await controller.dispatcher.wait_send( SignalType.HEOS_EVENT, SignalHeosEvent.USER_CREDENTIALS_INVALID ) @@ -151,7 +150,6 @@ async def test_update_sources_retry( hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos, - caplog: pytest.LogCaptureFixture, ) -> None: """Test update sources retries on failures to max attempts.""" config_entry.add_to_hass(hass) @@ -162,12 +160,10 @@ async def test_update_sources_retry( source_manager.retry_delay = 0 source_manager.max_retry_attempts = 1 controller.get_favorites.side_effect = CommandFailedError("Test", "test", 0) - controller.dispatcher.send( + await controller.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} ) - # Wait until it's finished - while "Unable to update sources" not in caplog.text: - await asyncio.sleep(0.1) + await hass.async_block_till_done() assert controller.get_favorites.call_count == 2 diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 98f701d423a..805e593935c 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -1,7 +1,5 @@ """Tests for the Heos Media Player platform.""" -import asyncio -from collections.abc import Sequence import re from typing import Any @@ -21,7 +19,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props -from homeassistant.components.heos.const import DOMAIN, SIGNAL_HEOS_UPDATED +from homeassistant.components.heos.const import DOMAIN from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, @@ -60,12 +58,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_connect from tests.common import MockConfigEntry -@pytest.mark.usefixtures("controller") async def test_state_attributes( hass: HomeAssistant, config_entry: MockConfigEntry, snapshot: SnapshotAssertion ) -> None: @@ -94,7 +90,7 @@ async def test_updates_from_signals( # Test player does not update for other players player.state = PlayState.PLAY - player.heos.dispatcher.send( + await player.heos.dispatcher.wait_send( SignalType.PLAYER_EVENT, 2, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() @@ -103,7 +99,7 @@ async def test_updates_from_signals( # Test player_update standard events player.state = PlayState.PLAY - player.heos.dispatcher.send( + await player.heos.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() @@ -114,7 +110,7 @@ async def test_updates_from_signals( # Test player_update progress events player.now_playing_media.duration = 360000 player.now_playing_media.current_position = 1000 - player.heos.dispatcher.send( + await player.heos.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_NOW_PLAYING_PROGRESS, @@ -136,38 +132,36 @@ async def test_updates_from_connection_event( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - event = asyncio.Event() - - async def set_signal(): - event.set() - - async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) # Connected player.available = True - player.heos.dispatcher.send(SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED) - await event.wait() + await player.heos.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + ) + await hass.async_block_till_done() state = hass.states.get("media_player.test_player") assert state.state == STATE_IDLE assert controller.load_players.call_count == 1 # Disconnected - event.clear() controller.load_players.reset_mock() player.available = False - player.heos.dispatcher.send(SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED) - await event.wait() + await player.heos.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED + ) + await hass.async_block_till_done() state = hass.states.get("media_player.test_player") assert state.state == STATE_UNAVAILABLE assert controller.load_players.call_count == 0 # Connected handles refresh failure - event.clear() controller.load_players.reset_mock() controller.load_players.side_effect = CommandFailedError(None, "Failure", 1) player.available = True - player.heos.dispatcher.send(SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED) - await event.wait() + await player.heos.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + ) + await hass.async_block_till_done() state = hass.states.get("media_player.test_player") assert state.state == STATE_IDLE assert controller.load_players.call_count == 1 @@ -178,28 +172,23 @@ async def test_updates_from_sources_updated( hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos, - input_sources: Sequence[MediaItem], + input_sources: list[MediaItem], ) -> None: """Tests player updates from changes in sources list.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - event = asyncio.Event() - - async def set_signal(): - event.set() - - async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) input_sources.clear() - player.heos.dispatcher.send( + await player.heos.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} ) - await event.wait() - source_list = config_entry.runtime_data.source_manager.source_list - assert len(source_list) == 2 + await hass.async_block_till_done() state = hass.states.get("media_player.test_player") - assert state.attributes[ATTR_INPUT_SOURCE_LIST] == source_list + assert state.attributes[ATTR_INPUT_SOURCE_LIST] == [ + "Today's Hits Radio", + "Classical MPR (Classical Music)", + ] async def test_updates_from_players_changed( @@ -212,19 +201,12 @@ async def test_updates_from_players_changed( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - event = asyncio.Event() - - async def set_signal(): - event.set() - - async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) assert hass.states.get("media_player.test_player").state == STATE_IDLE player.state = PlayState.PLAY - player.heos.dispatcher.send( + await player.heos.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data ) - await event.wait() await hass.async_block_till_done() assert hass.states.get("media_player.test_player").state == STATE_PLAYING @@ -241,7 +223,6 @@ async def test_updates_from_players_changed_new_ids( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - event = asyncio.Event() # Assert device registry matches current id assert device_registry.async_get_device(identifiers={(DOMAIN, "1")}) @@ -251,17 +232,12 @@ async def test_updates_from_players_changed_new_ids( == "media_player.test_player" ) - # Trigger update - async def set_signal(): - event.set() - - async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) - player.heos.dispatcher.send( + await player.heos.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data_mapped_ids, ) - await event.wait() + await hass.async_block_till_done() # Assert device registry identifiers were updated assert len(device_registry.devices) == 2 @@ -275,28 +251,23 @@ async def test_updates_from_players_changed_new_ids( async def test_updates_from_user_changed( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, ) -> None: """Tests player updates from changes in user.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - event = asyncio.Event() - - async def set_signal(): - event.set() - - async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) controller._signed_in_username = None - player.heos.dispatcher.send( + await player.heos.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_USER_CHANGED, None ) - await event.wait() - source_list = config_entry.runtime_data.source_manager.source_list - assert len(source_list) == 1 + await hass.async_block_till_done() + state = hass.states.get("media_player.test_player") - assert state.attributes[ATTR_INPUT_SOURCE_LIST] == source_list + assert state.attributes[ATTR_INPUT_SOURCE_LIST] == ["HEOS Drive - Line In 1"] async def test_clear_playlist( @@ -650,7 +621,7 @@ async def test_select_favorite( player.play_preset_station.assert_called_once_with(1) # Test state is matched by station name player.now_playing_media.station = favorite.name - player.heos.dispatcher.send( + await player.heos.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() @@ -680,7 +651,7 @@ async def test_select_radio_favorite( # Test state is matched by album id player.now_playing_media.station = "Classical" player.now_playing_media.album_id = favorite.media_id - player.heos.dispatcher.send( + await player.heos.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() @@ -721,7 +692,7 @@ async def test_select_input_source( hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos, - input_sources: Sequence[MediaItem], + input_sources: list[MediaItem], ) -> None: """Tests selecting input source and state.""" config_entry.add_to_hass(hass) @@ -742,7 +713,7 @@ async def test_select_input_source( # Test state is matched by media id player.now_playing_media.source_id = const.MUSIC_SOURCE_AUX_INPUT player.now_playing_media.media_id = const.INPUT_AUX_IN_1 - player.heos.dispatcher.send( + await player.heos.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() @@ -750,7 +721,6 @@ async def test_select_input_source( assert state.attributes[ATTR_INPUT_SOURCE] == input_source.name -@pytest.mark.usefixtures("controller") async def test_select_input_unknown_raises( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: @@ -773,7 +743,7 @@ async def test_select_input_command_error( hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos, - input_sources: Sequence[MediaItem], + input_sources: list[MediaItem], ) -> None: """Tests selecting an unknown input.""" config_entry.add_to_hass(hass) @@ -797,7 +767,6 @@ async def test_select_input_command_error( player.play_input_source.assert_called_once_with(input_source.media_id) -@pytest.mark.usefixtures("controller") async def test_unload_config_entry( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: @@ -926,7 +895,7 @@ async def test_play_media_playlist( hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos, - playlists: Sequence[MediaItem], + playlists: list[MediaItem], enqueue: Any, criteria: AddCriteriaType, ) -> None: @@ -1026,7 +995,6 @@ async def test_play_media_favorite_error( assert player.play_preset_station.call_count == 0 -@pytest.mark.usefixtures("controller") async def test_play_media_invalid_type( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: diff --git a/tests/components/heos/test_services.py b/tests/components/heos/test_services.py index 92ecc1f179d..8ca365497c6 100644 --- a/tests/components/heos/test_services.py +++ b/tests/components/heos/test_services.py @@ -81,7 +81,6 @@ async def test_sign_in_unknown_error( assert "Unable to sign in" in caplog.text -@pytest.mark.usefixtures("controller") async def test_sign_in_not_loaded_raises( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: From dd31c2c832fe3fd4e3943176f6fda6c588331869 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 21 Jan 2025 09:18:34 -0600 Subject: [PATCH 0782/2987] Set PARALLEL_UPDATES for HEOS media_player (#136178) Set PARALLEL_UPDATES --- homeassistant/components/heos/media_player.py | 2 ++ homeassistant/components/heos/quality_scale.yaml | 4 +--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 67a837b2888..b8690040061 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -41,6 +41,8 @@ from homeassistant.util.dt import utcnow from . import GroupManager, HeosConfigEntry, SourceManager from .const import DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_PLAYER_ADDED, SIGNAL_HEOS_UPDATED +PARALLEL_UPDATES = 0 + BASE_SUPPORTED_FEATURES = ( MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_SET diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index 3dd6953778b..81162ab9b97 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -33,9 +33,7 @@ rules: status: todo comment: | The integration currently spams the logs until reconnected - parallel-updates: - status: todo - comment: Needs to be set to 0. The underlying library handles parallel updates. + parallel-updates: done reauthentication-flow: done test-coverage: status: done From 22e0b0e9a77918867932135b91400b1bde8edb8b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 21 Jan 2025 12:12:30 -0500 Subject: [PATCH 0783/2987] Voip migrate entities (#136140) * Migrate VoIP entities * Revert device name to host again --- homeassistant/components/voip/devices.py | 25 ++++++++++++-- tests/components/voip/test_binary_sensor.py | 14 ++++---- tests/components/voip/test_devices.py | 38 ++++++++++++++------- tests/components/voip/test_select.py | 4 +-- tests/components/voip/test_switch.py | 14 ++++---- 5 files changed, 65 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/voip/devices.py b/homeassistant/components/voip/devices.py index 163cb445340..c33ec048cbd 100644 --- a/homeassistant/components/voip/devices.py +++ b/homeassistant/components/voip/devices.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Iterator from dataclasses import dataclass, field +from typing import Any from voip_utils import CallInfo, VoipDatagramProtocol @@ -144,19 +145,39 @@ class VoIPDevices: if voip_device is None: # If we couldn't find the device based on SIP URI, see if we can # find an old device based on just the host/IP and migrate it - voip_device = self.devices.get(call_info.caller_endpoint.host) + old_id = call_info.caller_endpoint.host + voip_device = self.devices.get(old_id) if voip_device is not None: voip_device.voip_id = voip_id self.devices[voip_id] = voip_device dev_reg.async_update_device( voip_device.device_id, new_identifiers={(DOMAIN, voip_id)} ) + # Migrate entities + old_prefix = f"{old_id}-" + + def entity_migrator(entry: er.RegistryEntry) -> dict[str, Any] | None: + """Migrate entities.""" + if not entry.unique_id.startswith(old_prefix): + return None + key = entry.unique_id[len(old_prefix) :] + return { + "new_unique_id": f"{voip_id}-{key}", + } + + self.config_entry.async_create_task( + self.hass, + er.async_migrate_entries( + self.hass, self.config_entry.entry_id, entity_migrator + ), + f"voip migrating entities {voip_id}", + ) # Update device with latest info device = dev_reg.async_get_or_create( config_entry_id=self.config_entry.entry_id, identifiers={(DOMAIN, voip_id)}, - name=voip_id, + name=call_info.caller_endpoint.host, manufacturer=manuf, model=model, sw_version=fw_version, diff --git a/tests/components/voip/test_binary_sensor.py b/tests/components/voip/test_binary_sensor.py index 55d8ac4473c..44ac8e4d77f 100644 --- a/tests/components/voip/test_binary_sensor.py +++ b/tests/components/voip/test_binary_sensor.py @@ -22,18 +22,18 @@ async def test_call_in_progress( voip_device: VoIPDevice, ) -> None: """Test call in progress.""" - state = hass.states.get("binary_sensor.sip_192_168_1_210_5060_call_in_progress") + state = hass.states.get("binary_sensor.192_168_1_210_call_in_progress") assert state is not None assert state.state == "off" voip_device.set_is_active(True) - state = hass.states.get("binary_sensor.sip_192_168_1_210_5060_call_in_progress") + state = hass.states.get("binary_sensor.192_168_1_210_call_in_progress") assert state.state == "on" voip_device.set_is_active(False) - state = hass.states.get("binary_sensor.sip_192_168_1_210_5060_call_in_progress") + state = hass.states.get("binary_sensor.192_168_1_210_call_in_progress") assert state.state == "off" @@ -45,9 +45,9 @@ async def test_assist_in_progress_disabled_by_default( ) -> None: """Test assist in progress binary sensor is added disabled.""" - assert not hass.states.get("binary_sensor.sip_192_168_1_210_5060_call_in_progress") + assert not hass.states.get("binary_sensor.192_168_1_210_call_in_progress") entity_entry = entity_registry.async_get( - "binary_sensor.sip_192_168_1_210_5060_call_in_progress" + "binary_sensor.192_168_1_210_call_in_progress" ) assert entity_entry assert entity_entry.disabled @@ -63,7 +63,7 @@ async def test_assist_in_progress_issue( ) -> None: """Test assist in progress binary sensor.""" - call_in_progress_entity_id = "binary_sensor.sip_192_168_1_210_5060_call_in_progress" + call_in_progress_entity_id = "binary_sensor.192_168_1_210_call_in_progress" state = hass.states.get(call_in_progress_entity_id) assert state is not None @@ -96,7 +96,7 @@ async def test_assist_in_progress_repair_flow( ) -> None: """Test assist in progress binary sensor deprecation issue flow.""" - call_in_progress_entity_id = "binary_sensor.sip_192_168_1_210_5060_call_in_progress" + call_in_progress_entity_id = "binary_sensor.192_168_1_210_call_in_progress" state = hass.states.get(call_in_progress_entity_id) assert state is not None diff --git a/tests/components/voip/test_devices.py b/tests/components/voip/test_devices.py index d16ac76d290..4e2e129d4be 100644 --- a/tests/components/voip/test_devices.py +++ b/tests/components/voip/test_devices.py @@ -8,7 +8,7 @@ from voip_utils import CallInfo from homeassistant.components.voip import DOMAIN from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices 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 tests.common import MockConfigEntry @@ -27,7 +27,7 @@ async def test_device_registry_info( identifiers={(DOMAIN, call_info.caller_endpoint.uri)} ) assert device is not None - assert device.name == call_info.caller_endpoint.uri + assert device.name == call_info.caller_endpoint.host assert device.manufacturer == "Grandstream" assert device.model == "HT801" assert device.sw_version == "1.0.17.5" @@ -71,47 +71,61 @@ async def test_remove_device_registry_entry( ) -> None: """Test removing a device registry entry.""" assert voip_device.voip_id in voip_devices.devices - assert hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") is not None + assert hass.states.get("switch.192_168_1_210_allow_calls") is not None device_registry.async_remove_device(voip_device.device_id) await hass.async_block_till_done() await hass.async_block_till_done() - assert hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") is None + assert hass.states.get("switch.192_168_1_210_allow_calls") is None assert voip_device.voip_id not in voip_devices.devices @pytest.fixture async def legacy_dev_reg_entry( + entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, call_info: CallInfo, ) -> None: """Fixture to run before we set up the VoIP integration via fixture.""" - return device_registry.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, call_info.caller_ip)}, ) + entity_registry.async_get_or_create( + "switch", + DOMAIN, + f"{call_info.caller_ip}-allow_calls", + device_id=device.id, + config_entry=config_entry, + ) + return device -async def test_device_registry_migation( +async def test_device_registry_migration( hass: HomeAssistant, legacy_dev_reg_entry: dr.DeviceEntry, voip_devices: VoIPDevices, call_info: CallInfo, + entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: """Test info in device registry migrates old devices.""" voip_device = voip_devices.async_get_or_create(call_info) - assert voip_device.voip_id == call_info.caller_endpoint.uri + new_id = call_info.caller_endpoint.uri + assert voip_device.voip_id == new_id - device = device_registry.async_get_device( - identifiers={(DOMAIN, call_info.caller_endpoint.uri)} - ) + device = device_registry.async_get_device(identifiers={(DOMAIN, new_id)}) assert device is not None assert device.id == legacy_dev_reg_entry.id - assert device.identifiers == {(DOMAIN, call_info.caller_endpoint.uri)} - assert device.name == call_info.caller_endpoint.uri + assert device.identifiers == {(DOMAIN, new_id)} + assert device.name == call_info.caller_endpoint.host assert device.manufacturer == "Grandstream" assert device.model == "HT801" assert device.sw_version == "1.0.17.5" + + assert ( + entity_registry.async_get_entity_id("switch", DOMAIN, f"{new_id}-allow_calls") + is not None + ) diff --git a/tests/components/voip/test_select.py b/tests/components/voip/test_select.py index 1b45c739535..78bb8d6c6b4 100644 --- a/tests/components/voip/test_select.py +++ b/tests/components/voip/test_select.py @@ -15,7 +15,7 @@ async def test_pipeline_select( Functionality is tested in assist_pipeline/test_select.py. This test is only to ensure it is set up. """ - state = hass.states.get("select.sip_192_168_1_210_5060_assistant") + state = hass.states.get("select.192_168_1_210_assistant") assert state is not None assert state.state == "preferred" @@ -30,6 +30,6 @@ async def test_vad_sensitivity_select( Functionality is tested in assist_pipeline/test_select.py. This test is only to ensure it is set up. """ - state = hass.states.get("select.sip_192_168_1_210_5060_finished_speaking_detection") + state = hass.states.get("select.192_168_1_210_finished_speaking_detection") assert state is not None assert state.state == "default" diff --git a/tests/components/voip/test_switch.py b/tests/components/voip/test_switch.py index ac331ed01a7..8b3cd03f2ac 100644 --- a/tests/components/voip/test_switch.py +++ b/tests/components/voip/test_switch.py @@ -13,41 +13,41 @@ async def test_allow_call( """Test allow call.""" assert not voip_device.async_allow_call(hass) - state = hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") + state = hass.states.get("switch.192_168_1_210_allow_calls") assert state is not None assert state.state == "off" await hass.config_entries.async_reload(config_entry.entry_id) - state = hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") + state = hass.states.get("switch.192_168_1_210_allow_calls") assert state.state == "off" await hass.services.async_call( "switch", "turn_on", - {"entity_id": "switch.sip_192_168_1_210_5060_allow_calls"}, + {"entity_id": "switch.192_168_1_210_allow_calls"}, blocking=True, ) assert voip_device.async_allow_call(hass) - state = hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") + state = hass.states.get("switch.192_168_1_210_allow_calls") assert state.state == "on" await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") + state = hass.states.get("switch.192_168_1_210_allow_calls") assert state.state == "on" await hass.services.async_call( "switch", "turn_off", - {"entity_id": "switch.sip_192_168_1_210_5060_allow_calls"}, + {"entity_id": "switch.192_168_1_210_allow_calls"}, blocking=True, ) assert not voip_device.async_allow_call(hass) - state = hass.states.get("switch.sip_192_168_1_210_5060_allow_calls") + state = hass.states.get("switch.192_168_1_210_allow_calls") assert state.state == "off" From e4d19a41fdbfba7a417abf0100662359cafbdc1a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 21 Jan 2025 18:36:23 +0100 Subject: [PATCH 0784/2987] Fix casing and spelling in user-facing strings of homematicip_cloud (#136188) - change all occurrences of "HomematicIP" to "Homematic IP" for consistency - use sentence-casing for "access point" and "configuration" - write all occurrences of "access point" in two words - change "id" to uppercase "ID" - Change abbreviation "hap" to "HAP" (Homematic access point) - make one action description consistent with HA standard - Reword config_output_path description to avoid starting with brackets - change one occurrence of "home-assistant" to "Home Assistant" --- .../components/homematicip_cloud/strings.json | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json index ac7b184e513..37deace7ebf 100644 --- a/homeassistant/components/homematicip_cloud/strings.json +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "init": { - "title": "Pick HomematicIP Access point", + "title": "Pick Homematic IP access point", "data": { "hapid": "Access point ID (SGTIN)", "pin": "[%key:common::config_flow::data::pin%]", @@ -10,8 +10,8 @@ } }, "link": { - "title": "Link Access point", - "description": "Press the blue button on the access point and the **Submit** button to register HomematicIP with Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)" + "title": "Link access point", + "description": "Press the blue button on the access point and the **Submit** button to register Homematic IP with Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)" } }, "error": { @@ -28,7 +28,7 @@ }, "exceptions": { "access_point_not_found": { - "message": "No matching access point found for access point id {id}" + "message": "No matching access point found for access point ID {id}" } }, "services": { @@ -41,8 +41,8 @@ "description": "The duration of eco mode in minutes." }, "accesspoint_id": { - "name": "Accesspoint ID", - "description": "The ID of the Homematic IP Access Point." + "name": "Access point ID", + "description": "The ID of the Homematic IP access point." } } }, @@ -113,20 +113,20 @@ } }, "dump_hap_config": { - "name": "Dump hap config", - "description": "Dumps the configuration of the Homematic IP Access Point(s).", + "name": "Dump HAP config", + "description": "Dumps the configuration of the Homematic IP access point(s).", "fields": { "config_output_path": { "name": "Config output path", - "description": "(Default is 'Your home-assistant config directory') Path where to store the config." + "description": "Path where to store the config. Default is 'Your Home Assistant config directory'." }, "config_output_file_prefix": { "name": "Config output file prefix", - "description": "Name of the config file. The SGTIN of the AP will always be appended." + "description": "Name of the config file. The SGTIN of the HAP will always be appended." }, "anonymize": { "name": "Anonymize", - "description": "Should the Configuration be anonymized?" + "description": "Should the configuration be anonymized?" } } }, @@ -142,7 +142,7 @@ }, "set_home_cooling_mode": { "name": "Set home cooling mode", - "description": "Set the heating/cooling mode for the entire home", + "description": "Sets the heating/cooling mode for the entire home", "fields": { "accesspoint_id": { "name": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_duration::fields::accesspoint_id::name%]", From baf5061fbabef1b284d2a8b807fd655456e7e356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 21 Jan 2025 19:04:41 +0000 Subject: [PATCH 0785/2987] Add strings and state attrs for ZHA 3 Phase current (#132871) * Add strings and state attrs for ZHA 3 Phase current * Use lower case --- homeassistant/components/zha/sensor.py | 2 ++ homeassistant/components/zha/strings.json | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index dde000b24b5..670d6af3c52 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -43,6 +43,8 @@ _EXTRA_STATE_ATTRIBUTES: set[str] = { "measurement_type", "apparent_power_max", "rms_current_max", + "rms_current_max_ph_b", + "rms_current_max_ph_c", "rms_voltage_max", "ac_frequency_max", "power_factor_max", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index da76c62e82e..35c9f35887d 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -1046,6 +1046,12 @@ "instantaneous_demand": { "name": "Instantaneous demand" }, + "rms_current_ph_b": { + "name": "Current phase B" + }, + "rms_current_ph_c": { + "name": "Current phase C" + }, "summation_delivered": { "name": "Summation delivered" }, From f274a3eb37bbab1d9fdd38710ac77354e0216bc3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 21 Jan 2025 21:33:11 +0100 Subject: [PATCH 0786/2987] Fix sentence-casing in user-facing strings of nmap_tracker (#136195) --- homeassistant/components/nmap_tracker/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nmap_tracker/strings.json b/homeassistant/components/nmap_tracker/strings.json index ef660c7e991..3cbbea007b1 100644 --- a/homeassistant/components/nmap_tracker/strings.json +++ b/homeassistant/components/nmap_tracker/strings.json @@ -21,7 +21,7 @@ "config": { "step": { "user": { - "description": "Configure hosts to be scanned by Nmap. Network address and excludes can be IP Addresses (192.168.1.1), IP Networks (192.168.0.0/24) or IP Ranges (192.168.1.0-32).", + "description": "Configure hosts to be scanned by Nmap. Network address and excludes can be IP addresses (192.168.1.1), IP networks (192.168.0.0/24) or IP ranges (192.168.1.0-32).", "data": { "hosts": "Network addresses (comma separated) to scan", "home_interval": "Minimum number of minutes between scans of active devices (preserve battery)", @@ -31,7 +31,7 @@ } }, "error": { - "invalid_hosts": "Invalid Hosts" + "invalid_hosts": "Invalid hosts" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" From 69900ed8cb759a9e3cad867d25a593070686c8e6 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 21 Jan 2025 14:12:15 -0700 Subject: [PATCH 0787/2987] Cleanup litterrobot switch entity (#136199) --- .../components/litterrobot/switch.py | 53 +++++++++---------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py index 133fd897cc6..a73449b01a1 100644 --- a/homeassistant/components/litterrobot/switch.py +++ b/homeassistant/components/litterrobot/switch.py @@ -17,18 +17,13 @@ from . import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT -@dataclass(frozen=True) -class RequiredKeysMixin(Generic[_RobotT]): - """A class that describes robot switch entity required keys.""" - - set_fn: Callable[[_RobotT, bool], Coroutine[Any, Any, bool]] - - -@dataclass(frozen=True) -class RobotSwitchEntityDescription(SwitchEntityDescription, RequiredKeysMixin[_RobotT]): +@dataclass(frozen=True, kw_only=True) +class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_RobotT]): """A class that describes robot switch entities.""" entity_category: EntityCategory = EntityCategory.CONFIG + set_fn: Callable[[_RobotT, bool], Coroutine[Any, Any, bool]] + value_fn: Callable[[_RobotT], bool] ROBOT_SWITCHES = [ @@ -36,34 +31,17 @@ ROBOT_SWITCHES = [ key="night_light_mode_enabled", translation_key="night_light_mode", set_fn=lambda robot, value: robot.set_night_light(value), + value_fn=lambda robot: robot.night_light_mode_enabled, ), RobotSwitchEntityDescription[LitterRobot | FeederRobot]( key="panel_lock_enabled", translation_key="panel_lockout", set_fn=lambda robot, value: robot.set_panel_lockout(value), + value_fn=lambda robot: robot.panel_lock_enabled, ), ] -class RobotSwitchEntity(LitterRobotEntity[_RobotT], SwitchEntity): - """Litter-Robot switch entity.""" - - entity_description: RobotSwitchEntityDescription[_RobotT] - - @property - def is_on(self) -> bool | None: - """Return true if switch is on.""" - return bool(getattr(self.robot, self.entity_description.key)) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the switch on.""" - await self.entity_description.set_fn(self.robot, True) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the switch off.""" - await self.entity_description.set_fn(self.robot, False) - - async def async_setup_entry( hass: HomeAssistant, entry: LitterRobotConfigEntry, @@ -78,3 +56,22 @@ async def async_setup_entry( if isinstance(robot, (LitterRobot, FeederRobot)) ] async_add_entities(entities) + + +class RobotSwitchEntity(LitterRobotEntity[_RobotT], SwitchEntity): + """Litter-Robot switch entity.""" + + entity_description: RobotSwitchEntityDescription[_RobotT] + + @property + def is_on(self) -> bool | None: + """Return true if switch is on.""" + return self.entity_description.value_fn(self.robot) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.entity_description.set_fn(self.robot, True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.entity_description.set_fn(self.robot, False) From 3bcef79562989c423b6eeab346f6c1b8173617d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Jan 2025 11:28:11 -1000 Subject: [PATCH 0788/2987] Bump bleak-retry-connector to 3.8.0 (#136203) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index b5aa6cfa12f..b88c6a99587 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,7 +16,7 @@ "quality_scale": "internal", "requirements": [ "bleak==0.22.3", - "bleak-retry-connector==3.7.0", + "bleak-retry-connector==3.8.0", "bluetooth-adapters==0.21.0", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a804cb90cf3..6af71a09901 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -18,7 +18,7 @@ audioop-lts==0.2.1;python_version>='3.13' av==13.1.0 awesomeversion==24.6.0 bcrypt==4.2.0 -bleak-retry-connector==3.7.0 +bleak-retry-connector==3.8.0 bleak==0.22.3 bluetooth-adapters==0.21.0 bluetooth-auto-recovery==1.4.2 diff --git a/requirements_all.txt b/requirements_all.txt index 2a05e882e17..a184bb10aa3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -594,7 +594,7 @@ bizkaibus==0.1.1 bleak-esphome==2.0.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.7.0 +bleak-retry-connector==3.8.0 # homeassistant.components.bluetooth bleak==0.22.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e7b4a92a93..e81a55cc9d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -525,7 +525,7 @@ bimmer-connected[china]==0.17.2 bleak-esphome==2.0.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.7.0 +bleak-retry-connector==3.8.0 # homeassistant.components.bluetooth bleak==0.22.3 From b9537466fd822d51cef6aa2195d8e9140faa03d9 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 21 Jan 2025 14:31:59 -0700 Subject: [PATCH 0789/2987] Add button to reset Litter-Robot 4 (#136191) --- .../components/litterrobot/button.py | 79 ++++++++----------- .../components/litterrobot/strings.json | 3 + 2 files changed, 38 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/litterrobot/button.py b/homeassistant/components/litterrobot/button.py index 6e6cc563c8e..984b28cc96e 100644 --- a/homeassistant/components/litterrobot/button.py +++ b/homeassistant/components/litterrobot/button.py @@ -4,10 +4,9 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass -import itertools from typing import Any, Generic -from pylitterbot import FeederRobot, LitterRobot3 +from pylitterbot import FeederRobot, LitterRobot3, LitterRobot4, Robot from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory @@ -18,6 +17,34 @@ from . import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT +@dataclass(frozen=True, kw_only=True) +class RobotButtonEntityDescription(ButtonEntityDescription, Generic[_RobotT]): + """A class that describes robot button entities.""" + + press_fn: Callable[[_RobotT], Coroutine[Any, Any, bool]] + + +ROBOT_BUTTON_MAP: dict[type[Robot], RobotButtonEntityDescription] = { + LitterRobot3: RobotButtonEntityDescription[LitterRobot3]( + key="reset_waste_drawer", + translation_key="reset_waste_drawer", + entity_category=EntityCategory.CONFIG, + press_fn=lambda robot: robot.reset_waste_drawer(), + ), + LitterRobot4: RobotButtonEntityDescription[LitterRobot4]( + key="reset", + translation_key="reset", + entity_category=EntityCategory.CONFIG, + press_fn=lambda robot: robot.reset(), + ), + FeederRobot: RobotButtonEntityDescription[FeederRobot]( + key="give_snack", + translation_key="give_snack", + press_fn=lambda robot: robot.give_snack(), + ), +} + + async def async_setup_entry( hass: HomeAssistant, entry: LitterRobotConfigEntry, @@ -25,51 +52,15 @@ async def async_setup_entry( ) -> None: """Set up Litter-Robot cleaner using config entry.""" hub = entry.runtime_data - entities: list[LitterRobotButtonEntity] = list( - itertools.chain( - ( - LitterRobotButtonEntity( - robot=robot, hub=hub, description=LITTER_ROBOT_BUTTON - ) - for robot in hub.litter_robots() - if isinstance(robot, LitterRobot3) - ), - ( - LitterRobotButtonEntity( - robot=robot, hub=hub, description=FEEDER_ROBOT_BUTTON - ) - for robot in hub.feeder_robots() - ), - ) - ) + entities = [ + LitterRobotButtonEntity(robot=robot, hub=hub, description=description) + for robot in hub.account.robots + for robot_type, description in ROBOT_BUTTON_MAP.items() + if isinstance(robot, robot_type) + ] async_add_entities(entities) -@dataclass(frozen=True) -class RequiredKeysMixin(Generic[_RobotT]): - """A class that describes robot button entity required keys.""" - - press_fn: Callable[[_RobotT], Coroutine[Any, Any, bool]] - - -@dataclass(frozen=True) -class RobotButtonEntityDescription(ButtonEntityDescription, RequiredKeysMixin[_RobotT]): - """A class that describes robot button entities.""" - - -LITTER_ROBOT_BUTTON = RobotButtonEntityDescription[LitterRobot3]( - key="reset_waste_drawer", - translation_key="reset_waste_drawer", - entity_category=EntityCategory.CONFIG, - press_fn=lambda robot: robot.reset_waste_drawer(), -) -FEEDER_ROBOT_BUTTON = RobotButtonEntityDescription[FeederRobot]( - key="give_snack", - translation_key="give_snack", - press_fn=lambda robot: robot.give_snack(), -) - - class LitterRobotButtonEntity(LitterRobotEntity[_RobotT], ButtonEntity): """Litter-Robot button entity.""" diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 7acfad69735..3b6e2f01ef9 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -38,6 +38,9 @@ } }, "button": { + "reset": { + "name": "Reset" + }, "reset_waste_drawer": { "name": "Reset waste drawer" }, From 6130c2f6761550af81ec1a81afa1553c78876d37 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 21 Jan 2025 22:35:45 +0100 Subject: [PATCH 0790/2987] Remove excessive newlines from envisalink strings (#136194) Remove excessive newline codes from user-facing strings Delete two occurrences of `\n.` from the strings.json file. --- homeassistant/components/envisalink/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/envisalink/strings.json b/homeassistant/components/envisalink/strings.json index a539c890169..265ce28f920 100644 --- a/homeassistant/components/envisalink/strings.json +++ b/homeassistant/components/envisalink/strings.json @@ -16,11 +16,11 @@ }, "invoke_custom_function": { "name": "Invoke custom function", - "description": "Allows users with DSC panels to trigger a PGM output (1-4). Note that you need to specify the alarm panel's \"code\" parameter for this to work.\n.", + "description": "Allows users with DSC panels to trigger a PGM output (1-4). Note that you need to specify the alarm panel's \"code\" parameter for this to work.", "fields": { "partition": { "name": "Partition", - "description": "The alarm panel partition to trigger the PGM output on. Typically this is just \"1\".\n." + "description": "The alarm panel partition to trigger the PGM output on. Typically this is just \"1\"." }, "pgm": { "name": "PGM", From 940a0f85e99b92c1136e9e009b6eba0ac5113e01 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 21 Jan 2025 22:37:02 +0100 Subject: [PATCH 0791/2987] Remove excessive newline codes from strings of nissan_leaf (#136197) Just three occurrences of `\n." to remove. --- homeassistant/components/nissan_leaf/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nissan_leaf/strings.json b/homeassistant/components/nissan_leaf/strings.json index d733e39a0fc..78335ab4c14 100644 --- a/homeassistant/components/nissan_leaf/strings.json +++ b/homeassistant/components/nissan_leaf/strings.json @@ -2,17 +2,17 @@ "services": { "start_charge": { "name": "Start charge", - "description": "Starts the vehicle charging. It must be plugged in first!\n.", + "description": "Starts the vehicle charging. It must be plugged in first!", "fields": { "vin": { "name": "VIN", - "description": "The vehicle identification number (VIN) of the vehicle, 17 characters\n." + "description": "The vehicle identification number (VIN) of the vehicle, 17 characters." } } }, "update": { "name": "Update", - "description": "Fetches the last state of the vehicle of all your accounts, requesting an update from of the state from the car if possible.\n.", + "description": "Fetches the last state of the vehicle of all your accounts, requesting an update from of the state from the car if possible.", "fields": { "vin": { "name": "[%key:component::nissan_leaf::services::start_charge::fields::vin::name%]", From e7345dd44a622bad2cb592297b5af34c136e80f0 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 21 Jan 2025 15:49:43 -0700 Subject: [PATCH 0792/2987] Remove extra_state_attributes from Litter-Robot vacuum entities (#136196) --- homeassistant/components/litterrobot/vacuum.py | 17 ----------------- tests/components/litterrobot/test_vacuum.py | 13 ------------- 2 files changed, 30 deletions(-) diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index bd00c328233..19789fb387c 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -79,13 +79,6 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity): """Return the state of the cleaner.""" return LITTER_BOX_STATUS_STATE_MAP.get(self.robot.status, VacuumActivity.ERROR) - @property - def status(self) -> str: - """Return the status of the cleaner.""" - return ( - f"{self.robot.status.text}{' (Sleeping)' if self.robot.is_sleeping else ''}" - ) - async def async_start(self) -> None: """Start a clean cycle.""" await self.robot.set_power_status(True) @@ -121,13 +114,3 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity): ) .timetz() ) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return device specific state attributes.""" - return { - "is_sleeping": self.robot.is_sleeping, - "sleep_mode_enabled": self.robot.sleep_mode_enabled, - "power_status": self.robot.power_status, - "status": self.status, - } diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index f18098ccf1d..16e58512ee8 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -11,7 +11,6 @@ import pytest from homeassistant.components.litterrobot import DOMAIN from homeassistant.components.litterrobot.vacuum import SERVICE_SET_SLEEP_MODE from homeassistant.components.vacuum import ( - ATTR_STATUS, DOMAIN as PLATFORM_DOMAIN, SERVICE_START, SERVICE_STOP, @@ -52,23 +51,11 @@ async def test_vacuum( vacuum = hass.states.get(VACUUM_ENTITY_ID) assert vacuum assert vacuum.state == VacuumActivity.DOCKED - assert vacuum.attributes["is_sleeping"] is False ent_reg_entry = entity_registry.async_get(VACUUM_ENTITY_ID) assert ent_reg_entry.unique_id == VACUUM_UNIQUE_ID -async def test_vacuum_status_when_sleeping( - hass: HomeAssistant, mock_account_with_sleeping_robot: MagicMock -) -> None: - """Tests the vacuum status when sleeping.""" - await setup_integration(hass, mock_account_with_sleeping_robot, PLATFORM_DOMAIN) - - vacuum = hass.states.get(VACUUM_ENTITY_ID) - assert vacuum - assert vacuum.attributes.get(ATTR_STATUS) == "Ready (Sleeping)" - - async def test_no_robots( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 561e027deead7629edba4590e4b2eecc2bab2806 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Jan 2025 13:27:09 -1000 Subject: [PATCH 0793/2987] Bump habluetooth to 3.10.0 (#136210) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index b88c6a99587..de446886c16 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.9.2" + "habluetooth==3.10.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6af71a09901..91fff829a47 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.9.2 +habluetooth==3.10.0 hass-nabucasa==0.88.1 hassil==2.1.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index a184bb10aa3..4201fab07d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1097,7 +1097,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.9.2 +habluetooth==3.10.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e81a55cc9d4..e032b3b14e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -938,7 +938,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.9.2 +habluetooth==3.10.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 From 386357d9bdeedbab8d854db43c4db1a2fc5a0dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 22 Jan 2025 01:16:26 +0100 Subject: [PATCH 0794/2987] Bump ollama to 0.4.7 (#136212) --- homeassistant/components/ollama/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ollama/manifest.json b/homeassistant/components/ollama/manifest.json index dbecbf87e4e..c3f7616ca16 100644 --- a/homeassistant/components/ollama/manifest.json +++ b/homeassistant/components/ollama/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/ollama", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["ollama==0.4.5"] + "requirements": ["ollama==0.4.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4201fab07d0..2f67abecee3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1543,7 +1543,7 @@ oemthermostat==1.1.1 ohme==1.2.3 # homeassistant.components.ollama -ollama==0.4.5 +ollama==0.4.7 # homeassistant.components.omnilogic omnilogic==0.4.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e032b3b14e4..3992bc6fd84 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1291,7 +1291,7 @@ odp-amsterdam==6.0.2 ohme==1.2.3 # homeassistant.components.ollama -ollama==0.4.5 +ollama==0.4.7 # homeassistant.components.omnilogic omnilogic==0.4.5 From 18ab882536c41e42a2663aa8403179efae2e5095 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Jan 2025 14:58:20 -1000 Subject: [PATCH 0795/2987] Bump bleak-esphome to 2.1.0 (#136214) --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 43f18d4fffc..68971759243 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.0.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.1.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index f56f8342df6..d43662a32f7 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==28.0.1", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.0.0" + "bleak-esphome==2.1.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 2f67abecee3..e4876679071 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -591,7 +591,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.0.0 +bleak-esphome==2.1.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3992bc6fd84..2d9fbcfdb94 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -522,7 +522,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.0.0 +bleak-esphome==2.1.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 From ffcb4d676b02643feed655156f71efe6422e9a25 Mon Sep 17 00:00:00 2001 From: krakonos1602 <99399180+krakonos1602@users.noreply.github.com> Date: Wed, 22 Jan 2025 03:42:07 +0100 Subject: [PATCH 0796/2987] Add Eve Thermo TRV Matter features (#135635) * Add Eve Thermo Matter features * Update homeassistant/components/matter/switch.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/matter/switch.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/matter/switch.py Co-authored-by: Martin Hjelmare * Add Eve Thermo Child lock test * Update homeassistant/components/matter/switch.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/matter/switch.py Co-authored-by: Martin Hjelmare * Implement thorough Child lock testing * Apply suggestions from code review --------- Co-authored-by: Martin Hjelmare Co-authored-by: Marcel van der Veldt --- homeassistant/components/matter/icons.json | 9 ++ homeassistant/components/matter/number.py | 29 ++++- homeassistant/components/matter/select.py | 21 ++++ homeassistant/components/matter/strings.json | 9 ++ homeassistant/components/matter/switch.py | 70 ++++++++++- .../matter/snapshots/test_number.ambr | 57 +++++++++ .../matter/snapshots/test_select.ambr | 110 ++++++++++++++++++ .../matter/snapshots/test_switch.ambr | 46 ++++++++ tests/components/matter/test_switch.py | 55 +++++++++ 9 files changed, 403 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index ef29601b831..f000bad87dd 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -61,6 +61,15 @@ "battery_replacement_description": { "default": "mdi:battery-sync-outline" } + }, + "switch": { + "child_lock": { + "default": "mdi:lock", + "state": { + "on": "mdi:lock", + "off": "mdi:lock-off" + } + } } } } diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index cc312cdc66a..22929c60b89 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -15,7 +15,13 @@ from homeassistant.components.number import ( NumberMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform, UnitOfLength, UnitOfTime +from homeassistant.const import ( + EntityCategory, + Platform, + UnitOfLength, + UnitOfTemperature, + UnitOfTime, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -155,4 +161,25 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterNumber, required_attributes=(custom_clusters.EveCluster.Attributes.Altitude,), ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="EveTemperatureOffset", + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + translation_key="temperature_offset", + native_max_value=25, + native_min_value=-25, + native_step=0.5, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + measurement_to_ha=lambda x: None if x is None else x / 10, + ha_to_native_value=lambda x: round(x * 10), + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=( + clusters.Thermostat.Attributes.LocalTemperatureCalibration, + ), + vendor_id=(4874,), + ), ] diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index 1a2fc36c014..06eb6f249eb 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -254,4 +254,25 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterSelectEntity, required_attributes=(clusters.SmokeCoAlarm.Attributes.SmokeSensitivityLevel,), ), + MatterDiscoverySchema( + platform=Platform.SELECT, + entity_description=MatterSelectEntityDescription( + key="TrvTemperatureDisplayMode", + entity_category=EntityCategory.CONFIG, + translation_key="temperature_display_mode", + options=["Celsius", "Fahrenheit"], + measurement_to_ha={ + 0: "Celsius", + 1: "Fahrenheit", + }.get, + ha_to_native_value={ + "Celsius": 0, + "Fahrenheit": 1, + }.get, + ), + entity_class=MatterSelectEntity, + required_attributes=( + clusters.ThermostatUserInterfaceConfiguration.Attributes.TemperatureDisplayMode, + ), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index ca15538997e..6eb47248564 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -161,6 +161,9 @@ }, "altitude": { "name": "Altitude above Sea Level" + }, + "temperature_offset": { + "name": "Temperature offset" } }, "light": { @@ -196,6 +199,9 @@ "toggle": "[%key:common::action::toggle%]", "previous": "Previous" } + }, + "temperature_display_mode": { + "name": "Temperature display mode" } }, "sensor": { @@ -256,6 +262,9 @@ }, "power": { "name": "Power" + }, + "child_lock": { + "name": "Child lock" } }, "vacuum": { diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 75269de953c..2a1e6d59a06 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -2,10 +2,12 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any from chip.clusters import Objects as clusters from matter_server.client.models import device_types +from matter_server.common.helpers.util import create_attribute_path_from_attribute from homeassistant.components.switch import ( SwitchDeviceClass, @@ -13,11 +15,11 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -61,6 +63,49 @@ class MatterSwitch(MatterEntity, SwitchEntity): ) +@dataclass(frozen=True) +class MatterNumericSwitchEntityDescription( + SwitchEntityDescription, MatterEntityDescription +): + """Describe Matter Numeric Switch entities.""" + + +class MatterNumericSwitch(MatterSwitch): + """Representation of a Matter Enum Attribute as a Switch entity.""" + + entity_description: MatterNumericSwitchEntityDescription + + async def _async_set_native_value(self, value: bool) -> None: + """Update the current value.""" + matter_attribute = self._entity_info.primary_attribute + if value_convert := self.entity_description.ha_to_native_value: + send_value = value_convert(value) + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + matter_attribute, + ), + value=send_value, + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn switch on.""" + await self._async_set_native_value(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn switch off.""" + await self._async_set_native_value(False) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + value = self.get_matter_attribute_value(self._entity_info.primary_attribute) + if value_convert := self.entity_description.measurement_to_ha: + value = value_convert(value) + self._attr_is_on = value + + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( @@ -139,4 +184,25 @@ DISCOVERY_SCHEMAS = [ device_types.Speaker, ), ), + MatterDiscoverySchema( + platform=Platform.SWITCH, + entity_description=MatterNumericSwitchEntityDescription( + key="EveTrvChildLock", + entity_category=EntityCategory.CONFIG, + translation_key="child_lock", + measurement_to_ha={ + 0: False, + 1: True, + }.get, + ha_to_native_value={ + False: 0, + True: 1, + }.get, + ), + entity_class=MatterNumericSwitch, + required_attributes=( + clusters.ThermostatUserInterfaceConfiguration.Attributes.KeypadLockout, + ), + vendor_id=(4874,), + ), ] diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index 9d51bb92e51..7e06b6f501d 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -388,6 +388,63 @@ 'state': '1.0', }) # --- +# name: test_numbers[eve_thermo][number.eve_thermo_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 25, + 'min': -25, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.eve_thermo_temperature_offset', + '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 offset', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-EveTemperatureOffset-513-16', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[eve_thermo][number.eve_thermo_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Eve Thermo Temperature offset', + 'max': 25, + 'min': -25, + 'mode': , + 'step': 0.5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.eve_thermo_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_numbers[eve_weather_sensor][number.eve_weather_altitude_above_sea_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 663b0cdaf51..4c2d7dd3e06 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -546,6 +546,61 @@ 'state': 'previous', }) # --- +# name: test_selects[eve_thermo][select.eve_thermo_temperature_display_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Celsius', + 'Fahrenheit', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.eve_thermo_temperature_display_mode', + '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': 'Temperature display mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_mode', + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-TrvTemperatureDisplayMode-516-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[eve_thermo][select.eve_thermo_temperature_display_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eve Thermo Temperature display mode', + 'options': list([ + 'Celsius', + 'Fahrenheit', + ]), + }), + 'context': , + 'entity_id': 'select.eve_thermo_temperature_display_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Celsius', + }) +# --- # name: test_selects[extended_color_light][select.mock_extended_color_light_lighting-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1573,6 +1628,61 @@ 'state': 'previous', }) # --- +# name: test_selects[thermostat][select.longan_link_hvac_temperature_display_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Celsius', + 'Fahrenheit', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.longan_link_hvac_temperature_display_mode', + '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': 'Temperature display mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_mode', + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-TrvTemperatureDisplayMode-516-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[thermostat][select.longan_link_hvac_temperature_display_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Longan link HVAC Temperature display mode', + 'options': list([ + 'Celsius', + 'Fahrenheit', + ]), + }), + 'context': , + 'entity_id': 'select.longan_link_hvac_temperature_display_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Celsius', + }) +# --- # name: test_selects[vacuum_cleaner][select.mock_vacuum_clean_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index 9396dccd245..612e81580a5 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -187,6 +187,52 @@ 'state': 'off', }) # --- +# name: test_switches[eve_thermo][switch.eve_thermo_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eve_thermo_child_lock', + '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': 'Child lock', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-EveTrvChildLock-516-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[eve_thermo][switch.eve_thermo_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eve Thermo Child lock', + }), + 'context': , + 'entity_id': 'switch.eve_thermo_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switches[on_off_plugin_unit][switch.mock_onoffpluginunit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index d7a6a700cde..11451c715c3 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode +from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest from syrupy import SnapshotAssertion @@ -110,3 +111,57 @@ async def test_power_switch(hass: HomeAssistant, matter_node: MatterNode) -> Non assert state assert state.state == "off" assert state.attributes["friendly_name"] == "Room AirConditioner Power" + + +@pytest.mark.parametrize("node_fixture", ["eve_thermo"]) +async def test_numeric_switch( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test numeric switch entity is discovered and working using an Eve Thermo fixture .""" + state = hass.states.get("switch.eve_thermo_child_lock") + assert state + assert state.state == "off" + # name should be derived from description attribute + assert state.attributes["friendly_name"] == "Eve Thermo Child lock" + # test attribute changes + set_node_attribute(matter_node, 1, 516, 1, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("switch.eve_thermo_child_lock") + assert state.state == "on" + set_node_attribute(matter_node, 1, 516, 1, 0) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("switch.eve_thermo_child_lock") + assert state.state == "off" + # test switch service + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": "switch.eve_thermo_child_lock"}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args_list[0] == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=clusters.ThermostatUserInterfaceConfiguration.Attributes.KeypadLockout, + ), + value=1, + ) + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": "switch.eve_thermo_child_lock"}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 2 + assert matter_client.write_attribute.call_args_list[1] == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=clusters.ThermostatUserInterfaceConfiguration.Attributes.KeypadLockout, + ), + value=0, + ) From f822fd82bb185b50a6e0ca45035cca2096efa8d2 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 22 Jan 2025 05:18:05 +0100 Subject: [PATCH 0797/2987] Fix recorder fixture typing (#136174) --- pylint/plugins/hass_enforce_type_hints.py | 3 ++- tests/components/duke_energy/conftest.py | 4 +-- tests/components/history/conftest.py | 4 +-- .../auto_repairs/events/test_schema.py | 10 +++---- .../auto_repairs/states/test_schema.py | 12 ++++----- .../statistics/test_duplicates.py | 8 +++--- .../auto_repairs/statistics/test_schema.py | 10 +++---- .../recorder/auto_repairs/test_schema.py | 4 +-- .../recorder/test_entity_registry.py | 4 +-- tests/components/recorder/test_history.py | 4 +-- .../recorder/test_history_db_schema_32.py | 4 +-- .../recorder/test_history_db_schema_42.py | 4 +-- tests/components/recorder/test_init.py | 8 +++--- tests/components/recorder/test_migrate.py | 4 +-- .../recorder/test_migration_from_schema_32.py | 26 +++++++++---------- ..._migration_run_time_migrations_remember.py | 8 +++--- tests/components/recorder/test_purge.py | 4 +-- .../recorder/test_purge_v32_schema.py | 4 +-- tests/components/recorder/test_statistics.py | 4 +-- .../recorder/test_statistics_v23_migration.py | 12 +++++---- tests/components/recorder/test_util.py | 4 +-- .../components/recorder/test_v32_migration.py | 12 ++++----- .../components/recorder/test_websocket_api.py | 8 ++++-- tests/components/sensor/test_recorder.py | 4 +-- .../sensor/test_recorder_missing_stats.py | 4 +-- tests/conftest.py | 9 ++++--- tests/typing.py | 5 ++++ 27 files changed, 100 insertions(+), 87 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index d06d078ae8b..f76e0b43c10 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -106,7 +106,8 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "aiohttp_client": "ClientSessionGenerator", "aiohttp_server": "Callable[[], TestServer]", "area_registry": "AreaRegistry", - "async_test_recorder": "RecorderInstanceGenerator", + "async_test_recorder": "RecorderInstanceContextManager", + "async_setup_recorder_instance": "RecorderInstanceGenerator", "caplog": "pytest.LogCaptureFixture", "capsys": "pytest.CaptureFixture[str]", "current_request_with_host": "None", diff --git a/tests/components/duke_energy/conftest.py b/tests/components/duke_energy/conftest.py index ed4182f450f..f74ef43bf07 100644 --- a/tests/components/duke_energy/conftest.py +++ b/tests/components/duke_energy/conftest.py @@ -11,12 +11,12 @@ from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/history/conftest.py b/tests/components/history/conftest.py index dd10fccccdc..8269d3319cb 100644 --- a/tests/components/history/conftest.py +++ b/tests/components/history/conftest.py @@ -8,12 +8,12 @@ from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/recorder/auto_repairs/events/test_schema.py b/tests/components/recorder/auto_repairs/events/test_schema.py index cae181a6270..91f5bd50298 100644 --- a/tests/components/recorder/auto_repairs/events/test_schema.py +++ b/tests/components/recorder/auto_repairs/events/test_schema.py @@ -8,12 +8,12 @@ from homeassistant.core import HomeAssistant from ...common import async_wait_recording_done -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -22,7 +22,7 @@ async def mock_recorder_before_hass( @pytest.mark.parametrize("db_engine", ["mysql", "postgresql"]) async def test_validate_db_schema_fix_float_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, @@ -58,7 +58,7 @@ async def test_validate_db_schema_fix_float_issue( @pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_utf8_issue_event_data( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, @@ -91,7 +91,7 @@ async def test_validate_db_schema_fix_utf8_issue_event_data( @pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_collation_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, diff --git a/tests/components/recorder/auto_repairs/states/test_schema.py b/tests/components/recorder/auto_repairs/states/test_schema.py index 915ac1f3500..982a6a732b6 100644 --- a/tests/components/recorder/auto_repairs/states/test_schema.py +++ b/tests/components/recorder/auto_repairs/states/test_schema.py @@ -8,12 +8,12 @@ from homeassistant.core import HomeAssistant from ...common import async_wait_recording_done -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -22,7 +22,7 @@ async def mock_recorder_before_hass( @pytest.mark.parametrize("db_engine", ["mysql", "postgresql"]) async def test_validate_db_schema_fix_float_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, @@ -60,7 +60,7 @@ async def test_validate_db_schema_fix_float_issue( @pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_utf8_issue_states( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, @@ -92,7 +92,7 @@ async def test_validate_db_schema_fix_utf8_issue_states( @pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_utf8_issue_state_attributes( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, @@ -125,7 +125,7 @@ async def test_validate_db_schema_fix_utf8_issue_state_attributes( @pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_collation_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, diff --git a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py index 9e287d13594..78a7ddaa300 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py +++ b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py @@ -22,12 +22,12 @@ import homeassistant.util.dt as dt_util from ...common import async_wait_recording_done from tests.common import async_test_home_assistant -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -134,7 +134,7 @@ def _create_engine_28(*args, **kwargs): @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_delete_metadata_duplicates( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of duplicated statistics.""" @@ -242,7 +242,7 @@ async def test_delete_metadata_duplicates( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_delete_metadata_duplicates_many( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of duplicated statistics.""" diff --git a/tests/components/recorder/auto_repairs/statistics/test_schema.py b/tests/components/recorder/auto_repairs/statistics/test_schema.py index 34a075afbc7..352a2345052 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_schema.py +++ b/tests/components/recorder/auto_repairs/statistics/test_schema.py @@ -8,12 +8,12 @@ from homeassistant.core import HomeAssistant from ...common import async_wait_recording_done -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -22,7 +22,7 @@ async def mock_recorder_before_hass( @pytest.mark.parametrize("enable_schema_validation", [True]) async def test_validate_db_schema_fix_utf8_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, @@ -56,7 +56,7 @@ async def test_validate_db_schema_fix_utf8_issue( @pytest.mark.parametrize("db_engine", ["mysql", "postgresql"]) async def test_validate_db_schema_fix_float_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, table: str, db_engine: str, @@ -100,7 +100,7 @@ async def test_validate_db_schema_fix_float_issue( @pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_collation_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, recorder_dialect_name: None, db_engine: str, diff --git a/tests/components/recorder/auto_repairs/test_schema.py b/tests/components/recorder/auto_repairs/test_schema.py index 857c0f6572f..bf2a925df17 100644 --- a/tests/components/recorder/auto_repairs/test_schema.py +++ b/tests/components/recorder/auto_repairs/test_schema.py @@ -18,12 +18,12 @@ from homeassistant.core import HomeAssistant from ..common import async_wait_recording_done -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/recorder/test_entity_registry.py b/tests/components/recorder/test_entity_registry.py index ad438dcc525..8a5ce23799c 100644 --- a/tests/components/recorder/test_entity_registry.py +++ b/tests/components/recorder/test_entity_registry.py @@ -23,7 +23,7 @@ from .common import ( ) from tests.common import MockEntity, MockEntityPlatform -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager def _count_entity_id_in_states_meta( @@ -40,7 +40,7 @@ def _count_entity_id_in_states_meta( @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index 28b8275247c..d9dbbf191f6 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -33,12 +33,12 @@ from .common import ( async_wait_recording_done, ) -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index 666626ff688..bfe5c852ca6 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -28,12 +28,12 @@ from .common import ( old_db_schema, ) -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/recorder/test_history_db_schema_42.py b/tests/components/recorder/test_history_db_schema_42.py index 85badeea281..23ac6f9fb8a 100644 --- a/tests/components/recorder/test_history_db_schema_42.py +++ b/tests/components/recorder/test_history_db_schema_42.py @@ -31,12 +31,12 @@ from .common import ( ) from .db_schema_42 import StateAttributes, States, StatesMeta -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 24070e6f156..f8d1ac4af57 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -98,12 +98,12 @@ from tests.common import ( async_test_home_assistant, mock_platform, ) -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager, RecorderInstanceGenerator @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -1373,7 +1373,7 @@ async def test_statistics_runs_initiated( @pytest.mark.parametrize("enable_missing_statistics", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_compile_missing_statistics( - async_test_recorder: RecorderInstanceGenerator, freezer: FrozenDateTimeFactory + async_test_recorder: RecorderInstanceContextManager, freezer: FrozenDateTimeFactory ) -> None: """Test missing statistics are compiled on startup.""" now = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) @@ -1632,7 +1632,7 @@ async def test_service_disable_states_not_recording( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_service_disable_run_information_recorded( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test that runs are still recorded when recorder is disabled.""" diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 052e9202715..e60a4705ac8 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -37,12 +37,12 @@ from .common import async_wait_recording_done, create_engine_test from .conftest import InstrumentedMigration from tests.common import async_fire_time_changed -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager, RecorderInstanceGenerator @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 0624955b0e9..94b7518edb7 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -52,7 +52,7 @@ from .common import ( from .conftest import instrument_migration from tests.common import async_test_home_assistant -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager CREATE_ENGINE_TARGET = "homeassistant.components.recorder.core.create_engine" SCHEMA_MODULE_32 = "tests.components.recorder.db_schema_32" @@ -60,7 +60,7 @@ SCHEMA_MODULE_32 = "tests.components.recorder.db_schema_32" @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -124,7 +124,7 @@ def db_schema_32(): @pytest.mark.parametrize("indices_to_drop", [[], [("events", "ix_events_context_id")]]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_events_context_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, indices_to_drop: list[tuple[str, str]], ) -> None: """Test we can migrate old uuid context ids and ulid context ids to binary format.""" @@ -396,7 +396,7 @@ async def test_migrate_events_context_ids( @pytest.mark.parametrize("enable_migrate_event_context_ids", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_finish_migrate_events_context_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test we re migrate old uuid context ids and ulid context ids to binary format. @@ -505,7 +505,7 @@ async def test_finish_migrate_events_context_ids( @pytest.mark.parametrize("indices_to_drop", [[], [("states", "ix_states_context_id")]]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_states_context_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, indices_to_drop: list[tuple[str, str]], ) -> None: """Test we can migrate old uuid context ids and ulid context ids to binary format.""" @@ -758,7 +758,7 @@ async def test_migrate_states_context_ids( @pytest.mark.parametrize("enable_migrate_state_context_ids", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_finish_migrate_states_context_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test we re migrate old uuid context ids and ulid context ids to binary format. @@ -866,7 +866,7 @@ async def test_finish_migrate_states_context_ids( @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_event_type_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test we can migrate event_types to the EventTypes table.""" importlib.import_module(SCHEMA_MODULE_32) @@ -984,7 +984,7 @@ async def test_migrate_event_type_ids( @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_entity_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test we can migrate entity_ids to the StatesMeta table.""" importlib.import_module(SCHEMA_MODULE_32) @@ -1092,7 +1092,7 @@ async def test_migrate_entity_ids( ) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_post_migrate_entity_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, indices_to_drop: list[tuple[str, str]], ) -> None: """Test we can migrate entity_ids to the StatesMeta table.""" @@ -1200,7 +1200,7 @@ async def test_post_migrate_entity_ids( @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_null_entity_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test we can migrate entity_ids to the StatesMeta table.""" importlib.import_module(SCHEMA_MODULE_32) @@ -1310,7 +1310,7 @@ async def test_migrate_null_entity_ids( @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_null_event_type_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test we can migrate event_types to the EventTypes table when the event_type is NULL.""" importlib.import_module(SCHEMA_MODULE_32) @@ -1991,7 +1991,7 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_stats_migrate_times( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, ) -> None: """Test we can migrate times in the statistics tables.""" @@ -2147,7 +2147,7 @@ async def test_stats_migrate_times( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_cleanup_unmigrated_state_timestamps( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Ensure schema 48 migration cleans up any unmigrated state timestamps.""" importlib.import_module(SCHEMA_MODULE_32) diff --git a/tests/components/recorder/test_migration_run_time_migrations_remember.py b/tests/components/recorder/test_migration_run_time_migrations_remember.py index 677abd6083c..43a1b028348 100644 --- a/tests/components/recorder/test_migration_run_time_migrations_remember.py +++ b/tests/components/recorder/test_migration_run_time_migrations_remember.py @@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant from .common import async_recorder_block_till_done, async_wait_recording_done from tests.common import async_test_home_assistant -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager CREATE_ENGINE_TARGET = "homeassistant.components.recorder.core.create_engine" SCHEMA_MODULE_32 = "tests.components.recorder.db_schema_32" @@ -34,7 +34,7 @@ SCHEMA_MODULE_CURRENT = "homeassistant.components.recorder.db_schema" @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -175,7 +175,7 @@ def _create_engine_test( ], ) async def test_data_migrator_logic( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, initial_version: int, expected_migrator_calls: dict[str, tuple[int, int]], expected_created_indices: list[str], @@ -274,7 +274,7 @@ async def test_data_migrator_logic( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migration_changes_prevent_trying_to_migrate_again( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test that we do not try to migrate when migration_changes indicate its already migrated. diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index c3ff5027b70..e5eea0cf89f 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -45,7 +45,7 @@ from .common import ( convert_pending_states_to_meta, ) -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager TEST_EVENT_TYPES = ( "EVENT_TEST_AUTOPURGE", @@ -59,7 +59,7 @@ TEST_EVENT_TYPES = ( @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index d68d1550268..45bef68dabd 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -47,12 +47,12 @@ from .db_schema_32 import ( StatisticsShortTerm, ) -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 6b1e1a655db..2baf7f2bcbc 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -54,12 +54,12 @@ from .common import ( ) from tests.common import MockPlatform, mock_platform -from tests.typing import RecorderInstanceGenerator, WebSocketGenerator +from tests.typing import RecorderInstanceContextManager, WebSocketGenerator @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/recorder/test_statistics_v23_migration.py b/tests/components/recorder/test_statistics_v23_migration.py index 1f9be0cabee..dafa4da81ee 100644 --- a/tests/components/recorder/test_statistics_v23_migration.py +++ b/tests/components/recorder/test_statistics_v23_migration.py @@ -27,7 +27,7 @@ from .common import ( ) from tests.common import async_test_home_assistant -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager SCHEMA_VERSION_POSTFIX = "23_with_newer_columns" SCHEMA_MODULE = get_schema_module_path(SCHEMA_VERSION_POSTFIX) @@ -37,7 +37,8 @@ SCHEMA_MODULE = get_schema_module_path(SCHEMA_VERSION_POSTFIX) @pytest.mark.usefixtures("skip_by_db_engine") @pytest.mark.parametrize("persistent_database", [True]) async def test_delete_duplicates( - async_test_recorder: RecorderInstanceGenerator, caplog: pytest.LogCaptureFixture + async_test_recorder: RecorderInstanceContextManager, + caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of duplicated statistics. @@ -224,7 +225,8 @@ async def test_delete_duplicates( @pytest.mark.usefixtures("skip_by_db_engine") @pytest.mark.parametrize("persistent_database", [True]) async def test_delete_duplicates_many( - async_test_recorder: RecorderInstanceGenerator, caplog: pytest.LogCaptureFixture + async_test_recorder: RecorderInstanceContextManager, + caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of duplicated statistics. @@ -418,7 +420,7 @@ async def test_delete_duplicates_many( @pytest.mark.usefixtures("skip_by_db_engine") @pytest.mark.parametrize("persistent_database", [True]) async def test_delete_duplicates_non_identical( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, tmp_path: Path, ) -> None: @@ -613,7 +615,7 @@ async def test_delete_duplicates_non_identical( @pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) @pytest.mark.usefixtures("skip_by_db_engine") async def test_delete_duplicates_short_term( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, tmp_path: Path, ) -> None: diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 4e6d664ec0a..c9020762d4b 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -55,12 +55,12 @@ from .common import ( ) from tests.common import async_test_home_assistant -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager, RecorderInstanceGenerator @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 21f7037c370..58be23bdc85 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -23,7 +23,7 @@ from .common import async_wait_recording_done from .conftest import instrument_migration from tests.common import async_test_home_assistant -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager CREATE_ENGINE_TARGET = "homeassistant.components.recorder.core.create_engine" SCHEMA_MODULE_30 = "tests.components.recorder.db_schema_30" @@ -73,7 +73,7 @@ def _create_engine_test( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_times( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, ) -> None: """Test we can migrate times in the events and states tables. @@ -240,7 +240,7 @@ async def test_migrate_times( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_can_resume_entity_id_post_migration( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, recorder_db_url: str, ) -> None: @@ -351,7 +351,7 @@ async def test_migrate_can_resume_entity_id_post_migration( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_can_resume_ix_states_event_id_removed( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, recorder_db_url: str, ) -> None: @@ -490,7 +490,7 @@ async def test_migrate_can_resume_ix_states_event_id_removed( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_out_of_disk_space_while_rebuild_states_table( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, recorder_db_url: str, ) -> None: @@ -670,7 +670,7 @@ async def test_out_of_disk_space_while_rebuild_states_table( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_out_of_disk_space_while_removing_foreign_key( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, recorder_db_url: str, ) -> None: diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 403384aee9f..94ed8da1b92 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -41,7 +41,11 @@ from .common import ( from .conftest import InstrumentedMigration from tests.common import async_fire_time_changed -from tests.typing import RecorderInstanceGenerator, WebSocketGenerator +from tests.typing import ( + RecorderInstanceContextManager, + RecorderInstanceGenerator, + WebSocketGenerator, +) @pytest.fixture @@ -2623,7 +2627,7 @@ async def test_recorder_info_no_instance( async def test_recorder_info_migration_queue_exhausted( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, instrument_migration: InstrumentedMigration, ) -> None: """Test getting recorder status when recorder queue is exhausted.""" diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index d011926848d..fcf5a711c46 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -57,7 +57,7 @@ from tests.components.recorder.common import ( ) from tests.typing import ( MockHAClientWebSocket, - RecorderInstanceGenerator, + RecorderInstanceContextManager, WebSocketGenerator, ) @@ -102,7 +102,7 @@ KW_SENSOR_ATTRIBUTES = { @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder patches.""" diff --git a/tests/components/sensor/test_recorder_missing_stats.py b/tests/components/sensor/test_recorder_missing_stats.py index 43e18b89e72..449ffd55727 100644 --- a/tests/components/sensor/test_recorder_missing_stats.py +++ b/tests/components/sensor/test_recorder_missing_stats.py @@ -24,7 +24,7 @@ from tests.components.recorder.common import ( async_wait_recording_done, do_adhoc_statistics, ) -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager POWER_SENSOR_ATTRIBUTES = { "device_class": "energy", @@ -47,7 +47,7 @@ def disable_db_issue_creation(): @pytest.mark.parametrize("enable_missing_statistics", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_compile_missing_statistics( - async_test_recorder: RecorderInstanceGenerator, freezer: FrozenDateTimeFactory + async_test_recorder: RecorderInstanceContextManager, freezer: FrozenDateTimeFactory ) -> None: """Test compile missing statistics.""" three_days_ago = datetime(2021, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) diff --git a/tests/conftest.py b/tests/conftest.py index a64543337b9..de627925941 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -102,6 +102,7 @@ from .typing import ( MqttMockHAClient, MqttMockHAClientGenerator, MqttMockPahoClient, + RecorderInstanceContextManager, RecorderInstanceGenerator, WebSocketGenerator, ) @@ -1536,7 +1537,7 @@ async def async_test_recorder( enable_migrate_event_type_ids: bool, enable_migrate_entity_ids: bool, enable_migrate_event_ids: bool, -) -> AsyncGenerator[RecorderInstanceGenerator]: +) -> AsyncGenerator[RecorderInstanceContextManager]: """Yield context manager to setup recorder instance.""" # pylint: disable-next=import-outside-toplevel from homeassistant.components import recorder @@ -1702,7 +1703,7 @@ async def async_test_recorder( @pytest.fixture async def async_setup_recorder_instance( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> AsyncGenerator[RecorderInstanceGenerator]: """Yield callable to setup recorder instance.""" @@ -1715,7 +1716,7 @@ async def async_setup_recorder_instance( expected_setup_result: bool = True, wait_recorder: bool = True, wait_recorder_setup: bool = True, - ) -> AsyncGenerator[recorder.Recorder]: + ) -> recorder.Recorder: """Set up and return recorder instance.""" return await stack.enter_async_context( @@ -1734,7 +1735,7 @@ async def async_setup_recorder_instance( @pytest.fixture async def recorder_mock( recorder_config: dict[str, Any] | None, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, hass: HomeAssistant, ) -> AsyncGenerator[recorder.Recorder]: """Fixture with in-memory recorder.""" diff --git a/tests/typing.py b/tests/typing.py index 7b61949a9c4..5bcb1a01104 100644 --- a/tests/typing.py +++ b/tests/typing.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine +from contextlib import AbstractAsyncContextManager from typing import TYPE_CHECKING, Any from unittest.mock import MagicMock @@ -30,6 +31,10 @@ type MqttMockHAClient = MagicMock """MagicMock for `homeassistant.components.mqtt.MQTT`.""" type MqttMockHAClientGenerator = Callable[..., Coroutine[Any, Any, MqttMockHAClient]] """MagicMock generator for `homeassistant.components.mqtt.MQTT`.""" +type RecorderInstanceContextManager = Callable[ + ..., AbstractAsyncContextManager[Recorder] +] +"""ContextManager for `homeassistant.components.recorder.Recorder`.""" type RecorderInstanceGenerator = Callable[..., Coroutine[Any, Any, Recorder]] """Instance generator for `homeassistant.components.recorder.Recorder`.""" type WebSocketGenerator = Callable[..., Coroutine[Any, Any, MockHAClientWebSocket]] From a511610f245781a89bcfbc5decf3ff342c6956f2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jan 2025 07:53:32 +0100 Subject: [PATCH 0798/2987] Bump github/codeql-action from 3.28.1 to 3.28.2 (#136225) --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7c9a076de64..e95e2b58448 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.1 + uses: github/codeql-action/init@v3.28.2 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.1 + uses: github/codeql-action/analyze@v3.28.2 with: category: "/language:python" From b8632063f5d86931393abdc9840a41831a211470 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 22 Jan 2025 07:55:55 +0100 Subject: [PATCH 0799/2987] Add dhcp discovery to incomfort integration (#136027) * Add dhcp discovery to incomfort integration * Remove duplicate code * Ensure confirmation when discovered via DHCP * Validate hostname is not changed * Fix test * Create gateway device with unique_id * Add tests for assertion on via device * Add registered devices to allow dhcp updates * Migrate existing entry with host match * Always load gatewate device an check if exising entry is loaded * Make isolated flow step for dhcp auth * Suggestions from code review --- .../components/incomfort/__init__.py | 15 +- homeassistant/components/incomfort/climate.py | 2 + .../components/incomfort/config_flow.py | 78 +++++++++ .../components/incomfort/coordinator.py | 5 +- homeassistant/components/incomfort/entity.py | 2 + .../components/incomfort/manifest.json | 4 + .../components/incomfort/strings.json | 16 ++ homeassistant/generated/dhcp.py | 9 ++ tests/components/incomfort/conftest.py | 5 + .../components/incomfort/test_config_flow.py | 151 +++++++++++++++++- 10 files changed, 283 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index e6775f5baca..5a57f9f4198 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -9,7 +9,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr +from .const import DOMAIN from .coordinator import InComfortDataCoordinator, async_connect_gateway from .errors import InConfortTimeout, InConfortUnknownError, NoHeaters, NotFound @@ -43,7 +45,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> except TimeoutError as exc: raise InConfortTimeout from exc - coordinator = InComfortDataCoordinator(hass, data) + # Register discovered gateway device + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.entry_id)}, + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + if entry.unique_id is not None + else set(), + manufacturer="Intergas", + name="RFGateway", + ) + coordinator = InComfortDataCoordinator(hass, data, entry.entry_id) entry.runtime_data = coordinator await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index 756e14fc545..32fec3951ae 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -73,6 +73,8 @@ class InComfortClimate(IncomfortEntity, ClimateEntity): manufacturer="Intergas", name=f"Thermostat {room.room_no}", ) + if coordinator.unique_id: + self._attr_device_info["via_device"] = (DOMAIN, coordinator.unique_id) @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py index 3db8e40f9f4..47db9b701bf 100644 --- a/homeassistant/components/incomfort/config_flow.py +++ b/homeassistant/components/incomfort/config_flow.py @@ -12,12 +12,15 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigEntry, + ConfigEntryState, ConfigFlow, ConfigFlowResult, OptionsFlow, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.selector import ( BooleanSelector, BooleanSelectorConfig, @@ -25,6 +28,7 @@ from homeassistant.helpers.selector import ( TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN from .coordinator import async_connect_gateway @@ -45,6 +49,17 @@ CONFIG_SCHEMA = vol.Schema( } ) +DHCP_CONFIG_SCHEMA = vol.Schema( + { + vol.Optional(CONF_USERNAME): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT, autocomplete="admin") + ), + vol.Optional(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } +) + REAUTH_SCHEMA = vol.Schema( { vol.Optional(CONF_PASSWORD): TextSelector( @@ -94,6 +109,8 @@ async def async_try_connect_gateway( class InComfortConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow to set up an Intergas InComfort boyler and thermostats.""" + _discovered_host: str + @staticmethod @callback def async_get_options_flow( @@ -102,6 +119,67 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return InComfortOptionsFlowHandler() + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Prepare configuration for a DHCP discovered Intergas Gateway device.""" + self._discovered_host = discovery_info.ip + # In case we have an existing entry with the same host + # we update the entry with the unique_id for the gateway, and abort the flow + unique_id = format_mac(discovery_info.macaddress) + existing_entries_without_unique_id = [ + entry + for entry in self._async_current_entries(include_ignore=False) + if entry.unique_id is None + and entry.data.get(CONF_HOST) == self._discovered_host + and entry.state is ConfigEntryState.LOADED + ] + if existing_entries_without_unique_id: + self.hass.config_entries.async_update_entry( + existing_entries_without_unique_id[0], unique_id=unique_id + ) + self.hass.config_entries.async_schedule_reload( + existing_entries_without_unique_id[0].entry_id + ) + raise AbortFlow("already_configured") + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured(updates={CONF_HOST: self._discovered_host}) + + return await self.async_step_dhcp_confirm() + + async def async_step_dhcp_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm setup from discovery.""" + if user_input is not None: + return await self.async_step_dhcp_auth({CONF_HOST: self._discovered_host}) + return self.async_show_form( + step_id="dhcp_confirm", + description_placeholders={CONF_HOST: self._discovered_host}, + ) + + async def async_step_dhcp_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial set up via DHCP.""" + errors: dict[str, str] | None = None + data_schema: vol.Schema = DHCP_CONFIG_SCHEMA + if user_input is not None: + user_input[CONF_HOST] = self._discovered_host + if ( + errors := await async_try_connect_gateway(self.hass, user_input) + ) is None: + return self.async_create_entry(title=TITLE, data=user_input) + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) + + return self.async_show_form( + step_id="dhcp_auth", + data_schema=data_schema, + errors=errors, + description_placeholders={CONF_HOST: self._discovered_host}, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/incomfort/coordinator.py b/homeassistant/components/incomfort/coordinator.py index 20cc8e7cc69..d1370f613ad 100644 --- a/homeassistant/components/incomfort/coordinator.py +++ b/homeassistant/components/incomfort/coordinator.py @@ -50,8 +50,11 @@ async def async_connect_gateway( class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]): """Data coordinator for InComfort entities.""" - def __init__(self, hass: HomeAssistant, incomfort_data: InComfortData) -> None: + def __init__( + self, hass: HomeAssistant, incomfort_data: InComfortData, unique_id: str | None + ) -> None: """Initialize coordinator.""" + self.unique_id = unique_id super().__init__( hass, _LOGGER, diff --git a/homeassistant/components/incomfort/entity.py b/homeassistant/components/incomfort/entity.py index dd662b411dd..1924c91376b 100644 --- a/homeassistant/components/incomfort/entity.py +++ b/homeassistant/components/incomfort/entity.py @@ -28,3 +28,5 @@ class IncomfortBoilerEntity(IncomfortEntity): name="Boiler", serial_number=heater.serial_no, ) + if coordinator.unique_id: + self._attr_device_info["via_device"] = (DOMAIN, coordinator.unique_id) diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index f404f33b970..65d781b1189 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -3,6 +3,10 @@ "name": "Intergas InComfort/Intouch Lan2RF gateway", "codeowners": ["@jbouwh"], "config_flow": true, + "dhcp": [ + { "hostname": "rfgateway", "macaddress": "0004A3*" }, + { "registered_devices": true } + ], "documentation": "https://www.home-assistant.io/integrations/incomfort", "iot_class": "local_polling", "loggers": ["incomfortclient"], diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 8bcfa4ce5e1..a59dc71d87f 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -14,6 +14,22 @@ "password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices." } }, + "dhcp_auth": { + "title": "Set up Intergas InComfort Lan2RF Gateway", + "description": "Please enter authentication details for gateway {host}", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "The username to log into the gateway. This is `admin` in most cases.", + "password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices." + } + }, + "dhcp_confirm": { + "title": "Set up Intergas InComfort Lan2RF Gateway", + "description": "Do you want to set up the discovered Intergas InComfort Lan2RF Gateway ({host})?" + }, "reauth_confirm": { "data": { "password": "[%key:common::config_flow::data::password%]" diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 5fef087a868..7d14ab0f444 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -253,6 +253,15 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "hunter*", "macaddress": "002674*", }, + { + "domain": "incomfort", + "hostname": "rfgateway", + "macaddress": "0004A3*", + }, + { + "domain": "incomfort", + "registered_devices": True, + }, { "domain": "insteon", "macaddress": "000EF3*", diff --git a/tests/components/incomfort/conftest.py b/tests/components/incomfort/conftest.py index 3829c42d07f..aacfa886f52 100644 --- a/tests/components/incomfort/conftest.py +++ b/tests/components/incomfort/conftest.py @@ -18,6 +18,11 @@ MOCK_CONFIG = { "password": "verysecret", } +MOCK_CONFIG_DHCP = { + "username": "admin", + "password": "verysecret", +} + MOCK_HEATER_STATUS = { "display_code": DisplayCode.STANDBY, "display_text": "standby", diff --git a/tests/components/incomfort/test_config_flow.py b/tests/components/incomfort/test_config_flow.py index 9ab5a672d61..e102595657f 100644 --- a/tests/components/incomfort/test_config_flow.py +++ b/tests/components/incomfort/test_config_flow.py @@ -8,15 +8,29 @@ from incomfortclient import IncomfortError, InvalidHeaterList import pytest from homeassistant.components.incomfort.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .conftest import MOCK_CONFIG +from .conftest import MOCK_CONFIG, MOCK_CONFIG_DHCP from tests.common import MockConfigEntry +DHCP_SERVICE_INFO = DhcpServiceInfo( + hostname="rfgateway", + ip="192.168.1.12", + macaddress="0004A3DEADFF", +) + +DHCP_SERVICE_INFO_ALT = DhcpServiceInfo( + hostname="rfgateway", + ip="192.168.1.99", + macaddress="0004A3DEADFF", +) + async def test_form( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_incomfort: MagicMock @@ -118,6 +132,139 @@ async def test_form_validation( assert "errors" not in result +async def test_dhcp_flow_simple( + hass: HomeAssistant, + mock_incomfort: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test dhcp flow for older gateway without authentication needed. + + Assert on the creation of the gateway device, climate and boiler devices. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "dhcp_confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {"host": "192.168.1.12"} + + config_entry: ConfigEntry = result["result"] + entry_id = config_entry.entry_id + + await hass.async_block_till_done(wait_background_tasks=True) + + # Check the gateway device is discovered + gateway_device = device_registry.async_get_device(identifiers={(DOMAIN, entry_id)}) + assert gateway_device is not None + assert gateway_device.name == "RFGateway" + assert gateway_device.manufacturer == "Intergas" + assert gateway_device.connections == {("mac", "00:04:a3:de:ad:ff")} + + devices = device_registry.devices.get_devices_for_config_entry_id(entry_id) + assert len(devices) == 3 + boiler_device = device_registry.async_get_device( + identifiers={(DOMAIN, "c0ffeec0ffee")} + ) + assert boiler_device.via_device_id == gateway_device.id + assert boiler_device is not None + climate_device = device_registry.async_get_device( + identifiers={(DOMAIN, "c0ffeec0ffee_1")} + ) + assert climate_device is not None + assert climate_device.via_device_id == gateway_device.id + + # Check the host is dynamically updated + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO_ALT + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] == DHCP_SERVICE_INFO_ALT.ip + + +async def test_dhcp_flow_migrates_existing_entry_without_unique_id( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test dhcp flow migrates an existing entry without unique_id.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Check the gateway device is discovered after a reload + # And has updated connections + gateway_device = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert gateway_device is not None + assert gateway_device.name == "RFGateway" + assert gateway_device.manufacturer == "Intergas" + assert gateway_device.connections == {("mac", "00:04:a3:de:ad:ff")} + + devices = device_registry.devices.get_devices_for_config_entry_id( + mock_config_entry.entry_id + ) + assert len(devices) == 3 + boiler_device = device_registry.async_get_device( + identifiers={(DOMAIN, "c0ffeec0ffee")} + ) + assert boiler_device.via_device_id == gateway_device.id + assert boiler_device is not None + climate_device = device_registry.async_get_device( + identifiers={(DOMAIN, "c0ffeec0ffee_1")} + ) + assert climate_device is not None + assert climate_device.via_device_id == gateway_device.id + + +async def test_dhcp_flow_wih_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_incomfort: MagicMock +) -> None: + """Test dhcp flow for with authentication.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "dhcp_confirm" + + # Try again, but now with the correct host, but still with an auth error + with patch.object( + mock_incomfort(), + "heaters", + side_effect=IncomfortError(ClientResponseError(None, None, status=401)), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.12"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "dhcp_auth" + assert result["errors"] == {CONF_PASSWORD: "auth_error"} + + # Submit the form with added credentials + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_CONFIG_DHCP + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Intergas InComfort/Intouch Lan2RF gateway" + assert result["data"] == MOCK_CONFIG + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_reauth_flow_success( hass: HomeAssistant, mock_incomfort: MagicMock, From 03be8a039cfe1e6957bb24ad0ff2cb88cca200e3 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Wed, 22 Jan 2025 09:54:45 +0100 Subject: [PATCH 0800/2987] Use icon translations for enphase_envoy. (#136190) --- .../components/enphase_envoy/binary_sensor.py | 1 - .../components/enphase_envoy/icons.json | 58 + .../enphase_envoy/quality_scale.yaml | 2 +- .../components/enphase_envoy/sensor.py | 4 - .../components/enphase_envoy/switch.py | 1 + .../snapshots/test_binary_sensor.ambr | 3 +- .../snapshots/test_diagnostics.ambr | 51 +- .../enphase_envoy/snapshots/test_sensor.ambr | 1350 ++++++----------- .../enphase_envoy/snapshots/test_switch.ambr | 6 +- 9 files changed, 532 insertions(+), 944 deletions(-) create mode 100644 homeassistant/components/enphase_envoy/icons.json diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py index 1ad6f259de1..0258281661a 100644 --- a/homeassistant/components/enphase_envoy/binary_sensor.py +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -67,7 +67,6 @@ ENPOWER_SENSORS = ( EnvoyEnpowerBinarySensorEntityDescription( key="mains_oper_state", translation_key="grid_status", - icon="mdi:transmission-tower", value_fn=lambda enpower: enpower.mains_oper_state == "closed", ), ) diff --git a/homeassistant/components/enphase_envoy/icons.json b/homeassistant/components/enphase_envoy/icons.json new file mode 100644 index 00000000000..21262d1dc89 --- /dev/null +++ b/homeassistant/components/enphase_envoy/icons.json @@ -0,0 +1,58 @@ +{ + "entity": { + "binary_sensor": { + "grid_status": { + "default": "mdi:transmission-tower", + "state": { + "off": "mdi:transmission-tower-off" + } + } + }, + "sensor": { + "current_power_production": { + "default": "mdi:solar-power" + }, + "daily_production": { + "default": "mdi:solar-power" + }, + "seven_days_production": { + "default": "mdi:solar-power" + }, + "lifetime_production": { + "default": "mdi:solar-power" + }, + "current_power_production_phase": { + "default": "mdi:solar-power" + }, + "daily_production_phase": { + "default": "mdi:solar-power" + }, + "seven_days_production_phase": { + "default": "mdi:solar-power" + }, + "lifetime_production_phase": { + "default": "mdi:solar-power" + }, + "max_capacity": { + "default": "mdi:battery-charging-100" + }, + "available_energy": { + "default": "mdi:battery-50" + } + }, + "switch": { + "grid_enabled": { + "default": "mdi:transmission-tower", + "state": { + "off": "mdi:transmission-tower-off" + } + }, + "relay_status": { + "default": "mdi:electric-switch-closed", + "state": { + "off": "mdi:electric-switch" + } + } + } + } +} diff --git a/homeassistant/components/enphase_envoy/quality_scale.yaml b/homeassistant/components/enphase_envoy/quality_scale.yaml index 6100c91fbb4..127b609784b 100644 --- a/homeassistant/components/enphase_envoy/quality_scale.yaml +++ b/homeassistant/components/enphase_envoy/quality_scale.yaml @@ -81,7 +81,7 @@ rules: entity-disabled-by-default: done entity-translations: done exception-translations: done - icon-translations: todo + icon-translations: done reconfiguration-flow: done repair-issues: status: exempt diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 62ae5b621ac..a7b98f9b15c 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -55,7 +55,6 @@ from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator from .entity import EnvoyBaseEntity -ICON = "mdi:flash" _LOGGER = logging.getLogger(__name__) INVERTERS_KEY = "inverters" @@ -946,8 +945,6 @@ class EnvoySensorBaseEntity(EnvoyBaseEntity, SensorEntity): class EnvoySystemSensorEntity(EnvoySensorBaseEntity): """Envoy system base entity.""" - _attr_icon = ICON - def __init__( self, coordinator: EnphaseUpdateCoordinator, @@ -1174,7 +1171,6 @@ class EnvoyStorageCTPhaseEntity(EnvoySystemSensorEntity): class EnvoyInverterEntity(EnvoySensorBaseEntity): """Envoy inverter entity.""" - _attr_icon = ICON entity_description: EnvoyInverterSensorEntityDescription def __init__( diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index 5170b694587..7074f341cc8 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -60,6 +60,7 @@ ENPOWER_GRID_SWITCH = EnvoyEnpowerSwitchEntityDescription( RELAY_STATE_SWITCH = EnvoyDryContactSwitchEntityDescription( key="relay_status", + translation_key="relay_status", value_fn=lambda dry_contact: dry_contact.status == DryContactStatus.CLOSED, turn_on_fn=lambda envoy, id: envoy.close_dry_contact(id), turn_off_fn=lambda envoy, id: envoy.open_dry_contact(id), diff --git a/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr index f936a9db76e..e9bf8378d79 100644 --- a/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr @@ -255,7 +255,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:transmission-tower', + 'original_icon': None, 'original_name': 'Grid status', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -269,7 +269,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Enpower 654321 Grid status', - 'icon': 'mdi:transmission-tower', }), 'context': , 'entity_id': 'binary_sensor.enpower_654321_grid_status', diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 76835098f27..4254ffe961a 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -88,7 +88,7 @@ }), }), 'original_device_class': 'power', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -101,7 +101,6 @@ 'attributes': dict({ 'device_class': 'power', 'friendly_name': 'Envoy <> Current power production', - 'icon': 'mdi:flash', 'state_class': 'measurement', 'unit_of_measurement': 'kW', }), @@ -140,7 +139,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -153,7 +152,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Energy production today', - 'icon': 'mdi:flash', 'state_class': 'total_increasing', 'unit_of_measurement': 'kWh', }), @@ -190,7 +188,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -203,7 +201,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': 'kWh', }), 'entity_id': 'sensor.envoy_<>_energy_production_last_seven_days', @@ -241,7 +238,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -254,7 +251,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': 'total_increasing', 'unit_of_measurement': 'MWh', }), @@ -321,7 +317,7 @@ 'options': dict({ }), 'original_device_class': 'power', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -334,7 +330,6 @@ 'attributes': dict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': 'measurement', 'unit_of_measurement': 'W', }), @@ -365,7 +360,7 @@ 'options': dict({ }), 'original_device_class': 'timestamp', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -522,7 +517,7 @@ }), }), 'original_device_class': 'power', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -535,7 +530,6 @@ 'attributes': dict({ 'device_class': 'power', 'friendly_name': 'Envoy <> Current power production', - 'icon': 'mdi:flash', 'state_class': 'measurement', 'unit_of_measurement': 'kW', }), @@ -574,7 +568,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -587,7 +581,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Energy production today', - 'icon': 'mdi:flash', 'state_class': 'total_increasing', 'unit_of_measurement': 'kWh', }), @@ -624,7 +617,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -637,7 +630,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': 'kWh', }), 'entity_id': 'sensor.envoy_<>_energy_production_last_seven_days', @@ -675,7 +667,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -688,7 +680,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': 'total_increasing', 'unit_of_measurement': 'MWh', }), @@ -755,7 +746,7 @@ 'options': dict({ }), 'original_device_class': 'power', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -768,7 +759,6 @@ 'attributes': dict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': 'measurement', 'unit_of_measurement': 'W', }), @@ -799,7 +789,7 @@ 'options': dict({ }), 'original_device_class': 'timestamp', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -996,7 +986,7 @@ }), }), 'original_device_class': 'power', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1009,7 +999,6 @@ 'attributes': dict({ 'device_class': 'power', 'friendly_name': 'Envoy <> Current power production', - 'icon': 'mdi:flash', 'state_class': 'measurement', 'unit_of_measurement': 'kW', }), @@ -1048,7 +1037,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1061,7 +1050,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Energy production today', - 'icon': 'mdi:flash', 'state_class': 'total_increasing', 'unit_of_measurement': 'kWh', }), @@ -1098,7 +1086,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1111,7 +1099,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': 'kWh', }), 'entity_id': 'sensor.envoy_<>_energy_production_last_seven_days', @@ -1149,7 +1136,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1162,7 +1149,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': 'total_increasing', 'unit_of_measurement': 'MWh', }), @@ -1229,7 +1215,7 @@ 'options': dict({ }), 'original_device_class': 'power', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1242,7 +1228,6 @@ 'attributes': dict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': 'measurement', 'unit_of_measurement': 'W', }), @@ -1273,7 +1258,7 @@ 'options': dict({ }), 'original_device_class': 'timestamp', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index d6a523a3e15..c11bff1697c 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -30,7 +30,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -45,7 +45,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -86,7 +85,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -101,7 +100,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -143,7 +141,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -158,7 +156,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -201,7 +198,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -216,7 +213,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -253,7 +249,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -268,7 +264,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -303,7 +298,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -318,7 +313,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', @@ -359,7 +353,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -374,7 +368,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -417,7 +410,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -432,7 +425,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -475,7 +467,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -490,7 +482,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -533,7 +524,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -548,7 +539,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -589,7 +579,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -604,7 +594,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -646,7 +635,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -661,7 +650,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -702,7 +690,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -717,7 +705,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -759,7 +746,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -774,7 +761,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -814,7 +800,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -829,7 +815,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -869,7 +854,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -884,7 +869,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -927,7 +911,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -942,7 +926,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -985,7 +968,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1000,7 +983,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1043,7 +1025,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1058,7 +1040,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1101,7 +1082,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1116,7 +1097,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1159,7 +1139,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1174,7 +1154,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1209,7 +1188,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1223,7 +1202,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', @@ -1256,7 +1234,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1270,7 +1248,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', @@ -1309,7 +1286,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1324,7 +1301,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -1368,7 +1344,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1383,7 +1359,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -1429,7 +1404,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1444,7 +1419,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1484,7 +1458,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1499,7 +1473,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -1538,7 +1511,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'powerfactor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1553,7 +1526,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 powerfactor production CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -1595,7 +1567,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1610,7 +1582,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1653,7 +1624,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1668,7 +1639,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1711,7 +1681,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1726,7 +1696,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1763,7 +1732,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1778,7 +1747,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1813,7 +1781,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1828,7 +1796,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', @@ -2256,7 +2223,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Aggregated available battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2271,7 +2238,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy_storage', 'friendly_name': 'Envoy 1234 Aggregated available battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -2305,7 +2271,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Aggregated Battery capacity', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2320,7 +2286,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy_storage', 'friendly_name': 'Envoy 1234 Aggregated Battery capacity', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -2354,7 +2319,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Aggregated battery soc', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2369,7 +2334,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Envoy 1234 Aggregated battery soc', - 'icon': 'mdi:flash', 'unit_of_measurement': '%', }), 'context': , @@ -2403,7 +2367,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Available ACB battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2418,7 +2382,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy_storage', 'friendly_name': 'Envoy 1234 Available ACB battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -2452,7 +2415,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Available battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2467,7 +2430,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Available battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -2509,7 +2471,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2524,7 +2486,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2559,7 +2520,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Battery', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2574,7 +2535,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Envoy 1234 Battery', - 'icon': 'mdi:flash', 'unit_of_measurement': '%', }), 'context': , @@ -2608,7 +2568,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Battery capacity', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2623,7 +2583,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Battery capacity', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -2665,7 +2624,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2680,7 +2639,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2723,7 +2681,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2738,7 +2696,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2781,7 +2738,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2796,7 +2753,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2839,7 +2795,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2854,7 +2810,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2897,7 +2852,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2912,7 +2867,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2955,7 +2909,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2970,7 +2924,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3011,7 +2964,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3026,7 +2979,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -3068,7 +3020,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3083,7 +3035,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3124,7 +3075,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3139,7 +3090,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -3181,7 +3131,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3196,7 +3146,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3236,7 +3185,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3251,7 +3200,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3291,7 +3239,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3306,7 +3254,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3346,7 +3293,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3361,7 +3308,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3401,7 +3347,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3416,7 +3362,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3456,7 +3401,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3471,7 +3416,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3511,7 +3455,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3526,7 +3470,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3566,7 +3509,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3581,7 +3524,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3621,7 +3563,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3636,7 +3578,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3679,7 +3620,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3694,7 +3635,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3737,7 +3677,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3752,7 +3692,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3795,7 +3734,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3810,7 +3749,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3853,7 +3791,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3868,7 +3806,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3911,7 +3848,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3926,7 +3863,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3969,7 +3905,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3984,7 +3920,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -4027,7 +3962,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4042,7 +3977,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -4085,7 +4019,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4100,7 +4034,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -4143,7 +4076,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4158,7 +4091,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -4201,7 +4133,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4216,7 +4148,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -4259,7 +4190,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4274,7 +4205,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -4309,7 +4239,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4323,7 +4253,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', @@ -4356,7 +4285,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4370,7 +4299,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', @@ -4403,7 +4331,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4417,7 +4345,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', @@ -4450,7 +4377,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4464,7 +4391,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', @@ -4497,7 +4423,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4511,7 +4437,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', @@ -4544,7 +4469,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4558,7 +4483,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', @@ -4591,7 +4515,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4605,7 +4529,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', @@ -4638,7 +4561,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4652,7 +4575,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', @@ -4691,7 +4613,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4706,7 +4628,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -4750,7 +4671,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4765,7 +4686,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -4809,7 +4729,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4824,7 +4744,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -4868,7 +4787,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4883,7 +4802,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -4927,7 +4845,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4942,7 +4860,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -4986,7 +4903,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5001,7 +4918,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -5045,7 +4961,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5060,7 +4976,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -5104,7 +5019,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5119,7 +5034,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -5165,7 +5079,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5180,7 +5094,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5223,7 +5136,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5238,7 +5151,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5281,7 +5193,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5296,7 +5208,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5339,7 +5250,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5354,7 +5265,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5394,7 +5304,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5409,7 +5319,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5448,7 +5357,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5463,7 +5372,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5502,7 +5410,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5517,7 +5425,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5556,7 +5463,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5571,7 +5478,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5610,7 +5516,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'powerfactor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5625,7 +5531,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 powerfactor production CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5664,7 +5569,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5679,7 +5584,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5718,7 +5622,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5733,7 +5637,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5772,7 +5675,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5787,7 +5690,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5829,7 +5731,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5844,7 +5746,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5887,7 +5788,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5902,7 +5803,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5945,7 +5845,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5960,7 +5860,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6003,7 +5902,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6018,7 +5917,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6053,7 +5951,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Reserve battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6068,7 +5966,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Reserve battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -6102,7 +5999,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Reserve battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6117,7 +6014,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Envoy 1234 Reserve battery level', - 'icon': 'mdi:flash', 'unit_of_measurement': '%', }), 'context': , @@ -6159,7 +6055,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6174,7 +6070,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6217,7 +6112,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6232,7 +6127,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6275,7 +6169,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6290,7 +6184,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6333,7 +6226,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6348,7 +6241,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6391,7 +6283,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6406,7 +6298,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6449,7 +6340,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6464,7 +6355,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6507,7 +6397,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6522,7 +6412,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6565,7 +6454,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6580,7 +6469,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6617,7 +6505,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6632,7 +6520,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6667,7 +6554,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6682,7 +6569,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', @@ -6954,7 +6840,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Available battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6969,7 +6855,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Available battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -7011,7 +6896,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7026,7 +6911,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7061,7 +6945,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Battery', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7076,7 +6960,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Envoy 1234 Battery', - 'icon': 'mdi:flash', 'unit_of_measurement': '%', }), 'context': , @@ -7110,7 +6993,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Battery capacity', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7125,7 +7008,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Battery capacity', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -7167,7 +7049,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7182,7 +7064,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7225,7 +7106,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7240,7 +7121,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7283,7 +7163,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7298,7 +7178,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7341,7 +7220,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7356,7 +7235,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7399,7 +7277,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7414,7 +7292,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7457,7 +7334,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7472,7 +7349,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7513,7 +7389,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7528,7 +7404,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -7570,7 +7445,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7585,7 +7460,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7626,7 +7500,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7641,7 +7515,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -7683,7 +7556,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7698,7 +7571,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7738,7 +7610,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7753,7 +7625,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7793,7 +7664,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7808,7 +7679,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7848,7 +7718,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7863,7 +7733,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7903,7 +7772,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7918,7 +7787,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7958,7 +7826,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7973,7 +7841,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8013,7 +7880,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8028,7 +7895,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8068,7 +7934,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8083,7 +7949,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8123,7 +7988,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8138,7 +8003,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8181,7 +8045,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8196,7 +8060,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8239,7 +8102,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8254,7 +8117,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8297,7 +8159,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8312,7 +8174,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8355,7 +8216,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8370,7 +8231,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8413,7 +8273,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8428,7 +8288,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8471,7 +8330,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8486,7 +8345,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8529,7 +8387,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8544,7 +8402,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8587,7 +8444,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8602,7 +8459,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8645,7 +8501,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8660,7 +8516,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8703,7 +8558,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8718,7 +8573,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8761,7 +8615,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8776,7 +8630,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8811,7 +8664,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8825,7 +8678,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', @@ -8858,7 +8710,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8872,7 +8724,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', @@ -8905,7 +8756,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8919,7 +8770,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', @@ -8952,7 +8802,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8966,7 +8816,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', @@ -8999,7 +8848,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9013,7 +8862,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', @@ -9046,7 +8894,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9060,7 +8908,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', @@ -9093,7 +8940,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9107,7 +8954,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', @@ -9140,7 +8986,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9154,7 +9000,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', @@ -9193,7 +9038,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9208,7 +9053,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -9252,7 +9096,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9267,7 +9111,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -9311,7 +9154,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9326,7 +9169,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -9370,7 +9212,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9385,7 +9227,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -9429,7 +9270,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9444,7 +9285,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -9488,7 +9328,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9503,7 +9343,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -9547,7 +9386,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9562,7 +9401,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -9606,7 +9444,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9621,7 +9459,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -9667,7 +9504,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9682,7 +9519,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9725,7 +9561,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9740,7 +9576,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9783,7 +9618,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9798,7 +9633,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9841,7 +9675,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9856,7 +9690,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9896,7 +9729,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9911,7 +9744,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -9950,7 +9782,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9965,7 +9797,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -10004,7 +9835,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10019,7 +9850,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -10058,7 +9888,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10073,7 +9903,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -10112,7 +9941,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'powerfactor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10127,7 +9956,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 powerfactor production CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -10166,7 +9994,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10181,7 +10009,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -10220,7 +10047,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10235,7 +10062,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -10274,7 +10100,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10289,7 +10115,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -10331,7 +10156,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10346,7 +10171,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10389,7 +10213,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10404,7 +10228,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10447,7 +10270,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10462,7 +10285,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10505,7 +10327,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10520,7 +10342,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10555,7 +10376,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Reserve battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10570,7 +10391,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Reserve battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -10604,7 +10424,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Reserve battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10619,7 +10439,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Envoy 1234 Reserve battery level', - 'icon': 'mdi:flash', 'unit_of_measurement': '%', }), 'context': , @@ -10661,7 +10480,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10676,7 +10495,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10719,7 +10537,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10734,7 +10552,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10777,7 +10594,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10792,7 +10609,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10835,7 +10651,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10850,7 +10666,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10893,7 +10708,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10908,7 +10723,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10951,7 +10765,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10966,7 +10780,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11009,7 +10822,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11024,7 +10837,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11067,7 +10879,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11082,7 +10894,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11119,7 +10930,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11134,7 +10945,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11169,7 +10979,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11184,7 +10994,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', @@ -11551,7 +11360,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Available battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11566,7 +11375,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Available battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -11608,7 +11416,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11623,7 +11431,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11666,7 +11473,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11681,7 +11488,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11724,7 +11530,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11739,7 +11545,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11782,7 +11587,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11797,7 +11602,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11832,7 +11636,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Battery', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11847,7 +11651,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Envoy 1234 Battery', - 'icon': 'mdi:flash', 'unit_of_measurement': '%', }), 'context': , @@ -11881,7 +11684,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Battery capacity', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11896,7 +11699,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Battery capacity', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -11938,7 +11740,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current battery discharge', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11953,7 +11755,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current battery discharge', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11996,7 +11797,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current battery discharge l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12011,7 +11812,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current battery discharge l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12054,7 +11854,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current battery discharge l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12069,7 +11869,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current battery discharge l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12112,7 +11911,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current battery discharge l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12127,7 +11926,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current battery discharge l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12170,7 +11968,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12185,7 +11983,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12228,7 +12025,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12243,7 +12040,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12286,7 +12082,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12301,7 +12097,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12344,7 +12139,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12359,7 +12154,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12402,7 +12196,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12417,7 +12211,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12460,7 +12253,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12475,7 +12268,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12518,7 +12310,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12533,7 +12325,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12576,7 +12367,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12591,7 +12382,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12634,7 +12424,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12649,7 +12439,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12692,7 +12481,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12707,7 +12496,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12750,7 +12538,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12765,7 +12553,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12808,7 +12595,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12823,7 +12610,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12864,7 +12650,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12879,7 +12665,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -12919,7 +12704,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12934,7 +12719,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days l1', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -12974,7 +12758,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12989,7 +12773,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days l2', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -13029,7 +12812,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13044,7 +12827,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days l3', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -13086,7 +12868,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13101,7 +12883,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13144,7 +12925,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13159,7 +12940,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13202,7 +12982,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13217,7 +12997,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13260,7 +13039,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13275,7 +13054,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13316,7 +13094,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13331,7 +13109,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -13371,7 +13148,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13386,7 +13163,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days l1', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -13426,7 +13202,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13441,7 +13217,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days l2', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -13481,7 +13256,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13496,7 +13271,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days l3', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -13538,7 +13312,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13553,7 +13327,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13596,7 +13369,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13611,7 +13384,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13654,7 +13426,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13669,7 +13441,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13712,7 +13483,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13727,7 +13498,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13767,7 +13537,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13782,7 +13552,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13822,7 +13591,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13837,7 +13606,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13877,7 +13645,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13892,7 +13660,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13932,7 +13699,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13947,7 +13714,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13987,7 +13753,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14002,7 +13768,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14042,7 +13807,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14057,7 +13822,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14097,7 +13861,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14112,7 +13876,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14152,7 +13915,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14167,7 +13930,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14207,7 +13969,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14222,7 +13984,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency storage CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14262,7 +14023,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14277,7 +14038,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency storage CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14317,7 +14077,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14332,7 +14092,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency storage CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14372,7 +14131,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14387,7 +14146,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency storage CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14430,7 +14188,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14445,7 +14203,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14488,7 +14245,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14503,7 +14260,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14546,7 +14302,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14561,7 +14317,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14604,7 +14359,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14619,7 +14374,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14662,7 +14416,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy charged', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14677,7 +14431,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy charged', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14720,7 +14473,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy charged l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14735,7 +14488,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy charged l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14778,7 +14530,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy charged l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14793,7 +14545,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy charged l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14836,7 +14587,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy charged l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14851,7 +14602,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy charged l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14894,7 +14644,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy discharged', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14909,7 +14659,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy discharged', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14952,7 +14701,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy discharged l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14967,7 +14716,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy discharged l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15010,7 +14758,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy discharged l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15025,7 +14773,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy discharged l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15068,7 +14815,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy discharged l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15083,7 +14830,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy discharged l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15126,7 +14872,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15141,7 +14887,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15184,7 +14929,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15199,7 +14944,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15242,7 +14986,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15257,7 +15001,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15300,7 +15043,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15315,7 +15058,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15358,7 +15100,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15373,7 +15115,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15416,7 +15157,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15431,7 +15172,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15474,7 +15214,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15489,7 +15229,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15532,7 +15271,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15547,7 +15286,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15590,7 +15328,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15605,7 +15343,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15648,7 +15385,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15663,7 +15400,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15706,7 +15442,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15721,7 +15457,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15764,7 +15499,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15779,7 +15514,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15822,7 +15556,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15837,7 +15571,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15880,7 +15613,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15895,7 +15628,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15938,7 +15670,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15953,7 +15685,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15996,7 +15727,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16011,7 +15742,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -16046,7 +15776,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16060,7 +15790,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', @@ -16093,7 +15822,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16107,7 +15836,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', @@ -16140,7 +15868,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16154,7 +15882,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', @@ -16187,7 +15914,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16201,7 +15928,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', @@ -16234,7 +15960,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16248,7 +15974,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', @@ -16281,7 +16006,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16295,7 +16020,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', @@ -16328,7 +16052,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16342,7 +16066,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', @@ -16375,7 +16098,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16389,7 +16112,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', @@ -16422,7 +16144,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16436,7 +16158,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active storage CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct', @@ -16469,7 +16190,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16483,7 +16204,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active storage CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct_l1', @@ -16516,7 +16236,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16530,7 +16250,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active storage CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct_l2', @@ -16563,7 +16282,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16577,7 +16296,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active storage CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct_l3', @@ -16616,7 +16334,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16631,7 +16349,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -16675,7 +16392,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16690,7 +16407,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -16734,7 +16450,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16749,7 +16465,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -16793,7 +16508,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16808,7 +16523,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -16852,7 +16566,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16867,7 +16581,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -16911,7 +16624,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16926,7 +16639,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -16970,7 +16682,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16985,7 +16697,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -17029,7 +16740,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17044,7 +16755,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -17088,7 +16798,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17103,7 +16813,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status storage CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -17147,7 +16856,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17162,7 +16871,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status storage CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -17206,7 +16914,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17221,7 +16929,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status storage CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -17265,7 +16972,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17280,7 +16987,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status storage CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -17326,7 +17032,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17341,7 +17047,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17384,7 +17089,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17399,7 +17104,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17442,7 +17146,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17457,7 +17161,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17500,7 +17203,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17515,7 +17218,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17555,7 +17257,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17570,7 +17272,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -17609,7 +17310,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17624,7 +17325,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -17663,7 +17363,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17678,7 +17378,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -17717,7 +17416,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17732,7 +17431,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -17771,7 +17469,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'powerfactor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17786,7 +17484,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 powerfactor production CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -17825,7 +17522,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17840,7 +17537,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -17879,7 +17575,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17894,7 +17590,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -17933,7 +17628,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17948,7 +17643,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -17987,7 +17681,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18002,7 +17696,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor storage CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -18041,7 +17734,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18056,7 +17749,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor storage CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -18095,7 +17787,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18110,7 +17802,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor storage CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -18149,7 +17840,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18164,7 +17855,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor storage CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -18206,7 +17896,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18221,7 +17911,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18264,7 +17953,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18279,7 +17968,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18322,7 +18010,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18337,7 +18025,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18380,7 +18067,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18395,7 +18082,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18430,7 +18116,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Reserve battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18445,7 +18131,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Reserve battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -18479,7 +18164,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Reserve battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18494,7 +18179,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Envoy 1234 Reserve battery level', - 'icon': 'mdi:flash', 'unit_of_measurement': '%', }), 'context': , @@ -18536,7 +18220,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Storage CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18551,7 +18235,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Storage CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18594,7 +18277,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Storage CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18609,7 +18292,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Storage CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18652,7 +18334,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Storage CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18667,7 +18349,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Storage CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18710,7 +18391,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Storage CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18725,7 +18406,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Storage CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18768,7 +18448,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18783,7 +18463,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18826,7 +18505,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18841,7 +18520,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18884,7 +18562,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18899,7 +18577,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18942,7 +18619,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18957,7 +18634,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19000,7 +18676,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19015,7 +18691,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19058,7 +18733,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19073,7 +18748,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19116,7 +18790,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19131,7 +18805,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19174,7 +18847,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19189,7 +18862,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19232,7 +18904,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19247,7 +18919,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage storage CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19290,7 +18961,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19305,7 +18976,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage storage CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19348,7 +19018,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19363,7 +19033,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage storage CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19406,7 +19075,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19421,7 +19090,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage storage CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19458,7 +19126,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19473,7 +19141,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19508,7 +19175,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19523,7 +19190,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', @@ -19564,7 +19230,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19579,7 +19245,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19622,7 +19287,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19637,7 +19302,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19680,7 +19344,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19695,7 +19359,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19738,7 +19401,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19753,7 +19416,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19796,7 +19458,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19811,7 +19473,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19854,7 +19515,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19869,7 +19530,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19912,7 +19572,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19927,7 +19587,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19970,7 +19629,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19985,7 +19644,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20028,7 +19686,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20043,7 +19701,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20086,7 +19743,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20101,7 +19758,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20144,7 +19800,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20159,7 +19815,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20202,7 +19857,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20217,7 +19872,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20260,7 +19914,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20275,7 +19929,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20318,7 +19971,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20333,7 +19986,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20376,7 +20028,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20391,7 +20043,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20434,7 +20085,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20449,7 +20100,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20490,7 +20140,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20505,7 +20155,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -20545,7 +20194,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20560,7 +20209,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days l1', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -20600,7 +20248,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20615,7 +20263,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days l2', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -20655,7 +20302,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20670,7 +20317,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days l3', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -20712,7 +20358,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20727,7 +20373,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20770,7 +20415,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20785,7 +20430,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20828,7 +20472,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20843,7 +20487,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20886,7 +20529,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20901,7 +20544,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20942,7 +20584,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20957,7 +20599,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -20997,7 +20638,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21012,7 +20653,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days l1', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -21052,7 +20692,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21067,7 +20707,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days l2', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -21107,7 +20746,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21122,7 +20761,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days l3', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -21164,7 +20802,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21179,7 +20817,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21222,7 +20859,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21237,7 +20874,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21280,7 +20916,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21295,7 +20931,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21338,7 +20973,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21353,7 +20988,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21393,7 +21027,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21408,7 +21042,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21448,7 +21081,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21463,7 +21096,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21503,7 +21135,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21518,7 +21150,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21558,7 +21189,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21573,7 +21204,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21613,7 +21243,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21628,7 +21258,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21668,7 +21297,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21683,7 +21312,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21723,7 +21351,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21738,7 +21366,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21778,7 +21405,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21793,7 +21420,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21836,7 +21462,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21851,7 +21477,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21894,7 +21519,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21909,7 +21534,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21952,7 +21576,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21967,7 +21591,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22010,7 +21633,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22025,7 +21648,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22068,7 +21690,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22083,7 +21705,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22126,7 +21747,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22141,7 +21762,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22184,7 +21804,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22199,7 +21819,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22242,7 +21861,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22257,7 +21876,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22300,7 +21918,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22315,7 +21933,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22358,7 +21975,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22373,7 +21990,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22416,7 +22032,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22431,7 +22047,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22474,7 +22089,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22489,7 +22104,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22532,7 +22146,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22547,7 +22161,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22590,7 +22203,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22605,7 +22218,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22648,7 +22260,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22663,7 +22275,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22706,7 +22317,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22721,7 +22332,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22764,7 +22374,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22779,7 +22389,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22822,7 +22431,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22837,7 +22446,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22880,7 +22488,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22895,7 +22503,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22938,7 +22545,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22953,7 +22560,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22988,7 +22594,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23002,7 +22608,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', @@ -23035,7 +22640,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23049,7 +22654,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', @@ -23082,7 +22686,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23096,7 +22700,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', @@ -23129,7 +22732,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23143,7 +22746,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', @@ -23176,7 +22778,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23190,7 +22792,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', @@ -23223,7 +22824,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23237,7 +22838,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', @@ -23270,7 +22870,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23284,7 +22884,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', @@ -23317,7 +22916,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23331,7 +22930,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', @@ -23370,7 +22968,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23385,7 +22983,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -23429,7 +23026,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23444,7 +23041,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -23488,7 +23084,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23503,7 +23099,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -23547,7 +23142,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23562,7 +23157,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -23606,7 +23200,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23621,7 +23215,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -23665,7 +23258,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23680,7 +23273,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -23724,7 +23316,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23739,7 +23331,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -23783,7 +23374,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23798,7 +23389,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -23844,7 +23434,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23859,7 +23449,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -23902,7 +23491,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23917,7 +23506,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -23960,7 +23548,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23975,7 +23563,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24018,7 +23605,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24033,7 +23620,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24073,7 +23659,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24088,7 +23674,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -24127,7 +23712,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24142,7 +23727,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -24181,7 +23765,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24196,7 +23780,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -24235,7 +23818,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24250,7 +23833,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -24289,7 +23871,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'powerfactor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24304,7 +23886,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 powerfactor production CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -24343,7 +23924,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24358,7 +23939,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -24397,7 +23977,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24412,7 +23992,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -24451,7 +24030,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24466,7 +24045,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -24508,7 +24086,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24523,7 +24101,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24566,7 +24143,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24581,7 +24158,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24624,7 +24200,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24639,7 +24215,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24682,7 +24257,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24697,7 +24272,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24740,7 +24314,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24755,7 +24329,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24798,7 +24371,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24813,7 +24386,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24856,7 +24428,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24871,7 +24443,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24914,7 +24485,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24929,7 +24500,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24972,7 +24542,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24987,7 +24557,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25030,7 +24599,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25045,7 +24614,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25088,7 +24656,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25103,7 +24671,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25146,7 +24713,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25161,7 +24728,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25198,7 +24764,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25213,7 +24779,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25248,7 +24813,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25263,7 +24828,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', @@ -25304,7 +24868,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25319,7 +24883,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25362,7 +24925,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25377,7 +24940,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25418,7 +24980,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25433,7 +24995,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -25475,7 +25036,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25490,7 +25051,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25530,7 +25090,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25545,7 +25105,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25588,7 +25147,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25603,7 +25162,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25646,7 +25204,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25661,7 +25219,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25696,7 +25253,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25710,7 +25267,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', @@ -25749,7 +25305,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25764,7 +25320,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -25807,7 +25362,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'powerfactor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25822,7 +25377,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 powerfactor production CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -25864,7 +25418,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25879,7 +25433,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25922,7 +25475,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25937,7 +25490,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25974,7 +25526,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25989,7 +25541,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -26024,7 +25575,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -26039,7 +25590,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', diff --git a/tests/components/enphase_envoy/snapshots/test_switch.ambr b/tests/components/enphase_envoy/snapshots/test_switch.ambr index 46123c03cec..a022e476d5c 100644 --- a/tests/components/enphase_envoy/snapshots/test_switch.ambr +++ b/tests/components/enphase_envoy/snapshots/test_switch.ambr @@ -165,7 +165,7 @@ 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'relay_status', 'unique_id': '654321_relay_NC1_relay_status', 'unit_of_measurement': None, }) @@ -211,7 +211,7 @@ 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'relay_status', 'unique_id': '654321_relay_NC2_relay_status', 'unit_of_measurement': None, }) @@ -257,7 +257,7 @@ 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'relay_status', 'unique_id': '654321_relay_NC3_relay_status', 'unit_of_measurement': None, }) From 6ee4eb22802e75f60f04a5080f13cf622c29a995 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Jan 2025 22:56:41 -1000 Subject: [PATCH 0801/2987] Bump bluetooth-adapters to 0.21.1 (#136220) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index de446886c16..6c58c79f8fa 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -17,7 +17,7 @@ "requirements": [ "bleak==0.22.3", "bleak-retry-connector==3.8.0", - "bluetooth-adapters==0.21.0", + "bluetooth-adapters==0.21.1", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 91fff829a47..4388175af1a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -20,7 +20,7 @@ awesomeversion==24.6.0 bcrypt==4.2.0 bleak-retry-connector==3.8.0 bleak==0.22.3 -bluetooth-adapters==0.21.0 +bluetooth-adapters==0.21.1 bluetooth-auto-recovery==1.4.2 bluetooth-data-tools==1.22.0 cached-ipaddress==0.8.0 diff --git a/requirements_all.txt b/requirements_all.txt index e4876679071..4360df8ed8d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -619,7 +619,7 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.21.0 +bluetooth-adapters==0.21.1 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2d9fbcfdb94..0489f9d2bb8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -543,7 +543,7 @@ bluecurrent-api==1.2.3 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.21.0 +bluetooth-adapters==0.21.1 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 From 29f9c880414c2994643d6fea2896a3f69457e369 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Jan 2025 22:59:15 -1000 Subject: [PATCH 0802/2987] Bump habluetooth to 3.11.2 (#136221) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 6c58c79f8fa..ed80d419867 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.10.0" + "habluetooth==3.11.2" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4388175af1a..7150ca567ae 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.10.0 +habluetooth==3.11.2 hass-nabucasa==0.88.1 hassil==2.1.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 4360df8ed8d..98d525be61e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1097,7 +1097,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.10.0 +habluetooth==3.11.2 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0489f9d2bb8..bdd0c56fff0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -938,7 +938,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.10.0 +habluetooth==3.11.2 # homeassistant.components.cloud hass-nabucasa==0.88.1 From 67ca9e45b568c377ca775cac334bf0df7ada923f Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Wed, 22 Jan 2025 02:14:48 -0700 Subject: [PATCH 0803/2987] Use kw_only attribute for remaining entity descriptions in litterrobot (#136202) * Use kw_only attribute for binary sensor descriptions in litterrobot * Update time.py with kw_only for litterrobot * Wrap multiline lambda --- .../components/litterrobot/binary_sensor.py | 33 ++++++++----------- homeassistant/components/litterrobot/time.py | 18 +++++----- 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index 91113d6c094..9a9a4b348b7 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -21,29 +21,13 @@ from . import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT -@dataclass(frozen=True) -class RequiredKeysMixin(Generic[_RobotT]): - """A class that describes robot binary sensor entity required keys.""" - - is_on_fn: Callable[[_RobotT], bool] - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class RobotBinarySensorEntityDescription( - BinarySensorEntityDescription, RequiredKeysMixin[_RobotT] + BinarySensorEntityDescription, Generic[_RobotT] ): """A class that describes robot binary sensor entities.""" - -class LitterRobotBinarySensorEntity(LitterRobotEntity[_RobotT], BinarySensorEntity): - """Litter-Robot binary sensor entity.""" - - entity_description: RobotBinarySensorEntityDescription[_RobotT] - - @property - def is_on(self) -> bool: - """Return the state.""" - return self.entity_description.is_on_fn(self.robot) + is_on_fn: Callable[[_RobotT], bool] BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, ...]] = { @@ -90,3 +74,14 @@ async def async_setup_entry( if isinstance(robot, robot_type) for description in entity_descriptions ) + + +class LitterRobotBinarySensorEntity(LitterRobotEntity[_RobotT], BinarySensorEntity): + """Litter-Robot binary sensor entity.""" + + entity_description: RobotBinarySensorEntityDescription[_RobotT] + + @property + def is_on(self) -> bool: + """Return the state.""" + return self.entity_description.is_on_fn(self.robot) diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py index ace30d9f3a9..7720798c8b8 100644 --- a/homeassistant/components/litterrobot/time.py +++ b/homeassistant/components/litterrobot/time.py @@ -19,19 +19,14 @@ from . import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT -@dataclass(frozen=True) -class RequiredKeysMixin(Generic[_RobotT]): - """A class that describes robot time entity required keys.""" +@dataclass(frozen=True, kw_only=True) +class RobotTimeEntityDescription(TimeEntityDescription, Generic[_RobotT]): + """A class that describes robot time entities.""" value_fn: Callable[[_RobotT], time | None] set_fn: Callable[[_RobotT, time], Coroutine[Any, Any, bool]] -@dataclass(frozen=True) -class RobotTimeEntityDescription(TimeEntityDescription, RequiredKeysMixin[_RobotT]): - """A class that describes robot time entities.""" - - def _as_local_time(start: datetime | None) -> time | None: """Return a datetime as local time.""" return dt_util.as_local(start).time() if start else None @@ -42,8 +37,11 @@ LITTER_ROBOT_3_SLEEP_START = RobotTimeEntityDescription[LitterRobot3]( translation_key="sleep_mode_start_time", entity_category=EntityCategory.CONFIG, value_fn=lambda robot: _as_local_time(robot.sleep_mode_start_time), - set_fn=lambda robot, value: robot.set_sleep_mode( - robot.sleep_mode_enabled, value.replace(tzinfo=dt_util.get_default_time_zone()) + set_fn=( + lambda robot, value: robot.set_sleep_mode( + robot.sleep_mode_enabled, + value.replace(tzinfo=dt_util.get_default_time_zone()), + ) ), ) From a3cc68754fdf1442dfe0ea4acff63025cd6e758b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 22 Jan 2025 10:18:41 +0100 Subject: [PATCH 0804/2987] Make description of hdmi_cec.select_device action consistent (#136228) The hdmi_cec.select_device action has an inconsistent description that causes wrong (machine) translations. This commit brings it in line with all other actions in the integration. --- homeassistant/components/hdmi_cec/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hdmi_cec/strings.json b/homeassistant/components/hdmi_cec/strings.json index 449b9f72fe7..70848b0514e 100644 --- a/homeassistant/components/hdmi_cec/strings.json +++ b/homeassistant/components/hdmi_cec/strings.json @@ -6,7 +6,7 @@ }, "select_device": { "name": "Select device", - "description": "Select HDMI device.", + "description": "Selects an HDMI device.", "fields": { "device": { "name": "[%key:common::config_flow::data::device%]", From f4d6cb45e5ea91fa34f7b92d1db1672dfbd1b076 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 22 Jan 2025 05:25:56 -0600 Subject: [PATCH 0805/2987] Add repeat feature to HEOS media player (#136180) --- homeassistant/components/heos/media_player.py | 24 ++++++++-- .../heos/snapshots/test_media_player.ambr | 3 +- tests/components/heos/test_media_player.py | 44 +++++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index b8690040061..d174d744756 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from functools import reduce, wraps -import logging from operator import ior from typing import Any @@ -14,6 +13,7 @@ from pyheos import ( HeosError, HeosPlayer, PlayState, + RepeatType, const as heos_const, ) @@ -26,6 +26,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, MediaType, + RepeatMode, async_process_play_media_url, ) from homeassistant.core import HomeAssistant @@ -48,7 +49,6 @@ BASE_SUPPORTED_FEATURES = ( | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.CLEAR_PLAYLIST - | MediaPlayerEntityFeature.SHUFFLE_SET | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.GROUPING @@ -78,7 +78,12 @@ HA_HEOS_ENQUEUE_MAP = { MediaPlayerEnqueue.PLAY: AddCriteriaType.PLAY_NOW, } -_LOGGER = logging.getLogger(__name__) +HEOS_HA_REPEAT_TYPE_MAP = { + RepeatType.OFF: RepeatMode.OFF, + RepeatType.ON_ALL: RepeatMode.ALL, + RepeatType.ON_ONE: RepeatMode.ONE, +} +HA_HEOS_REPEAT_TYPE_MAP = {v: k for k, v in HEOS_HA_REPEAT_TYPE_MAP.items()} async def async_setup_entry( @@ -293,6 +298,13 @@ class HeosMediaPlayer(MediaPlayerEntity): """Select input source.""" await self._source_manager.play_source(source, self._player) + @catch_action_error("set repeat") + async def async_set_repeat(self, repeat: RepeatMode) -> None: + """Set repeat mode.""" + await self._player.set_play_mode( + HA_HEOS_REPEAT_TYPE_MAP[repeat], self._player.shuffle + ) + @catch_action_error("set shuffle") async def async_set_shuffle(self, shuffle: bool) -> None: """Enable/disable shuffle mode.""" @@ -305,11 +317,17 @@ class HeosMediaPlayer(MediaPlayerEntity): async def async_update(self) -> None: """Update supported features of the player.""" + self._attr_repeat = HEOS_HA_REPEAT_TYPE_MAP[self._player.repeat] controls = self._player.now_playing_media.supported_controls current_support = [CONTROL_TO_SUPPORT[control] for control in controls] self._attr_supported_features = reduce( ior, current_support, BASE_SUPPORTED_FEATURES ) + if self.support_next_track and self.support_previous_track: + self._attr_supported_features |= ( + MediaPlayerEntityFeature.REPEAT_SET + | MediaPlayerEntityFeature.SHUFFLE_SET + ) @catch_action_error("unjoin player") async def async_unjoin_player(self) -> None: diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index 7ade53c92ee..56299a017f2 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -19,13 +19,14 @@ 'media_station': 'Station Name', 'media_title': 'Song', 'media_type': 'Station', + 'repeat': , 'shuffle': False, 'source_list': list([ "Today's Hits Radio", 'Classical MPR (Classical Music)', 'HEOS Drive - Line In 1', ]), - 'supported_features': , + 'supported_features': , 'volume_level': 0.25, }), 'entity_id': 'media_player.test_player', diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 805e593935c..00082c77f0f 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -11,6 +11,7 @@ from pyheos import ( MediaItem, PlayerUpdateResult, PlayState, + RepeatType, SignalHeosEvent, SignalType, const, @@ -30,6 +31,7 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_REPEAT, ATTR_MEDIA_SHUFFLE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, @@ -40,6 +42,7 @@ from homeassistant.components.media_player import ( SERVICE_SELECT_SOURCE, SERVICE_UNJOIN, MediaType, + RepeatMode, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -48,6 +51,7 @@ from homeassistant.const import ( SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, + SERVICE_REPEAT_SET, SERVICE_SHUFFLE_SET, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, @@ -563,6 +567,46 @@ async def test_shuffle_set_error( player.set_play_mode.assert_called_once_with(player.repeat, True) +async def test_repeat_set( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the repeat set service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_REPEAT_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_REPEAT: RepeatMode.ONE}, + blocking=True, + ) + player.set_play_mode.assert_called_once_with(RepeatType.ON_ONE, player.shuffle) + + +async def test_repeat_set_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the repeat set service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.set_play_mode.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to set repeat: Failure (1)"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_REPEAT_SET, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_REPEAT: RepeatMode.ALL, + }, + blocking=True, + ) + player.set_play_mode.assert_called_once_with(RepeatType.ON_ALL, player.shuffle) + + async def test_volume_set( hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: From 1ea6cba1f57311bab928e6c4768e84d67a0826e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 22 Jan 2025 12:28:18 +0100 Subject: [PATCH 0806/2987] Handle empty string `BatReplacementDescription` from Matter attribute value (#134457) --- homeassistant/components/matter/sensor.py | 2 + .../matter/snapshots/test_sensor.ambr | 230 ------------------ tests/components/matter/test_sensor.py | 6 + 3 files changed, 8 insertions(+), 230 deletions(-) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 847c9439b81..d8fe56278df 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -244,6 +244,8 @@ DISCOVERY_SCHEMAS = [ required_attributes=( clusters.PowerSource.Attributes.BatReplacementDescription, ), + # Some manufacturers returns an empty string + value_is_not="", ), MatterDiscoverySchema( platform=Platform.SENSOR, diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index f88604e7d46..fc0c80230fb 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -1145,98 +1145,6 @@ 'state': '189.0', }) # --- -# name: test_sensors[door_lock][sensor.mock_door_lock_battery_type-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.mock_door_lock_battery_type', - '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': 'Battery type', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_replacement_description', - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatReplacementDescription-47-19', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[door_lock][sensor.mock_door_lock_battery_type-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Door Lock Battery type', - }), - 'context': , - 'entity_id': 'sensor.mock_door_lock_battery_type', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '', - }) -# --- -# name: test_sensors[door_lock_with_unbolt][sensor.mock_door_lock_battery_type-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.mock_door_lock_battery_type', - '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': 'Battery type', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_replacement_description', - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatReplacementDescription-47-19', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[door_lock_with_unbolt][sensor.mock_door_lock_battery_type-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Door Lock Battery type', - }), - 'context': , - 'entity_id': 'sensor.mock_door_lock_battery_type', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '', - }) -# --- # name: test_sensors[eve_contact_sensor][sensor.eve_door_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1288,52 +1196,6 @@ 'state': '100', }) # --- -# name: test_sensors[eve_contact_sensor][sensor.eve_door_battery_type-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.eve_door_battery_type', - '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': 'Battery type', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_replacement_description', - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatReplacementDescription-47-19', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[eve_contact_sensor][sensor.eve_door_battery_type-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Eve Door Battery type', - }), - 'context': , - 'entity_id': 'sensor.eve_door_battery_type', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '', - }) -# --- # name: test_sensors[eve_contact_sensor][sensor.eve_door_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1883,52 +1745,6 @@ 'state': '100', }) # --- -# name: test_sensors[eve_thermo][sensor.eve_thermo_battery_type-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.eve_thermo_battery_type', - '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': 'Battery type', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_replacement_description', - 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-0-PowerSourceBatReplacementDescription-47-19', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[eve_thermo][sensor.eve_thermo_battery_type-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Eve Thermo Battery type', - }), - 'context': , - 'entity_id': 'sensor.eve_thermo_battery_type', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '', - }) -# --- # name: test_sensors[eve_thermo][sensor.eve_thermo_valve_position-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2081,52 +1897,6 @@ 'state': '100', }) # --- -# name: test_sensors[eve_weather_sensor][sensor.eve_weather_battery_type-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.eve_weather_battery_type', - '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': 'Battery type', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_replacement_description', - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-0-PowerSourceBatReplacementDescription-47-19', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[eve_weather_sensor][sensor.eve_weather_battery_type-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Eve Weather Battery type', - }), - 'context': , - 'entity_id': 'sensor.eve_weather_battery_type', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '', - }) -# --- # name: test_sensors[eve_weather_sensor][sensor.eve_weather_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 3215ec58116..630809a957d 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -193,6 +193,12 @@ async def test_battery_sensor_description( assert state assert state.state == "CR2032" + # case with a empty string to check if the attribute is indeed ignored + set_node_attribute(matter_node, 1, 47, 19, "") + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.smoke_sensor_battery_type") is None + @pytest.mark.parametrize("node_fixture", ["eve_thermo"]) async def test_eve_thermo_sensor( From 99d1c51a3b4c3ae484dd0332c1f7125a70b19c1e Mon Sep 17 00:00:00 2001 From: "Thijs W." Date: Wed, 22 Jan 2025 12:33:21 +0100 Subject: [PATCH 0807/2987] Fix passing value to pymodbus low level function (#135108) --- homeassistant/components/modbus/modbus.py | 25 ++++++++++++++++------- tests/components/modbus/test_climate.py | 4 ++-- tests/components/modbus/test_init.py | 17 +++++++++++---- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 8c8a879ead6..c18a256a1cf 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -72,48 +72,56 @@ from .validators import check_config _LOGGER = logging.getLogger(__name__) -ConfEntry = namedtuple("ConfEntry", "call_type attr func_name") # noqa: PYI024 -RunEntry = namedtuple("RunEntry", "attr func") # noqa: PYI024 +ConfEntry = namedtuple("ConfEntry", "call_type attr func_name value_attr_name") # noqa: PYI024 +RunEntry = namedtuple("RunEntry", "attr func value_attr_name") # noqa: PYI024 PB_CALL = [ ConfEntry( CALL_TYPE_COIL, "bits", "read_coils", + "count", ), ConfEntry( CALL_TYPE_DISCRETE, "bits", "read_discrete_inputs", + "count", ), ConfEntry( CALL_TYPE_REGISTER_HOLDING, "registers", "read_holding_registers", + "count", ), ConfEntry( CALL_TYPE_REGISTER_INPUT, "registers", "read_input_registers", + "count", ), ConfEntry( CALL_TYPE_WRITE_COIL, - "value", + "bits", "write_coil", + "value", ), ConfEntry( CALL_TYPE_WRITE_COILS, "count", "write_coils", + "values", ), ConfEntry( CALL_TYPE_WRITE_REGISTER, - "value", + "registers", "write_register", + "value", ), ConfEntry( CALL_TYPE_WRITE_REGISTERS, "count", "write_registers", + "values", ), ] @@ -322,7 +330,9 @@ class ModbusHub: for entry in PB_CALL: func = getattr(self._client, entry.func_name) - self._pb_request[entry.call_type] = RunEntry(entry.attr, func) + self._pb_request[entry.call_type] = RunEntry( + entry.attr, func, entry.value_attr_name + ) self.hass.async_create_background_task( self.async_pb_connect(), "modbus-connect" @@ -368,10 +378,11 @@ class ModbusHub: self, slave: int | None, address: int, value: int | list[int], use_call: str ) -> ModbusPDU | None: """Call sync. pymodbus.""" - kwargs = {"slave": slave} if slave else {} + kwargs: dict[str, Any] = {"slave": slave} if slave else {} entry = self._pb_request[use_call] + kwargs[entry.value_attr_name] = value try: - result: ModbusPDU = await entry.func(address, value, **kwargs) + result: ModbusPDU = await entry.func(address, **kwargs) except ModbusException as exception_error: error = f"Error: device: {slave} address: {address} -> {exception_error!s}" self._log_error(error) diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 1520e4478c6..b5bc9b02808 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -394,7 +394,7 @@ async def test_hvac_onoff_values(hass: HomeAssistant, mock_modbus) -> None: ) await hass.async_block_till_done() - mock_modbus.write_register.assert_called_with(11, 0xAA, slave=10) + mock_modbus.write_register.assert_called_with(11, value=0xAA, slave=10) await hass.services.async_call( CLIMATE_DOMAIN, @@ -404,7 +404,7 @@ async def test_hvac_onoff_values(hass: HomeAssistant, mock_modbus) -> None: ) await hass.async_block_till_done() - mock_modbus.write_register.assert_called_with(11, 0xFF, slave=10) + mock_modbus.write_register.assert_called_with(11, value=0xFF, slave=10) @pytest.mark.parametrize( diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 5dd3f6e9033..616a7580e9d 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -846,6 +846,13 @@ async def test_pb_service_write( CALL_TYPE_WRITE_REGISTERS: mock_modbus_with_pymodbus.write_registers, } + value_arg_name = { + CALL_TYPE_WRITE_COIL: "value", + CALL_TYPE_WRITE_COILS: "values", + CALL_TYPE_WRITE_REGISTER: "value", + CALL_TYPE_WRITE_REGISTERS: "values", + } + data = { ATTR_HUB: TEST_MODBUS_NAME, do_slave: 17, @@ -858,10 +865,12 @@ async def test_pb_service_write( func_name[do_write[FUNC]].return_value = do_return[VALUE] await hass.services.async_call(DOMAIN, do_write[SERVICE], data, blocking=True) assert func_name[do_write[FUNC]].called - assert func_name[do_write[FUNC]].call_args[0] == ( - data[ATTR_ADDRESS], - data[do_write[DATA]], - ) + assert func_name[do_write[FUNC]].call_args.args == (data[ATTR_ADDRESS],) + assert func_name[do_write[FUNC]].call_args.kwargs == { + "slave": 17, + value_arg_name[do_write[FUNC]]: data[do_write[DATA]], + } + if do_return[DATA]: assert any(message.startswith("Pymodbus:") for message in caplog.messages) From 2ca4c8aacf2429170d4088868b73970454825e4e Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 22 Jan 2025 13:42:18 +0200 Subject: [PATCH 0808/2987] Update LG webOS TV IQS (#135509) --- .../components/webostv/quality_scale.yaml | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/webostv/quality_scale.yaml b/homeassistant/components/webostv/quality_scale.yaml index 1b3a3173ffa..c4828e9e6dd 100644 --- a/homeassistant/components/webostv/quality_scale.yaml +++ b/homeassistant/components/webostv/quality_scale.yaml @@ -9,12 +9,10 @@ rules: config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: todo - comment: add description for parameters + docs-actions: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: done entity-unique-id: done has-entity-name: done @@ -24,10 +22,10 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: todo integration-owner: done log-when-unavailable: todo @@ -40,13 +38,13 @@ rules: diagnostics: done discovery-update-info: done discovery: done - 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: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: The integration connects to a single device. @@ -76,5 +74,5 @@ rules: async-dependency: done inject-websession: status: todo - comment: need to check if it is needed for websockets or migrate to aiohttp + comment: migrate to aiohttp strict-typing: done From a150e39922ef7715089f7b626fce5872b57fab1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jan 2025 01:50:00 -1000 Subject: [PATCH 0809/2987] Bump httpx to 0.28.1, httpcore to 1.0.7 along with required deps (#133840) --- homeassistant/components/anthropic/manifest.json | 2 +- homeassistant/components/openai_conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 4 ++-- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 4 ++-- requirements_test.txt | 2 +- requirements_test_all.txt | 4 ++-- script/gen_requirements_all.py | 2 +- tests/components/rest/test_sensor.py | 2 +- 10 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json index 7d51c458e4d..b5cbb36c034 100644 --- a/homeassistant/components/anthropic/manifest.json +++ b/homeassistant/components/anthropic/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/anthropic", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["anthropic==0.31.2"] + "requirements": ["anthropic==0.44.0"] } diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index fcbdc996ce5..9b70246117c 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.35.7"] + "requirements": ["openai==1.59.9"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7150ca567ae..c705005f75a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ hassil==2.1.0 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20250109.0 home-assistant-intents==2025.1.1 -httpx==0.27.2 +httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.5 lru-dict==1.3.0 @@ -110,7 +110,7 @@ uuid==1000000000.0.0 # requirements so we can directly link HA versions to these library versions. anyio==4.8.0 h11==0.14.0 -httpcore==1.0.5 +httpcore==1.0.7 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation diff --git a/pyproject.toml b/pyproject.toml index c4a1c45671a..3c8f68c5111 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ dependencies = [ "hass-nabucasa==0.88.1", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all - "httpx==0.27.2", + "httpx==0.28.1", "home-assistant-bluetooth==1.13.0", "ifaddr==0.2.0", "Jinja2==3.1.5", diff --git a/requirements.txt b/requirements.txt index 91a5d131b3b..e7a092c55a2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.2.2 hass-nabucasa==0.88.1 -httpx==0.27.2 +httpx==0.28.1 home-assistant-bluetooth==1.13.0 ifaddr==0.2.0 Jinja2==3.1.5 diff --git a/requirements_all.txt b/requirements_all.txt index 98d525be61e..7b77238af8a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -467,7 +467,7 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.31.2 +anthropic==0.44.0 # homeassistant.components.mcp_server anyio==4.8.0 @@ -1561,7 +1561,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.35.7 +openai==1.59.9 # homeassistant.components.openerz openerz-api==0.3.0 diff --git a/requirements_test.txt b/requirements_test.txt index 029073f19a2..2c488189291 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -31,7 +31,7 @@ pytest-picked==0.5.0 pytest-xdist==3.6.1 pytest==8.3.4 requests-mock==1.12.1 -respx==0.21.1 +respx==0.22.0 syrupy==4.8.0 tqdm==4.66.5 types-aiofiles==24.1.0.20241221 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bdd0c56fff0..0dea8a85638 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -440,7 +440,7 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.31.2 +anthropic==0.44.0 # homeassistant.components.mcp_server anyio==4.8.0 @@ -1309,7 +1309,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.35.7 +openai==1.59.9 # homeassistant.components.openerz openerz-api==0.3.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e2b60e777a2..2b6e4eda7b0 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -141,7 +141,7 @@ uuid==1000000000.0.0 # requirements so we can directly link HA versions to these library versions. anyio==4.8.0 h11==0.14.0 -httpcore==1.0.5 +httpcore==1.0.7 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 2e02063b215..d5fc5eca55c 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -591,7 +591,7 @@ async def test_update_with_no_template(hass: HomeAssistant) -> None: assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 state = hass.states.get("sensor.foo") - assert state.state == '{"key": "some_json_value"}' + assert state.state == '{"key":"some_json_value"}' @respx.mock From 0b7ed7dcbd14f65a4a26e775a7e3eb58d5f394c2 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Wed, 22 Jan 2025 05:17:59 -0700 Subject: [PATCH 0810/2987] Add quality_scale file to litterrobot (#135904) --- .../components/litterrobot/quality_scale.yaml | 114 ++++++++++++++++++ .../components/litterrobot/strings.json | 7 ++ script/hassfest/quality_scale.py | 1 - 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/litterrobot/quality_scale.yaml diff --git a/homeassistant/components/litterrobot/quality_scale.yaml b/homeassistant/components/litterrobot/quality_scale.yaml new file mode 100644 index 00000000000..bf4392bede6 --- /dev/null +++ b/homeassistant/components/litterrobot/quality_scale.yaml @@ -0,0 +1,114 @@ +rules: + # Adjust platform files for consistent flow: + # [entity description classes] + # [entity descriptions] + # [async_setup_entry] + # [entity classes]) + # Remove RequiredKeyMixins and add kw_only to classes + # Wrap multiline lambdas in parenthesis + # Extend entity description in switch.py to use value_fn instead of getattr + # Deprecate extra state attributes in vacuum.py + # Bronze + action-setup: + status: todo + comment: | + Action async_set_sleep_mode is currently setup in the vacuum platform + appropriate-polling: + status: done + comment: | + Primarily relies on push data, but polls every 5 minutes for missed updates + brands: done + common-modules: + status: todo + comment: | + hub.py should be renamed to coordinator.py and updated accordingly + Also should not need to return bool (never used) + config-flow-test-coverage: + status: todo + comment: | + Fix stale title and docstring + Replace litterrobot.DOMAIN references to DOMAIN (after correctly importing) + Make sure every test ends in either ABORT or CREATE_ENTRY + so we also test that the flow is able to recover + config-flow: done + dependency-transparency: done + docs-actions: + status: todo + comment: Can be finished after async_set_sleep_mode is moved to async_setup + docs-high-level-description: done + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: + status: todo + comment: Do we need to subscribe to both the coordinator and robot itself? + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: done + comment: No options to configure + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: done + test-coverage: + status: todo + comment: | + Move big data objects from common.py into JSON fixtures and oad them when needed. + Other fields can be moved to const.py. Consider snapshots and testing data updates + + # Gold + devices: + status: done + comment: Currently uses the device_info property, could be moved to _attr_device_info + diagnostics: todo + discovery-update-info: + status: done + comment: The integration is cloud-based + discovery: + status: todo + comment: Need to validate discovery + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: todo + comment: Check if we should disable any entities by default + entity-translations: + status: todo + comment: Make sure all translated states are in sentence case + exception-translations: todo + icon-translations: + status: todo + comment: BRIGHTNESS_LEVEL_ICON_MAP should be migrated to icons.json + reconfiguration-flow: todo + repair-issues: + status: done + comment: | + This integration doesn't have any cases where raising an issue is needed + stale-devices: + status: todo + comment: | + Currently handled via async_remove_config_entry_device, + but we should be able to remove devices automatically + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 3b6e2f01ef9..19b007de068 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -5,6 +5,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "The email address of your Whisker account.", + "password": "The password of your Whisker account." } }, "reauth_confirm": { @@ -12,6 +16,9 @@ "title": "[%key:common::config_flow::title::reauth%]", "data": { "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::litterrobot::config::step::user::data_description::password%]" } } }, diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 7ca7110c49b..357ca0b1050 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -595,7 +595,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "linux_battery", "lirc", "litejet", - "litterrobot", "livisi", "llamalab_automate", "local_calendar", From 5e63e02ebcbd9e179f2c7a6b896b649d0e67fb8b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 22 Jan 2025 13:47:13 +0100 Subject: [PATCH 0811/2987] Handle invalid auth in Overseerr (#136243) --- .../components/overseerr/config_flow.py | 9 +++++-- .../components/overseerr/coordinator.py | 14 +++++++++-- .../components/overseerr/strings.json | 4 ++++ .../components/overseerr/test_config_flow.py | 18 +++++++++++--- tests/components/overseerr/test_init.py | 24 +++++++++++++++++++ 5 files changed, 62 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/overseerr/config_flow.py b/homeassistant/components/overseerr/config_flow.py index 2ad0c8d6d61..e2994212bfe 100644 --- a/homeassistant/components/overseerr/config_flow.py +++ b/homeassistant/components/overseerr/config_flow.py @@ -2,8 +2,11 @@ from typing import Any -from python_overseerr import OverseerrClient -from python_overseerr.exceptions import OverseerrError +from python_overseerr import ( + OverseerrAuthenticationError, + OverseerrClient, + OverseerrError, +) import voluptuous as vol from yarl import URL @@ -47,6 +50,8 @@ class OverseerrConfigFlow(ConfigFlow, domain=DOMAIN): ) try: await client.get_request_count() + except OverseerrAuthenticationError: + errors["base"] = "invalid_auth" except OverseerrError: errors["base"] = "cannot_connect" else: diff --git a/homeassistant/components/overseerr/coordinator.py b/homeassistant/components/overseerr/coordinator.py index c8512d764f4..75a7d8d73d7 100644 --- a/homeassistant/components/overseerr/coordinator.py +++ b/homeassistant/components/overseerr/coordinator.py @@ -2,13 +2,18 @@ from datetime import timedelta -from python_overseerr import OverseerrClient, RequestCount -from python_overseerr.exceptions import OverseerrConnectionError +from python_overseerr import ( + OverseerrAuthenticationError, + OverseerrClient, + OverseerrConnectionError, + RequestCount, +) from yarl import URL from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -47,6 +52,11 @@ class OverseerrCoordinator(DataUpdateCoordinator[RequestCount]): """Fetch data from API endpoint.""" try: return await self.client.get_request_count() + except OverseerrAuthenticationError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="auth_error", + ) from err except OverseerrConnectionError as err: raise UpdateFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index 968b8c5b533..25b53303611 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -17,6 +17,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_host": "The provided URL is not a valid host." } }, @@ -66,6 +67,9 @@ "connection_error": { "message": "Error connecting to the Overseerr instance: {error}" }, + "auth_error": { + "message": "Invalid API key." + }, "not_loaded": { "message": "{target} is not loaded." }, diff --git a/tests/components/overseerr/test_config_flow.py b/tests/components/overseerr/test_config_flow.py index 487c843ff1c..937d697b8cb 100644 --- a/tests/components/overseerr/test_config_flow.py +++ b/tests/components/overseerr/test_config_flow.py @@ -3,7 +3,10 @@ from unittest.mock import AsyncMock, patch import pytest -from python_overseerr.exceptions import OverseerrConnectionError +from python_overseerr.exceptions import ( + OverseerrAuthenticationError, + OverseerrConnectionError, +) from homeassistant.components.overseerr.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -61,13 +64,22 @@ async def test_full_flow( } +@pytest.mark.parametrize( + ("exception", "error"), + [ + (OverseerrAuthenticationError, "invalid_auth"), + (OverseerrConnectionError, "cannot_connect"), + ], +) async def test_flow_errors( hass: HomeAssistant, mock_overseerr_client: AsyncMock, mock_setup_entry: AsyncMock, + exception: Exception, + error: str, ) -> None: """Test flow errors.""" - mock_overseerr_client.get_request_count.side_effect = OverseerrConnectionError() + mock_overseerr_client.get_request_count.side_effect = exception result = await hass.config_entries.flow.async_init( DOMAIN, @@ -82,7 +94,7 @@ async def test_flow_errors( ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": error} mock_overseerr_client.get_request_count.side_effect = None diff --git a/tests/components/overseerr/test_init.py b/tests/components/overseerr/test_init.py index 4c6897ed316..27c9f3fb3e9 100644 --- a/tests/components/overseerr/test_init.py +++ b/tests/components/overseerr/test_init.py @@ -4,6 +4,7 @@ from typing import Any from unittest.mock import AsyncMock, patch import pytest +from python_overseerr import OverseerrAuthenticationError, OverseerrConnectionError from python_overseerr.models import WebhookNotificationOptions from syrupy import SnapshotAssertion @@ -14,6 +15,7 @@ from homeassistant.components.overseerr import ( REGISTERED_NOTIFICATIONS, ) from homeassistant.components.overseerr.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -23,6 +25,28 @@ from tests.common import MockConfigEntry from tests.components.cloud import mock_cloud +@pytest.mark.parametrize( + ("exception", "config_entry_state"), + [ + (OverseerrAuthenticationError, ConfigEntryState.SETUP_ERROR), + (OverseerrConnectionError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_initialization_errors( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + config_entry_state: ConfigEntryState, +) -> None: + """Test the Overseerr integration initialization errors.""" + mock_overseerr_client.get_request_count.side_effect = exception + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state == config_entry_state + + async def test_device_info( hass: HomeAssistant, snapshot: SnapshotAssertion, From 06dc88f7b5cef151c49125df87bd197384bda6f1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 22 Jan 2025 14:05:55 +0100 Subject: [PATCH 0812/2987] Replace field keys in descriptions with translatable friendly names (#136230) Replace field keys in description with translatable names --- homeassistant/components/ecobee/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 8c636bd9b04..7713a8fb4b9 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -71,7 +71,7 @@ }, "start_date": { "name": "Start date", - "description": "Date the vacation starts in the YYYY-MM-DD format (optional, immediately if not provided along with start_time)." + "description": "Date the vacation starts in the YYYY-MM-DD format (optional, immediately if not provided along with 'Start time')." }, "start_time": { "name": "Start time", @@ -79,7 +79,7 @@ }, "end_date": { "name": "End date", - "description": "Date the vacation ends in the YYYY-MM-DD format (optional, 14 days from now if not provided along with end_time)." + "description": "Date the vacation ends in the YYYY-MM-DD format (optional, 14 days from now if not provided along with 'End time')." }, "end_time": { "name": "End time", @@ -149,11 +149,11 @@ }, "set_mic_mode": { "name": "Set mic mode", - "description": "Enables/disables Alexa mic (only for Ecobee 4).", + "description": "Enables/disables Alexa microphone (only for Ecobee 4).", "fields": { "mic_enabled": { "name": "Mic enabled", - "description": "Enable Alexa mic." + "description": "Enable Alexa microphone." } } }, From b90e3917a3c0942db9d41dfda9daa92ba2c564ed Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 22 Jan 2025 14:08:32 +0100 Subject: [PATCH 0813/2987] Bump PyViCare to 2.41.0 (#136231) --- 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 98ff6ce4c82..766cf22cb94 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.39.1"] + "requirements": ["PyViCare==2.41.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7b77238af8a..f6635f9ba4b 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.39.1 +PyViCare==2.41.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0dea8a85638..9c201695142 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.39.1 +PyViCare==2.41.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From 194d59df03d95019b39e849ed255a7d47e638286 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 22 Jan 2025 14:23:00 +0100 Subject: [PATCH 0814/2987] Add reauth flow to Overseerr (#136247) --- .../components/overseerr/config_flow.py | 68 ++++++++++++++---- .../components/overseerr/coordinator.py | 4 +- .../components/overseerr/quality_scale.yaml | 2 +- .../components/overseerr/strings.json | 11 ++- .../components/overseerr/test_config_flow.py | 71 +++++++++++++++++++ 5 files changed, 140 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/overseerr/config_flow.py b/homeassistant/components/overseerr/config_flow.py index e2994212bfe..6d765c6449e 100644 --- a/homeassistant/components/overseerr/config_flow.py +++ b/homeassistant/components/overseerr/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Overseerr.""" +from collections.abc import Mapping from typing import Any from python_overseerr import ( @@ -28,6 +29,25 @@ from .const import DOMAIN class OverseerrConfigFlow(ConfigFlow, domain=DOMAIN): """Overseerr config flow.""" + async def _check_connection( + self, host: str, port: int, ssl: bool, api_key: str + ) -> str | None: + """Check if we can connect to the Overseerr instance.""" + client = OverseerrClient( + host, + port, + api_key, + ssl=ssl, + session=async_get_clientsession(self.hass), + ) + try: + await client.get_request_count() + except OverseerrAuthenticationError: + return "invalid_auth" + except OverseerrError: + return "cannot_connect" + return None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -41,19 +61,11 @@ class OverseerrConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_HOST: host}) port = url.port assert port - client = OverseerrClient( - host, - port, - user_input[CONF_API_KEY], - ssl=url.scheme == "https", - session=async_get_clientsession(self.hass), + error = await self._check_connection( + host, port, url.scheme == "https", user_input[CONF_API_KEY] ) - try: - await client.get_request_count() - except OverseerrAuthenticationError: - errors["base"] = "invalid_auth" - except OverseerrError: - errors["base"] = "cannot_connect" + if error: + errors["base"] = error else: return self.async_create_entry( title="Overseerr", @@ -72,3 +84,35 @@ class OverseerrConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reauth( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-auth.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-auth confirmation.""" + errors: dict[str, str] = {} + if user_input: + entry = self._get_reauth_entry() + error = await self._check_connection( + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_SSL], + user_input[CONF_API_KEY], + ) + if error: + errors["base"] = error + else: + return self.async_update_reload_and_abort( + entry, + data={**entry.data, CONF_API_KEY: user_input[CONF_API_KEY]}, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) diff --git a/homeassistant/components/overseerr/coordinator.py b/homeassistant/components/overseerr/coordinator.py index 75a7d8d73d7..56002ddf558 100644 --- a/homeassistant/components/overseerr/coordinator.py +++ b/homeassistant/components/overseerr/coordinator.py @@ -13,7 +13,7 @@ from yarl import URL from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -53,7 +53,7 @@ class OverseerrCoordinator(DataUpdateCoordinator[RequestCount]): try: return await self.client.get_request_count() except OverseerrAuthenticationError as err: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="auth_error", ) from err diff --git a/homeassistant/components/overseerr/quality_scale.yaml b/homeassistant/components/overseerr/quality_scale.yaml index dfb794476aa..ffd03ed4a09 100644 --- a/homeassistant/components/overseerr/quality_scale.yaml +++ b/homeassistant/components/overseerr/quality_scale.yaml @@ -37,7 +37,7 @@ rules: status: done comment: Handled by the coordinator parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: todo # Gold devices: done diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index 25b53303611..8aa0ff7fe10 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -10,10 +10,19 @@ "url": "The URL of the Overseerr instance.", "api_key": "The API key of the Overseerr instance." } + }, + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::overseerr::config::step::user::data_description::api_key%]" + } } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/tests/components/overseerr/test_config_flow.py b/tests/components/overseerr/test_config_flow.py index 937d697b8cb..3227ffc6862 100644 --- a/tests/components/overseerr/test_config_flow.py +++ b/tests/components/overseerr/test_config_flow.py @@ -155,3 +155,74 @@ async def test_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "new-test-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_config_entry.data[CONF_API_KEY] == "new-test-key" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (OverseerrAuthenticationError, "invalid_auth"), + (OverseerrConnectionError, "cannot_connect"), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reauth flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_overseerr_client.get_request_count.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "new-test-key"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_overseerr_client.get_request_count.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "new-test-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_config_entry.data[CONF_API_KEY] == "new-test-key" From 4c8b4b36e5182ba5bfb9ecde0a125cf0ba20db18 Mon Sep 17 00:00:00 2001 From: Huyuwei Date: Wed, 22 Jan 2025 21:27:13 +0800 Subject: [PATCH 0815/2987] Record IQS for Switchbot (#136058) Co-authored-by: Joost Lekkerkerker --- .../components/switchbot/quality_scale.yaml | 96 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/switchbot/quality_scale.yaml diff --git a/homeassistant/components/switchbot/quality_scale.yaml b/homeassistant/components/switchbot/quality_scale.yaml new file mode 100644 index 00000000000..3b8976aeb8e --- /dev/null +++ b/homeassistant/components/switchbot/quality_scale.yaml @@ -0,0 +1,96 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: todo + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: + status: todo + comment: | + set `PARALLEL_UPDATES` in lock.py + reauthentication-flow: todo + test-coverage: + status: todo + comment: | + Consider using snapshots for fixating all the entities a device creates. + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + No network discovery. + discovery: + status: done + comment: | + Can be improved: Device type scan filtering is applied to only show devices that are actually supported. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Only one device per config entry. New devices are set up as new entries. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: + status: todo + comment: | + Needs to provide translations for hub2 temperature entity + exception-translations: todo + icon-translations: + status: exempt + comment: | + No custom icons. + reconfiguration-flow: + status: exempt + comment: | + No need for reconfiguration flow. + repair-issues: + status: exempt + comment: | + No repairs/issues. + stale-devices: + status: exempt + comment: | + Device type integration. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 357ca0b1050..3732101913c 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -978,7 +978,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "swisscom", "switch_as_x", "switchbee", - "switchbot", "switchbot_cloud", "switcher_kis", "switchmate", From eb20a00aa2c60c74a24a042badae019c50c4a001 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 22 Jan 2025 14:55:17 +0100 Subject: [PATCH 0816/2987] Add reconfigure flow to Overseerr (#136248) --- .../components/overseerr/config_flow.py | 26 ++++++- .../components/overseerr/quality_scale.yaml | 2 +- .../components/overseerr/strings.json | 3 +- .../components/overseerr/test_config_flow.py | 74 +++++++++++++++++++ 4 files changed, 99 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/overseerr/config_flow.py b/homeassistant/components/overseerr/config_flow.py index 6d765c6449e..9a8bdd1676f 100644 --- a/homeassistant/components/overseerr/config_flow.py +++ b/homeassistant/components/overseerr/config_flow.py @@ -12,7 +12,7 @@ import voluptuous as vol from yarl import URL from homeassistant.components.webhook import async_generate_id -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -67,14 +67,26 @@ class OverseerrConfigFlow(ConfigFlow, domain=DOMAIN): if error: errors["base"] = error else: - return self.async_create_entry( - title="Overseerr", + if self.source == SOURCE_USER: + return self.async_create_entry( + title="Overseerr", + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_SSL: url.scheme == "https", + CONF_API_KEY: user_input[CONF_API_KEY], + CONF_WEBHOOK_ID: async_generate_id(), + }, + ) + reconfigure_entry = self._get_reconfigure_entry() + return self.async_update_reload_and_abort( + reconfigure_entry, data={ + **reconfigure_entry.data, CONF_HOST: host, CONF_PORT: port, CONF_SSL: url.scheme == "https", CONF_API_KEY: user_input[CONF_API_KEY], - CONF_WEBHOOK_ID: async_generate_id(), }, ) return self.async_show_form( @@ -116,3 +128,9 @@ class OverseerrConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), errors=errors, ) + + async def async_step_reconfigure( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + return await self.async_step_user() diff --git a/homeassistant/components/overseerr/quality_scale.yaml b/homeassistant/components/overseerr/quality_scale.yaml index ffd03ed4a09..f42457ee23f 100644 --- a/homeassistant/components/overseerr/quality_scale.yaml +++ b/homeassistant/components/overseerr/quality_scale.yaml @@ -67,7 +67,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index 8aa0ff7fe10..5053bcedc41 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -22,7 +22,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/tests/components/overseerr/test_config_flow.py b/tests/components/overseerr/test_config_flow.py index 3227ffc6862..6a3b086a8e2 100644 --- a/tests/components/overseerr/test_config_flow.py +++ b/tests/components/overseerr/test_config_flow.py @@ -226,3 +226,77 @@ async def test_reauth_flow_errors( assert result["reason"] == "reauth_successful" assert mock_config_entry.data[CONF_API_KEY] == "new-test-key" + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr2.test", CONF_API_KEY: "new-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_HOST: "overseerr2.test", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: "new-key", + CONF_WEBHOOK_ID: WEBHOOK_ID, + } + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (OverseerrAuthenticationError, "invalid_auth"), + (OverseerrConnectionError, "cannot_connect"), + ], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reconfigure flow errors.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_overseerr_client.get_request_count.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr2.test", CONF_API_KEY: "new-key"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_overseerr_client.get_request_count.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr2.test", CONF_API_KEY: "new-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" From 7a78f87fa6a08d71ee880e7d3985221a39788ec6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 22 Jan 2025 15:17:57 +0100 Subject: [PATCH 0817/2987] Clean up attributes of Overseerr event entity (#136251) --- homeassistant/components/overseerr/event.py | 19 ++++++++++++++++++- .../overseerr/snapshots/test_event.ambr | 15 ++++++--------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/overseerr/event.py b/homeassistant/components/overseerr/event.py index b1b2efd6ec5..9dbfe37080b 100644 --- a/homeassistant/components/overseerr/event.py +++ b/homeassistant/components/overseerr/event.py @@ -55,6 +55,8 @@ async def async_setup_entry( class OverseerrEvent(OverseerrEntity, EventEntity): """Defines a Overseerr event entity.""" + entity_description: OverseerrEventEntityDescription + def __init__( self, coordinator: OverseerrCoordinator, @@ -76,7 +78,11 @@ class OverseerrEvent(OverseerrEntity, EventEntity): """Handle incoming event.""" event_type = event["notification_type"].lower() if event_type.split("_")[0] == self.entity_description.key: - self._trigger_event(event_type[6:], event) + self._attr_entity_picture = event.get("image") + self._trigger_event( + event_type[6:], + parse_event(event, self.entity_description.nullable_fields), + ) self.async_write_ha_state() @callback @@ -94,6 +100,17 @@ class OverseerrEvent(OverseerrEntity, EventEntity): def parse_event(event: dict[str, Any], nullable_fields: list[str]) -> dict[str, Any]: """Parse event.""" event.pop("notification_type") + event.pop("image") for field in nullable_fields: event.pop(field) + if (media := event.get("media")) is not None: + for field in ("status", "status4k"): + media[field] = media[field].lower() + for field in ("tmdb_id", "tvdb_id"): + if (value := media.get(field)) != "": + media[field] = int(value) + else: + media[field] = None + if (request := event.get("request")) is not None: + request["request_id"] = int(request["request_id"]) return event diff --git a/tests/components/overseerr/snapshots/test_event.ambr b/tests/components/overseerr/snapshots/test_event.ambr index 9bf23efb8f6..1002bc4cdad 100644 --- a/tests/components/overseerr/snapshots/test_event.ambr +++ b/tests/components/overseerr/snapshots/test_event.ambr @@ -44,7 +44,7 @@ # name: test_entities[event.overseerr_last_media_event-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'comment': None, + 'entity_picture': 'https://image.tmdb.org/t/p/w600_and_h900_bestv2/something.jpg', 'event_type': 'auto_approved', 'event_types': list([ 'pending', @@ -55,19 +55,16 @@ 'auto_approved', ]), 'friendly_name': 'Overseerr Last media event', - 'image': 'https://image.tmdb.org/t/p/w600_and_h900_bestv2/something.jpg', - 'issue': None, 'media': dict({ 'media_type': 'movie', - 'status': 'PENDING', - 'status4k': 'UNKNOWN', - 'tmdb_id': '123', - 'tvdb_id': '', + 'status': 'pending', + 'status4k': 'unknown', + 'tmdb_id': 123, + 'tvdb_id': None, }), 'message': 'Here is an interesting Linux ISO that was automatically approved.', - 'notification_type': 'MEDIA_AUTO_APPROVED', 'request': dict({ - 'request_id': '16', + 'request_id': 16, 'requested_by_avatar': 'https://plex.tv/users/abc/avatar?c=123', 'requested_by_email': 'my@email.com', 'requested_by_settings_discord_id': '123', From 3bbd7daa7f14c36349b1e814a72339f798ffcb89 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Jan 2025 16:27:01 +0100 Subject: [PATCH 0818/2987] Improve type hints in template helper (#136253) --- homeassistant/helpers/template.py | 34 ++++++++++++++++++------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index fac03300bdc..7866250d658 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1735,7 +1735,7 @@ def label_entities(hass: HomeAssistant, label_id_or_name: str) -> Iterable[str]: return [entry.entity_id for entry in entries] -def closest(hass, *args): +def closest(hass: HomeAssistant, *args: Any) -> State | None: """Find closest entity. Closest to home: @@ -1775,21 +1775,24 @@ def closest(hass, *args): ) return None - latitude = point_state.attributes.get(ATTR_LATITUDE) - longitude = point_state.attributes.get(ATTR_LONGITUDE) + latitude = point_state.attributes[ATTR_LATITUDE] + longitude = point_state.attributes[ATTR_LONGITUDE] entities = args[1] else: - latitude = convert(args[0], float) - longitude = convert(args[1], float) + latitude_arg = convert(args[0], float) + longitude_arg = convert(args[1], float) - if latitude is None or longitude is None: + if latitude_arg is None or longitude_arg is None: _LOGGER.warning( "Closest:Received invalid coordinates: %s, %s", args[0], args[1] ) return None + latitude = latitude_arg + longitude = longitude_arg + entities = args[2] states = expand(hass, entities) @@ -1798,20 +1801,20 @@ def closest(hass, *args): return loc_helper.closest(latitude, longitude, states) -def closest_filter(hass, *args): +def closest_filter(hass: HomeAssistant, *args: Any) -> State | None: """Call closest as a filter. Need to reorder arguments.""" new_args = list(args[1:]) new_args.append(args[0]) return closest(hass, *new_args) -def distance(hass, *args): +def distance(hass: HomeAssistant, *args: Any) -> float | None: """Calculate distance. Will calculate distance from home to a point or between points. Points can be passed in using state objects or lat/lng coordinates. """ - locations = [] + locations: list[tuple[float, float]] = [] to_process = list(args) @@ -1831,10 +1834,10 @@ def distance(hass, *args): return None value_2 = to_process.pop(0) - latitude = convert(value, float) - longitude = convert(value_2, float) + latitude_to_process = convert(value, float) + longitude_to_process = convert(value_2, float) - if latitude is None or longitude is None: + if latitude_to_process is None or longitude_to_process is None: _LOGGER.warning( "Distance:Unable to process latitude and longitude: %s, %s", value, @@ -1842,6 +1845,9 @@ def distance(hass, *args): ) return None + latitude = latitude_to_process + longitude = longitude_to_process + else: if not loc_helper.has_location(point_state): _LOGGER.warning( @@ -1849,8 +1855,8 @@ def distance(hass, *args): ) return None - latitude = point_state.attributes.get(ATTR_LATITUDE) - longitude = point_state.attributes.get(ATTR_LONGITUDE) + latitude = point_state.attributes[ATTR_LATITUDE] + longitude = point_state.attributes[ATTR_LONGITUDE] locations.append((latitude, longitude)) From 4e494aa393b93e433739ac69b3982343c8d8478b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Wed, 22 Jan 2025 18:41:58 +0100 Subject: [PATCH 0819/2987] Allow multiple Airzone entries with different System IDs (#135397) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/airzone/__init__.py | 24 ++++++++- .../components/airzone/config_flow.py | 9 +++- .../airzone/snapshots/test_diagnostics.ambr | 3 +- tests/components/airzone/test_config_flow.py | 14 +++--- tests/components/airzone/test_coordinator.py | 2 + tests/components/airzone/test_init.py | 50 ++++++++++++++++++- tests/components/airzone/util.py | 11 +++- 7 files changed, 100 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/airzone/__init__.py b/homeassistant/components/airzone/__init__.py index 39e4f73aa38..aa168dce858 100644 --- a/homeassistant/components/airzone/__init__.py +++ b/homeassistant/components/airzone/__init__.py @@ -86,7 +86,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b options = ConnectionOptions( entry.data[CONF_HOST], entry.data[CONF_PORT], - entry.data.get(CONF_ID, DEFAULT_SYSTEM_ID), + entry.data[CONF_ID], ) airzone = AirzoneLocalApi(aiohttp_client.async_get_clientsession(hass), options) @@ -120,3 +120,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> bool: + """Migrate an old entry.""" + if entry.version == 1 and entry.minor_version < 2: + # Add missing CONF_ID + system_id = entry.data.get(CONF_ID, DEFAULT_SYSTEM_ID) + new_data = entry.data.copy() + new_data[CONF_ID] = system_id + hass.config_entries.async_update_entry( + entry, + data=new_data, + minor_version=2, + ) + + _LOGGER.info( + "Migration to configuration version %s.%s successful", + entry.version, + entry.minor_version, + ) + + return True diff --git a/homeassistant/components/airzone/config_flow.py b/homeassistant/components/airzone/config_flow.py index b0a87dd4e57..c4088e950e9 100644 --- a/homeassistant/components/airzone/config_flow.py +++ b/homeassistant/components/airzone/config_flow.py @@ -44,6 +44,7 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN): _discovered_ip: str | None = None _discovered_mac: str | None = None + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -53,6 +54,9 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: + if CONF_ID not in user_input: + user_input[CONF_ID] = DEFAULT_SYSTEM_ID + self._async_abort_entries_match(user_input) airzone = AirzoneLocalApi( @@ -60,7 +64,7 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN): ConnectionOptions( user_input[CONF_HOST], user_input[CONF_PORT], - user_input.get(CONF_ID, DEFAULT_SYSTEM_ID), + user_input[CONF_ID], ), ) @@ -84,6 +88,9 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN): ) title = f"Airzone {user_input[CONF_HOST]}:{user_input[CONF_PORT]}" + if user_input[CONF_ID] != DEFAULT_SYSTEM_ID: + title += f" #{user_input[CONF_ID]}" + return self.async_create_entry(title=title, data=user_input) return self.async_show_form( diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index bb44a0abeb1..0c3c0ba7c7a 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -275,6 +275,7 @@ 'config_entry': dict({ 'data': dict({ 'host': '192.168.1.100', + 'id': 0, 'port': 3000, }), 'disabled_by': None, @@ -282,7 +283,7 @@ }), 'domain': 'airzone', 'entry_id': '6e7a0798c1734ba81d26ced0e690eaec', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/airzone/test_config_flow.py b/tests/components/airzone/test_config_flow.py index 9bc0a8cedbd..65897c6da7e 100644 --- a/tests/components/airzone/test_config_flow.py +++ b/tests/components/airzone/test_config_flow.py @@ -28,6 +28,7 @@ from .util import ( HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK, + USER_INPUT, ) from tests.common import MockConfigEntry @@ -81,7 +82,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result["errors"] == {} result = await hass.config_entries.flow.async_configure( - result["flow_id"], CONFIG + result["flow_id"], USER_INPUT ) await hass.async_block_till_done() @@ -94,7 +95,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result["title"] == f"Airzone {CONFIG[CONF_HOST]}:{CONFIG[CONF_PORT]}" assert result["data"][CONF_HOST] == CONFIG[CONF_HOST] assert result["data"][CONF_PORT] == CONFIG[CONF_PORT] - assert CONF_ID not in result["data"] + assert result["data"][CONF_ID] == CONFIG[CONF_ID] assert len(mock_setup_entry.mock_calls) == 1 @@ -129,7 +130,7 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT ) assert result["type"] is FlowResultType.FORM @@ -154,7 +155,7 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert ( result["title"] - == f"Airzone {CONFIG_ID1[CONF_HOST]}:{CONFIG_ID1[CONF_PORT]}" + == f"Airzone {CONFIG_ID1[CONF_HOST]}:{CONFIG_ID1[CONF_PORT]} #{CONFIG_ID1[CONF_ID]}" ) assert result["data"][CONF_HOST] == CONFIG_ID1[CONF_HOST] assert result["data"][CONF_PORT] == CONFIG_ID1[CONF_PORT] @@ -167,6 +168,7 @@ async def test_form_duplicated_id(hass: HomeAssistant) -> None: """Test setting up duplicated entry.""" config_entry = MockConfigEntry( + minor_version=2, data=CONFIG, domain=DOMAIN, unique_id="airzone_unique_id", @@ -174,7 +176,7 @@ async def test_form_duplicated_id(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT ) assert result["type"] is FlowResultType.ABORT @@ -189,7 +191,7 @@ async def test_connection_error(hass: HomeAssistant) -> None: side_effect=AirzoneError, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT ) assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/airzone/test_coordinator.py b/tests/components/airzone/test_coordinator.py index 583758a6bee..fcdcad6a32a 100644 --- a/tests/components/airzone/test_coordinator.py +++ b/tests/components/airzone/test_coordinator.py @@ -25,6 +25,7 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: """Test ClientConnectorError on coordinator update.""" config_entry = MockConfigEntry( + minor_version=2, data=CONFIG, domain=DOMAIN, unique_id="airzone_unique_id", @@ -74,6 +75,7 @@ async def test_coordinator_new_devices( """Test new devices on coordinator update.""" config_entry = MockConfigEntry( + minor_version=2, data=CONFIG, domain=DOMAIN, unique_id="airzone_unique_id", diff --git a/tests/components/airzone/test_init.py b/tests/components/airzone/test_init.py index 293fc75acb5..a2783cb7c2f 100644 --- a/tests/components/airzone/test_init.py +++ b/tests/components/airzone/test_init.py @@ -2,14 +2,16 @@ from unittest.mock import patch +from aioairzone.const import DEFAULT_SYSTEM_ID from aioairzone.exceptions import HotWaterNotAvailable, InvalidMethod, SystemOutOfRange from homeassistant.components.airzone.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .util import CONFIG, HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK +from .util import CONFIG, HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK, USER_INPUT from tests.common import MockConfigEntry @@ -19,7 +21,11 @@ async def test_unique_id_migrate( ) -> None: """Test unique id migration.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG) + config_entry = MockConfigEntry( + minor_version=2, + domain=DOMAIN, + data=CONFIG, + ) config_entry.add_to_hass(hass) with ( @@ -89,6 +95,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: """Test unload.""" config_entry = MockConfigEntry( + minor_version=2, data=CONFIG, domain=DOMAIN, unique_id="airzone_unique_id", @@ -112,3 +119,42 @@ async def test_unload_entry(hass: HomeAssistant) -> None: await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_migrate_entry_v2(hass: HomeAssistant) -> None: + """Test entry migration to v2.""" + + config_entry = MockConfigEntry( + minor_version=1, + data=USER_INPUT, + domain=DOMAIN, + ) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + side_effect=HotWaterNotAvailable, + ), + patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", + return_value=HVAC_MOCK, + ), + patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", + side_effect=SystemOutOfRange, + ), + patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, + ), + patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", + side_effect=InvalidMethod, + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.minor_version == 2 + assert config_entry.data.get(CONF_ID) == DEFAULT_SYSTEM_ID diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index b51dfb890e4..50d1964924d 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -55,6 +55,7 @@ from aioairzone.const import ( API_WS_AZ, API_WS_TYPE, API_ZONE_ID, + DEFAULT_SYSTEM_ID, ) from homeassistant.components.airzone.const import DOMAIN @@ -63,13 +64,18 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -CONFIG = { +USER_INPUT = { CONF_HOST: "192.168.1.100", CONF_PORT: 3000, } +CONFIG = { + **USER_INPUT, + CONF_ID: DEFAULT_SYSTEM_ID, +} + CONFIG_ID1 = { - **CONFIG, + **USER_INPUT, CONF_ID: 1, } @@ -359,6 +365,7 @@ async def async_init_integration( """Set up the Airzone integration in Home Assistant.""" config_entry = MockConfigEntry( + minor_version=2, data=CONFIG, entry_id="6e7a0798c1734ba81d26ced0e690eaec", domain=DOMAIN, From ea9be01c7c960d6ba0413a7326e46d703de32c59 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Jan 2025 19:01:46 +0100 Subject: [PATCH 0820/2987] Indicate in WS API when scheduling additional automatic backup (#136155) --- homeassistant/components/backup/config.py | 9 +++ homeassistant/components/backup/websocket.py | 4 +- .../backup/snapshots/test_backup.ambr | 5 ++ .../backup/snapshots/test_websocket.ambr | 70 +++++++++++++++++++ tests/components/backup/test_manager.py | 12 ++++ tests/components/backup/test_websocket.py | 16 +++++ tests/components/cloud/test_backup.py | 1 + 7 files changed, 116 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index bcfa95463d1..8edd6cf0f2b 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -320,6 +320,7 @@ class BackupSchedule: time: dt.time | None = None cron_event: CronSim | None = field(init=False, default=None) next_automatic_backup: datetime | None = field(init=False, default=None) + next_automatic_backup_additional = False @callback def apply( @@ -378,6 +379,14 @@ class BackupSchedule: # add a day to the next time to avoid scheduling at the same time again self.cron_event = CronSim(cron_pattern, now + timedelta(days=1)) + # Compare the computed next time with the next time from the cron pattern + # to determine if an additional backup has been scheduled + cron_event_configured = CronSim(cron_pattern, now) + next_configured_time = next(cron_event_configured) + self.next_automatic_backup_additional = next_time < next_configured_time + else: + self.next_automatic_backup_additional = False + async def _create_backup(now: datetime) -> None: """Create backup.""" manager.remove_next_backup_event = None diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 672dd5ebb13..70fc568c05c 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -61,6 +61,7 @@ async def handle_info( "last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup, "last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup, "next_automatic_backup": manager.config.data.schedule.next_automatic_backup, + "next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional, }, ) @@ -329,7 +330,8 @@ async def handle_config_info( { "config": config | { - "next_automatic_backup": manager.config.data.schedule.next_automatic_backup + "next_automatic_backup": manager.config.data.schedule.next_automatic_backup, + "next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional, } }, ) diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index f1208877690..f91473e3b70 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -84,6 +84,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -114,6 +115,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -144,6 +146,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -174,6 +177,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -204,6 +208,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 2c88dc50577..43b4c1260dd 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -245,6 +245,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -284,6 +285,7 @@ 'last_attempted_automatic_backup': '2024-10-26T04:45:00+01:00', 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00', 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': 3, 'days': 7, @@ -326,6 +328,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': 3, 'days': None, @@ -361,6 +364,7 @@ 'last_attempted_automatic_backup': '2024-10-27T04:45:00+01:00', 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00', 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': 7, @@ -396,6 +400,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': '2024-11-18T04:55:00+01:00', + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -432,6 +437,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -467,6 +473,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -503,6 +510,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -538,6 +546,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': 7, @@ -609,6 +618,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -644,6 +654,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': 3, 'days': None, @@ -715,6 +726,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -750,6 +762,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': 7, @@ -821,6 +834,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -856,6 +870,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': '2024-11-14T06:00:00+01:00', + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -927,6 +942,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -962,6 +978,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': '2024-11-18T04:55:00+01:00', + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -1035,6 +1052,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -1070,6 +1088,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -1141,6 +1160,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -1176,6 +1196,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -1251,6 +1272,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -1290,6 +1312,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -1365,6 +1388,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -1400,6 +1424,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': 3, 'days': 7, @@ -1471,6 +1496,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -1506,6 +1532,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -1577,6 +1604,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -1612,6 +1640,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': 3, 'days': None, @@ -1683,6 +1712,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -1718,6 +1748,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': 7, @@ -1789,6 +1820,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -1823,6 +1855,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -1857,6 +1890,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -1891,6 +1925,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -1925,6 +1960,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -1959,6 +1995,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -1993,6 +2030,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -2027,6 +2065,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -2061,6 +2100,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -2095,6 +2135,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -2129,6 +2170,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -2163,6 +2205,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -2197,6 +2240,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -2231,6 +2275,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -2265,6 +2310,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -2299,6 +2345,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -2333,6 +2380,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -2367,6 +2415,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, @@ -2394,6 +2443,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -2421,6 +2471,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -2464,6 +2515,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -2491,6 +2543,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -2534,6 +2587,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -2588,6 +2642,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -2626,6 +2681,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -2675,6 +2731,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -2719,6 +2776,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -2773,6 +2831,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -2828,6 +2887,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -2884,6 +2944,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -2938,6 +2999,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -2992,6 +3054,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -3046,6 +3109,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -3101,6 +3165,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -3546,6 +3611,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -3589,6 +3655,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -3633,6 +3700,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -3698,6 +3766,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', @@ -3742,6 +3811,7 @@ 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 4c7eaf634b3..b7a4291fb60 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -275,6 +275,7 @@ async def test_async_initiate_backup( "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, "next_automatic_backup": None, + "next_automatic_backup_additional": False, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -521,6 +522,7 @@ async def test_async_initiate_backup_with_agent_error( "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, "next_automatic_backup": None, + "next_automatic_backup_additional": False, } await ws_client.send_json_auto_id( @@ -616,6 +618,7 @@ async def test_async_initiate_backup_with_agent_error( "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, "next_automatic_backup": None, + "next_automatic_backup_additional": False, } await hass.async_block_till_done() @@ -884,6 +887,7 @@ async def test_async_initiate_backup_non_agent_upload_error( "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, "next_automatic_backup": None, + "next_automatic_backup_additional": False, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -995,6 +999,7 @@ async def test_async_initiate_backup_with_task_error( "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, "next_automatic_backup": None, + "next_automatic_backup_additional": False, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1100,6 +1105,7 @@ async def test_initiate_backup_file_error( "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, "next_automatic_backup": None, + "next_automatic_backup_additional": False, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1621,6 +1627,7 @@ async def test_receive_backup_agent_error( "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, "next_automatic_backup": None, + "next_automatic_backup_additional": False, } await ws_client.send_json_auto_id( @@ -1699,6 +1706,7 @@ async def test_receive_backup_agent_error( "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, "next_automatic_backup": None, + "next_automatic_backup_additional": False, } await hass.async_block_till_done() @@ -1760,6 +1768,7 @@ async def test_receive_backup_non_agent_upload_error( "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, "next_automatic_backup": None, + "next_automatic_backup_additional": False, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1881,6 +1890,7 @@ async def test_receive_backup_file_write_error( "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, "next_automatic_backup": None, + "next_automatic_backup_additional": False, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1990,6 +2000,7 @@ async def test_receive_backup_read_tar_error( "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, "next_automatic_backup": None, + "next_automatic_backup_additional": False, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -2158,6 +2169,7 @@ async def test_receive_backup_file_read_error( "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, "next_automatic_backup": None, + "next_automatic_backup_additional": False, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 44a470053a5..52c04474162 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -1310,6 +1310,7 @@ async def test_config_update_errors( "attempted_backup_time", "completed_backup_time", "scheduled_backup_time", + "additional_backup", "backup_calls_1", "backup_calls_2", "call_args", @@ -1325,6 +1326,7 @@ async def test_config_update_errors( "2024-11-12T04:55:00+01:00", "2024-11-12T04:55:00+01:00", "2024-11-12T04:55:00+01:00", + False, 1, 2, BACKUP_CALL, @@ -1345,6 +1347,7 @@ async def test_config_update_errors( "2024-11-12T04:55:00+01:00", "2024-11-12T04:55:00+01:00", "2024-11-12T04:55:00+01:00", + False, 1, 2, BACKUP_CALL, @@ -1364,6 +1367,7 @@ async def test_config_update_errors( "2024-11-18T04:55:00+01:00", "2024-11-18T04:55:00+01:00", "2024-11-18T04:55:00+01:00", + False, 1, 2, BACKUP_CALL, @@ -1387,6 +1391,7 @@ async def test_config_update_errors( "2024-11-18T03:45:00+01:00", "2024-11-18T03:45:00+01:00", "2024-11-18T03:45:00+01:00", + False, 1, 2, BACKUP_CALL, @@ -1406,6 +1411,7 @@ async def test_config_update_errors( "2024-11-12T03:45:00+01:00", "2024-11-12T03:45:00+01:00", "2024-11-12T03:45:00+01:00", + False, 1, 2, BACKUP_CALL, @@ -1425,6 +1431,7 @@ async def test_config_update_errors( "2024-11-13T04:55:00+01:00", "2024-11-13T04:55:00+01:00", "2024-11-13T04:55:00+01:00", + False, 1, 2, BACKUP_CALL, @@ -1444,6 +1451,7 @@ async def test_config_update_errors( "2024-11-11T04:45:00+01:00", "2024-11-11T04:45:00+01:00", None, + False, 0, 0, None, @@ -1463,6 +1471,7 @@ async def test_config_update_errors( "2024-11-11T04:45:00+01:00", "2024-11-11T04:45:00+01:00", None, + False, 0, 0, None, @@ -1482,6 +1491,7 @@ async def test_config_update_errors( "2024-11-12T04:55:00+01:00", "2024-11-12T04:55:00+01:00", "2024-11-12T04:55:00+01:00", + False, 1, 2, BACKUP_CALL, @@ -1501,6 +1511,7 @@ async def test_config_update_errors( "2024-11-12T04:55:00+01:00", # missed event uses daily schedule once "2024-11-12T04:55:00+01:00", # missed event uses daily schedule once "2024-11-12T04:55:00+01:00", + True, 1, 1, BACKUP_CALL, @@ -1520,6 +1531,7 @@ async def test_config_update_errors( "2024-10-26T04:45:00+01:00", "2024-10-26T04:45:00+01:00", None, + False, 0, 0, None, @@ -1539,6 +1551,7 @@ async def test_config_update_errors( "2024-11-12T04:55:00+01:00", # attempted to create backup but failed "2024-11-11T04:45:00+01:00", "2024-11-12T04:55:00+01:00", + False, 1, 2, BACKUP_CALL, @@ -1558,6 +1571,7 @@ async def test_config_update_errors( "2024-11-12T04:55:00+01:00", # attempted to create backup but failed "2024-11-11T04:45:00+01:00", "2024-11-12T04:55:00+01:00", + False, 1, 2, BACKUP_CALL, @@ -1579,6 +1593,7 @@ async def test_config_schedule_logic( attempted_backup_time: str, completed_backup_time: str, scheduled_backup_time: str, + additional_backup: bool, backup_calls_1: int, backup_calls_2: int, call_args: Any, @@ -1630,6 +1645,7 @@ async def test_config_schedule_logic( await client.send_json_auto_id({"type": "backup/info"}) result = await client.receive_json() assert result["result"]["next_automatic_backup"] == scheduled_backup_time + assert result["result"]["next_automatic_backup_additional"] == additional_backup freezer.move_to(time_1) async_fire_time_changed(hass) diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 112e71ec2db..db742525a48 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -205,6 +205,7 @@ async def test_agents_list_backups_fail_cloud( "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, "next_automatic_backup": None, + "next_automatic_backup_additional": False, } From ad205aeea3e89ecc9e764bfc6fa7a6be59b68f01 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Wed, 22 Jan 2025 18:29:08 +0000 Subject: [PATCH 0821/2987] Bump ohmepy to 1.2.4 (#136270) --- homeassistant/components/ohme/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index 935975502d0..98c738cea3c 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.3"] + "requirements": ["ohme==1.2.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index f6635f9ba4b..f720eb80236 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1540,7 +1540,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.2.3 +ohme==1.2.4 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c201695142..c9b9902a282 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1288,7 +1288,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.2.3 +ohme==1.2.4 # homeassistant.components.ollama ollama==0.4.7 From 9f2a6af1ecb8bb9c3a39f6a81acdc7b258e1f839 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 22 Jan 2025 19:58:48 +0100 Subject: [PATCH 0822/2987] Only add Overseerr event if we are push based (#136258) --- .../components/overseerr/__init__.py | 7 +-- .../components/overseerr/coordinator.py | 1 + homeassistant/components/overseerr/event.py | 17 ++++-- tests/components/overseerr/test_event.py | 61 +++++++++++++++++++ tests/components/overseerr/test_init.py | 43 ++++++++++++- 5 files changed, 120 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/overseerr/__init__.py b/homeassistant/components/overseerr/__init__.py index e4ac712e053..597d44f66cf 100644 --- a/homeassistant/components/overseerr/__init__.py +++ b/homeassistant/components/overseerr/__init__.py @@ -116,15 +116,13 @@ class OverseerrWebhookManager: allowed_methods=[METH_POST], ) if not await self.check_need_change(): + self.entry.runtime_data.push = True return for url in self.webhook_urls: if await self.test_and_set_webhook(url): return LOGGER.info("Failed to register Overseerr webhook") - if ( - cloud.async_active_subscription(self.hass) - and CONF_CLOUDHOOK_URL not in self.entry.data - ): + if cloud.async_active_subscription(self.hass): LOGGER.info("Trying to register a cloudhook URL") url = await _async_cloudhook_generate_url(self.hass, self.entry) if await self.test_and_set_webhook(url): @@ -151,6 +149,7 @@ class OverseerrWebhookManager: webhook_url=url, json_payload=JSON_PAYLOAD, ) + self.entry.runtime_data.push = True return True return False diff --git a/homeassistant/components/overseerr/coordinator.py b/homeassistant/components/overseerr/coordinator.py index 56002ddf558..2149dcbec7c 100644 --- a/homeassistant/components/overseerr/coordinator.py +++ b/homeassistant/components/overseerr/coordinator.py @@ -47,6 +47,7 @@ class OverseerrCoordinator(DataUpdateCoordinator[RequestCount]): session=async_get_clientsession(hass), ) self.url = URL.build(host=host, port=port, scheme="https" if ssl else "http") + self.push = False async def _async_update_data(self) -> RequestCount: """Fetch data from API endpoint.""" diff --git a/homeassistant/components/overseerr/event.py b/homeassistant/components/overseerr/event.py index 9dbfe37080b..589a80c5404 100644 --- a/homeassistant/components/overseerr/event.py +++ b/homeassistant/components/overseerr/event.py @@ -4,11 +4,13 @@ from dataclasses import dataclass from typing import Any from homeassistant.components.event import EventEntity, EventEntityDescription +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EVENT_KEY +from . import DOMAIN, EVENT_KEY from .coordinator import OverseerrConfigEntry, OverseerrCoordinator from .entity import OverseerrEntity @@ -47,10 +49,17 @@ async def async_setup_entry( """Set up Overseerr sensor entities based on a config entry.""" coordinator = entry.runtime_data - async_add_entities( - OverseerrEvent(coordinator, description) for description in EVENTS + ent_reg = er.async_get(hass) + + event_entities_setup_before = ent_reg.async_get_entity_id( + Platform.EVENT, DOMAIN, f"{entry.entry_id}-media" ) + if coordinator.push or event_entities_setup_before: + async_add_entities( + OverseerrEvent(coordinator, description) for description in EVENTS + ) + class OverseerrEvent(OverseerrEntity, EventEntity): """Defines a Overseerr event entity.""" @@ -94,7 +103,7 @@ class OverseerrEvent(OverseerrEntity, EventEntity): @property def available(self) -> bool: """Return True if entity is available.""" - return self._attr_available + return self._attr_available and self.coordinator.push def parse_event(event: dict[str, Any], nullable_fields: list[str]) -> dict[str, Any]: diff --git a/tests/components/overseerr/test_event.py b/tests/components/overseerr/test_event.py index 7ad6b53c7ed..3866ccc09ca 100644 --- a/tests/components/overseerr/test_event.py +++ b/tests/components/overseerr/test_event.py @@ -107,3 +107,64 @@ async def test_event_goes_unavailable( assert ( hass.states.get("event.overseerr_last_media_event").state == STATE_UNAVAILABLE ) + + +async def test_not_push_based( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test event entities aren't created if not push based.""" + + mock_overseerr_client_needs_change.test_webhook_notification_config.return_value = ( + False + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("event.overseerr_last_media_event") is None + + +async def test_cant_fetch_webhook_config( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client: AsyncMock, +) -> None: + """Test event entities aren't created if not push based.""" + + mock_overseerr_client.get_webhook_notification_config.side_effect = ( + OverseerrConnectionError("Boom") + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("event.overseerr_last_media_event") is None + + +async def test_not_push_based_but_was_before( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test event entities are created if push based in the past.""" + + entity_registry.async_get_or_create( + Platform.EVENT, + DOMAIN, + f"{mock_config_entry.entry_id}-media", + suggested_object_id="overseerr_last_media_event", + disabled_by=None, + ) + + mock_overseerr_client_needs_change.test_webhook_notification_config.return_value = ( + False + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("event.overseerr_last_media_event") is not None + + assert ( + hass.states.get("event.overseerr_last_media_event").state == STATE_UNAVAILABLE + ) diff --git a/tests/components/overseerr/test_init.py b/tests/components/overseerr/test_init.py index 27c9f3fb3e9..6418e2103db 100644 --- a/tests/components/overseerr/test_init.py +++ b/tests/components/overseerr/test_init.py @@ -9,6 +9,7 @@ from python_overseerr.models import WebhookNotificationOptions from syrupy import SnapshotAssertion from homeassistant.components import cloud +from homeassistant.components.cloud import CloudNotAvailable from homeassistant.components.overseerr import ( CONF_CLOUDHOOK_URL, JSON_PAYLOAD, @@ -362,10 +363,50 @@ async def test_cloudhook_not_connecting( len( mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls ) - == 2 + == 3 ) mock_overseerr_client_needs_change.set_webhook_notification_config.assert_not_called() assert hass.config_entries.async_entries(DOMAIN) fake_create_cloudhook.assert_not_called() + + +async def test_removing_entry_with_cloud_unavailable( + hass: HomeAssistant, + mock_cloudhook_config_entry: MockConfigEntry, + mock_overseerr_client: AsyncMock, +) -> None: + """Test handling cloud unavailable when deleting entry.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), + patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), + patch( + "homeassistant.components.cloud.async_delete_cloudhook", + side_effect=CloudNotAvailable(), + ), + ): + await setup_integration(hass, mock_cloudhook_config_entry) + + assert cloud.async_active_subscription(hass) is True + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) + + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) From 8c0515aff25d2a558f7c94b3daca8c1b3a8509da Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Wed, 22 Jan 2025 20:00:12 +0100 Subject: [PATCH 0823/2987] Set enphase_envoy CT Status flags entity_category to diagnostics. (#136241) --- .../enphase_envoy/quality_scale.yaml | 2 +- .../components/enphase_envoy/sensor.py | 7 + .../enphase_envoy/snapshots/test_sensor.ambr | 156 +++++++++--------- 3 files changed, 86 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/enphase_envoy/quality_scale.yaml b/homeassistant/components/enphase_envoy/quality_scale.yaml index 127b609784b..4431a298c8c 100644 --- a/homeassistant/components/enphase_envoy/quality_scale.yaml +++ b/homeassistant/components/enphase_envoy/quality_scale.yaml @@ -76,7 +76,7 @@ rules: comment: https://www.home-assistant.io/integrations/enphase_envoy#troubleshooting docs-use-cases: todo dynamic-devices: todo - entity-category: todo + entity-category: done entity-device-class: done entity-disabled-by-default: done entity-translations: done diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index a7b98f9b15c..dcf062a5417 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -37,6 +37,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( PERCENTAGE, + EntityCategory, UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -369,6 +370,7 @@ CT_NET_CONSUMPTION_SENSORS = ( key="net_consumption_ct_metering_status", translation_key="net_ct_metering_status", device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, options=list(CtMeterStatus), entity_registry_enabled_default=False, value_fn=attrgetter("metering_status"), @@ -378,6 +380,7 @@ CT_NET_CONSUMPTION_SENSORS = ( key="net_consumption_ct_status_flags", translation_key="net_ct_status_flags", state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags), on_phase=None, @@ -451,6 +454,7 @@ CT_PRODUCTION_SENSORS = ( translation_key="production_ct_metering_status", device_class=SensorDeviceClass.ENUM, options=list(CtMeterStatus), + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=attrgetter("metering_status"), on_phase=None, @@ -459,6 +463,7 @@ CT_PRODUCTION_SENSORS = ( key="production_ct_status_flags", translation_key="production_ct_status_flags", state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags), on_phase=None, @@ -564,6 +569,7 @@ CT_STORAGE_SENSORS = ( translation_key="storage_ct_metering_status", device_class=SensorDeviceClass.ENUM, options=list(CtMeterStatus), + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=attrgetter("metering_status"), on_phase=None, @@ -572,6 +578,7 @@ CT_STORAGE_SENSORS = ( key="storage_ct_status_flags", translation_key="storage_ct_status_flags", state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags), on_phase=None, diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index c11bff1697c..0f251b5e859 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -1176,7 +1176,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -1222,7 +1222,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -1274,7 +1274,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -1332,7 +1332,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -4227,7 +4227,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -4273,7 +4273,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -4319,7 +4319,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -4365,7 +4365,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -4411,7 +4411,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -4457,7 +4457,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -4503,7 +4503,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -4549,7 +4549,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -4601,7 +4601,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -4659,7 +4659,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -4717,7 +4717,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -4775,7 +4775,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -4833,7 +4833,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -4891,7 +4891,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -4949,7 +4949,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -5007,7 +5007,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -8652,7 +8652,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -8698,7 +8698,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -8744,7 +8744,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -8790,7 +8790,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -8836,7 +8836,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -8882,7 +8882,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -8928,7 +8928,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -8974,7 +8974,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -9026,7 +9026,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -9084,7 +9084,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -9142,7 +9142,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -9200,7 +9200,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -9258,7 +9258,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -9316,7 +9316,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -9374,7 +9374,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -9432,7 +9432,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -15764,7 +15764,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -15810,7 +15810,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -15856,7 +15856,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -15902,7 +15902,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -15948,7 +15948,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -15994,7 +15994,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -16040,7 +16040,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -16086,7 +16086,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -16132,7 +16132,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct', 'has_entity_name': True, 'hidden_by': None, @@ -16178,7 +16178,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -16224,7 +16224,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -16270,7 +16270,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -16322,7 +16322,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -16380,7 +16380,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -16438,7 +16438,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -16496,7 +16496,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -16554,7 +16554,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -16612,7 +16612,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -16670,7 +16670,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -16728,7 +16728,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -16786,7 +16786,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_storage_ct', 'has_entity_name': True, 'hidden_by': None, @@ -16844,7 +16844,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_storage_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -16902,7 +16902,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_storage_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -16960,7 +16960,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_storage_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -22582,7 +22582,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -22628,7 +22628,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -22674,7 +22674,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -22720,7 +22720,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -22766,7 +22766,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -22812,7 +22812,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -22858,7 +22858,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -22904,7 +22904,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -22956,7 +22956,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -23014,7 +23014,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -23072,7 +23072,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -23130,7 +23130,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -23188,7 +23188,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -23246,7 +23246,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -23304,7 +23304,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -23362,7 +23362,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -25241,7 +25241,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -25293,7 +25293,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', 'has_entity_name': True, 'hidden_by': None, From 5f67461c26b2c3e89a3d0f3abd03f4e37017451e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 22 Jan 2025 20:00:42 +0100 Subject: [PATCH 0824/2987] Provide beta release note for Shelly RPC devices (#136154) * Return beta release note for Shelly RPC devices * Cleaning * Fix test * Move release note check --- homeassistant/components/shelly/const.py | 1 + homeassistant/components/shelly/utils.py | 8 +++++++- tests/components/shelly/test_update.py | 4 ++-- tests/components/shelly/test_utils.py | 8 ++++++-- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 1adaad8f975..f81ba5ca7f7 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -237,6 +237,7 @@ OTA_SUCCESS = "ota_success" GEN1_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen1/#changelog" GEN2_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen2/changelog/" +GEN2_BETA_RELEASE_URL = f"{GEN2_RELEASE_URL}#unreleased" DEVICES_WITHOUT_FIRMWARE_CHANGELOG = ( MODEL_WALL_DISPLAY, MODEL_MOTION, diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index d450727ead6..81766c65388 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -50,6 +50,7 @@ from .const import ( DOMAIN, FIRMWARE_UNSUPPORTED_ISSUE_ID, GEN1_RELEASE_URL, + GEN2_BETA_RELEASE_URL, GEN2_RELEASE_URL, LOGGER, RPC_INPUTS_EVENTS_TYPES, @@ -453,9 +454,14 @@ def mac_address_from_name(name: str) -> str | None: def get_release_url(gen: int, model: str, beta: bool) -> str | None: """Return release URL or None.""" - if beta or model in DEVICES_WITHOUT_FIRMWARE_CHANGELOG: + if ( + beta and gen in BLOCK_GENERATIONS + ) or model in DEVICES_WITHOUT_FIRMWARE_CHANGELOG: return None + if beta: + return GEN2_BETA_RELEASE_URL + return GEN1_RELEASE_URL if gen in BLOCK_GENERATIONS else GEN2_RELEASE_URL diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index cd4cdf877a5..9ea66c1acb7 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -9,6 +9,7 @@ import pytest from homeassistant.components.shelly.const import ( DOMAIN, GEN1_RELEASE_URL, + GEN2_BETA_RELEASE_URL, GEN2_RELEASE_URL, ) from homeassistant.components.update import ( @@ -572,7 +573,6 @@ async def test_rpc_beta_update( assert state.attributes[ATTR_LATEST_VERSION] == "1" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - assert state.attributes[ATTR_RELEASE_URL] is None monkeypatch.setitem( mock_rpc_device.status["sys"], @@ -589,7 +589,7 @@ async def test_rpc_beta_update( assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2b" assert state.attributes[ATTR_IN_PROGRESS] is False - assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None + assert state.attributes[ATTR_RELEASE_URL] == GEN2_BETA_RELEASE_URL await hass.services.async_call( UPDATE_DOMAIN, diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index 17bcd6e3d40..b7c3dff10f6 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -17,7 +17,11 @@ from aioshelly.const import ( ) import pytest -from homeassistant.components.shelly.const import GEN1_RELEASE_URL, GEN2_RELEASE_URL +from homeassistant.components.shelly.const import ( + GEN1_RELEASE_URL, + GEN2_BETA_RELEASE_URL, + GEN2_RELEASE_URL, +) from homeassistant.components.shelly.utils import ( get_block_channel_name, get_block_device_sleep_period, @@ -300,7 +304,7 @@ async def test_get_rpc_input_triggers( (1, MODEL_1, True, None), (2, MODEL_WALL_DISPLAY, False, None), (2, MODEL_PLUS_2PM_V2, False, GEN2_RELEASE_URL), - (2, MODEL_PLUS_2PM_V2, True, None), + (2, MODEL_PLUS_2PM_V2, True, GEN2_BETA_RELEASE_URL), ], ) def test_get_release_url( From 4203345550eec59f2f917866a140bb0a61ce232e Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Wed, 22 Jan 2025 20:02:01 +0100 Subject: [PATCH 0825/2987] Bump python-linkplay to v0.1.3 (#136267) --- homeassistant/components/linkplay/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index cc124ceb611..ec9a8759a30 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.1.1"], + "requirements": ["python-linkplay==0.1.3"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index f720eb80236..1c9fbe47df7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2399,7 +2399,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.9.1 # homeassistant.components.linkplay -python-linkplay==0.1.1 +python-linkplay==0.1.3 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c9b9902a282..3adbc1e2fb1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1938,7 +1938,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.9.1 # homeassistant.components.linkplay -python-linkplay==0.1.1 +python-linkplay==0.1.3 # homeassistant.components.matter python-matter-server==7.0.0 From dcb17d03af20c7c42f6d9c56eb2bf9a87f4f1ee9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jan 2025 09:36:31 -1000 Subject: [PATCH 0826/2987] Bump bleak-esphome to 2.1.1 (#136277) --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 68971759243..43f524516a8 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.1.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.1.1"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index d43662a32f7..4682be1c5c7 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==28.0.1", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.1.0" + "bleak-esphome==2.1.1" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 1c9fbe47df7..00be847c2d9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -591,7 +591,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.1.0 +bleak-esphome==2.1.1 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3adbc1e2fb1..d6be4597d25 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -522,7 +522,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.1.0 +bleak-esphome==2.1.1 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 From 66115ce695a4903cbb084b7c11f79cc375efc710 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jan 2025 09:37:07 -1000 Subject: [PATCH 0827/2987] Remove myself from ibeacon codeowners (#136280) --- CODEOWNERS | 2 -- homeassistant/components/ibeacon/manifest.json | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 3553297b851..489b848c772 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -682,8 +682,6 @@ build.json @home-assistant/supervisor /homeassistant/components/iammeter/ @lewei50 /homeassistant/components/iaqualink/ @flz /tests/components/iaqualink/ @flz -/homeassistant/components/ibeacon/ @bdraco -/tests/components/ibeacon/ @bdraco /homeassistant/components/icloud/ @Quentame @nzapponi /tests/components/icloud/ @Quentame @nzapponi /homeassistant/components/idasen_desk/ @abmantis diff --git a/homeassistant/components/ibeacon/manifest.json b/homeassistant/components/ibeacon/manifest.json index 8bd7e3ab9cc..bdbdaea49d2 100644 --- a/homeassistant/components/ibeacon/manifest.json +++ b/homeassistant/components/ibeacon/manifest.json @@ -7,7 +7,7 @@ "manufacturer_data_start": [2, 21] } ], - "codeowners": ["@bdraco"], + "codeowners": [], "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/ibeacon", From 208805a930b13dfd0f96b1388d6f792248089e3e Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Wed, 22 Jan 2025 12:49:11 -0700 Subject: [PATCH 0828/2987] Move brightness icon map to icons.json (#136201) --- homeassistant/components/litterrobot/icons.json | 8 ++++++++ homeassistant/components/litterrobot/select.py | 16 ---------------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/litterrobot/icons.json b/homeassistant/components/litterrobot/icons.json index 482031f8424..ba3df2114b7 100644 --- a/homeassistant/components/litterrobot/icons.json +++ b/homeassistant/components/litterrobot/icons.json @@ -17,6 +17,14 @@ } }, "select": { + "brightness_level": { + "default": "mdi:lightbulb-question", + "state": { + "low": "mdi:lightbulb-on-30", + "medium": "mdi:lightbulb-on-50", + "high": "mdi:lightbulb-on" + } + }, "cycle_delay": { "default": "mdi:timer-outline" }, diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index 948fad45a76..6fab9c95040 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -20,13 +20,6 @@ from .hub import LitterRobotHub _CastTypeT = TypeVar("_CastTypeT", int, float, str) -BRIGHTNESS_LEVEL_ICON_MAP: dict[BrightnessLevel | None, str] = { - BrightnessLevel.LOW: "mdi:lightbulb-on-30", - BrightnessLevel.MEDIUM: "mdi:lightbulb-on-50", - BrightnessLevel.HIGH: "mdi:lightbulb-on", - None: "mdi:lightbulb-question", -} - @dataclass(frozen=True) class RequiredKeysMixin(Generic[_RobotT, _CastTypeT]): @@ -44,7 +37,6 @@ class RobotSelectEntityDescription( """A class that describes robot select entities.""" entity_category: EntityCategory = EntityCategory.CONFIG - icon_fn: Callable[[_RobotT], str] | None = None ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { @@ -66,7 +58,6 @@ ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { select_fn=lambda robot, opt: robot.set_panel_brightness( BrightnessLevel[opt.upper()] ), - icon_fn=lambda robot: BRIGHTNESS_LEVEL_ICON_MAP[robot.panel_brightness], ), FeederRobot: RobotSelectEntityDescription[FeederRobot, float]( key="meal_insert_size", @@ -113,13 +104,6 @@ class LitterRobotSelectEntity( options = self.entity_description.options_fn(self.robot) self._attr_options = list(map(str, options)) - @property - def icon(self) -> str | None: - """Return the icon to use in the frontend, if any.""" - if icon_fn := self.entity_description.icon_fn: - return str(icon_fn(self.robot)) - return super().icon - @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" From ea1cec25250fe3c8e2ba26711aa495c0e8d099a9 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Wed, 22 Jan 2025 19:55:52 +0000 Subject: [PATCH 0829/2987] Bump pyHomee to 1.2.3 (#136213) Co-authored-by: Joostlek --- homeassistant/components/homee/cover.py | 84 +++++++++++--------- homeassistant/components/homee/entity.py | 58 +++++++------- homeassistant/components/homee/manifest.json | 2 +- homeassistant/components/homee/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 80 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py index b594b23cc59..b4a853f7c35 100644 --- a/homeassistant/components/homee/cover.py +++ b/homeassistant/components/homee/cover.py @@ -121,14 +121,15 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): def current_cover_position(self) -> int | None: """Return the cover's position.""" # Translate the homee position values to HA's 0-100 scale - if self.has_attribute(AttributeType.POSITION): - attribute = self._node.get_attribute_by_type(AttributeType.POSITION) + if ( + attribute := self._node.get_attribute_by_type(AttributeType.POSITION) + ) is not None: homee_min = attribute.minimum homee_max = attribute.maximum homee_position = attribute.current_value position = ((homee_position - homee_min) / (homee_max - homee_min)) * 100 - return 100 - position + return int(100 - position) return None @@ -136,16 +137,17 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): def current_cover_tilt_position(self) -> int | None: """Return the cover's tilt position.""" # Translate the homee position values to HA's 0-100 scale - if self.has_attribute(AttributeType.SHUTTER_SLAT_POSITION): - attribute = self._node.get_attribute_by_type( + if ( + attribute := self._node.get_attribute_by_type( AttributeType.SHUTTER_SLAT_POSITION ) + ) is not None: homee_min = attribute.minimum homee_max = attribute.maximum homee_position = attribute.current_value position = ((homee_position - homee_min) / (homee_max - homee_min)) * 100 - return 100 - position + return int(100 - position) return None @@ -176,8 +178,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): @property def is_closed(self) -> bool | None: """Return if the cover is closed.""" - if self.has_attribute(AttributeType.POSITION): - attribute = self._node.get_attribute_by_type(AttributeType.POSITION) + if ( + attribute := self._node.get_attribute_by_type(AttributeType.POSITION) + ) is not None: return attribute.get_value() == attribute.maximum if self._open_close_attribute is not None: @@ -187,10 +190,11 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): return self._open_close_attribute.get_value() == 0 # If none of the above is present, it might be a slat only cover. - if self.has_attribute(AttributeType.SHUTTER_SLAT_POSITION): - attribute = self._node.get_attribute_by_type( + if ( + attribute := self._node.get_attribute_by_type( AttributeType.SHUTTER_SLAT_POSITION ) + ) is not None: return attribute.get_value() == attribute.minimum return None @@ -217,12 +221,14 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): position = 100 - cast(int, kwargs[ATTR_POSITION]) # Convert position to range of our entity. - attribute = self._node.get_attribute_by_type(AttributeType.POSITION) - homee_min = attribute.minimum - homee_max = attribute.maximum - homee_position = (position / 100) * (homee_max - homee_min) + homee_min + if ( + attribute := self._node.get_attribute_by_type(AttributeType.POSITION) + ) is not None: + homee_min = attribute.minimum + homee_max = attribute.maximum + homee_position = (position / 100) * (homee_max - homee_min) + homee_min - await self.async_set_value(attribute, homee_position) + await self.async_set_value(attribute, homee_position) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" @@ -231,23 +237,27 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" - slat_attribute = self._node.get_attribute_by_type( - AttributeType.SLAT_ROTATION_IMPULSE - ) - if not slat_attribute.is_reversed: - await self.async_set_value(slat_attribute, 2) - else: - await self.async_set_value(slat_attribute, 1) + if ( + slat_attribute := self._node.get_attribute_by_type( + AttributeType.SLAT_ROTATION_IMPULSE + ) + ) is not None: + if not slat_attribute.is_reversed: + await self.async_set_value(slat_attribute, 2) + else: + await self.async_set_value(slat_attribute, 1) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" - slat_attribute = self._node.get_attribute_by_type( - AttributeType.SLAT_ROTATION_IMPULSE - ) - if not slat_attribute.is_reversed: - await self.async_set_value(slat_attribute, 1) - else: - await self.async_set_value(slat_attribute, 2) + if ( + slat_attribute := self._node.get_attribute_by_type( + AttributeType.SLAT_ROTATION_IMPULSE + ) + ) is not None: + if not slat_attribute.is_reversed: + await self.async_set_value(slat_attribute, 1) + else: + await self.async_set_value(slat_attribute, 2) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" @@ -255,11 +265,13 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): position = 100 - cast(int, kwargs[ATTR_TILT_POSITION]) # Convert position to range of our entity. - attribute = self._node.get_attribute_by_type( - AttributeType.SHUTTER_SLAT_POSITION - ) - homee_min = attribute.minimum - homee_max = attribute.maximum - homee_position = (position / 100) * (homee_max - homee_min) + homee_min + if ( + attribute := self._node.get_attribute_by_type( + AttributeType.SHUTTER_SLAT_POSITION + ) + ) is not None: + homee_min = attribute.minimum + homee_max = attribute.maximum + homee_position = (position / 100) * (homee_max - homee_min) + homee_min - await self.async_set_value(attribute, homee_position) + await self.async_set_value(attribute, homee_position) diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py index 2af01358752..a6cd54354bf 100644 --- a/homeassistant/components/homee/entity.py +++ b/homeassistant/components/homee/entity.py @@ -73,13 +73,20 @@ class HomeeNodeEntity(Entity): self._attr_unique_id = f"{entry.unique_id}-{node.id}" self._entry = entry - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(node.id))}, - name=node.name, - model=get_name_for_enum(NodeProfile, node.profile), - sw_version=self._get_software_version(), - via_device=(DOMAIN, entry.runtime_data.settings.uid), - ) + ## Homee hub itself has node-id -1 + if node.id == -1: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.runtime_data.settings.uid)}, + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry.unique_id}-{node.id}")}, + name=node.name, + model=get_name_for_enum(NodeProfile, node.profile), + sw_version=self._get_software_version(), + via_device=(DOMAIN, entry.runtime_data.settings.uid), + ) + self._host_connected = entry.runtime_data.connected async def async_added_to_hass(self) -> None: @@ -91,23 +98,6 @@ class HomeeNodeEntity(Entity): ) ) - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - # Homee hub has id -1, but is identified only by the UID. - if self._node.id == -1: - return DeviceInfo( - identifiers={(DOMAIN, self._entry.runtime_data.settings.uid)}, - ) - - return DeviceInfo( - identifiers={(DOMAIN, f"{self._entry.unique_id}-{self._node.id}")}, - name=self._node.name, - model=get_name_for_enum(NodeProfile, self._node.profile), - sw_version=self._get_software_version(), - via_device=(DOMAIN, self._entry.runtime_data.settings.uid), - ) - @property def available(self) -> bool: """Return the availability of the underlying node.""" @@ -122,18 +112,26 @@ class HomeeNodeEntity(Entity): def _get_software_version(self) -> str | None: """Return the software version of the node.""" - if self.has_attribute(AttributeType.FIRMWARE_REVISION): - return self._node.get_attribute_by_type( + if ( + attribute := self._node.get_attribute_by_type( AttributeType.FIRMWARE_REVISION - ).get_value() - if self.has_attribute(AttributeType.SOFTWARE_REVISION): - return self._node.get_attribute_by_type( + ) + ) is not None: + return str(attribute.get_value()) + if ( + attribute := self._node.get_attribute_by_type( AttributeType.SOFTWARE_REVISION - ).get_value() + ) + ) is not None: + return str(attribute.get_value()) + return None def has_attribute(self, attribute_type: AttributeType) -> bool: """Check if an attribute of the given type exists.""" + if self._node.attribute_map is None: + return False + return attribute_type in self._node.attribute_map async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None: diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json index 5869a9760ea..6d03547efc9 100644 --- a/homeassistant/components/homee/manifest.json +++ b/homeassistant/components/homee/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["homee"], "quality_scale": "bronze", - "requirements": ["pyHomee==1.2.0"] + "requirements": ["pyHomee==1.2.3"] } diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py index 75b11811460..e9ef298ab4f 100644 --- a/homeassistant/components/homee/sensor.py +++ b/homeassistant/components/homee/sensor.py @@ -263,7 +263,7 @@ class HomeeSensor(HomeeEntity, SensorEntity): self.entity_description = description self._attr_translation_key = description.key if attribute.instance > 0: - self._attr_translation_key = f"{description.translation_key}_instance" + self._attr_translation_key = f"{self._attr_translation_key}_instance" self._attr_translation_placeholders = {"instance": str(attribute.instance)} @property diff --git a/requirements_all.txt b/requirements_all.txt index 00be847c2d9..a374b7f7e8a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1763,7 +1763,7 @@ pyEmby==1.10 pyHik==0.3.2 # homeassistant.components.homee -pyHomee==1.2.0 +pyHomee==1.2.3 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d6be4597d25..cf740431956 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1452,7 +1452,7 @@ pyDuotecno==2024.10.1 pyElectra==1.2.4 # homeassistant.components.homee -pyHomee==1.2.0 +pyHomee==1.2.3 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 From f8dc3d6624dac5a701fc047867c4dd0f0a9efa62 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jan 2025 10:14:19 -1000 Subject: [PATCH 0830/2987] Bump habluetooth to 3.12.0 (#136281) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index ed80d419867..22f8aa8fdb8 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.11.2" + "habluetooth==3.12.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c705005f75a..c8d3165e177 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.11.2 +habluetooth==3.12.0 hass-nabucasa==0.88.1 hassil==2.1.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index a374b7f7e8a..e1f7fea3abd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1097,7 +1097,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.11.2 +habluetooth==3.12.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf740431956..27c8bba219d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -938,7 +938,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.11.2 +habluetooth==3.12.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 From dc24f83407855a7d01b4742858467a4a74d03ff4 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Wed, 22 Jan 2025 13:27:28 -0700 Subject: [PATCH 0831/2987] Cleanup litterrobot select entity (#136282) --- .../components/litterrobot/select.py | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index 6fab9c95040..06f3bfc9ce7 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -21,22 +21,16 @@ from .hub import LitterRobotHub _CastTypeT = TypeVar("_CastTypeT", int, float, str) -@dataclass(frozen=True) -class RequiredKeysMixin(Generic[_RobotT, _CastTypeT]): - """A class that describes robot select entity required keys.""" - - current_fn: Callable[[_RobotT], _CastTypeT | None] - options_fn: Callable[[_RobotT], list[_CastTypeT]] - select_fn: Callable[[_RobotT, str], Coroutine[Any, Any, bool]] - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class RobotSelectEntityDescription( - SelectEntityDescription, RequiredKeysMixin[_RobotT, _CastTypeT] + SelectEntityDescription, Generic[_RobotT, _CastTypeT] ): """A class that describes robot select entities.""" entity_category: EntityCategory = EntityCategory.CONFIG + current_fn: Callable[[_RobotT], _CastTypeT | None] + options_fn: Callable[[_RobotT], list[_CastTypeT]] + select_fn: Callable[[_RobotT, str], Coroutine[Any, Any, bool]] ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { @@ -51,12 +45,14 @@ ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { LitterRobot4: RobotSelectEntityDescription[LitterRobot4, str]( key="panel_brightness", translation_key="brightness_level", - current_fn=lambda robot: bri.name.lower() - if (bri := robot.panel_brightness) is not None - else None, + current_fn=( + lambda robot: bri.name.lower() + if (bri := robot.panel_brightness) is not None + else None + ), options_fn=lambda _: [level.name.lower() for level in BrightnessLevel], - select_fn=lambda robot, opt: robot.set_panel_brightness( - BrightnessLevel[opt.upper()] + select_fn=( + lambda robot, opt: robot.set_panel_brightness(BrightnessLevel[opt.upper()]) ), ), FeederRobot: RobotSelectEntityDescription[FeederRobot, float]( From 52f77626f722de89fa7b048a5e183c87a7a42cc9 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 22 Jan 2025 15:12:05 -0600 Subject: [PATCH 0832/2987] Implement Coordinator for HEOS (initial plumbing) (#136205) --- homeassistant/components/heos/__init__.py | 85 +++-------------- homeassistant/components/heos/coordinator.py | 93 +++++++++++++++++++ homeassistant/components/heos/media_player.py | 60 +++++++----- tests/components/heos/conftest.py | 2 +- tests/components/heos/test_init.py | 31 +++++-- 5 files changed, 166 insertions(+), 105 deletions(-) create mode 100644 homeassistant/components/heos/coordinator.py diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index a3e720a5f21..e8d875d283c 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -9,10 +9,8 @@ import logging from typing import Any from pyheos import ( - Credentials, Heos, HeosError, - HeosOptions, HeosPlayer, PlayerUpdateResult, SignalHeosEvent, @@ -20,19 +18,9 @@ from pyheos import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, - Platform, -) +from homeassistant.const import Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.exceptions import ( - ConfigEntryNotReady, - HomeAssistantError, - ServiceValidationError, -) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -50,6 +38,7 @@ from .const import ( SIGNAL_HEOS_PLAYER_ADDED, SIGNAL_HEOS_UPDATED, ) +from .coordinator import HeosCoordinator PLATFORMS = [Platform.MEDIA_PLAYER] @@ -64,6 +53,7 @@ _LOGGER = logging.getLogger(__name__) class HeosRuntimeData: """Runtime data and coordinators for HEOS config entries.""" + coordinator: HeosCoordinator controller_manager: ControllerManager group_manager: GroupManager source_manager: SourceManager @@ -97,63 +87,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool ) break - host = entry.data[CONF_HOST] - credentials: Credentials | None = None - if entry.options: - credentials = Credentials( - entry.options[CONF_USERNAME], entry.options[CONF_PASSWORD] - ) - - # Setting all_progress_events=False ensures that we only receive a - # media position update upon start of playback or when media changes - controller = Heos( - HeosOptions( - host, - all_progress_events=False, - auto_reconnect=True, - credentials=credentials, - ) - ) - - # Auth failure handler must be added before connecting to the host, otherwise - # the event will be missed when login fails during connection. - async def auth_failure() -> None: - """Handle authentication failure.""" - entry.async_start_reauth(hass) - - entry.async_on_unload(controller.add_on_user_credentials_invalid(auth_failure)) - - try: - # Auto reconnect only operates if initial connection was successful. - await controller.connect() - except HeosError as error: - await controller.disconnect() - _LOGGER.debug("Unable to connect to controller %s: %s", host, error) - raise ConfigEntryNotReady from error - - # Disconnect when shutting down - async def disconnect_controller(event): - await controller.disconnect() - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect_controller) - ) - - # Get players and sources - try: - players = await controller.get_players() - favorites = {} - if controller.is_signed_in: - favorites = await controller.get_favorites() - else: - _LOGGER.warning( - "The HEOS System is not logged in: Enter credentials in the integration options to access favorites and streaming services" - ) - inputs = await controller.get_input_sources() - except HeosError as error: - await controller.disconnect() - _LOGGER.debug("Unable to retrieve players and sources: %s", error) - raise ConfigEntryNotReady from error + coordinator = HeosCoordinator(hass, entry) + await coordinator.async_setup() + # Preserve existing logic until migrated into coordinator + controller = coordinator.heos + players = controller.players + favorites = coordinator.favorites + inputs = coordinator.inputs controller_manager = ControllerManager(hass, controller) await controller_manager.connect_listeners() @@ -164,7 +104,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool group_manager = GroupManager(hass, controller, players) entry.runtime_data = HeosRuntimeData( - controller_manager, group_manager, source_manager, players + coordinator, controller_manager, group_manager, source_manager, players ) group_manager.connect_update() @@ -177,7 +117,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool async def async_unload_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool: """Unload a config entry.""" - await entry.runtime_data.controller_manager.disconnect() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py new file mode 100644 index 00000000000..8ccae0f63b6 --- /dev/null +++ b/homeassistant/components/heos/coordinator.py @@ -0,0 +1,93 @@ +"""HEOS integration coordinator. + +Control of all HEOS devices is through connection to a single device. Data is pushed through events. +The coordinator is responsible for refreshing data in response to system-wide events and notifying +entities to update. Entities subscribe to entity-specific updates within the entity class itself. +""" + +import logging + +from pyheos import Credentials, Heos, HeosError, HeosOptions, MediaItem + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class HeosCoordinator(DataUpdateCoordinator[None]): + """Define the HEOS integration coordinator.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Set up the coordinator and set in config_entry.""" + self.host: str = config_entry.data[CONF_HOST] + credentials: Credentials | None = None + if config_entry.options: + credentials = Credentials( + config_entry.options[CONF_USERNAME], config_entry.options[CONF_PASSWORD] + ) + # Setting all_progress_events=False ensures that we only receive a + # media position update upon start of playback or when media changes + self.heos = Heos( + HeosOptions( + self.host, + all_progress_events=False, + auto_reconnect=True, + credentials=credentials, + ) + ) + self.favorites: dict[int, MediaItem] = {} + self.inputs: list[MediaItem] = [] + super().__init__(hass, _LOGGER, config_entry=config_entry, name=DOMAIN) + + async def async_setup(self) -> None: + """Set up the coordinator; connect to the host; and retrieve initial data.""" + # Add before connect as it may occur during initial connection + self.heos.add_on_user_credentials_invalid(self._async_on_auth_failure) + # Connect to the device + try: + await self.heos.connect() + except HeosError as error: + raise ConfigEntryNotReady from error + # Load players + try: + await self.heos.get_players() + except HeosError as error: + raise ConfigEntryNotReady from error + + if not self.heos.is_signed_in: + _LOGGER.warning( + "The HEOS System is not logged in: Enter credentials in the integration options to access favorites and streaming services" + ) + # Retrieve initial data + await self._async_update_sources() + + async def async_shutdown(self) -> None: + """Disconnect all callbacks and disconnect from the device.""" + self.heos.dispatcher.disconnect_all() # Removes all connected through heos.add_on_* and player.add_on_* + await self.heos.disconnect() + await super().async_shutdown() + + async def _async_on_auth_failure(self) -> None: + """Handle when the user credentials are no longer valid.""" + assert self.config_entry is not None + self.config_entry.async_start_reauth(self.hass) + + async def _async_update_sources(self) -> None: + """Build source list for entities.""" + # Get favorites only if reportedly signed in. + if self.heos.is_signed_in: + try: + self.favorites = await self.heos.get_favorites() + except HeosError as error: + _LOGGER.error("Unable to retrieve favorites: %s", error) + # Get input sources (across all devices in the HEOS system) + try: + self.inputs = await self.heos.get_input_sources() + except HeosError as error: + _LOGGER.error("Unable to retrieve input sources: %s", error) diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index d174d744756..a98b0426be5 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -29,7 +29,7 @@ from homeassistant.components.media_player import ( RepeatMode, async_process_play_media_url, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( @@ -37,10 +37,12 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow from . import GroupManager, HeosConfigEntry, SourceManager from .const import DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_PLAYER_ADDED, SIGNAL_HEOS_UPDATED +from .coordinator import HeosCoordinator PARALLEL_UPDATES = 0 @@ -93,11 +95,14 @@ async def async_setup_entry( players = entry.runtime_data.players devices = [ HeosMediaPlayer( - player, entry.runtime_data.source_manager, entry.runtime_data.group_manager + entry.runtime_data.coordinator, + player, + entry.runtime_data.source_manager, + entry.runtime_data.group_manager, ) for player in players.values() ] - async_add_entities(devices, True) + async_add_entities(devices) type _FuncType[**_P] = Callable[_P, Awaitable[Any]] @@ -126,11 +131,10 @@ def catch_action_error[**_P]( return decorator -class HeosMediaPlayer(MediaPlayerEntity): +class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """The HEOS player.""" _attr_media_content_type = MediaType.MUSIC - _attr_should_poll = False _attr_supported_features = BASE_SUPPORTED_FEATURES _attr_media_image_remotely_accessible = True _attr_has_entity_name = True @@ -138,6 +142,7 @@ class HeosMediaPlayer(MediaPlayerEntity): def __init__( self, + coordinator: HeosCoordinator, player: HeosPlayer, source_manager: SourceManager, group_manager: GroupManager, @@ -159,16 +164,34 @@ class HeosMediaPlayer(MediaPlayerEntity): serial_number=player.serial, # Only available for some models sw_version=player.version, ) + self._update_attributes() + super().__init__(coordinator, context=player.player_id) async def _player_update(self, event): """Handle player attribute updated.""" if event == heos_const.EVENT_PLAYER_NOW_PLAYING_PROGRESS: self._media_position_updated_at = utcnow() - await self.async_update_ha_state(True) + self._handle_coordinator_update() - async def _heos_updated(self) -> None: - """Handle sources changed.""" - await self.async_update_ha_state(True) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attributes() + super()._handle_coordinator_update() + + def _update_attributes(self) -> None: + """Update core attributes of the media player.""" + self._attr_repeat = HEOS_HA_REPEAT_TYPE_MAP[self._player.repeat] + controls = self._player.now_playing_media.supported_controls + current_support = [CONTROL_TO_SUPPORT[control] for control in controls] + self._attr_supported_features = reduce( + ior, current_support, BASE_SUPPORTED_FEATURES + ) + if self.support_next_track and self.support_previous_track: + self._attr_supported_features |= ( + MediaPlayerEntityFeature.REPEAT_SET + | MediaPlayerEntityFeature.SHUFFLE_SET + ) async def async_added_to_hass(self) -> None: """Device added to hass.""" @@ -176,7 +199,9 @@ class HeosMediaPlayer(MediaPlayerEntity): self.async_on_remove(self._player.add_on_player_event(self._player_update)) # Update state when heos changes self.async_on_remove( - async_dispatcher_connect(self.hass, SIGNAL_HEOS_UPDATED, self._heos_updated) + async_dispatcher_connect( + self.hass, SIGNAL_HEOS_UPDATED, self._handle_coordinator_update + ) ) # Register this player's entity_id so it can be resolved by the group manager self.async_on_remove( @@ -185,6 +210,7 @@ class HeosMediaPlayer(MediaPlayerEntity): ) ) async_dispatcher_send(self.hass, SIGNAL_HEOS_PLAYER_ADDED) + await super().async_added_to_hass() @catch_action_error("clear playlist") async def async_clear_playlist(self) -> None: @@ -315,20 +341,6 @@ class HeosMediaPlayer(MediaPlayerEntity): """Set volume level, range 0..1.""" await self._player.set_volume(int(volume * 100)) - async def async_update(self) -> None: - """Update supported features of the player.""" - self._attr_repeat = HEOS_HA_REPEAT_TYPE_MAP[self._player.repeat] - controls = self._player.now_playing_media.supported_controls - current_support = [CONTROL_TO_SUPPORT[control] for control in controls] - self._attr_supported_features = reduce( - ior, current_support, BASE_SUPPORTED_FEATURES - ) - if self.support_next_track and self.support_previous_track: - self._attr_supported_features |= ( - MediaPlayerEntityFeature.REPEAT_SET - | MediaPlayerEntityFeature.SHUFFLE_SET - ) - @catch_action_error("unjoin player") async def async_unjoin_player(self) -> None: """Remove this player from any group.""" diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 3a69455772e..b5356e385cf 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -91,7 +91,7 @@ async def controller_fixture( new_mock = Mock(return_value=mock_heos) mock_heos.new_mock = new_mock with ( - patch("homeassistant.components.heos.Heos", new=new_mock), + patch("homeassistant.components.heos.coordinator.Heos", new=new_mock), patch("homeassistant.components.heos.config_flow.Heos", new=new_mock), ): yield mock_heos diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index cff73ad0394..39023d95375 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -30,7 +30,7 @@ async def test_async_setup_entry_loads_platforms( """Test load connects to heos, retrieves players, and loads platforms.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert hass.states.get("media_player.test_player") is not None assert controller.connect.call_count == 1 assert controller.get_players.call_count == 1 @@ -116,24 +116,41 @@ async def test_async_setup_entry_connect_failure( config_entry.add_to_hass(hass) controller.connect.side_effect = HeosError() assert not await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.state == ConfigEntryState.SETUP_RETRY assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 - controller.connect.reset_mock() - controller.disconnect.reset_mock() + assert config_entry.state is ConfigEntryState.SETUP_RETRY async def test_async_setup_entry_player_failure( hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: - """Failure to retrieve players/sources raises ConfigEntryNotReady.""" + """Failure to retrieve players raises ConfigEntryNotReady.""" config_entry.add_to_hass(hass) controller.get_players.side_effect = HeosError() assert not await hass.config_entries.async_setup(config_entry.entry_id) assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 - controller.connect.reset_mock() - controller.disconnect.reset_mock() + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_async_setup_entry_favorites_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Failure to retrieve favorites loads.""" + config_entry.add_to_hass(hass) + controller.get_favorites.side_effect = HeosError() + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + +async def test_async_setup_entry_inputs_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Failure to retrieve inputs loads.""" + config_entry.add_to_hass(hass) + controller.get_input_sources.side_effect = HeosError() + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED async def test_unload_entry( From e3c836aa7d061ea96d268908318a5f63876faa7d Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Wed, 22 Jan 2025 21:19:54 +0000 Subject: [PATCH 0833/2987] Add number platform to ohme (#136271) Co-authored-by: Shay Levy --- homeassistant/components/ohme/const.py | 2 +- homeassistant/components/ohme/icons.json | 5 ++ homeassistant/components/ohme/number.py | 77 +++++++++++++++++++ homeassistant/components/ohme/strings.json | 5 ++ tests/components/ohme/conftest.py | 1 + .../ohme/snapshots/test_number.ambr | 57 ++++++++++++++ tests/components/ohme/test_number.py | 55 +++++++++++++ 7 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/ohme/number.py create mode 100644 tests/components/ohme/snapshots/test_number.ambr create mode 100644 tests/components/ohme/test_number.py diff --git a/homeassistant/components/ohme/const.py b/homeassistant/components/ohme/const.py index 770d18e823a..2b7410dc0eb 100644 --- a/homeassistant/components/ohme/const.py +++ b/homeassistant/components/ohme/const.py @@ -3,4 +3,4 @@ from homeassistant.const import Platform DOMAIN = "ohme" -PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json index 6fa7925aa02..6d187ff7e8d 100644 --- a/homeassistant/components/ohme/icons.json +++ b/homeassistant/components/ohme/icons.json @@ -5,6 +5,11 @@ "default": "mdi:check-decagram" } }, + "number": { + "target_percentage": { + "default": "mdi:battery-heart" + } + }, "sensor": { "status": { "default": "mdi:car", diff --git a/homeassistant/components/ohme/number.py b/homeassistant/components/ohme/number.py new file mode 100644 index 00000000000..d618d4a873b --- /dev/null +++ b/homeassistant/components/ohme/number.py @@ -0,0 +1,77 @@ +"""Platform for number.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from ohme import ApiException, OhmeApiClient + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OhmeConfigEntry +from .const import DOMAIN +from .entity import OhmeEntity, OhmeEntityDescription + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class OhmeNumberDescription(OhmeEntityDescription, NumberEntityDescription): + """Class describing Ohme number entities.""" + + set_fn: Callable[[OhmeApiClient, float], Awaitable[None]] + value_fn: Callable[[OhmeApiClient], float] + + +NUMBER_DESCRIPTION = [ + OhmeNumberDescription( + key="target_percentage", + translation_key="target_percentage", + value_fn=lambda client: client.target_soc, + set_fn=lambda client, value: client.async_set_target(target_percent=value), + native_min_value=0, + native_max_value=100, + native_step=1, + native_unit_of_measurement=PERCENTAGE, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OhmeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up numbers.""" + coordinators = config_entry.runtime_data + coordinator = coordinators.charge_session_coordinator + + async_add_entities( + OhmeNumber(coordinator, description) + for description in NUMBER_DESCRIPTION + if description.is_supported_fn(coordinator.client) + ) + + +class OhmeNumber(OhmeEntity, NumberEntity): + """Generic number entity for Ohme.""" + + entity_description: OhmeNumberDescription + + @property + def native_value(self) -> float: + """Return the current value of the number.""" + return self.entity_description.value_fn(self.coordinator.client) + + async def async_set_native_value(self, value: float) -> None: + """Set the number value.""" + try: + await self.entity_description.set_fn(self.coordinator.client, value) + except ApiException as e: + raise HomeAssistantError( + translation_key="api_failed", translation_domain=DOMAIN + ) from e + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 4c45f8eca8c..6ba06c98c44 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -50,6 +50,11 @@ "name": "Approve charge" } }, + "number": { + "target_percentage": { + "name": "Target percentage" + } + }, "sensor": { "status": { "name": "Status", diff --git a/tests/components/ohme/conftest.py b/tests/components/ohme/conftest.py index 9a196a5b231..0a774c15143 100644 --- a/tests/components/ohme/conftest.py +++ b/tests/components/ohme/conftest.py @@ -54,6 +54,7 @@ def mock_client(): client.status = ChargerStatus.CHARGING client.power = ChargerPower(0, 0, 0, 0) + client.target_soc = 50 client.battery = 80 client.serial = "chargerid" client.ct_connected = True diff --git a/tests/components/ohme/snapshots/test_number.ambr b/tests/components/ohme/snapshots/test_number.ambr new file mode 100644 index 00000000000..580082635df --- /dev/null +++ b/tests/components/ohme/snapshots/test_number.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_numbers[number.ohme_home_pro_target_percentage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ohme_home_pro_target_percentage', + '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': 'Target percentage', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'target_percentage', + 'unique_id': 'chargerid_target_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_numbers[number.ohme_home_pro_target_percentage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Target percentage', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.ohme_home_pro_target_percentage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- diff --git a/tests/components/ohme/test_number.py b/tests/components/ohme/test_number.py new file mode 100644 index 00000000000..9cfce2a850f --- /dev/null +++ b/tests/components/ohme/test_number.py @@ -0,0 +1,55 @@ +"""Tests for numbers.""" + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_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 setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_numbers( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the Ohme sensors.""" + with patch("homeassistant.components.ohme.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_number( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the number set.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ + ATTR_VALUE: 100, + }, + target={ + ATTR_ENTITY_ID: "number.ohme_home_pro_target_percentage", + }, + blocking=True, + ) + + assert len(mock_client.async_set_target.mock_calls) == 1 From 33f966a12ef180f2aaa16b033c04629c1334f914 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Wed, 22 Jan 2025 14:20:13 -0700 Subject: [PATCH 0834/2987] Convert LitterRobotHub to a DataUpdateCoordinator (#136283) --- .../components/litterrobot/__init__.py | 13 ++-- .../components/litterrobot/binary_sensor.py | 10 ++-- .../components/litterrobot/button.py | 17 +++--- .../litterrobot/{hub.py => coordinator.py} | 60 ++++++++++--------- .../components/litterrobot/entity.py | 37 +++++------- .../components/litterrobot/select.py | 20 +++---- .../components/litterrobot/sensor.py | 15 ++--- .../components/litterrobot/switch.py | 13 ++-- homeassistant/components/litterrobot/time.py | 18 +++--- .../components/litterrobot/update.py | 14 +++-- .../components/litterrobot/vacuum.py | 15 ++--- tests/components/litterrobot/conftest.py | 4 +- tests/components/litterrobot/test_init.py | 2 +- 13 files changed, 119 insertions(+), 119 deletions(-) rename homeassistant/components/litterrobot/{hub.py => coordinator.py} (51%) diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index 3c55c4c4035..76274f987cd 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -4,15 +4,12 @@ from __future__ import annotations from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN -from .hub import LitterRobotHub - -type LitterRobotConfigEntry = ConfigEntry[LitterRobotHub] +from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator PLATFORMS_BY_TYPE = { Robot: ( @@ -41,11 +38,11 @@ def get_platforms_for_robots(robots: list[Robot]) -> set[Platform]: async def async_setup_entry(hass: HomeAssistant, entry: LitterRobotConfigEntry) -> bool: """Set up Litter-Robot from a config entry.""" - hub = LitterRobotHub(hass, entry.data) - await hub.login(load_robots=True, subscribe_for_updates=True) - entry.runtime_data = hub + coordinator = LitterRobotDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator - if platforms := get_platforms_for_robots(hub.account.robots): + if platforms := get_platforms_for_robots(coordinator.account.robots): await hass.config_entries.async_forward_entry_setups(entry, platforms) return True diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index 9a9a4b348b7..e6cf23fa27c 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LitterRobotConfigEntry +from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT @@ -66,10 +66,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot binary sensors using config entry.""" - hub = entry.runtime_data + coordinator = entry.runtime_data async_add_entities( - LitterRobotBinarySensorEntity(robot=robot, hub=hub, description=description) - for robot in hub.account.robots + LitterRobotBinarySensorEntity( + robot=robot, coordinator=coordinator, description=description + ) + for robot in coordinator.account.robots for robot_type, entity_descriptions in BINARY_SENSOR_MAP.items() if isinstance(robot, robot_type) for description in entity_descriptions diff --git a/homeassistant/components/litterrobot/button.py b/homeassistant/components/litterrobot/button.py index 984b28cc96e..01888e7fbae 100644 --- a/homeassistant/components/litterrobot/button.py +++ b/homeassistant/components/litterrobot/button.py @@ -13,7 +13,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LitterRobotConfigEntry +from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT @@ -51,14 +51,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot cleaner using config entry.""" - hub = entry.runtime_data - entities = [ - LitterRobotButtonEntity(robot=robot, hub=hub, description=description) - for robot in hub.account.robots + coordinator = entry.runtime_data + async_add_entities( + LitterRobotButtonEntity( + robot=robot, coordinator=coordinator, description=description + ) + for robot in coordinator.account.robots for robot_type, description in ROBOT_BUTTON_MAP.items() if isinstance(robot, robot_type) - ] - async_add_entities(entities) + ) class LitterRobotButtonEntity(LitterRobotEntity[_RobotT], ButtonEntity): @@ -69,4 +70,4 @@ class LitterRobotButtonEntity(LitterRobotEntity[_RobotT], ButtonEntity): async def async_press(self) -> None: """Press the button.""" await self.entity_description.press_fn(self.robot) - self.coordinator.async_set_updated_data(True) + self.coordinator.async_set_updated_data(None) diff --git a/homeassistant/components/litterrobot/hub.py b/homeassistant/components/litterrobot/coordinator.py similarity index 51% rename from homeassistant/components/litterrobot/hub.py rename to homeassistant/components/litterrobot/coordinator.py index 77050855c70..a56a6607d32 100644 --- a/homeassistant/components/litterrobot/hub.py +++ b/homeassistant/components/litterrobot/coordinator.py @@ -1,64 +1,66 @@ -"""A wrapper 'hub' for the Litter-Robot API.""" +"""The Litter-Robot coordinator.""" from __future__ import annotations -from collections.abc import Generator, Mapping +from collections.abc import Generator from datetime import timedelta import logging -from typing import Any from pylitterbot import Account, FeederRobot, LitterRobot from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -UPDATE_INTERVAL_SECONDS = 60 * 5 +UPDATE_INTERVAL = timedelta(minutes=5) + +type LitterRobotConfigEntry = ConfigEntry[LitterRobotDataUpdateCoordinator] -class LitterRobotHub: - """A Litter-Robot hub wrapper class.""" +class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]): + """The Litter-Robot data update coordinator.""" - def __init__(self, hass: HomeAssistant, data: Mapping[str, Any]) -> None: - """Initialize the Litter-Robot hub.""" - self._data = data - self.account = Account(websession=async_get_clientsession(hass)) + config_entry: LitterRobotConfigEntry - async def _async_update_data() -> bool: - """Update all device states from the Litter-Robot API.""" - await self.account.refresh_robots() - return True - - self.coordinator = DataUpdateCoordinator( + def __init__( + self, hass: HomeAssistant, config_entry: LitterRobotConfigEntry + ) -> None: + """Initialize the Litter-Robot data update coordinator.""" + super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, - update_method=_async_update_data, - update_interval=timedelta(seconds=UPDATE_INTERVAL_SECONDS), + update_interval=UPDATE_INTERVAL, ) - async def login( - self, load_robots: bool = False, subscribe_for_updates: bool = False - ) -> None: - """Login to Litter-Robot.""" + self.account = Account(websession=async_get_clientsession(hass)) + + async def _async_update_data(self) -> None: + """Update all device states from the Litter-Robot API.""" + await self.account.refresh_robots() + + async def _async_setup(self) -> None: + """Set up the coordinator.""" try: await self.account.connect( - username=self._data[CONF_USERNAME], - password=self._data[CONF_PASSWORD], - load_robots=load_robots, - subscribe_for_updates=subscribe_for_updates, + username=self.config_entry.data[CONF_USERNAME], + password=self.config_entry.data[CONF_PASSWORD], + load_robots=True, + subscribe_for_updates=True, ) except LitterRobotLoginException as ex: raise ConfigEntryAuthFailed("Invalid credentials") from ex except LitterRobotException as ex: - raise ConfigEntryNotReady("Unable to connect to Litter-Robot API") from ex + raise UpdateFailed("Unable to connect to Litter-Robot API") from ex def litter_robots(self) -> Generator[LitterRobot]: """Get Litter-Robots from the account.""" diff --git a/homeassistant/components/litterrobot/entity.py b/homeassistant/components/litterrobot/entity.py index 4639404b92b..36cbbb730ce 100644 --- a/homeassistant/components/litterrobot/entity.py +++ b/homeassistant/components/litterrobot/entity.py @@ -9,44 +9,39 @@ from pylitterbot.robot import EVENT_UPDATE from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .hub import LitterRobotHub +from .coordinator import LitterRobotDataUpdateCoordinator _RobotT = TypeVar("_RobotT", bound=Robot) class LitterRobotEntity( - CoordinatorEntity[DataUpdateCoordinator[bool]], Generic[_RobotT] + CoordinatorEntity[LitterRobotDataUpdateCoordinator], Generic[_RobotT] ): """Generic Litter-Robot entity representing common data and methods.""" _attr_has_entity_name = True def __init__( - self, robot: _RobotT, hub: LitterRobotHub, description: EntityDescription + self, + robot: _RobotT, + coordinator: LitterRobotDataUpdateCoordinator, + description: EntityDescription, ) -> None: """Pass coordinator to CoordinatorEntity.""" - super().__init__(hub.coordinator) + super().__init__(coordinator) self.robot = robot - self.hub = hub self.entity_description = description - self._attr_unique_id = f"{self.robot.serial}-{description.key}" - - @property - def device_info(self) -> DeviceInfo: - """Return the device information for a Litter-Robot.""" - assert self.robot.serial - return DeviceInfo( - identifiers={(DOMAIN, self.robot.serial)}, - manufacturer="Litter-Robot", - model=self.robot.model, - name=self.robot.name, - sw_version=getattr(self.robot, "firmware", None), + self._attr_unique_id = f"{robot.serial}-{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, robot.serial)}, + manufacturer="Whisker", + model=robot.model, + name=robot.name, + serial_number=robot.serial, + sw_version=getattr(robot, "firmware", None), ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index 06f3bfc9ce7..1a3d2fc2fb4 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -14,9 +14,8 @@ from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LitterRobotConfigEntry +from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator from .entity import LitterRobotEntity, _RobotT -from .hub import LitterRobotHub _CastTypeT = TypeVar("_CastTypeT", int, float, str) @@ -72,14 +71,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot selects using config entry.""" - hub = entry.runtime_data - entities = [ - LitterRobotSelectEntity(robot=robot, hub=hub, description=description) - for robot in hub.account.robots + coordinator = entry.runtime_data + async_add_entities( + LitterRobotSelectEntity( + robot=robot, coordinator=coordinator, description=description + ) + for robot in coordinator.account.robots for robot_type, description in ROBOT_SELECT_MAP.items() if isinstance(robot, robot_type) - ] - async_add_entities(entities) + ) class LitterRobotSelectEntity( @@ -92,11 +92,11 @@ class LitterRobotSelectEntity( def __init__( self, robot: _RobotT, - hub: LitterRobotHub, + coordinator: LitterRobotDataUpdateCoordinator, description: RobotSelectEntityDescription[_RobotT, _CastTypeT], ) -> None: """Initialize a Litter-Robot select entity.""" - super().__init__(robot, hub, description) + super().__init__(robot, coordinator, description) options = self.entity_description.options_fn(self.robot) self._attr_options = list(map(str, options)) diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index c110b89c7da..9541bca58c7 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfMass from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LitterRobotConfigEntry +from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT @@ -159,12 +159,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot sensors using config entry.""" - hub = entry.runtime_data - entities = [ - LitterRobotSensorEntity(robot=robot, hub=hub, description=description) - for robot in hub.account.robots + coordinator = entry.runtime_data + async_add_entities( + LitterRobotSensorEntity( + robot=robot, coordinator=coordinator, description=description + ) + for robot in coordinator.account.robots for robot_type, entity_descriptions in ROBOT_SENSOR_MAP.items() if isinstance(robot, robot_type) for description in entity_descriptions - ] - async_add_entities(entities) + ) diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py index a73449b01a1..7ded89d552b 100644 --- a/homeassistant/components/litterrobot/switch.py +++ b/homeassistant/components/litterrobot/switch.py @@ -13,7 +13,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LitterRobotConfigEntry +from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT @@ -48,14 +48,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot switches using config entry.""" - hub = entry.runtime_data - entities = [ - RobotSwitchEntity(robot=robot, hub=hub, description=description) + coordinator = entry.runtime_data + async_add_entities( + RobotSwitchEntity(robot=robot, coordinator=coordinator, description=description) for description in ROBOT_SWITCHES - for robot in hub.account.robots + for robot in coordinator.account.robots if isinstance(robot, (LitterRobot, FeederRobot)) - ] - async_add_entities(entities) + ) class RobotSwitchEntity(LitterRobotEntity[_RobotT], SwitchEntity): diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py index 7720798c8b8..6e3743059b3 100644 --- a/homeassistant/components/litterrobot/time.py +++ b/homeassistant/components/litterrobot/time.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from . import LitterRobotConfigEntry +from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT @@ -52,15 +52,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot cleaner using config entry.""" - hub = entry.runtime_data + coordinator = entry.runtime_data async_add_entities( - [ - LitterRobotTimeEntity( - robot=robot, hub=hub, description=LITTER_ROBOT_3_SLEEP_START - ) - for robot in hub.litter_robots() - if isinstance(robot, LitterRobot3) - ] + LitterRobotTimeEntity( + robot=robot, + coordinator=coordinator, + description=LITTER_ROBOT_3_SLEEP_START, + ) + for robot in coordinator.litter_robots() + if isinstance(robot, LitterRobot3) ) diff --git a/homeassistant/components/litterrobot/update.py b/homeassistant/components/litterrobot/update.py index 1d3e1dff57c..53ab23e9db8 100644 --- a/homeassistant/components/litterrobot/update.py +++ b/homeassistant/components/litterrobot/update.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LitterRobotConfigEntry +from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity SCAN_INTERVAL = timedelta(days=1) @@ -34,12 +34,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot update platform.""" - hub = entry.runtime_data - entities = [ - RobotUpdateEntity(robot=robot, hub=hub, description=FIRMWARE_UPDATE_ENTITY) - for robot in hub.litter_robots() + coordinator = entry.runtime_data + entities = ( + RobotUpdateEntity( + robot=robot, coordinator=coordinator, description=FIRMWARE_UPDATE_ENTITY + ) + for robot in coordinator.litter_robots() if isinstance(robot, LitterRobot4) - ] + ) async_add_entities(entities, True) diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index 19789fb387c..2f9e2e9b24d 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -20,7 +20,7 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from . import LitterRobotConfigEntry +from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity SERVICE_SET_SLEEP_MODE = "set_sleep_mode" @@ -49,12 +49,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot cleaner using config entry.""" - hub = entry.runtime_data - entities = [ - LitterRobotCleaner(robot=robot, hub=hub, description=LITTER_BOX_ENTITY) - for robot in hub.litter_robots() - ] - async_add_entities(entities) + coordinator = entry.runtime_data + async_add_entities( + LitterRobotCleaner( + robot=robot, coordinator=coordinator, description=LITTER_BOX_ENTITY + ) + for robot in coordinator.litter_robots() + ) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index 181e4fc1a90..17c77f0ce8f 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -117,7 +117,7 @@ def mock_account_with_side_effects() -> MagicMock: async def setup_integration( hass: HomeAssistant, mock_account: MagicMock, platform_domain: str | None = None ) -> MockConfigEntry: - """Load a Litter-Robot platform with the provided hub.""" + """Load a Litter-Robot platform with the provided coordinator.""" entry = MockConfigEntry( domain=litterrobot.DOMAIN, data=CONFIG[litterrobot.DOMAIN], @@ -126,7 +126,7 @@ async def setup_integration( with ( patch( - "homeassistant.components.litterrobot.hub.Account", + "homeassistant.components.litterrobot.coordinator.Account", return_value=mock_account, ), patch( diff --git a/tests/components/litterrobot/test_init.py b/tests/components/litterrobot/test_init.py index 1c8e0742b26..773f0273016 100644 --- a/tests/components/litterrobot/test_init.py +++ b/tests/components/litterrobot/test_init.py @@ -63,7 +63,7 @@ async def test_entry_not_setup( entry.add_to_hass(hass) with patch( - "homeassistant.components.litterrobot.hub.Account.connect", + "homeassistant.components.litterrobot.coordinator.Account.connect", side_effect=side_effect, ): await hass.config_entries.async_setup(entry.entry_id) From 3a493bb6c099fabf2bf9cba4c48c128b8b0475c6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Jan 2025 22:29:00 +0100 Subject: [PATCH 0835/2987] Improve type hints in benchmark script (#136259) --- homeassistant/scripts/benchmark/__init__.py | 22 ++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index b769d385a4f..c16269a2a8b 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -58,7 +58,7 @@ def benchmark[_CallableT: Callable](func: _CallableT) -> _CallableT: @benchmark -async def fire_events(hass): +async def fire_events(hass: core.HomeAssistant) -> float: """Fire a million events.""" count = 0 event_name = "benchmark_event" @@ -85,7 +85,7 @@ async def fire_events(hass): @benchmark -async def fire_events_with_filter(hass): +async def fire_events_with_filter(hass: core.HomeAssistant) -> float: """Fire a million events with a filter that rejects them.""" count = 0 event_name = "benchmark_event" @@ -117,7 +117,7 @@ async def fire_events_with_filter(hass): @benchmark -async def state_changed_helper(hass): +async def state_changed_helper(hass: core.HomeAssistant) -> float: """Run a million events through state changed helper with 1000 entities.""" count = 0 entity_id = "light.kitchen" @@ -141,7 +141,7 @@ async def state_changed_helper(hass): } for _ in range(10**6): - hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) + hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) # type: ignore[misc] start = timer() @@ -151,7 +151,7 @@ async def state_changed_helper(hass): @benchmark -async def state_changed_event_helper(hass): +async def state_changed_event_helper(hass: core.HomeAssistant) -> float: """Run a million events through state changed event helper with 1000 entities.""" count = 0 entity_id = "light.kitchen" @@ -174,7 +174,7 @@ async def state_changed_event_helper(hass): } for _ in range(events_to_fire): - hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) + hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) # type: ignore[misc] start = timer() @@ -186,7 +186,7 @@ async def state_changed_event_helper(hass): @benchmark -async def state_changed_event_filter_helper(hass): +async def state_changed_event_filter_helper(hass: core.HomeAssistant) -> float: """Run a million events through state changed event helper. With 1000 entities that all get filtered. @@ -212,7 +212,7 @@ async def state_changed_event_filter_helper(hass): } for _ in range(events_to_fire): - hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) + hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) # type: ignore[misc] start = timer() @@ -224,7 +224,7 @@ async def state_changed_event_filter_helper(hass): @benchmark -async def filtering_entity_id(hass): +async def filtering_entity_id(hass: core.HomeAssistant) -> float: """Run a 100k state changes through entity filter.""" config = { "include": { @@ -289,7 +289,7 @@ async def filtering_entity_id(hass): @benchmark -async def valid_entity_id(hass): +async def valid_entity_id(hass: core.HomeAssistant) -> float: """Run valid entity ID a million times.""" start = timer() for _ in range(10**6): @@ -298,7 +298,7 @@ async def valid_entity_id(hass): @benchmark -async def json_serialize_states(hass): +async def json_serialize_states(hass: core.HomeAssistant) -> float: """Serialize million states with websocket default encoder.""" states = [ core.State("light.kitchen", "on", {"friendly_name": "Kitchen Lights"}) From cad49453eb9c655697941c2d0cdd85beb985ad44 Mon Sep 17 00:00:00 2001 From: Thomas Lake Date: Wed, 22 Jan 2025 21:30:04 +0000 Subject: [PATCH 0836/2987] ping: Suppress ProcessLookupError on timeout (#134281) --- homeassistant/components/ping/helpers.py | 2 +- tests/components/ping/test_helpers.py | 59 ++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 tests/components/ping/test_helpers.py diff --git a/homeassistant/components/ping/helpers.py b/homeassistant/components/ping/helpers.py index 82ebf4532da..996faa99c5b 100644 --- a/homeassistant/components/ping/helpers.py +++ b/homeassistant/components/ping/helpers.py @@ -160,7 +160,7 @@ class PingDataSubProcess(PingData): ) if pinger: - with suppress(TypeError): + with suppress(TypeError, ProcessLookupError): await pinger.kill() # type: ignore[func-returns-value] del pinger diff --git a/tests/components/ping/test_helpers.py b/tests/components/ping/test_helpers.py new file mode 100644 index 00000000000..5a90c6b75b2 --- /dev/null +++ b/tests/components/ping/test_helpers.py @@ -0,0 +1,59 @@ +"""Test the exception handling in subprocess version of async_ping.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.ping.helpers import PingDataSubProcess +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +class MockAsyncSubprocess: + """Minimal mock implementation of asyncio.subprocess.Process for exception testing.""" + + def __init__(self, killsig=ProcessLookupError, **kwargs) -> None: + """Store provided exception type for later.""" + self.killsig = killsig + + async def communicate(self) -> None: + """Fails immediately with a timeout.""" + raise TimeoutError + + async def kill(self) -> None: + """Raise preset exception when called.""" + raise self.killsig + + +@pytest.mark.parametrize("exc", [TypeError, ProcessLookupError]) +async def test_async_ping_expected_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + exc: Exception, +) -> None: + """Test PingDataSubProcess.async_ping handles expected exceptions.""" + with patch( + "asyncio.create_subprocess_exec", return_value=MockAsyncSubprocess(killsig=exc) + ): + # Actual parameters irrelevant, as subprocess will not be created + ping = PingDataSubProcess(hass, host="10.10.10.10", count=3, privileged=False) + assert await ping.async_ping() is None + + +async def test_async_ping_unexpected_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test PingDataSubProcess.async_ping does not suppress unexpected exceptions.""" + with patch( + "asyncio.create_subprocess_exec", + return_value=MockAsyncSubprocess(killsig=KeyboardInterrupt), + ): + # Actual parameters irrelevant, as subprocess will not be created + ping = PingDataSubProcess(hass, host="10.10.10.10", count=3, privileged=False) + with pytest.raises(KeyboardInterrupt): + assert await ping.async_ping() is None From 4a7e009f27e5285749f3626f2226d5a6754257f6 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 22 Jan 2025 16:57:13 -0500 Subject: [PATCH 0837/2987] Allow time triggers with offsets to use input_datetimes (#131550) --- .../components/homeassistant/triggers/time.py | 32 +++++-- .../homeassistant/triggers/test_time.py | 84 ++++++++++++++++++- 2 files changed, 103 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index bea6e8a66a7..5cd1921d8a8 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -42,7 +42,7 @@ _TIME_AT_SCHEMA = vol.Any(cv.time, _TIME_TRIGGER_ENTITY) _TIME_TRIGGER_ENTITY_WITH_OFFSET = vol.Schema( { - vol.Required(CONF_ENTITY_ID): cv.entity_domain(["sensor"]), + vol.Required(CONF_ENTITY_ID): cv.entity_domain(["input_datetime", "sensor"]), vol.Optional(CONF_OFFSET): cv.time_period, } ) @@ -156,14 +156,17 @@ async def async_attach_trigger( if has_date: # If input_datetime has date, then track point in time. - trigger_dt = datetime( - year, - month, - day, - hour, - minute, - second, - tzinfo=dt_util.get_default_time_zone(), + trigger_dt = ( + datetime( + year, + month, + day, + hour, + minute, + second, + tzinfo=dt_util.get_default_time_zone(), + ) + + offset ) # Only set up listener if time is now or in the future. if trigger_dt >= dt_util.now(): @@ -178,6 +181,17 @@ async def async_attach_trigger( ) elif has_time: # Else if it has time, then track time change. + if offset != timedelta(0): + # Create a temporary datetime object to get an offset. + temp_dt = dt_util.now().replace( + hour=hour, minute=minute, second=second, microsecond=0 + ) + temp_dt += offset + # Ignore the date and apply the offset even if it wraps + # around to the next day. + hour = temp_dt.hour + minute = temp_dt.minute + second = temp_dt.second remove = async_track_time_change( hass, partial( diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index 8900998a7b8..40f62baa5e7 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -156,6 +156,86 @@ async def test_if_fires_using_at_input_datetime( ) +@pytest.mark.parametrize(("hour"), [0, 5, 23]) +@pytest.mark.parametrize( + ("has_date", "has_time"), [(True, True), (False, True), (True, False)] +) +@pytest.mark.parametrize( + ("offset", "delta"), + [ + ("00:00:10", timedelta(seconds=10)), + ("-00:00:10", timedelta(seconds=-10)), + ({"minutes": 5}, timedelta(minutes=5)), + ("01:00:10", timedelta(hours=1, seconds=10)), + ], +) +async def test_if_fires_using_at_input_datetime_with_offset( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + service_calls: list[ServiceCall], + has_date: bool, + has_time: bool, + offset: str, + delta: timedelta, + hour: int, +) -> None: + """Test for firing at input_datetime.""" + await async_setup_component( + hass, + "input_datetime", + {"input_datetime": {"trigger": {"has_date": has_date, "has_time": has_time}}}, + ) + now = dt_util.now() + + start_dt = now.replace( + hour=hour if has_time else 0, minute=0, second=0, microsecond=0 + ) + timedelta(2) + trigger_dt = start_dt + delta + + await hass.services.async_call( + "input_datetime", + "set_datetime", + { + ATTR_ENTITY_ID: "input_datetime.trigger", + "datetime": str(start_dt.replace(tzinfo=None)), + }, + blocking=True, + ) + await hass.async_block_till_done() + + time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) + + some_data = "{{ trigger.platform }}-{{ trigger.now.day }}-{{ trigger.now.hour }}-{{trigger.entity_id}}" + + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time", + "at": {"entity_id": "input_datetime.trigger", "offset": offset}, + }, + "action": { + "service": "test.automation", + "data_template": {"some": some_data}, + }, + } + }, + ) + await hass.async_block_till_done() + + async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(service_calls) == 2 + assert ( + service_calls[1].data["some"] + == f"time-{trigger_dt.day}-{trigger_dt.hour}-input_datetime.trigger" + ) + + @pytest.mark.parametrize( ("conf_at", "trigger_deltas"), [ @@ -654,10 +734,6 @@ def test_schema_valid(conf) -> None: {"platform": "time", "at": "binary_sensor.bla"}, {"platform": "time", "at": 745}, {"platform": "time", "at": "25:00"}, - { - "platform": "time", - "at": {"entity_id": "input_datetime.bla", "offset": "0:10"}, - }, {"platform": "time", "at": {"entity_id": "13:00:00", "offset": "0:10"}}, ], ) From 544c4a05834d9641fe0b923764c9af1bea56c8d4 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Wed, 22 Jan 2025 15:03:50 -0700 Subject: [PATCH 0838/2987] Cleanup litterrobot sensor entity (#136287) --- .../components/litterrobot/sensor.py | 66 +++++++++++-------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index 9541bca58c7..6545d7c7ae7 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from typing import Any, Generic, cast +from typing import Any, Generic from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Robot @@ -34,34 +34,12 @@ def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str return "mdi:gauge-low" -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class RobotSensorEntityDescription(SensorEntityDescription, Generic[_RobotT]): """A class that describes robot sensor entities.""" icon_fn: Callable[[Any], str | None] = lambda _: None - should_report: Callable[[_RobotT], bool] = lambda _: True - - -class LitterRobotSensorEntity(LitterRobotEntity[_RobotT], SensorEntity): - """Litter-Robot sensor entity.""" - - entity_description: RobotSensorEntityDescription[_RobotT] - - @property - def native_value(self) -> float | datetime | str | None: - """Return the state.""" - if self.entity_description.should_report(self.robot): - if isinstance(val := getattr(self.robot, self.entity_description.key), str): - return val.lower() - return cast(float | datetime | None, val) - return None - - @property - def icon(self) -> str | None: - """Return the icon to use in the frontend, if any.""" - if (icon := self.entity_description.icon_fn(self.state)) is not None: - return icon - return super().icon + value_fn: Callable[[_RobotT], float | datetime | str | None] ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { @@ -72,24 +50,34 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { native_unit_of_measurement=PERCENTAGE, icon_fn=lambda state: icon_for_gauge_level(state, 10), state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda robot: robot.waste_drawer_level, ), RobotSensorEntityDescription[LitterRobot]( key="sleep_mode_start_time", translation_key="sleep_mode_start_time", device_class=SensorDeviceClass.TIMESTAMP, - should_report=lambda robot: robot.sleep_mode_enabled, + value_fn=( + lambda robot: robot.sleep_mode_start_time + if robot.sleep_mode_enabled + else None + ), ), RobotSensorEntityDescription[LitterRobot]( key="sleep_mode_end_time", translation_key="sleep_mode_end_time", device_class=SensorDeviceClass.TIMESTAMP, - should_report=lambda robot: robot.sleep_mode_enabled, + value_fn=( + lambda robot: robot.sleep_mode_end_time + if robot.sleep_mode_enabled + else None + ), ), RobotSensorEntityDescription[LitterRobot]( key="last_seen", translation_key="last_seen", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda robot: robot.last_seen, ), RobotSensorEntityDescription[LitterRobot]( key="status_code", @@ -123,6 +111,9 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { "sdf", "spf", ], + value_fn=( + lambda robot: status.lower() if (status := robot.status_code) else None + ), ), ], LitterRobot4: [ @@ -132,6 +123,7 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { native_unit_of_measurement=PERCENTAGE, icon_fn=lambda state: icon_for_gauge_level(state, 10), state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda robot: robot.litter_level, ), RobotSensorEntityDescription[LitterRobot4]( key="pet_weight", @@ -139,6 +131,7 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { native_unit_of_measurement=UnitOfMass.POUNDS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda robot: robot.pet_weight, ), ], FeederRobot: [ @@ -148,6 +141,7 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { native_unit_of_measurement=PERCENTAGE, icon_fn=lambda state: icon_for_gauge_level(state, 10), state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda robot: robot.food_level, ) ], } @@ -169,3 +163,21 @@ async def async_setup_entry( if isinstance(robot, robot_type) for description in entity_descriptions ) + + +class LitterRobotSensorEntity(LitterRobotEntity[_RobotT], SensorEntity): + """Litter-Robot sensor entity.""" + + entity_description: RobotSensorEntityDescription[_RobotT] + + @property + def native_value(self) -> float | datetime | str | None: + """Return the state.""" + return self.entity_description.value_fn(self.robot) + + @property + def icon(self) -> str | None: + """Return the icon to use in the frontend, if any.""" + if (icon := self.entity_description.icon_fn(self.state)) is not None: + return icon + return super().icon From ff7601e676d3accb996b98e628ef109eccee3882 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 22 Jan 2025 23:30:10 +0100 Subject: [PATCH 0839/2987] Bump incomfort-client to v0.6.7 (#136285) * Bump incomfort-client to v0.6.7 * Fix mypy --- homeassistant/components/incomfort/climate.py | 2 +- homeassistant/components/incomfort/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index 32fec3951ae..f814b1fb1f5 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -108,7 +108,7 @@ class InComfortClimate(IncomfortEntity, ClimateEntity): async def async_set_temperature(self, **kwargs: Any) -> None: """Set a new target temperature for this zone.""" - temperature = kwargs.get(ATTR_TEMPERATURE) + temperature: float = kwargs[ATTR_TEMPERATURE] await self._room.set_override(temperature) await self.coordinator.async_refresh() diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index 65d781b1189..f4d752bfa48 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -10,5 +10,5 @@ "documentation": "https://www.home-assistant.io/integrations/incomfort", "iot_class": "local_polling", "loggers": ["incomfortclient"], - "requirements": ["incomfort-client==0.6.4"] + "requirements": ["incomfort-client==0.6.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index e1f7fea3abd..b1d687f5add 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1208,7 +1208,7 @@ ihcsdk==2.8.5 imgw_pib==1.0.9 # homeassistant.components.incomfort -incomfort-client==0.6.4 +incomfort-client==0.6.7 # homeassistant.components.influxdb influxdb-client==1.24.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 27c8bba219d..6aba27b0656 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1022,7 +1022,7 @@ igloohome-api==0.0.6 imgw_pib==1.0.9 # homeassistant.components.incomfort -incomfort-client==0.6.4 +incomfort-client==0.6.7 # homeassistant.components.influxdb influxdb-client==1.24.0 From 6fa4cbd3e1fa33b70f71293dd76c506c1840f7d5 Mon Sep 17 00:00:00 2001 From: rwalker777 <49888088+rwalker777@users.noreply.github.com> Date: Wed, 22 Jan 2025 22:04:39 -0600 Subject: [PATCH 0840/2987] Revert "Add Tuya based bluetooth lights" (#133386) Co-authored-by: J. Nick Koston --- homeassistant/components/led_ble/manifest.json | 3 --- homeassistant/generated/bluetooth.py | 4 ---- 2 files changed, 7 deletions(-) diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 24e986000bb..1c04337354e 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -26,9 +26,6 @@ { "local_name": "AP-*" }, - { - "local_name": "MELK-*" - }, { "local_name": "LD-0003" } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index b4e6660275c..8a5880dcde9 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -434,10 +434,6 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "led_ble", "local_name": "AP-*", }, - { - "domain": "led_ble", - "local_name": "MELK-*", - }, { "domain": "led_ble", "local_name": "LD-0003", From 43d8c0bb6e07185ea46dd6c18de9bbfc2fc1a661 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Wed, 22 Jan 2025 23:10:52 -0500 Subject: [PATCH 0841/2987] Fallback to None for literal "Blank" serial number for APCUPSD integration (#136297) * Fallback to None for Blank serial number * Fix comments --- homeassistant/components/apcupsd/coordinator.py | 5 ++++- tests/components/apcupsd/test_config_flow.py | 2 ++ tests/components/apcupsd/test_init.py | 8 ++++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py index 768e9605967..1ae12d8c4b0 100644 --- a/homeassistant/components/apcupsd/coordinator.py +++ b/homeassistant/components/apcupsd/coordinator.py @@ -44,7 +44,10 @@ class APCUPSdData(dict[str, str]): @property def serial_no(self) -> str | None: """Return the unique serial number of the UPS, if available.""" - return self.get("SERIALNO") + sn = self.get("SERIALNO") + # We had user reports that some UPS models simply return "Blank" as serial number, in + # which case we fall back to `None` to indicate that it is actually not available. + return None if sn == "Blank" else sn class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]): diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index 88594260579..0b8386dbb5a 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -125,6 +125,8 @@ async def test_flow_works(hass: HomeAssistant) -> None: ({"UPSNAME": "Friendly Name"}, "Friendly Name"), ({"MODEL": "MODEL X"}, "MODEL X"), ({"SERIALNO": "ZZZZ"}, "ZZZZ"), + # Some models report "Blank" as serial number, which we should treat it as not reported. + ({"SERIALNO": "Blank"}, "APC UPS"), ({}, "APC UPS"), ], ) diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 723ec164eae..6bb94ca2948 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -31,6 +31,8 @@ from tests.common import MockConfigEntry, async_fire_time_changed # Does not contain either "SERIALNO" field. # We should _not_ create devices for the entities and their IDs will not have prefixes. MOCK_MINIMAL_STATUS, + # Some models report "Blank" as SERIALNO, but we should treat it as not reported. + MOCK_MINIMAL_STATUS | {"SERIALNO": "Blank"}, ], ) async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> None: @@ -41,7 +43,7 @@ async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> No await async_init_integration(hass, status=status) prefix = "" - if "SERIALNO" in status: + if "SERIALNO" in status and status["SERIALNO"] != "Blank": prefix = slugify(status.get("UPSNAME", "APC UPS")) + "_" # Verify successful setup by querying the status sensor. @@ -56,6 +58,8 @@ async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> No [ # We should not create device entries if SERIALNO is not reported. MOCK_MINIMAL_STATUS, + # Some models report "Blank" as SERIALNO, but we should treat it as not reported. + MOCK_MINIMAL_STATUS | {"SERIALNO": "Blank"}, # We should set the device name to be the friendly UPSNAME field if available. MOCK_MINIMAL_STATUS | {"SERIALNO": "XXXX", "UPSNAME": "MyUPS"}, # Otherwise, we should fall back to default device name --- "APC UPS". @@ -71,7 +75,7 @@ async def test_device_entry( await async_init_integration(hass, status=status) # Verify device info is properly set up. - if "SERIALNO" not in status: + if "SERIALNO" not in status or status["SERIALNO"] == "Blank": assert len(device_registry.devices) == 0 return From 68b6a7c9870f470c0cde4a37eb48efebaf126fb1 Mon Sep 17 00:00:00 2001 From: Jeff Terrace Date: Wed, 22 Jan 2025 23:19:09 -0500 Subject: [PATCH 0842/2987] Add TP-Link Tapo pet detection to onvif parsers (#136303) --- homeassistant/components/onvif/parsers.py | 3 + tests/components/onvif/test_parsers.py | 76 +++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 9904a4bbfa9..6eb1d001796 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -381,6 +381,9 @@ _TAPO_EVENT_TEMPLATES: dict[str, Event] = { "IsPeople": Event( uid="", name="Person Detection", platform="binary_sensor", device_class="motion" ), + "IsPet": Event( + uid="", name="Pet Detection", platform="binary_sensor", device_class="motion" + ), "IsLineCross": Event( uid="", name="Line Detector Crossed", diff --git a/tests/components/onvif/test_parsers.py b/tests/components/onvif/test_parsers.py index 16172112c11..4f7e10abae6 100644 --- a/tests/components/onvif/test_parsers.py +++ b/tests/components/onvif/test_parsers.py @@ -426,6 +426,82 @@ async def test_tapo_tpsmartevent_person(hass: HomeAssistant) -> None: ) +async def test_tapo_tpsmartevent_pet(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - pet.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://192.168.56.63:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://192.168.56.63:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyTPSmartEventDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsPet", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 22, 13, 24, 57, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Pet Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/TPSmartEventDetector/" + "TPSmartEvent_VideoSourceToken_VideoAnalyticsToken_MyTPSmartEventDetectorRule" + ) + + async def test_tapo_cellmotiondetector_person(hass: HomeAssistant) -> None: """Tests tns1:RuleEngine/CellMotionDetector/People - person.""" event = await get_event( From ce792f6fe933186a018b1f4359479ca755860302 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jan 2025 18:19:56 -1000 Subject: [PATCH 0843/2987] Bump onvif-zeep-async to 3.2.5 (#136299) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 9d27314593c..c4d2b7f8812 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==3.2.3", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==3.2.5", "WSDiscovery==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b1d687f5add..a233ef7e30e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1552,7 +1552,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onvif -onvif-zeep-async==3.2.3 +onvif-zeep-async==3.2.5 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6aba27b0656..efd5c14e5ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1300,7 +1300,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onvif -onvif-zeep-async==3.2.3 +onvif-zeep-async==3.2.5 # homeassistant.components.opengarage open-garage==0.2.0 From 7afd1f8cf8f6c39625119f50aa372d99ec3b14a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jan 2025 18:24:12 -1000 Subject: [PATCH 0844/2987] Avoid useless data conversion in sonos config flow (#136294) We would convert the zeroconf data to a dict and pass it to async_step_discovery which does nothing with it --- homeassistant/components/sonos/config_flow.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/sonos/config_flow.py b/homeassistant/components/sonos/config_flow.py index 66fe0f0d78c..057cdb8ec08 100644 --- a/homeassistant/components/sonos/config_flow.py +++ b/homeassistant/components/sonos/config_flow.py @@ -1,7 +1,6 @@ """Config flow for SONOS.""" from collections.abc import Awaitable -import dataclasses from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlowResult @@ -32,15 +31,15 @@ class SonosDiscoveryFlowHandler(DiscoveryFlowHandler[Awaitable[bool]], domain=DO hostname = discovery_info.hostname if hostname is None or not hostname.lower().startswith("sonos"): return self.async_abort(reason="not_sonos_device") - await self.async_set_unique_id(self._domain, raise_on_progress=False) - host = discovery_info.host - mdns_name = discovery_info.name - properties = discovery_info.properties - boot_seqnum = properties.get("bootseq") - model = properties.get("model") - uid = hostname_to_uid(hostname) if discovery_manager := self.hass.data.get(DATA_SONOS_DISCOVERY_MANAGER): + host = discovery_info.host + mdns_name = discovery_info.name + properties = discovery_info.properties + boot_seqnum = properties.get("bootseq") + model = properties.get("model") + uid = hostname_to_uid(hostname) discovery_manager.async_discovered_player( "Zeroconf", properties, host, uid, boot_seqnum, model, mdns_name ) - return await self.async_step_discovery(dataclasses.asdict(discovery_info)) + await self.async_set_unique_id(self._domain, raise_on_progress=False) + return await self.async_step_discovery({}) From 29ce89ee4fc43d17328683ec3b2138fe618f22f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jan 2025 18:25:03 -1000 Subject: [PATCH 0845/2987] Bump zeroconf to 0.141.0 (#136292) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.140.1...0.141.0 --- 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 b301c1ad191..6fe2b5b1923 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.140.1"] + "requirements": ["zeroconf==0.141.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c8d3165e177..aa5fa65f7b9 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.140.1 +zeroconf==0.141.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 3c8f68c5111..56f2533840a 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.140.1" + "zeroconf==0.141.0" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index e7a092c55a2..f1eb8dac825 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.140.1 +zeroconf==0.141.0 diff --git a/requirements_all.txt b/requirements_all.txt index a233ef7e30e..c4db50c32d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3118,7 +3118,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.140.1 +zeroconf==0.141.0 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index efd5c14e5ba..d85b918d548 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2507,7 +2507,7 @@ yt-dlp[default]==2025.01.15 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.140.1 +zeroconf==0.141.0 # homeassistant.components.zeversolar zeversolar==0.3.2 From 75bdcee3e4921410485e3a99fbc8596036dfc5c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jan 2025 18:45:44 -1000 Subject: [PATCH 0846/2987] Bump led-ble to 1.1.4 (#136301) --- homeassistant/components/led_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 1c04337354e..7b07653e2db 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.22.0", "led-ble==1.1.1"] + "requirements": ["bluetooth-data-tools==1.22.0", "led-ble==1.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index c4db50c32d8..28b41f4f335 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1296,7 +1296,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.1 +led-ble==1.1.4 # homeassistant.components.lektrico lektricowifi==0.0.43 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d85b918d548..d169f7e5435 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1095,7 +1095,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.1 +led-ble==1.1.4 # homeassistant.components.lektrico lektricowifi==0.0.43 From 90bd783fff1a2e88b6cc3edf2c48799289c81413 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Thu, 23 Jan 2025 00:17:59 -0700 Subject: [PATCH 0847/2987] Standardize DOMAIN usage in litterrobot tests (#136290) * Standardize DOMAIN usage in litterrobot tests * Fix additional DOMAIN references in tests * Make platform domain usage more clear in tests --- .../components/litterrobot/quality_scale.yaml | 1 - tests/components/litterrobot/conftest.py | 7 +++---- .../litterrobot/test_binary_sensor.py | 4 ++-- .../litterrobot/test_config_flow.py | 21 +++++++++---------- tests/components/litterrobot/test_init.py | 9 ++++---- tests/components/litterrobot/test_select.py | 14 ++++++------- tests/components/litterrobot/test_vacuum.py | 21 +++++++++---------- 7 files changed, 36 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/litterrobot/quality_scale.yaml b/homeassistant/components/litterrobot/quality_scale.yaml index bf4392bede6..3eae5d3e668 100644 --- a/homeassistant/components/litterrobot/quality_scale.yaml +++ b/homeassistant/components/litterrobot/quality_scale.yaml @@ -27,7 +27,6 @@ rules: status: todo comment: | Fix stale title and docstring - Replace litterrobot.DOMAIN references to DOMAIN (after correctly importing) Make sure every test ends in either ABORT or CREATE_ENTRY so we also test that the flow is able to recover config-flow: done diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index 17c77f0ce8f..5cd97e5937d 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -9,10 +9,9 @@ from pylitterbot import Account, FeederRobot, LitterRobot3, LitterRobot4, Robot from pylitterbot.exceptions import InvalidCommandException import pytest -from homeassistant.components import litterrobot from homeassistant.core import HomeAssistant -from .common import CONFIG, FEEDER_ROBOT_DATA, ROBOT_4_DATA, ROBOT_DATA +from .common import CONFIG, DOMAIN, FEEDER_ROBOT_DATA, ROBOT_4_DATA, ROBOT_DATA from tests.common import MockConfigEntry @@ -119,8 +118,8 @@ async def setup_integration( ) -> MockConfigEntry: """Load a Litter-Robot platform with the provided coordinator.""" entry = MockConfigEntry( - domain=litterrobot.DOMAIN, - data=CONFIG[litterrobot.DOMAIN], + domain=DOMAIN, + data=CONFIG[DOMAIN], ) entry.add_to_hass(hass) diff --git a/tests/components/litterrobot/test_binary_sensor.py b/tests/components/litterrobot/test_binary_sensor.py index 69b3f7ce3ab..3fe72aef7e3 100644 --- a/tests/components/litterrobot/test_binary_sensor.py +++ b/tests/components/litterrobot/test_binary_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock import pytest from homeassistant.components.binary_sensor import ( - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, ) from homeassistant.const import ATTR_DEVICE_CLASS @@ -21,7 +21,7 @@ async def test_binary_sensors( mock_account: MagicMock, ) -> None: """Tests binary sensors.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, BINARY_SENSOR_DOMAIN) state = hass.states.get("binary_sensor.test_sleeping") assert state.state == "off" diff --git a/tests/components/litterrobot/test_config_flow.py b/tests/components/litterrobot/test_config_flow.py index 9420d3cb8a8..2eadafb0d0c 100644 --- a/tests/components/litterrobot/test_config_flow.py +++ b/tests/components/litterrobot/test_config_flow.py @@ -6,7 +6,6 @@ from pylitterbot import Account from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException from homeassistant import config_entries -from homeassistant.components import litterrobot from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -49,14 +48,14 @@ async def test_form(hass: HomeAssistant, mock_account) -> None: async def test_already_configured(hass: HomeAssistant) -> None: """Test we handle already configured.""" MockConfigEntry( - domain=litterrobot.DOMAIN, - data=CONFIG[litterrobot.DOMAIN], + domain=DOMAIN, + data=CONFIG[DOMAIN], ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, - data=CONFIG[litterrobot.DOMAIN], + data=CONFIG[DOMAIN], ) assert result["type"] is FlowResultType.ABORT @@ -119,8 +118,8 @@ async def test_form_unknown_error(hass: HomeAssistant) -> None: async def test_step_reauth(hass: HomeAssistant, mock_account: Account) -> None: """Test the reauth flow.""" entry = MockConfigEntry( - domain=litterrobot.DOMAIN, - data=CONFIG[litterrobot.DOMAIN], + domain=DOMAIN, + data=CONFIG[DOMAIN], ) entry.add_to_hass(hass) @@ -141,7 +140,7 @@ async def test_step_reauth(hass: HomeAssistant, mock_account: Account) -> None: ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_PASSWORD: CONFIG[litterrobot.DOMAIN][CONF_PASSWORD]}, + user_input={CONF_PASSWORD: CONFIG[DOMAIN][CONF_PASSWORD]}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -151,8 +150,8 @@ async def test_step_reauth(hass: HomeAssistant, mock_account: Account) -> None: async def test_step_reauth_failed(hass: HomeAssistant, mock_account: Account) -> None: """Test the reauth flow fails and recovers.""" entry = MockConfigEntry( - domain=litterrobot.DOMAIN, - data=CONFIG[litterrobot.DOMAIN], + domain=DOMAIN, + data=CONFIG[DOMAIN], ) entry.add_to_hass(hass) @@ -167,7 +166,7 @@ async def test_step_reauth_failed(hass: HomeAssistant, mock_account: Account) -> ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_PASSWORD: CONFIG[litterrobot.DOMAIN][CONF_PASSWORD]}, + user_input={CONF_PASSWORD: CONFIG[DOMAIN][CONF_PASSWORD]}, ) assert result["type"] is FlowResultType.FORM @@ -185,7 +184,7 @@ async def test_step_reauth_failed(hass: HomeAssistant, mock_account: Account) -> ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_PASSWORD: CONFIG[litterrobot.DOMAIN][CONF_PASSWORD]}, + user_input={CONF_PASSWORD: CONFIG[DOMAIN][CONF_PASSWORD]}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" diff --git a/tests/components/litterrobot/test_init.py b/tests/components/litterrobot/test_init.py index 773f0273016..e42bdb048b7 100644 --- a/tests/components/litterrobot/test_init.py +++ b/tests/components/litterrobot/test_init.py @@ -5,7 +5,6 @@ from unittest.mock import MagicMock, patch from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException import pytest -from homeassistant.components import litterrobot from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_START, @@ -17,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .common import CONFIG, VACUUM_ENTITY_ID +from .common import CONFIG, DOMAIN, VACUUM_ENTITY_ID from .conftest import setup_integration from tests.common import MockConfigEntry @@ -57,8 +56,8 @@ async def test_entry_not_setup( ) -> None: """Test being able to handle config entry not setup.""" entry = MockConfigEntry( - domain=litterrobot.DOMAIN, - data=CONFIG[litterrobot.DOMAIN], + domain=DOMAIN, + data=CONFIG[DOMAIN], ) entry.add_to_hass(hass) @@ -91,7 +90,7 @@ async def test_device_remove_devices( dead_device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(litterrobot.DOMAIN, "test-serial", "remove-serial")}, + identifiers={(DOMAIN, "test-serial", "remove-serial")}, ) response = await client.remove_device(dead_device_entry.id, config_entry.entry_id) assert response["success"] diff --git a/tests/components/litterrobot/test_select.py b/tests/components/litterrobot/test_select.py index 48ec1bb06a5..b4902a56e63 100644 --- a/tests/components/litterrobot/test_select.py +++ b/tests/components/litterrobot/test_select.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) from homeassistant.const import ATTR_ENTITY_ID, EntityCategory @@ -26,7 +26,7 @@ async def test_wait_time_select( hass: HomeAssistant, mock_account, entity_registry: er.EntityRegistry ) -> None: """Tests the wait time select entity.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, SELECT_DOMAIN) select = hass.states.get(SELECT_ENTITY_ID) assert select @@ -41,7 +41,7 @@ async def test_wait_time_select( data[ATTR_OPTION] = wait_time await hass.services.async_call( - PLATFORM_DOMAIN, + SELECT_DOMAIN, SERVICE_SELECT_OPTION, data, blocking=True, @@ -52,7 +52,7 @@ async def test_wait_time_select( async def test_invalid_wait_time_select(hass: HomeAssistant, mock_account) -> None: """Tests the wait time select entity with invalid value.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, SELECT_DOMAIN) select = hass.states.get(SELECT_ENTITY_ID) assert select @@ -61,7 +61,7 @@ async def test_invalid_wait_time_select(hass: HomeAssistant, mock_account) -> No with pytest.raises(ServiceValidationError): await hass.services.async_call( - PLATFORM_DOMAIN, + SELECT_DOMAIN, SERVICE_SELECT_OPTION, data, blocking=True, @@ -75,7 +75,7 @@ async def test_panel_brightness_select( entity_registry: er.EntityRegistry, ) -> None: """Tests the wait time select entity.""" - await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_litterrobot_4, SELECT_DOMAIN) select = hass.states.get(PANEL_BRIGHTNESS_ENTITY_ID) assert select @@ -94,7 +94,7 @@ async def test_panel_brightness_select( data[ATTR_OPTION] = option await hass.services.async_call( - PLATFORM_DOMAIN, + SELECT_DOMAIN, SERVICE_SELECT_OPTION, data, blocking=True, diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index 16e58512ee8..0255e0e6a8a 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -8,10 +8,9 @@ from unittest.mock import MagicMock from pylitterbot import Robot import pytest -from homeassistant.components.litterrobot import DOMAIN from homeassistant.components.litterrobot.vacuum import SERVICE_SET_SLEEP_MODE from homeassistant.components.vacuum import ( - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as VACUUM_DOMAIN, SERVICE_START, SERVICE_STOP, VacuumActivity, @@ -20,7 +19,7 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir -from .common import VACUUM_ENTITY_ID +from .common import DOMAIN, VACUUM_ENTITY_ID from .conftest import setup_integration VACUUM_UNIQUE_ID = "LR3C012345-litter_box" @@ -36,15 +35,15 @@ async def test_vacuum( """Tests the vacuum entity was set up.""" entity_registry.async_get_or_create( - PLATFORM_DOMAIN, + VACUUM_DOMAIN, DOMAIN, VACUUM_UNIQUE_ID, - suggested_object_id=VACUUM_ENTITY_ID.replace(PLATFORM_DOMAIN, ""), + suggested_object_id=VACUUM_ENTITY_ID.replace(VACUUM_DOMAIN, ""), ) ent_reg_entry = entity_registry.async_get(VACUUM_ENTITY_ID) assert ent_reg_entry.unique_id == VACUUM_UNIQUE_ID - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, VACUUM_DOMAIN) assert len(entity_registry.entities) == 1 assert hass.services.has_service(DOMAIN, SERVICE_SET_SLEEP_MODE) @@ -62,7 +61,7 @@ async def test_no_robots( mock_account_with_no_robots: MagicMock, ) -> None: """Tests the vacuum entity was set up.""" - entry = await setup_integration(hass, mock_account_with_no_robots, PLATFORM_DOMAIN) + entry = await setup_integration(hass, mock_account_with_no_robots, VACUUM_DOMAIN) assert not hass.services.has_service(DOMAIN, SERVICE_SET_SLEEP_MODE) @@ -76,7 +75,7 @@ async def test_vacuum_with_error( hass: HomeAssistant, mock_account_with_error: MagicMock ) -> None: """Tests a vacuum entity with an error.""" - await setup_integration(hass, mock_account_with_error, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_error, VACUUM_DOMAIN) vacuum = hass.states.get(VACUUM_ENTITY_ID) assert vacuum @@ -101,7 +100,7 @@ async def test_activities( expected_state: str, ) -> None: """Test sending commands to the switch.""" - await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_litterrobot_4, VACUUM_DOMAIN) robot: Robot = mock_account_with_litterrobot_4.robots[0] robot._update_data(robot_data, partial=True) @@ -134,7 +133,7 @@ async def test_commands( issue_registry: ir.IssueRegistry, ) -> None: """Test sending commands to the vacuum.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, VACUUM_DOMAIN) vacuum = hass.states.get(VACUUM_ENTITY_ID) assert vacuum @@ -145,7 +144,7 @@ async def test_commands( issues = extra.get("issues", set()) await hass.services.async_call( - COMPONENT_SERVICE_DOMAIN.get(service, PLATFORM_DOMAIN), + COMPONENT_SERVICE_DOMAIN.get(service, VACUUM_DOMAIN), service, data, blocking=True, From 95b49fd2bc32cc9490c3b610773ee9dc2d4506b4 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Thu, 23 Jan 2025 07:20:03 +0000 Subject: [PATCH 0848/2987] Add time platform to ohme (#136289) --- homeassistant/components/ohme/const.py | 8 +- homeassistant/components/ohme/icons.json | 5 ++ homeassistant/components/ohme/manifest.json | 2 +- homeassistant/components/ohme/strings.json | 5 ++ homeassistant/components/ohme/time.py | 77 +++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/ohme/conftest.py | 1 + .../components/ohme/snapshots/test_time.ambr | 47 +++++++++++ tests/components/ohme/test_time.py | 55 +++++++++++++ 10 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/ohme/time.py create mode 100644 tests/components/ohme/snapshots/test_time.ambr create mode 100644 tests/components/ohme/test_time.py diff --git a/homeassistant/components/ohme/const.py b/homeassistant/components/ohme/const.py index 2b7410dc0eb..308664ba0ad 100644 --- a/homeassistant/components/ohme/const.py +++ b/homeassistant/components/ohme/const.py @@ -3,4 +3,10 @@ from homeassistant.const import Platform DOMAIN = "ohme" -PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BUTTON, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, + Platform.TIME, +] diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json index 6d187ff7e8d..a6b04004833 100644 --- a/homeassistant/components/ohme/icons.json +++ b/homeassistant/components/ohme/icons.json @@ -41,6 +41,11 @@ "off": "mdi:sleep-off" } } + }, + "time": { + "target_time": { + "default": "mdi:clock-end" + } } }, "services": { diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index 98c738cea3c..67c41550491 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.4"] + "requirements": ["ohme==1.2.5"] } diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 6ba06c98c44..84f62ba65ab 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -83,6 +83,11 @@ "sleep_when_inactive": { "name": "Sleep when inactive" } + }, + "time": { + "target_time": { + "name": "Target time" + } } }, "exceptions": { diff --git a/homeassistant/components/ohme/time.py b/homeassistant/components/ohme/time.py new file mode 100644 index 00000000000..a7de913ef8e --- /dev/null +++ b/homeassistant/components/ohme/time.py @@ -0,0 +1,77 @@ +"""Platform for time.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import time + +from ohme import ApiException, OhmeApiClient + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OhmeConfigEntry +from .const import DOMAIN +from .entity import OhmeEntity, OhmeEntityDescription + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class OhmeTimeDescription(OhmeEntityDescription, TimeEntityDescription): + """Class describing Ohme time entities.""" + + set_fn: Callable[[OhmeApiClient, time], Awaitable[None]] + value_fn: Callable[[OhmeApiClient], time] + + +TIME_DESCRIPTION = [ + OhmeTimeDescription( + key="target_time", + translation_key="target_time", + value_fn=lambda client: time( + hour=client.target_time[0], minute=client.target_time[1] + ), + set_fn=lambda client, value: client.async_set_target( + target_time=(value.hour, value.minute) + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OhmeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up time entities.""" + coordinators = config_entry.runtime_data + coordinator = coordinators.charge_session_coordinator + + async_add_entities( + OhmeTime(coordinator, description) + for description in TIME_DESCRIPTION + if description.is_supported_fn(coordinator.client) + ) + + +class OhmeTime(OhmeEntity, TimeEntity): + """Generic time entity for Ohme.""" + + entity_description: OhmeTimeDescription + + @property + def native_value(self) -> time: + """Return the current value of the time.""" + return self.entity_description.value_fn(self.coordinator.client) + + async def async_set_value(self, value: time) -> None: + """Set the time value.""" + try: + await self.entity_description.set_fn(self.coordinator.client, value) + except ApiException as e: + raise HomeAssistantError( + translation_key="api_failed", translation_domain=DOMAIN + ) from e + await self.coordinator.async_request_refresh() diff --git a/requirements_all.txt b/requirements_all.txt index 28b41f4f335..96f276097b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1540,7 +1540,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.2.4 +ohme==1.2.5 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d169f7e5435..6b1053331cd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1288,7 +1288,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.2.4 +ohme==1.2.5 # homeassistant.components.ollama ollama==0.4.7 diff --git a/tests/components/ohme/conftest.py b/tests/components/ohme/conftest.py index 0a774c15143..3d3db730d08 100644 --- a/tests/components/ohme/conftest.py +++ b/tests/components/ohme/conftest.py @@ -55,6 +55,7 @@ def mock_client(): client.power = ChargerPower(0, 0, 0, 0) client.target_soc = 50 + client.target_time = (8, 0) client.battery = 80 client.serial = "chargerid" client.ct_connected = True diff --git a/tests/components/ohme/snapshots/test_time.ambr b/tests/components/ohme/snapshots/test_time.ambr new file mode 100644 index 00000000000..4d9fab20e0b --- /dev/null +++ b/tests/components/ohme/snapshots/test_time.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_time[time.ohme_home_pro_target_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': None, + 'entity_id': 'time.ohme_home_pro_target_time', + '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': 'Target time', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'target_time', + 'unique_id': 'chargerid_target_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_time[time.ohme_home_pro_target_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Target time', + }), + 'context': , + 'entity_id': 'time.ohme_home_pro_target_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '08:00:00', + }) +# --- diff --git a/tests/components/ohme/test_time.py b/tests/components/ohme/test_time.py new file mode 100644 index 00000000000..0562dfa124c --- /dev/null +++ b/tests/components/ohme/test_time.py @@ -0,0 +1,55 @@ +"""Tests for time.""" + +from unittest.mock import MagicMock, patch + +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 setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_time( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the Ohme sensors.""" + with patch("homeassistant.components.ohme.PLATFORMS", [Platform.TIME]): + await setup_integration(hass, mock_config_entry) + + 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, +) -> None: + """Test the time set.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + service_data={ + ATTR_TIME: "00:00:00", + }, + target={ + ATTR_ENTITY_ID: "time.ohme_home_pro_target_time", + }, + blocking=True, + ) + + assert len(mock_client.async_set_target.mock_calls) == 1 From 595a7fbcd7f3fb409cef5ccde8315a99f814837d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 23 Jan 2025 08:58:33 +0100 Subject: [PATCH 0849/2987] Fix grammar of OSO auth and action descriptions (#136312) --- .../components/osoenergy/strings.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json index b8f95c021fa..ca23265048f 100644 --- a/homeassistant/components/osoenergy/strings.json +++ b/homeassistant/components/osoenergy/strings.json @@ -2,15 +2,15 @@ "config": { "step": { "user": { - "title": "OSO Energy Auth", - "description": "Enter the generated 'Subscription Key' for your account at 'https://portal.osoenergy.no/'", + "title": "OSO Energy auth", + "description": "Enter the 'Subscription key' for your account generated at 'https://portal.osoenergy.no/'", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } }, "reauth": { - "title": "OSO Energy Auth", - "description": "Generate and enter a new 'Subscription Key' for your account at 'https://portal.osoenergy.no/'.", + "title": "OSO Energy auth", + "description": "Enter a new 'Subscription key' for your account generated at 'https://portal.osoenergy.no/'.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } @@ -95,11 +95,11 @@ "services": { "get_profile": { "name": "Get heater profile", - "description": "Get the temperature profile of water heater" + "description": "Gets the temperature profile for water heater" }, "set_profile": { "name": "Set heater profile", - "description": "Set the temperature profile of water heater", + "description": "Sets the temperature profile for water heater", "fields": { "hour_00": { "name": "00:00", @@ -201,7 +201,7 @@ }, "set_v40_min": { "name": "Set v40 min", - "description": "Set the minimum quantity of water at 40°C for a heater", + "description": "Sets the minimum quantity of water at 40°C for a heater", "fields": { "v40_min": { "name": "V40 Min", @@ -211,7 +211,7 @@ }, "turn_off": { "name": "Turn off heating", - "description": "Turn off heating for one hour or until min temperature is reached", + "description": "Turns off heating for one hour or until min temperature is reached", "fields": { "until_temp_limit": { "name": "Until temperature limit", @@ -221,7 +221,7 @@ }, "turn_on": { "name": "Turn on heating", - "description": "Turn on heating for one hour or until max temperature is reached", + "description": "Turns on heating for one hour or until max temperature is reached", "fields": { "until_temp_limit": { "name": "Until temperature limit", From 9fc21c389a684bf3b370aaaed9fb82c0be868b34 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jan 2025 09:01:39 +0100 Subject: [PATCH 0850/2987] Bump github/codeql-action from 3.28.2 to 3.28.3 (#136308) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e95e2b58448..0b58140a2fb 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.2 + uses: github/codeql-action/init@v3.28.3 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.2 + uses: github/codeql-action/analyze@v3.28.3 with: category: "/language:python" From df036d30914a0b2ec1fc14b750aaaa03c062c2c2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jan 2025 09:05:10 +0100 Subject: [PATCH 0851/2987] Bump dawidd6/action-download-artifact from 7 to 8 (#136309) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 6c53304a9ee..38d936039b0 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -94,7 +94,7 @@ jobs: - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v7 + uses: dawidd6/action-download-artifact@v8 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -105,7 +105,7 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v7 + uses: dawidd6/action-download-artifact@v8 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/intents-package From be0a344642030b30f18275a5588529cfb604f9a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jan 2025 09:05:32 +0100 Subject: [PATCH 0852/2987] Bump actions/attest-build-provenance from 2.1.0 to 2.2.0 (#136307) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 38d936039b0..5b1cf48df68 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -531,7 +531,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@7668571508540a607bdfd90a87a560489fe372eb # v2.1.0 + uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} From f5542450c4c7f0289d9dc25af665cf0f78596e21 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jan 2025 09:06:13 +0100 Subject: [PATCH 0853/2987] Bump codecov/codecov-action from 5.1.2 to 5.2.0 (#136306) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fb07d60da3b..7c5ba24714d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1273,7 +1273,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v5.1.2 + uses: codecov/codecov-action@v5.2.0 with: fail_ci_if_error: true flags: full-suite @@ -1411,7 +1411,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v5.1.2 + uses: codecov/codecov-action@v5.2.0 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} From b839a2e2bddbb46e729b75cf7ce145fe5a307157 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jan 2025 22:09:11 -1000 Subject: [PATCH 0854/2987] Fix handling of non-supported devices in led-ble (#136300) --- .../components/led_ble/config_flow.py | 4 ++- tests/components/led_ble/test_config_flow.py | 30 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/led_ble/config_flow.py b/homeassistant/components/led_ble/config_flow.py index 90d86d44160..517fb3759de 100644 --- a/homeassistant/components/led_ble/config_flow.py +++ b/homeassistant/components/led_ble/config_flow.py @@ -6,7 +6,7 @@ import logging from typing import Any from bluetooth_data_tools import human_readable_name -from led_ble import BLEAK_EXCEPTIONS, LEDBLE +from led_ble import BLEAK_EXCEPTIONS, LEDBLE, CharacteristicMissingError import voluptuous as vol from homeassistant.components.bluetooth import ( @@ -66,6 +66,8 @@ class LedBleConfigFlow(ConfigFlow, domain=DOMAIN): led_ble = LEDBLE(discovery_info.device) try: await led_ble.update() + except CharacteristicMissingError: + return self.async_abort(reason="not_supported") except BLEAK_EXCEPTIONS: errors["base"] = "cannot_connect" except Exception: diff --git a/tests/components/led_ble/test_config_flow.py b/tests/components/led_ble/test_config_flow.py index c22c62e2fb1..674700aebd9 100644 --- a/tests/components/led_ble/test_config_flow.py +++ b/tests/components/led_ble/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import patch from bleak import BleakError +from led_ble import CharacteristicMissingError from homeassistant import config_entries from homeassistant.components.led_ble.const import DOMAIN @@ -202,6 +203,35 @@ async def test_user_step_unknown_exception(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_step_not_supported(hass: HomeAssistant) -> None: + """Test user step with a non supported device.""" + with patch( + "homeassistant.components.led_ble.config_flow.async_discovered_service_info", + return_value=[LED_BLE_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.led_ble.config_flow.LEDBLE.update", + side_effect=CharacteristicMissingError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: LED_BLE_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "not_supported" + + async def test_bluetooth_step_success(hass: HomeAssistant) -> None: """Test bluetooth step success path.""" result = await hass.config_entries.flow.async_init( From 40348890da0efa61677178e328c6d9f6d9269d2b Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Thu, 23 Jan 2025 09:15:24 +0100 Subject: [PATCH 0855/2987] Add heat pump supply pressure sensor in ViCare integration (#136265) --- homeassistant/components/vicare/sensor.py | 11 + homeassistant/components/vicare/strings.json | 3 + .../vicare/fixtures/Vitocal250A.json | 4447 +++++++++++++++++ .../vicare/snapshots/test_sensor.ambr | 1255 ++++- tests/components/vicare/test_sensor.py | 32 +- 5 files changed, 5682 insertions(+), 66 deletions(-) create mode 100644 tests/components/vicare/fixtures/Vitocal250A.json diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index ba0191c5cd2..44c3f3cfc0f 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -30,6 +30,7 @@ from homeassistant.const import ( EntityCategory, UnitOfEnergy, UnitOfPower, + UnitOfPressure, UnitOfTemperature, UnitOfTime, UnitOfVolume, @@ -836,6 +837,16 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( "forcedlevelfour", ], ), + ViCareSensorEntityDescription( + key="supply_pressure", + translation_key="supply_pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.BAR, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getSupplyPressure(), + unit_getter=lambda api: api.getSupplyPressureUnit(), + ), ) CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 074c994d4a5..f49a73f1659 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -455,6 +455,9 @@ "silent": "Silent", "forcedlevelfour": "Boost" } + }, + "supply_pressure": { + "name": "Supply pressure" } }, "water_heater": { diff --git a/tests/components/vicare/fixtures/Vitocal250A.json b/tests/components/vicare/fixtures/Vitocal250A.json new file mode 100644 index 00000000000..1da43531a89 --- /dev/null +++ b/tests/components/vicare/fixtures/Vitocal250A.json @@ -0,0 +1,4447 @@ +{ + "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.errors.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [] + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.messages.errors.raw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.productIdentification", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "product": { + "type": "object", + "value": { + "busAddress": 1, + "busType": "CanExternal", + "productFamily": "B_00027_VC250", + "viessmannIdentificationNumber": "################" + } + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.productIdentification" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.productMatrix", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "product": { + "type": "array", + "value": [ + { + "busAddress": 1, + "busType": "CanExternal", + "productFamily": "B_00027_VC250", + "viessmannIdentificationNumber": "################" + }, + { + "busAddress": 71, + "busType": "CanExternal", + "productFamily": "B_00012_VCH200", + "viessmannIdentificationNumber": "################" + } + ] + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.productMatrix" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "deviceSerialVitocal250A" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.sensors.temperature.commonSupply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 44.6 + } + }, + "timestamp": "2024-10-01T16:28:33.694Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.commonSupply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.bufferCylinder.sensors.temperature.main", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.buffer.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 35.3 + } + }, + "timestamp": "2024-10-01T16:28:33.694Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.buffer.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.bufferCylinder.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 35.3 + } + }, + "timestamp": "2024-10-01T16:28:33.694Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.bufferCylinder.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["1"] + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:09:57.180Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.circuits.N.operating.programs.reducedEnergySaving and heating.circuits.0.operating.programs.eco", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.remoteController", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.remoteController" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.zone.mode", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.zone.mode" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 20, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1/commands/setName" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "name": { + "type": "string", + "value": "Heizkreis" + }, + "type": { + "type": "string", + "value": "heatingCircuit" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.circulation.pump", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2024-10-01T16:09:57.180Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.frostprotection", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "off" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.frostprotection" + }, + { + "apiVersion": 1, + "commands": { + "setCurve": { + "isExecutable": true, + "name": "setCurve", + "params": { + "shift": { + "constraints": { + "max": 40, + "min": -13, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "slope": { + "constraints": { + "max": 3.5, + "min": 0.2, + "stepping": 0.1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.curve/commands/setCurve" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.heating.curve", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "shift": { + "type": "number", + "unit": "", + "value": 0 + }, + "slope": { + "type": "number", + "unit": "", + "value": 1.1 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.curve" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": true, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "reduced", + "maxEntries": 4, + "modes": ["normal", "comfort"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.heating.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ] + } + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 20, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.name/commands/setName" + } + }, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.1.name", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "name": { + "type": "string", + "value": "Heizkreis" + } + }, + "timestamp": "2024-09-20T08:56:49.795Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.name" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["heating", "standby"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active/commands/setMode" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "heating" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "comfortHeating" + } + }, + "timestamp": "2024-10-01T03:59:26.407Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "cooling" + }, + "reason": { + "type": "string", + "value": "eco" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "reason": { + "type": "string", + "value": "eco" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortHeating/commands/activate" + }, + "deactivate": { + "isExecutable": false, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortHeating/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortHeating/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "demand": { + "type": "string", + "value": "heating" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 24 + } + }, + "timestamp": "2024-10-01T03:59:26.407Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T03:59:26.407Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.forcedLastFromSchedule/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.forcedLastFromSchedule/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "cooling" + }, + "reason": { + "type": "string", + "value": "eco" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "reason": { + "type": "string", + "value": "eco" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalHeating/commands/activate" + }, + "deactivate": { + "isExecutable": false, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalHeating/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalHeating/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 24 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "cooling" + }, + "reason": { + "type": "string", + "value": "eco" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "reason": { + "type": "string", + "value": "unknown" + } + }, + "timestamp": "2024-10-01T03:59:26.407Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedHeating/commands/activate" + }, + "deactivate": { + "isExecutable": false, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedHeating/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedHeating/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 24 + } + }, + "timestamp": "2024-10-01T03:59:26.407Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.circuits.N.operating.programs.reducedEnergySaving and heating.circuits.0.operating.programs.eco", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T03:59:26.407Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": { + "removeZigbeeController": { + "isExecutable": false, + "name": "removeZigbeeController", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.remoteController/commands/removeZigbeeController" + }, + "setZigbeeController": { + "isExecutable": true, + "name": "setZigbeeController", + "params": { + "deviceId": { + "constraints": { + "enum": [] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.remoteController/commands/setZigbeeController" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.remoteController", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.remoteController" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 24.1 + } + }, + "timestamp": "2024-10-01T16:05:52.313Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 39 + } + }, + "timestamp": "2024-10-01T16:28:40.965Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:26:48.295Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature" + }, + { + "apiVersion": 1, + "commands": { + "setLevels": { + "isExecutable": true, + "name": "setLevels", + "params": { + "maxTemperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "minTemperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels/commands/setLevels" + }, + "setMax": { + "isExecutable": true, + "name": "setMax", + "params": { + "temperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels/commands/setMax" + }, + "setMin": { + "isExecutable": true, + "name": "setMin", + "params": { + "temperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels/commands/setMin" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.temperature.levels", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "max": { + "type": "number", + "unit": "celsius", + "value": 55 + }, + "min": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.zone.mode", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:09:57.180Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.circuits.N.operating.programs.reducedEnergySaving and heating.circuits.0.operating.programs.eco", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.remoteController", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.remoteController" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.zone.mode", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:09:57.180Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.circuits.N.operating.programs.reducedEnergySaving and heating.circuits.0.operating.programs.eco", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.remoteController", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.remoteController" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.zone.mode", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "phase": { + "type": "string", + "value": "ready" + } + }, + "timestamp": "2024-10-01T16:12:14.713Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.heat.production.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "watt", + "value": 13.317 + } + }, + "timestamp": "2024-10-01T16:28:29.219Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.heat.production.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.power.consumption.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.power.consumption.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.power.consumption.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "kilowatt", + "value": 3.107 + } + }, + "timestamp": "2024-10-01T16:28:29.219Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.power.consumption.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.power.consumption.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [7.6, 5.4, 3, 2.6, 4.3, 1.2, 4.2, 2.7] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T11:46:35.700Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [7.6, 93.9, 41.5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T11:46:35.768Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [13, 21.799999999999997, 20.5, 27.4, 16.2] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T11:46:35.700Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [143, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T11:45:28.937Z" + } + }, + "timestamp": "2024-10-01T12:18:26.686Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.power.consumption.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.power.consumption.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [16.4, 31.2, 0, 0, 0, 0, 0, 0] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T16:25:33.871Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [16.4, 36.7, 2.1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T16:25:33.871Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [47.599999999999994, 0, 0, 5.5, 0] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T16:25:33.871Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [55.2, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T16:25:33.871Z" + } + }, + "timestamp": "2024-10-01T16:27:05.568Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.power.consumption.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.power.consumption.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [24, 36.6, 3, 2.6, 4.3, 1.2, 4.2, 2.7] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [24, 130.60000000000002, 43.6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [60.599999999999994, 21.799999999999997, 20.5, 32.9, 16.2] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [198.2, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + } + }, + "timestamp": "2024-10-01T16:27:05.568Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.power.consumption.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.statistics", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "hours": { + "type": "number", + "unit": "hour", + "value": 71 + }, + "starts": { + "type": "number", + "unit": "", + "value": 121 + } + }, + "timestamp": "2024-10-01T16:12:54.682Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.device.variant", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "Vitocal250A" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.device.variant" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene/commands/activate" + }, + "disable": { + "isExecutable": false, + "name": "disable", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene/commands/disable" + }, + "enable": { + "isExecutable": true, + "name": "enable", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene/commands/enable" + } + }, + "deviceId": "0", + "feature": "heating.dhw.hygiene", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.hygiene.trigger", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene.trigger" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.dhw.oneTimeCharge", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["efficientWithMinComfort", "efficient", "off"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.active/commands/setMode" + } + }, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "efficientWithMinComfort" + } + }, + "timestamp": "2024-10-01T00:31:26.139Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.balanced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.balanced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.comfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:26.139Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.comfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:26.139Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.efficient", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.efficient" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.efficientWithMinComfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.efficientWithMinComfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.off", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.off" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.secondary", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.secondary" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": true, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 4, + "modes": ["on"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ] + } + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 58.8 + } + }, + "timestamp": "2024-10-01T16:28:40.965Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.middle", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.middle" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.top", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:28:40.965Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 58.8 + } + }, + "timestamp": "2024-10-01T16:28:40.965Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.middle", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.middle", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.middle" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.top", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.top", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:28:40.965Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.temperature.hygiene", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hygiene" + }, + { + "apiVersion": 1, + "commands": { + "setHysteresis": { + "isExecutable": true, + "name": "setHysteresis", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresis" + }, + "setHysteresisSwitchOffValue": { + "isExecutable": true, + "name": "setHysteresisSwitchOffValue", + "params": { + "hysteresis": { + "constraints": { + "max": 2.5, + "min": 0, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOffValue" + }, + "setHysteresisSwitchOnValue": { + "isExecutable": true, + "name": "setHysteresisSwitchOnValue", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOnValue" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.hysteresis", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "switchOffValue": { + "type": "number", + "unit": "kelvin", + "value": 0 + }, + "switchOnValue": { + "type": "number", + "unit": "kelvin", + "value": 5 + }, + "value": { + "type": "number", + "unit": "kelvin", + "value": 5 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.temperature.levels", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "default": { + "type": "number", + "unit": "celsius", + "value": 50 + }, + "max": { + "type": "number", + "unit": "celsius", + "value": 10 + }, + "min": { + "type": "number", + "unit": "celsius", + "value": 10 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.levels" + }, + { + "apiVersion": 1, + "commands": { + "setTargetTemperature": { + "isExecutable": true, + "name": "setTargetTemperature", + "params": { + "temperature": { + "constraints": { + "efficientLowerBorder": 0, + "efficientUpperBorder": 55, + "max": 60, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main/commands/setTargetTemperature" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 47 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.heat.production.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "watt", + "value": 0 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.heat.production.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "watt", + "value": 0 + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0, 0, 0, 0] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0, 0, 0, 0] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + } + }, + "timestamp": "2024-10-01T00:31:26.139Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.summary.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.summary.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.summary.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + } + }, + "timestamp": "2024-10-01T00:31:26.139Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.summary.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0, 0, 0, 0] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.statistics", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "hours": { + "type": "number", + "unit": "hour", + "value": 0 + }, + "starts": { + "type": "number", + "unit": "", + "value": 0 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.status", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "level1": { + "type": "boolean", + "value": false + }, + "level2": { + "type": "boolean", + "value": false + }, + "level3": { + "type": "boolean", + "value": false + }, + "overall": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.status" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "heating.operating.programs.holiday", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "2000-01-01" + }, + "start": { + "type": "string", + "value": "2000-01-01" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "heating.operating.programs.holidayAtHome", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "2000-01-01" + }, + "start": { + "type": "string", + "value": "2000-01-01" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "kilowatt", + "value": 3.107 + } + }, + "timestamp": "2024-10-01T16:28:29.219Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [7.6, 5.4, 3, 2.6, 4.3, 1.2, 4.2, 2.7] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [7.6, 93.9, 41.5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [13, 21.799999999999997, 20.5, 27.4, 16.2] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [143, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + } + }, + "timestamp": "2024-10-01T12:18:26.686Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [16.4, 31.2, 0, 0, 0, 0, 0, 0] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [16.4, 36.7, 2.1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [47.599999999999994, 0, 0, 5.5, 0] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [55.2, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + } + }, + "timestamp": "2024-10-01T16:27:05.568Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.summary.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 7.6 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 7.6 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 143 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 93.9 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 28.3 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + } + }, + "timestamp": "2024-10-01T11:46:54.639Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.summary.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.summary.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 16.4 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 16.4 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 55.2 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 36.7 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 47.6 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + } + }, + "timestamp": "2024-10-01T16:27:05.568Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.summary.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [24, 36.6, 3, 2.6, 4.3, 1.2, 4.2, 2.7] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [24, 130.60000000000002, 43.6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [60.599999999999994, 21.799999999999997, 20.5, 32.9, 16.2] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [198.2, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + } + }, + "timestamp": "2024-10-01T16:27:05.568Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 12.8 + } + }, + "timestamp": "2024-10-01T16:28:36.488Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.spf.dhw", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.scop.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 4.1 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.scop.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.spf.heating", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.scop.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.2 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.scop.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.spf.total", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.scop.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.9 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.scop.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 45.1 + } + }, + "timestamp": "2024-10-01T16:28:36.488Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.pressure.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "bar", + "value": 2.1 + } + }, + "timestamp": "2024-10-01T15:06:07.125Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.pressure.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.allengra", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 35.8 + } + }, + "timestamp": "2024-10-01T16:28:20.497Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.allengra" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.hydraulicSeparator", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:28:33.694Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.hydraulicSeparator" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.outside", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 14.3 + } + }, + "timestamp": "2024-10-01T16:28:36.488Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.outside" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.return", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 35.3 + } + }, + "timestamp": "2024-10-01T16:28:04.882Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.volumetricFlow.allengra", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "liter/hour", + "value": 1015 + } + }, + "timestamp": "2024-10-01T16:28:36.488Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.volumetricFlow.allengra" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.spf.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 4.1 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.spf.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.spf.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.2 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.spf.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.spf.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.9 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.spf.total" + } + ] +} diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index 88c3c945253..aaf75e6753a 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_heating_entities[sensor.model0_boiler_temperature-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_boiler_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_heating_entities[sensor.model0_boiler_temperature-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_boiler_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -50,7 +50,7 @@ 'state': '63', }) # --- -# name: test_all_heating_entities[sensor.model0_burner_hours-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_burner_hours-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -85,7 +85,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_heating_entities[sensor.model0_burner_hours-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_burner_hours-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Burner hours', @@ -100,7 +100,7 @@ 'state': '18726.3', }) # --- -# name: test_all_heating_entities[sensor.model0_burner_modulation-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_burner_modulation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -135,7 +135,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_all_heating_entities[sensor.model0_burner_modulation-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_burner_modulation-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Burner modulation', @@ -150,7 +150,7 @@ 'state': '0', }) # --- -# name: test_all_heating_entities[sensor.model0_burner_starts-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_burner_starts-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -185,7 +185,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_heating_entities[sensor.model0_burner_starts-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_burner_starts-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Burner starts', @@ -199,7 +199,7 @@ 'state': '14315', }) # --- -# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_this_month-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_this_month-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -234,7 +234,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_this_month-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_this_month-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption this month', @@ -248,7 +248,7 @@ 'state': '805', }) # --- -# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_this_week-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_this_week-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -283,7 +283,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_this_week-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_this_week-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption this week', @@ -297,7 +297,7 @@ 'state': '84', }) # --- -# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_this_year-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_this_year-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -332,7 +332,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_this_year-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_this_year-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption this year', @@ -346,7 +346,7 @@ 'state': '8203', }) # --- -# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_today-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_today-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -381,7 +381,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_heating_entities[sensor.model0_dhw_gas_consumption_today-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption today', @@ -395,7 +395,7 @@ 'state': '22', }) # --- -# name: test_all_heating_entities[sensor.model0_dhw_max_temperature-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_max_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -430,7 +430,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_heating_entities[sensor.model0_dhw_max_temperature-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_max_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -446,7 +446,7 @@ 'state': '60', }) # --- -# name: test_all_heating_entities[sensor.model0_dhw_min_temperature-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_min_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -481,7 +481,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_heating_entities[sensor.model0_dhw_min_temperature-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_min_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -497,7 +497,7 @@ 'state': '10', }) # --- -# name: test_all_heating_entities[sensor.model0_electricity_consumption_this_week-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_electricity_consumption_this_week-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -532,7 +532,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_heating_entities[sensor.model0_electricity_consumption_this_week-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_electricity_consumption_this_week-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -548,7 +548,7 @@ 'state': '0.829', }) # --- -# name: test_all_heating_entities[sensor.model0_electricity_consumption_this_year-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_electricity_consumption_this_year-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -583,7 +583,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_heating_entities[sensor.model0_electricity_consumption_this_year-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_electricity_consumption_this_year-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -599,7 +599,7 @@ 'state': '207.106', }) # --- -# name: test_all_heating_entities[sensor.model0_electricity_consumption_today-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_electricity_consumption_today-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -634,7 +634,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_heating_entities[sensor.model0_electricity_consumption_today-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_electricity_consumption_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -650,7 +650,7 @@ 'state': '0.219', }) # --- -# name: test_all_heating_entities[sensor.model0_energy-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -685,7 +685,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_heating_entities[sensor.model0_energy-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -701,7 +701,7 @@ 'state': '7.843', }) # --- -# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_this_month-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_this_month-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -736,7 +736,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_this_month-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_this_month-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Heating gas consumption this month', @@ -750,7 +750,7 @@ 'state': '0', }) # --- -# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_this_week-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_this_week-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -785,7 +785,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_this_week-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_this_week-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Heating gas consumption this week', @@ -799,7 +799,7 @@ 'state': '0', }) # --- -# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_this_year-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_this_year-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -834,7 +834,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_this_year-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_this_year-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Heating gas consumption this year', @@ -848,7 +848,7 @@ 'state': '30946', }) # --- -# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_today-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_today-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -883,7 +883,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_heating_entities[sensor.model0_heating_gas_consumption_today-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Heating gas consumption today', @@ -897,7 +897,7 @@ 'state': '0', }) # --- -# name: test_all_heating_entities[sensor.model0_outside_temperature-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_outside_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -932,7 +932,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_heating_entities[sensor.model0_outside_temperature-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_outside_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -948,7 +948,7 @@ 'state': '20.8', }) # --- -# name: test_all_heating_entities[sensor.model0_supply_temperature-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_supply_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -983,7 +983,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_heating_entities[sensor.model0_supply_temperature-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_supply_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -999,7 +999,7 @@ 'state': '63', }) # --- -# name: test_all_heating_entities[sensor.model0_supply_temperature_2-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_supply_temperature_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1034,7 +1034,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_heating_entities[sensor.model0_supply_temperature_2-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_supply_temperature_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -1050,7 +1050,1170 @@ 'state': '25.5', }) # --- -# name: test_all_ventilation_entities[sensor.model0_ventilation_level-entry] +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_buffer_main_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_buffer_main_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': 'Buffer main temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'buffer_main_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-buffer main temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_buffer_main_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Buffer main temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_buffer_main_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.3', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_compressor_hours-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_compressor_hours', + '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': 'Compressor hours', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'compressor_hours', + 'unique_id': 'gateway0_deviceSerialVitocal250A-compressor_hours-0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_compressor_hours-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Compressor hours', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_compressor_hours', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '71', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_compressor_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_compressor_phase', + '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': 'Compressor phase', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'compressor_phase', + 'unique_id': 'gateway0_deviceSerialVitocal250A-compressor_phase-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_compressor_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Compressor phase', + }), + 'context': , + 'entity_id': 'sensor.model0_compressor_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ready', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_compressor_starts-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_compressor_starts', + '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': 'Compressor starts', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'compressor_starts', + 'unique_id': 'gateway0_deviceSerialVitocal250A-compressor_starts-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_compressor_starts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Compressor starts', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_compressor_starts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '121', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_last_seven_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_electricity_consumption_last_seven_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW electricity consumption last seven days', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_summary_dhw_consumption_heating_lastsevendays', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_dhw_consumption_heating_lastsevendays', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_last_seven_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 DHW electricity consumption last seven days', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_electricity_consumption_last_seven_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.3', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_this_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_electricity_consumption_this_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW electricity consumption this month', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_dhw_summary_consumption_heating_currentmonth', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_dhw_summary_consumption_heating_currentmonth', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_this_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 DHW electricity consumption this month', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_electricity_consumption_this_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.6', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_this_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_electricity_consumption_this_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW electricity consumption this year', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_dhw_summary_consumption_heating_currentyear', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_dhw_summary_consumption_heating_currentyear', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_this_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 DHW electricity consumption this year', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_electricity_consumption_this_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '143', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_electricity_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW electricity consumption today', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_dhw_summary_consumption_heating_currentday', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_dhw_summary_consumption_heating_currentday', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 DHW electricity consumption today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_electricity_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.6', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_max_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_max_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': 'DHW max temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hotwater_max_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-hotwater_max_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_max_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 DHW max temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_max_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_min_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_min_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': 'DHW min temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hotwater_min_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-hotwater_min_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_min_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 DHW min temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_min_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_storage_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_storage_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': 'DHW storage temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dhw_storage_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-dhw_storage_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_storage_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 DHW storage temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_storage_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '58.8', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_electricity_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_electricity_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumption today', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_consumption_today', + 'unique_id': 'gateway0_deviceSerialVitocal250A-power consumption today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_electricity_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Electricity consumption today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_electricity_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_last_seven_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_electricity_consumption_last_seven_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating electricity consumption last seven days', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_summary_consumption_heating_lastsevendays', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_consumption_heating_lastsevendays', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_last_seven_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Heating electricity consumption last seven days', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_electricity_consumption_last_seven_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.6', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_this_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_electricity_consumption_this_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating electricity consumption this month', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_summary_consumption_heating_currentmonth', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_consumption_heating_currentmonth', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_this_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Heating electricity consumption this month', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_electricity_consumption_this_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.4', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_this_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_electricity_consumption_this_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating electricity consumption this year', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_summary_consumption_heating_currentyear', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_consumption_heating_currentyear', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_this_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Heating electricity consumption this year', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_electricity_consumption_this_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55.2', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_electricity_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating electricity consumption today', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_summary_consumption_heating_currentday', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_consumption_heating_currentday', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Heating electricity consumption today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_electricity_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.4', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_outside_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': 'Outside temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.3', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_primary_circuit_supply_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_primary_circuit_supply_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': 'Primary circuit supply temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'primary_circuit_supply_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-primary_circuit_supply_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_primary_circuit_supply_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Primary circuit supply temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_primary_circuit_supply_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.8', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_return_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_return_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': 'Return temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'return_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-return_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_return_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Return temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_return_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.3', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_secondary_circuit_supply_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_secondary_circuit_supply_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': 'Secondary circuit supply temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'secondary_circuit_supply_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-secondary_circuit_supply_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_secondary_circuit_supply_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Secondary circuit supply temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_secondary_circuit_supply_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.1', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_supply_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_supply_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply pressure', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supply_pressure', + 'unique_id': 'gateway0_deviceSerialVitocal250A-supply_pressure', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_supply_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'model0 Supply pressure', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_supply_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.1', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_supply_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_supply_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': 'Supply temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supply_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-supply_temperature-1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_supply_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Supply temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_supply_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_volumetric_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_volumetric_flow', + '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': 'Volumetric flow', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volumetric_flow', + 'unique_id': 'gateway0_deviceSerialVitocal250A-volumetric_flow', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_volumetric_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Volumetric flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_volumetric_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.015', + }) +# --- +# name: test_all_entities[type:ventilation-vicare/ViAir300F.json][sensor.model0_ventilation_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1091,7 +2254,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_ventilation_entities[sensor.model0_ventilation_level-state] +# name: test_all_entities[type:ventilation-vicare/ViAir300F.json][sensor.model0_ventilation_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', @@ -1112,7 +2275,7 @@ 'state': 'levelone', }) # --- -# name: test_all_ventilation_entities[sensor.model0_ventilation_reason-entry] +# name: test_all_entities[type:ventilation-vicare/ViAir300F.json][sensor.model0_ventilation_reason-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1154,7 +2317,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_ventilation_entities[sensor.model0_ventilation_reason-state] +# name: test_all_entities[type:ventilation-vicare/ViAir300F.json][sensor.model0_ventilation_reason-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', diff --git a/tests/components/vicare/test_sensor.py b/tests/components/vicare/test_sensor.py index 9b8b69f29db..daad6bfa1c8 100644 --- a/tests/components/vicare/test_sensor.py +++ b/tests/components/vicare/test_sensor.py @@ -16,15 +16,25 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_all_heating_entities( +@pytest.mark.parametrize( + ("fixture_type", "fixture_data"), + [ + ("type:boiler", "vicare/Vitodens300W.json"), + ("type:heatpump", "vicare/Vitocal250A.json"), + ("type:ventilation", "vicare/ViAir300F.json"), + ], +) +async def test_all_entities( hass: HomeAssistant, + fixture_type: str, + fixture_data: str, snapshot: SnapshotAssertion, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" fixtures: list[Fixture] = [ - Fixture({"type:boiler"}, "vicare/Vitodens300W.json"), + Fixture({fixture_type}, fixture_data), ] with ( patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), @@ -35,24 +45,6 @@ async def test_all_heating_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_all_ventilation_entities( - hass: HomeAssistant, - snapshot: SnapshotAssertion, - mock_config_entry: MockConfigEntry, - entity_registry: er.EntityRegistry, -) -> None: - """Test all entities.""" - fixtures: list[Fixture] = [Fixture({"type:ventilation"}, "vicare/ViAir300F.json")] - with ( - patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), - patch(f"{MODULE}.PLATFORMS", [Platform.SENSOR]), - ): - await setup_integration(hass, mock_config_entry) - - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_room_sensors( hass: HomeAssistant, From 8172afd9f4b5152d9c604fc41581983de6b302a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Domingues?= <5983487+domingues@users.noreply.github.com> Date: Thu, 23 Jan 2025 08:41:29 +0000 Subject: [PATCH 0856/2987] Auto select thermostat preset when selecting temperature (#134146) --- .../components/generic_thermostat/climate.py | 2 ++ .../components/generic_thermostat/test_climate.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index dd6829eacce..fe6f0253f48 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -268,6 +268,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): else: self._attr_preset_modes = [PRESET_NONE] self._presets = presets + self._presets_inv = {v: k for k, v in presets.items()} async def async_added_to_hass(self) -> None: """Run when entity about to be added.""" @@ -421,6 +422,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return + self._attr_preset_mode = self._presets_inv.get(temperature, PRESET_NONE) self._target_temp = temperature await self._async_control_heating(force=True) self.async_write_ha_state() diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 39435f154c4..8cbbdbb49d4 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -319,6 +319,20 @@ async def test_set_target_temp(hass: HomeAssistant) -> None: assert state.attributes.get("temperature") == 30.0 +@pytest.mark.usefixtures("setup_comp_2") +async def test_set_target_temp_change_preset(hass: HomeAssistant) -> None: + """Test the setting of the target temperature. + + Verify that preset is changed. + """ + await common.async_set_temperature(hass, 30) + state = hass.states.get(ENTITY) + assert state.attributes.get("preset_mode") == PRESET_NONE + await common.async_set_temperature(hass, 20) + state = hass.states.get(ENTITY) + assert state.attributes.get("preset_mode") == PRESET_COMFORT + + @pytest.mark.parametrize( ("preset", "temp"), [ From 10cfef1f3e4885cb93051b24885a05301e5c89bb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Jan 2025 10:10:37 +0100 Subject: [PATCH 0857/2987] Cleanup map references in lovelace (#136314) * Cleanup map references in lovelace * Cleanup fixtures --- homeassistant/components/lovelace/__init__.py | 34 +---------- tests/components/lovelace/test_init.py | 58 ------------------- 2 files changed, 1 insertion(+), 91 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index d26e4f1d2d7..3723e7090d2 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -4,7 +4,7 @@ import logging import voluptuous as vol -from homeassistant.components import frontend, onboarding, websocket_api +from homeassistant.components import frontend, websocket_api from homeassistant.config import ( async_hass_config_yaml, async_process_component_and_handle_errors, @@ -14,7 +14,6 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.service import async_register_admin_service -from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration @@ -211,9 +210,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Process storage dashboards dashboards_collection = dashboard.DashboardsCollection(hass) - # This can be removed when the map integration is removed - hass.data[DOMAIN]["dashboards_collection"] = dashboards_collection - dashboards_collection.async_add_listener(storage_dashboard_changed) await dashboards_collection.async_load() @@ -225,12 +221,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: STORAGE_DASHBOARD_UPDATE_FIELDS, ).async_setup(hass) - def create_map_dashboard(): - hass.async_create_task(_create_map_dashboard(hass)) - - if not onboarding.async_is_onboarded(hass): - onboarding.async_add_listener(hass, create_map_dashboard) - return True @@ -268,25 +258,3 @@ def _register_panel(hass, url_path, mode, config, update): kwargs["sidebar_icon"] = config.get(CONF_ICON, DEFAULT_ICON) frontend.async_register_built_in_panel(hass, DOMAIN, **kwargs) - - -async def _create_map_dashboard(hass: HomeAssistant): - translations = await async_get_translations( - hass, hass.config.language, "dashboard", {onboarding.DOMAIN} - ) - title = translations["component.onboarding.dashboard.map.title"] - - dashboards_collection: dashboard.DashboardsCollection = hass.data[DOMAIN][ - "dashboards_collection" - ] - await dashboards_collection.async_create_item( - { - CONF_ALLOW_SINGLE_WORD: True, - CONF_ICON: "mdi:map", - CONF_TITLE: title, - CONF_URL_PATH: "map", - } - ) - - map_store: dashboard.LovelaceStorage = hass.data[DOMAIN]["dashboards"]["map"] - await map_store.async_save({"strategy": {"type": "map"}}) diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index 14d93d8302f..f56ff4371e6 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -12,16 +12,6 @@ from homeassistant.setup import async_setup_component from tests.typing import WebSocketGenerator -@pytest.fixture -def mock_onboarding_not_done() -> Generator[MagicMock]: - """Mock that Home Assistant is currently onboarding.""" - with patch( - "homeassistant.components.onboarding.async_is_onboarded", - return_value=False, - ) as mock_onboarding: - yield mock_onboarding - - @pytest.fixture def mock_onboarding_done() -> Generator[MagicMock]: """Mock that Home Assistant is currently onboarding.""" @@ -32,15 +22,6 @@ def mock_onboarding_done() -> Generator[MagicMock]: yield mock_onboarding -@pytest.fixture -def mock_add_onboarding_listener() -> Generator[MagicMock]: - """Mock that Home Assistant is currently onboarding.""" - with patch( - "homeassistant.components.onboarding.async_add_listener", - ) as mock_add_onboarding_listener: - yield mock_add_onboarding_listener - - async def test_create_dashboards_when_onboarded( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -57,42 +38,3 @@ async def test_create_dashboards_when_onboarded( response = await client.receive_json() assert response["success"] assert response["result"] == [] - - -async def test_create_dashboards_when_not_onboarded( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - hass_storage: dict[str, Any], - mock_add_onboarding_listener, - mock_onboarding_not_done, -) -> None: - """Test we automatically create dashboards when not onboarded.""" - client = await hass_ws_client(hass) - - assert await async_setup_component(hass, "lovelace", {}) - - # Call onboarding listener - mock_add_onboarding_listener.mock_calls[0][1][1]() - await hass.async_block_till_done() - - # List dashboards - await client.send_json_auto_id({"type": "lovelace/dashboards/list"}) - response = await client.receive_json() - assert response["success"] - assert response["result"] == [ - { - "icon": "mdi:map", - "id": "map", - "mode": "storage", - "require_admin": False, - "show_in_sidebar": True, - "title": "Map", - "url_path": "map", - } - ] - - # List map dashboard config - await client.send_json_auto_id({"type": "lovelace/config", "url_path": "map"}) - response = await client.receive_json() - assert response["success"] - assert response["result"] == {"strategy": {"type": "map"}} From ae65a81188591a6daba275c0aa9db2120cc54394 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 23 Jan 2025 10:24:26 +0100 Subject: [PATCH 0858/2987] Update Overseerr quality scale (#136260) * Update Overseerr quality scale * Update Overseerr quality scale * Update Overseerr quality scale --- .../components/overseerr/manifest.json | 2 +- .../components/overseerr/quality_scale.yaml | 28 +++++++++++-------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/overseerr/manifest.json b/homeassistant/components/overseerr/manifest.json index 46ac97073d6..396b9d7000b 100644 --- a/homeassistant/components/overseerr/manifest.json +++ b/homeassistant/components/overseerr/manifest.json @@ -8,6 +8,6 @@ "documentation": "https://www.home-assistant.io/integrations/overseerr", "integration_type": "service", "iot_class": "local_push", - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["python-overseerr==0.6.0"] } diff --git a/homeassistant/components/overseerr/quality_scale.yaml b/homeassistant/components/overseerr/quality_scale.yaml index f42457ee23f..7afbcd6aa07 100644 --- a/homeassistant/components/overseerr/quality_scale.yaml +++ b/homeassistant/components/overseerr/quality_scale.yaml @@ -38,7 +38,7 @@ rules: comment: Handled by the coordinator parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done diagnostics: done @@ -50,20 +50,26 @@ rules: status: exempt comment: | This integration does not support discovery. - 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: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | This integration has a fixed single device. - entity-category: todo - entity-device-class: todo - entity-disabled-by-default: todo + entity-category: done + entity-device-class: + status: exempt + comment: | + This integration has no relevant device class to use. + entity-disabled-by-default: + status: exempt + comment: | + This integration has no unpopular entities to disable. entity-translations: done exception-translations: done icon-translations: done From 73bd21e0ab0a7fc51d52d83d308d7590ebe2a4f8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Jan 2025 11:26:18 +0100 Subject: [PATCH 0859/2987] Use HassKey in lovelace (#136313) * Use HassKey in lovelace * Improve type hints * docstring * Rename constant --- homeassistant/components/lovelace/__init__.py | 38 ++++++++++++------- homeassistant/components/lovelace/cast.py | 15 +++++--- homeassistant/components/lovelace/const.py | 9 ++++- .../components/lovelace/dashboard.py | 3 +- .../components/lovelace/system_health.py | 17 +++++---- .../components/lovelace/websocket.py | 6 +-- 6 files changed, 57 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 3723e7090d2..65ef0ad3ac3 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -1,5 +1,6 @@ """Support for the Lovelace UI.""" +from dataclasses import dataclass import logging import voluptuous as vol @@ -29,6 +30,7 @@ from .const import ( # noqa: F401 DEFAULT_ICON, DOMAIN, EVENT_LOVELACE_UPDATED, + LOVELACE_DATA, MODE_STORAGE, MODE_YAML, RESOURCE_CREATE_FIELDS, @@ -73,6 +75,16 @@ CONFIG_SCHEMA = vol.Schema( ) +@dataclass +class LovelaceData: + """Dataclass to store information in hass.data.""" + + mode: str + dashboards: dict[str | None, dashboard.LovelaceConfig] + resources: resources.ResourceStorageCollection + yaml_dashboards: dict[str | None, ConfigType] + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Lovelace commands.""" mode = config[DOMAIN][CONF_MODE] @@ -100,7 +112,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: resource_collection = await create_yaml_resource_col( hass, config[DOMAIN].get(CONF_RESOURCES) ) - hass.data[DOMAIN]["resources"] = resource_collection + hass.data[LOVELACE_DATA].resources = resource_collection default_config: dashboard.LovelaceConfig if mode == MODE_YAML: @@ -151,13 +163,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, websocket.websocket_lovelace_delete_config ) - hass.data[DOMAIN] = { + hass.data[LOVELACE_DATA] = LovelaceData( + mode=mode, # We store a dictionary mapping url_path: config. None is the default. - "mode": mode, - "dashboards": {None: default_config}, - "resources": resource_collection, - "yaml_dashboards": config[DOMAIN].get(CONF_DASHBOARDS, {}), - } + dashboards={None: default_config}, + resources=resource_collection, + yaml_dashboards=config[DOMAIN].get(CONF_DASHBOARDS, {}), + ) if hass.config.recovery_mode: return True @@ -168,11 +180,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if change_type == collection.CHANGE_REMOVED: frontend.async_remove_panel(hass, url_path) - await hass.data[DOMAIN]["dashboards"].pop(url_path).async_delete() + await hass.data[LOVELACE_DATA].dashboards.pop(url_path).async_delete() return if change_type == collection.CHANGE_ADDED: - existing = hass.data[DOMAIN]["dashboards"].get(url_path) + existing = hass.data[LOVELACE_DATA].dashboards.get(url_path) if existing: _LOGGER.warning( @@ -182,13 +194,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) return - hass.data[DOMAIN]["dashboards"][url_path] = dashboard.LovelaceStorage( + hass.data[LOVELACE_DATA].dashboards[url_path] = dashboard.LovelaceStorage( hass, item ) update = False else: - hass.data[DOMAIN]["dashboards"][url_path].config = item + hass.data[LOVELACE_DATA].dashboards[url_path].config = item update = True try: @@ -197,10 +209,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.warning("Failed to %s panel %s from storage", change_type, url_path) # Process YAML dashboards - for url_path, dashboard_conf in hass.data[DOMAIN]["yaml_dashboards"].items(): + for url_path, dashboard_conf in hass.data[LOVELACE_DATA].yaml_dashboards.items(): # For now always mode=yaml lovelace_config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf) - hass.data[DOMAIN]["dashboards"][url_path] = lovelace_config + hass.data[LOVELACE_DATA].dashboards[url_path] = lovelace_config try: _register_panel(hass, url_path, MODE_YAML, dashboard_conf, False) diff --git a/homeassistant/components/lovelace/cast.py b/homeassistant/components/lovelace/cast.py index c380a296fc0..635425ba3dc 100644 --- a/homeassistant/components/lovelace/cast.py +++ b/homeassistant/components/lovelace/cast.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from pychromecast import Chromecast from pychromecast.const import CAST_TYPE_CHROMECAST @@ -23,8 +25,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.network import NoURLAvailableError, get_url -from .const import DOMAIN, ConfigNotFound -from .dashboard import LovelaceConfig +from .const import DOMAIN, LOVELACE_DATA, ConfigNotFound DEFAULT_DASHBOARD = "_default_" @@ -76,7 +77,7 @@ async def async_browse_media( can_expand=False, ) ] - for url_path in hass.data[DOMAIN]["dashboards"]: + for url_path in hass.data[LOVELACE_DATA].dashboards: if url_path is None: continue @@ -151,11 +152,13 @@ async def async_play_media( return True -async def _get_dashboard_info(hass, url_path): +async def _get_dashboard_info( + hass: HomeAssistant, url_path: str | None +) -> dict[str, Any]: """Load a dashboard and return info on views.""" if url_path == DEFAULT_DASHBOARD: url_path = None - dashboard: LovelaceConfig | None = hass.data[DOMAIN]["dashboards"].get(url_path) + dashboard = hass.data[LOVELACE_DATA].dashboards.get(url_path) if dashboard is None: raise ValueError("Invalid dashboard specified") @@ -172,7 +175,7 @@ async def _get_dashboard_info(hass, url_path): url_path = dashboard.url_path title = config.get("title", url_path) if config else url_path - views = [] + views: list[dict[str, Any]] = [] data = { "title": title, "url_path": url_path, diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py index 86f47fe2b5c..0bf5973e03d 100644 --- a/homeassistant/components/lovelace/const.py +++ b/homeassistant/components/lovelace/const.py @@ -1,6 +1,8 @@ """Constants for Lovelace.""" -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -15,8 +17,13 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType from homeassistant.util import slugify +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from . import LovelaceData DOMAIN = "lovelace" +LOVELACE_DATA: HassKey[LovelaceData] = HassKey(DOMAIN) DEFAULT_ICON = "hass:view-dashboard" diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index 411bbae9153..25e15d524c8 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -27,6 +27,7 @@ from .const import ( DOMAIN, EVENT_LOVELACE_UPDATED, LOVELACE_CONFIG_FILE, + LOVELACE_DATA, MODE_STORAGE, MODE_YAML, STORAGE_DASHBOARD_CREATE_FIELDS, @@ -315,7 +316,7 @@ class DashboardsCollectionWebSocket(collection.DictStorageCollectionWebsocket): msg["id"], [ dashboard.config - for dashboard in hass.data[DOMAIN]["dashboards"].values() + for dashboard in hass.data[LOVELACE_DATA].dashboards.values() if dashboard.config ], ) diff --git a/homeassistant/components/lovelace/system_health.py b/homeassistant/components/lovelace/system_health.py index 1e703768ae6..b629614d10d 100644 --- a/homeassistant/components/lovelace/system_health.py +++ b/homeassistant/components/lovelace/system_health.py @@ -1,12 +1,13 @@ """Provide info to system health.""" import asyncio +from typing import Any from homeassistant.components import system_health from homeassistant.const import CONF_MODE from homeassistant.core import HomeAssistant, callback -from .const import DOMAIN, MODE_AUTO, MODE_STORAGE, MODE_YAML +from .const import LOVELACE_DATA, MODE_AUTO, MODE_STORAGE, MODE_YAML @callback @@ -17,15 +18,17 @@ def async_register( register.async_register_info(system_health_info, "/config/lovelace") -async def system_health_info(hass): +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" - health_info = {"dashboards": len(hass.data[DOMAIN]["dashboards"])} - health_info.update(await hass.data[DOMAIN]["resources"].async_get_info()) + health_info: dict[str, Any] = { + "dashboards": len(hass.data[LOVELACE_DATA].dashboards) + } + health_info.update(await hass.data[LOVELACE_DATA].resources.async_get_info()) dashboards_info = await asyncio.gather( *( - hass.data[DOMAIN]["dashboards"][dashboard].async_get_info() - for dashboard in hass.data[DOMAIN]["dashboards"] + hass.data[LOVELACE_DATA].dashboards[dashboard].async_get_info() + for dashboard in hass.data[LOVELACE_DATA].dashboards ) ) @@ -39,7 +42,7 @@ async def system_health_info(hass): else: health_info[key] = dashboard[key] - if hass.data[DOMAIN][CONF_MODE] == MODE_YAML: + if hass.data[LOVELACE_DATA].mode == MODE_YAML: health_info[CONF_MODE] = MODE_YAML elif MODE_STORAGE in modes: health_info[CONF_MODE] = MODE_STORAGE diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index e402ba92f16..7424f551e7a 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -13,7 +13,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import json_fragment -from .const import CONF_URL_PATH, DOMAIN, ConfigNotFound +from .const import CONF_URL_PATH, LOVELACE_DATA, ConfigNotFound from .dashboard import LovelaceStorage @@ -27,7 +27,7 @@ def _handle_errors(func): msg: dict[str, Any], ) -> None: url_path = msg.get(CONF_URL_PATH) - config: LovelaceStorage | None = hass.data[DOMAIN]["dashboards"].get(url_path) + config = hass.data[LOVELACE_DATA].dashboards.get(url_path) if config is None: connection.send_error( @@ -74,7 +74,7 @@ async def websocket_lovelace_resources_impl( This function is called by both Storage and YAML mode WS handlers. """ - resources = hass.data[DOMAIN]["resources"] + resources = hass.data[LOVELACE_DATA].resources if hass.config.safe_mode: connection.send_result(msg["id"], []) From 75738f2105bf9e7682c6d33ac1ae8547df264847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 23 Jan 2025 11:30:46 +0000 Subject: [PATCH 0860/2987] Add system_health the to Network component (#135514) --- homeassistant/components/network/strings.json | 10 ++++ .../components/network/system_health.py | 53 +++++++++++++++++++ .../components/network/test_system_health.py | 32 +++++++++++ 3 files changed, 95 insertions(+) create mode 100644 homeassistant/components/network/strings.json create mode 100644 homeassistant/components/network/system_health.py create mode 100644 tests/components/network/test_system_health.py diff --git a/homeassistant/components/network/strings.json b/homeassistant/components/network/strings.json new file mode 100644 index 00000000000..6aca7343221 --- /dev/null +++ b/homeassistant/components/network/strings.json @@ -0,0 +1,10 @@ +{ + "system_health": { + "info": { + "adapters": "Adapters", + "ipv4_addresses": "IPv4 addresses", + "ipv6_addresses": "IPv6 addresses", + "announce_addresses": "Announce addresses" + } + } +} diff --git a/homeassistant/components/network/system_health.py b/homeassistant/components/network/system_health.py new file mode 100644 index 00000000000..ebabe055539 --- /dev/null +++ b/homeassistant/components/network/system_health.py @@ -0,0 +1,53 @@ +"""Provide info to system health.""" + +from typing import Any + +from homeassistant.components import system_health +from homeassistant.core import HomeAssistant, callback + +from . import Adapter, async_get_adapters, async_get_announce_addresses +from .models import IPv4ConfiguredAddress, IPv6ConfiguredAddress + + +@callback +def async_register( + hass: HomeAssistant, register: system_health.SystemHealthRegistration +) -> None: + """Register system health callbacks.""" + register.async_register_info(system_health_info, "/config/network") + + +def _format_ips(ips: list[IPv4ConfiguredAddress] | list[IPv6ConfiguredAddress]) -> str: + return ", ".join([f"{ip['address']}/{ip['network_prefix']!s}" for ip in ips]) + + +def _get_adapter_info(adapter: Adapter) -> str: + state = "enabled" if adapter["enabled"] else "disabled" + default = ", default" if adapter["default"] else "" + auto = ", auto" if adapter["auto"] else "" + return f"{adapter['name']} ({state}{default}{auto})" + + +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: + """Get info for the info page.""" + + adapters = await async_get_adapters(hass) + data: dict[str, Any] = { + # k: v for adapter in adapters for k, v in _get_adapter_info(adapter).items() + "adapters": ", ".join([_get_adapter_info(adapter) for adapter in adapters]), + "ipv4_addresses": ", ".join( + [ + f"{adapter['name']} ({_format_ips(adapter['ipv4'])})" + for adapter in adapters + ] + ), + "ipv6_addresses": ", ".join( + [ + f"{adapter['name']} ({_format_ips(adapter['ipv6'])})" + for adapter in adapters + ] + ), + "announce_addresses": ", ".join(await async_get_announce_addresses(hass)), + } + + return data diff --git a/tests/components/network/test_system_health.py b/tests/components/network/test_system_health.py new file mode 100644 index 00000000000..eb383aafde7 --- /dev/null +++ b/tests/components/network/test_system_health.py @@ -0,0 +1,32 @@ +"""Test network system health.""" + +import asyncio + +import pytest + +from homeassistant.components.network.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import get_system_health_info + + +@pytest.mark.usefixtures("mock_socket_no_loopback") +async def test_network_system_health(hass: HomeAssistant) -> None: + """Test network system health.""" + + assert await async_setup_component(hass, "system_health", {}) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + info = await get_system_health_info(hass, "network") + + for key, val in info.items(): + if asyncio.iscoroutine(val): + info[key] = await val + + assert info == { + "adapters": "eth0 (disabled), lo0 (disabled), eth1 (enabled, default, auto), vtun0 (disabled)", + "announce_addresses": "192.168.1.5", + "ipv4_addresses": "eth0 (), lo0 (127.0.0.1/8), eth1 (192.168.1.5/23), vtun0 (169.254.3.2/16)", + "ipv6_addresses": "eth0 (2001:db8::/8), lo0 (), eth1 (), vtun0 ()", + } From e57dafee6c6e39cd6b96e9ab24f5914f51071c87 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 23 Jan 2025 12:35:53 +0100 Subject: [PATCH 0861/2987] Add parallel updates to Airgradient (#136323) --- homeassistant/components/airgradient/button.py | 2 ++ homeassistant/components/airgradient/number.py | 2 ++ homeassistant/components/airgradient/quality_scale.yaml | 2 +- homeassistant/components/airgradient/select.py | 2 ++ homeassistant/components/airgradient/sensor.py | 2 ++ homeassistant/components/airgradient/switch.py | 2 ++ homeassistant/components/airgradient/update.py | 1 + 7 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/airgradient/button.py b/homeassistant/components/airgradient/button.py index 32a9b5adedf..c4cbb92f9ba 100644 --- a/homeassistant/components/airgradient/button.py +++ b/homeassistant/components/airgradient/button.py @@ -20,6 +20,8 @@ from .const import DOMAIN from .coordinator import AirGradientCoordinator from .entity import AirGradientEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class AirGradientButtonEntityDescription(ButtonEntityDescription): diff --git a/homeassistant/components/airgradient/number.py b/homeassistant/components/airgradient/number.py index 7fd282ddd8b..95a26f66530 100644 --- a/homeassistant/components/airgradient/number.py +++ b/homeassistant/components/airgradient/number.py @@ -21,6 +21,8 @@ from .const import DOMAIN from .coordinator import AirGradientCoordinator from .entity import AirGradientEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class AirGradientNumberEntityDescription(NumberEntityDescription): diff --git a/homeassistant/components/airgradient/quality_scale.yaml b/homeassistant/components/airgradient/quality_scale.yaml index 43816401cdb..333c64ded00 100644 --- a/homeassistant/components/airgradient/quality_scale.yaml +++ b/homeassistant/components/airgradient/quality_scale.yaml @@ -38,7 +38,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: status: exempt comment: | diff --git a/homeassistant/components/airgradient/select.py b/homeassistant/components/airgradient/select.py index af56802d842..467904654a4 100644 --- a/homeassistant/components/airgradient/select.py +++ b/homeassistant/components/airgradient/select.py @@ -21,6 +21,8 @@ from .const import DOMAIN, PM_STANDARD, PM_STANDARD_REVERSE from .coordinator import AirGradientCoordinator from .entity import AirGradientEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class AirGradientSelectEntityDescription(SelectEntityDescription): diff --git a/homeassistant/components/airgradient/sensor.py b/homeassistant/components/airgradient/sensor.py index 273ba20d6b7..3b20b31f923 100644 --- a/homeassistant/components/airgradient/sensor.py +++ b/homeassistant/components/airgradient/sensor.py @@ -35,6 +35,8 @@ from .const import PM_STANDARD, PM_STANDARD_REVERSE from .coordinator import AirGradientCoordinator from .entity import AirGradientEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class AirGradientMeasurementSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/airgradient/switch.py b/homeassistant/components/airgradient/switch.py index 329f704e755..6cdcbb53fae 100644 --- a/homeassistant/components/airgradient/switch.py +++ b/homeassistant/components/airgradient/switch.py @@ -22,6 +22,8 @@ from .const import DOMAIN from .coordinator import AirGradientCoordinator from .entity import AirGradientEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class AirGradientSwitchEntityDescription(SwitchEntityDescription): diff --git a/homeassistant/components/airgradient/update.py b/homeassistant/components/airgradient/update.py index 7c040524243..12cec65f791 100644 --- a/homeassistant/components/airgradient/update.py +++ b/homeassistant/components/airgradient/update.py @@ -11,6 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AirGradientConfigEntry, AirGradientCoordinator from .entity import AirGradientEntity +PARALLEL_UPDATES = 1 SCAN_INTERVAL = timedelta(hours=1) From d6f6961674ffa6798c94a0d483448747541c94a2 Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Thu, 23 Jan 2025 13:35:21 +0100 Subject: [PATCH 0862/2987] Restructure the youless integration internals (#135842) --- homeassistant/components/youless/__init__.py | 22 +- .../components/youless/coordinator.py | 25 + homeassistant/components/youless/entity.py | 25 + homeassistant/components/youless/sensor.py | 603 +++++++++--------- .../youless/snapshots/test_sensor.ambr | 42 +- 5 files changed, 367 insertions(+), 350 deletions(-) create mode 100644 homeassistant/components/youless/coordinator.py create mode 100644 homeassistant/components/youless/entity.py diff --git a/homeassistant/components/youless/__init__.py b/homeassistant/components/youless/__init__.py index d475034cc9d..03a27b5a378 100644 --- a/homeassistant/components/youless/__init__.py +++ b/homeassistant/components/youless/__init__.py @@ -1,6 +1,5 @@ """The youless integration.""" -from datetime import timedelta import logging from urllib.error import URLError @@ -10,9 +9,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +from .coordinator import YouLessCoordinator PLATFORMS = [Platform.SENSOR] @@ -28,24 +27,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except URLError as exception: raise ConfigEntryNotReady from exception - async def async_update_data() -> YoulessAPI: - """Fetch data from the API.""" - await hass.async_add_executor_job(api.update) - return api - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name="youless_gateway", - update_method=async_update_data, - update_interval=timedelta(seconds=10), - ) - - await coordinator.async_config_entry_first_refresh() + youless_coordinator = YouLessCoordinator(hass, api) + await youless_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + hass.data[DOMAIN][entry.entry_id] = youless_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/youless/coordinator.py b/homeassistant/components/youless/coordinator.py new file mode 100644 index 00000000000..0be5e463689 --- /dev/null +++ b/homeassistant/components/youless/coordinator.py @@ -0,0 +1,25 @@ +"""The coordinator for the Youless integration.""" + +from datetime import timedelta +import logging + +from youless_api import YoulessAPI + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class YouLessCoordinator(DataUpdateCoordinator[None]): + """Class to manage fetching YouLess data.""" + + def __init__(self, hass: HomeAssistant, device: YoulessAPI) -> None: + """Initialize global YouLess data provider.""" + super().__init__( + hass, _LOGGER, name="youless_gateway", update_interval=timedelta(seconds=10) + ) + self.device = device + + async def _async_update_data(self) -> None: + await self.hass.async_add_executor_job(self.device.update) diff --git a/homeassistant/components/youless/entity.py b/homeassistant/components/youless/entity.py new file mode 100644 index 00000000000..9931768c267 --- /dev/null +++ b/homeassistant/components/youless/entity.py @@ -0,0 +1,25 @@ +"""The entity for the Youless integration.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import YouLessCoordinator + + +class YouLessEntity(CoordinatorEntity[YouLessCoordinator]): + """Base entity for YouLess.""" + + def __init__( + self, coordinator: YouLessCoordinator, device_group: str, device_name: str + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.device = coordinator.device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_group)}, + manufacturer="YouLess", + model=self.device.model, + name=device_name, + sw_version=self.device.firmware_version, + ) diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index ed0fc703cc4..413f1ad6958 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -2,12 +2,15 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + from youless_api import YoulessAPI -from youless_api.youless_sensor import YoulessSensor from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -20,346 +23,316 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) from . import DOMAIN +from .coordinator import YouLessCoordinator +from .entity import YouLessEntity + + +@dataclass(frozen=True, kw_only=True) +class YouLessSensorEntityDescription(SensorEntityDescription): + """Describes a YouLess sensor entity.""" + + device_group: str + device_group_name: str + value_func: Callable[[YoulessAPI], float | None] + + +SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( + YouLessSensorEntityDescription( + key="water", + device_group="water", + device_group_name="Water meter", + name="Water usage", + icon="mdi:water", + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + value_func=( + lambda device: device.water_meter.value if device.water_meter else None + ), + ), + YouLessSensorEntityDescription( + key="gas", + device_group="gas", + device_group_name="Gas meter", + name="Gas usage", + icon="mdi:fire", + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + value_func=lambda device: device.gas_meter.value if device.gas_meter else None, + ), + YouLessSensorEntityDescription( + key="usage", + device_group="power", + device_group_name="Power usage", + name="Power Usage", + icon="mdi:meter-electric", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=( + lambda device: device.current_power_usage.value + if device.current_power_usage + else None + ), + ), + YouLessSensorEntityDescription( + key="power_low", + device_group="power", + device_group_name="Power usage", + name="Energy low", + icon="mdi:transmission-tower-export", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_func=( + lambda device: device.power_meter.low.value if device.power_meter else None + ), + ), + YouLessSensorEntityDescription( + key="power_high", + device_group="power", + device_group_name="Power usage", + name="Energy high", + icon="mdi:transmission-tower-export", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_func=( + lambda device: device.power_meter.high.value if device.power_meter else None + ), + ), + YouLessSensorEntityDescription( + key="power_total", + device_group="power", + device_group_name="Power usage", + name="Energy total", + icon="mdi:transmission-tower-export", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_func=( + lambda device: device.power_meter.total.value + if device.power_meter + else None + ), + ), + YouLessSensorEntityDescription( + key="phase_1_power", + device_group="power", + device_group_name="Power usage", + name="Phase 1 power", + icon=None, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=lambda device: device.phase1.power.value if device.phase1 else None, + ), + YouLessSensorEntityDescription( + key="phase_1_voltage", + device_group="power", + device_group_name="Power usage", + name="Phase 1 voltage", + icon=None, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_func=( + lambda device: device.phase1.voltage.value if device.phase1 else None + ), + ), + YouLessSensorEntityDescription( + key="phase_1_current", + device_group="power", + device_group_name="Power usage", + name="Phase 1 current", + icon=None, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_func=( + lambda device: device.phase1.current.value if device.phase1 else None + ), + ), + YouLessSensorEntityDescription( + key="phase_2_power", + device_group="power", + device_group_name="Power usage", + name="Phase 2 power", + icon=None, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=lambda device: device.phase2.power.value if device.phase2 else None, + ), + YouLessSensorEntityDescription( + key="phase_2_voltage", + device_group="power", + device_group_name="Power usage", + name="Phase 2 voltage", + icon=None, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_func=( + lambda device: device.phase2.voltage.value if device.phase2 else None + ), + ), + YouLessSensorEntityDescription( + key="phase_2_current", + device_group="power", + device_group_name="Power usage", + name="Phase 2 current", + icon=None, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_func=( + lambda device: device.phase2.current.value if device.phase1 else None + ), + ), + YouLessSensorEntityDescription( + key="phase_3_power", + device_group="power", + device_group_name="Power usage", + name="Phase 3 power", + icon=None, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=lambda device: device.phase3.power.value if device.phase3 else None, + ), + YouLessSensorEntityDescription( + key="phase_3_voltage", + device_group="power", + device_group_name="Power usage", + name="Phase 3 voltage", + icon=None, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_func=( + lambda device: device.phase3.voltage.value if device.phase3 else None + ), + ), + YouLessSensorEntityDescription( + key="phase_3_current", + device_group="power", + device_group_name="Power usage", + name="Phase 3 current", + icon=None, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_func=( + lambda device: device.phase3.current.value if device.phase1 else None + ), + ), + YouLessSensorEntityDescription( + key="delivery_low", + device_group="delivery", + device_group_name="Energy delivery", + name="Energy delivery low", + icon="mdi:transmission-tower-import", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_func=( + lambda device: device.delivery_meter.low.value + if device.delivery_meter + else None + ), + ), + YouLessSensorEntityDescription( + key="delivery_high", + device_group="delivery", + device_group_name="Energy delivery", + name="Energy delivery high", + icon="mdi:transmission-tower-import", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_func=( + lambda device: device.delivery_meter.high.value + if device.delivery_meter + else None + ), + ), + YouLessSensorEntityDescription( + key="extra_total", + device_group="extra", + device_group_name="Extra meter", + name="Extra total", + icon="mdi:meter-electric", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_func=( + lambda device: device.extra_meter.total.value + if device.extra_meter + else None + ), + ), + YouLessSensorEntityDescription( + key="extra_usage", + device_group="extra", + device_group_name="Extra meter", + name="Extra usage", + icon="mdi:lightning-bolt", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=( + lambda device: device.extra_meter.usage.value + if device.extra_meter + else None + ), + ), +) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Initialize the integration.""" - coordinator: DataUpdateCoordinator[YoulessAPI] = hass.data[DOMAIN][entry.entry_id] + coordinator: YouLessCoordinator = hass.data[DOMAIN][entry.entry_id] device = entry.data[CONF_DEVICE] if (device := entry.data[CONF_DEVICE]) is None: device = entry.entry_id async_add_entities( [ - WaterSensor(coordinator, device), - GasSensor(coordinator, device), - EnergyMeterSensor( - coordinator, device, "low", SensorStateClass.TOTAL_INCREASING - ), - EnergyMeterSensor( - coordinator, device, "high", SensorStateClass.TOTAL_INCREASING - ), - EnergyMeterSensor(coordinator, device, "total", SensorStateClass.TOTAL), - CurrentPowerSensor(coordinator, device), - DeliveryMeterSensor(coordinator, device, "low"), - DeliveryMeterSensor(coordinator, device, "high"), - ExtraMeterSensor(coordinator, device, "total"), - ExtraMeterPowerSensor(coordinator, device, "usage"), - PhasePowerSensor(coordinator, device, 1), - PhaseVoltageSensor(coordinator, device, 1), - PhaseCurrentSensor(coordinator, device, 1), - PhasePowerSensor(coordinator, device, 2), - PhaseVoltageSensor(coordinator, device, 2), - PhaseCurrentSensor(coordinator, device, 2), - PhasePowerSensor(coordinator, device, 3), - PhaseVoltageSensor(coordinator, device, 3), - PhaseCurrentSensor(coordinator, device, 3), + YouLessSensor(coordinator, description, device) + for description in SENSOR_TYPES ] ) -class YoulessBaseSensor( - CoordinatorEntity[DataUpdateCoordinator[YoulessAPI]], SensorEntity -): - """The base sensor for Youless.""" +class YouLessSensor(YouLessEntity, SensorEntity): + """Representation of a Sensor.""" + + entity_description: YouLessSensorEntityDescription def __init__( self, - coordinator: DataUpdateCoordinator[YoulessAPI], + coordinator: YouLessCoordinator, + description: YouLessSensorEntityDescription, device: str, - device_group: str, - friendly_name: str, - sensor_id: str, ) -> None: - """Create the sensor.""" - super().__init__(coordinator) - self._attr_unique_id = f"{DOMAIN}_{device}_{sensor_id}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{device}_{device_group}")}, - manufacturer="YouLess", - model=self.coordinator.data.model, - name=friendly_name, + """Initialize the sensor.""" + super().__init__( + coordinator, + f"{device}_{description.device_group}", + description.device_group_name, ) - - @property - def get_sensor(self) -> YoulessSensor | None: - """Property to get the underlying sensor object.""" - return None + self._attr_unique_id = f"{DOMAIN}_{device}_{description.key}" + self.entity_description = description @property def native_value(self) -> StateType: - """Determine the state value, only if a sensor is initialized.""" - if self.get_sensor is None: - return None - - return self.get_sensor.value - - @property - def available(self) -> bool: - """Return a flag to indicate the sensor not being available.""" - return super().available and self.get_sensor is not None - - -class WaterSensor(YoulessBaseSensor): - """The Youless Water sensor.""" - - _attr_native_unit_of_measurement = UnitOfVolume.CUBIC_METERS - _attr_device_class = SensorDeviceClass.WATER - _attr_state_class = SensorStateClass.TOTAL_INCREASING - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str - ) -> None: - """Instantiate a Water sensor.""" - super().__init__(coordinator, device, "water", "Water meter", "water") - self._attr_name = "Water usage" - self._attr_icon = "mdi:water" - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - return self.coordinator.data.water_meter - - -class GasSensor(YoulessBaseSensor): - """The Youless gas sensor.""" - - _attr_native_unit_of_measurement = UnitOfVolume.CUBIC_METERS - _attr_device_class = SensorDeviceClass.GAS - _attr_state_class = SensorStateClass.TOTAL_INCREASING - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str - ) -> None: - """Instantiate a gas sensor.""" - super().__init__(coordinator, device, "gas", "Gas meter", "gas") - self._attr_name = "Gas usage" - self._attr_icon = "mdi:fire" - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - return self.coordinator.data.gas_meter - - -class CurrentPowerSensor(YoulessBaseSensor): - """The current power usage sensor.""" - - _attr_native_unit_of_measurement = UnitOfPower.WATT - _attr_device_class = SensorDeviceClass.POWER - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str - ) -> None: - """Instantiate the usage meter.""" - super().__init__(coordinator, device, "power", "Power usage", "usage") - self._device = device - self._attr_name = "Power Usage" - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - return self.coordinator.data.current_power_usage - - -class DeliveryMeterSensor(YoulessBaseSensor): - """The Youless delivery meter value sensor.""" - - _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR - _attr_device_class = SensorDeviceClass.ENERGY - _attr_state_class = SensorStateClass.TOTAL_INCREASING - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, dev_type: str - ) -> None: - """Instantiate a delivery meter sensor.""" - super().__init__( - coordinator, device, "delivery", "Energy delivery", f"delivery_{dev_type}" - ) - self._type = dev_type - self._attr_name = f"Energy delivery {dev_type}" - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - if self.coordinator.data.delivery_meter is None: - return None - - return getattr(self.coordinator.data.delivery_meter, f"_{self._type}", None) - - -class EnergyMeterSensor(YoulessBaseSensor): - """The Youless low meter value sensor.""" - - _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR - _attr_device_class = SensorDeviceClass.ENERGY - _attr_state_class = SensorStateClass.TOTAL_INCREASING - - def __init__( - self, - coordinator: DataUpdateCoordinator[YoulessAPI], - device: str, - dev_type: str, - state_class: SensorStateClass, - ) -> None: - """Instantiate a energy meter sensor.""" - super().__init__( - coordinator, device, "power", "Energy usage", f"power_{dev_type}" - ) - self._device = device - self._type = dev_type - self._attr_name = f"Energy {dev_type}" - self._attr_state_class = state_class - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - if self.coordinator.data.power_meter is None: - return None - - return getattr(self.coordinator.data.power_meter, f"_{self._type}", None) - - -class PhasePowerSensor(YoulessBaseSensor): - """The current power usage of a single phase.""" - - _attr_native_unit_of_measurement = UnitOfPower.WATT - _attr_device_class = SensorDeviceClass.POWER - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int - ) -> None: - """Initialize the power phase sensor.""" - super().__init__( - coordinator, device, "power", "Energy usage", f"phase_{phase}_power" - ) - self._attr_name = f"Phase {phase} power" - self._phase = phase - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor value from the coordinator.""" - phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) - if phase_sensor is None: - return None - - return phase_sensor.power - - -class PhaseVoltageSensor(YoulessBaseSensor): - """The current voltage of a single phase.""" - - _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT - _attr_device_class = SensorDeviceClass.VOLTAGE - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int - ) -> None: - """Initialize the voltage phase sensor.""" - super().__init__( - coordinator, device, "power", "Energy usage", f"phase_{phase}_voltage" - ) - self._attr_name = f"Phase {phase} voltage" - self._phase = phase - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor value from the coordinator for phase voltage.""" - phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) - if phase_sensor is None: - return None - - return phase_sensor.voltage - - -class PhaseCurrentSensor(YoulessBaseSensor): - """The current current of a single phase.""" - - _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE - _attr_device_class = SensorDeviceClass.CURRENT - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int - ) -> None: - """Initialize the current phase sensor.""" - super().__init__( - coordinator, device, "power", "Energy usage", f"phase_{phase}_current" - ) - self._attr_name = f"Phase {phase} current" - self._phase = phase - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor value from the coordinator for phase current.""" - phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) - if phase_sensor is None: - return None - - return phase_sensor.current - - -class ExtraMeterSensor(YoulessBaseSensor): - """The Youless extra meter value sensor (s0).""" - - _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR - _attr_device_class = SensorDeviceClass.ENERGY - _attr_state_class = SensorStateClass.TOTAL_INCREASING - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, dev_type: str - ) -> None: - """Instantiate an extra meter sensor.""" - super().__init__( - coordinator, device, "extra", "Extra meter", f"extra_{dev_type}" - ) - self._type = dev_type - self._attr_name = f"Extra {dev_type}" - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - if self.coordinator.data.extra_meter is None: - return None - - return getattr(self.coordinator.data.extra_meter, f"_{self._type}", None) - - -class ExtraMeterPowerSensor(YoulessBaseSensor): - """The Youless extra meter power value sensor (s0).""" - - _attr_native_unit_of_measurement = UnitOfPower.WATT - _attr_device_class = SensorDeviceClass.POWER - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, dev_type: str - ) -> None: - """Instantiate an extra meter power sensor.""" - super().__init__( - coordinator, device, "extra", "Extra meter", f"extra_{dev_type}" - ) - self._type = dev_type - self._attr_name = f"Extra {dev_type}" - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - if self.coordinator.data.extra_meter is None: - return None - - return getattr(self.coordinator.data.extra_meter, f"_{self._type}", None) + """Return the state of the sensor.""" + return self.entity_description.value_func(self.device) diff --git a/tests/components/youless/snapshots/test_sensor.ambr b/tests/components/youless/snapshots/test_sensor.ambr index bcfd0139e5c..3424a264f48 100644 --- a/tests/components/youless/snapshots/test_sensor.ambr +++ b/tests/components/youless/snapshots/test_sensor.ambr @@ -24,7 +24,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, + 'original_icon': 'mdi:transmission-tower-import', 'original_name': 'Energy delivery high', 'platform': 'youless', 'previous_unique_id': None, @@ -39,6 +39,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Energy delivery high', + 'icon': 'mdi:transmission-tower-import', 'state_class': , 'unit_of_measurement': , }), @@ -75,7 +76,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, + 'original_icon': 'mdi:transmission-tower-import', 'original_name': 'Energy delivery low', 'platform': 'youless', 'previous_unique_id': None, @@ -90,6 +91,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Energy delivery low', + 'icon': 'mdi:transmission-tower-import', 'state_class': , 'unit_of_measurement': , }), @@ -126,7 +128,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, + 'original_icon': 'mdi:transmission-tower-export', 'original_name': 'Energy high', 'platform': 'youless', 'previous_unique_id': None, @@ -141,6 +143,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Energy high', + 'icon': 'mdi:transmission-tower-export', 'state_class': , 'unit_of_measurement': , }), @@ -177,7 +180,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, + 'original_icon': 'mdi:transmission-tower-export', 'original_name': 'Energy low', 'platform': 'youless', 'previous_unique_id': None, @@ -192,6 +195,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Energy low', + 'icon': 'mdi:transmission-tower-export', 'state_class': , 'unit_of_measurement': , }), @@ -228,7 +232,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, + 'original_icon': 'mdi:transmission-tower-export', 'original_name': 'Energy total', 'platform': 'youless', 'previous_unique_id': None, @@ -243,6 +247,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Energy total', + 'icon': 'mdi:transmission-tower-export', 'state_class': , 'unit_of_measurement': , }), @@ -279,7 +284,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, + 'original_icon': 'mdi:meter-electric', 'original_name': 'Extra total', 'platform': 'youless', 'previous_unique_id': None, @@ -294,6 +299,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Extra total', + 'icon': 'mdi:meter-electric', 'state_class': , 'unit_of_measurement': , }), @@ -330,7 +336,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, + 'original_icon': 'mdi:lightning-bolt', 'original_name': 'Extra usage', 'platform': 'youless', 'previous_unique_id': None, @@ -345,6 +351,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Extra usage', + 'icon': 'mdi:lightning-bolt', 'state_class': , 'unit_of_measurement': , }), @@ -456,7 +463,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.phase_1_power-entry] @@ -507,7 +514,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.phase_1_voltage-entry] @@ -558,7 +565,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.phase_2_current-entry] @@ -609,7 +616,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.phase_2_power-entry] @@ -660,7 +667,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.phase_2_voltage-entry] @@ -711,7 +718,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.phase_3_current-entry] @@ -762,7 +769,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.phase_3_power-entry] @@ -813,7 +820,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.phase_3_voltage-entry] @@ -864,7 +871,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.power_usage-entry] @@ -892,7 +899,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, + 'original_icon': 'mdi:meter-electric', 'original_name': 'Power Usage', 'platform': 'youless', 'previous_unique_id': None, @@ -907,6 +914,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Power Usage', + 'icon': 'mdi:meter-electric', 'state_class': , 'unit_of_measurement': , }), From 40ed0562bc7e3df76c4edfe76efd8a32dfd80eb5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 23 Jan 2025 13:48:46 +0100 Subject: [PATCH 0863/2987] Add translated action exceptions to Airgradient (#136322) * Add translated action exceptions to Airgradient * Add translated action exceptions to Airgradient --- .../components/airgradient/button.py | 3 +- .../components/airgradient/coordinator.py | 6 ++- .../components/airgradient/entity.py | 34 +++++++++++++++- .../components/airgradient/number.py | 3 +- .../components/airgradient/quality_scale.yaml | 4 +- .../components/airgradient/select.py | 3 +- .../components/airgradient/strings.json | 11 ++++++ .../components/airgradient/switch.py | 4 +- tests/components/airgradient/test_button.py | 38 +++++++++++++++++- tests/components/airgradient/test_number.py | 38 +++++++++++++++++- tests/components/airgradient/test_select.py | 39 ++++++++++++++++++- tests/components/airgradient/test_switch.py | 37 +++++++++++++++++- 12 files changed, 208 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/airgradient/button.py b/homeassistant/components/airgradient/button.py index c4cbb92f9ba..ea7b12062e8 100644 --- a/homeassistant/components/airgradient/button.py +++ b/homeassistant/components/airgradient/button.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AirGradientConfigEntry from .const import DOMAIN from .coordinator import AirGradientCoordinator -from .entity import AirGradientEntity +from .entity import AirGradientEntity, exception_handler PARALLEL_UPDATES = 1 @@ -102,6 +102,7 @@ class AirGradientButton(AirGradientEntity, ButtonEntity): self.entity_description = description self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" + @exception_handler async def async_press(self) -> None: """Press the button.""" await self.entity_description.press_fn(self.coordinator.client) diff --git a/homeassistant/components/airgradient/coordinator.py b/homeassistant/components/airgradient/coordinator.py index 03d58645853..d2fc2a9de1b 100644 --- a/homeassistant/components/airgradient/coordinator.py +++ b/homeassistant/components/airgradient/coordinator.py @@ -55,7 +55,11 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]): measures = await self.client.get_current_measures() config = await self.client.get_config() except AirGradientError as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"error": str(error)}, + ) from error if measures.firmware_version != self._current_version: device_registry = dr.async_get(self.hass) device_entry = device_registry.async_get_device( diff --git a/homeassistant/components/airgradient/entity.py b/homeassistant/components/airgradient/entity.py index 588a799610b..51256051259 100644 --- a/homeassistant/components/airgradient/entity.py +++ b/homeassistant/components/airgradient/entity.py @@ -1,7 +1,11 @@ """Base class for AirGradient entities.""" -from airgradient import get_model_name +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate +from airgradient import AirGradientConnectionError, AirGradientError, get_model_name + +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -26,3 +30,31 @@ class AirGradientEntity(CoordinatorEntity[AirGradientCoordinator]): serial_number=coordinator.serial_number, sw_version=measures.firmware_version, ) + + +def exception_handler[_EntityT: AirGradientEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate AirGradient calls to handle exceptions. + + A decorator that wraps the passed in function, catches AirGradient errors. + """ + + async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + except AirGradientConnectionError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(error)}, + ) from error + + except AirGradientError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unknown_error", + translation_placeholders={"error": str(error)}, + ) from error + + return handler diff --git a/homeassistant/components/airgradient/number.py b/homeassistant/components/airgradient/number.py index 95a26f66530..4265215fa25 100644 --- a/homeassistant/components/airgradient/number.py +++ b/homeassistant/components/airgradient/number.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AirGradientConfigEntry from .const import DOMAIN from .coordinator import AirGradientCoordinator -from .entity import AirGradientEntity +from .entity import AirGradientEntity, exception_handler PARALLEL_UPDATES = 1 @@ -123,6 +123,7 @@ class AirGradientNumber(AirGradientEntity, NumberEntity): """Return the state of the number.""" return self.entity_description.value_fn(self.coordinator.data.config) + @exception_handler async def async_set_native_value(self, value: float) -> None: """Set the selected value.""" await self.entity_description.set_value_fn(self.coordinator.client, int(value)) diff --git a/homeassistant/components/airgradient/quality_scale.yaml b/homeassistant/components/airgradient/quality_scale.yaml index 333c64ded00..a8904e71af5 100644 --- a/homeassistant/components/airgradient/quality_scale.yaml +++ b/homeassistant/components/airgradient/quality_scale.yaml @@ -29,7 +29,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt @@ -68,7 +68,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: todo repair-issues: diff --git a/homeassistant/components/airgradient/select.py b/homeassistant/components/airgradient/select.py index 467904654a4..8c15102ad3a 100644 --- a/homeassistant/components/airgradient/select.py +++ b/homeassistant/components/airgradient/select.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AirGradientConfigEntry from .const import DOMAIN, PM_STANDARD, PM_STANDARD_REVERSE from .coordinator import AirGradientCoordinator -from .entity import AirGradientEntity +from .entity import AirGradientEntity, exception_handler PARALLEL_UPDATES = 1 @@ -218,6 +218,7 @@ class AirGradientSelect(AirGradientEntity, SelectEntity): """Return the state of the select.""" return self.entity_description.value_fn(self.coordinator.data.config) + @exception_handler async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.entity_description.set_value_fn(self.coordinator.client, option) diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json index f3f78ea8fc9..f3b0bbdd60c 100644 --- a/homeassistant/components/airgradient/strings.json +++ b/homeassistant/components/airgradient/strings.json @@ -165,5 +165,16 @@ "name": "Post data to Airgradient" } } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the Airgradient device: {error}" + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the Airgradient device: {error}" + }, + "update_error": { + "message": "An error occurred while communicating with the Airgradient device: {error}" + } } } diff --git a/homeassistant/components/airgradient/switch.py b/homeassistant/components/airgradient/switch.py index 6cdcbb53fae..55835fa30a6 100644 --- a/homeassistant/components/airgradient/switch.py +++ b/homeassistant/components/airgradient/switch.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AirGradientConfigEntry from .const import DOMAIN from .coordinator import AirGradientCoordinator -from .entity import AirGradientEntity +from .entity import AirGradientEntity, exception_handler PARALLEL_UPDATES = 1 @@ -101,11 +101,13 @@ class AirGradientSwitch(AirGradientEntity, SwitchEntity): """Return the state of the switch.""" return self.entity_description.value_fn(self.coordinator.data.config) + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self.entity_description.set_value_fn(self.coordinator.client, True) await self.coordinator.async_request_refresh() + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.entity_description.set_value_fn(self.coordinator.client, False) diff --git a/tests/components/airgradient/test_button.py b/tests/components/airgradient/test_button.py index 83de2c2f048..2440669b6e8 100644 --- a/tests/components/airgradient/test_button.py +++ b/tests/components/airgradient/test_button.py @@ -3,14 +3,16 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from airgradient import Config +from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -97,3 +99,37 @@ async def test_cloud_creates_no_button( await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + AirGradientConnectionError("Something happened"), + "An error occurred while communicating with the Airgradient device: Something happened", + ), + ( + AirGradientError("Something else happened"), + "An unknown error occurred while communicating with the Airgradient device: Something else happened", + ), + ], +) +async def test_exception_handling( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling.""" + await setup_integration(hass, mock_config_entry) + mock_airgradient_client.request_co2_calibration.side_effect = exception + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: "button.airgradient_calibrate_co2_sensor", + }, + blocking=True, + ) diff --git a/tests/components/airgradient/test_number.py b/tests/components/airgradient/test_number.py index 7aabda8f81c..2cbd72d033a 100644 --- a/tests/components/airgradient/test_number.py +++ b/tests/components/airgradient/test_number.py @@ -3,8 +3,9 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from airgradient import Config +from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN @@ -15,6 +16,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -99,3 +101,37 @@ async def test_cloud_creates_no_number( await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + AirGradientConnectionError("Something happened"), + "An error occurred while communicating with the Airgradient device: Something happened", + ), + ( + AirGradientError("Something else happened"), + "An unknown error occurred while communicating with the Airgradient device: Something else happened", + ), + ], +) +async def test_exception_handling( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_airgradient_client.set_display_brightness.side_effect = exception + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: 50}, + target={ATTR_ENTITY_ID: "number.airgradient_display_brightness"}, + blocking=True, + ) diff --git a/tests/components/airgradient/test_select.py b/tests/components/airgradient/test_select.py index de4a7beaaa7..b8ae2cefa4e 100644 --- a/tests/components/airgradient/test_select.py +++ b/tests/components/airgradient/test_select.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from airgradient import Config +from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -15,6 +15,7 @@ from homeassistant.components.select import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -94,3 +95,39 @@ async def test_cloud_creates_no_number( await hass.async_block_till_done() assert len(hass.states.async_all()) == 1 + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + AirGradientConnectionError("Something happened"), + "An error occurred while communicating with the Airgradient device: Something happened", + ), + ( + AirGradientError("Something else happened"), + "An unknown error occurred while communicating with the Airgradient device: Something else happened", + ), + ], +) +async def test_exception_handling( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_airgradient_client.set_configuration_control.side_effect = exception + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.airgradient_configuration_source", + ATTR_OPTION: "local", + }, + blocking=True, + ) diff --git a/tests/components/airgradient/test_switch.py b/tests/components/airgradient/test_switch.py index a0cbdd17d75..475f38f554c 100644 --- a/tests/components/airgradient/test_switch.py +++ b/tests/components/airgradient/test_switch.py @@ -3,8 +3,9 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from airgradient import Config +from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN @@ -16,6 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -99,3 +101,36 @@ async def test_cloud_creates_no_switch( await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + AirGradientConnectionError("Something happened"), + "An error occurred while communicating with the Airgradient device: Something happened", + ), + ( + AirGradientError("Something else happened"), + "An unknown error occurred while communicating with the Airgradient device: Something else happened", + ), + ], +) +async def test_exception_handling( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_airgradient_client.enable_sharing_data.side_effect = exception + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + target={ATTR_ENTITY_ID: "switch.airgradient_post_data_to_airgradient"}, + blocking=True, + ) From 66f945e85220a2da1986b6465e93cbd83e169f47 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Thu, 23 Jan 2025 14:51:24 +0200 Subject: [PATCH 0864/2987] Bump aiowebostv to 0.6.0 (#136206) --- homeassistant/components/webostv/__init__.py | 15 ++++++++++++--- homeassistant/components/webostv/config_flow.py | 8 ++++---- homeassistant/components/webostv/const.py | 11 +++++------ homeassistant/components/webostv/helpers.py | 4 ++-- homeassistant/components/webostv/manifest.json | 4 ++-- .../components/webostv/quality_scale.yaml | 4 +--- homeassistant/generated/integrations.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/webostv/test_config_flow.py | 8 ++++---- tests/components/webostv/test_notify.py | 2 +- 11 files changed, 34 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index 3a3ee8e4c7e..186a7e68a64 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -19,6 +19,7 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .const import ( @@ -50,7 +51,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> b key = entry.data[CONF_CLIENT_SECRET] # Attempt a connection, but fail gracefully if tv is off for example. - entry.runtime_data = client = WebOsClient(host, key) + entry.runtime_data = client = WebOsClient( + host, key, client_session=async_get_clientsession(hass) + ) with suppress(*WEBOSTV_EXCEPTIONS): try: await client.connect() @@ -96,9 +99,15 @@ async def async_update_options(hass: HomeAssistant, entry: WebOsTvConfigEntry) - await hass.config_entries.async_reload(entry.entry_id) -async def async_control_connect(host: str, key: str | None) -> WebOsClient: +async def async_control_connect( + hass: HomeAssistant, host: str, key: str | None +) -> WebOsClient: """LG Connection.""" - client = WebOsClient(host, key) + client = WebOsClient( + host, + key, + client_session=async_get_clientsession(hass), + ) try: await client.connect() except WebOsTvPairError: diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index 6086fad8afd..f8125f0c0cf 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -69,7 +69,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - client = await async_control_connect(self._host, None) + client = await async_control_connect(self.hass, self._host, None) except WebOsTvPairError: errors["base"] = "error_pairing" except WEBOSTV_EXCEPTIONS: @@ -130,7 +130,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - client = await async_control_connect(self._host, None) + client = await async_control_connect(self.hass, self._host, None) except WebOsTvPairError: errors["base"] = "error_pairing" except WEBOSTV_EXCEPTIONS: @@ -154,7 +154,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): client_key = reconfigure_entry.data.get(CONF_CLIENT_SECRET) try: - client = await async_control_connect(host, client_key) + client = await async_control_connect(self.hass, host, client_key) except WebOsTvPairError: errors["base"] = "error_pairing" except WEBOSTV_EXCEPTIONS: @@ -195,7 +195,7 @@ class OptionsFlowHandler(OptionsFlow): options_input = {CONF_SOURCES: user_input[CONF_SOURCES]} return self.async_create_entry(title="", data=options_input) # Get sources - sources_list = await async_get_sources(self.host, self.key) + sources_list = await async_get_sources(self.hass, self.host, self.key) if not sources_list: errors["base"] = "cannot_retrieve" diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py index 65d964d8fd4..9c85c4cf5ac 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -2,8 +2,8 @@ import asyncio +import aiohttp from aiowebostv import WebOsTvCommandError -from websockets.exceptions import ConnectionClosed, ConnectionClosedOK from homeassistant.const import Platform @@ -27,11 +27,10 @@ SERVICE_SELECT_SOUND_OUTPUT = "select_sound_output" LIVE_TV_APP_ID = "com.webos.app.livetv" WEBOSTV_EXCEPTIONS = ( - OSError, - ConnectionClosed, - ConnectionClosedOK, - ConnectionRefusedError, + ConnectionResetError, WebOsTvCommandError, - TimeoutError, + aiohttp.ClientConnectorError, + aiohttp.ServerDisconnectedError, asyncio.CancelledError, + asyncio.TimeoutError, ) diff --git a/homeassistant/components/webostv/helpers.py b/homeassistant/components/webostv/helpers.py index 3aea860798a..f4563ef2394 100644 --- a/homeassistant/components/webostv/helpers.py +++ b/homeassistant/components/webostv/helpers.py @@ -72,10 +72,10 @@ def async_get_client_by_device_entry( ) -async def async_get_sources(host: str, key: str) -> list[str]: +async def async_get_sources(hass: HomeAssistant, host: str, key: str) -> list[str]: """Construct sources list.""" try: - client = await async_control_connect(host, key) + client = await async_control_connect(hass, host, key) except WEBOSTV_EXCEPTIONS: return [] diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 627bb83572c..f1a8e163398 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -1,12 +1,12 @@ { "domain": "webostv", - "name": "LG webOS Smart TV", + "name": "LG webOS TV", "codeowners": ["@thecode"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.5.0"], + "requirements": ["aiowebostv==0.6.0"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/homeassistant/components/webostv/quality_scale.yaml b/homeassistant/components/webostv/quality_scale.yaml index c4828e9e6dd..08c594d0298 100644 --- a/homeassistant/components/webostv/quality_scale.yaml +++ b/homeassistant/components/webostv/quality_scale.yaml @@ -72,7 +72,5 @@ rules: # Platinum async-dependency: done - inject-websession: - status: todo - comment: migrate to aiohttp + inject-websession: done strict-typing: done diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2ee871964c9..9a7167f5367 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3340,7 +3340,7 @@ "integration_type": "hub", "config_flow": true, "iot_class": "local_push", - "name": "LG webOS Smart TV" + "name": "LG webOS TV" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index 96f276097b0..ac0486c5324 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -416,7 +416,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.5.0 +aiowebostv==0.6.0 # homeassistant.components.withings aiowithings==3.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b1053331cd..262015682af 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -398,7 +398,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.5.0 +aiowebostv==0.6.0 # homeassistant.components.withings aiowithings==3.1.4 diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index 38c78bd087a..a52acae4b03 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -107,7 +107,7 @@ async def test_options_flow_cannot_retrieve(hass: HomeAssistant, client) -> None """Test options config flow cannot retrieve sources.""" entry = await setup_webostv(hass) - client.connect.side_effect = ConnectionRefusedError + client.connect.side_effect = ConnectionResetError result = await hass.config_entries.options.async_init(entry.entry_id) await hass.async_block_till_done() @@ -141,7 +141,7 @@ async def test_form_cannot_connect(hass: HomeAssistant, client) -> None: data=MOCK_USER_CONFIG, ) - client.connect.side_effect = ConnectionRefusedError + client.connect.side_effect = ConnectionResetError result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) @@ -305,7 +305,7 @@ async def test_reauth_successful(hass: HomeAssistant, client) -> None: ("side_effect", "error"), [ (WebOsTvPairError, "error_pairing"), - (ConnectionRefusedError, "cannot_connect"), + (ConnectionResetError, "cannot_connect"), ], ) async def test_reauth_errors(hass: HomeAssistant, client, side_effect, error) -> None: @@ -360,7 +360,7 @@ async def test_reconfigure_successful(hass: HomeAssistant, client) -> None: ("side_effect", "error"), [ (WebOsTvPairError, "error_pairing"), - (ConnectionRefusedError, "cannot_connect"), + (ConnectionResetError, "cannot_connect"), ], ) async def test_reconfigure_errors( diff --git a/tests/components/webostv/test_notify.py b/tests/components/webostv/test_notify.py index b12cd0c7c6c..61c73d1b151 100644 --- a/tests/components/webostv/test_notify.py +++ b/tests/components/webostv/test_notify.py @@ -125,7 +125,7 @@ async def test_icon_not_found( ("side_effect", "error"), [ (WebOsTvPairError, "Pairing with TV failed"), - (ConnectionRefusedError, "TV unreachable"), + (ConnectionResetError, "TV unreachable"), ], ) async def test_connection_errors( From f6a040d5981a81ffd2e23317674381c78241ef48 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 23 Jan 2025 14:02:30 +0100 Subject: [PATCH 0865/2987] Update peblar to v0.4.0 (#136329) * Update peblar to v0.4.0 * Update snapshots --- homeassistant/components/peblar/const.py | 2 +- homeassistant/components/peblar/manifest.json | 2 +- homeassistant/components/peblar/strings.json | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/peblar/snapshots/test_diagnostics.ambr | 5 ----- tests/components/peblar/snapshots/test_sensor.ambr | 4 ++-- 7 files changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/peblar/const.py b/homeassistant/components/peblar/const.py index d7d7c2fa5b5..58fcc9b85da 100644 --- a/homeassistant/components/peblar/const.py +++ b/homeassistant/components/peblar/const.py @@ -23,7 +23,7 @@ PEBLAR_CHARGE_LIMITER_TO_HOME_ASSISTANT = { ChargeLimiter.INSTALLATION_LIMIT: "installation_limit", ChargeLimiter.LOCAL_MODBUS_API: "local_modbus_api", ChargeLimiter.LOCAL_REST_API: "local_rest_api", - ChargeLimiter.LOCAL_SCHEDULED: "local_scheduled", + ChargeLimiter.LOCAL_SCHEDULED_CHARGING: "local_scheduled_charging", ChargeLimiter.OCPP_SMART_CHARGING: "ocpp_smart_charging", ChargeLimiter.OVERCURRENT_PROTECTION: "overcurrent_protection", ChargeLimiter.PHASE_IMBALANCE: "phase_imbalance", diff --git a/homeassistant/components/peblar/manifest.json b/homeassistant/components/peblar/manifest.json index 859682d3f1d..e2ae96de988 100644 --- a/homeassistant/components/peblar/manifest.json +++ b/homeassistant/components/peblar/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["peblar==0.3.3"], + "requirements": ["peblar==0.4.0"], "zeroconf": [{ "type": "_http._tcp.local.", "name": "pblr-*" }] } diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index fffa2b08d85..a33667fa533 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -96,6 +96,7 @@ "installation_limit": "Installation limit", "local_modbus_api": "Modbus API", "local_rest_api": "REST API", + "local_scheduled_charging": "Scheduled charging", "ocpp_smart_charging": "OCPP smart charging", "overcurrent_protection": "Overcurrent protection", "phase_imbalance": "Phase imbalance", diff --git a/requirements_all.txt b/requirements_all.txt index ac0486c5324..68426fb6b70 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1618,7 +1618,7 @@ panasonic-viera==0.4.2 pdunehd==1.3.2 # homeassistant.components.peblar -peblar==0.3.3 +peblar==0.4.0 # homeassistant.components.peco peco==0.0.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 262015682af..b92918bf0b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1345,7 +1345,7 @@ panasonic-viera==0.4.2 pdunehd==1.3.2 # homeassistant.components.peblar -peblar==0.3.3 +peblar==0.4.0 # homeassistant.components.peco peco==0.0.30 diff --git a/tests/components/peblar/snapshots/test_diagnostics.ambr b/tests/components/peblar/snapshots/test_diagnostics.ambr index e33a2f557de..fbcdcfbaff5 100644 --- a/tests/components/peblar/snapshots/test_diagnostics.ambr +++ b/tests/components/peblar/snapshots/test_diagnostics.ambr @@ -51,10 +51,8 @@ 'Hostname': 'PBLR-0000645', 'HwFixedCableRating': 20, 'HwFwCompat': 'wlac-2', - 'HwHas4pRelay': False, 'HwHasBop': True, 'HwHasBuzzer': True, - 'HwHasDualSocket': False, 'HwHasEichrechtLaserMarking': False, 'HwHasEthernet': True, 'HwHasLed': True, @@ -64,13 +62,11 @@ 'HwHasPlc': False, 'HwHasRfid': True, 'HwHasRs485': True, - 'HwHasShutter': False, 'HwHasSocket': False, 'HwHasTpm': False, 'HwHasWlan': True, 'HwMaxCurrent': 16, 'HwOneOrThreePhase': 3, - 'HwUKCompliant': False, 'MainboardPn': '6004-2300-7600', 'MainboardSn': '23-38-A4E-2MC', 'MeterCalIGainA': 267369, @@ -86,7 +82,6 @@ 'MeterCalVGainB': 246074, 'MeterCalVGainC': 230191, 'MeterFwIdent': 'b9cbcd', - 'NorFlash': 'True', 'ProductModelName': 'WLAC1-H11R0WE0ICR00', 'ProductPn': '6004-2300-8002', 'ProductSn': '23-45-A4O-MOF', diff --git a/tests/components/peblar/snapshots/test_sensor.ambr b/tests/components/peblar/snapshots/test_sensor.ambr index da17a4661ee..bb1a3eb34d6 100644 --- a/tests/components/peblar/snapshots/test_sensor.ambr +++ b/tests/components/peblar/snapshots/test_sensor.ambr @@ -302,7 +302,7 @@ 'installation_limit', 'local_modbus_api', 'local_rest_api', - 'local_scheduled', + 'local_scheduled_charging', 'ocpp_smart_charging', 'overcurrent_protection', 'phase_imbalance', @@ -354,7 +354,7 @@ 'installation_limit', 'local_modbus_api', 'local_rest_api', - 'local_scheduled', + 'local_scheduled_charging', 'ocpp_smart_charging', 'overcurrent_protection', 'phase_imbalance', From 5dfafd9f2e1d1114afc01e12a2b20a337823662b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 23 Jan 2025 15:15:08 +0100 Subject: [PATCH 0866/2987] Replace key names with translatable friendly names in zwave_js (#136318) Co-authored-by: Franck Nijhof --- .../components/zwave_js/strings.json | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index fc63b7e9119..e2d7720189d 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -1,28 +1,28 @@ { "config": { "abort": { - "addon_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.", - "addon_info_failed": "Failed to get Z-Wave JS add-on info.", - "addon_install_failed": "Failed to install the Z-Wave JS add-on.", - "addon_set_config_failed": "Failed to set Z-Wave JS configuration.", - "addon_start_failed": "Failed to start the Z-Wave JS add-on.", + "addon_get_discovery_info_failed": "Failed to get Z-Wave add-on discovery info.", + "addon_info_failed": "Failed to get Z-Wave add-on info.", + "addon_install_failed": "Failed to install the Z-Wave add-on.", + "addon_set_config_failed": "Failed to set Z-Wave configuration.", + "addon_start_failed": "Failed to start the Z-Wave add-on.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "discovery_requires_supervisor": "Discovery requires the supervisor.", "not_zwave_device": "Discovered device is not a Z-Wave device.", - "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave JS add-on." + "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on." }, "error": { - "addon_start_failed": "Failed to start the Z-Wave JS add-on. Check the configuration.", + "addon_start_failed": "Failed to start the Z-Wave add-on. Check the configuration.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_ws_url": "Invalid websocket URL", "unknown": "[%key:common::config_flow::error::unknown%]" }, "flow_title": "{name}", "progress": { - "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", - "start_addon": "Please wait while the Z-Wave JS add-on start completes. This may take some seconds." + "install_addon": "Please wait while the Z-Wave add-on installation finishes. This can take several minutes.", + "start_addon": "Please wait while the Z-Wave add-on start completes. This may take some seconds." }, "step": { "configure_addon": { @@ -34,13 +34,13 @@ "usb_path": "[%key:common::config_flow::data::usb_path%]" }, "description": "The add-on will generate security keys if those fields are left empty.", - "title": "Enter the Z-Wave JS add-on configuration" + "title": "Enter the Z-Wave add-on configuration" }, "hassio_confirm": { - "title": "Set up Z-Wave JS integration with the Z-Wave JS add-on" + "title": "Set up Z-Wave integration with the Z-Wave add-on" }, "install_addon": { - "title": "The Z-Wave JS add-on installation has started" + "title": "The Z-Wave add-on installation has started" }, "manual": { "data": { @@ -49,20 +49,20 @@ }, "on_supervisor": { "data": { - "use_addon": "Use the Z-Wave JS Supervisor add-on" + "use_addon": "Use the Z-Wave Supervisor add-on" }, - "description": "Do you want to use the Z-Wave JS Supervisor add-on?", + "description": "Do you want to use the Z-Wave Supervisor add-on?", "title": "Select connection method" }, "start_addon": { - "title": "The Z-Wave JS add-on is starting." + "title": "The Z-Wave add-on is starting." }, "usb_confirm": { - "description": "Do you want to set up {name} with the Z-Wave JS add-on?" + "description": "Do you want to set up {name} with the Z-Wave add-on?" }, "zeroconf_confirm": { - "description": "Do you want to add the Z-Wave JS Server with home ID {home_id} found at {url} to Home Assistant?", - "title": "Discovered Z-Wave JS Server" + "description": "Do you want to add the Z-Wave Server with home ID {home_id} found at {url} to Home Assistant?", + "title": "Discovered Z-Wave Server" } } }, @@ -89,7 +89,7 @@ "event.value_notification.scene_activation": "Scene Activation on {subtype}", "state.node_status": "Node status changed", "zwave_js.value_updated.config_parameter": "Value change on config parameter {subtype}", - "zwave_js.value_updated.value": "Value change on a Z-Wave JS Value" + "zwave_js.value_updated.value": "Value change on a Z-Wave Value" }, "extra_fields": { "code_slot": "Code slot", @@ -191,7 +191,7 @@ }, "step": { "init": { - "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave JS discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.", + "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.", "menu_options": { "confirm": "Re-interview device", "ignore": "Ignore device config update" @@ -203,8 +203,8 @@ "title": "Device configuration file changed: {device_name}" }, "invalid_server_version": { - "description": "The version of Z-Wave JS Server you are currently running is too old for this version of Home Assistant. Please update the Z-Wave JS Server to the latest version to fix this issue.", - "title": "Newer version of Z-Wave JS Server needed" + "description": "The version of Z-Wave Server you are currently running is too old for this version of Home Assistant. Please update the Z-Wave Server to the latest version to fix this issue.", + "title": "Newer version of Z-Wave Server needed" } }, "options": { @@ -306,7 +306,7 @@ "description": "Calls a Command Class API on a node. Some Command Classes can't be fully controlled via the `set_value` action and require direct calls to the Command Class API.", "fields": { "area_id": { - "description": "The area(s) to target for this action. If an area is specified, all zwave_js devices and entities in that area will be targeted for this action.", + "description": "The area(s) to target for this action. If an area is specified, all Z-Wave devices and entities in that area will be targeted for this action.", "name": "Area ID(s)" }, "command_class": { @@ -326,18 +326,18 @@ "name": "Entity ID(s)" }, "method_name": { - "description": "The name of the API method to call. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for available methods.", + "description": "The name of the API method to call. Refer to the Z-Wave Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for available methods.", "name": "Method name" }, "parameters": { - "description": "A list of parameters to pass to the API method. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters.", + "description": "A list of parameters to pass to the API method. Refer to the Z-Wave Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters.", "name": "Parameters" } }, "name": "Invoke a Command Class API on a node (advanced)" }, "multicast_set_value": { - "description": "Changes any value that Z-Wave JS recognizes on multiple Z-Wave devices using multicast, so all devices receive the message simultaneously. This action has minimal validation so only use this action if you know what you are doing.", + "description": "Changes any value that Z-Wave recognizes on multiple Z-Wave devices using multicast, so all devices receive the message simultaneously. This action has minimal validation so only use this action if you know what you are doing.", "fields": { "area_id": { "description": "[%key:component::zwave_js::services::set_value::fields::area_id::description%]", @@ -383,7 +383,7 @@ "name": "Set a value on multiple devices via multicast (advanced)" }, "ping": { - "description": "Forces Z-Wave JS to try to reach a node. This can be used to update the status of the node in Z-Wave JS when you think it doesn't accurately reflect reality, e.g. reviving a failed/dead node or marking the node as asleep.", + "description": "Forces Z-Wave to try to reach a node. This can be used to update the status of the node in Z-Wave when you think it doesn't accurately reflect reality, e.g. reviving a failed/dead node or marking the node as asleep.", "fields": { "area_id": { "description": "[%key:component::zwave_js::services::set_value::fields::area_id::description%]", @@ -474,7 +474,7 @@ "name": "[%key:component::zwave_js::services::set_value::fields::area_id::name%]" }, "bitmask": { - "description": "Target a specific bitmask (see the documentation for more information). Cannot be combined with value_size or value_format.", + "description": "Target a specific bitmask (see the documentation for more information). Cannot be combined with 'Value size' or 'Value format'.", "name": "Bitmask" }, "device_id": { @@ -498,11 +498,11 @@ "name": "Value" }, "value_format": { - "description": "Format of the value, 0 for signed integer, 1 for unsigned integer, 2 for enumerated, 3 for bitfield. Used in combination with value_size when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask.", + "description": "Format of the value, 0 for signed integer, 1 for unsigned integer, 2 for enumerated, 3 for bitfield. Used in combination with 'Value size' when a config parameter is not defined in your device's configuration file. Cannot be combined with 'Bitmask'.", "name": "Value format" }, "value_size": { - "description": "Size of the value, either 1, 2, or 4. Used in combination with value_format when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask.", + "description": "Size of the value, either 1, 2, or 4. Used in combination with 'Value format' when a config parameter is not defined in your device's configuration file. Cannot be combined with 'Bitmask'.", "name": "Value size" } }, @@ -553,10 +553,10 @@ "name": "Set lock user code" }, "set_value": { - "description": "Changes any value that Z-Wave JS recognizes on a Z-Wave device. This action has minimal validation so only use this action if you know what you are doing.", + "description": "Changes any value that Z-Wave recognizes on a Z-Wave device. This action has minimal validation so only use this action if you know what you are doing.", "fields": { "area_id": { - "description": "The area(s) to target for this action. If an area is specified, all zwave_js devices and entities in that area will be targeted for this action.", + "description": "The area(s) to target for this action. If an area is specified, all Z-Wave devices and entities in that area will be targeted for this action.", "name": "Area ID(s)" }, "command_class": { @@ -576,7 +576,7 @@ "name": "Entity ID(s)" }, "options": { - "description": "Set value options map. Refer to the Z-Wave JS documentation for more information on what options can be set.", + "description": "Set value options map. Refer to the Z-Wave documentation for more information on what options can be set.", "name": "Options" }, "property": { From dabcc6d55a6d61d8cf6f4b3e6dec78cd1aba656a Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 23 Jan 2025 15:23:44 +0100 Subject: [PATCH 0867/2987] Clean up remaining backup manager tests (#136335) --- tests/components/backup/test_manager.py | 102 ++++++++---------------- 1 file changed, 32 insertions(+), 70 deletions(-) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index b7a4291fb60..c961230e9e6 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -27,7 +27,6 @@ from homeassistant.components.backup import ( DOMAIN, AgentBackup, BackupAgentPlatformProtocol, - BackupManager, BackupReaderWriterError, Folder, LocalBackupAgent, @@ -38,8 +37,6 @@ from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.components.backup.manager import ( BackupManagerError, BackupManagerState, - CoreBackupReaderWriter, - CreateBackupEvent, CreateBackupStage, CreateBackupState, NewBackup, @@ -140,23 +137,31 @@ async def test_async_create_backup( ) -async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None: - """Test generate backup.""" - manager = BackupManager(hass, CoreBackupReaderWriter(hass)) - manager.last_event = CreateBackupEvent( - stage=None, state=CreateBackupState.IN_PROGRESS +@pytest.mark.usefixtures("mock_backup_generation") +async def test_create_backup_when_busy( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test generate backup with busy manager.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": [LOCAL_AGENT_ID]} ) - with pytest.raises(HomeAssistantError, match="Backup manager busy"): - await manager.async_create_backup( - agent_ids=[LOCAL_AGENT_ID], - include_addons=[], - include_all_addons=False, - include_database=True, - include_folders=[], - include_homeassistant=True, - name=None, - password=None, - ) + result = await ws_client.receive_json() + + assert result["success"] is True + + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": [LOCAL_AGENT_ID]} + ) + result = await ws_client.receive_json() + + assert result["success"] is False + assert result["error"]["code"] == "home_assistant_error" + assert result["error"]["message"] == "Backup manager busy: create_backup" @pytest.mark.parametrize( @@ -223,10 +228,9 @@ async def test_create_backup_wrong_parameters( {"password": "pass123"}, ], ) -async def test_async_initiate_backup( +async def test_initiate_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - caplog: pytest.LogCaptureFixture, mocked_json_bytes: Mock, mocked_tarfile: Mock, generate_backup_id: MagicMock, @@ -239,10 +243,7 @@ async def test_async_initiate_backup( """Test generate backup.""" local_agent = local_backup_platform.CoreLocalBackupAgent(hass) remote_agent = BackupAgentTest("remote", backups=[]) - agents = { - f"backup.{local_agent.name}": local_agent, - f"test.{remote_agent.name}": remote_agent, - } + with patch( "homeassistant.components.backup.backup.async_get_backup_agents" ) as core_get_backup_agents: @@ -349,7 +350,7 @@ async def test_async_initiate_backup( }, "name": name, "protected": bool(password), - "slug": ANY, + "slug": backup_id, "type": "partial", "version": 2, } @@ -365,7 +366,7 @@ async def test_async_initiate_backup( assert backup_agent_ids == agent_ids assert backup_data == { "addons": [], - "backup_id": ANY, + "backup_id": backup_id, "database_included": include_database, "date": ANY, "failed_agent_ids": [], @@ -378,16 +379,6 @@ async def test_async_initiate_backup( "with_automatic_settings": False, } - for agent_id in agent_ids: - agent = agents[agent_id] - assert len(agent._backups) == 1 - agent_backup = agent._backups[backup_data["backup_id"]] - assert agent_backup.backup_id == backup_data["backup_id"] - assert agent_backup.date == backup_data["date"] - assert agent_backup.name == backup_data["name"] - assert agent_backup.protected == backup_data["protected"] - assert agent_backup.size == backup_data["size"] - outer_tar = mocked_tarfile.return_value core_tar = outer_tar.create_inner_tar.return_value.__enter__.return_value expected_files = [call(hass.config.path(), arcname="data", recursive=False)] + [ @@ -398,12 +389,12 @@ async def test_async_initiate_backup( tar_file_path = str(mocked_tarfile.call_args_list[0][0][0]) backup_directory = hass.config.path(backup_directory) - assert tar_file_path == f"{backup_directory}/{backup_data['backup_id']}.tar" + assert tar_file_path == f"{backup_directory}/{backup_id}.tar" @pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.parametrize("exception", [BackupAgentError("Boom!"), Exception("Boom!")]) -async def test_async_initiate_backup_with_agent_error( +async def test_initiate_backup_with_agent_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, generate_backup_id: MagicMock, @@ -845,7 +836,7 @@ async def test_create_backup_failure_raises_issue( @pytest.mark.parametrize( "exception", [BackupReaderWriterError("Boom!"), BaseException("Boom!")] ) -async def test_async_initiate_backup_non_agent_upload_error( +async def test_initiate_backup_non_agent_upload_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, generate_backup_id: MagicMock, @@ -954,7 +945,7 @@ async def test_async_initiate_backup_non_agent_upload_error( @pytest.mark.parametrize( "exception", [BackupReaderWriterError("Boom!"), Exception("Boom!")] ) -async def test_async_initiate_backup_with_task_error( +async def test_initiate_backup_with_task_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, generate_backup_id: MagicMock, @@ -1173,35 +1164,6 @@ async def test_initiate_backup_file_error( assert unlink_mock.call_count == unlink_call_count -async def test_loading_platforms( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test loading backup platforms.""" - manager = BackupManager(hass, CoreBackupReaderWriter(hass)) - - assert not manager.platforms - - get_agents_mock = AsyncMock(return_value=[]) - - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_pre_backup=AsyncMock(), - async_post_backup=AsyncMock(), - async_get_backup_agents=get_agents_mock, - ), - ) - await manager.load_platforms() - await hass.async_block_till_done() - - assert len(manager.platforms) == 1 - assert "Loaded 1 platforms" in caplog.text - - get_agents_mock.assert_called_once_with(hass) - - class LocalBackupAgentTest(BackupAgentTest, LocalBackupAgent): """Local backup agent.""" From 093c41cd8311268c9c75f40fb4aaeac2fe176027 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 23 Jan 2025 15:49:18 +0100 Subject: [PATCH 0868/2987] Update frontend to 20250109.1 (#136339) --- 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 3d9f12bd3d3..3a736429516 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==20250109.0"] + "requirements": ["home-assistant-frontend==20250109.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index aa5fa65f7b9..6a2be4022fd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,7 +36,7 @@ habluetooth==3.12.0 hass-nabucasa==0.88.1 hassil==2.1.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250109.0 +home-assistant-frontend==20250109.1 home-assistant-intents==2025.1.1 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 68426fb6b70..4c12c001969 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1140,7 +1140,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250109.0 +home-assistant-frontend==20250109.1 # homeassistant.components.conversation home-assistant-intents==2025.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b92918bf0b6..82fa4fe6e7a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -969,7 +969,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250109.0 +home-assistant-frontend==20250109.1 # homeassistant.components.conversation home-assistant-intents==2025.1.1 From 132f418f92dc3b7c35b5211acda769a312b60e15 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 23 Jan 2025 15:53:31 +0100 Subject: [PATCH 0869/2987] Add reconfigure flow to Airgradient (#136324) * Add reconfigure flow to Airgradient * Update homeassistant/components/airgradient/strings.json --------- Co-authored-by: Robert Resch --- .../components/airgradient/config_flow.py | 28 +++++- .../components/airgradient/quality_scale.yaml | 2 +- .../components/airgradient/strings.json | 4 +- .../airgradient/test_config_flow.py | 96 +++++++++++++++++++ 4 files changed, 124 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/airgradient/config_flow.py b/homeassistant/components/airgradient/config_flow.py index a2f9440d376..fa3e77beeca 100644 --- a/homeassistant/components/airgradient/config_flow.py +++ b/homeassistant/components/airgradient/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Airgradient.""" +from collections.abc import Mapping from typing import Any from airgradient import ( @@ -11,7 +12,12 @@ from airgradient import ( from awesomeversion import AwesomeVersion import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -95,10 +101,18 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id( current_measures.serial_number, raise_on_progress=False ) - self._abort_if_unique_id_configured() + if self.source == SOURCE_USER: + self._abort_if_unique_id_configured() + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() await self.set_configuration_source() - return self.async_create_entry( - title=current_measures.model, + if self.source == SOURCE_USER: + return self.async_create_entry( + title=current_measures.model, + data={CONF_HOST: user_input[CONF_HOST]}, + ) + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), data={CONF_HOST: user_input[CONF_HOST]}, ) return self.async_show_form( @@ -106,3 +120,9 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({vol.Required(CONF_HOST): str}), errors=errors, ) + + async def async_step_reconfigure( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + return await self.async_step_user() diff --git a/homeassistant/components/airgradient/quality_scale.yaml b/homeassistant/components/airgradient/quality_scale.yaml index a8904e71af5..7a7f8d5ee1d 100644 --- a/homeassistant/components/airgradient/quality_scale.yaml +++ b/homeassistant/components/airgradient/quality_scale.yaml @@ -70,7 +70,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json index f3b0bbdd60c..4cf3a6a34ea 100644 --- a/homeassistant/components/airgradient/strings.json +++ b/homeassistant/components/airgradient/strings.json @@ -17,7 +17,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "invalid_version": "This firmware version is unsupported. Please upgrade the firmware of the device to at least version 3.1.1." + "invalid_version": "This firmware version is unsupported. Please upgrade the firmware of the device to at least version 3.1.1.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "Please ensure you reconfigure against the same device." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/tests/components/airgradient/test_config_flow.py b/tests/components/airgradient/test_config_flow.py index 01d48e852ca..4c035b09aa7 100644 --- a/tests/components/airgradient/test_config_flow.py +++ b/tests/components/airgradient/test_config_flow.py @@ -296,3 +296,99 @@ async def test_user_flow_works_discovery( # Verify the discovery flow was aborted assert not hass.config_entries.flow.async_progress(DOMAIN) + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_new_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.131"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_HOST: "10.0.0.131", + } + + +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_new_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + mock_new_airgradient_client.get_current_measures.side_effect = ( + AirGradientConnectionError() + ) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.132"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + mock_new_airgradient_client.get_current_measures.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.132"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_HOST: "10.0.0.132", + } + + +async def test_reconfigure_flow_unique_id_mismatch( + hass: HomeAssistant, + mock_new_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow aborts with unique id mismatch.""" + mock_config_entry.add_to_hass(hass) + + mock_new_airgradient_client.get_current_measures.return_value.serial_number = ( + "84fce612f5b9" + ) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.132"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert mock_config_entry.data == { + CONF_HOST: "10.0.0.131", + } From d8223a17710c85b42b149fd096b68abf351c2209 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 23 Jan 2025 17:17:07 +0100 Subject: [PATCH 0870/2987] Bump aiowithings to 3.1.5 (#136350) --- homeassistant/components/withings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index ad9b9a6fe71..4c78e077d21 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/withings", "iot_class": "cloud_push", "loggers": ["aiowithings"], - "requirements": ["aiowithings==3.1.4"] + "requirements": ["aiowithings==3.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4c12c001969..968aeb60c88 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -419,7 +419,7 @@ aiowatttime==0.1.1 aiowebostv==0.6.0 # homeassistant.components.withings -aiowithings==3.1.4 +aiowithings==3.1.5 # homeassistant.components.yandex_transport aioymaps==1.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 82fa4fe6e7a..6e94f4bb6b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -401,7 +401,7 @@ aiowatttime==0.1.1 aiowebostv==0.6.0 # homeassistant.components.withings -aiowithings==3.1.4 +aiowithings==3.1.5 # homeassistant.components.yandex_transport aioymaps==1.2.5 From d29572f3d03842b4b970110fff479be37df723a0 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 23 Jan 2025 17:18:00 +0100 Subject: [PATCH 0871/2987] Update frontend to 20250109.2 (#136348) --- 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 3a736429516..2724569d1ed 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==20250109.1"] + "requirements": ["home-assistant-frontend==20250109.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6a2be4022fd..9bd591df2e5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,7 +36,7 @@ habluetooth==3.12.0 hass-nabucasa==0.88.1 hassil==2.1.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250109.1 +home-assistant-frontend==20250109.2 home-assistant-intents==2025.1.1 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 968aeb60c88..6e79f0efefa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1140,7 +1140,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250109.1 +home-assistant-frontend==20250109.2 # homeassistant.components.conversation home-assistant-intents==2025.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e94f4bb6b1..d4de9795aea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -969,7 +969,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250109.1 +home-assistant-frontend==20250109.2 # homeassistant.components.conversation home-assistant-intents==2025.1.1 From 025f70445b1bccc24b9e49bebd7fa3662883aba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Thu, 23 Jan 2025 18:01:50 +0100 Subject: [PATCH 0872/2987] Bump myuplink lib to 0.7.0 (#136343) --- homeassistant/components/myuplink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/myuplink/manifest.json b/homeassistant/components/myuplink/manifest.json index 8438d24194c..d3242115acb 100644 --- a/homeassistant/components/myuplink/manifest.json +++ b/homeassistant/components/myuplink/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/myuplink", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["myuplink==0.6.0"] + "requirements": ["myuplink==0.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6e79f0efefa..ce52715cb36 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1446,7 +1446,7 @@ mutesync==0.0.1 mypermobil==0.1.8 # homeassistant.components.myuplink -myuplink==0.6.0 +myuplink==0.7.0 # homeassistant.components.nad nad-receiver==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d4de9795aea..1d892ff34e0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1218,7 +1218,7 @@ mutesync==0.0.1 mypermobil==0.1.8 # homeassistant.components.myuplink -myuplink==0.6.0 +myuplink==0.7.0 # homeassistant.components.keenetic_ndms2 ndms2-client==0.1.2 From 3da9c599dc3955ad1802754a1915dfb08331c9a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Thu, 23 Jan 2025 18:04:00 +0100 Subject: [PATCH 0873/2987] Avoid keyerror on incomplete api data in myuplink (#136333) * Avoid keyerror * Inject erroneous value in device point fixture * Update diagnostics snapshot --- homeassistant/components/myuplink/sensor.py | 4 ++-- .../myuplink/fixtures/device_points_nibe_f730.json | 2 +- tests/components/myuplink/snapshots/test_diagnostics.ambr | 4 ++-- tests/components/myuplink/snapshots/test_sensor.ambr | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py index ef827fc1fb1..fa50e8a7001 100644 --- a/homeassistant/components/myuplink/sensor.py +++ b/homeassistant/components/myuplink/sensor.py @@ -325,10 +325,10 @@ class MyUplinkEnumSensor(MyUplinkDevicePointSensor): } @property - def native_value(self) -> str: + def native_value(self) -> str | None: """Sensor state value for enum sensor.""" device_point = self.coordinator.data.points[self.device_id][self.point_id] - return self.options_map[str(int(device_point.value))] # type: ignore[no-any-return] + return self.options_map.get(str(int(device_point.value))) class MyUplinkEnumRawSensor(MyUplinkDevicePointSensor): diff --git a/tests/components/myuplink/fixtures/device_points_nibe_f730.json b/tests/components/myuplink/fixtures/device_points_nibe_f730.json index 0a61ab05f21..795a89e7e13 100644 --- a/tests/components/myuplink/fixtures/device_points_nibe_f730.json +++ b/tests/components/myuplink/fixtures/device_points_nibe_f730.json @@ -822,7 +822,7 @@ "parameterUnit": "", "writable": false, "timestamp": "2024-02-08T19:13:05+00:00", - "value": 30, + "value": 31, "strVal": "Heating", "smartHomeCategories": [], "minValue": null, diff --git a/tests/components/myuplink/snapshots/test_diagnostics.ambr b/tests/components/myuplink/snapshots/test_diagnostics.ambr index 6fe6becff11..521823e282d 100644 --- a/tests/components/myuplink/snapshots/test_diagnostics.ambr +++ b/tests/components/myuplink/snapshots/test_diagnostics.ambr @@ -883,7 +883,7 @@ "parameterUnit": "", "writable": false, "timestamp": "2024-02-08T19:13:05+00:00", - "value": 30, + "value": 31, "strVal": "Heating", "smartHomeCategories": [], "minValue": null, @@ -2045,7 +2045,7 @@ "parameterUnit": "", "writable": false, "timestamp": "2024-02-08T19:13:05+00:00", - "value": 30, + "value": 31, "strVal": "Heating", "smartHomeCategories": [], "minValue": null, diff --git a/tests/components/myuplink/snapshots/test_sensor.ambr b/tests/components/myuplink/snapshots/test_sensor.ambr index a5469dc9a77..34acbbb8785 100644 --- a/tests/components/myuplink/snapshots/test_sensor.ambr +++ b/tests/components/myuplink/snapshots/test_sensor.ambr @@ -3396,7 +3396,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Heating', + 'state': 'unknown', }) # --- # name: test_sensor_states[sensor.gotham_city_priority_2-entry] @@ -3462,7 +3462,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Heating', + 'state': 'unknown', }) # --- # name: test_sensor_states[sensor.gotham_city_priority_raw-entry] @@ -3508,7 +3508,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30', + 'state': '31', }) # --- # name: test_sensor_states[sensor.gotham_city_priority_raw_2-entry] @@ -3554,7 +3554,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30', + 'state': '31', }) # --- # name: test_sensor_states[sensor.gotham_city_r_start_diff_additional_heat-entry] From 33ce795695ebf6875a6ad9880a5314fb795363dd Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Jan 2025 18:26:28 +0100 Subject: [PATCH 0874/2987] Improve error handling for incomfort gateway (#136317) --- .../components/incomfort/__init__.py | 13 ++-- .../components/incomfort/config_flow.py | 17 +---- .../components/incomfort/coordinator.py | 11 +-- .../components/incomfort/test_config_flow.py | 32 ++++---- tests/components/incomfort/test_init.py | 76 +++++++++++-------- 5 files changed, 74 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index 5a57f9f4198..909a4731e84 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from aiohttp import ClientResponseError -from incomfortclient import IncomfortError, InvalidHeaterList +from incomfortclient import InvalidGateway, InvalidHeaterList from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -35,12 +35,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> await heater.update() except InvalidHeaterList as exc: raise NoHeaters from exc - except IncomfortError as exc: - if isinstance(exc.message, ClientResponseError): - if exc.message.status == 401: - raise ConfigEntryAuthFailed("Incorrect credentials") from exc - if exc.message.status == 404: - raise NotFound from exc + except InvalidGateway as exc: + raise ConfigEntryAuthFailed("Incorrect credentials") from exc + except ClientResponseError as exc: + if exc.status == 404: + raise NotFound from exc raise InConfortUnknownError from exc except TimeoutError as exc: raise InConfortTimeout from exc diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py index 47db9b701bf..779b0e97777 100644 --- a/homeassistant/components/incomfort/config_flow.py +++ b/homeassistant/components/incomfort/config_flow.py @@ -5,8 +5,7 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from aiohttp import ClientResponseError -from incomfortclient import IncomfortError, InvalidHeaterList +from incomfortclient import InvalidGateway, InvalidHeaterList import voluptuous as vol from homeassistant.config_entries import ( @@ -77,11 +76,6 @@ OPTIONS_SCHEMA = vol.Schema( } ) -ERROR_STATUS_MAPPING: dict[int, tuple[str, str]] = { - 401: (CONF_PASSWORD, "auth_error"), - 404: ("base", "not_found"), -} - async def async_try_connect_gateway( hass: HomeAssistant, config: dict[str, Any] @@ -89,15 +83,10 @@ async def async_try_connect_gateway( """Try to connect to the Lan2RF gateway.""" try: await async_connect_gateway(hass, config) + except InvalidGateway: + return {"base": "auth_error"} except InvalidHeaterList: return {"base": "no_heaters"} - except IncomfortError as exc: - if isinstance(exc.message, ClientResponseError): - scope, error = ERROR_STATUS_MAPPING.get( - exc.message.status, ("base", "unknown") - ) - return {scope: error} - return {"base": "unknown"} except TimeoutError: return {"base": "timeout_error"} except Exception: # noqa: BLE001 diff --git a/homeassistant/components/incomfort/coordinator.py b/homeassistant/components/incomfort/coordinator.py index d1370f613ad..3436d40298a 100644 --- a/homeassistant/components/incomfort/coordinator.py +++ b/homeassistant/components/incomfort/coordinator.py @@ -9,7 +9,7 @@ from aiohttp import ClientResponseError from incomfortclient import ( Gateway as InComfortGateway, Heater as InComfortHeater, - IncomfortError, + InvalidHeaterList, ) from homeassistant.const import CONF_HOST @@ -70,9 +70,10 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]): await heater.update() except TimeoutError as exc: raise UpdateFailed("Timeout error") from exc - except IncomfortError as exc: - if isinstance(exc.message, ClientResponseError): - if exc.message.status == 401: - raise ConfigEntryError("Incorrect credentials") from exc + except ClientResponseError as exc: + if exc.status == 401: + raise ConfigEntryError("Incorrect credentials") from exc + raise UpdateFailed(exc.message) from exc + except InvalidHeaterList as exc: raise UpdateFailed(exc.message) from exc return self.incomfort_data diff --git a/tests/components/incomfort/test_config_flow.py b/tests/components/incomfort/test_config_flow.py index e102595657f..e3579182b3d 100644 --- a/tests/components/incomfort/test_config_flow.py +++ b/tests/components/incomfort/test_config_flow.py @@ -4,7 +4,7 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientResponseError -from incomfortclient import IncomfortError, InvalidHeaterList +from incomfortclient import InvalidGateway, InvalidHeaterList import pytest from homeassistant.components.incomfort.const import DOMAIN @@ -81,24 +81,22 @@ async def test_entry_already_configured( ("exc", "error", "base"), [ ( - IncomfortError(ClientResponseError(None, None, status=401)), + InvalidGateway, "auth_error", - CONF_PASSWORD, - ), - ( - IncomfortError(ClientResponseError(None, None, status=404)), - "not_found", "base", ), ( - IncomfortError(ClientResponseError(None, None, status=500)), + InvalidHeaterList, + "no_heaters", + "base", + ), + ( + ClientResponseError(None, None, status=500), "unknown", "base", ), - (IncomfortError, "unknown", "base"), - (ValueError, "unknown", "base"), (TimeoutError, "timeout_error", "base"), - (InvalidHeaterList, "no_heaters", "base"), + (ValueError, "unknown", "base"), ], ) async def test_form_validation( @@ -243,7 +241,7 @@ async def test_dhcp_flow_wih_auth( with patch.object( mock_incomfort(), "heaters", - side_effect=IncomfortError(ClientResponseError(None, None, status=401)), + side_effect=InvalidGateway, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "192.168.1.12"} @@ -251,7 +249,7 @@ async def test_dhcp_flow_wih_auth( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "dhcp_auth" - assert result["errors"] == {CONF_PASSWORD: "auth_error"} + assert result["errors"] == {"base": "auth_error"} # Submit the form with added credentials result = await hass.config_entries.flow.async_configure( @@ -300,14 +298,14 @@ async def test_reauth_flow_failure( with patch.object( mock_incomfort(), "heaters", - side_effect=IncomfortError(ClientResponseError(None, None, status=401)), + side_effect=InvalidGateway, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "incorrect-password"}, ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {CONF_PASSWORD: "auth_error"} + assert result["errors"] == {"base": "auth_error"} result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -352,14 +350,14 @@ async def test_reconfigure_flow_failure( with patch.object( mock_incomfort(), "heaters", - side_effect=IncomfortError(ClientResponseError(None, None, status=401)), + side_effect=InvalidGateway, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_CONFIG | {CONF_PASSWORD: "wrong-password"}, ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {CONF_PASSWORD: "auth_error"} + assert result["errors"] == {"base": "auth_error"} result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/incomfort/test_init.py b/tests/components/incomfort/test_init.py index f603c3ce27b..a9b3a8e4e3a 100644 --- a/tests/components/incomfort/test_init.py +++ b/tests/components/incomfort/test_init.py @@ -5,10 +5,9 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientResponseError, RequestInfo from freezegun.api import FrozenDateTimeFactory -from incomfortclient import IncomfortError +from incomfortclient import InvalidGateway, InvalidHeaterList import pytest -from homeassistant.components.incomfort import InvalidHeaterList from homeassistant.components.incomfort.coordinator import UPDATE_INTERVAL from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE @@ -66,20 +65,27 @@ async def test_coordinator_updates( @pytest.mark.parametrize( "exc", [ - IncomfortError(ClientResponseError(None, None, status=401)), - IncomfortError( - ClientResponseError( - RequestInfo( - url="http://example.com", - method="GET", - headers=[], - real_url="http://example.com", - ), - None, - status=500, - ) + ClientResponseError( + RequestInfo( + url="http://example.com", + method="GET", + headers=[], + real_url="http://example.com", + ), + None, + status=401, + ), + InvalidHeaterList, + ClientResponseError( + RequestInfo( + url="http://example.com", + method="GET", + headers=[], + real_url="http://example.com", + ), + None, + status=500, ), - IncomfortError(ValueError("some_error")), TimeoutError, ], ) @@ -113,30 +119,36 @@ async def test_coordinator_update_fails( ("exc", "config_entry_state"), [ ( - IncomfortError(ClientResponseError(None, None, status=401)), - ConfigEntryState.SETUP_ERROR, - ), - ( - IncomfortError(ClientResponseError(None, None, status=404)), + InvalidGateway, ConfigEntryState.SETUP_ERROR, ), (InvalidHeaterList, ConfigEntryState.SETUP_RETRY), ( - IncomfortError( - ClientResponseError( - RequestInfo( - url="http://example.com", - method="GET", - headers=[], - real_url="http://example.com", - ), - None, - status=500, - ) + ClientResponseError( + RequestInfo( + url="http://example.com", + method="GET", + headers=[], + real_url="http://example.com", + ), + None, + status=404, + ), + ConfigEntryState.SETUP_ERROR, + ), + ( + ClientResponseError( + RequestInfo( + url="http://example.com", + method="GET", + headers=[], + real_url="http://example.com", + ), + None, + status=500, ), ConfigEntryState.SETUP_RETRY, ), - (IncomfortError(ValueError("some_error")), ConfigEntryState.SETUP_RETRY), (TimeoutError, ConfigEntryState.SETUP_RETRY), ], ) From 83e826219a3e970441a95900eb437ab17672bf02 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Jan 2025 18:37:58 +0100 Subject: [PATCH 0875/2987] Enable strict-typing in lovelace (#136327) --- .strict-typing | 1 + homeassistant/components/lovelace/__init__.py | 17 +++++++--- .../components/lovelace/dashboard.py | 32 +++++++++++-------- .../components/lovelace/resources.py | 6 ++-- .../components/lovelace/websocket.py | 25 +++++++++++---- mypy.ini | 10 ++++++ 6 files changed, 65 insertions(+), 26 deletions(-) diff --git a/.strict-typing b/.strict-typing index 46b14f22660..ce1ea1a6838 100644 --- a/.strict-typing +++ b/.strict-typing @@ -307,6 +307,7 @@ homeassistant.components.logbook.* homeassistant.components.logger.* homeassistant.components.london_underground.* homeassistant.components.lookin.* +homeassistant.components.lovelace.* homeassistant.components.luftdaten.* homeassistant.components.madvr.* homeassistant.components.manual.* diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 65ef0ad3ac3..82f3987c630 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -81,7 +81,7 @@ class LovelaceData: mode: str dashboards: dict[str | None, dashboard.LovelaceConfig] - resources: resources.ResourceStorageCollection + resources: resources.ResourceYAMLCollection | resources.ResourceStorageCollection yaml_dashboards: dict[str | None, ConfigType] @@ -115,6 +115,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[LOVELACE_DATA].resources = resource_collection default_config: dashboard.LovelaceConfig + resource_collection: ( + resources.ResourceYAMLCollection | resources.ResourceStorageCollection + ) if mode == MODE_YAML: default_config = dashboard.LovelaceYAML(hass, None, None) resource_collection = await create_yaml_resource_col(hass, yaml_resources) @@ -174,7 +177,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if hass.config.recovery_mode: return True - async def storage_dashboard_changed(change_type, item_id, item): + async def storage_dashboard_changed( + change_type: str, item_id: str, item: dict + ) -> None: """Handle a storage dashboard change.""" url_path = item[CONF_URL_PATH] @@ -236,7 +241,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def create_yaml_resource_col(hass, yaml_resources): +async def create_yaml_resource_col( + hass: HomeAssistant, yaml_resources: list[ConfigType] | None +) -> resources.ResourceYAMLCollection: """Create yaml resources collection.""" if yaml_resources is None: default_config = dashboard.LovelaceYAML(hass, None, None) @@ -256,7 +263,9 @@ async def create_yaml_resource_col(hass, yaml_resources): @callback -def _register_panel(hass, url_path, mode, config, update): +def _register_panel( + hass: HomeAssistant, url_path: str | None, mode: str, config: dict, update: bool +) -> None: """Register a panel.""" kwargs = { "frontend_url_path": url_path, diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index 25e15d524c8..ddb54e7618f 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -7,7 +7,7 @@ import logging import os from pathlib import Path import time -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -67,21 +67,25 @@ class LovelaceConfig(ABC): """Return mode of the lovelace config.""" @abstractmethod - async def async_get_info(self): + async def async_get_info(self) -> dict[str, Any]: """Return the config info.""" @abstractmethod async def async_load(self, force: bool) -> dict[str, Any]: """Load config.""" - async def async_save(self, config): + async def async_save(self, config: dict[str, Any]) -> None: """Save config.""" raise HomeAssistantError("Not supported") - async def async_delete(self): + async def async_delete(self) -> None: """Delete config.""" raise HomeAssistantError("Not supported") + @abstractmethod + async def async_json(self, force: bool) -> json_fragment: + """Return JSON representation of the config.""" + @callback def _config_updated(self) -> None: """Fire config updated event.""" @@ -113,7 +117,7 @@ class LovelaceStorage(LovelaceConfig): """Return mode of the lovelace config.""" return MODE_STORAGE - async def async_get_info(self): + async def async_get_info(self) -> dict[str, Any]: """Return the Lovelace storage info.""" data = self._data or await self._load() if data["config"] is None: @@ -129,7 +133,7 @@ class LovelaceStorage(LovelaceConfig): if (config := data["config"]) is None: raise ConfigNotFound - return config + return config # type: ignore[no-any-return] async def async_json(self, force: bool) -> json_fragment: """Return JSON representation of the config.""" @@ -139,19 +143,21 @@ class LovelaceStorage(LovelaceConfig): await self._load() return self._json_config or self._async_build_json() - async def async_save(self, config): + async def async_save(self, config: dict[str, Any]) -> None: """Save config.""" if self.hass.config.recovery_mode: raise HomeAssistantError("Saving not supported in recovery mode") if self._data is None: await self._load() + if TYPE_CHECKING: + assert self._data is not None self._data["config"] = config self._json_config = None self._config_updated() await self._store.async_save(self._data) - async def async_delete(self): + async def async_delete(self) -> None: """Delete config.""" if self.hass.config.recovery_mode: raise HomeAssistantError("Deleting not supported in recovery mode") @@ -195,7 +201,7 @@ class LovelaceYAML(LovelaceConfig): """Return mode of the lovelace config.""" return MODE_YAML - async def async_get_info(self): + async def async_get_info(self) -> dict[str, Any]: """Return the YAML storage mode.""" try: config = await self.async_load(False) @@ -251,7 +257,7 @@ class LovelaceYAML(LovelaceConfig): return is_updated, config, json -def _config_info(mode, config): +def _config_info(mode: str, config: dict[str, Any]) -> dict[str, Any]: """Generate info about the config.""" return { "mode": mode, @@ -265,7 +271,7 @@ class DashboardsCollection(collection.DictStorageCollection): CREATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_CREATE_FIELDS) UPDATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_UPDATE_FIELDS) - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialize the dashboards collection.""" super().__init__( storage.Store(hass, DASHBOARDS_STORAGE_VERSION, DASHBOARDS_STORAGE_KEY), @@ -283,12 +289,12 @@ class DashboardsCollection(collection.DictStorageCollection): if url_path in self.hass.data[DATA_PANELS]: raise vol.Invalid("Panel url path needs to be unique") - return self.CREATE_SCHEMA(data) + return self.CREATE_SCHEMA(data) # type: ignore[no-any-return] @callback def _get_suggested_id(self, info: dict) -> str: """Suggest an ID based on the config.""" - return info[CONF_URL_PATH] + return info[CONF_URL_PATH] # type: ignore[no-any-return] async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" diff --git a/homeassistant/components/lovelace/resources.py b/homeassistant/components/lovelace/resources.py index 316a31e8e9d..96f84ccbc60 100644 --- a/homeassistant/components/lovelace/resources.py +++ b/homeassistant/components/lovelace/resources.py @@ -34,11 +34,11 @@ class ResourceYAMLCollection: loaded = True - def __init__(self, data): + def __init__(self, data: list[dict[str, Any]]) -> None: """Initialize a resource YAML collection.""" self.data = data - async def async_get_info(self): + async def async_get_info(self) -> dict[str, int]: """Return the resources info for YAML mode.""" return {"resources": len(self.async_items() or [])} @@ -62,7 +62,7 @@ class ResourceStorageCollection(collection.DictStorageCollection): ) self.ll_config = ll_config - async def async_get_info(self): + async def async_get_info(self) -> dict[str, int]: """Return the resources info for YAML mode.""" if not self.loaded: await self.async_load() diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index 7424f551e7a..5feb7deb449 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from functools import wraps -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -14,10 +15,20 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import json_fragment from .const import CONF_URL_PATH, LOVELACE_DATA, ConfigNotFound -from .dashboard import LovelaceStorage +from .dashboard import LovelaceConfig + +if TYPE_CHECKING: + from .resources import ResourceStorageCollection + +type AsyncLovelaceWebSocketCommandHandler[_R] = Callable[ + [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any], LovelaceConfig], + Awaitable[_R], +] -def _handle_errors(func): +def _handle_errors[_R]( + func: AsyncLovelaceWebSocketCommandHandler[_R], +) -> websocket_api.AsyncWebSocketCommandHandler: """Handle error with WebSocket calls.""" @wraps(func) @@ -75,6 +86,8 @@ async def websocket_lovelace_resources_impl( This function is called by both Storage and YAML mode WS handlers. """ resources = hass.data[LOVELACE_DATA].resources + if TYPE_CHECKING: + assert isinstance(resources, ResourceStorageCollection) if hass.config.safe_mode: connection.send_result(msg["id"], []) @@ -100,7 +113,7 @@ async def websocket_lovelace_config( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], - config: LovelaceStorage, + config: LovelaceConfig, ) -> json_fragment: """Send Lovelace UI config over WebSocket connection.""" return await config.async_json(msg["force"]) @@ -120,7 +133,7 @@ async def websocket_lovelace_save_config( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], - config: LovelaceStorage, + config: LovelaceConfig, ) -> None: """Save Lovelace UI configuration.""" await config.async_save(msg["config"]) @@ -139,7 +152,7 @@ async def websocket_lovelace_delete_config( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], - config: LovelaceStorage, + config: LovelaceConfig, ) -> None: """Delete Lovelace UI configuration.""" await config.async_delete() diff --git a/mypy.ini b/mypy.ini index e4056203875..ccdc7c669d7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2826,6 +2826,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.lovelace.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.luftdaten.*] check_untyped_defs = true disallow_incomplete_defs = true From dae4b53cb7aed5c076bb6c848c6c84f4ae62049e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 23 Jan 2025 18:38:56 +0100 Subject: [PATCH 0876/2987] Fix sentence-casing in isy994 integration strings, reword "lock user code" (#136316) --- homeassistant/components/isy994/strings.json | 26 ++++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index f0e55881652..86a1f14ff91 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -37,7 +37,7 @@ "step": { "init": { "title": "ISY Options", - "description": "Set the options for the ISY Integration: \n • Node Sensor String: Any device or folder that contains 'Node Sensor String' in the name will be treated as a sensor or binary sensor. \n • Ignore String: Any device with 'Ignore String' in the name will be ignored. \n • Variable Sensor String: Any variable that contains 'Variable Sensor String' will be added as a sensor. \n • Restore Light Brightness: If enabled, the previous brightness will be restored when turning on a light instead of the device's built-in On-Level.", + "description": "Set the options for the ISY integration: \n • Node Sensor String: Any device or folder that contains 'Node Sensor String' in the name will be treated as a sensor or binary sensor. \n • Ignore String: Any device with 'Ignore String' in the name will be ignored. \n • Variable Sensor String: Any variable that contains 'Variable Sensor String' will be added as a sensor. \n • Restore Light Brightness: If enabled, the previous brightness will be restored when turning on a light instead of the device's built-in On-Level.", "data": { "sensor_string": "Node Sensor String", "ignore_string": "Ignore String", @@ -62,7 +62,7 @@ "fields": { "command": { "name": "Command", - "description": "The ISY REST Command to be sent to the device." + "description": "The ISY REST command to be sent to the device." }, "value": { "name": "Value", @@ -74,13 +74,13 @@ }, "unit_of_measurement": { "name": "Unit of measurement", - "description": "The ISY Unit of Measurement (UOM) to send with the command, if required." + "description": "The ISY unit of measurement (UOM) to send with the command, if required." } } }, "send_node_command": { "name": "Send node command", - "description": "Sends a command to an ISY Device using its Home Assistant entity ID. Valid commands are: beep, brighten, dim, disable, enable, fade_down, fade_stop, fade_up, fast_off, fast_on, and query.", + "description": "Sends a command to an ISY device using its Home Assistant entity ID. Valid commands are: beep, brighten, dim, disable, enable, fade_down, fade_stop, fade_up, fast_off, fast_on, and query.", "fields": { "command": { "name": "Command", @@ -90,7 +90,7 @@ }, "get_zwave_parameter": { "name": "Get Z-Wave Parameter", - "description": "Requests a Z-Wave Device parameter via the ISY. The parameter value will be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", + "description": "Requests a Z-Wave device parameter via the ISY. The parameter value will be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", "fields": { "parameter": { "name": "Parameter", @@ -99,8 +99,8 @@ } }, "set_zwave_parameter": { - "name": "Set Z-Wave Parameter", - "description": "Updates a Z-Wave Device parameter via the ISY. The parameter value will also be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", + "name": "Set Z-Wave parameter", + "description": "Updates a Z-Wave device parameter via the ISY. The parameter value will also be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", "fields": { "parameter": { "name": "[%key:component::isy994::services::get_zwave_parameter::fields::parameter::name%]", @@ -117,8 +117,8 @@ } }, "set_zwave_lock_user_code": { - "name": "Set Z-Wave Lock User Code", - "description": "Sets a Z-Wave Lock User Code via the ISY.", + "name": "Set Z-Wave lock user code", + "description": "Sets a user code for a Z-Wave lock via the ISY.", "fields": { "user_num": { "name": "User Number", @@ -131,8 +131,8 @@ } }, "delete_zwave_lock_user_code": { - "name": "Delete Z-Wave Lock User Code", - "description": "Delete a Z-Wave Lock User Code via the ISY.", + "name": "Delete Z-Wave lock user code", + "description": "Deletes a user code for a Z-Wave lock via the ISY.", "fields": { "user_num": { "name": "[%key:component::isy994::services::set_zwave_lock_user_code::fields::user_num::name%]", @@ -141,8 +141,8 @@ } }, "rename_node": { - "name": "Rename Node on ISY", - "description": "Renames a node or group (scene) on the ISY. Note: this will not automatically change the Home Assistant Entity Name or Entity ID to match. The entity name and ID will only be updated after reloading the integration or restarting Home Assistant, and ONLY IF you have not already customized the name within Home Assistant.", + "name": "Rename node on ISY", + "description": "Renames a node or group (scene) on the ISY. Note: this will not automatically change the Home Assistant entity name or entity ID to match. The entity name and ID will only be updated after reloading the integration or restarting Home Assistant, and ONLY IF you have not already customized the name within Home Assistant.", "fields": { "name": { "name": "New Name", From 8dba4affa91d05b5fed7379361cca4ac8864ae81 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Jan 2025 18:48:48 +0100 Subject: [PATCH 0877/2987] Move single-use lovelace function (#136336) --- homeassistant/components/lovelace/__init__.py | 19 +++++++++++++++++-- homeassistant/components/lovelace/const.py | 16 +--------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 82f3987c630..9b1c86edb36 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -2,6 +2,7 @@ from dataclasses import dataclass import logging +from typing import Any import voluptuous as vol @@ -17,6 +18,7 @@ from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration +from homeassistant.util import slugify from . import dashboard, resources, websocket from .const import ( # noqa: F401 @@ -40,12 +42,25 @@ from .const import ( # noqa: F401 SERVICE_RELOAD_RESOURCES, STORAGE_DASHBOARD_CREATE_FIELDS, STORAGE_DASHBOARD_UPDATE_FIELDS, - url_slug, ) from .system_health import system_health_info # noqa: F401 _LOGGER = logging.getLogger(__name__) + +def _validate_url_slug(value: Any) -> str: + """Validate value is a valid url slug.""" + if value is None: + raise vol.Invalid("Slug should not be None") + if "-" not in value: + raise vol.Invalid("Url path needs to contain a hyphen (-)") + str_value = str(value) + slg = slugify(str_value, separator="-") + if str_value == slg: + return str_value + raise vol.Invalid(f"invalid slug {value} (try {slg})") + + CONF_DASHBOARDS = "dashboards" YAML_DASHBOARD_SCHEMA = vol.Schema( @@ -65,7 +80,7 @@ CONFIG_SCHEMA = vol.Schema( ), vol.Optional(CONF_DASHBOARDS): cv.schema_with_slug_keys( YAML_DASHBOARD_SCHEMA, - slug_validator=url_slug, + slug_validator=_validate_url_slug, ), vol.Optional(CONF_RESOURCES): [RESOURCE_SCHEMA], } diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py index 0bf5973e03d..0450c62338d 100644 --- a/homeassistant/components/lovelace/const.py +++ b/homeassistant/components/lovelace/const.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import voluptuous as vol @@ -16,7 +16,6 @@ from homeassistant.const import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType -from homeassistant.util import slugify from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: @@ -91,18 +90,5 @@ STORAGE_DASHBOARD_CREATE_FIELDS: VolDictType = { STORAGE_DASHBOARD_UPDATE_FIELDS = DASHBOARD_BASE_UPDATE_FIELDS -def url_slug(value: Any) -> str: - """Validate value is a valid url slug.""" - if value is None: - raise vol.Invalid("Slug should not be None") - if "-" not in value: - raise vol.Invalid("Url path needs to contain a hyphen (-)") - str_value = str(value) - slg = slugify(str_value, separator="-") - if str_value == slg: - return str_value - raise vol.Invalid(f"invalid slug {value} (try {slg})") - - class ConfigNotFound(HomeAssistantError): """When no config available.""" From 29c528ee54926cae06213fe5b1bf379a4c84ad6b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Jan 2025 18:52:10 +0100 Subject: [PATCH 0878/2987] Use runtime_data in bosch_shc (#136356) --- .../components/bosch_shc/__init__.py | 34 ++++++------------- .../components/bosch_shc/binary_sensor.py | 9 +++-- homeassistant/components/bosch_shc/const.py | 3 -- homeassistant/components/bosch_shc/cover.py | 10 +++--- homeassistant/components/bosch_shc/sensor.py | 8 ++--- homeassistant/components/bosch_shc/switch.py | 8 ++--- 6 files changed, 24 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/bosch_shc/__init__.py b/homeassistant/components/bosch_shc/__init__.py index 9a00029412d..2871bc52450 100644 --- a/homeassistant/components/bosch_shc/__init__.py +++ b/homeassistant/components/bosch_shc/__init__.py @@ -12,13 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from .const import ( - CONF_SSL_CERTIFICATE, - CONF_SSL_KEY, - DATA_POLLING_HANDLER, - DATA_SESSION, - DOMAIN, -) +from .const import CONF_SSL_CERTIFICATE, CONF_SSL_KEY, DOMAIN PLATFORMS = [ Platform.BINARY_SENSOR, @@ -30,7 +24,10 @@ PLATFORMS = [ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type BoschConfigEntry = ConfigEntry[SHCSession] + + +async def async_setup_entry(hass: HomeAssistant, entry: BoschConfigEntry) -> bool: """Set up Bosch SHC from a config entry.""" data = entry.data @@ -53,10 +50,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if shc_info.updateState.name == "UPDATE_AVAILABLE": _LOGGER.warning("Please check for software updates in the Bosch Smart Home App") - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_SESSION: session, - } + entry.runtime_data = session device_registry = dr.async_get(hass) device_registry.async_get_or_create( @@ -76,23 +70,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.async_add_executor_job(session.stop_polling) await hass.async_add_executor_job(session.start_polling) - hass.data[DOMAIN][entry.entry_id][DATA_POLLING_HANDLER] = ( + entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_polling) ) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: BoschConfigEntry) -> bool: """Unload a config entry.""" - session: SHCSession = hass.data[DOMAIN][entry.entry_id][DATA_SESSION] + await hass.async_add_executor_job(entry.runtime_data.stop_polling) - hass.data[DOMAIN][entry.entry_id][DATA_POLLING_HANDLER]() - hass.data[DOMAIN][entry.entry_id].pop(DATA_POLLING_HANDLER) - await hass.async_add_executor_job(session.stop_polling) - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/bosch_shc/binary_sensor.py b/homeassistant/components/bosch_shc/binary_sensor.py index 342a3e3e417..dd0f31ea6f9 100644 --- a/homeassistant/components/bosch_shc/binary_sensor.py +++ b/homeassistant/components/bosch_shc/binary_sensor.py @@ -2,28 +2,27 @@ from __future__ import annotations -from boschshcpy import SHCBatteryDevice, SHCSession, SHCShutterContact +from boschshcpy import SHCBatteryDevice, SHCShutterContact from boschshcpy.device import SHCDevice from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_SESSION, DOMAIN +from . import BoschConfigEntry from .entity import SHCEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: BoschConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SHC binary sensor platform.""" - session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION] + session = config_entry.runtime_data entities: list[BinarySensorEntity] = [ ShutterContactSensor( diff --git a/homeassistant/components/bosch_shc/const.py b/homeassistant/components/bosch_shc/const.py index ccb1f2094cb..07ec3b7da85 100644 --- a/homeassistant/components/bosch_shc/const.py +++ b/homeassistant/components/bosch_shc/const.py @@ -6,7 +6,4 @@ CONF_SHC_KEY = "bosch_shc-key.pem" CONF_SSL_CERTIFICATE = "ssl_certificate" CONF_SSL_KEY = "ssl_key" -DATA_SESSION = "session" -DATA_POLLING_HANDLER = "polling_handler" - DOMAIN = "bosch_shc" diff --git a/homeassistant/components/bosch_shc/cover.py b/homeassistant/components/bosch_shc/cover.py index 5377f0c6a8f..55d6bfc35de 100644 --- a/homeassistant/components/bosch_shc/cover.py +++ b/homeassistant/components/bosch_shc/cover.py @@ -2,7 +2,7 @@ from typing import Any -from boschshcpy import SHCSession, SHCShutterControl +from boschshcpy import SHCShutterControl from homeassistant.components.cover import ( ATTR_POSITION, @@ -10,22 +10,20 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_SESSION, DOMAIN +from . import BoschConfigEntry from .entity import SHCEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: BoschConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SHC cover platform.""" - - session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION] + session = config_entry.runtime_data async_add_entities( ShutterControlCover( diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index 28f23cd9765..6408e21654e 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -6,7 +6,6 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any -from boschshcpy import SHCSession from boschshcpy.device import SHCDevice from homeassistant.components.sensor import ( @@ -15,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -27,7 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DATA_SESSION, DOMAIN +from . import BoschConfigEntry from .entity import SHCEntity @@ -127,11 +125,11 @@ SENSOR_DESCRIPTIONS: dict[str, SHCSensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: BoschConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SHC sensor platform.""" - session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION] + session = config_entry.runtime_data entities: list[SensorEntity] = [ SHCSensor( diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index 58370a120f2..76b1da3e534 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -9,7 +9,6 @@ from boschshcpy import ( SHCCamera360, SHCCameraEyes, SHCLightSwitch, - SHCSession, SHCSmartPlug, SHCSmartPlugCompact, ) @@ -20,13 +19,12 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DATA_SESSION, DOMAIN +from . import BoschConfigEntry from .entity import SHCEntity @@ -80,11 +78,11 @@ SWITCH_TYPES: dict[str, SHCSwitchEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: BoschConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SHC switch platform.""" - session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION] + session = config_entry.runtime_data entities: list[SwitchEntity] = [ SHCSwitch( From 21a83c4875fbc0007b7a94c8ab53682778abf414 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Jan 2025 18:53:04 +0100 Subject: [PATCH 0879/2987] Use runtime_data in canary (#136357) --- homeassistant/components/canary/__init__.py | 33 ++++++------------- .../components/canary/alarm_control_panel.py | 10 ++---- homeassistant/components/canary/camera.py | 17 +++------- homeassistant/components/canary/const.py | 4 --- .../components/canary/coordinator.py | 9 ++++- homeassistant/components/canary/sensor.py | 11 +++---- 6 files changed, 29 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/canary/__init__.py b/homeassistant/components/canary/__init__.py index f879c308a88..a28c37580ce 100644 --- a/homeassistant/components/canary/__init__.py +++ b/homeassistant/components/canary/__init__.py @@ -11,7 +11,7 @@ from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -20,13 +20,11 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_FFMPEG_ARGUMENTS, - DATA_COORDINATOR, - DATA_UNDO_UPDATE_LISTENER, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, ) -from .coordinator import CanaryDataUpdateCoordinator +from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator _LOGGER: Final = logging.getLogger(__name__) @@ -59,8 +57,6 @@ PLATFORMS: Final[list[Platform]] = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Canary integration.""" - hass.data.setdefault(DOMAIN, {}) - if hass.config_entries.async_entries(DOMAIN): return True @@ -90,7 +86,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: CanaryConfigEntry) -> bool: """Set up Canary from a config entry.""" if not entry.options: options = { @@ -107,38 +103,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Unable to connect to Canary service: %s", str(error)) raise ConfigEntryNotReady from error - coordinator = CanaryDataUpdateCoordinator(hass, api=canary_api) + coordinator = CanaryDataUpdateCoordinator(hass, entry, api=canary_api) await coordinator.async_config_entry_first_refresh() - undo_listener = entry.add_update_listener(_async_update_listener) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: coordinator, - DATA_UNDO_UPDATE_LISTENER: undo_listener, - } + 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: CanaryConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]() - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: CanaryConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -def _get_canary_api_instance(entry: ConfigEntry) -> Api: +def _get_canary_api_instance(entry: CanaryConfigEntry) -> Api: """Initialize a new instance of CanaryApi.""" return Api( entry.data[CONF_USERNAME], diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py index 69600e4bbc7..443944da8c3 100644 --- a/homeassistant/components/canary/alarm_control_panel.py +++ b/homeassistant/components/canary/alarm_control_panel.py @@ -12,24 +12,20 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import CanaryDataUpdateCoordinator +from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: CanaryConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Canary alarm control panels based on a config entry.""" - coordinator: CanaryDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data alarms = [ CanaryAlarm(coordinator, location) for location_id, location in coordinator.data["locations"].items() diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index a56d1ebc3de..8f4a01c9968 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -18,7 +18,6 @@ from homeassistant.components.camera import ( Camera, ) from homeassistant.components.ffmpeg import FFmpegManager, get_ffmpeg_manager -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream @@ -27,14 +26,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import ( - CONF_FFMPEG_ARGUMENTS, - DATA_COORDINATOR, - DEFAULT_FFMPEG_ARGUMENTS, - DOMAIN, - MANUFACTURER, -) -from .coordinator import CanaryDataUpdateCoordinator +from .const import CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS, DOMAIN, MANUFACTURER +from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator FORCE_CAMERA_REFRESH_INTERVAL: Final = timedelta(minutes=15) @@ -54,13 +47,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: CanaryConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Canary sensors based on a config entry.""" - coordinator: CanaryDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data ffmpeg_arguments: str = entry.options.get( CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS ) diff --git a/homeassistant/components/canary/const.py b/homeassistant/components/canary/const.py index 210da35c7c1..9b9229c3ac3 100644 --- a/homeassistant/components/canary/const.py +++ b/homeassistant/components/canary/const.py @@ -9,10 +9,6 @@ MANUFACTURER: Final = "Canary Connect, Inc" # Configuration CONF_FFMPEG_ARGUMENTS: Final = "ffmpeg_arguments" -# Data -DATA_COORDINATOR: Final = "coordinator" -DATA_UNDO_UPDATE_LISTENER: Final = "undo_update_listener" - # Defaults DEFAULT_FFMPEG_ARGUMENTS: Final = "-pred 1" DEFAULT_TIMEOUT: Final = 10 diff --git a/homeassistant/components/canary/coordinator.py b/homeassistant/components/canary/coordinator.py index d58d1da0f79..7c90074f81a 100644 --- a/homeassistant/components/canary/coordinator.py +++ b/homeassistant/components/canary/coordinator.py @@ -11,6 +11,7 @@ from canary.api import Api from canary.model import Location, Reading from requests.exceptions import ConnectTimeout, HTTPError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -20,10 +21,15 @@ from .model import CanaryData _LOGGER = logging.getLogger(__name__) +type CanaryConfigEntry = ConfigEntry[CanaryDataUpdateCoordinator] + + class CanaryDataUpdateCoordinator(DataUpdateCoordinator[CanaryData]): """Class to manage fetching Canary data.""" - def __init__(self, hass: HomeAssistant, *, api: Api) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: CanaryConfigEntry, *, api: Api + ) -> None: """Initialize global Canary data updater.""" self.canary = api update_interval = timedelta(seconds=30) @@ -31,6 +37,7 @@ class CanaryDataUpdateCoordinator(DataUpdateCoordinator[CanaryData]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=update_interval, ) diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index 9aab4698bf3..22f3eada2cb 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -7,7 +7,6 @@ from typing import Final from canary.model import Device, Location, SensorType from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -18,8 +17,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DATA_COORDINATOR, DOMAIN, MANUFACTURER -from .coordinator import CanaryDataUpdateCoordinator +from .const import DOMAIN, MANUFACTURER +from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator type SensorTypeItem = tuple[ str, str | None, str | None, SensorDeviceClass | None, list[str] @@ -64,13 +63,11 @@ STATE_AIR_QUALITY_VERY_ABNORMAL: Final = "very_abnormal" async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: CanaryConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Canary sensors based on a config entry.""" - coordinator: CanaryDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data sensors: list[CanarySensor] = [] for location in coordinator.data["locations"].values(): From 61694648fcfee5b27a839e1e22b6380ddf305e24 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 23 Jan 2025 18:56:08 +0100 Subject: [PATCH 0880/2987] Several fixes in user-facing strings of Renson integration actions (#136279) --- homeassistant/components/renson/strings.json | 26 ++++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index b756d16ea79..c81086502ad 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -186,46 +186,46 @@ "services": { "set_timer_level": { "name": "Set timer", - "description": "Set the ventilation timer", + "description": "Sets the ventilation timer", "fields": { "timer_level": { "name": "Level", - "description": "Level setting" + "description": "Ventilation level" }, "minutes": { "name": "Time", - "description": "Time of the timer (0 will disable the timer)" + "description": "Duration of the timer (0 will disable the timer)" } } }, "set_breeze": { - "name": "Set breeze", - "description": "Set the breeze function of the ventilation system", + "name": "Set Breeze", + "description": "Sets the Breeze function of the ventilation system", "fields": { "breeze_level": { "name": "[%key:component::renson::services::set_timer_level::fields::timer_level::name%]", - "description": "Ventilation level when breeze function is activated" + "description": "Ventilation level when Breeze function is activated" }, "temperature": { "name": "Temperature", - "description": "Temperature when the breeze function should be activated" + "description": "Temperature when the Breeze function should be activated" }, "activate": { "name": "Activate", - "description": "Activate or disable the breeze feature" + "description": "Activate or disable the Breeze feature" } } }, "set_pollution_settings": { "name": "Set pollution settings", - "description": "Set all the pollution settings of the ventilation system", + "description": "Sets all the pollution settings of the ventilation system", "fields": { "day_pollution_level": { - "name": "Day pollution Level", + "name": "Day pollution level", "description": "Ventilation level when pollution is detected in the day" }, "night_pollution_level": { - "name": "Night pollution Level", + "name": "Night pollution level", "description": "Ventilation level when pollution is detected in the night" }, "humidity_control": { @@ -242,11 +242,11 @@ }, "co2_threshold": { "name": "CO2 threshold", - "description": "Sets the CO2 pollution threshold level in ppm" + "description": "The CO2 pollution threshold level in ppm" }, "co2_hysteresis": { "name": "CO2 hysteresis", - "description": "Sets the CO2 pollution threshold hysteresis level in ppm" + "description": "The CO2 pollution threshold hysteresis level in ppm" } } } From 5803d4444381c203e01d6e41e9e9b27ae5e302a7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Jan 2025 19:04:10 +0100 Subject: [PATCH 0881/2987] Cleanup hass.data in cloudflare (#136358) --- homeassistant/components/cloudflare/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/cloudflare/__init__.py b/homeassistant/components/cloudflare/__init__.py index bd27be71d18..f8fbac396a6 100644 --- a/homeassistant/components/cloudflare/__init__.py +++ b/homeassistant/components/cloudflare/__init__.py @@ -74,9 +74,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_track_time_interval(hass, update_records, update_interval) ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {} - hass.services.async_register(DOMAIN, SERVICE_UPDATE_RECORDS, update_records_service) return True @@ -84,7 +81,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Cloudflare config entry.""" - hass.data[DOMAIN].pop(entry.entry_id) return True From c98df36b75d351789b2bc0dfd4127ab7d1d480b4 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 23 Jan 2025 19:05:57 +0100 Subject: [PATCH 0882/2987] Bump pyenphase to 1.23.1 (#136200) --- .../components/enphase_envoy/manifest.json | 2 +- .../components/enphase_envoy/select.py | 8 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/enphase_envoy/test_select.py | 25 +++++++++++++++++++ 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index bdc90e6c634..0b1fd8b04b9 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.0"], + "requirements": ["pyenphase==1.23.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index d9729a16683..7dc275aab37 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -37,7 +37,7 @@ class EnvoyRelaySelectEntityDescription(SelectEntityDescription): class EnvoyStorageSettingsSelectEntityDescription(SelectEntityDescription): """Describes an Envoy storage settings select entity.""" - value_fn: Callable[[EnvoyStorageSettings], str] + value_fn: Callable[[EnvoyStorageSettings], str | None] update_fn: Callable[[Envoy, str], Awaitable[dict[str, Any]]] @@ -118,7 +118,9 @@ STORAGE_MODE_ENTITY = EnvoyStorageSettingsSelectEntityDescription( key="storage_mode", translation_key="storage_mode", options=STORAGE_MODE_OPTIONS, - value_fn=lambda storage_settings: STORAGE_MODE_MAP[storage_settings.mode], + value_fn=lambda storage_settings: ( + None if not storage_settings.mode else STORAGE_MODE_MAP[storage_settings.mode] + ), update_fn=lambda envoy, value: envoy.set_storage_mode( REVERSE_STORAGE_MODE_MAP[value] ), @@ -235,7 +237,7 @@ class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity): ) @property - def current_option(self) -> str: + def current_option(self) -> str | None: """Return the state of the select entity.""" assert self.data.tariff is not None assert self.data.tariff.storage_settings is not None diff --git a/requirements_all.txt b/requirements_all.txt index ce52715cb36..c8386290b54 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1923,7 +1923,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.23.0 +pyenphase==1.23.1 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1d892ff34e0..78adfd85cdc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1567,7 +1567,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.23.0 +pyenphase==1.23.1 # homeassistant.components.everlights pyeverlights==0.1.0 diff --git a/tests/components/enphase_envoy/test_select.py b/tests/components/enphase_envoy/test_select.py index 071dbcb2fe2..9b3a63d1e23 100644 --- a/tests/components/enphase_envoy/test_select.py +++ b/tests/components/enphase_envoy/test_select.py @@ -226,3 +226,28 @@ async def test_select_storage_modes( mock_envoy.set_storage_mode.assert_called_once_with( REVERSE_STORAGE_MODE_MAP[current_state] ) + + +@pytest.mark.parametrize( + ("mock_envoy", "use_serial"), + [ + ("envoy_metered_batt_relay", "enpower_654321"), + ("envoy_eu_batt", "envoy_1234"), + ], + indirect=["mock_envoy"], +) +async def test_select_storage_modes_if_none( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + use_serial: str, +) -> None: + """Test select platform entity storage mode when tariff storage_mode is none.""" + mock_envoy.data.tariff.storage_settings.mode = None + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry) + + test_entity = f"{Platform.SELECT}.{use_serial}_storage_mode" + + assert (entity_state := hass.states.get(test_entity)) + assert entity_state.state == "unknown" From ac7b9d76395d2f0da1a46c3532cc4a47a97c86a3 Mon Sep 17 00:00:00 2001 From: Chris <1105672+firstof9@users.noreply.github.com> Date: Thu, 23 Jan 2025 11:09:03 -0700 Subject: [PATCH 0883/2987] Properly parse AirNow API data in coordinator (#136198) --- .../components/airnow/coordinator.py | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index 32185080d25..9434d368dbe 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -21,7 +21,6 @@ from .const import ( ATTR_API_CAT_DESCRIPTION, ATTR_API_CAT_LEVEL, ATTR_API_CATEGORY, - ATTR_API_PM25, ATTR_API_POLLUTANT, ATTR_API_REPORT_DATE, ATTR_API_REPORT_HOUR, @@ -91,18 +90,16 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): max_aqi_desc = obv[ATTR_API_CATEGORY][ATTR_API_CAT_DESCRIPTION] max_aqi_poll = pollutant - # Copy other data from PM2.5 Value - if obv[ATTR_API_AQI_PARAM] == ATTR_API_PM25: - # Copy Report Details - data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE] - data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR] - data[ATTR_API_REPORT_TZ] = obv[ATTR_API_REPORT_TZ] + # Copy Report Details + data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE] + data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR] + data[ATTR_API_REPORT_TZ] = obv[ATTR_API_REPORT_TZ] - # Copy Station Details - data[ATTR_API_STATE] = obv[ATTR_API_STATE] - data[ATTR_API_STATION] = obv[ATTR_API_STATION] - data[ATTR_API_STATION_LATITUDE] = obv[ATTR_API_STATION_LATITUDE] - data[ATTR_API_STATION_LONGITUDE] = obv[ATTR_API_STATION_LONGITUDE] + # Copy Station Details + data[ATTR_API_STATE] = obv[ATTR_API_STATE] + data[ATTR_API_STATION] = obv[ATTR_API_STATION] + data[ATTR_API_STATION_LATITUDE] = obv[ATTR_API_STATION_LATITUDE] + data[ATTR_API_STATION_LONGITUDE] = obv[ATTR_API_STATION_LONGITUDE] # Store Overall AQI data[ATTR_API_AQI] = max_aqi From 59d677ba3e8575373f7f89186da4e4289c699e5d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Jan 2025 19:21:39 +0100 Subject: [PATCH 0884/2987] Enable strict typing for incomfort integration (#136291) * Enable strict typing for incomfort integration * Comply to strict typing * Wrap in bool --- .strict-typing | 1 + homeassistant/components/incomfort/__init__.py | 2 +- homeassistant/components/incomfort/binary_sensor.py | 2 +- homeassistant/components/incomfort/config_flow.py | 4 ++-- homeassistant/components/incomfort/sensor.py | 2 +- mypy.ini | 10 ++++++++++ 6 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.strict-typing b/.strict-typing index ce1ea1a6838..7034ea1f0c1 100644 --- a/.strict-typing +++ b/.strict-typing @@ -262,6 +262,7 @@ homeassistant.components.image_processing.* homeassistant.components.image_upload.* homeassistant.components.imap.* homeassistant.components.imgw_pib.* +homeassistant.components.incomfort.* homeassistant.components.input_button.* homeassistant.components.input_select.* homeassistant.components.input_text.* diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index 909a4731e84..722518ba6c2 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -63,6 +63,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> bool: """Unload config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/incomfort/binary_sensor.py b/homeassistant/components/incomfort/binary_sensor.py index c4a23946bb2..e4353e457a5 100644 --- a/homeassistant/components/incomfort/binary_sensor.py +++ b/homeassistant/components/incomfort/binary_sensor.py @@ -102,7 +102,7 @@ class IncomfortBinarySensor(IncomfortBoilerEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return the status of the sensor.""" - return self._heater.status[self.entity_description.value_key] + return bool(self._heater.status[self.entity_description.value_key]) @property def extra_state_attributes(self) -> dict[str, Any] | None: diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py index 779b0e97777..8e4a5f72619 100644 --- a/homeassistant/components/incomfort/config_flow.py +++ b/homeassistant/components/incomfort/config_flow.py @@ -10,7 +10,6 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_RECONFIGURE, - ConfigEntry, ConfigEntryState, ConfigFlow, ConfigFlowResult, @@ -29,6 +28,7 @@ from homeassistant.helpers.selector import ( ) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from . import InComfortConfigEntry from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN from .coordinator import async_connect_gateway @@ -103,7 +103,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: InComfortConfigEntry, ) -> InComfortOptionsFlowHandler: """Get the options flow for this handler.""" return InComfortOptionsFlowHandler() diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index e9697a0036f..e3f3fc785b2 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -99,7 +99,7 @@ class IncomfortSensor(IncomfortBoilerEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the state of the sensor.""" - return self._heater.status[self.entity_description.value_key] + return self._heater.status[self.entity_description.value_key] # type: ignore [no-any-return] @property def extra_state_attributes(self) -> dict[str, Any] | None: diff --git a/mypy.ini b/mypy.ini index ccdc7c669d7..d0579ab8f41 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2376,6 +2376,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.incomfort.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.input_button.*] check_untyped_defs = true disallow_incomplete_defs = true From b2624e62746d769095cc0c72d2ccb6b8fa7998c6 Mon Sep 17 00:00:00 2001 From: Matt Doran Date: Fri, 24 Jan 2025 05:50:56 +1100 Subject: [PATCH 0885/2987] Update Hydrawise maximum watering duration to meet the app limits (#136050) Co-authored-by: Robert Resch --- homeassistant/components/hydrawise/binary_sensor.py | 2 +- homeassistant/components/hydrawise/services.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 34c31d3ad16..83e8a8325f9 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -68,7 +68,7 @@ ZONE_BINARY_SENSORS: tuple[HydrawiseBinarySensorEntityDescription, ...] = ( ) SCHEMA_START_WATERING: VolDictType = { - vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=90)), + vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=1440)), } SCHEMA_SUSPEND: VolDictType = { vol.Required("until"): cv.datetime, diff --git a/homeassistant/components/hydrawise/services.yaml b/homeassistant/components/hydrawise/services.yaml index 64c04901816..bf90a8e23b3 100644 --- a/homeassistant/components/hydrawise/services.yaml +++ b/homeassistant/components/hydrawise/services.yaml @@ -10,7 +10,7 @@ start_watering: selector: number: min: 0 - max: 90 + max: 1440 unit_of_measurement: min mode: box suspend: From 2466df2b782ee452b015befccf80d09d67a29541 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 23 Jan 2025 18:51:56 +0000 Subject: [PATCH 0886/2987] Fix tplink deprecated entity cleanup (#136160) --- .../components/tplink/binary_sensor.py | 5 +- homeassistant/components/tplink/button.py | 4 +- homeassistant/components/tplink/camera.py | 8 +- homeassistant/components/tplink/climate.py | 13 ++-- homeassistant/components/tplink/deprecate.py | 40 +++++++--- homeassistant/components/tplink/entity.py | 37 +++++++++- homeassistant/components/tplink/fan.py | 11 ++- homeassistant/components/tplink/light.py | 53 +++++++------ homeassistant/components/tplink/number.py | 3 +- homeassistant/components/tplink/select.py | 3 +- homeassistant/components/tplink/sensor.py | 3 +- homeassistant/components/tplink/siren.py | 11 ++- homeassistant/components/tplink/switch.py | 3 +- tests/components/tplink/__init__.py | 2 + tests/components/tplink/test_init.py | 74 +++++++++++++++++++ 15 files changed, 210 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py index 6153ec31de1..e08495f5c88 100644 --- a/homeassistant/components/tplink/binary_sensor.py +++ b/homeassistant/components/tplink/binary_sensor.py @@ -17,7 +17,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry -from .deprecate import async_cleanup_deprecated from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription @@ -88,12 +87,10 @@ async def async_setup_entry( feature_type=Feature.Type.BinarySensor, entity_class=TPLinkBinarySensorEntity, descriptions=BINARYSENSOR_DESCRIPTIONS_MAP, + platform_domain=BINARY_SENSOR_DOMAIN, known_child_device_ids=known_child_device_ids, first_check=first_check, ) - async_cleanup_deprecated( - hass, BINARY_SENSOR_DOMAIN, config_entry.entry_id, entities - ) async_add_entities(entities) _check_device() diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py index 990f0a608d3..0a4517b967d 100644 --- a/homeassistant/components/tplink/button.py +++ b/homeassistant/components/tplink/button.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry -from .deprecate import DeprecatedInfo, async_cleanup_deprecated +from .deprecate import DeprecatedInfo from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription @@ -95,10 +95,10 @@ async def async_setup_entry( feature_type=Feature.Type.Action, entity_class=TPLinkButtonEntity, descriptions=BUTTON_DESCRIPTIONS_MAP, + platform_domain=BUTTON_DOMAIN, known_child_device_ids=known_child_device_ids, first_check=first_check, ) - async_cleanup_deprecated(hass, BUTTON_DOMAIN, config_entry.entry_id, entities) async_add_entities(entities) _check_device() diff --git a/homeassistant/components/tplink/camera.py b/homeassistant/components/tplink/camera.py index 61a08887f5f..b0f1f1a62c1 100644 --- a/homeassistant/components/tplink/camera.py +++ b/homeassistant/components/tplink/camera.py @@ -11,6 +11,7 @@ from kasa import Device, Module, StreamResolution from homeassistant.components import ffmpeg, stream from homeassistant.components.camera import ( + DOMAIN as CAMERA_DOMAIN, Camera, CameraEntityDescription, CameraEntityFeature, @@ -20,7 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TPLinkConfigEntry, legacy_device_id +from . import TPLinkConfigEntry from .const import CONF_CAMERA_CREDENTIALS from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkModuleEntity, TPLinkModuleEntityDescription @@ -75,6 +76,7 @@ async def async_setup_entry( coordinator=parent_coordinator, entity_class=TPLinkCameraEntity, descriptions=CAMERA_DESCRIPTIONS, + platform_domain=CAMERA_DOMAIN, known_child_device_ids=known_child_device_ids, first_check=first_check, ) @@ -121,10 +123,6 @@ class TPLinkCameraEntity(CoordinatedTPLinkModuleEntity, Camera): self._can_stream = True self._http_mpeg_stream_running = False - def _get_unique_id(self) -> str: - """Return unique ID for the entity.""" - return f"{legacy_device_id(self._device)}-{self.entity_description.key}" - async def async_added_to_hass(self) -> None: """Call update attributes after the device is added to the platform.""" await super().async_added_to_hass() diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py index a7dd865e7bb..d4800d9e951 100644 --- a/homeassistant/components/tplink/climate.py +++ b/homeassistant/components/tplink/climate.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass import logging from typing import Any, cast @@ -11,6 +12,7 @@ from kasa.smart.modules.temperaturecontrol import ThermostatState from homeassistant.components.climate import ( ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, @@ -22,7 +24,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TPLinkConfigEntry +from . import TPLinkConfigEntry, legacy_device_id from .const import DOMAIN, UNIT_MAPPING from .coordinator import TPLinkDataUpdateCoordinator from .entity import ( @@ -52,6 +54,10 @@ class TPLinkClimateEntityDescription( ): """Base class for climate entity description.""" + unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = ( + lambda device, desc: f"{legacy_device_id(device)}_{desc.key}" + ) + CLIMATE_DESCRIPTIONS: tuple[TPLinkClimateEntityDescription, ...] = ( TPLinkClimateEntityDescription( @@ -81,6 +87,7 @@ async def async_setup_entry( coordinator=parent_coordinator, entity_class=TPLinkClimateEntity, descriptions=CLIMATE_DESCRIPTIONS, + platform_domain=CLIMATE_DOMAIN, known_child_device_ids=known_child_device_ids, first_check=first_check, ) @@ -182,7 +189,3 @@ class TPLinkClimateEntity(CoordinatedTPLinkModuleEntity, ClimateEntity): cast(ThermostatState, self._mode_feature.value) ] return True - - def _get_unique_id(self) -> str: - """Return unique id.""" - return f"{self._device.device_id}_climate" diff --git a/homeassistant/components/tplink/deprecate.py b/homeassistant/components/tplink/deprecate.py index 738f3d24c38..86d4f66cdc0 100644 --- a/homeassistant/components/tplink/deprecate.py +++ b/homeassistant/components/tplink/deprecate.py @@ -6,16 +6,20 @@ from collections.abc import Sequence from dataclasses import dataclass from typing import TYPE_CHECKING +from kasa import Device + from homeassistant.components.automation import automations_with_entity +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.script import scripts_with_entity from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from . import legacy_device_id from .const import DOMAIN if TYPE_CHECKING: - from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription + from .entity import CoordinatedTPLinkEntity, TPLinkEntityDescription @dataclass(slots=True) @@ -30,7 +34,7 @@ class DeprecatedInfo: def async_check_create_deprecated( hass: HomeAssistant, unique_id: str, - entity_description: TPLinkFeatureEntityDescription, + entity_description: TPLinkEntityDescription, ) -> bool: """Return true if the entity should be created based on the deprecated_info. @@ -58,13 +62,21 @@ def async_check_create_deprecated( return not entity_entry.disabled -def async_cleanup_deprecated( +def async_process_deprecated( hass: HomeAssistant, - platform: str, + platform_domain: str, entry_id: str, - entities: Sequence[CoordinatedTPLinkFeatureEntity], + entities: Sequence[CoordinatedTPLinkEntity], + device: Device, ) -> None: - """Remove disabled deprecated entities or create issues if necessary.""" + """Process deprecated entities for a device. + + Create issues for deprececated entities that appear in automations. + Delete entities that are no longer provided by the integration either + because they have been removed at the end of the deprecation period, or + they are disabled by the user so the async_check_create_deprecated + returned false. + """ ent_reg = er.async_get(hass) for entity in entities: if not (deprecated_info := entity.entity_description.deprecated_info): @@ -72,7 +84,7 @@ def async_cleanup_deprecated( assert entity.unique_id entity_id = ent_reg.async_get_entity_id( - platform, + platform_domain, DOMAIN, entity.unique_id, ) @@ -94,17 +106,27 @@ def async_cleanup_deprecated( translation_placeholders={ "entity": entity_id, "info": item, - "platform": platform, + "platform": platform_domain, "new_platform": deprecated_info.new_platform, }, ) + # The light platform does not currently support cleaning up disabled + # deprecated entities because it uses two entity classes so a completeness + # check is not possible. It also uses the mac address as device id in some + # instances instead of device_id. + if platform_domain == LIGHT_DOMAIN: + return + # Remove entities that are no longer provided and have been disabled. + device_id = legacy_device_id(device) + unique_ids = {entity.unique_id for entity in entities} for entity_entry in er.async_entries_for_config_entry(ent_reg, entry_id): if ( - entity_entry.domain == platform + entity_entry.domain == platform_domain and entity_entry.disabled + and entity_entry.unique_id.startswith(device_id) and entity_entry.unique_id not in unique_ids ): ent_reg.async_remove(entity_entry.entity_id) diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index e7c3600acc2..edef8bd83a0 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -36,7 +36,11 @@ from .const import ( PRIMARY_STATE_ID, ) from .coordinator import TPLinkConfigEntry, TPLinkDataUpdateCoordinator -from .deprecate import DeprecatedInfo, async_check_create_deprecated +from .deprecate import ( + DeprecatedInfo, + async_check_create_deprecated, + async_process_deprecated, +) _LOGGER = logging.getLogger(__name__) @@ -102,6 +106,9 @@ class TPLinkModuleEntityDescription(TPLinkEntityDescription): """Base class for a TPLink module based entity description.""" exists_fn: Callable[[Device, TPLinkConfigEntry], bool] + unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = ( + lambda device, desc: f"{legacy_device_id(device)}-{desc.key}" + ) def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( @@ -151,6 +158,8 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB _attr_has_entity_name = True _device: Device + entity_description: TPLinkEntityDescription + def __init__( self, device: Device, @@ -235,7 +244,7 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB def _get_unique_id(self) -> str: """Return unique ID for the entity.""" - return legacy_device_id(self._device) + raise NotImplementedError async def async_added_to_hass(self) -> None: """Call update attributes after the device is added to the platform.""" @@ -405,6 +414,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): feature_type: Feature.Type, entity_class: type[_E], descriptions: Mapping[str, _D], + platform_domain: str, parent: Device | None = None, ) -> list[_E]: """Return a list of entities to add. @@ -439,6 +449,9 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): desc, ) ] + async_process_deprecated( + hass, platform_domain, coordinator.config_entry.entry_id, entities, device + ) return entities @classmethod @@ -454,6 +467,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): feature_type: Feature.Type, entity_class: type[_E], descriptions: Mapping[str, _D], + platform_domain: str, known_child_device_ids: set[str], first_check: bool, ) -> list[_E]: @@ -473,6 +487,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): feature_type=feature_type, entity_class=entity_class, descriptions=descriptions, + platform_domain=platform_domain, ) ) @@ -498,6 +513,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): feature_type=feature_type, entity_class=entity_class, descriptions=descriptions, + platform_domain=platform_domain, parent=device, ) _LOGGER.debug( @@ -539,6 +555,11 @@ class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC): else: self._attr_name = get_device_name(device) + def _get_unique_id(self) -> str: + """Return unique ID for the entity.""" + desc = self.entity_description + return desc.unique_id_fn(self._device, desc) + @classmethod def _entities_for_device[ _E: CoordinatedTPLinkModuleEntity, @@ -551,6 +572,7 @@ class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC): *, entity_class: type[_E], descriptions: Iterable[_D], + platform_domain: str, parent: Device | None = None, ) -> list[_E]: """Return a list of entities to add.""" @@ -563,7 +585,15 @@ class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC): ) for description in descriptions if description.exists_fn(device, coordinator.config_entry) + and async_check_create_deprecated( + hass, + description.unique_id_fn(device, description), + description, + ) ] + async_process_deprecated( + hass, platform_domain, coordinator.config_entry.entry_id, entities, device + ) return entities @classmethod @@ -578,6 +608,7 @@ class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC): *, entity_class: type[_E], descriptions: Iterable[_D], + platform_domain: str, known_child_device_ids: set[str], first_check: bool, ) -> list[_E]: @@ -597,6 +628,7 @@ class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC): coordinator=coordinator, entity_class=entity_class, descriptions=descriptions, + platform_domain=platform_domain, ) ) has_parent_entities = bool(entities) @@ -621,6 +653,7 @@ class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC): coordinator=child_coordinator, entity_class=entity_class, descriptions=descriptions, + platform_domain=platform_domain, parent=device, ) _LOGGER.debug( diff --git a/homeassistant/components/tplink/fan.py b/homeassistant/components/tplink/fan.py index cb17955fbcb..1c31d84b778 100644 --- a/homeassistant/components/tplink/fan.py +++ b/homeassistant/components/tplink/fan.py @@ -1,5 +1,6 @@ """Support for TPLink Fan devices.""" +from collections.abc import Callable from dataclasses import dataclass import logging import math @@ -8,6 +9,7 @@ from typing import Any from kasa import Device, Module from homeassistant.components.fan import ( + DOMAIN as FAN_DOMAIN, FanEntity, FanEntityDescription, FanEntityFeature, @@ -20,7 +22,7 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range -from . import TPLinkConfigEntry +from . import TPLinkConfigEntry, legacy_device_id from .coordinator import TPLinkDataUpdateCoordinator from .entity import ( CoordinatedTPLinkModuleEntity, @@ -39,6 +41,12 @@ _LOGGER = logging.getLogger(__name__) class TPLinkFanEntityDescription(FanEntityDescription, TPLinkModuleEntityDescription): """Base class for fan entity description.""" + unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = ( + lambda device, desc: legacy_device_id(device) + if desc.key == "fan" + else f"{legacy_device_id(device)}-{desc.key}" + ) + FAN_DESCRIPTIONS: tuple[TPLinkFanEntityDescription, ...] = ( TPLinkFanEntityDescription( @@ -68,6 +76,7 @@ async def async_setup_entry( coordinator=parent_coordinator, entity_class=TPLinkFanEntity, descriptions=FAN_DESCRIPTIONS, + platform_domain=FAN_DOMAIN, known_child_device_ids=known_child_device_ids, first_check=first_check, ) diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index bc4d792b3f8..c1311c256df 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -18,6 +18,7 @@ from homeassistant.components.light import ( ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION, + DOMAIN as LIGHT_DOMAIN, EFFECT_OFF, ColorMode, LightEntity, @@ -141,12 +142,39 @@ def _async_build_base_effect( } +def _get_backwards_compatible_light_unique_id( + device: Device, entity_description: TPLinkModuleEntityDescription +) -> str: + """Return unique ID for the entity.""" + # For historical reasons the light platform uses the mac address as + # the unique id whereas all other platforms use device_id. + + # For backwards compat with pyHS100 + if device.device_type is DeviceType.Dimmer and isinstance(device, IotDevice): + # Dimmers used to use the switch format since + # pyHS100 treated them as SmartPlug but the old code + # created them as lights + # https://github.com/home-assistant/core/blob/2021.9.7/ \ + # homeassistant/components/tplink/common.py#L86 + return legacy_device_id(device) + + # Newer devices can have child lights. While there isn't currently + # an example of a device with more than one light we use the device_id + # for consistency and future proofing + if device.parent or device.children: + return legacy_device_id(device) + + return device.mac.replace(":", "").upper() + + @dataclass(frozen=True, kw_only=True) class TPLinkLightEntityDescription( LightEntityDescription, TPLinkModuleEntityDescription ): """Base class for tplink light entity description.""" + unique_id_fn = _get_backwards_compatible_light_unique_id + LIGHT_DESCRIPTIONS: tuple[TPLinkLightEntityDescription, ...] = ( TPLinkLightEntityDescription( @@ -186,6 +214,7 @@ async def async_setup_entry( coordinator=parent_coordinator, entity_class=TPLinkLightEntity, descriptions=LIGHT_DESCRIPTIONS, + platform_domain=LIGHT_DOMAIN, known_child_device_ids=known_child_device_ids_light, first_check=first_check, ) @@ -196,6 +225,7 @@ async def async_setup_entry( coordinator=parent_coordinator, entity_class=TPLinkLightEffectEntity, descriptions=LIGHT_EFFECT_DESCRIPTIONS, + platform_domain=LIGHT_DOMAIN, known_child_device_ids=known_child_device_ids_light_effect, first_check=first_check, ) @@ -242,29 +272,6 @@ class TPLinkLightEntity(CoordinatedTPLinkModuleEntity, LightEntity): # If the light supports only a single color mode, set it now self._fixed_color_mode = next(iter(self._attr_supported_color_modes)) - def _get_unique_id(self) -> str: - """Return unique ID for the entity.""" - # For historical reasons the light platform uses the mac address as - # the unique id whereas all other platforms use device_id. - device = self._device - - # For backwards compat with pyHS100 - if device.device_type is DeviceType.Dimmer and isinstance(device, IotDevice): - # Dimmers used to use the switch format since - # pyHS100 treated them as SmartPlug but the old code - # created them as lights - # https://github.com/home-assistant/core/blob/2021.9.7/ \ - # homeassistant/components/tplink/common.py#L86 - return legacy_device_id(device) - - # Newer devices can have child lights. While there isn't currently - # an example of a device with more than one light we use the device_id - # for consistency and future proofing - if self._parent or device.children: - return legacy_device_id(device) - - return device.mac.replace(":", "").upper() - @callback def _async_extract_brightness_transition( self, **kwargs: Any diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py index 97152ef4da8..0af2b7403e8 100644 --- a/homeassistant/components/tplink/number.py +++ b/homeassistant/components/tplink/number.py @@ -18,7 +18,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry -from .deprecate import async_cleanup_deprecated from .entity import ( CoordinatedTPLinkFeatureEntity, TPLinkDataUpdateCoordinator, @@ -91,10 +90,10 @@ async def async_setup_entry( feature_type=Feature.Type.Number, entity_class=TPLinkNumberEntity, descriptions=NUMBER_DESCRIPTIONS_MAP, + platform_domain=NUMBER_DOMAIN, known_child_device_ids=known_child_device_ids, first_check=first_check, ) - async_cleanup_deprecated(hass, NUMBER_DOMAIN, config_entry.entry_id, entities) async_add_entities(entities) _check_device() diff --git a/homeassistant/components/tplink/select.py b/homeassistant/components/tplink/select.py index a443546fdaa..8e9dee7b964 100644 --- a/homeassistant/components/tplink/select.py +++ b/homeassistant/components/tplink/select.py @@ -16,7 +16,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry -from .deprecate import async_cleanup_deprecated from .entity import ( CoordinatedTPLinkFeatureEntity, TPLinkDataUpdateCoordinator, @@ -71,10 +70,10 @@ async def async_setup_entry( feature_type=Feature.Type.Choice, entity_class=TPLinkSelectEntity, descriptions=SELECT_DESCRIPTIONS_MAP, + platform_domain=SELECT_DOMAIN, known_child_device_ids=known_child_device_ids, first_check=first_check, ) - async_cleanup_deprecated(hass, SELECT_DOMAIN, config_entry.entry_id, entities) async_add_entities(entities) _check_device() diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 0898a3379d1..aaba6b2674d 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -19,7 +19,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry from .const import UNIT_MAPPING -from .deprecate import async_cleanup_deprecated from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription @@ -141,10 +140,10 @@ async def async_setup_entry( feature_type=Feature.Type.Sensor, entity_class=TPLinkSensorEntity, descriptions=SENSOR_DESCRIPTIONS_MAP, + platform_domain=SENSOR_DOMAIN, known_child_device_ids=known_child_device_ids, first_check=first_check, ) - async_cleanup_deprecated(hass, SENSOR_DOMAIN, config_entry.entry_id, entities) async_add_entities(entities) _check_device() diff --git a/homeassistant/components/tplink/siren.py b/homeassistant/components/tplink/siren.py index 0c15477ee78..5931a508d6c 100644 --- a/homeassistant/components/tplink/siren.py +++ b/homeassistant/components/tplink/siren.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -9,6 +10,7 @@ from kasa import Device, Module from kasa.smart.modules.alarm import Alarm from homeassistant.components.siren import ( + DOMAIN as SIREN_DOMAIN, SirenEntity, SirenEntityDescription, SirenEntityFeature, @@ -16,7 +18,7 @@ from homeassistant.components.siren import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TPLinkConfigEntry +from . import TPLinkConfigEntry, legacy_device_id from .coordinator import TPLinkDataUpdateCoordinator from .entity import ( CoordinatedTPLinkModuleEntity, @@ -35,6 +37,12 @@ class TPLinkSirenEntityDescription( ): """Base class for siren entity description.""" + unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = ( + lambda device, desc: legacy_device_id(device) + if desc.key == "siren" + else f"{legacy_device_id(device)}-{desc.key}" + ) + SIREN_DESCRIPTIONS: tuple[TPLinkSirenEntityDescription, ...] = ( TPLinkSirenEntityDescription( @@ -64,6 +72,7 @@ async def async_setup_entry( coordinator=parent_coordinator, entity_class=TPLinkSirenEntity, descriptions=SIREN_DESCRIPTIONS, + platform_domain=SIREN_DOMAIN, known_child_device_ids=known_child_device_ids, first_check=first_check, ) diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 92ecd7992de..04ca95273af 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -17,7 +17,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry -from .deprecate import async_cleanup_deprecated from .entity import ( CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription, @@ -100,10 +99,10 @@ async def async_setup_entry( feature_type=Feature.Switch, entity_class=TPLinkSwitch, descriptions=SWITCH_DESCRIPTIONS_MAP, + platform_domain=SWITCH_DOMAIN, known_child_device_ids=known_child_device_ids, first_check=first_check, ) - async_cleanup_deprecated(hass, SWITCH_DOMAIN, config_entry.entry_id, entities) async_add_entities(entities) _check_device() diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 23e36eacdd5..81ee679a251 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -197,10 +197,12 @@ def _mocked_device( mod.get_feature.side_effect = device_features.get mod.has_feature.side_effect = lambda id: id in device_features + device.parent = None device.children = [] if children: for child in children: child.mac = mac + child.parent = device device.children = children device.device_type = device_type if device_type else DeviceType.Unknown if ( diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index ef0ae3b6827..01f422636b2 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -1052,6 +1052,10 @@ async def test_automatic_module_device_addition_and_removal( ip_address=IP_ADDRESS3, mac=MAC_ADDRESS3, ) + # Set the parent property for the dynamic children as mock_device only sets + # it on initialization + for child in children.values(): + child.parent = mock_device with override_side_effect(mock_connect["connect"], lambda *_, **__: mock_device): mock_camera_config_entry.add_to_hass(hass) @@ -1150,3 +1154,73 @@ async def test_automatic_module_device_addition_and_removal( ) assert device_entry assert device_entry.via_device_id == parent_device.id + + +async def test_automatic_device_addition_does_not_remove_disabled_default( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, + mock_connect: AsyncMock, + mock_discovery: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for automatic device addition does not remove disabled default entities.""" + + features = ["ssid", "signal_level"] + children = { + f"child{index}": _mocked_device( + alias=f"child {index}", + features=features, + device_id=f"child{index}", + ) + for index in range(1, 5) + } + + mock_device = _mocked_device( + alias="hub", + children=[children["child1"], children["child2"]], + features=features, + device_type=DeviceType.Hub, + device_id="hub_parent", + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + ) + # Set the parent property for the dynamic children as mock_device only sets + # it on initialization + for child in children.values(): + child.parent = mock_device + + with override_side_effect(mock_connect["connect"], lambda *_, **__: mock_device): + mock_camera_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_camera_config_entry.entry_id) + await hass.async_block_till_done() + + def check_entities(entity_id_device): + entity_id = f"sensor.{entity_id_device}_signal_level" + state = hass.states.get(entity_id) + assert state + reg_ent = entity_registry.async_get(entity_id) + assert reg_ent + assert reg_ent.disabled is False + + entity_id = f"sensor.{entity_id_device}_ssid" + state = hass.states.get(entity_id) + assert state is None + reg_ent = entity_registry.async_get(entity_id) + assert reg_ent + assert reg_ent.disabled is True + assert reg_ent.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + check_entities("hub") + for child_id in (1, 2): + check_entities(f"child_{child_id}") + + # Add child devices + mock_device.children = [children["child1"], children["child2"], children["child3"]] + freezer.tick(5) + async_fire_time_changed(hass) + + check_entities("hub") + for child_id in (1, 2, 3): + check_entities(f"child_{child_id}") From 9d83bbfec67ecf0c66651f68748c95d0fb6923e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Jan 2025 08:52:40 -1000 Subject: [PATCH 0887/2987] Refactor modbus polling to prevent dupe updates and memory leak (#136211) --- .../components/modbus/binary_sensor.py | 5 +- homeassistant/components/modbus/climate.py | 13 ++-- homeassistant/components/modbus/cover.py | 9 +-- homeassistant/components/modbus/entity.py | 72 ++++++++++++++----- homeassistant/components/modbus/sensor.py | 3 +- 5 files changed, 64 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 97ade53762b..00ed9ccafb7 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import datetime import logging from typing import Any @@ -104,7 +103,7 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): if state := await self.async_get_last_state(): self._attr_is_on = state.state == STATE_ON - async def async_update(self, now: datetime | None = None) -> None: + async def _async_update(self) -> None: """Update the state of the sensor.""" # do not allow multiple active calls to the same platform @@ -126,7 +125,6 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): self._result = result.registers self._attr_is_on = bool(self._result[0] & 1) - self.async_write_ha_state() if self._coordinator: self._coordinator.async_set_updated_data(self._result) @@ -159,7 +157,6 @@ class SlaveSensor( """Handle entity which will be added.""" if state := await self.async_get_last_state(): self._attr_is_on = state.state == STATE_ON - self.async_write_ha_state() await super().async_added_to_hass() @callback diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index ba09bd08377..c0b09183ac2 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import datetime import logging import struct from typing import Any, cast @@ -313,7 +312,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): ) break - await self.async_update() + await self._async_update_write_state() async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" @@ -335,7 +334,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): CALL_TYPE_WRITE_REGISTER, ) - await self.async_update() + await self._async_update_write_state() async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target swing mode.""" @@ -358,7 +357,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): CALL_TYPE_WRITE_REGISTER, ) break - await self.async_update() + await self._async_update_write_state() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -413,9 +412,9 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): CALL_TYPE_WRITE_REGISTERS, ) self._attr_available = result is not None - await self.async_update() + await self._async_update_write_state() - async def async_update(self, now: datetime | None = None) -> None: + async def _async_update(self) -> None: """Update Target & Current Temperature.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval @@ -490,8 +489,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): if onoff == self._hvac_off_value: self._attr_hvac_mode = HVACMode.OFF - self.async_write_ha_state() - async def _async_read_register( self, register_type: str, register: int, raw: bool | None = False ) -> float | None: diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index eb9dac58900..0840f522b5d 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import datetime from typing import Any from homeassistant.components.cover import CoverEntity, CoverEntityFeature, CoverState @@ -117,7 +116,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): self._slave, self._write_address, self._state_open, self._write_type ) self._attr_available = result is not None - await self.async_update() + await self._async_update_write_state() async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" @@ -125,9 +124,9 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): self._slave, self._write_address, self._state_closed, self._write_type ) self._attr_available = result is not None - await self.async_update() + await self._async_update_write_state() - async def async_update(self, now: datetime | None = None) -> None: + async def _async_update(self) -> None: """Update the state of the cover.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval @@ -136,11 +135,9 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): ) if result is None: self._attr_available = False - self.async_write_ha_state() return self._attr_available = True if self._input_type == CALL_TYPE_COIL: self._set_attr_state(bool(result.bits[0] & 1)) else: self._set_attr_state(int(result.registers[0])) - self.async_write_ha_state() diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 03bcc98de40..b3d7a10a94a 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import abstractmethod +import asyncio from collections.abc import Callable from datetime import datetime, timedelta import logging @@ -107,37 +108,73 @@ class BasePlatform(Entity): self._max_value = get_optional_numeric_config(CONF_MAX_VALUE) self._nan_value = entry.get(CONF_NAN_VALUE) self._zero_suppress = get_optional_numeric_config(CONF_ZERO_SUPPRESS) + self._update_lock = asyncio.Lock() @abstractmethod - async def async_update(self, now: datetime | None = None) -> None: + async def _async_update(self) -> None: """Virtual function to be overwritten.""" + async def async_update(self, now: datetime | None = None) -> None: + """Update the entity state.""" + async with self._update_lock: + await self._async_update() + + async def _async_update_write_state(self) -> None: + """Update the entity state and write it to the state machine.""" + await self.async_update() + self.async_write_ha_state() + + async def _async_update_if_not_in_progress( + self, now: datetime | None = None + ) -> None: + """Update the entity state if not already in progress.""" + if self._update_lock.locked(): + _LOGGER.debug("Update for entity %s is already in progress", self.name) + return + await self._async_update_write_state() + @callback def async_run(self) -> None: """Remote start entity.""" - self.async_hold(update=False) - self._cancel_call = async_call_later( - self.hass, timedelta(milliseconds=100), self.async_update - ) + self._async_cancel_update_polling() + self._async_schedule_future_update(0.1) if self._scan_interval > 0: self._cancel_timer = async_track_time_interval( - self.hass, self.async_update, timedelta(seconds=self._scan_interval) + self.hass, + self._async_update_if_not_in_progress, + timedelta(seconds=self._scan_interval), ) self._attr_available = True self.async_write_ha_state() @callback - def async_hold(self, update: bool = True) -> None: - """Remote stop entity.""" + def _async_schedule_future_update(self, delay: float) -> None: + """Schedule an update in the future.""" + self._async_cancel_future_pending_update() + self._cancel_call = async_call_later( + self.hass, delay, self._async_update_if_not_in_progress + ) + + @callback + def _async_cancel_future_pending_update(self) -> None: + """Cancel a future pending update.""" if self._cancel_call: self._cancel_call() self._cancel_call = None + + def _async_cancel_update_polling(self) -> None: + """Cancel the polling.""" if self._cancel_timer: self._cancel_timer() self._cancel_timer = None - if update: - self._attr_available = False - self.async_write_ha_state() + + @callback + def async_hold(self) -> None: + """Remote stop entity.""" + self._async_cancel_future_pending_update() + self._async_cancel_update_polling() + self._attr_available = False + self.async_write_ha_state() async def async_base_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -312,6 +349,7 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): self._attr_is_on = True elif state.state == STATE_OFF: self._attr_is_on = False + await super().async_added_to_hass() async def async_turn(self, command: int) -> None: """Evaluate switch result.""" @@ -330,21 +368,21 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): return if self._verify_delay: - async_call_later(self.hass, self._verify_delay, self.async_update) - else: - await self.async_update() + self._async_schedule_future_update(self._verify_delay) + return + + await self._async_update_write_state() async def async_turn_off(self, **kwargs: Any) -> None: """Set switch off.""" await self.async_turn(self._command_off) - async def async_update(self, now: datetime | None = None) -> None: + async def _async_update(self) -> None: """Update the entity state.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval if not self._verify_active: self._attr_available = True - self.async_write_ha_state() return # do not allow multiple active calls to the same platform @@ -357,7 +395,6 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): self._call_active = False if result is None: self._attr_available = False - self.async_write_ha_state() return self._attr_available = True @@ -379,4 +416,3 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): self._verify_address, value, ) - self.async_write_ha_state() diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index d5a16c95cc4..2c2efb70d5a 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import datetime import logging from typing import Any @@ -106,7 +105,7 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): if state: self._attr_native_value = state.native_value - async def async_update(self, now: datetime | None = None) -> None: + async def _async_update(self) -> None: """Update the state of the sensor.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval From 507239c661058ef348d90d7aa3cabd4c15bef709 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Thu, 23 Jan 2025 12:52:56 -0600 Subject: [PATCH 0888/2987] Incorporate ControllerManager into HEOS Coordinator (#136302) * Integrate ControllerManager * Test for uncovered * Correct test docstring * Cast entry before graph access * Assert config_entry state in reauth * Use implicit casting --- homeassistant/components/heos/__init__.py | 96 +------------------ homeassistant/components/heos/config_flow.py | 20 ++-- homeassistant/components/heos/coordinator.py | 80 +++++++++++++++- .../components/heos/quality_scale.yaml | 5 +- homeassistant/components/heos/services.py | 2 +- tests/components/heos/test_config_flow.py | 5 +- tests/components/heos/test_media_player.py | 30 ++++++ 7 files changed, 129 insertions(+), 109 deletions(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index e8d875d283c..8ca2040fd2f 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -8,20 +8,13 @@ from datetime import timedelta import logging from typing import Any -from pyheos import ( - Heos, - HeosError, - HeosPlayer, - PlayerUpdateResult, - SignalHeosEvent, - const as heos_const, -) +from pyheos import Heos, HeosError, HeosPlayer, const as heos_const from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -54,7 +47,6 @@ class HeosRuntimeData: """Runtime data and coordinators for HEOS config entries.""" coordinator: HeosCoordinator - controller_manager: ControllerManager group_manager: GroupManager source_manager: SourceManager players: dict[int, HeosPlayer] @@ -95,16 +87,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool favorites = coordinator.favorites inputs = coordinator.inputs - controller_manager = ControllerManager(hass, controller) - await controller_manager.connect_listeners() - source_manager = SourceManager(favorites, inputs) source_manager.connect_update(hass, controller) group_manager = GroupManager(hass, controller, players) entry.runtime_data = HeosRuntimeData( - coordinator, controller_manager, group_manager, source_manager, players + coordinator, group_manager, source_manager, players ) group_manager.connect_update() @@ -120,85 +109,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> boo return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -class ControllerManager: - """Class that manages events of the controller.""" - - def __init__(self, hass: HomeAssistant, controller: Heos) -> None: - """Init the controller manager.""" - self._hass = hass - self._device_registry: dr.DeviceRegistry | None = None - self._entity_registry: er.EntityRegistry | None = None - self.controller = controller - - async def connect_listeners(self): - """Subscribe to events of interest.""" - self._device_registry = dr.async_get(self._hass) - self._entity_registry = er.async_get(self._hass) - - # Handle controller events - self.controller.add_on_controller_event(self._controller_event) - - # Handle connection-related events - self.controller.add_on_heos_event(self._heos_event) - - async def disconnect(self): - """Disconnect subscriptions.""" - self.controller.dispatcher.disconnect_all() - await self.controller.disconnect() - - async def _controller_event( - self, event: str, data: PlayerUpdateResult | None - ) -> None: - """Handle controller event.""" - if event == heos_const.EVENT_PLAYERS_CHANGED: - assert data is not None - self.update_ids(data.updated_player_ids) - # Update players - async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) - - async def _heos_event(self, event): - """Handle connection event.""" - if event == SignalHeosEvent.CONNECTED: - try: - # Retrieve latest players and refresh status - data = await self.controller.load_players() - self.update_ids(data.updated_player_ids) - except HeosError as ex: - _LOGGER.error("Unable to refresh players: %s", ex) - # Update players - _LOGGER.debug("HEOS Controller event called, calling dispatcher") - async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) - - def update_ids(self, mapped_ids: dict[int, int]): - """Update the IDs in the device and entity registry.""" - # mapped_ids contains the mapped IDs (new:old) - for old_id, new_id in mapped_ids.items(): - # update device registry - assert self._device_registry is not None - entry = self._device_registry.async_get_device( - identifiers={(DOMAIN, str(old_id))} - ) - new_identifiers = {(DOMAIN, str(new_id))} - if entry: - self._device_registry.async_update_device( - entry.id, - new_identifiers=new_identifiers, - ) - _LOGGER.debug( - "Updated device %s identifiers to %s", entry.id, new_identifiers - ) - # update entity registry - assert self._entity_registry is not None - entity_id = self._entity_registry.async_get_entity_id( - Platform.MEDIA_PLAYER, DOMAIN, str(old_id) - ) - if entity_id: - self._entity_registry.async_update_entity( - entity_id, new_unique_id=str(new_id) - ) - _LOGGER.debug("Updated entity %s unique id to %s", entity_id, new_id) - - class GroupManager: """Class that manages HEOS groups.""" diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index 86d5123bccf..335b64977b8 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -2,7 +2,7 @@ from collections.abc import Mapping import logging -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse from pyheos import CommandAuthenticationError, Heos, HeosError, HeosOptions @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ( ConfigEntry, + ConfigEntryState, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -22,6 +23,7 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) +from . import HeosConfigEntry from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -183,10 +185,12 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Validate account credentials and update options.""" errors: dict[str, str] = {} - entry = self._get_reauth_entry() + entry: HeosConfigEntry = self._get_reauth_entry() if user_input is not None: - heos = cast(Heos, entry.runtime_data.controller_manager.controller) - if await _validate_auth(user_input, heos, errors): + assert entry.state is ConfigEntryState.LOADED + if await _validate_auth( + user_input, entry.runtime_data.coordinator.heos, errors + ): return self.async_update_reload_and_abort(entry, options=user_input) return self.async_show_form( @@ -207,10 +211,10 @@ class HeosOptionsFlowHandler(OptionsFlow): """Manage the options.""" errors: dict[str, str] = {} if user_input is not None: - heos = cast( - Heos, self.config_entry.runtime_data.controller_manager.controller - ) - if await _validate_auth(user_input, heos, errors): + entry: HeosConfigEntry = self.config_entry + if await _validate_auth( + user_input, entry.runtime_data.coordinator.heos, errors + ): return self.async_create_entry(data=user_input) return self.async_show_form( diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 8ccae0f63b6..9a59b54f6a3 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -7,12 +7,21 @@ entities to update. Entities subscribe to entity-specific updates within the ent import logging -from pyheos import Credentials, Heos, HeosError, HeosOptions, MediaItem +from pyheos import ( + Credentials, + Heos, + HeosError, + HeosOptions, + MediaItem, + PlayerUpdateResult, + const, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import DOMAIN @@ -66,6 +75,10 @@ class HeosCoordinator(DataUpdateCoordinator[None]): ) # Retrieve initial data await self._async_update_sources() + # Attach event callbacks + self.heos.add_on_disconnected(self._async_on_disconnected) + self.heos.add_on_connected(self._async_on_reconnected) + self.heos.add_on_controller_event(self._async_on_controller_event) async def async_shutdown(self) -> None: """Disconnect all callbacks and disconnect from the device.""" @@ -78,6 +91,58 @@ class HeosCoordinator(DataUpdateCoordinator[None]): assert self.config_entry is not None self.config_entry.async_start_reauth(self.hass) + async def _async_on_disconnected(self) -> None: + """Handle when disconnected so entities are marked unavailable.""" + _LOGGER.warning("Connection to HEOS host %s lost", self.host) + self.async_update_listeners() + + async def _async_on_reconnected(self) -> None: + """Handle when reconnected so resources are updated and entities marked available.""" + await self._async_update_players() + _LOGGER.warning("Successfully reconnected to HEOS host %s", self.host) + self.async_update_listeners() + + async def _async_on_controller_event( + self, event: str, data: PlayerUpdateResult | None + ) -> 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_update_listeners() + + def _async_update_player_ids(self, updated_player_ids: dict[int, int]) -> None: + """Update the IDs in the device and entity registry.""" + device_registry = dr.async_get(self.hass) + entity_registry = er.async_get(self.hass) + # updated_player_ids contains the mapped IDs in format old:new + for old_id, new_id in updated_player_ids.items(): + # update device registry + entry = device_registry.async_get_device( + identifiers={(DOMAIN, str(old_id))} + ) + if entry: + new_identifiers = entry.identifiers.copy() + new_identifiers.remove((DOMAIN, str(old_id))) + new_identifiers.add((DOMAIN, str(new_id))) + device_registry.async_update_device( + entry.id, + new_identifiers=new_identifiers, + ) + _LOGGER.debug( + "Updated device %s identifiers to %s", entry.id, new_identifiers + ) + # update entity registry + entity_id = entity_registry.async_get_entity_id( + Platform.MEDIA_PLAYER, DOMAIN, str(old_id) + ) + if entity_id: + entity_registry.async_update_entity( + entity_id, new_unique_id=str(new_id) + ) + _LOGGER.debug("Updated entity %s unique id to %s", entity_id, new_id) + async def _async_update_sources(self) -> None: """Build source list for entities.""" # Get favorites only if reportedly signed in. @@ -91,3 +156,14 @@ class HeosCoordinator(DataUpdateCoordinator[None]): self.inputs = await self.heos.get_input_sources() except HeosError as error: _LOGGER.error("Unable to retrieve input sources: %s", error) + + async def _async_update_players(self) -> None: + """Update players after reconnection.""" + try: + player_updates = await self.heos.load_players() + 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) diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index 81162ab9b97..2cd0ccaf567 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -29,10 +29,7 @@ rules: docs-installation-parameters: done entity-unavailable: done integration-owner: done - log-when-unavailable: - status: todo - comment: | - The integration currently spams the logs until reconnected + log-when-unavailable: done parallel-updates: done reauthentication-flow: done test-coverage: diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index a780c26fca6..00be409869a 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -64,7 +64,7 @@ def _get_controller(hass: HomeAssistant) -> Heos: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="integration_not_loaded" ) - return entry.runtime_data.controller_manager.controller + return entry.runtime_data.coordinator.heos async def _sign_in_handler(service: ServiceCall) -> None: diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 2f01e70e2d1..39ede354496 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -4,7 +4,7 @@ from pyheos import CommandAuthenticationError, CommandFailedError, Heos, HeosErr import pytest from homeassistant.components.heos.const import DOMAIN -from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER +from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -358,6 +358,7 @@ async def test_reauth_signs_in_aborts( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) result = await config_entry.start_reauth_flow(hass) + assert config_entry.state is ConfigEntryState.LOADED assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} @@ -396,6 +397,7 @@ async def test_reauth_signs_out( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) result = await config_entry.start_reauth_flow(hass) + assert config_entry.state is ConfigEntryState.LOADED assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} @@ -447,6 +449,7 @@ async def test_reauth_flow_missing_one_param_recovers( # Start the options flow. Entry has not current options. result = await config_entry.start_reauth_flow(hass) + assert config_entry.state is ConfigEntryState.LOADED assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} assert result["type"] is FlowResultType.FORM diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 00082c77f0f..539b4584502 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -172,6 +172,36 @@ async def test_updates_from_connection_event( assert "Unable to refresh players" in caplog.text +async def test_updates_from_connection_event_new_player_ids( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + controller: Heos, + change_data_mapped_ids: PlayerUpdateResult, +) -> None: + """Test player ids changed after reconnection updates ids.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # Assert current IDs + assert device_registry.async_get_device(identifiers={(DOMAIN, "1")}) + assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "1") + + # Send event which will result in updated IDs. + controller.load_players.return_value = change_data_mapped_ids + await controller.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + ) + await hass.async_block_till_done() + + # Assert updated IDs and previous don't exist + assert not device_registry.async_get_device(identifiers={(DOMAIN, "1")}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "101")}) + assert not entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "1") + assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "101") + + async def test_updates_from_sources_updated( hass: HomeAssistant, config_entry: MockConfigEntry, From 2617575e183948c9d21d6d9f202e2f2347361ac0 Mon Sep 17 00:00:00 2001 From: Markus Lanthaler Date: Thu, 23 Jan 2025 20:23:03 +0100 Subject: [PATCH 0889/2987] Set Netgear device entities to unavailable when the device is not connected (#135362) --- homeassistant/components/netgear/router.py | 6 ++++++ homeassistant/components/netgear/sensor.py | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index 1e4bf2480e9..d81f556193b 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -210,6 +210,12 @@ class NetgearRouter: for device in self.devices.values(): device["active"] = now - device["last_seen"] <= self._consider_home + if not device["active"]: + device["link_rate"] = None + device["signal"] = None + device["ip"] = None + device["ssid"] = None + device["conn_ap_mac"] = None if new_device: _LOGGER.debug("Netgear tracker: new device found") diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 4751e58a6a1..d807f7aed0a 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -344,6 +344,11 @@ class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity): self._attr_unique_id = f"{self._mac}-{attribute}" self._state = device.get(attribute) + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._device.get(self._attribute) is not None + @property def native_value(self): """Return the state of the sensor.""" From b682495fda2519dd5d0036b2cf1836143dabe1cb Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Thu, 23 Jan 2025 20:36:59 +0100 Subject: [PATCH 0890/2987] Handle LinkPlay devices with no mac (#136272) Co-authored-by: J. Nick Koston --- homeassistant/components/linkplay/entity.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/linkplay/entity.py b/homeassistant/components/linkplay/entity.py index 00e2f39b233..74e067f5eb3 100644 --- a/homeassistant/components/linkplay/entity.py +++ b/homeassistant/components/linkplay/entity.py @@ -44,9 +44,15 @@ class LinkPlayBaseEntity(Entity): if model != MANUFACTURER_GENERIC: model_id = bridge.device.properties["project"] + connections: set[tuple[str, str]] = set() + if "MAC" in bridge.device.properties: + connections.add( + (dr.CONNECTION_NETWORK_MAC, bridge.device.properties["MAC"]) + ) + self._attr_device_info = dr.DeviceInfo( configuration_url=bridge.endpoint, - connections={(dr.CONNECTION_NETWORK_MAC, bridge.device.properties["MAC"])}, + connections=connections, hw_version=bridge.device.properties["hardware"], identifiers={(DOMAIN, bridge.device.uuid)}, manufacturer=manufacturer, From cd16a57e041fb958c728672725a2ab8169038cfb Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Thu, 23 Jan 2025 20:52:54 +0100 Subject: [PATCH 0891/2987] Bump powerfox to v1.2.1 (#136366) --- homeassistant/components/powerfox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/powerfox/manifest.json b/homeassistant/components/powerfox/manifest.json index bb72d73b5a8..3938eb01a1b 100644 --- a/homeassistant/components/powerfox/manifest.json +++ b/homeassistant/components/powerfox/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/powerfox", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["powerfox==1.2.0"], + "requirements": ["powerfox==1.2.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index c8386290b54..8f8c7008235 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1665,7 +1665,7 @@ pmsensor==0.4 poolsense==0.0.8 # homeassistant.components.powerfox -powerfox==1.2.0 +powerfox==1.2.1 # homeassistant.components.reddit praw==7.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 78adfd85cdc..3751dd24184 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1375,7 +1375,7 @@ plumlightpad==0.0.11 poolsense==0.0.8 # homeassistant.components.powerfox -powerfox==1.2.0 +powerfox==1.2.1 # homeassistant.components.reddit praw==7.5.0 From 0cd87cf3e938386196f225d19962e1a49e7c6989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Herv=C3=A9=20Cauwelier?= Date: Thu, 23 Jan 2025 21:51:01 +0100 Subject: [PATCH 0892/2987] holiday: asynchronously generate the entity name (#136354) Asking the country translation was trigerring Babel to open a file, and thus a blocking I/O. --- .../components/holiday/config_flow.py | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py index 6d29e09c0f8..538d9971109 100644 --- a/homeassistant/components/holiday/config_flow.py +++ b/homeassistant/components/holiday/config_flow.py @@ -89,6 +89,19 @@ def get_options_schema(country: str) -> vol.Schema: return vol.Schema(schema) +def get_entry_name(language: str, country: str, province: str | None) -> str: + """Generate the entity name from the user language and location.""" + try: + locale = Locale.parse(language, sep="-") + except UnknownLocaleError: + # Default to (US) English if language not recognized by babel + # Mainly an issue with English flavors such as "en-GB" + locale = Locale("en") + country_str = locale.territories[country] # blocking I/O + province_str = f", {province}" if province else "" + return f"{country_str}{province_str}" + + class HolidayConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Holiday.""" @@ -159,15 +172,9 @@ class HolidayConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({**data, **(options or {})}) - try: - locale = Locale.parse(self.hass.config.language, sep="-") - except UnknownLocaleError: - # Default to (US) English if language not recognized by babel - # Mainly an issue with English flavors such as "en-GB" - locale = Locale("en") - province_str = f", {province}" if province else "" - name = f"{locale.territories[country]}{province_str}" - + name = await self.hass.async_add_executor_job( + get_entry_name, self.hass.config.language, country, province + ) return self.async_create_entry(title=name, data=data, options=options) options_schema = await self.hass.async_add_executor_job( @@ -196,14 +203,9 @@ class HolidayConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({**data, **(options or {})}) - try: - locale = Locale.parse(self.hass.config.language, sep="-") - except UnknownLocaleError: - # Default to (US) English if language not recognized by babel - # Mainly an issue with English flavors such as "en-GB" - locale = Locale("en") - province_str = f", {province}" if province else "" - name = f"{locale.territories[country]}{province_str}" + name = await self.hass.async_add_executor_job( + get_entry_name, self.hass.config.language, country, province + ) if options: return self.async_update_reload_and_abort( From 5e34babc39ee9852505a4e1518a5e98477e435e1 Mon Sep 17 00:00:00 2001 From: Claudio Ruggeri - CR-Tech <41435902+crug80@users.noreply.github.com> Date: Thu, 23 Jan 2025 22:12:02 +0100 Subject: [PATCH 0893/2987] Fix slave id equal to 0 (#136263) Co-authored-by: J. Nick Koston --- homeassistant/components/modbus/entity.py | 5 ++- homeassistant/components/modbus/modbus.py | 4 +- tests/components/modbus/test_init.py | 53 +++++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index b3d7a10a94a..2d99d8f382c 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -80,7 +80,10 @@ class BasePlatform(Entity): """Initialize the Modbus binary sensor.""" self._hub = hub - self._slave = entry.get(CONF_SLAVE) or entry.get(CONF_DEVICE_ADDRESS, 0) + if (conf_slave := entry.get(CONF_SLAVE)) is not None: + self._slave = conf_slave + else: + self._slave = entry.get(CONF_DEVICE_ADDRESS, 1) self._address = int(entry[CONF_ADDRESS]) self._input_type = entry[CONF_INPUT_TYPE] self._value: str | None = None diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index c18a256a1cf..e9bd301c193 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -378,7 +378,9 @@ class ModbusHub: self, slave: int | None, address: int, value: int | list[int], use_call: str ) -> ModbusPDU | None: """Call sync. pymodbus.""" - kwargs: dict[str, Any] = {"slave": slave} if slave else {} + kwargs: dict[str, Any] = ( + {ATTR_SLAVE: slave} if slave is not None else {ATTR_SLAVE: 1} + ) entry = self._pb_request[use_call] kwargs[entry.value_attr_name] = value try: diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 616a7580e9d..e105818d193 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -1274,3 +1274,56 @@ async def test_no_entities(hass: HomeAssistant) -> None: ] } assert await async_setup_component(hass, DOMAIN, config) is False + + +@pytest.mark.parametrize( + ("do_config", "expected_slave_value"), + [ + ( + { + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 1234, + }, + ], + }, + 1, + ), + ( + { + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 1234, + CONF_SLAVE: 0, + }, + ], + }, + 0, + ), + ( + { + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 1234, + CONF_DEVICE_ADDRESS: 6, + }, + ], + }, + 6, + ), + ], +) +async def test_check_default_slave( + hass: HomeAssistant, + mock_modbus, + do_config, + mock_do_cycle, + expected_slave_value: int, +) -> None: + """Test default slave.""" + assert mock_modbus.read_holding_registers.mock_calls + first_call = mock_modbus.read_holding_registers.mock_calls[0] + assert first_call.kwargs["slave"] == expected_slave_value From a12255ea5d86064fa54938174aff0b9476cb3520 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Jan 2025 11:56:31 -1000 Subject: [PATCH 0894/2987] Migrate modbus to use HassKey (#136379) --- homeassistant/components/modbus/__init__.py | 18 +++++++----------- homeassistant/components/modbus/modbus.py | 14 ++++++++------ 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index a7b32119917..1a331e16482 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -from typing import cast import voluptuous as vol @@ -143,7 +142,7 @@ from .const import ( UDP, DataType, ) -from .modbus import ModbusHub, async_modbus_setup +from .modbus import DATA_MODBUS_HUBS, ModbusHub, async_modbus_setup from .validators import ( duplicate_fan_mode_validator, duplicate_swing_mode_validator, @@ -458,7 +457,7 @@ CONFIG_SCHEMA = vol.Schema( def get_hub(hass: HomeAssistant, name: str) -> ModbusHub: """Return modbus hub with name.""" - return cast(ModbusHub, hass.data[DOMAIN][name]) + return hass.data[DATA_MODBUS_HUBS][name] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -468,12 +467,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _reload_config(call: Event | ServiceCall) -> None: """Reload Modbus.""" - if DOMAIN not in hass.data: + if DATA_MODBUS_HUBS not in hass.data: _LOGGER.error("Modbus cannot reload, because it was never loaded") return - hubs = hass.data[DOMAIN] - for name in hubs: - await hubs[name].async_close() + hubs = hass.data[DATA_MODBUS_HUBS] + for hub in hubs.values(): + await hub.async_close() reset_platforms = async_get_platforms(hass, DOMAIN) for reset_platform in reset_platforms: _LOGGER.debug("Reload modbus resetting platform: %s", reset_platform.domain) @@ -487,7 +486,4 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config) - return await async_modbus_setup( - hass, - config, - ) + return await async_modbus_setup(hass, config) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index e9bd301c193..319c68f50f0 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -35,6 +35,7 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey from .const import ( ATTR_ADDRESS, @@ -70,6 +71,7 @@ from .const import ( from .validators import check_config _LOGGER = logging.getLogger(__name__) +DATA_MODBUS_HUBS: HassKey[dict[str, ModbusHub]] = HassKey(DOMAIN) ConfEntry = namedtuple("ConfEntry", "call_type attr func_name value_attr_name") # noqa: PYI024 @@ -136,14 +138,14 @@ async def async_modbus_setup( config[DOMAIN] = check_config(hass, config[DOMAIN]) if not config[DOMAIN]: return False - if DOMAIN in hass.data and config[DOMAIN] == []: - hubs = hass.data[DOMAIN] - for name in hubs: - if not await hubs[name].async_setup(): + if DATA_MODBUS_HUBS in hass.data and config[DOMAIN] == []: + hubs = hass.data[DATA_MODBUS_HUBS] + for hub in hubs.values(): + if not await hub.async_setup(): return False - hub_collect = hass.data[DOMAIN] + hub_collect = hass.data[DATA_MODBUS_HUBS] else: - hass.data[DOMAIN] = hub_collect = {} + hass.data[DATA_MODBUS_HUBS] = hub_collect = {} for conf_hub in config[DOMAIN]: my_hub = ModbusHub(hass, conf_hub) From 414fa4125efc2648322dd3195efc92e967594081 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 23 Jan 2025 16:03:48 -0600 Subject: [PATCH 0895/2987] Don't translate state names in default agent responses (#136382) Don't translate state names in responses --- .../components/conversation/default_agent.py | 39 +++---------------- .../conversation/test_default_agent.py | 28 ++++++++++++- 2 files changed, 33 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 1d79709adf8..bb815698941 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -798,36 +798,13 @@ class DefaultAgent(ConversationEntity): intent_response: intent.IntentResponse, recognize_result: RecognizeResult, ) -> str: - # Make copies of the states here so we can add translated names for responses. - matched = [ - state_copy - for state in intent_response.matched_states - if (state_copy := core.State.from_dict(state.as_dict())) - ] - unmatched = [ - state_copy - for state in intent_response.unmatched_states - if (state_copy := core.State.from_dict(state.as_dict())) - ] - all_states = matched + unmatched - domains = {state.domain for state in all_states} - translations = await translation.async_get_translations( - self.hass, language, "entity_component", domains - ) - - # Use translated state names - for state in all_states: - device_class = state.attributes.get("device_class", "_") - key = f"component.{state.domain}.entity_component.{device_class}.state.{state.state}" - state.state = translations.get(key, state.state) - # Get first matched or unmatched state. # This is available in the response template as "state". state1: core.State | None = None if intent_response.matched_states: - state1 = matched[0] + state1 = intent_response.matched_states[0] elif intent_response.unmatched_states: - state1 = unmatched[0] + state1 = intent_response.unmatched_states[0] # Render response template speech_slots = { @@ -849,11 +826,13 @@ class DefaultAgent(ConversationEntity): "query": { # Entity states that matched the query (e.g, "on") "matched": [ - template.TemplateState(self.hass, state) for state in matched + template.TemplateState(self.hass, state) + for state in intent_response.matched_states ], # Entity states that did not match the query "unmatched": [ - template.TemplateState(self.hass, state) for state in unmatched + template.TemplateState(self.hass, state) + for state in intent_response.unmatched_states ], }, } @@ -1506,12 +1485,6 @@ def _get_match_error_response( # Entity is not in correct state assert constraints.states state = next(iter(constraints.states)) - if constraints.domains: - # Translate if domain is available - domain = next(iter(constraints.domains)) - state = translation.async_translate_state( - hass, state, domain, None, None, None - ) return ErrorKey.ENTITY_WRONG_STATE, {"state": state} diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 80a056a6ea0..54aa30b3fcf 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -11,7 +11,7 @@ import pytest from syrupy import SnapshotAssertion import yaml -from homeassistant.components import conversation, cover, media_player +from homeassistant.components import conversation, cover, media_player, weather from homeassistant.components.conversation import default_agent from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY from homeassistant.components.conversation.default_agent import METADATA_CUSTOM_SENTENCE @@ -3152,3 +3152,29 @@ async def test_handle_intents_with_response_errors( assert response is not None and response.error_code == error_code else: assert response is None + + +@pytest.mark.usefixtures("init_components") +async def test_state_names_are_not_translated( + hass: HomeAssistant, + init_components: None, +) -> None: + """Test that state names are not translated in responses.""" + await async_setup_component(hass, "weather", {}) + + hass.states.async_set("weather.test_weather", weather.ATTR_CONDITION_PARTLYCLOUDY) + expose_entity(hass, "weather.test_weather", True) + + with patch( + "homeassistant.helpers.template.Template.async_render" + ) as mock_async_render: + result = await conversation.async_converse( + hass, "what is the weather like?", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER + mock_async_render.assert_called_once() + + assert ( + mock_async_render.call_args.args[0]["state"].state + == weather.ATTR_CONDITION_PARTLYCLOUDY + ) From 005ae3ace66b7661042c472d82e98dd161d7caf1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 23 Jan 2025 17:54:04 -0500 Subject: [PATCH 0896/2987] Allow LLMs to get calendar events from exposed calendars (#136304) --- homeassistant/helpers/llm.py | 74 +++++++++++++++++++++++++- tests/helpers/test_llm.py | 100 ++++++++++++++++++++++++++++++++++- 2 files changed, 170 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index abad11bb36e..ea376923f9d 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -5,15 +5,20 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass +from datetime import timedelta from decimal import Decimal from enum import Enum from functools import cache, partial -from typing import Any +from typing import Any, cast import slugify as unicode_slug import voluptuous as vol from voluptuous_openapi import UNSUPPORTED, convert +from homeassistant.components.calendar import ( + DOMAIN as CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, +) from homeassistant.components.climate import INTENT_GET_TEMPERATURE from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER from homeassistant.components.homeassistant import async_should_expose @@ -28,7 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, HomeAssistant, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import yaml as yaml_util +from homeassistant.util import dt as dt_util, yaml as yaml_util from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import JsonObjectType @@ -415,6 +420,8 @@ class AssistAPI(API): IntentTool(self.cached_slugify(intent_handler.intent_type), intent_handler) for intent_handler in intent_handlers ] + if exposed_domains and CALENDAR_DOMAIN in exposed_domains: + tools.append(CalendarGetEventsTool()) if llm_context.assistant is not None: for state in self.hass.states.async_all(SCRIPT_DOMAIN): @@ -755,3 +762,66 @@ class ScriptTool(Tool): ) return {"success": True, "result": result} + + +class CalendarGetEventsTool(Tool): + """LLM Tool allowing querying a calendar.""" + + name = "calendar_get_events" + description = ( + "Get events from a calendar. " + "When asked when something happens, search the whole week. " + "Results are RFC 5545 which means 'end' is exclusive." + ) + parameters = vol.Schema( + { + vol.Required("calendar"): cv.string, + vol.Required("range"): vol.In(["today", "week"]), + } + ) + + async def async_call( + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext + ) -> JsonObjectType: + """Query a calendar.""" + data = self.parameters(tool_input.tool_args) + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name=data["calendar"], + domains=[CALENDAR_DOMAIN], + assistant=llm_context.assistant, + ), + ) + if not result.is_match: + return {"success": False, "error": "Calendar not found"} + + entity_id = result.states[0].entity_id + if data["range"] == "today": + start = dt_util.now() + end = dt_util.start_of_local_day() + timedelta(days=1) + elif data["range"] == "week": + start = dt_util.now() + end = dt_util.start_of_local_day() + timedelta(days=7) + + service_data = { + "entity_id": entity_id, + "start_date_time": start.isoformat(), + "end_date_time": end.isoformat(), + } + + service_result = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + service_data, + context=llm_context.context, + blocking=True, + return_response=True, + ) + + events = [ + event if "T" in event["start"] else {**event, "all_day": True} + for event in cast(dict, service_result)[entity_id]["events"] + ] + + return {"success": True, "result": events} diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 5348348bb0d..57e151ba8eb 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1,15 +1,17 @@ """Tests for the llm helpers.""" +from datetime import timedelta from decimal import Decimal from unittest.mock import patch import pytest import voluptuous as vol +from homeassistant.components import calendar from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.intent import async_register_timer_handler from homeassistant.components.script.config import ScriptConfig -from homeassistant.core import Context, HomeAssistant, State +from homeassistant.core import Context, HomeAssistant, State, SupportsResponse from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, @@ -22,8 +24,9 @@ from homeassistant.helpers import ( selector, ) from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_mock_service @pytest.fixture @@ -1162,3 +1165,96 @@ async def test_selector_serializer( assert selector_serializer(selector.FileSelector({"accept": ".txt"})) == { "type": "string" } + + +async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: + """Test the calendar get events tool.""" + assert await async_setup_component(hass, "homeassistant", {}) + hass.states.async_set("calendar.test_calendar", "on", {"friendly_name": "Test"}) + async_expose_entity(hass, "conversation", "calendar.test_calendar", True) + context = Context() + llm_context = llm.LLMContext( + platform="test_platform", + context=context, + user_prompt="test_text", + language="*", + assistant="conversation", + device_id=None, + ) + api = await llm.async_get_api(hass, "assist", llm_context) + assert [tool for tool in api.tools if tool.name == "calendar_get_events"] + + calls = async_mock_service( + hass, + domain=calendar.DOMAIN, + service=calendar.SERVICE_GET_EVENTS, + schema=calendar.SERVICE_GET_EVENTS_SCHEMA, + response={ + "calendar.test_calendar": { + "events": [ + { + "start": "2025-09-17", + "end": "2025-09-18", + "summary": "Home Assistant 12th birthday", + "description": "", + }, + { + "start": "2025-09-17T14:00:00-05:00", + "end": "2025-09-18T15:00:00-05:00", + "summary": "Champagne", + "description": "", + }, + ] + } + }, + supports_response=SupportsResponse.ONLY, + ) + + tool_input = llm.ToolInput( + tool_name="calendar_get_events", + tool_args={"calendar": "calendar.test_calendar", "range": "today"}, + ) + now = dt_util.now() + with patch("homeassistant.util.dt.now", return_value=now): + response = await api.async_call_tool(tool_input) + + assert len(calls) == 1 + call = calls[0] + assert call.domain == calendar.DOMAIN + assert call.service == calendar.SERVICE_GET_EVENTS + assert call.data == { + "entity_id": ["calendar.test_calendar"], + "start_date_time": now, + "end_date_time": dt_util.start_of_local_day() + timedelta(days=1), + } + + assert response == { + "success": True, + "result": [ + { + "start": "2025-09-17", + "end": "2025-09-18", + "summary": "Home Assistant 12th birthday", + "description": "", + "all_day": True, + }, + { + "start": "2025-09-17T14:00:00-05:00", + "end": "2025-09-18T15:00:00-05:00", + "summary": "Champagne", + "description": "", + }, + ], + } + + tool_input.tool_args["range"] = "week" + with patch("homeassistant.util.dt.now", return_value=now): + response = await api.async_call_tool(tool_input) + + assert len(calls) == 2 + call = calls[1] + assert call.data == { + "entity_id": ["calendar.test_calendar"], + "start_date_time": now, + "end_date_time": dt_util.start_of_local_day() + timedelta(days=7), + } From a70a9d2f7630f737304934c464558c0efbbf6a81 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jan 2025 00:12:08 +0100 Subject: [PATCH 0897/2987] Use runtime_data in coinbase (#136381) --- homeassistant/components/coinbase/__init__.py | 23 ++++++++----------- .../components/coinbase/config_flow.py | 17 ++++++-------- .../components/coinbase/diagnostics.py | 11 ++++----- homeassistant/components/coinbase/sensor.py | 7 +++--- 4 files changed, 24 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index 6aa33a7c14d..a29154d9c1b 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -37,7 +37,6 @@ from .const import ( CONF_CURRENCIES, CONF_EXCHANGE_BASE, CONF_EXCHANGE_RATES, - DOMAIN, ) _LOGGER = logging.getLogger(__name__) @@ -45,33 +44,29 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) +type CoinbaseConfigEntry = ConfigEntry[CoinbaseData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: CoinbaseConfigEntry) -> bool: """Set up Coinbase from a config entry.""" instance = await hass.async_add_executor_job(create_and_update_instance, entry) entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.data.setdefault(DOMAIN, {}) - - hass.data[DOMAIN][entry.entry_id] = instance + entry.runtime_data = instance 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: CoinbaseConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -def create_and_update_instance(entry: ConfigEntry) -> CoinbaseData: +def create_and_update_instance(entry: CoinbaseConfigEntry) -> CoinbaseData: """Create and update a Coinbase Data instance.""" if "organizations" not in entry.data[CONF_API_KEY]: client = LegacyClient(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN]) @@ -87,7 +82,9 @@ def create_and_update_instance(entry: ConfigEntry) -> CoinbaseData: return instance -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: CoinbaseConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 8b7b4b9e313..2b58f2b2f37 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -11,18 +11,13 @@ from coinbase.wallet.client import Client as LegacyClient from coinbase.wallet.error import AuthenticationError import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv -from . import get_accounts +from . import CoinbaseConfigEntry, get_accounts from .const import ( ACCOUNT_IS_VAULT, API_ACCOUNT_CURRENCY, @@ -83,10 +78,12 @@ async def validate_api(hass: HomeAssistant, data): return {"title": user, "api_version": api_version} -async def validate_options(hass: HomeAssistant, config_entry: ConfigEntry, options): +async def validate_options( + hass: HomeAssistant, config_entry: CoinbaseConfigEntry, options +): """Validate the requested resources are provided by API.""" - client = hass.data[DOMAIN][config_entry.entry_id].client + client = config_entry.runtime_data.client accounts = await hass.async_add_executor_job( get_accounts, client, config_entry.data.get("api_version", "v2") @@ -155,7 +152,7 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: CoinbaseConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() diff --git a/homeassistant/components/coinbase/diagnostics.py b/homeassistant/components/coinbase/diagnostics.py index 674ce9dca28..f391b1a14f5 100644 --- a/homeassistant/components/coinbase/diagnostics.py +++ b/homeassistant/components/coinbase/diagnostics.py @@ -3,12 +3,11 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_ID from homeassistant.core import HomeAssistant -from . import CoinbaseData -from .const import API_ACCOUNT_AMOUNT, API_RESOURCE_PATH, CONF_TITLE, DOMAIN +from . import CoinbaseConfigEntry +from .const import API_ACCOUNT_AMOUNT, API_RESOURCE_PATH, CONF_TITLE TO_REDACT = { API_ACCOUNT_AMOUNT, @@ -21,15 +20,13 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: CoinbaseConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - instance: CoinbaseData = hass.data[DOMAIN][entry.entry_id] - return async_redact_data( { "entry": entry.as_dict(), - "accounts": instance.accounts, + "accounts": entry.runtime_data.accounts, }, TO_REDACT, ) diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index d3f3c81fb0c..37509160247 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -5,12 +5,11 @@ from __future__ import annotations import logging from homeassistant.components.sensor import SensorEntity, SensorStateClass -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import CoinbaseData +from . import CoinbaseConfigEntry, CoinbaseData from .const import ( ACCOUNT_IS_VAULT, API_ACCOUNT_AMOUNT, @@ -45,11 +44,11 @@ ATTRIBUTION = "Data provided by coinbase.com" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: CoinbaseConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Coinbase sensor platform.""" - instance: CoinbaseData = hass.data[DOMAIN][config_entry.entry_id] + instance = config_entry.runtime_data entities: list[SensorEntity] = [] From 1593b40f52bfd656222a84c315f7951badf3fd94 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jan 2025 00:49:31 +0100 Subject: [PATCH 0898/2987] Use runtime_data in daikin (#136376) --- homeassistant/components/daikin/__init__.py | 22 +++++++------------ homeassistant/components/daikin/climate.py | 10 ++++----- .../components/daikin/coordinator.py | 8 ++++++- homeassistant/components/daikin/sensor.py | 10 ++++----- homeassistant/components/daikin/switch.py | 10 ++++----- 5 files changed, 30 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index c58578071ee..0eaffa39ee9 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -9,7 +9,6 @@ from aiohttp import ClientConnectionError from pydaikin.daikin_base import Appliance from pydaikin.factory import DaikinFactory -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -23,8 +22,8 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from .const import DOMAIN, KEY_MAC, TIMEOUT -from .coordinator import DaikinCoordinator +from .const import KEY_MAC, TIMEOUT +from .coordinator import DaikinConfigEntry, DaikinCoordinator _LOGGER = logging.getLogger(__name__) @@ -32,7 +31,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bool: """Establish connection with Daikin.""" conf = entry.data # For backwards compat, set unique ID @@ -58,29 +57,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("ClientConnectionError to %s", host) raise ConfigEntryNotReady from err - coordinator = DaikinCoordinator(hass, device) + coordinator = DaikinCoordinator(hass, entry, device) await coordinator.async_config_entry_first_refresh() await async_migrate_unique_id(hass, entry, device) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = 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: DaikinConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_migrate_unique_id( - hass: HomeAssistant, config_entry: ConfigEntry, device: Appliance + hass: HomeAssistant, config_entry: DaikinConfigEntry, device: Appliance ) -> None: """Migrate old entry.""" dev_reg = dr.async_get(hass) diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index 751683656f2..06ee0a03860 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -19,12 +19,10 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN as DAIKIN_DOMAIN from .const import ( ATTR_INSIDE_TEMPERATURE, ATTR_OUTSIDE_TEMPERATURE, @@ -32,7 +30,7 @@ from .const import ( ATTR_STATE_ON, ATTR_TARGET_TEMPERATURE, ) -from .coordinator import DaikinCoordinator +from .coordinator import DaikinConfigEntry, DaikinCoordinator from .entity import DaikinEntity _LOGGER = logging.getLogger(__name__) @@ -83,10 +81,12 @@ DAIKIN_ATTR_ADVANCED = "adv" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DaikinConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Daikin climate based on config_entry.""" - daikin_api = hass.data[DAIKIN_DOMAIN].get(entry.entry_id) + daikin_api = entry.runtime_data async_add_entities([DaikinClimate(daikin_api)]) diff --git a/homeassistant/components/daikin/coordinator.py b/homeassistant/components/daikin/coordinator.py index 35d998b4ba2..8e1713af5b2 100644 --- a/homeassistant/components/daikin/coordinator.py +++ b/homeassistant/components/daikin/coordinator.py @@ -5,6 +5,7 @@ import logging from pydaikin.daikin_base import Appliance +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -12,15 +13,20 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type DaikinConfigEntry = ConfigEntry[DaikinCoordinator] + class DaikinCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching Daikin data.""" - def __init__(self, hass: HomeAssistant, device: Appliance) -> None: + def __init__( + self, hass: HomeAssistant, entry: DaikinConfigEntry, device: Appliance + ) -> None: """Initialize global Daikin data updater.""" super().__init__( hass, _LOGGER, + config_entry=entry, name=device.values.get("name", DOMAIN), update_interval=timedelta(seconds=60), ) diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index d2d6ef02fc3..982aac1f3f2 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UnitOfEnergy, @@ -24,7 +23,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN as DAIKIN_DOMAIN from .const import ( ATTR_COMPRESSOR_FREQUENCY, ATTR_COOL_ENERGY, @@ -37,7 +35,7 @@ from .const import ( ATTR_TOTAL_ENERGY_TODAY, ATTR_TOTAL_POWER, ) -from .coordinator import DaikinCoordinator +from .coordinator import DaikinConfigEntry, DaikinCoordinator from .entity import DaikinEntity @@ -134,10 +132,12 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DaikinConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Daikin climate based on config_entry.""" - daikin_api = hass.data[DAIKIN_DOMAIN].get(entry.entry_id) + daikin_api = entry.runtime_data sensors = [ATTR_INSIDE_TEMPERATURE] if daikin_api.device.support_outside_temperature: sensors.append(ATTR_OUTSIDE_TEMPERATURE) diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 669048ac45e..8a3a15d367f 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -5,12 +5,10 @@ from __future__ import annotations from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN -from .coordinator import DaikinCoordinator +from .coordinator import DaikinConfigEntry, DaikinCoordinator from .entity import DaikinEntity DAIKIN_ATTR_ADVANCED = "adv" @@ -19,10 +17,12 @@ DAIKIN_ATTR_MODE = "mode" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DaikinConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Daikin climate based on config_entry.""" - daikin_api: DaikinCoordinator = hass.data[DOMAIN][entry.entry_id] + daikin_api = entry.runtime_data switches: list[SwitchEntity] = [] if zones := daikin_api.device.zones: switches.extend( From c691f8cc1e6ea333638431541aa7cbd0d04dde6d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jan 2025 00:50:36 +0100 Subject: [PATCH 0899/2987] Use runtime_data in comelit (#136384) --- homeassistant/components/comelit/__init__.py | 21 ++++++----- .../components/comelit/alarm_control_panel.py | 9 +++-- .../components/comelit/binary_sensor.py | 10 +++--- homeassistant/components/comelit/climate.py | 10 +++--- .../components/comelit/coordinator.py | 35 ++++++++++++++----- homeassistant/components/comelit/cover.py | 10 +++--- .../components/comelit/diagnostics.py | 8 ++--- .../components/comelit/humidifier.py | 9 +++-- homeassistant/components/comelit/light.py | 10 +++--- homeassistant/components/comelit/sensor.py | 16 ++++----- homeassistant/components/comelit/switch.py | 10 +++--- 11 files changed, 79 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index 12f28ef206d..60a4e40140d 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -2,12 +2,16 @@ from aiocomelit.const import BRIDGE -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE, Platform from homeassistant.core import HomeAssistant -from .const import DEFAULT_PORT, DOMAIN -from .coordinator import ComelitBaseCoordinator, ComelitSerialBridge, ComelitVedoSystem +from .const import DEFAULT_PORT +from .coordinator import ( + ComelitBaseCoordinator, + ComelitConfigEntry, + ComelitSerialBridge, + ComelitVedoSystem, +) BRIDGE_PLATFORMS = [ Platform.CLIMATE, @@ -24,13 +28,14 @@ VEDO_PLATFORMS = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> bool: """Set up Comelit platform.""" coordinator: ComelitBaseCoordinator if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE: coordinator = ComelitSerialBridge( hass, + entry, entry.data[CONF_HOST], entry.data.get(CONF_PORT, DEFAULT_PORT), entry.data[CONF_PIN], @@ -39,6 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: else: coordinator = ComelitVedoSystem( hass, + entry, entry.data[CONF_HOST], entry.data.get(CONF_PORT, DEFAULT_PORT), entry.data[CONF_PIN], @@ -47,14 +53,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = 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: ComelitConfigEntry) -> bool: """Unload a config entry.""" if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE: @@ -62,10 +68,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: else: platforms = VEDO_PLATFORMS - coordinator: ComelitBaseCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms): await coordinator.api.logout() await coordinator.api.close() - hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py index b3bd6664bf8..f694c2b392b 100644 --- a/homeassistant/components/comelit/alarm_control_panel.py +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import cast from aiocomelit.api import ComelitVedoAreaObject from aiocomelit.const import ALARM_AREAS, AlarmAreaState @@ -13,13 +14,11 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, CodeFormat, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import ComelitVedoSystem +from .coordinator import ComelitConfigEntry, ComelitVedoSystem _LOGGER = logging.getLogger(__name__) @@ -48,12 +47,12 @@ ALARM_AREA_ARMED_STATUS: dict[str, int] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Comelit VEDO system alarm control panel devices.""" - coordinator: ComelitVedoSystem = hass.data[DOMAIN][config_entry.entry_id] + coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) async_add_entities( ComelitAlarmEntity(coordinator, device, config_entry.entry_id) diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py index 30b642584f8..fa51e0b1fda 100644 --- a/homeassistant/components/comelit/binary_sensor.py +++ b/homeassistant/components/comelit/binary_sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import cast + from aiocomelit import ComelitVedoZoneObject from aiocomelit.const import ALARM_ZONES @@ -9,23 +11,21 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import ComelitVedoSystem +from .coordinator import ComelitConfigEntry, ComelitVedoSystem async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Comelit VEDO presence sensors.""" - coordinator: ComelitVedoSystem = hass.data[DOMAIN][config_entry.entry_id] + coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) async_add_entities( ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id) diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 6dc7c7e26d9..1baa777bf99 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import StrEnum -from typing import Any +from typing import Any, cast from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import CLIMATE @@ -15,14 +15,12 @@ from homeassistant.components.climate import ( HVACMode, UnitOfTemperature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import ComelitSerialBridge +from .coordinator import ComelitConfigEntry, ComelitSerialBridge class ClimaComelitMode(StrEnum): @@ -72,12 +70,12 @@ MODE_TO_ACTION: dict[HVACMode, ClimaComelitCommand] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Comelit climates.""" - coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) async_add_entities( ComelitClimateEntity(coordinator, device, config_entry.entry_id) diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 807f389a6d3..fcb149b21d6 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -23,15 +23,19 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import _LOGGER, DOMAIN +type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator] + class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Base coordinator for Comelit Devices.""" _hw_version: str - config_entry: ConfigEntry + config_entry: ComelitConfigEntry api: ComelitCommonApi - def __init__(self, hass: HomeAssistant, device: str, host: str) -> None: + def __init__( + self, hass: HomeAssistant, entry: ComelitConfigEntry, device: str, host: str + ) -> None: """Initialize the scanner.""" self._device = device @@ -40,13 +44,14 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass=hass, logger=_LOGGER, + config_entry=entry, name=f"{DOMAIN}-{host}-coordinator", update_interval=timedelta(seconds=5), ) device_registry = dr.async_get(self.hass) device_registry.async_get_or_create( - config_entry_id=self.config_entry.entry_id, - identifiers={(DOMAIN, self.config_entry.entry_id)}, + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.entry_id)}, model=device, name=f"{device} ({self._host})", manufacturer="Comelit", @@ -98,10 +103,17 @@ class ComelitSerialBridge(ComelitBaseCoordinator): _hw_version = "20003101" api: ComeliteSerialBridgeApi - def __init__(self, hass: HomeAssistant, host: str, port: int, pin: int) -> None: + def __init__( + self, + hass: HomeAssistant, + entry: ComelitConfigEntry, + host: str, + port: int, + pin: int, + ) -> None: """Initialize the scanner.""" self.api = ComeliteSerialBridgeApi(host, port, pin) - super().__init__(hass, BRIDGE, host) + super().__init__(hass, entry, BRIDGE, host) async def _async_update_system_data(self) -> dict[str, Any]: """Specific method for updating data.""" @@ -114,10 +126,17 @@ class ComelitVedoSystem(ComelitBaseCoordinator): _hw_version = "VEDO IP" api: ComelitVedoApi - def __init__(self, hass: HomeAssistant, host: str, port: int, pin: int) -> None: + def __init__( + self, + hass: HomeAssistant, + entry: ComelitConfigEntry, + host: str, + port: int, + pin: int, + ) -> None: """Initialize the scanner.""" self.api = ComelitVedoApi(host, port, pin) - super().__init__(hass, VEDO, host) + super().__init__(hass, entry, VEDO, host) async def _async_update_system_data(self) -> dict[str, Any]: """Specific method for updating data.""" diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 5169217ebc5..abb84824621 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -2,30 +2,28 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import ComelitSerialBridge +from .coordinator import ComelitConfigEntry, ComelitSerialBridge async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Comelit covers.""" - coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) async_add_entities( ComelitCoverEntity(coordinator, device, config_entry.entry_id) diff --git a/homeassistant/components/comelit/diagnostics.py b/homeassistant/components/comelit/diagnostics.py index afa57831eae..547735f3879 100644 --- a/homeassistant/components/comelit/diagnostics.py +++ b/homeassistant/components/comelit/diagnostics.py @@ -12,22 +12,20 @@ from aiocomelit import ( from aiocomelit.const import BRIDGE from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PIN, CONF_TYPE from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import ComelitBaseCoordinator +from .coordinator import ComelitConfigEntry TO_REDACT = {CONF_PIN} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: ComelitConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: ComelitBaseCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data dev_list: list[dict[str, Any]] = [] dev_type_list: list[dict[int, Any]] = [] diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index e7857535c78..d8058074c16 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import StrEnum -from typing import Any +from typing import Any, cast from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import CLIMATE @@ -16,14 +16,13 @@ from homeassistant.components.humidifier import ( HumidifierEntity, HumidifierEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import ComelitSerialBridge +from .coordinator import ComelitConfigEntry, ComelitSerialBridge class HumidifierComelitMode(StrEnum): @@ -55,12 +54,12 @@ MODE_TO_ACTION: dict[str, HumidifierComelitCommand] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Comelit humidifiers.""" - coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) entities: list[ComelitHumidifierEntity] = [] for device in coordinator.data[CLIMATE].values(): diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index bb5eb5fa160..9736c9ac2a0 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -2,29 +2,27 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import LIGHT, STATE_OFF, STATE_ON from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import ComelitSerialBridge +from .coordinator import ComelitConfigEntry, ComelitSerialBridge async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Comelit lights.""" - coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) async_add_entities( ComelitLightEntity(coordinator, device, config_entry.entry_id) diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index a86d49d73e9..efb2418244e 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Final +from typing import Final, cast from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject from aiocomelit.const import ALARM_ZONES, BRIDGE, OTHER, AlarmZoneState @@ -12,15 +12,13 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TYPE, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import ComelitSerialBridge, ComelitVedoSystem +from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem SENSOR_BRIDGE_TYPES: Final = ( SensorEntityDescription( @@ -43,7 +41,7 @@ SENSOR_VEDO_TYPES: Final = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Comelit sensors.""" @@ -56,12 +54,12 @@ async def async_setup_entry( async def async_setup_bridge_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Comelit Bridge sensors.""" - coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) entities: list[ComelitBridgeSensorEntity] = [] for device in coordinator.data[OTHER].values(): @@ -76,12 +74,12 @@ async def async_setup_bridge_entry( async def async_setup_vedo_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Comelit VEDO sensors.""" - coordinator: ComelitVedoSystem = hass.data[DOMAIN][config_entry.entry_id] + coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) entities: list[ComelitVedoSensorEntity] = [] for device in coordinator.data[ALARM_ZONES].values(): diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index 68ba934adb6..26d3b81ebde 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -2,29 +2,27 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import IRRIGATION, OTHER, STATE_OFF, STATE_ON from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import ComelitSerialBridge +from .coordinator import ComelitConfigEntry, ComelitSerialBridge async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Comelit switches.""" - coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) entities: list[ComelitSwitchEntity] = [] entities.extend( From 3bbcd37ec8c7ad911ba52da454277df155f12c70 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jan 2025 01:02:38 +0100 Subject: [PATCH 0900/2987] Use runtime_data in ccm15 (#136378) --- homeassistant/components/ccm15/__init__.py | 16 ++++++---------- homeassistant/components/ccm15/climate.py | 7 +++---- homeassistant/components/ccm15/coordinator.py | 8 +++++++- homeassistant/components/ccm15/diagnostics.py | 8 +++----- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/ccm15/__init__.py b/homeassistant/components/ccm15/__init__.py index a35568047ad..eae5d095ce7 100644 --- a/homeassistant/components/ccm15/__init__.py +++ b/homeassistant/components/ccm15/__init__.py @@ -2,34 +2,30 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import CCM15Coordinator +from .coordinator import CCM15ConfigEntry, CCM15Coordinator PLATFORMS: list[Platform] = [Platform.CLIMATE] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: CCM15ConfigEntry) -> bool: """Set up Midea ccm15 AC Controller from a config entry.""" coordinator = CCM15Coordinator( hass, + entry, entry.data[CONF_HOST], entry.data[CONF_PORT], ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = 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: CCM15ConfigEntry) -> 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(entry, PLATFORMS) diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py index 3db8c3e1016..099b91ec02c 100644 --- a/homeassistant/components/ccm15/climate.py +++ b/homeassistant/components/ccm15/climate.py @@ -17,7 +17,6 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -25,18 +24,18 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONST_CMD_FAN_MAP, CONST_CMD_STATE_MAP, DOMAIN -from .coordinator import CCM15Coordinator +from .coordinator import CCM15ConfigEntry, CCM15Coordinator _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: CCM15ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up all climate.""" - coordinator: CCM15Coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data ac_data: CCM15DeviceState = coordinator.data entities = [ diff --git a/homeassistant/components/ccm15/coordinator.py b/homeassistant/components/ccm15/coordinator.py index cd3b313f700..03a59aa3f24 100644 --- a/homeassistant/components/ccm15/coordinator.py +++ b/homeassistant/components/ccm15/coordinator.py @@ -7,6 +7,7 @@ from ccm15 import CCM15Device, CCM15DeviceState, CCM15SlaveDevice import httpx from homeassistant.components.climate import HVACMode +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -19,15 +20,20 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type CCM15ConfigEntry = ConfigEntry[CCM15Coordinator] + class CCM15Coordinator(DataUpdateCoordinator[CCM15DeviceState]): """Class to coordinate multiple CCM15Climate devices.""" - def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + def __init__( + self, hass: HomeAssistant, entry: CCM15ConfigEntry, host: str, port: int + ) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, name=host, update_interval=datetime.timedelta(seconds=DEFAULT_INTERVAL), ) diff --git a/homeassistant/components/ccm15/diagnostics.py b/homeassistant/components/ccm15/diagnostics.py index 08cc239e972..c259e7f35c9 100644 --- a/homeassistant/components/ccm15/diagnostics.py +++ b/homeassistant/components/ccm15/diagnostics.py @@ -4,18 +4,16 @@ from __future__ import annotations from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import CCM15Coordinator +from .coordinator import CCM15ConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: CCM15ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: CCM15Coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data return { str(device_id): { From fe67069c9151960ac8c3848a8d2abe5d4dce8667 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 24 Jan 2025 02:07:24 +0200 Subject: [PATCH 0901/2987] Add translated action exceptions to LG webOS TV (#136397) * Add translated action exceptions to LG webOS TV * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/webostv/__init__.py | 18 --- .../components/webostv/config_flow.py | 35 ++++-- .../components/webostv/device_trigger.py | 8 +- homeassistant/components/webostv/helpers.py | 11 +- .../components/webostv/media_player.py | 32 +++-- homeassistant/components/webostv/notify.py | 59 ++++++---- .../components/webostv/quality_scale.yaml | 2 +- homeassistant/components/webostv/strings.json | 23 +++- tests/components/webostv/conftest.py | 12 +- tests/components/webostv/test_config_flow.py | 17 ++- .../components/webostv/test_device_trigger.py | 2 +- tests/components/webostv/test_media_player.py | 53 +++++---- tests/components/webostv/test_notify.py | 111 +++++++----------- 13 files changed, 213 insertions(+), 170 deletions(-) diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index 186a7e68a64..6546f9aa0f0 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -99,24 +99,6 @@ async def async_update_options(hass: HomeAssistant, entry: WebOsTvConfigEntry) - await hass.config_entries.async_reload(entry.entry_id) -async def async_control_connect( - hass: HomeAssistant, host: str, key: str | None -) -> WebOsClient: - """LG Connection.""" - client = WebOsClient( - host, - key, - client_session=async_get_clientsession(hass), - ) - try: - await client.connect() - except WebOsTvPairError: - _LOGGER.warning("Connected to LG webOS TV %s but not paired", host) - raise - - return client - - def update_client_key( hass: HomeAssistant, entry: ConfigEntry, client: WebOsClient ) -> None: diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index f8125f0c0cf..1561a56defe 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -6,22 +6,23 @@ from collections.abc import Mapping from typing import Any, Self from urllib.parse import urlparse -from aiowebostv import WebOsTvPairError +from aiowebostv import WebOsClient, WebOsTvPairError import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN, SsdpServiceInfo, ) -from . import WebOsTvConfigEntry, async_control_connect +from . import WebOsTvConfigEntry from .const import CONF_SOURCES, DEFAULT_NAME, DOMAIN, WEBOSTV_EXCEPTIONS -from .helpers import async_get_sources +from .helpers import get_sources DATA_SCHEMA = vol.Schema( { @@ -31,6 +32,21 @@ DATA_SCHEMA = vol.Schema( ) +async def async_control_connect( + hass: HomeAssistant, host: str, key: str | None +) -> WebOsClient: + """Create LG WebOS client and connect to the TV.""" + client = WebOsClient( + host, + key, + client_session=async_get_clientsession(hass), + ) + + await client.connect() + + return client + + class FlowHandler(ConfigFlow, domain=DOMAIN): """WebosTV configuration flow.""" @@ -195,9 +211,14 @@ class OptionsFlowHandler(OptionsFlow): options_input = {CONF_SOURCES: user_input[CONF_SOURCES]} return self.async_create_entry(title="", data=options_input) # Get sources - sources_list = await async_get_sources(self.hass, self.host, self.key) - if not sources_list: - errors["base"] = "cannot_retrieve" + sources_list = [] + try: + client = await async_control_connect(self.hass, self.host, self.key) + sources_list = get_sources(client) + except WebOsTvPairError: + errors["base"] = "error_pairing" + except WEBOSTV_EXCEPTIONS: + errors["base"] = "cannot_connect" option_sources = self.config_entry.options.get(CONF_SOURCES, []) sources = [s for s in option_sources if s in sources_list] diff --git a/homeassistant/components/webostv/device_trigger.py b/homeassistant/components/webostv/device_trigger.py index 877c607f939..3021cc18ea5 100644 --- a/homeassistant/components/webostv/device_trigger.py +++ b/homeassistant/components/webostv/device_trigger.py @@ -14,7 +14,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import trigger +from . import DOMAIN, trigger from .helpers import ( async_get_client_by_device_entry, async_get_device_entry_by_device_id, @@ -75,4 +75,8 @@ async def async_attach_trigger( hass, trigger_config, action, trigger_info ) - raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unhandled_trigger_type", + translation_placeholders={"trigger_type": trigger_type}, + ) diff --git a/homeassistant/components/webostv/helpers.py b/homeassistant/components/webostv/helpers.py index f4563ef2394..389c866ba14 100644 --- a/homeassistant/components/webostv/helpers.py +++ b/homeassistant/components/webostv/helpers.py @@ -9,8 +9,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry -from . import WebOsTvConfigEntry, async_control_connect -from .const import DOMAIN, LIVE_TV_APP_ID, WEBOSTV_EXCEPTIONS +from . import WebOsTvConfigEntry +from .const import DOMAIN, LIVE_TV_APP_ID @callback @@ -72,13 +72,8 @@ def async_get_client_by_device_entry( ) -async def async_get_sources(hass: HomeAssistant, host: str, key: str) -> list[str]: +def get_sources(client: WebOsClient) -> list[str]: """Construct sources list.""" - try: - client = await async_control_connect(hass, host, key) - except WEBOSTV_EXCEPTIONS: - return [] - sources = [] found_live_tv = False for app in client.apps.values(): diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index a03449a49b6..1f280ddfc79 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -106,21 +106,27 @@ def cmd[_T: LgWebOSMediaPlayerEntity, **_P]( @wraps(func) async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: """Wrap all command methods.""" + if self.state is MediaPlayerState.OFF: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_off", + translation_placeholders={ + "name": str(self._entry.title), + "func": func.__name__, + }, + ) try: await func(self, *args, **kwargs) - except WEBOSTV_EXCEPTIONS as exc: - if self.state != MediaPlayerState.OFF: - raise HomeAssistantError( - f"Error calling {func.__name__} on entity {self.entity_id}," - f" state:{self.state}" - ) from exc - _LOGGER.warning( - "Error calling %s on entity %s, state:%s, error: %r", - func.__name__, - self.entity_id, - self.state, - exc, - ) + except WEBOSTV_EXCEPTIONS as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={ + "name": str(self._entry.title), + "func": func.__name__, + "error": str(error), + }, + ) from error return cmd_wrapper diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py index fde0e6ad607..dbd79363198 100644 --- a/homeassistant/components/webostv/notify.py +++ b/homeassistant/components/webostv/notify.py @@ -2,19 +2,18 @@ from __future__ import annotations -import logging from typing import Any -from aiowebostv import WebOsClient, WebOsTvPairError +from aiowebostv import WebOsClient from homeassistant.components.notify import ATTR_DATA, BaseNotificationService from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ATTR_CONFIG_ENTRY_ID, WEBOSTV_EXCEPTIONS - -_LOGGER = logging.getLogger(__name__) +from . import WebOsTvConfigEntry +from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, WEBOSTV_EXCEPTIONS PARALLEL_UPDATES = 0 @@ -34,28 +33,48 @@ async def async_get_service( ) assert config_entry is not None - return LgWebOSNotificationService(config_entry.runtime_data) + return LgWebOSNotificationService(config_entry) class LgWebOSNotificationService(BaseNotificationService): """Implement the notification service for LG WebOS TV.""" - def __init__(self, client: WebOsClient) -> None: + def __init__(self, entry: WebOsTvConfigEntry) -> None: """Initialize the service.""" - self._client = client + self._entry = entry async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to the tv.""" - try: - if not self._client.is_connected(): - await self._client.connect() + client: WebOsClient = self._entry.runtime_data + data = kwargs[ATTR_DATA] + icon_path = data.get(ATTR_ICON) if data else None - data = kwargs[ATTR_DATA] - icon_path = data.get(ATTR_ICON) if data else None - await self._client.send_message(message, icon_path=icon_path) - except WebOsTvPairError: - _LOGGER.error("Pairing with TV failed") - except FileNotFoundError: - _LOGGER.error("Icon %s not found", icon_path) - except WEBOSTV_EXCEPTIONS: - _LOGGER.error("TV unreachable") + if not client.is_on: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="notify_device_off", + translation_placeholders={ + "name": str(self._entry.title), + "func": __name__, + }, + ) + try: + await client.send_message(message, icon_path=icon_path) + except FileNotFoundError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="notify_icon_not_found", + translation_placeholders={ + "name": str(self._entry.title), + "icon_path": str(icon_path), + }, + ) from error + except WEBOSTV_EXCEPTIONS as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="notify_communication_error", + translation_placeholders={ + "name": str(self._entry.title), + "error": str(error), + }, + ) from error diff --git a/homeassistant/components/webostv/quality_scale.yaml b/homeassistant/components/webostv/quality_scale.yaml index 08c594d0298..70f845404cd 100644 --- a/homeassistant/components/webostv/quality_scale.yaml +++ b/homeassistant/components/webostv/quality_scale.yaml @@ -58,7 +58,7 @@ rules: entity-translations: status: exempt comment: There are no entities to translate. - exception-translations: todo + exception-translations: done icon-translations: status: exempt comment: The only entity can use the device class. diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index b0786bd06de..54cc8dbe230 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -54,7 +54,8 @@ } }, "error": { - "cannot_retrieve": "Unable to retrieve the list of sources. Make sure device is switched on" + "cannot_connect": "[%key:component::webostv::config::error::cannot_connect%]", + "error_pairing": "[%key:component::webostv::config::error::error_pairing%]" } }, "device_automation": { @@ -109,5 +110,25 @@ } } } + }, + "exceptions": { + "device_off": { + "message": "Error calling {func} for device {name}: Device is off and cannot be controlled." + }, + "communication_error": { + "message": "Communication error while calling {func} for device {name}: {error}" + }, + "notify_device_off": { + "message": "Error sending notification to device {name}: Device is off and cannot be controlled." + }, + "notify_icon_not_found": { + "message": "Icon {icon_path} not found when sending notification for device {name}" + }, + "notify_communication_error": { + "message": "Communication error while sending notification to device {name}: {error}" + }, + "unhandled_trigger_type": { + "message": "Unhandled trigger type: {trigger_type}" + } } } diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py index 1e3f7ecdc67..711d400b0e6 100644 --- a/tests/components/webostv/conftest.py +++ b/tests/components/webostv/conftest.py @@ -30,9 +30,15 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(name="client") def client_fixture(): """Patch of client library for tests.""" - with patch( - "homeassistant.components.webostv.WebOsClient", autospec=True - ) as mock_client_class: + with ( + patch( + "homeassistant.components.webostv.WebOsClient", autospec=True + ) as mock_client_class, + patch( + "homeassistant.components.webostv.config_flow.WebOsClient", + new=mock_client_class, + ), + ): client = mock_client_class.return_value client.hello_info = {"deviceUUID": FAKE_UUID} client.software_info = {"major_ver": "major", "minor_ver": "minor"} diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index a52acae4b03..0d8b86b4ac2 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -103,16 +103,25 @@ async def test_options_flow_live_tv_in_apps( assert result["data"][CONF_SOURCES] == ["Live TV", "Input01", "Input02"] -async def test_options_flow_cannot_retrieve(hass: HomeAssistant, client) -> None: - """Test options config flow cannot retrieve sources.""" +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (WebOsTvPairError, "error_pairing"), + (ConnectionResetError, "cannot_connect"), + ], +) +async def test_options_flow_errors( + hass: HomeAssistant, client, side_effect, error +) -> None: + """Test options config flow errors.""" entry = await setup_webostv(hass) - client.connect.side_effect = ConnectionResetError + client.connect.side_effect = side_effect result = await hass.config_entries.options.async_init(entry.entry_id) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_retrieve"} + assert result["errors"] == {"base": error} # recover client.connect.side_effect = None diff --git a/tests/components/webostv/test_device_trigger.py b/tests/components/webostv/test_device_trigger.py index 284cd8ad108..1995897e079 100644 --- a/tests/components/webostv/test_device_trigger.py +++ b/tests/components/webostv/test_device_trigger.py @@ -111,7 +111,7 @@ async def test_invalid_trigger_raises( await setup_webostv(hass) # Test wrong trigger platform type - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError, match="Unhandled trigger type: wrong.type"): await device_trigger.async_attach_trigger( hass, {"type": "wrong.type", "device_id": "invalid_device_id"}, None, {} ) diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index ab3feac1f2d..5789fd19492 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -482,35 +482,44 @@ async def test_client_key_update_on_connect( assert config_entry.data[CONF_CLIENT_SECRET] == client.client_key +@pytest.mark.parametrize( + ("is_on", "exception", "error_message"), + [ + ( + True, + WebOsTvCommandError("Some error"), + f"Communication error while calling async_media_play for device {TV_NAME}: Some error", + ), + ( + True, + WebOsTvCommandError("Some other error"), + f"Communication error while calling async_media_play for device {TV_NAME}: Some other error", + ), + ( + False, + None, + f"Error calling async_media_play for device {TV_NAME}: Device is off and cannot be controlled", + ), + ], +) async def test_control_error_handling( - hass: HomeAssistant, client, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + client, + is_on: bool, + exception: Exception, + error_message: str, ) -> None: """Test control errors handling.""" await setup_webostv(hass) - client.play.side_effect = WebOsTvCommandError - data = {ATTR_ENTITY_ID: ENTITY_ID} + client.play.side_effect = exception + client.is_on = is_on + await client.mock_state_update() - # Device on, raise HomeAssistantError - with pytest.raises(HomeAssistantError) as exc: + data = {ATTR_ENTITY_ID: ENTITY_ID} + with pytest.raises(HomeAssistantError, match=error_message): await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data, True) - assert ( - str(exc.value) - == f"Error calling async_media_play on entity {ENTITY_ID}, state:on" - ) - assert client.play.call_count == 1 - - # Device off, log a warning - client.is_on = False - client.play.side_effect = TimeoutError - await client.mock_state_update() - await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data, True) - - assert client.play.call_count == 2 - assert ( - f"Error calling async_media_play on entity {ENTITY_ID}, state:off, error:" - " TimeoutError()" in caplog.text - ) + assert client.play.call_count == int(is_on) async def test_supported_features(hass: HomeAssistant, client) -> None: diff --git a/tests/components/webostv/test_notify.py b/tests/components/webostv/test_notify.py index 61c73d1b151..e57451088e3 100644 --- a/tests/components/webostv/test_notify.py +++ b/tests/components/webostv/test_notify.py @@ -2,7 +2,7 @@ from unittest.mock import call -from aiowebostv import WebOsTvPairError +from aiowebostv import WebOsTvCommandError import pytest from homeassistant.components.notify import ( @@ -13,6 +13,7 @@ from homeassistant.components.notify import ( from homeassistant.components.webostv import DOMAIN from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from homeassistant.util import slugify @@ -74,84 +75,54 @@ async def test_notify(hass: HomeAssistant, client) -> None: ) -async def test_notify_not_connected(hass: HomeAssistant, client) -> None: - """Test sending a message when client is not connected.""" - await setup_webostv(hass) - assert hass.services.has_service(NOTIFY_DOMAIN, SERVICE_NAME) - - client.is_connected.return_value = False - await hass.services.async_call( - NOTIFY_DOMAIN, - SERVICE_NAME, - { - ATTR_MESSAGE: MESSAGE, - ATTR_DATA: { - ATTR_ICON: ICON_PATH, - }, - }, - blocking=True, - ) - assert client.mock_calls[0] == call.connect() - assert client.connect.call_count == 2 - client.send_message.assert_called_with(MESSAGE, icon_path=ICON_PATH) - - -async def test_icon_not_found( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, client -) -> None: - """Test notify icon not found error.""" - await setup_webostv(hass) - assert hass.services.has_service(NOTIFY_DOMAIN, SERVICE_NAME) - - client.send_message.side_effect = FileNotFoundError - await hass.services.async_call( - NOTIFY_DOMAIN, - SERVICE_NAME, - { - ATTR_MESSAGE: MESSAGE, - ATTR_DATA: { - ATTR_ICON: ICON_PATH, - }, - }, - blocking=True, - ) - assert client.mock_calls[0] == call.connect() - assert client.connect.call_count == 1 - client.send_message.assert_called_with(MESSAGE, icon_path=ICON_PATH) - assert f"Icon {ICON_PATH} not found" in caplog.text - - @pytest.mark.parametrize( - ("side_effect", "error"), + ("is_on", "exception", "error_message"), [ - (WebOsTvPairError, "Pairing with TV failed"), - (ConnectionResetError, "TV unreachable"), + ( + True, + WebOsTvCommandError("Some error"), + f"Communication error while sending notification to device {TV_NAME}: Some error", + ), + ( + True, + FileNotFoundError("Some other error"), + f"Icon {ICON_PATH} not found when sending notification for device {TV_NAME}", + ), + ( + False, + None, + f"Error sending notification to device {TV_NAME}: Device is off and cannot be controlled", + ), ], ) -async def test_connection_errors( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, client, side_effect, error +async def test_errors( + hass: HomeAssistant, + client, + is_on: bool, + exception: Exception, + error_message: str, ) -> None: - """Test connection errors scenarios.""" + """Test error scenarios.""" await setup_webostv(hass) + client.is_on = is_on + assert hass.services.has_service("notify", SERVICE_NAME) - client.is_connected.return_value = False - client.connect.side_effect = side_effect - await hass.services.async_call( - NOTIFY_DOMAIN, - SERVICE_NAME, - { - ATTR_MESSAGE: MESSAGE, - ATTR_DATA: { - ATTR_ICON: ICON_PATH, + client.send_message.side_effect = exception + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_NAME, + { + ATTR_MESSAGE: MESSAGE, + ATTR_DATA: { + ATTR_ICON: ICON_PATH, + }, }, - }, - blocking=True, - ) - assert client.mock_calls[0] == call.connect() - assert client.connect.call_count == 2 - client.send_message.assert_not_called() - assert error in caplog.text + blocking=True, + ) + + assert client.send_message.call_count == int(is_on) async def test_no_discovery_info( From 6854feeb4056fb875a2578f7d1814f8ae94ebab2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jan 2025 08:34:48 +0100 Subject: [PATCH 0902/2987] Bump github/codeql-action from 3.28.3 to 3.28.4 (#136401) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0b58140a2fb..ee7fad4bb4e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.3 + uses: github/codeql-action/init@v3.28.4 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.3 + uses: github/codeql-action/analyze@v3.28.4 with: category: "/language:python" From 90d95d935e137cbd1ccfe54ca000bea18475a93e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jan 2025 08:42:58 +0100 Subject: [PATCH 0903/2987] Bump codecov/codecov-action from 5.2.0 to 5.3.0 (#136402) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7c5ba24714d..6527a09e15f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1273,7 +1273,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v5.2.0 + uses: codecov/codecov-action@v5.3.0 with: fail_ci_if_error: true flags: full-suite @@ -1411,7 +1411,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v5.2.0 + uses: codecov/codecov-action@v5.3.0 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} From e44cfa00afaa8f795719b29ae3f66fe8f9d01b15 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 24 Jan 2025 08:43:18 +0100 Subject: [PATCH 0904/2987] Remove deprecated 17track package sensor (#136389) --- .../components/seventeentrack/const.py | 3 - .../components/seventeentrack/repairs.py | 49 ----- .../components/seventeentrack/sensor.py | 158 +--------------- .../components/seventeentrack/strings.json | 13 -- .../components/seventeentrack/test_repairs.py | 86 --------- .../components/seventeentrack/test_sensor.py | 170 +----------------- 6 files changed, 3 insertions(+), 476 deletions(-) delete mode 100644 homeassistant/components/seventeentrack/repairs.py delete mode 100644 tests/components/seventeentrack/test_repairs.py diff --git a/homeassistant/components/seventeentrack/const.py b/homeassistant/components/seventeentrack/const.py index 6b888590600..19e2d3083c9 100644 --- a/homeassistant/components/seventeentrack/const.py +++ b/homeassistant/components/seventeentrack/const.py @@ -47,6 +47,3 @@ SERVICE_ARCHIVE_PACKAGE = "archive_package" ATTR_PACKAGE_STATE = "package_state" ATTR_PACKAGE_TRACKING_NUMBER = "package_tracking_number" ATTR_CONFIG_ENTRY_ID = "config_entry_id" - - -DEPRECATED_KEY = "deprecated" diff --git a/homeassistant/components/seventeentrack/repairs.py b/homeassistant/components/seventeentrack/repairs.py deleted file mode 100644 index ce72960ea91..00000000000 --- a/homeassistant/components/seventeentrack/repairs.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Repairs for the SeventeenTrack integration.""" - -import voluptuous as vol - -from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult - -from .const import DEPRECATED_KEY - - -class SensorDeprecationRepairFlow(RepairsFlow): - """Handler for an issue fixing flow.""" - - def __init__(self, entry: ConfigEntry) -> None: - """Create flow.""" - self.entry = entry - - async def async_step_init( - self, user_input: dict[str, str] | None = None - ) -> FlowResult: - """Handle the first step of a fix flow.""" - return await self.async_step_confirm() - - async def async_step_confirm( - self, user_input: dict[str, str] | None = None - ) -> FlowResult: - """Handle the confirm step of a fix flow.""" - if user_input is not None: - data = {**self.entry.data, DEPRECATED_KEY: True} - self.hass.config_entries.async_update_entry(self.entry, data=data) - return self.async_create_entry(data={}) - - return self.async_show_form( - step_id="confirm", - data_schema=vol.Schema({}), - ) - - -async def async_create_fix_flow( - hass: HomeAssistant, issue_id: str, data: dict -) -> RepairsFlow: - """Create flow.""" - if issue_id.startswith("deprecate_sensor_") and ( - entry := hass.config_entries.async_get_entry(data["entry_id"]) - ): - return SensorDeprecationRepairFlow(entry) - return ConfirmRepairFlow() diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index 4e561a87961..dade9efb67c 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -4,12 +4,10 @@ from __future__ import annotations from typing import Any -from homeassistant.components import persistent_notification from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_LOCATION -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -17,23 +15,13 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SeventeenTrackCoordinator from .const import ( - ATTR_DESTINATION_COUNTRY, ATTR_INFO_TEXT, - ATTR_ORIGIN_COUNTRY, - ATTR_PACKAGE_TYPE, ATTR_PACKAGES, ATTR_STATUS, ATTR_TIMESTAMP, - ATTR_TRACKING_INFO_LANGUAGE, ATTR_TRACKING_NUMBER, ATTRIBUTION, - DEPRECATED_KEY, DOMAIN, - LOGGER, - NOTIFICATION_DELIVERED_MESSAGE, - NOTIFICATION_DELIVERED_TITLE, - UNIQUE_ID_TEMPLATE, - VALUE_DELIVERED, ) @@ -45,63 +33,12 @@ async def async_setup_entry( """Set up a 17Track sensor entry.""" coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][config_entry.entry_id] - previous_tracking_numbers: set[str] = set() - - # This has been deprecated in 2024.8, will be removed in 2025.2 - @callback - def _async_create_remove_entities(): - if config_entry.data.get(DEPRECATED_KEY): - remove_packages(hass, coordinator.account_id, previous_tracking_numbers) - return - live_tracking_numbers = set(coordinator.data.live_packages.keys()) - - new_tracking_numbers = live_tracking_numbers - previous_tracking_numbers - old_tracking_numbers = previous_tracking_numbers - live_tracking_numbers - - previous_tracking_numbers.update(live_tracking_numbers) - - packages_to_add = [ - coordinator.data.live_packages[tracking_number] - for tracking_number in new_tracking_numbers - ] - - for package_data in coordinator.data.live_packages.values(): - if ( - package_data.status == VALUE_DELIVERED - and not coordinator.show_delivered - ): - old_tracking_numbers.add(package_data.tracking_number) - notify_delivered( - hass, - package_data.friendly_name, - package_data.tracking_number, - ) - - remove_packages(hass, coordinator.account_id, old_tracking_numbers) - - async_add_entities( - SeventeenTrackPackageSensor( - coordinator, - package_data.tracking_number, - ) - for package_data in packages_to_add - if not ( - not coordinator.show_delivered and package_data.status == "Delivered" - ) - ) async_add_entities( SeventeenTrackSummarySensor(status, coordinator) for status, summary_data in coordinator.data.summary.items() ) - if not config_entry.data.get(DEPRECATED_KEY): - deprecate_sensor_issue(hass, config_entry.entry_id) - _async_create_remove_entities() - config_entry.async_on_unload( - coordinator.async_add_listener(_async_create_remove_entities) - ) - class SeventeenTrackSensor(CoordinatorEntity[SeventeenTrackCoordinator], SensorEntity): """Define a 17Track sensor.""" @@ -163,96 +100,3 @@ class SeventeenTrackSummarySensor(SeventeenTrackSensor): for package in packages ] } - - -# The dynamic package sensors have been replaced by the seventeentrack.get_packages service -class SeventeenTrackPackageSensor(SeventeenTrackSensor): - """Define an individual package sensor.""" - - _attr_translation_key = "package" - - def __init__( - self, - coordinator: SeventeenTrackCoordinator, - tracking_number: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self._tracking_number = tracking_number - self._previous_status = coordinator.data.live_packages[tracking_number].status - self._attr_unique_id = UNIQUE_ID_TEMPLATE.format( - coordinator.account_id, tracking_number - ) - package = coordinator.data.live_packages[tracking_number] - if not (name := package.friendly_name): - name = tracking_number - self._attr_translation_placeholders = {"name": name} - - @property - def available(self) -> bool: - """Return whether the entity is available.""" - return self._tracking_number in self.coordinator.data.live_packages - - @property - def native_value(self) -> StateType: - """Return the state.""" - return self.coordinator.data.live_packages[self._tracking_number].status - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes.""" - package = self.coordinator.data.live_packages[self._tracking_number] - return { - ATTR_DESTINATION_COUNTRY: package.destination_country, - ATTR_INFO_TEXT: package.info_text, - ATTR_TIMESTAMP: package.timestamp, - ATTR_LOCATION: package.location, - ATTR_ORIGIN_COUNTRY: package.origin_country, - ATTR_PACKAGE_TYPE: package.package_type, - ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, - ATTR_TRACKING_NUMBER: package.tracking_number, - } - - -def remove_packages(hass: HomeAssistant, account_id: str, packages: set[str]) -> None: - """Remove entity itself.""" - reg = er.async_get(hass) - for package in packages: - entity_id = reg.async_get_entity_id( - "sensor", - "seventeentrack", - UNIQUE_ID_TEMPLATE.format(account_id, package), - ) - if entity_id: - reg.async_remove(entity_id) - - -def notify_delivered(hass: HomeAssistant, friendly_name: str, tracking_number: str): - """Notify when package is delivered.""" - LOGGER.debug("Package delivered: %s", tracking_number) - - identification = friendly_name if friendly_name else tracking_number - message = NOTIFICATION_DELIVERED_MESSAGE.format(identification, tracking_number) - title = NOTIFICATION_DELIVERED_TITLE.format(identification) - notification_id = NOTIFICATION_DELIVERED_TITLE.format(tracking_number) - - persistent_notification.create( - hass, message, title=title, notification_id=notification_id - ) - - -@callback -def deprecate_sensor_issue(hass: HomeAssistant, entry_id: str) -> None: - """Ensure an issue is registered.""" - ir.async_create_issue( - hass, - DOMAIN, - f"deprecate_sensor_{entry_id}", - breaks_in_ha_version="2025.2.0", - issue_domain=DOMAIN, - is_fixable=True, - is_persistent=True, - translation_key="deprecate_sensor", - severity=ir.IssueSeverity.WARNING, - data={"entry_id": entry_id}, - ) diff --git a/homeassistant/components/seventeentrack/strings.json b/homeassistant/components/seventeentrack/strings.json index bbd01ed3055..982b15ab629 100644 --- a/homeassistant/components/seventeentrack/strings.json +++ b/homeassistant/components/seventeentrack/strings.json @@ -37,19 +37,6 @@ } } }, - "issues": { - "deprecate_sensor": { - "title": "17Track package sensors are being deprecated", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::seventeentrack::issues::deprecate_sensor::title%]", - "description": "17Track package sensors are deprecated and will be removed.\nPlease update your automations and scripts to get data using the `seventeentrack.get_packages` action." - } - } - } - } - }, "entity": { "sensor": { "not_found": { diff --git a/tests/components/seventeentrack/test_repairs.py b/tests/components/seventeentrack/test_repairs.py deleted file mode 100644 index 44d1f078432..00000000000 --- a/tests/components/seventeentrack/test_repairs.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Tests for the seventeentrack repair flow.""" - -from unittest.mock import AsyncMock - -from freezegun.api import FrozenDateTimeFactory - -from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN -from homeassistant.components.seventeentrack import DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component - -from . import goto_future, init_integration -from .conftest import DEFAULT_SUMMARY_LENGTH, get_package - -from tests.common import MockConfigEntry -from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow -from tests.typing import ClientSessionGenerator - - -async def test_repair( - hass: HomeAssistant, - mock_seventeentrack: AsyncMock, - issue_registry: ir.IssueRegistry, - hass_client: ClientSessionGenerator, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, -) -> None: - """Ensure everything starts correctly.""" - await init_integration(hass, mock_config_entry) # 2 - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH - assert len(issue_registry.issues) == 1 - - package = get_package() - mock_seventeentrack.return_value.profile.packages.return_value = [package] - await goto_future(hass, freezer) - - assert hass.states.get("sensor.17track_package_friendly_name_1") - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - - assert "deprecated" not in mock_config_entry.data - - repair_issue = issue_registry.async_get_issue( - domain=DOMAIN, issue_id=f"deprecate_sensor_{mock_config_entry.entry_id}" - ) - - assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) - - client = await hass_client() - - data = await start_repair_fix_flow(client, DOMAIN, repair_issue.issue_id) - - flow_id = data["flow_id"] - assert data == { - "type": "form", - "flow_id": flow_id, - "handler": DOMAIN, - "step_id": "confirm", - "data_schema": [], - "errors": None, - "description_placeholders": None, - "last_step": None, - "preview": None, - } - - data = await process_repair_fix_flow(client, flow_id) - - flow_id = data["flow_id"] - assert data == { - "type": "create_entry", - "handler": DOMAIN, - "flow_id": flow_id, - "description": None, - "description_placeholders": None, - } - - assert mock_config_entry.data["deprecated"] - - repair_issue = issue_registry.async_get_issue( - domain=DOMAIN, issue_id="deprecate_sensor" - ) - - assert repair_issue is None - - await goto_future(hass, freezer) - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH diff --git a/tests/components/seventeentrack/test_sensor.py b/tests/components/seventeentrack/test_sensor.py index a631996b4eb..5367fabba9e 100644 --- a/tests/components/seventeentrack/test_sensor.py +++ b/tests/components/seventeentrack/test_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from pyseventeentrack.errors import SeventeenTrackError @@ -63,87 +63,6 @@ async def test_login_exception( assert not hass.states.async_entity_ids("sensor") -async def test_add_package( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_seventeentrack: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure package is added correctly when user add a new package.""" - package = get_package() - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - await init_integration(hass, mock_config_entry) - assert hass.states.get("sensor.17track_package_friendly_name_1") - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - - package2 = get_package( - tracking_number="789", - friendly_name="friendly name 2", - info_text="info text 2", - location="location 2", - timestamp="2020-08-10 14:25", - ) - mock_seventeentrack.return_value.profile.packages.return_value = [package, package2] - - await goto_future(hass, freezer) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is not None - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 2 - - -async def test_add_package_default_friendly_name( - hass: HomeAssistant, - mock_seventeentrack: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure package is added correctly with default friendly name when user add a new package without his own friendly name.""" - package = get_package(friendly_name=None) - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - await init_integration(hass, mock_config_entry) - state_456 = hass.states.get("sensor.17track_package_456") - assert state_456 is not None - assert state_456.attributes["friendly_name"] == "17Track Package 456" - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - - -async def test_remove_package( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_seventeentrack: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure entity is not there anymore if package is not there.""" - package1 = get_package() - package2 = get_package( - tracking_number="789", - friendly_name="friendly name 2", - info_text="info text 2", - location="location 2", - timestamp="2020-08-10 14:25", - ) - - mock_seventeentrack.return_value.profile.packages.return_value = [ - package1, - package2, - ] - - await init_integration(hass, mock_config_entry) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is not None - assert hass.states.get("sensor.17track_package_friendly_name_2") is not None - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 2 - - mock_seventeentrack.return_value.profile.packages.return_value = [package2] - - await goto_future(hass, freezer) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is None - assert hass.states.get("sensor.17track_package_friendly_name_2") is not None - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - - async def test_package_error( hass: HomeAssistant, mock_seventeentrack: AsyncMock, @@ -159,72 +78,6 @@ async def test_package_error( assert hass.states.get("sensor.17track_package_friendly_name_1") is None -async def test_delivered_not_shown( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_seventeentrack: AsyncMock, - mock_config_entry_with_default_options: MockConfigEntry, -) -> None: - """Ensure delivered packages are not shown.""" - package = get_package(status=40) - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - with patch( - "homeassistant.components.seventeentrack.sensor.persistent_notification" - ) as persistent_notification_mock: - await init_integration(hass, mock_config_entry_with_default_options) - await goto_future(hass, freezer) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is None - persistent_notification_mock.create.assert_called() - - -async def test_delivered_shown( - hass: HomeAssistant, - mock_seventeentrack: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure delivered packages are show when user choose to show them.""" - package = get_package(status=40) - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - with patch( - "homeassistant.components.seventeentrack.sensor.persistent_notification" - ) as persistent_notification_mock: - await init_integration(hass, mock_config_entry) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is not None - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - persistent_notification_mock.create.assert_not_called() - - -async def test_becomes_delivered_not_shown_notification( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_seventeentrack: AsyncMock, - mock_config_entry_with_default_options: MockConfigEntry, -) -> None: - """Ensure notification is triggered when package becomes delivered.""" - package = get_package() - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - await init_integration(hass, mock_config_entry_with_default_options) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is not None - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - - package_delivered = get_package(status=40) - mock_seventeentrack.return_value.profile.packages.return_value = [package_delivered] - - with patch( - "homeassistant.components.seventeentrack.sensor.persistent_notification" - ) as persistent_notification_mock: - await goto_future(hass, freezer) - - persistent_notification_mock.create.assert_called() - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH - - async def test_summary_correctly_updated( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -237,7 +90,7 @@ async def test_summary_correctly_updated( await init_integration(hass, mock_config_entry) - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 + assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH state_ready_picked = hass.states.get("sensor.17track_ready_to_be_picked_up") assert state_ready_picked is not None @@ -278,25 +131,6 @@ async def test_summary_error( ) -async def test_utc_timestamp( - hass: HomeAssistant, - mock_seventeentrack: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure package timestamp is converted correctly from HA-defined time zone to UTC.""" - - package = get_package(tz="Asia/Jakarta") - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - await init_integration(hass, mock_config_entry) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is not None - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - state_456 = hass.states.get("sensor.17track_package_friendly_name_1") - assert state_456 is not None - assert str(state_456.attributes.get("timestamp")) == "2020-08-10 03:32:00+00:00" - - async def test_non_valid_platform_config( hass: HomeAssistant, mock_seventeentrack: AsyncMock ) -> None: From 6a1279611d538f7bfd2c3c4e25814d31a85913c3 Mon Sep 17 00:00:00 2001 From: Makrit Date: Fri, 24 Jan 2025 07:49:33 +0000 Subject: [PATCH 0905/2987] Handle width and height placeholders in the thumbnail URL (#136227) --- homeassistant/components/twitch/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/twitch/coordinator.py b/homeassistant/components/twitch/coordinator.py index c61e80bd2b8..010a9e90ccc 100644 --- a/homeassistant/components/twitch/coordinator.py +++ b/homeassistant/components/twitch/coordinator.py @@ -122,7 +122,7 @@ class TwitchCoordinator(DataUpdateCoordinator[dict[str, TwitchUpdate]]): stream.game_name if stream else None, stream.title if stream else None, stream.started_at if stream else None, - stream.thumbnail_url if stream else None, + stream.thumbnail_url.format(width="", height="") if stream else None, channel.profile_image_url, bool(sub), sub.is_gift if sub else None, From 7af7219b01afb6a5a144f544a6cd56a67141a08a Mon Sep 17 00:00:00 2001 From: Matt Doran Date: Fri, 24 Jan 2025 05:50:56 +1100 Subject: [PATCH 0906/2987] Update Hydrawise maximum watering duration to meet the app limits (#136050) Co-authored-by: Robert Resch --- homeassistant/components/hydrawise/binary_sensor.py | 2 +- homeassistant/components/hydrawise/services.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 34c31d3ad16..83e8a8325f9 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -68,7 +68,7 @@ ZONE_BINARY_SENSORS: tuple[HydrawiseBinarySensorEntityDescription, ...] = ( ) SCHEMA_START_WATERING: VolDictType = { - vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=90)), + vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=1440)), } SCHEMA_SUSPEND: VolDictType = { vol.Required("until"): cv.datetime, diff --git a/homeassistant/components/hydrawise/services.yaml b/homeassistant/components/hydrawise/services.yaml index 64c04901816..bf90a8e23b3 100644 --- a/homeassistant/components/hydrawise/services.yaml +++ b/homeassistant/components/hydrawise/services.yaml @@ -10,7 +10,7 @@ start_watering: selector: number: min: 0 - max: 90 + max: 1440 unit_of_measurement: min mode: box suspend: From 8440a271528c39d9d240db765903208822961fc2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 20 Jan 2025 23:59:12 +0100 Subject: [PATCH 0907/2987] Bump holidays to 0.65 (#136122) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 09943faf0a2..edf3ebe7f04 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.64", "babel==2.15.0"] + "requirements": ["holidays==0.65", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index bb5e6333b8b..4b9d072f747 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.64"] + "requirements": ["holidays==0.65"] } diff --git a/requirements_all.txt b/requirements_all.txt index 28269469a78..c4ff74efd76 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1131,7 +1131,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.64 +holidays==0.65 # homeassistant.components.frontend home-assistant-frontend==20250109.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 265390966db..fddaad5f9ca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -960,7 +960,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.64 +holidays==0.65 # homeassistant.components.frontend home-assistant-frontend==20250109.0 From 0512fc5e0c10ef3d52c6d35642930b05b33df3ba Mon Sep 17 00:00:00 2001 From: Makrit Date: Fri, 24 Jan 2025 07:49:33 +0000 Subject: [PATCH 0908/2987] Handle width and height placeholders in the thumbnail URL (#136227) --- homeassistant/components/twitch/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/twitch/coordinator.py b/homeassistant/components/twitch/coordinator.py index c61e80bd2b8..010a9e90ccc 100644 --- a/homeassistant/components/twitch/coordinator.py +++ b/homeassistant/components/twitch/coordinator.py @@ -122,7 +122,7 @@ class TwitchCoordinator(DataUpdateCoordinator[dict[str, TwitchUpdate]]): stream.game_name if stream else None, stream.title if stream else None, stream.started_at if stream else None, - stream.thumbnail_url if stream else None, + stream.thumbnail_url.format(width="", height="") if stream else None, channel.profile_image_url, bool(sub), sub.is_gift if sub else None, From 8b08cb9bc1540baefbb3df03a15f0cf5b55defca Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jan 2025 08:58:35 +0100 Subject: [PATCH 0909/2987] Use runtime_data in coolmaster (#136405) * Use runtime_data in coolmaster * Adjust test --- .../components/coolmaster/__init__.py | 22 +++++----------- .../components/coolmaster/binary_sensor.py | 11 +++----- homeassistant/components/coolmaster/button.py | 11 +++----- .../components/coolmaster/climate.py | 24 ++++++++++------- homeassistant/components/coolmaster/const.py | 3 --- .../components/coolmaster/coordinator.py | 26 ++++++++++++++++--- homeassistant/components/coolmaster/entity.py | 7 ++--- homeassistant/components/coolmaster/sensor.py | 11 +++----- tests/components/coolmaster/test_init.py | 3 --- 9 files changed, 59 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py index 1f3f5a66380..5892ef091d9 100644 --- a/homeassistant/components/coolmaster/__init__.py +++ b/homeassistant/components/coolmaster/__init__.py @@ -2,18 +2,17 @@ from pycoolmasternet_async import CoolMasterNet -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_SWING_SUPPORT, DATA_COORDINATOR, DATA_INFO, DOMAIN -from .coordinator import CoolmasterDataUpdateCoordinator +from .const import CONF_SWING_SUPPORT +from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -> bool: """Set up Coolmaster from a config entry.""" host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] @@ -38,21 +37,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady except OSError as error: raise ConfigEntryNotReady from error - coordinator = CoolmasterDataUpdateCoordinator(hass, coolmaster) - hass.data.setdefault(DOMAIN, {}) + coordinator = CoolmasterDataUpdateCoordinator(hass, entry, coolmaster, info) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = { - DATA_INFO: info, - 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: CoolmasterConfigEntry) -> bool: """Unload a Coolmaster config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/coolmaster/binary_sensor.py b/homeassistant/components/coolmaster/binary_sensor.py index ba54a073f0a..ab2718b9352 100644 --- a/homeassistant/components/coolmaster/binary_sensor.py +++ b/homeassistant/components/coolmaster/binary_sensor.py @@ -7,26 +7,23 @@ 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 AddEntitiesCallback -from .const import DATA_COORDINATOR, DATA_INFO, DOMAIN +from .coordinator import CoolmasterConfigEntry from .entity import CoolmasterEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: CoolmasterConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the CoolMasterNet binary_sensor platform.""" - info = hass.data[DOMAIN][config_entry.entry_id][DATA_INFO] - coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] + coordinator = config_entry.runtime_data async_add_entities( - CoolmasterCleanFilter(coordinator, unit_id, info) - for unit_id in coordinator.data + CoolmasterCleanFilter(coordinator, unit_id) for unit_id in coordinator.data ) diff --git a/homeassistant/components/coolmaster/button.py b/homeassistant/components/coolmaster/button.py index d958346614c..5463566d1ef 100644 --- a/homeassistant/components/coolmaster/button.py +++ b/homeassistant/components/coolmaster/button.py @@ -3,26 +3,23 @@ from __future__ import annotations 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 AddEntitiesCallback -from .const import DATA_COORDINATOR, DATA_INFO, DOMAIN +from .coordinator import CoolmasterConfigEntry from .entity import CoolmasterEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: CoolmasterConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the CoolMasterNet button platform.""" - info = hass.data[DOMAIN][config_entry.entry_id][DATA_INFO] - coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] + coordinator = config_entry.runtime_data async_add_entities( - CoolmasterResetFilter(coordinator, unit_id, info) - for unit_id in coordinator.data + CoolmasterResetFilter(coordinator, unit_id) for unit_id in coordinator.data ) diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py index 29be416d57e..cd1659e1666 100644 --- a/homeassistant/components/coolmaster/climate.py +++ b/homeassistant/components/coolmaster/climate.py @@ -12,13 +12,13 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_SUPPORTED_MODES, DATA_COORDINATOR, DATA_INFO, DOMAIN +from .const import CONF_SUPPORTED_MODES +from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator from .entity import CoolmasterEntity CM_TO_HA_STATE = { @@ -38,15 +38,16 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: CoolmasterConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the CoolMasterNet climate platform.""" - info = hass.data[DOMAIN][config_entry.entry_id][DATA_INFO] - coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] - supported_modes = config_entry.data.get(CONF_SUPPORTED_MODES) + coordinator = config_entry.runtime_data + supported_modes: list[str] = config_entry.data[CONF_SUPPORTED_MODES] async_add_entities( - CoolmasterClimate(coordinator, unit_id, info, supported_modes) + CoolmasterClimate( + coordinator, unit_id, [HVACMode(mode) for mode in supported_modes] + ) for unit_id in coordinator.data ) @@ -56,9 +57,14 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity): _attr_name = None - def __init__(self, coordinator, unit_id, info, supported_modes): + def __init__( + self, + coordinator: CoolmasterDataUpdateCoordinator, + unit_id: str, + supported_modes: list[HVACMode], + ) -> None: """Initialize the climate device.""" - super().__init__(coordinator, unit_id, info) + super().__init__(coordinator, unit_id) self._attr_hvac_modes = supported_modes self._attr_unique_id = unit_id diff --git a/homeassistant/components/coolmaster/const.py b/homeassistant/components/coolmaster/const.py index 1fa46e20ee9..9dd7ed3a444 100644 --- a/homeassistant/components/coolmaster/const.py +++ b/homeassistant/components/coolmaster/const.py @@ -1,8 +1,5 @@ """Constants for the Coolmaster integration.""" -DATA_INFO = "info" -DATA_COORDINATOR = "coordinator" - DOMAIN = "coolmaster" DEFAULT_PORT = 10102 diff --git a/homeassistant/components/coolmaster/coordinator.py b/homeassistant/components/coolmaster/coordinator.py index 54d69b1c540..b2c96ca12a4 100644 --- a/homeassistant/components/coolmaster/coordinator.py +++ b/homeassistant/components/coolmaster/coordinator.py @@ -1,8 +1,15 @@ """DataUpdateCoordinator for coolmaster integration.""" +from __future__ import annotations + import logging +from pycoolmasternet_async import CoolMasterNet +from pycoolmasternet_async.coolmasternet import CoolMasterNetUnit + from homeassistant.components.climate import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -10,21 +17,34 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class CoolmasterDataUpdateCoordinator(DataUpdateCoordinator): +type CoolmasterConfigEntry = ConfigEntry[CoolmasterDataUpdateCoordinator] + + +class CoolmasterDataUpdateCoordinator( + DataUpdateCoordinator[dict[str, CoolMasterNetUnit]] +): """Class to manage fetching Coolmaster data.""" - def __init__(self, hass, coolmaster): + def __init__( + self, + hass: HomeAssistant, + entry: CoolmasterConfigEntry, + coolmaster: CoolMasterNet, + info: dict[str, str], + ) -> None: """Initialize global Coolmaster data updater.""" self._coolmaster = coolmaster + self.info = info super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) - async def _async_update_data(self): + async def _async_update_data(self) -> dict[str, CoolMasterNetUnit]: """Fetch data from Coolmaster.""" try: return await self._coolmaster.status() diff --git a/homeassistant/components/coolmaster/entity.py b/homeassistant/components/coolmaster/entity.py index 73bd1e13a26..7d7bd8e62ba 100644 --- a/homeassistant/components/coolmaster/entity.py +++ b/homeassistant/components/coolmaster/entity.py @@ -1,7 +1,5 @@ """Base entity for Coolmaster integration.""" -from pycoolmasternet_async.coolmasternet import CoolMasterNetUnit - from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -19,18 +17,17 @@ class CoolmasterEntity(CoordinatorEntity[CoolmasterDataUpdateCoordinator]): self, coordinator: CoolmasterDataUpdateCoordinator, unit_id: str, - info: dict[str, str], ) -> None: """Initiate CoolmasterEntity.""" super().__init__(coordinator) self._unit_id: str = unit_id - self._unit: CoolMasterNetUnit = coordinator.data[self._unit_id] + self._unit = coordinator.data[self._unit_id] self._attr_device_info: DeviceInfo = DeviceInfo( identifiers={(DOMAIN, unit_id)}, manufacturer="CoolAutomation", model="CoolMasterNet", name=unit_id, - sw_version=info["version"], + sw_version=coordinator.info["version"], ) if hasattr(self, "entity_description"): self._attr_unique_id: str = f"{unit_id}-{self.entity_description.key}" diff --git a/homeassistant/components/coolmaster/sensor.py b/homeassistant/components/coolmaster/sensor.py index 4c2a09b1ce5..2b835565bae 100644 --- a/homeassistant/components/coolmaster/sensor.py +++ b/homeassistant/components/coolmaster/sensor.py @@ -3,26 +3,23 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DATA_INFO, DOMAIN +from .coordinator import CoolmasterConfigEntry from .entity import CoolmasterEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: CoolmasterConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the CoolMasterNet sensor platform.""" - info = hass.data[DOMAIN][config_entry.entry_id][DATA_INFO] - coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] + coordinator = config_entry.runtime_data async_add_entities( - CoolmasterCleanFilter(coordinator, unit_id, info) - for unit_id in coordinator.data + CoolmasterCleanFilter(coordinator, unit_id) for unit_id in coordinator.data ) diff --git a/tests/components/coolmaster/test_init.py b/tests/components/coolmaster/test_init.py index 4a90d0d9276..f8ff761517f 100644 --- a/tests/components/coolmaster/test_init.py +++ b/tests/components/coolmaster/test_init.py @@ -1,6 +1,5 @@ """The test for the Coolmaster integration.""" -from homeassistant.components.coolmaster.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant @@ -20,8 +19,6 @@ async def test_unload_entry( load_int: ConfigEntry, ) -> None: """Test Coolmaster unloading an entry.""" - assert load_int.entry_id in hass.data.get(DOMAIN) await hass.config_entries.async_unload(load_int.entry_id) await hass.async_block_till_done() assert load_int.state is ConfigEntryState.NOT_LOADED - assert not hass.data.get(DOMAIN) From e7a4f5fd2773c3cec0dbdb61325a60ed58131cc2 Mon Sep 17 00:00:00 2001 From: Claudio Ruggeri - CR-Tech <41435902+crug80@users.noreply.github.com> Date: Thu, 23 Jan 2025 22:12:02 +0100 Subject: [PATCH 0910/2987] Fix slave id equal to 0 (#136263) Co-authored-by: J. Nick Koston --- homeassistant/components/modbus/entity.py | 5 ++- homeassistant/components/modbus/modbus.py | 4 +- tests/components/modbus/test_init.py | 53 +++++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 90833516e59..d252528f6d4 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -79,7 +79,10 @@ class BasePlatform(Entity): """Initialize the Modbus binary sensor.""" self._hub = hub - self._slave = entry.get(CONF_SLAVE) or entry.get(CONF_DEVICE_ADDRESS, 0) + if (conf_slave := entry.get(CONF_SLAVE)) is not None: + self._slave = conf_slave + else: + self._slave = entry.get(CONF_DEVICE_ADDRESS, 1) self._address = int(entry[CONF_ADDRESS]) self._input_type = entry[CONF_INPUT_TYPE] self._value: str | None = None diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 8c8a879ead6..fce831e9cd4 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -368,7 +368,9 @@ class ModbusHub: self, slave: int | None, address: int, value: int | list[int], use_call: str ) -> ModbusPDU | None: """Call sync. pymodbus.""" - kwargs = {"slave": slave} if slave else {} + kwargs: dict[str, Any] = ( + {ATTR_SLAVE: slave} if slave is not None else {ATTR_SLAVE: 1} + ) entry = self._pb_request[use_call] try: result: ModbusPDU = await entry.func(address, value, **kwargs) diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 5dd3f6e9033..d37f55ede94 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -1265,3 +1265,56 @@ async def test_no_entities(hass: HomeAssistant) -> None: ] } assert await async_setup_component(hass, DOMAIN, config) is False + + +@pytest.mark.parametrize( + ("do_config", "expected_slave_value"), + [ + ( + { + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 1234, + }, + ], + }, + 1, + ), + ( + { + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 1234, + CONF_SLAVE: 0, + }, + ], + }, + 0, + ), + ( + { + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 1234, + CONF_DEVICE_ADDRESS: 6, + }, + ], + }, + 6, + ), + ], +) +async def test_check_default_slave( + hass: HomeAssistant, + mock_modbus, + do_config, + mock_do_cycle, + expected_slave_value: int, +) -> None: + """Test default slave.""" + assert mock_modbus.read_holding_registers.mock_calls + first_call = mock_modbus.read_holding_registers.mock_calls[0] + assert first_call.kwargs["slave"] == expected_slave_value From 0caa1ed8257ac68428e8ab39e3f942e3c8f68afb Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Thu, 23 Jan 2025 20:36:59 +0100 Subject: [PATCH 0911/2987] Handle LinkPlay devices with no mac (#136272) Co-authored-by: J. Nick Koston --- homeassistant/components/linkplay/entity.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/linkplay/entity.py b/homeassistant/components/linkplay/entity.py index 00e2f39b233..74e067f5eb3 100644 --- a/homeassistant/components/linkplay/entity.py +++ b/homeassistant/components/linkplay/entity.py @@ -44,9 +44,15 @@ class LinkPlayBaseEntity(Entity): if model != MANUFACTURER_GENERIC: model_id = bridge.device.properties["project"] + connections: set[tuple[str, str]] = set() + if "MAC" in bridge.device.properties: + connections.add( + (dr.CONNECTION_NETWORK_MAC, bridge.device.properties["MAC"]) + ) + self._attr_device_info = dr.DeviceInfo( configuration_url=bridge.endpoint, - connections={(dr.CONNECTION_NETWORK_MAC, bridge.device.properties["MAC"])}, + connections=connections, hw_version=bridge.device.properties["hardware"], identifiers={(DOMAIN, bridge.device.uuid)}, manufacturer=manufacturer, From 2e4a19b058d56904e236eeb08a0a7d0a52505f8c Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Wed, 22 Jan 2025 23:10:52 -0500 Subject: [PATCH 0912/2987] Fallback to None for literal "Blank" serial number for APCUPSD integration (#136297) * Fallback to None for Blank serial number * Fix comments --- homeassistant/components/apcupsd/coordinator.py | 5 ++++- tests/components/apcupsd/test_config_flow.py | 2 ++ tests/components/apcupsd/test_init.py | 8 ++++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py index 768e9605967..1ae12d8c4b0 100644 --- a/homeassistant/components/apcupsd/coordinator.py +++ b/homeassistant/components/apcupsd/coordinator.py @@ -44,7 +44,10 @@ class APCUPSdData(dict[str, str]): @property def serial_no(self) -> str | None: """Return the unique serial number of the UPS, if available.""" - return self.get("SERIALNO") + sn = self.get("SERIALNO") + # We had user reports that some UPS models simply return "Blank" as serial number, in + # which case we fall back to `None` to indicate that it is actually not available. + return None if sn == "Blank" else sn class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]): diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index 88594260579..0b8386dbb5a 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -125,6 +125,8 @@ async def test_flow_works(hass: HomeAssistant) -> None: ({"UPSNAME": "Friendly Name"}, "Friendly Name"), ({"MODEL": "MODEL X"}, "MODEL X"), ({"SERIALNO": "ZZZZ"}, "ZZZZ"), + # Some models report "Blank" as serial number, which we should treat it as not reported. + ({"SERIALNO": "Blank"}, "APC UPS"), ({}, "APC UPS"), ], ) diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 723ec164eae..6bb94ca2948 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -31,6 +31,8 @@ from tests.common import MockConfigEntry, async_fire_time_changed # Does not contain either "SERIALNO" field. # We should _not_ create devices for the entities and their IDs will not have prefixes. MOCK_MINIMAL_STATUS, + # Some models report "Blank" as SERIALNO, but we should treat it as not reported. + MOCK_MINIMAL_STATUS | {"SERIALNO": "Blank"}, ], ) async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> None: @@ -41,7 +43,7 @@ async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> No await async_init_integration(hass, status=status) prefix = "" - if "SERIALNO" in status: + if "SERIALNO" in status and status["SERIALNO"] != "Blank": prefix = slugify(status.get("UPSNAME", "APC UPS")) + "_" # Verify successful setup by querying the status sensor. @@ -56,6 +58,8 @@ async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> No [ # We should not create device entries if SERIALNO is not reported. MOCK_MINIMAL_STATUS, + # Some models report "Blank" as SERIALNO, but we should treat it as not reported. + MOCK_MINIMAL_STATUS | {"SERIALNO": "Blank"}, # We should set the device name to be the friendly UPSNAME field if available. MOCK_MINIMAL_STATUS | {"SERIALNO": "XXXX", "UPSNAME": "MyUPS"}, # Otherwise, we should fall back to default device name --- "APC UPS". @@ -71,7 +75,7 @@ async def test_device_entry( await async_init_integration(hass, status=status) # Verify device info is properly set up. - if "SERIALNO" not in status: + if "SERIALNO" not in status or status["SERIALNO"] == "Blank": assert len(device_registry.devices) == 0 return From 1f8129f4b83e670004b6c49632dbc214828b992a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 23 Jan 2025 14:02:30 +0100 Subject: [PATCH 0913/2987] Update peblar to v0.4.0 (#136329) * Update peblar to v0.4.0 * Update snapshots --- homeassistant/components/peblar/const.py | 2 +- homeassistant/components/peblar/manifest.json | 2 +- homeassistant/components/peblar/strings.json | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/peblar/snapshots/test_diagnostics.ambr | 5 ----- tests/components/peblar/snapshots/test_sensor.ambr | 4 ++-- 7 files changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/peblar/const.py b/homeassistant/components/peblar/const.py index d7d7c2fa5b5..58fcc9b85da 100644 --- a/homeassistant/components/peblar/const.py +++ b/homeassistant/components/peblar/const.py @@ -23,7 +23,7 @@ PEBLAR_CHARGE_LIMITER_TO_HOME_ASSISTANT = { ChargeLimiter.INSTALLATION_LIMIT: "installation_limit", ChargeLimiter.LOCAL_MODBUS_API: "local_modbus_api", ChargeLimiter.LOCAL_REST_API: "local_rest_api", - ChargeLimiter.LOCAL_SCHEDULED: "local_scheduled", + ChargeLimiter.LOCAL_SCHEDULED_CHARGING: "local_scheduled_charging", ChargeLimiter.OCPP_SMART_CHARGING: "ocpp_smart_charging", ChargeLimiter.OVERCURRENT_PROTECTION: "overcurrent_protection", ChargeLimiter.PHASE_IMBALANCE: "phase_imbalance", diff --git a/homeassistant/components/peblar/manifest.json b/homeassistant/components/peblar/manifest.json index 859682d3f1d..e2ae96de988 100644 --- a/homeassistant/components/peblar/manifest.json +++ b/homeassistant/components/peblar/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["peblar==0.3.3"], + "requirements": ["peblar==0.4.0"], "zeroconf": [{ "type": "_http._tcp.local.", "name": "pblr-*" }] } diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index fffa2b08d85..a33667fa533 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -96,6 +96,7 @@ "installation_limit": "Installation limit", "local_modbus_api": "Modbus API", "local_rest_api": "REST API", + "local_scheduled_charging": "Scheduled charging", "ocpp_smart_charging": "OCPP smart charging", "overcurrent_protection": "Overcurrent protection", "phase_imbalance": "Phase imbalance", diff --git a/requirements_all.txt b/requirements_all.txt index c4ff74efd76..7e420323548 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1603,7 +1603,7 @@ panasonic-viera==0.4.2 pdunehd==1.3.2 # homeassistant.components.peblar -peblar==0.3.3 +peblar==0.4.0 # homeassistant.components.peco peco==0.0.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fddaad5f9ca..661e9188b3f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1330,7 +1330,7 @@ panasonic-viera==0.4.2 pdunehd==1.3.2 # homeassistant.components.peblar -peblar==0.3.3 +peblar==0.4.0 # homeassistant.components.peco peco==0.0.30 diff --git a/tests/components/peblar/snapshots/test_diagnostics.ambr b/tests/components/peblar/snapshots/test_diagnostics.ambr index e33a2f557de..fbcdcfbaff5 100644 --- a/tests/components/peblar/snapshots/test_diagnostics.ambr +++ b/tests/components/peblar/snapshots/test_diagnostics.ambr @@ -51,10 +51,8 @@ 'Hostname': 'PBLR-0000645', 'HwFixedCableRating': 20, 'HwFwCompat': 'wlac-2', - 'HwHas4pRelay': False, 'HwHasBop': True, 'HwHasBuzzer': True, - 'HwHasDualSocket': False, 'HwHasEichrechtLaserMarking': False, 'HwHasEthernet': True, 'HwHasLed': True, @@ -64,13 +62,11 @@ 'HwHasPlc': False, 'HwHasRfid': True, 'HwHasRs485': True, - 'HwHasShutter': False, 'HwHasSocket': False, 'HwHasTpm': False, 'HwHasWlan': True, 'HwMaxCurrent': 16, 'HwOneOrThreePhase': 3, - 'HwUKCompliant': False, 'MainboardPn': '6004-2300-7600', 'MainboardSn': '23-38-A4E-2MC', 'MeterCalIGainA': 267369, @@ -86,7 +82,6 @@ 'MeterCalVGainB': 246074, 'MeterCalVGainC': 230191, 'MeterFwIdent': 'b9cbcd', - 'NorFlash': 'True', 'ProductModelName': 'WLAC1-H11R0WE0ICR00', 'ProductPn': '6004-2300-8002', 'ProductSn': '23-45-A4O-MOF', diff --git a/tests/components/peblar/snapshots/test_sensor.ambr b/tests/components/peblar/snapshots/test_sensor.ambr index da17a4661ee..bb1a3eb34d6 100644 --- a/tests/components/peblar/snapshots/test_sensor.ambr +++ b/tests/components/peblar/snapshots/test_sensor.ambr @@ -302,7 +302,7 @@ 'installation_limit', 'local_modbus_api', 'local_rest_api', - 'local_scheduled', + 'local_scheduled_charging', 'ocpp_smart_charging', 'overcurrent_protection', 'phase_imbalance', @@ -354,7 +354,7 @@ 'installation_limit', 'local_modbus_api', 'local_rest_api', - 'local_scheduled', + 'local_scheduled_charging', 'ocpp_smart_charging', 'overcurrent_protection', 'phase_imbalance', From 4cf1b1a707e1c47851926de42cd3f0c5dd9c380c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Thu, 23 Jan 2025 18:04:00 +0100 Subject: [PATCH 0914/2987] Avoid keyerror on incomplete api data in myuplink (#136333) * Avoid keyerror * Inject erroneous value in device point fixture * Update diagnostics snapshot --- homeassistant/components/myuplink/sensor.py | 4 ++-- .../myuplink/fixtures/device_points_nibe_f730.json | 2 +- tests/components/myuplink/snapshots/test_diagnostics.ambr | 4 ++-- tests/components/myuplink/snapshots/test_sensor.ambr | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py index ef827fc1fb1..fa50e8a7001 100644 --- a/homeassistant/components/myuplink/sensor.py +++ b/homeassistant/components/myuplink/sensor.py @@ -325,10 +325,10 @@ class MyUplinkEnumSensor(MyUplinkDevicePointSensor): } @property - def native_value(self) -> str: + def native_value(self) -> str | None: """Sensor state value for enum sensor.""" device_point = self.coordinator.data.points[self.device_id][self.point_id] - return self.options_map[str(int(device_point.value))] # type: ignore[no-any-return] + return self.options_map.get(str(int(device_point.value))) class MyUplinkEnumRawSensor(MyUplinkDevicePointSensor): diff --git a/tests/components/myuplink/fixtures/device_points_nibe_f730.json b/tests/components/myuplink/fixtures/device_points_nibe_f730.json index 0a61ab05f21..795a89e7e13 100644 --- a/tests/components/myuplink/fixtures/device_points_nibe_f730.json +++ b/tests/components/myuplink/fixtures/device_points_nibe_f730.json @@ -822,7 +822,7 @@ "parameterUnit": "", "writable": false, "timestamp": "2024-02-08T19:13:05+00:00", - "value": 30, + "value": 31, "strVal": "Heating", "smartHomeCategories": [], "minValue": null, diff --git a/tests/components/myuplink/snapshots/test_diagnostics.ambr b/tests/components/myuplink/snapshots/test_diagnostics.ambr index 6fe6becff11..521823e282d 100644 --- a/tests/components/myuplink/snapshots/test_diagnostics.ambr +++ b/tests/components/myuplink/snapshots/test_diagnostics.ambr @@ -883,7 +883,7 @@ "parameterUnit": "", "writable": false, "timestamp": "2024-02-08T19:13:05+00:00", - "value": 30, + "value": 31, "strVal": "Heating", "smartHomeCategories": [], "minValue": null, @@ -2045,7 +2045,7 @@ "parameterUnit": "", "writable": false, "timestamp": "2024-02-08T19:13:05+00:00", - "value": 30, + "value": 31, "strVal": "Heating", "smartHomeCategories": [], "minValue": null, diff --git a/tests/components/myuplink/snapshots/test_sensor.ambr b/tests/components/myuplink/snapshots/test_sensor.ambr index a5469dc9a77..34acbbb8785 100644 --- a/tests/components/myuplink/snapshots/test_sensor.ambr +++ b/tests/components/myuplink/snapshots/test_sensor.ambr @@ -3396,7 +3396,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Heating', + 'state': 'unknown', }) # --- # name: test_sensor_states[sensor.gotham_city_priority_2-entry] @@ -3462,7 +3462,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Heating', + 'state': 'unknown', }) # --- # name: test_sensor_states[sensor.gotham_city_priority_raw-entry] @@ -3508,7 +3508,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30', + 'state': '31', }) # --- # name: test_sensor_states[sensor.gotham_city_priority_raw_2-entry] @@ -3554,7 +3554,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30', + 'state': '31', }) # --- # name: test_sensor_states[sensor.gotham_city_r_start_diff_additional_heat-entry] From 4b13c20e7418ddaf2ad1b00777479e8814dde493 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 23 Jan 2025 15:49:18 +0100 Subject: [PATCH 0915/2987] Update frontend to 20250109.1 (#136339) --- 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 3d9f12bd3d3..3a736429516 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==20250109.0"] + "requirements": ["home-assistant-frontend==20250109.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3d9ecdece06..ec4da61054c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ habluetooth==3.7.0 hass-nabucasa==0.87.0 hassil==2.1.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250109.0 +home-assistant-frontend==20250109.1 home-assistant-intents==2025.1.1 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 7e420323548..8567768d7ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1134,7 +1134,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250109.0 +home-assistant-frontend==20250109.1 # homeassistant.components.conversation home-assistant-intents==2025.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 661e9188b3f..805e2fb1cf2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -963,7 +963,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250109.0 +home-assistant-frontend==20250109.1 # homeassistant.components.conversation home-assistant-intents==2025.1.1 From 7590a868b92edf0e8937e2386866a76b128b0a7f Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 23 Jan 2025 17:18:00 +0100 Subject: [PATCH 0916/2987] Update frontend to 20250109.2 (#136348) --- 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 3a736429516..2724569d1ed 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==20250109.1"] + "requirements": ["home-assistant-frontend==20250109.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ec4da61054c..061ff2a0ef7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ habluetooth==3.7.0 hass-nabucasa==0.87.0 hassil==2.1.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250109.1 +home-assistant-frontend==20250109.2 home-assistant-intents==2025.1.1 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8567768d7ae..0cd084daffd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1134,7 +1134,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250109.1 +home-assistant-frontend==20250109.2 # homeassistant.components.conversation home-assistant-intents==2025.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 805e2fb1cf2..28e8711a1de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -963,7 +963,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250109.1 +home-assistant-frontend==20250109.2 # homeassistant.components.conversation home-assistant-intents==2025.1.1 From acbbb1978888a884df1e123e2962794e3291e63f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 23 Jan 2025 17:17:07 +0100 Subject: [PATCH 0917/2987] Bump aiowithings to 3.1.5 (#136350) --- homeassistant/components/withings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index ad9b9a6fe71..4c78e077d21 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/withings", "iot_class": "cloud_push", "loggers": ["aiowithings"], - "requirements": ["aiowithings==3.1.4"] + "requirements": ["aiowithings==3.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0cd084daffd..b136d731e1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -416,7 +416,7 @@ aiowatttime==0.1.1 aiowebostv==0.4.2 # homeassistant.components.withings -aiowithings==3.1.4 +aiowithings==3.1.5 # homeassistant.components.yandex_transport aioymaps==1.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 28e8711a1de..20f06aa5caf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -398,7 +398,7 @@ aiowatttime==0.1.1 aiowebostv==0.4.2 # homeassistant.components.withings -aiowithings==3.1.4 +aiowithings==3.1.5 # homeassistant.components.yandex_transport aioymaps==1.2.5 From b9443fa204bf6a1d47a7eeceb5ed6c25caa82598 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Thu, 23 Jan 2025 20:52:54 +0100 Subject: [PATCH 0918/2987] Bump powerfox to v1.2.1 (#136366) --- homeassistant/components/powerfox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/powerfox/manifest.json b/homeassistant/components/powerfox/manifest.json index bb72d73b5a8..3938eb01a1b 100644 --- a/homeassistant/components/powerfox/manifest.json +++ b/homeassistant/components/powerfox/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/powerfox", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["powerfox==1.2.0"], + "requirements": ["powerfox==1.2.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index b136d731e1e..9fd6a3af6a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1650,7 +1650,7 @@ pmsensor==0.4 poolsense==0.0.8 # homeassistant.components.powerfox -powerfox==1.2.0 +powerfox==1.2.1 # homeassistant.components.reddit praw==7.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20f06aa5caf..5d63c7a5c61 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1360,7 +1360,7 @@ plumlightpad==0.0.11 poolsense==0.0.8 # homeassistant.components.powerfox -powerfox==1.2.0 +powerfox==1.2.1 # homeassistant.components.reddit praw==7.5.0 From 223b437cb96a6f627ec7383f96b59cc61c9ac560 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 24 Jan 2025 08:02:10 +0000 Subject: [PATCH 0919/2987] Bump version to 2025.1.4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f5046b510f8..101cd2e3173 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "3" +PATCH_VERSION: Final = "4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index e24dbcd58e5..fad27cfd7f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.1.3" +version = "2025.1.4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From f3074dc218dc46b25da2206dfa2bb590b5ac8c58 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Jan 2025 22:24:12 -1000 Subject: [PATCH 0920/2987] Bump aioharmony to 0.4.0 (#136398) --- homeassistant/components/harmony/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index d37801376ec..28fc084a2ef 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/harmony", "iot_class": "local_push", "loggers": ["aioharmony", "slixmpp"], - "requirements": ["aioharmony==0.2.10"], + "requirements": ["aioharmony==0.4.0"], "ssdp": [ { "manufacturer": "Logitech", diff --git a/requirements_all.txt b/requirements_all.txt index 8f8c7008235..c273379888a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -258,7 +258,7 @@ aiogithubapi==24.6.0 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.2.10 +aioharmony==0.4.0 # homeassistant.components.hassio aiohasupervisor==0.2.2b5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3751dd24184..5ffd27543a9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -243,7 +243,7 @@ aiogithubapi==24.6.0 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.2.10 +aioharmony==0.4.0 # homeassistant.components.hassio aiohasupervisor==0.2.2b5 From 5a30156372bbf5215654b094b7e29497c1beeabc Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 24 Jan 2025 09:38:38 +0100 Subject: [PATCH 0921/2987] Bump aioautomower to 2025.1.1 (#136365) --- .../components/husqvarna_automower/coordinator.py | 12 ++++++------ .../components/husqvarna_automower/entity.py | 4 ++-- .../components/husqvarna_automower/manifest.json | 2 +- .../components/husqvarna_automower/switch.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/husqvarna_automower/test_button.py | 6 +++--- tests/components/husqvarna_automower/test_init.py | 12 ++++++------ .../husqvarna_automower/test_lawn_mower.py | 8 ++++---- tests/components/husqvarna_automower/test_number.py | 6 +++--- tests/components/husqvarna_automower/test_select.py | 4 ++-- tests/components/husqvarna_automower/test_switch.py | 10 +++++----- 12 files changed, 36 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 2921b5ca68e..a587b4f3821 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -9,10 +9,10 @@ import logging from typing import TYPE_CHECKING from aioautomower.exceptions import ( - ApiException, - AuthException, + ApiError, + AuthError, + HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, - TimeoutException, ) from aioautomower.model import MowerAttributes from aioautomower.session import AutomowerSession @@ -64,9 +64,9 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib self.ws_connected = True try: data = await self.api.get_status() - except ApiException as err: + except ApiError as err: raise UpdateFailed(err) from err - except AuthException as err: + except AuthError as err: raise ConfigEntryAuthFailed(err) from err self._async_add_remove_devices(data) @@ -100,7 +100,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib "Failed to connect to websocket. Trying to reconnect: %s", err, ) - except TimeoutException as err: + except HusqvarnaTimeoutError as err: _LOGGER.debug( "Failed to listen to websocket. Trying to reconnect: %s", err, diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py index 5b5156e5f1d..150a3d18d87 100644 --- a/homeassistant/components/husqvarna_automower/entity.py +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -8,7 +8,7 @@ import functools import logging from typing import TYPE_CHECKING, Any, Concatenate -from aioautomower.exceptions import ApiException +from aioautomower.exceptions import ApiError from aioautomower.model import MowerActivities, MowerAttributes, MowerStates, WorkArea from homeassistant.core import callback @@ -67,7 +67,7 @@ def handle_sending_exception[_Entity: AutomowerBaseEntity, **_P]( async def wrapper(self: _Entity, *args: _P.args, **kwargs: _P.kwargs) -> None: try: await func(self, *args, **kwargs) - except ApiException as exception: + except ApiError as exception: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="command_send_failed", diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 1eed2be4575..0eabf5ec0d6 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2025.1.0"] + "requirements": ["aioautomower==2025.1.1"] } diff --git a/homeassistant/components/husqvarna_automower/switch.py b/homeassistant/components/husqvarna_automower/switch.py index b8004e17066..d55d51b42fe 100644 --- a/homeassistant/components/husqvarna_automower/switch.py +++ b/homeassistant/components/husqvarna_automower/switch.py @@ -165,14 +165,14 @@ class StayOutZoneSwitchEntity(AutomowerControlEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.coordinator.api.commands.switch_stay_out_zone( - self.mower_id, self.stay_out_zone_uid, False + self.mower_id, self.stay_out_zone_uid, switch=False ) @handle_sending_exception(poll_after_sending=True) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self.coordinator.api.commands.switch_stay_out_zone( - self.mower_id, self.stay_out_zone_uid, True + self.mower_id, self.stay_out_zone_uid, switch=True ) diff --git a/requirements_all.txt b/requirements_all.txt index c273379888a..6a4302ae99a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -201,7 +201,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.1.0 +aioautomower==2025.1.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ffd27543a9..17b6de05451 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -189,7 +189,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.1.0 +aioautomower==2025.1.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/tests/components/husqvarna_automower/test_button.py b/tests/components/husqvarna_automower/test_button.py index 25fa64b531f..5bef810150d 100644 --- a/tests/components/husqvarna_automower/test_button.py +++ b/tests/components/husqvarna_automower/test_button.py @@ -3,7 +3,7 @@ import datetime from unittest.mock import AsyncMock, patch -from aioautomower.exceptions import ApiException +from aioautomower.exceptions import ApiError from aioautomower.model import MowerAttributes from freezegun.api import FrozenDateTimeFactory import pytest @@ -69,7 +69,7 @@ async def test_button_states_and_commands( await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == "2023-06-05T00:16:00+00:00" - getattr(mock_automower_client.commands, "error_confirm").side_effect = ApiException( + getattr(mock_automower_client.commands, "error_confirm").side_effect = ApiError( "Test error" ) with pytest.raises( @@ -111,7 +111,7 @@ async def test_sync_clock( await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == "2024-02-29T11:00:00+00:00" - mock_automower_client.commands.set_datetime.side_effect = ApiException("Test error") + mock_automower_client.commands.set_datetime.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index 627cd065e79..ec1fb7391b4 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -7,10 +7,10 @@ import time from unittest.mock import AsyncMock, patch from aioautomower.exceptions import ( - ApiException, - AuthException, + ApiError, + AuthError, + HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, - TimeoutException, ) from aioautomower.model import MowerAttributes, WorkArea from freezegun.api import FrozenDateTimeFactory @@ -111,8 +111,8 @@ async def test_expired_token_refresh_failure( @pytest.mark.parametrize( ("exception", "entry_state"), [ - (ApiException, ConfigEntryState.SETUP_RETRY), - (AuthException, ConfigEntryState.SETUP_ERROR), + (ApiError, ConfigEntryState.SETUP_RETRY), + (AuthError, ConfigEntryState.SETUP_ERROR), ], ) async def test_update_failed( @@ -142,7 +142,7 @@ async def test_update_failed( ), ( ["start_listening"], - TimeoutException, + HusqvarnaTimeoutError, "Failed to listen to websocket.", ), ], diff --git a/tests/components/husqvarna_automower/test_lawn_mower.py b/tests/components/husqvarna_automower/test_lawn_mower.py index 3aca509e865..044989e5cf0 100644 --- a/tests/components/husqvarna_automower/test_lawn_mower.py +++ b/tests/components/husqvarna_automower/test_lawn_mower.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock -from aioautomower.exceptions import ApiException +from aioautomower.exceptions import ApiError from aioautomower.model import MowerActivities, MowerAttributes, MowerStates from freezegun.api import FrozenDateTimeFactory import pytest @@ -82,7 +82,7 @@ async def test_lawn_mower_commands( getattr( mock_automower_client.commands, aioautomower_command - ).side_effect = ApiException("Test error") + ).side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -142,7 +142,7 @@ async def test_lawn_mower_service_commands( getattr( mock_automower_client.commands, aioautomower_command - ).side_effect = ApiException("Test error") + ).side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -196,7 +196,7 @@ async def test_lawn_mower_override_work_area_command( getattr( mock_automower_client.commands, aioautomower_command - ).side_effect = ApiException("Test error") + ).side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/husqvarna_automower/test_number.py b/tests/components/husqvarna_automower/test_number.py index e1f232e7b5c..55bf5dda7eb 100644 --- a/tests/components/husqvarna_automower/test_number.py +++ b/tests/components/husqvarna_automower/test_number.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from aioautomower.exceptions import ApiException +from aioautomower.exceptions import ApiError from aioautomower.model import MowerAttributes from freezegun.api import FrozenDateTimeFactory import pytest @@ -40,7 +40,7 @@ async def test_number_commands( mocked_method = mock_automower_client.commands.set_cutting_height mocked_method.assert_called_once_with(TEST_MOWER_ID, 3) - mocked_method.side_effect = ApiException("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -84,7 +84,7 @@ async def test_number_workarea_commands( assert state.state is not None assert state.state == "75" - mocked_method.side_effect = ApiException("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/husqvarna_automower/test_select.py b/tests/components/husqvarna_automower/test_select.py index 18d1b0ed21f..01e7607735b 100644 --- a/tests/components/husqvarna_automower/test_select.py +++ b/tests/components/husqvarna_automower/test_select.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aioautomower.exceptions import ApiException +from aioautomower.exceptions import ApiError from aioautomower.model import HeadlightModes, MowerAttributes from freezegun.api import FrozenDateTimeFactory import pytest @@ -77,7 +77,7 @@ async def test_select_commands( mocked_method.assert_called_once_with(TEST_MOWER_ID, service.upper()) assert len(mocked_method.mock_calls) == 1 - mocked_method.side_effect = ApiException("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/husqvarna_automower/test_switch.py b/tests/components/husqvarna_automower/test_switch.py index 100fd9fe3a4..48903a9630b 100644 --- a/tests/components/husqvarna_automower/test_switch.py +++ b/tests/components/husqvarna_automower/test_switch.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch import zoneinfo -from aioautomower.exceptions import ApiException +from aioautomower.exceptions import ApiError from aioautomower.model import MowerAttributes, MowerModes, Zone from aioautomower.utils import mower_list_to_dictionary_dataclass from freezegun.api import FrozenDateTimeFactory @@ -92,7 +92,7 @@ async def test_switch_commands( mocked_method = getattr(mock_automower_client.commands, aioautomower_command) mocked_method.assert_called_once_with(TEST_MOWER_ID) - mocked_method.side_effect = ApiException("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -144,12 +144,12 @@ async def test_stay_out_zone_switch_commands( freezer.tick(timedelta(seconds=EXECUTION_TIME_DELAY)) async_fire_time_changed(hass) await hass.async_block_till_done() - mocked_method.assert_called_once_with(TEST_MOWER_ID, TEST_ZONE_ID, boolean) + mocked_method.assert_called_once_with(TEST_MOWER_ID, TEST_ZONE_ID, switch=boolean) state = hass.states.get(entity_id) assert state is not None assert state.state == excepted_state - mocked_method.side_effect = ApiException("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -207,7 +207,7 @@ async def test_work_area_switch_commands( assert state is not None assert state.state == excepted_state - mocked_method.side_effect = ApiException("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", From 0abdda7abb603c7cf1056d7522162f90527540c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Jan 2025 23:30:49 -1000 Subject: [PATCH 0922/2987] Bump WSDiscovery to 2.1.2 (#136363) --- homeassistant/components/onvif/config_flow.py | 15 +++++++++++---- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index fc5de57508b..f645444f9c6 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -11,6 +11,7 @@ from urllib.parse import urlparse from onvif.util import is_auth_error, stringify_onvif_error import voluptuous as vol from wsdiscovery.discovery import ThreadedWSDiscovery as WSDiscovery +from wsdiscovery.qname import QName from wsdiscovery.scope import Scope from wsdiscovery.service import Service from zeep.exceptions import Fault @@ -58,16 +59,22 @@ CONF_MANUAL_INPUT = "Manually configure ONVIF device" def wsdiscovery() -> list[Service]: """Get ONVIF Profile S devices from network.""" - discovery = WSDiscovery(ttl=4) + discovery = WSDiscovery(ttl=4, relates_to=True) try: discovery.start() return discovery.searchServices( - scopes=[Scope("onvif://www.onvif.org/Profile/Streaming")] + types=[ + QName( + "http://www.onvif.org/ver10/network/wsdl", + "NetworkVideoTransmitter", + "dp0", + ) + ], + scopes=[Scope("onvif://www.onvif.org/Profile/Streaming")], + timeout=10, ) finally: discovery.stop() - # Stop the threads started by WSDiscovery since otherwise there is a leak. - discovery._stopThreads() # noqa: SLF001 async def async_discovery(hass: HomeAssistant) -> list[dict[str, Any]]: diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index c4d2b7f8812..78df5130aed 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==3.2.5", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==3.2.5", "WSDiscovery==2.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6a4302ae99a..888f95f0061 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -128,7 +128,7 @@ TravisPy==0.3.5 TwitterAPI==2.7.12 # homeassistant.components.onvif -WSDiscovery==2.0.0 +WSDiscovery==2.1.2 # homeassistant.components.accuweather accuweather==4.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 17b6de05451..896e2930378 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -116,7 +116,7 @@ SQLAlchemy==2.0.36 Tami4EdgeAPI==3.0 # homeassistant.components.onvif -WSDiscovery==2.0.0 +WSDiscovery==2.1.2 # homeassistant.components.accuweather accuweather==4.0.0 From 6fde10ef9e4401df8a89a50551bd917976da035e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jan 2025 11:23:23 +0100 Subject: [PATCH 0923/2987] Move denonavr shared constants to central location (#136421) --- homeassistant/components/denonavr/__init__.py | 2 +- .../components/denonavr/config_flow.py | 32 +++++++++---------- homeassistant/components/denonavr/const.py | 19 +++++++++++ .../components/denonavr/media_player.py | 5 ++- 4 files changed, 38 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/denonavr/const.py diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py index 98b77a994f6..24d119e4be7 100644 --- a/homeassistant/components/denonavr/__init__.py +++ b/homeassistant/components/denonavr/__init__.py @@ -12,7 +12,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er from homeassistant.helpers.httpx_client import get_async_client -from .config_flow import ( +from .const import ( CONF_SHOW_ALL_SOURCES, CONF_UPDATE_AUDYSSEY, CONF_USE_TELNET, diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py index 9601b67081c..14342ddc822 100644 --- a/homeassistant/components/denonavr/config_flow.py +++ b/homeassistant/components/denonavr/config_flow.py @@ -27,29 +27,29 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) +from .const import ( + CONF_MANUFACTURER, + CONF_SERIAL_NUMBER, + CONF_SHOW_ALL_SOURCES, + CONF_UPDATE_AUDYSSEY, + CONF_USE_TELNET, + CONF_ZONE2, + CONF_ZONE3, + DEFAULT_SHOW_SOURCES, + DEFAULT_TIMEOUT, + DEFAULT_UPDATE_AUDYSSEY, + DEFAULT_USE_TELNET, + DEFAULT_ZONE2, + DEFAULT_ZONE3, + DOMAIN, +) from .receiver import ConnectDenonAVR _LOGGER = logging.getLogger(__name__) -DOMAIN = "denonavr" - SUPPORTED_MANUFACTURERS = ["Denon", "DENON", "DENON PROFESSIONAL", "Marantz"] IGNORED_MODELS = ["HEOS 1", "HEOS 3", "HEOS 5", "HEOS 7"] -CONF_SHOW_ALL_SOURCES = "show_all_sources" -CONF_ZONE2 = "zone2" -CONF_ZONE3 = "zone3" -CONF_MANUFACTURER = "manufacturer" -CONF_SERIAL_NUMBER = "serial_number" -CONF_UPDATE_AUDYSSEY = "update_audyssey" -CONF_USE_TELNET = "use_telnet" - -DEFAULT_SHOW_SOURCES = False -DEFAULT_TIMEOUT = 5 -DEFAULT_ZONE2 = False -DEFAULT_ZONE3 = False -DEFAULT_UPDATE_AUDYSSEY = False -DEFAULT_USE_TELNET = False DEFAULT_USE_TELNET_NEW_INSTALL = True CONFIG_SCHEMA = vol.Schema({vol.Optional(CONF_HOST): str}) diff --git a/homeassistant/components/denonavr/const.py b/homeassistant/components/denonavr/const.py new file mode 100644 index 00000000000..d28044ec018 --- /dev/null +++ b/homeassistant/components/denonavr/const.py @@ -0,0 +1,19 @@ +"""Constants for Denon AVR.""" + +DOMAIN = "denonavr" + + +CONF_SHOW_ALL_SOURCES = "show_all_sources" +CONF_ZONE2 = "zone2" +CONF_ZONE3 = "zone3" +CONF_MANUFACTURER = "manufacturer" +CONF_SERIAL_NUMBER = "serial_number" +CONF_UPDATE_AUDYSSEY = "update_audyssey" +CONF_USE_TELNET = "use_telnet" + +DEFAULT_SHOW_SOURCES = False +DEFAULT_TIMEOUT = 5 +DEFAULT_ZONE2 = False +DEFAULT_ZONE3 = False +DEFAULT_UPDATE_AUDYSSEY = False +DEFAULT_USE_TELNET = False diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 03d1b00cfaf..ba9318794df 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -36,17 +36,16 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_MODEL +from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_MODEL, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import CONF_RECEIVER -from .config_flow import ( +from .const import ( CONF_MANUFACTURER, CONF_SERIAL_NUMBER, - CONF_TYPE, CONF_UPDATE_AUDYSSEY, DEFAULT_UPDATE_AUDYSSEY, DOMAIN, From 4e89c2322beb96b653ce70bb44fc983885a015fc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jan 2025 11:26:09 +0100 Subject: [PATCH 0924/2987] Simplify update listener in denonavr (#136422) --- homeassistant/components/denonavr/__init__.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py index 24d119e4be7..a0ef454cc2d 100644 --- a/homeassistant/components/denonavr/__init__.py +++ b/homeassistant/components/denonavr/__init__.py @@ -29,7 +29,6 @@ from .const import ( from .receiver import ConnectDenonAVR CONF_RECEIVER = "receiver" -UNDO_UPDATE_LISTENER = "undo_update_listener" PLATFORMS = [Platform.MEDIA_PLAYER] _LOGGER = logging.getLogger(__name__) @@ -56,11 +55,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from ex receiver = connect_denonavr.receiver - undo_listener = entry.add_update_listener(update_listener) + entry.async_on_unload(entry.add_update_listener(update_listener)) hass.data[DOMAIN][entry.entry_id] = { CONF_RECEIVER: receiver, - UNDO_UPDATE_LISTENER: undo_listener, } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -89,8 +87,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> receiver: DenonAVR = hass.data[DOMAIN][config_entry.entry_id][CONF_RECEIVER] await receiver.async_telnet_disconnect() - hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() - # Remove zone2 and zone3 entities if needed entity_registry = er.async_get(hass) entries = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) From 20e936c7b93fa0c6cec3d5c37df67aab0f54104e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 24 Jan 2025 11:33:25 +0100 Subject: [PATCH 0925/2987] Omit Peblar update entities for most white label devices (#136374) --- homeassistant/components/peblar/update.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/peblar/update.py b/homeassistant/components/peblar/update.py index 9e132da63bc..58c2fbdc899 100644 --- a/homeassistant/components/peblar/update.py +++ b/homeassistant/components/peblar/update.py @@ -37,14 +37,14 @@ DESCRIPTIONS: tuple[PeblarUpdateEntityDescription, ...] = ( key="firmware", device_class=UpdateDeviceClass.FIRMWARE, installed_fn=lambda x: x.current.firmware, - has_fn=lambda x: x.current.firmware is not None, + has_fn=lambda x: x.available.firmware is not None, available_fn=lambda x: x.available.firmware, ), PeblarUpdateEntityDescription( key="customization", translation_key="customization", available_fn=lambda x: x.available.customization, - has_fn=lambda x: x.current.customization is not None, + has_fn=lambda x: x.available.customization is not None, installed_fn=lambda x: x.current.customization, ), ) From c2fe7230b565a128f9bb108b99e8edce3ebe8a34 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jan 2025 11:38:24 +0100 Subject: [PATCH 0926/2987] Use runtime_data in denonavr (#136424) --- homeassistant/components/denonavr/__init__.py | 25 ++++++++----------- .../components/denonavr/config_flow.py | 10 +++----- .../components/denonavr/media_player.py | 10 +++----- 3 files changed, 18 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py index a0ef454cc2d..da2b601317a 100644 --- a/homeassistant/components/denonavr/__init__.py +++ b/homeassistant/components/denonavr/__init__.py @@ -24,20 +24,18 @@ from .const import ( DEFAULT_USE_TELNET, DEFAULT_ZONE2, DEFAULT_ZONE3, - DOMAIN, ) from .receiver import ConnectDenonAVR -CONF_RECEIVER = "receiver" PLATFORMS = [Platform.MEDIA_PLAYER] _LOGGER = logging.getLogger(__name__) +type DenonavrConfigEntry = ConfigEntry[DenonAVR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: DenonavrConfigEntry) -> bool: """Set up the denonavr components from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - # Connect to receiver connect_denonavr = ConnectDenonAVR( entry.data[CONF_HOST], @@ -57,9 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.data[DOMAIN][entry.entry_id] = { - CONF_RECEIVER: receiver, - } + entry.runtime_data = receiver await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) use_telnet = entry.options.get(CONF_USE_TELNET, DEFAULT_USE_TELNET) @@ -77,14 +73,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: DenonavrConfigEntry +) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) if config_entry.options.get(CONF_USE_TELNET, DEFAULT_USE_TELNET): - receiver: DenonAVR = hass.data[DOMAIN][config_entry.entry_id][CONF_RECEIVER] + receiver = config_entry.runtime_data await receiver.async_telnet_disconnect() # Remove zone2 and zone3 entities if needed @@ -101,12 +99,11 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> entity_registry.async_remove(entry.entity_id) _LOGGER.debug("Removing zone3 from DenonAvr") - if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) - return unload_ok -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: DenonavrConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py index 14342ddc822..930d0e009ac 100644 --- a/homeassistant/components/denonavr/config_flow.py +++ b/homeassistant/components/denonavr/config_flow.py @@ -10,12 +10,7 @@ import denonavr from denonavr.exceptions import AvrNetworkError, AvrTimoutError import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TYPE from homeassistant.core import callback from homeassistant.helpers.httpx_client import get_async_client @@ -27,6 +22,7 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) +from . import DenonavrConfigEntry from .const import ( CONF_MANUFACTURER, CONF_SERIAL_NUMBER, @@ -118,7 +114,7 @@ class DenonAvrFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: DenonavrConfigEntry, ) -> OptionsFlowHandler: """Get the options flow.""" return OptionsFlowHandler() diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index ba9318794df..818d530ddab 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -35,14 +35,13 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_MODEL, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import CONF_RECEIVER +from . import DenonavrConfigEntry from .const import ( CONF_MANUFACTURER, CONF_SERIAL_NUMBER, @@ -109,13 +108,12 @@ DENON_STATE_MAPPING = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DenonavrConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the DenonAVR receiver from a config entry.""" entities = [] - data = hass.data[DOMAIN][config_entry.entry_id] - receiver = data[CONF_RECEIVER] + receiver = config_entry.runtime_data update_audyssey = config_entry.options.get( CONF_UPDATE_AUDYSSEY, DEFAULT_UPDATE_AUDYSSEY ) @@ -252,7 +250,7 @@ class DenonDevice(MediaPlayerEntity): self, receiver: DenonAVR, unique_id: str, - config_entry: ConfigEntry, + config_entry: DenonavrConfigEntry, update_audyssey: bool, ) -> None: """Initialize the device.""" From 72d1ac9f922468ec940920712259d433edd8cbf2 Mon Sep 17 00:00:00 2001 From: "Glenn Vandeuren (aka Iondependent)" Date: Fri, 24 Jan 2025 11:44:15 +0100 Subject: [PATCH 0927/2987] Bump nhc to 0.3.9 (#136418) --- homeassistant/components/niko_home_control/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index a75b0d72dca..57f83180eb0 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/niko_home_control", "iot_class": "local_push", "loggers": ["nikohomecontrol"], - "requirements": ["nhc==0.3.4"] + "requirements": ["nhc==0.3.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 888f95f0061..c7916af8b95 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1482,7 +1482,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.3.4 +nhc==0.3.9 # homeassistant.components.nibe_heatpump nibe==2.14.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 896e2930378..fba1b34a3f8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1245,7 +1245,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.3.4 +nhc==0.3.9 # homeassistant.components.nibe_heatpump nibe==2.14.0 From 50cf94ca9b4647d5b9af725544380fc5fbaea500 Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Fri, 24 Jan 2025 04:50:23 -0600 Subject: [PATCH 0928/2987] Fix humidifier mode for Vesync (#135746) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/vesync/humidifier.py | 48 +++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index 9c54afdfb82..3d89d5dc6db 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -6,7 +6,6 @@ from typing import Any from pyvesync.vesyncbasedevice import VeSyncBaseDevice from homeassistant.components.humidifier import ( - ATTR_HUMIDITY, MODE_AUTO, MODE_NORMAL, MODE_SLEEP, @@ -40,8 +39,6 @@ _LOGGER = logging.getLogger(__name__) MIN_HUMIDITY = 30 MAX_HUMIDITY = 80 -VS_TO_HA_ATTRIBUTES = {ATTR_HUMIDITY: "current_humidity"} - VS_TO_HA_MODE_MAP = { VS_HUMIDIFIER_MODE_AUTO: MODE_AUTO, VS_HUMIDIFIER_MODE_HUMIDITY: MODE_AUTO, @@ -49,8 +46,6 @@ VS_TO_HA_MODE_MAP = { VS_HUMIDIFIER_MODE_SLEEP: MODE_SLEEP, } -HA_TO_VS_MODE_MAP = {v: k for k, v in VS_TO_HA_MODE_MAP.items()} - async def async_setup_entry( hass: HomeAssistant, @@ -92,10 +87,6 @@ def _get_ha_mode(vs_mode: str) -> str | None: return ha_mode -def _get_vs_mode(ha_mode: str) -> str | None: - return HA_TO_VS_MODE_MAP.get(ha_mode) - - class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): """Representation of a VeSync humidifier.""" @@ -108,14 +99,35 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): device: VeSyncHumidifierDevice + def __init__( + self, + device: VeSyncBaseDevice, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the VeSyncHumidifierHA device.""" + super().__init__(device, coordinator) + + # 2 Vesync humidifier modes (humidity and auto) maps to the HA mode auto. + # They are on different devices though. We need to map HA mode to the + # device specific mode when setting it. + + self._ha_to_vs_mode_map: dict[str, str] = {} + self._available_modes: list[str] = [] + + # Populate maps once. + for vs_mode in self.device.mist_modes: + ha_mode = _get_ha_mode(vs_mode) + if ha_mode: + self._available_modes.append(ha_mode) + self._ha_to_vs_mode_map[ha_mode] = vs_mode + + def _get_vs_mode(self, ha_mode: str) -> str | None: + return self._ha_to_vs_mode_map.get(ha_mode) + @property def available_modes(self) -> list[str]: """Return the available mist modes.""" - return [ - ha_mode - for ha_mode in (_get_ha_mode(vs_mode) for vs_mode in self.device.mist_modes) - if ha_mode - ] + return self._available_modes @property def target_humidity(self) -> int: @@ -140,9 +152,15 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): raise HomeAssistantError( "{mode} is not one of the valid available modes: {self.available_modes}" ) - if not self.device.set_humidity_mode(_get_vs_mode(mode)): + if not self.device.set_humidity_mode(self._get_vs_mode(mode)): raise HomeAssistantError(f"An error occurred while setting mode {mode}.") + # Changing mode while humidifier is off actually turns it on, as per the app. But + # the library does not seem to update the device_status. It is also possible that + # other attributes get updated. Scheduling a forced refresh to get device status. + # updated. + self.schedule_update_ha_state(force_refresh=True) + def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" success = self.device.turn_on() From a3ba3bbb1d7f7f676b23294eb7c40ccdd78f354a Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 24 Jan 2025 04:56:41 -0600 Subject: [PATCH 0929/2987] Incorporate SourceManager into HEOS Coordinator (#136377) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/heos/__init__.py | 154 +----------------- homeassistant/components/heos/const.py | 2 - homeassistant/components/heos/coordinator.py | 110 ++++++++++++- homeassistant/components/heos/media_player.py | 32 +--- tests/components/heos/conftest.py | 35 ++-- .../heos/snapshots/test_media_player.ambr | 1 + tests/components/heos/test_init.py | 31 +--- tests/components/heos/test_media_player.py | 52 ++++-- 8 files changed, 180 insertions(+), 237 deletions(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 8ca2040fd2f..2830e70b3af 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio from dataclasses import dataclass from datetime import timedelta import logging @@ -13,7 +12,7 @@ from pyheos import Heos, HeosError, HeosPlayer, const as heos_const from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -21,16 +20,9 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle from . import services -from .const import ( - COMMAND_RETRY_ATTEMPTS, - COMMAND_RETRY_DELAY, - DOMAIN, - SIGNAL_HEOS_PLAYER_ADDED, - SIGNAL_HEOS_UPDATED, -) +from .const import DOMAIN, SIGNAL_HEOS_PLAYER_ADDED, SIGNAL_HEOS_UPDATED from .coordinator import HeosCoordinator PLATFORMS = [Platform.MEDIA_PLAYER] @@ -48,7 +40,6 @@ class HeosRuntimeData: coordinator: HeosCoordinator group_manager: GroupManager - source_manager: SourceManager players: dict[int, HeosPlayer] @@ -84,17 +75,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool # Preserve existing logic until migrated into coordinator controller = coordinator.heos players = controller.players - favorites = coordinator.favorites - inputs = coordinator.inputs - - source_manager = SourceManager(favorites, inputs) - source_manager.connect_update(hass, controller) group_manager = GroupManager(hass, controller, players) - entry.runtime_data = HeosRuntimeData( - coordinator, group_manager, source_manager, players - ) + entry.runtime_data = HeosRuntimeData(coordinator, group_manager, players) group_manager.connect_update() entry.async_on_unload(group_manager.disconnect_update) @@ -234,135 +218,3 @@ class GroupManager: def group_membership(self): """Provide access to group members for player entities.""" return self._group_membership - - -class SourceManager: - """Class that manages sources for players.""" - - def __init__( - self, - favorites, - inputs, - *, - retry_delay: int = COMMAND_RETRY_DELAY, - max_retry_attempts: int = COMMAND_RETRY_ATTEMPTS, - ) -> None: - """Init input manager.""" - self.retry_delay = retry_delay - self.max_retry_attempts = max_retry_attempts - self.favorites = favorites - self.inputs = inputs - self.source_list = self._build_source_list() - - def _build_source_list(self): - """Build a single list of inputs from various types.""" - source_list = [] - source_list.extend([favorite.name for favorite in self.favorites.values()]) - source_list.extend([source.name for source in self.inputs]) - return source_list - - async def play_source(self, source: str, player): - """Determine type of source and play it.""" - index = next( - ( - index - for index, favorite in self.favorites.items() - if favorite.name == source - ), - None, - ) - if index is not None: - await player.play_preset_station(index) - return - - input_source = next( - ( - input_source - for input_source in self.inputs - if input_source.name == source - ), - None, - ) - if input_source is not None: - await player.play_input_source(input_source.media_id) - return - - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="unknown_source", - translation_placeholders={"source": source}, - ) - - def get_current_source(self, now_playing_media): - """Determine current source from now playing media.""" - # Match input by input_name:media_id - if now_playing_media.source_id == heos_const.MUSIC_SOURCE_AUX_INPUT: - return next( - ( - input_source.name - for input_source in self.inputs - if input_source.media_id == now_playing_media.media_id - ), - None, - ) - # Try matching favorite by name:station or media_id:album_id - return next( - ( - source.name - for source in self.favorites.values() - if source.name == now_playing_media.station - or source.media_id == now_playing_media.album_id - ), - None, - ) - - @callback - def connect_update(self, hass: HomeAssistant, controller: Heos) -> None: - """Connect listener for when sources change and signal player update. - - EVENT_SOURCES_CHANGED is often raised multiple times in response to a - physical event therefore throttle it. Retrieving sources immediately - after the event may fail so retry. - """ - - @Throttle(MIN_UPDATE_SOURCES) - async def get_sources(): - retry_attempts = 0 - while True: - try: - favorites = {} - if controller.is_signed_in: - favorites = await controller.get_favorites() - inputs = await controller.get_input_sources() - except HeosError as error: - if retry_attempts < self.max_retry_attempts: - retry_attempts += 1 - _LOGGER.debug( - "Error retrieving sources and will retry: %s", error - ) - await asyncio.sleep(self.retry_delay) - else: - _LOGGER.error("Unable to update sources: %s", error) - return None - else: - return favorites, inputs - - async def _update_sources() -> None: - # If throttled, it will return None - if sources := await get_sources(): - self.favorites, self.inputs = sources - self.source_list = self._build_source_list() - _LOGGER.debug("Sources updated due to changed event") - # Let players know to update - async_dispatcher_send(hass, SIGNAL_HEOS_UPDATED) - - async def _on_controller_event(event: str, data: Any | None) -> None: - if event in ( - heos_const.EVENT_SOURCES_CHANGED, - heos_const.EVENT_USER_CHANGED, - ): - await _update_sources() - - controller.add_on_connected(_update_sources) - controller.add_on_user_credentials_invalid(_update_sources) - controller.add_on_controller_event(_on_controller_event) diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index 5b2df2b5ebf..9573306905f 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -2,8 +2,6 @@ ATTR_PASSWORD = "password" ATTR_USERNAME = "username" -COMMAND_RETRY_ATTEMPTS = 2 -COMMAND_RETRY_DELAY = 1 DOMAIN = "heos" SERVICE_SIGN_IN = "sign_in" SERVICE_SIGN_OUT = "sign_out" diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 9a59b54f6a3..c3c645ea1fa 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -5,23 +5,28 @@ The coordinator is responsible for refreshing data in response to system-wide ev entities to update. Entities subscribe to entity-specific updates within the entity class itself. """ +from datetime import datetime, timedelta import logging from pyheos import ( Credentials, Heos, HeosError, + HeosNowPlayingMedia, HeosOptions, + HeosPlayer, MediaItem, + MediaType, PlayerUpdateResult, const, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.core import HassJob, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import DOMAIN @@ -50,8 +55,10 @@ class HeosCoordinator(DataUpdateCoordinator[None]): credentials=credentials, ) ) - self.favorites: dict[int, MediaItem] = {} - self.inputs: list[MediaItem] = [] + self._update_sources_pending: bool = False + self._source_list: list[str] = [] + self._favorites: dict[int, MediaItem] = {} + self._inputs: list[MediaItem] = [] super().__init__(hass, _LOGGER, config_entry=config_entry, name=DOMAIN) async def async_setup(self) -> None: @@ -99,6 +106,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]): async def _async_on_reconnected(self) -> None: """Handle when reconnected so resources are updated and entities marked available.""" await self._async_update_players() + await self._async_update_sources() _LOGGER.warning("Successfully reconnected to HEOS host %s", self.host) self.async_update_listeners() @@ -110,6 +118,31 @@ class HeosCoordinator(DataUpdateCoordinator[None]): assert data is not None if data.updated_player_ids: self._async_update_player_ids(data.updated_player_ids) + elif ( + event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED) + and not self._update_sources_pending + ): + # Update the sources after a brief delay as we may have received multiple qualifying + # events at once and devices cannot handle immediately attempting to refresh sources. + self._update_sources_pending = True + + async def update_sources_job(_: datetime | None = None) -> None: + await self._async_update_sources() + self._update_sources_pending = False + self.async_update_listeners() + + assert self.config_entry is not None + self.config_entry.async_on_unload( + async_call_later( + self.hass, + timedelta(seconds=1), + HassJob( + update_sources_job, + "heos_update_sources", + cancel_on_shutdown=True, + ), + ) + ) self.async_update_listeners() def _async_update_player_ids(self, updated_player_ids: dict[int, int]) -> None: @@ -145,17 +178,24 @@ class HeosCoordinator(DataUpdateCoordinator[None]): async def _async_update_sources(self) -> None: """Build source list for entities.""" + self._source_list.clear() # Get favorites only if reportedly signed in. if self.heos.is_signed_in: try: - self.favorites = await self.heos.get_favorites() + self._favorites = await self.heos.get_favorites() except HeosError as error: _LOGGER.error("Unable to retrieve favorites: %s", error) + else: + self._source_list.extend( + favorite.name for favorite in self._favorites.values() + ) # Get input sources (across all devices in the HEOS system) try: - self.inputs = await self.heos.get_input_sources() + self._inputs = await self.heos.get_input_sources() except HeosError as error: _LOGGER.error("Unable to retrieve input sources: %s", error) + else: + self._source_list.extend([source.name for source in self._inputs]) async def _async_update_players(self) -> None: """Update players after reconnection.""" @@ -167,3 +207,61 @@ class HeosCoordinator(DataUpdateCoordinator[None]): # After reconnecting, player_id may have changed if player_updates.updated_player_ids: self._async_update_player_ids(player_updates.updated_player_ids) + + @callback + def async_get_source_list(self) -> list[str]: + """Return the list of sources for players.""" + return list(self._source_list) + + @callback + def async_get_favorite_index(self, name: str) -> int | None: + """Get the index of a favorite by name.""" + for index, favorite in self._favorites.items(): + if favorite.name == name: + return index + return None + + @callback + def async_get_current_source( + self, now_playing_media: HeosNowPlayingMedia + ) -> str | None: + """Determine current source from now playing media (either input source or favorite).""" + # Try matching input source + if now_playing_media.source_id == const.MUSIC_SOURCE_AUX_INPUT: + # If playing a remote input, name will match station + for input_source in self._inputs: + if input_source.name == now_playing_media.station: + return input_source.name + # If playing a local input, match media_id. This needs to be a second loop as media_id + # will match both local and remote inputs, so prioritize remote match by name first. + for input_source in self._inputs: + if input_source.media_id == now_playing_media.media_id: + return input_source.name + # Try matching favorite + if now_playing_media.type == MediaType.STATION: + # Some stations match on name:station, others match on media_id:album_id + for favorite in self._favorites.values(): + if ( + favorite.name == now_playing_media.station + or favorite.media_id == now_playing_media.album_id + ): + return favorite.name + return None + + async def async_play_source(self, source: str, player: HeosPlayer) -> None: + """Determine type of source and play it.""" + # Favorite + if (index := self.async_get_favorite_index(source)) is not None: + await player.play_preset_station(index) + return + # Input source + for input_source in self._inputs: + if input_source.name == source: + await player.play_media(input_source) + return + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unknown_source", + translation_placeholders={"source": source}, + ) diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index a98b0426be5..e5ce39a1773 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -40,7 +40,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow -from . import GroupManager, HeosConfigEntry, SourceManager +from . import GroupManager, HeosConfigEntry from .const import DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_PLAYER_ADDED, SIGNAL_HEOS_UPDATED from .coordinator import HeosCoordinator @@ -97,7 +97,6 @@ async def async_setup_entry( HeosMediaPlayer( entry.runtime_data.coordinator, player, - entry.runtime_data.source_manager, entry.runtime_data.group_manager, ) for player in players.values() @@ -144,13 +143,11 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): self, coordinator: HeosCoordinator, player: HeosPlayer, - source_manager: SourceManager, group_manager: GroupManager, ) -> None: """Initialize.""" self._media_position_updated_at = None self._player: HeosPlayer = player - self._source_manager = source_manager self._group_manager = group_manager self._attr_unique_id = str(player.player_id) model_parts = player.model.split(maxsplit=1) @@ -164,8 +161,8 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): serial_number=player.serial, # Only available for some models sw_version=player.version, ) - self._update_attributes() super().__init__(coordinator, context=player.player_id) + self._update_attributes() async def _player_update(self, event): """Handle player attribute updated.""" @@ -181,6 +178,10 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): def _update_attributes(self) -> None: """Update core attributes of the media player.""" + self._attr_source_list = self.coordinator.async_get_source_list() + self._attr_source = self.coordinator.async_get_current_source( + self._player.now_playing_media + ) self._attr_repeat = HEOS_HA_REPEAT_TYPE_MAP[self._player.repeat] controls = self._player.now_playing_media.supported_controls current_support = [CONTROL_TO_SUPPORT[control] for control in controls] @@ -304,14 +305,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): index = int(media_id) except ValueError: # Try finding index by name - index = next( - ( - index - for index, favorite in self._source_manager.favorites.items() - if favorite.name == media_id - ), - None, - ) + index = self.coordinator.async_get_favorite_index(media_id) if index is None: raise ValueError(f"Invalid favorite '{media_id}'") await self._player.play_preset_station(index) @@ -322,7 +316,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): @catch_action_error("select source") async def async_select_source(self, source: str) -> None: """Select input source.""" - await self._source_manager.play_source(source, self._player) + await self.coordinator.async_play_source(source, self._player) @catch_action_error("set repeat") async def async_set_repeat(self, repeat: RepeatMode) -> None: @@ -428,16 +422,6 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """Boolean if shuffle is enabled.""" return self._player.shuffle - @property - def source(self) -> str: - """Name of the current input source.""" - return self._source_manager.get_current_source(self._player.now_playing_media) - - @property - def source_list(self) -> list[str]: - """List of available input sources.""" - return self._source_manager.source_list - @property def state(self) -> MediaPlayerState: """State of the player.""" diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index b5356e385cf..1a363d64aeb 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -139,7 +139,7 @@ def players_fixture(quick_selects: dict[int, str]) -> dict[int, HeosPlayer]: player.mute = AsyncMock() player.pause = AsyncMock() player.play = AsyncMock() - player.play_input_source = AsyncMock() + player.play_media = AsyncMock() player.play_next = AsyncMock() player.play_previous = AsyncMock() player.play_preset_station = AsyncMock() @@ -193,17 +193,28 @@ def favorites_fixture() -> dict[int, MediaItem]: @pytest.fixture(name="input_sources") def input_sources_fixture() -> list[MediaItem]: """Create a set of input sources for testing.""" - source = MediaItem( - source_id=1, - name="HEOS Drive - Line In 1", - media_id=const.INPUT_AUX_IN_1, - type=MediaType.STATION, - playable=True, - browsable=False, - image_url="", - heos=None, - ) - return [source] + return [ + MediaItem( + source_id=const.MUSIC_SOURCE_AUX_INPUT, + name="HEOS Drive - Line In 1", + media_id=const.INPUT_AUX_IN_1, + type=MediaType.STATION, + playable=True, + browsable=False, + image_url="", + heos=None, + ), + MediaItem( + source_id=const.MUSIC_SOURCE_AUX_INPUT, + name="Speaker - Line In 1", + media_id=const.INPUT_AUX_IN_1, + type=MediaType.STATION, + playable=True, + browsable=False, + image_url="", + heos=None, + ), + ] @pytest.fixture(name="discovery_data") diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index 56299a017f2..7bfdac232cb 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -25,6 +25,7 @@ "Today's Hits Radio", 'Classical MPR (Classical Music)', 'HEOS Drive - Line In 1', + 'Speaker - Line In 1', ]), 'supported_features': , 'volume_level': 0.25, diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 39023d95375..4c5eee67e2c 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -2,15 +2,7 @@ from typing import cast -from pyheos import ( - CommandFailedError, - Heos, - HeosError, - HeosOptions, - SignalHeosEvent, - SignalType, - const, -) +from pyheos import Heos, HeosError, HeosOptions, SignalHeosEvent, SignalType import pytest from homeassistant.components.heos.const import DOMAIN @@ -163,27 +155,6 @@ async def test_unload_entry( assert controller.disconnect.call_count == 1 -async def test_update_sources_retry( - hass: HomeAssistant, - config_entry: MockConfigEntry, - controller: Heos, -) -> None: - """Test update sources retries on failures to max attempts.""" - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - controller.get_favorites.reset_mock() - controller.get_input_sources.reset_mock() - source_manager = config_entry.runtime_data.source_manager - source_manager.retry_delay = 0 - source_manager.max_retry_attempts = 1 - controller.get_favorites.side_effect = CommandFailedError("Test", "test", 0) - await controller.dispatcher.wait_send( - SignalType.CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} - ) - await hass.async_block_till_done() - assert controller.get_favorites.call_count == 2 - - async def test_device_info( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 539b4584502..b26652415df 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -1,14 +1,17 @@ """Tests for the Heos Media Player platform.""" +from datetime import timedelta import re from typing import Any +from freezegun.api import FrozenDateTimeFactory from pyheos import ( AddCriteriaType, CommandFailedError, Heos, HeosError, MediaItem, + MediaType as HeosMediaType, PlayerUpdateResult, PlayState, RepeatType, @@ -63,7 +66,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_state_attributes( @@ -206,18 +209,21 @@ async def test_updates_from_sources_updated( hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos, - input_sources: list[MediaItem], + freezer: FrozenDateTimeFactory, ) -> None: """Tests player updates from changes in sources list.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - input_sources.clear() + controller.get_input_sources.return_value = [] await player.heos.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} ) + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) await hass.async_block_till_done() + state = hass.states.get("media_player.test_player") assert state.attributes[ATTR_INPUT_SOURCE_LIST] == [ "Today's Hits Radio", @@ -288,6 +294,7 @@ async def test_updates_from_user_changed( hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos, + freezer: FrozenDateTimeFactory, ) -> None: """Tests player updates from changes in user.""" config_entry.add_to_hass(hass) @@ -298,10 +305,15 @@ async def test_updates_from_user_changed( await player.heos.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_USER_CHANGED, None ) + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") - assert state.attributes[ATTR_INPUT_SOURCE_LIST] == ["HEOS Drive - Line In 1"] + assert state.attributes[ATTR_INPUT_SOURCE_LIST] == [ + "HEOS Drive - Line In 1", + "Speaker - Line In 1", + ] async def test_clear_playlist( @@ -694,6 +706,7 @@ async def test_select_favorite( ) player.play_preset_station.assert_called_once_with(1) # Test state is matched by station name + player.now_playing_media.type = HeosMediaType.STATION player.now_playing_media.station = favorite.name await player.heos.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED @@ -723,6 +736,7 @@ async def test_select_radio_favorite( ) player.play_preset_station.assert_called_once_with(2) # Test state is matched by album id + player.now_playing_media.type = HeosMediaType.STATION player.now_playing_media.station = "Classical" player.now_playing_media.album_id = favorite.media_id await player.heos.dispatcher.wait_send( @@ -762,37 +776,51 @@ async def test_select_radio_favorite_command_error( player.play_preset_station.assert_called_once_with(2) +@pytest.mark.parametrize( + ("source_name", "station"), + [ + ("HEOS Drive - Line In 1", "Line In 1"), + ("Speaker - Line In 1", "Speaker - Line In 1"), + ], +) async def test_select_input_source( hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos, input_sources: list[MediaItem], + source_name: str, + station: str, ) -> None: """Tests selecting input source and state.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # Test proper service called - input_source = input_sources[0] + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_SELECT_SOURCE, { ATTR_ENTITY_ID: "media_player.test_player", - ATTR_INPUT_SOURCE: input_source.name, + ATTR_INPUT_SOURCE: source_name, }, blocking=True, ) - player.play_input_source.assert_called_once_with(input_source.media_id) - # Test state is matched by media id + input_sources = next( + input_sources + for input_sources in input_sources + if input_sources.name == source_name + ) + player.play_media.assert_called_once_with(input_sources) + # Update the now_playing_media to reflect play_media player.now_playing_media.source_id = const.MUSIC_SOURCE_AUX_INPUT + player.now_playing_media.station = station player.now_playing_media.media_id = const.INPUT_AUX_IN_1 await player.heos.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") - assert state.attributes[ATTR_INPUT_SOURCE] == input_source.name + assert state.attributes[ATTR_INPUT_SOURCE] == source_name async def test_select_input_unknown_raises( @@ -824,7 +852,7 @@ async def test_select_input_command_error( await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] input_source = input_sources[0] - player.play_input_source.side_effect = CommandFailedError(None, "Failure", 1) + player.play_media.side_effect = CommandFailedError(None, "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to select source: Failure (1)"), @@ -838,7 +866,7 @@ async def test_select_input_command_error( }, blocking=True, ) - player.play_input_source.assert_called_once_with(input_source.media_id) + player.play_media.assert_called_once_with(input_source) async def test_unload_config_entry( From 09559a43adccc95410dfd01a13380700a0edff80 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Jan 2025 12:17:23 +0100 Subject: [PATCH 0930/2987] Rename incomfort exceptions classes to fix typo and assign correct translation domain (#136426) --- homeassistant/components/incomfort/__init__.py | 6 +++--- homeassistant/components/incomfort/errors.py | 15 ++++++++------- .../components/incomfort/strings.json | 18 ++++++++++++++++-- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index 722518ba6c2..249a0ae9085 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -13,7 +13,7 @@ from homeassistant.helpers import device_registry as dr from .const import DOMAIN from .coordinator import InComfortDataCoordinator, async_connect_gateway -from .errors import InConfortTimeout, InConfortUnknownError, NoHeaters, NotFound +from .errors import InComfortTimeout, InComfortUnknownError, NoHeaters, NotFound PLATFORMS = ( Platform.WATER_HEATER, @@ -40,9 +40,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> except ClientResponseError as exc: if exc.status == 404: raise NotFound from exc - raise InConfortUnknownError from exc + raise InComfortUnknownError from exc except TimeoutError as exc: - raise InConfortTimeout from exc + raise InComfortTimeout from exc # Register discovered gateway device device_registry = dr.async_get(hass) diff --git a/homeassistant/components/incomfort/errors.py b/homeassistant/components/incomfort/errors.py index 93a29d05bb8..c367916d6c7 100644 --- a/homeassistant/components/incomfort/errors.py +++ b/homeassistant/components/incomfort/errors.py @@ -1,32 +1,33 @@ """Exceptions raised by Intergas InComfort integration.""" -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from .const import DOMAIN + class NotFound(HomeAssistantError): """Raise exception if no Lan2RF Gateway was found.""" - translation_domain = HOMEASSISTANT_DOMAIN + translation_domain = DOMAIN translation_key = "not_found" class NoHeaters(ConfigEntryNotReady): """Raise exception if no heaters are found.""" - translation_domain = HOMEASSISTANT_DOMAIN + translation_domain = DOMAIN translation_key = "no_heaters" -class InConfortTimeout(ConfigEntryNotReady): +class InComfortTimeout(ConfigEntryNotReady): """Raise exception if no heaters are found.""" - translation_domain = HOMEASSISTANT_DOMAIN + translation_domain = DOMAIN translation_key = "timeout_error" -class InConfortUnknownError(ConfigEntryNotReady): +class InComfortUnknownError(ConfigEntryNotReady): """Raise exception if no heaters are found.""" - translation_domain = HOMEASSISTANT_DOMAIN + translation_domain = DOMAIN translation_key = "unknown" diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index a59dc71d87f..4c47d4c57ad 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -49,8 +49,22 @@ "auth_error": "Invalid credentials.", "no_heaters": "No heaters found.", "not_found": "No Lan2RF gateway found.", - "timeout_error": "Time out when connection to Lan2RF gateway.", - "unknown": "Unknown error when connection to Lan2RF gateway." + "timeout_error": "Time out when connecting to Lan2RF gateway.", + "unknown": "Unknown error when connecting to Lan2RF gateway." + } + }, + "exceptions": { + "no_heaters": { + "message": "[%key:component::incomfort::config::error::no_heaters%]" + }, + "not_found": { + "message": "[%key:component::incomfort::config::error::not_found%]" + }, + "timeout_error": { + "message": "[%key:component::incomfort::config::error::timeout_error%]" + }, + "unknown": { + "message": "[%key:component::incomfort::config::error::unknown%]" } }, "options": { From 5d353a983349508cffddfe04e734d793d0240dd7 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 24 Jan 2025 13:05:54 +0100 Subject: [PATCH 0931/2987] Tado change to async and add Data Update Coordinator (#134175) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/tado/__init__.py | 77 ++-- .../components/tado/binary_sensor.py | 72 +--- homeassistant/components/tado/climate.py | 227 +++++----- homeassistant/components/tado/coordinator.py | 391 ++++++++++++++++++ .../components/tado/device_tracker.py | 62 +-- homeassistant/components/tado/entity.py | 55 +-- homeassistant/components/tado/helper.py | 17 +- homeassistant/components/tado/models.py | 13 + homeassistant/components/tado/sensor.py | 76 +--- homeassistant/components/tado/services.py | 7 +- .../components/tado/tado_connector.py | 332 --------------- homeassistant/components/tado/water_heater.py | 85 ++-- .../tado/snapshots/test_climate.ambr | 115 ++++++ tests/components/tado/test_climate.py | 115 ++++++ tests/components/tado/test_helper.py | 105 ++++- tests/components/tado/test_service.py | 2 +- tests/components/tado/util.py | 5 + 17 files changed, 1008 insertions(+), 748 deletions(-) create mode 100644 homeassistant/components/tado/coordinator.py create mode 100644 homeassistant/components/tado/models.py delete mode 100644 homeassistant/components/tado/tado_connector.py create mode 100644 tests/components/tado/snapshots/test_climate.ambr diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index cc5dee77617..3e42e33489f 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -3,14 +3,15 @@ from datetime import timedelta import logging -import requests.exceptions +import PyTado +import PyTado.exceptions +from PyTado.interface import Tado from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from .const import ( @@ -21,11 +22,9 @@ from .const import ( CONST_OVERLAY_TADO_OPTIONS, DOMAIN, ) +from .coordinator import TadoDataUpdateCoordinator, TadoMobileDeviceUpdateCoordinator +from .models import TadoData from .services import setup_services -from .tado_connector import TadoConnector - -_LOGGER = logging.getLogger(__name__) - PLATFORMS = [ Platform.BINARY_SENSOR, @@ -41,16 +40,17 @@ SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Tado.""" setup_services(hass) - return True -type TadoConfigEntry = ConfigEntry[TadoConnector] +type TadoConfigEntry = ConfigEntry[TadoData] async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: @@ -58,53 +58,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool _async_import_options_from_data_if_missing(hass, entry) - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - fallback = entry.options.get(CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT) - - tadoconnector = TadoConnector(hass, username, password, fallback) - + _LOGGER.debug("Setting up Tado connection") try: - await hass.async_add_executor_job(tadoconnector.setup) - except KeyError: - _LOGGER.error("Failed to login to tado") - return False - except RuntimeError as exc: - _LOGGER.error("Failed to setup tado: %s", exc) - return False - except requests.exceptions.Timeout as ex: - raise ConfigEntryNotReady from ex - except requests.exceptions.HTTPError as ex: - if ex.response.status_code > 400 and ex.response.status_code < 500: - _LOGGER.error("Failed to login to tado: %s", ex) - return False - raise ConfigEntryNotReady from ex - - # Do first update - await hass.async_add_executor_job(tadoconnector.update) - - # Poll for updates in the background - entry.async_on_unload( - async_track_time_interval( - hass, - lambda now: tadoconnector.update(), - SCAN_INTERVAL, + tado = await hass.async_add_executor_job( + Tado, + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], ) + except PyTado.exceptions.TadoWrongCredentialsException as err: + raise ConfigEntryError(f"Invalid Tado credentials. Error: {err}") from err + except PyTado.exceptions.TadoException as err: + raise ConfigEntryNotReady(f"Error during Tado setup: {err}") from err + _LOGGER.debug( + "Tado connection established for username: %s", entry.data[CONF_USERNAME] ) - entry.async_on_unload( - async_track_time_interval( - hass, - lambda now: tadoconnector.update_mobile_devices(), - SCAN_MOBILE_DEVICE_INTERVAL, - ) - ) + coordinator = TadoDataUpdateCoordinator(hass, entry, tado) + await coordinator.async_config_entry_first_refresh() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - - entry.runtime_data = tadoconnector + mobile_coordinator = TadoMobileDeviceUpdateCoordinator(hass, entry, tado) + await mobile_coordinator.async_config_entry_first_refresh() + entry.runtime_data = TadoData(coordinator, mobile_coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -126,7 +103,7 @@ def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: Confi hass.config_entries.async_update_entry(entry, options=options) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: TadoConfigEntry): """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py index 25c1c801155..c969ea34f42 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -13,21 +13,19 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import TadoConfigEntry from .const import ( - SIGNAL_TADO_UPDATE_RECEIVED, TYPE_AIR_CONDITIONING, TYPE_BATTERY, TYPE_HEATING, TYPE_HOT_WATER, TYPE_POWER, ) +from .coordinator import TadoDataUpdateCoordinator from .entity import TadoDeviceEntity, TadoZoneEntity -from .tado_connector import TadoConnector _LOGGER = logging.getLogger(__name__) @@ -121,7 +119,7 @@ async def async_setup_entry( ) -> None: """Set up the Tado sensor platform.""" - tado = entry.runtime_data + tado = entry.runtime_data.coordinator devices = tado.devices zones = tado.zones entities: list[BinarySensorEntity] = [] @@ -164,43 +162,23 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): def __init__( self, - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, device_info: dict[str, Any], entity_description: TadoBinarySensorEntityDescription, ) -> None: """Initialize of the Tado Sensor.""" self.entity_description = entity_description - self._tado = tado - super().__init__(device_info) + super().__init__(device_info, coordinator) self._attr_unique_id = ( - f"{entity_description.key} {self.device_id} {tado.home_id}" + f"{entity_description.key} {self.device_id} {coordinator.home_id}" ) - async def async_added_to_hass(self) -> None: - """Register for sensor updates.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self._tado.home_id, "device", self.device_id - ), - self._async_update_callback, - ) - ) - self._async_update_device_data() - @callback - def _async_update_callback(self) -> None: - """Update and write state.""" - self._async_update_device_data() - self.async_write_ha_state() - - @callback - def _async_update_device_data(self) -> None: - """Handle update callbacks.""" + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" try: - self._device_info = self._tado.data["device"][self.device_id] + self._device_info = self.coordinator.data["device"][self.device_id] except KeyError: return @@ -209,6 +187,7 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): self._attr_extra_state_attributes = self.entity_description.attributes_fn( self._device_info ) + super()._handle_coordinator_update() class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): @@ -218,42 +197,24 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): def __init__( self, - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, zone_name: str, zone_id: int, entity_description: TadoBinarySensorEntityDescription, ) -> None: """Initialize of the Tado Sensor.""" self.entity_description = entity_description - self._tado = tado - super().__init__(zone_name, tado.home_id, zone_id) + super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) - self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}" - - async def async_added_to_hass(self) -> None: - """Register for sensor updates.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self._tado.home_id, "zone", self.zone_id - ), - self._async_update_callback, - ) + self._attr_unique_id = ( + f"{entity_description.key} {zone_id} {coordinator.home_id}" ) - self._async_update_zone_data() @callback - def _async_update_callback(self) -> None: - """Update and write state.""" - self._async_update_zone_data() - self.async_write_ha_state() - - @callback - def _async_update_zone_data(self) -> None: - """Handle update callbacks.""" + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" try: - tado_zone_data = self._tado.data["zone"][self.zone_id] + tado_zone_data = self.coordinator.data["zone"][self.zone_id] except KeyError: return @@ -262,3 +223,4 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): self._attr_extra_state_attributes = self.entity_description.attributes_fn( tado_zone_data ) + super()._handle_coordinator_update() diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 5a81e951293..c8eaec76255 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -26,11 +26,10 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType -from . import TadoConfigEntry, TadoConnector +from . import TadoConfigEntry from .const import ( CONST_EXCLUSIVE_OVERLAY_GROUP, CONST_FAN_AUTO, @@ -50,7 +49,6 @@ from .const import ( HA_TO_TADO_HVAC_MODE_MAP, ORDERED_KNOWN_TADO_MODES, PRESET_AUTO, - SIGNAL_TADO_UPDATE_RECEIVED, SUPPORT_PRESET_AUTO, SUPPORT_PRESET_MANUAL, TADO_DEFAULT_MAX_TEMP, @@ -73,6 +71,7 @@ from .const import ( TYPE_AIR_CONDITIONING, TYPE_HEATING, ) +from .coordinator import TadoDataUpdateCoordinator from .entity import TadoZoneEntity from .helper import decide_duration, decide_overlay_mode, generate_supported_fanmodes @@ -105,8 +104,8 @@ async def async_setup_entry( ) -> None: """Set up the Tado climate platform.""" - tado = entry.runtime_data - entities = await hass.async_add_executor_job(_generate_entities, tado) + tado = entry.runtime_data.coordinator + entities = await _generate_entities(tado) platform = entity_platform.async_get_current_platform() @@ -125,12 +124,12 @@ async def async_setup_entry( async_add_entities(entities, True) -def _generate_entities(tado: TadoConnector) -> list[TadoClimate]: +async def _generate_entities(tado: TadoDataUpdateCoordinator) -> list[TadoClimate]: """Create all climate entities.""" entities = [] for zone in tado.zones: if zone["type"] in [TYPE_HEATING, TYPE_AIR_CONDITIONING]: - entity = create_climate_entity( + entity = await create_climate_entity( tado, zone["name"], zone["id"], zone["devices"][0] ) if entity: @@ -138,11 +137,11 @@ def _generate_entities(tado: TadoConnector) -> list[TadoClimate]: return entities -def create_climate_entity( - tado: TadoConnector, name: str, zone_id: int, device_info: dict +async def create_climate_entity( + tado: TadoDataUpdateCoordinator, name: str, zone_id: int, device_info: dict ) -> TadoClimate | None: """Create a Tado climate entity.""" - capabilities = tado.get_capabilities(zone_id) + capabilities = await tado.get_capabilities(zone_id) _LOGGER.debug("Capabilities for zone %s: %s", zone_id, capabilities) zone_type = capabilities["type"] @@ -243,6 +242,8 @@ def create_climate_entity( cool_max_temp = float(cool_temperatures["celsius"]["max"]) cool_step = cool_temperatures["celsius"].get("step", PRECISION_TENTHS) + auto_geofencing_supported = await tado.get_auto_geofencing_supported() + return TadoClimate( tado, name, @@ -251,6 +252,8 @@ def create_climate_entity( supported_hvac_modes, support_flags, device_info, + capabilities, + auto_geofencing_supported, heat_min_temp, heat_max_temp, heat_step, @@ -272,13 +275,15 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): def __init__( self, - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, zone_name: str, zone_id: int, zone_type: str, supported_hvac_modes: list[HVACMode], support_flags: ClimateEntityFeature, device_info: dict[str, str], + capabilities: dict[str, str], + auto_geofencing_supported: bool, heat_min_temp: float | None = None, heat_max_temp: float | None = None, heat_step: float | None = None, @@ -289,13 +294,13 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): supported_swing_modes: list[str] | None = None, ) -> None: """Initialize of Tado climate entity.""" - self._tado = tado - super().__init__(zone_name, tado.home_id, zone_id) + self._tado = coordinator + super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) self.zone_id = zone_id self.zone_type = zone_type - self._attr_unique_id = f"{zone_type} {zone_id} {tado.home_id}" + self._attr_unique_id = f"{zone_type} {zone_id} {coordinator.home_id}" self._device_info = device_info self._device_id = self._device_info["shortSerialNo"] @@ -327,36 +332,61 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._current_tado_vertical_swing = TADO_SWING_OFF self._current_tado_horizontal_swing = TADO_SWING_OFF - capabilities = tado.get_capabilities(zone_id) self._current_tado_capabilities = capabilities + self._auto_geofencing_supported = auto_geofencing_supported self._tado_zone_data: PyTado.TadoZone = {} self._tado_geofence_data: dict[str, str] | None = None self._tado_zone_temp_offset: dict[str, Any] = {} - self._async_update_home_data() self._async_update_zone_data() - async def async_added_to_hass(self) -> None: - """Register for sensor updates.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format(self._tado.home_id, "home", "data"), - self._async_update_home_callback, - ) - ) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_zone_data() + super()._handle_coordinator_update() - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self._tado.home_id, "zone", self.zone_id - ), - self._async_update_zone_callback, + @callback + def _async_update_zone_data(self) -> None: + """Load tado data into zone.""" + self._tado_geofence_data = self._tado.data["geofence"] + self._tado_zone_data = self._tado.data["zone"][self.zone_id] + + # Assign offset values to mapped attributes + for offset_key, attr in TADO_TO_HA_OFFSET_MAP.items(): + if ( + self._device_id in self._tado.data["device"] + and offset_key + in self._tado.data["device"][self._device_id][TEMP_OFFSET] + ): + self._tado_zone_temp_offset[attr] = self._tado.data["device"][ + self._device_id + ][TEMP_OFFSET][offset_key] + + self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode + self._current_tado_hvac_action = self._tado_zone_data.current_hvac_action + + if self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING): + self._current_tado_fan_level = self._tado_zone_data.current_fan_level + if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING): + self._current_tado_fan_speed = self._tado_zone_data.current_fan_speed + if self._is_valid_setting_for_hvac_mode(TADO_SWING_SETTING): + self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode + if self._is_valid_setting_for_hvac_mode(TADO_VERTICAL_SWING_SETTING): + self._current_tado_vertical_swing = ( + self._tado_zone_data.current_vertical_swing_mode ) - ) + if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING): + self._current_tado_horizontal_swing = ( + self._tado_zone_data.current_horizontal_swing_mode + ) + + @callback + def _async_update_zone_callback(self) -> None: + """Load tado data and update state.""" + self._async_update_zone_data() @property def current_humidity(self) -> int | None: @@ -401,12 +431,13 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): return FAN_AUTO return None - def set_fan_mode(self, fan_mode: str) -> None: + async def async_set_fan_mode(self, fan_mode: str) -> None: """Turn fan on/off.""" if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING): - self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP_LEGACY[fan_mode]) + await self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP_LEGACY[fan_mode]) elif self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING): - self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode]) + await self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode]) + await self.coordinator.async_request_refresh() @property def preset_mode(self) -> str: @@ -425,13 +456,14 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): @property def preset_modes(self) -> list[str]: """Return a list of available preset modes.""" - if self._tado.get_auto_geofencing_supported(): + if self._auto_geofencing_supported: return SUPPORT_PRESET_AUTO return SUPPORT_PRESET_MANUAL - def set_preset_mode(self, preset_mode: str) -> None: + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - self._tado.set_presence(preset_mode) + await self._tado.set_presence(preset_mode) + await self.coordinator.async_request_refresh() @property def target_temperature_step(self) -> float | None: @@ -449,7 +481,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): # the device is switching states return self._tado_zone_data.target_temp or self._tado_zone_data.current_temp - def set_timer( + async def set_timer( self, temperature: float, time_period: int | None = None, @@ -457,14 +489,15 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ): """Set the timer on the entity, and temperature if supported.""" - self._control_hvac( + await self._control_hvac( hvac_mode=CONST_MODE_HEAT, target_temp=temperature, duration=time_period, overlay_mode=requested_overlay, ) + await self.coordinator.async_request_refresh() - def set_temp_offset(self, offset: float) -> None: + async def set_temp_offset(self, offset: float) -> None: """Set offset on the entity.""" _LOGGER.debug( @@ -474,8 +507,9 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ) self._tado.set_temperature_offset(self._device_id, offset) + await self.coordinator.async_request_refresh() - def set_temperature(self, **kwargs: Any) -> None: + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return @@ -485,15 +519,21 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): CONST_MODE_AUTO, CONST_MODE_SMART_SCHEDULE, ): - self._control_hvac(target_temp=temperature) + await self._control_hvac(target_temp=temperature) + await self.coordinator.async_request_refresh() return new_hvac_mode = CONST_MODE_COOL if self._ac_device else CONST_MODE_HEAT - self._control_hvac(target_temp=temperature, hvac_mode=new_hvac_mode) + await self._control_hvac(target_temp=temperature, hvac_mode=new_hvac_mode) + await self.coordinator.async_request_refresh() - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - self._control_hvac(hvac_mode=HA_TO_TADO_HVAC_MODE_MAP[hvac_mode]) + _LOGGER.debug( + "Setting new hvac mode for device %s to %s", self._device_id, hvac_mode + ) + await self._control_hvac(hvac_mode=HA_TO_TADO_HVAC_MODE_MAP[hvac_mode]) + await self.coordinator.async_request_refresh() @property def available(self) -> bool: @@ -559,7 +599,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ) return state_attr - def set_swing_mode(self, swing_mode: str) -> None: + async def async_set_swing_mode(self, swing_mode: str) -> None: """Set swing modes for the device.""" vertical_swing = None horizontal_swing = None @@ -591,62 +631,12 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING): horizontal_swing = TADO_SWING_ON - self._control_hvac( + await self._control_hvac( swing_mode=swing, vertical_swing=vertical_swing, horizontal_swing=horizontal_swing, ) - - @callback - def _async_update_zone_data(self) -> None: - """Load tado data into zone.""" - self._tado_zone_data = self._tado.data["zone"][self.zone_id] - - # Assign offset values to mapped attributes - for offset_key, attr in TADO_TO_HA_OFFSET_MAP.items(): - if ( - self._device_id in self._tado.data["device"] - and offset_key - in self._tado.data["device"][self._device_id][TEMP_OFFSET] - ): - self._tado_zone_temp_offset[attr] = self._tado.data["device"][ - self._device_id - ][TEMP_OFFSET][offset_key] - - self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode - self._current_tado_hvac_action = self._tado_zone_data.current_hvac_action - - if self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING): - self._current_tado_fan_level = self._tado_zone_data.current_fan_level - if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING): - self._current_tado_fan_speed = self._tado_zone_data.current_fan_speed - if self._is_valid_setting_for_hvac_mode(TADO_SWING_SETTING): - self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode - if self._is_valid_setting_for_hvac_mode(TADO_VERTICAL_SWING_SETTING): - self._current_tado_vertical_swing = ( - self._tado_zone_data.current_vertical_swing_mode - ) - if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING): - self._current_tado_horizontal_swing = ( - self._tado_zone_data.current_horizontal_swing_mode - ) - - @callback - def _async_update_zone_callback(self) -> None: - """Load tado data and update state.""" - self._async_update_zone_data() - self.async_write_ha_state() - - @callback - def _async_update_home_data(self) -> None: - """Load tado geofencing data into zone.""" - self._tado_geofence_data = self._tado.data["geofence"] - - @callback - def _async_update_home_callback(self) -> None: - """Load tado data and update state.""" - self._async_update_home_data() - self.async_write_ha_state() + await self.coordinator.async_request_refresh() def _normalize_target_temp_for_hvac_mode(self) -> None: def adjust_temp(min_temp, max_temp) -> float | None: @@ -665,7 +655,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): elif self._current_tado_hvac_mode == CONST_MODE_HEAT: self._target_temp = adjust_temp(self._heat_min_temp, self._heat_max_temp) - def _control_hvac( + async def _control_hvac( self, hvac_mode: str | None = None, target_temp: float | None = None, @@ -712,7 +702,9 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): _LOGGER.debug( "Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id ) - self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, self.zone_type) + await self._tado.set_zone_off( + self.zone_id, CONST_OVERLAY_MANUAL, self.zone_type + ) return if self._current_tado_hvac_mode == CONST_MODE_SMART_SCHEDULE: @@ -721,17 +713,17 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self.zone_name, self.zone_id, ) - self._tado.reset_zone_overlay(self.zone_id) + await self._tado.reset_zone_overlay(self.zone_id) return overlay_mode = decide_overlay_mode( - tado=self._tado, + coordinator=self._tado, duration=duration, overlay_mode=overlay_mode, zone_id=self.zone_id, ) duration = decide_duration( - tado=self._tado, + coordinator=self._tado, duration=duration, zone_id=self.zone_id, overlay_mode=overlay_mode, @@ -785,7 +777,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ): swing = self._current_tado_swing_mode - self._tado.set_zone_overlay( + await self._tado.set_zone_overlay( zone_id=self.zone_id, overlay_mode=overlay_mode, # What to do when the period ends temperature=temperature_to_send, @@ -800,18 +792,23 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ) def _is_valid_setting_for_hvac_mode(self, setting: str) -> bool: - return ( - self._current_tado_capabilities.get(self._current_tado_hvac_mode, {}).get( - setting - ) - is not None + """Determine if a setting is valid for the current HVAC mode.""" + capabilities: str | dict[str, str] = self._current_tado_capabilities.get( + self._current_tado_hvac_mode, {} ) + if isinstance(capabilities, dict): + return capabilities.get(setting) is not None + return False def _is_current_setting_supported_by_current_hvac_mode( self, setting: str, current_state: str | None ) -> bool: - if self._is_valid_setting_for_hvac_mode(setting): - return current_state in self._current_tado_capabilities[ - self._current_tado_hvac_mode - ].get(setting, []) + """Determine if the current setting is supported by the current HVAC mode.""" + capabilities: str | dict[str, str] = self._current_tado_capabilities.get( + self._current_tado_hvac_mode, {} + ) + if isinstance(capabilities, dict) and self._is_valid_setting_for_hvac_mode( + setting + ): + return current_state in capabilities.get(setting, []) return False diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py new file mode 100644 index 00000000000..ddec9e7f292 --- /dev/null +++ b/homeassistant/components/tado/coordinator.py @@ -0,0 +1,391 @@ +"""Coordinator for the Tado integration.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import logging +from typing import Any + +from PyTado.interface import Tado +from requests import RequestException + +from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_FALLBACK, + CONST_OVERLAY_TADO_DEFAULT, + DOMAIN, + INSIDE_TEMPERATURE_MEASUREMENT, + PRESET_AUTO, + TEMP_OFFSET, +) + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4) +SCAN_INTERVAL = timedelta(minutes=5) +SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30) + +type TadoConfigEntry = ConfigEntry[TadoDataUpdateCoordinator] + + +class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): + """Class to manage API calls from and to Tado via PyTado.""" + + tado: Tado + home_id: int + home_name: str + config_entry: TadoConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + tado: Tado, + debug: bool = False, + ) -> None: + """Initialize the Tado data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self._tado = tado + self._username = entry.data[CONF_USERNAME] + self._password = entry.data[CONF_PASSWORD] + self._fallback = entry.options.get(CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT) + self._debug = debug + + self.home_id: int + self.home_name: str + self.zones: list[dict[Any, Any]] = [] + self.devices: list[dict[Any, Any]] = [] + self.data: dict[str, dict] = { + "device": {}, + "weather": {}, + "geofence": {}, + "zone": {}, + } + + @property + def fallback(self) -> str: + """Return fallback flag to Smart Schedule.""" + return self._fallback + + async def _async_update_data(self) -> dict[str, dict]: + """Fetch the (initial) latest data from Tado.""" + + try: + _LOGGER.debug("Preloading home data") + tado_home_call = await self.hass.async_add_executor_job(self._tado.get_me) + _LOGGER.debug("Preloading zones and devices") + self.zones = await self.hass.async_add_executor_job(self._tado.get_zones) + self.devices = await self.hass.async_add_executor_job( + self._tado.get_devices + ) + except RequestException as err: + raise UpdateFailed(f"Error during Tado setup: {err}") from err + + tado_home = tado_home_call["homes"][0] + self.home_id = tado_home["id"] + self.home_name = tado_home["name"] + + devices = await self._async_update_devices() + zones = await self._async_update_zones() + home = await self._async_update_home() + + self.data["device"] = devices + self.data["zone"] = zones + self.data["weather"] = home["weather"] + self.data["geofence"] = home["geofence"] + + return self.data + + async def _async_update_devices(self) -> dict[str, dict]: + """Update the device data from Tado.""" + + try: + devices = await self.hass.async_add_executor_job(self._tado.get_devices) + except RequestException as err: + _LOGGER.error("Error updating Tado devices: %s", err) + raise UpdateFailed(f"Error updating Tado devices: {err}") from err + + if not devices: + _LOGGER.error("No linked devices found for home ID %s", self.home_id) + raise UpdateFailed(f"No linked devices found for home ID {self.home_id}") + + return await self.hass.async_add_executor_job(self._update_device_info, devices) + + def _update_device_info(self, devices: list[dict[str, Any]]) -> dict[str, dict]: + """Update the device data from Tado.""" + mapped_devices: dict[str, dict] = {} + for device in devices: + device_short_serial_no = device["shortSerialNo"] + _LOGGER.debug("Updating device %s", device_short_serial_no) + try: + if ( + INSIDE_TEMPERATURE_MEASUREMENT + in device["characteristics"]["capabilities"] + ): + _LOGGER.debug( + "Updating temperature offset for device %s", + device_short_serial_no, + ) + device[TEMP_OFFSET] = self._tado.get_device_info( + device_short_serial_no, TEMP_OFFSET + ) + except RequestException as err: + _LOGGER.error( + "Error updating device %s: %s", device_short_serial_no, err + ) + + _LOGGER.debug( + "Device %s updated, with data: %s", device_short_serial_no, device + ) + mapped_devices[device_short_serial_no] = device + + return mapped_devices + + async def _async_update_zones(self) -> dict[int, dict]: + """Update the zone data from Tado.""" + + try: + zone_states_call = await self.hass.async_add_executor_job( + self._tado.get_zone_states + ) + zone_states = zone_states_call["zoneStates"] + except RequestException as err: + _LOGGER.error("Error updating Tado zones: %s", err) + raise UpdateFailed(f"Error updating Tado zones: {err}") from err + + mapped_zones: dict[int, dict] = {} + for zone in zone_states: + mapped_zones[int(zone)] = await self._update_zone(int(zone)) + + return mapped_zones + + async def _update_zone(self, zone_id: int) -> dict[str, str]: + """Update the internal data of a zone.""" + + _LOGGER.debug("Updating zone %s", zone_id) + try: + data = await self.hass.async_add_executor_job( + self._tado.get_zone_state, zone_id + ) + except RequestException as err: + _LOGGER.error("Error updating Tado zone %s: %s", zone_id, err) + raise UpdateFailed(f"Error updating Tado zone {zone_id}: {err}") from err + + _LOGGER.debug("Zone %s updated, with data: %s", zone_id, data) + return data + + async def _async_update_home(self) -> dict[str, dict]: + """Update the home data from Tado.""" + + try: + weather = await self.hass.async_add_executor_job(self._tado.get_weather) + geofence = await self.hass.async_add_executor_job(self._tado.get_home_state) + except RequestException as err: + _LOGGER.error("Error updating Tado home: %s", err) + raise UpdateFailed(f"Error updating Tado home: {err}") from err + + _LOGGER.debug( + "Home data updated, with weather and geofence data: %s, %s", + weather, + geofence, + ) + + return {"weather": weather, "geofence": geofence} + + async def get_capabilities(self, zone_id: int | str) -> dict: + """Fetch the capabilities from Tado.""" + + try: + return await self.hass.async_add_executor_job( + self._tado.get_capabilities, zone_id + ) + except RequestException as err: + raise UpdateFailed(f"Error updating Tado data: {err}") from err + + async def get_auto_geofencing_supported(self) -> bool: + """Fetch the auto geofencing supported from Tado.""" + + try: + return await self.hass.async_add_executor_job( + self._tado.get_auto_geofencing_supported + ) + except RequestException as err: + raise UpdateFailed(f"Error updating Tado data: {err}") from err + + async def reset_zone_overlay(self, zone_id): + """Reset the zone back to the default operation.""" + + try: + await self.hass.async_add_executor_job( + self._tado.reset_zone_overlay, zone_id + ) + await self._update_zone(zone_id) + except RequestException as err: + raise UpdateFailed(f"Error resetting Tado data: {err}") from err + + async def set_presence( + self, + presence=PRESET_HOME, + ): + """Set the presence to home, away or auto.""" + + if presence == PRESET_AWAY: + await self.hass.async_add_executor_job(self._tado.set_away) + elif presence == PRESET_HOME: + await self.hass.async_add_executor_job(self._tado.set_home) + elif presence == PRESET_AUTO: + await self.hass.async_add_executor_job(self._tado.set_auto) + + async def set_zone_overlay( + self, + zone_id=None, + overlay_mode=None, + temperature=None, + duration=None, + device_type="HEATING", + mode=None, + fan_speed=None, + swing=None, + fan_level=None, + vertical_swing=None, + horizontal_swing=None, + ) -> None: + """Set a zone overlay.""" + + _LOGGER.debug( + "Set overlay for zone %s: overlay_mode=%s, temp=%s, duration=%s, type=%s, mode=%s, fan_speed=%s, swing=%s, fan_level=%s, vertical_swing=%s, horizontal_swing=%s", + zone_id, + overlay_mode, + temperature, + duration, + device_type, + mode, + fan_speed, + swing, + fan_level, + vertical_swing, + horizontal_swing, + ) + + try: + await self.hass.async_add_executor_job( + self._tado.set_zone_overlay, + zone_id, + overlay_mode, + temperature, + duration, + device_type, + "ON", + mode, + fan_speed, + swing, + fan_level, + vertical_swing, + horizontal_swing, + ) + + except RequestException as err: + raise UpdateFailed(f"Error setting Tado overlay: {err}") from err + + await self._update_zone(zone_id) + + async def set_zone_off(self, zone_id, overlay_mode, device_type="HEATING"): + """Set a zone to off.""" + try: + await self.hass.async_add_executor_job( + self._tado.set_zone_overlay, + zone_id, + overlay_mode, + None, + None, + device_type, + "OFF", + ) + except RequestException as err: + raise UpdateFailed(f"Error setting Tado overlay: {err}") from err + + await self._update_zone(zone_id) + + async def set_temperature_offset(self, device_id, offset): + """Set temperature offset of device.""" + try: + await self.hass.async_add_executor_job( + self._tado.set_temp_offset, device_id, offset + ) + except RequestException as err: + raise UpdateFailed(f"Error setting Tado temperature offset: {err}") from err + + async def set_meter_reading(self, reading: int) -> dict[str, Any]: + """Send meter reading to Tado.""" + dt: str = datetime.now().strftime("%Y-%m-%d") + if self._tado is None: + raise HomeAssistantError("Tado client is not initialized") + + try: + return await self.hass.async_add_executor_job( + self._tado.set_eiq_meter_readings, dt, reading + ) + except RequestException as err: + raise UpdateFailed(f"Error setting Tado meter reading: {err}") from err + + +class TadoMobileDeviceUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): + """Class to manage the mobile devices from Tado via PyTado.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + tado: Tado, + ) -> None: + """Initialize the Tado data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_MOBILE_DEVICE_INTERVAL, + ) + self._tado = tado + self.data: dict[str, dict] = {} + + async def _async_update_data(self) -> dict[str, dict]: + """Fetch the latest data from Tado.""" + + try: + mobile_devices = await self.hass.async_add_executor_job( + self._tado.get_mobile_devices + ) + except RequestException as err: + _LOGGER.error("Error updating Tado mobile devices: %s", err) + raise UpdateFailed(f"Error updating Tado mobile devices: {err}") from err + + mapped_mobile_devices: dict[str, dict] = {} + for mobile_device in mobile_devices: + mobile_device_id = mobile_device["id"] + _LOGGER.debug("Updating mobile device %s", mobile_device_id) + try: + mapped_mobile_devices[mobile_device_id] = mobile_device + _LOGGER.debug( + "Mobile device %s updated, with data: %s", + mobile_device_id, + mobile_device, + ) + except RequestException: + _LOGGER.error( + "Unable to connect to Tado while updating mobile device %s", + mobile_device_id, + ) + + self.data["mobile_device"] = mapped_mobile_devices + return self.data diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 95e031329c3..a9be560f434 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -11,12 +11,15 @@ from homeassistant.components.device_tracker import ( from homeassistant.const import STATE_HOME, STATE_NOT_HOME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from . import TadoConfigEntry -from .const import DOMAIN, SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED -from .tado_connector import TadoConnector +from .const import DOMAIN +from .coordinator import TadoMobileDeviceUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -28,7 +31,7 @@ async def async_setup_entry( ) -> None: """Set up the Tado device scannery entity.""" _LOGGER.debug("Setting up Tado device scanner entity") - tado = entry.runtime_data + tado = entry.runtime_data.mobile_coordinator tracked: set = set() # Fix non-string unique_id for device trackers @@ -49,58 +52,56 @@ async def async_setup_entry( update_devices() - entry.async_on_unload( - async_dispatcher_connect( - hass, - SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(tado.home_id), - update_devices, - ) - ) - @callback def add_tracked_entities( hass: HomeAssistant, - tado: TadoConnector, + coordinator: TadoMobileDeviceUpdateCoordinator, async_add_entities: AddEntitiesCallback, tracked: set[str], ) -> None: """Add new tracker entities from Tado.""" _LOGGER.debug("Fetching Tado devices from API for (newly) tracked entities") new_tracked = [] - for device_key, device in tado.data["mobile_device"].items(): + for device_key, device in coordinator.data["mobile_device"].items(): if device_key in tracked: continue _LOGGER.debug( "Adding Tado device %s with deviceID %s", device["name"], device_key ) - new_tracked.append(TadoDeviceTrackerEntity(device_key, device["name"], tado)) + new_tracked.append( + TadoDeviceTrackerEntity(device_key, device["name"], coordinator) + ) tracked.add(device_key) async_add_entities(new_tracked) -class TadoDeviceTrackerEntity(TrackerEntity): +class TadoDeviceTrackerEntity(CoordinatorEntity[DataUpdateCoordinator], TrackerEntity): """A Tado Device Tracker entity.""" - _attr_should_poll = False _attr_available = False def __init__( self, device_id: str, device_name: str, - tado: TadoConnector, + coordinator: TadoMobileDeviceUpdateCoordinator, ) -> None: """Initialize a Tado Device Tracker entity.""" - super().__init__() + super().__init__(coordinator) self._attr_unique_id = str(device_id) self._device_id = device_id self._device_name = device_name - self._tado = tado self._active = False + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.update_state() + super()._handle_coordinator_update() + @callback def update_state(self) -> None: """Update the Tado device.""" @@ -109,7 +110,7 @@ class TadoDeviceTrackerEntity(TrackerEntity): self._device_name, self._device_id, ) - device = self._tado.data["mobile_device"][self._device_id] + device = self.coordinator.data["mobile_device"][self._device_id] self._attr_available = False _LOGGER.debug( @@ -129,25 +130,6 @@ class TadoDeviceTrackerEntity(TrackerEntity): else: _LOGGER.debug("Tado device %s is not at home", device["name"]) - @callback - def on_demand_update(self) -> None: - """Update state on demand.""" - self.update_state() - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Register state update callback.""" - _LOGGER.debug("Registering Tado device tracker entity") - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(self._tado.home_id), - self.on_demand_update, - ) - ) - - self.update_state() - @property def name(self) -> str: """Return the name of the device.""" diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py index 6bb90ab849a..971b2863aba 100644 --- a/homeassistant/components/tado/entity.py +++ b/homeassistant/components/tado/entity.py @@ -1,21 +1,30 @@ """Base class for Tado entity.""" +import logging + from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TadoConnector from .const import DEFAULT_NAME, DOMAIN, TADO_HOME, TADO_ZONE +from .coordinator import TadoDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) -class TadoDeviceEntity(Entity): - """Base implementation for Tado device.""" +class TadoCoordinatorEntity(CoordinatorEntity[TadoDataUpdateCoordinator]): + """Base class for Tado entity.""" - _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, device_info: dict[str, str]) -> None: + +class TadoDeviceEntity(TadoCoordinatorEntity): + """Base implementation for Tado device.""" + + def __init__( + self, device_info: dict[str, str], coordinator: TadoDataUpdateCoordinator + ) -> None: """Initialize a Tado device.""" - super().__init__() + super().__init__(coordinator) self._device_info = device_info self.device_name = device_info["serialNo"] self.device_id = device_info["shortSerialNo"] @@ -30,35 +39,35 @@ class TadoDeviceEntity(Entity): ) -class TadoHomeEntity(Entity): +class TadoHomeEntity(TadoCoordinatorEntity): """Base implementation for Tado home.""" - _attr_should_poll = False - _attr_has_entity_name = True - - def __init__(self, tado: TadoConnector) -> None: + def __init__(self, coordinator: TadoDataUpdateCoordinator) -> None: """Initialize a Tado home.""" - super().__init__() - self.home_name = tado.home_name - self.home_id = tado.home_id + super().__init__(coordinator) + self.home_name = coordinator.home_name + self.home_id = coordinator.home_id self._attr_device_info = DeviceInfo( configuration_url="https://app.tado.com", - identifiers={(DOMAIN, str(tado.home_id))}, + identifiers={(DOMAIN, str(coordinator.home_id))}, manufacturer=DEFAULT_NAME, model=TADO_HOME, - name=tado.home_name, + name=coordinator.home_name, ) -class TadoZoneEntity(Entity): +class TadoZoneEntity(TadoCoordinatorEntity): """Base implementation for Tado zone.""" - _attr_has_entity_name = True - _attr_should_poll = False - - def __init__(self, zone_name: str, home_id: int, zone_id: int) -> None: + def __init__( + self, + zone_name: str, + home_id: int, + zone_id: int, + coordinator: TadoDataUpdateCoordinator, + ) -> None: """Initialize a Tado zone.""" - super().__init__() + super().__init__(coordinator) self.zone_name = zone_name self.zone_id = zone_id self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/tado/helper.py b/homeassistant/components/tado/helper.py index 558aee164d0..571a757a3e8 100644 --- a/homeassistant/components/tado/helper.py +++ b/homeassistant/components/tado/helper.py @@ -5,26 +5,27 @@ from .const import ( CONST_OVERLAY_TADO_MODE, CONST_OVERLAY_TIMER, ) -from .tado_connector import TadoConnector +from .coordinator import TadoDataUpdateCoordinator def decide_overlay_mode( - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, duration: int | None, zone_id: int, overlay_mode: str | None = None, ) -> str: """Return correct overlay mode based on the action and defaults.""" + # If user gave duration then overlay mode needs to be timer if duration: return CONST_OVERLAY_TIMER # If no duration or timer set to fallback setting if overlay_mode is None: - overlay_mode = tado.fallback or CONST_OVERLAY_TADO_MODE + overlay_mode = coordinator.fallback or CONST_OVERLAY_TADO_MODE # If default is Tado default then look it up if overlay_mode == CONST_OVERLAY_TADO_DEFAULT: overlay_mode = ( - tado.data["zone"][zone_id].default_overlay_termination_type + coordinator.data["zone"][zone_id].default_overlay_termination_type or CONST_OVERLAY_TADO_MODE ) @@ -32,18 +33,19 @@ def decide_overlay_mode( def decide_duration( - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, duration: int | None, zone_id: int, overlay_mode: str | None = None, ) -> None | int: """Return correct duration based on the selected overlay mode/duration and tado config.""" + # If we ended up with a timer but no duration, set a default duration # If we ended up with a timer but no duration, set a default duration if overlay_mode == CONST_OVERLAY_TIMER and duration is None: duration = ( - int(tado.data["zone"][zone_id].default_overlay_termination_duration) - if tado.data["zone"][zone_id].default_overlay_termination_duration + int(coordinator.data["zone"][zone_id].default_overlay_termination_duration) + if coordinator.data["zone"][zone_id].default_overlay_termination_duration is not None else 3600 ) @@ -53,6 +55,7 @@ def decide_duration( def generate_supported_fanmodes(tado_to_ha_mapping: dict[str, str], options: list[str]): """Return correct list of fan modes or None.""" + supported_fanmodes = [ tado_to_ha_mapping.get(option) for option in options diff --git a/homeassistant/components/tado/models.py b/homeassistant/components/tado/models.py new file mode 100644 index 00000000000..08bdaceaf03 --- /dev/null +++ b/homeassistant/components/tado/models.py @@ -0,0 +1,13 @@ +"""Models for use in Tado integration.""" + +from dataclasses import dataclass + +from .coordinator import TadoDataUpdateCoordinator, TadoMobileDeviceUpdateCoordinator + + +@dataclass +class TadoData: + """Class to hold Tado data.""" + + coordinator: TadoDataUpdateCoordinator + mobile_coordinator: TadoMobileDeviceUpdateCoordinator diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index 8bb13a02cd1..037b33574e7 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -15,7 +15,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -24,13 +23,12 @@ from .const import ( CONDITIONS_MAP, SENSOR_DATA_CATEGORY_GEOFENCE, SENSOR_DATA_CATEGORY_WEATHER, - SIGNAL_TADO_UPDATE_RECEIVED, TYPE_AIR_CONDITIONING, TYPE_HEATING, TYPE_HOT_WATER, ) +from .coordinator import TadoDataUpdateCoordinator from .entity import TadoHomeEntity, TadoZoneEntity -from .tado_connector import TadoConnector _LOGGER = logging.getLogger(__name__) @@ -197,7 +195,7 @@ async def async_setup_entry( ) -> None: """Set up the Tado sensor platform.""" - tado = entry.runtime_data + tado = entry.runtime_data.coordinator zones = tado.zones entities: list[SensorEntity] = [] @@ -232,39 +230,22 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): entity_description: TadoSensorEntityDescription def __init__( - self, tado: TadoConnector, entity_description: TadoSensorEntityDescription + self, + coordinator: TadoDataUpdateCoordinator, + entity_description: TadoSensorEntityDescription, ) -> None: """Initialize of the Tado Sensor.""" self.entity_description = entity_description - super().__init__(tado) - self._tado = tado + super().__init__(coordinator) - self._attr_unique_id = f"{entity_description.key} {tado.home_id}" - - async def async_added_to_hass(self) -> None: - """Register for sensor updates.""" - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format(self._tado.home_id, "home", "data"), - self._async_update_callback, - ) - ) - self._async_update_home_data() + self._attr_unique_id = f"{entity_description.key} {coordinator.home_id}" @callback - def _async_update_callback(self) -> None: - """Update and write state.""" - self._async_update_home_data() - self.async_write_ha_state() - - @callback - def _async_update_home_data(self) -> None: - """Handle update callbacks.""" + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" try: - tado_weather_data = self._tado.data["weather"] - tado_geofence_data = self._tado.data["geofence"] + tado_weather_data = self.coordinator.data["weather"] + tado_geofence_data = self.coordinator.data["geofence"] except KeyError: return @@ -278,6 +259,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): self._attr_extra_state_attributes = self.entity_description.attributes_fn( tado_sensor_data ) + super()._handle_coordinator_update() class TadoZoneSensor(TadoZoneEntity, SensorEntity): @@ -287,43 +269,24 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): def __init__( self, - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, zone_name: str, zone_id: int, entity_description: TadoSensorEntityDescription, ) -> None: """Initialize of the Tado Sensor.""" self.entity_description = entity_description - self._tado = tado - super().__init__(zone_name, tado.home_id, zone_id) + super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) - self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}" - - async def async_added_to_hass(self) -> None: - """Register for sensor updates.""" - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self._tado.home_id, "zone", self.zone_id - ), - self._async_update_callback, - ) + self._attr_unique_id = ( + f"{entity_description.key} {zone_id} {coordinator.home_id}" ) - self._async_update_zone_data() @callback - def _async_update_callback(self) -> None: - """Update and write state.""" - self._async_update_zone_data() - self.async_write_ha_state() - - @callback - def _async_update_zone_data(self) -> None: - """Handle update callbacks.""" + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" try: - tado_zone_data = self._tado.data["zone"][self.zone_id] + tado_zone_data = self.coordinator.data["zone"][self.zone_id] except KeyError: return @@ -332,3 +295,4 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): self._attr_extra_state_attributes = self.entity_description.attributes_fn( tado_zone_data ) + super()._handle_coordinator_update() diff --git a/homeassistant/components/tado/services.py b/homeassistant/components/tado/services.py index 89711808066..d931ea303e9 100644 --- a/homeassistant/components/tado/services.py +++ b/homeassistant/components/tado/services.py @@ -43,11 +43,8 @@ def setup_services(hass: HomeAssistant) -> None: if entry is None: raise ServiceValidationError("Config entry not found") - tadoconnector = entry.runtime_data - - response: dict = await hass.async_add_executor_job( - tadoconnector.set_meter_reading, call.data[CONF_READING] - ) + coordinator = entry.runtime_data.coordinator + response: dict = await coordinator.set_meter_reading(call.data[CONF_READING]) if ATTR_MESSAGE in response: raise HomeAssistantError(response[ATTR_MESSAGE]) diff --git a/homeassistant/components/tado/tado_connector.py b/homeassistant/components/tado/tado_connector.py deleted file mode 100644 index 5ed53675153..00000000000 --- a/homeassistant/components/tado/tado_connector.py +++ /dev/null @@ -1,332 +0,0 @@ -"""Tado Connector a class to store the data as an object.""" - -from datetime import datetime, timedelta -import logging -from typing import Any - -from PyTado.interface import Tado -from requests import RequestException - -from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.util import Throttle - -from .const import ( - INSIDE_TEMPERATURE_MEASUREMENT, - PRESET_AUTO, - SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, - SIGNAL_TADO_UPDATE_RECEIVED, - TEMP_OFFSET, -) - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4) -SCAN_INTERVAL = timedelta(minutes=5) -SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30) - - -_LOGGER = logging.getLogger(__name__) - - -class TadoConnector: - """An object to store the Tado data.""" - - def __init__( - self, hass: HomeAssistant, username: str, password: str, fallback: str - ) -> None: - """Initialize Tado Connector.""" - self.hass = hass - self._username = username - self._password = password - self._fallback = fallback - - self.home_id: int = 0 - self.home_name = None - self.tado = None - self.zones: list[dict[Any, Any]] = [] - self.devices: list[dict[Any, Any]] = [] - self.data: dict[str, dict] = { - "device": {}, - "mobile_device": {}, - "weather": {}, - "geofence": {}, - "zone": {}, - } - - @property - def fallback(self): - """Return fallback flag to Smart Schedule.""" - return self._fallback - - def setup(self): - """Connect to Tado and fetch the zones.""" - self.tado = Tado(self._username, self._password) - # Load zones and devices - self.zones = self.tado.get_zones() - self.devices = self.tado.get_devices() - tado_home = self.tado.get_me()["homes"][0] - self.home_id = tado_home["id"] - self.home_name = tado_home["name"] - - def get_mobile_devices(self): - """Return the Tado mobile devices.""" - return self.tado.get_mobile_devices() - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Update the registered zones.""" - self.update_devices() - self.update_mobile_devices() - self.update_zones() - self.update_home() - - def update_mobile_devices(self) -> None: - """Update the mobile devices.""" - try: - mobile_devices = self.get_mobile_devices() - except RuntimeError: - _LOGGER.error("Unable to connect to Tado while updating mobile devices") - return - - if not mobile_devices: - _LOGGER.debug("No linked mobile devices found for home ID %s", self.home_id) - return - - # Errors are planned to be converted to exceptions - # in PyTado library, so this can be removed - if isinstance(mobile_devices, dict) and mobile_devices.get("errors"): - _LOGGER.error( - "Error for home ID %s while updating mobile devices: %s", - self.home_id, - mobile_devices["errors"], - ) - return - - for mobile_device in mobile_devices: - self.data["mobile_device"][mobile_device["id"]] = mobile_device - _LOGGER.debug( - "Dispatching update to %s mobile device: %s", - self.home_id, - mobile_device, - ) - - dispatcher_send( - self.hass, - SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(self.home_id), - ) - - def update_devices(self): - """Update the device data from Tado.""" - try: - devices = self.tado.get_devices() - except RuntimeError: - _LOGGER.error("Unable to connect to Tado while updating devices") - return - - if not devices: - _LOGGER.debug("No linked devices found for home ID %s", self.home_id) - return - - # Errors are planned to be converted to exceptions - # in PyTado library, so this can be removed - if isinstance(devices, dict) and devices.get("errors"): - _LOGGER.error( - "Error for home ID %s while updating devices: %s", - self.home_id, - devices["errors"], - ) - return - - for device in devices: - device_short_serial_no = device["shortSerialNo"] - _LOGGER.debug("Updating device %s", device_short_serial_no) - try: - if ( - INSIDE_TEMPERATURE_MEASUREMENT - in device["characteristics"]["capabilities"] - ): - device[TEMP_OFFSET] = self.tado.get_device_info( - device_short_serial_no, TEMP_OFFSET - ) - except RuntimeError: - _LOGGER.error( - "Unable to connect to Tado while updating device %s", - device_short_serial_no, - ) - return - - self.data["device"][device_short_serial_no] = device - - _LOGGER.debug( - "Dispatching update to %s device %s: %s", - self.home_id, - device_short_serial_no, - device, - ) - dispatcher_send( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self.home_id, "device", device_short_serial_no - ), - ) - - def update_zones(self): - """Update the zone data from Tado.""" - try: - zone_states = self.tado.get_zone_states()["zoneStates"] - except RuntimeError: - _LOGGER.error("Unable to connect to Tado while updating zones") - return - - for zone in zone_states: - self.update_zone(int(zone)) - - def update_zone(self, zone_id): - """Update the internal data from Tado.""" - _LOGGER.debug("Updating zone %s", zone_id) - try: - data = self.tado.get_zone_state(zone_id) - except RuntimeError: - _LOGGER.error("Unable to connect to Tado while updating zone %s", zone_id) - return - - self.data["zone"][zone_id] = data - - _LOGGER.debug( - "Dispatching update to %s zone %s: %s", - self.home_id, - zone_id, - data, - ) - dispatcher_send( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "zone", zone_id), - ) - - def update_home(self): - """Update the home data from Tado.""" - try: - self.data["weather"] = self.tado.get_weather() - self.data["geofence"] = self.tado.get_home_state() - dispatcher_send( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "home", "data"), - ) - except RuntimeError: - _LOGGER.error( - "Unable to connect to Tado while updating weather and geofence data" - ) - return - - def get_capabilities(self, zone_id): - """Return the capabilities of the devices.""" - return self.tado.get_capabilities(zone_id) - - def get_auto_geofencing_supported(self): - """Return whether the Tado Home supports auto geofencing.""" - return self.tado.get_auto_geofencing_supported() - - def reset_zone_overlay(self, zone_id): - """Reset the zone back to the default operation.""" - self.tado.reset_zone_overlay(zone_id) - self.update_zone(zone_id) - - def set_presence( - self, - presence=PRESET_HOME, - ): - """Set the presence to home, away or auto.""" - if presence == PRESET_AWAY: - self.tado.set_away() - elif presence == PRESET_HOME: - self.tado.set_home() - elif presence == PRESET_AUTO: - self.tado.set_auto() - - # Update everything when changing modes - self.update_zones() - self.update_home() - - def set_zone_overlay( - self, - zone_id=None, - overlay_mode=None, - temperature=None, - duration=None, - device_type="HEATING", - mode=None, - fan_speed=None, - swing=None, - fan_level=None, - vertical_swing=None, - horizontal_swing=None, - ): - """Set a zone overlay.""" - _LOGGER.debug( - ( - "Set overlay for zone %s: overlay_mode=%s, temp=%s, duration=%s," - " type=%s, mode=%s fan_speed=%s swing=%s fan_level=%s vertical_swing=%s horizontal_swing=%s" - ), - zone_id, - overlay_mode, - temperature, - duration, - device_type, - mode, - fan_speed, - swing, - fan_level, - vertical_swing, - horizontal_swing, - ) - - try: - self.tado.set_zone_overlay( - zone_id, - overlay_mode, - temperature, - duration, - device_type, - "ON", - mode, - fan_speed=fan_speed, - swing=swing, - fan_level=fan_level, - vertical_swing=vertical_swing, - horizontal_swing=horizontal_swing, - ) - - except RequestException as exc: - _LOGGER.error("Could not set zone overlay: %s", exc) - - self.update_zone(zone_id) - - def set_zone_off(self, zone_id, overlay_mode, device_type="HEATING"): - """Set a zone to off.""" - try: - self.tado.set_zone_overlay( - zone_id, overlay_mode, None, None, device_type, "OFF" - ) - except RequestException as exc: - _LOGGER.error("Could not set zone overlay: %s", exc) - - self.update_zone(zone_id) - - def set_temperature_offset(self, device_id, offset): - """Set temperature offset of device.""" - try: - self.tado.set_temp_offset(device_id, offset) - except RequestException as exc: - _LOGGER.error("Could not set temperature offset: %s", exc) - - def set_meter_reading(self, reading: int) -> dict[str, Any]: - """Send meter reading to Tado.""" - dt: str = datetime.now().strftime("%Y-%m-%d") - if self.tado is None: - raise HomeAssistantError("Tado client is not initialized") - - try: - return self.tado.set_eiq_meter_readings(date=dt, reading=reading) - except RequestException as exc: - raise HomeAssistantError("Could not set meter reading") from exc diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py index 6c964cfaddd..02fbb3f5e23 100644 --- a/homeassistant/components/tado/water_heater.py +++ b/homeassistant/components/tado/water_heater.py @@ -12,7 +12,6 @@ from homeassistant.components.water_heater import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType @@ -26,13 +25,12 @@ from .const import ( CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_MODE, CONST_OVERLAY_TIMER, - SIGNAL_TADO_UPDATE_RECEIVED, TYPE_HOT_WATER, ) +from .coordinator import TadoDataUpdateCoordinator from .entity import TadoZoneEntity from .helper import decide_duration, decide_overlay_mode from .repairs import manage_water_heater_fallback_issue -from .tado_connector import TadoConnector _LOGGER = logging.getLogger(__name__) @@ -67,8 +65,9 @@ async def async_setup_entry( ) -> None: """Set up the Tado water heater platform.""" - tado = entry.runtime_data - entities = await hass.async_add_executor_job(_generate_entities, tado) + data = entry.runtime_data + coordinator = data.coordinator + entities = await _generate_entities(coordinator) platform = entity_platform.async_get_current_platform() @@ -83,27 +82,29 @@ async def async_setup_entry( manage_water_heater_fallback_issue( hass=hass, water_heater_names=[e.zone_name for e in entities], - integration_overlay_fallback=tado.fallback, + integration_overlay_fallback=coordinator.fallback, ) -def _generate_entities(tado: TadoConnector) -> list: +async def _generate_entities(coordinator: TadoDataUpdateCoordinator) -> list: """Create all water heater entities.""" entities = [] - for zone in tado.zones: + for zone in coordinator.zones: if zone["type"] == TYPE_HOT_WATER: - entity = create_water_heater_entity( - tado, zone["name"], zone["id"], str(zone["name"]) + entity = await create_water_heater_entity( + coordinator, zone["name"], zone["id"], str(zone["name"]) ) entities.append(entity) return entities -def create_water_heater_entity(tado: TadoConnector, name: str, zone_id: int, zone: str): +async def create_water_heater_entity( + coordinator: TadoDataUpdateCoordinator, name: str, zone_id: int, zone: str +): """Create a Tado water heater device.""" - capabilities = tado.get_capabilities(zone_id) + capabilities = await coordinator.get_capabilities(zone_id) supports_temperature_control = capabilities["canSetTemperature"] @@ -116,7 +117,7 @@ def create_water_heater_entity(tado: TadoConnector, name: str, zone_id: int, zon max_temp = None return TadoWaterHeater( - tado, + coordinator, name, zone_id, supports_temperature_control, @@ -134,7 +135,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): def __init__( self, - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, zone_name: str, zone_id: int, supports_temperature_control: bool, @@ -142,11 +143,10 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): max_temp, ) -> None: """Initialize of Tado water heater entity.""" - self._tado = tado - super().__init__(zone_name, tado.home_id, zone_id) + super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) self.zone_id = zone_id - self._attr_unique_id = f"{zone_id} {tado.home_id}" + self._attr_unique_id = f"{zone_id} {coordinator.home_id}" self._device_is_active = False @@ -164,19 +164,14 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): self._overlay_mode = CONST_MODE_SMART_SCHEDULE self._tado_zone_data: Any = None - async def async_added_to_hass(self) -> None: - """Register for sensor updates.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self._tado.home_id, "zone", self.zone_id - ), - self._async_update_callback, - ) - ) self._async_update_data() + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_data() + super()._handle_coordinator_update() + @property def current_operation(self) -> str | None: """Return current readable operation mode.""" @@ -202,7 +197,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): """Return the maximum temperature.""" return self._max_temperature - def set_operation_mode(self, operation_mode: str) -> None: + async def async_set_operation_mode(self, operation_mode: str) -> None: """Set new operation mode.""" mode = None @@ -213,18 +208,20 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): elif operation_mode == MODE_HEAT: mode = CONST_MODE_HEAT - self._control_heater(hvac_mode=mode) + await self._control_heater(hvac_mode=mode) + await self.coordinator.async_request_refresh() - def set_timer(self, time_period: int, temperature: float | None = None): + async def set_timer(self, time_period: int, temperature: float | None = None): """Set the timer on the entity, and temperature if supported.""" if not self._supports_temperature_control and temperature is not None: temperature = None - self._control_heater( + await self._control_heater( hvac_mode=CONST_MODE_HEAT, target_temp=temperature, duration=time_period ) + await self.coordinator.async_request_refresh() - def set_temperature(self, **kwargs: Any) -> None: + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if not self._supports_temperature_control or temperature is None: @@ -235,10 +232,11 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): CONST_MODE_AUTO, CONST_MODE_SMART_SCHEDULE, ): - self._control_heater(target_temp=temperature) + await self._control_heater(target_temp=temperature) return - self._control_heater(target_temp=temperature, hvac_mode=CONST_MODE_HEAT) + await self._control_heater(target_temp=temperature, hvac_mode=CONST_MODE_HEAT) + await self.coordinator.async_request_refresh() @callback def _async_update_callback(self) -> None: @@ -250,10 +248,10 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): def _async_update_data(self) -> None: """Load tado data.""" _LOGGER.debug("Updating water_heater platform for zone %d", self.zone_id) - self._tado_zone_data = self._tado.data["zone"][self.zone_id] + self._tado_zone_data = self.coordinator.data["zone"][self.zone_id] self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode - def _control_heater( + async def _control_heater( self, hvac_mode: str | None = None, target_temp: float | None = None, @@ -276,23 +274,26 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): self.zone_name, self.zone_id, ) - self._tado.reset_zone_overlay(self.zone_id) + await self.coordinator.reset_zone_overlay(self.zone_id) + await self.coordinator.async_request_refresh() return if self._current_tado_hvac_mode == CONST_MODE_OFF: _LOGGER.debug( "Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id ) - self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, TYPE_HOT_WATER) + await self.coordinator.set_zone_off( + self.zone_id, CONST_OVERLAY_MANUAL, TYPE_HOT_WATER + ) return overlay_mode = decide_overlay_mode( - tado=self._tado, + coordinator=self.coordinator, duration=duration, zone_id=self.zone_id, ) duration = decide_duration( - tado=self._tado, + coordinator=self.coordinator, duration=duration, zone_id=self.zone_id, overlay_mode=overlay_mode, @@ -304,7 +305,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): self.zone_id, self._target_temp, ) - self._tado.set_zone_overlay( + await self.coordinator.set_zone_overlay( zone_id=self.zone_id, overlay_mode=overlay_mode, temperature=self._target_temp, diff --git a/tests/components/tado/snapshots/test_climate.ambr b/tests/components/tado/snapshots/test_climate.ambr new file mode 100644 index 00000000000..6ba35b6f6f2 --- /dev/null +++ b/tests/components/tado/snapshots/test_climate.ambr @@ -0,0 +1,115 @@ +# serializer version: 1 +# name: test_aircon_set_hvac_mode[cool-COOL] + _Call( + tuple( + 3, + 'NEXT_TIME_BLOCK', + 24.76, + None, + 'AIR_CONDITIONING', + 'ON', + 'COOL', + 'AUTO', + None, + None, + None, + None, + ), + dict({ + }), + ) +# --- +# name: test_aircon_set_hvac_mode[dry-DRY] + _Call( + tuple( + 3, + 'NEXT_TIME_BLOCK', + 24.76, + None, + 'AIR_CONDITIONING', + 'ON', + 'DRY', + None, + None, + None, + None, + None, + ), + dict({ + }), + ) +# --- +# name: test_aircon_set_hvac_mode[fan_only-FAN] + _Call( + tuple( + 3, + 'NEXT_TIME_BLOCK', + None, + None, + 'AIR_CONDITIONING', + 'ON', + 'FAN', + None, + None, + None, + None, + None, + ), + dict({ + }), + ) +# --- +# name: test_aircon_set_hvac_mode[heat-HEAT] + _Call( + tuple( + 3, + 'NEXT_TIME_BLOCK', + 24.76, + None, + 'AIR_CONDITIONING', + 'ON', + 'HEAT', + 'AUTO', + None, + None, + None, + None, + ), + dict({ + }), + ) +# --- +# name: test_aircon_set_hvac_mode[off-OFF] + _Call( + tuple( + 3, + 'MANUAL', + None, + None, + 'AIR_CONDITIONING', + 'OFF', + ), + dict({ + }), + ) +# --- +# name: test_heater_set_temperature + _Call( + tuple( + 1, + 'NEXT_TIME_BLOCK', + 22.0, + None, + 'HEATING', + 'ON', + 'HEAT', + None, + None, + None, + None, + None, + ), + dict({ + }), + ) +# --- diff --git a/tests/components/tado/test_climate.py b/tests/components/tado/test_climate.py index 5a43c728b6e..0699551c9c0 100644 --- a/tests/components/tado/test_climate.py +++ b/tests/components/tado/test_climate.py @@ -1,5 +1,19 @@ """The sensor tests for the tado platform.""" +from unittest.mock import patch + +from PyTado.interface.api.my_tado import TadoZone +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant from .util import async_init_integration @@ -121,3 +135,104 @@ async def test_smartac_with_fanlevel_vertical_and_horizontal_swing( # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all(item in state.attributes.items() for item in expected_attributes.items()) + + +async def test_heater_set_temperature( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test the set temperature of the heater.""" + + await async_init_integration(hass) + + with ( + patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.set_zone_overlay" + ) as mock_set_state, + patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.get_zone_state", + return_value={"setting": {"temperature": {"celsius": 22.0}}}, + ), + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.baseboard_heater", ATTR_TEMPERATURE: 22.0}, + blocking=True, + ) + + mock_set_state.assert_called_once() + snapshot.assert_match(mock_set_state.call_args) + + +@pytest.mark.parametrize( + ("hvac_mode", "set_hvac_mode"), + [ + (HVACMode.HEAT, "HEAT"), + (HVACMode.DRY, "DRY"), + (HVACMode.FAN_ONLY, "FAN"), + (HVACMode.COOL, "COOL"), + (HVACMode.OFF, "OFF"), + ], +) +async def test_aircon_set_hvac_mode( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + hvac_mode: HVACMode, + set_hvac_mode: str, +) -> None: + """Test the set hvac mode of the air conditioning.""" + + await async_init_integration(hass) + + with ( + patch( + "homeassistant.components.tado.__init__.PyTado.interface.api.Tado.set_zone_overlay" + ) as mock_set_state, + patch( + "homeassistant.components.tado.__init__.PyTado.interface.api.Tado.get_zone_state", + return_value=TadoZone( + zone_id=1, + current_temp=18.7, + connection=None, + current_temp_timestamp="2025-01-02T12:51:52.802Z", + current_humidity=45.1, + current_humidity_timestamp="2025-01-02T12:51:52.802Z", + is_away=False, + current_hvac_action="IDLE", + current_fan_speed=None, + current_fan_level=None, + current_hvac_mode=set_hvac_mode, + current_swing_mode="OFF", + current_vertical_swing_mode="OFF", + current_horizontal_swing_mode="OFF", + target_temp=16.0, + available=True, + power="ON", + link="ONLINE", + ac_power_timestamp=None, + heating_power_timestamp="2025-01-02T13:01:11.758Z", + ac_power=None, + heating_power=None, + heating_power_percentage=0.0, + tado_mode="HOME", + overlay_termination_type="MANUAL", + overlay_termination_timestamp=None, + default_overlay_termination_type="MANUAL", + default_overlay_termination_duration=None, + preparation=False, + open_window=False, + open_window_detected=False, + open_window_attr={}, + precision=0.1, + ), + ), + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.air_conditioning", ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + + mock_set_state.assert_called_once() + snapshot.assert_match(mock_set_state.call_args) diff --git a/tests/components/tado/test_helper.py b/tests/components/tado/test_helper.py index bdd7977f858..da959c2124a 100644 --- a/tests/components/tado/test_helper.py +++ b/tests/components/tado/test_helper.py @@ -1,45 +1,94 @@ """Helper method tests.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch -from homeassistant.components.tado import TadoConnector +from PyTado.interface import Tado +import pytest + +from homeassistant.components.tado import TadoDataUpdateCoordinator from homeassistant.components.tado.const import ( CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_MODE, CONST_OVERLAY_TIMER, + DOMAIN, ) from homeassistant.components.tado.helper import decide_duration, decide_overlay_mode +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from tests.common import MockConfigEntry -def dummy_tado_connector(hass: HomeAssistant, fallback) -> TadoConnector: + +@pytest.fixture +def entry(request: pytest.FixtureRequest) -> MockConfigEntry: + """Fixture for ConfigEntry with optional fallback.""" + fallback = ( + request.param if hasattr(request, "param") else CONST_OVERLAY_TADO_DEFAULT + ) + return MockConfigEntry( + version=1, + minor_version=1, + domain=DOMAIN, + title="Tado", + data={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + options={ + "fallback": fallback, + }, + ) + + +@pytest.fixture +def tado() -> Tado: + """Fixture for Tado instance.""" + with patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.set_zone_overlay" + ) as mock_set_zone_overlay: + instance = MagicMock(spec=Tado) + instance.set_zone_overlay = mock_set_zone_overlay + yield instance + + +def dummy_tado_connector( + hass: HomeAssistant, entry: ConfigEntry, tado: Tado +) -> TadoDataUpdateCoordinator: """Return dummy tado connector.""" - return TadoConnector(hass, username="dummy", password="dummy", fallback=fallback) + return TadoDataUpdateCoordinator(hass, entry, tado) -async def test_overlay_mode_duration_set(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("entry", [CONST_OVERLAY_TADO_MODE], indirect=True) +async def test_overlay_mode_duration_set( + hass: HomeAssistant, entry: ConfigEntry, tado: Tado +) -> None: """Test overlay method selection when duration is set.""" - tado = dummy_tado_connector(hass=hass, fallback=CONST_OVERLAY_TADO_MODE) - overlay_mode = decide_overlay_mode(tado=tado, duration=3600, zone_id=1) + tado = dummy_tado_connector(hass=hass, entry=entry, tado=tado) + overlay_mode = decide_overlay_mode(coordinator=tado, duration=3600, zone_id=1) # Must select TIMER overlay assert overlay_mode == CONST_OVERLAY_TIMER -async def test_overlay_mode_next_time_block_fallback(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("entry", [CONST_OVERLAY_TADO_MODE], indirect=True) +async def test_overlay_mode_next_time_block_fallback( + hass: HomeAssistant, entry: ConfigEntry, tado: Tado +) -> None: """Test overlay method selection when duration is not set.""" - integration_fallback = CONST_OVERLAY_TADO_MODE - tado = dummy_tado_connector(hass=hass, fallback=integration_fallback) - overlay_mode = decide_overlay_mode(tado=tado, duration=None, zone_id=1) + tado = dummy_tado_connector(hass=hass, entry=entry, tado=tado) + overlay_mode = decide_overlay_mode(coordinator=tado, duration=None, zone_id=1) # Must fallback to integration wide setting - assert overlay_mode == integration_fallback + assert overlay_mode == CONST_OVERLAY_TADO_MODE -async def test_overlay_mode_tado_default_fallback(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("entry", [CONST_OVERLAY_TADO_DEFAULT], indirect=True) +async def test_overlay_mode_tado_default_fallback( + hass: HomeAssistant, entry: ConfigEntry, tado: Tado +) -> None: """Test overlay method selection when tado default is selected.""" - integration_fallback = CONST_OVERLAY_TADO_DEFAULT zone_fallback = CONST_OVERLAY_MANUAL - tado = dummy_tado_connector(hass=hass, fallback=integration_fallback) + tado = dummy_tado_connector(hass=hass, entry=entry, tado=tado) class MockZoneData: def __init__(self) -> None: @@ -49,28 +98,40 @@ async def test_overlay_mode_tado_default_fallback(hass: HomeAssistant) -> None: zone_data = {"zone": {zone_id: MockZoneData()}} with patch.dict(tado.data, zone_data): - overlay_mode = decide_overlay_mode(tado=tado, duration=None, zone_id=zone_id) + overlay_mode = decide_overlay_mode( + coordinator=tado, duration=None, zone_id=zone_id + ) # Must fallback to zone setting assert overlay_mode == zone_fallback -async def test_duration_enabled_without_tado_default(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("entry", [CONST_OVERLAY_MANUAL], indirect=True) +async def test_duration_enabled_without_tado_default( + hass: HomeAssistant, entry: ConfigEntry, tado: Tado +) -> None: """Test duration decide method when overlay is timer and duration is set.""" overlay = CONST_OVERLAY_TIMER expected_duration = 600 - tado = dummy_tado_connector(hass=hass, fallback=CONST_OVERLAY_MANUAL) + tado = dummy_tado_connector(hass=hass, entry=entry, tado=tado) duration = decide_duration( - tado=tado, duration=expected_duration, overlay_mode=overlay, zone_id=0 + coordinator=tado, duration=expected_duration, overlay_mode=overlay, zone_id=0 ) # Should return the same duration value assert duration == expected_duration -async def test_duration_enabled_with_tado_default(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("entry", [CONST_OVERLAY_TIMER], indirect=True) +async def test_duration_enabled_with_tado_default( + hass: HomeAssistant, entry: ConfigEntry, tado: Tado +) -> None: """Test overlay method selection when ended up with timer overlay and None duration.""" zone_fallback = CONST_OVERLAY_TIMER expected_duration = 45000 - tado = dummy_tado_connector(hass=hass, fallback=zone_fallback) + tado = dummy_tado_connector( + hass=hass, + entry=entry, + tado=tado, + ) class MockZoneData: def __init__(self) -> None: @@ -81,7 +142,7 @@ async def test_duration_enabled_with_tado_default(hass: HomeAssistant) -> None: zone_data = {"zone": {zone_id: MockZoneData()}} with patch.dict(tado.data, zone_data): duration = decide_duration( - tado=tado, duration=None, zone_id=zone_id, overlay_mode=zone_fallback + coordinator=tado, duration=None, zone_id=zone_id, overlay_mode=zone_fallback ) # Must fallback to zone timer setting assert duration == expected_duration diff --git a/tests/components/tado/test_service.py b/tests/components/tado/test_service.py index f1d12d235cc..336bef55ea1 100644 --- a/tests/components/tado/test_service.py +++ b/tests/components/tado/test_service.py @@ -80,7 +80,7 @@ async def test_add_meter_readings_exception( blocking=True, ) - assert "Could not set meter reading" in str(exc) + assert "Error setting Tado meter reading: Error" in str(exc.value) async def test_add_meter_readings_invalid( diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index a76858ab98e..5bf87dbed33 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -188,3 +188,8 @@ async def async_init_integration( if not skip_setup: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + + # For a first refresh + await entry.runtime_data.coordinator.async_refresh() + await entry.runtime_data.mobile_coordinator.async_refresh() + await hass.async_block_till_done() From 47efb687808c1fd21b1cc3f1f68a6934865575cb Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 24 Jan 2025 15:13:10 +0200 Subject: [PATCH 0932/2987] Add missing translations for LG webOS TV and fix names (#136438) --- homeassistant/components/webostv/__init__.py | 27 +++------------ .../components/webostv/config_flow.py | 6 ++-- homeassistant/components/webostv/const.py | 2 +- .../components/webostv/device_trigger.py | 2 +- .../components/webostv/diagnostics.py | 2 +- homeassistant/components/webostv/helpers.py | 33 ++++++++++++++++--- .../components/webostv/media_player.py | 20 ++++++----- homeassistant/components/webostv/notify.py | 4 +-- homeassistant/components/webostv/strings.json | 13 ++++++-- homeassistant/components/webostv/trigger.py | 10 ++++-- .../components/webostv/triggers/__init__.py | 2 +- .../components/webostv/triggers/turn_on.py | 2 +- tests/components/webostv/__init__.py | 2 +- tests/components/webostv/conftest.py | 2 +- tests/components/webostv/const.py | 2 +- tests/components/webostv/test_config_flow.py | 2 +- .../components/webostv/test_device_trigger.py | 3 +- tests/components/webostv/test_diagnostics.py | 2 +- tests/components/webostv/test_media_player.py | 11 ++++--- tests/components/webostv/test_notify.py | 2 +- tests/components/webostv/test_trigger.py | 18 ++++------ 21 files changed, 92 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index 6546f9aa0f0..c1a1c698f92 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -1,14 +1,12 @@ -"""Support for LG webOS Smart TV.""" +"""The LG webOS TV integration.""" from __future__ import annotations from contextlib import suppress -import logging from aiowebostv import WebOsClient, WebOsTvPairError from homeassistant.components import notify as hass_notify -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CLIENT_SECRET, CONF_HOST, @@ -29,17 +27,13 @@ from .const import ( PLATFORMS, WEBOSTV_EXCEPTIONS, ) +from .helpers import WebOsTvConfigEntry, update_client_key CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -_LOGGER = logging.getLogger(__name__) - -type WebOsTvConfigEntry = ConfigEntry[WebOsClient] - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the LG WebOS TV platform.""" + """Set up the LG webOS TV platform.""" hass.data.setdefault(DOMAIN, {DATA_HASS_CONFIG: config}) return True @@ -62,7 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> b # If pairing request accepted there will be no error # Update the stored key without triggering reauth - update_client_key(hass, entry, client) + update_client_key(hass, entry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -99,19 +93,6 @@ async def async_update_options(hass: HomeAssistant, entry: WebOsTvConfigEntry) - await hass.config_entries.async_reload(entry.entry_id) -def update_client_key( - hass: HomeAssistant, entry: ConfigEntry, client: WebOsClient -) -> None: - """Check and update stored client key if key has changed.""" - host = entry.data[CONF_HOST] - key = entry.data[CONF_CLIENT_SECRET] - - if client.client_key != key: - _LOGGER.debug("Updating client key for host %s", host) - data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key} - hass.config_entries.async_update_entry(entry, data=data) - - async def async_unload_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index 1561a56defe..fbc3eb958dd 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow to configure webostv component.""" +"""Config flow for LG webOS TV integration.""" from __future__ import annotations @@ -35,7 +35,7 @@ DATA_SCHEMA = vol.Schema( async def async_control_connect( hass: HomeAssistant, host: str, key: str | None ) -> WebOsClient: - """Create LG WebOS client and connect to the TV.""" + """Create LG webOS client and connect to the TV.""" client = WebOsClient( host, key, @@ -48,7 +48,7 @@ async def async_control_connect( class FlowHandler(ConfigFlow, domain=DOMAIN): - """WebosTV configuration flow.""" + """LG webOS TV configuration flow.""" VERSION = 1 diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py index 9c85c4cf5ac..e505611db52 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -1,4 +1,4 @@ -"""Constants used for LG webOS Smart TV.""" +"""Constants for the LG webOS TV integration.""" import asyncio diff --git a/homeassistant/components/webostv/device_trigger.py b/homeassistant/components/webostv/device_trigger.py index 3021cc18ea5..951c11525b1 100644 --- a/homeassistant/components/webostv/device_trigger.py +++ b/homeassistant/components/webostv/device_trigger.py @@ -1,4 +1,4 @@ -"""Provides device automations for control of LG webOS Smart TV.""" +"""Provides device automations for control of LG webOS TV.""" from __future__ import annotations diff --git a/homeassistant/components/webostv/diagnostics.py b/homeassistant/components/webostv/diagnostics.py index d5e2dac06dc..7fb64a2cb8f 100644 --- a/homeassistant/components/webostv/diagnostics.py +++ b/homeassistant/components/webostv/diagnostics.py @@ -1,4 +1,4 @@ -"""Diagnostics support for LG webOS Smart TV.""" +"""Diagnostics support for LG webOS TV.""" from __future__ import annotations diff --git a/homeassistant/components/webostv/helpers.py b/homeassistant/components/webostv/helpers.py index 389c866ba14..3c509a56d1e 100644 --- a/homeassistant/components/webostv/helpers.py +++ b/homeassistant/components/webostv/helpers.py @@ -1,17 +1,24 @@ -"""Helper functions for webOS Smart TV.""" +"""Helper functions for LG webOS TV.""" from __future__ import annotations +import logging + from aiowebostv import WebOsClient -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry -from . import WebOsTvConfigEntry from .const import DOMAIN, LIVE_TV_APP_ID +_LOGGER = logging.getLogger(__name__) + +type WebOsTvConfigEntry = ConfigEntry[WebOsClient] + @callback def async_get_device_entry_by_device_id( @@ -32,7 +39,7 @@ def async_get_device_entry_by_device_id( def async_get_device_id_from_entity_id(hass: HomeAssistant, entity_id: str) -> str: """Get device ID from an entity ID. - Raises ValueError if entity or device ID is invalid. + Raises HomeAssistantError if entity or device ID is invalid. """ ent_reg = er.async_get(hass) entity_entry = ent_reg.async_get(entity_id) @@ -42,7 +49,11 @@ def async_get_device_id_from_entity_id(hass: HomeAssistant, entity_id: str) -> s or entity_entry.device_id is None or entity_entry.platform != DOMAIN ): - raise ValueError(f"Entity {entity_id} is not a valid {DOMAIN} entity.") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_entity_id", + translation_placeholders={"entity_id": entity_id}, + ) return entity_entry.device_id @@ -91,3 +102,15 @@ def get_sources(client: WebOsClient) -> list[str]: # Preserve order when filtering duplicates return list(dict.fromkeys(sources)) + + +def update_client_key(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> None: + """Check and update stored client key if key has changed.""" + client: WebOsClient = entry.runtime_data + host = entry.data[CONF_HOST] + key = entry.data[CONF_CLIENT_SECRET] + + if client.client_key != key: + _LOGGER.debug("Updating client key for host %s", host) + data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key} + hass.config_entries.async_update_entry(entry, data=data) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 1f280ddfc79..4b39841e29d 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -1,4 +1,4 @@ -"""Support for interface with an LG webOS Smart TV.""" +"""Support for interface with an LG webOS TV.""" from __future__ import annotations @@ -33,7 +33,6 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.trigger import PluggableAction from homeassistant.helpers.typing import VolDictType -from . import WebOsTvConfigEntry, update_client_key from .const import ( ATTR_BUTTON, ATTR_PAYLOAD, @@ -46,6 +45,7 @@ from .const import ( SERVICE_SELECT_SOUND_OUTPUT, WEBOSTV_EXCEPTIONS, ) +from .helpers import WebOsTvConfigEntry, update_client_key from .triggers.turn_on import async_get_turn_on_trigger _LOGGER = logging.getLogger(__name__) @@ -89,7 +89,7 @@ async def async_setup_entry( entry: WebOsTvConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the LG webOS Smart TV platform.""" + """Set up the LG webOS TV platform.""" platform = entity_platform.async_get_current_platform() for service_name, schema, method in SERVICES: @@ -132,7 +132,7 @@ def cmd[_T: LgWebOSMediaPlayerEntity, **_P]( class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): - """Representation of a LG webOS Smart TV.""" + """Representation of a LG webOS TV.""" _attr_device_class = MediaPlayerDeviceClass.TV _attr_has_entity_name = True @@ -335,7 +335,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): except WebOsTvPairError: self._entry.async_start_reauth(self.hass) else: - update_client_key(self.hass, self._entry, self._client) + update_client_key(self.hass, self._entry) @property def supported_features(self) -> MediaPlayerEntityFeature: @@ -392,10 +392,14 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): async def async_select_source(self, source: str) -> None: """Select input source.""" if (source_dict := self._source_list.get(source)) is None: - _LOGGER.warning( - "Source %s not found for %s", source, self._friendly_name_internal() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="source_not_found", + translation_placeholders={ + "source": source, + "name": str(self._friendly_name_internal()), + }, ) - return if source_dict.get("title"): await self._client.launch_app(source_dict["id"]) elif source_dict.get("label"): diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py index dbd79363198..2393cb4cd07 100644 --- a/homeassistant/components/webostv/notify.py +++ b/homeassistant/components/webostv/notify.py @@ -1,4 +1,4 @@ -"""Support for LG WebOS TV notification service.""" +"""Support for LG webOS TV notification service.""" from __future__ import annotations @@ -37,7 +37,7 @@ async def async_get_service( class LgWebOSNotificationService(BaseNotificationService): - """Implement the notification service for LG WebOS TV.""" + """Implement the notification service for LG webOS TV.""" def __init__(self, entry: WebOsTvConfigEntry) -> None: """Initialize the service.""" diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index 54cc8dbe230..f6d033af632 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -12,7 +12,7 @@ } }, "pairing": { - "title": "webOS TV Pairing", + "title": "LG webOS TV Pairing", "description": "Select **Submit** and accept the pairing request on your TV.\n\n![Image](/static/images/config_webos.png)" }, "reauth_confirm": { @@ -43,7 +43,7 @@ "options": { "step": { "init": { - "title": "Options for webOS Smart TV", + "title": "Options for LG webOS TV", "description": "Select enabled sources", "data": { "sources": "Sources list" @@ -129,6 +129,15 @@ }, "unhandled_trigger_type": { "message": "Unhandled trigger type: {trigger_type}" + }, + "unknown_trigger_platform": { + "message": "Unknown trigger platform: {platform}" + }, + "invalid_entity_id": { + "message": "Entity {entity_id} is not a valid webostv entity." + }, + "source_not_found": { + "message": "Source {source} not found in the sources list for {name}." } } } diff --git a/homeassistant/components/webostv/trigger.py b/homeassistant/components/webostv/trigger.py index 3290aa4a448..f121daafb91 100644 --- a/homeassistant/components/webostv/trigger.py +++ b/homeassistant/components/webostv/trigger.py @@ -1,4 +1,4 @@ -"""webOS Smart TV trigger dispatcher.""" +"""LG webOS TV trigger dispatcher.""" from __future__ import annotations @@ -6,6 +6,7 @@ from typing import cast from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.trigger import ( TriggerActionType, TriggerInfo, @@ -13,6 +14,7 @@ from homeassistant.helpers.trigger import ( ) from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN from .triggers import turn_on TRIGGERS = { @@ -24,8 +26,10 @@ def _get_trigger_platform(config: ConfigType) -> TriggerProtocol: """Return trigger platform.""" platform_split = config[CONF_PLATFORM].split(".", maxsplit=1) if len(platform_split) < 2 or platform_split[1] not in TRIGGERS: - raise ValueError( - f"Unknown webOS Smart TV trigger platform {config[CONF_PLATFORM]}" + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unknown_trigger_platform", + translation_placeholders={"platform": config[CONF_PLATFORM]}, ) return cast(TriggerProtocol, TRIGGERS[platform_split[1]]) diff --git a/homeassistant/components/webostv/triggers/__init__.py b/homeassistant/components/webostv/triggers/__init__.py index d8c5a28ef3f..89bdf5f90ee 100644 --- a/homeassistant/components/webostv/triggers/__init__.py +++ b/homeassistant/components/webostv/triggers/__init__.py @@ -1 +1 @@ -"""webOS Smart TV triggers.""" +"""LG webOS TV triggers.""" diff --git a/homeassistant/components/webostv/triggers/turn_on.py b/homeassistant/components/webostv/triggers/turn_on.py index f2ecb8aa98d..648da690715 100644 --- a/homeassistant/components/webostv/triggers/turn_on.py +++ b/homeassistant/components/webostv/triggers/turn_on.py @@ -1,4 +1,4 @@ -"""webOS Smart TV device turn on trigger.""" +"""LG webOS TV device turn on trigger.""" from __future__ import annotations diff --git a/tests/components/webostv/__init__.py b/tests/components/webostv/__init__.py index 5027b235eb1..d9a0a135023 100644 --- a/tests/components/webostv/__init__.py +++ b/tests/components/webostv/__init__.py @@ -1,4 +1,4 @@ -"""Tests for the WebOS TV integration.""" +"""Tests for the LG webOS TV integration.""" from homeassistant.components.webostv.const import DOMAIN from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py index 711d400b0e6..bf007f5b936 100644 --- a/tests/components/webostv/conftest.py +++ b/tests/components/webostv/conftest.py @@ -1,4 +1,4 @@ -"""Common fixtures and objects for the LG webOS integration tests.""" +"""Common fixtures and objects for the LG webOS TV integration tests.""" from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch diff --git a/tests/components/webostv/const.py b/tests/components/webostv/const.py index 52453d4ffa9..a63a4fe3289 100644 --- a/tests/components/webostv/const.py +++ b/tests/components/webostv/const.py @@ -1,4 +1,4 @@ -"""Constants for LG webOS Smart TV tests.""" +"""Constants for LG webOS TV tests.""" from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.webostv.const import LIVE_TV_APP_ID diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index 0d8b86b4ac2..34ab39618d8 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -1,4 +1,4 @@ -"""Test the WebOS Tv config flow.""" +"""Test the LG webOS TV config flow.""" from aiowebostv import WebOsTvPairError import pytest diff --git a/tests/components/webostv/test_device_trigger.py b/tests/components/webostv/test_device_trigger.py index 1995897e079..c14e8f4542a 100644 --- a/tests/components/webostv/test_device_trigger.py +++ b/tests/components/webostv/test_device_trigger.py @@ -1,4 +1,4 @@ -"""The tests for WebOS TV device triggers.""" +"""The tests for LG webOS TV device triggers.""" import pytest @@ -140,7 +140,6 @@ async def test_invalid_entry_raises( hass: HomeAssistant, device_registry: dr.DeviceRegistry, client, - caplog: pytest.LogCaptureFixture, domain: str, entry_state: ConfigEntryState, ) -> None: diff --git a/tests/components/webostv/test_diagnostics.py b/tests/components/webostv/test_diagnostics.py index d35dd1fb883..0cf815ce9e2 100644 --- a/tests/components/webostv/test_diagnostics.py +++ b/tests/components/webostv/test_diagnostics.py @@ -1,4 +1,4 @@ -"""Tests for the diagnostics data provided by LG webOS Smart TV.""" +"""Tests for the diagnostics data provided by LG webOS TV.""" from syrupy.assertion import SnapshotAssertion from syrupy.filters import props diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 5789fd19492..d5241dbe668 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -1,4 +1,4 @@ -"""The tests for the LG webOS media player platform.""" +"""The tests for the LG webOS TV media player platform.""" from datetime import timedelta from http import HTTPStatus @@ -165,7 +165,7 @@ async def test_media_next_previous_track( async def test_select_source_with_empty_source_list( - hass: HomeAssistant, client, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, client ) -> None: """Ensure we don't call client methods when we don't have sources.""" await setup_webostv(hass) @@ -175,11 +175,14 @@ async def test_select_source_with_empty_source_list( ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "nonexistent", } - await hass.services.async_call(MP_DOMAIN, SERVICE_SELECT_SOURCE, data, True) + with pytest.raises( + HomeAssistantError, + match=f"Source nonexistent not found in the sources list for {TV_NAME}", + ): + await hass.services.async_call(MP_DOMAIN, SERVICE_SELECT_SOURCE, data, True) client.launch_app.assert_not_called() client.set_input.assert_not_called() - assert f"Source nonexistent not found for {TV_NAME}" in caplog.text async def test_select_app_source(hass: HomeAssistant, client) -> None: diff --git a/tests/components/webostv/test_notify.py b/tests/components/webostv/test_notify.py index e57451088e3..fd56f0ea0bb 100644 --- a/tests/components/webostv/test_notify.py +++ b/tests/components/webostv/test_notify.py @@ -1,4 +1,4 @@ -"""The tests for the WebOS TV notify platform.""" +"""The tests for the LG webOS TV notify platform.""" from unittest.mock import call diff --git a/tests/components/webostv/test_trigger.py b/tests/components/webostv/test_trigger.py index d7eeae28ea3..c7decafff73 100644 --- a/tests/components/webostv/test_trigger.py +++ b/tests/components/webostv/test_trigger.py @@ -1,4 +1,4 @@ -"""The tests for WebOS TV automation triggers.""" +"""The tests for LG webOS TV automation triggers.""" from unittest.mock import patch @@ -118,10 +118,10 @@ async def test_webostv_turn_on_trigger_entity_id( assert service_calls[1].data["id"] == 0 -async def test_wrong_trigger_platform_type( +async def test_unknown_trigger_platform_type( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, client ) -> None: - """Test wrong trigger platform type.""" + """Test unknown trigger platform type.""" await setup_webostv(hass) await async_setup_component( @@ -131,7 +131,7 @@ async def test_wrong_trigger_platform_type( automation.DOMAIN: [ { "trigger": { - "platform": "webostv.wrong_type", + "platform": "webostv.unknown", "entity_id": ENTITY_ID, }, "action": { @@ -146,10 +146,7 @@ async def test_wrong_trigger_platform_type( }, ) - assert ( - "ValueError: Unknown webOS Smart TV trigger platform webostv.wrong_type" - in caplog.text - ) + assert "Unknown trigger platform: webostv.unknown" in caplog.text async def test_trigger_invalid_entity_id( @@ -185,7 +182,4 @@ async def test_trigger_invalid_entity_id( }, ) - assert ( - f"ValueError: Entity {invalid_entity} is not a valid webostv entity" - in caplog.text - ) + assert f"Entity {invalid_entity} is not a valid {DOMAIN} entity" in caplog.text From 7050dbb66dbe952e94153655a068bc192c288a3b Mon Sep 17 00:00:00 2001 From: David Knowles Date: Fri, 24 Jan 2025 08:13:54 -0500 Subject: [PATCH 0933/2987] Refactor the Hydrawise config flow (#135886) Co-authored-by: Joost Lekkerkerker --- .../components/hydrawise/config_flow.py | 143 ++++++++++-------- .../components/hydrawise/strings.json | 7 + .../components/hydrawise/test_config_flow.py | 80 +++++++--- 3 files changed, 146 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/hydrawise/config_flow.py b/homeassistant/components/hydrawise/config_flow.py index 5af32af3951..ed21e96cd0b 100644 --- a/homeassistant/components/hydrawise/config_flow.py +++ b/homeassistant/components/hydrawise/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Mapping from typing import Any from aiohttp import ClientError @@ -10,85 +10,104 @@ from pydrawise import auth as pydrawise_auth, client from pydrawise.exceptions import NotAuthorizedError import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from .const import APP_ID, DOMAIN, LOGGER +STEP_USER_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) + class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Hydrawise.""" VERSION = 1 - async def _create_or_update_entry( - self, - username: str, - password: str, - *, - on_failure: Callable[[str], ConfigFlowResult], - ) -> ConfigFlowResult: - """Create the config entry.""" - # Verify that the provided credentials work.""" - auth = pydrawise_auth.Auth(username, password) - try: - await auth.token() - except NotAuthorizedError: - return on_failure("invalid_auth") - except TimeoutError: - return on_failure("timeout_connect") - - try: - api = client.Hydrawise(auth, app_id=APP_ID) - # Don't fetch zones because we don't need them yet. - user = await api.get_user(fetch_zones=False) - except TimeoutError: - return on_failure("timeout_connect") - except ClientError as ex: - LOGGER.error("Unable to connect to Hydrawise cloud service: %s", ex) - return on_failure("cannot_connect") - - await self.async_set_unique_id(f"hydrawise-{user.customer_id}") - - if self.source != SOURCE_REAUTH: - self._abort_if_unique_id_configured() - return self.async_create_entry( - title="Hydrawise", - data={CONF_USERNAME: username, CONF_PASSWORD: password}, - ) - - return self.async_update_reload_and_abort( - self._get_reauth_entry(), - data_updates={CONF_USERNAME: username, CONF_PASSWORD: password}, - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial setup.""" - if user_input is not None: - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] + if user_input is None: + return self._show_user_form({}) + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + unique_id, errors = await _authenticate(username, password) + if errors: + return self._show_user_form(errors) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=username, + data={CONF_USERNAME: username, CONF_PASSWORD: password}, + ) - return await self._create_or_update_entry( - username=username, password=password, on_failure=self._show_form - ) - return self._show_form() - - def _show_form(self, error_type: str | None = None) -> ConfigFlowResult: - errors = {} - if error_type is not None: - errors["base"] = error_type + def _show_user_form(self, errors: dict[str, str]) -> ConfigFlowResult: return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} - ), - errors=errors, + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: - """Perform reauth after updating config to username/password.""" - return await self.async_step_user() + """Handle reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self._show_reauth_form({}) + + reauth_entry = self._get_reauth_entry() + username = reauth_entry.data[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + user_id, errors = await _authenticate(username, password) + if user_id is None: + return self._show_reauth_form(errors) + + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + reauth_entry, data={CONF_USERNAME: username, CONF_PASSWORD: password} + ) + + def _show_reauth_form(self, errors: dict[str, str]) -> ConfigFlowResult: + return self.async_show_form( + step_id="reauth_confirm", data_schema=STEP_REAUTH_DATA_SCHEMA, errors=errors + ) + + +async def _authenticate( + username: str, password: str +) -> tuple[str | None, dict[str, str]]: + """Authenticate with the Hydrawise API.""" + unique_id = None + errors: dict[str, str] = {} + auth = pydrawise_auth.Auth(username, password) + try: + await auth.token() + except NotAuthorizedError: + errors["base"] = "invalid_auth" + except TimeoutError: + errors["base"] = "timeout_connect" + + if errors: + return unique_id, errors + + try: + api = client.Hydrawise(auth, app_id=APP_ID) + # Don't fetch zones because we don't need them yet. + user = await api.get_user(fetch_zones=False) + except TimeoutError: + errors["base"] = "timeout_connect" + except ClientError as ex: + LOGGER.error("Unable to connect to Hydrawise cloud service: %s", ex) + errors["base"] = "cannot_connect" + else: + unique_id = f"hydrawise-{user.customer_id}" + + return unique_id, errors diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json index 4d50f10bcb2..74c63cbe758 100644 --- a/homeassistant/components/hydrawise/strings.json +++ b/homeassistant/components/hydrawise/strings.json @@ -8,6 +8,13 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Hydrawise integration needs to re-authenticate your account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { diff --git a/tests/components/hydrawise/test_config_flow.py b/tests/components/hydrawise/test_config_flow.py index 4d25fd5840b..cf723d885e1 100644 --- a/tests/components/hydrawise/test_config_flow.py +++ b/tests/components/hydrawise/test_config_flow.py @@ -9,7 +9,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.hydrawise.const import DOMAIN -from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -33,16 +33,16 @@ async def test_form( assert result["step_id"] == "user" assert result["errors"] == {} - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"}, ) mock_pydrawise.get_user.return_value = user await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Hydrawise" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "asdf@asdf.com" + assert result["data"] == { CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__", } @@ -69,14 +69,14 @@ async def test_form_api_error( mock_pydrawise.get_user.reset_mock(side_effect=True) mock_pydrawise.get_user.return_value = user - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) - assert result2["type"] is FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure(result["flow_id"], data) + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_form_auth_connect_timeout( hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock ) -> None: - """Test we handle API errors.""" + """Test we handle connection timeout errors.""" mock_auth.token.side_effect = TimeoutError init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -90,8 +90,8 @@ async def test_form_auth_connect_timeout( assert result["errors"] == {"base": "timeout_connect"} mock_auth.token.reset_mock(side_effect=True) - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) - assert result2["type"] is FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure(result["flow_id"], data) + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_form_client_connect_timeout( @@ -112,8 +112,8 @@ async def test_form_client_connect_timeout( mock_pydrawise.get_user.reset_mock(side_effect=True) mock_pydrawise.get_user.return_value = user - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) - assert result2["type"] is FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure(result["flow_id"], data) + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_form_not_authorized_error( @@ -133,8 +133,8 @@ async def test_form_not_authorized_error( assert result["errors"] == {"base": "invalid_auth"} mock_auth.token.reset_mock(side_effect=True) - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) - assert result2["type"] is FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure(result["flow_id"], data) + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_reauth( @@ -148,7 +148,8 @@ async def test_reauth( title="Hydrawise", domain=DOMAIN, data={ - CONF_API_KEY: "__api_key__", + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "bad-password", }, unique_id="hydrawise-12345", ) @@ -160,14 +161,49 @@ async def test_reauth( flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 [result] = flows - assert result["step_id"] == "user" + assert result["step_id"] == "reauth_confirm" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"}, - ) mock_pydrawise.get_user.return_value = user + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "__password__"} + ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_fails( + hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock, user: User +) -> None: + """Test that the reauth flow handles API errors.""" + mock_config_entry = MockConfigEntry( + title="Hydrawise", + domain=DOMAIN, + data={ + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "bad-password", + }, + unique_id="hydrawise-12345", + ) + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm" + + mock_auth.token.side_effect = NotAuthorizedError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "__password__"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + mock_auth.token.reset_mock(side_effect=True) + mock_pydrawise.get_user.return_value = user + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "__password__"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" From c991d4dac5617ddc69889ca60c5f5181c72b1040 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jan 2025 14:14:03 +0100 Subject: [PATCH 0934/2987] Move dormakaba_dkey coordinator to separate module (#136437) --- .../components/dormakaba_dkey/__init__.py | 40 +++------------- .../dormakaba_dkey/binary_sensor.py | 17 +++---- .../components/dormakaba_dkey/coordinator.py | 48 +++++++++++++++++++ .../components/dormakaba_dkey/entity.py | 20 ++++---- .../components/dormakaba_dkey/lock.py | 22 ++++----- .../components/dormakaba_dkey/models.py | 17 ------- .../components/dormakaba_dkey/sensor.py | 20 ++++---- 7 files changed, 89 insertions(+), 95 deletions(-) create mode 100644 homeassistant/components/dormakaba_dkey/coordinator.py delete mode 100644 homeassistant/components/dormakaba_dkey/models.py diff --git a/homeassistant/components/dormakaba_dkey/__init__.py b/homeassistant/components/dormakaba_dkey/__init__.py index b4304e75aab..5900198b268 100644 --- a/homeassistant/components/dormakaba_dkey/__init__.py +++ b/homeassistant/components/dormakaba_dkey/__init__.py @@ -2,11 +2,7 @@ from __future__ import annotations -from datetime import timedelta -import logging - from py_dormakaba_dkey import DKEYLock -from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS, NotAssociated from py_dormakaba_dkey.models import AssociationData from homeassistant.components import bluetooth @@ -14,16 +10,13 @@ from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackM from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_ASSOCIATION_DATA, DOMAIN, UPDATE_SECONDS -from .models import DormakabaDkeyData +from .const import CONF_ASSOCIATION_DATA, DOMAIN +from .coordinator import DormakabaDkeyCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Dormakaba dKey from a config entry.""" @@ -56,29 +49,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - async def _async_update() -> None: - """Update the device state.""" - try: - await lock.update() - await lock.disconnect() - except NotAssociated as ex: - raise ConfigEntryAuthFailed("Not associated") from ex - except DKEY_EXCEPTIONS as ex: - raise UpdateFailed(str(ex)) from ex - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=lock.name, - update_method=_async_update, - update_interval=timedelta(seconds=UPDATE_SECONDS), - ) + coordinator = DormakabaDkeyCoordinator(hass, entry, lock) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DormakabaDkeyData( - lock, coordinator - ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -95,7 +69,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.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - data: DormakabaDkeyData = hass.data[DOMAIN].pop(entry.entry_id) - await data.lock.disconnect() + coordinator: DormakabaDkeyCoordinator = hass.data[DOMAIN].pop(entry.entry_id) + await coordinator.lock.disconnect() return unload_ok diff --git a/homeassistant/components/dormakaba_dkey/binary_sensor.py b/homeassistant/components/dormakaba_dkey/binary_sensor.py index a8574443e35..f40fa2e89d2 100644 --- a/homeassistant/components/dormakaba_dkey/binary_sensor.py +++ b/homeassistant/components/dormakaba_dkey/binary_sensor.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from py_dormakaba_dkey import DKEYLock from py_dormakaba_dkey.commands import DoorPosition, Notifications, UnlockStatus from homeassistant.components.binary_sensor import ( @@ -16,11 +15,10 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +from .coordinator import DormakabaDkeyCoordinator from .entity import DormakabaDkeyEntity -from .models import DormakabaDkeyData @dataclass(frozen=True, kw_only=True) @@ -52,9 +50,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the binary sensor platform for Dormakaba dKey.""" - data: DormakabaDkeyData = hass.data[DOMAIN][entry.entry_id] + coordinator: DormakabaDkeyCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - DormakabaDkeyBinarySensor(data.coordinator, data.lock, description) + DormakabaDkeyBinarySensor(coordinator, description) for description in BINARY_SENSOR_DESCRIPTIONS ) @@ -67,16 +65,15 @@ class DormakabaDkeyBinarySensor(DormakabaDkeyEntity, BinarySensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator[None], - lock: DKEYLock, + coordinator: DormakabaDkeyCoordinator, description: DormakabaDkeyBinarySensorDescription, ) -> None: """Initialize a Dormakaba dKey binary sensor.""" self.entity_description = description - self._attr_unique_id = f"{lock.address}_{description.key}" - super().__init__(coordinator, lock) + self._attr_unique_id = f"{coordinator.lock.address}_{description.key}" + super().__init__(coordinator) @callback def _async_update_attrs(self) -> None: """Handle updating _attr values.""" - self._attr_is_on = self.entity_description.is_on(self._lock.state) + self._attr_is_on = self.entity_description.is_on(self.coordinator.lock.state) diff --git a/homeassistant/components/dormakaba_dkey/coordinator.py b/homeassistant/components/dormakaba_dkey/coordinator.py new file mode 100644 index 00000000000..c4abb8e8c24 --- /dev/null +++ b/homeassistant/components/dormakaba_dkey/coordinator.py @@ -0,0 +1,48 @@ +"""Coordinator for the Dormakaba dKey integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from py_dormakaba_dkey import DKEYLock +from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS, NotAssociated + +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 UPDATE_SECONDS + +_LOGGER = logging.getLogger(__name__) + + +class DormakabaDkeyCoordinator(DataUpdateCoordinator[None]): + """DormakabaDkey coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + lock: DKEYLock, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=lock.name, + update_interval=timedelta(seconds=UPDATE_SECONDS), + ) + self.lock = lock + + async def _async_update_data(self) -> None: + """Update the device state.""" + try: + await self.lock.update() + await self.lock.disconnect() + except NotAssociated as ex: + raise ConfigEntryAuthFailed("Not associated") from ex + except DKEY_EXCEPTIONS as ex: + raise UpdateFailed(str(ex)) from ex diff --git a/homeassistant/components/dormakaba_dkey/entity.py b/homeassistant/components/dormakaba_dkey/entity.py index 756edccf02f..cc34a70014d 100644 --- a/homeassistant/components/dormakaba_dkey/entity.py +++ b/homeassistant/components/dormakaba_dkey/entity.py @@ -4,29 +4,25 @@ from __future__ import annotations import abc -from py_dormakaba_dkey import DKEYLock from py_dormakaba_dkey.commands import Notifications from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import DormakabaDkeyCoordinator -class DormakabaDkeyEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): +class DormakabaDkeyEntity(CoordinatorEntity[DormakabaDkeyCoordinator]): """Dormakaba dKey base entity.""" _attr_has_entity_name = True - def __init__( - self, coordinator: DataUpdateCoordinator[None], lock: DKEYLock - ) -> None: + def __init__(self, coordinator: DormakabaDkeyCoordinator) -> None: """Initialize a Dormakaba dKey entity.""" super().__init__(coordinator) - self._lock = lock + lock = coordinator.lock self._attr_device_info = DeviceInfo( name=lock.device_info.device_name or lock.device_info.device_id, model="MTL 9291", @@ -53,5 +49,7 @@ class DormakabaDkeyEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): async def async_added_to_hass(self) -> None: """Register callbacks.""" - self.async_on_remove(self._lock.register_callback(self._handle_state_update)) + self.async_on_remove( + self.coordinator.lock.register_callback(self._handle_state_update) + ) return await super().async_added_to_hass() diff --git a/homeassistant/components/dormakaba_dkey/lock.py b/homeassistant/components/dormakaba_dkey/lock.py index 5f475d37152..94d25dd22ce 100644 --- a/homeassistant/components/dormakaba_dkey/lock.py +++ b/homeassistant/components/dormakaba_dkey/lock.py @@ -4,18 +4,16 @@ from __future__ import annotations from typing import Any -from py_dormakaba_dkey import DKEYLock from py_dormakaba_dkey.commands import UnlockStatus from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +from .coordinator import DormakabaDkeyCoordinator from .entity import DormakabaDkeyEntity -from .models import DormakabaDkeyData async def async_setup_entry( @@ -24,8 +22,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the lock platform for Dormakaba dKey.""" - data: DormakabaDkeyData = hass.data[DOMAIN][entry.entry_id] - async_add_entities([DormakabaDkeyLock(data.coordinator, data.lock)]) + coordinator: DormakabaDkeyCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([DormakabaDkeyLock(coordinator)]) class DormakabaDkeyLock(DormakabaDkeyEntity, LockEntity): @@ -33,25 +31,23 @@ class DormakabaDkeyLock(DormakabaDkeyEntity, LockEntity): _attr_has_entity_name = True - def __init__( - self, coordinator: DataUpdateCoordinator[None], lock: DKEYLock - ) -> None: + def __init__(self, coordinator: DormakabaDkeyCoordinator) -> None: """Initialize a Dormakaba dKey lock.""" - self._attr_unique_id = lock.address - super().__init__(coordinator, lock) + self._attr_unique_id = coordinator.lock.address + super().__init__(coordinator) @callback def _async_update_attrs(self) -> None: """Handle updating _attr values.""" - self._attr_is_locked = self._lock.state.unlock_status in ( + self._attr_is_locked = self.coordinator.lock.state.unlock_status in ( UnlockStatus.LOCKED, UnlockStatus.SECURITY_LOCKED, ) async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" - await self._lock.lock() + await self.coordinator.lock.lock() async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" - await self._lock.unlock() + await self.coordinator.lock.unlock() diff --git a/homeassistant/components/dormakaba_dkey/models.py b/homeassistant/components/dormakaba_dkey/models.py deleted file mode 100644 index 23687e82334..00000000000 --- a/homeassistant/components/dormakaba_dkey/models.py +++ /dev/null @@ -1,17 +0,0 @@ -"""The Dormakaba dKey integration models.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from py_dormakaba_dkey import DKEYLock - -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - - -@dataclass -class DormakabaDkeyData: - """Data for the Dormakaba dKey integration.""" - - lock: DKEYLock - coordinator: DataUpdateCoordinator[None] diff --git a/homeassistant/components/dormakaba_dkey/sensor.py b/homeassistant/components/dormakaba_dkey/sensor.py index e461ba1e44f..522fa6113af 100644 --- a/homeassistant/components/dormakaba_dkey/sensor.py +++ b/homeassistant/components/dormakaba_dkey/sensor.py @@ -2,8 +2,6 @@ from __future__ import annotations -from py_dormakaba_dkey import DKEYLock - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -14,11 +12,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +from .coordinator import DormakabaDkeyCoordinator from .entity import DormakabaDkeyEntity -from .models import DormakabaDkeyData BINARY_SENSOR_DESCRIPTIONS = ( SensorEntityDescription( @@ -36,9 +33,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the lock platform for Dormakaba dKey.""" - data: DormakabaDkeyData = hass.data[DOMAIN][entry.entry_id] + coordinator: DormakabaDkeyCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - DormakabaDkeySensor(data.coordinator, data.lock, description) + DormakabaDkeySensor(coordinator, description) for description in BINARY_SENSOR_DESCRIPTIONS ) @@ -50,16 +47,17 @@ class DormakabaDkeySensor(DormakabaDkeyEntity, SensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator[None], - lock: DKEYLock, + coordinator: DormakabaDkeyCoordinator, description: SensorEntityDescription, ) -> None: """Initialize a Dormakaba dKey binary sensor.""" self.entity_description = description - self._attr_unique_id = f"{lock.address}_{description.key}" - super().__init__(coordinator, lock) + self._attr_unique_id = f"{coordinator.lock.address}_{description.key}" + super().__init__(coordinator) @callback def _async_update_attrs(self) -> None: """Handle updating _attr values.""" - self._attr_native_value = getattr(self._lock, self.entity_description.key) + self._attr_native_value = getattr( + self.coordinator.lock, self.entity_description.key + ) From 384c173ab304141e6bb5e456ce2d1886409ee098 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jan 2025 14:14:42 +0100 Subject: [PATCH 0935/2987] Use runtime_data in directv (#136435) --- homeassistant/components/directv/__init__.py | 18 +++++++----------- .../components/directv/media_player.py | 7 +++---- homeassistant/components/directv/remote.py | 7 +++---- tests/components/directv/test_init.py | 3 --- 4 files changed, 13 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/directv/__init__.py b/homeassistant/components/directv/__init__.py index e59fa4e9d0d..274cc4cbf53 100644 --- a/homeassistant/components/directv/__init__.py +++ b/homeassistant/components/directv/__init__.py @@ -12,13 +12,14 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN - PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] SCAN_INTERVAL = timedelta(seconds=30) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type DirecTVConfigEntry = ConfigEntry[DIRECTV] + + +async def async_setup_entry(hass: HomeAssistant, entry: DirecTVConfigEntry) -> bool: """Set up DirecTV from a config entry.""" dtv = DIRECTV(entry.data[CONF_HOST], session=async_get_clientsession(hass)) @@ -27,18 +28,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except DIRECTVError as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = dtv + entry.runtime_data = dtv 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: DirecTVConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index 6c4a40598de..8998e050a75 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -14,17 +14,16 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util +from . import DirecTVConfigEntry from .const import ( ATTR_MEDIA_CURRENTLY_RECORDING, ATTR_MEDIA_RATING, ATTR_MEDIA_RECORDED, ATTR_MEDIA_START_TIME, - DOMAIN, ) from .entity import DIRECTVEntity @@ -55,11 +54,11 @@ SUPPORT_DTV_CLIENT = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DirecTVConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the DirecTV config entry.""" - dtv = hass.data[DOMAIN][entry.entry_id] + dtv = entry.runtime_data async_add_entities( ( diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py index 5a77d90bd3c..dbaab5fa4e6 100644 --- a/homeassistant/components/directv/remote.py +++ b/homeassistant/components/directv/remote.py @@ -10,11 +10,10 @@ from typing import Any from directv import DIRECTV, DIRECTVError from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import DirecTVConfigEntry from .entity import DIRECTVEntity _LOGGER = logging.getLogger(__name__) @@ -24,11 +23,11 @@ SCAN_INTERVAL = timedelta(minutes=2) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DirecTVConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Load DirecTV remote based on a config entry.""" - dtv = hass.data[DOMAIN][entry.entry_id] + dtv = entry.runtime_data async_add_entities( ( diff --git a/tests/components/directv/test_init.py b/tests/components/directv/test_init.py index 4bfe8e2121f..102c338e757 100644 --- a/tests/components/directv/test_init.py +++ b/tests/components/directv/test_init.py @@ -1,6 +1,5 @@ """Tests for the DirecTV integration.""" -from homeassistant.components.directv.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -24,11 +23,9 @@ async def test_unload_config_entry( """Test the DirecTV configuration entry unloading.""" entry = await setup_integration(hass, aioclient_mock) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED From f6b1786b13037f06a8c7abac12e5af24bc013903 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jan 2025 14:20:23 +0100 Subject: [PATCH 0936/2987] Move dexcom coordinator to separate module (#136433) --- homeassistant/components/dexcom/__init__.py | 26 ++---------- .../components/dexcom/coordinator.py | 42 +++++++++++++++++++ homeassistant/components/dexcom/sensor.py | 18 +++----- 3 files changed, 51 insertions(+), 35 deletions(-) create mode 100644 homeassistant/components/dexcom/coordinator.py diff --git a/homeassistant/components/dexcom/__init__.py b/homeassistant/components/dexcom/__init__.py index e93e8e66358..f799d150f61 100644 --- a/homeassistant/components/dexcom/__init__.py +++ b/homeassistant/components/dexcom/__init__.py @@ -1,21 +1,14 @@ """The Dexcom integration.""" -from datetime import timedelta -import logging - -from pydexcom import AccountError, Dexcom, GlucoseReading, SessionError +from pydexcom import AccountError, Dexcom, SessionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_SERVER, DOMAIN, PLATFORMS, SERVER_OUS - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(seconds=180) +from .coordinator import DexcomCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -32,20 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SessionError as error: raise ConfigEntryNotReady from error - async def async_update_data(): - try: - return await hass.async_add_executor_job(dexcom.get_current_glucose_reading) - except SessionError as error: - raise UpdateFailed(error) from error - - coordinator = DataUpdateCoordinator[GlucoseReading]( - hass, - _LOGGER, - config_entry=entry, - name=DOMAIN, - update_method=async_update_data, - update_interval=SCAN_INTERVAL, - ) + coordinator = DexcomCoordinator(hass, entry=entry, dexcom=dexcom) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/dexcom/coordinator.py b/homeassistant/components/dexcom/coordinator.py new file mode 100644 index 00000000000..af01482c4eb --- /dev/null +++ b/homeassistant/components/dexcom/coordinator.py @@ -0,0 +1,42 @@ +"""Coordinator for the Dexcom integration.""" + +from datetime import timedelta +import logging + +from pydexcom import Dexcom, GlucoseReading + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +_SCAN_INTERVAL = timedelta(seconds=180) + + +class DexcomCoordinator(DataUpdateCoordinator[GlucoseReading]): + """Dexcom Coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + dexcom: Dexcom, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=_SCAN_INTERVAL, + ) + self.dexcom = dexcom + + async def _async_update_data(self) -> GlucoseReading: + """Fetch data from API endpoint.""" + return await self.hass.async_add_executor_job( + self.dexcom.get_current_glucose_reading + ) diff --git a/homeassistant/components/dexcom/sensor.py b/homeassistant/components/dexcom/sensor.py index 850678e7ac9..d9381964db7 100644 --- a/homeassistant/components/dexcom/sensor.py +++ b/homeassistant/components/dexcom/sensor.py @@ -2,20 +2,16 @@ from __future__ import annotations -from pydexcom import GlucoseReading - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME, UnitOfBloodGlucoseConcentration from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import DexcomCoordinator TRENDS = { 1: "rising_quickly", @@ -44,16 +40,14 @@ async def async_setup_entry( ) -class DexcomSensorEntity( - CoordinatorEntity[DataUpdateCoordinator[GlucoseReading]], SensorEntity -): +class DexcomSensorEntity(CoordinatorEntity[DexcomCoordinator], SensorEntity): """Base Dexcom sensor entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: DataUpdateCoordinator[GlucoseReading], + coordinator: DexcomCoordinator, username: str, entry_id: str, key: str, @@ -78,7 +72,7 @@ class DexcomGlucoseValueSensor(DexcomSensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: DexcomCoordinator, username: str, entry_id: str, ) -> None: @@ -101,7 +95,7 @@ class DexcomGlucoseTrendSensor(DexcomSensorEntity): _attr_options = list(TRENDS.values()) def __init__( - self, coordinator: DataUpdateCoordinator, username: str, entry_id: str + self, coordinator: DexcomCoordinator, username: str, entry_id: str ) -> None: """Initialize the sensor.""" super().__init__(coordinator, username, entry_id, "trend") From 2e78ab620f1276db661013913eaee9d4e16cac3b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jan 2025 14:52:22 +0100 Subject: [PATCH 0937/2987] Use runtime_data in dormakaba_dkey (#136440) --- .../components/dormakaba_dkey/__init__.py | 22 +++++++++---------- .../dormakaba_dkey/binary_sensor.py | 8 +++---- .../components/dormakaba_dkey/coordinator.py | 4 +++- .../components/dormakaba_dkey/lock.py | 9 +++----- .../components/dormakaba_dkey/sensor.py | 8 +++---- 5 files changed, 23 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/__init__.py b/homeassistant/components/dormakaba_dkey/__init__.py index 5900198b268..0a00490f3d9 100644 --- a/homeassistant/components/dormakaba_dkey/__init__.py +++ b/homeassistant/components/dormakaba_dkey/__init__.py @@ -7,18 +7,19 @@ from py_dormakaba_dkey.models import AssociationData from homeassistant.components import bluetooth from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_ASSOCIATION_DATA, DOMAIN -from .coordinator import DormakabaDkeyCoordinator +from .const import CONF_ASSOCIATION_DATA +from .coordinator import DormakabaDkeyConfigEntry, DormakabaDkeyCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: DormakabaDkeyConfigEntry +) -> bool: """Set up Dormakaba dKey from a config entry.""" address: str = entry.data[CONF_ADDRESS] ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True) @@ -52,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = DormakabaDkeyCoordinator(hass, entry, lock) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -63,13 +64,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) ) + entry.async_on_unload(coordinator.lock.disconnect) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: DormakabaDkeyConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - coordinator: DormakabaDkeyCoordinator = hass.data[DOMAIN].pop(entry.entry_id) - await coordinator.lock.disconnect() - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/dormakaba_dkey/binary_sensor.py b/homeassistant/components/dormakaba_dkey/binary_sensor.py index f40fa2e89d2..56b991bf908 100644 --- a/homeassistant/components/dormakaba_dkey/binary_sensor.py +++ b/homeassistant/components/dormakaba_dkey/binary_sensor.py @@ -12,12 +12,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import DormakabaDkeyCoordinator +from .coordinator import DormakabaDkeyConfigEntry, DormakabaDkeyCoordinator from .entity import DormakabaDkeyEntity @@ -46,11 +44,11 @@ BINARY_SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DormakabaDkeyConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the binary sensor platform for Dormakaba dKey.""" - coordinator: DormakabaDkeyCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( DormakabaDkeyBinarySensor(coordinator, description) for description in BINARY_SENSOR_DESCRIPTIONS diff --git a/homeassistant/components/dormakaba_dkey/coordinator.py b/homeassistant/components/dormakaba_dkey/coordinator.py index c4abb8e8c24..32f71ebf59d 100644 --- a/homeassistant/components/dormakaba_dkey/coordinator.py +++ b/homeassistant/components/dormakaba_dkey/coordinator.py @@ -17,6 +17,8 @@ from .const import UPDATE_SECONDS _LOGGER = logging.getLogger(__name__) +type DormakabaDkeyConfigEntry = ConfigEntry[DormakabaDkeyCoordinator] + class DormakabaDkeyCoordinator(DataUpdateCoordinator[None]): """DormakabaDkey coordinator.""" @@ -24,7 +26,7 @@ class DormakabaDkeyCoordinator(DataUpdateCoordinator[None]): def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: DormakabaDkeyConfigEntry, lock: DKEYLock, ) -> None: """Initialize the coordinator.""" diff --git a/homeassistant/components/dormakaba_dkey/lock.py b/homeassistant/components/dormakaba_dkey/lock.py index 94d25dd22ce..352e7cbe0ac 100644 --- a/homeassistant/components/dormakaba_dkey/lock.py +++ b/homeassistant/components/dormakaba_dkey/lock.py @@ -7,23 +7,20 @@ from typing import Any from py_dormakaba_dkey.commands import UnlockStatus from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import DormakabaDkeyCoordinator +from .coordinator import DormakabaDkeyConfigEntry, DormakabaDkeyCoordinator from .entity import DormakabaDkeyEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DormakabaDkeyConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the lock platform for Dormakaba dKey.""" - coordinator: DormakabaDkeyCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([DormakabaDkeyLock(coordinator)]) + async_add_entities([DormakabaDkeyLock(entry.runtime_data)]) class DormakabaDkeyLock(DormakabaDkeyEntity, LockEntity): diff --git a/homeassistant/components/dormakaba_dkey/sensor.py b/homeassistant/components/dormakaba_dkey/sensor.py index 522fa6113af..b1e941bc7e1 100644 --- a/homeassistant/components/dormakaba_dkey/sensor.py +++ b/homeassistant/components/dormakaba_dkey/sensor.py @@ -8,13 +8,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import DormakabaDkeyCoordinator +from .coordinator import DormakabaDkeyConfigEntry, DormakabaDkeyCoordinator from .entity import DormakabaDkeyEntity BINARY_SENSOR_DESCRIPTIONS = ( @@ -29,11 +27,11 @@ BINARY_SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DormakabaDkeyConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the lock platform for Dormakaba dKey.""" - coordinator: DormakabaDkeyCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( DormakabaDkeySensor(coordinator, description) for description in BINARY_SENSOR_DESCRIPTIONS From f3e13f466214664ce615c211ae89a7cc82a7db3f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jan 2025 15:13:53 +0100 Subject: [PATCH 0938/2987] Use runtime_data in duotecno (#136444) --- homeassistant/components/duotecno/__init__.py | 17 ++++++++--------- .../components/duotecno/binary_sensor.py | 9 +++------ homeassistant/components/duotecno/climate.py | 10 ++++------ homeassistant/components/duotecno/cover.py | 10 ++++------ homeassistant/components/duotecno/light.py | 11 +++++------ homeassistant/components/duotecno/switch.py | 10 ++++------ 6 files changed, 28 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/duotecno/__init__.py b/homeassistant/components/duotecno/__init__.py index 1873db45226..766fad49e81 100644 --- a/homeassistant/components/duotecno/__init__.py +++ b/homeassistant/components/duotecno/__init__.py @@ -10,8 +10,6 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN - PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.CLIMATE, @@ -21,7 +19,10 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type DuotecnoConfigEntry = ConfigEntry[PyDuotecno] + + +async def async_setup_entry(hass: HomeAssistant, entry: DuotecnoConfigEntry) -> bool: """Set up duotecno from a config entry.""" controller = PyDuotecno() @@ -31,14 +32,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) except (OSError, InvalidPassword, LoadFailure) as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller + + entry.runtime_data = controller 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: DuotecnoConfigEntry) -> 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(entry, PLATFORMS) diff --git a/homeassistant/components/duotecno/binary_sensor.py b/homeassistant/components/duotecno/binary_sensor.py index 10c807a8023..aadef47b998 100644 --- a/homeassistant/components/duotecno/binary_sensor.py +++ b/homeassistant/components/duotecno/binary_sensor.py @@ -2,28 +2,25 @@ from __future__ import annotations -from duotecno.controller import PyDuotecno from duotecno.unit import ControlUnit, VirtualUnit from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import DuotecnoConfigEntry from .entity import DuotecnoEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DuotecnoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Duotecno binary sensor on config_entry.""" - cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] async_add_entities( DuotecnoBinarySensor(channel) - for channel in cntrl.get_units(["ControlUnit", "VirtualUnit"]) + for channel in entry.runtime_data.get_units(["ControlUnit", "VirtualUnit"]) ) diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py index 0355d2855d3..83a211d97f5 100644 --- a/homeassistant/components/duotecno/climate.py +++ b/homeassistant/components/duotecno/climate.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import Any, Final -from duotecno.controller import PyDuotecno from duotecno.unit import SensUnit from homeassistant.components.climate import ( @@ -12,12 +11,11 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import DuotecnoConfigEntry from .entity import DuotecnoEntity, api_call HVACMODE: Final = { @@ -33,13 +31,13 @@ PRESETMODES_REVERSE: Final = {value: key for key, value in PRESETMODES.items()} async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DuotecnoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Duotecno climate based on config_entry.""" - cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] async_add_entities( - DuotecnoClimate(channel) for channel in cntrl.get_units(["SensUnit"]) + DuotecnoClimate(channel) + for channel in entry.runtime_data.get_units(["SensUnit"]) ) diff --git a/homeassistant/components/duotecno/cover.py b/homeassistant/components/duotecno/cover.py index 1c4f7d70fc5..7d879741555 100644 --- a/homeassistant/components/duotecno/cover.py +++ b/homeassistant/components/duotecno/cover.py @@ -4,27 +4,25 @@ from __future__ import annotations from typing import Any -from duotecno.controller import PyDuotecno from duotecno.unit import DuoswitchUnit from homeassistant.components.cover import CoverEntity, CoverEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import DuotecnoConfigEntry from .entity import DuotecnoEntity, api_call async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DuotecnoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the duoswitch endities.""" - cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] async_add_entities( - DuotecnoCover(channel) for channel in cntrl.get_units("DuoswitchUnit") + DuotecnoCover(channel) + for channel in entry.runtime_data.get_units("DuoswitchUnit") ) diff --git a/homeassistant/components/duotecno/light.py b/homeassistant/components/duotecno/light.py index 57635ac2bc2..7b41cbaef22 100644 --- a/homeassistant/components/duotecno/light.py +++ b/homeassistant/components/duotecno/light.py @@ -2,26 +2,25 @@ from typing import Any -from duotecno.controller import PyDuotecno from duotecno.unit import DimUnit from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import DuotecnoConfigEntry from .entity import DuotecnoEntity, api_call async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DuotecnoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Duotecno light based on config_entry.""" - cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] - async_add_entities(DuotecnoLight(channel) for channel in cntrl.get_units("DimUnit")) + async_add_entities( + DuotecnoLight(channel) for channel in entry.runtime_data.get_units("DimUnit") + ) class DuotecnoLight(DuotecnoEntity, LightEntity): diff --git a/homeassistant/components/duotecno/switch.py b/homeassistant/components/duotecno/switch.py index b3a87786d4e..0c01a6ca4de 100644 --- a/homeassistant/components/duotecno/switch.py +++ b/homeassistant/components/duotecno/switch.py @@ -2,27 +2,25 @@ from typing import Any -from duotecno.controller import PyDuotecno from duotecno.unit import SwitchUnit from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import DuotecnoConfigEntry from .entity import DuotecnoEntity, api_call async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DuotecnoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" - cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] async_add_entities( - DuotecnoSwitch(channel) for channel in cntrl.get_units("SwitchUnit") + DuotecnoSwitch(channel) + for channel in entry.runtime_data.get_units("SwitchUnit") ) From 4dc873416fd82eeeaa80a779a6529364b6d3e23a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jan 2025 15:14:05 +0100 Subject: [PATCH 0939/2987] Use runtime_data in dexcom (#136441) --- homeassistant/components/dexcom/__init__.py | 15 ++++++--------- homeassistant/components/dexcom/coordinator.py | 4 +++- homeassistant/components/dexcom/sensor.py | 7 +++---- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/dexcom/__init__.py b/homeassistant/components/dexcom/__init__.py index f799d150f61..54722c8dade 100644 --- a/homeassistant/components/dexcom/__init__.py +++ b/homeassistant/components/dexcom/__init__.py @@ -2,16 +2,15 @@ from pydexcom import AccountError, Dexcom, SessionError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_SERVER, DOMAIN, PLATFORMS, SERVER_OUS -from .coordinator import DexcomCoordinator +from .const import CONF_SERVER, PLATFORMS, SERVER_OUS +from .coordinator import DexcomConfigEntry, DexcomCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: DexcomConfigEntry) -> bool: """Set up Dexcom from a config entry.""" try: dexcom = await hass.async_add_executor_job( @@ -28,15 +27,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = DexcomCoordinator(hass, entry=entry, dexcom=dexcom) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = 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: DexcomConfigEntry) -> 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(entry, PLATFORMS) diff --git a/homeassistant/components/dexcom/coordinator.py b/homeassistant/components/dexcom/coordinator.py index af01482c4eb..a9e14def350 100644 --- a/homeassistant/components/dexcom/coordinator.py +++ b/homeassistant/components/dexcom/coordinator.py @@ -15,6 +15,8 @@ _LOGGER = logging.getLogger(__name__) _SCAN_INTERVAL = timedelta(seconds=180) +type DexcomConfigEntry = ConfigEntry[DexcomCoordinator] + class DexcomCoordinator(DataUpdateCoordinator[GlucoseReading]): """Dexcom Coordinator.""" @@ -22,7 +24,7 @@ class DexcomCoordinator(DataUpdateCoordinator[GlucoseReading]): def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: DexcomConfigEntry, dexcom: Dexcom, ) -> None: """Initialize the coordinator.""" diff --git a/homeassistant/components/dexcom/sensor.py b/homeassistant/components/dexcom/sensor.py index d9381964db7..cdb1894b675 100644 --- a/homeassistant/components/dexcom/sensor.py +++ b/homeassistant/components/dexcom/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME, UnitOfBloodGlucoseConcentration from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -11,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import DexcomCoordinator +from .coordinator import DexcomConfigEntry, DexcomCoordinator TRENDS = { 1: "rising_quickly", @@ -26,11 +25,11 @@ TRENDS = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DexcomConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Dexcom sensors.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data username = config_entry.data[CONF_USERNAME] async_add_entities( [ From 98e59f01b7731e1a7b83f3579d1d52a42b342613 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Jan 2025 04:23:22 -1000 Subject: [PATCH 0940/2987] Bump aioharmony to 0.4.1 (#136413) changelog: https://github.com/Harmony-Libs/aioharmony/compare/v0.4.0...v0.4.1 --- homeassistant/components/harmony/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index 28fc084a2ef..aab4f51b09a 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/harmony", "iot_class": "local_push", "loggers": ["aioharmony", "slixmpp"], - "requirements": ["aioharmony==0.4.0"], + "requirements": ["aioharmony==0.4.1"], "ssdp": [ { "manufacturer": "Logitech", diff --git a/requirements_all.txt b/requirements_all.txt index c7916af8b95..9a1e61c6d93 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -258,7 +258,7 @@ aiogithubapi==24.6.0 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.4.0 +aioharmony==0.4.1 # homeassistant.components.hassio aiohasupervisor==0.2.2b5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fba1b34a3f8..975b632d99f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -243,7 +243,7 @@ aiogithubapi==24.6.0 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.4.0 +aioharmony==0.4.1 # homeassistant.components.hassio aiohasupervisor==0.2.2b5 From 51bc56929b11b1eee711e8faad58d7a84184b81c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jan 2025 15:45:34 +0100 Subject: [PATCH 0941/2987] Use runtime_data in dunehd (#136443) --- homeassistant/components/dunehd/__init__.py | 22 ++++++------------- .../components/dunehd/media_player.py | 14 ++++++------ 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/dunehd/__init__.py b/homeassistant/components/dunehd/__init__.py index 27e9e749472..302a7280128 100644 --- a/homeassistant/components/dunehd/__init__.py +++ b/homeassistant/components/dunehd/__init__.py @@ -10,29 +10,21 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN - PLATFORMS: Final[list[Platform]] = [Platform.MEDIA_PLAYER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type DuneHDConfigEntry = ConfigEntry[DuneHDPlayer] + + +async def async_setup_entry(hass: HomeAssistant, entry: DuneHDConfigEntry) -> bool: """Set up a config entry.""" - host: str = entry.data[CONF_HOST] - - player = DuneHDPlayer(host) - - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = player + entry.runtime_data = DuneHDPlayer(entry.data[CONF_HOST]) 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: DuneHDConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/dunehd/media_player.py b/homeassistant/components/dunehd/media_player.py index ded23ea4669..db903cac2bf 100644 --- a/homeassistant/components/dunehd/media_player.py +++ b/homeassistant/components/dunehd/media_player.py @@ -15,11 +15,11 @@ from homeassistant.components.media_player import ( MediaType, async_process_play_media_url, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DuneHDConfigEntry from .const import ATTR_MANUFACTURER, DEFAULT_NAME, DOMAIN CONF_SOURCES: Final = "sources" @@ -37,14 +37,14 @@ DUNEHD_PLAYER_SUPPORT: Final[MediaPlayerEntityFeature] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DuneHDConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Add Dune HD entities from a config_entry.""" - unique_id = entry.entry_id - - player: DuneHDPlayer = hass.data[DOMAIN][entry.entry_id] - - async_add_entities([DuneHDPlayerEntity(player, DEFAULT_NAME, unique_id)], True) + async_add_entities( + [DuneHDPlayerEntity(entry.runtime_data, DEFAULT_NAME, entry.entry_id)], True + ) class DuneHDPlayerEntity(MediaPlayerEntity): From fc9ad40ac8376ddd37322d834d38bc5e17698541 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Fri, 24 Jan 2025 15:45:53 +0100 Subject: [PATCH 0942/2987] Reorganize input sources in Onkyo options (#133511) --- homeassistant/components/onkyo/config_flow.py | 209 +++++++++++------- homeassistant/components/onkyo/strings.json | 23 +- tests/components/onkyo/test_config_flow.py | 110 +++++---- 3 files changed, 215 insertions(+), 127 deletions(-) diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 974b4082cae..228748d5257 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -15,6 +15,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback +from homeassistant.data_entry_flow import section from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, @@ -49,9 +50,13 @@ INPUT_SOURCES_ALL_MEANINGS = [ input_source.value_meaning for input_source in InputSource ] STEP_MANUAL_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) -STEP_CONFIGURE_SCHEMA = vol.Schema( +STEP_RECONFIGURE_SCHEMA = vol.Schema( { vol.Required(OPTION_VOLUME_RESOLUTION): vol.In(VOLUME_RESOLUTION_ALLOWED), + } +) +STEP_CONFIGURE_SCHEMA = STEP_RECONFIGURE_SCHEMA.extend( + { vol.Required(OPTION_INPUT_SOURCES): SelectSelector( SelectSelectorConfig( options=INPUT_SOURCES_ALL_MEANINGS, @@ -216,55 +221,52 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the configuration of a single receiver.""" errors = {} - entry = None - entry_options = None + reconfigure_entry = None + schema = STEP_CONFIGURE_SCHEMA if self.source == SOURCE_RECONFIGURE: - entry = self._get_reconfigure_entry() - entry_options = entry.options + schema = STEP_RECONFIGURE_SCHEMA + reconfigure_entry = self._get_reconfigure_entry() if user_input is not None: - source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES] - if not source_meanings: + volume_resolution = user_input[OPTION_VOLUME_RESOLUTION] + + if reconfigure_entry is not None: + entry_options = reconfigure_entry.options + result = self.async_update_reload_and_abort( + reconfigure_entry, + data={ + CONF_HOST: self._receiver_info.host, + }, + options={ + OPTION_VOLUME_RESOLUTION: volume_resolution, + OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], + OPTION_INPUT_SOURCES: entry_options[OPTION_INPUT_SOURCES], + }, + ) + + _LOGGER.debug("Reconfigured receiver, result: %s", result) + return result + + input_source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES] + if not input_source_meanings: errors[OPTION_INPUT_SOURCES] = "empty_input_source_list" else: - sources_store: dict[str, str] = {} - for source_meaning in source_meanings: - source = InputSource.from_meaning(source_meaning) + input_sources_store: dict[str, str] = {} + for input_source_meaning in input_source_meanings: + input_source = InputSource.from_meaning(input_source_meaning) + input_sources_store[input_source.value] = input_source_meaning - source_name = source_meaning - if entry_options is not None: - source_name = entry_options[OPTION_INPUT_SOURCES].get( - source.value, source_name - ) - sources_store[source.value] = source_name - - volume_resolution = user_input[OPTION_VOLUME_RESOLUTION] - - if entry_options is None: - result = self.async_create_entry( - title=self._receiver_info.model_name, - data={ - CONF_HOST: self._receiver_info.host, - }, - options={ - OPTION_VOLUME_RESOLUTION: volume_resolution, - OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT, - OPTION_INPUT_SOURCES: sources_store, - }, - ) - else: - assert entry is not None - result = self.async_update_reload_and_abort( - entry, - data={ - CONF_HOST: self._receiver_info.host, - }, - options={ - OPTION_VOLUME_RESOLUTION: volume_resolution, - OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], - OPTION_INPUT_SOURCES: sources_store, - }, - ) + result = self.async_create_entry( + title=self._receiver_info.model_name, + data={ + CONF_HOST: self._receiver_info.host, + }, + options={ + OPTION_VOLUME_RESOLUTION: volume_resolution, + OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT, + OPTION_INPUT_SOURCES: input_sources_store, + }, + ) _LOGGER.debug("Configured receiver, result: %s", result) return result @@ -273,12 +275,13 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): suggested_values = user_input if suggested_values is None: - if entry_options is None: + if reconfigure_entry is None: suggested_values = { OPTION_VOLUME_RESOLUTION: OPTION_VOLUME_RESOLUTION_DEFAULT, OPTION_INPUT_SOURCES: [], } else: + entry_options = reconfigure_entry.options suggested_values = { OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION], OPTION_INPUT_SOURCES: [ @@ -289,9 +292,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="configure_receiver", - data_schema=self.add_suggested_values_to_schema( - STEP_CONFIGURE_SCHEMA, suggested_values - ), + data_schema=self.add_suggested_values_to_schema(schema, suggested_values), errors=errors, description_placeholders={ "name": f"{self._receiver_info.model_name} ({self._receiver_info.host})" @@ -360,57 +361,107 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlow: + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: """Return the options flow.""" - return OnkyoOptionsFlowHandler(config_entry) + return OnkyoOptionsFlowHandler() + + +OPTIONS_STEP_INIT_SCHEMA = vol.Schema( + { + vol.Required(OPTION_MAX_VOLUME): NumberSelector( + NumberSelectorConfig(min=1, max=100, mode=NumberSelectorMode.BOX) + ), + vol.Required(OPTION_INPUT_SOURCES): SelectSelector( + SelectSelectorConfig( + options=INPUT_SOURCES_ALL_MEANINGS, + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } +) class OnkyoOptionsFlowHandler(OptionsFlow): """Handle an options flow for Onkyo.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - sources_store: dict[str, str] = config_entry.options[OPTION_INPUT_SOURCES] - self._input_sources = {InputSource(k): v for k, v in sources_store.items()} + _data: dict[str, Any] + _input_sources: dict[InputSource, str] async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" + errors = {} + + entry_options = self.config_entry.options + if user_input is not None: - sources_store: dict[str, str] = {} - for source_meaning, source_name in user_input.items(): - if source_meaning in INPUT_SOURCES_ALL_MEANINGS: - source = InputSource.from_meaning(source_meaning) - sources_store[source.value] = source_name + self._input_sources = {} + for input_source_meaning in user_input[OPTION_INPUT_SOURCES]: + input_source = InputSource.from_meaning(input_source_meaning) + input_source_name = entry_options[OPTION_INPUT_SOURCES].get( + input_source.value, input_source_meaning + ) + self._input_sources[input_source] = input_source_name + + if not self._input_sources: + errors[OPTION_INPUT_SOURCES] = "empty_input_source_list" + else: + self._data = { + OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION], + OPTION_MAX_VOLUME: user_input[OPTION_MAX_VOLUME], + } + + return await self.async_step_names() + + suggested_values = user_input + if suggested_values is None: + suggested_values = { + OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], + OPTION_INPUT_SOURCES: [ + InputSource(input_source).value_meaning + for input_source in entry_options[OPTION_INPUT_SOURCES] + ], + } + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + OPTIONS_STEP_INIT_SCHEMA, suggested_values + ), + errors=errors, + ) + + async def async_step_names( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Configure names.""" + if user_input is not None: + input_sources_store: dict[str, str] = {} + for input_source_meaning, input_source_name in user_input[ + "input_sources" + ].items(): + input_source = InputSource.from_meaning(input_source_meaning) + input_sources_store[input_source.value] = input_source_name return self.async_create_entry( data={ - OPTION_VOLUME_RESOLUTION: self.config_entry.options[ - OPTION_VOLUME_RESOLUTION - ], - OPTION_MAX_VOLUME: user_input[OPTION_MAX_VOLUME], - OPTION_INPUT_SOURCES: sources_store, + **self._data, + OPTION_INPUT_SOURCES: input_sources_store, } ) schema_dict: dict[Any, Selector] = {} - max_volume: float = self.config_entry.options[OPTION_MAX_VOLUME] - schema_dict[vol.Required(OPTION_MAX_VOLUME, default=max_volume)] = ( - NumberSelector( - NumberSelectorConfig(min=1, max=100, mode=NumberSelectorMode.BOX) - ) - ) - - for source, source_name in self._input_sources.items(): - schema_dict[vol.Required(source.value_meaning, default=source_name)] = ( - TextSelector() - ) + for input_source, input_source_name in self._input_sources.items(): + schema_dict[ + vol.Required(input_source.value_meaning, default=input_source_name) + ] = TextSelector() return self.async_show_form( - step_id="init", - data_schema=vol.Schema(schema_dict), + step_id="names", + data_schema=vol.Schema( + {vol.Required("input_sources"): section(vol.Schema(schema_dict))} + ), ) diff --git a/homeassistant/components/onkyo/strings.json b/homeassistant/components/onkyo/strings.json index 849171c7161..b3b14efec44 100644 --- a/homeassistant/components/onkyo/strings.json +++ b/homeassistant/components/onkyo/strings.json @@ -27,17 +27,17 @@ "description": "Configure {name}", "data": { "volume_resolution": "Volume resolution", - "input_sources": "Input sources" + "input_sources": "[%key:component::onkyo::options::step::init::data::input_sources%]" }, "data_description": { "volume_resolution": "Number of steps it takes for the receiver to go from the lowest to the highest possible volume.", - "input_sources": "List of input sources supported by the receiver." + "input_sources": "[%key:component::onkyo::options::step::init::data_description::input_sources%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "empty_input_source_list": "Input source list cannot be empty", + "empty_input_source_list": "[%key:component::onkyo::options::error::empty_input_source_list%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { @@ -52,12 +52,25 @@ "step": { "init": { "data": { - "max_volume": "Maximum volume limit (%)" + "max_volume": "Maximum volume limit (%)", + "input_sources": "Input sources" }, "data_description": { - "max_volume": "Maximum volume limit as a percentage. This will associate Home Assistant's maximum volume to this value on the receiver, i.e., if you set this to 50%, then setting the volume to 100% in Home Assistant will cause the volume on the receiver to be set to 50% of its maximum value." + "max_volume": "Maximum volume limit as a percentage. This will associate Home Assistant's maximum volume to this value on the receiver, i.e., if you set this to 50%, then setting the volume to 100% in Home Assistant will cause the volume on the receiver to be set to 50% of its maximum value.", + "input_sources": "List of input sources supported by the receiver." + } + }, + "names": { + "sections": { + "input_sources": { + "name": "Input source names", + "description": "Mappings of receiver's input sources to their names." + } } } + }, + "error": { + "empty_input_source_list": "Input source list cannot be empty" } }, "issues": { diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index 865bc1a6bbf..203cc22cf95 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -10,6 +10,7 @@ from homeassistant.components.onkyo import InputSource from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow from homeassistant.components.onkyo.const import ( DOMAIN, + OPTION_INPUT_SOURCES, OPTION_MAX_VOLUME, OPTION_VOLUME_RESOLUTION, ) @@ -87,35 +88,6 @@ async def test_manual_invalid_host(hass: HomeAssistant, stub_mock_discovery) -> assert host_result["errors"]["base"] == "cannot_connect" -async def test_ssdp_discovery_already_configured( - hass: HomeAssistant, default_mock_discovery -) -> None: - """Test SSDP discovery with already configured device.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "192.168.1.100"}, - unique_id="id1", - ) - config_entry.add_to_hass(hass) - - discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.1.100:8080", - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", - ssdp_st="mock_st", - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - async def test_manual_valid_host_unexpected_error( hass: HomeAssistant, empty_mock_discovery ) -> None: @@ -262,6 +234,35 @@ async def test_ssdp_discovery_success( assert select_result["result"].unique_id == "id1" +async def test_ssdp_discovery_already_configured( + hass: HomeAssistant, default_mock_discovery +) -> None: + """Test SSDP discovery with already configured device.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100"}, + unique_id="id1", + ) + config_entry.add_to_hass(hass) + + discovery_info = SsdpServiceInfo( + ssdp_location="http://192.168.1.100:8080", + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + ssdp_usn="uuid:mock_usn", + ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", + ssdp_st="mock_st", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None: """Test SSDP discovery with host info error.""" discovery_info = SsdpServiceInfo( @@ -466,7 +467,7 @@ async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: await setup_integration(hass, config_entry, receiver_info) old_host = config_entry.data[CONF_HOST] - old_max_volume = config_entry.options[OPTION_MAX_VOLUME] + old_options = config_entry.options result = await config_entry.start_reconfigure_flow(hass) @@ -483,7 +484,7 @@ async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - user_input={"volume_resolution": 200, "input_sources": ["TUNER"]}, + user_input={OPTION_VOLUME_RESOLUTION: 200}, ) assert result3["type"] is FlowResultType.ABORT @@ -491,7 +492,10 @@ async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: assert config_entry.data[CONF_HOST] == old_host assert config_entry.options[OPTION_VOLUME_RESOLUTION] == 200 - assert config_entry.options[OPTION_MAX_VOLUME] == old_max_volume + for option, option_value in old_options.items(): + if option == OPTION_VOLUME_RESOLUTION: + continue + assert config_entry.options[option] == option_value async def test_reconfigure_new_device(hass: HomeAssistant) -> None: @@ -610,8 +614,8 @@ async def test_import_success( "ignore_translations", [ [ # The schema is dynamically created from input sources - "component.onkyo.options.step.init.data.TV", - "component.onkyo.options.step.init.data_description.TV", + "component.onkyo.options.step.names.sections.input_sources.data.TV", + "component.onkyo.options.step.names.sections.input_sources.data_description.TV", ] ], ) @@ -622,23 +626,43 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) config_entry = create_empty_config_entry() await setup_integration(hass, config_entry, receiver_info) + old_volume_resolution = config_entry.options[OPTION_VOLUME_RESOLUTION] + result = await hass.config_entries.options.async_init(config_entry.entry_id) - await hass.async_block_till_done() result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "max_volume": 42, - "TV": "television", + OPTION_MAX_VOLUME: 42, + OPTION_INPUT_SOURCES: [], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {OPTION_INPUT_SOURCES: "empty_input_source_list"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + OPTION_MAX_VOLUME: 42, + OPTION_INPUT_SOURCES: ["TV"], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "names" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + OPTION_INPUT_SOURCES: {"TV": "television"}, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "volume_resolution": 80, - "max_volume": 42.0, - "input_sources": { - "12": "television", - }, + OPTION_VOLUME_RESOLUTION: old_volume_resolution, + OPTION_MAX_VOLUME: 42.0, + OPTION_INPUT_SOURCES: {"12": "television"}, } From 728d381eb384f12a637f4e2c6589c4dabbfd2625 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jan 2025 15:55:53 +0100 Subject: [PATCH 0943/2987] Move dynalite service definitions to separate module (#136446) --- homeassistant/components/dynalite/__init__.py | 60 +------------- homeassistant/components/dynalite/services.py | 79 +++++++++++++++++++ 2 files changed, 83 insertions(+), 56 deletions(-) create mode 100644 homeassistant/components/dynalite/services.py diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index 7388c43cb89..a1a6a38c8ab 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -2,27 +2,17 @@ from __future__ import annotations -import voluptuous as vol - from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .bridge import DynaliteBridge -from .const import ( - ATTR_AREA, - ATTR_CHANNEL, - ATTR_HOST, - DOMAIN, - LOGGER, - PLATFORMS, - SERVICE_REQUEST_AREA_PRESET, - SERVICE_REQUEST_CHANNEL_LEVEL, -) +from .const import DOMAIN, LOGGER, PLATFORMS from .convert_config import convert_config from .panel import async_register_dynalite_frontend +from .services import setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -31,49 +21,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Dynalite platform.""" hass.data[DOMAIN] = {} - async def dynalite_service(service_call: ServiceCall) -> None: - data = service_call.data - host = data.get(ATTR_HOST, "") - bridges = [ - bridge - for bridge in hass.data[DOMAIN].values() - if not host or bridge.host == host - ] - LOGGER.debug("Selected bridged for service call: %s", bridges) - if service_call.service == SERVICE_REQUEST_AREA_PRESET: - bridge_attr = "request_area_preset" - elif service_call.service == SERVICE_REQUEST_CHANNEL_LEVEL: - bridge_attr = "request_channel_level" - for bridge in bridges: - getattr(bridge.dynalite_devices, bridge_attr)( - data[ATTR_AREA], data.get(ATTR_CHANNEL) - ) - - hass.services.async_register( - DOMAIN, - SERVICE_REQUEST_AREA_PRESET, - dynalite_service, - vol.Schema( - { - vol.Optional(ATTR_HOST): cv.string, - vol.Required(ATTR_AREA): int, - vol.Optional(ATTR_CHANNEL): int, - } - ), - ) - - hass.services.async_register( - DOMAIN, - SERVICE_REQUEST_CHANNEL_LEVEL, - dynalite_service, - vol.Schema( - { - vol.Optional(ATTR_HOST): cv.string, - vol.Required(ATTR_AREA): int, - vol.Required(ATTR_CHANNEL): int, - } - ), - ) + setup_services(hass) await async_register_dynalite_frontend(hass) diff --git a/homeassistant/components/dynalite/services.py b/homeassistant/components/dynalite/services.py new file mode 100644 index 00000000000..14160cced9d --- /dev/null +++ b/homeassistant/components/dynalite/services.py @@ -0,0 +1,79 @@ +"""Support for the Dynalite networks.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv + +from .bridge import DynaliteBridge +from .const import ( + ATTR_AREA, + ATTR_CHANNEL, + ATTR_HOST, + DOMAIN, + LOGGER, + SERVICE_REQUEST_AREA_PRESET, + SERVICE_REQUEST_CHANNEL_LEVEL, +) + + +@callback +def _get_bridges(service_call: ServiceCall) -> list[DynaliteBridge]: + host = service_call.data.get(ATTR_HOST, "") + bridges = [ + bridge + for bridge in service_call.hass.data[DOMAIN].values() + if not host or bridge.host == host + ] + LOGGER.debug("Selected bridges for service call: %s", bridges) + return bridges + + +async def _request_area_preset(service_call: ServiceCall) -> None: + bridges = _get_bridges(service_call) + data = service_call.data + for bridge in bridges: + bridge.dynalite_devices.request_area_preset( + data[ATTR_AREA], data.get(ATTR_CHANNEL) + ) + + +async def _request_channel_level(service_call: ServiceCall) -> None: + bridges = _get_bridges(service_call) + data = service_call.data + for bridge in bridges: + bridge.dynalite_devices.request_channel_level( + data[ATTR_AREA], data[ATTR_CHANNEL] + ) + + +@callback +def setup_services(hass: HomeAssistant) -> None: + """Set up the Dynalite platform.""" + hass.services.async_register( + DOMAIN, + SERVICE_REQUEST_AREA_PRESET, + _request_area_preset, + vol.Schema( + { + vol.Optional(ATTR_HOST): cv.string, + vol.Required(ATTR_AREA): int, + vol.Optional(ATTR_CHANNEL): int, + } + ), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_REQUEST_CHANNEL_LEVEL, + _request_channel_level, + vol.Schema( + { + vol.Optional(ATTR_HOST): cv.string, + vol.Required(ATTR_AREA): int, + vol.Required(ATTR_CHANNEL): int, + } + ), + ) From a56c37a508d0ddd59dc2d78b6670be28f7f2517b Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Fri, 24 Jan 2025 15:02:14 +0000 Subject: [PATCH 0944/2987] Add more sensors to homee (#136445) --- .strict-typing | 1 + homeassistant/components/homee/const.py | 2 + .../components/homee/quality_scale.yaml | 12 +++--- homeassistant/components/homee/sensor.py | 40 ++++++++++++++++++- homeassistant/components/homee/strings.json | 33 +++++++++++++++ mypy.ini | 10 +++++ 6 files changed, 90 insertions(+), 8 deletions(-) diff --git a/.strict-typing b/.strict-typing index 7034ea1f0c1..1c0456a745d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -237,6 +237,7 @@ homeassistant.components.homeassistant_green.* homeassistant.components.homeassistant_hardware.* homeassistant.components.homeassistant_sky_connect.* homeassistant.components.homeassistant_yellow.* +homeassistant.components.homee.* homeassistant.components.homekit.* homeassistant.components.homekit_controller homeassistant.components.homekit_controller.alarm_control_panel diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index 8595f042af8..d1d5be97ef7 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -3,6 +3,7 @@ from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, + REVOLUTIONS_PER_MINUTE, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, @@ -24,6 +25,7 @@ HOMEE_UNIT_TO_HA_UNIT = { "%": PERCENTAGE, "lx": LIGHT_LUX, "klx": LIGHT_LUX, + "1/min": REVOLUTIONS_PER_MINUTE, "A": UnitOfElectricCurrent.AMPERE, "V": UnitOfElectricPotential.VOLT, "kWh": UnitOfEnergy.KILO_WATT_HOUR, diff --git a/homeassistant/components/homee/quality_scale.yaml b/homeassistant/components/homee/quality_scale.yaml index 96d4678b420..ff99d177018 100644 --- a/homeassistant/components/homee/quality_scale.yaml +++ b/homeassistant/components/homee/quality_scale.yaml @@ -52,12 +52,12 @@ rules: docs-troubleshooting: todo docs-use-cases: todo dynamic-devices: todo - entity-category: todo - entity-device-class: todo - entity-disabled-by-default: todo - entity-translations: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done exception-translations: todo - icon-translations: todo + icon-translations: done reconfiguration-flow: todo repair-issues: todo stale-devices: todo @@ -65,4 +65,4 @@ rules: # Platinum async-dependency: todo inject-websession: todo - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py index e9ef298ab4f..9b8fb0f6fe1 100644 --- a/homeassistant/components/homee/sensor.py +++ b/homeassistant/components/homee/sensor.py @@ -99,9 +99,29 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = { device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + AttributeType.EXHAUST_MOTOR_REVS: HomeeSensorEntityDescription( + key="exhaust_motor_revs", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.INDOOR_RELATIVE_HUMIDITY: HomeeSensorEntityDescription( + key="indoor_humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.INDOOR_TEMPERATURE: HomeeSensorEntityDescription( + key="indoor_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.INTAKE_MOTOR_REVS: HomeeSensorEntityDescription( + key="intake_motor_revs", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), AttributeType.LEVEL: HomeeSensorEntityDescription( key="level", - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.VOLUME_STORAGE, state_class=SensorStateClass.MEASUREMENT, ), AttributeType.LINK_QUALITY: HomeeSensorEntityDescription( @@ -109,6 +129,22 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = { entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), + AttributeType.OPERATING_HOURS: HomeeSensorEntityDescription( + key="operating_hours", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.OUTDOOR_RELATIVE_HUMIDITY: HomeeSensorEntityDescription( + key="outdoor_humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.OUTDOOR_TEMPERATURE: HomeeSensorEntityDescription( + key="outdoor_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), AttributeType.POSITION: HomeeSensorEntityDescription( key="position", state_class=SensorStateClass.MEASUREMENT, @@ -216,6 +252,7 @@ NODE_SENSOR_DESCRIPTIONS: tuple[HomeeNodeSensorEntityDescription, ...] = ( ], entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, + translation_key="node_sensor_state", value_fn=lambda node: get_name_for_enum(NodeState, node.state), ), ) @@ -293,7 +330,6 @@ class HomeeNodeSensor(HomeeNodeEntity, SensorEntity): """Initialize a homee node sensor entity.""" super().__init__(node, entry) self.entity_description = description - self._attr_translation_key = f"node_{description.key}" self._node = node self._attr_unique_id = f"{self._attr_unique_id}-{description.key}" diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index a657465126b..401996622f2 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -42,6 +42,24 @@ "energy_instance": { "name": "Energy {instance}" }, + "exhaust_motor_revs": { + "name": "Exhaust motor speed" + }, + "indoor_humidity": { + "name": "Indoor humidity" + }, + "indoor_humidity_instance": { + "name": "Indoor humidity {instance}" + }, + "indoor_temperature": { + "name": "Indoor temperature" + }, + "indoor_temperature_instance": { + "name": "Indoor temperature {instance}" + }, + "intake_motor_revs": { + "name": "Intake motor speed" + }, "level": { "name": "Level" }, @@ -51,6 +69,21 @@ "node_state": { "name": "Node state" }, + "operating_hours": { + "name": "Operating hours" + }, + "outdoor_humidity": { + "name": "Outdoor humidity" + }, + "outdoor_humidity_instance": { + "name": "Outdoor humidity {instance}" + }, + "outdoor_temperature": { + "name": "Outdoor temperature" + }, + "outdoor_temperature_instance": { + "name": "Outdoor temperature {instance}" + }, "position": { "name": "Position" }, diff --git a/mypy.ini b/mypy.ini index d0579ab8f41..7f7b66e238f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2126,6 +2126,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.homee.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.homekit.*] check_untyped_defs = true disallow_incomplete_defs = true From b0188772bc3f39bbf69b0bf774e3a820f1d532b4 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 24 Jan 2025 17:01:44 +0100 Subject: [PATCH 0945/2987] Bump aioacaia to 0.1.14 (#136453) --- homeassistant/components/acaia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/acaia/manifest.json b/homeassistant/components/acaia/manifest.json index 681f3f08555..f39511ad41a 100644 --- a/homeassistant/components/acaia/manifest.json +++ b/homeassistant/components/acaia/manifest.json @@ -26,5 +26,5 @@ "iot_class": "local_push", "loggers": ["aioacaia"], "quality_scale": "platinum", - "requirements": ["aioacaia==0.1.13"] + "requirements": ["aioacaia==0.1.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9a1e61c6d93..890a53428ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -173,7 +173,7 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.13 +aioacaia==0.1.14 # homeassistant.components.airq aioairq==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 975b632d99f..40b44051f5a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -161,7 +161,7 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.13 +aioacaia==0.1.14 # homeassistant.components.airq aioairq==0.4.3 From 1697e2406809a7ccfb3c2fec61775930078b5630 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 24 Jan 2025 19:48:55 +0000 Subject: [PATCH 0946/2987] Add PARALLEL_UPDATES constant to ring integration platforms (#136470) --- homeassistant/components/ring/binary_sensor.py | 3 +++ homeassistant/components/ring/button.py | 4 ++++ homeassistant/components/ring/camera.py | 4 ++++ homeassistant/components/ring/event.py | 3 +++ homeassistant/components/ring/light.py | 3 +++ homeassistant/components/ring/number.py | 4 ++++ homeassistant/components/ring/sensor.py | 3 +++ homeassistant/components/ring/siren.py | 4 ++++ homeassistant/components/ring/switch.py | 4 ++++ 9 files changed, 32 insertions(+) diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 85a916e95cd..2c458985498 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -30,6 +30,9 @@ from .entity import ( async_check_create_deprecated, ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RingBinarySensorEntityDescription( diff --git a/homeassistant/components/ring/button.py b/homeassistant/components/ring/button.py index b9d5cceb373..30600237847 100644 --- a/homeassistant/components/ring/button.py +++ b/homeassistant/components/ring/button.py @@ -12,6 +12,10 @@ from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap +# Coordinator is used to centralize the data updates +# Actions restricted to 1 at a time +PARALLEL_UPDATES = 1 + BUTTON_DESCRIPTION = ButtonEntityDescription( key="open_door", translation_key="open_door" ) diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index ccd91c163d6..c1a4e67ffd4 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -34,6 +34,10 @@ from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingDeviceT, RingEntity, exception_wrap +# Coordinator is used to centralize the data updates +# Actions restricted to 1 at a time +PARALLEL_UPDATES = 1 + FORCE_REFRESH_INTERVAL = timedelta(minutes=3) MOTION_DETECTION_CAPABILITY = "motion_detection" diff --git a/homeassistant/components/ring/event.py b/homeassistant/components/ring/event.py index 71a4bc8aea5..4d7a6277579 100644 --- a/homeassistant/components/ring/event.py +++ b/homeassistant/components/ring/event.py @@ -18,6 +18,9 @@ from . import RingConfigEntry from .coordinator import RingListenCoordinator from .entity import RingBaseEntity, RingDeviceT +# Event entity does not perform updates or actions. +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RingEventEntityDescription(EventEntityDescription, Generic[RingDeviceT]): diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 9e29373a3aa..9ae0bac1004 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -18,6 +18,9 @@ from .entity import RingEntity, exception_wrap _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +# Actions restricted to 1 at a time +PARALLEL_UPDATES = 1 # It takes a few seconds for the API to correctly return an update indicating # that the changes have been made. Once we request a change (i.e. a light diff --git a/homeassistant/components/ring/number.py b/homeassistant/components/ring/number.py index 91aabb6c800..b920ff7edc7 100644 --- a/homeassistant/components/ring/number.py +++ b/homeassistant/components/ring/number.py @@ -20,6 +20,10 @@ from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingDeviceT, RingEntity, refresh_after +# Coordinator is used to centralize the data updates +# Actions restricted to 1 at a time +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index dee67882857..cf851a113bc 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -41,6 +41,9 @@ from .entity import ( async_check_create_deprecated, ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index b1452f7aeb5..05fa07c39eb 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -36,6 +36,10 @@ from .entity import ( _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +# Actions restricted to 1 at a time +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class RingSirenEntityDescription( diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 0ac31fec209..e81d483adf3 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -27,6 +27,10 @@ from .entity import ( _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +# Actions restricted to 1 at a time +PARALLEL_UPDATES = 1 + IN_HOME_CHIME_IS_PRESENT = {v for k, v in DOORBELL_EXISTING_TYPE.items() if k != 2} From c25ffd3e661cd1534904d0977ca018710956d9f7 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Fri, 24 Jan 2025 20:57:19 +0100 Subject: [PATCH 0947/2987] Bump uiprotect to version 7.5.0 (#136475) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 018a600f037..69c7f8b205b 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.4.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.5.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 890a53428ec..09e6a9e810c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2934,7 +2934,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.4.1 +uiprotect==7.5.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 40b44051f5a..8a585477127 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2359,7 +2359,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.4.1 +uiprotect==7.5.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 7363413d3d17b238ab3c3eddca336c1da4a2a901 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 24 Jan 2025 21:00:46 +0100 Subject: [PATCH 0948/2987] Fix sentence-casing in strings of Vizio integration (#136465) --- homeassistant/components/vizio/strings.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 6091cd72f3f..2f97bb332e8 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -6,7 +6,7 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", - "device_class": "Device Type", + "device_class": "Device type", "access_token": "[%key:common::config_flow::data::access_token%]" }, "data_description": { @@ -14,25 +14,25 @@ } }, "pair_tv": { - "title": "Complete Pairing Process", + "title": "Complete pairing process", "description": "Your TV should be displaying a code. Enter that code into the form and then continue to the next step to complete the pairing.", "data": { "pin": "[%key:common::config_flow::data::pin%]" } }, "pairing_complete": { - "title": "Pairing Complete", - "description": "Your VIZIO SmartCast Device is now connected to Home Assistant." + "title": "Pairing complete", + "description": "Your VIZIO SmartCast device is now connected to Home Assistant." }, "pairing_complete_import": { "title": "[%key:component::vizio::config::step::pairing_complete::title%]", - "description": "Your VIZIO SmartCast Device is now connected to Home Assistant.\n\nYour access token is '**{access_token}**'." + "description": "Your VIZIO SmartCast device is now connected to Home Assistant.\n\nYour access token is '**{access_token}**'." } }, "error": { "complete_pairing_failed": "Unable to complete pairing. Ensure the PIN you provided is correct and the TV is still powered and connected to the network before resubmitting.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "existing_config_entry_found": "An existing VIZIO SmartCast Device config entry with the same serial number has already been configured. You must delete the existing entry in order to configure this one." + "existing_config_entry_found": "An existing VIZIO SmartCast device config entry with the same serial number has already been configured. You must delete the existing entry in order to configure this one." }, "abort": { "already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]", @@ -43,12 +43,12 @@ "options": { "step": { "init": { - "title": "Update VIZIO SmartCast Device Options", + "title": "Update VIZIO SmartCast device options", "description": "If you have a Smart TV, you can optionally filter your source list by choosing which apps to include or exclude in your source list.", "data": { - "volume_step": "Volume Step Size", - "include_or_exclude": "Include or Exclude Apps?", - "apps_to_include_or_exclude": "Apps to Include or Exclude" + "volume_step": "Volume step size", + "include_or_exclude": "Include or exclude apps?", + "apps_to_include_or_exclude": "Apps to include or exclude" } } } From f5fc46a7beeaf26e7c3c4eb0225787c749c04989 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 24 Jan 2025 22:03:46 +0100 Subject: [PATCH 0949/2987] Make Spotify polling interval dynamic (#136461) --- .../components/spotify/coordinator.py | 13 +- tests/components/spotify/test_media_player.py | 143 ++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py index 099b1cb3ca8..a86544d883e 100644 --- a/homeassistant/components/spotify/coordinator.py +++ b/homeassistant/components/spotify/coordinator.py @@ -31,6 +31,9 @@ _LOGGER = logging.getLogger(__name__) type SpotifyConfigEntry = ConfigEntry[SpotifyData] +UPDATE_INTERVAL = timedelta(seconds=30) + + @dataclass class SpotifyCoordinatorData: """Class to hold Spotify data.""" @@ -59,7 +62,7 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): hass, _LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=30), + update_interval=UPDATE_INTERVAL, ) self.client = client self._playlist: Playlist | None = None @@ -73,6 +76,7 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): raise UpdateFailed("Error communicating with Spotify API") from err async def _async_update_data(self) -> SpotifyCoordinatorData: + self.update_interval = UPDATE_INTERVAL try: current = await self.client.get_playback() except SpotifyConnectionError as err: @@ -120,6 +124,13 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): ) self._playlist = None self._checked_playlist_id = None + if current.is_playing and current.progress_ms is not None: + assert current.item is not None + time_left = timedelta( + milliseconds=current.item.duration_ms - current.progress_ms + ) + if time_left < UPDATE_INTERVAL: + self.update_interval = time_left + timedelta(seconds=1) return SpotifyCoordinatorData( current_playback=current, position_updated_at=position_updated_at, diff --git a/tests/components/spotify/test_media_player.py b/tests/components/spotify/test_media_player.py index 55e0ea8f1d8..a6f686475c7 100644 --- a/tests/components/spotify/test_media_player.py +++ b/tests/components/spotify/test_media_player.py @@ -641,3 +641,146 @@ async def test_no_album_images( state = hass.states.get("media_player.spotify_spotify_1") assert state assert ATTR_ENTITY_PICTURE not in state.attributes + + +@pytest.mark.usefixtures("setup_credentials") +async def test_normal_polling_interval( + hass: HomeAssistant, + mock_spotify: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Spotify media player polling interval.""" + await setup_integration(hass, mock_config_entry) + + assert mock_spotify.return_value.get_playback.return_value.is_playing is True + assert ( + mock_spotify.return_value.get_playback.return_value.progress_ms + - mock_spotify.return_value.get_playback.return_value.item.duration_ms + < 30000 + ) + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_called_once() + + +@pytest.mark.usefixtures("setup_credentials") +async def test_smart_polling_interval( + hass: HomeAssistant, + mock_spotify: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Spotify media player polling interval.""" + mock_spotify.return_value.get_playback.return_value.progress_ms = 10000 + mock_spotify.return_value.get_playback.return_value.item.duration_ms = 30000 + + await setup_integration(hass, mock_config_entry) + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_not_called() + + mock_spotify.return_value.get_playback.return_value.progress_ms = 10000 + mock_spotify.return_value.get_playback.return_value.item.duration_ms = 50000 + + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + freezer.tick(timedelta(seconds=21)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_not_called() + + freezer.tick(timedelta(seconds=9)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + +@pytest.mark.usefixtures("setup_credentials") +async def test_smart_polling_interval_handles_errors( + hass: HomeAssistant, + mock_spotify: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Spotify media player polling interval.""" + mock_spotify.return_value.get_playback.return_value.progress_ms = 10000 + mock_spotify.return_value.get_playback.return_value.item.duration_ms = 30000 + + await setup_integration(hass, mock_config_entry) + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + mock_spotify.return_value.get_playback.side_effect = SpotifyConnectionError + + freezer.tick(timedelta(seconds=21)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + freezer.tick(timedelta(seconds=21)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_not_called() + + freezer.tick(timedelta(seconds=9)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + +@pytest.mark.usefixtures("setup_credentials") +async def test_smart_polling_interval_handles_paused( + hass: HomeAssistant, + mock_spotify: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Spotify media player polling interval.""" + mock_spotify.return_value.get_playback.return_value.progress_ms = 10000 + mock_spotify.return_value.get_playback.return_value.item.duration_ms = 30000 + mock_spotify.return_value.get_playback.return_value.is_playing = False + + await setup_integration(hass, mock_config_entry) + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + freezer.tick(timedelta(seconds=21)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_not_called() + + freezer.tick(timedelta(seconds=9)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() From 9993a68a5571fb0c16c7292d25f59ffce9a22267 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 24 Jan 2025 13:52:24 -0800 Subject: [PATCH 0950/2987] Powerwall: Reuse authentication cookie (#136147) Co-authored-by: J. Nick Koston --- .../components/powerwall/__init__.py | 107 ++++++-- .../components/powerwall/config_flow.py | 6 +- homeassistant/components/powerwall/const.py | 3 + tests/components/powerwall/test_init.py | 241 +++++++++++++++++- 4 files changed, 326 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 6a2522ac43b..d84452c0443 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -14,6 +14,7 @@ from tesla_powerwall import ( Powerwall, PowerwallUnreachableError, ) +from yarl import URL from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry @@ -25,7 +26,14 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.network import is_ip_address -from .const import DOMAIN, POWERWALL_API_CHANGED, POWERWALL_COORDINATOR, UPDATE_INTERVAL +from .const import ( + AUTH_COOKIE_KEY, + CONFIG_ENTRY_COOKIE, + DOMAIN, + POWERWALL_API_CHANGED, + POWERWALL_COORDINATOR, + UPDATE_INTERVAL, +) from .models import ( PowerwallBaseInfo, PowerwallConfigEntry, @@ -52,6 +60,8 @@ class PowerwallDataManager: self, hass: HomeAssistant, power_wall: Powerwall, + cookie_jar: CookieJar, + entry: PowerwallConfigEntry, ip_address: str, password: str | None, runtime_data: PowerwallRuntimeData, @@ -62,6 +72,8 @@ class PowerwallDataManager: self.password = password self.runtime_data = runtime_data self.power_wall = power_wall + self.cookie_jar = cookie_jar + self.entry = entry @property def api_changed(self) -> int: @@ -72,7 +84,9 @@ class PowerwallDataManager: """Recreate the login on auth failure.""" if self.power_wall.is_authenticated(): await self.power_wall.logout() + # Always use the password when recreating the login await self.power_wall.login(self.password or "") + self.save_auth_cookie() async def async_update_data(self) -> PowerwallData: """Fetch data from API endpoint.""" @@ -116,41 +130,74 @@ class PowerwallDataManager: return data raise RuntimeError("unreachable") + @callback + def save_auth_cookie(self) -> None: + """Save the auth cookie.""" + for cookie in self.cookie_jar: + if cookie.key == AUTH_COOKIE_KEY: + self.hass.config_entries.async_update_entry( + self.entry, + data={**self.entry.data, CONFIG_ENTRY_COOKIE: cookie.value}, + ) + _LOGGER.debug("Saved auth cookie") + break + async def async_setup_entry(hass: HomeAssistant, entry: PowerwallConfigEntry) -> bool: """Set up Tesla Powerwall from a config entry.""" ip_address: str = entry.data[CONF_IP_ADDRESS] password: str | None = entry.data.get(CONF_PASSWORD) + + cookie_jar: CookieJar = CookieJar(unsafe=True) + use_auth_cookie: bool = False + # Try to reuse the auth cookie + auth_cookie_value: str | None = entry.data.get(CONFIG_ENTRY_COOKIE) + if auth_cookie_value: + cookie_jar.update_cookies( + {AUTH_COOKIE_KEY: auth_cookie_value}, + URL(f"http://{ip_address}"), + ) + _LOGGER.debug("Using existing auth cookie") + use_auth_cookie = True + http_session = async_create_clientsession( - hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) + hass, verify_ssl=False, cookie_jar=cookie_jar ) async with AsyncExitStack() as stack: power_wall = Powerwall(ip_address, http_session=http_session, verify_ssl=False) stack.push_async_callback(power_wall.close) - try: - base_info = await _login_and_fetch_base_info( - power_wall, ip_address, password - ) + for tries in range(2): + try: + base_info = await _login_and_fetch_base_info( + power_wall, ip_address, password, use_auth_cookie + ) - # Cancel closing power_wall on success - stack.pop_all() - except (TimeoutError, PowerwallUnreachableError) as err: - raise ConfigEntryNotReady from err - except MissingAttributeError as err: - # The error might include some important information about what exactly changed. - _LOGGER.error("The powerwall api has changed: %s", str(err)) - persistent_notification.async_create( - hass, API_CHANGED_ERROR_BODY, API_CHANGED_TITLE - ) - return False - except AccessDeniedError as err: - _LOGGER.debug("Authentication failed", exc_info=err) - raise ConfigEntryAuthFailed from err - except ApiError as err: - raise ConfigEntryNotReady from err + # Cancel closing power_wall on success + stack.pop_all() + break + except (TimeoutError, PowerwallUnreachableError) as err: + raise ConfigEntryNotReady from err + except MissingAttributeError as err: + # The error might include some important information about what exactly changed. + _LOGGER.error("The powerwall api has changed: %s", str(err)) + persistent_notification.async_create( + hass, API_CHANGED_ERROR_BODY, API_CHANGED_TITLE + ) + return False + except AccessDeniedError as err: + if use_auth_cookie and tries == 0: + _LOGGER.debug( + "Authentication failed with cookie, retrying with password" + ) + use_auth_cookie = False + continue + _LOGGER.debug("Authentication failed", exc_info=err) + raise ConfigEntryAuthFailed from err + except ApiError as err: + raise ConfigEntryNotReady from err gateway_din = base_info.gateway_din if entry.unique_id is not None and is_ip_address(entry.unique_id): @@ -163,7 +210,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerwallConfigEntry) -> api_instance=power_wall, ) - manager = PowerwallDataManager(hass, power_wall, ip_address, password, runtime_data) + manager = PowerwallDataManager( + hass, + power_wall, + cookie_jar, + entry, + ip_address, + password, + runtime_data, + ) + manager.save_auth_cookie() coordinator = DataUpdateCoordinator( hass, @@ -213,10 +269,11 @@ async def async_migrate_entity_unique_ids( async def _login_and_fetch_base_info( - power_wall: Powerwall, host: str, password: str | None + power_wall: Powerwall, host: str, password: str | None, use_auth_cookie: bool ) -> PowerwallBaseInfo: """Login to the powerwall and fetch the base info.""" - if password is not None: + # Login step is skipped if password is None or if we are using the auth cookie + if not (password is None or use_auth_cookie): await power_wall.login(password) return await _call_base_info(power_wall, host) diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index 396ba31b4ee..b082016e562 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -31,7 +31,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.util.network import is_ip_address from . import async_last_update_was_successful -from .const import DOMAIN +from .const import CONFIG_ENTRY_COOKIE, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -257,8 +257,10 @@ class PowerwallConfigFlow(ConfigFlow, domain=DOMAIN): {CONF_IP_ADDRESS: reauth_entry.data[CONF_IP_ADDRESS], **user_input} ) if not errors: + # We have a new valid connection, old cookie is no longer valid + user_input[CONFIG_ENTRY_COOKIE] = None return self.async_update_reload_and_abort( - reauth_entry, data_updates=user_input + reauth_entry, data_updates={**user_input, CONFIG_ENTRY_COOKIE: None} ) self.context["title_placeholders"] = { diff --git a/homeassistant/components/powerwall/const.py b/homeassistant/components/powerwall/const.py index bb3a6c2355e..186a1221a87 100644 --- a/homeassistant/components/powerwall/const.py +++ b/homeassistant/components/powerwall/const.py @@ -18,3 +18,6 @@ ATTR_IS_ACTIVE = "is_active" MODEL = "PowerWall 2" MANUFACTURER = "Tesla" + +CONFIG_ENTRY_COOKIE = "cookie" +AUTH_COOKIE_KEY = "AuthCookie" diff --git a/tests/components/powerwall/test_init.py b/tests/components/powerwall/test_init.py index e271cde0fc4..dd70dbb7c65 100644 --- a/tests/components/powerwall/test_init.py +++ b/tests/components/powerwall/test_init.py @@ -1,17 +1,23 @@ """Tests for the PowerwallDataManager.""" import datetime -from unittest.mock import patch +from http.cookies import Morsel +from unittest.mock import MagicMock, patch +from aiohttp import CookieJar from tesla_powerwall import AccessDeniedError, LoginResponse -from homeassistant.components.powerwall.const import DOMAIN +from homeassistant.components.powerwall.const import ( + AUTH_COOKIE_KEY, + CONFIG_ENTRY_COOKIE, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow -from .mocks import _mock_powerwall_with_fixtures +from .mocks import MOCK_GATEWAY_DIN, _mock_powerwall_with_fixtures from tests.common import MockConfigEntry, async_fire_time_changed @@ -37,7 +43,11 @@ async def test_update_data_reauthenticate_on_access_denied(hass: HomeAssistant) mock_powerwall.is_authenticated.return_value = True config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4", CONF_PASSWORD: "password"} + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "password", + }, ) config_entry.add_to_hass(hass) with ( @@ -72,3 +82,226 @@ async def test_update_data_reauthenticate_on_access_denied(hass: HomeAssistant) assert len(flows) == 1 reauth_flow = flows[0] assert reauth_flow["context"]["source"] == "reauth" + + +async def test_init_uses_cookie_if_present(hass: HomeAssistant) -> None: + """Tests if the init will use the auth cookie if present. + + If the cookie is present, the login step will be skipped and info will be fetched directly (see _login_and_fetch_base_info). + """ + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "somepassword", + CONFIG_ENTRY_COOKIE: "somecookie", + }, + ) + config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), + patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert not mock_powerwall.login.called + assert mock_powerwall.get_gateway_din.called + + +async def test_init_uses_password_if_no_cookies(hass: HomeAssistant) -> None: + """Tests if the init will use the password if no auth cookie present.""" + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "somepassword", + }, + ) + config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), + patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_powerwall.login.assert_called_with("somepassword") + assert mock_powerwall.get_charge.called + + +async def test_init_saves_the_cookie(hass: HomeAssistant) -> None: + """Tests that the cookie is properly saved.""" + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + mock_jar = MagicMock(CookieJar) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "somepassword", + }, + ) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), + patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ), + patch("homeassistant.components.powerwall.CookieJar", return_value=mock_jar), + ): + auth_cookie = Morsel() + auth_cookie.set(AUTH_COOKIE_KEY, "somecookie", "somecookie") + mock_jar.__iter__.return_value = [auth_cookie] + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.data[CONFIG_ENTRY_COOKIE] == "somecookie" + + +async def test_retry_ignores_cookie(hass: HomeAssistant) -> None: + """Tests that retrying uses the password instead.""" + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "somepassword", + CONFIG_ENTRY_COOKIE: "somecookie", + }, + ) + config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), + patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert not mock_powerwall.login.called + assert mock_powerwall.get_gateway_din.called + + mock_powerwall.login.reset_mock() + mock_powerwall.get_charge.reset_mock() + + mock_powerwall.get_charge.side_effect = [AccessDeniedError("test"), 90.0] + + async_fire_time_changed(hass, utcnow() + datetime.timedelta(minutes=1)) + await hass.async_block_till_done() + + mock_powerwall.login.assert_called_with("somepassword") + assert mock_powerwall.get_charge.call_count == 2 + + +async def test_reauth_ignores_and_clears_cookie(hass: HomeAssistant) -> None: + """Tests that the reauth flow uses password and clears the cookie.""" + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "somepassword", + CONFIG_ENTRY_COOKIE: "somecookie", + }, + ) + config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), + patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_powerwall.login.reset_mock() + mock_powerwall.get_charge.reset_mock() + + mock_powerwall.get_charge.side_effect = [ + AccessDeniedError("test"), + AccessDeniedError("test"), + ] + + async_fire_time_changed(hass, utcnow() + datetime.timedelta(minutes=1)) + await hass.async_block_till_done() + + mock_powerwall.login.assert_called_with("somepassword") + assert mock_powerwall.get_charge.call_count == 2 + + flows = hass.config_entries.flow.async_progress(DOMAIN) + assert len(flows) == 1 + reauth_flow = flows[0] + assert reauth_flow["context"]["source"] == "reauth" + + mock_powerwall.login.reset_mock() + assert config_entry.data[CONFIG_ENTRY_COOKIE] is not None + + await hass.config_entries.flow.async_configure( + reauth_flow["flow_id"], {CONF_PASSWORD: "somepassword"} + ) + + mock_powerwall.login.assert_called_with("somepassword") + assert config_entry.data[CONFIG_ENTRY_COOKIE] is None + + +async def test_init_retries_with_password(hass: HomeAssistant) -> None: + """Tests that the init retries with password if cookie fails.""" + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "somepassword", + CONFIG_ENTRY_COOKIE: "somecookie", + }, + ) + config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), + patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ), + ): + mock_powerwall.get_gateway_din.side_effect = [ + AccessDeniedError("get_gateway_din"), + MOCK_GATEWAY_DIN, + ] + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_powerwall.login.assert_called_with("somepassword") + assert mock_powerwall.get_gateway_din.call_count == 2 From 8622beb8a70f9b3ccac3f6f69507e20fd74fda3c Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Fri, 24 Jan 2025 23:05:31 +0100 Subject: [PATCH 0951/2987] Bump async-upnp-client to 0.43.0 (#136481) --- homeassistant/components/dlna_dmr/manifest.json | 2 +- homeassistant/components/dlna_dms/manifest.json | 2 +- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index adbb4198b9f..82541476a02 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.42.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.43.0", "getmac==0.9.5"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index ac5bf3719e3..17fc3dc27e8 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -7,7 +7,7 @@ "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", - "requirements": ["async-upnp-client==0.42.0"], + "requirements": ["async-upnp-client==0.43.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 43bd92799a8..6a30efd64f8 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", "wakeonlan==2.1.0", - "async-upnp-client==0.42.0" + "async-upnp-client==0.43.0" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 2632e37aa98..6e1fba8c3a3 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.42.0"] + "requirements": ["async-upnp-client==0.43.0"] } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 0ca103300da..df4daa8782c 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.42.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.43.0", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index eba970dc2db..6efb66449ab 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -16,7 +16,7 @@ }, "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.14", "async-upnp-client==0.42.0"], + "requirements": ["yeelight==0.7.14", "async-upnp-client==0.43.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9bd591df2e5..e5e5405686b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 async-interrupt==1.2.0 -async-upnp-client==0.42.0 +async-upnp-client==0.43.0 atomicwrites-homeassistant==1.4.1 attrs==24.2.0 audioop-lts==0.2.1;python_version>='3.13' diff --git a/requirements_all.txt b/requirements_all.txt index 09e6a9e810c..781f7d455f7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -505,7 +505,7 @@ asmog==0.0.6 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.42.0 +async-upnp-client==0.43.0 # homeassistant.components.arve asyncarve==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a585477127..f6968127a5e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -469,7 +469,7 @@ arcam-fmj==1.5.2 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.42.0 +async-upnp-client==0.43.0 # homeassistant.components.arve asyncarve==0.1.1 From 891485f306f4f80dbe82c20b4a728520da9df2ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Jan 2025 12:17:52 -1000 Subject: [PATCH 0952/2987] Bump pydantic to 2.10.6 (#136483) --- homeassistant/package_constraints.txt | 2 +- requirements_test.txt | 2 +- script/gen_requirements_all.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e5e5405686b..cb29214390b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -128,7 +128,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.10.4 +pydantic==2.10.6 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/requirements_test.txt b/requirements_test.txt index 2c488189291..68945852298 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -14,7 +14,7 @@ license-expression==30.4.0 mock-open==1.4.0 mypy-dev==1.15.0a2 pre-commit==4.0.0 -pydantic==2.10.4 +pydantic==2.10.6 pylint==3.3.3 pylint-per-file-ignores==1.3.2 pipdeptree==2.23.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 2b6e4eda7b0..ef57b9140ce 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -159,7 +159,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.10.4 +pydantic==2.10.6 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 From 829fab5371ae7943a27240657c73fd30c2f526a4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 25 Jan 2025 08:40:22 +0100 Subject: [PATCH 0953/2987] Cleanup update_listener in deconz (#136416) --- homeassistant/components/deconz/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 8007f3217d5..4d48e6c9892 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -46,7 +46,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hub = hass.data[DOMAIN][config_entry.entry_id] = DeconzHub(hass, config_entry, api) await hub.async_update_device_registry() - config_entry.add_update_listener(hub.async_config_entry_updated) + config_entry.async_on_unload( + config_entry.add_update_listener(hub.async_config_entry_updated) + ) await async_setup_events(hub) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) From ddf071c80e898e8931efe5e38f29c2a40b210abd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 25 Jan 2025 08:41:54 +0100 Subject: [PATCH 0954/2987] Move deconz function to util.py (#136414) --- homeassistant/components/deconz/__init__.py | 2 +- homeassistant/components/deconz/config_flow.py | 11 +---------- homeassistant/components/deconz/services.py | 2 +- homeassistant/components/deconz/util.py | 15 +++++++++++++++ 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 4d48e6c9892..42c81e69740 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -9,12 +9,12 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .config_flow import get_master_hub from .const import CONF_MASTER_GATEWAY, DOMAIN, PLATFORMS from .deconz_event import async_setup_events, async_unload_events from .errors import AuthenticationRequired, CannotConnect from .hub import DeconzHub, get_deconz_api from .services import async_setup_services +from .util import get_master_hub CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 7f5fc96c022..41e45d53c76 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -27,7 +27,7 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo @@ -51,15 +51,6 @@ CONF_SERIAL = "serial" CONF_MANUAL_INPUT = "Manually define gateway" -@callback -def get_master_hub(hass: HomeAssistant) -> DeconzHub: - """Return the gateway which is marked as master.""" - for hub in hass.data[DOMAIN].values(): - if hub.master: - return cast(DeconzHub, hub) - raise ValueError - - class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a deCONZ config flow.""" diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index e10195d86bc..6127fe44308 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -12,9 +12,9 @@ from homeassistant.helpers import ( from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.util.read_only_dict import ReadOnlyDict -from .config_flow import get_master_hub from .const import CONF_BRIDGE_ID, DOMAIN, LOGGER from .hub import DeconzHub +from .util import get_master_hub DECONZ_SERVICES = "deconz_services" diff --git a/homeassistant/components/deconz/util.py b/homeassistant/components/deconz/util.py index 7c44280200d..bcf338b2d6d 100644 --- a/homeassistant/components/deconz/util.py +++ b/homeassistant/components/deconz/util.py @@ -2,9 +2,24 @@ from __future__ import annotations +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .hub import DeconzHub + def serial_from_unique_id(unique_id: str | None) -> str | None: """Get a device serial number from a unique ID, if possible.""" if not unique_id or unique_id.count(":") != 7: return None return unique_id.partition("-")[0] + + +@callback +def get_master_hub(hass: HomeAssistant) -> DeconzHub: + """Return the gateway which is marked as master.""" + hub: DeconzHub + for hub in hass.data[DOMAIN].values(): + if hub.master: + return hub + raise ValueError From d84fa1fcfbe2e4c8a0f094eeed5b3d9c74fecef4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Jan 2025 22:15:05 -1000 Subject: [PATCH 0955/2987] Fix httpx late import of trio doing blocking I/O in the event loop (#136409) httpx 0.28.1 moved the trio import to happen a bit later ``` 2025-01-23 19:53:12.370 WARNING (MainThread) [homeassistant.util.loop] Detected blocking call to open with args (/lib/c, rb) inside the event loop by integration rest at homeassistant/components/rest/data.py, line 88: self._async_client = create_async_httpx_client( (offender: /usr/local/lib/python3.13/ctypes/util.py, line 285: with open(filepath, rb) as fh:), please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+rest%22 For developers, please see https://developers.home-assistant.io/docs/asyncio_blocking_operations/#open Traceback (most recent call last): File "", line 198, in _run_module_as_main File "", line 88, in _run_code File "/usr/src/homeassistant/homeassistant/__main__.py", line 227, in sys.exit(main()) File "/usr/src/homeassistant/homeassistant/__main__.py", line 213, in main exit_code = runner.run(runtime_conf) File "/usr/src/homeassistant/homeassistant/runner.py", line 154, in run return loop.run_until_complete(setup_and_run_hass(runtime_config)) File "/usr/local/lib/python3.13/asyncio/base_events.py", line 707, in run_until_complete self.run_forever() File "/usr/local/lib/python3.13/asyncio/base_events.py", line 678, in run_forever self._run_once() File "/usr/local/lib/python3.13/asyncio/base_events.py", line 2033, in _run_once handle._run() File "/usr/local/lib/python3.13/asyncio/events.py", line 89, in _run self._context.run(self._callback, *self._args) File "/usr/src/homeassistant/homeassistant/config_entries.py", line 2360, in _async_forward_entry_setup result = await async_setup_component( File "/usr/src/homeassistant/homeassistant/setup.py", line 165, in async_setup_component result = await _async_setup_component(hass, domain, config) File "/usr/src/homeassistant/homeassistant/setup.py", line 420, in _async_setup_component result = await task File "/usr/src/homeassistant/homeassistant/components/sensor/__init__.py", line 90, in async_setup await component.async_setup(config) File "/usr/src/homeassistant/homeassistant/helpers/entity_component.py", line 146, in async_setup self.hass.async_create_task_internal( File "/usr/src/homeassistant/homeassistant/core.py", line 832, in async_create_task_internal task = create_eager_task(target, name=name, loop=self.loop) File "/usr/src/homeassistant/homeassistant/util/async_.py", line 45, in create_eager_task return Task(coro, loop=loop, name=name, eager_start=True) File "/usr/src/homeassistant/homeassistant/helpers/entity_component.py", line 307, in async_setup_platform await self._platforms[key].async_setup(platform_config, discovery_info) File "/usr/src/homeassistant/homeassistant/helpers/entity_platform.py", line 303, in async_setup await self._async_setup_platform(async_create_setup_awaitable) File "/usr/src/homeassistant/homeassistant/helpers/entity_platform.py", line 363, in _async_setup_platform awaitable = create_eager_task(awaitable, loop=hass.loop) File "/usr/src/homeassistant/homeassistant/util/async_.py", line 45, in create_eager_task return Task(coro, loop=loop, name=name, eager_start=True) File "/usr/src/homeassistant/homeassistant/components/rest/sensor.py", line 85, in async_setup_platform await rest.async_update(log_errors=False) File "/usr/src/homeassistant/homeassistant/components/rest/data.py", line 88, in async_update self._async_client = create_async_httpx_client( 2025-01-23 19:53:12.371 WARNING (MainThread) [homeassistant.util.loop] Detected blocking call to glob with args (/lib/libc.so,) inside the event loop by integration rest at homeassistant/components/rest/data.py, line 88: self._async_client = create_async_httpx_client( (offender: /usr/local/lib/python3.13/ctypes/util.py, line 311: for f in glob({0}{1}.format(prefix, suffix)):), please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+rest%22 For developers, please see https://developers.home-assistant.io/docs/asyncio_blocking_operations/#glob Traceback (most recent call last): File "", line 198, in _run_module_as_main File "", line 88, in _run_code File "/usr/src/homeassistant/homeassistant/__main__.py", line 227, in sys.exit(main()) File "/usr/src/homeassistant/homeassistant/__main__.py", line 213, in main exit_code = runner.run(runtime_conf) File "/usr/src/homeassistant/homeassistant/runner.py", line 154, in run return loop.run_until_complete(setup_and_run_hass(runtime_config)) File "/usr/local/lib/python3.13/asyncio/base_events.py", line 707, in run_until_complete self.run_forever() File "/usr/local/lib/python3.13/asyncio/base_events.py", line 678, in run_forever self._run_once() File "/usr/local/lib/python3.13/asyncio/base_events.py", line 2033, in _run_once handle._run() File "/usr/local/lib/python3.13/asyncio/events.py", line 89, in _run self._context.run(self._callback, *self._args) File "/usr/src/homeassistant/homeassistant/config_entries.py", line 2360, in _async_forward_entry_setup result = await async_setup_component( File "/usr/src/homeassistant/homeassistant/setup.py", line 165, in async_setup_component result = await _async_setup_component(hass, domain, config) File "/usr/src/homeassistant/homeassistant/setup.py", line 420, in _async_setup_component result = await task File "/usr/src/homeassistant/homeassistant/components/sensor/__init__.py", line 90, in async_setup await component.async_setup(config) File "/usr/src/homeassistant/homeassistant/helpers/entity_component.py", line 146, in async_setup self.hass.async_create_task_internal( File "/usr/src/homeassistant/homeassistant/core.py", line 832, in async_create_task_internal task = create_eager_task(target, name=name, loop=self.loop) File "/usr/src/homeassistant/homeassistant/util/async_.py", line 45, in create_eager_task return Task(coro, loop=loop, name=name, eager_start=True) File "/usr/src/homeassistant/homeassistant/helpers/entity_component.py", line 307, in async_setup_platform await self._platforms[key].async_setup(platform_config, discovery_info) File "/usr/src/homeassistant/homeassistant/helpers/entity_platform.py", line 303, in async_setup await self._async_setup_platform(async_create_setup_awaitable) File "/usr/src/homeassistant/homeassistant/helpers/entity_platform.py", line 363, in _async_setup_platform awaitable = create_eager_task(awaitable, loop=hass.loop) File "/usr/src/homeassistant/homeassistant/util/async_.py", line 45, in create_eager_task return Task(coro, loop=loop, name=name, eager_start=True) File "/usr/src/homeassistant/homeassistant/components/rest/sensor.py", line 85, in async_setup_platform await rest.async_update(log_errors=False) File "/usr/src/homeassistant/homeassistant/components/rest/data.py", line 88, in async_update self._async_client = create_async_httpx_client( 2025-01-23 19:53:12.372 WARNING (MainThread) [homeassistant.util.loop] Detected blocking call to iglob with args (/lib/libc.so,) inside the event loop by integration rest at homeassistant/components/rest/data.py, line 88: self._async_client = create_async_httpx_client( (offender: /usr/local/lib/python3.13/glob.py, line 31: return list(iglob(pathname, root_dir=root_dir, dir_fd=dir_fd, recursive=recursive,), please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+rest%22 For developers, please see https://developers.home-assistant.io/docs/asyncio_blocking_operations/#iglob Traceback (most recent call last): File "", line 198, in _run_module_as_main File "", line 88, in _run_code File "/usr/src/homeassistant/homeassistant/__main__.py", line 227, in sys.exit(main()) File "/usr/src/homeassistant/homeassistant/__main__.py", line 213, in main exit_code = runner.run(runtime_conf) File "/usr/src/homeassistant/homeassistant/runner.py", line 154, in run return loop.run_until_complete(setup_and_run_hass(runtime_config)) File "/usr/local/lib/python3.13/asyncio/base_events.py", line 707, in run_until_complete self.run_forever() File "/usr/local/lib/python3.13/asyncio/base_events.py", line 678, in run_forever self._run_once() File "/usr/local/lib/python3.13/asyncio/base_events.py", line 2033, in _run_once handle._run() File "/usr/local/lib/python3.13/asyncio/events.py", line 89, in _run self._context.run(self._callback, *self._args) File "/usr/src/homeassistant/homeassistant/config_entries.py", line 2360, in _async_forward_entry_setup result = await async_setup_component( File "/usr/src/homeassistant/homeassistant/setup.py", line 165, in async_setup_component result = await _async_setup_component(hass, domain, config) File "/usr/src/homeassistant/homeassistant/setup.py", line 420, in _async_setup_component result = await task File "/usr/src/homeassistant/homeassistant/components/sensor/__init__.py", line 90, in async_setup await component.async_setup(config) File "/usr/src/homeassistant/homeassistant/helpers/entity_component.py", line 146, in async_setup self.hass.async_create_task_internal( File "/usr/src/homeassistant/homeassistant/core.py", line 832, in async_create_task_internal task = create_eager_task(target, name=name, loop=self.loop) File "/usr/src/homeassistant/homeassistant/util/async_.py", line 45, in create_eager_task return Task(coro, loop=loop, name=name, eager_start=True) File "/usr/src/homeassistant/homeassistant/helpers/entity_component.py", line 307, in async_setup_platform await self._platforms[key].async_setup(platform_config, discovery_info) File "/usr/src/homeassistant/homeassistant/helpers/entity_platform.py", line 303, in async_setup await self._async_setup_platform(async_create_setup_awaitable) File "/usr/src/homeassistant/homeassistant/helpers/entity_platform.py", line 363, in _async_setup_platform awaitable = create_eager_task(awaitable, loop=hass.loop) File "/usr/src/homeassistant/homeassistant/util/async_.py", line 45, in create_eager_task return Task(coro, loop=loop, name=name, eager_start=True) File "/usr/src/homeassistant/homeassistant/components/rest/sensor.py", line 85, in async_setup_platform await rest.async_update(log_errors=False) File "/usr/src/homeassistant/homeassistant/components/rest/data.py", line 88, in async_update self._async_client = create_async_httpx_client( 2025-01-23 19:53:12.374 WARNING (MainThread) [homeassistant.util.loop] Detected blocking call to scandir with args (/lib,) inside the event loop by integration rest at homeassistant/components/rest/data.py, line 88: self._async_client = create_async_httpx_client( (offender: /usr/local/lib/python3.13/glob.py, line 170: with os.scandir(arg) as it:), please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+rest%22 For developers, please see https://developers.home-assistant.io/docs/asyncio_blocking_operations/#scandir Traceback (most recent call last): File "", line 198, in _run_module_as_main File "", line 88, in _run_code File "/usr/src/homeassistant/homeassistant/__main__.py", line 227, in sys.exit(main()) File "/usr/src/homeassistant/homeassistant/__main__.py", line 213, in main exit_code = runner.run(runtime_conf) File "/usr/src/homeassistant/homeassistant/runner.py", line 154, in run return loop.run_until_complete(setup_and_run_hass(runtime_config)) File "/usr/local/lib/python3.13/asyncio/base_events.py", line 707, in run_until_complete self.run_forever() File "/usr/local/lib/python3.13/asyncio/base_events.py", line 678, in run_forever self._run_once() File "/usr/local/lib/python3.13/asyncio/base_events.py", line 2033, in _run_once handle._run() File "/usr/local/lib/python3.13/asyncio/events.py", line 89, in _run self._context.run(self._callback, *self._args) File "/usr/src/homeassistant/homeassistant/config_entries.py", line 2360, in _async_forward_entry_setup result = await async_setup_component( File "/usr/src/homeassistant/homeassistant/setup.py", line 165, in async_setup_component result = await _async_setup_component(hass, domain, config) File "/usr/src/homeassistant/homeassistant/setup.py", line 420, in _async_setup_component result = await task File "/usr/src/homeassistant/homeassistant/components/sensor/__init__.py", line 90, in async_setup await component.async_setup(config) File "/usr/src/homeassistant/homeassistant/helpers/entity_component.py", line 146, in async_setup self.hass.async_create_task_internal( File "/usr/src/homeassistant/homeassistant/core.py", line 832, in async_create_task_internal task = create_eager_task(target, name=name, loop=self.loop) File "/usr/src/homeassistant/homeassistant/util/async_.py", line 45, in create_eager_task return Task(coro, loop=loop, name=name, eager_start=True) File "/usr/src/homeassistant/homeassistant/helpers/entity_component.py", line 307, in async_setup_platform await self._platforms[key].async_setup(platform_config, discovery_info) File "/usr/src/homeassistant/homeassistant/helpers/entity_platform.py", line 303, in async_setup await self._async_setup_platform(async_create_setup_awaitable) File "/usr/src/homeassistant/homeassistant/helpers/entity_platform.py", line 363, in _async_setup_platform awaitable = create_eager_task(awaitable, loop=hass.loop) File "/usr/src/homeassistant/homeassistant/util/async_.py", line 45, in create_eager_task return Task(coro, loop=loop, name=name, eager_start=True) File "/usr/src/homeassistant/homeassistant/components/rest/sensor.py", line 85, in async_setup_platform await rest.async_update(log_errors=False) File "/usr/src/homeassistant/homeassistant/components/rest/data.py", line 88, in async_update self._async_client = create_async_httpx_client( ``` --- homeassistant/bootstrap.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index f1f1835863b..d89a9595868 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -112,6 +112,11 @@ with contextlib.suppress(ImportError): # Ensure anyio backend is imported to avoid it being imported in the event loop from anyio._backends import _asyncio # noqa: F401 +with contextlib.suppress(ImportError): + # httpx will import trio if it is installed which does + # blocking I/O in the event loop. We want to avoid that. + import trio # noqa: F401 + if TYPE_CHECKING: from .runner import RuntimeConfig From b25b97b6b62f617fd8a6c638b3db59146404d0ee Mon Sep 17 00:00:00 2001 From: TimL Date: Sat, 25 Jan 2025 19:22:26 +1100 Subject: [PATCH 0956/2987] Bump pysmlight to v0.1.6 (#136496) --- homeassistant/components/smlight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 6518cc81989..3a8578c8a59 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.1.5"], + "requirements": ["pysmlight==0.1.6"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 781f7d455f7..0dd95d65c98 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2303,7 +2303,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.5 +pysmlight==0.1.6 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6968127a5e..676466b8ac9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1875,7 +1875,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.5 +pysmlight==0.1.6 # homeassistant.components.snmp pysnmp==6.2.6 From 28951096a8526f51e78ee05f185c82e54a185cdb Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sat, 25 Jan 2025 09:38:06 +0000 Subject: [PATCH 0957/2987] Update tplink climate platform to use thermostat module (#136166) --- homeassistant/components/tplink/climate.py | 60 +++++++++++------- homeassistant/components/tplink/icons.json | 3 - tests/components/tplink/__init__.py | 25 +++++++- tests/components/tplink/test_climate.py | 72 +++++++++++++--------- tests/components/tplink/test_init.py | 4 +- 5 files changed, 106 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py index d4800d9e951..7204c2a7665 100644 --- a/homeassistant/components/tplink/climate.py +++ b/homeassistant/components/tplink/climate.py @@ -7,7 +7,7 @@ from dataclasses import dataclass import logging from typing import Any, cast -from kasa import Device +from kasa import Device, Module from kasa.smart.modules.temperaturecontrol import ThermostatState from homeassistant.components.climate import ( @@ -19,7 +19,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.const import PRECISION_TENTHS +from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -42,6 +42,7 @@ STATE_TO_ACTION = { ThermostatState.Idle: HVACAction.IDLE, ThermostatState.Heating: HVACAction.HEATING, ThermostatState.Off: HVACAction.OFF, + ThermostatState.Calibrating: HVACAction.IDLE, } @@ -62,7 +63,7 @@ class TPLinkClimateEntityDescription( CLIMATE_DESCRIPTIONS: tuple[TPLinkClimateEntityDescription, ...] = ( TPLinkClimateEntityDescription( key="climate", - exists_fn=lambda dev, _: dev.device_type is Device.Type.Thermostat, + exists_fn=lambda dev, _: Module.Thermostat in dev.modules, ), ) @@ -124,27 +125,42 @@ class TPLinkClimateEntity(CoordinatedTPLinkModuleEntity, ClimateEntity): ) -> None: """Initialize the climate entity.""" super().__init__(device, coordinator, description, parent=parent) - self._state_feature = device.features["state"] - self._mode_feature = device.features["thermostat_mode"] - self._temp_feature = device.features["temperature"] - self._target_feature = device.features["target_temperature"] + self._thermostat_module = device.modules[Module.Thermostat] - self._attr_min_temp = self._target_feature.minimum_value - self._attr_max_temp = self._target_feature.maximum_value - self._attr_temperature_unit = UNIT_MAPPING[cast(str, self._temp_feature.unit)] + if target_feature := self._thermostat_module.get_feature("target_temperature"): + self._attr_min_temp = target_feature.minimum_value + self._attr_max_temp = target_feature.maximum_value + else: + _LOGGER.error( + "Unable to get min/max target temperature for %s, using defaults", + device.host, + ) + + if temperature_feature := self._thermostat_module.get_feature("temperature"): + self._attr_temperature_unit = UNIT_MAPPING[ + cast(str, temperature_feature.unit) + ] + else: + _LOGGER.error( + "Unable to get correct temperature unit for %s, defaulting to celsius", + device.host, + ) + self._attr_temperature_unit = UnitOfTemperature.CELSIUS @async_refresh_after async def async_set_temperature(self, **kwargs: Any) -> None: """Set target temperature.""" - await self._target_feature.set_value(int(kwargs[ATTR_TEMPERATURE])) + await self._thermostat_module.set_target_temperature( + float(kwargs[ATTR_TEMPERATURE]) + ) @async_refresh_after async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode (heat/off).""" if hvac_mode is HVACMode.HEAT: - await self._state_feature.set_value(True) + await self._thermostat_module.set_state(True) elif hvac_mode is HVACMode.OFF: - await self._state_feature.set_value(False) + await self._thermostat_module.set_state(False) else: raise ServiceValidationError( translation_domain=DOMAIN, @@ -157,35 +173,33 @@ class TPLinkClimateEntity(CoordinatedTPLinkModuleEntity, ClimateEntity): @async_refresh_after async def async_turn_on(self) -> None: """Turn heating on.""" - await self._state_feature.set_value(True) + await self._thermostat_module.set_state(True) @async_refresh_after async def async_turn_off(self) -> None: """Turn heating off.""" - await self._state_feature.set_value(False) + await self._thermostat_module.set_state(False) @callback def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" - self._attr_current_temperature = cast(float | None, self._temp_feature.value) - self._attr_target_temperature = cast(float | None, self._target_feature.value) + self._attr_current_temperature = self._thermostat_module.temperature + self._attr_target_temperature = self._thermostat_module.target_temperature self._attr_hvac_mode = ( - HVACMode.HEAT if self._state_feature.value else HVACMode.OFF + HVACMode.HEAT if self._thermostat_module.state else HVACMode.OFF ) if ( - self._mode_feature.value not in STATE_TO_ACTION + self._thermostat_module.mode not in STATE_TO_ACTION and self._attr_hvac_action is not HVACAction.OFF ): _LOGGER.warning( "Unknown thermostat state, defaulting to OFF: %s", - self._mode_feature.value, + self._thermostat_module.mode, ) self._attr_hvac_action = HVACAction.OFF return True - self._attr_hvac_action = STATE_TO_ACTION[ - cast(ThermostatState, self._mode_feature.value) - ] + self._attr_hvac_action = STATE_TO_ACTION[self._thermostat_module.mode] return True diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json index aedbccfbd51..e00e8f69467 100644 --- a/homeassistant/components/tplink/icons.json +++ b/homeassistant/components/tplink/icons.json @@ -145,9 +145,6 @@ "temperature_offset": { "default": "mdi:contrast" }, - "target_temperature": { - "default": "mdi:thermometer" - }, "pan_step": { "default": "mdi:unfold-more-vertical" }, diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 81ee679a251..a056555f4c0 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -6,8 +6,16 @@ from datetime import datetime from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from kasa import BaseProtocol, Device, DeviceType, Feature, KasaException, Module -from kasa.interfaces import Fan, Light, LightEffect, LightState +from kasa import ( + BaseProtocol, + Device, + DeviceType, + Feature, + KasaException, + Module, + ThermostatState, +) +from kasa.interfaces import Fan, Light, LightEffect, LightState, Thermostat from kasa.smart.modules.alarm import Alarm from kasa.smartcam.modules.camera import LOCAL_STREAMING_PORT, Camera from syrupy import SnapshotAssertion @@ -361,6 +369,18 @@ def _mocked_camera_module(device): return camera +def _mocked_thermostat_module(device): + therm = MagicMock(auto_spec=Thermostat, name="Mocked thermostat") + therm.state = True + therm.temperature = 20.2 + therm.target_temperature = 22.2 + therm.mode = ThermostatState.Heating + therm.set_state = AsyncMock() + therm.set_target_temperature = AsyncMock() + + return therm + + def _mocked_strip_children(features=None, alias=None) -> list[Device]: plug0 = _mocked_device( alias="Plug0" if alias is None else alias, @@ -429,6 +449,7 @@ MODULE_TO_MOCK_GEN = { Module.Fan: _mocked_fan_module, Module.Alarm: _mocked_alarm_module, Module.Camera: _mocked_camera_module, + Module.Thermostat: _mocked_thermostat_module, } diff --git a/tests/components/tplink/test_climate.py b/tests/components/tplink/test_climate.py index b1c8abd3a9b..adcca24886b 100644 --- a/tests/components/tplink/test_climate.py +++ b/tests/components/tplink/test_climate.py @@ -2,7 +2,7 @@ from datetime import timedelta -from kasa import Device, Feature +from kasa import Device, Feature, Module from kasa.smart.modules.temperaturecontrol import ThermostatState import pytest from syrupy.assertion import SnapshotAssertion @@ -45,31 +45,24 @@ async def mocked_hub(hass: HomeAssistant) -> Device: features = [ _mocked_feature( - "temperature", value=20.2, category=Feature.Category.Primary, unit="celsius" - ), - _mocked_feature( - "target_temperature", - value=22.2, + "temperature", type_=Feature.Type.Number, category=Feature.Category.Primary, unit="celsius", ), _mocked_feature( - "state", - value=True, - type_=Feature.Type.Switch, - category=Feature.Category.Primary, - ), - _mocked_feature( - "thermostat_mode", - value=ThermostatState.Heating, - type_=Feature.Type.Choice, + "target_temperature", + type_=Feature.Type.Number, category=Feature.Category.Primary, + unit="celsius", ), ] thermostat = _mocked_device( - alias="thermostat", features=features, device_type=Device.Type.Thermostat + alias="thermostat", + features=features, + modules=[Module.Thermostat], + device_type=Device.Type.Thermostat, ) return _mocked_device( @@ -121,7 +114,9 @@ async def test_set_temperature( ) -> None: """Test that set_temperature service calls the setter.""" mocked_thermostat = mocked_hub.children[0] - mocked_thermostat.features["target_temperature"].minimum_value = 0 + + therm_module = mocked_thermostat.modules.get(Module.Thermostat) + assert therm_module await setup_platform_for_device( hass, mock_config_entry, Platform.CLIMATE, mocked_hub @@ -133,8 +128,8 @@ async def test_set_temperature( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 10}, blocking=True, ) - target_temp_feature = mocked_thermostat.features["target_temperature"] - target_temp_feature.set_value.assert_called_with(10) + + therm_module.set_target_temperature.assert_called_with(10) async def test_set_hvac_mode( @@ -146,8 +141,8 @@ async def test_set_hvac_mode( ) mocked_thermostat = mocked_hub.children[0] - mocked_state = mocked_thermostat.features["state"] - assert mocked_state is not None + therm_module = mocked_thermostat.modules.get(Module.Thermostat) + assert therm_module await hass.services.async_call( CLIMATE_DOMAIN, @@ -156,7 +151,7 @@ async def test_set_hvac_mode( blocking=True, ) - mocked_state.set_value.assert_called_with(False) + therm_module.set_state.assert_called_with(False) await hass.services.async_call( CLIMATE_DOMAIN, @@ -164,7 +159,7 @@ async def test_set_hvac_mode( {ATTR_ENTITY_ID: [ENTITY_ID], ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, ) - mocked_state.set_value.assert_called_with(True) + therm_module.set_state.assert_called_with(True) msg = "Tried to set unsupported mode: dry" with pytest.raises(ServiceValidationError, match=msg): @@ -185,7 +180,8 @@ async def test_turn_on_and_off( ) mocked_thermostat = mocked_hub.children[0] - mocked_state = mocked_thermostat.features["state"] + therm_module = mocked_thermostat.modules.get(Module.Thermostat) + assert therm_module await hass.services.async_call( CLIMATE_DOMAIN, @@ -194,7 +190,7 @@ async def test_turn_on_and_off( blocking=True, ) - mocked_state.set_value.assert_called_with(False) + therm_module.set_state.assert_called_with(False) await hass.services.async_call( CLIMATE_DOMAIN, @@ -203,7 +199,7 @@ async def test_turn_on_and_off( blocking=True, ) - mocked_state.set_value.assert_called_with(True) + therm_module.set_state.assert_called_with(True) async def test_unknown_mode( @@ -218,11 +214,31 @@ async def test_unknown_mode( ) mocked_thermostat = mocked_hub.children[0] - mocked_state = mocked_thermostat.features["thermostat_mode"] - mocked_state.value = ThermostatState.Unknown + therm_module = mocked_thermostat.modules.get(Module.Thermostat) + assert therm_module + + therm_module.mode = ThermostatState.Unknown async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF assert "Unknown thermostat state, defaulting to OFF" in caplog.text + + +async def test_missing_feature_attributes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_hub: Device, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that a module missing the min/max and unit feature logs an error.""" + mocked_thermostat = mocked_hub.children[0] + mocked_thermostat.features.pop("target_temperature") + mocked_thermostat.features.pop("temperature") + + await setup_platform_for_device( + hass, mock_config_entry, Platform.CLIMATE, mocked_hub + ) + assert "Unable to get min/max target temperature" in caplog.text + assert "Unable to get correct temperature unit" in caplog.text diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 01f422636b2..ffcadc79faf 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -1007,8 +1007,8 @@ async def test_automatic_feature_device_addition_and_removal( ), pytest.param( "climate", - [], - ["state", "thermostat_mode", "temperature", "target_temperature"], + [Module.Thermostat], + ["temperature", "target_temperature"], None, DeviceType.Thermostat, id="climate", From fb04c256a870e83053ba896dba1ff477cb1e5fda Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 25 Jan 2025 10:43:22 +0100 Subject: [PATCH 0958/2987] Refactor EZVIZ config flow tests (#136434) --- tests/components/ezviz/__init__.py | 91 +-- tests/components/ezviz/conftest.py | 112 ++- tests/components/ezviz/test_config_flow.py | 833 +++++++++++---------- 3 files changed, 510 insertions(+), 526 deletions(-) diff --git a/tests/components/ezviz/__init__.py b/tests/components/ezviz/__init__.py index 78bbee0b0ad..1d4911e9785 100644 --- a/tests/components/ezviz/__init__.py +++ b/tests/components/ezviz/__init__.py @@ -1,102 +1,13 @@ """Tests for the EZVIZ integration.""" -from unittest.mock import _patch, patch - -from homeassistant.components.ezviz.const import ( - ATTR_SERIAL, - ATTR_TYPE_CAMERA, - ATTR_TYPE_CLOUD, - CONF_FFMPEG_ARGUMENTS, - CONF_RFSESSION_ID, - CONF_SESSION_ID, - DEFAULT_FFMPEG_ARGUMENTS, - DEFAULT_TIMEOUT, - DOMAIN, -) -from homeassistant.const import ( - CONF_IP_ADDRESS, - CONF_PASSWORD, - CONF_TIMEOUT, - CONF_TYPE, - CONF_URL, - CONF_USERNAME, -) from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -ENTRY_CONFIG = { - CONF_SESSION_ID: "test-username", - CONF_RFSESSION_ID: "test-password", - CONF_URL: "apiieu.ezvizlife.com", - CONF_TYPE: ATTR_TYPE_CLOUD, -} -ENTRY_OPTIONS = { - CONF_FFMPEG_ARGUMENTS: DEFAULT_FFMPEG_ARGUMENTS, - CONF_TIMEOUT: DEFAULT_TIMEOUT, -} - -USER_INPUT_VALIDATE = { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_URL: "apiieu.ezvizlife.com", -} - -USER_INPUT = { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_URL: "apiieu.ezvizlife.com", - CONF_TYPE: ATTR_TYPE_CLOUD, -} - -USER_INPUT_CAMERA_VALIDATE = { - ATTR_SERIAL: "C666666", - CONF_PASSWORD: "test-password", - CONF_USERNAME: "test-username", -} - -USER_INPUT_CAMERA = { - CONF_PASSWORD: "test-password", - CONF_USERNAME: "test-username", - CONF_TYPE: ATTR_TYPE_CAMERA, -} - -DISCOVERY_INFO = { - ATTR_SERIAL: "C666666", - CONF_USERNAME: None, - CONF_PASSWORD: None, - CONF_IP_ADDRESS: "127.0.0.1", -} - -TEST = { - CONF_USERNAME: None, - CONF_PASSWORD: None, - CONF_IP_ADDRESS: "127.0.0.1", -} - -API_LOGIN_RETURN_VALIDATE = { - CONF_SESSION_ID: "fake_token", - CONF_RFSESSION_ID: "fake_rf_token", - CONF_URL: "apiieu.ezvizlife.com", - CONF_TYPE: ATTR_TYPE_CLOUD, -} - - -def patch_async_setup_entry() -> _patch: - """Patch async_setup_entry.""" - return patch( - "homeassistant.components.ezviz.async_setup_entry", - return_value=True, - ) - - -async def init_integration(hass: HomeAssistant) -> MockConfigEntry: +async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Set up the EZVIZ integration in Home Assistant.""" - entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG, options=ENTRY_OPTIONS) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - - return entry diff --git a/tests/components/ezviz/conftest.py b/tests/components/ezviz/conftest.py index 171cfffc2fc..fab8111b171 100644 --- a/tests/components/ezviz/conftest.py +++ b/tests/components/ezviz/conftest.py @@ -1,19 +1,30 @@ """Define pytest.fixtures available for all tests.""" from collections.abc import Generator -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch -from pyezviz import EzvizClient -from pyezviz.test_cam_rtsp import TestRTSPAuth import pytest +from homeassistant.components.ezviz import ( + ATTR_TYPE_CAMERA, + ATTR_TYPE_CLOUD, + CONF_RFSESSION_ID, + CONF_SESSION_ID, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_TYPE, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -ezviz_login_token_return = { - "session_id": "fake_token", - "rf_session_id": "fake_rf_token", - "api_url": "apiieu.ezvizlife.com", -} +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.ezviz.async_setup_entry", return_value=True + ) as setup_entry_mock: + yield setup_entry_mock @pytest.fixture(autouse=True) @@ -23,40 +34,67 @@ def mock_ffmpeg(hass: HomeAssistant) -> None: @pytest.fixture -def ezviz_test_rtsp_config_flow() -> Generator[MagicMock]: +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + title="test-username", + data={ + CONF_SESSION_ID: "test-username", + CONF_RFSESSION_ID: "test-password", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, + }, + ) + + +@pytest.fixture +def mock_camera_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="C666666", + title="Camera 1", + data={ + CONF_TYPE: ATTR_TYPE_CAMERA, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + +@pytest.fixture +def mock_ezviz_client() -> Generator[AsyncMock]: + """Mock the EzvizAPI for easier testing.""" + with ( + patch( + "homeassistant.components.ezviz.EzvizClient", autospec=True + ) as mock_ezviz, + patch("homeassistant.components.ezviz.config_flow.EzvizClient", new=mock_ezviz), + ): + instance = mock_ezviz.return_value + + instance.login.return_value = { + "session_id": "fake_token", + "rf_session_id": "fake_rf_token", + "api_url": "apiieu.ezvizlife.com", + } + instance.get_detection_sensibility.return_value = True + + yield instance + + +@pytest.fixture +def mock_test_rtsp_auth() -> Generator[MagicMock]: """Mock the EzvizApi for easier testing.""" with ( - patch.object(TestRTSPAuth, "main", return_value=True), patch( "homeassistant.components.ezviz.config_flow.TestRTSPAuth" ) as mock_ezviz_test_rtsp, ): - instance = mock_ezviz_test_rtsp.return_value = TestRTSPAuth( - "test-ip", - "test-username", - "test-password", - ) + instance = mock_ezviz_test_rtsp.return_value - instance.main = MagicMock(return_value=True) + instance.main.return_value = True - yield mock_ezviz_test_rtsp - - -@pytest.fixture -def ezviz_config_flow() -> Generator[MagicMock]: - """Mock the EzvizAPI for easier config flow testing.""" - with ( - patch.object(EzvizClient, "login", return_value=True), - patch("homeassistant.components.ezviz.config_flow.EzvizClient") as mock_ezviz, - ): - instance = mock_ezviz.return_value = EzvizClient( - "test-username", - "test-password", - "local.host", - "1", - ) - - instance.login = MagicMock(return_value=ezviz_login_token_return) - instance.get_detection_sensibility = MagicMock(return_value=True) - - yield mock_ezviz + yield instance diff --git a/tests/components/ezviz/test_config_flow.py b/tests/components/ezviz/test_config_flow.py index 63499996c89..ff538b31edb 100644 --- a/tests/components/ezviz/test_config_flow.py +++ b/tests/components/ezviz/test_config_flow.py @@ -1,11 +1,9 @@ """Test the EZVIZ config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock from pyezviz.exceptions import ( - AuthTestResultFailed, EzvizAuthVerificationCode, - HTTPError, InvalidHost, InvalidURL, PyEzvizError, @@ -15,7 +13,10 @@ import pytest from homeassistant.components.ezviz.const import ( ATTR_SERIAL, ATTR_TYPE_CAMERA, + ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, @@ -33,20 +34,14 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import ( - API_LOGIN_RETURN_VALIDATE, - DISCOVERY_INFO, - USER_INPUT_VALIDATE, - init_integration, - patch_async_setup_entry, -) +from . import setup_integration -from tests.common import MockConfigEntry, start_reauth_flow +from tests.common import MockConfigEntry -@pytest.mark.usefixtures("ezviz_config_flow") -async def test_user_form(hass: HomeAssistant) -> None: - """Test the user initiated form.""" +@pytest.mark.usefixtures("mock_ezviz_client") +async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test the full flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -55,28 +50,32 @@ async def test_user_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with patch_async_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT_VALIDATE, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: "apiieu.ezvizlife.com", + }, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" - assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + assert result["data"] == { + CONF_SESSION_ID: "fake_token", + CONF_RFSESSION_ID: "fake_rf_token", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, + } + assert result["result"].unique_id == "test-username" assert len(mock_setup_entry.mock_calls) == 1 - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured_account" - -@pytest.mark.usefixtures("ezviz_config_flow") -async def test_user_custom_url(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_ezviz_client") +async def test_user_custom_url( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test custom url step.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -95,45 +94,30 @@ async def test_user_custom_url(hass: HomeAssistant) -> None: assert result["step_id"] == "user_custom_url" assert result["errors"] == {} - with patch_async_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: "test-user"}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == API_LOGIN_RETURN_VALIDATE - - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.usefixtures("ezviz_config_flow") -async def test_async_step_reauth(hass: HomeAssistant) -> None: - """Test the reauth step.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "test-user"}, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - with patch_async_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT_VALIDATE, - ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test-username" - assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + assert result["data"] == { + CONF_SESSION_ID: "fake_token", + CONF_RFSESSION_ID: "fake_rf_token", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, + } assert len(mock_setup_entry.mock_calls) == 1 - new_entry = hass.config_entries.async_entries(DOMAIN)[0] - result = await start_reauth_flow(hass, new_entry) + +@pytest.mark.usefixtures("mock_ezviz_client", "mock_setup_entry") +async def test_async_step_reauth( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the reauth step.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} @@ -145,19 +129,26 @@ async def test_async_step_reauth(hass: HomeAssistant) -> None: CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" +@pytest.mark.usefixtures("mock_ezviz_client") async def test_step_discovery_abort_if_cloud_account_missing( - hass: HomeAssistant, + hass: HomeAssistant, mock_test_rtsp_auth: AsyncMock ) -> None: """Test discovery and confirm step, abort if cloud account was removed.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_INTEGRATION_DISCOVERY}, data=DISCOVERY_INFO + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={ + ATTR_SERIAL: "C666666", + CONF_USERNAME: None, + CONF_PASSWORD: None, + CONF_IP_ADDRESS: "127.0.0.1", + }, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" @@ -170,45 +161,52 @@ async def test_step_discovery_abort_if_cloud_account_missing( CONF_PASSWORD: "test-pass", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "ezviz_cloud_account_missing" -async def test_step_reauth_abort_if_cloud_account_missing(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_ezviz_client", "mock_test_rtsp_auth") +async def test_step_reauth_abort_if_cloud_account_missing( + hass: HomeAssistant, mock_camera_config_entry: MockConfigEntry +) -> None: """Test reauth and confirm step, abort if cloud account was removed.""" - entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT_VALIDATE) - entry.add_to_hass(hass) + mock_camera_config_entry.add_to_hass(hass) - result = await entry.start_reauth_flow(hass) + result = await mock_camera_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "ezviz_cloud_account_missing" -@pytest.mark.usefixtures("ezviz_config_flow", "ezviz_test_rtsp_config_flow") -async def test_async_step_integration_discovery(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_ezviz_client", "mock_test_rtsp_auth", "mock_setup_entry") +async def test_async_step_integration_discovery( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test discovery and confirm step.""" - with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): - await init_integration(hass) + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_INTEGRATION_DISCOVERY}, data=DISCOVERY_INFO + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={ + ATTR_SERIAL: "C666666", + CONF_USERNAME: None, + CONF_PASSWORD: None, + CONF_IP_ADDRESS: "127.0.0.1", + }, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" assert result["errors"] == {} - with patch_async_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-user", + CONF_PASSWORD: "test-pass", + }, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { @@ -216,40 +214,103 @@ async def test_async_step_integration_discovery(hass: HomeAssistant) -> None: CONF_TYPE: ATTR_TYPE_CAMERA, CONF_USERNAME: "test-user", } - - assert len(mock_setup_entry.mock_calls) == 1 + assert result["result"].unique_id == "C666666" -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test updating options.""" - with patch_async_setup_entry() as mock_setup_entry: - entry = await init_integration(hass) + await setup_integration(hass, mock_config_entry) - assert entry.options[CONF_FFMPEG_ARGUMENTS] == DEFAULT_FFMPEG_ARGUMENTS - assert entry.options[CONF_TIMEOUT] == DEFAULT_TIMEOUT + assert mock_config_entry.options[CONF_FFMPEG_ARGUMENTS] == DEFAULT_FFMPEG_ARGUMENTS + assert mock_config_entry.options[CONF_TIMEOUT] == DEFAULT_TIMEOUT - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] is None + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] is None - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_FFMPEG_ARGUMENTS: "/H.264", CONF_TIMEOUT: 25}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_FFMPEG_ARGUMENTS: "/H.264", CONF_TIMEOUT: 25}, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_FFMPEG_ARGUMENTS] == "/H.264" assert result["data"][CONF_TIMEOUT] == 25 + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidURL, "invalid_host"), + (InvalidHost, "cannot_connect"), + (EzvizAuthVerificationCode, "mfa_required"), + (PyEzvizError, "invalid_auth"), + ], +) +async def test_user_flow_errors( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test the full flow.""" + + 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"] == {} + + mock_ezviz_client.login.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: "apiieu.ezvizlife.com", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_ezviz_client.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: "apiieu.ezvizlife.com", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + CONF_SESSION_ID: "fake_token", + CONF_RFSESSION_ID: "fake_rf_token", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, + } + assert result["result"].unique_id == "test-username" + assert len(mock_setup_entry.mock_calls) == 1 -async def test_user_form_exception( - hass: HomeAssistant, ezviz_config_flow: MagicMock +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_unknown_exception( + hass: HomeAssistant, mock_ezviz_client: AsyncMock ) -> None: - """Test we handle exception on user form.""" + """Test the full flow.""" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -257,223 +318,53 @@ async def test_user_form_exception( assert result["step_id"] == "user" assert result["errors"] == {} - ezviz_config_flow.side_effect = PyEzvizError + mock_ezviz_client.login.side_effect = Exception result = await hass.config_entries.flow.async_configure( result["flow_id"], - USER_INPUT_VALIDATE, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} - - ezviz_config_flow.side_effect = InvalidURL - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT_VALIDATE, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_host"} - - ezviz_config_flow.side_effect = EzvizAuthVerificationCode - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT_VALIDATE, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "mfa_required"} - - ezviz_config_flow.side_effect = HTTPError - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT_VALIDATE, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} - - ezviz_config_flow.side_effect = Exception - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT_VALIDATE, + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: "apiieu.ezvizlife.com", + }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unknown" -async def test_discover_exception_step1( +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidURL, "invalid_host"), + (InvalidHost, "cannot_connect"), + (EzvizAuthVerificationCode, "mfa_required"), + (PyEzvizError, "invalid_auth"), + ], +) +async def test_user_custom_url_errors( hass: HomeAssistant, - ezviz_config_flow: MagicMock, + mock_ezviz_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, ) -> None: - """Test we handle unexpected exception on discovery.""" - with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): - await init_integration(hass) + """Test the full flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_INTEGRATION_DISCOVERY}, - data={ATTR_SERIAL: "C66666", CONF_IP_ADDRESS: "test-ip"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {} - - # Test Step 1 - ezviz_config_flow.side_effect = PyEzvizError - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_auth"} - - ezviz_config_flow.side_effect = InvalidURL - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_host"} - - ezviz_config_flow.side_effect = HTTPError - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_auth"} - - ezviz_config_flow.side_effect = EzvizAuthVerificationCode - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "mfa_required"} - - ezviz_config_flow.side_effect = Exception - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" - - -@pytest.mark.usefixtures("ezviz_config_flow") -async def test_discover_exception_step3( - hass: HomeAssistant, ezviz_test_rtsp_config_flow: MagicMock -) -> None: - """Test we handle unexpected exception on discovery.""" - with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): - await init_integration(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_INTEGRATION_DISCOVERY}, - data={ATTR_SERIAL: "C66666", CONF_IP_ADDRESS: "test-ip"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {} - - # Test Step 3 - ezviz_test_rtsp_config_flow.side_effect = AuthTestResultFailed - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_auth"} - - ezviz_test_rtsp_config_flow.side_effect = InvalidHost - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_host"} - - ezviz_test_rtsp_config_flow.side_effect = Exception - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" - - -async def test_user_custom_url_exception( - hass: HomeAssistant, ezviz_config_flow: MagicMock -) -> None: - """Test we handle unexpected exception.""" - ezviz_config_flow.side_effect = PyEzvizError() 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"] == {} + + mock_ezviz_client.login.side_effect = exception result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", CONF_URL: CONF_CUSTOMIZE, }, ) @@ -489,56 +380,33 @@ async def test_user_custom_url_exception( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user_custom_url" - assert result["errors"] == {"base": "invalid_auth"} + assert result["errors"] == {"base": error} - ezviz_config_flow.side_effect = InvalidURL + mock_ezviz_client.login.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_URL: "test-user"}, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user_custom_url" - assert result["errors"] == {"base": "invalid_host"} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + CONF_SESSION_ID: "fake_token", + CONF_RFSESSION_ID: "fake_rf_token", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, + } + assert result["result"].unique_id == "test-username" - ezviz_config_flow.side_effect = HTTPError - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: "test-user"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user_custom_url" - assert result["errors"] == {"base": "invalid_auth"} - - ezviz_config_flow.side_effect = EzvizAuthVerificationCode - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: "test-user"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user_custom_url" - assert result["errors"] == {"base": "mfa_required"} - - ezviz_config_flow.side_effect = Exception - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: "test-user"}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" + assert len(mock_setup_entry.mock_calls) == 1 -async def test_async_step_reauth_exception( - hass: HomeAssistant, ezviz_config_flow: MagicMock +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_custom_url_unknown_exception( + hass: HomeAssistant, mock_ezviz_client: AsyncMock ) -> None: - """Test the reauth step exceptions.""" + """Test the full flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -547,26 +415,210 @@ async def test_async_step_reauth_exception( assert result["step_id"] == "user" assert result["errors"] == {} - with patch_async_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT_VALIDATE, - ) - await hass.async_block_till_done() + mock_ezviz_client.login.side_effect = Exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: CONF_CUSTOMIZE, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user_custom_url" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "test-user"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_already_configured( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the flow when the account is already configured.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_account" + + +async def test_async_step_integration_discovery_duplicate( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_test_rtsp_auth: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_camera_config_entry: MockConfigEntry, +) -> None: + """Test discovery and confirm step.""" + mock_config_entry.add_to_hass(hass) + mock_camera_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={ + ATTR_SERIAL: "C666666", + CONF_USERNAME: None, + CONF_PASSWORD: None, + CONF_IP_ADDRESS: "127.0.0.1", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidURL, "invalid_host"), + (InvalidHost, "invalid_host"), + (EzvizAuthVerificationCode, "mfa_required"), + (PyEzvizError, "invalid_auth"), + ], +) +async def test_camera_errors( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_test_rtsp_auth: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test the camera flow with errors.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={ + ATTR_SERIAL: "C666666", + CONF_USERNAME: None, + CONF_PASSWORD: None, + CONF_IP_ADDRESS: "127.0.0.1", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["errors"] == {} + + mock_ezviz_client.login.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["errors"] == {"base": error} + + mock_ezviz_client.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test-username" - assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + assert result["title"] == "C666666" + assert result["data"] == { + CONF_TYPE: ATTR_TYPE_CAMERA, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert result["result"].unique_id == "C666666" - assert len(mock_setup_entry.mock_calls) == 1 - new_entry = hass.config_entries.async_entries(DOMAIN)[0] - result = await start_reauth_flow(hass, new_entry) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_camera_unknown_error( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_test_rtsp_auth: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the camera flow with errors.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={ + ATTR_SERIAL: "C666666", + CONF_USERNAME: None, + CONF_PASSWORD: None, + CONF_IP_ADDRESS: "127.0.0.1", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["errors"] == {} + + mock_ezviz_client.login.side_effect = Exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidURL, "invalid_host"), + (InvalidHost, "invalid_host"), + (EzvizAuthVerificationCode, "mfa_required"), + (PyEzvizError, "invalid_auth"), + ], +) +async def test_reauth_errors( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test the reauth step.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - ezviz_config_flow.side_effect = InvalidURL() + mock_ezviz_client.login.side_effect = exception + result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -574,13 +626,12 @@ async def test_async_step_reauth_exception( CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "invalid_host"} + assert result["errors"] == {"base": error} + + mock_ezviz_client.login.side_effect = None - ezviz_config_flow.side_effect = InvalidHost() result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -588,49 +639,33 @@ async def test_async_step_reauth_exception( CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "invalid_host"} - - ezviz_config_flow.side_effect = EzvizAuthVerificationCode() - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "mfa_required"} - - ezviz_config_flow.side_effect = PyEzvizError() - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "invalid_auth"} - - ezviz_config_flow.side_effect = Exception() - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_unknown_exception( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reauth step.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + mock_ezviz_client.login.side_effect = Exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unknown" From 8b24bac1d14106b049a88d0a361026cf0ca87d43 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 25 Jan 2025 11:28:52 +0100 Subject: [PATCH 0959/2987] Bump reolink_aio to 0.11.8 (#136504) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index bb6b668368b..83729fef3cd 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.11.6"] + "requirements": ["reolink-aio==0.11.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0dd95d65c98..99f5a43794d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2596,7 +2596,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.6 +reolink-aio==0.11.8 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 676466b8ac9..6bf4834dfc8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2099,7 +2099,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.6 +reolink-aio==0.11.8 # homeassistant.components.rflink rflink==0.0.66 From 71d63bac8dd07257679a4814e94814c32dbe4a8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 25 Jan 2025 12:22:45 +0100 Subject: [PATCH 0960/2987] Add TemperatureLevel feature from Matter TemperatureControl cluster (#134532) --- homeassistant/components/matter/icons.json | 5 + homeassistant/components/matter/select.py | 72 +- homeassistant/components/matter/strings.json | 3 + tests/components/matter/conftest.py | 1 + .../fixtures/nodes/silabs_laundrywasher.json | 909 ++++++++++++++++++ .../matter/snapshots/test_button.ambr | 231 +++++ .../matter/snapshots/test_select.ambr | 108 +++ .../matter/snapshots/test_sensor.ambr | 288 ++++++ tests/components/matter/test_select.py | 41 + 9 files changed, 1652 insertions(+), 6 deletions(-) create mode 100644 tests/components/matter/fixtures/nodes/silabs_laundrywasher.json diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index f000bad87dd..bd8665eb18b 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -36,6 +36,11 @@ } } }, + "select": { + "temperature_level": { + "default": "mdi:thermometer" + } + }, "sensor": { "contamination_state": { "default": "mdi:air-filter" diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index 06eb6f249eb..317c8515d4b 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -2,10 +2,12 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from chip.clusters import Objects as clusters +from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand from chip.clusters.Types import Nullable from matter_server.common.helpers.util import create_attribute_path_from_attribute @@ -47,7 +49,18 @@ class MatterSelectEntityDescription(SelectEntityDescription, MatterEntityDescrip """Describe Matter select entities.""" -class MatterSelectEntity(MatterEntity, SelectEntity): +@dataclass(frozen=True, kw_only=True) +class MatterListSelectEntityDescription(MatterSelectEntityDescription): + """Describe Matter select entities for MatterListSelectEntity.""" + + # command: a callback to create the command to send to the device + # the callback's argument will be the index of the selected list value + command: Callable[[int], ClusterCommand] + # list attribute: the attribute descriptor to get the list of values (= list of strings) + list_attribute: type[ClusterAttributeDescriptor] + + +class MatterAttributeSelectEntity(MatterEntity, SelectEntity): """Representation of a select entity from Matter Attribute read/write.""" entity_description: MatterSelectEntityDescription @@ -76,7 +89,7 @@ class MatterSelectEntity(MatterEntity, SelectEntity): self._attr_current_option = value_convert(value) -class MatterModeSelectEntity(MatterSelectEntity): +class MatterModeSelectEntity(MatterAttributeSelectEntity): """Representation of a select entity from Matter (Mode) Cluster attribute(s).""" async def async_select_option(self, option: str) -> None: @@ -111,6 +124,37 @@ class MatterModeSelectEntity(MatterSelectEntity): self._attr_name = desc +class MatterListSelectEntity(MatterEntity, SelectEntity): + """Representation of a select entity from Matter list and selected item Cluster attribute(s).""" + + entity_description: MatterListSelectEntityDescription + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + option_id = self._attr_options.index(option) + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=self.entity_description.command(option_id), + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + list_values = cast( + list[str], + self.get_matter_attribute_value(self.entity_description.list_attribute), + ) + self._attr_options = list_values + current_option_idx: int = self.get_matter_attribute_value( + self._entity_info.primary_attribute + ) + try: + self._attr_current_option = list_values[current_option_idx] + except IndexError: + self._attr_current_option = None + + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( @@ -230,7 +274,7 @@ DISCOVERY_SCHEMAS = [ "previous": None, }.get, ), - entity_class=MatterSelectEntity, + entity_class=MatterAttributeSelectEntity, required_attributes=(clusters.OnOff.Attributes.StartUpOnOff,), ), MatterDiscoverySchema( @@ -251,7 +295,7 @@ DISCOVERY_SCHEMAS = [ "low": 2, }.get, ), - entity_class=MatterSelectEntity, + entity_class=MatterAttributeSelectEntity, required_attributes=(clusters.SmokeCoAlarm.Attributes.SmokeSensitivityLevel,), ), MatterDiscoverySchema( @@ -270,9 +314,25 @@ DISCOVERY_SCHEMAS = [ "Fahrenheit": 1, }.get, ), - entity_class=MatterSelectEntity, + entity_class=MatterAttributeSelectEntity, required_attributes=( clusters.ThermostatUserInterfaceConfiguration.Attributes.TemperatureDisplayMode, ), ), + MatterDiscoverySchema( + platform=Platform.SELECT, + entity_description=MatterListSelectEntityDescription( + key="TemperatureControlSelectedTemperatureLevel", + translation_key="temperature_level", + command=lambda selected_index: clusters.TemperatureControl.Commands.SetTemperature( + targetTemperatureLevel=selected_index + ), + list_attribute=clusters.TemperatureControl.Attributes.SupportedTemperatureLevels, + ), + entity_class=MatterListSelectEntity, + required_attributes=( + clusters.TemperatureControl.Attributes.SelectedTemperatureLevel, + clusters.TemperatureControl.Attributes.SupportedTemperatureLevels, + ), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 6eb47248564..4054adba530 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -200,6 +200,9 @@ "previous": "Previous" } }, + "temperature_level": { + "name": "Temperature level" + }, "temperature_display_mode": { "name": "Temperature display mode" } diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index bbafec48e10..4e078f86939 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -104,6 +104,7 @@ async def integration_fixture( "pressure_sensor", "room_airconditioner", "silabs_dishwasher", + "silabs_laundrywasher", "smoke_detector", "switch_unit", "temperature_sensor", diff --git a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json new file mode 100644 index 00000000000..4d26dfb03aa --- /dev/null +++ b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json @@ -0,0 +1,909 @@ +{ + "node_id": 29, + "date_commissioned": "2024-10-19T19:49:36.900186", + "last_interview": "2024-10-20T09:26:38.517535", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [ + 29, 31, 40, 42, 43, 44, 45, 48, 49, 50, 51, 52, 53, 60, 62, 63, 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1, 2], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 4 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "Silabs", + "0/40/2": 65521, + "0/40/3": "LaundryWasher", + "0/40/4": 32773, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "", + "0/40/16": false, + "0/40/18": "DC840FF79F5DBFCE", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 16973824, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 3, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21, 22, + 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/45/0": 1, + "0/45/65532": 0, + "0/45/65533": 1, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "p0jbsOzJRNw=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "p0jbsOzJRNw=", + "0/49/7": null, + "0/49/9": 10, + "0/49/10": 4, + "0/49/65532": 2, + "0/49/65533": 2, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "MyHome", + "1": true, + "2": null, + "3": null, + "4": "GstaSerJSho=", + "5": [], + "6": [ + "/cS6oCynAAGilSC/p+bVSg==", + "/QANuACgAAAAAAD//gDIAA==", + "/QANuACgAABL3TOUNF1NGw==", + "/oAAAAAAAAAYy1pJ6slKGg==" + ], + "7": 4 + } + ], + "0/51/1": 10, + "0/51/2": 1934, + "0/51/3": 17, + "0/51/4": 6, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/0": [ + { + "0": 8, + "1": "shell", + "3": 324 + }, + { + "0": 3, + "1": "UART", + "3": 127 + }, + { + "0": 2, + "1": "OT Stack", + "3": 719 + }, + { + "0": 9, + "1": "LaundryW", + "3": 767 + }, + { + "0": 12, + "1": "Bluetoot", + "3": 174 + }, + { + "0": 1, + "1": "Bluetoot", + "3": 294 + }, + { + "0": 11, + "1": "Bluetoot", + "3": 216 + }, + { + "0": 6, + "1": "Tmr Svc", + "3": 586 + }, + { + "0": 5, + "1": "IDLE", + "3": 264 + }, + { + "0": 7, + "1": "CHIP", + "3": 699 + } + ], + "0/52/1": 99808, + "0/52/2": 17592, + "0/52/3": 4294959166, + "0/52/65532": 1, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [0], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/53/0": 25, + "0/53/1": 5, + "0/53/2": "MyHome", + "0/53/3": 4660, + "0/53/4": 12054125955590472924, + "0/53/5": "QP0ADbgAoAAA", + "0/53/6": 0, + "0/53/7": [ + { + "0": 7100699097952925053, + "1": 23, + "2": 15360, + "3": 222256, + "4": 71507, + "5": 2, + "6": -83, + "7": -90, + "8": 56, + "9": 3, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 9656160343072683744, + "1": 16, + "2": 17408, + "3": 211448, + "4": 95936, + "5": 3, + "6": -53, + "7": -59, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 5926511551178228101, + "1": 0, + "2": 19456, + "3": 420246, + "4": 89821, + "5": 3, + "6": -57, + "7": -56, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 3029834005214616809, + "1": 8, + "2": 22528, + "3": 125241, + "4": 91286, + "5": 3, + "6": -73, + "7": -81, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17459145101989614194, + "1": 7, + "2": 26624, + "3": 1426216, + "4": 36884, + "5": 3, + "6": -39, + "7": -39, + "8": 34, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17503311195895696084, + "1": 30, + "2": 29696, + "3": 577028, + "4": 98083, + "5": 2, + "6": -84, + "7": -85, + "8": 65, + "9": 20, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 8241705229565301122, + "1": 19, + "2": 57344, + "3": 488092, + "4": 55364, + "5": 3, + "6": -48, + "7": -48, + "8": 1, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 7100699097952925053, + "1": 15360, + "2": 15, + "3": 22, + "4": 1, + "5": 2, + "6": 2, + "7": 23, + "8": true, + "9": true + }, + { + "0": 9656160343072683744, + "1": 17408, + "2": 17, + "3": 19, + "4": 1, + "5": 3, + "6": 3, + "7": 16, + "8": true, + "9": true + }, + { + "0": 5926511551178228101, + "1": 19456, + "2": 19, + "3": 17, + "4": 1, + "5": 3, + "6": 3, + "7": 0, + "8": true, + "9": true + }, + { + "0": 3029834005214616809, + "1": 22528, + "2": 22, + "3": 17, + "4": 1, + "5": 3, + "6": 3, + "7": 8, + "8": true, + "9": true + }, + { + "0": 17459145101989614194, + "1": 26624, + "2": 26, + "3": 17, + "4": 1, + "5": 3, + "6": 3, + "7": 7, + "8": true, + "9": true + }, + { + "0": 17503311195895696084, + "1": 29696, + "2": 29, + "3": 26, + "4": 1, + "5": 2, + "6": 2, + "7": 30, + "8": true, + "9": true + }, + { + "0": 0, + "1": 51200, + "2": 50, + "3": 63, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": true, + "9": false + }, + { + "0": 8241705229565301122, + "1": 57344, + "2": 56, + "3": 17, + "4": 1, + "5": 3, + "6": 3, + "7": 19, + "8": true, + "9": true + } + ], + "0/53/9": 1348153998, + "0/53/10": 68, + "0/53/11": 49, + "0/53/12": 120, + "0/53/13": 56, + "0/53/14": 1, + "0/53/15": 0, + "0/53/16": 1, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 1, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 18798, + "0/53/23": 18683, + "0/53/24": 115, + "0/53/25": 18699, + "0/53/26": 18492, + "0/53/27": 115, + "0/53/28": 18814, + "0/53/29": 0, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 15745, + "0/53/34": 207, + "0/53/35": 0, + "0/53/36": 71, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 7183, + "0/53/40": 6295, + "0/53/41": 886, + "0/53/42": 6140, + "0/53/43": 0, + "0/53/44": 0, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 0, + "0/53/49": 1041, + "0/53/50": 0, + "0/53/51": 2, + "0/53/52": 0, + "0/53/53": 0, + "0/53/54": 0, + "0/53/55": 0, + "0/53/56": 65536, + "0/53/57": 0, + "0/53/58": 0, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "0/53/60": "AB//4A==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [], + "0/53/65532": 15, + "0/53/65533": 2, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRHRgkBwEkCAEwCUEEJu8N93WFULw4vts483kDAExYc3VhKuaWdmpdJnF5pDcls+y34i6RfchubiU77BJq8zo9VGn6J59mVROTzKgr0DcKNQEoARgkAgE2AwQCBAEYMAQUbJ+53QmsxXf2iP0oL4td/BQFi0gwBRRT9HTfU5Nds+HA8j+/MRP+0pVyIxgwC0BFzpzN0Z0DdN+oPUwK87jzZ8amzJxWlmbnW/Q+j1Z4ziWsFy3yLAsgKYL4nOexZZSqvlEvzMhpstndmh1eGYZfGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEyT62Yt4qMI+MorlmQ/Hxh2CpLetznVknlAbhvYAwTexpSxp9GnhR09SrcUhz3mOb0eZa2TylqcnPBhHJ2Ih2RTcKNQEpARgkAmAwBBRT9HTfU5Nds+HA8j+/MRP+0pVyIzAFFOMCO8Jk7ZCknJquFGPtPzJiNqsDGDALQI/Kc38hQyK7AkT7/pN4hiYW3LoWKT3NA43+ssMJoVpDcaZ989GXBQKIbHKbBEXzUQ1J8wfL7l2pL0Z8Lso9JwgY", + "254": 4 + } + ], + "0/62/1": [ + { + "1": "BIrruNo7r0gX6j6lq1dDi5zeK3jxcTavjt2o4adCCSCYtbxOakfb7C3GXqgV4LzulFSinbewmYkdqFBHqm5pxvU=", + "2": 4939, + "3": 2, + "4": 29, + "5": "", + "254": 4 + } + ], + "0/62/2": 5, + "0/62/3": 4, + "0/62/4": [ + "FTABAQAkAgE3AyYUyakYCSYVj6gLsxgmBP2G+CskBQA3BiYUyakYCSYVj6gLsxgkBwEkCAEwCUEEgYwxrTB+tyiEGfrRwjlXTG34MiQtJXbg5Qqd0ohdRW7MfwYY7vZiX/0h9hI8MqUralFaVPcnghAP0MSJm1YrqTcKNQEpARgkAmAwBBS3BS9aJzt+p6i28Nj+trB2Uu+vdzAFFLcFL1onO36nqLbw2P62sHZS7693GDALQIrLt7Uq3S9HEe7apdzYSR+j3BLWNXSTLWD4YbrdyYLpm6xqHDV/NPARcIp4skZdtz91WwFBDfuS4jO5aVoER1sY", + "FTABAQAkAgE3AycUQhmZbaIbYjokFQIYJgRWZLcqJAUANwYnFEIZmW2iG2I6JBUCGCQHASQIATAJQQT2AlKGW/kOMjqayzeO0md523/fuhrhGEUU91uQpTiKo0I7wcPpKnmrwfQNPX6g0kEQl+VGaXa3e22lzfu5Tzp0Nwo1ASkBGCQCYDAEFOOMk13ScMKuT2hlaydi1yEJnhTqMAUU44yTXdJwwq5PaGVrJ2LXIQmeFOoYMAtAv2jJd1qd5miXbYesH1XrJ+vgyY0hzGuZ78N6Jw4Cb1oN1sLSpA+PNM0u7+hsEqcSvvn2eSV8EaRR+hg5YQjHDxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEE0j40vjcb6ZsmtBR/I0rB3ZIfAA8lPeWCTxG7nPSbNpepe18XwLidhFIHKmvtZWDZ3Hl3MM9NBB+LAZlCFq/edjcKNQEpARgkAmAwBBS7EfW886qYxvWeWjpA/G/CjDuwEDAFFLsR9bzzqpjG9Z5aOkD8b8KMO7AQGDALQIgQgt5asUGXO0ZyTWWKdjAmBSoJAzRMuD4Z+tQYZanQ3s0OItL07MU2In6uyXhjNBfjJlRqon780lhjTsm2Y+8Y", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEiuu42juvSBfqPqWrV0OLnN4rePFxNq+O3ajhp0IJIJi1vE5qR9vsLcZeqBXgvO6UVKKdt7CZiR2oUEeqbmnG9TcKNQEpARgkAmAwBBTjAjvCZO2QpJyarhRj7T8yYjarAzAFFOMCO8Jk7ZCknJquFGPtPzJiNqsDGDALQE7hTxTRg92QOxwA1hK3xv8DaxvxL71r6ZHcNRzug9wNnonJ+NC84SFKvKDxwcBxHYqFdIyDiDgwJNTQIBgasmIY" + ], + "0/62/5": 4, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/64/0": [ + { + "0": "room", + "1": "bedroom 2" + }, + { + "0": "orientation", + "1": "North" + }, + { + "0": "floor", + "1": "2" + }, + { + "0": "direction", + "1": "up" + } + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 115, + "1": 1 + } + ], + "1/29/1": [3, 29, 30, 81, 83, 86, 96], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/30/0": [], + "1/30/65532": 0, + "1/30/65533": 1, + "1/30/65528": [], + "1/30/65529": [], + "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/81/0": null, + "1/81/1": null, + "1/81/65532": null, + "1/81/65533": 2, + "1/81/65528": [1], + "1/81/65529": [0], + "1/81/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/83/0": ["Off", "Low", "Medium", "High"], + "1/83/1": 0, + "1/83/2": 0, + "1/83/3": [1, 2], + "1/83/65532": 3, + "1/83/65533": 1, + "1/83/65528": [], + "1/83/65529": [], + "1/83/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/86/0": 0, + "1/86/1": 0, + "1/86/2": 0, + "1/86/3": 0, + "1/86/4": 1, + "1/86/5": ["Cold", "Colors", "Whites"], + "1/86/65532": 2, + "1/86/65533": 1, + "1/86/65528": [], + "1/86/65529": [0], + "1/86/65531": [4, 5, 65528, 65529, 65531, 65532, 65533], + "1/96/0": null, + "1/96/1": null, + "1/96/3": [ + { + "0": 0 + }, + { + "0": 1 + }, + { + "0": 2 + }, + { + "0": 3 + } + ], + "1/96/4": 0, + "1/96/5": { + "0": 0 + }, + "1/96/65532": 0, + "1/96/65533": 1, + "1/96/65528": [4], + "1/96/65529": [0, 1, 2, 3], + "1/96/65531": [0, 1, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "2/29/0": [ + { + "0": 1296, + "1": 1 + } + ], + "2/29/1": [29, 144, 145, 156], + "2/29/2": [], + "2/29/3": [], + "2/29/65532": 0, + "2/29/65533": 2, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/144/0": 2, + "2/144/1": 3, + "2/144/2": [ + { + "0": 5, + "1": true, + "2": -50000000, + "3": 50000000, + "4": [ + { + "0": -50000000, + "1": -10000000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -9999999, + "1": 9999999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 10000000, + "1": 50000000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 2, + "1": true, + "2": -100000, + "3": 100000, + "4": [ + { + "0": -100000, + "1": -5000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -4999, + "1": 4999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 5000, + "1": 100000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 1, + "1": true, + "2": -500000, + "3": 500000, + "4": [ + { + "0": -500000, + "1": -100000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -99999, + "1": 99999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 100000, + "1": 500000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + } + ], + "2/144/3": [ + { + "0": 0, + "1": 0, + "2": 300, + "7": 129, + "8": 129, + "9": 129, + "10": 129 + }, + { + "0": 1, + "1": 0, + "2": 500, + "7": 129, + "8": 129, + "9": 129, + "10": 129 + }, + { + "0": 2, + "1": 0, + "2": 1000, + "7": 129, + "8": 129, + "9": 129, + "10": 129 + } + ], + "2/144/4": 120000, + "2/144/5": 0, + "2/144/6": 0, + "2/144/7": 0, + "2/144/8": 0, + "2/144/9": 0, + "2/144/10": 0, + "2/144/11": 120000, + "2/144/12": 0, + "2/144/13": 0, + "2/144/14": 60, + "2/144/15": [ + { + "0": 1, + "1": 100000 + } + ], + "2/144/16": [ + { + "0": 1, + "1": 100000 + } + ], + "2/144/17": 9800, + "2/144/18": 0, + "2/144/65532": 31, + "2/144/65533": 1, + "2/144/65528": [], + "2/144/65529": [], + "2/144/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 65528, + 65529, 65531, 65532, 65533 + ], + "2/145/0": { + "0": 14, + "1": true, + "2": 0, + "3": 1000000000000000, + "4": [ + { + "0": 0, + "1": 1000000000000000, + "2": 500, + "3": 50 + } + ] + }, + "2/145/1": { + "0": 0, + "1": 1900, + "2": 1936, + "3": 1900222, + "4": 1936790 + }, + "2/145/5": { + "0": 0, + "1": 0, + "2": 0, + "3": 0 + }, + "2/145/65532": 5, + "2/145/65533": 1, + "2/145/65528": [], + "2/145/65529": [], + "2/145/65531": [0, 1, 5, 65528, 65529, 65531, 65532, 65533], + "2/156/0": [0, 1, 2], + "2/156/1": null, + "2/156/65532": 12, + "2/156/65533": 1, + "2/156/65528": [], + "2/156/65529": [], + "2/156/65531": [0, 1, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 10792b58d28..bcba0da808e 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -2340,6 +2340,237 @@ 'state': 'unknown', }) # --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.laundrywasher_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'LaundryWasher Identify', + }), + 'context': , + 'entity_id': 'button.laundrywasher_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_pause-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.laundrywasher_pause', + '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': 'Pause', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pause', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStatePauseButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_pause-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Pause', + }), + 'context': , + 'entity_id': 'button.laundrywasher_pause', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_resume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.laundrywasher_resume', + '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': 'Resume', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'resume', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStateResumeButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_resume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Resume', + }), + 'context': , + 'entity_id': 'button.laundrywasher_resume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.laundrywasher_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': 'Start', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStateStartButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Start', + }), + 'context': , + 'entity_id': 'button.laundrywasher_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.laundrywasher_stop', + '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': 'Stop', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStateStopButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Stop', + }), + 'context': , + 'entity_id': 'button.laundrywasher_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[smoke_detector][button.smoke_sensor_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 4c2d7dd3e06..19a90503086 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -1569,6 +1569,114 @@ 'state': 'unknown', }) # --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.laundrywasher_mode', + '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': 'Mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mode', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-MatterLaundryWasherMode-81-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Mode', + 'options': list([ + ]), + }), + 'context': , + 'entity_id': 'select.laundrywasher_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_temperature_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Cold', + 'Colors', + 'Whites', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.laundrywasher_temperature_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': 'Temperature level', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_level', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-TemperatureControlSelectedTemperatureLevel-86-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_temperature_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Temperature level', + 'options': list([ + 'Cold', + 'Colors', + 'Whites', + ]), + }), + 'context': , + 'entity_id': 'select.laundrywasher_temperature_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Colors', + }) +# --- # name: test_selects[switch_unit][select.mock_switchunit_power_on_behavior_on_startup-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index fc0c80230fb..205cba68d7c 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -2711,6 +2711,294 @@ 'state': '120.0', }) # --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundrywasher_current', + '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': 'Current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'LaundryWasher Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundrywasher_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalEnergyMeasurementCumulativeEnergyImported-145-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'LaundryWasher Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_operational_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.laundrywasher_operational_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': 'Operational state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_state', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalState-96-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_operational_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'LaundryWasher Operational state', + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + ]), + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_operational_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stopped', + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundrywasher_power', + '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': 'Power', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementWatt-144-8', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'LaundryWasher Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundrywasher_voltage', + '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': 'Voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'LaundryWasher Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120.0', + }) +# --- # name: test_sensors[smoke_detector][sensor.smoke_sensor_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index ffe996fd840..3643aa83fca 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -103,3 +103,44 @@ async def test_attribute_select_entities( await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state.state == "unknown" + + +@pytest.mark.parametrize("node_fixture", ["silabs_laundrywasher"]) +async def test_list_select_entities( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test ListSelect entities are discovered and working from a laundrywasher fixture.""" + state = hass.states.get("select.laundrywasher_temperature_level") + assert state + assert state.state == "Colors" + assert state.attributes["options"] == ["Cold", "Colors", "Whites"] + # Change temperature_level + set_node_attribute(matter_node, 1, 86, 4, 0) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_temperature_level") + assert state.state == "Cold" + # test select option + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.laundrywasher_temperature_level", + "option": "Whites", + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.TemperatureControl.Commands.SetTemperature( + targetTemperatureLevel=2 + ), + ) + # test that an invalid value (e.g. 253) leads to an unknown state + set_node_attribute(matter_node, 1, 86, 4, 253) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_temperature_level") + assert state.state == "unknown" From 05bdfe7aa6bdfa353980afcc3f91e043c27f8a1e Mon Sep 17 00:00:00 2001 From: TimL Date: Sat, 25 Jan 2025 23:17:38 +1100 Subject: [PATCH 0961/2987] Abort config flow is device is unsupported (#136505) * Abort config flow if device is not yet supported * Abort on user step for unsupported device * Add string for unsupported device * fix tests due to extra get_info calls * add tests for unsupported devices to abort flow --- .../components/smlight/config_flow.py | 16 +++ homeassistant/components/smlight/strings.json | 3 +- tests/components/smlight/test_config_flow.py | 100 ++++++++++++++++-- 3 files changed, 112 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index 1a222f1b21f..dee81264fa4 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -6,6 +6,7 @@ from collections.abc import Mapping from typing import Any from pysmlight import Api2 +from pysmlight.const import Devices from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError import voluptuous as vol @@ -51,6 +52,11 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): self.client = Api2(self.host, session=async_get_clientsession(self.hass)) 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): return await self._async_complete_entry(user_input) except SmlightConnectionError: @@ -70,6 +76,11 @@ 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): return await self._async_complete_entry(user_input) except SmlightConnectionError: @@ -116,6 +127,11 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: user_input[CONF_HOST] = self.host 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): return await self._async_complete_entry(user_input) diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 1e6a533beef..21ff5098d27 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -38,7 +38,8 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "reauth_failed": "[%key:common::config_flow::error::invalid_auth%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unsupported_device": "This device is not yet supported by the SMLIGHT integration" } }, "entity": { diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index 146f8e268a4..c4aea195aa7 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -3,6 +3,7 @@ from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock +from pysmlight import Info from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError import pytest @@ -97,7 +98,7 @@ async def test_zeroconf_flow( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 1 + assert len(mock_smlight_client.get_info.mock_calls) == 2 async def test_zeroconf_flow_auth( @@ -151,12 +152,99 @@ async def test_zeroconf_flow_auth( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 1 + assert len(mock_smlight_client.get_info.mock_calls) == 3 + + +async def test_zeroconf_unsupported_abort( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test we abort zeroconf flow if device unsupported.""" + mock_smlight_client.get_info.return_value = Info(model="SLZB-X") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unsupported_device" + + +async def test_user_unsupported_abort( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test we abort user flow if unsupported device.""" + mock_smlight_client.get_info.return_value = Info(model="SLZB-X") + + 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: MOCK_HOST, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unsupported_device" + + +async def test_user_unsupported_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, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: MOCK_HOST, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = Info(model="SLZB-X") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unsupported_device" @pytest.mark.usefixtures("mock_smlight_client") async def test_user_device_exists_abort( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, ) -> None: """Test we abort user flow if device already configured.""" mock_config_entry.add_to_hass(hass) @@ -239,7 +327,7 @@ async def test_user_invalid_auth( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 1 + assert len(mock_smlight_client.get_info.mock_calls) == 4 async def test_user_cannot_connect( @@ -276,7 +364,7 @@ async def test_user_cannot_connect( assert result2["title"] == "SLZB-06p7" assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 1 + assert len(mock_smlight_client.get_info.mock_calls) == 3 async def test_auth_cannot_connect( @@ -378,7 +466,7 @@ async def test_zeroconf_legacy_mac( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 2 + assert len(mock_smlight_client.get_info.mock_calls) == 3 async def test_reauth_flow( From 2db301fab9809ef15bdf1d4c607fc27a1be7b84f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 25 Jan 2025 17:53:27 +0100 Subject: [PATCH 0962/2987] Fix Spotify flaky test (#136529) --- tests/components/spotify/test_media_player.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/spotify/test_media_player.py b/tests/components/spotify/test_media_player.py index a6f686475c7..456af43d411 100644 --- a/tests/components/spotify/test_media_player.py +++ b/tests/components/spotify/test_media_player.py @@ -678,6 +678,7 @@ async def test_smart_polling_interval( freezer: FrozenDateTimeFactory, ) -> None: """Test the Spotify media player polling interval.""" + freezer.move_to("2023-10-21") mock_spotify.return_value.get_playback.return_value.progress_ms = 10000 mock_spotify.return_value.get_playback.return_value.item.duration_ms = 30000 From 2fb85aab8e13aa343ebd0451148a437fa6d1cde2 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 25 Jan 2025 11:04:33 -0600 Subject: [PATCH 0963/2987] Incorporate GroupManager into HEOS Coordinator (#136462) * Incorporate GroupManager * Update quality scale * Fix group params * Revert quality scale change * Rename varaible * Move group action implementaton out of coordinator * Fix get_group_members hass access * entity -> entity_id --- homeassistant/components/heos/__init__.py | 166 +----------------- homeassistant/components/heos/config_flow.py | 8 +- homeassistant/components/heos/const.py | 2 - homeassistant/components/heos/coordinator.py | 18 ++ homeassistant/components/heos/media_player.py | 116 +++++++----- .../components/heos/quality_scale.yaml | 2 +- homeassistant/components/heos/services.py | 2 +- homeassistant/components/heos/strings.json | 6 + tests/components/heos/conftest.py | 4 +- tests/components/heos/test_media_player.py | 119 ++++++++++--- 10 files changed, 202 insertions(+), 241 deletions(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 2830e70b3af..10fd2bfcff3 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -2,27 +2,17 @@ from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta -import logging -from typing import Any - -from pyheos import Heos, HeosError, HeosPlayer, const as heos_const from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) from homeassistant.helpers.typing import ConfigType from . import services -from .const import DOMAIN, SIGNAL_HEOS_PLAYER_ADDED, SIGNAL_HEOS_UPDATED +from .const import DOMAIN from .coordinator import HeosCoordinator PLATFORMS = [Platform.MEDIA_PLAYER] @@ -31,19 +21,7 @@ MIN_UPDATE_SOURCES = timedelta(seconds=1) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -_LOGGER = logging.getLogger(__name__) - - -@dataclass -class HeosRuntimeData: - """Runtime data and coordinators for HEOS config entries.""" - - coordinator: HeosCoordinator - group_manager: GroupManager - players: dict[int, HeosPlayer] - - -type HeosConfigEntry = ConfigEntry[HeosRuntimeData] +type HeosConfigEntry = ConfigEntry[HeosCoordinator] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -72,16 +50,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool coordinator = HeosCoordinator(hass, entry) await coordinator.async_setup() - # Preserve existing logic until migrated into coordinator - controller = coordinator.heos - players = controller.players - - group_manager = GroupManager(hass, controller, players) - - entry.runtime_data = HeosRuntimeData(coordinator, group_manager, players) - - group_manager.connect_update() - entry.async_on_unload(group_manager.disconnect_update) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -91,130 +60,3 @@ 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) - - -class GroupManager: - """Class that manages HEOS groups.""" - - def __init__( - self, hass: HomeAssistant, controller: Heos, players: dict[int, HeosPlayer] - ) -> None: - """Init group manager.""" - self._hass = hass - self._group_membership: dict[str, list[str]] = {} - self._disconnect_player_added = None - self._initialized = False - self.controller = controller - self.players = players - self.entity_id_map: dict[int, str] = {} - - def _get_entity_id_to_player_id_map(self) -> dict: - """Return mapping of all HeosMediaPlayer entity_ids to player_ids.""" - return {v: k for k, v in self.entity_id_map.items()} - - async def async_get_group_membership(self) -> dict[str, list[str]]: - """Return all group members for each player as entity_ids.""" - group_info_by_entity_id: dict[str, list[str]] = { - player_entity_id: [] - for player_entity_id in self._get_entity_id_to_player_id_map() - } - - try: - groups = await self.controller.get_groups() - except HeosError as err: - _LOGGER.error("Unable to get HEOS group info: %s", err) - return group_info_by_entity_id - - player_id_to_entity_id_map = self.entity_id_map - for group in groups.values(): - leader_entity_id = player_id_to_entity_id_map.get(group.lead_player_id) - member_entity_ids = [ - player_id_to_entity_id_map[member] - for member in group.member_player_ids - if member in player_id_to_entity_id_map - ] - # Make sure the group leader is always the first element - group_info = [leader_entity_id, *member_entity_ids] - if leader_entity_id: - group_info_by_entity_id[leader_entity_id] = group_info # type: ignore[assignment] - for member_entity_id in member_entity_ids: - group_info_by_entity_id[member_entity_id] = group_info # type: ignore[assignment] - - return group_info_by_entity_id - - async def async_join_players( - self, leader_id: int, member_entity_ids: list[str] - ) -> None: - """Create a group a group leader and member players.""" - # Resolve HEOS player_id for each member entity_id - entity_id_to_player_id_map = self._get_entity_id_to_player_id_map() - member_ids: list[int] = [] - for member in member_entity_ids: - member_id = entity_id_to_player_id_map.get(member) - if not member_id: - raise HomeAssistantError( - f"The group member {member} could not be resolved to a HEOS player." - ) - member_ids.append(member_id) - - await self.controller.create_group(leader_id, member_ids) - - async def async_unjoin_player(self, player_id: int): - """Remove `player_entity_id` from any group.""" - await self.controller.create_group(player_id, []) - - async def async_update_groups(self) -> None: - """Update the group membership from the controller.""" - if groups := await self.async_get_group_membership(): - self._group_membership = groups - _LOGGER.debug("Groups updated due to change event") - # Let players know to update - async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) - else: - _LOGGER.debug("Groups empty") - - @callback - def connect_update(self): - """Connect listener for when groups change and signal player update.""" - - async def _on_controller_event(event: str, data: Any | None) -> None: - if event == heos_const.EVENT_GROUPS_CHANGED: - await self.async_update_groups() - - self.controller.add_on_controller_event(_on_controller_event) - self.controller.add_on_connected(self.async_update_groups) - - # When adding a new HEOS player we need to update the groups. - async def _async_handle_player_added(): - # Avoid calling async_update_groups when the entity_id map has not been - # fully populated yet. This may only happen during early startup. - if len(self.players) <= len(self.entity_id_map) and not self._initialized: - self._initialized = True - await self.async_update_groups() - - self._disconnect_player_added = async_dispatcher_connect( - self._hass, SIGNAL_HEOS_PLAYER_ADDED, _async_handle_player_added - ) - - @callback - def disconnect_update(self): - """Disconnect the listeners.""" - if self._disconnect_player_added: - self._disconnect_player_added() - self._disconnect_player_added = None - - @callback - def register_media_player(self, player_id: int, entity_id: str) -> CALLBACK_TYPE: - """Register a media player player_id with it's entity_id so it can be resolved later.""" - self.entity_id_map[player_id] = entity_id - return lambda: self.unregister_media_player(player_id) - - @callback - def unregister_media_player(self, player_id) -> None: - """Remove a media player player_id from the entity_id map.""" - self.entity_id_map.pop(player_id, None) - - @property - def group_membership(self): - """Provide access to group members for player entities.""" - return self._group_membership diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index 335b64977b8..18b8f1f7918 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -188,9 +188,7 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): entry: HeosConfigEntry = self._get_reauth_entry() if user_input is not None: assert entry.state is ConfigEntryState.LOADED - if await _validate_auth( - user_input, entry.runtime_data.coordinator.heos, errors - ): + if await _validate_auth(user_input, entry.runtime_data.heos, errors): return self.async_update_reload_and_abort(entry, options=user_input) return self.async_show_form( @@ -212,9 +210,7 @@ class HeosOptionsFlowHandler(OptionsFlow): errors: dict[str, str] = {} if user_input is not None: entry: HeosConfigEntry = self.config_entry - if await _validate_auth( - user_input, entry.runtime_data.coordinator.heos, errors - ): + if await _validate_auth(user_input, entry.runtime_data.heos, errors): return self.async_create_entry(data=user_input) return self.async_show_form( diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index 9573306905f..7f03fa11e79 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -5,5 +5,3 @@ ATTR_USERNAME = "username" DOMAIN = "heos" SERVICE_SIGN_IN = "sign_in" SERVICE_SIGN_OUT = "sign_out" -SIGNAL_HEOS_PLAYER_ADDED = "heos_player_added" -SIGNAL_HEOS_UPDATED = "heos_updated" diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index c3c645ea1fa..8ed8449685a 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -5,6 +5,7 @@ The coordinator is responsible for refreshing data in response to system-wide ev entities to update. Entities subscribe to entity-specific updates within the entity class itself. """ +from collections.abc import Callable from datetime import datetime, timedelta import logging @@ -81,6 +82,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]): "The HEOS System is not logged in: Enter credentials in the integration options to access favorites and streaming services" ) # Retrieve initial data + await self._async_update_groups() await self._async_update_sources() # Attach event callbacks self.heos.add_on_disconnected(self._async_on_disconnected) @@ -93,6 +95,13 @@ class HeosCoordinator(DataUpdateCoordinator[None]): await self.heos.disconnect() await super().async_shutdown() + def async_add_listener(self, update_callback, context=None) -> Callable[[], None]: + """Add a listener for the coordinator.""" + remove_listener = super().async_add_listener(update_callback, context) + # Update entities so group_member entity_ids fully populate. + self.async_update_listeners() + return remove_listener + async def _async_on_auth_failure(self) -> None: """Handle when the user credentials are no longer valid.""" assert self.config_entry is not None @@ -118,6 +127,8 @@ class HeosCoordinator(DataUpdateCoordinator[None]): assert data is not None if data.updated_player_ids: self._async_update_player_ids(data.updated_player_ids) + elif event == const.EVENT_GROUPS_CHANGED: + await self._async_update_players() elif ( event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED) and not self._update_sources_pending @@ -176,6 +187,13 @@ class HeosCoordinator(DataUpdateCoordinator[None]): ) _LOGGER.debug("Updated entity %s unique id to %s", entity_id, new_id) + async def _async_update_groups(self) -> None: + """Update group information.""" + try: + await self.heos.get_groups(refresh=True) + except HeosError as error: + _LOGGER.error("Unable to retrieve groups: %s", error) + async def _async_update_sources(self) -> None: """Build source list for entities.""" self._source_list.clear() diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index e5ce39a1773..d405b235f76 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -29,19 +29,17 @@ from homeassistant.components.media_player import ( RepeatMode, async_process_play_media_url, ) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow -from . import GroupManager, HeosConfigEntry -from .const import DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_PLAYER_ADDED, SIGNAL_HEOS_UPDATED +from . import HeosConfigEntry +from .const import DOMAIN as HEOS_DOMAIN from .coordinator import HeosCoordinator PARALLEL_UPDATES = 0 @@ -92,14 +90,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: HeosConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Add media players for a config entry.""" - players = entry.runtime_data.players devices = [ - HeosMediaPlayer( - entry.runtime_data.coordinator, - player, - entry.runtime_data.group_manager, - ) - for player in players.values() + HeosMediaPlayer(entry.runtime_data, player) + for player in entry.runtime_data.heos.players.values() ] async_add_entities(devices) @@ -139,16 +132,10 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): _attr_has_entity_name = True _attr_name = None - def __init__( - self, - coordinator: HeosCoordinator, - player: HeosPlayer, - group_manager: GroupManager, - ) -> None: + def __init__(self, coordinator: HeosCoordinator, player: HeosPlayer) -> None: """Initialize.""" self._media_position_updated_at = None self._player: HeosPlayer = player - self._group_manager = group_manager self._attr_unique_id = str(player.player_id) model_parts = player.model.split(maxsplit=1) manufacturer = model_parts[0] if len(model_parts) == 2 else "HEOS" @@ -162,7 +149,6 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): sw_version=player.version, ) super().__init__(coordinator, context=player.player_id) - self._update_attributes() async def _player_update(self, event): """Handle player attribute updated.""" @@ -176,8 +162,31 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): self._update_attributes() super()._handle_coordinator_update() + @callback + def _get_group_members(self) -> list[str] | None: + """Get group member entity IDs for the group.""" + if self._player.group_id is None: + return None + if not (group := self.coordinator.heos.groups.get(self._player.group_id)): + return None + player_ids = [group.lead_player_id, *group.member_player_ids] + # Resolve player_ids to entity_ids + entity_registry = er.async_get(self.hass) + entity_ids = [ + entity_id + for member_id in player_ids + if ( + entity_id := entity_registry.async_get_entity_id( + Platform.MEDIA_PLAYER, HEOS_DOMAIN, str(member_id) + ) + ) + ] + return entity_ids or None + + @callback def _update_attributes(self) -> None: """Update core attributes of the media player.""" + self._attr_group_members = self._get_group_members() self._attr_source_list = self.coordinator.async_get_source_list() self._attr_source = self.coordinator.async_get_current_source( self._player.now_playing_media @@ -197,20 +206,8 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): async def async_added_to_hass(self) -> None: """Device added to hass.""" # Update state when attributes of the player change + self._update_attributes() self.async_on_remove(self._player.add_on_player_event(self._player_update)) - # Update state when heos changes - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_HEOS_UPDATED, self._handle_coordinator_update - ) - ) - # Register this player's entity_id so it can be resolved by the group manager - self.async_on_remove( - self._group_manager.register_media_player( - self._player.player_id, self.entity_id - ) - ) - async_dispatcher_send(self.hass, SIGNAL_HEOS_PLAYER_ADDED) await super().async_added_to_hass() @catch_action_error("clear playlist") @@ -218,13 +215,6 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """Clear players playlist.""" await self._player.clear_queue() - @catch_action_error("join players") - async def async_join_players(self, group_members: list[str]) -> None: - """Join `group_members` as a player group with the current player.""" - await self._group_manager.async_join_players( - self._player.player_id, group_members - ) - @catch_action_error("pause") async def async_media_pause(self) -> None: """Send pause command.""" @@ -335,10 +325,45 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """Set volume level, range 0..1.""" await self._player.set_volume(int(volume * 100)) + @catch_action_error("join players") + async def async_join_players(self, group_members: list[str]) -> None: + """Join `group_members` as a player group with the current player.""" + player_ids: list[int] = [self._player.player_id] + # Resolve entity_ids to player_ids + entity_registry = er.async_get(self.hass) + for entity_id in group_members: + entity_entry = entity_registry.async_get(entity_id) + if entity_entry is None: + raise ServiceValidationError( + translation_domain=HEOS_DOMAIN, + translation_key="entity_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + if entity_entry.platform != HEOS_DOMAIN: + raise ServiceValidationError( + translation_domain=HEOS_DOMAIN, + translation_key="not_heos_media_player", + translation_placeholders={"entity_id": entity_id}, + ) + player_id = int(entity_entry.unique_id) + if player_id not in player_ids: + player_ids.append(player_id) + await self.coordinator.heos.set_group(player_ids) + @catch_action_error("unjoin player") async def async_unjoin_player(self) -> None: """Remove this player from any group.""" - await self._group_manager.async_unjoin_player(self._player.player_id) + for group in self.coordinator.heos.groups.values(): + if group.lead_player_id == self._player.player_id: + # Player is the group leader, this effectively removes the group. + await self.coordinator.heos.set_group([self._player.player_id]) + return + if self._player.player_id in group.member_player_ids: + # Player is a group member, update the group to exclude it + new_members = [group.lead_player_id, *group.member_player_ids] + new_members.remove(self._player.player_id) + await self.coordinator.heos.set_group(new_members) + return @property def available(self) -> bool: @@ -356,11 +381,6 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): "media_type": self._player.now_playing_media.type, } - @property - def group_members(self) -> list[str]: - """List of players which are grouped together.""" - return self._group_manager.group_membership.get(self.entity_id, []) - @property def is_volume_muted(self) -> bool: """Boolean if volume is currently muted.""" diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index 2cd0ccaf567..d48bcc492cd 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -5,7 +5,7 @@ rules: status: done comment: Integration is a local push integration brands: done - common-modules: todo + common-modules: done config-flow-test-coverage: done config-flow: status: done diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index 00be409869a..5a0105f830e 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -64,7 +64,7 @@ def _get_controller(hass: HomeAssistant) -> Heos: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="integration_not_loaded" ) - return entry.runtime_data.coordinator.heos + return entry.runtime_data.heos async def _sign_in_handler(service: ServiceCall) -> None: diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index e99d8f7e7fb..907804d10e1 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -94,9 +94,15 @@ "action_error": { "message": "Unable to {action}: {error}" }, + "entity_not_found": { + "message": "Entity {entity_id} was not found" + }, "integration_not_loaded": { "message": "The HEOS integration is not loaded" }, + "not_heos_media_player": { + "message": "Entity {entity_id} is not a HEOS media player entity" + }, "unknown_source": { "message": "Unknown source: {source}" } diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 1a363d64aeb..122467c6b02 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -87,7 +87,8 @@ async def controller_fixture( mock_heos.load_players = AsyncMock(return_value=change_data) mock_heos._signed_in_username = "user@user.com" mock_heos.get_groups = AsyncMock(return_value=group) - mock_heos.create_group = AsyncMock(return_value=None) + mock_heos._groups = group + mock_heos.set_group = AsyncMock(return_value=None) new_mock = Mock(return_value=mock_heos) mock_heos.new_mock = new_mock with ( @@ -104,6 +105,7 @@ def players_fixture(quick_selects: dict[int, str]) -> dict[int, HeosPlayer]: for i in (1, 2): player = HeosPlayer( player_id=i, + group_id=999, name="Test Player" if i == 1 else f"Test Player {i}", model="HEOS Drive HS2" if i == 1 else "Speaker", serial="123456", diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index b26652415df..2d9f69d764d 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -316,6 +316,41 @@ async def test_updates_from_user_changed( ] +async def test_updates_from_groups_changed( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test player updates from changes to groups.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Assert current state + assert hass.states.get("media_player.test_player").attributes[ + ATTR_GROUP_MEMBERS + ] == ["media_player.test_player", "media_player.test_player_2"] + assert hass.states.get("media_player.test_player_2").attributes[ + ATTR_GROUP_MEMBERS + ] == ["media_player.test_player", "media_player.test_player_2"] + + # Clear group information + controller._groups = {} + for player in controller.players.values(): + player.group_id = None + await controller.dispatcher.wait_send( + SignalType.CONTROLLER_EVENT, const.EVENT_GROUPS_CHANGED, None + ) + await hass.async_block_till_done() + + # Assert groups changed + assert ( + hass.states.get("media_player.test_player").attributes[ATTR_GROUP_MEMBERS] + is None + ) + assert ( + hass.states.get("media_player.test_player_2").attributes[ATTR_GROUP_MEMBERS] + is None + ) + + async def test_clear_playlist( hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: @@ -1119,8 +1154,20 @@ async def test_play_media_invalid_type( ) +@pytest.mark.parametrize( + ("members", "expected"), + [ + (["media_player.test_player_2"], [1, 2]), + (["media_player.test_player_2", "media_player.test_player"], [1, 2]), + (["media_player.test_player"], [1]), + ], +) async def test_media_player_join_group( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + members: list[str], + expected: tuple[int, list[int]], ) -> None: """Test grouping of media players through the join service.""" config_entry.add_to_hass(hass) @@ -1130,16 +1177,11 @@ async def test_media_player_join_group( SERVICE_JOIN, { ATTR_ENTITY_ID: "media_player.test_player", - ATTR_GROUP_MEMBERS: ["media_player.test_player_2"], + ATTR_GROUP_MEMBERS: members, }, blocking=True, ) - controller.create_group.assert_called_once_with( - 1, - [ - 2, - ], - ) + controller.set_group.assert_called_once_with(expected) async def test_media_player_join_group_error( @@ -1148,7 +1190,7 @@ async def test_media_player_join_group_error( """Test grouping of media players through the join service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - controller.create_group.side_effect = HeosError("error") + controller.set_group.side_effect = HeosError("error") with pytest.raises( HomeAssistantError, match=re.escape("Unable to join players: error"), @@ -1190,15 +1232,24 @@ async def test_media_player_group_members_error( ) -> None: """Test error in HEOS API.""" controller.get_groups.side_effect = HeosError("error") + controller._groups = {} config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - assert "Unable to get HEOS group info" in caplog.text + assert "Unable to retrieve groups" in caplog.text player_entity = hass.states.get("media_player.test_player") - assert player_entity.attributes[ATTR_GROUP_MEMBERS] == [] + assert player_entity.attributes[ATTR_GROUP_MEMBERS] is None +@pytest.mark.parametrize( + ("entity_id", "expected_args"), + [("media_player.test_player", [1]), ("media_player.test_player_2", [1])], +) async def test_media_player_unjoin_group( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + entity_id: str, + expected_args: list[int], ) -> None: """Test ungrouping of media players through the unjoin service.""" config_entry.add_to_hass(hass) @@ -1207,11 +1258,11 @@ async def test_media_player_unjoin_group( MEDIA_PLAYER_DOMAIN, SERVICE_UNJOIN, { - ATTR_ENTITY_ID: "media_player.test_player", + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) - controller.create_group.assert_called_once_with(1, []) + controller.set_group.assert_called_once_with(expected_args) async def test_media_player_unjoin_group_error( @@ -1220,7 +1271,7 @@ async def test_media_player_unjoin_group_error( """Test ungrouping of media players through the unjoin service error raises.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - controller.create_group.side_effect = HeosError("error") + controller.set_group.side_effect = HeosError("error") with pytest.raises( HomeAssistantError, match=re.escape("Unable to unjoin player: error"), @@ -1249,10 +1300,7 @@ async def test_media_player_group_fails_when_entity_removed( entity_registry.async_remove("media_player.test_player_2") # Attempt to group - with pytest.raises( - HomeAssistantError, - match="The group member media_player.test_player_2 could not be resolved to a HEOS player.", - ): + with pytest.raises(ServiceValidationError, match="was not found"): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_JOIN, @@ -1262,4 +1310,35 @@ async def test_media_player_group_fails_when_entity_removed( }, blocking=True, ) - controller.create_group.assert_not_called() + controller.set_group.assert_not_called() + + +async def test_media_player_group_fails_wrong_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + entity_registry: er.EntityRegistry, +) -> None: + """Test grouping fails when trying to join from the wrong integration.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + # Create an entity in another integration + entry = entity_registry.async_get_or_create( + "media_player", "Other", "test_player_2" + ) + + # Attempt to group + with pytest.raises( + ServiceValidationError, match="is not a HEOS media player entity" + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_GROUP_MEMBERS: [entry.entity_id], + }, + blocking=True, + ) + controller.set_group.assert_not_called() From 772f61cf77ac174f846e57eb584a11a501d5d925 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Jan 2025 07:14:06 -1000 Subject: [PATCH 0964/2987] Reduce boilerplate code to setup modbus platform entities (#136491) --- homeassistant/components/modbus/climate.py | 11 +++-------- homeassistant/components/modbus/cover.py | 11 +++-------- homeassistant/components/modbus/fan.py | 10 +++------- homeassistant/components/modbus/light.py | 11 +++-------- homeassistant/components/modbus/switch.py | 12 +++--------- 5 files changed, 15 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index c0b09183ac2..e1a2688048d 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -112,15 +112,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Read configuration and create Modbus climate.""" - if discovery_info is None: + if discovery_info is None or not (climates := discovery_info[CONF_CLIMATES]): return - - entities = [] - for entity in discovery_info[CONF_CLIMATES]: - hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - entities.append(ModbusThermostat(hass, hub, entity)) - - async_add_entities(entities) + hub = get_hub(hass, discovery_info[CONF_NAME]) + async_add_entities(ModbusThermostat(hass, hub, config) for config in climates) class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 0840f522b5d..5e7b008ff7c 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -36,15 +36,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Read configuration and create Modbus cover.""" - if discovery_info is None: + if discovery_info is None or not (covers := discovery_info[CONF_COVERS]): return - - covers = [] - for cover in discovery_info[CONF_COVERS]: - hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - covers.append(ModbusCover(hass, hub, cover)) - - async_add_entities(covers) + hub = get_hub(hass, discovery_info[CONF_NAME]) + async_add_entities(ModbusCover(hass, hub, config) for config in covers) class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py index bed8ff102bb..8636ef4521a 100644 --- a/homeassistant/components/modbus/fan.py +++ b/homeassistant/components/modbus/fan.py @@ -25,14 +25,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Read configuration and create Modbus fans.""" - if discovery_info is None: + if discovery_info is None or not (fans := discovery_info[CONF_FANS]): return - fans = [] - - for entry in discovery_info[CONF_FANS]: - hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - fans.append(ModbusFan(hass, hub, entry)) - async_add_entities(fans) + hub = get_hub(hass, discovery_info[CONF_NAME]) + async_add_entities(ModbusFan(hass, hub, config) for config in fans) class ModbusFan(BaseSwitch, FanEntity): diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 42745c2bb78..ce1c881733e 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -12,7 +12,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .entity import BaseSwitch -from .modbus import ModbusHub PARALLEL_UPDATES = 1 @@ -24,14 +23,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Read configuration and create Modbus lights.""" - if discovery_info is None: + if discovery_info is None or not (lights := discovery_info[CONF_LIGHTS]): return - - lights = [] - for entry in discovery_info[CONF_LIGHTS]: - hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - lights.append(ModbusLight(hass, hub, entry)) - async_add_entities(lights) + hub = get_hub(hass, discovery_info[CONF_NAME]) + async_add_entities(ModbusLight(hass, hub, config) for config in lights) class ModbusLight(BaseSwitch, LightEntity): diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index 71413391a5f..44b0575d419 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -12,7 +12,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .entity import BaseSwitch -from .modbus import ModbusHub PARALLEL_UPDATES = 1 @@ -24,15 +23,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Read configuration and create Modbus switches.""" - switches = [] - - if discovery_info is None: + if discovery_info is None or not (switches := discovery_info[CONF_SWITCHES]): return - - for entry in discovery_info[CONF_SWITCHES]: - hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - switches.append(ModbusSwitch(hass, hub, entry)) - async_add_entities(switches) + hub = get_hub(hass, discovery_info[CONF_NAME]) + async_add_entities(ModbusSwitch(hass, hub, config) for config in switches) class ModbusSwitch(BaseSwitch, SwitchEntity): From 821abc8c534c564c2676800eedfa6b07ba7a4bd9 Mon Sep 17 00:00:00 2001 From: mkmer <7760516+mkmer@users.noreply.github.com> Date: Sat, 25 Jan 2025 12:22:03 -0500 Subject: [PATCH 0965/2987] Bump AIOSomeComfort to 0.0.30 in Honeywell (#136523) --- homeassistant/components/honeywell/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 4a50e326965..36a4f497601 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.28"] + "requirements": ["AIOSomecomfort==0.0.30"] } diff --git a/requirements_all.txt b/requirements_all.txt index 99f5a43794d..9cd511b98d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.28 +AIOSomecomfort==0.0.30 # homeassistant.components.adax Adax-local==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6bf4834dfc8..47159d31735 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.28 +AIOSomecomfort==0.0.30 # homeassistant.components.adax Adax-local==0.1.5 From 42f7bd0a8f1089a3f031fdb6c31dbb1d768f9cbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sat, 25 Jan 2025 17:30:52 +0000 Subject: [PATCH 0966/2987] Reuse fixtures in config flow tests for Whirlpool (#136489) * Use fixtures in config flow tests for Whirlpool * Keep old tests; new one will go to separate PR --- tests/components/whirlpool/conftest.py | 31 +- .../components/whirlpool/test_config_flow.py | 265 +++++++----------- 2 files changed, 128 insertions(+), 168 deletions(-) diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index 50620b20b8b..c302922fe25 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -39,7 +39,12 @@ def fixture_brand(request: pytest.FixtureRequest) -> tuple[str, Brand]: @pytest.fixture(name="mock_auth_api") def fixture_mock_auth_api(): """Set up Auth fixture.""" - with mock.patch("homeassistant.components.whirlpool.Auth") as mock_auth: + with ( + mock.patch("homeassistant.components.whirlpool.Auth") as mock_auth, + mock.patch( + "homeassistant.components.whirlpool.config_flow.Auth", new=mock_auth + ), + ): mock_auth.return_value.do_auth = AsyncMock() mock_auth.return_value.is_access_token_valid.return_value = True yield mock_auth @@ -48,9 +53,15 @@ def fixture_mock_auth_api(): @pytest.fixture(name="mock_appliances_manager_api") def fixture_mock_appliances_manager_api(): """Set up AppliancesManager fixture.""" - with mock.patch( - "homeassistant.components.whirlpool.AppliancesManager" - ) as mock_appliances_manager: + with ( + mock.patch( + "homeassistant.components.whirlpool.AppliancesManager" + ) as mock_appliances_manager, + mock.patch( + "homeassistant.components.whirlpool.config_flow.AppliancesManager", + new=mock_appliances_manager, + ), + ): mock_appliances_manager.return_value.fetch_appliances = AsyncMock() mock_appliances_manager.return_value.aircons = [ {"SAID": MOCK_SAID1, "NAME": "TestZone"}, @@ -81,9 +92,15 @@ def fixture_mock_appliances_manager_laundry_api(): @pytest.fixture(name="mock_backend_selector_api") def fixture_mock_backend_selector_api(): """Set up BackendSelector fixture.""" - with mock.patch( - "homeassistant.components.whirlpool.BackendSelector" - ) as mock_backend_selector: + with ( + mock.patch( + "homeassistant.components.whirlpool.BackendSelector" + ) as mock_backend_selector, + mock.patch( + "homeassistant.components.whirlpool.config_flow.BackendSelector", + new=mock_backend_selector, + ), + ): yield mock_backend_selector diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index 1240e1303e1..94a34c96e2c 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -1,9 +1,10 @@ """Test the Whirlpool Sixth Sense config flow.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch import aiohttp from aiohttp.client_exceptions import ClientConnectionError +import pytest from homeassistant import config_entries from homeassistant.components.whirlpool.const import CONF_BRAND, DOMAIN @@ -19,7 +20,10 @@ CONFIG_INPUT = { } -async def test_form(hass: HomeAssistant, region, brand) -> None: +@pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") +async def test_form( + hass: HomeAssistant, region, brand, mock_backend_selector_api: MagicMock +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -28,28 +32,9 @@ async def test_form(hass: HomeAssistant, region, brand) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - with ( - patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=True, - ), - patch( - "homeassistant.components.whirlpool.config_flow.BackendSelector" - ) as mock_backend_selector, - patch( - "homeassistant.components.whirlpool.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.aircons", - return_value=["test"], - ), - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.fetch_appliances", - return_value=True, - ), - ): + with patch( + "homeassistant.components.whirlpool.async_setup_entry", return_value=True + ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, @@ -65,92 +50,99 @@ async def test_form(hass: HomeAssistant, region, brand) -> None: "brand": brand[0], } assert len(mock_setup_entry.mock_calls) == 1 - mock_backend_selector.assert_called_once_with(brand[1], region[1]) + mock_backend_selector_api.assert_called_once_with(brand[1], region[1]) -async def test_form_invalid_auth(hass: HomeAssistant, region, brand) -> None: +async def test_form_invalid_auth( + hass: HomeAssistant, region, brand, mock_auth_api: MagicMock +) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=False, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, - ) + + mock_auth_api.return_value.is_access_token_valid.return_value = False + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "invalid_auth"} -async def test_form_cannot_connect(hass: HomeAssistant, region, brand) -> None: +async def test_form_cannot_connect( + hass: HomeAssistant, + region, + brand, + mock_auth_api: MagicMock, +) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.whirlpool.config_flow.Auth.do_auth", - side_effect=aiohttp.ClientConnectionError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, - ) + + mock_auth_api.return_value.do_auth.side_effect = aiohttp.ClientConnectionError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT + | { + "region": region[0], + "brand": brand[0], + }, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_auth_timeout(hass: HomeAssistant, region, brand) -> None: +async def test_form_auth_timeout( + hass: HomeAssistant, + region, + brand, + mock_auth_api: MagicMock, +) -> None: """Test we handle auth timeout error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.whirlpool.config_flow.Auth.do_auth", - side_effect=TimeoutError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, - ) + + mock_auth_api.return_value.do_auth.side_effect = TimeoutError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT + | { + "region": region[0], + "brand": brand[0], + }, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_generic_auth_exception(hass: HomeAssistant, region, brand) -> None: +async def test_form_generic_auth_exception( + hass: HomeAssistant, + region, + brand, + mock_auth_api: MagicMock, +) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.whirlpool.config_flow.Auth.do_auth", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, - ) + + mock_auth_api.return_value.do_auth.side_effect = Exception + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT + | { + "region": region[0], + "brand": brand[0], + }, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "unknown"} +@pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") async def test_form_already_configured(hass: HomeAssistant, region, brand) -> None: """Test we handle cannot connect error.""" mock_entry = MockConfigEntry( @@ -167,36 +159,24 @@ async def test_form_already_configured(hass: HomeAssistant, region, brand) -> No assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - with ( - patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=True, - ), - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.aircons", - return_value=["test"], - ), - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.fetch_appliances", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT + | { + "region": region[0], + "brand": brand[0], + }, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" -async def test_no_appliances_flow(hass: HomeAssistant, region, brand) -> None: +@pytest.mark.usefixtures("mock_auth_api") +async def test_no_appliances_flow( + hass: HomeAssistant, region, brand, mock_appliances_manager_api: MagicMock +) -> None: """Test we get an error with no appliances.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -205,27 +185,19 @@ async def test_no_appliances_flow(hass: HomeAssistant, region, brand) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - with ( - patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=True, - ), - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.fetch_appliances", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, - ) - await hass.async_block_till_done() + mock_appliances_manager_api.return_value.aircons = [] + mock_appliances_manager_api.return_value.washer_dryers = [] + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "no_appliances"} +@pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: """Test a successful reauth flow.""" mock_entry = MockConfigEntry( @@ -241,24 +213,8 @@ async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.whirlpool.async_setup_entry", - return_value=True, - ), - patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=True, - ), - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.aircons", - return_value=["test"], - ), - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.fetch_appliances", - return_value=True, - ), + with patch( + "homeassistant.components.whirlpool.async_setup_entry", return_value=True ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -276,7 +232,10 @@ async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: } -async def test_reauth_flow_auth_error(hass: HomeAssistant, region, brand) -> None: +@pytest.mark.usefixtures("mock_appliances_manager_api") +async def test_reauth_flow_auth_error( + hass: HomeAssistant, region, brand, mock_auth_api: MagicMock +) -> None: """Test an authorization error reauth flow.""" mock_entry = MockConfigEntry( @@ -290,16 +249,10 @@ async def test_reauth_flow_auth_error(hass: HomeAssistant, region, brand) -> Non assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.whirlpool.async_setup_entry", - return_value=True, - ), - patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=False, - ), + + mock_auth_api.return_value.is_access_token_valid.return_value = False + with patch( + "homeassistant.components.whirlpool.async_setup_entry", return_value=True ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -311,8 +264,9 @@ async def test_reauth_flow_auth_error(hass: HomeAssistant, region, brand) -> Non assert result2["errors"] == {"base": "invalid_auth"} +@pytest.mark.usefixtures("mock_appliances_manager_api") async def test_reauth_flow_connnection_error( - hass: HomeAssistant, region, brand + hass: HomeAssistant, region, brand, mock_auth_api: MagicMock ) -> None: """Test a connection error reauth flow.""" @@ -329,25 +283,14 @@ async def test_reauth_flow_connnection_error( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.whirlpool.async_setup_entry", - return_value=True, - ), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.do_auth", - side_effect=ClientConnectionError, - ), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=False, - ), + mock_auth_api.return_value.do_auth.side_effect = ClientConnectionError + with patch( + "homeassistant.components.whirlpool.async_setup_entry", return_value=True ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} From 1bf97e3f45e5a2d07a10a9b520db17e99e923a30 Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Sat, 25 Jan 2025 11:31:16 -0600 Subject: [PATCH 0967/2987] Bump pyvesync to 2.1.16 (#136493) Update use pyvesync 2.1.16 Co-authored-by: Shay Levy --- homeassistant/components/vesync/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index 81fb1a764f0..cdb5ed96652 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.15"] + "requirements": ["pyvesync==2.1.16"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9cd511b98d5..2a92d18f57d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2506,7 +2506,7 @@ pyvera==0.3.15 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==2.1.15 +pyvesync==2.1.16 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 47159d31735..dde87698b6b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2024,7 +2024,7 @@ pyuptimerobot==22.2.0 pyvera==0.3.15 # homeassistant.components.vesync -pyvesync==2.1.15 +pyvesync==2.1.16 # homeassistant.components.vizio pyvizio==0.1.61 From 412636a198730a75101f6c7de4f2276edb83ffad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Jan 2025 07:31:49 -1000 Subject: [PATCH 0968/2987] Remove unneeded call active check in modbus (#136487) We have an asyncio.Lock in place to prevent polling collisions now so this is no longer needed Co-authored-by: Joost Lekkerkerker Co-authored-by: Shay Levy --- homeassistant/components/modbus/binary_sensor.py | 4 ---- homeassistant/components/modbus/entity.py | 5 ----- 2 files changed, 9 deletions(-) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 00ed9ccafb7..28d1be24587 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -107,13 +107,9 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): """Update the state of the sensor.""" # do not allow multiple active calls to the same platform - if self._call_active: - return - self._call_active = True result = await self._hub.async_pb_call( self._slave, self._address, self._count, self._input_type ) - self._call_active = False if result is None: self._attr_available = False self._result = [] diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 2d99d8f382c..35b7c02aa05 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -88,7 +88,6 @@ class BasePlatform(Entity): self._input_type = entry[CONF_INPUT_TYPE] self._value: str | None = None self._scan_interval = int(entry[CONF_SCAN_INTERVAL]) - self._call_active = False self._cancel_timer: Callable[[], None] | None = None self._cancel_call: Callable[[], None] | None = None @@ -389,13 +388,9 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): return # do not allow multiple active calls to the same platform - if self._call_active: - return - self._call_active = True result = await self._hub.async_pb_call( self._slave, self._verify_address, 1, self._verify_type ) - self._call_active = False if result is None: self._attr_available = False return From 34e8595d19d35f2b3c14d9819c41180cd769a5af Mon Sep 17 00:00:00 2001 From: Keith <22891515+keithle888@users.noreply.github.com> Date: Sat, 25 Jan 2025 21:38:27 +0100 Subject: [PATCH 0969/2987] Updated igloohome-api dependency to 0.1.0 (#136516) - Updated igloohome-api to 0.1.0 --- homeassistant/components/igloohome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/igloohome/manifest.json b/homeassistant/components/igloohome/manifest.json index 28e287db2ab..35c58479d75 100644 --- a/homeassistant/components/igloohome/manifest.json +++ b/homeassistant/components/igloohome/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/igloohome", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["igloohome-api==0.0.6"] + "requirements": ["igloohome-api==0.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2a92d18f57d..f9a58779c8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1199,7 +1199,7 @@ ifaddr==0.2.0 iglo==1.2.7 # homeassistant.components.igloohome -igloohome-api==0.0.6 +igloohome-api==0.1.0 # homeassistant.components.ihc ihcsdk==2.8.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dde87698b6b..127d08c22d6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1016,7 +1016,7 @@ idasen-ha==2.6.3 ifaddr==0.2.0 # homeassistant.components.igloohome -igloohome-api==0.0.6 +igloohome-api==0.1.0 # homeassistant.components.imgw_pib imgw_pib==1.0.9 From 5e6f6249384a168251008c55db55fe9653a46e29 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sat, 25 Jan 2025 21:42:49 +0100 Subject: [PATCH 0970/2987] Add heat pump heating rod sensors in ViCare integration (#136467) * add heating rod sensors * add labels * update snapshot --- homeassistant/components/vicare/sensor.py | 15 +++ homeassistant/components/vicare/strings.json | 6 ++ .../vicare/snapshots/test_sensor.ambr | 99 +++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 44c3f3cfc0f..14624be2b6d 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -847,6 +847,21 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( value_getter=lambda api: api.getSupplyPressure(), unit_getter=lambda api: api.getSupplyPressureUnit(), ), + ViCareSensorEntityDescription( + key="heating_rod_starts", + translation_key="heating_rod_starts", + value_getter=lambda api: api.getHeatingRodStarts(), + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ViCareSensorEntityDescription( + key="heating_rod_hours", + translation_key="heating_rod_hours", + native_unit_of_measurement=UnitOfTime.HOURS, + value_getter=lambda api: api.getHeatingRodHours(), + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + ), ) CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index f49a73f1659..5ab92880ba0 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -458,6 +458,12 @@ }, "supply_pressure": { "name": "Supply pressure" + }, + "heating_rod_starts": { + "name": "Heating rod starts" + }, + "heating_rod_hours": { + "name": "Heating rod hours" } }, "water_heater": { diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index aaf75e6753a..17c9ee99320 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -1858,6 +1858,105 @@ 'state': '16.4', }) # --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_rod_hours-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_heating_rod_hours', + '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': 'Heating rod hours', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heating_rod_hours', + 'unique_id': 'gateway0_deviceSerialVitocal250A-heating_rod_hours', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_rod_hours-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Heating rod hours', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_rod_hours', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_rod_starts-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_heating_rod_starts', + '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': 'Heating rod starts', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heating_rod_starts', + 'unique_id': 'gateway0_deviceSerialVitocal250A-heating_rod_starts', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_rod_starts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Heating rod starts', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_rod_starts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_outside_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From cf8409dcd2d7f070f91cecfc8b4ee265091d4860 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 25 Jan 2025 22:31:30 +0100 Subject: [PATCH 0971/2987] Add backup agent to Synology DSM (#135227) * pre-alpha state * small type * use ChunkAsyncStreamIterator from aiohttp_client helper * create parent folders during upload if none exists * check file station permissionsduring setup * ensure backup-agents are reloaded * adjust config flow * fix check for availability of file station * fix possible unbound * add config flow tests * fix existing tests * add backup tests * backup listeners are not async * some more tests * migrate existing config entries * fix migration * notify backup listeners only when needed during setup * add backup settings to options flow * switch back to the listener approach from the dev docs example * add negative tests * fix tests * use HassKey * fix tests * Revert "use HassKey" This reverts commit 71c5a4d6fa9c04b4907ff5f8df6ef7bd1737aa85. * use hass loop call_soon instead of non-eager-start tasks * use HassKey for backup-agent-listeners * delete empty backup-agent-listener list from hass.data * don't handle single file download errors * Apply suggestions from code review Co-authored-by: J. Nick Koston * add more tests * we don't have entities related to file_station api * add more backup tests * test unload backup agent * revert sorting of properties * additional use hass config location for default backup path --------- Co-authored-by: J. Nick Koston --- .../components/synology_dsm/__init__.py | 24 +- .../components/synology_dsm/backup.py | 223 ++++++ .../components/synology_dsm/common.py | 42 +- .../components/synology_dsm/config_flow.py | 104 ++- .../components/synology_dsm/const.py | 9 + .../components/synology_dsm/strings.json | 11 + tests/components/synology_dsm/conftest.py | 2 +- .../snapshots/test_config_flow.ambr | 14 + tests/components/synology_dsm/test_backup.py | 709 ++++++++++++++++++ .../synology_dsm/test_config_flow.py | 152 +++- tests/components/synology_dsm/test_init.py | 44 +- .../synology_dsm/test_media_source.py | 1 + 12 files changed, 1297 insertions(+), 38 deletions(-) create mode 100644 homeassistant/components/synology_dsm/backup.py create mode 100644 tests/components/synology_dsm/test_backup.py diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 3619619782e..0b8b8731f8f 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -11,12 +11,15 @@ from synology_dsm.exceptions import SynologyDSMNotLoggedInException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_VERIFY_SSL -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .common import SynoApi, raise_config_entry_auth_error from .const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, + DATA_BACKUP_AGENT_LISTENERS, DEFAULT_VERIFY_SSL, DOMAIN, EXCEPTION_DETAILS, @@ -60,6 +63,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry( entry, data={**entry.data, CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL} ) + if CONF_BACKUP_SHARE not in entry.options: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_BACKUP_SHARE: None, CONF_BACKUP_PATH: None}, + ) # Continue setup api = SynoApi(hass, entry) @@ -118,6 +126,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + if entry.options[CONF_BACKUP_SHARE]: + _async_notify_backup_listeners_soon(hass) + return True @@ -127,9 +138,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] await entry_data.api.async_unload() hass.data[DOMAIN].pop(entry.unique_id) + _async_notify_backup_listeners_soon(hass) return unload_ok +def _async_notify_backup_listeners(hass: HomeAssistant) -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + +@callback +def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: + hass.loop.call_soon(_async_notify_backup_listeners, hass) + + async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py new file mode 100644 index 00000000000..eed6af758ba --- /dev/null +++ b/homeassistant/components/synology_dsm/backup.py @@ -0,0 +1,223 @@ +"""Support for Synology DSM backup agents.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +import logging +from typing import TYPE_CHECKING, Any + +from aiohttp import StreamReader +from synology_dsm.api.file_station import SynoFileStation +from synology_dsm.exceptions import SynologyDSMAPIErrorException + +from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator +from homeassistant.helpers.json import json_dumps +from homeassistant.util.json import JsonObjectType, json_loads_object + +from .const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, + DATA_BACKUP_AGENT_LISTENERS, + DOMAIN, +) +from .models import SynologyDSMData + +LOGGER = logging.getLogger(__name__) + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + if not ( + entries := hass.config_entries.async_loaded_entries(DOMAIN) + ) or not hass.data.get(DOMAIN): + LOGGER.debug("No proper config entry found") + return [] + syno_datas: dict[str, SynologyDSMData] = hass.data[DOMAIN] + return [ + SynologyDSMBackupAgent(hass, entry) + for entry in entries + if entry.unique_id is not None + and (syno_data := syno_datas.get(entry.unique_id)) + and syno_data.api.file_station + and entry.options.get(CONF_BACKUP_PATH) + ] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed. + + :return: A function to unregister the listener. + """ + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +class SynologyDSMBackupAgent(BackupAgent): + """Synology DSM backup agent.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the Synology DSM backup agent.""" + super().__init__() + LOGGER.debug("Initializing Synology DSM backup agent for %s", entry.unique_id) + self.name = entry.title + self.path = ( + f"{entry.options[CONF_BACKUP_SHARE]}/{entry.options[CONF_BACKUP_PATH]}" + ) + syno_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + self.api = syno_data.api + + @property + def _file_station(self) -> SynoFileStation: + if TYPE_CHECKING: + # we ensure that file_station exist already in async_get_backup_agents + assert self.api.file_station + return self.api.file_station + + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: An async iterator that yields bytes. + """ + try: + resp = await self._file_station.download_file( + path=self.path, + filename=f"{backup_id}.tar", + ) + except SynologyDSMAPIErrorException as err: + raise BackupAgentError("Failed to download backup") from err + + if TYPE_CHECKING: + assert isinstance(resp, StreamReader) + + return ChunkAsyncStreamIterator(resp) + + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup. + + :param open_stream: A function returning an async iterator that yields bytes. + :param backup: Metadata about the backup that should be uploaded. + """ + + # upload backup.tar file first + try: + await self._file_station.upload_file( + path=self.path, + filename=f"{backup.backup_id}.tar", + source=await open_stream(), + create_parents=True, + ) + except SynologyDSMAPIErrorException as err: + raise BackupAgentError("Failed to upload backup") from err + + # upload backup_meta.json file when backup.tar was successful uploaded + try: + await self._file_station.upload_file( + path=self.path, + filename=f"{backup.backup_id}_meta.json", + source=json_dumps(backup.as_dict()).encode(), + ) + except SynologyDSMAPIErrorException as err: + raise BackupAgentError("Failed to upload backup") from err + + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + """ + try: + await self._file_station.delete_file( + path=self.path, filename=f"{backup_id}.tar" + ) + await self._file_station.delete_file( + path=self.path, filename=f"{backup_id}_meta.json" + ) + except SynologyDSMAPIErrorException as err: + raise BackupAgentError("Failed to delete the backup") from err + + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + return list((await self._async_list_backups(**kwargs)).values()) + + async def _async_list_backups(self, **kwargs: Any) -> dict[str, AgentBackup]: + """List backups.""" + + async def _download_meta_data(filename: str) -> JsonObjectType: + try: + resp = await self._file_station.download_file( + path=self.path, filename=filename + ) + except SynologyDSMAPIErrorException as err: + raise BackupAgentError("Failed to download meta data") from err + + if TYPE_CHECKING: + assert isinstance(resp, StreamReader) + + try: + return json_loads_object(await resp.read()) + except Exception as err: + raise BackupAgentError("Failed to read meta data") from err + + try: + files = await self._file_station.get_files(path=self.path) + except SynologyDSMAPIErrorException as err: + raise BackupAgentError("Failed to list backups") from err + + if TYPE_CHECKING: + assert files + + backups: dict[str, AgentBackup] = {} + for file in files: + if file.name.endswith("_meta.json"): + try: + meta_data = await _download_meta_data(file.name) + except BackupAgentError as err: + LOGGER.error("Failed to download meta data: %s", err) + continue + agent_backup = AgentBackup.from_dict(meta_data) + backups[agent_backup.backup_id] = agent_backup + return backups + + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + backups = await self._async_list_backups() + return backups.get(backup_id) diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index 9a6284eff2b..dfc372e6bde 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -14,6 +14,7 @@ from synology_dsm.api.core.upgrade import SynoCoreUpgrade from synology_dsm.api.core.utilization import SynoCoreUtilization from synology_dsm.api.dsm.information import SynoDSMInformation from synology_dsm.api.dsm.network import SynoDSMNetwork +from synology_dsm.api.file_station import SynoFileStation from synology_dsm.api.photos import SynoPhotos from synology_dsm.api.storage.storage import SynoStorage from synology_dsm.api.surveillance_station import SynoSurveillanceStation @@ -62,11 +63,12 @@ class SynoApi: self.config_url = f"http://{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}" # DSM APIs + self.file_station: SynoFileStation | None = None self.information: SynoDSMInformation | None = None self.network: SynoDSMNetwork | None = None + self.photos: SynoPhotos | None = None self.security: SynoCoreSecurity | None = None self.storage: SynoStorage | None = None - self.photos: SynoPhotos | None = None self.surveillance_station: SynoSurveillanceStation | None = None self.system: SynoCoreSystem | None = None self.upgrade: SynoCoreUpgrade | None = None @@ -74,10 +76,11 @@ class SynoApi: # Should we fetch them self._fetching_entities: dict[str, set[str]] = {} + self._with_file_station = True self._with_information = True + self._with_photos = True self._with_security = True self._with_storage = True - self._with_photos = True self._with_surveillance_station = True self._with_system = True self._with_upgrade = True @@ -157,6 +160,26 @@ class SynoApi: self.dsm.reset(SynoCoreUpgrade.API_KEY) LOGGER.debug("Disabled fetching upgrade data during setup: %s", ex) + # check if file station is used and permitted + self._with_file_station = bool(self.dsm.apis.get(SynoFileStation.LIST_API_KEY)) + if self._with_file_station: + shares: list | None = None + with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS): + shares = await self.dsm.file.get_shared_folders(only_writable=True) + if not shares: + self._with_file_station = False + self.dsm.reset(SynoFileStation.API_KEY) + LOGGER.debug( + "File Station found, but disabled due to missing user" + " permissions or no writable shared folders available" + ) + + LOGGER.debug( + "State of File Station during setup of '%s': %s", + self._entry.unique_id, + self._with_file_station, + ) + await self._fetch_device_configuration() try: @@ -225,6 +248,15 @@ class SynoApi: self.dsm.reset(self.security) self.security = None + if not self._with_file_station: + LOGGER.debug( + "Disable file station api from being updated or '%s'", + self._entry.unique_id, + ) + if self.file_station: + self.dsm.reset(self.file_station) + self.file_station = None + if not self._with_photos: LOGGER.debug( "Disable photos api from being updated or '%s'", self._entry.unique_id @@ -272,6 +304,12 @@ class SynoApi: self.network = self.dsm.network await self.network.update() + if self._with_file_station: + LOGGER.debug( + "Enable file station api updates for '%s'", self._entry.unique_id + ) + self.file_station = self.dsm.file + if self._with_security: LOGGER.debug("Enable security api updates for '%s'", self._entry.unique_id) self.security = self.dsm.security diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 03e2eaf8e7b..30f5078f19d 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -3,12 +3,14 @@ from __future__ import annotations from collections.abc import Mapping +from contextlib import suppress from ipaddress import ip_address as ip import logging -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlparse from synology_dsm import SynologyDSM +from synology_dsm.api.file_station.models import SynoFileSharedFolder from synology_dsm.exceptions import ( SynologyDSMException, SynologyDSMLogin2SAFailedException, @@ -40,6 +42,12 @@ from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL, @@ -47,12 +55,16 @@ from homeassistant.helpers.service_info.ssdp import ( ) from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType, VolDictType +from homeassistant.util import slugify from homeassistant.util.network import is_ip_address as is_ip from .const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, CONF_DEVICE_TOKEN, CONF_SNAPSHOT_QUALITY, CONF_VOLUMES, + DEFAULT_BACKUP_PATH, DEFAULT_PORT, DEFAULT_PORT_SSL, DEFAULT_SCAN_INTERVAL, @@ -61,7 +73,9 @@ from .const import ( DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN, + SYNOLOGY_CONNECTION_EXCEPTIONS, ) +from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) @@ -131,6 +145,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): self.discovered_conf: dict[str, Any] = {} self.reauth_conf: Mapping[str, Any] = {} self.reauth_reason: str | None = None + self.shares: list[SynoFileSharedFolder] | None = None def _show_form( self, @@ -173,6 +188,8 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): verify_ssl = user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) otp_code = user_input.get(CONF_OTP_CODE) friendly_name = user_input.get(CONF_NAME) + backup_path = user_input.get(CONF_BACKUP_PATH) + backup_share = user_input.get(CONF_BACKUP_SHARE) if not port: if use_ssl is True: @@ -209,6 +226,12 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): if errors: return self._show_form(step_id, user_input, errors) + with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS): + self.shares = await api.file.get_shared_folders(only_writable=True) + + if self.shares and not backup_path: + return await self.async_step_backup_share(user_input) + # unique_id should be serial for services purpose existing_entry = await self.async_set_unique_id(serial, raise_on_progress=False) @@ -221,6 +244,10 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): CONF_PASSWORD: password, CONF_MAC: api.network.macs, } + config_options = { + CONF_BACKUP_PATH: backup_path, + CONF_BACKUP_SHARE: backup_share, + } if otp_code: config_data[CONF_DEVICE_TOKEN] = api.device_token if user_input.get(CONF_DISKS): @@ -233,10 +260,12 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): "reauth_successful" if self.reauth_conf else "reconfigure_successful" ) return self.async_update_reload_and_abort( - existing_entry, data=config_data, reason=reason + existing_entry, data=config_data, options=config_options, reason=reason ) - return self.async_create_entry(title=friendly_name or host, data=config_data) + return self.async_create_entry( + title=friendly_name or host, data=config_data, options=config_options + ) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -368,6 +397,43 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_user(user_input) + async def async_step_backup_share( + self, user_input: dict[str, Any], errors: dict[str, str] | None = None + ) -> ConfigFlowResult: + """Select backup location.""" + if TYPE_CHECKING: + assert self.shares is not None + + if not self.saved_user_input: + self.saved_user_input = user_input + + if CONF_BACKUP_PATH not in user_input and CONF_BACKUP_SHARE not in user_input: + return self.async_show_form( + step_id="backup_share", + data_schema=vol.Schema( + { + vol.Required(CONF_BACKUP_SHARE): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value=s.path, label=s.name) + for s in self.shares + ], + mode=SelectSelectorMode.DROPDOWN, + ), + ), + vol.Required( + CONF_BACKUP_PATH, + default=f"{DEFAULT_BACKUP_PATH}_{slugify(self.hass.config.location_name)}", + ): str, + } + ), + ) + + user_input = {**self.saved_user_input, **user_input} + self.saved_user_input = {} + + return await self.async_step_user(user_input) + def _async_get_existing_entry(self, discovered_mac: str) -> ConfigEntry | None: """See if we already have a configured NAS with this MAC address.""" for entry in self._async_current_entries(): @@ -388,6 +454,8 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow): if user_input is not None: return self.async_create_entry(title="", data=user_input) + syno_data: SynologyDSMData = self.hass.data[DOMAIN][self.config_entry.unique_id] + data_schema = vol.Schema( { vol.Required( @@ -404,6 +472,36 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow): ): vol.All(vol.Coerce(int), vol.Range(min=0, max=2)), } ) + + 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 shares: + data_schema = data_schema.extend( + { + vol.Required( + CONF_BACKUP_SHARE, + default=self.config_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.config_entry.options[CONF_BACKUP_PATH], + ): str, + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index e6367458578..dbee85b99d6 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -2,6 +2,8 @@ from __future__ import annotations +from collections.abc import Callable + from aiohttp import ClientTimeout from synology_dsm.api.surveillance_station.const import SNAPSHOT_PROFILE_BALANCED from synology_dsm.exceptions import ( @@ -15,8 +17,12 @@ from synology_dsm.exceptions import ( ) from homeassistant.const import Platform +from homeassistant.util.hass_dict import HassKey DOMAIN = "synology_dsm" +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}_backup_agent_listeners" +) ATTRIBUTION = "Data provided by Synology" PLATFORMS = [ Platform.BINARY_SENSOR, @@ -34,6 +40,8 @@ CONF_SERIAL = "serial" CONF_VOLUMES = "volumes" CONF_DEVICE_TOKEN = "device_token" CONF_SNAPSHOT_QUALITY = "snap_profile_type" +CONF_BACKUP_SHARE = "backup_share" +CONF_BACKUP_PATH = "backup_path" DEFAULT_USE_SSL = True DEFAULT_VERIFY_SSL = False @@ -43,6 +51,7 @@ DEFAULT_PORT_SSL = 5001 DEFAULT_SCAN_INTERVAL = 15 # min DEFAULT_TIMEOUT = ClientTimeout(total=60, connect=15) DEFAULT_SNAPSHOT_QUALITY = SNAPSHOT_PROFILE_BALANCED +DEFAULT_BACKUP_PATH = "ha_backup" ENTITY_UNIT_LOAD = "load" diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 0f8ea594732..3d64f908256 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -21,6 +21,17 @@ "otp_code": "Code" } }, + "backup_share": { + "title": "Synology DSM: Backup location", + "data": { + "backup_share": "Shared folder", + "backup_path": "Path" + }, + "data_description": { + "backup_share": "Select the shared folder, where the automatic Home-Assistant backup should be stored.", + "backup_path": "Define the path on the selected shared folder (will automatically be created, if not exist)." + } + }, "link": { "description": "Do you want to set up {name} ({host})?", "data": { diff --git a/tests/components/synology_dsm/conftest.py b/tests/components/synology_dsm/conftest.py index 0e8f79ffd40..331c879332d 100644 --- a/tests/components/synology_dsm/conftest.py +++ b/tests/components/synology_dsm/conftest.py @@ -34,5 +34,5 @@ def fixture_dsm(): dsm.network.update = AsyncMock(return_value=True) dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) - + dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) return dsm diff --git a/tests/components/synology_dsm/snapshots/test_config_flow.ambr b/tests/components/synology_dsm/snapshots/test_config_flow.ambr index 807ec764e52..384f6b885d7 100644 --- a/tests/components/synology_dsm/snapshots/test_config_flow.ambr +++ b/tests/components/synology_dsm/snapshots/test_config_flow.ambr @@ -84,3 +84,17 @@ 'verify_ssl': False, }) # --- +# name: test_user_with_filestation + dict({ + 'host': 'nas.meontheinternet.com', + 'mac': list([ + '00-11-32-XX-XX-59', + '00-11-32-XX-XX-5A', + ]), + 'password': 'password', + 'port': 1234, + 'ssl': True, + 'username': 'Home_Assistant', + 'verify_ssl': False, + }) +# --- diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py new file mode 100644 index 00000000000..0cd119cf015 --- /dev/null +++ b/tests/components/synology_dsm/test_backup.py @@ -0,0 +1,709 @@ +"""Tests for the Synology DSM backup agent.""" + +from io import StringIO +from typing import Any +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from synology_dsm.api.file_station.models import SynoFileFile, SynoFileSharedFolder +from synology_dsm.exceptions import SynologyDSMAPIErrorException + +from homeassistant.components.backup import ( + DOMAIN as BACKUP_DOMAIN, + AddonInfo, + AgentBackup, + Folder, +) +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.setup import async_setup_component +from homeassistant.util.aiohttp import MockStreamReader + +from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +class MockStreamReaderChunked(MockStreamReader): + """Mock a stream reader with simulated chunked data.""" + + async def readchunk(self) -> tuple[bytes, bool]: + """Read bytes.""" + return (self._content.read(), False) + + +async def _mock_download_file(path: str, filename: str) -> MockStreamReader: + if filename == "abcd12ef_meta.json": + return MockStreamReader( + b'{"addons":[],"backup_id":"abcd12ef","date":"2025-01-09T20:14:35.457323+01:00",' + b'"database_included":true,"extra_metadata":{"instance_id":"36b3b7e984da43fc89f7bafb2645fa36",' + b'"with_automatic_settings":true},"folders":[],"homeassistant_included":true,' + b'"homeassistant_version":"2025.2.0.dev0","name":"Automatic backup 2025.2.0.dev0","protected":true,"size":13916160}' + ) + if filename == "abcd12ef.tar": + return MockStreamReaderChunked(b"backup data") + raise MockStreamReaderChunked(b"") + + +async def _mock_download_file_meta_ok_tar_missing( + path: str, filename: str +) -> MockStreamReader: + if filename == "abcd12ef_meta.json": + return MockStreamReader( + b'{"addons":[],"backup_id":"abcd12ef","date":"2025-01-09T20:14:35.457323+01:00",' + b'"database_included":true,"extra_metadata":{"instance_id":"36b3b7e984da43fc89f7bafb2645fa36",' + b'"with_automatic_settings":true},"folders":[],"homeassistant_included":true,' + b'"homeassistant_version":"2025.2.0.dev0","name":"Automatic backup 2025.2.0.dev0","protected":true,"size":13916160}' + ) + if filename == "abcd12ef.tar": + raise SynologyDSMAPIErrorException("api", "404", "not found") + raise MockStreamReaderChunked(b"") + + +async def _mock_download_file_meta_defect(path: str, filename: str) -> MockStreamReader: + if filename == "abcd12ef_meta.json": + return MockStreamReader(b"im not a json") + if filename == "abcd12ef.tar": + return MockStreamReaderChunked(b"backup data") + raise MockStreamReaderChunked(b"") + + +@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", + ) + ] + ), + get_files=AsyncMock( + return_value=[ + SynoFileFile( + additional=None, + is_dir=False, + name="abcd12ef_meta.json", + path="/ha_backup/my_backup_path/abcd12ef_meta.json", + ), + SynoFileFile( + additional=None, + is_dir=False, + name="abcd12ef.tar", + path="/ha_backup/my_backup_path/abcd12ef.tar", + ), + ] + ), + download_file=_mock_download_file, + upload_file=AsyncMock(return_value=True), + delete_file=AsyncMock(return_value=True), + ) + dsm.logout = AsyncMock(return_value=True) + yield dsm + + +@pytest.fixture +def mock_dsm_without_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 = None + + 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: "my_backup_path", + CONF_BACKUP_SHARE: "/ha_backup", + }, + unique_id="mocked_syno_dsm_entry", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + await hass.async_block_till_done() + + yield mock_dsm_with_filestation + + +async def test_agents_info( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "synology_dsm.Mock Title"}, + {"agent_id": "backup.local"}, + ], + } + + +async def test_agents_not_loaded( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent with no loaded config entry.""" + with patch("homeassistant.components.backup.is_hassio", return_value=False): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local"}, + ], + } + + +async def test_agents_on_unload( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent on un-loading config entry.""" + # config entry is loaded + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "synology_dsm.Mock Title"}, + {"agent_id": "backup.local"}, + ], + } + + # unload config entry + entries = hass.config_entries.async_loaded_entries(DOMAIN) + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local"}, + ], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent list backups.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": [], + "backup_id": "abcd12ef", + "date": "2025-01-09T20:14:35.457323+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.0.dev0", + "name": "Automatic backup 2025.2.0.dev0", + "protected": True, + "size": 13916160, + "agent_ids": ["synology_dsm.Mock Title"], + "failed_agent_ids": [], + "with_automatic_settings": None, + } + ] + + +async def test_agents_list_backups_error( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent error while list backups.""" + client = await hass_ws_client(hass) + + setup_dsm_with_filestation.file.get_files.side_effect = ( + SynologyDSMAPIErrorException("api", "500", "error") + ) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {"synology_dsm.Mock Title": "Failed to list backups"}, + "backups": [], + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + } + + +async def test_agents_list_backups_disabled_filestation( + hass: HomeAssistant, + mock_dsm_without_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent error while list backups when file station is disabled.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert not response["success"] + + +@pytest.mark.parametrize( + ("backup_id", "expected_result"), + [ + ( + "abcd12ef", + { + "addons": [], + "backup_id": "abcd12ef", + "date": "2025-01-09T20:14:35.457323+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.0.dev0", + "name": "Automatic backup 2025.2.0.dev0", + "protected": True, + "size": 13916160, + "agent_ids": ["synology_dsm.Mock Title"], + "failed_agent_ids": [], + "with_automatic_settings": None, + }, + ), + ( + "12345", + None, + ), + ], + ids=["found", "not_found"], +) +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, + backup_id: str, + expected_result: dict[str, Any] | None, +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == expected_result + + +async def test_agents_get_backup_not_existing( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent get not existing backup.""" + client = await hass_ws_client(hass) + backup_id = "ef34ab12" + + setup_dsm_with_filestation.file.download_file = AsyncMock( + side_effect=SynologyDSMAPIErrorException("api", "404", "not found") + ) + + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}, "backup": None} + + +async def test_agents_get_backup_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent error while get backup.""" + client = await hass_ws_client(hass) + backup_id = "ef34ab12" + + setup_dsm_with_filestation.file.get_files.side_effect = ( + SynologyDSMAPIErrorException("api", "500", "error") + ) + + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {"synology_dsm.Mock Title": "Failed to list backups"}, + "backup": None, + } + + +async def test_agents_get_backup_defect_meta( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent error while get backup.""" + client = await hass_ws_client(hass) + backup_id = "ef34ab12" + + setup_dsm_with_filestation.file.download_file = _mock_download_file_meta_defect + + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}, "backup": None} + + +async def test_agents_download( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent download backup.""" + client = await hass_client() + backup_id = "abcd12ef" + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id=synology_dsm.Mock Title" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + +async def test_agents_download_not_existing( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent download not existing backup.""" + client = await hass_client() + backup_id = "abcd12ef" + + setup_dsm_with_filestation.file.download_file = ( + _mock_download_file_meta_ok_tar_missing + ) + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id=synology_dsm.Mock Title" + ) + assert resp.reason == "Internal Server Error" + assert resp.status == 500 + + +async def test_agents_upload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + 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=0, + ) + + 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=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=synology_dsm.Mock Title", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {backup_id}" in caplog.text + mock: AsyncMock = setup_dsm_with_filestation.file.upload_file + assert len(mock.mock_calls) == 2 + assert mock.call_args_list[0].kwargs["filename"] == "test-backup.tar" + assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" + assert mock.call_args_list[1].kwargs["filename"] == "test-backup_meta.json" + assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path" + + +async def test_agents_upload_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent error while uploading backup.""" + client = await hass_client() + 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=0, + ) + + # fail to upload the tar file + 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=[b"test", b""]) + fetch_backup.return_value = test_backup + setup_dsm_with_filestation.file.upload_file.side_effect = ( + SynologyDSMAPIErrorException("api", "500", "error") + ) + resp = await client.post( + "/api/backup/upload?agent_id=synology_dsm.Mock Title", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {backup_id}" in caplog.text + assert "Failed to upload backup" in caplog.text + mock: AsyncMock = setup_dsm_with_filestation.file.upload_file + assert len(mock.mock_calls) == 1 + assert mock.call_args_list[0].kwargs["filename"] == "test-backup.tar" + assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" + + # fail to upload the meta json file + 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=[b"test", b""]) + fetch_backup.return_value = test_backup + setup_dsm_with_filestation.file.upload_file.side_effect = [ + True, + SynologyDSMAPIErrorException("api", "500", "error"), + ] + + resp = await client.post( + "/api/backup/upload?agent_id=synology_dsm.Mock Title", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {backup_id}" in caplog.text + assert "Failed to upload backup" in caplog.text + mock: AsyncMock = setup_dsm_with_filestation.file.upload_file + assert len(mock.mock_calls) == 3 + assert mock.call_args_list[1].kwargs["filename"] == "test-backup.tar" + assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path" + assert mock.call_args_list[2].kwargs["filename"] == "test-backup_meta.json" + assert mock.call_args_list[2].kwargs["path"] == "/ha_backup/my_backup_path" + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + backup_id = "abcd12ef" + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock: AsyncMock = setup_dsm_with_filestation.file.delete_file + assert len(mock.mock_calls) == 2 + assert mock.call_args_list[0].kwargs["filename"] == "abcd12ef.tar" + assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" + assert mock.call_args_list[1].kwargs["filename"] == "abcd12ef_meta.json" + assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path" + + +async def test_agents_delete_not_existing( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test delete not existing backup.""" + client = await hass_ws_client(hass) + backup_id = "ef34ab12" + + setup_dsm_with_filestation.file.delete_file = AsyncMock( + side_effect=SynologyDSMAPIErrorException("api", "404", "not found") + ) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {"synology_dsm.Mock Title": "Failed to delete the backup"} + } + + +async def test_agents_delete_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test error while delete backup.""" + client = await hass_ws_client(hass) + + # error while delete + backup_id = "abcd12ef" + setup_dsm_with_filestation.file.delete_file.side_effect = ( + SynologyDSMAPIErrorException("api", "404", "not found") + ) + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {"synology_dsm.Mock Title": "Failed to delete the backup"} + } + mock: AsyncMock = setup_dsm_with_filestation.file.delete_file + assert len(mock.mock_calls) == 1 + assert mock.call_args_list[0].kwargs["filename"] == "abcd12ef.tar" + assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 3ef47292a9b..b63ce6c2e18 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -4,6 +4,7 @@ from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from synology_dsm.api.file_station.models import SynoFileSharedFolder from synology_dsm.exceptions import ( SynologyDSMException, SynologyDSMLogin2SAFailedException, @@ -15,9 +16,9 @@ from syrupy import SnapshotAssertion from homeassistant.components.synology_dsm.config_flow import CONF_OTP_CODE from homeassistant.components.synology_dsm.const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, CONF_SNAPSHOT_QUALITY, - DEFAULT_SCAN_INTERVAL, - DEFAULT_SNAPSHOT_QUALITY, DOMAIN, ) from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER, SOURCE_ZEROCONF @@ -73,7 +74,7 @@ def mock_controller_service(): update=AsyncMock(return_value=True), ) dsm.information = Mock(serial=SERIAL) - + dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm @@ -96,6 +97,7 @@ def mock_controller_service_2sa(): update=AsyncMock(return_value=True), ) dsm.information = Mock(serial=SERIAL) + dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm @@ -116,6 +118,39 @@ def mock_controller_service_vdsm(): update=AsyncMock(return_value=True), ) dsm.information = Mock(serial=SERIAL) + dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) + yield dsm + + +@pytest.fixture(name="service_with_filestation") +def mock_controller_service_with_filestation(): + """Mock a successful service with filestation support.""" + with patch("homeassistant.components.synology_dsm.config_flow.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", + ) + ] + ) + ) yield dsm @@ -137,7 +172,7 @@ def mock_controller_service_failed(): update=AsyncMock(return_value=True), ) dsm.information = Mock(serial=None) - + dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm @@ -283,6 +318,55 @@ async def test_user_vdsm( assert result["data"] == snapshot +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_with_filestation( + hass: HomeAssistant, + service_with_filestation: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test user config.""" + with patch( + "homeassistant.components.synology_dsm.config_flow.SynologyDSM", + return_value=service_with_filestation, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=None + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.synology_dsm.config_flow.SynologyDSM", + return_value=service_with_filestation, + ): + # test with all provided + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_VERIFY_SSL: VERIFY_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "backup_share" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_BACKUP_SHARE: "/ha_backup", CONF_BACKUP_PATH: "automatic_ha_backups"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == HOST + assert result["data"] == snapshot + + @pytest.mark.usefixtures("mock_setup_entry") async def test_reauth(hass: HomeAssistant, service: MagicMock) -> None: """Test reauthentication.""" @@ -560,46 +644,54 @@ async def test_existing_ssdp(hass: HomeAssistant, service: MagicMock) -> None: assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("mock_setup_entry") -async def test_options_flow(hass: HomeAssistant, service: MagicMock) -> None: +async def test_options_flow( + hass: HomeAssistant, service_with_filestation: MagicMock +) -> None: """Test config flow options.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: HOST, - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_MAC: MACS, - }, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=service_with_filestation, + ), + patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]), + ): + config_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], + }, + unique_id=SERIAL, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - assert config_entry.options == {} + assert config_entry.options == {CONF_BACKUP_SHARE: None, CONF_BACKUP_PATH: None} result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - # Scan interval - # Default - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={}, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options[CONF_SCAN_INTERVAL] == DEFAULT_SCAN_INTERVAL - assert config_entry.options[CONF_SNAPSHOT_QUALITY] == DEFAULT_SNAPSHOT_QUALITY - - # Manual result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={CONF_SCAN_INTERVAL: 2, CONF_SNAPSHOT_QUALITY: 0}, + 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" @pytest.mark.usefixtures("mock_setup_entry") diff --git a/tests/components/synology_dsm/test_init.py b/tests/components/synology_dsm/test_init.py index 13d568e6137..7eaafc98437 100644 --- a/tests/components/synology_dsm/test_init.py +++ b/tests/components/synology_dsm/test_init.py @@ -4,7 +4,13 @@ from unittest.mock import MagicMock, patch from synology_dsm.exceptions import SynologyDSMLoginInvalidException -from homeassistant.components.synology_dsm.const import DOMAIN, SERVICES +from homeassistant.components.synology_dsm.const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, + DEFAULT_VERIFY_SSL, + DOMAIN, + SERVICES, +) from homeassistant.const import ( CONF_HOST, CONF_MAC, @@ -12,6 +18,7 @@ from homeassistant.const import ( CONF_PORT, CONF_SSL, CONF_USERNAME, + CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -78,3 +85,38 @@ async def test_reauth_triggered(hass: HomeAssistant) -> None: assert not await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() mock_async_step_reauth.assert_called_once() + + +async def test_config_entry_migrations( + hass: HomeAssistant, mock_dsm: MagicMock +) -> None: + """Test if reauthentication flow is triggered.""" + with ( + patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=mock_dsm, + ), + 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], + }, + ) + entry.add_to_hass(hass) + + assert CONF_VERIFY_SSL not in entry.data + assert CONF_BACKUP_SHARE not in entry.options + assert CONF_BACKUP_PATH not in entry.options + + assert await hass.config_entries.async_setup(entry.entry_id) + + assert entry.data[CONF_VERIFY_SSL] == DEFAULT_VERIFY_SSL + assert entry.options[CONF_BACKUP_SHARE] is None + assert entry.options[CONF_BACKUP_PATH] is None diff --git a/tests/components/synology_dsm/test_media_source.py b/tests/components/synology_dsm/test_media_source.py index 0c7ab6bc1cc..baa91822ca0 100644 --- a/tests/components/synology_dsm/test_media_source.py +++ b/tests/components/synology_dsm/test_media_source.py @@ -62,6 +62,7 @@ def dsm_with_photos() -> MagicMock: dsm.photos.get_item_thumbnail_url = AsyncMock( return_value="http://my.thumbnail.url" ) + dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) return dsm From cffb0a03d2033c1db06c7764504f697f4af03f14 Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Sun, 26 Jan 2025 01:18:20 +0100 Subject: [PATCH 0972/2987] Add Darsstar as codeowner for solax integration (#136528) * Add Darsstar as codeowner for solax integration * Update manifest.json --- CODEOWNERS | 4 ++-- homeassistant/components/solax/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 489b848c772..f16b890d407 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1408,8 +1408,8 @@ build.json @home-assistant/supervisor /homeassistant/components/solaredge_local/ @drobtravels @scheric /homeassistant/components/solarlog/ @Ernst79 @dontinelli /tests/components/solarlog/ @Ernst79 @dontinelli -/homeassistant/components/solax/ @squishykid -/tests/components/solax/ @squishykid +/homeassistant/components/solax/ @squishykid @Darsstar +/tests/components/solax/ @squishykid @Darsstar /homeassistant/components/soma/ @ratsept @sebfortier2288 /tests/components/soma/ @ratsept @sebfortier2288 /homeassistant/components/sonarr/ @ctalkington diff --git a/homeassistant/components/solax/manifest.json b/homeassistant/components/solax/manifest.json index 925f11e4c65..5509901ae02 100644 --- a/homeassistant/components/solax/manifest.json +++ b/homeassistant/components/solax/manifest.json @@ -1,7 +1,7 @@ { "domain": "solax", "name": "SolaX Power", - "codeowners": ["@squishykid"], + "codeowners": ["@squishykid", "@Darsstar"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/solax", "iot_class": "local_polling", From 733e1feba3ef929c33d236b9ff7c3e465410b618 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 26 Jan 2025 01:20:05 +0100 Subject: [PATCH 0973/2987] Fix wrong plural on tado.add_meter_reading action (#136524) As this action can only take a single argument the plural introduced in the descriptions is misleading. This also makes the friendly name of the action consistent with its key name. --- homeassistant/components/tado/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 8124570f9c9..735fe34bcf4 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -135,12 +135,12 @@ } }, "add_meter_reading": { - "name": "Add meter readings", - "description": "Add meter readings to Tado Energy IQ.", + "name": "Add meter reading", + "description": "Adds a meter reading to Tado Energy IQ.", "fields": { "config_entry": { "name": "Config Entry", - "description": "Config entry to add meter readings to." + "description": "Config entry to add meter reading to." }, "reading": { "name": "Reading", From 1a57992e78bb051e4c54685f23810e72508fd975 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 26 Jan 2025 01:20:41 +0100 Subject: [PATCH 0974/2987] Add restore backup tests (#136538) * Test restore backup with busy manager * Test restore backup with agent error * Test restore backup with file error --- tests/components/backup/test_manager.py | 262 ++++++++++++++++++++++++ 1 file changed, 262 insertions(+) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index c961230e9e6..48e6db4ae9a 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -2483,3 +2483,265 @@ async def test_restore_backup_wrong_parameters( mocked_write_text.assert_not_called() mocked_service_call.assert_not_called() + + +@pytest.mark.usefixtures("mock_backup_generation") +async def test_restore_backup_when_busy( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test restore backup with busy manager.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": [LOCAL_AGENT_ID]} + ) + result = await ws_client.receive_json() + + assert result["success"] is True + + await ws_client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": LOCAL_AGENT_ID, + } + ) + result = await ws_client.receive_json() + + assert result["success"] is False + assert result["error"]["code"] == "home_assistant_error" + assert result["error"]["message"] == "Backup manager busy: create_backup" + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ("exception", "error_code", "error_message"), + [ + (BackupAgentError("Boom!"), "home_assistant_error", "Boom!"), + (Exception("Boom!"), "unknown_error", "Unknown error"), + ], +) +async def test_restore_backup_agent_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + exception: Exception, + error_code: str, + error_message: str, +) -> None: + """Test restore backup with agent error.""" + remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) + 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, + ), + ) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + 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, + } + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert not result["success"] + assert result["error"]["code"] == error_code + assert result["error"]["message"] == error_message + + assert download_mock.call_count == 1 + assert mocked_write_text.call_count == 0 + assert mocked_service_call.call_count == 0 + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "open_call_count", + "open_exception", + "write_call_count", + "write_exception", + "close_call_count", + "close_exception", + "write_text_call_count", + "write_text_exception", + "validate_password_call_count", + ), + [ + ( + 1, + OSError("Boom!"), + 0, + None, + 0, + None, + 0, + None, + 0, + ), + ( + 1, + None, + 1, + OSError("Boom!"), + 1, + None, + 0, + None, + 0, + ), + ( + 1, + None, + 1, + None, + 1, + OSError("Boom!"), + 0, + None, + 0, + ), + ( + 1, + None, + 1, + None, + 1, + None, + 1, + OSError("Boom!"), + 1, + ), + ], +) +async def test_restore_backup_file_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + open_call_count: int, + open_exception: list[Exception | None], + write_call_count: int, + write_exception: Exception | None, + close_call_count: int, + close_exception: list[Exception | None], + write_text_call_count: int, + write_text_exception: Exception | None, + validate_password_call_count: int, +) -> None: + """Test restore backup with file error.""" + remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) + 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, + ), + ) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + open_mock = mock_open() + open_mock.side_effect = open_exception + open_mock.return_value.write.side_effect = write_exception + open_mock.return_value.close.side_effect = close_exception + + with ( + patch("pathlib.Path.open", open_mock), + patch( + "pathlib.Path.write_text", side_effect=write_text_exception + ) as mocked_write_text, + patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, + 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, + } + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert not result["success"] + assert result["error"]["code"] == "unknown_error" + assert result["error"]["message"] == "Unknown error" + + assert download_mock.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 + assert open_mock.return_value.close.call_count == close_call_count + assert mocked_write_text.call_count == write_text_call_count + assert mocked_service_call.call_count == 0 From ee07f1f290a4ac9b4531c751cc04537be727c47b Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Sun, 26 Jan 2025 01:05:20 +0000 Subject: [PATCH 0975/2987] Bump ohmepy version to 1.2.6 (#136547) --- homeassistant/components/ohme/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index 67c41550491..bb3716c3e74 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.5"] + "requirements": ["ohme==1.2.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index f9a58779c8e..d006effb24f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1540,7 +1540,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.2.5 +ohme==1.2.6 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 127d08c22d6..6e720c4ee55 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1288,7 +1288,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.2.5 +ohme==1.2.6 # homeassistant.components.ollama ollama==0.4.7 From f8013655be6fcff339702619a149a2553e9a15fe Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sun, 26 Jan 2025 04:20:37 -0600 Subject: [PATCH 0976/2987] Move action implementation out of HEOS Coordinator (#136539) * Move play_source * Update property docstring * Correct import location --- homeassistant/components/heos/coordinator.py | 28 +++++-------------- homeassistant/components/heos/media_player.py | 16 ++++++++++- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 8ed8449685a..9fc3bb2460f 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -15,7 +15,6 @@ from pyheos import ( HeosError, HeosNowPlayingMedia, HeosOptions, - HeosPlayer, MediaItem, MediaType, PlayerUpdateResult, @@ -25,12 +24,12 @@ from pyheos import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HassJob, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -62,6 +61,11 @@ class HeosCoordinator(DataUpdateCoordinator[None]): self._inputs: list[MediaItem] = [] super().__init__(hass, _LOGGER, config_entry=config_entry, name=DOMAIN) + @property + def inputs(self) -> list[MediaItem]: + """Get input sources across all devices.""" + return self._inputs + async def async_setup(self) -> None: """Set up the coordinator; connect to the host; and retrieve initial data.""" # Add before connect as it may occur during initial connection @@ -265,21 +269,3 @@ class HeosCoordinator(DataUpdateCoordinator[None]): ): return favorite.name return None - - async def async_play_source(self, source: str, player: HeosPlayer) -> None: - """Determine type of source and play it.""" - # Favorite - if (index := self.async_get_favorite_index(source)) is not None: - await player.play_preset_station(index) - return - # Input source - for input_source in self._inputs: - if input_source.name == source: - await player.play_media(input_source) - return - - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="unknown_source", - translation_placeholders={"source": source}, - ) diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index d405b235f76..547f932c21f 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -306,7 +306,21 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): @catch_action_error("select source") async def async_select_source(self, source: str) -> None: """Select input source.""" - await self.coordinator.async_play_source(source, self._player) + # Favorite + if (index := self.coordinator.async_get_favorite_index(source)) is not None: + await self._player.play_preset_station(index) + return + # Input source + for input_source in self.coordinator.inputs: + if input_source.name == source: + await self._player.play_media(input_source) + return + + raise ServiceValidationError( + translation_domain=HEOS_DOMAIN, + translation_key="unknown_source", + translation_placeholders={"source": source}, + ) @catch_action_error("set repeat") async def async_set_repeat(self, repeat: RepeatMode) -> None: From 3adbf751549850645a9d5dad98faba164da87a54 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 26 Jan 2025 03:06:05 -0800 Subject: [PATCH 0977/2987] Bump opower to 0.8.8 (#136555) --- 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 bd68cc84d13..7227f7171ac 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.7"] + "requirements": ["opower==0.8.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index d006effb24f..e4231d4562a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1585,7 +1585,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.8.7 +opower==0.8.8 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e720c4ee55..bb8cc060fbc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1321,7 +1321,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.8.7 +opower==0.8.8 # homeassistant.components.oralb oralb-ble==0.17.6 From 93a231fb19b9e34097691d03da01406efd4ad943 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Sun, 26 Jan 2025 12:49:28 +0000 Subject: [PATCH 0978/2987] Homee cover-test (#136563) initial cover-test --- tests/components/homee/__init__.py | 12 ++ tests/components/homee/conftest.py | 21 ++-- tests/components/homee/fixtures/cover1.json | 101 ++++++++++++++++ tests/components/homee/fixtures/cover2.json | 101 ++++++++++++++++ tests/components/homee/fixtures/cover3.json | 101 ++++++++++++++++ tests/components/homee/fixtures/cover4.json | 101 ++++++++++++++++ tests/components/homee/test_cover.py | 124 ++++++++++++++++++++ 7 files changed, 551 insertions(+), 10 deletions(-) create mode 100644 tests/components/homee/fixtures/cover1.json create mode 100644 tests/components/homee/fixtures/cover2.json create mode 100644 tests/components/homee/fixtures/cover3.json create mode 100644 tests/components/homee/fixtures/cover4.json create mode 100644 tests/components/homee/test_cover.py diff --git a/tests/components/homee/__init__.py b/tests/components/homee/__init__.py index 03095aca7df..95fc6099269 100644 --- a/tests/components/homee/__init__.py +++ b/tests/components/homee/__init__.py @@ -1 +1,13 @@ """Tests for the homee component.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/homee/conftest.py b/tests/components/homee/conftest.py index a777f6b59a9..fb94ba0bbcc 100644 --- a/tests/components/homee/conftest.py +++ b/tests/components/homee/conftest.py @@ -17,6 +17,15 @@ TESTUSER = "testuser" TESTPASS = "testpass" +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.homee.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -32,15 +41,6 @@ def mock_config_entry() -> MockConfigEntry: ) -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Mock setting up a config entry.""" - with patch( - "homeassistant.components.homee.async_setup_entry", return_value=True - ) as mock_setup: - yield mock_setup - - @pytest.fixture def mock_homee() -> Generator[AsyncMock]: """Return a mock Homee instance.""" @@ -50,7 +50,7 @@ def mock_homee() -> Generator[AsyncMock]: ) as mocked_homee, patch( "homeassistant.components.homee.Homee", - autospec=True, + new=mocked_homee, ), ): homee = mocked_homee.return_value @@ -62,6 +62,7 @@ def mock_homee() -> Generator[AsyncMock]: homee.settings.uid = HOMEE_ID homee.settings.homee_name = HOMEE_NAME homee.reconnect_interval = 10 + homee.connected = True homee.get_access_token.return_value = "test_token" diff --git a/tests/components/homee/fixtures/cover1.json b/tests/components/homee/fixtures/cover1.json new file mode 100644 index 00000000000..8fedfb19d4f --- /dev/null +++ b/tests/components/homee/fixtures/cover1.json @@ -0,0 +1,101 @@ +{ + "id": 3, + "name": "Test%20Cover", + "profile": 2002, + "image": "default", + "favorite": 0, + "order": 4, + "protocol": 23, + "routing": 0, + "state": 1, + "state_changed": 1687175681, + "added": 1672086680, + "history": 1, + "cube_type": 14, + "note": "TestCoverDevice", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 4.0, + "unit": "n%2Fa", + "step_value": 1.0, + "editable": 1, + "type": 135, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "observes": [75], + "automations": ["toggle"] + } + }, + { + "id": 2, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "%25", + "step_value": 0.5, + "editable": 1, + "type": 15, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1 + } + } + }, + { + "id": 3, + "node_id": 3, + "instance": 0, + "minimum": -45, + "maximum": 90, + "current_value": -45.0, + "target_value": 0.0, + "last_value": -45.0, + "unit": "%C2%B0", + "step_value": 1.0, + "editable": 1, + "type": 113, + "state": 1, + "last_changed": 1678284920, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + } + ] +} diff --git a/tests/components/homee/fixtures/cover2.json b/tests/components/homee/fixtures/cover2.json new file mode 100644 index 00000000000..b53c3d49b62 --- /dev/null +++ b/tests/components/homee/fixtures/cover2.json @@ -0,0 +1,101 @@ +{ + "id": 1, + "name": "Test%20Cover", + "profile": 2002, + "image": "default", + "favorite": 0, + "order": 4, + "protocol": 23, + "routing": 0, + "state": 1, + "state_changed": 1687175681, + "added": 1672086680, + "history": 1, + "cube_type": 14, + "note": "TestCoverDevice", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "n%2Fa", + "step_value": 1.0, + "editable": 1, + "type": 135, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "observes": [75], + "automations": ["toggle"] + } + }, + { + "id": 2, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "%25", + "step_value": 0.5, + "editable": 1, + "type": 15, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1 + } + } + }, + { + "id": 3, + "node_id": 1, + "instance": 0, + "minimum": -45, + "maximum": 90, + "current_value": 90.0, + "target_value": 0.0, + "last_value": -45.0, + "unit": "%C2%B0", + "step_value": 1.0, + "editable": 1, + "type": 113, + "state": 1, + "last_changed": 1678284920, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + } + ] +} diff --git a/tests/components/homee/fixtures/cover3.json b/tests/components/homee/fixtures/cover3.json new file mode 100644 index 00000000000..0d3d5ea57e2 --- /dev/null +++ b/tests/components/homee/fixtures/cover3.json @@ -0,0 +1,101 @@ +{ + "id": 3, + "name": "Test%20Cover", + "profile": 2002, + "image": "default", + "favorite": 0, + "order": 4, + "protocol": 23, + "routing": 0, + "state": 1, + "state_changed": 1687175681, + "added": 1672086680, + "history": 1, + "cube_type": 14, + "note": "TestCoverDevice", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 3.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "n%2Fa", + "step_value": 1.0, + "editable": 1, + "type": 135, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "observes": [75], + "automations": ["toggle"] + } + }, + { + "id": 2, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 75.0, + "target_value": 0.0, + "last_value": 100.0, + "unit": "%25", + "step_value": 0.5, + "editable": 1, + "type": 15, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1 + } + } + }, + { + "id": 3, + "node_id": 3, + "instance": 0, + "minimum": -45, + "maximum": 90, + "current_value": 56.0, + "target_value": 56.0, + "last_value": 0.0, + "unit": "%C2%B0", + "step_value": 1.0, + "editable": 1, + "type": 113, + "state": 1, + "last_changed": 1678284920, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + } + ] +} diff --git a/tests/components/homee/fixtures/cover4.json b/tests/components/homee/fixtures/cover4.json new file mode 100644 index 00000000000..a3de555794a --- /dev/null +++ b/tests/components/homee/fixtures/cover4.json @@ -0,0 +1,101 @@ +{ + "id": 3, + "name": "Test%20Cover", + "profile": 2002, + "image": "default", + "favorite": 0, + "order": 4, + "protocol": 23, + "routing": 0, + "state": 1, + "state_changed": 1687175681, + "added": 1672086680, + "history": 1, + "cube_type": 14, + "note": "TestCoverDevice", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 4.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "n%2Fa", + "step_value": 1.0, + "editable": 1, + "type": 135, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "observes": [75], + "automations": ["toggle"] + } + }, + { + "id": 2, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 25.0, + "target_value": 100.0, + "last_value": 0.0, + "unit": "%25", + "step_value": 0.5, + "editable": 1, + "type": 15, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1 + } + } + }, + { + "id": 3, + "node_id": 3, + "instance": 0, + "minimum": -45, + "maximum": 90, + "current_value": -11.0, + "target_value": 0.0, + "last_value": -45.0, + "unit": "%C2%B0", + "step_value": 1.0, + "editable": 1, + "type": 113, + "state": 1, + "last_changed": 1678284920, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + } + ] +} diff --git a/tests/components/homee/test_cover.py b/tests/components/homee/test_cover.py new file mode 100644 index 00000000000..a7feaa10b66 --- /dev/null +++ b/tests/components/homee/test_cover.py @@ -0,0 +1,124 @@ +"""Test homee covers.""" + +from unittest.mock import AsyncMock, MagicMock + +from pyHomee import HomeeNode + +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN, CoverState +from homeassistant.components.homee.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry, load_json_object_fixture + + +async def test_cover_open( + hass: HomeAssistant, mock_homee: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test an open cover.""" + # Cover open, tilt open. + cover_json = load_json_object_fixture("cover1.json", DOMAIN) + cover_node = HomeeNode(cover_json) + mock_homee.nodes = [cover_node] + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.test_cover").state == CoverState.OPEN + + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("supported_features") == 143 + assert attributes.get("current_position") == 100 + assert attributes.get("current_tilt_position") == 100 + + +async def test_cover_closed( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test a closed cover.""" + # Cover closed, tilt closed. + cover_json = load_json_object_fixture("cover2.json", DOMAIN) + cover_node = HomeeNode(cover_json) + mock_homee.nodes = [cover_node] + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.test_cover").state == CoverState.CLOSED + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("current_position") == 0 + assert attributes.get("current_tilt_position") == 0 + + +async def test_cover_opening( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test an opening cover.""" + # opening, 75% homee / 25% HA + cover_json = load_json_object_fixture("cover3.json", DOMAIN) + cover_node = HomeeNode(cover_json) + mock_homee.nodes = [cover_node] + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.test_cover").state == CoverState.OPENING + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("current_position") == 25 + assert attributes.get("current_tilt_position") == 25 + + +async def test_cover_closing( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test a closing cover.""" + # closing, 25% homee / 75% HA + cover_json = load_json_object_fixture("cover4.json", DOMAIN) + cover_node = HomeeNode(cover_json) + mock_homee.nodes = [cover_node] + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.test_cover").state == CoverState.CLOSING + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("current_position") == 75 + assert attributes.get("current_tilt_position") == 74 + + +async def test_open_cover( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test opening the cover.""" + # Cover closed, tilt closed. + cover_json = load_json_object_fixture("cover2.json", DOMAIN) + cover_node = HomeeNode(cover_json) + mock_homee.nodes = [cover_node] + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_cover"}, + blocking=True, + ) + mock_homee.set_value.assert_called_once_with(cover_node.id, 1, 0) + + +async def test_close_cover( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test opening the cover.""" + # Cover open, tilt open. + cover_json = load_json_object_fixture("cover1.json", DOMAIN) + cover_node = HomeeNode(cover_json) + mock_homee.nodes = [cover_node] + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_cover"}, + blocking=True, + ) + mock_homee.set_value.assert_called_once_with(cover_node.id, 1, 1) From 7044771876a12d3d0f4a23b67664a3805f59ba2a Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Sun, 26 Jan 2025 12:52:01 +0000 Subject: [PATCH 0979/2987] Add select platform to Ohme (#136536) * Add select platform * Formatting * Add parallel updates to select * Remove comments --- homeassistant/components/ohme/const.py | 1 + homeassistant/components/ohme/icons.json | 5 ++ homeassistant/components/ohme/select.py | 70 ++++++++++++++++++ homeassistant/components/ohme/strings.json | 10 +++ .../ohme/snapshots/test_select.ambr | 58 +++++++++++++++ tests/components/ohme/test_select.py | 72 +++++++++++++++++++ 6 files changed, 216 insertions(+) create mode 100644 homeassistant/components/ohme/select.py create mode 100644 tests/components/ohme/snapshots/test_select.ambr create mode 100644 tests/components/ohme/test_select.py diff --git a/homeassistant/components/ohme/const.py b/homeassistant/components/ohme/const.py index 308664ba0ad..d97f6e3cfd7 100644 --- a/homeassistant/components/ohme/const.py +++ b/homeassistant/components/ohme/const.py @@ -6,6 +6,7 @@ DOMAIN = "ohme" PLATFORMS = [ Platform.BUTTON, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.TIME, diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json index a6b04004833..7a27156b2fe 100644 --- a/homeassistant/components/ohme/icons.json +++ b/homeassistant/components/ohme/icons.json @@ -10,6 +10,11 @@ "default": "mdi:battery-heart" } }, + "select": { + "charge_mode": { + "default": "mdi:play-box" + } + }, "sensor": { "status": { "default": "mdi:car", diff --git a/homeassistant/components/ohme/select.py b/homeassistant/components/ohme/select.py new file mode 100644 index 00000000000..a357e98f0a6 --- /dev/null +++ b/homeassistant/components/ohme/select.py @@ -0,0 +1,70 @@ +"""Platform for Ohme selects.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, Final + +from ohme import ApiException, ChargerMode, OhmeApiClient + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OhmeConfigEntry +from .const import DOMAIN +from .entity import OhmeEntity, OhmeEntityDescription + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class OhmeSelectDescription(OhmeEntityDescription, SelectEntityDescription): + """Class to describe an Ohme select entity.""" + + select_fn: Callable[[OhmeApiClient, Any], Awaitable[None]] + current_option_fn: Callable[[OhmeApiClient], str | None] + + +SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription( + key="charge_mode", + translation_key="charge_mode", + select_fn=lambda client, mode: client.async_set_mode(mode), + options=[e.value for e in ChargerMode], + current_option_fn=lambda client: client.mode.value if client.mode else None, + available_fn=lambda client: client.mode is not None, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OhmeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Ohme selects.""" + coordinator = config_entry.runtime_data.charge_session_coordinator + + async_add_entities([OhmeSelect(coordinator, SELECT_DESCRIPTION)]) + + +class OhmeSelect(OhmeEntity, SelectEntity): + """Ohme select entity.""" + + entity_description: OhmeSelectDescription + + async def async_select_option(self, option: str) -> None: + """Handle the selection of an option.""" + try: + await self.entity_description.select_fn(self.coordinator.client, option) + except ApiException as e: + raise HomeAssistantError( + translation_key="api_failed", translation_domain=DOMAIN + ) from e + await self.coordinator.async_request_refresh() + + @property + def current_option(self) -> str | None: + """Return the current selected option.""" + return self.entity_description.current_option_fn(self.coordinator.client) diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 84f62ba65ab..eb5bbffda52 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -55,6 +55,16 @@ "name": "Target percentage" } }, + "select": { + "charge_mode": { + "name": "Charge mode", + "state": { + "smart_charge": "Smart charge", + "max_charge": "Max charge", + "paused": "Paused" + } + } + }, "sensor": { "status": { "name": "Status", diff --git a/tests/components/ohme/snapshots/test_select.ambr b/tests/components/ohme/snapshots/test_select.ambr new file mode 100644 index 00000000000..04770397098 --- /dev/null +++ b/tests/components/ohme/snapshots/test_select.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_selects[select.ohme_home_pro_charge_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'smart_charge', + 'max_charge', + 'paused', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.ohme_home_pro_charge_mode', + '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': 'Charge mode', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'chargerid_charge_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.ohme_home_pro_charge_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Charge mode', + 'options': list([ + 'smart_charge', + 'max_charge', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'select.ohme_home_pro_charge_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/ohme/test_select.py b/tests/components/ohme/test_select.py new file mode 100644 index 00000000000..5aeebc1f477 --- /dev/null +++ b/tests/components/ohme/test_select.py @@ -0,0 +1,72 @@ +"""Tests for selects.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from ohme import ChargerMode +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, 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_selects( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the Ohme selects.""" + with patch("homeassistant.components.ohme.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_select_option( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test selecting an option in the Ohme select entity.""" + mock_client.mode = ChargerMode.SMART_CHARGE + mock_client.async_set_mode = AsyncMock() + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("select.ohme_home_pro_charge_mode") + assert state is not None + assert state.state == "smart_charge" + + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.ohme_home_pro_charge_mode", + "option": "max_charge", + }, + blocking=True, + ) + + mock_client.async_set_mode.assert_called_once_with("max_charge") + assert state.state == "smart_charge" + + +async def test_select_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test that the select entity shows as unavailable when no mode is set.""" + mock_client.mode = None + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("select.ohme_home_pro_charge_mode") + assert state is not None + assert state.state == STATE_UNAVAILABLE From a9f14ce174d39698b78c7076fad1f8338b26b7a8 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Sun, 26 Jan 2025 13:48:35 +0000 Subject: [PATCH 0980/2987] Bump pyHomee to 1.2.5 (#136567) --- homeassistant/components/homee/__init__.py | 4 ++-- homeassistant/components/homee/entity.py | 8 ++++---- homeassistant/components/homee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 1ec09e09694..9837d6094ff 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -51,14 +51,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo entry.runtime_data = homee entry.async_on_unload(homee.disconnect) - async def _connection_update_callback(connected: bool) -> None: + def _connection_update_callback(connected: bool) -> None: """Call when the device is notified of changes.""" if connected: _LOGGER.warning("Reconnected to Homee at %s", entry.data[CONF_HOST]) else: _LOGGER.warning("Disconnected from Homee at %s", entry.data[CONF_HOST]) - await homee.add_connection_listener(_connection_update_callback) + homee.add_connection_listener(_connection_update_callback) # create device register entry device_registry = dr.async_get(hass) diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py index a6cd54354bf..50b67e582bb 100644 --- a/homeassistant/components/homee/entity.py +++ b/homeassistant/components/homee/entity.py @@ -38,7 +38,7 @@ class HomeeEntity(Entity): self._attribute.add_on_changed_listener(self._on_node_updated) ) self.async_on_remove( - await self._entry.runtime_data.add_connection_listener( + self._entry.runtime_data.add_connection_listener( self._on_connection_changed ) ) @@ -56,7 +56,7 @@ class HomeeEntity(Entity): def _on_node_updated(self, attribute: HomeeAttribute) -> None: self.schedule_update_ha_state() - async def _on_connection_changed(self, connected: bool) -> None: + def _on_connection_changed(self, connected: bool) -> None: self._host_connected = connected self.schedule_update_ha_state() @@ -93,7 +93,7 @@ class HomeeNodeEntity(Entity): """Add the homee binary sensor device to home assistant.""" self.async_on_remove(self._node.add_on_changed_listener(self._on_node_updated)) self.async_on_remove( - await self._entry.runtime_data.add_connection_listener( + self._entry.runtime_data.add_connection_listener( self._on_connection_changed ) ) @@ -142,6 +142,6 @@ class HomeeNodeEntity(Entity): def _on_node_updated(self, node: HomeeNode) -> None: self.schedule_update_ha_state() - async def _on_connection_changed(self, connected: bool) -> None: + def _on_connection_changed(self, connected: bool) -> None: self._host_connected = connected self.schedule_update_ha_state() diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json index 6d03547efc9..d85ba25b6e7 100644 --- a/homeassistant/components/homee/manifest.json +++ b/homeassistant/components/homee/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["homee"], "quality_scale": "bronze", - "requirements": ["pyHomee==1.2.3"] + "requirements": ["pyHomee==1.2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index e4231d4562a..b0ecc10914b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1763,7 +1763,7 @@ pyEmby==1.10 pyHik==0.3.2 # homeassistant.components.homee -pyHomee==1.2.3 +pyHomee==1.2.5 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb8cc060fbc..9333914685a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1452,7 +1452,7 @@ pyDuotecno==2024.10.1 pyElectra==1.2.4 # homeassistant.components.homee -pyHomee==1.2.3 +pyHomee==1.2.5 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 From c9218b91c1950cebc502457ff5b396fa43a2ce1c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 26 Jan 2025 16:33:43 +0100 Subject: [PATCH 0981/2987] Make casing of "server" and action descriptions consistent (#136561) --- .../components/music_assistant/strings.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index af366c94310..32b72088518 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -7,15 +7,15 @@ } }, "manual": { - "title": "Manually add Music Assistant Server", - "description": "Enter the URL to your already running Music Assistant Server. If you do not have the Music Assistant Server running, you should install it first.", + "title": "Manually add Music Assistant server", + "description": "Enter the URL to your already running Music Assistant server. If you do not have the Music Assistant server running, you should install it first.", "data": { "url": "URL of the Music Assistant server" } }, "discovery_confirm": { - "description": "Do you want to add the Music Assistant Server `{url}` to Home Assistant?", - "title": "Discovered Music Assistant Server" + "description": "Do you want to add the Music Assistant server `{url}` to Home Assistant?", + "title": "Discovered Music Assistant server" } }, "error": { @@ -34,13 +34,13 @@ "issues": { "invalid_server_version": { "title": "The Music Assistant server is not the correct version", - "description": "Check if there are updates available for the Music Assistant Server and/or integration." + "description": "Check if there are updates available for the Music Assistant server and/or integration." } }, "services": { "play_media": { "name": "Play media", - "description": "Play media on a Music Assistant player with more fine-grained control options.", + "description": "Plays media on a Music Assistant player with more fine-grained control options.", "fields": { "media_id": { "name": "Media ID(s)", @@ -70,7 +70,7 @@ }, "play_announcement": { "name": "Play announcement", - "description": "Play announcement on a Music Assistant player with more fine-grained control options.", + "description": "Plays an announcement on a Music Assistant player with more fine-grained control options.", "fields": { "url": { "name": "URL", @@ -88,7 +88,7 @@ }, "transfer_queue": { "name": "Transfer queue", - "description": "Transfer the player's queue to another player.", + "description": "Transfers a player's queue to another player.", "fields": { "source_player": { "name": "Source media player", @@ -102,11 +102,11 @@ }, "get_queue": { "name": "Get playerQueue details (advanced)", - "description": "Get the details of the currently active queue of a Music Assistant player." + "description": "Retrieves the details of the currently active queue of a Music Assistant player." }, "search": { "name": "Search Music Assistant", - "description": "Perform a global search on the Music Assistant library and all providers.", + "description": "Performs a global search on the Music Assistant library and all providers.", "fields": { "config_entry_id": { "name": "Music Assistant instance", From b467bb2813e754a24431ff9b09fc7562e50b5945 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sun, 26 Jan 2025 09:41:04 -0600 Subject: [PATCH 0982/2987] Use typed ConfigEntry throughout HEOS (#136569) --- homeassistant/components/heos/__init__.py | 5 +---- homeassistant/components/heos/config_flow.py | 5 ++--- homeassistant/components/heos/coordinator.py | 4 +++- homeassistant/components/heos/media_player.py | 3 +-- homeassistant/components/heos/services.py | 7 ++++++- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 10fd2bfcff3..b119ea83064 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -4,7 +4,6 @@ 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 homeassistant.helpers import device_registry as dr @@ -13,7 +12,7 @@ from homeassistant.helpers.typing import ConfigType from . import services from .const import DOMAIN -from .coordinator import HeosCoordinator +from .coordinator import HeosConfigEntry, HeosCoordinator PLATFORMS = [Platform.MEDIA_PLAYER] @@ -21,8 +20,6 @@ MIN_UPDATE_SOURCES = timedelta(seconds=1) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -type HeosConfigEntry = ConfigEntry[HeosCoordinator] - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HEOS component.""" diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index 18b8f1f7918..db2abee559c 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -9,7 +9,6 @@ from pyheos import CommandAuthenticationError, Heos, HeosError, HeosOptions import voluptuous as vol from homeassistant.config_entries import ( - ConfigEntry, ConfigEntryState, ConfigFlow, ConfigFlowResult, @@ -23,8 +22,8 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) -from . import HeosConfigEntry from .const import DOMAIN +from .coordinator import HeosConfigEntry _LOGGER = logging.getLogger(__name__) @@ -107,7 +106,7 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: HeosConfigEntry) -> OptionsFlow: """Create the options flow.""" return HeosOptionsFlowHandler() diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 9fc3bb2460f..1cd75049f16 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -33,11 +33,13 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type HeosConfigEntry = ConfigEntry[HeosCoordinator] + class HeosCoordinator(DataUpdateCoordinator[None]): """Define the HEOS integration coordinator.""" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: HeosConfigEntry) -> None: """Set up the coordinator and set in config_entry.""" self.host: str = config_entry.data[CONF_HOST] credentials: Credentials | None = None diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 547f932c21f..bee03018f7c 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -38,9 +38,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow -from . import HeosConfigEntry from .const import DOMAIN as HEOS_DOMAIN -from .coordinator import HeosCoordinator +from .coordinator import HeosConfigEntry, HeosCoordinator PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index 5a0105f830e..c447befbb30 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -1,6 +1,7 @@ """Services for the HEOS integration.""" import logging +from typing import cast from pyheos import CommandAuthenticationError, Heos, HeosError import voluptuous as vol @@ -17,6 +18,7 @@ from .const import ( SERVICE_SIGN_IN, SERVICE_SIGN_OUT, ) +from .coordinator import HeosConfigEntry _LOGGER = logging.getLogger(__name__) @@ -59,7 +61,10 @@ def _get_controller(hass: HomeAssistant) -> Heos: translation_key="sign_in_out_deprecated", ) - entry = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN) + entry = cast( + HeosConfigEntry, + hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN), + ) if not entry or not entry.state == ConfigEntryState.LOADED: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="integration_not_loaded" From a2bc260dc15b285972bef28bec589ae628c9d2ef Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sun, 26 Jan 2025 09:51:29 -0600 Subject: [PATCH 0983/2987] Bump HEOS quality scale to silver (#136533) bump heos quality scale --- homeassistant/components/heos/manifest.json | 1 + script/hassfest/quality_scale.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index e3d2632e340..ebeb851f37a 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -7,6 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyheos"], + "quality_scale": "silver", "requirements": ["pyheos==1.0.0"], "single_config_entry": true, "ssdp": [ diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 3732101913c..706a482523a 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1544,7 +1544,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "haveibeenpwned", "hddtemp", "hdmi_cec", - "heos", "heatmiser", "here_travel_time", "hikvision", From 6a877ec77da9fd9f58b3a920534b97d9f26208cf Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sun, 26 Jan 2025 09:53:10 -0600 Subject: [PATCH 0984/2987] Don't cast type in HEOS services (#136583) --- homeassistant/components/heos/services.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index c447befbb30..f4d5961cc47 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -1,7 +1,6 @@ """Services for the HEOS integration.""" import logging -from typing import cast from pyheos import CommandAuthenticationError, Heos, HeosError import voluptuous as vol @@ -61,10 +60,10 @@ def _get_controller(hass: HomeAssistant) -> Heos: translation_key="sign_in_out_deprecated", ) - entry = cast( - HeosConfigEntry, - hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN), + entry: HeosConfigEntry | None = ( + hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN) ) + if not entry or not entry.state == ConfigEntryState.LOADED: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="integration_not_loaded" From b27ee261bbf02379bfcd2b165311008e64ccf386 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sun, 26 Jan 2025 10:25:30 -0600 Subject: [PATCH 0985/2987] Fix HEOS play media type playlist (#136585) --- homeassistant/components/heos/media_player.py | 7 +++---- tests/components/heos/test_media_player.py | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index bee03018f7c..0c401f01470 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -279,13 +279,12 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): return if media_type == MediaType.PLAYLIST: - playlists = await self._player.heos.get_playlists() + playlists = await self.coordinator.heos.get_playlists() playlist = next((p for p in playlists if p.name == media_id), None) if not playlist: raise ValueError(f"Invalid playlist '{media_id}'") - add_queue_option = HA_HEOS_ENQUEUE_MAP.get(kwargs.get(ATTR_MEDIA_ENQUEUE)) - - await self._player.add_to_queue(playlist, add_queue_option) + add_queue_option = HA_HEOS_ENQUEUE_MAP[kwargs.get(ATTR_MEDIA_ENQUEUE)] + await self._player.play_media(playlist, add_queue_option) return if media_type == "favorite": diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 2d9f69d764d..8fc63bbc7ad 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -1054,7 +1054,7 @@ async def test_play_media_playlist( service_data, blocking=True, ) - player.add_to_queue.assert_called_once_with(playlist, criteria) + player.play_media.assert_called_once_with(playlist, criteria) async def test_play_media_playlist_error( From 363ecde41b75dd7968a59ecc88da1ce649bab2d8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 26 Jan 2025 17:32:09 +0100 Subject: [PATCH 0986/2987] Fix spelling of "Home Assistant" and "IDs" in xiaomi_aqara (#136578) --- homeassistant/components/xiaomi_aqara/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara/strings.json b/homeassistant/components/xiaomi_aqara/strings.json index 75b4ab1ecda..6221b9b9d65 100644 --- a/homeassistant/components/xiaomi_aqara/strings.json +++ b/homeassistant/components/xiaomi_aqara/strings.json @@ -26,7 +26,7 @@ } }, "error": { - "discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running HomeAssistant as interface", + "discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running Home Assistant as interface", "invalid_interface": "Invalid network interface", "invalid_key": "Invalid Gateway key", "invalid_host": "Invalid hostname or IP address, see https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem", @@ -59,7 +59,7 @@ }, "ringtone_id": { "name": "Ringtone ID", - "description": "One of the allowed ringtone ids." + "description": "One of the allowed ringtone IDs." }, "ringtone_vol": { "name": "Ringtone volume", From 909af0db82f48e08e591687538a6a0dbbb296ead Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 26 Jan 2025 17:33:33 +0100 Subject: [PATCH 0987/2987] Fix sentence-casing in action names, spelling of "IDs" (#136576) --- homeassistant/components/ecobee/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 7713a8fb4b9..2b44c45edef 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -138,7 +138,7 @@ } }, "set_dst_mode": { - "name": "Set Daylight savings time mode", + "name": "Set daylight savings time mode", "description": "Enables/disables automatic daylight savings time.", "fields": { "dst_enabled": { @@ -172,8 +172,8 @@ } }, "set_sensors_used_in_climate": { - "name": "Set Sensors Used in Climate", - "description": "Sets the participating sensors for a climate.", + "name": "Set sensors used in climate", + "description": "Sets the participating sensors for a climate program.", "fields": { "entity_id": { "name": "Entity", @@ -198,7 +198,7 @@ "message": "Invalid sensor for thermostat, available options are: {options}" }, "sensor_lookup_failed": { - "message": "There was an error getting the sensor ids from sensor names. Try reloading the ecobee integration." + "message": "There was an error getting the sensor IDs from sensor names. Try reloading the ecobee integration." } }, "issues": { From feb65c7e9f7569aa75dd365cab72bee03bbd223c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Jan 2025 17:42:10 +0100 Subject: [PATCH 0988/2987] Fix optional argument in deconz test type definition (#136411) --- tests/components/deconz/conftest.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/components/deconz/conftest.py b/tests/components/deconz/conftest.py index fd3003b96ef..4a74a673ef8 100644 --- a/tests/components/deconz/conftest.py +++ b/tests/components/deconz/conftest.py @@ -19,9 +19,14 @@ from tests.common import MockConfigEntry from tests.components.light.conftest import mock_light_profiles # noqa: F401 from tests.test_util.aiohttp import AiohttpClientMocker -type ConfigEntryFactoryType = Callable[ - [MockConfigEntry], Coroutine[Any, Any, MockConfigEntry] -] + +class ConfigEntryFactoryType(Protocol): + """Fixture factory that can set up deCONZ config entry.""" + + async def __call__(self, entry: MockConfigEntry = ..., /) -> MockConfigEntry: + """Set up a deCONZ config entry.""" + + type WebsocketDataType = Callable[[dict[str, Any]], Coroutine[Any, Any, None]] type WebsocketStateType = Callable[[str], Coroutine[Any, Any, None]] @@ -203,10 +208,10 @@ async def fixture_config_entry_factory( config_entry: MockConfigEntry, mock_requests: Callable[[str], None], ) -> ConfigEntryFactoryType: - """Fixture factory that can set up UniFi network integration.""" + """Fixture factory that can set up deCONZ integration.""" async def __mock_setup_config_entry( - entry: MockConfigEntry = config_entry, + entry: MockConfigEntry = config_entry, / ) -> MockConfigEntry: entry.add_to_hass(hass) mock_requests(entry.data[CONF_HOST]) From 647a7ae8e0c46ac350f545c51d39ee662f4a6f81 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 26 Jan 2025 17:46:26 +0100 Subject: [PATCH 0989/2987] Bump yt-dlp to 2025.01.26 (#136581) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index becca8e6da8..f0f8ee03ad0 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2025.01.15"], + "requirements": ["yt-dlp[default]==2025.01.26"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index b0ecc10914b..c687944081f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3106,7 +3106,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.01.15 +yt-dlp[default]==2025.01.26 # homeassistant.components.zabbix zabbix-utils==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9333914685a..69b6912ec56 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2501,7 +2501,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.01.15 +yt-dlp[default]==2025.01.26 # homeassistant.components.zamg zamg==0.3.6 From db2fed2034f6c35dc05e80ba3b3f9f8e58ebfa8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Sun, 26 Jan 2025 18:42:44 +0100 Subject: [PATCH 0990/2987] Fix LetPot reauthentication flow tests setting up config entry (#136589) Fix LetPot reauth tests setting up config entry --- tests/components/letpot/test_config_flow.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/components/letpot/test_config_flow.py b/tests/components/letpot/test_config_flow.py index 0ec1bd95d91..425298dc231 100644 --- a/tests/components/letpot/test_config_flow.py +++ b/tests/components/letpot/test_config_flow.py @@ -149,7 +149,7 @@ async def test_flow_duplicate( async def test_reauth_flow( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry ) -> None: """Test reauth flow with success.""" mock_config_entry.add_to_hass(hass) @@ -196,6 +196,7 @@ async def test_reauth_flow( ) async def test_reauth_exceptions( hass: HomeAssistant, + mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry, exception: Exception, error: str, @@ -249,7 +250,7 @@ async def test_reauth_exceptions( async def test_reauth_different_user_id_new( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry ) -> None: """Test reauth flow with different, new user ID updating the existing entry.""" mock_config_entry.add_to_hass(hass) @@ -288,7 +289,7 @@ async def test_reauth_different_user_id_new( async def test_reauth_different_user_id_existing( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry ) -> None: """Test reauth flow with different, existing user ID aborting.""" mock_config_entry.add_to_hass(hass) From 40127a5ca4fb8b1b15c2b274df77d0251f7d83d8 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 26 Jan 2025 20:03:13 +0100 Subject: [PATCH 0991/2987] Add Reolink privacy switch entity (#136521) --- homeassistant/components/reolink/icons.json | 6 +++++ homeassistant/components/reolink/strings.json | 3 +++ homeassistant/components/reolink/switch.py | 26 +++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index a9c231bf68f..26198a11594 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -371,6 +371,12 @@ }, "led": { "default": "mdi:lightning-bolt-circle" + }, + "privacy_mode": { + "default": "mdi:eye", + "state": { + "on": "mdi:eye-off" + } } } }, diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 412362fc447..1cadc16f818 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -808,6 +808,9 @@ }, "led": { "name": "LED" + }, + "privacy_mode": { + "name": "Privacy mode" } } } diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 85c35b5c987..cecb0b0000f 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -208,6 +208,17 @@ SWITCH_ENTITIES = ( ), ) +AVAILABILITY_SWITCH_ENTITIES = ( + ReolinkSwitchEntityDescription( + key="privacy_mode", + translation_key="privacy_mode", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "privacy_mode"), + value=lambda api, ch: api.baichuan.privacy_mode(ch), + method=lambda api, ch, value: api.baichuan.set_privacy_mode(ch, value), + ), +) + NVR_SWITCH_ENTITIES = ( ReolinkNVRSwitchEntityDescription( key="email", @@ -344,6 +355,12 @@ async def async_setup_entry( for entity_description in CHIME_SWITCH_ENTITIES for chime in reolink_data.host.api.chime_list ) + entities.extend( + ReolinkAvailabilitySwitchEntity(reolink_data, channel, entity_description) + for entity_description in AVAILABILITY_SWITCH_ENTITIES + for channel in reolink_data.host.api.channels + if entity_description.supported(reolink_data.host.api, channel) + ) # Can be removed in HA 2025.4.0 depricated_dict = {} @@ -409,6 +426,15 @@ class ReolinkSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity): self.async_write_ha_state() +class ReolinkAvailabilitySwitchEntity(ReolinkSwitchEntity): + """Switch entity class for Reolink IP cameras which will be available even if API is unavailable.""" + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._host.api.camera_online(self._channel) + + class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity): """Switch entity class for Reolink NVR features.""" From 7133eec18588c4313e998c37785f9883cafc271d Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sun, 26 Jan 2025 20:43:31 +0000 Subject: [PATCH 0992/2987] Bump python-kasa to 0.10.0 (#136586) Bump python-kasa to 0.10.0 Release notes: https://github.com/python-kasa/python-kasa/releases/tag/0.10.0 --- homeassistant/components/tplink/manifest.json | 2 +- homeassistant/components/tplink/siren.py | 3 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tplink/const.py | 3 --- tests/components/tplink/test_config_flow.py | 4 ---- tests/components/tplink/test_init.py | 15 ++++++++++----- 7 files changed, 14 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index a975e675ceb..f55dfda1664 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -300,5 +300,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink", "iot_class": "local_polling", "loggers": ["kasa"], - "requirements": ["python-kasa[speedups]==0.9.1"] + "requirements": ["python-kasa[speedups]==0.10.0"] } diff --git a/homeassistant/components/tplink/siren.py b/homeassistant/components/tplink/siren.py index 5931a508d6c..d1ce03c1469 100644 --- a/homeassistant/components/tplink/siren.py +++ b/homeassistant/components/tplink/siren.py @@ -7,7 +7,6 @@ from dataclasses import dataclass from typing import Any from kasa import Device, Module -from kasa.smart.modules.alarm import Alarm from homeassistant.components.siren import ( DOMAIN as SIREN_DOMAIN, @@ -101,7 +100,7 @@ class TPLinkSirenEntity(CoordinatedTPLinkModuleEntity, SirenEntity): ) -> None: """Initialize the siren entity.""" super().__init__(device, coordinator, description, parent=parent) - self._alarm_module: Alarm = device.modules[Module.Alarm] + self._alarm_module = device.modules[Module.Alarm] @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index c687944081f..c1148cc3b6b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2396,7 +2396,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.9.1 +python-kasa[speedups]==0.10.0 # homeassistant.components.linkplay python-linkplay==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 69b6912ec56..3c946d89857 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1935,7 +1935,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.9.1 +python-kasa[speedups]==0.10.0 # homeassistant.components.linkplay python-linkplay==0.1.3 diff --git a/tests/components/tplink/const.py b/tests/components/tplink/const.py index 57829a7aa34..54aab1e2f3c 100644 --- a/tests/components/tplink/const.py +++ b/tests/components/tplink/const.py @@ -55,7 +55,6 @@ DEVICE_CONFIG_KLAP = DeviceConfig( IP_ADDRESS, credentials=CREDENTIALS, connection_type=CONN_PARAMS_KLAP, - uses_http=True, ) CONN_PARAMS_AES = DeviceConnectionParameters( DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Aes @@ -84,7 +83,6 @@ DEVICE_CONFIG_AES = DeviceConfig( IP_ADDRESS2, credentials=CREDENTIALS, connection_type=CONN_PARAMS_AES, - uses_http=True, aes_keys=AES_KEYS, ) CONN_PARAMS_AES_CAMERA = DeviceConnectionParameters( @@ -94,7 +92,6 @@ DEVICE_CONFIG_AES_CAMERA = DeviceConfig( IP_ADDRESS3, credentials=CREDENTIALS, connection_type=CONN_PARAMS_AES_CAMERA, - uses_http=True, ) DEVICE_CONFIG_DICT_KLAP = { diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index b093847869e..35fd4f418de 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -1169,7 +1169,6 @@ async def test_manual_port_override( host, credentials=None, port_override=port, - uses_http=True, connection_type=CONN_PARAMS_KLAP, ) mock_device = _mocked_device( @@ -1491,7 +1490,6 @@ async def test_integration_discovery_with_ip_change( # Check that init set the new host correctly before calling connect assert config.host == IP_ADDRESS config.host = IP_ADDRESS2 - config.uses_http = False # Not passed in to new config class config.http_client = "Foo" mock_connect["connect"].assert_awaited_once_with(config=config) @@ -1578,7 +1576,6 @@ async def test_integration_discovery_with_connection_change( assert mock_config_entry.state is ConfigEntryState.LOADED config.host = IP_ADDRESS2 - config.uses_http = False # Not passed in to new config class config.http_client = "Foo" config.aes_keys = AES_KEYS mock_connect["connect"].assert_awaited_once_with(config=config) @@ -1847,7 +1844,6 @@ async def test_reauth_update_with_encryption_change( connection_type=Device.ConnectionParameters( Device.Family.SmartTapoPlug, Device.EncryptionType.Klap ), - uses_http=True, ) mock_device = _mocked_device( alias="my_device", diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index ffcadc79faf..972ca73c45c 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -246,7 +246,6 @@ async def test_config_entry_with_stored_credentials( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED config = DeviceConfig.from_dict(DEVICE_CONFIG_KLAP.to_dict()) - config.uses_http = False config.http_client = "Foo" assert config.credentials != stored_credentials config.credentials = stored_credentials @@ -762,7 +761,6 @@ async def test_credentials_hash_auth_error( expected_config = DeviceConfig.from_dict( {**DEVICE_CONFIG_DICT_KLAP, "credentials_hash": "theHash"} ) - expected_config.uses_http = False expected_config.http_client = "Foo" connect_mock.assert_called_with(config=expected_config) assert entry.state is ConfigEntryState.SETUP_ERROR @@ -794,13 +792,20 @@ async def test_migrate_remove_device_config( As async_setup_entry will succeed the hash on the parent is updated from the device. """ + old_device_config = { + k: v for k, v in device_config.to_dict().items() if k != "credentials" + } + device_config_dict = { + **old_device_config, + "uses_http": device_config.connection_type.encryption_type + is not Device.EncryptionType.Xor, + } + OLD_CREATE_ENTRY_DATA = { CONF_HOST: expected_entry_data[CONF_HOST], CONF_ALIAS: ALIAS, CONF_MODEL: MODEL, - CONF_DEVICE_CONFIG: { - k: v for k, v in device_config.to_dict().items() if k != "credentials" - }, + CONF_DEVICE_CONFIG: device_config_dict, } entry = MockConfigEntry( From 3e0f6562c7ee5c7a89828d6c517867ca22d5c8f8 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 26 Jan 2025 21:57:32 +0100 Subject: [PATCH 0993/2987] Cleanup stale devices on incomfort integration startup (#136566) --- .../components/incomfort/__init__.py | 44 +++++++++++++- tests/components/incomfort/test_init.py | 58 ++++++++++++++++++- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index 249a0ae9085..4d05a57bcfa 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -7,12 +7,12 @@ from incomfortclient import InvalidGateway, InvalidHeaterList from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from .const import DOMAIN -from .coordinator import InComfortDataCoordinator, async_connect_gateway +from .coordinator import InComfortData, InComfortDataCoordinator, async_connect_gateway from .errors import InComfortTimeout, InComfortUnknownError, NoHeaters, NotFound PLATFORMS = ( @@ -27,6 +27,43 @@ INTEGRATION_TITLE = "Intergas InComfort/Intouch Lan2RF gateway" type InComfortConfigEntry = ConfigEntry[InComfortDataCoordinator] +@callback +def async_cleanup_stale_devices( + hass: HomeAssistant, + entry: InComfortConfigEntry, + data: InComfortData, + gateway_device: dr.DeviceEntry, +) -> None: + """Cleanup stale heater devices and climates.""" + heater_serial_numbers = {heater.serial_no for heater in data.heaters} + device_registry = dr.async_get(hass) + device_entries = device_registry.devices.get_devices_for_config_entry_id( + entry.entry_id + ) + stale_heater_serial_numbers: list[str] = [ + device_entry.serial_number + for device_entry in device_entries + if device_entry.id != gateway_device.id + and device_entry.serial_number is not None + and device_entry.serial_number not in heater_serial_numbers + ] + if not stale_heater_serial_numbers: + return + cleanup_devices: list[str] = [] + # Find stale heater and climate devices + for serial_number in stale_heater_serial_numbers: + cleanup_list = [f"{serial_number}_{index}" for index in range(1, 4)] + cleanup_list.append(serial_number) + cleanup_identifiers = [{(DOMAIN, cleanup_id)} for cleanup_id in cleanup_list] + cleanup_devices.extend( + device_entry.id + for device_entry in device_entries + if device_entry.identifiers in cleanup_identifiers + ) + for device_id in cleanup_devices: + device_registry.async_remove_device(device_id) + + async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> bool: """Set up a config entry.""" try: @@ -46,7 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> # Register discovered gateway device device_registry = dr.async_get(hass) - device_registry.async_get_or_create( + gateway_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.entry_id)}, connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} @@ -55,6 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> manufacturer="Intergas", name="RFGateway", ) + async_cleanup_stale_devices(hass, entry, data, gateway_device) coordinator = InComfortDataCoordinator(hass, data, entry.entry_id) entry.runtime_data = coordinator await coordinator.async_config_entry_first_refresh() diff --git a/tests/components/incomfort/test_init.py b/tests/components/incomfort/test_init.py index a9b3a8e4e3a..92ce0afa448 100644 --- a/tests/components/incomfort/test_init.py +++ b/tests/components/incomfort/test_init.py @@ -1,6 +1,7 @@ """Tests for Intergas InComfort integration.""" from datetime import timedelta +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientResponseError, RequestInfo @@ -8,13 +9,17 @@ from freezegun.api import FrozenDateTimeFactory from incomfortclient import InvalidGateway, InvalidHeaterList import pytest +from homeassistant.components.incomfort import DOMAIN from homeassistant.components.incomfort.coordinator import UPDATE_INTERVAL from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceRegistry -from tests.common import async_fire_time_changed +from .conftest import MOCK_HEATER_STATUS + +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -22,13 +27,62 @@ async def test_setup_platforms( hass: HomeAssistant, mock_incomfort: MagicMock, entity_registry: er.EntityRegistry, - mock_config_entry: ConfigEntry, + mock_config_entry: MockConfigEntry, ) -> None: """Test the incomfort integration is set up correctly.""" await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "mock_heater_status", [MOCK_HEATER_STATUS | {"serial_no": "c01d00c0ffee"}] +) +async def test_stale_devices_cleanup( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_incomfort: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_heater_status: dict[str, Any], +) -> None: + """Test the incomfort integration is cleaning up stale devices.""" + # Setup an old heater with serial_no c01d00c0ffee + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + await hass.config_entries.async_unload(mock_config_entry.entry_id) + old_entries = device_registry.devices.get_devices_for_config_entry_id( + mock_config_entry.entry_id + ) + assert len(old_entries) == 3 + old_heater = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee")}) + assert old_heater is not None + assert old_heater.serial_number == "c01d00c0ffee" + old_climate = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee_1")}) + assert old_heater is not None + old_climate = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee_1")}) + assert old_climate is not None + + mock_heater_status["serial_no"] = "c0ffeec0ffee" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + new_entries = device_registry.devices.get_devices_for_config_entry_id( + mock_config_entry.entry_id + ) + assert len(new_entries) == 3 + new_heater = device_registry.async_get_device({(DOMAIN, "c0ffeec0ffee")}) + assert new_heater is not None + assert new_heater.serial_number == "c0ffeec0ffee" + new_climate = device_registry.async_get_device({(DOMAIN, "c0ffeec0ffee_1")}) + assert new_climate is not None + + old_heater = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee")}) + assert old_heater is None + old_climate = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee_1")}) + assert old_climate is None + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_coordinator_updates( hass: HomeAssistant, From 17e12e6671670a74367eb2a67436615df5a28430 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 26 Jan 2025 22:44:15 +0100 Subject: [PATCH 0994/2987] Prevent errors when Reolink privacy mode is turned on (#136506) --- homeassistant/components/reolink/__init__.py | 31 ++++- homeassistant/components/reolink/entity.py | 18 ++- homeassistant/components/reolink/host.py | 29 +++-- tests/components/reolink/conftest.py | 1 + tests/components/reolink/test_init.py | 114 ++++++++++++++++++- 5 files changed, 179 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 747e68e8a00..576ab3c64f8 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -5,9 +5,14 @@ from __future__ import annotations import asyncio from datetime import timedelta import logging +from typing import Any from reolink_aio.api import RETRY_ATTEMPTS -from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError +from reolink_aio.exceptions import ( + CredentialsInvalidError, + LoginPrivacyModeError, + ReolinkError, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform @@ -19,6 +24,7 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -115,6 +121,8 @@ async def async_setup_entry( await host.stop() raise ConfigEntryAuthFailed(err) from err raise UpdateFailed(str(err)) from err + except LoginPrivacyModeError: + pass # HTTP API is shutdown when privacy mode is active except ReolinkError as err: host.credential_errors = 0 raise UpdateFailed(str(err)) from err @@ -192,6 +200,23 @@ async def async_setup_entry( hass.http.register_view(PlaybackProxyView(hass)) + async def refresh(*args: Any) -> None: + """Request refresh of coordinator.""" + await device_coordinator.async_request_refresh() + host.cancel_refresh_privacy_mode = None + + def async_privacy_mode_change() -> None: + """Request update when privacy mode is turned off.""" + if host.privacy_mode and not host.api.baichuan.privacy_mode(): + # The privacy mode just turned off, give the API 2 seconds to start + if host.cancel_refresh_privacy_mode is None: + host.cancel_refresh_privacy_mode = async_call_later(hass, 2, refresh) + host.privacy_mode = host.api.baichuan.privacy_mode() + + host.api.baichuan.register_callback( + "privacy_mode_change", async_privacy_mode_change, 623 + ) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) config_entry.async_on_unload( @@ -216,6 +241,10 @@ async def async_unload_entry( await host.stop() + host.api.baichuan.unregister_callback("privacy_mode_change") + if host.cancel_refresh_privacy_mode is not None: + host.cancel_refresh_privacy_mode() + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index dc2366e8f56..63c95c25025 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -69,7 +69,9 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] super().__init__(coordinator) self._host = reolink_data.host - self._attr_unique_id = f"{self._host.unique_id}_{self.entity_description.key}" + self._attr_unique_id: str = ( + f"{self._host.unique_id}_{self.entity_description.key}" + ) http_s = "https" if self._host.api.use_https else "http" self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}" @@ -90,7 +92,11 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] @property def available(self) -> bool: """Return True if entity is available.""" - return self._host.api.session_active and super().available + return ( + self._host.api.session_active + and not self._host.api.baichuan.privacy_mode() + and super().available + ) @callback def _push_callback(self) -> None: @@ -110,8 +116,10 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] cmd_id = self.entity_description.cmd_id if cmd_key is not None: self._host.async_register_update_cmd(cmd_key) - if cmd_id is not None and self._attr_unique_id is not None: + if cmd_id is not None: self.register_callback(self._attr_unique_id, cmd_id) + # Privacy mode + self.register_callback(f"{self._attr_unique_id}_623", 623) async def async_will_remove_from_hass(self) -> None: """Entity removed.""" @@ -119,8 +127,10 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] cmd_id = self.entity_description.cmd_id if cmd_key is not None: self._host.async_unregister_update_cmd(cmd_key) - if cmd_id is not None and self._attr_unique_id is not None: + if cmd_id is not None: self._host.api.baichuan.unregister_callback(self._attr_unique_id) + # Privacy mode + self._host.api.baichuan.unregister_callback(f"{self._attr_unique_id}_623") await super().async_will_remove_from_hass() diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 97d888c0323..e9b86f1e297 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -95,6 +95,7 @@ class ReolinkHost: self.firmware_ch_list: list[int | None] = [] self.starting: bool = True + self.privacy_mode: bool | None = None self.credential_errors: int = 0 self.webhook_id: str | None = None @@ -112,7 +113,9 @@ class ReolinkHost: self._poll_job = HassJob(self._async_poll_all_motion, cancel_on_shutdown=True) self._fast_poll_error: bool = False self._long_poll_task: asyncio.Task | None = None + self._lost_subscription_start: bool = False self._lost_subscription: bool = False + self.cancel_refresh_privacy_mode: CALLBACK_TYPE | None = None @callback def async_register_update_cmd(self, cmd: str, channel: int | None = None) -> None: @@ -232,6 +235,8 @@ class ReolinkHost: self._hass, FIRST_TCP_PUSH_TIMEOUT, self._async_check_tcp_push ) + self.privacy_mode = self._api.baichuan.privacy_mode() + ch_list: list[int | None] = [None] if self._api.is_nvr: ch_list.extend(self._api.channels) @@ -299,7 +304,7 @@ class ReolinkHost: ) # start long polling if ONVIF push failed immediately - if not self._onvif_push_supported: + if not self._onvif_push_supported and not self._api.baichuan.privacy_mode(): _LOGGER.debug( "Camera model %s does not support ONVIF push, using ONVIF long polling instead", self._api.model, @@ -416,6 +421,11 @@ class ReolinkHost: wake = True self.last_wake = time() + if self._api.baichuan.privacy_mode(): + await self._api.baichuan.get_privacy_mode() + if self._api.baichuan.privacy_mode(): + return # API is shutdown, no need to check states + await self._api.get_states(cmd_list=self.update_cmd, wake=wake) async def disconnect(self) -> None: @@ -459,8 +469,8 @@ class ReolinkHost: if initial: raise # make sure the long_poll_task is always created to try again later - if not self._lost_subscription: - self._lost_subscription = True + if not self._lost_subscription_start: + self._lost_subscription_start = True _LOGGER.error( "Reolink %s event long polling subscription lost: %s", self._api.nvr_name, @@ -468,15 +478,15 @@ class ReolinkHost: ) except ReolinkError as err: # make sure the long_poll_task is always created to try again later - if not self._lost_subscription: - self._lost_subscription = True + if not self._lost_subscription_start: + self._lost_subscription_start = True _LOGGER.error( "Reolink %s event long polling subscription lost: %s", self._api.nvr_name, err, ) else: - self._lost_subscription = False + self._lost_subscription_start = False self._long_poll_task = asyncio.create_task(self._async_long_polling()) async def _async_stop_long_polling(self) -> None: @@ -543,6 +553,9 @@ class ReolinkHost: self.unregister_webhook() await self._api.unsubscribe() + if self._api.baichuan.privacy_mode(): + return # API is shutdown, no need to subscribe + try: if self._onvif_push_supported and not self._api.baichuan.events_active: await self._renew(SubType.push) @@ -666,7 +679,9 @@ class ReolinkHost: try: channels = await self._api.pull_point_request() except ReolinkError as ex: - if not self._long_poll_error: + if not self._long_poll_error and self._api.subscribed( + SubType.long_poll + ): _LOGGER.error("Error while requesting ONVIF pull point: %s", ex) await self._api.unsubscribe(sub_type=SubType.long_poll) self._long_poll_error = True diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 81865d98801..f8012f91351 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -126,6 +126,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.baichuan = create_autospec(Baichuan) # Disable tcp push by default for tests host_mock.baichuan.events_active = False + host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") yield host_mock_class diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index f851e13c91d..7895923dd12 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -1,13 +1,18 @@ """Test the Reolink init.""" import asyncio +from collections.abc import Callable from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest from reolink_aio.api import Chime -from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError +from reolink_aio.exceptions import ( + CredentialsInvalidError, + LoginPrivacyModeError, + ReolinkError, +) from homeassistant.components.reolink import ( DEVICE_UPDATE_INTERVAL, @@ -16,7 +21,13 @@ from homeassistant.components.reolink import ( ) from homeassistant.components.reolink.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PORT, STATE_OFF, STATE_UNAVAILABLE, Platform +from homeassistant.const import ( + CONF_PORT, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.core_config import async_process_ha_core_config from homeassistant.helpers import ( @@ -749,3 +760,102 @@ async def test_port_changed( await hass.async_block_till_done() assert config_entry.data[CONF_PORT] == 4567 + + +async def test_privacy_mode_on( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test successful setup even when privacy mode is turned on.""" + reolink_connect.baichuan.privacy_mode.return_value = True + reolink_connect.get_states = AsyncMock( + side_effect=LoginPrivacyModeError("Test error") + ) + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + + reolink_connect.baichuan.privacy_mode.return_value = False + + +async def test_LoginPrivacyModeError( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test normal update when get_states returns a LoginPrivacyModeError.""" + reolink_connect.baichuan.privacy_mode.return_value = False + reolink_connect.get_states = AsyncMock( + side_effect=LoginPrivacyModeError("Test error") + ) + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + reolink_connect.baichuan.check_subscribe_events.reset_mock() + assert reolink_connect.baichuan.check_subscribe_events.call_count == 0 + + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert reolink_connect.baichuan.check_subscribe_events.call_count >= 1 + + +async def test_privacy_mode_change_callback( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test privacy mode changed callback.""" + + class callback_mock_class: + callback_func = None + + def register_callback( + self, callback_id: str, callback: Callable[[], None], *args, **key_args + ) -> None: + if callback_id == "privacy_mode_change": + self.callback_func = callback + + callback_mock = callback_mock_class() + + reolink_connect.model = TEST_HOST_MODEL + reolink_connect.baichuan.events_active = True + reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True) + reolink_connect.baichuan.register_callback = callback_mock.register_callback + reolink_connect.baichuan.privacy_mode.return_value = True + reolink_connect.audio_record.return_value = True + reolink_connect.get_states = AsyncMock() + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_record_audio" + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # simulate a TCP push callback signaling a privacy mode change + reolink_connect.baichuan.privacy_mode.return_value = False + assert callback_mock.callback_func is not None + callback_mock.callback_func() + + # check that a coordinator update was scheduled. + reolink_connect.get_states.reset_mock() + assert reolink_connect.get_states.call_count == 0 + + freezer.tick(5) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert reolink_connect.get_states.call_count >= 1 + assert hass.states.get(entity_id).state == STATE_ON From 3582d9b4dab65ea0c7cac3f91fb11001863bd5b1 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sun, 26 Jan 2025 18:34:16 -0500 Subject: [PATCH 0995/2987] Bump SoCo to 0.30.8 - Sonos (#136601) update soco to 0.30.8 --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 76a7d0bfa91..bfdf0da9dbb 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco"], - "requirements": ["soco==0.30.6", "sonos-websocket==0.1.3"], + "requirements": ["soco==0.30.8", "sonos-websocket==0.1.3"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/requirements_all.txt b/requirements_all.txt index c1148cc3b6b..24a550e05de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2735,7 +2735,7 @@ smhi-pkg==1.0.19 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.6 +soco==0.30.8 # homeassistant.components.solaredge_local solaredge-local==0.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c946d89857..fa1dd4bed7f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2202,7 +2202,7 @@ smhi-pkg==1.0.19 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.6 +soco==0.30.8 # homeassistant.components.solarlog solarlog_cli==0.4.0 From 642a06b0f087867a42462b592d8c92b347795bed Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Mon, 27 Jan 2025 00:38:45 +0100 Subject: [PATCH 0996/2987] Optimize enphase_envoy test integration setup. (#136572) --- tests/components/enphase_envoy/__init__.py | 12 +++++++++--- tests/components/enphase_envoy/test_init.py | 20 +------------------- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/tests/components/enphase_envoy/__init__.py b/tests/components/enphase_envoy/__init__.py index f69ab8e44f2..f5381eda2a7 100644 --- a/tests/components/enphase_envoy/__init__.py +++ b/tests/components/enphase_envoy/__init__.py @@ -1,13 +1,19 @@ """Tests for the Enphase Envoy integration.""" +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: - """Fixture for setting up the component.""" +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + expected_state: ConfigEntryState = ConfigEntryState.LOADED, +) -> None: + """Fixture for setting up the component and testing expected state.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is expected_state diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index 620bd654aca..93a150cfc5c 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -52,8 +52,6 @@ async def test_with_pre_v7_firmware( ) await setup_integration(hass, config_entry) - assert config_entry.state is ConfigEntryState.LOADED - assert (entity_state := hass.states.get("sensor.inverter_1")) assert entity_state.state == "1" @@ -84,8 +82,6 @@ async def test_token_in_config_file( ) mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=token, envoy_serial="1234") await setup_integration(hass, entry) - await hass.async_block_till_done(wait_background_tasks=True) - assert entry.state is ConfigEntryState.LOADED assert (entity_state := hass.states.get("sensor.inverter_1")) assert entity_state.state == "1" @@ -129,8 +125,6 @@ async def test_expired_token_in_config( cloud_password="test_password", ) await setup_integration(hass, entry) - await hass.async_block_till_done(wait_background_tasks=True) - assert entry.state is ConfigEntryState.LOADED assert (entity_state := hass.states.get("sensor.inverter_1")) assert entity_state.state == "1" @@ -230,9 +224,6 @@ async def test_coordinator_token_refresh_error( ): await setup_integration(hass, entry) - await hass.async_block_till_done(wait_background_tasks=True) - assert entry.state is ConfigEntryState.LOADED - assert (entity_state := hass.states.get("sensor.inverter_1")) assert entity_state.state == "1" @@ -255,7 +246,6 @@ async def test_config_no_unique_id( }, ) await setup_integration(hass, entry) - assert entry.state is ConfigEntryState.LOADED assert entry.unique_id == mock_envoy.serial_number @@ -276,8 +266,7 @@ async def test_config_different_unique_id( CONF_PASSWORD: "test-password", }, ) - await setup_integration(hass, entry) - assert entry.state is ConfigEntryState.SETUP_RETRY + await setup_integration(hass, entry, expected_state=ConfigEntryState.SETUP_RETRY) @pytest.mark.parametrize( @@ -298,7 +287,6 @@ async def test_remove_config_entry_device( """Test removing enphase_envoy config entry device.""" assert await async_setup_component(hass, "config", {}) await setup_integration(hass, config_entry) - assert config_entry.state is ConfigEntryState.LOADED # use client to send remove_device command hass_client = await hass_ws_client(hass) @@ -349,8 +337,6 @@ async def test_option_change_reload( ) -> None: """Test options change will reload entity.""" await setup_integration(hass, config_entry) - await hass.async_block_till_done(wait_background_tasks=True) - assert config_entry.state is ConfigEntryState.LOADED # By default neither option is available assert config_entry.options == {} @@ -403,8 +389,6 @@ async def test_coordinator_firmware_refresh( ) -> None: """Test coordinator scheduled firmware check.""" await setup_integration(hass, config_entry) - await hass.async_block_till_done(wait_background_tasks=True) - assert config_entry.state is ConfigEntryState.LOADED # Move time to next firmware check moment # SCAN_INTERVAL is patched to 1 day to disable it's firmware detection @@ -447,8 +431,6 @@ async def test_coordinator_firmware_refresh_with_envoy_error( ) -> None: """Test coordinator scheduled firmware check.""" await setup_integration(hass, config_entry) - await hass.async_block_till_done(wait_background_tasks=True) - assert config_entry.state is ConfigEntryState.LOADED caplog.set_level(logging.DEBUG) logging.getLogger("homeassistant.components.enphase_envoy.coordinator").setLevel( From 107184b55f6cab95a3ba2045638a9884f00afdc3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 27 Jan 2025 00:41:05 +0100 Subject: [PATCH 0997/2987] Update mypy-dev to 1.16.0a1 (#136544) * Update mypy-dev to 1.16.0a1 * Fix * Use type ignore until fixed upstream --- homeassistant/components/flux_led/config_flow.py | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py index 035be5b115c..69e40d59f7f 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -299,7 +299,7 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN): # AKA `HF-LPB100-ZJ200` return device bulb = async_wifi_bulb_for_host(host, discovery=device) - bulb.discovery = discovery + bulb.discovery = discovery # type: ignore[assignment] try: await bulb.async_setup(lambda: None) finally: diff --git a/requirements_test.txt b/requirements_test.txt index 68945852298..cf0a1e5473f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==7.6.8 freezegun==1.5.1 license-expression==30.4.0 mock-open==1.4.0 -mypy-dev==1.15.0a2 +mypy-dev==1.16.0a1 pre-commit==4.0.0 pydantic==2.10.6 pylint==3.3.3 From dfbb48552c3ca802c3df20525d2c1b416aa9162d Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sun, 26 Jan 2025 20:49:55 -0600 Subject: [PATCH 0998/2987] Bump pyheos to v1.0.1 (#136604) --- homeassistant/components/heos/coordinator.py | 8 +++---- homeassistant/components/heos/manifest.json | 2 +- homeassistant/components/heos/media_player.py | 22 +++++++++++-------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 1cd75049f16..ee0aeb3f165 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -5,7 +5,7 @@ The coordinator is responsible for refreshing data in response to system-wide ev entities to update. Entities subscribe to entity-specific updates within the entity class itself. """ -from collections.abc import Callable +from collections.abc import Callable, Sequence from datetime import datetime, timedelta import logging @@ -60,11 +60,11 @@ class HeosCoordinator(DataUpdateCoordinator[None]): self._update_sources_pending: bool = False self._source_list: list[str] = [] self._favorites: dict[int, MediaItem] = {} - self._inputs: list[MediaItem] = [] + self._inputs: Sequence[MediaItem] = [] super().__init__(hass, _LOGGER, config_entry=config_entry, name=DOMAIN) @property - def inputs(self) -> list[MediaItem]: + def inputs(self) -> Sequence[MediaItem]: """Get input sources across all devices.""" return self._inputs @@ -133,8 +133,6 @@ class HeosCoordinator(DataUpdateCoordinator[None]): assert data is not None if data.updated_player_ids: self._async_update_player_ids(data.updated_player_ids) - elif event == const.EVENT_GROUPS_CHANGED: - await self._async_update_players() elif ( event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED) and not self._update_sources_pending diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index ebeb851f37a..22dbbf4da28 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pyheos"], "quality_scale": "silver", - "requirements": ["pyheos==1.0.0"], + "requirements": ["pyheos==1.0.1"], "single_config_entry": true, "ssdp": [ { diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 0c401f01470..2f0945635c5 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine +from datetime import datetime from functools import reduce, wraps from operator import ior from typing import Any @@ -56,6 +57,7 @@ BASE_SUPPORTED_FEATURES = ( ) PLAY_STATE_TO_STATE = { + None: MediaPlayerState.IDLE, PlayState.PLAY: MediaPlayerState.PLAYING, PlayState.STOP: MediaPlayerState.IDLE, PlayState.PAUSE: MediaPlayerState.PAUSED, @@ -399,38 +401,40 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): return self._player.is_muted @property - def media_album_name(self) -> str: + def media_album_name(self) -> str | None: """Album name of current playing media, music track only.""" return self._player.now_playing_media.album @property - def media_artist(self) -> str: + def media_artist(self) -> str | None: """Artist of current playing media, music track only.""" return self._player.now_playing_media.artist @property - def media_content_id(self) -> str: + def media_content_id(self) -> str | None: """Content ID of current playing media.""" return self._player.now_playing_media.media_id @property - def media_duration(self): + def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" duration = self._player.now_playing_media.duration if isinstance(duration, int): - return duration / 1000 + return int(duration / 1000) return None @property - def media_position(self): + def media_position(self) -> int | None: """Position of current playing media in seconds.""" # Some media doesn't have duration but reports position, return None if not self._player.now_playing_media.duration: return None - return self._player.now_playing_media.current_position / 1000 + if isinstance(self._player.now_playing_media.current_position, int): + return int(self._player.now_playing_media.current_position / 1000) + return None @property - def media_position_updated_at(self): + def media_position_updated_at(self) -> datetime | None: """When was the position of the current playing media valid.""" # Some media doesn't have duration but reports position, return None if not self._player.now_playing_media.duration: @@ -445,7 +449,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): return image_url if image_url else None @property - def media_title(self) -> str: + def media_title(self) -> str | None: """Title of current playing media.""" return self._player.now_playing_media.song diff --git a/requirements_all.txt b/requirements_all.txt index 24a550e05de..d0b18b7cab5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1980,7 +1980,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.0 +pyheos==1.0.1 # homeassistant.components.hive pyhive-integration==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa1dd4bed7f..7ce62fbe43e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1609,7 +1609,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.0 +pyheos==1.0.1 # homeassistant.components.hive pyhive-integration==1.0.1 From 69938545df420b309684ad029bc8ddb53d884b57 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 26 Jan 2025 19:16:19 -0800 Subject: [PATCH 0999/2987] Push more of the LLM conversation agent loop into ChatSession (#136602) * Push more of the LLM conversation agent loop into ChatSession * Revert unnecessary changes * Revert changes to agent id filtering --- .../components/conversation/agent_manager.py | 17 ++- .../components/conversation/session.py | 37 ++++++- .../openai_conversation/conversation.py | 62 ++++------- tests/components/conversation/test_session.py | 102 +++++++++++++++++- tests/components/conversation/test_trace.py | 41 ++++++- 5 files changed, 202 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index 97dc5e1292e..ce3a0cf028d 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -9,7 +9,8 @@ from typing import Any import voluptuous as vol from homeassistant.core import Context, HomeAssistant, async_get_hass, callback -from homeassistant.helpers import config_validation as cv, singleton +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, intent, singleton from .const import ( DATA_COMPONENT, @@ -109,7 +110,19 @@ async def async_converse( dataclasses.asdict(conversation_input), ) ) - result = await method(conversation_input) + try: + result = await method(conversation_input) + except HomeAssistantError as err: + intent_response = intent.IntentResponse(language=language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + str(err), + ) + result = ConversationResult( + response=intent_response, + conversation_id=conversation_id, + ) + trace.set_result(**result.as_dict()) return result diff --git a/homeassistant/components/conversation/session.py b/homeassistant/components/conversation/session.py index 48040e8ac9c..2235459954f 100644 --- a/homeassistant/components/conversation/session.py +++ b/homeassistant/components/conversation/session.py @@ -9,6 +9,8 @@ from datetime import datetime, timedelta import logging from typing import Literal +import voluptuous as vol + from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import ( CALLBACK_TYPE, @@ -23,7 +25,9 @@ from homeassistant.helpers import intent, llm, template from homeassistant.helpers.event import async_call_later from homeassistant.util import dt as dt_util, ulid as ulid_util from homeassistant.util.hass_dict import HassKey +from homeassistant.util.json import JsonObjectType +from . import trace from .const import DOMAIN from .models import ConversationInput, ConversationResult @@ -120,7 +124,7 @@ async def async_get_chat_session( if history: history = replace(history, messages=history.messages.copy()) else: - history = ChatSession(hass, conversation_id) + history = ChatSession(hass, conversation_id, user_input.agent_id) message: ChatMessage = ChatMessage( role="user", @@ -190,6 +194,7 @@ class ChatSession[_NativeT]: hass: HomeAssistant conversation_id: str + agent_id: str | None user_name: str | None = None messages: list[ChatMessage[_NativeT]] = field( default_factory=lambda: [ChatMessage(role="system", agent_id=None, content="")] @@ -209,7 +214,9 @@ class ChatSession[_NativeT]: self.messages.append(message) @callback - def async_get_messages(self, agent_id: str | None) -> list[ChatMessage[_NativeT]]: + def async_get_messages( + self, agent_id: str | None = None + ) -> list[ChatMessage[_NativeT]]: """Get messages for a specific agent ID. This will filter out any native message tied to other agent IDs. @@ -326,3 +333,29 @@ class ChatSession[_NativeT]: agent_id=user_input.agent_id, content=prompt, ) + + LOGGER.debug("Prompt: %s", self.messages) + LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None) + + trace.async_conversation_trace_append( + trace.ConversationTraceEventType.AGENT_DETAIL, + { + "messages": self.messages, + "tools": self.llm_api.tools if self.llm_api else None, + }, + ) + + async def async_call_tool(self, tool_input: llm.ToolInput) -> JsonObjectType: + """Invoke LLM tool for the configured LLM API.""" + if not self.llm_api: + raise ValueError("No LLM API configured") + LOGGER.debug("Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args) + + try: + tool_response = await self.llm_api.async_call_tool(tool_input) + except (HomeAssistantError, vol.Invalid) as e: + tool_response = {"error": type(e).__name__} + if str(e): + tool_response["error_text"] = str(e) + LOGGER.debug("Tool response: %s", tool_response) + return tool_response diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index c89574bf3bd..1464f4224d7 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -16,11 +16,9 @@ from openai.types.chat import ( ) from openai.types.chat.chat_completion_message_tool_call_param import Function from openai.types.shared_params import FunctionDefinition -import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation -from homeassistant.components.conversation import trace from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant @@ -94,6 +92,19 @@ def _message_convert(message: ChatCompletionMessage) -> ChatCompletionMessagePar return param +def _chat_message_convert( + message: conversation.ChatMessage[ChatCompletionMessageParam], + agent_id: str | None, +) -> ChatCompletionMessageParam: + """Convert any native chat message for this agent to the native format.""" + if message.native is not None and message.agent_id == agent_id: + return message.native + return cast( + ChatCompletionMessageParam, + {"role": message.role, "content": message.content}, + ) + + class OpenAIConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent ): @@ -173,27 +184,10 @@ class OpenAIConversationEntity( for tool in session.llm_api.tools ] - messages: list[ChatCompletionMessageParam] = [] - for message in session.async_get_messages(user_input.agent_id): - if message.native is not None and message.agent_id == user_input.agent_id: - messages.append(message.native) - else: - messages.append( - cast( - ChatCompletionMessageParam, - {"role": message.role, "content": message.content}, - ) - ) - - LOGGER.debug("Prompt: %s", messages) - LOGGER.debug("Tools: %s", tools) - trace.async_conversation_trace_append( - trace.ConversationTraceEventType.AGENT_DETAIL, - { - "messages": session.messages, - "tools": session.llm_api.tools if session.llm_api else None, - }, - ) + messages = [ + _chat_message_convert(message, user_input.agent_id) + for message in session.async_get_messages() + ] client = self.entry.runtime_data @@ -211,14 +205,7 @@ class OpenAIConversationEntity( ) except openai.OpenAIError as err: LOGGER.error("Error talking to OpenAI: %s", err) - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Sorry, I had a problem talking to OpenAI", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=session.conversation_id - ) + raise HomeAssistantError("Error talking to OpenAI") from err LOGGER.debug("Response %s", result) response = result.choices[0].message @@ -241,18 +228,7 @@ class OpenAIConversationEntity( tool_name=tool_call.function.name, tool_args=json.loads(tool_call.function.arguments), ) - LOGGER.debug( - "Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args - ) - - try: - tool_response = await session.llm_api.async_call_tool(tool_input) - except (HomeAssistantError, vol.Invalid) as e: - tool_response = {"error": type(e).__name__} - if str(e): - tool_response["error_text"] = str(e) - - LOGGER.debug("Tool response: %s", tool_response) + tool_response = await session.async_call_tool(tool_input) messages.append( ChatCompletionToolMessageParam( role="tool", diff --git a/tests/components/conversation/test_session.py b/tests/components/conversation/test_session.py index feb6ca2a9e8..bca19b3b06a 100644 --- a/tests/components/conversation/test_session.py +++ b/tests/components/conversation/test_session.py @@ -2,13 +2,15 @@ from collections.abc import Generator from datetime import timedelta -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from syrupy.assertion import SnapshotAssertion +import voluptuous as vol from homeassistant.components.conversation import ConversationInput, session from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import llm from homeassistant.util import dt as dt_util @@ -182,7 +184,7 @@ async def test_message_filtering( ) assert messages[1] == session.ChatMessage( role="user", - agent_id=mock_conversation_input.agent_id, + agent_id="mock-agent-id", content=mock_conversation_input.text, ) # Cannot add a second user message in a row @@ -203,7 +205,7 @@ async def test_message_filtering( native="assistant-reply-native", ) ) - # Different agent, will be filtered out. + # Different agent, native messages will be filtered out. chat_session.async_add_message( session.ChatMessage( role="native", agent_id="another-mock-agent-id", content="", native=1 @@ -214,11 +216,20 @@ async def test_message_filtering( role="native", agent_id="mock-agent-id", content="", native=1 ) ) + # A non-native message from another agent is not filtered out. + chat_session.async_add_message( + session.ChatMessage( + role="assistant", + agent_id="another-mock-agent-id", + content="Hi!", + native=1, + ) + ) - assert len(chat_session.messages) == 5 + assert len(chat_session.messages) == 6 messages = chat_session.async_get_messages(agent_id="mock-agent-id") - assert len(messages) == 4 + assert len(messages) == 5 assert messages[2] == session.ChatMessage( role="assistant", @@ -229,6 +240,9 @@ async def test_message_filtering( assert messages[3] == session.ChatMessage( role="native", agent_id="mock-agent-id", content="", native=1 ) + assert messages[4] == session.ChatMessage( + role="assistant", agent_id="another-mock-agent-id", content="Hi!", native=1 + ) async def test_llm_api( @@ -413,3 +427,81 @@ async def test_extra_systen_prompt( assert chat_session.extra_system_prompt == extra_system_prompt2 assert chat_session.messages[0].content.endswith(extra_system_prompt2) + + +async def test_tool_call( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test using the session tool calling API.""" + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + {vol.Optional("param1", description="Test parameters"): str} + ) + mock_tool.async_call.return_value = "Test response" + + with patch( + "homeassistant.components.conversation.session.llm.AssistAPI._async_get_tools", + return_value=[], + ) as mock_get_tools: + mock_get_tools.return_value = [mock_tool] + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + result = await chat_session.async_call_tool( + llm.ToolInput( + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ) + ) + + assert result == "Test response" + + +async def test_tool_call_exception( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test using the session tool calling API.""" + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + {vol.Optional("param1", description="Test parameters"): str} + ) + mock_tool.async_call.side_effect = HomeAssistantError("Test error") + + with patch( + "homeassistant.components.conversation.session.llm.AssistAPI._async_get_tools", + return_value=[], + ) as mock_get_tools: + mock_get_tools.return_value = [mock_tool] + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + result = await chat_session.async_call_tool( + llm.ToolInput( + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ) + ) + + assert result == {"error": "HomeAssistantError", "error_text": "Test error"} diff --git a/tests/components/conversation/test_trace.py b/tests/components/conversation/test_trace.py index 7c00b9a80b2..a975c9b7983 100644 --- a/tests/components/conversation/test_trace.py +++ b/tests/components/conversation/test_trace.py @@ -61,18 +61,18 @@ async def test_converation_trace( } -async def test_converation_trace_error( +async def test_converation_trace_uncaught_error( hass: HomeAssistant, init_components: None, sl_setup: None, ) -> None: - """Test tracing a conversation.""" + """Test tracing a conversation that raises an uncaught error.""" with ( patch( "homeassistant.components.conversation.default_agent.DefaultAgent.async_process", - side_effect=HomeAssistantError("Failed to talk to agent"), + side_effect=ValueError("Unexpected error"), ), - pytest.raises(HomeAssistantError), + pytest.raises(ValueError), ): await conversation.async_converse( hass, "add apples to my shopping list", None, Context() @@ -87,4 +87,35 @@ async def test_converation_trace_error( assert ( trace_event.get("event_type") == trace.ConversationTraceEventType.ASYNC_PROCESS ) - assert last_trace.get("error") == "Failed to talk to agent" + assert last_trace.get("error") == "Unexpected error" + assert not last_trace.get("result") + + +async def test_converation_trace_homeassistant_error( + hass: HomeAssistant, + init_components: None, + sl_setup: None, +) -> None: + """Test tracing a conversation with a HomeAssistant error.""" + with ( + patch( + "homeassistant.components.conversation.default_agent.DefaultAgent.async_process", + side_effect=HomeAssistantError("Failed to talk to agent"), + ), + ): + await conversation.async_converse( + hass, "add apples to my shopping list", None, Context() + ) + + traces = trace.async_get_traces() + assert traces + last_trace = traces[-1].as_dict() + assert last_trace.get("events") + assert len(last_trace.get("events")) == 1 + trace_event = last_trace["events"][0] + assert ( + trace_event.get("event_type") == trace.ConversationTraceEventType.ASYNC_PROCESS + ) + result = last_trace.get("result") + assert result + assert result["response"]["speech"]["plain"]["speech"] == "Failed to talk to agent" From 245ee2498e615ffa9e3a1cb4fe2bb224b8bc5a0b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Jan 2025 08:25:22 +0100 Subject: [PATCH 1000/2987] Update hassio to use the backup integration to make backups before update (#136235) Co-authored-by: Martin Hjelmare --- homeassistant/components/backup/__init__.py | 30 +- homeassistant/components/backup/config.py | 13 +- homeassistant/components/backup/manager.py | 15 + homeassistant/components/hassio/backup.py | 67 +++ homeassistant/components/hassio/update.py | 35 +- .../components/hassio/update_helper.py | 59 +++ .../components/hassio/websocket_api.py | 48 ++ tests/components/conftest.py | 6 +- tests/components/hassio/test_update.py | 318 +++++++++++- tests/components/hassio/test_websocket_api.py | 452 +++++++++++++++++- 10 files changed, 979 insertions(+), 64 deletions(-) create mode 100644 homeassistant/components/hassio/update_helper.py diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 8d25a0c25cb..10294f6ff12 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -1,6 +1,7 @@ """The Backup integration.""" -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.typing import ConfigType @@ -19,6 +20,7 @@ from .const import DATA_MANAGER, DOMAIN from .http import async_register_http_views from .manager import ( BackupManager, + BackupManagerError, BackupPlatformProtocol, BackupReaderWriter, BackupReaderWriterError, @@ -39,6 +41,7 @@ __all__ = [ "BackupAgent", "BackupAgentError", "BackupAgentPlatformProtocol", + "BackupManagerError", "BackupPlatformProtocol", "BackupReaderWriter", "BackupReaderWriterError", @@ -90,18 +93,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_handle_create_automatic_service(call: ServiceCall) -> None: """Service handler for creating automatic backups.""" - config_data = backup_manager.config.data - await backup_manager.async_create_backup( - agent_ids=config_data.create_backup.agent_ids, - include_addons=config_data.create_backup.include_addons, - include_all_addons=config_data.create_backup.include_all_addons, - include_database=config_data.create_backup.include_database, - include_folders=config_data.create_backup.include_folders, - include_homeassistant=True, # always include HA - name=config_data.create_backup.name, - password=config_data.create_backup.password, - with_automatic_settings=True, - ) + await backup_manager.async_create_automatic_backup() if not with_hassio: hass.services.async_register(DOMAIN, "create", async_handle_create_service) @@ -112,3 +104,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_http_views(hass) return True + + +@callback +def async_get_manager(hass: HomeAssistant) -> BackupManager: + """Get the backup manager instance. + + Raises HomeAssistantError if the backup integration is not available. + """ + if DATA_MANAGER not in hass.data: + raise HomeAssistantError("Backup integration is not available") + + return hass.data[DATA_MANAGER] diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 8edd6cf0f2b..1d1b8046360 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -390,22 +390,11 @@ class BackupSchedule: async def _create_backup(now: datetime) -> None: """Create backup.""" manager.remove_next_backup_event = None - config_data = manager.config.data self._schedule_next(cron_pattern, manager) # create the backup try: - await manager.async_create_backup( - agent_ids=config_data.create_backup.agent_ids, - include_addons=config_data.create_backup.include_addons, - include_all_addons=config_data.create_backup.include_all_addons, - include_database=config_data.create_backup.include_database, - include_folders=config_data.create_backup.include_folders, - include_homeassistant=True, # always include HA - name=config_data.create_backup.name, - password=config_data.create_backup.password, - with_automatic_settings=True, - ) + await manager.async_create_automatic_backup() except BackupManagerError as err: LOGGER.error("Error creating backup: %s", err) except Exception: # noqa: BLE001 diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 32979194980..8c8cd805565 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -698,6 +698,21 @@ class BackupManager: await self._backup_finish_task return new_backup + async def async_create_automatic_backup(self) -> NewBackup: + """Create a backup with automatic backup settings.""" + config_data = self.config.data + return await self.async_create_backup( + agent_ids=config_data.create_backup.agent_ids, + include_addons=config_data.create_backup.include_addons, + include_all_addons=config_data.create_backup.include_all_addons, + include_database=config_data.create_backup.include_database, + include_folders=config_data.create_backup.include_folders, + include_homeassistant=True, # always include HA + name=config_data.create_backup.name, + password=config_data.create_backup.password, + with_automatic_settings=True, + ) + async def async_initiate_backup( self, *, diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 2ebd3f6aab4..d49fafb886f 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -8,6 +8,7 @@ import logging from pathlib import Path from typing import Any, cast +from aiohasupervisor import SupervisorClient from aiohasupervisor.exceptions import ( SupervisorBadRequestError, SupervisorError, @@ -23,6 +24,7 @@ from homeassistant.components.backup import ( AddonInfo, AgentBackup, BackupAgent, + BackupManagerError, BackupReaderWriter, BackupReaderWriterError, CreateBackupEvent, @@ -31,7 +33,9 @@ from homeassistant.components.backup import ( NewBackup, RestoreBackupEvent, WrittenBackup, + async_get_manager as async_get_backup_manager, ) +from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -477,3 +481,66 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): self._hass, EVENT_SUPERVISOR_EVENT, handle_signal ) return unsub + + +async def _default_agent(client: SupervisorClient) -> str: + """Return the default agent for creating a backup.""" + mounts = await client.mounts.info() + default_mount = mounts.default_backup_mount + return f"hassio.{default_mount if default_mount is not None else 'local'}" + + +async def backup_addon_before_update( + hass: HomeAssistant, + addon: str, + addon_name: str | None, + installed_version: str | None, +) -> None: + """Prepare for updating an add-on.""" + backup_manager = hass.data[DATA_MANAGER] + client = get_supervisor_client(hass) + + # Use the password from automatic settings if available + if backup_manager.config.data.create_backup.agent_ids: + password = backup_manager.config.data.create_backup.password + else: + password = None + + try: + await backup_manager.async_create_backup( + agent_ids=[await _default_agent(client)], + include_addons=[addon], + include_all_addons=False, + include_database=False, + include_folders=None, + include_homeassistant=False, + name=f"{addon_name or addon} {installed_version or ''}", + password=password, + ) + except BackupManagerError as err: + raise HomeAssistantError(f"Error creating backup: {err}") from err + + +async def backup_core_before_update(hass: HomeAssistant) -> None: + """Prepare for updating core.""" + backup_manager = async_get_backup_manager(hass) + client = get_supervisor_client(hass) + + try: + if backup_manager.config.data.create_backup.agent_ids: + # Create a backup with automatic settings + await backup_manager.async_create_automatic_backup() + else: + # Create a manual backup + await backup_manager.async_create_backup( + agent_ids=[await _default_agent(client)], + include_addons=None, + include_all_addons=False, + include_database=True, + include_folders=None, + include_homeassistant=True, + name=f"Home Assistant Core {HAVERSION}", + password=None, + ) + except BackupManagerError as err: + raise HomeAssistantError(f"Error creating backup: {err}") from err diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index fbb3e191f81..17b0a5bc9ca 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -4,12 +4,8 @@ from __future__ import annotations from typing import Any -from aiohasupervisor import SupervisorError -from aiohasupervisor.models import ( - HomeAssistantUpdateOptions, - OSUpdate, - StoreAddonUpdate, -) +from aiohasupervisor import SupervisorClient, SupervisorError +from aiohasupervisor.models import OSUpdate from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.components.update import ( @@ -40,6 +36,7 @@ from .entity import ( HassioOSEntity, HassioSupervisorEntity, ) +from .update_helper import update_addon, update_core ENTITY_DESCRIPTION = UpdateEntityDescription( name="Update", @@ -163,13 +160,9 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): **kwargs: Any, ) -> None: """Install an update.""" - try: - await self.coordinator.supervisor_client.store.update_addon( - self._addon_slug, StoreAddonUpdate(backup=backup) - ) - except SupervisorError as err: - raise HomeAssistantError(f"Error updating {self.title}: {err}") from err - + await update_addon( + self.hass, self._addon_slug, backup, self.title, self.installed_version + ) await self.coordinator.force_info_update_supervisor() @@ -303,11 +296,11 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity): self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" - try: - await self.coordinator.supervisor_client.homeassistant.update( - HomeAssistantUpdateOptions(version=version, backup=backup) - ) - except SupervisorError as err: - raise HomeAssistantError( - f"Error updating Home Assistant Core: {err}" - ) from err + await update_core(self.hass, version, backup) + + +async def _default_agent(client: SupervisorClient) -> str: + """Return the default agent for creating a backup.""" + mounts = await client.mounts.info() + default_mount = mounts.default_backup_mount + return f"hassio.{default_mount if default_mount is not None else 'local'}" diff --git a/homeassistant/components/hassio/update_helper.py b/homeassistant/components/hassio/update_helper.py new file mode 100644 index 00000000000..d801f6b5771 --- /dev/null +++ b/homeassistant/components/hassio/update_helper.py @@ -0,0 +1,59 @@ +"""Update helpers for Supervisor.""" + +from __future__ import annotations + +from aiohasupervisor import SupervisorError +from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .handler import get_supervisor_client + + +async def update_addon( + hass: HomeAssistant, + addon: str, + backup: bool, + addon_name: str | None, + installed_version: str | None, +) -> None: + """Update an addon. + + Optionally make a backup before updating. + """ + client = get_supervisor_client(hass) + + if backup: + # pylint: disable-next=import-outside-toplevel + from .backup import backup_addon_before_update + + await backup_addon_before_update(hass, addon, addon_name, installed_version) + + try: + await client.store.update_addon(addon, StoreAddonUpdate(backup=False)) + except SupervisorError as err: + raise HomeAssistantError( + f"Error updating {addon_name or addon}: {err}" + ) from err + + +async def update_core(hass: HomeAssistant, version: str | None, backup: bool) -> None: + """Update core. + + Optionally make a backup before updating. + """ + client = get_supervisor_client(hass) + + if backup: + # pylint: disable-next=import-outside-toplevel + from .backup import backup_core_before_update + + await backup_core_before_update(hass) + + try: + await client.homeassistant.update( + HomeAssistantUpdateOptions(version=version, backup=False) + ) + except SupervisorError as err: + raise HomeAssistantError(f"Error updating Home Assistant Core: {err}") from err diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index f9d1b40575b..23fdc721168 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.websocket_api import ActiveConnection +from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import Unauthorized import homeassistant.helpers.config_validation as cv @@ -23,7 +24,9 @@ from .const import ( ATTR_ENDPOINT, ATTR_METHOD, ATTR_SESSION_DATA_USER_ID, + ATTR_SLUG, ATTR_TIMEOUT, + ATTR_VERSION, ATTR_WS_EVENT, DATA_COMPONENT, EVENT_SUPERVISOR_EVENT, @@ -33,6 +36,8 @@ from .const import ( WS_TYPE_EVENT, WS_TYPE_SUBSCRIBE, ) +from .coordinator import get_supervisor_info +from .update_helper import update_addon, update_core SCHEMA_WEBSOCKET_EVENT = vol.Schema( {vol.Required(ATTR_WS_EVENT): cv.string}, @@ -58,6 +63,8 @@ def async_load_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_supervisor_event) websocket_api.async_register_command(hass, websocket_supervisor_api) websocket_api.async_register_command(hass, websocket_subscribe) + websocket_api.async_register_command(hass, websocket_update_addon) + websocket_api.async_register_command(hass, websocket_update_core) @callback @@ -137,3 +144,44 @@ async def websocket_supervisor_api( ) else: connection.send_result(msg[WS_ID], result.get(ATTR_DATA, {})) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(WS_TYPE): "hassio/update/addon", + vol.Required("addon"): str, + vol.Required("backup"): bool, + } +) +@websocket_api.async_response +async def websocket_update_addon( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Websocket handler to update an addon.""" + addon_name: str | None = None + addon_version: str | None = None + addons: list = (get_supervisor_info(hass) or {}).get("addons", []) + for addon in addons: + if addon[ATTR_SLUG] == msg["addon"]: + addon_name = addon[ATTR_NAME] + addon_version = addon[ATTR_VERSION] + break + await update_addon(hass, msg["addon"], msg["backup"], addon_name, addon_version) + connection.send_result(msg[WS_ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(WS_TYPE): "hassio/update/core", + vol.Required("backup"): bool, + } +) +@websocket_api.async_response +async def websocket_update_core( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Websocket handler to update an addon.""" + await update_core(hass, None, msg["backup"]) + connection.send_result(msg[WS_ID]) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 9e1ce8d7f43..0cd33e28d35 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -528,7 +528,7 @@ def resolution_suggestions_for_issue_fixture(supervisor_client: AsyncMock) -> As @pytest.fixture(name="supervisor_client") def supervisor_client() -> Generator[AsyncMock]: """Mock the supervisor client.""" - mounts_info_mock = AsyncMock(spec_set=["mounts"]) + mounts_info_mock = AsyncMock(spec_set=["default_backup_mount", "mounts"]) mounts_info_mock.mounts = [] supervisor_client = AsyncMock() supervisor_client.addons = AsyncMock() @@ -572,6 +572,10 @@ def supervisor_client() -> Generator[AsyncMock]: "homeassistant.components.hassio.repairs.get_supervisor_client", return_value=supervisor_client, ), + patch( + "homeassistant.components.hassio.update_helper.get_supervisor_client", + return_value=supervisor_client, + ), ): yield supervisor_client diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index c1775d6e0b4..88d7076824f 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -2,14 +2,17 @@ from datetime import timedelta import os +from typing import Any from unittest.mock import AsyncMock, patch from aiohasupervisor import SupervisorBadRequestError, SupervisorError -from aiohasupervisor.models import StoreAddonUpdate +from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest +from homeassistant.components.backup import BackupManagerError from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY +from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component @@ -216,12 +219,119 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non assert result await hass.async_block_till_done() - await hass.services.async_call( - "update", - "install", - {"entity_id": "update.test_update"}, - blocking=True, - ) + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update"}, + blocking=True, + ) + mock_create_backup.assert_not_called() + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + +@pytest.mark.parametrize( + ("commands", "default_mount", "expected_kwargs"), + [ + ( + [], + None, + { + "agent_ids": ["hassio.local"], + "include_addons": ["test"], + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "include_homeassistant": False, + "name": "test 2.0.0", + "password": None, + }, + ), + ( + [], + "my_nas", + { + "agent_ids": ["hassio.my_nas"], + "include_addons": ["test"], + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "include_homeassistant": False, + "name": "test 2.0.0", + "password": None, + }, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "name": "cool_backup", + "password": "hunter2", + }, + }, + ], + None, + { + "agent_ids": ["hassio.local"], + "include_addons": ["test"], + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "include_homeassistant": False, + "name": "test 2.0.0", + "password": "hunter2", + }, + ), + ], +) +async def test_update_addon_with_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + update_addon: AsyncMock, + commands: list[dict[str, Any]], + default_mount: str | None, + expected_kwargs: dict[str, Any], +) -> None: + """Test updating addon update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + supervisor_client.mounts.info.return_value.default_backup_mount = default_mount + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update", "backup": True}, + blocking=True, + ) + mock_create_backup.assert_called_once_with(**expected_kwargs) update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) @@ -264,13 +374,125 @@ async def test_update_core(hass: HomeAssistant, supervisor_client: AsyncMock) -> await hass.async_block_till_done() supervisor_client.homeassistant.update.return_value = None - await hass.services.async_call( - "update", - "install", - {"entity_id": "update.home_assistant_core_update"}, - blocking=True, + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_core_update"}, + blocking=True, + ) + mock_create_backup.assert_not_called() + supervisor_client.homeassistant.update.assert_called_once_with( + HomeAssistantUpdateOptions(version=None, backup=False) + ) + + +@pytest.mark.parametrize( + ("commands", "default_mount", "expected_kwargs"), + [ + ( + [], + None, + { + "agent_ids": ["hassio.local"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": f"Home Assistant Core {HAVERSION}", + "password": None, + }, + ), + ( + [], + "my_nas", + { + "agent_ids": ["hassio.my_nas"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": f"Home Assistant Core {HAVERSION}", + "password": None, + }, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "name": "cool_backup", + "password": "hunter2", + }, + }, + ], + None, + { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "include_homeassistant": True, + "name": "cool_backup", + "password": "hunter2", + "with_automatic_settings": True, + }, + ), + ], +) +async def test_update_core_with_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + commands: list[dict[str, Any]], + default_mount: str | None, + expected_kwargs: dict[str, Any], +) -> None: + """Test updating core update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + supervisor_client.homeassistant.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = default_mount + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_core_update", "backup": True}, + blocking=True, + ) + mock_create_backup.assert_called_once_with(**expected_kwargs) + supervisor_client.homeassistant.update.assert_called_once_with( + HomeAssistantUpdateOptions(version=None, backup=False) ) - supervisor_client.homeassistant.update.assert_called_once() async def test_update_supervisor( @@ -325,6 +547,41 @@ async def test_update_addon_with_error( ) +async def test_update_addon_with_backup_and_error( + hass: HomeAssistant, + supervisor_client: AsyncMock, +) -> None: + """Test updating addon update entity with error.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + supervisor_client.homeassistant.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + side_effect=BackupManagerError, + ), + pytest.raises(HomeAssistantError, match=r"^Error creating backup:"), + ): + assert not await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update", "backup": True}, + blocking=True, + ) + + async def test_update_os_with_error( hass: HomeAssistant, supervisor_client: AsyncMock ) -> None: @@ -406,6 +663,41 @@ async def test_update_core_with_error( ) +async def test_update_core_with_backup_and_error( + hass: HomeAssistant, + supervisor_client: AsyncMock, +) -> None: + """Test updating core update entity with error.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + supervisor_client.homeassistant.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + side_effect=BackupManagerError, + ), + pytest.raises(HomeAssistantError, match=r"^Error creating backup:"), + ): + assert not await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_core_update", "backup": True}, + blocking=True, + ) + + async def test_release_notes_between_versions( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 21e6b03678b..1fefe54ad75 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -1,9 +1,15 @@ """Test websocket API.""" -from unittest.mock import AsyncMock +import os +from typing import Any +from unittest.mock import AsyncMock, patch +from aiohasupervisor import SupervisorError +from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest +from homeassistant.components.backup import BackupManagerError +from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import ( ATTR_DATA, ATTR_ENDPOINT, @@ -15,14 +21,17 @@ from homeassistant.components.hassio.const import ( WS_TYPE_API, WS_TYPE_SUBSCRIBE, ) +from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component -from tests.common import MockUser, async_mock_signal +from tests.common import MockConfigEntry, MockUser, async_mock_signal from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} + @pytest.fixture(autouse=True) def mock_all( @@ -56,7 +65,7 @@ def mock_all( ) aioclient_mock.get( "http://127.0.0.1/core/info", - json={"result": "ok", "data": {"version_latest": "1.0.0"}}, + json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}}, ) aioclient_mock.get( "http://127.0.0.1/os/info", @@ -64,11 +73,42 @@ def mock_all( ) aioclient_mock.get( "http://127.0.0.1/supervisor/info", - json={"result": "ok", "data": {"version_latest": "1.0.0"}}, + json={ + "result": "ok", + "data": { + "version": "1.0.0", + "version_latest": "1.0.0", + "auto_update": True, + "addons": [ + { + "name": "test", + "state": "started", + "slug": "test", + "installed": True, + "update_available": True, + "icon": False, + "version": "2.0.0", + "version_latest": "2.0.1", + "repository": "core", + "url": "https://github.com/home-assistant/addons/test", + }, + ], + }, + }, ) aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) + aioclient_mock.get( + "http://127.0.0.1/network/info", + json={ + "result": "ok", + "data": { + "host_internet": True, + "supervisor_internet": True, + }, + }, + ) @pytest.mark.usefixtures("hassio_env") @@ -279,3 +319,407 @@ async def test_websocket_non_admin_user( msg = await websocket_client.receive_json() assert msg["error"]["message"] == "Unauthorized" + + +async def test_update_addon( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + update_addon: AsyncMock, +) -> None: + """Test updating addon.""" + client = await hass_ws_client(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": False} + ) + result = await client.receive_json() + assert result["success"] + mock_create_backup.assert_not_called() + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + +@pytest.mark.parametrize( + ("commands", "default_mount", "expected_kwargs"), + [ + ( + [], + None, + { + "agent_ids": ["hassio.local"], + "include_addons": ["test"], + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "include_homeassistant": False, + "name": "test 2.0.0", + "password": None, + }, + ), + ( + [], + "my_nas", + { + "agent_ids": ["hassio.my_nas"], + "include_addons": ["test"], + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "include_homeassistant": False, + "name": "test 2.0.0", + "password": None, + }, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "name": "cool_backup", + "password": "hunter2", + }, + }, + ], + None, + { + "agent_ids": ["hassio.local"], + "include_addons": ["test"], + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "include_homeassistant": False, + "name": "test 2.0.0", + "password": "hunter2", + }, + ), + ], +) +async def test_update_addon_with_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + update_addon: AsyncMock, + commands: list[dict[str, Any]], + default_mount: str | None, + expected_kwargs: dict[str, Any], +) -> None: + """Test updating addon with backup.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + supervisor_client.mounts.info.return_value.default_backup_mount = default_mount + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": True} + ) + result = await client.receive_json() + assert result["success"] + mock_create_backup.assert_called_once_with(**expected_kwargs) + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + +async def test_update_core( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test updating core.""" + client = await hass_ws_client(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + supervisor_client.homeassistant.update.return_value = None + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await client.send_json_auto_id({"type": "hassio/update/core", "backup": False}) + result = await client.receive_json() + assert result["success"] + mock_create_backup.assert_not_called() + supervisor_client.homeassistant.update.assert_called_once_with( + HomeAssistantUpdateOptions(version=None, backup=False) + ) + + +@pytest.mark.parametrize( + ("commands", "default_mount", "expected_kwargs"), + [ + ( + [], + None, + { + "agent_ids": ["hassio.local"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": f"Home Assistant Core {HAVERSION}", + "password": None, + }, + ), + ( + [], + "my_nas", + { + "agent_ids": ["hassio.my_nas"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": f"Home Assistant Core {HAVERSION}", + "password": None, + }, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "name": "cool_backup", + "password": "hunter2", + }, + }, + ], + None, + { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "include_homeassistant": True, + "name": "cool_backup", + "password": "hunter2", + "with_automatic_settings": True, + }, + ), + ], +) +async def test_update_core_with_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + commands: list[dict[str, Any]], + default_mount: str | None, + expected_kwargs: dict[str, Any], +) -> None: + """Test updating core with backup.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + supervisor_client.homeassistant.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = default_mount + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await client.send_json_auto_id({"type": "hassio/update/core", "backup": True}) + result = await client.receive_json() + assert result["success"] + mock_create_backup.assert_called_once_with(**expected_kwargs) + supervisor_client.homeassistant.update.assert_called_once_with( + HomeAssistantUpdateOptions(version=None, backup=False) + ) + + +async def test_update_addon_with_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + update_addon: AsyncMock, +) -> None: + """Test updating addon with error.""" + client = await hass_ws_client(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + assert await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + await hass.async_block_till_done() + + update_addon.side_effect = SupervisorError + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": False} + ) + result = await client.receive_json() + assert not result["success"] + assert result["error"] == { + "code": "home_assistant_error", + "message": "Error updating test: ", + } + + +async def test_update_addon_with_backup_and_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test updating addon with backup and error.""" + client = await hass_ws_client(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + supervisor_client.homeassistant.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + side_effect=BackupManagerError, + ), + ): + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": True} + ) + result = await client.receive_json() + assert not result["success"] + assert result["error"] == { + "code": "home_assistant_error", + "message": "Error creating backup: ", + } + + +async def test_update_core_with_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test updating core with error.""" + client = await hass_ws_client(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + assert await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + await hass.async_block_till_done() + + supervisor_client.homeassistant.update.side_effect = SupervisorError + await client.send_json_auto_id({"type": "hassio/update/core", "backup": False}) + result = await client.receive_json() + assert not result["success"] + assert result["error"] == { + "code": "home_assistant_error", + "message": "Error updating Home Assistant Core: ", + } + + +async def test_update_core_with_backup_and_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test updating core with backup and error.""" + client = await hass_ws_client(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + supervisor_client.homeassistant.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + side_effect=BackupManagerError, + ), + ): + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": True} + ) + result = await client.receive_json() + assert not result["success"] + assert result["error"] == { + "code": "home_assistant_error", + "message": "Error creating backup: ", + } From 33a23ad1c6ae42bdb815fd3fd5af1d0cadcbb1d2 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Mon, 27 Jan 2025 08:43:30 +0100 Subject: [PATCH 1001/2987] Add diagnostic sensors for the active subscription of Cookidoo (#136485) * add diagnostics for the active subcription * fix mapping between api and ha states for subscription * multiline lambda --- homeassistant/components/cookidoo/__init__.py | 2 +- homeassistant/components/cookidoo/const.py | 6 + .../components/cookidoo/coordinator.py | 7 +- homeassistant/components/cookidoo/icons.json | 13 ++ homeassistant/components/cookidoo/sensor.py | 111 ++++++++++++++++++ .../components/cookidoo/strings.json | 13 ++ tests/components/cookidoo/conftest.py | 5 + .../cookidoo/fixtures/subscriptions.json | 12 ++ .../cookidoo/snapshots/test_sensor.ambr | 106 +++++++++++++++++ tests/components/cookidoo/test_sensor.py | 44 +++++++ 10 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/cookidoo/sensor.py create mode 100644 tests/components/cookidoo/fixtures/subscriptions.json create mode 100644 tests/components/cookidoo/snapshots/test_sensor.ambr create mode 100644 tests/components/cookidoo/test_sensor.py diff --git a/homeassistant/components/cookidoo/__init__.py b/homeassistant/components/cookidoo/__init__.py index 67095422e65..bff4c8123d6 100644 --- a/homeassistant/components/cookidoo/__init__.py +++ b/homeassistant/components/cookidoo/__init__.py @@ -14,7 +14,7 @@ from .const import DOMAIN from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator from .helpers import cookidoo_from_config_entry -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.TODO] +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.TODO] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cookidoo/const.py b/homeassistant/components/cookidoo/const.py index 37c584404a0..0381e18725d 100644 --- a/homeassistant/components/cookidoo/const.py +++ b/homeassistant/components/cookidoo/const.py @@ -1,3 +1,9 @@ """Constants for the Cookidoo integration.""" DOMAIN = "cookidoo" + +SUBSCRIPTION_MAP = { + "NONE": "free", + "TRIAL": "trial", + "REGULAR": "premium", +} diff --git a/homeassistant/components/cookidoo/coordinator.py b/homeassistant/components/cookidoo/coordinator.py index ad86d1fb9f1..f99f58c2dd6 100644 --- a/homeassistant/components/cookidoo/coordinator.py +++ b/homeassistant/components/cookidoo/coordinator.py @@ -13,6 +13,7 @@ from cookidoo_api import ( CookidooException, CookidooIngredientItem, CookidooRequestException, + CookidooSubscription, ) from homeassistant.config_entries import ConfigEntry @@ -34,6 +35,7 @@ class CookidooData: ingredient_items: list[CookidooIngredientItem] additional_items: list[CookidooAdditionalItem] + subscription: CookidooSubscription | None class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): @@ -75,6 +77,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): try: ingredient_items = await self.cookidoo.get_ingredient_items() additional_items = await self.cookidoo.get_additional_items() + subscription = await self.cookidoo.get_active_subscription() except CookidooAuthException: try: await self.cookidoo.refresh_token() @@ -97,5 +100,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): ) from e return CookidooData( - ingredient_items=ingredient_items, additional_items=additional_items + ingredient_items=ingredient_items, + additional_items=additional_items, + subscription=subscription, ) diff --git a/homeassistant/components/cookidoo/icons.json b/homeassistant/components/cookidoo/icons.json index 0e411a70fc2..cf4d9dc2858 100644 --- a/homeassistant/components/cookidoo/icons.json +++ b/homeassistant/components/cookidoo/icons.json @@ -1,5 +1,18 @@ { "entity": { + "sensor": { + "subscription": { + "default": "mdi:account", + "state": { + "free": "mdi:account", + "trial": "mdi:account-question", + "regular": "mdi:account-star" + } + }, + "expiration": { + "default": "mdi:account-reactivate" + } + }, "button": { "todo_clear": { "default": "mdi:cart-off" diff --git a/homeassistant/components/cookidoo/sensor.py b/homeassistant/components/cookidoo/sensor.py new file mode 100644 index 00000000000..7fbacea18bc --- /dev/null +++ b/homeassistant/components/cookidoo/sensor.py @@ -0,0 +1,111 @@ +"""Sensor platform for the Cookidoo integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util + +from .const import SUBSCRIPTION_MAP +from .coordinator import ( + CookidooConfigEntry, + CookidooData, + CookidooDataUpdateCoordinator, +) +from .entity import CookidooBaseEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class CookidooSensorEntityDescription(SensorEntityDescription): + """Cookidoo Sensor Description.""" + + value_fn: Callable[[CookidooData], StateType | datetime] + + +class CookidooSensor(StrEnum): + """Cookidoo sensors.""" + + SUBSCRIPTION = "subscription" + EXPIRES = "expires" + + +SENSOR_DESCRIPTIONS: tuple[CookidooSensorEntityDescription, ...] = ( + CookidooSensorEntityDescription( + key=CookidooSensor.SUBSCRIPTION, + translation_key=CookidooSensor.SUBSCRIPTION, + value_fn=( + lambda data: SUBSCRIPTION_MAP[data.subscription.type] + if data.subscription + else SUBSCRIPTION_MAP["NONE"] + ), + entity_category=EntityCategory.DIAGNOSTIC, + options=list(SUBSCRIPTION_MAP.values()), + device_class=SensorDeviceClass.ENUM, + ), + CookidooSensorEntityDescription( + key=CookidooSensor.EXPIRES, + translation_key=CookidooSensor.EXPIRES, + value_fn=( + lambda data: dt_util.parse_datetime(data.subscription.expires) + if data.subscription + else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.TIMESTAMP, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: CookidooConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + CookidooSensorEntity( + coordinator, + description, + ) + for description in SENSOR_DESCRIPTIONS + ) + + +class CookidooSensorEntity(CookidooBaseEntity, SensorEntity): + """A sensor entity.""" + + entity_description: CookidooSensorEntityDescription + + def __init__( + self, + coordinator: CookidooDataUpdateCoordinator, + entity_description: CookidooSensorEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}_{self.entity_description.key}" + ) + + @property + def native_value(self) -> StateType | datetime: + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/cookidoo/strings.json b/homeassistant/components/cookidoo/strings.json index 8a2a288d11b..ae384fb6635 100644 --- a/homeassistant/components/cookidoo/strings.json +++ b/homeassistant/components/cookidoo/strings.json @@ -49,6 +49,19 @@ } }, "entity": { + "sensor": { + "subscription": { + "name": "Subscription", + "state": { + "free": "Free", + "trial": "Trial", + "premium": "Premium" + } + }, + "expires": { + "name": "Subscription expiration date" + } + }, "button": { "todo_clear": { "name": "Clear shopping list and additional purchases" diff --git a/tests/components/cookidoo/conftest.py b/tests/components/cookidoo/conftest.py index a14bc285379..096b2abf958 100644 --- a/tests/components/cookidoo/conftest.py +++ b/tests/components/cookidoo/conftest.py @@ -8,6 +8,7 @@ from cookidoo_api import ( CookidooAdditionalItem, CookidooAuthResponse, CookidooIngredientItem, + CookidooSubscription, ) import pytest @@ -54,6 +55,10 @@ def mock_cookidoo_client() -> Generator[AsyncMock]: "data" ] ] + client.get_active_subscription.return_value = CookidooSubscription( + **load_json_object_fixture("subscriptions.json", DOMAIN)["data"] + ) + client.login.return_value = CookidooAuthResponse( **load_json_object_fixture("login.json", DOMAIN) ) diff --git a/tests/components/cookidoo/fixtures/subscriptions.json b/tests/components/cookidoo/fixtures/subscriptions.json new file mode 100644 index 00000000000..12b74b3af08 --- /dev/null +++ b/tests/components/cookidoo/fixtures/subscriptions.json @@ -0,0 +1,12 @@ +{ + "data": { + "active": true, + "start_date": "2024-12-16T00:00:00Z", + "expires": "2025-12-16T23:59:00Z", + "type": "REGULAR", + "extended_type": "REGULAR", + "subscription_level": "FULL", + "subscription_source": "COMMERCE", + "status": "ACTIVE" + } +} diff --git a/tests/components/cookidoo/snapshots/test_sensor.ambr b/tests/components/cookidoo/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..568b0baf688 --- /dev/null +++ b/tests/components/cookidoo/snapshots/test_sensor.ambr @@ -0,0 +1,106 @@ +# serializer version: 1 +# name: test_setup[sensor.cookidoo_subscription-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'free', + 'trial', + 'premium', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.cookidoo_subscription', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Subscription', + 'platform': 'cookidoo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'sub_uuid_subscription', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.cookidoo_subscription-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Cookidoo Subscription', + 'options': list([ + 'free', + 'trial', + 'premium', + ]), + }), + 'context': , + 'entity_id': 'sensor.cookidoo_subscription', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'premium', + }) +# --- +# name: test_setup[sensor.cookidoo_subscription_expiration_date-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.cookidoo_subscription_expiration_date', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Subscription expiration date', + 'platform': 'cookidoo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'sub_uuid_expires', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.cookidoo_subscription_expiration_date-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Cookidoo Subscription expiration date', + }), + 'context': , + 'entity_id': 'sensor.cookidoo_subscription_expiration_date', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-12-16T23:59:00+00:00', + }) +# --- diff --git a/tests/components/cookidoo/test_sensor.py b/tests/components/cookidoo/test_sensor.py new file mode 100644 index 00000000000..d2ef88f2857 --- /dev/null +++ b/tests/components/cookidoo/test_sensor.py @@ -0,0 +1,44 @@ +"""Test for sensor platform of the Cookidoo integration.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def sensor_only() -> Generator[None]: + """Enable only the sensor platform.""" + with patch( + "homeassistant.components.cookidoo.PLATFORMS", + [Platform.SENSOR], + ): + yield + + +@pytest.mark.usefixtures("mock_cookidoo_client") +async def test_setup( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of sensor platform.""" + + cookidoo_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(cookidoo_config_entry.entry_id) + await hass.async_block_till_done() + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform( + hass, entity_registry, snapshot, cookidoo_config_entry.entry_id + ) From 385a0786759a634e2f318f2b9080233ea28e9d34 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 27 Jan 2025 01:04:27 -0800 Subject: [PATCH 1002/2987] Bump nest to python-nest-sdm to 7.1.0 (#136611) --- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index e14474dc309..f7e78b2d538 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/nest", "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], - "requirements": ["google-nest-sdm==7.0.0"] + "requirements": ["google-nest-sdm==7.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d0b18b7cab5..96f53acf13d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1030,7 +1030,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.0.0 +google-nest-sdm==7.1.0 # homeassistant.components.google_photos google-photos-library-api==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7ce62fbe43e..5ecdd37fe58 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -880,7 +880,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.0.0 +google-nest-sdm==7.1.0 # homeassistant.components.google_photos google-photos-library-api==0.12.1 From ffdb686363d61ac82cd2daf68ea81b18f1e763e8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jan 2025 10:15:53 +0100 Subject: [PATCH 1003/2987] Use runtime_data in crownstone (#136406) * Use runtime_data in crownstone * Move some logic into __init__ * Remove underscore in async_update_listener --- .../components/crownstone/__init__.py | 39 +++++++++++++----- .../components/crownstone/config_flow.py | 12 +++--- .../components/crownstone/entry_manager.py | 40 +++++-------------- homeassistant/components/crownstone/light.py | 12 ++---- .../components/crownstone/test_config_flow.py | 15 +++---- 5 files changed, 55 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/crownstone/__init__.py b/homeassistant/components/crownstone/__init__.py index e1443eb9516..8f5739f9172 100644 --- a/homeassistant/components/crownstone/__init__.py +++ b/homeassistant/components/crownstone/__init__.py @@ -2,25 +2,42 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .entry_manager import CrownstoneEntryManager +from .const import PLATFORMS +from .entry_manager import CrownstoneConfigEntry, CrownstoneEntryManager -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: CrownstoneConfigEntry) -> bool: """Initiate setup for a Crownstone config entry.""" manager = CrownstoneEntryManager(hass, entry) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = manager + if not await manager.async_setup(): + return False - return await manager.async_setup() + entry.runtime_data = manager + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # HA specific listeners + entry.async_on_unload(entry.add_update_listener(async_update_listener)) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.on_shutdown) + ) + + return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: CrownstoneConfigEntry) -> bool: """Unload a config entry.""" - unload_ok: bool = await hass.data[DOMAIN][entry.entry_id].async_unload() - if len(hass.data[DOMAIN]) == 0: - hass.data.pop(DOMAIN) - return unload_ok + entry.runtime_data.async_unload() + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_update_listener( + hass: HomeAssistant, entry: CrownstoneConfigEntry +) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/crownstone/config_flow.py b/homeassistant/components/crownstone/config_flow.py index 2a96098421a..5f5af4f51a4 100644 --- a/homeassistant/components/crownstone/config_flow.py +++ b/homeassistant/components/crownstone/config_flow.py @@ -16,7 +16,6 @@ import voluptuous as vol from homeassistant.components import usb from homeassistant.config_entries import ( - ConfigEntry, ConfigEntryBaseFlow, ConfigFlow, ConfigFlowResult, @@ -37,6 +36,7 @@ from .const import ( MANUAL_PATH, REFRESH_LIST, ) +from .entry_manager import CrownstoneConfigEntry from .helpers import list_ports_as_str CONFIG_FLOW = "config_flow" @@ -140,7 +140,7 @@ class CrownstoneConfigFlowHandler(BaseCrownstoneFlowHandler, ConfigFlow, domain= @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: CrownstoneConfigEntry, ) -> CrownstoneOptionsFlowHandler: """Return the Crownstone options.""" return CrownstoneOptionsFlowHandler(config_entry) @@ -210,7 +210,9 @@ class CrownstoneConfigFlowHandler(BaseCrownstoneFlowHandler, ConfigFlow, domain= class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow): """Handle Crownstone options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + config_entry: CrownstoneConfigEntry + + def __init__(self, config_entry: CrownstoneConfigEntry) -> None: """Initialize Crownstone options.""" super().__init__(OPTIONS_FLOW, self.async_create_new_entry) self.options = config_entry.options.copy() @@ -219,9 +221,7 @@ class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage Crownstone options.""" - self.cloud: CrownstoneCloud = self.hass.data[DOMAIN][ - self.config_entry.entry_id - ].cloud + self.cloud = self.config_entry.runtime_data.cloud spheres = {sphere.name: sphere.cloud_id for sphere in self.cloud.cloud_data} usb_path = self.config_entry.options.get(CONF_USB_PATH) diff --git a/homeassistant/components/crownstone/entry_manager.py b/homeassistant/components/crownstone/entry_manager.py index efee05a19c8..e414e3c7055 100644 --- a/homeassistant/components/crownstone/entry_manager.py +++ b/homeassistant/components/crownstone/entry_manager.py @@ -16,7 +16,7 @@ from crownstone_uart.Exceptions import UartException from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client @@ -26,7 +26,6 @@ from .const import ( CONF_USB_PATH, CONF_USB_SPHERE, DOMAIN, - PLATFORMS, PROJECT_NAME, SSE_LISTENERS, UART_LISTENERS, @@ -36,6 +35,8 @@ from .listeners import setup_sse_listeners, setup_uart_listeners _LOGGER = logging.getLogger(__name__) +type CrownstoneConfigEntry = ConfigEntry[CrownstoneEntryManager] + class CrownstoneEntryManager: """Manage a Crownstone config entry.""" @@ -44,7 +45,9 @@ class CrownstoneEntryManager: cloud: CrownstoneCloud sse: CrownstoneSSEAsync - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: CrownstoneConfigEntry + ) -> None: """Initialize the hub.""" self.hass = hass self.config_entry = config_entry @@ -100,18 +103,6 @@ class CrownstoneEntryManager: # Makes HA aware of the Crownstone environment HA is placed in, a user can have multiple self.usb_sphere_id = self.config_entry.options[CONF_USB_SPHERE] - await self.hass.config_entries.async_forward_entry_setups( - self.config_entry, PLATFORMS - ) - - # HA specific listeners - self.config_entry.async_on_unload( - self.config_entry.add_update_listener(_async_update_listener) - ) - self.config_entry.async_on_unload( - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.on_shutdown) - ) - return True async def async_process_events(self, sse_client: CrownstoneSSEAsync) -> None: @@ -161,11 +152,12 @@ class CrownstoneEntryManager: setup_uart_listeners(self) - async def async_unload(self) -> bool: + @callback + def async_unload(self) -> None: """Unload the current config entry.""" # Authentication failed if self.cloud.cloud_data is None: - return True + return self.sse.close_client() for sse_unsub in self.listeners[SSE_LISTENERS]: @@ -176,23 +168,9 @@ class CrownstoneEntryManager: for subscription_id in self.listeners[UART_LISTENERS]: UartEventBus.unsubscribe(subscription_id) - unload_ok = await self.hass.config_entries.async_unload_platforms( - self.config_entry, PLATFORMS - ) - - if unload_ok: - self.hass.data[DOMAIN].pop(self.config_entry.entry_id) - - return unload_ok - @callback def on_shutdown(self, _: Event) -> None: """Close all IO connections.""" self.sse.close_client() if self.uart: self.uart.stop() - - -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/crownstone/light.py b/homeassistant/components/crownstone/light.py index 16faa3a36d2..70b7631fe6b 100644 --- a/homeassistant/components/crownstone/light.py +++ b/homeassistant/components/crownstone/light.py @@ -3,7 +3,7 @@ from __future__ import annotations from functools import partial -from typing import TYPE_CHECKING, Any +from typing import Any from crownstone_cloud.cloud_models.crownstones import Crownstone from crownstone_cloud.const import DIMMING_ABILITY @@ -11,7 +11,6 @@ from crownstone_cloud.exceptions import CrownstoneAbilityError from crownstone_uart import CrownstoneUart from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -20,24 +19,21 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( CROWNSTONE_INCLUDE_TYPES, CROWNSTONE_SUFFIX, - DOMAIN, SIG_CROWNSTONE_STATE_UPDATE, SIG_UART_STATE_CHANGE, ) from .entity import CrownstoneEntity +from .entry_manager import CrownstoneConfigEntry from .helpers import map_from_to -if TYPE_CHECKING: - from .entry_manager import CrownstoneEntryManager - async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: CrownstoneConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up crownstones from a config entry.""" - manager: CrownstoneEntryManager = hass.data[DOMAIN][config_entry.entry_id] + manager = config_entry.runtime_data entities: list[CrownstoneLightEntity] = [] diff --git a/tests/components/crownstone/test_config_flow.py b/tests/components/crownstone/test_config_flow.py index a38a04cb2ad..c3bb17cb6d6 100644 --- a/tests/components/crownstone/test_config_flow.py +++ b/tests/components/crownstone/test_config_flow.py @@ -163,7 +163,7 @@ async def start_config_flow(hass: HomeAssistant, mocked_cloud: MagicMock): async def start_options_flow( - hass: HomeAssistant, entry_id: str, mocked_manager: MagicMock + hass: HomeAssistant, entry: MockConfigEntry, mocked_manager: MagicMock ): """Patch CrownstoneEntryManager and start the flow.""" # set up integration @@ -171,9 +171,10 @@ async def start_options_flow( "homeassistant.components.crownstone.CrownstoneEntryManager", return_value=mocked_manager, ): - await hass.config_entries.async_setup(entry_id) + await hass.config_entries.async_setup(entry.entry_id) - return await hass.config_entries.options.async_init(entry_id) + entry.runtime_data = mocked_manager + return await hass.config_entries.options.async_init(entry.entry_id) async def test_no_user_input( @@ -413,7 +414,7 @@ async def test_options_flow_setup_usb( result = await start_options_flow( hass, - entry.entry_id, + entry, get_mocked_crownstone_entry_manager( get_mocked_crownstone_cloud(create_mocked_spheres(2)) ), @@ -490,7 +491,7 @@ async def test_options_flow_remove_usb(hass: HomeAssistant) -> None: result = await start_options_flow( hass, - entry.entry_id, + entry, get_mocked_crownstone_entry_manager( get_mocked_crownstone_cloud(create_mocked_spheres(2)) ), @@ -543,7 +544,7 @@ async def test_options_flow_manual_usb_path( result = await start_options_flow( hass, - entry.entry_id, + entry, get_mocked_crownstone_entry_manager( get_mocked_crownstone_cloud(create_mocked_spheres(1)) ), @@ -602,7 +603,7 @@ async def test_options_flow_change_usb_sphere(hass: HomeAssistant) -> None: result = await start_options_flow( hass, - entry.entry_id, + entry, get_mocked_crownstone_entry_manager( get_mocked_crownstone_cloud(create_mocked_spheres(3)) ), From 1e0165c5f708bff5d9072d65ce51994f8c523ad1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jan 2025 10:16:13 +0100 Subject: [PATCH 1004/2987] Add lovelace compatiblity code (#136617) * Add lovelace compatiblity code * Docstring * Add tests --- homeassistant/components/lovelace/__init__.py | 27 ++++++++++++ tests/components/lovelace/test_init.py | 41 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 9b1c86edb36..51d2ed3eab7 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -15,6 +15,7 @@ from homeassistant.const import CONF_FILENAME, CONF_MODE, CONF_RESOURCES from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, config_validation as cv +from homeassistant.helpers.frame import report_usage from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration @@ -99,6 +100,32 @@ class LovelaceData: resources: resources.ResourceYAMLCollection | resources.ResourceStorageCollection yaml_dashboards: dict[str | None, ConfigType] + def __getitem__(self, name: str) -> Any: + """Enable method for compatibility reason. + + Following migration from an untyped dict to a dataclass in + https://github.com/home-assistant/core/pull/136313 + """ + report_usage( + f"accessed lovelace_data['{name}'] instead of lovelace_data.{name}", + breaks_in_ha_version="2026.2", + ) + return getattr(self, name) + + def get(self, name: str, default: Any = None) -> Any: + """Enable method for compatibility reason. + + Following migration from an untyped dict to a dataclass in + https://github.com/home-assistant/core/pull/136313 + """ + report_usage( + f"accessed lovelace_data.get('{name}') instead of lovelace_data.{name}", + breaks_in_ha_version="2026.2", + ) + if hasattr(self, name): + return getattr(self, name) + return default + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Lovelace commands.""" diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index f56ff4371e6..6f11c22466e 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -38,3 +38,44 @@ async def test_create_dashboards_when_onboarded( response = await client.receive_json() assert response["success"] assert response["result"] == [] + + +async def test_hass_data_compatibility( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test compatibility for external access. + + See: + https://github.com/hacs/integration/blob/4a820e8b1b066bc54a1c9c61102038af6c030603 + /custom_components/hacs/repositories/plugin.py#L173 + """ + expected = ( + "Detected that integration 'lovelace' accessed lovelace_data.get('resources')" + " instead of lovelace_data.resources at" + ) + + assert await async_setup_component(hass, "lovelace", {}) + + assert (lovelace_data := hass.data.get("lovelace")) is not None + assert expected not in caplog.text + + # Direct access to resources is fine + assert lovelace_data.resources is not None + assert ( + "Detected that integration 'lovelace' accessed lovelace_data" not in caplog.text + ) + + # Dict compatibility logs warning + assert lovelace_data["resources"] is not None + assert ( + "Detected that integration 'lovelace' accessed lovelace_data['resources']" + in caplog.text + ) + + # Dict get compatibility logs warning + assert lovelace_data.get("resources") is not None + assert ( + "Detected that integration 'lovelace' accessed lovelace_data.get('resources')" + in caplog.text + ) From acb9d687064034d679f99f4c18c8039cfa122132 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jan 2025 10:16:33 +0100 Subject: [PATCH 1005/2987] Use runtime_data in dynalite (#136448) * Use runtime_data in dynalite * Delay listener --- homeassistant/components/dynalite/__init__.py | 23 ++++++++----------- homeassistant/components/dynalite/bridge.py | 3 +++ homeassistant/components/dynalite/cover.py | 5 ++-- homeassistant/components/dynalite/entity.py | 7 +++--- homeassistant/components/dynalite/light.py | 4 ++-- homeassistant/components/dynalite/services.py | 6 ++--- homeassistant/components/dynalite/switch.py | 4 ++-- 7 files changed, 24 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index a1a6a38c8ab..3411882b725 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -16,11 +16,11 @@ from .services import setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +type DynaliteConfigEntry = ConfigEntry[DynaliteBridge] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Dynalite platform.""" - hass.data[DOMAIN] = {} - setup_services(hass) await async_register_dynalite_frontend(hass) @@ -28,35 +28,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_entry_changed(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_entry_changed(hass: HomeAssistant, entry: DynaliteConfigEntry) -> None: """Reload entry since the data has changed.""" LOGGER.debug("Reconfiguring entry %s", entry.data) - bridge = hass.data[DOMAIN][entry.entry_id] + bridge = entry.runtime_data bridge.reload_config(entry.data) LOGGER.debug("Reconfiguring entry finished %s", entry.data) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: DynaliteConfigEntry) -> bool: """Set up a bridge from a config entry.""" LOGGER.debug("Setting up entry %s", entry.data) bridge = DynaliteBridge(hass, convert_config(entry.data)) - # need to do it before the listener - hass.data[DOMAIN][entry.entry_id] = bridge - entry.async_on_unload(entry.add_update_listener(async_entry_changed)) if not await bridge.async_setup(): LOGGER.error("Could not set up bridge for entry %s", entry.data) - hass.data[DOMAIN][entry.entry_id] = None raise ConfigEntryNotReady + entry.runtime_data = bridge + entry.async_on_unload(entry.add_update_listener(async_entry_changed)) 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: DynaliteConfigEntry) -> bool: """Unload a config entry.""" LOGGER.debug("Unloading entry %s", entry.data) - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py index 6f090371eee..0e491281619 100644 --- a/homeassistant/components/dynalite/bridge.py +++ b/homeassistant/components/dynalite/bridge.py @@ -16,6 +16,7 @@ from dynalite_devices_lib.dynalite_devices import ( DynaliteNotification, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -23,6 +24,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ATTR_AREA, ATTR_HOST, ATTR_PACKET, ATTR_PRESET, LOGGER, PLATFORMS from .convert_config import convert_config +type DynaliteConfigEntry = ConfigEntry[DynaliteBridge] + class DynaliteBridge: """Manages a single Dynalite bridge.""" diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py index d7f366d919c..17adf1947ec 100644 --- a/homeassistant/components/dynalite/cover.py +++ b/homeassistant/components/dynalite/cover.py @@ -7,18 +7,17 @@ from homeassistant.components.cover import ( CoverDeviceClass, CoverEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from .bridge import DynaliteBridge +from .bridge import DynaliteBridge, DynaliteConfigEntry from .entity import DynaliteBase, async_setup_entry_base async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DynaliteConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" diff --git a/homeassistant/components/dynalite/entity.py b/homeassistant/components/dynalite/entity.py index 62667dc19c3..7957e9c8515 100644 --- a/homeassistant/components/dynalite/entity.py +++ b/homeassistant/components/dynalite/entity.py @@ -6,27 +6,26 @@ from abc import ABC, abstractmethod from collections.abc import Callable from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .bridge import DynaliteBridge +from .bridge import DynaliteBridge, DynaliteConfigEntry from .const import DOMAIN, LOGGER def async_setup_entry_base( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DynaliteConfigEntry, async_add_entities: AddEntitiesCallback, platform: str, entity_from_device: Callable, ) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" LOGGER.debug("Setting up %s entry = %s", platform, config_entry.data) - bridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data @callback def async_add_entities_platform(devices): diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py index e0dd8b147aa..ea2bc2bc96f 100644 --- a/homeassistant/components/dynalite/light.py +++ b/homeassistant/components/dynalite/light.py @@ -3,16 +3,16 @@ from typing import Any from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .bridge import DynaliteConfigEntry from .entity import DynaliteBase, async_setup_entry_base async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DynaliteConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" diff --git a/homeassistant/components/dynalite/services.py b/homeassistant/components/dynalite/services.py index 14160cced9d..d0d57a582b4 100644 --- a/homeassistant/components/dynalite/services.py +++ b/homeassistant/components/dynalite/services.py @@ -23,9 +23,9 @@ from .const import ( def _get_bridges(service_call: ServiceCall) -> list[DynaliteBridge]: host = service_call.data.get(ATTR_HOST, "") bridges = [ - bridge - for bridge in service_call.hass.data[DOMAIN].values() - if not host or bridge.host == host + entry.runtime_data + for entry in service_call.hass.config_entries.async_loaded_entries(DOMAIN) + if not host or entry.runtime_data.host == host ] LOGGER.debug("Selected bridges for service call: %s", bridges) return bridges diff --git a/homeassistant/components/dynalite/switch.py b/homeassistant/components/dynalite/switch.py index d24a098056a..dd6aad8670c 100644 --- a/homeassistant/components/dynalite/switch.py +++ b/homeassistant/components/dynalite/switch.py @@ -3,17 +3,17 @@ from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .bridge import DynaliteConfigEntry from .entity import DynaliteBase, async_setup_entry_base async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DynaliteConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" From 439a393816daef146262dd1ae37342d5eb10b1b2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jan 2025 10:16:57 +0100 Subject: [PATCH 1006/2987] Use runtime_data in deconz (#136412) * Use runtime_data in deconz * Adjust master logic * Simplify * Move DeconzConfigEntry definition * More TYPE_CHECKING * Apply suggestions from code review --- homeassistant/components/deconz/__init__.py | 47 ++++++++++++------- .../components/deconz/alarm_control_panel.py | 6 +-- .../components/deconz/binary_sensor.py | 6 +-- homeassistant/components/deconz/button.py | 6 +-- homeassistant/components/deconz/climate.py | 6 +-- homeassistant/components/deconz/cover.py | 6 +-- .../components/deconz/device_trigger.py | 9 ++-- .../components/deconz/diagnostics.py | 7 ++- homeassistant/components/deconz/fan.py | 6 +-- homeassistant/components/deconz/hub/api.py | 7 ++- homeassistant/components/deconz/hub/config.py | 10 ++-- homeassistant/components/deconz/hub/hub.py | 19 ++------ homeassistant/components/deconz/light.py | 6 +-- homeassistant/components/deconz/lock.py | 7 ++- homeassistant/components/deconz/number.py | 6 +-- homeassistant/components/deconz/scene.py | 7 ++- homeassistant/components/deconz/select.py | 7 ++- homeassistant/components/deconz/sensor.py | 6 +-- homeassistant/components/deconz/services.py | 10 +++- homeassistant/components/deconz/siren.py | 7 ++- homeassistant/components/deconz/switch.py | 7 ++- homeassistant/components/deconz/util.py | 10 +++- 22 files changed, 113 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 42c81e69740..7de091c1292 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -18,6 +18,8 @@ from .util import get_master_hub CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +type DeconzConfigEntry = ConfigEntry[DeconzHub] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up services.""" @@ -25,14 +27,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: DeconzConfigEntry +) -> bool: """Set up a deCONZ bridge for a config entry. Load config, group, light and sensor data for server information. Start websocket for push notification of state changes from deCONZ. """ - hass.data.setdefault(DOMAIN, {}) - if not config_entry.options: await async_update_master_hub(hass, config_entry) @@ -43,7 +45,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except AuthenticationRequired as err: raise ConfigEntryAuthFailed from err - hub = hass.data[DOMAIN][config_entry.entry_id] = DeconzHub(hass, config_entry, api) + hub = DeconzHub(hass, config_entry, api) + config_entry.runtime_data = hub await hub.async_update_device_registry() config_entry.async_on_unload( @@ -62,32 +65,44 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: DeconzConfigEntry +) -> bool: """Unload deCONZ config entry.""" - hub: DeconzHub = hass.data[DOMAIN].pop(config_entry.entry_id) + hub = config_entry.runtime_data async_unload_events(hub) - if hass.data[DOMAIN] and hub.master: - await async_update_master_hub(hass, config_entry) - new_master_hub = next(iter(hass.data[DOMAIN].values())) - await async_update_master_hub(hass, new_master_hub.config_entry) + other_loaded_entries: list[DeconzConfigEntry] = [ + e + for e in hass.config_entries.async_loaded_entries(DOMAIN) + # exclude the config entry being unloaded + if e.entry_id != config_entry.entry_id + ] + if other_loaded_entries and hub.master: + await async_update_master_hub(hass, config_entry, master=False) + new_master_hub = next(iter(other_loaded_entries)).runtime_data + await async_update_master_hub(hass, new_master_hub.config_entry, master=True) return await hub.async_reset() async def async_update_master_hub( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, + config_entry: DeconzConfigEntry, + *, + master: bool | None = None, ) -> None: """Update master hub boolean. Called by setup_entry and unload_entry. Makes sure there is always one master available. """ - try: - master_hub = get_master_hub(hass) - master = master_hub.config_entry == config_entry - except ValueError: - master = True + if master is None: + try: + master_hub = get_master_hub(hass) + master = master_hub.config_entry == config_entry + except ValueError: + master = True options = {**config_entry.options, CONF_MASTER_GATEWAY: master} diff --git a/homeassistant/components/deconz/alarm_control_panel.py b/homeassistant/components/deconz/alarm_control_panel.py index 678e441a7a9..94f4cd1ddd6 100644 --- a/homeassistant/components/deconz/alarm_control_panel.py +++ b/homeassistant/components/deconz/alarm_control_panel.py @@ -16,10 +16,10 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, CodeFormat, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .entity import DeconzDevice from .hub import DeconzHub @@ -47,11 +47,11 @@ def get_alarm_system_id_for_unique_id(hub: DeconzHub, unique_id: str) -> str | N async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the deCONZ alarm control panel devices.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[ALARM_CONTROl_PANEL_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index a5496d3bc10..e3b0fc2f2c0 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -23,11 +23,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .const import ATTR_DARK, ATTR_ON from .entity import DeconzDevice from .hub import DeconzHub @@ -160,11 +160,11 @@ ENTITY_DESCRIPTIONS: tuple[DeconzBinarySensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the deCONZ binary sensor.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[BINARY_SENSOR_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/button.py b/homeassistant/components/deconz/button.py index ecf28b5e22c..9fea1d02ab8 100644 --- a/homeassistant/components/deconz/button.py +++ b/homeassistant/components/deconz/button.py @@ -14,11 +14,11 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .entity import DeconzDevice, DeconzSceneMixin from .hub import DeconzHub @@ -46,11 +46,11 @@ ENTITY_DESCRIPTIONS = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the deCONZ button entity.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[BUTTON_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index 690f943379d..aa274e6c0c1 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -28,11 +28,11 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE from .entity import DeconzDevice from .hub import DeconzHub @@ -76,11 +76,11 @@ DECONZ_TO_PRESET_MODE = {value: key for key, value in PRESET_MODE_TO_DECONZ.item async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the deCONZ climate devices.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[CLIMATE_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py index 030c4b12709..6dee00248ff 100644 --- a/homeassistant/components/deconz/cover.py +++ b/homeassistant/components/deconz/cover.py @@ -17,10 +17,10 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .entity import DeconzDevice from .hub import DeconzHub @@ -33,11 +33,11 @@ DECONZ_TYPE_TO_DEVICE_CLASS = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up covers for deCONZ component.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[COVER_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index 2aeeece3ac5..158ac391b9b 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -22,7 +22,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN +from . import DOMAIN, DeconzConfigEntry from .deconz_event import ( CONF_DECONZ_EVENT, CONF_GESTURE, @@ -31,7 +31,6 @@ from .deconz_event import ( DeconzPresenceEvent, DeconzRelativeRotaryEvent, ) -from .hub import DeconzHub CONF_SUBTYPE = "subtype" @@ -684,9 +683,9 @@ def _get_deconz_event_from_device( device: dr.DeviceEntry, ) -> DeconzAlarmEvent | DeconzEvent | DeconzPresenceEvent | DeconzRelativeRotaryEvent: """Resolve deconz event from device.""" - hubs: dict[str, DeconzHub] = hass.data.get(DOMAIN, {}) - for hub in hubs.values(): - for deconz_event in hub.events: + entry: DeconzConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + for deconz_event in entry.runtime_data.events: if device.id == deconz_event.device_id: return deconz_event diff --git a/homeassistant/components/deconz/diagnostics.py b/homeassistant/components/deconz/diagnostics.py index fcd5dec120f..284b538d1dd 100644 --- a/homeassistant/components/deconz/diagnostics.py +++ b/homeassistant/components/deconz/diagnostics.py @@ -5,21 +5,20 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from .hub import DeconzHub +from . import DeconzConfigEntry REDACT_CONFIG = {CONF_API_KEY, CONF_UNIQUE_ID} REDACT_DECONZ_CONFIG = {"bridgeid", "mac", "panid"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: DeconzConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data diag: dict[str, Any] = {} diag["config"] = async_redact_data(config_entry.as_dict(), REDACT_CONFIG) diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index 26e4d3328b8..aec078f771f 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -12,7 +12,6 @@ from homeassistant.components.fan import ( FanEntity, FanEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( @@ -20,6 +19,7 @@ from homeassistant.util.percentage import ( percentage_to_ordered_list_item, ) +from . import DeconzConfigEntry from .entity import DeconzDevice from .hub import DeconzHub @@ -33,11 +33,11 @@ ORDERED_NAMED_FAN_SPEEDS: list[LightFanSpeed] = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up fans for deCONZ component.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[FAN_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/hub/api.py b/homeassistant/components/deconz/hub/api.py index 916c34672d8..c00a2178eb0 100644 --- a/homeassistant/components/deconz/hub/api.py +++ b/homeassistant/components/deconz/hub/api.py @@ -3,10 +3,10 @@ from __future__ import annotations import asyncio +from typing import TYPE_CHECKING from pydeconz import DeconzSession, errors -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client @@ -14,9 +14,12 @@ from ..const import LOGGER from ..errors import AuthenticationRequired, CannotConnect from .config import DeconzConfig +if TYPE_CHECKING: + from .. import DeconzConfigEntry + async def get_deconz_api( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: DeconzConfigEntry ) -> DeconzSession: """Create a gateway object and verify configuration.""" session = aiohttp_client.async_get_clientsession(hass) diff --git a/homeassistant/components/deconz/hub/config.py b/homeassistant/components/deconz/hub/config.py index 06d2dc10542..5acbe816833 100644 --- a/homeassistant/components/deconz/hub/config.py +++ b/homeassistant/components/deconz/hub/config.py @@ -3,9 +3,8 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Self +from typing import TYPE_CHECKING, Self -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from ..const import ( @@ -17,12 +16,15 @@ from ..const import ( DEFAULT_ALLOW_NEW_DEVICES, ) +if TYPE_CHECKING: + from .. import DeconzConfigEntry + @dataclass class DeconzConfig: """Represent a deCONZ config entry.""" - entry: ConfigEntry + entry: DeconzConfigEntry host: str port: int @@ -33,7 +35,7 @@ class DeconzConfig: allow_new_devices: bool @classmethod - def from_config_entry(cls, config_entry: ConfigEntry) -> Self: + def from_config_entry(cls, config_entry: DeconzConfigEntry) -> Self: """Create object from config entry.""" config = config_entry.data options = config_entry.options diff --git a/homeassistant/components/deconz/hub/hub.py b/homeassistant/components/deconz/hub/hub.py index ff958bbda50..3020d624f97 100644 --- a/homeassistant/components/deconz/hub/hub.py +++ b/homeassistant/components/deconz/hub/hub.py @@ -11,7 +11,7 @@ from pydeconz.interfaces.api_handlers import APIHandler, GroupedAPIHandler from pydeconz.interfaces.groups import GroupHandler from pydeconz.models.event import EventType -from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry +from homeassistant.config_entries import SOURCE_HASSIO from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC @@ -26,6 +26,7 @@ from ..const import ( from .config import DeconzConfig if TYPE_CHECKING: + from .. import DeconzConfigEntry from ..deconz_event import ( DeconzAlarmEvent, DeconzEvent, @@ -67,7 +68,7 @@ class DeconzHub: """Manages a single deCONZ gateway.""" def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: DeconzSession + self, hass: HomeAssistant, config_entry: DeconzConfigEntry, api: DeconzSession ) -> None: """Initialize the system.""" self.hass = hass @@ -94,12 +95,6 @@ class DeconzHub: self.deconz_groups: set[tuple[Callable[[EventType, str], None], str]] = set() self.ignored_devices: set[tuple[Callable[[EventType, str], None], str]] = set() - @callback - @staticmethod - def get_hub(hass: HomeAssistant, config_entry: ConfigEntry) -> DeconzHub: - """Return hub with a matching config entry ID.""" - return cast(DeconzHub, hass.data[DECONZ_DOMAIN][config_entry.entry_id]) - @property def bridgeid(self) -> str: """Return the unique identifier of the gateway.""" @@ -208,7 +203,7 @@ class DeconzHub: @staticmethod async def async_config_entry_updated( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: DeconzConfigEntry ) -> None: """Handle signals of config entry being updated. @@ -217,11 +212,7 @@ class DeconzHub: Causes for this is either discovery updating host address or config entry options changing. """ - if config_entry.entry_id not in hass.data[DECONZ_DOMAIN]: - # A race condition can occur if multiple config entries are - # unloaded in parallel - return - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data previous_config = hub.config hub.config = DeconzConfig.from_config_entry(config_entry) if previous_config.host != hub.config.host: diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index d82c05f14eb..72ba7035c8e 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -28,7 +28,6 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -38,6 +37,7 @@ from homeassistant.util.color import ( color_temperature_mired_to_kelvin, ) +from . import DeconzConfigEntry from .const import DOMAIN as DECONZ_DOMAIN, POWER_PLUGS from .entity import DeconzDevice from .hub import DeconzHub @@ -141,11 +141,11 @@ def update_color_state( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the deCONZ lights and groups from a config entry.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[LIGHT_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/lock.py b/homeassistant/components/deconz/lock.py index 50375e99778..e5e2faf1d57 100644 --- a/homeassistant/components/deconz/lock.py +++ b/homeassistant/components/deconz/lock.py @@ -9,21 +9,20 @@ from pydeconz.models.light.lock import Lock from pydeconz.models.sensor.door_lock import DoorLock from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .entity import DeconzDevice -from .hub import DeconzHub async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up locks for deCONZ component.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[LOCK_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py index 53461960573..9de86c1c79b 100644 --- a/homeassistant/components/deconz/number.py +++ b/homeassistant/components/deconz/number.py @@ -17,11 +17,11 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .entity import DeconzDevice from .hub import DeconzHub @@ -69,11 +69,11 @@ ENTITY_DESCRIPTIONS: tuple[DeconzNumberDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the deCONZ number entity.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[NUMBER_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py index 70b9f3f21b5..3f29b12b05f 100644 --- a/homeassistant/components/deconz/scene.py +++ b/homeassistant/components/deconz/scene.py @@ -7,21 +7,20 @@ from typing import Any from pydeconz.models.event import EventType from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, Scene -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .entity import DeconzSceneMixin -from .hub import DeconzHub async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up scenes for deCONZ integration.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[SCENE_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/select.py b/homeassistant/components/deconz/select.py index cbd96a4faf9..a3109a278fc 100644 --- a/homeassistant/components/deconz/select.py +++ b/homeassistant/components/deconz/select.py @@ -12,13 +12,12 @@ from pydeconz.models.sensor.presence import ( ) from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .entity import DeconzDevice -from .hub import DeconzHub SENSITIVITY_TO_DECONZ = { "High": PresenceConfigSensitivity.HIGH.value, @@ -30,11 +29,11 @@ DECONZ_TO_SENSITIVITY = {value: key for key, value in SENSITIVITY_TO_DECONZ.item async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the deCONZ button entity.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[SELECT_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 241ba015c67..576d356bca9 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -34,7 +34,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_VOLTAGE, @@ -55,6 +54,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType import homeassistant.util.dt as dt_util +from . import DeconzConfigEntry from .const import ATTR_DARK, ATTR_ON from .entity import DeconzDevice from .hub import DeconzHub @@ -331,11 +331,11 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the deCONZ sensors.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[SENSOR_DOMAIN] = set() known_device_entities: dict[str, set[str]] = { diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index 6127fe44308..1f032f3866a 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -1,5 +1,7 @@ """deCONZ services.""" +from typing import TYPE_CHECKING + from pydeconz.utils import normalize_bridge_id import voluptuous as vol @@ -16,6 +18,10 @@ from .const import CONF_BRIDGE_ID, DOMAIN, LOGGER from .hub import DeconzHub from .util import get_master_hub +if TYPE_CHECKING: + from . import DeconzConfigEntry + + DECONZ_SERVICES = "deconz_services" SERVICE_FIELD = "field" @@ -65,7 +71,9 @@ def async_setup_services(hass: HomeAssistant) -> None: found_hub = False bridge_id = normalize_bridge_id(service_data[CONF_BRIDGE_ID]) - for possible_hub in hass.data[DOMAIN].values(): + entry: DeconzConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + possible_hub = entry.runtime_data if possible_hub.bridgeid == bridge_id: hub = possible_hub found_hub = True diff --git a/homeassistant/components/deconz/siren.py b/homeassistant/components/deconz/siren.py index 982a0bd1b9e..28b606e30ba 100644 --- a/homeassistant/components/deconz/siren.py +++ b/homeassistant/components/deconz/siren.py @@ -13,21 +13,20 @@ from homeassistant.components.siren import ( SirenEntity, SirenEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .entity import DeconzDevice -from .hub import DeconzHub async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up sirens for deCONZ component.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[SIREN_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py index c79cd7b28db..cd28871e35b 100644 --- a/homeassistant/components/deconz/switch.py +++ b/homeassistant/components/deconz/switch.py @@ -8,25 +8,24 @@ from pydeconz.models.event import EventType from pydeconz.models.light.light import Light from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .const import POWER_PLUGS from .entity import DeconzDevice -from .hub import DeconzHub async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches for deCONZ component. Switches are based on the same device class as lights in deCONZ. """ - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[SWITCH_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/util.py b/homeassistant/components/deconz/util.py index bcf338b2d6d..c4dc9df08ce 100644 --- a/homeassistant/components/deconz/util.py +++ b/homeassistant/components/deconz/util.py @@ -2,11 +2,16 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from homeassistant.core import HomeAssistant, callback from .const import DOMAIN from .hub import DeconzHub +if TYPE_CHECKING: + from . import DeconzConfigEntry + def serial_from_unique_id(unique_id: str | None) -> str | None: """Get a device serial number from a unique ID, if possible.""" @@ -18,8 +23,9 @@ def serial_from_unique_id(unique_id: str | None) -> str | None: @callback def get_master_hub(hass: HomeAssistant) -> DeconzHub: """Return the gateway which is marked as master.""" + entry: DeconzConfigEntry hub: DeconzHub - for hub in hass.data[DOMAIN].values(): - if hub.master: + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + if (hub := entry.runtime_data).master: return hub raise ValueError From f1dfae6937c7d4f499dd8f731d2ef0baba389e3c Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 27 Jan 2025 10:52:48 +0100 Subject: [PATCH 1007/2987] Ask for permission to disable Reolink privacy mode during config flow (#136511) --- .../components/reolink/config_flow.py | 27 +++++++++ homeassistant/components/reolink/strings.json | 4 ++ tests/components/reolink/test_config_flow.py | 56 ++++++++++++++++++- 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 48be2fc8ca7..e15a43e360b 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import Mapping import logging from typing import Any @@ -11,6 +12,7 @@ from reolink_aio.exceptions import ( ApiError, CredentialsInvalidError, LoginFirmwareError, + LoginPrivacyModeError, ReolinkError, ) import voluptuous as vol @@ -49,6 +51,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_PROTOCOL = "rtsp" DEFAULT_OPTIONS = {CONF_PROTOCOL: DEFAULT_PROTOCOL} +API_STARTUP_TIME = 5 class ReolinkOptionsFlowHandler(OptionsFlow): @@ -101,6 +104,8 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): self._host: str | None = None self._username: str = "admin" self._password: str | None = None + self._user_input: dict[str, Any] | None = None + self._disable_privacy: bool = False @staticmethod @callback @@ -198,6 +203,21 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): self._host = discovery_info.ip return await self.async_step_user() + async def async_step_privacy( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask permission to disable privacy mode.""" + if user_input is not None: + self._disable_privacy = True + return await self.async_step_user(self._user_input) + + assert self._user_input is not None + placeholders = {"host": self._user_input[CONF_HOST]} + return self.async_show_form( + step_id="privacy", + description_placeholders=placeholders, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -219,6 +239,10 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): host = ReolinkHost(self.hass, user_input, DEFAULT_OPTIONS) try: + if self._disable_privacy: + await host.api.baichuan.set_privacy_mode(enable=False) + # give the camera some time to startup the HTTP API server + await asyncio.sleep(API_STARTUP_TIME) await host.async_init() except UserNotAdmin: errors[CONF_USERNAME] = "not_admin" @@ -227,6 +251,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): except PasswordIncompatible: errors[CONF_PASSWORD] = "password_incompatible" placeholders["special_chars"] = ALLOWED_SPECIAL_CHARS + except LoginPrivacyModeError: + self._user_input = user_input + return await self.async_step_privacy() except CredentialsInvalidError: errors[CONF_PASSWORD] = "invalid_auth" except LoginFirmwareError: diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 1cadc16f818..b72e7bbd00d 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -18,6 +18,10 @@ "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.", "password": "Password to login to the Reolink device itself. Not the Reolink cloud account." } + }, + "privacy": { + "title": "Permission to disable Reolink privacy mode", + "description": "Privacy mode is enabled on Reolink device {host}. By pressing SUBMIT, the privacy mode will be disabled to retrieve the necessary information from the Reolink device. You can abort the setup by pressing X and repeat the setup at a time in which privacy mode can be disabled. After this configuration, you are free to enable the privacy mode again using the privacy mode switch entity. During normal startup the privacy mode will not be disabled. Note however that all entities will be marked unavailable as long as the privacy mode is active." } }, "error": { diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 5950fc49966..4d474588f38 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -2,7 +2,7 @@ import json from typing import Any -from unittest.mock import ANY, AsyncMock, MagicMock, call +from unittest.mock import ANY, AsyncMock, MagicMock, call, patch from aiohttp import ClientSession from freezegun.api import FrozenDateTimeFactory @@ -11,6 +11,7 @@ from reolink_aio.exceptions import ( ApiError, CredentialsInvalidError, LoginFirmwareError, + LoginPrivacyModeError, ReolinkError, ) @@ -88,6 +89,59 @@ async def test_config_flow_manual_success( assert result["result"].unique_id == TEST_MAC +async def test_config_flow_privacy_success( + hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock +) -> None: + """Successful flow when privacy mode is turned on.""" + reolink_connect.baichuan.privacy_mode.return_value = True + reolink_connect.get_host_data.side_effect = LoginPrivacyModeError("Test error") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "privacy" + assert result["errors"] is None + + assert reolink_connect.baichuan.set_privacy_mode.call_count == 0 + reolink_connect.get_host_data.reset_mock(side_effect=True) + + with patch("homeassistant.components.reolink.config_flow.API_STARTUP_TIME", new=0): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert reolink_connect.baichuan.set_privacy_mode.call_count == 1 + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_NVR_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + CONF_USE_HTTPS: TEST_USE_HTTPS, + } + assert result["options"] == { + CONF_PROTOCOL: DEFAULT_PROTOCOL, + } + assert result["result"].unique_id == TEST_MAC + + reolink_connect.baichuan.privacy_mode.return_value = False + + async def test_config_flow_errors( hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock ) -> None: From 6015c936b03a4f583510b12d3956cd320e0f7465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 27 Jan 2025 11:35:33 +0100 Subject: [PATCH 1008/2987] Add a Matter temperature sensor based on `Thermostat` device `LocalTemperature` attribute (#133888) --- homeassistant/components/matter/climate.py | 1 + homeassistant/components/matter/sensor.py | 15 ++ .../matter/snapshots/test_sensor.ambr | 153 ++++++++++++++++++ tests/components/matter/test_sensor.py | 12 ++ 4 files changed, 181 insertions(+) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index be6f024695d..8f6cd92d31f 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -445,5 +445,6 @@ DISCOVERY_SCHEMAS = [ clusters.OnOff.Attributes.OnOff, ), device_type=(device_types.Thermostat, device_types.RoomAirConditioner), + allow_multi=True, # also used for sensor entity ), ] diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index d8fe56278df..77b51d2dfbb 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, cast from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue +from matter_server.client.models import device_types from matter_server.common.custom_clusters import ( EveCluster, NeoCluster, @@ -677,4 +678,18 @@ DISCOVERY_SCHEMAS = [ clusters.OperationalState.Attributes.OperationalStateList, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ThermostatLocalTemperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + measurement_to_ha=lambda x: x / 100, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.Thermostat.Attributes.LocalTemperature,), + device_type=(device_types.Thermostat,), + allow_multi=True, # also used for climate entity + ), ] diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 205cba68d7c..5e22b9a1476 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -622,6 +622,57 @@ 'state': '20.0', }) # --- +# name: test_sensors[air_purifier][sensor.air_purifier_temperature_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_temperature_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': 'Temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-5-ThermostatLocalTemperature-513-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[air_purifier][sensor.air_purifier_temperature_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Air Purifier Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.air_purifier_temperature_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- # name: test_sensors[air_purifier][sensor.air_purifier_vocs-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1745,6 +1796,57 @@ 'state': '100', }) # --- +# name: test_sensors[eve_thermo][sensor.eve_thermo_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_thermo_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': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-ThermostatLocalTemperature-513-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[eve_thermo][sensor.eve_thermo_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Eve Thermo Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eve_thermo_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.0', + }) +# --- # name: test_sensors[eve_thermo][sensor.eve_thermo_valve_position-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3201,3 +3303,54 @@ 'state': '21.0', }) # --- +# name: test_sensors[thermostat][sensor.longan_link_hvac_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.longan_link_hvac_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': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ThermostatLocalTemperature-513-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[thermostat][sensor.longan_link_hvac_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Longan link HVAC Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.longan_link_hvac_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.3', + }) +# --- diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 630809a957d..bd3e146264a 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -219,6 +219,18 @@ async def test_eve_thermo_sensor( assert state assert state.state == "0" + # LocalTemperature + state = hass.states.get("sensor.eve_thermo_temperature") + assert state + assert state.state == "21.0" + + set_node_attribute(matter_node, 1, 513, 0, 1800) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.eve_thermo_temperature") + assert state + assert state.state == "18.0" + @pytest.mark.parametrize("node_fixture", ["pressure_sensor"]) async def test_pressure_sensor( From 111906f54ec1e3d09b52bdc514298e5fd5752b56 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jan 2025 12:41:25 +0100 Subject: [PATCH 1009/2987] Add missing exclude_integrations in lovelace compatibility code (#136618) Add missing exclude_integrations in lovelace --- homeassistant/components/lovelace/__init__.py | 2 ++ tests/components/lovelace/test_init.py | 29 ++++++++----------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 51d2ed3eab7..4d8472da9a2 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -109,6 +109,7 @@ class LovelaceData: report_usage( f"accessed lovelace_data['{name}'] instead of lovelace_data.{name}", breaks_in_ha_version="2026.2", + exclude_integrations={DOMAIN}, ) return getattr(self, name) @@ -121,6 +122,7 @@ class LovelaceData: report_usage( f"accessed lovelace_data.get('{name}') instead of lovelace_data.{name}", breaks_in_ha_version="2026.2", + exclude_integrations={DOMAIN}, ) if hasattr(self, name): return getattr(self, name) diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index 6f11c22466e..f35f7369f93 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant.core import HomeAssistant +from homeassistant.helpers import frame from homeassistant.setup import async_setup_component from tests.typing import WebSocketGenerator @@ -40,6 +41,8 @@ async def test_create_dashboards_when_onboarded( assert response["result"] == [] +@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) +@pytest.mark.usefixtures("mock_integration_frame") async def test_hass_data_compatibility( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -50,32 +53,24 @@ async def test_hass_data_compatibility( https://github.com/hacs/integration/blob/4a820e8b1b066bc54a1c9c61102038af6c030603 /custom_components/hacs/repositories/plugin.py#L173 """ - expected = ( - "Detected that integration 'lovelace' accessed lovelace_data.get('resources')" - " instead of lovelace_data.resources at" + expected_prefix = ( + "Detected that custom integration 'my_integration' accessed lovelace_data" ) assert await async_setup_component(hass, "lovelace", {}) assert (lovelace_data := hass.data.get("lovelace")) is not None - assert expected not in caplog.text # Direct access to resources is fine assert lovelace_data.resources is not None - assert ( - "Detected that integration 'lovelace' accessed lovelace_data" not in caplog.text - ) + assert expected_prefix not in caplog.text # Dict compatibility logs warning - assert lovelace_data["resources"] is not None - assert ( - "Detected that integration 'lovelace' accessed lovelace_data['resources']" - in caplog.text - ) + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + assert lovelace_data["resources"] is not None + assert f"{expected_prefix}['resources']" in caplog.text # Dict get compatibility logs warning - assert lovelace_data.get("resources") is not None - assert ( - "Detected that integration 'lovelace' accessed lovelace_data.get('resources')" - in caplog.text - ) + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + assert lovelace_data.get("resources") is not None + assert f"{expected_prefix}.get('resources')" in caplog.text From 4e29ac8e1b0f19597e938b0ca07c2400bfec99ff Mon Sep 17 00:00:00 2001 From: David Rapan Date: Mon, 27 Jan 2025 12:44:59 +0100 Subject: [PATCH 1010/2987] Starlink's energy consumption & usage cumulation fix (#135889) * refactor: history_stats result indexing * fix: Energy consumption & Usage cumulation * fix: typo * fix: mypy error: Call to untyped function * refactor: Use generic tuple instead of typing's Tuple * fix: tuple * fix: just syntax test * fix: AttributeError: 'NoneType' object has no attribute 'usage' * refactor: Return type * refactor: Merge into single method * refactor: Complex unpack test --- homeassistant/components/starlink/coordinator.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py index 89d03a4fadc..6fcfd8e0bfe 100644 --- a/homeassistant/components/starlink/coordinator.py +++ b/homeassistant/components/starlink/coordinator.py @@ -52,6 +52,7 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): def __init__(self, hass: HomeAssistant, name: str, url: str) -> None: """Initialize an UpdateCoordinator for a group of sensors.""" self.channel_context = ChannelContext(target=url) + self.history_stats_start = None self.timezone = ZoneInfo(hass.config.time_zone) super().__init__( hass, @@ -67,7 +68,18 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): location = location_data(context) sleep = get_sleep_config(context) status, obstruction, alert = status_data(context) - usage, consumption = history_stats(parse_samples=-1, context=context)[-2:] + index, _, _, _, _, usage, consumption, *_ = history_stats( + parse_samples=-1, start=self.history_stats_start, context=context + ) + self.history_stats_start = index["end_counter"] + if self.data: + if index["samples"] > 0: + usage["download_usage"] += self.data.usage["download_usage"] + usage["upload_usage"] += self.data.usage["upload_usage"] + consumption["total_energy"] += self.data.consumption["total_energy"] + else: + usage = self.data.usage + consumption = self.data.consumption return StarlinkData( location, sleep, status, obstruction, alert, usage, consumption ) From 6c9ff41b0b843ec6ed65fe0859b790fdc3e8f8c0 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Mon, 27 Jan 2025 23:06:01 +1100 Subject: [PATCH 1011/2987] Add product IDs for new LIFX Ceiling lights (#136619) --- homeassistant/components/lifx/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index 667afe1125d..58c3550b812 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -61,7 +61,7 @@ INFRARED_BRIGHTNESS_VALUES_MAP = { } DATA_LIFX_MANAGER = "lifx_manager" -LIFX_CEILING_PRODUCT_IDS = {176, 177} +LIFX_CEILING_PRODUCT_IDS = {176, 177, 201, 202} _LOGGER = logging.getLogger(__package__) From 55278ebfc8a9f0667c0eea42971755026ac38ec3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jan 2025 13:31:47 +0100 Subject: [PATCH 1012/2987] Use runtime_data in ecobee (#136632) --- homeassistant/components/ecobee/__init__.py | 21 +++++++++---------- .../components/ecobee/binary_sensor.py | 6 +++--- homeassistant/components/ecobee/climate.py | 7 +++---- homeassistant/components/ecobee/humidifier.py | 6 +++--- homeassistant/components/ecobee/notify.py | 8 +++---- homeassistant/components/ecobee/number.py | 8 +++---- homeassistant/components/ecobee/sensor.py | 6 +++--- homeassistant/components/ecobee/switch.py | 9 ++++---- homeassistant/components/ecobee/weather.py | 6 +++--- 9 files changed, 35 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py index 54af6c0f801..ae5ee96a6a4 100644 --- a/homeassistant/components/ecobee/__init__.py +++ b/homeassistant/components/ecobee/__init__.py @@ -27,6 +27,8 @@ CONFIG_SCHEMA = vol.Schema( {DOMAIN: vol.Schema({vol.Optional(CONF_API_KEY): cv.string})}, extra=vol.ALLOW_EXTRA ) +type EcobeeConfigEntry = ConfigEntry[EcobeeData] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Ecobee uses config flow for configuration. @@ -52,23 +54,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bool: """Set up ecobee via a config entry.""" api_key = entry.data[CONF_API_KEY] refresh_token = entry.data[CONF_REFRESH_TOKEN] - data = EcobeeData(hass, entry, api_key=api_key, refresh_token=refresh_token) + runtime_data = EcobeeData(hass, entry, api_key=api_key, refresh_token=refresh_token) - if not await data.refresh(): + if not await runtime_data.refresh(): return False - await data.update() + await runtime_data.update() - if data.ecobee.thermostats is None: + if runtime_data.ecobee.thermostats is None: _LOGGER.error("No ecobee devices found to set up") return False - hass.data[DOMAIN] = data + entry.runtime_data = runtime_data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -117,9 +119,6 @@ class EcobeeData: return False -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bool: """Unload the config entry and platforms.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data.pop(DOMAIN) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ecobee/binary_sensor.py b/homeassistant/components/ecobee/binary_sensor.py index 2a021442a63..9c9f2192f43 100644 --- a/homeassistant/components/ecobee/binary_sensor.py +++ b/homeassistant/components/ecobee/binary_sensor.py @@ -6,21 +6,21 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import EcobeeConfigEntry from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcobeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up ecobee binary (occupancy) sensors.""" - data = hass.data[DOMAIN] + data = config_entry.runtime_data dev = [] for index in range(len(data.ecobee.thermostats)): for sensor in data.ecobee.get_remote_sensors(index): diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index bfb2635481c..4e32990a661 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -21,7 +21,6 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -39,7 +38,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter -from . import EcobeeData +from . import EcobeeConfigEntry, EcobeeData from .const import ( _LOGGER, ATTR_ACTIVE_SENSORS, @@ -201,12 +200,12 @@ SUPPORT_FLAGS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcobeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ecobee thermostat.""" - data = hass.data[DOMAIN] + data = config_entry.runtime_data entities = [] for index in range(len(data.ecobee.thermostats)): diff --git a/homeassistant/components/ecobee/humidifier.py b/homeassistant/components/ecobee/humidifier.py index d9616383ab6..982cbdd07f2 100644 --- a/homeassistant/components/ecobee/humidifier.py +++ b/homeassistant/components/ecobee/humidifier.py @@ -12,11 +12,11 @@ from homeassistant.components.humidifier import ( HumidifierEntity, HumidifierEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import EcobeeConfigEntry from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER SCAN_INTERVAL = timedelta(minutes=3) @@ -27,11 +27,11 @@ MODE_OFF = "off" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcobeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ecobee thermostat humidifier entity.""" - data = hass.data[DOMAIN] + data = config_entry.runtime_data entities = [] for index in range(len(data.ecobee.thermostats)): thermostat = data.ecobee.get_thermostat(index) diff --git a/homeassistant/components/ecobee/notify.py b/homeassistant/components/ecobee/notify.py index 70860003b2a..7c70d7ae4ac 100644 --- a/homeassistant/components/ecobee/notify.py +++ b/homeassistant/components/ecobee/notify.py @@ -3,22 +3,20 @@ from __future__ import annotations from homeassistant.components.notify import NotifyEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EcobeeData -from .const import DOMAIN +from . import EcobeeConfigEntry, EcobeeData from .entity import EcobeeBaseEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcobeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ecobee thermostat.""" - data: EcobeeData = hass.data[DOMAIN] + data = config_entry.runtime_data async_add_entities( EcobeeNotifyEntity(data, index) for index in range(len(data.ecobee.thermostats)) ) diff --git a/homeassistant/components/ecobee/number.py b/homeassistant/components/ecobee/number.py index ed3744bf11e..f047ea8f896 100644 --- a/homeassistant/components/ecobee/number.py +++ b/homeassistant/components/ecobee/number.py @@ -12,13 +12,11 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EcobeeData -from .const import DOMAIN +from . import EcobeeConfigEntry, EcobeeData from .entity import EcobeeBaseEntity _LOGGER = logging.getLogger(__name__) @@ -54,11 +52,11 @@ VENTILATOR_NUMBERS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcobeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ecobee thermostat number entity.""" - data: EcobeeData = hass.data[DOMAIN] + data = config_entry.runtime_data assert data is not None diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index fe0442fb885..1b50fc21edf 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, @@ -23,6 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import EcobeeConfigEntry from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER @@ -73,11 +73,11 @@ SENSOR_TYPES: tuple[EcobeeSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcobeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up ecobee sensors.""" - data = hass.data[DOMAIN] + data = config_entry.runtime_data entities = [ EcobeeSensor(data, sensor["name"], index, description) for index in range(len(data.ecobee.thermostats)) diff --git a/homeassistant/components/ecobee/switch.py b/homeassistant/components/ecobee/switch.py index 89ee433c072..c92082b7b58 100644 --- a/homeassistant/components/ecobee/switch.py +++ b/homeassistant/components/ecobee/switch.py @@ -8,14 +8,13 @@ from typing import Any from homeassistant.components.climate import HVACMode from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from . import EcobeeData +from . import EcobeeConfigEntry, EcobeeData from .climate import HASS_TO_ECOBEE_HVAC -from .const import DOMAIN, ECOBEE_AUX_HEAT_ONLY +from .const import ECOBEE_AUX_HEAT_ONLY from .entity import EcobeeBaseEntity _LOGGER = logging.getLogger(__name__) @@ -25,11 +24,11 @@ DATE_FORMAT = "%Y-%m-%d %H:%M:%S" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcobeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ecobee thermostat switch entity.""" - data: EcobeeData = hass.data[DOMAIN] + data = config_entry.runtime_data entities: list[SwitchEntity] = [ EcobeeVentilator20MinSwitch( diff --git a/homeassistant/components/ecobee/weather.py b/homeassistant/components/ecobee/weather.py index b6378504c65..39b2d30ddd8 100644 --- a/homeassistant/components/ecobee/weather.py +++ b/homeassistant/components/ecobee/weather.py @@ -17,7 +17,6 @@ from homeassistant.components.weather import ( WeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfLength, UnitOfPressure, @@ -29,6 +28,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util +from . import EcobeeConfigEntry from .const import ( DOMAIN, ECOBEE_MODEL_TO_NAME, @@ -39,11 +39,11 @@ from .const import ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcobeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ecobee weather platform.""" - data = hass.data[DOMAIN] + data = config_entry.runtime_data dev = [] for index in range(len(data.ecobee.thermostats)): thermostat = data.ecobee.get_thermostat(index) From f87d952816a66e2fe153daca343877a918520166 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 14:09:04 +0100 Subject: [PATCH 1013/2987] Bump codecov/codecov-action from 5.3.0 to 5.3.1 (#136614) --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6527a09e15f..dad662a9202 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1273,7 +1273,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v5.3.0 + uses: codecov/codecov-action@v5.3.1 with: fail_ci_if_error: true flags: full-suite @@ -1411,7 +1411,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v5.3.0 + uses: codecov/codecov-action@v5.3.1 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} From ba070b34c8f844e75e4d0cc941ff18173812c6cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 14:27:41 +0100 Subject: [PATCH 1014/2987] Bump docker/build-push-action from 6.12.0 to 6.13.0 (#136612) --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 5b1cf48df68..39dc08444d3 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -509,7 +509,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0 + uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -522,7 +522,7 @@ jobs: - name: Push Docker image if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' id: push - uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0 + uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile From 2878ba601b3c73a7f76f722802c9352d475efff2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 14:28:04 +0100 Subject: [PATCH 1015/2987] Bump github/codeql-action from 3.28.4 to 3.28.5 (#136613) --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ee7fad4bb4e..9dbd39b4bc5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.4 + uses: github/codeql-action/init@v3.28.5 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.4 + uses: github/codeql-action/analyze@v3.28.5 with: category: "/language:python" From 7dc2b92452970da84b66c38261642cc72bc9aeb8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jan 2025 15:05:20 +0100 Subject: [PATCH 1016/2987] Use typed coordinator and runtime_data in eafm (#136629) * Move coordinator and use runtime_data in eafm * Add type hints --- homeassistant/components/eafm/__init__.py | 52 ++---------------- homeassistant/components/eafm/coordinator.py | 57 ++++++++++++++++++++ homeassistant/components/eafm/sensor.py | 13 ++--- tests/components/eafm/conftest.py | 2 +- 4 files changed, 68 insertions(+), 56 deletions(-) create mode 100644 homeassistant/components/eafm/coordinator.py diff --git a/homeassistant/components/eafm/__init__.py b/homeassistant/components/eafm/__init__.py index dc618a983f3..e2af2bae9f5 100644 --- a/homeassistant/components/eafm/__init__.py +++ b/homeassistant/components/eafm/__init__.py @@ -1,64 +1,22 @@ """UK Environment Agency Flood Monitoring Integration.""" -import asyncio -from datetime import timedelta -import logging -from typing import Any - -from aioeafm import get_station - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .coordinator import EafmConfigEntry, EafmCoordinator PLATFORMS = [Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - -def get_measures(station_data): - """Force measure key to always be a list.""" - if "measures" not in station_data: - return [] - if isinstance(station_data["measures"], dict): - return [station_data["measures"]] - return station_data["measures"] - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EafmConfigEntry) -> bool: """Set up flood monitoring sensors for this config entry.""" - station_key = entry.data["station"] - session = async_get_clientsession(hass=hass) - - async def _async_update_data() -> dict[str, dict[str, Any]]: - # DataUpdateCoordinator will handle aiohttp ClientErrors and timeouts - async with asyncio.timeout(30): - data = await get_station(session, station_key) - - measures = get_measures(data) - # Turn data.measures into a dict rather than a list so easier for entities to - # find themselves. - data["measures"] = {measure["@id"]: measure for measure in measures} - return data - - coordinator = DataUpdateCoordinator[dict[str, dict[str, Any]]]( - hass, - _LOGGER, - config_entry=entry, - name="sensor", - update_method=_async_update_data, - update_interval=timedelta(seconds=15 * 60), - ) + coordinator = EafmCoordinator(hass, entry=entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = 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: EafmConfigEntry) -> bool: """Unload flood monitoring sensors.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/eafm/coordinator.py b/homeassistant/components/eafm/coordinator.py new file mode 100644 index 00000000000..375368210a5 --- /dev/null +++ b/homeassistant/components/eafm/coordinator.py @@ -0,0 +1,57 @@ +"""UK Environment Agency Flood Monitoring Integration.""" + +import asyncio +from datetime import timedelta +import logging +from typing import Any + +from aioeafm import get_station + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + +type EafmConfigEntry = ConfigEntry[EafmCoordinator] + + +def _get_measures(station_data: dict[str, Any]) -> list[dict[str, Any]]: + """Force measure key to always be a list.""" + if "measures" not in station_data: + return [] + if isinstance(station_data["measures"], dict): + return [station_data["measures"]] + return station_data["measures"] + + +class EafmCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Class to manage fetching UK Flood Monitoring data.""" + + def __init__(self, hass: HomeAssistant, entry: EafmConfigEntry) -> None: + """Initialize.""" + self._station_key = entry.data["station"] + self._session = async_get_clientsession(hass=hass) + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name="sensor", + update_interval=timedelta(seconds=15 * 60), + ) + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + """Fetch the latest data from the source.""" + # DataUpdateCoordinator will handle aiohttp ClientErrors and timeouts + async with asyncio.timeout(30): + data = await get_station(self._session, self._station_key) + + measures = _get_measures(data) + # Turn data.measures into a dict rather than a list so easier for entities to + # find themselves. + data["measures"] = {measure["@id"]: measure for measure in measures} + return data diff --git a/homeassistant/components/eafm/sensor.py b/homeassistant/components/eafm/sensor.py index 297f4d6d2c8..d9b18cbc663 100644 --- a/homeassistant/components/eafm/sensor.py +++ b/homeassistant/components/eafm/sensor.py @@ -3,17 +3,14 @@ from typing import Any from homeassistant.components.sensor import SensorEntity, SensorStateClass -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import EafmConfigEntry, EafmCoordinator UNIT_MAPPING = { "http://qudt.org/1.1/vocab/unit#Meter": UnitOfLength.METERS, @@ -22,11 +19,11 @@ UNIT_MAPPING = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EafmConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up UK Flood Monitoring Sensors.""" - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data created_entities: set[str] = set() @callback @@ -70,7 +67,7 @@ class Measurement(CoordinatorEntity, SensorEntity): _attr_has_entity_name = True _attr_name = None - def __init__(self, coordinator, key): + def __init__(self, coordinator: EafmCoordinator, key: str) -> None: """Initialise the gauge with a data instance and station.""" super().__init__(coordinator) self.key = key diff --git a/tests/components/eafm/conftest.py b/tests/components/eafm/conftest.py index 3b060563a30..5dbdc98ad29 100644 --- a/tests/components/eafm/conftest.py +++ b/tests/components/eafm/conftest.py @@ -15,5 +15,5 @@ def mock_get_stations(): @pytest.fixture def mock_get_station(): """Mock aioeafm.get_station.""" - with patch("homeassistant.components.eafm.get_station") as patched: + with patch("homeassistant.components.eafm.coordinator.get_station") as patched: yield patched From e1607344f041b5529bf891a3e97933c633b149ef Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jan 2025 15:05:42 +0100 Subject: [PATCH 1017/2987] Cleanup unnecessary type hint in assist_satellite (#136626) --- homeassistant/components/assist_satellite/websocket_api.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py index c81648c6ee3..6cd7af2bbdb 100644 --- a/homeassistant/components/assist_satellite/websocket_api.py +++ b/homeassistant/components/assist_satellite/websocket_api.py @@ -10,7 +10,6 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_component import EntityComponent from homeassistant.util import uuid as uuid_util from .connection_test import CONNECTION_TEST_URL_BASE @@ -20,7 +19,6 @@ from .const import ( DOMAIN, AssistSatelliteEntityFeature, ) -from .entity import AssistSatelliteEntity CONNECTION_TEST_TIMEOUT = 30 @@ -167,7 +165,7 @@ async def websocket_test_connection( Send an announcement to the device with a special media id. """ - component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN] + component = hass.data[DATA_COMPONENT] satellite = component.get_entity(msg["entity_id"]) if satellite is None: connection.send_error( From 037a0f25a4a3f44e68d30628be79099c34bcfd33 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jan 2025 15:06:03 +0100 Subject: [PATCH 1018/2987] Cleanup hass.data[DOMAIN] in application_credentials (#136625) --- homeassistant/components/application_credentials/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 58146818624..0ee936aeef2 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -143,8 +143,6 @@ class ApplicationCredentialsStorageCollection(collection.DictStorageCollection): async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Application Credentials.""" - hass.data[DOMAIN] = {} - id_manager = collection.IDManager() storage_collection = ApplicationCredentialsStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), From 84561b744632e4ca2449bc07632663af52c7684f Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 27 Jan 2025 14:56:04 +0000 Subject: [PATCH 1019/2987] Use typed ConfigEntry in ring coordinator (#136457) * Use typed ConfigEntry in ring coordinator * Make config_entry a positional argument for coordinator --- homeassistant/components/ring/__init__.py | 34 +++++++------------- homeassistant/components/ring/coordinator.py | 30 +++++++++++++---- 2 files changed, 36 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index edc084fb57b..8e36f3e85e7 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -2,39 +2,29 @@ from __future__ import annotations -from dataclasses import dataclass import logging from typing import Any, cast import uuid -from ring_doorbell import Auth, Ring, RingDevices +from ring_doorbell import Auth, Ring from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import APPLICATION_NAME, CONF_DEVICE_ID, CONF_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_LISTEN_CREDENTIALS, DOMAIN, PLATFORMS -from .coordinator import RingDataCoordinator, RingListenCoordinator +from .coordinator import ( + RingConfigEntry, + RingData, + RingDataCoordinator, + RingListenCoordinator, +) _LOGGER = logging.getLogger(__name__) -@dataclass -class RingData: - """Class to support type hinting of ring data collection.""" - - api: Ring - devices: RingDevices - devices_coordinator: RingDataCoordinator - listen_coordinator: RingListenCoordinator - - -type RingConfigEntry = ConfigEntry[RingData] - - def get_auth_user_agent() -> str: """Return user-agent for Auth instantiation. @@ -71,10 +61,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool ) ring = Ring(auth) - devices_coordinator = RingDataCoordinator(hass, ring) + devices_coordinator = RingDataCoordinator(hass, entry, ring) listen_credentials = entry.data.get(CONF_LISTEN_CREDENTIALS) listen_coordinator = RingListenCoordinator( - hass, ring, listen_credentials, listen_credentials_updater + hass, entry, ring, listen_credentials, listen_credentials_updater ) await devices_coordinator.async_config_entry_first_refresh() @@ -91,19 +81,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool: """Unload Ring entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, entry: RingConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove a config entry from a device.""" return True -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool: """Migrate old config entry.""" entry_version = entry.version entry_minor_version = entry.minor_version diff --git a/homeassistant/components/ring/coordinator.py b/homeassistant/components/ring/coordinator.py index b143fd3dda0..f35a6e10b9f 100644 --- a/homeassistant/components/ring/coordinator.py +++ b/homeassistant/components/ring/coordinator.py @@ -1,9 +1,12 @@ """Data coordinators for the ring integration.""" +from __future__ import annotations + from asyncio import TaskGroup from collections.abc import Callable, Coroutine +from dataclasses import dataclass import logging -from typing import TYPE_CHECKING, Any +from typing import Any from ring_doorbell import ( AuthenticationError, @@ -15,7 +18,7 @@ from ring_doorbell import ( ) from ring_doorbell.listen import RingEventListener -from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import ( @@ -29,6 +32,19 @@ from .const import SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) +@dataclass +class RingData: + """Class to support type hinting of ring data collection.""" + + api: Ring + devices: RingDevices + devices_coordinator: RingDataCoordinator + listen_coordinator: RingListenCoordinator + + +type RingConfigEntry = ConfigEntry[RingData] + + async def _call_api[*_Ts, _R]( hass: HomeAssistant, target: Callable[[*_Ts], Coroutine[Any, Any, _R]], @@ -52,9 +68,12 @@ async def _call_api[*_Ts, _R]( class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): """Base class for device coordinators.""" + config_entry: RingConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: RingConfigEntry, ring_api: Ring, ) -> None: """Initialize my coordinator.""" @@ -63,6 +82,7 @@ class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): name="devices", logger=_LOGGER, update_interval=SCAN_INTERVAL, + config_entry=config_entry, ) self.ring_api: Ring = ring_api self.first_call: bool = True @@ -107,11 +127,12 @@ class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): class RingListenCoordinator(BaseDataUpdateCoordinatorProtocol): """Global notifications coordinator.""" - config_entry: config_entries.ConfigEntry + config_entry: RingConfigEntry def __init__( self, hass: HomeAssistant, + config_entry: RingConfigEntry, ring_api: Ring, listen_credentials: dict[str, Any] | None, listen_credentials_updater: Callable[[dict[str, Any]], None], @@ -126,9 +147,6 @@ class RingListenCoordinator(BaseDataUpdateCoordinatorProtocol): self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} self._listen_callback_id: int | None = None - config_entry = config_entries.current_entry.get() - if TYPE_CHECKING: - assert config_entry self.config_entry = config_entry self.start_timeout = 10 self.config_entry.async_on_unload(self.async_shutdown) From 679b7f403283ec7c374bbfa1a58d5117d5a19721 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Mon, 27 Jan 2025 16:40:39 +0100 Subject: [PATCH 1020/2987] Fix test logic flaw in enphase_envoy test_select (#136570) * Fix test logic flaw in enphase_envoy test_select * Replace test loops by test parameters * Implement review feedback to Improve use of parametrize parameters --- tests/components/enphase_envoy/test_select.py | 179 ++++++++---------- 1 file changed, 74 insertions(+), 105 deletions(-) diff --git a/tests/components/enphase_envoy/test_select.py b/tests/components/enphase_envoy/test_select.py index 9b3a63d1e23..e13492c7f54 100644 --- a/tests/components/enphase_envoy/test_select.py +++ b/tests/components/enphase_envoy/test_select.py @@ -7,15 +7,12 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.enphase_envoy.const import Platform from homeassistant.components.enphase_envoy.select import ( - ACTION_OPTIONS, - MODE_OPTIONS, RELAY_ACTION_MAP, RELAY_MODE_MAP, REVERSE_RELAY_ACTION_MAP, REVERSE_RELAY_MODE_MAP, REVERSE_STORAGE_MODE_MAP, STORAGE_MODE_MAP, - STORAGE_MODE_OPTIONS, ) from homeassistant.components.select import ATTR_OPTION, DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION @@ -28,9 +25,9 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.parametrize( - ("mock_envoy"), + "mock_envoy", ["envoy_metered_batt_relay", "envoy_eu_batt"], - indirect=["mock_envoy"], + indirect=True, ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_select( @@ -47,14 +44,14 @@ async def test_select( @pytest.mark.parametrize( - ("mock_envoy"), + "mock_envoy", [ "envoy", "envoy_1p_metered", "envoy_nobatt_metered_3p", "envoy_tot_cons_metered", ], - indirect=["mock_envoy"], + indirect=True, ) async def test_no_select( hass: HomeAssistant, @@ -68,13 +65,31 @@ async def test_no_select( assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) +@pytest.mark.parametrize("mock_envoy", ["envoy_metered_batt_relay"], indirect=True) @pytest.mark.parametrize( - ("mock_envoy"), ["envoy_metered_batt_relay"], indirect=["mock_envoy"] + ("relay", "target", "expected_state", "call_parameter"), + [ + ("NC1", "generator_action", "shed", "generator_action"), + ("NC1", "microgrid_action", "shed", "micro_grid_action"), + ("NC1", "grid_action", "shed", "grid_action"), + ("NC2", "generator_action", "shed", "generator_action"), + ("NC2", "microgrid_action", "shed", "micro_grid_action"), + ("NC2", "grid_action", "apply", "grid_action"), + ("NC3", "generator_action", "apply", "generator_action"), + ("NC3", "microgrid_action", "apply", "micro_grid_action"), + ("NC3", "grid_action", "shed", "grid_action"), + ], ) +@pytest.mark.parametrize("action", ["powered", "not_powered", "schedule", "none"]) async def test_select_relay_actions( hass: HomeAssistant, mock_envoy: AsyncMock, config_entry: MockConfigEntry, + target: str, + expected_state: str, + call_parameter: str, + relay: str, + action: str, ) -> None: """Test select platform entities dry contact relay actions.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): @@ -82,54 +97,37 @@ async def test_select_relay_actions( entity_base = f"{Platform.SELECT}." - for contact_id, dry_contact in mock_envoy.data.dry_contact_settings.items(): - name = dry_contact.load_name.lower().replace(" ", "_") - for target in ( - ("generator_action", dry_contact.generator_action, "generator_action"), - ("microgrid_action", dry_contact.micro_grid_action, "micro_grid_action"), - ("grid_action", dry_contact.grid_action, "grid_action"), - ): - test_entity = f"{entity_base}{name}_{target[0]}" - assert (entity_state := hass.states.get(test_entity)) - assert RELAY_ACTION_MAP[target[1]] == (current_state := entity_state.state) - # set all relay modes except current mode - for action in [action for action in ACTION_OPTIONS if not current_state]: - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: test_entity, - ATTR_OPTION: action, - }, - blocking=True, - ) - mock_envoy.update_dry_contact.assert_called_once_with( - {"id": contact_id, target[2]: REVERSE_RELAY_ACTION_MAP[action]} - ) - mock_envoy.update_dry_contact.reset_mock() - # and finally back to original - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: test_entity, - ATTR_OPTION: current_state, - }, - blocking=True, - ) - mock_envoy.update_dry_contact.assert_called_once_with( - {"id": contact_id, target[2]: REVERSE_RELAY_ACTION_MAP[current_state]} - ) - mock_envoy.update_dry_contact.reset_mock() + 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}" + + assert (entity_state := hass.states.get(test_entity)) + assert entity_state.state == RELAY_ACTION_MAP[expected_state] + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: test_entity, + ATTR_OPTION: action, + }, + blocking=True, + ) + mock_envoy.update_dry_contact.assert_called_once_with( + {"id": relay, call_parameter: REVERSE_RELAY_ACTION_MAP[action]} + ) -@pytest.mark.parametrize( - ("mock_envoy"), ["envoy_metered_batt_relay"], indirect=["mock_envoy"] -) +@pytest.mark.parametrize("mock_envoy", ["envoy_metered_batt_relay"], indirect=True) +@pytest.mark.parametrize("relay_mode", ["battery", "standard"]) +@pytest.mark.parametrize("relay", ["NC1", "NC2", "NC3"]) async def test_select_relay_modes( hass: HomeAssistant, mock_envoy: AsyncMock, config_entry: MockConfigEntry, + relay_mode: str, + relay: str, ) -> None: """Test select platform dry contact relay mode changes.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): @@ -137,40 +135,26 @@ async def test_select_relay_modes( entity_base = f"{Platform.SELECT}." - for contact_id, dry_contact in mock_envoy.data.dry_contact_settings.items(): - name = dry_contact.load_name.lower().replace(" ", "_") - test_entity = f"{entity_base}{name}_mode" - assert (entity_state := hass.states.get(test_entity)) - assert RELAY_MODE_MAP[dry_contact.mode] == (current_state := entity_state.state) - for mode in [mode for mode in MODE_OPTIONS if not current_state]: - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: test_entity, - ATTR_OPTION: mode, - }, - blocking=True, - ) - mock_envoy.update_dry_contact.assert_called_once_with( - {"id": contact_id, "mode": REVERSE_RELAY_MODE_MAP[mode]} - ) - mock_envoy.update_dry_contact.reset_mock() + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) - # and finally current mode again - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: test_entity, - ATTR_OPTION: current_state, - }, - blocking=True, - ) - mock_envoy.update_dry_contact.assert_called_once_with( - {"id": contact_id, "mode": REVERSE_RELAY_MODE_MAP[current_state]} - ) - mock_envoy.update_dry_contact.reset_mock() + test_entity = f"{entity_base}{name}_mode" + + assert (entity_state := hass.states.get(test_entity)) + assert entity_state.state == RELAY_MODE_MAP[dry_contact.mode] + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: test_entity, + ATTR_OPTION: relay_mode, + }, + blocking=True, + ) + mock_envoy.update_dry_contact.assert_called_once_with( + {"id": relay, "mode": REVERSE_RELAY_MODE_MAP[relay_mode]} + ) @pytest.mark.parametrize( @@ -181,11 +165,13 @@ async def test_select_relay_modes( ], indirect=["mock_envoy"], ) +@pytest.mark.parametrize(("mode"), ["backup", "self_consumption", "savings"]) async def test_select_storage_modes( hass: HomeAssistant, mock_envoy: AsyncMock, config_entry: MockConfigEntry, use_serial: str, + mode: str, ) -> None: """Test select platform entities storage mode changes.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): @@ -194,38 +180,21 @@ async def test_select_storage_modes( test_entity = f"{Platform.SELECT}.{use_serial}_storage_mode" assert (entity_state := hass.states.get(test_entity)) - assert STORAGE_MODE_MAP[mock_envoy.data.tariff.storage_settings.mode] == ( - current_state := entity_state.state + assert ( + entity_state.state + == STORAGE_MODE_MAP[mock_envoy.data.tariff.storage_settings.mode] ) - for mode in [mode for mode in STORAGE_MODE_OPTIONS if not current_state]: - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: test_entity, - ATTR_OPTION: mode, - }, - blocking=True, - ) - mock_envoy.set_storage_mode.assert_called_once_with( - REVERSE_STORAGE_MODE_MAP[mode] - ) - mock_envoy.set_storage_mode.reset_mock() - - # and finally with original mode await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: test_entity, - ATTR_OPTION: current_state, + ATTR_OPTION: mode, }, blocking=True, ) - mock_envoy.set_storage_mode.assert_called_once_with( - REVERSE_STORAGE_MODE_MAP[current_state] - ) + mock_envoy.set_storage_mode.assert_called_once_with(REVERSE_STORAGE_MODE_MAP[mode]) @pytest.mark.parametrize( From b9c3548b5a5a0a517adfde1646f0492bcb46852c Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 27 Jan 2025 16:42:22 +0100 Subject: [PATCH 1021/2987] Change discovery schema for Matter Identify button to ignore type of None (#136621) --- homeassistant/components/matter/button.py | 4 +- .../matter/snapshots/test_button.ambr | 1274 +---------------- 2 files changed, 28 insertions(+), 1250 deletions(-) diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py index 153124a4f7e..2c5e641e640 100644 --- a/homeassistant/components/matter/button.py +++ b/homeassistant/components/matter/button.py @@ -67,8 +67,8 @@ DISCOVERY_SCHEMAS = [ command=lambda: clusters.Identify.Commands.Identify(identifyTime=15), ), entity_class=MatterCommandButton, - required_attributes=(clusters.Identify.Attributes.AcceptedCommandList,), - value_contains=clusters.Identify.Commands.Identify.command_id, + required_attributes=(clusters.Identify.Attributes.IdentifyType,), + value_is_not=clusters.Identify.Enums.IdentifyTypeEnum.kNone, allow_multi=True, ), MatterDiscoverySchema( diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index bcba0da808e..7973f1a5147 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -1,239 +1,4 @@ # serializer version: 1 -# name: test_buttons[air_purifier][button.air_purifier_identify_1-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.air_purifier_identify_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': 'Identify (1)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Air Purifier Identify (1)', - }), - 'context': , - 'entity_id': 'button.air_purifier_identify_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_2-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.air_purifier_identify_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': 'Identify (2)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Air Purifier Identify (2)', - }), - 'context': , - 'entity_id': 'button.air_purifier_identify_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_3-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.air_purifier_identify_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify (3)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-3-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_3-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Air Purifier Identify (3)', - }), - 'context': , - 'entity_id': 'button.air_purifier_identify_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_4-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.air_purifier_identify_4', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify (4)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-4-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_4-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Air Purifier Identify (4)', - }), - 'context': , - 'entity_id': 'button.air_purifier_identify_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_5-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.air_purifier_identify_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify (5)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-5-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_5-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Air Purifier Identify (5)', - }), - 'context': , - 'entity_id': 'button.air_purifier_identify_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[air_purifier][button.air_purifier_reset_filter_condition-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -326,53 +91,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[air_quality_sensor][button.lightfi_aq1_air_quality_sensor_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.lightfi_aq1_air_quality_sensor_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[air_quality_sensor][button.lightfi_aq1_air_quality_sensor_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'lightfi-aq1-air-quality-sensor Identify', - }), - 'context': , - 'entity_id': 'button.lightfi_aq1_air_quality_sensor_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[color_temperature_light][button.mock_color_temperature_light_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -402,7 +120,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -449,7 +167,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -467,100 +185,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[door_lock][button.mock_door_lock_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_door_lock_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[door_lock][button.mock_door_lock_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Door Lock Identify', - }), - 'context': , - 'entity_id': 'button.mock_door_lock_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[door_lock_with_unbolt][button.mock_door_lock_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_door_lock_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[door_lock_with_unbolt][button.mock_door_lock_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Door Lock Identify', - }), - 'context': , - 'entity_id': 'button.mock_door_lock_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[eve_contact_sensor][button.eve_door_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -590,7 +214,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -637,7 +261,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -684,7 +308,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -731,7 +355,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -778,7 +402,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -825,7 +449,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -872,7 +496,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -919,7 +543,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -937,335 +561,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[flow_sensor][button.mock_flow_sensor_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_flow_sensor_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[flow_sensor][button.mock_flow_sensor_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Flow Sensor Identify', - }), - 'context': , - 'entity_id': 'button.mock_flow_sensor_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[generic_switch][button.mock_generic_switch_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_generic_switch_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[generic_switch][button.mock_generic_switch_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Generic Switch Identify', - }), - 'context': , - 'entity_id': 'button.mock_generic_switch_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[generic_switch_multi][button.mock_generic_switch_fancy_button-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_generic_switch_fancy_button', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fancy Button', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-2-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[generic_switch_multi][button.mock_generic_switch_fancy_button-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Generic Switch Fancy Button', - }), - 'context': , - 'entity_id': 'button.mock_generic_switch_fancy_button', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[generic_switch_multi][button.mock_generic_switch_identify_1-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_generic_switch_identify_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': 'Identify (1)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[generic_switch_multi][button.mock_generic_switch_identify_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Generic Switch Identify (1)', - }), - 'context': , - 'entity_id': 'button.mock_generic_switch_identify_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[humidity_sensor][button.mock_humidity_sensor_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_humidity_sensor_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[humidity_sensor][button.mock_humidity_sensor_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Humidity Sensor Identify', - }), - 'context': , - 'entity_id': 'button.mock_humidity_sensor_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[light_sensor][button.mock_light_sensor_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_light_sensor_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[light_sensor][button.mock_light_sensor_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Light Sensor Identify', - }), - 'context': , - 'entity_id': 'button.mock_light_sensor_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[microwave_oven][button.microwave_oven_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.microwave_oven_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000009D-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[microwave_oven][button.microwave_oven_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Microwave Oven Identify', - }), - 'context': , - 'entity_id': 'button.microwave_oven_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[microwave_oven][button.microwave_oven_pause-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1479,7 +774,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-5-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-5-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1526,7 +821,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-4-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-4-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1573,7 +868,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1620,7 +915,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-2-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-2-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1667,7 +962,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-6-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-6-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1714,7 +1009,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-3-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-3-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1761,7 +1056,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1808,7 +1103,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1826,147 +1121,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[onoff_light][button.mock_onoff_light_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_onoff_light_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[onoff_light][button.mock_onoff_light_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock OnOff Light Identify', - }), - 'context': , - 'entity_id': 'button.mock_onoff_light_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[onoff_light_alt_name][button.mock_onoff_light_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_onoff_light_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[onoff_light_alt_name][button.mock_onoff_light_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock OnOff Light Identify', - }), - 'context': , - 'entity_id': 'button.mock_onoff_light_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[onoff_light_no_name][button.mock_light_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_light_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[onoff_light_no_name][button.mock_light_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Light Identify', - }), - 'context': , - 'entity_id': 'button.mock_light_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[onoff_light_with_levelcontrol_present][button.d215s_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1996,7 +1150,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2014,147 +1168,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[pressure_sensor][button.mock_pressure_sensor_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_pressure_sensor_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[pressure_sensor][button.mock_pressure_sensor_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Pressure Sensor Identify', - }), - 'context': , - 'entity_id': 'button.mock_pressure_sensor_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[room_airconditioner][button.room_airconditioner_identify_1-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.room_airconditioner_identify_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': 'Identify (1)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[room_airconditioner][button.room_airconditioner_identify_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Room AirConditioner Identify (1)', - }), - 'context': , - 'entity_id': 'button.room_airconditioner_identify_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[room_airconditioner][button.room_airconditioner_identify_2-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.room_airconditioner_identify_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': 'Identify (2)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-2-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[room_airconditioner][button.room_airconditioner_identify_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Room AirConditioner Identify (2)', - }), - 'context': , - 'entity_id': 'button.room_airconditioner_identify_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[silabs_dishwasher][button.dishwasher_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2184,7 +1197,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2369,7 +1382,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2600,7 +1613,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2647,7 +1660,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2694,7 +1707,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-0-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2741,7 +1754,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2759,147 +1772,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[valve][button.valve_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.valve_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[valve][button.valve_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Valve Identify', - }), - 'context': , - 'entity_id': 'button.valve_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[window_covering_full][button.mock_full_window_covering_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_full_window_covering_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[window_covering_full][button.mock_full_window_covering_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Full Window Covering Identify', - }), - 'context': , - 'entity_id': 'button.mock_full_window_covering_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[window_covering_lift][button.mock_lift_window_covering_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_lift_window_covering_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[window_covering_lift][button.mock_lift_window_covering_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Lift Window Covering Identify', - }), - 'context': , - 'entity_id': 'button.mock_lift_window_covering_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[window_covering_pa_lift][button.longan_link_wncv_da01_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2929,7 +1801,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2947,97 +1819,3 @@ 'state': 'unknown', }) # --- -# name: test_buttons[window_covering_pa_tilt][button.mock_pa_tilt_window_covering_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_pa_tilt_window_covering_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[window_covering_pa_tilt][button.mock_pa_tilt_window_covering_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock PA Tilt Window Covering Identify', - }), - 'context': , - 'entity_id': 'button.mock_pa_tilt_window_covering_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[window_covering_tilt][button.mock_tilt_window_covering_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_tilt_window_covering_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[window_covering_tilt][button.mock_tilt_window_covering_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Tilt Window Covering Identify', - }), - 'context': , - 'entity_id': 'button.mock_tilt_window_covering_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- From fc75d939eb424bd593148d8132a343ddf78ab7e8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 27 Jan 2025 16:43:02 +0100 Subject: [PATCH 1022/2987] Fix spelling of "Hub" and sentence-casing of "options" (#136573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix spelling of "Hub" and sentence-casing of "options" * Change "the change channel command" to "a …" Co-authored-by: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> --------- Co-authored-by: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> --- homeassistant/components/harmony/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/harmony/strings.json b/homeassistant/components/harmony/strings.json index e13573a9ea3..577eb308d78 100644 --- a/homeassistant/components/harmony/strings.json +++ b/homeassistant/components/harmony/strings.json @@ -28,7 +28,7 @@ "options": { "step": { "init": { - "description": "Adjust Harmony Hub Options", + "description": "Adjust Harmony Hub options", "data": { "activity": "The default activity to execute when none is specified.", "delay_secs": "The delay between sending commands." @@ -53,7 +53,7 @@ }, "change_channel": { "name": "Change channel", - "description": "Sends change channel command to the Harmony HUB.", + "description": "Sends a change channel command to the Harmony Hub.", "fields": { "channel": { "name": "Channel", From a2830e7ebb574671b3552bffe18adc333f17591b Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 27 Jan 2025 15:46:32 +0000 Subject: [PATCH 1023/2987] Add config flow data descriptions to ring integration (#136464) * Add config flow data descriptions to ring integration * Change Ring cloud to Ring account * Revert config_flow change --- homeassistant/components/ring/strings.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 8170ec8e161..1f146bcf358 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -6,12 +6,19 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Your Ring account username.", + "password": "Your Ring account password." } }, "2fa": { "title": "Two-factor authentication", "data": { "2fa": "Two-factor code" + }, + "data_description": { + "2fa": "Account verification code via the method selected in your ring account settings." } }, "reauth_confirm": { @@ -19,6 +26,9 @@ "description": "The Ring integration needs to re-authenticate your account {username}", "data": { "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::ring::config::step::user::data_description::password%]" } }, "reconfigure": { @@ -26,6 +36,9 @@ "description": "Will create a new Authorized Device for {username} at ring.com", "data": { "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::ring::config::step::user::data_description::password%]" } } }, From 7c87bb2ffb69cbf8a40492f124068528d50e63b1 Mon Sep 17 00:00:00 2001 From: Splint77 Date: Mon, 27 Jan 2025 16:53:26 +0100 Subject: [PATCH 1024/2987] Twinkly RGBW color fixed (#136593) --- homeassistant/components/twinkly/light.py | 2 +- tests/components/twinkly/test_light.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index de55aa5f217..31e95d70fc0 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -99,9 +99,9 @@ class TwinklyLight(TwinklyEntity, LightEntity): ): await self.client.interview() if LightEntityFeature.EFFECT & self.supported_features: - # Static color only supports rgb await self.client.set_static_colour( ( + kwargs[ATTR_RGBW_COLOR][3], kwargs[ATTR_RGBW_COLOR][0], kwargs[ATTR_RGBW_COLOR][1], kwargs[ATTR_RGBW_COLOR][2], diff --git a/tests/components/twinkly/test_light.py b/tests/components/twinkly/test_light.py index acf30764bab..f8289cb95e3 100644 --- a/tests/components/twinkly/test_light.py +++ b/tests/components/twinkly/test_light.py @@ -140,7 +140,7 @@ async def test_turn_on_with_color_rgbw( ) mock_twinkly_client.interview.assert_called_once_with() - mock_twinkly_client.set_static_colour.assert_called_once_with((128, 64, 32)) + mock_twinkly_client.set_static_colour.assert_called_once_with((0, 128, 64, 32)) mock_twinkly_client.set_mode.assert_called_once_with("color") assert mock_twinkly_client.default_mode == "color" From 5faf2fd66ccc2e6de90012e92f3ad790e3683134 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 27 Jan 2025 17:15:05 +0100 Subject: [PATCH 1025/2987] Replace "bosch_shc" with friendly name of integration (#136410) --- homeassistant/components/bosch_shc/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 88eb817bbd9..7aa3b0ace32 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -24,7 +24,7 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The bosch_shc integration needs to re-authenticate your account", + "description": "The Bosch SHC integration needs to re-authenticate your account", "data": { "host": "[%key:common::config_flow::data::host%]" } @@ -34,7 +34,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "pairing_failed": "Pairing failed; please check the Bosch Smart Home Controller is in pairing mode (LED flashing) as well as your password is correct.", - "session_error": "Session error: API return Non-OK result.", + "session_error": "Session error: API returned Non-OK result.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { From d2138fe45bd514f5eed5acc5f92894ff4013e5a4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Jan 2025 17:28:45 +0100 Subject: [PATCH 1026/2987] Bump securetar to 2025.1.4 (#136639) --- homeassistant/components/backup/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/backup/manifest.json b/homeassistant/components/backup/manifest.json index ffaed260c88..6cbfb834c7f 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["cronsim==2.6", "securetar==2025.1.3"] + "requirements": ["cronsim==2.6", "securetar==2025.1.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cb29214390b..2959e8bf322 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -60,7 +60,7 @@ PyTurboJPEG==1.7.5 pyudev==0.24.1 PyYAML==6.0.2 requests==2.32.3 -securetar==2025.1.3 +securetar==2025.1.4 SQLAlchemy==2.0.36 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' diff --git a/pyproject.toml b/pyproject.toml index 56f2533840a..0e67a78954b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ dependencies = [ "python-slugify==8.0.4", "PyYAML==6.0.2", "requests==2.32.3", - "securetar==2025.1.3", + "securetar==2025.1.4", "SQLAlchemy==2.0.36", "standard-aifc==3.13.0;python_version>='3.13'", "standard-telnetlib==3.13.0;python_version>='3.13'", diff --git a/requirements.txt b/requirements.txt index f1eb8dac825..2ffb530393e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,7 +38,7 @@ psutil-home-assistant==0.0.1 python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 -securetar==2025.1.3 +securetar==2025.1.4 SQLAlchemy==2.0.36 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' diff --git a/requirements_all.txt b/requirements_all.txt index 96f53acf13d..1a86e1b0560 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2665,7 +2665,7 @@ screenlogicpy==0.10.0 scsgate==0.1.0 # homeassistant.components.backup -securetar==2025.1.3 +securetar==2025.1.4 # homeassistant.components.sendgrid sendgrid==6.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ecdd37fe58..9dcae1ea28a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2147,7 +2147,7 @@ sanix==1.0.6 screenlogicpy==0.10.0 # homeassistant.components.backup -securetar==2025.1.3 +securetar==2025.1.4 # homeassistant.components.emulated_kasa # homeassistant.components.sense From 6bbb857d0fb71287d22132e6b5f9dda2822e7068 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 27 Jan 2025 17:59:21 +0100 Subject: [PATCH 1027/2987] Fix spelling of "Pi-hole" and "API" in user-facing strings (#136645) --- homeassistant/components/pi_hole/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/pi_hole/strings.json b/homeassistant/components/pi_hole/strings.json index 9e1d5948a09..504be7a62dd 100644 --- a/homeassistant/components/pi_hole/strings.json +++ b/homeassistant/components/pi_hole/strings.json @@ -17,8 +17,8 @@ } }, "reauth_confirm": { - "title": "Reauthenticate PI-Hole", - "description": "Please enter a new api key for PI-Hole at {host}/{location}", + "title": "Reauthenticate Pi-hole", + "description": "Please enter a new API key for Pi-hole at {host}/{location}", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } From ea92523af4c349a5a51501b7063bbf5f9340fb49 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 27 Jan 2025 18:06:03 +0100 Subject: [PATCH 1028/2987] Bump aioshelly to 12.3.2 (#136486) * Bump aioshelly * Add timeout parameter for call_rpc * Increase timeout for BLU TRV * Log timeout * Update test * Use const in test * Coverage --- homeassistant/components/shelly/climate.py | 2 + homeassistant/components/shelly/const.py | 3 ++ homeassistant/components/shelly/entity.py | 9 ++++- homeassistant/components/shelly/manifest.json | 2 +- homeassistant/components/shelly/number.py | 13 ++++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/shelly/test_climate.py | 3 +- tests/components/shelly/test_number.py | 38 ++++++++++++++++++- 9 files changed, 66 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index f8e157a6a5d..f1491acdd81 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -36,6 +36,7 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( BLU_TRV_TEMPERATURE_SETTINGS, + BLU_TRV_TIMEOUT, DOMAIN, LOGGER, NOT_CALIBRATED_ISSUE_ID, @@ -604,4 +605,5 @@ class RpcBluTrvClimate(ShellyRpcEntity, ClimateEntity): "method": "Trv.SetTarget", "params": {"id": 0, "target_C": target_temp}, }, + timeout=BLU_TRV_TIMEOUT, ) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index f81ba5ca7f7..e78a6f1a59d 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -265,3 +265,6 @@ VIRTUAL_NUMBER_MODE_MAP = { API_WS_URL = "/api/shelly/ws" COMPONENT_ID_PATTERN = re.compile(r"[a-z\d]+:\d+") + +# value confirmed by Shelly team +BLU_TRV_TIMEOUT = 60 diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 8c9044aeaff..001727c74b3 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -390,15 +390,20 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): """Handle device update.""" self.async_write_ha_state() - async def call_rpc(self, method: str, params: Any) -> Any: + async def call_rpc( + self, method: str, params: Any, timeout: float | None = None + ) -> Any: """Call RPC method.""" LOGGER.debug( - "Call RPC for entity %s, method: %s, params: %s", + "Call RPC for entity %s, method: %s, params: %s, timeout: %s", self.name, method, params, + timeout, ) try: + if timeout: + return await self.coordinator.device.call_rpc(method, params, timeout) return await self.coordinator.device.call_rpc(method, params) except DeviceConnectionError as err: self.coordinator.last_update_success = False diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index cf5c59da5e3..e0d8c03ffc4 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==12.3.1"], + "requirements": ["aioshelly==12.3.2"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index fb61c885423..7140c79fbb6 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -25,7 +25,7 @@ from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceIn from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry -from .const import CONF_SLEEP_PERIOD, LOGGER, VIRTUAL_NUMBER_MODE_MAP +from .const import BLU_TRV_TIMEOUT, CONF_SLEEP_PERIOD, LOGGER, VIRTUAL_NUMBER_MODE_MAP from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -127,6 +127,17 @@ class RpcBluTrvNumber(RpcNumber): connections={(CONNECTION_BLUETOOTH, ble_addr)} ) + async def async_set_native_value(self, value: float) -> None: + """Change the value.""" + if TYPE_CHECKING: + assert isinstance(self._id, int) + + await self.call_rpc( + self.entity_description.method, + self.entity_description.method_params_fn(self._id, value), + timeout=BLU_TRV_TIMEOUT, + ) + NUMBERS: dict[tuple[str, str], BlockNumberDescription] = { ("device", "valvePos"): BlockNumberDescription( diff --git a/requirements_all.txt b/requirements_all.txt index 1a86e1b0560..11973ad3c2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -368,7 +368,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.3.1 +aioshelly==12.3.2 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9dcae1ea28a..7a3fd7857cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -350,7 +350,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.3.1 +aioshelly==12.3.2 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 352bdcb0a7d..5ad298c15a1 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -26,7 +26,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.shelly.const import BLU_TRV_TIMEOUT, DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( @@ -804,6 +804,7 @@ async def test_blu_trv_climate_set_temperature( "method": "Trv.SetTarget", "params": {"id": 0, "target_C": 28.0}, }, + BLU_TRV_TIMEOUT, ) assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 28 diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 2a64ab839ea..15ed098093b 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -18,7 +18,7 @@ from homeassistant.components.number import ( SERVICE_SET_VALUE, NumberMode, ) -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.shelly.const import BLU_TRV_TIMEOUT, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State @@ -415,3 +415,39 @@ async def test_blu_trv_number_entity( entry = entity_registry.async_get(entity_id) assert entry == snapshot(name=f"{entity_id}-entry") + + +async def test_blu_trv_set_value( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the set value action for BLU TRV number entity.""" + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + entity_id = f"{NUMBER_DOMAIN}.trv_name_external_temperature" + + assert hass.states.get(entity_id).state == "15.2" + + monkeypatch.setitem(mock_blu_trv.status["blutrv:200"], "current_C", 22.2) + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.trv_name_external_temperature", + ATTR_VALUE: 22.2, + }, + blocking=True, + ) + mock_blu_trv.mock_update() + mock_blu_trv.call_rpc.assert_called_once_with( + "BluTRV.Call", + { + "id": 200, + "method": "Trv.SetExternalTemperature", + "params": {"id": 0, "t_C": 22.2}, + }, + BLU_TRV_TIMEOUT, + ) + + assert hass.states.get(entity_id).state == "22.2" From 39845650841a561c8cab91c83be1ca5a05778e32 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 27 Jan 2025 11:42:00 -0600 Subject: [PATCH 1029/2987] Bump voip-utils to 0.3.0 (#136648) --- homeassistant/components/voip/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/voip/conftest.py | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index ed7f11f8fbc..e96039a6b45 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["voip-utils==0.2.2"] + "requirements": ["voip-utils==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 11973ad3c2f..80890e8b612 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2984,7 +2984,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.2.2 +voip-utils==0.3.0 # homeassistant.components.volkszaehler volkszaehler==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a3fd7857cb..a3bc80b736b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2400,7 +2400,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.2.2 +voip-utils==0.3.0 # homeassistant.components.volvooncall volvooncall==0.10.3 diff --git a/tests/components/voip/conftest.py b/tests/components/voip/conftest.py index 99707297230..d47db58d585 100644 --- a/tests/components/voip/conftest.py +++ b/tests/components/voip/conftest.py @@ -57,6 +57,7 @@ def call_info() -> CallInfo: """Fake call info.""" return CallInfo( caller_endpoint=get_sip_endpoint("192.168.1.210", 5060), + local_endpoint=get_sip_endpoint("192.168.1.10", 5060), caller_rtp_port=5004, server_ip="192.168.1.10", headers={ From 557b9d88b5cdef26ccff63a70affc8b4e5d205ec Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 27 Jan 2025 19:36:16 +0100 Subject: [PATCH 1030/2987] Catch and convert MatterError when sending device commands (#136635) --- homeassistant/components/matter/button.py | 6 +- homeassistant/components/matter/climate.py | 38 +++++-------- homeassistant/components/matter/cover.py | 8 --- homeassistant/components/matter/entity.py | 66 ++++++++++++++++++++-- homeassistant/components/matter/fan.py | 65 ++++++++------------- homeassistant/components/matter/light.py | 8 --- homeassistant/components/matter/lock.py | 25 +++----- homeassistant/components/matter/number.py | 9 +-- homeassistant/components/matter/select.py | 19 ++----- homeassistant/components/matter/switch.py | 21 ++----- homeassistant/components/matter/vacuum.py | 23 ++------ homeassistant/components/matter/valve.py | 11 ---- tests/components/matter/test_number.py | 24 ++++++++ tests/components/matter/test_switch.py | 23 ++++++++ 14 files changed, 170 insertions(+), 176 deletions(-) diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py index 2c5e641e640..634406d18eb 100644 --- a/homeassistant/components/matter/button.py +++ b/homeassistant/components/matter/button.py @@ -49,11 +49,7 @@ class MatterCommandButton(MatterEntity, ButtonEntity): """Handle the button press leveraging a Matter command.""" if TYPE_CHECKING: assert self.entity_description.command is not None - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=self.entity_description.command(), - ) + await self.send_device_command(self.entity_description.command()) # Discovery schema(s) to map Matter Attributes to HA entities diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 8f6cd92d31f..25419c34e42 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -212,57 +212,45 @@ class MatterClimate(MatterEntity, ClimateEntity): matter_attribute = ( clusters.Thermostat.Attributes.OccupiedHeatingSetpoint ) - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - matter_attribute, - ), + await self.write_attribute( value=int(target_temperature * TEMPERATURE_SCALING_FACTOR), + matter_attribute=matter_attribute, ) return if target_temperature_low is not None: # multi setpoint control - low setpoint (heat) if self.target_temperature_low != target_temperature_low: - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - clusters.Thermostat.Attributes.OccupiedHeatingSetpoint, - ), + await self.write_attribute( value=int(target_temperature_low * TEMPERATURE_SCALING_FACTOR), + matter_attribute=clusters.Thermostat.Attributes.OccupiedHeatingSetpoint, ) if target_temperature_high is not None: # multi setpoint control - high setpoint (cool) if self.target_temperature_high != target_temperature_high: - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - clusters.Thermostat.Attributes.OccupiedCoolingSetpoint, - ), + await self.write_attribute( value=int(target_temperature_high * TEMPERATURE_SCALING_FACTOR), + matter_attribute=clusters.Thermostat.Attributes.OccupiedCoolingSetpoint, ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - system_mode_path = create_attribute_path_from_attribute( - endpoint_id=self._endpoint.endpoint_id, - attribute=clusters.Thermostat.Attributes.SystemMode, - ) + system_mode_value = HVAC_SYSTEM_MODE_MAP.get(hvac_mode) if system_mode_value is None: raise ValueError(f"Unsupported hvac mode {hvac_mode} in Matter") - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=system_mode_path, + await self.write_attribute( value=system_mode_value, + matter_attribute=clusters.Thermostat.Attributes.SystemMode, ) # we need to optimistically update the attribute's value here # to prevent a race condition when adjusting the mode and temperature # in the same call + system_mode_path = create_attribute_path_from_attribute( + endpoint_id=self._endpoint.endpoint_id, + attribute=clusters.Thermostat.Attributes.SystemMode, + ) self._endpoint.set_attribute_value(system_mode_path, system_mode_value) self._update_from_device() diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py index ba9c3afbdee..5b109d52189 100644 --- a/homeassistant/components/matter/cover.py +++ b/homeassistant/components/matter/cover.py @@ -102,14 +102,6 @@ class MatterCover(MatterEntity, CoverEntity): clusters.WindowCovering.Commands.GoToTiltPercentage((100 - position) * 100) ) - async def send_device_command(self, command: Any) -> None: - """Send device command.""" - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=command, - ) - @callback def _update_from_device(self) -> None: """Update from device.""" diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 61c62d8b564..a6d0dbb08d8 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -2,18 +2,24 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass +import functools import logging -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Concatenate, cast from chip.clusters import Objects as clusters -from chip.clusters.Objects import ClusterAttributeDescriptor, NullValue -from matter_server.common.helpers.util import create_attribute_path +from chip.clusters.Objects import ClusterAttributeDescriptor, ClusterCommand, NullValue +from matter_server.common.errors import MatterError +from matter_server.common.helpers.util import ( + create_attribute_path, + create_attribute_path_from_attribute, +) from matter_server.common.models import EventType, ServerInfoMessage from propcache.api import cached_property from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription import homeassistant.helpers.entity_registry as er @@ -31,6 +37,23 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(__name__) +def catch_matter_error[_R, **P]( + func: Callable[Concatenate[MatterEntity, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[MatterEntity, P], Coroutine[Any, Any, _R]]: + """Catch Matter errors and convert to Home Assistant error.""" + + @functools.wraps(func) + async def wrapper(self: MatterEntity, *args: P.args, **kwargs: P.kwargs) -> _R: + """Catch Matter errors and convert to Home Assistant error.""" + try: + return await func(self, *args, **kwargs) + except MatterError as err: + error_msg = str(err) or err.__class__.__name__ + raise HomeAssistantError(error_msg) from err + + return wrapper + + @dataclass(frozen=True) class MatterEntityDescription(EntityDescription): """Describe the Matter entity.""" @@ -218,3 +241,38 @@ class MatterEntity(Entity): return create_attribute_path( self._endpoint.endpoint_id, attribute.cluster_id, attribute.attribute_id ) + + @catch_matter_error + async def send_device_command( + self, + command: ClusterCommand, + **kwargs: Any, + ) -> None: + """Send device command on the primary attribute's endpoint.""" + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=command, + **kwargs, + ) + + @catch_matter_error + async def write_attribute( + self, + value: Any, + matter_attribute: type[ClusterAttributeDescriptor] | None = None, + ) -> Any: + """Write an attribute(value) on the primary endpoint. + + If matter_attribute is not provided, the primary attribute of the entity is used. + """ + if matter_attribute is None: + matter_attribute = self._entity_info.primary_attribute + return await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + matter_attribute, + ), + value=value, + ) diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py index 593693dbbf9..8b8ebee619d 100644 --- a/homeassistant/components/matter/fan.py +++ b/homeassistant/components/matter/fan.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any from chip.clusters import Objects as clusters -from matter_server.common.helpers.util import create_attribute_path_from_attribute from homeassistant.components.fan import ( DIRECTION_FORWARD, @@ -97,24 +96,16 @@ class MatterFan(MatterEntity, FanEntity): # clear the wind setting if its currently set if self._attr_preset_mode in [PRESET_NATURAL_WIND, PRESET_SLEEP_WIND]: await self._set_wind_mode(None) - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - clusters.FanControl.Attributes.FanMode, - ), + await self.write_attribute( value=clusters.FanControl.Enums.FanModeEnum.kOff, + matter_attribute=clusters.FanControl.Attributes.FanMode, ) async def async_set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - clusters.FanControl.Attributes.PercentSetting, - ), + await self.write_attribute( value=percentage, + matter_attribute=clusters.FanControl.Attributes.PercentSetting, ) async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -128,41 +119,33 @@ class MatterFan(MatterEntity, FanEntity): if self._attr_preset_mode in [PRESET_NATURAL_WIND, PRESET_SLEEP_WIND]: await self._set_wind_mode(None) - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - clusters.FanControl.Attributes.FanMode, - ), + await self.write_attribute( value=FAN_MODE_MAP[preset_mode], + matter_attribute=clusters.FanControl.Attributes.FanMode, ) async def async_oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - clusters.FanControl.Attributes.RockSetting, + await self.write_attribute( + value=( + self.get_matter_attribute_value( + clusters.FanControl.Attributes.RockSupport + ) + if oscillating + else 0 ), - value=self.get_matter_attribute_value( - clusters.FanControl.Attributes.RockSupport - ) - if oscillating - else 0, + matter_attribute=clusters.FanControl.Attributes.RockSetting, ) async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - clusters.FanControl.Attributes.AirflowDirection, + await self.write_attribute( + value=( + clusters.FanControl.Enums.AirflowDirectionEnum.kReverse + if direction == DIRECTION_REVERSE + else clusters.FanControl.Enums.AirflowDirectionEnum.kForward ), - value=clusters.FanControl.Enums.AirflowDirectionEnum.kReverse - if direction == DIRECTION_REVERSE - else clusters.FanControl.Enums.AirflowDirectionEnum.kForward, + matter_attribute=clusters.FanControl.Attributes.AirflowDirection, ) async def _set_wind_mode(self, wind_mode: str | None) -> None: @@ -173,13 +156,9 @@ class MatterFan(MatterEntity, FanEntity): wind_setting = WindBitmap.kSleepWind else: wind_setting = 0 - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - clusters.FanControl.Attributes.WindSetting, - ), + await self.write_attribute( value=wind_setting, + matter_attribute=clusters.FanControl.Attributes.WindSetting, ) @callback diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 5a2768d1d50..5c20554f065 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -282,14 +282,6 @@ class MatterLight(MatterEntity, LightEntity): return ha_color_mode - async def send_device_command(self, command: Any) -> None: - """Send device command.""" - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=command, - ) - async def async_turn_on(self, **kwargs: Any) -> None: """Turn light on.""" diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index d69d0fd3dab..8524b39d584 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -62,19 +62,6 @@ class MatterLock(MatterEntity, LockEntity): return None - async def send_device_command( - self, - command: clusters.ClusterCommand, - timed_request_timeout_ms: int = 1000, - ) -> None: - """Send a command to the device.""" - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=command, - timed_request_timeout_ms=timed_request_timeout_ms, - ) - async def async_lock(self, **kwargs: Any) -> None: """Lock the lock with pin if needed.""" if not self._attr_is_locked: @@ -89,7 +76,8 @@ class MatterLock(MatterEntity, LockEntity): code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None await self.send_device_command( - command=clusters.DoorLock.Commands.LockDoor(code_bytes) + command=clusters.DoorLock.Commands.LockDoor(code_bytes), + timed_request_timeout_ms=1000, ) async def async_unlock(self, **kwargs: Any) -> None: @@ -110,11 +98,13 @@ class MatterLock(MatterEntity, LockEntity): # the unlock command should unbolt only on the unlock command # and unlatch on the HA 'open' command. await self.send_device_command( - command=clusters.DoorLock.Commands.UnboltDoor(code_bytes) + command=clusters.DoorLock.Commands.UnboltDoor(code_bytes), + timed_request_timeout_ms=1000, ) else: await self.send_device_command( - command=clusters.DoorLock.Commands.UnlockDoor(code_bytes) + command=clusters.DoorLock.Commands.UnlockDoor(code_bytes), + timed_request_timeout_ms=1000, ) async def async_open(self, **kwargs: Any) -> None: @@ -130,7 +120,8 @@ class MatterLock(MatterEntity, LockEntity): code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None await self.send_device_command( - command=clusters.DoorLock.Commands.UnlockDoor(code_bytes) + command=clusters.DoorLock.Commands.UnlockDoor(code_bytes), + timed_request_timeout_ms=1000, ) @callback diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 22929c60b89..4518e83e9d0 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from chip.clusters import Objects as clusters from matter_server.common import custom_clusters -from matter_server.common.helpers.util import create_attribute_path_from_attribute from homeassistant.components.number import ( NumberDeviceClass, @@ -52,16 +51,10 @@ class MatterNumber(MatterEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - matter_attribute = self._entity_info.primary_attribute sendvalue = int(value) if value_convert := self.entity_description.ha_to_native_value: sendvalue = value_convert(value) - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - matter_attribute, - ), + await self.write_attribute( value=sendvalue, ) diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index 317c8515d4b..1018bed6af0 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, cast from chip.clusters import Objects as clusters from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand from chip.clusters.Types import Nullable -from matter_server.common.helpers.util import create_attribute_path_from_attribute from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -70,11 +69,7 @@ class MatterAttributeSelectEntity(MatterEntity, SelectEntity): value_convert = self.entity_description.ha_to_native_value if TYPE_CHECKING: assert value_convert is not None - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, self._entity_info.primary_attribute - ), + await self.write_attribute( value=value_convert(option), ) @@ -101,10 +96,8 @@ class MatterModeSelectEntity(MatterAttributeSelectEntity): for mode in cluster.supportedModes: if mode.label != option: continue - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=cluster.Commands.ChangeToMode(newMode=mode.mode), + await self.send_device_command( + cluster.Commands.ChangeToMode(newMode=mode.mode), ) break @@ -132,10 +125,8 @@ class MatterListSelectEntity(MatterEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" option_id = self._attr_options.index(option) - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=self.entity_description.command(option_id), + await self.send_device_command( + self.entity_description.command(option_id), ) @callback diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 2a1e6d59a06..890ca662295 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -7,7 +7,6 @@ from typing import Any from chip.clusters import Objects as clusters from matter_server.client.models import device_types -from matter_server.common.helpers.util import create_attribute_path_from_attribute from homeassistant.components.switch import ( SwitchDeviceClass, @@ -41,18 +40,14 @@ class MatterSwitch(MatterEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn switch on.""" - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=clusters.OnOff.Commands.On(), + await self.send_device_command( + clusters.OnOff.Commands.On(), ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn switch off.""" - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=clusters.OnOff.Commands.Off(), + await self.send_device_command( + clusters.OnOff.Commands.Off(), ) @callback @@ -77,15 +72,9 @@ class MatterNumericSwitch(MatterSwitch): async def _async_set_native_value(self, value: bool) -> None: """Update the current value.""" - matter_attribute = self._entity_info.primary_attribute if value_convert := self.entity_description.ha_to_native_value: send_value = value_convert(value) - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - matter_attribute, - ), + await self.write_attribute( value=send_value, ) diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index e98e1ad0bbd..511b32d3182 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -69,15 +69,15 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): async def async_stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" - await self._send_device_command(clusters.OperationalState.Commands.Stop()) + await self.send_device_command(clusters.OperationalState.Commands.Stop()) async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" - await self._send_device_command(clusters.RvcOperationalState.Commands.GoHome()) + await self.send_device_command(clusters.RvcOperationalState.Commands.GoHome()) async def async_locate(self, **kwargs: Any) -> None: """Locate the vacuum cleaner.""" - await self._send_device_command(clusters.Identify.Commands.Identify()) + await self.send_device_command(clusters.Identify.Commands.Identify()) async def async_start(self) -> None: """Start or resume the cleaning task.""" @@ -87,26 +87,15 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): clusters.RvcOperationalState.Commands.Resume.command_id in self._last_accepted_commands ): - await self._send_device_command( + await self.send_device_command( clusters.RvcOperationalState.Commands.Resume() ) else: - await self._send_device_command(clusters.OperationalState.Commands.Start()) + await self.send_device_command(clusters.OperationalState.Commands.Start()) async def async_pause(self) -> None: """Pause the cleaning task.""" - await self._send_device_command(clusters.OperationalState.Commands.Pause()) - - async def _send_device_command( - self, - command: clusters.ClusterCommand, - ) -> None: - """Send a command to the device.""" - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=command, - ) + await self.send_device_command(clusters.OperationalState.Commands.Pause()) @callback def _update_from_device(self) -> None: diff --git a/homeassistant/components/matter/valve.py b/homeassistant/components/matter/valve.py index ccb4e89da17..29946621853 100644 --- a/homeassistant/components/matter/valve.py +++ b/homeassistant/components/matter/valve.py @@ -42,17 +42,6 @@ class MatterValve(MatterEntity, ValveEntity): entity_description: ValveEntityDescription _platform_translation_key = "valve" - async def send_device_command( - self, - command: clusters.ClusterCommand, - ) -> None: - """Send a command to the device.""" - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=command, - ) - async def async_open_valve(self) -> None: """Open the valve.""" await self.send_device_command(ValveConfigurationAndControl.Commands.Open()) diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index 86e1fbbf419..2a4eea1c324 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -4,12 +4,14 @@ from unittest.mock import MagicMock, call from matter_server.client.models.node import MatterNode from matter_server.common import custom_clusters +from matter_server.common.errors import MatterError from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest from syrupy import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from .common import ( @@ -97,3 +99,25 @@ async def test_eve_weather_sensor_altitude( ), value=500, ) + + +@pytest.mark.parametrize("node_fixture", ["dimmable_light"]) +async def test_matter_exception_on_write_attribute( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test if a MatterError gets converted to HomeAssistantError by using a dimmable_light fixture.""" + state = hass.states.get("number.mock_dimmable_light_on_level") + assert state + matter_client.write_attribute.side_effect = MatterError("Boom") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": "number.mock_dimmable_light_on_level", + "value": 500, + }, + blocking=True, + ) diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index 11451c715c3..e82848fcc3a 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -4,12 +4,14 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode +from matter_server.common.errors import MatterError from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest from syrupy import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from .common import ( @@ -165,3 +167,24 @@ async def test_numeric_switch( ), value=0, ) + + +@pytest.mark.parametrize("node_fixture", ["on_off_plugin_unit"]) +async def test_matter_exception_on_command( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test if a MatterError gets converted to HomeAssistantError by using a switch fixture.""" + state = hass.states.get("switch.mock_onoffpluginunit") + assert state + matter_client.send_device_command.side_effect = MatterError("Boom") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "switch", + "turn_on", + { + "entity_id": "switch.mock_onoffpluginunit", + }, + blocking=True, + ) From 7497beefed331a37c32864342078ad4333759cdd Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 27 Jan 2025 13:06:21 -0600 Subject: [PATCH 1031/2987] Add single target constraint to async_match_targets (#136643) Add single target constraint --- homeassistant/helpers/intent.py | 53 +++++++++++++++++++++++++++++++-- tests/helpers/test_intent.py | 35 ++++++++++++++++++++-- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 649819a5f06..c93545ed414 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -215,6 +215,9 @@ class MatchFailedReason(Enum): DUPLICATE_NAME = auto() """Two or more entities matched the same name constraint and could not be disambiguated.""" + MULTIPLE_TARGETS = auto() + """Two or more entities matched when a single target is required.""" + def is_no_entities_reason(self) -> bool: """Return True if the match failed because no entities matched.""" return self not in ( @@ -255,6 +258,9 @@ class MatchTargetsConstraints: allow_duplicate_names: bool = False """True if entities with duplicate names are allowed in result.""" + single_target: bool = False + """True if result must contain a single target.""" + @property def has_constraints(self) -> bool: """Returns True if at least one constraint is set (ignores assistant).""" @@ -266,6 +272,7 @@ class MatchTargetsConstraints: or self.device_classes or self.features or self.states + or self.single_target ) @@ -291,7 +298,7 @@ class MatchTargetsResult: """Reason for failed match when is_match = False.""" states: list[State] = field(default_factory=list) - """List of matched entity states when is_match = True.""" + """List of matched entity states.""" no_match_name: str | None = None """Name of invalid area/floor or duplicate name when match fails for those reasons.""" @@ -357,7 +364,6 @@ class MatchTargetsCandidate: is_exposed: bool entity: entity_registry.RegistryEntry | None = None area: area_registry.AreaEntry | None = None - floor: floor_registry.FloorEntry | None = None device: device_registry.DeviceEntry | None = None matched_name: str | None = None @@ -549,6 +555,7 @@ def async_match_targets( # noqa: C901 or constraints.device_classes or constraints.area_name or constraints.floor_name + or constraints.single_target ): if constraints.assistant: # Check exposure @@ -719,6 +726,48 @@ def async_match_targets( # noqa: C901 candidates = final_candidates + if constraints.single_target and len(candidates) > 1: + # Find best match using preferences + if not (preferences.area_id or preferences.floor_id): + # No preferences + return MatchTargetsResult( + False, + MatchFailedReason.MULTIPLE_TARGETS, + states=[c.state for c in candidates], + ) + + if not areas_added: + ar = area_registry.async_get(hass) + dr = device_registry.async_get(hass) + _add_areas(ar, dr, candidates) + areas_added = True + + filtered_candidates: list[MatchTargetsCandidate] = candidates + if preferences.area_id: + # Filter by area + filtered_candidates = [ + c for c in candidates if c.area and (c.area.id == preferences.area_id) + ] + + if (len(filtered_candidates) > 1) and preferences.floor_id: + # Filter by floor + filtered_candidates = [ + c + for c in candidates + if c.area and (c.area.floor_id == preferences.floor_id) + ] + + if len(filtered_candidates) != 1: + # Filtering could not restrict to a single target + return MatchTargetsResult( + False, + MatchFailedReason.MULTIPLE_TARGETS, + states=[c.state for c in candidates], + ) + + # Filtering succeeded + candidates = filtered_candidates + return MatchTargetsResult( True, None, diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index ae8c2ed65d0..bf0df305c35 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -234,7 +234,7 @@ async def test_async_match_targets( # Floor 2 floor_2 = floor_registry.async_create("second floor", aliases={"upstairs"}) - area_bedroom_2 = area_registry.async_get_or_create("bedroom") + area_bedroom_2 = area_registry.async_get_or_create("second floor bedroom") area_bedroom_2 = area_registry.async_update( area_bedroom_2.id, floor_id=floor_2.floor_id ) @@ -269,7 +269,7 @@ async def test_async_match_targets( # Floor 3 floor_3 = floor_registry.async_create("third floor", aliases={"upstairs"}) - area_bedroom_3 = area_registry.async_get_or_create("bedroom") + area_bedroom_3 = area_registry.async_get_or_create("third floor bedroom") area_bedroom_3 = area_registry.async_update( area_bedroom_3.id, floor_id=floor_3.floor_id ) @@ -510,6 +510,37 @@ async def test_async_match_targets( bathroom_light_3.entity_id, } + # Check single target constraint + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(domains={"light"}, single_target=True), + states=states, + ) + assert not result.is_match + assert result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS + + # Only one light on the ground floor + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(domains={"light"}, single_target=True), + preferences=intent.MatchTargetsPreferences(floor_id=floor_1.floor_id), + states=states, + ) + assert result.is_match + assert len(result.states) == 1 + assert result.states[0].entity_id == bathroom_light_1.entity_id + + # Only one switch in bedroom + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(domains={"switch"}, single_target=True), + preferences=intent.MatchTargetsPreferences(area_id=area_bedroom_2.id), + states=states, + ) + assert result.is_match + assert len(result.states) == 1 + assert result.states[0].entity_id == bedroom_switch_2.entity_id + async def test_match_device_area( hass: HomeAssistant, From 85540cea3fb6584373863a4e73ac7b56fab85d12 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 27 Jan 2025 22:21:27 +0300 Subject: [PATCH 1032/2987] Add LLM ActionTool (#136591) Add ActionTool --- homeassistant/helpers/llm.py | 173 ++++++++++++++++++++--------------- tests/helpers/test_llm.py | 8 +- 2 files changed, 103 insertions(+), 78 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index ea376923f9d..cc397c5d428 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -49,9 +49,9 @@ from . import ( ) from .singleton import singleton -SCRIPT_PARAMETERS_CACHE: HassKey[dict[str, tuple[str | None, vol.Schema]]] = HassKey( - "llm_script_parameters_cache" -) +ACTION_PARAMETERS_CACHE: HassKey[ + dict[str, dict[str, tuple[str | None, vol.Schema]]] +] = HassKey("llm_action_parameters_cache") LLM_API_ASSIST = "assist" @@ -624,104 +624,105 @@ def _selector_serializer(schema: Any) -> Any: # noqa: C901 return {"type": "string"} -def _get_cached_script_parameters( - hass: HomeAssistant, entity_id: str +def _get_cached_action_parameters( + hass: HomeAssistant, domain: str, action: str ) -> tuple[str | None, vol.Schema]: - """Get script description and schema.""" - entity_registry = er.async_get(hass) - + """Get action description and schema.""" description = None parameters = vol.Schema({}) - entity_entry = entity_registry.async_get(entity_id) - if entity_entry and entity_entry.unique_id: - parameters_cache = hass.data.get(SCRIPT_PARAMETERS_CACHE) - if parameters_cache is None: - parameters_cache = hass.data[SCRIPT_PARAMETERS_CACHE] = {} + parameters_cache = hass.data.get(ACTION_PARAMETERS_CACHE) - @callback - def clear_cache(event: Event) -> None: - """Clear script parameter cache on script reload or delete.""" - if ( - event.data[ATTR_DOMAIN] == SCRIPT_DOMAIN - and event.data[ATTR_SERVICE] in parameters_cache - ): - parameters_cache.pop(event.data[ATTR_SERVICE]) + if parameters_cache is None: + parameters_cache = hass.data[ACTION_PARAMETERS_CACHE] = {} - cancel = hass.bus.async_listen(EVENT_SERVICE_REMOVED, clear_cache) + @callback + def clear_cache(event: Event) -> None: + """Clear action parameter cache on action removal.""" + if ( + event.data[ATTR_DOMAIN] in parameters_cache + and event.data[ATTR_SERVICE] + in parameters_cache[event.data[ATTR_DOMAIN]] + ): + parameters_cache[event.data[ATTR_DOMAIN]].pop(event.data[ATTR_SERVICE]) - @callback - def on_homeassistant_close(event: Event) -> None: - """Cleanup.""" - cancel() + cancel = hass.bus.async_listen(EVENT_SERVICE_REMOVED, clear_cache) - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_CLOSE, on_homeassistant_close - ) + @callback + def on_homeassistant_close(event: Event) -> None: + """Cleanup.""" + cancel() - if entity_entry.unique_id in parameters_cache: - return parameters_cache[entity_entry.unique_id] + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, on_homeassistant_close) - if service_desc := service.async_get_cached_service_description( - hass, SCRIPT_DOMAIN, entity_entry.unique_id - ): - description = service_desc.get("description") - schema: dict[vol.Marker, Any] = {} - fields = service_desc.get("fields", {}) + if domain in parameters_cache and action in parameters_cache[domain]: + return parameters_cache[domain][action] - for field, config in fields.items(): - field_description = config.get("description") - if not field_description: - field_description = config.get("name") - key: vol.Marker - if config.get("required"): - key = vol.Required(field, description=field_description) - else: - key = vol.Optional(field, description=field_description) - if "selector" in config: - schema[key] = selector.selector(config["selector"]) - else: - schema[key] = cv.string + if action_desc := service.async_get_cached_service_description( + hass, domain, action + ): + description = action_desc.get("description") + schema: dict[vol.Marker, Any] = {} + fields = action_desc.get("fields", {}) - parameters = vol.Schema(schema) + for field, config in fields.items(): + field_description = config.get("description") + if not field_description: + field_description = config.get("name") + key: vol.Marker + if config.get("required"): + key = vol.Required(field, description=field_description) + else: + key = vol.Optional(field, description=field_description) + if "selector" in config: + schema[key] = selector.selector(config["selector"]) + else: + schema[key] = cv.string - aliases: list[str] = [] - if entity_entry.name: - aliases.append(entity_entry.name) - if entity_entry.aliases: - aliases.extend(entity_entry.aliases) - if aliases: - if description: - description = description + ". Aliases: " + str(list(aliases)) - else: - description = "Aliases: " + str(list(aliases)) + parameters = vol.Schema(schema) - parameters_cache[entity_entry.unique_id] = (description, parameters) + if domain == SCRIPT_DOMAIN: + entity_registry = er.async_get(hass) + if ( + entity_id := entity_registry.async_get_entity_id(domain, domain, action) + ) and (entity_entry := entity_registry.async_get(entity_id)): + aliases: list[str] = [] + if entity_entry.name: + aliases.append(entity_entry.name) + if entity_entry.aliases: + aliases.extend(entity_entry.aliases) + if aliases: + if description: + description = description + ". Aliases: " + str(list(aliases)) + else: + description = "Aliases: " + str(list(aliases)) + + parameters_cache.setdefault(domain, {})[action] = (description, parameters) return description, parameters -class ScriptTool(Tool): - """LLM Tool representing a Script.""" +class ActionTool(Tool): + """LLM Tool representing an action.""" def __init__( self, hass: HomeAssistant, - script_entity_id: str, + domain: str, + action: str, ) -> None: """Init the class.""" - self._object_id = self.name = split_entity_id(script_entity_id)[1] - if self.name[0].isdigit(): - self.name = "_" + self.name - - self.description, self.parameters = _get_cached_script_parameters( - hass, script_entity_id + self._domain = domain + self._action = action + self.name = f"{domain}.{action}" + self.description, self.parameters = _get_cached_action_parameters( + hass, domain, action ) async def async_call( self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext ) -> JsonObjectType: - """Run the script.""" + """Call the action.""" for field, validator in self.parameters.schema.items(): if field not in tool_input.tool_args: @@ -753,8 +754,8 @@ class ScriptTool(Tool): tool_input.tool_args[field] = floor result = await hass.services.async_call( - SCRIPT_DOMAIN, - self._object_id, + self._domain, + self._action, tool_input.tool_args, context=llm_context.context, blocking=True, @@ -764,6 +765,30 @@ class ScriptTool(Tool): return {"success": True, "result": result} +class ScriptTool(ActionTool): + """LLM Tool representing a Script.""" + + def __init__( + self, + hass: HomeAssistant, + script_entity_id: str, + ) -> None: + """Init the class.""" + script_name = split_entity_id(script_entity_id)[1] + + action = script_name + entity_registry = er.async_get(hass) + entity_entry = entity_registry.async_get(script_entity_id) + if entity_entry and entity_entry.unique_id: + action = entity_entry.unique_id + + super().__init__(hass, SCRIPT_DOMAIN, action) + + self.name = script_name + if self.name[0].isdigit(): + self.name = "_" + self.name + + class CalendarGetEventsTool(Tool): """LLM Tool allowing querying a calendar.""" diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 57e151ba8eb..e288026b67b 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -745,7 +745,7 @@ async def test_script_tool( area = area_registry.async_create("Living room") floor = floor_registry.async_create("2") - assert llm.SCRIPT_PARAMETERS_CACHE not in hass.data + assert llm.ACTION_PARAMETERS_CACHE not in hass.data api = await llm.async_get_api(hass, "assist", llm_context) @@ -769,7 +769,7 @@ async def test_script_tool( } assert tool.parameters.schema == schema - assert hass.data[llm.SCRIPT_PARAMETERS_CACHE] == { + assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == { "test_script": ( "This is a test script. Aliases: ['script name', 'script alias']", vol.Schema(schema), @@ -866,7 +866,7 @@ async def test_script_tool( ): await hass.services.async_call("script", "reload", blocking=True) - assert hass.data[llm.SCRIPT_PARAMETERS_CACHE] == {} + assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == {} api = await llm.async_get_api(hass, "assist", llm_context) @@ -882,7 +882,7 @@ async def test_script_tool( schema = {vol.Required("beer", description="Number of beers"): cv.string} assert tool.parameters.schema == schema - assert hass.data[llm.SCRIPT_PARAMETERS_CACHE] == { + assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == { "test_script": ( "This is a new test script. Aliases: ['script name', 'script alias']", vol.Schema(schema), From 58b4556a1dcb5bd88d259610b77764707d4aa4c6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 27 Jan 2025 11:38:52 -0800 Subject: [PATCH 1033/2987] Add the Model Context Protocol integration (#135058) * Add the Model Context Protocol integration * Improvements to mcp integration * Move the API prompt constant * Update config flow error handling * Update test descriptions * Update tests/components/mcp/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/mcp/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Address PR feedback * Update homeassistant/components/mcp/coordinator.py Co-authored-by: Paulus Schoutsen * Move tool parsing to the coordinator * Update session handling not to use a context manager --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Paulus Schoutsen --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/mcp/__init__.py | 69 ++++++ homeassistant/components/mcp/config_flow.py | 111 +++++++++ homeassistant/components/mcp/const.py | 3 + homeassistant/components/mcp/coordinator.py | 171 +++++++++++++ homeassistant/components/mcp/manifest.json | 10 + .../components/mcp/quality_scale.yaml | 88 +++++++ homeassistant/components/mcp/strings.json | 25 ++ homeassistant/components/mcp/types.py | 7 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + tests/components/mcp/__init__.py | 1 + tests/components/mcp/conftest.py | 45 ++++ tests/components/mcp/test_config_flow.py | 234 ++++++++++++++++++ tests/components/mcp/test_init.py | 225 +++++++++++++++++ 19 files changed, 1011 insertions(+) create mode 100644 homeassistant/components/mcp/__init__.py create mode 100644 homeassistant/components/mcp/config_flow.py create mode 100644 homeassistant/components/mcp/const.py create mode 100644 homeassistant/components/mcp/coordinator.py create mode 100644 homeassistant/components/mcp/manifest.json create mode 100644 homeassistant/components/mcp/quality_scale.yaml create mode 100644 homeassistant/components/mcp/strings.json create mode 100644 homeassistant/components/mcp/types.py create mode 100644 tests/components/mcp/__init__.py create mode 100644 tests/components/mcp/conftest.py create mode 100644 tests/components/mcp/test_config_flow.py create mode 100644 tests/components/mcp/test_init.py diff --git a/.strict-typing b/.strict-typing index 1c0456a745d..62da6c5ca92 100644 --- a/.strict-typing +++ b/.strict-typing @@ -316,6 +316,7 @@ homeassistant.components.manual.* homeassistant.components.mastodon.* homeassistant.components.matrix.* homeassistant.components.matter.* +homeassistant.components.mcp.* homeassistant.components.mcp_server.* homeassistant.components.mealie.* homeassistant.components.media_extractor.* diff --git a/CODEOWNERS b/CODEOWNERS index f16b890d407..faded2af138 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -891,6 +891,8 @@ build.json @home-assistant/supervisor /tests/components/matrix/ @PaarthShah /homeassistant/components/matter/ @home-assistant/matter /tests/components/matter/ @home-assistant/matter +/homeassistant/components/mcp/ @allenporter +/tests/components/mcp/ @allenporter /homeassistant/components/mcp_server/ @allenporter /tests/components/mcp_server/ @allenporter /homeassistant/components/mealie/ @joostlek @andrew-codechimp diff --git a/homeassistant/components/mcp/__init__.py b/homeassistant/components/mcp/__init__.py new file mode 100644 index 00000000000..4a2b4da990d --- /dev/null +++ b/homeassistant/components/mcp/__init__.py @@ -0,0 +1,69 @@ +"""The Model Context Protocol integration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm + +from .const import DOMAIN +from .coordinator import ModelContextProtocolCoordinator +from .types import ModelContextProtocolConfigEntry + +__all__ = [ + "DOMAIN", + "async_setup_entry", + "async_unload_entry", +] + +API_PROMPT = "The following tools are available from a remote server named {name}." + + +async def async_setup_entry( + hass: HomeAssistant, entry: ModelContextProtocolConfigEntry +) -> bool: + """Set up Model Context Protocol from a config entry.""" + coordinator = ModelContextProtocolCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + unsub = llm.async_register_api( + hass, + ModelContextProtocolAPI( + hass=hass, + id=f"{DOMAIN}-{entry.entry_id}", + name=entry.title, + coordinator=coordinator, + ), + ) + entry.async_on_unload(unsub) + + entry.runtime_data = coordinator + entry.async_on_unload(coordinator.close) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: ModelContextProtocolConfigEntry +) -> bool: + """Unload a config entry.""" + return True + + +@dataclass(kw_only=True) +class ModelContextProtocolAPI(llm.API): + """Define an object to hold the Model Context Protocol API.""" + + coordinator: ModelContextProtocolCoordinator + + async def async_get_api_instance( + self, llm_context: llm.LLMContext + ) -> llm.APIInstance: + """Return the instance of the API.""" + return llm.APIInstance( + self, + API_PROMPT.format(name=self.name), + llm_context, + tools=self.coordinator.data, + ) diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py new file mode 100644 index 00000000000..92e0052c665 --- /dev/null +++ b/homeassistant/components/mcp/config_flow.py @@ -0,0 +1,111 @@ +"""Config flow for the Model Context Protocol integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import httpx +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN +from .coordinator import mcp_client + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): str, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input and connect to the MCP server.""" + url = data[CONF_URL] + try: + cv.url(url) # Cannot be added to schema directly + except vol.Invalid as error: + raise InvalidUrl from error + try: + async with mcp_client(url) as session: + response = await session.initialize() + except httpx.TimeoutException as error: + _LOGGER.info("Timeout connecting to MCP server: %s", error) + raise TimeoutConnectError from error + except httpx.HTTPStatusError as error: + _LOGGER.info("Cannot connect to MCP server: %s", error) + if error.response.status_code == 401: + raise InvalidAuth from error + raise CannotConnect from error + except httpx.HTTPError as error: + _LOGGER.info("Cannot connect to MCP server: %s", error) + raise CannotConnect from error + + if not response.capabilities.tools: + raise MissingCapabilities( + f"MCP Server {url} does not support 'Tools' capability" + ) + + return {"title": response.serverInfo.name} + + +class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Model Context Protocol.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except InvalidUrl: + errors[CONF_URL] = "invalid_url" + except TimeoutConnectError: + errors["base"] = "timeout_connect" + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + return self.async_abort(reason="invalid_auth") + except MissingCapabilities: + return self.async_abort(reason="missing_capabilities") + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class InvalidUrl(HomeAssistantError): + """Error to indicate the URL format is invalid.""" + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class TimeoutConnectError(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class MissingCapabilities(HomeAssistantError): + """Error to indicate that the MCP server is missing required capabilities.""" diff --git a/homeassistant/components/mcp/const.py b/homeassistant/components/mcp/const.py new file mode 100644 index 00000000000..675b2d7031c --- /dev/null +++ b/homeassistant/components/mcp/const.py @@ -0,0 +1,3 @@ +"""Constants for the Model Context Protocol integration.""" + +DOMAIN = "mcp" diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py new file mode 100644 index 00000000000..a5c5ee55dbf --- /dev/null +++ b/homeassistant/components/mcp/coordinator.py @@ -0,0 +1,171 @@ +"""Types for the Model Context Protocol integration.""" + +import asyncio +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +import datetime +import logging + +import httpx +from mcp.client.session import ClientSession +from mcp.client.sse import sse_client +import voluptuous as vol +from voluptuous_openapi import convert_to_voluptuous + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import llm +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.json import JsonObjectType + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = datetime.timedelta(minutes=30) +TIMEOUT = 10 + + +@asynccontextmanager +async def mcp_client(url: str) -> AsyncGenerator[ClientSession]: + """Create a server-sent event MCP client. + + This is an asynccontext manager that exists to wrap other async context managers + so that the coordinator has a single object to manage. + """ + try: + async with sse_client(url=url) as streams, ClientSession(*streams) as session: + await session.initialize() + yield session + except ExceptionGroup as err: + raise err.exceptions[0] from err + + +class ModelContextProtocolTool(llm.Tool): + """A Tool exposed over the Model Context Protocol.""" + + def __init__( + self, + name: str, + description: str | None, + parameters: vol.Schema, + session: ClientSession, + ) -> None: + """Initialize the tool.""" + self.name = name + self.description = description + self.parameters = parameters + self.session = session + + async def async_call( + self, + hass: HomeAssistant, + tool_input: llm.ToolInput, + llm_context: llm.LLMContext, + ) -> JsonObjectType: + """Call the tool.""" + try: + result = await self.session.call_tool( + tool_input.tool_name, tool_input.tool_args + ) + except httpx.HTTPStatusError as error: + raise HomeAssistantError(f"Error when calling tool: {error}") from error + return result.model_dump(exclude_unset=True, exclude_none=True) + + +class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): + """Define an object to hold MCP data.""" + + config_entry: ConfigEntry + _session: ClientSession | None = None + _setup_error: Exception | None = None + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize ModelContextProtocolCoordinator.""" + super().__init__( + hass, + logger=_LOGGER, + name=DOMAIN, + config_entry=config_entry, + update_interval=UPDATE_INTERVAL, + ) + self._stop = asyncio.Event() + + async def _async_setup(self) -> None: + """Set up the client connection.""" + connected = asyncio.Event() + stop = asyncio.Event() + self.config_entry.async_create_background_task( + self.hass, self._connect(connected, stop), "mcp-client" + ) + try: + async with asyncio.timeout(TIMEOUT): + await connected.wait() + self._stop = stop + finally: + if self._setup_error is not None: + raise self._setup_error + + async def _connect(self, connected: asyncio.Event, stop: asyncio.Event) -> None: + """Create a server-sent event MCP client.""" + url = self.config_entry.data[CONF_URL] + try: + async with ( + sse_client(url=url) as streams, + ClientSession(*streams) as session, + ): + await session.initialize() + self._session = session + connected.set() + await stop.wait() + except httpx.HTTPStatusError as err: + self._setup_error = err + _LOGGER.debug("Error connecting to MCP server: %s", err) + raise UpdateFailed(f"Error connecting to MCP server: {err}") from err + except ExceptionGroup as err: + self._setup_error = err.exceptions[0] + _LOGGER.debug("Error connecting to MCP server: %s", err) + raise UpdateFailed( + "Error connecting to MCP server: {err.exceptions[0]}" + ) from err.exceptions[0] + finally: + self._session = None + + async def close(self) -> None: + """Close the client connection.""" + if self._stop is not None: + self._stop.set() + + async def _async_update_data(self) -> list[llm.Tool]: + """Fetch data from API endpoint. + + This is the place to pre-process the data to lookup tables + so entities can quickly look up their data. + """ + if self._session is None: + raise UpdateFailed("No session available") + try: + result = await self._session.list_tools() + except httpx.HTTPError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + _LOGGER.debug("Received tools: %s", result.tools) + tools: list[llm.Tool] = [] + for tool in result.tools: + try: + parameters = convert_to_voluptuous(tool.inputSchema) + except Exception as err: + raise UpdateFailed( + f"Error converting schema {err}: {tool.inputSchema}" + ) from err + tools.append( + ModelContextProtocolTool( + tool.name, + tool.description, + parameters, + self._session, + ) + ) + return tools diff --git a/homeassistant/components/mcp/manifest.json b/homeassistant/components/mcp/manifest.json new file mode 100644 index 00000000000..ee4baf04802 --- /dev/null +++ b/homeassistant/components/mcp/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "mcp", + "name": "Model Context Protocol", + "codeowners": ["@allenporter"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/mcp", + "iot_class": "local_polling", + "quality_scale": "silver", + "requirements": ["mcp==1.1.2"] +} diff --git a/homeassistant/components/mcp/quality_scale.yaml b/homeassistant/components/mcp/quality_scale.yaml new file mode 100644 index 00000000000..76afdf5860d --- /dev/null +++ b/homeassistant/components/mcp/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not have actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not have actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not have entities. + entity-unique-id: + status: exempt + comment: Integration does not have entities. + has-entity-name: + status: exempt + comment: Integration does not have entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not have actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: Integration does not have entities. + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: Integration does not have platforms. + reauthentication-flow: + status: exempt + comment: Integration does not support authentication. + test-coverage: done + + # Gold + devices: + status: exempt + comment: Integration does not have devices. + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: + status: exempt + comment: Integration does not have entities. + entity-device-class: + status: exempt + comment: Integration does not have entities. + entity-disabled-by-default: + status: exempt + comment: Integration does not have entities. + entity-translations: + status: exempt + comment: Integration does not have entities. + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: done diff --git a/homeassistant/components/mcp/strings.json b/homeassistant/components/mcp/strings.json new file mode 100644 index 00000000000..97a75fc6f85 --- /dev/null +++ b/homeassistant/components/mcp/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "The remote MCP server URL for the SSE endpoint, for example http://example/sse" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "invalid_url": "Must be a valid MCP server URL e.g. https://example.com/sse" + }, + "abort": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_capabilities": "The MCP server does not support a required capability (Tools)", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/mcp/types.py b/homeassistant/components/mcp/types.py new file mode 100644 index 00000000000..961c9ab3d18 --- /dev/null +++ b/homeassistant/components/mcp/types.py @@ -0,0 +1,7 @@ +"""Types for the Model Context Protocol integration.""" + +from homeassistant.config_entries import ConfigEntry + +from .coordinator import ModelContextProtocolCoordinator + +type ModelContextProtocolConfigEntry = ConfigEntry[ModelContextProtocolCoordinator] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b393e5c8851..7dea4598790 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -358,6 +358,7 @@ FLOWS = { "mailgun", "mastodon", "matter", + "mcp", "mcp_server", "mealie", "meater", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9a7167f5367..6d2e784c583 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3607,6 +3607,12 @@ "config_flow": true, "iot_class": "local_push" }, + "mcp": { + "name": "Model Context Protocol", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "mcp_server": { "name": "Model Context Protocol Server", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index 7f7b66e238f..188f1f7bbd7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2916,6 +2916,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.mcp.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.mcp_server.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 80890e8b612..87580b45ca9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1364,6 +1364,7 @@ maxcube-api==0.4.3 # homeassistant.components.mythicbeastsdns mbddns==0.1.2 +# homeassistant.components.mcp # homeassistant.components.mcp_server mcp==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a3bc80b736b..2894749732e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1142,6 +1142,7 @@ maxcube-api==0.4.3 # homeassistant.components.mythicbeastsdns mbddns==0.1.2 +# homeassistant.components.mcp # homeassistant.components.mcp_server mcp==1.1.2 diff --git a/tests/components/mcp/__init__.py b/tests/components/mcp/__init__.py new file mode 100644 index 00000000000..e8e8635ab36 --- /dev/null +++ b/tests/components/mcp/__init__.py @@ -0,0 +1 @@ +"""Tests for the Model Context Protocol integration.""" diff --git a/tests/components/mcp/conftest.py b/tests/components/mcp/conftest.py new file mode 100644 index 00000000000..d86603a12ed --- /dev/null +++ b/tests/components/mcp/conftest.py @@ -0,0 +1,45 @@ +"""Common fixtures for the Model Context Protocol tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.mcp.const import DOMAIN +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_API_NAME = "Memory Server" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.mcp.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_mcp_client() -> Generator[AsyncMock]: + """Fixture to mock the MCP client.""" + with ( + patch("homeassistant.components.mcp.coordinator.sse_client"), + patch("homeassistant.components.mcp.coordinator.ClientSession") as mock_session, + ): + yield mock_session.return_value.__aenter__ + + +@pytest.fixture(name="config_entry") +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Fixture to load the integration.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://1.1.1.1/sse"}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/mcp/test_config_flow.py b/tests/components/mcp/test_config_flow.py new file mode 100644 index 00000000000..29733e653a6 --- /dev/null +++ b/tests/components/mcp/test_config_flow.py @@ -0,0 +1,234 @@ +"""Test the Model Context Protocol config flow.""" + +from typing import Any +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest + +from homeassistant import config_entries +from homeassistant.components.mcp.const import DOMAIN +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TEST_API_NAME + +from tests.common import MockConfigEntry + + +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_mcp_client: Mock +) -> None: + """Test the complete configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_API_NAME + assert result["data"] == { + CONF_URL: "http://1.1.1.1/sse", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (httpx.TimeoutException("Some timeout"), "timeout_connect"), + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(500)), + "cannot_connect", + ), + (httpx.HTTPError("Some HTTP error"), "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_mcp_client_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test we handle different client library errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_mcp_client.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + # Reset the error and make sure the config flow can resume successfully. + mock_mcp_client.side_effect = None + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_API_NAME + assert result["data"] == { + CONF_URL: "http://1.1.1.1/sse", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(401)), + "invalid_auth", + ), + ], +) +async def test_form_mcp_client_error_abort( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test we handle different client library errors that end with an abort.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_mcp_client.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_error + + +@pytest.mark.parametrize( + "user_input", + [ + ({CONF_URL: "not a url"}), + ({CONF_URL: "rtsp://1.1.1.1"}), + ], +) +async def test_input_form_validation_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + user_input: dict[str, Any], +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_URL: "invalid_url"} + + # Reset the error and make sure the config flow can resume successfully. + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_API_NAME + assert result["data"] == { + CONF_URL: "http://1.1.1.1/sse", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_unique_url( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_mcp_client: Mock +) -> None: + """Test that the same url cannot be configured twice.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://1.1.1.1/sse"}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_server_missing_capbilities( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, +) -> None: + """Test we handle different client library errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + response = Mock() + response.serverInfo.name = TEST_API_NAME + response.capabilities.tools = None + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_capabilities" diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py new file mode 100644 index 00000000000..460df2c5785 --- /dev/null +++ b/tests/components/mcp/test_init.py @@ -0,0 +1,225 @@ +"""Tests for the Model Context Protocol component.""" + +import re +from unittest.mock import Mock, patch + +import httpx +from mcp.types import CallToolResult, ListToolsResult, TextContent, Tool +import pytest +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import llm + +from .conftest import TEST_API_NAME + +from tests.common import MockConfigEntry + +SEARCH_MEMORY_TOOL = Tool( + name="search_memory", + description="Search memory for relevant context based on a query.", + inputSchema={ + "type": "object", + "required": ["query"], + "properties": { + "query": { + "type": "string", + "description": "A free text query to search context for.", + } + }, + }, +) +SAVE_MEMORY_TOOL = Tool( + name="save_memory", + description="Save a memory context.", + inputSchema={ + "type": "object", + "required": ["context"], + "properties": { + "context": { + "type": "object", + "description": "The context to save.", + "properties": { + "fact": { + "type": "string", + "description": "The key for the context.", + }, + }, + }, + }, + }, +) + + +def create_llm_context() -> llm.LLMContext: + """Create a test LLM context.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + user_prompt="test_text", + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_init( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test the integration is initialized and can be unloaded cleanly.""" + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_mcp_server_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test the integration fails to setup if the server fails initialization.""" + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "", request=None, response=httpx.Response(500) + ) + + with patch("homeassistant.components.mcp.coordinator.TIMEOUT", 1): + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_list_tools_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test the integration fails to load if the first data fetch returns an error.""" + mock_mcp_client.return_value.list_tools.side_effect = httpx.HTTPStatusError( + "", request=None, response=httpx.Response(500) + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_llm_get_api_tools( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test MCP tools are returned as LLM API tools.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL, SAVE_MEMORY_TOOL], + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + assert api + + api_instance = await api.async_get_api_instance(create_llm_context()) + assert len(api_instance.tools) == 2 + tool = api_instance.tools[0] + assert tool.name == "search_memory" + assert tool.description == "Search memory for relevant context based on a query." + with pytest.raises( + vol.Invalid, match=re.escape("required key not provided @ data['query']") + ): + tool.parameters({}) + assert tool.parameters({"query": "frogs"}) == {"query": "frogs"} + + tool = api_instance.tools[1] + assert tool.name == "save_memory" + assert tool.description == "Save a memory context." + with pytest.raises( + vol.Invalid, match=re.escape("required key not provided @ data['context']") + ): + tool.parameters({}) + assert tool.parameters({"context": {"fact": "User was born in February"}}) == { + "context": {"fact": "User was born in February"} + } + + +async def test_call_tool( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test calling an MCP Tool through the LLM API.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + assert api + + api_instance = await api.async_get_api_instance(create_llm_context()) + assert len(api_instance.tools) == 1 + tool = api_instance.tools[0] + assert tool.name == "search_memory" + + mock_mcp_client.return_value.call_tool.return_value = CallToolResult( + content=[TextContent(type="text", text="User was born in February")] + ) + result = await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + assert result == { + "content": [{"text": "User was born in February", "type": "text"}] + } + + +async def test_call_tool_fails( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test handling an MCP Tool call failure.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + assert api + + api_instance = await api.async_get_api_instance(create_llm_context()) + assert len(api_instance.tools) == 1 + tool = api_instance.tools[0] + assert tool.name == "search_memory" + + mock_mcp_client.return_value.call_tool.side_effect = httpx.HTTPStatusError( + "Server error", request=None, response=httpx.Response(500) + ) + with pytest.raises( + HomeAssistantError, match="Error when calling tool: Server error" + ): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + + +async def test_convert_tool_schema_fails( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test a failure converting an MCP tool schema to a Home Assistant schema.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + with patch( + "homeassistant.components.mcp.coordinator.convert_to_voluptuous", + side_effect=ValueError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY From b633a0424a6a594c907da0cfb9ebb6510cdab1cf Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 27 Jan 2025 14:18:31 -0600 Subject: [PATCH 1034/2987] Add HassClimateSetTemperature (#136484) * Add HassClimateSetTemperature * Use single target constraint --- homeassistant/components/climate/__init__.py | 1 + homeassistant/components/climate/const.py | 1 + homeassistant/components/climate/intent.py | 94 ++++++- tests/components/climate/test_intent.py | 252 ++++++++++++++++++- 4 files changed, 345 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index af64b06ebe6..3ea0f887e76 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -69,6 +69,7 @@ from .const import ( # noqa: F401 FAN_TOP, HVAC_MODES, INTENT_GET_TEMPERATURE, + INTENT_SET_TEMPERATURE, PRESET_ACTIVITY, PRESET_AWAY, PRESET_BOOST, diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index 111401a2251..d347ccbbb29 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -127,6 +127,7 @@ DEFAULT_MAX_HUMIDITY = 99 DOMAIN = "climate" INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" +INTENT_SET_TEMPERATURE = "HassClimateSetTemperature" SERVICE_SET_AUX_HEAT = "set_aux_heat" SERVICE_SET_FAN_MODE = "set_fan_mode" diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 9a8dfdda4ec..9837a326188 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -4,15 +4,24 @@ from __future__ import annotations import voluptuous as vol +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent +from homeassistant.helpers import config_validation as cv, intent -from . import DOMAIN, INTENT_GET_TEMPERATURE +from . import ( + ATTR_TEMPERATURE, + DOMAIN, + INTENT_GET_TEMPERATURE, + INTENT_SET_TEMPERATURE, + SERVICE_SET_TEMPERATURE, + ClimateEntityFeature, +) async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the climate intents.""" intent.async_register(hass, GetTemperatureIntent()) + intent.async_register(hass, SetTemperatureIntent()) class GetTemperatureIntent(intent.IntentHandler): @@ -52,3 +61,84 @@ class GetTemperatureIntent(intent.IntentHandler): response.response_type = intent.IntentResponseType.QUERY_ANSWER response.async_set_states(matched_states=match_result.states) return response + + +class SetTemperatureIntent(intent.IntentHandler): + """Handle SetTemperature intents.""" + + intent_type = INTENT_SET_TEMPERATURE + description = "Sets the target temperature of a climate device or entity" + slot_schema = { + vol.Required("temperature"): vol.Coerce(float), + vol.Optional("area"): intent.non_empty_string, + vol.Optional("name"): intent.non_empty_string, + vol.Optional("floor"): intent.non_empty_string, + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, + } + platforms = {DOMAIN} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + + temperature: float = slots["temperature"]["value"] + + name: str | None = None + if "name" in slots: + name = slots["name"]["value"] + + area_name: str | None = None + if "area" in slots: + area_name = slots["area"]["value"] + + floor_name: str | None = None + if "floor" in slots: + floor_name = slots["floor"]["value"] + + match_constraints = intent.MatchTargetsConstraints( + name=name, + area_name=area_name, + floor_name=floor_name, + domains=[DOMAIN], + assistant=intent_obj.assistant, + features=ClimateEntityFeature.TARGET_TEMPERATURE, + single_target=True, + ) + match_preferences = intent.MatchTargetsPreferences( + area_id=slots.get("preferred_area_id", {}).get("value"), + floor_id=slots.get("preferred_floor_id", {}).get("value"), + ) + match_result = intent.async_match_targets( + hass, match_constraints, match_preferences + ) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + + assert match_result.states + climate_state = match_result.states[0] + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + service_data={ATTR_TEMPERATURE: temperature}, + target={ATTR_ENTITY_ID: climate_state.entity_id}, + blocking=True, + ) + + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_results( + success_results=[ + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.ENTITY, + name=climate_state.name, + id=climate_state.entity_id, + ) + ] + ) + response.async_set_states(matched_states=[climate_state]) + return response diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index d17f3a1747d..00ab2f8d278 100644 --- a/tests/components/climate/test_intent.py +++ b/tests/components/climate/test_intent.py @@ -1,13 +1,16 @@ """Test climate intents.""" from collections.abc import Generator +from typing import Any import pytest from homeassistant.components import conversation from homeassistant.components.climate import ( + ATTR_TEMPERATURE, DOMAIN, ClimateEntity, + ClimateEntityFeature, HVACMode, intent as climate_intent, ) @@ -15,7 +18,12 @@ from homeassistant.components.homeassistant.exposed_entities import async_expose from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers import area_registry as ar, entity_registry as er, intent +from homeassistant.helpers import ( + area_registry as ar, + entity_registry as er, + floor_registry as fr, + intent, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.setup import async_setup_component @@ -107,6 +115,20 @@ class MockClimateEntity(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_mode = HVACMode.OFF _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the thermostat temperature.""" + value = kwargs[ATTR_TEMPERATURE] + self._attr_target_temperature = value + + +class MockClimateEntityNoSetTemperature(ClimateEntity): + """Mock Climate device to use in tests.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_mode = HVACMode.OFF + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] async def test_get_temperature( @@ -436,3 +458,231 @@ async def test_not_exposed( assistant=conversation.DOMAIN, ) assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + +async def test_set_temperature( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, +) -> None: + """Test HassClimateSetTemperature intent.""" + assert await async_setup_component(hass, "homeassistant", {}) + await climate_intent.async_setup_intents(hass) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + climate_1._attr_target_temperature = 10.0 + entity_registry.async_get_or_create( + DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + climate_2._attr_target_temperature = 22.0 + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to different areas: + # climate_1 => living room + # climate_2 => bedroom + # nothing in office + living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") + office_area = area_registry.async_create(name="Office") + + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) + + # Put areas on different floors: + # first floor => living room and office + # upstairs => bedroom + floor_registry = fr.async_get(hass) + first_floor = floor_registry.async_create("First floor") + living_room_area = area_registry.async_update( + living_room_area.id, floor_id=first_floor.floor_id + ) + office_area = area_registry.async_update( + office_area.id, floor_id=first_floor.floor_id + ) + + second_floor = floor_registry.async_create("Second floor") + bedroom_area = area_registry.async_update( + bedroom_area.id, floor_id=second_floor.floor_id + ) + + # Cannot target multiple climate devices + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"temperature": {"value": 20}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS + + # Select by area explicitly (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"area": {"value": bedroom_area.name}, "temperature": {"value": 20.1}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.1 + + # Select by area implicitly (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + { + "preferred_area_id": {"value": bedroom_area.id}, + "temperature": {"value": 20.2}, + }, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.matched_states + assert response.matched_states[0].entity_id == climate_2.entity_id + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.2 + + # Select by floor explicitly (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"floor": {"value": second_floor.name}, "temperature": {"value": 20.3}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.matched_states + assert response.matched_states[0].entity_id == climate_2.entity_id + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.3 + + # Select by floor implicitly (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + { + "preferred_floor_id": {"value": second_floor.floor_id}, + "temperature": {"value": 20.4}, + }, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.matched_states + assert response.matched_states[0].entity_id == climate_2.entity_id + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.4 + + # Select by name (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"name": {"value": "Climate 2"}, "temperature": {"value": 20.5}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.5 + + # Check area with no climate entities (explicit) + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"area": {"value": office_area.name}, "temperature": {"value": 20.6}}, + assistant=conversation.DOMAIN, + ) + + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA + constraints = error.value.constraints + assert constraints.name is None + assert constraints.area_name == office_area.name + assert constraints.domains and (set(constraints.domains) == {DOMAIN}) + assert constraints.device_classes is None + + # Implicit area with no climate entities will fail with multiple targets + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + { + "preferred_area_id": {"value": office_area.id}, + "temperature": {"value": 20.7}, + }, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS + + +async def test_set_temperature_no_entities( + hass: HomeAssistant, +) -> None: + """Test HassClimateSetTemperature intent with no climate entities.""" + assert await async_setup_component(hass, "homeassistant", {}) + await climate_intent.async_setup_intents(hass) + + await create_mock_platform(hass, []) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"temperature": {"value": 20}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN + + +async def test_set_temperature_not_supported(hass: HomeAssistant) -> None: + """Test HassClimateSetTemperature intent when climate entity doesn't support required feature.""" + assert await async_setup_component(hass, "homeassistant", {}) + await climate_intent.async_setup_intents(hass) + + climate_1 = MockClimateEntityNoSetTemperature() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + climate_1._attr_target_temperature = 10.0 + + await create_mock_platform(hass, [climate_1]) + + with pytest.raises(intent.MatchFailedError) as error: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"temperature": {"value": 20.0}}, + assistant=conversation.DOMAIN, + ) + + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.FEATURE From b79221e66610e9336aabd9f42834e0f5fa3688ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jan 2025 10:25:20 -1000 Subject: [PATCH 1035/2987] Make static modbus entity values classvar defaults (#136488) --- homeassistant/components/modbus/entity.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 35b7c02aa05..4684c2f2b8a 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -74,6 +74,11 @@ _LOGGER = logging.getLogger(__name__) class BasePlatform(Entity): """Base for readonly platforms.""" + _value: str | None = None + _attr_should_poll = False + _attr_available = True + _attr_unit_of_measurement = None + def __init__( self, hass: HomeAssistant, hub: ModbusHub, entry: dict[str, Any] ) -> None: @@ -86,17 +91,13 @@ class BasePlatform(Entity): self._slave = entry.get(CONF_DEVICE_ADDRESS, 1) self._address = int(entry[CONF_ADDRESS]) self._input_type = entry[CONF_INPUT_TYPE] - self._value: str | None = None self._scan_interval = int(entry[CONF_SCAN_INTERVAL]) self._cancel_timer: Callable[[], None] | None = None self._cancel_call: Callable[[], None] | None = None self._attr_unique_id = entry.get(CONF_UNIQUE_ID) self._attr_name = entry[CONF_NAME] - self._attr_should_poll = False self._attr_device_class = entry.get(CONF_DEVICE_CLASS) - self._attr_available = True - self._attr_unit_of_measurement = None def get_optional_numeric_config(config_name: str) -> int | float | None: if (val := entry.get(config_name)) is None: From c12fa34e33800b67466be75771cee54e3df50dc5 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 27 Jan 2025 20:27:29 +0000 Subject: [PATCH 1036/2987] Add support for tplink siren turn on parameters (#136642) Add support for tplink siren parameters - Allow passing tone, volume, and duration for siren's play action. --------- Co-authored-by: Teemu Rytilahti --- homeassistant/components/tplink/siren.py | 51 +++++++++- homeassistant/components/tplink/strings.json | 3 + tests/components/tplink/__init__.py | 35 +++++-- .../tplink/snapshots/test_siren.ambr | 15 ++- tests/components/tplink/test_siren.py | 92 +++++++++++++++++++ 5 files changed, 182 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/tplink/siren.py b/homeassistant/components/tplink/siren.py index d1ce03c1469..027fa2dd58f 100644 --- a/homeassistant/components/tplink/siren.py +++ b/homeassistant/components/tplink/siren.py @@ -4,20 +4,27 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any +import math +from typing import TYPE_CHECKING, Any, cast from kasa import Device, Module from homeassistant.components.siren import ( + ATTR_DURATION, + ATTR_TONE, + ATTR_VOLUME_LEVEL, DOMAIN as SIREN_DOMAIN, SirenEntity, SirenEntityDescription, SirenEntityFeature, + SirenTurnOnServiceParameters, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry, legacy_device_id +from .const import DOMAIN from .coordinator import TPLinkDataUpdateCoordinator from .entity import ( CoordinatedTPLinkModuleEntity, @@ -86,7 +93,13 @@ class TPLinkSirenEntity(CoordinatedTPLinkModuleEntity, SirenEntity): """Representation of a tplink siren entity.""" _attr_name = None - _attr_supported_features = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON + _attr_supported_features = ( + SirenEntityFeature.TURN_OFF + | SirenEntityFeature.TURN_ON + | SirenEntityFeature.TONES + | SirenEntityFeature.DURATION + | SirenEntityFeature.VOLUME_SET + ) entity_description: TPLinkSirenEntityDescription @@ -102,10 +115,38 @@ class TPLinkSirenEntity(CoordinatedTPLinkModuleEntity, SirenEntity): super().__init__(device, coordinator, description, parent=parent) self._alarm_module = device.modules[Module.Alarm] + alarm_vol_feat = self._alarm_module.get_feature("alarm_volume") + alarm_duration_feat = self._alarm_module.get_feature("alarm_duration") + if TYPE_CHECKING: + assert alarm_vol_feat + assert alarm_duration_feat + self._alarm_volume_max = alarm_vol_feat.maximum_value + self._alarm_duration_max = alarm_duration_feat.maximum_value + @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: """Turn the siren on.""" - await self._alarm_module.play() + turn_on_params = cast(SirenTurnOnServiceParameters, kwargs) + if (volume := kwargs.get(ATTR_VOLUME_LEVEL)) is not None: + # service parameter is a % so we round up to the nearest int + volume = math.ceil(volume * self._alarm_volume_max) + + if (duration := kwargs.get(ATTR_DURATION)) is not None: + if duration < 1 or duration > self._alarm_duration_max: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_alarm_duration", + translation_placeholders={ + "duration": str(duration), + "duration_max": str(self._alarm_duration_max), + }, + ) + + await self._alarm_module.play( + duration=turn_on_params.get(ATTR_DURATION), + volume=volume, + sound=kwargs.get(ATTR_TONE), + ) @async_refresh_after async def async_turn_off(self, **kwargs: Any) -> None: @@ -116,4 +157,8 @@ class TPLinkSirenEntity(CoordinatedTPLinkModuleEntity, SirenEntity): def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" self._attr_is_on = self._alarm_module.active + # alarm_sounds returns list[str], so we need to widen the type + self._attr_available_tones = cast( + list[str | int], self._alarm_module.alarm_sounds + ) return True diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 9c32dd5bbf4..fa284a3cc83 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -367,6 +367,9 @@ }, "unsupported_mode": { "message": "Tried to set unsupported mode: {mode}" + }, + "invalid_alarm_duration": { + "message": "Invalid duration {duration} available: 1-{duration_max}s" } }, "issues": { diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index a056555f4c0..008d25a3dcb 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -178,12 +178,6 @@ def _mocked_device( device_config.host = ip_address device.host = ip_address - if modules: - device.modules = { - module_name: MODULE_TO_MOCK_GEN[module_name](device) - for module_name in modules - } - device_features = {} if features: device_features = { @@ -201,6 +195,13 @@ def _mocked_device( ) device.features = device_features + # Add modules after features so modules can add required features + if modules: + device.modules = { + module_name: MODULE_TO_MOCK_GEN[module_name](device) + for module_name in modules + } + for mod in device.modules.values(): mod.get_feature.side_effect = device_features.get mod.has_feature.side_effect = lambda id: id in device_features @@ -251,7 +252,10 @@ def _mocked_feature( feature.id = id feature.name = name or id.upper() feature.set_value = AsyncMock() - if not (fixture := FEATURES_FIXTURE.get(id)): + if fixture := FEATURES_FIXTURE.get(id): + # copy the fixture so tests do not interfere with each other + fixture = dict(fixture) + else: assert require_fixture is False, ( f"No fixture defined for feature {id} and require_fixture is True" ) @@ -259,7 +263,8 @@ def _mocked_feature( f"Value must be provided if feature {id} not defined in features.json" ) fixture = {"value": value, "category": "Primary", "type": "Sensor"} - elif value is not UNDEFINED: + + if value is not UNDEFINED: fixture["value"] = value feature.value = fixture["value"] @@ -352,9 +357,23 @@ def _mocked_fan_module(effect) -> Fan: def _mocked_alarm_module(device): alarm = MagicMock(auto_spec=Alarm, name="Mocked alarm") alarm.active = False + alarm.alarm_sounds = "Foo", "Bar" alarm.play = AsyncMock() alarm.stop = AsyncMock() + device.features["alarm_volume"] = _mocked_feature( + "alarm_volume", + minimum_value=0, + maximum_value=3, + value=None, + ) + device.features["alarm_duration"] = _mocked_feature( + "alarm_duration", + minimum_value=0, + maximum_value=300, + value=None, + ) + return alarm diff --git a/tests/components/tplink/snapshots/test_siren.ambr b/tests/components/tplink/snapshots/test_siren.ambr index b144288bd1c..7141ccfa084 100644 --- a/tests/components/tplink/snapshots/test_siren.ambr +++ b/tests/components/tplink/snapshots/test_siren.ambr @@ -40,7 +40,12 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'available_tones': tuple( + 'Foo', + 'Bar', + ), + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -62,7 +67,7 @@ 'original_name': None, 'platform': 'tplink', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABCDEFGH', 'unit_of_measurement': None, @@ -71,8 +76,12 @@ # name: test_states[siren.hub-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'available_tones': tuple( + 'Foo', + 'Bar', + ), 'friendly_name': 'hub', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'siren.hub', diff --git a/tests/components/tplink/test_siren.py b/tests/components/tplink/test_siren.py index 8c3328558b0..1d820bca1d1 100644 --- a/tests/components/tplink/test_siren.py +++ b/tests/components/tplink/test_siren.py @@ -7,12 +7,16 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.siren import ( + ATTR_DURATION, + ATTR_TONE, + ATTR_VOLUME_LEVEL, DOMAIN as SIREN_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er from . import _mocked_device, setup_platform_for_device, snapshot_platform @@ -74,3 +78,91 @@ async def test_turn_on_and_off( ) alarm_module.play.assert_called() + + +@pytest.mark.parametrize( + ("max_volume", "volume_level", "expected_volume"), + [ + pytest.param(3, 0.1, 1, id="smart-10%"), + pytest.param(3, 0.3, 1, id="smart-30%"), + pytest.param(3, 0.99, 3, id="smart-99%"), + pytest.param(3, 1, 3, id="smart-100%"), + pytest.param(10, 0.1, 1, id="smartcam-10%"), + pytest.param(10, 0.3, 3, id="smartcam-30%"), + pytest.param(10, 0.99, 10, id="smartcam-99%"), + pytest.param(10, 1, 10, id="smartcam-100%"), + ], +) +async def test_turn_on_with_volume( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_hub: Device, + max_volume: int, + volume_level: float, + expected_volume: int, +) -> None: + """Test that turn_on volume parameters work as expected.""" + + alarm_module = mocked_hub.modules[Module.Alarm] + alarm_volume_feat = alarm_module.get_feature("alarm_volume") + assert alarm_volume_feat + alarm_volume_feat.maximum_value = max_volume + + await setup_platform_for_device(hass, mock_config_entry, Platform.SIREN, mocked_hub) + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [ENTITY_ID], ATTR_VOLUME_LEVEL: volume_level}, + blocking=True, + ) + + alarm_module.play.assert_called_with( + volume=expected_volume, duration=None, sound=None + ) + + +async def test_turn_on_with_duration_and_sound( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_hub: Device, +) -> None: + """Test that turn_on tone and duration parameters work as expected.""" + + alarm_module = mocked_hub.modules[Module.Alarm] + + await setup_platform_for_device(hass, mock_config_entry, Platform.SIREN, mocked_hub) + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [ENTITY_ID], ATTR_DURATION: 5, ATTR_TONE: "Foo"}, + blocking=True, + ) + + alarm_module.play.assert_called_with(volume=None, duration=5, sound="Foo") + + +@pytest.mark.parametrize(("duration"), [0, 301]) +async def test_turn_on_with_invalid_duration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_hub: Device, + duration: int, +) -> None: + """Test that turn_on with invalid_duration raises an error.""" + + await setup_platform_for_device(hass, mock_config_entry, Platform.SIREN, mocked_hub) + + msg = f"Invalid duration {duration} available: 1-300s" + + with pytest.raises(ServiceValidationError, match=msg): + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: [ENTITY_ID], + ATTR_DURATION: duration, + }, + blocking=True, + ) From 7cf20c95c209ccb08750d5c27d98876a9f463df7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jan 2025 10:38:18 -1000 Subject: [PATCH 1037/2987] Log the error when the WebSocket receives a error message (#136492) * Log the error when the WebSocket receives a non-text message related issue #126754 Right now we only log that it was a non-Text message and silently swallow the exception * coverage --- .../components/websocket_api/http.py | 16 +++- tests/components/websocket_api/test_auth.py | 77 ++++++++++++++++++- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 8bfa9480ff4..ebca497193b 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -387,7 +387,14 @@ class WebSocketHandler: raise Disconnect("Received close message during auth phase") if msg.type is not WSMsgType.TEXT: - raise Disconnect("Received non-Text message during auth phase") + if msg.type is WSMsgType.ERROR: + # msg.data is the exception + raise Disconnect( + f"Received error message during auth phase: {msg.data}" + ) + raise Disconnect( + f"Received non-Text message of type {msg.type} during auth phase" + ) try: auth_msg_data = json_loads(msg.data) @@ -477,7 +484,12 @@ class WebSocketHandler: continue if msg_type is not WSMsgType.TEXT: - raise Disconnect("Received non-Text message.") + if msg_type is WSMsgType.ERROR: + # msg.data is the exception + raise Disconnect( + f"Received error message during command phase: {msg.data}" + ) + raise Disconnect(f"Received non-Text message of type {msg_type}.") try: command_msg_data = json_loads(msg_data) diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index d55d2f97017..49ee593fed7 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -3,7 +3,7 @@ from unittest.mock import patch import aiohttp -from aiohttp import WSMsgType +from aiohttp import WSMsgType, web import pytest from homeassistant.auth.providers.homeassistant import HassAuthProvider @@ -258,7 +258,7 @@ async def test_auth_sending_binary_disconnects( await ws.send_bytes(b"[INVALID]") auth_msg = await ws.receive() - assert auth_msg.type == WSMsgType.close + assert auth_msg.type is WSMsgType.CLOSE async def test_auth_close_disconnects( @@ -277,7 +277,40 @@ async def test_auth_close_disconnects( await ws.close() auth_msg = await ws.receive() - assert auth_msg.type == WSMsgType.CLOSED + assert auth_msg.type is WSMsgType.CLOSED + + +async def test_auth_error_disconnects( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error during auth.""" + assert await async_setup_component(hass, "websocket_api", {}) + await hass.async_block_till_done() + + client = await hass_client_no_auth() + ws_response = web.WebSocketResponse() + + with patch( + "homeassistant.components.websocket_api.http.web.WebSocketResponse", + return_value=ws_response, + ): + async with client.ws_connect(URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_REQUIRED + + ws_response._reader.feed_data( + aiohttp.WSMessage( + type=WSMsgType.ERROR, data=Exception("explode"), extra=None + ), + 0, + ) + + auth_msg = await ws.receive() + assert auth_msg.type is WSMsgType.CLOSE + + assert "Received error message during auth phase: explode" in caplog.text async def test_auth_sending_unknown_type_disconnects( @@ -296,3 +329,41 @@ async def test_auth_sending_unknown_type_disconnects( await ws._writer.send_frame(b"1" * 130, 0x30) auth_msg = await ws.receive() assert auth_msg.type == WSMsgType.close + + +async def test_error_right_after_auth_disconnects( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + hass_access_token: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error right after auth.""" + assert await async_setup_component(hass, "websocket_api", {}) + await hass.async_block_till_done() + + client = await hass_client_no_auth() + ws_response = web.WebSocketResponse() + + with patch( + "homeassistant.components.websocket_api.http.web.WebSocketResponse", + return_value=ws_response, + ): + async with client.ws_connect(URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_REQUIRED + + await ws.send_json({"type": TYPE_AUTH, "access_token": hass_access_token}) + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_OK + + ws_response._reader.feed_data( + aiohttp.WSMessage( + type=WSMsgType.ERROR, data=Exception("explode"), extra=None + ), + 0, + ) + + close_error_msg = await ws.receive() + assert close_error_msg.type is WSMsgType.CLOSE + + assert "Received error message during command phase: explode" in caplog.text From 50b0abbd7b075eefe27af3e8e239acae7b70ea50 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 27 Jan 2025 22:24:14 +0100 Subject: [PATCH 1038/2987] Bump pyfritzhome to 0.6.14 (#136661) bump pyfritzhome to 0.6.14 --- homeassistant/components/fritzbox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index 1a127597b81..2fbb75443b2 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyfritzhome"], - "requirements": ["pyfritzhome==0.6.12"], + "requirements": ["pyfritzhome==0.6.14"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/requirements_all.txt b/requirements_all.txt index 87580b45ca9..f9f597b7f80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1963,7 +1963,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.12 +pyfritzhome==0.6.14 # homeassistant.components.ifttt pyfttt==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2894749732e..96793fc02a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1598,7 +1598,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.12 +pyfritzhome==0.6.14 # homeassistant.components.ifttt pyfttt==0.3 From 0b17d1168300e5f9a7462a2f1df2c0bf4d09139a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 27 Jan 2025 22:28:55 +0100 Subject: [PATCH 1039/2987] Update flux-led to 1.1.3 (#136666) --- homeassistant/components/flux_led/config_flow.py | 2 +- homeassistant/components/flux_led/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py index 69e40d59f7f..035be5b115c 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -299,7 +299,7 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN): # AKA `HF-LPB100-ZJ200` return device bulb = async_wifi_bulb_for_host(host, discovery=device) - bulb.discovery = discovery # type: ignore[assignment] + bulb.discovery = discovery try: await bulb.async_setup(lambda: None) finally: diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index 962098a0bf8..fcb16c9742b 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -53,5 +53,5 @@ "documentation": "https://www.home-assistant.io/integrations/flux_led", "iot_class": "local_push", "loggers": ["flux_led"], - "requirements": ["flux-led==1.1.0"] + "requirements": ["flux-led==1.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index f9f597b7f80..e7e1b767fe4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -930,7 +930,7 @@ flexit_bacnet==2.2.1 flipr-api==1.6.1 # homeassistant.components.flux_led -flux-led==1.1.0 +flux-led==1.1.3 # homeassistant.components.homekit # homeassistant.components.recorder diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 96793fc02a1..2d7a55f1a60 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -789,7 +789,7 @@ flexit_bacnet==2.2.1 flipr-api==1.6.1 # homeassistant.components.flux_led -flux-led==1.1.0 +flux-led==1.1.3 # homeassistant.components.homekit # homeassistant.components.recorder From e0ea5bfc518e57e0e830be765d595310282cf7a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jan 2025 11:49:49 -1000 Subject: [PATCH 1040/2987] Add Bluetooth WebSocket API to subscribe to connection allocations (#136215) --- homeassistant/components/bluetooth/util.py | 23 ++- .../components/bluetooth/websocket_api.py | 45 ++++- tests/components/bluetooth/__init__.py | 13 +- tests/components/bluetooth/conftest.py | 30 ++- tests/components/bluetooth/test_manager.py | 102 +++++++--- .../bluetooth/test_websocket_api.py | 174 +++++++++++++++++- 6 files changed, 345 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index 8c7ad13294a..ca2e0180c00 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -11,13 +11,23 @@ from bluetooth_adapters import ( adapter_unique_name, ) from bluetooth_data_tools import monotonic_time_coarse +from habluetooth import get_manager -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from .models import BluetoothServiceInfoBleak from .storage import BluetoothStorage +class InvalidConfigEntryID(HomeAssistantError): + """Invalid config entry id.""" + + +class InvalidSource(HomeAssistantError): + """Invalid source.""" + + @callback def async_load_history_from_system( adapters: BluetoothAdapters, storage: BluetoothStorage @@ -85,3 +95,14 @@ def adapter_title(adapter: str, details: AdapterDetails) -> str: model = details.get(ADAPTER_PRODUCT, "Unknown") manufacturer = details[ADAPTER_MANUFACTURER] or "Unknown" return f"{manufacturer} {model} ({unique_name})" + + +def config_entry_id_to_source(hass: HomeAssistant, config_entry_id: str) -> str: + """Convert a config entry id to a source.""" + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise InvalidConfigEntryID(f"Config entry {config_entry_id} not found") + source = entry.unique_id + assert source is not None + if not get_manager().async_scanner_by_source(source): + raise InvalidSource(f"Source {source} not found") + return source diff --git a/homeassistant/components/bluetooth/websocket_api.py b/homeassistant/components/bluetooth/websocket_api.py index 45445a7a00f..2829617d09e 100644 --- a/homeassistant/components/bluetooth/websocket_api.py +++ b/homeassistant/components/bluetooth/websocket_api.py @@ -7,7 +7,7 @@ from functools import lru_cache, partial import time from typing import Any -from habluetooth import BluetoothScanningMode +from habluetooth import BluetoothScanningMode, HaBluetoothSlotAllocations from home_assistant_bluetooth import BluetoothServiceInfoBleak import voluptuous as vol @@ -18,12 +18,14 @@ from homeassistant.helpers.json import json_bytes from .api import _get_manager, async_register_callback from .match import BluetoothCallbackMatcher from .models import BluetoothChange +from .util import InvalidConfigEntryID, InvalidSource, config_entry_id_to_source @callback def async_setup(hass: HomeAssistant) -> None: """Set up the bluetooth websocket API.""" websocket_api.async_register_command(hass, ws_subscribe_advertisements) + websocket_api.async_register_command(hass, ws_subscribe_connection_allocations) @lru_cache(maxsize=1024) @@ -135,6 +137,7 @@ class _AdvertisementSubscription: self._async_added((service_info,)) +@websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "bluetooth/subscribe_advertisements", @@ -148,3 +151,43 @@ async def ws_subscribe_advertisements( _AdvertisementSubscription( hass, connection, msg["id"], BluetoothCallbackMatcher(connectable=False) ).async_start() + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "bluetooth/subscribe_connection_allocations", + vol.Optional("config_entry_id"): str, + } +) +@websocket_api.async_response +async def ws_subscribe_connection_allocations( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe advertisements websocket command.""" + ws_msg_id = msg["id"] + source: str | None = None + if config_entry_id := msg.get("config_entry_id"): + try: + source = config_entry_id_to_source(hass, config_entry_id) + except InvalidConfigEntryID as err: + connection.send_error(ws_msg_id, "invalid_config_entry_id", str(err)) + return + except InvalidSource as err: + connection.send_error(ws_msg_id, "invalid_source", str(err)) + return + + def _async_allocations_changed(allocations: HaBluetoothSlotAllocations) -> None: + connection.send_message( + json_bytes(websocket_api.event_message(ws_msg_id, [allocations])) + ) + + manager = _get_manager(hass) + connection.subscriptions[ws_msg_id] = manager.async_register_allocation_callback( + _async_allocations_changed, source + ) + connection.send_message(json_bytes(websocket_api.result_message(ws_msg_id))) + if current_allocations := manager.async_current_allocations(source): + connection.send_message( + json_bytes(websocket_api.event_message(ws_msg_id, current_allocations)) + ) diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index c672de7424b..31d301e2dac 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -10,7 +10,7 @@ from unittest.mock import MagicMock, patch from bleak import BleakClient from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS -from habluetooth import BaseHaScanner, BluetoothManager, get_manager +from habluetooth import BaseHaScanner, get_manager from homeassistant.components.bluetooth import ( DOMAIN, @@ -21,6 +21,7 @@ from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_get_advertisement_callback, ) +from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -57,6 +58,11 @@ BLE_DEVICE_DEFAULTS = { } +HCI0_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:00" +HCI1_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:11" +NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:FF" + + @contextmanager def patch_bluetooth_time(mock_time: float) -> None: """Patch the bluetooth time.""" @@ -101,9 +107,10 @@ def generate_ble_device( return BLEDevice(**new) -def _get_manager() -> BluetoothManager: +def _get_manager() -> HomeAssistantBluetoothManager: """Return the bluetooth manager.""" - return get_manager() + manager: HomeAssistantBluetoothManager = get_manager() + return manager def inject_advertisement( diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 6fa0b375e81..e07b580acb2 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -5,13 +5,19 @@ from unittest.mock import patch from bleak_retry_connector import bleak_manager from dbus_fast.aio import message_bus +from habluetooth import BaseHaRemoteScanner import habluetooth.util as habluetooth_utils import pytest from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant -from . import FakeScanner +from . import ( + HCI0_SOURCE_ADDRESS, + HCI1_SOURCE_ADDRESS, + NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, + FakeScanner, +) @pytest.fixture(name="disable_bluez_manager_socket", autouse=True, scope="package") @@ -314,8 +320,9 @@ def disable_new_discovery_flows_fixture(): @pytest.fixture def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]: """Register an hci0 scanner.""" - hci0_scanner = FakeScanner("hci0", "hci0") - cancel = bluetooth.async_register_scanner(hass, hci0_scanner) + hci0_scanner = FakeScanner(HCI0_SOURCE_ADDRESS, "hci0") + hci0_scanner.connectable = True + cancel = bluetooth.async_register_scanner(hass, hci0_scanner, connection_slots=5) yield cancel() bluetooth.async_remove_scanner(hass, hci0_scanner.source) @@ -324,8 +331,21 @@ def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]: @pytest.fixture def register_hci1_scanner(hass: HomeAssistant) -> Generator[None]: """Register an hci1 scanner.""" - hci1_scanner = FakeScanner("hci1", "hci1") - cancel = bluetooth.async_register_scanner(hass, hci1_scanner) + hci1_scanner = FakeScanner(HCI1_SOURCE_ADDRESS, "hci1") + hci1_scanner.connectable = True + cancel = bluetooth.async_register_scanner(hass, hci1_scanner, connection_slots=5) yield cancel() bluetooth.async_remove_scanner(hass, hci1_scanner.source) + + +@pytest.fixture +def register_non_connectable_scanner(hass: HomeAssistant) -> Generator[None]: + """Register an non connectable remote scanner.""" + remote_scanner = BaseHaRemoteScanner( + NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, "non connectable", None, False + ) + cancel = bluetooth.async_register_scanner(hass, remote_scanner) + yield + cancel() + bluetooth.async_remove_scanner(hass, remote_scanner.source) diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 77071368dd0..c7fc80ba068 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -45,6 +45,8 @@ from homeassistant.util.dt import utcnow from homeassistant.util.json import json_loads from . import ( + HCI0_SOURCE_ADDRESS, + HCI1_SOURCE_ADDRESS, FakeScanner, MockBleakClient, _get_manager, @@ -82,7 +84,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( local_name="wohand_signal_100", service_uuids=[] ) inject_advertisement_with_source( - hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" + hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS ) assert ( @@ -97,7 +99,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( local_name="wohand_signal_99", service_uuids=[] ) inject_advertisement_with_source( - hass, switchbot_device_signal_99, switchbot_adv_signal_99, "hci0" + hass, switchbot_device_signal_99, switchbot_adv_signal_99, HCI0_SOURCE_ADDRESS ) assert ( @@ -112,7 +114,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( local_name="wohand_good_signal", service_uuids=[] ) inject_advertisement_with_source( - hass, switchbot_device_signal_98, switchbot_adv_signal_98, "hci1" + hass, switchbot_device_signal_98, switchbot_adv_signal_98, HCI1_SOURCE_ADDRESS ) # should not switch to hci1 @@ -137,7 +139,10 @@ async def test_switching_adapters_based_on_rssi( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + HCI0_SOURCE_ADDRESS, ) assert ( @@ -150,7 +155,10 @@ async def test_switching_adapters_based_on_rssi( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) inject_advertisement_with_source( - hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci1" + hass, + switchbot_device_good_signal, + switchbot_adv_good_signal, + HCI1_SOURCE_ADDRESS, ) assert ( @@ -159,7 +167,10 @@ async def test_switching_adapters_based_on_rssi( ) inject_advertisement_with_source( - hass, switchbot_device_good_signal, switchbot_adv_poor_signal, "hci0" + hass, + switchbot_device_good_signal, + switchbot_adv_poor_signal, + HCI0_SOURCE_ADDRESS, ) assert ( bluetooth.async_ble_device_from_address(hass, address) @@ -175,7 +186,10 @@ async def test_switching_adapters_based_on_rssi( ) inject_advertisement_with_source( - hass, switchbot_device_similar_signal, switchbot_adv_similar_signal, "hci0" + hass, + switchbot_device_similar_signal, + switchbot_adv_similar_signal, + HCI0_SOURCE_ADDRESS, ) assert ( bluetooth.async_ble_device_from_address(hass, address) @@ -198,7 +212,7 @@ async def test_switching_adapters_based_on_zero_rssi( local_name="wohand_no_rssi", service_uuids=[], rssi=0 ) inject_advertisement_with_source( - hass, switchbot_device_no_rssi, switchbot_adv_no_rssi, "hci0" + hass, switchbot_device_no_rssi, switchbot_adv_no_rssi, HCI0_SOURCE_ADDRESS ) assert ( @@ -211,7 +225,10 @@ async def test_switching_adapters_based_on_zero_rssi( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) inject_advertisement_with_source( - hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci1" + hass, + switchbot_device_good_signal, + switchbot_adv_good_signal, + HCI1_SOURCE_ADDRESS, ) assert ( @@ -220,7 +237,7 @@ async def test_switching_adapters_based_on_zero_rssi( ) inject_advertisement_with_source( - hass, switchbot_device_good_signal, switchbot_adv_no_rssi, "hci0" + hass, switchbot_device_good_signal, switchbot_adv_no_rssi, HCI0_SOURCE_ADDRESS ) assert ( bluetooth.async_ble_device_from_address(hass, address) @@ -236,7 +253,10 @@ async def test_switching_adapters_based_on_zero_rssi( ) inject_advertisement_with_source( - hass, switchbot_device_similar_signal, switchbot_adv_similar_signal, "hci0" + hass, + switchbot_device_similar_signal, + switchbot_adv_similar_signal, + HCI0_SOURCE_ADDRESS, ) assert ( bluetooth.async_ble_device_from_address(hass, address) @@ -266,7 +286,7 @@ async def test_switching_adapters_based_on_stale( switchbot_device_poor_signal_hci0, switchbot_adv_poor_signal_hci0, start_time_monotonic, - "hci0", + HCI0_SOURCE_ADDRESS, ) assert ( @@ -285,7 +305,7 @@ async def test_switching_adapters_based_on_stale( switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, start_time_monotonic, - "hci1", + HCI1_SOURCE_ADDRESS, ) # Should not switch adapters until the advertisement is stale @@ -333,7 +353,7 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval( switchbot_device_poor_signal_hci0, switchbot_adv_poor_signal_hci0, start_time_monotonic, - "hci0", + HCI0_SOURCE_ADDRESS, ) assert ( @@ -354,7 +374,7 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval( switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, start_time_monotonic, - "hci1", + HCI1_SOURCE_ADDRESS, ) # Should not switch adapters until the advertisement is stale @@ -368,7 +388,7 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval( switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, start_time_monotonic + 10 + 1, - "hci1", + HCI1_SOURCE_ADDRESS, ) # Should not switch yet since we are not within the @@ -383,7 +403,7 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval( switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, start_time_monotonic + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1, - "hci1", + HCI1_SOURCE_ADDRESS, ) # Should switch to hci1 since the previous advertisement is stale # even though the signal is poor because the device is now @@ -404,7 +424,9 @@ async def test_restore_history_from_dbus( ble_device = generate_ble_device(address, "name") history = { address: AdvertisementHistory( - ble_device, generate_advertisement_data(local_name="name"), "hci0" + ble_device, + generate_advertisement_data(local_name="name"), + HCI0_SOURCE_ADDRESS, ) } @@ -440,7 +462,9 @@ async def test_restore_history_from_dbus_and_remote_adapters( ble_device = generate_ble_device(address, "name") history = { address: AdvertisementHistory( - ble_device, generate_advertisement_data(local_name="name"), "hci0" + ble_device, + generate_advertisement_data(local_name="name"), + HCI0_SOURCE_ADDRESS, ) } @@ -480,7 +504,9 @@ async def test_restore_history_from_dbus_and_corrupted_remote_adapters( ble_device = generate_ble_device(address, "name") history = { address: AdvertisementHistory( - ble_device, generate_advertisement_data(local_name="name"), "hci0" + ble_device, + generate_advertisement_data(local_name="name"), + HCI0_SOURCE_ADDRESS, ) } @@ -511,7 +537,12 @@ async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_time_and_source_connectable( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, now, "hci0", True + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + now, + HCI0_SOURCE_ADDRESS, + True, ) assert ( @@ -607,7 +638,7 @@ async def test_connectable_advertisement_can_be_retrieved_with_best_path_is_non_ switchbot_device_good_signal, switchbot_adv_good_signal, now, - "hci1", + HCI1_SOURCE_ADDRESS, False, ) @@ -622,7 +653,12 @@ async def test_connectable_advertisement_can_be_retrieved_with_best_path_is_non_ local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_time_and_source_connectable( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, now, "hci0", True + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + now, + HCI0_SOURCE_ADDRESS, + True, ) assert ( @@ -662,7 +698,10 @@ async def test_switching_adapters_when_one_goes_away( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + HCI0_SOURCE_ADDRESS, ) # We want to prefer the good signal when we have options @@ -674,7 +713,10 @@ async def test_switching_adapters_when_one_goes_away( cancel_hci2() inject_advertisement_with_source( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + HCI0_SOURCE_ADDRESS, ) # Now that hci2 is gone, we should prefer the poor signal @@ -713,7 +755,10 @@ async def test_switching_adapters_when_one_stop_scanning( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + HCI0_SOURCE_ADDRESS, ) # We want to prefer the good signal when we have options @@ -725,7 +770,10 @@ async def test_switching_adapters_when_one_stop_scanning( hci2_scanner.scanning = False inject_advertisement_with_source( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + HCI0_SOURCE_ADDRESS, ) # Now that hci2 has stopped scanning, we should prefer the poor signal diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py index c9670f2f895..d9289fe8380 100644 --- a/tests/components/bluetooth/test_websocket_api.py +++ b/tests/components/bluetooth/test_websocket_api.py @@ -5,19 +5,25 @@ from datetime import timedelta import time from unittest.mock import ANY, patch +from bleak_retry_connector import Allocations from freezegun import freeze_time import pytest +from homeassistant.components.bluetooth import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow from . import ( + HCI0_SOURCE_ADDRESS, + HCI1_SOURCE_ADDRESS, + NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, + _get_manager, generate_advertisement_data, generate_ble_device, inject_advertisement_with_source, ) -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator @@ -38,7 +44,7 @@ async def test_subscribe_advertisements( local_name="wohand_signal_100", service_uuids=[] ) inject_advertisement_with_source( - hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" + hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS ) client = await hass_ws_client() @@ -64,7 +70,7 @@ async def test_subscribe_advertisements( "rssi": -127, "service_data": {}, "service_uuids": [], - "source": "hci0", + "source": HCI0_SOURCE_ADDRESS, "time": ANY, "tx_power": -127, } @@ -79,7 +85,7 @@ async def test_subscribe_advertisements( rssi=-80, ) inject_advertisement_with_source( - hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci1" + hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI1_SOURCE_ADDRESS ) async with asyncio.timeout(1): response = await client.receive_json() @@ -93,7 +99,7 @@ async def test_subscribe_advertisements( "rssi": -80, "service_data": {}, "service_uuids": [], - "source": "hci1", + "source": HCI1_SOURCE_ADDRESS, "time": ANY, "tx_power": -127, } @@ -114,3 +120,161 @@ async def test_subscribe_advertisements( async with asyncio.timeout(1): response = await client.receive_json() assert response["event"] == {"remove": [{"address": "44:44:33:11:23:12"}]} + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_subscribe_connection_allocations( + hass: HomeAssistant, + register_hci0_scanner: None, + register_hci1_scanner: None, + register_non_connectable_scanner: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_connection_allocations.""" + address = "44:44:33:11:23:12" + + switchbot_device_signal_100 = generate_ble_device( + address, "wohand_signal_100", rssi=-100 + ) + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", service_uuids=[] + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS + ) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_connection_allocations", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + async with asyncio.timeout(1): + response = await client.receive_json() + + assert response["event"] == [ + { + "allocated": [], + "free": 0, + "slots": 0, + "source": NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, + } + ] + + manager = _get_manager() + manager.async_on_allocation_changed( + Allocations( + adapter="hci1", # Will be translated to source + slots=5, + free=4, + allocated=["AA:BB:CC:DD:EE:EE"], + ) + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == [ + { + "allocated": ["AA:BB:CC:DD:EE:EE"], + "free": 4, + "slots": 5, + "source": "AA:BB:CC:DD:EE:11", + } + ] + manager.async_on_allocation_changed( + Allocations( + adapter="hci1", # Will be translated to source + slots=5, + free=5, + allocated=[], + ) + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == [ + {"allocated": [], "free": 5, "slots": 5, "source": HCI1_SOURCE_ADDRESS} + ] + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_subscribe_connection_allocations_specific_scanner( + hass: HomeAssistant, + register_non_connectable_scanner: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_connection_allocations for a specific source address.""" + entry = MockConfigEntry( + domain=DOMAIN, unique_id=NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS + ) + entry.add_to_hass(hass) + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_connection_allocations", + "config_entry_id": entry.entry_id, + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + async with asyncio.timeout(1): + response = await client.receive_json() + + assert response["event"] == [ + { + "allocated": [], + "free": 0, + "slots": 0, + "source": NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, + } + ] + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_subscribe_connection_allocations_invalid_config_entry_id( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_connection_allocations for an invalid config entry id.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_connection_allocations", + "config_entry_id": "non_existent", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_config_entry_id" + assert response["error"]["message"] == "Config entry non_existent not found" + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_subscribe_connection_allocations_invalid_scanner( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_connection_allocations for an invalid source address.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id="invalid") + entry.add_to_hass(hass) + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_connection_allocations", + "config_entry_id": entry.entry_id, + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_source" + assert response["error"]["message"] == "Source invalid not found" From 5a53ed9e5b739cd627120cb48f7ed3c027b74944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 27 Jan 2025 23:51:40 +0000 Subject: [PATCH 1041/2987] Merge Whirlpool tests into a parameterized test (#136490) * Use fixtures in config flow tests for Whirlpool * Keep old tests; new one will go to separate PR * Merge Whirlpool tests into a parameterized test * Address review comments * Remove uneeded block wait calls --- .../components/whirlpool/test_config_flow.py | 155 ++++++++---------- 1 file changed, 67 insertions(+), 88 deletions(-) diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index 94a34c96e2c..e451fda82ad 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -20,9 +20,22 @@ CONFIG_INPUT = { } +@pytest.fixture(name="mock_whirlpool_setup_entry") +def fixture_mock_whirlpool_setup_entry(): + """Set up async_setup_entry fixture.""" + with patch( + "homeassistant.components.whirlpool.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") async def test_form( - hass: HomeAssistant, region, brand, mock_backend_selector_api: MagicMock + hass: HomeAssistant, + region, + brand, + mock_backend_selector_api: MagicMock, + mock_whirlpool_setup_entry: MagicMock, ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -32,14 +45,10 @@ async def test_form( assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - with patch( - "homeassistant.components.whirlpool.async_setup_entry", return_value=True - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "test-username" @@ -49,7 +58,7 @@ async def test_form( "region": region[0], "brand": brand[0], } - assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_whirlpool_setup_entry.mock_calls) == 1 mock_backend_selector_api.assert_called_once_with(brand[1], region[1]) @@ -70,19 +79,31 @@ async def test_form_invalid_auth( assert result2["errors"] == {"base": "invalid_auth"} -async def test_form_cannot_connect( +@pytest.mark.usefixtures("mock_appliances_manager_api") +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (aiohttp.ClientConnectionError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_auth_error( hass: HomeAssistant, + exception: Exception, + expected_error: str, region, brand, mock_auth_api: MagicMock, + mock_whirlpool_setup_entry: MagicMock, ) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_auth_api.return_value.do_auth.side_effect = aiohttp.ClientConnectionError - result2 = await hass.config_entries.flow.async_configure( + mock_auth_api.return_value.do_auth.side_effect = exception + result = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG_INPUT | { @@ -90,56 +111,25 @@ async def test_form_cannot_connect( "brand": brand[0], }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} - -async def test_form_auth_timeout( - hass: HomeAssistant, - region, - brand, - mock_auth_api: MagicMock, -) -> None: - """Test we handle auth timeout error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - mock_auth_api.return_value.do_auth.side_effect = TimeoutError - result2 = await hass.config_entries.flow.async_configure( + # Test that it succeeds after the error is cleared + mock_auth_api.return_value.do_auth.side_effect = None + result = await hass.config_entries.flow.async_configure( result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_generic_auth_exception( - hass: HomeAssistant, - region, - brand, - mock_auth_api: MagicMock, -) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, ) - mock_auth_api.return_value.do_auth.side_effect = Exception - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + "username": "test-username", + "password": "test-password", + "region": region[0], + "brand": brand[0], + } + assert len(mock_whirlpool_setup_entry.mock_calls) == 1 @pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") @@ -167,7 +157,6 @@ async def test_form_already_configured(hass: HomeAssistant, region, brand) -> No "brand": brand[0], }, ) - await hass.async_block_till_done() assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" @@ -191,13 +180,14 @@ async def test_no_appliances_flow( result["flow_id"], CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, ) - await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "no_appliances"} -@pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") +@pytest.mark.usefixtures( + "mock_auth_api", "mock_appliances_manager_api", "mock_whirlpool_setup_entry" +) async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: """Test a successful reauth flow.""" mock_entry = MockConfigEntry( @@ -213,14 +203,10 @@ async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch( - "homeassistant.components.whirlpool.async_setup_entry", return_value=True - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, + ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "reauth_successful" @@ -232,7 +218,7 @@ async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: } -@pytest.mark.usefixtures("mock_appliances_manager_api") +@pytest.mark.usefixtures("mock_appliances_manager_api", "mock_whirlpool_setup_entry") async def test_reauth_flow_auth_error( hass: HomeAssistant, region, brand, mock_auth_api: MagicMock ) -> None: @@ -251,20 +237,16 @@ async def test_reauth_flow_auth_error( assert result["errors"] == {} mock_auth_api.return_value.is_access_token_valid.return_value = False - with patch( - "homeassistant.components.whirlpool.async_setup_entry", return_value=True - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "invalid_auth"} -@pytest.mark.usefixtures("mock_appliances_manager_api") +@pytest.mark.usefixtures("mock_appliances_manager_api", "mock_whirlpool_setup_entry") async def test_reauth_flow_connnection_error( hass: HomeAssistant, region, brand, mock_auth_api: MagicMock ) -> None: @@ -284,13 +266,10 @@ async def test_reauth_flow_connnection_error( assert result["errors"] == {} mock_auth_api.return_value.do_auth.side_effect = ClientConnectionError - with patch( - "homeassistant.components.whirlpool.async_setup_entry", return_value=True - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, + ) + assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} From 517d258fb416379b44cdf2330e8e3335ee54f26c Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Mon, 27 Jan 2025 21:59:40 -0500 Subject: [PATCH 1042/2987] Increase LaCrosse View polling interval to 60 seconds (#136680) --- homeassistant/components/lacrosse_view/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lacrosse_view/const.py b/homeassistant/components/lacrosse_view/const.py index 900463cff6e..8750d1867e6 100644 --- a/homeassistant/components/lacrosse_view/const.py +++ b/homeassistant/components/lacrosse_view/const.py @@ -1,4 +1,4 @@ """Constants for the LaCrosse View integration.""" DOMAIN = "lacrosse_view" -SCAN_INTERVAL = 30 +SCAN_INTERVAL = 60 From 48a91540e1897051a11182c250cd6b8ff8619527 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jan 2025 19:04:46 -1000 Subject: [PATCH 1043/2987] Bump aioesphomeapi to 29.0.0 and bleak-esphome to 2.2.0 (#136684) --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 43f524516a8..bab62723c82 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.1.1"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.2.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 4682be1c5c7..ecc7afb3661 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,9 +16,9 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==28.0.1", + "aioesphomeapi==29.0.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.1.1" + "bleak-esphome==2.2.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e7e1b767fe4..8e5081db52d 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==28.0.1 +aioesphomeapi==29.0.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -591,7 +591,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.1.1 +bleak-esphome==2.2.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2d7a55f1a60..4a28323c95e 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==28.0.1 +aioesphomeapi==29.0.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -522,7 +522,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.1.1 +bleak-esphome==2.2.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 From 5690516852a4134a5445d5b2d888d0d1cca284da Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Jan 2025 00:12:42 -0500 Subject: [PATCH 1044/2987] ChatSession: Split native content out of message class (#136668) Split native content out of message class --- .../components/assist_pipeline/pipeline.py | 3 +- .../components/conversation/__init__.py | 11 +++- .../components/conversation/default_agent.py | 5 +- .../components/conversation/session.py | 36 +++++++------ .../openai_conversation/conversation.py | 26 +++++----- tests/components/conversation/test_session.py | 51 +++++++------------ 6 files changed, 59 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 9353bbe0007..9fdcc2bf690 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1101,11 +1101,10 @@ class PipelineRun: "speech", "" ) chat_session.async_add_message( - conversation.ChatMessage( + conversation.Content( role="assistant", agent_id=agent_id, content=speech, - native=intent_response, ) ) conversation_result = conversation.ConversationResult( diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 9c1db128f15..b110d53540c 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -48,21 +48,28 @@ from .default_agent import DefaultAgent, async_setup_default_agent from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http from .models import AbstractConversationAgent, ConversationInput, ConversationResult -from .session import ChatMessage, ChatSession, ConverseError, async_get_chat_session +from .session import ( + ChatSession, + Content, + ConverseError, + NativeContent, + async_get_chat_session, +) from .trace import ConversationTraceEventType, async_conversation_trace_append __all__ = [ "DOMAIN", "HOME_ASSISTANT_AGENT", "OLD_HOME_ASSISTANT_AGENT", - "ChatMessage", "ChatSession", + "Content", "ConversationEntity", "ConversationEntityFeature", "ConversationInput", "ConversationResult", "ConversationTraceEventType", "ConverseError", + "NativeContent", "async_conversation_trace_append", "async_converse", "async_get_agent_info", diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index bb815698941..be0387555dc 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -62,7 +62,7 @@ from .const import ( ) from .entity import ConversationEntity from .models import ConversationInput, ConversationResult -from .session import ChatMessage, async_get_chat_session +from .session import Content, async_get_chat_session from .trace import ConversationTraceEventType, async_conversation_trace_append _LOGGER = logging.getLogger(__name__) @@ -374,11 +374,10 @@ class DefaultAgent(ConversationEntity): speech: str = response.speech.get("plain", {}).get("speech", "") chat_session.async_add_message( - ChatMessage( + Content( role="assistant", agent_id=user_input.agent_id, content=speech, - native=response, ) ) diff --git a/homeassistant/components/conversation/session.py b/homeassistant/components/conversation/session.py index 2235459954f..43f4cbf427c 100644 --- a/homeassistant/components/conversation/session.py +++ b/homeassistant/components/conversation/session.py @@ -126,7 +126,7 @@ async def async_get_chat_session( else: history = ChatSession(hass, conversation_id, user_input.agent_id) - message: ChatMessage = ChatMessage( + message: Content = Content( role="user", agent_id=user_input.agent_id, content=user_input.text, @@ -169,23 +169,21 @@ class ConverseError(HomeAssistantError): @dataclass -class ChatMessage[_NativeT]: - """Base class for chat messages. +class Content: + """Base class for chat messages.""" - When role is native, the content is to be ignored and message - is only meant for storing the native object. - """ - - role: Literal["system", "assistant", "user", "native"] + role: Literal["system", "assistant", "user"] agent_id: str | None content: str - native: _NativeT | None = field(default=None) - # Validate in post-init that if role is native, there is no content and a native object exists - def __post_init__(self) -> None: - """Validate native message.""" - if self.role == "native" and self.native is None: - raise ValueError("Native message must have a native object") + +@dataclass(frozen=True) +class NativeContent[_NativeT]: + """Native content.""" + + role: str = field(init=False, default="native") + agent_id: str + content: _NativeT @dataclass @@ -196,15 +194,15 @@ class ChatSession[_NativeT]: conversation_id: str agent_id: str | None user_name: str | None = None - messages: list[ChatMessage[_NativeT]] = field( - default_factory=lambda: [ChatMessage(role="system", agent_id=None, content="")] + messages: list[Content | NativeContent[_NativeT]] = field( + default_factory=lambda: [Content(role="system", agent_id=None, content="")] ) extra_system_prompt: str | None = None llm_api: llm.APIInstance | None = None last_updated: datetime = field(default_factory=dt_util.utcnow) @callback - def async_add_message(self, message: ChatMessage[_NativeT]) -> None: + def async_add_message(self, message: Content | NativeContent[_NativeT]) -> None: """Process intent.""" if message.role == "system": raise ValueError("Cannot add system messages to history") @@ -216,7 +214,7 @@ class ChatSession[_NativeT]: @callback def async_get_messages( self, agent_id: str | None = None - ) -> list[ChatMessage[_NativeT]]: + ) -> list[Content | NativeContent[_NativeT]]: """Get messages for a specific agent ID. This will filter out any native message tied to other agent IDs. @@ -328,7 +326,7 @@ class ChatSession[_NativeT]: self.llm_api = llm_api self.user_name = user_name self.extra_system_prompt = extra_system_prompt - self.messages[0] = ChatMessage( + self.messages[0] = Content( role="system", agent_id=user_input.agent_id, content=prompt, diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 1464f4224d7..2f35bea97e2 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -93,12 +93,13 @@ def _message_convert(message: ChatCompletionMessage) -> ChatCompletionMessagePar def _chat_message_convert( - message: conversation.ChatMessage[ChatCompletionMessageParam], - agent_id: str | None, + message: conversation.Content + | conversation.NativeContent[ChatCompletionMessageParam], ) -> ChatCompletionMessageParam: """Convert any native chat message for this agent to the native format.""" - if message.native is not None and message.agent_id == agent_id: - return message.native + if message.role == "native": + # mypy doesn't understand that checking role ensures content type + return message.content # type: ignore[return-value] return cast( ChatCompletionMessageParam, {"role": message.role, "content": message.content}, @@ -157,14 +158,15 @@ class OpenAIConversationEntity( async with conversation.async_get_chat_session( self.hass, user_input ) as session: - return await self._async_call_api(user_input, session) + return await self._async_handle_message(user_input, session) - async def _async_call_api( + async def _async_handle_message( self, user_input: conversation.ConversationInput, session: conversation.ChatSession[ChatCompletionMessageParam], ) -> conversation.ConversationResult: """Call the API.""" + assert user_input.agent_id options = self.entry.options try: @@ -185,8 +187,7 @@ class OpenAIConversationEntity( ] messages = [ - _chat_message_convert(message, user_input.agent_id) - for message in session.async_get_messages() + _chat_message_convert(message) for message in session.async_get_messages() ] client = self.entry.runtime_data @@ -212,11 +213,10 @@ class OpenAIConversationEntity( messages.append(_message_convert(response)) session.async_add_message( - conversation.ChatMessage( + conversation.Content( role=response.role, agent_id=user_input.agent_id, content=response.content or "", - native=messages[-1], ), ) @@ -237,11 +237,9 @@ class OpenAIConversationEntity( ) ) session.async_add_message( - conversation.ChatMessage( - role="native", + conversation.NativeContent( agent_id=user_input.agent_id, - content="", - native=messages[-1], + content=messages[-1], ) ) diff --git a/tests/components/conversation/test_session.py b/tests/components/conversation/test_session.py index bca19b3b06a..60c7f2957b8 100644 --- a/tests/components/conversation/test_session.py +++ b/tests/components/conversation/test_session.py @@ -82,7 +82,7 @@ async def test_cleanup( assert chat_session.conversation_id != conversation_id conversation_id = chat_session.conversation_id chat_session.async_add_message( - session.ChatMessage( + session.Content( role="assistant", agent_id="mock-agent-id", content="Hey!", @@ -127,12 +127,6 @@ async def test_cleanup( assert len(chat_session.messages) == 2 -def test_chat_message() -> None: - """Test chat message.""" - with pytest.raises(ValueError): - session.ChatMessage(role="native", agent_id=None, content="", native=None) - - async def test_add_message( hass: HomeAssistant, mock_conversation_input: ConversationInput ) -> None: @@ -144,7 +138,7 @@ async def test_add_message( with pytest.raises(ValueError): chat_session.async_add_message( - session.ChatMessage(role="system", agent_id=None, content="") + session.Content(role="system", agent_id=None, content="") ) # No 2 user messages in a row @@ -152,19 +146,19 @@ async def test_add_message( with pytest.raises(ValueError): chat_session.async_add_message( - session.ChatMessage(role="user", agent_id=None, content="") + session.Content(role="user", agent_id=None, content="") ) # No 2 assistant messages in a row chat_session.async_add_message( - session.ChatMessage(role="assistant", agent_id=None, content="") + session.Content(role="assistant", agent_id=None, content="") ) assert len(chat_session.messages) == 3 assert chat_session.messages[-1].role == "assistant" with pytest.raises(ValueError): chat_session.async_add_message( - session.ChatMessage(role="assistant", agent_id=None, content="") + session.Content(role="assistant", agent_id=None, content="") ) @@ -177,12 +171,12 @@ async def test_message_filtering( ) as chat_session: messages = chat_session.async_get_messages(agent_id=None) assert len(messages) == 2 - assert messages[0] == session.ChatMessage( + assert messages[0] == session.Content( role="system", agent_id=None, content="", ) - assert messages[1] == session.ChatMessage( + assert messages[1] == session.Content( role="user", agent_id="mock-agent-id", content=mock_conversation_input.text, @@ -190,7 +184,7 @@ async def test_message_filtering( # Cannot add a second user message in a row with pytest.raises(ValueError): chat_session.async_add_message( - session.ChatMessage( + session.Content( role="user", agent_id="mock-agent-id", content="Hey!", @@ -198,31 +192,25 @@ async def test_message_filtering( ) chat_session.async_add_message( - session.ChatMessage( + session.Content( role="assistant", agent_id="mock-agent-id", content="Hey!", - native="assistant-reply-native", ) ) # Different agent, native messages will be filtered out. chat_session.async_add_message( - session.ChatMessage( - role="native", agent_id="another-mock-agent-id", content="", native=1 - ) + session.NativeContent(agent_id="another-mock-agent-id", content=1) ) chat_session.async_add_message( - session.ChatMessage( - role="native", agent_id="mock-agent-id", content="", native=1 - ) + session.NativeContent(agent_id="mock-agent-id", content=1) ) # A non-native message from another agent is not filtered out. chat_session.async_add_message( - session.ChatMessage( + session.Content( role="assistant", agent_id="another-mock-agent-id", content="Hi!", - native=1, ) ) @@ -231,17 +219,14 @@ async def test_message_filtering( messages = chat_session.async_get_messages(agent_id="mock-agent-id") assert len(messages) == 5 - assert messages[2] == session.ChatMessage( + assert messages[2] == session.Content( role="assistant", agent_id="mock-agent-id", content="Hey!", - native="assistant-reply-native", ) - assert messages[3] == session.ChatMessage( - role="native", agent_id="mock-agent-id", content="", native=1 - ) - assert messages[4] == session.ChatMessage( - role="assistant", agent_id="another-mock-agent-id", content="Hi!", native=1 + assert messages[3] == session.NativeContent(agent_id="mock-agent-id", content=1) + assert messages[4] == session.Content( + role="assistant", agent_id="another-mock-agent-id", content="Hi!" ) @@ -361,7 +346,7 @@ async def test_extra_systen_prompt( user_llm_prompt=None, ) chat_session.async_add_message( - session.ChatMessage( + session.Content( role="assistant", agent_id="mock-agent-id", content="Hey!", @@ -401,7 +386,7 @@ async def test_extra_systen_prompt( user_llm_prompt=None, ) chat_session.async_add_message( - session.ChatMessage( + session.Content( role="assistant", agent_id="mock-agent-id", content="Hey!", From 0cd7aff6ea33cec626d4e01dfce033e1ff945b3e Mon Sep 17 00:00:00 2001 From: Artem Sorokin Date: Tue, 28 Jan 2025 10:37:39 +0300 Subject: [PATCH 1045/2987] Add power/energy sensor for Matter draft electrical measurement cluster (#132920) --- homeassistant/components/matter/sensor.py | 84 ++++++ tests/components/matter/conftest.py | 1 + .../fixtures/nodes/yandex_smart_socket.json | 278 ++++++++++++++++++ .../matter/snapshots/test_button.ambr | 47 +++ .../matter/snapshots/test_select.ambr | 59 ++++ .../matter/snapshots/test_sensor.ambr | 162 ++++++++++ .../matter/snapshots/test_switch.ambr | 47 +++ tests/components/matter/test_sensor.py | 28 ++ 8 files changed, 706 insertions(+) create mode 100644 tests/components/matter/fixtures/nodes/yandex_smart_socket.json diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 77b51d2dfbb..39e11a683f5 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -10,6 +10,7 @@ from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue from matter_server.client.models import device_types from matter_server.common.custom_clusters import ( + DraftElectricalMeasurementCluster, EveCluster, NeoCluster, ThirdRealityMeteringCluster, @@ -105,6 +106,35 @@ class MatterSensor(MatterEntity, SensorEntity): self._attr_native_value = value +class MatterDraftElectricalMeasurementSensor(MatterEntity, SensorEntity): + """Representation of a Matter sensor for Matter 1.0 draft ElectricalMeasurement cluster.""" + + entity_description: MatterSensorEntityDescription + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + raw_value: Nullable | float | None + divisor: Nullable | float | None + multiplier: Nullable | float | None + + raw_value, divisor, multiplier = ( + self.get_matter_attribute_value(self._entity_info.attributes_to_watch[0]), + self.get_matter_attribute_value(self._entity_info.attributes_to_watch[1]), + self.get_matter_attribute_value(self._entity_info.attributes_to_watch[2]), + ) + + for value in (divisor, multiplier): + if value in (None, NullValue, 0): + self._attr_native_value = None + return + + if raw_value in (None, NullValue): + self._attr_native_value = None + else: + self._attr_native_value = round(raw_value / divisor * multiplier, 2) + + class MatterOperationalStateSensor(MatterSensor): """Representation of a sensor for Matter Operational State.""" @@ -641,6 +671,60 @@ DISCOVERY_SCHEMAS = [ clusters.ElectricalEnergyMeasurement.Attributes.CumulativeEnergyImported, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalMeasurementActivePower", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterDraftElectricalMeasurementSensor, + required_attributes=( + DraftElectricalMeasurementCluster.Attributes.ActivePower, + DraftElectricalMeasurementCluster.Attributes.AcPowerDivisor, + DraftElectricalMeasurementCluster.Attributes.AcPowerMultiplier, + ), + absent_clusters=(clusters.ElectricalPowerMeasurement,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalMeasurementRmsVoltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterDraftElectricalMeasurementSensor, + required_attributes=( + DraftElectricalMeasurementCluster.Attributes.RmsVoltage, + DraftElectricalMeasurementCluster.Attributes.AcVoltageDivisor, + DraftElectricalMeasurementCluster.Attributes.AcVoltageMultiplier, + ), + absent_clusters=(clusters.ElectricalPowerMeasurement,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalMeasurementRmsCurrent", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterDraftElectricalMeasurementSensor, + required_attributes=( + DraftElectricalMeasurementCluster.Attributes.RmsCurrent, + DraftElectricalMeasurementCluster.Attributes.AcCurrentDivisor, + DraftElectricalMeasurementCluster.Attributes.AcCurrentMultiplier, + ), + absent_clusters=(clusters.ElectricalPowerMeasurement,), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index 4e078f86939..d7429f6087d 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -116,6 +116,7 @@ async def integration_fixture( "window_covering_pa_lift", "window_covering_pa_tilt", "window_covering_tilt", + "yandex_smart_socket", ] ) async def matter_devices( diff --git a/tests/components/matter/fixtures/nodes/yandex_smart_socket.json b/tests/components/matter/fixtures/nodes/yandex_smart_socket.json new file mode 100644 index 00000000000..26cdf38414f --- /dev/null +++ b/tests/components/matter/fixtures/nodes/yandex_smart_socket.json @@ -0,0 +1,278 @@ +{ + "node_id": 4, + "date_commissioned": "2024-12-05T10:54:31.635203", + "last_interview": "2024-12-05T12:16:52.038776", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 52, 54, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "Yandex", + "0/40/2": 5130, + "0/40/3": "YNDX-00540", + "0/40/4": 540, + "0/40/5": "", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v0.4", + "0/40/9": 18, + "0/40/10": "8.0.r13402545-18", + "0/40/15": "HP000RM000V4RW", + "0/40/17": true, + "0/40/18": "E4480D32A5480B29", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 17, 18, 19, 65528, 65529, 65531, + 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "**REDACTED**", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "**REDACTED**", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "PAtP8Nse", + "5": ["wKgAEw=="], + "6": ["/oAAAAAAAAA+C0///vDbHg==", "/YrmoeskHZU+C0///vDbHg=="], + "7": 1 + } + ], + "0/51/1": 4, + "0/51/2": 124, + "0/51/3": 0, + "0/51/4": 1, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/1": 79260, + "0/52/2": 171268, + "0/52/65532": 0, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [], + "0/52/65531": [1, 2, 65528, 65529, 65531, 65532, 65533], + "0/54/0": "eJoYDvok", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 11, + "0/54/4": -53, + "0/54/65532": 0, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBBgkBwEkCAEwCUEEvOt9COzrjgf+b8q6FcKeKfbtqJybToVtEF0jiidbqg8FPmTIPTm1kU9hEiE6sd2N/GWSQHRoMi3YNl19h1PM3zcKNQEoARgkAgE2AwQCBAEYMAQUSu0+nQ/nOzrUNECyeBAqGPVu33YwBRS2PEiS/N109emRL3DTMaiWoWrEShgwC0DoGPCGt0HeGYnTS4TS2R7vbNhiFuuIrUQuxY5phP/UXBZosBDQTsnRTbMof18OkeO68MEcLXdIXjBJvBDaP/TsGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEz8nO5tz0gMFM5TW4YjYXGxkhr/UKHZg1rCa21StYqGd0wGaP7a5eMR+2BY20D1b11R7i6teWKnAaW+WqY0vQTjcKNQEpARgkAmAwBBS2PEiS/N109emRL3DTMaiWoWrESjAFFG//dFS5V0Y6/QdSQcC+z7idKKeJGDALQGFBsf7Ecq44e7NN8dCZIoJMUG16rmwD4ZtHtD4JPTxYabEreeblNF2ZDSgbo+A8sfz7Ci37WjznxbEj96vR8MgY", + "254": 3 + } + ], + "0/62/1": [ + { + "1": "BLB0QnDldRPfV2xt6Nd/34ja8uaWwvsLYZsF3yCdIwyB/krYZ0u1uBS0FTo7E3iqvN0cDZ7fbhw0OUsKTVZ9Y10=", + "2": 65521, + "3": 1, + "4": 4, + "5": "", + "254": 3 + } + ], + "0/62/2": 5, + "0/62/3": 4, + "0/62/4": [ + "FTABAQAkAgE3AyYU3a/iASYVn7wdXBgmBKaW1SwkBQA3BiYU3a/iASYVn7wdXBgkBwEkCAEwCUEE/OyhHiUZDgJ7iUVCKouxsZgI0DGBcK8E+vbDIHD5gfeFPNuT5sXN8aHlsEl7fZhfjbdEbIFudeJKIr5uf7+PLTcKNQEpARgkAmAwBBQtr6wAOFJ7UJLwYUKvomZh5wPaszAFFC2vrAA4UntQkvBhQq+iZmHnA9qzGDALQM5/1ziQdNcMURJqGH+j9wt7w/wPyeq8zf+u3FGgmmfhBSouJw4f+TIJLk7m/eQD0p2Q5rSDEuuwI2VBTxxeuWgY", + "FTABAQAkAgE3AycUe5hjm9Wdt4YmFewk8wUYJgTGN00tJAUANwYnFHuYY5vVnbeGJhXsJPMFGCQHASQIATAJQQR56PnGPW5p1dXhHDSVnjoah8C2+JYHzPAm5tvYgup9gf7DukH2TxxLdDEaBdD4hgQj/R8hrMYSmj8XmHQ8HhdZNwo1ASkBGCQCYDAEFN8wYcjYskj9OSQoEXkOn0QmWDrkMAUU3zBhyNiySP05JCgReQ6fRCZYOuQYMAtA+j7ir4H1KYIxAe49jhZr/Gg7pDUKtIcYyUVJD0g9egIYHShM1y1j3BsOQTBX6mnLPp4FS4AtNsUgaM+XPKSFSxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEsHRCcOV1E99XbG3o13/fiNry5pbC+wthmwXfIJ0jDIH+SthnS7W4FLQVOjsTeKq83RwNnt9uHDQ5SwpNVn1jXTcKNQEpARgkAmAwBBRv/3RUuVdGOv0HUkHAvs+4nSiniTAFFG//dFS5V0Y6/QdSQcC+z7idKKeJGDALQKrvVhoinxo07C2nI/zakt4xUZKgab6DVI4mBXYoPQXaZM8jmEqWboPnLBUGbr9UAnqEc9yARHwlC77eXN1BCdUY", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEMmvMdDf/h+u7fawdjIe6gXEeWuszCShR8ulsHMLnJYTMHVrkztOcj4cHw6haH/q909aVmL3xLlbEC2lZtmZClDcKNQEpARgkAmAwBBRoZjEcSXeh6IFBtW0A2OilJBdeYjAFFGhmMRxJd6HogUG1bQDY6KUkF15iGDALQJm5+/SkVrR4iBpGVqZZGOH+DpS+cQYqceN1+JSnDFwxJe+khYxFifMohSQ5NLlTiJQTZWYpqMKMZHT36pWWADUY" + ], + "0/62/5": 3, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": true, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": 0, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "0": 266, + "1": 2 + } + ], + "1/29/1": [3, 4, 6, 29, 2820, 336264194, 336264195], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/2820/0": 9, + "1/2820/1285": 2170, + "1/2820/1288": 592, + "1/2820/1291": 560, + "1/2820/1536": 1, + "1/2820/1537": 10, + "1/2820/1538": 1, + "1/2820/1539": 1000, + "1/2820/1540": 1, + "1/2820/1541": 8, + "1/2820/2049": 2530, + "1/2820/2050": 16300, + "1/2820/65532": 0, + "1/2820/65533": 3, + "1/2820/65528": [], + "1/2820/65529": [], + "1/2820/65531": [ + 0, 1285, 1288, 1291, 1536, 1537, 1538, 1539, 1540, 1541, 2049, 2050, + 65528, 65529, 65531, 65532, 65533 + ], + "1/336264194/336199680": 44, + "1/336264194/336199681": 0, + "1/336264194/336199682": 0, + "1/336264194/336199698": 70, + "1/336264194/65532": 0, + "1/336264194/65533": 1, + "1/336264194/65528": [], + "1/336264194/65529": [], + "1/336264194/65531": [ + 65528, 65529, 65531, 336199680, 336199681, 336199682, 336199698, 65532, + 65533 + ], + "1/336264195/336199680": 0, + "1/336264195/65532": 0, + "1/336264195/65533": 1, + "1/336264195/65528": [], + "1/336264195/65529": [], + "1/336264195/65531": [65528, 65529, 65531, 336199680, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 7973f1a5147..dbbc984ab2f 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -1819,3 +1819,50 @@ 'state': 'unknown', }) # --- +# name: test_buttons[yandex_smart_socket][button.yndx_00540_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.yndx_00540_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[yandex_smart_socket][button.yndx_00540_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'YNDX-00540 Identify', + }), + 'context': , + 'entity_id': 'button.yndx_00540_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 19a90503086..9a2639ba7e1 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -1852,3 +1852,62 @@ 'state': 'Quick', }) # --- +# name: test_selects[yandex_smart_socket][select.yndx_00540_power_on_behavior_on_startup-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'off', + 'toggle', + 'previous', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.yndx_00540_power_on_behavior_on_startup', + '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': 'Power-on behavior on startup', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'startup_on_off', + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[yandex_smart_socket][select.yndx_00540_power_on_behavior_on_startup-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'YNDX-00540 Power-on behavior on startup', + 'options': list([ + 'on', + 'off', + 'toggle', + 'previous', + ]), + }), + 'context': , + 'entity_id': 'select.yndx_00540_power_on_behavior_on_startup', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 5e22b9a1476..0215abf47c6 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -3354,3 +3354,165 @@ 'state': '28.3', }) # --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.yndx_00540_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ElectricalMeasurementRmsCurrent-2820-1288', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'YNDX-00540 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yndx_00540_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.59', + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.yndx_00540_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ElectricalMeasurementActivePower-2820-1291', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'YNDX-00540 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yndx_00540_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70.0', + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.yndx_00540_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ElectricalMeasurementRmsVoltage-2820-1285', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'YNDX-00540 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yndx_00540_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '217.0', + }) +# --- diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index 612e81580a5..8277ee28838 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -421,3 +421,50 @@ 'state': 'on', }) # --- +# name: test_switches[yandex_smart_socket][switch.yndx_00540-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.yndx_00540', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterPlug-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[yandex_smart_socket][switch.yndx_00540-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'YNDX-00540', + }), + 'context': , + 'entity_id': 'switch.yndx_00540', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index bd3e146264a..8a5fbf48a49 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -351,3 +351,31 @@ async def test_operational_state_sensor( state = hass.states.get("sensor.dishwasher_operational_state") assert state assert state.state == "extra_state" + + +@pytest.mark.parametrize("node_fixture", ["yandex_smart_socket"]) +async def test_draft_electrical_measurement_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test Draft Electrical Measurement cluster sensors, using Yandex Smart Socket fixture.""" + state = hass.states.get("sensor.yndx_00540_power") + assert state + assert state.state == "70.0" + + # AcPowerDivisor + set_node_attribute(matter_node, 1, 2820, 1541, 0) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.yndx_00540_power") + assert state + assert state.state == "unknown" + + # ActivePower + set_node_attribute(matter_node, 1, 2820, 1291, None) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.yndx_00540_power") + assert state + assert state.state == "unknown" From b43379be7d1d96a7d6d5d506b34e364cba6c1737 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 08:48:34 +0100 Subject: [PATCH 1046/2987] Standardize `helpers.xxx_registry` imports (#136688) Standardize registry imports --- homeassistant/components/acmeda/__init__.py | 2 +- homeassistant/components/ambient_station/__init__.py | 3 +-- homeassistant/components/analytics/analytics.py | 2 +- homeassistant/components/aprilaire/coordinator.py | 2 +- homeassistant/components/assist_pipeline/logbook.py | 2 +- homeassistant/components/bang_olufsen/__init__.py | 2 +- homeassistant/components/bang_olufsen/diagnostics.py | 2 +- homeassistant/components/cloud/assist_pipeline.py | 2 +- homeassistant/components/deconz/logbook.py | 2 +- homeassistant/components/esphome/entity.py | 3 +-- homeassistant/components/esphome/manager.py | 3 +-- homeassistant/components/fully_kiosk/services.py | 2 +- homeassistant/components/fyta/coordinator.py | 2 +- homeassistant/components/group/notify.py | 2 +- homeassistant/components/hue/v2/group.py | 2 +- .../components/hunterdouglas_powerview/__init__.py | 2 +- homeassistant/components/hunterdouglas_powerview/entity.py | 2 +- homeassistant/components/isy994/__init__.py | 7 +++++-- homeassistant/components/isy994/util.py | 2 +- homeassistant/components/lamarzocco/coordinator.py | 2 +- homeassistant/components/matter/entity.py | 2 +- homeassistant/components/minecraft_server/__init__.py | 3 +-- homeassistant/components/nasweb/switch.py | 2 +- homeassistant/components/octoprint/__init__.py | 2 +- homeassistant/components/pvpc_hourly_pricing/__init__.py | 2 +- homeassistant/components/ring/config_flow.py | 2 +- homeassistant/components/roomba/entity.py | 2 +- homeassistant/components/sabnzbd/__init__.py | 3 +-- homeassistant/components/schlage/coordinator.py | 2 +- homeassistant/components/solarlog/coordinator.py | 2 +- homeassistant/components/sonos/entity.py | 2 +- homeassistant/components/switchbee/__init__.py | 3 +-- homeassistant/components/tedee/coordinator.py | 2 +- homeassistant/components/tradfri/__init__.py | 2 +- homeassistant/components/unifi/device_tracker.py | 2 +- homeassistant/components/unifi/switch.py | 2 +- homeassistant/components/unifiprotect/entity.py | 2 +- homeassistant/components/withings/binary_sensor.py | 2 +- homeassistant/components/withings/calendar.py | 2 +- homeassistant/components/youtube/__init__.py | 2 +- homeassistant/components/zha/logbook.py | 2 +- homeassistant/components/zwave_js/api.py | 3 +-- homeassistant/components/zwave_js/logbook.py | 2 +- tests/components/bsblan/test_climate.py | 2 +- tests/components/bsblan/test_sensor.py | 2 +- tests/components/bsblan/test_water_heater.py | 2 +- tests/components/cloud/test_repairs.py | 2 +- tests/components/dhcp/test_init.py | 2 +- tests/components/esphome/test_assist_satellite.py | 7 +++++-- tests/components/esphome/test_media_player.py | 2 +- tests/components/fan/test_init.py | 2 +- tests/components/flexit_bacnet/test_climate.py | 2 +- tests/components/group/test_sensor.py | 3 +-- tests/components/hassio/test_repairs.py | 2 +- tests/components/home_connect/test_binary_sensor.py | 2 +- tests/components/home_connect/test_switch.py | 2 +- tests/components/html5/test_config_flow.py | 2 +- tests/components/html5/test_init.py | 2 +- .../components/hunterdouglas_powerview/test_config_flow.py | 2 +- tests/components/lcn/test_binary_sensor.py | 3 +-- tests/components/lcn/test_services.py | 2 +- tests/components/lock/test_init.py | 2 +- tests/components/madvr/test_binary_sensor.py | 2 +- tests/components/madvr/test_remote.py | 2 +- tests/components/madvr/test_sensor.py | 2 +- tests/components/matter/test_lock.py | 2 +- tests/components/melissa/test_climate.py | 2 +- tests/components/min_max/test_sensor.py | 2 +- tests/components/netatmo/common.py | 2 +- tests/components/netatmo/test_button.py | 2 +- tests/components/netatmo/test_camera.py | 2 +- tests/components/netatmo/test_climate.py | 2 +- tests/components/netatmo/test_cover.py | 2 +- tests/components/netatmo/test_fan.py | 2 +- tests/components/netatmo/test_light.py | 2 +- tests/components/netatmo/test_select.py | 2 +- tests/components/netatmo/test_switch.py | 2 +- tests/components/plugwise/test_sensor.py | 2 +- tests/components/plugwise/test_switch.py | 2 +- tests/components/proximity/test_init.py | 3 +-- tests/components/schlage/test_init.py | 2 +- tests/components/sun/test_sensor.py | 2 +- tests/components/tod/test_binary_sensor.py | 2 +- tests/components/velbus/test_services.py | 2 +- tests/components/zwave_js/test_repairs.py | 3 +-- tests/components/zwave_js/test_select.py | 2 +- 86 files changed, 94 insertions(+), 99 deletions(-) diff --git a/homeassistant/components/acmeda/__init__.py b/homeassistant/components/acmeda/__init__.py index 62a62795a05..ec7abe258cf 100644 --- a/homeassistant/components/acmeda/__init__.py +++ b/homeassistant/components/acmeda/__init__.py @@ -3,7 +3,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .hub import PulseHub diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 469ad7e6e06..374c313a144 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -17,9 +17,8 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send -import homeassistant.helpers.entity_registry as er from .const import ( ATTR_LAST_DATA, diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index b63475c80a4..9260642a58f 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -27,8 +27,8 @@ from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.entity_registry as er from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.storage import Store from homeassistant.helpers.system_info import async_get_system_info diff --git a/homeassistant/components/aprilaire/coordinator.py b/homeassistant/components/aprilaire/coordinator.py index 6b132cfcc95..a5126eda95e 100644 --- a/homeassistant/components/aprilaire/coordinator.py +++ b/homeassistant/components/aprilaire/coordinator.py @@ -11,7 +11,7 @@ from pyaprilaire.const import MODELS, Attribute, FunctionalDomain from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol diff --git a/homeassistant/components/assist_pipeline/logbook.py b/homeassistant/components/assist_pipeline/logbook.py index 50c5176bb22..b7ab24d2f2f 100644 --- a/homeassistant/components/assist_pipeline/logbook.py +++ b/homeassistant/components/assist_pipeline/logbook.py @@ -7,7 +7,7 @@ from collections.abc import Callable from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from .const import DOMAIN, EVENT_RECORDING diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py index b80e625e8d4..eab2bb3d4e5 100644 --- a/homeassistant/components/bang_olufsen/__init__.py +++ b/homeassistant/components/bang_olufsen/__init__.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MODEL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.util.ssl import get_default_context from .const import DOMAIN diff --git a/homeassistant/components/bang_olufsen/diagnostics.py b/homeassistant/components/bang_olufsen/diagnostics.py index cab7eae5e25..bf7b06e694a 100644 --- a/homeassistant/components/bang_olufsen/diagnostics.py +++ b/homeassistant/components/bang_olufsen/diagnostics.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import BangOlufsenConfigEntry from .const import DOMAIN diff --git a/homeassistant/components/cloud/assist_pipeline.py b/homeassistant/components/cloud/assist_pipeline.py index c97e5bdc0a2..0e3736d9da8 100644 --- a/homeassistant/components/cloud/assist_pipeline.py +++ b/homeassistant/components/cloud/assist_pipeline.py @@ -14,7 +14,7 @@ from homeassistant.components.stt import DOMAIN as STT_DOMAIN from homeassistant.components.tts import DOMAIN as TTS_DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .const import ( DATA_PLATFORMS_SETUP, diff --git a/homeassistant/components/deconz/logbook.py b/homeassistant/components/deconz/logbook.py index 3ef14eca657..28dfb603d8b 100644 --- a/homeassistant/components/deconz/logbook.py +++ b/homeassistant/components/deconz/logbook.py @@ -7,7 +7,7 @@ from collections.abc import Callable from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME from homeassistant.const import ATTR_DEVICE_ID, CONF_EVENT, CONF_ID from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from .const import CONF_GESTURE, DOMAIN as DECONZ_DOMAIN from .deconz_event import CONF_DECONZ_ALARM_EVENT, CONF_DECONZ_EVENT diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 455a3f8d105..ae9e0d2491d 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -19,9 +19,8 @@ import voluptuous as vol from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform +from homeassistant.helpers import device_registry as dr, entity_platform import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index b382622281e..494df51721a 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -41,9 +41,8 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import TemplateError -from homeassistant.helpers import template +from homeassistant.helpers import device_registry as dr, template import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.issue_registry import ( diff --git a/homeassistant/components/fully_kiosk/services.py b/homeassistant/components/fully_kiosk/services.py index 089ae1d4246..bff78aa627a 100644 --- a/homeassistant/components/fully_kiosk/services.py +++ b/homeassistant/components/fully_kiosk/services.py @@ -8,8 +8,8 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr from .const import ( ATTR_APPLICATION, diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py index 553960bdcc6..a0c42d449d5 100644 --- a/homeassistant/components/fyta/coordinator.py +++ b/homeassistant/components/fyta/coordinator.py @@ -19,7 +19,7 @@ from fyta_cli.fyta_models import Plant from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_EXPIRATION, DOMAIN diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index fdef327cb73..5bba2a677d5 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -28,9 +28,9 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .entity import GroupEntity diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index c7f966ce9f2..17cd20b55aa 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -24,9 +24,9 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from homeassistant.util import color as color_util from ..bridge import HueBridge diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index d9358db2753..b4bbc37b1e8 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -11,7 +11,7 @@ from aiopvapi.shades import Shades from homeassistant.const import CONF_API_VERSION, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .const import DOMAIN, HUB_EXCEPTIONS from .coordinator import PowerviewShadeUpdateCoordinator diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py index ba572ecefce..f2a841a7d0e 100644 --- a/homeassistant/components/hunterdouglas_powerview/entity.py +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -4,7 +4,7 @@ import logging from aiopvapi.resources.shade import BaseShade, ShadePosition -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index d2862054971..738c7e2d5ad 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -21,8 +21,11 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_validation as cv -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import ( + aiohttp_client, + config_validation as cv, + device_registry as dr, +) from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from .const import ( diff --git a/homeassistant/components/isy994/util.py b/homeassistant/components/isy994/util.py index ed1a5abca8b..ca5c5ea46a9 100644 --- a/homeassistant/components/isy994/util.py +++ b/homeassistant/components/isy994/util.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .const import _LOGGER, DOMAIN diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index 2385039f53d..dddca6565e4 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index a6d0dbb08d8..96696193466 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -20,9 +20,9 @@ from propcache.api import cached_property from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription -import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import UndefinedType from .const import DOMAIN, FEATUREMAP_ATTRIBUTE_ID, ID_TYPE_DEVICE_ID diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index f937c304471..f1392ea488a 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -20,8 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.device_registry as dr -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType from .const import DOMAIN, KEY_LATENCY, KEY_MOTD diff --git a/homeassistant/components/nasweb/switch.py b/homeassistant/components/nasweb/switch.py index 00e5a21da18..c5a9e085b83 100644 --- a/homeassistant/components/nasweb/switch.py +++ b/homeassistant/components/nasweb/switch.py @@ -10,9 +10,9 @@ from webio_api import Output as NASwebOutput from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntity from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( BaseCoordinatorEntity, diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index 7a9f3990435..2b081eae45a 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -28,8 +28,8 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify as util_slugify from homeassistant.util.ssl import get_default_context, get_default_no_verify_context diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py index 6327164e3c8..4d120e9fae7 100644 --- a/homeassistant/components/pvpc_hourly_pricing/__init__.py +++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py @@ -3,7 +3,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .const import ATTR_POWER, ATTR_POWER_P3, DOMAIN from .coordinator import ElecPricesDataUpdateCoordinator diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index a23fd8f73de..7d5654947d8 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -23,8 +23,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import get_auth_user_agent diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py index d55a260e53a..69e8d5b5414 100644 --- a/homeassistant/components/roomba/entity.py +++ b/homeassistant/components/roomba/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant.const import ATTR_CONNECTIONS -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index fee459340f3..1f68781a3a2 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -11,8 +11,7 @@ import voluptuous as vol from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import config_validation as cv -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py index b319b21be0c..936ef9ee91e 100644 --- a/homeassistant/components/schlage/coordinator.py +++ b/homeassistant/components/schlage/coordinator.py @@ -13,7 +13,7 @@ from pyschlage.log import LockLog from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, UPDATE_INTERVAL diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index 11f268db32a..bf2bc849111 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -19,8 +19,8 @@ from solarlog_cli.solarlog_models import SolarlogData from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 98dc8b8b752..a9a76b3b4d0 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -8,7 +8,7 @@ import logging from soco.core import SoCo -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index b1a71665222..a2a3ecf0df9 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -13,9 +13,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr -import homeassistant.helpers.entity_registry as er from .const import DOMAIN from .coordinator import SwitchBeeCoordinator diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index f9ebb29dd04..fec59d1c596 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -22,8 +22,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 0060310e6c2..92ed2ea8b82 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 2ac47e67913..eebffc63277 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -24,9 +24,9 @@ from homeassistant.components.device_tracker import ( ScannerEntityDescription, ) from homeassistant.core import Event as core_Event, HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er import homeassistant.util.dt as dt_util from . import UnifiConfigEntry diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 7741e57c82c..91e4a0222f6 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -44,9 +44,9 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from . import UnifiConfigEntry from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 335bc1e933d..90804559297 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -22,7 +22,7 @@ from uiprotect.data import ( ) from homeassistant.core import callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 691026ccb9a..856aeeffc5c 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from . import WithingsConfigEntry from .const import DOMAIN diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py index acab0fa5c40..ac867fbfdca 100644 --- a/homeassistant/components/withings/calendar.py +++ b/homeassistant/components/withings/calendar.py @@ -10,8 +10,8 @@ from aiowithings import WithingsClient, WorkoutCategory from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from . import DOMAIN, WithingsConfigEntry from .coordinator import WithingsWorkoutDataUpdateCoordinator diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py index 8460a105fcb..aee4b83508c 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -8,11 +8,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -import homeassistant.helpers.device_registry as dr from .api import AsyncConfigEntryAuth from .const import AUTH, COORDINATOR, DOMAIN diff --git a/homeassistant/components/zha/logbook.py b/homeassistant/components/zha/logbook.py index 3de81e1255d..05539a063d2 100644 --- a/homeassistant/components/zha/logbook.py +++ b/homeassistant/components/zha/logbook.py @@ -10,7 +10,7 @@ from zha.application.const import ZHA_EVENT from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from .const import DOMAIN as ZHA_DOMAIN from .helpers import async_get_zha_device_proxy diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 1a1cd6ae9c1..37ce9a51c91 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -70,9 +70,8 @@ from homeassistant.components.websocket_api import ( ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from .config_validation import BITMASK_SCHEMA diff --git a/homeassistant/components/zwave_js/logbook.py b/homeassistant/components/zwave_js/logbook.py index 315793b9726..120084788e1 100644 --- a/homeassistant/components/zwave_js/logbook.py +++ b/homeassistant/components/zwave_js/logbook.py @@ -9,7 +9,7 @@ from zwave_js_server.const import CommandClass from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from .const import ( ATTR_COMMAND_CLASS, diff --git a/tests/components/bsblan/test_climate.py b/tests/components/bsblan/test_climate.py index 7ee12c5fa1a..41d566fc375 100644 --- a/tests/components/bsblan/test_climate.py +++ b/tests/components/bsblan/test_climate.py @@ -22,7 +22,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_with_selected_platforms diff --git a/tests/components/bsblan/test_sensor.py b/tests/components/bsblan/test_sensor.py index c95671a1a6b..ba2af40f319 100644 --- a/tests/components/bsblan/test_sensor.py +++ b/tests/components/bsblan/test_sensor.py @@ -7,7 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_with_selected_platforms diff --git a/tests/components/bsblan/test_water_heater.py b/tests/components/bsblan/test_water_heater.py index ed920774aa5..173498b14ff 100644 --- a/tests/components/bsblan/test_water_heater.py +++ b/tests/components/bsblan/test_water_heater.py @@ -20,7 +20,7 @@ from homeassistant.components.water_heater import ( from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_with_selected_platforms diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py index d165a129dbe..d131d211e2f 100644 --- a/tests/components/cloud/test_repairs.py +++ b/tests/components/cloud/test_repairs.py @@ -12,7 +12,7 @@ from homeassistant.components.cloud.repairs import ( ) from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 9f3435f0cd9..76f15eb3e51 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -31,7 +31,7 @@ from homeassistant.const import ( STATE_NOT_HOME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 5ca333df1e2..30535236970 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -48,8 +48,11 @@ from homeassistant.components.select import ( ) from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, intent as intent_helper -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + intent as intent_helper, +) from homeassistant.helpers.entity_component import EntityComponent from .conftest import MockESPHomeDevice diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index 42b7e72a06e..a425b730771 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -38,7 +38,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from .conftest import MockESPHomeDevice diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index 90061ec60a1..0ab7686a68b 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -12,7 +12,7 @@ from homeassistant.components.fan import ( NotValidPresetModeError, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .common import MockFan diff --git a/tests/components/flexit_bacnet/test_climate.py b/tests/components/flexit_bacnet/test_climate.py index 7b0546f60ea..79ee84bdc14 100644 --- a/tests/components/flexit_bacnet/test_climate.py +++ b/tests/components/flexit_bacnet/test_climate.py @@ -6,7 +6,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_with_selected_platforms diff --git a/tests/components/group/test_sensor.py b/tests/components/group/test_sensor.py index de406cb251c..187991141e7 100644 --- a/tests/components/group/test_sensor.py +++ b/tests/components/group/test_sensor.py @@ -35,8 +35,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import get_fixture_path diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index f8cac4e1a97..4c4f0e24dcc 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -17,7 +17,7 @@ from aiohasupervisor.models import ( import pytest from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from .test_init import MOCK_ENVIRON diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index b564b003af6..8e108cc2b0a 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -22,8 +22,8 @@ from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.entity_component import async_update_entity -import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_json_object_fixture diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 9d54feeaa54..80bfcf9db96 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -33,7 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from .conftest import get_all_appliances diff --git a/tests/components/html5/test_config_flow.py b/tests/components/html5/test_config_flow.py index ca0b3da0389..3cde435771e 100644 --- a/tests/components/html5/test_config_flow.py +++ b/tests/components/html5/test_config_flow.py @@ -17,7 +17,7 @@ from homeassistant.components.html5.issues import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir MOCK_CONF = { ATTR_VAPID_EMAIL: "test@example.com", diff --git a/tests/components/html5/test_init.py b/tests/components/html5/test_init.py index 290cb381296..840890f18d1 100644 --- a/tests/components/html5/test_init.py +++ b/tests/components/html5/test_init.py @@ -1,7 +1,7 @@ """Test the HTML5 setup.""" from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py index cf159c23bae..5a48e08e5db 100644 --- a/tests/components/hunterdouglas_powerview/test_config_flow.py +++ b/tests/components/hunterdouglas_powerview/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.hunterdouglas_powerview.const import DOMAIN from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo diff --git a/tests/components/lcn/test_binary_sensor.py b/tests/components/lcn/test_binary_sensor.py index 2f64f421b93..7d636f546c4 100644 --- a/tests/components/lcn/test_binary_sensor.py +++ b/tests/components/lcn/test_binary_sensor.py @@ -15,8 +15,7 @@ from homeassistant.components.lcn.helpers import get_device_connection from homeassistant.components.script import scripts_with_entity from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from .conftest import MockConfigEntry, init_integration diff --git a/tests/components/lcn/test_services.py b/tests/components/lcn/test_services.py index cd97e3484e3..c9eda40fdba 100644 --- a/tests/components/lcn/test_services.py +++ b/tests/components/lcn/test_services.py @@ -31,7 +31,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from .conftest import ( diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 68af8c7d482..510034a2172 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -21,7 +21,7 @@ from homeassistant.components.lock import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .conftest import MockLock diff --git a/tests/components/madvr/test_binary_sensor.py b/tests/components/madvr/test_binary_sensor.py index 469a3225ca0..9ddbc7b3afe 100644 --- a/tests/components/madvr/test_binary_sensor.py +++ b/tests/components/madvr/test_binary_sensor.py @@ -9,7 +9,7 @@ from syrupy import SnapshotAssertion from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_integration from .conftest import get_update_callback diff --git a/tests/components/madvr/test_remote.py b/tests/components/madvr/test_remote.py index 6fc507534d6..1ddbacdb6e9 100644 --- a/tests/components/madvr/test_remote.py +++ b/tests/components/madvr/test_remote.py @@ -20,7 +20,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_integration from .const import ( diff --git a/tests/components/madvr/test_sensor.py b/tests/components/madvr/test_sensor.py index ddc01fc737a..dd1722913f2 100644 --- a/tests/components/madvr/test_sensor.py +++ b/tests/components/madvr/test_sensor.py @@ -10,7 +10,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.madvr.sensor import get_temperature from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_integration from .conftest import get_update_callback diff --git a/tests/components/matter/test_lock.py b/tests/components/matter/test_lock.py index 7bcfd381d6c..bb03b296fc6 100644 --- a/tests/components/matter/test_lock.py +++ b/tests/components/matter/test_lock.py @@ -11,7 +11,7 @@ from homeassistant.components.lock import LockEntityFeature, LockState from homeassistant.const import ATTR_CODE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import ( set_node_attribute, diff --git a/tests/components/melissa/test_climate.py b/tests/components/melissa/test_climate.py index ceb14faf8fb..b305d629a91 100644 --- a/tests/components/melissa/test_climate.py +++ b/tests/components/melissa/test_climate.py @@ -10,7 +10,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_integration diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py index c875697bf2f..a7a70043d94 100644 --- a/tests/components/min_max/test_sensor.py +++ b/tests/components/min_max/test_sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import get_fixture_path diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 730cb0cb117..9110f8c724f 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -11,7 +11,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.webhook import async_handle_webhook from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.util.aiohttp import MockRequest from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/netatmo/test_button.py b/tests/components/netatmo/test_button.py index 681e42af051..bffecf7d83a 100644 --- a/tests/components/netatmo/test_button.py +++ b/tests/components/netatmo/test_button.py @@ -8,7 +8,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, snapshot_platform_entities diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 43904ed8f71..32f20544043 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -19,7 +19,7 @@ from homeassistant.components.netatmo.const import ( from homeassistant.const import CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from .common import ( diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index dc0312f7acd..18c811fd76b 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -41,7 +41,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from .common import selected_platforms, simulate_webhook, snapshot_platform_entities diff --git a/tests/components/netatmo/test_cover.py b/tests/components/netatmo/test_cover.py index 509c1de736e..9368a564afb 100644 --- a/tests/components/netatmo/test_cover.py +++ b/tests/components/netatmo/test_cover.py @@ -14,7 +14,7 @@ from homeassistant.components.cover import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, snapshot_platform_entities diff --git a/tests/components/netatmo/test_fan.py b/tests/components/netatmo/test_fan.py index 989ea1ac364..3dbc8b3a6f5 100644 --- a/tests/components/netatmo/test_fan.py +++ b/tests/components/netatmo/test_fan.py @@ -11,7 +11,7 @@ from homeassistant.components.fan import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, snapshot_platform_entities diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index c90d67e7630..0932395b8ec 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -12,7 +12,7 @@ from homeassistant.components.light import ( from homeassistant.components.netatmo import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import ( FAKE_WEBHOOK_ACTIVATION, diff --git a/tests/components/netatmo/test_select.py b/tests/components/netatmo/test_select.py index 274113405f6..458115f8f5c 100644 --- a/tests/components/netatmo/test_select.py +++ b/tests/components/netatmo/test_select.py @@ -17,7 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, simulate_webhook, snapshot_platform_entities diff --git a/tests/components/netatmo/test_switch.py b/tests/components/netatmo/test_switch.py index dd82fad3d08..837f6201b1e 100644 --- a/tests/components/netatmo/test_switch.py +++ b/tests/components/netatmo/test_switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, snapshot_platform_entities diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py index b3243d6b127..11aa68bded7 100644 --- a/tests/components/plugwise/test_sensor.py +++ b/tests/components/plugwise/test_sensor.py @@ -7,8 +7,8 @@ import pytest from homeassistant.components.plugwise.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -import homeassistant.helpers.entity_registry as er from tests.common import MockConfigEntry diff --git a/tests/components/plugwise/test_switch.py b/tests/components/plugwise/test_switch.py index fa8a8a434e7..003c47ed1f4 100644 --- a/tests/components/plugwise/test_switch.py +++ b/tests/components/plugwise/test_switch.py @@ -17,7 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index eeb181e0670..22a546e6abe 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -15,8 +15,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.util import slugify from tests.common import MockConfigEntry diff --git a/tests/components/schlage/test_init.py b/tests/components/schlage/test_init.py index 57a139e582e..97da66c7e93 100644 --- a/tests/components/schlage/test_init.py +++ b/tests/components/schlage/test_init.py @@ -12,7 +12,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.schlage.const import DOMAIN, UPDATE_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceRegistry from . import MockSchlageConfigEntry diff --git a/tests/components/sun/test_sensor.py b/tests/components/sun/test_sensor.py index cb97ae565c7..495a97b88fe 100644 --- a/tests/components/sun/test_sensor.py +++ b/tests/components/sun/test_sensor.py @@ -10,7 +10,7 @@ import pytest from homeassistant.components import sun from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util diff --git a/tests/components/tod/test_binary_sensor.py b/tests/components/tod/test_binary_sensor.py index b4b6b13d8e3..47e64353004 100644 --- a/tests/components/tod/test_binary_sensor.py +++ b/tests/components/tod/test_binary_sensor.py @@ -7,7 +7,7 @@ import pytest from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.sun import get_astral_event_date, get_astral_event_next from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util diff --git a/tests/components/velbus/test_services.py b/tests/components/velbus/test_services.py index 2bcbac7b80d..94ba91e6dc3 100644 --- a/tests/components/velbus/test_services.py +++ b/tests/components/velbus/test_services.py @@ -18,7 +18,7 @@ from homeassistant.components.velbus.const import ( from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from . import init_integration diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index d237a6e410a..a46320168eb 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -10,8 +10,7 @@ from zwave_js_server.model.node import Node from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import device_registry as dr, issue_registry as ir from tests.components.repairs import ( async_process_repairs_platforms, diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index ddfd205b017..d26cccbc7d5 100644 --- a/tests/components/zwave_js/test_select.py +++ b/tests/components/zwave_js/test_select.py @@ -10,7 +10,7 @@ from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import replace_value_of_zwave_value From 1ad2598c6f549d2c4c38bc7887b7d6ff3c3777cc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 08:48:59 +0100 Subject: [PATCH 1047/2987] Use runtime_data in ecoforest (#136689) --- homeassistant/components/ecoforest/__init__.py | 17 ++++++----------- .../components/ecoforest/coordinator.py | 8 +++++++- homeassistant/components/ecoforest/number.py | 8 +++----- homeassistant/components/ecoforest/sensor.py | 10 +++++----- homeassistant/components/ecoforest/switch.py | 8 +++----- tests/components/ecoforest/conftest.py | 2 +- 6 files changed, 25 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/ecoforest/__init__.py b/homeassistant/components/ecoforest/__init__.py index 4d5aaa40576..e5350beba8e 100644 --- a/homeassistant/components/ecoforest/__init__.py +++ b/homeassistant/components/ecoforest/__init__.py @@ -11,20 +11,18 @@ from pyecoforest.exceptions import ( EcoforestConnectionError, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN -from .coordinator import EcoforestCoordinator +from .coordinator import EcoforestConfigEntry, EcoforestCoordinator PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EcoforestConfigEntry) -> bool: """Set up Ecoforest from a config entry.""" host = entry.data[CONF_HOST] @@ -41,20 +39,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Error communicating with device %s", host) raise ConfigEntryNotReady from err - coordinator = EcoforestCoordinator(hass, api) + coordinator = EcoforestCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = 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: EcoforestConfigEntry) -> 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(entry, PLATFORMS) diff --git a/homeassistant/components/ecoforest/coordinator.py b/homeassistant/components/ecoforest/coordinator.py index 3b04325bd50..603fde38388 100644 --- a/homeassistant/components/ecoforest/coordinator.py +++ b/homeassistant/components/ecoforest/coordinator.py @@ -6,6 +6,7 @@ from pyecoforest.api import EcoforestApi from pyecoforest.exceptions import EcoforestError from pyecoforest.models.device import Device +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -13,16 +14,21 @@ from .const import POLLING_INTERVAL _LOGGER = logging.getLogger(__name__) +type EcoforestConfigEntry = ConfigEntry[EcoforestCoordinator] + class EcoforestCoordinator(DataUpdateCoordinator[Device]): """DataUpdateCoordinator to gather data from ecoforest device.""" - def __init__(self, hass: HomeAssistant, api: EcoforestApi) -> None: + def __init__( + self, hass: HomeAssistant, entry: EcoforestConfigEntry, api: EcoforestApi + ) -> None: """Initialize DataUpdateCoordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, name="ecoforest", update_interval=POLLING_INTERVAL, ) diff --git a/homeassistant/components/ecoforest/number.py b/homeassistant/components/ecoforest/number.py index db3275c1fcc..878c150343e 100644 --- a/homeassistant/components/ecoforest/number.py +++ b/homeassistant/components/ecoforest/number.py @@ -8,12 +8,10 @@ from dataclasses import dataclass from pyecoforest.models.device import Device from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import EcoforestCoordinator +from .coordinator import EcoforestConfigEntry from .entity import EcoforestEntity @@ -38,11 +36,11 @@ NUMBER_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcoforestConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Ecoforest number platform.""" - coordinator: EcoforestCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data entities = [ EcoforestNumberEntity(coordinator, description) diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py index 997b02436cc..0babb476ab6 100644 --- a/homeassistant/components/ecoforest/sensor.py +++ b/homeassistant/components/ecoforest/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UnitOfPressure, @@ -24,8 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN -from .coordinator import EcoforestCoordinator +from .coordinator import EcoforestConfigEntry from .entity import EcoforestEntity _LOGGER = logging.getLogger(__name__) @@ -143,10 +141,12 @@ SENSOR_TYPES: tuple[EcoforestSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EcoforestConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Ecoforest sensor platform.""" - coordinator: EcoforestCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [ EcoforestSensor(coordinator, description) for description in SENSOR_TYPES diff --git a/homeassistant/components/ecoforest/switch.py b/homeassistant/components/ecoforest/switch.py index d643217bebc..de52248e751 100644 --- a/homeassistant/components/ecoforest/switch.py +++ b/homeassistant/components/ecoforest/switch.py @@ -10,12 +10,10 @@ from pyecoforest.api import EcoforestApi from pyecoforest.models.device import Device from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import EcoforestCoordinator +from .coordinator import EcoforestConfigEntry from .entity import EcoforestEntity @@ -39,11 +37,11 @@ SWITCH_TYPES: tuple[EcoforestSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcoforestConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Ecoforest switch platform.""" - coordinator: EcoforestCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data entities = [ EcoforestSwitchEntity(coordinator, description) for description in SWITCH_TYPES diff --git a/tests/components/ecoforest/conftest.py b/tests/components/ecoforest/conftest.py index 85bfff08bdf..8678cfd4d05 100644 --- a/tests/components/ecoforest/conftest.py +++ b/tests/components/ecoforest/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch from pyecoforest.models.device import Alarm, Device, OperationMode, State import pytest -from homeassistant.components.ecoforest import DOMAIN +from homeassistant.components.ecoforest.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant From b1fec51e2f8f3699a3183e7e68cbec235c918c4b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 28 Jan 2025 01:54:36 -0800 Subject: [PATCH 1048/2987] Update roborock tests to patch client before test setup (#136587) --- tests/components/roborock/conftest.py | 19 +++++++++- tests/components/roborock/test_switch.py | 46 +++++++++++------------- tests/components/roborock/test_time.py | 32 ++++++++--------- 3 files changed, 53 insertions(+), 44 deletions(-) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index d65bf7c61d7..4df5f479b7c 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -2,7 +2,8 @@ from collections.abc import Generator from copy import deepcopy -from unittest.mock import patch +from typing import Any +from unittest.mock import Mock, patch import pytest from roborock import RoborockCategory, RoomMapping @@ -139,6 +140,22 @@ def bypass_api_fixture() -> None: yield +@pytest.fixture(name="send_message_side_effect") +def send_message_side_effect_fixture() -> Any: + """Fixture to return a side effect for the send_message method.""" + return None + + +@pytest.fixture(name="mock_send_message") +def mock_send_message_fixture(send_message_side_effect: Any) -> Mock: + """Fixture to mock the send_message method.""" + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command", + side_effect=send_message_side_effect, + ) as mock_send_message: + yield mock_send_message + + @pytest.fixture def bypass_api_fixture_v1_only(bypass_api_fixture) -> None: """Bypass api for tests that require only having v1 devices.""" diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index 2476bfe497c..e2df9a3498f 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -1,6 +1,6 @@ """Test Roborock Switch platform.""" -from unittest.mock import patch +from unittest.mock import Mock import pytest import roborock @@ -29,6 +29,7 @@ def platforms() -> list[Platform]: ) async def test_update_success( hass: HomeAssistant, + mock_send_message: Mock, bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, @@ -36,27 +37,22 @@ async def test_update_success( """Test turning switch entities on and off.""" # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command" - ) as mock_send_message: - await hass.services.async_call( - "switch", - SERVICE_TURN_ON, - service_data=None, - blocking=True, - target={"entity_id": entity_id}, - ) + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) assert mock_send_message.assert_called_once - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_message" - ) as mock_send_message: - await hass.services.async_call( - "switch", - SERVICE_TURN_OFF, - service_data=None, - blocking=True, - target={"entity_id": entity_id}, - ) + mock_send_message.reset_mock() + await hass.services.async_call( + "switch", + SERVICE_TURN_OFF, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) assert mock_send_message.assert_called_once @@ -67,8 +63,12 @@ async def test_update_success( ("switch.roborock_s7_maxv_status_indicator_light", SERVICE_TURN_OFF), ], ) +@pytest.mark.parametrize( + "send_message_side_effect", [roborock.exceptions.RoborockTimeout] +) async def test_update_failed( hass: HomeAssistant, + mock_send_message: Mock, bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, @@ -78,10 +78,6 @@ async def test_update_failed( # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command", - side_effect=roborock.exceptions.RoborockTimeout, - ) as mock_send_message, pytest.raises(HomeAssistantError, match="Failed to update Roborock options"), ): await hass.services.async_call( diff --git a/tests/components/roborock/test_time.py b/tests/components/roborock/test_time.py index eb48e8e537f..9c0a53893ed 100644 --- a/tests/components/roborock/test_time.py +++ b/tests/components/roborock/test_time.py @@ -1,7 +1,7 @@ """Test Roborock Time platform.""" from datetime import time -from unittest.mock import patch +from unittest.mock import Mock import pytest import roborock @@ -29,6 +29,7 @@ def platforms() -> list[Platform]: ) async def test_update_success( hass: HomeAssistant, + mock_send_message: Mock, bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, @@ -36,16 +37,13 @@ async def test_update_success( """Test turning switch entities on and off.""" # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command" - ) as mock_send_message: - await hass.services.async_call( - "time", - SERVICE_SET_VALUE, - service_data={"time": time(hour=1, minute=1)}, - blocking=True, - target={"entity_id": entity_id}, - ) + await hass.services.async_call( + "time", + SERVICE_SET_VALUE, + service_data={"time": time(hour=1, minute=1)}, + blocking=True, + target={"entity_id": entity_id}, + ) assert mock_send_message.assert_called_once @@ -55,8 +53,12 @@ async def test_update_success( ("time.roborock_s7_maxv_do_not_disturb_begin"), ], ) +@pytest.mark.parametrize( + "send_message_side_effect", [roborock.exceptions.RoborockTimeout] +) async def test_update_failure( hass: HomeAssistant, + mock_send_message: Mock, bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, @@ -64,13 +66,7 @@ async def test_update_failure( """Test turning switch entities on and off.""" # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None - with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command", - side_effect=roborock.exceptions.RoborockTimeout, - ) as mock_send_message, - pytest.raises(HomeAssistantError, match="Failed to update Roborock options"), - ): + with pytest.raises(HomeAssistantError, match="Failed to update Roborock options"): await hass.services.async_call( "time", SERVICE_SET_VALUE, From 5d55dcf3922d39b2c22a460cd59ce6e886d8a7a3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 10:58:10 +0100 Subject: [PATCH 1049/2987] Use runtime_data in electrasmart (#136696) --- .../components/electrasmart/__init__.py | 32 ++++++++++--------- .../components/electrasmart/climate.py | 8 +++-- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/electrasmart/__init__.py b/homeassistant/components/electrasmart/__init__.py index b8e5eb1bdd8..27cebc9aee9 100644 --- a/homeassistant/components/electrasmart/__init__.py +++ b/homeassistant/components/electrasmart/__init__.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import cast - from electrasmart.api import ElectraAPI, ElectraApiError from homeassistant.config_entries import ConfigEntry @@ -12,36 +10,40 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_IMEI, DOMAIN +from .const import CONF_IMEI PLATFORMS: list[Platform] = [Platform.CLIMATE] +type ElectraSmartConfigEntry = ConfigEntry[ElectraAPI] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, entry: ElectraSmartConfigEntry +) -> bool: """Set up Electra Smart Air Conditioner from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.data[DOMAIN][entry.entry_id] = ElectraAPI( + api = ElectraAPI( async_get_clientsession(hass), entry.data[CONF_IMEI], entry.data[CONF_TOKEN] ) - try: - await cast(ElectraAPI, hass.data[DOMAIN][entry.entry_id]).fetch_devices() + await api.fetch_devices() except ElectraApiError as exp: raise ConfigEntryNotReady(f"Error communicating with API: {exp}") from exp + entry.async_on_unload(entry.add_update_listener(update_listener)) + entry.runtime_data = api 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: ElectraSmartConfigEntry +) -> 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(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: ElectraSmartConfigEntry +) -> None: """Update listener.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py index 04e4742554b..84def436dfb 100644 --- a/homeassistant/components/electrasmart/climate.py +++ b/homeassistant/components/electrasmart/climate.py @@ -24,13 +24,13 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import ElectraSmartConfigEntry from .const import ( API_DELAY, CONSECUTIVE_FAILURE_THRESHOLD, @@ -89,10 +89,12 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ElectraSmartConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Add Electra AC devices.""" - api: ElectraAPI = hass.data[DOMAIN][entry.entry_id] + api = entry.runtime_data _LOGGER.debug("Discovered %i Electra devices", len(api.devices)) async_add_entities( From b1a4ba7b7cdee75d2da9367fd61ad1fd2fa21cec Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 28 Jan 2025 03:21:46 -0700 Subject: [PATCH 1050/2987] Update config flow tests for litterrobot (#136658) Co-authored-by: Joostlek --- .../components/litterrobot/quality_scale.yaml | 7 +- .../litterrobot/test_config_flow.py | 102 ++++++------------ 2 files changed, 32 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/litterrobot/quality_scale.yaml b/homeassistant/components/litterrobot/quality_scale.yaml index 3eae5d3e668..d5f943943bc 100644 --- a/homeassistant/components/litterrobot/quality_scale.yaml +++ b/homeassistant/components/litterrobot/quality_scale.yaml @@ -23,12 +23,7 @@ rules: comment: | hub.py should be renamed to coordinator.py and updated accordingly Also should not need to return bool (never used) - config-flow-test-coverage: - status: todo - comment: | - Fix stale title and docstring - Make sure every test ends in either ABORT or CREATE_ENTRY - so we also test that the flow is able to recover + config-flow-test-coverage: done config-flow: done dependency-transparency: done docs-actions: diff --git a/tests/components/litterrobot/test_config_flow.py b/tests/components/litterrobot/test_config_flow.py index 2eadafb0d0c..caaf832b780 100644 --- a/tests/components/litterrobot/test_config_flow.py +++ b/tests/components/litterrobot/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import patch from pylitterbot import Account from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException +import pytest from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD @@ -15,9 +16,8 @@ from .common import CONF_USERNAME, CONFIG, DOMAIN from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, mock_account) -> None: - """Test we get the form.""" - +async def test_full_flow(hass: HomeAssistant, mock_account) -> None: + """Test full flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -34,19 +34,18 @@ async def test_form(hass: HomeAssistant, mock_account) -> None: return_value=True, ) as mock_setup_entry, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG[DOMAIN] ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == CONFIG[DOMAIN][CONF_USERNAME] - assert result2["data"] == CONFIG[DOMAIN] + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == CONFIG[DOMAIN][CONF_USERNAME] + assert result["data"] == CONFIG[DOMAIN] assert len(mock_setup_entry.mock_calls) == 1 async def test_already_configured(hass: HomeAssistant) -> None: - """Test we handle already configured.""" + """Test already configured case.""" MockConfigEntry( domain=DOMAIN, data=CONFIG[DOMAIN], @@ -62,71 +61,32 @@ async def test_already_configured(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" +@pytest.mark.parametrize( + ("side_effect", "connect_errors"), + [ + (Exception, {"base": "unknown"}), + (LitterRobotLoginException, {"base": "invalid_auth"}), + (LitterRobotException, {"base": "cannot_connect"}), + ], +) +async def test_create_entry( + hass: HomeAssistant, mock_account, side_effect, connect_errors +) -> None: + """Test creating an entry.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( "homeassistant.components.litterrobot.config_flow.Account.connect", - side_effect=LitterRobotLoginException, + side_effect=side_effect, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG[DOMAIN] ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.litterrobot.config_flow.Account.connect", - side_effect=LitterRobotException, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], CONFIG[DOMAIN] - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_unknown_error(hass: HomeAssistant) -> None: - """Test we handle unknown error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "homeassistant.components.litterrobot.config_flow.Account.connect", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], CONFIG[DOMAIN] - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} - - -async def test_step_reauth(hass: HomeAssistant, mock_account: Account) -> None: - """Test the reauth flow.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG[DOMAIN], - ) - entry.add_to_hass(hass) - - result = await entry.start_reauth_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" + assert result["errors"] == connect_errors with ( patch( @@ -136,19 +96,19 @@ async def test_step_reauth(hass: HomeAssistant, mock_account: Account) -> None: patch( "homeassistant.components.litterrobot.async_setup_entry", return_value=True, - ) as mock_setup_entry, + ), ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_PASSWORD: CONFIG[DOMAIN][CONF_PASSWORD]}, + result["flow_id"], CONFIG[DOMAIN] ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - assert len(mock_setup_entry.mock_calls) == 1 + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == CONFIG[DOMAIN][CONF_USERNAME] + assert result["data"] == CONFIG[DOMAIN] -async def test_step_reauth_failed(hass: HomeAssistant, mock_account: Account) -> None: - """Test the reauth flow fails and recovers.""" +async def test_reauth(hass: HomeAssistant, mock_account: Account) -> None: + """Test reauth flow (with fail and recover).""" entry = MockConfigEntry( domain=DOMAIN, data=CONFIG[DOMAIN], From ff73545a8690d4b2c563445c65811624f79f45de Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:25:27 +0100 Subject: [PATCH 1051/2987] Use runtime_data in econet (#136691) --- homeassistant/components/econet/__init__.py | 24 +++++++++---------- .../components/econet/binary_sensor.py | 9 +++---- homeassistant/components/econet/climate.py | 10 ++++---- homeassistant/components/econet/const.py | 2 -- homeassistant/components/econet/sensor.py | 9 +++---- homeassistant/components/econet/switch.py | 7 +++--- .../components/econet/water_heater.py | 9 +++---- tests/components/econet/test_config_flow.py | 2 +- 8 files changed, 37 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 4fd920a5ecc..40bece93599 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -6,7 +6,7 @@ import logging from aiohttp.client_exceptions import ClientError from pyeconet import EcoNetApiInterface -from pyeconet.equipment import EquipmentType +from pyeconet.equipment import Equipment, EquipmentType from pyeconet.errors import ( GenericHTTPError, InvalidCredentialsError, @@ -21,7 +21,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from .const import API_CLIENT, DOMAIN, EQUIPMENT, PUSH_UPDATE +from .const import PUSH_UPDATE _LOGGER = logging.getLogger(__name__) @@ -36,7 +36,12 @@ PLATFORMS = [ INTERVAL = timedelta(minutes=60) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +type EconetConfigEntry = ConfigEntry[dict[EquipmentType, list[Equipment]]] + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: EconetConfigEntry +) -> bool: """Set up EcoNet as config entry.""" email = config_entry.data[CONF_EMAIL] @@ -57,9 +62,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) except (ClientError, GenericHTTPError, InvalidResponseFormat) as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {API_CLIENT: {}, EQUIPMENT: {}}) - hass.data[DOMAIN][API_CLIENT][config_entry.entry_id] = api - hass.data[DOMAIN][EQUIPMENT][config_entry.entry_id] = equipment + + config_entry.runtime_data = equipment await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -89,10 +93,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EconetConfigEntry) -> bool: """Unload a EcoNet config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN][API_CLIENT].pop(entry.entry_id) - hass.data[DOMAIN][EQUIPMENT].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/econet/binary_sensor.py b/homeassistant/components/econet/binary_sensor.py index 0f5cb6f92af..d66a8536bd0 100644 --- a/homeassistant/components/econet/binary_sensor.py +++ b/homeassistant/components/econet/binary_sensor.py @@ -9,11 +9,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, EQUIPMENT +from . import EconetConfigEntry from .entity import EcoNetEntity BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( @@ -41,10 +40,12 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EconetConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EcoNet binary sensor based on a config entry.""" - equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + equipment = entry.runtime_data all_equipment = equipment[EquipmentType.WATER_HEATER].copy() all_equipment.extend(equipment[EquipmentType.THERMOSTAT].copy()) diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index cdf82f6817f..1ebb7e483d4 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -16,13 +16,13 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .const import DOMAIN, EQUIPMENT +from . import EconetConfigEntry +from .const import DOMAIN from .entity import EcoNetEntity ECONET_STATE_TO_HA = { @@ -51,10 +51,12 @@ SUPPORT_FLAGS_THERMOSTAT = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EconetConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EcoNet thermostat based on a config entry.""" - equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + equipment = entry.runtime_data async_add_entities( [ EcoNetThermostat(thermostat) diff --git a/homeassistant/components/econet/const.py b/homeassistant/components/econet/const.py index ee8d4fc8a46..78384f7683d 100644 --- a/homeassistant/components/econet/const.py +++ b/homeassistant/components/econet/const.py @@ -1,7 +1,5 @@ """Constants for Econet integration.""" DOMAIN = "econet" -API_CLIENT = "api_client" -EQUIPMENT = "equipment" PUSH_UPDATE = "econet.push_update" diff --git a/homeassistant/components/econet/sensor.py b/homeassistant/components/econet/sensor.py index 19bac8c9e1f..510906d699c 100644 --- a/homeassistant/components/econet/sensor.py +++ b/homeassistant/components/econet/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, @@ -21,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, EQUIPMENT +from . import EconetConfigEntry from .entity import EcoNetEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( @@ -82,11 +81,13 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EconetConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EcoNet sensor based on a config entry.""" - data = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + data = entry.runtime_data equipment = data[EquipmentType.WATER_HEATER].copy() equipment.extend(data[EquipmentType.THERMOSTAT].copy()) diff --git a/homeassistant/components/econet/switch.py b/homeassistant/components/econet/switch.py index e36f6c834b1..283256f25e3 100644 --- a/homeassistant/components/econet/switch.py +++ b/homeassistant/components/econet/switch.py @@ -9,11 +9,10 @@ from pyeconet.equipment import EquipmentType from pyeconet.equipment.thermostat import ThermostatOperationMode from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, EQUIPMENT +from . import EconetConfigEntry from .entity import EcoNetEntity _LOGGER = logging.getLogger(__name__) @@ -21,11 +20,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: EconetConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ecobee thermostat switch entity.""" - equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + equipment = entry.runtime_data async_add_entities( EcoNetSwitchAuxHeatOnly(thermostat) for thermostat in equipment[EquipmentType.THERMOSTAT] diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index efe4196993c..fc3fe5e4bdf 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -17,12 +17,11 @@ from homeassistant.components.water_heater import ( WaterHeaterEntity, WaterHeaterEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, EQUIPMENT +from . import EconetConfigEntry from .entity import EcoNetEntity SCAN_INTERVAL = timedelta(hours=1) @@ -47,10 +46,12 @@ SUPPORT_FLAGS_HEATER = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EconetConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EcoNet water heater based on a config entry.""" - equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + equipment = entry.runtime_data async_add_entities( [ EcoNetWaterHeater(water_heater) diff --git a/tests/components/econet/test_config_flow.py b/tests/components/econet/test_config_flow.py index 2ef10c1bd41..2fc4356d1d8 100644 --- a/tests/components/econet/test_config_flow.py +++ b/tests/components/econet/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import patch from pyeconet.api import EcoNetApiInterface from pyeconet.errors import InvalidCredentialsError, PyeconetError -from homeassistant.components.econet import DOMAIN +from homeassistant.components.econet.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant From 6ad4dfc0709179707826d5cc67e1e763b21a4c91 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:28:21 +0100 Subject: [PATCH 1052/2987] Bump actions/setup-python from 5.3.0 to 5.4.0 (#136685) --- .github/workflows/builder.yml | 6 +++--- .github/workflows/ci.yaml | 32 +++++++++++++++--------------- .github/workflows/translations.yml | 2 +- .github/workflows/wheels.yml | 2 +- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 39dc08444d3..aa4bfc60c11 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -32,7 +32,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -116,7 +116,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -454,7 +454,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dad662a9202..a58648212e3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -234,7 +234,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -279,7 +279,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -319,7 +319,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -359,7 +359,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -469,7 +469,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -572,7 +572,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -605,7 +605,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -643,7 +643,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -686,7 +686,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -733,7 +733,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -778,7 +778,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -859,7 +859,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -923,7 +923,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1044,7 +1044,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1173,7 +1173,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1319,7 +1319,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python-version }} check-latest: true diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index fa3c2305190..619d83aef51 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 00f0c507414..e8dafe88833 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -36,7 +36,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true From edac4b83d9deed68d1e86899cbddd0d3781294cd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:29:26 +0100 Subject: [PATCH 1053/2987] Use runtime_data in ezviz (#136702) --- homeassistant/components/ezviz/__init__.py | 25 +++++++------------ .../components/ezviz/alarm_control_panel.py | 13 +++++----- .../components/ezviz/binary_sensor.py | 12 ++++----- homeassistant/components/ezviz/button.py | 12 ++++----- homeassistant/components/ezviz/camera.py | 17 +++++-------- homeassistant/components/ezviz/config_flow.py | 12 ++++----- homeassistant/components/ezviz/const.py | 3 --- homeassistant/components/ezviz/coordinator.py | 18 +++++++++++-- homeassistant/components/ezviz/image.py | 14 +++++------ homeassistant/components/ezviz/light.py | 12 ++++----- homeassistant/components/ezviz/number.py | 12 ++++----- homeassistant/components/ezviz/select.py | 12 ++++----- homeassistant/components/ezviz/sensor.py | 12 ++++----- homeassistant/components/ezviz/siren.py | 12 ++++----- homeassistant/components/ezviz/switch.py | 12 ++++----- homeassistant/components/ezviz/update.py | 12 ++++----- 16 files changed, 94 insertions(+), 116 deletions(-) diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index 6885304e0de..43a71458fb2 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -11,7 +11,6 @@ from pyezviz.exceptions import ( PyEzvizError, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TIMEOUT, CONF_TYPE, CONF_URL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -22,12 +21,11 @@ from .const import ( CONF_FFMPEG_ARGUMENTS, CONF_RFSESSION_ID, CONF_SESSION_ID, - DATA_COORDINATOR, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, ) -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -50,9 +48,8 @@ PLATFORMS_BY_TYPE: dict[str, list] = { } -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> bool: """Set up EZVIZ from a config entry.""" - hass.data.setdefault(DOMAIN, {}) sensor_type: str = entry.data[CONF_TYPE] ezviz_client = None @@ -90,20 +87,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from error coordinator = EzvizDataUpdateCoordinator( - hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] + hass, entry, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] ) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} + entry.runtime_data = coordinator entry.async_on_unload(entry.add_update_listener(_async_update_listener)) # Check EZVIZ cloud account entity is present, reload cloud account entities for camera entity change to take effect. # Cameras are accessed via local RTSP stream with unique credentials per camera. # Separate camera entities allow for credential changes per camera. - if sensor_type == ATTR_TYPE_CAMERA and hass.data[DOMAIN]: - for item in hass.config_entries.async_entries(domain=DOMAIN): + if sensor_type == ATTR_TYPE_CAMERA: + for item in hass.config_entries.async_loaded_entries(domain=DOMAIN): if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: _LOGGER.debug("Reload Ezviz main account with camera entry") await hass.config_entries.async_reload(item.entry_id) @@ -116,19 +113,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> bool: """Unload a config entry.""" sensor_type = entry.data[CONF_TYPE] - unload_ok = await hass.config_entries.async_unload_platforms( + return await hass.config_entries.async_unload_platforms( entry, PLATFORMS_BY_TYPE[sensor_type] ) - if sensor_type == ATTR_TYPE_CLOUD and unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: EzvizConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/ezviz/alarm_control_panel.py b/homeassistant/components/ezviz/alarm_control_panel.py index f30a7852b4e..66a76df2cdc 100644 --- a/homeassistant/components/ezviz/alarm_control_panel.py +++ b/homeassistant/components/ezviz/alarm_control_panel.py @@ -15,14 +15,13 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN, MANUFACTURER -from .coordinator import EzvizDataUpdateCoordinator +from .const import DOMAIN, MANUFACTURER +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -49,12 +48,12 @@ ALARM_TYPE = EzvizAlarmControlPanelEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Ezviz alarm control panel.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data device_info = DeviceInfo( identifiers={(DOMAIN, entry.unique_id)}, # type: ignore[arg-type] diff --git a/homeassistant/components/ezviz/binary_sensor.py b/homeassistant/components/ezviz/binary_sensor.py index c13375cb487..6f0d87c8218 100644 --- a/homeassistant/components/ezviz/binary_sensor.py +++ b/homeassistant/components/ezviz/binary_sensor.py @@ -7,12 +7,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity PARALLEL_UPDATES = 1 @@ -34,12 +32,12 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( [ diff --git a/homeassistant/components/ezviz/button.py b/homeassistant/components/ezviz/button.py index 3c89677da09..b99674b0693 100644 --- a/homeassistant/components/ezviz/button.py +++ b/homeassistant/components/ezviz/button.py @@ -11,13 +11,11 @@ from pyezviz.constants import SupportExt from pyezviz.exceptions import HTTPError, PyEzvizError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity PARALLEL_UPDATES = 1 @@ -68,12 +66,12 @@ BUTTON_ENTITIES = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ button based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data # Add button entities if supportExt value indicates PTZ capbility. # Could be missing or "0" for unsupported. diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 3c4a5f70ff4..d96fc949c86 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -10,11 +10,7 @@ from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.ffmpeg import get_ffmpeg_manager from homeassistant.components.stream import CONF_USE_WALLCLOCK_AS_TIMESTAMPS -from homeassistant.config_entries import ( - SOURCE_IGNORE, - SOURCE_INTEGRATION_DISCOVERY, - ConfigEntry, -) +from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_INTEGRATION_DISCOVERY from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery_flow @@ -26,26 +22,25 @@ from homeassistant.helpers.entity_platform import ( from .const import ( ATTR_SERIAL, CONF_FFMPEG_ARGUMENTS, - DATA_COORDINATOR, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, DOMAIN, SERVICE_WAKE_DEVICE, ) -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ cameras based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data camera_entities = [] diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index a7551737c10..845656c1d1d 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -17,12 +17,7 @@ from pyezviz.exceptions import ( from pyezviz.test_cam_rtsp import TestRTSPAuth import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_CUSTOMIZE, CONF_IP_ADDRESS, @@ -48,6 +43,7 @@ from .const import ( EU_URL, RUSSIA_URL, ) +from .coordinator import EzvizConfigEntry _LOGGER = logging.getLogger(__name__) DEFAULT_OPTIONS = { @@ -148,7 +144,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> EzvizOptionsFlowHandler: + def async_get_options_flow( + config_entry: EzvizConfigEntry, + ) -> EzvizOptionsFlowHandler: """Get the options flow for this handler.""" return EzvizOptionsFlowHandler() diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py index 651110dd5d7..e6de538335c 100644 --- a/homeassistant/components/ezviz/const.py +++ b/homeassistant/components/ezviz/const.py @@ -33,6 +33,3 @@ RUSSIA_URL = "apirus.ezvizru.com" DEFAULT_CAMERA_USERNAME = "admin" DEFAULT_TIMEOUT = 25 DEFAULT_FFMPEG_ARGUMENTS = "" - -# Data -DATA_COORDINATOR = "coordinator" diff --git a/homeassistant/components/ezviz/coordinator.py b/homeassistant/components/ezviz/coordinator.py index c983371f4f8..0830784a501 100644 --- a/homeassistant/components/ezviz/coordinator.py +++ b/homeassistant/components/ezviz/coordinator.py @@ -13,6 +13,7 @@ from pyezviz.exceptions import ( PyEzvizError, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -21,19 +22,32 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type EzvizConfigEntry = ConfigEntry[EzvizDataUpdateCoordinator] + class EzvizDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching EZVIZ data.""" def __init__( - self, hass: HomeAssistant, *, api: EzvizClient, api_timeout: int + self, + hass: HomeAssistant, + entry: EzvizConfigEntry, + *, + api: EzvizClient, + api_timeout: int, ) -> None: """Initialize global EZVIZ data updater.""" self.ezviz_client = api self._api_timeout = api_timeout update_interval = timedelta(seconds=30) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=update_interval, + ) async def _async_update_data(self) -> dict: """Fetch data from EZVIZ.""" diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index 73c09244222..d4c7a267b1e 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -8,14 +8,14 @@ from pyezviz.exceptions import PyEzvizError from pyezviz.utils import decrypt_image from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription -from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry +from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity _LOGGER = logging.getLogger(__name__) @@ -27,13 +27,13 @@ IMAGE_TYPE = ImageEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ image entities based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizLastMotion(hass, coordinator, camera) for camera in coordinator.data diff --git a/homeassistant/components/ezviz/light.py b/homeassistant/components/ezviz/light.py index c35b53b47b7..145c8b1ca20 100644 --- a/homeassistant/components/ezviz/light.py +++ b/homeassistant/components/ezviz/light.py @@ -8,7 +8,6 @@ from pyezviz.constants import DeviceCatagories, DeviceSwitchType, SupportExt from pyezviz.exceptions import HTTPError, PyEzvizError from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -17,8 +16,7 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity PARALLEL_UPDATES = 1 @@ -26,12 +24,12 @@ BRIGHTNESS_RANGE = (1, 255) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ lights based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizLight(coordinator, camera) diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py index 08fbd3afb34..9e8a20f36dd 100644 --- a/homeassistant/components/ezviz/number.py +++ b/homeassistant/components/ezviz/number.py @@ -16,14 +16,12 @@ from pyezviz.exceptions import ( ) from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizBaseEntity SCAN_INTERVAL = timedelta(seconds=3600) @@ -51,12 +49,12 @@ NUMBER_TYPE = EzvizNumberEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizNumber(coordinator, camera, value, entry.entry_id) diff --git a/homeassistant/components/ezviz/select.py b/homeassistant/components/ezviz/select.py index d6dc3dc8550..8e037fe6c33 100644 --- a/homeassistant/components/ezviz/select.py +++ b/homeassistant/components/ezviz/select.py @@ -8,14 +8,12 @@ from pyezviz.constants import DeviceSwitchType, SoundMode from pyezviz.exceptions import HTTPError, PyEzvizError from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity PARALLEL_UPDATES = 1 @@ -38,12 +36,12 @@ SELECT_TYPE = EzvizSelectEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ select entities based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizSelect(coordinator, camera) diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index e0750b985fc..f3d50836bc7 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -7,13 +7,11 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity PARALLEL_UPDATES = 1 @@ -72,12 +70,12 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( [ diff --git a/homeassistant/components/ezviz/siren.py b/homeassistant/components/ezviz/siren.py index 8bacceff29f..a52e499eee2 100644 --- a/homeassistant/components/ezviz/siren.py +++ b/homeassistant/components/ezviz/siren.py @@ -13,7 +13,6 @@ from homeassistant.components.siren import ( SirenEntityDescription, SirenEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -21,8 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.event as evt from homeassistant.helpers.restore_state import RestoreEntity -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizBaseEntity PARALLEL_UPDATES = 1 @@ -35,12 +33,12 @@ SIREN_ENTITY_TYPE = SirenEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizSirenEntity(coordinator, camera, SIREN_ENTITY_TYPE) diff --git a/homeassistant/components/ezviz/switch.py b/homeassistant/components/ezviz/switch.py index 65fb7b9f36b..1a347c931a6 100644 --- a/homeassistant/components/ezviz/switch.py +++ b/homeassistant/components/ezviz/switch.py @@ -13,13 +13,11 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity @@ -107,12 +105,12 @@ SWITCH_TYPES: dict[int, EzvizSwitchEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ switch based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizSwitch(coordinator, camera, switch_number) diff --git a/homeassistant/components/ezviz/update.py b/homeassistant/components/ezviz/update.py index 25a506a0052..3027e048688 100644 --- a/homeassistant/components/ezviz/update.py +++ b/homeassistant/components/ezviz/update.py @@ -12,13 +12,11 @@ from homeassistant.components.update import ( UpdateEntityDescription, UpdateEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity PARALLEL_UPDATES = 1 @@ -30,12 +28,12 @@ UPDATE_ENTITY_TYPES = UpdateEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizUpdateEntity(coordinator, camera, sensor, UPDATE_ENTITY_TYPES) From 164078ac69e94aa9d9b07c5e8e61781981e20fac Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Tue, 28 Jan 2025 11:29:29 +0100 Subject: [PATCH 1054/2987] Add translations for youless sensors (#136349) --- homeassistant/components/youless/entity.py | 2 +- homeassistant/components/youless/sensor.py | 93 +- homeassistant/components/youless/strings.json | 54 + .../youless/snapshots/test_sensor.ambr | 1270 ++++++++--------- 4 files changed, 719 insertions(+), 700 deletions(-) diff --git a/homeassistant/components/youless/entity.py b/homeassistant/components/youless/entity.py index 9931768c267..4500fe71a96 100644 --- a/homeassistant/components/youless/entity.py +++ b/homeassistant/components/youless/entity.py @@ -20,6 +20,6 @@ class YouLessEntity(CoordinatorEntity[YouLessCoordinator]): identifiers={(DOMAIN, device_group)}, manufacturer="YouLess", model=self.device.model, - name=device_name, + translation_key=device_name, sw_version=self.device.firmware_version, ) diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 413f1ad6958..3afb215ed5f 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -36,7 +36,6 @@ class YouLessSensorEntityDescription(SensorEntityDescription): """Describes a YouLess sensor entity.""" device_group: str - device_group_name: str value_func: Callable[[YoulessAPI], float | None] @@ -44,9 +43,7 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="water", device_group="water", - device_group_name="Water meter", - name="Water usage", - icon="mdi:water", + translation_key="total_water", device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, @@ -57,9 +54,7 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="gas", device_group="gas", - device_group_name="Gas meter", - name="Gas usage", - icon="mdi:fire", + translation_key="total_gas_m3", device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, @@ -68,9 +63,7 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="usage", device_group="power", - device_group_name="Power usage", - name="Power Usage", - icon="mdi:meter-electric", + translation_key="active_power_w", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -83,9 +76,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="power_low", device_group="power", - device_group_name="Power usage", - name="Energy low", - icon="mdi:transmission-tower-export", + translation_key="total_energy_import_tariff_kwh", + translation_placeholders={"tariff": "1"}, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -96,9 +88,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="power_high", device_group="power", - device_group_name="Power usage", - name="Energy high", - icon="mdi:transmission-tower-export", + translation_key="total_energy_import_tariff_kwh", + translation_placeholders={"tariff": "2"}, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -109,9 +100,7 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="power_total", device_group="power", - device_group_name="Power usage", - name="Energy total", - icon="mdi:transmission-tower-export", + translation_key="total_energy_import_kwh", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -124,9 +113,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_1_power", device_group="power", - device_group_name="Power usage", - name="Phase 1 power", - icon=None, + translation_key="active_power_phase_w", + translation_placeholders={"phase": "1"}, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -135,9 +123,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_1_voltage", device_group="power", - device_group_name="Power usage", - name="Phase 1 voltage", - icon=None, + translation_key="active_voltage_phase_v", + translation_placeholders={"phase": "1"}, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -148,9 +135,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_1_current", device_group="power", - device_group_name="Power usage", - name="Phase 1 current", - icon=None, + translation_key="active_current_phase_a", + translation_placeholders={"phase": "1"}, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -161,9 +147,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_2_power", device_group="power", - device_group_name="Power usage", - name="Phase 2 power", - icon=None, + translation_key="active_power_phase_w", + translation_placeholders={"phase": "2"}, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -172,9 +157,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_2_voltage", device_group="power", - device_group_name="Power usage", - name="Phase 2 voltage", - icon=None, + translation_key="active_voltage_phase_v", + translation_placeholders={"phase": "2"}, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -185,9 +169,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_2_current", device_group="power", - device_group_name="Power usage", - name="Phase 2 current", - icon=None, + translation_key="active_current_phase_a", + translation_placeholders={"phase": "2"}, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -198,9 +181,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_3_power", device_group="power", - device_group_name="Power usage", - name="Phase 3 power", - icon=None, + translation_key="active_power_phase_w", + translation_placeholders={"phase": "3"}, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -209,9 +191,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_3_voltage", device_group="power", - device_group_name="Power usage", - name="Phase 3 voltage", - icon=None, + translation_key="active_voltage_phase_v", + translation_placeholders={"phase": "3"}, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -222,9 +203,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_3_current", device_group="power", - device_group_name="Power usage", - name="Phase 3 current", - icon=None, + translation_key="active_current_phase_a", + translation_placeholders={"phase": "3"}, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -235,9 +215,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="delivery_low", device_group="delivery", - device_group_name="Energy delivery", - name="Energy delivery low", - icon="mdi:transmission-tower-import", + translation_key="total_energy_export_tariff_kwh", + translation_placeholders={"tariff": "1"}, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -250,9 +229,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="delivery_high", device_group="delivery", - device_group_name="Energy delivery", - name="Energy delivery high", - icon="mdi:transmission-tower-import", + translation_key="total_energy_export_tariff_kwh", + translation_placeholders={"tariff": "2"}, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -265,9 +243,7 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="extra_total", device_group="extra", - device_group_name="Extra meter", - name="Extra total", - icon="mdi:meter-electric", + translation_key="total_s0_kwh", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -280,9 +256,7 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="extra_usage", device_group="extra", - device_group_name="Extra meter", - name="Extra usage", - icon="mdi:lightning-bolt", + translation_key="active_s0_w", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -316,6 +290,7 @@ class YouLessSensor(YouLessEntity, SensorEntity): """Representation of a Sensor.""" entity_description: YouLessSensorEntityDescription + _attr_has_entity_name = True def __init__( self, @@ -327,7 +302,7 @@ class YouLessSensor(YouLessEntity, SensorEntity): super().__init__( coordinator, f"{device}_{description.device_group}", - description.device_group_name, + description.device_group, ) self._attr_unique_id = f"{DOMAIN}_{device}_{description.key}" self.entity_description = description diff --git a/homeassistant/components/youless/strings.json b/homeassistant/components/youless/strings.json index e0eddd7d137..8a3f6cb5d8b 100644 --- a/homeassistant/components/youless/strings.json +++ b/homeassistant/components/youless/strings.json @@ -14,5 +14,59 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "device": { + "water": { + "name": "Water meter" + }, + "gas": { + "name": "Gas meter" + }, + "power": { + "name": "Power meter" + }, + "delivery": { + "name": "Energy delivery meter" + }, + "extra": { + "name": "S0 meter" + } + }, + "entity": { + "sensor": { + "total_water": { + "name": "Total water usage" + }, + "total_gas_m3": { + "name": "Total gas usage" + }, + "active_power_w": { + "name": "Current power usage" + }, + "active_power_phase_w": { + "name": "Power phase {phase}" + }, + "active_voltage_phase_v": { + "name": "Voltage phase {phase}" + }, + "active_current_phase_a": { + "name": "Current phase {phase}" + }, + "total_energy_import_tariff_kwh": { + "name": "Energy import tariff {tariff}" + }, + "total_energy_import_kwh": { + "name": "Total energy import" + }, + "total_energy_export_tariff_kwh": { + "name": "Energy export tariff {tariff}" + }, + "total_s0_kwh": { + "name": "Total energy" + }, + "active_s0_w": { + "name": "Current usage" + } + } } } diff --git a/tests/components/youless/snapshots/test_sensor.ambr b/tests/components/youless/snapshots/test_sensor.ambr index 3424a264f48..0647d854d2a 100644 --- a/tests/components/youless/snapshots/test_sensor.ambr +++ b/tests/components/youless/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensors[sensor.energy_delivery_high-entry] +# name: test_sensors[sensor.energy_delivery_meter_energy_export_tariff_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -13,8 +13,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.energy_delivery_high', - 'has_entity_name': False, + 'entity_id': 'sensor.energy_delivery_meter_energy_export_tariff_1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,86 +24,33 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:transmission-tower-import', - 'original_name': 'Energy delivery high', + 'original_icon': None, + 'original_name': 'Energy export tariff 1', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_delivery_high', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.energy_delivery_high-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Energy delivery high', - 'icon': 'mdi:transmission-tower-import', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.energy_delivery_high', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_sensors[sensor.energy_delivery_low-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.energy_delivery_low', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:transmission-tower-import', - 'original_name': 'Energy delivery low', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'youless_localhost_delivery_low', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.energy_delivery_low-state] +# name: test_sensors[sensor.energy_delivery_meter_energy_export_tariff_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Energy delivery low', - 'icon': 'mdi:transmission-tower-import', + 'friendly_name': 'Energy delivery meter Energy export tariff 1', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energy_delivery_low', + 'entity_id': 'sensor.energy_delivery_meter_energy_export_tariff_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.029', }) # --- -# name: test_sensors[sensor.energy_high-entry] +# name: test_sensors[sensor.energy_delivery_meter_energy_export_tariff_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -117,8 +64,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.energy_high', - 'has_entity_name': False, + 'entity_id': 'sensor.energy_delivery_meter_energy_export_tariff_2', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -128,242 +75,33 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:transmission-tower-export', - 'original_name': 'Energy high', + 'original_icon': None, + 'original_name': 'Energy export tariff 2', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_power_high', + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'youless_localhost_delivery_high', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.energy_high-state] +# name: test_sensors[sensor.energy_delivery_meter_energy_export_tariff_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Energy high', - 'icon': 'mdi:transmission-tower-export', + 'friendly_name': 'Energy delivery meter Energy export tariff 2', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energy_high', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4490.631', - }) -# --- -# name: test_sensors[sensor.energy_low-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.energy_low', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:transmission-tower-export', - 'original_name': 'Energy low', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_power_low', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.energy_low-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Energy low', - 'icon': 'mdi:transmission-tower-export', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.energy_low', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4703.562', - }) -# --- -# name: test_sensors[sensor.energy_total-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.energy_total', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:transmission-tower-export', - 'original_name': 'Energy total', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_power_total', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.energy_total-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Energy total', - 'icon': 'mdi:transmission-tower-export', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.energy_total', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '9194.164', - }) -# --- -# name: test_sensors[sensor.extra_total-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.extra_total', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:meter-electric', - 'original_name': 'Extra total', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_extra_total', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.extra_total-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Extra total', - 'icon': 'mdi:meter-electric', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.extra_total', + 'entity_id': 'sensor.energy_delivery_meter_energy_export_tariff_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_sensors[sensor.extra_usage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.extra_usage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:lightning-bolt', - 'original_name': 'Extra usage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_extra_usage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.extra_usage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Extra usage', - 'icon': 'mdi:lightning-bolt', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.extra_usage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- -# name: test_sensors[sensor.gas_usage-entry] +# name: test_sensors[sensor.gas_meter_total_gas_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -377,8 +115,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.gas_usage', - 'has_entity_name': False, + 'entity_id': 'sensor.gas_meter_total_gas_usage', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -388,34 +126,33 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:fire', - 'original_name': 'Gas usage', + 'original_icon': None, + 'original_name': 'Total gas usage', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'total_gas_m3', 'unique_id': 'youless_localhost_gas', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.gas_usage-state] +# name: test_sensors[sensor.gas_meter_total_gas_usage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'gas', - 'friendly_name': 'Gas usage', - 'icon': 'mdi:fire', + 'friendly_name': 'Gas meter Total gas usage', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.gas_usage', + 'entity_id': 'sensor.gas_meter_total_gas_usage', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1624.264', }) # --- -# name: test_sensors[sensor.phase_1_current-entry] +# name: test_sensors[sensor.power_meter_current_phase_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -429,8 +166,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.phase_1_current', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_current_phase_1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -441,32 +178,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase 1 current', + 'original_name': 'Current phase 1', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_current_phase_a', 'unique_id': 'youless_localhost_phase_1_current', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.phase_1_current-state] +# name: test_sensors[sensor.power_meter_current_phase_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Phase 1 current', + 'friendly_name': 'Power meter Current phase 1', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.phase_1_current', + 'entity_id': 'sensor.power_meter_current_phase_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_sensors[sensor.phase_1_power-entry] +# name: test_sensors[sensor.power_meter_current_phase_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -480,110 +217,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.phase_1_power', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 1 power', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_1_power', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_1_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Phase 1 power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_1_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_sensors[sensor.phase_1_voltage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.phase_1_voltage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 1 voltage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_1_voltage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_1_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Phase 1 voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_1_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_sensors[sensor.phase_2_current-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.phase_2_current', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_current_phase_2', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -594,32 +229,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase 2 current', + 'original_name': 'Current phase 2', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_current_phase_a', 'unique_id': 'youless_localhost_phase_2_current', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.phase_2_current-state] +# name: test_sensors[sensor.power_meter_current_phase_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Phase 2 current', + 'friendly_name': 'Power meter Current phase 2', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.phase_2_current', + 'entity_id': 'sensor.power_meter_current_phase_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_sensors[sensor.phase_2_power-entry] +# name: test_sensors[sensor.power_meter_current_phase_3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -633,110 +268,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.phase_2_power', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 2 power', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_2_power', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_2_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Phase 2 power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_2_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_sensors[sensor.phase_2_voltage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.phase_2_voltage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 2 voltage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_2_voltage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_2_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Phase 2 voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_2_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_sensors[sensor.phase_3_current-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.phase_3_current', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_current_phase_3', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -747,32 +280,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase 3 current', + 'original_name': 'Current phase 3', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_current_phase_a', 'unique_id': 'youless_localhost_phase_3_current', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.phase_3_current-state] +# name: test_sensors[sensor.power_meter_current_phase_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Phase 3 current', + 'friendly_name': 'Power meter Current phase 3', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.phase_3_current', + 'entity_id': 'sensor.power_meter_current_phase_3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_sensors[sensor.phase_3_power-entry] +# name: test_sensors[sensor.power_meter_current_power_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -786,8 +319,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.phase_3_power', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_current_power_usage', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -798,135 +331,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase 3 power', + 'original_name': 'Current power usage', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_3_power', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_3_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Phase 3 power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_3_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_sensors[sensor.phase_3_voltage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.phase_3_voltage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 3 voltage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_3_voltage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_3_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Phase 3 voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_3_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_sensors[sensor.power_usage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.power_usage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:meter-electric', - 'original_name': 'Power Usage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_power_w', 'unique_id': 'youless_localhost_usage', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.power_usage-state] +# name: test_sensors[sensor.power_meter_current_power_usage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Power Usage', - 'icon': 'mdi:meter-electric', + 'friendly_name': 'Power meter Current power usage', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.power_usage', + 'entity_id': 'sensor.power_meter_current_power_usage', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2382', }) # --- -# name: test_sensors[sensor.water_usage-entry] +# name: test_sensors[sensor.power_meter_energy_import_tariff_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -940,8 +370,569 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.water_usage', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_energy_import_tariff_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 import tariff 1', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'youless_localhost_power_low', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_energy_import_tariff_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Power meter Energy import tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_energy_import_tariff_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4703.562', + }) +# --- +# name: test_sensors[sensor.power_meter_energy_import_tariff_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_energy_import_tariff_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 import tariff 2', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'youless_localhost_power_high', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_energy_import_tariff_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Power meter Energy import tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_energy_import_tariff_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4490.631', + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_power_phase_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': 'Power phase 1', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'youless_localhost_phase_1_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_power_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_power_phase_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': 'Power phase 2', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'youless_localhost_phase_2_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_power_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 3', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'youless_localhost_phase_3_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_power_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.power_meter_total_energy_import-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_total_energy_import', + '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 import', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'youless_localhost_power_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_total_energy_import-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Power meter Total energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_total_energy_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9194.164', + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_voltage_phase_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 phase 1', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'youless_localhost_phase_1_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Power meter Voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_voltage_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_voltage_phase_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 phase 2', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'youless_localhost_phase_2_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Power meter Voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_voltage_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_voltage_phase_3', + '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 phase 3', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'youless_localhost_phase_3_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Power meter Voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_voltage_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.s0_meter_current_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.s0_meter_current_usage', + '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 usage', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_s0_w', + 'unique_id': 'youless_localhost_extra_usage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.s0_meter_current_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'S0 meter Current usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.s0_meter_current_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.s0_meter_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.s0_meter_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': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_s0_kwh', + 'unique_id': 'youless_localhost_extra_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.s0_meter_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'S0 meter Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.s0_meter_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.water_meter_total_water_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_meter_total_water_usage', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -951,27 +942,26 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:water', - 'original_name': 'Water usage', + 'original_icon': None, + 'original_name': 'Total water usage', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'total_water', 'unique_id': 'youless_localhost_water', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.water_usage-state] +# name: test_sensors[sensor.water_meter_total_water_usage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'water', - 'friendly_name': 'Water usage', - 'icon': 'mdi:water', + 'friendly_name': 'Water meter Total water usage', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.water_usage', + 'entity_id': 'sensor.water_meter_total_water_usage', 'last_changed': , 'last_reported': , 'last_updated': , From c7c234c5dd6276675d744b276b4e432235165790 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:34:57 +0100 Subject: [PATCH 1055/2987] Use runtime_data in electric_kiwi (#136699) --- .../components/electric_kiwi/__init__.py | 28 ++++++++--------- .../components/electric_kiwi/const.py | 3 -- .../components/electric_kiwi/coordinator.py | 31 +++++++++++++++++-- .../components/electric_kiwi/select.py | 13 ++++---- .../components/electric_kiwi/sensor.py | 16 +++++----- 5 files changed, 56 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/electric_kiwi/__init__.py b/homeassistant/components/electric_kiwi/__init__.py index 8c9a0b3950e..de8d87553a3 100644 --- a/homeassistant/components/electric_kiwi/__init__.py +++ b/homeassistant/components/electric_kiwi/__init__.py @@ -6,23 +6,25 @@ import aiohttp from electrickiwi_api import ElectricKiwiApi from electrickiwi_api.exceptions import ApiException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from . import api -from .const import ACCOUNT_COORDINATOR, DOMAIN, HOP_COORDINATOR from .coordinator import ( ElectricKiwiAccountDataCoordinator, + ElectricKiwiConfigEntry, ElectricKiwiHOPDataCoordinator, + ElectricKiwiRuntimeData, ) PLATFORMS: list[Platform] = [Platform.SELECT, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: ElectricKiwiConfigEntry +) -> bool: """Set up Electric Kiwi from a config entry.""" implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( @@ -44,8 +46,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ek_api = ElectricKiwiApi( api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) ) - hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, ek_api) - account_coordinator = ElectricKiwiAccountDataCoordinator(hass, ek_api) + hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, entry, ek_api) + account_coordinator = ElectricKiwiAccountDataCoordinator(hass, entry, ek_api) try: await ek_api.set_active_session() @@ -54,19 +56,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except ApiException as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - HOP_COORDINATOR: hop_coordinator, - ACCOUNT_COORDINATOR: account_coordinator, - } + entry.runtime_data = ElectricKiwiRuntimeData( + hop=hop_coordinator, account=account_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: ElectricKiwiConfigEntry +) -> 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(entry, PLATFORMS) diff --git a/homeassistant/components/electric_kiwi/const.py b/homeassistant/components/electric_kiwi/const.py index 0b455b045cf..907b6247172 100644 --- a/homeassistant/components/electric_kiwi/const.py +++ b/homeassistant/components/electric_kiwi/const.py @@ -9,6 +9,3 @@ OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token" API_BASE_URL = "https://api.electrickiwi.co.nz" SCOPE_VALUES = "read_connection_detail read_billing_frequency read_account_running_balance read_consumption_summary read_consumption_averages read_hop_intervals_config read_hop_connection save_hop_connection read_session" - -HOP_COORDINATOR = "hop_coordinator" -ACCOUNT_COORDINATOR = "account_coordinator" diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py index a10be5eafdd..2065da5d668 100644 --- a/homeassistant/components/electric_kiwi/coordinator.py +++ b/homeassistant/components/electric_kiwi/coordinator.py @@ -1,7 +1,10 @@ """Electric Kiwi coordinators.""" +from __future__ import annotations + import asyncio from collections import OrderedDict +from dataclasses import dataclass from datetime import timedelta import logging @@ -9,6 +12,7 @@ from electrickiwi_api import ElectricKiwiApi from electrickiwi_api.exceptions import ApiException, AuthException from electrickiwi_api.model import AccountBalance, Hop, HopIntervals +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -19,14 +23,31 @@ ACCOUNT_SCAN_INTERVAL = timedelta(hours=6) HOP_SCAN_INTERVAL = timedelta(minutes=20) +@dataclass +class ElectricKiwiRuntimeData: + """ElectricKiwi runtime data.""" + + hop: ElectricKiwiHOPDataCoordinator + account: ElectricKiwiAccountDataCoordinator + + +type ElectricKiwiConfigEntry = ConfigEntry[ElectricKiwiRuntimeData] + + class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]): """ElectricKiwi Account Data object.""" - def __init__(self, hass: HomeAssistant, ek_api: ElectricKiwiApi) -> None: + def __init__( + self, + hass: HomeAssistant, + entry: ElectricKiwiConfigEntry, + ek_api: ElectricKiwiApi, + ) -> None: """Initialize ElectricKiwiAccountDataCoordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, name="Electric Kiwi Account Data", update_interval=ACCOUNT_SCAN_INTERVAL, ) @@ -48,11 +69,17 @@ class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]): class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): """ElectricKiwi HOP Data object.""" - def __init__(self, hass: HomeAssistant, ek_api: ElectricKiwiApi) -> None: + def __init__( + self, + hass: HomeAssistant, + entry: ElectricKiwiConfigEntry, + ek_api: ElectricKiwiApi, + ) -> None: """Initialize ElectricKiwiAccountDataCoordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, # Name of the data. For logging purposes. name="Electric Kiwi HOP Data", # Polling interval. Will only be polled if there are subscribers. diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py index a3f073b8ca2..fa111381612 100644 --- a/homeassistant/components/electric_kiwi/select.py +++ b/homeassistant/components/electric_kiwi/select.py @@ -5,14 +5,13 @@ from __future__ import annotations import logging from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTRIBUTION, DOMAIN, HOP_COORDINATOR -from .coordinator import ElectricKiwiHOPDataCoordinator +from .const import ATTRIBUTION +from .coordinator import ElectricKiwiConfigEntry, ElectricKiwiHOPDataCoordinator _LOGGER = logging.getLogger(__name__) ATTR_EK_HOP_SELECT = "hop_select" @@ -25,12 +24,12 @@ HOP_SELECT = SelectEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ElectricKiwiConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Electric Kiwi select setup.""" - hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id][ - HOP_COORDINATOR - ] + hop_coordinator = entry.runtime_data.hop _LOGGER.debug("Setting up select entity") async_add_entities([ElectricKiwiSelectHOPEntity(hop_coordinator, HOP_SELECT)]) diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index 7672466106b..e070f9495c1 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -14,16 +14,16 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import ACCOUNT_COORDINATOR, ATTRIBUTION, DOMAIN, HOP_COORDINATOR +from .const import ATTRIBUTION from .coordinator import ( ElectricKiwiAccountDataCoordinator, + ElectricKiwiConfigEntry, ElectricKiwiHOPDataCoordinator, ) @@ -122,12 +122,12 @@ HOP_SENSOR_TYPES: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ElectricKiwiConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Electric Kiwi Sensors Setup.""" - account_coordinator: ElectricKiwiAccountDataCoordinator = hass.data[DOMAIN][ - entry.entry_id - ][ACCOUNT_COORDINATOR] + account_coordinator = entry.runtime_data.account entities: list[SensorEntity] = [ ElectricKiwiAccountEntity( @@ -137,9 +137,7 @@ async def async_setup_entry( for description in ACCOUNT_SENSOR_TYPES ] - hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id][ - HOP_COORDINATOR - ] + hop_coordinator = entry.runtime_data.hop entities.extend( [ ElectricKiwiHOPEntity(hop_coordinator, description) From d9d6308b7817c7881c453b5d1d61d254d79a2b97 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:43:59 +0100 Subject: [PATCH 1056/2987] Cleanup use of hass.data in edl21 (#136694) --- homeassistant/components/edl21/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 4474893d9b6..62d06a8a535 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -292,8 +292,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the EDL21 sensor.""" - hass.data[DOMAIN] = EDL21(hass, config_entry.data, async_add_entities) - await hass.data[DOMAIN].connect() + api = EDL21(hass, config_entry.data, async_add_entities) + await api.connect() class EDL21: From f1305cd5a3b3c15842017d2b19a60277c5d7e6d0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:45:24 +0100 Subject: [PATCH 1057/2987] Improve type hints in econet (#136693) Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- .../components/econet/binary_sensor.py | 4 +-- homeassistant/components/econet/climate.py | 28 +++++++++++-------- homeassistant/components/econet/entity.py | 12 ++++---- homeassistant/components/econet/switch.py | 6 ++-- .../components/econet/water_heater.py | 14 +++++----- 5 files changed, 35 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/econet/binary_sensor.py b/homeassistant/components/econet/binary_sensor.py index d66a8536bd0..13ef8c4713b 100644 --- a/homeassistant/components/econet/binary_sensor.py +++ b/homeassistant/components/econet/binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pyeconet.equipment import EquipmentType +from pyeconet.equipment import Equipment, EquipmentType from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -63,7 +63,7 @@ class EcoNetBinarySensor(EcoNetEntity, BinarySensorEntity): """Define a Econet binary sensor.""" def __init__( - self, econet_device, description: BinarySensorEntityDescription + self, econet_device: Equipment, description: BinarySensorEntityDescription ) -> None: """Initialize.""" super().__init__(econet_device) diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index 1ebb7e483d4..d46dbd8750a 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -3,7 +3,11 @@ from typing import Any from pyeconet.equipment import EquipmentType -from pyeconet.equipment.thermostat import ThermostatFanMode, ThermostatOperationMode +from pyeconet.equipment.thermostat import ( + Thermostat, + ThermostatFanMode, + ThermostatOperationMode, +) from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, @@ -65,13 +69,13 @@ async def async_setup_entry( ) -class EcoNetThermostat(EcoNetEntity, ClimateEntity): +class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity): """Define an Econet thermostat.""" _attr_should_poll = True _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - def __init__(self, thermostat): + def __init__(self, thermostat: Thermostat) -> None: """Initialize.""" super().__init__(thermostat) self._attr_hvac_modes = [] @@ -92,24 +96,24 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): ) @property - def current_temperature(self): + def current_temperature(self) -> int: """Return the current temperature.""" return self._econet.set_point @property - def current_humidity(self): + def current_humidity(self) -> int: """Return the current humidity.""" return self._econet.humidity @property - def target_humidity(self): + def target_humidity(self) -> int | None: """Return the humidity we try to reach.""" if self._econet.supports_humidifier: return self._econet.dehumidifier_set_point return None @property - def target_temperature(self): + def target_temperature(self) -> int | None: """Return the temperature we try to reach.""" if self.hvac_mode == HVACMode.COOL: return self._econet.cool_set_point @@ -118,14 +122,14 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): return None @property - def target_temperature_low(self): + def target_temperature_low(self) -> int | None: """Return the lower bound temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: return self._econet.heat_set_point return None @property - def target_temperature_high(self): + def target_temperature_high(self) -> int | None: """Return the higher bound temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: return self._econet.cool_set_point @@ -142,7 +146,7 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): self._econet.set_set_point(None, target_temp_high, target_temp_low) @property - def is_aux_heat(self): + def is_aux_heat(self) -> bool: """Return true if aux heater.""" return self._econet.mode == ThermostatOperationMode.EMERGENCY_HEAT @@ -171,7 +175,7 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): self._econet.set_dehumidifier_set_point(humidity) @property - def fan_mode(self): + def fan_mode(self) -> str: """Return the current fan mode.""" econet_fan_mode = self._econet.fan_mode @@ -185,7 +189,7 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): return _current_fan_mode @property - def fan_modes(self): + def fan_modes(self) -> list[str]: """Return the fan modes.""" return [ ECONET_FAN_STATE_TO_HA[mode] diff --git a/homeassistant/components/econet/entity.py b/homeassistant/components/econet/entity.py index 44488f0b133..2ec8af83dd0 100644 --- a/homeassistant/components/econet/entity.py +++ b/homeassistant/components/econet/entity.py @@ -1,5 +1,7 @@ """Support for EcoNet products.""" +from pyeconet.equipment import Equipment + from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -8,18 +10,18 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN, PUSH_UPDATE -class EcoNetEntity(Entity): +class EcoNetEntity[_EquipmentT: Equipment = Equipment](Entity): """Define a base EcoNet entity.""" _attr_should_poll = False - def __init__(self, econet): + def __init__(self, econet: _EquipmentT) -> None: """Initialize.""" self._econet = econet self._attr_name = econet.device_name self._attr_unique_id = f"{econet.device_id}_{econet.device_name}" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to device events.""" await super().async_added_to_hass() self.async_on_remove( @@ -27,12 +29,12 @@ class EcoNetEntity(Entity): ) @callback - def on_update_received(self): + def on_update_received(self) -> None: """Update was pushed from the ecoent API.""" self.async_write_ha_state() @property - def available(self): + def available(self) -> bool: """Return if the device is online or not.""" return self._econet.connected diff --git a/homeassistant/components/econet/switch.py b/homeassistant/components/econet/switch.py index 283256f25e3..9fcd38c860e 100644 --- a/homeassistant/components/econet/switch.py +++ b/homeassistant/components/econet/switch.py @@ -6,7 +6,7 @@ import logging from typing import Any from pyeconet.equipment import EquipmentType -from pyeconet.equipment.thermostat import ThermostatOperationMode +from pyeconet.equipment.thermostat import Thermostat, ThermostatOperationMode from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant @@ -31,10 +31,10 @@ async def async_setup_entry( ) -class EcoNetSwitchAuxHeatOnly(EcoNetEntity, SwitchEntity): +class EcoNetSwitchAuxHeatOnly(EcoNetEntity[Thermostat], SwitchEntity): """Representation of a aux_heat_only EcoNet switch.""" - def __init__(self, thermostat) -> None: + def __init__(self, thermostat: Thermostat) -> None: """Initialize EcoNet ventilator platform.""" super().__init__(thermostat) self._attr_name = f"{thermostat.device_name} emergency heat" diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index fc3fe5e4bdf..cfbff70b580 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -5,7 +5,7 @@ import logging from typing import Any from pyeconet.equipment import EquipmentType -from pyeconet.equipment.water_heater import WaterHeaterOperationMode +from pyeconet.equipment.water_heater import WaterHeater, WaterHeaterOperationMode from homeassistant.components.water_heater import ( STATE_ECO, @@ -61,24 +61,24 @@ async def async_setup_entry( ) -class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): +class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity): """Define an Econet water heater.""" _attr_should_poll = True # Override False default from EcoNetEntity _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - def __init__(self, water_heater): + def __init__(self, water_heater: WaterHeater) -> None: """Initialize.""" super().__init__(water_heater) self.water_heater = water_heater @property - def is_away_mode_on(self): + def is_away_mode_on(self) -> bool: """Return true if away mode is on.""" return self._econet.away @property - def current_operation(self): + def current_operation(self) -> str: """Return current operation.""" econet_mode = self.water_heater.mode _current_op = STATE_OFF @@ -88,7 +88,7 @@ class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): return _current_op @property - def operation_list(self): + def operation_list(self) -> list[str]: """List of available operation modes.""" econet_modes = self.water_heater.modes op_list = [] @@ -131,7 +131,7 @@ class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): _LOGGER.error("Invalid operation mode: %s", operation_mode) @property - def target_temperature(self): + def target_temperature(self) -> int: """Return the temperature we try to reach.""" return self.water_heater.set_point From 7fc5a2294d94bd12f4561c56efa593459ad7b154 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:50:11 +0100 Subject: [PATCH 1058/2987] Use runtime_data in evil_genius_labs (#136704) --- .../components/evil_genius_labs/__init__.py | 18 ++++++------------ .../components/evil_genius_labs/coordinator.py | 15 ++++++++++++--- .../components/evil_genius_labs/diagnostics.py | 8 +++----- .../components/evil_genius_labs/light.py | 9 +++------ 4 files changed, 24 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/evil_genius_labs/__init__.py b/homeassistant/components/evil_genius_labs/__init__.py index d5bc3a564a2..7fb7430a044 100644 --- a/homeassistant/components/evil_genius_labs/__init__.py +++ b/homeassistant/components/evil_genius_labs/__init__.py @@ -4,38 +4,32 @@ from __future__ import annotations import pyevilgenius -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import DOMAIN -from .coordinator import EvilGeniusUpdateCoordinator +from .coordinator import EvilGeniusConfigEntry, EvilGeniusUpdateCoordinator PLATFORMS = [Platform.LIGHT] UPDATE_INTERVAL = 10 -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EvilGeniusConfigEntry) -> bool: """Set up Evil Genius Labs from a config entry.""" coordinator = EvilGeniusUpdateCoordinator( hass, - entry.title, + entry, pyevilgenius.EvilGeniusDevice( entry.data["host"], aiohttp_client.async_get_clientsession(hass) ), ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = 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: EvilGeniusConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/evil_genius_labs/coordinator.py b/homeassistant/components/evil_genius_labs/coordinator.py index 9f0f0df02af..202dcaf6ba7 100644 --- a/homeassistant/components/evil_genius_labs/coordinator.py +++ b/homeassistant/components/evil_genius_labs/coordinator.py @@ -10,11 +10,16 @@ from typing import cast from aiohttp import ContentTypeError import pyevilgenius +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator UPDATE_INTERVAL = 10 +_LOGGER = logging.getLogger(__name__) + +type EvilGeniusConfigEntry = ConfigEntry[EvilGeniusUpdateCoordinator] + class EvilGeniusUpdateCoordinator(DataUpdateCoordinator[dict]): """Update coordinator for Evil Genius data.""" @@ -24,14 +29,18 @@ class EvilGeniusUpdateCoordinator(DataUpdateCoordinator[dict]): product: dict | None def __init__( - self, hass: HomeAssistant, name: str, client: pyevilgenius.EvilGeniusDevice + self, + hass: HomeAssistant, + entry: EvilGeniusConfigEntry, + client: pyevilgenius.EvilGeniusDevice, ) -> None: """Initialize the data update coordinator.""" self.client = client super().__init__( hass, - logging.getLogger(__name__), - name=name, + _LOGGER, + config_entry=entry, + name=entry.title, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) diff --git a/homeassistant/components/evil_genius_labs/diagnostics.py b/homeassistant/components/evil_genius_labs/diagnostics.py index c9c79acc1bb..371e0c85b35 100644 --- a/homeassistant/components/evil_genius_labs/diagnostics.py +++ b/homeassistant/components/evil_genius_labs/diagnostics.py @@ -5,20 +5,18 @@ from __future__ import annotations 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 EvilGeniusUpdateCoordinator +from .coordinator import EvilGeniusConfigEntry TO_REDACT = {"wiFiSsidDefault", "wiFiSSID"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: EvilGeniusConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: EvilGeniusUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data return { "info": async_redact_data(coordinator.info, TO_REDACT), diff --git a/homeassistant/components/evil_genius_labs/light.py b/homeassistant/components/evil_genius_labs/light.py index 3556672dcce..a6d1d9531b5 100644 --- a/homeassistant/components/evil_genius_labs/light.py +++ b/homeassistant/components/evil_genius_labs/light.py @@ -7,12 +7,10 @@ from typing import Any, cast from homeassistant.components import light from homeassistant.components.light import ColorMode, LightEntity, LightEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import EvilGeniusUpdateCoordinator +from .coordinator import EvilGeniusConfigEntry, EvilGeniusUpdateCoordinator from .entity import EvilGeniusEntity from .util import update_when_done @@ -22,12 +20,11 @@ FIB_NO_EFFECT = "Solid Color" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EvilGeniusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Evil Genius light platform.""" - coordinator: EvilGeniusUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([EvilGeniusLight(coordinator)]) + async_add_entities([EvilGeniusLight(config_entry.runtime_data)]) class EvilGeniusLight(EvilGeniusEntity, LightEntity): From 8b738c919c7c43322b68477fcd5fd2cbfc4b6979 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:51:07 +0100 Subject: [PATCH 1059/2987] Correct labels in EnOcean config flow (#136338) --- homeassistant/components/enocean/__init__.py | 2 +- .../components/enocean/config_flow.py | 27 ++++++++++++++----- homeassistant/components/enocean/const.py | 2 +- homeassistant/components/enocean/dongle.py | 6 ++--- homeassistant/components/enocean/strings.json | 22 ++++++++++++--- tests/components/enocean/test_config_flow.py | 6 ++--- 6 files changed, 47 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/enocean/__init__.py b/homeassistant/components/enocean/__init__.py index 6dcec5ec218..9f53c79cc5b 100644 --- a/homeassistant/components/enocean/__init__.py +++ b/homeassistant/components/enocean/__init__.py @@ -47,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: - """Unload ENOcean config entry.""" + """Unload EnOcean config entry.""" enocean_dongle = hass.data[DATA_ENOCEAN][ENOCEAN_DONGLE] enocean_dongle.unload() diff --git a/homeassistant/components/enocean/config_flow.py b/homeassistant/components/enocean/config_flow.py index 2452d27b168..fd25b0c6ce1 100644 --- a/homeassistant/components/enocean/config_flow.py +++ b/homeassistant/components/enocean/config_flow.py @@ -1,4 +1,4 @@ -"""Config flows for the ENOcean integration.""" +"""Config flows for the EnOcean integration.""" from typing import Any @@ -6,6 +6,11 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from . import dongle from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER @@ -15,7 +20,7 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN): """Handle the enOcean config flows.""" VERSION = 1 - MANUAL_PATH_VALUE = "Custom path" + MANUAL_PATH_VALUE = "manual" def __init__(self) -> None: """Initialize the EnOcean config flow.""" @@ -52,14 +57,24 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN): return self.create_enocean_entry(user_input) errors = {CONF_DEVICE: ERROR_INVALID_DONGLE_PATH} - bridges = await self.hass.async_add_executor_job(dongle.detect) - if len(bridges) == 0: + devices = await self.hass.async_add_executor_job(dongle.detect) + if len(devices) == 0: return await self.async_step_manual(user_input) + devices.append(self.MANUAL_PATH_VALUE) - bridges.append(self.MANUAL_PATH_VALUE) return self.async_show_form( step_id="detect", - data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(bridges)}), + data_schema=vol.Schema( + { + vol.Required(CONF_DEVICE): SelectSelector( + SelectSelectorConfig( + options=devices, + translation_key="devices", + mode=SelectSelectorMode.LIST, + ) + ) + } + ), errors=errors, ) diff --git a/homeassistant/components/enocean/const.py b/homeassistant/components/enocean/const.py index 3624493b42e..0f3271655d8 100644 --- a/homeassistant/components/enocean/const.py +++ b/homeassistant/components/enocean/const.py @@ -1,4 +1,4 @@ -"""Constants for the ENOcean integration.""" +"""Constants for the EnOcean integration.""" import logging diff --git a/homeassistant/components/enocean/dongle.py b/homeassistant/components/enocean/dongle.py index 2d9a3f8787e..43214b12064 100644 --- a/homeassistant/components/enocean/dongle.py +++ b/homeassistant/components/enocean/dongle.py @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) class EnOceanDongle: """Representation of an EnOcean dongle. - The dongle is responsible for receiving the ENOcean frames, + The dongle is responsible for receiving the EnOcean frames, creating devices if needed, and dispatching messages to platforms. """ @@ -53,7 +53,7 @@ class EnOceanDongle: def callback(self, packet): """Handle EnOcean device's callback. - This is the callback function called by python-enocan whenever there + This is the callback function called by python-enocean whenever there is an incoming packet. """ @@ -63,7 +63,7 @@ class EnOceanDongle: def detect(): - """Return a list of candidate paths for USB ENOcean dongles. + """Return a list of candidate paths for USB EnOcean dongles. This method is currently a bit simplistic, it may need to be improved to support more configurations and OS. diff --git a/homeassistant/components/enocean/strings.json b/homeassistant/components/enocean/strings.json index 1a6f08cbf37..9baf4386eda 100644 --- a/homeassistant/components/enocean/strings.json +++ b/homeassistant/components/enocean/strings.json @@ -1,16 +1,23 @@ { "config": { + "flow_title": "{name}", "step": { "detect": { - "title": "Select the path to your EnOcean dongle", + "description": "Select your EnOcean USB dongle.", "data": { - "path": "USB dongle path" + "device": "USB dongle" + }, + "data_description": { + "device": "Path to your EnOcean USB dongle." } }, "manual": { - "title": "Enter the path to your EnOcean dongle", + "description": "Enter the path to your EnOcean USB dongle.", "data": { - "path": "[%key:component::enocean::config::step::detect::data::path%]" + "device": "[%key:component::enocean::config::step::detect::data::device%]" + }, + "data_description": { + "device": "[%key:component::enocean::config::step::detect::data_description::device%]" } } }, @@ -20,5 +27,12 @@ "abort": { "invalid_dongle_path": "Invalid dongle path" } + }, + "selector": { + "devices": { + "options": { + "manual": "Custom path" + } + } } } diff --git a/tests/components/enocean/test_config_flow.py b/tests/components/enocean/test_config_flow.py index 96c0843906f..fb5b1de19d8 100644 --- a/tests/components/enocean/test_config_flow.py +++ b/tests/components/enocean/test_config_flow.py @@ -32,7 +32,7 @@ async def test_user_flow_cannot_create_multiple_instances(hass: HomeAssistant) - async def test_user_flow_with_detected_dongle(hass: HomeAssistant) -> None: - """Test the user flow with a detected ENOcean dongle.""" + """Test the user flow with a detected EnOcean dongle.""" FAKE_DONGLE_PATH = "/fake/dongle" with patch(DONGLE_DETECT_METHOD, Mock(return_value=[FAKE_DONGLE_PATH])): @@ -42,13 +42,13 @@ async def test_user_flow_with_detected_dongle(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "detect" - devices = result["data_schema"].schema.get("device").container + devices = result["data_schema"].schema.get(CONF_DEVICE).config.get("options") assert FAKE_DONGLE_PATH in devices assert EnOceanFlowHandler.MANUAL_PATH_VALUE in devices async def test_user_flow_with_no_detected_dongle(hass: HomeAssistant) -> None: - """Test the user flow with a detected ENOcean dongle.""" + """Test the user flow with a detected EnOcean dongle.""" with patch(DONGLE_DETECT_METHOD, Mock(return_value=[])): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} From cd9abacdb22d3b95267a644f36a21fcb3e9e40e0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:52:10 +0100 Subject: [PATCH 1060/2987] Use runtime_data in eufylife_ble (#136705) --- .../components/eufylife_ble/__init__.py | 19 +++++-------------- .../components/eufylife_ble/models.py | 4 ++++ .../components/eufylife_ble/sensor.py | 8 +++----- 3 files changed, 12 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/eufylife_ble/__init__.py b/homeassistant/components/eufylife_ble/__init__.py index f66cf7df30d..8a58c50c8e4 100644 --- a/homeassistant/components/eufylife_ble/__init__.py +++ b/homeassistant/components/eufylife_ble/__init__.py @@ -6,17 +6,15 @@ from eufylife_ble_client import EufyLifeBLEDevice from homeassistant.components import bluetooth from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MODEL, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback -from .const import DOMAIN -from .models import EufyLifeData +from .models import EufyLifeConfigEntry, EufyLifeData PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EufyLifeConfigEntry) -> bool: """Set up EufyLife device from a config entry.""" address = entry.unique_id assert address is not None @@ -45,11 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = EufyLifeData( - address, - model, - client, - ) + entry.runtime_data = EufyLifeData(address, model, client) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -63,9 +57,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EufyLifeConfigEntry) -> 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(entry, PLATFORMS) diff --git a/homeassistant/components/eufylife_ble/models.py b/homeassistant/components/eufylife_ble/models.py index eb937fc4f3d..26154a74fac 100644 --- a/homeassistant/components/eufylife_ble/models.py +++ b/homeassistant/components/eufylife_ble/models.py @@ -6,6 +6,10 @@ from dataclasses import dataclass from eufylife_ble_client import EufyLifeBLEDevice +from homeassistant.config_entries import ConfigEntry + +type EufyLifeConfigEntry = ConfigEntry[EufyLifeData] + @dataclass class EufyLifeData: diff --git a/homeassistant/components/eufylife_ble/sensor.py b/homeassistant/components/eufylife_ble/sensor.py index 5e3ae64aabf..d9cef45ce4d 100644 --- a/homeassistant/components/eufylife_ble/sensor.py +++ b/homeassistant/components/eufylife_ble/sensor.py @@ -6,7 +6,6 @@ from typing import Any from eufylife_ble_client import MODEL_TO_NAME -from homeassistant import config_entries from homeassistant.components.bluetooth import async_address_present from homeassistant.components.sensor import ( RestoreSensor, @@ -20,19 +19,18 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from .const import DOMAIN -from .models import EufyLifeData +from .models import EufyLifeConfigEntry, EufyLifeData IGNORED_STATES = {STATE_UNAVAILABLE, STATE_UNKNOWN} async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: EufyLifeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the EufyLife sensors.""" - data: EufyLifeData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data entities = [ EufyLifeWeightSensorEntity(data), From 3ac062453f09f6b87344077d5886867cccdeaee0 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 28 Jan 2025 02:53:57 -0800 Subject: [PATCH 1061/2987] Update nest config flow to create pub/sub topics (#136609) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/nest/config_flow.py | 137 +++++---- homeassistant/components/nest/strings.json | 31 ++- tests/components/nest/conftest.py | 19 +- tests/components/nest/test_config_flow.py | 278 ++++++++++++++----- 4 files changed, 333 insertions(+), 132 deletions(-) diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 274e4c288b4..0b249db7a4b 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -15,6 +15,7 @@ import logging from typing import TYPE_CHECKING, Any from google_nest_sdm.admin_client import ( + DEFAULT_TOPIC_IAM_POLICY, AdminClient, EligibleSubscriptions, EligibleTopics, @@ -25,6 +26,11 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from homeassistant.util import get_random_string from . import api @@ -41,8 +47,9 @@ from .const import ( ) DATA_FLOW_IMPL = "nest_flow_implementation" +TOPIC_FORMAT = "projects/{cloud_project_id}/topics/home-assistant-{rnd}" SUBSCRIPTION_FORMAT = "projects/{cloud_project_id}/subscriptions/home-assistant-{rnd}" -SUBSCRIPTION_RAND_LENGTH = 10 +RAND_LENGTH = 10 MORE_INFO_URL = "https://www.home-assistant.io/integrations/nest/#configuration" @@ -59,6 +66,7 @@ DEVICE_ACCESS_CONSOLE_URL = "https://console.nest.google.com/device-access/" DEVICE_ACCESS_CONSOLE_EDIT_URL = ( "https://console.nest.google.com/device-access/project/{project_id}/information" ) +CREATE_NEW_TOPIC_KEY = "create_new_topic" CREATE_NEW_SUBSCRIPTION_KEY = "create_new_subscription" _LOGGER = logging.getLogger(__name__) @@ -66,10 +74,16 @@ _LOGGER = logging.getLogger(__name__) def _generate_subscription_id(cloud_project_id: str) -> str: """Create a new subscription id.""" - rnd = get_random_string(SUBSCRIPTION_RAND_LENGTH) + rnd = get_random_string(RAND_LENGTH) return SUBSCRIPTION_FORMAT.format(cloud_project_id=cloud_project_id, rnd=rnd) +def _generate_topic_id(cloud_project_id: str) -> str: + """Create a new topic id.""" + rnd = get_random_string(RAND_LENGTH) + return TOPIC_FORMAT.format(cloud_project_id=cloud_project_id, rnd=rnd) + + def generate_config_title(structures: Iterable[Structure]) -> str | None: """Pick a user friendly config title based on the Google Home name(s).""" names: list[str] = [ @@ -130,7 +144,7 @@ class NestFlowHandler( if self.source == SOURCE_REAUTH: _LOGGER.debug("Skipping Pub/Sub configuration") return await self._async_finish() - return await self.async_step_pubsub() + return await self.async_step_pubsub_topic() async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -192,7 +206,9 @@ class NestFlowHandler( ) -> ConfigFlowResult: """Handle cloud project in user input.""" if user_input is not None: - self._data.update(user_input) + self._data[CONF_CLOUD_PROJECT_ID] = user_input[ + CONF_CLOUD_PROJECT_ID + ].strip() return await self.async_step_device_project() return self.async_show_form( step_id="cloud_project", @@ -213,7 +229,7 @@ class NestFlowHandler( """Collect device access project from user input.""" errors = {} if user_input is not None: - project_id = user_input[CONF_PROJECT_ID] + project_id = user_input[CONF_PROJECT_ID].strip() if project_id == self._data[CONF_CLOUD_PROJECT_ID]: _LOGGER.error( "Device Access Project ID and Cloud Project ID must not be the" @@ -240,72 +256,83 @@ class NestFlowHandler( errors=errors, ) - async def async_step_pubsub( + async def async_step_pubsub_topic( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Configure and the pre-requisites to configure Pub/Sub topics and subscriptions.""" - data = { - **self._data, - **(user_input if user_input is not None else {}), - } - cloud_project_id = data.get(CONF_CLOUD_PROJECT_ID, "").strip() - device_access_project_id = data[CONF_PROJECT_ID] - - errors: dict[str, str] = {} - if cloud_project_id: + """Configure and create Pub/Sub topic.""" + cloud_project_id = self._data[CONF_CLOUD_PROJECT_ID] + if self._admin_client is None: access_token = self._data["token"]["access_token"] self._admin_client = api.new_pubsub_admin_client( - self.hass, access_token=access_token, cloud_project_id=cloud_project_id + self.hass, + access_token=access_token, + cloud_project_id=cloud_project_id, ) - try: - eligible_topics = await self._admin_client.list_eligible_topics( - device_access_project_id=device_access_project_id - ) - except ApiException as err: - _LOGGER.error("Error listing eligible Pub/Sub topics: %s", err) - errors["base"] = "pubsub_api_error" - else: - if not eligible_topics.topic_names: - errors["base"] = "no_pubsub_topics" + errors = {} + if user_input is not None: + topic_name = user_input[CONF_TOPIC_NAME] + if topic_name == CREATE_NEW_TOPIC_KEY: + topic_name = _generate_topic_id(cloud_project_id) + _LOGGER.debug("Creating topic %s", topic_name) + try: + await self._admin_client.create_topic(topic_name) + await self._admin_client.set_topic_iam_policy( + topic_name, DEFAULT_TOPIC_IAM_POLICY + ) + except ApiException as err: + _LOGGER.error("Error creating Pub/Sub topic: %s", err) + errors["base"] = "pubsub_api_error" if not errors: - self._data[CONF_CLOUD_PROJECT_ID] = cloud_project_id - self._eligible_topics = eligible_topics - return await self.async_step_pubsub_topic() + self._data[CONF_TOPIC_NAME] = topic_name + return await self.async_step_pubsub_topic_confirm() + device_access_project_id = self._data[CONF_PROJECT_ID] + try: + eligible_topics = await self._admin_client.list_eligible_topics( + device_access_project_id=device_access_project_id + ) + except ApiException as err: + _LOGGER.error("Error listing eligible Pub/Sub topics: %s", err) + return self.async_abort(reason="pubsub_api_error") + topics = [ + *eligible_topics.topic_names, # Untranslated topic paths + CREATE_NEW_TOPIC_KEY, + ] return self.async_show_form( - step_id="pubsub", + step_id="pubsub_topic", data_schema=vol.Schema( { - vol.Required(CONF_CLOUD_PROJECT_ID, default=cloud_project_id): str, + vol.Required( + CONF_TOPIC_NAME, default=next(iter(topics)) + ): SelectSelector( + SelectSelectorConfig( + translation_key="topic_name", + mode=SelectSelectorMode.LIST, + options=topics, + ) + ) } ), description_placeholders={ - "url": CLOUD_CONSOLE_URL, "device_access_console_url": DEVICE_ACCESS_CONSOLE_URL, "more_info_url": MORE_INFO_URL, }, errors=errors, ) - async def async_step_pubsub_topic( - self, user_input: dict[str, Any] | None = None + async def async_step_pubsub_topic_confirm( + self, user_input: dict | None = None ) -> ConfigFlowResult: - """Configure and create Pub/Sub topic.""" - if TYPE_CHECKING: - assert self._eligible_topics + """Have the user confirm the Pub/Sub topic is set correctly in Device Access Console.""" if user_input is not None: - self._data.update(user_input) return await self.async_step_pubsub_subscription() - topics = list(self._eligible_topics.topic_names) return self.async_show_form( - step_id="pubsub_topic", - data_schema=vol.Schema( - { - vol.Optional(CONF_TOPIC_NAME, default=topics[0]): vol.In(topics), - } - ), + step_id="pubsub_topic_confirm", description_placeholders={ - "device_access_console_url": DEVICE_ACCESS_CONSOLE_URL, + "device_access_console_url": DEVICE_ACCESS_CONSOLE_EDIT_URL.format( + project_id=self._data[CONF_PROJECT_ID] + ), + "topic_name": self._data[CONF_TOPIC_NAME], "more_info_url": MORE_INFO_URL, }, ) @@ -362,7 +389,7 @@ class NestFlowHandler( ) return await self._async_finish() - subscriptions = {} + subscriptions = [] try: eligible_subscriptions = ( await self._admin_client.list_eligible_subscriptions( @@ -375,10 +402,8 @@ class NestFlowHandler( ) errors["base"] = "pubsub_api_error" else: - subscriptions.update( - {name: name for name in eligible_subscriptions.subscription_names} - ) - subscriptions[CREATE_NEW_SUBSCRIPTION_KEY] = "Create New" + subscriptions.extend(eligible_subscriptions.subscription_names) + subscriptions.append(CREATE_NEW_SUBSCRIPTION_KEY) return self.async_show_form( step_id="pubsub_subscription", data_schema=vol.Schema( @@ -386,7 +411,13 @@ class NestFlowHandler( vol.Optional( CONF_SUBSCRIPTION_NAME, default=next(iter(subscriptions)), - ): vol.In(subscriptions), + ): SelectSelector( + SelectSelectorConfig( + translation_key="subscription_name", + mode=SelectSelectorMode.LIST, + options=subscriptions, + ) + ) } ), description_placeholders={ diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index a31a2856544..23da524ab7e 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -17,7 +17,7 @@ }, "device_project": { "title": "Nest: Create a Device Access Project", - "description": "Create a Nest Device Access project which **requires paying Google a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Select on **Create project**\n1. Give your Device Access project a name and select **Next**.\n1. Enter your OAuth Client ID\n1. Enable events by clicking **Enable** and **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).", + "description": "Create a Nest Device Access project which **requires paying Google a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Select on **Create project**\n1. Give your Device Access project a name and select **Next**.\n1. Enter your OAuth Client ID\n1. Skip enabling events for now and select **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).", "data": { "project_id": "Device Access Project ID" } @@ -25,20 +25,18 @@ "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "pubsub": { - "title": "Configure Google Cloud Pub/Sub", - "description": "Home Assistant uses Cloud Pub/Sub receive realtime Nest device updates. Nest servers publish updates to a Pub/Sub topic and Home Assistant receives the updates through a Pub/Sub subscription.\n\n1. Visit the [Device Access Console]({device_access_console_url}) and ensure a Pub/Sub topic is configured.\n2. Visit the [Cloud Console]({url}) to find your Google Cloud Project ID and confirm it is correct below.\n3. The next step will attempt to auto-discover Pub/Sub topics and subscriptions.\n\nSee the integration documentation for [more info]({more_info_url}).", - "data": { - "cloud_project_id": "[%key:component::nest::config::step::cloud_project::data::cloud_project_id%]" - } - }, "pubsub_topic": { "title": "Configure Cloud Pub/Sub topic", - "description": "Nest devices publish updates on a Cloud Pub/Sub topic. Select the Pub/Sub topic below that is the same as the [Device Access Console]({device_access_console_url}). See the integration documentation for [more info]({more_info_url}).", + "description": "Nest devices publish updates on a Cloud Pub/Sub topic. You can select an existing topic if one exists, or choose to create a new topic and the next step will create it for you with the necessary permissions. See the integration documentation for [more info]({more_info_url}).", "data": { "topic_name": "Pub/Sub topic Name" } }, + "pubsub_topic_confirm": { + "title": "Enable events", + "description": "The Nest Device Access Console needs to be configured to publish device events to your Pub/Sub topic.\n\n1. Visit the [Device Access Console]({device_access_console_url}).\n2. Open the project.\n3. Enable *Events* and set the Pub/Sub topic name to `{topic_name}`\n4. Click *Add & Validate* to verify the topic is configured correctly.\n\nSee the integration documentation for [more info]({more_info_url}).", + "submit": "Confirm" + }, "pubsub_subscription": { "title": "Configure Cloud Pub/Sub subscription", "description": "Home Assistant receives realtime Nest device updates with a Cloud Pub/Sub subscription for topic `{topic}`.\n\nSelect an existing subscription below if one already exists, or the next step will create a new one for you. See the integration documentation for [more info]({more_info_url}).", @@ -70,7 +68,8 @@ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "pubsub_api_error": "[%key:component::nest::config::error::pubsub_api_error%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -109,5 +108,17 @@ } } } + }, + "selector": { + "topic_name": { + "options": { + "create_new_topic": "Create new topic" + } + }, + "subscription_name": { + "options": { + "create_new_subscription": "Create new subscription" + } + } } } diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index b5e3cd2b91c..92d90a18a7e 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -74,20 +74,25 @@ class FakeAuth: self.json = None self.headers = None self.captured_requests = [] + self._project_id = project_id + self._aioclient_mock = aioclient_mock + self.register_mock_requests() + def register_mock_requests(self) -> None: + """Register the mocks.""" # API makes a call to request structures to initiate pubsub feed, but the # integration does not use this. - aioclient_mock.get( - f"{API_URL}/enterprises/{project_id}/structures", + self._aioclient_mock.get( + f"{API_URL}/enterprises/{self._project_id}/structures", side_effect=self.request_structures, ) - aioclient_mock.get( - f"{API_URL}/enterprises/{project_id}/devices", + self._aioclient_mock.get( + f"{API_URL}/enterprises/{self._project_id}/devices", side_effect=self.request_devices, ) - aioclient_mock.post(DEVICE_URL_MATCH, side_effect=self.request) - aioclient_mock.get(TEST_IMAGE_URL, side_effect=self.request) - aioclient_mock.get(TEST_CLIP_URL, side_effect=self.request) + self._aioclient_mock.post(DEVICE_URL_MATCH, side_effect=self.request) + self._aioclient_mock.get(TEST_IMAGE_URL, side_effect=self.request) + self._aioclient_mock.get(TEST_CLIP_URL, side_effect=self.request) async def request_structures( self, method: str, url: str, data: dict[str, Any] diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow.py index f08eeb82a1d..0e6ec290841 100644 --- a/tests/components/nest/test_config_flow.py +++ b/tests/components/nest/test_config_flow.py @@ -34,7 +34,7 @@ from tests.typing import ClientSessionGenerator WEB_REDIRECT_URL = "https://example.com/auth/external/callback" APP_REDIRECT_URL = "urn:ietf:wg:oauth:2.0:oob" -RAND_SUBSCRIBER_SUFFIX = "ABCDEF" +RAND_SUFFIX = "ABCDEF" FAKE_DHCP_DATA = DhcpServiceInfo( ip="127.0.0.2", macaddress="001122334455", hostname="fake_hostname" @@ -52,7 +52,7 @@ def mock_rand_topic_name_fixture() -> None: """Set the topic name random string to a constant.""" with patch( "homeassistant.components.nest.config_flow.get_random_string", - return_value=RAND_SUBSCRIBER_SUFFIX, + return_value=RAND_SUFFIX, ): yield @@ -173,6 +173,7 @@ class OAuthFixture: selected_topic: str, selected_subscription: str = "create_new_subscription", user_input: dict | None = None, + existing_errors: dict | None = None, ) -> ConfigEntry: """Fixture to walk through the Pub/Sub topic and subscription steps. @@ -193,6 +194,12 @@ class OAuthFixture: }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + # ACK the topic selection. User is instructed to do some manual + result = await self.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert not result.get("errors") @@ -267,6 +274,12 @@ def mock_cloud_project_id() -> str: return CLOUD_PROJECT_ID +@pytest.fixture(name="create_topic_status") +def mock_create_topic_status() -> str: + """Fixture to configure the return code when creating the topic.""" + return HTTPStatus.OK + + @pytest.fixture(name="create_subscription_status") def mock_create_subscription_status() -> str: """Fixture to configure the return code when creating the subscription.""" @@ -285,6 +298,64 @@ def mock_list_subscriptions_status() -> str: return HTTPStatus.OK +def setup_mock_list_subscriptions_responses( + aioclient_mock: AiohttpClientMocker, + cloud_project_id: str, + subscriptions: list[tuple[str, str]], + list_subscriptions_status: HTTPStatus = HTTPStatus.OK, +) -> None: + """Configure the mock responses for listing Pub/Sub subscriptions.""" + aioclient_mock.get( + f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/subscriptions", + json={ + "subscriptions": [ + { + "name": subscription_name, + "topic": topic, + "pushConfig": {}, + "ackDeadlineSeconds": 10, + "messageRetentionDuration": "604800s", + "expirationPolicy": {"ttl": "2678400s"}, + "state": "ACTIVE", + } + for (subscription_name, topic) in subscriptions or () + ] + }, + status=list_subscriptions_status, + ) + + +def setup_mock_create_topic_responses( + aioclient_mock: AiohttpClientMocker, + cloud_project_id: str, + create_topic_status: HTTPStatus = HTTPStatus.OK, +) -> None: + """Configure the mock responses for creating a Pub/Sub topic.""" + aioclient_mock.put( + f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/topics/home-assistant-{RAND_SUFFIX}", + json={}, + status=create_topic_status, + ) + aioclient_mock.post( + f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/topics/home-assistant-{RAND_SUFFIX}:setIamPolicy", + json={}, + status=create_topic_status, + ) + + +def setup_mock_create_subscription_responses( + aioclient_mock: AiohttpClientMocker, + cloud_project_id: str, + create_subscription_status: HTTPStatus = HTTPStatus.OK, +) -> None: + """Configure the mock responses for creating a Pub/Sub subscription.""" + aioclient_mock.put( + f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/subscriptions/home-assistant-{RAND_SUFFIX}", + json={}, + status=create_subscription_status, + ) + + @pytest.fixture(autouse=True) def mock_pubsub_api_responses( aioclient_mock: AiohttpClientMocker, @@ -293,6 +364,7 @@ def mock_pubsub_api_responses( subscriptions: list[tuple[str, str]], device_access_project_id: str, cloud_project_id: str, + create_topic_status: HTTPStatus, create_subscription_status: HTTPStatus, list_topics_status: HTTPStatus, list_subscriptions_status: HTTPStatus, @@ -320,28 +392,14 @@ def mock_pubsub_api_responses( ) # We check for a topic created by the SDM Device Access Console (but note we don't have permission to read it) # or the user has created one themselves in the Google Cloud Project. - aioclient_mock.get( - f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/subscriptions", - json={ - "subscriptions": [ - { - "name": subscription_name, - "topic": topic, - "pushConfig": {}, - "ackDeadlineSeconds": 10, - "messageRetentionDuration": "604800s", - "expirationPolicy": {"ttl": "2678400s"}, - "state": "ACTIVE", - } - for (subscription_name, topic) in subscriptions or () - ] - }, - status=list_subscriptions_status, + setup_mock_list_subscriptions_responses( + aioclient_mock, cloud_project_id, subscriptions, list_subscriptions_status ) - aioclient_mock.put( - f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}", - json={}, - status=create_subscription_status, + setup_mock_create_topic_responses( + aioclient_mock, cloud_project_id, create_topic_status + ) + setup_mock_create_subscription_responses( + aioclient_mock, cloud_project_id, create_subscription_status ) @@ -371,7 +429,7 @@ async def test_app_credentials( "auth_implementation": "imported-cred", "cloud_project_id": CLOUD_PROJECT_ID, "project_id": PROJECT_ID, - "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}", + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", "topic_name": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", "token": { "refresh_token": "mock-refresh-token", @@ -520,6 +578,11 @@ async def test_config_flow_pubsub_configuration_error( }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert result.get("data_schema")({}) == { "subscription_name": "create_new_subscription", @@ -565,6 +628,11 @@ async def test_config_flow_pubsub_subscriber_error( }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert result.get("data_schema")({}) == { "subscription_name": "create_new_subscription", @@ -691,37 +759,6 @@ async def test_reauth_multiple_config_entries( assert entry.data.get("extra_data") -@pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) -async def test_pubsub_subscription_strip_whitespace( - hass: HomeAssistant, - oauth: OAuthFixture, -) -> None: - """Check that project id has whitespace stripped on entry.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - await oauth.async_app_creds_flow( - result, cloud_project_id=" " + CLOUD_PROJECT_ID + " " - ) - oauth.async_mock_refresh() - result = await oauth.async_configure(result, {"code": "1234"}) - entry = await oauth.async_complete_pubsub_flow( - result, selected_topic="projects/sdm-prod/topics/enterprise-some-project-id" - ) - assert entry.title == "Import from configuration.yaml" - assert "token" in entry.data - entry.data["token"].pop("expires_at") - assert entry.unique_id == PROJECT_ID - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - assert "subscription_name" in entry.data - assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID - - @pytest.mark.parametrize( ("sdm_managed_topic", "create_subscription_status"), [(True, HTTPStatus.UNAUTHORIZED)], @@ -751,6 +788,11 @@ async def test_pubsub_subscription_auth_failure( }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert result.get("data_schema")({}) == { "subscription_name": "create_new_subscription", @@ -833,7 +875,7 @@ async def test_config_entry_title_from_home( assert entry.data.get("cloud_project_id") == CLOUD_PROJECT_ID assert ( entry.data.get("subscription_name") - == f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}" + == f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}" ) assert ( entry.data.get("topic_name") @@ -905,7 +947,7 @@ async def test_title_failure_fallback( assert entry.data.get("cloud_project_id") == CLOUD_PROJECT_ID assert ( entry.data.get("subscription_name") - == f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}" + == f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}" ) assert ( entry.data.get("topic_name") @@ -997,7 +1039,7 @@ async def test_dhcp_discovery_with_creds( "auth_implementation": "imported-cred", "cloud_project_id": CLOUD_PROJECT_ID, "project_id": PROJECT_ID, - "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}", + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", "topic_name": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", "token": { "refresh_token": "mock-refresh-token", @@ -1092,7 +1134,7 @@ async def test_no_eligible_topics( hass: HomeAssistant, oauth: OAuthFixture, ) -> None: - """Test the case where there are no eligible pub/sub topics.""" + """Test the case where there are no eligible pub/sub topics and the topic is created.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -1101,8 +1143,36 @@ async def test_no_eligible_topics( result = await oauth.async_configure(result, None) assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "pubsub" - assert result.get("errors") == {"base": "no_pubsub_topics"} + assert result.get("step_id") == "pubsub_topic" + assert not result.get("errors") + # Option shown to create a new topic + assert result.get("data_schema")({}) == { + "topic_name": "create_new_topic", + } + + entry = await oauth.async_complete_pubsub_flow( + result, + selected_topic="create_new_topic", + selected_subscription="create_new_subscription", + ) + + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", + "topic_name": f"projects/{CLOUD_PROJECT_ID}/topics/home-assistant-{RAND_SUFFIX}", + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, + } @pytest.mark.parametrize( @@ -1122,11 +1192,90 @@ async def test_list_topics_failure( await oauth.async_app_creds_flow(result) oauth.async_mock_refresh() + result = await oauth.async_configure(result, None) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "pubsub_api_error" + + +@pytest.mark.parametrize( + ("create_topic_status"), + [(HTTPStatus.INTERNAL_SERVER_ERROR)], +) +async def test_create_topic_failed( + hass: HomeAssistant, + oauth: OAuthFixture, + aioclient_mock: AiohttpClientMocker, + cloud_project_id: str, + subscriptions: list[tuple[str, str]], + auth: FakeAuth, +) -> None: + """Test the case where there are no eligible pub/sub topics and the topic is created.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() + result = await oauth.async_configure(result, None) assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "pubsub" + assert result.get("step_id") == "pubsub_topic" + assert not result.get("errors") + # Option shown to create a new topic + assert result.get("data_schema")({}) == { + "topic_name": "create_new_topic", + } + + result = await oauth.async_configure(result, {"topic_name": "create_new_topic"}) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic" assert result.get("errors") == {"base": "pubsub_api_error"} + # Re-register mock requests needed for the rest of the test. The topic + # request will now succeed. + aioclient_mock.clear_requests() + setup_mock_create_topic_responses(aioclient_mock, cloud_project_id) + # Fix up other mock responses cleared above + auth.register_mock_requests() + setup_mock_list_subscriptions_responses( + aioclient_mock, + cloud_project_id, + subscriptions, + ) + setup_mock_create_subscription_responses(aioclient_mock, cloud_project_id) + + result = await oauth.async_configure(result, {"topic_name": "create_new_topic"}) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_subscription" + assert not result.get("errors") + + # Create a subscription for the topic and end the flow + entry = await oauth.async_finish_setup( + result, + {"subscription_name": "create_new_subscription"}, + ) + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", + "topic_name": f"projects/{CLOUD_PROJECT_ID}/topics/home-assistant-{RAND_SUFFIX}", + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, + } + @pytest.mark.parametrize( ("sdm_managed_topic", "list_subscriptions_status"), @@ -1158,5 +1307,10 @@ async def test_list_subscriptions_failure( }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert result.get("errors") == {"base": "pubsub_api_error"} From f14f7936eb71f9baa45608a2d84bbc311cf288f4 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:55:40 +0100 Subject: [PATCH 1062/2987] Support integrated ventilation on heating devices in ViCare integration (#130356) --- homeassistant/components/vicare/fan.py | 26 ++++++++++++-------- homeassistant/components/vicare/strings.json | 10 +++++--- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index fc18bdbd8da..190a893157c 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -13,9 +13,6 @@ from PyViCare.PyViCareUtils import ( PyViCareNotSupportedFeatureError, PyViCareRateLimitError, ) -from PyViCare.PyViCareVentilationDevice import ( - VentilationDevice as PyViCareVentilationDevice, -) from requests.exceptions import ConnectionError as RequestConnectionError from homeassistant.components.fan import FanEntity, FanEntityFeature @@ -50,6 +47,8 @@ class VentilationMode(enum.StrEnum): PERMANENT = "permanent" # on, speed controlled by program (levelOne-levelFour) VENTILATION = "ventilation" # activated by schedule + STANDBY = "standby" # activated by schedule + STANDARD = "standard" # activated by schedule SENSOR_DRIVEN = "sensor_driven" # activated by schedule, override by sensor SENSOR_OVERRIDE = "sensor_override" # activated by sensor @@ -77,6 +76,8 @@ class VentilationMode(enum.StrEnum): HA_TO_VICARE_MODE_VENTILATION = { VentilationMode.PERMANENT: "permanent", VentilationMode.VENTILATION: "ventilation", + VentilationMode.STANDBY: "standby", + VentilationMode.STANDARD: "standard", VentilationMode.SENSOR_DRIVEN: "sensorDriven", VentilationMode.SENSOR_OVERRIDE: "sensorOverride", } @@ -96,7 +97,7 @@ def _build_entities( return [ ViCareFan(get_device_serial(device.api), device.config, device.api) for device in device_list - if isinstance(device.api, PyViCareVentilationDevice) + if device.api.isVentilationDevice() ] @@ -118,7 +119,6 @@ class ViCareFan(ViCareEntity, FanEntity): """Representation of the ViCare ventilation device.""" _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) - _attr_supported_features = FanEntityFeature.SET_SPEED _attr_translation_key = "ventilation" def __init__( @@ -131,8 +131,8 @@ class ViCareFan(ViCareEntity, FanEntity): super().__init__( self._attr_translation_key, device_serial, device_config, device ) - # init presets - supported_modes = list[str](self._api.getAvailableModes()) + # init preset_mode + supported_modes = list[str](self._api.getVentilationModes()) self._attr_preset_modes = [ mode for mode in VentilationMode @@ -140,6 +140,12 @@ class ViCareFan(ViCareEntity, FanEntity): ] if len(self._attr_preset_modes) > 0: self._attr_supported_features |= FanEntityFeature.PRESET_MODE + # init set_speed + supported_levels: list[str] | None = None + with suppress(PyViCareNotSupportedFeatureError): + supported_levels = self._api.getVentilationLevels() + if supported_levels is not None and len(supported_levels) > 0: + self._attr_supported_features |= FanEntityFeature.SET_SPEED def update(self) -> None: """Update state of fan.""" @@ -147,7 +153,7 @@ class ViCareFan(ViCareEntity, FanEntity): try: with suppress(PyViCareNotSupportedFeatureError): self._attr_preset_mode = VentilationMode.from_vicare_mode( - self._api.getActiveMode() + self._api.getActiveVentilationMode() ) with suppress(PyViCareNotSupportedFeatureError): level = filter_state(self._api.getVentilationLevel()) @@ -203,10 +209,10 @@ class ViCareFan(ViCareEntity, FanEntity): level = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage) _LOGGER.debug("changing ventilation level to %s", level) - self._api.setPermanentLevel(level) + self._api.setVentilationLevel(level) def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" target_mode = VentilationMode.to_vicare_mode(preset_mode) _LOGGER.debug("changing ventilation mode to %s", target_mode) - self._api.setActiveMode(target_mode) + self._api.activateVentilationMode(target_mode) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 5ab92880ba0..4eee81f3d05 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -81,10 +81,12 @@ "state_attributes": { "preset_mode": { "state": { - "permanent": "permanent", - "ventilation": "schedule", - "sensor_driven": "sensor", - "sensor_override": "schedule with sensor-override" + "standby": "[%key:common::state::standby%]", + "permanent": "Permanent", + "ventilation": "Schedule", + "sensor_driven": "Sensor-driven", + "sensor_override": "Schedule with sensor-override", + "standard": "Minimal" } } } From 933aec1027cfcdb7b0b7fd9a8445c2a42973a994 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:57:12 +0100 Subject: [PATCH 1063/2987] Use runtime_data in epson (#136706) --- homeassistant/components/epson/__init__.py | 22 +++++++++---------- .../components/epson/media_player.py | 6 ++--- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/epson/__init__.py b/homeassistant/components/epson/__init__.py index 715b55824b4..27dbaa93734 100644 --- a/homeassistant/components/epson/__init__.py +++ b/homeassistant/components/epson/__init__.py @@ -13,13 +13,15 @@ from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_CONNECTION_TYPE, DOMAIN, HTTP +from .const import CONF_CONNECTION_TYPE, HTTP from .exceptions import CannotConnect, PoweredOff PLATFORMS = [Platform.MEDIA_PLAYER] _LOGGER = logging.getLogger(__name__) +type EpsonConfigEntry = ConfigEntry[Projector] + async def validate_projector( hass: HomeAssistant, @@ -45,7 +47,7 @@ async def validate_projector( return epson_proj -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EpsonConfigEntry) -> bool: """Set up epson from a config entry.""" projector = await validate_projector( hass=hass, @@ -54,23 +56,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: check_power=False, check_powered_on=False, ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = projector + entry.runtime_data = projector await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(projector.close) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EpsonConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - projector = hass.data[DOMAIN].pop(entry.entry_id) - projector.close() - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: EpsonConfigEntry +) -> bool: """Migrate old entry.""" _LOGGER.debug( "Migrating configuration from version %s.%s", diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py index a901e9df216..e0eac4a1cfb 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -45,6 +45,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import EpsonConfigEntry from .const import ATTR_CMODE, DOMAIN, SERVICE_SELECT_CMODE _LOGGER = logging.getLogger(__name__) @@ -52,13 +53,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EpsonConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Epson projector from a config entry.""" - projector: Projector = hass.data[DOMAIN][config_entry.entry_id] projector_entity = EpsonProjectorMediaPlayer( - projector=projector, + projector=config_entry.runtime_data, unique_id=config_entry.unique_id or config_entry.entry_id, entry=config_entry, ) From 91ff31a3beaad07ca4a8b06ea9e734f23eec0a8e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 12:01:19 +0100 Subject: [PATCH 1064/2987] Use runtime_data in epion (#136708) --- homeassistant/components/epion/__init__.py | 17 ++++++----------- homeassistant/components/epion/coordinator.py | 8 +++++++- homeassistant/components/epion/sensor.py | 7 +++---- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/epion/__init__.py b/homeassistant/components/epion/__init__.py index fec975c5098..c04c77f760d 100644 --- a/homeassistant/components/epion/__init__.py +++ b/homeassistant/components/epion/__init__.py @@ -4,30 +4,25 @@ from __future__ import annotations from epion import Epion -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import EpionCoordinator +from .coordinator import EpionConfigEntry, EpionCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EpionConfigEntry) -> bool: """Set up the Epion coordinator from a config entry.""" api = Epion(entry.data[CONF_API_KEY]) - coordinator = EpionCoordinator(hass, api) + coordinator = EpionCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = 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: EpionConfigEntry) -> bool: """Unload Epion 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/epion/coordinator.py b/homeassistant/components/epion/coordinator.py index 3eb7efb5dc7..9eb31331097 100644 --- a/homeassistant/components/epion/coordinator.py +++ b/homeassistant/components/epion/coordinator.py @@ -5,6 +5,7 @@ from typing import Any from epion import Epion, EpionAuthenticationError, EpionConnectionError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -13,15 +14,20 @@ from .const import REFRESH_INTERVAL _LOGGER = logging.getLogger(__name__) +type EpionConfigEntry = ConfigEntry[EpionCoordinator] + class EpionCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Epion data update coordinator.""" - def __init__(self, hass: HomeAssistant, epion_api: Epion) -> None: + def __init__( + self, hass: HomeAssistant, entry: EpionConfigEntry, epion_api: Epion + ) -> None: """Initialize the Epion coordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, name="Epion", update_interval=REFRESH_INTERVAL, ) diff --git a/homeassistant/components/epion/sensor.py b/homeassistant/components/epion/sensor.py index 4717c095bfe..78027813ffa 100644 --- a/homeassistant/components/epion/sensor.py +++ b/homeassistant/components/epion/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -23,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import EpionCoordinator +from .coordinator import EpionConfigEntry, EpionCoordinator SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -59,11 +58,11 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: EpionConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add an Epion entry.""" - coordinator: EpionCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [ EpionSensor(coordinator, epion_device_id, description) From 8300fd2de86e1020075db4e730206240c3fa2bbd Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 28 Jan 2025 12:06:03 +0100 Subject: [PATCH 1065/2987] Introduce `unique_id` to BackupAgent (#136651) * add unique_id to BackupAgent * adjust tests --- homeassistant/components/backup/agent.py | 3 +- homeassistant/components/backup/backup.py | 1 + homeassistant/components/backup/websocket.py | 5 ++- homeassistant/components/cloud/backup.py | 3 +- homeassistant/components/hassio/backup.py | 2 +- .../components/kitchen_sink/backup.py | 2 +- .../components/synology_dsm/backup.py | 5 ++- tests/components/backup/common.py | 1 + .../backup/snapshots/test_backup.ambr | 5 +++ .../backup/snapshots/test_websocket.ambr | 4 +- tests/components/backup/test_manager.py | 8 ++-- tests/components/cloud/test_backup.py | 5 ++- tests/components/hassio/test_backup.py | 23 ++++++---- tests/components/kitchen_sink/test_backup.py | 14 +++++-- tests/components/synology_dsm/test_backup.py | 42 +++++++++++-------- 15 files changed, 81 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index cb03327e941..fe9eb9ea699 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -30,11 +30,12 @@ class BackupAgent(abc.ABC): domain: str name: str + unique_id: str @cached_property def agent_id(self) -> str: """Return the agent_id.""" - return f"{self.domain}.{self.name}" + return f"{self.domain}.{self.unique_id}" @abc.abstractmethod async def async_download_backup( diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index ef4924161c2..3f60bd0b88e 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -32,6 +32,7 @@ class CoreLocalBackupAgent(LocalBackupAgent): domain = DOMAIN name = "local" + unique_id = "local" def __init__(self, hass: HomeAssistant) -> None: """Initialize the backup agent.""" diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 70fc568c05c..74f56102670 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -306,7 +306,10 @@ async def backup_agents_info( connection.send_result( msg["id"], { - "agents": [{"agent_id": agent_id} for agent_id in manager.backup_agents], + "agents": [ + {"agent_id": agent.agent_id, "name": agent.name} + for agent in manager.backup_agents.values() + ], }, ) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 153d0741770..d42e846259c 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -82,8 +82,7 @@ def async_register_backup_agents_listener( class CloudBackupAgent(BackupAgent): """Cloud backup agent.""" - domain = DOMAIN - name = DOMAIN + domain = name = unique_id = DOMAIN def __init__(self, hass: HomeAssistant, cloud: Cloud[CloudClient]) -> None: """Initialize the cloud backup sync agent.""" diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index d49fafb886f..98ad2ad20e3 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -133,7 +133,7 @@ class SupervisorBackupAgent(BackupAgent): self._hass = hass self._backup_dir = Path("/backups") self._client = get_supervisor_client(hass) - self.name = name + self.name = self.unique_id = name self.location = location async def async_download_backup( diff --git a/homeassistant/components/kitchen_sink/backup.py b/homeassistant/components/kitchen_sink/backup.py index c4a045aeefc..44ac0456105 100644 --- a/homeassistant/components/kitchen_sink/backup.py +++ b/homeassistant/components/kitchen_sink/backup.py @@ -51,7 +51,7 @@ class KitchenSinkBackupAgent(BackupAgent): def __init__(self, name: str) -> None: """Initialize the kitchen sink backup sync agent.""" super().__init__() - self.name = name + self.name = self.unique_id = name self._uploads = [ AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py index eed6af758ba..62a1b97b717 100644 --- a/homeassistant/components/synology_dsm/backup.py +++ b/homeassistant/components/synology_dsm/backup.py @@ -39,7 +39,7 @@ async def async_get_backup_agents( return [] syno_datas: dict[str, SynologyDSMData] = hass.data[DOMAIN] return [ - SynologyDSMBackupAgent(hass, entry) + SynologyDSMBackupAgent(hass, entry, entry.unique_id) for entry in entries if entry.unique_id is not None and (syno_data := syno_datas.get(entry.unique_id)) @@ -76,11 +76,12 @@ class SynologyDSMBackupAgent(BackupAgent): domain = DOMAIN - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, unique_id: str) -> None: """Initialize the Synology DSM backup agent.""" super().__init__() LOGGER.debug("Initializing Synology DSM backup agent for %s", entry.unique_id) self.name = entry.title + self.unique_id = unique_id self.path = ( f"{entry.options[CONF_BACKUP_SHARE]}/{entry.options[CONF_BACKUP_PATH]}" ) diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index 4f456cc6d72..97236ee995d 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -64,6 +64,7 @@ class BackupAgentTest(BackupAgent): 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( diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index f91473e3b70..1a6774e7a95 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -39,6 +39,7 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -97,6 +98,7 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -128,6 +130,7 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -159,6 +162,7 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -190,6 +194,7 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 43b4c1260dd..2a6bc14fb74 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -17,9 +17,11 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), dict({ - 'agent_id': 'domain.test', + 'agent_id': 'test.test', + 'name': 'test', }), ]), }), diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 48e6db4ae9a..8a99f90d234 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -1203,8 +1203,8 @@ async def test_loading_platform_with_listener( await ws_client.send_json_auto_id({"type": "backup/agents/info"}) resp = await ws_client.receive_json() assert resp["result"]["agents"] == [ - {"agent_id": "backup.local"}, - {"agent_id": "test.remote1"}, + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "test.remote1", "name": "remote1"}, ] assert len(manager.local_backup_agents) == num_local_agents @@ -1220,8 +1220,8 @@ async def test_loading_platform_with_listener( await ws_client.send_json_auto_id({"type": "backup/agents/info"}) resp = await ws_client.receive_json() assert resp["result"]["agents"] == [ - {"agent_id": "backup.local"}, - {"agent_id": "test.remote2"}, + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "test.remote2", "name": "remote2"}, ] assert len(manager.local_backup_agents) == num_local_agents diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index db742525a48..373bd164c0c 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -146,7 +146,10 @@ async def test_agents_info( assert response["success"] assert response["result"] == { - "agents": [{"agent_id": "backup.local"}, {"agent_id": "cloud.cloud"}], + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "cloud.cloud", "name": "cloud"}, + ], } diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 40ab253b7e6..9483b513718 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -34,6 +34,7 @@ from homeassistant.components.backup import ( BackupAgentPlatformProtocol, Folder, ) +from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.backup import LOCATION_CLOUD_BACKUP from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -252,11 +253,11 @@ async def setup_integration( class BackupAgentTest(BackupAgent): """Test backup agent.""" - domain = "test" - - def __init__(self, name: str) -> None: + def __init__(self, name: str, domain: str = "test") -> None: """Initialize the backup agent.""" + self.domain = domain self.name = name + self.unique_id = name async def async_download_backup( self, backup_id: str, **kwargs: Any @@ -304,7 +305,10 @@ async def _setup_backup_platform( @pytest.mark.parametrize( ("mounts", "expected_agents"), [ - (MountsInfo(default_backup_mount=None, mounts=[]), ["hassio.local"]), + ( + MountsInfo(default_backup_mount=None, mounts=[]), + [BackupAgentTest("local", DOMAIN)], + ), ( MountsInfo( default_backup_mount=None, @@ -321,7 +325,7 @@ async def _setup_backup_platform( ) ], ), - ["hassio.local", "hassio.test"], + [BackupAgentTest("local", DOMAIN), BackupAgentTest("test", DOMAIN)], ), ( MountsInfo( @@ -339,7 +343,7 @@ async def _setup_backup_platform( ) ], ), - ["hassio.local"], + [BackupAgentTest("local", DOMAIN)], ), ], ) @@ -348,7 +352,7 @@ async def test_agent_info( hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, mounts: MountsInfo, - expected_agents: list[str], + expected_agents: list[BackupAgent], ) -> None: """Test backup agent info.""" client = await hass_ws_client(hass) @@ -361,7 +365,10 @@ async def test_agent_info( assert response["success"] assert response["result"] == { - "agents": [{"agent_id": agent_id} for agent_id in expected_agents], + "agents": [ + {"agent_id": agent.agent_id, "name": agent.name} + for agent in expected_agents + ], } diff --git a/tests/components/kitchen_sink/test_backup.py b/tests/components/kitchen_sink/test_backup.py index 9e46845e1cb..827bde39d7d 100644 --- a/tests/components/kitchen_sink/test_backup.py +++ b/tests/components/kitchen_sink/test_backup.py @@ -55,7 +55,10 @@ async def test_agents_info( assert response["success"] assert response["result"] == { - "agents": [{"agent_id": "backup.local"}, {"agent_id": "kitchen_sink.syncer"}], + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "kitchen_sink.syncer", "name": "syncer"}, + ], } config_entry = hass.config_entries.async_entries(DOMAIN)[0] @@ -66,7 +69,9 @@ async def test_agents_info( response = await client.receive_json() assert response["success"] - assert response["result"] == {"agents": [{"agent_id": "backup.local"}]} + assert response["result"] == { + "agents": [{"agent_id": "backup.local", "name": "local"}] + } await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -76,7 +81,10 @@ async def test_agents_info( assert response["success"] assert response["result"] == { - "agents": [{"agent_id": "backup.local"}, {"agent_id": "kitchen_sink.syncer"}], + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "kitchen_sink.syncer", "name": "syncer"}, + ], } diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 0cd119cf015..436e3666176 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -208,8 +208,8 @@ async def test_agents_info( assert response["success"] assert response["result"] == { "agents": [ - {"agent_id": "synology_dsm.Mock Title"}, - {"agent_id": "backup.local"}, + {"agent_id": "synology_dsm.mocked_syno_dsm_entry", "name": "Mock Title"}, + {"agent_id": "backup.local", "name": "local"}, ], } @@ -231,7 +231,7 @@ async def test_agents_not_loaded( assert response["success"] assert response["result"] == { "agents": [ - {"agent_id": "backup.local"}, + {"agent_id": "backup.local", "name": "local"}, ], } @@ -251,8 +251,8 @@ async def test_agents_on_unload( assert response["success"] assert response["result"] == { "agents": [ - {"agent_id": "synology_dsm.Mock Title"}, - {"agent_id": "backup.local"}, + {"agent_id": "synology_dsm.mocked_syno_dsm_entry", "name": "Mock Title"}, + {"agent_id": "backup.local", "name": "local"}, ], } @@ -269,7 +269,7 @@ async def test_agents_on_unload( assert response["success"] assert response["result"] == { "agents": [ - {"agent_id": "backup.local"}, + {"agent_id": "backup.local", "name": "local"}, ], } @@ -299,7 +299,7 @@ async def test_agents_list_backups( "name": "Automatic backup 2025.2.0.dev0", "protected": True, "size": 13916160, - "agent_ids": ["synology_dsm.Mock Title"], + "agent_ids": ["synology_dsm.mocked_syno_dsm_entry"], "failed_agent_ids": [], "with_automatic_settings": None, } @@ -323,7 +323,9 @@ async def test_agents_list_backups_error( assert response["success"] assert response["result"] == { - "agent_errors": {"synology_dsm.Mock Title": "Failed to list backups"}, + "agent_errors": { + "synology_dsm.mocked_syno_dsm_entry": "Failed to list backups" + }, "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, @@ -362,7 +364,7 @@ async def test_agents_list_backups_disabled_filestation( "name": "Automatic backup 2025.2.0.dev0", "protected": True, "size": 13916160, - "agent_ids": ["synology_dsm.Mock Title"], + "agent_ids": ["synology_dsm.mocked_syno_dsm_entry"], "failed_agent_ids": [], "with_automatic_settings": None, }, @@ -429,7 +431,9 @@ async def test_agents_get_backup_error( assert response["success"] assert response["result"] == { - "agent_errors": {"synology_dsm.Mock Title": "Failed to list backups"}, + "agent_errors": { + "synology_dsm.mocked_syno_dsm_entry": "Failed to list backups" + }, "backup": None, } @@ -462,7 +466,7 @@ async def test_agents_download( backup_id = "abcd12ef" resp = await client.get( - f"/api/backup/download/{backup_id}?agent_id=synology_dsm.Mock Title" + f"/api/backup/download/{backup_id}?agent_id=synology_dsm.mocked_syno_dsm_entry" ) assert resp.status == 200 assert await resp.content.read() == b"backup data" @@ -482,7 +486,7 @@ async def test_agents_download_not_existing( ) resp = await client.get( - f"/api/backup/download/{backup_id}?agent_id=synology_dsm.Mock Title" + f"/api/backup/download/{backup_id}?agent_id=synology_dsm.mocked_syno_dsm_entry" ) assert resp.reason == "Internal Server Error" assert resp.status == 500 @@ -524,7 +528,7 @@ async def test_agents_upload( mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) fetch_backup.return_value = test_backup resp = await client.post( - "/api/backup/upload?agent_id=synology_dsm.Mock Title", + "/api/backup/upload?agent_id=synology_dsm.mocked_syno_dsm_entry", data={"file": StringIO("test")}, ) @@ -578,7 +582,7 @@ async def test_agents_upload_error( SynologyDSMAPIErrorException("api", "500", "error") ) resp = await client.post( - "/api/backup/upload?agent_id=synology_dsm.Mock Title", + "/api/backup/upload?agent_id=synology_dsm.mocked_syno_dsm_entry", data={"file": StringIO("test")}, ) @@ -609,7 +613,7 @@ async def test_agents_upload_error( ] resp = await client.post( - "/api/backup/upload?agent_id=synology_dsm.Mock Title", + "/api/backup/upload?agent_id=synology_dsm.mocked_syno_dsm_entry", data={"file": StringIO("test")}, ) @@ -674,7 +678,9 @@ async def test_agents_delete_not_existing( assert response["success"] assert response["result"] == { - "agent_errors": {"synology_dsm.Mock Title": "Failed to delete the backup"} + "agent_errors": { + "synology_dsm.mocked_syno_dsm_entry": "Failed to delete the backup" + } } @@ -701,7 +707,9 @@ async def test_agents_delete_error( assert response["success"] assert response["result"] == { - "agent_errors": {"synology_dsm.Mock Title": "Failed to delete the backup"} + "agent_errors": { + "synology_dsm.mocked_syno_dsm_entry": "Failed to delete the backup" + } } mock: AsyncMock = setup_dsm_with_filestation.file.delete_file assert len(mock.mock_calls) == 1 From 1f35451863863142b50aa94055b82519564c0b1e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 12:24:03 +0100 Subject: [PATCH 1066/2987] Use runtime_data in epic_games_store (#136709) --- .../components/epic_games_store/__init__.py | 15 +++++---------- .../components/epic_games_store/calendar.py | 7 +++---- .../components/epic_games_store/coordinator.py | 5 ++++- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/epic_games_store/__init__.py b/homeassistant/components/epic_games_store/__init__.py index af25eb98137..d9fb3bee529 100644 --- a/homeassistant/components/epic_games_store/__init__.py +++ b/homeassistant/components/epic_games_store/__init__.py @@ -2,34 +2,29 @@ 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 EGSCalendarUpdateCoordinator +from .coordinator import EGSCalendarUpdateCoordinator, EGSConfigEntry PLATFORMS: list[Platform] = [ Platform.CALENDAR, ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EGSConfigEntry) -> bool: """Set up Epic Games Store from a config entry.""" coordinator = EGSCalendarUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = 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: EGSConfigEntry) -> 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(entry, PLATFORMS) diff --git a/homeassistant/components/epic_games_store/calendar.py b/homeassistant/components/epic_games_store/calendar.py index 2ebb381341e..5df1d6b756d 100644 --- a/homeassistant/components/epic_games_store/calendar.py +++ b/homeassistant/components/epic_games_store/calendar.py @@ -7,25 +7,24 @@ from datetime import datetime from typing import Any from homeassistant.components.calendar import CalendarEntity, CalendarEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, CalendarType -from .coordinator import EGSCalendarUpdateCoordinator +from .coordinator import EGSCalendarUpdateCoordinator, EGSConfigEntry DateRange = namedtuple("DateRange", ["start", "end"]) # noqa: PYI024 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: EGSConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the local calendar platform.""" - coordinator: EGSCalendarUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [ EGSCalendar(coordinator, entry.entry_id, CalendarType.FREE), diff --git a/homeassistant/components/epic_games_store/coordinator.py b/homeassistant/components/epic_games_store/coordinator.py index d9c48f5da02..0653a3da9b3 100644 --- a/homeassistant/components/epic_games_store/coordinator.py +++ b/homeassistant/components/epic_games_store/coordinator.py @@ -20,13 +20,15 @@ SCAN_INTERVAL = timedelta(days=1) _LOGGER = logging.getLogger(__name__) +type EGSConfigEntry = ConfigEntry[EGSCalendarUpdateCoordinator] + class EGSCalendarUpdateCoordinator( DataUpdateCoordinator[dict[str, list[dict[str, Any]]]] ): """Class to manage fetching data from the Epic Game Store.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: EGSConfigEntry) -> None: """Initialize.""" self._api = EpicGamesStoreAPI( entry.data[CONF_LANGUAGE], @@ -37,6 +39,7 @@ class EGSCalendarUpdateCoordinator( super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) From 82ee47ef77e8a773b30f5e10dd6eac8865844a3e Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 28 Jan 2025 12:44:46 +0100 Subject: [PATCH 1067/2987] Initial implementation for tplink tapo vacuums (#131965) Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- homeassistant/components/tplink/const.py | 1 + homeassistant/components/tplink/entity.py | 1 + homeassistant/components/tplink/vacuum.py | 158 ++++++++++++++++++ tests/components/tplink/__init__.py | 75 ++++++++- .../tplink/snapshots/test_vacuum.ambr | 96 +++++++++++ tests/components/tplink/test_vacuum.py | 125 ++++++++++++++ 6 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/tplink/vacuum.py create mode 100644 tests/components/tplink/snapshots/test_vacuum.ambr create mode 100644 tests/components/tplink/test_vacuum.py diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index 61c1bf1cb9b..ad17aadeb5b 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -41,6 +41,7 @@ PLATFORMS: Final = [ Platform.SENSOR, Platform.SIREN, Platform.SWITCH, + Platform.VACUUM, ] UNIT_MAPPING = { diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index edef8bd83a0..6c21ab63285 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -59,6 +59,7 @@ DEVICETYPES_WITH_SPECIALIZED_PLATFORMS = { DeviceType.Dimmer, DeviceType.Fan, DeviceType.Thermostat, + DeviceType.Vacuum, } # Primary features to always include even when the device type has its own platform diff --git a/homeassistant/components/tplink/vacuum.py b/homeassistant/components/tplink/vacuum.py new file mode 100644 index 00000000000..666584f4980 --- /dev/null +++ b/homeassistant/components/tplink/vacuum.py @@ -0,0 +1,158 @@ +"""Support for TPLink vacuum.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast + +from kasa import Device, Feature, Module +from kasa.smart.modules.clean import Clean, Status + +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + StateVacuumEntity, + StateVacuumEntityDescription, + VacuumActivity, + VacuumEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TPLinkConfigEntry +from .coordinator import TPLinkDataUpdateCoordinator +from .entity import ( + CoordinatedTPLinkModuleEntity, + TPLinkModuleEntityDescription, + async_refresh_after, +) + +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + +# Upstream state to VacuumActivity +STATUS_TO_ACTIVITY = { + Status.Idle: VacuumActivity.IDLE, + Status.Cleaning: VacuumActivity.CLEANING, + Status.GoingHome: VacuumActivity.RETURNING, + Status.Charging: VacuumActivity.DOCKED, + Status.Charged: VacuumActivity.DOCKED, + Status.Undocked: VacuumActivity.IDLE, + Status.Paused: VacuumActivity.PAUSED, + Status.Error: VacuumActivity.ERROR, +} + + +@dataclass(frozen=True, kw_only=True) +class TPLinkVacuumEntityDescription( + StateVacuumEntityDescription, TPLinkModuleEntityDescription +): + """Base class for vacuum entity description.""" + + +VACUUM_DESCRIPTIONS: tuple[TPLinkVacuumEntityDescription, ...] = ( + TPLinkVacuumEntityDescription( + key="vacuum", exists_fn=lambda dev, _: Module.Clean in dev.modules + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TPLinkConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up vacuum entities.""" + data = config_entry.runtime_data + parent_coordinator = data.parent_coordinator + device = parent_coordinator.device + + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkVacuumEntity, + descriptions=VACUUM_DESCRIPTIONS, + platform_domain=VACUUM_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) + + +class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity): + """Representation of a tplink vacuum.""" + + _attr_supported_features = ( + VacuumEntityFeature.STATE + | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.START + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.FAN_SPEED + ) + + entity_description: TPLinkVacuumEntityDescription + + def __init__( + self, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkVacuumEntityDescription, + *, + parent: Device, + ) -> None: + """Initialize the vacuum entity.""" + super().__init__(device, coordinator, description, parent=parent) + self._vacuum_module: Clean = device.modules[Module.Clean] + if speaker := device.modules.get(Module.Speaker): + self._speaker_module = speaker + self._attr_supported_features |= VacuumEntityFeature.LOCATE + + # Needs to be initialized empty, as vacuumentity's capability_attributes accesses it + self._attr_fan_speed_list: list[str] = [] + + @async_refresh_after + async def async_start(self) -> None: + """Start cleaning.""" + await self._vacuum_module.start() + + @async_refresh_after + async def async_pause(self) -> None: + """Pause cleaning.""" + await self._vacuum_module.pause() + + @async_refresh_after + async def async_return_to_base(self, **kwargs: Any) -> None: + """Return home.""" + await self._vacuum_module.return_home() + + @async_refresh_after + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + await self._vacuum_module.set_fan_speed_preset(fan_speed) + + async def async_locate(self, **kwargs: Any) -> None: + """Locate the device.""" + await self._speaker_module.locate() + + @property + def battery_level(self) -> int | None: + """Return battery level.""" + return self._vacuum_module.battery + + def _async_update_attrs(self) -> bool: + """Update the entity's attributes.""" + self._attr_activity = STATUS_TO_ACTIVITY.get(self._vacuum_module.status) + fanspeeds = cast(Feature, self._vacuum_module.get_feature("fan_speed_preset")) + self._attr_fan_speed_list = cast(list[str], fanspeeds.choices) + self._attr_fan_speed = self._vacuum_module.fan_speed_preset + return True diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 008d25a3dcb..664fb96fe71 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -16,7 +16,9 @@ from kasa import ( ThermostatState, ) from kasa.interfaces import Fan, Light, LightEffect, LightState, Thermostat +from kasa.smart.modules import Speaker from kasa.smart.modules.alarm import Alarm +from kasa.smart.modules.clean import Clean, ErrorCode, Status from kasa.smartcam.modules.camera import LOCAL_STREAMING_PORT, Camera from syrupy import SnapshotAssertion @@ -195,16 +197,33 @@ def _mocked_device( ) device.features = device_features - # Add modules after features so modules can add required features + # Add modules after features so modules can add any required features if modules: device.modules = { module_name: MODULE_TO_MOCK_GEN[module_name](device) for module_name in modules } + # module features are accessed from a module via get_feature which is + # keyed on the module attribute name. Usually this is the same as the + # feature.id but not always so accept overrides. + module_features = { + mod_key if (mod_key := v.expected_module_key) else k: v + for k, v in device_features.items() + } for mod in device.modules.values(): - mod.get_feature.side_effect = device_features.get - mod.has_feature.side_effect = lambda id: id in device_features + # Some tests remove the feature from device_features to test missing + # features, so check the key is still present there. + mod.get_feature.side_effect = ( + lambda mod_id: mod_feat + if (mod_feat := module_features.get(mod_id)) + and mod_feat.id in device_features + else None + ) + mod.has_feature.side_effect = ( + lambda mod_id: (mod_feat := module_features.get(mod_id)) + and mod_feat.id in device_features + ) device.parent = None device.children = [] @@ -243,6 +262,7 @@ def _mocked_feature( unit=None, minimum_value=None, maximum_value=None, + expected_module_key=None, ) -> Feature: """Get a mocked feature. @@ -284,6 +304,16 @@ def _mocked_feature( # select feature.choices = choices or fixture.get("choices") + # module features are accessed from a module via get_feature which is + # keyed on the module attribute name. Usually this is the same as the + # feature.id but not always. module_key indicates the key of the feature + # in the module. + feature.expected_module_key = ( + mod_key + if (mod_key := fixture.get("expected_module_key", expected_module_key)) + else None + ) + return feature @@ -400,6 +430,43 @@ def _mocked_thermostat_module(device): return therm +def _mocked_clean_module(device): + clean = MagicMock(auto_spec=Clean, name="Mocked clean") + + # methods + clean.start = AsyncMock() + clean.pause = AsyncMock() + clean.resume = AsyncMock() + clean.return_home = AsyncMock() + clean.set_fan_speed_preset = AsyncMock() + + # properties + clean.fan_speed_preset = "Max" + clean.error = ErrorCode.Ok + clean.battery = 100 + clean.status = Status.Charged + + # Need to manually create the fan speed preset feature, + # as we are going to read its choices through it + device.features["vacuum_fan_speed"] = _mocked_feature( + "vacuum_fan_speed", + type_=Feature.Type.Choice, + category=Feature.Category.Config, + choices=["Quiet", "Max"], + value="Max", + expected_module_key="fan_speed_preset", + ) + + return clean + + +def _mocked_speaker_module(device): + speaker = MagicMock(auto_spec=Speaker, name="Mocked speaker") + speaker.locate = AsyncMock() + + return speaker + + def _mocked_strip_children(features=None, alias=None) -> list[Device]: plug0 = _mocked_device( alias="Plug0" if alias is None else alias, @@ -469,6 +536,8 @@ MODULE_TO_MOCK_GEN = { Module.Alarm: _mocked_alarm_module, Module.Camera: _mocked_camera_module, Module.Thermostat: _mocked_thermostat_module, + Module.Clean: _mocked_clean_module, + Module.Speaker: _mocked_speaker_module, } diff --git a/tests/components/tplink/snapshots/test_vacuum.ambr b/tests/components/tplink/snapshots/test_vacuum.ambr new file mode 100644 index 00000000000..a28a7d80ab4 --- /dev/null +++ b/tests/components/tplink/snapshots/test_vacuum.ambr @@ -0,0 +1,96 @@ +# serializer version: 1 +# name: test_states[my_vacuum-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'id': , + 'identifiers': set({ + tuple( + 'tplink', + '123456789ABCDEFGH', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'TP-Link', + 'model': 'HS100', + 'model_id': None, + 'name': 'my_vacuum', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }) +# --- +# name: test_states[vacuum.my_vacuum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_speed_list': list([ + 'Quiet', + 'Max', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.my_vacuum', + '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': None, + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABCDEFGH-vacuum', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[vacuum.my_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_icon': 'mdi:battery-charging-100', + 'battery_level': 100, + 'fan_speed': 'Max', + 'fan_speed_list': list([ + 'Quiet', + 'Max', + ]), + 'friendly_name': 'my_vacuum', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.my_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docked', + }) +# --- diff --git a/tests/components/tplink/test_vacuum.py b/tests/components/tplink/test_vacuum.py new file mode 100644 index 00000000000..aac7c4f7fc8 --- /dev/null +++ b/tests/components/tplink/test_vacuum.py @@ -0,0 +1,125 @@ +"""Tests for vacuum platform.""" + +from __future__ import annotations + +from kasa import Device, Module +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.vacuum import ( + ATTR_BATTERY_LEVEL, + ATTR_FAN_SPEED, + DOMAIN as VACUUM_DOMAIN, + SERVICE_LOCATE, + SERVICE_PAUSE, + SERVICE_RETURN_TO_BASE, + SERVICE_SET_FAN_SPEED, + SERVICE_START, + VacuumActivity, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import DEVICE_ID, _mocked_device, setup_platform_for_device, snapshot_platform + +from tests.common import MockConfigEntry + +ENTITY_ID = "vacuum.my_vacuum" + + +@pytest.fixture +async def mocked_vacuum(hass: HomeAssistant) -> Device: + """Return mocked tplink vacuum.""" + + return _mocked_device(modules=[Module.Clean, Module.Speaker], alias="my_vacuum") + + +async def test_vacuum( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mocked_vacuum: Device, +) -> None: + """Test initialization.""" + await setup_platform_for_device( + hass, mock_config_entry, Platform.VACUUM, mocked_vacuum + ) + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert device_entries + + entity = entity_registry.async_get(ENTITY_ID) + assert entity + assert entity.unique_id == f"{DEVICE_ID}-vacuum" + + state = hass.states.get(ENTITY_ID) + assert state.state == VacuumActivity.DOCKED + + assert state.attributes[ATTR_FAN_SPEED] == "Max" + assert state.attributes[ATTR_BATTERY_LEVEL] == 100 + + +async def test_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + mocked_vacuum: Device, +) -> None: + """Test vacuum states.""" + await setup_platform_for_device( + hass, mock_config_entry, Platform.VACUUM, mocked_vacuum + ) + await snapshot_platform( + hass, entity_registry, device_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("service_call", "module_name", "method", "params"), + [ + (SERVICE_START, Module.Clean, "start", {}), + (SERVICE_PAUSE, Module.Clean, "pause", {}), + (SERVICE_RETURN_TO_BASE, Module.Clean, "return_home", {}), + ( + SERVICE_SET_FAN_SPEED, + Module.Clean, + "set_fan_speed_preset", + {ATTR_FAN_SPEED: "Quiet"}, + ), + (SERVICE_LOCATE, Module.Speaker, "locate", {}), + ], +) +async def test_vacuum_module( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_vacuum: Device, + service_call: str, + module_name: str, + method: str, + params: dict, +) -> None: + """Test that all vacuum commands work correctly.""" + vacuum = mocked_vacuum + module = vacuum.modules[module_name] + + await setup_platform_for_device(hass, mock_config_entry, Platform.VACUUM, vacuum) + + mock_method = getattr(module, method) + + service_data = {ATTR_ENTITY_ID: ENTITY_ID} + service_data |= params + + await hass.services.async_call( + VACUUM_DOMAIN, service_call, service_data, blocking=True + ) + + # Is this required when using blocking=True? + await hass.async_block_till_done(wait_background_tasks=True) + + mock_method.assert_called() From 7db6f44f2d4d055f07f0209f97a685c9385ae0ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jan 2025 13:15:41 +0100 Subject: [PATCH 1068/2987] Bump github/codeql-action from 3.28.5 to 3.28.6 (#136686) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9dbd39b4bc5..d7f46b176cd 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.5 + uses: github/codeql-action/init@v3.28.6 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.5 + uses: github/codeql-action/analyze@v3.28.6 with: category: "/language:python" From 7f3e56eb582a21b4fb881f37caf62e70a771ec5c Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 28 Jan 2025 12:17:35 +0000 Subject: [PATCH 1069/2987] Update tplink coordinators to update hub-attached children (#135586) --- homeassistant/components/tplink/coordinator.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index 186840e8faf..d1b4694779d 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -49,6 +49,12 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): ) -> None: """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" self.device = device + + # The iot HS300 allows a limited number of concurrent requests and + # fetching the emeter information requires separate ones, so child + # coordinators are created below in get_child_coordinator. + self._update_children = not isinstance(device, IotStrip) + super().__init__( hass, _LOGGER, @@ -68,7 +74,7 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Fetch all device and sensor data from api.""" try: - await self.device.update(update_children=False) + await self.device.update(update_children=self._update_children) except AuthenticationError as ex: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, From fa4b93da2b25dde5914a65823ab785a9007e63c5 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 28 Jan 2025 13:24:44 +0100 Subject: [PATCH 1070/2987] Bump bring-api to 1.0.0 (#136657) --- homeassistant/components/bring/config_flow.py | 3 +- homeassistant/components/bring/coordinator.py | 16 +- homeassistant/components/bring/diagnostics.py | 7 +- homeassistant/components/bring/entity.py | 6 +- homeassistant/components/bring/manifest.json | 2 +- homeassistant/components/bring/sensor.py | 4 +- homeassistant/components/bring/todo.py | 48 ++--- homeassistant/components/bring/util.py | 24 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bring/conftest.py | 26 ++- tests/components/bring/fixtures/items.json | 78 ++++---- .../bring/fixtures/items_invitation.json | 78 ++++---- .../bring/fixtures/items_shared.json | 78 ++++---- tests/components/bring/fixtures/login.json | 12 ++ .../bring/snapshots/test_diagnostics.ambr | 168 ++++++++++-------- tests/components/bring/test_sensor.py | 8 +- tests/components/bring/test_util.py | 20 +-- 18 files changed, 311 insertions(+), 271 deletions(-) create mode 100644 tests/components/bring/fixtures/login.json diff --git a/homeassistant/components/bring/config_flow.py b/homeassistant/components/bring/config_flow.py index b8ee9d1e6ae..bfb5a2cd50f 100644 --- a/homeassistant/components/bring/config_flow.py +++ b/homeassistant/components/bring/config_flow.py @@ -63,7 +63,8 @@ class BringConfigFlow(ConfigFlow, domain=DOMAIN): ): self._abort_if_unique_id_configured() return self.async_create_entry( - title=self.info.get("name") or user_input[CONF_EMAIL], data=user_input + title=self.info.name or user_input[CONF_EMAIL], + data=user_input, ) return self.async_show_form( diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index d02237e84eb..0511d285afc 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging @@ -12,6 +13,7 @@ from bring_api import ( BringRequestException, ) from bring_api.types import BringItemsResponse, BringList, BringUserSettingsResponse +from mashumaro.mixins.orjson import DataClassORJSONMixin from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL @@ -24,9 +26,13 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class BringData(BringList, BringItemsResponse): +@dataclass(frozen=True) +class BringData(DataClassORJSONMixin): """Coordinator data class.""" + lst: BringList + content: BringItemsResponse + class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): """A Bring Data Update Coordinator.""" @@ -67,11 +73,11 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): return self.data list_dict: dict[str, BringData] = {} - for lst in lists_response["lists"]: - if (ctx := set(self.async_contexts())) and lst["listUuid"] not in ctx: + for lst in lists_response.lists: + if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx: continue try: - items = await self.bring.get_list(lst["listUuid"]) + items = await self.bring.get_list(lst.listUuid) except BringRequestException as e: raise UpdateFailed( "Unable to connect and retrieve data from bring" @@ -79,7 +85,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): except BringParseException as e: raise UpdateFailed("Unable to parse response from bring") from e else: - list_dict[lst["listUuid"]] = BringData(**lst, **items) + list_dict[lst.listUuid] = BringData(lst, items) return list_dict diff --git a/homeassistant/components/bring/diagnostics.py b/homeassistant/components/bring/diagnostics.py index f4193a9993c..1dec8f3a5ed 100644 --- a/homeassistant/components/bring/diagnostics.py +++ b/homeassistant/components/bring/diagnostics.py @@ -2,15 +2,16 @@ from __future__ import annotations +from typing import Any + from homeassistant.core import HomeAssistant from . import BringConfigEntry -from .coordinator import BringData async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: BringConfigEntry -) -> dict[str, BringData]: +) -> dict[str, Any]: """Return diagnostics for a config entry.""" - return config_entry.runtime_data.data + return {k: v.to_dict() for k, v in config_entry.runtime_data.data.items()} diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py index a1e0cb2edc0..74076d66df9 100644 --- a/homeassistant/components/bring/entity.py +++ b/homeassistant/components/bring/entity.py @@ -20,13 +20,13 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): bring_list: BringData, ) -> None: """Initialize the entity.""" - super().__init__(coordinator, bring_list["listUuid"]) + super().__init__(coordinator, bring_list.lst.listUuid) - self._list_uuid = bring_list["listUuid"] + self._list_uuid = bring_list.lst.listUuid self.device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - name=bring_list["name"], + name=bring_list.lst.name, identifiers={ (DOMAIN, f"{coordinator.config_entry.unique_id}_{self._list_uuid}") }, diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index 71fe733ccf5..ecd3e911078 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["bring_api"], - "requirements": ["bring-api==0.9.1"] + "requirements": ["bring-api==1.0.0"] } diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index bd33ce9bf88..02bd0e50788 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -65,7 +65,7 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = ( translation_key=BringSensor.LIST_LANGUAGE, value_fn=( lambda lst, settings: x.lower() - if (x := list_language(lst["listUuid"], settings)) + if (x := list_language(lst.lst.listUuid, settings)) else None ), entity_category=EntityCategory.DIAGNOSTIC, @@ -75,7 +75,7 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = ( BringSensorEntityDescription( key=BringSensor.LIST_ACCESS, translation_key=BringSensor.LIST_ACCESS, - value_fn=lambda lst, _: lst["status"].lower(), + value_fn=lambda lst, _: lst.content.status.value.lower(), entity_category=EntityCategory.DIAGNOSTIC, options=["registered", "shared", "invitation"], device_class=SensorDeviceClass.ENUM, diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index 75657e2fd64..7ab60084314 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -2,6 +2,7 @@ from __future__ import annotations +from itertools import chain from typing import TYPE_CHECKING import uuid @@ -59,7 +60,7 @@ async def async_setup_entry( SERVICE_PUSH_NOTIFICATION, { vol.Required(ATTR_NOTIFICATION_TYPE): vol.All( - vol.Upper, cv.enum(BringNotificationType) + vol.Upper, vol.Coerce(BringNotificationType) ), vol.Optional(ATTR_ITEM_NAME): cv.string, }, @@ -92,21 +93,21 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): return [ *( TodoItem( - uid=item["uuid"], - summary=item["itemId"], - description=item["specification"] or "", + uid=item.uuid, + summary=item.itemId, + description=item.specification, status=TodoItemStatus.NEEDS_ACTION, ) - for item in self.bring_list["purchase"] + for item in self.bring_list.content.items.purchase ), *( TodoItem( - uid=item["uuid"], - summary=item["itemId"], - description=item["specification"] or "", + uid=item.uuid, + summary=item.itemId, + description=item.specification, status=TodoItemStatus.COMPLETED, ) - for item in self.bring_list["recently"] + for item in self.bring_list.content.items.recently ), ] @@ -119,7 +120,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): """Add an item to the To-do list.""" try: await self.coordinator.bring.save_item( - self.bring_list["listUuid"], + self._list_uuid, item.summary or "", item.description or "", str(uuid.uuid4()), @@ -154,26 +155,25 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): bring_list = self.bring_list - bring_purchase_item = next( - (i for i in bring_list["purchase"] if i["uuid"] == item.uid), + current_item = next( + ( + i + for i in chain( + bring_list.content.items.purchase, bring_list.content.items.recently + ) + if i.uuid == item.uid + ), None, ) - bring_recently_item = next( - (i for i in bring_list["recently"] if i["uuid"] == item.uid), - None, - ) - - current_item = bring_purchase_item or bring_recently_item - if TYPE_CHECKING: assert item.uid assert current_item - if item.summary == current_item["itemId"]: + if item.summary == current_item.itemId: try: await self.coordinator.bring.batch_update_list( - bring_list["listUuid"], + self._list_uuid, BringItem( itemId=item.summary or "", spec=item.description or "", @@ -192,10 +192,10 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): else: try: await self.coordinator.bring.batch_update_list( - bring_list["listUuid"], + self._list_uuid, [ BringItem( - itemId=current_item["itemId"], + itemId=current_item.itemId, spec=item.description or "", uuid=item.uid, operation=BringItemOperation.REMOVE, @@ -225,7 +225,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): try: await self.coordinator.bring.batch_update_list( - self.bring_list["listUuid"], + self._list_uuid, [ BringItem( itemId=uid, diff --git a/homeassistant/components/bring/util.py b/homeassistant/components/bring/util.py index b706156a3d3..9a075f7bb89 100644 --- a/homeassistant/components/bring/util.py +++ b/homeassistant/components/bring/util.py @@ -14,27 +14,25 @@ def list_language( """Get the lists language setting.""" try: list_settings = next( - filter( - lambda x: x["listUuid"] == list_uuid, - user_settings["userlistsettings"], - ) + filter(lambda x: x.listUuid == list_uuid, user_settings.userlistsettings) ) - return next( - filter( - lambda x: x["key"] == "listArticleLanguage", - list_settings["usersettings"], + return ( + next( + filter( + lambda x: x.key == "listArticleLanguage", list_settings.usersettings + ) ) - )["value"] + ).value - except (StopIteration, KeyError): + except StopIteration: return None def sum_attributes(bring_list: BringData, attribute: str) -> int: """Count items with given attribute set.""" return sum( - item["attributes"][0]["content"][attribute] - for item in bring_list["purchase"] - if len(item.get("attributes", [])) + getattr(item.attributes[0].content, attribute) + for item in bring_list.content.items.purchase + if item.attributes ) diff --git a/requirements_all.txt b/requirements_all.txt index 8e5081db52d..f154531e83b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -644,7 +644,7 @@ boto3==1.34.131 botocore==1.34.131 # homeassistant.components.bring -bring-api==0.9.1 +bring-api==1.0.0 # homeassistant.components.broadlink broadlink==0.19.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a28323c95e..9be1cd9dab5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -564,7 +564,7 @@ boschshcpy==0.2.91 botocore==1.34.131 # homeassistant.components.bring -bring-api==0.9.1 +bring-api==1.0.0 # homeassistant.components.broadlink broadlink==0.19.0 diff --git a/tests/components/bring/conftest.py b/tests/components/bring/conftest.py index 7d1b787ff0b..2b2e9257097 100644 --- a/tests/components/bring/conftest.py +++ b/tests/components/bring/conftest.py @@ -1,17 +1,21 @@ """Common fixtures for the Bring! tests.""" from collections.abc import Generator -from typing import cast from unittest.mock import AsyncMock, patch import uuid -from bring_api.types import BringAuthResponse +from bring_api.types import ( + BringAuthResponse, + BringItemsResponse, + BringListResponse, + BringUserSettingsResponse, +) import pytest from homeassistant.components.bring.const import DOMAIN from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry, load_fixture EMAIL = "test-email" PASSWORD = "test-password" @@ -44,11 +48,17 @@ def mock_bring_client() -> Generator[AsyncMock]: client = mock_client.return_value client.uuid = UUID client.mail = EMAIL - client.login.return_value = cast(BringAuthResponse, {"name": "Bring"}) - client.load_lists.return_value = load_json_object_fixture("lists.json", DOMAIN) - client.get_list.return_value = load_json_object_fixture("items.json", DOMAIN) - client.get_all_user_settings.return_value = load_json_object_fixture( - "usersettings.json", DOMAIN + client.login.return_value = BringAuthResponse.from_json( + load_fixture("login.json", DOMAIN) + ) + client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists.json", DOMAIN) + ) + client.get_list.return_value = BringItemsResponse.from_json( + load_fixture("items.json", DOMAIN) + ) + client.get_all_user_settings.return_value = BringUserSettingsResponse.from_json( + load_fixture("usersettings.json", DOMAIN) ) yield client diff --git a/tests/components/bring/fixtures/items.json b/tests/components/bring/fixtures/items.json index e0b9006167b..eecdbaac8c7 100644 --- a/tests/components/bring/fixtures/items.json +++ b/tests/components/bring/fixtures/items.json @@ -1,44 +1,46 @@ { "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", "status": "REGISTERED", - "purchase": [ - { - "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", - "itemId": "Paprika", - "specification": "Rot", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + "items": { + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - }, - { - "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", - "itemId": "Pouletbrüstli", - "specification": "Bio", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - } - ], - "recently": [ - { - "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", - "itemId": "Ananas", - "specification": "", - "attributes": [] - } - ] + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] + } } diff --git a/tests/components/bring/fixtures/items_invitation.json b/tests/components/bring/fixtures/items_invitation.json index 82ef623e439..be3671c359a 100644 --- a/tests/components/bring/fixtures/items_invitation.json +++ b/tests/components/bring/fixtures/items_invitation.json @@ -1,44 +1,46 @@ { "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", "status": "INVITATION", - "purchase": [ - { - "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", - "itemId": "Paprika", - "specification": "Rot", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + "items": { + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - }, - { - "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", - "itemId": "Pouletbrüstli", - "specification": "Bio", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - } - ], - "recently": [ - { - "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", - "itemId": "Ananas", - "specification": "", - "attributes": [] - } - ] + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] + } } diff --git a/tests/components/bring/fixtures/items_shared.json b/tests/components/bring/fixtures/items_shared.json index 9ac999729d3..5e381d27ca8 100644 --- a/tests/components/bring/fixtures/items_shared.json +++ b/tests/components/bring/fixtures/items_shared.json @@ -1,44 +1,46 @@ { "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", "status": "SHARED", - "purchase": [ - { - "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", - "itemId": "Paprika", - "specification": "Rot", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + "items": { + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - }, - { - "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", - "itemId": "Pouletbrüstli", - "specification": "Bio", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - } - ], - "recently": [ - { - "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", - "itemId": "Ananas", - "specification": "", - "attributes": [] - } - ] + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] + } } diff --git a/tests/components/bring/fixtures/login.json b/tests/components/bring/fixtures/login.json new file mode 100644 index 00000000000..62616471734 --- /dev/null +++ b/tests/components/bring/fixtures/login.json @@ -0,0 +1,12 @@ +{ + "uuid": "4d717571-174a-4bc1-ab24-929c7227ca43", + "publicUuid": "9a21fdfc-63a4-441a-afc1-ef3030605a9d", + "email": "test-email", + "name": "Bring", + "photoPath": "", + "bringListUUID": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", + "access_token": "ACCESS_TOKEN", + "refresh_token": "REFRESH_TOKEN", + "token_type": "Bearer", + "expires_in": 604799 +} diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index 6d830a12133..5955ded832a 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -2,100 +2,112 @@ # name: test_diagnostics dict({ 'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({ - 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', - 'name': 'Baumarkt', - 'purchase': list([ - dict({ - 'attributes': list([ + 'content': dict({ + 'items': dict({ + 'purchase': list([ dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Paprika', + 'specification': 'Rot', + 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + }), + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Pouletbrüstli', + 'specification': 'Bio', + 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', }), ]), - 'itemId': 'Paprika', - 'specification': 'Rot', - 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', - }), - dict({ - 'attributes': list([ + 'recently': list([ dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', + 'attributes': list([ + ]), + 'itemId': 'Ananas', + 'specification': '', + 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', }), ]), - 'itemId': 'Pouletbrüstli', - 'specification': 'Bio', - 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', }), - ]), - 'recently': list([ - dict({ - 'attributes': list([ - ]), - 'itemId': 'Ananas', - 'specification': '', - 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', - }), - ]), - 'status': 'REGISTERED', - 'theme': 'ch.publisheria.bring.theme.home', - 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + 'status': 'REGISTERED', + 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + }), + 'lst': dict({ + 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + 'name': 'Baumarkt', + 'theme': 'ch.publisheria.bring.theme.home', + }), }), 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5': dict({ - 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', - 'name': 'Einkauf', - 'purchase': list([ - dict({ - 'attributes': list([ + 'content': dict({ + 'items': dict({ + 'purchase': list([ dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Paprika', + 'specification': 'Rot', + 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + }), + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Pouletbrüstli', + 'specification': 'Bio', + 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', }), ]), - 'itemId': 'Paprika', - 'specification': 'Rot', - 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', - }), - dict({ - 'attributes': list([ + 'recently': list([ dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', + 'attributes': list([ + ]), + 'itemId': 'Ananas', + 'specification': '', + 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', }), ]), - 'itemId': 'Pouletbrüstli', - 'specification': 'Bio', - 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', }), - ]), - 'recently': list([ - dict({ - 'attributes': list([ - ]), - 'itemId': 'Ananas', - 'specification': '', - 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', - }), - ]), - 'status': 'REGISTERED', - 'theme': 'ch.publisheria.bring.theme.home', - 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + 'status': 'REGISTERED', + 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + }), + 'lst': dict({ + 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', + 'name': 'Einkauf', + 'theme': 'ch.publisheria.bring.theme.home', + }), }), }) # --- diff --git a/tests/components/bring/test_sensor.py b/tests/components/bring/test_sensor.py index 974818ccedf..442fea5a247 100644 --- a/tests/components/bring/test_sensor.py +++ b/tests/components/bring/test_sensor.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from bring_api import BringItemsResponse import pytest from syrupy.assertion import SnapshotAssertion @@ -12,7 +13,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform +from tests.common import MockConfigEntry, load_fixture, snapshot_platform @pytest.fixture(autouse=True) @@ -62,10 +63,9 @@ async def test_list_access_states( ) -> None: """Snapshot test states of list access sensor.""" - mock_bring_client.get_list.return_value = load_json_object_fixture( - f"{fixture}.json", DOMAIN + mock_bring_client.get_list.return_value = BringItemsResponse.from_json( + load_fixture(f"{fixture}.json", DOMAIN) ) - bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/bring/test_util.py b/tests/components/bring/test_util.py index 88379530362..3060f31c134 100644 --- a/tests/components/bring/test_util.py +++ b/tests/components/bring/test_util.py @@ -1,15 +1,13 @@ """Test for utility functions of the Bring! integration.""" -from typing import cast - -from bring_api import BringUserSettingsResponse +from bring_api import BringItemsResponse, BringListResponse, BringUserSettingsResponse import pytest from homeassistant.components.bring.const import DOMAIN from homeassistant.components.bring.coordinator import BringData from homeassistant.components.bring.util import list_language, sum_attributes -from tests.common import load_json_object_fixture +from tests.common import load_fixture @pytest.mark.parametrize( @@ -17,7 +15,7 @@ from tests.common import load_json_object_fixture [ ("e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "de-DE"), ("b4776778-7f6c-496e-951b-92a35d3db0dd", "en-US"), - ("00000000-0000-0000-0000-00000000", None), + ("00000000-0000-0000-0000-000000000000", None), ], ) def test_list_language(list_uuid: str, expected: str | None) -> None: @@ -25,10 +23,7 @@ def test_list_language(list_uuid: str, expected: str | None) -> None: result = list_language( list_uuid, - cast( - BringUserSettingsResponse, - load_json_object_fixture("usersettings.json", DOMAIN), - ), + BringUserSettingsResponse.from_json(load_fixture("usersettings.json", DOMAIN)), ) assert result == expected @@ -44,12 +39,11 @@ def test_list_language(list_uuid: str, expected: str | None) -> None: ) def test_sum_attributes(attribute: str, expected: int) -> None: """Test function sum_attributes.""" + items = BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)) + lst = BringListResponse.from_json(load_fixture("lists.json", DOMAIN)) result = sum_attributes( - cast( - BringData, - load_json_object_fixture("items.json", DOMAIN), - ), + BringData(lst.lists[0], items), attribute, ) From 1b78bbaaabebb57a7f7f34ac287b0943f97a6e70 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Tue, 28 Jan 2025 07:25:54 -0500 Subject: [PATCH 1071/2987] Bump nice-go to 1.0.1 (#136649) --- homeassistant/components/nice_go/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nice_go/manifest.json b/homeassistant/components/nice_go/manifest.json index 1af23ec4d9b..8f43ed8a3e8 100644 --- a/homeassistant/components/nice_go/manifest.json +++ b/homeassistant/components/nice_go/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["nice_go"], - "requirements": ["nice-go==1.0.0"] + "requirements": ["nice-go==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f154531e83b..97d3a5402a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1489,7 +1489,7 @@ nhc==0.3.9 nibe==2.14.0 # homeassistant.components.nice_go -nice-go==1.0.0 +nice-go==1.0.1 # homeassistant.components.nilu niluclient==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9be1cd9dab5..3e91e456224 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1252,7 +1252,7 @@ nhc==0.3.9 nibe==2.14.0 # homeassistant.components.nice_go -nice-go==1.0.0 +nice-go==1.0.1 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From be7a7c94f6cb11227df01a3ebf5cadd62a5453fe Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 28 Jan 2025 13:43:31 +0100 Subject: [PATCH 1072/2987] Remove unused function in hassio/update (#136701) --- homeassistant/components/hassio/update.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 17b0a5bc9ca..8e0585892f5 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from aiohasupervisor import SupervisorClient, SupervisorError +from aiohasupervisor import SupervisorError from aiohasupervisor.models import OSUpdate from awesomeversion import AwesomeVersion, AwesomeVersionStrategy @@ -297,10 +297,3 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity): ) -> None: """Install an update.""" await update_core(self.hass, version, backup) - - -async def _default_agent(client: SupervisorClient) -> str: - """Return the default agent for creating a backup.""" - mounts = await client.mounts.info() - default_mount = mounts.default_backup_mount - return f"hassio.{default_mount if default_mount is not None else 'local'}" From b1abf50a31b82220676860314c0af6053f3f8afe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jan 2025 13:48:28 +0100 Subject: [PATCH 1073/2987] Tag backups created when updating addon with supervisor.addon_update (#136690) --- homeassistant/components/backup/manager.py | 9 +- homeassistant/components/hassio/backup.py | 1 + tests/components/backup/test_manager.py | 126 +++++++++++++++++- tests/components/hassio/test_update.py | 3 + tests/components/hassio/test_websocket_api.py | 3 + 5 files changed, 139 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 8c8cd805565..f4ea27ca5f1 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -672,6 +672,7 @@ class BackupManager: self, *, agent_ids: list[str], + extra_metadata: dict[str, bool | str] | None = None, include_addons: list[str] | None, include_all_addons: bool, include_database: bool, @@ -684,6 +685,7 @@ class BackupManager: """Create a backup.""" new_backup = await self.async_initiate_backup( agent_ids=agent_ids, + extra_metadata=extra_metadata, include_addons=include_addons, include_all_addons=include_all_addons, include_database=include_database, @@ -717,6 +719,7 @@ class BackupManager: self, *, agent_ids: list[str], + extra_metadata: dict[str, bool | str] | None = None, include_addons: list[str] | None, include_all_addons: bool, include_database: bool, @@ -741,6 +744,7 @@ class BackupManager: try: return await self._async_create_backup( agent_ids=agent_ids, + extra_metadata=extra_metadata, include_addons=include_addons, include_all_addons=include_all_addons, include_database=include_database, @@ -764,6 +768,7 @@ class BackupManager: self, *, agent_ids: list[str], + extra_metadata: dict[str, bool | str] | None, include_addons: list[str] | None, include_all_addons: bool, include_database: bool, @@ -790,6 +795,7 @@ class BackupManager: name or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}" ) + extra_metadata = extra_metadata or {} try: ( @@ -798,7 +804,8 @@ class BackupManager: ) = await self._reader_writer.async_create_backup( agent_ids=agent_ids, backup_name=backup_name, - extra_metadata={ + extra_metadata=extra_metadata + | { "instance_id": await instance_id.async_get(self.hass), "with_automatic_settings": with_automatic_settings, }, diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 98ad2ad20e3..4a9bfaded15 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -509,6 +509,7 @@ async def backup_addon_before_update( try: await backup_manager.async_create_backup( agent_ids=[await _default_agent(client)], + extra_metadata={"supervisor.addon_update": addon}, include_addons=[addon], include_all_addons=False, include_database=False, diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 8a99f90d234..c6eeff79d45 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -90,13 +90,13 @@ def generate_backup_id_fixture() -> Generator[MagicMock]: @pytest.mark.usefixtures("mock_backup_generation") -async def test_async_create_backup( +async def test_create_backup_service( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mocked_json_bytes: Mock, mocked_tarfile: Mock, ) -> None: - """Test create backup.""" + """Test create backup service.""" assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() @@ -137,6 +137,128 @@ async def test_async_create_backup( ) +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ("manager_kwargs", "expected_writer_kwargs"), + [ + ( + { + "agent_ids": ["backup.local"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": None, + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "Custom backup 2025.1.0", + "extra_metadata": { + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ( + { + "agent_ids": ["backup.local"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": None, + "password": None, + "with_automatic_settings": True, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "Automatic backup 2025.1.0", + "extra_metadata": { + "instance_id": ANY, + "with_automatic_settings": True, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ( + { + "agent_ids": ["backup.local"], + "extra_metadata": {"custom": "data"}, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": None, + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "Custom backup 2025.1.0", + "extra_metadata": { + "custom": "data", + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ], +) +async def test_async_create_backup( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mocked_json_bytes: Mock, + mocked_tarfile: Mock, + manager_kwargs: dict[str, Any], + expected_writer_kwargs: dict[str, Any], +) -> None: + """Test create backup.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + manager = hass.data[DATA_MANAGER] + + new_backup = NewBackup(backup_job_id="time-123") + backup_task = AsyncMock( + return_value=WrittenBackup( + backup=TEST_BACKUP_ABC123, + open_stream=AsyncMock(), + release_stream=AsyncMock(), + ), + )() # call it so that it can be awaited + + with patch( + "homeassistant.components.backup.manager.CoreBackupReaderWriter.async_create_backup", + return_value=(new_backup, backup_task), + ) as create_backup: + await manager.async_create_backup(**manager_kwargs) + + assert create_backup.called + assert create_backup.call_args == call(**expected_writer_kwargs) + + @pytest.mark.usefixtures("mock_backup_generation") async def test_create_backup_when_busy( hass: HomeAssistant, diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 88d7076824f..732b2655107 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -240,6 +240,7 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non None, { "agent_ids": ["hassio.local"], + "extra_metadata": {"supervisor.addon_update": "test"}, "include_addons": ["test"], "include_all_addons": False, "include_database": False, @@ -254,6 +255,7 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non "my_nas", { "agent_ids": ["hassio.my_nas"], + "extra_metadata": {"supervisor.addon_update": "test"}, "include_addons": ["test"], "include_all_addons": False, "include_database": False, @@ -281,6 +283,7 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non None, { "agent_ids": ["hassio.local"], + "extra_metadata": {"supervisor.addon_update": "test"}, "include_addons": ["test"], "include_all_addons": False, "include_database": False, diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 1fefe54ad75..ab8dc1475e2 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -360,6 +360,7 @@ async def test_update_addon( None, { "agent_ids": ["hassio.local"], + "extra_metadata": {"supervisor.addon_update": "test"}, "include_addons": ["test"], "include_all_addons": False, "include_database": False, @@ -374,6 +375,7 @@ async def test_update_addon( "my_nas", { "agent_ids": ["hassio.my_nas"], + "extra_metadata": {"supervisor.addon_update": "test"}, "include_addons": ["test"], "include_all_addons": False, "include_database": False, @@ -401,6 +403,7 @@ async def test_update_addon( None, { "agent_ids": ["hassio.local"], + "extra_metadata": {"supervisor.addon_update": "test"}, "include_addons": ["test"], "include_all_addons": False, "include_database": False, From e120a7b59c786dc2f58c9d1a76f0523f8d252cc0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jan 2025 13:48:42 +0100 Subject: [PATCH 1074/2987] Fix deadlock in WS command backup/can_decrypt_on_download (#136707) --- homeassistant/components/backup/manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index f4ea27ca5f1..99740428863 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1055,7 +1055,9 @@ class BackupManager: backup_stream = await agent.async_download_backup(backup_id) reader = cast(IO[bytes], AsyncIteratorReader(self.hass, backup_stream)) try: - validate_password_stream(reader, password) + await self.hass.async_add_executor_job( + validate_password_stream, reader, password + ) except backup_util.IncorrectPassword as err: raise IncorrectPasswordError from err except backup_util.UnsupportedSecureTarVersion as err: From 5a52c775235bf8e656e9439518b9f3246e55fb7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Tue, 28 Jan 2025 13:48:58 +0100 Subject: [PATCH 1075/2987] Add test for myuplink DeviceInfo (#136360) --- .../myuplink/fixtures/device-alfred.json | 40 ++++++++ .../myuplink/fixtures/device-batman.json | 40 ++++++++ .../myuplink/fixtures/device-robin.json | 40 ++++++++ .../myuplink/fixtures/systems-multi.json | 61 ++++++++++++ .../myuplink/snapshots/test_init.ambr | 97 +++++++++++++++++++ tests/components/myuplink/test_init.py | 45 +++++++++ 6 files changed, 323 insertions(+) create mode 100644 tests/components/myuplink/fixtures/device-alfred.json create mode 100644 tests/components/myuplink/fixtures/device-batman.json create mode 100644 tests/components/myuplink/fixtures/device-robin.json create mode 100644 tests/components/myuplink/fixtures/systems-multi.json create mode 100644 tests/components/myuplink/snapshots/test_init.ambr diff --git a/tests/components/myuplink/fixtures/device-alfred.json b/tests/components/myuplink/fixtures/device-alfred.json new file mode 100644 index 00000000000..ca6f91459f6 --- /dev/null +++ b/tests/components/myuplink/fixtures/device-alfred.json @@ -0,0 +1,40 @@ +{ + "id": "alfred-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "firmware": { + "currentFwVersion": "9682R7A", + "desiredFwVersion": "9682R7A" + }, + "product": { + "serialNumber": "10001", + "name": "Tehowatti Air" + }, + "availableFeatures": { + "settings": true, + "reboot": true, + "forcesync": true, + "forceUpdate": false, + "requestUpdate": false, + "resetAlarm": true, + "triggerEvent": true, + "getMenu": false, + "getMenuChain": false, + "getGuideQuestion": false, + "sendHaystack": true, + "setSmartMode": false, + "setAidMode": true, + "getZones": false, + "processIntent": false, + "boostHotWater": true, + "boostVentilation": true, + "getScheduleConfig": false, + "getScheduleModes": false, + "getScheduleWeekly": false, + "getScheduleVacation": false, + "setScheduleModes": false, + "setScheduleWeekly": false, + "setScheduleOverride": false, + "setScheduleVacation": false, + "setVentilationMode": false + } +} diff --git a/tests/components/myuplink/fixtures/device-batman.json b/tests/components/myuplink/fixtures/device-batman.json new file mode 100644 index 00000000000..f7c079be5dd --- /dev/null +++ b/tests/components/myuplink/fixtures/device-batman.json @@ -0,0 +1,40 @@ +{ + "id": "batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "firmware": { + "currentFwVersion": "9682R7B", + "desiredFwVersion": "9682R7B" + }, + "product": { + "serialNumber": "10002", + "name": "F730 CU 3x400V" + }, + "availableFeatures": { + "settings": true, + "reboot": true, + "forcesync": true, + "forceUpdate": false, + "requestUpdate": false, + "resetAlarm": true, + "triggerEvent": true, + "getMenu": false, + "getMenuChain": false, + "getGuideQuestion": false, + "sendHaystack": true, + "setSmartMode": false, + "setAidMode": true, + "getZones": false, + "processIntent": false, + "boostHotWater": true, + "boostVentilation": true, + "getScheduleConfig": false, + "getScheduleModes": false, + "getScheduleWeekly": false, + "getScheduleVacation": false, + "setScheduleModes": false, + "setScheduleWeekly": false, + "setScheduleOverride": false, + "setScheduleVacation": false, + "setVentilationMode": false + } +} diff --git a/tests/components/myuplink/fixtures/device-robin.json b/tests/components/myuplink/fixtures/device-robin.json new file mode 100644 index 00000000000..3155d6e3f70 --- /dev/null +++ b/tests/components/myuplink/fixtures/device-robin.json @@ -0,0 +1,40 @@ +{ + "id": "robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "firmware": { + "currentFwVersion": "9682R7C", + "desiredFwVersion": "9682R7C" + }, + "product": { + "serialNumber": "10003", + "name": "SMO 20" + }, + "availableFeatures": { + "settings": true, + "reboot": true, + "forcesync": true, + "forceUpdate": false, + "requestUpdate": false, + "resetAlarm": true, + "triggerEvent": true, + "getMenu": false, + "getMenuChain": false, + "getGuideQuestion": false, + "sendHaystack": true, + "setSmartMode": false, + "setAidMode": true, + "getZones": false, + "processIntent": false, + "boostHotWater": true, + "boostVentilation": true, + "getScheduleConfig": false, + "getScheduleModes": false, + "getScheduleWeekly": false, + "getScheduleVacation": false, + "setScheduleModes": false, + "setScheduleWeekly": false, + "setScheduleOverride": false, + "setScheduleVacation": false, + "setVentilationMode": false + } +} diff --git a/tests/components/myuplink/fixtures/systems-multi.json b/tests/components/myuplink/fixtures/systems-multi.json new file mode 100644 index 00000000000..a587900d23c --- /dev/null +++ b/tests/components/myuplink/fixtures/systems-multi.json @@ -0,0 +1,61 @@ +{ + "page": 1, + "itemsPerPage": 10, + "numItems": 3, + "systems": [ + { + "systemId": "123456-7890-1234", + "name": "Gotham City", + "securityLevel": "admin", + "hasAlarm": false, + "country": "Sweden", + "devices": [ + { + "id": "alfred-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "currentFwVersion": "9682R7A", + "product": { + "serialNumber": "10001", + "name": "Tehowatti Air" + } + } + ] + }, + { + "systemId": "123456-7890-1234", + "name": "Batcave", + "securityLevel": "admin", + "hasAlarm": false, + "country": "Sweden", + "devices": [ + { + "id": "batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "currentFwVersion": "9682R7B", + "product": { + "serialNumber": "10002", + "name": "F730 CU 3x400V" + } + } + ] + }, + { + "systemId": "123456-7890-1234", + "name": "Duckburg", + "securityLevel": "admin", + "hasAlarm": false, + "country": "Sweden", + "devices": [ + { + "id": "robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "currentFwVersion": "9682R7C", + "product": { + "serialNumber": "10003", + "name": "SM0 20" + } + } + ] + } + ] +} diff --git a/tests/components/myuplink/snapshots/test_init.ambr b/tests/components/myuplink/snapshots/test_init.ambr new file mode 100644 index 00000000000..42ed9c20669 --- /dev/null +++ b/tests/components/myuplink/snapshots/test_init.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_device_info[alfred-multi] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'myuplink', + 'alfred-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Jäspi', + 'model': 'Tehowatti Air', + 'model_id': None, + 'name': 'Gotham City', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '10001', + 'suggested_area': None, + 'sw_version': '9682R7A', + 'via_device_id': None, + }) +# --- +# name: test_device_info[batman-multi] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'myuplink', + 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Nibe', + 'model': 'F730', + 'model_id': None, + 'name': 'Batcave', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '10002', + 'suggested_area': None, + 'sw_version': '9682R7B', + 'via_device_id': None, + }) +# --- +# name: test_device_info[robin-multi] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'myuplink', + 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Nibe', + 'model': 'SMO 20', + 'model_id': None, + 'name': 'Duckburg', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '10003', + 'suggested_area': None, + 'sw_version': '9682R7C', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/myuplink/test_init.py b/tests/components/myuplink/test_init.py index fda0d3526f9..320bf202024 100644 --- a/tests/components/myuplink/test_init.py +++ b/tests/components/myuplink/test_init.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock from aiohttp import ClientConnectionError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.myuplink.const import DOMAIN, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState @@ -214,3 +215,47 @@ async def test_device_remove_devices( old_device_entry.id, mock_config_entry.entry_id ) assert response["success"] + + +@pytest.mark.parametrize( + "load_systems_file", + [load_fixture("systems-multi.json", DOMAIN)], + ids=[ + "multi", + ], +) +@pytest.mark.parametrize( + ("load_device_file", "device_id"), + [ + ( + load_fixture("device-alfred.json", DOMAIN), + "alfred-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + ), + ( + load_fixture("device-batman.json", DOMAIN), + "batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + ), + ( + load_fixture("device-robin.json", DOMAIN), + "robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + ), + ], + ids=[ + "alfred", + "batman", + "robin", + ], +) +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_myuplink_client: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + device_id: str, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device_entry is not None + assert device_entry == snapshot From 6278d36981285ffe932463f606ff7ca9d7987432 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 13:49:49 +0100 Subject: [PATCH 1076/2987] Use HassKey in diagnostics (#136627) --- homeassistant/components/diagnostics/__init__.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index b23b7cef2bd..7bc43f2c3f5 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -33,6 +33,7 @@ from homeassistant.loader import ( async_get_integration, ) from homeassistant.setup import async_get_domain_setup_times +from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import format_unserializable_data from .const import DOMAIN, REDACTED, DiagnosticsSubType, DiagnosticsType @@ -44,6 +45,7 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +_DIAGNOSTICS_DATA: HassKey[DiagnosticsData] = HassKey(DOMAIN) @dataclass(slots=True) @@ -72,7 +74,7 @@ class DiagnosticsData: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Diagnostics from a config entry.""" - hass.data[DOMAIN] = DiagnosticsData() + hass.data[_DIAGNOSTICS_DATA] = DiagnosticsData() await integration_platform.async_process_integration_platforms( hass, DOMAIN, _register_diagnostics_platform @@ -104,7 +106,7 @@ def _register_diagnostics_platform( hass: HomeAssistant, integration_domain: str, platform: DiagnosticsProtocol ) -> None: """Register a diagnostics platform.""" - diagnostics_data: DiagnosticsData = hass.data[DOMAIN] + diagnostics_data = hass.data[_DIAGNOSTICS_DATA] diagnostics_data.platforms[integration_domain] = DiagnosticsPlatformData( getattr(platform, "async_get_config_entry_diagnostics", None), getattr(platform, "async_get_device_diagnostics", None), @@ -118,7 +120,7 @@ def handle_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """List all possible diagnostic handlers.""" - diagnostics_data: DiagnosticsData = hass.data[DOMAIN] + diagnostics_data = hass.data[_DIAGNOSTICS_DATA] result = [ { "domain": domain, @@ -145,7 +147,7 @@ def handle_get( ) -> None: """List all diagnostic handlers for a domain.""" domain = msg["domain"] - diagnostics_data: DiagnosticsData = hass.data[DOMAIN] + diagnostics_data = hass.data[_DIAGNOSTICS_DATA] if (info := diagnostics_data.platforms.get(domain)) is None: connection.send_error( @@ -267,7 +269,7 @@ class DownloadDiagnosticsView(http.HomeAssistantView): if (config_entry := hass.config_entries.async_get_entry(d_id)) is None: return web.Response(status=HTTPStatus.NOT_FOUND) - diagnostics_data: DiagnosticsData = hass.data[DOMAIN] + diagnostics_data = hass.data[_DIAGNOSTICS_DATA] if (info := diagnostics_data.platforms.get(config_entry.domain)) is None: return web.Response(status=HTTPStatus.NOT_FOUND) From c2da844f76ddc696bd313357ee9aff697b3fb752 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 28 Jan 2025 07:02:15 -0600 Subject: [PATCH 1077/2987] Add HEOS diagnostics (#136663) --- homeassistant/components/heos/coordinator.py | 5 + homeassistant/components/heos/diagnostics.py | 90 +++++ .../components/heos/quality_scale.yaml | 2 +- tests/components/heos/conftest.py | 57 ++- .../heos/snapshots/test_diagnostics.ambr | 371 ++++++++++++++++++ .../heos/snapshots/test_media_player.ambr | 6 +- tests/components/heos/test_diagnostics.py | 98 +++++ 7 files changed, 610 insertions(+), 19 deletions(-) create mode 100644 homeassistant/components/heos/diagnostics.py create mode 100644 tests/components/heos/snapshots/test_diagnostics.ambr create mode 100644 tests/components/heos/test_diagnostics.py diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index ee0aeb3f165..dd0e0a19d0b 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -68,6 +68,11 @@ class HeosCoordinator(DataUpdateCoordinator[None]): """Get input sources across all devices.""" return self._inputs + @property + def favorites(self) -> dict[int, MediaItem]: + """Get favorite stations.""" + return self._favorites + async def async_setup(self) -> None: """Set up the coordinator; connect to the host; and retrieve initial data.""" # Add before connect as it may occur during initial connection diff --git a/homeassistant/components/heos/diagnostics.py b/homeassistant/components/heos/diagnostics.py new file mode 100644 index 00000000000..bf33fc9bc15 --- /dev/null +++ b/homeassistant/components/heos/diagnostics.py @@ -0,0 +1,90 @@ +"""Define the HEOS integration diagnostics module.""" + +from collections.abc import Mapping, Sequence +import dataclasses +from typing import Any + +from pyheos import HeosError + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import ATTR_PASSWORD, ATTR_USERNAME, DOMAIN +from .coordinator import HeosConfigEntry + +TO_REDACT = [ + ATTR_PASSWORD, + ATTR_USERNAME, + "signed_in_username", + "serial", + "serial_number", +] + + +def _as_dict( + data: Any, redact: bool = False +) -> Mapping[str, Any] | Sequence[Any] | Any: + """Convert dataclasses to dicts within various data structures.""" + if dataclasses.is_dataclass(data): + data_dict = dataclasses.asdict(data) # type: ignore[arg-type] + return data_dict if not redact else async_redact_data(data_dict, TO_REDACT) + if not isinstance(data, (Mapping, Sequence)): + return data + if isinstance(data, Sequence): + return [_as_dict(val) for val in data] + return {k: _as_dict(v) for k, v in data.items()} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: HeosConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = config_entry.runtime_data + diagnostics = { + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), + "heos": { + "connection_state": coordinator.heos.connection_state, + "current_credentials": _as_dict( + coordinator.heos.current_credentials, redact=True + ), + }, + "groups": _as_dict(coordinator.heos.groups), + "source_list": coordinator.async_get_source_list(), + "inputs": _as_dict(coordinator.inputs), + "favorites": _as_dict(coordinator.favorites), + } + # Try getting system information + try: + system_info = await coordinator.heos.get_system_info() + except HeosError as err: + diagnostics["system"] = {"error": str(err)} + else: + diagnostics["system"] = _as_dict(system_info, redact=True) + return diagnostics + + +async def async_get_device_diagnostics( + hass: HomeAssistant, config_entry: HeosConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device.""" + entity_registry = er.async_get(hass) + entities = entity_registry.entities.get_entries_for_device_id(device.id, True) + player_id = next( + int(value) for domain, value in device.identifiers if domain == DOMAIN + ) + player = config_entry.runtime_data.heos.players.get(player_id) + return { + "device": async_redact_data(device.dict_repr, TO_REDACT), + "entities": [ + { + "entity": entity.as_partial_dict, + "state": state.as_dict() + if (state := hass.states.get(entity.entity_id)) + else None, + } + for entity in entities + ], + "player": _as_dict(player, redact=True), + } diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index d48bcc492cd..cc110c627f0 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -37,7 +37,7 @@ rules: comment: 99% test coverage # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: todo comment: Explore if this is possible. diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 122467c6b02..5312b8295ed 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -6,11 +6,13 @@ from collections.abc import AsyncIterator from unittest.mock import AsyncMock, Mock, patch from pyheos import ( - CONTROLS_ALL, Heos, HeosGroup, + HeosHost, + HeosNowPlayingMedia, HeosOptions, HeosPlayer, + HeosSystem, LineOutLevelType, MediaItem, MediaType, @@ -98,6 +100,33 @@ async def controller_fixture( yield mock_heos +@pytest.fixture(name="system") +def system_info_fixture() -> dict[str, str]: + """Create a system info fixture.""" + return HeosSystem( + "user@user.com", + "127.0.0.1", + hosts=[ + HeosHost( + "Test Player", + "HEOS Drive HS2", + "123456", + "1.0.0", + "127.0.0.1", + NetworkType.WIRED, + ), + HeosHost( + "Test Player 2", + "Speaker", + "123456", + "1.0.0", + "127.0.0.2", + NetworkType.WIFI, + ), + ], + ) + + @pytest.fixture(name="players") def players_fixture(quick_selects: dict[int, str]) -> dict[int, HeosPlayer]: """Create two mock HeosPlayers.""" @@ -121,20 +150,18 @@ def players_fixture(quick_selects: dict[int, str]) -> dict[int, HeosPlayer]: volume=25, heos=None, ) - player.now_playing_media = Mock() - player.now_playing_media.supported_controls = CONTROLS_ALL - player.now_playing_media.album_id = 1 - player.now_playing_media.queue_id = 1 - player.now_playing_media.source_id = 1 - player.now_playing_media.station = "Station Name" - player.now_playing_media.type = "Station" - player.now_playing_media.album = "Album" - player.now_playing_media.artist = "Artist" - player.now_playing_media.media_id = "1" - player.now_playing_media.duration = None - player.now_playing_media.current_position = None - player.now_playing_media.image_url = "http://" - player.now_playing_media.song = "Song" + 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, + ) player.add_to_queue = AsyncMock() player.clear_queue = AsyncMock() player.get_quick_selects = AsyncMock(return_value=quick_selects) diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..6de0a645f17 --- /dev/null +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -0,0 +1,371 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': '127.0.0.1', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'heos', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'HEOS System (via 127.0.0.1)', + 'unique_id': 'heos', + 'version': 1, + }), + 'favorites': dict({ + '1': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': '123456789', + 'name': "Today's Hits Radio", + 'playable': True, + 'source_id': 1, + 'type': 'station', + }), + '2': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 's1234', + 'name': 'Classical MPR (Classical Music)', + 'playable': True, + 'source_id': 3, + 'type': 'station', + }), + }), + 'groups': dict({ + '999': dict({ + 'group_id': 999, + 'is_muted': False, + 'lead_player_id': 1, + 'member_player_ids': list([ + 2, + ]), + 'name': 'Group', + 'volume': 0, + }), + }), + 'heos': dict({ + 'connection_state': 'disconnected', + 'current_credentials': None, + }), + 'inputs': list([ + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/aux_in_1', + 'name': 'HEOS Drive - Line In 1', + 'playable': True, + 'source_id': 1027, + 'type': 'station', + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/aux_in_1', + 'name': 'Speaker - Line In 1', + 'playable': True, + 'source_id': 1027, + 'type': 'station', + }), + ]), + 'source_list': list([ + "Today's Hits Radio", + 'Classical MPR (Classical Music)', + 'HEOS Drive - Line In 1', + 'Speaker - Line In 1', + ]), + 'system': dict({ + 'connected_to_preferred_host': False, + 'host': '127.0.0.1', + 'hosts': list([ + dict({ + 'ip_address': '127.0.0.1', + 'model': 'HEOS Drive HS2', + 'name': 'Test Player', + 'network': 'wired', + 'serial': '**REDACTED**', + 'version': '1.0.0', + }), + dict({ + 'ip_address': '127.0.0.2', + 'model': 'Speaker', + 'name': 'Test Player 2', + 'network': 'wifi', + 'serial': '**REDACTED**', + 'version': '1.0.0', + }), + ]), + 'is_signed_in': True, + 'preferred_hosts': list([ + dict({ + 'ip_address': '127.0.0.1', + 'model': 'HEOS Drive HS2', + 'name': 'Test Player', + 'network': 'wired', + 'serial': '**REDACTED**', + 'version': '1.0.0', + }), + ]), + 'signed_in_username': '**REDACTED**', + }), + }) +# --- +# name: test_config_entry_diagnostics_error_getting_system + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': '127.0.0.1', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'heos', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'HEOS System (via 127.0.0.1)', + 'unique_id': 'heos', + 'version': 1, + }), + 'favorites': dict({ + '1': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': '123456789', + 'name': "Today's Hits Radio", + 'playable': True, + 'source_id': 1, + 'type': 'station', + }), + '2': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 's1234', + 'name': 'Classical MPR (Classical Music)', + 'playable': True, + 'source_id': 3, + 'type': 'station', + }), + }), + 'groups': dict({ + '999': dict({ + 'group_id': 999, + 'is_muted': False, + 'lead_player_id': 1, + 'member_player_ids': list([ + 2, + ]), + 'name': 'Group', + 'volume': 0, + }), + }), + 'heos': dict({ + 'connection_state': 'disconnected', + 'current_credentials': None, + }), + 'inputs': list([ + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/aux_in_1', + 'name': 'HEOS Drive - Line In 1', + 'playable': True, + 'source_id': 1027, + 'type': 'station', + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/aux_in_1', + 'name': 'Speaker - Line In 1', + 'playable': True, + 'source_id': 1027, + 'type': 'station', + }), + ]), + 'source_list': list([ + "Today's Hits Radio", + 'Classical MPR (Classical Music)', + 'HEOS Drive - Line In 1', + 'Speaker - Line In 1', + ]), + 'system': dict({ + 'error': 'Not connected to device', + }), + }) +# --- +# name: test_device_diagnostics + dict({ + 'device': dict({ + 'area_id': None, + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'identifiers': list([ + list([ + 'heos', + '1', + ]), + ]), + 'labels': list([ + ]), + 'manufacturer': 'HEOS', + 'model': 'Drive HS2', + 'model_id': None, + 'name': 'Test Player', + 'name_by_user': None, + 'serial_number': '**REDACTED**', + 'sw_version': '1.0.0', + 'via_device_id': None, + }), + 'entities': list([ + dict({ + 'entity': dict({ + 'area_id': None, + 'categories': dict({ + }), + 'disabled_by': None, + 'entity_category': None, + 'entity_id': 'media_player.test_player', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_name': None, + 'platform': 'heos', + 'translation_key': None, + 'unique_id': '1', + }), + 'state': dict({ + 'attributes': dict({ + 'entity_picture': 'http://', + 'friendly_name': 'Test Player', + 'group_members': list([ + 'media_player.test_player', + 'media_player.test_player_2', + ]), + 'is_volume_muted': False, + 'media_album_id': '1', + 'media_album_name': 'Album', + 'media_artist': 'Artist', + 'media_content_id': '1', + 'media_content_type': 'music', + 'media_queue_id': 1, + 'media_source_id': 10, + 'media_station': 'Station Name', + 'media_title': 'Song', + 'media_type': 'station', + 'repeat': 'off', + 'shuffle': False, + 'source_list': list([ + "Today's Hits Radio", + 'Classical MPR (Classical Music)', + 'HEOS Drive - Line In 1', + 'Speaker - Line In 1', + ]), + 'supported_features': 3079741, + 'volume_level': 0.25, + }), + 'context': dict({ + 'parent_id': None, + 'user_id': None, + }), + 'entity_id': 'media_player.test_player', + 'state': 'idle', + }), + }), + ]), + 'player': dict({ + 'available': True, + 'control': 0, + 'group_id': 999, + 'ip_address': '127.0.0.1', + 'is_muted': False, + 'line_out': 1, + 'model': 'HEOS Drive HS2', + 'name': 'Test Player', + 'network': 'wired', + 'now_playing_media': dict({ + 'album': 'Album', + 'album_id': '1', + 'artist': 'Artist', + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': 'http://', + 'media_id': '1', + 'options': list([ + ]), + 'queue_id': 1, + 'song': 'Song', + 'source_id': 10, + 'station': 'Station Name', + 'supported_controls': list([ + 'play', + 'pause', + 'stop', + 'play_next', + 'play_previous', + ]), + 'type': 'station', + }), + 'playback_error': None, + 'player_id': 1, + 'repeat': 'off', + 'serial': '**REDACTED**', + 'shuffle': False, + 'state': 'stop', + 'version': '1.0.0', + 'volume': 25, + }), + }) +# --- diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index 7bfdac232cb..88d27f2073a 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -9,16 +9,16 @@ 'media_player.test_player_2', ]), 'is_volume_muted': False, - 'media_album_id': 1, + 'media_album_id': '1', 'media_album_name': 'Album', 'media_artist': 'Artist', 'media_content_id': '1', 'media_content_type': , 'media_queue_id': 1, - 'media_source_id': 1, + 'media_source_id': 10, 'media_station': 'Station Name', 'media_title': 'Song', - 'media_type': 'Station', + 'media_type': , 'repeat': , 'shuffle': False, 'source_list': list([ diff --git a/tests/components/heos/test_diagnostics.py b/tests/components/heos/test_diagnostics.py new file mode 100644 index 00000000000..d6fb8e1a8fe --- /dev/null +++ b/tests/components/heos/test_diagnostics.py @@ -0,0 +1,98 @@ +"""Tests for the HEOS diagnostics module.""" + +from unittest import mock + +from pyheos import Heos, HeosSystem +import pytest +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.heos.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +async def test_config_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + controller: Heos, + system: HeosSystem, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + with mock.patch.object( + controller, controller.get_system_info.__name__, return_value=system + ): + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + + assert diagnostics == snapshot( + exclude=props("created_at", "modified_at", "entry_id") + ) + + +@pytest.mark.usefixtures("controller") +async def test_config_entry_diagnostics_error_getting_system( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics with error during getting system info.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # Not patching get_system_info to raise error 'Not connected to device' + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + + assert diagnostics == snapshot( + exclude=props("created_at", "modified_at", "entry_id") + ) + + +@pytest.mark.usefixtures("controller") +async def test_device_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, "1")}) + + diagnostics = await get_diagnostics_for_device( + hass, hass_client, config_entry, device + ) + assert diagnostics == snapshot( + exclude=props( + "created_at", + "modified_at", + "config_entries", + "id", + "primary_config_entry", + "config_entry_id", + "device_id", + "entity_picture_local", + "last_changed", + "last_reported", + "last_updated", + ) + ) From 3dbcdf933ead73dea37825aa705f022e7f0afcd2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 14:04:09 +0100 Subject: [PATCH 1078/2987] Cleanup ecobee YAML configuration import (#136633) --- homeassistant/components/ecobee/__init__.py | 42 +------ .../components/ecobee/config_flow.py | 69 +---------- homeassistant/components/ecobee/const.py | 2 - tests/components/ecobee/test_config_flow.py | 110 +----------------- 4 files changed, 8 insertions(+), 215 deletions(-) diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py index ae5ee96a6a4..c34211e9ff0 100644 --- a/homeassistant/components/ecobee/__init__.py +++ b/homeassistant/components/ecobee/__init__.py @@ -3,57 +3,19 @@ from datetime import timedelta from pyecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN, Ecobee, ExpiredTokenError -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle -from .const import ( - _LOGGER, - CONF_REFRESH_TOKEN, - DATA_ECOBEE_CONFIG, - DATA_HASS_CONFIG, - DOMAIN, - PLATFORMS, -) +from .const import _LOGGER, CONF_REFRESH_TOKEN, PLATFORMS MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Schema({vol.Optional(CONF_API_KEY): cv.string})}, extra=vol.ALLOW_EXTRA -) - type EcobeeConfigEntry = ConfigEntry[EcobeeData] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Ecobee uses config flow for configuration. - - But, an "ecobee:" entry in configuration.yaml will trigger an import flow - if a config entry doesn't already exist. If ecobee.conf exists, the import - flow will attempt to import it and create a config entry, to assist users - migrating from the old ecobee integration. Otherwise, the user will have to - continue setting up the integration via the config flow. - """ - - hass.data[DATA_ECOBEE_CONFIG] = config.get(DOMAIN, {}) - hass.data[DATA_HASS_CONFIG] = config - - if not hass.config_entries.async_entries(DOMAIN) and hass.data[DATA_ECOBEE_CONFIG]: - # No config entry exists and configuration.yaml config exists, trigger the import flow. - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bool: """Set up ecobee via a config entry.""" api_key = entry.data[CONF_API_KEY] diff --git a/homeassistant/components/ecobee/config_flow.py b/homeassistant/components/ecobee/config_flow.py index 687d9173a66..ac834e92ca8 100644 --- a/homeassistant/components/ecobee/config_flow.py +++ b/homeassistant/components/ecobee/config_flow.py @@ -2,20 +2,15 @@ from typing import Any -from pyecobee import ( - ECOBEE_API_KEY, - ECOBEE_CONFIG_FILENAME, - ECOBEE_REFRESH_TOKEN, - Ecobee, -) +from pyecobee import ECOBEE_API_KEY, Ecobee import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY -from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.json import load_json_object -from .const import _LOGGER, CONF_REFRESH_TOKEN, DATA_ECOBEE_CONFIG, DOMAIN +from .const import CONF_REFRESH_TOKEN, DOMAIN + +_USER_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): @@ -30,11 +25,6 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" errors = {} - stored_api_key = ( - self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY) - if DATA_ECOBEE_CONFIG in self.hass.data - else "" - ) if user_input is not None: # Use the user-supplied API key to attempt to obtain a PIN from ecobee. @@ -47,9 +37,7 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - data_schema=vol.Schema( - {vol.Required(CONF_API_KEY, default=stored_api_key): str} - ), + data_schema=_USER_SCHEMA, errors=errors, ) @@ -75,50 +63,3 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, description_placeholders={"pin": self._ecobee.pin}, ) - - async def async_step_import(self, import_data: None) -> ConfigFlowResult: - """Import ecobee config from configuration.yaml. - - Triggered by async_setup only if a config entry doesn't already exist. - If ecobee.conf exists, we will attempt to validate the credentials - and create an entry if valid. Otherwise, we will delegate to the user - step so that the user can continue the config flow. - """ - try: - legacy_config = await self.hass.async_add_executor_job( - load_json_object, self.hass.config.path(ECOBEE_CONFIG_FILENAME) - ) - config = { - ECOBEE_API_KEY: legacy_config[ECOBEE_API_KEY], - ECOBEE_REFRESH_TOKEN: legacy_config[ECOBEE_REFRESH_TOKEN], - } - except (HomeAssistantError, KeyError): - _LOGGER.debug( - "No valid ecobee.conf configuration found for import, delegating to" - " user step" - ) - return await self.async_step_user( - user_input={ - CONF_API_KEY: self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY) - } - ) - - ecobee = Ecobee(config=config) - if await self.hass.async_add_executor_job(ecobee.refresh_tokens): - # Credentials found and validated; create the entry. - _LOGGER.debug( - "Valid ecobee configuration found for import, creating configuration" - " entry" - ) - return self.async_create_entry( - title=DOMAIN, - data={ - CONF_API_KEY: ecobee.api_key, - CONF_REFRESH_TOKEN: ecobee.refresh_token, - }, - ) - return await self.async_step_user( - user_input={ - CONF_API_KEY: self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY) - } - ) diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py index d0e9ba8e8e9..115c91eceeb 100644 --- a/homeassistant/components/ecobee/const.py +++ b/homeassistant/components/ecobee/const.py @@ -20,8 +20,6 @@ from homeassistant.const import Platform _LOGGER = logging.getLogger(__package__) DOMAIN = "ecobee" -DATA_ECOBEE_CONFIG = "ecobee_config" -DATA_HASS_CONFIG = "ecobee_hass_config" ATTR_CONFIG_ENTRY_ID = "entry_id" ATTR_AVAILABLE_SENSORS = "available_sensors" ATTR_ACTIVE_SENSORS = "active_sensors" diff --git a/tests/components/ecobee/test_config_flow.py b/tests/components/ecobee/test_config_flow.py index 5c919ffab5c..9edb1d42331 100644 --- a/tests/components/ecobee/test_config_flow.py +++ b/tests/components/ecobee/test_config_flow.py @@ -2,15 +2,8 @@ from unittest.mock import patch -from pyecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN -import pytest - from homeassistant.components.ecobee import config_flow -from homeassistant.components.ecobee.const import ( - CONF_REFRESH_TOKEN, - DATA_ECOBEE_CONFIG, - DOMAIN, -) +from homeassistant.components.ecobee.const import CONF_REFRESH_TOKEN, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant @@ -35,7 +28,6 @@ async def test_user_step_without_user_input(hass: HomeAssistant) -> None: """Test expected result if user step is called.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} result = await flow.async_step_user() assert result["type"] is FlowResultType.FORM @@ -46,7 +38,6 @@ async def test_pin_request_succeeds(hass: HomeAssistant) -> None: """Test expected result if pin request succeeds.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee: mock_ecobee = mock_ecobee.return_value @@ -64,7 +55,6 @@ async def test_pin_request_fails(hass: HomeAssistant) -> None: """Test expected result if pin request fails.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee: mock_ecobee = mock_ecobee.return_value @@ -81,7 +71,6 @@ async def test_token_request_succeeds(hass: HomeAssistant) -> None: """Test expected result if token request succeeds.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee: mock_ecobee = mock_ecobee.return_value @@ -105,7 +94,6 @@ async def test_token_request_fails(hass: HomeAssistant) -> None: """Test expected result if token request fails.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee: mock_ecobee = mock_ecobee.return_value @@ -120,99 +108,3 @@ async def test_token_request_fails(hass: HomeAssistant) -> None: assert result["step_id"] == "authorize" assert result["errors"]["base"] == "token_request_failed" assert result["description_placeholders"] == {"pin": "test-pin"} - - -@pytest.mark.skip(reason="Flaky/slow") -async def test_import_flow_triggered_but_no_ecobee_conf(hass: HomeAssistant) -> None: - """Test expected result if import flow triggers but ecobee.conf doesn't exist.""" - flow = config_flow.EcobeeFlowHandler() - flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} - - result = await flow.async_step_import(import_data=None) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - -async def test_import_flow_triggered_with_ecobee_conf_and_valid_data_and_valid_tokens( - hass: HomeAssistant, -) -> None: - """Test expected result if import flow triggers and ecobee.conf exists with valid tokens.""" - flow = config_flow.EcobeeFlowHandler() - flow.hass = hass - - MOCK_ECOBEE_CONF = {ECOBEE_API_KEY: None, ECOBEE_REFRESH_TOKEN: None} - - with ( - patch( - "homeassistant.components.ecobee.config_flow.load_json_object", - return_value=MOCK_ECOBEE_CONF, - ), - patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee, - ): - mock_ecobee = mock_ecobee.return_value - mock_ecobee.refresh_tokens.return_value = True - mock_ecobee.api_key = "test-api-key" - mock_ecobee.refresh_token = "test-token" - - result = await flow.async_step_import(import_data=None) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == DOMAIN - assert result["data"] == { - CONF_API_KEY: "test-api-key", - CONF_REFRESH_TOKEN: "test-token", - } - - -async def test_import_flow_triggered_with_ecobee_conf_and_invalid_data( - hass: HomeAssistant, -) -> None: - """Test expected result if import flow triggers and ecobee.conf exists with invalid data.""" - flow = config_flow.EcobeeFlowHandler() - flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {CONF_API_KEY: "test-api-key"} - - MOCK_ECOBEE_CONF = {} - - with ( - patch( - "homeassistant.components.ecobee.config_flow.load_json_object", - return_value=MOCK_ECOBEE_CONF, - ), - patch.object(flow, "async_step_user") as mock_async_step_user, - ): - await flow.async_step_import(import_data=None) - - mock_async_step_user.assert_called_once_with( - user_input={CONF_API_KEY: "test-api-key"} - ) - - -async def test_import_flow_triggered_with_ecobee_conf_and_valid_data_and_stale_tokens( - hass: HomeAssistant, -) -> None: - """Test expected result if import flow triggers and ecobee.conf exists with stale tokens.""" - flow = config_flow.EcobeeFlowHandler() - flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {CONF_API_KEY: "test-api-key"} - - MOCK_ECOBEE_CONF = {ECOBEE_API_KEY: None, ECOBEE_REFRESH_TOKEN: None} - - with ( - patch( - "homeassistant.components.ecobee.config_flow.load_json_object", - return_value=MOCK_ECOBEE_CONF, - ), - patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee, - patch.object(flow, "async_step_user") as mock_async_step_user, - ): - mock_ecobee = mock_ecobee.return_value - mock_ecobee.refresh_tokens.return_value = False - - await flow.async_step_import(import_data=None) - - mock_async_step_user.assert_called_once_with( - user_input={CONF_API_KEY: "test-api-key"} - ) From 5053b203a562dc5fd95fde18b84117920a2629ac Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 28 Jan 2025 14:06:59 +0100 Subject: [PATCH 1079/2987] Fix spelling of "Ring" and sentence-casing of "integration" (#136652) --- homeassistant/components/ring/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 1f146bcf358..8320a3ec47f 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -18,7 +18,7 @@ "2fa": "Two-factor code" }, "data_description": { - "2fa": "Account verification code via the method selected in your ring account settings." + "2fa": "Account verification code via the method selected in your Ring account settings." } }, "reauth_confirm": { @@ -32,7 +32,7 @@ } }, "reconfigure": { - "title": "Reconfigure Ring Integration", + "title": "Reconfigure Ring integration", "description": "Will create a new Authorized Device for {username} at ring.com", "data": { "password": "[%key:common::config_flow::data::password%]" From 79de8114d3731f4edfba10f09c5c1e7ef5dc7827 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 28 Jan 2025 14:07:49 +0100 Subject: [PATCH 1080/2987] Fix spelling errors in user-facing strings of OctoPrint integration (#136644) --- homeassistant/components/octoprint/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index 5687ab36033..7f08d04e3da 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -1,11 +1,11 @@ { "config": { - "flow_title": "OctoPrint Printer: {host}", + "flow_title": "OctoPrint printer: {host}", "step": { "user": { "data": { "host": "[%key:common::config_flow::data::host%]", - "path": "Application Path", + "path": "Application path", "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", @@ -29,7 +29,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "unknown": "[%key:common::config_flow::error::unknown%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "auth_failed": "Failed to retrieve application api key", + "auth_failed": "Failed to retrieve API key", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "progress": { @@ -44,7 +44,7 @@ "services": { "printer_connect": { "name": "Connect to a printer", - "description": "Instructs the octoprint server to connect to a printer.", + "description": "Instructs the OctoPrint server to connect to a printer.", "fields": { "device_id": { "name": "Server", From c4f8de8fd97eb6d31e5c78668eff352891e345a9 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 28 Jan 2025 07:08:40 -0600 Subject: [PATCH 1081/2987] Raise exceptions in HEOS custom actions (#136546) --- homeassistant/components/heos/services.py | 20 ++++++--- homeassistant/components/heos/strings.json | 9 ++++ tests/components/heos/test_services.py | 49 +++++++++------------- 3 files changed, 43 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index f4d5961cc47..4dc3b247707 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, issue_registry as ir from .const import ( @@ -46,7 +46,6 @@ def register(hass: HomeAssistant): def _get_controller(hass: HomeAssistant) -> Heos: """Get the HEOS controller instance.""" - _LOGGER.warning( "Actions 'heos.sign_in' and 'heos.sign_out' are deprecated and will be removed in the 2025.8.0 release" ) @@ -79,16 +78,25 @@ async def _sign_in_handler(service: ServiceCall) -> None: try: await controller.sign_in(username, password) except CommandAuthenticationError as err: - _LOGGER.error("Sign in failed: %s", err) + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="sign_in_auth_error" + ) from err except HeosError as err: - _LOGGER.error("Unable to sign in: %s", err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="sign_in_error", + translation_placeholders={"error": str(err)}, + ) from err async def _sign_out_handler(service: ServiceCall) -> None: """Sign out of the HEOS account.""" - controller = _get_controller(service.hass) try: await controller.sign_out() except HeosError as err: - _LOGGER.error("Unable to sign out: %s", err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="sign_out_error", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 907804d10e1..4092d4360db 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -100,6 +100,15 @@ "integration_not_loaded": { "message": "The HEOS integration is not loaded" }, + "sign_in_auth_error": { + "message": "Failed to sign in: Invalid username and/or password" + }, + "sign_in_error": { + "message": "Unable to sign in: {error}" + }, + "sign_out_error": { + "message": "Unable to sign out: {error}" + }, "not_heos_media_player": { "message": "Entity {entity_id} is not a HEOS media player entity" }, diff --git a/tests/components/heos/test_services.py b/tests/components/heos/test_services.py index 8ca365497c6..8eda26d2b3d 100644 --- a/tests/components/heos/test_services.py +++ b/tests/components/heos/test_services.py @@ -11,7 +11,7 @@ from homeassistant.components.heos.const import ( SERVICE_SIGN_OUT, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from tests.common import MockConfigEntry @@ -34,10 +34,7 @@ async def test_sign_in( async def test_sign_in_failed( - hass: HomeAssistant, - config_entry: MockConfigEntry, - controller: Heos, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test sign-in service logs error when not connected.""" config_entry.add_to_hass(hass) @@ -47,22 +44,19 @@ async def test_sign_in_failed( "", "Invalid credentials", 6 ) - await hass.services.async_call( - DOMAIN, - SERVICE_SIGN_IN, - {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, - blocking=True, - ) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SIGN_IN, + {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, + blocking=True, + ) controller.sign_in.assert_called_once_with("test@test.com", "password") - assert "Sign in failed: Invalid credentials (6)" in caplog.text async def test_sign_in_unknown_error( - hass: HomeAssistant, - config_entry: MockConfigEntry, - controller: Heos, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test sign-in service logs error for failure.""" config_entry.add_to_hass(hass) @@ -70,15 +64,15 @@ async def test_sign_in_unknown_error( controller.sign_in.side_effect = HeosError() - await hass.services.async_call( - DOMAIN, - SERVICE_SIGN_IN, - {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SIGN_IN, + {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, + blocking=True, + ) controller.sign_in.assert_called_once_with("test@test.com", "password") - assert "Unable to sign in" in caplog.text async def test_sign_in_not_loaded_raises( @@ -123,17 +117,14 @@ async def test_sign_out_not_loaded_raises( async def test_sign_out_unknown_error( - hass: HomeAssistant, - config_entry: MockConfigEntry, - controller: Heos, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the sign-out service.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) controller.sign_out.side_effect = HeosError() - await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) + with pytest.raises(HomeAssistantError): + await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) assert controller.sign_out.call_count == 1 - assert "Unable to sign out" in caplog.text From 2c3cd6e1198dd2ae377deea120626d1c1ac6cf9e Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 28 Jan 2025 14:09:22 +0100 Subject: [PATCH 1082/2987] Fix total coffees sensor for lamarzocco (#135283) --- homeassistant/components/lamarzocco/sensor.py | 4 ++-- tests/components/lamarzocco/snapshots/test_sensor.ambr | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 2acca879d52..406e8e40e92 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -3,7 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass -from pylamarzocco.const import BoilerType, MachineModel, PhysicalKey +from pylamarzocco.const import BoilerType, MachineModel from pylamarzocco.devices.machine import LaMarzoccoMachine from homeassistant.components.sensor import ( @@ -81,7 +81,7 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( translation_key="drink_stats_coffee", native_unit_of_measurement="drinks", state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda device: device.statistics.drink_stats.get(PhysicalKey.A, 0), + value_fn=lambda device: device.statistics.total_coffee, available_fn=lambda device: len(device.statistics.drink_stats) > 0, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index 723f9738e1c..9e2eae482d2 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -256,7 +256,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1047', + 'state': '2387', }) # --- # name: test_sensors[sensor.gs012345_total_flushes_made-entry] From 9897e4d3e491bd9d27b3c16a57f8b6643a745733 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 14:10:09 +0100 Subject: [PATCH 1083/2987] Use runtime_data in drop_connect (#136442) --- .../components/drop_connect/__init__.py | 23 ++++++++----------- .../components/drop_connect/binary_sensor.py | 9 ++++---- .../components/drop_connect/coordinator.py | 11 ++++++--- .../components/drop_connect/select.py | 10 ++++---- .../components/drop_connect/sensor.py | 9 ++++---- .../components/drop_connect/switch.py | 9 ++++---- 6 files changed, 34 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/drop_connect/__init__.py b/homeassistant/components/drop_connect/__init__.py index bc700456398..52b8f5a7d6e 100644 --- a/homeassistant/components/drop_connect/__init__.py +++ b/homeassistant/components/drop_connect/__init__.py @@ -7,12 +7,11 @@ from typing import TYPE_CHECKING from homeassistant.components import mqtt from homeassistant.components.mqtt import ReceiveMessage -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from .const import CONF_DATA_TOPIC, CONF_DEVICE_TYPE, DOMAIN -from .coordinator import DROPDeviceDataUpdateCoordinator +from .const import CONF_DATA_TOPIC, CONF_DEVICE_TYPE +from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -24,7 +23,7 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: DROPConfigEntry) -> bool: """Set up DROP from a config entry.""" # Make sure MQTT integration is enabled and the client is available. @@ -34,9 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if TYPE_CHECKING: assert config_entry.unique_id is not None - drop_data_coordinator = DROPDeviceDataUpdateCoordinator( - hass, config_entry.unique_id - ) + drop_data_coordinator = DROPDeviceDataUpdateCoordinator(hass, config_entry) @callback def mqtt_callback(msg: ReceiveMessage) -> None: @@ -58,15 +55,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.data[CONF_DATA_TOPIC], ) - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = drop_data_coordinator + config_entry.runtime_data = drop_data_coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: DROPConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ): - hass.data[DOMAIN].pop(config_entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/drop_connect/binary_sensor.py b/homeassistant/components/drop_connect/binary_sensor.py index 093c5bcbb8e..bc8cf900610 100644 --- a/homeassistant/components/drop_connect/binary_sensor.py +++ b/homeassistant/components/drop_connect/binary_sensor.py @@ -11,7 +11,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -25,9 +24,8 @@ from .const import ( DEV_RO_FILTER, DEV_SALT_SENSOR, DEV_SOFTENER, - DOMAIN, ) -from .coordinator import DROPDeviceDataUpdateCoordinator +from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator from .entity import DROPEntity _LOGGER = logging.getLogger(__name__) @@ -106,7 +104,7 @@ DEVICE_BINARY_SENSORS: dict[str, list[str]] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DROPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the DROP binary sensors from config entry.""" @@ -116,9 +114,10 @@ async def async_setup_entry( config_entry.entry_id, ) + coordinator = config_entry.runtime_data if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_BINARY_SENSORS: async_add_entities( - DROPBinarySensor(hass.data[DOMAIN][config_entry.entry_id], sensor) + DROPBinarySensor(coordinator, sensor) for sensor in BINARY_SENSORS if sensor.key in DEVICE_BINARY_SENSORS[config_entry.data[CONF_DEVICE_TYPE]] ) diff --git a/homeassistant/components/drop_connect/coordinator.py b/homeassistant/components/drop_connect/coordinator.py index 0861e091153..d37127d89ed 100644 --- a/homeassistant/components/drop_connect/coordinator.py +++ b/homeassistant/components/drop_connect/coordinator.py @@ -16,14 +16,19 @@ from .const import CONF_COMMAND_TOPIC, DOMAIN _LOGGER = logging.getLogger(__name__) +type DROPConfigEntry = ConfigEntry[DROPDeviceDataUpdateCoordinator] + + class DROPDeviceDataUpdateCoordinator(DataUpdateCoordinator[None]): """DROP device object.""" - config_entry: ConfigEntry + config_entry: DROPConfigEntry - def __init__(self, hass: HomeAssistant, unique_id: str) -> None: + def __init__(self, hass: HomeAssistant, entry: DROPConfigEntry) -> None: """Initialize the device.""" - super().__init__(hass, _LOGGER, name=f"{DOMAIN}-{unique_id}") + super().__init__( + hass, _LOGGER, config_entry=entry, name=f"{DOMAIN}-{entry.unique_id}" + ) self.drop_api = DropAPI() async def set_water(self, value: int) -> None: diff --git a/homeassistant/components/drop_connect/select.py b/homeassistant/components/drop_connect/select.py index ad06576c9f3..9e4c74b67e6 100644 --- a/homeassistant/components/drop_connect/select.py +++ b/homeassistant/components/drop_connect/select.py @@ -8,12 +8,11 @@ import logging from typing import Any from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_DEVICE_TYPE, DEV_HUB, DOMAIN -from .coordinator import DROPDeviceDataUpdateCoordinator +from .const import CONF_DEVICE_TYPE, DEV_HUB +from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator from .entity import DROPEntity _LOGGER = logging.getLogger(__name__) @@ -50,7 +49,7 @@ DEVICE_SELECTS: dict[str, list[str]] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DROPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the DROP selects from config entry.""" @@ -60,9 +59,10 @@ async def async_setup_entry( config_entry.entry_id, ) + coordinator = config_entry.runtime_data if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SELECTS: async_add_entities( - DROPSelect(hass.data[DOMAIN][config_entry.entry_id], select) + DROPSelect(coordinator, select) for select in SELECTS if select.key in DEVICE_SELECTS[config_entry.data[CONF_DEVICE_TYPE]] ) diff --git a/homeassistant/components/drop_connect/sensor.py b/homeassistant/components/drop_connect/sensor.py index ad123ee13c7..5ec47ed9eb1 100644 --- a/homeassistant/components/drop_connect/sensor.py +++ b/homeassistant/components/drop_connect/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -35,9 +34,8 @@ from .const import ( DEV_PUMP_CONTROLLER, DEV_RO_FILTER, DEV_SOFTENER, - DOMAIN, ) -from .coordinator import DROPDeviceDataUpdateCoordinator +from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator from .entity import DROPEntity _LOGGER = logging.getLogger(__name__) @@ -243,7 +241,7 @@ DEVICE_SENSORS: dict[str, list[str]] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DROPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the DROP sensors from config entry.""" @@ -253,9 +251,10 @@ async def async_setup_entry( config_entry.entry_id, ) + coordinator = config_entry.runtime_data if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SENSORS: async_add_entities( - DROPSensor(hass.data[DOMAIN][config_entry.entry_id], sensor) + DROPSensor(coordinator, sensor) for sensor in SENSORS if sensor.key in DEVICE_SENSORS[config_entry.data[CONF_DEVICE_TYPE]] ) diff --git a/homeassistant/components/drop_connect/switch.py b/homeassistant/components/drop_connect/switch.py index 98841d7ca24..404059d3196 100644 --- a/homeassistant/components/drop_connect/switch.py +++ b/homeassistant/components/drop_connect/switch.py @@ -8,7 +8,6 @@ import logging from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -18,9 +17,8 @@ from .const import ( DEV_HUB, DEV_PROTECTION_VALVE, DEV_SOFTENER, - DOMAIN, ) -from .coordinator import DROPDeviceDataUpdateCoordinator +from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator from .entity import DROPEntity _LOGGER = logging.getLogger(__name__) @@ -66,7 +64,7 @@ DEVICE_SWITCHES: dict[str, list[str]] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DROPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the DROP switches from config entry.""" @@ -76,9 +74,10 @@ async def async_setup_entry( config_entry.entry_id, ) + coordinator = config_entry.runtime_data if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SWITCHES: async_add_entities( - DROPSwitch(hass.data[DOMAIN][config_entry.entry_id], switch) + DROPSwitch(coordinator, switch) for switch in SWITCHES if switch.key in DEVICE_SWITCHES[config_entry.data[CONF_DEVICE_TYPE]] ) From abb58ec785fdc79b8aca7350ea681e84a021d139 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jan 2025 14:44:09 +0100 Subject: [PATCH 1084/2987] Include error reason in backup events (#136697) * Include error reason in backup events * Update hassio backup tests * Sort code * Remove catching BackupError in async_receive_backup --- homeassistant/components/backup/agent.py | 8 +- homeassistant/components/backup/manager.py | 104 ++++++++++++++---- homeassistant/components/backup/models.py | 10 +- .../backup/snapshots/test_websocket.ambr | 13 +++ tests/components/backup/test_manager.py | 77 ++++++++++++- tests/components/backup/test_websocket.py | 2 +- tests/components/hassio/test_backup.py | 41 +++++-- 7 files changed, 219 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index fe9eb9ea699..33656b6edcc 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -10,18 +10,20 @@ from typing import Any, Protocol from propcache.api import cached_property from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from .models import AgentBackup +from .models import AgentBackup, BackupError -class BackupAgentError(HomeAssistantError): +class BackupAgentError(BackupError): """Base class for backup agent errors.""" + error_code = "backup_agent_error" + class BackupAgentUnreachableError(BackupAgentError): """Raised when the agent can't reach its API.""" + error_code = "backup_agent_unreachable" _message = "The backup agent is unreachable." diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 99740428863..4a871cdf73e 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -22,7 +22,6 @@ from securetar import SecureTarFile, atomic_contents_add from homeassistant.backup_restore import RESTORE_BACKUP_FILE, password_to_key from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( instance_id, integration_platform, @@ -47,7 +46,7 @@ from .const import ( EXCLUDE_FROM_BACKUP, LOGGER, ) -from .models import AgentBackup, BackupManagerError, Folder +from .models import AgentBackup, BackupError, BackupManagerError, Folder from .store import BackupStore from .util import ( AsyncIteratorReader, @@ -171,6 +170,7 @@ class CreateBackupEvent(ManagerStateEvent): """Backup in progress.""" manager_state: BackupManagerState = BackupManagerState.CREATE_BACKUP + reason: str | None stage: CreateBackupStage | None state: CreateBackupState @@ -180,6 +180,7 @@ class ReceiveBackupEvent(ManagerStateEvent): """Backup receive.""" manager_state: BackupManagerState = BackupManagerState.RECEIVE_BACKUP + reason: str | None stage: ReceiveBackupStage | None state: ReceiveBackupState @@ -189,6 +190,7 @@ class RestoreBackupEvent(ManagerStateEvent): """Backup restore.""" manager_state: BackupManagerState = BackupManagerState.RESTORE_BACKUP + reason: str | None stage: RestoreBackupStage | None state: RestoreBackupState @@ -250,19 +252,23 @@ class BackupReaderWriter(abc.ABC): """Restore a backup.""" -class BackupReaderWriterError(HomeAssistantError): +class BackupReaderWriterError(BackupError): """Backup reader/writer error.""" + error_code = "backup_reader_writer_error" + class IncorrectPasswordError(BackupReaderWriterError): """Raised when the password is incorrect.""" + error_code = "password_incorrect" _message = "The password provided is incorrect." class DecryptOnDowloadNotSupported(BackupManagerError): """Raised when on-the-fly decryption is not supported.""" + error_code = "decrypt_on_download_not_supported" _message = "On-the-fly decryption is not supported for this backup." @@ -619,18 +625,30 @@ class BackupManager: if self.state is not BackupManagerState.IDLE: raise BackupManagerError(f"Backup manager busy: {self.state}") self.async_on_backup_event( - ReceiveBackupEvent(stage=None, state=ReceiveBackupState.IN_PROGRESS) + ReceiveBackupEvent( + reason=None, + stage=None, + state=ReceiveBackupState.IN_PROGRESS, + ) ) try: await self._async_receive_backup(agent_ids=agent_ids, contents=contents) except Exception: self.async_on_backup_event( - ReceiveBackupEvent(stage=None, state=ReceiveBackupState.FAILED) + ReceiveBackupEvent( + reason="unknown_error", + stage=None, + state=ReceiveBackupState.FAILED, + ) ) raise else: self.async_on_backup_event( - ReceiveBackupEvent(stage=None, state=ReceiveBackupState.COMPLETED) + ReceiveBackupEvent( + reason=None, + stage=None, + state=ReceiveBackupState.COMPLETED, + ) ) finally: self.async_on_backup_event(IdleEvent()) @@ -645,6 +663,7 @@ class BackupManager: contents.chunk_size = BUF_SIZE self.async_on_backup_event( ReceiveBackupEvent( + reason=None, stage=ReceiveBackupStage.RECEIVE_FILE, state=ReceiveBackupState.IN_PROGRESS, ) @@ -656,6 +675,7 @@ class BackupManager: ) self.async_on_backup_event( ReceiveBackupEvent( + reason=None, stage=ReceiveBackupStage.UPLOAD_TO_AGENTS, state=ReceiveBackupState.IN_PROGRESS, ) @@ -739,7 +759,11 @@ class BackupManager: self.store.save() self.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS) + CreateBackupEvent( + reason=None, + stage=None, + state=CreateBackupState.IN_PROGRESS, + ) ) try: return await self._async_create_backup( @@ -755,9 +779,14 @@ class BackupManager: raise_task_error=raise_task_error, with_automatic_settings=with_automatic_settings, ) - except Exception: + except Exception as err: + reason = err.error_code if isinstance(err, BackupError) else "unknown_error" self.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) + CreateBackupEvent( + reason=reason, + stage=None, + state=CreateBackupState.FAILED, + ) ) self.async_on_backup_event(IdleEvent()) if with_automatic_settings: @@ -861,6 +890,7 @@ class BackupManager: ) self.async_on_backup_event( CreateBackupEvent( + reason=None, stage=CreateBackupStage.UPLOAD_TO_AGENTS, state=CreateBackupState.IN_PROGRESS, ) @@ -891,14 +921,22 @@ class BackupManager: finally: self._backup_task = None self._backup_finish_task = None - self.async_on_backup_event( - CreateBackupEvent( - stage=None, - state=CreateBackupState.COMPLETED - if backup_success - else CreateBackupState.FAILED, + if backup_success: + self.async_on_backup_event( + CreateBackupEvent( + reason=None, + stage=None, + state=CreateBackupState.COMPLETED, + ) + ) + else: + self.async_on_backup_event( + CreateBackupEvent( + reason="upload_failed", + stage=None, + state=CreateBackupState.FAILED, + ) ) - ) self.async_on_backup_event(IdleEvent()) async def async_restore_backup( @@ -917,7 +955,11 @@ class BackupManager: raise BackupManagerError(f"Backup manager busy: {self.state}") self.async_on_backup_event( - RestoreBackupEvent(stage=None, state=RestoreBackupState.IN_PROGRESS) + RestoreBackupEvent( + reason=None, + stage=None, + state=RestoreBackupState.IN_PROGRESS, + ) ) try: await self._async_restore_backup( @@ -930,11 +972,28 @@ class BackupManager: restore_homeassistant=restore_homeassistant, ) self.async_on_backup_event( - RestoreBackupEvent(stage=None, state=RestoreBackupState.COMPLETED) + RestoreBackupEvent( + reason=None, + stage=None, + state=RestoreBackupState.COMPLETED, + ) ) + except BackupError as err: + self.async_on_backup_event( + RestoreBackupEvent( + reason=err.error_code, + stage=None, + state=RestoreBackupState.FAILED, + ) + ) + raise except Exception: self.async_on_backup_event( - RestoreBackupEvent(stage=None, state=RestoreBackupState.FAILED) + RestoreBackupEvent( + reason="unknown_error", + stage=None, + state=RestoreBackupState.FAILED, + ) ) raise finally: @@ -1210,6 +1269,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): on_progress( CreateBackupEvent( + reason=None, stage=CreateBackupStage.HOME_ASSISTANT, state=CreateBackupState.IN_PROGRESS, ) @@ -1469,7 +1529,11 @@ class CoreBackupReaderWriter(BackupReaderWriter): await self._hass.async_add_executor_job(_write_restore_file) on_progress( - RestoreBackupEvent(stage=None, state=RestoreBackupState.CORE_RESTART) + RestoreBackupEvent( + reason=None, + stage=None, + state=RestoreBackupState.CORE_RESTART, + ) ) await self._hass.services.async_call("homeassistant", "restart", blocking=True) diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py index 81c00d699c6..f2a83f50c17 100644 --- a/homeassistant/components/backup/models.py +++ b/homeassistant/components/backup/models.py @@ -71,5 +71,13 @@ class AgentBackup: ) -class BackupManagerError(HomeAssistantError): +class BackupError(HomeAssistantError): + """Base class for backup errors.""" + + error_code = "unknown" + + +class BackupManagerError(BackupError): """Backup manager error.""" + + error_code = "backup_manager_error" diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 2a6bc14fb74..634404b09cd 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -3383,6 +3383,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'in_progress', }), @@ -3404,6 +3405,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'home_assistant', 'state': 'in_progress', }), @@ -3415,6 +3417,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'upload_to_agents', 'state': 'in_progress', }), @@ -3426,6 +3429,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'completed', }), @@ -3454,6 +3458,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'in_progress', }), @@ -3475,6 +3480,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'home_assistant', 'state': 'in_progress', }), @@ -3486,6 +3492,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'upload_to_agents', 'state': 'in_progress', }), @@ -3497,6 +3504,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'completed', }), @@ -3525,6 +3533,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'in_progress', }), @@ -3546,6 +3555,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'home_assistant', 'state': 'in_progress', }), @@ -3557,6 +3567,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'upload_to_agents', 'state': 'in_progress', }), @@ -3568,6 +3579,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'completed', }), @@ -3912,6 +3924,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'in_progress', }), diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index c6eeff79d45..f2c2e5c5b05 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -419,6 +419,7 @@ async def test_initiate_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.IN_PROGRESS, } @@ -433,6 +434,7 @@ async def test_initiate_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.HOME_ASSISTANT, "state": CreateBackupState.IN_PROGRESS, } @@ -440,6 +442,7 @@ async def test_initiate_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.UPLOAD_TO_AGENTS, "state": CreateBackupState.IN_PROGRESS, } @@ -447,6 +450,7 @@ async def test_initiate_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.COMPLETED, } @@ -670,6 +674,7 @@ async def test_initiate_backup_with_agent_error( assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, "stage": None, + "reason": None, "state": CreateBackupState.IN_PROGRESS, } result = await ws_client.receive_json() @@ -683,6 +688,7 @@ async def test_initiate_backup_with_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.HOME_ASSISTANT, "state": CreateBackupState.IN_PROGRESS, } @@ -690,6 +696,7 @@ async def test_initiate_backup_with_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.UPLOAD_TO_AGENTS, "state": CreateBackupState.IN_PROGRESS, } @@ -697,6 +704,7 @@ async def test_initiate_backup_with_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", "stage": None, "state": CreateBackupState.FAILED, } @@ -1025,6 +1033,7 @@ async def test_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.IN_PROGRESS, } @@ -1039,6 +1048,7 @@ async def test_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.HOME_ASSISTANT, "state": CreateBackupState.IN_PROGRESS, } @@ -1046,6 +1056,7 @@ async def test_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.UPLOAD_TO_AGENTS, "state": CreateBackupState.IN_PROGRESS, } @@ -1053,6 +1064,7 @@ async def test_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", "stage": None, "state": CreateBackupState.FAILED, } @@ -1131,6 +1143,7 @@ async def test_initiate_backup_with_task_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.IN_PROGRESS, } @@ -1138,6 +1151,7 @@ async def test_initiate_backup_with_task_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", "stage": None, "state": CreateBackupState.FAILED, } @@ -1245,6 +1259,7 @@ async def test_initiate_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.IN_PROGRESS, } @@ -1259,6 +1274,7 @@ async def test_initiate_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.HOME_ASSISTANT, "state": CreateBackupState.IN_PROGRESS, } @@ -1266,6 +1282,7 @@ async def test_initiate_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.UPLOAD_TO_AGENTS, "state": CreateBackupState.IN_PROGRESS, } @@ -1273,6 +1290,7 @@ async def test_initiate_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", "stage": None, "state": CreateBackupState.FAILED, } @@ -1559,6 +1577,7 @@ async def test_receive_backup_busy_manager( result = await ws_client.receive_json() assert result["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1752,6 +1771,7 @@ async def test_receive_backup_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": None, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1759,6 +1779,7 @@ async def test_receive_backup_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.RECEIVE_FILE, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1766,6 +1787,7 @@ async def test_receive_backup_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1773,6 +1795,7 @@ async def test_receive_backup_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": None, "state": ReceiveBackupState.COMPLETED, } @@ -1885,6 +1908,7 @@ async def test_receive_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": None, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1892,6 +1916,7 @@ async def test_receive_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.RECEIVE_FILE, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1899,6 +1924,7 @@ async def test_receive_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2007,6 +2033,7 @@ async def test_receive_backup_file_write_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": None, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2014,6 +2041,7 @@ async def test_receive_backup_file_write_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.RECEIVE_FILE, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2021,6 +2049,7 @@ async def test_receive_backup_file_write_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": "unknown_error", "stage": None, "state": ReceiveBackupState.FAILED, } @@ -2114,6 +2143,7 @@ async def test_receive_backup_read_tar_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": None, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2121,6 +2151,7 @@ async def test_receive_backup_read_tar_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.RECEIVE_FILE, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2128,6 +2159,7 @@ async def test_receive_backup_read_tar_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": "unknown_error", "stage": None, "state": ReceiveBackupState.FAILED, } @@ -2151,6 +2183,7 @@ async def test_receive_backup_read_tar_error( "unlink_call_count", "unlink_exception", "final_state", + "final_state_reason", "response_status", ), [ @@ -2164,6 +2197,7 @@ async def test_receive_backup_read_tar_error( 1, None, ReceiveBackupState.COMPLETED, + None, 201, ), ( @@ -2176,6 +2210,7 @@ async def test_receive_backup_read_tar_error( 1, None, ReceiveBackupState.COMPLETED, + None, 201, ), ( @@ -2188,6 +2223,7 @@ async def test_receive_backup_read_tar_error( 1, None, ReceiveBackupState.COMPLETED, + None, 201, ), ( @@ -2200,6 +2236,7 @@ async def test_receive_backup_read_tar_error( 1, OSError("Boom!"), ReceiveBackupState.FAILED, + "unknown_error", 500, ), ], @@ -2218,6 +2255,7 @@ async def test_receive_backup_file_read_error( unlink_call_count: int, unlink_exception: Exception | None, final_state: ReceiveBackupState, + final_state_reason: str | None, response_status: int, ) -> None: """Test file read error during backup receive.""" @@ -2288,6 +2326,7 @@ async def test_receive_backup_file_read_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": None, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2295,6 +2334,7 @@ async def test_receive_backup_file_read_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.RECEIVE_FILE, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2302,6 +2342,7 @@ async def test_receive_backup_file_read_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2309,6 +2350,7 @@ async def test_receive_backup_file_read_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": final_state_reason, "stage": None, "state": final_state, } @@ -2394,6 +2436,7 @@ async def test_restore_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.IN_PROGRESS, } @@ -2401,6 +2444,7 @@ async def test_restore_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.CORE_RESTART, } @@ -2410,6 +2454,7 @@ async def test_restore_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.COMPLETED, } @@ -2497,6 +2542,7 @@ async def test_restore_backup_wrong_password( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.IN_PROGRESS, } @@ -2504,6 +2550,7 @@ async def test_restore_backup_wrong_password( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": "password_incorrect", "stage": None, "state": RestoreBackupState.FAILED, } @@ -2523,23 +2570,27 @@ async def test_restore_backup_wrong_password( @pytest.mark.usefixtures("path_glob") @pytest.mark.parametrize( - ("parameters", "expected_error"), + ("parameters", "expected_error", "expected_reason"), [ ( {"backup_id": TEST_BACKUP_DEF456.backup_id}, f"Backup def456 not found in agent {LOCAL_AGENT_ID}", + "backup_manager_error", ), ( {"restore_addons": ["blah"]}, "Addons and folders are not supported in core restore", + "backup_reader_writer_error", ), ( {"restore_folders": [Folder.ADDONS]}, "Addons and folders are not supported in core restore", + "backup_reader_writer_error", ), ( {"restore_database": False, "restore_homeassistant": False}, "Home Assistant or database must be included in restore", + "backup_reader_writer_error", ), ], ) @@ -2548,6 +2599,7 @@ async def test_restore_backup_wrong_parameters( hass_ws_client: WebSocketGenerator, parameters: dict[str, Any], expected_error: str, + expected_reason: str, ) -> None: """Test restore backup wrong parameters.""" await async_setup_component(hass, DOMAIN, {}) @@ -2584,6 +2636,7 @@ async def test_restore_backup_wrong_parameters( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.IN_PROGRESS, } @@ -2591,6 +2644,7 @@ async def test_restore_backup_wrong_parameters( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": expected_reason, "stage": None, "state": RestoreBackupState.FAILED, } @@ -2640,10 +2694,20 @@ async def test_restore_backup_when_busy( @pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.parametrize( - ("exception", "error_code", "error_message"), + ("exception", "error_code", "error_message", "expected_reason"), [ - (BackupAgentError("Boom!"), "home_assistant_error", "Boom!"), - (Exception("Boom!"), "unknown_error", "Unknown error"), + ( + BackupAgentError("Boom!"), + "home_assistant_error", + "Boom!", + "backup_agent_error", + ), + ( + Exception("Boom!"), + "unknown_error", + "Unknown error", + "unknown_error", + ), ], ) async def test_restore_backup_agent_error( @@ -2652,6 +2716,7 @@ async def test_restore_backup_agent_error( exception: Exception, error_code: str, error_message: str, + expected_reason: str, ) -> None: """Test restore backup with agent error.""" remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) @@ -2694,6 +2759,7 @@ async def test_restore_backup_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.IN_PROGRESS, } @@ -2701,6 +2767,7 @@ async def test_restore_backup_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": expected_reason, "stage": None, "state": RestoreBackupState.FAILED, } @@ -2841,6 +2908,7 @@ async def test_restore_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.IN_PROGRESS, } @@ -2848,6 +2916,7 @@ async def test_restore_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": "unknown_error", "stage": None, "state": RestoreBackupState.FAILED, } diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 52c04474162..0fd0ba308b3 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -2816,7 +2816,7 @@ async def test_subscribe_event( assert await client.receive_json() == snapshot manager.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS) + CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS, reason=None) ) assert await client.receive_json() == snapshot diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 9483b513718..8cf8d11af04 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -753,6 +753,7 @@ async def test_reader_writer_create( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -780,6 +781,7 @@ async def test_reader_writer_create( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": "upload_to_agents", "state": "in_progress", } @@ -787,6 +789,7 @@ async def test_reader_writer_create( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "completed", } @@ -800,14 +803,20 @@ async def test_reader_writer_create( @pytest.mark.usefixtures("hassio_client", "setup_integration") @pytest.mark.parametrize( - ("side_effect", "error_code", "error_message"), + ("side_effect", "error_code", "error_message", "expected_reason"), [ ( SupervisorError("Boom!"), "home_assistant_error", "Error creating backup: Boom!", + "backup_manager_error", + ), + ( + Exception("Boom!"), + "unknown_error", + "Unknown error", + "unknown_error", ), - (Exception("Boom!"), "unknown_error", "Unknown error"), ], ) async def test_reader_writer_create_partial_backup_error( @@ -817,6 +826,7 @@ async def test_reader_writer_create_partial_backup_error( side_effect: Exception, error_code: str, error_message: str, + expected_reason: str, ) -> None: """Test client partial backup error when generating a backup.""" client = await hass_ws_client(hass) @@ -834,6 +844,7 @@ async def test_reader_writer_create_partial_backup_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -841,6 +852,7 @@ async def test_reader_writer_create_partial_backup_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": expected_reason, "stage": None, "state": "failed", } @@ -878,6 +890,7 @@ async def test_reader_writer_create_missing_reference_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -903,6 +916,7 @@ async def test_reader_writer_create_missing_reference_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": "upload_failed", "stage": None, "state": "failed", } @@ -961,6 +975,7 @@ async def test_reader_writer_create_download_remove_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -986,6 +1001,7 @@ async def test_reader_writer_create_download_remove_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": "upload_to_agents", "state": "in_progress", } @@ -993,6 +1009,7 @@ async def test_reader_writer_create_download_remove_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": "upload_failed", "stage": None, "state": "failed", } @@ -1042,6 +1059,7 @@ async def test_reader_writer_create_info_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1067,6 +1085,7 @@ async def test_reader_writer_create_info_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": "upload_failed", "stage": None, "state": "failed", } @@ -1114,6 +1133,7 @@ async def test_reader_writer_create_remote_backup( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1141,6 +1161,7 @@ async def test_reader_writer_create_remote_backup( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": "upload_to_agents", "state": "in_progress", } @@ -1148,6 +1169,7 @@ async def test_reader_writer_create_remote_backup( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "completed", } @@ -1204,6 +1226,7 @@ async def test_reader_writer_create_wrong_parameters( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1211,6 +1234,7 @@ async def test_reader_writer_create_wrong_parameters( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": "unknown_error", "stage": None, "state": "failed", } @@ -1316,6 +1340,7 @@ async def test_reader_writer_restore( response = await client.receive_json() assert response["event"] == { "manager_state": "restore_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1347,6 +1372,7 @@ async def test_reader_writer_restore( response = await client.receive_json() assert response["event"] == { "manager_state": "restore_backup", + "reason": None, "stage": None, "state": "completed", } @@ -1360,15 +1386,13 @@ async def test_reader_writer_restore( @pytest.mark.parametrize( - ("supervisor_error_string", "expected_error_code"), + ("supervisor_error_string", "expected_error_code", "expected_reason"), [ - ( - "Invalid password for backup", - "password_incorrect", - ), + ("Invalid password for backup", "password_incorrect", "password_incorrect"), ( "Backup was made on supervisor version 2025.12.0, can't restore on 2024.12.0. Must update supervisor first.", "home_assistant_error", + "unknown_error", ), ], ) @@ -1379,6 +1403,7 @@ async def test_reader_writer_restore_error( supervisor_client: AsyncMock, supervisor_error_string: str, expected_error_code: str, + expected_reason: str, ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) @@ -1400,6 +1425,7 @@ async def test_reader_writer_restore_error( response = await client.receive_json() assert response["event"] == { "manager_state": "restore_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1419,6 +1445,7 @@ async def test_reader_writer_restore_error( response = await client.receive_json() assert response["event"] == { "manager_state": "restore_backup", + "reason": expected_reason, "stage": None, "state": "failed", } From 139061afa349937055894848a84511b671b4fbdb Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Tue, 28 Jan 2025 14:14:43 +0000 Subject: [PATCH 1085/2987] Bump ohmepy to 1.2.8 (#136719) --- homeassistant/components/ohme/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index bb3716c3e74..602c53ced7b 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.6"] + "requirements": ["ohme==1.2.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 97d3a5402a3..baec606c57c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1541,7 +1541,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.2.6 +ohme==1.2.8 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e91e456224..ad8c67ba1fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1289,7 +1289,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.2.6 +ohme==1.2.8 # homeassistant.components.ollama ollama==0.4.7 From 658d3cf06e107e3003cf63943af5537b8833355c Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 28 Jan 2025 15:16:58 +0100 Subject: [PATCH 1086/2987] Add support for KNX UI to create BinarySensor entities (#136703) --- homeassistant/components/knx/binary_sensor.py | 130 +++++++++++++----- homeassistant/components/knx/const.py | 8 +- homeassistant/components/knx/schema.py | 10 +- homeassistant/components/knx/storage/const.py | 1 + .../knx/storage/entity_store_schema.py | 32 ++++- tests/components/knx/test_binary_sensor.py | 57 +++++++- 6 files changed, 191 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index 96438df96d7..c629860351c 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -18,14 +18,28 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from . import KNXModule -from .const import ATTR_COUNTER, ATTR_SOURCE, KNX_MODULE_KEY -from .entity import KnxYamlEntity -from .schema import BinarySensorSchema +from .const import ( + ATTR_COUNTER, + ATTR_SOURCE, + CONF_CONTEXT_TIMEOUT, + CONF_IGNORE_INTERNAL_STATE, + CONF_INVERT, + CONF_RESET_AFTER, + CONF_STATE_ADDRESS, + CONF_SYNC_STATE, + DOMAIN, + KNX_MODULE_KEY, +) +from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity +from .storage.const import CONF_ENTITY, CONF_GA_PASSIVE, CONF_GA_SENSOR, CONF_GA_STATE async def async_setup_entry( @@ -35,40 +49,38 @@ async def async_setup_entry( ) -> None: """Set up the KNX binary sensor platform.""" knx_module = hass.data[KNX_MODULE_KEY] - config: list[ConfigType] = knx_module.config_yaml[Platform.BINARY_SENSOR] - - async_add_entities( - KNXBinarySensor(knx_module, entity_config) for entity_config in config + platform = async_get_current_platform() + knx_module.config_store.add_platform( + platform=Platform.BINARY_SENSOR, + controller=KnxUiEntityPlatformController( + knx_module=knx_module, + entity_platform=platform, + entity_class=KnxUiBinarySensor, + ), ) + entities: list[KnxYamlEntity | KnxUiEntity] = [] + if yaml_platform_config := knx_module.config_yaml.get(Platform.BINARY_SENSOR): + entities.extend( + KnxYamlBinarySensor(knx_module, entity_config) + for entity_config in yaml_platform_config + ) + if ui_config := knx_module.config_store.data["entities"].get( + Platform.BINARY_SENSOR + ): + entities.extend( + KnxUiBinarySensor(knx_module, unique_id, config) + for unique_id, config in ui_config.items() + ) + if entities: + async_add_entities(entities) -class KNXBinarySensor(KnxYamlEntity, BinarySensorEntity, RestoreEntity): + +class _KnxBinarySensor(BinarySensorEntity, RestoreEntity): """Representation of a KNX binary sensor.""" _device: XknxBinarySensor - def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: - """Initialize of KNX binary sensor.""" - super().__init__( - knx_module=knx_module, - device=XknxBinarySensor( - xknx=knx_module.xknx, - name=config[CONF_NAME], - group_address_state=config[BinarySensorSchema.CONF_STATE_ADDRESS], - invert=config[BinarySensorSchema.CONF_INVERT], - sync_state=config[BinarySensorSchema.CONF_SYNC_STATE], - ignore_internal_state=config[ - BinarySensorSchema.CONF_IGNORE_INTERNAL_STATE - ], - context_timeout=config.get(BinarySensorSchema.CONF_CONTEXT_TIMEOUT), - reset_after=config.get(BinarySensorSchema.CONF_RESET_AFTER), - ), - ) - self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) - self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._attr_force_update = self._device.ignore_internal_state - self._attr_unique_id = str(self._device.remote_value.group_address_state) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() @@ -92,3 +104,59 @@ class KNXBinarySensor(KnxYamlEntity, BinarySensorEntity, RestoreEntity): if self._device.last_telegram is not None: attr[ATTR_SOURCE] = str(self._device.last_telegram.source_address) return attr + + +class KnxYamlBinarySensor(_KnxBinarySensor, KnxYamlEntity): + """Representation of a KNX binary sensor configured from YAML.""" + + _device: XknxBinarySensor + + def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: + """Initialize of KNX binary sensor.""" + super().__init__( + knx_module=knx_module, + device=XknxBinarySensor( + xknx=knx_module.xknx, + name=config[CONF_NAME], + group_address_state=config[CONF_STATE_ADDRESS], + invert=config[CONF_INVERT], + sync_state=config[CONF_SYNC_STATE], + ignore_internal_state=config[CONF_IGNORE_INTERNAL_STATE], + context_timeout=config.get(CONF_CONTEXT_TIMEOUT), + reset_after=config.get(CONF_RESET_AFTER), + ), + ) + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._attr_force_update = self._device.ignore_internal_state + self._attr_unique_id = str(self._device.remote_value.group_address_state) + + +class KnxUiBinarySensor(_KnxBinarySensor, KnxUiEntity): + """Representation of a KNX binary sensor configured from UI.""" + + _device: XknxBinarySensor + + def __init__( + self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + ) -> None: + """Initialize KNX binary sensor.""" + super().__init__( + knx_module=knx_module, + unique_id=unique_id, + entity_config=config[CONF_ENTITY], + ) + self._device = XknxBinarySensor( + xknx=knx_module.xknx, + name=config[CONF_ENTITY][CONF_NAME], + group_address_state=[ + config[DOMAIN][CONF_GA_SENSOR][CONF_GA_STATE], + *config[DOMAIN][CONF_GA_SENSOR][CONF_GA_PASSIVE], + ], + sync_state=config[DOMAIN][CONF_SYNC_STATE], + invert=config[DOMAIN].get(CONF_INVERT, False), + ignore_internal_state=config[DOMAIN].get(CONF_IGNORE_INTERNAL_STATE, False), + context_timeout=config[DOMAIN].get(CONF_CONTEXT_TIMEOUT), + reset_after=config[DOMAIN].get(CONF_RESET_AFTER), + ) + self._attr_force_update = self._device.ignore_internal_state diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 3ef35479c4e..b403018dae3 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -67,6 +67,8 @@ CONF_KNX_SECURE_USER_PASSWORD: Final = "user_password" CONF_KNX_SECURE_DEVICE_AUTHENTICATION: Final = "device_authentication" +CONF_CONTEXT_TIMEOUT: Final = "context_timeout" +CONF_IGNORE_INTERNAL_STATE: Final = "ignore_internal_state" CONF_PAYLOAD_LENGTH: Final = "payload_length" CONF_RESET_AFTER: Final = "reset_after" CONF_RESPOND_TO_READ: Final = "respond_to_read" @@ -156,7 +158,11 @@ SUPPORTED_PLATFORMS_YAML: Final = { Platform.WEATHER, } -SUPPORTED_PLATFORMS_UI: Final = {Platform.SWITCH, Platform.LIGHT} +SUPPORTED_PLATFORMS_UI: Final = { + Platform.BINARY_SENSOR, + Platform.LIGHT, + Platform.SWITCH, +} # Map KNX controller modes to HA modes. This list might not be complete. CONTROLLER_MODES: Final = { diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 9311046e410..5c83da58c3a 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -45,6 +45,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA from .const import ( + CONF_CONTEXT_TIMEOUT, + CONF_IGNORE_INTERNAL_STATE, CONF_INVERT, CONF_KNX_EXPOSE, CONF_PAYLOAD_LENGTH, @@ -211,14 +213,6 @@ class BinarySensorSchema(KNXPlatformSchema): """Voluptuous schema for KNX binary sensors.""" PLATFORM = Platform.BINARY_SENSOR - - CONF_STATE_ADDRESS = CONF_STATE_ADDRESS - CONF_SYNC_STATE = CONF_SYNC_STATE - CONF_INVERT = CONF_INVERT - CONF_IGNORE_INTERNAL_STATE = "ignore_internal_state" - CONF_CONTEXT_TIMEOUT = "context_timeout" - CONF_RESET_AFTER = CONF_RESET_AFTER - DEFAULT_NAME = "KNX Binary Sensor" ENTITY_SCHEMA = vol.All( diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index 42b76a5a0fd..cf3f2bb9f95 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -10,6 +10,7 @@ CONF_GA_STATE: Final = "state" CONF_GA_PASSIVE: Final = "passive" CONF_DPT: Final = "dpt" +CONF_GA_SENSOR: Final = "ga_sensor" CONF_GA_SWITCH: Final = "ga_switch" CONF_GA_COLOR_TEMP: Final = "ga_color_temp" CONF_COLOR_TEMP_MIN: Final = "color_temp_min" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 84854d2ec85..d99ffa86f52 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -11,12 +11,15 @@ from homeassistant.const import ( CONF_PLATFORM, Platform, ) -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA from homeassistant.helpers.typing import VolDictType, VolSchemaType from ..const import ( + CONF_CONTEXT_TIMEOUT, + CONF_IGNORE_INTERNAL_STATE, CONF_INVERT, + CONF_RESET_AFTER, CONF_RESPOND_TO_READ, CONF_SYNC_STATE, DOMAIN, @@ -42,6 +45,7 @@ from .const import ( CONF_GA_RED_BRIGHTNESS, CONF_GA_RED_SWITCH, CONF_GA_SATURATION, + CONF_GA_SENSOR, CONF_GA_STATE, CONF_GA_SWITCH, CONF_GA_WHITE_BRIGHTNESS, @@ -94,6 +98,29 @@ def optional_ga_schema(key: str, ga_selector: GASelector) -> VolDictType: } +BINARY_SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, + vol.Required(DOMAIN): { + vol.Required(CONF_GA_SENSOR): GASelector(write=False, state_required=True), + vol.Required(CONF_RESPOND_TO_READ, default=False): bool, + vol.Required(CONF_SYNC_STATE, default=True): sync_state_validator, + vol.Optional(CONF_INVERT): selector.BooleanSelector(), + vol.Optional(CONF_IGNORE_INTERNAL_STATE): selector.BooleanSelector(), + vol.Optional(CONF_CONTEXT_TIMEOUT): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=10, step=0.1, unit_of_measurement="s" + ) + ), + vol.Optional(CONF_RESET_AFTER): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=10, step=0.1, unit_of_measurement="s" + ) + ), + }, + } +) + SWITCH_SCHEMA = vol.Schema( { vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, @@ -213,6 +240,9 @@ ENTITY_STORE_DATA_SCHEMA: VolSchemaType = vol.All( cv.key_value_schemas( CONF_PLATFORM, { + Platform.BINARY_SENSOR: vol.Schema( + {vol.Required(CONF_DATA): BINARY_SENSOR_SCHEMA}, extra=vol.ALLOW_EXTRA + ), Platform.SWITCH: vol.Schema( {vol.Required(CONF_DATA): SWITCH_SCHEMA}, extra=vol.ALLOW_EXTRA ), diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index dbb8d2ee832..4b58801a8a0 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -1,10 +1,19 @@ """Test KNX binary sensor.""" from datetime import timedelta +from typing import Any from freezegun.api import FrozenDateTimeFactory +import pytest -from homeassistant.components.knx.const import CONF_STATE_ADDRESS, CONF_SYNC_STATE +from homeassistant.components.knx.const import ( + CONF_CONTEXT_TIMEOUT, + CONF_IGNORE_INTERNAL_STATE, + CONF_INVERT, + CONF_RESET_AFTER, + CONF_STATE_ADDRESS, + CONF_SYNC_STATE, +) from homeassistant.components.knx.schema import BinarySensorSchema from homeassistant.const import ( CONF_ENTITY_CATEGORY, @@ -12,10 +21,12 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, EntityCategory, + Platform, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er +from . import KnxEntityGenerator from .conftest import KNXTestKit from tests.common import ( @@ -60,7 +71,7 @@ async def test_binary_sensor(hass: HomeAssistant, knx: KNXTestKit) -> None: { CONF_NAME: "test_invert", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_INVERT: True, + CONF_INVERT: True, }, ] } @@ -113,7 +124,7 @@ async def test_binary_sensor_ignore_internal_state( { CONF_NAME: "test_ignore", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_IGNORE_INTERNAL_STATE: True, + CONF_IGNORE_INTERNAL_STATE: True, CONF_SYNC_STATE: False, }, ] @@ -156,7 +167,7 @@ async def test_binary_sensor_counter( { CONF_NAME: "test", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_CONTEXT_TIMEOUT: context_timeout, + CONF_CONTEXT_TIMEOUT: context_timeout, CONF_SYNC_STATE: False, }, ] @@ -220,7 +231,7 @@ async def test_binary_sensor_reset( { CONF_NAME: "test", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_RESET_AFTER: 1, + CONF_RESET_AFTER: 1, CONF_SYNC_STATE: False, }, ] @@ -279,7 +290,7 @@ async def test_binary_sensor_restore_invert(hass: HomeAssistant, knx) -> None: { CONF_NAME: "test", CONF_STATE_ADDRESS: _ADDRESS, - BinarySensorSchema.CONF_INVERT: True, + CONF_INVERT: True, CONF_SYNC_STATE: False, }, ] @@ -295,3 +306,37 @@ async def test_binary_sensor_restore_invert(hass: HomeAssistant, knx) -> None: await knx.receive_write(_ADDRESS, True) state = hass.states.get("binary_sensor.test") assert state.state is STATE_OFF + + +@pytest.mark.parametrize( + ("knx_data"), + [ + { + "ga_sensor": {"state": "2/2/2"}, + "sync_state": True, + }, + { + "ga_sensor": {"state": "2/2/2"}, + "sync_state": True, + "invert": True, + }, + ], +) +async def test_binary_sensor_ui_create( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + knx_data: dict[str, Any], +) -> None: + """Test creating a binary sensor.""" + await knx.setup_integration({}) + await create_ui_entity( + platform=Platform.BINARY_SENSOR, + entity_data={"name": "test"}, + knx_data=knx_data, + ) + # created entity sends read-request to KNX bus + await knx.assert_read("2/2/2") + await knx.receive_response("2/2/2", not knx_data.get("invert")) + state = hass.states.get("binary_sensor.test") + assert state.state is STATE_ON From 58f7dd5dcc92437f16b57fd57d26030cf4e1fdd5 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 28 Jan 2025 16:18:37 +0200 Subject: [PATCH 1087/2987] Fix LG webOS TV external arc volume set action (#136717) --- homeassistant/components/webostv/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 4b39841e29d..796dede88b6 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -228,7 +228,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): if self.state != MediaPlayerState.OFF or not self._supported_features: supported = SUPPORT_WEBOSTV - if self._client.sound_output in ("external_arc", "external_speaker"): + if self._client.sound_output == "external_speaker": supported = supported | SUPPORT_WEBOSTV_VOLUME elif self._client.sound_output != "lineout": supported = ( From 259f57b3aa3bc31492255563577fb046544887a6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 15:19:54 +0100 Subject: [PATCH 1088/2987] Use runtime_data in devialet (#136432) --- homeassistant/components/devialet/__init__.py | 19 ++++++++--------- .../components/devialet/coordinator.py | 10 ++++++++- .../components/devialet/diagnostics.py | 11 +++------- .../components/devialet/media_player.py | 21 ++++++++----------- tests/components/devialet/test_init.py | 5 ----- .../components/devialet/test_media_player.py | 9 -------- 6 files changed, 30 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/devialet/__init__.py b/homeassistant/components/devialet/__init__.py index 2eccdb2a4b6..be641ad58a5 100644 --- a/homeassistant/components/devialet/__init__.py +++ b/homeassistant/components/devialet/__init__.py @@ -4,29 +4,28 @@ from __future__ import annotations from devialet import DevialetApi -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN +from .coordinator import DevialetConfigEntry, DevialetCoordinator PLATFORMS = [Platform.MEDIA_PLAYER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: DevialetConfigEntry) -> bool: """Set up Devialet from a config entry.""" session = async_get_clientsession(hass) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DevialetApi( - entry.data[CONF_HOST], session - ) + client = DevialetApi(entry.data[CONF_HOST], session) + coordinator = DevialetCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + + 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: DevialetConfigEntry) -> bool: """Unload Devialet config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - 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/devialet/coordinator.py b/homeassistant/components/devialet/coordinator.py index 9cfeb797373..7b022b921f8 100644 --- a/homeassistant/components/devialet/coordinator.py +++ b/homeassistant/components/devialet/coordinator.py @@ -5,6 +5,7 @@ import logging from devialet import DevialetApi +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -14,15 +15,22 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=5) +type DevialetConfigEntry = ConfigEntry[DevialetCoordinator] + class DevialetCoordinator(DataUpdateCoordinator[None]): """Devialet update coordinator.""" - def __init__(self, hass: HomeAssistant, client: DevialetApi) -> None: + config_entry: DevialetConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: DevialetConfigEntry, client: DevialetApi + ) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/devialet/diagnostics.py b/homeassistant/components/devialet/diagnostics.py index ae887dd1c8c..75d6e7aa222 100644 --- a/homeassistant/components/devialet/diagnostics.py +++ b/homeassistant/components/devialet/diagnostics.py @@ -4,18 +4,13 @@ from __future__ import annotations from typing import Any -from devialet import DevialetApi - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .coordinator import DevialetConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: DevialetConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - client: DevialetApi = hass.data[DOMAIN][entry.entry_id] - - return await client.async_get_diagnostics() + return await entry.runtime_data.client.async_get_diagnostics() diff --git a/homeassistant/components/devialet/media_player.py b/homeassistant/components/devialet/media_player.py index 8789516650a..04ec58723cf 100644 --- a/homeassistant/components/devialet/media_player.py +++ b/homeassistant/components/devialet/media_player.py @@ -9,7 +9,6 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -17,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER, SOUND_MODES -from .coordinator import DevialetCoordinator +from .coordinator import DevialetConfigEntry, DevialetCoordinator SUPPORT_DEVIALET = ( MediaPlayerEntityFeature.VOLUME_SET @@ -37,14 +36,12 @@ DEVIALET_TO_HA_FEATURE_MAP = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DevialetConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Devialet entry.""" - client = hass.data[DOMAIN][entry.entry_id] - coordinator = DevialetCoordinator(hass, client) - await coordinator.async_config_entry_first_refresh() - - async_add_entities([DevialetMediaPlayerEntity(coordinator, entry)]) + async_add_entities([DevialetMediaPlayerEntity(entry.runtime_data)]) class DevialetMediaPlayerEntity( @@ -55,18 +52,18 @@ class DevialetMediaPlayerEntity( _attr_has_entity_name = True _attr_name = None - def __init__(self, coordinator: DevialetCoordinator, entry: ConfigEntry) -> None: + def __init__(self, coordinator: DevialetCoordinator) -> None: """Initialize the Devialet device.""" - self.coordinator = coordinator super().__init__(coordinator) + entry = coordinator.config_entry self._attr_unique_id = str(entry.unique_id) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer=MANUFACTURER, - model=self.coordinator.client.model, + model=coordinator.client.model, name=entry.data[CONF_NAME], - sw_version=self.coordinator.client.version, + sw_version=coordinator.client.version, ) @callback diff --git a/tests/components/devialet/test_init.py b/tests/components/devialet/test_init.py index a87e8ac05c3..6808ee0983e 100644 --- a/tests/components/devialet/test_init.py +++ b/tests/components/devialet/test_init.py @@ -1,6 +1,5 @@ """Test the Devialet init.""" -from homeassistant.components.devialet.const import DOMAIN from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, MediaPlayerState from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -16,7 +15,6 @@ async def test_load_unload_config_entry( """Test the Devialet configuration entry loading and unloading.""" entry = await setup_integration(hass, aioclient_mock) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED assert entry.unique_id is not None @@ -26,7 +24,6 @@ async def test_load_unload_config_entry( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED @@ -36,7 +33,6 @@ async def test_load_unload_config_entry_when_device_unavailable( """Test the Devialet configuration entry loading and unloading when the device is unavailable.""" entry = await setup_integration(hass, aioclient_mock, state="unavailable") - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED assert entry.unique_id is not None @@ -46,5 +42,4 @@ async def test_load_unload_config_entry_when_device_unavailable( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/devialet/test_media_player.py b/tests/components/devialet/test_media_player.py index 6ca3d23218f..fd593a10a98 100644 --- a/tests/components/devialet/test_media_player.py +++ b/tests/components/devialet/test_media_player.py @@ -6,7 +6,6 @@ from devialet import DevialetApi from devialet.const import UrlSuffix from yarl import URL -from homeassistant.components.devialet.const import DOMAIN from homeassistant.components.devialet.media_player import SUPPORT_DEVIALET from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY from homeassistant.components.media_player import ( @@ -108,7 +107,6 @@ async def test_media_player_playing( await async_setup_component(hass, "homeassistant", {}) entry = await setup_integration(hass, aioclient_mock) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED await hass.services.async_call( @@ -227,7 +225,6 @@ async def test_media_player_playing( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED @@ -237,7 +234,6 @@ async def test_media_player_offline( """Test the Devialet configuration entry loading and unloading.""" entry = await setup_integration(hass, aioclient_mock, state=STATE_UNAVAILABLE) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED state = hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}") @@ -247,7 +243,6 @@ async def test_media_player_offline( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED @@ -257,14 +252,12 @@ async def test_media_player_without_serial( """Test the Devialet configuration entry loading and unloading.""" entry = await setup_integration(hass, aioclient_mock, serial=None) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED assert entry.unique_id is None await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED @@ -276,7 +269,6 @@ async def test_media_player_services( hass, aioclient_mock, state=MediaPlayerState.PLAYING ) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED target = {ATTR_ENTITY_ID: hass.states.get(f"{MP_DOMAIN}.{NAME}").entity_id} @@ -309,5 +301,4 @@ async def test_media_player_services( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED From 22e72953e543ec3d5727f07d98c1668fc6d3d6a2 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 28 Jan 2025 15:24:15 +0100 Subject: [PATCH 1089/2987] Adjust Matter discovery logic to disallow the primary value(s) to be None (#136712) --- homeassistant/components/matter/discovery.py | 9 ++++++++- homeassistant/components/matter/models.py | 3 +++ homeassistant/components/matter/number.py | 8 ++++++++ homeassistant/components/matter/select.py | 2 ++ homeassistant/components/matter/update.py | 1 + homeassistant/components/matter/vacuum.py | 1 + 6 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index de03d250836..7ca64482763 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Generator -from chip.clusters.Objects import ClusterAttributeDescriptor +from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, NullValue from matter_server.client.models.node import MatterEndpoint from homeassistant.const import Platform @@ -121,6 +121,13 @@ def async_discover_entities( ): continue + # check if value exists but is none/null + if not schema.allow_none_value and any( + endpoint.get_attribute_value(None, val_schema) in (None, NullValue) + for val_schema in schema.required_attributes + ): + continue + # check for required value in (primary) attribute primary_attribute = schema.required_attributes[0] primary_value = endpoint.get_attribute_value(None, primary_attribute) diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index f1fd7ca9fa3..ea80d0eb903 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -128,3 +128,6 @@ class MatterDiscoverySchema: # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False + + # [optional] the primary attribute value may not be null/None + allow_none_value: bool = False diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 4518e83e9d0..93b6b8f75c9 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -86,6 +86,8 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterNumber, required_attributes=(clusters.LevelControl.Attributes.OnLevel,), + # allow None value to account for 'default' value + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.NUMBER, @@ -103,6 +105,8 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterNumber, required_attributes=(clusters.LevelControl.Attributes.OnTransitionTime,), + # allow None value to account for 'default' value + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.NUMBER, @@ -120,6 +124,8 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterNumber, required_attributes=(clusters.LevelControl.Attributes.OffTransitionTime,), + # allow None value to account for 'default' value + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.NUMBER, @@ -137,6 +143,8 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterNumber, required_attributes=(clusters.LevelControl.Attributes.OnOffTransitionTime,), + # allow None value to account for 'default' value + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.NUMBER, diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index 1018bed6af0..b10f4e0e484 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -267,6 +267,8 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterAttributeSelectEntity, required_attributes=(clusters.OnOff.Attributes.StartUpOnOff,), + # allow None value for previous state + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.SELECT, diff --git a/homeassistant/components/matter/update.py b/homeassistant/components/matter/update.py index f31dd7b3aa3..5ee9b2e5fa0 100644 --- a/homeassistant/components/matter/update.py +++ b/homeassistant/components/matter/update.py @@ -261,5 +261,6 @@ DISCOVERY_SCHEMAS = [ clusters.OtaSoftwareUpdateRequestor.Attributes.UpdateState, clusters.OtaSoftwareUpdateRequestor.Attributes.UpdateStateProgress, ), + allow_none_value=True, ), ] diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index 511b32d3182..de4a885d8fb 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -208,5 +208,6 @@ DISCOVERY_SCHEMAS = [ clusters.PowerSource.Attributes.BatPercentRemaining, ), device_type=(device_types.RoboticVacuumCleaner,), + allow_none_value=True, ), ] From a05ac6255c5d3bc775fa2ffad58f6aacb4becd87 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 15:54:06 +0100 Subject: [PATCH 1090/2987] Standardize util imports (#136723) --- homeassistant/components/alexa/capabilities.py | 3 +-- homeassistant/components/avea/light.py | 2 +- homeassistant/components/blinksticklight/light.py | 4 ++-- homeassistant/components/eufy/light.py | 2 +- homeassistant/components/everlights/light.py | 4 ++-- homeassistant/components/hive/light.py | 2 +- homeassistant/components/home_connect/light.py | 2 +- homeassistant/components/homekit_controller/light.py | 2 +- homeassistant/components/hyperion/light.py | 2 +- homeassistant/components/iglo/light.py | 4 ++-- homeassistant/components/knx/light.py | 2 +- homeassistant/components/lifx/util.py | 2 +- homeassistant/components/light/__init__.py | 2 +- homeassistant/components/light/intent.py | 2 +- homeassistant/components/lw12wifi/light.py | 4 ++-- homeassistant/components/mqtt/light/schema_basic.py | 4 ++-- homeassistant/components/mqtt/light/schema_json.py | 4 ++-- homeassistant/components/mqtt/light/schema_template.py | 4 ++-- homeassistant/components/osramlightify/light.py | 4 ++-- homeassistant/components/plum_lightpad/light.py | 2 +- homeassistant/components/tikteck/light.py | 4 ++-- homeassistant/components/trace/models.py | 3 +-- homeassistant/components/tradfri/light.py | 2 +- homeassistant/components/vera/light.py | 2 +- homeassistant/components/wemo/light.py | 2 +- homeassistant/components/xiaomi_aqara/light.py | 2 +- homeassistant/components/yeelight/light.py | 5 ++--- homeassistant/components/yeelightsunflower/light.py | 4 ++-- homeassistant/components/zengge/light.py | 4 ++-- homeassistant/components/zerproc/light.py | 2 +- homeassistant/components/zwave_js/light.py | 2 +- homeassistant/helpers/check_config.py | 2 +- homeassistant/helpers/device_registry.py | 2 +- homeassistant/scripts/check_config.py | 3 +-- tests/components/color_extractor/test_service.py | 2 +- tests/components/light/test_init.py | 2 +- 36 files changed, 48 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index c5b4ad15904..e70055c20b1 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -50,8 +50,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant, State -import homeassistant.util.color as color_util -import homeassistant.util.dt as dt_util +from homeassistant.util import color as color_util, dt as dt_util from .const import ( API_TEMP_UNITS, diff --git a/homeassistant/components/avea/light.py b/homeassistant/components/avea/light.py index 48471b41633..ec39a6f371c 100644 --- a/homeassistant/components/avea/light.py +++ b/homeassistant/components/avea/light.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util def setup_platform( diff --git a/homeassistant/components/blinksticklight/light.py b/homeassistant/components/blinksticklight/light.py index 19ac5f80242..01e5c90aadf 100644 --- a/homeassistant/components/blinksticklight/light.py +++ b/homeassistant/components/blinksticklight/light.py @@ -17,10 +17,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util CONF_SERIAL = "serial" diff --git a/homeassistant/components/eufy/light.py b/homeassistant/components/eufy/light.py index 95ad8a15d1c..dcce52612ee 100644 --- a/homeassistant/components/eufy/light.py +++ b/homeassistant/components/eufy/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util EUFYHOME_MAX_KELVIN = 6500 EUFYHOME_MIN_KELVIN = 2700 diff --git a/homeassistant/components/everlights/light.py b/homeassistant/components/everlights/light.py index 2ba47978353..ae159d77240 100644 --- a/homeassistant/components/everlights/light.py +++ b/homeassistant/components/everlights/light.py @@ -21,11 +21,11 @@ from homeassistant.components.light import ( from homeassistant.const import CONF_HOSTS from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index 8d09c902f36..e941087c6fb 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import refresh_system from .const import ATTR_MODE, DOMAIN diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index e33017cd51f..3e81bcbddad 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .api import HomeConnectDevice diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index b306c440d7b..04c75731731 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import KNOWN_DEVICES from .connection import HKDevice diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index 5fa129ce7ad..40d093430a5 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -26,7 +26,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import ( get_hyperion_device_id, diff --git a/homeassistant/components/iglo/light.py b/homeassistant/components/iglo/light.py index 0d20761c6e5..d356ad05541 100644 --- a/homeassistant/components/iglo/light.py +++ b/homeassistant/components/iglo/light.py @@ -20,10 +20,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util DEFAULT_NAME = "iGlo Light" DEFAULT_PORT = 8080 diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 6115f8be128..33edc19fb1c 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity_platform import ( async_get_current_platform, ) from homeassistant.helpers.typing import ConfigType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import KNXModule from .const import CONF_SYNC_STATE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ColorTempModes diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index ffffe7a4856..3d37f1c3bc5 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -24,7 +24,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import ( _ATTR_COLOR_TEMP, diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 65a89b7d688..d87dcf41161 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -35,7 +35,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.loader import bind_hass -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import ( # noqa: F401 COLOR_MODES_BRIGHTNESS, diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py index e496255029a..83f2ee58b5e 100644 --- a/homeassistant/components/light/intent.py +++ b/homeassistant/components/light/intent.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import SERVICE_TURN_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, intent -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR from .const import DOMAIN diff --git a/homeassistant/components/lw12wifi/light.py b/homeassistant/components/lw12wifi/light.py index 60741c861dd..7071cc9f416 100644 --- a/homeassistant/components/lw12wifi/light.py +++ b/homeassistant/components/lw12wifi/light.py @@ -20,10 +20,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index eaaa80af223..a2f424b247d 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -42,11 +42,11 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 2d152ca12c8..43b0cbf77b3 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -49,12 +49,12 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import async_get_hass, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.json import json_dumps from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, VolSchemaType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from homeassistant.util.json import json_loads_object from homeassistant.util.yaml import dump as yaml_dump diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 69bc801ff1e..901cee6f14c 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -31,11 +31,11 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, TemplateVarsType, VolSchemaType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA diff --git a/homeassistant/components/osramlightify/light.py b/homeassistant/components/osramlightify/light.py index 6ddd392af7b..25380810862 100644 --- a/homeassistant/components/osramlightify/light.py +++ b/homeassistant/components/osramlightify/light.py @@ -24,10 +24,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/plum_lightpad/light.py b/homeassistant/components/plum_lightpad/light.py index a385565b837..08a3d0ab0b9 100644 --- a/homeassistant/components/plum_lightpad/light.py +++ b/homeassistant/components/plum_lightpad/light.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import DOMAIN diff --git a/homeassistant/components/tikteck/light.py b/homeassistant/components/tikteck/light.py index 26ffc0e7b6d..a3961cbb569 100644 --- a/homeassistant/components/tikteck/light.py +++ b/homeassistant/components/tikteck/light.py @@ -17,10 +17,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/trace/models.py b/homeassistant/components/trace/models.py index e8ef417ca5f..3c503efdd28 100644 --- a/homeassistant/components/trace/models.py +++ b/homeassistant/components/trace/models.py @@ -15,9 +15,8 @@ from homeassistant.helpers.trace import ( trace_id_set, trace_set_child_id, ) -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, uuid as uuid_util from homeassistant.util.limited_size_dict import LimitedSizeDict -import homeassistant.util.uuid as uuid_util type TraceData = dict[str, LimitedSizeDict[str, BaseTrace]] diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index a71691e6e90..e464d1a8142 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -20,7 +20,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API from .coordinator import TradfriDeviceDataUpdateCoordinator diff --git a/homeassistant/components/vera/light.py b/homeassistant/components/vera/light.py index e512676de9a..9b8ae42f620 100644 --- a/homeassistant/components/vera/light.py +++ b/homeassistant/components/vera/light.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .common import ControllerData, get_controller_data from .entity import VeraEntity diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 6068cd3ff0b..619e0952457 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import async_wemo_dispatcher_connect from .const import DOMAIN as WEMO_DOMAIN diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py index c8057f1df4a..11ce7a0107b 100644 --- a/homeassistant/components/xiaomi_aqara/light.py +++ b/homeassistant/components/xiaomi_aqara/light.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import DOMAIN, GATEWAYS_KEY from .entity import XiaomiDevice diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 8cc3f2600e5..92ee3976f7f 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -32,13 +32,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import VolDictType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import YEELIGHT_FLOW_TRANSITION_SCHEMA from .const import ( diff --git a/homeassistant/components/yeelightsunflower/light.py b/homeassistant/components/yeelightsunflower/light.py index 0d8247fc865..4cacd1def22 100644 --- a/homeassistant/components/yeelightsunflower/light.py +++ b/homeassistant/components/yeelightsunflower/light.py @@ -17,10 +17,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zengge/light.py b/homeassistant/components/zengge/light.py index 69b7c63476a..2ab46820b56 100644 --- a/homeassistant/components/zengge/light.py +++ b/homeassistant/components/zengge/light.py @@ -18,10 +18,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zerproc/light.py b/homeassistant/components/zerproc/light.py index ed6ed03ad27..36a964a46ab 100644 --- a/homeassistant/components/zerproc/light.py +++ b/homeassistant/components/zerproc/light.py @@ -20,7 +20,7 @@ from homeassistant.core import Event, HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 639d2fbcd7a..0a2ca95a2b0 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -42,7 +42,7 @@ 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 AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index a8e8fa4160d..0841585e1a1 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -29,7 +29,7 @@ from homeassistant.requirements import ( async_clear_install_history, async_get_integration_with_requirements, ) -import homeassistant.util.yaml.loader as yaml_loader +from homeassistant.util.yaml import loader as yaml_loader from . import config_validation as cv from .typing import ConfigType diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 685509cb29d..975b4a2aec9 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -24,11 +24,11 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_suggest_report_issue +from homeassistant.util import uuid as uuid_util from homeassistant.util.dt import utc_from_timestamp, utcnow from homeassistant.util.event_type import EventType from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import format_unserializable_data -import homeassistant.util.uuid as uuid_util from . import storage, translation from .debounce import Debouncer diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 568e8c84a30..a24568e9a6f 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -23,8 +23,7 @@ from homeassistant.helpers import ( issue_registry as ir, ) from homeassistant.helpers.check_config import async_check_ha_config_file -from homeassistant.util.yaml import Secrets -import homeassistant.util.yaml.loader as yaml_loader +from homeassistant.util.yaml import Secrets, loader as yaml_loader # mypy: allow-untyped-calls, allow-untyped-defs diff --git a/tests/components/color_extractor/test_service.py b/tests/components/color_extractor/test_service.py index 23ba5e7808c..3f920b7dee2 100644 --- a/tests/components/color_extractor/test_service.py +++ b/tests/components/color_extractor/test_service.py @@ -25,7 +25,7 @@ from homeassistant.components.light import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMocker diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 6d0337f37a5..5bc17ea3e24 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import frame from homeassistant.setup import async_setup_component -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .common import MockLight From 3d7e3590d422a3edbbdd3d3ca37e50f8a85696a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jan 2025 04:57:11 -1000 Subject: [PATCH 1091/2987] Migrate usb to use aiousbwatcher (#136676) * Migrate usb to use aiousbwatcher aiousbwatcher uses inotify on /dev/bus/usb to look for devices added and being removed which works on a lot more systems * bump asyncinotify * bump aiousbwatcher to 1.1.1 * tweaks * tweaks * tweaks * fixes * debugging * Update homeassistant/components/usb/__init__.py * Update homeassistant/components/usb/__init__.py --------- Co-authored-by: Paulus Schoutsen --- .../components/keyboard_remote/manifest.json | 2 +- homeassistant/components/usb/__init__.py | 124 ++++----- homeassistant/components/usb/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 8 +- requirements_test_all.txt | 6 +- tests/components/usb/test_init.py | 241 ++++++++---------- 7 files changed, 175 insertions(+), 210 deletions(-) diff --git a/homeassistant/components/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json index b405f36bb23..f543ae72972 100644 --- a/homeassistant/components/keyboard_remote/manifest.json +++ b/homeassistant/components/keyboard_remote/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aionotify", "evdev"], "quality_scale": "legacy", - "requirements": ["evdev==1.6.1", "asyncinotify==4.0.2"] + "requirements": ["evdev==1.6.1", "asyncinotify==4.2.0"] } diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index ec65143b984..d68742522a0 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import Callable, Coroutine, Sequence import dataclasses from datetime import datetime, timedelta @@ -10,8 +11,9 @@ from functools import partial import logging import os import sys -from typing import TYPE_CHECKING, Any, overload +from typing import Any, overload +from aiousbwatcher import AIOUSBWatcher, InotifyNotAvailableError from serial.tools.list_ports import comports from serial.tools.list_ports_common import ListPortInfo import voluptuous as vol @@ -26,7 +28,7 @@ from homeassistant.core import ( HomeAssistant, callback as hass_callback, ) -from homeassistant.helpers import config_validation as cv, discovery_flow, system_info +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.deprecation import ( DeprecatedConstant, @@ -43,15 +45,13 @@ from .const import DOMAIN from .models import USBDevice from .utils import usb_device_from_port -if TYPE_CHECKING: - from pyudev import Device, MonitorObserver - _LOGGER = logging.getLogger(__name__) PORT_EVENT_CALLBACK_TYPE = Callable[[set[USBDevice], set[USBDevice]], None] POLLING_MONITOR_SCAN_PERIOD = timedelta(seconds=5) REQUEST_SCAN_COOLDOWN = 10 # 10 second cooldown +ADD_REMOVE_SCAN_COOLDOWN = 5 # 5 second cooldown to give devices a chance to register __all__ = [ "USBCallbackMatcher", @@ -255,15 +255,17 @@ class USBDiscovery: self.seen: set[tuple[str, ...]] = set() self.observer_active = False self._request_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None + self._add_remove_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None self._request_callbacks: list[CALLBACK_TYPE] = [] self.initial_scan_done = False self._initial_scan_callbacks: list[CALLBACK_TYPE] = [] self._port_event_callbacks: set[PORT_EVENT_CALLBACK_TYPE] = set() self._last_processed_devices: set[USBDevice] = set() + self._scan_lock = asyncio.Lock() async def async_setup(self) -> None: """Set up USB Discovery.""" - if await self._async_supports_monitoring(): + if self._async_supports_monitoring(): await self._async_start_monitor() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start) @@ -279,16 +281,19 @@ class USBDiscovery: if self._request_debouncer: self._request_debouncer.async_shutdown() - async def _async_supports_monitoring(self) -> bool: - info = await system_info.async_get_system_info(self.hass) - return not info.get("docker") + @hass_callback + def _async_supports_monitoring(self) -> bool: + return sys.platform == "linux" async def _async_start_monitor(self) -> None: """Start monitoring hardware.""" - if not await self._async_start_monitor_udev(): + try: + await self._async_start_aiousbwatcher() + except InotifyNotAvailableError as ex: _LOGGER.info( - "Falling back to periodic filesystem polling for development, libudev " - "is not present" + "Falling back to periodic filesystem polling for development, aiousbwatcher " + "is not available on this system: %s", + ex, ) self._async_start_monitor_polling() @@ -309,70 +314,27 @@ class USBDiscovery: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling) - async def _async_start_monitor_udev(self) -> bool: - """Start monitoring hardware with pyudev. Returns True if successful.""" - if not sys.platform.startswith("linux"): - return False + async def _async_start_aiousbwatcher(self) -> None: + """Start monitoring hardware with aiousbwatcher. - if not ( - observer := await self.hass.async_add_executor_job( - self._get_monitor_observer - ) - ): - return False - - def _stop_observer(event: Event) -> None: - observer.stop() - - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_observer) - self.observer_active = True - return True - - def _get_monitor_observer(self) -> MonitorObserver | None: - """Get the monitor observer. - - This runs in the executor because the import - does blocking I/O. + Returns True if successful. """ - from pyudev import ( # pylint: disable=import-outside-toplevel - Context, - Monitor, - MonitorObserver, - ) - try: - context = Context() - except (ImportError, OSError): - return None + @hass_callback + def _usb_change_callback() -> None: + self._async_delayed_add_remove_scan() - monitor = Monitor.from_netlink(context) - try: - monitor.filter_by(subsystem="tty") - except ValueError as ex: # this fails on WSL - _LOGGER.debug( - "Unable to setup pyudev filtering; This is expected on WSL: %s", ex - ) - return None + watcher = AIOUSBWatcher() + watcher.async_register_callback(_usb_change_callback) + cancel = watcher.async_start() - observer = MonitorObserver( - monitor, callback=self._device_event, name="usb-observer" - ) + @hass_callback + def _async_stop_watcher(event: Event) -> None: + cancel() - observer.start() - return observer + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_watcher) - def _device_event(self, device: Device) -> None: - """Call when the observer receives a USB device event.""" - if device.action not in ("add", "remove"): - return - - _LOGGER.info( - "Received a udev device event %r for %s, triggering scan", - device.action, - device.device_node, - ) - - self.hass.create_task(self._async_scan()) + self.observer_active = True @hass_callback def async_register_scan_request_callback( @@ -466,11 +428,13 @@ class USBDiscovery: async def _async_process_ports(self, ports: Sequence[ListPortInfo]) -> None: """Process each discovered port.""" + _LOGGER.debug("Processing ports: %r", ports) usb_devices = { usb_device_from_port(port) for port in ports if port.vid is not None or port.pid is not None } + _LOGGER.debug("USB devices: %r", usb_devices) # CP2102N chips create *two* serial ports on macOS: `/dev/cu.usbserial-` and # `/dev/cu.SLAB_USBtoUART*`. The former does not work and we should ignore them. @@ -509,11 +473,27 @@ class USBDiscovery: for usb_device in usb_devices: await self._async_process_discovered_usb_device(usb_device) + @hass_callback + def _async_delayed_add_remove_scan(self) -> None: + """Request a serial scan after a debouncer delay.""" + if not self._add_remove_debouncer: + self._add_remove_debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=ADD_REMOVE_SCAN_COOLDOWN, + immediate=False, + function=self._async_scan, + background=True, + ) + self._add_remove_debouncer.async_schedule_call() + async def _async_scan_serial(self) -> None: """Scan serial ports.""" - await self._async_process_ports( - await self.hass.async_add_executor_job(comports) - ) + _LOGGER.debug("Executing comports scan") + async with self._scan_lock: + await self._async_process_ports( + await self.hass.async_add_executor_job(comports) + ) if self.initial_scan_done: return diff --git a/homeassistant/components/usb/manifest.json b/homeassistant/components/usb/manifest.json index 19269801c11..7035e2ab2cb 100644 --- a/homeassistant/components/usb/manifest.json +++ b/homeassistant/components/usb/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["pyudev==0.24.1", "pyserial==3.5"] + "requirements": ["aiousbwatcher==1.1.1", "pyserial==3.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2959e8bf322..e29c0f25d7c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -8,6 +8,7 @@ aiohttp-asyncmdnsresolver==0.0.1 aiohttp-fast-zlib==0.2.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 +aiousbwatcher==1.1.1 aiozoneinfo==0.2.1 astral==2.2 async-interrupt==1.2.0 @@ -57,7 +58,6 @@ pyserial==3.5 pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.7.5 -pyudev==0.24.1 PyYAML==6.0.2 requests==2.32.3 securetar==2025.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index baec606c57c..e9436475775 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -403,6 +403,9 @@ aiotractive==0.6.0 # homeassistant.components.unifi aiounifi==81 +# homeassistant.components.usb +aiousbwatcher==1.1.1 + # homeassistant.components.vlc_telnet aiovlc==0.5.1 @@ -511,7 +514,7 @@ async-upnp-client==0.43.0 asyncarve==0.1.1 # homeassistant.components.keyboard_remote -asyncinotify==4.0.2 +asyncinotify==4.2.0 # homeassistant.components.supla asyncpysupla==0.0.5 @@ -2491,9 +2494,6 @@ pytrafikverket==1.1.1 # homeassistant.components.v2c pytrydan==0.8.0 -# homeassistant.components.usb -pyudev==0.24.1 - # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ad8c67ba1fb..c1752dc7e45 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -385,6 +385,9 @@ aiotractive==0.6.0 # homeassistant.components.unifi aiounifi==81 +# homeassistant.components.usb +aiousbwatcher==1.1.1 + # homeassistant.components.vlc_telnet aiovlc==0.5.1 @@ -2015,9 +2018,6 @@ pytrafikverket==1.1.1 # homeassistant.components.v2c pytrydan==0.8.0 -# homeassistant.components.usb -pyudev==0.24.1 - # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 8f8ed672374..9730dba53d7 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -7,6 +7,7 @@ import os from typing import Any from unittest.mock import MagicMock, Mock, call, patch, sentinel +from aiousbwatcher import InotifyNotAvailableError import pytest from homeassistant.components import usb @@ -15,58 +16,29 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT from homeassistant.core import HomeAssistant from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from . import conbee_device, slae_sh_device -from tests.common import import_and_test_deprecated_constant +from tests.common import async_fire_time_changed, import_and_test_deprecated_constant from tests.typing import WebSocketGenerator -@pytest.fixture(name="operating_system") -def mock_operating_system(): - """Mock running Home Assistant Operating system.""" +@pytest.fixture(name="aiousbwatcher_no_inotify") +def aiousbwatcher_no_inotify(): + """Patch AIOUSBWatcher to not use inotify.""" with patch( - "homeassistant.components.usb.system_info.async_get_system_info", - return_value={ - "hassio": True, - "docker": True, - }, + "homeassistant.components.usb.AIOUSBWatcher.async_start", + side_effect=InotifyNotAvailableError, ): yield -@pytest.fixture(name="docker") -def mock_docker(): - """Mock running Home Assistant in docker container.""" - with patch( - "homeassistant.components.usb.system_info.async_get_system_info", - return_value={ - "hassio": False, - "docker": True, - }, - ): - yield - - -@pytest.fixture(name="venv") -def mock_venv(): - """Mock running Home Assistant in a venv container.""" - with patch( - "homeassistant.components.usb.system_info.async_get_system_info", - return_value={ - "hassio": False, - "docker": False, - "virtualenv": True, - }, - ): - yield - - -async def test_observer_discovery( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv +async def test_aiousbwatcher_discovery( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: - """Test that observer can discover a device without raising an exception.""" - new_usb = [{"domain": "test1", "vid": "3039"}] + """Test that aiousbwatcher can discover a device without raising an exception.""" + new_usb = [{"domain": "test1", "vid": "3039"}, {"domain": "test2", "vid": "0FA0"}] mock_comports = [ MagicMock( @@ -78,26 +50,23 @@ async def test_observer_discovery( description=slae_sh_device.description, ) ] - mock_observer = None - async def _mock_monitor_observer_callback(callback): - await hass.async_add_executor_job( - callback, MagicMock(action="add", device_path="/dev/new") - ) + aiousbwatcher_callback = None - def _create_mock_monitor_observer(monitor, callback, name): - nonlocal mock_observer - hass.create_task(_mock_monitor_observer_callback(callback)) - mock_observer = MagicMock() - return mock_observer + def async_register_callback(callback): + nonlocal aiousbwatcher_callback + aiousbwatcher_callback = callback + + MockAIOUSBWatcher = MagicMock() + MockAIOUSBWatcher.async_register_callback = async_register_callback with ( patch("sys.platform", "linux"), - patch("pyudev.Context"), - patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer), - patch("pyudev.Monitor.filter_by"), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch( + "homeassistant.components.usb.AIOUSBWatcher", return_value=MockAIOUSBWatcher + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -105,18 +74,42 @@ async def test_observer_discovery( hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert len(mock_config_flow.mock_calls) == 1 - assert mock_config_flow.mock_calls[0][1][0] == "test1" + assert aiousbwatcher_callback is not None - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + await hass.async_block_till_done() + assert len(mock_config_flow.mock_calls) == 1 - # pylint:disable-next=unnecessary-dunder-call - assert mock_observer.mock_calls == [call.start(), call.__bool__(), call.stop()] + mock_comports.append( + MagicMock( + device=slae_sh_device.device, + vid=4000, + pid=4000, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ) + + aiousbwatcher_callback() + await hass.async_block_till_done() + + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=usb.ADD_REMOVE_SCAN_COOLDOWN) + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(mock_config_flow.mock_calls) == 2 + assert mock_config_flow.mock_calls[1][1][0] == "test2" + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_polling_discovery( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test that polling can discover a device without raising an exception.""" new_usb = [{"domain": "test1", "vid": "3039"}] @@ -143,10 +136,6 @@ async def test_polling_discovery( with ( patch("sys.platform", "linux"), - patch( - "homeassistant.components.usb.USBDiscovery._get_monitor_observer", - return_value=None, - ), patch( "homeassistant.components.usb.POLLING_MONITOR_SCAN_PERIOD", timedelta(seconds=0.01), @@ -174,19 +163,9 @@ async def test_polling_discovery( await hass.async_block_till_done() -async def test_removal_by_observer_before_started( - hass: HomeAssistant, operating_system -) -> None: - """Test a device is removed by the observer before started.""" - - async def _mock_monitor_observer_callback(callback): - await hass.async_add_executor_job( - callback, MagicMock(action="remove", device_path="/dev/new") - ) - - def _create_mock_monitor_observer(monitor, callback, name): - hass.async_create_task(_mock_monitor_observer_callback(callback)) - +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +async def test_removal_by_aiousbwatcher_before_started(hass: HomeAssistant) -> None: + """Test a device is removed by the aiousbwatcher before started.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] mock_comports = [ @@ -203,7 +182,6 @@ async def test_removal_by_observer_before_started( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), - patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -219,6 +197,7 @@ async def test_removal_by_observer_before_started( await hass.async_block_till_done() +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -237,7 +216,6 @@ async def test_discovered_by_websocket_scan( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -256,6 +234,7 @@ async def test_discovered_by_websocket_scan( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_limited_by_description_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -276,7 +255,6 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -295,6 +273,7 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_most_targeted_matcher_wins( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -316,7 +295,6 @@ async def test_most_targeted_matcher_wins( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -335,6 +313,7 @@ async def test_most_targeted_matcher_wins( assert mock_config_flow.mock_calls[0][1][0] == "more" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_rejected_by_description_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -355,7 +334,6 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -373,6 +351,7 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -398,7 +377,6 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -417,6 +395,7 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -437,7 +416,6 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -455,6 +433,7 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -480,7 +459,6 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -499,6 +477,7 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -524,7 +503,6 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -542,6 +520,7 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -562,7 +541,6 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -580,6 +558,7 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_match_vid_only( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -598,7 +577,6 @@ async def test_discovered_by_websocket_scan_match_vid_only( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -617,6 +595,7 @@ async def test_discovered_by_websocket_scan_match_vid_only( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_match_vid_wrong_pid( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -635,7 +614,6 @@ async def test_discovered_by_websocket_scan_match_vid_wrong_pid( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -653,6 +631,7 @@ async def test_discovered_by_websocket_scan_match_vid_wrong_pid( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_no_vid_pid( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -671,7 +650,6 @@ async def test_discovered_by_websocket_no_vid_pid( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -689,9 +667,9 @@ async def test_discovered_by_websocket_no_vid_pid( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.parametrize("exception_type", [ImportError, OSError]) +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_non_matching_discovered_by_scanner_after_started( - hass: HomeAssistant, exception_type, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a websocket scan that does not match.""" new_usb = [{"domain": "test1", "vid": "4444", "pid": "4444"}] @@ -708,7 +686,6 @@ async def test_non_matching_discovered_by_scanner_after_started( ] with ( - patch("pyudev.Context", side_effect=exception_type), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -726,10 +703,10 @@ async def test_non_matching_discovered_by_scanner_after_started( assert len(mock_config_flow.mock_calls) == 0 -async def test_observer_on_wsl_fallback_without_throwing_exception( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv +async def test_aiousbwatcher_on_wsl_fallback_without_throwing_exception( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: - """Test that observer on WSL failure results in fallback to scanning without raising an exception.""" + """Test that aiousbwatcher on WSL failure results in fallback to scanning without raising an exception.""" new_usb = [{"domain": "test1", "vid": "3039"}] mock_comports = [ @@ -744,8 +721,6 @@ async def test_observer_on_wsl_fallback_without_throwing_exception( ] with ( - patch("pyudev.Context"), - patch("pyudev.Monitor.filter_by", side_effect=ValueError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -764,20 +739,8 @@ async def test_observer_on_wsl_fallback_without_throwing_exception( assert mock_config_flow.mock_calls[0][1][0] == "test1" -async def test_not_discovered_by_observer_before_started_on_docker( - hass: HomeAssistant, docker -) -> None: - """Test a device is not discovered since observer is not running on bare docker.""" - - async def _mock_monitor_observer_callback(callback): - await hass.async_add_executor_job( - callback, MagicMock(action="add", device_path="/dev/new") - ) - - def _create_mock_monitor_observer(monitor, callback, name): - hass.async_create_task(_mock_monitor_observer_callback(callback)) - return MagicMock() - +async def test_discovered_by_aiousbwatcher_before_started(hass: HomeAssistant) -> None: + """Test a device is discovered since aiousbwatcher is now running.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] mock_comports = [ @@ -790,23 +753,45 @@ async def test_not_discovered_by_observer_before_started_on_docker( description=slae_sh_device.description, ) ] + initial_mock_comports = [] + aiousbwatcher_callback = None + + def async_register_callback(callback): + nonlocal aiousbwatcher_callback + aiousbwatcher_callback = callback + + MockAIOUSBWatcher = MagicMock() + MockAIOUSBWatcher.async_register_callback = async_register_callback with ( + patch("sys.platform", "linux"), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), - patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer), + patch( + "homeassistant.components.usb.comports", return_value=initial_mock_comports + ), + patch( + "homeassistant.components.usb.AIOUSBWatcher", return_value=MockAIOUSBWatcher + ), + patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() - with ( - patch("homeassistant.components.usb.comports", return_value=[]), - patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, - ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert len(mock_config_flow.mock_calls) == 0 + assert len(mock_config_flow.mock_calls) == 0 + + initial_mock_comports.extend(mock_comports) + aiousbwatcher_callback() + await hass.async_block_till_done() + + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=usb.ADD_REMOVE_SCAN_COOLDOWN) + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(mock_config_flow.mock_calls) == 1 def test_get_serial_by_id_no_dir() -> None: @@ -889,6 +874,7 @@ def test_human_readable_device_name() -> None: assert "8A2A" in name +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_async_is_plugged_in( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -912,7 +898,6 @@ async def test_async_is_plugged_in( } with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -935,6 +920,7 @@ async def test_async_is_plugged_in( assert usb.async_is_plugged_in(hass, matcher) +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") @pytest.mark.parametrize( "matcher", [ @@ -953,7 +939,6 @@ async def test_async_is_plugged_in_case_enforcement( new_usb = [{"domain": "test1", "vid": "ABCD"}] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -967,6 +952,7 @@ async def test_async_is_plugged_in_case_enforcement( usb.async_is_plugged_in(hass, matcher) +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_web_socket_triggers_discovery_request_callbacks( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -974,7 +960,6 @@ async def test_web_socket_triggers_discovery_request_callbacks( mock_callback = Mock() with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=[]), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -1002,6 +987,7 @@ async def test_web_socket_triggers_discovery_request_callbacks( assert len(mock_callback.mock_calls) == 1 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_initial_scan_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -1010,7 +996,6 @@ async def test_initial_scan_callback( mock_callback_2 = Mock() with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=[]), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -1038,6 +1023,7 @@ async def test_initial_scan_callback( cancel_2() +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_cancel_initial_scan_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -1045,7 +1031,6 @@ async def test_cancel_initial_scan_callback( mock_callback = Mock() with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=[]), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -1064,6 +1049,7 @@ async def test_cancel_initial_scan_callback( assert len(mock_callback.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_resolve_serial_by_id( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -1082,7 +1068,6 @@ async def test_resolve_serial_by_id( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch( @@ -1106,6 +1091,7 @@ async def test_resolve_serial_by_id( assert mock_config_flow.mock_calls[0][2]["data"].device == "/dev/serial/by-id/bla" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") @pytest.mark.parametrize( "ports", [ @@ -1190,7 +1176,6 @@ async def test_cp2102n_ordering_on_macos( with ( patch("sys.platform", "darwin"), - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -1239,6 +1224,7 @@ def test_deprecated_constants( ) +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") @patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) async def test_register_port_event_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -1273,7 +1259,6 @@ async def test_register_port_event_callback( # Start off with no ports with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.comports", return_value=[]), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -1335,6 +1320,7 @@ async def test_register_port_event_callback( assert mock_callback2.mock_calls == [] +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") @patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) async def test_register_port_event_callback_failure( hass: HomeAssistant, @@ -1371,7 +1357,6 @@ async def test_register_port_event_callback_failure( # Start off with no ports with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.comports", return_value=[]), ): assert await async_setup_component(hass, "usb", {"usb": {}}) From 56955823878a373510d8e36fc9382f756bfe7e81 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 28 Jan 2025 15:57:46 +0100 Subject: [PATCH 1092/2987] Add OneDrive as backup provider (#135121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/microsoft.json | 1 + homeassistant/components/onedrive/__init__.py | 167 ++++++++ homeassistant/components/onedrive/api.py | 53 +++ .../onedrive/application_credentials.py | 14 + homeassistant/components/onedrive/backup.py | 290 ++++++++++++++ .../components/onedrive/config_flow.py | 112 ++++++ homeassistant/components/onedrive/const.py | 24 ++ .../components/onedrive/manifest.json | 13 + .../components/onedrive/quality_scale.yaml | 139 +++++++ .../components/onedrive/strings.json | 53 +++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/onedrive/__init__.py | 14 + tests/components/onedrive/conftest.py | 178 +++++++++ tests/components/onedrive/const.py | 19 + tests/components/onedrive/test_backup.py | 363 ++++++++++++++++++ tests/components/onedrive/test_config_flow.py | 197 ++++++++++ tests/components/onedrive/test_init.py | 112 ++++++ 24 files changed, 1776 insertions(+) create mode 100644 homeassistant/components/onedrive/__init__.py create mode 100644 homeassistant/components/onedrive/api.py create mode 100644 homeassistant/components/onedrive/application_credentials.py create mode 100644 homeassistant/components/onedrive/backup.py create mode 100644 homeassistant/components/onedrive/config_flow.py create mode 100644 homeassistant/components/onedrive/const.py create mode 100644 homeassistant/components/onedrive/manifest.json create mode 100644 homeassistant/components/onedrive/quality_scale.yaml create mode 100644 homeassistant/components/onedrive/strings.json create mode 100644 tests/components/onedrive/__init__.py create mode 100644 tests/components/onedrive/conftest.py create mode 100644 tests/components/onedrive/const.py create mode 100644 tests/components/onedrive/test_backup.py create mode 100644 tests/components/onedrive/test_config_flow.py create mode 100644 tests/components/onedrive/test_init.py diff --git a/.strict-typing b/.strict-typing index 62da6c5ca92..811e5d54c81 100644 --- a/.strict-typing +++ b/.strict-typing @@ -359,6 +359,7 @@ homeassistant.components.number.* homeassistant.components.nut.* homeassistant.components.onboarding.* homeassistant.components.oncue.* +homeassistant.components.onedrive.* homeassistant.components.onewire.* homeassistant.components.onkyo.* homeassistant.components.open_meteo.* diff --git a/CODEOWNERS b/CODEOWNERS index faded2af138..68a33f34f9a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1071,6 +1071,8 @@ build.json @home-assistant/supervisor /tests/components/oncue/ @bdraco @peterager /homeassistant/components/ondilo_ico/ @JeromeHXP /tests/components/ondilo_ico/ @JeromeHXP +/homeassistant/components/onedrive/ @zweckj +/tests/components/onedrive/ @zweckj /homeassistant/components/onewire/ @garbled1 @epenet /tests/components/onewire/ @garbled1 @epenet /homeassistant/components/onkyo/ @arturpragacz @eclair4151 diff --git a/homeassistant/brands/microsoft.json b/homeassistant/brands/microsoft.json index 4d9eb5f95f3..0e00c4a7bc3 100644 --- a/homeassistant/brands/microsoft.json +++ b/homeassistant/brands/microsoft.json @@ -11,6 +11,7 @@ "microsoft_face", "microsoft", "msteams", + "onedrive", "xbox" ] } diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py new file mode 100644 index 00000000000..7419ca6e20c --- /dev/null +++ b/homeassistant/components/onedrive/__init__.py @@ -0,0 +1,167 @@ +"""The OneDrive integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from kiota_abstractions.api_error import APIError +from kiota_abstractions.authentication import BaseBearerTokenAuthenticationProvider +from msgraph import GraphRequestAdapter, GraphServiceClient +from msgraph.generated.drives.item.items.items_request_builder import ( + ItemsRequestBuilder, +) +from msgraph.generated.models.drive_item import DriveItem +from msgraph.generated.models.folder import Folder + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) +from homeassistant.helpers.httpx_client import create_async_httpx_client +from homeassistant.helpers.instance_id import async_get as async_get_instance_id + +from .api import OneDriveConfigEntryAccessTokenProvider +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN, OAUTH_SCOPES + + +@dataclass +class OneDriveRuntimeData: + """Runtime data for the OneDrive integration.""" + + items: ItemsRequestBuilder + backup_folder_id: str + + +type OneDriveConfigEntry = ConfigEntry[OneDriveRuntimeData] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: + """Set up OneDrive from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + + session = OAuth2Session(hass, entry, implementation) + + auth_provider = BaseBearerTokenAuthenticationProvider( + access_token_provider=OneDriveConfigEntryAccessTokenProvider(session) + ) + adapter = GraphRequestAdapter( + auth_provider=auth_provider, + client=create_async_httpx_client(hass, follow_redirects=True), + ) + + graph_client = GraphServiceClient( + request_adapter=adapter, + scopes=OAUTH_SCOPES, + ) + assert entry.unique_id + drive_item = graph_client.drives.by_drive_id(entry.unique_id) + + # get approot, will be created automatically if it does not exist + try: + approot = await drive_item.special.by_drive_item_id("approot").get() + except APIError as err: + if err.response_status_code == 403: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + _LOGGER.debug("Failed to get approot", exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": "approot"}, + ) from err + + if approot is None or not approot.id: + _LOGGER.debug("Failed to get approot, was None") + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": "approot"}, + ) + + instance_id = await async_get_instance_id(hass) + backup_folder_id = await _async_create_folder_if_not_exists( + items=drive_item.items, + base_folder_id=approot.id, + folder=f"backups_{instance_id[:8]}", + ) + + entry.runtime_data = OneDriveRuntimeData( + items=drive_item.items, + backup_folder_id=backup_folder_id, + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: + """Unload a OneDrive config entry.""" + _async_notify_backup_listeners_soon(hass) + return True + + +def _async_notify_backup_listeners(hass: HomeAssistant) -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + +@callback +def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: + hass.loop.call_soon(_async_notify_backup_listeners, hass) + + +async def _async_create_folder_if_not_exists( + items: ItemsRequestBuilder, + base_folder_id: str, + folder: str, +) -> str: + """Check if a folder exists and create it if it does not exist.""" + folder_item: DriveItem | None = None + + try: + folder_item = await items.by_drive_item_id(f"{base_folder_id}:/{folder}:").get() + except APIError as err: + if err.response_status_code != 404: + _LOGGER.debug("Failed to get folder %s", folder, exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": folder}, + ) from err + # is 404 not found, create folder + _LOGGER.debug("Creating folder %s", folder) + request_body = DriveItem( + name=folder, + folder=Folder(), + additional_data={ + "@microsoft_graph_conflict_behavior": "fail", + }, + ) + try: + folder_item = await items.by_drive_item_id(base_folder_id).children.post( + request_body + ) + except APIError as create_err: + _LOGGER.debug("Failed to create folder %s", folder, exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_create_folder", + translation_placeholders={"folder": folder}, + ) from create_err + _LOGGER.debug("Created folder %s", folder) + else: + _LOGGER.debug("Found folder %s", folder) + if folder_item is None or not folder_item.id: + _LOGGER.debug("Failed to get folder %s, was None", folder) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": folder}, + ) + return folder_item.id diff --git a/homeassistant/components/onedrive/api.py b/homeassistant/components/onedrive/api.py new file mode 100644 index 00000000000..934a4f74ec9 --- /dev/null +++ b/homeassistant/components/onedrive/api.py @@ -0,0 +1,53 @@ +"""API for OneDrive bound to Home Assistant OAuth.""" + +from typing import Any, cast + +from kiota_abstractions.authentication import AccessTokenProvider, AllowedHostsValidator + +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.helpers import config_entry_oauth2_flow + + +class OneDriveAccessTokenProvider(AccessTokenProvider): + """Provide OneDrive authentication tied to an OAuth2 based config entry.""" + + def __init__(self) -> None: + """Initialize OneDrive auth.""" + super().__init__() + # currently allowing all hosts + self._allowed_hosts_validator = AllowedHostsValidator(allowed_hosts=[]) + + def get_allowed_hosts_validator(self) -> AllowedHostsValidator: + """Retrieve the allowed hosts validator.""" + return self._allowed_hosts_validator + + +class OneDriveConfigFlowAccessTokenProvider(OneDriveAccessTokenProvider): + """Provide OneDrive authentication tied to an OAuth2 based config entry.""" + + def __init__(self, token: str) -> None: + """Initialize OneDrive auth.""" + super().__init__() + self._token = token + + async def get_authorization_token( # pylint: disable=dangerous-default-value + self, uri: str, additional_authentication_context: dict[str, Any] = {} + ) -> str: + """Return a valid authorization token.""" + return self._token + + +class OneDriveConfigEntryAccessTokenProvider(OneDriveAccessTokenProvider): + """Provide OneDrive authentication tied to an OAuth2 based config entry.""" + + def __init__(self, oauth_session: config_entry_oauth2_flow.OAuth2Session) -> None: + """Initialize OneDrive auth.""" + super().__init__() + self._oauth_session = oauth_session + + async def get_authorization_token( # pylint: disable=dangerous-default-value + self, uri: str, additional_authentication_context: dict[str, Any] = {} + ) -> str: + """Return a valid authorization token.""" + await self._oauth_session.async_ensure_token_valid() + return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/onedrive/application_credentials.py b/homeassistant/components/onedrive/application_credentials.py new file mode 100644 index 00000000000..b38aa9313d0 --- /dev/null +++ b/homeassistant/components/onedrive/application_credentials.py @@ -0,0 +1,14 @@ +"""Application credentials platform for the OneDrive integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py new file mode 100644 index 00000000000..a5a5c019797 --- /dev/null +++ b/homeassistant/components/onedrive/backup.py @@ -0,0 +1,290 @@ +"""Support for OneDrive backup.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +from functools import wraps +import html +import json +import logging +from typing import Any, Concatenate, cast + +from httpx import Response +from kiota_abstractions.api_error import APIError +from kiota_abstractions.authentication import AnonymousAuthenticationProvider +from kiota_abstractions.headers_collection import HeadersCollection +from kiota_abstractions.method import Method +from kiota_abstractions.native_response_handler import NativeResponseHandler +from kiota_abstractions.request_information import RequestInformation +from kiota_http.middleware.options import ResponseHandlerOption +from msgraph import GraphRequestAdapter +from msgraph.generated.drives.item.items.item.content.content_request_builder import ( + ContentRequestBuilder, +) +from msgraph.generated.drives.item.items.item.create_upload_session.create_upload_session_post_request_body import ( + CreateUploadSessionPostRequestBody, +) +from msgraph.generated.drives.item.items.item.drive_item_item_request_builder import ( + DriveItemItemRequestBuilder, +) +from msgraph.generated.models.drive_item import DriveItem +from msgraph.generated.models.drive_item_uploadable_properties import ( + DriveItemUploadableProperties, +) +from msgraph_core.models import LargeFileUploadSession + +from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.httpx_client import get_async_client + +from . import OneDriveConfigEntry +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +_LOGGER = logging.getLogger(__name__) +UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries: list[OneDriveConfigEntry] = hass.config_entries.async_loaded_entries( + DOMAIN + ) + return [OneDriveBackupAgent(hass, entry) for entry in entries] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed.""" + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +def handle_backup_errors[_R, **P]( + func: Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]]: + """Handle backup errors with a specific translation key.""" + + @wraps(func) + async def wrapper( + self: OneDriveBackupAgent, *args: P.args, **kwargs: P.kwargs + ) -> _R: + try: + return await func(self, *args, **kwargs) + except APIError as err: + if err.response_status_code == 403: + self._entry.async_start_reauth(self._hass) + _LOGGER.error( + "Error during backup in %s: Status %s, message %s", + func.__name__, + err.response_status_code, + err.message, + ) + _LOGGER.debug("Full error: %s", err, exc_info=True) + raise BackupAgentError("Backup operation failed") from err + except TimeoutError as err: + _LOGGER.error( + "Error during backup in %s: Timeout", + func.__name__, + ) + raise BackupAgentError("Backup operation timed out") from err + + return wrapper + + +class OneDriveBackupAgent(BackupAgent): + """OneDrive backup agent.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: OneDriveConfigEntry) -> None: + """Initialize the OneDrive backup agent.""" + super().__init__() + self._hass = hass + self._entry = entry + self._items = entry.runtime_data.items + self._folder_id = entry.runtime_data.backup_folder_id + self.name = entry.title + assert entry.unique_id + self.unique_id = entry.unique_id + + @handle_backup_errors + async def async_download_backup( + self, backup_id: str, **kwargs: Any + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + # this forces the query to return a raw httpx response, but breaks typing + request_config = ( + ContentRequestBuilder.ContentRequestBuilderGetRequestConfiguration( + options=[ResponseHandlerOption(NativeResponseHandler())], + ) + ) + response = cast( + Response, + await self._get_backup_file_item(backup_id).content.get( + request_configuration=request_config + ), + ) + + return response.aiter_bytes(chunk_size=1024) + + @handle_backup_errors + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + + # upload file in chunks to support large files + upload_session_request_body = CreateUploadSessionPostRequestBody( + item=DriveItemUploadableProperties( + additional_data={ + "@microsoft.graph.conflictBehavior": "fail", + }, + ) + ) + upload_session = await self._get_backup_file_item( + backup.backup_id + ).create_upload_session.post(upload_session_request_body) + + if upload_session is None or upload_session.upload_url is None: + raise BackupAgentError( + translation_domain=DOMAIN, translation_key="backup_no_upload_session" + ) + + await self._upload_file( + upload_session.upload_url, await open_stream(), backup.size + ) + + # store metadata in description + backup_dict = backup.as_dict() + backup_dict["metadata_version"] = 1 # version of the backup metadata + description = json.dumps(backup_dict) + _LOGGER.debug("Creating metadata: %s", description) + + await self._get_backup_file_item(backup.backup_id).patch( + DriveItem(description=description) + ) + + @handle_backup_errors + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file.""" + await self._get_backup_file_item(backup_id).delete() + + @handle_backup_errors + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + backups: list[AgentBackup] = [] + items = await self._items.by_drive_item_id(f"{self._folder_id}").children.get() + if items and (values := items.value): + for item in values: + if (description := item.description) is None: + continue + if "homeassistant_version" in description: + backups.append(self._backup_from_description(description)) + return backups + + @handle_backup_errors + async def async_get_backup( + self, backup_id: str, **kwargs: Any + ) -> AgentBackup | None: + """Return a backup.""" + try: + drive_item = await self._get_backup_file_item(backup_id).get() + except APIError as err: + if err.response_status_code == 404: + return None + raise + if ( + drive_item is not None + and (description := drive_item.description) is not None + ): + return self._backup_from_description(description) + return None + + def _backup_from_description(self, description: str) -> AgentBackup: + """Create a backup object from a description.""" + description = html.unescape( + description + ) # OneDrive encodes the description on save automatically + return AgentBackup.from_dict(json.loads(description)) + + def _get_backup_file_item(self, backup_id: str) -> DriveItemItemRequestBuilder: + return self._items.by_drive_item_id(f"{self._folder_id}:/{backup_id}.tar:") + + async def _upload_file( + self, upload_url: str, stream: AsyncIterator[bytes], total_size: int + ) -> None: + """Use custom large file upload; SDK does not support stream.""" + + adapter = GraphRequestAdapter( + auth_provider=AnonymousAuthenticationProvider(), + client=get_async_client(self._hass), + ) + + async def async_upload( + start: int, end: int, chunk_data: bytes + ) -> LargeFileUploadSession: + info = RequestInformation() + info.url = upload_url + info.http_method = Method.PUT + info.headers = HeadersCollection() + info.headers.try_add("Content-Range", f"bytes {start}-{end}/{total_size}") + info.headers.try_add("Content-Length", str(len(chunk_data))) + info.headers.try_add("Content-Type", "application/octet-stream") + _LOGGER.debug(info.headers.get_all()) + info.set_stream_content(chunk_data) + result = await adapter.send_async(info, LargeFileUploadSession, {}) + _LOGGER.debug("Next expected range: %s", result.next_expected_ranges) + return result + + start = 0 + buffer: list[bytes] = [] + buffer_size = 0 + + async for chunk in stream: + buffer.append(chunk) + buffer_size += len(chunk) + if buffer_size >= UPLOAD_CHUNK_SIZE: + chunk_data = b"".join(buffer) + uploaded_chunks = 0 + while ( + buffer_size > UPLOAD_CHUNK_SIZE + ): # Loop in case the buffer is >= UPLOAD_CHUNK_SIZE * 2 + slice_start = uploaded_chunks * UPLOAD_CHUNK_SIZE + await async_upload( + start, + start + UPLOAD_CHUNK_SIZE - 1, + chunk_data[slice_start : slice_start + UPLOAD_CHUNK_SIZE], + ) + start += UPLOAD_CHUNK_SIZE + uploaded_chunks += 1 + buffer_size -= UPLOAD_CHUNK_SIZE + buffer = [chunk_data[UPLOAD_CHUNK_SIZE * uploaded_chunks :]] + + # upload the remaining bytes + if buffer: + _LOGGER.debug("Last chunk") + chunk_data = b"".join(buffer) + await async_upload(start, start + len(chunk_data) - 1, chunk_data) diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py new file mode 100644 index 00000000000..83f6dd6e2ee --- /dev/null +++ b/homeassistant/components/onedrive/config_flow.py @@ -0,0 +1,112 @@ +"""Config flow for OneDrive.""" + +from collections.abc import Mapping +import logging +from typing import Any, cast + +from kiota_abstractions.api_error import APIError +from kiota_abstractions.authentication import BaseBearerTokenAuthenticationProvider +from kiota_abstractions.method import Method +from kiota_abstractions.request_information import RequestInformation +from msgraph import GraphRequestAdapter, GraphServiceClient + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler +from homeassistant.helpers.httpx_client import get_async_client + +from .api import OneDriveConfigFlowAccessTokenProvider +from .const import DOMAIN, OAUTH_SCOPES + + +class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): + """Config flow to handle OneDrive OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": " ".join(OAUTH_SCOPES)} + + async def async_oauth_create_entry( + self, + data: dict[str, Any], + ) -> ConfigFlowResult: + """Handle the initial step.""" + auth_provider = BaseBearerTokenAuthenticationProvider( + access_token_provider=OneDriveConfigFlowAccessTokenProvider( + cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + ) + ) + adapter = GraphRequestAdapter( + auth_provider=auth_provider, + client=get_async_client(self.hass), + ) + + graph_client = GraphServiceClient( + request_adapter=adapter, + scopes=OAUTH_SCOPES, + ) + + # need to get adapter from client, as client changes it + request_adapter = cast(GraphRequestAdapter, graph_client.request_adapter) + + request_info = RequestInformation( + method=Method.GET, + url_template="{+baseurl}/me/drive/special/approot", + path_parameters={}, + ) + parent_span = request_adapter.start_tracing_span(request_info, "get_approot") + + # get the OneDrive id + # use low level methods, to avoid files.read permissions + # which would be required by drives.me.get() + try: + response = await request_adapter.get_http_response_message( + request_info=request_info, parent_span=parent_span + ) + except APIError: + self.logger.exception("Failed to connect to OneDrive") + return self.async_abort(reason="connection_error") + except Exception: + self.logger.exception("Unknown error") + return self.async_abort(reason="unknown") + + drive = response.json() + + await self.async_set_unique_id(drive["parentReference"]["driveId"]) + + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + self._abort_if_unique_id_mismatch( + reason="wrong_drive", + ) + return self.async_update_reload_and_abort( + entry=reauth_entry, + data=data, + ) + + self._abort_if_unique_id_configured() + + title = f"{drive['shared']['owner']['user']['displayName']}'s OneDrive" + return self.async_create_entry(title=title, data=data) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() diff --git a/homeassistant/components/onedrive/const.py b/homeassistant/components/onedrive/const.py new file mode 100644 index 00000000000..f9d49b141e5 --- /dev/null +++ b/homeassistant/components/onedrive/const.py @@ -0,0 +1,24 @@ +"""Constants for the OneDrive integration.""" + +from collections.abc import Callable +from typing import Final + +from homeassistant.util.hass_dict import HassKey + +DOMAIN: Final = "onedrive" + +# replace "consumers" with "common", when adding SharePoint or OneDrive for Business support +OAUTH2_AUTHORIZE: Final = ( + "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize" +) +OAUTH2_TOKEN: Final = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" + +OAUTH_SCOPES: Final = [ + "Files.ReadWrite.AppFolder", + "offline_access", + "openid", +] + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json new file mode 100644 index 00000000000..056e31864a4 --- /dev/null +++ b/homeassistant/components/onedrive/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "onedrive", + "name": "OneDrive", + "codeowners": ["@zweckj"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/onedrive", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["msgraph", "msgraph-core", "kiota"], + "quality_scale": "bronze", + "requirements": ["msgraph-sdk==1.16.0"] +} diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml new file mode 100644 index 00000000000..f0d58d89c9a --- /dev/null +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -0,0 +1,139 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + 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 have any custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + 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. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + No Options flow. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: | + This integration does not have entities. + 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. + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: + status: exempt + comment: | + This integration connects to a single service. + diagnostics: + status: exempt + comment: | + There is no data to diagnose. + discovery-update-info: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + discovery: + 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-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-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. + exception-translations: done + icon-translations: + status: exempt + comment: | + This integration does not have entities. + reconfiguration-flow: + status: exempt + comment: | + Nothing to reconfigure. + repair-issues: done + stale-devices: + status: exempt + comment: | + This integration connects to a single service. + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json new file mode 100644 index 00000000000..9cbdb2bdeae --- /dev/null +++ b/homeassistant/components/onedrive/strings.json @@ -0,0 +1,53 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The OneDrive integration needs to re-authenticate your account" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "connection_error": "Failed to connect to OneDrive.", + "wrong_drive": "New account does not contain previously configured OneDrive.", + "unknown": "[%key:common::config_flow::error::unknown%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "failed_to_create_folder": "Failed to create backup folder" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "exceptions": { + "backup_not_found": { + "message": "Backup not found" + }, + "backup_no_content": { + "message": "Backup has no content" + }, + "backup_no_upload_session": { + "message": "Failed to start backup upload" + }, + "authentication_failed": { + "message": "Authentication failed" + }, + "failed_to_get_folder": { + "message": "Failed to get {folder} folder" + }, + "failed_to_create_folder": { + "message": "Failed to create {folder} folder" + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 6b3028826dc..ef55798b3a0 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -24,6 +24,7 @@ APPLICATION_CREDENTIALS = [ "neato", "nest", "netatmo", + "onedrive", "point", "senz", "spotify", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7dea4598790..12dda0f56be 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -434,6 +434,7 @@ FLOWS = { "omnilogic", "oncue", "ondilo_ico", + "onedrive", "onewire", "onkyo", "onvif", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6d2e784c583..53a485a1340 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3802,6 +3802,12 @@ "iot_class": "cloud_push", "name": "Microsoft Teams" }, + "onedrive": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "OneDrive" + }, "xbox": { "integration_type": "hub", "config_flow": true, diff --git a/mypy.ini b/mypy.ini index 188f1f7bbd7..db1ec0a04e4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3346,6 +3346,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.onedrive.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.onewire.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index e9436475775..128586eb01e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1434,6 +1434,9 @@ motioneye-client==0.3.14 # homeassistant.components.bang_olufsen mozart-api==4.1.1.116.4 +# homeassistant.components.onedrive +msgraph-sdk==1.16.0 + # homeassistant.components.mullvad mullvad-api==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c1752dc7e45..117886a0bc8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1206,6 +1206,9 @@ motioneye-client==0.3.14 # homeassistant.components.bang_olufsen mozart-api==4.1.1.116.4 +# homeassistant.components.onedrive +msgraph-sdk==1.16.0 + # homeassistant.components.mullvad mullvad-api==1.0.0 diff --git a/tests/components/onedrive/__init__.py b/tests/components/onedrive/__init__.py new file mode 100644 index 00000000000..0bafe37775b --- /dev/null +++ b/tests/components/onedrive/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the OneDrive integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the OneDrive integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py new file mode 100644 index 00000000000..0cca8e9df0b --- /dev/null +++ b/tests/components/onedrive/conftest.py @@ -0,0 +1,178 @@ +"""Fixtures for OneDrive tests.""" + +from collections.abc import AsyncIterator, Generator +from html import escape +from json import dumps +import time +from unittest.mock import AsyncMock, MagicMock, patch + +from httpx import Response +from msgraph.generated.models.drive_item import DriveItem +from msgraph.generated.models.drive_item_collection_response import ( + DriveItemCollectionResponse, +) +from msgraph.generated.models.upload_session import UploadSession +from msgraph_core.models import LargeFileUploadSession +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.onedrive.const import DOMAIN, OAUTH_SCOPES +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import BACKUP_METADATA, CLIENT_ID, CLIENT_SECRET + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture to set the scopes present in the OAuth token.""" + return OAUTH_SCOPES + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture +def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + }, + unique_id="mock_drive_id", + ) + + +@pytest.fixture +def mock_adapter() -> Generator[MagicMock]: + """Return a mocked GraphAdapter.""" + with ( + patch( + "homeassistant.components.onedrive.config_flow.GraphRequestAdapter", + autospec=True, + ) as mock_adapter, + patch( + "homeassistant.components.onedrive.backup.GraphRequestAdapter", + new=mock_adapter, + ), + ): + adapter = mock_adapter.return_value + adapter.get_http_response_message.return_value = Response( + status_code=200, + json={ + "parentReference": {"driveId": "mock_drive_id"}, + "shared": {"owner": {"user": {"displayName": "John Doe"}}}, + }, + ) + yield adapter + adapter.send_async.return_value = LargeFileUploadSession( + next_expected_ranges=["2-"] + ) + + +@pytest.fixture(autouse=True) +def mock_graph_client(mock_adapter: MagicMock) -> Generator[MagicMock]: + """Return a mocked GraphServiceClient.""" + with ( + patch( + "homeassistant.components.onedrive.config_flow.GraphServiceClient", + autospec=True, + ) as graph_client, + patch( + "homeassistant.components.onedrive.GraphServiceClient", + new=graph_client, + ), + ): + client = graph_client.return_value + + client.request_adapter = mock_adapter + + drives = client.drives.by_drive_id.return_value + drives.special.by_drive_item_id.return_value.get = AsyncMock( + return_value=DriveItem(id="approot") + ) + + drive_items = drives.items.by_drive_item_id.return_value + drive_items.get = AsyncMock(return_value=DriveItem(id="folder_id")) + drive_items.children.post = AsyncMock(return_value=DriveItem(id="folder_id")) + drive_items.children.get = AsyncMock( + return_value=DriveItemCollectionResponse( + value=[ + DriveItem(description=escape(dumps(BACKUP_METADATA))), + DriveItem(), + ] + ) + ) + drive_items.delete = AsyncMock(return_value=None) + drive_items.create_upload_session.post = AsyncMock( + return_value=UploadSession(upload_url="https://test.tld") + ) + drive_items.patch = AsyncMock(return_value=None) + + async def generate_bytes() -> AsyncIterator[bytes]: + """Asynchronous generator that yields bytes.""" + yield b"backup data" + + drive_items.content.get = AsyncMock( + return_value=Response(status_code=200, content=generate_bytes()) + ) + + yield client + + +@pytest.fixture +def mock_drive_items(mock_graph_client: MagicMock) -> MagicMock: + """Return a mocked DriveItems.""" + return mock_graph_client.drives.by_drive_id.return_value.items.by_drive_item_id.return_value + + +@pytest.fixture +def mock_get_special_folder(mock_graph_client: MagicMock) -> MagicMock: + """Mock the get special folder method.""" + return mock_graph_client.drives.by_drive_id.return_value.special.by_drive_item_id.return_value.get + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.onedrive.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(autouse=True) +def mock_instance_id() -> Generator[AsyncMock]: + """Mock the instance ID.""" + with patch( + "homeassistant.components.onedrive.async_get_instance_id", + return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", + ): + yield diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py new file mode 100644 index 00000000000..c187feef30a --- /dev/null +++ b/tests/components/onedrive/const.py @@ -0,0 +1,19 @@ +"""Consts for OneDrive tests.""" + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +BACKUP_METADATA = { + "addons": [], + "backup_id": "23e64aec", + "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, +} diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py new file mode 100644 index 00000000000..a3cfbe95a46 --- /dev/null +++ b/tests/components/onedrive/test_backup.py @@ -0,0 +1,363 @@ +"""Test the backups for OneDrive.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from html import escape +from io import StringIO +from json import dumps +from unittest.mock import Mock, patch + +from kiota_abstractions.api_error import APIError +from msgraph.generated.models.drive_item import DriveItem +import pytest + +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup +from homeassistant.components.onedrive.const import 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 tests.common import AsyncMock, MockConfigEntry +from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_backup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> AsyncGenerator[None]: + """Set up onedrive integration.""" + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + await setup_integration(hass, mock_config_entry) + + await hass.async_block_till_done() + yield + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + { + "agent_id": f"{DOMAIN}.{mock_config_entry.unique_id}", + "name": mock_config_entry.title, + }, + ], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent list backups.""" + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "agent_ids": [f"{DOMAIN}.{mock_config_entry.unique_id}"], + "failed_agent_ids": [], + "with_automatic_settings": None, + } + ] + + +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent get backup.""" + + mock_drive_items.get = AsyncMock( + return_value=DriveItem(description=escape(dumps(BACKUP_METADATA))) + ) + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "agent_ids": [f"{DOMAIN}.{mock_config_entry.unique_id}"], + "failed_agent_ids": [], + "with_automatic_settings": None, + } + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock_drive_items.delete.assert_called_once() + + +async def test_agents_upload( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + 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, + patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + mock_drive_items.create_upload_session.post.assert_called_once() + mock_drive_items.patch.assert_called_once() + assert mock_adapter.send_async.call_count == 2 + assert mock_adapter.method_calls[0].args[0].content == b"tes" + assert mock_adapter.method_calls[0].args[0].headers.get("Content-Range") == { + "bytes 0-2/34519040" + } + assert mock_adapter.method_calls[1].args[0].content == b"t" + assert mock_adapter.method_calls[1].args[0].headers.get("Content-Range") == { + "bytes 3-3/34519040" + } + + +async def test_broken_upload_session( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test broken upload session.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_drive_items.create_upload_session.post = AsyncMock(return_value=None) + + 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=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert "Failed to start backup upload" in caplog.text + + +async def test_agents_download( + hass_client: ClientSessionGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + mock_drive_items.get = AsyncMock( + return_value=DriveItem(description=escape(dumps(BACKUP_METADATA))) + ) + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.unique_id}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + mock_drive_items.content.get.assert_called_once() + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + ( + APIError(response_status_code=404, message="File not found."), + "Backup operation failed", + ), + (TimeoutError(), "Backup operation timed out"), + ], +) +async def test_delete_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test error during delete.""" + mock_drive_items.delete = AsyncMock(side_effect=side_effect) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {f"{DOMAIN}.{mock_config_entry.unique_id}": error} + } + + +@pytest.mark.parametrize( + "problem", + [ + AsyncMock(return_value=None), + AsyncMock(side_effect=APIError(response_status_code=404)), + ], +) +async def test_agents_backup_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + problem: AsyncMock, +) -> None: + """Test backup not found.""" + + mock_drive_items.get = problem + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backup"] is None + + +async def test_agents_backup_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup not found.""" + + mock_drive_items.get = AsyncMock(side_effect=APIError(response_status_code=500)) + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == { + f"{DOMAIN}.{mock_config_entry.unique_id}": "Backup operation failed" + } + + +async def test_reauth_on_403( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we re-authenticate on 403.""" + + mock_drive_items.get = AsyncMock(side_effect=APIError(response_status_code=403)) + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == { + f"{DOMAIN}.{mock_config_entry.unique_id}": "Backup operation failed" + } + + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert "context" in flow + assert flow["context"]["source"] == SOURCE_REAUTH + assert flow["context"]["entry_id"] == mock_config_entry.entry_id diff --git a/tests/components/onedrive/test_config_flow.py b/tests/components/onedrive/test_config_flow.py new file mode 100644 index 00000000000..8be6aadfd0f --- /dev/null +++ b/tests/components/onedrive/test_config_flow.py @@ -0,0 +1,197 @@ +"""Test the OneDrive config flow.""" + +from http import HTTPStatus +from unittest.mock import AsyncMock, MagicMock + +from httpx import Response +from kiota_abstractions.api_error import APIError +import pytest + +from homeassistant import config_entries +from homeassistant.components.onedrive.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from . import setup_integration +from .const import CLIENT_ID + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +async def _do_get_token( + hass: HomeAssistant, + result: ConfigFlowResult, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + scope = "Files.ReadWrite.AppFolder+offline_access+openid" + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={scope}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, +) -> None: + """Check full flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["title"] == "John Doe's OneDrive" + assert result["result"].unique_id == "mock_drive_id" + assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ("exception", "error"), + [ + (Exception, "unknown"), + (APIError, "connection_error"), + ], +) +async def test_flow_errors( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_adapter: MagicMock, + exception: Exception, + error: str, +) -> None: + """Test errors during flow.""" + + mock_adapter.get_http_response_message.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == error + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_already_configured( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test already configured account.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the reauth flow works.""" + + await setup_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow_id_changed( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, +) -> None: + """Test that the reauth flow fails on a different drive id.""" + mock_adapter.get_http_response_message.return_value = Response( + status_code=200, + json={ + "parentReference": {"driveId": "other_drive_id"}, + }, + ) + + await setup_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_drive" diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py new file mode 100644 index 00000000000..bc5c22c3ce6 --- /dev/null +++ b/tests/components/onedrive/test_init.py @@ -0,0 +1,112 @@ +"""Test the OneDrive setup.""" + +from unittest.mock import MagicMock + +from kiota_abstractions.api_error import APIError +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("side_effect", "state"), + [ + (APIError(response_status_code=403), ConfigEntryState.SETUP_ERROR), + (APIError(response_status_code=500), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_approot_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_special_folder: MagicMock, + side_effect: Exception, + state: ConfigEntryState, +) -> None: + """Test errors during approot retrieval.""" + mock_get_special_folder.side_effect = side_effect + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is state + + +async def test_faulty_approot( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_special_folder: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test faulty approot retrieval.""" + mock_get_special_folder.return_value = None + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to get approot folder" in caplog.text + + +async def test_faulty_integration_folder( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_drive_items: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test faulty approot retrieval.""" + mock_drive_items.get.return_value = None + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to get backups_9f86d081 folder" in caplog.text + + +async def test_500_error_during_backup_folder_get( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_drive_items: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error during backup folder creation.""" + mock_drive_items.get.side_effect = APIError(response_status_code=500) + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to get backups_9f86d081 folder" in caplog.text + + +async def test_error_during_backup_folder_creation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_drive_items: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error during backup folder creation.""" + mock_drive_items.get.side_effect = APIError(response_status_code=404) + mock_drive_items.children.post.side_effect = APIError() + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to create backups_9f86d081 folder" in caplog.text + + +async def test_successful_backup_folder_creation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_drive_items: MagicMock, +) -> None: + """Test successful backup folder creation.""" + mock_drive_items.get.side_effect = APIError(response_status_code=404) + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED From 01f63cfefd9dd882e8a4754f758767bfc7b1a42c Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:34:08 +0100 Subject: [PATCH 1093/2987] Add SPF sensor for heat pumps in ViCare integration (#136233) Co-authored-by: Dave T <17680170+davet2001@users.noreply.github.com> --- homeassistant/components/vicare/sensor.py | 21 +++ homeassistant/components/vicare/strings.json | 9 ++ .../vicare/snapshots/test_sensor.ambr | 147 ++++++++++++++++++ 3 files changed, 177 insertions(+) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 14624be2b6d..091deeba2a9 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -862,6 +862,27 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, ), + ViCareSensorEntityDescription( + key="spf_total", + translation_key="spf_total", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getSeasonalPerformanceFactorTotal(), + ), + ViCareSensorEntityDescription( + key="spf_dhw", + translation_key="spf_dhw", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getSeasonalPerformanceFactorDHW(), + ), + ViCareSensorEntityDescription( + key="spf_heating", + translation_key="spf_heating", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getSeasonalPerformanceFactorHeating(), + ), ) CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 4eee81f3d05..a8636f651f3 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -466,6 +466,15 @@ }, "heating_rod_hours": { "name": "Heating rod hours" + }, + "spf_total": { + "name": "Seasonal performance factor" + }, + "spf_dhw": { + "name": "Seasonal performance factor - domestic hot water" + }, + "spf_heating": { + "name": "Seasonal performance factor - heating" } }, "water_heater": { diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index 17c9ee99320..ace22391797 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -2110,6 +2110,153 @@ 'state': '35.3', }) # --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_seasonal_performance_factor', + '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': 'Seasonal performance factor', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'spf_total', + 'unique_id': 'gateway0_deviceSerialVitocal250A-spf_total', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Seasonal performance factor', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_seasonal_performance_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.9', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor_domestic_hot_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_seasonal_performance_factor_domestic_hot_water', + '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': 'Seasonal performance factor - domestic hot water', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'spf_dhw', + 'unique_id': 'gateway0_deviceSerialVitocal250A-spf_dhw', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor_domestic_hot_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Seasonal performance factor - domestic hot water', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_seasonal_performance_factor_domestic_hot_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.1', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor_heating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_seasonal_performance_factor_heating', + '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': 'Seasonal performance factor - heating', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'spf_heating', + 'unique_id': 'gateway0_deviceSerialVitocal250A-spf_heating', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Seasonal performance factor - heating', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_seasonal_performance_factor_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.2', + }) +# --- # name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_secondary_circuit_supply_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 3f013ab620440d99e7d689c731810b85abe08676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 28 Jan 2025 16:39:41 +0100 Subject: [PATCH 1094/2987] Add sensor for Matter OperationalState cluster / CurrentPhase attribute (#129757) --- homeassistant/components/matter/icons.json | 3 + homeassistant/components/matter/sensor.py | 44 ++++++++++++++ homeassistant/components/matter/strings.json | 3 + .../fixtures/nodes/silabs_laundrywasher.json | 4 +- .../matter/snapshots/test_sensor.ambr | 58 +++++++++++++++++++ tests/components/matter/test_sensor.py | 22 ++++++- 6 files changed, 131 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index bd8665eb18b..4f3e532d877 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -45,6 +45,9 @@ "contamination_state": { "default": "mdi:air-filter" }, + "current_phase": { + "default": "mdi:state-machine" + }, "air_quality": { "default": "mdi:air-filter" }, diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 39e11a683f5..40b25d14c46 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -7,6 +7,7 @@ from datetime import datetime from typing import TYPE_CHECKING, cast from chip.clusters import Objects as clusters +from chip.clusters.ClusterObjects import ClusterAttributeDescriptor from chip.clusters.Types import Nullable, NullValue from matter_server.client.models import device_types from matter_server.common.custom_clusters import ( @@ -89,6 +90,14 @@ class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescrip """Describe Matter sensor entities.""" +@dataclass(frozen=True, kw_only=True) +class MatterListSensorEntityDescription(MatterSensorEntityDescription): + """Describe Matter sensor entities from MatterListSensor.""" + + # list attribute: the attribute descriptor to get the list of values (= list of strings) + list_attribute: type[ClusterAttributeDescriptor] + + class MatterSensor(MatterEntity, SensorEntity): """Representation of a Matter sensor.""" @@ -171,6 +180,28 @@ class MatterOperationalStateSensor(MatterSensor): ) +class MatterListSensor(MatterSensor): + """Representation of a sensor entity from Matter list from Cluster attribute(s).""" + + entity_description: MatterListSensorEntityDescription + _attr_device_class = SensorDeviceClass.ENUM + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + self._attr_options = list_values = cast( + list[str], + self.get_matter_attribute_value(self.entity_description.list_attribute), + ) + current_value: int = self.get_matter_attribute_value( + self._entity_info.primary_attribute + ) + try: + self._attr_native_value = list_values[current_value] + except IndexError: + self._attr_native_value = None + + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( @@ -762,6 +793,19 @@ DISCOVERY_SCHEMAS = [ clusters.OperationalState.Attributes.OperationalStateList, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterListSensorEntityDescription( + key="OperationalStateCurrentPhase", + translation_key="current_phase", + list_attribute=clusters.OperationalState.Attributes.PhaseList, + ), + entity_class=MatterListSensor, + required_attributes=( + clusters.OperationalState.Attributes.CurrentPhase, + clusters.OperationalState.Attributes.PhaseList, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 4054adba530..8bac67a4ca7 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -257,6 +257,9 @@ }, "battery_replacement_description": { "name": "Battery type" + }, + "current_phase": { + "name": "Current phase" } }, "switch": { diff --git a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json index 4d26dfb03aa..a91584d7212 100644 --- a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json +++ b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json @@ -673,8 +673,8 @@ "1/86/65528": [], "1/86/65529": [0], "1/86/65531": [4, 5, 65528, 65529, 65531, 65532, 65533], - "1/96/0": null, - "1/96/1": null, + "1/96/0": ["pre-soak", "rinse", "spin"], + "1/96/1": 0, "1/96/3": [ { "0": 0 diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 0215abf47c6..d9bc0bdf1fc 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -2870,6 +2870,64 @@ 'state': '0.0', }) # --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pre-soak', + 'rinse', + 'spin', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.laundrywasher_current_phase', + '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 phase', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStateCurrentPhase-96-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'LaundryWasher Current phase', + 'options': list([ + 'pre-soak', + 'rinse', + 'spin', + ]), + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_current_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'pre-soak', + }) +# --- # name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 8a5fbf48a49..251aab73e3b 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -332,7 +332,7 @@ async def test_operational_state_sensor( matter_client: MagicMock, matter_node: MatterNode, ) -> None: - """Test dishwasher sensor.""" + """Test Operational State sensor, using a dishwasher fixture.""" # OperationalState Cluster / OperationalState attribute (1/96/4) state = hass.states.get("sensor.dishwasher_operational_state") assert state @@ -379,3 +379,23 @@ async def test_draft_electrical_measurement_sensor( state = hass.states.get("sensor.yndx_00540_power") assert state assert state.state == "unknown" + + +@pytest.mark.parametrize("node_fixture", ["silabs_laundrywasher"]) +async def test_list_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test Matter List sensor.""" + # OperationalState Cluster / CurrentPhase attribute (1/96/1) + state = hass.states.get("sensor.laundrywasher_current_phase") + assert state + assert state.state == "pre-soak" + + set_node_attribute(matter_node, 1, 96, 1, 1) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.laundrywasher_current_phase") + assert state + assert state.state == "rinse" From b16c3a55a57e7a9da7f996a512b7417f9ceca176 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:45:19 +0100 Subject: [PATCH 1095/2987] Add authentication support to MotionMount integration (#126487) --- .../components/motionmount/__init__.py | 20 +- .../components/motionmount/config_flow.py | 154 ++++- .../components/motionmount/entity.py | 28 +- .../components/motionmount/select.py | 32 + .../components/motionmount/strings.json | 31 +- tests/components/motionmount/__init__.py | 4 +- .../motionmount/test_config_flow.py | 566 ++++++++++++++---- 7 files changed, 674 insertions(+), 161 deletions(-) diff --git a/homeassistant/components/motionmount/__init__.py b/homeassistant/components/motionmount/__init__.py index 28963d83d89..9b27ce9bc6c 100644 --- a/homeassistant/components/motionmount/__init__.py +++ b/homeassistant/components/motionmount/__init__.py @@ -7,9 +7,9 @@ import socket import motionmount from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN, EMPTY_MAC @@ -48,6 +48,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}" ) + # Check we're properly authenticated or be able to become so + if not mm.is_authenticated: + if CONF_PIN not in entry.data: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="no_pin_provided", + ) + + pin = entry.data[CONF_PIN] + await mm.authenticate(pin) + if not mm.is_authenticated: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="incorrect_pin", + ) + # Store an API object for your platforms to access hass.data.setdefault(DOMAIN, {})[entry.entry_id] = mm diff --git a/homeassistant/components/motionmount/config_flow.py b/homeassistant/components/motionmount/config_flow.py index 50a1e334f1d..283f1f01d6e 100644 --- a/homeassistant/components/motionmount/config_flow.py +++ b/homeassistant/components/motionmount/config_flow.py @@ -1,5 +1,7 @@ """Config flow for Vogel's MotionMount.""" +import asyncio +from collections.abc import Mapping import logging import socket from typing import Any @@ -9,10 +11,11 @@ import voluptuous as vol from homeassistant.config_entries import ( DEFAULT_DISCOVERY_UNIQUE_ID, + SOURCE_REAUTH, ConfigFlow, ConfigFlowResult, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_UUID +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT, CONF_UUID from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -34,7 +37,9 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Set up the instance.""" - self.discovery_info: dict[str, Any] = {} + self.connection_data: dict[str, Any] = {} + self.backoff_task: asyncio.Task | None = None + self.backoff_time: int = 0 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -43,23 +48,16 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is None: return self._show_setup_form() + self.connection_data.update(user_input) info = {} try: - info = await self._validate_input(user_input) + info = await self._validate_input_connect(self.connection_data) except (ConnectionError, socket.gaierror): return self.async_abort(reason="cannot_connect") except TimeoutError: return self.async_abort(reason="time_out") except motionmount.NotConnectedError: return self.async_abort(reason="not_connected") - except motionmount.MotionMountResponseError: - # This is most likely due to missing support for the mac address property - # Abort if the handler has config entries already - if self._async_current_entries(): - return self.async_abort(reason="already_configured") - - # Otherwise we try to continue with the generic uid - info[CONF_UUID] = DEFAULT_DISCOVERY_UNIQUE_ID # If the device mac is valid we use it, otherwise we use the default id if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC: @@ -67,17 +65,22 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): else: unique_id = DEFAULT_DISCOVERY_UNIQUE_ID - name = info.get(CONF_NAME, user_input[CONF_HOST]) + name = info.get(CONF_NAME, self.connection_data[CONF_HOST]) + self.connection_data[CONF_NAME] = name await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured( updates={ - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], + CONF_HOST: self.connection_data[CONF_HOST], + CONF_PORT: self.connection_data[CONF_PORT], } ) - return self.async_create_entry(title=name, data=user_input) + if not info[CONF_PIN]: + # We need a pin to authenticate + return await self.async_step_auth() + # No pin is needed + return self._create_or_update_entry() async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo @@ -91,7 +94,7 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): name = discovery_info.name.removesuffix(f".{zctype}") unique_id = discovery_info.properties.get("mac") - self.discovery_info.update( + self.connection_data.update( { CONF_HOST: host, CONF_PORT: port, @@ -114,16 +117,13 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): self.context.update({"title_placeholders": {"name": name}}) try: - info = await self._validate_input(self.discovery_info) + info = await self._validate_input_connect(self.connection_data) except (ConnectionError, socket.gaierror): return self.async_abort(reason="cannot_connect") except TimeoutError: return self.async_abort(reason="time_out") except motionmount.NotConnectedError: return self.async_abort(reason="not_connected") - except motionmount.MotionMountResponseError: - info = {} - # We continue as we want to be able to connect with older FW that does not support MAC address # If the device supplied as with a valid MAC we use that if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC: @@ -137,6 +137,10 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): else: await self._async_handle_discovery_without_unique_id() + if not info[CONF_PIN]: + # We need a pin to authenticate + return await self.async_step_auth() + # No pin is needed return await self.async_step_zeroconf_confirm() async def async_step_zeroconf_confirm( @@ -146,16 +150,82 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form( step_id="zeroconf_confirm", - description_placeholders={CONF_NAME: self.discovery_info[CONF_NAME]}, + description_placeholders={CONF_NAME: self.connection_data[CONF_NAME]}, errors={}, ) - return self.async_create_entry( - title=self.discovery_info[CONF_NAME], - data=self.discovery_info, + return self._create_or_update_entry() + + async def async_step_reauth( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication.""" + reauth_entry = self._get_reauth_entry() + self.connection_data.update(reauth_entry.data) + return await self.async_step_auth() + + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle authentication form.""" + errors = {} + + if user_input is not None: + self.connection_data[CONF_PIN] = user_input[CONF_PIN] + + # Validate pin code + valid_or_wait_time = await self._validate_input_pin(self.connection_data) + if valid_or_wait_time is True: + return self._create_or_update_entry() + + if type(valid_or_wait_time) is int: + self.backoff_time = valid_or_wait_time + self.backoff_task = self.hass.async_create_task( + self._backoff(valid_or_wait_time) + ) + return await self.async_step_backoff() + + errors[CONF_PIN] = CONF_PIN + + return self.async_show_form( + step_id="auth", + data_schema=vol.Schema( + { + vol.Required(CONF_PIN): vol.All(int, vol.Range(min=1, max=9999)), + } + ), + errors=errors, ) - async def _validate_input(self, data: dict) -> dict[str, Any]: + async def async_step_backoff( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle backoff progress.""" + if not self.backoff_task or self.backoff_task.done(): + self.backoff_task = None + return self.async_show_progress_done(next_step_id="auth") + + return self.async_show_progress( + step_id="backoff", + description_placeholders={ + "timeout": str(self.backoff_time), + }, + progress_action="progress_action", + progress_task=self.backoff_task, + ) + + def _create_or_update_entry(self) -> ConfigFlowResult: + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + return self.async_update_reload_and_abort( + reauth_entry, data_updates=self.connection_data + ) + return self.async_create_entry( + title=self.connection_data[CONF_NAME], + data=self.connection_data, + ) + + async def _validate_input_connect(self, data: dict) -> dict[str, Any]: """Validate the user input allows us to connect.""" mm = motionmount.MotionMount(data[CONF_HOST], data[CONF_PORT]) @@ -164,7 +234,33 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): finally: await mm.disconnect() - return {CONF_UUID: format_mac(mm.mac.hex()), CONF_NAME: mm.name} + return { + CONF_UUID: format_mac(mm.mac.hex()), + CONF_NAME: mm.name, + CONF_PIN: mm.is_authenticated, + } + + async def _validate_input_pin(self, data: dict) -> bool | int: + """Validate the user input allows us to authenticate.""" + + mm = motionmount.MotionMount(data[CONF_HOST], data[CONF_PORT]) + try: + await mm.connect() + + can_authenticate = mm.can_authenticate + if can_authenticate is True: + await mm.authenticate(data[CONF_PIN]) + else: + # The backoff is running, return the remaining time + return can_authenticate + finally: + await mm.disconnect() + + can_authenticate = mm.can_authenticate + if can_authenticate is True: + return mm.is_authenticated + + return can_authenticate def _show_setup_form( self, errors: dict[str, str] | None = None @@ -180,3 +276,9 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): ), errors=errors or {}, ) + + async def _backoff(self, time: int) -> None: + while time > 0: + time -= 1 + self.backoff_time = time + await asyncio.sleep(1) diff --git a/homeassistant/components/motionmount/entity.py b/homeassistant/components/motionmount/entity.py index ba81c9d10bd..57a5f638d54 100644 --- a/homeassistant/components/motionmount/entity.py +++ b/homeassistant/components/motionmount/entity.py @@ -1,13 +1,12 @@ """Support for MotionMount sensors.""" import logging -import socket from typing import TYPE_CHECKING import motionmount from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS +from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_PIN from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.entity import Entity @@ -26,6 +25,11 @@ class MotionMountEntity(Entity): def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: """Initialize general MotionMount entity.""" self.mm = mm + self.config_entry = config_entry + + # We store the pin, as we might need it during reconnect + self.pin = config_entry.data[CONF_PIN] + mac = format_mac(mm.mac.hex()) # Create a base unique id @@ -74,23 +78,3 @@ class MotionMountEntity(Entity): self.mm.remove_listener(self.async_write_ha_state) self.mm.remove_listener(self.update_name) await super().async_will_remove_from_hass() - - async def _ensure_connected(self) -> bool: - """Make sure there is a connection with the MotionMount. - - Returns false if the connection failed to be ensured. - """ - - if self.mm.is_connected: - return True - try: - await self.mm.connect() - except (ConnectionError, TimeoutError, socket.gaierror): - # We're not interested in exceptions here. In case of a failed connection - # the try/except from the caller will report it. - # The purpose of `_ensure_connected()` is only to make sure we try to - # reconnect, where failures should not be logged each time - return False - else: - _LOGGER.warning("Successfully reconnected to MotionMount") - return True diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index 9b43d901a21..23fcf576af0 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -51,6 +51,38 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): self._attr_options = options + async def _ensure_connected(self) -> bool: + """Make sure there is a connection with the MotionMount. + + Returns false if the connection failed to be ensured. + """ + if self.mm.is_connected: + return True + try: + await self.mm.connect() + except (ConnectionError, TimeoutError, socket.gaierror): + # We're not interested in exceptions here. In case of a failed connection + # the try/except from the caller will report it. + # The purpose of `_ensure_connected()` is only to make sure we try to + # reconnect, where failures should not be logged each time + return False + + # Check we're properly authenticated or be able to become so + if not self.mm.is_authenticated: + if self.pin is None: + await self.mm.disconnect() + self.config_entry.async_start_reauth(self.hass) + return False + await self.mm.authenticate(self.pin) + if not self.mm.is_authenticated: + self.pin = None + await self.mm.disconnect() + self.config_entry.async_start_reauth(self.hass) + return False + + _LOGGER.debug("Successfully reconnected to MotionMount") + return True + async def async_update(self) -> None: """Get latest state from MotionMount.""" if not await self._ensure_connected(): diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index bd28156607c..098a7a592f3 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -1,4 +1,7 @@ { + "common": { + "incorrect_pin": "Pin is not correct" + }, "config": { "flow_title": "{name}", "step": { @@ -13,15 +16,33 @@ "zeroconf_confirm": { "description": "Do you want to set up {name}?", "title": "Discovered MotionMount" + }, + "auth": { + "title": "Authenticate to your MotionMount", + "description": "Your MotionMount requires a pin to operate.", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + } + }, + "backoff": { + "title": "Authenticate to your MotionMount", + "description": "Too many incorrect pin attempts." } }, + "error": { + "pin": "[%key:component::motionmount::common::incorrect_pin%]" + }, + "progress": { + "progress_action": "Too many incorrect pin attempts. Please wait {timeout} s..." + }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "time_out": "Failed to connect due to a time out.", + "time_out": "[%key:common::config_flow::error::timeout_connect%]", "not_connected": "Failed to connect.", - "invalid_response": "Failed to connect due to an invalid response from the MotionMount." + "invalid_response": "Failed to connect due to an invalid response from the MotionMount.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { @@ -60,6 +81,12 @@ "exceptions": { "failed_communication": { "message": "Failed to communicate with MotionMount" + }, + "no_pin_provided": { + "message": "No pin provided" + }, + "incorrect_pin": { + "message": "[%key:component::motionmount::common::incorrect_pin%]" } } } diff --git a/tests/components/motionmount/__init__.py b/tests/components/motionmount/__init__.py index ed7dae26663..3b97c8aa7fe 100644 --- a/tests/components/motionmount/__init__.py +++ b/tests/components/motionmount/__init__.py @@ -2,7 +2,7 @@ from ipaddress import ip_address -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo HOST = "192.168.1.31" @@ -21,6 +21,8 @@ MOCK_USER_INPUT = { CONF_PORT: PORT, } +MOCK_PIN_INPUT = {CONF_PIN: 1234} + MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = ZeroconfServiceInfo( type=TVM_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", diff --git a/tests/components/motionmount/test_config_flow.py b/tests/components/motionmount/test_config_flow.py index 4de23de63c9..1fa2715595d 100644 --- a/tests/components/motionmount/test_config_flow.py +++ b/tests/components/motionmount/test_config_flow.py @@ -1,20 +1,23 @@ """Tests for the Vogel's MotionMount config flow.""" import dataclasses +from datetime import timedelta import socket from unittest.mock import MagicMock, PropertyMock +from freezegun.api import FrozenDateTimeFactory import motionmount import pytest from homeassistant.components.motionmount.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( HOST, + MOCK_PIN_INPUT, MOCK_USER_INPUT, MOCK_ZEROCONF_TVM_SERVICE_INFO_V1, MOCK_ZEROCONF_TVM_SERVICE_INFO_V2, @@ -24,23 +27,12 @@ from . import ( ZEROCONF_NAME, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed MAC = bytes.fromhex("c4dd57f8a55f") pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_show_user_form(hass: HomeAssistant) -> None: - """Test that the user set up form is served.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - assert result["step_id"] == "user" - assert result["type"] is FlowResultType.FORM - - async def test_user_connection_error( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -117,33 +109,6 @@ async def test_user_not_connected_error( assert result["reason"] == "not_connected" -async def test_user_response_error_single_device_old_ce_old_new_pro( - hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, -) -> None: - """Test that the flow creates an entry when there is a response error.""" - mock_motionmount_config_flow.connect.side_effect = ( - motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) - ) - - user_input = MOCK_USER_INPUT.copy() - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == HOST - - assert result["data"] - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == PORT - - assert result["result"] - - async def test_user_response_error_single_device_new_ce_old_pro( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -199,30 +164,6 @@ async def test_user_response_error_single_device_new_ce_new_pro( assert result["result"].unique_id == ZEROCONF_MAC -async def test_user_response_error_multi_device_old_ce_old_new_pro( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, -) -> None: - """Test that the flow is aborted when there are multiple devices.""" - mock_config_entry.add_to_hass(hass) - - mock_motionmount_config_flow.connect.side_effect = ( - motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) - ) - - user_input = MOCK_USER_INPUT.copy() - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - async def test_user_response_error_multi_device_new_ce_new_pro( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -246,6 +187,53 @@ async def test_user_response_error_multi_device_new_ce_new_pro( assert result["reason"] == "already_configured" +async def test_user_response_authentication_needed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + async def test_zeroconf_connection_error( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -322,48 +310,6 @@ async def test_zeroconf_not_connected_error( assert result["reason"] == "not_connected" -async def test_show_zeroconf_form_old_ce_old_pro( - hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, -) -> None: - """Test that the zeroconf confirmation form is served.""" - mock_motionmount_config_flow.connect.side_effect = ( - motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) - ) - - discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=discovery_info, - ) - - assert result["step_id"] == "zeroconf_confirm" - assert result["type"] is FlowResultType.FORM - assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} - - -async def test_show_zeroconf_form_old_ce_new_pro( - hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, -) -> None: - """Test that the zeroconf confirmation form is served.""" - mock_motionmount_config_flow.connect.side_effect = ( - motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) - ) - - discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=discovery_info, - ) - - assert result["step_id"] == "zeroconf_confirm" - assert result["type"] is FlowResultType.FORM - assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} - - async def test_show_zeroconf_form_new_ce_old_pro( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -384,6 +330,21 @@ async def test_show_zeroconf_form_new_ce_old_pro( assert result["type"] is FlowResultType.FORM assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME + assert result["data"][CONF_PORT] == PORT + assert result["data"][CONF_NAME] == ZEROCONF_NAME + + assert result["result"] + assert result["result"].unique_id is None + async def test_show_zeroconf_form_new_ce_new_pro( hass: HomeAssistant, @@ -403,6 +364,21 @@ async def test_show_zeroconf_form_new_ce_new_pro( assert result["type"] is FlowResultType.FORM assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME + assert result["data"][CONF_PORT] == PORT + assert result["data"][CONF_NAME] == ZEROCONF_NAME + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + async def test_zeroconf_device_exists_abort( hass: HomeAssistant, @@ -423,6 +399,346 @@ async def test_zeroconf_device_exists_abort( assert result["reason"] == "already_configured" +async def test_zeroconf_authentication_needed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME + assert result["data"][CONF_PORT] == PORT + assert result["data"][CONF_NAME] == ZEROCONF_NAME + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_incorrect_then_correct_pin( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + assert result["errors"] + assert result["errors"][CONF_PIN] == CONF_PIN + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_first_incorrect_pin_to_backoff( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + side_effect=[True, 1] + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=MOCK_USER_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert mock_motionmount_config_flow.authenticate.called + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backoff" + + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_multiple_incorrect_pins( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backoff" + + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_show_backoff_when_still_running( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=MOCK_USER_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backoff" + + # This situation happens when the user cancels the progress dialog and tries to + # configure the MotionMount again + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=None, + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backoff" + + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_correct_pin( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + async def test_full_user_flow_implementation( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -459,7 +775,7 @@ async def test_full_zeroconf_flow_implementation( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, ) -> None: - """Test the full manual user flow from start to finish.""" + """Test the full zeroconf flow from start to finish.""" type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) @@ -487,3 +803,37 @@ async def test_full_zeroconf_flow_implementation( assert result["result"] assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_full_reauth_flow_implementation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test reauthentication.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" From 52dc124cfeed917cc2a4b2f0f37206429df56b20 Mon Sep 17 00:00:00 2001 From: Roman Sivriver Date: Tue, 28 Jan 2025 10:46:08 -0500 Subject: [PATCH 1096/2987] Fix Telegram webhook registration if deregistration previously failed (#133398) --- homeassistant/components/telegram_bot/webhooks.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 3eb3c71a0bb..9bd360f5e41 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -109,13 +109,12 @@ class PushBot(BaseTelegramBotEntity): else: _LOGGER.debug("telegram webhook status: %s", current_status) - if current_status and current_status["url"] != self.webhook_url: - result = await self._try_to_set_webhook() - if result: - _LOGGER.debug("Set new telegram webhook %s", self.webhook_url) - else: - _LOGGER.error("Set telegram webhook failed %s", self.webhook_url) - return False + result = await self._try_to_set_webhook() + if result: + _LOGGER.debug("Set new telegram webhook %s", self.webhook_url) + else: + _LOGGER.error("Set telegram webhook failed %s", self.webhook_url) + return False return True From ae157e859229b4837c4917a9ad1d7611747fd1d3 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:56:14 +0100 Subject: [PATCH 1097/2987] Parameterize enphase_envoy number tests. (#136631) --- tests/components/enphase_envoy/test_number.py | 98 +++++++++---------- 1 file changed, 44 insertions(+), 54 deletions(-) diff --git a/tests/components/enphase_envoy/test_number.py b/tests/components/enphase_envoy/test_number.py index dbf711cacaa..7f9293eef7c 100644 --- a/tests/components/enphase_envoy/test_number.py +++ b/tests/components/enphase_envoy/test_number.py @@ -21,9 +21,9 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.parametrize( - ("mock_envoy"), + "mock_envoy", ["envoy_metered_batt_relay", "envoy_eu_batt"], - indirect=["mock_envoy"], + indirect=True, ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_number( @@ -40,14 +40,14 @@ async def test_number( @pytest.mark.parametrize( - ("mock_envoy"), + "mock_envoy", [ "envoy", "envoy_1p_metered", "envoy_nobatt_metered_3p", "envoy_tot_cons_metered", ], - indirect=["mock_envoy"], + indirect=True, ) async def test_no_number( hass: HomeAssistant, @@ -62,10 +62,10 @@ async def test_no_number( @pytest.mark.parametrize( - ("mock_envoy", "use_serial"), + ("mock_envoy", "use_serial", "expected_value", "test_value"), [ - ("envoy_metered_batt_relay", "enpower_654321"), - ("envoy_eu_batt", "envoy_1234"), + ("envoy_metered_batt_relay", "enpower_654321", 15.0, 30.0), + ("envoy_eu_batt", "envoy_1234", 0.0, 80.0), ], indirect=["mock_envoy"], ) @@ -74,6 +74,8 @@ async def test_number_operation_storage( mock_envoy: AsyncMock, config_entry: MockConfigEntry, use_serial: bool, + expected_value: float, + test_value: float, ) -> None: """Test enphase_envoy number storage entities operation.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.NUMBER]): @@ -82,10 +84,8 @@ async def test_number_operation_storage( test_entity = f"{Platform.NUMBER}.{use_serial}_reserve_battery_level" assert (entity_state := hass.states.get(test_entity)) - assert mock_envoy.data.tariff.storage_settings.reserved_soc == float( - entity_state.state - ) - test_value = 30.0 + assert float(entity_state.state) == expected_value + await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -99,13 +99,27 @@ async def test_number_operation_storage( mock_envoy.set_reserve_soc.assert_awaited_once_with(test_value) +@pytest.mark.parametrize("mock_envoy", ["envoy_metered_batt_relay"], indirect=True) @pytest.mark.parametrize( - ("mock_envoy"), ["envoy_metered_batt_relay"], indirect=["mock_envoy"] + ("relay", "target", "expected_value", "test_value", "test_field"), + [ + ("NC1", "cutoff_battery_level", 25.0, 15.0, "soc_low"), + ("NC1", "restore_battery_level", 70.0, 75.0, "soc_high"), + ("NC2", "cutoff_battery_level", 30.0, 25.0, "soc_low"), + ("NC2", "restore_battery_level", 70.0, 80.0, "soc_high"), + ("NC3", "cutoff_battery_level", 30.0, 45.0, "soc_low"), + ("NC3", "restore_battery_level", 70.0, 90.0, "soc_high"), + ], ) async def test_number_operation_relays( hass: HomeAssistant, mock_envoy: AsyncMock, config_entry: MockConfigEntry, + relay: str, + target: str, + expected_value: float, + test_value: float, + test_field: str, ) -> None: """Test enphase_envoy number relay entities operation.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.NUMBER]): @@ -113,48 +127,24 @@ async def test_number_operation_relays( entity_base = f"{Platform.NUMBER}." - for counter, (contact_id, dry_contact) in enumerate( - mock_envoy.data.dry_contact_settings.items() - ): - name = dry_contact.load_name.lower().replace(" ", "_") - test_entity = f"{entity_base}{name}_cutoff_battery_level" - assert (entity_state := hass.states.get(test_entity)) - assert mock_envoy.data.dry_contact_settings[contact_id].soc_low == float( - entity_state.state - ) - test_value = 10.0 + counter - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: test_entity, - ATTR_VALUE: test_value, - }, - blocking=True, - ) + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) - mock_envoy.update_dry_contact.assert_awaited_once_with( - {"id": contact_id, "soc_low": test_value} - ) - mock_envoy.update_dry_contact.reset_mock() + test_entity = f"{entity_base}{name}_{target}" - test_entity = f"{entity_base}{name}_restore_battery_level" - assert (entity_state := hass.states.get(test_entity)) - assert mock_envoy.data.dry_contact_settings[contact_id].soc_high == float( - entity_state.state - ) - test_value = 80.0 - counter - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: test_entity, - ATTR_VALUE: test_value, - }, - blocking=True, - ) + assert (entity_state := hass.states.get(test_entity)) + assert float(entity_state.state) == expected_value - mock_envoy.update_dry_contact.assert_awaited_once_with( - {"id": contact_id, "soc_high": test_value} - ) - mock_envoy.update_dry_contact.reset_mock() + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: test_entity, + ATTR_VALUE: test_value, + }, + blocking=True, + ) + + mock_envoy.update_dry_contact.assert_awaited_once_with( + {"id": relay, test_field: int(test_value)} + ) From 7cbc6f35d2ad35b27ce29854399758d15e59f291 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 28 Jan 2025 17:08:55 +0100 Subject: [PATCH 1098/2987] Fix all occurrences of "PIN" in MotionMount user strings (#136734) --- homeassistant/components/motionmount/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index 098a7a592f3..bef04634431 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -1,6 +1,6 @@ { "common": { - "incorrect_pin": "Pin is not correct" + "incorrect_pin": "PIN is not correct" }, "config": { "flow_title": "{name}", @@ -19,21 +19,21 @@ }, "auth": { "title": "Authenticate to your MotionMount", - "description": "Your MotionMount requires a pin to operate.", + "description": "Your MotionMount requires a PIN to operate.", "data": { "pin": "[%key:common::config_flow::data::pin%]" } }, "backoff": { "title": "Authenticate to your MotionMount", - "description": "Too many incorrect pin attempts." + "description": "Too many incorrect PIN attempts." } }, "error": { "pin": "[%key:component::motionmount::common::incorrect_pin%]" }, "progress": { - "progress_action": "Too many incorrect pin attempts. Please wait {timeout} s..." + "progress_action": "Too many incorrect PIN attempts. Please wait {timeout} s..." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -83,7 +83,7 @@ "message": "Failed to communicate with MotionMount" }, "no_pin_provided": { - "message": "No pin provided" + "message": "No PIN provided" }, "incorrect_pin": { "message": "[%key:component::motionmount::common::incorrect_pin%]" From e9ef82f89895fef691c3c30fcf703a4da94913d4 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 28 Jan 2025 08:32:09 -0800 Subject: [PATCH 1099/2987] Bump python-roborock to 2.9.7 (#136727) --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index d104ebff12a..76d7ab98a34 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==2.8.4", + "python-roborock==2.9.7", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 128586eb01e..287ca9364a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2446,7 +2446,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.8.4 +python-roborock==2.9.7 # homeassistant.components.smarttub python-smarttub==0.0.38 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 117886a0bc8..d7220be9718 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1979,7 +1979,7 @@ python-picnic-api==1.1.0 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.8.4 +python-roborock==2.9.7 # homeassistant.components.smarttub python-smarttub==0.0.38 From 661bacda1056864463b032e7d9748e3a894101d6 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Tue, 28 Jan 2025 09:34:25 -0700 Subject: [PATCH 1100/2987] Add SmartTowerFan to VeSync Integration (#136596) --- homeassistant/components/vesync/const.py | 6 + homeassistant/components/vesync/fan.py | 14 +++ homeassistant/components/vesync/icons.json | 1 + homeassistant/components/vesync/strings.json | 1 + tests/components/vesync/common.py | 3 + .../vesync/fixtures/SmartTowerFan-detail.json | 37 +++++++ .../vesync/fixtures/vesync-devices.json | 9 ++ .../components/vesync/snapshots/test_fan.ambr | 103 ++++++++++++++++++ .../vesync/snapshots/test_light.ambr | 38 +++++++ .../vesync/snapshots/test_sensor.ambr | 38 +++++++ .../vesync/snapshots/test_switch.ambr | 38 +++++++ 11 files changed, 288 insertions(+) create mode 100644 tests/components/vesync/fixtures/SmartTowerFan-detail.json diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 841185e4308..34454081567 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -48,6 +48,7 @@ DEV_TYPE_TO_HA = { "EverestAir": "fan", "Vital200S": "fan", "Vital100S": "fan", + "SmartTowerFan": "fan", "ESD16": "walldimmer", "ESWD16": "walldimmer", "ESL100": "bulb-dimmable", @@ -91,4 +92,9 @@ SKU_TO_BASE_DEVICE = { "LAP-EL551S-AEUR": "EverestAir", # Alt ID Model EverestAir "LAP-EL551S-WEU": "EverestAir", # Alt ID Model EverestAir "LAP-EL551S-WUS": "EverestAir", # Alt ID Model EverestAir + "SmartTowerFan": "SmartTowerFan", + "LTF-F422S-KEU": "SmartTowerFan", # Alt ID Model SmartTowerFan + "LTF-F422S-WUSR": "SmartTowerFan", # Alt ID Model SmartTowerFan + "LTF-F422_WJP": "SmartTowerFan", # Alt ID Model SmartTowerFan + "LTF-F422S-WUS": "SmartTowerFan", # Alt ID Model SmartTowerFan } diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index ba1880f2492..9744e5062f0 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -36,6 +36,9 @@ FAN_MODE_AUTO = "auto" FAN_MODE_SLEEP = "sleep" FAN_MODE_PET = "pet" FAN_MODE_TURBO = "turbo" +FAN_MODE_ADVANCED_SLEEP = "advancedSleep" +FAN_MODE_NORMAL = "normal" + PRESET_MODES = { "LV-PUR131S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], @@ -46,6 +49,12 @@ PRESET_MODES = { "EverestAir": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_TURBO], "Vital200S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], "Vital100S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], + "SmartTowerFan": [ + FAN_MODE_ADVANCED_SLEEP, + FAN_MODE_AUTO, + FAN_MODE_TURBO, + FAN_MODE_NORMAL, + ], } SPEED_RANGE = { # off is not included "LV-PUR131S": (1, 3), @@ -56,6 +65,7 @@ SPEED_RANGE = { # off is not included "EverestAir": (1, 3), "Vital200S": (1, 4), "Vital100S": (1, 4), + "SmartTowerFan": (1, 13), } @@ -212,10 +222,14 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): self.smartfan.auto_mode() elif preset_mode == FAN_MODE_SLEEP: self.smartfan.sleep_mode() + elif preset_mode == FAN_MODE_ADVANCED_SLEEP: + self.smartfan.advanced_sleep_mode() elif preset_mode == FAN_MODE_PET: self.smartfan.pet_mode() elif preset_mode == FAN_MODE_TURBO: self.smartfan.turbo_mode() + elif preset_mode == FAN_MODE_NORMAL: + self.smartfan.normal_mode() self.schedule_update_ha_state() diff --git a/homeassistant/components/vesync/icons.json b/homeassistant/components/vesync/icons.json index e4769acc9a5..c11bd002049 100644 --- a/homeassistant/components/vesync/icons.json +++ b/homeassistant/components/vesync/icons.json @@ -7,6 +7,7 @@ "state": { "auto": "mdi:fan-auto", "sleep": "mdi:sleep", + "advanced_sleep": "mdi:sleep", "pet": "mdi:paw", "turbo": "mdi:weather-tornado" } diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index a23fe7936e7..87a8ea8746e 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -55,6 +55,7 @@ "state": { "auto": "Auto", "sleep": "Sleep", + "advanced_sleep": "Advanced sleep", "pet": "Pet", "turbo": "Turbo" } diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index ead3ecdc173..ee9f9b94052 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -51,6 +51,9 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { ("post", "/inwallswitch/v1/device/devicedetail", "device-detail.json") ], "Dimmer Switch": [("post", "/dimmer/v1/device/devicedetail", "dimmer-detail.json")], + "SmartTowerFan": [ + ("post", "/cloud/v2/deviceManaged/bypassV2", "SmartTowerFan-detail.json") + ], } diff --git a/tests/components/vesync/fixtures/SmartTowerFan-detail.json b/tests/components/vesync/fixtures/SmartTowerFan-detail.json new file mode 100644 index 00000000000..061dcb5b0d0 --- /dev/null +++ b/tests/components/vesync/fixtures/SmartTowerFan-detail.json @@ -0,0 +1,37 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "module": null, + "stacktrace": null, + "result": { + "traceId": "0000000000", + "code": 0, + "result": { + "powerSwitch": 0, + "workMode": "normal", + "manualSpeedLevel": 1, + "fanSpeedLevel": 0, + "screenState": 0, + "screenSwitch": 0, + "oscillationSwitch": 1, + "oscillationState": 1, + "muteSwitch": 1, + "muteState": 1, + "timerRemain": 0, + "temperature": 717, + "humidity": 40, + "thermalComfort": 65, + "errorCode": 0, + "sleepPreference": { + "sleepPreferenceType": "default", + "oscillationSwitch": 0, + "initFanSpeedLevel": 0, + "fallAsleepRemain": 0, + "autoChangeFanLevelSwitch": 0 + }, + "scheduleCount": 0, + "displayingType": 0 + } + } +} diff --git a/tests/components/vesync/fixtures/vesync-devices.json b/tests/components/vesync/fixtures/vesync-devices.json index eac2bf9f5fa..bb32bae0435 100644 --- a/tests/components/vesync/fixtures/vesync-devices.json +++ b/tests/components/vesync/fixtures/vesync-devices.json @@ -108,6 +108,15 @@ "deviceStatus": "on", "connectionStatus": "online", "configModule": "configModule" + }, + { + "cid": "smarttowerfan", + "deviceType": "LTF-F422S-KEU", + "deviceName": "SmartTowerFan", + "subDeviceNo": null, + "deviceStatus": "on", + "connectionStatus": "online", + "configModule": "configModule" } ] } diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 1dea5f28f2c..e1b630e8d81 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -576,6 +576,109 @@ list([ ]) # --- +# name: test_fan_state[SmartTowerFan][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'smarttowerfan', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LTF-F422S-KEU', + 'model_id': None, + 'name': 'SmartTowerFan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_fan_state[SmartTowerFan][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'advancedSleep', + 'auto', + 'turbo', + 'normal', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.smarttowerfan', + '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': None, + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vesync', + 'unique_id': 'smarttowerfan', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_fan_state[SmartTowerFan][fan.smarttowerfan] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'child_lock': False, + 'friendly_name': 'SmartTowerFan', + 'mode': 'normal', + 'night_light': 'off', + 'percentage': None, + 'percentage_step': 7.6923076923076925, + 'preset_mode': None, + 'preset_modes': list([ + 'advancedSleep', + 'auto', + 'turbo', + 'normal', + ]), + 'screen_status': False, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.smarttowerfan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_fan_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index ba6c7ab51b9..74f63ce72a1 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -447,6 +447,44 @@ list([ ]) # --- +# name: test_light_state[SmartTowerFan][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'smarttowerfan', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LTF-F422S-KEU', + 'model_id': None, + 'name': 'SmartTowerFan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_light_state[SmartTowerFan][entities] + list([ + ]) +# --- # name: test_light_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index 50bee417a28..2525dcd642e 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -1155,6 +1155,44 @@ 'state': '0', }) # --- +# name: test_sensor_state[SmartTowerFan][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'smarttowerfan', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LTF-F422S-KEU', + 'model_id': None, + 'name': 'SmartTowerFan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[SmartTowerFan][entities] + list([ + ]) +# --- # name: test_sensor_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index 596aa0c94ad..0a72bb3ca47 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -385,6 +385,44 @@ 'state': 'on', }) # --- +# name: test_switch_state[SmartTowerFan][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'smarttowerfan', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LTF-F422S-KEU', + 'model_id': None, + 'name': 'SmartTowerFan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switch_state[SmartTowerFan][entities] + list([ + ]) +# --- # name: test_switch_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ From 3680e39c437a0e869d844e03ed5d6074f8002782 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Tue, 28 Jan 2025 17:38:28 +0100 Subject: [PATCH 1101/2987] Add climate platform to eheimdigital (#135878) --- .../components/eheimdigital/__init__.py | 2 +- .../components/eheimdigital/climate.py | 139 +++++++++++ .../components/eheimdigital/const.py | 12 +- .../components/eheimdigital/strings.json | 12 + tests/components/eheimdigital/conftest.py | 27 ++- .../eheimdigital/snapshots/test_climate.ambr | 77 ++++++ tests/components/eheimdigital/test_climate.py | 219 ++++++++++++++++++ 7 files changed, 483 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/eheimdigital/climate.py create mode 100644 tests/components/eheimdigital/snapshots/test_climate.ambr create mode 100644 tests/components/eheimdigital/test_climate.py diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index cf08f45bed5..a555a87cfbc 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -10,7 +10,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN from .coordinator import EheimDigitalUpdateCoordinator -PLATFORMS = [Platform.LIGHT] +PLATFORMS = [Platform.CLIMATE, Platform.LIGHT] type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator] diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py new file mode 100644 index 00000000000..16771ba227d --- /dev/null +++ b/homeassistant/components/eheimdigital/climate.py @@ -0,0 +1,139 @@ +"""EHEIM Digital climate.""" + +from typing import Any + +from eheimdigital.heater import EheimDigitalHeater +from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit + +from homeassistant.components.climate import ( + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_HALVES, + PRECISION_TENTHS, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import EheimDigitalConfigEntry +from .const import HEATER_BIO_MODE, HEATER_PRESET_TO_HEATER_MODE, HEATER_SMART_MODE +from .coordinator import EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so climate entities can be added as devices are found.""" + coordinator = entry.runtime_data + + async def async_setup_device_entities(device_address: str) -> None: + """Set up the light entities for a device.""" + device = coordinator.hub.devices[device_address] + + if isinstance(device, EheimDigitalHeater): + async_add_entities([EheimDigitalHeaterClimate(coordinator, device)]) + + coordinator.add_platform_callback(async_setup_device_entities) + + for device_address in entry.runtime_data.hub.devices: + await async_setup_device_entities(device_address) + + +class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateEntity): + """Represent an EHEIM Digital heater.""" + + _attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO] + _attr_hvac_mode = HVACMode.OFF + _attr_precision = PRECISION_TENTHS + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.PRESET_MODE + ) + _attr_target_temperature_step = PRECISION_HALVES + _attr_preset_modes = [PRESET_NONE, HEATER_BIO_MODE, HEATER_SMART_MODE] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_preset_mode = PRESET_NONE + _attr_translation_key = "heater" + + def __init__( + self, coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalHeater + ) -> None: + """Initialize an EHEIM Digital thermocontrol climate entity.""" + super().__init__(coordinator, device) + self._attr_unique_id = self._device_address + self._async_update_attrs() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode.""" + try: + if preset_mode in HEATER_PRESET_TO_HEATER_MODE: + await self._device.set_operation_mode( + HEATER_PRESET_TO_HEATER_MODE[preset_mode] + ) + except EheimDigitalClientError as err: + raise HomeAssistantError from err + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set a new temperature.""" + try: + if ATTR_TEMPERATURE in kwargs: + await self._device.set_target_temperature(kwargs[ATTR_TEMPERATURE]) + except EheimDigitalClientError as err: + raise HomeAssistantError from err + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the heating mode.""" + try: + match hvac_mode: + case HVACMode.OFF: + await self._device.set_active(active=False) + case HVACMode.AUTO: + await self._device.set_active(active=True) + except EheimDigitalClientError as err: + raise HomeAssistantError from err + + def _async_update_attrs(self) -> None: + if self._device.temperature_unit == HeaterUnit.CELSIUS: + self._attr_min_temp = 18 + self._attr_max_temp = 32 + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + elif self._device.temperature_unit == HeaterUnit.FAHRENHEIT: + self._attr_min_temp = 64 + self._attr_max_temp = 90 + self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + + self._attr_current_temperature = self._device.current_temperature + self._attr_target_temperature = self._device.target_temperature + + if self._device.is_heating: + self._attr_hvac_action = HVACAction.HEATING + self._attr_hvac_mode = HVACMode.AUTO + elif self._device.is_active: + self._attr_hvac_action = HVACAction.IDLE + self._attr_hvac_mode = HVACMode.AUTO + else: + self._attr_hvac_action = HVACAction.OFF + self._attr_hvac_mode = HVACMode.OFF + + match self._device.operation_mode: + case HeaterMode.MANUAL: + self._attr_preset_mode = PRESET_NONE + case HeaterMode.BIO: + self._attr_preset_mode = HEATER_BIO_MODE + case HeaterMode.SMART: + self._attr_preset_mode = HEATER_SMART_MODE diff --git a/homeassistant/components/eheimdigital/const.py b/homeassistant/components/eheimdigital/const.py index 5ed9303be40..61b391b6c63 100644 --- a/homeassistant/components/eheimdigital/const.py +++ b/homeassistant/components/eheimdigital/const.py @@ -2,8 +2,9 @@ from logging import Logger, getLogger -from eheimdigital.types import LightMode +from eheimdigital.types import HeaterMode, LightMode +from homeassistant.components.climate import PRESET_NONE from homeassistant.components.light import EFFECT_OFF LOGGER: Logger = getLogger(__package__) @@ -15,3 +16,12 @@ EFFECT_TO_LIGHT_MODE = { EFFECT_DAYCL_MODE: LightMode.DAYCL_MODE, EFFECT_OFF: LightMode.MAN_MODE, } + +HEATER_BIO_MODE = "bio_mode" +HEATER_SMART_MODE = "smart_mode" + +HEATER_PRESET_TO_HEATER_MODE = { + HEATER_BIO_MODE: HeaterMode.BIO, + HEATER_SMART_MODE: HeaterMode.SMART, + PRESET_NONE: HeaterMode.MANUAL, +} diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json index 0e6fa6a0814..ef6f6b10d0a 100644 --- a/homeassistant/components/eheimdigital/strings.json +++ b/homeassistant/components/eheimdigital/strings.json @@ -23,6 +23,18 @@ } }, "entity": { + "climate": { + "heater": { + "state_attributes": { + "preset_mode": { + "state": { + "bio_mode": "Bio mode", + "smart_mode": "Smart mode" + } + } + } + } + }, "light": { "channel": { "name": "Channel {channel_id}", diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py index cdad628de6b..ef52eade9ae 100644 --- a/tests/components/eheimdigital/conftest.py +++ b/tests/components/eheimdigital/conftest.py @@ -4,8 +4,9 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.heater import EheimDigitalHeater from eheimdigital.hub import EheimDigitalHub -from eheimdigital.types import EheimDeviceType, LightMode +from eheimdigital.types import EheimDeviceType, HeaterMode, HeaterUnit, LightMode import pytest from homeassistant.components.eheimdigital.const import DOMAIN @@ -39,7 +40,26 @@ def classic_led_ctrl_mock(): @pytest.fixture -def eheimdigital_hub_mock(classic_led_ctrl_mock: MagicMock) -> Generator[AsyncMock]: +def heater_mock(): + """Mock a Heater device.""" + heater_mock = MagicMock(spec=EheimDigitalHeater) + heater_mock.mac_address = "00:00:00:00:00:02" + heater_mock.device_type = EheimDeviceType.VERSION_EHEIM_EXT_HEATER + heater_mock.name = "Mock Heater" + heater_mock.aquarium_name = "Mock Aquarium" + heater_mock.temperature_unit = HeaterUnit.CELSIUS + heater_mock.current_temperature = 24.2 + heater_mock.target_temperature = 25.5 + heater_mock.is_heating = True + heater_mock.is_active = True + heater_mock.operation_mode = HeaterMode.MANUAL + return heater_mock + + +@pytest.fixture +def eheimdigital_hub_mock( + classic_led_ctrl_mock: MagicMock, heater_mock: MagicMock +) -> Generator[AsyncMock]: """Mock eheimdigital hub.""" with ( patch( @@ -52,7 +72,8 @@ def eheimdigital_hub_mock(classic_led_ctrl_mock: MagicMock) -> Generator[AsyncMo ), ): eheimdigital_hub_mock.return_value.devices = { - "00:00:00:00:00:01": classic_led_ctrl_mock + "00:00:00:00:00:01": classic_led_ctrl_mock, + "00:00:00:00:00:02": heater_mock, } eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock yield eheimdigital_hub_mock diff --git a/tests/components/eheimdigital/snapshots/test_climate.ambr b/tests/components/eheimdigital/snapshots/test_climate.ambr new file mode 100644 index 00000000000..02d60677b24 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_climate.ambr @@ -0,0 +1,77 @@ +# serializer version: 1 +# name: test_setup_heater[climate.mock_heater_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.mock_heater_none', + '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': None, + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'heater', + 'unique_id': '00:00:00:00:00:02', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_heater[climate.mock_heater_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 24.2, + 'friendly_name': 'Mock Heater None', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 25.5, + }), + 'context': , + 'entity_id': 'climate.mock_heater_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py new file mode 100644 index 00000000000..4e770882263 --- /dev/null +++ b/tests/components/eheimdigital/test_climate.py @@ -0,0 +1,219 @@ +"""Tests for the climate module.""" + +from unittest.mock import MagicMock, patch + +from eheimdigital.types import ( + EheimDeviceType, + EheimDigitalClientError, + HeaterMode, + HeaterUnit, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + DOMAIN as CLIMATE_DOMAIN, + PRESET_NONE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, + HVACMode, +) +from homeassistant.components.eheimdigital.const import ( + HEATER_BIO_MODE, + HEATER_SMART_MODE, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("heater_mock") +async def test_setup_heater( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test climate platform setup for heater.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]): + 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 + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("preset_mode", "heater_mode"), + [ + (PRESET_NONE, HeaterMode.MANUAL), + (HEATER_BIO_MODE, HeaterMode.BIO), + (HEATER_SMART_MODE, HeaterMode.SMART), + ], +) +async def test_set_preset_mode( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + heater_mock: MagicMock, + mock_config_entry: MockConfigEntry, + preset_mode: str, + 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 eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + heater_mock.set_operation_mode.side_effect = EheimDigitalClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + + heater_mock.set_operation_mode.side_effect = None + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + + heater_mock.set_operation_mode.assert_awaited_with(heater_mode) + + +async def test_set_temperature( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + heater_mock: MagicMock, + 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 eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + heater_mock.set_target_temperature.side_effect = EheimDigitalClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_TEMPERATURE: 26.0}, + blocking=True, + ) + + heater_mock.set_target_temperature.side_effect = None + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_TEMPERATURE: 26.0}, + blocking=True, + ) + + heater_mock.set_target_temperature.assert_awaited_with(26.0) + + +@pytest.mark.parametrize( + ("hvac_mode", "active"), [(HVACMode.AUTO, True), (HVACMode.OFF, False)] +) +async def test_set_hvac_mode( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + heater_mock: MagicMock, + mock_config_entry: MockConfigEntry, + hvac_mode: HVACMode, + 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 eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + heater_mock.set_active.side_effect = EheimDigitalClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + + heater_mock.set_active.side_effect = None + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + + heater_mock.set_active.assert_awaited_with(active=active) + + +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + heater_mock: MagicMock, +) -> None: + """Test the climate state update.""" + heater_mock.temperature_unit = HeaterUnit.FAHRENHEIT + heater_mock.is_heating = False + heater_mock.operation_mode = HeaterMode.BIO + + mock_config_entry.add_to_hass(hass) + + 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 + ) + await hass.async_block_till_done() + + assert (state := hass.states.get("climate.mock_heater_none")) + + assert state.attributes["hvac_action"] == HVACAction.IDLE + assert state.attributes["preset_mode"] == HEATER_BIO_MODE + + heater_mock.is_active = False + heater_mock.operation_mode = HeaterMode.SMART + + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + + assert (state := hass.states.get("climate.mock_heater_none")) + assert state.state == HVACMode.OFF + assert state.attributes["preset_mode"] == HEATER_SMART_MODE From 9b598ed69c1c0783de4b1acafaffd9eb1382ef68 Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Tue, 28 Jan 2025 10:38:53 -0600 Subject: [PATCH 1102/2987] Add more tests to vesync (#135681) --- homeassistant/components/vesync/fan.py | 5 -- homeassistant/components/vesync/humidifier.py | 2 +- tests/components/vesync/conftest.py | 26 +++++++- .../vesync/fixtures/vesync-devices.json | 2 +- .../components/vesync/snapshots/test_fan.ambr | 2 +- .../vesync/snapshots/test_light.ambr | 2 +- .../vesync/snapshots/test_sensor.ambr | 4 +- .../vesync/snapshots/test_switch.ambr | 2 +- tests/components/vesync/test_humidifier.py | 62 ++++++++++++++++++- tests/components/vesync/test_init.py | 37 +++++++---- 10 files changed, 118 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 9744e5062f0..21a92a22db2 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -161,11 +161,6 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): return self.smartfan.mode return None - @property - def unique_info(self): - """Return the ID of this fan.""" - return self.smartfan.uuid - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the fan.""" diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index 3d89d5dc6db..8557c7a8866 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -137,7 +137,7 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): @property def mode(self) -> str | None: """Get the current preset mode.""" - return _get_ha_mode(self.device.mode) + return None if self.device.mode is None else _get_ha_mode(self.device.mode) def set_humidity(self, humidity: int) -> None: """Set the target humidity of the device.""" diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 8272da8dfad..a80c2631088 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -108,7 +108,31 @@ def outlet_fixture(): @pytest.fixture(name="humidifier") def humidifier_fixture(): """Create a mock VeSync humidifier fixture.""" - return Mock(VeSyncHumid200300S) + return Mock( + VeSyncHumid200300S, + cid="200s-humidifier", + config={ + "auto_target_humidity": 40, + "display": "true", + "automatic_stop": "true", + }, + details={ + "humidity": 35, + "mode": "manual", + }, + device_type="Classic200S", + device_name="Humidifier 200s", + device_status="on", + mist_level=6, + mist_modes=["auto", "manual"], + mode=None, + 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") diff --git a/tests/components/vesync/fixtures/vesync-devices.json b/tests/components/vesync/fixtures/vesync-devices.json index bb32bae0435..3109fd3ea40 100644 --- a/tests/components/vesync/fixtures/vesync-devices.json +++ b/tests/components/vesync/fixtures/vesync-devices.json @@ -6,7 +6,7 @@ "cid": "200s-humidifier", "deviceType": "Classic200S", "deviceName": "Humidifier 200s", - "subDeviceNo": null, + "subDeviceNo": 4321, "deviceStatus": "on", "connectionStatus": "online", "uuid": "00000000-1111-2222-3333-444444444444", diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index e1b630e8d81..fddc75630d2 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -477,7 +477,7 @@ 'identifiers': set({ tuple( 'vesync', - '200s-humidifier', + '200s-humidifier4321', ), }), 'is_new': False, diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index 74f63ce72a1..b89cf8cdd4d 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -348,7 +348,7 @@ 'identifiers': set({ tuple( 'vesync', - '200s-humidifier', + '200s-humidifier4321', ), }), 'is_new': False, diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index 2525dcd642e..ca7a5cf3ea6 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -664,7 +664,7 @@ 'identifiers': set({ tuple( 'vesync', - '200s-humidifier', + '200s-humidifier4321', ), }), 'is_new': False, @@ -715,7 +715,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '200s-humidifier-humidity', + 'unique_id': '200s-humidifier4321-humidity', 'unit_of_measurement': '%', }), ]) diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index 0a72bb3ca47..ec9cbc4398c 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -242,7 +242,7 @@ 'identifiers': set({ tuple( 'vesync', - '200s-humidifier', + '200s-humidifier4321', ), }), 'is_new': False, diff --git a/tests/components/vesync/test_humidifier.py b/tests/components/vesync/test_humidifier.py index 3b89ba8e742..b93c97baab6 100644 --- a/tests/components/vesync/test_humidifier.py +++ b/tests/components/vesync/test_humidifier.py @@ -1,6 +1,7 @@ """Tests for the humidifier platform.""" from contextlib import nullcontext +import logging from unittest.mock import patch import pytest @@ -12,7 +13,7 @@ from homeassistant.components.humidifier import ( SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -21,6 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er from .common import ( ENTITY_HUMIDIFIER, @@ -225,3 +227,61 @@ async def test_set_mode( ) await hass.async_block_till_done() method_mock.assert_called_once() + + +async def test_base_unique_id( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that unique_id is based on subDeviceNo.""" + # vesync-device.json defines subDeviceNo for 200s-humidifier as 4321. + entity = entity_registry.async_get(ENTITY_HUMIDIFIER) + assert entity.unique_id.endswith("4321") + + +async def test_invalid_mist_modes( + hass: HomeAssistant, + config_entry: ConfigEntry, + humidifier, + manager, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test unsupported mist mode.""" + + humidifier.mist_modes = ["invalid_mode"] + + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[humidifier], + ): + caplog.clear() + caplog.set_level(logging.WARNING) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert "Unknown mode 'invalid_mode'" in caplog.text + + +async def test_valid_mist_modes( + hass: HomeAssistant, + config_entry: ConfigEntry, + humidifier, + manager, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test supported mist mode.""" + + humidifier.mist_modes = ["auto", "manual"] + + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[humidifier], + ): + caplog.clear() + caplog.set_level(logging.WARNING) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert "Unknown mode 'auto'" not in caplog.text + assert "Unknown mode 'manual'" not in caplog.text diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index 3b0df128240..7873b911f6f 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -90,23 +90,36 @@ async def test_async_setup_entry__loads_fans( assert hass.data[DOMAIN][VS_DEVICES] == [fan] -async def test_async_new_device_discovery__loads_fans( - hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan +async def test_async_new_device_discovery( + hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan, humidifier ) -> None: - """Test setup connects to vesync and loads fan as an update call.""" + """Test new device discovery.""" assert await hass.config_entries.async_setup(config_entry.entry_id) # Assert platforms loaded await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED assert not hass.data[DOMAIN][VS_DEVICES] - fans = [fan] - manager.fans = fans - manager._dev_list = { - "fans": fans, - } - await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) - assert manager.login.call_count == 1 - assert hass.data[DOMAIN][VS_MANAGER] == manager - assert hass.data[DOMAIN][VS_DEVICES] == [fan] + # Mock discovery of new fan which would get added to VS_DEVICES. + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[fan], + ): + await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) + + assert manager.login.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert hass.data[DOMAIN][VS_DEVICES] == [fan] + + # Mock discovery of new humidifier which would invoke discovery in all platforms. + # The mocked humidifier needs to have all properties populated for correct processing. + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[humidifier], + ): + await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) + + assert manager.login.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert hass.data[DOMAIN][VS_DEVICES] == [fan, humidifier] From 3eb1b182f5a127300768bd1e23071f38aba2831f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Tue, 28 Jan 2025 17:42:26 +0100 Subject: [PATCH 1103/2987] Add config entry load/unload tests for LetPot (#136736) --- tests/components/letpot/__init__.py | 36 +++- tests/components/letpot/conftest.py | 46 ++++- tests/components/letpot/test_config_flow.py | 175 +++++++++----------- tests/components/letpot/test_init.py | 96 +++++++++++ 4 files changed, 252 insertions(+), 101 deletions(-) create mode 100644 tests/components/letpot/test_init.py diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py index f7686f815fe..829d1df54f3 100644 --- a/tests/components/letpot/__init__.py +++ b/tests/components/letpot/__init__.py @@ -1,12 +1,42 @@ """Tests for the LetPot integration.""" -from letpot.models import AuthenticationInfo +import datetime + +from letpot.models import AuthenticationInfo, LetPotDeviceStatus + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + AUTHENTICATION = AuthenticationInfo( access_token="access_token", - access_token_expires=0, + access_token_expires=1738368000, # 2025-02-01 00:00:00 GMT refresh_token="refresh_token", - refresh_token_expires=0, + refresh_token_expires=1740441600, # 2025-02-25 00:00:00 GMT user_id="a1b2c3d4e5f6a1b2c3d4e5f6", email="email@example.com", ) + +STATUS = LetPotDeviceStatus( + light_brightness=500, + light_mode=1, + light_schedule_end=datetime.time(12, 10), + light_schedule_start=datetime.time(12, 0), + online=True, + plant_days=1, + pump_mode=1, + pump_nutrient=None, + pump_status=0, + raw=[77, 0, 1, 18, 98, 1, 0, 0, 1, 1, 1, 0, 1, 12, 0, 12, 10, 1, 244, 0, 0, 0], + system_on=True, + system_sound=False, + system_state=0, +) diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py index 4cd7ef442a6..7971bca50ae 100644 --- a/tests/components/letpot/conftest.py +++ b/tests/components/letpot/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from letpot.models import LetPotDevice import pytest from homeassistant.components.letpot.const import ( @@ -14,7 +15,7 @@ from homeassistant.components.letpot.const import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL -from . import AUTHENTICATION +from . import AUTHENTICATION, STATUS from tests.common import MockConfigEntry @@ -28,6 +29,49 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry +@pytest.fixture +def mock_client() -> Generator[AsyncMock]: + """Mock a LetPotClient.""" + with ( + patch( + "homeassistant.components.letpot.LetPotClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.letpot.config_flow.LetPotClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.login.return_value = AUTHENTICATION + client.refresh_token.return_value = AUTHENTICATION + client.get_devices.return_value = [ + LetPotDevice( + serial_number="LPH21ABCD", + name="Garden", + device_type="LPH21", + is_online=True, + is_remote=False, + ) + ] + yield client + + +@pytest.fixture +def mock_device_client() -> 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_model_code = "LPH21" + device_client.device_model_name = "LetPot Air" + device_client.get_current_status.return_value = STATUS + device_client.last_status.return_value = STATUS + yield device_client + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Mock a config entry.""" diff --git a/tests/components/letpot/test_config_flow.py b/tests/components/letpot/test_config_flow.py index 425298dc231..a659b235213 100644 --- a/tests/components/letpot/test_config_flow.py +++ b/tests/components/letpot/test_config_flow.py @@ -2,7 +2,7 @@ import dataclasses from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException import pytest @@ -39,7 +39,9 @@ def _assert_result_success(result: Any) -> None: assert result["result"].unique_id == AUTHENTICATION.user_id -async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +async def test_full_flow( + hass: HomeAssistant, mock_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: """Test full flow with success.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -47,18 +49,13 @@ async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=AUTHENTICATION, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "email@example.com", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) _assert_result_success(result) assert len(mock_setup_entry.mock_calls) == 1 @@ -74,6 +71,7 @@ async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No ) async def test_flow_exceptions( hass: HomeAssistant, + mock_client: AsyncMock, mock_setup_entry: AsyncMock, exception: Exception, error: str, @@ -83,41 +81,37 @@ async def test_flow_exceptions( DOMAIN, context={"source": SOURCE_USER} ) - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "email@example.com", - CONF_PASSWORD: "test-password", - }, - ) + mock_client.login.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} # Retry to show recovery. - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=AUTHENTICATION, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "email@example.com", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + mock_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) _assert_result_success(result) assert len(mock_setup_entry.mock_calls) == 1 async def test_flow_duplicate( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test flow aborts when trying to add a previously added account.""" mock_config_entry.add_to_hass(hass) @@ -130,18 +124,13 @@ async def test_flow_duplicate( assert result["step_id"] == "user" assert result["errors"] == {} - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=AUTHENTICATION, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "email@example.com", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -149,7 +138,10 @@ async def test_flow_duplicate( async def test_reauth_flow( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test reauth flow with success.""" mock_config_entry.add_to_hass(hass) @@ -163,15 +155,11 @@ async def test_reauth_flow( access_token="new_access_token", refresh_token="new_refresh_token", ) - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=updated_auth, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) - await hass.async_block_till_done() + mock_client.login.return_value = updated_auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -196,6 +184,7 @@ async def test_reauth_flow( ) async def test_reauth_exceptions( hass: HomeAssistant, + mock_client: AsyncMock, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry, exception: Exception, @@ -208,14 +197,11 @@ async def test_reauth_exceptions( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) + mock_client.login.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} @@ -226,15 +212,12 @@ async def test_reauth_exceptions( access_token="new_access_token", refresh_token="new_refresh_token", ) - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=updated_auth, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) - await hass.async_block_till_done() + mock_client.login.return_value = updated_auth + mock_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -250,7 +233,10 @@ async def test_reauth_exceptions( async def test_reauth_different_user_id_new( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test reauth flow with different, new user ID updating the existing entry.""" mock_config_entry.add_to_hass(hass) @@ -263,15 +249,11 @@ async def test_reauth_different_user_id_new( assert result["step_id"] == "reauth_confirm" updated_auth = dataclasses.replace(AUTHENTICATION, user_id="new_user_id") - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=updated_auth, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) - await hass.async_block_till_done() + mock_client.login.return_value = updated_auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -289,7 +271,10 @@ async def test_reauth_different_user_id_new( async def test_reauth_different_user_id_existing( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test reauth flow with different, existing user ID aborting.""" mock_config_entry.add_to_hass(hass) @@ -303,15 +288,11 @@ async def test_reauth_different_user_id_existing( assert result["step_id"] == "reauth_confirm" updated_auth = dataclasses.replace(AUTHENTICATION, user_id="other_user_id") - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=updated_auth, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) - await hass.async_block_till_done() + mock_client.login.return_value = updated_auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/letpot/test_init.py b/tests/components/letpot/test_init.py new file mode 100644 index 00000000000..178227a6506 --- /dev/null +++ b/tests/components/letpot/test_init.py @@ -0,0 +1,96 @@ +"""Test the LetPot integration initialization and setup.""" + +from unittest.mock import MagicMock + +from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.freeze_time("2025-01-31 00:00:00") +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test config entry loading/unloading.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_client.refresh_token.assert_not_called() # Didn't refresh valid token + mock_client.get_devices.assert_called_once() + mock_device_client.subscribe.assert_called_once() + mock_device_client.get_current_status.assert_called_once() + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + mock_device_client.disconnect.assert_called_once() + + +@pytest.mark.freeze_time("2025-02-15 00:00:00") +async def test_refresh_authentication_on_load( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test expired access token refreshed when needed to load config entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_client.refresh_token.assert_called_once() + + # Check loading continued as expected after refreshing token + mock_client.get_devices.assert_called_once() + mock_device_client.subscribe.assert_called_once() + mock_device_client.get_current_status.assert_called_once() + + +@pytest.mark.freeze_time("2025-03-01 00:00:00") +async def test_refresh_token_error_aborts( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test expired refresh token aborting config entry loading.""" + mock_client.refresh_token.side_effect = LetPotAuthenticationException + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + mock_client.refresh_token.assert_called_once() + mock_client.get_devices.assert_not_called() + + +@pytest.mark.parametrize( + ("exception", "config_entry_state"), + [ + (LetPotAuthenticationException, ConfigEntryState.SETUP_ERROR), + (LetPotConnectionException, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_get_devices_exceptions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + exception: Exception, + config_entry_state: ConfigEntryState, +) -> None: + """Test config entry errors if an exception is raised when getting devices.""" + mock_client.get_devices.side_effect = exception + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is config_entry_state + mock_client.get_devices.assert_called_once() + mock_device_client.subscribe.assert_not_called() From 941461b4274835c16d4cc085bac5b5e9d8b8a9a9 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 29 Jan 2025 02:43:41 +1000 Subject: [PATCH 1104/2987] Add streaming to Teslemetry number platform (#136048) --- homeassistant/components/teslemetry/number.py | 151 +++++++++++++++--- tests/components/teslemetry/__init__.py | 13 ++ .../teslemetry/snapshots/test_number.ambr | 6 + tests/components/teslemetry/test_number.py | 39 ++++- 4 files changed, 182 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index 9ba9c28b199..c44028f2da7 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -9,20 +9,33 @@ from typing import Any from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.number import ( NumberDeviceClass, NumberEntity, NumberEntityDescription, NumberMode, + RestoreNumber, +) +from homeassistant.const import ( + PERCENTAGE, + PRECISION_WHOLE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfElectricCurrent, ) -from homeassistant.const import PERCENTAGE, PRECISION_WHOLE, UnitOfElectricCurrent from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from . import TeslemetryConfigEntry -from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity +from .entity import ( + TeslemetryEnergyInfoEntity, + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -33,12 +46,22 @@ PARALLEL_UPDATES = 0 class TeslemetryNumberVehicleEntityDescription(NumberEntityDescription): """Describes Teslemetry Number entity.""" - func: Callable[[VehicleSpecific, float], Awaitable[Any]] - native_min_value: float - native_max_value: float + func: Callable[[VehicleSpecific, int], Awaitable[Any]] min_key: str | None = None max_key: str + native_min_value: float + native_max_value: float scopes: list[Scope] + value_listener: Callable[ + [TeslemetryStreamVehicle, Callable[[int | None], None]], + Callable[[], None], + ] + max_listener: ( + Callable[ + [TeslemetryStreamVehicle, Callable[[int | None], None]], Callable[[], None] + ] + | None + ) = None VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = ( @@ -52,7 +75,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = ( mode=NumberMode.AUTO, max_key="charge_state_charge_current_request_max", func=lambda api, value: api.set_charging_amps(value), - scopes=[Scope.VEHICLE_CHARGING_CMDS], + scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + value_listener=lambda x, y: x.listen_ChargeCurrentRequest(y), + max_listener=lambda x, y: x.listen_ChargeCurrentRequestMax(y), ), TeslemetryNumberVehicleEntityDescription( key="charge_state_charge_limit_soc", @@ -62,10 +87,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, device_class=NumberDeviceClass.BATTERY, mode=NumberMode.AUTO, - min_key="charge_state_charge_limit_soc_min", max_key="charge_state_charge_limit_soc_max", func=lambda api, value: api.set_charge_limit(value), scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + value_listener=lambda x, y: x.listen_ChargeLimitSoc(y), ), ) @@ -76,16 +101,29 @@ class TeslemetryNumberBatteryEntityDescription(NumberEntityDescription): func: Callable[[EnergySpecific, float], Awaitable[Any]] requires: str | None = None + scopes: list[Scope] ENERGY_INFO_DESCRIPTIONS: tuple[TeslemetryNumberBatteryEntityDescription, ...] = ( TeslemetryNumberBatteryEntityDescription( key="backup_reserve_percent", + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=100, + device_class=NumberDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + scopes=[Scope.ENERGY_CMDS], func=lambda api, value: api.backup(int(value)), requires="components_battery", ), TeslemetryNumberBatteryEntityDescription( key="off_grid_vehicle_charging_reserve_percent", + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=100, + device_class=NumberDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + scopes=[Scope.ENERGY_CMDS], func=lambda api, value: api.off_grid_vehicle_charging_reserve(int(value)), requires="components_off_grid_vehicle_charging_reserve_supported", ), @@ -101,8 +139,14 @@ async def async_setup_entry( async_add_entities( chain( - ( # Add vehicle entities - TeslemetryVehicleNumberEntity( + ( + TeslemetryPollingNumberEntity( + vehicle, + description, + entry.runtime_data.scopes, + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingNumberEntity( vehicle, description, entry.runtime_data.scopes, @@ -110,7 +154,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles for description in VEHICLE_DESCRIPTIONS ), - ( # Add energy site entities + ( TeslemetryEnergyInfoNumberSensorEntity( energysite, description, @@ -125,11 +169,25 @@ async def async_setup_entry( ) -class TeslemetryVehicleNumberEntity(TeslemetryVehicleEntity, NumberEntity): +class TeslemetryVehicleNumberEntity(TeslemetryRootEntity, NumberEntity): """Vehicle number entity base class.""" entity_description: TeslemetryNumberVehicleEntityDescription + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + value = int(value) + self.raise_for_scope(self.entity_description.scopes[0]) + await handle_vehicle_command(self.entity_description.func(self.api, value)) + self._attr_native_value = value + self.async_write_ha_state() + + +class TeslemetryPollingNumberEntity( + TeslemetryVehicleEntity, TeslemetryVehicleNumberEntity +): + """Vehicle polling number entity.""" + def __init__( self, data: TeslemetryVehicleData, @@ -148,26 +206,67 @@ class TeslemetryVehicleNumberEntity(TeslemetryVehicleEntity, NumberEntity): """Update the attributes of the entity.""" self._attr_native_value = self._value - if (min_key := self.entity_description.min_key) is not None: - self._attr_native_min_value = self.get_number( - min_key, - self.entity_description.native_min_value, - ) - else: - self._attr_native_min_value = self.entity_description.native_min_value - self._attr_native_max_value = self.get_number( self.entity_description.max_key, self.entity_description.native_max_value, ) - async def async_set_native_value(self, value: float) -> None: - """Set new value.""" - value = int(value) - self.raise_for_scope(self.entity_description.scopes[0]) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.entity_description.func(self.api, value)) - self._attr_native_value = value + +class TeslemetryStreamingNumberEntity( + TeslemetryVehicleStreamEntity, TeslemetryVehicleNumberEntity, RestoreNumber +): + """Number entity for current charge.""" + + entity_description: TeslemetryNumberVehicleEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryNumberVehicleEntityDescription, + scopes: list[Scope], + ) -> None: + """Initialize the Number entity.""" + self.scoped = any(scope in scopes for scope in description.scopes) + self.entity_description = description + self._attr_native_max_value = self.entity_description.native_max_value + super().__init__(data, description.key) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore state + if (last_state := await self.async_get_last_state()) and ( + last_number_data := await self.async_get_last_number_data() + ): + if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): + self._attr_native_value = last_number_data.native_value + if last_number_data.native_max_value: + self._attr_native_max_value = last_number_data.native_max_value + + # Add listeners + self.async_on_remove( + self.entity_description.value_listener( + self.vehicle.stream_vehicle, self._value_callback + ) + ) + if self.entity_description.max_listener: + self.async_on_remove( + self.entity_description.max_listener( + self.vehicle.stream_vehicle, self._max_callback + ) + ) + + def _value_callback(self, value: int | None) -> None: + """Update the value of the entity.""" + self._attr_native_value = None if value is None else value + self.async_write_ha_state() + + def _max_callback(self, value: int | None) -> None: + """Update the value of the entity.""" + self._attr_native_max_value = ( + self.entity_description.native_max_value if value is None else value + ) self.async_write_ha_state() diff --git a/tests/components/teslemetry/__init__.py b/tests/components/teslemetry/__init__.py index b6b9df7eb4b..b5aae06168c 100644 --- a/tests/components/teslemetry/__init__.py +++ b/tests/components/teslemetry/__init__.py @@ -32,6 +32,19 @@ async def setup_platform(hass: HomeAssistant, platforms: list[Platform] | None = return mock_entry +async def reload_platform( + hass: HomeAssistant, entry: MockConfigEntry, platforms: list[Platform] | None = None +): + """Reload the Teslemetry platform.""" + + if platforms is None: + await hass.config_entries.async_reload(entry.entry_id) + else: + with patch("homeassistant.components.teslemetry.PLATFORMS", platforms): + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + def assert_entities( hass: HomeAssistant, entry_id: str, diff --git a/tests/components/teslemetry/snapshots/test_number.ambr b/tests/components/teslemetry/snapshots/test_number.ambr index 0f30daf635e..8e8f10397d0 100644 --- a/tests/components/teslemetry/snapshots/test_number.ambr +++ b/tests/components/teslemetry/snapshots/test_number.ambr @@ -229,3 +229,9 @@ 'state': '80', }) # --- +# name: test_number_streaming[number.test_charge_current-state] + '24' +# --- +# name: test_number_streaming[number.test_charge_limit-state] + '99' +# --- diff --git a/tests/components/teslemetry/test_number.py b/tests/components/teslemetry/test_number.py index 65c03514d22..95eed5a3f1e 100644 --- a/tests/components/teslemetry/test_number.py +++ b/tests/components/teslemetry/test_number.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.number import ( ATTR_VALUE, @@ -14,7 +15,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform +from . import assert_entities, reload_platform, setup_platform from .const import COMMAND_OK, VEHICLE_DATA_ALT @@ -23,6 +24,7 @@ async def test_number( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the number entities are correct.""" @@ -100,3 +102,38 @@ async def test_number_services( state = hass.states.get(entity_id) assert state.state == "88" call.assert_called_once() + + +async def test_number_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the number entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.NUMBER]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.CHARGE_CURRENT_REQUEST: 24, + Signal.CHARGE_CURRENT_REQUEST_MAX: 32, + Signal.CHARGE_LIMIT_SOC: 99, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + await reload_platform(hass, entry, [Platform.NUMBER]) + + # Assert the entities restored their values + for entity_id in ( + "number.test_charge_current", + "number.test_charge_limit", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-state") From 77d42f6c576ca4b6c5b428b7517895e66425c524 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 29 Jan 2025 02:44:05 +1000 Subject: [PATCH 1105/2987] Add streaming to Teslemetry lock platform (#136037) --- homeassistant/components/teslemetry/lock.py | 174 +++++++++++++++--- tests/components/teslemetry/__init__.py | 4 +- .../teslemetry/fixtures/vehicle_data_alt.json | 2 +- .../teslemetry/snapshots/test_lock.ambr | 106 +++++++++++ tests/components/teslemetry/test_lock.py | 79 +++++++- 5 files changed, 330 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py index 4600391145b..18b88273bec 100644 --- a/homeassistant/components/teslemetry/lock.py +++ b/homeassistant/components/teslemetry/lock.py @@ -2,6 +2,7 @@ from __future__ import annotations +from itertools import chain from typing import Any from tesla_fleet_api.const import Scope @@ -10,10 +11,15 @@ from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry from .const import DOMAIN -from .entity import TeslemetryVehicleEntity +from .entity import ( + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_vehicle_command from .models import TeslemetryVehicleData @@ -30,31 +36,38 @@ async def async_setup_entry( """Set up the Teslemetry lock platform from a config entry.""" async_add_entities( - klass(vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes) - for klass in ( - TeslemetryVehicleLockEntity, - TeslemetryCableLockEntity, + chain( + ( + TeslemetryPollingVehicleLockEntity( + vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingVehicleLockEntity( + vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryPollingCableLockEntity( + vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingCableLockEntity( + vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), ) - for vehicle in entry.runtime_data.vehicles ) -class TeslemetryVehicleLockEntity(TeslemetryVehicleEntity, LockEntity): - """Lock entity for Teslemetry.""" - - def __init__(self, data: TeslemetryVehicleData, scoped: bool) -> None: - """Initialize the lock.""" - super().__init__(data, "vehicle_state_locked") - self.scoped = scoped - - def _async_update_attrs(self) -> None: - """Update entity attributes.""" - self._attr_is_locked = self._value +class TeslemetryVehicleLockEntity(TeslemetryRootEntity, LockEntity): + """Base vehicle lock entity for Teslemetry.""" async def async_lock(self, **kwargs: Any) -> None: """Lock the doors.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.door_lock()) self._attr_is_locked = True self.async_write_ha_state() @@ -62,27 +75,65 @@ class TeslemetryVehicleLockEntity(TeslemetryVehicleEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the doors.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.door_unlock()) self._attr_is_locked = False self.async_write_ha_state() -class TeslemetryCableLockEntity(TeslemetryVehicleEntity, LockEntity): - """Cable Lock entity for Teslemetry.""" +class TeslemetryPollingVehicleLockEntity( + TeslemetryVehicleEntity, TeslemetryVehicleLockEntity +): + """Polling vehicle lock entity for Teslemetry.""" - def __init__( - self, - data: TeslemetryVehicleData, - scoped: bool, - ) -> None: - """Initialize the lock.""" - super().__init__(data, "charge_state_charge_port_latch") + def __init__(self, data: TeslemetryVehicleData, scoped: bool) -> None: + """Initialize the sensor.""" + super().__init__( + data, + "vehicle_state_locked", + ) self.scoped = scoped def _async_update_attrs(self) -> None: """Update entity attributes.""" - self._attr_is_locked = self._value == ENGAGED + self._attr_is_locked = self._value + + +class TeslemetryStreamingVehicleLockEntity( + TeslemetryVehicleStreamEntity, TeslemetryVehicleLockEntity, RestoreEntity +): + """Streaming vehicle lock entity for Teslemetry.""" + + def __init__(self, data: TeslemetryVehicleData, scoped: bool) -> None: + """Initialize the sensor.""" + super().__init__( + data, + "vehicle_state_locked", + ) + self.scoped = scoped + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore state + if (state := await self.async_get_last_state()) is not None: + if state.state == "locked": + self._attr_is_locked = True + elif state.state == "unlocked": + self._attr_is_locked = False + + # Add streaming listener + self.async_on_remove(self.vehicle.stream_vehicle.listen_Locked(self._callback)) + + def _callback(self, value: bool | None) -> None: + """Update entity attributes.""" + self._attr_is_locked = value + self.async_write_ha_state() + + +class TeslemetryCableLockEntity(TeslemetryRootEntity, LockEntity): + """Base cable Lock entity for Teslemetry.""" async def async_lock(self, **kwargs: Any) -> None: """Charge cable Lock cannot be manually locked.""" @@ -95,7 +146,70 @@ class TeslemetryCableLockEntity(TeslemetryVehicleEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock charge cable lock.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.charge_port_door_open()) self._attr_is_locked = False self.async_write_ha_state() + + +class TeslemetryPollingCableLockEntity( + TeslemetryVehicleEntity, TeslemetryCableLockEntity +): + """Polling cable lock entity for Teslemetry.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scoped: bool, + ) -> None: + """Initialize the sensor.""" + super().__init__( + data, + "charge_state_charge_port_latch", + ) + self.scoped = scoped + + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + if self._value is None: + self._attr_is_locked = None + self._attr_is_locked = self._value == ENGAGED + + +class TeslemetryStreamingCableLockEntity( + TeslemetryVehicleStreamEntity, TeslemetryCableLockEntity, RestoreEntity +): + """Streaming cable lock entity for Teslemetry.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scoped: bool, + ) -> None: + """Initialize the sensor.""" + super().__init__( + data, + "charge_state_charge_port_latch", + ) + self.scoped = scoped + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore state + if (state := await self.async_get_last_state()) is not None: + if state.state == "locked": + self._attr_is_locked = True + elif state.state == "unlocked": + self._attr_is_locked = False + + # Add streaming listener + self.async_on_remove( + self.vehicle.stream_vehicle.listen_ChargePortLatch(self._callback) + ) + + def _callback(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_is_locked = None if value is None else value == ENGAGED + self.async_write_ha_state() diff --git a/tests/components/teslemetry/__init__.py b/tests/components/teslemetry/__init__.py index b5aae06168c..59727926f03 100644 --- a/tests/components/teslemetry/__init__.py +++ b/tests/components/teslemetry/__init__.py @@ -14,7 +14,9 @@ from .const import CONFIG from tests.common import MockConfigEntry -async def setup_platform(hass: HomeAssistant, platforms: list[Platform] | None = None): +async def setup_platform( + hass: HomeAssistant, platforms: list[Platform] | None = None +) -> MockConfigEntry: """Set up the Teslemetry platform.""" mock_entry = MockConfigEntry( diff --git a/tests/components/teslemetry/fixtures/vehicle_data_alt.json b/tests/components/teslemetry/fixtures/vehicle_data_alt.json index 25b3878f4dd..ec524614d49 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data_alt.json +++ b/tests/components/teslemetry/fixtures/vehicle_data_alt.json @@ -35,7 +35,7 @@ "charge_port_cold_weather_mode": false, "charge_port_color": "", "charge_port_door_open": true, - "charge_port_latch": "Engaged", + "charge_port_latch": null, "charge_rate": 0, "charger_actual_current": 0, "charger_phases": null, diff --git a/tests/components/teslemetry/snapshots/test_lock.ambr b/tests/components/teslemetry/snapshots/test_lock.ambr index 2130c4d9574..bb5693fe3ab 100644 --- a/tests/components/teslemetry/snapshots/test_lock.ambr +++ b/tests/components/teslemetry/snapshots/test_lock.ambr @@ -93,3 +93,109 @@ 'state': 'unlocked', }) # --- +# name: test_lock_alt[lock.test_charge_cable_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_charge_cable_lock', + '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': 'Charge cable lock', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_port_latch', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_latch', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock_alt[lock.test_charge_cable_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge cable lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_charge_cable_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_lock_alt[lock.test_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_lock', + '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': 'Lock', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_locked', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock_alt[lock.test_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_lock_streaming[lock.test_charge_cable_lock-locked] + 'locked' +# --- +# name: test_lock_streaming[lock.test_charge_cable_lock-unlocked] + 'unlocked' +# --- +# name: test_lock_streaming[lock.test_lock-locked] + 'locked' +# --- +# name: test_lock_streaming[lock.test_lock-unlocked] + 'unlocked' +# --- diff --git a/tests/components/teslemetry/test_lock.py b/tests/components/teslemetry/test_lock.py index f7c9fea1400..848eee82c39 100644 --- a/tests/components/teslemetry/test_lock.py +++ b/tests/components/teslemetry/test_lock.py @@ -1,9 +1,10 @@ """Test the Teslemetry lock platform.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream.const import Signal from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, @@ -16,14 +17,15 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform -from .const import COMMAND_OK +from . import assert_entities, reload_platform, setup_platform +from .const import COMMAND_OK, VEHICLE_DATA_ALT async def test_lock( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the lock entities are correct.""" @@ -31,6 +33,20 @@ async def test_lock( assert_entities(hass, entry.entry_id, entity_registry, snapshot) +async def test_lock_alt( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, +) -> None: + """Tests that the lock entities are correct.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entry = await setup_platform(hass, [Platform.LOCK]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + async def test_lock_services( hass: HomeAssistant, ) -> None: @@ -91,3 +107,60 @@ async def test_lock_services( state = hass.states.get(entity_id) assert state.state == LockState.UNLOCKED call.assert_called_once() + + +async def test_lock_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the lock entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.LOCK]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.LOCKED: True, + Signal.CHARGE_PORT_LATCH: "ChargePortLatchEngaged", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + await reload_platform(hass, entry, [Platform.LOCK]) + + # Assert the entities restored their values + for entity_id in ( + "lock.test_lock", + "lock.test_charge_cable_lock", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-locked") + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.LOCKED: False, + Signal.CHARGE_PORT_LATCH: "ChargePortLatchDisengaged", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + await reload_platform(hass, entry, [Platform.LOCK]) + + # Assert the entities restored their values + for entity_id in ( + "lock.test_lock", + "lock.test_charge_cable_lock", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-unlocked") From c3db493f34023adc9d249d456485cb44d85b58a0 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:50:06 +0000 Subject: [PATCH 1106/2987] Mark tplink quality_scale platinum (#136456) --- homeassistant/components/tplink/manifest.json | 1 + homeassistant/components/tplink/quality_scale.yaml | 4 ++-- script/hassfest/quality_scale.py | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index f55dfda1664..6f9eefbdabb 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -300,5 +300,6 @@ "documentation": "https://www.home-assistant.io/integrations/tplink", "iot_class": "local_polling", "loggers": ["kasa"], + "quality_scale": "platinum", "requirements": ["python-kasa[speedups]==0.10.0"] } diff --git a/homeassistant/components/tplink/quality_scale.yaml b/homeassistant/components/tplink/quality_scale.yaml index ced9cbcc831..f120945771c 100644 --- a/homeassistant/components/tplink/quality_scale.yaml +++ b/homeassistant/components/tplink/quality_scale.yaml @@ -44,12 +44,12 @@ rules: entity-category: done entity-disabled-by-default: done discovery: done - stale-devices: todo + stale-devices: done diagnostics: done exception-translations: done icon-translations: done reconfiguration-flow: done - dynamic-devices: todo + dynamic-devices: done discovery-update-info: done repair-issues: done docs-use-cases: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 706a482523a..3eedc43f613 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2131,7 +2131,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "torque", "touchline", "touchline_sl", - "tplink", "tplink_lte", "tplink_omada", "traccar", From a8c382566cae06c43a33c4d3d44c9bc92ef7b4d8 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 28 Jan 2025 17:57:02 +0100 Subject: [PATCH 1107/2987] Register service actions in async_setup of AVM Fritz!Box tools (#136380) * move service setup into integrations async_setup * move back to own module * add service test * remove unneccessary CONFIG_SCHEMA * remove unused constant FRITZ_SERVICES * Revert "remove unneccessary CONFIG_SCHEMA" This reverts commit cce1ba76a067895d62d0485479002c7bebbfb511. * remove useless CONFIG_SCHEMA from services.py * move logic of `service_fritzbox` into services.py * add more service tests * simplify logic, use ServiceValidationError --- homeassistant/components/fritz/__init__.py | 16 ++- homeassistant/components/fritz/const.py | 3 - homeassistant/components/fritz/coordinator.py | 34 +---- .../components/fritz/quality_scale.yaml | 4 +- homeassistant/components/fritz/services.py | 117 +++++++-------- tests/components/fritz/test_services.py | 134 ++++++++++++++++++ 6 files changed, 197 insertions(+), 111 deletions(-) create mode 100644 tests/components/fritz/test_services.py diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index 1e1830ca1c1..25888328cd2 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -12,6 +12,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import ( DATA_FRITZ, @@ -22,10 +24,18 @@ from .const import ( PLATFORMS, ) from .coordinator import AvmWrapper, FritzData -from .services import async_setup_services, async_unload_services +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up fritzboxtools integration.""" + await async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up fritzboxtools from config entry.""" @@ -65,8 +75,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Load the other platforms like switch await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - await async_setup_services(hass) - return True @@ -84,8 +92,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) - await async_unload_services(hass) - return unload_ok diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 9a266507c25..f8f5b43f4b1 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -56,9 +56,6 @@ ERROR_CANNOT_CONNECT = "cannot_connect" ERROR_UPNP_NOT_CONFIGURED = "upnp_not_configured" ERROR_UNKNOWN = "unknown_error" -FRITZ_SERVICES = "fritz_services" -SERVICE_SET_GUEST_WIFI_PW = "set_guest_wifi_password" - SWITCH_TYPE_DEFLECTION = "CallDeflection" SWITCH_TYPE_PORTFORWARD = "PortForward" SWITCH_TYPE_PROFILE = "Profile" diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 52bff67c229..7f8ae6c5b3c 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -16,11 +16,10 @@ from fritzconnection.core.exceptions import ( FritzActionError, FritzConnectionException, FritzSecurityError, - FritzServiceError, ) from fritzconnection.lib.fritzhosts import FritzHosts from fritzconnection.lib.fritzstatus import FritzStatus -from fritzconnection.lib.fritzwlan import DEFAULT_PASSWORD_LENGTH, FritzGuestWLAN +from fritzconnection.lib.fritzwlan import FritzGuestWLAN import xmltodict from homeassistant.components.device_tracker import ( @@ -29,7 +28,7 @@ from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC @@ -46,7 +45,6 @@ from .const import ( DEFAULT_USERNAME, DOMAIN, FRITZ_EXCEPTIONS, - SERVICE_SET_GUEST_WIFI_PW, MeshRoles, ) @@ -693,34 +691,6 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): device.id, remove_config_entry_id=config_entry.entry_id ) - async def service_fritzbox( - self, service_call: ServiceCall, config_entry: ConfigEntry - ) -> None: - """Define FRITZ!Box services.""" - _LOGGER.debug("FRITZ!Box service: %s", service_call.service) - - if not self.connection: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="unable_to_connect" - ) - - try: - if service_call.service == SERVICE_SET_GUEST_WIFI_PW: - await self.async_trigger_set_guest_password( - service_call.data.get("password"), - service_call.data.get("length", DEFAULT_PASSWORD_LENGTH), - ) - return - - except (FritzServiceError, FritzActionError) as ex: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="service_parameter_unknown" - ) from ex - except FritzConnectionException as ex: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="service_not_supported" - ) from ex - class AvmWrapper(FritzBoxTools): """Setup AVM wrapper for API calls.""" diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index 06c572f93a6..d6fadd3a20e 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -1,8 +1,6 @@ rules: # Bronze - action-setup: - status: todo - comment: still in async_setup_entry, needs to be moved to async_setup + action-setup: done appropriate-polling: done brands: done common-modules: done diff --git a/homeassistant/components/fritz/services.py b/homeassistant/components/fritz/services.py index bace7480ba5..ac542be8631 100644 --- a/homeassistant/components/fritz/services.py +++ b/homeassistant/components/fritz/services.py @@ -1,21 +1,25 @@ """Services for Fritz integration.""" -from __future__ import annotations - import logging +from fritzconnection.core.exceptions import ( + FritzActionError, + FritzConnectionException, + FritzServiceError, +) +from fritzconnection.lib.fritzwlan import DEFAULT_PASSWORD_LENGTH import voluptuous as vol -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.service import async_extract_config_entry_ids -from .const import DOMAIN, FRITZ_SERVICES, SERVICE_SET_GUEST_WIFI_PW +from .const import DOMAIN from .coordinator import AvmWrapper _LOGGER = logging.getLogger(__name__) +SERVICE_SET_GUEST_WIFI_PW = "set_guest_wifi_password" SERVICE_SCHEMA_SET_GUEST_WIFI_PW = vol.Schema( { vol.Required("device_id"): str, @@ -24,71 +28,48 @@ SERVICE_SCHEMA_SET_GUEST_WIFI_PW = vol.Schema( } ) -SERVICE_LIST: list[tuple[str, vol.Schema | None]] = [ - (SERVICE_SET_GUEST_WIFI_PW, SERVICE_SCHEMA_SET_GUEST_WIFI_PW), -] + +async def _async_set_guest_wifi_password(service_call: ServiceCall) -> None: + """Call Fritz set guest wifi password service.""" + hass = service_call.hass + target_entry_ids = await async_extract_config_entry_ids(hass, service_call) + target_entries = [ + loaded_entry + for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN) + if loaded_entry.entry_id in target_entry_ids + ] + + if not target_entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + translation_placeholders={"service": service_call.service}, + ) + + for target_entry in target_entries: + _LOGGER.debug("Executing service %s", service_call.service) + avm_wrapper: AvmWrapper = hass.data[DOMAIN][target_entry.entry_id] + try: + await avm_wrapper.async_trigger_set_guest_password( + service_call.data.get("password"), + service_call.data.get("length", DEFAULT_PASSWORD_LENGTH), + ) + except (FritzServiceError, FritzActionError) as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="service_parameter_unknown" + ) from ex + except FritzConnectionException as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="service_not_supported" + ) from ex async def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Fritz integration.""" - for service, _ in SERVICE_LIST: - if hass.services.has_service(DOMAIN, service): - return - - async def async_call_fritz_service(service_call: ServiceCall) -> None: - """Call correct Fritz service.""" - - if not ( - fritzbox_entry_ids := await _async_get_configured_avm_device( - hass, service_call - ) - ): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="config_entry_not_found", - translation_placeholders={"service": service_call.service}, - ) - - for entry_id in fritzbox_entry_ids: - _LOGGER.debug("Executing service %s", service_call.service) - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry_id] - if config_entry := hass.config_entries.async_get_entry(entry_id): - await avm_wrapper.service_fritzbox(service_call, config_entry) - else: - _LOGGER.error( - "Executing service %s failed, no config entry found", - service_call.service, - ) - - for service, schema in SERVICE_LIST: - hass.services.async_register(DOMAIN, service, async_call_fritz_service, schema) - - -async def _async_get_configured_avm_device( - hass: HomeAssistant, service_call: ServiceCall -) -> list: - """Get FritzBoxTools class from config entry.""" - - list_entry_id: list = [] - for entry_id in await async_extract_config_entry_ids(hass, service_call): - config_entry = hass.config_entries.async_get_entry(entry_id) - if ( - config_entry - and config_entry.domain == DOMAIN - and config_entry.state == ConfigEntryState.LOADED - ): - list_entry_id.append(entry_id) - return list_entry_id - - -async def async_unload_services(hass: HomeAssistant) -> None: - """Unload services for Fritz integration.""" - - if not hass.data.get(FRITZ_SERVICES): - return - - hass.data[FRITZ_SERVICES] = False - - for service, _ in SERVICE_LIST: - hass.services.async_remove(DOMAIN, service) + hass.services.async_register( + DOMAIN, + SERVICE_SET_GUEST_WIFI_PW, + _async_set_guest_wifi_password, + SERVICE_SCHEMA_SET_GUEST_WIFI_PW, + ) diff --git a/tests/components/fritz/test_services.py b/tests/components/fritz/test_services.py new file mode 100644 index 00000000000..d7b85cbc448 --- /dev/null +++ b/tests/components/fritz/test_services.py @@ -0,0 +1,134 @@ +"""Tests for Fritz!Tools services.""" + +from unittest.mock import patch + +from fritzconnection.core.exceptions import FritzConnectionException, FritzServiceError +import pytest + +from homeassistant.components.fritz.const import DOMAIN +from homeassistant.components.fritz.services import SERVICE_SET_GUEST_WIFI_PW +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from .const import MOCK_USER_DATA + +from tests.common import MockConfigEntry + + +async def test_setup_services(hass: HomeAssistant) -> None: + """Test setup of Fritz!Tools services.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + services = hass.services.async_services_for_domain(DOMAIN) + assert services + assert SERVICE_SET_GUEST_WIFI_PW in services + + +async def test_service_set_guest_wifi_password( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + fc_class_mock, + fh_class_mock, +) -> None: + """Test service set_guest_wifi_password.""" + assert await async_setup_component(hass, DOMAIN, {}) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "1C:ED:6F:12:34:11")} + ) + assert device + with patch( + "homeassistant.components.fritz.coordinator.AvmWrapper.async_trigger_set_guest_password" + ) as mock_async_trigger_set_guest_password: + await hass.services.async_call( + DOMAIN, SERVICE_SET_GUEST_WIFI_PW, {"device_id": device.id} + ) + assert mock_async_trigger_set_guest_password.called + + +async def test_service_set_guest_wifi_password_unknown_parameter( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + fc_class_mock, + fh_class_mock, +) -> None: + """Test service set_guest_wifi_password with unknown parameter.""" + assert await async_setup_component(hass, DOMAIN, {}) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "1C:ED:6F:12:34:11")} + ) + assert device + + with patch( + "homeassistant.components.fritz.coordinator.AvmWrapper.async_trigger_set_guest_password", + side_effect=FritzServiceError("boom"), + ) as mock_async_trigger_set_guest_password: + await hass.services.async_call( + DOMAIN, SERVICE_SET_GUEST_WIFI_PW, {"device_id": device.id} + ) + assert mock_async_trigger_set_guest_password.called + assert "HomeAssistantError: Action or parameter unknown" in caplog.text + + +async def test_service_set_guest_wifi_password_service_not_supported( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + fc_class_mock, + fh_class_mock, +) -> None: + """Test service set_guest_wifi_password with connection error.""" + assert await async_setup_component(hass, DOMAIN, {}) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "1C:ED:6F:12:34:11")} + ) + assert device + + with patch( + "homeassistant.components.fritz.coordinator.AvmWrapper.async_trigger_set_guest_password", + side_effect=FritzConnectionException("boom"), + ) as mock_async_trigger_set_guest_password: + await hass.services.async_call( + DOMAIN, SERVICE_SET_GUEST_WIFI_PW, {"device_id": device.id} + ) + assert mock_async_trigger_set_guest_password.called + assert "HomeAssistantError: Action not supported" in caplog.text + + +async def test_service_set_guest_wifi_password_unloaded( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test service set_guest_wifi_password.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.fritz.coordinator.AvmWrapper.async_trigger_set_guest_password" + ) as mock_async_trigger_set_guest_password: + await hass.services.async_call( + DOMAIN, SERVICE_SET_GUEST_WIFI_PW, {"device_id": "12345678"} + ) + assert not mock_async_trigger_set_guest_password.called + assert ( + 'ServiceValidationError: Failed to perform action "set_guest_wifi_password". Config entry for target not found' + in caplog.text + ) From cb407bdfc675d62bb4f5c52ef0acc825798f6237 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 28 Jan 2025 19:09:49 +0100 Subject: [PATCH 1108/2987] Add support for HomeWizard Plug-In Battery and v2 API (#136733) --- .../components/homewizard/__init__.py | 34 +- .../components/homewizard/config_flow.py | 205 +++-- homeassistant/components/homewizard/const.py | 2 - .../components/homewizard/coordinator.py | 6 +- .../components/homewizard/icons.json | 3 + .../components/homewizard/manifest.json | 4 +- .../components/homewizard/quality_scale.yaml | 10 +- .../components/homewizard/repairs.py | 79 ++ homeassistant/components/homewizard/sensor.py | 269 ++++--- .../components/homewizard/strings.json | 43 +- homeassistant/components/homewizard/switch.py | 2 +- homeassistant/generated/zeroconf.py | 5 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/homewizard/conftest.py | 78 +- .../homewizard/fixtures/HWE-BAT/data.json | 12 + .../homewizard/fixtures/HWE-BAT/device.json | 7 + .../homewizard/fixtures/HWE-BAT/system.json | 7 + .../homewizard/fixtures/v2/HWE-P1/device.json | 7 + .../fixtures/v2/HWE-P1/measurement.json | 48 ++ .../homewizard/fixtures/v2/HWE-P1/system.json | 8 + .../homewizard/fixtures/v2/generic/token.json | 4 + .../snapshots/test_diagnostics.ambr | 106 ++- .../homewizard/snapshots/test_sensor.ambr | 700 ++++++++++++++++++ .../components/homewizard/test_config_flow.py | 292 +++++++- .../components/homewizard/test_diagnostics.py | 1 + tests/components/homewizard/test_init.py | 54 +- tests/components/homewizard/test_repair.py | 82 ++ tests/components/homewizard/test_sensor.py | 83 +++ 29 files changed, 1916 insertions(+), 239 deletions(-) create mode 100644 homeassistant/components/homewizard/repairs.py create mode 100644 tests/components/homewizard/fixtures/HWE-BAT/data.json create mode 100644 tests/components/homewizard/fixtures/HWE-BAT/device.json create mode 100644 tests/components/homewizard/fixtures/HWE-BAT/system.json create mode 100644 tests/components/homewizard/fixtures/v2/HWE-P1/device.json create mode 100644 tests/components/homewizard/fixtures/v2/HWE-P1/measurement.json create mode 100644 tests/components/homewizard/fixtures/v2/HWE-P1/system.json create mode 100644 tests/components/homewizard/fixtures/v2/generic/token.json create mode 100644 tests/components/homewizard/test_repair.py diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index a911f5398da..1f29be8e6b6 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -1,12 +1,18 @@ """The Homewizard integration.""" -from homewizard_energy import HomeWizardEnergy, HomeWizardEnergyV1, HomeWizardEnergyV2 +from homewizard_energy import ( + HomeWizardEnergy, + HomeWizardEnergyV1, + HomeWizardEnergyV2, + has_v2_api, +) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN, PLATFORMS from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -31,6 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - clientsession=async_get_clientsession(hass), ) + await async_check_v2_support_and_create_issue(hass, entry) + coordinator = HWEnergyDeviceUpdateCoordinator(hass, api) try: await coordinator.async_config_entry_first_refresh() @@ -63,3 +71,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - async def async_unload_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_check_v2_support_and_create_issue( + hass: HomeAssistant, entry: HomeWizardConfigEntry +) -> None: + """Check if the device supports v2 and create an issue if not.""" + + if not await has_v2_api(entry.data[CONF_IP_ADDRESS], async_get_clientsession(hass)): + return + + async_create_issue( + hass, + DOMAIN, + f"migrate_to_v2_api_{entry.entry_id}", + is_fixable=True, + is_persistent=False, + learn_more_url="https://home-assistant.io/integrations/homewizard/#which-button-do-i-need-to-press-to-configure-the-device", + translation_key="migrate_to_v2_api", + translation_placeholders={ + "title": entry.title, + }, + severity=IssueSeverity.WARNING, + data={"entry_id": entry.entry_id}, + ) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index fe78385381c..c94f590f000 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -5,28 +5,31 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from homewizard_energy import HomeWizardEnergyV1 -from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError +from homewizard_energy import ( + HomeWizardEnergy, + HomeWizardEnergyV1, + HomeWizardEnergyV2, + has_v2_api, +) +from homewizard_energy.errors import ( + DisabledError, + RequestError, + UnauthorizedError, + UnsupportedError, +) from homewizard_energy.models import Device import voluptuous as vol from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_IP_ADDRESS, CONF_PATH +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import TextSelector from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import ( - CONF_API_ENABLED, - CONF_PRODUCT_NAME, - CONF_PRODUCT_TYPE, - CONF_SERIAL, - DOMAIN, - LOGGER, -) +from .const import CONF_PRODUCT_NAME, CONF_PRODUCT_TYPE, CONF_SERIAL, DOMAIN, LOGGER class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): @@ -46,10 +49,14 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] | None = None if user_input is not None: try: - device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS]) + device_info = await async_try_connect(user_input[CONF_IP_ADDRESS]) except RecoverableError as ex: LOGGER.error(ex) errors = {"base": ex.error_code} + except UnauthorizedError: + # Device responded, so IP is correct. But we have to authorize + self.ip_address = user_input[CONF_IP_ADDRESS] + return await self.async_step_authorize() else: await self.async_set_unique_id( f"{device_info.product_type}_{device_info.serial}" @@ -73,22 +80,54 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_authorize( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step where we attempt to get a token.""" + assert self.ip_address + + # 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) + errors: dict[str, str] | None = None + + if token is None: + if user_input is not None: + errors = {"base": "authorization_failed"} + + return self.async_show_form(step_id="authorize", errors=errors) + + # Now we got a token, we can ask for some more info + + async with HomeWizardEnergyV2(self.ip_address, token=token) as api: + device_info = await api.device() + + data = { + CONF_IP_ADDRESS: self.ip_address, + CONF_TOKEN: token, + } + + await self.async_set_unique_id( + f"{device_info.product_type}_{device_info.serial}" + ) + self._abort_if_unique_id_configured(updates=data) + return self.async_create_entry( + title=f"{device_info.product_name}", + data=data, + ) + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" + if ( - CONF_API_ENABLED not in discovery_info.properties - or CONF_PATH not in discovery_info.properties - or CONF_PRODUCT_NAME not in discovery_info.properties + CONF_PRODUCT_NAME not in discovery_info.properties or CONF_PRODUCT_TYPE not in discovery_info.properties or CONF_SERIAL not in discovery_info.properties ): return self.async_abort(reason="invalid_discovery_parameters") - if (discovery_info.properties[CONF_PATH]) != "/api/v1": - return self.async_abort(reason="unsupported_api_version") - self.ip_address = discovery_info.host self.product_type = discovery_info.properties[CONF_PRODUCT_TYPE] self.product_name = discovery_info.properties[CONF_PRODUCT_NAME] @@ -109,10 +148,12 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): This flow is triggered only by DHCP discovery of known devices. """ try: - device = await self._async_try_connect(discovery_info.ip) + device = await async_try_connect(discovery_info.ip) except RecoverableError as ex: LOGGER.error(ex) return self.async_abort(reason="unknown") + except UnauthorizedError: + return self.async_abort(reason="unsupported_api_version") await self.async_set_unique_id( f"{device.product_type}_{discovery_info.macaddress}" @@ -139,10 +180,12 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] | None = None if user_input is not None or not onboarding.async_is_onboarded(self.hass): try: - await self._async_try_connect(self.ip_address) + await async_try_connect(self.ip_address) except RecoverableError as ex: LOGGER.error(ex) errors = {"base": ex.error_code} + except UnauthorizedError: + return await self.async_step_authorize() else: return self.async_create_entry( title=self.product_name, @@ -172,25 +215,57 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle re-auth if API was disabled.""" - return await self.async_step_reauth_confirm() + self.ip_address = entry_data[CONF_IP_ADDRESS] - async def async_step_reauth_confirm( + # If token exists, we assume we use the v2 API and that the token has been invalidated + if entry_data.get(CONF_TOKEN): + return await self.async_step_reauth_confirm_update_token() + + # Else we assume we use the v1 API and that the API has been disabled + return await self.async_step_reauth_enable_api() + + async def async_step_reauth_enable_api( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Confirm reauth dialog.""" + """Confirm reauth dialog, where user is asked to re-enable the HomeWizard API.""" errors: dict[str, str] | None = None if user_input is not None: reauth_entry = self._get_reauth_entry() try: - await self._async_try_connect(reauth_entry.data[CONF_IP_ADDRESS]) + await async_try_connect(reauth_entry.data[CONF_IP_ADDRESS]) except RecoverableError as ex: LOGGER.error(ex) errors = {"base": ex.error_code} else: await self.hass.config_entries.async_reload(reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") + return self.async_abort(reason="reauth_enable_api_successful") - return self.async_show_form(step_id="reauth_confirm", errors=errors) + return self.async_show_form(step_id="reauth_enable_api", errors=errors) + + async def async_step_reauth_confirm_update_token( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + assert self.ip_address + + errors: dict[str, str] | None = None + + token = await async_request_token(self.ip_address) + + if user_input is not None: + if token is None: + errors = {"base": "authorization_failed"} + else: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={ + CONF_TOKEN: token, + }, + ) + + return self.async_show_form( + step_id="reauth_confirm_update_token", errors=errors + ) async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None @@ -199,7 +274,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input: try: - device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS]) + device_info = await async_try_connect(user_input[CONF_IP_ADDRESS]) except RecoverableError as ex: LOGGER.error(ex) @@ -230,37 +305,65 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - @staticmethod - async def _async_try_connect(ip_address: str) -> Device: - """Try to connect. - Make connection with device to test the connection - and to get info for unique_id. - """ +async def async_try_connect(ip_address: str) -> Device: + """Try to connect. + + Make connection with device to test the connection + and to get info for unique_id. + """ + + energy_api: HomeWizardEnergy + + # Determine if device is v1 or v2 capable + if await has_v2_api(ip_address): + energy_api = HomeWizardEnergyV2(ip_address) + else: energy_api = HomeWizardEnergyV1(ip_address) - try: - return await energy_api.device() - except DisabledError as ex: - raise RecoverableError( - "API disabled, API must be enabled in the app", "api_not_enabled" - ) from ex + try: + return await energy_api.device() - except UnsupportedError as ex: - LOGGER.error("API version unsuppored") - raise AbortFlow("unsupported_api_version") from ex + except DisabledError as ex: + raise RecoverableError( + "API disabled, API must be enabled in the app", "api_not_enabled" + ) from ex - except RequestError as ex: - raise RecoverableError( - "Device unreachable or unexpected response", "network_error" - ) from ex + except UnsupportedError as ex: + LOGGER.error("API version unsuppored") + raise AbortFlow("unsupported_api_version") from ex - except Exception as ex: - LOGGER.exception("Unexpected exception") - raise AbortFlow("unknown_error") from ex + except RequestError as ex: + raise RecoverableError( + "Device unreachable or unexpected response", "network_error" + ) from ex - finally: - await energy_api.close() + except UnauthorizedError as ex: + raise UnauthorizedError("Unauthorized") from ex + + except Exception as ex: + LOGGER.exception("Unexpected exception") + raise AbortFlow("unknown_error") from ex + + finally: + await energy_api.close() + + +async def async_request_token(ip_address: str) -> str | None: + """Try to request a token from the device. + + This method is used to request a token from the device, + it will return None if the token request failed. + """ + + api = HomeWizardEnergyV2(ip_address) + + try: + return await api.get_token("home-assistant") + except DisabledError: + return None + finally: + await api.close() class RecoverableError(HomeAssistantError): diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index 4bed4675833..e0448edaf86 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -13,8 +13,6 @@ PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] LOGGER = logging.getLogger(__package__) # Platform config. -CONF_API_ENABLED = "api_enabled" -CONF_DATA = "data" CONF_PRODUCT_NAME = "product_name" CONF_PRODUCT_TYPE = "product_type" CONF_SERIAL = "serial" diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index 7024c760b93..92beb99ad2c 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -3,11 +3,12 @@ from __future__ import annotations from homewizard_energy import HomeWizardEnergy -from homewizard_energy.errors import DisabledError, RequestError +from homewizard_energy.errors import DisabledError, RequestError, UnauthorizedError from homewizard_energy.models import CombinedModels as DeviceResponseEntry 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, LOGGER, UPDATE_INTERVAL @@ -51,6 +52,9 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] ex, translation_domain=DOMAIN, translation_key="api_disabled" ) from ex + except UnauthorizedError as ex: + raise ConfigEntryAuthFailed from ex + self.api_disabled = False self.data = data diff --git a/homeassistant/components/homewizard/icons.json b/homeassistant/components/homewizard/icons.json index e6b1a34841f..68ebd6b84d0 100644 --- a/homeassistant/components/homewizard/icons.json +++ b/homeassistant/components/homewizard/icons.json @@ -15,6 +15,9 @@ "any_power_fail_count": { "default": "mdi:transmission-tower-off" }, + "cycles": { + "default": "mdi:battery-sync-outline" + }, "dsmr_version": { "default": "mdi:counter" }, diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 4cc94d09d74..b1a19134752 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==v8.1.1"], - "zeroconf": ["_hwenergy._tcp.local."] + "requirements": ["python-homewizard-energy==v8.2.0"], + "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/homeassistant/components/homewizard/quality_scale.yaml b/homeassistant/components/homewizard/quality_scale.yaml index 423bc4dea49..008772a5a29 100644 --- a/homeassistant/components/homewizard/quality_scale.yaml +++ b/homeassistant/components/homewizard/quality_scale.yaml @@ -47,7 +47,10 @@ rules: devices: done diagnostics: done discovery-update-info: done - discovery: done + discovery: + status: done + comment: | + DHCP IP address updates are not supported for the v2 API. docs-data-update: done docs-examples: done docs-known-limitations: done @@ -66,10 +69,7 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: - status: exempt - comment: | - This integration does not raise any repairable issues. + repair-issues: done stale-devices: status: exempt comment: | diff --git a/homeassistant/components/homewizard/repairs.py b/homeassistant/components/homewizard/repairs.py new file mode 100644 index 00000000000..4c9a03b493f --- /dev/null +++ b/homeassistant/components/homewizard/repairs.py @@ -0,0 +1,79 @@ +"""Repairs for HomeWizard integration.""" + +from __future__ import annotations + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import RepairsFlow +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult + +from .config_flow import async_request_token + + +class MigrateToV2ApiRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, entry: ConfigEntry) -> None: + """Create flow.""" + self.entry = entry + + 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 await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the confirm step of a fix flow.""" + + if user_input is not None: + return await self.async_step_authorize() + + return self.async_show_form( + step_id="confirm", description_placeholders={"title": self.entry.title} + ) + + async def async_step_authorize( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the authorize step of a fix flow.""" + + ip_address = self.entry.data[CONF_IP_ADDRESS] + + # 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) + errors: dict[str, str] | None = None + + if token is None: + if user_input is not None: + errors = {"base": "authorization_failed"} + + return self.async_show_form(step_id="authorize", errors=errors) + + data = {**self.entry.data, CONF_TOKEN: token} + self.hass.config_entries.async_update_entry(self.entry, data=data) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_create_entry(data={}) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + assert data is not None + assert isinstance(data["entry_id"], str) + + if issue_id.startswith("migrate_to_v2_api_") and ( + entry := hass.config_entries.async_get_entry(data["entry_id"]) + ): + return MigrateToV2ApiRepairFlow(entry) + + raise ValueError(f"unknown repair {issue_id}") # pragma: no cover diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 8a9738e7ae7..b6227a03bed 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Final -from homewizard_energy.models import ExternalDevice, Measurement +from homewizard_energy.models import CombinedModels, ExternalDevice from homeassistant.components.sensor import ( DEVICE_CLASS_UNITS, @@ -46,9 +46,9 @@ PARALLEL_UPDATES = 1 class HomeWizardSensorEntityDescription(SensorEntityDescription): """Class describing HomeWizard sensor entities.""" - enabled_fn: Callable[[Measurement], bool] = lambda x: True - has_fn: Callable[[Measurement], bool] - value_fn: Callable[[Measurement], StateType] + enabled_fn: Callable[[CombinedModels], bool] = lambda x: True + has_fn: Callable[[CombinedModels], bool] + value_fn: Callable[[CombinedModels], StateType] @dataclass(frozen=True, kw_only=True) @@ -69,35 +69,43 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( key="smr_version", translation_key="dsmr_version", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.protocol_version is not None, - value_fn=lambda data: data.protocol_version, + has_fn=lambda data: data.measurement.protocol_version is not None, + value_fn=lambda data: data.measurement.protocol_version, ), HomeWizardSensorEntityDescription( key="meter_model", translation_key="meter_model", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.meter_model is not None, - value_fn=lambda data: data.meter_model, + has_fn=lambda data: data.measurement.meter_model is not None, + value_fn=lambda data: data.measurement.meter_model, ), HomeWizardSensorEntityDescription( key="unique_meter_id", translation_key="unique_meter_id", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.unique_id is not None, - value_fn=lambda data: data.unique_id, + has_fn=lambda data: data.measurement.unique_id is not None, + value_fn=lambda data: data.measurement.unique_id, ), HomeWizardSensorEntityDescription( key="wifi_ssid", translation_key="wifi_ssid", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.wifi_ssid is not None, - value_fn=lambda data: data.wifi_ssid, + has_fn=( + lambda data: data.system is not None and data.system.wifi_ssid is not None + ), + value_fn=( + lambda data: data.system.wifi_ssid if data.system is not None else None + ), ), HomeWizardSensorEntityDescription( key="active_tariff", translation_key="active_tariff", - has_fn=lambda data: data.tariff is not None, - value_fn=lambda data: None if data.tariff is None else str(data.tariff), + has_fn=lambda data: data.measurement.tariff is not None, + value_fn=( + lambda data: None + if data.measurement.tariff is None + else str(data.measurement.tariff) + ), device_class=SensorDeviceClass.ENUM, options=["1", "2", "3", "4"], ), @@ -108,8 +116,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - has_fn=lambda data: data.wifi_strength is not None, - value_fn=lambda data: data.wifi_strength, + has_fn=lambda data: data.measurement.wifi_strength is not None, + value_fn=lambda data: data.measurement.wifi_strength, ), HomeWizardSensorEntityDescription( key="total_power_import_kwh", @@ -117,8 +125,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_import_kwh is not None, - value_fn=lambda data: data.energy_import_kwh, + has_fn=lambda data: data.measurement.energy_import_kwh is not None, + value_fn=lambda data: data.measurement.energy_import_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t1_kwh", @@ -129,10 +137,10 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: ( # SKT/SDM230/630 provides both total and tariff 1: duplicate. - data.energy_import_t1_kwh is not None - and data.energy_export_t2_kwh is not None + data.measurement.energy_import_t1_kwh is not None + and data.measurement.energy_export_t2_kwh is not None ), - value_fn=lambda data: data.energy_import_t1_kwh, + value_fn=lambda data: data.measurement.energy_import_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t2_kwh", @@ -141,8 +149,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_import_t2_kwh is not None, - value_fn=lambda data: data.energy_import_t2_kwh, + has_fn=lambda data: data.measurement.energy_import_t2_kwh is not None, + value_fn=lambda data: data.measurement.energy_import_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t3_kwh", @@ -151,8 +159,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_import_t3_kwh is not None, - value_fn=lambda data: data.energy_import_t3_kwh, + has_fn=lambda data: data.measurement.energy_import_t3_kwh is not None, + value_fn=lambda data: data.measurement.energy_import_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t4_kwh", @@ -161,8 +169,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_import_t4_kwh is not None, - value_fn=lambda data: data.energy_import_t4_kwh, + has_fn=lambda data: data.measurement.energy_import_t4_kwh is not None, + value_fn=lambda data: data.measurement.energy_import_t4_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_kwh", @@ -170,9 +178,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_export_kwh is not None, - enabled_fn=lambda data: data.energy_export_kwh != 0, - value_fn=lambda data: data.energy_export_kwh, + has_fn=lambda data: data.measurement.energy_export_kwh is not None, + enabled_fn=lambda data: data.measurement.energy_export_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t1_kwh", @@ -183,11 +191,11 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: ( # SKT/SDM230/630 provides both total and tariff 1: duplicate. - data.energy_export_t1_kwh is not None - and data.energy_export_t2_kwh is not None + data.measurement.energy_export_t1_kwh is not None + and data.measurement.energy_export_t2_kwh is not None ), - enabled_fn=lambda data: data.energy_export_t1_kwh != 0, - value_fn=lambda data: data.energy_export_t1_kwh, + enabled_fn=lambda data: data.measurement.energy_export_t1_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t2_kwh", @@ -196,9 +204,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_export_t2_kwh is not None, - enabled_fn=lambda data: data.energy_export_t2_kwh != 0, - value_fn=lambda data: data.energy_export_t2_kwh, + has_fn=lambda data: data.measurement.energy_export_t2_kwh is not None, + enabled_fn=lambda data: data.measurement.energy_export_t2_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t3_kwh", @@ -207,9 +215,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_export_t3_kwh is not None, - enabled_fn=lambda data: data.energy_export_t3_kwh != 0, - value_fn=lambda data: data.energy_export_t3_kwh, + has_fn=lambda data: data.measurement.energy_export_t3_kwh is not None, + enabled_fn=lambda data: data.measurement.energy_export_t3_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t4_kwh", @@ -218,9 +226,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_export_t4_kwh is not None, - enabled_fn=lambda data: data.energy_export_t4_kwh != 0, - value_fn=lambda data: data.energy_export_t4_kwh, + has_fn=lambda data: data.measurement.energy_export_t4_kwh is not None, + enabled_fn=lambda data: data.measurement.energy_export_t4_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_t4_kwh, ), HomeWizardSensorEntityDescription( key="active_power_w", @@ -228,8 +236,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.power_w is not None, - value_fn=lambda data: data.power_w, + has_fn=lambda data: data.measurement.power_w is not None, + value_fn=lambda data: data.measurement.power_w, ), HomeWizardSensorEntityDescription( key="active_power_l1_w", @@ -239,8 +247,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.power_l1_w is not None, - value_fn=lambda data: data.power_l1_w, + has_fn=lambda data: data.measurement.power_l1_w is not None, + value_fn=lambda data: data.measurement.power_l1_w, ), HomeWizardSensorEntityDescription( key="active_power_l2_w", @@ -250,8 +258,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.power_l2_w is not None, - value_fn=lambda data: data.power_l2_w, + has_fn=lambda data: data.measurement.power_l2_w is not None, + value_fn=lambda data: data.measurement.power_l2_w, ), HomeWizardSensorEntityDescription( key="active_power_l3_w", @@ -261,8 +269,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.power_l3_w is not None, - value_fn=lambda data: data.power_l3_w, + has_fn=lambda data: data.measurement.power_l3_w is not None, + value_fn=lambda data: data.measurement.power_l3_w, ), HomeWizardSensorEntityDescription( key="active_voltage_v", @@ -270,8 +278,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.voltage_v is not None, - value_fn=lambda data: data.voltage_v, + has_fn=lambda data: data.measurement.voltage_v is not None, + value_fn=lambda data: data.measurement.voltage_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l1_v", @@ -281,8 +289,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.voltage_l1_v is not None, - value_fn=lambda data: data.voltage_l1_v, + has_fn=lambda data: data.measurement.voltage_l1_v is not None, + value_fn=lambda data: data.measurement.voltage_l1_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l2_v", @@ -292,8 +300,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.voltage_l2_v is not None, - value_fn=lambda data: data.voltage_l2_v, + has_fn=lambda data: data.measurement.voltage_l2_v is not None, + value_fn=lambda data: data.measurement.voltage_l2_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l3_v", @@ -303,8 +311,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.voltage_l3_v is not None, - value_fn=lambda data: data.voltage_l3_v, + has_fn=lambda data: data.measurement.voltage_l3_v is not None, + value_fn=lambda data: data.measurement.voltage_l3_v, ), HomeWizardSensorEntityDescription( key="active_current_a", @@ -312,8 +320,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.current_a is not None, - value_fn=lambda data: data.current_a, + has_fn=lambda data: data.measurement.current_a is not None, + value_fn=lambda data: data.measurement.current_a, ), HomeWizardSensorEntityDescription( key="active_current_l1_a", @@ -323,8 +331,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.current_l1_a is not None, - value_fn=lambda data: data.current_l1_a, + has_fn=lambda data: data.measurement.current_l1_a is not None, + value_fn=lambda data: data.measurement.current_l1_a, ), HomeWizardSensorEntityDescription( key="active_current_l2_a", @@ -334,8 +342,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.current_l2_a is not None, - value_fn=lambda data: data.current_l2_a, + has_fn=lambda data: data.measurement.current_l2_a is not None, + value_fn=lambda data: data.measurement.current_l2_a, ), HomeWizardSensorEntityDescription( key="active_current_l3_a", @@ -345,8 +353,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.current_l3_a is not None, - value_fn=lambda data: data.current_l3_a, + has_fn=lambda data: data.measurement.current_l3_a is not None, + value_fn=lambda data: data.measurement.current_l3_a, ), HomeWizardSensorEntityDescription( key="active_frequency_hz", @@ -354,8 +362,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.frequency_hz is not None, - value_fn=lambda data: data.frequency_hz, + has_fn=lambda data: data.measurement.frequency_hz is not None, + value_fn=lambda data: data.measurement.frequency_hz, ), HomeWizardSensorEntityDescription( key="active_apparent_power_va", @@ -363,8 +371,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.apparent_power_va is not None, - value_fn=lambda data: data.apparent_power_va, + has_fn=lambda data: data.measurement.apparent_power_va is not None, + value_fn=lambda data: data.measurement.apparent_power_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l1_va", @@ -374,8 +382,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.apparent_power_l1_va is not None, - value_fn=lambda data: data.apparent_power_l1_va, + has_fn=lambda data: data.measurement.apparent_power_l1_va is not None, + value_fn=lambda data: data.measurement.apparent_power_l1_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l2_va", @@ -385,8 +393,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.apparent_power_l2_va is not None, - value_fn=lambda data: data.apparent_power_l2_va, + has_fn=lambda data: data.measurement.apparent_power_l2_va is not None, + value_fn=lambda data: data.measurement.apparent_power_l2_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l3_va", @@ -396,8 +404,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.apparent_power_l3_va is not None, - value_fn=lambda data: data.apparent_power_l3_va, + has_fn=lambda data: data.measurement.apparent_power_l3_va is not None, + value_fn=lambda data: data.measurement.apparent_power_l3_va, ), HomeWizardSensorEntityDescription( key="active_reactive_power_var", @@ -405,8 +413,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.reactive_power_var is not None, - value_fn=lambda data: data.reactive_power_var, + has_fn=lambda data: data.measurement.reactive_power_var is not None, + value_fn=lambda data: data.measurement.reactive_power_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l1_var", @@ -416,8 +424,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.reactive_power_l1_var is not None, - value_fn=lambda data: data.reactive_power_l1_var, + has_fn=lambda data: data.measurement.reactive_power_l1_var is not None, + value_fn=lambda data: data.measurement.reactive_power_l1_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l2_var", @@ -427,8 +435,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.reactive_power_l2_var is not None, - value_fn=lambda data: data.reactive_power_l2_var, + has_fn=lambda data: data.measurement.reactive_power_l2_var is not None, + value_fn=lambda data: data.measurement.reactive_power_l2_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l3_var", @@ -438,8 +446,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.reactive_power_l3_var is not None, - value_fn=lambda data: data.reactive_power_l3_var, + has_fn=lambda data: data.measurement.reactive_power_l3_var is not None, + value_fn=lambda data: data.measurement.reactive_power_l3_var, ), HomeWizardSensorEntityDescription( key="active_power_factor", @@ -447,8 +455,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.power_factor is not None, - value_fn=lambda data: to_percentage(data.power_factor), + has_fn=lambda data: data.measurement.power_factor is not None, + value_fn=lambda data: to_percentage(data.measurement.power_factor), ), HomeWizardSensorEntityDescription( key="active_power_factor_l1", @@ -458,8 +466,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.power_factor_l1 is not None, - value_fn=lambda data: to_percentage(data.power_factor_l1), + has_fn=lambda data: data.measurement.power_factor_l1 is not None, + value_fn=lambda data: to_percentage(data.measurement.power_factor_l1), ), HomeWizardSensorEntityDescription( key="active_power_factor_l2", @@ -469,8 +477,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.power_factor_l2 is not None, - value_fn=lambda data: to_percentage(data.power_factor_l2), + has_fn=lambda data: data.measurement.power_factor_l2 is not None, + value_fn=lambda data: to_percentage(data.measurement.power_factor_l2), ), HomeWizardSensorEntityDescription( key="active_power_factor_l3", @@ -480,94 +488,94 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.power_factor_l3 is not None, - value_fn=lambda data: to_percentage(data.power_factor_l3), + has_fn=lambda data: data.measurement.power_factor_l3 is not None, + value_fn=lambda data: to_percentage(data.measurement.power_factor_l3), ), HomeWizardSensorEntityDescription( key="voltage_sag_l1_count", translation_key="voltage_sag_phase_count", translation_placeholders={"phase": "1"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_sag_l1_count is not None, - value_fn=lambda data: data.voltage_sag_l1_count, + has_fn=lambda data: data.measurement.voltage_sag_l1_count is not None, + value_fn=lambda data: data.measurement.voltage_sag_l1_count, ), HomeWizardSensorEntityDescription( key="voltage_sag_l2_count", translation_key="voltage_sag_phase_count", translation_placeholders={"phase": "2"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_sag_l2_count is not None, - value_fn=lambda data: data.voltage_sag_l2_count, + has_fn=lambda data: data.measurement.voltage_sag_l2_count is not None, + value_fn=lambda data: data.measurement.voltage_sag_l2_count, ), HomeWizardSensorEntityDescription( key="voltage_sag_l3_count", translation_key="voltage_sag_phase_count", translation_placeholders={"phase": "3"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_sag_l3_count is not None, - value_fn=lambda data: data.voltage_sag_l3_count, + has_fn=lambda data: data.measurement.voltage_sag_l3_count is not None, + value_fn=lambda data: data.measurement.voltage_sag_l3_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l1_count", translation_key="voltage_swell_phase_count", translation_placeholders={"phase": "1"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_swell_l1_count is not None, - value_fn=lambda data: data.voltage_swell_l1_count, + has_fn=lambda data: data.measurement.voltage_swell_l1_count is not None, + value_fn=lambda data: data.measurement.voltage_swell_l1_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l2_count", translation_key="voltage_swell_phase_count", translation_placeholders={"phase": "2"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_swell_l2_count is not None, - value_fn=lambda data: data.voltage_swell_l2_count, + has_fn=lambda data: data.measurement.voltage_swell_l2_count is not None, + value_fn=lambda data: data.measurement.voltage_swell_l2_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l3_count", translation_key="voltage_swell_phase_count", translation_placeholders={"phase": "3"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_swell_l3_count is not None, - value_fn=lambda data: data.voltage_swell_l3_count, + has_fn=lambda data: data.measurement.voltage_swell_l3_count is not None, + value_fn=lambda data: data.measurement.voltage_swell_l3_count, ), HomeWizardSensorEntityDescription( key="any_power_fail_count", translation_key="any_power_fail_count", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.any_power_fail_count is not None, - value_fn=lambda data: data.any_power_fail_count, + has_fn=lambda data: data.measurement.any_power_fail_count is not None, + value_fn=lambda data: data.measurement.any_power_fail_count, ), HomeWizardSensorEntityDescription( key="long_power_fail_count", translation_key="long_power_fail_count", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.long_power_fail_count is not None, - value_fn=lambda data: data.long_power_fail_count, + has_fn=lambda data: data.measurement.long_power_fail_count is not None, + value_fn=lambda data: data.measurement.long_power_fail_count, ), HomeWizardSensorEntityDescription( key="active_power_average_w", translation_key="active_power_average_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, - has_fn=lambda data: data.average_power_15m_w is not None, - value_fn=lambda data: data.average_power_15m_w, + has_fn=lambda data: data.measurement.average_power_15m_w is not None, + value_fn=lambda data: data.measurement.average_power_15m_w, ), HomeWizardSensorEntityDescription( key="monthly_power_peak_w", translation_key="monthly_power_peak_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, - has_fn=lambda data: data.monthly_power_peak_w is not None, - value_fn=lambda data: data.monthly_power_peak_w, + has_fn=lambda data: data.measurement.monthly_power_peak_w is not None, + value_fn=lambda data: data.measurement.monthly_power_peak_w, ), HomeWizardSensorEntityDescription( key="active_liter_lpm", translation_key="active_liter_lpm", native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, - has_fn=lambda data: data.active_liter_lpm is not None, - value_fn=lambda data: data.active_liter_lpm, + has_fn=lambda data: data.measurement.active_liter_lpm is not None, + value_fn=lambda data: data.measurement.active_liter_lpm, ), HomeWizardSensorEntityDescription( key="total_liter_m3", @@ -575,8 +583,26 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_liter_m3 is not None, - value_fn=lambda data: data.total_liter_m3, + has_fn=lambda data: data.measurement.total_liter_m3 is not None, + value_fn=lambda data: data.measurement.total_liter_m3, + ), + HomeWizardSensorEntityDescription( + key="state_of_charge_pct", + translation_key="state_of_charge_pct", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + has_fn=lambda data: data.measurement.state_of_charge_pct is not None, + value_fn=lambda data: data.measurement.state_of_charge_pct, + ), + HomeWizardSensorEntityDescription( + key="cycles", + translation_key="cycles", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + has_fn=lambda data: data.measurement.cycles is not None, + value_fn=lambda data: data.measurement.cycles, ), ) @@ -622,16 +648,15 @@ async def async_setup_entry( ) -> None: """Initialize sensors.""" - measurement = entry.runtime_data.data.measurement - # Initialize default sensors entities: list = [ HomeWizardSensorEntity(entry.runtime_data, description) for description in SENSORS - if description.has_fn(measurement) + if description.has_fn(entry.runtime_data.data) ] # Initialize external devices + measurement = entry.runtime_data.data.measurement if measurement.external_devices is not None: for unique_id, device in measurement.external_devices.items(): if device.type is not None and ( @@ -661,13 +686,13 @@ class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity): super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" - if not description.enabled_fn(self.coordinator.data.measurement): + if not description.enabled_fn(self.coordinator.data): self._attr_entity_registry_enabled_default = False @property def native_value(self) -> StateType: """Return the sensor value.""" - return self.entity_description.value_fn(self.coordinator.data.measurement) + return self.entity_description.value_fn(self.coordinator.data) @property def available(self) -> bool: diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 4309664c4c8..dbaef8439d9 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -15,9 +15,17 @@ "title": "Confirm", "description": "Do you want to set up {product_type} ({serial}) at {ip_address}?" }, - "reauth_confirm": { + "reauth_enable_api": { "description": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings." }, + "reauth_confirm_update_token": { + "title": "Re-authenticate", + "description": "[%key:component::homewizard::config::step::authorize::description%]" + }, + "authorize": { + "title": "Authorize", + "description": "Press the button on the HomeWizard Energy device, then select the button below." + }, "reconfigure": { "description": "Update configuration for {title}.", "data": { @@ -30,7 +38,8 @@ }, "error": { "api_not_enabled": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings.", - "network_error": "Device unreachable, make sure that you have entered the correct IP address and that the device is available in your network" + "network_error": "Device unreachable, make sure that you have entered the correct IP address and that the device is available in your network", + "authorization_failed": "Failed to authorize, make sure to press the button of the device within 30 seconds" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -38,7 +47,8 @@ "device_not_supported": "This device is not supported", "unknown_error": "[%key:common::config_flow::error::unknown%]", "unsupported_api_version": "Detected unsupported API version", - "reauth_successful": "Enabling API was successful", + "reauth_enable_api_successful": "Enabling API was successful", + "reauth_successful": "Authorization successful", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "wrong_device": "The configured device is not the same found on this IP address." } @@ -121,6 +131,12 @@ }, "total_liter_m3": { "name": "Total water usage" + }, + "cycles": { + "name": "Battery cycles" + }, + "state_of_charge_pct": { + "name": "State of charge" } }, "switch": { @@ -139,5 +155,26 @@ "communication_error": { "message": "An error occurred while communicating with HomeWizard device" } + }, + "issues": { + "migrate_to_v2_api": { + "title": "Update authentication method", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::homewizard::issues::migrate_to_v2_api::title%]", + "description": "Your {title} now supports a more secure and feature-rich communication method. To take advantage of this, you need to reconfigure the integration.\n\nSelect **Submit** to start the reconfiguration." + }, + "authorize": { + "title": "[%key:component::homewizard::config::step::authorize::title%]", + "description": "[%key:component::homewizard::config::step::authorize::description%]" + } + }, + "error": { + "authorization_failed": "[%key:component::homewizard::config::error::authorization_failed%]", + "unknown_error": "[%key:common::config_flow::error::unknown%]" + } + } + } } } diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index 0878703e4d5..8ebb56433b1 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -59,7 +59,7 @@ SWITCHES = [ key="cloud_connection", translation_key="cloud_connection", entity_category=EntityCategory.CONFIG, - create_fn=lambda _: True, + create_fn=lambda x: x.device.supports_cloud_enable(), available_fn=lambda x: x.system is not None, is_on_fn=lambda x: x.system.cloud_enabled if x.system else None, set_fn=lambda api, active: api.system(cloud_enabled=active), diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 203f01e7d68..be15d88aec2 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -522,6 +522,11 @@ ZEROCONF = { "domain": "homekit", }, ], + "_homewizard._tcp.local.": [ + { + "domain": "homewizard", + }, + ], "_hscp._tcp.local.": [ { "domain": "apple_tv", diff --git a/requirements_all.txt b/requirements_all.txt index 287ca9364a5..57c534d0e2c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2388,7 +2388,7 @@ python-gitlab==1.6.0 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.1.1 +python-homewizard-energy==v8.2.0 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d7220be9718..c4aa58667c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1933,7 +1933,7 @@ python-fullykiosk==0.0.14 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.1.1 +python-homewizard-energy==v8.2.0 # homeassistant.components.izone python-izone==1.2.9 diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index b540ebac91a..f9c5e617904 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -3,11 +3,18 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from homewizard_energy.models import CombinedModels, Device, Measurement, State, System +from homewizard_energy.models import ( + CombinedModels, + Device, + Measurement, + State, + System, + Token, +) import pytest from homeassistant.components.homewizard.const import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, get_fixture_path, load_json_object_fixture @@ -65,6 +72,59 @@ def mock_homewizardenergy( yield client +@pytest.fixture +def mock_homewizardenergy_v2( + device_fixture: str, +) -> MagicMock: + """Return a mock bridge.""" + with ( + patch( + "homeassistant.components.homewizard.HomeWizardEnergyV2", + autospec=True, + ) as homewizard, + patch( + "homeassistant.components.homewizard.config_flow.HomeWizardEnergyV2", + new=homewizard, + ), + ): + client = homewizard.return_value + + client.combined.return_value = CombinedModels( + device=Device.from_dict( + load_json_object_fixture(f"v2/{device_fixture}/device.json", DOMAIN) + ), + measurement=Measurement.from_dict( + load_json_object_fixture( + f"v2/{device_fixture}/measurement.json", DOMAIN + ) + ), + state=( + State.from_dict( + load_json_object_fixture(f"v2/{device_fixture}/state.json", DOMAIN) + ) + if get_fixture_path(f"v2/{device_fixture}/state.json", DOMAIN).exists() + else None + ), + system=( + System.from_dict( + load_json_object_fixture(f"v2/{device_fixture}/system.json", DOMAIN) + ) + if get_fixture_path(f"v2/{device_fixture}/system.json", DOMAIN).exists() + else None + ), + ) + + # device() call is used during configuration flow + client.device.return_value = client.combined.return_value.device + + # Authorization flow is used during configuration flow + client.get_token.return_value = Token.from_dict( + load_json_object_fixture("v2/generic/token.json", DOMAIN) + ).token + + yield client + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" @@ -90,6 +150,20 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_config_entry_v2() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Device", + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "127.0.0.1", + CONF_TOKEN: "00112233445566778899ABCDEFABCDEF", + }, + unique_id="HWE-P1_5c2fafabcdef", + ) + + @pytest.fixture async def init_integration( hass: HomeAssistant, diff --git a/tests/components/homewizard/fixtures/HWE-BAT/data.json b/tests/components/homewizard/fixtures/HWE-BAT/data.json new file mode 100644 index 00000000000..490120e7ffd --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-BAT/data.json @@ -0,0 +1,12 @@ +{ + "wifi_ssid": "simulating v1 support", + "wifi_strength": 100, + "total_power_import_kwh": 123.456, + "total_power_export_kwh": 123.456, + "active_power_w": 123, + "active_voltage_v": 230, + "active_current_a": 1.5, + "active_frequency_hz": 50, + "state_of_charge_pct": 50, + "cycles": 123 +} diff --git a/tests/components/homewizard/fixtures/HWE-BAT/device.json b/tests/components/homewizard/fixtures/HWE-BAT/device.json new file mode 100644 index 00000000000..c551dc34c91 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-BAT/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-BAT", + "product_name": "Plug-In Battery", + "serial": "5c2fafabcdef", + "firmware_version": "1.00", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/HWE-BAT/system.json b/tests/components/homewizard/fixtures/HWE-BAT/system.json new file mode 100644 index 00000000000..b4094f497cb --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-BAT/system.json @@ -0,0 +1,7 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_rssi_db": -77, + "cloud_enabled": false, + "uptime_s": 356, + "status_led_brightness_pct": 100 +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-P1/device.json b/tests/components/homewizard/fixtures/v2/HWE-P1/device.json new file mode 100644 index 00000000000..2dc3f0692a2 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-P1/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-P1", + "product_name": "P1 meter", + "serial": "5c2fafabcdef", + "firmware_version": "4.19", + "api_version": "2.0.0" +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-P1/measurement.json b/tests/components/homewizard/fixtures/v2/HWE-P1/measurement.json new file mode 100644 index 00000000000..2004b0cd37f --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-P1/measurement.json @@ -0,0 +1,48 @@ +{ + "protocol_version": 50, + "meter_model": "ISKRA 2M550T-101", + "unique_id": "4E6576657220476F6E6E61204C657420596F7520446F776E", + "timestamp": "2024-06-28T14:12:34", + "tariff": 2, + "energy_import_kwh": 13779.338, + "energy_import_t1_kwh": 10830.511, + "energy_import_t2_kwh": 2948.827, + "energy_export_kwh": 1234.567, + "energy_export_t1_kwh": 234.567, + "energy_export_t2_kwh": 1000, + "power_w": -543, + "power_l1_w": -676, + "power_l2_w": 133, + "power_l3_w": 0, + "current_a": 6, + "current_l1_a": -4, + "current_l2_a": 2, + "current_l3_a": 0, + "voltage_sag_l1_count": 1, + "voltage_sag_l2_count": 1, + "voltage_sag_l3_count": 0, + "voltage_swell_l1_count": 0, + "voltage_swell_l2_count": 0, + "voltage_swell_l3_count": 0, + "any_power_fail_count": 4, + "long_power_fail_count": 5, + "average_power_15m_w": 123.0, + "monthly_power_peak_w": 1111.0, + "monthly_power_peak_timestamp": "2024-06-04T10:11:22", + "external": [ + { + "unique_id": "4E6576657220676F6E6E612072756E2061726F756E64", + "type": "gas_meter", + "timestamp": "2024-06-28T14:00:00", + "value": 2569.646, + "unit": "m3" + }, + { + "unique_id": "616E642064657365727420796F75", + "type": "water_meter", + "timestamp": "2024-06-28T14:05:00", + "value": 123.456, + "unit": "m3" + } + ] +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-P1/system.json b/tests/components/homewizard/fixtures/v2/HWE-P1/system.json new file mode 100644 index 00000000000..38bcaeeb584 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-P1/system.json @@ -0,0 +1,8 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_rssi_db": -77, + "cloud_enabled": false, + "uptime_s": 356, + "status_led_brightness_pct": 100, + "api_v1_enabled": true +} diff --git a/tests/components/homewizard/fixtures/v2/generic/token.json b/tests/components/homewizard/fixtures/v2/generic/token.json new file mode 100644 index 00000000000..8fa1e9cb8d1 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/generic/token.json @@ -0,0 +1,4 @@ +{ + "token": "00112233445566778899aabbccddeeff", + "name": "local/new_user" +} diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index b8cf98d9211..192b9dbdc32 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -1,9 +1,99 @@ # serializer version: 1 +# name: test_diagnostics[HWE-BAT] + dict({ + 'data': dict({ + 'device': dict({ + 'api_version': '1.0.0', + 'firmware_version': '1.00', + 'id': '**REDACTED**', + 'model_name': 'Plug-In Battery', + 'product_name': 'Plug-In Battery', + 'product_type': 'HWE-BAT', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ + 'active_liter_lpm': None, + 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': None, + 'average_power_15m_w': None, + 'current_a': 1.5, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': 123, + 'energy_export_kwh': 123.456, + 'energy_export_t1_kwh': None, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 123.456, + 'energy_import_t1_kwh': None, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, + 'external_devices': None, + 'frequency_hz': 50.0, + 'long_power_fail_count': None, + 'meter_model': None, + 'monthly_power_peak_timestamp': None, + 'monthly_power_peak_w': None, + 'power_factor': None, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': None, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': 123.0, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': None, + 'state_of_charge_pct': 50.0, + 'tariff': None, + 'timestamp': None, + 'total_liter_m3': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, + 'voltage_sag_l1_count': None, + 'voltage_sag_l2_count': None, + 'voltage_sag_l3_count': None, + 'voltage_swell_l1_count': None, + 'voltage_swell_l2_count': None, + 'voltage_swell_l3_count': None, + 'voltage_v': 230.0, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 100, + }), + 'state': None, + 'system': dict({ + 'api_v1_enabled': None, + 'cloud_enabled': False, + 'status_led_brightness_pct': 100, + 'uptime_s': 356, + 'wifi_rssi_db': -77, + 'wifi_ssid': '**REDACTED**', + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + 'product_name': 'P1 Meter', + 'product_type': 'HWE-P1', + 'serial': '**REDACTED**', + }), + }) +# --- # name: test_diagnostics[HWE-KWH1] dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', 'model_name': 'Wi-Fi kWh Meter 1-phase', @@ -93,7 +183,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', 'model_name': 'Wi-Fi kWh Meter 3-phase', @@ -183,7 +273,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '4.19', 'id': '**REDACTED**', 'model_name': 'Wi-Fi P1 Meter', @@ -309,7 +399,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.03', 'id': '**REDACTED**', 'model_name': 'Wi-Fi Energy Socket', @@ -403,7 +493,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '4.07', 'id': '**REDACTED**', 'model_name': 'Wi-Fi Energy Socket', @@ -497,7 +587,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '2.03', 'id': '**REDACTED**', 'model_name': 'Wi-Fi Watermeter', @@ -587,7 +677,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', 'model_name': 'Wi-Fi kWh Meter 1-phase', @@ -677,7 +767,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', 'model_name': 'Wi-Fi kWh Meter 3-phase', diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 31a949ca7bd..df445a9ddca 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -1,4 +1,704 @@ # serializer version: 1 +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_battery_cycles:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_battery_cycles:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_battery_cycles', + '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': 'Battery cycles', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cycles', + 'unique_id': 'HWE-P1_5c2fafabcdef_cycles', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_battery_cycles:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Battery cycles', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.device_battery_cycles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_current:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_current:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_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': 'Current', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_current:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.5', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export', + '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 export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.456', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import', + '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 import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.456', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_frequency:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.0', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_state_of_charge:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_state_of_charge:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_state_of_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State of charge', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state_of_charge_pct', + 'unique_id': 'HWE-P1_5c2fafabcdef_state_of_charge_pct', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_state_of_charge:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Device State of charge', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_state_of_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_voltage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_voltage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_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': 'Voltage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_voltage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- # name: test_sensors[HWE-KWH1-entity_ids7][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index b2ae7bd45e0..c39853c3f9a 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -1,15 +1,20 @@ """Test the homewizard config flow.""" from ipaddress import ip_address -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch -from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError +from homewizard_energy.errors import ( + DisabledError, + RequestError, + UnauthorizedError, + UnsupportedError, +) import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components.homewizard.const import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo @@ -225,10 +230,10 @@ async def test_discovery_missing_data_in_service_info(hass: HomeAssistant) -> No type="", name="", properties={ - # "api_enabled": "1", --> removed + "api_enabled": "1", "path": "/api/v1", "product_name": "P1 meter", - "product_type": "HWE-P1", + # "product_type": "HWE-P1", --> removed "serial": "5c2fafabcdef", }, ), @@ -238,32 +243,6 @@ async def test_discovery_missing_data_in_service_info(hass: HomeAssistant) -> No assert result["reason"] == "invalid_discovery_parameters" -async def test_discovery_invalid_api(hass: HomeAssistant) -> None: - """Test discovery detecting invalid_api.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=ZeroconfServiceInfo( - ip_address=ip_address("127.0.0.1"), - ip_addresses=[ip_address("127.0.0.1")], - port=80, - hostname="p1meter-ddeeff.local.", - type="", - name="", - properties={ - "api_enabled": "1", - "path": "/api/not_v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", - "serial": "5c2fafabcdef", - }, - ), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unsupported_api_version" - - async def test_dhcp_discovery_updates_entry( hass: HomeAssistant, mock_homewizardenergy: MagicMock, @@ -338,6 +317,32 @@ async def test_dhcp_discovery_ignores_unknown( assert result.get("reason") == "unknown" +async def test_dhcp_discovery_aborts_for_v2_api( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery aborts when v2 API is detected. + + DHCP discovery requires authorization which is not yet implemented + """ + mock_homewizardenergy.device.side_effect = UnauthorizedError + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.0.0.127", + hostname="HW-p1meter-aabbcc", + macaddress="5c2fafabcdef", + ), + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "unsupported_api_version" + + async def test_discovery_flow_updates_new_ip( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -455,12 +460,12 @@ async def test_reauth_flow( result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" + assert result["step_id"] == "reauth_enable_api" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["reason"] == "reauth_enable_api_successful" async def test_reauth_error( @@ -475,7 +480,7 @@ async def test_reauth_error( result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" + assert result["step_id"] == "reauth_enable_api" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -609,3 +614,222 @@ async def test_reconfigure_cannot_connect( # changed entry assert mock_config_entry.data[CONF_IP_ADDRESS] == "1.0.0.127" + + +### TESTS FOR V2 IMPLEMENTATION ### + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_manual_flow_works_with_v2_api_support( + hass: HomeAssistant, + mock_homewizardenergy_v2: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test config flow accepts user configuration and triggers authorization when detected v2 support.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Simulate v2 support but not authorized + mock_homewizardenergy_v2.device.side_effect = UnauthorizedError + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + with patch( + "homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + + # Simulate user authorizing + mock_homewizardenergy_v2.device.side_effect = None + mock_homewizardenergy_v2.get_token.side_effect = None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_manual_flow_detects_failed_user_authorization( + hass: HomeAssistant, + mock_homewizardenergy_v2: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test config flow accepts user configuration and detects failed button press by user.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Simulate v2 support but not authorized + mock_homewizardenergy_v2.device.side_effect = UnauthorizedError + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + with patch( + "homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + assert result["errors"] == {"base": "authorization_failed"} + + # Restore normal functionality + mock_homewizardenergy_v2.device.side_effect = None + mock_homewizardenergy_v2.get_token.side_effect = None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_updates_token( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test reauth flow token is updated.""" + + mock_config_entry_v2.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + result = await mock_config_entry_v2.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm_update_token" + + # Simulate user pressing the button and getting a new token + mock_homewizardenergy_v2.get_token.return_value = "cool_new_token" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + # Verify that the token was updated + await hass.async_block_till_done() + assert ( + hass.config_entries.async_entries(DOMAIN)[0].data.get(CONF_TOKEN) + == "cool_new_token" + ) + assert len(mock_setup_entry.mock_calls) == 2 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_handles_user_not_pressing_button( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test reauth flow token is updated.""" + + mock_config_entry_v2.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + result = await mock_config_entry_v2.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm_update_token" + assert result["errors"] is None + + # Simulate button not being pressed + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "authorization_failed"} + + # Simulate user pressing the button and getting a new token + mock_homewizardenergy_v2.get_token.side_effect = None + mock_homewizardenergy_v2.get_token.return_value = "cool_new_token" + + # Successful reauth + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + # Verify that the token was updated + await hass.async_block_till_done() + assert ( + hass.config_entries.async_entries(DOMAIN)[0].data.get(CONF_TOKEN) + == "cool_new_token" + ) + assert len(mock_setup_entry.mock_calls) == 2 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_discovery_with_v2_api_ask_authorization( + hass: HomeAssistant, + # mock_setup_entry: AsyncMock, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test discovery detecting missing discovery info.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=443, + hostname="p1meter-abcdef.local.", + type="", + name="", + properties={ + "api_version": "2.0.0", + "id": "appliance/p1dongle/5c2fafabcdef", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "5c2fafabcdef", + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + mock_homewizardenergy_v2.device.side_effect = UnauthorizedError + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + with patch( + "homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + + mock_homewizardenergy_v2.get_token.side_effect = None + mock_homewizardenergy_v2.get_token.return_value = "cool_token" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_TOKEN] == "cool_token" diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py index e3d7f4e6da9..c7063d497c3 100644 --- a/tests/components/homewizard/test_diagnostics.py +++ b/tests/components/homewizard/test_diagnostics.py @@ -21,6 +21,7 @@ from tests.typing import ClientSessionGenerator "SDM630", "HWE-KWH1", "HWE-KWH3", + "HWE-BAT", ], ) async def test_diagnostics( diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index ed4bad8b2e8..77366da84c5 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory -from homewizard_energy.errors import DisabledError +from homewizard_energy.errors import DisabledError, UnauthorizedError import pytest from homeassistant.components.homewizard.const import DOMAIN @@ -14,12 +14,12 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, async_fire_time_changed -async def test_load_unload( +async def test_load_unload_v1( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homewizardenergy: MagicMock, ) -> None: - """Test loading and unloading of integration.""" + """Test loading and unloading of integration with v1 config.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -33,6 +33,25 @@ async def test_load_unload( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +async def test_load_unload_v2( + hass: HomeAssistant, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test loading and unloading of integration with v2 config.""" + mock_config_entry_v2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v2.state is ConfigEntryState.LOADED + assert len(mock_homewizardenergy_v2.combined.mock_calls) == 1 + + await hass.config_entries.async_unload(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v2.state is ConfigEntryState.NOT_LOADED + + async def test_load_failed_host_unavailable( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -64,7 +83,7 @@ async def test_load_detect_api_disabled( assert len(flows) == 1 flow = flows[0] - assert flow.get("step_id") == "reauth_confirm" + assert flow.get("step_id") == "reauth_enable_api" assert flow.get("handler") == DOMAIN assert "context" in flow @@ -72,6 +91,31 @@ async def test_load_detect_api_disabled( assert flow["context"].get("entry_id") == mock_config_entry.entry_id +async def test_load_detect_invalid_token( + hass: HomeAssistant, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test setup detects invalid token.""" + mock_homewizardenergy_v2.combined.side_effect = UnauthorizedError() + mock_config_entry_v2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v2.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm_update_token" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry_v2.entry_id + + @pytest.mark.usefixtures("mock_homewizardenergy") async def test_load_removes_reauth_flow( hass: HomeAssistant, @@ -128,5 +172,5 @@ async def test_disablederror_reloads_integration( assert len(flows) == 1 flow = flows[0] - assert flow.get("step_id") == "reauth_confirm" + assert flow.get("step_id") == "reauth_enable_api" assert flow.get("handler") == DOMAIN diff --git a/tests/components/homewizard/test_repair.py b/tests/components/homewizard/test_repair.py new file mode 100644 index 00000000000..a59d6f415dd --- /dev/null +++ b/tests/components/homewizard/test_repair.py @@ -0,0 +1,82 @@ +"""Test the homewizard config flow.""" + +from unittest.mock import MagicMock, patch + +from homewizard_energy.errors import DisabledError + +from homeassistant.components.homewizard.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator + + +async def test_repair_acquires_token( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homewizardenergy: MagicMock, + mock_homewizardenergy_v2: MagicMock, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair flow is able to obtain and use token.""" + + assert await async_setup_component(hass, "repairs", {}) + await async_process_repairs_platforms(hass) + client = await hass_client() + + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.homewizard.has_v2_api", return_value=True): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Get active repair flow + issue_id = f"migrate_to_v2_api_{mock_config_entry.entry_id}" + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + + assert issue.data.get("entry_id") == mock_config_entry.entry_id + + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm" + + result = await process_repair_fix_flow(client, flow_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "authorize" + + # Simulate user not pressing the button + result = await process_repair_fix_flow(client, flow_id, json={}) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "authorize" + assert result["errors"] == {"base": "authorization_failed"} + + # Simulate user pressing the button and getting a new token + mock_homewizardenergy_v2.get_token.side_effect = None + mock_homewizardenergy_v2.get_token.return_value = "cool_token" + result = await process_repair_fix_flow(client, flow_id, json={}) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert mock_config_entry.data[CONF_TOKEN] == "cool_token" + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 128a3de2ebf..c1474c4b947 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -291,6 +291,19 @@ pytestmark = [ "sensor.water_meter_water", ], ), + ( + "HWE-BAT", + [ + "sensor.device_battery_cycles", + "sensor.device_current", + "sensor.device_energy_export", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_power", + "sensor.device_state_of_charge", + "sensor.device_voltage", + ], + ), ], ) async def test_sensors( @@ -431,6 +444,14 @@ async def test_sensors( "sensor.device_wi_fi_strength", ], ), + ( + "HWE-BAT", + [ + "sensor.device_current", + "sensor.device_frequency", + "sensor.device_voltage", + ], + ), ], ) async def test_disabled_by_default_sensors( @@ -492,6 +513,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_apparent_power", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -521,6 +543,7 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", @@ -543,6 +566,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_2", "sensor.device_apparent_power_phase_3", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -568,6 +592,7 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power_phase_3", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", @@ -590,6 +615,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_apparent_power", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -623,6 +649,7 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", @@ -644,6 +671,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_average_demand", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -670,6 +698,7 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power_phase_3", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", @@ -688,6 +717,7 @@ async def test_external_sensors_unreachable( "SDM630", [ "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -706,6 +736,7 @@ async def test_external_sensors_unreachable( "sensor.device_power_failures_detected", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", @@ -729,6 +760,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_average_demand", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -755,6 +787,7 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power_phase_3", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", @@ -773,6 +806,7 @@ async def test_external_sensors_unreachable( "HWE-KWH3", [ "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -791,6 +825,7 @@ async def test_external_sensors_unreachable( "sensor.device_power_failures_detected", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", @@ -806,6 +841,54 @@ async def test_external_sensors_unreachable( "sensor.device_water_usage", ], ), + ( + "HWE-BAT", + [ + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_apparent_power", + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_dsmr_version", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_power_factor", + "sensor.device_power_failures_detected", + "sensor.device_power_phase_1", + "sensor.device_power_phase_3", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_reactive_power", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_tariff", + "sensor.device_total_water_usage", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + "sensor.device_water_usage", + ], + ), ], ) async def test_entities_not_created_for_device( From 0e263aa42736df64f12b3fc0c1a66b2d216626c7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 19:10:20 +0100 Subject: [PATCH 1109/2987] Standardize homeassistant imports in full-CI tests (#136735) --- tests/components/history/test_init.py | 2 +- tests/components/history/test_websocket_api.py | 2 +- tests/components/history/test_websocket_api_schema_32.py | 2 +- tests/components/light/test_device_condition.py | 2 +- tests/components/light/test_device_trigger.py | 2 +- tests/components/logbook/common.py | 2 +- tests/components/logbook/test_init.py | 4 ++-- tests/components/logbook/test_websocket_api.py | 2 +- .../recorder/auto_repairs/statistics/test_duplicates.py | 2 +- tests/components/recorder/common.py | 2 +- tests/components/recorder/db_schema_0.py | 2 +- tests/components/recorder/db_schema_16.py | 2 +- tests/components/recorder/db_schema_18.py | 2 +- tests/components/recorder/db_schema_22.py | 2 +- tests/components/recorder/db_schema_23.py | 2 +- tests/components/recorder/db_schema_23_with_newer_columns.py | 2 +- tests/components/recorder/db_schema_25.py | 2 +- tests/components/recorder/db_schema_28.py | 2 +- tests/components/recorder/db_schema_30.py | 2 +- tests/components/recorder/db_schema_32.py | 2 +- tests/components/recorder/db_schema_42.py | 2 +- tests/components/recorder/db_schema_43.py | 2 +- tests/components/recorder/db_schema_9.py | 2 +- tests/components/recorder/test_history.py | 2 +- tests/components/recorder/test_history_db_schema_32.py | 2 +- tests/components/recorder/test_history_db_schema_42.py | 4 ++-- tests/components/recorder/test_migrate.py | 2 +- tests/components/recorder/test_migration_from_schema_32.py | 2 +- tests/components/recorder/test_models.py | 2 +- tests/components/recorder/test_statistics.py | 2 +- tests/components/recorder/test_statistics_v23_migration.py | 2 +- tests/components/recorder/test_v32_migration.py | 2 +- tests/components/recorder/test_websocket_api.py | 2 +- tests/components/sensor/test_device_trigger.py | 2 +- tests/components/sensor/test_recorder.py | 2 +- tests/components/sensor/test_recorder_missing_stats.py | 2 +- 36 files changed, 38 insertions(+), 38 deletions(-) diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 3b4b02a877e..f1890073567 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -16,7 +16,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE from homeassistant.core import HomeAssistant, State from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.components.recorder.common import ( assert_dict_of_states_equal_without_context_and_last_changed, diff --git a/tests/components/history/test_websocket_api.py b/tests/components/history/test_websocket_api.py index 717840c6b05..01b49ad5575 100644 --- a/tests/components/history/test_websocket_api.py +++ b/tests/components/history/test_websocket_api.py @@ -14,7 +14,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, STATE_OFF, STAT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed from tests.components.recorder.common import ( diff --git a/tests/components/history/test_websocket_api_schema_32.py b/tests/components/history/test_websocket_api_schema_32.py index 301de387c80..7b84c47e81b 100644 --- a/tests/components/history/test_websocket_api_schema_32.py +++ b/tests/components/history/test_websocket_api_schema_32.py @@ -6,7 +6,7 @@ from homeassistant.components import recorder from homeassistant.components.recorder import Recorder from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.components.recorder.common import ( async_recorder_block_till_done, diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py index 94e12ffbfa5..2a5c9f0bb18 100644 --- a/tests/components/light/test_device_condition.py +++ b/tests/components/light/test_device_condition.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockLight diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py index 4e8414edabc..ae54bbd2512 100644 --- a/tests/components/light/test_device_trigger.py +++ b/tests/components/light/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/logbook/common.py b/tests/components/logbook/common.py index abb118467f4..b303a34e151 100644 --- a/tests/components/logbook/common.py +++ b/tests/components/logbook/common.py @@ -16,7 +16,7 @@ from homeassistant.components.recorder.models import ( from homeassistant.core import Context from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util IDX_TO_NAME = dict(enumerate(EventAsRow._fields)) diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 841c8ed1247..c62bdcaa824 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -10,6 +10,7 @@ from freezegun import freeze_time import pytest import voluptuous as vol +from homeassistant import core as ha from homeassistant.components import logbook, recorder # pylint: disable-next=hass-component-root-import @@ -40,12 +41,11 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -import homeassistant.core as ha from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockRow, mock_humanify diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 50139d0f4f7..7b2550ccc82 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -37,7 +37,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.recorder.common import ( diff --git a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py index 78a7ddaa300..2466a761364 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py +++ b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py @@ -17,7 +17,7 @@ from homeassistant.components.recorder.auto_repairs.statistics.duplicates import from homeassistant.components.recorder.statistics import async_add_external_statistics from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ...common import async_wait_recording_done diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index fbb0991c960..792000c3725 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -37,7 +37,7 @@ from homeassistant.components.recorder.db_schema import ( from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask from homeassistant.const import UnitOfTemperature from homeassistant.core import Event, HomeAssistant, State -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import db_schema_0 diff --git a/tests/components/recorder/db_schema_0.py b/tests/components/recorder/db_schema_0.py index 12336dcc96a..12228e99211 100644 --- a/tests/components/recorder/db_schema_0.py +++ b/tests/components/recorder/db_schema_0.py @@ -23,7 +23,7 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_16.py b/tests/components/recorder/db_schema_16.py index 522bd6ea367..3455af1d019 100644 --- a/tests/components/recorder/db_schema_16.py +++ b/tests/components/recorder/db_schema_16.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_18.py b/tests/components/recorder/db_schema_18.py index 026227f68a0..9e9dc786580 100644 --- a/tests/components/recorder/db_schema_18.py +++ b/tests/components/recorder/db_schema_18.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_22.py b/tests/components/recorder/db_schema_22.py index 770d25c9cf2..766ff88ff72 100644 --- a/tests/components/recorder/db_schema_22.py +++ b/tests/components/recorder/db_schema_22.py @@ -42,7 +42,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_23.py b/tests/components/recorder/db_schema_23.py index 8cf3e16e5a8..fe36029b61f 100644 --- a/tests/components/recorder/db_schema_23.py +++ b/tests/components/recorder/db_schema_23.py @@ -41,7 +41,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_23_with_newer_columns.py b/tests/components/recorder/db_schema_23_with_newer_columns.py index 2ba62ba78f5..a77bc1fcbd5 100644 --- a/tests/components/recorder/db_schema_23_with_newer_columns.py +++ b/tests/components/recorder/db_schema_23_with_newer_columns.py @@ -49,7 +49,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_25.py b/tests/components/recorder/db_schema_25.py index 3b7c4a300c2..bd3cb23bd07 100644 --- a/tests/components/recorder/db_schema_25.py +++ b/tests/components/recorder/db_schema_25.py @@ -37,7 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.typing import UNDEFINED, UndefinedType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_28.py b/tests/components/recorder/db_schema_28.py index 4d7f893de25..7f34343d995 100644 --- a/tests/components/recorder/db_schema_28.py +++ b/tests/components/recorder/db_schema_28.py @@ -43,7 +43,7 @@ from homeassistant.const import ( MAX_LENGTH_STATE_STATE, ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_30.py b/tests/components/recorder/db_schema_30.py index 97c33334111..185dce786de 100644 --- a/tests/components/recorder/db_schema_30.py +++ b/tests/components/recorder/db_schema_30.py @@ -50,7 +50,7 @@ from homeassistant.const import ( from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSON_DUMP, json_bytes -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} diff --git a/tests/components/recorder/db_schema_32.py b/tests/components/recorder/db_schema_32.py index 39ddb8e3148..daa7fb6977c 100644 --- a/tests/components/recorder/db_schema_32.py +++ b/tests/components/recorder/db_schema_32.py @@ -51,7 +51,7 @@ from homeassistant.const import ( from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSON_DUMP, json_bytes -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} diff --git a/tests/components/recorder/db_schema_42.py b/tests/components/recorder/db_schema_42.py index efeade46562..a5381d633cb 100644 --- a/tests/components/recorder/db_schema_42.py +++ b/tests/components/recorder/db_schema_42.py @@ -66,7 +66,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import ( JSON_DECODE_EXCEPTIONS, json_loads, diff --git a/tests/components/recorder/db_schema_43.py b/tests/components/recorder/db_schema_43.py index 8e77e8782ee..379e6fbd416 100644 --- a/tests/components/recorder/db_schema_43.py +++ b/tests/components/recorder/db_schema_43.py @@ -66,7 +66,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, EventStateChangedData, State from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import ( JSON_DECODE_EXCEPTIONS, json_loads, diff --git a/tests/components/recorder/db_schema_9.py b/tests/components/recorder/db_schema_9.py index f9a8c2d2cad..784e326e1c3 100644 --- a/tests/components/recorder/db_schema_9.py +++ b/tests/components/recorder/db_schema_9.py @@ -25,7 +25,7 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index d9dbbf191f6..166451cc971 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -22,7 +22,7 @@ from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant, State from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index bfe5c852ca6..142d2fc87f6 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -17,7 +17,7 @@ from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant, State from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, diff --git a/tests/components/recorder/test_history_db_schema_42.py b/tests/components/recorder/test_history_db_schema_42.py index 23ac6f9fb8a..1523f373ea8 100644 --- a/tests/components/recorder/test_history_db_schema_42.py +++ b/tests/components/recorder/test_history_db_schema_42.py @@ -10,15 +10,15 @@ from unittest.mock import sentinel from freezegun import freeze_time import pytest +from homeassistant import core as ha from homeassistant.components import recorder from homeassistant.components.recorder import Recorder, history from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.util import session_scope -import homeassistant.core as ha from homeassistant.core import HomeAssistant, State from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index e60a4705ac8..081394c780c 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -31,7 +31,7 @@ from homeassistant.components.recorder.db_schema import ( from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant, State from homeassistant.helpers import recorder as recorder_helper -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import async_wait_recording_done, create_engine_test from .conftest import InstrumentedMigration diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 94b7518edb7..0a5f5d4da73 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -41,7 +41,7 @@ from homeassistant.components.recorder.util import ( session_scope, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.ulid import bytes_to_ulid, ulid_at_time, ulid_to_bytes from .common import ( diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index b2894883ff2..689441260c7 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -5,6 +5,7 @@ from unittest.mock import PropertyMock import pytest +from homeassistant import core as ha from homeassistant.components.recorder.const import SupportedDialect from homeassistant.components.recorder.db_schema import ( EventData, @@ -18,7 +19,6 @@ from homeassistant.components.recorder.models import ( process_timestamp_to_utc_isoformat, ) from homeassistant.const import EVENT_STATE_CHANGED -import homeassistant.core as ha from homeassistant.exceptions import InvalidEntityFormatError from homeassistant.util import dt as dt_util from homeassistant.util.json import json_loads diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 2baf7f2bcbc..6e192295c58 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -41,7 +41,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, diff --git a/tests/components/recorder/test_statistics_v23_migration.py b/tests/components/recorder/test_statistics_v23_migration.py index dafa4da81ee..49b8836af70 100644 --- a/tests/components/recorder/test_statistics_v23_migration.py +++ b/tests/components/recorder/test_statistics_v23_migration.py @@ -17,7 +17,7 @@ import pytest from homeassistant.components import recorder from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.util import session_scope -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( CREATE_ENGINE_TARGET, diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 58be23bdc85..c4c1285990d 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -17,7 +17,7 @@ from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.util import session_scope from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.core import Event, EventOrigin, State -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import async_wait_recording_done from .conftest import instrument_migration diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 94ed8da1b92..9e5172ae1f0 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -27,7 +27,7 @@ from homeassistant.components.sensor import UNIT_CONVERTERS from homeassistant.core import HomeAssistant from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .common import ( diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index f50e92bc9df..f35c9520f71 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import load_json from .common import UNITS_OF_MEASUREMENT, MockSensor diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index fcf5a711c46..615960defbb 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -40,7 +40,7 @@ from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .common import MockSensor diff --git a/tests/components/sensor/test_recorder_missing_stats.py b/tests/components/sensor/test_recorder_missing_stats.py index 449ffd55727..fd28a7052a5 100644 --- a/tests/components/sensor/test_recorder_missing_stats.py +++ b/tests/components/sensor/test_recorder_missing_stats.py @@ -17,7 +17,7 @@ from homeassistant.components.recorder.util import session_scope from homeassistant.core import CoreState from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_test_home_assistant from tests.components.recorder.common import ( From d5568ff95543018aaf488571bad45ed7d620ed4f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 19:11:19 +0100 Subject: [PATCH 1110/2987] Standardize homeassistant imports in full-CI components (#136731) Standardize homeassistant imports in components --- homeassistant/components/alexa/flash_briefings.py | 2 +- homeassistant/components/alexa/state_report.py | 2 +- .../components/application_credentials/__init__.py | 7 +++++-- homeassistant/components/auth/mfa_setup_flow.py | 2 +- homeassistant/components/automation/__init__.py | 3 +-- homeassistant/components/cloud/tts.py | 2 +- homeassistant/components/conversation/trigger.py | 2 +- homeassistant/components/demo/__init__.py | 3 +-- homeassistant/components/demo/calendar.py | 2 +- homeassistant/components/demo/config_flow.py | 2 +- homeassistant/components/demo/media_player.py | 2 +- homeassistant/components/demo/weather.py | 2 +- homeassistant/components/energy/sensor.py | 3 +-- homeassistant/components/ffmpeg/__init__.py | 2 +- homeassistant/components/ffmpeg/camera.py | 2 +- homeassistant/components/frontend/__init__.py | 3 +-- homeassistant/components/group/notify.py | 3 +-- homeassistant/components/hassio/auth.py | 2 +- homeassistant/components/hassio/websocket_api.py | 2 +- homeassistant/components/homeassistant/const.py | 2 +- .../components/homeassistant/triggers/time.py | 2 +- homeassistant/components/http/__init__.py | 8 ++++++-- homeassistant/components/http/ban.py | 2 +- homeassistant/components/input_boolean/__init__.py | 3 +-- homeassistant/components/input_button/__init__.py | 3 +-- homeassistant/components/input_datetime/__init__.py | 3 +-- homeassistant/components/input_number/__init__.py | 3 +-- homeassistant/components/input_select/__init__.py | 3 +-- homeassistant/components/input_text/__init__.py | 3 +-- homeassistant/components/logbook/processor.py | 2 +- homeassistant/components/logbook/rest_api.py | 2 +- homeassistant/components/logbook/websocket_api.py | 2 +- homeassistant/components/logger/__init__.py | 2 +- homeassistant/components/modbus/__init__.py | 2 +- homeassistant/components/modbus/modbus.py | 2 +- homeassistant/components/mqtt/alarm_control_panel.py | 4 ++-- homeassistant/components/mqtt/binary_sensor.py | 3 +-- homeassistant/components/mqtt/button.py | 2 +- homeassistant/components/mqtt/climate.py | 2 +- homeassistant/components/mqtt/cover.py | 2 +- homeassistant/components/mqtt/device_tracker.py | 2 +- homeassistant/components/mqtt/discovery.py | 3 +-- homeassistant/components/mqtt/event.py | 2 +- homeassistant/components/mqtt/fan.py | 2 +- homeassistant/components/mqtt/humidifier.py | 2 +- homeassistant/components/mqtt/lock.py | 2 +- homeassistant/components/mqtt/notify.py | 2 +- homeassistant/components/mqtt/scene.py | 2 +- homeassistant/components/mqtt/sensor.py | 2 +- homeassistant/components/mqtt/siren.py | 2 +- homeassistant/components/mqtt/switch.py | 2 +- homeassistant/components/mqtt/tag.py | 2 +- homeassistant/components/mqtt/vacuum.py | 2 +- homeassistant/components/mqtt/valve.py | 2 +- homeassistant/components/mqtt/water_heater.py | 2 +- homeassistant/components/network/const.py | 2 +- .../components/persistent_notification/__init__.py | 2 +- .../components/persistent_notification/trigger.py | 2 +- homeassistant/components/recorder/__init__.py | 2 +- homeassistant/components/recorder/core.py | 2 +- homeassistant/components/recorder/db_schema.py | 2 +- homeassistant/components/recorder/history/legacy.py | 2 +- homeassistant/components/recorder/history/modern.py | 2 +- homeassistant/components/recorder/models/legacy.py | 2 +- homeassistant/components/recorder/models/state.py | 2 +- homeassistant/components/recorder/models/time.py | 2 +- homeassistant/components/recorder/services.py | 4 ++-- .../recorder/table_managers/recorder_runs.py | 2 +- homeassistant/components/recorder/util.py | 2 +- homeassistant/components/script/__init__.py | 2 +- homeassistant/components/shopping_list/__init__.py | 2 +- homeassistant/components/shopping_list/intent.py | 3 +-- homeassistant/components/stream/__init__.py | 2 +- homeassistant/components/sun/trigger.py | 2 +- homeassistant/components/tag/__init__.py | 10 ++++++---- .../components/template/alarm_control_panel.py | 3 +-- homeassistant/components/template/binary_sensor.py | 3 +-- homeassistant/components/template/cover.py | 2 +- homeassistant/components/template/fan.py | 2 +- homeassistant/components/template/lock.py | 2 +- homeassistant/components/template/template_entity.py | 2 +- homeassistant/components/template/vacuum.py | 2 +- homeassistant/components/timer/__init__.py | 5 ++--- homeassistant/components/trace/__init__.py | 2 +- homeassistant/components/webhook/trigger.py | 2 +- 85 files changed, 102 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py index 0d75ee04b7a..a37a95e59d5 100644 --- a/homeassistant/components/alexa/flash_briefings.py +++ b/homeassistant/components/alexa/flash_briefings.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import template from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( API_PASSWORD, diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 03b6a22007c..20e3ef1d7c7 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -24,7 +24,7 @@ from homeassistant.core import ( ) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.significant_change import create_checker -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonObjectType, json_loads_object from .const import ( diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 0ee936aeef2..68f10df7886 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -26,8 +26,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import collection, config_entry_oauth2_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + collection, + config_entry_oauth2_flow, + config_validation as cv, +) from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.loader import ( diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py index c9efb081a01..6c85f5b7f55 100644 --- a/homeassistant/components/auth/mfa_setup_flow.py +++ b/homeassistant/components/auth/mfa_setup_flow.py @@ -12,7 +12,7 @@ from homeassistant import data_entry_flow from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowContext -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.util.hass_dict import HassKey WS_TYPE_SETUP_MFA = "auth/setup_mfa" diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 4e6b098ef1e..856060f8c75 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -48,8 +48,7 @@ from homeassistant.core import ( valid_entity_id, ) from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError -from homeassistant.helpers import condition -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.issue_registry import ( diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 4dbee10fbaf..645ff4f9e75 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -22,7 +22,7 @@ from homeassistant.components.tts import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant, async_get_hass, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index 24eb54c5694..634ae1fd9aa 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.const import CONF_COMMAND, CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.script import ScriptRunResult from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import UNDEFINED, ConfigType diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index d088dfb140b..9314fc211de 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio -from homeassistant import config_entries, setup +from homeassistant import config_entries, core as ha, setup from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -13,7 +13,6 @@ from homeassistant.const import ( Platform, UnitOfSoundPressure, ) -import homeassistant.core as ha from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform diff --git a/homeassistant/components/demo/calendar.py b/homeassistant/components/demo/calendar.py index d513bc38250..4e2fa7b3460 100644 --- a/homeassistant/components/demo/calendar.py +++ b/homeassistant/components/demo/calendar.py @@ -8,7 +8,7 @@ from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util async def async_setup_entry( diff --git a/homeassistant/components/demo/config_flow.py b/homeassistant/components/demo/config_flow.py index 53c1678aa81..6f8ee26f511 100644 --- a/homeassistant/components/demo/config_flow.py +++ b/homeassistant/components/demo/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from . import DOMAIN diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index 8ce77bcd615..fa3c3e3b2fc 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util async def async_setup_entry( diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py index fbc2b660efb..2468c54dde3 100644 --- a/homeassistant/components/demo/weather.py +++ b/homeassistant/components/demo/weather.py @@ -28,7 +28,7 @@ from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util CONDITION_CLASSES: dict[str, list[str]] = { ATTR_CONDITION_CLOUDY: [], diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 199d18d6b07..eec92c32f98 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -29,8 +29,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import unit_conversion -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, unit_conversion from homeassistant.util.unit_system import METRIC_SYSTEM from .const import DOMAIN diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index 6957702523f..fc5341b025e 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -17,7 +17,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/ffmpeg/camera.py b/homeassistant/components/ffmpeg/camera.py index 2c46c4c29d1..03566ba162c 100644 --- a/homeassistant/components/ffmpeg/camera.py +++ b/homeassistant/components/ffmpeg/camera.py @@ -16,8 +16,8 @@ from homeassistant.components.camera import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 050d57fc358..6184d888004 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,8 +26,7 @@ from homeassistant.const import ( EVENT_THEMES_UPDATED, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import service -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, service from homeassistant.helpers.icon import async_get_icons from homeassistant.helpers.json import json_dumps_sorted from homeassistant.helpers.storage import Store diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index 5bba2a677d5..d6a9a6fd3c7 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -28,8 +28,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index 6ca89ee24be..8589bc0f134 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -14,7 +14,7 @@ from homeassistant.auth.providers import homeassistant as auth_ha from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 23fdc721168..c046e20feab 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -12,7 +12,7 @@ from homeassistant.components.websocket_api import ActiveConnection from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import Unauthorized -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/homeassistant/const.py b/homeassistant/components/homeassistant/const.py index 7a51e218a16..7fad6728a74 100644 --- a/homeassistant/components/homeassistant/const.py +++ b/homeassistant/components/homeassistant/const.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Final -import homeassistant.core as ha +from homeassistant import core as ha from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 5cd1921d8a8..e07d806d3dc 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -35,7 +35,7 @@ from homeassistant.helpers.event import ( ) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _TIME_TRIGGER_ENTITY = vol.All(str, cv.entity_domain(["input_datetime", "sensor"])) _TIME_AT_SCHEMA = vol.Any(cv.time, _TIME_TRIGGER_ENTITY) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 95cdee9ab9e..8ee27039441 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -37,8 +37,12 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import frame, issue_registry as ir, storage -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + frame, + issue_registry as ir, + storage, +) from homeassistant.helpers.http import ( KEY_ALLOW_CONFIGURED_CORS, KEY_AUTHENTICATED, # noqa: F401 diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index b5093999836..821d44eebaa 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -26,7 +26,7 @@ import voluptuous as vol from homeassistant.config import load_yaml_config_file from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.hassio import get_supervisor_ip, is_hassio from homeassistant.util import dt as dt_util, yaml as yaml_util diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index 54457ab2fb7..a0a7514eaaf 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -19,8 +19,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity diff --git a/homeassistant/components/input_button/__init__.py b/homeassistant/components/input_button/__init__.py index 69ff235948d..12bc98f7674 100644 --- a/homeassistant/components/input_button/__init__.py +++ b/homeassistant/components/input_button/__init__.py @@ -16,8 +16,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 428ffccb7c1..60f882c2726 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -18,8 +18,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index d52bfedfe77..3352b55442a 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -19,8 +19,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index a117cf0a867..171998c02bc 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -27,8 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 7d8f6663673..998bf35cd82 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -18,8 +18,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index a53a604daae..1a139bb379e 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.event_type import EventType from .const import ( diff --git a/homeassistant/components/logbook/rest_api.py b/homeassistant/components/logbook/rest_api.py index c7ba196275b..e4a8e64cecf 100644 --- a/homeassistant/components/logbook/rest_api.py +++ b/homeassistant/components/logbook/rest_api.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import InvalidEntityFormatError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .helpers import async_determine_event_types from .processor import EventProcessor diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index b295b845532..e3d0d8a29fa 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -17,8 +17,8 @@ from homeassistant.components.websocket_api import ActiveConnection, messages from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.json import json_bytes +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import create_eager_task -import homeassistant.util.dt as dt_util from .const import DOMAIN from .helpers import ( diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index be6e8c1b24e..15283b246b2 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import EVENT_LOGGING_CHANGED # noqa: F401 from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from . import websocket_api diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 1a331e16482..5b1b78a5aef 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -48,7 +48,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import Event, HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 319c68f50f0..81cfc3127d1 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -30,7 +30,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 613f665c302..7bdc13d0522 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -6,7 +6,7 @@ import logging import voluptuous as vol -import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components import alarm_control_panel as alarm from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, @@ -14,7 +14,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CODE, CONF_NAME, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index b49dc7aa24c..d736123eae8 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -26,9 +26,8 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, event as evt from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.event as evt from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index 8e5446b532e..b6056c2efd9 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -9,7 +9,7 @@ from homeassistant.components.button import DEVICE_CLASSES_SCHEMA, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index e62303472ed..12619609f64 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -44,7 +44,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.template import Template diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index c7d041848f0..626e0cef64a 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -29,7 +29,7 @@ from homeassistant.const import ( STATE_OPENING, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index bdf543e046a..d3ad57ef43d 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -21,7 +21,7 @@ from homeassistant.const import ( STATE_NOT_HOME, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 21d250db29a..a14240ce008 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -21,8 +21,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_DEVICE, CONF_PLATFORM from homeassistant.core import HassJobType, HomeAssistant, callback -from homeassistant.helpers import discovery_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index f665f2c4016..5855f94dad7 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -17,7 +17,7 @@ from homeassistant.components.event import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 4d2e764a0d5..d8e96eb2734 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -27,7 +27,7 @@ from homeassistant.const import ( CONF_STATE, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.template import Template diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 5d1af03ad24..bffe0ec1420 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -30,7 +30,7 @@ from homeassistant.const import ( CONF_STATE, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.template import Template diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 2113dbbd5ba..895bfba3560 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py index 84442e75e73..7e0a7fd4dd8 100644 --- a/homeassistant/components/mqtt/notify.py +++ b/homeassistant/components/mqtt/notify.py @@ -9,7 +9,7 @@ from homeassistant.components.notify import NotifyEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index 314bd716ee0..c6651510a36 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -11,7 +11,7 @@ from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PAYLOAD_ON from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index bacbf4d323e..ad84ebb09a3 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -30,7 +30,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.service_info.mqtt import ReceivePayloadType diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 1cc5ba2d2e5..5e3ca76e722 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -28,7 +28,7 @@ from homeassistant.const import ( CONF_PAYLOAD_ON, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.service_info.mqtt import ReceivePayloadType diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 0a54bcdb378..a305fa83485 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -20,7 +20,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index 680f252fb20..9a05d1896f7 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -12,7 +12,7 @@ from homeassistant.components import tag from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_VALUE_TEMPLATE from homeassistant.core import HassJobType, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index 743bfb363f3..ae6b25eff14 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -17,7 +17,7 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolSchemaType diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py index 50c5960f801..b380199332b 100644 --- a/homeassistant/components/mqtt/valve.py +++ b/homeassistant/components/mqtt/valve.py @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 4c1d3fa8a53..967eceac326 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -35,7 +35,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, VolSchemaType diff --git a/homeassistant/components/network/const.py b/homeassistant/components/network/const.py index 6c5b6f80eda..120ae9dfd7c 100644 --- a/homeassistant/components/network/const.py +++ b/homeassistant/components/network/const.py @@ -6,7 +6,7 @@ from typing import Final import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv DOMAIN: Final = "network" STORAGE_KEY: Final = "core.network" diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index a5eb8bb4f4d..2871f4b575a 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.signal_type import SignalType from homeassistant.util.uuid import random_uuid_hex diff --git a/homeassistant/components/persistent_notification/trigger.py b/homeassistant/components/persistent_notification/trigger.py index 431443d9139..8e0808f9879 100644 --- a/homeassistant/components/persistent_notification/trigger.py +++ b/homeassistant/components/persistent_notification/trigger.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import TriggerActionType, TriggerData, TriggerInfo from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index a40760c67f4..5a95ace92cb 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, INCLUDE_EXCLUDE_FILTER_SCHEMA_INNER, diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index fc8b136f38a..05a5731e791 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -45,7 +45,7 @@ from homeassistant.helpers.event import ( ) from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import UNDEFINED, UndefinedType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum from homeassistant.util.event_type import EventType diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index cefce9c4e72..d1a2405406e 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -47,7 +47,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, EventStateChangedData, State from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import ( JSON_DECODE_EXCEPTIONS, json_loads, diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index dc49ebb9768..4323ad9466b 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -20,7 +20,7 @@ from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.helpers.recorder import get_instance -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ..db_schema import StateAttributes, States from ..filters import Filters diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 2d8f4da5f38..aed2fcf8508 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -25,7 +25,7 @@ from sqlalchemy.orm.session import Session from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.helpers.recorder import get_instance -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ..const import LAST_REPORTED_SCHEMA_VERSION from ..db_schema import ( diff --git a/homeassistant/components/recorder/models/legacy.py b/homeassistant/components/recorder/models/legacy.py index b5e67ff050b..11ea9141fc0 100644 --- a/homeassistant/components/recorder/models/legacy.py +++ b/homeassistant/components/recorder/models/legacy.py @@ -14,7 +14,7 @@ from homeassistant.const import ( COMPRESSED_STATE_STATE, ) from homeassistant.core import Context, State -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .state_attributes import decode_attributes_from_source from .time import process_timestamp diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index 1ceaee633ae..919ee078a99 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -16,7 +16,7 @@ from homeassistant.const import ( COMPRESSED_STATE_STATE, ) from homeassistant.core import Context, State -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .state_attributes import decode_attributes_from_source diff --git a/homeassistant/components/recorder/models/time.py b/homeassistant/components/recorder/models/time.py index 33218000faa..91acad1500e 100644 --- a/homeassistant/components/recorder/models/time.py +++ b/homeassistant/components/recorder/models/time.py @@ -6,7 +6,7 @@ from datetime import datetime import logging from typing import overload -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/recorder/services.py b/homeassistant/components/recorder/services.py index 2be02fe8091..cc74d7a2376 100644 --- a/homeassistant/components/recorder/services.py +++ b/homeassistant/components/recorder/services.py @@ -9,13 +9,13 @@ import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import generate_filter from homeassistant.helpers.service import ( async_extract_entity_ids, async_register_admin_service, ) -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ATTR_APPLY_FILTER, ATTR_KEEP_DAYS, ATTR_REPACK, DOMAIN from .core import Recorder diff --git a/homeassistant/components/recorder/table_managers/recorder_runs.py b/homeassistant/components/recorder/table_managers/recorder_runs.py index 4ca0aa18b88..191fa44c194 100644 --- a/homeassistant/components/recorder/table_managers/recorder_runs.py +++ b/homeassistant/components/recorder/table_managers/recorder_runs.py @@ -6,7 +6,7 @@ from datetime import datetime from sqlalchemy.orm.session import Session -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ..db_schema import RecorderRuns diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index a1f8d90953c..a686c7c6498 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -34,7 +34,7 @@ from homeassistant.helpers.recorder import ( # noqa: F401 get_instance, session_scope, ) -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DEFAULT_MAX_BIND_VARS, DOMAIN, SQLITE_URL_PREFIX, SupportedDialect from .db_schema import ( diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 14104ad0219..dd293726484 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -39,7 +39,7 @@ from homeassistant.core import ( SupportsResponse, callback, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 531bbf37980..4ce596e72f0 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -17,7 +17,7 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME, Platform from homeassistant.core import Context, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JsonValueType, load_json_array diff --git a/homeassistant/components/shopping_list/intent.py b/homeassistant/components/shopping_list/intent.py index 1a6370f4168..118287f70d2 100644 --- a/homeassistant/components/shopping_list/intent.py +++ b/homeassistant/components/shopping_list/intent.py @@ -3,8 +3,7 @@ from __future__ import annotations from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, intent from . import DOMAIN, EVENT_SHOPPING_LIST_UPDATED diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 2772fc2d30e..8fa4c69ac5a 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -33,7 +33,7 @@ from yarl import URL from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.setup import SetupPhases, async_pause_setup from homeassistant.util.async_ import create_eager_task diff --git a/homeassistant/components/sun/trigger.py b/homeassistant/components/sun/trigger.py index 7724816d636..71498990b6f 100644 --- a/homeassistant/components/sun/trigger.py +++ b/homeassistant/components/sun/trigger.py @@ -11,7 +11,7 @@ from homeassistant.const import ( SUN_EVENT_SUNRISE, ) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_sunrise, async_track_sunset from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index 47c1d14ce60..8d42596d3db 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -13,14 +13,16 @@ from homeassistant.components import websocket_api from homeassistant.const import CONF_ID, CONF_NAME from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import collection, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + collection, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from homeassistant.util.hass_dict import HassKey from .const import DEFAULT_NAME, DEVICE_ID, DOMAIN, EVENT_TAG_SCANNED, LOGGER, TAG_ID diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index aa1f99f0423..a67e2969f9a 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -28,8 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import selector -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 922f1d88ffb..3c6e4899502 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -40,8 +40,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import selector, template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector, template from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 2642ede9c3a..306b4405c6a 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 7720ef7e1b3..6ed525fd45f 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -29,7 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index f194154a50c..0804f92e46d 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError, TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index d025f052732..8f9edca5976 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -33,7 +33,7 @@ from homeassistant.core import ( validate_state, ) from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import ( TrackTemplate, diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 19029cc708b..b977f4e659a 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 19b1de427ef..b0ade17b9c9 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -19,15 +19,14 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/trace/__init__.py b/homeassistant/components/trace/__init__.py index 9ff645ce4d6..bb0f3e5251a 100644 --- a/homeassistant/components/trace/__init__.py +++ b/homeassistant/components/trace/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import ExtendedJSONEncoder from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/webhook/trigger.py b/homeassistant/components/webhook/trigger.py index b4fd3008cd8..907123561f7 100644 --- a/homeassistant/components/webhook/trigger.py +++ b/homeassistant/components/webhook/trigger.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType From b7a344fd652b114492a44910c753ea0b057b563f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 19:11:48 +0100 Subject: [PATCH 1111/2987] Standardize homeassistant imports in core and base platforms (#136730) Standardize homeassistant imports in core --- homeassistant/auth/providers/trusted_networks.py | 2 +- homeassistant/components/alarm_control_panel/__init__.py | 2 +- .../components/alarm_control_panel/device_action.py | 3 +-- homeassistant/components/button/device_action.py | 3 +-- homeassistant/components/climate/device_action.py | 3 +-- homeassistant/components/cover/device_action.py | 3 +-- homeassistant/components/humidifier/device_action.py | 3 +-- homeassistant/components/humidifier/intent.py | 3 +-- homeassistant/components/image/__init__.py | 2 +- homeassistant/components/image_processing/__init__.py | 2 +- homeassistant/components/lock/__init__.py | 2 +- homeassistant/components/lock/device_action.py | 3 +-- homeassistant/components/notify/__init__.py | 4 ++-- homeassistant/components/notify/const.py | 2 +- homeassistant/components/number/device_action.py | 3 +-- homeassistant/components/select/device_action.py | 3 +-- homeassistant/components/switch/light.py | 3 +-- homeassistant/components/text/device_action.py | 3 +-- homeassistant/components/tts/__init__.py | 2 +- homeassistant/components/tts/legacy.py | 3 +-- homeassistant/components/tts/notify.py | 2 +- homeassistant/components/vacuum/device_action.py | 3 +-- homeassistant/components/water_heater/device_action.py | 3 +-- homeassistant/helpers/condition.py | 2 +- homeassistant/helpers/config_validation.py | 7 +++++-- homeassistant/helpers/issue_registry.py | 2 +- homeassistant/helpers/restore_state.py | 2 +- homeassistant/helpers/storage.py | 3 +-- homeassistant/helpers/trace.py | 2 +- homeassistant/scripts/ensure_config.py | 2 +- 30 files changed, 35 insertions(+), 47 deletions(-) diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index 799fd4d2e16..83299859de9 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -21,7 +21,7 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.network import is_cloud_connection from .. import InvalidAuthError diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 80a676a40fa..fde4638e179 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py index 72b1084d072..6779eada070 100644 --- a/homeassistant/components/alarm_control_panel/device_action.py +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -23,8 +23,7 @@ from homeassistant.const import ( SERVICE_ALARM_TRIGGER, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/button/device_action.py b/homeassistant/components/button/device_action.py index f4db7b619f8..30c0cc36835 100644 --- a/homeassistant/components/button/device_action.py +++ b/homeassistant/components/button/device_action.py @@ -13,8 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from .const import DOMAIN, SERVICE_PRESS diff --git a/homeassistant/components/climate/device_action.py b/homeassistant/components/climate/device_action.py index 84f166b752e..c9d098d7be6 100644 --- a/homeassistant/components/climate/device_action.py +++ b/homeassistant/components/climate/device_action.py @@ -17,8 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index acef2cde4d8..a982e99776b 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -20,8 +20,7 @@ from homeassistant.const import ( SERVICE_STOP_COVER, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/humidifier/device_action.py b/homeassistant/components/humidifier/device_action.py index 06440480277..9ff36412418 100644 --- a/homeassistant/components/humidifier/device_action.py +++ b/homeassistant/components/humidifier/device_action.py @@ -19,8 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType, VolDictType diff --git a/homeassistant/components/humidifier/intent.py b/homeassistant/components/humidifier/intent.py index 425fdbcc679..490143c728d 100644 --- a/homeassistant/components/humidifier/intent.py +++ b/homeassistant/components/humidifier/intent.py @@ -6,8 +6,7 @@ import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, STATE_OFF from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, intent from . import ( ATTR_AVAILABLE_MODES, diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 1cf2de278d1..644d335bbca 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -28,7 +28,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import ( diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 0ac8d39813b..06b6bb7a57f 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 60eb29240cd..05aed8a827f 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -29,7 +29,7 @@ from homeassistant.const import ( # noqa: F401 ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.deprecation import ( all_with_deprecated_constants, check_if_deprecated_constant, diff --git a/homeassistant/components/lock/device_action.py b/homeassistant/components/lock/device_action.py index a75966414f8..a396849f049 100644 --- a/homeassistant/components/lock/device_action.py +++ b/homeassistant/components/lock/device_action.py @@ -16,8 +16,7 @@ from homeassistant.const import ( SERVICE_UNLOCK, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 7f41817a683..97759db4c13 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -11,11 +11,11 @@ from typing import Any, final, override from propcache.api import cached_property import voluptuous as vol -import homeassistant.components.persistent_notification as pn +from homeassistant.components import persistent_notification as pn from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PLATFORM, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity diff --git a/homeassistant/components/notify/const.py b/homeassistant/components/notify/const.py index 29064f24a66..11ce4e801a1 100644 --- a/homeassistant/components/notify/const.py +++ b/homeassistant/components/notify/const.py @@ -4,7 +4,7 @@ import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv ATTR_DATA = "data" diff --git a/homeassistant/components/number/device_action.py b/homeassistant/components/number/device_action.py index 8882bb22a0d..6dd85e000bd 100644 --- a/homeassistant/components/number/device_action.py +++ b/homeassistant/components/number/device_action.py @@ -13,8 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from .const import ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE diff --git a/homeassistant/components/select/device_action.py b/homeassistant/components/select/device_action.py index a3827a23d41..1801d34d182 100644 --- a/homeassistant/components/select/device_action.py +++ b/homeassistant/components/select/device_action.py @@ -19,8 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_capability from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/switch/light.py b/homeassistant/components/switch/light.py index 48d555e6616..276496ce614 100644 --- a/homeassistant/components/switch/light.py +++ b/homeassistant/components/switch/light.py @@ -21,8 +21,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/text/device_action.py b/homeassistant/components/text/device_action.py index 94269ac12fb..b1eca1e36b6 100644 --- a/homeassistant/components/text/device_action.py +++ b/homeassistant/components/text/device_action.py @@ -13,8 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from .const import ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index bbe4d334def..6c7e521f3ef 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -43,7 +43,7 @@ from homeassistant.const import ( ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index 54ea89cb674..6f0541734d1 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -27,8 +27,7 @@ from homeassistant.const import ( CONF_PLATFORM, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import ( diff --git a/homeassistant/components/tts/notify.py b/homeassistant/components/tts/notify.py index 429d46660e7..c4c1bb1ae15 100644 --- a/homeassistant/components/tts/notify.py +++ b/homeassistant/components/tts/notify.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import ATTR_ENTITY_ID, CONF_ENTITY_ID, CONF_NAME from homeassistant.core import HomeAssistant, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ATTR_LANGUAGE, ATTR_MEDIA_PLAYER_ENTITY_ID, ATTR_MESSAGE, DOMAIN diff --git a/homeassistant/components/vacuum/device_action.py b/homeassistant/components/vacuum/device_action.py index 82c00a57b5e..0ae03d9219e 100644 --- a/homeassistant/components/vacuum/device_action.py +++ b/homeassistant/components/vacuum/device_action.py @@ -13,8 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START diff --git a/homeassistant/components/water_heater/device_action.py b/homeassistant/components/water_heater/device_action.py index 49cfc7e9a07..d68919ff8f3 100644 --- a/homeassistant/components/water_heater/device_action.py +++ b/homeassistant/components/water_heater/device_action.py @@ -15,8 +15,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 695af80bc1c..fa2dd42589b 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -56,8 +56,8 @@ from homeassistant.exceptions import ( TemplateError, ) from homeassistant.loader import IntegrationNotFound, async_get_integration +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe -import homeassistant.util.dt as dt_util from . import config_validation as cv, entity_registry as er from .sun import get_astral_event_date diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 2c8dbe69c22..4978158c0f6 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -107,8 +107,11 @@ from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.generated import currencies from homeassistant.generated.countries import COUNTRIES from homeassistant.generated.languages import LANGUAGES -from homeassistant.util import raise_if_invalid_path, slugify as util_slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import ( + dt as dt_util, + raise_if_invalid_path, + slugify as util_slugify, +) from homeassistant.util.yaml.objects import NodeStrClass from . import script_variables as script_variables_helper, template as template_helper diff --git a/homeassistant/helpers/issue_registry.py b/homeassistant/helpers/issue_registry.py index 109d363d262..1a1373e19ef 100644 --- a/homeassistant/helpers/issue_registry.py +++ b/homeassistant/helpers/issue_registry.py @@ -12,8 +12,8 @@ from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.const import __version__ as ha_version from homeassistant.core import HomeAssistant, callback +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe -import homeassistant.util.dt as dt_util from homeassistant.util.event_type import EventType from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index fd1f84a85ff..78812061a03 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -10,7 +10,7 @@ from typing import Any, Self, cast from homeassistant.const import ATTR_RESTORED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, State, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import json_loads diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index ac1fe3bb29d..fe94be68763 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -30,8 +30,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass -from homeassistant.util import json as json_util -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, json as json_util from homeassistant.util.file import WriteError from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index d191d474480..ef11028515a 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -10,7 +10,7 @@ from functools import wraps from typing import Any from homeassistant.core import ServiceResponse -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .typing import TemplateVarsType diff --git a/homeassistant/scripts/ensure_config.py b/homeassistant/scripts/ensure_config.py index e1ae7bc9142..1d568ec68b0 100644 --- a/homeassistant/scripts/ensure_config.py +++ b/homeassistant/scripts/ensure_config.py @@ -4,7 +4,7 @@ import argparse import asyncio import os -import homeassistant.config as config_util +from homeassistant import config as config_util from homeassistant.core import HomeAssistant # mypy: allow-untyped-calls, allow-untyped-defs From 37b23a9691f2e18d3395bea8459635c7bf1eb117 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 28 Jan 2025 19:17:34 +0100 Subject: [PATCH 1112/2987] Add pair/unpair buttons for tplink (#135847) --- homeassistant/components/tplink/button.py | 2 + homeassistant/components/tplink/strings.json | 6 ++ .../components/tplink/fixtures/features.json | 10 +++ .../tplink/snapshots/test_button.ambr | 79 +++++++++++++++++++ 4 files changed, 97 insertions(+) diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py index 0a4517b967d..6d9269b8c44 100644 --- a/homeassistant/components/tplink/button.py +++ b/homeassistant/components/tplink/button.py @@ -70,6 +70,8 @@ BUTTON_DESCRIPTIONS: Final = [ key="tilt_down", available_fn=lambda dev: dev.is_on, ), + TPLinkButtonEntityDescription(key="pair"), + TPLinkButtonEntityDescription(key="unpair"), ] BUTTON_DESCRIPTIONS_MAP = {desc.key: desc for desc in BUTTON_DESCRIPTIONS} diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index fa284a3cc83..304bf353b7c 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -138,6 +138,12 @@ }, "tilt_down": { "name": "Tilt down" + }, + "pair": { + "name": "Pair new device" + }, + "unpair": { + "name": "Unpair device" } }, "camera": { diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index 3d27e63b06a..adb6c08ee50 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -370,5 +370,15 @@ "value": 10, "type": "Number", "category": "Config" + }, + "pair": { + "value": "", + "type": "Action", + "category": "Config" + }, + "unpair": { + "value": "", + "type": "Action", + "category": "Debug" } } diff --git a/tests/components/tplink/snapshots/test_button.ambr b/tests/components/tplink/snapshots/test_button.ambr index de626cd5818..087aec39cfc 100644 --- a/tests/components/tplink/snapshots/test_button.ambr +++ b/tests/components/tplink/snapshots/test_button.ambr @@ -1,4 +1,50 @@ # serializer version: 1 +# name: test_states[button.my_device_pair_new_device-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_pair_new_device', + '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': 'Pair new device', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pair', + 'unique_id': '123456789ABCDEFGH_pair', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_pair_new_device-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Pair new device', + }), + 'context': , + 'entity_id': 'button.my_device_pair_new_device', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_states[button.my_device_pan_left-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -308,6 +354,39 @@ 'state': 'unknown', }) # --- +# name: test_states[button.my_device_unpair_device-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_unpair_device', + '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': 'Unpair device', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'unpair', + 'unique_id': '123456789ABCDEFGH_unpair', + 'unit_of_measurement': None, + }) +# --- # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, From 404ca283c6c66206125fd6512a06ee0322c1787c Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 28 Jan 2025 11:28:01 -0700 Subject: [PATCH 1113/2987] Let platforms decide entity creation in litterrobot (#136738) --- .../components/litterrobot/__init__.py | 43 ++++++------------- tests/components/litterrobot/conftest.py | 12 ++---- tests/components/litterrobot/test_vacuum.py | 4 -- 3 files changed, 15 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index 76274f987cd..1f926d37a61 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -2,8 +2,6 @@ from __future__ import annotations -from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot - from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry @@ -11,29 +9,16 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator -PLATFORMS_BY_TYPE = { - Robot: ( - Platform.BINARY_SENSOR, - Platform.SELECT, - Platform.SENSOR, - Platform.SWITCH, - ), - LitterRobot: (Platform.VACUUM,), - LitterRobot3: (Platform.BUTTON, Platform.TIME), - LitterRobot4: (Platform.UPDATE,), - FeederRobot: (Platform.BUTTON,), -} - - -def get_platforms_for_robots(robots: list[Robot]) -> set[Platform]: - """Get platforms for robots.""" - return { - platform - for robot in robots - for robot_type, platforms in PLATFORMS_BY_TYPE.items() - if isinstance(robot, robot_type) - for platform in platforms - } +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.TIME, + Platform.UPDATE, + Platform.VACUUM, +] async def async_setup_entry(hass: HomeAssistant, entry: LitterRobotConfigEntry) -> bool: @@ -41,9 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LitterRobotConfigEntry) coordinator = LitterRobotDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator - - if platforms := get_platforms_for_robots(coordinator.account.robots): - await hass.config_entries.async_forward_entry_setups(entry, platforms) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -52,9 +35,7 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" await entry.runtime_data.account.disconnect() - - platforms = get_platforms_for_robots(entry.runtime_data.account.robots) - return await hass.config_entries.async_unload_platforms(entry, platforms) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_remove_config_entry_device( diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index 5cd97e5937d..e60e0cbd36d 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -123,15 +123,9 @@ async def setup_integration( ) entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.litterrobot.coordinator.Account", - return_value=mock_account, - ), - patch( - "homeassistant.components.litterrobot.PLATFORMS_BY_TYPE", - {Robot: (platform_domain,)} if platform_domain else {}, - ), + with patch( + "homeassistant.components.litterrobot.coordinator.Account", + return_value=mock_account, ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index 0255e0e6a8a..911dfb3b880 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -33,7 +33,6 @@ async def test_vacuum( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_account: MagicMock ) -> None: """Tests the vacuum entity was set up.""" - entity_registry.async_get_or_create( VACUUM_DOMAIN, DOMAIN, @@ -44,7 +43,6 @@ async def test_vacuum( assert ent_reg_entry.unique_id == VACUUM_UNIQUE_ID await setup_integration(hass, mock_account, VACUUM_DOMAIN) - assert len(entity_registry.entities) == 1 assert hass.services.has_service(DOMAIN, SERVICE_SET_SLEEP_MODE) vacuum = hass.states.get(VACUUM_ENTITY_ID) @@ -63,8 +61,6 @@ async def test_no_robots( """Tests the vacuum entity was set up.""" entry = await setup_integration(hass, mock_account_with_no_robots, VACUUM_DOMAIN) - assert not hass.services.has_service(DOMAIN, SERVICE_SET_SLEEP_MODE) - assert len(entity_registry.entities) == 0 assert await hass.config_entries.async_unload(entry.entry_id) From bae9516fc23591b67f800aa64b99935564a4bc5f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jan 2025 08:44:25 -1000 Subject: [PATCH 1114/2987] Bump yeelight to 0.7.16 (#136679) --- homeassistant/components/yeelight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 6efb66449ab..cf7bc9c9035 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -16,7 +16,7 @@ }, "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.14", "async-upnp-client==0.43.0"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.43.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 57c534d0e2c..8f1d1fa9a70 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3095,7 +3095,7 @@ yalexs-ble==2.5.6 yalexs==8.10.0 # homeassistant.components.yeelight -yeelight==0.7.14 +yeelight==0.7.16 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4aa58667c2..43fbdb63b26 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2493,7 +2493,7 @@ yalexs-ble==2.5.6 yalexs==8.10.0 # homeassistant.components.yeelight -yeelight==0.7.14 +yeelight==0.7.16 # homeassistant.components.yolink yolink-api==0.4.7 From 55fc01be8e1ab00b95f7cabf5f11bd58bc177b1a Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 28 Jan 2025 20:55:06 +0200 Subject: [PATCH 1115/2987] Fix LG webOS TV actions not returning responses (#136743) --- .../components/webostv/media_player.py | 51 +++++++++++++------ .../webostv/snapshots/test_media_player.ambr | 18 +++++++ tests/components/webostv/test_media_player.py | 40 ++++++++++++--- 3 files changed, 87 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 796dede88b6..c8b871b3bf2 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Callable, Coroutine from contextlib import suppress from datetime import timedelta from functools import wraps @@ -23,7 +23,7 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.const import ATTR_COMMAND, ATTR_SUPPORTED_FEATURES -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -78,9 +78,24 @@ COMMAND_SCHEMA: VolDictType = { SOUND_OUTPUT_SCHEMA: VolDictType = {vol.Required(ATTR_SOUND_OUTPUT): cv.string} SERVICES = ( - (SERVICE_BUTTON, BUTTON_SCHEMA, "async_button"), - (SERVICE_COMMAND, COMMAND_SCHEMA, "async_command"), - (SERVICE_SELECT_SOUND_OUTPUT, SOUND_OUTPUT_SCHEMA, "async_select_sound_output"), + ( + SERVICE_BUTTON, + BUTTON_SCHEMA, + "async_button", + SupportsResponse.NONE, + ), + ( + SERVICE_COMMAND, + COMMAND_SCHEMA, + "async_command", + SupportsResponse.OPTIONAL, + ), + ( + SERVICE_SELECT_SOUND_OUTPUT, + SOUND_OUTPUT_SCHEMA, + "async_select_sound_output", + SupportsResponse.OPTIONAL, + ), ) @@ -92,19 +107,23 @@ async def async_setup_entry( """Set up the LG webOS TV platform.""" platform = entity_platform.async_get_current_platform() - for service_name, schema, method in SERVICES: - platform.async_register_entity_service(service_name, schema, method) + for service_name, schema, method, supports_response in SERVICES: + platform.async_register_entity_service( + service_name, schema, method, supports_response=supports_response + ) async_add_entities([LgWebOSMediaPlayerEntity(entry)]) -def cmd[_T: LgWebOSMediaPlayerEntity, **_P]( - func: Callable[Concatenate[_T, _P], Awaitable[None]], -) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: +def cmd[_R, **_P]( + func: Callable[Concatenate[LgWebOSMediaPlayerEntity, _P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[LgWebOSMediaPlayerEntity, _P], Coroutine[Any, Any, _R]]: """Catch command exceptions.""" @wraps(func) - async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + async def cmd_wrapper( + self: LgWebOSMediaPlayerEntity, *args: _P.args, **kwargs: _P.kwargs + ) -> _R: """Wrap all command methods.""" if self.state is MediaPlayerState.OFF: raise HomeAssistantError( @@ -116,7 +135,7 @@ def cmd[_T: LgWebOSMediaPlayerEntity, **_P]( }, ) try: - await func(self, *args, **kwargs) + return await func(self, *args, **kwargs) except WEBOSTV_EXCEPTIONS as error: raise HomeAssistantError( translation_domain=DOMAIN, @@ -376,9 +395,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): await self._client.set_mute(mute) @cmd - async def async_select_sound_output(self, sound_output: str) -> None: + async def async_select_sound_output(self, sound_output: str) -> ServiceResponse: """Select the sound output.""" - await self._client.change_sound_output(sound_output) + return await self._client.change_sound_output(sound_output) @cmd async def async_media_play_pause(self) -> None: @@ -481,9 +500,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): await self._client.button(button) @cmd - async def async_command(self, command: str, **kwargs: Any) -> None: + async def async_command(self, command: str, **kwargs: Any) -> ServiceResponse: """Send a command.""" - await self._client.request(command, payload=kwargs.get(ATTR_PAYLOAD)) + return await self._client.request(command, payload=kwargs.get(ATTR_PAYLOAD)) async def _async_fetch_image(self, url: str) -> tuple[bytes | None, str | None]: """Retrieve an image. diff --git a/tests/components/webostv/snapshots/test_media_player.ambr b/tests/components/webostv/snapshots/test_media_player.ambr index 78c0bd517a6..35a703cc109 100644 --- a/tests/components/webostv/snapshots/test_media_player.ambr +++ b/tests/components/webostv/snapshots/test_media_player.ambr @@ -1,4 +1,14 @@ # serializer version: 1 +# name: test_command + dict({ + 'media_player.lg_webos_tv_model': dict({ + 'muted': False, + 'returnValue': True, + 'scenario': 'mastervolume_tv_speaker_ext', + 'volume': 1, + }), + }) +# --- # name: test_entity_attributes StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -57,3 +67,11 @@ 'via_device_id': None, }) # --- +# name: test_select_sound_output + dict({ + 'media_player.lg_webos_tv_model': dict({ + 'method': 'setSystemSettings', + 'returnValue': True, + }), + }) +# --- diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index d5241dbe668..820ab856ebb 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -229,17 +229,30 @@ async def test_button(hass: HomeAssistant, client) -> None: client.button.assert_called_with("test") -async def test_command(hass: HomeAssistant, client) -> None: +async def test_command( + hass: HomeAssistant, + client, + snapshot: SnapshotAssertion, +) -> None: """Test generic command functionality.""" await setup_webostv(hass) + client.request.return_value = { + "returnValue": True, + "scenario": "mastervolume_tv_speaker_ext", + "volume": 1, + "muted": False, + } data = { ATTR_ENTITY_ID: ENTITY_ID, - ATTR_COMMAND: "test", + ATTR_COMMAND: "audio/getVolume", } - await hass.services.async_call(DOMAIN, SERVICE_COMMAND, data, True) + response = await hass.services.async_call( + DOMAIN, SERVICE_COMMAND, data, True, return_response=True + ) await hass.async_block_till_done() - client.request.assert_called_with("test", payload=None) + client.request.assert_called_with("audio/getVolume", payload=None) + assert response == snapshot async def test_command_with_optional_arg(hass: HomeAssistant, client) -> None: @@ -258,17 +271,32 @@ async def test_command_with_optional_arg(hass: HomeAssistant, client) -> None: ) -async def test_select_sound_output(hass: HomeAssistant, client) -> None: +async def test_select_sound_output( + hass: HomeAssistant, + client, + snapshot: SnapshotAssertion, +) -> None: """Test select sound output service.""" await setup_webostv(hass) + client.change_sound_output.return_value = { + "returnValue": True, + "method": "setSystemSettings", + } data = { ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_OUTPUT: "external_speaker", } - await hass.services.async_call(DOMAIN, SERVICE_SELECT_SOUND_OUTPUT, data, True) + response = await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_SOUND_OUTPUT, + data, + True, + return_response=True, + ) await hass.async_block_till_done() client.change_sound_output.assert_called_once_with("external_speaker") + assert response == snapshot async def test_device_info_startup_off( From ee1d76de9f5d22e4e24e84784bf0aafb03ea72e4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 28 Jan 2025 20:37:01 +0100 Subject: [PATCH 1116/2987] Capitalize "Velbus", replace "service calls" with "actions" (#136744) --- homeassistant/components/velbus/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index 90938a6c1d2..69fc3d661e9 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -2,9 +2,9 @@ "config": { "step": { "user": { - "title": "Define the velbus connection type", + "title": "Define the Velbus connection type", "data": { - "name": "The name for this velbus connection", + "name": "The name for this Velbus connection", "port": "Connection string" } } @@ -31,21 +31,21 @@ "services": { "sync_clock": { "name": "Sync clock", - "description": "Syncs the velbus modules clock to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink.", + "description": "Syncs the Velbus modules clock to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink.", "fields": { "interface": { "name": "Interface", - "description": "The velbus interface to send the command to, this will be the same value as used during configuration." + "description": "The Velbus interface to send the command to, this will be the same value as used during configuration." }, "config_entry": { "name": "Config entry", - "description": "The config entry of the velbus integration" + "description": "The config entry of the Velbus integration" } } }, "scan": { "name": "Scan", - "description": "Scans the velbus modules, this will be needed if you see unknown module warnings in the logs, or when you added new modules.", + "description": "Scans the Velbus modules, this will be needed if you see unknown module warnings in the logs, or when you added new modules.", "fields": { "interface": { "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", @@ -59,7 +59,7 @@ }, "clear_cache": { "name": "Clear cache", - "description": "Clears the velbuscache and then starts a new scan.", + "description": "Clears the Velbus cache and then starts a new scan.", "fields": { "interface": { "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", @@ -101,7 +101,7 @@ "issues": { "deprecated_interface_parameter": { "title": "Deprecated 'interface' parameter", - "description": "The 'interface' parameter in the Velbus service calls is deprecated. The 'config_entry' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." + "description": "The 'interface' parameter in the Velbus actions is deprecated. The 'config_entry' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." } } } From 1face8df565e4ad16281bcfcf8dc7a280a15c203 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jan 2025 09:43:00 -1000 Subject: [PATCH 1117/2987] Bump habluetooth to 3.13.0 (#136749) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 22f8aa8fdb8..b172a6c6aef 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.12.0" + "habluetooth==3.13.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e29c0f25d7c..e147ce58c57 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.12.0 +habluetooth==3.13.0 hass-nabucasa==0.88.1 hassil==2.1.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8f1d1fa9a70..88578c429ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.12.0 +habluetooth==3.13.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 43fbdb63b26..ce2eff26935 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -941,7 +941,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.12.0 +habluetooth==3.13.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 From 51ce6f093383c4065d9e350d78688391ca10c09e Mon Sep 17 00:00:00 2001 From: Richard Polzer Date: Tue, 28 Jan 2025 22:24:47 +0100 Subject: [PATCH 1118/2987] Update xknx to 3.5.0 (#136759) Dependency Bump 3.5.0 --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 73a61be68ee..acb9b9b61a0 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -10,7 +10,7 @@ "iot_class": "local_push", "loggers": ["xknx", "xknxproject"], "requirements": [ - "xknx==3.4.0", + "xknx==3.5.0", "xknxproject==3.8.1", "knx-frontend==2025.1.18.164225" ], diff --git a/requirements_all.txt b/requirements_all.txt index 88578c429ec..32be8cf54eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3067,7 +3067,7 @@ xbox-webapi==2.1.0 xiaomi-ble==0.33.0 # homeassistant.components.knx -xknx==3.4.0 +xknx==3.5.0 # homeassistant.components.knx xknxproject==3.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce2eff26935..db654e4bf4b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2468,7 +2468,7 @@ xbox-webapi==2.1.0 xiaomi-ble==0.33.0 # homeassistant.components.knx -xknx==3.4.0 +xknx==3.5.0 # homeassistant.components.knx xknxproject==3.8.1 From c46258fbf78ed94a6ed3c54fa0217f3c370d357a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 28 Jan 2025 21:39:33 +0000 Subject: [PATCH 1119/2987] Add volt/power/power_factor strings and state attrs for ZHA 3 phase meters (#133969) --- homeassistant/components/zha/sensor.py | 6 ++++++ homeassistant/components/zha/strings.json | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 670d6af3c52..0506496f447 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -46,9 +46,15 @@ _EXTRA_STATE_ATTRIBUTES: set[str] = { "rms_current_max_ph_b", "rms_current_max_ph_c", "rms_voltage_max", + "rms_voltage_max_ph_b", + "rms_voltage_max_ph_c", "ac_frequency_max", "power_factor_max", + "power_factor_max_ph_b", + "power_factor_max_ph_c", "active_power_max", + "active_power_max_ph_b", + "active_power_max_ph_c", # Smart Energy metering "device_type", "status", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 35c9f35887d..f3320e7560e 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -1031,6 +1031,12 @@ } }, "sensor": { + "active_power_ph_b": { + "name": "Power phase B" + }, + "active_power_ph_c": { + "name": "Power phase C" + }, "analog_input": { "name": "Analog input" }, @@ -1046,12 +1052,24 @@ "instantaneous_demand": { "name": "Instantaneous demand" }, + "power_factor_ph_b": { + "name": "Power factor phase B" + }, + "power_factor_ph_c": { + "name": "Power factor phase C" + }, "rms_current_ph_b": { "name": "Current phase B" }, "rms_current_ph_c": { "name": "Current phase C" }, + "rms_voltage_ph_b": { + "name": "Voltage phase B" + }, + "rms_voltage_ph_c": { + "name": "Voltage phase C" + }, "summation_delivered": { "name": "Summation delivered" }, From 814e98f66aa4ae1bafeb1a254d8025d3bf296177 Mon Sep 17 00:00:00 2001 From: mkmer <7760516+mkmer@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:50:01 -0500 Subject: [PATCH 1120/2987] Bump AIOSomecomfort to 0.0.32 (#136751) --- homeassistant/components/honeywell/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 36a4f497601..7fa102c6599 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.30"] + "requirements": ["AIOSomecomfort==0.0.32"] } diff --git a/requirements_all.txt b/requirements_all.txt index 32be8cf54eb..e4478503eb0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.30 +AIOSomecomfort==0.0.32 # homeassistant.components.adax Adax-local==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index db654e4bf4b..1a43f26f7e0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.30 +AIOSomecomfort==0.0.32 # homeassistant.components.adax Adax-local==0.1.5 From 77d9309b81d0ca190709158d06fa6f764e60b8d9 Mon Sep 17 00:00:00 2001 From: Richard Polzer Date: Tue, 28 Jan 2025 22:52:39 +0100 Subject: [PATCH 1121/2987] Add swing support for KNX climate entities (#136752) * added swing to knx climate * added tests for climate swing * removed type ignores * removed unreachable code --- homeassistant/components/knx/climate.py | 39 +++++++++++ homeassistant/components/knx/schema.py | 8 +++ tests/components/knx/test_climate.py | 88 +++++++++++++++++++++++++ 3 files changed, 135 insertions(+) diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 2c0153c5d2b..e3bb63581e7 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -19,6 +19,8 @@ from homeassistant.components.climate import ( FAN_LOW, FAN_MEDIUM, FAN_ON, + SWING_OFF, + SWING_ON, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -136,6 +138,14 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate: ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS ), fan_speed_mode=config[ClimateSchema.CONF_FAN_SPEED_MODE], + group_address_swing=config.get(ClimateSchema.CONF_SWING_ADDRESS), + group_address_swing_state=config.get(ClimateSchema.CONF_SWING_STATE_ADDRESS), + group_address_horizontal_swing=config.get( + ClimateSchema.CONF_SWING_HORIZONTAL_ADDRESS + ), + group_address_horizontal_swing_state=config.get( + ClimateSchema.CONF_SWING_HORIZONTAL_STATE_ADDRESS + ), group_address_humidity_state=config.get( ClimateSchema.CONF_HUMIDITY_STATE_ADDRESS ), @@ -207,6 +217,13 @@ class KNXClimate(KnxYamlEntity, ClimateEntity): self._attr_fan_modes = [self.fan_zero_mode] + [ f"{percentage}%" for percentage in self._fan_modes_percentages[1:] ] + if self._device.swing.initialized: + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + self._attr_swing_modes = [SWING_ON, SWING_OFF] + + if self._device.horizontal_swing.initialized: + self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE + self._attr_swing_horizontal_modes = [SWING_ON, SWING_OFF] self._attr_target_temperature_step = self._device.temperature_step self._attr_unique_id = ( @@ -399,6 +416,28 @@ class KNXClimate(KnxYamlEntity, ClimateEntity): await self._device.set_fan_speed(self._fan_modes_percentages[fan_mode_index]) + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set the swing setting.""" + await self._device.set_swing(swing_mode == SWING_ON) + + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set the horizontal swing setting.""" + await self._device.set_horizontal_swing(swing_horizontal_mode == SWING_ON) + + @property + def swing_mode(self) -> str | None: + """Return the swing setting.""" + if self._device.swing.value is not None: + return SWING_ON if self._device.swing.value else SWING_OFF + return None + + @property + def swing_horizontal_mode(self) -> str | None: + """Return the horizontal swing setting.""" + if self._device.horizontal_swing.value is not None: + return SWING_ON if self._device.horizontal_swing.value else SWING_OFF + return None + @property def current_humidity(self) -> float | None: """Return the current humidity.""" diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 5c83da58c3a..1ac2b82247c 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -339,6 +339,10 @@ class ClimateSchema(KNXPlatformSchema): CONF_FAN_SPEED_MODE = "fan_speed_mode" CONF_FAN_ZERO_MODE = "fan_zero_mode" CONF_HUMIDITY_STATE_ADDRESS = "humidity_state_address" + CONF_SWING_ADDRESS = "swing_address" + CONF_SWING_STATE_ADDRESS = "swing_state_address" + CONF_SWING_HORIZONTAL_ADDRESS = "swing_horizontal_address" + CONF_SWING_HORIZONTAL_STATE_ADDRESS = "swing_horizontal_state_address" DEFAULT_NAME = "KNX Climate" DEFAULT_SETPOINT_SHIFT_MODE = "DPT6010" @@ -427,6 +431,10 @@ class ClimateSchema(KNXPlatformSchema): vol.Optional(CONF_FAN_ZERO_MODE, default=FAN_OFF): vol.Coerce( FanZeroMode ), + vol.Optional(CONF_SWING_ADDRESS): ga_list_validator, + vol.Optional(CONF_SWING_STATE_ADDRESS): ga_list_validator, + vol.Optional(CONF_SWING_HORIZONTAL_ADDRESS): ga_list_validator, + vol.Optional(CONF_SWING_HORIZONTAL_STATE_ADDRESS): ga_list_validator, vol.Optional(CONF_HUMIDITY_STATE_ADDRESS): ga_list_validator, } ), diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index 8fb348f1724..b5a90428ef2 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -850,3 +850,91 @@ async def test_climate_humidity(hass: HomeAssistant, knx: KNXTestKit) -> None: HVACMode.HEAT, current_humidity=45.6, ) + + +async def test_swing(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX climate swing.""" + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_SWING_ADDRESS: "1/2/6", + ClimateSchema.CONF_SWING_STATE_ADDRESS: "1/2/7", + } + } + ) + + # read states state updater + await knx.assert_read("1/2/3") + await knx.assert_read("1/2/5") + + # StateUpdater initialize state + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + + # Query status + await knx.assert_read("1/2/7") + await knx.receive_response("1/2/7", True) + knx.assert_state( + "climate.test", + HVACMode.HEAT, + swing_mode="on", + swing_modes=["on", "off"], + ) + + # turn off + await hass.services.async_call( + "climate", + "set_swing_mode", + {"entity_id": "climate.test", "swing_mode": "off"}, + blocking=True, + ) + await knx.assert_write("1/2/6", False) + knx.assert_state("climate.test", HVACMode.HEAT, swing_mode="off") + + +async def test_horizontal_swing(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX climate horizontal swing.""" + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_SWING_HORIZONTAL_ADDRESS: "1/2/6", + ClimateSchema.CONF_SWING_HORIZONTAL_STATE_ADDRESS: "1/2/7", + } + } + ) + + # read states state updater + await knx.assert_read("1/2/3") + await knx.assert_read("1/2/5") + + # StateUpdater initialize state + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + + # Query status + await knx.assert_read("1/2/7") + await knx.receive_response("1/2/7", True) + knx.assert_state( + "climate.test", + HVACMode.HEAT, + swing_horizontal_mode="on", + swing_horizontal_modes=["on", "off"], + ) + + # turn off + await hass.services.async_call( + "climate", + "set_swing_horizontal_mode", + {"entity_id": "climate.test", "swing_horizontal_mode": "off"}, + blocking=True, + ) + await knx.assert_write("1/2/6", False) + knx.assert_state("climate.test", HVACMode.HEAT, swing_horizontal_mode="off") From cc4abcadcdb0f31c6249f43af1b5829b853507cc Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 28 Jan 2025 23:32:13 +0100 Subject: [PATCH 1122/2987] Add translations for ZHA pilot wire mode and device mode (#136753) --- homeassistant/components/zha/icons.json | 6 ++++++ homeassistant/components/zha/strings.json | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/homeassistant/components/zha/icons.json b/homeassistant/components/zha/icons.json index 6ba4aab18ab..d43e213aa4a 100644 --- a/homeassistant/components/zha/icons.json +++ b/homeassistant/components/zha/icons.json @@ -124,6 +124,12 @@ }, "on_led_color": { "default": "mdi:palette" + }, + "device_mode": { + "default": "mdi:cogs" + }, + "pilot_wire_mode": { + "default": "mdi:radiator" } }, "sensor": { diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index f3320e7560e..5d4fec92af7 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -1028,6 +1028,12 @@ }, "operation_mode": { "name": "Operation mode" + }, + "device_mode": { + "name": "Device mode" + }, + "pilot_wire_mode": { + "name": "Pilot wire mode" } }, "sensor": { From c55caabbffeee93f70535d613f7f5982dd548bd9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jan 2025 13:05:53 -1000 Subject: [PATCH 1123/2987] Abort Bluetooth options flow if local adapters do not support passive scans (#136748) --- .../components/bluetooth/config_flow.py | 18 ++++++++++++- .../components/bluetooth/strings.json | 3 ++- .../components/bluetooth/test_config_flow.py | 27 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index 5bfe5e7089c..6425aabe12f 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -211,10 +211,16 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> SchemaOptionsFlowHandler | RemoteAdapterOptionsFlowHandler: + ) -> ( + SchemaOptionsFlowHandler + | RemoteAdapterOptionsFlowHandler + | LocalNoPassiveOptionsFlowHandler + ): """Get the options flow for this handler.""" if CONF_SOURCE in config_entry.data: return RemoteAdapterOptionsFlowHandler() + if not (manager := get_manager()) or not manager.supports_passive_scan: + return LocalNoPassiveOptionsFlowHandler() return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) @classmethod @@ -232,3 +238,13 @@ class RemoteAdapterOptionsFlowHandler(OptionsFlow): ) -> ConfigFlowResult: """Handle options flow.""" return self.async_abort(reason="remote_adapters_not_supported") + + +class LocalNoPassiveOptionsFlowHandler(OptionsFlow): + """Handle a option flow for local adapters with no passive support.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle options flow.""" + return self.async_abort(reason="local_adapters_no_passive_support") diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index 1b8231c66ca..5f9a380d631 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -35,7 +35,8 @@ } }, "abort": { - "remote_adapters_not_supported": "Bluetooth configuration for remote adapters is not supported." + "remote_adapters_not_supported": "Bluetooth configuration for remote adapters is not supported.", + "local_adapters_no_passive_support": "Local Bluetooth adapters that do not support passive scanning cannot be configured." } } } diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index abb3a5e2393..0070bebe4b6 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -487,6 +487,33 @@ async def test_options_flow_remote_adapter(hass: HomeAssistant) -> None: assert result["reason"] == "remote_adapters_not_supported" +@pytest.mark.usefixtures( + "one_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters" +) +async def test_options_flow_local_no_passive_support(hass: HomeAssistant) -> None: + """Test options are not available for local adapters without passive support.""" + source_entry = MockConfigEntry( + domain="test", + ) + source_entry.add_to_hass(hass) + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={}, + unique_id="BB:BB:BB:BB:BB:BB", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + _get_manager()._adapters["hci0"]["passive_scan"] = False + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "local_adapters_no_passive_support" + + @pytest.mark.usefixtures("one_adapter") async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> None: """Test we give a hint that the adapter is ignored.""" From 29a3f0a27193be0ac328da1148ef559c3278d376 Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Wed, 29 Jan 2025 00:06:19 +0100 Subject: [PATCH 1124/2987] Bump homematicip to 1.1.7 (#136767) --- homeassistant/components/homematicip_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 6fc422498ab..414ba37709e 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==1.1.6"] + "requirements": ["homematicip==1.1.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index e4478503eb0..ba11e9c0d6d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1152,7 +1152,7 @@ home-assistant-intents==2025.1.1 homeconnect==0.8.0 # homeassistant.components.homematicip_cloud -homematicip==1.1.6 +homematicip==1.1.7 # homeassistant.components.horizon horimote==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a43f26f7e0..4f7bf17edda 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ home-assistant-intents==2025.1.1 homeconnect==0.8.0 # homeassistant.components.homematicip_cloud -homematicip==1.1.6 +homematicip==1.1.7 # homeassistant.components.remember_the_milk httplib2==0.20.4 From 68dbe34b89076b7f921c8b6daaf36ecc20174dec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jan 2025 13:06:24 -1000 Subject: [PATCH 1125/2987] Add Bluetooth WebSocket API to subscribe to scanner details (#136750) --- .../components/bluetooth/websocket_api.py | 64 +++++++- .../bluetooth/test_websocket_api.py | 142 +++++++++++++++++- 2 files changed, 201 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/bluetooth/websocket_api.py b/homeassistant/components/bluetooth/websocket_api.py index 2829617d09e..d21b11b050f 100644 --- a/homeassistant/components/bluetooth/websocket_api.py +++ b/homeassistant/components/bluetooth/websocket_api.py @@ -7,7 +7,12 @@ from functools import lru_cache, partial import time from typing import Any -from habluetooth import BluetoothScanningMode, HaBluetoothSlotAllocations +from habluetooth import ( + BluetoothScanningMode, + HaBluetoothSlotAllocations, + HaScannerRegistration, + HaScannerRegistrationEvent, +) from home_assistant_bluetooth import BluetoothServiceInfoBleak import voluptuous as vol @@ -16,6 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.json import json_bytes from .api import _get_manager, async_register_callback +from .const import DOMAIN from .match import BluetoothCallbackMatcher from .models import BluetoothChange from .util import InvalidConfigEntryID, InvalidSource, config_entry_id_to_source @@ -26,6 +32,7 @@ def async_setup(hass: HomeAssistant) -> None: """Set up the bluetooth websocket API.""" websocket_api.async_register_command(hass, ws_subscribe_advertisements) websocket_api.async_register_command(hass, ws_subscribe_connection_allocations) + websocket_api.async_register_command(hass, ws_subscribe_scanner_details) @lru_cache(maxsize=1024) @@ -191,3 +198,58 @@ async def ws_subscribe_connection_allocations( connection.send_message( json_bytes(websocket_api.event_message(ws_msg_id, current_allocations)) ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "bluetooth/subscribe_scanner_details", + vol.Optional("config_entry_id"): str, + } +) +@websocket_api.async_response +async def ws_subscribe_scanner_details( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe scanner details websocket command.""" + ws_msg_id = msg["id"] + source: str | None = None + if config_entry_id := msg.get("config_entry_id"): + if ( + not (entry := hass.config_entries.async_get_entry(config_entry_id)) + or entry.domain != DOMAIN + ): + connection.send_error( + ws_msg_id, + "invalid_config_entry_id", + f"Invalid config entry id: {config_entry_id}", + ) + return + source = entry.unique_id + assert source is not None + + def _async_event_message(message: dict[str, Any]) -> None: + connection.send_message( + json_bytes(websocket_api.event_message(ws_msg_id, message)) + ) + + def _async_registration_changed(registration: HaScannerRegistration) -> None: + added_event = HaScannerRegistrationEvent.ADDED + event_type = "add" if registration.event == added_event else "remove" + _async_event_message({event_type: [registration.scanner.details]}) + + manager = _get_manager(hass) + connection.subscriptions[ws_msg_id] = ( + manager.async_register_scanner_registration_callback( + _async_registration_changed, source + ) + ) + connection.send_message(json_bytes(websocket_api.result_message(ws_msg_id))) + if (scanners := manager.async_current_scanners()) and ( + matching_scanners := [ + scanner.details + for scanner in scanners + if source is None or scanner.source == source + ] + ): + _async_event_message({"add": matching_scanners}) diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py index d9289fe8380..bacdbbd5eed 100644 --- a/tests/components/bluetooth/test_websocket_api.py +++ b/tests/components/bluetooth/test_websocket_api.py @@ -17,6 +17,7 @@ from . import ( HCI0_SOURCE_ADDRESS, HCI1_SOURCE_ADDRESS, NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, + FakeScanner, _get_manager, generate_advertisement_data, generate_ble_device, @@ -123,7 +124,7 @@ async def test_subscribe_advertisements( @pytest.mark.usefixtures("enable_bluetooth") -async def test_subscribe_subscribe_connection_allocations( +async def test_subscribe_connection_allocations( hass: HomeAssistant, register_hci0_scanner: None, register_hci1_scanner: None, @@ -201,7 +202,7 @@ async def test_subscribe_subscribe_connection_allocations( @pytest.mark.usefixtures("enable_bluetooth") -async def test_subscribe_subscribe_connection_allocations_specific_scanner( +async def test_subscribe_connection_allocations_specific_scanner( hass: HomeAssistant, register_non_connectable_scanner: None, hass_ws_client: WebSocketGenerator, @@ -237,7 +238,7 @@ async def test_subscribe_subscribe_connection_allocations_specific_scanner( @pytest.mark.usefixtures("enable_bluetooth") -async def test_subscribe_subscribe_connection_allocations_invalid_config_entry_id( +async def test_subscribe_connection_allocations_invalid_config_entry_id( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: @@ -258,7 +259,7 @@ async def test_subscribe_subscribe_connection_allocations_invalid_config_entry_i @pytest.mark.usefixtures("enable_bluetooth") -async def test_subscribe_subscribe_connection_allocations_invalid_scanner( +async def test_subscribe_connection_allocations_invalid_scanner( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: @@ -278,3 +279,136 @@ async def test_subscribe_subscribe_connection_allocations_invalid_scanner( assert not response["success"] assert response["error"]["code"] == "invalid_source" assert response["error"]["message"] == "Source invalid not found" + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_scanner_details( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_connection_allocations.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_scanner_details", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + async with asyncio.timeout(1): + response = await client.receive_json() + + assert response["event"] == { + "add": [ + { + "adapter": "hci0", + "connectable": False, + "name": "hci0 (00:00:00:00:00:01)", + "source": "00:00:00:00:00:01", + } + ] + } + + manager = _get_manager() + hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3") + cancel_hci3 = manager.async_register_hass_scanner(hci3_scanner) + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + cancel_hci3() + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "remove": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_scanner_details_specific_scanner( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_scanner_details for a specific source address.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id="AA:BB:CC:DD:EE:33") + entry.add_to_hass(hass) + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_scanner_details", + "config_entry_id": entry.entry_id, + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + manager = _get_manager() + hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3") + cancel_hci3 = manager.async_register_hass_scanner(hci3_scanner) + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + cancel_hci3() + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "remove": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_scanner_details_invalid_config_entry_id( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_scanner_details for an invalid config entry id.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_scanner_details", + "config_entry_id": "non_existent", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_config_entry_id" + assert response["error"]["message"] == "Invalid config entry id: non_existent" From eb4a05e3652b016056f9cf85e1095329bb7c1d27 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 28 Jan 2025 17:58:53 -0600 Subject: [PATCH 1126/2987] Bump hassil to 2.2.0 (#136787) --- .../components/conversation/manifest.json | 2 +- homeassistant/components/conversation/trigger.py | 14 ++++++++++++-- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- .../conversation/snapshots/test_http.ambr | 4 ++-- 7 files changed, 19 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 979ea7538c4..7ca1799b2d1 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.1.0", "home-assistant-intents==2025.1.1"] + "requirements": ["hassil==2.2.0", "home-assistant-intents==2025.1.1"] } diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index 634ae1fd9aa..752e294a8b3 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -5,7 +5,12 @@ from __future__ import annotations from typing import Any from hassil.recognize import RecognizeResult -from hassil.util import PUNCTUATION_ALL +from hassil.util import ( + PUNCTUATION_END, + PUNCTUATION_END_WORD, + PUNCTUATION_START, + PUNCTUATION_START_WORD, +) import voluptuous as vol from homeassistant.const import CONF_COMMAND, CONF_PLATFORM @@ -22,7 +27,12 @@ from .models import ConversationInput def has_no_punctuation(value: list[str]) -> list[str]: """Validate result does not contain punctuation.""" for sentence in value: - if PUNCTUATION_ALL.search(sentence): + if ( + PUNCTUATION_START.search(sentence) + or PUNCTUATION_END.search(sentence) + or PUNCTUATION_START_WORD.search(sentence) + or PUNCTUATION_END_WORD.search(sentence) + ): raise vol.Invalid("sentence should not contain punctuation") return value diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e147ce58c57..51393c2a516 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.13.0 hass-nabucasa==0.88.1 -hassil==2.1.0 +hassil==2.2.0 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20250109.2 home-assistant-intents==2025.1.1 diff --git a/requirements_all.txt b/requirements_all.txt index ba11e9c0d6d..a366b5f2f32 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1109,7 +1109,7 @@ hass-nabucasa==0.88.1 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==2.1.0 +hassil==2.2.0 # homeassistant.components.jewish_calendar hdate==0.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f7bf17edda..a8e934d8dd2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ habluetooth==3.13.0 hass-nabucasa==0.88.1 # homeassistant.components.conversation -hassil==2.1.0 +hassil==2.2.0 # homeassistant.components.jewish_calendar hdate==0.11.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 21b98d30f1e..5700ca01462 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.21,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.9.1 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.1.0 home-assistant-intents==2025.1.1 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.0 home-assistant-intents==2025.1.1 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 1102a41e6c3..3e71ee99382 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -638,7 +638,7 @@ 'brightness': dict({ 'name': 'brightness', 'text': '100', - 'value': 100, + 'value': 100.0, }), 'name': dict({ 'name': 'name', @@ -690,7 +690,7 @@ 'targets': dict({ }), 'unmatched_slots': dict({ - 'brightness': 1001, + 'brightness': 1001.0, }), }), ]), From 7d0e314c356e7663d77c6941e46844580218d835 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Wed, 29 Jan 2025 01:00:46 +0100 Subject: [PATCH 1127/2987] Bumb python-homewizard-energy to 8.3.0 (#136765) --- homeassistant/components/homewizard/manifest.json | 2 +- homeassistant/components/homewizard/sensor.py | 11 +++++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../homewizard/snapshots/test_diagnostics.ambr | 9 +++++++++ 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index b1a19134752..957ed912b7d 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==v8.2.0"], + "requirements": ["python-homewizard-energy==v8.3.0"], "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index b6227a03bed..02355bc6c5e 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -116,8 +116,15 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - has_fn=lambda data: data.measurement.wifi_strength is not None, - value_fn=lambda data: data.measurement.wifi_strength, + has_fn=( + lambda data: data.system is not None + and data.system.wifi_strength_pct is not None + ), + value_fn=( + lambda data: data.system.wifi_strength_pct + if data.system is not None + else None + ), ), HomeWizardSensorEntityDescription( key="total_power_import_kwh", diff --git a/requirements_all.txt b/requirements_all.txt index a366b5f2f32..b0823505444 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2388,7 +2388,7 @@ python-gitlab==1.6.0 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.2.0 +python-homewizard-energy==v8.3.0 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a8e934d8dd2..dfd3edcba67 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1933,7 +1933,7 @@ python-fullykiosk==0.0.14 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.2.0 +python-homewizard-energy==v8.3.0 # homeassistant.components.izone python-izone==1.2.9 diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index 192b9dbdc32..2545f674bbd 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -79,6 +79,7 @@ 'uptime_s': 356, 'wifi_rssi_db': -77, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 100, }), }), 'entry': dict({ @@ -169,6 +170,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 92, }), }), 'entry': dict({ @@ -259,6 +261,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 92, }), }), 'entry': dict({ @@ -385,6 +388,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 100, }), }), 'entry': dict({ @@ -479,6 +483,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 94, }), }), 'entry': dict({ @@ -573,6 +578,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 100, }), }), 'entry': dict({ @@ -663,6 +669,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 84, }), }), 'entry': dict({ @@ -753,6 +760,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 92, }), }), 'entry': dict({ @@ -843,6 +851,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 92, }), }), 'entry': dict({ From 898d12aa21e5e5eb01aee605b6f7098056f8e5db Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 29 Jan 2025 02:05:05 +0200 Subject: [PATCH 1128/2987] Bump aiowebostv to 0.6.1 (#136784) --- 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 f1a8e163398..174e8025dd0 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.0"], + "requirements": ["aiowebostv==0.6.1"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index b0823505444..17cf0bcbd8d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -419,7 +419,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.6.0 +aiowebostv==0.6.1 # homeassistant.components.withings aiowithings==3.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dfd3edcba67..fda1b7c4630 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -401,7 +401,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.6.0 +aiowebostv==0.6.1 # homeassistant.components.withings aiowithings==3.1.5 From fa2aeae30f15732e44c44ad80fd3a3b5c3899d4a Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 29 Jan 2025 01:05:32 +0100 Subject: [PATCH 1129/2987] Bump ZHA to 0.0.46 (#136785) --- homeassistant/components/zha/manifest.json | 2 +- homeassistant/components/zha/strings.json | 342 +++++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 345 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index f9323fe99df..fa8bab409c9 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.45"], + "requirements": ["zha==0.0.46"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 5d4fec92af7..c73a0989faa 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -592,6 +592,24 @@ }, "window_detection": { "name": "Open window detection" + }, + "silence_alarm": { + "name": "Silence alarm" + }, + "preheat_active": { + "name": "Preheat active" + }, + "fault_alarm": { + "name": "Fault alarm" + }, + "led_indicator": { + "name": "LED indicator" + }, + "error_or_battery_low": { + "name": "Error or battery low" + }, + "flow_switch": { + "name": "Flow switch" } }, "button": { @@ -612,6 +630,9 @@ }, "restart_device": { "name": "Restart device" + }, + "frost_lock_reset": { + "name": "Frost lock reset" } }, "climate": { @@ -885,6 +906,144 @@ }, "fading_time": { "name": "Fading time" + }, + "temperature_offset": { + "name": "Temperature offset" + }, + "humidity_offset": { + "name": "Humidity offset" + }, + "comfort_temperature_min": { + "name": "Comfort temperature min" + }, + "comfort_temperature_max": { + "name": "Comfort temperature max" + }, + "comfort_humidity_min": { + "name": "Comfort humidity min" + }, + "comfort_humidity_max": { + "name": "Comfort humidity max" + }, + "measurement_interval": { + "name": "Measurement interval" + }, + "on_time": { + "name": "On time" + }, + "alarm_duration": { + "name": "Alarm duration" + }, + "max_set": { + "name": "Liquid max percentage" + }, + "mini_set": { + "name": "Liquid minimal percentage" + }, + "installation_height": { + "name": "Height from sensor to tank bottom" + }, + "liquid_depth_max": { + "name": "Height from sensor to liquid level" + }, + "interval_time": { + "name": "Interval time" + }, + "target_distance": { + "name": "Target distance" + }, + "hold_delay_time": { + "name": "Hold delay time" + }, + "breath_detection_max": { + "name": "Breath detection max" + }, + "breath_detection_min": { + "name": "Breath detection min" + }, + "small_move_detection_max": { + "name": "Small move detection max" + }, + "small_move_detection_min": { + "name": "Small move detection min" + }, + "small_move_sensitivity": { + "name": "Small move sensitivity" + }, + "breath_sensitivity": { + "name": "Breath sensitivity" + }, + "entry_sensitivity": { + "name": "Entry sensitivity" + }, + "entry_distance_indentation": { + "name": "Entry distance indentation" + }, + "illuminance_threshold": { + "name": "Illuminance threshold" + }, + "block_time": { + "name": "Block time" + }, + "motion_sensitivity": { + "name": "Motion sensitivity" + }, + "radar_sensitivity": { + "name": "Radar sensitivity" + }, + "motionless_detection": { + "name": "Motionless detection" + }, + "motionless_sensitivity": { + "name": "Motionless detection sensitivity" + }, + "output_time": { + "name": "Output time" + }, + "illuminance_interval": { + "name": "Illuminance interval" + }, + "temperature_report_interval": { + "name": "Temperature report interval" + }, + "humidity_report_interval": { + "name": "Humidity report interval" + }, + "alarm_temperature_max": { + "name": "Alarm temperature max" + }, + "alarm_temperature_min": { + "name": "Alarm temperature min" + }, + "temperature_sensitivity": { + "name": "Temperature sensitivity" + }, + "alarm_humidity_max": { + "name": "Alarm humidity max" + }, + "alarm_humidity_min": { + "name": "Alarm humidity min" + }, + "humidity_sensitivity": { + "name": "Humidity sensitivity" + }, + "deadzone_temperature": { + "name": "Deadzone temperature" + }, + "min_temperature": { + "name": "Min temperature" + }, + "max_temperature": { + "name": "Max temperature" + }, + "valve_countdown": { + "name": "Irrigation time" + }, + "quantitative_watering": { + "name": "Quantitative watering" + }, + "valve_duration": { + "name": "Irrigation duration" } }, "select": { @@ -1034,6 +1193,48 @@ }, "pilot_wire_mode": { "name": "Pilot wire mode" + }, + "alarm_ringtone": { + "name": "Alarm ringtone" + }, + "liquid_state": { + "name": "Liquid state" + }, + "breaker_mode": { + "name": "Breaker mode" + }, + "breaker_status": { + "name": "Breaker status" + }, + "status_indication": { + "name": "Status indication" + }, + "breaker_polarity": { + "name": "Breaker polarity" + }, + "work_mode": { + "name": "Work mode" + }, + "presence_sensitivity": { + "name": "Presence sensitivity" + }, + "fading_time": { + "name": "Fading time" + }, + "display_unit": { + "name": "Display unit" + }, + "alarm_mode": { + "name": "Alarm mode" + }, + "alarm_volume": { + "name": "Alarm volume" + }, + "working_day": { + "name": "Working day" + }, + "eco_mode": { + "name": "Eco mode" } }, "sensor": { @@ -1276,6 +1477,90 @@ }, "self_test": { "name": "Self test result" + }, + "voc_index": { + "name": "VOC index" + }, + "energy_ph_a": { + "name": "Energy phase A" + }, + "energy_ph_b": { + "name": "Energy phase B" + }, + "energy_ph_c": { + "name": "Energy phase C" + }, + "energy_produced": { + "name": "Energy produced" + }, + "energy_produced_ph_a": { + "name": "Energy produced phase A" + }, + "energy_produced_ph_b": { + "name": "Energy produced phase B" + }, + "energy_produced_ph_c": { + "name": "Energy produced phase C" + }, + "total_power_factor": { + "name": "Total power factor" + }, + "self_test_result": { + "name": "Self test result" + }, + "lower_explosive_limit": { + "name": "% Lower explosive limit" + }, + "liquid_depth": { + "name": "Liquid depth" + }, + "liquid_level_percent": { + "name": "Liquid level ratio" + }, + "target_distance": { + "name": "Target distance" + }, + "human_motion_state": { + "name": "Human motion state" + }, + "temperature_alarm": { + "name": "Temperature alarm" + }, + "humidity_alarm": { + "name": "Humidity alarm" + }, + "alarm_state": { + "name": "Alarm state" + }, + "power_type": { + "name": "Power type" + }, + "valve_position": { + "name": "Valve position" + }, + "time_left": { + "name": "Time left" + }, + "valve_status": { + "name": "Valve status" + }, + "valve_duration": { + "name": "Irrigation duration" + }, + "smart_irrigation": { + "name": "Smart irrigation" + }, + "surplus_flow": { + "name": "Surplus flow" + }, + "single_watering_duration": { + "name": "Single watering duration" + }, + "single_watering_amount": { + "name": "Single watering amount" + }, + "error_status": { + "name": "Error status" } }, "switch": { @@ -1404,6 +1689,63 @@ }, "find_switch": { "name": "Distance switch" + }, + "display_enabled": { + "name": "Display enabled" + }, + "show_smiley": { + "name": "Show smiley" + }, + "on_only_when_dark": { + "name": "On only when dark" + }, + "mute_siren": { + "name": "Mute siren" + }, + "self_test_switch": { + "name": "Self test" + }, + "output_switch": { + "name": "Output switch" + }, + "siren_on": { + "name": "Siren on" + }, + "enable_tamper_alarm": { + "name": "Enable tamper alarm" + }, + "temperature_alarm": { + "name": "Temperature alarm" + }, + "humidity_alarm": { + "name": "Humidity alarm" + }, + "silence_alarm": { + "name": "Silence alarm" + }, + "frost_protection": { + "name": "Frost protection" + }, + "factory_reset": { + "name": "Factory reset" + }, + "away_mode": { + "name": "Away mode" + }, + "schedule_enable": { + "name": "Schedule enable" + }, + "scale_protection": { + "name": "Scale protection" + }, + "frost_lock": { + "name": "Frost lock" + }, + "switch_enabled": { + "name": "Switch enabled" + }, + "total_flow_reset_switch": { + "name": "Total flow reset switch" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 17cf0bcbd8d..71921b7d41c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3128,7 +3128,7 @@ zeroconf==0.141.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.45 +zha==0.0.46 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fda1b7c4630..5ba9dd11345 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2517,7 +2517,7 @@ zeroconf==0.141.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.45 +zha==0.0.46 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 From 177bb29f6912e786351ad5669675f52bbfb21d52 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Jan 2025 01:08:27 +0100 Subject: [PATCH 1130/2987] Explicitly pass in the config_entry in Feedreader coordinator init (#136777) --- .../components/feedreader/__init__.py | 16 +++------- .../components/feedreader/coordinator.py | 29 ++++++++++--------- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 9faed54c041..31617cb220b 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -2,15 +2,12 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_URL, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.util.hass_dict import HassKey -from .const import CONF_MAX_ENTRIES, DOMAIN -from .coordinator import FeedReaderCoordinator, StoredData - -type FeedReaderConfigEntry = ConfigEntry[FeedReaderCoordinator] +from .const import DOMAIN +from .coordinator import FeedReaderConfigEntry, FeedReaderCoordinator, StoredData CONF_URLS = "urls" @@ -23,12 +20,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) - if not storage.is_initialized: await storage.async_setup() - coordinator = FeedReaderCoordinator( - hass, - entry.data[CONF_URL], - entry.options[CONF_MAX_ENTRIES], - storage, - ) + coordinator = FeedReaderCoordinator(hass, entry, storage) await coordinator.async_setup() diff --git a/homeassistant/components/feedreader/coordinator.py b/homeassistant/components/feedreader/coordinator.py index fc338d63268..9901bd9f1b4 100644 --- a/homeassistant/components/feedreader/coordinator.py +++ b/homeassistant/components/feedreader/coordinator.py @@ -13,13 +13,14 @@ from urllib.error import URLError import feedparser from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, EVENT_FEEDREADER +from .const import CONF_MAX_ENTRIES, DEFAULT_SCAN_INTERVAL, DOMAIN, EVENT_FEEDREADER DELAY_SAVE = 30 STORAGE_VERSION = 1 @@ -27,37 +28,39 @@ STORAGE_VERSION = 1 _LOGGER = getLogger(__name__) +type FeedReaderConfigEntry = ConfigEntry[FeedReaderCoordinator] + class FeedReaderCoordinator( DataUpdateCoordinator[list[feedparser.FeedParserDict] | None] ): """Abstraction over Feedparser module.""" - config_entry: ConfigEntry + config_entry: FeedReaderConfigEntry def __init__( self, hass: HomeAssistant, - url: str, - max_entries: int, + config_entry: FeedReaderConfigEntry, storage: StoredData, ) -> None: """Initialize the FeedManager object, poll as per scan interval.""" - super().__init__( - hass=hass, - logger=_LOGGER, - name=f"{DOMAIN} {url}", - update_interval=DEFAULT_SCAN_INTERVAL, - ) - self.url = url + self.url = config_entry.data[CONF_URL] self.feed_author: str | None = None self.feed_version: str | None = None - self._max_entries = max_entries + self._max_entries = config_entry.options[CONF_MAX_ENTRIES] self._storage = storage self._last_entry_timestamp: struct_time | None = None self._event_type = EVENT_FEEDREADER self._feed: feedparser.FeedParserDict | None = None - self._feed_id = url + self._feed_id = self.url + super().__init__( + hass=hass, + logger=_LOGGER, + config_entry=config_entry, + name=f"{DOMAIN} {self.url}", + update_interval=DEFAULT_SCAN_INTERVAL, + ) @callback def _log_no_entries(self) -> None: From 032e17720c84759f0d027bc4d08b7116f920cc0b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Jan 2025 01:11:54 +0100 Subject: [PATCH 1131/2987] Explicitly pass in the config_entry in PEGELONLINE coordinator init (#136773) --- homeassistant/components/pegel_online/__init__.py | 11 +++++------ .../components/pegel_online/coordinator.py | 14 ++++++++++++-- .../components/pegel_online/diagnostics.py | 2 +- homeassistant/components/pegel_online/sensor.py | 3 +-- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/pegel_online/__init__.py b/homeassistant/components/pegel_online/__init__.py index 30e5f4d2a38..1c71603e41e 100644 --- a/homeassistant/components/pegel_online/__init__.py +++ b/homeassistant/components/pegel_online/__init__.py @@ -7,21 +7,18 @@ import logging from aiopegelonline import PegelOnline from aiopegelonline.const import CONNECT_ERRORS -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_STATION -from .coordinator import PegelOnlineDataUpdateCoordinator +from .coordinator import PegelOnlineConfigEntry, PegelOnlineDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -type PegelOnlineConfigEntry = ConfigEntry[PegelOnlineDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) -> bool: """Set up PEGELONLINE entry.""" @@ -35,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) except CONNECT_ERRORS as err: raise ConfigEntryNotReady("Failed to connect") from err - coordinator = PegelOnlineDataUpdateCoordinator(hass, entry.title, api, station) + coordinator = PegelOnlineDataUpdateCoordinator(hass, entry, api, station) await coordinator.async_config_entry_first_refresh() @@ -46,6 +43,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: PegelOnlineConfigEntry +) -> bool: """Unload PEGELONLINE entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/pegel_online/coordinator.py b/homeassistant/components/pegel_online/coordinator.py index c8233673fde..1e2471a59f2 100644 --- a/homeassistant/components/pegel_online/coordinator.py +++ b/homeassistant/components/pegel_online/coordinator.py @@ -4,6 +4,7 @@ import logging from aiopegelonline import CONNECT_ERRORS, PegelOnline, Station, StationMeasurements +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -11,12 +12,20 @@ from .const import DOMAIN, MIN_TIME_BETWEEN_UPDATES _LOGGER = logging.getLogger(__name__) +type PegelOnlineConfigEntry = ConfigEntry[PegelOnlineDataUpdateCoordinator] + class PegelOnlineDataUpdateCoordinator(DataUpdateCoordinator[StationMeasurements]): """DataUpdateCoordinator for the pegel_online integration.""" + config_entry: PegelOnlineConfigEntry + def __init__( - self, hass: HomeAssistant, name: str, api: PegelOnline, station: Station + self, + hass: HomeAssistant, + config_entry: PegelOnlineConfigEntry, + api: PegelOnline, + station: Station, ) -> None: """Initialize the PegelOnlineDataUpdateCoordinator.""" self.api = api @@ -24,7 +33,8 @@ class PegelOnlineDataUpdateCoordinator(DataUpdateCoordinator[StationMeasurements super().__init__( hass, _LOGGER, - name=name, + config_entry=config_entry, + name=config_entry.title, update_interval=MIN_TIME_BETWEEN_UPDATES, ) diff --git a/homeassistant/components/pegel_online/diagnostics.py b/homeassistant/components/pegel_online/diagnostics.py index b68437c5ee7..e3b4a166cb4 100644 --- a/homeassistant/components/pegel_online/diagnostics.py +++ b/homeassistant/components/pegel_online/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import PegelOnlineConfigEntry +from .coordinator import PegelOnlineConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index 50eb80bafa8..181c0f5dc6d 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -16,8 +16,7 @@ from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PegelOnlineConfigEntry -from .coordinator import PegelOnlineDataUpdateCoordinator +from .coordinator import PegelOnlineConfigEntry, PegelOnlineDataUpdateCoordinator from .entity import PegelOnlineEntity From f98dc160f3fdd26b86146f8889c1230247b20424 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Jan 2025 01:13:49 +0100 Subject: [PATCH 1132/2987] Explicitly pass in the config_entry in AVM Fritz!SmartHome coordinator init (#136769) --- homeassistant/components/fritzbox/__init__.py | 2 +- homeassistant/components/fritzbox/coordinator.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index 07bc8fb15f2..afe6f1abba8 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzboxConfigEntry) -> await async_migrate_entries(hass, entry.entry_id, _update_unique_id) - coordinator = FritzboxDataUpdateCoordinator(hass, entry.entry_id) + coordinator = FritzboxDataUpdateCoordinator(hass, entry) await coordinator.async_setup() entry.runtime_data = coordinator diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index a6a30ffdc6a..34df3885deb 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -38,12 +38,13 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat fritz: Fritzhome has_templates: bool - def __init__(self, hass: HomeAssistant, name: str) -> None: + def __init__(self, hass: HomeAssistant, config_entry: FritzboxConfigEntry) -> None: """Initialize the Fritzbox Smarthome device coordinator.""" super().__init__( hass, LOGGER, - name=name, + config_entry=config_entry, + name=config_entry.entry_id, update_interval=timedelta(seconds=30), ) From ba2d1e698d1e418b6cbd02c6d62d98166cbe0e7f Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Tue, 28 Jan 2025 19:15:06 -0500 Subject: [PATCH 1133/2987] Bump peco to 0.1.2 (#136732) --- homeassistant/components/peco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/peco/manifest.json b/homeassistant/components/peco/manifest.json index 698981e9361..7dc80c6f837 100644 --- a/homeassistant/components/peco/manifest.json +++ b/homeassistant/components/peco/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/peco", "iot_class": "cloud_polling", - "requirements": ["peco==0.0.30"] + "requirements": ["peco==0.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 71921b7d41c..8fcf58b734a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1628,7 +1628,7 @@ pdunehd==1.3.2 peblar==0.4.0 # homeassistant.components.peco -peco==0.0.30 +peco==0.1.2 # homeassistant.components.pencom pencompy==0.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ba9dd11345..9bfa0db1304 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1355,7 +1355,7 @@ pdunehd==1.3.2 peblar==0.4.0 # homeassistant.components.peco -peco==0.0.30 +peco==0.1.2 # homeassistant.components.escea pescea==1.0.12 From 01b278c5472d48257c1162d988823241ac866dca Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Jan 2025 01:18:21 +0100 Subject: [PATCH 1134/2987] Explicitly pass in the config_entry in Tankerkoenig coordinator init (#136780) --- homeassistant/components/tankerkoenig/__init__.py | 6 +----- homeassistant/components/tankerkoenig/coordinator.py | 7 ++++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index a500549a648..b2b60db9675 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -17,11 +17,7 @@ async def async_setup_entry( """Set a tankerkoenig configuration entry up.""" hass.data.setdefault(DOMAIN, {}) - coordinator = TankerkoenigDataUpdateCoordinator( - hass, - name=entry.unique_id or DOMAIN, - update_interval=DEFAULT_SCAN_INTERVAL, - ) + coordinator = TankerkoenigDataUpdateCoordinator(hass, entry, DEFAULT_SCAN_INTERVAL) await coordinator.async_setup() await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index 17e94f62fe9..1f73d0577b3 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -24,7 +24,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_FUEL_TYPES, CONF_STATIONS +from .const import CONF_FUEL_TYPES, CONF_STATIONS, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -39,7 +39,7 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf def __init__( self, hass: HomeAssistant, - name: str, + config_entry: TankerkoenigConfigEntry, update_interval: int, ) -> None: """Initialize the data object.""" @@ -47,7 +47,8 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf super().__init__( hass=hass, logger=_LOGGER, - name=name, + config_entry=config_entry, + name=config_entry.unique_id or DOMAIN, update_interval=timedelta(minutes=update_interval), ) From e07e8b87069a46554d1ed5a343b4818d03dc7284 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Jan 2025 01:19:16 +0100 Subject: [PATCH 1135/2987] Explicitly pass in the config_entry in Proximity coordinator init (#136775) --- homeassistant/components/proximity/__init__.py | 9 +++++---- .../components/proximity/coordinator.py | 18 ++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 763274243c5..2338464558d 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -4,7 +4,6 @@ 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.event import ( @@ -22,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> """Set up Proximity from a config entry.""" _LOGGER.debug("setup %s with config:%s", entry.title, entry.data) - coordinator = ProximityDataUpdateCoordinator(hass, entry.title, dict(entry.data)) + coordinator = ProximityDataUpdateCoordinator(hass, entry) entry.async_on_unload( async_track_state_change_event( @@ -48,11 +47,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, [Platform.SENSOR]) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: ProximityConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index a8dd85c1523..055c15125f1 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -23,7 +23,6 @@ from homeassistant.core import ( ) from homeassistant.helpers import entity_registry as er from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.location import distance @@ -75,16 +74,14 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): config_entry: ProximityConfigEntry - def __init__( - self, hass: HomeAssistant, friendly_name: str, config: ConfigType - ) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ProximityConfigEntry) -> None: """Initialize the Proximity coordinator.""" - self.ignored_zone_ids: list[str] = config[CONF_IGNORED_ZONES] - self.tracked_entities: list[str] = config[CONF_TRACKED_ENTITIES] - self.tolerance: int = config[CONF_TOLERANCE] - self.proximity_zone_id: str = config[CONF_ZONE] + self.ignored_zone_ids: list[str] = config_entry.data[CONF_IGNORED_ZONES] + self.tracked_entities: list[str] = config_entry.data[CONF_TRACKED_ENTITIES] + self.tolerance: int = config_entry.data[CONF_TOLERANCE] + self.proximity_zone_id: str = config_entry.data[CONF_ZONE] self.proximity_zone_name: str = self.proximity_zone_id.split(".")[-1] - self.unit_of_measurement: str = config.get( + self.unit_of_measurement: str = config_entry.data.get( CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit ) self.entity_mapping: dict[str, list[str]] = defaultdict(list) @@ -92,7 +89,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): super().__init__( hass, _LOGGER, - name=friendly_name, + config_entry=config_entry, + name=config_entry.title, update_interval=None, ) From c2cbbf1e1cc85064217d6f71304d5e2fe6d7107d Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 29 Jan 2025 01:23:29 +0100 Subject: [PATCH 1136/2987] Add more vacuum features for tplink (#136580) --- homeassistant/components/tplink/icons.json | 9 +++ homeassistant/components/tplink/number.py | 4 + homeassistant/components/tplink/sensor.py | 16 +++- homeassistant/components/tplink/strings.json | 23 ++++++ homeassistant/components/tplink/switch.py | 3 + tests/components/tplink/__init__.py | 5 ++ .../components/tplink/fixtures/features.json | 16 ++++ .../tplink/snapshots/test_number.ambr | 55 ++++++++++++++ .../tplink/snapshots/test_sensor.ambr | 76 +++++++++++++++++++ .../tplink/snapshots/test_switch.ambr | 46 +++++++++++ 10 files changed, 252 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json index e00e8f69467..15e9406b2c9 100644 --- a/homeassistant/components/tplink/icons.json +++ b/homeassistant/components/tplink/icons.json @@ -113,6 +113,9 @@ "state": { "on": "mdi:baby-face" } + }, + "carpet_boost": { + "default": "mdi:rug" } }, "sensor": { @@ -130,6 +133,9 @@ }, "water_alert_timestamp": { "default": "mdi:clock-alert-outline" + }, + "vacuum_error": { + "default": "mdi:alert-circle" } }, "number": { @@ -150,6 +156,9 @@ }, "tilt_step": { "default": "mdi:unfold-more-horizontal" + }, + "clean_count": { + "default": "mdi:counter" } } }, diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py index 0af2b7403e8..b47c50d688f 100644 --- a/homeassistant/components/tplink/number.py +++ b/homeassistant/components/tplink/number.py @@ -65,6 +65,10 @@ NUMBER_DESCRIPTIONS: Final = ( key="tilt_step", mode=NumberMode.BOX, ), + TPLinkNumberEntityDescription( + key="clean_count", + mode=NumberMode.SLIDER, + ), ) NUMBER_DESCRIPTIONS_MAP = {desc.key: desc for desc in NUMBER_DESCRIPTIONS} diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index aaba6b2674d..0f5dbc0a2e3 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -2,10 +2,12 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from kasa import Feature +from kasa.smart.modules.clean import ErrorCode as VacuumError from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, @@ -28,6 +30,9 @@ class TPLinkSensorEntityDescription( ): """Base class for a TPLink feature based sensor entity description.""" + #: Optional callable to convert the value + convert_fn: Callable[[Any], Any] | None = None + # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -115,6 +120,12 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="alarm_source", ), + TPLinkSensorEntityDescription( + key="vacuum_error", + device_class=SensorDeviceClass.ENUM, + options=[name.lower() for name in VacuumError._member_names_], + convert_fn=lambda x: x.name.lower(), + ), ) SENSOR_DESCRIPTIONS_MAP = {desc.key: desc for desc in SENSOR_DESCRIPTIONS} @@ -165,6 +176,9 @@ class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity): # We probably do not need this, when we are rounding already? self._attr_suggested_display_precision = self._feature.precision_hint + if self.entity_description.convert_fn: + value = self.entity_description.convert_fn(value) + if TYPE_CHECKING: # pylint: disable-next=import-outside-toplevel from datetime import date, datetime diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 304bf353b7c..034aff7a763 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -198,6 +198,23 @@ }, "alarm_source": { "name": "Alarm source" + }, + "vacuum_error": { + "name": "Error", + "state": { + "ok": "No error", + "sidebrushstuck": "Side brush stuck", + "mainbrushstuck": "Main brush stuck", + "wheelblocked": "Wheel blocked", + "trapped": "Unable to move", + "trappedcliff": "Unable to move (cliff sensor)", + "dustbinremoved": "Missing dust bin", + "unabletomove": "Unable to move", + "lidarblocked": "Lidar blocked", + "unabletofinddock": "Unable to find dock", + "batterylow": "Low on battery", + "unknowninternal": "Unknown error, report to upstream" + } } }, "switch": { @@ -233,6 +250,9 @@ }, "baby_cry_detection": { "name": "Baby cry detection" + }, + "carpet_boost": { + "name": "Carpet boost" } }, "number": { @@ -253,6 +273,9 @@ }, "tilt_step": { "name": "Tilt degrees" + }, + "clean_count": { + "name": "Clean count" } } }, diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 04ca95273af..f08753def26 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -74,6 +74,9 @@ SWITCH_DESCRIPTIONS: tuple[TPLinkSwitchEntityDescription, ...] = ( TPLinkSwitchEntityDescription( key="baby_cry_detection", ), + TPLinkSwitchEntityDescription( + key="carpet_boost", + ), ) SWITCH_DESCRIPTIONS_MAP = {desc.key: desc for desc in SWITCH_DESCRIPTIONS} diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 664fb96fe71..028215dc157 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -60,6 +60,7 @@ def _load_feature_fixtures(): FEATURES_FIXTURE = _load_feature_fixtures() +FIXTURE_ENUM_TYPES = {"CleanErrorCode": ErrorCode} async def setup_platform_for_device( @@ -275,6 +276,10 @@ def _mocked_feature( if fixture := FEATURES_FIXTURE.get(id): # copy the fixture so tests do not interfere with each other fixture = dict(fixture) + if enum_type := fixture.get("enum_type"): + val = FIXTURE_ENUM_TYPES[enum_type](fixture["value"]) + fixture["value"] = val + else: assert require_fixture is False, ( f"No fixture defined for feature {id} and require_fixture is True" diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index adb6c08ee50..d366a91c33c 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -371,6 +371,22 @@ "type": "Number", "category": "Config" }, + "clean_count": { + "value": 1, + "type": "Number", + "category": "Config" + }, + "carpet_boost": { + "value": true, + "type": "Switch", + "category": "Config" + }, + "vacuum_error": { + "value": 0, + "type": "Sensor", + "category": "Info", + "enum_type": "CleanErrorCode" + }, "pair": { "value": "", "type": "Action", diff --git a/tests/components/tplink/snapshots/test_number.ambr b/tests/components/tplink/snapshots/test_number.ambr index df5ef71bf44..6733c5423a0 100644 --- a/tests/components/tplink/snapshots/test_number.ambr +++ b/tests/components/tplink/snapshots/test_number.ambr @@ -35,6 +35,61 @@ 'via_device_id': None, }) # --- +# name: test_states[number.my_device_clean_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 65536, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.my_device_clean_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Clean count', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'clean_count', + 'unique_id': '123456789ABCDEFGH_clean_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.my_device_clean_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Clean count', + 'max': 65536, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.my_device_clean_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- # name: test_states[number.my_device_pan_degrees-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 461e8c6e505..e223a72dbc0 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -307,6 +307,82 @@ 'unit_of_measurement': None, }) # --- +# name: test_states[sensor.my_device_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ok', + 'sidebrushstuck', + 'mainbrushstuck', + 'wheelblocked', + 'trapped', + 'trappedcliff', + 'dustbinremoved', + 'unabletomove', + 'lidarblocked', + 'unabletofinddock', + 'batterylow', + 'unknowninternal', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vacuum_error', + 'unique_id': '123456789ABCDEFGH_vacuum_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'my_device Error', + 'options': list([ + 'ok', + 'sidebrushstuck', + 'mainbrushstuck', + 'wheelblocked', + 'trapped', + 'trappedcliff', + 'dustbinremoved', + 'unabletomove', + 'lidarblocked', + 'unabletofinddock', + 'batterylow', + 'unknowninternal', + ]), + }), + 'context': , + 'entity_id': 'sensor.my_device_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- # name: test_states[sensor.my_device_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index 7adda900c02..f22f8d0cd36 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -219,6 +219,52 @@ 'state': 'on', }) # --- +# name: test_states[switch.my_device_carpet_boost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.my_device_carpet_boost', + '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': 'Carpet boost', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'carpet_boost', + 'unique_id': '123456789ABCDEFGH_carpet_boost', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.my_device_carpet_boost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Carpet boost', + }), + 'context': , + 'entity_id': 'switch.my_device_carpet_boost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_states[switch.my_device_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 3638eb1d34b0c66a98063a502e59bf80e8f15ee4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Jan 2025 01:25:34 +0100 Subject: [PATCH 1137/2987] Explicitly pass in the config_entry in Synology DSM coordinator init (#136772) --- homeassistant/components/synology_dsm/coordinator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py index 357de10b5b8..30d1260ef32 100644 --- a/homeassistant/components/synology_dsm/coordinator.py +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -59,6 +59,8 @@ def async_re_login_on_expired[_T: SynologyDSMUpdateCoordinator[Any], **_P, _R]( class SynologyDSMUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """DataUpdateCoordinator base class for synology_dsm.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -68,10 +70,10 @@ class SynologyDSMUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): ) -> None: """Initialize synology_dsm DataUpdateCoordinator.""" self.api = api - self.entry = entry super().__init__( hass, _LOGGER, + config_entry=entry, name=f"{entry.title} {self.__class__.__name__}", update_interval=update_interval, ) @@ -174,7 +176,7 @@ class SynologyDSMCameraUpdateCoordinator( ): async_dispatcher_send( self.hass, - f"{SIGNAL_CAMERA_SOURCE_CHANGED}_{self.entry.entry_id}_{cam_id}", + f"{SIGNAL_CAMERA_SOURCE_CHANGED}_{self.config_entry.entry_id}_{cam_id}", cam_data_new.live_view.rtsp, ) From 7256575c09a8f85a1f3cf3ef8cd813186fae8ce1 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Jan 2025 01:26:20 +0100 Subject: [PATCH 1138/2987] Explicitly pass in the config_entry in Nextcloud coordinator init (#136774) --- homeassistant/components/nextcloud/__init__.py | 5 +---- homeassistant/components/nextcloud/binary_sensor.py | 2 +- homeassistant/components/nextcloud/coordinator.py | 7 ++++++- homeassistant/components/nextcloud/entity.py | 3 +-- homeassistant/components/nextcloud/sensor.py | 2 +- homeassistant/components/nextcloud/update.py | 2 +- 6 files changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index a487a3f1414..3edff53919d 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -9,7 +9,6 @@ from nextcloudmonitor import ( NextcloudMonitorRequestError, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_URL, @@ -21,15 +20,13 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import entity_registry as er -from .coordinator import NextcloudDataUpdateCoordinator +from .coordinator import NextcloudConfigEntry, NextcloudDataUpdateCoordinator PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR, Platform.UPDATE) _LOGGER = logging.getLogger(__name__) -type NextcloudConfigEntry = ConfigEntry[NextcloudDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: NextcloudConfigEntry) -> bool: """Set up the Nextcloud integration.""" diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index c9d19efbd45..10e1a000a68 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NextcloudConfigEntry +from .coordinator import NextcloudConfigEntry from .entity import NextcloudEntity BINARY_SENSORS: Final[list[BinarySensorEntityDescription]] = [ diff --git a/homeassistant/components/nextcloud/coordinator.py b/homeassistant/components/nextcloud/coordinator.py index b5dc5e29507..d6bccec07bb 100644 --- a/homeassistant/components/nextcloud/coordinator.py +++ b/homeassistant/components/nextcloud/coordinator.py @@ -14,12 +14,16 @@ from .const import DEFAULT_SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) +type NextcloudConfigEntry = ConfigEntry[NextcloudDataUpdateCoordinator] + class NextcloudDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Nextcloud data update coordinator.""" + config_entry: NextcloudConfigEntry + def __init__( - self, hass: HomeAssistant, ncm: NextcloudMonitor, entry: ConfigEntry + self, hass: HomeAssistant, ncm: NextcloudMonitor, entry: NextcloudConfigEntry ) -> None: """Initialize the Nextcloud coordinator.""" self.ncm = ncm @@ -28,6 +32,7 @@ class NextcloudDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, _LOGGER, + config_entry=entry, name=self.url, update_interval=DEFAULT_SCAN_INTERVAL, ) diff --git a/homeassistant/components/nextcloud/entity.py b/homeassistant/components/nextcloud/entity.py index 6632b2674eb..f2ebba7fdb2 100644 --- a/homeassistant/components/nextcloud/entity.py +++ b/homeassistant/components/nextcloud/entity.py @@ -6,9 +6,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import NextcloudConfigEntry from .const import DOMAIN -from .coordinator import NextcloudDataUpdateCoordinator +from .coordinator import NextcloudConfigEntry, NextcloudDataUpdateCoordinator class NextcloudEntity(CoordinatorEntity[NextcloudDataUpdateCoordinator]): diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 19ac7bb0df7..a6722821012 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utc_from_timestamp -from . import NextcloudConfigEntry +from .coordinator import NextcloudConfigEntry from .entity import NextcloudEntity UNIT_OF_LOAD: Final[str] = "load" diff --git a/homeassistant/components/nextcloud/update.py b/homeassistant/components/nextcloud/update.py index 5b9de52ad1d..aad6412b7b3 100644 --- a/homeassistant/components/nextcloud/update.py +++ b/homeassistant/components/nextcloud/update.py @@ -6,7 +6,7 @@ from homeassistant.components.update import UpdateEntity, UpdateEntityDescriptio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NextcloudConfigEntry +from .coordinator import NextcloudConfigEntry from .entity import NextcloudEntity From 64cda8cdb8a08fa3ab1f767cc8dcdc48d7201152 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 28 Jan 2025 18:32:08 -0600 Subject: [PATCH 1139/2987] Add VoIP announce (#136781) * Implement async_announce for VoIP * Add tests * Add network to voip dependencies --- .../components/voip/assist_satellite.py | 140 ++++++++++++++++-- homeassistant/components/voip/manifest.json | 2 +- tests/components/voip/test_voip.py | 99 ++++++++++++- 3 files changed, 227 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 0100435d6dc..738c3a1e235 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -8,23 +8,29 @@ from functools import partial import io import logging from pathlib import Path +import socket +import time from typing import TYPE_CHECKING, Any, Final import wave -from voip_utils import RtpDatagramProtocol +from voip_utils import SIP_PORT, RtpDatagramProtocol +from voip_utils.sip import SipEndpoint, get_sip_endpoint from homeassistant.components import tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite import ( + AssistSatelliteAnnouncement, AssistSatelliteConfiguration, AssistSatelliteEntity, AssistSatelliteEntityDescription, + AssistSatelliteEntityFeature, ) +from homeassistant.components.network import async_get_source_ip from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CHANNELS, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH +from .const import CHANNELS, CONF_SIP_PORT, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH from .devices import VoIPDevice from .entity import VoIPEntity @@ -34,6 +40,9 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) _PIPELINE_TIMEOUT_SEC: Final = 30 +_ANNOUNCEMENT_BEFORE_DELAY: Final = 0.5 +_ANNOUNCEMENT_AFTER_DELAY: Final = 1.0 +_ANNOUNCEMENT_HANGUP_SEC: Final = 0.5 class Tones(IntFlag): @@ -80,6 +89,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol entity_description = AssistSatelliteEntityDescription(key="assist_satellite") _attr_translation_key = "assist_satellite" _attr_name = None + _attr_supported_features = AssistSatelliteEntityFeature.ANNOUNCE def __init__( self, @@ -105,6 +115,12 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._tones = tones self._processing_tone_done = asyncio.Event() + self._announcement: AssistSatelliteAnnouncement | None = None + self._announcement_done = asyncio.Event() + self._check_announcement_ended_task: asyncio.Task | None = None + self._last_chunk_time: float | None = None + self._rtp_port: int | None = None + @property def pipeline_entity_id(self) -> str | None: """Return the entity ID of the pipeline to use for the next conversation.""" @@ -149,25 +165,108 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol """Set the current satellite configuration.""" raise NotImplementedError + async def async_announce(self, announcement: AssistSatelliteAnnouncement) -> None: + """Announce media on the satellite. + + Plays announcement in a loop, blocking until the caller hangs up. + """ + self._announcement_done.clear() + + if self._rtp_port is None: + # Choose random port for RTP + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + sock.bind(("", 0)) + _rtp_ip, self._rtp_port = sock.getsockname() + sock.close() + + # HA SIP server + source_ip = await async_get_source_ip(self.hass) + sip_port = self.config_entry.options.get(CONF_SIP_PORT, SIP_PORT) + source_endpoint = get_sip_endpoint(host=source_ip, port=sip_port) + + try: + # VoIP ID is SIP header + destination_endpoint = SipEndpoint(self.voip_device.voip_id) + except ValueError: + # VoIP ID is IP address + destination_endpoint = get_sip_endpoint( + host=self.voip_device.voip_id, port=SIP_PORT + ) + + self._announcement = announcement + + # Make the call + self.hass.data[DOMAIN].protocol.outgoing_call( + source=source_endpoint, + destination=destination_endpoint, + rtp_port=self._rtp_port, + ) + + await self._announcement_done.wait() + + async def _check_announcement_ended(self) -> None: + """Continuously checks if an audio chunk was received within a time limit. + + If not, the caller is presumed to have hung up and the announcement is ended. + """ + while self._announcement is not None: + if (self._last_chunk_time is not None) and ( + (time.monotonic() - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC + ): + # Caller hung up + self._announcement = None + self._announcement_done.set() + self._check_announcement_ended_task = None + _LOGGER.debug("Announcement ended") + break + + await asyncio.sleep(_ANNOUNCEMENT_HANGUP_SEC / 2) + # ------------------------------------------------------------------------- # VoIP # ------------------------------------------------------------------------- def on_chunk(self, audio_bytes: bytes) -> None: """Handle raw audio chunk.""" - if self._run_pipeline_task is None: - # Run pipeline until voice command finishes, then start over - self._clear_audio_queue() - self._tts_done.clear() + self._last_chunk_time = time.monotonic() + + if self._announcement is None: + # Pipeline with STT + if self._run_pipeline_task is None: + # Run pipeline until voice command finishes, then start over + self._clear_audio_queue() + self._tts_done.clear() + self._run_pipeline_task = ( + self.config_entry.async_create_background_task( + self.hass, + self._run_pipeline(), + "voip_pipeline_run", + ) + ) + + self._audio_queue.put_nowait(audio_bytes) + elif self._run_pipeline_task is None: + # Announcement only + if self._check_announcement_ended_task is None: + # Check if caller hung up + self._check_announcement_ended_task = ( + self.config_entry.async_create_background_task( + self.hass, + self._check_announcement_ended(), + "voip_announcement_ended", + ) + ) + + # Play announcement (will repeat) self._run_pipeline_task = self.config_entry.async_create_background_task( self.hass, - self._run_pipeline(), - "voip_pipeline_run", + self._play_announcement(self._announcement), + "voip_play_announcement", ) - self._audio_queue.put_nowait(audio_bytes) - async def _run_pipeline(self) -> None: + """Run a pipeline with STT input and TTS output.""" _LOGGER.debug("Starting pipeline") self.async_set_context(Context(user_id=self.config_entry.data["user"])) @@ -209,6 +308,23 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._run_pipeline_task = None _LOGGER.debug("Pipeline finished") + async def _play_announcement( + self, announcement: AssistSatelliteAnnouncement + ) -> None: + """Play an announcement once.""" + _LOGGER.debug("Playing announcement") + + try: + await asyncio.sleep(_ANNOUNCEMENT_BEFORE_DELAY) + await self._send_tts(announcement.original_media_id, wait_for_tone=False) + await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY) + except Exception: + _LOGGER.exception("Unexpected error while playing announcement") + raise + finally: + self._run_pipeline_task = None + _LOGGER.debug("Announcement finished") + def _clear_audio_queue(self) -> None: """Ensure audio queue is empty.""" while not self._audio_queue.empty(): @@ -239,7 +355,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._pipeline_had_error = True _LOGGER.warning(event) - async def _send_tts(self, media_id: str) -> None: + async def _send_tts(self, media_id: str, wait_for_tone: bool = True) -> None: """Send TTS audio to caller via RTP.""" try: if self.transport is None: @@ -253,7 +369,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol if extension != "wav": raise ValueError(f"Only WAV audio can be streamed, got {extension}") - if (self._tones & Tones.PROCESSING) == Tones.PROCESSING: + if wait_for_tone and ((self._tones & Tones.PROCESSING) == Tones.PROCESSING): # Don't overlap TTS and processing beep _LOGGER.debug("Waiting for processing tone") await self._processing_tone_done.wait() diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index e96039a6b45..b279665a03a 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -3,7 +3,7 @@ "name": "Voice over IP", "codeowners": ["@balloob", "@synesthesiam"], "config_flow": true, - "dependencies": ["assist_pipeline", "assist_satellite"], + "dependencies": ["assist_pipeline", "assist_satellite", "network"], "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "quality_scale": "internal", diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 17af2748c1c..ac7c295c934 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -16,7 +16,7 @@ from homeassistant.components.assist_satellite import AssistSatelliteEntity # pylint: disable-next=hass-component-root-import from homeassistant.components.assist_satellite.entity import AssistSatelliteState -from homeassistant.components.voip import HassVoipDatagramProtocol +from homeassistant.components.voip import DOMAIN, HassVoipDatagramProtocol from homeassistant.components.voip.assist_satellite import Tones, VoipAssistSatellite from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices from homeassistant.components.voip.voip import PreRecordMessageProtocol, make_protocol @@ -844,3 +844,100 @@ async def test_pipeline_error( assert sum(played_audio_bytes) > 0 assert played_audio_bytes == snapshot() + + +@pytest.mark.usefixtures("socket_enabled") +async def test_announce( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test announcement.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but "outgoing_call" is not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + + with ( + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", + ) as mock_send_tts, + ): + satellite.transport = Mock() + announce_task = hass.async_create_background_task( + satellite.async_announce(announcement), "voip_announce" + ) + await asyncio.sleep(0) + mock_protocol.outgoing_call.assert_called_once() + + # Trigger announcement + satellite.on_chunk(bytes(_ONE_SECOND)) + await announce_task + + mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) + + +@pytest.mark.usefixtures("socket_enabled") +async def test_voip_id_is_ip_address( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test announcement when VoIP is an IP address instead of a SIP header.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but "outgoing_call" is not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + + with ( + patch.object(voip_device, "voip_id", "192.168.68.10"), + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", + ) as mock_send_tts, + ): + satellite.transport = Mock() + announce_task = hass.async_create_background_task( + satellite.async_announce(announcement), "voip_announce" + ) + await asyncio.sleep(0) + mock_protocol.outgoing_call.assert_called_once() + assert ( + mock_protocol.outgoing_call.call_args.kwargs["destination"].host + == "192.168.68.10" + ) + + # Trigger announcement + satellite.on_chunk(bytes(_ONE_SECOND)) + await announce_task + + mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) From 9f586ea547ffad5ce222650b3b895df2cca22e32 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jan 2025 15:10:33 -1000 Subject: [PATCH 1140/2987] Bump habluetooth to 3.14.0 (#136791) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bluetooth/test_diagnostics.py | 3 +++ 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index b172a6c6aef..1fcd507da83 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.13.0" + "habluetooth==3.14.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 51393c2a516..8643d53ff68 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.13.0 +habluetooth==3.14.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8fcf58b734a..c67a83de01a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.13.0 +habluetooth==3.14.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bfa0db1304..78b4ed27566 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -941,7 +941,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.13.0 +habluetooth==3.14.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index be4412db4d8..384eae7e49a 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -133,6 +133,7 @@ async def test_diagnostics( } }, "manager": { + "allocations": {}, "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -291,6 +292,7 @@ async def test_diagnostics_macos( } }, "manager": { + "allocations": {}, "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", @@ -484,6 +486,7 @@ async def test_diagnostics_remote_adapter( }, "dbus": {}, "manager": { + "allocations": {}, "adapters": { "hci0": { "address": "00:00:00:00:00:01", From bc7c5fbc860cea539e44fd3e4c2a5b20caa354a5 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 28 Jan 2025 21:44:57 -0600 Subject: [PATCH 1141/2987] Fix typing errors in HEOS tests (#136795) * Correct typing errors of mocked heos * Fix player related typing issues * Sort mocks --- tests/components/heos/__init__.py | 57 +++ tests/components/heos/conftest.py | 98 +++--- .../heos/snapshots/test_diagnostics.ambr | 11 +- tests/components/heos/test_config_flow.py | 52 +-- tests/components/heos/test_diagnostics.py | 10 +- tests/components/heos/test_init.py | 47 ++- tests/components/heos/test_media_player.py | 324 +++++++++--------- tests/components/heos/test_services.py | 14 +- 8 files changed, 343 insertions(+), 270 deletions(-) diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index 3a774529c69..cf0d10790b7 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -1 +1,58 @@ """Tests for the Heos component.""" + +from unittest.mock import AsyncMock + +from pyheos import Heos, HeosGroup, HeosOptions, HeosPlayer + + +class MockHeos(Heos): + """Defines a mocked HEOS API.""" + + def __init__(self, options: HeosOptions) -> None: + """Initialize the mock.""" + super().__init__(options) + # Overwrite the methods with async mocks, changing type + self.add_to_queue: AsyncMock = AsyncMock() + self.connect: AsyncMock = AsyncMock() + self.disconnect: AsyncMock = AsyncMock() + self.get_favorites: AsyncMock = AsyncMock() + self.get_groups: AsyncMock = AsyncMock() + self.get_input_sources: AsyncMock = AsyncMock() + self.get_playlists: AsyncMock = AsyncMock() + self.get_players: AsyncMock = AsyncMock() + self.load_players: AsyncMock = AsyncMock() + self.play_media: AsyncMock = AsyncMock() + self.play_preset_station: AsyncMock = AsyncMock() + self.play_url: AsyncMock = AsyncMock() + self.player_clear_queue: AsyncMock = AsyncMock() + self.player_get_quick_selects: AsyncMock = AsyncMock() + self.player_play_next: AsyncMock = AsyncMock() + self.player_play_previous: AsyncMock = AsyncMock() + self.player_play_quick_select: AsyncMock = AsyncMock() + self.player_set_mute: AsyncMock = AsyncMock() + self.player_set_play_mode: AsyncMock = AsyncMock() + self.player_set_play_state: AsyncMock = AsyncMock() + self.player_set_volume: AsyncMock = AsyncMock() + self.set_group: AsyncMock = AsyncMock() + self.sign_in: AsyncMock = AsyncMock() + self.sign_out: AsyncMock = AsyncMock() + + def mock_set_players(self, players: dict[int, HeosPlayer]) -> None: + """Set the players on the mock instance.""" + for player in players.values(): + player.heos = self + self._players = players + self._players_loaded = bool(players) + self.get_players.return_value = players + + def mock_set_groups(self, groups: dict[int, HeosGroup]) -> None: + """Set the groups on the mock instance.""" + for group in groups.values(): + group.heos = self + self._groups = groups + self._groups_loaded = bool(groups) + self.get_groups.return_value = groups + + def mock_set_signed_in_username(self, signed_in_username: str | None) -> None: + """Set the signed in status on the mock instance.""" + self._signed_in_username = signed_in_username diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 5312b8295ed..5ec809b10e9 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -2,11 +2,10 @@ from __future__ import annotations -from collections.abc import AsyncIterator -from unittest.mock import AsyncMock, Mock, patch +from collections.abc import Iterator +from unittest.mock import Mock, patch from pyheos import ( - Heos, HeosGroup, HeosHost, HeosNowPlayingMedia, @@ -38,6 +37,8 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) +from . import MockHeos + from tests.common import MockConfigEntry @@ -64,6 +65,17 @@ def config_entry_options_fixture() -> MockConfigEntry: ) +@pytest.fixture(name="new_mock", autouse=True) +def new_heos_mock_fixture(controller: MockHeos) -> Iterator[Mock]: + """Patch the Heos class to return the mock instance.""" + new_mock = Mock(return_value=controller) + with ( + patch("homeassistant.components.heos.coordinator.Heos", new=new_mock), + patch("homeassistant.components.heos.config_flow.Heos", new=new_mock), + ): + yield new_mock + + @pytest_asyncio.fixture(name="controller", autouse=True) async def controller_fixture( players: dict[int, HeosPlayer], @@ -72,49 +84,38 @@ async def controller_fixture( playlists: list[MediaItem], change_data: PlayerUpdateResult, group: dict[int, HeosGroup], -) -> AsyncIterator[Heos]: + quick_selects: dict[int, str], +) -> MockHeos: """Create a mock Heos controller fixture.""" - mock_heos = Heos(HeosOptions(host="127.0.0.1")) - for player in players.values(): - player.heos = mock_heos - mock_heos.connect = AsyncMock() - mock_heos.disconnect = AsyncMock() - mock_heos.sign_in = AsyncMock() - mock_heos.sign_out = AsyncMock() - mock_heos.get_players = AsyncMock(return_value=players) - mock_heos._players = players - mock_heos.get_favorites = AsyncMock(return_value=favorites) - mock_heos.get_input_sources = AsyncMock(return_value=input_sources) - mock_heos.get_playlists = AsyncMock(return_value=playlists) - mock_heos.load_players = AsyncMock(return_value=change_data) - mock_heos._signed_in_username = "user@user.com" - mock_heos.get_groups = AsyncMock(return_value=group) - mock_heos._groups = group - mock_heos.set_group = AsyncMock(return_value=None) - new_mock = Mock(return_value=mock_heos) - mock_heos.new_mock = new_mock - with ( - patch("homeassistant.components.heos.coordinator.Heos", new=new_mock), - patch("homeassistant.components.heos.config_flow.Heos", new=new_mock), - ): - yield mock_heos + + mock_heos = MockHeos(HeosOptions(host="127.0.0.1")) + mock_heos.mock_set_signed_in_username("user@user.com") + mock_heos.mock_set_players(players) + mock_heos.mock_set_groups(group) + mock_heos.get_favorites.return_value = favorites + mock_heos.get_input_sources.return_value = input_sources + mock_heos.get_playlists.return_value = playlists + mock_heos.load_players.return_value = change_data + mock_heos.player_get_quick_selects.return_value = quick_selects + return mock_heos @pytest.fixture(name="system") -def system_info_fixture() -> dict[str, str]: +def system_info_fixture() -> HeosSystem: """Create a system info fixture.""" + main_host = HeosHost( + "Test Player", + "HEOS Drive HS2", + "123456", + "1.0.0", + "127.0.0.1", + NetworkType.WIRED, + ) return HeosSystem( "user@user.com", - "127.0.0.1", + main_host, hosts=[ - HeosHost( - "Test Player", - "HEOS Drive HS2", - "123456", - "1.0.0", - "127.0.0.1", - NetworkType.WIRED, - ), + main_host, HeosHost( "Test Player 2", "Speaker", @@ -128,7 +129,7 @@ def system_info_fixture() -> dict[str, str]: @pytest.fixture(name="players") -def players_fixture(quick_selects: dict[int, str]) -> dict[int, HeosPlayer]: +def players_fixture() -> dict[int, HeosPlayer]: """Create two mock HeosPlayers.""" players = {} for i in (1, 2): @@ -148,7 +149,6 @@ def players_fixture(quick_selects: dict[int, str]) -> dict[int, HeosPlayer]: shuffle=False, repeat=RepeatType.OFF, volume=25, - heos=None, ) player.now_playing_media = HeosNowPlayingMedia( type=MediaType.STATION, @@ -162,24 +162,6 @@ def players_fixture(quick_selects: dict[int, str]) -> dict[int, HeosPlayer]: queue_id=1, source_id=10, ) - player.add_to_queue = AsyncMock() - player.clear_queue = AsyncMock() - player.get_quick_selects = AsyncMock(return_value=quick_selects) - player.mute = AsyncMock() - player.pause = AsyncMock() - player.play = AsyncMock() - player.play_media = AsyncMock() - player.play_next = AsyncMock() - player.play_previous = AsyncMock() - player.play_preset_station = AsyncMock() - player.play_quick_select = AsyncMock() - player.play_url = AsyncMock() - player.set_mute = AsyncMock() - player.set_play_mode = AsyncMock() - player.set_quick_select = AsyncMock() - player.set_volume = AsyncMock() - player.stop = AsyncMock() - player.unmute = AsyncMock() players[player.player_id] = player return players diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr index 6de0a645f17..1df2d172142 100644 --- a/tests/components/heos/snapshots/test_diagnostics.ambr +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -98,8 +98,15 @@ 'Speaker - Line In 1', ]), 'system': dict({ - 'connected_to_preferred_host': False, - 'host': '127.0.0.1', + 'connected_to_preferred_host': True, + 'host': dict({ + 'ip_address': '127.0.0.1', + 'model': 'HEOS Drive HS2', + 'name': 'Test Player', + 'network': 'wired', + 'serial': '**REDACTED**', + 'version': '1.0.0', + }), 'hosts': list([ dict({ 'ip_address': '127.0.0.1', diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 39ede354496..cbc32526958 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -1,6 +1,8 @@ """Tests for the Heos config flow module.""" -from pyheos import CommandAuthenticationError, CommandFailedError, Heos, HeosError +from typing import Any + +from pyheos import CommandAuthenticationError, CommandFailedError, HeosError import pytest from homeassistant.components.heos.const import DOMAIN @@ -10,6 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from . import MockHeos + from tests.common import MockConfigEntry @@ -38,7 +42,7 @@ async def test_no_host_shows_form(hass: HomeAssistant) -> None: async def test_cannot_connect_shows_error_form( - hass: HomeAssistant, controller: Heos + hass: HomeAssistant, controller: MockHeos ) -> None: """Test form is shown with error when cannot connect.""" controller.connect.side_effect = HeosError() @@ -47,13 +51,15 @@ async def test_cannot_connect_shows_error_form( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"][CONF_HOST] == "cannot_connect" + errors = result["errors"] + assert errors is not None + assert errors[CONF_HOST] == "cannot_connect" assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 async def test_create_entry_when_host_valid( - hass: HomeAssistant, controller: Heos + hass: HomeAssistant, controller: MockHeos ) -> None: """Test result type is create entry when host is valid.""" data = {CONF_HOST: "127.0.0.1"} @@ -70,7 +76,7 @@ async def test_create_entry_when_host_valid( async def test_create_entry_when_friendly_name_valid( - hass: HomeAssistant, controller: Heos + hass: HomeAssistant, controller: MockHeos ) -> None: """Test result type is create entry when friendly name is valid.""" hass.data[DOMAIN] = {"Office (127.0.0.1)": "127.0.0.1"} @@ -131,7 +137,7 @@ async def test_discovery_flow_aborts_already_setup( async def test_reconfigure_validates_and_updates_config( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test reconfigure validates host and successfully updates.""" config_entry.add_to_hass(hass) @@ -139,9 +145,9 @@ async def test_reconfigure_validates_and_updates_config( assert config_entry.data[CONF_HOST] == "127.0.0.1" # Test reconfigure initially shows form with current host value. - host = next( - key.default() for key in result["data_schema"].schema if key == CONF_HOST - ) + schema = result["data_schema"] + assert schema is not None + host = next(key.default() for key in schema.schema if key == CONF_HOST) assert host == "127.0.0.1" assert result["errors"] == {} assert result["step_id"] == "reconfigure" @@ -161,7 +167,7 @@ async def test_reconfigure_validates_and_updates_config( async def test_reconfigure_cannot_connect_recovers( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test reconfigure cannot connect and recovers.""" controller.connect.side_effect = HeosError() @@ -176,11 +182,13 @@ async def test_reconfigure_cannot_connect_recovers( assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 - host = next( - key.default() for key in result["data_schema"].schema if key == CONF_HOST - ) + schema = result["data_schema"] + assert schema is not None + host = next(key.default() for key in schema.schema if key == CONF_HOST) assert host == "127.0.0.2" - assert result["errors"][CONF_HOST] == "cannot_connect" + errors = result["errors"] + assert errors is not None + assert errors[CONF_HOST] == "cannot_connect" assert result["step_id"] == "reconfigure" assert result["type"] is FlowResultType.FORM @@ -214,7 +222,7 @@ async def test_reconfigure_cannot_connect_recovers( async def test_options_flow_signs_in( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, error: HeosError, expected_error_key: str, ) -> None: @@ -255,7 +263,7 @@ async def test_options_flow_signs_in( async def test_options_flow_signs_out( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test options flow signs-out when credentials cleared.""" config_entry.add_to_hass(hass) @@ -268,7 +276,7 @@ async def test_options_flow_signs_out( assert result["type"] is FlowResultType.FORM # Fail to sign-out, show error - user_input = {} + user_input: dict[str, Any] = {} controller.sign_out.side_effect = HeosError() result = await hass.config_entries.options.async_configure( result["flow_id"], user_input @@ -301,7 +309,7 @@ async def test_options_flow_signs_out( async def test_options_flow_missing_one_param_recovers( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, user_input: dict[str, str], expected_errors: dict[str, str], ) -> None: @@ -350,7 +358,7 @@ async def test_options_flow_missing_one_param_recovers( async def test_reauth_signs_in_aborts( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, error: HeosError, expected_error_key: str, ) -> None: @@ -391,7 +399,7 @@ async def test_reauth_signs_in_aborts( async def test_reauth_signs_out( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test reauth flow signs-out when credentials cleared and aborts.""" config_entry.add_to_hass(hass) @@ -404,7 +412,7 @@ async def test_reauth_signs_out( assert result["type"] is FlowResultType.FORM # Fail to sign-out, show error - user_input = {} + user_input: dict[str, Any] = {} controller.sign_out.side_effect = HeosError() result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input @@ -439,7 +447,7 @@ async def test_reauth_signs_out( async def test_reauth_flow_missing_one_param_recovers( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, user_input: dict[str, str], expected_errors: dict[str, str], ) -> None: diff --git a/tests/components/heos/test_diagnostics.py b/tests/components/heos/test_diagnostics.py index d6fb8e1a8fe..2a7deccfb33 100644 --- a/tests/components/heos/test_diagnostics.py +++ b/tests/components/heos/test_diagnostics.py @@ -2,15 +2,17 @@ from unittest import mock -from pyheos import Heos, HeosSystem +from pyheos import HeosSystem import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.heos.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from . import MockHeos + from tests.common import MockConfigEntry from tests.components.diagnostics import ( get_diagnostics_for_config_entry, @@ -23,7 +25,7 @@ async def test_config_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, system: HeosSystem, snapshot: SnapshotAssertion, ) -> None: @@ -77,7 +79,7 @@ async def test_device_diagnostics( assert await hass.config_entries.async_setup(config_entry.entry_id) device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, "1")}) - + assert device is not None diagnostics = await get_diagnostics_for_device( hass, hass_client, config_entry, device ) diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 4c5eee67e2c..27dea82dcf2 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -1,8 +1,9 @@ """Tests for the init module.""" from typing import cast +from unittest.mock import Mock -from pyheos import Heos, HeosError, HeosOptions, SignalHeosEvent, SignalType +from pyheos import HeosError, HeosOptions, SignalHeosEvent, SignalType import pytest from homeassistant.components.heos.const import DOMAIN @@ -11,13 +12,15 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from . import MockHeos + from tests.common import MockConfigEntry async def test_async_setup_entry_loads_platforms( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, ) -> None: """Test load connects to heos, retrieves players, and loads platforms.""" config_entry.add_to_hass(hass) @@ -32,7 +35,10 @@ async def test_async_setup_entry_loads_platforms( async def test_async_setup_entry_with_options_loads_platforms( - hass: HomeAssistant, config_entry_options: MockConfigEntry, controller: Heos + hass: HomeAssistant, + config_entry_options: MockConfigEntry, + controller: MockHeos, + new_mock: Mock, ) -> None: """Test load connects to heos with options, retrieves players, and loads platforms.""" config_entry_options.add_to_hass(hass) @@ -40,8 +46,9 @@ async def test_async_setup_entry_with_options_loads_platforms( # Assert options passed and methods called assert config_entry_options.state is ConfigEntryState.LOADED - options = cast(HeosOptions, controller.new_mock.call_args[0][0]) + options = cast(HeosOptions, new_mock.call_args[0][0]) assert options.host == config_entry_options.data[CONF_HOST] + assert options.credentials is not None assert options.credentials.username == config_entry_options.options[CONF_USERNAME] assert options.credentials.password == config_entry_options.options[CONF_PASSWORD] assert controller.connect.call_count == 1 @@ -54,14 +61,14 @@ async def test_async_setup_entry_with_options_loads_platforms( async def test_async_setup_entry_auth_failure_starts_reauth( hass: HomeAssistant, config_entry_options: MockConfigEntry, - controller: Heos, + controller: MockHeos, ) -> None: """Test load with auth failure starts reauth, loads platforms.""" config_entry_options.add_to_hass(hass) # Simulates what happens when the controller can't sign-in during connection async def connect_send_auth_failure() -> None: - controller._signed_in_username = None + controller.mock_set_signed_in_username(None) await controller.dispatcher.wait_send( SignalType.HEOS_EVENT, SignalHeosEvent.USER_CREDENTIALS_INVALID ) @@ -76,19 +83,19 @@ async def test_async_setup_entry_auth_failure_starts_reauth( controller.disconnect.assert_not_called() assert config_entry_options.state is ConfigEntryState.LOADED assert any( - config_entry_options.async_get_active_flows(hass, sources=[SOURCE_REAUTH]) + config_entry_options.async_get_active_flows(hass, sources={SOURCE_REAUTH}) ) async def test_async_setup_entry_not_signed_in_loads_platforms( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, caplog: pytest.LogCaptureFixture, ) -> None: """Test setup does not retrieve favorites when not logged in.""" config_entry.add_to_hass(hass) - controller._signed_in_username = None + controller.mock_set_signed_in_username(None) assert await hass.config_entries.async_setup(config_entry.entry_id) assert controller.connect.call_count == 1 assert controller.get_players.call_count == 1 @@ -102,7 +109,7 @@ async def test_async_setup_entry_not_signed_in_loads_platforms( async def test_async_setup_entry_connect_failure( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Connection failure raises ConfigEntryNotReady.""" config_entry.add_to_hass(hass) @@ -114,7 +121,7 @@ async def test_async_setup_entry_connect_failure( async def test_async_setup_entry_player_failure( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Failure to retrieve players raises ConfigEntryNotReady.""" config_entry.add_to_hass(hass) @@ -126,7 +133,7 @@ async def test_async_setup_entry_player_failure( async def test_async_setup_entry_favorites_failure( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Failure to retrieve favorites loads.""" config_entry.add_to_hass(hass) @@ -136,7 +143,7 @@ async def test_async_setup_entry_favorites_failure( async def test_async_setup_entry_inputs_failure( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Failure to retrieve inputs loads.""" config_entry.add_to_hass(hass) @@ -146,7 +153,7 @@ async def test_async_setup_entry_inputs_failure( async def test_unload_entry( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test entries are unloaded correctly.""" config_entry.add_to_hass(hass) @@ -164,12 +171,14 @@ async def test_device_info( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) device = device_registry.async_get_device({(DOMAIN, "1")}) + assert device is not None assert device.manufacturer == "HEOS" assert device.model == "Drive HS2" assert device.name == "Test Player" assert device.serial_number == "123456" assert device.sw_version == "1.0.0" device = device_registry.async_get_device({(DOMAIN, "2")}) + assert device is not None assert device.manufacturer == "HEOS" assert device.model == "Speaker" @@ -183,12 +192,14 @@ async def test_device_id_migration( config_entry.add_to_hass(hass) # Create a device with a legacy identifier device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, 1)} + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, 1)}, # type: ignore[arg-type] ) device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, identifiers={("Other", 1)} + config_entry_id=config_entry.entry_id, + identifiers={("Other", 1)}, # type: ignore[arg-type] ) assert await hass.config_entries.async_setup(config_entry.entry_id) - assert device_registry.async_get_device({("Other", 1)}) is not None - assert device_registry.async_get_device({(DOMAIN, 1)}) is None + assert device_registry.async_get_device({("Other", 1)}) is not None # type: ignore[arg-type] + 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 diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 8fc63bbc7ad..3768462eada 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -8,7 +8,6 @@ from freezegun.api import FrozenDateTimeFactory from pyheos import ( AddCriteriaType, CommandFailedError, - Heos, HeosError, MediaItem, MediaType as HeosMediaType, @@ -66,6 +65,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er +from . import MockHeos + from tests.common import MockConfigEntry, async_fire_time_changed @@ -88,7 +89,7 @@ async def test_state_attributes( async def test_updates_from_signals( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Tests dispatched signals update player.""" config_entry.add_to_hass(hass) @@ -97,33 +98,36 @@ async def test_updates_from_signals( # Test player does not update for other players player.state = PlayState.PLAY - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.PLAYER_EVENT, 2, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_IDLE # Test player_update standard events player.state = PlayState.PLAY - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_PLAYING # Test player_update progress events player.now_playing_media.duration = 360000 player.now_playing_media.current_position = 1000 - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_NOW_PLAYING_PROGRESS, ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] is not None assert state.attributes[ATTR_MEDIA_DURATION] == 360 assert state.attributes[ATTR_MEDIA_POSITION] == 1 @@ -132,7 +136,7 @@ async def test_updates_from_signals( async def test_updates_from_connection_event( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, caplog: pytest.LogCaptureFixture, ) -> None: """Tests player updates from connection event after connection failure.""" @@ -142,34 +146,37 @@ async def test_updates_from_connection_event( # Connected player.available = True - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_IDLE assert controller.load_players.call_count == 1 # Disconnected controller.load_players.reset_mock() player.available = False - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_UNAVAILABLE assert controller.load_players.call_count == 0 # Connected handles refresh failure controller.load_players.reset_mock() - controller.load_players.side_effect = CommandFailedError(None, "Failure", 1) + controller.load_players.side_effect = CommandFailedError("", "Failure", 1) player.available = True - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_IDLE assert controller.load_players.call_count == 1 assert "Unable to refresh players" in caplog.text @@ -180,7 +187,7 @@ async def test_updates_from_connection_event_new_player_ids( entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, change_data_mapped_ids: PlayerUpdateResult, ) -> None: """Test player ids changed after reconnection updates ids.""" @@ -208,16 +215,15 @@ async def test_updates_from_connection_event_new_player_ids( async def test_updates_from_sources_updated( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, freezer: FrozenDateTimeFactory, ) -> None: """Tests player updates from changes in sources list.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] controller.get_input_sources.return_value = [] - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} ) freezer.tick(timedelta(seconds=1)) @@ -225,6 +231,7 @@ async def test_updates_from_sources_updated( await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_INPUT_SOURCE_LIST] == [ "Today's Hits Radio", "Classical MPR (Classical Music)", @@ -234,7 +241,7 @@ async def test_updates_from_sources_updated( async def test_updates_from_players_changed( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, change_data: PlayerUpdateResult, ) -> None: """Test player updates from changes to available players.""" @@ -242,13 +249,17 @@ async def test_updates_from_players_changed( assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - assert hass.states.get("media_player.test_player").state == STATE_IDLE + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.state == STATE_IDLE player.state = PlayState.PLAY - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data ) await hass.async_block_till_done() - assert hass.states.get("media_player.test_player").state == STATE_PLAYING + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.state == STATE_PLAYING async def test_updates_from_players_changed_new_ids( @@ -256,13 +267,12 @@ async def test_updates_from_players_changed_new_ids( entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, change_data_mapped_ids: PlayerUpdateResult, ) -> None: """Test player updates from changes to available players.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] # Assert device registry matches current id assert device_registry.async_get_device(identifiers={(DOMAIN, "1")}) @@ -272,7 +282,7 @@ async def test_updates_from_players_changed_new_ids( == "media_player.test_player" ) - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data_mapped_ids, @@ -293,16 +303,15 @@ async def test_updates_from_players_changed_new_ids( async def test_updates_from_user_changed( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, freezer: FrozenDateTimeFactory, ) -> None: """Tests player updates from changes in user.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - controller._signed_in_username = None - await player.heos.dispatcher.wait_send( + controller.mock_set_signed_in_username(None) + await controller.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_USER_CHANGED, None ) freezer.tick(timedelta(seconds=1)) @@ -310,6 +319,7 @@ async def test_updates_from_user_changed( await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_INPUT_SOURCE_LIST] == [ "HEOS Drive - Line In 1", "Speaker - Line In 1", @@ -317,22 +327,28 @@ async def test_updates_from_user_changed( async def test_updates_from_groups_changed( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test player updates from changes to groups.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() # Assert current state - assert hass.states.get("media_player.test_player").attributes[ - ATTR_GROUP_MEMBERS - ] == ["media_player.test_player", "media_player.test_player_2"] - assert hass.states.get("media_player.test_player_2").attributes[ - ATTR_GROUP_MEMBERS - ] == ["media_player.test_player", "media_player.test_player_2"] + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.attributes[ATTR_GROUP_MEMBERS] == [ + "media_player.test_player", + "media_player.test_player_2", + ] + state = hass.states.get("media_player.test_player_2") + assert state is not None + assert state.attributes[ATTR_GROUP_MEMBERS] == [ + "media_player.test_player", + "media_player.test_player_2", + ] # Clear group information - controller._groups = {} + controller.mock_set_groups({}) for player in controller.players.values(): player.group_id = None await controller.dispatcher.wait_send( @@ -341,40 +357,37 @@ async def test_updates_from_groups_changed( await hass.async_block_till_done() # Assert groups changed - assert ( - hass.states.get("media_player.test_player").attributes[ATTR_GROUP_MEMBERS] - is None - ) - assert ( - hass.states.get("media_player.test_player_2").attributes[ATTR_GROUP_MEMBERS] - is None - ) + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.attributes[ATTR_GROUP_MEMBERS] is None + + state = hass.states.get("media_player.test_player_2") + assert state is not None + assert state.attributes[ATTR_GROUP_MEMBERS] is None async def test_clear_playlist( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the clear playlist service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.clear_queue.call_count == 1 + assert controller.player_clear_queue.call_count == 1 async def test_clear_playlist_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test error raised when clear playlist fails.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.clear_queue.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_clear_queue.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to clear playlist: Failure (1)") ): @@ -384,33 +397,31 @@ async def test_clear_playlist_error( {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.clear_queue.call_count == 1 + assert controller.player_clear_queue.call_count == 1 async def test_pause( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the pause service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PAUSE, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.pause.call_count == 1 + assert controller.player_set_play_state.call_count == 1 async def test_pause_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the pause service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.pause.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_play_state.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to pause: Failure (1)") ): @@ -420,33 +431,31 @@ async def test_pause_error( {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.pause.call_count == 1 + assert controller.player_set_play_state.call_count == 1 async def test_play( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the play service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PLAY, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play.call_count == 1 + assert controller.player_set_play_state.call_count == 1 async def test_play_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the play service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.play.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_play_state.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to play: Failure (1)") ): @@ -456,33 +465,31 @@ async def test_play_error( {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play.call_count == 1 + assert controller.player_set_play_state.call_count == 1 async def test_previous_track( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the previous track service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play_previous.call_count == 1 + assert controller.player_play_previous.call_count == 1 async def test_previous_track_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the previous track service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.play_previous.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_play_previous.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to move to previous track: Failure (1)"), @@ -493,33 +500,31 @@ async def test_previous_track_error( {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play_previous.call_count == 1 + assert controller.player_play_previous.call_count == 1 async def test_next_track( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the next track service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play_next.call_count == 1 + assert controller.player_play_next.call_count == 1 async def test_next_track_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the next track service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.play_next.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_play_next.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to move to next track: Failure (1)"), @@ -530,33 +535,31 @@ async def test_next_track_error( {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play_next.call_count == 1 + assert controller.player_play_next.call_count == 1 async def test_stop( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the stop service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_STOP, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.stop.call_count == 1 + assert controller.player_set_play_state.call_count == 1 async def test_stop_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the stop service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.stop.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_play_state.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to stop: Failure (1)"), @@ -567,33 +570,31 @@ async def test_stop_error( {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.stop.call_count == 1 + assert controller.player_set_play_state.call_count == 1 async def test_volume_mute( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the volume mute service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_MUTE, {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_MUTED: True}, blocking=True, ) - assert player.set_mute.call_count == 1 + assert controller.player_set_mute.call_count == 1 async def test_volume_mute_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the volume mute service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.set_mute.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_mute.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to set mute: Failure (1)"), @@ -604,11 +605,11 @@ async def test_volume_mute_error( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_MUTED: True}, blocking=True, ) - assert player.set_mute.call_count == 1 + assert controller.player_set_mute.call_count == 1 async def test_shuffle_set( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the shuffle set service.""" config_entry.add_to_hass(hass) @@ -620,17 +621,17 @@ async def test_shuffle_set( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_SHUFFLE: True}, blocking=True, ) - player.set_play_mode.assert_called_once_with(player.repeat, True) + controller.player_set_play_mode.assert_called_once_with(1, player.repeat, True) async def test_shuffle_set_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the shuffle set service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - player.set_play_mode.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_play_mode.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to set shuffle: Failure (1)"), @@ -641,11 +642,11 @@ async def test_shuffle_set_error( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_SHUFFLE: True}, blocking=True, ) - player.set_play_mode.assert_called_once_with(player.repeat, True) + controller.player_set_play_mode.assert_called_once_with(1, player.repeat, True) async def test_repeat_set( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the repeat set service.""" config_entry.add_to_hass(hass) @@ -657,17 +658,19 @@ async def test_repeat_set( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_REPEAT: RepeatMode.ONE}, blocking=True, ) - player.set_play_mode.assert_called_once_with(RepeatType.ON_ONE, player.shuffle) + controller.player_set_play_mode.assert_called_once_with( + 1, RepeatType.ON_ONE, player.shuffle + ) async def test_repeat_set_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the repeat set service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - player.set_play_mode.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_play_mode.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to set repeat: Failure (1)"), @@ -681,33 +684,33 @@ async def test_repeat_set_error( }, blocking=True, ) - player.set_play_mode.assert_called_once_with(RepeatType.ON_ALL, player.shuffle) + controller.player_set_play_mode.assert_called_once_with( + 1, RepeatType.ON_ALL, player.shuffle + ) async def test_volume_set( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the volume set service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_SET, {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, blocking=True, ) - player.set_volume.assert_called_once_with(100) + controller.player_set_volume.assert_called_once_with(1, 100) async def test_volume_set_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the volume set service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.set_volume.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_volume.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to set volume level: Failure (1)"), @@ -718,13 +721,13 @@ async def test_volume_set_error( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, blocking=True, ) - player.set_volume.assert_called_once_with(100) + controller.player_set_volume.assert_called_once_with(1, 100) async def test_select_favorite( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, favorites: dict[int, MediaItem], ) -> None: """Tests selecting a music service favorite and state.""" @@ -739,22 +742,23 @@ async def test_select_favorite( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, blocking=True, ) - player.play_preset_station.assert_called_once_with(1) + controller.play_preset_station.assert_called_once_with(1, 1) # Test state is matched by station name player.now_playing_media.type = HeosMediaType.STATION player.now_playing_media.station = favorite.name - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_INPUT_SOURCE] == favorite.name async def test_select_radio_favorite( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, favorites: dict[int, MediaItem], ) -> None: """Tests selecting a radio favorite and state.""" @@ -769,32 +773,32 @@ async def test_select_radio_favorite( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, blocking=True, ) - player.play_preset_station.assert_called_once_with(2) + controller.play_preset_station.assert_called_once_with(1, 2) # Test state is matched by album id player.now_playing_media.type = HeosMediaType.STATION player.now_playing_media.station = "Classical" player.now_playing_media.album_id = favorite.media_id - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_INPUT_SOURCE] == favorite.name async def test_select_radio_favorite_command_error( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, favorites: dict[int, MediaItem], ) -> None: """Tests command error raises when playing favorite.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] # Test set radio preset favorite = favorites[2] - player.play_preset_station.side_effect = CommandFailedError(None, "Failure", 1) + controller.play_preset_station.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to select source: Failure (1)"), @@ -808,7 +812,7 @@ async def test_select_radio_favorite_command_error( }, blocking=True, ) - player.play_preset_station.assert_called_once_with(2) + controller.play_preset_station.assert_called_once_with(1, 2) @pytest.mark.parametrize( @@ -821,7 +825,7 @@ async def test_select_radio_favorite_command_error( async def test_select_input_source( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, input_sources: list[MediaItem], source_name: str, station: str, @@ -840,21 +844,24 @@ async def test_select_input_source( }, blocking=True, ) - input_sources = next( + input_source = next( input_sources for input_sources in input_sources if input_sources.name == source_name ) - player.play_media.assert_called_once_with(input_sources) + controller.play_media.assert_called_once_with( + 1, input_source, AddCriteriaType.PLAY_NOW + ) # Update the now_playing_media to reflect play_media player.now_playing_media.source_id = const.MUSIC_SOURCE_AUX_INPUT player.now_playing_media.station = station player.now_playing_media.media_id = const.INPUT_AUX_IN_1 - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_INPUT_SOURCE] == source_name @@ -879,15 +886,14 @@ async def test_select_input_unknown_raises( async def test_select_input_command_error( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, input_sources: list[MediaItem], ) -> None: """Tests selecting an unknown input.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] input_source = input_sources[0] - player.play_media.side_effect = CommandFailedError(None, "Failure", 1) + controller.play_media.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to select source: Failure (1)"), @@ -901,7 +907,9 @@ async def test_select_input_command_error( }, blocking=True, ) - player.play_media.assert_called_once_with(input_source) + controller.play_media.assert_called_once_with( + 1, input_source, AddCriteriaType.PLAY_NOW + ) async def test_unload_config_entry( @@ -911,20 +919,21 @@ async def test_unload_config_entry( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) assert await hass.config_entries.async_unload(config_entry.entry_id) - assert hass.states.get("media_player.test_player").state == STATE_UNAVAILABLE + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize("media_type", [MediaType.URL, MediaType.MUSIC]) async def test_play_media( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, media_type: MediaType, ) -> None: """Test the play media service with type url.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] url = "http://news/podcast.mp3" await hass.services.async_call( MEDIA_PLAYER_DOMAIN, @@ -936,21 +945,20 @@ async def test_play_media( }, blocking=True, ) - player.play_url.assert_called_once_with(url) + controller.play_url.assert_called_once_with(1, url) @pytest.mark.parametrize("media_type", [MediaType.URL, MediaType.MUSIC]) async def test_play_media_error( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, media_type: MediaType, ) -> None: """Test the play media service with type url error raises.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.play_url.side_effect = CommandFailedError(None, "Failure", 1) + controller.play_url.side_effect = CommandFailedError("", "Failure", 1) url = "http://news/podcast.mp3" with pytest.raises( HomeAssistantError, @@ -966,7 +974,7 @@ async def test_play_media_error( }, blocking=True, ) - player.play_url.assert_called_once_with(url) + controller.play_url.assert_called_once_with(1, url) @pytest.mark.parametrize( @@ -975,14 +983,13 @@ async def test_play_media_error( async def test_play_media_quick_select( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, content_id: str, expected_index: int, ) -> None: """Test the play media service with type quick_select.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -993,16 +1000,15 @@ async def test_play_media_quick_select( }, blocking=True, ) - player.play_quick_select.assert_called_once_with(expected_index) + controller.player_play_quick_select.assert_called_once_with(1, expected_index) async def test_play_media_quick_select_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the play media service with invalid quick_select raises.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] with pytest.raises( HomeAssistantError, match=re.escape("Unable to play media: Invalid quick select 'Invalid'"), @@ -1017,7 +1023,7 @@ async def test_play_media_quick_select_error( }, blocking=True, ) - assert player.play_quick_select.call_count == 0 + assert controller.player_play_quick_select.call_count == 0 @pytest.mark.parametrize( @@ -1031,7 +1037,7 @@ async def test_play_media_quick_select_error( async def test_play_media_playlist( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, playlists: list[MediaItem], enqueue: Any, criteria: AddCriteriaType, @@ -1039,7 +1045,6 @@ async def test_play_media_playlist( """Test the play media service with type playlist.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] playlist = playlists[0] service_data = { ATTR_ENTITY_ID: "media_player.test_player", @@ -1054,16 +1059,15 @@ async def test_play_media_playlist( service_data, blocking=True, ) - player.play_media.assert_called_once_with(playlist, criteria) + controller.play_media.assert_called_once_with(1, playlist, criteria) async def test_play_media_playlist_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the play media service with an invalid playlist name.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] with pytest.raises( HomeAssistantError, match=re.escape("Unable to play media: Invalid playlist 'Invalid'"), @@ -1078,7 +1082,7 @@ async def test_play_media_playlist_error( }, blocking=True, ) - assert player.add_to_queue.call_count == 0 + assert controller.add_to_queue.call_count == 0 @pytest.mark.parametrize( @@ -1087,14 +1091,13 @@ async def test_play_media_playlist_error( async def test_play_media_favorite( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, content_id: str, expected_index: int, ) -> None: """Test the play media service with type favorite.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -1105,16 +1108,15 @@ async def test_play_media_favorite( }, blocking=True, ) - player.play_preset_station.assert_called_once_with(expected_index) + controller.play_preset_station.assert_called_once_with(1, expected_index) async def test_play_media_favorite_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the play media service with an invalid favorite raises.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] with pytest.raises( HomeAssistantError, match=re.escape("Unable to play media: Invalid favorite 'Invalid'"), @@ -1129,7 +1131,7 @@ async def test_play_media_favorite_error( }, blocking=True, ) - assert player.play_preset_station.call_count == 0 + assert controller.play_preset_station.call_count == 0 async def test_play_media_invalid_type( @@ -1165,7 +1167,7 @@ async def test_play_media_invalid_type( async def test_media_player_join_group( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, members: list[str], expected: tuple[int, list[int]], ) -> None: @@ -1185,7 +1187,7 @@ async def test_media_player_join_group( async def test_media_player_join_group_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test grouping of media players through the join service raises error.""" config_entry.add_to_hass(hass) @@ -1209,13 +1211,14 @@ async def test_media_player_join_group_error( async def test_media_player_group_members( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, caplog: pytest.LogCaptureFixture, ) -> None: """Test group_members attribute.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) player_entity = hass.states.get("media_player.test_player") + assert player_entity is not None assert player_entity.attributes[ATTR_GROUP_MEMBERS] == [ "media_player.test_player", "media_player.test_player_2", @@ -1227,16 +1230,17 @@ async def test_media_player_group_members( async def test_media_player_group_members_error( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, caplog: pytest.LogCaptureFixture, ) -> None: """Test error in HEOS API.""" + controller.mock_set_groups({}) controller.get_groups.side_effect = HeosError("error") - controller._groups = {} config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) assert "Unable to retrieve groups" in caplog.text player_entity = hass.states.get("media_player.test_player") + assert player_entity is not None assert player_entity.attributes[ATTR_GROUP_MEMBERS] is None @@ -1247,7 +1251,7 @@ async def test_media_player_group_members_error( async def test_media_player_unjoin_group( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, entity_id: str, expected_args: list[int], ) -> None: @@ -1266,7 +1270,7 @@ async def test_media_player_unjoin_group( async def test_media_player_unjoin_group_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test ungrouping of media players through the unjoin service error raises.""" config_entry.add_to_hass(hass) @@ -1289,7 +1293,7 @@ async def test_media_player_unjoin_group_error( async def test_media_player_group_fails_when_entity_removed( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, entity_registry: er.EntityRegistry, ) -> None: """Test grouping fails when entity removed.""" @@ -1316,7 +1320,7 @@ async def test_media_player_group_fails_when_entity_removed( async def test_media_player_group_fails_wrong_integration( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, entity_registry: er.EntityRegistry, ) -> None: """Test grouping fails when trying to join from the wrong integration.""" diff --git a/tests/components/heos/test_services.py b/tests/components/heos/test_services.py index 8eda26d2b3d..151571ceb50 100644 --- a/tests/components/heos/test_services.py +++ b/tests/components/heos/test_services.py @@ -1,6 +1,6 @@ """Tests for the services module.""" -from pyheos import CommandAuthenticationError, Heos, HeosError +from pyheos import CommandAuthenticationError, HeosError import pytest from homeassistant.components.heos.const import ( @@ -13,11 +13,13 @@ from homeassistant.components.heos.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from . import MockHeos + from tests.common import MockConfigEntry async def test_sign_in( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the sign-in service.""" config_entry.add_to_hass(hass) @@ -34,7 +36,7 @@ async def test_sign_in( async def test_sign_in_failed( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test sign-in service logs error when not connected.""" config_entry.add_to_hass(hass) @@ -56,7 +58,7 @@ async def test_sign_in_failed( async def test_sign_in_unknown_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test sign-in service logs error for failure.""" config_entry.add_to_hass(hass) @@ -93,7 +95,7 @@ async def test_sign_in_not_loaded_raises( async def test_sign_out( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the sign-out service.""" config_entry.add_to_hass(hass) @@ -117,7 +119,7 @@ async def test_sign_out_not_loaded_raises( async def test_sign_out_unknown_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the sign-out service.""" config_entry.add_to_hass(hass) From 688a1f1d52fdb9f12a6429dcaeeb2721a05c8e9f Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 29 Jan 2025 04:46:26 +0100 Subject: [PATCH 1142/2987] Add UI to create KNX BinarySensor entities (#136786) Update knx-frontend to 2025.1.28.225404 --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index acb9b9b61a0..f34ce0f4589 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "requirements": [ "xknx==3.5.0", "xknxproject==3.8.1", - "knx-frontend==2025.1.18.164225" + "knx-frontend==2025.1.28.225404" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index c67a83de01a..9fceadde1b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1272,7 +1272,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.18.164225 +knx-frontend==2025.1.28.225404 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 78b4ed27566..ff43be037f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1074,7 +1074,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.18.164225 +knx-frontend==2025.1.28.225404 # homeassistant.components.konnected konnected==1.2.0 From f909b548111ab5059e273056ad7fe1ecedd460a5 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Wed, 29 Jan 2025 04:46:52 +0100 Subject: [PATCH 1143/2987] Redact stored authentication token in HomeWizard diagnostics (#136766) --- homeassistant/components/homewizard/diagnostics.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homewizard/diagnostics.py b/homeassistant/components/homewizard/diagnostics.py index c776cdb18f2..12bd25671e0 100644 --- a/homeassistant/components/homewizard/diagnostics.py +++ b/homeassistant/components/homewizard/diagnostics.py @@ -16,6 +16,7 @@ TO_REDACT = { "gas_unique_id", "id", "serial", + "token", "unique_id", "unique_meter_id", "wifi_ssid", From d06b0fe3403589795cea5096a825f6cdcf7111d1 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 28 Jan 2025 22:48:38 -0500 Subject: [PATCH 1144/2987] Reload template blueprints when reloading templates (#136794) --- homeassistant/components/template/__init__.py | 1 + tests/components/template/test_blueprint.py | 63 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 7b7b5eb9b29..15a73cf3de5 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -53,6 +53,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _reload_config(call: Event | ServiceCall) -> None: """Reload top-level + platforms.""" + await async_get_blueprints(hass).async_reset_cache() try: unprocessed_conf = await conf_util.async_hass_config_yaml(hass) except HomeAssistantError as err: diff --git a/tests/components/template/test_blueprint.py b/tests/components/template/test_blueprint.py index cb4e83d934c..dd008a27822 100644 --- a/tests/components/template/test_blueprint.py +++ b/tests/components/template/test_blueprint.py @@ -149,6 +149,69 @@ async def test_inverted_binary_sensor( ) +async def test_reload_template_when_blueprint_changes(hass: HomeAssistant) -> None: + """Test a template is updated at reload if the blueprint has changed.""" + hass.states.async_set("binary_sensor.foo", "on", {"friendly_name": "Foo"}) + config = { + DOMAIN: [ + { + "use_blueprint": { + "path": "inverted_binary_sensor.yaml", + "input": {"reference_entity": "binary_sensor.foo"}, + }, + "name": "Inverted foo", + }, + ] + } + with patch_blueprint( + "inverted_binary_sensor.yaml", + BUILTIN_BLUEPRINT_FOLDER / "inverted_binary_sensor.yaml", + ): + assert await async_setup_component(hass, DOMAIN, config) + + hass.states.async_set("binary_sensor.foo", "off", {"friendly_name": "Foo"}) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.foo").state == "off" + + inverted = hass.states.get("binary_sensor.inverted_foo") + assert inverted + assert inverted.state == "on" + + # Reload the automations without any change, but with updated blueprint + blueprint_config = yaml_util.load_yaml( + BUILTIN_BLUEPRINT_FOLDER / "inverted_binary_sensor.yaml" + ) + blueprint_config["binary_sensor"]["state"] = "{{ states(reference_entity) }}" + with ( + patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ), + patch( + "homeassistant.components.blueprint.models.yaml_util.load_yaml_dict", + autospec=True, + return_value=blueprint_config, + ), + ): + await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True) + + hass.states.async_set("binary_sensor.foo", "off", {"friendly_name": "Foo"}) + await hass.async_block_till_done() + + not_inverted = hass.states.get("binary_sensor.inverted_foo") + assert not_inverted + assert not_inverted.state == "off" + + hass.states.async_set("binary_sensor.foo", "on", {"friendly_name": "Foo"}) + await hass.async_block_till_done() + + not_inverted = hass.states.get("binary_sensor.inverted_foo") + assert not_inverted + assert not_inverted.state == "on" + + async def test_domain_blueprint(hass: HomeAssistant) -> None: """Test DomainBlueprint services.""" reload_handler_calls = async_mock_service(hass, DOMAIN, SERVICE_RELOAD) From 48dfa037bd8455d54ddfbb7d898edbf68a01ac1c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 28 Jan 2025 22:25:35 -0600 Subject: [PATCH 1145/2987] Bump intents to 2025.1.28 (#136782) * Bump intents to 2025.1.28 * Fix snapshots --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- .../components/assist_pipeline/snapshots/test_websocket.ambr | 4 ++-- tests/components/conversation/snapshots/test_http.ambr | 3 ++- 7 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 7ca1799b2d1..0485cb75fcb 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.0", "home-assistant-intents==2025.1.1"] + "requirements": ["hassil==2.2.0", "home-assistant-intents==2025.1.28"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8643d53ff68..f7f30bf7d71 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20250109.2 -home-assistant-intents==2025.1.1 +home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.5 diff --git a/requirements_all.txt b/requirements_all.txt index 9fceadde1b9..66ec0b992f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,7 @@ holidays==0.65 home-assistant-frontend==20250109.2 # homeassistant.components.conversation -home-assistant-intents==2025.1.1 +home-assistant-intents==2025.1.28 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ff43be037f7..bf28f289f2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -975,7 +975,7 @@ holidays==0.65 home-assistant-frontend==20250109.2 # homeassistant.components.conversation -home-assistant-intents==2025.1.1 +home-assistant-intents==2025.1.28 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 5700ca01462..2c433ba362e 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.21,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.9.1 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.0 home-assistant-intents==2025.1.1 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.0 home-assistant-intents==2025.1.28 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 917a9b654d5..5f06172404b 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -710,7 +710,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any area called Are', + 'speech': 'Sorry, I am not aware of any area called Are the', }), }), }), @@ -756,7 +756,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any area called Are', + 'speech': 'Sorry, I am not aware of any area called Are the', }), }), }), diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 3e71ee99382..c6ac6c2df9c 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -49,6 +49,7 @@ 'sk', 'sl', 'sr', + 'sr-Latn', 'sv', 'sw', 'te', @@ -539,7 +540,7 @@ 'name': 'HassTurnOn', }), 'match': True, - 'sentence_template': ' on [] ', + 'sentence_template': ' on [(|)] ', 'slots': dict({ 'area': 'kitchen', 'domain': 'light', From 94e4863cbe727ebe4094e2958d637262f94a6216 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 29 Jan 2025 06:34:26 +0100 Subject: [PATCH 1146/2987] Add power protection entities for tplink (#132267) Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .../components/tplink/binary_sensor.py | 4 ++ homeassistant/components/tplink/number.py | 4 ++ homeassistant/components/tplink/strings.json | 6 ++ .../components/tplink/fixtures/features.json | 11 ++++ .../tplink/snapshots/test_binary_sensor.ambr | 47 ++++++++++++++++ .../tplink/snapshots/test_number.ambr | 55 +++++++++++++++++++ 6 files changed, 127 insertions(+) diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py index e08495f5c88..6986765b110 100644 --- a/homeassistant/components/tplink/binary_sensor.py +++ b/homeassistant/components/tplink/binary_sensor.py @@ -35,6 +35,10 @@ BINARY_SENSOR_DESCRIPTIONS: Final = ( key="overheated", device_class=BinarySensorDeviceClass.PROBLEM, ), + TPLinkBinarySensorEntityDescription( + key="overloaded", + device_class=BinarySensorDeviceClass.PROBLEM, + ), TPLinkBinarySensorEntityDescription( key="battery_low", device_class=BinarySensorDeviceClass.BATTERY, diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py index b47c50d688f..a9d002c0083 100644 --- a/homeassistant/components/tplink/number.py +++ b/homeassistant/components/tplink/number.py @@ -65,6 +65,10 @@ NUMBER_DESCRIPTIONS: Final = ( key="tilt_step", mode=NumberMode.BOX, ), + TPLinkNumberEntityDescription( + key="power_protection_threshold", + mode=NumberMode.SLIDER, + ), TPLinkNumberEntityDescription( key="clean_count", mode=NumberMode.SLIDER, diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 034aff7a763..fe661fa2529 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -109,6 +109,9 @@ "overheated": { "name": "Overheated" }, + "overloaded": { + "name": "Overloaded" + }, "cloud_connection": { "name": "Cloud connection" }, @@ -268,6 +271,9 @@ "temperature_offset": { "name": "Temperature offset" }, + "power_protection_threshold": { + "name": "Power protection" + }, "pan_step": { "name": "Pan degrees" }, diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index d366a91c33c..c49c5881d5c 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -195,6 +195,11 @@ "type": "BinarySensor", "category": "Info" }, + "overloaded": { + "value": false, + "type": "BinarySensor", + "category": "Info" + }, "battery_low": { "value": false, "type": "BinarySensor", @@ -284,6 +289,12 @@ "minimum_value": -10, "maximum_value": 10 }, + "power_protection_threshold": { + "value": 100, + "type": "Number", + "category": "Config", + "minimum_value": 0 + }, "target_temperature": { "value": false, "type": "Number", diff --git a/tests/components/tplink/snapshots/test_binary_sensor.ambr b/tests/components/tplink/snapshots/test_binary_sensor.ambr index e16d4409511..125592b053c 100644 --- a/tests/components/tplink/snapshots/test_binary_sensor.ambr +++ b/tests/components/tplink/snapshots/test_binary_sensor.ambr @@ -300,6 +300,53 @@ 'state': 'off', }) # --- +# name: test_states[binary_sensor.my_device_overloaded-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_device_overloaded', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overloaded', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'overloaded', + 'unique_id': '123456789ABCDEFGH_overloaded', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[binary_sensor.my_device_overloaded-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'my_device Overloaded', + }), + 'context': , + 'entity_id': 'binary_sensor.my_device_overloaded', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_states[binary_sensor.my_device_temperature_warning-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tplink/snapshots/test_number.ambr b/tests/components/tplink/snapshots/test_number.ambr index 6733c5423a0..4bdb92aeab6 100644 --- a/tests/components/tplink/snapshots/test_number.ambr +++ b/tests/components/tplink/snapshots/test_number.ambr @@ -145,6 +145,61 @@ 'state': '10', }) # --- +# name: test_states[number.my_device_power_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 65536, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.my_device_power_protection', + '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': 'Power protection', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_protection_threshold', + 'unique_id': '123456789ABCDEFGH_power_protection_threshold', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.my_device_power_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Power protection', + 'max': 65536, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.my_device_power_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_states[number.my_device_smooth_off-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From a2b5a96bc939b0111b8f01c47927589fa3173b2e Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 28 Jan 2025 21:43:30 -0800 Subject: [PATCH 1147/2987] Add Google Drive integration for backup (#134576) * Add Google Drive integration for backup * Add test_config_flow * Stop using aiogoogle * address a few comments * Check folder exists in setup * fix test * address comments * fix * fix * Use ChunkAsyncStreamIterator in helpers * repair-issues: todo * Remove check if folder exists in the reatuh flow. This is done in setup. * single_config_entry": true * Add test_init.py * Store into backups.json to avoid 124 bytes per property limit * Address comments * autouse=True on setup_credentials * Store metadata in description and remove backups.json * improvements * timeout downloads * library * fixes * strings * review * ruff * fix test * Set unique_id * Use slugify in homeassistant.util * Fix * Remove RefreshError * review * push more fields to the test constant --------- Co-authored-by: Joostlek --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/google.json | 1 + .../components/google_drive/__init__.py | 65 +++ homeassistant/components/google_drive/api.py | 201 ++++++++ .../google_drive/application_credentials.py | 21 + .../components/google_drive/backup.py | 147 ++++++ .../components/google_drive/config_flow.py | 114 +++++ .../components/google_drive/const.py | 5 + .../components/google_drive/manifest.json | 14 + .../google_drive/quality_scale.yaml | 113 +++++ .../components/google_drive/strings.json | 40 ++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/google_drive/__init__.py | 1 + tests/components/google_drive/conftest.py | 80 +++ .../google_drive/snapshots/test_backup.ambr | 237 +++++++++ .../snapshots/test_config_flow.ambr | 44 ++ tests/components/google_drive/test_backup.py | 461 ++++++++++++++++++ .../google_drive/test_config_flow.py | 363 ++++++++++++++ tests/components/google_drive/test_init.py | 164 +++++++ 25 files changed, 2098 insertions(+) create mode 100644 homeassistant/components/google_drive/__init__.py create mode 100644 homeassistant/components/google_drive/api.py create mode 100644 homeassistant/components/google_drive/application_credentials.py create mode 100644 homeassistant/components/google_drive/backup.py create mode 100644 homeassistant/components/google_drive/config_flow.py create mode 100644 homeassistant/components/google_drive/const.py create mode 100644 homeassistant/components/google_drive/manifest.json create mode 100644 homeassistant/components/google_drive/quality_scale.yaml create mode 100644 homeassistant/components/google_drive/strings.json create mode 100644 tests/components/google_drive/__init__.py create mode 100644 tests/components/google_drive/conftest.py create mode 100644 tests/components/google_drive/snapshots/test_backup.ambr create mode 100644 tests/components/google_drive/snapshots/test_config_flow.ambr create mode 100644 tests/components/google_drive/test_backup.py create mode 100644 tests/components/google_drive/test_config_flow.py create mode 100644 tests/components/google_drive/test_init.py diff --git a/.strict-typing b/.strict-typing index 811e5d54c81..1a5450d8eb4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -217,6 +217,7 @@ homeassistant.components.goalzero.* homeassistant.components.google.* homeassistant.components.google_assistant_sdk.* homeassistant.components.google_cloud.* +homeassistant.components.google_drive.* homeassistant.components.google_photos.* homeassistant.components.google_sheets.* homeassistant.components.govee_ble.* diff --git a/CODEOWNERS b/CODEOWNERS index 68a33f34f9a..7baeea72178 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -566,6 +566,8 @@ build.json @home-assistant/supervisor /tests/components/google_assistant_sdk/ @tronikos /homeassistant/components/google_cloud/ @lufton @tronikos /tests/components/google_cloud/ @lufton @tronikos +/homeassistant/components/google_drive/ @tronikos +/tests/components/google_drive/ @tronikos /homeassistant/components/google_generative_ai_conversation/ @tronikos /tests/components/google_generative_ai_conversation/ @tronikos /homeassistant/components/google_mail/ @tkdrob diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index 028fa544a5f..872cfc0aac5 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -5,6 +5,7 @@ "google_assistant", "google_assistant_sdk", "google_cloud", + "google_drive", "google_generative_ai_conversation", "google_mail", "google_maps", diff --git a/homeassistant/components/google_drive/__init__.py b/homeassistant/components/google_drive/__init__.py new file mode 100644 index 00000000000..af93956931a --- /dev/null +++ b/homeassistant/components/google_drive/__init__.py @@ -0,0 +1,65 @@ +"""The Google Drive integration.""" + +from __future__ import annotations + +from collections.abc import Callable + +from google_drive_api.exceptions import GoogleDriveApiError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import instance_id +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) +from homeassistant.util.hass_dict import HassKey + +from .api import AsyncConfigEntryAuth, DriveClient +from .const import DOMAIN + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) + + +type GoogleDriveConfigEntry = ConfigEntry[DriveClient] + + +async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) -> bool: + """Set up Google Drive from a config entry.""" + auth = AsyncConfigEntryAuth( + async_get_clientsession(hass), + OAuth2Session( + hass, entry, await async_get_config_entry_implementation(hass, entry) + ), + ) + + # Test we can refresh the token and raise ConfigEntryAuthFailed or ConfigEntryNotReady if not + await auth.async_get_access_token() + + client = DriveClient(await instance_id.async_get(hass), auth) + entry.runtime_data = client + + # Test we can access Google Drive and raise if not + try: + await client.async_create_ha_root_folder_if_not_exists() + except GoogleDriveApiError as err: + raise ConfigEntryNotReady from err + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: GoogleDriveConfigEntry +) -> bool: + """Unload a config entry.""" + hass.loop.call_soon(_notify_backup_listeners, hass) + return True + + +def _notify_backup_listeners(hass: HomeAssistant) -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() diff --git a/homeassistant/components/google_drive/api.py b/homeassistant/components/google_drive/api.py new file mode 100644 index 00000000000..a26512db35b --- /dev/null +++ b/homeassistant/components/google_drive/api.py @@ -0,0 +1,201 @@ +"""API for Google Drive bound to Home Assistant OAuth.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +import json +import logging +from typing import Any + +from aiohttp import ClientSession, ClientTimeout, StreamReader +from aiohttp.client_exceptions import ClientError, ClientResponseError +from google_drive_api.api import AbstractAuth, GoogleDriveApi + +from homeassistant.components.backup import AgentBackup +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) +from homeassistant.helpers import config_entry_oauth2_flow + +_UPLOAD_AND_DOWNLOAD_TIMEOUT = 12 * 3600 + +_LOGGER = logging.getLogger(__name__) + + +class AsyncConfigEntryAuth(AbstractAuth): + """Provide Google Drive authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize AsyncConfigEntryAuth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + try: + await self._oauth_session.async_ensure_token_valid() + except ClientError as ex: + if ( + self._oauth_session.config_entry.state + is ConfigEntryState.SETUP_IN_PROGRESS + ): + if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500: + raise ConfigEntryAuthFailed( + "OAuth session is not valid, reauth required" + ) from ex + raise ConfigEntryNotReady from ex + if hasattr(ex, "status") and ex.status == 400: + self._oauth_session.config_entry.async_start_reauth( + self._oauth_session.hass + ) + raise HomeAssistantError(ex) from ex + return str(self._oauth_session.token[CONF_ACCESS_TOKEN]) + + +class AsyncConfigFlowAuth(AbstractAuth): + """Provide authentication tied to a fixed token for the config flow.""" + + def __init__( + self, + websession: ClientSession, + token: str, + ) -> None: + """Initialize AsyncConfigFlowAuth.""" + super().__init__(websession) + self._token = token + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + return self._token + + +class DriveClient: + """Google Drive client.""" + + def __init__( + self, + ha_instance_id: str, + auth: AbstractAuth, + ) -> None: + """Initialize Google Drive client.""" + self._ha_instance_id = ha_instance_id + self._api = GoogleDriveApi(auth) + + async def async_get_email_address(self) -> str: + """Get email address of the current user.""" + res = await self._api.get_user(params={"fields": "user(emailAddress)"}) + return str(res["user"]["emailAddress"]) + + async def async_create_ha_root_folder_if_not_exists(self) -> tuple[str, str]: + """Create Home Assistant folder if it doesn't exist.""" + fields = "id,name" + query = " and ".join( + [ + "properties has { key='home_assistant' and value='root' }", + f"properties has {{ key='instance_id' and value='{self._ha_instance_id}' }}", + "trashed=false", + ] + ) + res = await self._api.list_files( + params={"q": query, "fields": f"files({fields})"} + ) + for file in res["files"]: + _LOGGER.debug("Found existing folder: %s", file) + return str(file["id"]), str(file["name"]) + + file_metadata = { + "name": "Home Assistant", + "mimeType": "application/vnd.google-apps.folder", + "properties": { + "home_assistant": "root", + "instance_id": self._ha_instance_id, + }, + } + _LOGGER.debug("Creating new folder with metadata: %s", file_metadata) + res = await self._api.create_file(params={"fields": fields}, json=file_metadata) + _LOGGER.debug("Created folder: %s", res) + return str(res["id"]), str(res["name"]) + + async def async_upload_backup( + self, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + ) -> None: + """Upload a backup.""" + folder_id, _ = await self.async_create_ha_root_folder_if_not_exists() + backup_metadata = { + "name": f"{backup.name} {backup.date}.tar", + "description": json.dumps(backup.as_dict()), + "parents": [folder_id], + "properties": { + "home_assistant": "backup", + "instance_id": self._ha_instance_id, + "backup_id": backup.backup_id, + }, + } + _LOGGER.debug( + "Uploading backup: %s with Google Drive metadata: %s", + backup.backup_id, + backup_metadata, + ) + await self._api.upload_file( + backup_metadata, + open_stream, + timeout=ClientTimeout(total=_UPLOAD_AND_DOWNLOAD_TIMEOUT), + ) + _LOGGER.debug( + "Uploaded backup: %s to: '%s'", + backup.backup_id, + backup_metadata["name"], + ) + + async def async_list_backups(self) -> list[AgentBackup]: + """List backups.""" + query = " and ".join( + [ + "properties has { key='home_assistant' and value='backup' }", + f"properties has {{ key='instance_id' and value='{self._ha_instance_id}' }}", + "trashed=false", + ] + ) + res = await self._api.list_files( + params={"q": query, "fields": "files(description)"} + ) + backups = [] + for file in res["files"]: + backup = AgentBackup.from_dict(json.loads(file["description"])) + backups.append(backup) + return backups + + async def async_get_backup_file_id(self, backup_id: str) -> str | None: + """Get file_id of backup if it exists.""" + query = " and ".join( + [ + "properties has { key='home_assistant' and value='backup' }", + f"properties has {{ key='instance_id' and value='{self._ha_instance_id}' }}", + f"properties has {{ key='backup_id' and value='{backup_id}' }}", + ] + ) + res = await self._api.list_files(params={"q": query, "fields": "files(id)"}) + for file in res["files"]: + return str(file["id"]) + return None + + async def async_delete(self, file_id: str) -> None: + """Delete file.""" + await self._api.delete_file(file_id) + + async def async_download(self, file_id: str) -> StreamReader: + """Download a file.""" + resp = await self._api.get_file_content( + file_id, timeout=ClientTimeout(total=_UPLOAD_AND_DOWNLOAD_TIMEOUT) + ) + return resp.content diff --git a/homeassistant/components/google_drive/application_credentials.py b/homeassistant/components/google_drive/application_credentials.py new file mode 100644 index 00000000000..c2f59b298cb --- /dev/null +++ b/homeassistant/components/google_drive/application_credentials.py @@ -0,0 +1,21 @@ +"""application_credentials platform for Google Drive.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + "https://accounts.google.com/o/oauth2/v2/auth", + "https://oauth2.googleapis.com/token", + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_drive/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + } diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py new file mode 100644 index 00000000000..4c81f041c8b --- /dev/null +++ b/homeassistant/components/google_drive/backup.py @@ -0,0 +1,147 @@ +"""Backup platform for the Google Drive integration.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +import logging +from typing import Any + +from google_drive_api.exceptions import GoogleDriveApiError + +from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator +from homeassistant.util import slugify + +from . import DATA_BACKUP_AGENT_LISTENERS, GoogleDriveConfigEntry +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_backup_agents( + hass: HomeAssistant, + **kwargs: Any, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries = hass.config_entries.async_loaded_entries(DOMAIN) + return [GoogleDriveBackupAgent(entry) for entry in entries] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed. + + :return: A function to unregister the listener. + """ + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +class GoogleDriveBackupAgent(BackupAgent): + """Google Drive backup agent.""" + + domain = DOMAIN + + def __init__(self, config_entry: GoogleDriveConfigEntry) -> None: + """Initialize the cloud backup sync agent.""" + super().__init__() + assert config_entry.unique_id + self.name = config_entry.title + self.unique_id = slugify(config_entry.unique_id) + self._client = config_entry.runtime_data + + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup. + + :param open_stream: A function returning an async iterator that yields bytes. + :param backup: Metadata about the backup that should be uploaded. + """ + try: + await self._client.async_upload_backup(open_stream, backup) + except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: + _LOGGER.error("Upload backup error: %s", err) + raise BackupAgentError("Failed to upload backup") from err + + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + try: + return await self._client.async_list_backups() + except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: + _LOGGER.error("List backups error: %s", err) + raise BackupAgentError("Failed to list backups") from err + + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + backups = await self.async_list_backups() + for backup in backups: + if backup.backup_id == backup_id: + return backup + return None + + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: An async iterator that yields bytes. + """ + _LOGGER.debug("Downloading backup_id: %s", backup_id) + try: + file_id = await self._client.async_get_backup_file_id(backup_id) + if file_id: + _LOGGER.debug("Downloading file_id: %s", file_id) + stream = await self._client.async_download(file_id) + return ChunkAsyncStreamIterator(stream) + except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: + _LOGGER.error("Download backup error: %s", err) + raise BackupAgentError("Failed to download backup") from err + _LOGGER.error("Download backup_id: %s not found", backup_id) + raise BackupAgentError("Backup not found") + + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + """ + _LOGGER.debug("Deleting backup_id: %s", backup_id) + try: + file_id = await self._client.async_get_backup_file_id(backup_id) + if file_id: + _LOGGER.debug("Deleting file_id: %s", file_id) + await self._client.async_delete(file_id) + _LOGGER.debug("Deleted backup_id: %s", backup_id) + except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: + _LOGGER.error("Delete backup error: %s", err) + raise BackupAgentError("Failed to delete backup") from err diff --git a/homeassistant/components/google_drive/config_flow.py b/homeassistant/components/google_drive/config_flow.py new file mode 100644 index 00000000000..fb74af42210 --- /dev/null +++ b/homeassistant/components/google_drive/config_flow.py @@ -0,0 +1,114 @@ +"""Config flow for the Google Drive integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any, cast + +from google_drive_api.exceptions import GoogleDriveApiError + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers import config_entry_oauth2_flow, instance_id +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import AsyncConfigFlowAuth, DriveClient +from .const import DOMAIN + +DEFAULT_NAME = "Google Drive" +DRIVE_FOLDER_URL_PREFIX = "https://drive.google.com/drive/folders/" +OAUTH2_SCOPES = [ + "https://www.googleapis.com/auth/drive.file", +] + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Google Drive OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": " ".join(OAUTH2_SCOPES), + # Add params to ensure we get back a refresh token + "access_type": "offline", + "prompt": "consent", + } + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an entry for the flow, or update existing entry.""" + client = DriveClient( + await instance_id.async_get(self.hass), + AsyncConfigFlowAuth( + async_get_clientsession(self.hass), data[CONF_TOKEN][CONF_ACCESS_TOKEN] + ), + ) + + try: + email_address = await client.async_get_email_address() + except GoogleDriveApiError as err: + self.logger.error("Error getting email address: %s", err) + return self.async_abort( + reason="access_not_configured", + description_placeholders={"message": str(err)}, + ) + except Exception: + self.logger.exception("Unknown error occurred") + return self.async_abort(reason="unknown") + + await self.async_set_unique_id(email_address) + + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + self._abort_if_unique_id_mismatch( + reason="wrong_account", + description_placeholders={"email": cast(str, reauth_entry.unique_id)}, + ) + return self.async_update_reload_and_abort(reauth_entry, data=data) + + self._abort_if_unique_id_configured() + + try: + ( + folder_id, + folder_name, + ) = await client.async_create_ha_root_folder_if_not_exists() + except GoogleDriveApiError as err: + self.logger.error("Error creating folder: %s", str(err)) + return self.async_abort( + reason="create_folder_failure", + description_placeholders={"message": str(err)}, + ) + + return self.async_create_entry( + title=DEFAULT_NAME, + data=data, + description_placeholders={ + "folder_name": folder_name, + "url": f"{DRIVE_FOLDER_URL_PREFIX}{folder_id}", + }, + ) diff --git a/homeassistant/components/google_drive/const.py b/homeassistant/components/google_drive/const.py new file mode 100644 index 00000000000..3f0b3e9d610 --- /dev/null +++ b/homeassistant/components/google_drive/const.py @@ -0,0 +1,5 @@ +"""Constants for the Google Drive integration.""" + +from __future__ import annotations + +DOMAIN = "google_drive" diff --git a/homeassistant/components/google_drive/manifest.json b/homeassistant/components/google_drive/manifest.json new file mode 100644 index 00000000000..a1abb9b260a --- /dev/null +++ b/homeassistant/components/google_drive/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "google_drive", + "name": "Google Drive", + "after_dependencies": ["backup"], + "codeowners": ["@tronikos"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/google_drive", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["google_drive_api"], + "quality_scale": "platinum", + "requirements": ["python-google-drive-api==0.0.2"] +} diff --git a/homeassistant/components/google_drive/quality_scale.yaml b/homeassistant/components/google_drive/quality_scale.yaml new file mode 100644 index 00000000000..70627a6a6d7 --- /dev/null +++ b/homeassistant/components/google_drive/quality_scale.yaml @@ -0,0 +1,113 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No actions. + appropriate-polling: + status: exempt + comment: No polling. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: No entities. + entity-unique-id: + status: exempt + comment: No entities. + has-entity-name: + status: exempt + comment: No entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No configuration options. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: No entities. + integration-owner: done + log-when-unavailable: + status: exempt + comment: No entities. + parallel-updates: + status: exempt + comment: No actions and no entities. + reauthentication-flow: done + test-coverage: done + + # Gold + devices: + status: exempt + comment: No devices. + diagnostics: + status: exempt + comment: No data to diagnose. + discovery-update-info: + status: exempt + comment: No discovery. + discovery: + status: exempt + comment: No discovery. + docs-data-update: + status: exempt + comment: No updates. + docs-examples: + status: exempt + comment: | + This integration only serves backup. + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: No devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: No devices. + entity-category: + status: exempt + comment: No entities. + entity-device-class: + status: exempt + comment: No entities. + entity-disabled-by-default: + status: exempt + comment: No entities. + entity-translations: + status: exempt + comment: No entities. + exception-translations: done + icon-translations: + status: exempt + comment: No entities. + reconfiguration-flow: + status: exempt + comment: No configuration options. + repair-issues: + status: exempt + comment: No repairs. + stale-devices: + status: exempt + comment: No devices. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/google_drive/strings.json b/homeassistant/components/google_drive/strings.json new file mode 100644 index 00000000000..3441bec4294 --- /dev/null +++ b/homeassistant/components/google_drive/strings.json @@ -0,0 +1,40 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Google Drive integration needs to re-authenticate your account" + }, + "auth": { + "title": "Link Google Account" + } + }, + "abort": { + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "access_not_configured": "Unable to access the Google Drive API:\n\n{message}", + "create_folder_failure": "Error while creating Google Drive folder:\n\n{message}", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "Wrong account: Please authenticate with {email}." + }, + "create_entry": { + "default": "Using [{folder_name}]({url}) folder. Feel free to rename it in Google Drive as you wish." + } + }, + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Drive. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index ef55798b3a0..08fe28e4df5 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -9,6 +9,7 @@ APPLICATION_CREDENTIALS = [ "geocaching", "google", "google_assistant_sdk", + "google_drive", "google_mail", "google_photos", "google_sheets", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 12dda0f56be..921910d5046 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -230,6 +230,7 @@ FLOWS = { "google", "google_assistant_sdk", "google_cloud", + "google_drive", "google_generative_ai_conversation", "google_mail", "google_photos", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 53a485a1340..05227e20159 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2295,6 +2295,12 @@ "iot_class": "cloud_push", "name": "Google Cloud" }, + "google_drive": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Google Drive" + }, "google_generative_ai_conversation": { "integration_type": "service", "config_flow": true, diff --git a/mypy.ini b/mypy.ini index db1ec0a04e4..2139449ba8d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1926,6 +1926,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.google_drive.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.google_photos.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 66ec0b992f3..d6fac067973 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2384,6 +2384,9 @@ python-gc100==1.0.3a0 # homeassistant.components.gitlab_ci python-gitlab==1.6.0 +# homeassistant.components.google_drive +python-google-drive-api==0.0.2 + # homeassistant.components.analytics_insights python-homeassistant-analytics==0.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf28f289f2e..366edfd23ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1929,6 +1929,9 @@ python-fullykiosk==0.0.14 # homeassistant.components.sms # python-gammu==3.2.4 +# homeassistant.components.google_drive +python-google-drive-api==0.0.2 + # homeassistant.components.analytics_insights python-homeassistant-analytics==0.8.1 diff --git a/tests/components/google_drive/__init__.py b/tests/components/google_drive/__init__.py new file mode 100644 index 00000000000..7a55f70a3d6 --- /dev/null +++ b/tests/components/google_drive/__init__.py @@ -0,0 +1 @@ +"""Tests for the Google Drive integration.""" diff --git a/tests/components/google_drive/conftest.py b/tests/components/google_drive/conftest.py new file mode 100644 index 00000000000..479412ddbe2 --- /dev/null +++ b/tests/components/google_drive/conftest.py @@ -0,0 +1,80 @@ +"""PyTest fixtures and test helpers.""" + +from collections.abc import Generator +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.google_drive.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +HA_UUID = "0a123c" +TEST_AGENT_ID = "google_drive.testuser_domain_com" +TEST_USER_EMAIL = "testuser@domain.com" +CONFIG_ENTRY_TITLE = "Google Drive entry title" + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture +def mock_api() -> Generator[MagicMock]: + """Return a mocked GoogleDriveApi.""" + with patch( + "homeassistant.components.google_drive.api.GoogleDriveApi" + ) as mock_api_cl: + mock_api = mock_api_cl.return_value + yield mock_api + + +@pytest.fixture(autouse=True) +def mock_instance_id() -> Generator[AsyncMock]: + """Mock instance_id.""" + with patch( + "homeassistant.components.google_drive.config_flow.instance_id.async_get", + return_value=HA_UUID, + ): + yield + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture(name="config_entry") +def mock_config_entry(expires_at: int) -> MockConfigEntry: + """Fixture for MockConfigEntry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USER_EMAIL, + title=CONFIG_ENTRY_TITLE, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": "https://www.googleapis.com/auth/drive.file", + }, + }, + ) diff --git a/tests/components/google_drive/snapshots/test_backup.ambr b/tests/components/google_drive/snapshots/test_backup.ambr new file mode 100644 index 00000000000..0832682b74d --- /dev/null +++ b/tests/components/google_drive/snapshots/test_backup.ambr @@ -0,0 +1,237 @@ +# serializer version: 1 +# name: test_agents_delete + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and properties has { key='backup_id' and value='test-backup' }", + }), + }), + ), + tuple( + 'delete_file', + tuple( + 'backup-file-id', + ), + dict({ + }), + ), + ]) +# --- +# name: test_agents_download + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(description)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and properties has { key='backup_id' and value='test-backup' }", + }), + }), + ), + tuple( + 'get_file_content', + tuple( + 'backup-file-id', + ), + dict({ + 'timeout': dict({ + 'ceil_threshold': 5, + 'connect': None, + 'sock_connect': None, + 'sock_read': None, + 'total': 43200, + }), + }), + ), + ]) +# --- +# name: test_agents_list_backups + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(description)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + ]) +# --- +# name: test_agents_upload + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'upload_file', + tuple( + dict({ + 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', + 'name': 'Test 2025-01-01T01:23:45.678Z.tar', + 'parents': list([ + 'HA folder ID', + ]), + 'properties': dict({ + 'backup_id': 'test-backup', + 'home_assistant': 'backup', + 'instance_id': '0a123c', + }), + }), + "CoreBackupReaderWriter.async_receive_backup..open_backup() -> 'AsyncIterator[bytes]'", + ), + dict({ + 'timeout': dict({ + 'ceil_threshold': 5, + 'connect': None, + 'sock_connect': None, + 'sock_read': None, + 'total': 43200, + }), + }), + ), + ]) +# --- +# name: test_agents_upload_create_folder_if_missing + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'create_file', + tuple( + ), + dict({ + 'json': dict({ + 'mimeType': 'application/vnd.google-apps.folder', + 'name': 'Home Assistant', + 'properties': dict({ + 'home_assistant': 'root', + 'instance_id': '0a123c', + }), + }), + 'params': dict({ + 'fields': 'id,name', + }), + }), + ), + tuple( + 'upload_file', + tuple( + dict({ + 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', + 'name': 'Test 2025-01-01T01:23:45.678Z.tar', + 'parents': list([ + 'new folder id', + ]), + 'properties': dict({ + 'backup_id': 'test-backup', + 'home_assistant': 'backup', + 'instance_id': '0a123c', + }), + }), + "CoreBackupReaderWriter.async_receive_backup..open_backup() -> 'AsyncIterator[bytes]'", + ), + dict({ + 'timeout': dict({ + 'ceil_threshold': 5, + 'connect': None, + 'sock_connect': None, + 'sock_read': None, + 'total': 43200, + }), + }), + ), + ]) +# --- diff --git a/tests/components/google_drive/snapshots/test_config_flow.ambr b/tests/components/google_drive/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000..68e5416c5ec --- /dev/null +++ b/tests/components/google_drive/snapshots/test_config_flow.ambr @@ -0,0 +1,44 @@ +# serializer version: 1 +# name: test_full_flow + list([ + tuple( + 'get_user', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'user(emailAddress)', + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'create_file', + tuple( + ), + dict({ + 'json': dict({ + 'mimeType': 'application/vnd.google-apps.folder', + 'name': 'Home Assistant', + 'properties': dict({ + 'home_assistant': 'root', + 'instance_id': '0a123c', + }), + }), + 'params': dict({ + 'fields': 'id,name', + }), + }), + ), + ]) +# --- diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py new file mode 100644 index 00000000000..765f6bba887 --- /dev/null +++ b/tests/components/google_drive/test_backup.py @@ -0,0 +1,461 @@ +"""Test the Google Drive backup platform.""" + +from io import StringIO +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from aiohttp import ClientResponse +from google_drive_api.exceptions import GoogleDriveApiError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.backup import ( + DOMAIN as BACKUP_DOMAIN, + AddonInfo, + AgentBackup, +) +from homeassistant.components.google_drive import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .conftest import CONFIG_ENTRY_TITLE, TEST_AGENT_ID + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import mock_stream +from tests.typing import ClientSessionGenerator, WebSocketGenerator + +FOLDER_ID = "google-folder-id" +TEST_AGENT_BACKUP = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="test-backup", + database_included=True, + date="2025-01-01T01:23:45.678Z", + extra_metadata={ + "with_automatic_settings": False, + }, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=987, +) +TEST_AGENT_BACKUP_RESULT = { + "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], + "backup_id": "test-backup", + "database_included": True, + "date": "2025-01-01T01:23:45.678Z", + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 987, + "agent_ids": [TEST_AGENT_ID], + "failed_agent_ids": [], + "with_automatic_settings": None, +} + + +@pytest.fixture(autouse=True) +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_api: MagicMock, +) -> None: + """Set up Google Drive integration.""" + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + mock_api.list_files = AsyncMock( + return_value={"files": [{"id": "HA folder ID", "name": "HA folder name"}]} + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": TEST_AGENT_ID, "name": CONFIG_ENTRY_TITLE}, + ], + } + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [{"agent_id": "backup.local", "name": "local"}] + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent list backups.""" + mock_api.list_files = AsyncMock( + return_value={ + "files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}] + } + ) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [TEST_AGENT_BACKUP_RESULT] + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_list_backups_fail( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, +) -> None: + """Test agent list backups fails.""" + mock_api.list_files = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backups"] == [] + assert response["result"]["agent_errors"] == { + TEST_AGENT_ID: "Failed to list backups" + } + + +@pytest.mark.parametrize( + ("backup_id", "expected_result"), + [ + (TEST_AGENT_BACKUP.backup_id, TEST_AGENT_BACKUP_RESULT), + ("12345", None), + ], + ids=["found", "not_found"], +) +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, + backup_id: str, + expected_result: dict[str, Any] | None, +) -> None: + """Test agent get backup.""" + mock_api.list_files = AsyncMock( + return_value={ + "files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}] + } + ) + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == expected_result + + +async def test_agents_download( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent download backup.""" + mock_api.list_files = AsyncMock( + side_effect=[ + {"files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}]}, + {"files": [{"id": "backup-file-id"}]}, + ] + ) + mock_response = AsyncMock(spec=ClientResponse) + mock_response.content = mock_stream(b"backup data") + mock_api.get_file_content = AsyncMock(return_value=mock_response) + + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_download_fail( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_api: MagicMock, +) -> None: + """Test agent download backup fails.""" + mock_api.list_files = AsyncMock( + side_effect=[ + {"files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}]}, + {"files": [{"id": "backup-file-id"}]}, + ] + ) + mock_response = AsyncMock(spec=ClientResponse) + mock_response.content = mock_stream(b"backup data") + mock_api.get_file_content = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" + ) + assert resp.status == 500 + content = await resp.content.read() + assert "Failed to download backup" in content.decode() + + +async def test_agents_download_file_not_found( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_api: MagicMock, +) -> None: + """Test agent download backup raises error if not found.""" + mock_api.list_files = AsyncMock( + side_effect=[ + {"files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}]}, + {"files": []}, + ] + ) + + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" + ) + assert resp.status == 500 + content = await resp.content.read() + assert "Backup not found" in content.decode() + + +async def test_agents_download_metadata_not_found( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_api: MagicMock, +) -> None: + """Test agent download backup raises error if not found.""" + mock_api.list_files = AsyncMock( + return_value={ + "files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}] + } + ) + + client = await hass_client() + backup_id = "1234" + assert backup_id != TEST_AGENT_BACKUP.backup_id + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={TEST_AGENT_ID}" + ) + assert resp.status == 404 + assert await resp.content.read() == b"" + + +async def test_agents_upload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent upload backup.""" + mock_api.upload_file = AsyncMock(return_value=None) + + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_AGENT_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = TEST_AGENT_BACKUP + resp = await client.post( + f"/api/backup/upload?agent_id={TEST_AGENT_ID}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text + assert f"Uploaded backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text + + mock_api.upload_file.assert_called_once() + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_upload_create_folder_if_missing( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent upload backup creates folder if missing.""" + mock_api.list_files = AsyncMock(return_value={"files": []}) + mock_api.create_file = AsyncMock( + return_value={"id": "new folder id", "name": "Home Assistant"} + ) + mock_api.upload_file = AsyncMock(return_value=None) + + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_AGENT_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = TEST_AGENT_BACKUP + resp = await client.post( + f"/api/backup/upload?agent_id={TEST_AGENT_ID}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text + assert f"Uploaded backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text + + mock_api.create_file.assert_called_once() + mock_api.upload_file.assert_called_once() + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_upload_fail( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_api: MagicMock, +) -> None: + """Test agent upload backup fails.""" + mock_api.upload_file = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_AGENT_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = TEST_AGENT_BACKUP + resp = await client.post( + f"/api/backup/upload?agent_id={TEST_AGENT_ID}", + data={"file": StringIO("test")}, + ) + await hass.async_block_till_done() + + assert resp.status == 201 + assert "Upload backup error: some error" in caplog.text + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent delete backup.""" + mock_api.list_files = AsyncMock(return_value={"files": [{"id": "backup-file-id"}]}) + mock_api.delete_file = AsyncMock(return_value=None) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_AGENT_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + mock_api.delete_file.assert_called_once() + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_delete_fail( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, +) -> None: + """Test agent delete backup fails.""" + mock_api.list_files = AsyncMock(return_value={"files": [{"id": "backup-file-id"}]}) + mock_api.delete_file = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_AGENT_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {TEST_AGENT_ID: "Failed to delete backup"} + } + + +async def test_agents_delete_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, +) -> None: + """Test agent delete backup not found.""" + mock_api.list_files = AsyncMock(return_value={"files": []}) + + client = await hass_ws_client(hass) + backup_id = "1234" + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + mock_api.delete_file.assert_not_called() diff --git a/tests/components/google_drive/test_config_flow.py b/tests/components/google_drive/test_config_flow.py new file mode 100644 index 00000000000..10f73d53a66 --- /dev/null +++ b/tests/components/google_drive/test_config_flow.py @@ -0,0 +1,363 @@ +"""Test the Google Drive config flow.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from google_drive_api.exceptions import GoogleDriveApiError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.google_drive.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from .conftest import CLIENT_ID, TEST_USER_EMAIL + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token" +FOLDER_ID = "google-folder-id" +FOLDER_NAME = "folder name" +TITLE = "Google Drive" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock( + return_value={"user": {"emailAddress": TEST_USER_EMAIL}} + ) + mock_api.list_files = AsyncMock(return_value={"files": []}) + mock_api.create_file = AsyncMock( + return_value={"id": FOLDER_ID, "name": FOLDER_NAME} + ) + + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_drive.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + assert len(aioclient_mock.mock_calls) == 1 + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == TITLE + assert result.get("description_placeholders") == { + "folder_name": FOLDER_NAME, + "url": f"https://drive.google.com/drive/folders/{FOLDER_ID}", + } + assert "result" in result + assert result.get("result").unique_id == TEST_USER_EMAIL + assert "token" in result.get("result").data + assert result.get("result").data["token"].get("access_token") == "mock-access-token" + assert ( + result.get("result").data["token"].get("refresh_token") == "mock-refresh-token" + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_create_folder_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, +) -> None: + """Test case where creating the folder fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock( + return_value={"user": {"emailAddress": TEST_USER_EMAIL}} + ) + mock_api.list_files = AsyncMock(return_value={"files": []}) + mock_api.create_file = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "create_folder_failure" + assert result.get("description_placeholders") == {"message": "some error"} + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ("exception", "expected_abort_reason", "expected_placeholders"), + [ + ( + GoogleDriveApiError("some error"), + "access_not_configured", + {"message": "some error"}, + ), + (Exception, "unknown", None), + ], + ids=["api_not_enabled", "general_exception"], +) +async def test_get_email_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, + exception: Exception, + expected_abort_reason, + expected_placeholders, +) -> None: + """Test case where getting the email address fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock(side_effect=exception) + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == expected_abort_reason + assert result.get("description_placeholders") == expected_placeholders + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ( + "new_email", + "expected_abort_reason", + "expected_placeholders", + "expected_access_token", + "expected_setup_calls", + ), + [ + (TEST_USER_EMAIL, "reauth_successful", None, "updated-access-token", 1), + ( + "other.user@domain.com", + "wrong_account", + {"email": TEST_USER_EMAIL}, + "mock-access-token", + 0, + ), + ], + ids=["reauth_successful", "wrong_account"], +) +async def test_reauth( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, + new_email: str, + expected_abort_reason: str, + expected_placeholders: dict[str, str] | None, + expected_access_token: str, + expected_setup_calls: int, +) -> None: + """Test the reauthentication flow.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock(return_value={"user": {"emailAddress": new_email}}) + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "updated-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_drive.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == expected_setup_calls + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == expected_abort_reason + assert result.get("description_placeholders") == expected_placeholders + + assert config_entry.unique_id == TEST_USER_EMAIL + assert "token" in config_entry.data + + # Verify access token is refreshed + assert config_entry.data["token"].get("access_token") == expected_access_token + assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_already_configured( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, +) -> None: + """Test already configured account.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock( + return_value={"user": {"emailAddress": TEST_USER_EMAIL}} + ) + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" diff --git a/tests/components/google_drive/test_init.py b/tests/components/google_drive/test_init.py new file mode 100644 index 00000000000..8173e00fb54 --- /dev/null +++ b/tests/components/google_drive/test_init.py @@ -0,0 +1,164 @@ +"""Tests for Google Drive.""" + +from collections.abc import Awaitable, Callable, Coroutine +import http +import time +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from google_drive_api.exceptions import GoogleDriveApiError +import pytest + +from homeassistant.components.google_drive.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + +type ComponentSetup = Callable[[], Awaitable[None]] + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> Callable[[], Coroutine[Any, Any, None]]: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + async def func() -> None: + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return func + + +async def test_setup_success( + hass: HomeAssistant, + setup_integration: ComponentSetup, + mock_api: MagicMock, +) -> None: + """Test successful setup and unload.""" + # Setup looks up existing folder to make sure it still exists + mock_api.list_files = AsyncMock( + return_value={"files": [{"id": "HA folder ID", "name": "HA folder name"}]} + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done() + + assert entries[0].state is ConfigEntryState.NOT_LOADED + + +async def test_create_folder_if_missing( + hass: HomeAssistant, + setup_integration: ComponentSetup, + mock_api: MagicMock, +) -> None: + """Test folder is created if missing.""" + # Setup looks up existing folder to make sure it still exists + # and creates it if missing + mock_api.list_files = AsyncMock(return_value={"files": []}) + mock_api.create_file = AsyncMock( + return_value={"id": "new folder id", "name": "Home Assistant"} + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + mock_api.list_files.assert_called_once() + mock_api.create_file.assert_called_once() + + +async def test_setup_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, + mock_api: MagicMock, +) -> None: + """Test setup error.""" + # Simulate failure looking up existing folder + mock_api.list_files = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_success( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, +) -> None: + """Test expired token is refreshed.""" + # Setup looks up existing folder to make sure it still exists + mock_api.list_files = AsyncMock( + return_value={"files": [{"id": "HA folder ID", "name": "HA folder name"}]} + ) + aioclient_mock.post( + "https://oauth2.googleapis.com/token", + json={ + "access_token": "updated-access-token", + "refresh_token": "updated-refresh-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + }, + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert entries[0].data["token"]["access_token"] == "updated-access-token" + assert entries[0].data["token"]["expires_in"] == 3600 + + +@pytest.mark.parametrize( + ("expires_at", "status", "expected_state"), + [ + ( + time.time() - 3600, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["failure_requires_reauth", "transient_failure"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.post( + "https://oauth2.googleapis.com/token", + status=status, + ) + + await setup_integration() + + # Verify a transient failure has occurred + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is expected_state From a135b4bb432ec47c1900771277fc5dab2acd970e Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 29 Jan 2025 00:28:13 -0600 Subject: [PATCH 1148/2987] Enable strict typing for HEOS (#136797) --- .strict-typing | 1 + homeassistant/components/heos/__init__.py | 2 +- homeassistant/components/heos/coordinator.py | 7 +++++-- homeassistant/components/heos/media_player.py | 4 ++-- homeassistant/components/heos/quality_scale.yaml | 2 +- homeassistant/components/heos/services.py | 2 +- mypy.ini | 10 ++++++++++ 7 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.strict-typing b/.strict-typing index 1a5450d8eb4..4cebcb6f445 100644 --- a/.strict-typing +++ b/.strict-typing @@ -228,6 +228,7 @@ homeassistant.components.guardian.* homeassistant.components.habitica.* homeassistant.components.hardkernel.* homeassistant.components.hardware.* +homeassistant.components.heos.* homeassistant.components.here_travel_time.* homeassistant.components.history.* homeassistant.components.history_stats.* diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index b119ea83064..f76b95c271e 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -40,7 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool ): for domain, player_id in device.identifiers: if domain == DOMAIN and not isinstance(player_id, str): - device_registry.async_update_device( + device_registry.async_update_device( # type: ignore[unreachable] device.id, new_identifiers={(DOMAIN, str(player_id))} ) break diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index dd0e0a19d0b..dc8989fd55b 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -8,6 +8,7 @@ entities to update. Entities subscribe to entity-specific updates within the ent from collections.abc import Callable, Sequence from datetime import datetime, timedelta import logging +from typing import Any from pyheos import ( Credentials, @@ -23,7 +24,7 @@ from pyheos import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_call_later @@ -106,7 +107,9 @@ class HeosCoordinator(DataUpdateCoordinator[None]): await self.heos.disconnect() await super().async_shutdown() - def async_add_listener(self, update_callback, context=None) -> Callable[[], None]: + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: """Add a listener for the coordinator.""" remove_listener = super().async_add_listener(update_callback, context) # Update entities so group_member entity_ids fully populate. diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 2f0945635c5..b53cb94d8e7 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -135,7 +135,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): def __init__(self, coordinator: HeosCoordinator, player: HeosPlayer) -> None: """Initialize.""" - self._media_position_updated_at = None + self._media_position_updated_at: datetime | None = None self._player: HeosPlayer = player self._attr_unique_id = str(player.player_id) model_parts = player.model.split(maxsplit=1) @@ -151,7 +151,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): ) super().__init__(coordinator, context=player.player_id) - async def _player_update(self, event): + async def _player_update(self, event: str) -> None: """Handle player attribute updated.""" if event == heos_const.EVENT_PLAYER_NOW_PLAYING_PROGRESS: self._media_position_updated_at = utcnow() diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index cc110c627f0..f5066d0a743 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -64,4 +64,4 @@ rules: inject-websession: status: done comment: The integration does not use websession - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index 4dc3b247707..dc11bb7a76d 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -28,7 +28,7 @@ HEOS_SIGN_IN_SCHEMA = vol.Schema( HEOS_SIGN_OUT_SCHEMA = vol.Schema({}) -def register(hass: HomeAssistant): +def register(hass: HomeAssistant) -> None: """Register HEOS services.""" hass.services.async_register( DOMAIN, diff --git a/mypy.ini b/mypy.ini index 2139449ba8d..ddc5589dc09 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2036,6 +2036,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.heos.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.here_travel_time.*] check_untyped_defs = true disallow_incomplete_defs = true From d0a188b86d192bd4ba09c5fece94517dfae98c5a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 08:57:57 +0100 Subject: [PATCH 1149/2987] Standardize homeassistant imports in component tests (m-z) (#136807) --- tests/components/manual/test_alarm_control_panel.py | 2 +- tests/components/manual_mqtt/test_alarm_control_panel.py | 2 +- tests/components/media_player/test_async_helpers.py | 2 +- tests/components/media_player/test_device_trigger.py | 2 +- tests/components/melnor/test_sensor.py | 2 +- tests/components/melnor/test_time.py | 2 +- tests/components/mfi/test_sensor.py | 4 ++-- tests/components/mfi/test_switch.py | 4 ++-- tests/components/modbus/conftest.py | 2 +- tests/components/modbus/test_init.py | 2 +- tests/components/modbus/test_switch.py | 2 +- tests/components/motioneye/test_camera.py | 2 +- tests/components/motioneye/test_web_hooks.py | 2 +- tests/components/mqtt/test_binary_sensor.py | 2 +- tests/components/mqtt/test_init.py | 2 +- tests/components/mqtt/test_sensor.py | 2 +- tests/components/mqtt_eventstream/test_init.py | 4 ++-- tests/components/mqtt_statestream/test_init.py | 2 +- tests/components/nest/test_media_source.py | 2 +- tests/components/netgear_lte/test_init.py | 2 +- .../nsw_rural_fire_service_feed/test_geo_location.py | 2 +- tests/components/nuheat/test_climate.py | 2 +- tests/components/persistent_notification/conftest.py | 2 +- tests/components/persistent_notification/test_init.py | 2 +- tests/components/persistent_notification/test_trigger.py | 2 +- tests/components/plex/helpers.py | 2 +- tests/components/plex/test_init.py | 2 +- tests/components/powerwall/test_config_flow.py | 2 +- tests/components/powerwall/test_sensor.py | 2 +- tests/components/profiler/test_init.py | 2 +- tests/components/qld_bushfire/test_geo_location.py | 2 +- tests/components/radarr/test_sensor.py | 2 +- tests/components/remember_the_milk/test_init.py | 2 +- tests/components/remote/test_device_condition.py | 2 +- tests/components/remote/test_device_trigger.py | 2 +- tests/components/rflink/test_binary_sensor.py | 2 +- tests/components/roku/test_select.py | 2 +- tests/components/samsungtv/conftest.py | 2 +- tests/components/samsungtv/test_media_player.py | 2 +- tests/components/script/test_init.py | 3 +-- tests/components/sighthound/test_image_processing.py | 2 +- tests/components/sonos/test_init.py | 2 +- tests/components/speedtestdotnet/test_init.py | 2 +- tests/components/srp_energy/conftest.py | 2 +- tests/components/ssdp/test_init.py | 2 +- tests/components/steamist/test_switch.py | 2 +- tests/components/stream/test_hls.py | 2 +- tests/components/stream/test_recorder.py | 2 +- tests/components/subaru/conftest.py | 2 +- tests/components/sun/test_init.py | 2 +- tests/components/sun/test_sensor.py | 2 +- tests/components/sun/test_trigger.py | 2 +- tests/components/switch/test_device_condition.py | 2 +- tests/components/switch/test_device_trigger.py | 2 +- tests/components/tankerkoenig/test_coordinator.py | 2 +- tests/components/tasmota/test_binary_sensor.py | 4 ++-- tests/components/tcp/test_sensor.py | 2 +- tests/components/temper/test_sensor.py | 2 +- tests/components/template/test_binary_sensor.py | 2 +- tests/components/template/test_sensor.py | 2 +- tests/components/template/test_trigger.py | 2 +- tests/components/tesla_wall_connector/conftest.py | 2 +- tests/components/time_date/test_sensor.py | 2 +- tests/components/tod/test_binary_sensor.py | 2 +- tests/components/tomato/test_device_tracker.py | 2 +- tests/components/tplink/test_light.py | 2 +- tests/components/tts/test_init.py | 2 +- tests/components/unifi/conftest.py | 2 +- tests/components/unifi/test_button.py | 2 +- tests/components/unifi/test_device_tracker.py | 2 +- tests/components/unifi/test_hub.py | 2 +- tests/components/unifi/test_sensor.py | 2 +- tests/components/unifiprotect/conftest.py | 2 +- tests/components/unifiprotect/utils.py | 2 +- tests/components/universal/test_media_player.py | 2 +- tests/components/update/test_device_trigger.py | 2 +- tests/components/upnp/test_binary_sensor.py | 2 +- tests/components/upnp/test_sensor.py | 2 +- .../components/usgs_earthquakes_feed/test_geo_location.py | 2 +- tests/components/utility_meter/test_init.py | 8 +++++--- tests/components/utility_meter/test_sensor.py | 2 +- tests/components/vacuum/test_device_trigger.py | 2 +- tests/components/vizio/test_init.py | 2 +- tests/components/vultr/test_sensor.py | 3 +-- tests/components/whois/test_sensor.py | 2 +- tests/components/wled/test_coordinator.py | 2 +- tests/components/worldclock/test_sensor.py | 2 +- tests/components/wsdot/test_sensor.py | 2 +- tests/components/xiaomi/test_device_tracker.py | 2 +- tests/components/yale/test_binary_sensor.py | 2 +- tests/components/yale/test_event.py | 2 +- tests/components/yale/test_lock.py | 2 +- tests/components/yandex_transport/test_sensor.py | 2 +- tests/components/zerproc/test_light.py | 2 +- tests/components/zha/common.py | 2 +- tests/components/zha/conftest.py | 2 +- tests/components/zha/test_device_tracker.py | 2 +- tests/components/zha/test_helpers.py | 4 ++-- tests/components/zha/test_siren.py | 2 +- tests/components/zodiac/test_sensor.py | 2 +- 100 files changed, 109 insertions(+), 109 deletions(-) diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index 9fc92cd5458..941d7523220 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -28,7 +28,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_component, mock_restore_cache from tests.components.alarm_control_panel import common diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py index 2b401cb10a0..9bb506b935a 100644 --- a/tests/components/manual_mqtt/test_alarm_control_panel.py +++ b/tests/components/manual_mqtt/test_alarm_control_panel.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( assert_setup_component, diff --git a/tests/components/media_player/test_async_helpers.py b/tests/components/media_player/test_async_helpers.py index 750d2861f21..680603c097d 100644 --- a/tests/components/media_player/test_async_helpers.py +++ b/tests/components/media_player/test_async_helpers.py @@ -2,7 +2,7 @@ import pytest -import homeassistant.components.media_player as mp +from homeassistant.components import media_player as mp from homeassistant.const import ( STATE_IDLE, STATE_OFF, diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py index 4bb27b73f24..ae3a84e66a0 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/melnor/test_sensor.py b/tests/components/melnor/test_sensor.py index a2ba23d9e61..23902a4b780 100644 --- a/tests/components/melnor/test_sensor.py +++ b/tests/components/melnor/test_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( mock_config_entry, diff --git a/tests/components/melnor/test_time.py b/tests/components/melnor/test_time.py index 50b51d31ff8..f8a3adcf3d0 100644 --- a/tests/components/melnor/test_time.py +++ b/tests/components/melnor/test_time.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import time, timedelta from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( mock_config_entry, diff --git a/tests/components/mfi/test_sensor.py b/tests/components/mfi/test_sensor.py index 37512ca78f8..8c21fa9cb36 100644 --- a/tests/components/mfi/test_sensor.py +++ b/tests/components/mfi/test_sensor.py @@ -7,8 +7,8 @@ from mficlient.client import FailedToLogin import pytest import requests -import homeassistant.components.mfi.sensor as mfi -import homeassistant.components.sensor as sensor_component +from homeassistant.components import sensor as sensor_component +from homeassistant.components.mfi import sensor as mfi from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant diff --git a/tests/components/mfi/test_switch.py b/tests/components/mfi/test_switch.py index 03b5d5f2c0a..fb586073a3d 100644 --- a/tests/components/mfi/test_switch.py +++ b/tests/components/mfi/test_switch.py @@ -4,8 +4,8 @@ from unittest import mock import pytest -import homeassistant.components.mfi.switch as mfi -import homeassistant.components.switch as switch_component +from homeassistant.components import switch as switch_component +from homeassistant.components.mfi import switch as mfi from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index cdea046ceea..0a2cbf44b9e 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_restore_cache diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index e105818d193..7b76dbc3528 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -107,7 +107,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( TEST_ENTITY_NAME, diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 4e0ad0841ea..4b2c123ba75 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -41,7 +41,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, State from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import TEST_ENTITY_NAME, ReadResult diff --git a/tests/components/motioneye/test_camera.py b/tests/components/motioneye/test_camera.py index 8ef58cc968d..d9a9a847b63 100644 --- a/tests/components/motioneye/test_camera.py +++ b/tests/components/motioneye/test_camera.py @@ -45,8 +45,8 @@ from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import dt as dt_util from homeassistant.util.aiohttp import MockRequest -import homeassistant.util.dt as dt_util from . import ( TEST_CAMERA, diff --git a/tests/components/motioneye/test_web_hooks.py b/tests/components/motioneye/test_web_hooks.py index fae7fccbb6d..bc345c0b66f 100644 --- a/tests/components/motioneye/test_web_hooks.py +++ b/tests/components/motioneye/test_web_hooks.py @@ -31,7 +31,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.network import NoURLAvailableError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( TEST_CAMERA, diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index d27163c3423..34be237fb72 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .test_common import ( help_custom_config, diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 4e0873c6e1b..d05c340dac2 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -13,6 +13,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol +from homeassistant import core as ha from homeassistant.components import mqtt from homeassistant.components.mqtt import debug_info from homeassistant.components.mqtt.models import ( @@ -30,7 +31,6 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er, template diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 7f418864872..6b3bbd6334c 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .test_common import ( help_custom_config, diff --git a/tests/components/mqtt_eventstream/test_init.py b/tests/components/mqtt_eventstream/test_init.py index b6c1940b149..cbf02299b09 100644 --- a/tests/components/mqtt_eventstream/test_init.py +++ b/tests/components/mqtt_eventstream/test_init.py @@ -5,12 +5,12 @@ from unittest.mock import ANY, patch import pytest -import homeassistant.components.mqtt_eventstream as eventstream +from homeassistant.components import mqtt_eventstream as eventstream from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( async_fire_mqtt_message, diff --git a/tests/components/mqtt_statestream/test_init.py b/tests/components/mqtt_statestream/test_init.py index 9798477945c..63c3ea14e44 100644 --- a/tests/components/mqtt_statestream/test_init.py +++ b/tests/components/mqtt_statestream/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import ANY, call import pytest -import homeassistant.components.mqtt_statestream as statestream +from homeassistant.components import mqtt_statestream as statestream from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.setup import async_setup_component diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 051f7bb87e4..d009e1185da 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -28,7 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( DEVICE_ID, diff --git a/tests/components/netgear_lte/test_init.py b/tests/components/netgear_lte/test_init.py index 1bd3dff1eff..e853109e33e 100644 --- a/tests/components/netgear_lte/test_init.py +++ b/tests/components/netgear_lte/test_init.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import CONF_DATA diff --git a/tests/components/nsw_rural_fire_service_feed/test_geo_location.py b/tests/components/nsw_rural_fire_service_feed/test_geo_location.py index ad987325b97..96d5e815ff0 100644 --- a/tests/components/nsw_rural_fire_service_feed/test_geo_location.py +++ b/tests/components/nsw_rural_fire_service_feed/test_geo_location.py @@ -37,7 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/nuheat/test_climate.py b/tests/components/nuheat/test_climate.py index bc00df126e5..5e3ba384b2d 100644 --- a/tests/components/nuheat/test_climate.py +++ b/tests/components/nuheat/test_climate.py @@ -6,7 +6,7 @@ from unittest.mock import patch from homeassistant.components.nuheat.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( MOCK_CONFIG_ENTRY, diff --git a/tests/components/persistent_notification/conftest.py b/tests/components/persistent_notification/conftest.py index 29ba5a6008a..76fdc70ea7b 100644 --- a/tests/components/persistent_notification/conftest.py +++ b/tests/components/persistent_notification/conftest.py @@ -2,7 +2,7 @@ import pytest -import homeassistant.components.persistent_notification as pn +from homeassistant.components import persistent_notification as pn from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/persistent_notification/test_init.py b/tests/components/persistent_notification/test_init.py index 956183d8420..89559d45dc4 100644 --- a/tests/components/persistent_notification/test_init.py +++ b/tests/components/persistent_notification/test_init.py @@ -1,6 +1,6 @@ """The tests for the persistent notification component.""" -import homeassistant.components.persistent_notification as pn +from homeassistant.components import persistent_notification as pn from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/persistent_notification/test_trigger.py b/tests/components/persistent_notification/test_trigger.py index 16208143447..5e03fbf5f19 100644 --- a/tests/components/persistent_notification/test_trigger.py +++ b/tests/components/persistent_notification/test_trigger.py @@ -2,7 +2,7 @@ from typing import Any -import homeassistant.components.persistent_notification as pn +from homeassistant.components import persistent_notification as pn from homeassistant.components.persistent_notification import trigger from homeassistant.core import Context, HomeAssistant, callback diff --git a/tests/components/plex/helpers.py b/tests/components/plex/helpers.py index 434c31996e4..4dc80d3e7aa 100644 --- a/tests/components/plex/helpers.py +++ b/tests/components/plex/helpers.py @@ -7,7 +7,7 @@ from plexwebsocket import SIGNAL_CONNECTION_STATE, STATE_CONNECTED from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import UNDEFINED, UndefinedType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index 490091998ff..036b2d87f3f 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -25,7 +25,7 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DEFAULT_DATA, DEFAULT_OPTIONS, PLEX_DIRECT_URL from .helpers import trigger_plex_update, wait_for_debouncer diff --git a/tests/components/powerwall/test_config_flow.py b/tests/components/powerwall/test_config_flow.py index cd4f1250aa4..ab5034de637 100644 --- a/tests/components/powerwall/test_config_flow.py +++ b/tests/components/powerwall/test_config_flow.py @@ -17,7 +17,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( MOCK_GATEWAY_DIN, diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index fa2d986d12a..9b533304fbc 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import MOCK_GATEWAY_DIN, _mock_powerwall_with_fixtures diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 540e644aca4..e724a9e5cab 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -34,7 +34,7 @@ from homeassistant.components.profiler.const import DOMAIN from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/qld_bushfire/test_geo_location.py b/tests/components/qld_bushfire/test_geo_location.py index 20659182726..aefee4113cc 100644 --- a/tests/components/qld_bushfire/test_geo_location.py +++ b/tests/components/qld_bushfire/test_geo_location.py @@ -31,7 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index 563ac504057..9139e13a957 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import setup_integration diff --git a/tests/components/remember_the_milk/test_init.py b/tests/components/remember_the_milk/test_init.py index c68fe14430a..3ada2d343fe 100644 --- a/tests/components/remember_the_milk/test_init.py +++ b/tests/components/remember_the_milk/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import Mock, mock_open, patch -import homeassistant.components.remember_the_milk as rtm +from homeassistant.components import remember_the_milk as rtm from homeassistant.core import HomeAssistant from .const import JSON_STRING, PROFILE, TOKEN diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py index 6c9334aeac4..b4dd513c317 100644 --- a/tests/components/remote/test_device_condition.py +++ b/tests/components/remote/test_device_condition.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/remote/test_device_trigger.py b/tests/components/remote/test_device_trigger.py index c647faba2c1..800d090fd7b 100644 --- a/tests/components/remote/test_device_trigger.py +++ b/tests/components/remote/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/rflink/test_binary_sensor.py b/tests/components/rflink/test_binary_sensor.py index 9329edb3a00..fd113bceaa0 100644 --- a/tests/components/rflink/test_binary_sensor.py +++ b/tests/components/rflink/test_binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import CoreState, HomeAssistant, State, callback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .test_init import mock_rflink diff --git a/tests/components/roku/test_select.py b/tests/components/roku/test_select.py index 78cd65250f8..a79a23782ce 100644 --- a/tests/components/roku/test_select.py +++ b/tests/components/roku/test_select.py @@ -22,7 +22,7 @@ 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 -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index ec12031ef96..105ef0f25ad 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -21,7 +21,7 @@ from samsungtvws.exceptions import ResponseError from samsungtvws.remote import ChannelEmitCommand from homeassistant.components.samsungtv.const import WEBSOCKET_SSL_PORT -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import SAMPLE_DEVICE_INFO_UE48JU6400, SAMPLE_DEVICE_INFO_WIFI diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 1a7c8713b17..3d9633bbf96 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -78,7 +78,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import async_wait_config_entry_reload, setup_samsungtv_entry from .const import ( diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 248ada605cc..3b0bff7e82e 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -42,8 +42,7 @@ from homeassistant.helpers.script import ( ) from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.setup import async_setup_component -from homeassistant.util import yaml as yaml_util -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, yaml as yaml_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/sighthound/test_image_processing.py b/tests/components/sighthound/test_image_processing.py index 5db6347a832..ba03f6fc804 100644 --- a/tests/components/sighthound/test_image_processing.py +++ b/tests/components/sighthound/test_image_processing.py @@ -11,7 +11,7 @@ import pytest import simplehound.core as hound from homeassistant.components.image_processing import DOMAIN as IP_DOMAIN, SERVICE_SCAN -import homeassistant.components.sighthound.image_processing as sh +from homeassistant.components.sighthound import image_processing as sh from homeassistant.const import ( ATTR_ENTITY_ID, CONF_API_KEY, diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 3fc8da6a952..a7ad2f4cb82 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -21,7 +21,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import MockSoCo, SoCoMockFactory diff --git a/tests/components/speedtestdotnet/test_init.py b/tests/components/speedtestdotnet/test_init.py index 2e20aaa259c..1dd30c425b3 100644 --- a/tests/components/speedtestdotnet/test_init.py +++ b/tests/components/speedtestdotnet/test_init.py @@ -16,7 +16,7 @@ from homeassistant.components.speedtestdotnet.coordinator import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/srp_energy/conftest.py b/tests/components/srp_energy/conftest.py index b612bc9f3f3..b1d5b958d47 100644 --- a/tests/components/srp_energy/conftest.py +++ b/tests/components/srp_energy/conftest.py @@ -12,7 +12,7 @@ import pytest from homeassistant.components.srp_energy.const import DOMAIN, PHOENIX_TIME_ZONE from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import MOCK_USAGE, TEST_CONFIG_HOME diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 839509e756b..56623b51bb5 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -39,7 +39,7 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/steamist/test_switch.py b/tests/components/steamist/test_switch.py index a20bebc4052..cd62c18590a 100644 --- a/tests/components/steamist/test_switch.py +++ b/tests/components/steamist/test_switch.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( MOCK_ASYNC_GET_STATUS_ACTIVE, diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index cd48fd94c24..c96b7d9427f 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -19,7 +19,7 @@ from homeassistant.components.stream.const import ( from homeassistant.components.stream.core import Orientation, Part from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( FAKE_TIME, diff --git a/tests/components/stream/test_recorder.py b/tests/components/stream/test_recorder.py index 8e079cded45..7c856180f77 100644 --- a/tests/components/stream/test_recorder.py +++ b/tests/components/stream/test_recorder.py @@ -21,7 +21,7 @@ from homeassistant.components.stream.fmp4utils import find_box from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( DefaultSegment as Segment, diff --git a/tests/components/subaru/conftest.py b/tests/components/subaru/conftest.py index e18ea8fd398..84d2fde97f7 100644 --- a/tests/components/subaru/conftest.py +++ b/tests/components/subaru/conftest.py @@ -33,7 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .api_responses import TEST_VIN_2_EV, VEHICLE_DATA, VEHICLE_STATUS_EV diff --git a/tests/components/sun/test_init.py b/tests/components/sun/test_init.py index a30076d6d3c..3896498bbb0 100644 --- a/tests/components/sun/test_init.py +++ b/tests/components/sun/test_init.py @@ -13,7 +13,7 @@ from homeassistant.components.sun import entity from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/sun/test_sensor.py b/tests/components/sun/test_sensor.py index 495a97b88fe..59e4e4c700b 100644 --- a/tests/components/sun/test_sensor.py +++ b/tests/components/sun/test_sensor.py @@ -12,7 +12,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util @pytest.mark.usefixtures("entity_registry_enabled_by_default") diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index 303ca3b80cd..a7aeae25ac7 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_component diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py index 7c4f434b0a4..5c5737804e1 100644 --- a/tests/components/switch/test_device_condition.py +++ b/tests/components/switch/test_device_condition.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py index 08e6ab6d0f6..81f8a93611d 100644 --- a/tests/components/switch/test_device_trigger.py +++ b/tests/components/switch/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/tankerkoenig/test_coordinator.py b/tests/components/tankerkoenig/test_coordinator.py index 3ba0dc31c5f..ff2ceca7442 100644 --- a/tests/components/tankerkoenig/test_coordinator.py +++ b/tests/components/tankerkoenig/test_coordinator.py @@ -25,7 +25,7 @@ from homeassistant.const import ATTR_ID, CONF_SHOW_ON_MAP, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONFIG_DATA diff --git a/tests/components/tasmota/test_binary_sensor.py b/tests/components/tasmota/test_binary_sensor.py index 5abb9ab9bf2..ff951e058cb 100644 --- a/tests/components/tasmota/test_binary_sensor.py +++ b/tests/components/tasmota/test_binary_sensor.py @@ -13,6 +13,7 @@ from hatasmota.utils import ( ) import pytest +from homeassistant import core as ha from homeassistant.components.tasmota.const import DEFAULT_PREFIX from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -22,9 +23,8 @@ from homeassistant.const import ( STATE_UNKNOWN, Platform, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .test_common import ( DEFAULT_CONFIG, diff --git a/tests/components/tcp/test_sensor.py b/tests/components/tcp/test_sensor.py index 27003df46cd..ade4b9f93d4 100644 --- a/tests/components/tcp/test_sensor.py +++ b/tests/components/tcp/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import call, patch import pytest -import homeassistant.components.tcp.common as tcp +from homeassistant.components.tcp import common as tcp from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/temper/test_sensor.py b/tests/components/temper/test_sensor.py index d1e74f1ab0f..445adc0b5bd 100644 --- a/tests/components/temper/test_sensor.py +++ b/tests/components/temper/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import Mock, patch from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 3e3a629b4be..a7ee953bb09 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 929a890ab38..3bf91549114 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -28,7 +28,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import ATTR_COMPONENT, async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/template/test_trigger.py b/tests/components/template/test_trigger.py index a131f5f606b..49b89b61d34 100644 --- a/tests/components/template/test_trigger.py +++ b/tests/components/template/test_trigger.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, ServiceCall, callback from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_component diff --git a/tests/components/tesla_wall_connector/conftest.py b/tests/components/tesla_wall_connector/conftest.py index 9533b7e691e..e4499d6e308 100644 --- a/tests/components/tesla_wall_connector/conftest.py +++ b/tests/components/tesla_wall_connector/conftest.py @@ -14,7 +14,7 @@ from homeassistant.components.tesla_wall_connector.const import ( ) from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index ddeec48b3d2..3daa0314cbd 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components.time_date.const import OPTION_TYPES from homeassistant.core import HomeAssistant from homeassistant.helpers import event -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import load_int diff --git a/tests/components/tod/test_binary_sensor.py b/tests/components/tod/test_binary_sensor.py index 47e64353004..8b9a81d7542 100644 --- a/tests/components/tod/test_binary_sensor.py +++ b/tests/components/tod/test_binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.sun import get_astral_event_date, get_astral_event_next from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/tomato/test_device_tracker.py b/tests/components/tomato/test_device_tracker.py index f50d999548f..e4f08f55dba 100644 --- a/tests/components/tomato/test_device_tracker.py +++ b/tests/components/tomato/test_device_tracker.py @@ -8,7 +8,7 @@ import requests_mock import voluptuous as vol from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN -import homeassistant.components.tomato.device_tracker as tomato +from homeassistant.components.tomato import device_tracker as tomato from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 565d4f1221a..7ae21fb4a26 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -61,7 +61,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( _mocked_device, diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 2159d92ae4b..d115546c9bc 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -23,7 +23,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( DEFAULT_LANG, diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index 702f8629219..ec7a0595731 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -26,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index 6a493e32b02..94343d12ba2 100644 --- a/tests/components/unifi/test_button.py +++ b/tests/components/unifi/test_button.py @@ -21,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( ConfigEntryFactoryType, diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index b37e4f47137..39b70344db7 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -26,7 +26,7 @@ from homeassistant.components.unifi.const import ( from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( ConfigEntryFactoryType, diff --git a/tests/components/unifi/test_hub.py b/tests/components/unifi/test_hub.py index af134c7449b..5492f6fe0df 100644 --- a/tests/components/unifi/test_hub.py +++ b/tests/components/unifi/test_hub.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ConfigEntryFactoryType, WebsocketStateManager diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 5e47d263079..ee8b102edaa 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -37,7 +37,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( ConfigEntryFactoryType, diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 352c33297ba..c49ade514bc 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -33,7 +33,7 @@ from uiprotect.websocket import WebsocketState from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import _patch_discovery from .utils import MockUFPFixture diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 5a1ffa8258e..7dd0362f17c 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityDescription from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 5be9cb3fe02..351e11db512 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import ( MediaClass, MediaPlayerEntityFeature, ) -import homeassistant.components.universal.media_player as universal +from homeassistant.components.universal import media_player as universal from homeassistant.const import ( SERVICE_RELOAD, STATE_OFF, diff --git a/tests/components/update/test_device_trigger.py b/tests/components/update/test_device_trigger.py index 202b3d32509..55138430ca0 100644 --- a/tests/components/update/test_device_trigger.py +++ b/tests/components/update/test_device_trigger.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCatego from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockUpdateEntity diff --git a/tests/components/upnp/test_binary_sensor.py b/tests/components/upnp/test_binary_sensor.py index 087cd9e9fb4..d9b5b442b00 100644 --- a/tests/components/upnp/test_binary_sensor.py +++ b/tests/components/upnp/test_binary_sensor.py @@ -6,7 +6,7 @@ from async_upnp_client.profiles.igd import IgdDevice, IgdState from homeassistant.components.upnp.const import DEFAULT_SCAN_INTERVAL from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/upnp/test_sensor.py b/tests/components/upnp/test_sensor.py index e9d8a9cce8f..e7461c91da4 100644 --- a/tests/components/upnp/test_sensor.py +++ b/tests/components/upnp/test_sensor.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.upnp.const import DEFAULT_SCAN_INTERVAL from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/usgs_earthquakes_feed/test_geo_location.py b/tests/components/usgs_earthquakes_feed/test_geo_location.py index 40d19422ced..e412d53a0d0 100644 --- a/tests/components/usgs_earthquakes_feed/test_geo_location.py +++ b/tests/components/usgs_earthquakes_feed/test_geo_location.py @@ -35,7 +35,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index cd549c77913..eba7cf913db 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -12,9 +12,11 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) +from homeassistant.components.utility_meter import ( + select as um_select, + sensor as um_sensor, +) from homeassistant.components.utility_meter.const import DOMAIN, SERVICE_RESET -import homeassistant.components.utility_meter.select as um_select -import homeassistant.components.utility_meter.sensor as um_sensor from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, @@ -26,7 +28,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, mock_restore_cache diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 348afac57f7..c671969c5ac 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -44,7 +44,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py index 3a0cbafb4a1..381cc1caa47 100644 --- a/tests/components/vacuum/test_device_trigger.py +++ b/tests/components/vacuum/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/vizio/test_init.py b/tests/components/vizio/test_init.py index e004255ec6d..9d776ba6a59 100644 --- a/tests/components/vizio/test_init.py +++ b/tests/components/vizio/test_init.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.vizio.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import MOCK_SPEAKER_CONFIG, MOCK_USER_VALID_TV_CONFIG, UNIQUE_ID diff --git a/tests/components/vultr/test_sensor.py b/tests/components/vultr/test_sensor.py index f9f922b35d4..65be23fc168 100644 --- a/tests/components/vultr/test_sensor.py +++ b/tests/components/vultr/test_sensor.py @@ -4,8 +4,7 @@ import pytest import voluptuous as vol from homeassistant.components import vultr as base_vultr -from homeassistant.components.vultr import CONF_SUBSCRIPTION -import homeassistant.components.vultr.sensor as vultr +from homeassistant.components.vultr import CONF_SUBSCRIPTION, sensor as vultr from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, diff --git a/tests/components/whois/test_sensor.py b/tests/components/whois/test_sensor.py index d58cc342745..d290bc347a9 100644 --- a/tests/components/whois/test_sensor.py +++ b/tests/components/whois/test_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.whois.const import SCAN_INTERVAL from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/wled/test_coordinator.py b/tests/components/wled/test_coordinator.py index 14e8b620983..e2935290f03 100644 --- a/tests/components/wled/test_coordinator.py +++ b/tests/components/wled/test_coordinator.py @@ -21,7 +21,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/worldclock/test_sensor.py b/tests/components/worldclock/test_sensor.py index f901f605730..4941462cb14 100644 --- a/tests/components/worldclock/test_sensor.py +++ b/tests/components/worldclock/test_sensor.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.worldclock.const import CONF_TIME_FORMAT, DEFAULT_NAME from homeassistant.const import CONF_NAME, CONF_TIME_ZONE from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry diff --git a/tests/components/wsdot/test_sensor.py b/tests/components/wsdot/test_sensor.py index 9f5ec92a5b6..ff3d4960735 100644 --- a/tests/components/wsdot/test_sensor.py +++ b/tests/components/wsdot/test_sensor.py @@ -5,7 +5,7 @@ import re import requests_mock -import homeassistant.components.wsdot.sensor as wsdot +from homeassistant.components.wsdot import sensor as wsdot from homeassistant.components.wsdot.sensor import ( ATTR_DESCRIPTION, ATTR_TIME_UPDATED, diff --git a/tests/components/xiaomi/test_device_tracker.py b/tests/components/xiaomi/test_device_tracker.py index 625e6f404ad..e3cc1898ce9 100644 --- a/tests/components/xiaomi/test_device_tracker.py +++ b/tests/components/xiaomi/test_device_tracker.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, call, patch import requests from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN -import homeassistant.components.xiaomi.device_tracker as xiaomi +from homeassistant.components.xiaomi import device_tracker as xiaomi from homeassistant.components.xiaomi.device_tracker import get_scanner from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/tests/components/yale/test_binary_sensor.py b/tests/components/yale/test_binary_sensor.py index 811c845e359..16ec0ffbeb4 100644 --- a/tests/components/yale/test_binary_sensor.py +++ b/tests/components/yale/test_binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_yale_with_devices, diff --git a/tests/components/yale/test_event.py b/tests/components/yale/test_event.py index 7aeb9d8f12b..ce7f2635eea 100644 --- a/tests/components/yale/test_event.py +++ b/tests/components/yale/test_event.py @@ -4,7 +4,7 @@ from freezegun.api import FrozenDateTimeFactory from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_yale_with_devices, diff --git a/tests/components/yale/test_lock.py b/tests/components/yale/test_lock.py index f6b96120d0d..1a99cf967ba 100644 --- a/tests/components/yale/test_lock.py +++ b/tests/components/yale/test_lock.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_yale_with_devices, diff --git a/tests/components/yandex_transport/test_sensor.py b/tests/components/yandex_transport/test_sensor.py index 13432850b2b..dd8e82278f3 100644 --- a/tests/components/yandex_transport/test_sensor.py +++ b/tests/components/yandex_transport/test_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components import sensor from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, load_fixture diff --git a/tests/components/zerproc/test_light.py b/tests/components/zerproc/test_light.py index 724414b5965..6cadc025385 100644 --- a/tests/components/zerproc/test_light.py +++ b/tests/components/zerproc/test_light.py @@ -29,7 +29,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 1dd1e5f81aa..89526f6431e 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -9,7 +9,7 @@ import zigpy.zcl.foundation as zcl_f from homeassistant.components.zha.helpers import ZHADeviceProxy from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 1b280ea499a..78d335469b8 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -25,7 +25,7 @@ from zigpy.zcl.clusters.general import Basic, Groups from zigpy.zcl.foundation import Status import zigpy.zdo.types as zdo_t -import homeassistant.components.zha.const as zha_const +from homeassistant.components.zha import const as zha_const from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/zha/test_device_tracker.py b/tests/components/zha/test_device_tracker.py index ae96de44f17..8a587966f81 100644 --- a/tests/components/zha/test_device_tracker.py +++ b/tests/components/zha/test_device_tracker.py @@ -18,7 +18,7 @@ from homeassistant.components.zha.helpers import ( ) from homeassistant.const import STATE_HOME, STATE_NOT_HOME, Platform from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import find_entity_id, send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE diff --git a/tests/components/zha/test_helpers.py b/tests/components/zha/test_helpers.py index f8a809df51e..f52b403869e 100644 --- a/tests/components/zha/test_helpers.py +++ b/tests/components/zha/test_helpers.py @@ -9,7 +9,7 @@ from zigpy.application import ControllerApplication from zigpy.types.basic import uint16_t from zigpy.zcl.clusters import lighting -import homeassistant.components.zha.const as zha_const +from homeassistant.components.zha import const as zha_const from homeassistant.components.zha.helpers import ( cluster_command_schema_to_vol_schema, convert_to_zcl_values, @@ -18,7 +18,7 @@ from homeassistant.components.zha.helpers import ( get_zha_data, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry diff --git a/tests/components/zha/test_siren.py b/tests/components/zha/test_siren.py index f9837a7d016..5849cc6f233 100644 --- a/tests/components/zha/test_siren.py +++ b/tests/components/zha/test_siren.py @@ -28,7 +28,7 @@ from homeassistant.components.zha.helpers import ( ) from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import find_entity_id diff --git a/tests/components/zodiac/test_sensor.py b/tests/components/zodiac/test_sensor.py index 19b9733e4f5..880e5c889ec 100644 --- a/tests/components/zodiac/test_sensor.py +++ b/tests/components/zodiac/test_sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry From 5038847d678e3bd77bfafb18a906bb75ae8886b3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 09:03:25 +0100 Subject: [PATCH 1150/2987] Use runtime_data in environment_canada (#136805) --- .../components/environment_canada/__init__.py | 43 ++++++++----------- .../components/environment_canada/camera.py | 8 ++-- .../environment_canada/coordinator.py | 40 ++++++++++++++--- .../environment_canada/diagnostics.py | 12 +++--- .../components/environment_canada/sensor.py | 18 ++++---- .../components/environment_canada/weather.py | 7 ++- 6 files changed, 76 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index 0b6eadf6d13..c87832de6ab 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -5,14 +5,13 @@ import logging from env_canada import ECAirQuality, ECRadar, ECWeather -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from .const import CONF_STATION, DOMAIN -from .coordinator import ECDataUpdateCoordinator +from .coordinator import ECConfigEntry, ECDataUpdateCoordinator, ECRuntimeData DEFAULT_RADAR_UPDATE_INTERVAL = timedelta(minutes=5) DEFAULT_WEATHER_UPDATE_INTERVAL = timedelta(minutes=5) @@ -22,14 +21,13 @@ PLATFORMS = [Platform.CAMERA, Platform.SENSOR, Platform.WEATHER] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> bool: """Set up EC as config entry.""" lat = config_entry.data.get(CONF_LATITUDE) lon = config_entry.data.get(CONF_LONGITUDE) station = config_entry.data.get(CONF_STATION) lang = config_entry.data.get(CONF_LANGUAGE, "English") - coordinators = {} errors = 0 weather_data = ECWeather( @@ -37,31 +35,31 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b coordinates=(lat, lon), language=lang.lower(), ) - coordinators["weather_coordinator"] = ECDataUpdateCoordinator( - hass, weather_data, "weather", DEFAULT_WEATHER_UPDATE_INTERVAL + weather_coordinator = ECDataUpdateCoordinator( + hass, config_entry, weather_data, "weather", DEFAULT_WEATHER_UPDATE_INTERVAL ) try: - await coordinators["weather_coordinator"].async_config_entry_first_refresh() + await weather_coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: errors = errors + 1 _LOGGER.warning("Unable to retrieve Environment Canada weather") radar_data = ECRadar(coordinates=(lat, lon)) - coordinators["radar_coordinator"] = ECDataUpdateCoordinator( - hass, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL + radar_coordinator = ECDataUpdateCoordinator( + hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL ) try: - await coordinators["radar_coordinator"].async_config_entry_first_refresh() + await radar_coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: errors = errors + 1 _LOGGER.warning("Unable to retrieve Environment Canada radar") aqhi_data = ECAirQuality(coordinates=(lat, lon)) - coordinators["aqhi_coordinator"] = ECDataUpdateCoordinator( - hass, aqhi_data, "AQHI", DEFAULT_WEATHER_UPDATE_INTERVAL + aqhi_coordinator = ECDataUpdateCoordinator( + hass, config_entry, aqhi_data, "AQHI", DEFAULT_WEATHER_UPDATE_INTERVAL ) try: - await coordinators["aqhi_coordinator"].async_config_entry_first_refresh() + await aqhi_coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: errors = errors + 1 _LOGGER.warning("Unable to retrieve Environment Canada AQHI") @@ -69,26 +67,23 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if errors == 3: raise ConfigEntryNotReady - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = coordinators + config_entry.runtime_data = ECRuntimeData( + aqhi_coordinator=aqhi_coordinator, + radar_coordinator=radar_coordinator, + weather_coordinator=weather_coordinator, + ) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - - hass.data[DOMAIN].pop(config_entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) -def device_info(config_entry: ConfigEntry) -> DeviceInfo: +def device_info(config_entry: ECConfigEntry) -> DeviceInfo: """Build and return the device info for EC.""" return DeviceInfo( entry_type=DeviceEntryType.SERVICE, diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index 1625cd253da..d0497d855e5 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -5,7 +5,6 @@ from __future__ import annotations import voluptuous as vol from homeassistant.components.camera import Camera -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, @@ -15,7 +14,8 @@ from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import device_info -from .const import ATTR_OBSERVATION_TIME, DOMAIN +from .const import ATTR_OBSERVATION_TIME +from .coordinator import ECConfigEntry SERVICE_SET_RADAR_TYPE = "set_radar_type" SET_RADAR_TYPE_SCHEMA: VolDictType = { @@ -25,11 +25,11 @@ SET_RADAR_TYPE_SCHEMA: VolDictType = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ECConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id]["radar_coordinator"] + coordinator = config_entry.runtime_data.radar_coordinator async_add_entities([ECCamera(coordinator)]) platform = async_get_current_platform() diff --git a/homeassistant/components/environment_canada/coordinator.py b/homeassistant/components/environment_canada/coordinator.py index 8e77b309c78..8161e26028c 100644 --- a/homeassistant/components/environment_canada/coordinator.py +++ b/homeassistant/components/environment_canada/coordinator.py @@ -1,29 +1,59 @@ """Coordinator for the Environment Canada (EC) component.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta import logging import xml.etree.ElementTree as ET -from env_canada import ec_exc +from env_canada import ECAirQuality, ECRadar, ECWeather, ec_exc +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type ECConfigEntry = ConfigEntry[ECRuntimeData] -class ECDataUpdateCoordinator(DataUpdateCoordinator): + +@dataclass +class ECRuntimeData: + """Class to hold EC runtime data.""" + + aqhi_coordinator: ECDataUpdateCoordinator[ECAirQuality] + radar_coordinator: ECDataUpdateCoordinator[ECRadar] + weather_coordinator: ECDataUpdateCoordinator[ECWeather] + + +class ECDataUpdateCoordinator[_ECDataTypeT: ECAirQuality | ECRadar | ECWeather]( + DataUpdateCoordinator[_ECDataTypeT] +): """Class to manage fetching EC data.""" - def __init__(self, hass, ec_data, name, update_interval): + def __init__( + self, + hass: HomeAssistant, + entry: ECConfigEntry, + ec_data: _ECDataTypeT, + name: str, + update_interval: timedelta, + ) -> None: """Initialize global EC data updater.""" super().__init__( - hass, _LOGGER, name=f"{DOMAIN} {name}", update_interval=update_interval + hass, + _LOGGER, + config_entry=entry, + name=f"{DOMAIN} {name}", + update_interval=update_interval, ) self.ec_data = ec_data self.last_update_success = False - async def _async_update_data(self): + async def _async_update_data(self) -> _ECDataTypeT: """Fetch data from EC.""" try: await self.ec_data.update() diff --git a/homeassistant/components/environment_canada/diagnostics.py b/homeassistant/components/environment_canada/diagnostics.py index 0fb565fda59..024cca15f12 100644 --- a/homeassistant/components/environment_canada/diagnostics.py +++ b/homeassistant/components/environment_canada/diagnostics.py @@ -5,23 +5,21 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .coordinator import ECConfigEntry TO_REDACT = {CONF_LATITUDE, CONF_LONGITUDE} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: ECConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinators = hass.data[DOMAIN][config_entry.entry_id] - weather_coord = coordinators["weather_coordinator"] - return { "config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT), - "weather_data": dict(weather_coord.ec_data.conditions), + "weather_data": dict( + config_entry.runtime_data.weather_coordinator.ec_data.conditions + ), } diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 1a5d096203d..ddececa8132 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LOCATION, DEGREE, @@ -28,7 +27,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import device_info -from .const import ATTR_STATION, DOMAIN +from .const import ATTR_STATION +from .coordinator import ECConfigEntry ATTR_TIME = "alert time" @@ -251,15 +251,17 @@ ALERT_TYPES: tuple[ECSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ECConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id]["weather_coordinator"] - sensors: list[ECBaseSensor] = [ECSensor(coordinator, desc) for desc in SENSOR_TYPES] - sensors.extend([ECAlertSensor(coordinator, desc) for desc in ALERT_TYPES]) - aqhi_coordinator = hass.data[DOMAIN][config_entry.entry_id]["aqhi_coordinator"] - sensors.append(ECSensor(aqhi_coordinator, AQHI_SENSOR)) + weather_coordinator = config_entry.runtime_data.weather_coordinator + sensors: list[ECBaseSensor] = [ + ECSensor(weather_coordinator, desc) for desc in SENSOR_TYPES + ] + sensors.extend([ECAlertSensor(weather_coordinator, desc) for desc in ALERT_TYPES]) + + sensors.append(ECSensor(config_entry.runtime_data.aqhi_coordinator, AQHI_SENSOR)) async_add_entities(sensors) diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index 1871062c2e9..e49164d6b81 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -27,7 +27,6 @@ from homeassistant.components.weather import ( SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfLength, UnitOfPressure, @@ -40,6 +39,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import device_info from .const import DOMAIN +from .coordinator import ECConfigEntry # Icon codes from http://dd.weatheroffice.ec.gc.ca/citypage_weather/ # docs/current_conditions_icon_code_descriptions_e.csv @@ -61,11 +61,10 @@ ICON_CONDITION_MAP = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ECConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id]["weather_coordinator"] entity_registry = er.async_get(hass) # Remove hourly entity from legacy config entries @@ -76,7 +75,7 @@ async def async_setup_entry( ): entity_registry.async_remove(hourly_entity_id) - async_add_entities([ECWeather(coordinator)]) + async_add_entities([ECWeather(config_entry.runtime_data.weather_coordinator)]) def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> str: From 0c6c9e0ae6e09e053d5f35785386cdce0b9d3997 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 09:04:24 +0100 Subject: [PATCH 1151/2987] Use runtime_data in elmax (#136803) --- homeassistant/components/elmax/__init__.py | 30 +++++-------------- .../components/elmax/alarm_control_panel.py | 7 ++--- .../components/elmax/binary_sensor.py | 8 ++--- homeassistant/components/elmax/coordinator.py | 19 ++++++++---- homeassistant/components/elmax/cover.py | 8 ++--- homeassistant/components/elmax/switch.py | 8 ++--- .../elmax/test_alarm_control_panel.py | 2 +- 7 files changed, 34 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/elmax/__init__.py b/homeassistant/components/elmax/__init__.py index d85e5778a39..ec293be8273 100644 --- a/homeassistant/components/elmax/__init__.py +++ b/homeassistant/components/elmax/__init__.py @@ -2,14 +2,10 @@ from __future__ import annotations -from datetime import timedelta -import logging - from elmax_api.exceptions import ElmaxBadLoginError from elmax_api.http import Elmax, ElmaxLocal, GenericElmax from elmax_api.model.panel import PanelEntry -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -27,17 +23,13 @@ from .const import ( CONF_ELMAX_PANEL_PIN, CONF_ELMAX_PASSWORD, CONF_ELMAX_USERNAME, - DOMAIN, ELMAX_PLATFORMS, - POLLING_SECONDS, ) -from .coordinator import ElmaxCoordinator - -_LOGGER = logging.getLogger(__name__) +from .coordinator import ElmaxConfigEntry, ElmaxCoordinator async def _load_elmax_panel_client( - entry: ConfigEntry, + entry: ElmaxConfigEntry, ) -> tuple[GenericElmax, PanelEntry]: # Connection mode was not present in initial version, default to cloud if not set mode = entry.data.get(CONF_ELMAX_MODE, CONF_ELMAX_MODE_CLOUD) @@ -87,7 +79,7 @@ async def _check_cloud_panel_status(client: Elmax, panel_id: str) -> PanelEntry: return panel -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ElmaxConfigEntry) -> bool: """Set up elmax-cloud from a config entry.""" try: client, panel = await _load_elmax_panel_client(entry) @@ -98,11 +90,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # if there is something wrong with user credentials coordinator = ElmaxCoordinator( hass=hass, - logger=_LOGGER, + entry=entry, elmax_api_client=client, panel=panel, - name=f"Elmax Cloud {entry.entry_id}", - update_interval=timedelta(seconds=POLLING_SECONDS), ) async def _async_on_hass_stop(_: Event) -> None: @@ -117,7 +107,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() # Store a global reference to the coordinator for later use - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator entry.async_on_unload(entry.add_update_listener(async_reload_entry)) @@ -126,15 +116,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_reload_entry(hass: HomeAssistant, entry: ElmaxConfigEntry) -> None: """Handle an options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ElmaxConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, ELMAX_PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, ELMAX_PLATFORMS) diff --git a/homeassistant/components/elmax/alarm_control_panel.py b/homeassistant/components/elmax/alarm_control_panel.py index 841b94a3d72..139c9080c15 100644 --- a/homeassistant/components/elmax/alarm_control_panel.py +++ b/homeassistant/components/elmax/alarm_control_panel.py @@ -13,23 +13,22 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, CodeFormat, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, InvalidStateError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import ElmaxCoordinator +from .coordinator import ElmaxConfigEntry from .entity import ElmaxEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElmaxConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Elmax area platform.""" - coordinator: ElmaxCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data known_devices = set() def _discover_new_devices(): diff --git a/homeassistant/components/elmax/binary_sensor.py b/homeassistant/components/elmax/binary_sensor.py index ec51f861819..351c386a084 100644 --- a/homeassistant/components/elmax/binary_sensor.py +++ b/homeassistant/components/elmax/binary_sensor.py @@ -8,22 +8,20 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import ElmaxCoordinator +from .coordinator import ElmaxConfigEntry from .entity import ElmaxEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElmaxConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Elmax sensor platform.""" - coordinator: ElmaxCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data known_devices = set() def _discover_new_devices(): diff --git a/homeassistant/components/elmax/coordinator.py b/homeassistant/components/elmax/coordinator.py index 844a3413089..abcc098359e 100644 --- a/homeassistant/components/elmax/coordinator.py +++ b/homeassistant/components/elmax/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from asyncio import timeout from datetime import timedelta -from logging import Logger +import logging from elmax_api.exceptions import ( ElmaxApiError, @@ -22,11 +22,16 @@ from elmax_api.model.panel import PanelEntry, PanelStatus from elmax_api.push.push import PushNotificationHandler from httpx import ConnectError, ConnectTimeout +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DEFAULT_TIMEOUT +from .const import DEFAULT_TIMEOUT, POLLING_SECONDS + +_LOGGER = logging.getLogger(__name__) + +type ElmaxConfigEntry = ConfigEntry[ElmaxCoordinator] class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): @@ -37,11 +42,9 @@ class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): def __init__( self, hass: HomeAssistant, - logger: Logger, + entry: ElmaxConfigEntry, elmax_api_client: GenericElmax, panel: PanelEntry, - name: str, - update_interval: timedelta, ) -> None: """Instantiate the object.""" self._client = elmax_api_client @@ -49,7 +52,11 @@ class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): self._state_by_endpoint = {} self._push_notification_handler = None super().__init__( - hass=hass, logger=logger, name=name, update_interval=update_interval + hass=hass, + config_entry=entry, + logger=_LOGGER, + name=f"Elmax Cloud {entry.entry_id}", + update_interval=timedelta(seconds=POLLING_SECONDS), ) @property diff --git a/homeassistant/components/elmax/cover.py b/homeassistant/components/elmax/cover.py index 403bc51dbff..e98477fe496 100644 --- a/homeassistant/components/elmax/cover.py +++ b/homeassistant/components/elmax/cover.py @@ -9,12 +9,10 @@ from elmax_api.model.command import CoverCommand from elmax_api.model.cover_status import CoverStatus from homeassistant.components.cover import CoverEntity, CoverEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import ElmaxCoordinator +from .coordinator import ElmaxConfigEntry from .entity import ElmaxEntity _LOGGER = logging.getLogger(__name__) @@ -28,11 +26,11 @@ _COMMAND_BY_MOTION_STATUS = { # Maps the stop command to use for every cover mo async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElmaxConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Elmax cover platform.""" - coordinator: ElmaxCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # Add the cover feature only if supported by the current panel. if coordinator.data is None or not coordinator.data.cover_feature: return diff --git a/homeassistant/components/elmax/switch.py b/homeassistant/components/elmax/switch.py index d0e52c556f6..70faa44cf01 100644 --- a/homeassistant/components/elmax/switch.py +++ b/homeassistant/components/elmax/switch.py @@ -8,12 +8,10 @@ from elmax_api.model.command import SwitchCommand from elmax_api.model.panel import PanelStatus from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import ElmaxCoordinator +from .coordinator import ElmaxConfigEntry from .entity import ElmaxEntity _LOGGER = logging.getLogger(__name__) @@ -21,11 +19,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElmaxConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Elmax switch platform.""" - coordinator: ElmaxCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data known_devices = set() def _discover_new_devices(): diff --git a/tests/components/elmax/test_alarm_control_panel.py b/tests/components/elmax/test_alarm_control_panel.py index 76dc8845662..88fc0a33c51 100644 --- a/tests/components/elmax/test_alarm_control_panel.py +++ b/tests/components/elmax/test_alarm_control_panel.py @@ -5,7 +5,7 @@ from unittest.mock import patch from syrupy import SnapshotAssertion -from homeassistant.components.elmax import POLLING_SECONDS +from homeassistant.components.elmax.const import POLLING_SECONDS from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er From 447096b295f426db2e81d4cb0387454c5df6f18c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 29 Jan 2025 18:12:36 +1000 Subject: [PATCH 1152/2987] Fix percentage_charged in Teslemetry (#136798) Fix percentage_charged --- homeassistant/components/teslemetry/sensor.py | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 0fb0a6ee0e0..dd83ad04ed6 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime, timedelta from propcache.api import cached_property from teslemetry_stream import Signal @@ -369,8 +369,16 @@ VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = ( ), ) -ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( + +@dataclass(frozen=True, kw_only=True) +class TeslemetryEnergySensorEntityDescription(SensorEntityDescription): + """Describes Teslemetry Sensor entity.""" + + value_fn: Callable[[StateType], StateType | datetime] = lambda x: x + + +ENERGY_LIVE_DESCRIPTIONS: tuple[TeslemetryEnergySensorEntityDescription, ...] = ( + TeslemetryEnergySensorEntityDescription( key="solar_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -378,7 +386,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="energy_left", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -387,7 +395,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENERGY_STORAGE, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="total_pack_energy", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -397,14 +405,15 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="percentage_charged", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, suggested_display_precision=2, + value_fn=lambda value: value or 0, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="battery_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -412,7 +421,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="load_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -420,7 +429,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="grid_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -428,7 +437,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="grid_services_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -436,7 +445,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="generator_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -445,7 +454,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="island_status", device_class=SensorDeviceClass.ENUM, options=[ @@ -555,6 +564,7 @@ async def async_setup_entry( if energysite.live_coordinator for description in ENERGY_LIVE_DESCRIPTIONS if description.key in energysite.live_coordinator.data + or description.key == "percentage_charged" ) entities.extend( @@ -704,12 +714,12 @@ class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity): class TeslemetryEnergyLiveSensorEntity(TeslemetryEnergyLiveEntity, SensorEntity): """Base class for Teslemetry energy site metric sensors.""" - entity_description: SensorEntityDescription + entity_description: TeslemetryEnergySensorEntityDescription def __init__( self, data: TeslemetryEnergyData, - description: SensorEntityDescription, + description: TeslemetryEnergySensorEntityDescription, ) -> None: """Initialize the sensor.""" self.entity_description = description @@ -718,7 +728,7 @@ class TeslemetryEnergyLiveSensorEntity(TeslemetryEnergyLiveEntity, SensorEntity) def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" self._attr_available = not self.is_none - self._attr_native_value = self._value + self._attr_native_value = self.entity_description.value_fn(self._value) class TeslemetryWallConnectorSensorEntity(TeslemetryWallConnectorEntity, SensorEntity): From 609eb00a2615eaab29285ff0dc1913d6908b0632 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 29 Jan 2025 09:16:30 +0100 Subject: [PATCH 1153/2987] Add remaining Matter Operational State sensor discovery schemas (#136741) --- homeassistant/components/matter/sensor.py | 76 ++++++++++++++++++- homeassistant/components/matter/strings.json | 5 +- .../matter/snapshots/test_sensor.ambr | 66 ++++++++++++++++ 3 files changed, 144 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 40b25d14c46..eaab91136c9 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -72,6 +72,9 @@ OPERATIONAL_STATE_MAP = { clusters.OperationalState.Enums.OperationalStateEnum.kRunning: "running", clusters.OperationalState.Enums.OperationalStateEnum.kPaused: "paused", clusters.OperationalState.Enums.OperationalStateEnum.kError: "error", + clusters.RvcOperationalState.Enums.OperationalStateEnum.kSeekingCharger: "seeking_charger", + clusters.RvcOperationalState.Enums.OperationalStateEnum.kCharging: "charging", + clusters.RvcOperationalState.Enums.OperationalStateEnum.kDocked: "docked", } @@ -98,6 +101,18 @@ class MatterListSensorEntityDescription(MatterSensorEntityDescription): list_attribute: type[ClusterAttributeDescriptor] +@dataclass(frozen=True, kw_only=True) +class MatterOperationalStateSensorEntityDescription(MatterSensorEntityDescription): + """Describe Matter sensor entities from Matter OperationalState objects.""" + + # list attribute: the attribute descriptor to get the list of values (= list of structs) + # needs to be set for handling OperationalState not on the OperationalState cluster, but + # on one of its derived clusters (e.g. RvcOperationalState) + state_list_attribute: type[ClusterAttributeDescriptor] = ( + clusters.OperationalState.Attributes.OperationalStateList + ) + + class MatterSensor(MatterEntity, SensorEntity): """Representation of a Matter sensor.""" @@ -147,6 +162,7 @@ class MatterDraftElectricalMeasurementSensor(MatterEntity, SensorEntity): class MatterOperationalStateSensor(MatterSensor): """Representation of a sensor for Matter Operational State.""" + entity_description: MatterOperationalStateSensorEntityDescription states_map: dict[int, str] @callback @@ -157,10 +173,11 @@ class MatterOperationalStateSensor(MatterSensor): # therefore it is not possible to provide a fixed list of options # or to provide a mapping to a translateable string for all options operational_state_list = self.get_matter_attribute_value( - clusters.OperationalState.Attributes.OperationalStateList + self.entity_description.state_list_attribute ) if TYPE_CHECKING: operational_state_list = cast( + # cast to the generic OperationalStateStruct type just to help typing list[clusters.OperationalState.Structs.OperationalStateStruct], operational_state_list, ) @@ -782,7 +799,7 @@ DISCOVERY_SCHEMAS = [ ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=MatterSensorEntityDescription( + entity_description=MatterOperationalStateSensorEntityDescription( key="OperationalState", device_class=SensorDeviceClass.ENUM, translation_key="operational_state", @@ -806,6 +823,32 @@ DISCOVERY_SCHEMAS = [ clusters.OperationalState.Attributes.PhaseList, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterListSensorEntityDescription( + key="RvcOperationalStateCurrentPhase", + translation_key="current_phase", + list_attribute=clusters.RvcOperationalState.Attributes.PhaseList, + ), + entity_class=MatterListSensor, + required_attributes=( + clusters.RvcOperationalState.Attributes.CurrentPhase, + clusters.RvcOperationalState.Attributes.PhaseList, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterListSensorEntityDescription( + key="OvenCavityOperationalStateCurrentPhase", + translation_key="current_phase", + list_attribute=clusters.OvenCavityOperationalState.Attributes.PhaseList, + ), + entity_class=MatterListSensor, + required_attributes=( + clusters.OvenCavityOperationalState.Attributes.CurrentPhase, + clusters.OvenCavityOperationalState.Attributes.PhaseList, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( @@ -820,4 +863,33 @@ DISCOVERY_SCHEMAS = [ device_type=(device_types.Thermostat,), allow_multi=True, # also used for climate entity ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterOperationalStateSensorEntityDescription( + key="RvcOperationalState", + device_class=SensorDeviceClass.ENUM, + translation_key="operational_state", + state_list_attribute=clusters.RvcOperationalState.Attributes.OperationalStateList, + ), + entity_class=MatterOperationalStateSensor, + required_attributes=( + clusters.RvcOperationalState.Attributes.OperationalState, + clusters.RvcOperationalState.Attributes.OperationalStateList, + ), + allow_multi=True, # also used for vacuum entity + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterOperationalStateSensorEntityDescription( + key="OvenCavityOperationalState", + device_class=SensorDeviceClass.ENUM, + translation_key="operational_state", + state_list_attribute=clusters.OvenCavityOperationalState.Attributes.OperationalStateList, + ), + entity_class=MatterOperationalStateSensor, + required_attributes=( + clusters.OvenCavityOperationalState.Attributes.OperationalState, + clusters.OvenCavityOperationalState.Attributes.OperationalStateList, + ), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 8bac67a4ca7..73ce41937fd 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -246,7 +246,10 @@ "stopped": "Stopped", "running": "Running", "paused": "[%key:common::state::paused%]", - "error": "Error" + "error": "Error", + "seeking_charger": "Seeking charger", + "charging": "Charging", + "docked": "Docked" } }, "switch_current_position": { diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index d9bc0bdf1fc..541f1bc178f 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -3412,6 +3412,72 @@ 'state': '28.3', }) # --- +# name: test_sensors[vacuum_cleaner][sensor.mock_vacuum_operational_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + 'seeking_charger', + 'charging', + 'docked', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_vacuum_operational_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': 'Operational state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_state', + 'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-RvcOperationalState-97-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[vacuum_cleaner][sensor.mock_vacuum_operational_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Vacuum Operational state', + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + 'seeking_charger', + 'charging', + 'docked', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_vacuum_operational_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[yandex_smart_socket][sensor.yndx_00540_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 11671e1875f491b75f106455565512517b0163e7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 09:36:53 +0100 Subject: [PATCH 1154/2987] Use runtime_data in energenie_power_sockets (#136801) * Use runtime_data in energenie_power_sockets * Fix tests --- .../energenie_power_sockets/__init__.py | 20 ++++++++----------- .../energenie_power_sockets/switch.py | 6 +++--- .../energenie_power_sockets/conftest.py | 2 +- .../energenie_power_sockets/test_init.py | 3 --- .../energenie_power_sockets/test_switch.py | 2 -- 5 files changed, 12 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/energenie_power_sockets/__init__.py b/homeassistant/components/energenie_power_sockets/__init__.py index 12ddb0d1389..0496c6f9b92 100644 --- a/homeassistant/components/energenie_power_sockets/__init__.py +++ b/homeassistant/components/energenie_power_sockets/__init__.py @@ -8,12 +8,14 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from .const import CONF_DEVICE_API_ID, DOMAIN +from .const import CONF_DEVICE_API_ID PLATFORMS = [Platform.SWITCH] +type EnergenieConfigEntry = ConfigEntry[PowerStripUSB] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: EnergenieConfigEntry) -> bool: """Set up Energenie Power Sockets.""" try: powerstrip: PowerStripUSB | None = get_device(entry.data[CONF_DEVICE_API_ID]) @@ -26,19 +28,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Can't access Energenie Power Sockets, will retry later." ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = powerstrip + entry.runtime_data = powerstrip + entry.async_on_unload(powerstrip.release) 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: EnergenieConfigEntry) -> bool: """Unload config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - powerstrip = hass.data[DOMAIN].pop(entry.entry_id) - powerstrip.release() - - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/energenie_power_sockets/switch.py b/homeassistant/components/energenie_power_sockets/switch.py index 1d5b9ed5197..e4fb7653e5e 100644 --- a/homeassistant/components/energenie_power_sockets/switch.py +++ b/homeassistant/components/energenie_power_sockets/switch.py @@ -7,22 +7,22 @@ from pyegps.exceptions import EgpsException from pyegps.powerstrip import PowerStrip from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import EnergenieConfigEntry from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EnergenieConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add EGPS sockets for passed config_entry in HA.""" - powerstrip: PowerStrip = hass.data[DOMAIN][config_entry.entry_id] + powerstrip = config_entry.runtime_data async_add_entities( ( diff --git a/tests/components/energenie_power_sockets/conftest.py b/tests/components/energenie_power_sockets/conftest.py index c142e436fd3..d0301034cf8 100644 --- a/tests/components/energenie_power_sockets/conftest.py +++ b/tests/components/energenie_power_sockets/conftest.py @@ -44,7 +44,7 @@ def get_pyegps_device_mock() -> MagicMock: fkObj = FakePowerStrip( devId=DEMO_CONFIG_DATA[CONF_DEVICE_API_ID], number_of_sockets=4 ) - fkObj.release = lambda: True + fkObj.release = lambda: None fkObj._status = [0, 1, 0, 1] usb_device_mock = MagicMock(wraps=fkObj) diff --git a/tests/components/energenie_power_sockets/test_init.py b/tests/components/energenie_power_sockets/test_init.py index 4e2fe51665b..a11cef319b2 100644 --- a/tests/components/energenie_power_sockets/test_init.py +++ b/tests/components/energenie_power_sockets/test_init.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock from pyegps.exceptions import UsbError -from homeassistant.components.energenie_power_sockets.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -24,13 +23,11 @@ async def test_load_unload_entry( await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - assert entry.entry_id in hass.data[DOMAIN] assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED - assert DOMAIN not in hass.data async def test_device_not_found_on_load_entry( diff --git a/tests/components/energenie_power_sockets/test_switch.py b/tests/components/energenie_power_sockets/test_switch.py index 4cd2bd60028..27f13390a83 100644 --- a/tests/components/energenie_power_sockets/test_switch.py +++ b/tests/components/energenie_power_sockets/test_switch.py @@ -6,7 +6,6 @@ from pyegps.exceptions import EgpsException import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.energenie_power_sockets.const import DOMAIN from homeassistant.components.homeassistant import ( DOMAIN as HOME_ASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -118,7 +117,6 @@ async def test_switch_setup( await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - assert entry.entry_id in hass.data[DOMAIN] state = hass.states.get(f"switch.{entity_name}") assert state == snapshot From 9169d55cf6d167c0bce1581a7e5845f456d19b9d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Jan 2025 09:49:02 +0100 Subject: [PATCH 1155/2987] Use ConfigEntry.runtime_data in AVM Fritz!Box tools (#136386) * implement FritzConfigEntry with runtime_data * use HassKey for platform global data * update quality scale * fix after rebase * use FritzConfigEntry everywhere possible * fix import of FritzConfigEntry in services.py * pass the config_entry explicitly in coordinator init * improve typing of FritzData * use FritzConfigEntry in config_flow.py --- homeassistant/components/fritz/__init__.py | 30 ++++++++----------- .../components/fritz/binary_sensor.py | 10 +++---- homeassistant/components/fritz/button.py | 18 +++++++---- homeassistant/components/fritz/config_flow.py | 12 +++----- homeassistant/components/fritz/const.py | 2 -- homeassistant/components/fritz/coordinator.py | 19 ++++++++---- .../components/fritz/device_tracker.py | 12 ++++---- homeassistant/components/fritz/diagnostics.py | 8 ++--- homeassistant/components/fritz/image.py | 8 ++--- .../components/fritz/quality_scale.yaml | 4 +-- homeassistant/components/fritz/sensor.py | 11 +++---- homeassistant/components/fritz/services.py | 6 ++-- homeassistant/components/fritz/switch.py | 12 ++++---- homeassistant/components/fritz/update.py | 10 +++---- 14 files changed, 81 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index 25888328cd2..05a2a07707f 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -2,7 +2,6 @@ import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -16,14 +15,13 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( - DATA_FRITZ, DEFAULT_SSL, DOMAIN, FRITZ_AUTH_EXCEPTIONS, FRITZ_EXCEPTIONS, PLATFORMS, ) -from .coordinator import AvmWrapper, FritzData +from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -37,11 +35,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bool: """Set up fritzboxtools from config entry.""" _LOGGER.debug("Setting up FRITZ!Box Tools component") avm_wrapper = AvmWrapper( hass=hass, + config_entry=entry, host=entry.data[CONF_HOST], port=entry.data[CONF_PORT], username=entry.data[CONF_USERNAME], @@ -64,11 +63,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await avm_wrapper.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = avm_wrapper + entry.runtime_data = avm_wrapper - if DATA_FRITZ not in hass.data: - hass.data[DATA_FRITZ] = FritzData() + if FRITZ_DATA_KEY not in hass.data: + hass.data[FRITZ_DATA_KEY] = FritzData() entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -78,24 +76,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bool: """Unload FRITZ!Box Tools config entry.""" - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data - fritz_data = hass.data[DATA_FRITZ] + fritz_data = hass.data[FRITZ_DATA_KEY] fritz_data.tracked.pop(avm_wrapper.unique_id) if not bool(fritz_data.tracked): - hass.data.pop(DATA_FRITZ) + hass.data.pop(FRITZ_DATA_KEY) - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: FritzConfigEntry) -> None: """Update when config_entry options update.""" if entry.options: await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index cb1f698bdca..7553328a64c 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -11,13 +11,11 @@ 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 AddEntitiesCallback -from .const import DOMAIN -from .coordinator import AvmWrapper, ConnectionInfo +from .coordinator import ConnectionInfo, FritzConfigEntry from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) @@ -51,11 +49,13 @@ SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up FRITZ!Box binary sensors") - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data connection_info = await avm_wrapper.async_get_connection_info() diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index 263521d23f4..f3ffbe42099 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -12,15 +12,21 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DATA_FRITZ, DOMAIN, MeshRoles -from .coordinator import AvmWrapper, FritzData, FritzDevice, _is_tracked +from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DOMAIN, MeshRoles +from .coordinator import ( + FRITZ_DATA_KEY, + AvmWrapper, + FritzConfigEntry, + FritzData, + FritzDevice, + _is_tracked, +) from .entity import FritzDeviceBase _LOGGER = logging.getLogger(__name__) @@ -65,12 +71,12 @@ BUTTONS: Final = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FritzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set buttons for device.""" _LOGGER.debug("Setting up buttons") - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data entities_list: list[ButtonEntity] = [ FritzButton(avm_wrapper, entry.title, button) for button in BUTTONS @@ -80,7 +86,7 @@ async def async_setup_entry( async_add_entities(entities_list) return - data_fritz: FritzData = hass.data[DATA_FRITZ] + data_fritz = hass.data[FRITZ_DATA_KEY] entities_list += _async_wol_buttons_list(avm_wrapper, data_fritz) async_add_entities(entities_list) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 7b6057b3ba2..fb17f872cb6 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -17,12 +17,7 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -53,6 +48,7 @@ from .const import ( ERROR_UPNP_NOT_CONFIGURED, FRITZ_AUTH_EXCEPTIONS, ) +from .coordinator import FritzConfigEntry _LOGGER = logging.getLogger(__name__) @@ -67,7 +63,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: FritzConfigEntry, ) -> FritzBoxToolsOptionsFlowHandler: """Get the options flow for this handler.""" return FritzBoxToolsOptionsFlowHandler() @@ -116,7 +112,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): return None - async def async_check_configured_entry(self) -> ConfigEntry | None: + async def async_check_configured_entry(self) -> FritzConfigEntry | None: """Check if entry is configured.""" current_host = await self.hass.async_add_executor_job( socket.gethostbyname, self._host diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index f8f5b43f4b1..2237823bc3b 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -40,8 +40,6 @@ PLATFORMS = [ CONF_OLD_DISCOVERY = "old_discovery" DEFAULT_CONF_OLD_DISCOVERY = False -DATA_FRITZ = "fritz_data" - DSL_CONNECTION: Literal["dsl"] = "dsl" DEFAULT_DEVICE_NAME = "Unknown device" diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 7f8ae6c5b3c..38d76c92871 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -36,6 +36,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey from .const import ( CONF_OLD_DISCOVERY, @@ -50,8 +51,12 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +FRITZ_DATA_KEY: HassKey[FritzData] = HassKey(DOMAIN) -def _is_tracked(mac: str, current_devices: ValuesView) -> bool: +type FritzConfigEntry = ConfigEntry[AvmWrapper] + + +def _is_tracked(mac: str, current_devices: ValuesView[set[str]]) -> bool: """Check if device is already tracked.""" return any(mac in tracked for tracked in current_devices) @@ -59,7 +64,7 @@ def _is_tracked(mac: str, current_devices: ValuesView) -> bool: def device_filter_out_from_trackers( mac: str, device: FritzDevice, - current_devices: ValuesView, + current_devices: ValuesView[set[str]], ) -> bool: """Check if device should be filtered out from trackers.""" reason: str | None = None @@ -160,11 +165,12 @@ class UpdateCoordinatorDataType(TypedDict): class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): """FritzBoxTools class.""" - config_entry: ConfigEntry + config_entry: FritzConfigEntry def __init__( self, hass: HomeAssistant, + config_entry: FritzConfigEntry, password: str, port: int, username: str = DEFAULT_USERNAME, @@ -174,6 +180,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): """Initialize FritzboxTools class.""" super().__init__( hass=hass, + config_entry=config_entry, logger=_LOGGER, name=f"{DOMAIN}-{host}-coordinator", update_interval=timedelta(seconds=30), @@ -869,9 +876,9 @@ class AvmWrapper(FritzBoxTools): class FritzData: """Storage class for platform global data.""" - tracked: dict = field(default_factory=dict) - profile_switches: dict = field(default_factory=dict) - wol_buttons: dict = field(default_factory=dict) + tracked: dict[str, set[str]] = field(default_factory=dict) + profile_switches: dict[str, set[str]] = field(default_factory=dict) + wol_buttons: dict[str, set[str]] = field(default_factory=dict) class FritzDevice: diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index d1270a0510c..ba3c9a5aab6 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -6,14 +6,14 @@ import datetime import logging from homeassistant.components.device_tracker import ScannerEntity -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 AddEntitiesCallback -from .const import DATA_FRITZ, DOMAIN from .coordinator import ( + FRITZ_DATA_KEY, AvmWrapper, + FritzConfigEntry, FritzData, FritzDevice, device_filter_out_from_trackers, @@ -24,12 +24,14 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up device tracker for FRITZ!Box component.""" _LOGGER.debug("Starting FRITZ!Box device tracker") - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] - data_fritz: FritzData = hass.data[DATA_FRITZ] + avm_wrapper = entry.runtime_data + data_fritz = hass.data[FRITZ_DATA_KEY] @callback def update_avm_device() -> None: diff --git a/homeassistant/components/fritz/diagnostics.py b/homeassistant/components/fritz/diagnostics.py index 8823d55baa9..b9ae9edf04d 100644 --- a/homeassistant/components/fritz/diagnostics.py +++ b/homeassistant/components/fritz/diagnostics.py @@ -5,21 +5,19 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import AvmWrapper +from .coordinator import FritzConfigEntry TO_REDACT = {CONF_USERNAME, CONF_PASSWORD} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: FritzConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), diff --git a/homeassistant/components/fritz/image.py b/homeassistant/components/fritz/image.py index 19c98446ccd..d305551b097 100644 --- a/homeassistant/components/fritz/image.py +++ b/homeassistant/components/fritz/image.py @@ -8,14 +8,12 @@ import logging from requests.exceptions import RequestException from homeassistant.components.image import ImageEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util, slugify -from .const import DOMAIN -from .coordinator import AvmWrapper +from .coordinator import AvmWrapper, FritzConfigEntry from .entity import FritzBoxBaseEntity _LOGGER = logging.getLogger(__name__) @@ -23,11 +21,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FritzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up guest WiFi QR code for device.""" - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data guest_wifi_info = await hass.async_add_executor_job( avm_wrapper.fritz_guest_wifi.get_info diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index d6fadd3a20e..805705eb4b4 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -22,9 +22,7 @@ rules: has-entity-name: status: todo comment: partially done - runtime-data: - status: todo - comment: still uses hass.data + runtime-data: done test-before-configure: done test-before-setup: done unique-config-entry: done diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 11ee0ad5510..81b50bd21ac 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -15,7 +15,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS, EntityCategory, @@ -27,8 +26,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION -from .coordinator import AvmWrapper, ConnectionInfo +from .const import DSL_CONNECTION, UPTIME_DEVIATION +from .coordinator import ConnectionInfo, FritzConfigEntry from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) @@ -267,11 +266,13 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up FRITZ!Box sensors") - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data connection_info = await avm_wrapper.async_get_connection_info() diff --git a/homeassistant/components/fritz/services.py b/homeassistant/components/fritz/services.py index ac542be8631..02e6c91f4bf 100644 --- a/homeassistant/components/fritz/services.py +++ b/homeassistant/components/fritz/services.py @@ -15,7 +15,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.service import async_extract_config_entry_ids from .const import DOMAIN -from .coordinator import AvmWrapper +from .coordinator import FritzConfigEntry _LOGGER = logging.getLogger(__name__) @@ -33,7 +33,7 @@ async def _async_set_guest_wifi_password(service_call: ServiceCall) -> None: """Call Fritz set guest wifi password service.""" hass = service_call.hass target_entry_ids = await async_extract_config_entry_ids(hass, service_call) - target_entries = [ + target_entries: list[FritzConfigEntry] = [ loaded_entry for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN) if loaded_entry.entry_id in target_entry_ids @@ -48,7 +48,7 @@ async def _async_set_guest_wifi_password(service_call: ServiceCall) -> None: for target_entry in target_entries: _LOGGER.debug("Executing service %s", service_call.service) - avm_wrapper: AvmWrapper = hass.data[DOMAIN][target_entry.entry_id] + avm_wrapper = target_entry.runtime_data try: await avm_wrapper.async_trigger_set_guest_password( service_call.data.get("password"), diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 372af89cc9e..9c12fe0cecc 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -7,7 +7,6 @@ from typing import Any from homeassistant.components.network import async_get_source_ip from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -18,7 +17,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify from .const import ( - DATA_FRITZ, DOMAIN, SWITCH_TYPE_DEFLECTION, SWITCH_TYPE_PORTFORWARD, @@ -28,7 +26,9 @@ from .const import ( MeshRoles, ) from .coordinator import ( + FRITZ_DATA_KEY, AvmWrapper, + FritzConfigEntry, FritzData, FritzDevice, SwitchInfo, @@ -220,12 +220,14 @@ async def async_all_entities_list( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up switches") - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] - data_fritz: FritzData = hass.data[DATA_FRITZ] + avm_wrapper = entry.runtime_data + data_fritz = hass.data[FRITZ_DATA_KEY] _LOGGER.debug("Fritzbox services: %s", avm_wrapper.connection.services) diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py index 6969f201f27..ad23a076ca6 100644 --- a/homeassistant/components/fritz/update.py +++ b/homeassistant/components/fritz/update.py @@ -11,13 +11,11 @@ from homeassistant.components.update import ( UpdateEntityDescription, UpdateEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import AvmWrapper +from .coordinator import AvmWrapper, FritzConfigEntry from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) @@ -29,11 +27,13 @@ class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescripti async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up AVM FRITZ!Box update entities.""" _LOGGER.debug("Setting up AVM FRITZ!Box update entities") - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data entities = [FritzBoxUpdateEntity(avm_wrapper, entry.title)] From 7b1b2297185b98902e458c1c392151c14ddcbee0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 10:00:45 +0100 Subject: [PATCH 1156/2987] Standardize homeassistant imports in component tests (a-l) (#136806) --- tests/components/aemet/test_sensor.py | 2 +- tests/components/alarm_control_panel/test_device_trigger.py | 2 +- tests/components/api/test_init.py | 3 +-- tests/components/august/test_binary_sensor.py | 2 +- tests/components/august/test_lock.py | 2 +- tests/components/automation/test_init.py | 3 +-- tests/components/binary_sensor/test_device_condition.py | 2 +- tests/components/binary_sensor/test_device_trigger.py | 2 +- tests/components/bluetooth/test_base_scanner.py | 2 +- tests/components/calendar/test_init.py | 2 +- tests/components/calendar/test_trigger.py | 2 +- tests/components/clicksend_tts/test_notify.py | 2 +- tests/components/cloudflare/test_init.py | 2 +- tests/components/command_line/test_cover.py | 2 +- tests/components/command_line/test_init.py | 2 +- tests/components/command_line/test_switch.py | 2 +- tests/components/configurator/test_init.py | 2 +- tests/components/conversation/test_entity.py | 2 +- tests/components/cover/test_device_trigger.py | 2 +- tests/components/demo/test_cover.py | 2 +- tests/components/demo/test_geo_location.py | 2 +- tests/components/demo/test_notify.py | 3 +-- tests/components/derivative/test_sensor.py | 2 +- tests/components/device_automation/test_toggle_entity.py | 2 +- tests/components/device_tracker/test_init.py | 2 +- tests/components/dhcp/test_init.py | 2 +- tests/components/dremel_3d_printer/test_init.py | 2 +- tests/components/dynalite/test_init.py | 2 +- tests/components/eafm/test_sensor.py | 2 +- tests/components/efergy/test_sensor.py | 2 +- tests/components/electric_kiwi/test_sensor.py | 2 +- tests/components/emulated_hue/test_hue_api.py | 2 +- tests/components/energy/test_sensor.py | 2 +- tests/components/evohome/test_storage.py | 2 +- tests/components/facebook/test_notify.py | 2 +- tests/components/fan/test_device_trigger.py | 2 +- tests/components/feedreader/test_event.py | 2 +- tests/components/feedreader/test_init.py | 2 +- tests/components/file/test_notify.py | 2 +- tests/components/filter/test_sensor.py | 2 +- tests/components/flux/test_switch.py | 2 +- tests/components/flux_led/test_number.py | 2 +- tests/components/fritz/test_sensor.py | 2 +- tests/components/fritzbox/test_binary_sensor.py | 2 +- tests/components/fritzbox/test_button.py | 2 +- tests/components/fritzbox/test_climate.py | 2 +- tests/components/fritzbox/test_cover.py | 2 +- tests/components/fritzbox/test_light.py | 2 +- tests/components/fritzbox/test_sensor.py | 2 +- tests/components/fritzbox/test_switch.py | 2 +- tests/components/gdacs/test_geo_location.py | 2 +- tests/components/gdacs/test_sensor.py | 2 +- tests/components/generic_hygrostat/test_humidifier.py | 4 ++-- tests/components/generic_thermostat/test_climate.py | 3 +-- tests/components/geo_rss_events/test_sensor.py | 4 ++-- tests/components/geonetnz_quakes/test_geo_location.py | 2 +- tests/components/geonetnz_quakes/test_sensor.py | 2 +- tests/components/geonetnz_volcano/test_sensor.py | 2 +- tests/components/goalzero/test_init.py | 2 +- tests/components/google/test_calendar.py | 2 +- tests/components/google_mail/test_sensor.py | 2 +- tests/components/google_wifi/test_sensor.py | 2 +- tests/components/gree/test_bridge.py | 2 +- tests/components/group/test_cover.py | 2 +- tests/components/group/test_light.py | 3 +-- tests/components/hardware/test_websocket_api.py | 2 +- tests/components/hassio/test_sensor.py | 2 +- tests/components/hassio/test_update.py | 2 +- tests/components/history_stats/test_sensor.py | 5 ++--- tests/components/homeassistant/test_init.py | 3 +-- .../components/homeassistant/triggers/test_numeric_state.py | 2 +- tests/components/homeassistant/triggers/test_state.py | 2 +- tests/components/homeassistant/triggers/test_time.py | 2 +- .../components/homeassistant/triggers/test_time_pattern.py | 2 +- tests/components/homekit/test_type_lights.py | 2 +- tests/components/homekit/test_type_switches.py | 2 +- tests/components/homekit_controller/common.py | 2 +- tests/components/homekit_controller/conftest.py | 2 +- .../homekit_controller/specific_devices/test_koogeek_ls1.py | 2 +- tests/components/homewizard/test_number.py | 2 +- tests/components/homewizard/test_sensor.py | 2 +- tests/components/homewizard/test_switch.py | 2 +- tests/components/html5/test_notify.py | 2 +- tests/components/humidifier/test_device_trigger.py | 2 +- tests/components/ign_sismologia/test_geo_location.py | 2 +- tests/components/image_processing/test_init.py | 3 +-- tests/components/integration/test_sensor.py | 2 +- tests/components/islamic_prayer_times/__init__.py | 2 +- tests/components/islamic_prayer_times/test_init.py | 2 +- tests/components/jewish_calendar/__init__.py | 2 +- tests/components/jewish_calendar/test_binary_sensor.py | 2 +- tests/components/jewish_calendar/test_sensor.py | 2 +- tests/components/kitchen_sink/test_init.py | 2 +- tests/components/kulersky/test_light.py | 2 +- tests/components/lametric/test_switch.py | 2 +- tests/components/litejet/conftest.py | 2 +- tests/components/litejet/test_trigger.py | 2 +- tests/components/local_calendar/test_calendar.py | 2 +- tests/components/lock/test_device_trigger.py | 2 +- tests/components/lutron_caseta/test_config_flow.py | 6 ++++-- 100 files changed, 106 insertions(+), 112 deletions(-) diff --git a/tests/components/aemet/test_sensor.py b/tests/components/aemet/test_sensor.py index d0f577c8068..d4fca62e98b 100644 --- a/tests/components/aemet/test_sensor.py +++ b/tests/components/aemet/test_sensor.py @@ -4,7 +4,7 @@ from freezegun.api import FrozenDateTimeFactory from homeassistant.components.weather import ATTR_CONDITION_SNOWY from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .util import async_init_integration diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index 17a301ccdf1..3efacb80560 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -16,7 +16,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index abce262fd12..6363304effc 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -11,10 +11,9 @@ from aiohttp.test_utils import TestClient import pytest import voluptuous as vol -from homeassistant import const +from homeassistant import const, core as ha from homeassistant.auth.models import Credentials from homeassistant.bootstrap import DATA_LOGGING -import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index 4ae300ae56b..bcdd4d55330 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_august_with_devices, diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index eb177a35cfb..065ffef91ff 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_august_with_devices, diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 6466e5e7f22..243e132dae2 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -51,8 +51,7 @@ from homeassistant.helpers.script import ( _async_stop_scripts_at_shutdown, ) from homeassistant.setup import async_setup_component -from homeassistant.util import yaml as yaml_util -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, yaml as yaml_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index 8a0132ff2af..59fbdf9a253 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCatego from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockBinarySensor diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index 78e382f77bf..dd71c1e5d06 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCatego from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockBinarySensor diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index e3bdca256c0..acd630863d2 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -29,7 +29,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import json_loads from . import ( diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 36b102b933a..2d712f408c2 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -16,7 +16,7 @@ from homeassistant.components.calendar import DOMAIN, SERVICE_GET_EVENTS from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import MockCalendarEntity, MockConfigEntry diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index dfe4622e82e..b0d7944041d 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -25,7 +25,7 @@ from homeassistant.components.calendar.trigger import EVENT_END, EVENT_START from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import MockCalendarEntity diff --git a/tests/components/clicksend_tts/test_notify.py b/tests/components/clicksend_tts/test_notify.py index 892d7541354..811978eead5 100644 --- a/tests/components/clicksend_tts/test_notify.py +++ b/tests/components/clicksend_tts/test_notify.py @@ -9,7 +9,7 @@ import pytest import requests_mock from homeassistant.components import notify -import homeassistant.components.clicksend_tts.notify as cs_tts +from homeassistant.components.clicksend_tts import notify as cs_tts from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/cloudflare/test_init.py b/tests/components/cloudflare/test_init.py index d629607e503..15a6c5740ff 100644 --- a/tests/components/cloudflare/test_init.py +++ b/tests/components/cloudflare/test_init.py @@ -15,7 +15,7 @@ from homeassistant.components.cloudflare.const import ( from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.location import LocationInfo from . import ENTRY_CONFIG, init_integration diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index 426968eccc5..a6e384fdd6b 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -32,7 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import mock_asyncio_subprocess_run diff --git a/tests/components/command_line/test_init.py b/tests/components/command_line/test_init.py index 3fbd0e0f898..16a783d4f59 100644 --- a/tests/components/command_line/test_init.py +++ b/tests/components/command_line/test_init.py @@ -11,7 +11,7 @@ from homeassistant import config as hass_config from homeassistant.components.command_line.const import DOMAIN from homeassistant.const import SERVICE_RELOAD, STATE_ON, STATE_OPEN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, get_fixture_path diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index d62410fa792..6b34cf0fa77 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import mock_asyncio_subprocess_run diff --git a/tests/components/configurator/test_init.py b/tests/components/configurator/test_init.py index a4faab483ee..1985c6e5c8c 100644 --- a/tests/components/configurator/test_init.py +++ b/tests/components/configurator/test_init.py @@ -5,7 +5,7 @@ from datetime import timedelta from homeassistant.components import configurator from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/conversation/test_entity.py b/tests/components/conversation/test_entity.py index 109c0ed361f..f03b24818bf 100644 --- a/tests/components/conversation/test_entity.py +++ b/tests/components/conversation/test_entity.py @@ -6,7 +6,7 @@ from homeassistant.components import conversation from homeassistant.core import Context, HomeAssistant, State from homeassistant.helpers import intent from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import mock_restore_cache diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index a6c10d4acf1..7901baaa3b8 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockCover diff --git a/tests/components/demo/test_cover.py b/tests/components/demo/test_cover.py index 97cad5bbe14..dcec921c01d 100644 --- a/tests/components/demo/test_cover.py +++ b/tests/components/demo/test_cover.py @@ -31,7 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/demo/test_geo_location.py b/tests/components/demo/test_geo_location.py index d3c2937d12b..a93c79828d6 100644 --- a/tests/components/demo/test_geo_location.py +++ b/tests/components/demo/test_geo_location.py @@ -15,7 +15,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/demo/test_notify.py b/tests/components/demo/test_notify.py index 98b3de8448a..f3677c6e373 100644 --- a/tests/components/demo/test_notify.py +++ b/tests/components/demo/test_notify.py @@ -6,8 +6,7 @@ from unittest.mock import patch import pytest from homeassistant.components import notify -from homeassistant.components.demo import DOMAIN -import homeassistant.components.demo.notify as demo +from homeassistant.components.demo import DOMAIN, notify as demo from homeassistant.const import Platform from homeassistant.core import Event, HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 4a4d8519b25..a543de974f1 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import UnitOfPower, UnitOfTime from homeassistant.core import HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry diff --git a/tests/components/device_automation/test_toggle_entity.py b/tests/components/device_automation/test_toggle_entity.py index be4d3bd4c9e..a7b2f8a3b75 100644 --- a/tests/components/device_automation/test_toggle_entity.py +++ b/tests/components/device_automation/test_toggle_entity.py @@ -9,7 +9,7 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 6226669aa0f..ea07365bd2f 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -28,7 +28,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import common from .common import MockScanner, mock_legacy_device_tracker_setup diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 76f15eb3e51..223dc83f83a 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -36,7 +36,7 @@ from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/dremel_3d_printer/test_init.py b/tests/components/dremel_3d_printer/test_init.py index 6b008c7fac1..fda1ecc6cf6 100644 --- a/tests/components/dremel_3d_printer/test_init.py +++ b/tests/components/dremel_3d_printer/test_init.py @@ -12,7 +12,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/dynalite/test_init.py b/tests/components/dynalite/test_init.py index 4bf4eb53ad6..3335e12b2a2 100644 --- a/tests/components/dynalite/test_init.py +++ b/tests/components/dynalite/test_init.py @@ -5,7 +5,7 @@ from unittest.mock import call, patch import pytest from voluptuous import MultipleInvalid -import homeassistant.components.dynalite.const as dynalite +from homeassistant.components.dynalite import const as dynalite from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/eafm/test_sensor.py b/tests/components/eafm/test_sensor.py index add604167b9..11febb26669 100644 --- a/tests/components/eafm/test_sensor.py +++ b/tests/components/eafm/test_sensor.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/efergy/test_sensor.py b/tests/components/efergy/test_sensor.py index addaa1b9c48..49c18bab239 100644 --- a/tests/components/efergy/test_sensor.py +++ b/tests/components/efergy/test_sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import MULTI_SENSOR_TOKEN, mock_responses, setup_platform diff --git a/tests/components/electric_kiwi/test_sensor.py b/tests/components/electric_kiwi/test_sensor.py index bb3304ec66c..a85eb16a986 100644 --- a/tests/components/electric_kiwi/test_sensor.py +++ b/tests/components/electric_kiwi/test_sensor.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ComponentSetup, YieldFixture diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 8a340d5e2dd..97dcc782096 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -58,7 +58,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonObjectType from tests.common import ( diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index a27451b853d..a438842f8a5 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.components.recorder.common import async_wait_recording_done diff --git a/tests/components/evohome/test_storage.py b/tests/components/evohome/test_storage.py index 4cc21078333..b3597352487 100644 --- a/tests/components/evohome/test_storage.py +++ b/tests/components/evohome/test_storage.py @@ -15,7 +15,7 @@ from homeassistant.components.evohome import ( dt_aware_to_naive, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import setup_evohome from .const import ACCESS_TOKEN, REFRESH_TOKEN, SESSION_ID, USERNAME diff --git a/tests/components/facebook/test_notify.py b/tests/components/facebook/test_notify.py index 77ae544646d..db9cd86e086 100644 --- a/tests/components/facebook/test_notify.py +++ b/tests/components/facebook/test_notify.py @@ -5,7 +5,7 @@ from http import HTTPStatus import pytest import requests_mock -import homeassistant.components.facebook.notify as fb +from homeassistant.components.facebook import notify as fb from homeassistant.core import HomeAssistant diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py index f4673636637..bef44c92f34 100644 --- a/tests/components/fan/test_device_trigger.py +++ b/tests/components/fan/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/feedreader/test_event.py b/tests/components/feedreader/test_event.py index 32f8ecb8080..8f5f3870bfe 100644 --- a/tests/components/feedreader/test_event.py +++ b/tests/components/feedreader/test_event.py @@ -13,7 +13,7 @@ from homeassistant.components.feedreader.event import ( ATTR_TITLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import create_mock_entry from .const import VALID_CONFIG_DEFAULT diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 9a2575bf591..5d2ac1a4406 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -14,7 +14,7 @@ from homeassistant.components.feedreader.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import async_setup_config_entry, create_mock_entry from .const import ( diff --git a/tests/components/file/test_notify.py b/tests/components/file/test_notify.py index e7cb85a9cfc..44b9d61efec 100644 --- a/tests/components/file/test_notify.py +++ b/tests/components/file/test_notify.py @@ -12,7 +12,7 @@ from homeassistant.components.file import DOMAIN from homeassistant.components.notify import ATTR_TITLE_DEFAULT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index a3e0e58908a..4312047278f 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -32,7 +32,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, get_fixture_path diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index f7dc30db240..e1bd07cdfd7 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( assert_setup_component, diff --git a/tests/components/flux_led/test_number.py b/tests/components/flux_led/test_number.py index 2ed0d34989f..8dd8196a2db 100644 --- a/tests/components/flux_led/test_number.py +++ b/tests/components/flux_led/test_number.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( DEFAULT_ENTRY_TITLE, diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index 7dec640b898..1b10ddb8fc1 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import MOCK_USER_DATA diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index f4cc1b2e2ca..594ed14a7d1 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FritzDeviceBinarySensorMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG diff --git a/tests/components/fritzbox/test_button.py b/tests/components/fritzbox/test_button.py index 913f828efbc..0053a8d3446 100644 --- a/tests/components/fritzbox/test_button.py +++ b/tests/components/fritzbox/test_button.py @@ -12,7 +12,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FritzEntityBaseMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 29f5742216f..0fb5f5038c3 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -44,7 +44,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( FritzDeviceClimateMock, diff --git a/tests/components/fritzbox/test_cover.py b/tests/components/fritzbox/test_cover.py index f26e65fc28a..82723b083ae 100644 --- a/tests/components/fritzbox/test_cover.py +++ b/tests/components/fritzbox/test_cover.py @@ -20,7 +20,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( FritzDeviceCoverMock, diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 84fafe25521..071642fb358 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -31,7 +31,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FritzDeviceLightMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index 0da040bbb5b..67b2c3e8ab6 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( FritzDeviceClimateMock, diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index e394ccbc7f3..511725c663f 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -32,7 +32,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FritzDeviceSwitchMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG diff --git a/tests/components/gdacs/test_geo_location.py b/tests/components/gdacs/test_geo_location.py index 4ea28bd8fd3..68e2d061259 100644 --- a/tests/components/gdacs/test_geo_location.py +++ b/tests/components/gdacs/test_geo_location.py @@ -33,7 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry diff --git a/tests/components/gdacs/test_sensor.py b/tests/components/gdacs/test_sensor.py index 87b66295006..01609cf485e 100644 --- a/tests/components/gdacs/test_sensor.py +++ b/tests/components/gdacs/test_sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import _generate_mock_feed_entry diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index 33a8a0f37bd..3acb50fa38d 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -7,6 +7,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol +from homeassistant import core as ha from homeassistant.components import input_boolean, switch from homeassistant.components.generic_hygrostat import ( DOMAIN as GENERIC_HYDROSTAT_DOMAIN, @@ -28,7 +29,6 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) -import homeassistant.core as ha from homeassistant.core import ( DOMAIN as HOMEASSISTANT_DOMAIN, CoreState, @@ -40,7 +40,7 @@ from homeassistant.core import ( from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import StateType from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 8cbbdbb49d4..7e2e92f025b 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -7,7 +7,7 @@ from freezegun import freeze_time import pytest import voluptuous as vol -from homeassistant import config as hass_config +from homeassistant import config as hass_config, core as ha from homeassistant.components import input_boolean, switch from homeassistant.components.climate import ( ATTR_PRESET_MODE, @@ -35,7 +35,6 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTemperature, ) -import homeassistant.core as ha from homeassistant.core import ( DOMAIN as HOMEASSISTANT_DOMAIN, CoreState, diff --git a/tests/components/geo_rss_events/test_sensor.py b/tests/components/geo_rss_events/test_sensor.py index d19262c3339..3b6ef8a0642 100644 --- a/tests/components/geo_rss_events/test_sensor.py +++ b/tests/components/geo_rss_events/test_sensor.py @@ -6,7 +6,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import sensor -import homeassistant.components.geo_rss_events.sensor as geo_rss_events +from homeassistant.components.geo_rss_events import sensor as geo_rss_events from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ICON, @@ -15,7 +15,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/geonetnz_quakes/test_geo_location.py b/tests/components/geonetnz_quakes/test_geo_location.py index 163bca775c9..fd8ba81fca7 100644 --- a/tests/components/geonetnz_quakes/test_geo_location.py +++ b/tests/components/geonetnz_quakes/test_geo_location.py @@ -31,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry diff --git a/tests/components/geonetnz_quakes/test_sensor.py b/tests/components/geonetnz_quakes/test_sensor.py index 82143baa374..2daeab9e7ef 100644 --- a/tests/components/geonetnz_quakes/test_sensor.py +++ b/tests/components/geonetnz_quakes/test_sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import _generate_mock_feed_entry diff --git a/tests/components/geonetnz_volcano/test_sensor.py b/tests/components/geonetnz_volcano/test_sensor.py index d6ebbcd6582..a79d8512df6 100644 --- a/tests/components/geonetnz_volcano/test_sensor.py +++ b/tests/components/geonetnz_volcano/test_sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry diff --git a/tests/components/goalzero/test_init.py b/tests/components/goalzero/test_init.py index 1d44c7e808e..4817be1ce35 100644 --- a/tests/components/goalzero/test_init.py +++ b/tests/components/goalzero/test_init.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import CONF_DATA, async_init_integration, create_entry, create_mocked_yeti diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 305f30d99d4..3d10e753714 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.helpers.template import DATE_STR_FORMAT -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( CALENDAR_ID, diff --git a/tests/components/google_mail/test_sensor.py b/tests/components/google_mail/test_sensor.py index 6f2f1a4ec32..e9dd2da85de 100644 --- a/tests/components/google_mail/test_sensor.py +++ b/tests/components/google_mail/test_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.google_mail.const import DOMAIN from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNKNOWN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import SENSOR, TOKEN, ComponentSetup diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py index 18d96e3a1c0..88adcbf6587 100644 --- a/tests/components/google_wifi/test_sensor.py +++ b/tests/components/google_wifi/test_sensor.py @@ -7,7 +7,7 @@ from unittest.mock import Mock, patch import requests_mock -import homeassistant.components.google_wifi.sensor as google_wifi +from homeassistant.components.google_wifi import sensor as google_wifi from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util diff --git a/tests/components/gree/test_bridge.py b/tests/components/gree/test_bridge.py index ae2f0c74236..acfa1ba43f5 100644 --- a/tests/components/gree/test_bridge.py +++ b/tests/components/gree/test_bridge.py @@ -12,7 +12,7 @@ from homeassistant.components.gree.const import ( UPDATE_INTERVAL, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import async_setup_gree, build_device_mock diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index b1f622569bd..ab92b18cc91 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -38,7 +38,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index 91604d663b3..dbd74e95780 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -6,8 +6,7 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant import config as hass_config -from homeassistant.components.group import DOMAIN, SERVICE_RELOAD -import homeassistant.components.group.light as group +from homeassistant.components.group import DOMAIN, SERVICE_RELOAD, light as group from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, diff --git a/tests/components/hardware/test_websocket_api.py b/tests/components/hardware/test_websocket_api.py index 1379bdba120..64fcda02df4 100644 --- a/tests/components/hardware/test_websocket_api.py +++ b/tests/components/hardware/test_websocket_api.py @@ -10,7 +10,7 @@ import psutil_home_assistant as ha_psutil from homeassistant.components.hardware.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.typing import WebSocketGenerator diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 7160a2cbf16..f4b01a85900 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MOCK_REPOSITORIES, MOCK_STORE_ADDONS diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 732b2655107..62fe49c5f23 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -16,7 +16,7 @@ from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 3039612d1a0..721e540b04d 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -7,7 +7,7 @@ from freezegun import freeze_time import pytest import voluptuous as vol -from homeassistant import config as hass_config +from homeassistant import config as hass_config, core as ha from homeassistant.components.history_stats.const import ( CONF_END, CONF_START, @@ -27,12 +27,11 @@ from homeassistant.const import ( SERVICE_RELOAD, STATE_UNKNOWN, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed, get_fixture_path from tests.components.recorder.common import async_wait_recording_done diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 0aed3dc929e..4facd1695c5 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -6,7 +6,7 @@ import pytest import voluptuous as vol import yaml -from homeassistant import config +from homeassistant import config, core as ha from homeassistant.components.homeassistant import ( ATTR_ENTRY_ID, ATTR_SAFE_MODE, @@ -30,7 +30,6 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import entity, entity_registry as er diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index fe4fb53962a..0d4294ca16f 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -21,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed, mock_component diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index c3117bbb660..f6478e9dda0 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed, mock_component diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index 40f62baa5e7..9a4f41d08e1 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed, mock_component diff --git a/tests/components/homeassistant/triggers/test_time_pattern.py b/tests/components/homeassistant/triggers/test_time_pattern.py index 7138fd7dd02..ffce8cd476b 100644 --- a/tests/components/homeassistant/triggers/test_time_pattern.py +++ b/tests/components/homeassistant/triggers/test_time_pattern.py @@ -11,7 +11,7 @@ from homeassistant.components.homeassistant.triggers import time_pattern from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_component diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index c1870cecd9c..5bad7aa8f39 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -42,7 +42,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, Event, HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_mock_service diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 0d19763e4c7..141141e7f15 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -42,7 +42,7 @@ from homeassistant.const import ( STATE_OPEN, ) from homeassistant.core import Event, HomeAssistant, split_entity_id -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_mock_service diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index b94a267104b..e2aaf58d63e 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -32,7 +32,7 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index eea3f4b67f2..4e787f305b6 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -9,7 +9,7 @@ from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.components.light.conftest import mock_light_profiles # noqa: F401 diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index a16cd052c87..a71465716c4 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -12,7 +12,7 @@ from homeassistant.components.homekit_controller.connection import ( MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ..common import Helper, setup_accessories_from_file, setup_test_accessories diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index b668043608c..67e51cbafe2 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -14,7 +14,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index c1474c4b947..d9698db7469 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.homewizard.const import UPDATE_INTERVAL from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index ccf99ee27fa..ae9b7653b6d 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -18,7 +18,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/html5/test_notify.py b/tests/components/html5/test_notify.py index 0d9388907a9..f602a8f3807 100644 --- a/tests/components/html5/test_notify.py +++ b/tests/components/html5/test_notify.py @@ -8,7 +8,7 @@ from unittest.mock import mock_open, patch from aiohttp.hdrs import AUTHORIZATION from aiohttp.test_utils import TestClient -import homeassistant.components.html5.notify as html5 +from homeassistant.components.html5 import notify as html5 from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index 3bb1f8c2551..e1b2b2bff61 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -24,7 +24,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/ign_sismologia/test_geo_location.py b/tests/components/ign_sismologia/test_geo_location.py index c26eae28086..2f946459bfe 100644 --- a/tests/components/ign_sismologia/test_geo_location.py +++ b/tests/components/ign_sismologia/test_geo_location.py @@ -32,7 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/image_processing/test_init.py b/tests/components/image_processing/test_init.py index 3e7c8f2fb91..6ff6d925d7e 100644 --- a/tests/components/image_processing/test_init.py +++ b/tests/components/image_processing/test_init.py @@ -6,8 +6,7 @@ from unittest.mock import PropertyMock, patch import pytest -from homeassistant.components import http -import homeassistant.components.image_processing as ip +from homeassistant.components import http, image_processing as ip from homeassistant.const import ATTR_ENTITY_PICTURE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 07390cd9571..ba4a6bdf198 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -28,7 +28,7 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index 522006b0847..90a3a90c451 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -3,7 +3,7 @@ from datetime import datetime from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util MOCK_USER_INPUT = { CONF_NAME: "Home", diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index 7961b79676b..5ae11d8f850 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import NOW, PRAYER_TIMES diff --git a/tests/components/jewish_calendar/__init__.py b/tests/components/jewish_calendar/__init__.py index 440bffc2256..ba0a2b4835e 100644 --- a/tests/components/jewish_calendar/__init__.py +++ b/tests/components/jewish_calendar/__init__.py @@ -6,7 +6,7 @@ from datetime import datetime from freezegun import freeze_time as alter_time # noqa: F401 from homeassistant.components import jewish_calendar -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LatLng = namedtuple("_LatLng", ["lat", "lng"]) # noqa: PYI024 diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py index 8abaaecb77d..5cfaaedfc72 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.jewish_calendar.const import ( from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import alter_time, make_jerusalem_test_params, make_nyc_test_params diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index 4897ef7749b..aac0f583b05 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.jewish_calendar.const import ( from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import alter_time, make_jerusalem_test_params, make_nyc_test_params diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index b832577a48a..7338c1dca99 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -17,7 +17,7 @@ from homeassistant.components.recorder.statistics import ( from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from tests.components.recorder.common import async_wait_recording_done diff --git a/tests/components/kulersky/test_light.py b/tests/components/kulersky/test_light.py index a2245e721c5..230a2562282 100644 --- a/tests/components/kulersky/test_light.py +++ b/tests/components/kulersky/test_light.py @@ -32,7 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/lametric/test_switch.py b/tests/components/lametric/test_switch.py index 64ebe22e98b..3e73b710942 100644 --- a/tests/components/lametric/test_switch.py +++ b/tests/components/lametric/test_switch.py @@ -22,7 +22,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/litejet/conftest.py b/tests/components/litejet/conftest.py index 41517acf1e9..975f943d2fa 100644 --- a/tests/components/litejet/conftest.py +++ b/tests/components/litejet/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util @pytest.fixture diff --git a/tests/components/litejet/test_trigger.py b/tests/components/litejet/test_trigger.py index c13fda9068c..de99d701926 100644 --- a/tests/components/litejet/test_trigger.py +++ b/tests/components/litejet/test_trigger.py @@ -11,7 +11,7 @@ import pytest from homeassistant import setup from homeassistant.components import automation from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import async_init_integration diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index 61908faeca6..0720e6d7ded 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -8,7 +8,7 @@ import pytest from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.template import DATE_STR_FORMAT -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( FRIENDLY_NAME, diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index 3ecdf2a9bca..7d1c39d10f0 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index cc80bc08817..bdbe6501470 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -10,8 +10,10 @@ from pylutron_caseta.smartbridge import Smartbridge import pytest from homeassistant import config_entries -from homeassistant.components.lutron_caseta import DOMAIN -import homeassistant.components.lutron_caseta.config_flow as CasetaConfigFlow +from homeassistant.components.lutron_caseta import ( + DOMAIN, + config_flow as CasetaConfigFlow, +) from homeassistant.components.lutron_caseta.const import ( CONF_CA_CERTS, CONF_CERTFILE, From 417003ad35029a8ca6fe6db4952b967a03ad0053 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 10:23:37 +0100 Subject: [PATCH 1157/2987] Rename environment_canada entities (#136817) --- .../components/environment_canada/camera.py | 4 ++-- .../components/environment_canada/sensor.py | 18 +++++++++++------- .../components/environment_canada/weather.py | 4 ++-- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index d0497d855e5..4a321e88e6d 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -30,7 +30,7 @@ async def async_setup_entry( ) -> None: """Add a weather entity from a config_entry.""" coordinator = config_entry.runtime_data.radar_coordinator - async_add_entities([ECCamera(coordinator)]) + async_add_entities([ECCameraEntity(coordinator)]) platform = async_get_current_platform() platform.async_register_entity_service( @@ -40,7 +40,7 @@ async def async_setup_entry( ) -class ECCamera(CoordinatorEntity, Camera): +class ECCameraEntity(CoordinatorEntity, Camera): """Implementation of an Environment Canada radar camera.""" _attr_has_entity_name = True diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index ddececa8132..2d7a9648446 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -256,16 +256,20 @@ async def async_setup_entry( ) -> None: """Add a weather entity from a config_entry.""" weather_coordinator = config_entry.runtime_data.weather_coordinator - sensors: list[ECBaseSensor] = [ - ECSensor(weather_coordinator, desc) for desc in SENSOR_TYPES + sensors: list[ECBaseSensorEntity] = [ + ECSensorEntity(weather_coordinator, desc) for desc in SENSOR_TYPES ] - sensors.extend([ECAlertSensor(weather_coordinator, desc) for desc in ALERT_TYPES]) + sensors.extend( + [ECAlertSensorEntity(weather_coordinator, desc) for desc in ALERT_TYPES] + ) - sensors.append(ECSensor(config_entry.runtime_data.aqhi_coordinator, AQHI_SENSOR)) + sensors.append( + ECSensorEntity(config_entry.runtime_data.aqhi_coordinator, AQHI_SENSOR) + ) async_add_entities(sensors) -class ECBaseSensor(CoordinatorEntity, SensorEntity): +class ECBaseSensorEntity(CoordinatorEntity, SensorEntity): """Environment Canada sensor base.""" entity_description: ECSensorEntityDescription @@ -289,7 +293,7 @@ class ECBaseSensor(CoordinatorEntity, SensorEntity): return value -class ECSensor(ECBaseSensor): +class ECSensorEntity(ECBaseSensorEntity): """Environment Canada sensor for conditions.""" def __init__(self, coordinator, description): @@ -301,7 +305,7 @@ class ECSensor(ECBaseSensor): } -class ECAlertSensor(ECBaseSensor): +class ECAlertSensorEntity(ECBaseSensorEntity): """Environment Canada sensor for alerts.""" @property diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index e49164d6b81..a5bc72856e7 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -75,7 +75,7 @@ async def async_setup_entry( ): entity_registry.async_remove(hourly_entity_id) - async_add_entities([ECWeather(config_entry.runtime_data.weather_coordinator)]) + async_add_entities([ECWeatherEntity(config_entry.runtime_data.weather_coordinator)]) def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> str: @@ -83,7 +83,7 @@ def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> st return f"{config_entry_unique_id}{'-hourly' if hourly else '-daily'}" -class ECWeather(SingleCoordinatorWeatherEntity): +class ECWeatherEntity(SingleCoordinatorWeatherEntity): """Representation of a weather condition.""" _attr_has_entity_name = True From b93c2382ce66631d964d5a3f714150f35e0215c1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 29 Jan 2025 10:35:01 +0100 Subject: [PATCH 1158/2987] Add config flow to filter helper (#121522) Co-authored-by: Robert Resch --- homeassistant/components/filter/__init__.py | 25 +- .../components/filter/config_flow.py | 243 ++++++++++++++++++ homeassistant/components/filter/const.py | 36 +++ homeassistant/components/filter/manifest.json | 1 + homeassistant/components/filter/sensor.py | 81 ++++-- homeassistant/components/filter/strings.json | 192 ++++++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/filter/conftest.py | 93 +++++++ tests/components/filter/test_config_flow.py | 227 ++++++++++++++++ tests/components/filter/test_init.py | 20 ++ tests/components/filter/test_sensor.py | 51 +++- 12 files changed, 938 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/filter/config_flow.py create mode 100644 homeassistant/components/filter/const.py create mode 100644 tests/components/filter/conftest.py create mode 100644 tests/components/filter/test_config_flow.py create mode 100644 tests/components/filter/test_init.py diff --git a/homeassistant/components/filter/__init__.py b/homeassistant/components/filter/__init__.py index 7f3f6cbfffc..9a4f4913c9f 100644 --- a/homeassistant/components/filter/__init__.py +++ b/homeassistant/components/filter/__init__.py @@ -1,6 +1,25 @@ """The filter component.""" -from homeassistant.const import Platform +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant -DOMAIN = "filter" -PLATFORMS = [Platform.SENSOR] +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Filter from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Filter config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/filter/config_flow.py b/homeassistant/components/filter/config_flow.py new file mode 100644 index 00000000000..dac2d8995bf --- /dev/null +++ b/homeassistant/components/filter/config_flow.py @@ -0,0 +1,243 @@ +"""Config flow for filter.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, +) +from homeassistant.helpers.selector import ( + DurationSelector, + DurationSelectorConfig, + EntitySelector, + EntitySelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) + +from .const import ( + CONF_FILTER_LOWER_BOUND, + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_RADIUS, + CONF_FILTER_TIME_CONSTANT, + CONF_FILTER_UPPER_BOUND, + CONF_FILTER_WINDOW_SIZE, + CONF_TIME_SMA_TYPE, + DEFAULT_FILTER_RADIUS, + DEFAULT_FILTER_TIME_CONSTANT, + DEFAULT_NAME, + DEFAULT_PRECISION, + DEFAULT_WINDOW_SIZE, + DOMAIN, + FILTER_NAME_LOWPASS, + FILTER_NAME_OUTLIER, + FILTER_NAME_RANGE, + FILTER_NAME_THROTTLE, + FILTER_NAME_TIME_SMA, + FILTER_NAME_TIME_THROTTLE, + TIME_SMA_LAST, +) + +FILTERS = [ + FILTER_NAME_LOWPASS, + FILTER_NAME_OUTLIER, + FILTER_NAME_RANGE, + FILTER_NAME_THROTTLE, + FILTER_NAME_TIME_SMA, + FILTER_NAME_TIME_THROTTLE, +] + + +async def get_next_step(user_input: dict[str, Any]) -> str: + """Return next step for options.""" + return cast(str, user_input[CONF_FILTER_NAME]) + + +async def validate_options( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate options selected.""" + + if CONF_FILTER_WINDOW_SIZE in user_input and isinstance( + user_input[CONF_FILTER_WINDOW_SIZE], float + ): + user_input[CONF_FILTER_WINDOW_SIZE] = int(user_input[CONF_FILTER_WINDOW_SIZE]) + if CONF_FILTER_TIME_CONSTANT in user_input: + user_input[CONF_FILTER_TIME_CONSTANT] = int( + user_input[CONF_FILTER_TIME_CONSTANT] + ) + if CONF_FILTER_PRECISION in user_input: + user_input[CONF_FILTER_PRECISION] = int(user_input[CONF_FILTER_PRECISION]) + + handler.parent_handler._async_abort_entries_match({**handler.options, **user_input}) # noqa: SLF001 + + return user_input + + +DATA_SCHEMA_SETUP = vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), + vol.Required(CONF_ENTITY_ID): EntitySelector( + EntitySelectorConfig(domain=[SENSOR_DOMAIN]) + ), + vol.Required(CONF_FILTER_NAME): SelectSelector( + SelectSelectorConfig( + options=FILTERS, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_FILTER_NAME, + ) + ), + } +) + +BASE_OPTIONS_SCHEMA = { + vol.Optional(CONF_FILTER_PRECISION, default=DEFAULT_PRECISION): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ) +} + +OUTLIER_SCHEMA = vol.Schema( + { + vol.Optional( + CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_FILTER_RADIUS, default=DEFAULT_FILTER_RADIUS): NumberSelector( + NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +LOWPASS_SCHEMA = vol.Schema( + { + vol.Optional( + CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional( + CONF_FILTER_TIME_CONSTANT, default=DEFAULT_FILTER_TIME_CONSTANT + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +RANGE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_FILTER_LOWER_BOUND): NumberSelector( + NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_FILTER_UPPER_BOUND): NumberSelector( + NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +TIME_SMA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_TIME_SMA_TYPE, default=TIME_SMA_LAST): SelectSelector( + SelectSelectorConfig( + options=[TIME_SMA_LAST], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_TIME_SMA_TYPE, + ) + ), + vol.Required(CONF_FILTER_WINDOW_SIZE): DurationSelector( + DurationSelectorConfig(enable_day=False, allow_negative=False) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +THROTTLE_SCHEMA = vol.Schema( + { + vol.Optional( + CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +TIME_THROTTLE_SCHEMA = vol.Schema( + { + vol.Required(CONF_FILTER_WINDOW_SIZE): DurationSelector( + DurationSelectorConfig(enable_day=False, allow_negative=False) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +CONFIG_FLOW = { + "user": SchemaFlowFormStep( + schema=DATA_SCHEMA_SETUP, + next_step=get_next_step, + ), + "lowpass": SchemaFlowFormStep( + schema=LOWPASS_SCHEMA, validate_user_input=validate_options + ), + "outlier": SchemaFlowFormStep( + schema=OUTLIER_SCHEMA, validate_user_input=validate_options + ), + "range": SchemaFlowFormStep( + schema=RANGE_SCHEMA, validate_user_input=validate_options + ), + "time_simple_moving_average": SchemaFlowFormStep( + schema=TIME_SMA_SCHEMA, validate_user_input=validate_options + ), + "throttle": SchemaFlowFormStep( + schema=THROTTLE_SCHEMA, validate_user_input=validate_options + ), + "time_throttle": SchemaFlowFormStep( + schema=TIME_THROTTLE_SCHEMA, validate_user_input=validate_options + ), +} +OPTIONS_FLOW = { + "init": SchemaFlowFormStep( + schema=None, + next_step=get_next_step, + ), + "lowpass": SchemaFlowFormStep( + schema=LOWPASS_SCHEMA, validate_user_input=validate_options + ), + "outlier": SchemaFlowFormStep( + schema=OUTLIER_SCHEMA, validate_user_input=validate_options + ), + "range": SchemaFlowFormStep( + schema=RANGE_SCHEMA, validate_user_input=validate_options + ), + "time_simple_moving_average": SchemaFlowFormStep( + schema=TIME_SMA_SCHEMA, validate_user_input=validate_options + ), + "throttle": SchemaFlowFormStep( + schema=THROTTLE_SCHEMA, validate_user_input=validate_options + ), + "time_throttle": SchemaFlowFormStep( + schema=TIME_THROTTLE_SCHEMA, validate_user_input=validate_options + ), +} + + +class FilterConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for Filter.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options[CONF_NAME]) diff --git a/homeassistant/components/filter/const.py b/homeassistant/components/filter/const.py new file mode 100644 index 00000000000..92d2498528e --- /dev/null +++ b/homeassistant/components/filter/const.py @@ -0,0 +1,36 @@ +"""The filter component constants.""" + +from homeassistant.const import Platform + +DOMAIN = "filter" +PLATFORMS = [Platform.SENSOR] + +CONF_INDEX = "index" + +FILTER_NAME_RANGE = "range" +FILTER_NAME_LOWPASS = "lowpass" +FILTER_NAME_OUTLIER = "outlier" +FILTER_NAME_THROTTLE = "throttle" +FILTER_NAME_TIME_THROTTLE = "time_throttle" +FILTER_NAME_TIME_SMA = "time_simple_moving_average" + +CONF_FILTERS = "filters" +CONF_FILTER_NAME = "filter" +CONF_FILTER_WINDOW_SIZE = "window_size" +CONF_FILTER_PRECISION = "precision" +CONF_FILTER_RADIUS = "radius" +CONF_FILTER_TIME_CONSTANT = "time_constant" +CONF_FILTER_LOWER_BOUND = "lower_bound" +CONF_FILTER_UPPER_BOUND = "upper_bound" +CONF_TIME_SMA_TYPE = "type" + +TIME_SMA_LAST = "last" + +WINDOW_SIZE_UNIT_NUMBER_EVENTS = 1 +WINDOW_SIZE_UNIT_TIME = 2 + +DEFAULT_NAME = "Filtered sensor" +DEFAULT_WINDOW_SIZE = 1 +DEFAULT_PRECISION = 2 +DEFAULT_FILTER_RADIUS = 2.0 +DEFAULT_FILTER_TIME_CONSTANT = 10 diff --git a/homeassistant/components/filter/manifest.json b/homeassistant/components/filter/manifest.json index 4d9a8992036..392351a235d 100644 --- a/homeassistant/components/filter/manifest.json +++ b/homeassistant/components/filter/manifest.json @@ -2,6 +2,7 @@ "domain": "filter", "name": "Filter", "codeowners": ["@dgomes"], + "config_flow": true, "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/filter", "integration_type": "helper", diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 549d74ffd09..5bb6cadabc7 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -24,6 +24,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -51,39 +52,37 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateTyp from homeassistant.util.decorator import Registry import homeassistant.util.dt as dt_util -from . import DOMAIN, PLATFORMS +from .const import ( + CONF_FILTER_LOWER_BOUND, + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_RADIUS, + CONF_FILTER_TIME_CONSTANT, + CONF_FILTER_UPPER_BOUND, + CONF_FILTER_WINDOW_SIZE, + CONF_FILTERS, + CONF_TIME_SMA_TYPE, + DEFAULT_FILTER_RADIUS, + DEFAULT_FILTER_TIME_CONSTANT, + DEFAULT_PRECISION, + DEFAULT_WINDOW_SIZE, + DOMAIN, + FILTER_NAME_LOWPASS, + FILTER_NAME_OUTLIER, + FILTER_NAME_RANGE, + FILTER_NAME_THROTTLE, + FILTER_NAME_TIME_SMA, + FILTER_NAME_TIME_THROTTLE, + PLATFORMS, + TIME_SMA_LAST, + WINDOW_SIZE_UNIT_NUMBER_EVENTS, + WINDOW_SIZE_UNIT_TIME, +) _LOGGER = logging.getLogger(__name__) -FILTER_NAME_RANGE = "range" -FILTER_NAME_LOWPASS = "lowpass" -FILTER_NAME_OUTLIER = "outlier" -FILTER_NAME_THROTTLE = "throttle" -FILTER_NAME_TIME_THROTTLE = "time_throttle" -FILTER_NAME_TIME_SMA = "time_simple_moving_average" FILTERS: Registry[str, type[Filter]] = Registry() -CONF_FILTERS = "filters" -CONF_FILTER_NAME = "filter" -CONF_FILTER_WINDOW_SIZE = "window_size" -CONF_FILTER_PRECISION = "precision" -CONF_FILTER_RADIUS = "radius" -CONF_FILTER_TIME_CONSTANT = "time_constant" -CONF_FILTER_LOWER_BOUND = "lower_bound" -CONF_FILTER_UPPER_BOUND = "upper_bound" -CONF_TIME_SMA_TYPE = "type" - -TIME_SMA_LAST = "last" - -WINDOW_SIZE_UNIT_NUMBER_EVENTS = 1 -WINDOW_SIZE_UNIT_TIME = 2 - -DEFAULT_WINDOW_SIZE = 1 -DEFAULT_PRECISION = 2 -DEFAULT_FILTER_RADIUS = 2.0 -DEFAULT_FILTER_TIME_CONSTANT = 10 - -NAME_TEMPLATE = "{} filter" ICON = "mdi:chart-line-variant" FILTER_SCHEMA = vol.Schema({vol.Optional(CONF_FILTER_PRECISION): vol.Coerce(int)}) @@ -199,6 +198,32 @@ async def async_setup_platform( async_add_entities([SensorFilter(name, unique_id, entity_id, filters)]) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Filter sensor entry.""" + name: str = entry.options[CONF_NAME] + entity_id: str = entry.options[CONF_ENTITY_ID] + + filter_config = { + k: v for k, v in entry.options.items() if k not in (CONF_NAME, CONF_ENTITY_ID) + } + if CONF_FILTER_WINDOW_SIZE in filter_config and isinstance( + filter_config[CONF_FILTER_WINDOW_SIZE], dict + ): + filter_config[CONF_FILTER_WINDOW_SIZE] = timedelta( + **filter_config[CONF_FILTER_WINDOW_SIZE] + ) + + filters = [ + FILTERS[filter_config.pop(CONF_FILTER_NAME)](entity=entity_id, **filter_config) + ] + + async_add_entities([SensorFilter(name, entry.entry_id, entity_id, filters)]) + + class SensorFilter(SensorEntity): """Representation of a Filter Sensor.""" diff --git a/homeassistant/components/filter/strings.json b/homeassistant/components/filter/strings.json index 2a83a05bb96..b0403227fd4 100644 --- a/homeassistant/components/filter/strings.json +++ b/homeassistant/components/filter/strings.json @@ -1,5 +1,197 @@ { "title": "Filter", + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "step": { + "user": { + "description": "Add a filter sensor. UI configuration is limited to a single filter, use YAML for filter chain.", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "entity_id": "Entity", + "filter": "Filter" + }, + "data_description": { + "name": "Name for the created entity.", + "entity_id": "Entity to filter from.", + "filter": "Select filter to configure." + } + }, + "outlier": { + "description": "Read the documentation for further details on how to configure the filter sensor using these options.", + "data": { + "window_size": "Window size", + "precision": "Precision", + "radius": "Radius" + }, + "data_description": { + "window_size": "Size of the window of previous states.", + "precision": "Defines the number of decimal places of the calculated sensor value.", + "radius": "Band radius from median of previous states." + } + }, + "lowpass": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "time_constant": "Time constant" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "time_constant": "Loosely relates to the amount of time it takes for a state to influence the output." + } + }, + "range": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "lower_bound": "Lower bound", + "upper_bound": "Upper bound" + }, + "data_description": { + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "lower_bound": "Lower bound for filter range.", + "upper_bound": "Upper bound for filter range." + } + }, + "time_simple_moving_average": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "type": "Type" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "type": "Defines the type of Simple Moving Average." + } + }, + "throttle": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + } + }, + "time_throttle": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + } + } + } + }, + "options": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "step": { + "outlier": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "radius": "[%key:component::filter::config::step::outlier::data::radius%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "radius": "[%key:component::filter::config::step::outlier::data_description::radius%]" + } + }, + "lowpass": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "time_constant": "[%key:component::filter::config::step::lowpass::data::time_constant%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "time_constant": "[%key:component::filter::config::step::lowpass::data_description::time_constant%]" + } + }, + "range": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "lower_bound": "[%key:component::filter::config::step::range::data::lower_bound%]", + "upper_bound": "[%key:component::filter::config::step::range::data::upper_bound%]" + }, + "data_description": { + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "lower_bound": "[%key:component::filter::config::step::range::data_description::lower_bound%]", + "upper_bound": "[%key:component::filter::config::step::range::data_description::upper_bound%]" + } + }, + "time_simple_moving_average": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "type": "[%key:component::filter::config::step::time_simple_moving_average::data::type%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "type": "[%key:component::filter::config::step::time_simple_moving_average::data_description::type%]" + } + }, + "throttle": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + } + }, + "time_throttle": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + } + } + } + }, + "selector": { + "filter": { + "options": { + "range": "Range", + "lowpass": "Lowpass", + "outlier": "Outlier", + "throttle": "Throttle", + "time_throttle": "Time throttle", + "time_simple_moving_average": "Moving Average (Time based)" + } + }, + "type": { + "options": { + "last": "Last" + } + } + }, "services": { "reload": { "name": "[%key:common::action::reload%]", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 921910d5046..3c8a1d40dc2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -6,6 +6,7 @@ To update, run python3 -m script.hassfest FLOWS = { "helper": [ "derivative", + "filter", "generic_hygrostat", "generic_thermostat", "group", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 05227e20159..e8a4290bb7d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7436,7 +7436,7 @@ }, "filter": { "integration_type": "helper", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "generic_hygrostat": { diff --git a/tests/components/filter/conftest.py b/tests/components/filter/conftest.py new file mode 100644 index 00000000000..e703430446c --- /dev/null +++ b/tests/components/filter/conftest.py @@ -0,0 +1,93 @@ +"""Fixtures for the Filter integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from datetime import timedelta +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.filter.const import ( + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_RADIUS, + CONF_FILTER_WINDOW_SIZE, + DEFAULT_FILTER_RADIUS, + DEFAULT_NAME, + DEFAULT_PRECISION, + DEFAULT_WINDOW_SIZE, + DOMAIN, + FILTER_NAME_OUTLIER, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.core import HomeAssistant, State +import homeassistant.util.dt as dt_util + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="values") +def values_fixture() -> list[State]: + """Fixture for a list of test States.""" + values = [] + raw_values = [20, 19, 18, 21, 22, 0] + timestamp = dt_util.utcnow() + for val in raw_values: + values.append(State("sensor.test_monitored", str(val), last_updated=timestamp)) + timestamp += timedelta(minutes=1) + return values + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Automatically patch setup_entry.""" + with patch( + "homeassistant.components.filter.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="get_config") +async def get_config_to_integration_load() -> dict[str, Any]: + """Return configuration. + + To override the config, tests can be marked with: + @pytest.mark.parametrize("get_config", [{...}]) + """ + return { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + CONF_FILTER_WINDOW_SIZE: DEFAULT_WINDOW_SIZE, + CONF_FILTER_RADIUS: DEFAULT_FILTER_RADIUS, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + } + + +@pytest.fixture(name="loaded_entry") +async def load_integration( + hass: HomeAssistant, get_config: dict[str, Any], values: list[State] +) -> MockConfigEntry: + """Set up the Filter integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + options=get_config, + entry_id="1", + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + for value in values: + hass.states.async_set(get_config["entity_id"], value.state) + await hass.async_block_till_done() + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/filter/test_config_flow.py b/tests/components/filter/test_config_flow.py new file mode 100644 index 00000000000..d4a7f7a854f --- /dev/null +++ b/tests/components/filter/test_config_flow.py @@ -0,0 +1,227 @@ +"""Test the Filter config flow.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.filter.const import ( + CONF_FILTER_LOWER_BOUND, + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_RADIUS, + CONF_FILTER_TIME_CONSTANT, + CONF_FILTER_UPPER_BOUND, + CONF_FILTER_WINDOW_SIZE, + CONF_TIME_SMA_TYPE, + DEFAULT_FILTER_RADIUS, + DEFAULT_NAME, + DEFAULT_PRECISION, + DEFAULT_WINDOW_SIZE, + DOMAIN, + FILTER_NAME_LOWPASS, + FILTER_NAME_OUTLIER, + FILTER_NAME_RANGE, + FILTER_NAME_THROTTLE, + FILTER_NAME_TIME_SMA, + FILTER_NAME_TIME_THROTTLE, + TIME_SMA_LAST, +) +from homeassistant.components.recorder import Recorder +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entry_config", "options", "result_options"), + [ + ( + {CONF_FILTER_NAME: FILTER_NAME_OUTLIER}, + { + CONF_FILTER_WINDOW_SIZE: 1.0, + CONF_FILTER_RADIUS: 2.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + CONF_FILTER_WINDOW_SIZE: 1, + CONF_FILTER_RADIUS: 2.0, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_LOWPASS}, + { + CONF_FILTER_WINDOW_SIZE: 1.0, + CONF_FILTER_TIME_CONSTANT: 10.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_LOWPASS, + CONF_FILTER_WINDOW_SIZE: 1, + CONF_FILTER_TIME_CONSTANT: 10, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_RANGE}, + { + CONF_FILTER_LOWER_BOUND: 1.0, + CONF_FILTER_UPPER_BOUND: 10.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_RANGE, + CONF_FILTER_LOWER_BOUND: 1.0, + CONF_FILTER_UPPER_BOUND: 10.0, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_TIME_SMA}, + { + CONF_TIME_SMA_TYPE: TIME_SMA_LAST, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + { + CONF_FILTER_NAME: FILTER_NAME_TIME_SMA, + CONF_TIME_SMA_TYPE: TIME_SMA_LAST, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_THROTTLE}, + { + CONF_FILTER_WINDOW_SIZE: 1.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_THROTTLE, + CONF_FILTER_WINDOW_SIZE: 1, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_TIME_THROTTLE}, + { + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + { + CONF_FILTER_NAME: FILTER_NAME_TIME_THROTTLE, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + ), + ], +) +async def test_form( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + entry_config: dict[str, Any], + options: dict[str, Any], + result_options: dict[str, Any], +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + **entry_config, + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_FILTER_PRECISION: DEFAULT_PRECISION, **options}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["version"] == 1 + assert result["options"] == { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + **result_options, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options_flow( + recorder_mock: Recorder, hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test options flow.""" + + result = await hass.config_entries.options.async_init(loaded_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "outlier" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_FILTER_WINDOW_SIZE: 2.0, + CONF_FILTER_RADIUS: 3.0, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + CONF_FILTER_WINDOW_SIZE: 2, + CONF_FILTER_RADIUS: 3.0, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + } + + await hass.async_block_till_done() + + # Check the entity was updated, no new entity was created + assert len(hass.states.async_all()) == 2 + + state = hass.states.get("sensor.filtered_sensor") + assert state is not None + + +async def test_entry_already_exist( + recorder_mock: Recorder, hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test abort when entry already exist.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_FILTER_WINDOW_SIZE: DEFAULT_WINDOW_SIZE, + CONF_FILTER_RADIUS: DEFAULT_FILTER_RADIUS, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/filter/test_init.py b/tests/components/filter/test_init.py new file mode 100644 index 00000000000..a5d5cf84a67 --- /dev/null +++ b/tests/components/filter/test_init.py @@ -0,0 +1,20 @@ +"""Test Filter component setup process.""" + +from __future__ import annotations + +from homeassistant.components.recorder import Recorder +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_unload_entry( + recorder_mock: Recorder, hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test unload an entry.""" + + assert loaded_entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(loaded_entry.entry_id) + await hass.async_block_till_done() + assert loaded_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index 4312047278f..22db1c3cec2 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -6,8 +6,18 @@ from unittest.mock import patch import pytest from homeassistant import config as hass_config -from homeassistant.components.filter.sensor import ( +from homeassistant.components.filter.const import ( + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_WINDOW_SIZE, + CONF_TIME_SMA_TYPE, + DEFAULT_NAME, + DEFAULT_PRECISION, DOMAIN, + FILTER_NAME_TIME_SMA, + TIME_SMA_LAST, +) +from homeassistant.components.filter.sensor import ( LowPassFilter, OutlierFilter, RangeFilter, @@ -24,6 +34,8 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, + CONF_ENTITY_ID, + CONF_NAME, SERVICE_RELOAD, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -34,7 +46,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import assert_setup_component, get_fixture_path +from tests.common import MockConfigEntry, assert_setup_component, get_fixture_path @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -97,6 +109,41 @@ async def test_chain( assert state.state == "18.05" +async def test_from_config_entry( + recorder_mock: Recorder, + hass: HomeAssistant, + loaded_entry: MockConfigEntry, +) -> None: + """Test if filter works loaded from config entry.""" + + state = hass.states.get("sensor.filtered_sensor") + assert state.state == "22.0" + + +@pytest.mark.parametrize( + "get_config", + [ + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_TIME_SMA, + CONF_TIME_SMA_TYPE: TIME_SMA_LAST, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + } + ], +) +async def test_from_config_entry_duration( + recorder_mock: Recorder, + hass: HomeAssistant, + loaded_entry: MockConfigEntry, +) -> None: + """Test if filter works loaded from config entry with duration.""" + + state = hass.states.get("sensor.filtered_sensor") + assert state.state == "20.0" + + @pytest.mark.parametrize("missing", [True, False]) async def test_chain_history( recorder_mock: Recorder, From a6d132a3371d8e9f907c66ffd0cea88391963a64 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 10:41:33 +0100 Subject: [PATCH 1159/2987] Simplify device_info access in environment_canada (#136816) * Simplify device_info access in environment_canada * Update homeassistant/components/environment_canada/coordinator.py --------- Co-authored-by: Joost Lekkerkerker --- .../components/environment_canada/__init__.py | 14 +------------- .../components/environment_canada/camera.py | 3 +-- .../components/environment_canada/coordinator.py | 7 +++++++ .../components/environment_canada/sensor.py | 3 +-- .../components/environment_canada/weather.py | 3 +-- 5 files changed, 11 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index c87832de6ab..6afea2f983d 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -8,9 +8,8 @@ from env_canada import ECAirQuality, ECRadar, ECWeather from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from .const import CONF_STATION, DOMAIN +from .const import CONF_STATION from .coordinator import ECConfigEntry, ECDataUpdateCoordinator, ECRuntimeData DEFAULT_RADAR_UPDATE_INTERVAL = timedelta(minutes=5) @@ -81,14 +80,3 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) - - -def device_info(config_entry: ECConfigEntry) -> DeviceInfo: - """Build and return the device info for EC.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, config_entry.entry_id)}, - manufacturer="Environment Canada", - name=config_entry.title, - configuration_url="https://weather.gc.ca/", - ) diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index 4a321e88e6d..fd82ac97bea 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -13,7 +13,6 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import device_info from .const import ATTR_OBSERVATION_TIME from .coordinator import ECConfigEntry @@ -55,7 +54,7 @@ class ECCameraEntity(CoordinatorEntity, Camera): self._attr_unique_id = f"{coordinator.config_entry.unique_id}-radar" self._attr_attribution = self.radar_object.metadata["attribution"] self._attr_entity_registry_enabled_default = False - self._attr_device_info = device_info(coordinator.config_entry) + self._attr_device_info = coordinator.device_info self.content_type = "image/gif" diff --git a/homeassistant/components/environment_canada/coordinator.py b/homeassistant/components/environment_canada/coordinator.py index 8161e26028c..e65d8f6e471 100644 --- a/homeassistant/components/environment_canada/coordinator.py +++ b/homeassistant/components/environment_canada/coordinator.py @@ -11,6 +11,7 @@ from env_canada import ECAirQuality, ECRadar, ECWeather, ec_exc from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -52,6 +53,12 @@ class ECDataUpdateCoordinator[_ECDataTypeT: ECAirQuality | ECRadar | ECWeather]( ) self.ec_data = ec_data self.last_update_success = False + self.device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="Environment Canada", + configuration_url="https://weather.gc.ca/", + ) async def _async_update_data(self) -> _ECDataTypeT: """Fetch data from EC.""" diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 2d7a9648446..1485f890cd2 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -26,7 +26,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import device_info from .const import ATTR_STATION from .coordinator import ECConfigEntry @@ -282,7 +281,7 @@ class ECBaseSensorEntity(CoordinatorEntity, SensorEntity): self._ec_data = coordinator.ec_data self._attr_attribution = self._ec_data.metadata["attribution"] self._attr_unique_id = f"{coordinator.config_entry.title}-{description.key}" - self._attr_device_info = device_info(coordinator.config_entry) + self._attr_device_info = coordinator.device_info @property def native_value(self): diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index a5bc72856e7..5cfe32f18dd 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -37,7 +37,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import device_info from .const import DOMAIN from .coordinator import ECConfigEntry @@ -104,7 +103,7 @@ class ECWeatherEntity(SingleCoordinatorWeatherEntity): self._attr_unique_id = _calculate_unique_id( coordinator.config_entry.unique_id, False ) - self._attr_device_info = device_info(coordinator.config_entry) + self._attr_device_info = coordinator.device_info @property def native_temperature(self): From 646e0d46266550d168373412e8bee3edae95401c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 10:42:30 +0100 Subject: [PATCH 1160/2987] Bump aiohasupervisor to version 0.2.2b6 (#136814) --- homeassistant/components/hassio/backup.py | 1 + homeassistant/components/hassio/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/hassio/test_backup.py | 26 ++++++++++++++++++- 8 files changed, 32 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 4a9bfaded15..9362c03b0be 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -44,6 +44,7 @@ from .const import DOMAIN, EVENT_SUPERVISOR_EVENT from .handler import get_supervisor_client LOCATION_CLOUD_BACKUP = ".cloud_backup" +LOCATION_LOCAL = ".local" MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index c9ecf6657e8..ccc0f23fb43 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["aiohasupervisor==0.2.2b5"], + "requirements": ["aiohasupervisor==0.2.2b6"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f7f30bf7d71..f29c00244a6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 -aiohasupervisor==0.2.2b5 +aiohasupervisor==0.2.2b6 aiohttp-asyncmdnsresolver==0.0.1 aiohttp-fast-zlib==0.2.0 aiohttp==3.11.11 diff --git a/pyproject.toml b/pyproject.toml index 0e67a78954b..5393193a41e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 - "aiohasupervisor==0.2.2b5", + "aiohasupervisor==0.2.2b6", "aiohttp==3.11.11", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", diff --git a/requirements.txt b/requirements.txt index 2ffb530393e..a98d53b6037 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ # Home Assistant Core aiodns==3.2.0 -aiohasupervisor==0.2.2b5 +aiohasupervisor==0.2.2b6 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index d6fac067973..c8d7ccbe50f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -261,7 +261,7 @@ aioguardian==2022.07.0 aioharmony==0.4.1 # homeassistant.components.hassio -aiohasupervisor==0.2.2b5 +aiohasupervisor==0.2.2b6 # homeassistant.components.homekit_controller aiohomekit==3.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 366edfd23ce..1a5c9ba91b9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -246,7 +246,7 @@ aioguardian==2022.07.0 aioharmony==0.4.1 # homeassistant.components.hassio -aiohasupervisor==0.2.2b5 +aiohasupervisor==0.2.2b6 # homeassistant.components.homekit_controller aiohomekit==3.2.7 diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 8cf8d11af04..1a5701a79cf 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -35,7 +35,7 @@ from homeassistant.components.backup import ( Folder, ) from homeassistant.components.hassio import DOMAIN -from homeassistant.components.hassio.backup import LOCATION_CLOUD_BACKUP +from homeassistant.components.hassio.backup import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -53,6 +53,11 @@ TEST_BACKUP = supervisor_backups.Backup( ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), location=None, + location_attributes={ + LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, locations={None}, name="Test", protected=False, @@ -77,6 +82,7 @@ TEST_BACKUP_DETAILS = supervisor_backups.BackupComplete( homeassistant_exclude_database=False, homeassistant="2024.12.0", location=TEST_BACKUP.location, + location_attributes=TEST_BACKUP.location_attributes, locations=TEST_BACKUP.locations, name=TEST_BACKUP.name, protected=TEST_BACKUP.protected, @@ -97,6 +103,11 @@ TEST_BACKUP_2 = supervisor_backups.Backup( ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), location=None, + location_attributes={ + LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, locations={None}, name="Test", protected=False, @@ -121,6 +132,7 @@ TEST_BACKUP_DETAILS_2 = supervisor_backups.BackupComplete( homeassistant_exclude_database=False, homeassistant=None, location=TEST_BACKUP_2.location, + location_attributes=TEST_BACKUP_2.location_attributes, locations=TEST_BACKUP_2.locations, name=TEST_BACKUP_2.name, protected=TEST_BACKUP_2.protected, @@ -141,6 +153,11 @@ TEST_BACKUP_3 = supervisor_backups.Backup( ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), location="share", + location_attributes={ + LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, locations={"share"}, name="Test", protected=False, @@ -165,6 +182,7 @@ TEST_BACKUP_DETAILS_3 = supervisor_backups.BackupComplete( homeassistant_exclude_database=False, homeassistant=None, location=TEST_BACKUP_3.location, + location_attributes=TEST_BACKUP_3.location_attributes, locations=TEST_BACKUP_3.locations, name=TEST_BACKUP_3.name, protected=TEST_BACKUP_3.protected, @@ -186,6 +204,11 @@ TEST_BACKUP_4 = supervisor_backups.Backup( ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), location=None, + location_attributes={ + LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, locations={None}, name="Test", protected=False, @@ -210,6 +233,7 @@ TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete( homeassistant_exclude_database=True, homeassistant="2024.12.0", location=TEST_BACKUP.location, + location_attributes=TEST_BACKUP.location_attributes, locations=TEST_BACKUP.locations, name=TEST_BACKUP.name, protected=TEST_BACKUP.protected, From fe31dc936ce3c8af68966e5d5539cca1f8bbaba4 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 29 Jan 2025 10:49:49 +0100 Subject: [PATCH 1161/2987] Stop building wheels for 3.12 (#136811) --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index e8dafe88833..41e7b351184 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -131,7 +131,7 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp312", "cp313"] + abi: ["cp313"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository @@ -180,7 +180,7 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp312", "cp313"] + abi: ["cp313"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository From 60b6a11d4ec899ecd90f1054d1b8880a634749f9 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Wed, 29 Jan 2025 10:51:58 +0100 Subject: [PATCH 1162/2987] Add last restart sensor to HomeWizard (#136763) --- homeassistant/components/homewizard/sensor.py | 28 ++++++- .../components/homewizard/strings.json | 3 + .../homewizard/snapshots/test_sensor.ambr | 83 +++++++++++++++++++ tests/components/homewizard/test_sensor.py | 10 +++ 4 files changed, 122 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 02355bc6c5e..f47fcfc7ca7 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime, timedelta from typing import Final from homewizard_energy.models import CombinedModels, ExternalDevice @@ -33,6 +34,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import utcnow from . import HomeWizardConfigEntry from .const import DOMAIN @@ -48,7 +50,7 @@ class HomeWizardSensorEntityDescription(SensorEntityDescription): enabled_fn: Callable[[CombinedModels], bool] = lambda x: True has_fn: Callable[[CombinedModels], bool] - value_fn: Callable[[CombinedModels], StateType] + value_fn: Callable[[CombinedModels], StateType | datetime] @dataclass(frozen=True, kw_only=True) @@ -64,6 +66,15 @@ def to_percentage(value: float | None) -> float | None: return value * 100 if value is not None else None +def time_to_datetime(value: int | None) -> datetime | None: + """Convert seconds to datetime when value is not None.""" + return ( + utcnow().replace(microsecond=0) - timedelta(seconds=value) + if value is not None + else None + ) + + SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="smr_version", @@ -611,6 +622,19 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( has_fn=lambda data: data.measurement.cycles is not None, value_fn=lambda data: data.measurement.cycles, ), + HomeWizardSensorEntityDescription( + key="last_restart", + translation_key="last_restart", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=( + lambda data: data.system is not None and data.system.uptime_s is not None + ), + value_fn=( + lambda data: time_to_datetime(data.system.uptime_s) if data.system else None + ), + ), ) @@ -697,7 +721,7 @@ class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity): self._attr_entity_registry_enabled_default = False @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime | None: """Return the sensor value.""" return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index dbaef8439d9..645c4292ae1 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -137,6 +137,9 @@ }, "state_of_charge_pct": { "name": "State of charge" + }, + "last_restart": { + "name": "Last restart" } }, "switch": { diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index df445a9ddca..622c6d8a852 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -432,6 +432,89 @@ 'state': '50.0', }) # --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_last_restart:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_last_restart:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_last_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last restart', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_restart', + 'unique_id': 'HWE-P1_5c2fafabcdef_last_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_last_restart:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Device Last restart', + }), + 'context': , + 'entity_id': 'sensor.device_last_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-28T21:39:04+00:00', + }) +# --- # name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index d9698db7469..e4498d2d47a 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -19,6 +19,7 @@ pytestmark = [ ] +@pytest.mark.freeze_time("2025-01-28 21:45:00") @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("device_fixture", "entity_ids"), @@ -301,6 +302,7 @@ pytestmark = [ "sensor.device_frequency", "sensor.device_power", "sensor.device_state_of_charge", + "sensor.device_last_restart", "sensor.device_voltage", ], ), @@ -449,6 +451,7 @@ async def test_sensors( [ "sensor.device_current", "sensor.device_frequency", + "sensor.device_last_restart", "sensor.device_voltage", ], ), @@ -546,6 +549,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_last_restart", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -595,6 +599,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_last_restart", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -651,6 +656,7 @@ async def test_external_sensors_unreachable( "sensor.device_smart_meter_model", "sensor.device_state_of_charge", "sensor.device_tariff", + "sensor.device_last_restart", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -701,6 +707,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_last_restart", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -739,6 +746,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_last_restart", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -790,6 +798,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_last_restart", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -828,6 +837,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_last_restart", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", From 6b4ec3f3f4496a4d34ff237c1f0358819d5f581f Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 29 Jan 2025 09:55:19 +0000 Subject: [PATCH 1163/2987] Use translations for fan_speed in tplink vacuum entity (#136718) --- homeassistant/components/tplink/entity.py | 7 +++++- homeassistant/components/tplink/strings.json | 15 ++++++++++++ homeassistant/components/tplink/vacuum.py | 24 +++++++++++-------- tests/components/tplink/__init__.py | 2 +- .../tplink/snapshots/test_vacuum.ambr | 12 +++++----- tests/components/tplink/test_vacuum.py | 14 ++++++++--- 6 files changed, 53 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 6c21ab63285..15c07655e69 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -110,6 +110,9 @@ class TPLinkModuleEntityDescription(TPLinkEntityDescription): unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = ( lambda device, desc: f"{legacy_device_id(device)}-{desc.key}" ) + entity_name_fn: ( + Callable[[Device, TPLinkModuleEntityDescription], str | None] | None + ) = None def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( @@ -550,7 +553,9 @@ class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC): # the description should have a translation key. # HA logic is to name entities based on the following logic: # _attr_name > translation.name > description.name - if not description.translation_key: + if entity_name_fn := description.entity_name_fn: + self._attr_name = entity_name_fn(device, description) + elif not description.translation_key: if parent is None or parent.device_type is Device.Type.Hub: self._attr_name = None else: diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index fe661fa2529..fe1560b75d5 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -283,6 +283,21 @@ "clean_count": { "name": "Clean count" } + }, + "vacuum": { + "vacuum": { + "state_attributes": { + "fan_speed": { + "state": { + "quiet": "Quiet", + "standard": "Standard", + "turbo": "Turbo", + "max": "Max", + "ultra": "Ultra" + } + } + } + } } }, "device": { diff --git a/homeassistant/components/tplink/vacuum.py b/homeassistant/components/tplink/vacuum.py index 666584f4980..c62cd1d27c8 100644 --- a/homeassistant/components/tplink/vacuum.py +++ b/homeassistant/components/tplink/vacuum.py @@ -3,9 +3,9 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, cast +from typing import Any -from kasa import Device, Feature, Module +from kasa import Device, Module from kasa.smart.modules.clean import Clean, Status from homeassistant.components.vacuum import ( @@ -52,7 +52,10 @@ class TPLinkVacuumEntityDescription( VACUUM_DESCRIPTIONS: tuple[TPLinkVacuumEntityDescription, ...] = ( TPLinkVacuumEntityDescription( - key="vacuum", exists_fn=lambda dev, _: Module.Clean in dev.modules + key="vacuum", + translation_key="vacuum", + exists_fn=lambda dev, _: Module.Clean in dev.modules, + entity_name_fn=lambda _, __: None, ), ) @@ -97,7 +100,6 @@ class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity): | VacuumEntityFeature.START | VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.FAN_SPEED ) entity_description: TPLinkVacuumEntityDescription @@ -117,8 +119,11 @@ class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity): self._speaker_module = speaker self._attr_supported_features |= VacuumEntityFeature.LOCATE - # Needs to be initialized empty, as vacuumentity's capability_attributes accesses it - self._attr_fan_speed_list: list[str] = [] + if ( + fanspeed_feat := self._vacuum_module.get_feature("fan_speed_preset") + ) and fanspeed_feat.choices: + self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED + self._attr_fan_speed_list = [c.lower() for c in fanspeed_feat.choices] @async_refresh_after async def async_start(self) -> None: @@ -138,7 +143,7 @@ class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity): @async_refresh_after async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" - await self._vacuum_module.set_fan_speed_preset(fan_speed) + await self._vacuum_module.set_fan_speed_preset(fan_speed.capitalize()) async def async_locate(self, **kwargs: Any) -> None: """Locate the device.""" @@ -152,7 +157,6 @@ class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity): def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" self._attr_activity = STATUS_TO_ACTIVITY.get(self._vacuum_module.status) - fanspeeds = cast(Feature, self._vacuum_module.get_feature("fan_speed_preset")) - self._attr_fan_speed_list = cast(list[str], fanspeeds.choices) - self._attr_fan_speed = self._vacuum_module.fan_speed_preset + if self._vacuum_module.has_feature("fan_speed_preset"): + self._attr_fan_speed = self._vacuum_module.fan_speed_preset.lower() return True diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 028215dc157..4737d7432df 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -106,7 +106,7 @@ async def snapshot_platform( if entity_entry.translation_key: key = f"component.{DOMAIN}.entity.{entity_entry.domain}.{entity_entry.translation_key}.name" single_device_class_translation = False - if key not in translations and entity_entry.original_device_class: + if key not in translations: # No name translation if entity_entry.original_device_class not in unique_device_classes: single_device_class_translation = True unique_device_classes.append(entity_entry.original_device_class) diff --git a/tests/components/tplink/snapshots/test_vacuum.ambr b/tests/components/tplink/snapshots/test_vacuum.ambr index a28a7d80ab4..c0a48327e26 100644 --- a/tests/components/tplink/snapshots/test_vacuum.ambr +++ b/tests/components/tplink/snapshots/test_vacuum.ambr @@ -42,8 +42,8 @@ 'area_id': None, 'capabilities': dict({ 'fan_speed_list': list([ - 'Quiet', - 'Max', + 'quiet', + 'max', ]), }), 'config_entry_id': , @@ -68,7 +68,7 @@ 'platform': 'tplink', 'previous_unique_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'vacuum', 'unique_id': '123456789ABCDEFGH-vacuum', 'unit_of_measurement': None, }) @@ -78,10 +78,10 @@ 'attributes': ReadOnlyDict({ 'battery_icon': 'mdi:battery-charging-100', 'battery_level': 100, - 'fan_speed': 'Max', + 'fan_speed': 'max', 'fan_speed_list': list([ - 'Quiet', - 'Max', + 'quiet', + 'max', ]), 'friendly_name': 'my_vacuum', 'supported_features': , diff --git a/tests/components/tplink/test_vacuum.py b/tests/components/tplink/test_vacuum.py index aac7c4f7fc8..55bb8c0b504 100644 --- a/tests/components/tplink/test_vacuum.py +++ b/tests/components/tplink/test_vacuum.py @@ -19,7 +19,11 @@ from homeassistant.components.vacuum import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + translation, +) from . import DEVICE_ID, _mocked_device, setup_platform_for_device, snapshot_platform @@ -59,8 +63,12 @@ async def test_vacuum( state = hass.states.get(ENTITY_ID) assert state.state == VacuumActivity.DOCKED - assert state.attributes[ATTR_FAN_SPEED] == "Max" + assert state.attributes[ATTR_FAN_SPEED] == "max" assert state.attributes[ATTR_BATTERY_LEVEL] == 100 + result = translation.async_translate_state( + hass, "max", "vacuum", "tplink", "vacuum.state_attributes.fan_speed", None + ) + assert result == "Max" async def test_states( @@ -90,7 +98,7 @@ async def test_states( SERVICE_SET_FAN_SPEED, Module.Clean, "set_fan_speed_preset", - {ATTR_FAN_SPEED: "Quiet"}, + {ATTR_FAN_SPEED: "quiet"}, ), (SERVICE_LOCATE, Module.Speaker, "locate", {}), ], From c312796aae7d8de860bad42018dbf99d67f37170 Mon Sep 17 00:00:00 2001 From: Iskra kranj <162285659+iskrakranj@users.noreply.github.com> Date: Wed, 29 Jan 2025 10:57:22 +0100 Subject: [PATCH 1164/2987] Bump pyiskra to 0.1.15 (#136810) --- homeassistant/components/iskra/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iskra/manifest.json b/homeassistant/components/iskra/manifest.json index 94f20b4d93c..caa176ab6b6 100644 --- a/homeassistant/components/iskra/manifest.json +++ b/homeassistant/components/iskra/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyiskra"], - "requirements": ["pyiskra==0.1.14"] + "requirements": ["pyiskra==0.1.15"] } diff --git a/requirements_all.txt b/requirements_all.txt index c8d7ccbe50f..0b36e1fb84e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2023,7 +2023,7 @@ pyiqvia==2022.04.0 pyirishrail==0.0.2 # homeassistant.components.iskra -pyiskra==0.1.14 +pyiskra==0.1.15 # homeassistant.components.iss pyiss==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a5c9ba91b9..86ed63fe404 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1646,7 +1646,7 @@ pyipp==0.17.0 pyiqvia==2022.04.0 # homeassistant.components.iskra -pyiskra==0.1.14 +pyiskra==0.1.15 # homeassistant.components.iss pyiss==1.0.1 From e27a980742e949036b401d69cab1fbb19ed9013a Mon Sep 17 00:00:00 2001 From: Andrew Onyshchuk Date: Wed, 29 Jan 2025 01:57:49 -0800 Subject: [PATCH 1165/2987] vesync: report current humidity (#136799) --- homeassistant/components/vesync/humidifier.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index 8557c7a8866..86e0d6b5d87 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -129,6 +129,11 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): """Return the available mist modes.""" return self._available_modes + @property + def current_humidity(self) -> int: + """Return the current humidity.""" + return self.device.humidity + @property def target_humidity(self) -> int: """Return the humidity we try to reach.""" From ce432555f040cedc9b79bfc8650b5e56ae2d2f7f Mon Sep 17 00:00:00 2001 From: cdnninja Date: Wed, 29 Jan 2025 02:59:34 -0700 Subject: [PATCH 1166/2987] Add binary sensor platform to VeSync (#134221) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/vesync/__init__.py | 1 + .../components/vesync/binary_sensor.py | 106 ++++++++++++++++++ homeassistant/components/vesync/common.py | 22 ++++ homeassistant/components/vesync/strings.json | 8 ++ tests/components/vesync/test_diagnostics.py | 3 + tests/components/vesync/test_init.py | 2 + 6 files changed, 142 insertions(+) create mode 100644 homeassistant/components/vesync/binary_sensor.py diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 240a793f518..27e626faeac 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -21,6 +21,7 @@ from .const import ( from .coordinator import VeSyncDataCoordinator PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, diff --git a/homeassistant/components/vesync/binary_sensor.py b/homeassistant/components/vesync/binary_sensor.py new file mode 100644 index 00000000000..dd1b6398c06 --- /dev/null +++ b/homeassistant/components/vesync/binary_sensor.py @@ -0,0 +1,106 @@ +"""Binary Sensor for VeSync.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +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 AddEntitiesCallback + +from .common import rgetattr +from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class VeSyncBinarySensorEntityDescription(BinarySensorEntityDescription): + """A class that describes custom binary sensor entities.""" + + is_on: Callable[[VeSyncBaseDevice], bool] + + +SENSOR_DESCRIPTIONS: tuple[VeSyncBinarySensorEntityDescription, ...] = ( + VeSyncBinarySensorEntityDescription( + key="water_lacks", + translation_key="water_lacks", + is_on=lambda device: device.water_lacks, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + VeSyncBinarySensorEntityDescription( + key="details.water_tank_lifted", + translation_key="water_tank_lifted", + is_on=lambda device: device.details["water_tank_lifted"], + device_class=BinarySensorDeviceClass.PROBLEM, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary_sensor platform.""" + + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + + @callback + def discover(devices): + """Add new devices to platform.""" + _setup_entities(devices, async_add_entities) + + 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, async_add_entities, coordinator): + """Add entity.""" + async_add_entities( + ( + VeSyncBinarySensor(dev, description, coordinator) + for dev in devices + for description in SENSOR_DESCRIPTIONS + if rgetattr(dev, description.key) is not None + ), + ) + + +class VeSyncBinarySensor(BinarySensorEntity, VeSyncBaseEntity): + """Vesync binary sensor class.""" + + entity_description: VeSyncBinarySensorEntityDescription + + def __init__( + self, + device: VeSyncBaseDevice, + description: VeSyncBinarySensorEntityDescription, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}-{description.key}" + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + _LOGGER.debug(rgetattr(self.device, self.entity_description.key)) + return self.entity_description.is_on(self.device) diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index c51b6a913d3..e2f4e1db2e4 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -12,6 +12,28 @@ from .const import VeSyncHumidifierDevice _LOGGER = logging.getLogger(__name__) +def rgetattr(obj: object, attr: str): + """Return a string in the form word.1.2.3 and return the item as 3. Note that this last value could be in a dict as well.""" + _this_func = rgetattr + sp = attr.split(".", 1) + if len(sp) == 1: + left, right = sp[0], "" + else: + left, right = sp + + if isinstance(obj, dict): + obj = obj.get(left) + elif hasattr(obj, left): + obj = getattr(obj, left) + else: + return None + + if right: + obj = _this_func(obj, right) + + return obj + + async def async_generate_device_list( hass: HomeAssistant, manager: VeSync ) -> list[VeSyncBaseDevice]: diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index 87a8ea8746e..3eb2a0c3fd5 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -43,6 +43,14 @@ "name": "Current voltage" } }, + "binary_sensor": { + "water_lacks": { + "name": "Low water" + }, + "water_tank_lifted": { + "name": "Water tank lifted" + } + }, "number": { "mist_level": { "name": "Mist level" diff --git a/tests/components/vesync/test_diagnostics.py b/tests/components/vesync/test_diagnostics.py index b948053c3a0..25aa5337281 100644 --- a/tests/components/vesync/test_diagnostics.py +++ b/tests/components/vesync/test_diagnostics.py @@ -101,6 +101,9 @@ async def test_async_get_device_diagnostics__single_fan( "home_assistant.entities.2.state.last_changed": (str,), "home_assistant.entities.2.state.last_reported": (str,), "home_assistant.entities.2.state.last_updated": (str,), + "home_assistant.entities.3.state.last_changed": (str,), + "home_assistant.entities.3.state.last_reported": (str,), + "home_assistant.entities.3.state.last_updated": (str,), } ) ) diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index 7873b911f6f..883e850fc62 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -48,6 +48,7 @@ async def test_async_setup_entry__no_devices( assert setups_mock.call_count == 1 assert setups_mock.call_args.args[0] == config_entry assert setups_mock.call_args.args[1] == [ + Platform.BINARY_SENSOR, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, @@ -78,6 +79,7 @@ async def test_async_setup_entry__loads_fans( assert setups_mock.call_count == 1 assert setups_mock.call_args.args[0] == config_entry assert setups_mock.call_args.args[1] == [ + Platform.BINARY_SENSOR, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, From 04d1d80917f48665af929a377f2d5b644ea9e2e8 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Wed, 29 Jan 2025 11:06:39 +0100 Subject: [PATCH 1167/2987] Add diagnostics for Cookidoo integration (#136770) Co-authored-by: Joost Lekkerkerker --- .../components/cookidoo/coordinator.py | 3 ++ .../components/cookidoo/diagnostics.py | 26 +++++++++++ .../components/cookidoo/quality_scale.yaml | 2 +- tests/components/cookidoo/conftest.py | 5 ++- .../cookidoo/fixtures/user_info.json | 7 +++ .../cookidoo/snapshots/test_diagnostics.ambr | 43 +++++++++++++++++++ tests/components/cookidoo/test_diagnostics.py | 29 +++++++++++++ 7 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/cookidoo/diagnostics.py create mode 100644 tests/components/cookidoo/fixtures/user_info.json create mode 100644 tests/components/cookidoo/snapshots/test_diagnostics.ambr create mode 100644 tests/components/cookidoo/test_diagnostics.py diff --git a/homeassistant/components/cookidoo/coordinator.py b/homeassistant/components/cookidoo/coordinator.py index f99f58c2dd6..2ce61306afe 100644 --- a/homeassistant/components/cookidoo/coordinator.py +++ b/homeassistant/components/cookidoo/coordinator.py @@ -14,6 +14,7 @@ from cookidoo_api import ( CookidooIngredientItem, CookidooRequestException, CookidooSubscription, + CookidooUserInfo, ) from homeassistant.config_entries import ConfigEntry @@ -42,6 +43,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): """A Cookidoo Data Update Coordinator.""" config_entry: CookidooConfigEntry + user: CookidooUserInfo def __init__( self, hass: HomeAssistant, cookidoo: Cookidoo, entry: CookidooConfigEntry @@ -59,6 +61,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): async def _async_setup(self) -> None: try: await self.cookidoo.login() + self.user = await self.cookidoo.get_user_info() except CookidooRequestException as e: raise UpdateFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/cookidoo/diagnostics.py b/homeassistant/components/cookidoo/diagnostics.py new file mode 100644 index 00000000000..f981317df19 --- /dev/null +++ b/homeassistant/components/cookidoo/diagnostics.py @@ -0,0 +1,26 @@ +"""Diagnostics for the Cookidoo integration.""" + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from .coordinator import CookidooConfigEntry + +TO_REDACT = [ + CONF_PASSWORD, +] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: CookidooConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "data": asdict(entry.runtime_data.data), + "user": asdict(entry.runtime_data.user), + } diff --git a/homeassistant/components/cookidoo/quality_scale.yaml b/homeassistant/components/cookidoo/quality_scale.yaml index 95a35829079..209f2ce5686 100644 --- a/homeassistant/components/cookidoo/quality_scale.yaml +++ b/homeassistant/components/cookidoo/quality_scale.yaml @@ -63,7 +63,7 @@ rules: stale-devices: status: exempt comment: No stale entities possible - diagnostics: todo + diagnostics: done exception-translations: done icon-translations: done reconfiguration-flow: done diff --git a/tests/components/cookidoo/conftest.py b/tests/components/cookidoo/conftest.py index 096b2abf958..7d84e7ac83e 100644 --- a/tests/components/cookidoo/conftest.py +++ b/tests/components/cookidoo/conftest.py @@ -9,6 +9,7 @@ from cookidoo_api import ( CookidooAuthResponse, CookidooIngredientItem, CookidooSubscription, + CookidooUserInfo, ) import pytest @@ -58,7 +59,9 @@ def mock_cookidoo_client() -> Generator[AsyncMock]: client.get_active_subscription.return_value = CookidooSubscription( **load_json_object_fixture("subscriptions.json", DOMAIN)["data"] ) - + client.get_user_info.return_value = CookidooUserInfo( + **load_json_object_fixture("user_info.json", DOMAIN)["data"] + ) client.login.return_value = CookidooAuthResponse( **load_json_object_fixture("login.json", DOMAIN) ) diff --git a/tests/components/cookidoo/fixtures/user_info.json b/tests/components/cookidoo/fixtures/user_info.json new file mode 100644 index 00000000000..1c99ae84823 --- /dev/null +++ b/tests/components/cookidoo/fixtures/user_info.json @@ -0,0 +1,7 @@ +{ + "data": { + "username": "username_1234", + "description": null, + "picture": null + } +} diff --git a/tests/components/cookidoo/snapshots/test_diagnostics.ambr b/tests/components/cookidoo/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..3dc799c1108 --- /dev/null +++ b/tests/components/cookidoo/snapshots/test_diagnostics.ambr @@ -0,0 +1,43 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'additional_items': list([ + dict({ + 'id': 'unique_id_tomaten', + 'is_owned': False, + 'name': 'Tomaten', + }), + ]), + 'ingredient_items': list([ + dict({ + 'description': '200 g', + 'id': 'unique_id_mehl', + 'is_owned': False, + 'name': 'Mehl', + }), + ]), + 'subscription': dict({ + 'active': True, + 'expires': '2025-12-16T23:59:00Z', + 'extended_type': 'REGULAR', + 'start_date': '2024-12-16T00:00:00Z', + 'status': 'ACTIVE', + 'subscription_level': 'FULL', + 'subscription_source': 'COMMERCE', + 'type': 'REGULAR', + }), + }), + 'entry_data': dict({ + 'country': 'CH', + 'email': 'test-email', + 'language': 'de-CH', + 'password': '**REDACTED**', + }), + 'user': dict({ + 'description': None, + 'picture': None, + 'username': 'username_1234', + }), + }) +# --- diff --git a/tests/components/cookidoo/test_diagnostics.py b/tests/components/cookidoo/test_diagnostics.py new file mode 100644 index 00000000000..c253e1f6e09 --- /dev/null +++ b/tests/components/cookidoo/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for the diagnostics data provided by the Cookidoo integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, cookidoo_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, cookidoo_config_entry) + == snapshot + ) From b73203fdf6b919c938b293e89dc407eb2788c9d3 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Wed, 29 Jan 2025 05:06:59 -0500 Subject: [PATCH 1168/2987] Use the new hybrid Hydrawise client (#136522) Co-authored-by: Joost Lekkerkerker --- .../components/hydrawise/__init__.py | 19 +++-- .../components/hydrawise/config_flow.py | 42 +++++++---- .../components/hydrawise/coordinator.py | 8 +-- .../components/hydrawise/strings.json | 12 +++- homeassistant/components/hydrawise/switch.py | 6 +- tests/components/hydrawise/conftest.py | 7 +- .../components/hydrawise/test_config_flow.py | 72 ++++++++++++++----- 7 files changed, 118 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index ea5a5801e69..ee5a8a66610 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -1,9 +1,9 @@ """Support for Hydrawise cloud.""" -from pydrawise import auth, client +from pydrawise import auth, hybrid from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -21,16 +21,21 @@ PLATFORMS: list[Platform] = [ Platform.VALVE, ] +_REQUIRED_AUTH_KEYS = (CONF_USERNAME, CONF_PASSWORD, CONF_API_KEY) + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Hydrawise from a config entry.""" - if CONF_USERNAME not in config_entry.data or CONF_PASSWORD not in config_entry.data: - # The GraphQL API requires username and password to authenticate. If either is - # missing, reauth is required. + if any(k not in config_entry.data for k in _REQUIRED_AUTH_KEYS): + # If we are missing any required authentication keys, trigger a reauth flow. raise ConfigEntryAuthFailed - hydrawise = client.Hydrawise( - auth.Auth(config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD]), + hydrawise = hybrid.HybridClient( + auth.HybridAuth( + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + config_entry.data[CONF_API_KEY], + ), app_id=APP_ID, ) diff --git a/homeassistant/components/hydrawise/config_flow.py b/homeassistant/components/hydrawise/config_flow.py index ed21e96cd0b..3a61908ee2d 100644 --- a/homeassistant/components/hydrawise/config_flow.py +++ b/homeassistant/components/hydrawise/config_flow.py @@ -6,25 +6,32 @@ from collections.abc import Mapping from typing import Any from aiohttp import ClientError -from pydrawise import auth as pydrawise_auth, client +from pydrawise import auth as pydrawise_auth, hybrid from pydrawise.exceptions import NotAuthorizedError import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME from .const import APP_ID, DOMAIN, LOGGER STEP_USER_DATA_SCHEMA = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_API_KEY): str, + } +) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_PASSWORD): str, vol.Required(CONF_API_KEY): str} ) -STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Hydrawise.""" VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -34,14 +41,19 @@ class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN): return self._show_user_form({}) username = user_input[CONF_USERNAME] password = user_input[CONF_PASSWORD] - unique_id, errors = await _authenticate(username, password) + api_key = user_input[CONF_API_KEY] + unique_id, errors = await _authenticate(username, password, api_key) if errors: return self._show_user_form(errors) await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() return self.async_create_entry( title=username, - data={CONF_USERNAME: username, CONF_PASSWORD: password}, + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_API_KEY: api_key, + }, ) def _show_user_form(self, errors: dict[str, str]) -> ConfigFlowResult: @@ -65,14 +77,20 @@ class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN): reauth_entry = self._get_reauth_entry() username = reauth_entry.data[CONF_USERNAME] password = user_input[CONF_PASSWORD] - user_id, errors = await _authenticate(username, password) + api_key = user_input[CONF_API_KEY] + user_id, errors = await _authenticate(username, password, api_key) if user_id is None: return self._show_reauth_form(errors) await self.async_set_unique_id(user_id) self._abort_if_unique_id_mismatch(reason="wrong_account") return self.async_update_reload_and_abort( - reauth_entry, data={CONF_USERNAME: username, CONF_PASSWORD: password} + reauth_entry, + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_API_KEY: api_key, + }, ) def _show_reauth_form(self, errors: dict[str, str]) -> ConfigFlowResult: @@ -82,14 +100,14 @@ class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN): async def _authenticate( - username: str, password: str + username: str, password: str, api_key: str ) -> tuple[str | None, dict[str, str]]: """Authenticate with the Hydrawise API.""" unique_id = None errors: dict[str, str] = {} - auth = pydrawise_auth.Auth(username, password) + auth = pydrawise_auth.HybridAuth(username, password, api_key) try: - await auth.token() + await auth.check() except NotAuthorizedError: errors["base"] = "invalid_auth" except TimeoutError: @@ -99,7 +117,7 @@ async def _authenticate( return unique_id, errors try: - api = client.Hydrawise(auth, app_id=APP_ID) + api = hybrid.HybridClient(auth, app_id=APP_ID) # Don't fetch zones because we don't need them yet. user = await api.get_user(fetch_zones=False) except TimeoutError: diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index e82a4ec1588..4721a9fb154 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from pydrawise import Hydrawise +from pydrawise import HydrawiseBase from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone from homeassistant.core import HomeAssistant @@ -38,7 +38,7 @@ class HydrawiseUpdateCoordinators: class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]): """Base class for Hydrawise Data Update Coordinators.""" - api: Hydrawise + api: HydrawiseBase class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): @@ -49,7 +49,7 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): integration are updated in a timely manner. """ - def __init__(self, hass: HomeAssistant, api: Hydrawise) -> None: + def __init__(self, hass: HomeAssistant, api: HydrawiseBase) -> None: """Initialize HydrawiseDataUpdateCoordinator.""" super().__init__(hass, LOGGER, name=DOMAIN, update_interval=MAIN_SCAN_INTERVAL) self.api = api @@ -82,7 +82,7 @@ class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): def __init__( self, hass: HomeAssistant, - api: Hydrawise, + api: HydrawiseBase, main_coordinator: HydrawiseMainDataUpdateCoordinator, ) -> None: """Initialize HydrawiseWaterUseDataUpdateCoordinator.""" diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json index 74c63cbe758..47543aa2f8f 100644 --- a/homeassistant/components/hydrawise/strings.json +++ b/homeassistant/components/hydrawise/strings.json @@ -6,14 +6,22 @@ "description": "Please provide the username and password for your Hydrawise cloud account:", "data": { "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "You can generate an API Key in the 'Account Details' section of the Hydrawise app" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Hydrawise integration needs to re-authenticate your account", "data": { - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::hydrawise::config::step::user::data_description::api_key%]" } } }, diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 1addaf1ec92..62cd81a0481 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import timedelta from typing import Any -from pydrawise import Hydrawise, Zone +from pydrawise import HydrawiseBase, Zone from homeassistant.components.switch import ( SwitchDeviceClass, @@ -28,8 +28,8 @@ from .entity import HydrawiseEntity class HydrawiseSwitchEntityDescription(SwitchEntityDescription): """Describes Hydrawise binary sensor.""" - turn_on_fn: Callable[[Hydrawise, Zone], Coroutine[Any, Any, None]] - turn_off_fn: Callable[[Hydrawise, Zone], Coroutine[Any, Any, None]] + turn_on_fn: Callable[[HydrawiseBase, Zone], Coroutine[Any, Any, None]] + turn_off_fn: Callable[[HydrawiseBase, Zone], Coroutine[Any, Any, None]] value_fn: Callable[[Zone], bool] diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py index 2de7fb1da9a..ad3a97fa6e0 100644 --- a/tests/components/hydrawise/conftest.py +++ b/tests/components/hydrawise/conftest.py @@ -63,7 +63,7 @@ def mock_pydrawise( controller_water_use_summary: ControllerWaterUseSummary, ) -> Generator[AsyncMock]: """Mock Hydrawise.""" - with patch("pydrawise.client.Hydrawise", autospec=True) as mock_pydrawise: + with patch("pydrawise.hybrid.HybridClient", autospec=True) as mock_pydrawise: user.controllers = [controller] controller.sensors = sensors mock_pydrawise.return_value.get_user.return_value = user @@ -76,8 +76,8 @@ def mock_pydrawise( @pytest.fixture def mock_auth() -> Generator[AsyncMock]: - """Mock pydrawise Auth.""" - with patch("pydrawise.auth.Auth", autospec=True) as mock_auth: + """Mock pydrawise HybridAuth.""" + with patch("pydrawise.auth.HybridAuth", autospec=True) as mock_auth: yield mock_auth.return_value @@ -215,6 +215,7 @@ def mock_config_entry() -> MockConfigEntry: data={ CONF_USERNAME: "asfd@asdf.com", CONF_PASSWORD: "__password__", + CONF_API_KEY: "abc123", }, unique_id="hydrawise-customerid", version=1, diff --git a/tests/components/hydrawise/test_config_flow.py b/tests/components/hydrawise/test_config_flow.py index cf723d885e1..594286b7f01 100644 --- a/tests/components/hydrawise/test_config_flow.py +++ b/tests/components/hydrawise/test_config_flow.py @@ -9,7 +9,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.hydrawise.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -35,7 +35,11 @@ async def test_form( result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"}, + { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + }, ) mock_pydrawise.get_user.return_value = user await hass.async_block_till_done() @@ -45,9 +49,10 @@ async def test_form( assert result["data"] == { CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", } assert len(mock_setup_entry.mock_calls) == 1 - mock_auth.token.assert_awaited_once_with() + mock_auth.check.assert_awaited_once_with() mock_pydrawise.get_user.assert_awaited_once_with(fetch_zones=False) @@ -60,7 +65,11 @@ async def test_form_api_error( init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"} + data = { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + } result = await hass.config_entries.flow.async_configure( init_result["flow_id"], data ) @@ -77,11 +86,18 @@ async def test_form_auth_connect_timeout( hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock ) -> None: """Test we handle connection timeout errors.""" - mock_auth.token.side_effect = TimeoutError + mock_auth.check.side_effect = TimeoutError init_result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={ + "source": config_entries.SOURCE_USER, + }, ) - data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"} + data = { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + } result = await hass.config_entries.flow.async_configure( init_result["flow_id"], data ) @@ -89,7 +105,7 @@ async def test_form_auth_connect_timeout( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "timeout_connect"} - mock_auth.token.reset_mock(side_effect=True) + mock_auth.check.reset_mock(side_effect=True) result = await hass.config_entries.flow.async_configure(result["flow_id"], data) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -102,7 +118,11 @@ async def test_form_client_connect_timeout( init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"} + data = { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + } result = await hass.config_entries.flow.async_configure( init_result["flow_id"], data ) @@ -120,19 +140,23 @@ async def test_form_not_authorized_error( hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock ) -> None: """Test we handle API errors.""" - mock_auth.token.side_effect = NotAuthorizedError + mock_auth.check.side_effect = NotAuthorizedError init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"} + data = { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + } result = await hass.config_entries.flow.async_configure( init_result["flow_id"], data ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} - mock_auth.token.reset_mock(side_effect=True) + mock_auth.check.reset_mock(side_effect=True) result = await hass.config_entries.flow.async_configure(result["flow_id"], data) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -150,6 +174,7 @@ async def test_reauth( data={ CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "bad-password", + CONF_API_KEY: "__api-key__", }, unique_id="hydrawise-12345", ) @@ -165,7 +190,11 @@ async def test_reauth( mock_pydrawise.get_user.return_value = user result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PASSWORD: "__password__"} + result["flow_id"], + { + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + }, ) await hass.async_block_till_done() @@ -183,6 +212,7 @@ async def test_reauth_fails( data={ CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "bad-password", + CONF_API_KEY: "__api-key__", }, unique_id="hydrawise-12345", ) @@ -191,18 +221,26 @@ async def test_reauth_fails( result = await mock_config_entry.start_reauth_flow(hass) assert result["step_id"] == "reauth_confirm" - mock_auth.token.side_effect = NotAuthorizedError + mock_auth.check.side_effect = NotAuthorizedError result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PASSWORD: "__password__"} + result["flow_id"], + { + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + }, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} - mock_auth.token.reset_mock(side_effect=True) + mock_auth.check.reset_mock(side_effect=True) mock_pydrawise.get_user.return_value = user result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PASSWORD: "__password__"} + result["flow_id"], + { + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + }, ) assert result["type"] is FlowResultType.ABORT From 5e6f4a374e096e33a9717ffd0529f3c71c186162 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 29 Jan 2025 11:13:55 +0100 Subject: [PATCH 1169/2987] Bump deebot-client to 11.1.0b1 (#136818) --- 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 157d5b4a5ea..188f59f74e4 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==11.0.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==11.1.0b1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0b36e1fb84e..b483ba42e6a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -747,7 +747,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==11.0.0 +deebot-client==11.1.0b1 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 86ed63fe404..e92e8cf6ca3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -637,7 +637,7 @@ dbus-fast==2.30.2 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==11.0.0 +deebot-client==11.1.0b1 # homeassistant.components.ihc # homeassistant.components.namecheapdns From bfa7eaa221aa8be5df850faf1363d3d029c5678d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:31:54 +0100 Subject: [PATCH 1170/2987] Improve type hints in environment_canada sensors (#136813) * Use TypeVar * Use bound for TypeVar * Remove PEP 695 syntax * Add type alias to use new TypeVar syntax --------- Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- .../environment_canada/coordinator.py | 11 +++++---- .../components/environment_canada/sensor.py | 24 ++++++++++++++----- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/environment_canada/coordinator.py b/homeassistant/components/environment_canada/coordinator.py index e65d8f6e471..e31e847cd2d 100644 --- a/homeassistant/components/environment_canada/coordinator.py +++ b/homeassistant/components/environment_canada/coordinator.py @@ -19,6 +19,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) type ECConfigEntry = ConfigEntry[ECRuntimeData] +type ECDataType = ECAirQuality | ECRadar | ECWeather @dataclass @@ -30,16 +31,16 @@ class ECRuntimeData: weather_coordinator: ECDataUpdateCoordinator[ECWeather] -class ECDataUpdateCoordinator[_ECDataTypeT: ECAirQuality | ECRadar | ECWeather]( - DataUpdateCoordinator[_ECDataTypeT] -): +class ECDataUpdateCoordinator[DataT: ECDataType](DataUpdateCoordinator[DataT]): """Class to manage fetching EC data.""" + config_entry: ECConfigEntry + def __init__( self, hass: HomeAssistant, entry: ECConfigEntry, - ec_data: _ECDataTypeT, + ec_data: DataT, name: str, update_interval: timedelta, ) -> None: @@ -60,7 +61,7 @@ class ECDataUpdateCoordinator[_ECDataTypeT: ECAirQuality | ECRadar | ECWeather]( configuration_url="https://weather.gc.ca/", ) - async def _async_update_data(self) -> _ECDataTypeT: + async def _async_update_data(self) -> DataT: """Fetch data from EC.""" try: await self.ec_data.update() diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 1485f890cd2..989667fb1ac 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -6,6 +6,8 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any +from env_canada import ECWeather + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -27,7 +29,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_STATION -from .coordinator import ECConfigEntry +from .coordinator import ECConfigEntry, ECDataType, ECDataUpdateCoordinator ATTR_TIME = "alert time" @@ -268,13 +270,19 @@ async def async_setup_entry( async_add_entities(sensors) -class ECBaseSensorEntity(CoordinatorEntity, SensorEntity): +class ECBaseSensorEntity[DataT: ECDataType]( + CoordinatorEntity[ECDataUpdateCoordinator[DataT]], SensorEntity +): """Environment Canada sensor base.""" entity_description: ECSensorEntityDescription _attr_has_entity_name = True - def __init__(self, coordinator, description): + def __init__( + self, + coordinator: ECDataUpdateCoordinator[DataT], + description: ECSensorEntityDescription, + ) -> None: """Initialize the base sensor.""" super().__init__(coordinator) self.entity_description = description @@ -292,10 +300,14 @@ class ECBaseSensorEntity(CoordinatorEntity, SensorEntity): return value -class ECSensorEntity(ECBaseSensorEntity): +class ECSensorEntity[DataT: ECDataType](ECBaseSensorEntity[DataT]): """Environment Canada sensor for conditions.""" - def __init__(self, coordinator, description): + def __init__( + self, + coordinator: ECDataUpdateCoordinator[DataT], + description: ECSensorEntityDescription, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator, description) self._attr_extra_state_attributes = { @@ -304,7 +316,7 @@ class ECSensorEntity(ECBaseSensorEntity): } -class ECAlertSensorEntity(ECBaseSensorEntity): +class ECAlertSensorEntity(ECBaseSensorEntity[ECWeather]): """Environment Canada sensor for alerts.""" @property From a9433ca6977521b4c5f70139fd09e0807210b39a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:36:22 +0100 Subject: [PATCH 1171/2987] Standardize homeassistant imports in component (e-f) (#136824) --- homeassistant/components/ebox/sensor.py | 2 +- homeassistant/components/ebusd/__init__.py | 2 +- homeassistant/components/ebusd/sensor.py | 3 +-- homeassistant/components/ecoal_boiler/__init__.py | 2 +- homeassistant/components/ecobee/climate.py | 7 +++++-- homeassistant/components/eddystone_temperature/sensor.py | 2 +- homeassistant/components/edimax/switch.py | 2 +- homeassistant/components/egardia/__init__.py | 3 +-- homeassistant/components/eliqonline/sensor.py | 2 +- homeassistant/components/elkm1/__init__.py | 2 +- homeassistant/components/elkm1/alarm_control_panel.py | 3 +-- homeassistant/components/elv/__init__.py | 3 +-- homeassistant/components/emby/media_player.py | 4 ++-- homeassistant/components/emoncms/sensor.py | 3 +-- homeassistant/components/emoncms_history/__init__.py | 3 +-- homeassistant/components/emulated_hue/__init__.py | 2 +- homeassistant/components/emulated_kasa/__init__.py | 3 +-- homeassistant/components/emulated_roku/__init__.py | 2 +- homeassistant/components/enocean/__init__.py | 2 +- homeassistant/components/enocean/binary_sensor.py | 2 +- homeassistant/components/enocean/light.py | 2 +- homeassistant/components/enocean/sensor.py | 2 +- homeassistant/components/enphase_envoy/coordinator.py | 2 +- homeassistant/components/entur_public_transport/sensor.py | 5 ++--- homeassistant/components/envisalink/__init__.py | 2 +- homeassistant/components/envisalink/alarm_control_panel.py | 2 +- homeassistant/components/ephember/climate.py | 2 +- homeassistant/components/esphome/__init__.py | 2 +- homeassistant/components/esphome/datetime.py | 2 +- homeassistant/components/esphome/entity.py | 7 +++++-- homeassistant/components/esphome/manager.py | 7 +++++-- homeassistant/components/etherscan/sensor.py | 2 +- homeassistant/components/eufy/__init__.py | 3 +-- homeassistant/components/evohome/__init__.py | 5 ++--- homeassistant/components/evohome/climate.py | 2 +- homeassistant/components/evohome/entity.py | 2 +- homeassistant/components/evohome/helpers.py | 2 +- homeassistant/components/evohome/water_heater.py | 2 +- homeassistant/components/ezviz/siren.py | 2 +- homeassistant/components/facebook/notify.py | 2 +- homeassistant/components/fail2ban/sensor.py | 2 +- homeassistant/components/familyhub/camera.py | 2 +- homeassistant/components/feedreader/config_flow.py | 2 +- homeassistant/components/ffmpeg_motion/binary_sensor.py | 2 +- homeassistant/components/ffmpeg_noise/binary_sensor.py | 2 +- homeassistant/components/fido/sensor.py | 2 +- homeassistant/components/file/notify.py | 2 +- homeassistant/components/filesize/coordinator.py | 2 +- homeassistant/components/filter/sensor.py | 4 ++-- homeassistant/components/fints/sensor.py | 2 +- homeassistant/components/fivem/config_flow.py | 2 +- homeassistant/components/fixer/sensor.py | 2 +- homeassistant/components/fleetgo/device_tracker.py | 2 +- homeassistant/components/flexit/climate.py | 2 +- homeassistant/components/flic/binary_sensor.py | 2 +- homeassistant/components/flo/coordinator.py | 2 +- homeassistant/components/flock/notify.py | 2 +- homeassistant/components/flux_led/light.py | 3 +-- homeassistant/components/folder/sensor.py | 2 +- homeassistant/components/fortios/device_tracker.py | 2 +- homeassistant/components/foursquare/__init__.py | 2 +- homeassistant/components/free_mobile/notify.py | 2 +- homeassistant/components/freebox/sensor.py | 2 +- homeassistant/components/freedns/__init__.py | 2 +- homeassistant/components/fully_kiosk/image.py | 2 +- homeassistant/components/fully_kiosk/services.py | 3 +-- homeassistant/components/futurenow/light.py | 2 +- 67 files changed, 83 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py index 691e9dd8275..a7628e78a9a 100644 --- a/homeassistant/components/ebox/sensor.py +++ b/homeassistant/components/ebox/sensor.py @@ -29,8 +29,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/ebusd/__init__.py b/homeassistant/components/ebusd/__init__.py index c9386999fae..4cb8d92c391 100644 --- a/homeassistant/components/ebusd/__init__.py +++ b/homeassistant/components/ebusd/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/ebusd/sensor.py b/homeassistant/components/ebusd/sensor.py index 2eaaddf7e2f..ccd04be585e 100644 --- a/homeassistant/components/ebusd/sensor.py +++ b/homeassistant/components/ebusd/sensor.py @@ -9,8 +9,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/ecoal_boiler/__init__.py b/homeassistant/components/ecoal_boiler/__init__.py index e9b519c7095..0648dfb56bf 100644 --- a/homeassistant/components/ecoal_boiler/__init__.py +++ b/homeassistant/components/ecoal_boiler/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 4e32990a661..743e2e1ba4b 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -32,8 +32,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import device_registry as dr, entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_platform, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py index 5dc30a575d7..1047c52e111 100644 --- a/homeassistant/components/eddystone_temperature/sensor.py +++ b/homeassistant/components/eddystone_temperature/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/edimax/switch.py b/homeassistant/components/edimax/switch.py index e0d063eb9fd..5482143fc37 100644 --- a/homeassistant/components/edimax/switch.py +++ b/homeassistant/components/edimax/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/egardia/__init__.py b/homeassistant/components/egardia/__init__.py index 89dae7d23c9..eb6b4cd49d8 100644 --- a/homeassistant/components/egardia/__init__.py +++ b/homeassistant/components/egardia/__init__.py @@ -16,8 +16,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/eliqonline/sensor.py b/homeassistant/components/eliqonline/sensor.py index 7c9f76824e8..1a5490da0a5 100644 --- a/homeassistant/components/eliqonline/sensor.py +++ b/homeassistant/components/eliqonline/sensor.py @@ -16,8 +16,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, UnitOfPower from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 34a35fbeb09..5286b7ad66f 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -32,7 +32,7 @@ from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.network import is_ip_address from .const import ( diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index f1ecf626263..ab51b6fe281 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -19,8 +19,7 @@ from homeassistant.components.alarm_control_panel import ( CodeFormat, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import VolDictType diff --git a/homeassistant/components/elv/__init__.py b/homeassistant/components/elv/__init__.py index 208d19a0f8e..97f08c786f4 100644 --- a/homeassistant/components/elv/__init__.py +++ b/homeassistant/components/elv/__init__.py @@ -4,8 +4,7 @@ import voluptuous as vol from homeassistant.const import CONF_DEVICE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType DOMAIN = "elv" diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py index 21ee6449c11..812e58ecc19 100644 --- a/homeassistant/components/emby/media_player.py +++ b/homeassistant/components/emby/media_player.py @@ -24,10 +24,10 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 291ecad0bd3..1920e06a8e8 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -38,8 +38,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/emoncms_history/__init__.py b/homeassistant/components/emoncms_history/__init__.py index 00af1fec6c6..2ab00d6ca42 100644 --- a/homeassistant/components/emoncms_history/__init__.py +++ b/homeassistant/components/emoncms_history/__init__.py @@ -16,8 +16,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.event import track_point_in_time from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 3e229d07b6c..556831496c6 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .config import ( diff --git a/homeassistant/components/emulated_kasa/__init__.py b/homeassistant/components/emulated_kasa/__init__.py index 408d8c4eff8..11f4ce80490 100644 --- a/homeassistant/components/emulated_kasa/__init__.py +++ b/homeassistant/components/emulated_kasa/__init__.py @@ -15,8 +15,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.template import Template, is_template_string from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/emulated_roku/__init__.py b/homeassistant/components/emulated_roku/__init__.py index 4ebd31730bf..d4466f47ef2 100644 --- a/homeassistant/components/emulated_roku/__init__.py +++ b/homeassistant/components/emulated_roku/__init__.py @@ -7,7 +7,7 @@ from homeassistant.components.network import async_get_source_ip from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .binding import EmulatedRoku diff --git a/homeassistant/components/enocean/__init__.py b/homeassistant/components/enocean/__init__.py index 9f53c79cc5b..c1db27c1c34 100644 --- a/homeassistant/components/enocean/__init__.py +++ b/homeassistant/components/enocean/__init__.py @@ -5,7 +5,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DATA_ENOCEAN, DOMAIN, ENOCEAN_DONGLE diff --git a/homeassistant/components/enocean/binary_sensor.py b/homeassistant/components/enocean/binary_sensor.py index 01e39f96510..26039036ca0 100644 --- a/homeassistant/components/enocean/binary_sensor.py +++ b/homeassistant/components/enocean/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/enocean/light.py b/homeassistant/components/enocean/light.py index aae84e73848..6586714c1b6 100644 --- a/homeassistant/components/enocean/light.py +++ b/homeassistant/components/enocean/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 98e32ce1a4f..2a4b9364d81 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index d92b998e731..8eb2b32ac7b 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN, INVALID_AUTH_ERRORS diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py index f88bb99cea0..8fa8a06e369 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -20,12 +20,11 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util API_CLIENT_NAME = "homeassistant-{}" diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py index 0146b650c22..919704a6728 100644 --- a/homeassistant/components/envisalink/__init__.py +++ b/homeassistant/components/envisalink/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py index ce65178b8d8..9d1b6d0d7a1 100644 --- a/homeassistant/components/envisalink/alarm_control_panel.py +++ b/homeassistant/components/envisalink/alarm_control_panel.py @@ -14,7 +14,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.const import ATTR_ENTITY_ID, CONF_CODE from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py index cedad8b76e2..f92be005db6 100644 --- a/homeassistant/components/ephember/climate.py +++ b/homeassistant/components/ephember/climate.py @@ -33,7 +33,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 5934c9a6f68..fee2531fa20 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import ( __version__ as ha_version, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN diff --git a/homeassistant/components/esphome/datetime.py b/homeassistant/components/esphome/datetime.py index 20d0d651bba..d1bb0bb77ff 100644 --- a/homeassistant/components/esphome/datetime.py +++ b/homeassistant/components/esphome/datetime.py @@ -8,7 +8,7 @@ from functools import partial from aioesphomeapi import DateTimeInfo, DateTimeState from homeassistant.components.datetime import DateTimeEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index ae9e0d2491d..ff08e5f578a 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -19,8 +19,11 @@ import voluptuous as vol from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_platform, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 494df51721a..93d6c53e590 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -41,8 +41,11 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import TemplateError -from homeassistant.helpers import device_registry as dr, template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + template, +) from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.issue_registry import ( diff --git a/homeassistant/components/etherscan/sensor.py b/homeassistant/components/etherscan/sensor.py index e64b596a119..3e48307e8bf 100644 --- a/homeassistant/components/etherscan/sensor.py +++ b/homeassistant/components/etherscan/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TOKEN from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/eufy/__init__.py b/homeassistant/components/eufy/__init__.py index 8ebe3e08843..57f90503049 100644 --- a/homeassistant/components/eufy/__init__.py +++ b/homeassistant/components/eufy/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType DOMAIN = "eufy" diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 612131919d4..97f7c2db54d 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -29,16 +29,15 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service import verify_domain_control from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( ACCESS_TOKEN, diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index c71831fa4bc..64e7367bc32 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -34,7 +34,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( ATTR_DURATION_DAYS, diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index b5842c1073a..a42d8ef7582 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -16,7 +16,7 @@ from evohomeasync2.schema.const import ( from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import EvoBroker, EvoService from .const import DOMAIN diff --git a/homeassistant/components/evohome/helpers.py b/homeassistant/components/evohome/helpers.py index f84d2945779..0e2de36eb47 100644 --- a/homeassistant/components/evohome/helpers.py +++ b/homeassistant/components/evohome/helpers.py @@ -11,7 +11,7 @@ from typing import Any import evohomeasync2 as evo from homeassistant.const import CONF_SCAN_INTERVAL -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index a50e16b5dda..2c3cf9de12d 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -29,7 +29,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN, EVO_FOLLOW, EVO_PERMOVER from .entity import EvoChild diff --git a/homeassistant/components/ezviz/siren.py b/homeassistant/components/ezviz/siren.py index a52e499eee2..5a612aa0772 100644 --- a/homeassistant/components/ezviz/siren.py +++ b/homeassistant/components/ezviz/siren.py @@ -16,8 +16,8 @@ from homeassistant.components.siren import ( from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import event as evt from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.event as evt from homeassistant.helpers.restore_state import RestoreEntity from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator diff --git a/homeassistant/components/facebook/notify.py b/homeassistant/components/facebook/notify.py index 3319f6bdebd..edd46d24982 100644 --- a/homeassistant/components/facebook/notify.py +++ b/homeassistant/components/facebook/notify.py @@ -17,7 +17,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fail2ban/sensor.py b/homeassistant/components/fail2ban/sensor.py index 9e6d23556d2..d71d404c7a0 100644 --- a/homeassistant/components/fail2ban/sensor.py +++ b/homeassistant/components/fail2ban/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_FILE_PATH, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/familyhub/camera.py b/homeassistant/components/familyhub/camera.py index 462983278b0..6be13b23568 100644 --- a/homeassistant/components/familyhub/camera.py +++ b/homeassistant/components/familyhub/camera.py @@ -11,8 +11,8 @@ from homeassistant.components.camera import ( ) from homeassistant.const import CONF_IP_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py index f3e56ad1778..3d0fec1a6f5 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, diff --git a/homeassistant/components/ffmpeg_motion/binary_sensor.py b/homeassistant/components/ffmpeg_motion/binary_sensor.py index 7dc32fd96a3..3adae8441df 100644 --- a/homeassistant/components/ffmpeg_motion/binary_sensor.py +++ b/homeassistant/components/ffmpeg_motion/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.components.ffmpeg import ( ) from homeassistant.const import CONF_NAME, CONF_REPEAT from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ffmpeg_noise/binary_sensor.py b/homeassistant/components/ffmpeg_noise/binary_sensor.py index abbf77eba6b..1623d7c7660 100644 --- a/homeassistant/components/ffmpeg_noise/binary_sensor.py +++ b/homeassistant/components/ffmpeg_noise/binary_sensor.py @@ -22,7 +22,7 @@ from homeassistant.components.ffmpeg import ( from homeassistant.components.ffmpeg_motion.binary_sensor import FFmpegBinarySensor from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py index bc6e6340111..86e81a596d7 100644 --- a/homeassistant/components/fido/sensor.py +++ b/homeassistant/components/fido/sensor.py @@ -28,8 +28,8 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index 10e3d4a4ac6..3d61dbb04e0 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_FILE_PATH, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONF_TIMESTAMP, DEFAULT_NAME, DOMAIN, FILE_ICON diff --git a/homeassistant/components/filesize/coordinator.py b/homeassistant/components/filesize/coordinator.py index 8350cee91bf..0c2a0277434 100644 --- a/homeassistant/components/filesize/coordinator.py +++ b/homeassistant/components/filesize/coordinator.py @@ -9,7 +9,7 @@ import pathlib from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 5bb6cadabc7..330e61f499e 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -43,14 +43,14 @@ from homeassistant.core import ( State, callback, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.util import dt as dt_util from homeassistant.util.decorator import Registry -import homeassistant.util.dt as dt_util from .const import ( CONF_FILTER_LOWER_BOUND, diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index c85f08ba3d0..318325dbb09 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_PIN, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/fivem/config_flow.py b/homeassistant/components/fivem/config_flow.py index b5ced70b846..d5132627b9d 100644 --- a/homeassistant/components/fivem/config_flow.py +++ b/homeassistant/components/fivem/config_flow.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py index f8b4546d4c7..3fb241208ad 100644 --- a/homeassistant/components/fixer/sensor.py +++ b/homeassistant/components/fixer/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_TARGET from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/fleetgo/device_tracker.py b/homeassistant/components/fleetgo/device_tracker.py index 008c0765c07..71f6c174dde 100644 --- a/homeassistant/components/fleetgo/device_tracker.py +++ b/homeassistant/components/fleetgo/device_tracker.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_utc_time_change from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py index 8be5df4eca7..32c94638b1f 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -29,7 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/flic/binary_sensor.py b/homeassistant/components/flic/binary_sensor.py index cd160480674..281e960f222 100644 --- a/homeassistant/components/flic/binary_sensor.py +++ b/homeassistant/components/flic/binary_sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/flo/coordinator.py b/homeassistant/components/flo/coordinator.py index 0edb80004fd..d0dd38bd490 100644 --- a/homeassistant/components/flo/coordinator.py +++ b/homeassistant/components/flo/coordinator.py @@ -12,7 +12,7 @@ from orjson import JSONDecodeError from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN as FLO_DOMAIN, LOGGER diff --git a/homeassistant/components/flock/notify.py b/homeassistant/components/flock/notify.py index 811ee51749c..f50e04cba36 100644 --- a/homeassistant/components/flock/notify.py +++ b/homeassistant/components/flock/notify.py @@ -14,8 +14,8 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index ca7fb7aeea2..2a0b5795970 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -25,8 +25,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_EFFECT from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/folder/sensor.py b/homeassistant/components/folder/sensor.py index 3a8a4fdc380..4667a6c348d 100644 --- a/homeassistant/components/folder/sensor.py +++ b/homeassistant/components/folder/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfInformation from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/fortios/device_tracker.py b/homeassistant/components/fortios/device_tracker.py index af2bc92a065..4360dd031c7 100644 --- a/homeassistant/components/fortios/device_tracker.py +++ b/homeassistant/components/fortios/device_tracker.py @@ -19,7 +19,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_TOKEN, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/foursquare/__init__.py b/homeassistant/components/foursquare/__init__.py index 12a29fd632e..25effac073d 100644 --- a/homeassistant/components/foursquare/__init__.py +++ b/homeassistant/components/foursquare/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/free_mobile/notify.py b/homeassistant/components/free_mobile/notify.py index 90c8ef3246e..c7e3071c771 100644 --- a/homeassistant/components/free_mobile/notify.py +++ b/homeassistant/components/free_mobile/notify.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 097c8c138ee..588992a7f21 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN from .entity import FreeboxHomeEntity diff --git a/homeassistant/components/freedns/__init__.py b/homeassistant/components/freedns/__init__.py index 5338c0d0700..460ad163f61 100644 --- a/homeassistant/components/freedns/__init__.py +++ b/homeassistant/components/freedns/__init__.py @@ -9,8 +9,8 @@ import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL, CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/fully_kiosk/image.py b/homeassistant/components/fully_kiosk/image.py index 00318a77ab5..e1a4240c9e9 100644 --- a/homeassistant/components/fully_kiosk/image.py +++ b/homeassistant/components/fully_kiosk/image.py @@ -12,7 +12,7 @@ from homeassistant.components.image import ImageEntity, ImageEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FullyKioskConfigEntry from .coordinator import FullyKioskDataUpdateCoordinator diff --git a/homeassistant/components/fully_kiosk/services.py b/homeassistant/components/fully_kiosk/services.py index bff78aa627a..ac6faf76a9d 100644 --- a/homeassistant/components/fully_kiosk/services.py +++ b/homeassistant/components/fully_kiosk/services.py @@ -8,8 +8,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ( ATTR_APPLICATION, diff --git a/homeassistant/components/futurenow/light.py b/homeassistant/components/futurenow/light.py index d1ad6f42083..e9dcfd7a151 100644 --- a/homeassistant/components/futurenow/light.py +++ b/homeassistant/components/futurenow/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType From 4f6a5bb65b416d2ac121154ca4e99488efdc3ab2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:37:16 +0100 Subject: [PATCH 1172/2987] Standardize homeassistant imports in component (c-d) (#136823) --- homeassistant/components/caldav/calendar.py | 2 +- homeassistant/components/canary/__init__.py | 2 +- homeassistant/components/cast/media_player.py | 2 +- homeassistant/components/ccm15/config_flow.py | 2 +- homeassistant/components/cisco_ios/device_tracker.py | 2 +- .../components/cisco_mobility_express/device_tracker.py | 2 +- homeassistant/components/cisco_webex_teams/notify.py | 2 +- homeassistant/components/citybikes/sensor.py | 2 +- homeassistant/components/clementine/media_player.py | 2 +- homeassistant/components/clickatell/notify.py | 2 +- homeassistant/components/clicksend/notify.py | 2 +- homeassistant/components/clicksend_tts/notify.py | 2 +- homeassistant/components/cmus/media_player.py | 2 +- homeassistant/components/co2signal/config_flow.py | 2 +- homeassistant/components/coinbase/config_flow.py | 2 +- homeassistant/components/color_extractor/__init__.py | 3 +-- homeassistant/components/comed_hourly_pricing/sensor.py | 2 +- homeassistant/components/comelit/config_flow.py | 2 +- homeassistant/components/comfoconnect/__init__.py | 3 +-- homeassistant/components/comfoconnect/sensor.py | 2 +- homeassistant/components/command_line/__init__.py | 3 +-- homeassistant/components/concord232/alarm_control_panel.py | 2 +- homeassistant/components/concord232/binary_sensor.py | 4 ++-- homeassistant/components/counter/__init__.py | 3 +-- homeassistant/components/cppm_tracker/device_tracker.py | 2 +- homeassistant/components/cups/sensor.py | 2 +- homeassistant/components/currencylayer/sensor.py | 2 +- homeassistant/components/danfoss_air/__init__.py | 3 +-- homeassistant/components/datadog/__init__.py | 3 +-- homeassistant/components/ddwrt/device_tracker.py | 2 +- homeassistant/components/debugpy/__init__.py | 2 +- homeassistant/components/deconz/sensor.py | 2 +- homeassistant/components/decora/light.py | 2 +- homeassistant/components/decora_wifi/light.py | 2 +- homeassistant/components/delijn/sensor.py | 2 +- homeassistant/components/deluge/config_flow.py | 2 +- homeassistant/components/denon/media_player.py | 2 +- homeassistant/components/device_sun_light_trigger/__init__.py | 4 ++-- homeassistant/components/devolo_home_network/image.py | 2 +- homeassistant/components/digital_ocean/__init__.py | 2 +- homeassistant/components/digital_ocean/binary_sensor.py | 2 +- homeassistant/components/digital_ocean/switch.py | 2 +- homeassistant/components/discogs/sensor.py | 2 +- .../components/dlib_face_identify/image_processing.py | 2 +- homeassistant/components/dnsip/config_flow.py | 2 +- homeassistant/components/dominos/__init__.py | 2 +- homeassistant/components/doods/image_processing.py | 3 +-- homeassistant/components/doorbird/__init__.py | 3 +-- homeassistant/components/doorbird/camera.py | 2 +- homeassistant/components/dovado/__init__.py | 2 +- homeassistant/components/dovado/sensor.py | 2 +- homeassistant/components/downloader/__init__.py | 2 +- homeassistant/components/dremel_3d_printer/config_flow.py | 2 +- homeassistant/components/dublin_bus_transport/sensor.py | 4 ++-- homeassistant/components/duckdns/__init__.py | 2 +- homeassistant/components/dwd_weather_warnings/config_flow.py | 3 +-- homeassistant/components/dweet/__init__.py | 3 +-- homeassistant/components/dweet/sensor.py | 2 +- 58 files changed, 61 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index fb53947a723..c2bf1b2dce1 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/canary/__init__.py b/homeassistant/components/canary/__init__.py index a28c37580ce..b0e59e49a6f 100644 --- a/homeassistant/components/canary/__init__.py +++ b/homeassistant/components/canary/__init__.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 28db97a857d..3cc17fae43b 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -53,7 +53,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.network import NoURLAvailableError, get_url, is_hass_url -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.logging import async_create_catching_coro from .const import ( diff --git a/homeassistant/components/ccm15/config_flow.py b/homeassistant/components/ccm15/config_flow.py index 0e49e0929e5..c059796045c 100644 --- a/homeassistant/components/ccm15/config_flow.py +++ b/homeassistant/components/ccm15/config_flow.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DEFAULT_TIMEOUT, DOMAIN diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py index b882f046a8e..0477ebb111c 100644 --- a/homeassistant/components/cisco_ios/device_tracker.py +++ b/homeassistant/components/cisco_ios/device_tracker.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cisco_mobility_express/device_tracker.py b/homeassistant/components/cisco_mobility_express/device_tracker.py index 2c7398ae172..78bbcc9edbc 100644 --- a/homeassistant/components/cisco_mobility_express/device_tracker.py +++ b/homeassistant/components/cisco_mobility_express/device_tracker.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cisco_webex_teams/notify.py b/homeassistant/components/cisco_webex_teams/notify.py index 74d033c62d4..2f76ed8f65a 100644 --- a/homeassistant/components/cisco_webex_teams/notify.py +++ b/homeassistant/components/cisco_webex_teams/notify.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index 6cd401989c8..e08b651dd70 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -28,8 +28,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval diff --git a/homeassistant/components/clementine/media_player.py b/homeassistant/components/clementine/media_player.py index 233ffc840c0..04c1305cb13 100644 --- a/homeassistant/components/clementine/media_player.py +++ b/homeassistant/components/clementine/media_player.py @@ -17,7 +17,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/clickatell/notify.py b/homeassistant/components/clickatell/notify.py index c8d96d48faf..9a5a5160ada 100644 --- a/homeassistant/components/clickatell/notify.py +++ b/homeassistant/components/clickatell/notify.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_API_KEY, CONF_RECIPIENT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/clicksend/notify.py b/homeassistant/components/clicksend/notify.py index d00d7b413cc..53f16875d6f 100644 --- a/homeassistant/components/clicksend/notify.py +++ b/homeassistant/components/clicksend/notify.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONTENT_TYPE_JSON, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/clicksend_tts/notify.py b/homeassistant/components/clicksend_tts/notify.py index 6b5f2040448..5a08ccd7988 100644 --- a/homeassistant/components/clicksend_tts/notify.py +++ b/homeassistant/components/clicksend_tts/notify.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONTENT_TYPE_JSON, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cmus/media_player.py b/homeassistant/components/cmus/media_player.py index d55e9ca8f0b..a1f303809d0 100644 --- a/homeassistant/components/cmus/media_player.py +++ b/homeassistant/components/cmus/media_player.py @@ -17,7 +17,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 0d357cce199..530496811d9 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -20,8 +20,8 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, ) +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 2b58f2b2f37..3234ec29679 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFl from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from . import CoinbaseConfigEntry, get_accounts from .const import ( diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index 81cd55564b9..775adb6a7d5 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -17,8 +17,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import SERVICE_TURN_ON as LIGHT_SERVICE_TURN_ON from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import aiohttp_client -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_TURN_ON diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py index b47255828e8..ac217eeb353 100644 --- a/homeassistant/components/comed_hourly_pricing/sensor.py +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -17,8 +17,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_OFFSET, CURRENCY_CENT, UnitOfEnergy from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index 46fc13796a0..f29cc62136b 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN diff --git a/homeassistant/components/comfoconnect/__init__.py b/homeassistant/components/comfoconnect/__init__.py index 4e0671fd134..b28f7a748e1 100644 --- a/homeassistant/components/comfoconnect/__init__.py +++ b/homeassistant/components/comfoconnect/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py index 6a15e37f3f1..fbe958e6d67 100644 --- a/homeassistant/components/comfoconnect/sensor.py +++ b/homeassistant/components/comfoconnect/sensor.py @@ -48,7 +48,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index 2440fcde76c..1832e83e7dd 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -52,8 +52,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant, ServiceCall -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service diff --git a/homeassistant/components/concord232/alarm_control_panel.py b/homeassistant/components/concord232/alarm_control_panel.py index 02453b56376..61cf2aebb31 100644 --- a/homeassistant/components/concord232/alarm_control_panel.py +++ b/homeassistant/components/concord232/alarm_control_panel.py @@ -18,7 +18,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.const import CONF_CODE, CONF_HOST, CONF_MODE, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/concord232/binary_sensor.py b/homeassistant/components/concord232/binary_sensor.py index 2b86e72e63c..a60cf31a646 100644 --- a/homeassistant/components/concord232/binary_sensor.py +++ b/homeassistant/components/concord232/binary_sensor.py @@ -17,10 +17,10 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index f0a14aa7951..e84a92328b2 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -16,8 +16,7 @@ from homeassistant.const import ( CONF_NAME, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store diff --git a/homeassistant/components/cppm_tracker/device_tracker.py b/homeassistant/components/cppm_tracker/device_tracker.py index b6fdc0a8889..3b2682d4e32 100644 --- a/homeassistant/components/cppm_tracker/device_tracker.py +++ b/homeassistant/components/cppm_tracker/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_API_KEY, CONF_CLIENT_ID, CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType SCAN_INTERVAL = timedelta(seconds=120) diff --git a/homeassistant/components/cups/sensor.py b/homeassistant/components/cups/sensor.py index 7f45e99f93d..701bad3f104 100644 --- a/homeassistant/components/cups/sensor.py +++ b/homeassistant/components/cups/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_HOST, CONF_PORT, PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py index 01dec10efe0..7c985b12ba4 100644 --- a/homeassistant/components/currencylayer/sensor.py +++ b/homeassistant/components/currencylayer/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_BASE, CONF_NAME, CONF_QUOTE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/danfoss_air/__init__.py b/homeassistant/components/danfoss_air/__init__.py index 5e4880705d5..d7c16d5da09 100644 --- a/homeassistant/components/danfoss_air/__init__.py +++ b/homeassistant/components/danfoss_air/__init__.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/datadog/__init__.py b/homeassistant/components/datadog/__init__.py index 2d550e48e2f..fa852399b09 100644 --- a/homeassistant/components/datadog/__init__.py +++ b/homeassistant/components/datadog/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py index d72496e4d1e..e93b7e14e05 100644 --- a/homeassistant/components/ddwrt/device_tracker.py +++ b/homeassistant/components/ddwrt/device_tracker.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/debugpy/__init__.py b/homeassistant/components/debugpy/__init__.py index 5caf517a483..cef98211d9e 100644 --- a/homeassistant/components/debugpy/__init__.py +++ b/homeassistant/components/debugpy/__init__.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 576d356bca9..3003fb1008d 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -52,7 +52,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import DeconzConfigEntry from .const import ATTR_DARK, ATTR_ON diff --git a/homeassistant/components/decora/light.py b/homeassistant/components/decora/light.py index cef7b98a2c1..a7d14b83aca 100644 --- a/homeassistant/components/decora/light.py +++ b/homeassistant/components/decora/light.py @@ -21,7 +21,7 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.const import CONF_API_KEY, CONF_DEVICES, CONF_NAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv if TYPE_CHECKING: from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/decora_wifi/light.py b/homeassistant/components/decora_wifi/light.py index 63ab5c2bf02..9ad1d9ced04 100644 --- a/homeassistant/components/decora_wifi/light.py +++ b/homeassistant/components/decora_wifi/light.py @@ -22,7 +22,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/delijn/sensor.py b/homeassistant/components/delijn/sensor.py index 017a4c5b2fa..7f94f272c0d 100644 --- a/homeassistant/components/delijn/sensor.py +++ b/homeassistant/components/delijn/sensor.py @@ -16,8 +16,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/deluge/config_flow.py b/homeassistant/components/deluge/config_flow.py index d58f23464d1..19afe26e8f9 100644 --- a/homeassistant/components/deluge/config_flow.py +++ b/homeassistant/components/deluge/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_WEB_PORT, diff --git a/homeassistant/components/denon/media_player.py b/homeassistant/components/denon/media_player.py index 2f46cd42294..9e7cebe0702 100644 --- a/homeassistant/components/denon/media_player.py +++ b/homeassistant/components/denon/media_player.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/device_sun_light_trigger/__init__.py b/homeassistant/components/device_sun_light_trigger/__init__.py index 6781b9afaf7..ee427eb1ba6 100644 --- a/homeassistant/components/device_sun_light_trigger/__init__.py +++ b/homeassistant/components/device_sun_light_trigger/__init__.py @@ -29,14 +29,14 @@ from homeassistant.const import ( SUN_EVENT_SUNSET, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import ( async_track_point_in_utc_time, async_track_state_change_event, ) from homeassistant.helpers.sun import get_astral_event_next, is_up from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util DOMAIN = "device_sun_light_trigger" CONF_DEVICE_GROUP = "device_group" diff --git a/homeassistant/components/devolo_home_network/image.py b/homeassistant/components/devolo_home_network/image.py index 240686ed3bb..91e8dd83b7d 100644 --- a/homeassistant/components/devolo_home_network/image.py +++ b/homeassistant/components/devolo_home_network/image.py @@ -13,7 +13,7 @@ from homeassistant.components.image import ImageEntity, ImageEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import DevoloHomeNetworkConfigEntry from .const import IMAGE_GUEST_WIFI, SWITCH_GUEST_WIFI diff --git a/homeassistant/components/digital_ocean/__init__.py b/homeassistant/components/digital_ocean/__init__.py index e5b62d430b6..306ddc8e9a5 100644 --- a/homeassistant/components/digital_ocean/__init__.py +++ b/homeassistant/components/digital_ocean/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/digital_ocean/binary_sensor.py b/homeassistant/components/digital_ocean/binary_sensor.py index 0d4b31faa2c..f0bb6eba049 100644 --- a/homeassistant/components/digital_ocean/binary_sensor.py +++ b/homeassistant/components/digital_ocean/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/digital_ocean/switch.py b/homeassistant/components/digital_ocean/switch.py index 856c9301cfd..409fa63c1c2 100644 --- a/homeassistant/components/digital_ocean/switch.py +++ b/homeassistant/components/digital_ocean/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import ( SwitchEntity, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/discogs/sensor.py b/homeassistant/components/discogs/sensor.py index 3cea6ec4dac..3c64b9020c3 100644 --- a/homeassistant/components/discogs/sensor.py +++ b/homeassistant/components/discogs/sensor.py @@ -16,8 +16,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/dlib_face_identify/image_processing.py b/homeassistant/components/dlib_face_identify/image_processing.py index e17f892a7fe..fee9f8dab3c 100644 --- a/homeassistant/components/dlib_face_identify/image_processing.py +++ b/homeassistant/components/dlib_face_identify/image_processing.py @@ -15,7 +15,7 @@ from homeassistant.components.image_processing import ( ) from homeassistant.const import ATTR_NAME, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index 8c2cfa5e556..9e98178e718 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_HOSTNAME, diff --git a/homeassistant/components/dominos/__init__.py b/homeassistant/components/dominos/__init__.py index 9b11b667e84..6fccecfec5c 100644 --- a/homeassistant/components/dominos/__init__.py +++ b/homeassistant/components/dominos/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components import http from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py index 7b055c6dd05..bcc6e7a8050 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -25,8 +25,7 @@ from homeassistant.const import ( CONF_URL, ) from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.pil import draw_box diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index c943fa68766..5090f309c49 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -17,9 +17,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import CONF_EVENTS, DOMAIN, PLATFORMS diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index 640d6630c18..45f37527ac1 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -10,7 +10,7 @@ import aiohttp from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .entity import DoorBirdEntity from .models import DoorBirdConfigEntry, DoorBirdData diff --git a/homeassistant/components/dovado/__init__.py b/homeassistant/components/dovado/__init__.py index 5f63bbd0b2b..0a5fb602a08 100644 --- a/homeassistant/components/dovado/__init__.py +++ b/homeassistant/components/dovado/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( DEVICE_DEFAULT_NAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py index 013b51bfc8f..e35fdeb2dc0 100644 --- a/homeassistant/components/dovado/sensor.py +++ b/homeassistant/components/dovado/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_SENSORS, PERCENTAGE, UnitOfInformation from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py index 75e1103a712..1a45886879a 100644 --- a/homeassistant/components/downloader/__init__.py +++ b/homeassistant/components/downloader/__init__.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_register_admin_service from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path diff --git a/homeassistant/components/dremel_3d_printer/config_flow.py b/homeassistant/components/dremel_3d_printer/config_flow.py index 913180db0f7..0cb5c7d9cfc 100644 --- a/homeassistant/components/dremel_3d_printer/config_flow.py +++ b/homeassistant/components/dremel_3d_printer/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index 5fc3453fca6..8720be7330f 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -20,10 +20,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _RESOURCE = "https://data.dublinked.ie/cgi-bin/rtpi/realtimebusinformation" diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py index 557178de571..a49bfde2785 100644 --- a/homeassistant/components/duckdns/__init__.py +++ b/homeassistant/components/duckdns/__init__.py @@ -18,8 +18,8 @@ from homeassistant.core import ( ServiceCall, callback, ) +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass diff --git a/homeassistant/components/dwd_weather_warnings/config_flow.py b/homeassistant/components/dwd_weather_warnings/config_flow.py index f148f4e05ac..064cf52d04d 100644 --- a/homeassistant/components/dwd_weather_warnings/config_flow.py +++ b/homeassistant/components/dwd_weather_warnings/config_flow.py @@ -8,8 +8,7 @@ from dwdwfsapi import DwdWeatherWarningsAPI import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig from .const import CONF_REGION_DEVICE_TRACKER, CONF_REGION_IDENTIFIER, DOMAIN diff --git a/homeassistant/components/dweet/__init__.py b/homeassistant/components/dweet/__init__.py index c1232bab2cf..b43ce3db8c1 100644 --- a/homeassistant/components/dweet/__init__.py +++ b/homeassistant/components/dweet/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/dweet/sensor.py b/homeassistant/components/dweet/sensor.py index 10109189eb0..6110f17f826 100644 --- a/homeassistant/components/dweet/sensor.py +++ b/homeassistant/components/dweet/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType From 3472e0e370ba3f50cb4099a012dc2a0ec1d98ea6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:37:48 +0100 Subject: [PATCH 1173/2987] Standardize homeassistant imports in component (a-b) (#136821) --- homeassistant/components/accuweather/config_flow.py | 2 +- homeassistant/components/acer_projector/switch.py | 2 +- homeassistant/components/actiontec/device_tracker.py | 2 +- homeassistant/components/ads/__init__.py | 2 +- homeassistant/components/ads/binary_sensor.py | 2 +- homeassistant/components/ads/cover.py | 2 +- homeassistant/components/ads/light.py | 2 +- homeassistant/components/ads/select.py | 2 +- homeassistant/components/ads/sensor.py | 2 +- homeassistant/components/ads/switch.py | 2 +- homeassistant/components/ads/valve.py | 2 +- homeassistant/components/aftership/const.py | 2 +- homeassistant/components/aftership/sensor.py | 2 +- homeassistant/components/airly/config_flow.py | 2 +- homeassistant/components/airnow/config_flow.py | 2 +- homeassistant/components/alarmdecoder/alarm_control_panel.py | 3 +-- homeassistant/components/alert/__init__.py | 2 +- homeassistant/components/alpha_vantage/sensor.py | 2 +- homeassistant/components/amazon_polly/tts.py | 2 +- homeassistant/components/amcrest/__init__.py | 3 +-- homeassistant/components/ampio/air_quality.py | 2 +- homeassistant/components/analytics/__init__.py | 2 +- homeassistant/components/analytics/analytics.py | 2 +- homeassistant/components/anel_pwrctrl/switch.py | 2 +- homeassistant/components/anthemav/config_flow.py | 2 +- homeassistant/components/apache_kafka/__init__.py | 2 +- homeassistant/components/apcupsd/config_flow.py | 3 +-- homeassistant/components/api/__init__.py | 2 +- homeassistant/components/apple_tv/media_player.py | 2 +- homeassistant/components/apprise/notify.py | 2 +- homeassistant/components/aprilaire/config_flow.py | 2 +- homeassistant/components/aprs/device_tracker.py | 2 +- homeassistant/components/apsystems/config_flow.py | 2 +- homeassistant/components/aqualogic/sensor.py | 2 +- homeassistant/components/aqualogic/switch.py | 2 +- homeassistant/components/aquostv/media_player.py | 2 +- homeassistant/components/arest/binary_sensor.py | 2 +- homeassistant/components/arest/sensor.py | 2 +- homeassistant/components/arest/switch.py | 2 +- homeassistant/components/arris_tg2492lg/device_tracker.py | 2 +- homeassistant/components/aruba/device_tracker.py | 2 +- homeassistant/components/aten_pe/switch.py | 2 +- homeassistant/components/atome/sensor.py | 2 +- homeassistant/components/august/lock.py | 2 +- homeassistant/components/avion/light.py | 2 +- homeassistant/components/azure_event_hub/__init__.py | 2 +- homeassistant/components/azure_service_bus/notify.py | 2 +- homeassistant/components/baidu/tts.py | 2 +- homeassistant/components/balboa/__init__.py | 2 +- homeassistant/components/bayesian/binary_sensor.py | 3 +-- homeassistant/components/bbox/device_tracker.py | 5 ++--- homeassistant/components/bbox/sensor.py | 2 +- homeassistant/components/beewi_smartclim/sensor.py | 2 +- homeassistant/components/bitcoin/sensor.py | 2 +- homeassistant/components/bizkaibus/sensor.py | 2 +- homeassistant/components/blackbird/media_player.py | 2 +- homeassistant/components/blink/camera.py | 3 +-- homeassistant/components/blockchain/sensor.py | 2 +- homeassistant/components/bluesound/media_player.py | 2 +- .../components/bluetooth_le_tracker/device_tracker.py | 4 ++-- homeassistant/components/bluetooth_tracker/device_tracker.py | 2 +- homeassistant/components/bmw_connected_drive/__init__.py | 2 +- homeassistant/components/broadlink/switch.py | 2 +- homeassistant/components/bt_home_hub_5/device_tracker.py | 2 +- homeassistant/components/bt_smarthub/device_tracker.py | 2 +- homeassistant/components/buienradar/config_flow.py | 3 +-- 66 files changed, 68 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py index 71f7de89528..3e65374f391 100644 --- a/homeassistant/components/accuweather/config_flow.py +++ b/homeassistant/components/accuweather/config_flow.py @@ -12,8 +12,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/acer_projector/switch.py b/homeassistant/components/acer_projector/switch.py index c1463cd9a08..846164202d8 100644 --- a/homeassistant/components/acer_projector/switch.py +++ b/homeassistant/components/acer_projector/switch.py @@ -22,7 +22,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py index 273ca6a772f..41876ce478f 100644 --- a/homeassistant/components/actiontec/device_tracker.py +++ b/homeassistant/components/actiontec/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import LEASES_REGEX diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index 892390a91eb..da34bd36e2c 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import CONF_ADS_VAR, DATA_ADS, DOMAIN, AdsType diff --git a/homeassistant/components/ads/binary_sensor.py b/homeassistant/components/ads/binary_sensor.py index 72a12506dc1..560d090caf0 100644 --- a/homeassistant/components/ads/binary_sensor.py +++ b/homeassistant/components/ads/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py index c7b0f4f2f8a..15d5b3a7d09 100644 --- a/homeassistant/components/ads/cover.py +++ b/homeassistant/components/ads/cover.py @@ -17,7 +17,7 @@ from homeassistant.components.cover import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ads/light.py b/homeassistant/components/ads/light.py index 5ea4868bf11..3de223e5fc4 100644 --- a/homeassistant/components/ads/light.py +++ b/homeassistant/components/ads/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ads/select.py b/homeassistant/components/ads/select.py index 39f813dec27..e31e089d669 100644 --- a/homeassistant/components/ads/select.py +++ b/homeassistant/components/ads/select.py @@ -11,7 +11,7 @@ from homeassistant.components.select import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ads/sensor.py b/homeassistant/components/ads/sensor.py index 09579161a94..0fd1b84ffd1 100644 --- a/homeassistant/components/ads/sensor.py +++ b/homeassistant/components/ads/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType diff --git a/homeassistant/components/ads/switch.py b/homeassistant/components/ads/switch.py index 0412a127c95..2506757e9d2 100644 --- a/homeassistant/components/ads/switch.py +++ b/homeassistant/components/ads/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ads/valve.py b/homeassistant/components/ads/valve.py index b94215ec9ea..a251e14b3c3 100644 --- a/homeassistant/components/ads/valve.py +++ b/homeassistant/components/ads/valve.py @@ -14,7 +14,7 @@ from homeassistant.components.valve import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/aftership/const.py b/homeassistant/components/aftership/const.py index 385570e145f..c5d7b00a942 100644 --- a/homeassistant/components/aftership/const.py +++ b/homeassistant/components/aftership/const.py @@ -7,7 +7,7 @@ from typing import Final import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv DOMAIN: Final = "aftership" diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py index c019634197d..085be2499d4 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -9,7 +9,7 @@ from pyaftership import AfterShip, AfterShipException from homeassistant.components.sensor import SensorEntity from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/airly/config_flow.py b/homeassistant/components/airly/config_flow.py index 2811156ac90..de60ef84efa 100644 --- a/homeassistant/components/airly/config_flow.py +++ b/homeassistant/components/airly/config_flow.py @@ -13,8 +13,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import CONF_USE_NEAREST, DOMAIN, NO_AIRLY_SENSORS diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index d0ab16e9758..7cd113125a8 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -18,8 +18,8 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py index cf72133ea12..d7092bbe1c4 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -12,8 +12,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index 12341c158c0..b6ce87941f6 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/alpha_vantage/sensor.py b/homeassistant/components/alpha_vantage/sensor.py index 506cb41659a..48d3ae6f526 100644 --- a/homeassistant/components/alpha_vantage/sensor.py +++ b/homeassistant/components/alpha_vantage/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_CURRENCY, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py index 62852848a9c..985b3b6dd7c 100644 --- a/homeassistant/components/amazon_polly/tts.py +++ b/homeassistant/components/amazon_polly/tts.py @@ -22,7 +22,7 @@ from homeassistant.generated.amazon_polly import ( SUPPORTED_REGIONS, SUPPORTED_VOICES, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 624e0145b86..313d3263932 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -37,8 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import Unauthorized, UnknownUser -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.service import async_extract_entity_ids diff --git a/homeassistant/components/ampio/air_quality.py b/homeassistant/components/ampio/air_quality.py index 05581df6371..ce2830d5b14 100644 --- a/homeassistant/components/ampio/air_quality.py +++ b/homeassistant/components/ampio/air_quality.py @@ -14,8 +14,8 @@ from homeassistant.components.air_quality import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index 9bcddcb868f..0df3b8138e2 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import Event, HassJob, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 9260642a58f..9339e2986e5 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -11,6 +11,7 @@ import uuid import aiohttp +from homeassistant import config as conf_util from homeassistant.components import hassio from homeassistant.components.api import ATTR_INSTALLATION_TYPE from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN @@ -22,7 +23,6 @@ from homeassistant.components.recorder import ( DOMAIN as RECORDER_DOMAIN, get_instance as get_recorder_instance, ) -import homeassistant.config as conf_util from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/anel_pwrctrl/switch.py b/homeassistant/components/anel_pwrctrl/switch.py index 6b27a61e065..97691c8b028 100644 --- a/homeassistant/components/anel_pwrctrl/switch.py +++ b/homeassistant/components/anel_pwrctrl/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/anthemav/config_flow.py b/homeassistant/components/anthemav/config_flow.py index 400ac6d5899..fe9c6513041 100644 --- a/homeassistant/components/anthemav/config_flow.py +++ b/homeassistant/components/anthemav/config_flow.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from .const import DEFAULT_NAME, DEFAULT_PORT, DEVICE_TIMEOUT_SECONDS, DOMAIN diff --git a/homeassistant/components/apache_kafka/__init__.py b/homeassistant/components/apache_kafka/__init__.py index 68d3f58a63a..40f71ec4e4b 100644 --- a/homeassistant/components/apache_kafka/__init__.py +++ b/homeassistant/components/apache_kafka/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter from homeassistant.helpers.typing import ConfigType from homeassistant.util import ssl as ssl_util diff --git a/homeassistant/components/apcupsd/config_flow.py b/homeassistant/components/apcupsd/config_flow.py index 00f757a1fd7..b65c9c33265 100644 --- a/homeassistant/components/apcupsd/config_flow.py +++ b/homeassistant/components/apcupsd/config_flow.py @@ -10,8 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.helpers import selector -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from .const import CONNECTION_TIMEOUT, DOMAIN from .coordinator import APCUPSdData diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index ba71fb0def1..d183d46a717 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -11,6 +11,7 @@ from aiohttp import web from aiohttp.web_exceptions import HTTPBadRequest import voluptuous as vol +from homeassistant import core as ha from homeassistant.auth.models import User from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.components.http import ( @@ -36,7 +37,6 @@ from homeassistant.const import ( URL_API_STREAM, URL_API_TEMPLATE, ) -import homeassistant.core as ha from homeassistant.core import Event, EventStateChangedData, HomeAssistant from homeassistant.exceptions import ( InvalidEntityFormatError, diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index c6b71c64b4f..8a2336eea3b 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -40,7 +40,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import AppleTvConfigEntry, AppleTVManager from .browse_media import build_app_list diff --git a/homeassistant/components/apprise/notify.py b/homeassistant/components/apprise/notify.py index eb4e21c127f..a2efcb577d3 100644 --- a/homeassistant/components/apprise/notify.py +++ b/homeassistant/components/apprise/notify.py @@ -17,7 +17,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/aprilaire/config_flow.py b/homeassistant/components/aprilaire/config_flow.py index f6c33f75e53..0b4f9af3401 100644 --- a/homeassistant/components/aprilaire/config_flow.py +++ b/homeassistant/components/aprilaire/config_flow.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py index fc23fc5e436..fc3dbcabfe8 100644 --- a/homeassistant/components/aprs/device_tracker.py +++ b/homeassistant/components/aprs/device_tracker.py @@ -26,7 +26,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify diff --git a/homeassistant/components/apsystems/config_flow.py b/homeassistant/components/apsystems/config_flow.py index 5f2f1393aa0..9be0b5f4cf7 100644 --- a/homeassistant/components/apsystems/config_flow.py +++ b/homeassistant/components/apsystems/config_flow.py @@ -8,8 +8,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import DEFAULT_PORT, DOMAIN diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index 9c2ee9957af..e0cae5df162 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/aqualogic/switch.py b/homeassistant/components/aqualogic/switch.py index ed0cc463263..667842a020c 100644 --- a/homeassistant/components/aqualogic/switch.py +++ b/homeassistant/components/aqualogic/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/aquostv/media_player.py b/homeassistant/components/aquostv/media_player.py index 343cb6492da..90660028b83 100644 --- a/homeassistant/components/aquostv/media_player.py +++ b/homeassistant/components/aquostv/media_player.py @@ -24,7 +24,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/arest/binary_sensor.py b/homeassistant/components/arest/binary_sensor.py index 00d4d6bbf9b..a99ef049543 100644 --- a/homeassistant/components/arest/binary_sensor.py +++ b/homeassistant/components/arest/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_PIN, CONF_RESOURCE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py index 8c68c13018b..6554704b230 100644 --- a/homeassistant/components/arest/sensor.py +++ b/homeassistant/components/arest/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py index bcdba36cb58..7539336c38b 100644 --- a/homeassistant/components/arest/switch.py +++ b/homeassistant/components/arest/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_NAME, CONF_RESOURCE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/arris_tg2492lg/device_tracker.py b/homeassistant/components/arris_tg2492lg/device_tracker.py index c3650587690..828528508ec 100644 --- a/homeassistant/components/arris_tg2492lg/device_tracker.py +++ b/homeassistant/components/arris_tg2492lg/device_tracker.py @@ -13,8 +13,8 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType DEFAULT_HOST = "192.168.178.1" diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index 911fab441e5..c2f0d44a6f8 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -16,7 +16,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/aten_pe/switch.py b/homeassistant/components/aten_pe/switch.py index 39b18089284..30afab16011 100644 --- a/homeassistant/components/aten_pe/switch.py +++ b/homeassistant/components/aten_pe/switch.py @@ -16,7 +16,7 @@ from homeassistant.components.switch import ( from homeassistant.const import CONF_HOST, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/atome/sensor.py b/homeassistant/components/atome/sensor.py index fd8250e899f..a1254c1ff49 100644 --- a/homeassistant/components/atome/sensor.py +++ b/homeassistant/components/atome/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index fe5d90371ad..c681cc98808 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -16,7 +16,7 @@ from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import AugustConfigEntry, AugustData from .entity import AugustEntity diff --git a/homeassistant/components/avion/light.py b/homeassistant/components/avion/light.py index 687405e3064..5b9371e0e2b 100644 --- a/homeassistant/components/avion/light.py +++ b/homeassistant/components/avion/light.py @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py index bc9d34e728e..abe6cdfe15f 100644 --- a/homeassistant/components/azure_event_hub/__init__.py +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import MATCH_ALL from homeassistant.core import Event, HomeAssistant, State from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter from homeassistant.helpers.event import async_call_later from homeassistant.helpers.json import JSONEncoder diff --git a/homeassistant/components/azure_service_bus/notify.py b/homeassistant/components/azure_service_bus/notify.py index a0aa36804c3..83eb8076fef 100644 --- a/homeassistant/components/azure_service_bus/notify.py +++ b/homeassistant/components/azure_service_bus/notify.py @@ -23,7 +23,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType CONF_CONNECTION_STRING = "connection_string" diff --git a/homeassistant/components/baidu/tts.py b/homeassistant/components/baidu/tts.py index cdb6697d143..064dfb8d24c 100644 --- a/homeassistant/components/baidu/tts.py +++ b/homeassistant/components/baidu/tts.py @@ -11,7 +11,7 @@ from homeassistant.components.tts import ( Provider, ) from homeassistant.const import CONF_API_KEY -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py index 7838db16820..c982d59d513 100644 --- a/homeassistant/components/balboa/__init__.py +++ b/homeassistant/components/balboa/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONF_SYNC_TIME, DEFAULT_SYNC_TIME diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 74e3db34b68..32f43983991 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -31,8 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.exceptions import ConditionError, TemplateError -from homeassistant.helpers import condition -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( TrackTemplate, diff --git a/homeassistant/components/bbox/device_tracker.py b/homeassistant/components/bbox/device_tracker.py index 12174d395f7..18b62f2a506 100644 --- a/homeassistant/components/bbox/device_tracker.py +++ b/homeassistant/components/bbox/device_tracker.py @@ -16,10 +16,9 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bbox/sensor.py b/homeassistant/components/bbox/sensor.py index 72fa870efbf..fed059247d0 100644 --- a/homeassistant/components/bbox/sensor.py +++ b/homeassistant/components/bbox/sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_VARIABLES, CONF_NAME, UnitOfDataRate from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/beewi_smartclim/sensor.py b/homeassistant/components/beewi_smartclim/sensor.py index 1c80f62e64f..3a0a6f21f98 100644 --- a/homeassistant/components/beewi_smartclim/sensor.py +++ b/homeassistant/components/beewi_smartclim/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MAC, CONF_NAME, PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/bitcoin/sensor.py b/homeassistant/components/bitcoin/sensor.py index e4da2ddc2f4..cb7bc5a043b 100644 --- a/homeassistant/components/bitcoin/sensor.py +++ b/homeassistant/components/bitcoin/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_CURRENCY, CONF_DISPLAY_OPTIONS, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/bizkaibus/sensor.py b/homeassistant/components/bizkaibus/sensor.py index 3efddf0b0d7..085c0093073 100644 --- a/homeassistant/components/bizkaibus/sensor.py +++ b/homeassistant/components/bizkaibus/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/blackbird/media_player.py b/homeassistant/components/blackbird/media_player.py index 37672e98e0b..2d39512cbe0 100644 --- a/homeassistant/components/blackbird/media_player.py +++ b/homeassistant/components/blackbird/media_player.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 56a84135a9b..e35dd20eea7 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -13,8 +13,7 @@ from homeassistant.components.camera import Camera from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/blockchain/sensor.py b/homeassistant/components/blockchain/sensor.py index 8ae091fa95e..a6aedb2c472 100644 --- a/homeassistant/components/blockchain/sensor.py +++ b/homeassistant/components/blockchain/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 12e2f537935..6bb3c101cd1 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -34,7 +34,7 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN from .coordinator import BluesoundCoordinator diff --git a/homeassistant/components/bluetooth_le_tracker/device_tracker.py b/homeassistant/components/bluetooth_le_tracker/device_tracker.py index 25e620ff15d..25a1aa60a1d 100644 --- a/homeassistant/components/bluetooth_le_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_le_tracker/device_tracker.py @@ -24,10 +24,10 @@ from homeassistant.components.device_tracker.legacy import ( ) from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py index 1d64d31a248..17d166f2b32 100644 --- a/homeassistant/components/bluetooth_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_tracker/device_tracker.py @@ -27,7 +27,7 @@ from homeassistant.components.device_tracker.legacy import ( ) from homeassistant.const import CONF_DEVICE_ID from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 05fa3e3cab0..287cb226b51 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -9,11 +9,11 @@ import voluptuous as vol from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( + config_validation as cv, device_registry as dr, discovery, entity_registry as er, ) -import homeassistant.helpers.config_validation as cv from .const import ATTR_VIN, CONF_READ_ONLY, DOMAIN from .coordinator import BMWConfigEntry, BMWDataUpdateCoordinator diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index cc3b9dad464..9098440a5c4 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -29,7 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/bt_home_hub_5/device_tracker.py b/homeassistant/components/bt_home_hub_5/device_tracker.py index cbd06381578..84450573989 100644 --- a/homeassistant/components/bt_home_hub_5/device_tracker.py +++ b/homeassistant/components/bt_home_hub_5/device_tracker.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bt_smarthub/device_tracker.py b/homeassistant/components/bt_smarthub/device_tracker.py index 29f60bd317f..57ceb01700d 100644 --- a/homeassistant/components/bt_smarthub/device_tracker.py +++ b/homeassistant/components/bt_smarthub/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/buienradar/config_flow.py b/homeassistant/components/buienradar/config_flow.py index 45ad9028eb0..12f292036df 100644 --- a/homeassistant/components/buienradar/config_flow.py +++ b/homeassistant/components/buienradar/config_flow.py @@ -10,8 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import callback -from homeassistant.helpers import selector -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaFlowFormStep, From aa6ffb3da56458975678a19e3f1e68cd807e5e0d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:39:40 +0100 Subject: [PATCH 1174/2987] Improve type hints in environment_canada camera and weather (#136819) --- homeassistant/components/environment_canada/camera.py | 7 ++++--- homeassistant/components/environment_canada/weather.py | 10 +++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index fd82ac97bea..3ba059e2206 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -2,6 +2,7 @@ from __future__ import annotations +from env_canada import ECRadar import voluptuous as vol from homeassistant.components.camera import Camera @@ -14,7 +15,7 @@ from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_OBSERVATION_TIME -from .coordinator import ECConfigEntry +from .coordinator import ECConfigEntry, ECDataUpdateCoordinator SERVICE_SET_RADAR_TYPE = "set_radar_type" SET_RADAR_TYPE_SCHEMA: VolDictType = { @@ -39,13 +40,13 @@ async def async_setup_entry( ) -class ECCameraEntity(CoordinatorEntity, Camera): +class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera): """Implementation of an Environment Canada radar camera.""" _attr_has_entity_name = True _attr_translation_key = "radar" - def __init__(self, coordinator): + def __init__(self, coordinator: ECDataUpdateCoordinator[ECRadar]) -> None: """Initialize the camera.""" super().__init__(coordinator) Camera.__init__(self) diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index 5cfe32f18dd..156b9f4152b 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any +from env_canada import ECWeather + from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -38,7 +40,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import ECConfigEntry +from .coordinator import ECConfigEntry, ECDataUpdateCoordinator # Icon codes from http://dd.weatheroffice.ec.gc.ca/citypage_weather/ # docs/current_conditions_icon_code_descriptions_e.csv @@ -82,7 +84,9 @@ def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> st return f"{config_entry_unique_id}{'-hourly' if hourly else '-daily'}" -class ECWeatherEntity(SingleCoordinatorWeatherEntity): +class ECWeatherEntity( + SingleCoordinatorWeatherEntity[ECDataUpdateCoordinator[ECWeather]] +): """Representation of a weather condition.""" _attr_has_entity_name = True @@ -94,7 +98,7 @@ class ECWeatherEntity(SingleCoordinatorWeatherEntity): WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY ) - def __init__(self, coordinator): + def __init__(self, coordinator: ECDataUpdateCoordinator[ECWeather]) -> None: """Initialize Environment Canada weather.""" super().__init__(coordinator) self.ec_data = coordinator.ec_data From ea62da553e793e4490b3cf9979a839d60f930ade Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 29 Jan 2025 20:41:33 +1000 Subject: [PATCH 1175/2987] Correct the behavior of the Charge switch in Tessie/Teslemetry/Tesla Fleet (#136562) --- .../components/tesla_fleet/icons.json | 2 +- .../components/tesla_fleet/strings.json | 2 +- .../components/tesla_fleet/switch.py | 41 +++++++------------ .../components/teslemetry/icons.json | 2 +- .../components/teslemetry/strings.json | 2 +- homeassistant/components/teslemetry/switch.py | 41 +++++++------------ homeassistant/components/tessie/strings.json | 2 +- homeassistant/components/tessie/switch.py | 38 +++++++---------- .../tesla_fleet/snapshots/test_switch.ambr | 6 +-- .../teslemetry/snapshots/test_switch.ambr | 6 +-- .../tessie/snapshots/test_switch.ambr | 4 +- tests/components/tessie/test_switch.py | 2 +- 12 files changed, 58 insertions(+), 90 deletions(-) diff --git a/homeassistant/components/tesla_fleet/icons.json b/homeassistant/components/tesla_fleet/icons.json index c806138c219..f907107fd40 100644 --- a/homeassistant/components/tesla_fleet/icons.json +++ b/homeassistant/components/tesla_fleet/icons.json @@ -298,7 +298,7 @@ } }, "switch": { - "charge_state_user_charge_enable_request": { + "charge_state_charging_state": { "default": "mdi:ev-station" }, "climate_state_auto_seat_climate_left": { diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index c438bfff50f..540ea2b7135 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -522,7 +522,7 @@ } }, "switch": { - "charge_state_user_charge_enable_request": { + "charge_state_charging_state": { "name": "Charge" }, "climate_state_auto_seat_climate_left": { diff --git a/homeassistant/components/tesla_fleet/switch.py b/homeassistant/components/tesla_fleet/switch.py index d602cff78c0..054ea84cbe1 100644 --- a/homeassistant/components/tesla_fleet/switch.py +++ b/homeassistant/components/tesla_fleet/switch.py @@ -16,6 +16,7 @@ from homeassistant.components.switch import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from . import TeslaFleetConfigEntry from .entity import TeslaFleetEnergyInfoEntity, TeslaFleetVehicleEntity @@ -32,6 +33,8 @@ class TeslaFleetSwitchEntityDescription(SwitchEntityDescription): on_func: Callable off_func: Callable scopes: list[Scope] + value_func: Callable[[StateType], bool] = bool + unique_id: str | None = None VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( @@ -77,13 +80,14 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( ), scopes=[Scope.VEHICLE_CMDS], ), -) - -VEHICLE_CHARGE_DESCRIPTION = TeslaFleetSwitchEntityDescription( - key="charge_state_user_charge_enable_request", - on_func=lambda api: api.charge_start(), - off_func=lambda api: api.charge_stop(), - scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + TeslaFleetSwitchEntityDescription( + key="charge_state_charging_state", + unique_id="charge_state_user_charge_enable_request", + on_func=lambda api: api.charge_start(), + off_func=lambda api: api.charge_stop(), + value_func=lambda state: state in {"Starting", "Charging"}, + scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + ), ) @@ -103,12 +107,6 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles for description in VEHICLE_DESCRIPTIONS ), - ( - TeslaFleetChargeSwitchEntity( - vehicle, VEHICLE_CHARGE_DESCRIPTION, entry.runtime_data.scopes - ) - for vehicle in entry.runtime_data.vehicles - ), ( TeslaFleetChargeFromGridSwitchEntity( energysite, @@ -144,16 +142,18 @@ class TeslaFleetVehicleSwitchEntity(TeslaFleetVehicleEntity, TeslaFleetSwitchEnt scopes: list[Scope], ) -> None: """Initialize the Switch.""" - super().__init__(data, description.key) self.entity_description = description self.scoped = any(scope in scopes for scope in description.scopes) + super().__init__(data, description.key) + if description.unique_id: + self._attr_unique_id = f"{data.vin}-{description.unique_id}" def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" if self._value is None: self._attr_is_on = None else: - self._attr_is_on = bool(self._value) + self._attr_is_on = self.entity_description.value_func(self._value) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Switch.""" @@ -172,17 +172,6 @@ class TeslaFleetVehicleSwitchEntity(TeslaFleetVehicleEntity, TeslaFleetSwitchEnt self.async_write_ha_state() -class TeslaFleetChargeSwitchEntity(TeslaFleetVehicleSwitchEntity): - """Entity class for TeslaFleet charge switch.""" - - def _async_update_attrs(self) -> None: - """Update the attributes of the entity.""" - if self._value is None: - self._attr_is_on = self.get("charge_state_charge_enable_request") - else: - self._attr_is_on = self._value - - class TeslaFleetChargeFromGridSwitchEntity( TeslaFleetEnergyInfoEntity, TeslaFleetSwitchEntity ): diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index 6559acf89dc..9996a508177 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -291,7 +291,7 @@ } }, "switch": { - "charge_state_user_charge_enable_request": { + "charge_state_charging_state": { "default": "mdi:ev-station" }, "climate_state_auto_seat_climate_left": { diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 8dc8b053712..68ad12a46b6 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -608,7 +608,7 @@ } }, "switch": { - "charge_state_user_charge_enable_request": { + "charge_state_charging_state": { "name": "Charge" }, "climate_state_auto_seat_climate_left": { diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index 6a1cff4c5da..f810dee8554 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -16,6 +16,7 @@ from homeassistant.components.switch import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from . import TeslemetryConfigEntry from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity @@ -32,6 +33,8 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): on_func: Callable off_func: Callable scopes: list[Scope] + value_func: Callable[[StateType], bool] = bool + unique_id: str | None = None VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( @@ -77,13 +80,14 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( ), scopes=[Scope.VEHICLE_CMDS], ), -) - -VEHICLE_CHARGE_DESCRIPTION = TeslemetrySwitchEntityDescription( - key="charge_state_user_charge_enable_request", - on_func=lambda api: api.charge_start(), - off_func=lambda api: api.charge_stop(), - scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS], + TeslemetrySwitchEntityDescription( + key="charge_state_charging_state", + unique_id="charge_state_user_charge_enable_request", + on_func=lambda api: api.charge_start(), + off_func=lambda api: api.charge_stop(), + value_func=lambda state: state in {"Starting", "Charging"}, + scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS], + ), ) @@ -104,12 +108,6 @@ async def async_setup_entry( for description in VEHICLE_DESCRIPTIONS if description.key in vehicle.coordinator.data ), - ( - TeslemetryChargeSwitchEntity( - vehicle, VEHICLE_CHARGE_DESCRIPTION, entry.runtime_data.scopes - ) - for vehicle in entry.runtime_data.vehicles - ), ( TeslemetryChargeFromGridSwitchEntity( energysite, @@ -145,13 +143,15 @@ class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEnt scopes: list[Scope], ) -> None: """Initialize the Switch.""" - super().__init__(data, description.key) self.entity_description = description self.scoped = any(scope in scopes for scope in description.scopes) + super().__init__(data, description.key) + if description.unique_id: + self._attr_unique_id = f"{data.vin}-{description.unique_id}" def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_is_on = bool(self._value) + self._attr_is_on = self.entity_description.value_func(self._value) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Switch.""" @@ -170,17 +170,6 @@ class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEnt self.async_write_ha_state() -class TeslemetryChargeSwitchEntity(TeslemetryVehicleSwitchEntity): - """Entity class for Teslemetry charge switch.""" - - def _async_update_attrs(self) -> None: - """Update the attributes of the entity.""" - if self._value is None: - self._attr_is_on = self.get("charge_state_charge_enable_request") - else: - self._attr_is_on = self._value - - class TeslemetryChargeFromGridSwitchEntity( TeslemetryEnergyInfoEntity, TeslemetrySwitchEntity ): diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 4ac645a0270..8384bb3d8fb 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -459,7 +459,7 @@ } }, "switch": { - "charge_state_charge_enable_request": { + "charge_state_charging_state": { "name": "Charge" }, "climate_state_defrost_mode": { diff --git a/homeassistant/components/tessie/switch.py b/homeassistant/components/tessie/switch.py index f0088a4444f..dba00a85bb2 100644 --- a/homeassistant/components/tessie/switch.py +++ b/homeassistant/components/tessie/switch.py @@ -27,6 +27,7 @@ from homeassistant.components.switch import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from . import TessieConfigEntry from .entity import TessieEnergyEntity, TessieEntity @@ -40,6 +41,8 @@ class TessieSwitchEntityDescription(SwitchEntityDescription): on_func: Callable off_func: Callable + value_func: Callable[[StateType], bool] = bool + unique_id: str | None = None DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = ( @@ -63,12 +66,13 @@ DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = ( on_func=lambda: start_steering_wheel_heater, off_func=lambda: stop_steering_wheel_heater, ), -) - -CHARGE_DESCRIPTION: TessieSwitchEntityDescription = TessieSwitchEntityDescription( - key="charge_state_charge_enable_request", - on_func=lambda: start_charging, - off_func=lambda: stop_charging, + TessieSwitchEntityDescription( + key="charge_state_charging_state", + unique_id="charge_state_charge_enable_request", + on_func=lambda: start_charging, + off_func=lambda: stop_charging, + value_func=lambda state: state in {"Starting", "Charging"}, + ), ) PARALLEL_UPDATES = 0 @@ -89,10 +93,6 @@ async def async_setup_entry( for description in DESCRIPTIONS if description.key in vehicle.data_coordinator.data ), - ( - TessieChargeSwitchEntity(vehicle, CHARGE_DESCRIPTION) - for vehicle in entry.runtime_data.vehicles - ), ( TessieChargeFromGridSwitchEntity(energysite) for energysite in entry.runtime_data.energysites @@ -120,13 +120,15 @@ class TessieSwitchEntity(TessieEntity, SwitchEntity): description: TessieSwitchEntityDescription, ) -> None: """Initialize the Switch.""" - super().__init__(vehicle, description.key) self.entity_description = description + super().__init__(vehicle, description.key) + if description.unique_id: + self._attr_unique_id = f"{vehicle.vin}-{description.unique_id}" @property def is_on(self) -> bool: """Return the state of the Switch.""" - return self._value + return self.entity_description.value_func(self._value) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Switch.""" @@ -139,18 +141,6 @@ class TessieSwitchEntity(TessieEntity, SwitchEntity): self.set((self.entity_description.key, False)) -class TessieChargeSwitchEntity(TessieSwitchEntity): - """Entity class for Tessie charge switch.""" - - @property - def is_on(self) -> bool: - """Return the state of the Switch.""" - - if (charge := self.get("charge_state_user_charge_enable_request")) is not None: - return charge - return self._value - - class TessieChargeFromGridSwitchEntity(TessieEnergyEntity, SwitchEntity): """Entity class for Charge From Grid switch.""" diff --git a/tests/components/tesla_fleet/snapshots/test_switch.ambr b/tests/components/tesla_fleet/snapshots/test_switch.ambr index 2d69a7d314a..43d59a9da85 100644 --- a/tests/components/tesla_fleet/snapshots/test_switch.ambr +++ b/tests/components/tesla_fleet/snapshots/test_switch.ambr @@ -262,7 +262,7 @@ 'platform': 'tesla_fleet', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_user_charge_enable_request', + 'translation_key': 'charge_state_charging_state', 'unique_id': 'LRWXF7EK4KC700000-charge_state_user_charge_enable_request', 'unit_of_measurement': None, }) @@ -278,7 +278,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_switch[switch.test_defrost-entry] @@ -456,7 +456,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_switch_alt[switch.test_defrost-statealt] diff --git a/tests/components/teslemetry/snapshots/test_switch.ambr b/tests/components/teslemetry/snapshots/test_switch.ambr index 5693d4bdd5e..b34d9c65393 100644 --- a/tests/components/teslemetry/snapshots/test_switch.ambr +++ b/tests/components/teslemetry/snapshots/test_switch.ambr @@ -262,7 +262,7 @@ 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_user_charge_enable_request', + 'translation_key': 'charge_state_charging_state', 'unique_id': 'LRW3F7EK4NC700000-charge_state_user_charge_enable_request', 'unit_of_measurement': None, }) @@ -278,7 +278,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_switch[switch.test_defrost-entry] @@ -456,7 +456,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_switch_alt[switch.test_defrost-statealt] diff --git a/tests/components/tessie/snapshots/test_switch.ambr b/tests/components/tessie/snapshots/test_switch.ambr index 3b7a3623de8..35e36010830 100644 --- a/tests/components/tessie/snapshots/test_switch.ambr +++ b/tests/components/tessie/snapshots/test_switch.ambr @@ -119,7 +119,7 @@ 'platform': 'tessie', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_charge_enable_request', + 'translation_key': 'charge_state_charging_state', 'unique_id': 'VINVINVIN-charge_state_charge_enable_request', 'unit_of_measurement': None, }) @@ -351,6 +351,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- diff --git a/tests/components/tessie/test_switch.py b/tests/components/tessie/test_switch.py index 499e529b2e8..690ad7d1ab4 100644 --- a/tests/components/tessie/test_switch.py +++ b/tests/components/tessie/test_switch.py @@ -20,7 +20,7 @@ from .common import RESPONSE_OK, assert_entities, setup_platform async def test_switches( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry ) -> None: - """Tests that the switche entities are correct.""" + """Tests that the switch entities are correct.""" entry = await setup_platform(hass, [Platform.SWITCH]) From ccdcba97b58c3242130a20cd59c8c246562f1f19 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:56:40 +0100 Subject: [PATCH 1176/2987] Standardize homeassistant imports in component (l-m) (#136827) --- homeassistant/components/lacrosse/sensor.py | 2 +- homeassistant/components/lametric/__init__.py | 3 +-- homeassistant/components/lannouncer/notify.py | 2 +- homeassistant/components/lcn/config_flow.py | 2 +- homeassistant/components/lcn/schemas.py | 2 +- homeassistant/components/lcn/services.py | 3 +-- homeassistant/components/lcn/websocket.py | 7 +++++-- homeassistant/components/lifx/__init__.py | 2 +- homeassistant/components/lifx/light.py | 3 +-- homeassistant/components/lifx/manager.py | 2 +- homeassistant/components/lifx_cloud/scene.py | 2 +- homeassistant/components/lightwave/__init__.py | 2 +- homeassistant/components/limitlessled/light.py | 2 +- homeassistant/components/linksys_smart/device_tracker.py | 2 +- homeassistant/components/linode/__init__.py | 2 +- homeassistant/components/linode/binary_sensor.py | 2 +- homeassistant/components/linode/switch.py | 2 +- homeassistant/components/linux_battery/sensor.py | 2 +- homeassistant/components/litejet/config_flow.py | 2 +- homeassistant/components/litejet/trigger.py | 4 ++-- homeassistant/components/litterrobot/time.py | 2 +- homeassistant/components/litterrobot/vacuum.py | 2 +- homeassistant/components/locative/__init__.py | 3 +-- homeassistant/components/logentries/__init__.py | 3 +-- homeassistant/components/london_air/sensor.py | 2 +- homeassistant/components/london_underground/sensor.py | 2 +- homeassistant/components/luci/device_tracker.py | 2 +- homeassistant/components/luftdaten/config_flow.py | 2 +- homeassistant/components/lutron_caseta/__init__.py | 7 +++++-- homeassistant/components/lyric/climate.py | 3 +-- homeassistant/components/mailgun/__init__.py | 3 +-- homeassistant/components/manual/alarm_control_panel.py | 4 ++-- .../components/manual_mqtt/alarm_control_panel.py | 4 ++-- homeassistant/components/marytts/tts.py | 2 +- homeassistant/components/matrix/__init__.py | 2 +- homeassistant/components/matrix/notify.py | 2 +- homeassistant/components/maxcube/__init__.py | 2 +- homeassistant/components/mealie/coordinator.py | 2 +- homeassistant/components/mediaroom/media_player.py | 2 +- homeassistant/components/meraki/device_tracker.py | 2 +- homeassistant/components/message_bird/notify.py | 2 +- homeassistant/components/met/config_flow.py | 2 +- homeassistant/components/met_eireann/__init__.py | 2 +- homeassistant/components/met_eireann/config_flow.py | 2 +- homeassistant/components/meteo_france/__init__.py | 2 +- homeassistant/components/meteoalarm/binary_sensor.py | 4 ++-- homeassistant/components/mfi/sensor.py | 2 +- homeassistant/components/mfi/switch.py | 2 +- homeassistant/components/microsoft/tts.py | 2 +- homeassistant/components/microsoft_face/__init__.py | 2 +- .../components/microsoft_face_detect/image_processing.py | 2 +- .../components/microsoft_face_identify/image_processing.py | 2 +- homeassistant/components/mikrotik/device.py | 3 +-- homeassistant/components/mikrotik/device_tracker.py | 2 +- homeassistant/components/minio/__init__.py | 2 +- homeassistant/components/mobile_app/notify.py | 2 +- homeassistant/components/mochad/__init__.py | 2 +- homeassistant/components/mold_indicator/sensor.py | 2 +- homeassistant/components/moon/sensor.py | 2 +- homeassistant/components/mpd/media_player.py | 5 ++--- homeassistant/components/mqtt_eventstream/__init__.py | 2 +- homeassistant/components/mqtt_json/device_tracker.py | 2 +- homeassistant/components/mqtt_room/sensor.py | 2 +- homeassistant/components/mqtt_statestream/__init__.py | 2 +- homeassistant/components/msteams/notify.py | 2 +- homeassistant/components/music_assistant/__init__.py | 3 +-- homeassistant/components/music_assistant/actions.py | 2 +- homeassistant/components/music_assistant/media_player.py | 3 +-- homeassistant/components/music_assistant/schemas.py | 2 +- homeassistant/components/mvglive/sensor.py | 2 +- homeassistant/components/mycroft/__init__.py | 3 +-- homeassistant/components/mysensors/config_flow.py | 3 +-- homeassistant/components/mysensors/gateway.py | 2 +- homeassistant/components/mysensors/helpers.py | 2 +- homeassistant/components/mythicbeastsdns/__init__.py | 2 +- 75 files changed, 88 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py index d7df7a08e76..2cdf28d5e69 100644 --- a/homeassistant/components/lacrosse/sensor.py +++ b/homeassistant/components/lacrosse/sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time diff --git a/homeassistant/components/lametric/__init__.py b/homeassistant/components/lametric/__init__.py index 779cfa10445..89659fbd2c0 100644 --- a/homeassistant/components/lametric/__init__.py +++ b/homeassistant/components/lametric/__init__.py @@ -4,8 +4,7 @@ from homeassistant.components import notify as hass_notify from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS diff --git a/homeassistant/components/lannouncer/notify.py b/homeassistant/components/lannouncer/notify.py index 6c3cd1922cf..fe486660438 100644 --- a/homeassistant/components/lannouncer/notify.py +++ b/homeassistant/components/lannouncer/notify.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType ATTR_METHOD = "method" diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index a1be32704f7..63e0d8c8b26 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from . import PchkConnectionManager diff --git a/homeassistant/components/lcn/schemas.py b/homeassistant/components/lcn/schemas.py index 809701c680a..d90e264692c 100644 --- a/homeassistant/components/lcn/schemas.py +++ b/homeassistant/components/lcn/schemas.py @@ -9,7 +9,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, UnitOfTemperature, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType from .const import ( diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index a6c42de0487..2694bed31d2 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -20,8 +20,7 @@ from homeassistant.core import ( SupportsResponse, ) from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import ( diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py index 46df71d4235..9084ec838d9 100644 --- a/homeassistant/components/lcn/websocket.py +++ b/homeassistant/components/lcn/websocket.py @@ -22,8 +22,11 @@ from homeassistant.const import ( CONF_RESOURCE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from .const import ( ADD_ENTITIES_CALLBACKS, diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 974292c6e80..2847862029f 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 22bcef4915e..2a8031b3874 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -21,8 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import VolDictType diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 16c39c25219..887bc3c3527 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -27,7 +27,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import ATTR_MODE from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_extract_referenced_entity_ids from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DATA_LIFX_MANAGER, DOMAIN diff --git a/homeassistant/components/lifx_cloud/scene.py b/homeassistant/components/lifx_cloud/scene.py index b40cb081ed7..f6ba01dbdae 100644 --- a/homeassistant/components/lifx_cloud/scene.py +++ b/homeassistant/components/lifx_cloud/scene.py @@ -14,8 +14,8 @@ import voluptuous as vol from homeassistant.components.scene import Scene from homeassistant.const import CONF_PLATFORM, CONF_TIMEOUT, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/lightwave/__init__.py b/homeassistant/components/lightwave/__init__.py index 6c462b040d4..ef2a69c9f4f 100644 --- a/homeassistant/components/lightwave/__init__.py +++ b/homeassistant/components/lightwave/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index 4b2b75be9d7..22e2071b6a7 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -34,7 +34,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/linksys_smart/device_tracker.py b/homeassistant/components/linksys_smart/device_tracker.py index 596b7012140..c3b0b666d50 100644 --- a/homeassistant/components/linksys_smart/device_tracker.py +++ b/homeassistant/components/linksys_smart/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType DEFAULT_TIMEOUT = 10 diff --git a/homeassistant/components/linode/__init__.py b/homeassistant/components/linode/__init__.py index 80c082344e7..d59c849f8f0 100644 --- a/homeassistant/components/linode/__init__.py +++ b/homeassistant/components/linode/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/linode/binary_sensor.py b/homeassistant/components/linode/binary_sensor.py index d0c49c7171b..93bdef4a1f4 100644 --- a/homeassistant/components/linode/binary_sensor.py +++ b/homeassistant/components/linode/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/linode/switch.py b/homeassistant/components/linode/switch.py index abaf77648ef..74d2099a844 100644 --- a/homeassistant/components/linode/switch.py +++ b/homeassistant/components/linode/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import ( SwitchEntity, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/linux_battery/sensor.py b/homeassistant/components/linux_battery/sensor.py index 789195e1169..fffb6357a28 100644 --- a/homeassistant/components/linux_battery/sensor.py +++ b/homeassistant/components/linux_battery/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ATTR_NAME, ATTR_SERIAL_NUMBER, CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/litejet/config_flow.py b/homeassistant/components/litejet/config_flow.py index 9aa0b19c506..aeae8f52144 100644 --- a/homeassistant/components/litejet/config_flow.py +++ b/homeassistant/components/litejet/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_PORT from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import CONF_DEFAULT_TRANSITION, DOMAIN diff --git a/homeassistant/components/litejet/trigger.py b/homeassistant/components/litejet/trigger.py index 2786cc8b76a..a35bf6fb65e 100644 --- a/homeassistant/components/litejet/trigger.py +++ b/homeassistant/components/litejet/trigger.py @@ -11,11 +11,11 @@ import voluptuous as vol from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py index 6e3743059b3..3fa93b14dd9 100644 --- a/homeassistant/components/litterrobot/time.py +++ b/homeassistant/components/litterrobot/time.py @@ -13,7 +13,7 @@ from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index 2f9e2e9b24d..314fab6a621 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -18,7 +18,7 @@ from homeassistant.components.vacuum import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity diff --git a/homeassistant/components/locative/__init__.py b/homeassistant/components/locative/__init__.py index ff2c2c4c3a3..4154f343f42 100644 --- a/homeassistant/components/locative/__init__.py +++ b/homeassistant/components/locative/__init__.py @@ -19,8 +19,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/logentries/__init__.py b/homeassistant/components/logentries/__init__.py index 8ddf4a1a543..68d6af7e7dd 100644 --- a/homeassistant/components/logentries/__init__.py +++ b/homeassistant/components/logentries/__init__.py @@ -8,8 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_TOKEN, EVENT_STATE_CHANGED from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/london_air/sensor.py b/homeassistant/components/london_air/sensor.py index 16dbfa5b871..81133433d05 100644 --- a/homeassistant/components/london_air/sensor.py +++ b/homeassistant/components/london_air/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( SensorEntity, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index 015f7e8ecdc..645f8f48ae2 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -14,8 +14,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/luci/device_tracker.py b/homeassistant/components/luci/device_tracker.py index cf04cdb292a..0ce92538472 100644 --- a/homeassistant/components/luci/device_tracker.py +++ b/homeassistant/components/luci/device_tracker.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/luftdaten/config_flow.py b/homeassistant/components/luftdaten/config_flow.py index ba14afeb092..1ee444d5c84 100644 --- a/homeassistant/components/luftdaten/config_flow.py +++ b/homeassistant/components/luftdaten/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_SHOW_ON_MAP from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import CONF_SENSOR_ID, DOMAIN diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 26fc5ba153e..d697d6244b5 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -17,8 +17,11 @@ from homeassistant import config_entries from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index 87b5d566bb8..c5d17cfb176 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -34,8 +34,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator diff --git a/homeassistant/components/mailgun/__init__.py b/homeassistant/components/mailgun/__init__.py index 72617b2f42d..eb704a2d797 100644 --- a/homeassistant/components/mailgun/__init__.py +++ b/homeassistant/components/mailgun/__init__.py @@ -12,8 +12,7 @@ from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_DOMAIN, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index 244f38e0902..2b4d680208e 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -25,13 +25,13 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util DOMAIN = "manual" diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index 768690e8ec5..cb03b71ce22 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -26,14 +26,14 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( async_track_point_in_time, async_track_state_change_event, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/marytts/tts.py b/homeassistant/components/marytts/tts.py index 89832c01937..08d78ecf5c3 100644 --- a/homeassistant/components/marytts/tts.py +++ b/homeassistant/components/marytts/tts.py @@ -11,7 +11,7 @@ from homeassistant.components.tts import ( Provider, ) from homeassistant.const import CONF_EFFECT, CONF_HOST, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv CONF_VOICE = "voice" CONF_CODEC = "codec" diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index e1b488c0fce..8640aa4d074 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -39,7 +39,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event as HassEvent, HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JsonObjectType, load_json_object diff --git a/homeassistant/components/matrix/notify.py b/homeassistant/components/matrix/notify.py index b05c7952d1f..0fc08e6c5aa 100644 --- a/homeassistant/components/matrix/notify.py +++ b/homeassistant/components/matrix/notify.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( BaseNotificationService, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import RoomID diff --git a/homeassistant/components/maxcube/__init__.py b/homeassistant/components/maxcube/__init__.py index d4a3a45f441..4e79a00fed0 100644 --- a/homeassistant/components/maxcube/__init__.py +++ b/homeassistant/components/maxcube/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType from homeassistant.util.dt import now diff --git a/homeassistant/components/mealie/coordinator.py b/homeassistant/components/mealie/coordinator.py index 7d4f23d706e..cf8dfb5bc90 100644 --- a/homeassistant/components/mealie/coordinator.py +++ b/homeassistant/components/mealie/coordinator.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/mediaroom/media_player.py b/homeassistant/components/mediaroom/media_player.py index 97b61da437a..4561c38ce80 100644 --- a/homeassistant/components/mediaroom/media_player.py +++ b/homeassistant/components/mediaroom/media_player.py @@ -29,7 +29,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/meraki/device_tracker.py b/homeassistant/components/meraki/device_tracker.py index 0eb3742a878..70995fc69b5 100644 --- a/homeassistant/components/meraki/device_tracker.py +++ b/homeassistant/components/meraki/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType CONF_VALIDATOR = "validator" diff --git a/homeassistant/components/message_bird/notify.py b/homeassistant/components/message_bird/notify.py index 6da0e8176ef..c5cbe695243 100644 --- a/homeassistant/components/message_bird/notify.py +++ b/homeassistant/components/message_bird/notify.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_API_KEY, CONF_SENDER from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index 62964d22bb1..e5db80b2997 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, diff --git a/homeassistant/components/met_eireann/__init__.py b/homeassistant/components/met_eireann/__init__.py index ab2695cbd11..01917707bf7 100644 --- a/homeassistant/components/met_eireann/__init__.py +++ b/homeassistant/components/met_eireann/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, P from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/met_eireann/config_flow.py b/homeassistant/components/met_eireann/config_flow.py index 422b46827da..761d0655237 100644 --- a/homeassistant/components/met_eireann/config_flow.py +++ b/homeassistant/components/met_eireann/config_flow.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN, HOME_LOCATION_NAME diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 4b79b046b75..5c4ada6b5f1 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( diff --git a/homeassistant/components/meteoalarm/binary_sensor.py b/homeassistant/components/meteoalarm/binary_sensor.py index 3400ca52f50..95124445363 100644 --- a/homeassistant/components/meteoalarm/binary_sensor.py +++ b/homeassistant/components/meteoalarm/binary_sensor.py @@ -15,10 +15,10 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mfi/sensor.py b/homeassistant/components/mfi/sensor.py index b93cc669e62..f666e2d614a 100644 --- a/homeassistant/components/mfi/sensor.py +++ b/homeassistant/components/mfi/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/mfi/switch.py b/homeassistant/components/mfi/switch.py index 833a2c21301..2a05018f301 100644 --- a/homeassistant/components/mfi/switch.py +++ b/homeassistant/components/mfi/switch.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/microsoft/tts.py b/homeassistant/components/microsoft/tts.py index aa33072089f..b5e770601b4 100644 --- a/homeassistant/components/microsoft/tts.py +++ b/homeassistant/components/microsoft/tts.py @@ -13,7 +13,7 @@ from homeassistant.components.tts import ( ) from homeassistant.const import CONF_API_KEY, CONF_REGION, CONF_TYPE, PERCENTAGE from homeassistant.generated.microsoft_tts import SUPPORTED_LANGUAGES -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv CONF_GENDER = "gender" CONF_OUTPUT = "output" diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index fa4de7f9c99..23c9885e0c5 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -16,8 +16,8 @@ from homeassistant.components import camera from homeassistant.const import ATTR_NAME, CONF_API_KEY, CONF_TIMEOUT, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/microsoft_face_detect/image_processing.py b/homeassistant/components/microsoft_face_detect/image_processing.py index 80037a29fa8..ce49f0b1f65 100644 --- a/homeassistant/components/microsoft_face_detect/image_processing.py +++ b/homeassistant/components/microsoft_face_detect/image_processing.py @@ -17,7 +17,7 @@ from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/microsoft_face_identify/image_processing.py b/homeassistant/components/microsoft_face_identify/image_processing.py index 03a6ad22fcd..025a7eccdda 100644 --- a/homeassistant/components/microsoft_face_identify/image_processing.py +++ b/homeassistant/components/microsoft_face_identify/image_processing.py @@ -16,7 +16,7 @@ from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE from homeassistant.const import ATTR_NAME, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/mikrotik/device.py b/homeassistant/components/mikrotik/device.py index bf3cb47adc3..7963c48d936 100644 --- a/homeassistant/components/mikrotik/device.py +++ b/homeassistant/components/mikrotik/device.py @@ -5,8 +5,7 @@ from __future__ import annotations from datetime import datetime from typing import Any -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from .const import ATTR_DEVICE_TRACKER diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index c2d9e0d2f33..19d5c789c09 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import MikrotikConfigEntry from .coordinator import Device, MikrotikDataUpdateCoordinator diff --git a/homeassistant/components/minio/__init__.py b/homeassistant/components/minio/__init__.py index 57a9632a6ff..18a82f3a8ed 100644 --- a/homeassistant/components/minio/__init__.py +++ b/homeassistant/components/minio/__init__.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .minio_helper import MinioEventThread, create_minio_client diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index c0efd302c47..1980c80ce69 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( ATTR_APP_DATA, diff --git a/homeassistant/components/mochad/__init__.py b/homeassistant/components/mochad/__init__.py index c8714c902a3..9e992b5babd 100644 --- a/homeassistant/components/mochad/__init__.py +++ b/homeassistant/components/mochad/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 262d13ad3af..750ddce8513 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -34,7 +34,7 @@ from homeassistant.core import ( State, callback, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index 1e2674a24bf..09048579859 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index a79d933a782..db3901016f7 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -29,11 +29,10 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/mqtt_eventstream/__init__.py b/homeassistant/components/mqtt_eventstream/__init__.py index 5e677d13cfe..20602e03f81 100644 --- a/homeassistant/components/mqtt_eventstream/__init__.py +++ b/homeassistant/components/mqtt_eventstream/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import ( MATCH_ALL, ) from homeassistant.core import EventOrigin, HomeAssistant, State, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt_json/device_tracker.py b/homeassistant/components/mqtt_json/device_tracker.py index 3200da56cf6..6f4e83799d1 100644 --- a/homeassistant/components/mqtt_json/device_tracker.py +++ b/homeassistant/components/mqtt_json/device_tracker.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_DEVICES, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 849d4562423..242c39cb983 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( STATE_NOT_HOME, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify diff --git a/homeassistant/components/mqtt_statestream/__init__.py b/homeassistant/components/mqtt_statestream/__init__.py index 3a0953a0158..9a08fa2c73a 100644 --- a/homeassistant/components/mqtt_statestream/__init__.py +++ b/homeassistant/components/mqtt_statestream/__init__.py @@ -9,7 +9,7 @@ from homeassistant.components import mqtt from homeassistant.components.mqtt import valid_publish_topic from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, convert_include_exclude_filter, diff --git a/homeassistant/components/msteams/notify.py b/homeassistant/components/msteams/notify.py index a4de5d126d5..06f9bc42e91 100644 --- a/homeassistant/components/msteams/notify.py +++ b/homeassistant/components/msteams/notify.py @@ -16,7 +16,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index 052f4f556c1..e569bb93a42 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -15,9 +15,8 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py index f3297bf0a6f..bcd33b7fd6c 100644 --- a/homeassistant/components/music_assistant/actions.py +++ b/homeassistant/components/music_assistant/actions.py @@ -16,7 +16,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( ATTR_ALBUM_ARTISTS_ONLY, diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 9aa7498a2ee..4a7e20046b2 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -39,8 +39,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import ATTR_NAME, STATE_OFF from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, diff --git a/homeassistant/components/music_assistant/schemas.py b/homeassistant/components/music_assistant/schemas.py index 9caae2ee0b4..d8c4fe1649d 100644 --- a/homeassistant/components/music_assistant/schemas.py +++ b/homeassistant/components/music_assistant/schemas.py @@ -8,7 +8,7 @@ from music_assistant_models.enums import MediaType import voluptuous as vol from homeassistant.const import ATTR_NAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( ATTR_ACTIVE, diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py index b482de8130c..d8b43517711 100644 --- a/homeassistant/components/mvglive/sensor.py +++ b/homeassistant/components/mvglive/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/mycroft/__init__.py b/homeassistant/components/mycroft/__init__.py index 557eca972e6..e5893e57a8e 100644 --- a/homeassistant/components/mycroft/__init__.py +++ b/homeassistant/components/mycroft/__init__.py @@ -4,8 +4,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType DOMAIN = "mycroft" diff --git a/homeassistant/components/mysensors/config_flow.py b/homeassistant/components/mysensors/config_flow.py index f3fb03ffac8..e616e325835 100644 --- a/homeassistant/components/mysensors/config_flow.py +++ b/homeassistant/components/mysensors/config_flow.py @@ -20,8 +20,7 @@ from homeassistant.components.mqtt import ( from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE from homeassistant.core import callback -from homeassistant.helpers import selector -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.typing import VolDictType from .const import ( diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index fa3464c0088..bdc83f30b21 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -22,7 +22,7 @@ from homeassistant.components.mqtt import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.setup import SetupPhases, async_pause_setup from homeassistant.util.unit_system import METRIC_SYSTEM diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index 74dc99e76d3..c96ad6cea8e 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util.decorator import Registry diff --git a/homeassistant/components/mythicbeastsdns/__init__.py b/homeassistant/components/mythicbeastsdns/__init__.py index f4de18aa0ef..58ac6051c8a 100644 --- a/homeassistant/components/mythicbeastsdns/__init__.py +++ b/homeassistant/components/mythicbeastsdns/__init__.py @@ -12,8 +12,8 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType From 9046ab025021979d955d14892e7f354b9d132b13 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:56:50 +0100 Subject: [PATCH 1177/2987] Standardize homeassistant imports in component (i-k) (#136826) --- homeassistant/components/iammeter/sensor.py | 8 ++++++-- homeassistant/components/ibeacon/config_flow.py | 2 +- homeassistant/components/icloud/__init__.py | 2 +- homeassistant/components/idteck_prox/__init__.py | 2 +- homeassistant/components/ifttt/__init__.py | 3 +-- homeassistant/components/ifttt/alarm_control_panel.py | 2 +- homeassistant/components/ign_sismologia/geo_location.py | 2 +- homeassistant/components/ihc/__init__.py | 2 +- homeassistant/components/ihc/auto_setup.py | 3 +-- homeassistant/components/ihc/manual_setup.py | 3 +-- homeassistant/components/ihc/service_functions.py | 2 +- homeassistant/components/image_upload/__init__.py | 2 +- homeassistant/components/imap/__init__.py | 2 +- homeassistant/components/influxdb/__init__.py | 7 +++++-- homeassistant/components/influxdb/const.py | 2 +- homeassistant/components/influxdb/sensor.py | 2 +- homeassistant/components/insteon/api/properties.py | 2 +- homeassistant/components/insteon/schemas.py | 2 +- homeassistant/components/intesishome/climate.py | 2 +- homeassistant/components/ios/notify.py | 2 +- homeassistant/components/iperf3/__init__.py | 2 +- homeassistant/components/ipma/config_flow.py | 2 +- homeassistant/components/irish_rail_transport/sensor.py | 2 +- .../components/islamic_prayer_times/coordinator.py | 2 +- homeassistant/components/israel_rail/coordinator.py | 2 +- homeassistant/components/isy994/services.py | 2 +- homeassistant/components/itach/remote.py | 2 +- homeassistant/components/itunes/media_player.py | 2 +- homeassistant/components/izone/__init__.py | 2 +- homeassistant/components/jewish_calendar/binary_sensor.py | 2 +- homeassistant/components/jewish_calendar/sensor.py | 2 +- homeassistant/components/joaoapps_join/__init__.py | 2 +- homeassistant/components/joaoapps_join/notify.py | 2 +- homeassistant/components/kankun/switch.py | 2 +- homeassistant/components/keba/__init__.py | 3 +-- homeassistant/components/keenetic_ndms2/device_tracker.py | 2 +- homeassistant/components/keenetic_ndms2/router.py | 2 +- homeassistant/components/keyboard_remote/__init__.py | 2 +- homeassistant/components/kira/__init__.py | 3 +-- homeassistant/components/kitchen_sink/__init__.py | 2 +- homeassistant/components/kitchen_sink/weather.py | 2 +- homeassistant/components/kiwi/lock.py | 2 +- homeassistant/components/knx/datetime.py | 2 +- homeassistant/components/knx/schema.py | 2 +- homeassistant/components/knx/services.py | 2 +- homeassistant/components/knx/telegrams.py | 2 +- homeassistant/components/knx/validation.py | 2 +- homeassistant/components/kodi/media_player.py | 2 +- homeassistant/components/kodi/notify.py | 2 +- homeassistant/components/kwb/sensor.py | 2 +- 50 files changed, 59 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/iammeter/sensor.py b/homeassistant/components/iammeter/sensor.py index 1069c6696fc..047281bdb27 100644 --- a/homeassistant/components/iammeter/sensor.py +++ b/homeassistant/components/iammeter/sensor.py @@ -32,8 +32,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import debounce, entity_registry as er, update_coordinator -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + debounce, + entity_registry as er, + update_coordinator, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ibeacon/config_flow.py b/homeassistant/components/ibeacon/config_flow.py index c00398e39b0..5850a623ad8 100644 --- a/homeassistant/components/ibeacon/config_flow.py +++ b/homeassistant/components/ibeacon/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType from .const import CONF_ALLOW_NAMELESS_UUIDS, DOMAIN diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index 5bdfd00dc60..4ed66be6a4b 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.storage import Store from homeassistant.util import slugify diff --git a/homeassistant/components/idteck_prox/__init__.py b/homeassistant/components/idteck_prox/__init__.py index 7b92499a197..68969f1eced 100644 --- a/homeassistant/components/idteck_prox/__init__.py +++ b/homeassistant/components/idteck_prox/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index e3db68e2302..c5682e5a8d9 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -15,8 +15,7 @@ from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/ifttt/alarm_control_panel.py b/homeassistant/components/ifttt/alarm_control_panel.py index 739352485bd..f36fe8e672b 100644 --- a/homeassistant/components/ifttt/alarm_control_panel.py +++ b/homeassistant/components/ifttt/alarm_control_panel.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_OPTIMISTIC, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ign_sismologia/geo_location.py b/homeassistant/components/ign_sismologia/geo_location.py index 7076d6a77a9..e99f2b23ca0 100644 --- a/homeassistant/components/ign_sismologia/geo_location.py +++ b/homeassistant/components/ign_sismologia/geo_location.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_time_interval diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index d443ac335db..0fc62301984 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .auto_setup import autosetup_ihc_products diff --git a/homeassistant/components/ihc/auto_setup.py b/homeassistant/components/ihc/auto_setup.py index 2d6e59131cd..9b711875167 100644 --- a/homeassistant/components/ihc/auto_setup.py +++ b/homeassistant/components/ihc/auto_setup.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.config import load_yaml_config_file from homeassistant.const import CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from .const import ( AUTO_SETUP_YAML, diff --git a/homeassistant/components/ihc/manual_setup.py b/homeassistant/components/ihc/manual_setup.py index c453494e263..f17920145e7 100644 --- a/homeassistant/components/ihc/manual_setup.py +++ b/homeassistant/components/ihc/manual_setup.py @@ -15,8 +15,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/ihc/service_functions.py b/homeassistant/components/ihc/service_functions.py index 61eba4791ac..d5507328e73 100644 --- a/homeassistant/components/ihc/service_functions.py +++ b/homeassistant/components/ihc/service_functions.py @@ -3,7 +3,7 @@ import voluptuous as vol from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( ATTR_CONTROLLER_ID, diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py index 5e9cf8c4e0e..2bf28d13fd2 100644 --- a/homeassistant/components/image_upload/__init__.py +++ b/homeassistant/components/image_upload/__init__.py @@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index f62edf1451f..5349f249ab3 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -23,7 +23,7 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, ServiceValidationError, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import CONF_ENABLE_PUSH, DOMAIN diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index b1c0cc53d61..95a94cf8fa0 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -40,8 +40,11 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Event, HomeAssistant, State, callback -from homeassistant.helpers import event as event_helper, state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + event as event_helper, + state as state_helper, +) from homeassistant.helpers.entity_values import EntityValues from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, diff --git a/homeassistant/components/influxdb/const.py b/homeassistant/components/influxdb/const.py index cab9d1e4c41..78cb7908eec 100644 --- a/homeassistant/components/influxdb/const.py +++ b/homeassistant/components/influxdb/const.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv CONF_DB_NAME = "database" CONF_BUCKET = "bucket" diff --git a/homeassistant/components/influxdb/sensor.py b/homeassistant/components/influxdb/sensor.py index cc601888f56..30319416a61 100644 --- a/homeassistant/components/influxdb/sensor.py +++ b/homeassistant/components/influxdb/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady, TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/insteon/api/properties.py b/homeassistant/components/insteon/api/properties.py index 4d36f1d71e5..ac633e2a457 100644 --- a/homeassistant/components/insteon/api/properties.py +++ b/homeassistant/components/insteon/api/properties.py @@ -22,7 +22,7 @@ import voluptuous_serialize from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from ..const import ( DEVICE_ADDRESS, diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index 4cf8d49d170..70458dc5d6f 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_USERNAME, ENTITY_MATCH_ALL, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_CAT, diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index 1a1f58a6b80..a04a6ee6377 100644 --- a/homeassistant/components/intesishome/climate.py +++ b/homeassistant/components/intesishome/climate.py @@ -32,8 +32,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index b5bd0aea58f..cf70a97f52a 100644 --- a/homeassistant/components/ios/notify.py +++ b/homeassistant/components/ios/notify.py @@ -18,7 +18,7 @@ from homeassistant.components.notify import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import device_name_for_push_id, devices_with_push, enabled_push_ids diff --git a/homeassistant/components/iperf3/__init__.py b/homeassistant/components/iperf3/__init__.py index a621f1fb27e..3fbe447f9fb 100644 --- a/homeassistant/components/iperf3/__init__.py +++ b/homeassistant/components/iperf3/__init__.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfDataRate, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import async_track_time_interval diff --git a/homeassistant/components/ipma/config_flow.py b/homeassistant/components/ipma/config_flow.py index a0ecf1f582e..9b0fbe29736 100644 --- a/homeassistant/components/ipma/config_flow.py +++ b/homeassistant/components/ipma/config_flow.py @@ -10,8 +10,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py index 2765a14b7a3..cd0ccccaece 100644 --- a/homeassistant/components/irish_rail_transport/sensor.py +++ b/homeassistant/components/irish_rail_transport/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index 7005bee3585..35903afa393 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( CONF_CALC_METHOD, diff --git a/homeassistant/components/israel_rail/coordinator.py b/homeassistant/components/israel_rail/coordinator.py index d707f8c5ea6..b022e3fd790 100644 --- a/homeassistant/components/israel_rail/coordinator.py +++ b/homeassistant/components/israel_rail/coordinator.py @@ -13,7 +13,7 @@ from israelrailapi.train_station import station_name_to_id from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DEFAULT_SCAN_INTERVAL, DEPARTURES_COUNT, DOMAIN diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 1cd46446ed6..6546aec6efa 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -14,7 +14,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.service import entity_service_call diff --git a/homeassistant/components/itach/remote.py b/homeassistant/components/itach/remote.py index 986dbfb8b95..235d290cccb 100644 --- a/homeassistant/components/itach/remote.py +++ b/homeassistant/components/itach/remote.py @@ -24,7 +24,7 @@ from homeassistant.const import ( DEVICE_DEFAULT_NAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/itunes/media_player.py b/homeassistant/components/itunes/media_player.py index 0f241041c0d..92e3aefe975 100644 --- a/homeassistant/components/itunes/media_player.py +++ b/homeassistant/components/itunes/media_player.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py index c00f2d1f83f..1fd9a03e05f 100644 --- a/homeassistant/components/izone/__init__.py +++ b/homeassistant/components/izone/__init__.py @@ -6,7 +6,7 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EXCLUDE, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DATA_CONFIG, IZONE diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 9fd1371f8a8..85519bf37b0 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import event from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .entity import JewishCalendarConfigEntry, JewishCalendarEntity diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index d3e70eb411c..5e02435ed06 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import SUN_EVENT_SUNSET, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import get_astral_event_date -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .entity import JewishCalendarConfigEntry, JewishCalendarEntity diff --git a/homeassistant/components/joaoapps_join/__init__.py b/homeassistant/components/joaoapps_join/__init__.py index f537866054f..89b5748a714 100644 --- a/homeassistant/components/joaoapps_join/__init__.py +++ b/homeassistant/components/joaoapps_join/__init__.py @@ -15,7 +15,7 @@ import voluptuous as vol from homeassistant.const import CONF_API_KEY, CONF_DEVICE_ID, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/joaoapps_join/notify.py b/homeassistant/components/joaoapps_join/notify.py index 7fab894b0e4..a3432b96b13 100644 --- a/homeassistant/components/joaoapps_join/notify.py +++ b/homeassistant/components/joaoapps_join/notify.py @@ -16,7 +16,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_API_KEY, CONF_DEVICE_ID from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/kankun/switch.py b/homeassistant/components/kankun/switch.py index cd91b7660c8..51bddebeb77 100644 --- a/homeassistant/components/kankun/switch.py +++ b/homeassistant/components/kankun/switch.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/keba/__init__.py b/homeassistant/components/keba/__init__.py index 34eb7c99166..2c372cf1a25 100644 --- a/homeassistant/components/keba/__init__.py +++ b/homeassistant/components/keba/__init__.py @@ -8,8 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py index efd2a88b1f8..0f5166e16dd 100644 --- a/homeassistant/components/keenetic_ndms2/device_tracker.py +++ b/homeassistant/components/keenetic_ndms2/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN, ROUTER from .router import KeeneticRouter diff --git a/homeassistant/components/keenetic_ndms2/router.py b/homeassistant/components/keenetic_ndms2/router.py index 5a4f32a05cd..8c3079b910d 100644 --- a/homeassistant/components/keenetic_ndms2/router.py +++ b/homeassistant/components/keenetic_ndms2/router.py @@ -22,7 +22,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( CONF_CONSIDER_HOME, diff --git a/homeassistant/components/keyboard_remote/__init__.py b/homeassistant/components/keyboard_remote/__init__.py index 5831a770466..979aeb73e45 100644 --- a/homeassistant/components/keyboard_remote/__init__.py +++ b/homeassistant/components/keyboard_remote/__init__.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/kira/__init__.py b/homeassistant/components/kira/__init__.py index 52618a125b6..092fdf8398c 100644 --- a/homeassistant/components/kira/__init__.py +++ b/homeassistant/components/kira/__init__.py @@ -21,8 +21,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType DOMAIN = "kira" diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 88d0c868636..09a72fc529c 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN diff --git a/homeassistant/components/kitchen_sink/weather.py b/homeassistant/components/kitchen_sink/weather.py index 8a12cb4bdb9..e94e823c692 100644 --- a/homeassistant/components/kitchen_sink/weather.py +++ b/homeassistant/components/kitchen_sink/weather.py @@ -28,7 +28,7 @@ from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util CONDITION_CLASSES: dict[str, list[str]] = { ATTR_CONDITION_CLOUDY: [], diff --git a/homeassistant/components/kiwi/lock.py b/homeassistant/components/kiwi/lock.py index 887747d4ca4..d378fcbcbed 100644 --- a/homeassistant/components/kiwi/lock.py +++ b/homeassistant/components/kiwi/lock.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index caeaed6da93..b75e1a14f67 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import KNXModule from .const import ( diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 1ac2b82247c..c9fe0bfc34e 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -41,7 +41,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, Platform, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA from .const import ( diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py index 6c392902737..f0f760180f4 100644 --- a/homeassistant/components/knx/services.py +++ b/homeassistant/components/knx/services.py @@ -15,7 +15,7 @@ from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWri from homeassistant.const import CONF_TYPE, SERVICE_RELOAD from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_register_admin_service from .const import ( diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index dcd5f477679..df49c84b6d5 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -15,7 +15,7 @@ from xknx.telegram.apci import GroupValueResponse, GroupValueWrite from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.signal_type import SignalType from .const import DOMAIN diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py index 0283b65f899..6a2224c5561 100644 --- a/homeassistant/components/knx/validation.py +++ b/homeassistant/components/knx/validation.py @@ -10,7 +10,7 @@ from xknx.dpt import DPTBase, DPTNumeric, DPTString from xknx.exceptions import CouldNotParseAddress from xknx.telegram.address import IndividualAddress, parse_device_group_address -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv def dpt_subclass_validator(dpt_base_class: type[DPTBase]) -> Callable[[Any], str | int]: diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index cdbe4e334cb..bbddbd9f348 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -50,7 +50,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.network import is_internal_request from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .browse_media import ( build_item_response, diff --git a/homeassistant/components/kodi/notify.py b/homeassistant/components/kodi/notify.py index c811a073cbb..8360f74ce24 100644 --- a/homeassistant/components/kodi/notify.py +++ b/homeassistant/components/kodi/notify.py @@ -24,8 +24,8 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/kwb/sensor.py b/homeassistant/components/kwb/sensor.py index dbe57f9a517..0074c3a4344 100644 --- a/homeassistant/components/kwb/sensor.py +++ b/homeassistant/components/kwb/sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType From b594c29171ff74dfd5681185a97b2d22b77b5218 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:57:01 +0100 Subject: [PATCH 1178/2987] Standardize homeassistant imports in component (g-h) (#136825) --- homeassistant/components/garadget/cover.py | 2 +- homeassistant/components/gardena_bluetooth/__init__.py | 2 +- homeassistant/components/gardena_bluetooth/sensor.py | 2 +- homeassistant/components/gc100/__init__.py | 2 +- homeassistant/components/gc100/binary_sensor.py | 2 +- homeassistant/components/gc100/switch.py | 2 +- homeassistant/components/geniushub/entity.py | 2 +- homeassistant/components/geniushub/sensor.py | 2 +- homeassistant/components/geo_rss_events/sensor.py | 2 +- homeassistant/components/geofency/__init__.py | 3 +-- homeassistant/components/github/config_flow.py | 2 +- homeassistant/components/gitlab_ci/sensor.py | 2 +- homeassistant/components/gitter/sensor.py | 2 +- homeassistant/components/goodwe/sensor.py | 2 +- homeassistant/components/google/__init__.py | 3 +-- homeassistant/components/google_cloud/helpers.py | 2 +- homeassistant/components/google_maps/device_tracker.py | 2 +- homeassistant/components/google_pubsub/__init__.py | 2 +- homeassistant/components/google_sheets/__init__.py | 2 +- homeassistant/components/google_travel_time/config_flow.py | 2 +- homeassistant/components/google_travel_time/sensor.py | 2 +- homeassistant/components/google_wifi/sensor.py | 2 +- homeassistant/components/gpslogger/__init__.py | 3 +-- homeassistant/components/graphite/__init__.py | 3 +-- homeassistant/components/greeneye_monitor/__init__.py | 2 +- homeassistant/components/greenwave/light.py | 2 +- homeassistant/components/gstreamer/media_player.py | 2 +- homeassistant/components/gtfs/sensor.py | 5 ++--- homeassistant/components/hardware/websocket_api.py | 2 +- homeassistant/components/harman_kardon_avr/media_player.py | 2 +- homeassistant/components/harmony/remote.py | 3 +-- homeassistant/components/haveibeenpwned/sensor.py | 5 ++--- homeassistant/components/hddtemp/sensor.py | 2 +- homeassistant/components/hdmi_cec/__init__.py | 3 +-- homeassistant/components/heatmiser/climate.py | 2 +- homeassistant/components/heos/__init__.py | 3 +-- homeassistant/components/here_travel_time/config_flow.py | 2 +- homeassistant/components/here_travel_time/coordinator.py | 2 +- homeassistant/components/hikvision/binary_sensor.py | 2 +- homeassistant/components/hikvisioncam/switch.py | 2 +- homeassistant/components/hisense_aehw4a1/__init__.py | 2 +- homeassistant/components/history/__init__.py | 4 ++-- homeassistant/components/history/websocket_api.py | 2 +- homeassistant/components/history_stats/data.py | 2 +- homeassistant/components/history_stats/helpers.py | 2 +- homeassistant/components/history_stats/sensor.py | 2 +- homeassistant/components/hitron_coda/device_tracker.py | 2 +- homeassistant/components/hlk_sw16/__init__.py | 2 +- homeassistant/components/home_connect/sensor.py | 3 +-- homeassistant/components/homekit/util.py | 2 +- homeassistant/components/homematic/__init__.py | 3 +-- homeassistant/components/homematic/entity.py | 2 +- homeassistant/components/homematic/notify.py | 3 +-- homeassistant/components/homematicip_cloud/services.py | 2 +- homeassistant/components/homeworks/__init__.py | 2 +- homeassistant/components/horizon/media_player.py | 2 +- homeassistant/components/hp_ilo/sensor.py | 2 +- homeassistant/components/hue/services.py | 2 +- homeassistant/components/hvv_departures/config_flow.py | 3 +-- homeassistant/components/hyperion/config_flow.py | 2 +- 60 files changed, 63 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py index 82045e91321..ef11038aee4 100644 --- a/homeassistant/components/garadget/cover.py +++ b/homeassistant/components/garadget/cover.py @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_utc_time_change from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py index 7aae629974c..47034e61fb9 100644 --- a/homeassistant/components/gardena_bluetooth/__init__.py +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceInfo -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import DeviceUnavailable, GardenaBluetoothCoordinator diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index ee8a2663218..c07d2ba6866 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import GardenaBluetoothConfigEntry from .coordinator import GardenaBluetoothCoordinator diff --git a/homeassistant/components/gc100/__init__.py b/homeassistant/components/gc100/__init__.py index 57c8e92499f..a43741b9249 100644 --- a/homeassistant/components/gc100/__init__.py +++ b/homeassistant/components/gc100/__init__.py @@ -5,7 +5,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType CONF_PORTS = "ports" diff --git a/homeassistant/components/gc100/binary_sensor.py b/homeassistant/components/gc100/binary_sensor.py index 55df72cc3b9..cef798935cb 100644 --- a/homeassistant/components/gc100/binary_sensor.py +++ b/homeassistant/components/gc100/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/gc100/switch.py b/homeassistant/components/gc100/switch.py index 1bcdc7365cf..23b178cc647 100644 --- a/homeassistant/components/gc100/switch.py +++ b/homeassistant/components/gc100/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/geniushub/entity.py b/homeassistant/components/geniushub/entity.py index 7c40c41bda5..24917ab5e95 100644 --- a/homeassistant/components/geniushub/entity.py +++ b/homeassistant/components/geniushub/entity.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ATTR_DURATION, ATTR_ZONE_MODE, DOMAIN, SVC_SET_ZONE_OVERRIDE diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py index cfe4107428c..a558ad18672 100644 --- a/homeassistant/components/geniushub/sensor.py +++ b/homeassistant/components/geniushub/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import GeniusHubConfigEntry from .entity import GeniusDevice, GeniusEntity diff --git a/homeassistant/components/geo_rss_events/sensor.py b/homeassistant/components/geo_rss_events/sensor.py index 0dc8918b7dd..079a47a6c27 100644 --- a/homeassistant/components/geo_rss_events/sensor.py +++ b/homeassistant/components/geo_rss_events/sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py index d38514fc412..46a3482ce1e 100644 --- a/homeassistant/components/geofency/__init__.py +++ b/homeassistant/components/geofency/__init__.py @@ -16,8 +16,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify diff --git a/homeassistant/components/github/config_flow.py b/homeassistant/components/github/config_flow.py index 9977f9d84cc..17338119b9f 100644 --- a/homeassistant/components/github/config_flow.py +++ b/homeassistant/components/github/config_flow.py @@ -23,11 +23,11 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import ( SERVER_SOFTWARE, async_get_clientsession, ) -import homeassistant.helpers.config_validation as cv from .const import CLIENT_ID, CONF_REPOSITORIES, DEFAULT_REPOSITORIES, DOMAIN, LOGGER diff --git a/homeassistant/components/gitlab_ci/sensor.py b/homeassistant/components/gitlab_ci/sensor.py index 6ed3112b2af..933ba0e482e 100644 --- a/homeassistant/components/gitlab_ci/sensor.py +++ b/homeassistant/components/gitlab_ci/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/gitter/sensor.py b/homeassistant/components/gitter/sensor.py index bc444655908..957ac4e9d8c 100644 --- a/homeassistant/components/gitter/sensor.py +++ b/homeassistant/components/gitter/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_ROOM from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index 03912c9a1ec..5a88ac612da 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -37,7 +37,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN, KEY_COORDINATOR, KEY_DEVICE_INFO, KEY_INVERTER from .coordinator import GoodweUpdateCoordinator diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 2ad400aabab..2b7aeadc0ba 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -28,9 +28,8 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import generate_entity_id from .api import ApiAuthImpl, get_feature_access diff --git a/homeassistant/components/google_cloud/helpers.py b/homeassistant/components/google_cloud/helpers.py index f1adc42b4cd..f71ccea00cc 100644 --- a/homeassistant/components/google_cloud/helpers.py +++ b/homeassistant/components/google_cloud/helpers.py @@ -12,7 +12,7 @@ from google.oauth2.service_account import Credentials import voluptuous as vol from homeassistant.components.tts import CONF_LANG -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py index 31eca8fba01..fd50295a6a1 100644 --- a/homeassistant/components/google_maps/device_tracker.py +++ b/homeassistant/components/google_maps/device_tracker.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify diff --git a/homeassistant/components/google_pubsub/__init__.py b/homeassistant/components/google_pubsub/__init__.py index f289fae2456..ace56bf9354 100644 --- a/homeassistant/components/google_pubsub/__init__.py +++ b/homeassistant/components/google_pubsub/__init__.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.const import EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import Event, EventStateChangedData, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index 3f34b23d522..942db675b5a 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -20,11 +20,11 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ConfigEntrySelector from .const import DEFAULT_ACCESS, DOMAIN diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index 08de293bc7d..a29d3d75b3e 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index a764036321b..a3f9c236136 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -25,7 +25,7 @@ from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.location import find_coordinates -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( ATTRIBUTION, diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 3dd421d99da..6ce1c49410f 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle, dt as dt_util diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py index 50a98e277a6..7c7612ed201 100644 --- a/homeassistant/components/gpslogger/__init__.py +++ b/homeassistant/components/gpslogger/__init__.py @@ -10,8 +10,7 @@ from homeassistant.components.device_tracker import ATTR_BATTERY from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( diff --git a/homeassistant/components/graphite/__init__.py b/homeassistant/components/graphite/__init__.py index 336ca6ba2cb..8d1864f5522 100644 --- a/homeassistant/components/graphite/__init__.py +++ b/homeassistant/components/graphite/__init__.py @@ -19,8 +19,7 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import state -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/greeneye_monitor/__init__.py b/homeassistant/components/greeneye_monitor/__init__.py index 083d431e338..e3acbcd56e9 100644 --- a/homeassistant/components/greeneye_monitor/__init__.py +++ b/homeassistant/components/greeneye_monitor/__init__.py @@ -17,7 +17,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/greenwave/light.py b/homeassistant/components/greenwave/light.py index 89d3ca3a535..9b7a3cf29ea 100644 --- a/homeassistant/components/greenwave/light.py +++ b/homeassistant/components/greenwave/light.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/gstreamer/media_player.py b/homeassistant/components/gstreamer/media_player.py index fd9de62c016..bb78aff8faf 100644 --- a/homeassistant/components/gstreamer/media_player.py +++ b/homeassistant/components/gstreamer/media_player.py @@ -20,7 +20,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index f9e9c31ce46..2637a55f772 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -19,11 +19,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_OFFSET, STATE_UNKNOWN from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hardware/websocket_api.py b/homeassistant/components/hardware/websocket_api.py index dfbcfd4c4ac..7224c0f8f7e 100644 --- a/homeassistant/components/hardware/websocket_api.py +++ b/homeassistant/components/hardware/websocket_api.py @@ -14,7 +14,7 @@ from homeassistant.components import websocket_api from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN from .hardware import async_process_hardware_platforms diff --git a/homeassistant/components/harman_kardon_avr/media_player.py b/homeassistant/components/harman_kardon_avr/media_player.py index b8d9f27bcf1..22bc1a6d529 100644 --- a/homeassistant/components/harman_kardon_avr/media_player.py +++ b/homeassistant/components/harman_kardon_avr/media_player.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index efbd4b2ac02..43bf0a348c0 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -20,8 +20,7 @@ from homeassistant.components.remote import ( RemoteEntityFeature, ) from homeassistant.core import HassJob, HomeAssistant, callback -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity diff --git a/homeassistant/components/haveibeenpwned/sensor.py b/homeassistant/components/haveibeenpwned/sensor.py index 1aebe696e82..d9d2889848e 100644 --- a/homeassistant/components/haveibeenpwned/sensor.py +++ b/homeassistant/components/haveibeenpwned/sensor.py @@ -15,12 +15,11 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_EMAIL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_point_in_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index 7ff00b8e282..4d9bbeb9516 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 6b4a949c0fc..3e31dd73b5d 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -33,8 +33,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback -from homeassistant.helpers import discovery, event -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery, event from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, EVENT_HDMI_CEC_UNAVAILABLE diff --git a/homeassistant/components/heatmiser/climate.py b/homeassistant/components/heatmiser/climate.py index de66315a467..f44156bdcb0 100644 --- a/homeassistant/components/heatmiser/climate.py +++ b/homeassistant/components/heatmiser/climate.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index f76b95c271e..d735469c5cb 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -6,8 +6,7 @@ from datetime import timedelta from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from . import services diff --git a/homeassistant/components/here_travel_time/config_flow.py b/homeassistant/components/here_travel_time/config_flow.py index c2b70de148c..6425b5ffbed 100644 --- a/homeassistant/components/here_travel_time/config_flow.py +++ b/homeassistant/components/here_travel_time/config_flow.py @@ -31,7 +31,7 @@ from homeassistant.const import ( CONF_NAME, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( EntitySelector, LocationSelector, diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index 6591f4cb5cc..65e1305e44e 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -27,7 +27,7 @@ import voluptuous as vol from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.location import find_coordinates from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py index 0656733db6b..76cca5079e4 100644 --- a/homeassistant/components/hikvision/binary_sensor.py +++ b/homeassistant/components/hikvision/binary_sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/hikvisioncam/switch.py b/homeassistant/components/hikvisioncam/switch.py index 653d5a07174..aa16097f402 100644 --- a/homeassistant/components/hikvisioncam/switch.py +++ b/homeassistant/components/hikvisioncam/switch.py @@ -23,7 +23,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/hisense_aehw4a1/__init__.py b/homeassistant/components/hisense_aehw4a1/__init__.py index d20f6d13217..3694853fb5a 100644 --- a/homeassistant/components/hisense_aehw4a1/__init__.py +++ b/homeassistant/components/hisense_aehw4a1/__init__.py @@ -12,7 +12,7 @@ from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index ba4614bbc35..fd82b74b048 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -15,10 +15,10 @@ from homeassistant.components.recorder import get_instance, history from homeassistant.components.recorder.util import session_scope from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE from homeassistant.core import HomeAssistant, valid_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import websocket_api from .const import DOMAIN diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index e6c91453213..c57e766eaed 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -35,8 +35,8 @@ from homeassistant.helpers.event import ( async_track_state_change_event, ) from homeassistant.helpers.json import json_bytes +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import create_eager_task -import homeassistant.util.dt as dt_util from .const import EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES from .helpers import entities_may_have_state_changes_after, has_states_before diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 83528b73f6f..a69abe26f6c 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -10,7 +10,7 @@ import math from homeassistant.components.recorder import get_instance, history from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State from homeassistant.helpers.template import Template -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .helpers import async_calculate_period, floored_timestamp diff --git a/homeassistant/components/history_stats/helpers.py b/homeassistant/components/history_stats/helpers.py index 33a45d10735..99214a51369 100644 --- a/homeassistant/components/history_stats/helpers.py +++ b/homeassistant/components/history_stats/helpers.py @@ -9,7 +9,7 @@ import math from homeassistant.core import callback from homeassistant.exceptions import TemplateError from homeassistant.helpers.template import Template -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index e1241034aeb..b25daf56598 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.reload import async_setup_reload_service diff --git a/homeassistant/components/hitron_coda/device_tracker.py b/homeassistant/components/hitron_coda/device_tracker.py index 2126f5834ce..25de2d8956e 100644 --- a/homeassistant/components/hitron_coda/device_tracker.py +++ b/homeassistant/components/hitron_coda/device_tracker.py @@ -16,7 +16,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TYPE, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py index ce37be96dcd..ebd92908b93 100644 --- a/homeassistant/components/hlk_sw16/__init__.py +++ b/homeassistant/components/hlk_sw16/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SWITCHES, Platform from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 7b82ef8b676..c11254d2c02 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -14,8 +14,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import PERCENTAGE, UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from . import HomeConnectConfigEntry from .const import ( diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index a0dfcea7616..c36738b286d 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -47,7 +47,7 @@ from homeassistant.core import ( callback, split_entity_id, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.util.unit_conversion import TemperatureConverter diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index f0fc2a40278..710f2ede52b 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -24,8 +24,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py index ac0a05d24c1..5a5b2a3b8c8 100644 --- a/homeassistant/components/homematic/entity.py +++ b/homeassistant/components/homematic/entity.py @@ -10,7 +10,7 @@ from pyhomematic import HMConnection from pyhomematic.devicetypes.generic import HMGeneric from homeassistant.const import ATTR_NAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.event import track_time_interval diff --git a/homeassistant/components/homematic/notify.py b/homeassistant/components/homematic/notify.py index ced8ea6a951..1f89abea5cc 100644 --- a/homeassistant/components/homematic/notify.py +++ b/homeassistant/components/homematic/notify.py @@ -10,8 +10,7 @@ from homeassistant.components.notify import ( BaseNotificationService, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.template as template_helper +from homeassistant.helpers import config_validation as cv, template as template_helper from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index 69765ccc601..7a4dfd4916f 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import comp_entity_ids from homeassistant.helpers.service import ( async_register_admin_service, diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py index e9e8c969b61..75fdeb4f8cc 100644 --- a/homeassistant/components/homeworks/__init__.py +++ b/homeassistant/components/homeworks/__init__.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/horizon/media_player.py b/homeassistant/components/horizon/media_player.py index ba3ca5e2e35..d1b733ab84a 100644 --- a/homeassistant/components/horizon/media_player.py +++ b/homeassistant/components/horizon/media_player.py @@ -21,7 +21,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/hp_ilo/sensor.py b/homeassistant/components/hp_ilo/sensor.py index 0eeb443cf2d..b4263f53d24 100644 --- a/homeassistant/components/hp_ilo/sensor.py +++ b/homeassistant/components/hp_ilo/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/hue/services.py b/homeassistant/components/hue/services.py index 30555339f19..de6da161fba 100644 --- a/homeassistant/components/hue/services.py +++ b/homeassistant/components/hue/services.py @@ -9,7 +9,7 @@ from aiohue import HueBridgeV1, HueBridgeV2 import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import verify_domain_control from .bridge import HueBridge diff --git a/homeassistant/components/hvv_departures/config_flow.py b/homeassistant/components/hvv_departures/config_flow.py index 536b8f18259..d76ccef7cab 100644 --- a/homeassistant/components/hvv_departures/config_flow.py +++ b/homeassistant/components/hvv_departures/config_flow.py @@ -17,8 +17,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_HOST, CONF_OFFSET, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback -from homeassistant.helpers import aiohttp_client -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import CONF_FILTER, CONF_REAL_TIME, CONF_STATION, DOMAIN from .hub import GTIHub diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 045fbd986cc..72e76ef8667 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -28,7 +28,7 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo from . import create_hyperion_client From ddb71a85b3fff393143e448519ed531b091ef06a Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Wed, 29 Jan 2025 03:58:14 -0700 Subject: [PATCH 1179/2987] Update quality scale for litterrobot (#136764) --- .../components/litterrobot/manifest.json | 1 + .../components/litterrobot/quality_scale.yaml | 44 ++++--------------- script/hassfest/quality_scale.py | 1 - 3 files changed, 10 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 4f1deb9a567..f7563296711 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,6 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], + "quality_scale": "bronze", "requirements": ["pylitterbot==2024.0.0"] } diff --git a/homeassistant/components/litterrobot/quality_scale.yaml b/homeassistant/components/litterrobot/quality_scale.yaml index d5f943943bc..82f01f64d18 100644 --- a/homeassistant/components/litterrobot/quality_scale.yaml +++ b/homeassistant/components/litterrobot/quality_scale.yaml @@ -1,40 +1,20 @@ rules: - # Adjust platform files for consistent flow: - # [entity description classes] - # [entity descriptions] - # [async_setup_entry] - # [entity classes]) - # Remove RequiredKeyMixins and add kw_only to classes - # Wrap multiline lambdas in parenthesis - # Extend entity description in switch.py to use value_fn instead of getattr - # Deprecate extra state attributes in vacuum.py # Bronze - action-setup: - status: todo - comment: | - Action async_set_sleep_mode is currently setup in the vacuum platform + action-setup: done appropriate-polling: status: done comment: | Primarily relies on push data, but polls every 5 minutes for missed updates brands: done - common-modules: - status: todo - comment: | - hub.py should be renamed to coordinator.py and updated accordingly - Also should not need to return bool (never used) + common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: todo - comment: Can be finished after async_set_sleep_mode is moved to async_setup + docs-actions: done docs-high-level-description: done - docs-installation-instructions: todo - docs-removal-instructions: todo - entity-event-setup: - status: todo - comment: Do we need to subscribe to both the coordinator and robot itself? + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done entity-unique-id: done has-entity-name: done runtime-data: done @@ -61,9 +41,7 @@ rules: Other fields can be moved to const.py. Consider snapshots and testing data updates # Gold - devices: - status: done - comment: Currently uses the device_info property, could be moved to _attr_device_info + devices: done diagnostics: todo discovery-update-info: status: done @@ -81,16 +59,12 @@ rules: dynamic-devices: todo entity-category: done entity-device-class: done - entity-disabled-by-default: - status: todo - comment: Check if we should disable any entities by default + entity-disabled-by-default: done entity-translations: status: todo comment: Make sure all translated states are in sentence case exception-translations: todo - icon-translations: - status: todo - comment: BRIGHTNESS_LEVEL_ICON_MAP should be migrated to icons.json + icon-translations: done reconfiguration-flow: todo repair-issues: status: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 3eedc43f613..72c1cfae219 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1671,7 +1671,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "linux_battery", "lirc", "litejet", - "litterrobot", "livisi", "llamalab_automate", "local_calendar", From 95c632e2836f1757bddfec10296d1e107cd9dc8b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:14:21 +0100 Subject: [PATCH 1180/2987] Standardize homeassistant imports in component (t-u) (#136833) --- homeassistant/components/tami4/config_flow.py | 2 +- homeassistant/components/tank_utility/sensor.py | 2 +- homeassistant/components/tankerkoenig/config_flow.py | 2 +- homeassistant/components/tapsaff/binary_sensor.py | 2 +- homeassistant/components/tasmota/binary_sensor.py | 2 +- homeassistant/components/tcp/common.py | 2 +- homeassistant/components/tellstick/__init__.py | 3 +-- homeassistant/components/tellstick/sensor.py | 2 +- homeassistant/components/telnet/switch.py | 2 +- homeassistant/components/tensorflow/image_processing.py | 3 +-- homeassistant/components/tesla_fleet/__init__.py | 3 +-- homeassistant/components/teslemetry/__init__.py | 3 +-- homeassistant/components/tfiac/climate.py | 2 +- homeassistant/components/thermoworks_smoke/sensor.py | 2 +- homeassistant/components/thingspeak/__init__.py | 3 +-- homeassistant/components/thinkingcleaner/sensor.py | 2 +- homeassistant/components/thinkingcleaner/switch.py | 2 +- homeassistant/components/thomson/device_tracker.py | 2 +- homeassistant/components/tibber/__init__.py | 2 +- homeassistant/components/time_date/sensor.py | 4 ++-- homeassistant/components/tmb/sensor.py | 2 +- homeassistant/components/todoist/calendar.py | 2 +- homeassistant/components/tomato/device_tracker.py | 2 +- homeassistant/components/torque/sensor.py | 2 +- homeassistant/components/touchline/climate.py | 2 +- homeassistant/components/tplink/light.py | 2 +- homeassistant/components/traccar/__init__.py | 3 +-- homeassistant/components/trafikverket_train/config_flow.py | 2 +- .../components/trafikverket_weatherstation/config_flow.py | 2 +- homeassistant/components/transport_nsw/sensor.py | 2 +- homeassistant/components/travisci/sensor.py | 2 +- homeassistant/components/trend/binary_sensor.py | 3 +-- homeassistant/components/twentemilieu/calendar.py | 2 +- homeassistant/components/twilio/__init__.py | 3 +-- homeassistant/components/twilio_call/notify.py | 2 +- homeassistant/components/twilio_sms/notify.py | 2 +- homeassistant/components/twitter/notify.py | 2 +- homeassistant/components/ubus/device_tracker.py | 2 +- homeassistant/components/uk_transport/sensor.py | 5 ++--- homeassistant/components/unifi/config_flow.py | 2 +- homeassistant/components/unifi/device_tracker.py | 2 +- homeassistant/components/unifi/hub/entity_helper.py | 2 +- homeassistant/components/unifi/image.py | 2 +- homeassistant/components/unifi/sensor.py | 3 +-- homeassistant/components/unifi_direct/device_tracker.py | 2 +- homeassistant/components/unifiled/light.py | 2 +- homeassistant/components/upb/const.py | 2 +- homeassistant/components/upc_connect/device_tracker.py | 2 +- homeassistant/components/uptime/sensor.py | 2 +- .../components/usgs_earthquakes_feed/geo_location.py | 3 +-- homeassistant/components/utility_meter/__init__.py | 7 +++++-- homeassistant/components/utility_meter/sensor.py | 3 +-- homeassistant/components/uvc/camera.py | 2 +- 53 files changed, 59 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/tami4/config_flow.py b/homeassistant/components/tami4/config_flow.py index 72b19470f45..a58c801c403 100644 --- a/homeassistant/components/tami4/config_flow.py +++ b/homeassistant/components/tami4/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import CONF_PHONE, CONF_REFRESH_TOKEN, DOMAIN diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py index 6d4327a1d06..e9377e346d4 100644 --- a/homeassistant/components/tank_utility/sensor.py +++ b/homeassistant/components/tank_utility/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_DEVICES, CONF_EMAIL, CONF_PASSWORD, PERCENTAGE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index 509f293665d..8796ae46ab7 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -31,8 +31,8 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( LocationSelector, NumberSelector, diff --git a/homeassistant/components/tapsaff/binary_sensor.py b/homeassistant/components/tapsaff/binary_sensor.py index 0eb612bdc8e..beba9c91538 100644 --- a/homeassistant/components/tapsaff/binary_sensor.py +++ b/homeassistant/components/tapsaff/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_LOCATION, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/tasmota/binary_sensor.py b/homeassistant/components/tasmota/binary_sensor.py index 8a4b501af05..22cdf1a5ff0 100644 --- a/homeassistant/components/tasmota/binary_sensor.py +++ b/homeassistant/components/tasmota/binary_sensor.py @@ -14,9 +14,9 @@ from homeassistant.components import binary_sensor from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import event as evt from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.event as evt from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW diff --git a/homeassistant/components/tcp/common.py b/homeassistant/components/tcp/common.py index a89cd999ddd..1263effa96b 100644 --- a/homeassistant/components/tcp/common.py +++ b/homeassistant/components/tcp/common.py @@ -17,7 +17,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_BUFFER_SIZE, diff --git a/homeassistant/components/tellstick/__init__.py b/homeassistant/components/tellstick/__init__.py index 9d120b7aaa8..6ccc1f14b5f 100644 --- a/homeassistant/components/tellstick/__init__.py +++ b/homeassistant/components/tellstick/__init__.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/tellstick/sensor.py b/homeassistant/components/tellstick/sensor.py index 1e27511bd84..c777aa6f01f 100644 --- a/homeassistant/components/tellstick/sensor.py +++ b/homeassistant/components/tellstick/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/telnet/switch.py b/homeassistant/components/telnet/switch.py index 0178a6521c4..0fa1076c943 100644 --- a/homeassistant/components/telnet/switch.py +++ b/homeassistant/components/telnet/switch.py @@ -26,7 +26,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py index f4a3a7bfe07..15addd3513d 100644 --- a/homeassistant/components/tensorflow/image_processing.py +++ b/homeassistant/components/tensorflow/image_processing.py @@ -26,8 +26,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, ) from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.pil import draw_box diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 945c6351cfc..634e8f845f9 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -25,13 +25,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from .const import DOMAIN, LOGGER, MODELS diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index b9cbc64dcd9..6e60b34825f 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -18,9 +18,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/tfiac/climate.py b/homeassistant/components/tfiac/climate.py index e3aa9060787..9571597abe6 100644 --- a/homeassistant/components/tfiac/climate.py +++ b/homeassistant/components/tfiac/climate.py @@ -26,7 +26,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, UnitOfTemperature from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/thermoworks_smoke/sensor.py b/homeassistant/components/thermoworks_smoke/sensor.py index 7dc845ecf60..7ce0dfb9993 100644 --- a/homeassistant/components/thermoworks_smoke/sensor.py +++ b/homeassistant/components/thermoworks_smoke/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/thingspeak/__init__.py b/homeassistant/components/thingspeak/__init__.py index fdf06a9709a..1798e4f1de0 100644 --- a/homeassistant/components/thingspeak/__init__.py +++ b/homeassistant/components/thingspeak/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import event, state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, event, state as state_helper from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/thinkingcleaner/sensor.py b/homeassistant/components/thinkingcleaner/sensor.py index 4d28912e20d..ccdc1ada48e 100644 --- a/homeassistant/components/thinkingcleaner/sensor.py +++ b/homeassistant/components/thinkingcleaner/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_HOST, PERCENTAGE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/thinkingcleaner/switch.py b/homeassistant/components/thinkingcleaner/switch.py index 76c7cdb0db2..8397eeedc23 100644 --- a/homeassistant/components/thinkingcleaner/switch.py +++ b/homeassistant/components/thinkingcleaner/switch.py @@ -17,7 +17,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/thomson/device_tracker.py b/homeassistant/components/thomson/device_tracker.py index 4e44b2b1ffd..f003264b6d7 100644 --- a/homeassistant/components/thomson/device_tracker.py +++ b/homeassistant/components/thomson/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 9b5c7ee1168..424b35b963b 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -9,8 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util, ssl as ssl_util diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 245d10bebba..1e86a1ba6c6 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -17,11 +17,11 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISPLAY_OPTIONS, EVENT_CORE_CONFIG_UPDATE from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import OPTION_TYPES diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py index 126c3128f91..cbf3b073578 100644 --- a/homeassistant/components/tmb/sensor.py +++ b/homeassistant/components/tmb/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 62f9fafc02a..94581439ae9 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -22,8 +22,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/tomato/device_tracker.py b/homeassistant/components/tomato/device_tracker.py index dfa8d2bd4e1..2cef5eea0cf 100644 --- a/homeassistant/components/tomato/device_tracker.py +++ b/homeassistant/components/tomato/device_tracker.py @@ -24,7 +24,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType CONF_HTTP_ID = "http_id" diff --git a/homeassistant/components/torque/sensor.py b/homeassistant/components/torque/sensor.py index 543046fac1c..8d4183e2961 100644 --- a/homeassistant/components/torque/sensor.py +++ b/homeassistant/components/torque/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_EMAIL, CONF_NAME, DEGREE from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py index e9d27341cb7..f7eec7c54f9 100644 --- a/homeassistant/components/touchline/climate.py +++ b/homeassistant/components/touchline/climate.py @@ -15,7 +15,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, UnitOfTemperature from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index c1311c256df..718b5ed7120 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -28,7 +28,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py index fe08c3db234..5b9bc2551b7 100644 --- a/homeassistant/components/traccar/__init__.py +++ b/homeassistant/components/traccar/__init__.py @@ -9,8 +9,7 @@ from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ID, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index da1fb0f7622..57d74eef78a 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -24,8 +24,8 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py index 28b9a124fc6..f4316b887b3 100644 --- a/homeassistant/components/trafikverket_weatherstation/config_flow.py +++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py @@ -15,8 +15,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, diff --git a/homeassistant/components/transport_nsw/sensor.py b/homeassistant/components/transport_nsw/sensor.py index 5628274b967..49a11a57f65 100644 --- a/homeassistant/components/transport_nsw/sensor.py +++ b/homeassistant/components/transport_nsw/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ATTR_MODE, CONF_API_KEY, CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/travisci/sensor.py b/homeassistant/components/travisci/sensor.py index fe4a6541d9e..8193c5a67dc 100644 --- a/homeassistant/components/travisci/sensor.py +++ b/homeassistant/components/travisci/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 9691ecf0744..e5ff5c64a8b 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -32,8 +32,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py index 69c509b9edf..606fb4913d1 100644 --- a/homeassistant/components/twentemilieu/calendar.py +++ b/homeassistant/components/twentemilieu/calendar.py @@ -8,7 +8,7 @@ from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import WASTE_TYPE_TO_DESCRIPTION from .coordinator import TwenteMilieuConfigEntry diff --git a/homeassistant/components/twilio/__init__.py b/homeassistant/components/twilio/__init__.py index b54af031af3..7ed65bdd54b 100644 --- a/homeassistant/components/twilio/__init__.py +++ b/homeassistant/components/twilio/__init__.py @@ -8,8 +8,7 @@ from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/twilio_call/notify.py b/homeassistant/components/twilio_call/notify.py index ab79ea9692d..4c432e0aeb5 100644 --- a/homeassistant/components/twilio_call/notify.py +++ b/homeassistant/components/twilio_call/notify.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( ) from homeassistant.components.twilio import DATA_TWILIO from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/twilio_sms/notify.py b/homeassistant/components/twilio_sms/notify.py index 531fadcf259..a3f824f375f 100644 --- a/homeassistant/components/twilio_sms/notify.py +++ b/homeassistant/components/twilio_sms/notify.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( ) from homeassistant.components.twilio import DATA_TWILIO from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/twitter/notify.py b/homeassistant/components/twitter/notify.py index eef51ca9613..f94bcd54459 100644 --- a/homeassistant/components/twitter/notify.py +++ b/homeassistant/components/twitter/notify.py @@ -21,7 +21,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ubus/device_tracker.py b/homeassistant/components/ubus/device_tracker.py index 285a176af0a..7c50b69683f 100644 --- a/homeassistant/components/ubus/device_tracker.py +++ b/homeassistant/components/ubus/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py index a86f7a1cc83..b06d0e24891 100644 --- a/homeassistant/components/uk_transport/sensor.py +++ b/homeassistant/components/uk_transport/sensor.py @@ -17,11 +17,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MODE, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 479055b84eb..3878e4c60eb 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -33,7 +33,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_MODEL_DESCRIPTION, diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index eebffc63277..da5ca74fc37 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -27,7 +27,7 @@ from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import UnifiConfigEntry from .const import DOMAIN as UNIFI_DOMAIN diff --git a/homeassistant/components/unifi/hub/entity_helper.py b/homeassistant/components/unifi/hub/entity_helper.py index 782b026d6e4..b353ba6fc5c 100644 --- a/homeassistant/components/unifi/hub/entity_helper.py +++ b/homeassistant/components/unifi/hub/entity_helper.py @@ -10,7 +10,7 @@ from aiounifi.models.device import DeviceSetPoePortModeRequest from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util class UnifiEntityHelper: diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index 1f54f56b194..f1ada9a01e0 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -17,7 +17,7 @@ from homeassistant.components.image import ImageEntity, ImageEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import UnifiConfigEntry from .entity import ( diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 194a8575174..fd78c606043 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -48,8 +48,7 @@ from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from . import UnifiConfigEntry from .const import DEVICE_STATES diff --git a/homeassistant/components/unifi_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py index d5e2e926114..1d7511aaae8 100644 --- a/homeassistant/components/unifi_direct/device_tracker.py +++ b/homeassistant/components/unifi_direct/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/unifiled/light.py b/homeassistant/components/unifiled/light.py index 4e1981875f4..dbc73177f21 100644 --- a/homeassistant/components/unifiled/light.py +++ b/homeassistant/components/unifiled/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/upb/const.py b/homeassistant/components/upb/const.py index 16f2f1b7923..6e063c5a088 100644 --- a/homeassistant/components/upb/const.py +++ b/homeassistant/components/upb/const.py @@ -2,7 +2,7 @@ import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType DOMAIN = "upb" diff --git a/homeassistant/components/upc_connect/device_tracker.py b/homeassistant/components/upc_connect/device_tracker.py index c279be78666..bdaf01518f1 100644 --- a/homeassistant/components/upc_connect/device_tracker.py +++ b/homeassistant/components/upc_connect/device_tracker.py @@ -15,8 +15,8 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/uptime/sensor.py b/homeassistant/components/uptime/sensor.py index 266542de9d6..25917d09096 100644 --- a/homeassistant/components/uptime/sensor.py +++ b/homeassistant/components/uptime/sensor.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/usgs_earthquakes_feed/geo_location.py b/homeassistant/components/usgs_earthquakes_feed/geo_location.py index aa9817eab7d..3dd380e79a8 100644 --- a/homeassistant/components/usgs_earthquakes_feed/geo_location.py +++ b/homeassistant/components/usgs_earthquakes_feed/geo_location.py @@ -27,8 +27,7 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import aiohttp_client -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index aac31e085a0..e2b3411c193 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -11,8 +11,11 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, Platform from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import discovery, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + discovery, + entity_registry as er, +) from homeassistant.helpers.device import ( async_remove_stale_devices_links_keep_entity_device, ) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 9c13aa1984a..cd65c42b22a 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -49,8 +49,7 @@ from homeassistant.helpers.event import ( from homeassistant.helpers.start import async_at_started from homeassistant.helpers.template import is_number from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from homeassistant.util.enum import try_parse_enum from .const import ( diff --git a/homeassistant/components/uvc/camera.py b/homeassistant/components/uvc/camera.py index a6f0202ee25..0e09408551d 100644 --- a/homeassistant/components/uvc/camera.py +++ b/homeassistant/components/uvc/camera.py @@ -20,7 +20,7 @@ from homeassistant.components.camera import ( from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utc_from_timestamp From c486cc8cbb70c1190d08549d1e47a4f8b043b448 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:14:39 +0100 Subject: [PATCH 1181/2987] Add image entity for fyta (#135105) --- homeassistant/components/fyta/__init__.py | 1 + homeassistant/components/fyta/image.py | 64 +++++++++ tests/components/fyta/conftest.py | 10 ++ .../fyta/fixtures/plant_status1.json | 4 +- .../fyta/fixtures/plant_status1_update.json | 30 ++++ .../fyta/fixtures/plant_status3.json | 4 +- .../fyta/snapshots/test_diagnostics.ambr | 4 +- .../components/fyta/snapshots/test_image.ambr | 97 +++++++++++++ tests/components/fyta/test_image.py | 129 ++++++++++++++++++ 9 files changed, 337 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/fyta/image.py create mode 100644 tests/components/fyta/fixtures/plant_status1_update.json create mode 100644 tests/components/fyta/snapshots/test_image.ambr create mode 100644 tests/components/fyta/test_image.py diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py index 77724e3f673..ab4a74c627a 100644 --- a/homeassistant/components/fyta/__init__.py +++ b/homeassistant/components/fyta/__init__.py @@ -25,6 +25,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.IMAGE, Platform.SENSOR, ] type FytaConfigEntry = ConfigEntry[FytaCoordinator] diff --git a/homeassistant/components/fyta/image.py b/homeassistant/components/fyta/image.py new file mode 100644 index 00000000000..f03df969dcc --- /dev/null +++ b/homeassistant/components/fyta/image.py @@ -0,0 +1,64 @@ +"""Entity for Fyta plant image.""" + +from __future__ import annotations + +from datetime import datetime + +from homeassistant.components.image import ImageEntity, ImageEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FytaConfigEntry +from .coordinator import FytaCoordinator +from .entity import FytaPlantEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: FytaConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the FYTA plant images.""" + coordinator = entry.runtime_data + + description = ImageEntityDescription(key="plant_image") + + async_add_entities( + FytaPlantImageEntity(coordinator, entry, description, plant_id) + for plant_id in coordinator.fyta.plant_list + if plant_id in coordinator.data + ) + + def _async_add_new_device(plant_id: int) -> None: + async_add_entities( + [FytaPlantImageEntity(coordinator, entry, description, plant_id)] + ) + + coordinator.new_device_callbacks.append(_async_add_new_device) + + +class FytaPlantImageEntity(FytaPlantEntity, ImageEntity): + """Represents a Fyta image.""" + + entity_description: ImageEntityDescription + + def __init__( + self, + coordinator: FytaCoordinator, + entry: ConfigEntry, + description: ImageEntityDescription, + plant_id: int, + ) -> None: + """Initiatlize Fyta Image entity.""" + super().__init__(coordinator, entry, description, plant_id) + ImageEntity.__init__(self, coordinator.hass) + + self._attr_name = None + + @property + def image_url(self) -> str: + """Return the image_url for this sensor.""" + image = self.plant.plant_origin_path + if image != self._attr_image_url: + self._attr_image_last_updated = datetime.now() + + return image diff --git a/tests/components/fyta/conftest.py b/tests/components/fyta/conftest.py index 299b96be959..92abab7091a 100644 --- a/tests/components/fyta/conftest.py +++ b/tests/components/fyta/conftest.py @@ -81,3 +81,13 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.fyta.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture(autouse=True) +def mock_getrandbits(): + """Mock image access token which normally is randomized.""" + with patch( + "homeassistant.components.image.SystemRandom.getrandbits", + return_value=1, + ): + yield diff --git a/tests/components/fyta/fixtures/plant_status1.json b/tests/components/fyta/fixtures/plant_status1.json index ca5662714a0..21e1fcfb0ab 100644 --- a/tests/components/fyta/fixtures/plant_status1.json +++ b/tests/components/fyta/fixtures/plant_status1.json @@ -19,8 +19,8 @@ "online": true, "ph": null, "plant_id": 0, - "plant_origin_path": "", - "plant_thumb_path": "", + "plant_origin_path": "http://www.plant_picture.com/picture", + "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", "is_productive_plant": false, "salinity": 1, "salinity_status": 4, diff --git a/tests/components/fyta/fixtures/plant_status1_update.json b/tests/components/fyta/fixtures/plant_status1_update.json new file mode 100644 index 00000000000..98a4c6a9d91 --- /dev/null +++ b/tests/components/fyta/fixtures/plant_status1_update.json @@ -0,0 +1,30 @@ +{ + "battery_level": 80, + "fertilisation": { + "was_repotted": true + }, + "low_battery": false, + "last_updated": "2023-01-10 10:10:00", + "light": 2, + "light_status": 3, + "nickname": "Gummibaum", + "nutrients_status": 3, + "moisture": 61, + "moisture_status": 3, + "sensor_available": true, + "sensor_id": "FD:1D:B7:E3:D0:E2", + "sensor_update_available": true, + "sw_version": "1.0", + "status": 1, + "online": true, + "ph": null, + "plant_id": 0, + "plant_origin_path": "http://www.plant_picture.com/picture1", + "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", + "is_productive_plant": false, + "salinity": 1, + "salinity_status": 4, + "scientific_name": "Ficus elastica", + "temperature": 25.2, + "temperature_status": 3 +} diff --git a/tests/components/fyta/fixtures/plant_status3.json b/tests/components/fyta/fixtures/plant_status3.json index 2bedd196fe1..4bb4e0b81a7 100644 --- a/tests/components/fyta/fixtures/plant_status3.json +++ b/tests/components/fyta/fixtures/plant_status3.json @@ -19,8 +19,8 @@ "online": true, "ph": 7, "plant_id": 0, - "plant_origin_path": "", - "plant_thumb_path": "", + "plant_origin_path": "http://www.plant_picture.com/picture", + "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", "is_productive_plant": true, "salinity": 1, "salinity_status": 4, diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr index b4da0238db0..a252e81952c 100644 --- a/tests/components/fyta/snapshots/test_diagnostics.ambr +++ b/tests/components/fyta/snapshots/test_diagnostics.ambr @@ -43,8 +43,8 @@ 'online': True, 'ph': None, 'plant_id': 0, - 'plant_origin_path': '', - 'plant_thumb_path': '', + 'plant_origin_path': 'http://www.plant_picture.com/picture', + 'plant_thumb_path': 'http://www.plant_picture.com/picture_thumb', 'productive_plant': False, 'repotted': True, 'salinity': 1.0, diff --git a/tests/components/fyta/snapshots/test_image.ambr b/tests/components/fyta/snapshots/test_image.ambr new file mode 100644 index 00000000000..95e25e0a4d7 --- /dev/null +++ b/tests/components/fyta/snapshots/test_image.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_all_entities[image.gummibaum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.gummibaum', + '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': None, + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-plant_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[image.gummibaum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'entity_picture': '/api/image_proxy/image.gummibaum?token=1', + 'friendly_name': 'Gummibaum', + }), + 'context': , + 'entity_id': 'image.gummibaum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[image.kakaobaum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.kakaobaum', + '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': None, + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-plant_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[image.kakaobaum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'entity_picture': '/api/image_proxy/image.kakaobaum?token=1', + 'friendly_name': 'Kakaobaum', + }), + 'context': , + 'entity_id': 'image.kakaobaum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/fyta/test_image.py b/tests/components/fyta/test_image.py new file mode 100644 index 00000000000..4feb125bd15 --- /dev/null +++ b/tests/components/fyta/test_image.py @@ -0,0 +1,129 @@ +"""Test the Home Assistant fyta sensor module.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from fyta_cli.fyta_exceptions import FytaConnectionError, FytaPlantError +from fyta_cli.fyta_models import Plant +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.fyta.const import DOMAIN as FYTA_DOMAIN +from homeassistant.components.image import ImageEntity +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_platform + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) + + +async def test_all_entities( + hass: HomeAssistant, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all entities.""" + + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + assert len(hass.states.async_all("image")) == 2 + + +@pytest.mark.parametrize( + "exception", + [ + FytaConnectionError, + FytaPlantError, + ], +) +async def test_connection_error( + hass: HomeAssistant, + exception: Exception, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection error.""" + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + + mock_fyta_connector.update_all_plants.side_effect = exception + + freezer.tick(delta=timedelta(hours=12)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("image.gummibaum").state == STATE_UNAVAILABLE + + +async def test_add_remove_entities( + hass: HomeAssistant, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if entities are added and old are removed.""" + + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + + assert hass.states.get("image.gummibaum") is not None + + plants: dict[int, Plant] = { + 0: Plant.from_dict(load_json_object_fixture("plant_status1.json", FYTA_DOMAIN)), + 2: Plant.from_dict(load_json_object_fixture("plant_status3.json", FYTA_DOMAIN)), + } + mock_fyta_connector.update_all_plants.return_value = plants + mock_fyta_connector.plant_list = { + 0: "Kautschukbaum", + 2: "Tomatenpflanze", + } + + freezer.tick(delta=timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("image.kakaobaum") is None + assert hass.states.get("image.tomatenpflanze") is not None + + +async def test_update_image( + hass: HomeAssistant, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if entity picture is updated.""" + + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + + image_entity: ImageEntity = hass.data["domain_entities"]["image"]["image.gummibaum"] + + assert image_entity.image_url == "http://www.plant_picture.com/picture" + + plants: dict[int, Plant] = { + 0: Plant.from_dict( + load_json_object_fixture("plant_status1_update.json", FYTA_DOMAIN) + ), + 2: Plant.from_dict(load_json_object_fixture("plant_status3.json", FYTA_DOMAIN)), + } + mock_fyta_connector.update_all_plants.return_value = plants + mock_fyta_connector.plant_list = { + 0: "Kautschukbaum", + 2: "Tomatenpflanze", + } + + freezer.tick(delta=timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert image_entity.image_url == "http://www.plant_picture.com/picture1" From ebda2f99946d6d3c2b7794a2540159e7d6c049f8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:23:49 +0100 Subject: [PATCH 1182/2987] Standardize homeassistant imports in component (n-p) (#136830) --- homeassistant/components/nad/media_player.py | 2 +- homeassistant/components/namecheapdns/__init__.py | 2 +- homeassistant/components/nederlandse_spoorwegen/sensor.py | 2 +- homeassistant/components/netdata/sensor.py | 2 +- homeassistant/components/netio/switch.py | 2 +- homeassistant/components/neurio_energy/sensor.py | 5 ++--- homeassistant/components/nexia/climate.py | 3 +-- homeassistant/components/nfandroidtv/__init__.py | 3 +-- homeassistant/components/nfandroidtv/notify.py | 2 +- homeassistant/components/niko_home_control/light.py | 3 +-- homeassistant/components/nilu/air_quality.py | 2 +- homeassistant/components/nina/config_flow.py | 3 +-- homeassistant/components/nissan_leaf/__init__.py | 2 +- homeassistant/components/nmap_tracker/__init__.py | 5 ++--- homeassistant/components/nmap_tracker/config_flow.py | 2 +- homeassistant/components/nmbs/__init__.py | 2 +- homeassistant/components/nmbs/sensor.py | 4 ++-- homeassistant/components/no_ip/__init__.py | 2 +- homeassistant/components/noaa_tides/sensor.py | 2 +- homeassistant/components/nobo_hub/__init__.py | 2 +- homeassistant/components/norway_air/air_quality.py | 2 +- homeassistant/components/notify_events/__init__.py | 3 +-- homeassistant/components/nsw_fuel_station/sensor.py | 2 +- homeassistant/components/numato/__init__.py | 2 +- homeassistant/components/nut/device_action.py | 3 +-- homeassistant/components/nx584/binary_sensor.py | 2 +- homeassistant/components/oasa_telematics/sensor.py | 2 +- homeassistant/components/octoprint/__init__.py | 3 +-- homeassistant/components/octoprint/config_flow.py | 2 +- homeassistant/components/octoprint/coordinator.py | 2 +- homeassistant/components/oem/climate.py | 2 +- homeassistant/components/ohmconnect/sensor.py | 2 +- homeassistant/components/ombi/__init__.py | 2 +- homeassistant/components/onvif/device.py | 2 +- .../components/openalpr_cloud/image_processing.py | 2 +- homeassistant/components/openevse/sensor.py | 2 +- homeassistant/components/openhardwaremonitor/sensor.py | 2 +- homeassistant/components/opensensemap/air_quality.py | 2 +- homeassistant/components/opensky/config_flow.py | 2 +- homeassistant/components/opentherm_gw/config_flow.py | 2 +- homeassistant/components/openweathermap/config_flow.py | 2 +- homeassistant/components/opnsense/__init__.py | 2 +- homeassistant/components/opple/light.py | 2 +- homeassistant/components/oru/sensor.py | 2 +- homeassistant/components/orvibo/switch.py | 2 +- homeassistant/components/osoenergy/water_heater.py | 2 +- .../components/overkiz/climate/evo_home_controller.py | 2 +- homeassistant/components/owntracks/__init__.py | 2 +- homeassistant/components/panasonic_bluray/media_player.py | 2 +- homeassistant/components/panasonic_viera/__init__.py | 2 +- homeassistant/components/panel_custom/__init__.py | 2 +- homeassistant/components/pencom/switch.py | 2 +- homeassistant/components/permobil/config_flow.py | 3 +-- homeassistant/components/picnic/services.py | 2 +- homeassistant/components/pilight/__init__.py | 2 +- homeassistant/components/pilight/entity.py | 2 +- homeassistant/components/pilight/light.py | 2 +- homeassistant/components/pilight/sensor.py | 2 +- homeassistant/components/pilight/switch.py | 2 +- homeassistant/components/pioneer/media_player.py | 2 +- homeassistant/components/pjlink/media_player.py | 2 +- homeassistant/components/plaato/__init__.py | 2 +- homeassistant/components/plaato/config_flow.py | 2 +- homeassistant/components/plant/__init__.py | 2 +- homeassistant/components/plex/config_flow.py | 3 +-- homeassistant/components/pocketcasts/sensor.py | 2 +- homeassistant/components/profiler/__init__.py | 2 +- homeassistant/components/proliphix/climate.py | 2 +- homeassistant/components/prometheus/__init__.py | 7 +++++-- homeassistant/components/prowl/notify.py | 2 +- homeassistant/components/proxmoxve/__init__.py | 2 +- homeassistant/components/proxy/camera.py | 2 +- homeassistant/components/pulseaudio_loopback/switch.py | 2 +- homeassistant/components/push/camera.py | 2 +- homeassistant/components/pushsafer/notify.py | 2 +- homeassistant/components/pyload/config_flow.py | 2 +- homeassistant/components/python_script/__init__.py | 3 +-- 77 files changed, 84 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/nad/media_player.py b/homeassistant/components/nad/media_player.py index e3c22b42d28..c1efa18f72b 100644 --- a/homeassistant/components/nad/media_player.py +++ b/homeassistant/components/nad/media_player.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/namecheapdns/__init__.py b/homeassistant/components/namecheapdns/__init__.py index 43310c5e922..7fbd49d979b 100644 --- a/homeassistant/components/namecheapdns/__init__.py +++ b/homeassistant/components/namecheapdns/__init__.py @@ -8,8 +8,8 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index ce3e7d3a002..ff3eea9252c 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index f33349c56ce..4346cbe8689 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/netio/switch.py b/homeassistant/components/netio/switch.py index 5c2b93bcae7..4560b7a2ecc 100644 --- a/homeassistant/components/netio/switch.py +++ b/homeassistant/components/netio/switch.py @@ -25,7 +25,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/neurio_energy/sensor.py b/homeassistant/components/neurio_energy/sensor.py index 5c6482da59a..7a7ceff338e 100644 --- a/homeassistant/components/neurio_energy/sensor.py +++ b/homeassistant/components/neurio_energy/sensor.py @@ -17,11 +17,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index becd664756b..81e7800fd01 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -32,8 +32,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import VolDictType diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index ae7a4e615d4..50674a7ed46 100644 --- a/homeassistant/components/nfandroidtv/__init__.py +++ b/homeassistant/components/nfandroidtv/__init__.py @@ -6,8 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DATA_HASS_CONFIG, DOMAIN diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index dd6b15400d9..f6d9bcde432 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -20,7 +20,7 @@ from homeassistant.components.notify import ( from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index 80f47e56438..5c2b372fd25 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -18,8 +18,7 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_HOST from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/nilu/air_quality.py b/homeassistant/components/nilu/air_quality.py index 7600a878548..31259349dea 100644 --- a/homeassistant/components/nilu/air_quality.py +++ b/homeassistant/components/nilu/air_quality.py @@ -34,7 +34,7 @@ from homeassistant.const import ( CONF_SHOW_ON_MAP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index a1ba9ae0c61..24c016e5e64 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -14,9 +14,8 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import VolDictType from .const import ( diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py index 865ae33b38c..4f24cde0578 100644 --- a/homeassistant/components/nissan_leaf/__init__.py +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -18,7 +18,7 @@ import voluptuous as vol from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_point_in_utc_time diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index dcb4e1361fd..72bf9284573 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -21,12 +21,11 @@ from homeassistant.components.device_tracker import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant, callback -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( CONF_HOME_INTERVAL, diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index e05150995aa..1f436edd60c 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType from .const import ( diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py index 9972d41ac7b..7d06baf37b6 100644 --- a/homeassistant/components/nmbs/__init__.py +++ b/homeassistant/components/nmbs/__init__.py @@ -7,7 +7,7 @@ from pyrail import iRail from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 85ae56144a0..ca18d3b1bbd 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -22,11 +22,11 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( # noqa: F401 CONF_EXCLUDE_VIAS, diff --git a/homeassistant/components/no_ip/__init__.py b/homeassistant/components/no_ip/__init__.py index cb02490ac08..c23177ddf94 100644 --- a/homeassistant/components/no_ip/__init__.py +++ b/homeassistant/components/no_ip/__init__.py @@ -11,11 +11,11 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import ( SERVER_SOFTWARE, async_get_clientsession, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/noaa_tides/sensor.py b/homeassistant/components/noaa_tides/sensor.py index b165478927e..f6ec9dc4bf2 100644 --- a/homeassistant/components/noaa_tides/sensor.py +++ b/homeassistant/components/noaa_tides/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_NAME, CONF_TIME_ZONE, CONF_UNIT_SYSTEM from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_system import METRIC_SYSTEM diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 5b777205c8d..3bbf46f0264 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -7,7 +7,7 @@ from pynobo import nobo from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONF_AUTO_DISCOVERED, CONF_SERIAL, DOMAIN diff --git a/homeassistant/components/norway_air/air_quality.py b/homeassistant/components/norway_air/air_quality.py index bba4737550b..36de8c8b1ad 100644 --- a/homeassistant/components/norway_air/air_quality.py +++ b/homeassistant/components/norway_air/air_quality.py @@ -14,8 +14,8 @@ from homeassistant.components.air_quality import ( ) from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/notify_events/__init__.py b/homeassistant/components/notify_events/__init__.py index 2be97d709a9..76cfd9be4ff 100644 --- a/homeassistant/components/notify_events/__init__.py +++ b/homeassistant/components/notify_events/__init__.py @@ -4,8 +4,7 @@ import voluptuous as vol from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/nsw_fuel_station/sensor.py b/homeassistant/components/nsw_fuel_station/sensor.py index f99790664da..7ae9b3a4d9f 100644 --- a/homeassistant/components/nsw_fuel_station/sensor.py +++ b/homeassistant/components/nsw_fuel_station/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CURRENCY_CENT, UnitOfVolume from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( diff --git a/homeassistant/components/numato/__init__.py b/homeassistant/components/numato/__init__.py index 00122132d44..d3882bea290 100644 --- a/homeassistant/components/numato/__init__.py +++ b/homeassistant/components/numato/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/nut/device_action.py b/homeassistant/components/nut/device_action.py index a051f843226..ffaa195deaf 100644 --- a/homeassistant/components/nut/device_action.py +++ b/homeassistant/components/nut/device_action.py @@ -7,8 +7,7 @@ import voluptuous as vol from homeassistant.components.device_automation import InvalidDeviceAutomationConfig from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import NutRuntimeData diff --git a/homeassistant/components/nx584/binary_sensor.py b/homeassistant/components/nx584/binary_sensor.py index 04e79716423..69e2f626049 100644 --- a/homeassistant/components/nx584/binary_sensor.py +++ b/homeassistant/components/nx584/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py index fef4cef48af..ddf4942ef25 100644 --- a/homeassistant/components/oasa_telematics/sensor.py +++ b/homeassistant/components/oasa_telematics/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index 2b081eae45a..59fd04357eb 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -28,8 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify as util_slugify from homeassistant.util.ssl import get_default_context, get_default_no_verify_context diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index 627ca999acd..010b45e5a1c 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.ssl import get_default_context, get_default_no_verify_context diff --git a/homeassistant/components/octoprint/coordinator.py b/homeassistant/components/octoprint/coordinator.py index c6d7373a002..d4f8f652b80 100644 --- a/homeassistant/components/octoprint/coordinator.py +++ b/homeassistant/components/octoprint/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/oem/climate.py b/homeassistant/components/oem/climate.py index 4cecb9ff195..e5ccdf6ede8 100644 --- a/homeassistant/components/oem/climate.py +++ b/homeassistant/components/oem/climate.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ohmconnect/sensor.py b/homeassistant/components/ohmconnect/sensor.py index b32db33cc2d..287842178d8 100644 --- a/homeassistant/components/ohmconnect/sensor.py +++ b/homeassistant/components/ohmconnect/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/ombi/__init__.py b/homeassistant/components/ombi/__init__.py index d63f72592f8..c3a51bacce2 100644 --- a/homeassistant/components/ombi/__init__.py +++ b/homeassistant/components/ombi/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index f15f6637ab9..6d1a340fc7b 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -25,7 +25,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( ABSOLUTE_MOVE, diff --git a/homeassistant/components/openalpr_cloud/image_processing.py b/homeassistant/components/openalpr_cloud/image_processing.py index e8a8d6859c1..2bdf9947fe2 100644 --- a/homeassistant/components/openalpr_cloud/image_processing.py +++ b/homeassistant/components/openalpr_cloud/image_processing.py @@ -26,8 +26,8 @@ from homeassistant.const import ( CONF_SOURCE, ) from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.async_ import run_callback_threadsafe diff --git a/homeassistant/components/openevse/sensor.py b/homeassistant/components/openevse/sensor.py index c228b6c1a14..de86e3d581f 100644 --- a/homeassistant/components/openevse/sensor.py +++ b/homeassistant/components/openevse/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/openhardwaremonitor/sensor.py b/homeassistant/components/openhardwaremonitor/sensor.py index 30801a59436..4aa334da3a7 100644 --- a/homeassistant/components/openhardwaremonitor/sensor.py +++ b/homeassistant/components/openhardwaremonitor/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/opensensemap/air_quality.py b/homeassistant/components/opensensemap/air_quality.py index eb8435751c0..19d19f19a54 100644 --- a/homeassistant/components/opensensemap/air_quality.py +++ b/homeassistant/components/opensensemap/air_quality.py @@ -16,8 +16,8 @@ from homeassistant.components.air_quality import ( from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py index 867a4781265..5e53a805753 100644 --- a/homeassistant/components/opensky/config_flow.py +++ b/homeassistant/components/opensky/config_flow.py @@ -23,8 +23,8 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import ( CONF_ALTITUDE, diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index 80c16ee88e1..bcbf279f3f7 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -25,7 +25,7 @@ from homeassistant.const import ( PRECISION_WHOLE, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from . import DOMAIN from .const import ( diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index 8d33e117287..4c66778119e 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_NAME, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONFIG_FLOW_VERSION, diff --git a/homeassistant/components/opnsense/__init__.py b/homeassistant/components/opnsense/__init__.py index d2ee2e2dfbd..66f35a51b87 100644 --- a/homeassistant/components/opnsense/__init__.py +++ b/homeassistant/components/opnsense/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/opple/light.py b/homeassistant/components/opple/light.py index da2993d1996..e804f06faa3 100644 --- a/homeassistant/components/opple/light.py +++ b/homeassistant/components/opple/light.py @@ -17,7 +17,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/oru/sensor.py b/homeassistant/components/oru/sensor.py index 213350db6a4..450c56ae50e 100644 --- a/homeassistant/components/oru/sensor.py +++ b/homeassistant/components/oru/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/orvibo/switch.py b/homeassistant/components/orvibo/switch.py index 2f990333cf6..211abc838e7 100644 --- a/homeassistant/components/orvibo/switch.py +++ b/homeassistant/components/orvibo/switch.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_SWITCHES, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py index ff117d6577d..b3281193da3 100644 --- a/homeassistant/components/osoenergy/water_heater.py +++ b/homeassistant/components/osoenergy/water_heater.py @@ -20,7 +20,7 @@ from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonValueType from .const import DOMAIN diff --git a/homeassistant/components/overkiz/climate/evo_home_controller.py b/homeassistant/components/overkiz/climate/evo_home_controller.py index 272acbb13b9..e0cb8be7380 100644 --- a/homeassistant/components/overkiz/climate/evo_home_controller.py +++ b/homeassistant/components/overkiz/climate/evo_home_controller.py @@ -11,7 +11,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.const import UnitOfTemperature -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ..entity import OverkizDataUpdateCoordinator, OverkizEntity diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index 720c3718a4f..623e5e17b66 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/panasonic_bluray/media_player.py b/homeassistant/components/panasonic_bluray/media_player.py index a7cb0780ca9..b0e23031a24 100644 --- a/homeassistant/components/panasonic_bluray/media_player.py +++ b/homeassistant/components/panasonic_bluray/media_player.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utcnow diff --git a/homeassistant/components/panasonic_viera/__init__.py b/homeassistant/components/panasonic_viera/__init__.py index 69800d2ef1e..6dacc08077d 100644 --- a/homeassistant/components/panasonic_viera/__init__.py +++ b/homeassistant/components/panasonic_viera/__init__.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import MediaPlayerState, MediaType from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, Platform from homeassistant.core import Context, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/panel_custom/__init__.py b/homeassistant/components/panel_custom/__init__.py index 89ad6066f48..db9c35a7608 100644 --- a/homeassistant/components/panel_custom/__init__.py +++ b/homeassistant/components/panel_custom/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.components import frontend from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass diff --git a/homeassistant/components/pencom/switch.py b/homeassistant/components/pencom/switch.py index d16c7e1600c..d9d89494bd9 100644 --- a/homeassistant/components/pencom/switch.py +++ b/homeassistant/components/pencom/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/permobil/config_flow.py b/homeassistant/components/permobil/config_flow.py index 07ddefa9dce..e0fb55a0363 100644 --- a/homeassistant/components/permobil/config_flow.py +++ b/homeassistant/components/permobil/config_flow.py @@ -17,9 +17,8 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_REGION, CONF_TOKEN, CONF_TTL from homeassistant.core import HomeAssistant, async_get_hass -from homeassistant.helpers import selector +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index c01fc00a29e..bbc775891b7 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -8,7 +8,7 @@ from python_picnic_api import PicnicAPI import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( ATTR_AMOUNT, diff --git a/homeassistant/components/pilight/__init__.py b/homeassistant/components/pilight/__init__.py index 21d5603e4c2..5f1238772b0 100644 --- a/homeassistant/components/pilight/__init__.py +++ b/homeassistant/components/pilight/__init__.py @@ -21,7 +21,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/pilight/entity.py b/homeassistant/components/pilight/entity.py index d2d83813516..fbb924d7f8f 100644 --- a/homeassistant/components/pilight/entity.py +++ b/homeassistant/components/pilight/entity.py @@ -10,7 +10,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from . import DOMAIN, EVENT, SERVICE_NAME diff --git a/homeassistant/components/pilight/light.py b/homeassistant/components/pilight/light.py index c3d1a3c234c..9e1ecbf59d4 100644 --- a/homeassistant/components/pilight/light.py +++ b/homeassistant/components/pilight/light.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_LIGHTS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/pilight/sensor.py b/homeassistant/components/pilight/sensor.py index 5ab80f57dc6..532681e2b93 100644 --- a/homeassistant/components/pilight/sensor.py +++ b/homeassistant/components/pilight/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_PAYLOAD, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/pilight/switch.py b/homeassistant/components/pilight/switch.py index a1976921269..9b812075e17 100644 --- a/homeassistant/components/pilight/switch.py +++ b/homeassistant/components/pilight/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_SWITCHES from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/pioneer/media_player.py b/homeassistant/components/pioneer/media_player.py index 02072b6cb43..385acbe4818 100644 --- a/homeassistant/components/pioneer/media_player.py +++ b/homeassistant/components/pioneer/media_player.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TIMEOUT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/pjlink/media_player.py b/homeassistant/components/pjlink/media_player.py index 93f8ea5ad9b..1e035205f8f 100644 --- a/homeassistant/components/pjlink/media_player.py +++ b/homeassistant/components/pjlink/media_player.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py index 585b6ecfd82..6001a243a2d 100644 --- a/homeassistant/components/plaato/__init__.py +++ b/homeassistant/components/plaato/__init__.py @@ -32,7 +32,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( diff --git a/homeassistant/components/plaato/config_flow.py b/homeassistant/components/plaato/config_flow.py index f398a733cd6..9adfb4a14fe 100644 --- a/homeassistant/components/plaato/config_flow.py +++ b/homeassistant/components/plaato/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_WEBHOOK_ID from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_CLOUDHOOK, diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py index 48c606865df..27993a93779 100644 --- a/homeassistant/components/plant/__init__.py +++ b/homeassistant/components/plant/__init__.py @@ -32,7 +32,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change_event diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index ae7cbb12574..3c9f35b20a4 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -36,9 +36,8 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import discovery_flow +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import ( AUTH_CALLBACK_NAME, diff --git a/homeassistant/components/pocketcasts/sensor.py b/homeassistant/components/pocketcasts/sensor.py index 1f6af298688..bbe75ae544c 100644 --- a/homeassistant/components/pocketcasts/sensor.py +++ b/homeassistant/components/pocketcasts/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 9b2b9736574..04dc6d76a5e 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TYPE from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.service import async_register_admin_service diff --git a/homeassistant/components/proliphix/climate.py b/homeassistant/components/proliphix/climate.py index be7d394993a..03f53dec390 100644 --- a/homeassistant/components/proliphix/climate.py +++ b/homeassistant/components/proliphix/climate.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index ab012847bba..3adc33e9935 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -63,8 +63,11 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State -from homeassistant.helpers import entityfilter, state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + entityfilter, + state as state_helper, +) from homeassistant.helpers.entity_registry import ( EVENT_ENTITY_REGISTRY_UPDATED, EventEntityRegistryUpdatedData, diff --git a/homeassistant/components/prowl/notify.py b/homeassistant/components/prowl/notify.py index 1118e747275..e9d2bbde4e5 100644 --- a/homeassistant/components/prowl/notify.py +++ b/homeassistant/components/prowl/notify.py @@ -17,8 +17,8 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 6d6771debc4..0db6ea28652 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -20,7 +20,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator diff --git a/homeassistant/components/proxy/camera.py b/homeassistant/components/proxy/camera.py index e5e3d01591a..f6e909f13d1 100644 --- a/homeassistant/components/proxy/camera.py +++ b/homeassistant/components/proxy/camera.py @@ -23,7 +23,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/pulseaudio_loopback/switch.py b/homeassistant/components/pulseaudio_loopback/switch.py index 4ab1f905068..1974363a8e3 100644 --- a/homeassistant/components/pulseaudio_loopback/switch.py +++ b/homeassistant/components/pulseaudio_loopback/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index 37ac6144d0d..603fe89d542 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -24,7 +24,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/pushsafer/notify.py b/homeassistant/components/pushsafer/notify.py index b5c517c8662..faca654b420 100644 --- a/homeassistant/components/pushsafer/notify.py +++ b/homeassistant/components/pushsafer/notify.py @@ -21,7 +21,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index 5df11711d6f..b9bfc579cfc 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -22,8 +22,8 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_create_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index dbd1a5dce4b..0729d73a034 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -36,8 +36,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from homeassistant.util import raise_if_invalid_filename -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, raise_if_invalid_filename from homeassistant.util.yaml.loader import load_yaml_dict _LOGGER = logging.getLogger(__name__) From 1ef809c716f4f923347892ab6a9b4711cd1f3eed Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:24:09 +0100 Subject: [PATCH 1183/2987] Standardize homeassistant imports in component (q-r) (#136831) --- homeassistant/components/qld_bushfire/geo_location.py | 2 +- homeassistant/components/quantum_gateway/device_tracker.py | 2 +- homeassistant/components/qvr_pro/__init__.py | 2 +- homeassistant/components/qwikswitch/__init__.py | 2 +- homeassistant/components/raincloud/__init__.py | 2 +- homeassistant/components/raincloud/binary_sensor.py | 2 +- homeassistant/components/raincloud/sensor.py | 2 +- homeassistant/components/raincloud/switch.py | 2 +- homeassistant/components/random/binary_sensor.py | 2 +- homeassistant/components/random/config_flow.py | 2 +- homeassistant/components/random/sensor.py | 2 +- homeassistant/components/raspyrfm/switch.py | 2 +- homeassistant/components/recswitch/switch.py | 2 +- homeassistant/components/reddit/sensor.py | 2 +- homeassistant/components/rejseplanen/sensor.py | 4 ++-- homeassistant/components/remember_the_milk/__init__.py | 2 +- homeassistant/components/remote_rpi_gpio/binary_sensor.py | 2 +- homeassistant/components/remote_rpi_gpio/switch.py | 2 +- homeassistant/components/renson/fan.py | 3 +-- homeassistant/components/repetier/__init__.py | 2 +- homeassistant/components/rest/binary_sensor.py | 2 +- homeassistant/components/rest/notify.py | 2 +- homeassistant/components/rest/schema.py | 2 +- homeassistant/components/rest/sensor.py | 2 +- homeassistant/components/rest_command/__init__.py | 2 +- homeassistant/components/rflink/__init__.py | 2 +- homeassistant/components/rflink/binary_sensor.py | 3 +-- homeassistant/components/rflink/const.py | 2 +- homeassistant/components/rflink/cover.py | 2 +- homeassistant/components/rflink/light.py | 2 +- homeassistant/components/rflink/sensor.py | 2 +- homeassistant/components/rflink/switch.py | 2 +- homeassistant/components/rfxtrx/device_action.py | 2 +- homeassistant/components/ring/light.py | 2 +- homeassistant/components/ring/switch.py | 2 +- homeassistant/components/ripple/sensor.py | 2 +- homeassistant/components/rmvtransport/sensor.py | 2 +- homeassistant/components/roborock/image.py | 3 +-- homeassistant/components/rocketchat/notify.py | 2 +- homeassistant/components/romy/config_flow.py | 2 +- homeassistant/components/roomba/entity.py | 2 +- homeassistant/components/roon/config_flow.py | 2 +- homeassistant/components/route53/__init__.py | 2 +- homeassistant/components/rss_feed_template/__init__.py | 2 +- homeassistant/components/rtorrent/sensor.py | 2 +- homeassistant/components/russound_rnet/media_player.py | 2 +- 46 files changed, 47 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/qld_bushfire/geo_location.py b/homeassistant/components/qld_bushfire/geo_location.py index c1266ab951b..c235d441133 100644 --- a/homeassistant/components/qld_bushfire/geo_location.py +++ b/homeassistant/components/qld_bushfire/geo_location.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_time_interval diff --git a/homeassistant/components/quantum_gateway/device_tracker.py b/homeassistant/components/quantum_gateway/device_tracker.py index dc68472d94e..6491dca2e2c 100644 --- a/homeassistant/components/quantum_gateway/device_tracker.py +++ b/homeassistant/components/quantum_gateway/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/qvr_pro/__init__.py b/homeassistant/components/qvr_pro/__init__.py index 9aad94790c6..98f0bcbaf99 100644 --- a/homeassistant/components/qvr_pro/__init__.py +++ b/homeassistant/components/qvr_pro/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/qwikswitch/__init__.py b/homeassistant/components/qwikswitch/__init__.py index 776e32dded1..d3cf2ff3d9b 100644 --- a/homeassistant/components/qwikswitch/__init__.py +++ b/homeassistant/components/qwikswitch/__init__.py @@ -18,8 +18,8 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/raincloud/__init__.py b/homeassistant/components/raincloud/__init__.py index f1eef40f307..0ee12612323 100644 --- a/homeassistant/components/raincloud/__init__.py +++ b/homeassistant/components/raincloud/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/raincloud/binary_sensor.py b/homeassistant/components/raincloud/binary_sensor.py index 2696c192ed6..84621aba99d 100644 --- a/homeassistant/components/raincloud/binary_sensor.py +++ b/homeassistant/components/raincloud/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/raincloud/sensor.py b/homeassistant/components/raincloud/sensor.py index 1f9d8d7b2c5..8aaec605c04 100644 --- a/homeassistant/components/raincloud/sensor.py +++ b/homeassistant/components/raincloud/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, PERCENTAGE, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/raincloud/switch.py b/homeassistant/components/raincloud/switch.py index 59a11a6b167..babadcba676 100644 --- a/homeassistant/components/raincloud/switch.py +++ b/homeassistant/components/raincloud/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py index ae9a5886d59..fadc966bc3d 100644 --- a/homeassistant/components/random/binary_sensor.py +++ b/homeassistant/components/random/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/random/config_flow.py b/homeassistant/components/random/config_flow.py index 35b7757580e..406100388e6 100644 --- a/homeassistant/components/random/config_flow.py +++ b/homeassistant/components/random/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaConfigFlowHandler, diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index aad4fcb851c..590b391c3a0 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/raspyrfm/switch.py b/homeassistant/components/raspyrfm/switch.py index 37835ecb40a..b9506c3688c 100644 --- a/homeassistant/components/raspyrfm/switch.py +++ b/homeassistant/components/raspyrfm/switch.py @@ -25,7 +25,7 @@ from homeassistant.const import ( DEVICE_DEFAULT_NAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/recswitch/switch.py b/homeassistant/components/recswitch/switch.py index 78fc0a805f6..f5b566ce59d 100644 --- a/homeassistant/components/recswitch/switch.py +++ b/homeassistant/components/recswitch/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/reddit/sensor.py b/homeassistant/components/reddit/sensor.py index 35962ac091b..564cc6c3c06 100644 --- a/homeassistant/components/reddit/sensor.py +++ b/homeassistant/components/reddit/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index 40b27014211..1d9b281e9b7 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -20,10 +20,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index d544c42efe1..0d1c54efb56 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components import configurator from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TOKEN from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/remote_rpi_gpio/binary_sensor.py b/homeassistant/components/remote_rpi_gpio/binary_sensor.py index b3a8075c6ba..42e8517c1e8 100644 --- a/homeassistant/components/remote_rpi_gpio/binary_sensor.py +++ b/homeassistant/components/remote_rpi_gpio/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/remote_rpi_gpio/switch.py b/homeassistant/components/remote_rpi_gpio/switch.py index bf31e4bb55a..91b389c5a1e 100644 --- a/homeassistant/components/remote_rpi_gpio/switch.py +++ b/homeassistant/components/remote_rpi_gpio/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index 56b3655ef94..00edd4547cb 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -18,8 +18,7 @@ import voluptuous as vol from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.util.percentage import ( diff --git a/homeassistant/components/repetier/__init__.py b/homeassistant/components/repetier/__init__.py index 27ddc62a847..16c92d6cd37 100644 --- a/homeassistant/components/repetier/__init__.py +++ b/homeassistant/components/repetier/__init__.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import track_time_interval diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index c976506d1ba..fa5bd388009 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( diff --git a/homeassistant/components/rest/notify.py b/homeassistant/components/rest/notify.py index 1ca3c55e2b2..ace216e1918 100644 --- a/homeassistant/components/rest/notify.py +++ b/homeassistant/components/rest/notify.py @@ -31,7 +31,7 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index f7fd8a36113..62ed2d5c5b2 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -26,7 +26,7 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, TEMPLATE_ENTITY_BASE_SCHEMA, diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index fc6ce8c6749..b95e6dd72b7 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index ee93fde35fa..fe3702510af 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -30,8 +30,8 @@ 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 -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 7e86854dbce..85195fb1581 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import CoreState, HassJob, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/rflink/binary_sensor.py b/homeassistant/components/rflink/binary_sensor.py index 29046ba7616..43a7c03c67b 100644 --- a/homeassistant/components/rflink/binary_sensor.py +++ b/homeassistant/components/rflink/binary_sensor.py @@ -20,9 +20,8 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, event as evt from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.event as evt from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rflink/const.py b/homeassistant/components/rflink/const.py index cc52ea978bd..83eb2915f70 100644 --- a/homeassistant/components/rflink/const.py +++ b/homeassistant/components/rflink/const.py @@ -4,7 +4,7 @@ from __future__ import annotations import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv CONF_ALIASES = "aliases" CONF_GROUP_ALIASES = "group_aliases" diff --git a/homeassistant/components/rflink/cover.py b/homeassistant/components/rflink/cover.py index 695825cf31b..8b21bc9274d 100644 --- a/homeassistant/components/rflink/cover.py +++ b/homeassistant/components/rflink/cover.py @@ -14,7 +14,7 @@ from homeassistant.components.cover import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index 00117140abb..2a5b1ccf8d7 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index 89632ac50b3..027c39da70f 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -35,7 +35,7 @@ from homeassistant.const import ( UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rflink/switch.py b/homeassistant/components/rflink/switch.py index 23b93896878..bbbce2b8e9a 100644 --- a/homeassistant/components/rflink/switch.py +++ b/homeassistant/components/rflink/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rfxtrx/device_action.py b/homeassistant/components/rfxtrx/device_action.py index 405daa37ec5..c3f61dee026 100644 --- a/homeassistant/components/rfxtrx/device_action.py +++ b/homeassistant/components/rfxtrx/device_action.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.device_automation import InvalidDeviceAutomationConfig from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE from homeassistant.core import Context, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DATA_RFXOBJECT, DOMAIN diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 9ae0bac1004..62c5217a89b 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -10,7 +10,7 @@ from ring_doorbell import RingStickUpCam from homeassistant.components.light import ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import RingConfigEntry from .coordinator import RingDataCoordinator diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index e81d483adf3..cab5654fc5a 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import RingConfigEntry from .coordinator import RingDataCoordinator diff --git a/homeassistant/components/ripple/sensor.py b/homeassistant/components/ripple/sensor.py index 72510ea251d..30d2d77dcb4 100644 --- a/homeassistant/components/ripple/sensor.py +++ b/homeassistant/components/ripple/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index ac6c66bb6d2..c3217d9334e 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -20,7 +20,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_NAME, CONF_TIMEOUT, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 8717920b907..3818a039fb8 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -17,8 +17,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from . import RoborockConfigEntry from .const import DEFAULT_DRAWABLES, DOMAIN, DRAWABLES, IMAGE_CACHE_INTERVAL, MAP_SLEEP diff --git a/homeassistant/components/rocketchat/notify.py b/homeassistant/components/rocketchat/notify.py index a06226d22ee..20ae0708c15 100644 --- a/homeassistant/components/rocketchat/notify.py +++ b/homeassistant/components/rocketchat/notify.py @@ -19,7 +19,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_PASSWORD, CONF_ROOM, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/romy/config_flow.py b/homeassistant/components/romy/config_flow.py index 6bb5c337b29..48558cd98c7 100644 --- a/homeassistant/components/romy/config_flow.py +++ b/homeassistant/components/romy/config_flow.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py index 69e8d5b5414..ae5577da4e4 100644 --- a/homeassistant/components/roomba/entity.py +++ b/homeassistant/components/roomba/entity.py @@ -6,7 +6,7 @@ from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import roomba_reported_state from .const import DOMAIN diff --git a/homeassistant/components/roon/config_flow.py b/homeassistant/components/roon/config_flow.py index b896f6775ae..3421cbf646c 100644 --- a/homeassistant/components/roon/config_flow.py +++ b/homeassistant/components/roon/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( AUTHENTICATE_TIMEOUT, diff --git a/homeassistant/components/route53/__init__.py b/homeassistant/components/route53/__init__.py index 92094b0b608..2c9824d0628 100644 --- a/homeassistant/components/route53/__init__.py +++ b/homeassistant/components/route53/__init__.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN, CONF_TTL, CONF_ZONE from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/rss_feed_template/__init__.py b/homeassistant/components/rss_feed_template/__init__.py index 89624c922e6..98d0e1bf790 100644 --- a/homeassistant/components/rss_feed_template/__init__.py +++ b/homeassistant/components/rss_feed_template/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/rtorrent/sensor.py b/homeassistant/components/rtorrent/sensor.py index 654288927d3..70fe7919edb 100644 --- a/homeassistant/components/rtorrent/sensor.py +++ b/homeassistant/components/rtorrent/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/russound_rnet/media_player.py b/homeassistant/components/russound_rnet/media_player.py index f8369ed64ca..48808930d9f 100644 --- a/homeassistant/components/russound_rnet/media_player.py +++ b/homeassistant/components/russound_rnet/media_player.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType From 844259bd6c767ec8917e367335b5448b68f37a77 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:24:31 +0100 Subject: [PATCH 1184/2987] Standardize homeassistant imports in component (s) (#136832) --- homeassistant/components/saj/sensor.py | 2 +- homeassistant/components/schedule/__init__.py | 2 +- homeassistant/components/schluter/__init__.py | 3 +-- homeassistant/components/scrape/__init__.py | 7 +++++-- homeassistant/components/screenlogic/config_flow.py | 2 +- homeassistant/components/scsgate/__init__.py | 2 +- homeassistant/components/scsgate/cover.py | 2 +- homeassistant/components/scsgate/light.py | 2 +- homeassistant/components/scsgate/switch.py | 2 +- homeassistant/components/sendgrid/notify.py | 2 +- homeassistant/components/serial/sensor.py | 2 +- homeassistant/components/serial_pm/sensor.py | 2 +- homeassistant/components/sesame/lock.py | 2 +- .../components/seven_segments/image_processing.py | 2 +- homeassistant/components/sharkiq/vacuum.py | 3 +-- homeassistant/components/shodan/sensor.py | 2 +- homeassistant/components/sigfox/sensor.py | 2 +- homeassistant/components/sighthound/image_processing.py | 4 ++-- homeassistant/components/signal_messenger/notify.py | 2 +- homeassistant/components/sinch/notify.py | 2 +- homeassistant/components/sisyphus/__init__.py | 2 +- homeassistant/components/sky_hub/device_tracker.py | 2 +- homeassistant/components/sky_remote/config_flow.py | 2 +- homeassistant/components/skybeacon/sensor.py | 2 +- homeassistant/components/slack/sensor.py | 2 +- homeassistant/components/sleepiq/__init__.py | 3 +-- homeassistant/components/sma/config_flow.py | 2 +- homeassistant/components/smarty/__init__.py | 3 +-- homeassistant/components/smarty/sensor.py | 2 +- homeassistant/components/smtp/notify.py | 4 ++-- homeassistant/components/snmp/device_tracker.py | 2 +- homeassistant/components/snmp/sensor.py | 2 +- homeassistant/components/snmp/switch.py | 2 +- homeassistant/components/solaredge_local/sensor.py | 2 +- homeassistant/components/solax/config_flow.py | 2 +- homeassistant/components/soma/__init__.py | 2 +- homeassistant/components/sonarr/coordinator.py | 2 +- homeassistant/components/sonarr/sensor.py | 2 +- homeassistant/components/sony_projector/switch.py | 2 +- homeassistant/components/soundtouch/__init__.py | 2 +- homeassistant/components/spaceapi/__init__.py | 6 +++--- homeassistant/components/spc/__init__.py | 3 +-- homeassistant/components/splunk/__init__.py | 3 +-- homeassistant/components/spotify/coordinator.py | 2 +- homeassistant/components/sql/__init__.py | 3 +-- homeassistant/components/starlingbank/sensor.py | 2 +- homeassistant/components/startca/sensor.py | 2 +- homeassistant/components/statsd/__init__.py | 3 +-- homeassistant/components/stiebel_eltron/__init__.py | 3 +-- homeassistant/components/streamlabswater/__init__.py | 2 +- homeassistant/components/supervisord/sensor.py | 2 +- homeassistant/components/supla/__init__.py | 2 +- homeassistant/components/swiss_hydrological_data/sensor.py | 2 +- .../components/swiss_public_transport/config_flow.py | 2 +- .../components/swiss_public_transport/coordinator.py | 2 +- homeassistant/components/swiss_public_transport/helper.py | 2 +- homeassistant/components/swisscom/device_tracker.py | 2 +- homeassistant/components/switchbee/config_flow.py | 2 +- homeassistant/components/switchbot_cloud/climate.py | 2 +- homeassistant/components/switchmate/switch.py | 2 +- homeassistant/components/synology_chat/notify.py | 2 +- homeassistant/components/synology_dsm/config_flow.py | 2 +- homeassistant/components/synology_srm/device_tracker.py | 2 +- homeassistant/components/system_log/__init__.py | 2 +- 64 files changed, 72 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py index c8b40fd5476..89b6658c418 100644 --- a/homeassistant/components/saj/sensor.py +++ b/homeassistant/components/saj/sensor.py @@ -31,7 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.start import async_at_start diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index 30ca44fe3ee..20dc9c1256a 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -19,6 +19,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.collection import ( CollectionEntity, DictStorageCollection, @@ -28,7 +29,6 @@ from homeassistant.helpers.collection import ( YamlCollection, sync_entity_lifecycle, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.service import async_register_admin_service diff --git a/homeassistant/components/schluter/__init__.py b/homeassistant/components/schluter/__init__.py index 907841a2e5e..f7a8b631a05 100644 --- a/homeassistant/components/schluter/__init__.py +++ b/homeassistant/components/schluter/__init__.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index ff991c5f348..68a8cf62fe4 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -19,8 +19,11 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + discovery, + entity_registry as er, +) from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 54067055a69..0fdf5d96445 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo diff --git a/homeassistant/components/scsgate/__init__.py b/homeassistant/components/scsgate/__init__.py index 9aabb315942..636c157b076 100644 --- a/homeassistant/components/scsgate/__init__.py +++ b/homeassistant/components/scsgate/__init__.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_DEVICE, CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/scsgate/cover.py b/homeassistant/components/scsgate/cover.py index b6d3317555c..4c4d2c2949a 100644 --- a/homeassistant/components/scsgate/cover.py +++ b/homeassistant/components/scsgate/cover.py @@ -18,7 +18,7 @@ from homeassistant.components.cover import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/scsgate/light.py b/homeassistant/components/scsgate/light.py index 23b73a0fd6b..0addbda9e09 100644 --- a/homeassistant/components/scsgate/light.py +++ b/homeassistant/components/scsgate/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE, CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/scsgate/switch.py b/homeassistant/components/scsgate/switch.py index abc906a5533..4607d65ac7a 100644 --- a/homeassistant/components/scsgate/switch.py +++ b/homeassistant/components/scsgate/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE, CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/sendgrid/notify.py b/homeassistant/components/sendgrid/notify.py index 86f01804574..4dbb95085cb 100644 --- a/homeassistant/components/sendgrid/notify.py +++ b/homeassistant/components/sendgrid/notify.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONTENT_TYPE_TEXT_PLAIN, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/serial/sensor.py b/homeassistant/components/serial/sensor.py index a09401473b2..4d43408397f 100644 --- a/homeassistant/components/serial/sensor.py +++ b/homeassistant/components/serial/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/serial_pm/sensor.py b/homeassistant/components/serial_pm/sensor.py index b454424591d..570d1ac0d63 100644 --- a/homeassistant/components/serial_pm/sensor.py +++ b/homeassistant/components/serial_pm/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/sesame/lock.py b/homeassistant/components/sesame/lock.py index ad8b26f7034..5165d3d4798 100644 --- a/homeassistant/components/sesame/lock.py +++ b/homeassistant/components/sesame/lock.py @@ -13,7 +13,7 @@ from homeassistant.components.lock import ( ) from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_DEVICE_ID, CONF_API_KEY from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/seven_segments/image_processing.py b/homeassistant/components/seven_segments/image_processing.py index 63fd27e0dd0..bda17b75081 100644 --- a/homeassistant/components/seven_segments/image_processing.py +++ b/homeassistant/components/seven_segments/image_processing.py @@ -17,7 +17,7 @@ from homeassistant.components.image_processing import ( ) from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 873d3fbd290..332d95b0a3e 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -16,8 +16,7 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/shodan/sensor.py b/homeassistant/components/shodan/sensor.py index 867b58ad1ba..ef0f4dafd83 100644 --- a/homeassistant/components/shodan/sensor.py +++ b/homeassistant/components/shodan/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/sigfox/sensor.py b/homeassistant/components/sigfox/sensor.py index 8f9190e4436..aece5675cbc 100644 --- a/homeassistant/components/sigfox/sensor.py +++ b/homeassistant/components/sigfox/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/sighthound/image_processing.py b/homeassistant/components/sighthound/image_processing.py index acc8309af26..222b61456c4 100644 --- a/homeassistant/components/sighthound/image_processing.py +++ b/homeassistant/components/sighthound/image_processing.py @@ -22,10 +22,10 @@ from homeassistant.const import ( CONF_SOURCE, ) from homeassistant.core import HomeAssistant, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.pil import draw_box _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/signal_messenger/notify.py b/homeassistant/components/signal_messenger/notify.py index 53a255da5ff..bc007eaa689 100644 --- a/homeassistant/components/signal_messenger/notify.py +++ b/homeassistant/components/signal_messenger/notify.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( BaseNotificationService, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sinch/notify.py b/homeassistant/components/sinch/notify.py index 16780a05704..8c906d26c23 100644 --- a/homeassistant/components/sinch/notify.py +++ b/homeassistant/components/sinch/notify.py @@ -23,7 +23,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_API_KEY, CONF_SENDER from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType DOMAIN = "sinch" diff --git a/homeassistant/components/sisyphus/__init__.py b/homeassistant/components/sisyphus/__init__.py index da8d670d412..1406826e471 100644 --- a/homeassistant/components/sisyphus/__init__.py +++ b/homeassistant/components/sisyphus/__init__.py @@ -8,8 +8,8 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/sky_hub/device_tracker.py b/homeassistant/components/sky_hub/device_tracker.py index b0ad48ed985..7507175b321 100644 --- a/homeassistant/components/sky_hub/device_tracker.py +++ b/homeassistant/components/sky_hub/device_tracker.py @@ -14,8 +14,8 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sky_remote/config_flow.py b/homeassistant/components/sky_remote/config_flow.py index a55dfb2a52b..13cddf99332 100644 --- a/homeassistant/components/sky_remote/config_flow.py +++ b/homeassistant/components/sky_remote/config_flow.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DEFAULT_PORT, DOMAIN, LEGACY_PORT diff --git a/homeassistant/components/skybeacon/sensor.py b/homeassistant/components/skybeacon/sensor.py index 6cb5064b40e..650e62bc4a1 100644 --- a/homeassistant/components/skybeacon/sensor.py +++ b/homeassistant/components/skybeacon/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/slack/sensor.py b/homeassistant/components/slack/sensor.py index d53555ba82a..ca8c9830818 100644 --- a/homeassistant/components/slack/sensor.py +++ b/homeassistant/components/slack/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ATTR_SNOOZE, DOMAIN, SLACK_DATA from .entity import SlackEntity diff --git a/homeassistant/components/sleepiq/__init__.py b/homeassistant/components/sleepiq/__init__.py index 6506be06e72..4f54b4cd305 100644 --- a/homeassistant/components/sleepiq/__init__.py +++ b/homeassistant/components/sleepiq/__init__.py @@ -17,9 +17,8 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, PRESSURE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, IS_IN_BED, SLEEP_NUMBER diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index 4b3e01a79a8..3f5eb635989 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -11,8 +11,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import CONF_GROUP, DOMAIN, GROUPS diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index 0d043804c3d..0e1e99aa444 100644 --- a/homeassistant/components/smarty/__init__.py +++ b/homeassistant/components/smarty/__init__.py @@ -9,8 +9,7 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_HOST, CONF_NAME, Platform from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py index 9d847003a59..48b169c104e 100644 --- a/homeassistant/components/smarty/sensor.py +++ b/homeassistant/components/smarty/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import REVOLUTIONS_PER_MINUTE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .coordinator import SmartyConfigEntry, SmartyCoordinator from .entity import SmartyEntity diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 5d19a705d87..e86b22690a4 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -34,10 +34,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.ssl import client_context from .const import ( diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index 4c2b2b25ad8..f69c844f191 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -24,7 +24,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 4586d0600e9..0baecd68ec4 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -37,7 +37,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index 2f9f8b0bfb7..fd405567d60 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -44,7 +44,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index a7940aa34b5..80c418ef132 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/solax/config_flow.py b/homeassistant/components/solax/config_flow.py index e6c60667869..5baead641fc 100644 --- a/homeassistant/components/solax/config_flow.py +++ b/homeassistant/components/solax/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/soma/__init__.py b/homeassistant/components/soma/__init__.py index 9ffe5539ff3..127b51338ee 100644 --- a/homeassistant/components/soma/__init__.py +++ b/homeassistant/components/soma/__init__.py @@ -9,7 +9,7 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import API, DEVICES, DOMAIN, HOST, PORT diff --git a/homeassistant/components/sonarr/coordinator.py b/homeassistant/components/sonarr/coordinator.py index 2d807bcf140..25fc736212b 100644 --- a/homeassistant/components/sonarr/coordinator.py +++ b/homeassistant/components/sonarr/coordinator.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONF_UPCOMING_DAYS, CONF_WANTED_MAX_ITEMS, DOMAIN, LOGGER diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index f25c885ed84..fa7d0aa7756 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import SonarrDataT, SonarrDataUpdateCoordinator diff --git a/homeassistant/components/sony_projector/switch.py b/homeassistant/components/sony_projector/switch.py index e018c06e050..f024c4ef4f7 100644 --- a/homeassistant/components/sony_projector/switch.py +++ b/homeassistant/components/sony_projector/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/soundtouch/__init__.py b/homeassistant/components/soundtouch/__init__.py index c35c1e6f9c3..49750bc9baf 100644 --- a/homeassistant/components/soundtouch/__init__.py +++ b/homeassistant/components/soundtouch/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/spaceapi/__init__.py b/homeassistant/components/spaceapi/__init__.py index 90281fe311c..6ef643488ad 100644 --- a/homeassistant/components/spaceapi/__init__.py +++ b/homeassistant/components/spaceapi/__init__.py @@ -5,6 +5,7 @@ import math import voluptuous as vol +from homeassistant import core as ha from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.const import ( ATTR_ENTITY_ID, @@ -21,11 +22,10 @@ from homeassistant.const import ( CONF_STATE, CONF_URL, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util ATTR_ADDRESS = "address" ATTR_SPACEFED = "spacefed" diff --git a/homeassistant/components/spc/__init__.py b/homeassistant/components/spc/__init__.py index 3d9467f2041..2fed542e382 100644 --- a/homeassistant/components/spc/__init__.py +++ b/homeassistant/components/spc/__init__.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv, discovery from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/splunk/__init__.py b/homeassistant/components/splunk/__init__.py index 4294020eeee..6ef8fed78d6 100644 --- a/homeassistant/components/splunk/__init__.py +++ b/homeassistant/components/splunk/__init__.py @@ -19,9 +19,8 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py index a86544d883e..8b8539d715a 100644 --- a/homeassistant/components/spotify/coordinator.py +++ b/homeassistant/components/spotify/coordinator.py @@ -18,7 +18,7 @@ from spotifyaio import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index 71e3671ce96..1b9e8502209 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -24,8 +24,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, diff --git a/homeassistant/components/starlingbank/sensor.py b/homeassistant/components/starlingbank/sensor.py index 282323d8b7b..063919179ac 100644 --- a/homeassistant/components/starlingbank/sensor.py +++ b/homeassistant/components/starlingbank/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py index 5fc4872a754..62e02426fcb 100644 --- a/homeassistant/components/startca/sensor.py +++ b/homeassistant/components/startca/sensor.py @@ -25,8 +25,8 @@ from homeassistant.const import ( UnitOfInformation, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/statsd/__init__.py b/homeassistant/components/statsd/__init__.py index 50b74b20028..4e8e5b7f942 100644 --- a/homeassistant/components/statsd/__init__.py +++ b/homeassistant/components/statsd/__init__.py @@ -7,8 +7,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PREFIX, EVENT_STATE_CHANGED from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py index 80c1dad3ee8..94a3bd1058b 100644 --- a/homeassistant/components/stiebel_eltron/__init__.py +++ b/homeassistant/components/stiebel_eltron/__init__.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py index 5eeb40630f8..313fc1f24c5 100644 --- a/homeassistant/components/streamlabswater/__init__.py +++ b/homeassistant/components/streamlabswater/__init__.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN from .coordinator import StreamlabsCoordinator diff --git a/homeassistant/components/supervisord/sensor.py b/homeassistant/components/supervisord/sensor.py index 24189fb7de0..c443e1e63df 100644 --- a/homeassistant/components/supervisord/sensor.py +++ b/homeassistant/components/supervisord/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py index 8f04b5b662e..62f9b4b232d 100644 --- a/homeassistant/components/supla/__init__.py +++ b/homeassistant/components/supla/__init__.py @@ -11,8 +11,8 @@ import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator diff --git a/homeassistant/components/swiss_hydrological_data/sensor.py b/homeassistant/components/swiss_hydrological_data/sensor.py index 3d88182eaa4..897b440a934 100644 --- a/homeassistant/components/swiss_hydrological_data/sensor.py +++ b/homeassistant/components/swiss_hydrological_data/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py index 58d674f0c26..4dc6efc2e85 100644 --- a/homeassistant/components/swiss_public_transport/config_flow.py +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -11,8 +11,8 @@ from opendata_transport.exceptions import ( import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( DurationSelector, SelectSelector, diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py index c4cf2390dd0..81322117a6f 100644 --- a/homeassistant/components/swiss_public_transport/coordinator.py +++ b/homeassistant/components/swiss_public_transport/coordinator.py @@ -15,7 +15,7 @@ from opendata_transport.exceptions import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonValueType from .const import CONNECTIONS_COUNT, DEFAULT_UPDATE_TIME, DOMAIN diff --git a/homeassistant/components/swiss_public_transport/helper.py b/homeassistant/components/swiss_public_transport/helper.py index 704479b77d6..e41901337f4 100644 --- a/homeassistant/components/swiss_public_transport/helper.py +++ b/homeassistant/components/swiss_public_transport/helper.py @@ -6,7 +6,7 @@ from typing import Any from opendata_transport import OpendataTransport -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( CONF_DESTINATION, diff --git a/homeassistant/components/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index 66537a4311e..842dc657817 100644 --- a/homeassistant/components/swisscom/device_tracker.py +++ b/homeassistant/components/swisscom/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switchbee/config_flow.py b/homeassistant/components/switchbee/config_flow.py index c8d3d58ee09..b2cd53398ab 100644 --- a/homeassistant/components/switchbee/config_flow.py +++ b/homeassistant/components/switchbee/config_flow.py @@ -13,8 +13,8 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index 4e05e9e9a1e..9e996649e8c 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -4,7 +4,7 @@ from typing import Any from switchbot_api import AirConditionerCommands -import homeassistant.components.climate as FanState +from homeassistant.components import climate as FanState from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, diff --git a/homeassistant/components/switchmate/switch.py b/homeassistant/components/switchmate/switch.py index 8484eb5a2d1..0b449c65194 100644 --- a/homeassistant/components/switchmate/switch.py +++ b/homeassistant/components/switchmate/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/synology_chat/notify.py b/homeassistant/components/synology_chat/notify.py index 38c302b7968..37ea3238a06 100644 --- a/homeassistant/components/synology_chat/notify.py +++ b/homeassistant/components/synology_chat/notify.py @@ -16,7 +16,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_RESOURCE, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType ATTR_FILE_URL = "file_url" diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 30f5078f19d..b4453366718 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -40,8 +40,8 @@ from homeassistant.const import ( ) 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 -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, diff --git a/homeassistant/components/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py index 3e0e7add185..b916be84acf 100644 --- a/homeassistant/components/synology_srm/device_tracker.py +++ b/homeassistant/components/synology_srm/device_tracker.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 191a2b5feb8..facfb270627 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -16,7 +16,7 @@ from homeassistant import __path__ as HOMEASSISTANT_PATH from homeassistant.components import websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE from homeassistant.core import Event, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType type KeyType = tuple[str, tuple[str, int], tuple[str, int, str] | None] From 706a01837cf23a7b68831645a40e798b850ece9f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:25:04 +0100 Subject: [PATCH 1185/2987] Standardize homeassistant imports in component (v-z) (#136834) --- homeassistant/components/vasttrafik/sensor.py | 2 +- homeassistant/components/velux/config_flow.py | 2 +- homeassistant/components/venstar/climate.py | 2 +- homeassistant/components/versasense/__init__.py | 3 +-- homeassistant/components/vesync/config_flow.py | 2 +- homeassistant/components/viaggiatreno/sensor.py | 2 +- homeassistant/components/vicare/climate.py | 3 +-- homeassistant/components/vicare/config_flow.py | 2 +- homeassistant/components/vizio/const.py | 2 +- homeassistant/components/vlc/media_player.py | 4 ++-- homeassistant/components/vlc_telnet/media_player.py | 2 +- homeassistant/components/voicerss/tts.py | 2 +- homeassistant/components/volkszaehler/sensor.py | 2 +- homeassistant/components/vultr/__init__.py | 2 +- homeassistant/components/vultr/binary_sensor.py | 2 +- homeassistant/components/vultr/sensor.py | 2 +- homeassistant/components/vultr/switch.py | 2 +- homeassistant/components/w800rf32/__init__.py | 2 +- homeassistant/components/wake_on_lan/__init__.py | 2 +- homeassistant/components/wake_on_lan/switch.py | 3 +-- homeassistant/components/watson_iot/__init__.py | 3 +-- homeassistant/components/watson_tts/tts.py | 2 +- homeassistant/components/wirelesstag/__init__.py | 2 +- homeassistant/components/wirelesstag/binary_sensor.py | 2 +- homeassistant/components/wirelesstag/sensor.py | 2 +- homeassistant/components/wirelesstag/switch.py | 2 +- homeassistant/components/workday/binary_sensor.py | 2 +- homeassistant/components/worldclock/sensor.py | 2 +- homeassistant/components/worldtidesinfo/sensor.py | 2 +- homeassistant/components/worxlandroid/sensor.py | 2 +- homeassistant/components/wsdot/sensor.py | 2 +- homeassistant/components/x10/light.py | 2 +- homeassistant/components/xiaomi/device_tracker.py | 2 +- homeassistant/components/xiaomi_aqara/__init__.py | 3 +-- homeassistant/components/xiaomi_miio/device_tracker.py | 2 +- homeassistant/components/xiaomi_miio/fan.py | 2 +- homeassistant/components/xiaomi_miio/light.py | 2 +- homeassistant/components/xiaomi_miio/switch.py | 2 +- homeassistant/components/xiaomi_tv/media_player.py | 2 +- homeassistant/components/xmpp/notify.py | 3 +-- homeassistant/components/xs1/__init__.py | 3 +-- homeassistant/components/yale/lock.py | 2 +- homeassistant/components/yale_smart_alarm/config_flow.py | 2 +- homeassistant/components/yandex_transport/sensor.py | 4 ++-- homeassistant/components/yandextts/tts.py | 2 +- homeassistant/components/yeelight/__init__.py | 2 +- homeassistant/components/yeelight/config_flow.py | 2 +- homeassistant/components/zabbix/__init__.py | 7 +++++-- homeassistant/components/zabbix/sensor.py | 2 +- homeassistant/components/zestimate/sensor.py | 2 +- homeassistant/components/zha/__init__.py | 3 +-- homeassistant/components/zha/websocket_api.py | 3 +-- homeassistant/components/zhong_hong/climate.py | 2 +- homeassistant/components/ziggo_mediabox_xl/media_player.py | 2 +- homeassistant/components/zoneminder/__init__.py | 2 +- homeassistant/components/zoneminder/sensor.py | 2 +- homeassistant/components/zoneminder/switch.py | 2 +- homeassistant/components/zwave_js/config_validation.py | 2 +- homeassistant/components/zwave_js/services.py | 7 +++++-- 59 files changed, 69 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 48f659103e1..424ffdc0ed2 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_DELAY, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/velux/config_flow.py b/homeassistant/components/velux/config_flow.py index fba023f7638..24f65aa3b0b 100644 --- a/homeassistant/components/velux/config_flow.py +++ b/homeassistant/components/velux/config_flow.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py index c5323e1e9a8..50f6508e7ed 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -32,7 +32,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/versasense/__init__.py b/homeassistant/components/versasense/__init__.py index ed4a8edf32c..cbd69ba0a81 100644 --- a/homeassistant/components/versasense/__init__.py +++ b/homeassistant/components/versasense/__init__.py @@ -7,8 +7,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/vesync/config_flow.py b/homeassistant/components/vesync/config_flow.py index 6115cb9ee76..e19c46e5490 100644 --- a/homeassistant/components/vesync/config_flow.py +++ b/homeassistant/components/vesync/config_flow.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index cb652270c69..4a75f5cccd2 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -16,8 +16,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 62231a4e2fe..f62fdc363a6 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -32,8 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index 36db8e92cc7..c1d4adda62a 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py index 8451ae747de..fbfaf222cad 100644 --- a/homeassistant/components/vizio/const.py +++ b/homeassistant/components/vizio/const.py @@ -10,7 +10,7 @@ from homeassistant.components.media_player import ( MediaPlayerDeviceClass, MediaPlayerEntityFeature, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType SERVICE_UPDATE_SETTING = "update_setting" diff --git a/homeassistant/components/vlc/media_player.py b/homeassistant/components/vlc/media_player.py index cd05c919d58..d1a481a99b1 100644 --- a/homeassistant/components/vlc/media_player.py +++ b/homeassistant/components/vlc/media_player.py @@ -20,10 +20,10 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index b95e987aef8..9597c706570 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import VlcConfigEntry from .const import DEFAULT_NAME, DOMAIN, LOGGER diff --git a/homeassistant/components/voicerss/tts.py b/homeassistant/components/voicerss/tts.py index 9f1615ffa01..6bf42d86836 100644 --- a/homeassistant/components/voicerss/tts.py +++ b/homeassistant/components/voicerss/tts.py @@ -13,8 +13,8 @@ from homeassistant.components.tts import ( Provider, ) from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/volkszaehler/sensor.py b/homeassistant/components/volkszaehler/sensor.py index c4fa7b1088b..5bd4a63c923 100644 --- a/homeassistant/components/volkszaehler/sensor.py +++ b/homeassistant/components/volkszaehler/sensor.py @@ -26,8 +26,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/vultr/__init__.py b/homeassistant/components/vultr/__init__.py index 36f43cf0ac0..66527bf458e 100644 --- a/homeassistant/components/vultr/__init__.py +++ b/homeassistant/components/vultr/__init__.py @@ -9,7 +9,7 @@ from vultr import Vultr as VultrAPI from homeassistant.components import persistent_notification from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/vultr/binary_sensor.py b/homeassistant/components/vultr/binary_sensor.py index 6a697eebe11..3972de8a625 100644 --- a/homeassistant/components/vultr/binary_sensor.py +++ b/homeassistant/components/vultr/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/vultr/sensor.py b/homeassistant/components/vultr/sensor.py index 843aa416297..c392c382cbd 100644 --- a/homeassistant/components/vultr/sensor.py +++ b/homeassistant/components/vultr/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, UnitOfInformation from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/vultr/switch.py b/homeassistant/components/vultr/switch.py index b03d613895a..0b1f2247684 100644 --- a/homeassistant/components/vultr/switch.py +++ b/homeassistant/components/vultr/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/w800rf32/__init__.py b/homeassistant/components/w800rf32/__init__.py index 62b9ba810d9..7dab0b137c5 100644 --- a/homeassistant/components/w800rf32/__init__.py +++ b/homeassistant/components/w800rf32/__init__.py @@ -11,7 +11,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/wake_on_lan/__init__.py b/homeassistant/components/wake_on_lan/__init__.py index efd72c4564c..d68d950e641 100644 --- a/homeassistant/components/wake_on_lan/__init__.py +++ b/homeassistant/components/wake_on_lan/__init__.py @@ -9,7 +9,7 @@ import wakeonlan from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_BROADCAST_ADDRESS, CONF_BROADCAST_PORT, CONF_MAC from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS diff --git a/homeassistant/components/wake_on_lan/switch.py b/homeassistant/components/wake_on_lan/switch.py index fcf8936d498..16df34c1d1b 100644 --- a/homeassistant/components/wake_on_lan/switch.py +++ b/homeassistant/components/wake_on_lan/switch.py @@ -21,8 +21,7 @@ from homeassistant.const import ( CONF_NAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/watson_iot/__init__.py b/homeassistant/components/watson_iot/__init__.py index de8c85f5ff0..0130b53930b 100644 --- a/homeassistant/components/watson_iot/__init__.py +++ b/homeassistant/components/watson_iot/__init__.py @@ -23,8 +23,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py index 373d17438c9..194e0905ff0 100644 --- a/homeassistant/components/watson_tts/tts.py +++ b/homeassistant/components/watson_tts/tts.py @@ -10,7 +10,7 @@ from homeassistant.components.tts import ( PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA, Provider, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index a32e940073b..806e7abed00 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -10,7 +10,7 @@ from wirelesstagpy.exceptions import WirelessTagsException from homeassistant.components import persistent_notification from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index 9e8075dd874..8a0957e16e3 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index 7a3cbe5efe2..9b92480ecf9 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py index cae5d63988c..9fa630d4f55 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 3684208f102..3aad6d805d0 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import ( SupportsResponse, callback, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, diff --git a/homeassistant/components/worldclock/sensor.py b/homeassistant/components/worldclock/sensor.py index 89ea14bbbd0..88e5a317cdd 100644 --- a/homeassistant/components/worldclock/sensor.py +++ b/homeassistant/components/worldclock/sensor.py @@ -10,7 +10,7 @@ from homeassistant.const import CONF_NAME, CONF_TIME_ZONE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONF_TIME_FORMAT, DOMAIN diff --git a/homeassistant/components/worldtidesinfo/sensor.py b/homeassistant/components/worldtidesinfo/sensor.py index 45f39894abb..1a64954bb4a 100644 --- a/homeassistant/components/worldtidesinfo/sensor.py +++ b/homeassistant/components/worldtidesinfo/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py index 50700b78f35..ed3312fc950 100644 --- a/homeassistant/components/worxlandroid/sensor.py +++ b/homeassistant/components/worxlandroid/sensor.py @@ -14,8 +14,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_HOST, CONF_PIN, CONF_TIMEOUT, PERCENTAGE from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/wsdot/sensor.py b/homeassistant/components/wsdot/sensor.py index 73714b75c95..8ae93c809f2 100644 --- a/homeassistant/components/wsdot/sensor.py +++ b/homeassistant/components/wsdot/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ATTR_NAME, CONF_API_KEY, CONF_ID, CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/x10/light.py b/homeassistant/components/x10/light.py index d98f1f51d54..fbdebe11657 100644 --- a/homeassistant/components/x10/light.py +++ b/homeassistant/components/x10/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/xiaomi/device_tracker.py b/homeassistant/components/xiaomi/device_tracker.py index 9d4a29d2c78..5968a17f418 100644 --- a/homeassistant/components/xiaomi/device_tracker.py +++ b/homeassistant/components/xiaomi/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index b7f4aa1942e..579994aaf6b 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -17,8 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/xiaomi_miio/device_tracker.py b/homeassistant/components/xiaomi_miio/device_tracker.py index 1dfc5e53410..518003ceedb 100644 --- a/homeassistant/components/xiaomi_miio/device_tracker.py +++ b/homeassistant/components/xiaomi_miio/device_tracker.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index e1de3f56252..12ed9f7195b 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -33,7 +33,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICE, CONF_MODEL from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 3f1f8b926b3..c1f778928d9 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -42,7 +42,7 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import color as color_util, dt as dt_util diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 02f4d4e94e5..b4c4300dbe8 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -29,7 +29,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( diff --git a/homeassistant/components/xiaomi_tv/media_player.py b/homeassistant/components/xiaomi_tv/media_player.py index 675c802f79c..19cb4faf2b9 100644 --- a/homeassistant/components/xiaomi_tv/media_player.py +++ b/homeassistant/components/xiaomi_tv/media_player.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 3fb5dd166a1..968f925d1e8 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -35,8 +35,7 @@ from homeassistant.const import ( CONF_SENDER, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.template as template_helper +from homeassistant.helpers import config_validation as cv, template as template_helper from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xs1/__init__.py b/homeassistant/components/xs1/__init__.py index 6f7197817d7..15fb9d021c6 100644 --- a/homeassistant/components/xs1/__init__.py +++ b/homeassistant/components/xs1/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/yale/lock.py b/homeassistant/components/yale/lock.py index b911c92ba0f..7fdad118cde 100644 --- a/homeassistant/components/yale/lock.py +++ b/homeassistant/components/yale/lock.py @@ -16,7 +16,7 @@ from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import YaleConfigEntry, YaleData from .entity import YaleEntity diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index 3ceee367284..1aaad2aa63a 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_AREA_ID, diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index 95c4785a341..f87d29fffed 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -15,11 +15,11 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_create_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/yandextts/tts.py b/homeassistant/components/yandextts/tts.py index 850afd05150..c7621eb639a 100644 --- a/homeassistant/components/yandextts/tts.py +++ b/homeassistant/components/yandextts/tts.py @@ -13,8 +13,8 @@ from homeassistant.components.tts import ( Provider, ) from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 9b71bbc3b16..0b3ceaf2aee 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, VolDictType from .const import ( diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 35892764bcb..15975ba22bd 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo diff --git a/homeassistant/components/zabbix/__init__.py b/homeassistant/components/zabbix/__init__.py index 05881d649cf..524bac271de 100644 --- a/homeassistant/components/zabbix/__init__.py +++ b/homeassistant/components/zabbix/__init__.py @@ -27,8 +27,11 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import event as event_helper, state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + event as event_helper, + state as state_helper, +) from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, convert_include_exclude_filter, diff --git a/homeassistant/components/zabbix/sensor.py b/homeassistant/components/zabbix/sensor.py index 7728233ebc0..27d7e71d8d9 100644 --- a/homeassistant/components/zabbix/sensor.py +++ b/homeassistant/components/zabbix/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index 12831c96932..ec8850b187d 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 1897b741d87..28f029b62d5 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -21,8 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 5ffd7117d93..d562a807a4f 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -59,8 +59,7 @@ from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import VolDictType, VolSchemaType diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index b5acc230472..af3287d3068 100644 --- a/homeassistant/components/zhong_hong/climate.py +++ b/homeassistant/components/zhong_hong/climate.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/ziggo_mediabox_xl/media_player.py b/homeassistant/components/ziggo_mediabox_xl/media_player.py index 6e858b454e9..fe180208801 100644 --- a/homeassistant/components/ziggo_mediabox_xl/media_player.py +++ b/homeassistant/components/ziggo_mediabox_xl/media_player.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py index e87a2b1531d..c2e57b0448b 100644 --- a/homeassistant/components/zoneminder/__init__.py +++ b/homeassistant/components/zoneminder/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/zoneminder/sensor.py b/homeassistant/components/zoneminder/sensor.py index 75769d9fd98..4f79f8876e5 100644 --- a/homeassistant/components/zoneminder/sensor.py +++ b/homeassistant/components/zoneminder/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/zoneminder/switch.py b/homeassistant/components/zoneminder/switch.py index 23adf2f4c88..13da0927196 100644 --- a/homeassistant/components/zoneminder/switch.py +++ b/homeassistant/components/zoneminder/switch.py @@ -16,7 +16,7 @@ from homeassistant.components.switch import ( from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/zwave_js/config_validation.py b/homeassistant/components/zwave_js/config_validation.py index 30bc2f16789..2615bfc72b3 100644 --- a/homeassistant/components/zwave_js/config_validation.py +++ b/homeassistant/components/zwave_js/config_validation.py @@ -4,7 +4,7 @@ from typing import Any import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv # Validates that a bitmask is provided in hex form and converts it to decimal # int equivalent since that's what the library uses diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index fe293fd178b..8389eff8cb2 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -29,8 +29,11 @@ from zwave_js_server.util.node import ( from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.group import expand_entity_ids From 7249c02655760080c75e687a2ca64e5c719f04fc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 12:32:18 +0100 Subject: [PATCH 1186/2987] Add backup endpoints to the onboarding integration (#136051) * Add backup endpoints to the onboarding integration * Add backup as after dependency of onboarding * Add test snapshots * Fix stale docstrings * Add utility function for getting the backup manager instance * Return backup_id when uploading backup * Change /api/onboarding/backup/restore to accept a JSON body * Fix with_backup_manager --- homeassistant/components/backup/__init__.py | 1 + homeassistant/components/backup/http.py | 12 +- homeassistant/components/backup/manager.py | 13 +- .../components/onboarding/manifest.json | 2 +- homeassistant/components/onboarding/views.py | 129 +++++++- tests/components/backup/test_http.py | 2 + .../onboarding/snapshots/test_views.ambr | 58 ++++ tests/components/onboarding/test_views.py | 311 +++++++++++++++++- 8 files changed, 514 insertions(+), 14 deletions(-) create mode 100644 tests/components/onboarding/snapshots/test_views.ambr diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 10294f6ff12..ce3fea80f67 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -53,6 +53,7 @@ __all__ = [ "NewBackup", "RestoreBackupEvent", "WrittenBackup", + "async_get_manager", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index b909b2728a7..3d3877cc2f7 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -144,13 +144,17 @@ class DownloadBackupView(HomeAssistantView): class UploadBackupView(HomeAssistantView): - """Generate backup view.""" + """Upload backup view.""" url = "/api/backup/upload" name = "api:backup:upload" @require_admin async def post(self, request: Request) -> Response: + """Upload a backup file.""" + return await self._post(request) + + async def _post(self, request: Request) -> Response: """Upload a backup file.""" try: agent_ids = request.query.getall("agent_id") @@ -161,7 +165,9 @@ class UploadBackupView(HomeAssistantView): contents = cast(BodyPartReader, await reader.next()) try: - await manager.async_receive_backup(contents=contents, agent_ids=agent_ids) + backup_id = await manager.async_receive_backup( + contents=contents, agent_ids=agent_ids + ) except OSError as err: return Response( body=f"Can't write backup file: {err}", @@ -175,4 +181,4 @@ class UploadBackupView(HomeAssistantView): except asyncio.CancelledError: return Response(status=HTTPStatus.INTERNAL_SERVER_ERROR) - return Response(status=HTTPStatus.CREATED) + return self.json({"backup_id": backup_id}, status_code=HTTPStatus.CREATED) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 4a871cdf73e..19ebb8011ee 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -298,6 +298,7 @@ class BackupManager: # Latest backup event and backup event subscribers self.last_event: ManagerStateEvent = IdleEvent() + self.last_non_idle_event: ManagerStateEvent | None = None self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = [] async def async_setup(self) -> None: @@ -620,7 +621,7 @@ class BackupManager: *, agent_ids: list[str], contents: aiohttp.BodyPartReader, - ) -> None: + ) -> str: """Receive and store a backup file from upload.""" if self.state is not BackupManagerState.IDLE: raise BackupManagerError(f"Backup manager busy: {self.state}") @@ -632,7 +633,9 @@ class BackupManager: ) ) try: - await self._async_receive_backup(agent_ids=agent_ids, contents=contents) + backup_id = await self._async_receive_backup( + agent_ids=agent_ids, contents=contents + ) except Exception: self.async_on_backup_event( ReceiveBackupEvent( @@ -650,6 +653,7 @@ class BackupManager: state=ReceiveBackupState.COMPLETED, ) ) + return backup_id finally: self.async_on_backup_event(IdleEvent()) @@ -658,7 +662,7 @@ class BackupManager: *, agent_ids: list[str], contents: aiohttp.BodyPartReader, - ) -> None: + ) -> str: """Receive and store a backup file from upload.""" contents.chunk_size = BUF_SIZE self.async_on_backup_event( @@ -687,6 +691,7 @@ class BackupManager: ) await written_backup.release_stream() self.known_backups.add(written_backup.backup, agent_errors) + return written_backup.backup.backup_id async def async_create_backup( self, @@ -1041,6 +1046,8 @@ class BackupManager: if (current_state := self.state) != (new_state := event.manager_state): LOGGER.debug("Backup state: %s -> %s", current_state, new_state) self.last_event = event + if not isinstance(event, IdleEvent): + self.last_non_idle_event = event for subscription in self._backup_event_subscriptions: subscription(event) diff --git a/homeassistant/components/onboarding/manifest.json b/homeassistant/components/onboarding/manifest.json index 918d845993a..8e253d4bff9 100644 --- a/homeassistant/components/onboarding/manifest.json +++ b/homeassistant/components/onboarding/manifest.json @@ -1,7 +1,7 @@ { "domain": "onboarding", "name": "Home Assistant Onboarding", - "after_dependencies": ["hassio"], + "after_dependencies": ["backup", "hassio"], "codeowners": ["@home-assistant/core"], "dependencies": ["auth", "http", "person"], "documentation": "https://www.home-assistant.io/integrations/onboarding", diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index b33440a9eb7..edf0b615779 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -3,9 +3,10 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine +from functools import wraps from http import HTTPStatus -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Concatenate, cast from aiohttp import web from aiohttp.web_exceptions import HTTPUnauthorized @@ -15,10 +16,18 @@ from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import person from homeassistant.components.auth import indieauth +from homeassistant.components.backup import ( + BackupManager, + Folder, + IncorrectPasswordError, + async_get_manager as async_get_backup_manager, + http as backup_http, +) from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.system_info import async_get_system_info @@ -50,6 +59,9 @@ async def async_setup( hass.http.register_view(CoreConfigOnboardingView(data, store)) hass.http.register_view(IntegrationOnboardingView(data, store)) hass.http.register_view(AnalyticsOnboardingView(data, store)) + hass.http.register_view(BackupInfoView(data)) + hass.http.register_view(RestoreBackupView(data)) + hass.http.register_view(UploadBackupView(data)) class OnboardingView(HomeAssistantView): @@ -312,6 +324,119 @@ class AnalyticsOnboardingView(_BaseOnboardingView): return self.json({}) +class BackupOnboardingView(HomeAssistantView): + """Backup onboarding view.""" + + requires_auth = False + + def __init__(self, data: OnboardingStoreData) -> None: + """Initialize the view.""" + self._data = data + + +def with_backup_manager[_ViewT: BackupOnboardingView, **_P]( + func: Callable[ + Concatenate[_ViewT, BackupManager, web.Request, _P], + Coroutine[Any, Any, web.Response], + ], +) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]: + """Home Assistant API decorator to check onboarding and inject manager.""" + + @wraps(func) + async def with_backup( + self: _ViewT, + request: web.Request, + *args: _P.args, + **kwargs: _P.kwargs, + ) -> web.Response: + """Check admin and call function.""" + if self._data["done"]: + raise HTTPUnauthorized + + try: + manager = async_get_backup_manager(request.app[KEY_HASS]) + except HomeAssistantError: + return self.json( + {"error": "backup_disabled"}, + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + return await func(self, manager, request, *args, **kwargs) + + return with_backup + + +class BackupInfoView(BackupOnboardingView): + """Get backup info view.""" + + url = "/api/onboarding/backup/info" + name = "api:onboarding:backup:info" + + @with_backup_manager + async def get(self, manager: BackupManager, request: web.Request) -> web.Response: + """Return backup info.""" + backups, _ = await manager.async_get_backups() + return self.json( + { + "backups": [backup.as_frontend_json() for backup in backups.values()], + "state": manager.state, + "last_non_idle_event": manager.last_non_idle_event, + } + ) + + +class RestoreBackupView(BackupOnboardingView): + """Restore backup view.""" + + url = "/api/onboarding/backup/restore" + name = "api:onboarding:backup:restore" + + @RequestDataValidator( + vol.Schema( + { + vol.Required("backup_id"): str, + vol.Required("agent_id"): str, + vol.Optional("password"): str, + vol.Optional("restore_addons"): [str], + vol.Optional("restore_database", default=True): bool, + vol.Optional("restore_folders"): [vol.Coerce(Folder)], + } + ) + ) + @with_backup_manager + async def post( + self, manager: BackupManager, request: web.Request, data: dict[str, Any] + ) -> web.Response: + """Restore a backup.""" + try: + await manager.async_restore_backup( + data["backup_id"], + agent_id=data["agent_id"], + password=data.get("password"), + restore_addons=data.get("restore_addons"), + restore_database=data["restore_database"], + restore_folders=data.get("restore_folders"), + restore_homeassistant=True, + ) + except IncorrectPasswordError: + return self.json( + {"message": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST + ) + return web.Response(status=HTTPStatus.OK) + + +class UploadBackupView(BackupOnboardingView, backup_http.UploadBackupView): + """Upload backup view.""" + + url = "/api/onboarding/backup/upload" + name = "api:onboarding:backup:upload" + + @with_backup_manager + async def post(self, manager: BackupManager, request: web.Request) -> web.Response: + """Upload a backup file.""" + return await self._post(request) + + @callback def _async_get_hass_provider(hass: HomeAssistant) -> HassAuthProvider: """Get the Home Assistant auth provider.""" diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index b7b86cc1d45..ee6803655d5 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -233,12 +233,14 @@ async def test_uploading_a_backup_file( with patch( "homeassistant.components.backup.manager.BackupManager.async_receive_backup", + return_value=TEST_BACKUP_ABC123.backup_id, ) as async_receive_backup_mock: resp = await client.post( "/api/backup/upload?agent_id=backup.local", data={"file": StringIO("test")}, ) assert resp.status == 201 + assert await resp.json() == {"backup_id": TEST_BACKUP_ABC123.backup_id} assert async_receive_backup_mock.called diff --git a/tests/components/onboarding/snapshots/test_views.ambr b/tests/components/onboarding/snapshots/test_views.ambr new file mode 100644 index 00000000000..90428055823 --- /dev/null +++ b/tests/components/onboarding/snapshots/test_views.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_onboarding_backup_info + dict({ + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_automatic_settings': True, + }), + dict({ + 'addons': list([ + ]), + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'def456', + 'database_included': False, + 'date': '1980-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test 2', + 'protected': False, + 'size': 1, + 'with_automatic_settings': None, + }), + ]), + 'last_non_idle_event': None, + 'state': 'idle', + }) +# --- diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 35f6b7d739c..683d2c370f2 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -3,13 +3,15 @@ import asyncio from collections.abc import AsyncGenerator from http import HTTPStatus +from io import StringIO import os from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch import pytest +from syrupy import SnapshotAssertion -from homeassistant.components import onboarding +from homeassistant.components import backup, onboarding from homeassistant.components.onboarding import const, views from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar @@ -649,12 +651,28 @@ async def test_onboarding_installation_type( assert resp_content["installation_type"] == "Home Assistant Core" -async def test_onboarding_installation_type_after_done( +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ("get", "installation_type", {}), + ("get", "backup/info", {}), + ( + "post", + "backup/restore", + {"json": {"backup_id": "abc123", "agent_id": "test"}}, + ), + ("post", "backup/upload", {}), + ], +) +async def test_onboarding_view_after_done( hass: HomeAssistant, hass_storage: dict[str, Any], hass_client: ClientSessionGenerator, + method: str, + view: str, + kwargs: dict[str, Any], ) -> None: - """Test raising for installation type after onboarding.""" + """Test raising after onboarding.""" mock_storage(hass_storage, {"done": [const.STEP_USER]}) assert await async_setup_component(hass, "onboarding", {}) @@ -662,7 +680,7 @@ async def test_onboarding_installation_type_after_done( client = await hass_client() - resp = await client.get("/api/onboarding/installation_type") + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) assert resp.status == 401 @@ -726,3 +744,286 @@ async def test_complete_onboarding( listener_3 = Mock() onboarding.async_add_listener(hass, listener_3) listener_3.assert_called_once_with() + + +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ("get", "backup/info", {}), + ( + "post", + "backup/restore", + {"json": {"backup_id": "abc123", "agent_id": "test"}}, + ), + ("post", "backup/upload", {}), + ], +) +async def test_onboarding_backup_view_without_backup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + method: str, + view: str, + kwargs: dict[str, Any], +) -> None: + """Test interacting with backup wievs when backup integration is missing.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) + + assert resp.status == 500 + assert await resp.json() == {"error": "backup_disabled"} + + +async def test_onboarding_backup_info( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + backups = { + "abc123": backup.ManagerBackup( + addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="abc123", + date="1970-01-01T00:00:00.000Z", + database_included=True, + extra_metadata={"instance_id": "abc123", "with_automatic_settings": True}, + folders=[backup.Folder.MEDIA, backup.Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=0, + agent_ids=["backup.local"], + failed_agent_ids=[], + with_automatic_settings=True, + ), + "def456": backup.ManagerBackup( + addons=[], + backup_id="def456", + date="1980-01-01T00:00:00.000Z", + database_included=False, + extra_metadata={ + "instance_id": "unknown_uuid", + "with_automatic_settings": True, + }, + folders=[backup.Folder.MEDIA, backup.Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test 2", + protected=False, + size=1, + agent_ids=["test.remote"], + failed_agent_ids=[], + with_automatic_settings=None, + ), + } + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ): + resp = await client.get("/api/onboarding/backup/info") + + assert resp.status == 200 + assert await resp.json() == snapshot + + +@pytest.mark.parametrize( + ("params", "expected_kwargs"), + [ + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + { + "agent_id": "backup.local", + "password": None, + "restore_addons": None, + "restore_database": True, + "restore_folders": None, + "restore_homeassistant": True, + }, + ), + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1"], + "restore_database": True, + "restore_folders": ["media"], + }, + { + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1"], + "restore_database": True, + "restore_folders": [backup.Folder.MEDIA], + "restore_homeassistant": True, + }, + ), + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1", "addon_2"], + "restore_database": False, + "restore_folders": ["media", "share"], + }, + { + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1", "addon_2"], + "restore_database": False, + "restore_folders": [backup.Folder.MEDIA, backup.Folder.SHARE], + "restore_homeassistant": True, + }, + ), + ], +) +async def test_onboarding_backup_restore( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + expected_kwargs: dict[str, Any], +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + assert resp.status == 200 + mock_restore.assert_called_once_with("abc123", **expected_kwargs) + + +@pytest.mark.parametrize( + ("params", "restore_error", "expected_status", "expected_message", "restore_calls"), + [ + # Missing agent_id + ( + {"backup_id": "abc123"}, + None, + 400, + "Message format incorrect: required key not provided @ data['agent_id']", + 0, + ), + # Missing backup_id + ( + {"agent_id": "backup.local"}, + None, + 400, + "Message format incorrect: required key not provided @ data['backup_id']", + 0, + ), + # Invalid restore_database + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "restore_database": "yes_please", + }, + None, + 400, + "Message format incorrect: expected bool for dictionary value @ data['restore_database']", + 0, + ), + # Invalid folder + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "restore_folders": ["invalid"], + }, + None, + 400, + "Message format incorrect: expected Folder or one of 'share', 'addons/local', 'ssl', 'media' @ data['restore_folders'][0]", + 0, + ), + # Wrong password + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + backup.IncorrectPasswordError, + 400, + "incorrect_password", + 1, + ), + ], +) +async def test_onboarding_backup_restore_error( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + restore_error: Exception | None, + expected_status: int, + expected_message: str, + restore_calls: int, +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + side_effect=restore_error, + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + + assert resp.status == expected_status + assert await resp.json() == {"message": expected_message} + assert len(mock_restore.mock_calls) == restore_calls + + +async def test_onboarding_backup_upload( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_receive_backup", + return_value="abc123", + ) as mock_receive: + resp = await client.post( + "/api/onboarding/backup/upload?agent_id=backup.local", + data={"file": StringIO("test")}, + ) + assert resp.status == 201 + assert await resp.json() == {"backup_id": "abc123"} + mock_receive.assert_called_once_with(agent_ids=["backup.local"], contents=ANY) From bc2976904e23e9997d87c6505af8f831863c7b42 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:54:08 +0100 Subject: [PATCH 1187/2987] Rename HomeWizard last restart sensor to Uptime (#136829) --- homeassistant/components/homewizard/sensor.py | 4 +- .../components/homewizard/strings.json | 4 +- .../homewizard/snapshots/test_sensor.ambr | 166 +++++++++--------- tests/components/homewizard/test_sensor.py | 18 +- 4 files changed, 96 insertions(+), 96 deletions(-) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index f47fcfc7ca7..582c65f2838 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -623,8 +623,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( value_fn=lambda data: data.measurement.cycles, ), HomeWizardSensorEntityDescription( - key="last_restart", - translation_key="last_restart", + key="uptime", + translation_key="uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 645c4292ae1..806dbf6e083 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -138,8 +138,8 @@ "state_of_charge_pct": { "name": "State of charge" }, - "last_restart": { - "name": "Last restart" + "uptime": { + "name": "Uptime" } }, "switch": { diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 622c6d8a852..692383b4794 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -432,89 +432,6 @@ 'state': '50.0', }) # --- -# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_last_restart:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '5c:2f:af:ab:cd:ef', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '5c2fafabcdef', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'HomeWizard', - 'model': 'Plug-In Battery', - 'model_id': 'HWE-BAT', - 'name': 'Device', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '1.00', - 'via_device_id': None, - }) -# --- -# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_last_restart:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.device_last_restart', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Last restart', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'last_restart', - 'unique_id': 'HWE-P1_5c2fafabcdef_last_restart', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_last_restart:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Device Last restart', - }), - 'context': , - 'entity_id': 'sensor.device_last_restart', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2025-01-28T21:39:04+00:00', - }) -# --- # name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -695,6 +612,89 @@ 'state': '50.0', }) # --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_uptime:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_uptime:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uptime', + 'unique_id': 'HWE-P1_5c2fafabcdef_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_uptime:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Device Uptime', + }), + 'context': , + 'entity_id': 'sensor.device_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-28T21:39:04+00:00', + }) +# --- # name: test_sensors[HWE-BAT-entity_ids10][sensor.device_voltage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index e4498d2d47a..94a59551eb4 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -302,7 +302,7 @@ pytestmark = [ "sensor.device_frequency", "sensor.device_power", "sensor.device_state_of_charge", - "sensor.device_last_restart", + "sensor.device_uptime", "sensor.device_voltage", ], ), @@ -451,7 +451,7 @@ async def test_sensors( [ "sensor.device_current", "sensor.device_frequency", - "sensor.device_last_restart", + "sensor.device_uptime", "sensor.device_voltage", ], ), @@ -549,7 +549,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", - "sensor.device_last_restart", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -599,7 +599,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", - "sensor.device_last_restart", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -656,7 +656,7 @@ async def test_external_sensors_unreachable( "sensor.device_smart_meter_model", "sensor.device_state_of_charge", "sensor.device_tariff", - "sensor.device_last_restart", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -707,7 +707,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", - "sensor.device_last_restart", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -746,7 +746,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", - "sensor.device_last_restart", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -798,7 +798,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", - "sensor.device_last_restart", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -837,7 +837,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", - "sensor.device_last_restart", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", From c974251faa76add41a88ffa64f85e4bcf97b5269 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:55:59 +0100 Subject: [PATCH 1188/2987] Fix command latency in AVM Fritz!SmartHome (#136739) --- homeassistant/components/fritzbox/climate.py | 2 +- homeassistant/components/fritzbox/cover.py | 8 +++---- homeassistant/components/fritzbox/light.py | 15 +++++++------ homeassistant/components/fritzbox/switch.py | 4 ++-- tests/components/fritzbox/test_climate.py | 22 ++++++++++---------- tests/components/fritzbox/test_cover.py | 2 +- tests/components/fritzbox/test_light.py | 12 +++++------ 7 files changed, 34 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index d5a81fdef1a..87a87ac691f 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -141,7 +141,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): await self.async_set_hvac_mode(hvac_mode) elif target_temp is not None: await self.hass.async_add_executor_job( - self.data.set_target_temperature, target_temp + self.data.set_target_temperature, target_temp, True ) else: return diff --git a/homeassistant/components/fritzbox/cover.py b/homeassistant/components/fritzbox/cover.py index de87d6f8852..070bb868298 100644 --- a/homeassistant/components/fritzbox/cover.py +++ b/homeassistant/components/fritzbox/cover.py @@ -71,21 +71,21 @@ class FritzboxCover(FritzBoxDeviceEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self.hass.async_add_executor_job(self.data.set_blind_open) + await self.hass.async_add_executor_job(self.data.set_blind_open, True) await self.coordinator.async_refresh() async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self.hass.async_add_executor_job(self.data.set_blind_close) + await self.hass.async_add_executor_job(self.data.set_blind_close, True) await self.coordinator.async_refresh() async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" await self.hass.async_add_executor_job( - self.data.set_level_percentage, 100 - kwargs[ATTR_POSITION] + self.data.set_level_percentage, 100 - kwargs[ATTR_POSITION], True ) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - await self.hass.async_add_executor_job(self.data.set_blind_stop) + await self.hass.async_add_executor_job(self.data.set_blind_stop, True) await self.coordinator.async_refresh() diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index 36cb7dc8cff..f6a1ba4cc94 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -122,7 +122,7 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): """Turn the light on.""" if kwargs.get(ATTR_BRIGHTNESS) is not None: level = kwargs[ATTR_BRIGHTNESS] - await self.hass.async_add_executor_job(self.data.set_level, level) + await self.hass.async_add_executor_job(self.data.set_level, level, True) if kwargs.get(ATTR_HS_COLOR) is not None: # Try setunmappedcolor first. This allows free color selection, # but we don't know if its supported by all devices. @@ -133,7 +133,10 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): cast(float, kwargs[ATTR_HS_COLOR][1]) * 255.0 / 100.0 ) await self.hass.async_add_executor_job( - self.data.set_unmapped_color, (unmapped_hue, unmapped_saturation) + self.data.set_unmapped_color, + (unmapped_hue, unmapped_saturation), + 0, + True, ) # This will raise 400 BAD REQUEST if the setunmappedcolor is not available except HTTPError as err: @@ -152,18 +155,18 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): key=lambda x: abs(x - unmapped_saturation), ) await self.hass.async_add_executor_job( - self.data.set_color, (hue, saturation) + self.data.set_color, (hue, saturation), 0, True ) if kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None: await self.hass.async_add_executor_job( - self.data.set_color_temp, kwargs[ATTR_COLOR_TEMP_KELVIN] + self.data.set_color_temp, kwargs[ATTR_COLOR_TEMP_KELVIN], 0, True ) - await self.hass.async_add_executor_job(self.data.set_state_on) + await self.hass.async_add_executor_job(self.data.set_state_on, True) await self.coordinator.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" - await self.hass.async_add_executor_job(self.data.set_state_off) + await self.hass.async_add_executor_job(self.data.set_state_off, True) await self.coordinator.async_refresh() diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index 18b676d449e..d83793c77dc 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -51,13 +51,13 @@ class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" self.check_lock_state() - await self.hass.async_add_executor_job(self.data.set_switch_state_on) + await self.hass.async_add_executor_job(self.data.set_switch_state_on, True) await self.coordinator.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" self.check_lock_state() - await self.hass.async_add_executor_job(self.data.set_switch_state_off) + await self.hass.async_add_executor_job(self.data.set_switch_state_off, True) await self.coordinator.async_refresh() def check_lock_state(self) -> None: diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 0fb5f5038c3..87e6d36e3b6 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -273,20 +273,20 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: @pytest.mark.parametrize( ("service_data", "expected_call_args"), [ - ({ATTR_TEMPERATURE: 23}, [call(23)]), + ({ATTR_TEMPERATURE: 23}, [call(23, True)]), ( { ATTR_HVAC_MODE: HVACMode.OFF, ATTR_TEMPERATURE: 23, }, - [call(0)], + [call(0, True)], ), ( { ATTR_HVAC_MODE: HVACMode.HEAT, ATTR_TEMPERATURE: 23, }, - [call(23)], + [call(23, True)], ), ], ) @@ -316,14 +316,14 @@ async def test_set_temperature( ("service_data", "target_temperature", "current_preset", "expected_call_args"), [ # mode off always sets target temperature to 0 - ({ATTR_HVAC_MODE: HVACMode.OFF}, 22, PRESET_COMFORT, [call(0)]), - ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, PRESET_ECO, [call(0)]), - ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, None, [call(0)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 22, PRESET_COMFORT, [call(0, True)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, PRESET_ECO, [call(0, True)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, None, [call(0, True)]), # mode heat sets target temperature based on current scheduled preset, # when not already in mode heat - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_COMFORT, [call(22)]), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_ECO, [call(16)]), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, None, [call(22)]), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_COMFORT, [call(22, True)]), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_ECO, [call(16, True)]), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, None, [call(22, True)]), # mode heat does not set target temperature, when already in mode heat ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_COMFORT, []), ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_ECO, []), @@ -380,7 +380,7 @@ async def test_set_preset_mode_comfort(hass: HomeAssistant, fritz: Mock) -> None {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_COMFORT}, True, ) - assert device.set_target_temperature.call_args_list == [call(22)] + assert device.set_target_temperature.call_args_list == [call(22, True)] async def test_set_preset_mode_eco(hass: HomeAssistant, fritz: Mock) -> None: @@ -396,7 +396,7 @@ async def test_set_preset_mode_eco(hass: HomeAssistant, fritz: Mock) -> None: {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_ECO}, True, ) - assert device.set_target_temperature.call_args_list == [call(16)] + assert device.set_target_temperature.call_args_list == [call(16, True)] async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: diff --git a/tests/components/fritzbox/test_cover.py b/tests/components/fritzbox/test_cover.py index 82723b083ae..535306e4ef2 100644 --- a/tests/components/fritzbox/test_cover.py +++ b/tests/components/fritzbox/test_cover.py @@ -99,7 +99,7 @@ async def test_set_position_cover(hass: HomeAssistant, fritz: Mock) -> None: {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 50}, True, ) - assert device.set_level_percentage.call_args_list == [call(50)] + assert device.set_level_percentage.call_args_list == [call(50, True)] async def test_stop_cover(hass: HomeAssistant, fritz: Mock) -> None: diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 071642fb358..47209075a86 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -155,8 +155,8 @@ async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_color_temp.call_count == 1 - assert device.set_color_temp.call_args_list == [call(3000)] - assert device.set_level.call_args_list == [call(100)] + assert device.set_color_temp.call_args_list == [call(3000, 0, True)] + assert device.set_level.call_args_list == [call(100, True)] async def test_turn_on_color(hass: HomeAssistant, fritz: Mock) -> None: @@ -178,9 +178,9 @@ async def test_turn_on_color(hass: HomeAssistant, fritz: Mock) -> None: assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_unmapped_color.call_count == 1 - assert device.set_level.call_args_list == [call(100)] + assert device.set_level.call_args_list == [call(100, True)] assert device.set_unmapped_color.call_args_list == [ - call((100, round(70 * 255.0 / 100.0))) + call((100, round(70 * 255.0 / 100.0)), 0, True) ] @@ -212,8 +212,8 @@ async def test_turn_on_color_unsupported_api_method( assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_color.call_count == 1 - assert device.set_level.call_args_list == [call(100)] - assert device.set_color.call_args_list == [call((100, 70))] + assert device.set_level.call_args_list == [call(100, True)] + assert device.set_color.call_args_list == [call((100, 70), 0, True)] # test for unknown error error.response.status_code = 500 From 40f92b7b6b3be4fa117d9a8f28d320243c1eaf1e Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Wed, 29 Jan 2025 13:02:20 +0100 Subject: [PATCH 1189/2987] Bump qbusmqttapi to 1.2.4 (#136835) --- homeassistant/components/qbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/qbus/manifest.json b/homeassistant/components/qbus/manifest.json index ac76110363f..b7d277f3953 100644 --- a/homeassistant/components/qbus/manifest.json +++ b/homeassistant/components/qbus/manifest.json @@ -13,5 +13,5 @@ "cloudapp/QBUSMQTTGW/+/state" ], "quality_scale": "bronze", - "requirements": ["qbusmqttapi==1.2.3"] + "requirements": ["qbusmqttapi==1.2.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index b483ba42e6a..05d040af2b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2564,7 +2564,7 @@ pyzerproc==0.4.8 qbittorrent-api==2024.2.59 # homeassistant.components.qbus -qbusmqttapi==1.2.3 +qbusmqttapi==1.2.4 # homeassistant.components.qingping qingping-ble==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e92e8cf6ca3..236b908f6d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2076,7 +2076,7 @@ pyzerproc==0.4.8 qbittorrent-api==2024.2.59 # homeassistant.components.qbus -qbusmqttapi==1.2.3 +qbusmqttapi==1.2.4 # homeassistant.components.qingping qingping-ble==0.10.0 From b6cc5090e4e83677282feaa9b7cc16e0f3d5117a Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 29 Jan 2025 13:39:05 +0100 Subject: [PATCH 1190/2987] Update photovoltaic related labels in ViCare (#136430) --- homeassistant/components/vicare/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index a8636f651f3..26ca0f5a264 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -377,25 +377,25 @@ "name": "Energy export to grid" }, "photovoltaic_power_production_current": { - "name": "Solar power" + "name": "PV power" }, "photovoltaic_energy_production_today": { - "name": "Solar energy production today" + "name": "PV energy production today" }, "photovoltaic_energy_production_this_week": { - "name": "Solar energy production this week" + "name": "PV energy production this week" }, "photovoltaic_energy_production_this_month": { - "name": "Solar energy production this month" + "name": "PV energy production this month" }, "photovoltaic_energy_production_this_year": { - "name": "Solar energy production this year" + "name": "PV energy production this year" }, "photovoltaic_energy_production_total": { - "name": "Solar energy production total" + "name": "PV energy production total" }, "photovoltaic_status": { - "name": "Solar state", + "name": "PV state", "state": { "ready": "Standby", "production": "Producing" From 20ab6e2279d72dcde7ef61222dfb4866fbb1a1a9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 13:39:31 +0100 Subject: [PATCH 1191/2987] Standardize remaining homeassistant imports (#136836) --- homeassistant/components/config/config_entries.py | 2 +- tests/components/filter/conftest.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index da50f7e93a1..4a070a87734 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -17,7 +17,7 @@ from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_a from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import DependencyError, Unauthorized -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, FlowManagerResourceView, diff --git a/tests/components/filter/conftest.py b/tests/components/filter/conftest.py index e703430446c..a576a2edb37 100644 --- a/tests/components/filter/conftest.py +++ b/tests/components/filter/conftest.py @@ -24,7 +24,7 @@ from homeassistant.components.filter.const import ( from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_ENTITY_ID, CONF_NAME from homeassistant.core import HomeAssistant, State -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry From 3e513dda626f0af9d7c6b2f05e525de31afec47f Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 29 Jan 2025 13:40:05 +0100 Subject: [PATCH 1192/2987] IQS completion of documentation for Plugwise (#134051) --- .../components/plugwise/manifest.json | 1 + .../components/plugwise/quality_scale.yaml | 36 +++++-------------- script/hassfest/quality_scale.py | 1 - 3 files changed, 10 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index ae60d4d7452..f7bd646f801 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["plugwise"], + "quality_scale": "platinum", "requirements": ["plugwise==1.6.4"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/quality_scale.yaml b/homeassistant/components/plugwise/quality_scale.yaml index a7b955b4713..55abf3c330e 100644 --- a/homeassistant/components/plugwise/quality_scale.yaml +++ b/homeassistant/components/plugwise/quality_scale.yaml @@ -15,12 +15,8 @@ rules: status: exempt comment: Plugwise integration has no custom actions common-modules: done - docs-high-level-description: - status: todo - comment: Rewrite top section, docs PR prepared waiting for 36087 merge - docs-installation-instructions: - status: todo - comment: Docs PR 36087 + docs-high-level-description: done + docs-installation-instructions: done docs-removal-instructions: done docs-actions: done brands: done @@ -35,9 +31,7 @@ rules: parallel-updates: done test-coverage: done integration-owner: done - docs-installation-parameters: - status: todo - comment: Docs PR 36087 (partial) + todo rewrite generically (PR prepared) + docs-installation-parameters: done docs-configuration-parameters: status: exempt comment: Plugwise has no options flow @@ -58,25 +52,13 @@ rules: repair-issues: status: exempt comment: This integration does not have repairs - docs-use-cases: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge - docs-supported-devices: - status: todo - comment: The list is there but could be improved for readability, PR prepared waiting for 36087 merge - docs-supported-functions: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done docs-data-update: done - docs-known-limitations: - status: todo - comment: Partial in 36087 but could be more elaborate - docs-troubleshooting: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge - docs-examples: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done ## Platinum async-dependency: done inject-websession: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 72c1cfae219..a1ad52e6aa8 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1872,7 +1872,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "pioneer", "pjlink", "plaato", - "plugwise", "plant", "plex", "plum_lightpad", From 9a687e7f945870341b0ccc2c593713e23a28b728 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 14:04:17 +0100 Subject: [PATCH 1193/2987] Add support for per-backup agent encryption flag (#136622) * Add support for per-backup agent encryption flag * Adjust * Don't attempt decrypting an unprotected backup * Address review comments * Add some tests * Add fixture * Rename fixture * Correct condition for when we should encrypt or decrypt * Update tests in integrations * Improve test coverage * Fix onedrive tests * Add test * Improve cipher worker shutdown * Improve test coverage * Fix google_drive tests * Move inner class _CipherBackupStreamer._WorkerStatus to module scope --- homeassistant/components/backup/config.py | 44 + homeassistant/components/backup/http.py | 6 +- homeassistant/components/backup/manager.py | 103 ++- homeassistant/components/backup/models.py | 20 +- homeassistant/components/backup/store.py | 4 +- homeassistant/components/backup/util.py | 205 +++- homeassistant/components/backup/websocket.py | 3 +- tests/components/backup/conftest.py | 1 + .../test_backups/c0cb53bd.tar.decrypted | Bin 0 -> 10240 bytes .../backup/snapshots/test_backup.ambr | 11 +- .../backup/snapshots/test_store.ambr | 14 + .../backup/snapshots/test_websocket.ambr | 873 +++++++++++++++--- tests/components/backup/test_manager.py | 265 +++++- tests/components/backup/test_store.py | 1 + tests/components/backup/test_util.py | 258 +++++- tests/components/backup/test_websocket.py | 270 ++++-- tests/components/cloud/test_backup.py | 8 +- tests/components/google_drive/test_backup.py | 4 +- tests/components/hassio/test_backup.py | 8 +- tests/components/kitchen_sink/test_backup.py | 8 +- tests/components/onedrive/test_backup.py | 15 +- tests/components/synology_dsm/test_backup.py | 18 +- 22 files changed, 1791 insertions(+), 348 deletions(-) create mode 100644 tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 1d1b8046360..0baefe1f52d 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -40,6 +40,7 @@ BACKUP_START_TIME_JITTER = 60 * 60 class StoredBackupConfig(TypedDict): """Represent the stored backup config.""" + agents: dict[str, StoredAgentConfig] create_backup: StoredCreateBackupConfig last_attempted_automatic_backup: str | None last_completed_automatic_backup: str | None @@ -51,6 +52,7 @@ class StoredBackupConfig(TypedDict): class BackupConfigData: """Represent loaded backup config data.""" + agents: dict[str, AgentConfig] create_backup: CreateBackupConfig last_attempted_automatic_backup: datetime | None = None last_completed_automatic_backup: datetime | None = None @@ -84,6 +86,10 @@ class BackupConfigData: days = [Day(day) for day in data["schedule"]["days"]] return cls( + agents={ + agent_id: AgentConfig(protected=agent_data["protected"]) + for agent_id, agent_data in data["agents"].items() + }, create_backup=CreateBackupConfig( agent_ids=data["create_backup"]["agent_ids"], include_addons=data["create_backup"]["include_addons"], @@ -120,6 +126,9 @@ class BackupConfigData: last_completed = None return StoredBackupConfig( + agents={ + agent_id: agent.to_dict() for agent_id, agent in self.agents.items() + }, create_backup=self.create_backup.to_dict(), last_attempted_automatic_backup=last_attempted, last_completed_automatic_backup=last_completed, @@ -134,6 +143,7 @@ class BackupConfig: def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None: """Initialize backup config.""" self.data = BackupConfigData( + agents={}, create_backup=CreateBackupConfig(), retention=RetentionConfig(), schedule=BackupSchedule(), @@ -149,11 +159,20 @@ class BackupConfig: async def update( self, *, + agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED, create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED, retention: RetentionParametersDict | UndefinedType = UNDEFINED, schedule: ScheduleParametersDict | UndefinedType = UNDEFINED, ) -> None: """Update config.""" + if agents is not UNDEFINED: + for agent_id, agent_config in agents.items(): + if agent_id not in self.data.agents: + self.data.agents[agent_id] = AgentConfig(**agent_config) + else: + self.data.agents[agent_id] = replace( + self.data.agents[agent_id], **agent_config + ) if create_backup is not UNDEFINED: self.data.create_backup = replace(self.data.create_backup, **create_backup) if retention is not UNDEFINED: @@ -170,6 +189,31 @@ class BackupConfig: self._manager.store.save() +@dataclass(kw_only=True) +class AgentConfig: + """Represent the config for an agent.""" + + protected: bool + + def to_dict(self) -> StoredAgentConfig: + """Convert agent config to a dict.""" + return { + "protected": self.protected, + } + + +class StoredAgentConfig(TypedDict): + """Represent the stored config for an agent.""" + + protected: bool + + +class AgentParametersDict(TypedDict, total=False): + """Represent the parameters for an agent.""" + + protected: bool + + @dataclass(kw_only=True) class RetentionConfig: """Represent the backup retention configuration.""" diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index 3d3877cc2f7..6b06db4601d 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -69,7 +69,7 @@ class DownloadBackupView(HomeAssistantView): CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" } - if not password: + if not password or not backup.protected: return await self._send_backup_no_password( request, headers, backup_id, agent_id, agent, manager ) @@ -123,13 +123,13 @@ class DownloadBackupView(HomeAssistantView): worker_done_event = asyncio.Event() - def on_done() -> None: + def on_done(error: Exception | None) -> None: """Call by the worker thread when it's done.""" hass.loop.call_soon_threadsafe(worker_done_event.set) stream = util.AsyncIteratorWriter(hass) worker = threading.Thread( - target=util.decrypt_backup, args=[reader, stream, password, on_done] + target=util.decrypt_backup, args=[reader, stream, password, on_done, 0, []] ) try: worker.start() diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 19ebb8011ee..1f439160381 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -5,7 +5,7 @@ from __future__ import annotations import abc import asyncio from collections.abc import AsyncIterator, Callable, Coroutine -from dataclasses import dataclass +from dataclasses import dataclass, replace from enum import StrEnum import hashlib import io @@ -46,10 +46,12 @@ from .const import ( EXCLUDE_FROM_BACKUP, LOGGER, ) -from .models import AgentBackup, BackupError, BackupManagerError, Folder +from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder from .store import BackupStore from .util import ( AsyncIteratorReader, + DecryptedBackupStreamer, + EncryptedBackupStreamer, make_backup_dir, read_backup, validate_password, @@ -65,10 +67,18 @@ class NewBackup: @dataclass(frozen=True, kw_only=True, slots=True) -class ManagerBackup(AgentBackup): +class AgentBackupStatus: + """Agent specific backup attributes.""" + + protected: bool + size: int + + +@dataclass(frozen=True, kw_only=True, slots=True) +class ManagerBackup(BaseBackup): """Backup class.""" - agent_ids: list[str] + agents: dict[str, AgentBackupStatus] failed_agent_ids: list[str] with_automatic_settings: bool | None @@ -437,20 +447,61 @@ class BackupManager: backup: AgentBackup, agent_ids: list[str], open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + password: str | None, ) -> dict[str, Exception]: """Upload a backup to selected agents.""" agent_errors: dict[str, Exception] = {} LOGGER.debug("Uploading backup %s to agents %s", backup.backup_id, agent_ids) - sync_backup_results = await asyncio.gather( - *( - self.backup_agents[agent_id].async_upload_backup( - open_stream=open_stream, - backup=backup, + async def upload_backup_to_agent(agent_id: str) -> None: + """Upload backup to a single agent, and encrypt or decrypt as needed.""" + config = self.config.data.agents.get(agent_id) + should_encrypt = config.protected if config else password is not None + streamer: DecryptedBackupStreamer | EncryptedBackupStreamer | None = None + if should_encrypt == backup.protected or password is None: + # The backup we're uploading is already in the correct state, or we + # don't have a password to encrypt or decrypt it + LOGGER.debug( + "Uploading backup %s to agent %s as is", backup.backup_id, agent_id ) - for agent_id in agent_ids - ), + open_stream_func = open_stream + _backup = backup + elif should_encrypt: + # The backup we're uploading is not encrypted, but the agent requires it + LOGGER.debug( + "Uploading encrypted backup %s to agent %s", + backup.backup_id, + agent_id, + ) + streamer = EncryptedBackupStreamer( + self.hass, backup, open_stream, password + ) + else: + # The backup we're uploading is encrypted, but the agent requires it + # decrypted + LOGGER.debug( + "Uploading decrypted backup %s to agent %s", + backup.backup_id, + agent_id, + ) + streamer = DecryptedBackupStreamer( + self.hass, backup, open_stream, password + ) + if streamer: + open_stream_func = streamer.open_stream + _backup = replace( + backup, protected=should_encrypt, size=streamer.size() + ) + await self.backup_agents[agent_id].async_upload_backup( + open_stream=open_stream_func, + backup=_backup, + ) + if streamer: + await streamer.wait() + + sync_backup_results = await asyncio.gather( + *(upload_backup_to_agent(agent_id) for agent_id in agent_ids), return_exceptions=True, ) for idx, result in enumerate(sync_backup_results): @@ -506,7 +557,7 @@ class BackupManager: agent_backup, await instance_id.async_get(self.hass) ) backups[backup_id] = ManagerBackup( - agent_ids=[], + agents={}, addons=agent_backup.addons, backup_id=backup_id, date=agent_backup.date, @@ -517,11 +568,12 @@ class BackupManager: homeassistant_included=agent_backup.homeassistant_included, homeassistant_version=agent_backup.homeassistant_version, name=agent_backup.name, - protected=agent_backup.protected, - size=agent_backup.size, with_automatic_settings=with_automatic_settings, ) - backups[backup_id].agent_ids.append(agent_ids[idx]) + backups[backup_id].agents[agent_ids[idx]] = AgentBackupStatus( + protected=agent_backup.protected, + size=agent_backup.size, + ) return (backups, agent_errors) @@ -557,7 +609,7 @@ class BackupManager: result, await instance_id.async_get(self.hass) ) backup = ManagerBackup( - agent_ids=[], + agents={}, addons=result.addons, backup_id=result.backup_id, date=result.date, @@ -568,11 +620,12 @@ class BackupManager: homeassistant_included=result.homeassistant_included, homeassistant_version=result.homeassistant_version, name=result.name, - protected=result.protected, - size=result.size, with_automatic_settings=with_automatic_settings, ) - backup.agent_ids.append(agent_ids[idx]) + backup.agents[agent_ids[idx]] = AgentBackupStatus( + protected=result.protected, + size=result.size, + ) return (backup, agent_errors) @@ -688,6 +741,9 @@ class BackupManager: backup=written_backup.backup, agent_ids=agent_ids, open_stream=written_backup.open_stream, + # When receiving a backup, we don't decrypt or encrypt it according to the + # agent settings, we just upload it as is. + password=None, ) await written_backup.release_stream() self.known_backups.add(written_backup.backup, agent_errors) @@ -855,7 +911,7 @@ class BackupManager: raise BackupManagerError(str(err)) from err backup_finish_task = self._backup_finish_task = self.hass.async_create_task( - self._async_finish_backup(agent_ids, with_automatic_settings), + self._async_finish_backup(agent_ids, with_automatic_settings, password), name="backup_manager_finish_backup", ) if not raise_task_error: @@ -872,7 +928,7 @@ class BackupManager: return new_backup async def _async_finish_backup( - self, agent_ids: list[str], with_automatic_settings: bool + self, agent_ids: list[str], with_automatic_settings: bool, password: str | None ) -> None: """Finish a backup.""" if TYPE_CHECKING: @@ -906,6 +962,7 @@ class BackupManager: backup=written_backup.backup, agent_ids=agent_ids, open_stream=written_backup.open_stream, + password=password, ) finally: await written_backup.release_stream() @@ -1269,6 +1326,10 @@ class CoreBackupReaderWriter(BackupReaderWriter): """Generate a backup.""" manager = self._hass.data[DATA_MANAGER] + agent_config = manager.config.data.agents.get(self._local_agent_id) + if agent_config and not agent_config.protected: + password = None + local_agent_tar_file_path = None if self._local_agent_id in agent_ids: local_agent = manager.local_backup_agents[self._local_agent_id] diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py index f2a83f50c17..1543d577964 100644 --- a/homeassistant/components/backup/models.py +++ b/homeassistant/components/backup/models.py @@ -28,7 +28,7 @@ class Folder(StrEnum): @dataclass(frozen=True, kw_only=True) -class AgentBackup: +class BaseBackup: """Base backup class.""" addons: list[AddonInfo] @@ -40,12 +40,6 @@ class AgentBackup: homeassistant_included: bool homeassistant_version: str | None # None if homeassistant_included is False name: str - protected: bool - size: int - - def as_dict(self) -> dict: - """Return a dict representation of this backup.""" - return asdict(self) def as_frontend_json(self) -> dict: """Return a dict representation of this backup for sending to frontend.""" @@ -53,6 +47,18 @@ class AgentBackup: key: val for key, val in asdict(self).items() if key != "extra_metadata" } + +@dataclass(frozen=True, kw_only=True) +class AgentBackup(BaseBackup): + """Agent backup class.""" + + protected: bool + size: int + + def as_dict(self) -> dict: + """Return a dict representation of this backup.""" + return asdict(self) + @classmethod def from_dict(cls, data: dict[str, Any]) -> Self: """Create an instance from a JSON serialization.""" diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 0e1c49426c5..3e2a88b8168 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -48,7 +48,9 @@ class _BackupStore(Store[StoredBackupData]): data = old_data if old_major_version == 1: if old_minor_version < 2: - # Version 1.2 adds configurable backup time and custom days + # Version 1.2 adds per agent settings, configurable backup time + # and custom days + data["config"]["agents"] = {} data["config"]["schedule"]["time"] = None if (state := data["config"]["schedule"]["state"]) in ("daily", "never"): data["config"]["schedule"]["days"] = [] diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index e5acf974012..bea3fe1f4ef 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -3,14 +3,17 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncIterator, Callable +from collections.abc import AsyncIterator, Callable, Coroutine import copy +from dataclasses import dataclass, replace from io import BytesIO import json +import os from pathlib import Path, PurePath from queue import SimpleQueue import tarfile -from typing import IO, Self, cast +import threading +from typing import IO, Any, Self, cast import aiohttp from securetar import SecureTarError, SecureTarFile, SecureTarReadError @@ -30,6 +33,12 @@ class DecryptError(HomeAssistantError): _message = "Unexpected error during decryption." +class EncryptError(HomeAssistantError): + """Error during encryption.""" + + _message = "Unexpected error during encryption." + + class UnsupportedSecureTarVersion(DecryptError): """Unsupported securetar version.""" @@ -179,6 +188,7 @@ class AsyncIteratorWriter: def __init__(self, hass: HomeAssistant) -> None: """Initialize the wrapper.""" self._hass = hass + self._pos: int = 0 self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1) def __aiter__(self) -> Self: @@ -191,9 +201,14 @@ class AsyncIteratorWriter: return data raise StopAsyncIteration + def tell(self) -> int: + """Return the current position in the iterator.""" + return self._pos + def write(self, s: bytes, /) -> int: """Write data to the iterator.""" asyncio.run_coroutine_threadsafe(self._queue.put(s), self._hass.loop).result() + self._pos += len(s) return len(s) @@ -230,9 +245,12 @@ def decrypt_backup( input_stream: IO[bytes], output_stream: IO[bytes], password: str | None, - on_done: Callable[[], None], + on_done: Callable[[Exception | None], None], + minimum_size: int, + nonces: list[bytes], ) -> None: """Decrypt a backup.""" + error: Exception | None = None try: with ( tarfile.open( @@ -245,9 +263,14 @@ def decrypt_backup( _decrypt_backup(input_tar, output_tar, password) except (DecryptError, SecureTarError, tarfile.TarError) as err: LOGGER.warning("Error decrypting backup: %s", err) + error = err + else: + # Pad the output stream to the requested minimum size + padding = max(minimum_size - output_stream.tell(), 0) + output_stream.write(b"\0" * padding) finally: output_stream.write(b"") # Write an empty chunk to signal the end of the stream - on_done() + on_done(error) def _decrypt_backup( @@ -288,6 +311,180 @@ def _decrypt_backup( output_tar.addfile(decrypted_obj, decrypted) +def encrypt_backup( + input_stream: IO[bytes], + output_stream: IO[bytes], + password: str | None, + on_done: Callable[[Exception | None], None], + minimum_size: int, + nonces: list[bytes], +) -> None: + """Encrypt a backup.""" + error: Exception | None = None + try: + with ( + tarfile.open( + fileobj=input_stream, mode="r|", bufsize=BUF_SIZE + ) as input_tar, + tarfile.open( + fileobj=output_stream, mode="w|", bufsize=BUF_SIZE + ) as output_tar, + ): + _encrypt_backup(input_tar, output_tar, password, nonces) + except (EncryptError, SecureTarError, tarfile.TarError) as err: + LOGGER.warning("Error encrypting backup: %s", err) + error = err + else: + # Pad the output stream to the requested minimum size + padding = max(minimum_size - output_stream.tell(), 0) + output_stream.write(b"\0" * padding) + finally: + output_stream.write(b"") # Write an empty chunk to signal the end of the stream + on_done(error) + + +def _encrypt_backup( + input_tar: tarfile.TarFile, + output_tar: tarfile.TarFile, + password: str | None, + nonces: list[bytes], +) -> None: + """Encrypt a backup.""" + inner_tar_idx = 0 + for obj in input_tar: + # We compare with PurePath to avoid issues with different path separators, + # for example when backup.json is added as "./backup.json" + if PurePath(obj.name) == PurePath("backup.json"): + # Rewrite the backup.json file to indicate that the backup is encrypted + if not (reader := input_tar.extractfile(obj)): + raise EncryptError + metadata = json_loads_object(reader.read()) + metadata["protected"] = True + updated_metadata_b = json.dumps(metadata).encode() + metadata_obj = copy.deepcopy(obj) + metadata_obj.size = len(updated_metadata_b) + output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b)) + continue + if not obj.name.endswith((".tar", ".tgz", ".tar.gz")): + output_tar.addfile(obj, input_tar.extractfile(obj)) + continue + istf = SecureTarFile( + None, # Not used + gzip=False, + key=password_to_key(password) if password is not None else None, + mode="r", + fileobj=input_tar.extractfile(obj), + nonce=nonces[inner_tar_idx], + ) + inner_tar_idx += 1 + with istf.encrypt(obj) as encrypted: + encrypted_obj = copy.deepcopy(obj) + encrypted_obj.size = encrypted.encrypted_size + output_tar.addfile(encrypted_obj, encrypted) + + +@dataclass(kw_only=True) +class _CipherWorkerStatus: + done: asyncio.Event + error: Exception | None = None + thread: threading.Thread + + +class _CipherBackupStreamer: + """Encrypt or decrypt a backup.""" + + _cipher_func: Callable[ + [ + IO[bytes], + IO[bytes], + str | None, + Callable[[Exception | None], None], + int, + list[bytes], + ], + None, + ] + + def __init__( + self, + hass: HomeAssistant, + backup: AgentBackup, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + password: str | None, + ) -> None: + """Initialize.""" + self._workers: list[_CipherWorkerStatus] = [] + self._backup = backup + self._hass = hass + self._open_stream = open_stream + self._password = password + self._nonces: list[bytes] = [] + + def size(self) -> int: + """Return the maximum size of the decrypted or encrypted backup.""" + return self._backup.size + self._num_tar_files() * tarfile.RECORDSIZE + + def _num_tar_files(self) -> int: + """Return the number of inner tar files.""" + b = self._backup + return len(b.addons) + len(b.folders) + b.homeassistant_included + 1 + + async def open_stream(self) -> AsyncIterator[bytes]: + """Open a stream.""" + + def on_done(error: Exception | None) -> None: + """Call by the worker thread when it's done.""" + worker_status.error = error + self._hass.loop.call_soon_threadsafe(worker_status.done.set) + + stream = await self._open_stream() + reader = AsyncIteratorReader(self._hass, stream) + writer = AsyncIteratorWriter(self._hass) + worker = threading.Thread( + target=self._cipher_func, + args=[reader, writer, self._password, on_done, self.size(), self._nonces], + ) + worker_status = _CipherWorkerStatus(done=asyncio.Event(), thread=worker) + self._workers.append(worker_status) + worker.start() + return writer + + async def wait(self) -> None: + """Wait for the worker threads to finish.""" + await asyncio.gather(*(worker.done.wait() for worker in self._workers)) + + +class DecryptedBackupStreamer(_CipherBackupStreamer): + """Decrypt a backup.""" + + _cipher_func = staticmethod(decrypt_backup) + + def backup(self) -> AgentBackup: + """Return the decrypted backup.""" + return replace(self._backup, protected=False, size=self.size()) + + +class EncryptedBackupStreamer(_CipherBackupStreamer): + """Encrypt a backup.""" + + def __init__( + self, + hass: HomeAssistant, + backup: AgentBackup, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + password: str | None, + ) -> None: + """Initialize.""" + super().__init__(hass, backup, open_stream, password) + self._nonces = [os.urandom(16) for _ in range(self._num_tar_files())] + + _cipher_func = staticmethod(encrypt_backup) + + def backup(self) -> AgentBackup: + """Return the encrypted backup.""" + return replace(self._backup, protected=True, size=self.size()) + + async def receive_file( hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path ) -> None: diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 74f56102670..d8a425ab6ba 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -198,7 +198,7 @@ async def handle_can_decrypt_on_download( vol.Optional("include_folders"): [vol.Coerce(Folder)], vol.Optional("include_homeassistant", default=True): bool, vol.Optional("name"): str, - vol.Optional("password"): str, + vol.Optional("password"): vol.Any(str, None), } ) @websocket_api.async_response @@ -344,6 +344,7 @@ async def handle_config_info( @websocket_api.websocket_command( { vol.Required("type"): "backup/config/update", + vol.Optional("agents"): vol.Schema({str: {"protected": bool}}), vol.Optional("create_backup"): vol.Schema( { vol.Optional("agent_ids"): vol.All([str], vol.Unique()), diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index 7831efeff9a..bef48498ede 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -72,6 +72,7 @@ def mock_create_backup() -> Generator[AsyncMock]: """Mock manager create backup.""" mock_written_backup = MagicMock(spec_set=WrittenBackup) mock_written_backup.backup.backup_id = "abc123" + mock_written_backup.backup.protected = False mock_written_backup.open_stream = AsyncMock() mock_written_backup.release_stream = AsyncMock() fut: Future[MagicMock] = Future() diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted new file mode 100644 index 0000000000000000000000000000000000000000..c97533fc1afb35fafb7be57651fc72e4069f44b3 GIT binary patch literal 10240 zcmdPXPfASAE-lc@D$dVipbanp0y7g61`rJd=(K^Uu^~(hB5!D5WMasmU_cw^pqg4* zT#{G>v>sJ-#PF(>lJj#5ic*VRNu4klY zpqG+bW}pNzC@(P=>?vnpl;`IvK+?Sesyd*uf};GA)Z`LyaDW{F6f4dtO$Qm8Y>=E} zYMhh;a(YQ+0ob^L#G;bS#2k+&OtSGgy(-F3x(X0%-mF4Lvv$u149cV3u8SC14{#Ab0aR)fEwiu z#}G))FG|$|)_{8HRW$P+C{yFB|IJK{7z|C!O^ggpOwCMzWq~0Gj@JJ)ix4D(<-0jJ zR-f)jXZp|ZcElJxxFZT|7>;M;dW=HA|yIR4BHo>;un?)+hk0>-8bvYCy!p}Q~n z`|s-X$OwGyTE!bS>j=kEp2=1pd6b%BY;^MK`X*cndo6GiYHg=zif>8FPOb#7{4Iw5|p4L4d_^LiZEiSAmx*7&MTgiYCm_`hR4%|FPEp#uk=l<`$#%KTUGn;4&0c z{~OV`0YFauZ)QAN|I;I-jMo37_5Z;4|BVr20ibaL;P{^*u>C*U|EGNkU}TP^|8HVw zWNbWI{nI}52i^uy{ck)N>wlBc`kx-DW3>Js+4Vm?(%7gSqaiRF0;3@?8UmvsFd71* QAut*OqaiRF0)rz203J%KF#rGn literal 0 HcmV?d00001 diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index 1a6774e7a95..441f79276a5 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -62,9 +62,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -77,8 +80,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index 45af91645ad..7069860638a 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -11,6 +11,8 @@ }), ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -53,6 +55,8 @@ }), ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -96,6 +100,11 @@ }), ]), 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -138,6 +147,11 @@ }), ]), 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 634404b09cd..f5a22201138 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -234,6 +234,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -269,6 +271,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -316,6 +320,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -352,6 +358,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -388,6 +396,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -425,6 +435,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -461,6 +473,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -494,11 +508,59 @@ 'type': 'result', }) # --- -# name: test_config_update[command0] +# name: test_config_info[storage_data7] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands0] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -529,11 +591,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command0].1 +# name: test_config_update[commands0].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -565,12 +629,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command0].2 +# name: test_config_update[commands0].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -602,11 +668,13 @@ 'version': 1, }) # --- -# name: test_config_update[command10] +# name: test_config_update[commands10] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -637,11 +705,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command10].1 +# name: test_config_update[commands10].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -673,12 +743,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command10].2 +# name: test_config_update[commands10].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -710,11 +782,13 @@ 'version': 1, }) # --- -# name: test_config_update[command11] +# name: test_config_update[commands11] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -745,11 +819,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command11].1 +# name: test_config_update[commands11].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -781,12 +857,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command11].2 +# name: test_config_update[commands11].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -818,11 +896,13 @@ 'version': 1, }) # --- -# name: test_config_update[command1] +# name: test_config_update[commands12] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -853,11 +933,304 @@ 'type': 'result', }) # --- -# name: test_config_update[command1].1 +# name: test_config_update[commands12].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands12].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_config_update[commands13] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands13].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands13].2 + dict({ + 'id': 5, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': False, + }), + 'test-agent2': dict({ + 'protected': True, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands13].3 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': False, + }), + 'test-agent2': dict({ + 'protected': True, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_config_update[commands1] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands1].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -889,12 +1262,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command1].2 +# name: test_config_update[commands1].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -926,11 +1301,13 @@ 'version': 1, }) # --- -# name: test_config_update[command2] +# name: test_config_update[commands2] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -961,11 +1338,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command2].1 +# name: test_config_update[commands2].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -998,12 +1377,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command2].2 +# name: test_config_update[commands2].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1036,11 +1417,13 @@ 'version': 1, }) # --- -# name: test_config_update[command3] +# name: test_config_update[commands3] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1071,11 +1454,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command3].1 +# name: test_config_update[commands3].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1107,12 +1492,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command3].2 +# name: test_config_update[commands3].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1144,11 +1531,13 @@ 'version': 1, }) # --- -# name: test_config_update[command4] +# name: test_config_update[commands4] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1179,11 +1568,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command4].1 +# name: test_config_update[commands4].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1217,12 +1608,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command4].2 +# name: test_config_update[commands4].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1256,11 +1649,13 @@ 'version': 1, }) # --- -# name: test_config_update[command5] +# name: test_config_update[commands5] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1291,11 +1686,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command5].1 +# name: test_config_update[commands5].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1331,12 +1728,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command5].2 +# name: test_config_update[commands5].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1372,11 +1771,13 @@ 'version': 1, }) # --- -# name: test_config_update[command6] +# name: test_config_update[commands6] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1407,11 +1808,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command6].1 +# name: test_config_update[commands6].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1443,12 +1846,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command6].2 +# name: test_config_update[commands6].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1480,11 +1885,13 @@ 'version': 1, }) # --- -# name: test_config_update[command7] +# name: test_config_update[commands7] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1515,11 +1922,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command7].1 +# name: test_config_update[commands7].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1551,12 +1960,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command7].2 +# name: test_config_update[commands7].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1588,11 +1999,13 @@ 'version': 1, }) # --- -# name: test_config_update[command8] +# name: test_config_update[commands8] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1623,11 +2036,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command8].1 +# name: test_config_update[commands8].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1659,12 +2074,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command8].2 +# name: test_config_update[commands8].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1696,11 +2113,13 @@ 'version': 1, }) # --- -# name: test_config_update[command9] +# name: test_config_update[commands9] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1731,11 +2150,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command9].1 +# name: test_config_update[commands9].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1767,12 +2188,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command9].2 +# name: test_config_update[commands9].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1809,6 +2232,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1844,6 +2269,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1879,6 +2306,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1914,6 +2343,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1949,6 +2380,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1984,6 +2417,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2019,6 +2454,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2054,6 +2491,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2089,6 +2528,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2124,6 +2565,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2159,6 +2602,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2194,6 +2639,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2229,6 +2676,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2264,6 +2713,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2299,6 +2750,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2334,6 +2787,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2369,6 +2824,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2404,6 +2861,82 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command9] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command9].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2494,9 +3027,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -2509,8 +3045,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), @@ -2566,9 +3100,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -2581,8 +3118,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), @@ -2621,9 +3156,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -2636,8 +3174,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), @@ -2660,9 +3196,12 @@ dict({ 'addons': list([ ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 1, + }), + }), 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', @@ -2675,8 +3214,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test 2', - 'protected': False, - 'size': 1, 'with_automatic_settings': None, }), ]), @@ -2710,9 +3247,12 @@ dict({ 'addons': list([ ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 1, + }), + }), 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', @@ -2725,8 +3265,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test 2', - 'protected': False, - 'size': 1, 'with_automatic_settings': None, }), ]), @@ -2754,10 +3292,16 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -2770,8 +3314,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), @@ -2810,9 +3352,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -2825,8 +3370,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), @@ -2866,9 +3409,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'domain.test': dict({ + 'protected': False, + 'size': 13, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', @@ -2881,8 +3427,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, 'with_automatic_settings': None, }), ]), @@ -2922,9 +3466,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'domain.test': dict({ + 'protected': False, + 'size': 13, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', @@ -2938,8 +3485,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, 'with_automatic_settings': None, }), ]), @@ -2978,9 +3523,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'domain.test': dict({ + 'protected': False, + 'size': 13, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', @@ -2993,8 +3541,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, 'with_automatic_settings': None, }), ]), @@ -3033,9 +3579,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'domain.test': dict({ + 'protected': False, + 'size': 13, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', @@ -3048,8 +3597,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, 'with_automatic_settings': None, }), ]), @@ -3088,9 +3635,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'domain.test': dict({ + 'protected': False, + 'size': 13, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', @@ -3103,8 +3653,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, 'with_automatic_settings': None, }), ]), @@ -3143,9 +3691,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'domain.test': dict({ + 'protected': False, + 'size': 13, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', @@ -3159,8 +3710,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, 'with_automatic_settings': None, }), ]), @@ -3199,9 +3748,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3214,8 +3766,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), }), @@ -3237,9 +3787,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3252,8 +3805,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), }), @@ -3287,10 +3838,16 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3303,8 +3860,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), }), @@ -3327,9 +3882,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3342,8 +3900,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), }), @@ -3602,9 +4158,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3617,8 +4176,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), @@ -3646,9 +4203,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3661,8 +4221,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), @@ -3690,10 +4248,16 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3706,8 +4270,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), @@ -3730,9 +4292,12 @@ dict({ 'addons': list([ ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 1, + }), + }), 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', @@ -3745,8 +4310,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test 2', - 'protected': False, - 'size': 1, 'with_automatic_settings': None, }), dict({ @@ -3757,9 +4320,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3772,8 +4338,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), @@ -3802,9 +4366,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3817,8 +4384,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index f2c2e5c5b05..d2993e53410 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -487,11 +487,13 @@ async def test_initiate_backup( result = await ws_client.receive_json() backup_data = result["result"]["backup"] - backup_agent_ids = backup_data.pop("agent_ids") - assert backup_agent_ids == agent_ids assert backup_data == { "addons": [], + "agents": { + agent_id: {"protected": bool(password), "size": ANY} + for agent_id in agent_ids + }, "backup_id": backup_id, "database_included": include_database, "date": ANY, @@ -500,8 +502,6 @@ async def test_initiate_backup( "homeassistant_included": True, "homeassistant_version": "2025.1.0", "name": name, - "protected": bool(password), - "size": ANY, "with_automatic_settings": False, } @@ -543,9 +543,7 @@ async def test_initiate_backup_with_agent_error( "version": "1.0.0", }, ], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 0}}, "backup_id": "backup1", "database_included": True, "date": "1970-01-01T00:00:00.000Z", @@ -557,15 +555,11 @@ async def test_initiate_backup_with_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 0, "with_automatic_settings": True, }, { "addons": [], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 1}}, "backup_id": "backup2", "database_included": False, "date": "1980-01-01T00:00:00.000Z", @@ -577,8 +571,6 @@ async def test_initiate_backup_with_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test 2", - "protected": False, - "size": 1, "with_automatic_settings": None, }, { @@ -589,9 +581,7 @@ async def test_initiate_backup_with_agent_error( "version": "1.0.0", }, ], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 0}}, "backup_id": "backup3", "database_included": True, "date": "1970-01-01T00:00:00.000Z", @@ -603,8 +593,6 @@ async def test_initiate_backup_with_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 0, "with_automatic_settings": True, }, ] @@ -714,7 +702,7 @@ async def test_initiate_backup_with_agent_error( new_expected_backup_data = { "addons": [], - "agent_ids": ["backup.local"], + "agents": {"backup.local": {"protected": False, "size": 123}}, "backup_id": "abc123", "database_included": True, "date": ANY, @@ -723,8 +711,6 @@ async def test_initiate_backup_with_agent_error( "homeassistant_included": True, "homeassistant_version": "2025.1.0", "name": "Custom backup 2025.1.0", - "protected": False, - "size": 123, "with_automatic_settings": False, } @@ -1633,9 +1619,7 @@ async def test_receive_backup_agent_error( "version": "1.0.0", }, ], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 0}}, "backup_id": "backup1", "database_included": True, "date": "1970-01-01T00:00:00.000Z", @@ -1647,15 +1631,11 @@ async def test_receive_backup_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 0, "with_automatic_settings": True, }, { "addons": [], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 1}}, "backup_id": "backup2", "database_included": False, "date": "1980-01-01T00:00:00.000Z", @@ -1667,8 +1647,6 @@ async def test_receive_backup_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test 2", - "protected": False, - "size": 1, "with_automatic_settings": None, }, { @@ -1679,9 +1657,7 @@ async def test_receive_backup_agent_error( "version": "1.0.0", }, ], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 0}}, "backup_id": "backup3", "database_included": True, "date": "1970-01-01T00:00:00.000Z", @@ -1693,8 +1669,6 @@ async def test_receive_backup_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 0, "with_automatic_settings": True, }, ] @@ -2936,3 +2910,220 @@ async def test_restore_backup_file_error( assert open_mock.return_value.close.call_count == close_call_count assert mocked_write_text.call_count == write_text_call_count assert mocked_service_call.call_count == 0 + + +@pytest.mark.parametrize( + ("commands", "password", "protected_backup"), + [ + ( + [], + None, + {"backup.local": False, "test.remote": False}, + ), + ( + [], + "hunter2", + {"backup.local": True, "test.remote": True}, + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": False}, + }, + } + ], + "hunter2", + {"backup.local": False, "test.remote": False}, + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": True}, + }, + } + ], + "hunter2", + {"backup.local": False, "test.remote": True}, + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": True}, + "test.remote": {"protected": False}, + }, + } + ], + "hunter2", + {"backup.local": True, "test.remote": False}, + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": True}, + "test.remote": {"protected": True}, + }, + } + ], + "hunter2", + {"backup.local": True, "test.remote": True}, + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": True}, + }, + } + ], + None, + {"backup.local": False, "test.remote": False}, + ), + ], +) +@pytest.mark.usefixtures("mock_backup_generation") +async def test_initiate_backup_per_agent_encryption( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + generate_backup_id: MagicMock, + path_glob: MagicMock, + commands: dict[str, Any], + password: str | None, + protected_backup: dict[str, bool], +) -> None: + """Test generate backup where encryption is selectively set on agents.""" + agent_ids = ["backup.local", "test.remote"] + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + + 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, + ), + ) + + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + } + + for command in commands: + await ws_client.send_json_auto_id(command) + result = await ws_client.receive_json() + assert result["success"] is True + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + 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, + "password": password, + "name": "test", + } + ) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": CreateBackupStage.HOME_ASSISTANT, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": CreateBackupStage.UPLOAD_TO_AGENTS, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": None, + "state": CreateBackupState.COMPLETED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + await ws_client.send_json_auto_id( + {"type": "backup/details", "backup_id": backup_id} + ) + result = await ws_client.receive_json() + + backup_data = result["result"]["backup"] + + assert backup_data == { + "addons": [], + "agents": { + agent_id: {"protected": protected_backup[agent_id], "size": ANY} + for agent_id in agent_ids + }, + "backup_id": backup_id, + "database_included": True, + "date": ANY, + "failed_agent_ids": [], + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.1.0", + "name": "test", + "with_automatic_settings": False, + } diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py index cc84b66340c..f05afbea9ec 100644 --- a/tests/components/backup/test_store.py +++ b/tests/components/backup/test_store.py @@ -66,6 +66,7 @@ def mock_delay_save() -> Generator[None]: } ], "config": { + "agents": {"test.remote": {"protected": True}}, "create_backup": { "agent_ids": [], "include_addons": None, diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index 60cfc77b1aa..db759805c8f 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -2,13 +2,24 @@ from __future__ import annotations +from collections.abc import AsyncIterator +import dataclasses import tarfile from unittest.mock import Mock, patch import pytest +import securetar -from homeassistant.components.backup import AddonInfo, AgentBackup, Folder -from homeassistant.components.backup.util import read_backup, validate_password +from homeassistant.components.backup import DOMAIN, AddonInfo, AgentBackup, Folder +from homeassistant.components.backup.util import ( + DecryptedBackupStreamer, + EncryptedBackupStreamer, + read_backup, + validate_password, +) +from homeassistant.core import HomeAssistant + +from tests.common import get_fixture_path @pytest.mark.parametrize( @@ -130,3 +141,246 @@ def test_validate_password_no_homeassistant() -> None: KeyError ) assert validate_password(mock_path, "hunter2") is False + + +async def test_decrypted_backup_streamer(hass: HomeAssistant) -> None: + """Test the decrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=encrypted_backup_path.stat().st_size, + ) + expected_padding = b"\0" * 40960 # 4 x 10240 byte of padding + + async def send_backup() -> AsyncIterator[bytes]: + f = encrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "hunter2") + assert decryptor.backup() == dataclasses.replace( + backup, protected=False, size=backup.size + len(expected_padding) + ) + decrypted_stream = await decryptor.open_stream() + decrypted_output = b"" + async for chunk in decrypted_stream: + decrypted_output += chunk + await decryptor.wait() + + # Expect the output to match the stored decrypted backup file, with additional + # padding. + decrypted_backup_data = decrypted_backup_path.read_bytes() + assert decrypted_output == decrypted_backup_data + expected_padding + + +async def test_decrypted_backup_streamer_wrong_password(hass: HomeAssistant) -> None: + """Test the decrypted backup streamer with wrong password.""" + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=encrypted_backup_path.stat().st_size, + ) + + async def send_backup() -> AsyncIterator[bytes]: + f = encrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "wrong_password") + decrypted_stream = await decryptor.open_stream() + async for _ in decrypted_stream: + pass + + await decryptor.wait() + assert isinstance(decryptor._workers[0].error, securetar.SecureTarReadError) + + +async def test_encrypted_backup_streamer(hass: HomeAssistant) -> None: + """Test the encrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=False, + size=decrypted_backup_path.stat().st_size, + ) + expected_padding = b"\0" * 40960 # 4 x 10240 byte of padding + + async def send_backup() -> AsyncIterator[bytes]: + f = decrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + # Patch os.urandom to return values matching the nonce used in the encrypted + # test backup. The backup has three inner tar files, but we need an extra nonce + # for a future planned supervisor.tar. + with patch("os.urandom") as mock_randbytes: + mock_randbytes.side_effect = ( + bytes.fromhex("bd34ea6fc93b0614ce7af2b44b4f3957"), + bytes.fromhex("1296d6f7554e2cb629a3dc4082bae36c"), + bytes.fromhex("8b7a58e48faf2efb23845eb3164382e0"), + bytes.fromhex("00000000000000000000000000000000"), + ) + encryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + assert encryptor.backup() == dataclasses.replace( + backup, protected=True, size=backup.size + len(expected_padding) + ) + + encrypted_stream = await encryptor.open_stream() + encrypted_output = b"" + async for chunk in encrypted_stream: + encrypted_output += chunk + await encryptor.wait() + + # Expect the output to match the stored encrypted backup file, with additional + # padding. + encrypted_backup_data = encrypted_backup_path.read_bytes() + assert encrypted_output == encrypted_backup_data + expected_padding + + +async def test_encrypted_backup_streamer_random_nonce(hass: HomeAssistant) -> None: + """Test the encrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=False, + size=decrypted_backup_path.stat().st_size, + ) + + async def send_backup() -> AsyncIterator[bytes]: + f = decrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + encryptor1 = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + encryptor2 = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + + async def read_stream(stream: AsyncIterator[bytes]) -> bytes: + output = b"" + async for chunk in stream: + output += chunk + return output + + # When reading twice from the same streamer, the same nonce is used. + encrypted_output1 = await read_stream(await encryptor1.open_stream()) + encrypted_output2 = await read_stream(await encryptor1.open_stream()) + assert encrypted_output1 == encrypted_output2 + + encrypted_output3 = await read_stream(await encryptor2.open_stream()) + encrypted_output4 = await read_stream(await encryptor2.open_stream()) + assert encrypted_output3 == encrypted_output4 + + # Wait for workers to terminate + await encryptor1.wait() + await encryptor2.wait() + + # Output from the two streames should differ but have the same length. + assert encrypted_output1 != encrypted_output3 + assert len(encrypted_output1) == len(encrypted_output3) + + # Expect the output length to match the stored encrypted backup file, with + # additional padding. + encrypted_backup_data = encrypted_backup_path.read_bytes() + # 4 x 10240 byte of padding + assert len(encrypted_output1) == len(encrypted_backup_data) + 40960 + assert encrypted_output1[: len(encrypted_backup_data)] != encrypted_backup_data + + +async def test_encrypted_backup_streamer_error(hass: HomeAssistant) -> None: + """Test the encrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=False, + size=decrypted_backup_path.stat().st_size, + ) + + async def send_backup() -> AsyncIterator[bytes]: + f = decrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + # Patch os.urandom to return values matching the nonce used in the encrypted + # test backup. The backup has three inner tar files, but we need an extra nonce + # for a future planned supervisor.tar. + encryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + + with patch( + "homeassistant.components.backup.util.tarfile.open", + side_effect=tarfile.TarError, + ): + encrypted_stream = await encryptor.open_stream() + async for _ in encrypted_stream: + pass + + # Expect the output to match the stored encrypted backup file, with additional + # padding. + await encryptor.wait() + assert isinstance(encryptor._workers[0].error, tarfile.TarError) diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 0fd0ba308b3..613c0b69b6b 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -56,6 +56,7 @@ BACKUP_CALL = call( DEFAULT_STORAGE_DATA: dict[str, Any] = { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": [], "include_addons": None, @@ -587,6 +588,8 @@ async def test_generate_with_default_settings_calls_create( last_completed_automatic_backup: str, ) -> None: """Test backup/generate_with_automatic_settings calls async_initiate_backup.""" + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = create_backup_settings["password"] is not None 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") @@ -913,6 +916,7 @@ async def test_agents_info( "data": { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -943,6 +947,7 @@ async def test_agents_info( "data": { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -973,6 +978,7 @@ async def test_agents_info( "data": { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1003,6 +1009,7 @@ async def test_agents_info( "data": { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1033,6 +1040,7 @@ async def test_agents_info( "data": { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1063,6 +1071,41 @@ async def test_agents_info( "data": { "backups": [], "config": { + "agents": {}, + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": ["mon", "sun"], + "recurrence": "custom_days", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": { + "test-agent1": {"protected": True}, + "test-agent2": {"protected": False}, + }, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1115,80 +1158,130 @@ async def test_config_info( @pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") @pytest.mark.parametrize( - "command", + "commands", [ - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": 7}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": {"recurrence": "daily", "time": "06:00"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": {"days": ["mon"], "recurrence": "custom_days"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": {"recurrence": "never"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": {"days": ["mon", "sun"], "recurrence": "custom_days"}, - }, - { - "type": "backup/config/update", - "create_backup": { - "agent_ids": ["test-agent"], - "include_addons": ["test-addon"], - "include_folders": ["media"], - "name": "test-name", - "password": "test-password", + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 7}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"recurrence": "daily", "time": "06:00"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"days": ["mon"], "recurrence": "custom_days"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"recurrence": "never"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"days": ["mon", "sun"], "recurrence": "custom_days"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["test-addon"], + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": 3, "days": 7}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": None}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": 3, "days": None}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 7}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": 3}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"days": 7}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "agents": { + "test-agent1": {"protected": True}, + "test-agent2": {"protected": False}, + }, + } + ], + [ + # Test we can update AgentConfig + { + "type": "backup/config/update", + "agents": { + "test-agent1": {"protected": True}, + "test-agent2": {"protected": False}, + }, }, - "schedule": {"recurrence": "daily"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": 3, "days": 7}, - "schedule": {"recurrence": "daily"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": None}, - "schedule": {"recurrence": "daily"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": 3, "days": None}, - "schedule": {"recurrence": "daily"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": 7}, - "schedule": {"recurrence": "daily"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": 3}, - "schedule": {"recurrence": "daily"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"days": 7}, - "schedule": {"recurrence": "daily"}, - }, + { + "type": "backup/config/update", + "agents": { + "test-agent1": {"protected": False}, + "test-agent2": {"protected": True}, + }, + }, + ], ], ) @patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) @@ -1197,7 +1290,7 @@ async def test_config_update( hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, - command: dict[str, Any], + commands: dict[str, Any], hass_storage: dict[str, Any], ) -> None: """Test updating the backup config.""" @@ -1211,14 +1304,14 @@ async def test_config_update( await client.send_json_auto_id({"type": "backup/config/info"}) assert await client.receive_json() == snapshot - await client.send_json_auto_id(command) - result = await client.receive_json() + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] - assert result["success"] - - await client.send_json_auto_id({"type": "backup/config/info"}) - assert await client.receive_json() == snapshot - await hass.async_block_till_done() + await client.send_json_auto_id({"type": "backup/config/info"}) + assert await client.receive_json() == snapshot + await hass.async_block_till_done() # Trigger store write freezer.tick(60) @@ -1274,6 +1367,10 @@ async def test_config_update( "type": "backup/config/update", "create_backup": {"include_folders": ["media", "media"]}, }, + { + "type": "backup/config/update", + "agents": {"test-agent1": {"favorite": True}}, + }, ], ) async def test_config_update_errors( @@ -1600,10 +1697,14 @@ async def test_config_schedule_logic( create_backup_side_effect: list[Exception | None] | None, ) -> None: """Test config schedule logic.""" + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = True + client = await hass_ws_client(hass) storage_data = { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test.test-agent"], "include_addons": ["test-addon"], @@ -2057,10 +2158,14 @@ async def test_config_retention_copies_logic( delete_args_list: Any, ) -> None: """Test config backup retention copies logic.""" + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = True + client = await hass_ws_client(hass) storage_data = { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -2320,10 +2425,14 @@ async def test_config_retention_copies_logic_manual_backup( delete_args_list: Any, ) -> None: """Test config backup retention copies logic for manual backup.""" + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = True + client = await hass_ws_client(hass) storage_data = { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -2750,6 +2859,7 @@ async def test_config_retention_days_logic( storage_data = { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 373bd164c0c..516dacd5f3d 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -170,6 +170,7 @@ async def test_agents_list_backups( assert response["result"]["backups"] == [ { "addons": [], + "agents": {"cloud.cloud": {"protected": False, "size": 34519040}}, "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, @@ -177,9 +178,6 @@ async def test_agents_list_backups( "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", - "protected": False, - "size": 34519040, - "agent_ids": ["cloud.cloud"], "failed_agent_ids": [], "with_automatic_settings": None, } @@ -219,6 +217,7 @@ async def test_agents_list_backups_fail_cloud( "23e64aec", { "addons": [], + "agents": {"cloud.cloud": {"protected": False, "size": 34519040}}, "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, @@ -226,9 +225,6 @@ async def test_agents_list_backups_fail_cloud( "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", - "protected": False, - "size": 34519040, - "agent_ids": ["cloud.cloud"], "failed_agent_ids": [], "with_automatic_settings": None, }, diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 765f6bba887..62b7930012c 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -43,6 +43,7 @@ TEST_AGENT_BACKUP = AgentBackup( ) TEST_AGENT_BACKUP_RESULT = { "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], + "agents": {TEST_AGENT_ID: {"protected": False, "size": 987}}, "backup_id": "test-backup", "database_included": True, "date": "2025-01-01T01:23:45.678Z", @@ -50,9 +51,6 @@ TEST_AGENT_BACKUP_RESULT = { "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 987, - "agent_ids": [TEST_AGENT_ID], "failed_agent_ids": [], "with_automatic_settings": None, } diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 1a5701a79cf..1c257416ad0 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -407,7 +407,7 @@ async def test_agent_info( "addons": [ {"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"} ], - "agent_ids": ["hassio.local"], + "agents": {"hassio.local": {"protected": False, "size": 1048576}}, "backup_id": "abc123", "database_included": True, "date": "1970-01-01T00:00:00+00:00", @@ -416,8 +416,6 @@ async def test_agent_info( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 1048576, "with_automatic_settings": None, }, ), @@ -428,7 +426,7 @@ async def test_agent_info( "addons": [ {"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"} ], - "agent_ids": ["hassio.local"], + "agents": {"hassio.local": {"protected": False, "size": 1048576}}, "backup_id": "abc123", "database_included": False, "date": "1970-01-01T00:00:00+00:00", @@ -437,8 +435,6 @@ async def test_agent_info( "homeassistant_included": False, "homeassistant_version": None, "name": "Test", - "protected": False, - "size": 1048576, "with_automatic_settings": None, }, ), diff --git a/tests/components/kitchen_sink/test_backup.py b/tests/components/kitchen_sink/test_backup.py index 827bde39d7d..a664b91393d 100644 --- a/tests/components/kitchen_sink/test_backup.py +++ b/tests/components/kitchen_sink/test_backup.py @@ -102,7 +102,7 @@ async def test_agents_list_backups( assert response["result"]["backups"] == [ { "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], - "agent_ids": ["kitchen_sink.syncer"], + "agents": {"kitchen_sink.syncer": {"protected": False, "size": 1234}}, "backup_id": "abc123", "database_included": False, "date": "1970-01-01T00:00:00Z", @@ -111,8 +111,6 @@ async def test_agents_list_backups( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Kitchen sink syncer", - "protected": False, - "size": 1234, "with_automatic_settings": None, } ] @@ -185,7 +183,7 @@ async def test_agents_upload( assert len(backup_list) == 2 assert backup_list[1] == { "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], - "agent_ids": ["kitchen_sink.syncer"], + "agents": {"kitchen_sink.syncer": {"protected": False, "size": 0.0}}, "backup_id": "test-backup", "database_included": True, "date": "1970-01-01T00:00:00.000Z", @@ -194,8 +192,6 @@ async def test_agents_upload( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 0.0, "with_automatic_settings": False, } diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index a3cfbe95a46..a3d1129377f 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -81,6 +81,9 @@ async def test_agents_list_backups( assert response["result"]["backups"] == [ { "addons": [], + "agents": { + "onedrive.mock_drive_id": {"protected": False, "size": 34519040} + }, "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, @@ -88,9 +91,6 @@ async def test_agents_list_backups( "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", - "protected": False, - "size": 34519040, - "agent_ids": [f"{DOMAIN}.{mock_config_entry.unique_id}"], "failed_agent_ids": [], "with_automatic_settings": None, } @@ -117,6 +117,12 @@ async def test_agents_get_backup( assert response["result"]["agent_errors"] == {} assert response["result"]["backup"] == { "addons": [], + "agents": { + f"{DOMAIN}.{mock_config_entry.unique_id}": { + "protected": False, + "size": 34519040, + } + }, "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, @@ -124,9 +130,6 @@ async def test_agents_get_backup( "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", - "protected": False, - "size": 34519040, - "agent_ids": [f"{DOMAIN}.{mock_config_entry.unique_id}"], "failed_agent_ids": [], "with_automatic_settings": None, } diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 436e3666176..0d4fd0dc080 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -290,6 +290,12 @@ async def test_agents_list_backups( assert response["result"]["backups"] == [ { "addons": [], + "agents": { + "synology_dsm.mocked_syno_dsm_entry": { + "protected": True, + "size": 13916160, + } + }, "backup_id": "abcd12ef", "date": "2025-01-09T20:14:35.457323+01:00", "database_included": True, @@ -297,9 +303,6 @@ async def test_agents_list_backups( "homeassistant_included": True, "homeassistant_version": "2025.2.0.dev0", "name": "Automatic backup 2025.2.0.dev0", - "protected": True, - "size": 13916160, - "agent_ids": ["synology_dsm.mocked_syno_dsm_entry"], "failed_agent_ids": [], "with_automatic_settings": None, } @@ -355,6 +358,12 @@ async def test_agents_list_backups_disabled_filestation( "abcd12ef", { "addons": [], + "agents": { + "synology_dsm.mocked_syno_dsm_entry": { + "protected": True, + "size": 13916160, + } + }, "backup_id": "abcd12ef", "date": "2025-01-09T20:14:35.457323+01:00", "database_included": True, @@ -362,9 +371,6 @@ async def test_agents_list_backups_disabled_filestation( "homeassistant_included": True, "homeassistant_version": "2025.2.0.dev0", "name": "Automatic backup 2025.2.0.dev0", - "protected": True, - "size": 13916160, - "agent_ids": ["synology_dsm.mocked_syno_dsm_entry"], "failed_agent_ids": [], "with_automatic_settings": None, }, From 32829596ebce2ba87f7d0ff7f987a728795886b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 29 Jan 2025 14:17:00 +0100 Subject: [PATCH 1194/2987] Add select platform discovery schemas for the Matter LaundryWasherControls cluster (#136261) --- homeassistant/components/matter/icons.json | 3 + homeassistant/components/matter/select.py | 95 ++++++++++++++- homeassistant/components/matter/strings.json | 12 ++ .../fixtures/nodes/silabs_laundrywasher.json | 2 +- .../matter/snapshots/test_select.ambr | 114 ++++++++++++++++++ tests/components/matter/test_select.py | 54 +++++++++ 6 files changed, 274 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 4f3e532d877..f9217cabcc4 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -37,6 +37,9 @@ } }, "select": { + "laundry_washer_spin_speed": { + "default": "mdi:reload" + }, "temperature_level": { "default": "mdi:thermometer" } diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index b10f4e0e484..ab3e708d7a9 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -20,6 +20,17 @@ from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema +NUMBER_OF_RINSES_STATE_MAP = { + clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kNone: "off", + clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kNormal: "normal", + clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kExtra: "extra", + clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kMax: "max", + clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kUnknownEnumValue: None, +} +NUMBER_OF_RINSES_STATE_MAP_REVERSE = { + v: k for k, v in NUMBER_OF_RINSES_STATE_MAP.items() +} + type SelectCluster = ( clusters.ModeSelect | clusters.OvenMode @@ -48,15 +59,27 @@ class MatterSelectEntityDescription(SelectEntityDescription, MatterEntityDescrip """Describe Matter select entities.""" +@dataclass(frozen=True, kw_only=True) +class MatterMapSelectEntityDescription(MatterSelectEntityDescription): + """Describe Matter select entities for MatterMapSelectEntityDescription.""" + + measurement_to_ha: Callable[[int], str | None] + ha_to_native_value: Callable[[str], int | None] + + # list attribute: the attribute descriptor to get the list of values (= list of integers) + list_attribute: type[ClusterAttributeDescriptor] + + @dataclass(frozen=True, kw_only=True) class MatterListSelectEntityDescription(MatterSelectEntityDescription): """Describe Matter select entities for MatterListSelectEntity.""" - # command: a callback to create the command to send to the device - # the callback's argument will be the index of the selected list value - command: Callable[[int], ClusterCommand] # list attribute: the attribute descriptor to get the list of values (= list of strings) list_attribute: type[ClusterAttributeDescriptor] + # command: a custom callback to create the command to send to the device + # the callback's argument will be the index of the selected list value + # if omitted the command will just be a write_attribute command to the primary attribute + command: Callable[[int], ClusterCommand] | None = None class MatterAttributeSelectEntity(MatterEntity, SelectEntity): @@ -84,6 +107,29 @@ class MatterAttributeSelectEntity(MatterEntity, SelectEntity): self._attr_current_option = value_convert(value) +class MatterMapSelectEntity(MatterAttributeSelectEntity): + """Representation of a Matter select entity where the options are defined in a State map.""" + + entity_description: MatterMapSelectEntityDescription + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + # the options can dynamically change based on the state of the device + available_values = cast( + list[int], + self.get_matter_attribute_value(self.entity_description.list_attribute), + ) + # map available (int) values to string representation + self._attr_options = [ + mapped_value + for value in available_values + if (mapped_value := self.entity_description.measurement_to_ha(value)) + ] + # use base implementation from MatterAttributeSelectEntity to set the current option + super()._update_from_device() + + class MatterModeSelectEntity(MatterAttributeSelectEntity): """Representation of a select entity from Matter (Mode) Cluster attribute(s).""" @@ -125,8 +171,19 @@ class MatterListSelectEntity(MatterEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" option_id = self._attr_options.index(option) - await self.send_device_command( - self.entity_description.command(option_id), + + if TYPE_CHECKING: + assert option_id is not None + + if self.entity_description.command: + # custom command defined to set the new value + await self.send_device_command( + self.entity_description.command(option_id), + ) + return + # regular write attribute to set the new value + await self.write_attribute( + value=option_id, ) @callback @@ -328,4 +385,32 @@ DISCOVERY_SCHEMAS = [ clusters.TemperatureControl.Attributes.SupportedTemperatureLevels, ), ), + MatterDiscoverySchema( + platform=Platform.SELECT, + entity_description=MatterListSelectEntityDescription( + key="LaundryWasherControlsSpinSpeed", + translation_key="laundry_washer_spin_speed", + list_attribute=clusters.LaundryWasherControls.Attributes.SpinSpeeds, + ), + entity_class=MatterListSelectEntity, + required_attributes=( + clusters.LaundryWasherControls.Attributes.SpinSpeedCurrent, + clusters.LaundryWasherControls.Attributes.SpinSpeeds, + ), + ), + MatterDiscoverySchema( + platform=Platform.SELECT, + entity_description=MatterMapSelectEntityDescription( + key="MatterLaundryWasherNumberOfRinses", + translation_key="laundry_washer_number_of_rinses", + list_attribute=clusters.LaundryWasherControls.Attributes.SupportedRinses, + measurement_to_ha=NUMBER_OF_RINSES_STATE_MAP.get, + ha_to_native_value=NUMBER_OF_RINSES_STATE_MAP_REVERSE.get, + ), + entity_class=MatterMapSelectEntity, + required_attributes=( + clusters.LaundryWasherControls.Attributes.NumberOfRinses, + clusters.LaundryWasherControls.Attributes.SupportedRinses, + ), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 73ce41937fd..f1a123c61be 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -205,6 +205,18 @@ }, "temperature_display_mode": { "name": "Temperature display mode" + }, + "laundry_washer_number_of_rinses": { + "name": "Number of rinses", + "state": { + "off": "[%key:common::state::off%]", + "normal": "Normal", + "extra": "Extra", + "max": "Max" + } + }, + "laundry_washer_spin_speed": { + "name": "Spin speed" } }, "sensor": { diff --git a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json index a91584d7212..3b1ed0043de 100644 --- a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json +++ b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json @@ -656,7 +656,7 @@ "1/83/0": ["Off", "Low", "Medium", "High"], "1/83/1": 0, "1/83/2": 0, - "1/83/3": [1, 2], + "1/83/3": [0, 1], "1/83/65532": 3, "1/83/65533": 1, "1/83/65528": [], diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 9a2639ba7e1..e9aa169b4fd 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -1620,6 +1620,120 @@ 'state': 'unknown', }) # --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_number_of_rinses-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'normal', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.laundrywasher_number_of_rinses', + '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': 'Number of rinses', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'laundry_washer_number_of_rinses', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-MatterLaundryWasherNumberOfRinses-83-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_number_of_rinses-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Number of rinses', + 'options': list([ + 'off', + 'normal', + ]), + }), + 'context': , + 'entity_id': 'select.laundrywasher_number_of_rinses', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_spin_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Off', + 'Low', + 'Medium', + 'High', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.laundrywasher_spin_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': 'Spin speed', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'laundry_washer_spin_speed', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-LaundryWasherControlsSpinSpeed-83-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_spin_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Spin speed', + 'options': list([ + 'Off', + 'Low', + 'Medium', + 'High', + ]), + }), + 'context': , + 'entity_id': 'select.laundrywasher_spin_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Off', + }) +# --- # name: test_selects[silabs_laundrywasher][select.laundrywasher_temperature_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index 3643aa83fca..2403b4b1623 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode +from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest from syrupy import SnapshotAssertion @@ -144,3 +145,56 @@ async def test_list_select_entities( await trigger_subscription_callback(hass, matter_client) state = hass.states.get("select.laundrywasher_temperature_level") assert state.state == "unknown" + + # SpinSpeedCurrent + matter_client.write_attribute.reset_mock() + state = hass.states.get("select.laundrywasher_spin_speed") + assert state + assert state.state == "Off" + assert state.attributes["options"] == ["Off", "Low", "Medium", "High"] + set_node_attribute(matter_node, 1, 83, 1, 3) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_spin_speed") + assert state.state == "High" + # test select option + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.laundrywasher_spin_speed", + "option": "High", + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=clusters.LaundryWasherControls.Attributes.SpinSpeedCurrent, + ), + value=3, + ) + # test that an invalid value (e.g. 253) leads to an unknown state + set_node_attribute(matter_node, 1, 83, 1, 253) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_spin_speed") + assert state.state == "unknown" + + +@pytest.mark.parametrize("node_fixture", ["silabs_laundrywasher"]) +async def test_map_select_entities( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test MatterMapSelectEntity entities are discovered and working from a laundrywasher fixture.""" + # NumberOfRinses + state = hass.states.get("select.laundrywasher_number_of_rinses") + assert state + assert state.state == "off" + assert state.attributes["options"] == ["off", "normal"] + set_node_attribute(matter_node, 1, 83, 2, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_number_of_rinses") + assert state.state == "normal" From d9deba3916f535af3b109465cb48a166ccf7ebba Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Wed, 29 Jan 2025 14:25:28 +0100 Subject: [PATCH 1195/2987] Take exclude vias in unique ids for nmbs (#136590) --- homeassistant/components/nmbs/config_flow.py | 8 ++++--- homeassistant/components/nmbs/sensor.py | 15 +++++++++---- tests/components/nmbs/test_config_flow.py | 23 +++++++++++++++++++- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/nmbs/config_flow.py b/homeassistant/components/nmbs/config_flow.py index 24ef8cd4995..e45b2d9adeb 100644 --- a/homeassistant/components/nmbs/config_flow.py +++ b/homeassistant/components/nmbs/config_flow.py @@ -79,8 +79,9 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): for station in self.stations if station["id"] == user_input[CONF_STATION_TO] ] + vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS) else "" await self.async_set_unique_id( - f"{user_input[CONF_STATION_FROM]}_{user_input[CONF_STATION_TO]}" + f"{user_input[CONF_STATION_FROM]}_{user_input[CONF_STATION_TO]}{vias}" ) self._abort_if_unique_id_configured() @@ -154,12 +155,13 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_STATION_LIVE] = station_live["id"] entity_registry = er.async_get(self.hass) prefix = "live" + vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS, False) else "" if entity_id := entity_registry.async_get_entity_id( Platform.SENSOR, DOMAIN, f"{prefix}_{station_live['standardname']}_{station_from['standardname']}_{station_to['standardname']}", ): - new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}" + new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}{vias}" entity_registry.async_update_entity( entity_id, new_unique_id=new_unique_id ) @@ -168,7 +170,7 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): DOMAIN, f"{prefix}_{station_live['name']}_{station_from['name']}_{station_to['name']}", ): - new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}" + new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}{vias}" entity_registry.async_update_entity( entity_id, new_unique_id=new_unique_id ) diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index ca18d3b1bbd..6d13777e10a 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -170,8 +170,10 @@ async def async_setup_entry( NMBSSensor( api_client, name, show_on_map, station_from, station_to, excl_vias ), - NMBSLiveBoard(api_client, station_from, station_from, station_to), - NMBSLiveBoard(api_client, station_to, station_from, station_to), + NMBSLiveBoard( + api_client, station_from, station_from, station_to, excl_vias + ), + NMBSLiveBoard(api_client, station_to, station_from, station_to, excl_vias), ] ) @@ -187,12 +189,15 @@ class NMBSLiveBoard(SensorEntity): live_station: dict[str, Any], station_from: dict[str, Any], station_to: dict[str, Any], + excl_vias: bool, ) -> None: """Initialize the sensor for getting liveboard data.""" self._station = live_station self._api_client = api_client self._station_from = station_from self._station_to = station_to + + self._excl_vias = excl_vias self._attrs: dict[str, Any] | None = {} self._state: str | None = None @@ -210,7 +215,8 @@ class NMBSLiveBoard(SensorEntity): unique_id = ( f"{self._station['id']}_{self._station_from['id']}_{self._station_to['id']}" ) - return f"nmbs_live_{unique_id}" + vias = "_excl_vias" if self._excl_vias else "" + return f"nmbs_live_{unique_id}{vias}" @property def icon(self) -> str: @@ -303,7 +309,8 @@ class NMBSSensor(SensorEntity): """Return the unique ID.""" unique_id = f"{self._station_from['id']}_{self._station_to['id']}" - return f"nmbs_connection_{unique_id}" + vias = "_excl_vias" if self._excl_vias else "" + return f"nmbs_connection_{unique_id}{vias}" @property def name(self) -> str: diff --git a/tests/components/nmbs/test_config_flow.py b/tests/components/nmbs/test_config_flow.py index 6e55f89e54a..ff4c5bdf72a 100644 --- a/tests/components/nmbs/test_config_flow.py +++ b/tests/components/nmbs/test_config_flow.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock import pytest from homeassistant import config_entries +from homeassistant.components.nmbs.config_flow import CONF_EXCLUDE_VIAS from homeassistant.components.nmbs.const import ( CONF_STATION_FROM, CONF_STATION_LIVE, @@ -120,6 +121,23 @@ async def test_abort_if_exists( assert result["reason"] == "already_configured" +async def test_dont_abort_if_exists_when_vias_differs( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test aborting the flow if the entry already exists.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + CONF_EXCLUDE_VIAS: True, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + async def test_unavailable_api( hass: HomeAssistant, mock_nmbs_client: AsyncMock ) -> None: @@ -158,7 +176,10 @@ async def test_import( CONF_STATION_LIVE: "BE.NMBS.008813003", CONF_STATION_TO: "BE.NMBS.008814001", } - assert result["result"].unique_id == "BE.NMBS.008812005_BE.NMBS.008814001" + assert ( + result["result"].unique_id + == f"{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_{DUMMY_DATA['STAT_BRUSSELS_SOUTH']}" + ) async def test_step_import_abort_if_already_setup( From 6d91f8d86c47e12887c3bd90da442dbda22708bd Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 29 Jan 2025 14:36:05 +0100 Subject: [PATCH 1196/2987] Fix spelling of "API" for consistency in Home Assistant UI (#136842) --- homeassistant/components/weatherflow_cloud/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/weatherflow_cloud/strings.json b/homeassistant/components/weatherflow_cloud/strings.json index f707cbb0353..d22c62a030c 100644 --- a/homeassistant/components/weatherflow_cloud/strings.json +++ b/homeassistant/components/weatherflow_cloud/strings.json @@ -4,7 +4,7 @@ "user": { "description": "Set up a WeatherFlow Forecast Station", "data": { - "api_token": "Personal api token" + "api_token": "Personal API token" } }, "reauth_confirm": { From c7176f68492523945940a5a91beefb7a572b84df Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 29 Jan 2025 15:23:54 +0100 Subject: [PATCH 1197/2987] Add consumables for tplink tapo vacuums (#136510) Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> Co-authored-by: J. Nick Koston --- homeassistant/components/tplink/button.py | 15 + homeassistant/components/tplink/icons.json | 41 +- homeassistant/components/tplink/sensor.py | 74 ++++ homeassistant/components/tplink/strings.json | 45 +++ tests/components/tplink/__init__.py | 4 +- .../components/tplink/fixtures/features.json | 85 +++++ .../tplink/snapshots/test_button.ambr | 165 ++++++++ .../tplink/snapshots/test_sensor.ambr | 360 ++++++++++++++++++ 8 files changed, 787 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py index 6d9269b8c44..4279a233d21 100644 --- a/homeassistant/components/tplink/button.py +++ b/homeassistant/components/tplink/button.py @@ -72,6 +72,21 @@ BUTTON_DESCRIPTIONS: Final = [ ), TPLinkButtonEntityDescription(key="pair"), TPLinkButtonEntityDescription(key="unpair"), + TPLinkButtonEntityDescription( + key="main_brush_reset", + ), + TPLinkButtonEntityDescription( + key="side_brush_reset", + ), + TPLinkButtonEntityDescription( + key="sensor_reset", + ), + TPLinkButtonEntityDescription( + key="filter_reset", + ), + TPLinkButtonEntityDescription( + key="charging_contacts_reset", + ), ] BUTTON_DESCRIPTIONS_MAP = {desc.key: desc for desc in BUTTON_DESCRIPTIONS} diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json index 15e9406b2c9..73bb40a8386 100644 --- a/homeassistant/components/tplink/icons.json +++ b/homeassistant/components/tplink/icons.json @@ -32,7 +32,20 @@ }, "tilt_down": { "default": "mdi:chevron-down" - } + }, + "main_brush_reset": { + "default": "mdi:brush" + }, + "side_brush_reset": { + "default": "mdi:brush" + }, + "sensor_reset": { + "default": "mdi:eye-outline" + }, + "filter_reset": { + "default": "mdi:air-filter" + }, + "charging_contacts_reset": {} }, "select": { "light_preset": { @@ -134,6 +147,32 @@ "water_alert_timestamp": { "default": "mdi:clock-alert-outline" }, + "main_brush_remaining": { + "default": "mdi:brush" + }, + "main_brush_used": { + "default": "mdi:brush" + }, + "side_brush_remaining": { + "default": "mdi:brush" + }, + "side_brush_used": { + "default": "mdi:brush" + }, + "filter_remaining": { + "default": "mdi:air-filter" + }, + "filter_used": { + "default": "mdi:air-filter" + }, + "sensor_remaining": { + "default": "mdi:eye-outline" + }, + "sensor_used": { + "default": "mdi:eye-outline" + }, + "charging_contacts_remaining": {}, + "charging_contacts_used": {}, "vacuum_error": { "default": "mdi:alert-circle" } diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 0f5dbc0a2e3..4c38591b64d 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from operator import methodcaller from typing import TYPE_CHECKING, Any, cast from kasa import Feature @@ -16,6 +17,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) +from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -37,6 +39,8 @@ class TPLinkSensorEntityDescription( # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 +_TOTAL_SECONDS_METHOD_CALLER = methodcaller("total_seconds") + SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="current_consumption", @@ -120,6 +124,76 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="alarm_source", ), + TPLinkSensorEntityDescription( + key="main_brush_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="main_brush_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="side_brush_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="side_brush_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="filter_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="filter_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="sensor_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="sensor_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="charging_contacts_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="charging_contacts_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), TPLinkSensorEntityDescription( key="vacuum_error", device_class=SensorDeviceClass.ENUM, diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index fe1560b75d5..2714e92bd5c 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -147,6 +147,21 @@ }, "unpair": { "name": "Unpair device" + }, + "main_brush_reset": { + "name": "Reset main brush consumable" + }, + "side_brush_reset": { + "name": "Reset side brush consumable" + }, + "sensor_reset": { + "name": "Reset sensor consumable" + }, + "filter_reset": { + "name": "Reset filter consumable" + }, + "charging_contacts_reset": { + "name": "Reset charging contacts consumable" } }, "camera": { @@ -202,6 +217,36 @@ "alarm_source": { "name": "Alarm source" }, + "main_brush_remaining": { + "name": "Main brush remaining" + }, + "main_brush_used": { + "name": "Main brush used" + }, + "side_brush_remaining": { + "name": "Side brush remaining" + }, + "side_brush_used": { + "name": "Side brush used" + }, + "filter_remaining": { + "name": "Filter remaining" + }, + "filter_used": { + "name": "Filter used" + }, + "sensor_remaining": { + "name": "Sensor remaining" + }, + "sensor_used": { + "name": "Sensor used" + }, + "charging_contacts_remaining": { + "name": "Charging contacts remaining" + }, + "charging_contacts_used": { + "name": "Charging contacts used" + }, "vacuum_error": { "name": "Error", "state": { diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 4737d7432df..851d05636b0 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -2,7 +2,7 @@ from collections import namedtuple from dataclasses import replace -from datetime import datetime +from datetime import datetime, timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -279,6 +279,8 @@ def _mocked_feature( if enum_type := fixture.get("enum_type"): val = FIXTURE_ENUM_TYPES[enum_type](fixture["value"]) fixture["value"] = val + if timedelta_type := fixture.get("timedelta_type"): + fixture["value"] = timedelta(**{timedelta_type: fixture["value"]}) else: assert require_fixture is False, ( diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index c49c5881d5c..45b85da4583 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -407,5 +407,90 @@ "value": "", "type": "Action", "category": "Debug" + }, + "main_brush_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "side_brush_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "sensor_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "filter_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "charging_contacts_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "main_brush_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "main_brush_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "side_brush_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "side_brush_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "filter_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "filter_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "sensor_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "sensor_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "charging_contacts_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "charging_contacts_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" } } diff --git a/tests/components/tplink/snapshots/test_button.ambr b/tests/components/tplink/snapshots/test_button.ambr index 087aec39cfc..c0c74e11923 100644 --- a/tests/components/tplink/snapshots/test_button.ambr +++ b/tests/components/tplink/snapshots/test_button.ambr @@ -137,6 +137,171 @@ 'state': 'unknown', }) # --- +# name: test_states[button.my_device_reset_charging_contacts_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_charging_contacts_consumable', + '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': 'Reset charging contacts consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_contacts_reset', + 'unique_id': '123456789ABCDEFGH_charging_contacts_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_reset_filter_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_filter_consumable', + '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': 'Reset filter consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_reset', + 'unique_id': '123456789ABCDEFGH_filter_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_reset_main_brush_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_main_brush_consumable', + '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': 'Reset main brush consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'main_brush_reset', + 'unique_id': '123456789ABCDEFGH_main_brush_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_reset_sensor_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_sensor_consumable', + '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': 'Reset sensor consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensor_reset', + 'unique_id': '123456789ABCDEFGH_sensor_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_reset_side_brush_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_side_brush_consumable', + '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': 'Reset side brush consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_reset', + 'unique_id': '123456789ABCDEFGH_side_brush_reset', + 'unit_of_measurement': None, + }) +# --- # name: test_states[button.my_device_restart-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index e223a72dbc0..344b9e28b98 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -166,6 +166,78 @@ 'state': '85', }) # --- +# name: test_states[sensor.my_device_charging_contacts_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_charging_contacts_remaining', + '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': 'Charging contacts remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_contacts_remaining', + 'unique_id': '123456789ABCDEFGH_charging_contacts_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_charging_contacts_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_charging_contacts_used', + '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': 'Charging contacts used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_contacts_used', + 'unique_id': '123456789ABCDEFGH_charging_contacts_used', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -383,6 +455,78 @@ 'state': 'ok', }) # --- +# name: test_states[sensor.my_device_filter_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_filter_remaining', + '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': 'Filter remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_remaining', + 'unique_id': '123456789ABCDEFGH_filter_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_filter_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_filter_used', + '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': 'Filter used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_used', + 'unique_id': '123456789ABCDEFGH_filter_used', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -481,6 +625,78 @@ 'state': '2024-06-24T09:03:11+00:00', }) # --- +# name: test_states[sensor.my_device_main_brush_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_main_brush_remaining', + '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': 'Main brush remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'main_brush_remaining', + 'unique_id': '123456789ABCDEFGH_main_brush_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_main_brush_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_main_brush_used', + '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': 'Main brush used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'main_brush_used', + 'unique_id': '123456789ABCDEFGH_main_brush_used', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_on_since-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -547,6 +763,150 @@ 'unit_of_measurement': '%', }) # --- +# name: test_states[sensor.my_device_sensor_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_sensor_remaining', + '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': 'Sensor remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensor_remaining', + 'unique_id': '123456789ABCDEFGH_sensor_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_sensor_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_sensor_used', + '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': 'Sensor used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensor_used', + 'unique_id': '123456789ABCDEFGH_sensor_used', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_side_brush_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_side_brush_remaining', + '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': 'Side brush remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_remaining', + 'unique_id': '123456789ABCDEFGH_side_brush_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_side_brush_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_side_brush_used', + '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': 'Side brush used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_used', + 'unique_id': '123456789ABCDEFGH_side_brush_used', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_signal_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 653ff4717105b9c33d5312eeba65c75073af35bb Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 29 Jan 2025 15:56:47 +0100 Subject: [PATCH 1198/2987] Add cleaning statistics for tplink (#135784) Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- homeassistant/components/tplink/const.py | 6 +- homeassistant/components/tplink/sensor.py | 44 +++ homeassistant/components/tplink/strings.json | 27 ++ tests/components/tplink/__init__.py | 9 +- .../components/tplink/fixtures/features.json | 55 +++ .../tplink/snapshots/test_sensor.ambr | 359 ++++++++++++++++++ 6 files changed, 497 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index ad17aadeb5b..2df7101791a 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -4,7 +4,9 @@ from __future__ import annotations from typing import Final -from homeassistant.const import Platform, UnitOfTemperature +from kasa.smart.modules.clean import AreaUnit + +from homeassistant.const import Platform, UnitOfArea, UnitOfTemperature DOMAIN = "tplink" @@ -47,4 +49,6 @@ PLATFORMS: Final = [ UNIT_MAPPING = { "celsius": UnitOfTemperature.CELSIUS, "fahrenheit": UnitOfTemperature.FAHRENHEIT, + AreaUnit.Sqm: UnitOfArea.SQUARE_METERS, + AreaUnit.Sqft: UnitOfArea.SQUARE_FEET, } diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 4c38591b64d..38aab26cf8b 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -124,6 +124,50 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="alarm_source", ), + # Vacuum cleaning records + TPLinkSensorEntityDescription( + key="clean_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="clean_area", + device_class=SensorDeviceClass.AREA, + ), + TPLinkSensorEntityDescription( + key="clean_progress", + ), + TPLinkSensorEntityDescription( + key="last_clean_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="last_clean_area", + device_class=SensorDeviceClass.AREA, + ), + TPLinkSensorEntityDescription( + key="last_clean_timestamp", + device_class=SensorDeviceClass.TIMESTAMP, + ), + TPLinkSensorEntityDescription( + key="total_clean_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="total_clean_area", + device_class=SensorDeviceClass.AREA, + ), + TPLinkSensorEntityDescription( + key="total_clean_count", + ), TPLinkSensorEntityDescription( key="main_brush_remaining", device_class=SensorDeviceClass.DURATION, diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 2714e92bd5c..ded4806a726 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -217,6 +217,33 @@ "alarm_source": { "name": "Alarm source" }, + "clean_area": { + "name": "Cleaning area" + }, + "clean_time": { + "name": "Cleaning time" + }, + "clean_progress": { + "name": "Cleaning progress" + }, + "total_clean_area": { + "name": "Total cleaning area" + }, + "total_clean_time": { + "name": "Total cleaning time" + }, + "total_clean_count": { + "name": "Total cleaning count" + }, + "last_clean_area": { + "name": "Last cleaned area" + }, + "last_clean_time": { + "name": "Last cleaned time" + }, + "last_clean_timestamp": { + "name": "Last clean start" + }, "main_brush_remaining": { "name": "Main brush remaining" }, diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 851d05636b0..ac5bb347765 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -18,7 +18,7 @@ from kasa import ( from kasa.interfaces import Fan, Light, LightEffect, LightState, Thermostat from kasa.smart.modules import Speaker from kasa.smart.modules.alarm import Alarm -from kasa.smart.modules.clean import Clean, ErrorCode, Status +from kasa.smart.modules.clean import AreaUnit, Clean, ErrorCode, Status from kasa.smartcam.modules.camera import LOCAL_STREAMING_PORT, Camera from syrupy import SnapshotAssertion @@ -60,7 +60,7 @@ def _load_feature_fixtures(): FEATURES_FIXTURE = _load_feature_fixtures() -FIXTURE_ENUM_TYPES = {"CleanErrorCode": ErrorCode} +FIXTURE_ENUM_TYPES = {"CleanErrorCode": ErrorCode, "CleanAreaUnit": AreaUnit} async def setup_platform_for_device( @@ -276,12 +276,17 @@ def _mocked_feature( if fixture := FEATURES_FIXTURE.get(id): # copy the fixture so tests do not interfere with each other fixture = dict(fixture) + if enum_type := fixture.get("enum_type"): val = FIXTURE_ENUM_TYPES[enum_type](fixture["value"]) fixture["value"] = val if timedelta_type := fixture.get("timedelta_type"): fixture["value"] = timedelta(**{timedelta_type: fixture["value"]}) + if unit_enum_type := fixture.get("unit_enum_type"): + val = FIXTURE_ENUM_TYPES[unit_enum_type](fixture["unit"]) + fixture["unit"] = val + else: assert require_fixture is False, ( f"No fixture defined for feature {id} and require_fixture is True" diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index 45b85da4583..81277ddd3ae 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -341,6 +341,61 @@ "Connection 2" ] }, + "clean_time": { + "type": "Sensor", + "category": "Info", + "value": 12, + "timedelta_type": "minutes" + }, + "clean_area": { + "type": "Sensor", + "category": "Info", + "value": 2, + "unit": 1, + "unit_enum_type": "CleanAreaUnit" + }, + "clean_progress": { + "type": "Sensor", + "category": "Info", + "value": 30, + "unit": "%" + }, + "total_clean_time": { + "type": "Sensor", + "category": "Debug", + "value": 120, + "timedelta_type": "minutes" + }, + "total_clean_area": { + "type": "Sensor", + "category": "Debug", + "value": 2, + "unit": 1, + "unit_enum_type": "CleanAreaUnit" + }, + "last_clean_time": { + "type": "Sensor", + "category": "Debug", + "value": 60, + "timedelta_type": "minutes" + }, + "last_clean_area": { + "type": "Sensor", + "category": "Debug", + "value": 2, + "unit": 1, + "unit_enum_type": "CleanAreaUnit" + }, + "last_clean_timestamp": { + "type": "Sensor", + "category": "Debug", + "value": "2024-06-24 10:03:11.046643+01:00" + }, + "total_clean_count": { + "type": "Sensor", + "category": "Debug", + "value": 12 + }, "alarm_volume": { "value": "normal", "type": "Choice", diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 344b9e28b98..0d1cc9a03e4 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -238,6 +238,155 @@ 'unit_of_measurement': , }) # --- +# name: test_states[sensor.my_device_cleaning_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_cleaning_area', + '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': 'Cleaning area', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'clean_area', + 'unique_id': '123456789ABCDEFGH_clean_area', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'area', + 'friendly_name': 'my_device Cleaning area', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_device_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_states[sensor.my_device_cleaning_progress-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_cleaning_progress', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cleaning progress', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'clean_progress', + 'unique_id': '123456789ABCDEFGH_clean_progress', + 'unit_of_measurement': '%', + }) +# --- +# name: test_states[sensor.my_device_cleaning_progress-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Cleaning progress', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_device_cleaning_progress', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_states[sensor.my_device_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_cleaning_time', + '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': 'Cleaning time', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'clean_time', + 'unique_id': '123456789ABCDEFGH_clean_time', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'my_device Cleaning time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_device_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.00', + }) +# --- # name: test_states[sensor.my_device_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -578,6 +727,111 @@ 'state': '12', }) # --- +# name: test_states[sensor.my_device_last_clean_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_last_clean_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last clean start', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_clean_timestamp', + 'unique_id': '123456789ABCDEFGH_last_clean_timestamp', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_last_cleaned_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_last_cleaned_area', + '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': 'Last cleaned area', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_clean_area', + 'unique_id': '123456789ABCDEFGH_last_clean_area', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_last_cleaned_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_last_cleaned_time', + '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': 'Last cleaned time', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_clean_time', + 'unique_id': '123456789ABCDEFGH_last_clean_time', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_last_water_leak_alert-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1167,6 +1421,111 @@ 'state': '5.23', }) # --- +# name: test_states[sensor.my_device_total_cleaning_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_total_cleaning_area', + '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': 'Total cleaning area', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_clean_area', + 'unique_id': '123456789ABCDEFGH_total_clean_area', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_total_cleaning_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_total_cleaning_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleaning count', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_clean_count', + 'unique_id': '123456789ABCDEFGH_total_clean_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_total_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_total_cleaning_time', + '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': 'Total cleaning time', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_clean_time', + 'unique_id': '123456789ABCDEFGH_total_clean_time', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_total_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 83b34c6fafec4fdc80b2c2180740dde56dc410e1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 16:15:20 +0100 Subject: [PATCH 1199/2987] Adjust deprecation in water heater (#136577) --- .../components/water_heater/__init__.py | 25 ++++++++++---- tests/components/water_heater/test_init.py | 33 ++++++++++++++----- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index 3e1387cb714..c9155950680 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -25,7 +25,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.deprecation import deprecated_class +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp @@ -134,11 +139,11 @@ class WaterHeaterEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes water heater entities.""" -@deprecated_class("WaterHeaterEntityDescription", breaks_in_ha_version="2026.1") -class WaterHeaterEntityEntityDescription( - WaterHeaterEntityDescription, frozen_or_thawed=True -): - """A (deprecated) class that describes water heater entities.""" +_DEPRECATED_WaterHeaterEntityEntityDescription = DeprecatedConstant( + WaterHeaterEntityDescription, + "WaterHeaterEntityDescription", + breaks_in_ha_version="2026.1", +) CACHED_PROPERTIES_WITH_ATTR_ = { @@ -414,3 +419,11 @@ async def async_service_temperature_set( kwargs[value] = temp await entity.async_set_temperature(**kwargs) + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/tests/components/water_heater/test_init.py b/tests/components/water_heater/test_init.py index 09a0a711582..67f0c1de36e 100644 --- a/tests/components/water_heater/test_init.py +++ b/tests/components/water_heater/test_init.py @@ -2,19 +2,20 @@ from __future__ import annotations +from typing import Any from unittest import mock from unittest.mock import AsyncMock, MagicMock import pytest import voluptuous as vol +from homeassistant.components import water_heater from homeassistant.components.water_heater import ( DOMAIN, SERVICE_SET_OPERATION_MODE, SET_TEMPERATURE_SCHEMA, WaterHeaterEntity, WaterHeaterEntityDescription, - WaterHeaterEntityEntityDescription, WaterHeaterEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -29,6 +30,7 @@ from tests.common import ( MockModule, MockPlatform, async_mock_service, + import_and_test_deprecated_constant, mock_integration, mock_platform, ) @@ -209,12 +211,27 @@ async def test_operation_mode_validation( @pytest.mark.parametrize( - ("class_name", "expected_log"), - [(WaterHeaterEntityDescription, False), (WaterHeaterEntityEntityDescription, True)], + ("constant_name", "replacement_name", "replacement"), + [ + ( + "WaterHeaterEntityEntityDescription", + "WaterHeaterEntityDescription", + WaterHeaterEntityDescription, + ), + ], ) -async def test_deprecated_entity_description( - caplog: pytest.LogCaptureFixture, class_name: type, expected_log: bool +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement_name: str, + replacement: Any, ) -> None: - """Test deprecated WaterHeaterEntityEntityDescription logs warning.""" - class_name(key="test") - assert ("is a deprecated class" in caplog.text) is expected_log + """Test deprecated automation constants.""" + import_and_test_deprecated_constant( + caplog, + water_heater, + constant_name, + replacement_name, + replacement, + "2026.1", + ) From 8ab6bec746a97bf7e7db65a71451a3ca043c3d70 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Jan 2025 10:42:39 -0500 Subject: [PATCH 1200/2987] Migrate Google Gen AI to ChatSession (#136779) * Migrate Google Gen AI to ChatSession * Remove unused method --- .../conversation.py | 199 +++++++----------- .../snapshots/test_conversation.ambr | 40 ++++ .../test_conversation.py | 13 +- 3 files changed, 128 insertions(+), 124 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 81cc7ab8a73..db2df9cddd3 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -11,18 +11,15 @@ import google.generativeai as genai from google.generativeai import protos import google.generativeai.types as genai_types from google.protobuf.json_format import MessageToDict -import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation -from homeassistant.components.conversation import trace from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.helpers import device_registry as dr, intent, llm, template +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid as ulid_util from .const import ( CONF_CHAT_MODEL, @@ -152,6 +149,17 @@ def _escape_decode(value: Any) -> Any: return value +def _chat_message_convert( + message: conversation.Content | conversation.NativeContent[genai_types.ContentDict], +) -> genai_types.ContentDict: + """Convert any native chat message for this agent to the native format.""" + if message.role == "native": + return message.content + + role = "model" if message.role == "assistant" else message.role + return {"role": role, "parts": message.content} + + class GoogleGenerativeAIConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent ): @@ -163,7 +171,6 @@ class GoogleGenerativeAIConversationEntity( def __init__(self, entry: ConfigEntry) -> None: """Initialize the agent.""" self.entry = entry - self.history: dict[str, list[genai_types.ContentType]] = {} self._attr_unique_id = entry.entry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, @@ -202,49 +209,37 @@ class GoogleGenerativeAIConversationEntity( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: """Process a sentence.""" - result = conversation.ConversationResult( - response=intent.IntentResponse(language=user_input.language), - conversation_id=user_input.conversation_id or ulid_util.ulid_now(), - ) - assert result.conversation_id + async with conversation.async_get_chat_session( + self.hass, user_input + ) as session: + return await self._async_handle_message(user_input, session) - llm_context = llm.LLMContext( - platform=DOMAIN, - context=user_input.context, - user_prompt=user_input.text, - language=user_input.language, - assistant=conversation.DOMAIN, - device_id=user_input.device_id, - ) - llm_api: llm.APIInstance | None = None - tools: list[dict[str, Any]] | None = None - if self.entry.options.get(CONF_LLM_HASS_API): - try: - llm_api = await llm.async_get_api( - self.hass, - self.entry.options[CONF_LLM_HASS_API], - llm_context, - ) - except HomeAssistantError as err: - LOGGER.error("Error getting LLM API: %s", err) - result.response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Error preparing LLM API: {err}", - ) - return result - tools = [ - _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools - ] + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + session: conversation.ChatSession[genai_types.ContentDict], + ) -> conversation.ConversationResult: + """Call the API.""" + + assert user_input.agent_id + options = self.entry.options try: - prompt = await self._async_render_prompt(user_input, llm_api, llm_context) - except TemplateError as err: - LOGGER.error("Error rendering prompt: %s", err) - result.response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem with my template: {err}", + await session.async_update_llm_data( + DOMAIN, + user_input, + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), ) - return result + except conversation.ConverseError as err: + return err.as_conversation_result() + + tools: list[dict[str, Any]] | None = None + if session.llm_api: + tools = [ + _format_tool(tool, session.llm_api.custom_serializer) + for tool in session.llm_api.tools + ] model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) # Gemini 1.0 doesn't support system_instruction while 1.5 does. @@ -254,6 +249,9 @@ class GoogleGenerativeAIConversationEntity( "gemini-1.0" not in model_name and "gemini-pro" not in model_name ) + prompt, *messages = [ + _chat_message_convert(message) for message in session.async_get_messages() + ] model = genai.GenerativeModel( model_name=model_name, generation_config={ @@ -281,27 +279,15 @@ class GoogleGenerativeAIConversationEntity( ), }, tools=tools or None, - system_instruction=prompt if supports_system_instruction else None, + system_instruction=prompt["parts"] if supports_system_instruction else None, ) - messages = self.history.get(result.conversation_id, []) if not supports_system_instruction: - if not messages: - messages = [{}, {"role": "model", "parts": "Ok"}] - messages[0] = {"role": "user", "parts": prompt} - - LOGGER.debug("Input: '%s' with history: %s", user_input.text, messages) - trace.async_conversation_trace_append( - trace.ConversationTraceEventType.AGENT_DETAIL, - { - # Make a copy to attach it to the trace event. - "messages": messages[:] - if supports_system_instruction - else messages[2:], - "prompt": prompt, - "tools": [*llm_api.tools] if llm_api else None, - }, - ) + messages = [ + {"role": "user", "parts": prompt["parts"]}, + {"role": "model", "parts": "Ok"}, + *messages, + ] chat = model.start_chat(history=messages) chat_request = user_input.text @@ -326,24 +312,30 @@ class GoogleGenerativeAIConversationEntity( f"Sorry, I had a problem talking to Google Generative AI: {err}" ) - result.response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - error, - ) - return result + raise HomeAssistantError(error) from err LOGGER.debug("Response: %s", chat_response.parts) if not chat_response.parts: - result.response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Sorry, I had a problem getting a response from Google Generative AI.", + raise HomeAssistantError( + "Sorry, I had a problem getting a response from Google Generative AI." ) - return result - self.history[result.conversation_id] = chat.history + content = " ".join( + [part.text.strip() for part in chat_response.parts if part.text] + ) + if content: + session.async_add_message( + conversation.Content( + role="assistant", + agent_id=user_input.agent_id, + content=content, + ) + ) + function_calls = [ part.function_call for part in chat_response.parts if part.function_call ] - if not function_calls or not llm_api: + + if not function_calls or not session.llm_api: break tool_responses = [] @@ -351,16 +343,8 @@ class GoogleGenerativeAIConversationEntity( tool_call = MessageToDict(function_call._pb) # noqa: SLF001 tool_name = tool_call["name"] tool_args = _escape_decode(tool_call["args"]) - LOGGER.debug("Tool call: %s(%s)", tool_name, tool_args) tool_input = llm.ToolInput(tool_name=tool_name, tool_args=tool_args) - try: - function_response = await llm_api.async_call_tool(tool_input) - except (HomeAssistantError, vol.Invalid) as e: - function_response = {"error": type(e).__name__} - if str(e): - function_response["error_text"] = str(e) - - LOGGER.debug("Tool response: %s", function_response) + function_response = await session.async_call_tool(tool_input) tool_responses.append( protos.Part( function_response=protos.FunctionResponse( @@ -369,47 +353,20 @@ class GoogleGenerativeAIConversationEntity( ) ) chat_request = protos.Content(parts=tool_responses) + session.async_add_message( + conversation.NativeContent( + agent_id=user_input.agent_id, + content=chat_request, + ) + ) - result.response.async_set_speech( + response = intent.IntentResponse(language=user_input.language) + response.async_set_speech( " ".join([part.text.strip() for part in chat_response.parts if part.text]) ) - return result - - async def _async_render_prompt( - self, - user_input: conversation.ConversationInput, - llm_api: llm.APIInstance | None, - llm_context: llm.LLMContext, - ) -> str: - user_name: str | None = None - if ( - user_input.context - and user_input.context.user_id - and ( - user := await self.hass.auth.async_get_user(user_input.context.user_id) - ) - ): - user_name = user.name - - parts = [ - template.Template( - llm.BASE_PROMPT - + self.entry.options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT), - self.hass, - ).async_render( - { - "ha_name": self.hass.config.location_name, - "user_name": user_name, - "llm_context": llm_context, - }, - parse_result=False, - ) - ] - - if llm_api: - parts.append(llm_api.api_prompt) - - return "\n".join(parts) + return conversation.ConversationResult( + response=response, conversation_id=session.conversation_id + ) async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index 65238c5212a..21458abb7c8 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -42,6 +42,10 @@ 'parts': 'Ok', 'role': 'model', }), + dict({ + 'parts': '1st user request', + 'role': 'user', + }), ]), }), ), @@ -102,6 +106,10 @@ 'parts': '1st model response', 'role': 'model', }), + dict({ + 'parts': '2nd user request', + 'role': 'user', + }), ]), }), ), @@ -150,6 +158,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': '1st user request', + 'role': 'user', + }), ]), }), ), @@ -202,6 +214,10 @@ 'parts': '1st model response', 'role': 'model', }), + dict({ + 'parts': '2nd user request', + 'role': 'user', + }), ]), }), ), @@ -250,6 +266,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': 'hello', + 'role': 'user', + }), ]), }), ), @@ -298,6 +318,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': 'hello', + 'role': 'user', + }), ]), }), ), @@ -347,6 +371,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': 'hello', + 'role': 'user', + }), ]), }), ), @@ -396,6 +424,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': 'hello', + 'role': 'user', + }), ]), }), ), @@ -482,6 +514,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': 'Please call the test function', + 'role': 'user', + }), ]), }), ), @@ -558,6 +594,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': 'Please call the test function', + 'role': 'user', + }), ]), }), ), diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index df0b11487d8..a87056275dc 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -208,6 +208,7 @@ async def test_function_call( chat_response = MagicMock() mock_chat.send_message_async.return_value = chat_response mock_part = MagicMock() + mock_part.text = "" mock_part.function_call = FunctionCall( name="test_tool", args={ @@ -284,8 +285,12 @@ async def test_function_call( ] # AGENT_DETAIL event contains the raw prompt passed to the model detail_event = trace_events[1] - assert "Answer in plain text" in detail_event["data"]["prompt"] - assert [t.name for t in detail_event["data"]["tools"]] == ["test_tool"] + assert "Answer in plain text" in detail_event["data"]["messages"][0]["content"] + assert [ + p.function_response.name + for p in detail_event["data"]["messages"][2]["content"].parts + if p.function_response + ] == ["test_tool"] @patch( @@ -315,6 +320,7 @@ async def test_function_call_without_parameters( chat_response = MagicMock() mock_chat.send_message_async.return_value = chat_response mock_part = MagicMock() + mock_part.text = "" mock_part.function_call = FunctionCall(name="test_tool", args={}) def tool_call( @@ -403,6 +409,7 @@ async def test_function_exception( chat_response = MagicMock() mock_chat.send_message_async.return_value = chat_response mock_part = MagicMock() + mock_part.text = "" mock_part.function_call = FunctionCall(name="test_tool", args={"param1": 1}) def tool_call( @@ -543,7 +550,7 @@ async def test_invalid_llm_api( assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result assert result.response.as_dict()["speech"]["plain"]["speech"] == ( - "Error preparing LLM API: API invalid_llm_api not found" + "Error preparing LLM API" ) From b2ec72d75fd93d13b0b63b165909d2b353bf6608 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 16:58:33 +0100 Subject: [PATCH 1201/2987] Persist backup restore status after core restart (#136838) * Persist backup restore status after core restart * Don't blow up if restore result file can't be removed * Update tests --- homeassistant/backup_restore.py | 32 ++++- homeassistant/components/backup/__init__.py | 2 + homeassistant/components/backup/manager.py | 68 +++++++++- homeassistant/components/backup/websocket.py | 2 + homeassistant/components/hassio/backup.py | 8 ++ .../backup/snapshots/test_backup.ambr | 10 ++ .../backup/snapshots/test_websocket.ambr | 42 ++++++ tests/components/backup/test_manager.py | 121 ++++++++++++++++++ tests/components/cloud/test_backup.py | 2 + .../onboarding/snapshots/test_views.ambr | 22 ++-- tests/components/onboarding/test_views.py | 12 +- tests/components/synology_dsm/test_backup.py | 2 + tests/test_backup_restore.py | 53 ++++++++ 13 files changed, 356 insertions(+), 20 deletions(-) diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py index 3d24d807a06..9287aa2bf1b 100644 --- a/homeassistant/backup_restore.py +++ b/homeassistant/backup_restore.py @@ -18,6 +18,7 @@ import securetar from .const import __version__ as HA_VERSION RESTORE_BACKUP_FILE = ".HA_RESTORE" +RESTORE_BACKUP_RESULT_FILE = ".HA_RESTORE_RESULT" KEEP_BACKUPS = ("backups",) KEEP_DATABASE = ( "home-assistant_v2.db", @@ -62,7 +63,10 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | restore_database=instruction_content["restore_database"], restore_homeassistant=instruction_content["restore_homeassistant"], ) - except (FileNotFoundError, KeyError, json.JSONDecodeError): + except FileNotFoundError: + return None + except (KeyError, json.JSONDecodeError) as err: + _write_restore_result_file(config_dir, False, err) return None finally: # Always remove the backup instruction file to prevent a boot loop @@ -159,6 +163,23 @@ def _extract_backup( ) +def _write_restore_result_file( + config_dir: Path, success: bool, error: Exception | None +) -> None: + """Write the restore result file.""" + result_path = config_dir.joinpath(RESTORE_BACKUP_RESULT_FILE) + result_path.write_text( + json.dumps( + { + "success": success, + "error": str(error) if error else None, + "error_type": str(type(error).__name__) if error else None, + } + ), + encoding="utf-8", + ) + + def restore_backup(config_dir_path: str) -> bool: """Restore the backup file if any. @@ -177,7 +198,14 @@ def restore_backup(config_dir_path: str) -> bool: restore_content=restore_content, ) except FileNotFoundError as err: - raise ValueError(f"Backup file {backup_file_path} does not exist") from err + file_not_found = ValueError(f"Backup file {backup_file_path} does not exist") + _write_restore_result_file(config_dir, False, file_not_found) + raise file_not_found from err + except Exception as err: + _write_restore_result_file(config_dir, False, err) + raise + else: + _write_restore_result_file(config_dir, True, None) if restore_content.remove_after_restore: backup_file_path.unlink(missing_ok=True) _LOGGER.info("Restore complete, restarting") diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index ce3fea80f67..d3903c2d679 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -26,6 +26,7 @@ from .manager import ( BackupReaderWriterError, CoreBackupReaderWriter, CreateBackupEvent, + IdleEvent, IncorrectPasswordError, ManagerBackup, NewBackup, @@ -47,6 +48,7 @@ __all__ = [ "BackupReaderWriterError", "CreateBackupEvent", "Folder", + "IdleEvent", "IncorrectPasswordError", "LocalBackupAgent", "ManagerBackup", diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 1f439160381..fc56505e343 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -19,7 +19,11 @@ from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast import aiohttp from securetar import SecureTarFile, atomic_contents_add -from homeassistant.backup_restore import RESTORE_BACKUP_FILE, password_to_key +from homeassistant.backup_restore import ( + RESTORE_BACKUP_FILE, + RESTORE_BACKUP_RESULT_FILE, + password_to_key, +) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( @@ -28,7 +32,7 @@ from homeassistant.helpers import ( issue_registry as ir, ) from homeassistant.helpers.json import json_bytes -from homeassistant.util import dt as dt_util +from homeassistant.util import dt as dt_util, json as json_util from . import util as backup_util from .agent import ( @@ -261,6 +265,14 @@ class BackupReaderWriter(abc.ABC): ) -> None: """Restore a backup.""" + @abc.abstractmethod + async def async_resume_restore_progress_after_restart( + self, + *, + on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], + ) -> None: + """Get restore events after core restart.""" + class BackupReaderWriterError(BackupError): """Backup reader/writer error.""" @@ -318,6 +330,10 @@ class BackupManager: self.config.load(stored["config"]) self.known_backups.load(stored["backups"]) + await self._reader_writer.async_resume_restore_progress_after_restart( + on_progress=self.async_on_backup_event + ) + await self.load_platforms() @property @@ -1605,6 +1621,54 @@ class CoreBackupReaderWriter(BackupReaderWriter): ) await self._hass.services.async_call("homeassistant", "restart", blocking=True) + async def async_resume_restore_progress_after_restart( + self, + *, + on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], + ) -> None: + """Check restore status after core restart.""" + + def _read_restore_file() -> json_util.JsonObjectType | None: + """Read the restore file.""" + result_path = Path(self._hass.config.path(RESTORE_BACKUP_RESULT_FILE)) + + try: + restore_result = json_util.json_loads_object(result_path.read_bytes()) + except FileNotFoundError: + return None + finally: + try: + result_path.unlink(missing_ok=True) + except OSError as err: + LOGGER.warning( + "Unexpected error deleting backup restore result file: %s %s", + type(err), + err, + ) + + return restore_result + + restore_result = await self._hass.async_add_executor_job(_read_restore_file) + if not restore_result: + return + + success = restore_result["success"] + if not success: + LOGGER.warning( + "Backup restore failed with %s: %s", + restore_result["error_type"], + restore_result["error"], + ) + state = RestoreBackupState.COMPLETED if success else RestoreBackupState.FAILED + on_progress( + RestoreBackupEvent( + reason=cast(str, restore_result["error"]), + stage=None, + state=state, + ) + ) + on_progress(IdleEvent()) + def _generate_backup_id(date: str, name: str) -> str: """Generate a backup ID.""" diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index d8a425ab6ba..feb762bb50b 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -60,8 +60,10 @@ async def handle_info( "backups": [backup.as_frontend_json() for backup in backups.values()], "last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup, "last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup, + "last_non_idle_event": manager.last_non_idle_event, "next_automatic_backup": manager.config.data.schedule.next_automatic_backup, "next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional, + "state": manager.state, }, ) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 9362c03b0be..5318e4cd351 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -29,6 +29,7 @@ from homeassistant.components.backup import ( BackupReaderWriterError, CreateBackupEvent, Folder, + IdleEvent, IncorrectPasswordError, NewBackup, RestoreBackupEvent, @@ -456,6 +457,13 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): finally: unsub() + async def async_resume_restore_progress_after_restart( + self, + *, + on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], + ) -> None: + """Check restore status after core restart.""" + @callback def _async_listen_job_events( self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index 441f79276a5..032eb7ac537 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -85,8 +85,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -117,8 +119,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -149,8 +153,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -181,8 +187,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -213,8 +221,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index f5a22201138..7ea911496de 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -2977,8 +2977,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3005,8 +3007,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3050,8 +3054,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3078,8 +3084,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3123,8 +3131,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3179,8 +3189,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3219,8 +3231,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3270,8 +3284,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3319,8 +3335,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3375,8 +3393,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3432,8 +3452,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3490,8 +3512,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3546,8 +3570,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3602,8 +3628,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3658,8 +3686,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3715,8 +3745,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -4181,8 +4213,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -4226,8 +4260,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -4275,8 +4311,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -4343,8 +4381,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -4389,8 +4429,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index d2993e53410..5e5b0df74cd 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -397,8 +397,10 @@ async def test_initiate_backup( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -626,8 +628,10 @@ async def test_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id( @@ -724,8 +728,15 @@ async def test_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": { + "manager_state": "create_backup", + "reason": "upload_failed", + "stage": None, + "state": "failed", + }, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await hass.async_block_till_done() @@ -993,8 +1004,10 @@ async def test_initiate_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1109,8 +1122,10 @@ async def test_initiate_backup_with_task_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1217,8 +1232,10 @@ async def test_initiate_backup_file_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1703,8 +1720,10 @@ async def test_receive_backup_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id( @@ -1786,8 +1805,15 @@ async def test_receive_backup_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": { + "manager_state": "receive_backup", + "reason": None, + "stage": None, + "state": "completed", + }, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await hass.async_block_till_done() @@ -1848,8 +1874,10 @@ async def test_receive_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1973,8 +2001,10 @@ async def test_receive_backup_file_write_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -2086,8 +2116,10 @@ async def test_receive_backup_read_tar_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -2264,8 +2296,10 @@ async def test_receive_backup_file_read_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -3034,8 +3068,10 @@ async def test_initiate_backup_per_agent_encryption( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } for command in commands: @@ -3127,3 +3163,88 @@ async def test_initiate_backup_per_agent_encryption( "name": "test", "with_automatic_settings": False, } + + +@pytest.mark.parametrize( + ("restore_result", "last_non_idle_event"), + [ + ( + {"error": None, "error_type": None, "success": True}, + { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "completed", + }, + ), + ( + {"error": "Boom!", "error_type": "ValueError", "success": False}, + { + "manager_state": "restore_backup", + "reason": "Boom!", + "stage": None, + "state": "failed", + }, + ), + ], +) +async def test_restore_progress_after_restart( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + restore_result: dict[str, Any], + last_non_idle_event: dict[str, Any], +) -> None: + """Test restore backup 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() + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == { + "agent_errors": {}, + "backups": [], + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": last_non_idle_event, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + +async def test_restore_progress_after_restart_fail_to_remove( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """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() + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == { + "agent_errors": {}, + "backups": [], + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + assert ( + "Unexpected error deleting backup restore result file: Boom!" + in caplog.text + ) diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 516dacd5f3d..c2513168ab9 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -205,8 +205,10 @@ async def test_agents_list_backups_fail_cloud( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } diff --git a/tests/components/onboarding/snapshots/test_views.ambr b/tests/components/onboarding/snapshots/test_views.ambr index 90428055823..b57c6cf96dd 100644 --- a/tests/components/onboarding/snapshots/test_views.ambr +++ b/tests/components/onboarding/snapshots/test_views.ambr @@ -10,9 +10,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': True, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -25,16 +28,17 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), dict({ 'addons': list([ ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + 'size': 0, + }), + }), 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', @@ -47,8 +51,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test 2', - 'protected': False, - 'size': 1, 'with_automatic_settings': None, }), ]), diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 683d2c370f2..98f6426609e 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -798,6 +798,9 @@ async def test_onboarding_backup_info( backups = { "abc123": backup.ManagerBackup( addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")], + agents={ + "backup.local": backup.manager.AgentBackupStatus(protected=True, size=0) + }, backup_id="abc123", date="1970-01-01T00:00:00.000Z", database_included=True, @@ -806,14 +809,14 @@ async def test_onboarding_backup_info( homeassistant_included=True, homeassistant_version="2024.12.0", name="Test", - protected=False, - size=0, - agent_ids=["backup.local"], failed_agent_ids=[], with_automatic_settings=True, ), "def456": backup.ManagerBackup( addons=[], + agents={ + "test.remote": backup.manager.AgentBackupStatus(protected=True, size=0) + }, backup_id="def456", date="1980-01-01T00:00:00.000Z", database_included=False, @@ -825,9 +828,6 @@ async def test_onboarding_backup_info( homeassistant_included=True, homeassistant_version="2024.12.0", name="Test 2", - protected=False, - size=1, - agent_ids=["test.remote"], failed_agent_ids=[], with_automatic_settings=None, ), diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 0d4fd0dc080..cdbc5934c5f 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -332,8 +332,10 @@ async def test_agents_list_backups_error( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } diff --git a/tests/test_backup_restore.py b/tests/test_backup_restore.py index 10ea64a6a61..4c6bc930667 100644 --- a/tests/test_backup_restore.py +++ b/tests/test_backup_restore.py @@ -1,7 +1,10 @@ """Test methods in backup_restore.""" +from collections.abc import Generator +import json from pathlib import Path import tarfile +from typing import Any from unittest import mock import pytest @@ -11,6 +14,23 @@ from homeassistant import backup_restore from .common import get_test_config_dir +@pytest.fixture(autouse=True) +def remove_restore_result_file() -> Generator[None, Any, Any]: + """Remove the restore result file.""" + yield + Path(get_test_config_dir(".HA_RESTORE_RESULT")).unlink(missing_ok=True) + + +def restore_result_file_content() -> dict[str, Any] | None: + """Return the content of the restore result file.""" + try: + return json.loads( + Path(get_test_config_dir(".HA_RESTORE_RESULT")).read_text("utf-8") + ) + except FileNotFoundError: + return None + + @pytest.mark.parametrize( ("side_effect", "content", "expected"), [ @@ -87,6 +107,11 @@ def test_restoring_backup_that_does_not_exist() -> None: ), ): assert backup_restore.restore_backup(Path(get_test_config_dir())) is False + assert restore_result_file_content() == { + "error": f"Backup file {backup_file_path} does not exist", + "error_type": "ValueError", + "success": False, + } def test_restoring_backup_when_instructions_can_not_be_read() -> None: @@ -98,6 +123,7 @@ def test_restoring_backup_when_instructions_can_not_be_read() -> None: ), ): assert backup_restore.restore_backup(Path(get_test_config_dir())) is False + assert restore_result_file_content() is None def test_restoring_backup_that_is_not_a_file() -> None: @@ -121,6 +147,11 @@ def test_restoring_backup_that_is_not_a_file() -> None: ), ): assert backup_restore.restore_backup(Path(get_test_config_dir())) is False + assert restore_result_file_content() == { + "error": f"Backup file {backup_file_path} does not exist", + "error_type": "ValueError", + "success": False, + } def test_aborting_for_older_versions() -> None: @@ -152,6 +183,13 @@ def test_aborting_for_older_versions() -> None: ), ): assert backup_restore.restore_backup(config_dir) is True + assert restore_result_file_content() == { + "error": ( + "You need at least Home Assistant version 9999.99.99 to restore this backup" + ), + "error_type": "ValueError", + "success": False, + } @pytest.mark.parametrize( @@ -280,6 +318,11 @@ def test_removal_of_current_configuration_when_restoring( assert removed_directories == { Path(config_dir, d) for d in expected_removed_directories } + assert restore_result_file_content() == { + "error": None, + "error_type": None, + "success": True, + } def test_extracting_the_contents_of_a_backup_file() -> None: @@ -332,6 +375,11 @@ def test_extracting_the_contents_of_a_backup_file() -> None: assert { member.name for member in extractall_mock.mock_calls[-1].kwargs["members"] } == {"data", "data/.HA_VERSION", "data/.storage", "data/www"} + assert restore_result_file_content() == { + "error": None, + "error_type": None, + "success": True, + } @pytest.mark.parametrize( @@ -362,6 +410,11 @@ def test_remove_backup_file_after_restore( assert mock_unlink.call_count == unlink_calls for call in mock_unlink.mock_calls: assert call.args[0] == backup_file_path + assert restore_result_file_content() == { + "error": None, + "error_type": None, + "success": True, + } @pytest.mark.parametrize( From fa6df1cc2525241dd6672eee43983d5ccdac1531 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 29 Jan 2025 17:15:54 +0100 Subject: [PATCH 1202/2987] Check for fullcolorsupport in fritzbox light (#136850) --- homeassistant/components/fritzbox/light.py | 25 ++++++++-------------- tests/components/fritzbox/test_light.py | 23 +++++--------------- 2 files changed, 14 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index f6a1ba4cc94..94d7d320704 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -4,8 +4,6 @@ from __future__ import annotations from typing import Any, cast -from requests.exceptions import HTTPError - from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -124,27 +122,22 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): level = kwargs[ATTR_BRIGHTNESS] await self.hass.async_add_executor_job(self.data.set_level, level, True) if kwargs.get(ATTR_HS_COLOR) is not None: - # Try setunmappedcolor first. This allows free color selection, - # but we don't know if its supported by all devices. - try: - # HA gives 0..360 for hue, fritz light only supports 0..359 - unmapped_hue = int(kwargs[ATTR_HS_COLOR][0] % 360) - unmapped_saturation = round( - cast(float, kwargs[ATTR_HS_COLOR][1]) * 255.0 / 100.0 - ) + # HA gives 0..360 for hue, fritz light only supports 0..359 + unmapped_hue = int(kwargs[ATTR_HS_COLOR][0] % 360) + unmapped_saturation = round( + cast(float, kwargs[ATTR_HS_COLOR][1]) * 255.0 / 100.0 + ) + if self.data.fullcolorsupport: + LOGGER.debug("device has fullcolorsupport, using 'setunmappedcolor'") await self.hass.async_add_executor_job( self.data.set_unmapped_color, (unmapped_hue, unmapped_saturation), 0, True, ) - # This will raise 400 BAD REQUEST if the setunmappedcolor is not available - except HTTPError as err: - if err.response.status_code != 400: - raise + else: LOGGER.debug( - "fritzbox does not support method 'setunmappedcolor', fallback to" - " 'setcolor'" + "device has no fullcolorsupport, using supported colors with 'setcolor'" ) # find supported hs values closest to what user selected hue = min( diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 47209075a86..fe8bb32066e 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -3,7 +3,6 @@ from datetime import timedelta from unittest.mock import Mock, call -import pytest from requests.exceptions import HTTPError from homeassistant.components.fritzbox.const import ( @@ -166,6 +165,7 @@ async def test_turn_on_color(hass: HomeAssistant, fritz: Mock) -> None: device.get_colors.return_value = { "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] } + device.fullcolorsupport = True assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -178,13 +178,14 @@ async def test_turn_on_color(hass: HomeAssistant, fritz: Mock) -> None: assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_unmapped_color.call_count == 1 + assert device.set_color.call_count == 0 assert device.set_level.call_args_list == [call(100, True)] assert device.set_unmapped_color.call_args_list == [ call((100, round(70 * 255.0 / 100.0)), 0, True) ] -async def test_turn_on_color_unsupported_api_method( +async def test_turn_on_color_no_fullcolorsupport( hass: HomeAssistant, fritz: Mock ) -> None: """Test turn device on in mapped color mode if unmapped is not supported.""" @@ -193,16 +194,11 @@ async def test_turn_on_color_unsupported_api_method( device.get_colors.return_value = { "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] } + device.fullcolorsupport = False assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - # test fallback to `setcolor` - error = HTTPError("Bad Request") - error.response = Mock() - error.response.status_code = 400 - device.set_unmapped_color.side_effect = error - await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -212,19 +208,10 @@ async def test_turn_on_color_unsupported_api_method( assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_color.call_count == 1 + assert device.set_unmapped_color.call_count == 0 assert device.set_level.call_args_list == [call(100, True)] assert device.set_color.call_args_list == [call((100, 70), 0, True)] - # test for unknown error - error.response.status_code = 500 - with pytest.raises(HTTPError, match="Bad Request"): - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 100, ATTR_HS_COLOR: (100, 70)}, - True, - ) - async def test_turn_off(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device off.""" From 35e395277058b9d2c078f0f9ee08824a6e8cde06 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Wed, 29 Jan 2025 09:28:09 -0700 Subject: [PATCH 1203/2987] Add DHCP discovery to balboa (#136762) --- .../components/balboa/config_flow.py | 47 ++++++- homeassistant/components/balboa/manifest.json | 8 ++ homeassistant/components/balboa/strings.json | 4 + homeassistant/generated/dhcp.py | 8 ++ tests/components/balboa/test_config_flow.py | 124 +++++++++++++++++- 5 files changed, 182 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/balboa/config_flow.py b/homeassistant/components/balboa/config_flow.py index fccfeceb331..24375ad4e55 100644 --- a/homeassistant/components/balboa/config_flow.py +++ b/homeassistant/components/balboa/config_flow.py @@ -10,7 +10,7 @@ from pybalboa.exceptions import SpaConnectionError import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import format_mac @@ -18,6 +18,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, SchemaOptionsFlowHandler, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_SYNC_TIME, DOMAIN @@ -55,7 +56,8 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - _host: str | None + _host: str + _model: str @staticmethod @callback @@ -63,6 +65,43 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) + + error = None + try: + info = await validate_input({CONF_HOST: discovery_info.ip}) + except CannotConnect: + error = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + error = "unknown" + if not error: + self._host = discovery_info.ip + self._model = info["title"] + self.context["title_placeholders"] = {CONF_MODEL: self._model} + return await self.async_step_discovery_confirm() + return self.async_abort(reason=error) + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Allow the user to confirm adding the device.""" + if user_input is not None: + data = {CONF_HOST: self._host} + return self.async_create_entry(title=self._model, data=data) + + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={CONF_HOST: self._host}, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -78,7 +117,9 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(info["formatted_mac"]) + await self.async_set_unique_id( + info["formatted_mac"], raise_on_progress=False + ) self._abort_if_unique_id_configured() return self.async_create_entry(title=info["title"], data=user_input) diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json index d7c15bab88f..867e277358c 100644 --- a/homeassistant/components/balboa/manifest.json +++ b/homeassistant/components/balboa/manifest.json @@ -3,6 +3,14 @@ "name": "Balboa Spa Client", "codeowners": ["@garbled1", "@natekspencer"], "config_flow": true, + "dhcp": [ + { + "registered_devices": true + }, + { + "macaddress": "001527*" + } + ], "documentation": "https://www.home-assistant.io/integrations/balboa", "iot_class": "local_push", "loggers": ["pybalboa"], diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 6ced7dfd8c3..c00567a6052 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -1,5 +1,6 @@ { "config": { + "flow_title": "{model}", "step": { "user": { "description": "Connect to the Balboa Wi-Fi device", @@ -9,6 +10,9 @@ "data_description": { "host": "Hostname or IP address of your Balboa Spa Wi-Fi Device. For example, 192.168.1.58." } + }, + "confirm_discovery": { + "description": "Do you want to set up the spa at {host}?" } }, "error": { diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 7d14ab0f444..b9d51ac1006 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -61,6 +61,14 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "axis-e82725*", "macaddress": "E82725*", }, + { + "domain": "balboa", + "registered_devices": True, + }, + { + "domain": "balboa", + "macaddress": "001527*", + }, { "domain": "blink", "hostname": "blink*", diff --git a/tests/components/balboa/test_config_flow.py b/tests/components/balboa/test_config_flow.py index afa170577df..d81edaad3b4 100644 --- a/tests/components/balboa/test_config_flow.py +++ b/tests/components/balboa/test_config_flow.py @@ -3,19 +3,23 @@ from unittest.mock import MagicMock, patch from pybalboa.exceptions import SpaConnectionError +import pytest from homeassistant import config_entries from homeassistant.components.balboa.const import CONF_SYNC_TIME, DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry -TEST_DATA = { - CONF_HOST: "1.1.1.1", -} -TEST_ID = "FakeBalboa" +TEST_HOST = "1.1.1.1" +TEST_DATA = {CONF_HOST: TEST_HOST} +TEST_MAC = "ef:ef:ef:c0:ff:ee" +TEST_DHCP_SERVICE_INFO = DhcpServiceInfo( + ip=TEST_HOST, macaddress=TEST_MAC.replace(":", ""), hostname="fakespa" +) async def test_form(hass: HomeAssistant, client: MagicMock) -> None: @@ -107,7 +111,7 @@ async def test_unknown_error(hass: HomeAssistant, client: MagicMock) -> None: async def test_already_configured(hass: HomeAssistant, client: MagicMock) -> None: """Test when provided credentials are already configured.""" - MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID).add_to_hass(hass) + MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_MAC).add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -138,7 +142,7 @@ async def test_already_configured(hass: HomeAssistant, client: MagicMock) -> Non async def test_options_flow(hass: HomeAssistant, client: MagicMock) -> None: """Test specifying non default settings using options flow.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID) + config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_MAC) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -161,3 +165,111 @@ async def test_options_flow(hass: HomeAssistant, client: MagicMock) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert dict(config_entry.options) == {CONF_SYNC_TIME: True} + + +async def test_dhcp_discovery(hass: HomeAssistant, client: MagicMock) -> None: + """Test we can process the discovery from dhcp.""" + with patch( + "homeassistant.components.balboa.config_flow.SpaClient.__aenter__", + return_value=client, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "FakeSpa" + assert result["data"] == TEST_DATA + assert result["result"].unique_id == TEST_MAC + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery_updates_host( + hass: HomeAssistant, client: MagicMock +) -> None: + """Test dhcp discovery updates host and aborts.""" + entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_MAC) + entry.add_to_hass(hass) + + updated_ip = "1.1.1.2" + TEST_DHCP_SERVICE_INFO.ip = updated_ip + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert entry.data[CONF_HOST] == updated_ip + + +@pytest.mark.parametrize( + ("side_effect", "reason"), + [ + (SpaConnectionError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_dhcp_discovery_failed( + hass: HomeAssistant, client: MagicMock, side_effect: Exception, reason: str +) -> None: + """Test failed setup from dhcp.""" + with patch( + "homeassistant.components.balboa.config_flow.SpaClient.__aenter__", + return_value=client, + side_effect=side_effect(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_dhcp_discovery_manual_user_setup( + hass: HomeAssistant, client: MagicMock +) -> None: + """Test dhcp discovery with manual user setup.""" + with patch( + "homeassistant.components.balboa.config_flow.SpaClient.__aenter__", + return_value=client, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == TEST_DATA From 63f34e346a7a78e5d9e1d9265ab20d334dcb7157 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 29 Jan 2025 17:28:32 +0100 Subject: [PATCH 1204/2987] Fix spelling of "API" for consistency in Home Assistant UI (#136843) --- homeassistant/components/fivem/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fivem/strings.json b/homeassistant/components/fivem/strings.json index fd58922a481..f925a625259 100644 --- a/homeassistant/components/fivem/strings.json +++ b/homeassistant/components/fivem/strings.json @@ -14,7 +14,7 @@ }, "error": { "cannot_connect": "Failed to connect. Please check the host and port and try again. Also ensure that you are running the latest FiveM server.", - "invalid_game_name": "The api of the game you are trying to connect to is not a FiveM game.", + "invalid_game_name": "The API of the game you are trying to connect to is not a FiveM game.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { From acbf40c384f6420ead8ca1bd5e0bd232cb06c3a4 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 29 Jan 2025 17:33:31 +0100 Subject: [PATCH 1205/2987] Update frontend to 20250129.0 (#136852) --- 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 2724569d1ed..f4e426485c8 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==20250109.2"] + "requirements": ["home-assistant-frontend==20250129.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f29c00244a6..6d9e8f43755 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.14.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250109.2 +home-assistant-frontend==20250129.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 05d040af2b0..0ab09a60906 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,7 +1143,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250109.2 +home-assistant-frontend==20250129.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 236b908f6d1..e2ab69f3cf2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250109.2 +home-assistant-frontend==20250129.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From 72caf9d5a240b0719af5fe7e615c3c10fb59e090 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 29 Jan 2025 17:41:28 +0100 Subject: [PATCH 1206/2987] Tweak Matter discovery to ignore empty lists (#136854) --- homeassistant/components/matter/discovery.py | 84 ++++++++++----- homeassistant/components/matter/models.py | 37 +++++-- homeassistant/components/matter/select.py | 22 ++++ homeassistant/components/matter/sensor.py | 12 +++ .../matter/snapshots/test_select.ambr | 102 ------------------ 5 files changed, 119 insertions(+), 138 deletions(-) diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 7ca64482763..7102b693e45 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -19,7 +19,7 @@ from .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS from .fan import DISCOVERY_SCHEMAS as FAN_SCHEMAS from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS from .lock import DISCOVERY_SCHEMAS as LOCK_SCHEMAS -from .models import MatterDiscoverySchema, MatterEntityInfo +from .models import UNSET, MatterDiscoverySchema, MatterEntityInfo from .number import DISCOVERY_SCHEMAS as NUMBER_SCHEMAS from .select import DISCOVERY_SCHEMAS as SELECT_SCHEMAS from .sensor import DISCOVERY_SCHEMAS as SENSOR_SCHEMAS @@ -67,6 +67,8 @@ def async_discover_entities( if any(x in schema.required_attributes for x in discovered_attributes): continue + primary_attribute = schema.required_attributes[0] + # check vendor_id if ( schema.vendor_id is not None @@ -121,31 +123,6 @@ def async_discover_entities( ): continue - # check if value exists but is none/null - if not schema.allow_none_value and any( - endpoint.get_attribute_value(None, val_schema) in (None, NullValue) - for val_schema in schema.required_attributes - ): - continue - - # check for required value in (primary) attribute - primary_attribute = schema.required_attributes[0] - primary_value = endpoint.get_attribute_value(None, primary_attribute) - if schema.value_contains is not None and ( - isinstance(primary_value, list) - and schema.value_contains not in primary_value - ): - continue - - # check for value that may not be present - if schema.value_is_not is not None and ( - schema.value_is_not == primary_value - or ( - isinstance(primary_value, list) and schema.value_is_not in primary_value - ) - ): - continue - # check for required value in cluster featuremap if schema.featuremap_contains is not None and ( not bool( @@ -159,6 +136,61 @@ def async_discover_entities( ): continue + # BEGIN checks on actual attribute values + # these are the least likely to be used and least efficient, so they are checked last + + # check if PRIMARY value exists but is none/null + if not schema.allow_none_value and any( + endpoint.get_attribute_value(None, val_schema) in (None, NullValue) + for val_schema in schema.required_attributes + ): + continue + + # check for required value in PRIMARY attribute + primary_value = endpoint.get_attribute_value(None, primary_attribute) + if schema.value_contains is not UNSET and ( + isinstance(primary_value, list) + and schema.value_contains not in primary_value + ): + continue + + # check for value that may not be present in PRIMARY attribute + if schema.value_is_not is not UNSET and ( + schema.value_is_not == primary_value + or ( + isinstance(primary_value, list) and schema.value_is_not in primary_value + ) + ): + continue + + # check for value that may not be present in SECONDARY attribute + secondary_attribute = ( + schema.required_attributes[1] + if len(schema.required_attributes) > 1 + else None + ) + secondary_value = ( + endpoint.get_attribute_value(None, secondary_attribute) + if secondary_attribute + else None + ) + if schema.secondary_value_is_not is not UNSET and ( + (schema.secondary_value_is_not == secondary_value) + or ( + isinstance(secondary_value, list) + and schema.secondary_value_is_not in secondary_value + ) + ): + continue + + # check for required value in SECONDARY attribute + if schema.secondary_value_contains is not UNSET and ( + isinstance(secondary_value, list) + and schema.secondary_value_contains not in secondary_value + ): + continue + + # FINISH all validation checks # all checks passed, this value belongs to an entity attributes_to_watch = list(schema.required_attributes) diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index ea80d0eb903..4af7cc3c026 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -18,6 +18,14 @@ type SensorValueTypes = type[ ] +# A sentinel object to detect if a parameter is supplied or not. +class _UNSET_TYPE: + pass + + +UNSET = _UNSET_TYPE() + + class MatterDeviceInfo(TypedDict): """Dictionary with Matter Device info. @@ -111,16 +119,6 @@ class MatterDiscoverySchema: # are not discovered by other entities optional_attributes: tuple[type[ClusterAttributeDescriptor], ...] | None = None - # [optional] the primary attribute value must contain this value - # for example for the AcceptedCommandList - # NOTE: only works for list values - value_contains: Any | None = None - - # [optional] the primary attribute value must NOT have this value - # for example to filter out invalid values (such as empty string instead of null) - # in case of a list value, the list may not contain this value - value_is_not: Any | None = None - # [optional] the primary attribute's cluster featuremap must contain this value # for example for the DoorSensor on a DoorLock Cluster featuremap_contains: int | None = None @@ -131,3 +129,22 @@ class MatterDiscoverySchema: # [optional] the primary attribute value may not be null/None allow_none_value: bool = False + + # [optional] the primary attribute value must contain this value + # for example for the AcceptedCommandList + # NOTE: only works for list values + value_contains: Any = UNSET + + # [optional] the secondary (required) attribute value must contain this value + # for example for the AcceptedCommandList + # NOTE: only works for list values + secondary_value_contains: Any = UNSET + + # [optional] the primary attribute value must NOT have this value + # for example to filter out invalid values (such as empty string instead of null) + # in case of a list value, the list may not contain this value + value_is_not: Any = UNSET + + # [optional] the secondary (required) attribute value must NOT have this value + # for example to filter out empty lists in list sensor values + secondary_value_is_not: Any = UNSET diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index ab3e708d7a9..dd4f8314bef 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -217,6 +217,8 @@ DISCOVERY_SCHEMAS = [ clusters.ModeSelect.Attributes.CurrentMode, clusters.ModeSelect.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -229,6 +231,8 @@ DISCOVERY_SCHEMAS = [ clusters.OvenMode.Attributes.CurrentMode, clusters.OvenMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -241,6 +245,8 @@ DISCOVERY_SCHEMAS = [ clusters.LaundryWasherMode.Attributes.CurrentMode, clusters.LaundryWasherMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -253,6 +259,8 @@ DISCOVERY_SCHEMAS = [ clusters.RefrigeratorAndTemperatureControlledCabinetMode.Attributes.CurrentMode, clusters.RefrigeratorAndTemperatureControlledCabinetMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -265,6 +273,8 @@ DISCOVERY_SCHEMAS = [ clusters.RvcCleanMode.Attributes.CurrentMode, clusters.RvcCleanMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -277,6 +287,8 @@ DISCOVERY_SCHEMAS = [ clusters.DishwasherMode.Attributes.CurrentMode, clusters.DishwasherMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -289,6 +301,8 @@ DISCOVERY_SCHEMAS = [ clusters.EnergyEvseMode.Attributes.CurrentMode, clusters.EnergyEvseMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -301,6 +315,8 @@ DISCOVERY_SCHEMAS = [ clusters.DeviceEnergyManagementMode.Attributes.CurrentMode, clusters.DeviceEnergyManagementMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -384,6 +400,8 @@ DISCOVERY_SCHEMAS = [ clusters.TemperatureControl.Attributes.SelectedTemperatureLevel, clusters.TemperatureControl.Attributes.SupportedTemperatureLevels, ), + # don't discover this entry if the supported levels list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -397,6 +415,8 @@ DISCOVERY_SCHEMAS = [ clusters.LaundryWasherControls.Attributes.SpinSpeedCurrent, clusters.LaundryWasherControls.Attributes.SpinSpeeds, ), + # don't discover this entry if the spinspeeds list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -412,5 +432,7 @@ DISCOVERY_SCHEMAS = [ clusters.LaundryWasherControls.Attributes.NumberOfRinses, clusters.LaundryWasherControls.Attributes.SupportedRinses, ), + # don't discover this entry if the supported rinses list is empty + secondary_value_is_not=[], ), ] diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index eaab91136c9..3503e112db5 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -809,6 +809,8 @@ DISCOVERY_SCHEMAS = [ clusters.OperationalState.Attributes.OperationalState, clusters.OperationalState.Attributes.OperationalStateList, ), + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -822,6 +824,8 @@ DISCOVERY_SCHEMAS = [ clusters.OperationalState.Attributes.CurrentPhase, clusters.OperationalState.Attributes.PhaseList, ), + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -835,6 +839,8 @@ DISCOVERY_SCHEMAS = [ clusters.RvcOperationalState.Attributes.CurrentPhase, clusters.RvcOperationalState.Attributes.PhaseList, ), + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -848,6 +854,8 @@ DISCOVERY_SCHEMAS = [ clusters.OvenCavityOperationalState.Attributes.CurrentPhase, clusters.OvenCavityOperationalState.Attributes.PhaseList, ), + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -877,6 +885,8 @@ DISCOVERY_SCHEMAS = [ clusters.RvcOperationalState.Attributes.OperationalStateList, ), allow_multi=True, # also used for vacuum entity + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -891,5 +901,7 @@ DISCOVERY_SCHEMAS = [ clusters.OvenCavityOperationalState.Attributes.OperationalState, clusters.OvenCavityOperationalState.Attributes.OperationalStateList, ), + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], ), ] diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index e9aa169b4fd..d7ddf636ff9 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -1518,108 +1518,6 @@ 'state': 'previous', }) # --- -# name: test_selects[silabs_dishwasher][select.dishwasher_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.dishwasher_mode', - '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': 'Mode', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mode', - 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-MatterDishwasherMode-89-1', - 'unit_of_measurement': None, - }) -# --- -# name: test_selects[silabs_dishwasher][select.dishwasher_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dishwasher Mode', - 'options': list([ - ]), - }), - 'context': , - 'entity_id': 'select.dishwasher_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_selects[silabs_laundrywasher][select.laundrywasher_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.laundrywasher_mode', - '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': 'Mode', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mode', - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-MatterLaundryWasherMode-81-1', - 'unit_of_measurement': None, - }) -# --- -# name: test_selects[silabs_laundrywasher][select.laundrywasher_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'LaundryWasher Mode', - 'options': list([ - ]), - }), - 'context': , - 'entity_id': 'select.laundrywasher_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_selects[silabs_laundrywasher][select.laundrywasher_number_of_rinses-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 3118831557e461628607ef0f6bfd7977c54817fb Mon Sep 17 00:00:00 2001 From: Tomer <57483589+tomer-w@users.noreply.github.com> Date: Wed, 29 Jan 2025 18:43:25 +0200 Subject: [PATCH 1207/2987] Ease understanding of integration failures (#134475) Co-authored-by: Shay Levy Co-authored-by: David Bonnes --- homeassistant/helpers/entity_platform.py | 5 +++-- homeassistant/setup.py | 4 ++-- tests/components/evohome/test_init.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 0d7614c569c..c8cc6979226 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -426,11 +426,12 @@ class EntityPlatform: type(exc).__name__, ) return False - except Exception: + except Exception as exc: logger.exception( - "Error while setting up %s platform for %s", + "Error while setting up %s platform for %s: %s", self.platform_name, self.domain, + exc, # noqa: TRY401 ) return False else: diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 331389da7c6..1fa93a80cd5 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -425,8 +425,8 @@ async def _async_setup_component( ) return False # pylint: disable-next=broad-except - except (asyncio.CancelledError, SystemExit, Exception): - _LOGGER.exception("Error during setup of component %s", domain) + except (asyncio.CancelledError, SystemExit, Exception) as exc: + _LOGGER.exception("Error during setup of component %s: %s", domain, exc) # noqa: TRY401 async_notify_setup_error(hass, domain, integration.documentation) return False finally: diff --git a/tests/components/evohome/test_init.py b/tests/components/evohome/test_init.py index 49a854016ea..9b5fe6ad62d 100644 --- a/tests/components/evohome/test_init.py +++ b/tests/components/evohome/test_init.py @@ -25,7 +25,7 @@ SETUP_FAILED_ANTICIPATED = ( SETUP_FAILED_UNEXPECTED = ( "homeassistant.setup", logging.ERROR, - "Error during setup of component evohome", + "Error during setup of component evohome: ", ) AUTHENTICATION_FAILED = ( "homeassistant.components.evohome.helpers", From 660653e226fa0d672c5837be85744ff1745ad7a3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 17:44:29 +0100 Subject: [PATCH 1208/2987] Interrupt _CipherBackupStreamer workers (#136845) * Interrupt _CipherBackupStreamer workers * Fix cleanup * Only abort live threads --- homeassistant/components/backup/util.py | 94 +++++++++++++++---------- 1 file changed, 57 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index bea3fe1f4ef..2416aa5f28e 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -12,7 +12,6 @@ import os from pathlib import Path, PurePath from queue import SimpleQueue import tarfile -import threading from typing import IO, Any, Self, cast import aiohttp @@ -22,6 +21,7 @@ from homeassistant.backup_restore import password_to_key from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.util.json import JsonObjectType, json_loads_object +from homeassistant.util.thread import ThreadWithException from .const import BUF_SIZE, LOGGER from .models import AddonInfo, AgentBackup, Folder @@ -57,6 +57,12 @@ class BackupEmpty(DecryptError): _message = "No tar files found in the backup." +class AbortCipher(HomeAssistantError): + """Abort the cipher operation.""" + + _message = "Abort cipher operation." + + def make_backup_dir(path: Path) -> None: """Create a backup directory if it does not exist.""" path.mkdir(exist_ok=True) @@ -252,24 +258,29 @@ def decrypt_backup( """Decrypt a backup.""" error: Exception | None = None try: - with ( - tarfile.open( - fileobj=input_stream, mode="r|", bufsize=BUF_SIZE - ) as input_tar, - tarfile.open( - fileobj=output_stream, mode="w|", bufsize=BUF_SIZE - ) as output_tar, - ): - _decrypt_backup(input_tar, output_tar, password) - except (DecryptError, SecureTarError, tarfile.TarError) as err: - LOGGER.warning("Error decrypting backup: %s", err) - error = err - else: - # Pad the output stream to the requested minimum size - padding = max(minimum_size - output_stream.tell(), 0) - output_stream.write(b"\0" * padding) + try: + with ( + tarfile.open( + fileobj=input_stream, mode="r|", bufsize=BUF_SIZE + ) as input_tar, + tarfile.open( + fileobj=output_stream, mode="w|", bufsize=BUF_SIZE + ) as output_tar, + ): + _decrypt_backup(input_tar, output_tar, password) + except (DecryptError, SecureTarError, tarfile.TarError) as err: + LOGGER.warning("Error decrypting backup: %s", err) + error = err + else: + # Pad the output stream to the requested minimum size + padding = max(minimum_size - output_stream.tell(), 0) + output_stream.write(b"\0" * padding) + finally: + # Write an empty chunk to signal the end of the stream + output_stream.write(b"") + except AbortCipher: + LOGGER.debug("Cipher operation aborted") finally: - output_stream.write(b"") # Write an empty chunk to signal the end of the stream on_done(error) @@ -322,24 +333,29 @@ def encrypt_backup( """Encrypt a backup.""" error: Exception | None = None try: - with ( - tarfile.open( - fileobj=input_stream, mode="r|", bufsize=BUF_SIZE - ) as input_tar, - tarfile.open( - fileobj=output_stream, mode="w|", bufsize=BUF_SIZE - ) as output_tar, - ): - _encrypt_backup(input_tar, output_tar, password, nonces) - except (EncryptError, SecureTarError, tarfile.TarError) as err: - LOGGER.warning("Error encrypting backup: %s", err) - error = err - else: - # Pad the output stream to the requested minimum size - padding = max(minimum_size - output_stream.tell(), 0) - output_stream.write(b"\0" * padding) + try: + with ( + tarfile.open( + fileobj=input_stream, mode="r|", bufsize=BUF_SIZE + ) as input_tar, + tarfile.open( + fileobj=output_stream, mode="w|", bufsize=BUF_SIZE + ) as output_tar, + ): + _encrypt_backup(input_tar, output_tar, password, nonces) + except (EncryptError, SecureTarError, tarfile.TarError) as err: + LOGGER.warning("Error encrypting backup: %s", err) + error = err + else: + # Pad the output stream to the requested minimum size + padding = max(minimum_size - output_stream.tell(), 0) + output_stream.write(b"\0" * padding) + finally: + # Write an empty chunk to signal the end of the stream + output_stream.write(b"") + except AbortCipher: + LOGGER.debug("Cipher operation aborted") finally: - output_stream.write(b"") # Write an empty chunk to signal the end of the stream on_done(error) @@ -387,7 +403,7 @@ def _encrypt_backup( class _CipherWorkerStatus: done: asyncio.Event error: Exception | None = None - thread: threading.Thread + thread: ThreadWithException class _CipherBackupStreamer: @@ -440,7 +456,7 @@ class _CipherBackupStreamer: stream = await self._open_stream() reader = AsyncIteratorReader(self._hass, stream) writer = AsyncIteratorWriter(self._hass) - worker = threading.Thread( + worker = ThreadWithException( target=self._cipher_func, args=[reader, writer, self._password, on_done, self.size(), self._nonces], ) @@ -451,6 +467,10 @@ class _CipherBackupStreamer: async def wait(self) -> None: """Wait for the worker threads to finish.""" + for worker in self._workers: + if not worker.thread.is_alive(): + continue + worker.thread.raise_exc(AbortCipher) await asyncio.gather(*(worker.done.wait() for worker in self._workers)) From 89e6791fee25de98a0038b5d1e5f4e9ba7a64ea8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 17:50:36 +0100 Subject: [PATCH 1209/2987] Use runtime_data in control4 (#136403) --- homeassistant/components/control4/__init__.py | 102 ++++++++++-------- .../components/control4/config_flow.py | 10 +- homeassistant/components/control4/const.py | 8 -- .../components/control4/director_utils.py | 19 ++-- homeassistant/components/control4/entity.py | 9 +- homeassistant/components/control4/light.py | 32 +++--- .../components/control4/media_player.py | 32 +++--- 7 files changed, 101 insertions(+), 111 deletions(-) diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index 8d0eb72a73b..df5771fe5bb 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -2,8 +2,10 @@ from __future__ import annotations +from dataclasses import dataclass import json import logging +from typing import Any from aiohttp import client_exceptions from pyControl4.account import C4Account @@ -25,14 +27,7 @@ from homeassistant.helpers import aiohttp_client, device_registry as dr from .const import ( API_RETRY_TIMES, - CONF_ACCOUNT, - CONF_CONFIG_LISTENER, CONF_CONTROLLER_UNIQUE_ID, - CONF_DIRECTOR, - CONF_DIRECTOR_ALL_ITEMS, - CONF_DIRECTOR_MODEL, - CONF_DIRECTOR_SW_VERSION, - CONF_UI_CONFIGURATION, DEFAULT_SCAN_INTERVAL, DOMAIN, ) @@ -42,6 +37,23 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.LIGHT, Platform.MEDIA_PLAYER] +@dataclass +class Control4RuntimeData: + """Control4 runtime data.""" + + account: C4Account + controller_unique_id: str + director: C4Director + director_all_items: list[dict[str, Any]] + director_model: str + director_sw_version: str + scan_interval: int + ui_configuration: dict[str, Any] | None + + +type Control4ConfigEntry = ConfigEntry[Control4RuntimeData] + + async def call_c4_api_retry(func, *func_args): """Call C4 API function and retry on failure.""" # Ruff doesn't understand this loop - the exception is always raised after the retries @@ -54,10 +66,8 @@ async def call_c4_api_retry(func, *func_args): raise ConfigEntryNotReady(exception) from exception -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> bool: """Set up Control4 from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - entry_data = hass.data[DOMAIN].setdefault(entry.entry_id, {}) account_session = aiohttp_client.async_get_clientsession(hass) config = entry.data @@ -76,10 +86,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: exception, ) return False - entry_data[CONF_ACCOUNT] = account - controller_unique_id = config[CONF_CONTROLLER_UNIQUE_ID] - entry_data[CONF_CONTROLLER_UNIQUE_ID] = controller_unique_id + controller_unique_id: str = config[CONF_CONTROLLER_UNIQUE_ID] director_token_dict = await call_c4_api_retry( account.getDirectorBearerToken, controller_unique_id @@ -89,15 +97,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: director = C4Director( config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session ) - entry_data[CONF_DIRECTOR] = director controller_href = (await call_c4_api_retry(account.getAccountControllers))["href"] - entry_data[CONF_DIRECTOR_SW_VERSION] = await call_c4_api_retry( + director_sw_version = await call_c4_api_retry( account.getControllerOSVersion, controller_href ) _, model, mac_address = controller_unique_id.split("_", 3) - entry_data[CONF_DIRECTOR_MODEL] = model.upper() + director_model = model.upper() device_registry = dr.async_get(hass) device_registry.async_get_or_create( @@ -106,57 +113,60 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: connections={(dr.CONNECTION_NETWORK_MAC, mac_address)}, manufacturer="Control4", name=controller_unique_id, - model=entry_data[CONF_DIRECTOR_MODEL], - sw_version=entry_data[CONF_DIRECTOR_SW_VERSION], + model=director_model, + sw_version=director_sw_version, ) # Store all items found on controller for platforms to use - director_all_items = await director.getAllItemInfo() - director_all_items = json.loads(director_all_items) - entry_data[CONF_DIRECTOR_ALL_ITEMS] = director_all_items - - # Check if OS version is 3 or higher to get UI configuration - entry_data[CONF_UI_CONFIGURATION] = None - if int(entry_data[CONF_DIRECTOR_SW_VERSION].split(".")[0]) >= 3: - entry_data[CONF_UI_CONFIGURATION] = json.loads( - await director.getUiConfiguration() - ) - - # Load options from config entry - entry_data[CONF_SCAN_INTERVAL] = entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + director_all_items: list[dict[str, Any]] = json.loads( + await director.getAllItemInfo() ) - entry_data[CONF_CONFIG_LISTENER] = entry.add_update_listener(update_listener) + # Check if OS version is 3 or higher to get UI configuration + ui_configuration: dict[str, Any] | None = None + if int(director_sw_version.split(".")[0]) >= 3: + ui_configuration = json.loads(await director.getUiConfiguration()) + + # Load options from config entry + scan_interval: int = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + + entry.runtime_data = Control4RuntimeData( + account=account, + controller_unique_id=controller_unique_id, + director=director, + director_all_items=director_all_items, + director_model=director_model, + director_sw_version=director_sw_version, + scan_interval=scan_interval, + ui_configuration=ui_configuration, + ) + + entry.async_on_unload(entry.add_update_listener(update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: Control4ConfigEntry +) -> None: """Update when config_entry options update.""" _LOGGER.debug("Config entry was updated, rerunning setup") await hass.config_entries.async_reload(config_entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - hass.data[DOMAIN][entry.entry_id][CONF_CONFIG_LISTENER]() - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - _LOGGER.debug("Unloaded entry for %s", entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def get_items_of_category(hass: HomeAssistant, entry: ConfigEntry, category: str): +async def get_items_of_category( + hass: HomeAssistant, entry: Control4ConfigEntry, category: str +): """Return a list of all Control4 items with the specified category.""" - director_all_items = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR_ALL_ITEMS] return [ item - for item in director_all_items + for item in entry.runtime_data.director_all_items if "categories" in item and category in item["categories"] ] diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 19fae1ef7ca..3ca96ca4e52 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -11,12 +11,7 @@ from pyControl4.director import C4Director from pyControl4.error_handling import NotFound, Unauthorized import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -28,6 +23,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.device_registry import format_mac +from . import Control4ConfigEntry from .const import ( CONF_CONTROLLER_UNIQUE_ID, DEFAULT_SCAN_INTERVAL, @@ -151,7 +147,7 @@ class Control4ConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: Control4ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() diff --git a/homeassistant/components/control4/const.py b/homeassistant/components/control4/const.py index 57074c00108..2fe9c42849b 100644 --- a/homeassistant/components/control4/const.py +++ b/homeassistant/components/control4/const.py @@ -7,14 +7,6 @@ MIN_SCAN_INTERVAL = 1 API_RETRY_TIMES = 5 -CONF_ACCOUNT = "account" -CONF_DIRECTOR = "director" -CONF_DIRECTOR_SW_VERSION = "director_sw_version" -CONF_DIRECTOR_MODEL = "director_model" -CONF_DIRECTOR_ALL_ITEMS = "director_all_items" -CONF_UI_CONFIGURATION = "ui_configuration" CONF_CONTROLLER_UNIQUE_ID = "controller_unique_id" -CONF_CONFIG_LISTENER = "config_listener" - CONTROL4_ENTITY_TYPE = 7 diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py index 5e57237337c..a26c5f9f413 100644 --- a/homeassistant/components/control4/director_utils.py +++ b/homeassistant/components/control4/director_utils.py @@ -8,21 +8,21 @@ from pyControl4.account import C4Account from pyControl4.director import C4Director from pyControl4.error_handling import BadToken -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import CONF_ACCOUNT, CONF_CONTROLLER_UNIQUE_ID, CONF_DIRECTOR, DOMAIN +from . import Control4ConfigEntry +from .const import CONF_CONTROLLER_UNIQUE_ID _LOGGER = logging.getLogger(__name__) async def _update_variables_for_config_entry( - hass: HomeAssistant, entry: ConfigEntry, variable_names: set[str] + hass: HomeAssistant, entry: Control4ConfigEntry, variable_names: set[str] ) -> dict[int, dict[str, Any]]: """Retrieve data from the Control4 director.""" - director: C4Director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] + director = entry.runtime_data.director data = await director.getAllItemVariableValue(variable_names) result_dict: defaultdict[int, dict[str, Any]] = defaultdict(dict) for item in data: @@ -31,7 +31,7 @@ async def _update_variables_for_config_entry( async def update_variables_for_config_entry( - hass: HomeAssistant, entry: ConfigEntry, variable_names: set[str] + hass: HomeAssistant, entry: Control4ConfigEntry, variable_names: set[str] ) -> dict[int, dict[str, Any]]: """Try to Retrieve data from the Control4 director for update_coordinator.""" try: @@ -42,8 +42,8 @@ async def update_variables_for_config_entry( return await _update_variables_for_config_entry(hass, entry, variable_names) -async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry): - """Store updated authentication and director tokens in hass.data.""" +async def refresh_tokens(hass: HomeAssistant, entry: Control4ConfigEntry): + """Store updated authentication and director tokens in runtime_data.""" config = entry.data account_session = aiohttp_client.async_get_clientsession(hass) @@ -59,6 +59,5 @@ async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry): ) _LOGGER.debug("Saving new tokens in hass data") - entry_data = hass.data[DOMAIN][entry.entry_id] - entry_data[CONF_ACCOUNT] = account - entry_data[CONF_DIRECTOR] = director + entry.runtime_data.account = account + entry.runtime_data.director = director diff --git a/homeassistant/components/control4/entity.py b/homeassistant/components/control4/entity.py index fdb22e6578d..f7ca0e1fabc 100644 --- a/homeassistant/components/control4/entity.py +++ b/homeassistant/components/control4/entity.py @@ -10,7 +10,8 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import CONF_CONTROLLER_UNIQUE_ID, DOMAIN +from . import Control4RuntimeData +from .const import DOMAIN class Control4Entity(CoordinatorEntity[Any]): @@ -18,7 +19,7 @@ class Control4Entity(CoordinatorEntity[Any]): def __init__( self, - entry_data: dict, + runtime_data: Control4RuntimeData, coordinator: DataUpdateCoordinator[Any], name: str | None, idx: int, @@ -29,11 +30,11 @@ class Control4Entity(CoordinatorEntity[Any]): ) -> None: """Initialize a Control4 entity.""" super().__init__(coordinator) - self.entry_data = entry_data + self.runtime_data = runtime_data self._attr_name = name self._attr_unique_id = str(idx) self._idx = idx - self._controller_unique_id = entry_data[CONF_CONTROLLER_UNIQUE_ID] + self._controller_unique_id = runtime_data.controller_unique_id self._device_name = device_name self._device_manufacturer = device_manufacturer self._device_model = device_model diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index 927f4643619..cedfbeb49c3 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -17,14 +17,12 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from . import get_items_of_category -from .const import CONF_DIRECTOR, CONTROL4_ENTITY_TYPE, DOMAIN +from . import Control4ConfigEntry, Control4RuntimeData, get_items_of_category +from .const import CONTROL4_ENTITY_TYPE from .director_utils import update_variables_for_config_entry from .entity import Control4Entity @@ -36,15 +34,13 @@ CONTROL4_DIMMER_VARS = ["LIGHT_LEVEL", "Brightness Percent"] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: Control4ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Control4 lights from a config entry.""" - entry_data = hass.data[DOMAIN][entry.entry_id] - scan_interval = entry_data[CONF_SCAN_INTERVAL] - _LOGGER.debug( - "Scan interval = %s", - scan_interval, - ) + runtime_data = entry.runtime_data + _LOGGER.debug("Scan interval = %s", runtime_data.scan_interval) async def async_update_data_non_dimmer() -> dict[int, dict[str, Any]]: """Fetch data from Control4 director for non-dimmer lights.""" @@ -69,14 +65,14 @@ async def async_setup_entry( _LOGGER, name="light", update_method=async_update_data_non_dimmer, - update_interval=timedelta(seconds=scan_interval), + update_interval=timedelta(seconds=runtime_data.scan_interval), ) dimmer_coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]]( hass, _LOGGER, name="light", update_method=async_update_data_dimmer, - update_interval=timedelta(seconds=scan_interval), + update_interval=timedelta(seconds=runtime_data.scan_interval), ) # Fetch initial data so we have data when entities subscribe @@ -118,7 +114,7 @@ async def async_setup_entry( item_is_dimmer = False item_coordinator = non_dimmer_coordinator else: - director = entry_data[CONF_DIRECTOR] + director = runtime_data.director item_variables = await director.getItemVariables(item_id) _LOGGER.warning( ( @@ -132,7 +128,7 @@ async def async_setup_entry( entity_list.append( Control4Light( - entry_data, + runtime_data, item_coordinator, item_name, item_id, @@ -154,7 +150,7 @@ class Control4Light(Control4Entity, LightEntity): def __init__( self, - entry_data: dict, + runtime_data: Control4RuntimeData, coordinator: DataUpdateCoordinator[dict[int, dict[str, Any]]], name: str, idx: int, @@ -166,7 +162,7 @@ class Control4Light(Control4Entity, LightEntity): ) -> None: """Initialize Control4 light entity.""" super().__init__( - entry_data, + runtime_data, coordinator, name, idx, @@ -188,7 +184,7 @@ class Control4Light(Control4Entity, LightEntity): This exists so the director token used is always the latest one, without needing to re-init the entire entity. """ - return C4Light(self.entry_data[CONF_DIRECTOR], self._idx) + return C4Light(self.runtime_data.director, self._idx) @property def is_on(self): diff --git a/homeassistant/components/control4/media_player.py b/homeassistant/components/control4/media_player.py index 9e3421817a3..bd8e3fb38fe 100644 --- a/homeassistant/components/control4/media_player.py +++ b/homeassistant/components/control4/media_player.py @@ -18,13 +18,11 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_DIRECTOR, CONF_DIRECTOR_ALL_ITEMS, CONF_UI_CONFIGURATION, DOMAIN +from . import Control4ConfigEntry, Control4RuntimeData from .director_utils import update_variables_for_config_entry from .entity import Control4Entity @@ -67,22 +65,23 @@ class _RoomSource: name: str -async def get_rooms(hass: HomeAssistant, entry: ConfigEntry): +async def get_rooms(hass: HomeAssistant, entry: Control4ConfigEntry): """Return a list of all Control4 rooms.""" - director_all_items = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR_ALL_ITEMS] return [ item - for item in director_all_items + for item in entry.runtime_data.director_all_items if "typeName" in item and item["typeName"] == "room" ] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: Control4ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Control4 rooms from a config entry.""" - entry_data = hass.data[DOMAIN][entry.entry_id] - ui_config = entry_data[CONF_UI_CONFIGURATION] + runtime_data = entry.runtime_data + ui_config = runtime_data.ui_configuration # OS 2 will not have a ui_configuration if not ui_config: @@ -93,7 +92,7 @@ async def async_setup_entry( if not all_rooms: return - scan_interval = entry_data[CONF_SCAN_INTERVAL] + scan_interval = runtime_data.scan_interval _LOGGER.debug("Scan interval = %s", scan_interval) async def async_update_data() -> dict[int, dict[str, Any]]: @@ -116,10 +115,7 @@ async def async_setup_entry( # Fetch initial data so we have data when entities subscribe await coordinator.async_refresh() - items_by_id = { - item["id"]: item - for item in hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR_ALL_ITEMS] - } + items_by_id = {item["id"]: item for item in runtime_data.director_all_items} item_to_parent_map = { k: item["parentId"] for k, item in items_by_id.items() @@ -156,7 +152,7 @@ async def async_setup_entry( hidden = room["roomHidden"] entity_list.append( Control4Room( - entry_data, + runtime_data, coordinator, room["name"], room_id, @@ -182,7 +178,7 @@ class Control4Room(Control4Entity, MediaPlayerEntity): def __init__( self, - entry_data: dict, + runtime_data: Control4RuntimeData, coordinator: DataUpdateCoordinator[dict[int, dict[str, Any]]], name: str, room_id: int, @@ -192,7 +188,7 @@ class Control4Room(Control4Entity, MediaPlayerEntity): ) -> None: """Initialize Control4 room entity.""" super().__init__( - entry_data, + runtime_data, coordinator, None, room_id, @@ -220,7 +216,7 @@ class Control4Room(Control4Entity, MediaPlayerEntity): This exists so the director token used is always the latest one, without needing to re-init the entire entity. """ - return C4Room(self.entry_data[CONF_DIRECTOR], self._idx) + return C4Room(self.runtime_data.director, self._idx) def _get_device_from_variable(self, var: str) -> int | None: current_device = self.coordinator.data[self._idx][var] From a61399f18975c3c0de69b762a9bd05f3cc674c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 29 Jan 2025 17:09:44 +0000 Subject: [PATCH 1210/2987] Simplify Whirlpool auth flows (#136856) --- .../components/whirlpool/config_flow.py | 74 +++++++------------ .../components/whirlpool/test_config_flow.py | 25 +++++-- 2 files changed, 46 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index 069a5ca1e4f..44445dee03f 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -15,7 +15,6 @@ from whirlpool.backendselector import BackendSelector from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_BRAND, CONF_BRANDS_MAP, CONF_REGIONS_MAP, DOMAIN @@ -40,31 +39,39 @@ REAUTH_SCHEMA = vol.Schema( ) -async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, str]: - """Validate the user input allows us to connect. +async def authenticate( + hass: HomeAssistant, data: dict[str, str], check_appliances_exist: bool +) -> str | None: + """Authenticate with the api. - Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + Returns the error translation key if authentication fails, or None on success. """ session = async_get_clientsession(hass) region = CONF_REGIONS_MAP[data[CONF_REGION]] brand = CONF_BRANDS_MAP[data[CONF_BRAND]] backend_selector = BackendSelector(brand, region) auth = Auth(backend_selector, data[CONF_USERNAME], data[CONF_PASSWORD], session) + try: await auth.do_auth() - except (TimeoutError, ClientError) as exc: - raise CannotConnect from exc + except (TimeoutError, ClientError): + return "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + return "unknown" if not auth.is_access_token_valid(): - raise InvalidAuth + return "invalid_auth" - appliances_manager = AppliancesManager(backend_selector, auth, session) - await appliances_manager.fetch_appliances() + if check_appliances_exist: + appliances_manager = AppliancesManager(backend_selector, auth, session) + await appliances_manager.fetch_appliances() - if not appliances_manager.aircons and not appliances_manager.washer_dryers: - raise NoAppliances + if not appliances_manager.aircons and not appliances_manager.washer_dryers: + return "no_appliances" - return {"title": data[CONF_USERNAME]} + return None class WhirlpoolConfigFlow(ConfigFlow, domain=DOMAIN): @@ -90,14 +97,10 @@ class WhirlpoolConfigFlow(ConfigFlow, domain=DOMAIN): brand = user_input[CONF_BRAND] data = {**reauth_entry.data, CONF_PASSWORD: password, CONF_BRAND: brand} - try: - await validate_input(self.hass, data) - except InvalidAuth: - errors["base"] = "invalid_auth" - except (CannotConnect, TimeoutError): - errors["base"] = "cannot_connect" - else: + error_key = await authenticate(self.hass, data, False) + if not error_key: return self.async_update_reload_and_abort(reauth_entry, data=data) + errors["base"] = error_key return self.async_show_form( step_id="reauth_confirm", @@ -113,38 +116,17 @@ class WhirlpoolConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) - errors = {} - - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except NoAppliances: - errors["base"] = "no_appliances" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + error_key = await authenticate(self.hass, user_input, True) + if not error_key: await self.async_set_unique_id( user_input[CONF_USERNAME].lower(), raise_on_progress=False ) self._abort_if_unique_id_configured() - return self.async_create_entry(title=info["title"], data=user_input) + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + errors = {"base": error_key} return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - - -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" - - -class NoAppliances(HomeAssistantError): - """Error to indicate no supported appliances in the user account.""" diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index e451fda82ad..a82c2a22695 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -3,7 +3,6 @@ from unittest.mock import MagicMock, patch import aiohttp -from aiohttp.client_exceptions import ClientConnectionError import pytest from homeassistant import config_entries @@ -219,7 +218,7 @@ async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: @pytest.mark.usefixtures("mock_appliances_manager_api", "mock_whirlpool_setup_entry") -async def test_reauth_flow_auth_error( +async def test_reauth_flow_invalid_auth( hass: HomeAssistant, region, brand, mock_auth_api: MagicMock ) -> None: """Test an authorization error reauth flow.""" @@ -247,8 +246,21 @@ async def test_reauth_flow_auth_error( @pytest.mark.usefixtures("mock_appliances_manager_api", "mock_whirlpool_setup_entry") -async def test_reauth_flow_connnection_error( - hass: HomeAssistant, region, brand, mock_auth_api: MagicMock +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (aiohttp.ClientConnectionError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_reauth_flow_auth_error( + hass: HomeAssistant, + exception: Exception, + expected_error: str, + region, + brand, + mock_auth_api: MagicMock, ) -> None: """Test a connection error reauth flow.""" @@ -265,11 +277,10 @@ async def test_reauth_flow_connnection_error( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_auth_api.return_value.do_auth.side_effect = ClientConnectionError + mock_auth_api.return_value.do_auth.side_effect = exception result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["errors"] == {"base": expected_error} From 4ce891512e14e6907e3f15a340b42bcde7a2d5ad Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 29 Jan 2025 12:16:28 -0500 Subject: [PATCH 1211/2987] Add ability to cache Roborock maps instead of always reloading (#112047) Co-authored-by: Paulus Schoutsen Co-authored-by: Allen Porter Co-authored-by: Joost Lekkerkerker Co-authored-by: Allen Porter Co-authored-by: Robert Resch --- homeassistant/components/roborock/__init__.py | 6 + homeassistant/components/roborock/const.py | 2 + .../components/roborock/coordinator.py | 7 + homeassistant/components/roborock/image.py | 151 +++++++++--------- .../components/roborock/roborock_storage.py | 81 ++++++++++ tests/components/roborock/conftest.py | 23 +++ tests/components/roborock/test_image.py | 136 +++++++++++++++- tests/components/roborock/test_init.py | 58 +++++++ 8 files changed, 380 insertions(+), 84 deletions(-) create mode 100644 homeassistant/components/roborock/roborock_storage.py diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 9ab9226c9a5..1b34dc891d1 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -28,6 +28,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS from .coordinator import RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01 +from .roborock_storage import async_remove_map_storage SCAN_INTERVAL = timedelta(seconds=30) @@ -259,3 +260,8 @@ async def update_listener(hass: HomeAssistant, entry: RoborockConfigEntry) -> No """Handle options update.""" # Reload entry to update data await hass.config_entries.async_reload(entry.entry_id) + + +async def async_remove_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> None: + """Handle removal of an entry.""" + await async_remove_map_storage(hass, entry.entry_id) diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 4a9bd14bfe1..cc8d34fbadc 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -49,5 +49,7 @@ IMAGE_CACHE_INTERVAL = 90 MAP_SLEEP = 3 GET_MAPS_SERVICE_NAME = "get_maps" +MAP_FILE_FORMAT = "PNG" +MAP_FILENAME_SUFFIX = ".png" SET_VACUUM_GOTO_POSITION_SERVICE_NAME = "set_vacuum_goto_position" GET_VACUUM_CURRENT_POSITION_SERVICE_NAME = "get_vacuum_current_position" diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index d34ba49da52..36333f1c55e 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -16,6 +16,7 @@ from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClient from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_a01_apis import RoborockClientA01 +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -26,6 +27,7 @@ from homeassistant.util import slugify from .const import DOMAIN from .models import RoborockA01HassDeviceInfo, RoborockHassDeviceInfo, RoborockMapInfo +from .roborock_storage import RoborockMapStorage SCAN_INTERVAL = timedelta(seconds=30) @@ -35,6 +37,8 @@ _LOGGER = logging.getLogger(__name__) class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): """Class to manage fetching data from the API.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -72,6 +76,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # Maps from map flag to map name self.maps: dict[int, RoborockMapInfo] = {} self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms} + self.map_storage = RoborockMapStorage( + hass, self.config_entry.entry_id, slugify(self.duid) + ) async def _async_setup(self) -> None: """Set up the coordinator.""" diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 3818a039fb8..b0de4f9caa5 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -1,26 +1,33 @@ """Support for Roborock image.""" import asyncio +from collections.abc import Callable from datetime import datetime import io -from itertools import chain from roborock import RoborockCommand from vacuum_map_parser_base.config.color import ColorsPalette -from vacuum_map_parser_base.config.drawable import Drawable from vacuum_map_parser_base.config.image_config import ImageConfig from vacuum_map_parser_base.config.size import Sizes from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser from homeassistant.components.image import ImageEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt as dt_util, slugify +from homeassistant.util import dt as dt_util from . import RoborockConfigEntry -from .const import DEFAULT_DRAWABLES, DOMAIN, DRAWABLES, IMAGE_CACHE_INTERVAL, MAP_SLEEP +from .const import ( + DEFAULT_DRAWABLES, + DOMAIN, + DRAWABLES, + IMAGE_CACHE_INTERVAL, + MAP_FILE_FORMAT, + MAP_SLEEP, +) from .coordinator import RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 @@ -37,17 +44,35 @@ async def async_setup_entry( for drawable, default_value in DEFAULT_DRAWABLES.items() if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value) ] - entities = list( - chain.from_iterable( - await asyncio.gather( - *( - create_coordinator_maps(coord, drawables) - for coord in config_entry.runtime_data.v1 - ) - ) - ) + parser = RoborockMapDataParser( + ColorsPalette(), Sizes(), drawables, ImageConfig(), [] + ) + + def parse_image(map_bytes: bytes) -> bytes | None: + parsed_map = parser.parse(map_bytes) + if parsed_map.image is None: + return None + img_byte_arr = io.BytesIO() + parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT) + return img_byte_arr.getvalue() + + await asyncio.gather( + *(refresh_coordinators(hass, coord) for coord in config_entry.runtime_data.v1) + ) + async_add_entities( + ( + RoborockMap( + config_entry, + f"{coord.duid_slug}_map_{map_info.name}", + coord, + map_info.flag, + map_info.name, + parse_image, + ) + for coord in config_entry.runtime_data.v1 + for map_info in coord.maps.values() + ), ) - async_add_entities(entities) class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): @@ -55,39 +80,27 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): _attr_has_entity_name = True image_last_updated: datetime + _attr_name: str def __init__( self, + config_entry: ConfigEntry, unique_id: str, coordinator: RoborockDataUpdateCoordinator, map_flag: int, - starting_map: bytes, map_name: str, - drawables: list[Drawable], + parser: Callable[[bytes], bytes | None], ) -> None: """Initialize a Roborock map.""" RoborockCoordinatedEntityV1.__init__(self, unique_id, coordinator) ImageEntity.__init__(self, coordinator.hass) + self.config_entry = config_entry self._attr_name = map_name - self.parser = RoborockMapDataParser( - ColorsPalette(), Sizes(), drawables, ImageConfig(), [] - ) - self._attr_image_last_updated = dt_util.utcnow() + self.parser = parser self.map_flag = map_flag - try: - self.cached_map = self._create_image(starting_map) - except HomeAssistantError: - # If we failed to update the image on init, - # we set cached_map to empty bytes - # so that we are unavailable and can try again later. - self.cached_map = b"" + self.cached_map = b"" self._attr_entity_category = EntityCategory.DIAGNOSTIC - @property - def available(self) -> bool: - """Determines if the entity is available.""" - return self.cached_map != b"" - @property def is_selected(self) -> bool: """Return if this map is the currently selected map.""" @@ -106,6 +119,14 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): and bool(self.coordinator.roborock_device_info.props.status.in_cleaning) ) + async def async_added_to_hass(self) -> None: + """When entity is added to hass load any previously cached maps from disk.""" + await super().async_added_to_hass() + content = await self.coordinator.map_storage.async_load_map(self.map_flag) + self.cached_map = content or b"" + self._attr_image_last_updated = dt_util.utcnow() + self.async_write_ha_state() + def _handle_coordinator_update(self) -> None: # Bump last updated every third time the coordinator runs, so that async_image # will be called and we will evaluate on the new coordinator data if we should @@ -126,47 +147,40 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): ), return_exceptions=True, ) - if not isinstance(response[0], bytes): + if ( + not isinstance(response[0], bytes) + or (content := self.parser(response[0])) is None + ): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="map_failure", ) - map_data = response[0] - self.cached_map = self._create_image(map_data) + if self.cached_map != content: + self.cached_map = content + self.config_entry.async_create_task( + self.hass, + self.coordinator.map_storage.async_save_map( + self.map_flag, + content, + ), + f"{self.unique_id} map", + ) return self.cached_map - def _create_image(self, map_bytes: bytes) -> bytes: - """Create an image using the map parser.""" - parsed_map = self.parser.parse(map_bytes) - if parsed_map.image is None: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="map_failure", - ) - img_byte_arr = io.BytesIO() - parsed_map.image.data.save(img_byte_arr, format="PNG") - return img_byte_arr.getvalue() - -async def create_coordinator_maps( - coord: RoborockDataUpdateCoordinator, drawables: list[Drawable] -) -> list[RoborockMap]: +async def refresh_coordinators( + hass: HomeAssistant, coord: RoborockDataUpdateCoordinator +) -> None: """Get the starting map information for all maps for this device. The following steps must be done synchronously. Only one map can be loaded at a time per device. """ - entities = [] cur_map = coord.current_map # This won't be None at this point as the coordinator will have run first. assert cur_map is not None - # Sort the maps so that we start with the current map and we can skip the - # load_multi_map call. - maps_info = sorted( - coord.maps.items(), key=lambda data: data[0] == cur_map, reverse=True - ) - for map_flag, map_info in maps_info: - # Load the map - so we can access it with get_map_v1 + map_flags = sorted(coord.maps, key=lambda data: data == cur_map, reverse=True) + for map_flag in map_flags: if map_flag != cur_map: # Only change the map and sleep if we have multiple maps. await coord.api.send_command(RoborockCommand.LOAD_MULTI_MAP, [map_flag]) @@ -174,28 +188,11 @@ async def create_coordinator_maps( # We cannot get the map until the roborock servers fully process the # map change. await asyncio.sleep(MAP_SLEEP) - # Get the map data - map_update = await asyncio.gather( - *[coord.cloud_api.get_map_v1(), coord.set_current_map_rooms()], - return_exceptions=True, - ) - # If we fail to get the map, we should set it to empty byte, - # still create it, and set it as unavailable. - api_data: bytes = map_update[0] if isinstance(map_update[0], bytes) else b"" - entities.append( - RoborockMap( - f"{slugify(coord.duid)}_map_{map_info.name}", - coord, - map_flag, - api_data, - map_info.name, - drawables, - ) - ) + await coord.set_current_map_rooms() + if len(coord.maps) != 1: # Set the map back to the map the user previously had selected so that it # does not change the end user's app. # Only needs to happen when we changed maps above. await coord.cloud_api.send_command(RoborockCommand.LOAD_MULTI_MAP, [cur_map]) coord.current_map = cur_map - return entities diff --git a/homeassistant/components/roborock/roborock_storage.py b/homeassistant/components/roborock/roborock_storage.py new file mode 100644 index 00000000000..62e15e889be --- /dev/null +++ b/homeassistant/components/roborock/roborock_storage.py @@ -0,0 +1,81 @@ +"""Roborock storage.""" + +import logging +from pathlib import Path +import shutil + +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, MAP_FILENAME_SUFFIX + +_LOGGER = logging.getLogger(__name__) + +STORAGE_PATH = f".storage/{DOMAIN}" +MAPS_PATH = "maps" + + +def _storage_path_prefix(hass: HomeAssistant, entry_id: str) -> Path: + return Path(hass.config.path(STORAGE_PATH)) / entry_id + + +class RoborockMapStorage: + """Store and retrieve maps for a Roborock device. + + An instance of RoborockMapStorage is created for each device and manages + local storage of maps for that device. + """ + + def __init__(self, hass: HomeAssistant, entry_id: str, device_id_slug: str) -> None: + """Initialize RoborockMapStorage.""" + self._hass = hass + self._path_prefix = ( + _storage_path_prefix(hass, entry_id) / MAPS_PATH / device_id_slug + ) + + async def async_load_map(self, map_flag: int) -> bytes | None: + """Load maps from disk.""" + filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" + return await self._hass.async_add_executor_job(self._load_map, filename) + + def _load_map(self, filename: Path) -> bytes | None: + """Load maps from disk.""" + if not filename.exists(): + return None + try: + return filename.read_bytes() + except OSError as err: + _LOGGER.debug("Unable to read map file: %s %s", filename, err) + return None + + async def async_save_map(self, map_flag: int, content: bytes) -> None: + """Write map if it should be updated.""" + filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" + await self._hass.async_add_executor_job(self._save_map, filename, content) + + def _save_map(self, filename: Path, content: bytes) -> None: + """Write the map to disk.""" + _LOGGER.debug("Saving map to disk: %s", filename) + try: + filename.parent.mkdir(parents=True, exist_ok=True) + except OSError as err: + _LOGGER.error("Unable to create map directory: %s %s", filename, err) + return + try: + filename.write_bytes(content) + except OSError as err: + _LOGGER.error("Unable to write map file: %s %s", filename, err) + + +async def async_remove_map_storage(hass: HomeAssistant, entry_id: str) -> None: + """Remove all map storage associated with a config entry.""" + + def remove(path_prefix: Path) -> None: + try: + if path_prefix.exists(): + shutil.rmtree(path_prefix, ignore_errors=True) + except OSError as err: + _LOGGER.error("Unable to remove map files in %s: %s", path_prefix, err) + + path_prefix = _storage_path_prefix(hass, entry_id) + _LOGGER.debug("Removing maps from disk store: %s", path_prefix) + await hass.async_add_executor_job(remove, path_prefix) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 4df5f479b7c..e5fc5cb7eb6 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -2,8 +2,11 @@ from collections.abc import Generator from copy import deepcopy +import pathlib +import shutil from typing import Any from unittest.mock import Mock, patch +import uuid import pytest from roborock import RoborockCategory, RoomMapping @@ -70,6 +73,9 @@ def bypass_api_fixture() -> None: with ( patch("homeassistant.components.roborock.RoborockMqttClientV1.async_connect"), patch("homeassistant.components.roborock.RoborockMqttClientV1._send_command"), + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1._send_command" + ), patch( "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", return_value=HOME_DATA, @@ -196,6 +202,7 @@ async def setup_entry( hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry, + cleanup_map_storage: pathlib.Path, platforms: list[Platform], ) -> Generator[MockConfigEntry]: """Set up the Roborock platform.""" @@ -203,3 +210,19 @@ async def setup_entry( assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() yield mock_roborock_entry + + +@pytest.fixture +def cleanup_map_storage( + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry +) -> Generator[pathlib.Path]: + """Test cleanup, remove any map storage persisted during the test.""" + tmp_path = str(uuid.uuid4()) + with patch( + "homeassistant.components.roborock.roborock_storage.STORAGE_PATH", new=tmp_path + ): + storage_path = ( + pathlib.Path(hass.config.path(tmp_path)) / mock_roborock_entry.entry_id + ) + yield storage_path + shutil.rmtree(str(storage_path), ignore_errors=True) diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index e240dccf7eb..90886f25929 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -3,13 +3,16 @@ import copy from datetime import timedelta from http import HTTPStatus +import io from unittest.mock import patch +from PIL import Image import pytest from roborock import RoborockException +from vacuum_map_parser_base.map_data import ImageConfig, ImageData from homeassistant.components.roborock import DOMAIN -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -32,22 +35,27 @@ async def test_floorplan_image( hass_client: ClientSessionGenerator, ) -> None: """Test floor plan map image is correctly set up.""" - # Setup calls the image parsing the first time and caches it. assert len(hass.states.async_all("image")) == 4 assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None - # call a second time -should return cached data + # Load the image on demand client = await hass_client() resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK body = await resp.read() assert body is not None - # Call a third time - this time forcing it to update - now = dt_util.utcnow() + timedelta(seconds=91) + assert body[0:4] == b"\x89PNG" + + # Call a second time - this time forcing it to update - and save new image + now = dt_util.utcnow() + timedelta(minutes=61) # Copy the device prop so we don't override it prop = copy.deepcopy(PROP) prop.status.in_cleaning = 1 + new_map_data = copy.deepcopy(MAP_DATA) + new_map_data.image = ImageData( + 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (2, 2)), lambda p: p + ) with ( patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", @@ -56,6 +64,10 @@ async def test_floorplan_image( patch( "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now ), + patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + return_value=new_map_data, + ) as parse_map, ): async_fire_time_changed(hass, now) await hass.async_block_till_done() @@ -63,6 +75,7 @@ async def test_floorplan_image( assert resp.status == HTTPStatus.OK body = await resp.read() assert body is not None + assert parse_map.call_count == 1 async def test_floorplan_image_failed_parse( @@ -97,13 +110,101 @@ async def test_floorplan_image_failed_parse( assert not resp.ok +async def test_load_stored_image( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_entry: MockConfigEntry, +) -> None: + """Test that we correctly load an image from storage when it already exists.""" + img_byte_arr = io.BytesIO() + MAP_DATA.image.data.save(img_byte_arr, format="PNG") + img_bytes = img_byte_arr.getvalue() + + # Load the image on demand, which should ensure it is cached on disk + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + + with patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + ) as parse_map: + # Reload the config entry so that the map is saved in storage and entities exist. + await hass.config_entries.async_reload(setup_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + # Test that we can get the image and it correctly serialized and unserialized. + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == img_bytes + + # Ensure that we never tried to update the map, and only used the cached image. + assert parse_map.call_count == 0 + + +async def test_fail_to_save_image( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we gracefully handle a oserror on saving an image.""" + # Reload the config entry so that the map is saved in storage and entities exist. + with patch( + "homeassistant.components.roborock.roborock_storage.Path.write_bytes", + side_effect=OSError, + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Ensure that map is still working properly. + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + # Test that we can get the image and it correctly serialized and unserialized. + assert resp.status == HTTPStatus.OK + + assert "Unable to write map file" in caplog.text + + +async def test_fail_to_load_image( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we gracefully handle failing to load an image.""" + with ( + patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + ) as parse_map, + patch( + "homeassistant.components.roborock.roborock_storage.Path.exists", + return_value=True, + ), + patch( + "homeassistant.components.roborock.roborock_storage.Path.read_bytes", + side_effect=OSError, + ) as read_bytes, + ): + # Reload the config entry so that the map is saved in storage and entities exist. + await hass.config_entries.async_reload(setup_entry.entry_id) + await hass.async_block_till_done() + assert read_bytes.call_count == 4 + # Ensure that we never updated the map manually since we couldn't load it. + assert parse_map.call_count == 0 + assert "Unable to read map file" in caplog.text + + async def test_fail_parse_on_startup( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_roborock_entry: MockConfigEntry, bypass_api_fixture, ) -> None: - """Test that if we fail parsing on startup, we create the entity but set it as unavailable.""" + """Test that if we fail parsing on startup, we still create the entity.""" map_data = copy.deepcopy(MAP_DATA) map_data.image = None with patch( @@ -115,7 +216,28 @@ async def test_fail_parse_on_startup( assert ( image_entity := hass.states.get("image.roborock_s7_maxv_upstairs") ) is not None - assert image_entity.state == STATE_UNAVAILABLE + assert image_entity.state + + +async def test_fail_get_map_on_startup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture, +) -> None: + """Test that if we fail getting map on startup, we can still create the entity.""" + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", + return_value=None, + ), + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert ( + image_entity := hass.states.get("image.roborock_s7_maxv_upstairs") + ) is not None + assert image_entity.state async def test_fail_updating_image( diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index f4f490e68d9..efd1c3f66f4 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -1,6 +1,8 @@ """Test for Roborock init.""" from copy import deepcopy +from http import HTTPStatus +import pathlib from unittest.mock import patch import pytest @@ -13,12 +15,14 @@ from roborock import ( from homeassistant.components.roborock.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from .mock_data import HOME_DATA from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator async def test_unload_entry( @@ -163,6 +167,60 @@ async def test_reauth_started( assert flows[0]["step_id"] == "reauth_confirm" +@pytest.mark.parametrize("platforms", [[Platform.IMAGE]]) +async def test_remove_from_hass( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + cleanup_map_storage: pathlib.Path, +) -> None: + """Test that removing from hass removes any existing images.""" + + # Ensure some image content is cached + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + + assert cleanup_map_storage.exists() + paths = list(cleanup_map_storage.walk()) + assert len(paths) == 3 # One map image and two directories + + await hass.config_entries.async_remove(setup_entry.entry_id) + # After removal, directories should be empty. + assert not cleanup_map_storage.exists() + + +@pytest.mark.parametrize("platforms", [[Platform.IMAGE]]) +async def test_oserror_remove_image( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + cleanup_map_storage: pathlib.Path, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we gracefully handle failing to remove an image.""" + + # Ensure some image content is cached + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + + assert cleanup_map_storage.exists() + paths = list(cleanup_map_storage.walk()) + assert len(paths) == 3 # One map image and two directories + + with patch( + "homeassistant.components.roborock.roborock_storage.shutil.rmtree", + side_effect=OSError, + ): + await hass.config_entries.async_remove(setup_entry.entry_id) + assert "Unable to remove map files" in caplog.text + + async def test_not_supported_protocol( hass: HomeAssistant, bypass_api_fixture, From 6a8e45c51e24603aa1a3fc170c64ae021776a506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 29 Jan 2025 17:20:14 +0000 Subject: [PATCH 1212/2987] Update whirlpool-sixth-sense to 0.18.12 (#136851) --- homeassistant/components/whirlpool/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index b463a1a76f8..67901eea482 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["whirlpool"], - "requirements": ["whirlpool-sixth-sense==0.18.11"] + "requirements": ["whirlpool-sixth-sense==0.18.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0ab09a60906..c87835f9153 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3043,7 +3043,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.1.15 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.11 +whirlpool-sixth-sense==0.18.12 # homeassistant.components.whois whois==0.9.27 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e2ab69f3cf2..968eec09d28 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2447,7 +2447,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.1.15 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.11 +whirlpool-sixth-sense==0.18.12 # homeassistant.components.whois whois==0.9.27 From 823df4242d0cf3532ad5f33c3d5caef5bb709191 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 18:23:25 +0100 Subject: [PATCH 1213/2987] Add support for per-backup agent encryption flag to hassio (#136828) * Add support for per-backup agent encryption flag to hassio * Improve comment * Set password to None when supervisor should not encrypt --- homeassistant/components/hassio/backup.py | 89 +++++-- tests/components/hassio/test_backup.py | 282 +++++++++++++++++++++- 2 files changed, 350 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 5318e4cd351..afeee1f4469 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -97,7 +97,7 @@ def async_register_backup_agents_listener( def _backup_details_to_agent_backup( - details: supervisor_backups.BackupComplete, + details: supervisor_backups.BackupComplete, location: str | None ) -> AgentBackup: """Convert a supervisor backup details object to an agent backup.""" homeassistant_included = details.homeassistant is not None @@ -109,6 +109,7 @@ def _backup_details_to_agent_backup( AddonInfo(name=addon.name, slug=addon.slug, version=addon.version) for addon in details.addons ] + location = location or LOCATION_LOCAL return AgentBackup( addons=addons, backup_id=details.slug, @@ -119,8 +120,8 @@ def _backup_details_to_agent_backup( homeassistant_included=homeassistant_included, homeassistant_version=details.homeassistant, name=details.name, - protected=details.protected, - size=details.size_bytes, + protected=details.location_attributes[location].protected, + size=details.location_attributes[location].size_bytes, ) @@ -158,8 +159,23 @@ class SupervisorBackupAgent(BackupAgent): ) -> None: """Upload a backup. - Not required for supervisor, the SupervisorBackupReaderWriter stores files. + The upload will be skipped if the backup already exists in the agent's location. """ + if await self.async_get_backup(backup.backup_id): + _LOGGER.debug( + "Backup %s already exists in location %s", + backup.backup_id, + self.location, + ) + return + stream = await open_stream() + upload_options = supervisor_backups.UploadBackupOptions( + location={self.location} + ) + await self._client.backups.upload_backup( + stream, + upload_options, + ) async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" @@ -169,7 +185,7 @@ class SupervisorBackupAgent(BackupAgent): if not backup.locations or self.location not in backup.locations: continue details = await self._client.backups.backup_info(backup.slug) - result.append(_backup_details_to_agent_backup(details)) + result.append(_backup_details_to_agent_backup(details, self.location)) return result async def async_get_backup( @@ -181,7 +197,7 @@ class SupervisorBackupAgent(BackupAgent): details = await self._client.backups.backup_info(backup_id) if self.location not in details.locations: return None - return _backup_details_to_agent_backup(details) + return _backup_details_to_agent_backup(details, self.location) async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: """Remove a backup.""" @@ -246,7 +262,41 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): for agent_id in agent_ids if manager.backup_agents[agent_id].domain == DOMAIN ] - locations = [agent.location for agent in hassio_agents] + + # Supervisor does not support creating backups spread across multiple + # locations, where some locations are encrypted and some are not. + # It's inefficient to let core do all the copying so we want to let + # supervisor handle as much as possible. + # Therefore, we split the locations into two lists: encrypted and decrypted. + # The longest list will be sent to supervisor, and the remaining locations + # will be handled by async_upload_backup. + # If the lists are the same length, it does not matter which one we send, + # we send the encrypted list to have a well defined behavior. + encrypted_locations: list[str | None] = [] + decrypted_locations: list[str | None] = [] + agents_settings = manager.config.data.agents + for hassio_agent in hassio_agents: + if password is not None: + if agent_settings := agents_settings.get(hassio_agent.agent_id): + if agent_settings.protected: + encrypted_locations.append(hassio_agent.location) + else: + decrypted_locations.append(hassio_agent.location) + else: + encrypted_locations.append(hassio_agent.location) + else: + decrypted_locations.append(hassio_agent.location) + _LOGGER.debug("Encrypted locations: %s", encrypted_locations) + _LOGGER.debug("Decrypted locations: %s", decrypted_locations) + if hassio_agents: + if len(encrypted_locations) >= len(decrypted_locations): + locations = encrypted_locations + else: + locations = decrypted_locations + password = None + else: + locations = [] + locations = locations or [LOCATION_CLOUD_BACKUP] try: backup = await self._client.backups.partial_backup( @@ -257,7 +307,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): name=backup_name, password=password, compressed=True, - location=locations or LOCATION_CLOUD_BACKUP, + location=locations, homeassistant_exclude_database=not include_database, background=True, extra=extra_metadata, @@ -267,7 +317,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): raise BackupReaderWriterError(f"Error creating backup: {err}") from err backup_task = self._hass.async_create_task( self._async_wait_for_backup( - backup, remove_after_upload=not bool(locations) + backup, + locations, + remove_after_upload=locations == [LOCATION_CLOUD_BACKUP], ), name="backup_manager_create_backup", eager_start=False, # To ensure the task is not started before we return @@ -276,7 +328,11 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): return (NewBackup(backup_job_id=backup.job_id), backup_task) async def _async_wait_for_backup( - self, backup: supervisor_backups.NewBackup, *, remove_after_upload: bool + self, + backup: supervisor_backups.NewBackup, + locations: list[str | None], + *, + remove_after_upload: bool, ) -> WrittenBackup: """Wait for a backup to complete.""" backup_complete = asyncio.Event() @@ -327,7 +383,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ) from err return WrittenBackup( - backup=_backup_details_to_agent_backup(details), + backup=_backup_details_to_agent_backup(details, locations[0]), open_stream=open_backup, release_stream=remove_backup, ) @@ -347,20 +403,19 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): for agent_id in agent_ids if manager.backup_agents[agent_id].domain == DOMAIN ] - locations = {agent.location for agent in hassio_agents} + locations = [agent.location for agent in hassio_agents] + locations = locations or [LOCATION_CLOUD_BACKUP] backup_id = await self._client.backups.upload_backup( stream, - supervisor_backups.UploadBackupOptions( - location=locations or {LOCATION_CLOUD_BACKUP} - ), + supervisor_backups.UploadBackupOptions(location=set(locations)), ) async def open_backup() -> AsyncIterator[bytes]: return await self._client.backups.download_backup(backup_id) async def remove_backup() -> None: - if locations: + if locations != [LOCATION_CLOUD_BACKUP]: return await self._client.backups.remove_backup( backup_id, @@ -372,7 +427,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): details = await self._client.backups.backup_info(backup_id) return WrittenBackup( - backup=_backup_details_to_agent_backup(details), + backup=_backup_details_to_agent_backup(details, locations[0]), open_stream=open_backup, release_stream=remove_backup, ) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 1c257416ad0..7c2bf8921ef 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -245,6 +245,56 @@ TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete( type=TEST_BACKUP.type, ) +TEST_BACKUP_5 = supervisor_backups.Backup( + compressed=False, + content=supervisor_backups.BackupContent( + addons=["ssl"], + folders=[supervisor_backups.Folder.SHARE], + homeassistant=True, + ), + date=datetime.fromisoformat("1970-01-01T00:00:00Z"), + location=LOCATION_CLOUD_BACKUP, + location_attributes={ + LOCATION_CLOUD_BACKUP: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, + locations={LOCATION_CLOUD_BACKUP}, + name="Test", + protected=False, + size=1.0, + size_bytes=1048576, + slug="abc123", + type=supervisor_backups.BackupType.PARTIAL, +) +TEST_BACKUP_DETAILS_5 = supervisor_backups.BackupComplete( + addons=[ + supervisor_backups.BackupAddon( + name="Terminal & SSH", + size=0.0, + slug="core_ssh", + version="9.14.0", + ) + ], + compressed=TEST_BACKUP_5.compressed, + date=TEST_BACKUP_5.date, + extra=None, + folders=[supervisor_backups.Folder.SHARE], + homeassistant_exclude_database=False, + homeassistant="2024.12.0", + location=TEST_BACKUP_5.location, + location_attributes=TEST_BACKUP_5.location_attributes, + locations=TEST_BACKUP_5.locations, + name=TEST_BACKUP_5.name, + protected=TEST_BACKUP_5.protected, + repositories=[], + size=TEST_BACKUP_5.size, + size_bytes=TEST_BACKUP_5.size_bytes, + slug=TEST_BACKUP_5.slug, + supervisor_version="2024.11.2", + type=TEST_BACKUP_5.type, +) + @pytest.fixture(autouse=True) def fixture_supervisor_environ() -> Generator[None]: @@ -821,6 +871,230 @@ async def test_reader_writer_create( assert response["event"] == {"manager_state": "idle"} +@pytest.mark.usefixtures("hassio_client") +@pytest.mark.parametrize( + ( + "commands", + "password", + "agent_ids", + "password_sent_to_supervisor", + "create_locations", + "create_protected", + "upload_locations", + ), + [ + ( + [], + None, + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + None, + [None, "share1", "share2", "share3"], + False, + [], + ), + ( + [], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + [None, "share1", "share2", "share3"], + True, + [], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + ["share1", "share2", "share3"], + True, + [None], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + "hassio.share1": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + ["share2", "share3"], + True, + [None, "share1"], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + "hassio.share1": {"protected": False}, + "hassio.share2": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + None, + [None, "share1", "share2"], + True, + ["share3"], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local"], + None, + [None], + False, + [], + ), + ], +) +async def test_reader_writer_create_per_agent_encryption( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + commands: dict[str, Any], + password: str | None, + agent_ids: list[str], + password_sent_to_supervisor: str | None, + create_locations: list[str | None], + create_protected: bool, + upload_locations: list[str | None], +) -> None: + """Test generating a backup.""" + client = await hass_ws_client(hass) + mounts = MountsInfo( + default_backup_mount=None, + mounts=[ + supervisor_mounts.CIFSMountResponse( + share=f"share{i}", + name=f"share{i}", + read_only=False, + state=supervisor_mounts.MountState.ACTIVE, + user_path=f"share{i}", + usage=supervisor_mounts.MountUsage.BACKUP, + server=f"share{i}", + type=supervisor_mounts.MountType.CIFS, + ) + for i in range(1, 4) + ], + ) + supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.backup_info.return_value = replace( + TEST_BACKUP_DETAILS, + locations=create_locations, + location_attributes={ + location or LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + protected=create_protected, + size_bytes=TEST_BACKUP_DETAILS.size_bytes, + ) + for location in create_locations + }, + ) + supervisor_client.mounts.info.return_value = mounts + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] is True + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + { + "type": "backup/generate", + "agent_ids": agent_ids, + "name": "Test", + "password": password, + } + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": "abc123"} + + supervisor_client.backups.partial_backup.assert_called_once_with( + replace( + DEFAULT_BACKUP_OPTIONS, + password=password_sent_to_supervisor, + location=create_locations, + ) + ) + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + assert len(supervisor_client.backups.upload_backup.mock_calls) == len( + upload_locations + ) + for call in supervisor_client.backups.upload_backup.mock_calls: + upload_call_locations: set = call.args[1].location + assert len(upload_call_locations) == 1 + assert upload_call_locations.pop() in upload_locations + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + @pytest.mark.usefixtures("hassio_client", "setup_integration") @pytest.mark.parametrize( ("side_effect", "error_code", "error_message", "expected_reason"), @@ -969,7 +1243,7 @@ async def test_reader_writer_create_download_remove_error( """Test download and remove error when generating a backup.""" client = await hass_ws_client(hass) supervisor_client.backups.partial_backup.return_value.job_id = "abc123" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 method_mock = getattr(supervisor_client.backups, method) method_mock.side_effect = exception @@ -1129,7 +1403,7 @@ async def test_reader_writer_create_remote_backup( """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) supervisor_client.backups.partial_backup.return_value.job_id = "abc123" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1163,7 +1437,7 @@ async def test_reader_writer_create_remote_backup( assert response["result"] == {"backup_job_id": "abc123"} supervisor_client.backups.partial_backup.assert_called_once_with( - replace(DEFAULT_BACKUP_OPTIONS, location=LOCATION_CLOUD_BACKUP), + replace(DEFAULT_BACKUP_OPTIONS, location=[LOCATION_CLOUD_BACKUP]), ) await client.send_json_auto_id( @@ -1280,7 +1554,7 @@ async def test_agent_receive_remote_backup( """Test receiving a backup which will be uploaded to a remote agent.""" client = await hass_client() backup_id = "test-backup" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.backups.upload_backup.return_value = "test_slug" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], From 8749210d1b2b065e5da837971b26a08053f85e9a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 18:23:25 +0100 Subject: [PATCH 1214/2987] Add support for per-backup agent encryption flag to hassio (#136828) * Add support for per-backup agent encryption flag to hassio * Improve comment * Set password to None when supervisor should not encrypt --- homeassistant/components/hassio/backup.py | 89 +++++-- tests/components/hassio/test_backup.py | 282 +++++++++++++++++++++- 2 files changed, 350 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 5318e4cd351..afeee1f4469 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -97,7 +97,7 @@ def async_register_backup_agents_listener( def _backup_details_to_agent_backup( - details: supervisor_backups.BackupComplete, + details: supervisor_backups.BackupComplete, location: str | None ) -> AgentBackup: """Convert a supervisor backup details object to an agent backup.""" homeassistant_included = details.homeassistant is not None @@ -109,6 +109,7 @@ def _backup_details_to_agent_backup( AddonInfo(name=addon.name, slug=addon.slug, version=addon.version) for addon in details.addons ] + location = location or LOCATION_LOCAL return AgentBackup( addons=addons, backup_id=details.slug, @@ -119,8 +120,8 @@ def _backup_details_to_agent_backup( homeassistant_included=homeassistant_included, homeassistant_version=details.homeassistant, name=details.name, - protected=details.protected, - size=details.size_bytes, + protected=details.location_attributes[location].protected, + size=details.location_attributes[location].size_bytes, ) @@ -158,8 +159,23 @@ class SupervisorBackupAgent(BackupAgent): ) -> None: """Upload a backup. - Not required for supervisor, the SupervisorBackupReaderWriter stores files. + The upload will be skipped if the backup already exists in the agent's location. """ + if await self.async_get_backup(backup.backup_id): + _LOGGER.debug( + "Backup %s already exists in location %s", + backup.backup_id, + self.location, + ) + return + stream = await open_stream() + upload_options = supervisor_backups.UploadBackupOptions( + location={self.location} + ) + await self._client.backups.upload_backup( + stream, + upload_options, + ) async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" @@ -169,7 +185,7 @@ class SupervisorBackupAgent(BackupAgent): if not backup.locations or self.location not in backup.locations: continue details = await self._client.backups.backup_info(backup.slug) - result.append(_backup_details_to_agent_backup(details)) + result.append(_backup_details_to_agent_backup(details, self.location)) return result async def async_get_backup( @@ -181,7 +197,7 @@ class SupervisorBackupAgent(BackupAgent): details = await self._client.backups.backup_info(backup_id) if self.location not in details.locations: return None - return _backup_details_to_agent_backup(details) + return _backup_details_to_agent_backup(details, self.location) async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: """Remove a backup.""" @@ -246,7 +262,41 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): for agent_id in agent_ids if manager.backup_agents[agent_id].domain == DOMAIN ] - locations = [agent.location for agent in hassio_agents] + + # Supervisor does not support creating backups spread across multiple + # locations, where some locations are encrypted and some are not. + # It's inefficient to let core do all the copying so we want to let + # supervisor handle as much as possible. + # Therefore, we split the locations into two lists: encrypted and decrypted. + # The longest list will be sent to supervisor, and the remaining locations + # will be handled by async_upload_backup. + # If the lists are the same length, it does not matter which one we send, + # we send the encrypted list to have a well defined behavior. + encrypted_locations: list[str | None] = [] + decrypted_locations: list[str | None] = [] + agents_settings = manager.config.data.agents + for hassio_agent in hassio_agents: + if password is not None: + if agent_settings := agents_settings.get(hassio_agent.agent_id): + if agent_settings.protected: + encrypted_locations.append(hassio_agent.location) + else: + decrypted_locations.append(hassio_agent.location) + else: + encrypted_locations.append(hassio_agent.location) + else: + decrypted_locations.append(hassio_agent.location) + _LOGGER.debug("Encrypted locations: %s", encrypted_locations) + _LOGGER.debug("Decrypted locations: %s", decrypted_locations) + if hassio_agents: + if len(encrypted_locations) >= len(decrypted_locations): + locations = encrypted_locations + else: + locations = decrypted_locations + password = None + else: + locations = [] + locations = locations or [LOCATION_CLOUD_BACKUP] try: backup = await self._client.backups.partial_backup( @@ -257,7 +307,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): name=backup_name, password=password, compressed=True, - location=locations or LOCATION_CLOUD_BACKUP, + location=locations, homeassistant_exclude_database=not include_database, background=True, extra=extra_metadata, @@ -267,7 +317,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): raise BackupReaderWriterError(f"Error creating backup: {err}") from err backup_task = self._hass.async_create_task( self._async_wait_for_backup( - backup, remove_after_upload=not bool(locations) + backup, + locations, + remove_after_upload=locations == [LOCATION_CLOUD_BACKUP], ), name="backup_manager_create_backup", eager_start=False, # To ensure the task is not started before we return @@ -276,7 +328,11 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): return (NewBackup(backup_job_id=backup.job_id), backup_task) async def _async_wait_for_backup( - self, backup: supervisor_backups.NewBackup, *, remove_after_upload: bool + self, + backup: supervisor_backups.NewBackup, + locations: list[str | None], + *, + remove_after_upload: bool, ) -> WrittenBackup: """Wait for a backup to complete.""" backup_complete = asyncio.Event() @@ -327,7 +383,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ) from err return WrittenBackup( - backup=_backup_details_to_agent_backup(details), + backup=_backup_details_to_agent_backup(details, locations[0]), open_stream=open_backup, release_stream=remove_backup, ) @@ -347,20 +403,19 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): for agent_id in agent_ids if manager.backup_agents[agent_id].domain == DOMAIN ] - locations = {agent.location for agent in hassio_agents} + locations = [agent.location for agent in hassio_agents] + locations = locations or [LOCATION_CLOUD_BACKUP] backup_id = await self._client.backups.upload_backup( stream, - supervisor_backups.UploadBackupOptions( - location=locations or {LOCATION_CLOUD_BACKUP} - ), + supervisor_backups.UploadBackupOptions(location=set(locations)), ) async def open_backup() -> AsyncIterator[bytes]: return await self._client.backups.download_backup(backup_id) async def remove_backup() -> None: - if locations: + if locations != [LOCATION_CLOUD_BACKUP]: return await self._client.backups.remove_backup( backup_id, @@ -372,7 +427,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): details = await self._client.backups.backup_info(backup_id) return WrittenBackup( - backup=_backup_details_to_agent_backup(details), + backup=_backup_details_to_agent_backup(details, locations[0]), open_stream=open_backup, release_stream=remove_backup, ) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 1c257416ad0..7c2bf8921ef 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -245,6 +245,56 @@ TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete( type=TEST_BACKUP.type, ) +TEST_BACKUP_5 = supervisor_backups.Backup( + compressed=False, + content=supervisor_backups.BackupContent( + addons=["ssl"], + folders=[supervisor_backups.Folder.SHARE], + homeassistant=True, + ), + date=datetime.fromisoformat("1970-01-01T00:00:00Z"), + location=LOCATION_CLOUD_BACKUP, + location_attributes={ + LOCATION_CLOUD_BACKUP: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, + locations={LOCATION_CLOUD_BACKUP}, + name="Test", + protected=False, + size=1.0, + size_bytes=1048576, + slug="abc123", + type=supervisor_backups.BackupType.PARTIAL, +) +TEST_BACKUP_DETAILS_5 = supervisor_backups.BackupComplete( + addons=[ + supervisor_backups.BackupAddon( + name="Terminal & SSH", + size=0.0, + slug="core_ssh", + version="9.14.0", + ) + ], + compressed=TEST_BACKUP_5.compressed, + date=TEST_BACKUP_5.date, + extra=None, + folders=[supervisor_backups.Folder.SHARE], + homeassistant_exclude_database=False, + homeassistant="2024.12.0", + location=TEST_BACKUP_5.location, + location_attributes=TEST_BACKUP_5.location_attributes, + locations=TEST_BACKUP_5.locations, + name=TEST_BACKUP_5.name, + protected=TEST_BACKUP_5.protected, + repositories=[], + size=TEST_BACKUP_5.size, + size_bytes=TEST_BACKUP_5.size_bytes, + slug=TEST_BACKUP_5.slug, + supervisor_version="2024.11.2", + type=TEST_BACKUP_5.type, +) + @pytest.fixture(autouse=True) def fixture_supervisor_environ() -> Generator[None]: @@ -821,6 +871,230 @@ async def test_reader_writer_create( assert response["event"] == {"manager_state": "idle"} +@pytest.mark.usefixtures("hassio_client") +@pytest.mark.parametrize( + ( + "commands", + "password", + "agent_ids", + "password_sent_to_supervisor", + "create_locations", + "create_protected", + "upload_locations", + ), + [ + ( + [], + None, + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + None, + [None, "share1", "share2", "share3"], + False, + [], + ), + ( + [], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + [None, "share1", "share2", "share3"], + True, + [], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + ["share1", "share2", "share3"], + True, + [None], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + "hassio.share1": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + ["share2", "share3"], + True, + [None, "share1"], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + "hassio.share1": {"protected": False}, + "hassio.share2": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + None, + [None, "share1", "share2"], + True, + ["share3"], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local"], + None, + [None], + False, + [], + ), + ], +) +async def test_reader_writer_create_per_agent_encryption( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + commands: dict[str, Any], + password: str | None, + agent_ids: list[str], + password_sent_to_supervisor: str | None, + create_locations: list[str | None], + create_protected: bool, + upload_locations: list[str | None], +) -> None: + """Test generating a backup.""" + client = await hass_ws_client(hass) + mounts = MountsInfo( + default_backup_mount=None, + mounts=[ + supervisor_mounts.CIFSMountResponse( + share=f"share{i}", + name=f"share{i}", + read_only=False, + state=supervisor_mounts.MountState.ACTIVE, + user_path=f"share{i}", + usage=supervisor_mounts.MountUsage.BACKUP, + server=f"share{i}", + type=supervisor_mounts.MountType.CIFS, + ) + for i in range(1, 4) + ], + ) + supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.backup_info.return_value = replace( + TEST_BACKUP_DETAILS, + locations=create_locations, + location_attributes={ + location or LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + protected=create_protected, + size_bytes=TEST_BACKUP_DETAILS.size_bytes, + ) + for location in create_locations + }, + ) + supervisor_client.mounts.info.return_value = mounts + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] is True + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + { + "type": "backup/generate", + "agent_ids": agent_ids, + "name": "Test", + "password": password, + } + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": "abc123"} + + supervisor_client.backups.partial_backup.assert_called_once_with( + replace( + DEFAULT_BACKUP_OPTIONS, + password=password_sent_to_supervisor, + location=create_locations, + ) + ) + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + assert len(supervisor_client.backups.upload_backup.mock_calls) == len( + upload_locations + ) + for call in supervisor_client.backups.upload_backup.mock_calls: + upload_call_locations: set = call.args[1].location + assert len(upload_call_locations) == 1 + assert upload_call_locations.pop() in upload_locations + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + @pytest.mark.usefixtures("hassio_client", "setup_integration") @pytest.mark.parametrize( ("side_effect", "error_code", "error_message", "expected_reason"), @@ -969,7 +1243,7 @@ async def test_reader_writer_create_download_remove_error( """Test download and remove error when generating a backup.""" client = await hass_ws_client(hass) supervisor_client.backups.partial_backup.return_value.job_id = "abc123" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 method_mock = getattr(supervisor_client.backups, method) method_mock.side_effect = exception @@ -1129,7 +1403,7 @@ async def test_reader_writer_create_remote_backup( """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) supervisor_client.backups.partial_backup.return_value.job_id = "abc123" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1163,7 +1437,7 @@ async def test_reader_writer_create_remote_backup( assert response["result"] == {"backup_job_id": "abc123"} supervisor_client.backups.partial_backup.assert_called_once_with( - replace(DEFAULT_BACKUP_OPTIONS, location=LOCATION_CLOUD_BACKUP), + replace(DEFAULT_BACKUP_OPTIONS, location=[LOCATION_CLOUD_BACKUP]), ) await client.send_json_auto_id( @@ -1280,7 +1554,7 @@ async def test_agent_receive_remote_backup( """Test receiving a backup which will be uploaded to a remote agent.""" client = await hass_client() backup_id = "test-backup" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.backups.upload_backup.return_value = "test_slug" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], From 1d196e1b1f60402b19445a09da4313a652fb4a86 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 29 Jan 2025 18:22:41 +0100 Subject: [PATCH 1215/2987] Bump version to 2025.2.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 699aebcafdf..3fc165526ee 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 5393193a41e..55d7e7d2231 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0.dev0" +version = "2025.2.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 46cef2986c531e2f2d530fa474e4796b067f65d9 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 29 Jan 2025 19:32:36 +0100 Subject: [PATCH 1216/2987] Bump version to 2025.3.0 (#136859) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a58648212e3..863c861db75 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 11 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 9 - HA_SHORT_VERSION: "2025.2" + HA_SHORT_VERSION: "2025.3" DEFAULT_PYTHON: "3.13" ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 699aebcafdf..bdce303e64a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 -MINOR_VERSION: Final = 2 +MINOR_VERSION: Final = 3 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index 5393193a41e..31aeb180b8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0.dev0" +version = "2025.3.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From b500fde46843ac29e81a979ce366b221b3ab0fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 29 Jan 2025 18:51:09 +0000 Subject: [PATCH 1217/2987] Handle locked account error in Whirlpool (#136861) --- homeassistant/components/whirlpool/__init__.py | 6 +++++- homeassistant/components/whirlpool/config_flow.py | 4 +++- homeassistant/components/whirlpool/strings.json | 9 +++++++++ tests/components/whirlpool/test_config_flow.py | 3 +++ tests/components/whirlpool/test_init.py | 13 +++++++++++++ 5 files changed, 33 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 64adcda4742..6231324bb0d 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -5,7 +5,7 @@ import logging from aiohttp import ClientError from whirlpool.appliancesmanager import AppliancesManager -from whirlpool.auth import Auth +from whirlpool.auth import AccountLockedError as WhirlpoolAccountLocked, Auth from whirlpool.backendselector import BackendSelector from homeassistant.config_entries import ConfigEntry @@ -39,6 +39,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> await auth.do_auth(store=False) except (ClientError, TimeoutError) as ex: raise ConfigEntryNotReady("Cannot connect") from ex + except WhirlpoolAccountLocked as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="account_locked" + ) from ex if not auth.is_access_token_valid(): _LOGGER.error("Authentication failed") diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index 44445dee03f..19715643e3a 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -9,7 +9,7 @@ from typing import Any from aiohttp import ClientError import voluptuous as vol from whirlpool.appliancesmanager import AppliancesManager -from whirlpool.auth import Auth +from whirlpool.auth import AccountLockedError as WhirlpoolAccountLocked, Auth from whirlpool.backendselector import BackendSelector from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -55,6 +55,8 @@ async def authenticate( try: await auth.do_auth() + except WhirlpoolAccountLocked: + return "account_locked" except (TimeoutError, ClientError): return "cannot_connect" except Exception: diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 09257652ece..95df3fb9098 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -1,4 +1,7 @@ { + "common": { + "account_locked_error": "The account is locked. Please follow the instructions in the manufacturer's app to unlock it" + }, "config": { "step": { "user": { @@ -31,6 +34,7 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { + "account_locked": "[%key:component::whirlpool::common::account_locked_error%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", @@ -85,5 +89,10 @@ "name": "End time" } } + }, + "exceptions": { + "account_locked": { + "message": "[%key:component::whirlpool::common::account_locked_error%]" + } } } diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index a82c2a22695..e01fbc07b51 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import aiohttp import pytest +from whirlpool.auth import AccountLockedError from homeassistant import config_entries from homeassistant.components.whirlpool.const import CONF_BRAND, DOMAIN @@ -82,6 +83,7 @@ async def test_form_invalid_auth( @pytest.mark.parametrize( ("exception", "expected_error"), [ + (AccountLockedError, "account_locked"), (aiohttp.ClientConnectionError, "cannot_connect"), (TimeoutError, "cannot_connect"), (Exception, "unknown"), @@ -249,6 +251,7 @@ async def test_reauth_flow_invalid_auth( @pytest.mark.parametrize( ("exception", "expected_error"), [ + (AccountLockedError, "account_locked"), (aiohttp.ClientConnectionError, "cannot_connect"), (TimeoutError, "cannot_connect"), (Exception, "unknown"), diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index f9d28e78a06..8f082ff6294 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock import aiohttp +from whirlpool.auth import AccountLockedError from whirlpool.backendselector import Brand, Region from homeassistant.components.whirlpool.const import DOMAIN @@ -104,6 +105,18 @@ async def test_setup_auth_failed( assert entry.state is ConfigEntryState.SETUP_ERROR +async def test_setup_auth_account_locked( + hass: HomeAssistant, + mock_auth_api: MagicMock, + mock_aircon_api_instances: MagicMock, +) -> None: + """Test setup with failed auth due to account being locked.""" + mock_auth_api.return_value.do_auth.side_effect = AccountLockedError + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.SETUP_ERROR + + async def test_setup_fetch_appliances_failed( hass: HomeAssistant, mock_appliances_manager_api: MagicMock, From d206553a0da4bcafa2e840cebd29bd4baeba25bc Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 29 Jan 2025 12:52:32 -0600 Subject: [PATCH 1218/2987] Cancel call if user does not pick up (#136858) --- .../components/voip/assist_satellite.py | 64 ++++++++++++++----- homeassistant/components/voip/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/voip/test_voip.py | 40 ++++++++++++ 5 files changed, 90 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 738c3a1e235..6cacdd79af4 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Any, Final import wave from voip_utils import SIP_PORT, RtpDatagramProtocol -from voip_utils.sip import SipEndpoint, get_sip_endpoint +from voip_utils.sip import SipDatagramProtocol, SipEndpoint, get_sip_endpoint from homeassistant.components import tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType @@ -43,6 +43,7 @@ _PIPELINE_TIMEOUT_SEC: Final = 30 _ANNOUNCEMENT_BEFORE_DELAY: Final = 0.5 _ANNOUNCEMENT_AFTER_DELAY: Final = 1.0 _ANNOUNCEMENT_HANGUP_SEC: Final = 0.5 +_ANNOUNCEMENT_RING_TIMEOUT: Final = 30 class Tones(IntFlag): @@ -116,7 +117,8 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._processing_tone_done = asyncio.Event() self._announcement: AssistSatelliteAnnouncement | None = None - self._announcement_done = asyncio.Event() + self._announcement_future: asyncio.Future[Any] = asyncio.Future() + self._announcment_start_time: float = 0.0 self._check_announcement_ended_task: asyncio.Task | None = None self._last_chunk_time: float | None = None self._rtp_port: int | None = None @@ -170,7 +172,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol Plays announcement in a loop, blocking until the caller hangs up. """ - self._announcement_done.clear() + self._announcement_future = asyncio.Future() if self._rtp_port is None: # Choose random port for RTP @@ -194,16 +196,34 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol host=self.voip_device.voip_id, port=SIP_PORT ) + # Reset state so we can time out if needed + self._last_chunk_time = None + self._announcment_start_time = time.monotonic() self._announcement = announcement # Make the call - self.hass.data[DOMAIN].protocol.outgoing_call( + sip_protocol: SipDatagramProtocol = self.hass.data[DOMAIN].protocol + call_info = sip_protocol.outgoing_call( source=source_endpoint, destination=destination_endpoint, rtp_port=self._rtp_port, ) - await self._announcement_done.wait() + # Check if caller hung up or didn't pick up + self._check_announcement_ended_task = ( + self.config_entry.async_create_background_task( + self.hass, + self._check_announcement_ended(), + "voip_announcement_ended", + ) + ) + + try: + await self._announcement_future + except TimeoutError: + # Stop ringing + sip_protocol.cancel_call(call_info) + raise async def _check_announcement_ended(self) -> None: """Continuously checks if an audio chunk was received within a time limit. @@ -211,12 +231,32 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol If not, the caller is presumed to have hung up and the announcement is ended. """ while self._announcement is not None: + current_time = time.monotonic() + _LOGGER.debug( + "%s %s %s", + self._last_chunk_time, + current_time, + self._announcment_start_time, + ) + if (self._last_chunk_time is None) and ( + (current_time - self._announcment_start_time) + > _ANNOUNCEMENT_RING_TIMEOUT + ): + # Ring timeout + self._announcement = None + self._check_announcement_ended_task = None + self._announcement_future.set_exception( + TimeoutError("User did not pick up in time") + ) + _LOGGER.debug("Timed out waiting for the user to pick up the phone") + break + if (self._last_chunk_time is not None) and ( - (time.monotonic() - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC + (current_time - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC ): # Caller hung up self._announcement = None - self._announcement_done.set() + self._announcement_future.set_result(None) self._check_announcement_ended_task = None _LOGGER.debug("Announcement ended") break @@ -248,16 +288,6 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._audio_queue.put_nowait(audio_bytes) elif self._run_pipeline_task is None: # Announcement only - if self._check_announcement_ended_task is None: - # Check if caller hung up - self._check_announcement_ended_task = ( - self.config_entry.async_create_background_task( - self.hass, - self._check_announcement_ended(), - "voip_announcement_ended", - ) - ) - # Play announcement (will repeat) self._run_pipeline_task = self.config_entry.async_create_background_task( self.hass, diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index b279665a03a..e3b2861dbe5 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["voip-utils==0.3.0"] + "requirements": ["voip-utils==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index c87835f9153..9e6da1045a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2991,7 +2991,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.0 +voip-utils==0.3.1 # homeassistant.components.volkszaehler volkszaehler==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 968eec09d28..76ae46099c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2407,7 +2407,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.0 +voip-utils==0.3.1 # homeassistant.components.volvooncall volvooncall==0.10.3 diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index ac7c295c934..306857a1a44 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -941,3 +941,43 @@ async def test_voip_id_is_ip_address( await announce_task mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) + + +@pytest.mark.usefixtures("socket_enabled") +async def test_announce_timeout( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test announcement when user does not pick up the phone in time.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but some methods are not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + mock_protocol.cancel_call = Mock() + + # Very short timeout which will trigger because we don't send any audio in + with ( + patch( + "homeassistant.components.voip.assist_satellite._ANNOUNCEMENT_RING_TIMEOUT", + 0.01, + ), + ): + satellite.transport = Mock() + with pytest.raises(TimeoutError): + await satellite.async_announce(announcement) From 5286bd8f0c65deaa313cafa02b5b334ebc23c4c2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 19:55:02 +0100 Subject: [PATCH 1219/2987] Persist hassio backup restore status after core restart (#136857) * Persist hassio backup restore status after core restart * Remove useless condition --- homeassistant/components/backup/__init__.py | 2 + homeassistant/components/hassio/backup.py | 43 ++++++++++++ tests/components/conftest.py | 1 + tests/components/hassio/test_backup.py | 74 ++++++++++++++++++++- 4 files changed, 119 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index d3903c2d679..3003f94c2ed 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -31,6 +31,7 @@ from .manager import ( ManagerBackup, NewBackup, RestoreBackupEvent, + RestoreBackupState, WrittenBackup, ) from .models import AddonInfo, AgentBackup, Folder @@ -54,6 +55,7 @@ __all__ = [ "ManagerBackup", "NewBackup", "RestoreBackupEvent", + "RestoreBackupState", "WrittenBackup", "async_get_manager", ] diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index afeee1f4469..6b63ab92d5c 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -5,8 +5,10 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import logging +import os from pathlib import Path from typing import Any, cast +from uuid import UUID from aiohasupervisor import SupervisorClient from aiohasupervisor.exceptions import ( @@ -33,6 +35,7 @@ from homeassistant.components.backup import ( IncorrectPasswordError, NewBackup, RestoreBackupEvent, + RestoreBackupState, WrittenBackup, async_get_manager as async_get_backup_manager, ) @@ -47,6 +50,7 @@ from .handler import get_supervisor_client LOCATION_CLOUD_BACKUP = ".cloud_backup" LOCATION_LOCAL = ".local" MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") +RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID" _LOGGER = logging.getLogger(__name__) @@ -518,6 +522,37 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], ) -> None: """Check restore status after core restart.""" + if not (restore_job_id := os.environ.get(RESTORE_JOB_ID_ENV)): + _LOGGER.debug("No restore job ID found in environment") + return + + _LOGGER.debug("Found restore job ID %s in environment", restore_job_id) + + @callback + def on_job_progress(data: Mapping[str, Any]) -> None: + """Handle backup restore progress.""" + if data.get("done") is not True: + on_progress( + RestoreBackupEvent( + reason="", stage=None, state=RestoreBackupState.IN_PROGRESS + ) + ) + return + + on_progress( + RestoreBackupEvent( + reason="", stage=None, state=RestoreBackupState.COMPLETED + ) + ) + on_progress(IdleEvent()) + unsub() + + unsub = self._async_listen_job_events(restore_job_id, on_job_progress) + try: + await self._get_job_state(restore_job_id, on_job_progress) + except SupervisorError as err: + _LOGGER.debug("Could not get restore job %s: %s", restore_job_id, err) + unsub() @callback def _async_listen_job_events( @@ -546,6 +581,14 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ) return unsub + async def _get_job_state( + self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] + ) -> None: + """Poll a job for its state.""" + job = await self._client.jobs.get_job(UUID(job_id)) + _LOGGER.debug("Job state: %s", job) + on_event(job.to_dict()) + async def _default_agent(client: SupervisorClient) -> str: """Return the default agent for creating a backup.""" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 0cd33e28d35..ebf390e30d7 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -535,6 +535,7 @@ def supervisor_client() -> Generator[AsyncMock]: supervisor_client.discovery = AsyncMock() supervisor_client.homeassistant = AsyncMock() supervisor_client.host = AsyncMock() + supervisor_client.jobs = AsyncMock() supervisor_client.mounts.info.return_value = mounts_info_mock supervisor_client.os = AsyncMock() supervisor_client.resolution = AsyncMock() diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 7c2bf8921ef..49360783517 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -13,6 +13,7 @@ from io import StringIO import os from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch +from uuid import UUID from aiohasupervisor.exceptions import ( SupervisorBadRequestError, @@ -21,6 +22,7 @@ from aiohasupervisor.exceptions import ( ) from aiohasupervisor.models import ( backups as supervisor_backups, + jobs as supervisor_jobs, mounts as supervisor_mounts, ) from aiohasupervisor.models.mounts import MountsInfo @@ -35,7 +37,11 @@ from homeassistant.components.backup import ( Folder, ) from homeassistant.components.hassio import DOMAIN -from homeassistant.components.hassio.backup import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL +from homeassistant.components.hassio.backup import ( + LOCATION_CLOUD_BACKUP, + LOCATION_LOCAL, + RESTORE_JOB_ID_ENV, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -1802,3 +1808,69 @@ async def test_reader_writer_restore_wrong_parameters( "code": "home_assistant_error", "message": expected_error, } + + +@pytest.mark.usefixtures("hassio_client") +async def test_restore_progress_after_restart( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restore backup progress after restart.""" + + supervisor_client.jobs.get_job.return_value = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID("d17bd02be1f0437fa7264b16d38f700e"), + progress=0.0, + stage="copy_additional_locations", + done=True, + errors=[], + child_jobs=[], + ) + + with patch.dict( + os.environ, + MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["last_non_idle_event"] == { + "manager_state": "restore_backup", + "reason": "", + "stage": None, + "state": "completed", + } + assert response["result"]["state"] == "idle" + + +@pytest.mark.usefixtures("hassio_client") +async def test_restore_progress_after_restart_unknown_job( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restore backup progress after restart.""" + + supervisor_client.jobs.get_job.side_effect = SupervisorError + + with patch.dict( + os.environ, + MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["last_non_idle_event"] is None + assert response["result"]["state"] == "idle" From edabf0f8dd5b52132f4043c686f63a1ba9f4b70f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Jan 2025 09:09:00 -1000 Subject: [PATCH 1220/2987] Fix incorrect Bluetooth source address when restoring data from D-Bus (#136862) --- homeassistant/components/bluetooth/util.py | 10 +++++++++- tests/components/bluetooth/test_manager.py | 4 +++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index ca2e0180c00..738a61b6f33 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -39,6 +39,10 @@ def async_load_history_from_system( now_monotonic = monotonic_time_coarse() connectable_loaded_history: dict[str, BluetoothServiceInfoBleak] = {} all_loaded_history: dict[str, BluetoothServiceInfoBleak] = {} + adapter_to_source_address = { + adapter: details[ADAPTER_ADDRESS] + for adapter, details in adapters.adapters.items() + } # Restore local adapters for address, history in adapters.history.items(): @@ -50,7 +54,11 @@ def async_load_history_from_system( BluetoothServiceInfoBleak.from_device_and_advertisement_data( history.device, history.advertisement_data, - history.source, + # history.source is really the adapter name + # for historical compatibility since BlueZ + # does not know the MAC address of the adapter + # so we need to convert it to the source address (MAC) + adapter_to_source_address.get(history.source, history.source), now_monotonic, True, ) diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index c7fc80ba068..be23a536f49 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -426,7 +426,7 @@ async def test_restore_history_from_dbus( address: AdvertisementHistory( ble_device, generate_advertisement_data(local_name="name"), - HCI0_SOURCE_ADDRESS, + "hci0", ) } @@ -438,6 +438,8 @@ async def test_restore_history_from_dbus( await hass.async_block_till_done() assert bluetooth.async_ble_device_from_address(hass, address) is ble_device + info = bluetooth.async_last_service_info(hass, address, False) + assert info.source == "00:00:00:00:00:01" @pytest.mark.usefixtures("one_adapter") From aca9607e2fb7b35d62c1179ce79a3d484a6a0437 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 21:58:06 +0100 Subject: [PATCH 1221/2987] Bump backup store to version 1.3 (#136870) Co-authored-by: Paulus Schoutsen --- homeassistant/components/backup/store.py | 8 ++++-- .../backup/snapshots/test_store.ambr | 8 +++--- .../backup/snapshots/test_websocket.ambr | 28 +++++++++---------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 3e2a88b8168..9b4af823c77 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: STORE_DELAY_SAVE = 30 STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 2 +STORAGE_VERSION_MINOR = 3 class StoredBackupData(TypedDict): @@ -47,8 +47,10 @@ class _BackupStore(Store[StoredBackupData]): """Migrate to the new version.""" data = old_data if old_major_version == 1: - if old_minor_version < 2: - # Version 1.2 adds per agent settings, configurable backup time + if old_minor_version < 3: + # Version 1.2 bumped to 1.3 because 1.2 was changed several + # times during development. + # Version 1.3 adds per agent settings, configurable backup time # and custom days data["config"]["agents"] = {} data["config"]["schedule"]["time"] = None diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index 7069860638a..2fd81d6841a 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -39,7 +39,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -84,7 +84,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -131,7 +131,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -179,7 +179,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 7ea911496de..08c19906241 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -664,7 +664,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -778,7 +778,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -892,7 +892,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1016,7 +1016,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1183,7 +1183,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1297,7 +1297,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1413,7 +1413,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1527,7 +1527,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1645,7 +1645,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1767,7 +1767,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1881,7 +1881,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1995,7 +1995,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -2109,7 +2109,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -2223,7 +2223,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- From 6247a847bf9ae912ab152397e20e47df3591b644 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 19:55:02 +0100 Subject: [PATCH 1222/2987] Persist hassio backup restore status after core restart (#136857) * Persist hassio backup restore status after core restart * Remove useless condition --- homeassistant/components/backup/__init__.py | 2 + homeassistant/components/hassio/backup.py | 43 ++++++++++++ tests/components/conftest.py | 1 + tests/components/hassio/test_backup.py | 74 ++++++++++++++++++++- 4 files changed, 119 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index d3903c2d679..3003f94c2ed 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -31,6 +31,7 @@ from .manager import ( ManagerBackup, NewBackup, RestoreBackupEvent, + RestoreBackupState, WrittenBackup, ) from .models import AddonInfo, AgentBackup, Folder @@ -54,6 +55,7 @@ __all__ = [ "ManagerBackup", "NewBackup", "RestoreBackupEvent", + "RestoreBackupState", "WrittenBackup", "async_get_manager", ] diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index afeee1f4469..6b63ab92d5c 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -5,8 +5,10 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import logging +import os from pathlib import Path from typing import Any, cast +from uuid import UUID from aiohasupervisor import SupervisorClient from aiohasupervisor.exceptions import ( @@ -33,6 +35,7 @@ from homeassistant.components.backup import ( IncorrectPasswordError, NewBackup, RestoreBackupEvent, + RestoreBackupState, WrittenBackup, async_get_manager as async_get_backup_manager, ) @@ -47,6 +50,7 @@ from .handler import get_supervisor_client LOCATION_CLOUD_BACKUP = ".cloud_backup" LOCATION_LOCAL = ".local" MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") +RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID" _LOGGER = logging.getLogger(__name__) @@ -518,6 +522,37 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], ) -> None: """Check restore status after core restart.""" + if not (restore_job_id := os.environ.get(RESTORE_JOB_ID_ENV)): + _LOGGER.debug("No restore job ID found in environment") + return + + _LOGGER.debug("Found restore job ID %s in environment", restore_job_id) + + @callback + def on_job_progress(data: Mapping[str, Any]) -> None: + """Handle backup restore progress.""" + if data.get("done") is not True: + on_progress( + RestoreBackupEvent( + reason="", stage=None, state=RestoreBackupState.IN_PROGRESS + ) + ) + return + + on_progress( + RestoreBackupEvent( + reason="", stage=None, state=RestoreBackupState.COMPLETED + ) + ) + on_progress(IdleEvent()) + unsub() + + unsub = self._async_listen_job_events(restore_job_id, on_job_progress) + try: + await self._get_job_state(restore_job_id, on_job_progress) + except SupervisorError as err: + _LOGGER.debug("Could not get restore job %s: %s", restore_job_id, err) + unsub() @callback def _async_listen_job_events( @@ -546,6 +581,14 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ) return unsub + async def _get_job_state( + self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] + ) -> None: + """Poll a job for its state.""" + job = await self._client.jobs.get_job(UUID(job_id)) + _LOGGER.debug("Job state: %s", job) + on_event(job.to_dict()) + async def _default_agent(client: SupervisorClient) -> str: """Return the default agent for creating a backup.""" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 0cd33e28d35..ebf390e30d7 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -535,6 +535,7 @@ def supervisor_client() -> Generator[AsyncMock]: supervisor_client.discovery = AsyncMock() supervisor_client.homeassistant = AsyncMock() supervisor_client.host = AsyncMock() + supervisor_client.jobs = AsyncMock() supervisor_client.mounts.info.return_value = mounts_info_mock supervisor_client.os = AsyncMock() supervisor_client.resolution = AsyncMock() diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 7c2bf8921ef..49360783517 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -13,6 +13,7 @@ from io import StringIO import os from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch +from uuid import UUID from aiohasupervisor.exceptions import ( SupervisorBadRequestError, @@ -21,6 +22,7 @@ from aiohasupervisor.exceptions import ( ) from aiohasupervisor.models import ( backups as supervisor_backups, + jobs as supervisor_jobs, mounts as supervisor_mounts, ) from aiohasupervisor.models.mounts import MountsInfo @@ -35,7 +37,11 @@ from homeassistant.components.backup import ( Folder, ) from homeassistant.components.hassio import DOMAIN -from homeassistant.components.hassio.backup import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL +from homeassistant.components.hassio.backup import ( + LOCATION_CLOUD_BACKUP, + LOCATION_LOCAL, + RESTORE_JOB_ID_ENV, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -1802,3 +1808,69 @@ async def test_reader_writer_restore_wrong_parameters( "code": "home_assistant_error", "message": expected_error, } + + +@pytest.mark.usefixtures("hassio_client") +async def test_restore_progress_after_restart( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restore backup progress after restart.""" + + supervisor_client.jobs.get_job.return_value = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID("d17bd02be1f0437fa7264b16d38f700e"), + progress=0.0, + stage="copy_additional_locations", + done=True, + errors=[], + child_jobs=[], + ) + + with patch.dict( + os.environ, + MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["last_non_idle_event"] == { + "manager_state": "restore_backup", + "reason": "", + "stage": None, + "state": "completed", + } + assert response["result"]["state"] == "idle" + + +@pytest.mark.usefixtures("hassio_client") +async def test_restore_progress_after_restart_unknown_job( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restore backup progress after restart.""" + + supervisor_client.jobs.get_job.side_effect = SupervisorError + + with patch.dict( + os.environ, + MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["last_non_idle_event"] is None + assert response["result"]["state"] == "idle" From d338b0a2ffa4374c89d9feb8e6d6a9b5e7e2ef09 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 29 Jan 2025 12:52:32 -0600 Subject: [PATCH 1223/2987] Cancel call if user does not pick up (#136858) --- .../components/voip/assist_satellite.py | 64 ++++++++++++++----- homeassistant/components/voip/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/voip/test_voip.py | 40 ++++++++++++ 5 files changed, 90 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 738c3a1e235..6cacdd79af4 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Any, Final import wave from voip_utils import SIP_PORT, RtpDatagramProtocol -from voip_utils.sip import SipEndpoint, get_sip_endpoint +from voip_utils.sip import SipDatagramProtocol, SipEndpoint, get_sip_endpoint from homeassistant.components import tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType @@ -43,6 +43,7 @@ _PIPELINE_TIMEOUT_SEC: Final = 30 _ANNOUNCEMENT_BEFORE_DELAY: Final = 0.5 _ANNOUNCEMENT_AFTER_DELAY: Final = 1.0 _ANNOUNCEMENT_HANGUP_SEC: Final = 0.5 +_ANNOUNCEMENT_RING_TIMEOUT: Final = 30 class Tones(IntFlag): @@ -116,7 +117,8 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._processing_tone_done = asyncio.Event() self._announcement: AssistSatelliteAnnouncement | None = None - self._announcement_done = asyncio.Event() + self._announcement_future: asyncio.Future[Any] = asyncio.Future() + self._announcment_start_time: float = 0.0 self._check_announcement_ended_task: asyncio.Task | None = None self._last_chunk_time: float | None = None self._rtp_port: int | None = None @@ -170,7 +172,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol Plays announcement in a loop, blocking until the caller hangs up. """ - self._announcement_done.clear() + self._announcement_future = asyncio.Future() if self._rtp_port is None: # Choose random port for RTP @@ -194,16 +196,34 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol host=self.voip_device.voip_id, port=SIP_PORT ) + # Reset state so we can time out if needed + self._last_chunk_time = None + self._announcment_start_time = time.monotonic() self._announcement = announcement # Make the call - self.hass.data[DOMAIN].protocol.outgoing_call( + sip_protocol: SipDatagramProtocol = self.hass.data[DOMAIN].protocol + call_info = sip_protocol.outgoing_call( source=source_endpoint, destination=destination_endpoint, rtp_port=self._rtp_port, ) - await self._announcement_done.wait() + # Check if caller hung up or didn't pick up + self._check_announcement_ended_task = ( + self.config_entry.async_create_background_task( + self.hass, + self._check_announcement_ended(), + "voip_announcement_ended", + ) + ) + + try: + await self._announcement_future + except TimeoutError: + # Stop ringing + sip_protocol.cancel_call(call_info) + raise async def _check_announcement_ended(self) -> None: """Continuously checks if an audio chunk was received within a time limit. @@ -211,12 +231,32 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol If not, the caller is presumed to have hung up and the announcement is ended. """ while self._announcement is not None: + current_time = time.monotonic() + _LOGGER.debug( + "%s %s %s", + self._last_chunk_time, + current_time, + self._announcment_start_time, + ) + if (self._last_chunk_time is None) and ( + (current_time - self._announcment_start_time) + > _ANNOUNCEMENT_RING_TIMEOUT + ): + # Ring timeout + self._announcement = None + self._check_announcement_ended_task = None + self._announcement_future.set_exception( + TimeoutError("User did not pick up in time") + ) + _LOGGER.debug("Timed out waiting for the user to pick up the phone") + break + if (self._last_chunk_time is not None) and ( - (time.monotonic() - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC + (current_time - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC ): # Caller hung up self._announcement = None - self._announcement_done.set() + self._announcement_future.set_result(None) self._check_announcement_ended_task = None _LOGGER.debug("Announcement ended") break @@ -248,16 +288,6 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._audio_queue.put_nowait(audio_bytes) elif self._run_pipeline_task is None: # Announcement only - if self._check_announcement_ended_task is None: - # Check if caller hung up - self._check_announcement_ended_task = ( - self.config_entry.async_create_background_task( - self.hass, - self._check_announcement_ended(), - "voip_announcement_ended", - ) - ) - # Play announcement (will repeat) self._run_pipeline_task = self.config_entry.async_create_background_task( self.hass, diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index b279665a03a..e3b2861dbe5 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["voip-utils==0.3.0"] + "requirements": ["voip-utils==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index c87835f9153..9e6da1045a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2991,7 +2991,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.0 +voip-utils==0.3.1 # homeassistant.components.volkszaehler volkszaehler==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 968eec09d28..76ae46099c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2407,7 +2407,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.0 +voip-utils==0.3.1 # homeassistant.components.volvooncall volvooncall==0.10.3 diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index ac7c295c934..306857a1a44 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -941,3 +941,43 @@ async def test_voip_id_is_ip_address( await announce_task mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) + + +@pytest.mark.usefixtures("socket_enabled") +async def test_announce_timeout( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test announcement when user does not pick up the phone in time.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but some methods are not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + mock_protocol.cancel_call = Mock() + + # Very short timeout which will trigger because we don't send any audio in + with ( + patch( + "homeassistant.components.voip.assist_satellite._ANNOUNCEMENT_RING_TIMEOUT", + 0.01, + ), + ): + satellite.transport = Mock() + with pytest.raises(TimeoutError): + await satellite.async_announce(announcement) From 0f97747d276093141124988d353799230d9d1087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 29 Jan 2025 18:51:09 +0000 Subject: [PATCH 1224/2987] Handle locked account error in Whirlpool (#136861) --- homeassistant/components/whirlpool/__init__.py | 6 +++++- homeassistant/components/whirlpool/config_flow.py | 4 +++- homeassistant/components/whirlpool/strings.json | 9 +++++++++ tests/components/whirlpool/test_config_flow.py | 3 +++ tests/components/whirlpool/test_init.py | 13 +++++++++++++ 5 files changed, 33 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 64adcda4742..6231324bb0d 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -5,7 +5,7 @@ import logging from aiohttp import ClientError from whirlpool.appliancesmanager import AppliancesManager -from whirlpool.auth import Auth +from whirlpool.auth import AccountLockedError as WhirlpoolAccountLocked, Auth from whirlpool.backendselector import BackendSelector from homeassistant.config_entries import ConfigEntry @@ -39,6 +39,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> await auth.do_auth(store=False) except (ClientError, TimeoutError) as ex: raise ConfigEntryNotReady("Cannot connect") from ex + except WhirlpoolAccountLocked as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="account_locked" + ) from ex if not auth.is_access_token_valid(): _LOGGER.error("Authentication failed") diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index 44445dee03f..19715643e3a 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -9,7 +9,7 @@ from typing import Any from aiohttp import ClientError import voluptuous as vol from whirlpool.appliancesmanager import AppliancesManager -from whirlpool.auth import Auth +from whirlpool.auth import AccountLockedError as WhirlpoolAccountLocked, Auth from whirlpool.backendselector import BackendSelector from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -55,6 +55,8 @@ async def authenticate( try: await auth.do_auth() + except WhirlpoolAccountLocked: + return "account_locked" except (TimeoutError, ClientError): return "cannot_connect" except Exception: diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 09257652ece..95df3fb9098 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -1,4 +1,7 @@ { + "common": { + "account_locked_error": "The account is locked. Please follow the instructions in the manufacturer's app to unlock it" + }, "config": { "step": { "user": { @@ -31,6 +34,7 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { + "account_locked": "[%key:component::whirlpool::common::account_locked_error%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", @@ -85,5 +89,10 @@ "name": "End time" } } + }, + "exceptions": { + "account_locked": { + "message": "[%key:component::whirlpool::common::account_locked_error%]" + } } } diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index a82c2a22695..e01fbc07b51 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import aiohttp import pytest +from whirlpool.auth import AccountLockedError from homeassistant import config_entries from homeassistant.components.whirlpool.const import CONF_BRAND, DOMAIN @@ -82,6 +83,7 @@ async def test_form_invalid_auth( @pytest.mark.parametrize( ("exception", "expected_error"), [ + (AccountLockedError, "account_locked"), (aiohttp.ClientConnectionError, "cannot_connect"), (TimeoutError, "cannot_connect"), (Exception, "unknown"), @@ -249,6 +251,7 @@ async def test_reauth_flow_invalid_auth( @pytest.mark.parametrize( ("exception", "expected_error"), [ + (AccountLockedError, "account_locked"), (aiohttp.ClientConnectionError, "cannot_connect"), (TimeoutError, "cannot_connect"), (Exception, "unknown"), diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index f9d28e78a06..8f082ff6294 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock import aiohttp +from whirlpool.auth import AccountLockedError from whirlpool.backendselector import Brand, Region from homeassistant.components.whirlpool.const import DOMAIN @@ -104,6 +105,18 @@ async def test_setup_auth_failed( assert entry.state is ConfigEntryState.SETUP_ERROR +async def test_setup_auth_account_locked( + hass: HomeAssistant, + mock_auth_api: MagicMock, + mock_aircon_api_instances: MagicMock, +) -> None: + """Test setup with failed auth due to account being locked.""" + mock_auth_api.return_value.do_auth.side_effect = AccountLockedError + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.SETUP_ERROR + + async def test_setup_fetch_appliances_failed( hass: HomeAssistant, mock_appliances_manager_api: MagicMock, From 9c0fa327a6a8708ab50b09a8d7137ead6271ea77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Jan 2025 09:09:00 -1000 Subject: [PATCH 1225/2987] Fix incorrect Bluetooth source address when restoring data from D-Bus (#136862) --- homeassistant/components/bluetooth/util.py | 10 +++++++++- tests/components/bluetooth/test_manager.py | 4 +++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index ca2e0180c00..738a61b6f33 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -39,6 +39,10 @@ def async_load_history_from_system( now_monotonic = monotonic_time_coarse() connectable_loaded_history: dict[str, BluetoothServiceInfoBleak] = {} all_loaded_history: dict[str, BluetoothServiceInfoBleak] = {} + adapter_to_source_address = { + adapter: details[ADAPTER_ADDRESS] + for adapter, details in adapters.adapters.items() + } # Restore local adapters for address, history in adapters.history.items(): @@ -50,7 +54,11 @@ def async_load_history_from_system( BluetoothServiceInfoBleak.from_device_and_advertisement_data( history.device, history.advertisement_data, - history.source, + # history.source is really the adapter name + # for historical compatibility since BlueZ + # does not know the MAC address of the adapter + # so we need to convert it to the source address (MAC) + adapter_to_source_address.get(history.source, history.source), now_monotonic, True, ) diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index c7fc80ba068..be23a536f49 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -426,7 +426,7 @@ async def test_restore_history_from_dbus( address: AdvertisementHistory( ble_device, generate_advertisement_data(local_name="name"), - HCI0_SOURCE_ADDRESS, + "hci0", ) } @@ -438,6 +438,8 @@ async def test_restore_history_from_dbus( await hass.async_block_till_done() assert bluetooth.async_ble_device_from_address(hass, address) is ble_device + info = bluetooth.async_last_service_info(hass, address, False) + assert info.source == "00:00:00:00:00:01" @pytest.mark.usefixtures("one_adapter") From 49b90fc140e17a88477523db44ca4624c81b6d8f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 21:58:06 +0100 Subject: [PATCH 1226/2987] Bump backup store to version 1.3 (#136870) Co-authored-by: Paulus Schoutsen --- homeassistant/components/backup/store.py | 8 ++++-- .../backup/snapshots/test_store.ambr | 8 +++--- .../backup/snapshots/test_websocket.ambr | 28 +++++++++---------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 3e2a88b8168..9b4af823c77 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: STORE_DELAY_SAVE = 30 STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 2 +STORAGE_VERSION_MINOR = 3 class StoredBackupData(TypedDict): @@ -47,8 +47,10 @@ class _BackupStore(Store[StoredBackupData]): """Migrate to the new version.""" data = old_data if old_major_version == 1: - if old_minor_version < 2: - # Version 1.2 adds per agent settings, configurable backup time + if old_minor_version < 3: + # Version 1.2 bumped to 1.3 because 1.2 was changed several + # times during development. + # Version 1.3 adds per agent settings, configurable backup time # and custom days data["config"]["agents"] = {} data["config"]["schedule"]["time"] = None diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index 7069860638a..2fd81d6841a 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -39,7 +39,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -84,7 +84,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -131,7 +131,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -179,7 +179,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 7ea911496de..08c19906241 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -664,7 +664,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -778,7 +778,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -892,7 +892,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1016,7 +1016,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1183,7 +1183,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1297,7 +1297,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1413,7 +1413,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1527,7 +1527,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1645,7 +1645,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1767,7 +1767,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1881,7 +1881,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1995,7 +1995,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -2109,7 +2109,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -2223,7 +2223,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- From 9c8d31a3d5c33af3e4c6847612471501136ad691 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Jan 2025 21:18:11 +0000 Subject: [PATCH 1227/2987] Bump version to 2025.2.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3fc165526ee..77b223fcbcf 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 55d7e7d2231..a592b8a194d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b0" +version = "2025.2.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 40662896621a377a09796ba73385d8d9b033b364 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 29 Jan 2025 23:32:16 +0100 Subject: [PATCH 1228/2987] Update quality scale in Onkyo (#136710) --- homeassistant/components/onkyo/quality_scale.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onkyo/quality_scale.yaml b/homeassistant/components/onkyo/quality_scale.yaml index cdcf88e72d7..4b9fbe7c019 100644 --- a/homeassistant/components/onkyo/quality_scale.yaml +++ b/homeassistant/components/onkyo/quality_scale.yaml @@ -16,7 +16,7 @@ rules: docs-actions: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: done comment: | @@ -45,8 +45,8 @@ rules: # Gold devices: todo diagnostics: todo - discovery: todo - discovery-update-info: todo + discovery: done + discovery-update-info: done docs-data-update: todo docs-examples: todo docs-known-limitations: todo From 4e3e1e91b7adac7142870ed401c3eeee06c1ad58 Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 30 Jan 2025 12:01:39 +1100 Subject: [PATCH 1229/2987] Fix loading of SMLIGHT integration when no internet is available (#136497) * Don't fail to load integration if internet unavailable * Add test case for no internet * Also test we recover after internet returns --- .../components/smlight/coordinator.py | 16 ++++--- tests/components/smlight/test_init.py | 44 ++++++++++++++++++- tests/components/smlight/test_update.py | 2 +- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 5b38ec4a89e..6be36439e9f 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -144,11 +144,15 @@ class SmFirmwareUpdateCoordinator(SmBaseDataUpdateCoordinator[SmFwData]): async def _internal_update_data(self) -> SmFwData: """Fetch data from the SMLIGHT device.""" info = await self.client.get_info() + esp_firmware = None + zb_firmware = None - return SmFwData( - info=info, - esp_firmware=await self.client.get_firmware_version(info.fw_channel), - zb_firmware=await self.client.get_firmware_version( + try: + esp_firmware = await self.client.get_firmware_version(info.fw_channel) + zb_firmware = await self.client.get_firmware_version( info.fw_channel, device=info.model, mode="zigbee" - ), - ) + ) + except SmlightConnectionError as err: + self.async_set_update_error(err) + + return SmFwData(info=info, esp_firmware=esp_firmware, zb_firmware=zb_firmware) diff --git a/tests/components/smlight/test_init.py b/tests/components/smlight/test_init.py index afc53932fb0..d0c5e494ae8 100644 --- a/tests/components/smlight/test_init.py +++ b/tests/components/smlight/test_init.py @@ -8,9 +8,14 @@ from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError, Smlig import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smlight.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.smlight.const import ( + DOMAIN, + SCAN_FIRMWARE_INTERVAL, + SCAN_INTERVAL, +) +from homeassistant.components.update import ATTR_INSTALLED_VERSION from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.issue_registry import IssueRegistry @@ -73,6 +78,41 @@ async def test_async_setup_missing_credentials( assert progress[0]["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" +async def test_async_setup_no_internet( + hass: HomeAssistant, + mock_config_entry_host: MockConfigEntry, + mock_smlight_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we still load integration when no internet is available.""" + mock_smlight_client.get_firmware_version.side_effect = SmlightConnectionError + + await setup_integration(hass, mock_config_entry_host) + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_UNKNOWN + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_UNKNOWN + + mock_smlight_client.get_firmware_version.side_effect = None + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_ON + assert entity.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" + + @pytest.mark.parametrize("error", [SmlightConnectionError, SmlightAuthError]) async def test_update_failed( hass: HomeAssistant, diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index 0bb2e34d7ca..4fca7369116 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -81,7 +81,7 @@ async def test_update_setup( mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test setup of SMLIGHT switches.""" + """Test setup of SMLIGHT update entities.""" entry = await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) From b637129208e45620a1e6d264b92844f16787f49b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 30 Jan 2025 02:42:41 +0100 Subject: [PATCH 1230/2987] Migrate from homeconnect dependency to aiohomeconnect (#136116) * Migrate from homeconnect dependency to aiohomeconnect * Reload the integration if there is an API error on event stream * fix typos at coordinator tests * Setup config entry at coordinator tests * fix ruff * Bump aiohomeconnect to version 0.11.4 * Fix set program options * Use context based updates at coordinator * Improved how `context_callbacks` cache is invalidated * fix * fixes and improvements at coordinator Co-authored-by: Martin Hjelmare * Remove stale Entity inheritance * Small improvement for light subscriptions * Remove non-needed function It had its purpose before some refactoring before the firs commit, no is no needed as is only used at HomeConnectEntity constructor * Static methods and variables at conftest * Refresh the data after an event stream interruption * Cleaned debug logs * Fetch programs at coordinator * Improvements Co-authored-by: Martin Hjelmare * Simplify obtaining power settings from coordinator data Co-authored-by: Martin Hjelmare * Remove unnecessary statement * use `is UNDEFINED` instead of `isinstance` * Request power setting only when it is strictly necessary * Bump aiohomeconnect to 0.12.1 * use raw keys for diagnostics * Use keyword arguments where needed * Remove unnecessary statements Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/__init__.py | 325 +++++----- homeassistant/components/home_connect/api.py | 79 +-- .../home_connect/application_credentials.py | 4 +- .../components/home_connect/binary_sensor.py | 102 ++- .../components/home_connect/const.py | 129 +--- .../components/home_connect/coordinator.py | 258 ++++++++ .../components/home_connect/diagnostics.py | 49 +- .../components/home_connect/entity.py | 59 +- .../components/home_connect/light.py | 241 +++---- .../components/home_connect/manifest.json | 2 +- .../components/home_connect/number.py | 101 +-- .../components/home_connect/select.py | 278 ++------- .../components/home_connect/sensor.py | 224 ++++--- .../components/home_connect/strings.json | 39 +- .../components/home_connect/switch.py | 290 ++++----- homeassistant/components/home_connect/time.py | 61 +- .../components/home_connect/utils.py | 29 + requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/home_connect/conftest.py | 380 ++++++----- .../home_connect/fixtures/settings.json | 24 +- .../snapshots/test_diagnostics.ambr | 588 +++++++----------- .../home_connect/test_binary_sensor.py | 145 +++-- .../home_connect/test_config_flow.py | 7 +- .../home_connect/test_coordinator.py | 367 +++++++++++ .../home_connect/test_diagnostics.py | 90 +-- tests/components/home_connect/test_init.py | 220 ++++--- tests/components/home_connect/test_light.py | 400 +++++++----- tests/components/home_connect/test_number.py | 154 +++-- tests/components/home_connect/test_select.py | 202 +++--- tests/components/home_connect/test_sensor.py | 249 +++++--- tests/components/home_connect/test_switch.py | 548 +++++++++------- tests/components/home_connect/test_time.py | 102 ++- 33 files changed, 3117 insertions(+), 2641 deletions(-) create mode 100644 homeassistant/components/home_connect/coordinator.py create mode 100644 homeassistant/components/home_connect/utils.py create mode 100644 tests/components/home_connect/test_coordinator.py diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index d7c042c2a91..a019ae0f250 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -2,17 +2,16 @@ from __future__ import annotations -from datetime import timedelta import logging -import re from typing import Any, cast -from requests import HTTPError +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import CommandKey, Option, OptionKey +from aiohomeconnect.model.error import HomeConnectError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_ID, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_entry_oauth2_flow, @@ -21,16 +20,13 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle -from . import api +from .api import AsyncConfigEntryAuth from .const import ( ATTR_KEY, ATTR_PROGRAM, ATTR_UNIT, ATTR_VALUE, - BSH_PAUSE, - BSH_RESUME, DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP, SERVICE_OPTION_ACTIVE, @@ -44,15 +40,11 @@ from .const import ( SVE_TRANSLATION_PLACEHOLDER_PROGRAM, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) - -type HomeConnectConfigEntry = ConfigEntry[api.ConfigEntryAuth] +from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) -RE_CAMEL_CASE = re.compile(r"(? api.HomeConnectAppliance: - """Return a Home Connect appliance instance given a device id or a device entry.""" - if device_id is not None and device_entry is None: - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get(device_id) - assert device_entry, "Either a device id or a device entry must be provided" +async def _get_client_and_ha_id( + hass: HomeAssistant, device_id: str +) -> tuple[HomeConnectClient, str]: + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_id) + if device_entry is None: + raise ServiceValidationError("Device entry not found for device id") + entry: HomeConnectConfigEntry | None = None + for entry_id in device_entry.config_entries: + _entry = hass.config_entries.async_get_entry(entry_id) + assert _entry + if _entry.domain == DOMAIN: + entry = cast(HomeConnectConfigEntry, _entry) + break + if entry is None: + raise ServiceValidationError( + "Home Connect config entry not found for that device id" + ) ha_id = next( ( @@ -119,158 +118,148 @@ def _get_appliance( ), None, ) - assert ha_id - - def find_appliance( - entry: HomeConnectConfigEntry, - ) -> api.HomeConnectAppliance | None: - for device in entry.runtime_data.devices: - appliance = device.appliance - if appliance.haId == ha_id: - return appliance - return None - - if entry is None: - for entry_id in device_entry.config_entries: - entry = hass.config_entries.async_get_entry(entry_id) - assert entry - if entry.domain == DOMAIN: - entry = cast(HomeConnectConfigEntry, entry) - if (appliance := find_appliance(entry)) is not None: - return appliance - elif (appliance := find_appliance(entry)) is not None: - return appliance - raise ValueError(f"Appliance for device id {device_entry.id} not found") - - -def _get_appliance_or_raise_service_validation_error( - hass: HomeAssistant, device_id: str -) -> api.HomeConnectAppliance: - """Return a Home Connect appliance instance or raise a service validation error.""" - try: - return _get_appliance(hass, device_id) - except (ValueError, AssertionError) as err: + if ha_id is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="appliance_not_found", translation_placeholders={ "device_id": device_id, }, - ) from err - - -async def _run_appliance_service[*_Ts]( - hass: HomeAssistant, - appliance: api.HomeConnectAppliance, - method: str, - *args: *_Ts, - error_translation_key: str, - error_translation_placeholders: dict[str, str], -) -> None: - try: - await hass.async_add_executor_job(getattr(appliance, method), *args) - except api.HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=error_translation_key, - translation_placeholders={ - **get_dict_from_home_connect_error(err), - **error_translation_placeholders, - }, - ) from err + ) + return entry.runtime_data.client, ha_id async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" - async def _async_service_program(call, method): + async def _async_service_program(call: ServiceCall, start: bool): """Execute calls to services taking a program.""" program = call.data[ATTR_PROGRAM] - device_id = call.data[ATTR_DEVICE_ID] - - options = [] + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) option_key = call.data.get(ATTR_KEY) - if option_key is not None: - option = {ATTR_KEY: option_key, ATTR_VALUE: call.data[ATTR_VALUE]} - - option_unit = call.data.get(ATTR_UNIT) - if option_unit is not None: - option[ATTR_UNIT] = option_unit - - options.append(option) - await _run_appliance_service( - hass, - _get_appliance_or_raise_service_validation_error(hass, device_id), - method, - program, - options, - error_translation_key=method, - error_translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program, - }, + options = ( + [ + Option( + OptionKey(option_key), + call.data[ATTR_VALUE], + unit=call.data.get(ATTR_UNIT), + ) + ] + if option_key is not None + else None ) - async def _async_service_command(call, command): - """Execute calls to services executing a command.""" - device_id = call.data[ATTR_DEVICE_ID] + try: + if start: + await client.start_program(ha_id, program_key=program, options=options) + else: + await client.set_selected_program( + ha_id, program_key=program, options=options + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="start_program" if start else "select_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program, + }, + ) from err - appliance = _get_appliance_or_raise_service_validation_error(hass, device_id) - await _run_appliance_service( - hass, - appliance, - "execute_command", - command, - error_translation_key="execute_command", - error_translation_placeholders={"command": command}, - ) - - async def _async_service_key_value(call, method): - """Execute calls to services taking a key and value.""" - key = call.data[ATTR_KEY] + async def _async_service_set_program_options(call: ServiceCall, active: bool): + """Execute calls to services taking a program.""" + option_key = call.data[ATTR_KEY] value = call.data[ATTR_VALUE] unit = call.data.get(ATTR_UNIT) - device_id = call.data[ATTR_DEVICE_ID] + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - await _run_appliance_service( - hass, - _get_appliance_or_raise_service_validation_error(hass, device_id), - method, - *((key, value) if unit is None else (key, value, unit)), - error_translation_key=method, - error_translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_KEY: key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), - }, - ) + try: + if active: + await client.set_active_program_option( + ha_id, + option_key=OptionKey(option_key), + value=value, + unit=unit, + ) + else: + await client.set_selected_program_option( + ha_id, + option_key=OptionKey(option_key), + value=value, + unit=unit, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_options_active_program" + if active + else "set_options_selected_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_KEY: option_key, + SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + }, + ) from err - async def async_service_option_active(call): + async def _async_service_command(call: ServiceCall, command_key: CommandKey): + """Execute calls to services executing a command.""" + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) + + try: + await client.put_command(ha_id, command_key=command_key, value=True) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="execute_command", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "command": command_key.value, + }, + ) from err + + async def async_service_option_active(call: ServiceCall): """Service for setting an option for an active program.""" - await _async_service_key_value(call, "set_options_active_program") + await _async_service_set_program_options(call, True) - async def async_service_option_selected(call): + async def async_service_option_selected(call: ServiceCall): """Service for setting an option for a selected program.""" - await _async_service_key_value(call, "set_options_selected_program") + await _async_service_set_program_options(call, False) - async def async_service_setting(call): + async def async_service_setting(call: ServiceCall): """Service for changing a setting.""" - await _async_service_key_value(call, "set_setting") + key = call.data[ATTR_KEY] + value = call.data[ATTR_VALUE] + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - async def async_service_pause_program(call): + try: + await client.set_setting(ha_id, setting_key=key, value=value) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_setting", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_KEY: key, + SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + }, + ) from err + + async def async_service_pause_program(call: ServiceCall): """Service for pausing a program.""" - await _async_service_command(call, BSH_PAUSE) + await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM) - async def async_service_resume_program(call): + async def async_service_resume_program(call: ServiceCall): """Service for resuming a paused program.""" - await _async_service_command(call, BSH_RESUME) + await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM) - async def async_service_select_program(call): + async def async_service_select_program(call: ServiceCall): """Service for selecting a program.""" - await _async_service_program(call, "select_program") + await _async_service_program(call, False) - async def async_service_start_program(call): + async def async_service_start_program(call: ServiceCall): """Service for starting a program.""" - await _async_service_program(call, "start_program") + await _async_service_program(call, True) hass.services.async_register( DOMAIN, @@ -323,12 +312,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) ) ) - entry.runtime_data = api.ConfigEntryAuth(hass, entry, implementation) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - await update_all_devices(hass, entry) + config_entry_auth = AsyncConfigEntryAuth(hass, session) + + home_connect_client = HomeConnectClient(config_entry_auth) + + coordinator = HomeConnectCoordinator(hass, entry, home_connect_client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.runtime_data.start_event_listener() + return True @@ -339,21 +337,6 @@ async def async_unload_entry( return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -@Throttle(SCAN_INTERVAL) -async def update_all_devices( - hass: HomeAssistant, entry: HomeConnectConfigEntry -) -> None: - """Update all the devices.""" - hc_api = entry.runtime_data - - try: - await hass.async_add_executor_job(hc_api.get_devices) - for device in hc_api.devices: - await hass.async_add_executor_job(device.initialize) - except HTTPError as err: - _LOGGER.warning("Cannot update devices: %s", err.response.status_code) - - async def async_migrate_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> bool: @@ -382,25 +365,3 @@ async def async_migrate_entry( _LOGGER.debug("Migration to version %s successful", entry.version) return True - - -def get_dict_from_home_connect_error(err: api.HomeConnectError) -> dict[str, Any]: - """Return a dict from a Home Connect error.""" - return { - "description": cast(dict[str, Any], err.args[0]).get("description", "?") - if len(err.args) > 0 and isinstance(err.args[0], dict) - else err.args[0] - if len(err.args) > 0 and isinstance(err.args[0], str) - else "?", - } - - -def bsh_key_to_translation_key(bsh_key: str) -> str: - """Convert a BSH key to a translation key format. - - This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`, - and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`. - """ - return "_".join( - RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".") - ).lower() diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index 453f926c402..5d711dae032 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -1,85 +1,28 @@ """API for Home Connect bound to HASS OAuth.""" -from asyncio import run_coroutine_threadsafe -import logging +from aiohomeconnect.client import AbstractAuth +from aiohomeconnect.const import API_ENDPOINT -import homeconnect -from homeconnect.api import HomeConnectAppliance, HomeConnectError - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.dispatcher import dispatcher_send - -from .const import ATTR_KEY, ATTR_VALUE, BSH_ACTIVE_PROGRAM, SIGNAL_UPDATE_ENTITIES - -_LOGGER = logging.getLogger(__name__) +from homeassistant.helpers.httpx_client import get_async_client -class ConfigEntryAuth(homeconnect.HomeConnectAPI): +class AsyncConfigEntryAuth(AbstractAuth): """Provide Home Connect authentication tied to an OAuth2 based config entry.""" def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, - implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, + oauth_session: config_entry_oauth2_flow.OAuth2Session, ) -> None: """Initialize Home Connect Auth.""" self.hass = hass - self.config_entry = config_entry - self.session = config_entry_oauth2_flow.OAuth2Session( - hass, config_entry, implementation - ) - super().__init__(self.session.token) - self.devices: list[HomeConnectDevice] = [] + super().__init__(get_async_client(hass), host=API_ENDPOINT) + self.session = oauth_session - def refresh_tokens(self) -> dict: - """Refresh and return new Home Connect tokens using Home Assistant OAuth2 session.""" - run_coroutine_threadsafe( - self.session.async_ensure_token_valid(), self.hass.loop - ).result() + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + await self.session.async_ensure_token_valid() - return self.session.token - - def get_devices(self) -> list[HomeConnectAppliance]: - """Get a dictionary of devices.""" - appl: list[HomeConnectAppliance] = self.get_appliances() - self.devices = [HomeConnectDevice(self.hass, app) for app in appl] - return self.devices - - -class HomeConnectDevice: - """Generic Home Connect device.""" - - def __init__(self, hass: HomeAssistant, appliance: HomeConnectAppliance) -> None: - """Initialize the device class.""" - self.hass = hass - self.appliance = appliance - - def initialize(self) -> None: - """Fetch the info needed to initialize the device.""" - try: - self.appliance.get_status() - except (HomeConnectError, ValueError): - _LOGGER.debug("Unable to fetch appliance status. Probably offline") - try: - self.appliance.get_settings() - except (HomeConnectError, ValueError): - _LOGGER.debug("Unable to fetch settings. Probably offline") - try: - program_active = self.appliance.get_programs_active() - except (HomeConnectError, ValueError): - _LOGGER.debug("Unable to fetch active programs. Probably offline") - program_active = None - if program_active and ATTR_KEY in program_active: - self.appliance.status[BSH_ACTIVE_PROGRAM] = { - ATTR_VALUE: program_active[ATTR_KEY] - } - self.appliance.listen_events(callback=self.event_callback) - - def event_callback(self, appliance: HomeConnectAppliance) -> None: - """Handle event.""" - _LOGGER.debug("Update triggered on %s", appliance.name) - _LOGGER.debug(self.appliance.status) - dispatcher_send(self.hass, SIGNAL_UPDATE_ENTITIES, appliance.haId) + return self.session.token["access_token"] diff --git a/homeassistant/components/home_connect/application_credentials.py b/homeassistant/components/home_connect/application_credentials.py index 3d5a407b487..d66255e6810 100644 --- a/homeassistant/components/home_connect/application_credentials.py +++ b/homeassistant/components/home_connect/application_credentials.py @@ -1,10 +1,10 @@ """Application credentials platform for Home Connect.""" +from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant -from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN - async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: """Return authorization server.""" diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index f9775918f16..90743c829e2 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -1,7 +1,9 @@ """Provides a binary sensor for Home Connect.""" from dataclasses import dataclass -import logging +from typing import cast + +from aiohomeconnect.model import StatusKey from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( @@ -19,26 +21,21 @@ from homeassistant.helpers.issue_registry import ( async_delete_issue, ) -from . import HomeConnectConfigEntry -from .api import HomeConnectDevice from .const import ( - ATTR_VALUE, - BSH_DOOR_STATE, BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, - BSH_REMOTE_CONTROL_ACTIVATION_STATE, - BSH_REMOTE_START_ALLOWANCE_STATE, DOMAIN, - REFRIGERATION_STATUS_DOOR_CHILLER, REFRIGERATION_STATUS_DOOR_CLOSED, - REFRIGERATION_STATUS_DOOR_FREEZER, REFRIGERATION_STATUS_DOOR_OPEN, - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, +) +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, ) from .entity import HomeConnectEntity -_LOGGER = logging.getLogger(__name__) REFRIGERATION_DOOR_BOOLEAN_MAP = { REFRIGERATION_STATUS_DOOR_CLOSED: False, REFRIGERATION_STATUS_DOOR_OPEN: True, @@ -54,19 +51,19 @@ class HomeConnectBinarySensorEntityDescription(BinarySensorEntityDescription): BINARY_SENSORS = ( HomeConnectBinarySensorEntityDescription( - key=BSH_REMOTE_CONTROL_ACTIVATION_STATE, + key=StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE, translation_key="remote_control", ), HomeConnectBinarySensorEntityDescription( - key=BSH_REMOTE_START_ALLOWANCE_STATE, + key=StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED, translation_key="remote_start", ), HomeConnectBinarySensorEntityDescription( - key="BSH.Common.Status.LocalControlActive", + key=StatusKey.BSH_COMMON_LOCAL_CONTROL_ACTIVE, translation_key="local_control", ), HomeConnectBinarySensorEntityDescription( - key="BSH.Common.Status.BatteryChargingState", + key=StatusKey.BSH_COMMON_BATTERY_CHARGING_STATE, device_class=BinarySensorDeviceClass.BATTERY_CHARGING, boolean_map={ "BSH.Common.EnumType.BatteryChargingState.Charging": True, @@ -75,7 +72,7 @@ BINARY_SENSORS = ( translation_key="battery_charging_state", ), HomeConnectBinarySensorEntityDescription( - key="BSH.Common.Status.ChargingConnection", + key=StatusKey.BSH_COMMON_CHARGING_CONNECTION, device_class=BinarySensorDeviceClass.PLUG, boolean_map={ "BSH.Common.EnumType.ChargingConnection.Connected": True, @@ -84,31 +81,31 @@ BINARY_SENSORS = ( translation_key="charging_connection", ), HomeConnectBinarySensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.DustBoxInserted", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_DUST_BOX_INSERTED, translation_key="dust_box_inserted", ), HomeConnectBinarySensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.Lifted", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LIFTED, translation_key="lifted", ), HomeConnectBinarySensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.Lost", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LOST, translation_key="lost", ), HomeConnectBinarySensorEntityDescription( - key=REFRIGERATION_STATUS_DOOR_CHILLER, + key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_COMMON, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, translation_key="chiller_door", ), HomeConnectBinarySensorEntityDescription( - key=REFRIGERATION_STATUS_DOOR_FREEZER, + key=StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, translation_key="freezer_door", ), HomeConnectBinarySensorEntityDescription( - key=REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + key=StatusKey.REFRIGERATION_COMMON_DOOR_REFRIGERATOR, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, translation_key="refrigerator_door", @@ -123,19 +120,17 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect binary sensor.""" - def get_entities() -> list[BinarySensorEntity]: - entities: list[BinarySensorEntity] = [] - for device in entry.runtime_data.devices: - entities.extend( - HomeConnectBinarySensor(device, description) - for description in BINARY_SENSORS - if description.key in device.appliance.status - ) - if BSH_DOOR_STATE in device.appliance.status: - entities.append(HomeConnectDoorBinarySensor(device)) - return entities + entities: list[BinarySensorEntity] = [] + for appliance in entry.runtime_data.data.values(): + entities.extend( + HomeConnectBinarySensor(entry.runtime_data, appliance, description) + for description in BINARY_SENSORS + if description.key in appliance.status + ) + if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status: + entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance)) - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(entities) class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): @@ -143,25 +138,15 @@ class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): entity_description: HomeConnectBinarySensorEntityDescription - @property - def available(self) -> bool: - """Return true if the binary sensor is available.""" - return self._attr_is_on is not None - - async def async_update(self) -> None: - """Update the binary sensor's status.""" - if not self.device.appliance.status or not ( - status := self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) - ): - self._attr_is_on = None - return - if self.entity_description.boolean_map: - self._attr_is_on = self.entity_description.boolean_map.get(status) - elif status not in [True, False]: - self._attr_is_on = None - else: + def update_native_value(self) -> None: + """Set the native value of the binary sensor.""" + status = self.appliance.status[cast(StatusKey, self.bsh_key)].value + if isinstance(status, bool): self._attr_is_on = status - _LOGGER.debug("Updated, new state: %s", self._attr_is_on) + elif self.entity_description.boolean_map: + self._attr_is_on = self.entity_description.boolean_map.get(status) + else: + self._attr_is_on = None class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): @@ -171,13 +156,15 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): def __init__( self, - device: HomeConnectDevice, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, ) -> None: """Initialize the entity.""" super().__init__( - device, + coordinator, + appliance, HomeConnectBinarySensorEntityDescription( - key=BSH_DOOR_STATE, + key=StatusKey.BSH_COMMON_DOOR_STATE, device_class=BinarySensorDeviceClass.DOOR, boolean_map={ BSH_DOOR_STATE_CLOSED: False, @@ -186,8 +173,8 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): }, ), ) - self._attr_unique_id = f"{device.appliance.haId}-Door" - self._attr_name = f"{device.appliance.name} Door" + self._attr_unique_id = f"{appliance.info.ha_id}-Door" + self._attr_name = f"{appliance.info.name} Door" async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" @@ -234,6 +221,7 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): async def async_will_remove_from_hass(self) -> None: """Call when entity will be removed from hass.""" + await super().async_will_remove_from_hass() async_delete_issue( self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}" ) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index e20cf3b1fa0..127aa1ffe92 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -1,9 +1,9 @@ """Constants for the Home Connect integration.""" +from aiohomeconnect.model import EventKey, SettingKey, StatusKey + DOMAIN = "home_connect" -OAUTH2_AUTHORIZE = "https://api.home-connect.com/security/oauth/authorize" -OAUTH2_TOKEN = "https://api.home-connect.com/security/oauth/token" APPLIANCES_WITH_PROGRAMS = ( "CleaningRobot", @@ -17,93 +17,35 @@ APPLIANCES_WITH_PROGRAMS = ( "WasherDryer", ) -BSH_POWER_STATE = "BSH.Common.Setting.PowerState" + BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On" BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off" BSH_POWER_STANDBY = "BSH.Common.EnumType.PowerState.Standby" -BSH_SELECTED_PROGRAM = "BSH.Common.Root.SelectedProgram" -BSH_ACTIVE_PROGRAM = "BSH.Common.Root.ActiveProgram" -BSH_REMOTE_CONTROL_ACTIVATION_STATE = "BSH.Common.Status.RemoteControlActive" -BSH_REMOTE_START_ALLOWANCE_STATE = "BSH.Common.Status.RemoteControlStartAllowed" -BSH_CHILD_LOCK_STATE = "BSH.Common.Setting.ChildLock" -BSH_REMAINING_PROGRAM_TIME = "BSH.Common.Option.RemainingProgramTime" -BSH_COMMON_OPTION_DURATION = "BSH.Common.Option.Duration" -BSH_COMMON_OPTION_PROGRAM_PROGRESS = "BSH.Common.Option.ProgramProgress" BSH_EVENT_PRESENT_STATE_PRESENT = "BSH.Common.EnumType.EventPresentState.Present" BSH_EVENT_PRESENT_STATE_CONFIRMED = "BSH.Common.EnumType.EventPresentState.Confirmed" BSH_EVENT_PRESENT_STATE_OFF = "BSH.Common.EnumType.EventPresentState.Off" -BSH_OPERATION_STATE = "BSH.Common.Status.OperationState" + BSH_OPERATION_STATE_RUN = "BSH.Common.EnumType.OperationState.Run" BSH_OPERATION_STATE_PAUSE = "BSH.Common.EnumType.OperationState.Pause" BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished" -COOKING_LIGHTING = "Cooking.Common.Setting.Lighting" -COOKING_LIGHTING_BRIGHTNESS = "Cooking.Common.Setting.LightingBrightness" - -COFFEE_EVENT_BEAN_CONTAINER_EMPTY = ( - "ConsumerProducts.CoffeeMaker.Event.BeanContainerEmpty" -) -COFFEE_EVENT_WATER_TANK_EMPTY = "ConsumerProducts.CoffeeMaker.Event.WaterTankEmpty" -COFFEE_EVENT_DRIP_TRAY_FULL = "ConsumerProducts.CoffeeMaker.Event.DripTrayFull" - -DISHWASHER_EVENT_SALT_NEARLY_EMPTY = "Dishcare.Dishwasher.Event.SaltNearlyEmpty" -DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY = ( - "Dishcare.Dishwasher.Event.RinseAidNearlyEmpty" -) - -REFRIGERATION_INTERNAL_LIGHT_POWER = "Refrigeration.Common.Setting.Light.Internal.Power" -REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS = ( - "Refrigeration.Common.Setting.Light.Internal.Brightness" -) -REFRIGERATION_EXTERNAL_LIGHT_POWER = "Refrigeration.Common.Setting.Light.External.Power" -REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS = ( - "Refrigeration.Common.Setting.Light.External.Brightness" -) - -REFRIGERATION_SUPERMODEFREEZER = "Refrigeration.FridgeFreezer.Setting.SuperModeFreezer" -REFRIGERATION_SUPERMODEREFRIGERATOR = ( - "Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator" -) -REFRIGERATION_DISPENSER = "Refrigeration.Common.Setting.Dispenser.Enabled" - -REFRIGERATION_STATUS_DOOR_CHILLER = "Refrigeration.Common.Status.Door.ChillerCommon" -REFRIGERATION_STATUS_DOOR_FREEZER = "Refrigeration.Common.Status.Door.Freezer" -REFRIGERATION_STATUS_DOOR_REFRIGERATOR = "Refrigeration.Common.Status.Door.Refrigerator" REFRIGERATION_STATUS_DOOR_CLOSED = "Refrigeration.Common.EnumType.Door.States.Closed" REFRIGERATION_STATUS_DOOR_OPEN = "Refrigeration.Common.EnumType.Door.States.Open" -REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR = ( - "Refrigeration.FridgeFreezer.Event.DoorAlarmRefrigerator" -) -REFRIGERATION_EVENT_DOOR_ALARM_FREEZER = ( - "Refrigeration.FridgeFreezer.Event.DoorAlarmFreezer" -) -REFRIGERATION_EVENT_TEMP_ALARM_FREEZER = ( - "Refrigeration.FridgeFreezer.Event.TemperatureAlarmFreezer" -) - -BSH_AMBIENT_LIGHT_ENABLED = "BSH.Common.Setting.AmbientLightEnabled" -BSH_AMBIENT_LIGHT_BRIGHTNESS = "BSH.Common.Setting.AmbientLightBrightness" -BSH_AMBIENT_LIGHT_COLOR = "BSH.Common.Setting.AmbientLightColor" BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR = ( "BSH.Common.EnumType.AmbientLightColor.CustomColor" ) -BSH_AMBIENT_LIGHT_CUSTOM_COLOR = "BSH.Common.Setting.AmbientLightCustomColor" -BSH_DOOR_STATE = "BSH.Common.Status.DoorState" + BSH_DOOR_STATE_CLOSED = "BSH.Common.EnumType.DoorState.Closed" BSH_DOOR_STATE_LOCKED = "BSH.Common.EnumType.DoorState.Locked" BSH_DOOR_STATE_OPEN = "BSH.Common.EnumType.DoorState.Open" -BSH_PAUSE = "BSH.Common.Command.PauseProgram" -BSH_RESUME = "BSH.Common.Command.ResumeProgram" - -SIGNAL_UPDATE_ENTITIES = "home_connect.update_entities" SERVICE_OPTION_ACTIVE = "set_option_active" SERVICE_OPTION_SELECTED = "set_option_selected" @@ -113,51 +55,44 @@ SERVICE_SELECT_PROGRAM = "select_program" SERVICE_SETTING = "change_setting" SERVICE_START_PROGRAM = "start_program" -ATTR_ALLOWED_VALUES = "allowedvalues" -ATTR_AMBIENT = "ambient" -ATTR_BSH_KEY = "bsh_key" -ATTR_CONSTRAINTS = "constraints" -ATTR_DESC = "desc" -ATTR_DEVICE = "device" + ATTR_KEY = "key" ATTR_PROGRAM = "program" -ATTR_SENSOR_TYPE = "sensor_type" -ATTR_SIGN = "sign" -ATTR_STEPSIZE = "stepsize" ATTR_UNIT = "unit" ATTR_VALUE = "value" -SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity" +SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity" SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name" SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID = "entity_id" SVE_TRANSLATION_PLACEHOLDER_PROGRAM = "program" SVE_TRANSLATION_PLACEHOLDER_KEY = "key" SVE_TRANSLATION_PLACEHOLDER_VALUE = "value" + OLD_NEW_UNIQUE_ID_SUFFIX_MAP = { - "ChildLock": BSH_CHILD_LOCK_STATE, - "Operation State": BSH_OPERATION_STATE, - "Light": COOKING_LIGHTING, - "AmbientLight": BSH_AMBIENT_LIGHT_ENABLED, - "Power": BSH_POWER_STATE, - "Remaining Program Time": BSH_REMAINING_PROGRAM_TIME, - "Duration": BSH_COMMON_OPTION_DURATION, - "Program Progress": BSH_COMMON_OPTION_PROGRAM_PROGRESS, - "Remote Control": BSH_REMOTE_CONTROL_ACTIVATION_STATE, - "Remote Start": BSH_REMOTE_START_ALLOWANCE_STATE, - "Supermode Freezer": REFRIGERATION_SUPERMODEFREEZER, - "Supermode Refrigerator": REFRIGERATION_SUPERMODEREFRIGERATOR, - "Dispenser Enabled": REFRIGERATION_DISPENSER, - "Internal Light": REFRIGERATION_INTERNAL_LIGHT_POWER, - "External Light": REFRIGERATION_EXTERNAL_LIGHT_POWER, - "Chiller Door": REFRIGERATION_STATUS_DOOR_CHILLER, - "Freezer Door": REFRIGERATION_STATUS_DOOR_FREEZER, - "Refrigerator Door": REFRIGERATION_STATUS_DOOR_REFRIGERATOR, - "Door Alarm Freezer": REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, - "Door Alarm Refrigerator": REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, - "Temperature Alarm Freezer": REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, - "Bean Container Empty": COFFEE_EVENT_BEAN_CONTAINER_EMPTY, - "Water Tank Empty": COFFEE_EVENT_WATER_TANK_EMPTY, - "Drip Tray Full": COFFEE_EVENT_DRIP_TRAY_FULL, + "ChildLock": SettingKey.BSH_COMMON_CHILD_LOCK, + "Operation State": StatusKey.BSH_COMMON_OPERATION_STATE, + "Light": SettingKey.COOKING_COMMON_LIGHTING, + "AmbientLight": SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED, + "Power": SettingKey.BSH_COMMON_POWER_STATE, + "Remaining Program Time": EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME, + "Duration": EventKey.BSH_COMMON_OPTION_DURATION, + "Program Progress": EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS, + "Remote Control": StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE, + "Remote Start": StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED, + "Supermode Freezer": SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER, + "Supermode Refrigerator": SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_REFRIGERATOR, + "Dispenser Enabled": SettingKey.REFRIGERATION_COMMON_DISPENSER_ENABLED, + "Internal Light": SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_POWER, + "External Light": SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER, + "Chiller Door": StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER, + "Freezer Door": StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER, + "Refrigerator Door": StatusKey.REFRIGERATION_COMMON_DOOR_REFRIGERATOR, + "Door Alarm Freezer": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + "Door Alarm Refrigerator": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, + "Temperature Alarm Freezer": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, + "Bean Container Empty": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + "Water Tank Empty": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY, + "Drip Tray Full": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL, } diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py new file mode 100644 index 00000000000..2c70d74150e --- /dev/null +++ b/homeassistant/components/home_connect/coordinator.py @@ -0,0 +1,258 @@ +"""Coordinator for Home Connect.""" + +import asyncio +from collections import defaultdict +from collections.abc import Callable +from dataclasses import dataclass, field +import logging +from typing import Any + +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import ( + Event, + EventKey, + EventMessage, + EventType, + GetSetting, + HomeAppliance, + SettingKey, + Status, + StatusKey, +) +from aiohomeconnect.model.error import ( + EventStreamInterruptedError, + HomeConnectApiError, + HomeConnectError, + HomeConnectRequestError, +) +from aiohomeconnect.model.program import EnumerateAvailableProgram +from propcache.api import cached_property + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN +from .utils import get_dict_from_home_connect_error + +_LOGGER = logging.getLogger(__name__) + +type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator] + +EVENT_STREAM_RECONNECT_DELAY = 30 + + +@dataclass(frozen=True, kw_only=True) +class HomeConnectApplianceData: + """Class to hold Home Connect appliance data.""" + + events: dict[EventKey, Event] = field(default_factory=dict) + info: HomeAppliance + programs: list[EnumerateAvailableProgram] = field(default_factory=list) + settings: dict[SettingKey, GetSetting] + status: dict[StatusKey, Status] + + def update(self, other: "HomeConnectApplianceData") -> None: + """Update data with data from other instance.""" + self.events.update(other.events) + self.info.connected = other.info.connected + self.programs.clear() + self.programs.extend(other.programs) + self.settings.update(other.settings) + self.status.update(other.status) + + +class HomeConnectCoordinator( + DataUpdateCoordinator[dict[str, HomeConnectApplianceData]] +): + """Class to manage fetching Home Connect data.""" + + config_entry: HomeConnectConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: HomeConnectConfigEntry, + client: HomeConnectClient, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=config_entry.entry_id, + ) + self.client = client + + @cached_property + def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]: + """Return a dict of all listeners registered for a given context.""" + listeners: dict[tuple[str, EventKey], list[CALLBACK_TYPE]] = defaultdict(list) + for listener, context in list(self._listeners.values()): + assert isinstance(context, tuple) + listeners[context].append(listener) + return listeners + + @callback + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: + """Listen for data updates.""" + remove_listener = super().async_add_listener(update_callback, context) + self.__dict__.pop("context_listeners", None) + + def remove_listener_and_invalidate_context_listeners() -> None: + remove_listener() + self.__dict__.pop("context_listeners", None) + + return remove_listener_and_invalidate_context_listeners + + @callback + def start_event_listener(self) -> None: + """Start event listener.""" + self.config_entry.async_create_background_task( + self.hass, + self._event_listener(), + f"home_connect-events_listener_task-{self.config_entry.entry_id}", + ) + + async def _event_listener(self) -> None: + """Match event with listener for event type.""" + while True: + try: + async for event_message in self.client.stream_all_events(): + match event_message.type: + case EventType.STATUS: + statuses = self.data[event_message.ha_id].status + for event in event_message.data.items: + status_key = StatusKey(event.key) + if status_key in statuses: + statuses[status_key].value = event.value + else: + statuses[status_key] = Status( + key=status_key, + raw_key=status_key.value, + value=event.value, + ) + + case EventType.NOTIFY: + settings = self.data[event_message.ha_id].settings + events = self.data[event_message.ha_id].events + for event in event_message.data.items: + if event.key in SettingKey: + setting_key = SettingKey(event.key) + if setting_key in settings: + settings[setting_key].value = event.value + else: + settings[setting_key] = GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=event.value, + ) + else: + events[event.key] = event + + case EventType.EVENT: + events = self.data[event_message.ha_id].events + for event in event_message.data.items: + events[event.key] = event + + self._call_event_listener(event_message) + + except (EventStreamInterruptedError, HomeConnectRequestError) as error: + _LOGGER.debug( + "Non-breaking error (%s) while listening for events," + " continuing in 30 seconds", + type(error).__name__, + ) + await asyncio.sleep(EVENT_STREAM_RECONNECT_DELAY) + except HomeConnectApiError as error: + _LOGGER.error("Error while listening for events: %s", error) + self.hass.config_entries.async_schedule_reload( + self.config_entry.entry_id + ) + break + # if there was a non-breaking error, we continue listening + # but we need to refresh the data to get the possible changes + # that happened while the event stream was interrupted + await self.async_refresh() + + @callback + def _call_event_listener(self, event_message: EventMessage): + """Call listener for event.""" + for event in event_message.data.items: + for listener in self.context_listeners.get( + (event_message.ha_id, event.key), [] + ): + listener() + + async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]: + """Fetch data from Home Connect.""" + try: + appliances = await self.client.get_home_appliances() + except HomeConnectError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="fetch_api_error", + translation_placeholders=get_dict_from_home_connect_error(error), + ) from error + + appliances_data = self.data or {} + for appliance in appliances.homeappliances: + try: + settings = { + setting.key: setting + for setting in ( + await self.client.get_settings(appliance.ha_id) + ).settings + } + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching settings for %s: %s", + appliance.ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + settings = {} + try: + status = { + status.key: status + for status in (await self.client.get_status(appliance.ha_id)).status + } + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching status for %s: %s", + appliance.ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + status = {} + appliance_data = HomeConnectApplianceData( + info=appliance, settings=settings, status=status + ) + if appliance.ha_id in appliances_data: + appliances_data[appliance.ha_id].update(appliance_data) + appliance_data = appliances_data[appliance.ha_id] + else: + appliances_data[appliance.ha_id] = appliance_data + if ( + appliance.type in APPLIANCES_WITH_PROGRAMS + and not appliance_data.programs + ): + try: + appliance_data.programs.extend( + ( + await self.client.get_available_programs(appliance.ha_id) + ).programs + ) + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching programs for %s: %s", + appliance.ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + return appliances_data diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py index e095bc503ab..fd74277a815 100644 --- a/homeassistant/components/home_connect/diagnostics.py +++ b/homeassistant/components/home_connect/diagnostics.py @@ -4,33 +4,25 @@ from __future__ import annotations from typing import Any -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.client import Client as HomeConnectClient from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from . import HomeConnectConfigEntry, _get_appliance -from .api import HomeConnectDevice +from .const import DOMAIN +from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -def _generate_appliance_diagnostics(appliance: HomeConnectAppliance) -> dict[str, Any]: - try: - programs = appliance.get_programs_available() - except HomeConnectError: - programs = None +async def _generate_appliance_diagnostics( + client: HomeConnectClient, appliance: HomeConnectApplianceData +) -> dict[str, Any]: return { - "connected": appliance.connected, - "status": appliance.status, - "programs": programs, - } - - -def _generate_entry_diagnostics( - devices: list[HomeConnectDevice], -) -> dict[str, dict[str, Any]]: - return { - device.appliance.haId: _generate_appliance_diagnostics(device.appliance) - for device in devices + **appliance.info.to_dict(), + "status": {key.value: status.value for key, status in appliance.status.items()}, + "settings": { + key.value: setting.value for key, setting in appliance.settings.items() + }, + "programs": [program.raw_key for program in appliance.programs], } @@ -38,14 +30,21 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - return await hass.async_add_executor_job( - _generate_entry_diagnostics, entry.runtime_data.devices - ) + return { + appliance.info.ha_id: await _generate_appliance_diagnostics( + entry.runtime_data.client, appliance + ) + for appliance in entry.runtime_data.data.values() + } async def async_get_device_diagnostics( hass: HomeAssistant, entry: HomeConnectConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" - appliance = _get_appliance(hass, device_entry=device, entry=entry) - return await hass.async_add_executor_job(_generate_appliance_diagnostics, appliance) + ha_id = next( + (identifier[1] for identifier in device.identifiers if identifier[0] == DOMAIN), + ) + return await _generate_appliance_diagnostics( + entry.runtime_data.client, entry.runtime_data.data[ha_id] + ) diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 0ae4a28b8d4..ba8500fe8b6 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -1,55 +1,56 @@ """Home Connect entity base class.""" +from abc import abstractmethod import logging +from aiohomeconnect.model import EventKey + from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .api import HomeConnectDevice -from .const import DOMAIN, SIGNAL_UPDATE_ENTITIES +from .const import DOMAIN +from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator _LOGGER = logging.getLogger(__name__) -class HomeConnectEntity(Entity): +class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): """Generic Home Connect entity (base class).""" _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, device: HomeConnectDevice, desc: EntityDescription) -> None: + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: EntityDescription, + ) -> None: """Initialize the entity.""" - self.device = device + super().__init__(coordinator, (appliance.info.ha_id, EventKey(desc.key))) + self.appliance = appliance self.entity_description = desc - self._attr_unique_id = f"{device.appliance.haId}-{self.bsh_key}" + self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.appliance.haId)}, - manufacturer=device.appliance.brand, - model=device.appliance.vib, - name=device.appliance.name, + identifiers={(DOMAIN, appliance.info.ha_id)}, + manufacturer=appliance.info.brand, + model=appliance.info.vib, + name=appliance.info.name, ) + self.update_native_value() - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ENTITIES, self._update_callback - ) - ) + @abstractmethod + def update_native_value(self) -> None: + """Set the value of the entity.""" @callback - def _update_callback(self, ha_id: str) -> None: - """Update data.""" - if ha_id == self.device.appliance.haId: - self.async_entity_update() - - @callback - def async_entity_update(self) -> None: - """Update the entity.""" - _LOGGER.debug("Entity update triggered on %s", self) - self.async_schedule_update_ha_state(True) + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.update_native_value() + self.async_write_ha_state() + _LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state) @property def bsh_key(self) -> str: diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 3e81bcbddad..9d1c4d7a55b 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -2,10 +2,10 @@ from dataclasses import dataclass import logging -from math import ceil -from typing import Any +from typing import Any, cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import EventKey, SettingKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -20,25 +20,18 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import color as color_util -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error -from .api import HomeConnectDevice from .const import ( - ATTR_VALUE, - BSH_AMBIENT_LIGHT_BRIGHTNESS, - BSH_AMBIENT_LIGHT_COLOR, BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR, - BSH_AMBIENT_LIGHT_ENABLED, - COOKING_LIGHTING, - COOKING_LIGHTING_BRIGHTNESS, DOMAIN, - REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, - REFRIGERATION_EXTERNAL_LIGHT_POWER, - REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS, - REFRIGERATION_INTERNAL_LIGHT_POWER, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, ) +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, +) from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -47,38 +40,38 @@ _LOGGER = logging.getLogger(__name__) class HomeConnectLightEntityDescription(LightEntityDescription): """Light entity description.""" - brightness_key: str | None = None - color_key: str | None = None + brightness_key: SettingKey | None = None + color_key: SettingKey | None = None enable_custom_color_value_key: str | None = None - custom_color_key: str | None = None + custom_color_key: SettingKey | None = None brightness_scale: tuple[float, float] = (0.0, 100.0) LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = ( HomeConnectLightEntityDescription( - key=REFRIGERATION_INTERNAL_LIGHT_POWER, - brightness_key=REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS, + key=SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_POWER, + brightness_key=SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_BRIGHTNESS, brightness_scale=(1.0, 100.0), translation_key="internal_light", ), HomeConnectLightEntityDescription( - key=REFRIGERATION_EXTERNAL_LIGHT_POWER, - brightness_key=REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, + key=SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER, + brightness_key=SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_BRIGHTNESS, brightness_scale=(1.0, 100.0), translation_key="external_light", ), HomeConnectLightEntityDescription( - key=COOKING_LIGHTING, - brightness_key=COOKING_LIGHTING_BRIGHTNESS, + key=SettingKey.COOKING_COMMON_LIGHTING, + brightness_key=SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS, brightness_scale=(10.0, 100.0), translation_key="cooking_lighting", ), HomeConnectLightEntityDescription( - key=BSH_AMBIENT_LIGHT_ENABLED, - brightness_key=BSH_AMBIENT_LIGHT_BRIGHTNESS, - color_key=BSH_AMBIENT_LIGHT_COLOR, + key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED, + brightness_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS, + color_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, enable_custom_color_value_key=BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, - custom_color_key=BSH_AMBIENT_LIGHT_CUSTOM_COLOR, + custom_color_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR, brightness_scale=(10.0, 100.0), translation_key="ambient_light", ), @@ -92,16 +85,14 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect light.""" - def get_entities() -> list[LightEntity]: - """Get a list of entities.""" - return [ - HomeConnectLight(device, description) + async_add_entities( + [ + HomeConnectLight(entry.runtime_data, appliance, description) for description in LIGHTS - for device in entry.runtime_data.devices - if description.key in device.appliance.status - ] - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + for appliance in entry.runtime_data.data.values() + if description.key in appliance.settings + ], + ) class HomeConnectLight(HomeConnectEntity, LightEntity): @@ -110,13 +101,17 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): entity_description: LightEntityDescription def __init__( - self, device: HomeConnectDevice, desc: HomeConnectLightEntityDescription + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: HomeConnectLightEntityDescription, ) -> None: """Initialize the entity.""" - super().__init__(device, desc) - def get_setting_key_if_setting_exists(setting_key: str | None) -> str | None: - if setting_key and setting_key in device.appliance.status: + def get_setting_key_if_setting_exists( + setting_key: SettingKey | None, + ) -> SettingKey | None: + if setting_key and setting_key in appliance.settings: return setting_key return None @@ -131,6 +126,8 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): ) self._brightness_scale = desc.brightness_scale + super().__init__(coordinator, appliance, desc) + match (self._brightness_key, self._custom_color_key): case (None, None): self._attr_color_mode = ColorMode.ONOFF @@ -144,10 +141,11 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Switch the light on, change brightness, change color.""" - _LOGGER.debug("Switching light on for: %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.bsh_key, True + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=True, ) except HomeConnectError as err: raise HomeAssistantError( @@ -158,15 +156,15 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, }, ) from err - if self._custom_color_key: + if self._color_key and self._custom_color_key: if ( ATTR_RGB_COLOR in kwargs or ATTR_HS_COLOR in kwargs ) and self._enable_custom_color_value_key: try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._color_key, - self._enable_custom_color_value_key, + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._color_key, + value=self._enable_custom_color_value_key, ) except HomeConnectError as err: raise HomeAssistantError( @@ -181,10 +179,10 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): if ATTR_RGB_COLOR in kwargs: hex_val = color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR]) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._custom_color_key, - f"#{hex_val}", + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._custom_color_key, + value=f"#{hex_val}", ) except HomeConnectError as err: raise HomeAssistantError( @@ -195,10 +193,11 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, }, ) from err - elif (ATTR_BRIGHTNESS in kwargs or ATTR_HS_COLOR in kwargs) and ( - self._attr_brightness is not None or ATTR_BRIGHTNESS in kwargs + return + if (self._attr_brightness is not None or ATTR_BRIGHTNESS in kwargs) and ( + self._attr_hs_color is not None or ATTR_HS_COLOR in kwargs ): - brightness = 10 + ceil( + brightness = round( color_util.brightness_to_value( self._brightness_scale, kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness), @@ -207,41 +206,36 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) - if hs_color is not None: - rgb = color_util.color_hsv_to_RGB( - hs_color[0], hs_color[1], brightness + rgb = color_util.color_hsv_to_RGB(hs_color[0], hs_color[1], brightness) + hex_val = color_util.color_rgb_to_hex(*rgb) + try: + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._custom_color_key, + value=f"#{hex_val}", ) - hex_val = color_util.color_rgb_to_hex(*rgb) - try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._custom_color_key, - f"#{hex_val}", - ) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="set_light_color", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - }, - ) from err + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_light_color", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + }, + ) from err + return - elif self._brightness_key and ATTR_BRIGHTNESS in kwargs: - _LOGGER.debug( - "Changing brightness for: %s, to: %s", - self.name, - kwargs[ATTR_BRIGHTNESS], - ) - brightness = ceil( + if self._brightness_key and ATTR_BRIGHTNESS in kwargs: + brightness = round( color_util.brightness_to_value( self._brightness_scale, kwargs[ATTR_BRIGHTNESS] ) ) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self._brightness_key, brightness + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._brightness_key, + value=brightness, ) except HomeConnectError as err: raise HomeAssistantError( @@ -253,14 +247,13 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): }, ) from err - self.async_entity_update() - async def async_turn_off(self, **kwargs: Any) -> None: """Switch the light off.""" - _LOGGER.debug("Switching light off for: %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.bsh_key, False + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=False, ) except HomeConnectError as err: raise HomeAssistantError( @@ -271,30 +264,50 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, }, ) from err - self.async_entity_update() - async def async_update(self) -> None: + async def async_added_to_hass(self) -> None: + """Register listener.""" + await super().async_added_to_hass() + keys_to_listen = [] + if self._brightness_key: + keys_to_listen.append(self._brightness_key) + if self._color_key and self._custom_color_key: + keys_to_listen.extend([self._color_key, self._custom_color_key]) + for key in keys_to_listen: + self.async_on_remove( + self.coordinator.async_add_listener( + self._handle_coordinator_update, + ( + self.appliance.info.ha_id, + EventKey(key), + ), + ) + ) + + def update_native_value(self) -> None: """Update the light's status.""" - if self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) is True: - self._attr_is_on = True - elif ( - self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) is False - ): - self._attr_is_on = False - else: - self._attr_is_on = None + self._attr_is_on = self.appliance.settings[SettingKey(self.bsh_key)].value - _LOGGER.debug("Updated, new light state: %s", self._attr_is_on) - - if self._custom_color_key: - color = self.device.appliance.status.get(self._custom_color_key, {}) - - if not color: + if self._brightness_key: + brightness = cast( + float, self.appliance.settings[self._brightness_key].value + ) + self._attr_brightness = color_util.value_to_brightness( + self._brightness_scale, brightness + ) + _LOGGER.debug( + "Updated %s, new brightness: %s", self.entity_id, self._attr_brightness + ) + if self._color_key and self._custom_color_key: + color = cast(str, self.appliance.settings[self._color_key].value) + if color != self._enable_custom_color_value_key: self._attr_rgb_color = None self._attr_hs_color = None - self._attr_brightness = None else: - color_value = color.get(ATTR_VALUE)[1:] + custom_color = cast( + str, self.appliance.settings[self._custom_color_key].value + ) + color_value = custom_color[1:] rgb = color_util.rgb_hex_to_rgb_list(color_value) self._attr_rgb_color = (rgb[0], rgb[1], rgb[2]) hsv = color_util.color_RGB_to_hsv(*rgb) @@ -303,16 +316,8 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): self._brightness_scale, hsv[2] ) _LOGGER.debug( - "Updated, new color (%s) and new brightness (%s) ", + "Updated %s, new color (%s) and new brightness (%s) ", + self.entity_id, color_value, self._attr_brightness, ) - elif self._brightness_key: - brightness = self.device.appliance.status.get(self._brightness_key, {}) - if brightness is None: - self._attr_brightness = None - else: - self._attr_brightness = color_util.value_to_brightness( - self._brightness_scale, brightness[ATTR_VALUE] - ) - _LOGGER.debug("Updated, new brightness: %s", self._attr_brightness) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index e041e13d36b..905a7c67f11 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["homeconnect"], - "requirements": ["homeconnect==0.8.0"] + "requirements": ["aiohomeconnect==0.12.1"] } diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 0703b4772bb..7c6101950bf 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -1,12 +1,12 @@ """Provides number enties for Home Connect.""" import logging +from typing import cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import GetSetting, SettingKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.number import ( - ATTR_MAX, - ATTR_MIN, NumberDeviceClass, NumberEntity, NumberEntityDescription, @@ -15,66 +15,63 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( - ATTR_CONSTRAINTS, - ATTR_STEPSIZE, - ATTR_UNIT, - ATTR_VALUE, DOMAIN, SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) +from .coordinator import HomeConnectConfigEntry from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) NUMBERS = ( NumberEntityDescription( - key="Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, device_class=NumberDeviceClass.TEMPERATURE, translation_key="refrigerator_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.FridgeFreezer.Setting.SetpointTemperatureFreezer", + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_FREEZER, device_class=NumberDeviceClass.TEMPERATURE, translation_key="freezer_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.BottleCooler.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_BOTTLE_COOLER_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="bottle_cooler_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.ChillerLeft.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_CHILLER_LEFT_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="chiller_left_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.ChillerCommon.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_CHILLER_COMMON_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="chiller_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.ChillerRight.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_CHILLER_RIGHT_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="chiller_right_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.WineCompartment.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.WineCompartment2.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_2_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_2_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.WineCompartment3.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_3_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_3_setpoint_temperature", ), @@ -87,17 +84,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect number.""" - - def get_entities() -> list[HomeConnectNumberEntity]: - """Get a list of entities.""" - return [ - HomeConnectNumberEntity(device, description) + async_add_entities( + [ + HomeConnectNumberEntity(entry.runtime_data, appliance, description) for description in NUMBERS - for device in entry.runtime_data.devices - if description.key in device.appliance.status - ] - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + for appliance in entry.runtime_data.data.values() + if description.key in appliance.settings + ], + ) class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): @@ -112,10 +106,10 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): self.entity_id, ) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self.bsh_key, - value, + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=value, ) except HomeConnectError as err: raise HomeAssistantError( @@ -132,34 +126,41 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): async def async_fetch_constraints(self) -> None: """Fetch the max and min values and step for the number entity.""" try: - data = await self.hass.async_add_executor_job( - self.device.appliance.get, f"/settings/{self.bsh_key}" + data = await self.coordinator.client.get_setting( + self.appliance.info.ha_id, setting_key=SettingKey(self.bsh_key) ) except HomeConnectError as err: _LOGGER.error("An error occurred: %s", err) - return - if not data or not (constraints := data.get(ATTR_CONSTRAINTS)): - return - self._attr_native_max_value = constraints.get(ATTR_MAX) - self._attr_native_min_value = constraints.get(ATTR_MIN) - self._attr_native_step = constraints.get(ATTR_STEPSIZE) - self._attr_native_unit_of_measurement = data.get(ATTR_UNIT) + else: + self.set_constraints(data) - async def async_update(self) -> None: - """Update the number setting status.""" - if not (data := self.device.appliance.status.get(self.bsh_key)): - _LOGGER.error("No value for %s", self.bsh_key) - self._attr_native_value = None + def set_constraints(self, setting: GetSetting) -> None: + """Set constraints for the number entity.""" + if not (constraints := setting.constraints): return - self._attr_native_value = data.get(ATTR_VALUE, None) - _LOGGER.debug("Updated, new value: %s", self._attr_native_value) + if constraints.max: + self._attr_native_max_value = constraints.max + if constraints.min: + self._attr_native_min_value = constraints.min + if constraints.step_size: + self._attr_native_step = constraints.step_size + else: + self._attr_native_step = 0.1 if setting.type == "Double" else 1 + def update_native_value(self) -> None: + """Update status when an event for the entity is received.""" + data = self.appliance.settings[cast(SettingKey, self.bsh_key)] + self._attr_native_value = cast(float, data.value) + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + data = self.appliance.settings[cast(SettingKey, self.bsh_key)] + self._attr_native_unit_of_measurement = data.unit + self.set_constraints(data) if ( not hasattr(self, "_attr_native_min_value") - or self._attr_native_min_value is None or not hasattr(self, "_attr_native_max_value") - or self._attr_native_max_value is None or not hasattr(self, "_attr_native_step") - or self._attr_native_step is None ): await self.async_fetch_constraints() diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index a4a5861afbe..c7408094aed 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -1,191 +1,28 @@ """Provides a select platform for Home Connect.""" -import contextlib -import logging +from typing import cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import EventKey, ProgramKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( +from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM +from .coordinator import ( + HomeConnectApplianceData, HomeConnectConfigEntry, - bsh_key_to_translation_key, - get_dict_from_home_connect_error, -) -from .api import HomeConnectDevice -from .const import ( - APPLIANCES_WITH_PROGRAMS, - ATTR_VALUE, - BSH_ACTIVE_PROGRAM, - BSH_SELECTED_PROGRAM, - DOMAIN, - SVE_TRANSLATION_PLACEHOLDER_PROGRAM, + HomeConnectCoordinator, ) from .entity import HomeConnectEntity - -_LOGGER = logging.getLogger(__name__) +from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error TRANSLATION_KEYS_PROGRAMS_MAP = { - bsh_key_to_translation_key(program): program - for program in ( - "ConsumerProducts.CleaningRobot.Program.Cleaning.CleanAll", - "ConsumerProducts.CleaningRobot.Program.Cleaning.CleanMap", - "ConsumerProducts.CleaningRobot.Program.Basic.GoHome", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Ristretto", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso", - "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoDoppio", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee", - "ConsumerProducts.CoffeeMaker.Program.Beverage.XLCoffee", - "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeGrande", - "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino", - "ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato", - "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte", - "ConsumerProducts.CoffeeMaker.Program.Beverage.MilkFroth", - "ConsumerProducts.CoffeeMaker.Program.Beverage.WarmMilk", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KleinerBrauner", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.GrosserBrauner", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Verlaengerter", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.VerlaengerterBraun", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.WienerMelange", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.FlatWhite", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Cortado", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeCortado", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeConLeche", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeAuLait", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Doppio", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Kaapi", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KoffieVerkeerd", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Galao", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Garoto", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Americano", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.RedEye", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.BlackEye", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.DeadEye", - "ConsumerProducts.CoffeeMaker.Program.Beverage.HotWater", - "Dishcare.Dishwasher.Program.PreRinse", - "Dishcare.Dishwasher.Program.Auto1", - "Dishcare.Dishwasher.Program.Auto2", - "Dishcare.Dishwasher.Program.Auto3", - "Dishcare.Dishwasher.Program.Eco50", - "Dishcare.Dishwasher.Program.Quick45", - "Dishcare.Dishwasher.Program.Intensiv70", - "Dishcare.Dishwasher.Program.Normal65", - "Dishcare.Dishwasher.Program.Glas40", - "Dishcare.Dishwasher.Program.GlassCare", - "Dishcare.Dishwasher.Program.NightWash", - "Dishcare.Dishwasher.Program.Quick65", - "Dishcare.Dishwasher.Program.Normal45", - "Dishcare.Dishwasher.Program.Intensiv45", - "Dishcare.Dishwasher.Program.AutoHalfLoad", - "Dishcare.Dishwasher.Program.IntensivPower", - "Dishcare.Dishwasher.Program.MagicDaily", - "Dishcare.Dishwasher.Program.Super60", - "Dishcare.Dishwasher.Program.Kurz60", - "Dishcare.Dishwasher.Program.ExpressSparkle65", - "Dishcare.Dishwasher.Program.MachineCare", - "Dishcare.Dishwasher.Program.SteamFresh", - "Dishcare.Dishwasher.Program.MaximumCleaning", - "Dishcare.Dishwasher.Program.MixedLoad", - "LaundryCare.Dryer.Program.Cotton", - "LaundryCare.Dryer.Program.Synthetic", - "LaundryCare.Dryer.Program.Mix", - "LaundryCare.Dryer.Program.Blankets", - "LaundryCare.Dryer.Program.BusinessShirts", - "LaundryCare.Dryer.Program.DownFeathers", - "LaundryCare.Dryer.Program.Hygiene", - "LaundryCare.Dryer.Program.Jeans", - "LaundryCare.Dryer.Program.Outdoor", - "LaundryCare.Dryer.Program.SyntheticRefresh", - "LaundryCare.Dryer.Program.Towels", - "LaundryCare.Dryer.Program.Delicates", - "LaundryCare.Dryer.Program.Super40", - "LaundryCare.Dryer.Program.Shirts15", - "LaundryCare.Dryer.Program.Pillow", - "LaundryCare.Dryer.Program.AntiShrink", - "LaundryCare.Dryer.Program.MyTime.MyDryingTime", - "LaundryCare.Dryer.Program.TimeCold", - "LaundryCare.Dryer.Program.TimeWarm", - "LaundryCare.Dryer.Program.InBasket", - "LaundryCare.Dryer.Program.TimeColdFix.TimeCold20", - "LaundryCare.Dryer.Program.TimeColdFix.TimeCold30", - "LaundryCare.Dryer.Program.TimeColdFix.TimeCold60", - "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm30", - "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm40", - "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm60", - "LaundryCare.Dryer.Program.Dessous", - "Cooking.Common.Program.Hood.Automatic", - "Cooking.Common.Program.Hood.Venting", - "Cooking.Common.Program.Hood.DelayedShutOff", - "Cooking.Oven.Program.HeatingMode.PreHeating", - "Cooking.Oven.Program.HeatingMode.HotAir", - "Cooking.Oven.Program.HeatingMode.HotAirEco", - "Cooking.Oven.Program.HeatingMode.HotAirGrilling", - "Cooking.Oven.Program.HeatingMode.TopBottomHeating", - "Cooking.Oven.Program.HeatingMode.TopBottomHeatingEco", - "Cooking.Oven.Program.HeatingMode.BottomHeating", - "Cooking.Oven.Program.HeatingMode.PizzaSetting", - "Cooking.Oven.Program.HeatingMode.SlowCook", - "Cooking.Oven.Program.HeatingMode.IntensiveHeat", - "Cooking.Oven.Program.HeatingMode.KeepWarm", - "Cooking.Oven.Program.HeatingMode.PreheatOvenware", - "Cooking.Oven.Program.HeatingMode.FrozenHeatupSpecial", - "Cooking.Oven.Program.HeatingMode.Desiccation", - "Cooking.Oven.Program.HeatingMode.Defrost", - "Cooking.Oven.Program.HeatingMode.Proof", - "Cooking.Oven.Program.HeatingMode.HotAir30Steam", - "Cooking.Oven.Program.HeatingMode.HotAir60Steam", - "Cooking.Oven.Program.HeatingMode.HotAir80Steam", - "Cooking.Oven.Program.HeatingMode.HotAir100Steam", - "Cooking.Oven.Program.HeatingMode.SabbathProgramme", - "Cooking.Oven.Program.Microwave.90Watt", - "Cooking.Oven.Program.Microwave.180Watt", - "Cooking.Oven.Program.Microwave.360Watt", - "Cooking.Oven.Program.Microwave.600Watt", - "Cooking.Oven.Program.Microwave.900Watt", - "Cooking.Oven.Program.Microwave.1000Watt", - "Cooking.Oven.Program.Microwave.Max", - "Cooking.Oven.Program.HeatingMode.WarmingDrawer", - "LaundryCare.Washer.Program.Cotton", - "LaundryCare.Washer.Program.Cotton.CottonEco", - "LaundryCare.Washer.Program.Cotton.Eco4060", - "LaundryCare.Washer.Program.Cotton.Colour", - "LaundryCare.Washer.Program.EasyCare", - "LaundryCare.Washer.Program.Mix", - "LaundryCare.Washer.Program.Mix.NightWash", - "LaundryCare.Washer.Program.DelicatesSilk", - "LaundryCare.Washer.Program.Wool", - "LaundryCare.Washer.Program.Sensitive", - "LaundryCare.Washer.Program.Auto30", - "LaundryCare.Washer.Program.Auto40", - "LaundryCare.Washer.Program.Auto60", - "LaundryCare.Washer.Program.Chiffon", - "LaundryCare.Washer.Program.Curtains", - "LaundryCare.Washer.Program.DarkWash", - "LaundryCare.Washer.Program.Dessous", - "LaundryCare.Washer.Program.Monsoon", - "LaundryCare.Washer.Program.Outdoor", - "LaundryCare.Washer.Program.PlushToy", - "LaundryCare.Washer.Program.ShirtsBlouses", - "LaundryCare.Washer.Program.SportFitness", - "LaundryCare.Washer.Program.Towels", - "LaundryCare.Washer.Program.WaterProof", - "LaundryCare.Washer.Program.PowerSpeed59", - "LaundryCare.Washer.Program.Super153045.Super15", - "LaundryCare.Washer.Program.Super153045.Super1530", - "LaundryCare.Washer.Program.DownDuvet.Duvet", - "LaundryCare.Washer.Program.Rinse.RinseSpinDrain", - "LaundryCare.Washer.Program.DrumClean", - "LaundryCare.WasherDryer.Program.Cotton", - "LaundryCare.WasherDryer.Program.Cotton.Eco4060", - "LaundryCare.WasherDryer.Program.Mix", - "LaundryCare.WasherDryer.Program.EasyCare", - "LaundryCare.WasherDryer.Program.WashAndDry60", - "LaundryCare.WasherDryer.Program.WashAndDry90", - ) + bsh_key_to_translation_key(program.value): cast(ProgramKey, program) + for program in ProgramKey + if program != ProgramKey.UNKNOWN } PROGRAMS_TRANSLATION_KEYS_MAP = { @@ -194,11 +31,11 @@ PROGRAMS_TRANSLATION_KEYS_MAP = { PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( SelectEntityDescription( - key=BSH_ACTIVE_PROGRAM, + key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, translation_key="active_program", ), SelectEntityDescription( - key=BSH_SELECTED_PROGRAM, + key=EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, translation_key="selected_program", ), ) @@ -211,31 +48,12 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect select entities.""" - def get_entities() -> list[HomeConnectProgramSelectEntity]: - """Get a list of entities.""" - entities: list[HomeConnectProgramSelectEntity] = [] - programs_not_found = set() - for device in entry.runtime_data.devices: - if device.appliance.type in APPLIANCES_WITH_PROGRAMS: - with contextlib.suppress(HomeConnectError): - programs = device.appliance.get_programs_available() - if programs: - for program in programs.copy(): - if program not in PROGRAMS_TRANSLATION_KEYS_MAP: - programs.remove(program) - if program not in programs_not_found: - _LOGGER.info( - 'The program "%s" is not part of the official Home Connect API specification', - program, - ) - programs_not_found.add(program) - entities.extend( - HomeConnectProgramSelectEntity(device, programs, desc) - for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS - ) - return entities - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities( + HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc) + for appliance in entry.runtime_data.data.values() + for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS + if appliance.info.type in APPLIANCES_WITH_PROGRAMS + ) class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): @@ -243,48 +61,45 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): def __init__( self, - device: HomeConnectDevice, - programs: list[str], + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, desc: SelectEntityDescription, ) -> None: """Initialize the entity.""" super().__init__( - device, + coordinator, + appliance, desc, ) self._attr_options = [ - PROGRAMS_TRANSLATION_KEYS_MAP[program] for program in programs + PROGRAMS_TRANSLATION_KEYS_MAP[program.key] + for program in appliance.programs + if program.key != ProgramKey.UNKNOWN ] - self.start_on_select = desc.key == BSH_ACTIVE_PROGRAM + self.start_on_select = desc.key == EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM + self._attr_current_option = None - async def async_update(self) -> None: - """Update the program selection status.""" - program = self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) - if not program: - program_translation_key = None - elif not ( - program_translation_key := PROGRAMS_TRANSLATION_KEYS_MAP.get(program) - ): - _LOGGER.debug( - 'The program "%s" is not part of the official Home Connect API specification', - program, - ) - self._attr_current_option = program_translation_key - _LOGGER.debug("Updated, new program: %s", self._attr_current_option) + def update_native_value(self) -> None: + """Set the program value.""" + event = self.appliance.events.get(cast(EventKey, self.bsh_key)) + self._attr_current_option = ( + PROGRAMS_TRANSLATION_KEYS_MAP.get(cast(ProgramKey, event.value)) + if event + else None + ) async def async_select_option(self, option: str) -> None: """Select new program.""" - bsh_key = TRANSLATION_KEYS_PROGRAMS_MAP[option] - _LOGGER.debug( - "Starting program: %s" if self.start_on_select else "Selecting program: %s", - bsh_key, - ) - if self.start_on_select: - target = self.device.appliance.start_program - else: - target = self.device.appliance.select_program + program_key = TRANSLATION_KEYS_PROGRAMS_MAP[option] try: - await self.hass.async_add_executor_job(target, bsh_key) + if self.start_on_select: + await self.coordinator.client.start_program( + self.appliance.info.ha_id, program_key=program_key + ) + else: + await self.coordinator.client.set_selected_program( + self.appliance.info.ha_id, program_key=program_key + ) except HomeConnectError as err: if self.start_on_select: translation_key = "start_program" @@ -295,7 +110,6 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): translation_key=translation_key, translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_PROGRAM: bsh_key, + SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value, }, ) from err - self.async_entity_update() diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index c11254d2c02..5e7c417a172 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,10 +1,11 @@ """Provides a sensor for Home Connect.""" from dataclasses import dataclass -from datetime import datetime, timedelta -import logging +from datetime import timedelta from typing import cast +from aiohomeconnect.model import EventKey, StatusKey + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -12,38 +13,26 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import PERCENTAGE, UnitOfTime, UnitOfVolume -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util, slugify -from . import HomeConnectConfigEntry from .const import ( APPLIANCES_WITH_PROGRAMS, - ATTR_VALUE, - BSH_DOOR_STATE, - BSH_OPERATION_STATE, BSH_OPERATION_STATE_FINISHED, BSH_OPERATION_STATE_PAUSE, BSH_OPERATION_STATE_RUN, - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, - COFFEE_EVENT_DRIP_TRAY_FULL, - COFFEE_EVENT_WATER_TANK_EMPTY, - DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, - DISHWASHER_EVENT_SALT_NEARLY_EMPTY, - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, - REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, - REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, ) +from .coordinator import HomeConnectConfigEntry from .entity import HomeConnectEntity -_LOGGER = logging.getLogger(__name__) - - EVENT_OPTIONS = ["confirmed", "off", "present"] @dataclass(frozen=True, kw_only=True) -class HomeConnectSensorEntityDescription(SensorEntityDescription): +class HomeConnectSensorEntityDescription( + SensorEntityDescription, +): """Entity Description class for sensors.""" default_value: str | None = None @@ -52,7 +41,7 @@ class HomeConnectSensorEntityDescription(SensorEntityDescription): BSH_PROGRAM_SENSORS = ( HomeConnectSensorEntityDescription( - key="BSH.Common.Option.RemainingProgramTime", + key=EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME, device_class=SensorDeviceClass.TIMESTAMP, translation_key="program_finish_time", appliance_types=( @@ -67,13 +56,13 @@ BSH_PROGRAM_SENSORS = ( ), ), HomeConnectSensorEntityDescription( - key="BSH.Common.Option.Duration", + key=EventKey.BSH_COMMON_OPTION_DURATION, device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, appliance_types=("Oven",), ), HomeConnectSensorEntityDescription( - key="BSH.Common.Option.ProgramProgress", + key=EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS, native_unit_of_measurement=PERCENTAGE, translation_key="program_progress", appliance_types=APPLIANCES_WITH_PROGRAMS, @@ -82,7 +71,7 @@ BSH_PROGRAM_SENSORS = ( SENSORS = ( HomeConnectSensorEntityDescription( - key=BSH_OPERATION_STATE, + key=StatusKey.BSH_COMMON_OPERATION_STATE, device_class=SensorDeviceClass.ENUM, options=[ "inactive", @@ -98,7 +87,7 @@ SENSORS = ( translation_key="operation_state", ), HomeConnectSensorEntityDescription( - key=BSH_DOOR_STATE, + key=StatusKey.BSH_COMMON_DOOR_STATE, device_class=SensorDeviceClass.ENUM, options=[ "closed", @@ -108,59 +97,59 @@ SENSORS = ( translation_key="door", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterCoffee", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="coffee_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterPowderCoffee", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_POWDER_COFFEE, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="powder_coffee_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotWater", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER, native_unit_of_measurement=UnitOfVolume.MILLILITERS, device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_water_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotWaterCups", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER_CUPS, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_water_cups_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterFrothyMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_FROTHY_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="frothy_milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterCoffeeAndMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE_AND_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="coffee_and_milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterRistrettoEspresso", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_RISTRETTO_ESPRESSO, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="ristretto_espresso_counter", ), HomeConnectSensorEntityDescription( - key="BSH.Common.Status.BatteryLevel", + key=StatusKey.BSH_COMMON_BATTERY_LEVEL, device_class=SensorDeviceClass.BATTERY, translation_key="battery_level", ), HomeConnectSensorEntityDescription( - key="BSH.Common.Status.Video.CameraState", + key=StatusKey.BSH_COMMON_VIDEO_CAMERA_STATE, device_class=SensorDeviceClass.ENUM, options=[ "disabled", @@ -174,7 +163,7 @@ SENSORS = ( translation_key="camera_state", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.LastSelectedMap", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LAST_SELECTED_MAP, device_class=SensorDeviceClass.ENUM, options=[ "tempmap", @@ -188,7 +177,7 @@ SENSORS = ( EVENT_SENSORS = ( HomeConnectSensorEntityDescription( - key=REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -196,7 +185,7 @@ EVENT_SENSORS = ( appliance_types=("FridgeFreezer", "Freezer"), ), HomeConnectSensorEntityDescription( - key=REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -204,7 +193,7 @@ EVENT_SENSORS = ( appliance_types=("FridgeFreezer", "Refrigerator"), ), HomeConnectSensorEntityDescription( - key=REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -212,7 +201,7 @@ EVENT_SENSORS = ( appliance_types=("FridgeFreezer", "Freezer"), ), HomeConnectSensorEntityDescription( - key=COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -220,7 +209,7 @@ EVENT_SENSORS = ( appliance_types=("CoffeeMaker",), ), HomeConnectSensorEntityDescription( - key=COFFEE_EVENT_WATER_TANK_EMPTY, + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -228,7 +217,7 @@ EVENT_SENSORS = ( appliance_types=("CoffeeMaker",), ), HomeConnectSensorEntityDescription( - key=COFFEE_EVENT_DRIP_TRAY_FULL, + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -236,7 +225,7 @@ EVENT_SENSORS = ( appliance_types=("CoffeeMaker",), ), HomeConnectSensorEntityDescription( - key=DISHWASHER_EVENT_SALT_NEARLY_EMPTY, + key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -244,7 +233,7 @@ EVENT_SENSORS = ( appliance_types=("Dishwasher",), ), HomeConnectSensorEntityDescription( - key=DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, + key=EventKey.DISHCARE_DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -261,33 +250,30 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect sensor.""" - def get_entities() -> list[SensorEntity]: - """Get a list of entities.""" - entities: list[SensorEntity] = [] - for device in entry.runtime_data.devices: - entities.extend( - HomeConnectSensor( - device, - description, - ) - for description in EVENT_SENSORS - if description.appliance_types - and device.appliance.type in description.appliance_types + entities: list[SensorEntity] = [] + for appliance in entry.runtime_data.data.values(): + entities.extend( + HomeConnectEventSensor( + entry.runtime_data, + appliance, + description, ) - entities.extend( - HomeConnectProgramSensor(device, desc) - for desc in BSH_PROGRAM_SENSORS - if desc.appliance_types - and device.appliance.type in desc.appliance_types - ) - entities.extend( - HomeConnectSensor(device, description) - for description in SENSORS - if description.key in device.appliance.status - ) - return entities + for description in EVENT_SENSORS + if description.appliance_types + and appliance.info.type in description.appliance_types + ) + entities.extend( + HomeConnectProgramSensor(entry.runtime_data, appliance, desc) + for desc in BSH_PROGRAM_SENSORS + if desc.appliance_types and appliance.info.type in desc.appliance_types + ) + entities.extend( + HomeConnectSensor(entry.runtime_data, appliance, description) + for description in SENSORS + if description.key in appliance.status + ) - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(entities) class HomeConnectSensor(HomeConnectEntity, SensorEntity): @@ -295,44 +281,25 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): entity_description: HomeConnectSensorEntityDescription - async def async_update(self) -> None: - """Update the sensor's status.""" - appliance_status = self.device.appliance.status - if ( - self.bsh_key not in appliance_status - or ATTR_VALUE not in appliance_status[self.bsh_key] - ): - self._attr_native_value = self.entity_description.default_value - _LOGGER.debug("Updated, new state: %s", self._attr_native_value) - return - status = appliance_status[self.bsh_key] + def update_native_value(self) -> None: + """Set the value of the sensor.""" + status = self.appliance.status[cast(StatusKey, self.bsh_key)].value + self._update_native_value(status) + + def _update_native_value(self, status: str | float) -> None: + """Set the value of the sensor based on the given value.""" match self.device_class: case SensorDeviceClass.TIMESTAMP: - if ATTR_VALUE not in status: - self._attr_native_value = None - elif ( - self._attr_native_value is not None - and isinstance(self._attr_native_value, datetime) - and self._attr_native_value < dt_util.utcnow() - ): - # if the date is supposed to be in the future but we're - # already past it, set state to None. - self._attr_native_value = None - else: - seconds = float(status[ATTR_VALUE]) - self._attr_native_value = dt_util.utcnow() + timedelta( - seconds=seconds - ) + self._attr_native_value = dt_util.utcnow() + timedelta( + seconds=cast(float, status) + ) case SensorDeviceClass.ENUM: # Value comes back as an enum, we only really care about the # last part, so split it off # https://developer.home-connect.com/docs/status/operation_state - self._attr_native_value = slugify( - cast(str, status.get(ATTR_VALUE)).split(".")[-1] - ) + self._attr_native_value = slugify(cast(str, status).split(".")[-1]) case _: - self._attr_native_value = status.get(ATTR_VALUE) - _LOGGER.debug("Updated, new state: %s", self._attr_native_value) + self._attr_native_value = status class HomeConnectProgramSensor(HomeConnectSensor): @@ -340,6 +307,31 @@ class HomeConnectProgramSensor(HomeConnectSensor): program_running: bool = False + async def async_added_to_hass(self) -> None: + """Register listener.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_add_listener( + self._handle_operation_state_event, + (self.appliance.info.ha_id, EventKey.BSH_COMMON_STATUS_OPERATION_STATE), + ) + ) + + @callback + def _handle_operation_state_event(self) -> None: + """Update status when an event for the entity is received.""" + self.program_running = ( + status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE) + ) is not None and status.value in [ + BSH_OPERATION_STATE_RUN, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_FINISHED, + ] + if not self.program_running: + # reset the value when the program is not running, paused or finished + self._attr_native_value = None + self.async_write_ha_state() + @property def available(self) -> bool: """Return true if the sensor is available.""" @@ -347,20 +339,20 @@ class HomeConnectProgramSensor(HomeConnectSensor): # Otherwise, some sensors report erroneous values. return super().available and self.program_running - async def async_update(self) -> None: + def update_native_value(self) -> None: + """Update the program sensor's status.""" + event = self.appliance.events.get(cast(EventKey, self.bsh_key)) + if event: + self._update_native_value(event.value) + + +class HomeConnectEventSensor(HomeConnectSensor): + """Sensor class for Home Connect events.""" + + def update_native_value(self) -> None: """Update the sensor's status.""" - self.program_running = ( - BSH_OPERATION_STATE in (appliance_status := self.device.appliance.status) - and ATTR_VALUE in appliance_status[BSH_OPERATION_STATE] - and appliance_status[BSH_OPERATION_STATE][ATTR_VALUE] - in [ - BSH_OPERATION_STATE_RUN, - BSH_OPERATION_STATE_PAUSE, - BSH_OPERATION_STATE_FINISHED, - ] - ) - if self.program_running: - await super().async_update() - else: - # reset the value when the program is not running, paused or finished - self._attr_native_value = None + event = self.appliance.events.get(cast(EventKey, self.bsh_key)) + if event: + self._update_native_value(event.value) + elif not self._attr_native_value: + self._attr_native_value = self.entity_description.default_value diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 7ededaae5b7..d163d04a6f7 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -26,64 +26,67 @@ "message": "Appliance for device ID {device_id} not found" }, "turn_on_light": { - "message": "Error turning on {entity_id}: {description}" + "message": "Error turning on {entity_id}: {error}" }, "turn_off_light": { - "message": "Error turning off {entity_id}: {description}" + "message": "Error turning off {entity_id}: {error}" }, "set_light_brightness": { - "message": "Error setting brightness of {entity_id}: {description}" + "message": "Error setting brightness of {entity_id}: {error}" }, "select_light_custom_color": { - "message": "Error selecting custom color of {entity_id}: {description}" + "message": "Error selecting custom color of {entity_id}: {error}" }, "set_light_color": { - "message": "Error setting color of {entity_id}: {description}" + "message": "Error setting color of {entity_id}: {error}" }, "set_setting_entity": { - "message": "Error assigning the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}" + "message": "Error assigning the value \"{value}\" to the setting \"{key}\" for {entity_id}: {error}" }, "set_setting": { - "message": "Error assigning the value \"{value}\" to the setting \"{key}\": {description}" + "message": "Error assigning the value \"{value}\" to the setting \"{key}\": {error}" }, "turn_on": { - "message": "Error turning on {entity_id} ({key}): {description}" + "message": "Error turning on {entity_id} ({key}): {error}" }, "turn_off": { - "message": "Error turning off {entity_id} ({key}): {description}" + "message": "Error turning off {entity_id} ({key}): {error}" }, "select_program": { - "message": "Error selecting program {program}: {description}" + "message": "Error selecting program {program}: {error}" }, "start_program": { - "message": "Error starting program {program}: {description}" + "message": "Error starting program {program}: {error}" }, "pause_program": { - "message": "Error pausing program: {description}" + "message": "Error pausing program: {error}" }, "stop_program": { - "message": "Error stopping program: {description}" + "message": "Error stopping program: {error}" }, "set_options_active_program": { - "message": "Error setting options for the active program: {description}" + "message": "Error setting options for the active program: {error}" }, "set_options_selected_program": { - "message": "Error setting options for the selected program: {description}" + "message": "Error setting options for the selected program: {error}" }, "execute_command": { - "message": "Error executing command {command}: {description}" + "message": "Error executing command {command}: {error}" }, "power_on": { - "message": "Error turning on {appliance_name}: {description}" + "message": "Error turning on {appliance_name}: {error}" }, "power_off": { - "message": "Error turning off {appliance_name} with value \"{value}\": {description}" + "message": "Error turning off {appliance_name} with value \"{value}\": {error}" }, "turn_off_not_supported": { "message": "{appliance_name} does not support turning off or entering standby mode." }, "unable_to_retrieve_turn_off": { "message": "Unable to turn off {appliance_name} because its support for turning off or entering standby mode could not be determined." + }, + "fetch_api_error": { + "message": "Error obtaining data from the API: {error}" } }, "issues": { diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 1bd02e03eb1..c3a0858e0bb 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -1,10 +1,11 @@ """Provides a switch for Home Connect.""" -import contextlib import logging -from typing import Any +from typing import Any, cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import EventKey, ProgramKey, SettingKey +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.program import EnumerateAvailableProgram from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity @@ -18,87 +19,83 @@ from homeassistant.helpers.issue_registry import ( async_create_issue, async_delete_issue, ) +from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( - APPLIANCES_WITH_PROGRAMS, - ATTR_ALLOWED_VALUES, - ATTR_CONSTRAINTS, - ATTR_VALUE, - BSH_ACTIVE_PROGRAM, - BSH_CHILD_LOCK_STATE, BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, - BSH_POWER_STATE, DOMAIN, - REFRIGERATION_DISPENSER, - REFRIGERATION_SUPERMODEFREEZER, - REFRIGERATION_SUPERMODEREFRIGERATOR, SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) -from .entity import HomeConnectDevice, HomeConnectEntity +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, +) +from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) SWITCHES = ( SwitchEntityDescription( - key=BSH_CHILD_LOCK_STATE, + key=SettingKey.BSH_COMMON_CHILD_LOCK, translation_key="child_lock", ), SwitchEntityDescription( - key="ConsumerProducts.CoffeeMaker.Setting.CupWarmer", + key=SettingKey.CONSUMER_PRODUCTS_COFFEE_MAKER_CUP_WARMER, translation_key="cup_warmer", ), SwitchEntityDescription( - key=REFRIGERATION_SUPERMODEFREEZER, + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER, translation_key="freezer_super_mode", ), SwitchEntityDescription( - key=REFRIGERATION_SUPERMODEREFRIGERATOR, + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_REFRIGERATOR, translation_key="refrigerator_super_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.EcoMode", + key=SettingKey.REFRIGERATION_COMMON_ECO_MODE, translation_key="eco_mode", ), SwitchEntityDescription( - key="Cooking.Oven.Setting.SabbathMode", + key=SettingKey.COOKING_OVEN_SABBATH_MODE, translation_key="sabbath_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.SabbathMode", + key=SettingKey.REFRIGERATION_COMMON_SABBATH_MODE, translation_key="sabbath_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.VacationMode", + key=SettingKey.REFRIGERATION_COMMON_VACATION_MODE, translation_key="vacation_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.FreshMode", + key=SettingKey.REFRIGERATION_COMMON_FRESH_MODE, translation_key="fresh_mode", ), SwitchEntityDescription( - key=REFRIGERATION_DISPENSER, + key=SettingKey.REFRIGERATION_COMMON_DISPENSER_ENABLED, translation_key="dispenser_enabled", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.Door.AssistantFridge", + key=SettingKey.REFRIGERATION_COMMON_DOOR_ASSISTANT_FRIDGE, translation_key="door_assistant_fridge", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.Door.AssistantFreezer", + key=SettingKey.REFRIGERATION_COMMON_DOOR_ASSISTANT_FREEZER, translation_key="door_assistant_freezer", ), ) POWER_SWITCH_DESCRIPTION = SwitchEntityDescription( - key=BSH_POWER_STATE, + key=SettingKey.BSH_COMMON_POWER_STATE, translation_key="power", ) @@ -110,29 +107,26 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect switch.""" - def get_entities() -> list[SwitchEntity]: - """Get a list of entities.""" - entities: list[SwitchEntity] = [] - for device in entry.runtime_data.devices: - if device.appliance.type in APPLIANCES_WITH_PROGRAMS: - with contextlib.suppress(HomeConnectError): - programs = device.appliance.get_programs_available() - if programs: - entities.extend( - HomeConnectProgramSwitch(device, program) - for program in programs - ) - if BSH_POWER_STATE in device.appliance.status: - entities.append(HomeConnectPowerSwitch(device)) - entities.extend( - HomeConnectSwitch(device, description) - for description in SWITCHES - if description.key in device.appliance.status + entities: list[SwitchEntity] = [] + for appliance in entry.runtime_data.data.values(): + entities.extend( + HomeConnectProgramSwitch(entry.runtime_data, appliance, program) + for program in appliance.programs + if program.key != ProgramKey.UNKNOWN + ) + if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings: + entities.append( + HomeConnectPowerSwitch( + entry.runtime_data, appliance, POWER_SWITCH_DESCRIPTION + ) ) + entities.extend( + HomeConnectSwitch(entry.runtime_data, appliance, description) + for description in SWITCHES + if description.key in appliance.settings + ) - return entities - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(entities) class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): @@ -140,11 +134,11 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on setting.""" - - _LOGGER.debug("Turning on %s", self.entity_description.key) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.entity_description.key, True + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=True, ) except HomeConnectError as err: self._attr_available = False @@ -158,19 +152,15 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): }, ) from err - self._attr_available = True - self.async_entity_update() - async def async_turn_off(self, **kwargs: Any) -> None: """Turn off setting.""" - - _LOGGER.debug("Turning off %s", self.entity_description.key) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.entity_description.key, False + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=False, ) except HomeConnectError as err: - _LOGGER.error("Error while trying to turn off: %s", err) self._attr_available = False raise HomeAssistantError( translation_domain=DOMAIN, @@ -182,38 +172,35 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): }, ) from err - self._attr_available = True - self.async_entity_update() - - async def async_update(self) -> None: + def update_native_value(self) -> None: """Update the switch's status.""" - - self._attr_is_on = self.device.appliance.status.get( - self.entity_description.key, {} - ).get(ATTR_VALUE) - self._attr_available = True - _LOGGER.debug( - "Updated %s, new state: %s", - self.entity_description.key, - self._attr_is_on, - ) + self._attr_is_on = self.appliance.settings[SettingKey(self.bsh_key)].value class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): """Switch class for Home Connect.""" - def __init__(self, device: HomeConnectDevice, program_name: str) -> None: + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + program: EnumerateAvailableProgram, + ) -> None: """Initialize the entity.""" - desc = " ".join(["Program", program_name.split(".")[-1]]) - if device.appliance.type == "WasherDryer": + desc = " ".join(["Program", program.key.split(".")[-1]]) + if appliance.info.type == "WasherDryer": desc = " ".join( - ["Program", program_name.split(".")[-3], program_name.split(".")[-1]] + ["Program", program.key.split(".")[-3], program.key.split(".")[-1]] ) - super().__init__(device, SwitchEntityDescription(key=program_name)) - self._attr_name = f"{device.appliance.name} {desc}" - self._attr_unique_id = f"{device.appliance.haId}-{desc}" + super().__init__( + coordinator, + appliance, + SwitchEntityDescription(key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM), + ) + self._attr_name = f"{appliance.info.name} {desc}" + self._attr_unique_id = f"{appliance.info.ha_id}-{desc}" self._attr_has_entity_name = False - self.program_name = program_name + self.program = program async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" @@ -266,10 +253,9 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Start the program.""" - _LOGGER.debug("Tried to turn on program %s", self.program_name) try: - await self.hass.async_add_executor_job( - self.device.appliance.start_program, self.program_name + await self.coordinator.client.start_program( + self.appliance.info.ha_id, program_key=self.program.key ) except HomeConnectError as err: raise HomeAssistantError( @@ -277,16 +263,14 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): translation_key="start_program", translation_placeholders={ **get_dict_from_home_connect_error(err), - "program": self.program_name, + "program": self.program.key, }, ) from err - self.async_entity_update() async def async_turn_off(self, **kwargs: Any) -> None: """Stop the program.""" - _LOGGER.debug("Tried to stop program %s", self.program_name) try: - await self.hass.async_add_executor_job(self.device.appliance.stop_program) + await self.coordinator.client.stop_program(self.appliance.info.ha_id) except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, @@ -295,48 +279,25 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): **get_dict_from_home_connect_error(err), }, ) from err - self.async_entity_update() - async def async_update(self) -> None: - """Update the switch's status.""" - state = self.device.appliance.status.get(BSH_ACTIVE_PROGRAM, {}) - if state.get(ATTR_VALUE) == self.program_name: - self._attr_is_on = True - else: - self._attr_is_on = False - _LOGGER.debug("Updated, new state: %s", self._attr_is_on) + def update_native_value(self) -> None: + """Update the switch's status based on if the program related to this entity is currently active.""" + event = self.appliance.events.get(EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM) + self._attr_is_on = bool(event and event.value == self.program.key) class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): """Power switch class for Home Connect.""" - power_off_state: str | None - - def __init__(self, device: HomeConnectDevice) -> None: - """Initialize the entity.""" - super().__init__( - device, - POWER_SWITCH_DESCRIPTION, - ) - if ( - power_state := device.appliance.status.get(BSH_POWER_STATE, {}).get( - ATTR_VALUE - ) - ) and power_state in [BSH_POWER_OFF, BSH_POWER_STANDBY]: - self.power_off_state = power_state - - async def async_added_to_hass(self) -> None: - """Add the entity to the hass instance.""" - await super().async_added_to_hass() - if not hasattr(self, "power_off_state"): - await self.async_fetch_power_off_state() + power_off_state: str | None | UndefinedType = UNDEFINED async def async_turn_on(self, **kwargs: Any) -> None: """Switch the device on.""" - _LOGGER.debug("Tried to switch on %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, BSH_POWER_STATE, BSH_POWER_ON + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + value=BSH_POWER_ON, ) except HomeConnectError as err: self._attr_is_on = False @@ -345,36 +306,36 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_key="power_on", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name, + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name, }, ) from err - self.async_entity_update() async def async_turn_off(self, **kwargs: Any) -> None: """Switch the device off.""" - if not hasattr(self, "power_off_state"): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unable_to_retrieve_turn_off", - translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name - }, - ) + if self.power_off_state is UNDEFINED: + await self.async_fetch_power_off_state() + if self.power_off_state is UNDEFINED: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_retrieve_turn_off", + translation_placeholders={ + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name + }, + ) if self.power_off_state is None: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="turn_off_not_supported", translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name }, ) - _LOGGER.debug("tried to switch off %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - BSH_POWER_STATE, - self.power_off_state, + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + value=self.power_off_state, ) except HomeConnectError as err: self._attr_is_on = True @@ -383,46 +344,51 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_key="power_off", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name, + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name, SVE_TRANSLATION_PLACEHOLDER_VALUE: self.power_off_state, }, ) from err - self.async_entity_update() - async def async_update(self) -> None: - """Update the switch's status.""" - if ( - self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) - == BSH_POWER_ON - ): + def update_native_value(self) -> None: + """Set the value of the entity.""" + power_state = self.appliance.settings[SettingKey.BSH_COMMON_POWER_STATE] + value = cast(str, power_state.value) + if value == BSH_POWER_ON: self._attr_is_on = True elif ( - hasattr(self, "power_off_state") - and self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) - == self.power_off_state + isinstance(self.power_off_state, str) + and self.power_off_state + and value == self.power_off_state ): self._attr_is_on = False + elif self.power_off_state is UNDEFINED and value in [ + BSH_POWER_OFF, + BSH_POWER_STANDBY, + ]: + self.power_off_state = value + self._attr_is_on = False else: self._attr_is_on = None - _LOGGER.debug("Updated, new state: %s", self._attr_is_on) async def async_fetch_power_off_state(self) -> None: """Fetch the power off state.""" - try: - data = await self.hass.async_add_executor_job( - self.device.appliance.get, f"/settings/{self.bsh_key}" - ) - except HomeConnectError as err: - _LOGGER.error("An error occurred: %s", err) - return - if not data or not ( - allowed_values := data.get(ATTR_CONSTRAINTS, {}).get(ATTR_ALLOWED_VALUES) - ): + data = self.appliance.settings[SettingKey.BSH_COMMON_POWER_STATE] + + if not data.constraints or not data.constraints.allowed_values: + try: + data = await self.coordinator.client.get_setting( + self.appliance.info.ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + ) + except HomeConnectError as err: + _LOGGER.error("An error occurred fetching the power settings: %s", err) + return + if not data.constraints or not data.constraints.allowed_values: return - if BSH_POWER_OFF in allowed_values: + if BSH_POWER_OFF in data.constraints.allowed_values: self.power_off_state = BSH_POWER_OFF - elif BSH_POWER_STANDBY in allowed_values: + elif BSH_POWER_STANDBY in data.constraints.allowed_values: self.power_off_state = BSH_POWER_STANDBY else: self.power_off_state = None diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index c1f125cd2f7..5ed07424082 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -1,32 +1,30 @@ """Provides time enties for Home Connect.""" from datetime import time -import logging +from typing import cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import SettingKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( - ATTR_VALUE, DOMAIN, SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) +from .coordinator import HomeConnectConfigEntry from .entity import HomeConnectEntity - -_LOGGER = logging.getLogger(__name__) - +from .utils import get_dict_from_home_connect_error TIME_ENTITIES = ( TimeEntityDescription( - key="BSH.Common.Setting.AlarmClock", + key=SettingKey.BSH_COMMON_ALARM_CLOCK, translation_key="alarm_clock", ), ) @@ -39,16 +37,14 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect switch.""" - def get_entities() -> list[HomeConnectTimeEntity]: - """Get a list of entities.""" - return [ - HomeConnectTimeEntity(device, description) + async_add_entities( + [ + HomeConnectTimeEntity(entry.runtime_data, appliance, description) for description in TIME_ENTITIES - for device in entry.runtime_data.devices - if description.key in device.appliance.status - ] - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + for appliance in entry.runtime_data.data.values() + if description.key in appliance.settings + ], + ) def seconds_to_time(seconds: int) -> time: @@ -68,17 +64,11 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): async def async_set_value(self, value: time) -> None: """Set the native value of the entity.""" - _LOGGER.debug( - "Tried to set value %s to %s for %s", - value, - self.bsh_key, - self.entity_id, - ) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self.bsh_key, - time_to_seconds(value), + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=time_to_seconds(value), ) except HomeConnectError as err: raise HomeAssistantError( @@ -92,16 +82,7 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): }, ) from err - async def async_update(self) -> None: - """Update the Time setting status.""" - data = self.device.appliance.status.get(self.bsh_key) - if data is None: - _LOGGER.error("No value for %s", self.bsh_key) - self._attr_native_value = None - return - seconds = data.get(ATTR_VALUE, None) - if seconds is not None: - self._attr_native_value = seconds_to_time(seconds) - else: - self._attr_native_value = None - _LOGGER.debug("Updated, new value: %s", self._attr_native_value) + def update_native_value(self) -> None: + """Set the value of the entity.""" + data = self.appliance.settings[cast(SettingKey, self.bsh_key)] + self._attr_native_value = seconds_to_time(data.value) diff --git a/homeassistant/components/home_connect/utils.py b/homeassistant/components/home_connect/utils.py new file mode 100644 index 00000000000..108465072e1 --- /dev/null +++ b/homeassistant/components/home_connect/utils.py @@ -0,0 +1,29 @@ +"""Utility functions for Home Connect.""" + +import re + +from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError + +RE_CAMEL_CASE = re.compile(r"(? dict[str, str]: + """Return a translation string from a Home Connect error.""" + return { + "error": str(err) + if isinstance(err, HomeConnectApiError) + else type(err).__name__ + } + + +def bsh_key_to_translation_key(bsh_key: str) -> str: + """Convert a BSH key to a translation key format. + + This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`, + and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`. + """ + return "_".join( + RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".") + ).lower() diff --git a/requirements_all.txt b/requirements_all.txt index 9e6da1045a4..731b1cdeb67 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -263,6 +263,9 @@ aioharmony==0.4.1 # homeassistant.components.hassio aiohasupervisor==0.2.2b6 +# homeassistant.components.home_connect +aiohomeconnect==0.12.1 + # homeassistant.components.homekit_controller aiohomekit==3.2.7 @@ -1148,9 +1151,6 @@ home-assistant-frontend==20250129.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 -# homeassistant.components.home_connect -homeconnect==0.8.0 - # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 76ae46099c2..db89f8db9d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -248,6 +248,9 @@ aioharmony==0.4.1 # homeassistant.components.hassio aiohasupervisor==0.2.2b6 +# homeassistant.components.home_connect +aiohomeconnect==0.12.1 + # homeassistant.components.homekit_controller aiohomekit==3.2.7 @@ -977,9 +980,6 @@ home-assistant-frontend==20250129.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 -# homeassistant.components.home_connect -homeconnect==0.8.0 - # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 2ac8c851e1b..af039f04c03 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -1,18 +1,32 @@ """Test fixtures for home_connect.""" -from collections.abc import Awaitable, Callable, Generator +import asyncio +from collections.abc import AsyncGenerator, Awaitable, Callable +import copy import time -from typing import Any -from unittest.mock import MagicMock, Mock, PropertyMock, patch +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock, patch -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import ( + ArrayOfAvailablePrograms, + ArrayOfEvents, + ArrayOfHomeAppliances, + ArrayOfSettings, + ArrayOfStatus, + Event, + EventKey, + EventMessage, + EventType, + Option, +) +from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError import pytest from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.home_connect import update_all_devices from homeassistant.components.home_connect.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -20,12 +34,17 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_json_object_fixture -MOCK_APPLIANCES_PROPERTIES = { - x["name"]: x - for x in load_json_object_fixture("home_connect/appliances.json")["data"][ - "homeappliances" - ] -} +MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( + load_json_object_fixture("home_connect/appliances.json")["data"] +) +MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture( + "home_connect/programs-available.json" +) +MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") +MOCK_STATUS = ArrayOfStatus.from_dict( + load_json_object_fixture("home_connect/status.json")["data"] +) + CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -102,32 +121,23 @@ def platforms() -> list[Platform]: return [] -async def bypass_throttle(hass: HomeAssistant, config_entry: MockConfigEntry): - """Add kwarg to disable throttle.""" - await update_all_devices(hass, config_entry, no_throttle=True) - - -@pytest.fixture(name="bypass_throttle") -def mock_bypass_throttle() -> Generator[None]: - """Fixture to bypass the throttle decorator in __init__.""" - with patch( - "homeassistant.components.home_connect.update_all_devices", - side_effect=bypass_throttle, - ): - yield - - @pytest.fixture(name="integration_setup") async def mock_integration_setup( hass: HomeAssistant, platforms: list[Platform], config_entry: MockConfigEntry, -) -> Callable[[], Awaitable[bool]]: +) -> Callable[[MagicMock], Awaitable[bool]]: """Fixture to set up the integration.""" config_entry.add_to_hass(hass) - async def run() -> bool: - with patch("homeassistant.components.home_connect.PLATFORMS", platforms): + async def run(client: MagicMock) -> bool: + with ( + patch("homeassistant.components.home_connect.PLATFORMS", platforms), + patch( + "homeassistant.components.home_connect.HomeConnectClient" + ) as client_mock, + ): + client_mock.return_value = client result = await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() return result @@ -135,125 +145,205 @@ async def mock_integration_setup( return run -@pytest.fixture(name="get_appliances") -def mock_get_appliances() -> Generator[MagicMock]: - """Mock ConfigEntryAuth parent (HomeAssistantAPI) method.""" - with patch( - "homeassistant.components.home_connect.api.ConfigEntryAuth.get_appliances", - ) as mock: - yield mock +def _get_set_program_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], event_key: EventKey +): + """Set program side effect.""" + + async def set_program_side_effect(ha_id: str, *_, **kwargs) -> None: + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=str(kwargs["program_key"]), + ), + *[ + Event( + key=(option_event := EventKey(option.key)), + raw_key=option_event.value, + timestamp=0, + level="", + handling="", + value=str(option.key), + ) + for option in cast( + list[Option], kwargs.get("options", []) + ) + ], + ] + ), + ), + ] + ) + + return set_program_side_effect -@pytest.fixture(name="appliance") -def mock_appliance(request: pytest.FixtureRequest) -> MagicMock: +def _get_set_key_value_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], parameter_key: str +): + """Set program options side effect.""" + + async def set_key_value_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs[parameter_key]) + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=kwargs["value"], + ) + ] + ), + ), + ] + ) + + return set_key_value_side_effect + + +async def _get_available_programs_side_effect(ha_id: str) -> ArrayOfAvailablePrograms: + """Get available programs.""" + appliance_type = next( + appliance + for appliance in MOCK_APPLIANCES.homeappliances + if appliance.ha_id == ha_id + ).type + if appliance_type not in MOCK_PROGRAMS: + raise HomeConnectApiError("error.key", "error description") + + return ArrayOfAvailablePrograms.from_dict(MOCK_PROGRAMS[appliance_type]["data"]) + + +async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + """Get settings.""" + return ArrayOfSettings.from_dict( + MOCK_SETTINGS.get( + next( + appliance + for appliance in MOCK_APPLIANCES.homeappliances + if appliance.ha_id == ha_id + ).type, + {}, + ).get("data", {"settings": []}) + ) + + +@pytest.fixture(name="client") +def mock_client(request: pytest.FixtureRequest) -> MagicMock: + """Fixture to mock Client from HomeConnect.""" + + mock = MagicMock( + autospec=HomeConnectClient, + ) + + event_queue: asyncio.Queue[list[EventMessage]] = asyncio.Queue() + + async def add_events(events: list[EventMessage]) -> None: + await event_queue.put(events) + + mock.add_events = add_events + + async def stream_all_events() -> AsyncGenerator[EventMessage]: + """Mock stream_all_events.""" + while True: + for event in await event_queue.get(): + yield event + + mock.get_home_appliances = AsyncMock(return_value=MOCK_APPLIANCES) + mock.stream_all_events = stream_all_events + mock.start_program = AsyncMock( + side_effect=_get_set_program_side_effect( + event_queue, EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM + ) + ) + mock.set_selected_program = AsyncMock( + side_effect=_get_set_program_side_effect( + event_queue, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM + ), + ) + mock.set_active_program_option = AsyncMock( + side_effect=_get_set_key_value_side_effect(event_queue, "option_key"), + ) + mock.set_selected_program_option = AsyncMock( + side_effect=_get_set_key_value_side_effect(event_queue, "option_key"), + ) + mock.set_setting = AsyncMock( + side_effect=_get_set_key_value_side_effect(event_queue, "setting_key"), + ) + mock.get_settings = AsyncMock(side_effect=_get_settings_side_effect) + mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS)) + mock.get_available_programs = AsyncMock( + side_effect=_get_available_programs_side_effect + ) + mock.put_command = AsyncMock() + + mock.side_effect = mock + return mock + + +@pytest.fixture(name="client_with_exception") +def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: + """Fixture to mock Client from HomeConnect that raise exceptions.""" + mock = MagicMock( + autospec=HomeConnectClient, + ) + + exception = HomeConnectError() + if hasattr(request, "param") and request.param: + exception = request.param + + event_queue: asyncio.Queue[list[EventMessage]] = asyncio.Queue() + + async def stream_all_events() -> AsyncGenerator[EventMessage]: + """Mock stream_all_events.""" + while True: + for event in await event_queue.get(): + yield event + + mock.get_home_appliances = AsyncMock(return_value=MOCK_APPLIANCES) + mock.stream_all_events = stream_all_events + + mock.start_program = AsyncMock(side_effect=exception) + mock.stop_program = AsyncMock(side_effect=exception) + mock.get_available_programs = AsyncMock(side_effect=exception) + mock.set_selected_program = AsyncMock(side_effect=exception) + mock.set_active_program_option = AsyncMock(side_effect=exception) + mock.set_selected_program_option = 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) + mock.get_status = AsyncMock(side_effect=exception) + mock.get_available_programs = AsyncMock(side_effect=exception) + mock.put_command = AsyncMock(side_effect=exception) + + return mock + + +@pytest.fixture(name="appliance_ha_id") +def mock_appliance_ha_id(request: pytest.FixtureRequest) -> str: """Fixture to mock Appliance.""" app = "Washer" if hasattr(request, "param") and request.param: app = request.param - - mock = MagicMock( - autospec=HomeConnectAppliance, - **MOCK_APPLIANCES_PROPERTIES.get(app), - ) - mock.name = app - type(mock).status = PropertyMock(return_value={}) - mock.get.return_value = {} - mock.get_programs_available.return_value = [] - mock.get_status.return_value = {} - mock.get_settings.return_value = {} - - return mock - - -@pytest.fixture(name="problematic_appliance") -def mock_problematic_appliance(request: pytest.FixtureRequest) -> Mock: - """Fixture to mock a problematic Appliance.""" - app = "Washer" - if hasattr(request, "param") and request.param: - app = request.param - - mock = Mock( - autospec=HomeConnectAppliance, - **MOCK_APPLIANCES_PROPERTIES.get(app), - ) - mock.name = app - type(mock).status = PropertyMock(return_value={}) - mock.get.side_effect = HomeConnectError - mock.get_programs_active.side_effect = HomeConnectError - mock.get_programs_available.side_effect = HomeConnectError - mock.start_program.side_effect = HomeConnectError - mock.select_program.side_effect = HomeConnectError - mock.pause_program.side_effect = HomeConnectError - mock.stop_program.side_effect = HomeConnectError - mock.set_options_active_program.side_effect = HomeConnectError - mock.set_options_selected_program.side_effect = HomeConnectError - mock.get_status.side_effect = HomeConnectError - mock.get_settings.side_effect = HomeConnectError - mock.set_setting.side_effect = HomeConnectError - mock.set_setting.side_effect = HomeConnectError - mock.execute_command.side_effect = HomeConnectError - - return mock - - -def get_all_appliances(): - """Return a list of `HomeConnectAppliance` instances for all appliances.""" - - appliances = {} - - data = load_json_object_fixture("home_connect/appliances.json").get("data") - programs_active = load_json_object_fixture("home_connect/programs-active.json") - programs_available = load_json_object_fixture( - "home_connect/programs-available.json" - ) - - def listen_callback(mock, callback): - callback["callback"](mock) - - for home_appliance in data["homeappliances"]: - api_status = load_json_object_fixture("home_connect/status.json") - api_settings = load_json_object_fixture("home_connect/settings.json") - - ha_id = home_appliance["haId"] - ha_type = home_appliance["type"] - - appliance = MagicMock(spec=HomeConnectAppliance, **home_appliance) - appliance.name = home_appliance["name"] - appliance.listen_events.side_effect = ( - lambda app=appliance, **x: listen_callback(app, x) - ) - appliance.get_programs_active.return_value = programs_active.get( - ha_type, {} - ).get("data", {}) - appliance.get_programs_available.return_value = [ - program["key"] - for program in programs_available.get(ha_type, {}) - .get("data", {}) - .get("programs", []) - ] - appliance.get_status.return_value = HomeConnectAppliance.json2dict( - api_status.get("data", {}).get("status", []) - ) - appliance.get_settings.return_value = HomeConnectAppliance.json2dict( - api_settings.get(ha_type, {}).get("data", {}).get("settings", []) - ) - setattr(appliance, "status", {}) - appliance.status.update(appliance.get_status.return_value) - appliance.status.update(appliance.get_settings.return_value) - appliance.set_setting.side_effect = ( - lambda x, y, appliance=appliance: appliance.status.update({x: {"value": y}}) - ) - appliance.start_program.side_effect = ( - lambda x, appliance=appliance: appliance.status.update( - {"BSH.Common.Root.ActiveProgram": {"value": x}} - ) - ) - appliance.stop_program.side_effect = ( - lambda appliance=appliance: appliance.status.update( - {"BSH.Common.Root.ActiveProgram": {}} - ) - ) - - appliances[ha_id] = appliance - - return list(appliances.values()) + for appliance in MOCK_APPLIANCES.homeappliances: + if appliance.type == app: + return appliance.ha_id + raise ValueError(f"Appliance {app} not found") diff --git a/tests/components/home_connect/fixtures/settings.json b/tests/components/home_connect/fixtures/settings.json index 1b9bec57276..a357d8fb43e 100644 --- a/tests/components/home_connect/fixtures/settings.json +++ b/tests/components/home_connect/fixtures/settings.json @@ -2,6 +2,11 @@ "Dishwasher": { "data": { "settings": [ + { + "key": "BSH.Common.Setting.ChildLock", + "value": false, + "type": "Boolean" + }, { "key": "BSH.Common.Setting.AmbientLightEnabled", "value": true, @@ -26,7 +31,13 @@ { "key": "BSH.Common.Setting.PowerState", "value": "BSH.Common.EnumType.PowerState.On", - "type": "BSH.Common.EnumType.PowerState" + "type": "BSH.Common.EnumType.PowerState", + "constraints": { + "allowedvalues": [ + "BSH.Common.EnumType.PowerState.On", + "BSH.Common.EnumType.PowerState.Off" + ] + } }, { "key": "BSH.Common.Setting.ChildLock", @@ -92,6 +103,11 @@ "key": "BSH.Common.Setting.PowerState", "value": "BSH.Common.EnumType.PowerState.On", "type": "BSH.Common.EnumType.PowerState" + }, + { + "key": "BSH.Common.Setting.AlarmClock", + "value": 0, + "type": "Integer" } ] } @@ -154,6 +170,12 @@ "max": 100, "access": "readWrite" } + }, + { + "key": "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + "value": 8, + "unit": "°C", + "type": "Double" } ] } diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index f3131eac52f..f3c73a32d95 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -2,255 +2,209 @@ # name: test_async_get_config_entry_diagnostics dict({ 'BOSCH-000000000-000000000000': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/00', + 'ha_id': 'BOSCH-000000000-000000000000', + 'name': 'DNE', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'DNE', + 'vib': 'HCS000000', }), 'BOSCH-HCS000000-D00000000001': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/01', + 'ha_id': 'BOSCH-HCS000000-D00000000001', + 'name': 'WasherDryer', 'programs': list([ 'LaundryCare.WasherDryer.Program.Mix', 'LaundryCare.Washer.Option.Temperature', ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'WasherDryer', + 'vib': 'HCS000001', }), 'BOSCH-HCS000000-D00000000002': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/02', + 'ha_id': 'BOSCH-HCS000000-D00000000002', + 'name': 'Refrigerator', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Refrigerator', + 'vib': 'HCS000002', }), 'BOSCH-HCS000000-D00000000003': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/03', + 'ha_id': 'BOSCH-HCS000000-D00000000003', + 'name': 'Freezer', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Freezer', + 'vib': 'HCS000003', }), 'BOSCH-HCS000000-D00000000004': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/04', + 'ha_id': 'BOSCH-HCS000000-D00000000004', + 'name': 'Hood', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ColorTemperature': dict({ - 'type': 'BSH.Common.EnumType.ColorTemperature', - 'value': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Cooking.Common.Setting.Lighting': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'Cooking.Common.Setting.LightingBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Cooking.Hood.Setting.ColorTemperaturePercent': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': 70, + 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', + 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', + 'BSH.Common.Setting.AmbientLightEnabled': True, + 'Cooking.Common.Setting.Lighting': True, + 'Cooking.Common.Setting.LightingBrightness': 70, + 'Cooking.Hood.Setting.ColorTemperaturePercent': 70, + 'unknown': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Hood', + 'vib': 'HCS000004', }), 'BOSCH-HCS000000-D00000000005': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/05', + 'ha_id': 'BOSCH-HCS000000-D00000000005', + 'name': 'Hob', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Hob', + 'vib': 'HCS000005', }), 'BOSCH-HCS000000-D00000000006': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/06', + 'ha_id': 'BOSCH-HCS000000-D00000000006', + 'name': 'CookProcessor', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'CookProcessor', + 'vib': 'HCS000006', }), 'BOSCH-HCS01OVN1-43E0065FE245': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS01OVN1/03', + 'ha_id': 'BOSCH-HCS01OVN1-43E0065FE245', + 'name': 'Oven', 'programs': list([ 'Cooking.Oven.Program.HeatingMode.HotAir', 'Cooking.Oven.Program.HeatingMode.TopBottomHeating', 'Cooking.Oven.Program.HeatingMode.PizzaSetting', ]), - 'status': dict({ - 'BSH.Common.Root.ActiveProgram': dict({ - 'value': 'Cooking.Oven.Program.HeatingMode.HotAir', - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AlarmClock': 0, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Oven', + 'vib': 'HCS01OVN1', }), 'BOSCH-HCS04DYR1-831694AE3C5A': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS04DYR1/03', + 'ha_id': 'BOSCH-HCS04DYR1-831694AE3C5A', + 'name': 'Dryer', 'programs': list([ 'LaundryCare.Dryer.Program.Cotton', 'LaundryCare.Dryer.Program.Synthetic', 'LaundryCare.Dryer.Program.Mix', ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Dryer', + 'vib': 'HCS04DYR1', }), 'BOSCH-HCS06COM1-D70390681C2C': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS06COM1/03', + 'ha_id': 'BOSCH-HCS06COM1-D70390681C2C', + 'name': 'CoffeeMaker', 'programs': list([ 'ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso', 'ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato', @@ -259,26 +213,24 @@ 'ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato', 'ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte', ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'CoffeeMaker', + 'vib': 'HCS06COM1', }), 'SIEMENS-HCS02DWH1-6BE58C26DCC1': dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS02DWH1/03', + 'ha_id': 'SIEMENS-HCS02DWH1-6BE58C26DCC1', + 'name': 'Dishwasher', 'programs': list([ 'Dishcare.Dishwasher.Program.Auto1', 'Dishcare.Dishwasher.Program.Auto2', @@ -286,51 +238,30 @@ 'Dishcare.Dishwasher.Program.Eco50', 'Dishcare.Dishwasher.Program.Quick45', ]), - 'status': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': 70, + 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', + 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', + 'BSH.Common.Setting.AmbientLightEnabled': True, + 'BSH.Common.Setting.ChildLock': False, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Dishwasher', + 'vib': 'HCS02DWH1', }), 'SIEMENS-HCS03WCH1-7BC6383CF794': dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS03WCH1/03', + 'ha_id': 'SIEMENS-HCS03WCH1-7BC6383CF794', + 'name': 'Washer', 'programs': list([ 'LaundryCare.Washer.Program.Cotton', 'LaundryCare.Washer.Program.EasyCare', @@ -338,97 +269,55 @@ 'LaundryCare.Washer.Program.DelicatesSilk', 'LaundryCare.Washer.Program.Wool', ]), - 'status': dict({ - 'BSH.Common.Root.ActiveProgram': dict({ - 'value': 'BSH.Common.Root.ActiveProgram', - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.ChildLock': False, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Washer', + 'vib': 'HCS03WCH1', }), 'SIEMENS-HCS05FRF1-304F4F9E541D': dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS05FRF1/03', + 'ha_id': 'SIEMENS-HCS05FRF1-304F4F9E541D', + 'name': 'FridgeFreezer', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Setting.Dispenser.Enabled': dict({ - 'constraints': dict({ - 'access': 'readWrite', - }), - 'type': 'Boolean', - 'value': False, - }), - 'Refrigeration.Common.Setting.Light.External.Brightness': dict({ - 'constraints': dict({ - 'access': 'readWrite', - 'max': 100, - 'min': 0, - }), - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Refrigeration.Common.Setting.Light.External.Power': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), - 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': dict({ - 'constraints': dict({ - 'access': 'readWrite', - }), - 'type': 'Boolean', - 'value': False, - }), - 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': dict({ - 'constraints': dict({ - 'access': 'readWrite', - }), - 'type': 'Boolean', - 'value': False, - }), + 'settings': dict({ + 'Refrigeration.Common.Setting.Dispenser.Enabled': False, + 'Refrigeration.Common.Setting.Light.External.Brightness': 70, + 'Refrigeration.Common.Setting.Light.External.Power': True, + 'Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator': 8, + 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': False, + 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': False, }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'FridgeFreezer', + 'vib': 'HCS05FRF1', }), }) # --- # name: test_async_get_device_diagnostics dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS02DWH1/03', + 'ha_id': 'SIEMENS-HCS02DWH1-6BE58C26DCC1', + 'name': 'Dishwasher', 'programs': list([ 'Dishcare.Dishwasher.Program.Auto1', 'Dishcare.Dishwasher.Program.Auto2', @@ -436,47 +325,22 @@ 'Dishcare.Dishwasher.Program.Eco50', 'Dishcare.Dishwasher.Program.Quick45', ]), - 'status': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': 70, + 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', + 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', + 'BSH.Common.Setting.AmbientLightEnabled': True, + 'BSH.Common.Setting.ChildLock': False, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Dishwasher', + 'vib': 'HCS02DWH1', }) # --- diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index 8e108cc2b0a..182051ad64a 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -1,32 +1,29 @@ """Tests for home_connect binary_sensor entities.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectAPI +from aiohomeconnect.model import ArrayOfEvents, Event, EventKey, EventMessage, EventType import pytest from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE, BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, DOMAIN, REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN, - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, ) from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.entity_component import async_update_entity +import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry @pytest.fixture @@ -35,123 +32,166 @@ def platforms() -> list[str]: return [Platform.BINARY_SENSOR] -@pytest.mark.usefixtures("bypass_throttle") async def test_binary_sensors( config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, ) -> None: """Test binary sensor entities.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @pytest.mark.parametrize( - ("state", "expected"), + ("value", "expected"), [ (BSH_DOOR_STATE_CLOSED, "off"), (BSH_DOOR_STATE_LOCKED, "off"), (BSH_DOOR_STATE_OPEN, "on"), - ("", "unavailable"), + ("", STATE_UNKNOWN), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_binary_sensors_door_states( + appliance_ha_id: str, expected: str, - state: str, + value: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, ) -> None: """Tests for Appliance door states.""" entity_id = "binary_sensor.washer_door" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.status.update({BSH_DOOR_STATE: {"value": state}}) - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - await async_update_entity(hass, entity_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_DOOR_STATE, + raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + ] + ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) @pytest.mark.parametrize( - ("entity_id", "status_key", "event_value_update", "expected", "appliance"), + ("entity_id", "event_key", "event_value_update", "expected", "appliance_ha_id"), [ + ( + "binary_sensor.washer_remote_control", + EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + False, + STATE_OFF, + "Washer", + ), + ( + "binary_sensor.washer_remote_control", + EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + True, + STATE_ON, + "Washer", + ), + ( + "binary_sensor.washer_remote_control", + EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + "", + STATE_UNKNOWN, + "Washer", + ), ( "binary_sensor.fridgefreezer_refrigerator_door", - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR, REFRIGERATION_STATUS_DOOR_CLOSED, STATE_OFF, "FridgeFreezer", ), ( "binary_sensor.fridgefreezer_refrigerator_door", - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR, REFRIGERATION_STATUS_DOOR_OPEN, STATE_ON, "FridgeFreezer", ), ( "binary_sensor.fridgefreezer_refrigerator_door", - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR, "", - STATE_UNAVAILABLE, + STATE_UNKNOWN, "FridgeFreezer", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) -@pytest.mark.usefixtures("bypass_throttle") -async def test_bianry_sensors_fridge_door_states( +async def test_binary_sensors_functionality( entity_id: str, - status_key: str, + event_key: EventKey, event_value_update: str, - appliance: Mock, + appliance_ha_id: str, expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Tests for Home Connect Fridge appliance door states.""" - appliance.status.update( - HomeConnectAPI.json2dict( - load_json_object_fixture("home_connect/status.json")["data"]["status"] - ) - ) - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update({status_key: {"value": event_value_update}}) - await async_update_entity(hass, entity_id) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=event_value_update, + ) + ], + ), + ) + ] + ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) @pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.usefixtures("bypass_throttle") async def test_create_issue( hass: HomeAssistant, - appliance: Mock, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" entity_id = "binary_sensor.washer_door" - get_appliances.return_value = [appliance] issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" assert await async_setup_component( @@ -189,8 +229,7 @@ async def test_create_issue( ) assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.status.update({BSH_DOOR_STATE: {"value": BSH_DOOR_STATE_OPEN}}) - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert automations_with_entity(hass, entity_id)[0] == "automation.test" diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index 80f53e20b39..c015a881343 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -3,6 +3,7 @@ from http import HTTPStatus from unittest.mock import patch +from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN import pytest from homeassistant import config_entries, setup @@ -10,11 +11,7 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.home_connect.const import ( - DOMAIN, - OAUTH2_AUTHORIZE, - OAUTH2_TOKEN, -) +from homeassistant.components.home_connect.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py new file mode 100644 index 00000000000..51f42a98f42 --- /dev/null +++ b/tests/components/home_connect/test_coordinator.py @@ -0,0 +1,367 @@ +"""Test for Home Connect coordinator.""" + +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfSettings, + ArrayOfStatus, + Event, + EventKey, + EventMessage, + EventType, + Status, + StatusKey, +) +from aiohomeconnect.model.error import ( + EventStreamInterruptedError, + HomeConnectApiError, + HomeConnectError, + HomeConnectRequestError, +) +import pytest + +from homeassistant.components.home_connect.const import ( + BSH_DOOR_STATE_LOCKED, + BSH_DOOR_STATE_OPEN, + BSH_EVENT_PRESENT_STATE_PRESENT, + BSH_POWER_OFF, +) +from homeassistant.config_entries import ConfigEntries, ConfigEntryState +from homeassistant.const import EVENT_STATE_REPORTED, Platform +from homeassistant.core import ( + Event as HassEvent, + EventStateReportedData, + HomeAssistant, + callback, +) +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR, Platform.SWITCH] + + +async def test_coordinator_update( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the coordinator can update.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_coordinator_update_failing_get_appliances( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test that the coordinator raises ConfigEntryNotReady when it fails to get appliances.""" + client_with_exception.get_home_appliances.return_value = None + client_with_exception.get_home_appliances.side_effect = HomeConnectError() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_coordinator_update_failing_get_settings_status( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test that although is not possible to get settings and status, the config entry is loaded. + + This is for cases where some appliances are reachable and some are not in the same configuration entry. + """ + # Get home appliances does pass at client_with_exception.get_home_appliances mock, so no need to mock it again + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + +@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) +@pytest.mark.parametrize( + ("event_type", "event_key", "event_value", "entity_id"), + [ + ( + EventType.STATUS, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + BSH_DOOR_STATE_OPEN, + "sensor.dishwasher_door", + ), + ( + EventType.NOTIFY, + EventKey.BSH_COMMON_SETTING_POWER_STATE, + BSH_POWER_OFF, + "switch.dishwasher_power", + ), + ( + EventType.EVENT, + EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, + BSH_EVENT_PRESENT_STATE_PRESENT, + "sensor.dishwasher_salt_nearly_empty", + ), + ], +) +async def test_event_listener( + event_type: EventType, + event_key: EventKey, + event_value: str, + entity_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the event listener works.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + state = hass.states.get(entity_id) + assert state + event_message = EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=event_value, + ) + ], + ), + ) + await client.add_events([event_message]) + await hass.async_block_till_done() + + new_state = hass.states.get(entity_id) + assert new_state + assert new_state.state != state.state + + # Following, we are gonna check that the listeners are clean up correctly + new_entity_id = entity_id + "_new" + listener = MagicMock() + + @callback + def listener_callback(event: HassEvent[EventStateReportedData]) -> None: + listener(event.data["entity_id"]) + + @callback + def event_filter(_: EventStateReportedData) -> bool: + return True + + hass.bus.async_listen(EVENT_STATE_REPORTED, listener_callback, event_filter) + + entity_registry.async_update_entity(entity_id, new_entity_id=new_entity_id) + await hass.async_block_till_done() + await client.add_events([event_message]) + await hass.async_block_till_done() + + # Because the entity's id has been updated, the entity has been unloaded + # and the listener has been removed, and the new entity adds a new listener, + # so the only entity that should report states is the one with the new entity id + listener.assert_called_once_with(new_entity_id) + + +async def tests_receive_setting_and_status_for_first_time_at_events( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test that the event listener is capable of receiving settings and status for the first time.""" + client.get_setting = AsyncMock(return_value=ArrayOfSettings([])) + client.get_status = AsyncMock(return_value=ArrayOfStatus([])) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=EventKey.LAUNDRY_CARE_WASHER_SETTING_I_DOS_1_BASE_LEVEL, + raw_key=EventKey.LAUNDRY_CARE_WASHER_SETTING_I_DOS_1_BASE_LEVEL.value, + timestamp=0, + level="", + handling="", + value="some value", + ) + ], + ), + ), + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_DOOR_STATE, + raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value, + timestamp=0, + level="", + handling="", + value="some value", + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + assert len(config_entry._background_tasks) == 1 + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_event_listener_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test that the configuration entry is reloaded when the event stream raises an API error.""" + client_with_exception.stream_all_events = MagicMock( + side_effect=HomeConnectApiError("error.key", "error description") + ) + + with patch.object( + ConfigEntries, + "async_schedule_reload", + ) as mock_schedule_reload: + await integration_setup(client_with_exception) + await hass.async_block_till_done() + + client_with_exception.stream_all_events.assert_called_once() + mock_schedule_reload.assert_called_once_with(config_entry.entry_id) + assert not config_entry._background_tasks + + +@pytest.mark.parametrize( + "exception", + [HomeConnectRequestError(), EventStreamInterruptedError()], +) +@pytest.mark.parametrize( + ( + "entity_id", + "initial_state", + "status_key", + "status_value", + "after_refresh_expected_state", + "event_key", + "event_value", + "after_event_expected_state", + ), + [ + ( + "sensor.washer_door", + "closed", + StatusKey.BSH_COMMON_DOOR_STATE, + BSH_DOOR_STATE_LOCKED, + "locked", + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + BSH_DOOR_STATE_OPEN, + "open", + ), + ], +) +@patch( + "homeassistant.components.home_connect.coordinator.EVENT_STREAM_RECONNECT_DELAY", 0 +) +async def test_event_listener_resilience( + entity_id: str, + initial_state: str, + status_key: StatusKey, + status_value: Any, + after_refresh_expected_state: str, + event_key: EventKey, + event_value: Any, + after_event_expected_state: str, + exception: HomeConnectError, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test that the event listener is resilient to interruptions.""" + future = hass.loop.create_future() + + async def stream_exception(): + yield await future + + client.stream_all_events = MagicMock( + side_effect=[stream_exception(), client.stream_all_events()] + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + assert len(config_entry._background_tasks) == 1 + + assert hass.states.is_state(entity_id, initial_state) + + client.get_status.return_value = ArrayOfStatus( + [Status(key=status_key, raw_key=status_key.value, value=status_value)], + ) + await hass.async_block_till_done() + future.set_exception(exception) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert client.stream_all_events.call_count == 2 + assert hass.states.is_state(entity_id, after_refresh_expected_state) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=event_value, + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state(entity_id, after_event_expected_state) diff --git a/tests/components/home_connect/test_diagnostics.py b/tests/components/home_connect/test_diagnostics.py index f2db6e2b67a..ab6823411dc 100644 --- a/tests/components/home_connect/test_diagnostics.py +++ b/tests/components/home_connect/test_diagnostics.py @@ -1,11 +1,9 @@ """Test diagnostics for Home Connect.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectError -import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.home_connect.diagnostics import ( @@ -16,43 +14,37 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .conftest import get_all_appliances - from tests.common import MockConfigEntry -@pytest.mark.usefixtures("bypass_throttle") async def test_async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert await async_get_config_entry_diagnostics(hass, config_entry) == snapshot -@pytest.mark.usefixtures("bypass_throttle") async def test_async_get_device_diagnostics( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: """Test device config entry diagnostics.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED device = device_registry.async_get_or_create( @@ -61,69 +53,3 @@ async def test_async_get_device_diagnostics( ) assert await async_get_device_diagnostics(hass, config_entry, device) == snapshot - - -@pytest.mark.usefixtures("bypass_throttle") -async def test_async_device_diagnostics_not_found( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - get_appliances: MagicMock, - device_registry: dr.DeviceRegistry, -) -> None: - """Test device config entry diagnostics.""" - get_appliances.side_effect = get_all_appliances - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - - device = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, "Random-Device-ID")}, - ) - - with pytest.raises(ValueError): - await async_get_device_diagnostics(hass, config_entry, device) - - -@pytest.mark.parametrize( - ("api_error", "expected_connection_status"), - [ - (HomeConnectError(), "unknown"), - ( - HomeConnectError( - { - "key": "SDK.Error.HomeAppliance.Connection.Initialization.Failed", - } - ), - "offline", - ), - ], -) -@pytest.mark.usefixtures("bypass_throttle") -async def test_async_device_diagnostics_api_error( - api_error: HomeConnectError, - expected_connection_status: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, - device_registry: dr.DeviceRegistry, -) -> None: - """Test device config entry diagnostics.""" - appliance.get_programs_available.side_effect = api_error - get_appliances.return_value = [appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - - device = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance.haId)}, - ) - - diagnostics = await async_get_device_diagnostics(hass, config_entry, device) - assert diagnostics["programs"] is None diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 69601efb42d..f62feca700a 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -2,27 +2,18 @@ from collections.abc import Awaitable, Callable from typing import Any -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, patch -from freezegun.api import FrozenDateTimeFactory +from aiohomeconnect.const import OAUTH2_TOKEN +from aiohomeconnect.model import SettingKey, StatusKey +from aiohomeconnect.model.error import HomeConnectError import pytest -from requests import HTTPError import requests_mock +import respx from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.home_connect import ( - SCAN_INTERVAL, - bsh_key_to_translation_key, -) -from homeassistant.components.home_connect.const import ( - BSH_CHILD_LOCK_STATE, - BSH_OPERATION_STATE, - BSH_POWER_STATE, - BSH_REMOTE_START_ALLOWANCE_STATE, - COOKING_LIGHTING, - DOMAIN, - OAUTH2_TOKEN, -) +from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.components.home_connect.utils import bsh_key_to_translation_key from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -39,7 +30,6 @@ from .conftest import ( FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN, SERVER_ACCESS_TOKEN, - get_all_appliances, ) from tests.common import MockConfigEntry @@ -126,28 +116,26 @@ SERVICE_PROGRAM_CALL_PARAMS = [ ] SERVICE_APPLIANCE_METHOD_MAPPING = { - "set_option_active": "set_options_active_program", - "set_option_selected": "set_options_selected_program", + "set_option_active": "set_active_program_option", + "set_option_selected": "set_selected_program_option", "change_setting": "set_setting", - "pause_program": "execute_command", - "resume_program": "execute_command", - "select_program": "select_program", + "pause_program": "put_command", + "resume_program": "put_command", + "select_program": "set_selected_program", "start_program": "start_program", } -@pytest.mark.usefixtures("bypass_throttle") -async def test_api_setup( +async def test_entry_setup( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test setup and unload.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert await hass.config_entries.async_unload(config_entry.entry_id) @@ -156,72 +144,60 @@ async def test_api_setup( assert config_entry.state == ConfigEntryState.NOT_LOADED -async def test_update_throttle( - appliance: Mock, - freezer: FrozenDateTimeFactory, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - get_appliances: MagicMock, -) -> None: - """Test to check Throttle functionality.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - get_appliances_call_count = get_appliances.call_count - - # First re-load after 1 minute is not blocked. - assert await hass.config_entries.async_unload(config_entry.entry_id) - assert config_entry.state == ConfigEntryState.NOT_LOADED - freezer.tick(SCAN_INTERVAL.seconds + 0.1) - assert await hass.config_entries.async_setup(config_entry.entry_id) - assert get_appliances.call_count == get_appliances_call_count + 1 - - # Second re-load is blocked by Throttle. - assert await hass.config_entries.async_unload(config_entry.entry_id) - assert config_entry.state == ConfigEntryState.NOT_LOADED - freezer.tick(SCAN_INTERVAL.seconds - 0.1) - assert await hass.config_entries.async_setup(config_entry.entry_id) - assert get_appliances.call_count == get_appliances_call_count + 1 - - -@pytest.mark.usefixtures("bypass_throttle") async def test_exception_handling( - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - get_appliances: MagicMock, - problematic_appliance: Mock, + client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - get_appliances.return_value = [problematic_appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED @pytest.mark.parametrize("token_expiration_time", [12345]) -@pytest.mark.usefixtures("bypass_throttle") +@respx.mock async def test_token_refresh_success( - integration_setup: Callable[[], Awaitable[bool]], + hass: HomeAssistant, + platforms: list[Platform], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, requests_mock: requests_mock.Mocker, setup_credentials: None, + client: MagicMock, ) -> None: """Test where token is expired and the refresh attempt succeeds.""" assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN requests_mock.post(OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN) - requests_mock.get("/api/homeappliances", json={"data": {"homeappliances": []}}) - aioclient_mock.post( OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN, ) - assert await integration_setup() + appliances = client.get_home_appliances.return_value + + async def mock_get_home_appliances(): + await client._auth.async_get_access_token() + return appliances + + client.get_home_appliances.return_value = None + client.get_home_appliances.side_effect = mock_get_home_appliances + + def init_side_effect(auth) -> MagicMock: + client._auth = auth + return client + + assert config_entry.state == ConfigEntryState.NOT_LOADED + with ( + patch("homeassistant.components.home_connect.PLATFORMS", platforms), + patch("homeassistant.components.home_connect.HomeConnectClient") as client_mock, + ): + client_mock.side_effect = MagicMock(side_effect=init_side_effect) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() assert config_entry.state == ConfigEntryState.LOADED # Verify token request @@ -240,45 +216,43 @@ async def test_token_refresh_success( ) -@pytest.mark.usefixtures("bypass_throttle") -async def test_http_error( +async def test_client_error( config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: - """Test HTTP errors during setup integration.""" - get_appliances.side_effect = HTTPError(response=MagicMock()) + """Test client errors during setup integration.""" + client_with_exception.get_home_appliances.return_value = None + client_with_exception.get_home_appliances.side_effect = HomeConnectError() assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - assert get_appliances.call_count == 1 + assert not await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.SETUP_RETRY + assert client_with_exception.get_home_appliances.call_count == 1 @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) -@pytest.mark.usefixtures("bypass_throttle") async def test_services( - service_call: list[dict[str, Any]], + service_call: dict[str, Any], hass: HomeAssistant, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, + appliance_ha_id: str, ) -> None: """Create and test services.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + 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.haId)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) service_name = service_call["service"] @@ -286,8 +260,7 @@ async def test_services( await hass.services.async_call(**service_call) await hass.async_block_till_done() assert ( - getattr(appliance, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count - == 1 + getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1 ) @@ -295,26 +268,24 @@ async def test_services( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) -@pytest.mark.usefixtures("bypass_throttle") async def test_services_exception( - service_call: list[dict[str, Any]], + service_call: dict[str, Any], hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - problematic_appliance: Mock, + client_with_exception: MagicMock, + appliance_ha_id: str, device_registry: dr.DeviceRegistry, ) -> None: """Raise a HomeAssistantError when there is an API error.""" - get_appliances.return_value = [problematic_appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + 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, problematic_appliance.haId)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) service_call["service_data"]["device_id"] = device_entry.id @@ -323,25 +294,47 @@ async def test_services_exception( await hass.services.async_call(**service_call) -@pytest.mark.usefixtures("bypass_throttle") async def test_services_appliance_not_found( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, + device_registry: dr.DeviceRegistry, ) -> None: """Raise a ServiceValidationError when device id does not match.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED service_call = SERVICE_KV_CALL_PARAMS[0] service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" + with pytest.raises(ServiceValidationError, match=r"Device entry.*not found"): + await hass.services.async_call(**service_call) + + unrelated_config_entry = MockConfigEntry( + domain="TEST", + ) + unrelated_config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=unrelated_config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises( + ServiceValidationError, match=r"Home Connect config entry.*not found" + ): + await hass.services.async_call(**service_call) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"): await hass.services.async_call(**service_call) @@ -351,7 +344,7 @@ async def test_entity_migration( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, config_entry_v1_1: MockConfigEntry, - appliance: Mock, + appliance_ha_id: str, platforms: list[Platform], ) -> None: """Test entity migration.""" @@ -360,34 +353,39 @@ async def test_entity_migration( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_v1_1.entry_id, - identifiers={(DOMAIN, appliance.haId)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) test_entities = [ ( SENSOR_DOMAIN, "Operation State", - BSH_OPERATION_STATE, + StatusKey.BSH_COMMON_OPERATION_STATE, ), ( SWITCH_DOMAIN, "ChildLock", - BSH_CHILD_LOCK_STATE, + SettingKey.BSH_COMMON_CHILD_LOCK, ), ( SWITCH_DOMAIN, "Power", - BSH_POWER_STATE, + SettingKey.BSH_COMMON_POWER_STATE, ), ( BINARY_SENSOR_DOMAIN, "Remote Start", - BSH_REMOTE_START_ALLOWANCE_STATE, + StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED, ), ( LIGHT_DOMAIN, "Light", - COOKING_LIGHTING, + SettingKey.COOKING_COMMON_LIGHTING, + ), + ( # An already migrated entity + SWITCH_DOMAIN, + SettingKey.REFRIGERATION_COMMON_VACATION_MODE, + SettingKey.REFRIGERATION_COMMON_VACATION_MODE, ), ] @@ -395,7 +393,7 @@ async def test_entity_migration( entity_registry.async_get_or_create( domain, DOMAIN, - f"{appliance.haId}-{old_unique_id_suffix}", + f"{appliance_ha_id}-{old_unique_id_suffix}", device_id=device_entry.id, config_entry=config_entry_v1_1, ) @@ -406,7 +404,7 @@ async def test_entity_migration( for domain, _, expected_unique_id_suffix in test_entities: assert entity_registry.async_get_entity_id( - domain, DOMAIN, f"{appliance.haId}-{expected_unique_id_suffix}" + domain, DOMAIN, f"{appliance_ha_id}-{expected_unique_id_suffix}" ) assert config_entry_v1_1.minor_version == 2 diff --git a/tests/components/home_connect/test_light.py b/tests/components/home_connect/test_light.py index 471ddf0ec54..4f8cb60d881 100644 --- a/tests/components/home_connect/test_light.py +++ b/tests/components/home_connect/test_light.py @@ -1,20 +1,24 @@ """Tests for home_connect light entities.""" -from collections.abc import Awaitable, Callable, Generator -from unittest.mock import MagicMock, Mock +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import MagicMock, call -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfSettings, + Event, + EventKey, + EventMessage, + EventType, + GetSetting, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectError import pytest from homeassistant.components.home_connect.const import ( - BSH_AMBIENT_LIGHT_BRIGHTNESS, - BSH_AMBIENT_LIGHT_COLOR, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR, - BSH_AMBIENT_LIGHT_ENABLED, - COOKING_LIGHTING, - COOKING_LIGHTING_BRIGHTNESS, - REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, - REFRIGERATION_EXTERNAL_LIGHT_POWER, + BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, ) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -23,26 +27,15 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, - STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import get_all_appliances - -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry TEST_HC_APP = "Hood" -SETTINGS_STATUS = { - setting.pop("key"): setting - for setting in load_json_object_fixture("home_connect/settings.json") - .get(TEST_HC_APP) - .get("data") - .get("settings") -} - @pytest.fixture def platforms() -> list[str]: @@ -51,29 +44,31 @@ def platforms() -> list[str]: async def test_light( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test switch entities.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @pytest.mark.parametrize( - ("entity_id", "status", "service", "service_data", "state", "appliance"), + ( + "entity_id", + "set_settings_args", + "service", + "exprected_attributes", + "state", + "appliance_ha_id", + ), [ ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": True, - }, + SettingKey.COOKING_COMMON_LIGHTING: True, }, SERVICE_TURN_ON, {}, @@ -83,58 +78,18 @@ async def test_light( ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": True, - }, - COOKING_LIGHTING_BRIGHTNESS: {"value": 70}, + SettingKey.COOKING_COMMON_LIGHTING: True, + SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 80, }, SERVICE_TURN_ON, - {"brightness": 200}, + {"brightness": 199}, STATE_ON, "Hood", ), ( "light.hood_functional_light", { - COOKING_LIGHTING: {"value": False}, - COOKING_LIGHTING_BRIGHTNESS: {"value": 70}, - }, - SERVICE_TURN_OFF, - {}, - STATE_OFF, - "Hood", - ), - ( - "light.hood_functional_light", - { - COOKING_LIGHTING: { - "value": None, - }, - COOKING_LIGHTING_BRIGHTNESS: None, - }, - SERVICE_TURN_ON, - {}, - STATE_UNKNOWN, - "Hood", - ), - ( - "light.hood_ambient_light", - { - BSH_AMBIENT_LIGHT_ENABLED: { - "value": True, - }, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, - }, - SERVICE_TURN_ON, - {"brightness": 200}, - STATE_ON, - "Hood", - ), - ( - "light.hood_ambient_light", - { - BSH_AMBIENT_LIGHT_ENABLED: {"value": False}, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, + SettingKey.COOKING_COMMON_LIGHTING: False, }, SERVICE_TURN_OFF, {}, @@ -144,8 +99,28 @@ async def test_light( ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: {"value": True}, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR: {}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 80, + }, + SERVICE_TURN_ON, + {"brightness": 199}, + STATE_ON, + "Hood", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: False, + }, + SERVICE_TURN_OFF, + {}, + STATE_OFF, + "Hood", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, }, SERVICE_TURN_ON, {}, @@ -155,15 +130,28 @@ async def test_light( ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: {"value": True}, - BSH_AMBIENT_LIGHT_COLOR: { - "value": "", - }, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR: {}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", }, SERVICE_TURN_ON, { - "rgb_color": [255, 255, 0], + "rgb_color": (255, 255, 0), + }, + STATE_ON, + "Hood", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#b5adcc", + }, + SERVICE_TURN_ON, + { + "hs_color": (255.484, 15.196), + "brightness": 199, }, STATE_ON, "Hood", @@ -171,10 +159,7 @@ async def test_light( ( "light.fridgefreezer_external_light", { - REFRIGERATION_EXTERNAL_LIGHT_POWER: { - "value": True, - }, - REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS: {"value": 75}, + SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER: True, }, SERVICE_TURN_ON, {}, @@ -182,167 +167,268 @@ async def test_light( "FridgeFreezer", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) async def test_light_functionality( entity_id: str, - status: dict, + set_settings_args: dict[SettingKey, Any], service: str, - service_data: dict, + exprected_attributes: dict[str, Any], state: str, - appliance: Mock, - bypass_throttle: Generator[None], + appliance_ha_id: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test light functionality.""" - appliance.status.update( - HomeConnectAppliance.json2dict( - load_json_object_fixture("home_connect/settings.json") - .get(appliance.name) - .get("data") - .get("settings") - ) - ) - get_appliances.return_value = [appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(status) + service_data = exprected_attributes.copy() service_data["entity_id"] = entity_id await hass.services.async_call( LIGHT_DOMAIN, service, - service_data, - blocking=True, + {key: value for key, value in service_data.items() if value is not None}, ) - assert hass.states.is_state(entity_id, state) + await hass.async_block_till_done() + client.set_setting.assert_has_calls( + [ + call(appliance_ha_id, setting_key=setting_key, value=value) + for setting_key, value in set_settings_args.items() + ] + ) + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == state + for key, value in exprected_attributes.items(): + assert entity_state.attributes[key] == value @pytest.mark.parametrize( ( "entity_id", - "status", + "events", + "appliance_ha_id", + ), + [ + ( + "light.hood_ambient_light", + { + EventKey.BSH_COMMON_SETTING_AMBIENT_LIGHT_COLOR: "BSH.Common.EnumType.AmbientLightColor.Color1", + }, + "Hood", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_light_color_different_than_custom( + entity_id: str, + events: dict[EventKey, Any], + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that light color attributes are not set if color is different than custom.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + "rgb_color": (255, 255, 0), + "entity_id": entity_id, + }, + ) + await hass.async_block_till_done() + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == STATE_ON + assert entity_state.attributes["rgb_color"] is not None + assert entity_state.attributes["hs_color"] is not None + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + for event_key, value in events.items() + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == STATE_ON + assert entity_state.attributes["rgb_color"] is None + assert entity_state.attributes["hs_color"] is None + + +@pytest.mark.parametrize( + ( + "entity_id", + "setting", "service", "service_data", - "mock_attr", "attr_side_effect", - "problematic_appliance", "exception_match", ), [ ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": False, - }, + SettingKey.COOKING_COMMON_LIGHTING: True, }, SERVICE_TURN_ON, {}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*on.*", ), ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": True, - }, - COOKING_LIGHTING_BRIGHTNESS: {"value": 70}, + SettingKey.COOKING_COMMON_LIGHTING: True, + SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 70, }, SERVICE_TURN_ON, {"brightness": 200}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*on.*", ), ( "light.hood_functional_light", { - COOKING_LIGHTING: {"value": False}, + SettingKey.COOKING_COMMON_LIGHTING: False, }, SERVICE_TURN_OFF, {}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*off.*", ), ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: { - "value": True, - }, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, }, SERVICE_TURN_ON, {}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*on.*", ), ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: { - "value": True, - }, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, }, SERVICE_TURN_ON, {"brightness": 200}, - "set_setting", [HomeConnectError, None, HomeConnectError], - "Hood", + r"Error.*set.*brightness.*", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: 70, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", + }, + SERVICE_TURN_ON, + {"rgb_color": (255, 255, 0)}, + [HomeConnectError, None, HomeConnectError], + r"Error.*select.*custom color.*", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", + }, + SERVICE_TURN_ON, + {"rgb_color": (255, 255, 0)}, + [HomeConnectError, None, None, HomeConnectError], + r"Error.*set.*color.*", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#b5adcc", + }, + SERVICE_TURN_ON, + { + "hs_color": (255.484, 15.196), + "brightness": 199, + }, + [HomeConnectError, None, None, HomeConnectError], r"Error.*set.*color.*", ), ], - indirect=["problematic_appliance"], ) -async def test_switch_exception_handling( +async def test_light_exception_handling( entity_id: str, - status: dict, + setting: dict[SettingKey, dict[str, Any]], service: str, service_data: dict, - mock_attr: str, - attr_side_effect: list, - problematic_appliance: Mock, + attr_side_effect: list[type[HomeConnectError] | None], exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test light exception handling.""" - problematic_appliance.status.update(SETTINGS_STATUS) - problematic_appliance.set_setting.side_effect = attr_side_effect - get_appliances.return_value = [problematic_appliance] - + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=value, + ) + for setting_key, value in setting.items() + ] + ) + client_with_exception.set_setting.side_effect = [ + exception() if exception else None for exception in attr_side_effect + ] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await client_with_exception.set_setting() - problematic_appliance.status.update(status) service_data["entity_id"] = entity_id with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( LIGHT_DOMAIN, service, service_data, blocking=True ) - assert getattr(problematic_appliance, mock_attr).call_count == len(attr_side_effect) + assert client_with_exception.set_setting.call_count == len(attr_side_effect) diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index bce19161cf8..371aed928dd 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -1,22 +1,17 @@ """Tests for home_connect number entities.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable import random -from unittest.mock import MagicMock, Mock +from unittest.mock import AsyncMock, MagicMock -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import ArrayOfSettings, GetSetting, SettingKey +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.setting import SettingConstraints import pytest -from homeassistant.components.home_connect.const import ( - ATTR_CONSTRAINTS, - ATTR_STEPSIZE, - ATTR_UNIT, - ATTR_VALUE, -) from homeassistant.components.number import ( - ATTR_MAX, - ATTR_MIN, ATTR_VALUE as SERVICE_ATTR_VALUE, + DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -26,8 +21,6 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import get_all_appliances - from tests.common import MockConfigEntry @@ -38,25 +31,24 @@ def platforms() -> list[str]: async def test_number( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test number entity.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance", ["Refrigerator"], indirect=True) +@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) @pytest.mark.parametrize( ( "entity_id", "setting_key", + "type", + "expected_state", "min_value", "max_value", "step_size", @@ -64,102 +56,132 @@ async def test_number( ), [ ( - f"{NUMBER_DOMAIN.lower()}.refrigerator_refrigerator_temperature", - "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, + "Double", + 8, 7, 15, 0.1, "°C", ), + ( + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, + "Double", + 8, + 7, + 15, + 5, + "°C", + ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_number_entity_functionality( - appliance: Mock, + appliance_ha_id: str, entity_id: str, - setting_key: str, - bypass_throttle: Generator[None], + setting_key: SettingKey, + type: str, + expected_state: int, min_value: int, max_value: int, step_size: float, unit_of_measurement: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test number entity functionality.""" - appliance.get.side_effect = [ - { - ATTR_CONSTRAINTS: { - ATTR_MIN: min_value, - ATTR_MAX: max_value, - ATTR_STEPSIZE: step_size, - }, - ATTR_UNIT: unit_of_measurement, - } - ] - get_appliances.return_value = [appliance] - current_value = min_value - appliance.status.update({setting_key: {ATTR_VALUE: current_value}}) + client.get_setting.side_effect = None + client.get_setting = AsyncMock( + return_value=GetSetting( + key=setting_key, + raw_key=setting_key.value, + value="", # This should not change the value + unit=unit_of_measurement, + type=type, + constraints=SettingConstraints( + min=min_value, + max=max_value, + step_size=step_size if isinstance(step_size, int) else None, + ), + ) + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - assert hass.states.is_state(entity_id, str(current_value)) - state = hass.states.get(entity_id) - assert state.attributes["min"] == min_value - assert state.attributes["max"] == max_value - assert state.attributes["step"] == step_size - assert state.attributes["unit_of_measurement"] == unit_of_measurement + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.state == str(expected_state) + attributes = entity_state.attributes + assert attributes["min"] == min_value + assert attributes["max"] == max_value + assert attributes["step"] == step_size + assert attributes["unit_of_measurement"] == unit_of_measurement - new_value = random.randint(min_value + 1, max_value) + value = random.choice( + [num for num in range(min_value, max_value + 1) if num != expected_state] + ) await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: entity_id, - SERVICE_ATTR_VALUE: new_value, + SERVICE_ATTR_VALUE: value, }, - blocking=True, ) - appliance.set_setting.assert_called_once_with(setting_key, new_value) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, setting_key=setting_key, value=value + ) + assert hass.states.is_state(entity_id, str(float(value))) -@pytest.mark.parametrize("problematic_appliance", ["Refrigerator"], indirect=True) @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ ( - f"{NUMBER_DOMAIN.lower()}.refrigerator_refrigerator_temperature", - "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, "set_setting", ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_number_entity_error( - problematic_appliance: Mock, entity_id: str, - setting_key: str, + setting_key: SettingKey, mock_attr: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test number entity error.""" - get_appliances.return_value = [problematic_appliance] - + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=DEFAULT_MIN_VALUE, + constraints=SettingConstraints( + min=int(DEFAULT_MIN_VALUE), + max=int(DEFAULT_MAX_VALUE), + step_size=1, + ), + ) + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - problematic_appliance.status.update({setting_key: {}}) - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() with pytest.raises( HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" @@ -173,4 +195,4 @@ async def test_number_entity_error( }, blocking=True, ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index af975979196..6ebd37266cd 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -1,39 +1,38 @@ """Tests for home_connect select entities.""" -from collections.abc import Awaitable, Callable, Generator -from unittest.mock import MagicMock, Mock +from collections.abc import Awaitable, Callable +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import ( + ArrayOfAvailablePrograms, + ArrayOfEvents, + Event, + EventKey, + EventMessage, + EventType, + ProgramKey, +) +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.program import EnumerateAvailableProgram import pytest -from homeassistant.components.home_connect.const import ( - BSH_ACTIVE_PROGRAM, - BSH_SELECTED_PROGRAM, -) from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_SELECT_OPTION, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .conftest import get_all_appliances - -from tests.common import MockConfigEntry, load_json_object_fixture - -SETTINGS_STATUS = { - setting.pop("key"): setting - for setting in load_json_object_fixture("home_connect/settings.json") - .get("Washer") - .get("data") - .get("settings") -} - -PROGRAM = "Dishcare.Dishwasher.Program.Eco50" +from tests.common import MockConfigEntry @pytest.fixture @@ -43,119 +42,148 @@ def platforms() -> list[str]: async def test_select( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test select entity.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED async def test_filter_unknown_programs( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, - appliance: Mock, + client: MagicMock, entity_registry: er.EntityRegistry, ) -> None: - """Test select that programs that are not part of the official Home Connect API specification are filtered out. - - We use two programs to ensure that programs are iterated over a copy of the list, - and it does not raise problems when removing an element from the original list. - """ - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [ - PROGRAM, - "NonOfficialProgram", - "AntotherNonOfficialProgram", - ] - get_appliances.return_value = [appliance] + """Test select that only known programs are shown.""" + client.get_available_programs.side_effect = None + client.get_available_programs.return_value = ArrayOfAvailablePrograms( + [ + EnumerateAvailableProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ), + EnumerateAvailableProgram( + key=ProgramKey.UNKNOWN, + raw_key="an unknown program", + ), + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - entity = entity_registry.async_get("select.washer_selected_program") + entity = entity_registry.async_get("select.dishwasher_selected_program") assert entity - assert entity.capabilities.get(ATTR_OPTIONS) == [ - "dishcare_dishwasher_program_eco_50" - ] + assert entity.capabilities + assert entity.capabilities[ATTR_OPTIONS] == ["dishcare_dishwasher_program_eco_50"] @pytest.mark.parametrize( - ("entity_id", "status", "program_to_set"), + ( + "appliance_ha_id", + "entity_id", + "mock_method", + "program_key", + "program_to_set", + "event_key", + ), [ ( - "select.washer_selected_program", - {BSH_SELECTED_PROGRAM: {"value": PROGRAM}}, + "Dishwasher", + "select.dishwasher_selected_program", + "set_selected_program", + ProgramKey.DISHCARE_DISHWASHER_ECO_50, "dishcare_dishwasher_program_eco_50", + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, ), ( - "select.washer_active_program", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "Dishwasher", + "select.dishwasher_active_program", + "start_program", + ProgramKey.DISHCARE_DISHWASHER_ECO_50, "dishcare_dishwasher_program_eco_50", + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, ), ], + indirect=["appliance_ha_id"], ) -async def test_select_functionality( +async def test_select_program_functionality( + appliance_ha_id: str, entity_id: str, - status: dict, + mock_method: str, + program_key: ProgramKey, program_to_set: str, - bypass_throttle: Generator[None], + event_key: EventKey, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test select functionality.""" - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [appliance] - assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - appliance.status.update(status) + assert hass.states.is_state(entity_id, "unknown") await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: program_to_set}, - blocking=True, + ) + await hass.async_block_till_done() + getattr(client, mock_method).assert_awaited_once_with( + appliance_ha_id, program_key=program_key ) assert hass.states.is_state(entity_id, program_to_set) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value="A not known program", + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, STATE_UNKNOWN) + @pytest.mark.parametrize( ( "entity_id", - "status", "program_to_set", "mock_attr", "exception_match", ), [ ( - "select.washer_selected_program", - {BSH_SELECTED_PROGRAM: {"value": PROGRAM}}, + "select.dishwasher_selected_program", "dishcare_dishwasher_program_eco_50", - "select_program", + "set_selected_program", r"Error.*select.*program.*", ), ( - "select.washer_active_program", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "select.dishwasher_active_program", "dishcare_dishwasher_program_eco_50", "start_program", r"Error.*start.*program.*", @@ -164,32 +192,36 @@ async def test_select_functionality( ) async def test_select_exception_handling( entity_id: str, - status: dict, program_to_set: str, mock_attr: str, exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - problematic_appliance: Mock, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - problematic_appliance.get_programs_available.side_effect = None - problematic_appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [problematic_appliance] + client_with_exception.get_available_programs.side_effect = None + client_with_exception.get_available_programs.return_value = ( + ArrayOfAvailablePrograms( + [ + EnumerateAvailableProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ) + ] + ) + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() - problematic_appliance.status.update(status) with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( SELECT_DOMAIN, @@ -197,4 +229,4 @@ async def test_select_exception_handling( {"entity_id": entity_id, "option": program_to_set}, blocking=True, ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index f2ee3b13922..ce06a841bbb 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -1,75 +1,77 @@ """Tests for home_connect sensor entities.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock +from aiohomeconnect.model import ( + ArrayOfEvents, + Event, + EventKey, + EventMessage, + EventType, + Status, + StatusKey, +) from freezegun.api import FrozenDateTimeFactory -from homeconnect.api import HomeConnectAPI import pytest from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE, BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, BSH_EVENT_PRESENT_STATE_CONFIRMED, BSH_EVENT_PRESENT_STATE_OFF, BSH_EVENT_PRESENT_STATE_PRESENT, - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_component import async_update_entity -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry TEST_HC_APP = "Dishwasher" EVENT_PROG_DELAYED_START = { - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.DelayedStart" - }, -} - -EVENT_PROG_REMAIN_NO_VALUE = { - "BSH.Common.Option.RemainingProgramTime": {}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.DelayedStart" + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.DelayedStart", }, } EVENT_PROG_RUN = { - "BSH.Common.Option.RemainingProgramTime": {"value": "0"}, - "BSH.Common.Option.ProgramProgress": {"value": "60"}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Run" + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run", + }, + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 0, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 60, }, } - EVENT_PROG_UPDATE_1 = { - "BSH.Common.Option.RemainingProgramTime": {"value": "0"}, - "BSH.Common.Option.ProgramProgress": {"value": "80"}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Run" + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 0, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 80, + }, + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run", }, } EVENT_PROG_UPDATE_2 = { - "BSH.Common.Option.RemainingProgramTime": {"value": "20"}, - "BSH.Common.Option.ProgramProgress": {"value": "99"}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Run" + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 20, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 99, + }, + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run", }, } EVENT_PROG_END = { - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Ready" + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Ready", }, } @@ -80,22 +82,19 @@ def platforms() -> list[str]: return [Platform.SENSOR] -@pytest.mark.usefixtures("bypass_throttle") async def test_sensors( config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, ) -> None: """Test sensor entities.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED -# Appliance program sequence with a delayed start. +# Appliance_ha_id program sequence with a delayed start. PROGRAM_SEQUENCE_EVENTS = ( EVENT_PROG_DELAYED_START, EVENT_PROG_RUN, @@ -130,7 +129,7 @@ ENTITY_ID_STATES = { } -@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) @pytest.mark.parametrize( ("states", "event_run"), list( @@ -141,17 +140,16 @@ ENTITY_ID_STATES = { ) ), ) -@pytest.mark.usefixtures("bypass_throttle") async def test_event_sensors( - appliance: Mock, + client: MagicMock, + appliance_ha_id: str, states: tuple, - event_run: dict, + event_run: dict[EventType, dict[EventKey, str | int]], freezer: FrozenDateTimeFactory, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, ) -> None: """Test sequence for sensors that are only available after an event happens.""" entity_ids = ENTITY_ID_STATES.keys() @@ -159,24 +157,48 @@ async def test_event_sensors( time_to_freeze = "2021-01-09 12:00:00+00:00" freezer.move_to(time_to_freeze) - get_appliances.return_value = [appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.get_programs_available = MagicMock(return_value=["dummy_program"]) - appliance.status.update(EVENT_PROG_DELAYED_START) - assert await integration_setup() + client.get_status.return_value.status.extend( + Status( + key=StatusKey(event_key.value), + raw_key=event_key.value, + value=value, + ) + for event_key, value in EVENT_PROG_DELAYED_START[EventType.STATUS].items() + ) + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(event_run) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + for event_type, events in event_run.items() + for event_key, value in events.items() + ] + ) + await hass.async_block_till_done() for entity_id, state in zip(entity_ids, states, strict=False): - await async_update_entity(hass, entity_id) - await hass.async_block_till_done() assert hass.states.is_state(entity_id, state) # Program sequence for SensorDeviceClass.TIMESTAMP edge cases. PROGRAM_SEQUENCE_EDGE_CASE = [ - EVENT_PROG_REMAIN_NO_VALUE, + EVENT_PROG_DELAYED_START, EVENT_PROG_RUN, EVENT_PROG_END, EVENT_PROG_END, @@ -191,60 +213,86 @@ ENTITY_ID_EDGE_CASE_STATES = [ ] -@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) -@pytest.mark.usefixtures("bypass_throttle") +@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) async def test_remaining_prog_time_edge_cases( - appliance: Mock, + appliance_ha_id: str, freezer: FrozenDateTimeFactory, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Run program sequence to test edge cases for the remaining_prog_time entity.""" - get_appliances.return_value = [appliance] entity_id = "sensor.dishwasher_program_finish_time" time_to_freeze = "2021-01-09 12:00:00+00:00" freezer.move_to(time_to_freeze) assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.get_programs_available = MagicMock(return_value=["dummy_program"]) - appliance.status.update(EVENT_PROG_REMAIN_NO_VALUE) - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED for ( event, expected_state, ) in zip(PROGRAM_SEQUENCE_EDGE_CASE, ENTITY_ID_EDGE_CASE_STATES, strict=False): - appliance.status.update(event) - await async_update_entity(hass, entity_id) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + for event_type, events in event.items() + for event_key, value in events.items() + ] + ) await hass.async_block_till_done() freezer.tick() assert hass.states.is_state(entity_id, expected_state) @pytest.mark.parametrize( - ("entity_id", "status_key", "event_value_update", "expected", "appliance"), + ( + "entity_id", + "event_key", + "event_type", + "event_value_update", + "expected", + "appliance_ha_id", + ), [ ( "sensor.dishwasher_door", - BSH_DOOR_STATE, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + EventType.STATUS, BSH_DOOR_STATE_LOCKED, "locked", "Dishwasher", ), ( "sensor.dishwasher_door", - BSH_DOOR_STATE, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + EventType.STATUS, BSH_DOOR_STATE_CLOSED, "closed", "Dishwasher", ), ( "sensor.dishwasher_door", - BSH_DOOR_STATE, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + EventType.STATUS, BSH_DOOR_STATE_OPEN, "open", "Dishwasher", @@ -252,33 +300,38 @@ async def test_remaining_prog_time_edge_cases( ( "sensor.fridgefreezer_freezer_door_alarm", "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", + EventType.EVENT, "", "off", "FridgeFreezer", ), ( "sensor.fridgefreezer_freezer_door_alarm", - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_OFF, "off", "FridgeFreezer", ), ( "sensor.fridgefreezer_freezer_door_alarm", - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_PRESENT, "present", "FridgeFreezer", ), ( "sensor.fridgefreezer_freezer_door_alarm", - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_CONFIRMED, "confirmed", "FridgeFreezer", ), ( "sensor.coffeemaker_bean_container_empty", + EventType.EVENT, "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", "", "off", @@ -286,52 +339,68 @@ async def test_remaining_prog_time_edge_cases( ), ( "sensor.coffeemaker_bean_container_empty", - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_OFF, "off", "CoffeeMaker", ), ( "sensor.coffeemaker_bean_container_empty", - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_PRESENT, "present", "CoffeeMaker", ), ( "sensor.coffeemaker_bean_container_empty", - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_CONFIRMED, "confirmed", "CoffeeMaker", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_sensors_states( entity_id: str, - status_key: str, + event_key: EventKey, + event_type: EventType, event_value_update: str, - appliance: Mock, + appliance_ha_id: str, expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: - """Tests for Appliance alarm sensors.""" - appliance.status.update( - HomeConnectAPI.json2dict( - load_json_object_fixture("home_connect/status.json")["data"]["status"] - ) - ) - get_appliances.return_value = [appliance] + """Tests for Appliance_ha_id alarm sensors.""" assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update({status_key: {"value": event_value_update}}) - await async_update_entity(hass, entity_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=str(event_key), + timestamp=0, + level="", + handling="", + value=event_value_update, + ) + ], + ), + ), + ] + ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 80bfcf9db96..10d393423be 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -1,24 +1,34 @@ """Tests for home_connect sensor entities.""" -from collections.abc import Awaitable, Callable, Generator -from unittest.mock import MagicMock, Mock +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.model import ( + ArrayOfSettings, + Event, + EventKey, + EventMessage, + GetSetting, + ProgramKey, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.event import ArrayOfEvents, EventType +from aiohomeconnect.model.program import ( + ArrayOfAvailablePrograms, + EnumerateAvailableProgram, +) +from aiohomeconnect.model.setting import SettingConstraints import pytest from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( - ATTR_ALLOWED_VALUES, - ATTR_CONSTRAINTS, - BSH_ACTIVE_PROGRAM, - BSH_CHILD_LOCK_STATE, BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, - BSH_POWER_STATE, DOMAIN, - REFRIGERATION_SUPERMODEFREEZER, ) from homeassistant.components.script import scripts_with_entity from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -36,19 +46,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from .conftest import get_all_appliances - -from tests.common import MockConfigEntry, load_json_object_fixture - -SETTINGS_STATUS = { - setting.pop("key"): setting - for setting in load_json_object_fixture("home_connect/settings.json") - .get("Dishwasher") - .get("data") - .get("settings") -} - -PROGRAM = "LaundryCare.Dryer.Program.Mix" +from tests.common import MockConfigEntry @pytest.fixture @@ -58,231 +56,285 @@ def platforms() -> list[str]: async def test_switches( - bypass_throttle: Generator[None], hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test switch entities.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED -@pytest.mark.parametrize( - ("entity_id", "status", "service", "state", "appliance"), - [ - ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, - SERVICE_TURN_ON, - STATE_ON, - "Dishwasher", - ), - ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": ""}}, - SERVICE_TURN_OFF, - STATE_OFF, - "Dishwasher", - ), - ( - "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": True}}, - SERVICE_TURN_ON, - STATE_ON, - "Dishwasher", - ), - ( - "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": False}}, - SERVICE_TURN_OFF, - STATE_OFF, - "Dishwasher", - ), - ], - indirect=["appliance"], -) -async def test_switch_functionality( - entity_id: str, - status: dict, - service: str, - state: str, - bypass_throttle: Generator[None], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, -) -> None: - """Test switch functionality.""" - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [appliance] - - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - - appliance.status.update(status) - await hass.services.async_call( - SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True - ) - assert hass.states.is_state(entity_id, state) - - @pytest.mark.parametrize( ( "entity_id", - "status", + "service", + "settings_key_arg", + "setting_value_arg", + "state", + "appliance_ha_id", + ), + [ + ( + "switch.dishwasher_child_lock", + SERVICE_TURN_ON, + SettingKey.BSH_COMMON_CHILD_LOCK, + True, + STATE_ON, + "Dishwasher", + ), + ( + "switch.dishwasher_child_lock", + SERVICE_TURN_OFF, + SettingKey.BSH_COMMON_CHILD_LOCK, + False, + STATE_OFF, + "Dishwasher", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_switch_functionality( + entity_id: str, + settings_key_arg: SettingKey, + setting_value_arg: Any, + service: str, + state: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + appliance_ha_id: str, + client: MagicMock, +) -> None: + """Test switch functionality.""" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, setting_key=settings_key_arg, value=setting_value_arg + ) + assert hass.states.is_state(entity_id, state) + + +@pytest.mark.parametrize( + ("entity_id", "program_key", "appliance_ha_id"), + [ + ( + "switch.dryer_program_mix", + ProgramKey.LAUNDRY_CARE_DRYER_MIX, + "Dryer", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_program_switch_functionality( + entity_id: str, + program_key: ProgramKey, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + appliance_ha_id: str, + client: MagicMock, +) -> None: + """Test switch functionality.""" + + async def mock_stop_program(ha_id: str) -> None: + """Mock stop program.""" + await client.add_events( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.UNKNOWN, + ) + ] + ), + ), + ] + ) + + client.stop_program = AsyncMock(side_effect=mock_stop_program) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, STATE_ON) + client.start_program.assert_awaited_once_with( + appliance_ha_id, program_key=program_key + ) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, STATE_OFF) + client.stop_program.assert_awaited_once_with(appliance_ha_id) + + +@pytest.mark.parametrize( + ( + "entity_id", "service", "mock_attr", - "problematic_appliance", "exception_match", ), [ ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "switch.dishwasher_program_eco50", SERVICE_TURN_ON, "start_program", - "Dishwasher", r"Error.*start.*program.*", ), ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "switch.dishwasher_program_eco50", SERVICE_TURN_OFF, "stop_program", - "Dishwasher", r"Error.*stop.*program.*", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_OFF}}, SERVICE_TURN_OFF, "set_setting", - "Dishwasher", r"Error.*turn.*off.*", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": ""}}, SERVICE_TURN_ON, "set_setting", - "Dishwasher", r"Error.*turn.*on.*", ), ( "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": ""}}, SERVICE_TURN_ON, "set_setting", - "Dishwasher", r"Error.*turn.*on.*", ), ( "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": ""}}, SERVICE_TURN_OFF, "set_setting", - "Dishwasher", r"Error.*turn.*off.*", ), ], - indirect=["problematic_appliance"], ) async def test_switch_exception_handling( entity_id: str, - status: dict, service: str, mock_attr: str, exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - problematic_appliance: Mock, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - problematic_appliance.get_programs_available.side_effect = None - problematic_appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [problematic_appliance] + client_with_exception.get_available_programs.side_effect = None + client_with_exception.get_available_programs.return_value = ( + ArrayOfAvailablePrograms( + [ + EnumerateAvailableProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ) + ] + ) + ) + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_CHILD_LOCK, + raw_key=SettingKey.BSH_COMMON_CHILD_LOCK.value, + value=False, + ), + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=BSH_POWER_ON, + constraints=SettingConstraints( + allowed_values=[BSH_POWER_ON, BSH_POWER_OFF] + ), + ), + ] + ) assert config_entry.state == ConfigEntryState.NOT_LOADED - problematic_appliance.status.update(status) - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( - SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True + SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 @pytest.mark.parametrize( - ("entity_id", "status", "service", "state", "appliance"), + ("entity_id", "status", "service", "state", "appliance_ha_id"), [ ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": True}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: True}, SERVICE_TURN_ON, STATE_ON, "FridgeFreezer", ), ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": False}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: False}, SERVICE_TURN_OFF, STATE_OFF, "FridgeFreezer", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) async def test_ent_desc_switch_functionality( entity_id: str, status: dict, service: str, state: str, - bypass_throttle: Generator[None], hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + appliance_ha_id: str, + client: MagicMock, ) -> None: """Test switch functionality - entity description setup.""" - appliance.status.update( - HomeConnectAppliance.json2dict( - load_json_object_fixture("home_connect/settings.json") - .get(appliance.name) - .get("data") - .get("settings") - ) - ) - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(status) - await hass.services.async_call( - SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True - ) + await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) + await hass.async_block_till_done() assert hass.states.is_state(entity_id, state) @@ -292,13 +344,13 @@ async def test_ent_desc_switch_functionality( "status", "service", "mock_attr", - "problematic_appliance", + "appliance_ha_id", "exception_match", ), [ ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": ""}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""}, SERVICE_TURN_ON, "set_setting", "FridgeFreezer", @@ -306,203 +358,257 @@ async def test_ent_desc_switch_functionality( ), ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": ""}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""}, SERVICE_TURN_OFF, "set_setting", "FridgeFreezer", r"Error.*turn.*off.*", ), ], - indirect=["problematic_appliance"], + indirect=["appliance_ha_id"], ) async def test_ent_desc_switch_exception_handling( entity_id: str, - status: dict, + status: dict[SettingKey, str], service: str, mock_attr: str, exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - problematic_appliance: Mock, - get_appliances: MagicMock, + appliance_ha_id: str, + client_with_exception: MagicMock, ) -> None: """Test switch exception handling - entity description setup.""" - problematic_appliance.status.update( - HomeConnectAppliance.json2dict( - load_json_object_fixture("home_connect/settings.json") - .get(problematic_appliance.name) - .get("data") - .get("settings") - ) + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=key, + raw_key=key.value, + value=value, + ) + for key, value in status.items() + ] ) - get_appliances.return_value = [problematic_appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() - - problematic_appliance.status.update(status) + await client_with_exception.set_setting() with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert client_with_exception.set_setting.call_count == 2 @pytest.mark.parametrize( - ("entity_id", "status", "allowed_values", "service", "power_state", "appliance"), + ( + "entity_id", + "allowed_values", + "service", + "setting_value_arg", + "power_state", + "appliance_ha_id", + ), [ ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_ON}}, [BSH_POWER_ON, BSH_POWER_OFF], SERVICE_TURN_ON, + BSH_POWER_ON, STATE_ON, "Dishwasher", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_OFF}}, [BSH_POWER_ON, BSH_POWER_OFF], SERVICE_TURN_OFF, + BSH_POWER_OFF, STATE_OFF, "Dishwasher", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_ON}}, [BSH_POWER_ON, BSH_POWER_STANDBY], SERVICE_TURN_ON, + BSH_POWER_ON, STATE_ON, "Dishwasher", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_STANDBY}}, [BSH_POWER_ON, BSH_POWER_STANDBY], SERVICE_TURN_OFF, + BSH_POWER_STANDBY, STATE_OFF, "Dishwasher", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_power_swtich( entity_id: str, - status: dict, - allowed_values: list[str], + allowed_values: list[str | None] | None, service: str, + setting_value_arg: str, power_state: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + appliance_ha_id: str, + client: MagicMock, ) -> None: """Test power switch functionality.""" - appliance.get.side_effect = [ - { - ATTR_CONSTRAINTS: { - ATTR_ALLOWED_VALUES: allowed_values, - }, - } - ] - appliance.status.update(SETTINGS_STATUS) - appliance.status.update(status) - get_appliances.return_value = [appliance] + client.get_settings.side_effect = None + client.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value="", + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ) + ] + ) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - await hass.services.async_call( - SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True + await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + value=setting_value_arg, ) assert hass.states.is_state(entity_id, power_state) @pytest.mark.parametrize( - ("entity_id", "allowed_values", "service", "appliance", "exception_match"), + ("initial_value"), + [ + (BSH_POWER_OFF), + (BSH_POWER_STANDBY), + ], +) +async def test_power_switch_fetch_off_state_from_current_value( + initial_value: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test power switch functionality to fetch the off state from the current value.""" + client.get_settings.side_effect = None + client.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=initial_value, + ) + ] + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) + + +@pytest.mark.parametrize( + ("entity_id", "allowed_values", "service", "exception_match"), [ ( "switch.dishwasher_power", [BSH_POWER_ON], SERVICE_TURN_OFF, - "Dishwasher", r".*not support.*turn.*off.*", ), ( "switch.dishwasher_power", None, SERVICE_TURN_OFF, - "Dishwasher", + r".*Unable.*turn.*off.*support.*not.*determined.*", + ), + ( + "switch.dishwasher_power", + HomeConnectError(), + SERVICE_TURN_OFF, r".*Unable.*turn.*off.*support.*not.*determined.*", ), ], - indirect=["appliance"], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_power_switch_service_validation_errors( entity_id: str, - allowed_values: list[str], + allowed_values: list[str | None] | None | HomeConnectError, service: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, exception_match: str, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test power switch functionality validation errors.""" - if allowed_values: - appliance.get.side_effect = [ - { - ATTR_CONSTRAINTS: { - ATTR_ALLOWED_VALUES: allowed_values, - }, - } - ] - appliance.status.update(SETTINGS_STATUS) - get_appliances.return_value = [appliance] + client.get_settings.side_effect = None + if isinstance(allowed_values, HomeConnectError): + exception = allowed_values + client.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=BSH_POWER_ON, + ) + ] + ) + client.get_setting = AsyncMock(side_effect=exception) + else: + setting = GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=BSH_POWER_ON, + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ) + client.get_settings.return_value = ArrayOfSettings([setting]) + client.get_setting = AsyncMock(return_value=setting) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update({BSH_POWER_STATE: {"value": BSH_POWER_ON}}) - with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( - SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True + SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.usefixtures("bypass_throttle") async def test_create_issue( hass: HomeAssistant, - appliance: Mock, + appliance_ha_id: str, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" entity_id = "switch.washer_program_mix" - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [appliance] issue_id = f"deprecated_program_switch_{entity_id}" assert await async_setup_component( @@ -539,7 +645,7 @@ async def test_create_issue( ) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert automations_with_entity(hass, entity_id)[0] == "automation.test" diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index 1401e07b05a..95f9ddeba80 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -1,21 +1,19 @@ """Tests for home_connect time entities.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable from datetime import time -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import ArrayOfSettings, GetSetting, SettingKey +from aiohomeconnect.model.error import HomeConnectError import pytest -from homeassistant.components.home_connect.const import ATTR_VALUE from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import get_all_appliances - from tests.common import MockConfigEntry @@ -26,114 +24,98 @@ def platforms() -> list[str]: async def test_time( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test time entity.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) @pytest.mark.parametrize( - ("entity_id", "setting_key", "setting_value", "expected_state"), + ("entity_id", "setting_key"), [ ( f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", - {ATTR_VALUE: 59}, - str(time(second=59)), - ), - ( - f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", - {ATTR_VALUE: None}, - "unknown", - ), - ( - f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", - None, - "unknown", + SettingKey.BSH_COMMON_ALARM_CLOCK, ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_time_entity_functionality( - appliance: Mock, + appliance_ha_id: str, entity_id: str, - setting_key: str, - setting_value: dict, - expected_state: str, - bypass_throttle: Generator[None], + setting_key: SettingKey, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test time entity functionality.""" - get_appliances.return_value = [appliance] - appliance.status.update({setting_key: setting_value}) - assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - assert hass.states.is_state(entity_id, expected_state) - new_value = 30 - assert hass.states.get(entity_id).state != new_value + value = 30 + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state != value await hass.services.async_call( TIME_DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: entity_id, - ATTR_TIME: time(second=new_value), + ATTR_TIME: time(second=value), }, - blocking=True, ) - appliance.set_setting.assert_called_once_with(setting_key, new_value) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, setting_key=setting_key, value=value + ) + assert hass.states.is_state(entity_id, str(time(second=value))) -@pytest.mark.parametrize("problematic_appliance", ["Oven"], indirect=True) @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ ( f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", + SettingKey.BSH_COMMON_ALARM_CLOCK, "set_setting", ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_time_entity_error( - problematic_appliance: Mock, entity_id: str, - setting_key: str, + setting_key: SettingKey, mock_attr: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test time entity error.""" - get_appliances.return_value = [problematic_appliance] - + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=30, + ) + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - problematic_appliance.status.update({setting_key: {}}) - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() with pytest.raises( HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" @@ -147,4 +129,4 @@ async def test_time_entity_error( }, blocking=True, ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 From 427c437a68654d149a99ead7dbf94c74f6e35773 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Jan 2025 21:32:10 -0500 Subject: [PATCH 1231/2987] Add start_conversation service to Assist Satellite (#134921) * Add start_conversation service to Assist Satellite * Fix tests * Implement start_conversation in voip * Update homeassistant/components/assist_satellite/entity.py --------- Co-authored-by: Michael Hansen --- .../components/assist_pipeline/pipeline.py | 1 + .../components/assist_satellite/__init__.py | 15 ++ .../components/assist_satellite/const.py | 3 + .../components/assist_satellite/entity.py | 59 +++++++- .../components/assist_satellite/icons.json | 3 + .../components/assist_satellite/services.yaml | 20 +++ .../components/assist_satellite/strings.json | 18 +++ .../components/voip/assist_satellite.py | 38 ++++-- homeassistant/helpers/service.py | 2 + tests/components/assist_satellite/conftest.py | 15 +- .../assist_satellite/test_entity.py | 128 ++++++++++++++++-- tests/components/voip/test_voip.py | 107 ++++++++++++++- 12 files changed, 384 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 9fdcc2bf690..1d320d79bf2 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1122,6 +1122,7 @@ class PipelineRun: context=user_input.context, language=user_input.language, agent_id=user_input.agent_id, + extra_system_prompt=user_input.extra_system_prompt, ) speech = conversation_result.response.speech.get("plain", {}).get( "speech", "" diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 47b0123a244..038ff517264 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -63,6 +63,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_internal_announce", [AssistSatelliteEntityFeature.ANNOUNCE], ) + component.async_register_entity_service( + "start_conversation", + vol.All( + cv.make_entity_service_schema( + { + vol.Optional("start_message"): str, + vol.Optional("start_media_id"): str, + vol.Optional("extra_system_prompt"): str, + } + ), + cv.has_at_least_one_key("start_message", "start_media_id"), + ), + "async_internal_start_conversation", + [AssistSatelliteEntityFeature.START_CONVERSATION], + ) hass.data[CONNECTION_TEST_DATA] = {} async_register_websocket_api(hass) hass.http.register_view(ConnectionTestView()) diff --git a/homeassistant/components/assist_satellite/const.py b/homeassistant/components/assist_satellite/const.py index 61ac7ecb39d..f7ac7e524b4 100644 --- a/homeassistant/components/assist_satellite/const.py +++ b/homeassistant/components/assist_satellite/const.py @@ -26,3 +26,6 @@ class AssistSatelliteEntityFeature(IntFlag): ANNOUNCE = 1 """Device supports remotely triggered announcements.""" + + START_CONVERSATION = 2 + """Device supports starting conversations.""" diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index e9a5d22c0d0..927229c9756 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -10,7 +10,7 @@ import logging import time from typing import Any, Final, Literal, final -from homeassistant.components import media_source, stt, tts +from homeassistant.components import conversation, media_source, stt, tts from homeassistant.components.assist_pipeline import ( OPTION_PREFERRED, AudioSettings, @@ -27,6 +27,7 @@ from homeassistant.components.tts import ( generate_media_source_id as tts_generate_media_source_id, ) from homeassistant.core import Context, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity from homeassistant.helpers.entity import EntityDescription @@ -117,6 +118,7 @@ class AssistSatelliteEntity(entity.Entity): _run_has_tts: bool = False _is_announcing = False + _extra_system_prompt: str | None = None _wake_word_intercept_future: asyncio.Future[str | None] | None = None _attr_tts_options: dict[str, Any] | None = None _pipeline_task: asyncio.Task | None = None @@ -216,6 +218,60 @@ class AssistSatelliteEntity(entity.Entity): """ raise NotImplementedError + async def async_internal_start_conversation( + self, + start_message: str | None = None, + start_media_id: str | None = None, + extra_system_prompt: str | None = None, + ) -> None: + """Start a conversation from the satellite. + + If start_media_id is not provided, message is synthesized to + audio with the selected pipeline. + + If start_media_id is provided, it is played directly. It is possible + to omit the message and the satellite will not show any text. + + Calls async_start_conversation. + """ + await self._cancel_running_pipeline() + + # The Home Assistant built-in agent doesn't support conversations. + pipeline = async_get_pipeline(self.hass, self._resolve_pipeline()) + if pipeline.conversation_engine == conversation.HOME_ASSISTANT_AGENT: + raise HomeAssistantError( + "Built-in conversation agent does not support starting conversations" + ) + + if start_message is None: + start_message = "" + + announcement = await self._resolve_announcement_media_id( + start_message, start_media_id + ) + + if self._is_announcing: + raise SatelliteBusyError + + self._is_announcing = True + # Provide our start info to the LLM so it understands context of incoming message + if extra_system_prompt is not None: + self._extra_system_prompt = extra_system_prompt + else: + self._extra_system_prompt = start_message or None + + try: + await self.async_start_conversation(announcement) + finally: + self._is_announcing = False + self._extra_system_prompt = None + + async def async_start_conversation( + self, start_announcement: AssistSatelliteAnnouncement + ) -> None: + """Start a conversation from the satellite.""" + raise NotImplementedError + async def async_accept_pipeline_from_satellite( self, audio_stream: AsyncIterable[bytes], @@ -302,6 +358,7 @@ class AssistSatelliteEntity(entity.Entity): ), start_stage=start_stage, end_stage=end_stage, + conversation_extra_system_prompt=self._extra_system_prompt, ), f"{self.entity_id}_pipeline", ) diff --git a/homeassistant/components/assist_satellite/icons.json b/homeassistant/components/assist_satellite/icons.json index a98c3aefc5b..1ed29541621 100644 --- a/homeassistant/components/assist_satellite/icons.json +++ b/homeassistant/components/assist_satellite/icons.json @@ -7,6 +7,9 @@ "services": { "announce": { "service": "mdi:bullhorn" + }, + "start_conversation": { + "service": "mdi:forum" } } } diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index e7fefc4705f..89a20ada6f3 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -14,3 +14,23 @@ announce: required: false selector: text: +start_conversation: + target: + entity: + domain: assist_satellite + supported_features: + - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + fields: + start_message: + required: false + example: "You left the lights on in the living room. Turn them off?" + selector: + text: + start_media_id: + required: false + selector: + text: + extra_system_prompt: + required: false + selector: + text: diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index 7f1426ef529..e83f4666b5d 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -25,6 +25,24 @@ "description": "The media ID to announce instead of using text-to-speech." } } + }, + "start_conversation": { + "name": "Start Conversation", + "description": "Start a conversation from a satellite.", + "fields": { + "start_message": { + "name": "Message", + "description": "The message to start with." + }, + "start_media_id": { + "name": "Media ID", + "description": "The media ID to start with instead of using text-to-speech." + }, + "extra_system_prompt": { + "name": "Extra system prompt", + "description": "Provide background information to the AI about the request." + } + } } } } diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 6cacdd79af4..1877b8c655c 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -90,7 +90,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol entity_description = AssistSatelliteEntityDescription(key="assist_satellite") _attr_translation_key = "assist_satellite" _attr_name = None - _attr_supported_features = AssistSatelliteEntityFeature.ANNOUNCE + _attr_supported_features = ( + AssistSatelliteEntityFeature.ANNOUNCE + | AssistSatelliteEntityFeature.START_CONVERSATION + ) def __init__( self, @@ -122,6 +125,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._check_announcement_ended_task: asyncio.Task | None = None self._last_chunk_time: float | None = None self._rtp_port: int | None = None + self._run_pipeline_after_announce: bool = False @property def pipeline_entity_id(self) -> str | None: @@ -172,7 +176,17 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol Plays announcement in a loop, blocking until the caller hangs up. """ + await self._do_announce(announcement, run_pipeline_after=False) + + async def _do_announce( + self, announcement: AssistSatelliteAnnouncement, run_pipeline_after: bool + ) -> None: + """Announce media on the satellite. + + Optionally run a voice pipeline after the announcement has finished. + """ self._announcement_future = asyncio.Future() + self._run_pipeline_after_announce = run_pipeline_after if self._rtp_port is None: # Choose random port for RTP @@ -232,12 +246,6 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol """ while self._announcement is not None: current_time = time.monotonic() - _LOGGER.debug( - "%s %s %s", - self._last_chunk_time, - current_time, - self._announcment_start_time, - ) if (self._last_chunk_time is None) and ( (current_time - self._announcment_start_time) > _ANNOUNCEMENT_RING_TIMEOUT @@ -263,6 +271,12 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol await asyncio.sleep(_ANNOUNCEMENT_HANGUP_SEC / 2) + async def async_start_conversation( + self, start_announcement: AssistSatelliteAnnouncement + ) -> None: + """Start a conversation from the satellite.""" + await self._do_announce(start_announcement, run_pipeline_after=True) + # ------------------------------------------------------------------------- # VoIP # ------------------------------------------------------------------------- @@ -347,7 +361,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol try: await asyncio.sleep(_ANNOUNCEMENT_BEFORE_DELAY) await self._send_tts(announcement.original_media_id, wait_for_tone=False) - await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY) + + if not self._run_pipeline_after_announce: + # Delay before looping announcement + await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY) except Exception: _LOGGER.exception("Unexpected error while playing announcement") raise @@ -355,6 +372,11 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._run_pipeline_task = None _LOGGER.debug("Announcement finished") + if self._run_pipeline_after_announce: + # Clear announcement to allow pipeline to run + self._announcement = None + self._announcement_future.set_result(None) + def _clear_audio_queue(self) -> None: """Ensure audio queue is empty.""" while not self._audio_queue.empty(): diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 255739c0059..4873d935537 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -88,6 +88,7 @@ def _base_components() -> dict[str, ModuleType]: # pylint: disable-next=import-outside-toplevel from homeassistant.components import ( alarm_control_panel, + assist_satellite, calendar, camera, climate, @@ -108,6 +109,7 @@ def _base_components() -> dict[str, ModuleType]: return { "alarm_control_panel": alarm_control_panel, + "assist_satellite": assist_satellite, "calendar": calendar, "camera": camera, "climate": climate, diff --git a/tests/components/assist_satellite/conftest.py b/tests/components/assist_satellite/conftest.py index d75cbd072e0..0cc0e94e149 100644 --- a/tests/components/assist_satellite/conftest.py +++ b/tests/components/assist_satellite/conftest.py @@ -40,6 +40,8 @@ def mock_tts(mock_tts_cache_dir: pathlib.Path) -> None: class MockAssistSatellite(AssistSatelliteEntity): """Mock Assist Satellite Entity.""" + _attr_tts_options = {"test-option": "test-value"} + def __init__(self, name: str, features: AssistSatelliteEntityFeature) -> None: """Initialize the mock entity.""" self._attr_unique_id = ulid_hex() @@ -67,6 +69,7 @@ class MockAssistSatellite(AssistSatelliteEntity): active_wake_words=["1234"], max_active_wake_words=1, ) + self.start_conversations = [] def on_pipeline_event(self, event: PipelineEvent) -> None: """Handle pipeline events.""" @@ -87,11 +90,21 @@ class MockAssistSatellite(AssistSatelliteEntity): """Set the current satellite configuration.""" self.config = config + async def async_start_conversation( + self, start_announcement: AssistSatelliteConfiguration + ) -> None: + """Start a conversation from the satellite.""" + self.start_conversations.append((self._extra_system_prompt, start_announcement)) + @pytest.fixture def entity() -> MockAssistSatellite: """Mock Assist Satellite Entity.""" - return MockAssistSatellite("Test Entity", AssistSatelliteEntityFeature.ANNOUNCE) + return MockAssistSatellite( + "Test Entity", + AssistSatelliteEntityFeature.ANNOUNCE + | AssistSatelliteEntityFeature.START_CONVERSATION, + ) @pytest.fixture diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index c3464beac97..46facb80844 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -25,11 +25,24 @@ from homeassistant.components.assist_satellite.entity import AssistSatelliteStat from homeassistant.components.media_source import PlayMedia from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from . import ENTITY_ID from .conftest import MockAssistSatellite +@pytest.fixture(autouse=True) +async def set_pipeline_tts(hass: HomeAssistant, init_components: ConfigEntry) -> None: + """Set up a pipeline with a TTS engine.""" + await async_update_pipeline( + hass, + async_get_pipeline(hass), + tts_engine="tts.mock_entity", + tts_language="en", + tts_voice="test-voice", + ) + + async def test_entity_state( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: @@ -64,7 +77,7 @@ async def test_entity_state( assert kwargs["stt_stream"] is audio_stream assert kwargs["pipeline_id"] is None assert kwargs["device_id"] is entity.device_entry.id - assert kwargs["tts_audio_output"] is None + assert kwargs["tts_audio_output"] == {"test-option": "test-value"} assert kwargs["wake_word_phrase"] is None assert kwargs["audio_settings"] == AudioSettings( silence_seconds=vad.VadSensitivity.to_seconds(vad.VadSensitivity.DEFAULT) @@ -200,24 +213,12 @@ async def test_announce( expected_params: tuple[str, str], ) -> None: """Test announcing on a device.""" - await async_update_pipeline( - hass, - async_get_pipeline(hass), - tts_engine="tts.mock_entity", - tts_language="en", - tts_voice="test-voice", - ) - - entity._attr_tts_options = {"test-option": "test-value"} - original_announce = entity.async_announce - announce_started = asyncio.Event() async def async_announce(announcement): # Verify state change assert entity.state == AssistSatelliteState.RESPONDING await original_announce(announcement) - announce_started.set() def tts_generate_media_source_id( hass: HomeAssistant, @@ -475,3 +476,104 @@ async def test_vad_sensitivity_entity_not_found( with pytest.raises(RuntimeError): await entity.async_accept_pipeline_from_satellite(audio_stream) + + +@pytest.mark.parametrize( + ("service_data", "expected_params"), + [ + ( + { + "start_message": "Hello", + "extra_system_prompt": "Better system prompt", + }, + ( + "Better system prompt", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://generated", + media_id_source="tts", + ), + ), + ), + ( + { + "start_message": "Hello", + "start_media_id": "media-source://given", + }, + ( + "Hello", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://given", + media_id_source="media_id", + ), + ), + ), + ( + {"start_media_id": "http://example.com/given.mp3"}, + ( + None, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/given.mp3", + original_media_id="http://example.com/given.mp3", + media_id_source="url", + ), + ), + ), + ], +) +async def test_start_conversation( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + service_data: dict, + expected_params: tuple[str, str], +) -> None: + """Test starting a conversation on a device.""" + await async_update_pipeline( + hass, + async_get_pipeline(hass), + conversation_engine="conversation.some_llm", + ) + + with ( + patch( + "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + return_value="media-source://generated", + ), + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=PlayMedia( + url="https://www.home-assistant.io/resolved.mp3", + mime_type="audio/mp3", + ), + ), + ): + await hass.services.async_call( + "assist_satellite", + "start_conversation", + service_data, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) + + assert entity.start_conversations[0] == expected_params + + +async def test_start_conversation_reject_builtin_agent( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, +) -> None: + """Test starting a conversation on a device.""" + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "assist_satellite", + "start_conversation", + {"start_message": "Hey!"}, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 306857a1a44..442f4a62392 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -887,7 +887,8 @@ async def test_announce( # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - await announce_task + async with asyncio.timeout(1): + await announce_task mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) @@ -938,7 +939,8 @@ async def test_voip_id_is_ip_address( # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - await announce_task + async with asyncio.timeout(1): + await announce_task mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) @@ -981,3 +983,104 @@ async def test_announce_timeout( satellite.transport = Mock() with pytest.raises(TimeoutError): await satellite.async_announce(announcement) + + +@pytest.mark.usefixtures("socket_enabled") +async def test_start_conversation( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test start conversation.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but "outgoing_call" is not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + + tts_sent = asyncio.Event() + + async def _send_tts(*args, **kwargs): + tts_sent.set() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context: Context, + *args, + device_id: str | None, + tts_audio_output: str | dict[str, Any] | None, + **kwargs, + ): + event_callback = kwargs["event_callback"] + + # Fake tts result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_START, + data={ + "engine": "test", + "language": hass.config.language, + "voice": "test", + "tts_input": "fake-text", + }, + ) + ) + + # Proceed with media output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {"media_id": _MEDIA_ID}}, + ) + ) + + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.RUN_END + ) + ) + + with ( + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", + new=_send_tts, + ), + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + ): + satellite.transport = Mock() + conversation_task = hass.async_create_background_task( + satellite.async_start_conversation(announcement), "voip_start_conversation" + ) + await asyncio.sleep(0) + mock_protocol.outgoing_call.assert_called_once() + + # Trigger announcement and wait for it to finish + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + await tts_sent.wait() + + tts_sent.clear() + + # Trigger pipeline + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + # Wait for TTS + await tts_sent.wait() + await conversation_task From 64b056fbe998cf7231906c26f4daab02bb4124a5 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 30 Jan 2025 03:57:36 +0100 Subject: [PATCH 1232/2987] Bump ZHA to 0.0.47 (#136883) --- 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 fa8bab409c9..6a42bc986e9 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.46"], + "requirements": ["zha==0.0.47"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 731b1cdeb67..16079cca64d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3131,7 +3131,7 @@ zeroconf==0.141.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.46 +zha==0.0.47 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index db89f8db9d0..a5bd58dff58 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2520,7 +2520,7 @@ zeroconf==0.141.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.46 +zha==0.0.47 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 From a8175b785f1445319cec7edae411a78272d51707 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Jan 2025 08:42:23 +0100 Subject: [PATCH 1233/2987] Bump github/codeql-action from 3.28.6 to 3.28.8 (#136890) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.28.6 to 3.28.8. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3.28.6...v3.28.8) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d7f46b176cd..c1272759acc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.6 + uses: github/codeql-action/init@v3.28.8 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.6 + uses: github/codeql-action/analyze@v3.28.8 with: category: "/language:python" From 97fcbed6e08e3a37eb8d852f695a9d5bdfca514d Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 30 Jan 2025 10:07:10 +0100 Subject: [PATCH 1234/2987] Add error handling to enphase_envoy switch platform action (#136837) * Add error handling to enphase_envoy switch platform action * Use decorators for exception handling --- .../components/enphase_envoy/entity.py | 37 ++++- .../components/enphase_envoy/strings.json | 3 + .../components/enphase_envoy/switch.py | 8 +- tests/components/enphase_envoy/test_switch.py | 137 ++++++++++++++++++ 4 files changed, 183 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/enphase_envoy/entity.py b/homeassistant/components/enphase_envoy/entity.py index 491951625ee..04987d861d2 100644 --- a/homeassistant/components/enphase_envoy/entity.py +++ b/homeassistant/components/enphase_envoy/entity.py @@ -2,13 +2,22 @@ from __future__ import annotations -from pyenphase import EnvoyData +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate +from httpx import HTTPError +from pyenphase import EnvoyData +from pyenphase.exceptions import EnvoyError + +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN from .coordinator import EnphaseUpdateCoordinator +ACTIONERRORS = (EnvoyError, HTTPError) + class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]): """Defines a base envoy entity.""" @@ -33,3 +42,29 @@ class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]): data = self.coordinator.envoy.data assert data is not None return data + + +def exception_handler[_EntityT: EnvoyBaseEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate Enphase Envoy calls to handle exceptions. + + A decorator that wraps the passed in function, catches enphase_envoy errors. + """ + + async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + except ACTIONERRORS as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="action_error", + translation_placeholders={ + "host": self.coordinator.envoy.host, + "args": error.args[0], + "action": func.__name__, + "entity": self.entity_id, + }, + ) from error + + return handler diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 589dc52f71d..e99c45c5c7a 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -400,6 +400,9 @@ }, "envoy_error": { "message": "Error communicating with Envoy API on {host}: {args}" + }, + "action_error": { + "message": "Failed to execute {action} for {entity}, host: {host}: {args}" } } } diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index 7074f341cc8..8a3ca493562 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator -from .entity import EnvoyBaseEntity +from .entity import EnvoyBaseEntity, exception_handler PARALLEL_UPDATES = 1 @@ -147,11 +147,13 @@ class EnvoyEnpowerSwitchEntity(EnvoyBaseEntity, SwitchEntity): assert enpower is not None return self.entity_description.value_fn(enpower) + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Enpower switch.""" await self.entity_description.turn_on_fn(self.envoy) await self.coordinator.async_request_refresh() + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the Enpower switch.""" await self.entity_description.turn_off_fn(self.envoy) @@ -195,11 +197,13 @@ class EnvoyDryContactSwitchEntity(EnvoyBaseEntity, SwitchEntity): assert relay is not None return self.entity_description.value_fn(relay) + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on (close) the dry contact.""" if await self.entity_description.turn_on_fn(self.envoy, self.relay_id): self.async_write_ha_state() + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off (open) the dry contact.""" if await self.entity_description.turn_off_fn(self.envoy, self.relay_id): @@ -252,11 +256,13 @@ class EnvoyStorageSettingsSwitchEntity(EnvoyBaseEntity, SwitchEntity): 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_turn_on(self, **kwargs: Any) -> None: """Turn on the storage settings switch.""" await self.entity_description.turn_on_fn(self.envoy) await self.coordinator.async_request_refresh() + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the storage switch.""" await self.entity_description.turn_off_fn(self.envoy) diff --git a/tests/components/enphase_envoy/test_switch.py b/tests/components/enphase_envoy/test_switch.py index f30cba4d201..d15c0ad740f 100644 --- a/tests/components/enphase_envoy/test_switch.py +++ b/tests/components/enphase_envoy/test_switch.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +from pyenphase.exceptions import EnvoyError import pytest from syrupy.assertion import SnapshotAssertion @@ -16,6 +17,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -112,6 +114,46 @@ async def test_switch_grid_operation( mock_envoy.go_off_grid.reset_mock() +@pytest.mark.parametrize("mock_envoy", ["envoy_metered_batt_relay"], indirect=True) +async def test_switch_grid_operation_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test switch platform operation for grid switches when error occurs.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry) + + sn = mock_envoy.data.enpower.serial_number + test_entity = f"{Platform.SWITCH}.enpower_{sn}_grid_enabled" + + mock_envoy.go_off_grid.side_effect = EnvoyError("Test") + mock_envoy.go_on_grid.side_effect = EnvoyError("Test") + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_off for {test_entity}, host", + ): + # test grid status switch operation + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_on for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + @pytest.mark.parametrize( ("mock_envoy", "use_serial"), [ @@ -165,6 +207,53 @@ async def test_switch_charge_from_grid_operation( mock_envoy.disable_charge_from_grid.reset_mock() +@pytest.mark.parametrize( + ("mock_envoy", "use_serial"), + [ + ("envoy_metered_batt_relay", "enpower_654321"), + ("envoy_eu_batt", "envoy_1234"), + ], + indirect=["mock_envoy"], +) +async def test_switch_charge_from_grid_operation_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + use_serial: str, +) -> None: + """Test switch platform operation for charge from grid switches.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry) + + test_entity = f"{Platform.SWITCH}.{use_serial}_charge_from_grid" + + mock_envoy.disable_charge_from_grid.side_effect = EnvoyError("Test") + mock_envoy.enable_charge_from_grid.side_effect = EnvoyError("Test") + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_off for {test_entity}, host", + ): + # test grid status switch operation + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_on for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + @pytest.mark.parametrize( ("mock_envoy", "entity_states"), [ @@ -232,3 +321,51 @@ async def test_switch_relay_operation( assert mock_envoy.close_dry_contact.await_count == close_count mock_envoy.open_dry_contact.reset_mock() mock_envoy.close_dry_contact.reset_mock() + + +@pytest.mark.parametrize( + ("mock_envoy", "relay"), + [("envoy_metered_batt_relay", "NC1")], + indirect=["mock_envoy"], +) +async def test_switch_relay_operation_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + relay: str, +) -> None: + """Test enphase_envoy switch relay entities operation.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry) + + entity_base = f"{Platform.SWITCH}." + + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) + + test_entity = f"{entity_base}{name}" + + mock_envoy.close_dry_contact.side_effect = EnvoyError("Test") + mock_envoy.open_dry_contact.side_effect = EnvoyError("Test") + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_off for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_on for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) From 708ae09c7abd1a4a91fa6dfbeb4aacbc392f78fe Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 30 Jan 2025 01:07:55 -0800 Subject: [PATCH 1235/2987] Bump nest to 7.1.1 (#136888) --- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index f7e78b2d538..cd961276082 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/nest", "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], - "requirements": ["google-nest-sdm==7.1.0"] + "requirements": ["google-nest-sdm==7.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 16079cca64d..90a8709395f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1036,7 +1036,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.1.0 +google-nest-sdm==7.1.1 # homeassistant.components.google_photos google-photos-library-api==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a5bd58dff58..c7a0959bbb8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -886,7 +886,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.1.0 +google-nest-sdm==7.1.1 # homeassistant.components.google_photos google-photos-library-api==0.12.1 From 1b5316b269ac69f43988653664aea774f3796149 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 11:09:07 +0100 Subject: [PATCH 1236/2987] Ignore dangling symlinks when restoring backup (#136893) --- homeassistant/backup_restore.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py index 9287aa2bf1b..4d309469017 100644 --- a/homeassistant/backup_restore.py +++ b/homeassistant/backup_restore.py @@ -146,6 +146,7 @@ def _extract_backup( config_dir, dirs_exist_ok=True, ignore=shutil.ignore_patterns(*(keep)), + ignore_dangling_symlinks=True, ) elif restore_content.restore_database: for entry in KEEP_DATABASE: From 52feeedd2b3d36c347dd9a860b0ee638b4513d63 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 11:09:31 +0100 Subject: [PATCH 1237/2987] Poll supervisor job state when creating or restoring a backup (#136891) * Poll supervisor job state when creating or restoring a backup * Update tests * Add tests for create and restore jobs finishing early --- homeassistant/components/hassio/backup.py | 8 +- tests/components/hassio/test_backup.py | 180 ++++++++++++++++------ 2 files changed, 136 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 6b63ab92d5c..b81605264be 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -350,8 +350,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): backup_id = data.get("reference") backup_complete.set() + unsub = self._async_listen_job_events(backup.job_id, on_job_progress) try: - unsub = self._async_listen_job_events(backup.job_id, on_job_progress) + await self._get_job_state(backup.job_id, on_job_progress) await backup_complete.wait() finally: unsub() @@ -506,12 +507,13 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): @callback def on_job_progress(data: Mapping[str, Any]) -> None: - """Handle backup progress.""" + """Handle backup restore progress.""" if data.get("done") is True: restore_complete.set() + unsub = self._async_listen_job_events(job.job_id, on_job_progress) try: - unsub = self._async_listen_job_events(job.job_id, on_job_progress) + await self._get_job_state(job.job_id, on_job_progress) await restore_complete.wait() finally: unsub() diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 49360783517..f7379b81a14 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -301,6 +301,28 @@ TEST_BACKUP_DETAILS_5 = supervisor_backups.BackupComplete( type=TEST_BACKUP_5.type, ) +TEST_JOB_ID = "d17bd02be1f0437fa7264b16d38f700e" +TEST_JOB_NOT_DONE = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID(TEST_JOB_ID), + progress=0.0, + stage="copy_additional_locations", + done=False, + errors=[], + child_jobs=[], +) +TEST_JOB_DONE = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID(TEST_JOB_ID), + progress=0.0, + stage="copy_additional_locations", + done=True, + errors=[], + child_jobs=[], +) + @pytest.fixture(autouse=True) def fixture_supervisor_environ() -> Generator[None]: @@ -813,8 +835,9 @@ async def test_reader_writer_create( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -836,7 +859,7 @@ async def test_reader_writer_create( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( expected_supervisor_options @@ -847,7 +870,7 @@ async def test_reader_writer_create( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -877,6 +900,66 @@ async def test_reader_writer_create( assert response["event"] == {"manager_state": "idle"} +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_create_job_done( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test generating a backup, and backup job finishes early.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": TEST_JOB_ID} + + supervisor_client.backups.partial_backup.assert_called_once_with( + DEFAULT_BACKUP_OPTIONS + ) + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + supervisor_client.backups.download_backup.assert_not_called() + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + @pytest.mark.usefixtures("hassio_client") @pytest.mark.parametrize( ( @@ -1006,7 +1089,7 @@ async def test_reader_writer_create_per_agent_encryption( for i in range(1, 4) ], ) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = replace( TEST_BACKUP_DETAILS, locations=create_locations, @@ -1018,6 +1101,7 @@ async def test_reader_writer_create_per_agent_encryption( for location in create_locations }, ) + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE supervisor_client.mounts.info.return_value = mounts assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -1050,7 +1134,7 @@ async def test_reader_writer_create_per_agent_encryption( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( replace( @@ -1065,7 +1149,7 @@ async def test_reader_writer_create_per_agent_encryption( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1176,7 +1260,8 @@ async def test_reader_writer_create_missing_reference_error( ) -> None: """Test missing reference error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -1197,7 +1282,7 @@ async def test_reader_writer_create_missing_reference_error( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -1206,7 +1291,7 @@ async def test_reader_writer_create_missing_reference_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123"}, + "data": {"done": True, "uuid": TEST_JOB_ID}, }, } ) @@ -1248,8 +1333,9 @@ async def test_reader_writer_create_download_remove_error( ) -> None: """Test download and remove error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE method_mock = getattr(supervisor_client.backups, method) method_mock.side_effect = exception @@ -1282,7 +1368,7 @@ async def test_reader_writer_create_download_remove_error( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -1291,7 +1377,7 @@ async def test_reader_writer_create_download_remove_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1334,8 +1420,9 @@ async def test_reader_writer_create_info_error( ) -> None: """Test backup info error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.side_effect = exception + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1366,7 +1453,7 @@ async def test_reader_writer_create_info_error( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -1375,7 +1462,7 @@ async def test_reader_writer_create_info_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1408,8 +1495,9 @@ async def test_reader_writer_create_remote_backup( ) -> None: """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1440,7 +1528,7 @@ async def test_reader_writer_create_remote_backup( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( replace(DEFAULT_BACKUP_OPTIONS, location=[LOCATION_CLOUD_BACKUP]), @@ -1451,7 +1539,7 @@ async def test_reader_writer_create_remote_backup( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1510,7 +1598,7 @@ async def test_reader_writer_create_wrong_parameters( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS await client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1614,17 +1702,33 @@ async def test_agent_receive_remote_backup( ) +@pytest.mark.parametrize( + ("get_job_result", "supervisor_events"), + [ + ( + TEST_JOB_NOT_DONE, + [{"event": "job", "data": {"done": True, "uuid": TEST_JOB_ID}}], + ), + ( + TEST_JOB_DONE, + [], + ), + ], +) @pytest.mark.usefixtures("hassio_client", "setup_integration") async def test_reader_writer_restore( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + get_job_result: supervisor_jobs.Job, + supervisor_events: list[dict[str, Any]], ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = "abc123" + supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = get_job_result await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -1657,17 +1761,10 @@ async def test_reader_writer_restore( ), ) - await client.send_json_auto_id( - { - "type": "supervisor/event", - "data": { - "event": "job", - "data": {"done": True, "uuid": "abc123"}, - }, - } - ) - response = await client.receive_json() - assert response["success"] + for event in supervisor_events: + await client.send_json_auto_id({"type": "supervisor/event", "data": event}) + response = await client.receive_json() + assert response["success"] response = await client.receive_json() assert response["event"] == { @@ -1818,21 +1915,9 @@ async def test_restore_progress_after_restart( ) -> None: """Test restore backup progress after restart.""" - supervisor_client.jobs.get_job.return_value = supervisor_jobs.Job( - name="backup_manager_partial_backup", - reference="1ef41507", - uuid=UUID("d17bd02be1f0437fa7264b16d38f700e"), - progress=0.0, - stage="copy_additional_locations", - done=True, - errors=[], - child_jobs=[], - ) + supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE - with patch.dict( - os.environ, - MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, - ): + with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) client = await hass_ws_client(hass) @@ -1860,10 +1945,7 @@ async def test_restore_progress_after_restart_unknown_job( supervisor_client.jobs.get_job.side_effect = SupervisorError - with patch.dict( - os.environ, - MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, - ): + with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) client = await hass_ws_client(hass) From 9eb383f3148c559995631bc4ae44269f67d9f3cd Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 30 Jan 2025 21:11:40 +1100 Subject: [PATCH 1238/2987] Bump Pysmlight to v0.2.0 (#136886) * Bump pysmlight to v0.2.0 * Update info.json fixture with radios list * Update diagnostics snapshot --- homeassistant/components/smlight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smlight/fixtures/info.json | 14 +++++++++++++- .../smlight/snapshots/test_diagnostics.ambr | 18 ++++++++++++++++++ 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 3a8578c8a59..9410e54cee1 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.1.6"], + "requirements": ["pysmlight==0.2.0"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 90a8709395f..5cea3cd444e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.6 +pysmlight==0.2.0 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7a0959bbb8..a6c6271c39a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.6 +pysmlight==0.2.0 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/tests/components/smlight/fixtures/info.json b/tests/components/smlight/fixtures/info.json index e3defb4410e..b94fdc3d61c 100644 --- a/tests/components/smlight/fixtures/info.json +++ b/tests/components/smlight/fixtures/info.json @@ -15,5 +15,17 @@ "zb_hw": "CC2652P7", "zb_ram_size": 152, "zb_version": "20240314", - "zb_type": 0 + "zb_type": 0, + "radios": [ + { + "chip_index": 0, + "zb_hw": "CC2652P7", + "zb_version": "20240314", + "zb_type": 0, + "zb_channel": 0, + "zb_ram_size": 152, + "zb_flash_size": 704, + "radioModes": [true, true, true, false, false] + } + ] } diff --git a/tests/components/smlight/snapshots/test_diagnostics.ambr b/tests/components/smlight/snapshots/test_diagnostics.ambr index 97177de1704..5ee6cd19676 100644 --- a/tests/components/smlight/snapshots/test_diagnostics.ambr +++ b/tests/components/smlight/snapshots/test_diagnostics.ambr @@ -10,6 +10,24 @@ 'hostname': 'SLZB-06p7', 'legacy_api': 0, 'model': 'SLZB-06p7', + 'radios': list([ + dict({ + 'chip_index': 0, + 'radioModes': list([ + True, + True, + True, + False, + False, + ]), + 'zb_channel': 0, + 'zb_flash_size': 704, + 'zb_hw': 'CC2652P7', + 'zb_ram_size': 152, + 'zb_type': 0, + 'zb_version': '20240314', + }), + ]), 'ram_total': 296, 'sw_version': 'v2.3.6', 'wifi_mode': 0, From 5dd147e83b3f02e7da75c33513760967b51850b9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 30 Jan 2025 11:46:27 +0100 Subject: [PATCH 1239/2987] Add missing discovery string from onewire (#136892) --- homeassistant/components/onewire/config_flow.py | 1 + homeassistant/components/onewire/strings.json | 3 +++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index e40e99d0903..8a5623772f7 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -147,6 +147,7 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="discovery_confirm", + description_placeholders={"host": self._discovery_data[CONF_HOST]}, errors=errors, ) diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 9613a927f8d..8f46369a70b 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -8,6 +8,9 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { + "discovery_confirm": { + "description": "Do you want to set up OWServer from {host}?" + }, "reconfigure": { "data": { "host": "[%key:common::config_flow::data::host%]", From 76570b51443bb0efb01b91e8cc9f2d2157f00a4a Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Thu, 30 Jan 2025 13:47:33 +0100 Subject: [PATCH 1240/2987] Remove stale translation string in HomeWizard (#136917) Remove stale translation in HomeWizard --- homeassistant/components/homewizard/strings.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 806dbf6e083..02b18d5fa4e 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -174,8 +174,7 @@ } }, "error": { - "authorization_failed": "[%key:component::homewizard::config::error::authorization_failed%]", - "unknown_error": "[%key:common::config_flow::error::unknown%]" + "authorization_failed": "[%key:component::homewizard::config::error::authorization_failed%]" } } } From 1c4ddb36d5586052d13f6fb515ffb006a32e7ac5 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Jan 2025 14:16:51 +0100 Subject: [PATCH 1241/2987] Convert valve position to int for Shelly BLU TRV (#136912) --- homeassistant/components/shelly/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 7140c79fbb6..5f0567d034a 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -208,7 +208,7 @@ RPC_NUMBERS: Final = { method_params_fn=lambda idx, value: { "id": idx, "method": "Trv.SetPosition", - "params": {"id": 0, "pos": value}, + "params": {"id": 0, "pos": int(value)}, }, removal_condition=lambda config, _status, key: config[key].get("enable", True) is True, From bab616fa61a8a94d6144776a32e0c6d444f702a7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 15:25:16 +0100 Subject: [PATCH 1242/2987] Fix handling of renamed backup files in the core writer (#136898) * Fix handling of renamed backup files in the core writer * Adjust mocking * Raise BackupAgentError instead of KeyError in get_backup_path * Add specific error indicating backup not found * Fix tests * Ensure backups are loaded * Fix tests --- homeassistant/components/backup/agent.py | 13 ++- homeassistant/components/backup/backup.py | 41 ++++++--- homeassistant/components/backup/manager.py | 32 +++---- tests/components/backup/common.py | 17 +++- tests/components/backup/conftest.py | 10 ++- .../backup/snapshots/test_backup.ambr | 33 +++++-- tests/components/backup/test_backup.py | 50 ++++++++--- tests/components/backup/test_http.py | 29 +++--- tests/components/backup/test_manager.py | 90 +++++++++++++++---- 9 files changed, 234 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index 33656b6edcc..297ccd6f685 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -27,6 +27,12 @@ class BackupAgentUnreachableError(BackupAgentError): _message = "The backup agent is unreachable." +class BackupNotFound(BackupAgentError): + """Raised when a backup is not found.""" + + error_code = "backup_not_found" + + class BackupAgent(abc.ABC): """Backup agent interface.""" @@ -94,11 +100,16 @@ class LocalBackupAgent(BackupAgent): @abc.abstractmethod def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup. + """Return the local path to an existing backup. The method should return the path to the backup file with the specified id. + Raises BackupAgentError if the backup does not exist. """ + @abc.abstractmethod + def get_new_backup_path(self, backup: AgentBackup) -> Path: + """Return the local path to a new backup.""" + class BackupAgentPlatformProtocol(Protocol): """Define the format of backup platforms which implement backup agents.""" diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index 3f60bd0b88e..c76b50b5935 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -11,7 +11,7 @@ from typing import Any from homeassistant.core import HomeAssistant from homeassistant.helpers.hassio import is_hassio -from .agent import BackupAgent, LocalBackupAgent +from .agent import BackupAgent, BackupNotFound, LocalBackupAgent from .const import DOMAIN, LOGGER from .models import AgentBackup from .util import read_backup @@ -39,7 +39,7 @@ class CoreLocalBackupAgent(LocalBackupAgent): super().__init__() self._hass = hass self._backup_dir = Path(hass.config.path("backups")) - self._backups: dict[str, AgentBackup] = {} + self._backups: dict[str, tuple[AgentBackup, Path]] = {} self._loaded_backups = False async def _load_backups(self) -> None: @@ -49,13 +49,13 @@ class CoreLocalBackupAgent(LocalBackupAgent): self._backups = backups self._loaded_backups = True - def _read_backups(self) -> dict[str, AgentBackup]: + def _read_backups(self) -> dict[str, tuple[AgentBackup, Path]]: """Read backups from disk.""" - backups: dict[str, AgentBackup] = {} + backups: dict[str, tuple[AgentBackup, Path]] = {} for backup_path in self._backup_dir.glob("*.tar"): try: backup = read_backup(backup_path) - backups[backup.backup_id] = backup + backups[backup.backup_id] = (backup, backup_path) except (OSError, TarError, json.JSONDecodeError, KeyError) as err: LOGGER.warning("Unable to read backup %s: %s", backup_path, err) return backups @@ -76,13 +76,13 @@ class CoreLocalBackupAgent(LocalBackupAgent): **kwargs: Any, ) -> None: """Upload a backup.""" - self._backups[backup.backup_id] = backup + self._backups[backup.backup_id] = (backup, self.get_new_backup_path(backup)) async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" if not self._loaded_backups: await self._load_backups() - return list(self._backups.values()) + return [backup for backup, _ in self._backups.values()] async def async_get_backup( self, @@ -93,10 +93,10 @@ class CoreLocalBackupAgent(LocalBackupAgent): if not self._loaded_backups: await self._load_backups() - if not (backup := self._backups.get(backup_id)): + if backup_id not in self._backups: return None - backup_path = self.get_backup_path(backup_id) + backup, backup_path = self._backups[backup_id] if not await self._hass.async_add_executor_job(backup_path.exists): LOGGER.debug( ( @@ -112,15 +112,28 @@ class CoreLocalBackupAgent(LocalBackupAgent): return backup def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup.""" - return self._backup_dir / f"{backup_id}.tar" + """Return the local path to an existing backup. + + Raises BackupAgentError if the backup does not exist. + """ + try: + return self._backups[backup_id][1] + except KeyError as err: + raise BackupNotFound(f"Backup {backup_id} does not exist") from err + + def get_new_backup_path(self, backup: AgentBackup) -> Path: + """Return the local path to a new backup.""" + return self._backup_dir / f"{backup.backup_id}.tar" async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: """Delete a backup file.""" - if await self.async_get_backup(backup_id) is None: - return + if not self._loaded_backups: + await self._load_backups() - backup_path = self.get_backup_path(backup_id) + try: + backup_path = self.get_backup_path(backup_id) + except BackupNotFound: + return await self._hass.async_add_executor_job(backup_path.unlink, True) LOGGER.debug("Deleted backup located at %s", backup_path) self._backups.pop(backup_id) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index fc56505e343..d1f27fa270b 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1346,10 +1346,24 @@ class CoreBackupReaderWriter(BackupReaderWriter): if agent_config and not agent_config.protected: password = None + backup = AgentBackup( + addons=[], + backup_id=backup_id, + database_included=include_database, + date=date_str, + extra_metadata=extra_metadata, + folders=[], + homeassistant_included=True, + homeassistant_version=HAVERSION, + name=backup_name, + protected=password is not None, + size=0, + ) + local_agent_tar_file_path = None if self._local_agent_id in agent_ids: local_agent = manager.local_backup_agents[self._local_agent_id] - local_agent_tar_file_path = local_agent.get_backup_path(backup_id) + local_agent_tar_file_path = local_agent.get_new_backup_path(backup) on_progress( CreateBackupEvent( @@ -1391,19 +1405,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): # ValueError from json_bytes raise BackupReaderWriterError(str(err)) from err else: - backup = AgentBackup( - addons=[], - backup_id=backup_id, - database_included=include_database, - date=date_str, - extra_metadata=extra_metadata, - folders=[], - homeassistant_included=True, - homeassistant_version=HAVERSION, - name=backup_name, - protected=password is not None, - size=size_in_bytes, - ) + backup = replace(backup, size=size_in_bytes) async_add_executor_job = self._hass.async_add_executor_job @@ -1517,7 +1519,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): manager = self._hass.data[DATA_MANAGER] if self._local_agent_id in agent_ids: local_agent = manager.local_backup_agents[self._local_agent_id] - tar_file_path = local_agent.get_backup_path(backup.backup_id) + tar_file_path = local_agent.get_new_backup_path(backup) await async_add_executor_job(make_backup_dir, tar_file_path.parent) await async_add_executor_job(shutil.move, temp_file, tar_file_path) else: diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index 97236ee995d..a7888dbd08c 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Callable, Coroutine +from collections.abc import AsyncIterator, Callable, Coroutine, Iterable from pathlib import Path from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch @@ -52,10 +52,17 @@ TEST_BACKUP_DEF456 = AgentBackup( protected=False, size=1, ) +TEST_BACKUP_PATH_DEF456 = Path("custom_def456.tar") TEST_DOMAIN = "test" +async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: + """Convert an iterable to an async iterator.""" + for i in iterable: + yield i + + class BackupAgentTest(BackupAgent): """Test backup agent.""" @@ -162,7 +169,13 @@ async def setup_backup_integration( if with_hassio and agent_id == LOCAL_AGENT_ID: continue agent = hass.data[DATA_MANAGER].backup_agents[agent_id] - agent._backups = {backups.backup_id: backups for backups in agent_backups} + + async def open_stream() -> AsyncIterator[bytes]: + """Open a stream.""" + return aiter_from_iter((b"backup data",)) + + 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 diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index bef48498ede..d0d9ac7e0e1 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -13,7 +13,7 @@ from homeassistant.components.backup import DOMAIN from homeassistant.components.backup.manager import NewBackup, WrittenBackup from homeassistant.core import HomeAssistant -from .common import TEST_BACKUP_PATH_ABC123 +from .common import TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456 from tests.common import get_fixture_path @@ -38,10 +38,14 @@ def mocked_tarfile_fixture() -> Generator[Mock]: @pytest.fixture(name="path_glob") -def path_glob_fixture() -> Generator[MagicMock]: +def path_glob_fixture(hass: HomeAssistant) -> Generator[MagicMock]: """Mock path glob.""" with patch( - "pathlib.Path.glob", return_value=[TEST_BACKUP_PATH_ABC123] + "pathlib.Path.glob", + return_value=[ + Path(hass.config.path()) / "backups" / TEST_BACKUP_PATH_ABC123, + Path(hass.config.path()) / "backups" / TEST_BACKUP_PATH_DEF456, + ], ) as path_glob: yield path_glob diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index 032eb7ac537..68b00632a6b 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_delete_backup[found_backups0-True-1] +# name: test_delete_backup[found_backups0-abc123-1-unlink_path0] dict({ 'id': 1, 'result': dict({ @@ -10,7 +10,7 @@ 'type': 'result', }) # --- -# name: test_delete_backup[found_backups1-False-0] +# name: test_delete_backup[found_backups1-def456-1-unlink_path1] dict({ 'id': 1, 'result': dict({ @@ -21,7 +21,7 @@ 'type': 'result', }) # --- -# name: test_delete_backup[found_backups2-True-0] +# name: test_delete_backup[found_backups2-abc123-0-None] dict({ 'id': 1, 'result': dict({ @@ -32,7 +32,7 @@ 'type': 'result', }) # --- -# name: test_load_backups[None] +# name: test_load_backups[mock_read_backup] dict({ 'id': 1, 'result': dict({ @@ -47,7 +47,7 @@ 'type': 'result', }) # --- -# name: test_load_backups[None].1 +# name: test_load_backups[mock_read_backup].1 dict({ 'id': 2, 'result': dict({ @@ -82,6 +82,29 @@ 'name': 'Test', 'with_automatic_settings': True, }), + dict({ + 'addons': list([ + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 1, + }), + }), + 'backup_id': 'def456', + 'database_included': False, + 'date': '1980-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test 2', + 'with_automatic_settings': None, + }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index 02252ef6fa5..ce34c51c105 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -12,21 +12,35 @@ from unittest.mock import MagicMock, mock_open, patch import pytest from syrupy import SnapshotAssertion -from homeassistant.components.backup import DOMAIN +from homeassistant.components.backup import DOMAIN, AgentBackup from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .common import TEST_BACKUP_ABC123, TEST_BACKUP_PATH_ABC123 +from .common import ( + TEST_BACKUP_ABC123, + TEST_BACKUP_DEF456, + TEST_BACKUP_PATH_ABC123, + TEST_BACKUP_PATH_DEF456, +) from tests.typing import ClientSessionGenerator, WebSocketGenerator +def mock_read_backup(backup_path: Path) -> AgentBackup: + """Mock read backup.""" + mock_backups = { + "abc123": TEST_BACKUP_ABC123, + "custom_def456": TEST_BACKUP_DEF456, + } + return mock_backups[backup_path.stem] + + @pytest.fixture(name="read_backup") def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]: """Mock read backup.""" with patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ) as read_backup: yield read_backup @@ -34,7 +48,7 @@ def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]: @pytest.mark.parametrize( "side_effect", [ - None, + mock_read_backup, OSError("Boom"), TarError("Boom"), json.JSONDecodeError("Boom", "test", 1), @@ -94,11 +108,21 @@ async def test_upload( @pytest.mark.usefixtures("read_backup") @pytest.mark.parametrize( - ("found_backups", "backup_exists", "unlink_calls"), + ("found_backups", "backup_id", "unlink_calls", "unlink_path"), [ - ([TEST_BACKUP_PATH_ABC123], True, 1), - ([TEST_BACKUP_PATH_ABC123], False, 0), - (([], True, 0)), + ( + [TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456], + TEST_BACKUP_ABC123.backup_id, + 1, + TEST_BACKUP_PATH_ABC123, + ), + ( + [TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456], + TEST_BACKUP_DEF456.backup_id, + 1, + TEST_BACKUP_PATH_DEF456, + ), + (([], TEST_BACKUP_ABC123.backup_id, 0, None)), ], ) async def test_delete_backup( @@ -108,8 +132,9 @@ async def test_delete_backup( snapshot: SnapshotAssertion, path_glob: MagicMock, found_backups: list[Path], - backup_exists: bool, + backup_id: str, unlink_calls: int, + unlink_path: Path | None, ) -> None: """Test delete backup.""" assert await async_setup_component(hass, DOMAIN, {}) @@ -118,12 +143,13 @@ async def test_delete_backup( path_glob.return_value = found_backups with ( - patch("pathlib.Path.exists", return_value=backup_exists), - patch("pathlib.Path.unlink") as unlink, + patch("pathlib.Path.unlink", autospec=True) as unlink, ): await client.send_json_auto_id( - {"type": "backup/delete", "backup_id": TEST_BACKUP_ABC123.backup_id} + {"type": "backup/delete", "backup_id": backup_id} ) assert await client.receive_json() == snapshot assert unlink.call_count == unlink_calls + for call in unlink.mock_calls: + assert call.args[0] == unlink_path diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index ee6803655d5..aac39c04d31 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -1,7 +1,7 @@ """Tests for the Backup integration.""" import asyncio -from collections.abc import AsyncIterator, Iterable +from collections.abc import AsyncIterator from io import BytesIO, StringIO import json import tarfile @@ -15,7 +15,12 @@ from homeassistant.components.backup import AddonInfo, AgentBackup, Folder from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.core import HomeAssistant -from .common import TEST_BACKUP_ABC123, BackupAgentTest, setup_backup_integration +from .common import ( + TEST_BACKUP_ABC123, + BackupAgentTest, + aiter_from_iter, + setup_backup_integration, +) from tests.common import MockUser, get_fixture_path from tests.typing import ClientSessionGenerator @@ -35,6 +40,9 @@ async def test_downloading_local_backup( "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", return_value=TEST_BACKUP_ABC123, ), + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.get_backup_path", + ), patch("pathlib.Path.exists", return_value=True), patch( "homeassistant.components.backup.http.FileResponse", @@ -73,9 +81,14 @@ async def test_downloading_local_encrypted_backup_file_not_found( await setup_backup_integration(hass) client = await hass_client() - with patch( - "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", - return_value=TEST_BACKUP_ABC123, + with ( + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", + return_value=TEST_BACKUP_ABC123, + ), + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.get_backup_path", + ), ): resp = await client.get( "/api/backup/download/abc123?agent_id=backup.local&password=blah" @@ -93,12 +106,6 @@ async def test_downloading_local_encrypted_backup( await _test_downloading_encrypted_backup(hass_client, "backup.local") -async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: - """Convert an iterable to an async iterator.""" - for i in iterable: - yield i - - @patch.object(BackupAgentTest, "async_download_backup") async def test_downloading_remote_encrypted_backup( download_mock, diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 5e5b0df74cd..69994028297 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -54,6 +54,8 @@ from .common import ( LOCAL_AGENT_ID, TEST_BACKUP_ABC123, TEST_BACKUP_DEF456, + TEST_BACKUP_PATH_ABC123, + TEST_BACKUP_PATH_DEF456, BackupAgentTest, setup_backup_platform, ) @@ -89,6 +91,15 @@ def generate_backup_id_fixture() -> Generator[MagicMock]: yield mock +def mock_read_backup(backup_path: Path) -> AgentBackup: + """Mock read backup.""" + mock_backups = { + "abc123": TEST_BACKUP_ABC123, + "custom_def456": TEST_BACKUP_DEF456, + } + return mock_backups[backup_path.stem] + + @pytest.mark.usefixtures("mock_backup_generation") async def test_create_backup_service( hass: HomeAssistant, @@ -1311,7 +1322,11 @@ class LocalBackupAgentTest(BackupAgentTest, LocalBackupAgent): """Local backup agent.""" def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup.""" + """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") @@ -2023,10 +2038,6 @@ async def test_receive_backup_file_write_error( with ( patch("pathlib.Path.open", open_mock), - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=TEST_BACKUP_ABC123, - ), ): resp = await client.post( "/api/backup/upload?agent_id=test.remote", @@ -2375,18 +2386,61 @@ async def test_receive_backup_file_read_error( @pytest.mark.usefixtures("path_glob") @pytest.mark.parametrize( - ("agent_id", "password_param", "restore_database", "restore_homeassistant", "dir"), + ( + "agent_id", + "backup_id", + "password_param", + "backup_path", + "restore_database", + "restore_homeassistant", + "dir", + ), [ - (LOCAL_AGENT_ID, {}, True, False, "backups"), - (LOCAL_AGENT_ID, {"password": "abc123"}, False, True, "backups"), - ("test.remote", {}, True, True, "tmp_backups"), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_ABC123.backup_id, + {}, + TEST_BACKUP_PATH_ABC123, + True, + False, + "backups", + ), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_DEF456.backup_id, + {}, + TEST_BACKUP_PATH_DEF456, + True, + False, + "backups", + ), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_ABC123.backup_id, + {"password": "abc123"}, + TEST_BACKUP_PATH_ABC123, + False, + True, + "backups", + ), + ( + "test.remote", + TEST_BACKUP_ABC123.backup_id, + {}, + TEST_BACKUP_PATH_ABC123, + True, + True, + "tmp_backups", + ), ], ) async def test_restore_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, agent_id: str, + backup_id: str, password_param: dict[str, str], + backup_path: Path, restore_database: bool, restore_homeassistant: bool, dir: str, @@ -2426,14 +2480,14 @@ async def test_restore_backup( patch.object(remote_agent, "async_download_backup") as download_mock, patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + 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", - "backup_id": TEST_BACKUP_ABC123.backup_id, + "backup_id": backup_id, "agent_id": agent_id, "restore_database": restore_database, "restore_homeassistant": restore_homeassistant, @@ -2473,17 +2527,17 @@ async def test_restore_backup( result = await ws_client.receive_json() assert result["success"] is True - backup_path = f"{hass.config.path()}/{dir}/abc123.tar" + full_backup_path = f"{hass.config.path()}/{dir}/{backup_path.name}" expected_restore_file = json.dumps( { - "path": backup_path, + "path": full_backup_path, "password": password, "remove_after_restore": agent_id != LOCAL_AGENT_ID, "restore_database": restore_database, "restore_homeassistant": restore_homeassistant, } ) - validate_password_mock.assert_called_once_with(Path(backup_path), password) + validate_password_mock.assert_called_once_with(Path(full_backup_path), password) assert mocked_write_text.call_args[0][0] == expected_restore_file assert mocked_service_call.called @@ -2533,7 +2587,7 @@ async def test_restore_backup_wrong_password( patch.object(remote_agent, "async_download_backup") as download_mock, patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ), ): download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) @@ -2581,8 +2635,8 @@ async def test_restore_backup_wrong_password( ("parameters", "expected_error", "expected_reason"), [ ( - {"backup_id": TEST_BACKUP_DEF456.backup_id}, - f"Backup def456 not found in agent {LOCAL_AGENT_ID}", + {"backup_id": "no_such_backup"}, + f"Backup no_such_backup not found in agent {LOCAL_AGENT_ID}", "backup_manager_error", ), ( @@ -2629,7 +2683,7 @@ async def test_restore_backup_wrong_parameters( patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ), ): await ws_client.send_json_auto_id( From 232e99b62ed388bb36b81ff56db43f81b878d606 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Jan 2025 16:16:22 +0100 Subject: [PATCH 1243/2987] Create Xbox signed session in executor (#136927) --- homeassistant/components/xbox/__init__.py | 4 +++- homeassistant/components/xbox/api.py | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 5282a34903a..ab0d510a709 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -6,6 +6,7 @@ import logging from xbox.webapi.api.client import XboxLiveClient from xbox.webapi.api.provider.smartglass.models import SmartglassConsoleList +from xbox.webapi.common.signed_session import SignedSession from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -36,7 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - auth = api.AsyncConfigEntryAuth(session) + signed_session = await hass.async_add_executor_job(SignedSession) + auth = api.AsyncConfigEntryAuth(signed_session, session) client = XboxLiveClient(auth) consoles: SmartglassConsoleList = await client.smartglass.get_console_list() diff --git a/homeassistant/components/xbox/api.py b/homeassistant/components/xbox/api.py index d4c47e4cc39..9fa7c14b5c9 100644 --- a/homeassistant/components/xbox/api.py +++ b/homeassistant/components/xbox/api.py @@ -11,10 +11,12 @@ from homeassistant.util.dt import utc_from_timestamp class AsyncConfigEntryAuth(AuthenticationManager): """Provide xbox authentication tied to an OAuth2 based config entry.""" - def __init__(self, oauth_session: OAuth2Session) -> None: + def __init__( + self, signed_session: SignedSession, oauth_session: OAuth2Session + ) -> None: """Initialize xbox auth.""" # Leaving out client credentials as they are handled by Home Assistant - super().__init__(SignedSession(), "", "", "") + super().__init__(signed_session, "", "", "") self._oauth_session = oauth_session self.oauth = self._get_oauth_token() From 773375e7b0d5cb3c197ca2c318c2f67bb9d10631 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Jan 2025 16:16:39 +0100 Subject: [PATCH 1244/2987] Fix Sonos importing deprecating constant (#136926) --- homeassistant/components/sonos/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 98bff8d2934..d530fa21e39 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -34,7 +34,11 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_time_interval -from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import create_eager_task @@ -503,7 +507,7 @@ class SonosDiscoveryManager: def _async_ssdp_discovered_player( self, info: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: - uid = info.upnp[ssdp.ATTR_UPNP_UDN] + uid = info.upnp[ATTR_UPNP_UDN] if not uid.startswith("uuid:RINCON_"): return uid = uid[5:] @@ -522,7 +526,7 @@ class SonosDiscoveryManager: cast(str, urlparse(info.ssdp_location).hostname), uid, info.ssdp_headers.get("X-RINCON-BOOTSEQ"), - cast(str, info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME)), + cast(str, info.upnp.get(ATTR_UPNP_MODEL_NAME)), None, ) From d148bd9b0cf128c74b971a25012c0363ee63ddbc Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 30 Jan 2025 16:33:59 +0100 Subject: [PATCH 1245/2987] Fix onedrive does not fail on delete not found (#136910) * Fix onedrive does not fail on delete not found * Fix onedrive does not fail on delete not found --- homeassistant/components/onedrive/backup.py | 8 ++++++- tests/components/onedrive/test_backup.py | 24 ++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index a5a5c019797..94d60bc6398 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -190,7 +190,13 @@ class OneDriveBackupAgent(BackupAgent): **kwargs: Any, ) -> None: """Delete a backup file.""" - await self._get_backup_file_item(backup_id).delete() + + try: + await self._get_backup_file_item(backup_id).delete() + except APIError as err: + if err.response_status_code == 404: + return + raise @handle_backup_errors async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index a3d1129377f..3492202d3fe 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -156,6 +156,28 @@ async def test_agents_delete( mock_drive_items.delete.assert_called_once() +async def test_agents_delete_not_found_does_not_throw( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, +) -> None: + """Test agent delete backup.""" + mock_drive_items.delete = AsyncMock(side_effect=APIError(response_status_code=404)) + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock_drive_items.delete.assert_called_once() + + async def test_agents_upload( hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, @@ -257,7 +279,7 @@ async def test_agents_download( ("side_effect", "error"), [ ( - APIError(response_status_code=404, message="File not found."), + APIError(response_status_code=500), "Backup operation failed", ), (TimeoutError(), "Backup operation timed out"), From 8db6a6cf176122746901414e7638e94be9fe62c9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 30 Jan 2025 16:47:09 +0100 Subject: [PATCH 1246/2987] Shorten the integration name for `incomfort` (#136930) --- .../components/incomfort/manifest.json | 2 +- .../components/incomfort/strings.json | 22 +++++++++---------- homeassistant/generated/integrations.json | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index f4d752bfa48..d02b1d27554 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -1,6 +1,6 @@ { "domain": "incomfort", - "name": "Intergas InComfort/Intouch Lan2RF gateway", + "name": "Intergas gateway", "codeowners": ["@jbouwh"], "config_flow": true, "dhcp": [ diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 4c47d4c57ad..15e28b6e0b9 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -2,20 +2,20 @@ "config": { "step": { "user": { - "description": "Set up new Intergas InComfort Lan2RF Gateway, some older systems might not need credentials to be set up. For newer devices authentication is required.", + "description": "Set up new Intergas gateway, some older systems might not need credentials to be set up. For newer devices authentication is required.", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "Hostname or IP-address of the Intergas InComfort Lan2RF Gateway.", + "host": "Hostname or IP-address of the Intergas gateway.", "username": "The username to log into the gateway. This is `admin` in most cases.", - "password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices." + "password": "The password to log into the gateway, is printed at the bottom of the gateway or is `intergas` for some older devices." } }, "dhcp_auth": { - "title": "Set up Intergas InComfort Lan2RF Gateway", + "title": "Set up Intergas gateway", "description": "Please enter authentication details for gateway {host}", "data": { "username": "[%key:common::config_flow::data::username%]", @@ -23,12 +23,12 @@ }, "data_description": { "username": "The username to log into the gateway. This is `admin` in most cases.", - "password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices." + "password": "The password to log into the gateway, is printed at the bottom of the Gateway or is `intergas` for some older devices." } }, "dhcp_confirm": { - "title": "Set up Intergas InComfort Lan2RF Gateway", - "description": "Do you want to set up the discovered Intergas InComfort Lan2RF Gateway ({host})?" + "title": "Set up Intergas gateway", + "description": "Do you want to set up the discovered Intergas gateway ({host})?" }, "reauth_confirm": { "data": { @@ -48,9 +48,9 @@ "error": { "auth_error": "Invalid credentials.", "no_heaters": "No heaters found.", - "not_found": "No Lan2RF gateway found.", - "timeout_error": "Time out when connecting to Lan2RF gateway.", - "unknown": "Unknown error when connecting to Lan2RF gateway." + "not_found": "No gateway found.", + "timeout_error": "Time out when connecting to the gateway.", + "unknown": "Unknown error when connecting to the gateway." } }, "exceptions": { @@ -70,7 +70,7 @@ "options": { "step": { "init": { - "title": "Intergas InComfort Lan2RF Gateway options", + "title": "Intergas gateway options", "data": { "legacy_setpoint_status": "Legacy setpoint handling" }, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e8a4290bb7d..cab624ecb5b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2866,7 +2866,7 @@ "iot_class": "local_polling" }, "incomfort": { - "name": "Intergas InComfort/Intouch Lan2RF gateway", + "name": "Intergas gateway", "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" From 6dd2d46328391689fade057dd5a6c09e24ed75e7 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 30 Jan 2025 16:59:39 +0100 Subject: [PATCH 1247/2987] Fix backup related translations in Synology DSM (#136931) refernce backup related strings in option-flow strings --- homeassistant/components/synology_dsm/strings.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 3d64f908256..d6d40be3fea 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -70,7 +70,13 @@ "data": { "scan_interval": "Minutes between scans", "timeout": "Timeout (seconds)", - "snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)" + "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%]" + }, + "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%]" } } } From 63af407f8fb5f1e3d748a2e5d1cb0e8134a3a501 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 30 Jan 2025 17:08:35 +0100 Subject: [PATCH 1248/2987] Pick onedrive owner from a more reliable source (#136929) * Pick onedrive owner from a more reliable source * fix --- homeassistant/components/onedrive/config_flow.py | 7 +++++-- tests/components/onedrive/conftest.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py index 83f6dd6e2ee..09c0d1b44cc 100644 --- a/homeassistant/components/onedrive/config_flow.py +++ b/homeassistant/components/onedrive/config_flow.py @@ -78,7 +78,7 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self.logger.exception("Unknown error") return self.async_abort(reason="unknown") - drive = response.json() + drive: dict = response.json() await self.async_set_unique_id(drive["parentReference"]["driveId"]) @@ -94,7 +94,10 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self._abort_if_unique_id_configured() - title = f"{drive['shared']['owner']['user']['displayName']}'s OneDrive" + user = drive.get("createdBy", {}).get("user", {}).get("displayName") + + title = f"{user}'s OneDrive" if user else "OneDrive" + return self.async_create_entry(title=title, data=data) async def async_step_reauth( diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 0cca8e9df0b..65142217017 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -88,7 +88,7 @@ def mock_adapter() -> Generator[MagicMock]: status_code=200, json={ "parentReference": {"driveId": "mock_drive_id"}, - "shared": {"owner": {"user": {"displayName": "John Doe"}}}, + "createdBy": {"user": {"displayName": "John Doe"}}, }, ) yield adapter From ec53b08e0907177091edb7c5a7aa6f746e520171 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 17:32:01 +0100 Subject: [PATCH 1249/2987] Don't blow up when a backup doesn't exist on supervisor (#136907) --- homeassistant/components/hassio/backup.py | 9 +-- tests/components/hassio/test_backup.py | 96 ++++++++++++++++++++--- 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index b81605264be..b9439183d8c 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -198,7 +198,10 @@ class SupervisorBackupAgent(BackupAgent): **kwargs: Any, ) -> AgentBackup | None: """Return a backup.""" - details = await self._client.backups.backup_info(backup_id) + try: + details = await self._client.backups.backup_info(backup_id) + except SupervisorNotFoundError: + return None if self.location not in details.locations: return None return _backup_details_to_agent_backup(details, self.location) @@ -212,10 +215,6 @@ class SupervisorBackupAgent(BackupAgent): location={self.location} ), ) - except SupervisorBadRequestError as err: - if err.args[0] != "Backup does not exist": - raise - _LOGGER.debug("Backup %s does not exist", backup_id) except SupervisorNotFoundError: _LOGGER.debug("Backup %s does not exist", backup_id) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index f7379b81a14..9ba73ade1a3 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -544,7 +544,7 @@ async def test_agent_download( hass_client: ClientSessionGenerator, supervisor_client: AsyncMock, ) -> None: - """Test agent download backup, when cloud user is logged in.""" + """Test agent download backup.""" client = await hass_client() backup_id = "abc123" supervisor_client.backups.list.return_value = [TEST_BACKUP] @@ -568,7 +568,7 @@ async def test_agent_download_unavailable_backup( hass_client: ClientSessionGenerator, supervisor_client: AsyncMock, ) -> None: - """Test agent download backup, when cloud user is logged in.""" + """Test agent download backup which does not exist.""" client = await hass_client() backup_id = "abc123" supervisor_client.backups.list.return_value = [TEST_BACKUP_3] @@ -630,6 +630,91 @@ async def test_agent_upload( supervisor_client.backups.remove_backup.assert_not_called() +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_agent_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + backup_id = "abc123" + + await client.send_json_auto_id( + { + "type": "backup/details", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {}, + "backup": { + "addons": [ + {"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"} + ], + "agents": {"hassio.local": {"protected": False, "size": 1048576}}, + "backup_id": "abc123", + "database_included": True, + "date": "1970-01-01T00:00:00+00:00", + "failed_agent_ids": [], + "folders": ["share"], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "with_automatic_settings": None, + }, + } + supervisor_client.backups.backup_info.assert_called_once_with(backup_id) + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + ("backup_info_side_effect", "expected_response"), + [ + ( + SupervisorBadRequestError("blah"), + { + "success": False, + "error": {"code": "unknown_error", "message": "Unknown error"}, + }, + ), + ( + SupervisorNotFoundError(), + { + "success": True, + "result": {"agent_errors": {}, "backup": None}, + }, + ), + ], +) +async def test_agent_get_backup_with_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + backup_info_side_effect: Exception, + expected_response: dict[str, Any], +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + backup_id = "abc123" + + supervisor_client.backups.backup_info.side_effect = backup_info_side_effect + await client.send_json_auto_id( + { + "type": "backup/details", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response == {"id": 1, "type": "result"} | expected_response + supervisor_client.backups.backup_info.assert_called_once_with(backup_id) + + @pytest.mark.usefixtures("hassio_client", "setup_integration") async def test_agent_delete_backup( hass: HomeAssistant, @@ -666,13 +751,6 @@ async def test_agent_delete_backup( "error": {"code": "unknown_error", "message": "Unknown error"}, }, ), - ( - SupervisorBadRequestError("Backup does not exist"), - { - "success": True, - "result": {"agent_errors": {}}, - }, - ), ( SupervisorNotFoundError(), { From eca93f1f4e318a787d1d7f18bd9a0b6913c45c72 Mon Sep 17 00:00:00 2001 From: moritzthecat Date: Thu, 30 Jan 2025 17:33:41 +0100 Subject: [PATCH 1250/2987] Add DS2450 to onewire integration (#136882) * add DS2450 to onewire integration * added tests for DS2450 in const.py * Update homeassistant/components/onewire/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * spelling change voltage -> Voltage * use translation key * tests run after en.json edited * Update homeassistant/components/onewire/strings.json Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * naming convention adapted * Update homeassistant/components/onewire/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * adatpt owfs namings to HA namings. volt -> voltage * Apply suggestions from code review --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/onewire/const.py | 2 + homeassistant/components/onewire/sensor.py | 28 ++ homeassistant/components/onewire/strings.json | 6 + tests/components/onewire/const.py | 13 + .../onewire/snapshots/test_init.ambr | 32 ++ .../onewire/snapshots/test_sensor.ambr | 424 ++++++++++++++++++ 6 files changed, 505 insertions(+) diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py index 2ab44c47892..57cdd8c483c 100644 --- a/homeassistant/components/onewire/const.py +++ b/homeassistant/components/onewire/const.py @@ -10,6 +10,7 @@ DOMAIN = "onewire" DEVICE_KEYS_0_3 = range(4) DEVICE_KEYS_0_7 = range(8) DEVICE_KEYS_A_B = ("A", "B") +DEVICE_KEYS_A_D = ("A", "B", "C", "D") DEVICE_SUPPORT = { "05": (), @@ -17,6 +18,7 @@ DEVICE_SUPPORT = { "12": (), "1D": (), "1F": (), + "20": (), "22": (), "26": (), "28": (), diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 1c4047abf0a..04141f87847 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -33,6 +33,7 @@ from homeassistant.helpers.typing import StateType from .const import ( DEVICE_KEYS_0_3, DEVICE_KEYS_A_B, + DEVICE_KEYS_A_D, OPTION_ENTRY_DEVICE_OPTIONS, OPTION_ENTRY_SENSOR_PRECISION, PRECISION_MAPPING_FAMILY_28, @@ -108,6 +109,33 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), + "20": tuple( + [ + OneWireSensorEntityDescription( + key=f"latestvolt.{device_key}", + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + read_mode=READ_MODE_FLOAT, + state_class=SensorStateClass.MEASUREMENT, + translation_key="latest_voltage_id", + translation_placeholders={"id": str(device_key)}, + ) + for device_key in DEVICE_KEYS_A_D + ] + + [ + OneWireSensorEntityDescription( + key=f"volt.{device_key}", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + read_mode=READ_MODE_FLOAT, + state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_id", + translation_placeholders={"id": str(device_key)}, + ) + for device_key in DEVICE_KEYS_A_D + ] + ), "22": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), "26": ( SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION, diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 8f46369a70b..46f41503d97 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -74,12 +74,18 @@ "humidity_raw": { "name": "Raw humidity" }, + "latest_voltage_id": { + "name": "Latest voltage {id}" + }, "moisture_id": { "name": "Moisture {id}" }, "thermocouple_temperature_k": { "name": "Thermocouple K temperature" }, + "voltage_id": { + "name": "Voltage {id}" + }, "voltage_vad": { "name": "VAD voltage" }, diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index 4c05442eadc..370bcc871c6 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -65,6 +65,19 @@ MOCK_OWPROXY_DEVICES = { }, }, }, + "20.111111111111": { + ATTR_INJECT_READS: { + "/type": [b"DS2450"], + "/volt.A": [b" 1.1"], + "/volt.B": [b" 2.2"], + "/volt.C": [b" 3.3"], + "/volt.D": [b" 4.4"], + "/latestvolt.A": [b" 1.11"], + "/latestvolt.B": [b" 2.22"], + "/latestvolt.C": [b" 3.33"], + "/latestvolt.D": [b" 4.44"], + } + }, "22.111111111111": { ATTR_INJECT_READS: { "/type": [b"DS1822"], diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr index 159f3acea42..ee5d6d99158 100644 --- a/tests/components/onewire/snapshots/test_init.ambr +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -159,6 +159,38 @@ 'via_device_id': None, }) # --- +# name: test_registry[20.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '20.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2450', + 'model_id': 'DS2450', + 'name': '20.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_registry[22.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index 1b8484b27a4..b963e29d160 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -260,6 +260,430 @@ 'state': '248125', }) # --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_latest_voltage_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.A', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.A', + 'friendly_name': '20.111111111111 Latest voltage A', + 'raw_value': 1.11, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.11', + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_latest_voltage_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.B', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.B', + 'friendly_name': '20.111111111111 Latest voltage B', + 'raw_value': 2.22, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.22', + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_latest_voltage_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage C', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.C', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.C', + 'friendly_name': '20.111111111111 Latest voltage C', + 'raw_value': 3.33, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.33', + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_d-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_latest_voltage_d', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage D', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.D', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_d-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.D', + 'friendly_name': '20.111111111111 Latest voltage D', + 'raw_value': 4.44, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_d', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.44', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_voltage_a', + '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 A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.A', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.A', + 'friendly_name': '20.111111111111 Voltage A', + 'raw_value': 1.1, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_voltage_b', + '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 B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.B', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.B', + 'friendly_name': '20.111111111111 Voltage B', + 'raw_value': 2.2, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_voltage_c', + '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 C', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.C', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.C', + 'friendly_name': '20.111111111111 Voltage C', + 'raw_value': 3.3, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.3', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_d-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_voltage_d', + '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 D', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.D', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_d-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.D', + 'friendly_name': '20.111111111111 Voltage D', + 'raw_value': 4.4, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_d', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.4', + }) +# --- # name: test_sensors[sensor.22_111111111111_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From f501b55aedbd1830475d815ad5c0fe394a9ab598 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Jan 2025 17:43:48 +0100 Subject: [PATCH 1251/2987] Fix KeyError for Shelly virtual number component (#136932) --- homeassistant/components/shelly/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 5f0567d034a..c4420783bbb 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -186,7 +186,7 @@ RPC_NUMBERS: Final = { mode_fn=lambda config: VIRTUAL_NUMBER_MODE_MAP.get( config["meta"]["ui"]["view"], NumberMode.BOX ), - step_fn=lambda config: config["meta"]["ui"]["step"], + step_fn=lambda config: config["meta"]["ui"].get("step"), # If the unit is not set, the device sends an empty string unit=lambda config: config["meta"]["ui"]["unit"] if config["meta"]["ui"]["unit"] From 3dc52774fc250e6437d54e84968b72c8377b837a Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Jan 2025 09:15:13 -0800 Subject: [PATCH 1252/2987] Don't log errors when raising a backup exception in Google Drive (#136916) --- homeassistant/components/google_drive/backup.py | 13 ++++--------- tests/components/google_drive/test_backup.py | 6 +++--- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py index 4c81f041c8b..73e5902f8f5 100644 --- a/homeassistant/components/google_drive/backup.py +++ b/homeassistant/components/google_drive/backup.py @@ -80,16 +80,14 @@ class GoogleDriveBackupAgent(BackupAgent): try: await self._client.async_upload_backup(open_stream, backup) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("Upload backup error: %s", err) - raise BackupAgentError("Failed to upload backup") from err + raise BackupAgentError(f"Failed to upload backup: {err}") from err async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" try: return await self._client.async_list_backups() except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("List backups error: %s", err) - raise BackupAgentError("Failed to list backups") from err + raise BackupAgentError(f"Failed to list backups: {err}") from err async def async_get_backup( self, @@ -121,9 +119,7 @@ class GoogleDriveBackupAgent(BackupAgent): stream = await self._client.async_download(file_id) return ChunkAsyncStreamIterator(stream) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("Download backup error: %s", err) - raise BackupAgentError("Failed to download backup") from err - _LOGGER.error("Download backup_id: %s not found", backup_id) + raise BackupAgentError(f"Failed to download backup: {err}") from err raise BackupAgentError("Backup not found") async def async_delete_backup( @@ -143,5 +139,4 @@ class GoogleDriveBackupAgent(BackupAgent): await self._client.async_delete(file_id) _LOGGER.debug("Deleted backup_id: %s", backup_id) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("Delete backup error: %s", err) - raise BackupAgentError("Failed to delete backup") from err + raise BackupAgentError(f"Failed to delete backup: {err}") from err diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 62b7930012c..7e455ebb535 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -141,7 +141,7 @@ async def test_agents_list_backups_fail( assert response["success"] assert response["result"]["backups"] == [] assert response["result"]["agent_errors"] == { - TEST_AGENT_ID: "Failed to list backups" + TEST_AGENT_ID: "Failed to list backups: some error" } @@ -381,7 +381,7 @@ async def test_agents_upload_fail( await hass.async_block_till_done() assert resp.status == 201 - assert "Upload backup error: some error" in caplog.text + assert "Failed to upload backup: some error" in caplog.text async def test_agents_delete( @@ -430,7 +430,7 @@ async def test_agents_delete_fail( assert response["success"] assert response["result"] == { - "agent_errors": {TEST_AGENT_ID: "Failed to delete backup"} + "agent_errors": {TEST_AGENT_ID: "Failed to delete backup: some error"} } From c3b0bc3e0db5f0faa0914eeca92ebe14ec4d98c6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Jan 2025 18:15:54 +0100 Subject: [PATCH 1253/2987] Show name of the backup agents in issue (#136925) * Show name of the backup agents in issue * Show name of the backup agents in issue * Update homeassistant/components/backup/manager.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/backup/manager.py | 6 +++++- tests/components/backup/test_manager.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index d1f27fa270b..1dbd8f8547d 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1166,7 +1166,11 @@ class BackupManager: learn_more_url="homeassistant://config/backup", severity=ir.IssueSeverity.WARNING, translation_key="automatic_backup_failed_upload_agents", - translation_placeholders={"failed_agents": ", ".join(agent_errors)}, + translation_placeholders={ + "failed_agents": ", ".join( + self.backup_agents[agent_id].name for agent_id in agent_errors + ) + }, ) async def async_can_decrypt_on_download( diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 69994028297..4a8d2360d3f 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -908,7 +908,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: { (DOMAIN, "automatic_backup_failed"): { "translation_key": "automatic_backup_failed_upload_agents", - "translation_placeholders": {"failed_agents": "test.remote"}, + "translation_placeholders": {"failed_agents": "remote"}, } }, ), From 6858f2a3d2deb6facce4815d514426dfb68e3e9e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Jan 2025 18:38:11 +0100 Subject: [PATCH 1254/2987] Update frontend to 20250130.0 (#136937) --- 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 f4e426485c8..b545026059c 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==20250129.0"] + "requirements": ["home-assistant-frontend==20250130.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6d9e8f43755..01cfc57f3a8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.14.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250129.0 +home-assistant-frontend==20250130.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5cea3cd444e..cdc710bc3c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250129.0 +home-assistant-frontend==20250130.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a6c6271c39a..ce31cb1dbc1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -975,7 +975,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250129.0 +home-assistant-frontend==20250130.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From f391438d0ad6e8c70403fa0b9319c1226d4e03a2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Jan 2025 21:32:10 -0500 Subject: [PATCH 1255/2987] Add start_conversation service to Assist Satellite (#134921) * Add start_conversation service to Assist Satellite * Fix tests * Implement start_conversation in voip * Update homeassistant/components/assist_satellite/entity.py --------- Co-authored-by: Michael Hansen --- .../components/assist_pipeline/pipeline.py | 1 + .../components/assist_satellite/__init__.py | 15 ++ .../components/assist_satellite/const.py | 3 + .../components/assist_satellite/entity.py | 59 +++++++- .../components/assist_satellite/icons.json | 3 + .../components/assist_satellite/services.yaml | 20 +++ .../components/assist_satellite/strings.json | 18 +++ .../components/voip/assist_satellite.py | 38 ++++-- homeassistant/helpers/service.py | 2 + tests/components/assist_satellite/conftest.py | 15 +- .../assist_satellite/test_entity.py | 128 ++++++++++++++++-- tests/components/voip/test_voip.py | 107 ++++++++++++++- 12 files changed, 384 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 9fdcc2bf690..1d320d79bf2 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1122,6 +1122,7 @@ class PipelineRun: context=user_input.context, language=user_input.language, agent_id=user_input.agent_id, + extra_system_prompt=user_input.extra_system_prompt, ) speech = conversation_result.response.speech.get("plain", {}).get( "speech", "" diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 47b0123a244..038ff517264 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -63,6 +63,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_internal_announce", [AssistSatelliteEntityFeature.ANNOUNCE], ) + component.async_register_entity_service( + "start_conversation", + vol.All( + cv.make_entity_service_schema( + { + vol.Optional("start_message"): str, + vol.Optional("start_media_id"): str, + vol.Optional("extra_system_prompt"): str, + } + ), + cv.has_at_least_one_key("start_message", "start_media_id"), + ), + "async_internal_start_conversation", + [AssistSatelliteEntityFeature.START_CONVERSATION], + ) hass.data[CONNECTION_TEST_DATA] = {} async_register_websocket_api(hass) hass.http.register_view(ConnectionTestView()) diff --git a/homeassistant/components/assist_satellite/const.py b/homeassistant/components/assist_satellite/const.py index 61ac7ecb39d..f7ac7e524b4 100644 --- a/homeassistant/components/assist_satellite/const.py +++ b/homeassistant/components/assist_satellite/const.py @@ -26,3 +26,6 @@ class AssistSatelliteEntityFeature(IntFlag): ANNOUNCE = 1 """Device supports remotely triggered announcements.""" + + START_CONVERSATION = 2 + """Device supports starting conversations.""" diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index e9a5d22c0d0..927229c9756 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -10,7 +10,7 @@ import logging import time from typing import Any, Final, Literal, final -from homeassistant.components import media_source, stt, tts +from homeassistant.components import conversation, media_source, stt, tts from homeassistant.components.assist_pipeline import ( OPTION_PREFERRED, AudioSettings, @@ -27,6 +27,7 @@ from homeassistant.components.tts import ( generate_media_source_id as tts_generate_media_source_id, ) from homeassistant.core import Context, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity from homeassistant.helpers.entity import EntityDescription @@ -117,6 +118,7 @@ class AssistSatelliteEntity(entity.Entity): _run_has_tts: bool = False _is_announcing = False + _extra_system_prompt: str | None = None _wake_word_intercept_future: asyncio.Future[str | None] | None = None _attr_tts_options: dict[str, Any] | None = None _pipeline_task: asyncio.Task | None = None @@ -216,6 +218,60 @@ class AssistSatelliteEntity(entity.Entity): """ raise NotImplementedError + async def async_internal_start_conversation( + self, + start_message: str | None = None, + start_media_id: str | None = None, + extra_system_prompt: str | None = None, + ) -> None: + """Start a conversation from the satellite. + + If start_media_id is not provided, message is synthesized to + audio with the selected pipeline. + + If start_media_id is provided, it is played directly. It is possible + to omit the message and the satellite will not show any text. + + Calls async_start_conversation. + """ + await self._cancel_running_pipeline() + + # The Home Assistant built-in agent doesn't support conversations. + pipeline = async_get_pipeline(self.hass, self._resolve_pipeline()) + if pipeline.conversation_engine == conversation.HOME_ASSISTANT_AGENT: + raise HomeAssistantError( + "Built-in conversation agent does not support starting conversations" + ) + + if start_message is None: + start_message = "" + + announcement = await self._resolve_announcement_media_id( + start_message, start_media_id + ) + + if self._is_announcing: + raise SatelliteBusyError + + self._is_announcing = True + # Provide our start info to the LLM so it understands context of incoming message + if extra_system_prompt is not None: + self._extra_system_prompt = extra_system_prompt + else: + self._extra_system_prompt = start_message or None + + try: + await self.async_start_conversation(announcement) + finally: + self._is_announcing = False + self._extra_system_prompt = None + + async def async_start_conversation( + self, start_announcement: AssistSatelliteAnnouncement + ) -> None: + """Start a conversation from the satellite.""" + raise NotImplementedError + async def async_accept_pipeline_from_satellite( self, audio_stream: AsyncIterable[bytes], @@ -302,6 +358,7 @@ class AssistSatelliteEntity(entity.Entity): ), start_stage=start_stage, end_stage=end_stage, + conversation_extra_system_prompt=self._extra_system_prompt, ), f"{self.entity_id}_pipeline", ) diff --git a/homeassistant/components/assist_satellite/icons.json b/homeassistant/components/assist_satellite/icons.json index a98c3aefc5b..1ed29541621 100644 --- a/homeassistant/components/assist_satellite/icons.json +++ b/homeassistant/components/assist_satellite/icons.json @@ -7,6 +7,9 @@ "services": { "announce": { "service": "mdi:bullhorn" + }, + "start_conversation": { + "service": "mdi:forum" } } } diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index e7fefc4705f..89a20ada6f3 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -14,3 +14,23 @@ announce: required: false selector: text: +start_conversation: + target: + entity: + domain: assist_satellite + supported_features: + - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + fields: + start_message: + required: false + example: "You left the lights on in the living room. Turn them off?" + selector: + text: + start_media_id: + required: false + selector: + text: + extra_system_prompt: + required: false + selector: + text: diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index 7f1426ef529..e83f4666b5d 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -25,6 +25,24 @@ "description": "The media ID to announce instead of using text-to-speech." } } + }, + "start_conversation": { + "name": "Start Conversation", + "description": "Start a conversation from a satellite.", + "fields": { + "start_message": { + "name": "Message", + "description": "The message to start with." + }, + "start_media_id": { + "name": "Media ID", + "description": "The media ID to start with instead of using text-to-speech." + }, + "extra_system_prompt": { + "name": "Extra system prompt", + "description": "Provide background information to the AI about the request." + } + } } } } diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 6cacdd79af4..1877b8c655c 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -90,7 +90,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol entity_description = AssistSatelliteEntityDescription(key="assist_satellite") _attr_translation_key = "assist_satellite" _attr_name = None - _attr_supported_features = AssistSatelliteEntityFeature.ANNOUNCE + _attr_supported_features = ( + AssistSatelliteEntityFeature.ANNOUNCE + | AssistSatelliteEntityFeature.START_CONVERSATION + ) def __init__( self, @@ -122,6 +125,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._check_announcement_ended_task: asyncio.Task | None = None self._last_chunk_time: float | None = None self._rtp_port: int | None = None + self._run_pipeline_after_announce: bool = False @property def pipeline_entity_id(self) -> str | None: @@ -172,7 +176,17 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol Plays announcement in a loop, blocking until the caller hangs up. """ + await self._do_announce(announcement, run_pipeline_after=False) + + async def _do_announce( + self, announcement: AssistSatelliteAnnouncement, run_pipeline_after: bool + ) -> None: + """Announce media on the satellite. + + Optionally run a voice pipeline after the announcement has finished. + """ self._announcement_future = asyncio.Future() + self._run_pipeline_after_announce = run_pipeline_after if self._rtp_port is None: # Choose random port for RTP @@ -232,12 +246,6 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol """ while self._announcement is not None: current_time = time.monotonic() - _LOGGER.debug( - "%s %s %s", - self._last_chunk_time, - current_time, - self._announcment_start_time, - ) if (self._last_chunk_time is None) and ( (current_time - self._announcment_start_time) > _ANNOUNCEMENT_RING_TIMEOUT @@ -263,6 +271,12 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol await asyncio.sleep(_ANNOUNCEMENT_HANGUP_SEC / 2) + async def async_start_conversation( + self, start_announcement: AssistSatelliteAnnouncement + ) -> None: + """Start a conversation from the satellite.""" + await self._do_announce(start_announcement, run_pipeline_after=True) + # ------------------------------------------------------------------------- # VoIP # ------------------------------------------------------------------------- @@ -347,7 +361,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol try: await asyncio.sleep(_ANNOUNCEMENT_BEFORE_DELAY) await self._send_tts(announcement.original_media_id, wait_for_tone=False) - await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY) + + if not self._run_pipeline_after_announce: + # Delay before looping announcement + await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY) except Exception: _LOGGER.exception("Unexpected error while playing announcement") raise @@ -355,6 +372,11 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._run_pipeline_task = None _LOGGER.debug("Announcement finished") + if self._run_pipeline_after_announce: + # Clear announcement to allow pipeline to run + self._announcement = None + self._announcement_future.set_result(None) + def _clear_audio_queue(self) -> None: """Ensure audio queue is empty.""" while not self._audio_queue.empty(): diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 255739c0059..4873d935537 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -88,6 +88,7 @@ def _base_components() -> dict[str, ModuleType]: # pylint: disable-next=import-outside-toplevel from homeassistant.components import ( alarm_control_panel, + assist_satellite, calendar, camera, climate, @@ -108,6 +109,7 @@ def _base_components() -> dict[str, ModuleType]: return { "alarm_control_panel": alarm_control_panel, + "assist_satellite": assist_satellite, "calendar": calendar, "camera": camera, "climate": climate, diff --git a/tests/components/assist_satellite/conftest.py b/tests/components/assist_satellite/conftest.py index d75cbd072e0..0cc0e94e149 100644 --- a/tests/components/assist_satellite/conftest.py +++ b/tests/components/assist_satellite/conftest.py @@ -40,6 +40,8 @@ def mock_tts(mock_tts_cache_dir: pathlib.Path) -> None: class MockAssistSatellite(AssistSatelliteEntity): """Mock Assist Satellite Entity.""" + _attr_tts_options = {"test-option": "test-value"} + def __init__(self, name: str, features: AssistSatelliteEntityFeature) -> None: """Initialize the mock entity.""" self._attr_unique_id = ulid_hex() @@ -67,6 +69,7 @@ class MockAssistSatellite(AssistSatelliteEntity): active_wake_words=["1234"], max_active_wake_words=1, ) + self.start_conversations = [] def on_pipeline_event(self, event: PipelineEvent) -> None: """Handle pipeline events.""" @@ -87,11 +90,21 @@ class MockAssistSatellite(AssistSatelliteEntity): """Set the current satellite configuration.""" self.config = config + async def async_start_conversation( + self, start_announcement: AssistSatelliteConfiguration + ) -> None: + """Start a conversation from the satellite.""" + self.start_conversations.append((self._extra_system_prompt, start_announcement)) + @pytest.fixture def entity() -> MockAssistSatellite: """Mock Assist Satellite Entity.""" - return MockAssistSatellite("Test Entity", AssistSatelliteEntityFeature.ANNOUNCE) + return MockAssistSatellite( + "Test Entity", + AssistSatelliteEntityFeature.ANNOUNCE + | AssistSatelliteEntityFeature.START_CONVERSATION, + ) @pytest.fixture diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index c3464beac97..46facb80844 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -25,11 +25,24 @@ from homeassistant.components.assist_satellite.entity import AssistSatelliteStat from homeassistant.components.media_source import PlayMedia from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from . import ENTITY_ID from .conftest import MockAssistSatellite +@pytest.fixture(autouse=True) +async def set_pipeline_tts(hass: HomeAssistant, init_components: ConfigEntry) -> None: + """Set up a pipeline with a TTS engine.""" + await async_update_pipeline( + hass, + async_get_pipeline(hass), + tts_engine="tts.mock_entity", + tts_language="en", + tts_voice="test-voice", + ) + + async def test_entity_state( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: @@ -64,7 +77,7 @@ async def test_entity_state( assert kwargs["stt_stream"] is audio_stream assert kwargs["pipeline_id"] is None assert kwargs["device_id"] is entity.device_entry.id - assert kwargs["tts_audio_output"] is None + assert kwargs["tts_audio_output"] == {"test-option": "test-value"} assert kwargs["wake_word_phrase"] is None assert kwargs["audio_settings"] == AudioSettings( silence_seconds=vad.VadSensitivity.to_seconds(vad.VadSensitivity.DEFAULT) @@ -200,24 +213,12 @@ async def test_announce( expected_params: tuple[str, str], ) -> None: """Test announcing on a device.""" - await async_update_pipeline( - hass, - async_get_pipeline(hass), - tts_engine="tts.mock_entity", - tts_language="en", - tts_voice="test-voice", - ) - - entity._attr_tts_options = {"test-option": "test-value"} - original_announce = entity.async_announce - announce_started = asyncio.Event() async def async_announce(announcement): # Verify state change assert entity.state == AssistSatelliteState.RESPONDING await original_announce(announcement) - announce_started.set() def tts_generate_media_source_id( hass: HomeAssistant, @@ -475,3 +476,104 @@ async def test_vad_sensitivity_entity_not_found( with pytest.raises(RuntimeError): await entity.async_accept_pipeline_from_satellite(audio_stream) + + +@pytest.mark.parametrize( + ("service_data", "expected_params"), + [ + ( + { + "start_message": "Hello", + "extra_system_prompt": "Better system prompt", + }, + ( + "Better system prompt", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://generated", + media_id_source="tts", + ), + ), + ), + ( + { + "start_message": "Hello", + "start_media_id": "media-source://given", + }, + ( + "Hello", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://given", + media_id_source="media_id", + ), + ), + ), + ( + {"start_media_id": "http://example.com/given.mp3"}, + ( + None, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/given.mp3", + original_media_id="http://example.com/given.mp3", + media_id_source="url", + ), + ), + ), + ], +) +async def test_start_conversation( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + service_data: dict, + expected_params: tuple[str, str], +) -> None: + """Test starting a conversation on a device.""" + await async_update_pipeline( + hass, + async_get_pipeline(hass), + conversation_engine="conversation.some_llm", + ) + + with ( + patch( + "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + return_value="media-source://generated", + ), + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=PlayMedia( + url="https://www.home-assistant.io/resolved.mp3", + mime_type="audio/mp3", + ), + ), + ): + await hass.services.async_call( + "assist_satellite", + "start_conversation", + service_data, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) + + assert entity.start_conversations[0] == expected_params + + +async def test_start_conversation_reject_builtin_agent( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, +) -> None: + """Test starting a conversation on a device.""" + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "assist_satellite", + "start_conversation", + {"start_message": "Hey!"}, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 306857a1a44..442f4a62392 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -887,7 +887,8 @@ async def test_announce( # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - await announce_task + async with asyncio.timeout(1): + await announce_task mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) @@ -938,7 +939,8 @@ async def test_voip_id_is_ip_address( # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - await announce_task + async with asyncio.timeout(1): + await announce_task mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) @@ -981,3 +983,104 @@ async def test_announce_timeout( satellite.transport = Mock() with pytest.raises(TimeoutError): await satellite.async_announce(announcement) + + +@pytest.mark.usefixtures("socket_enabled") +async def test_start_conversation( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test start conversation.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but "outgoing_call" is not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + + tts_sent = asyncio.Event() + + async def _send_tts(*args, **kwargs): + tts_sent.set() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context: Context, + *args, + device_id: str | None, + tts_audio_output: str | dict[str, Any] | None, + **kwargs, + ): + event_callback = kwargs["event_callback"] + + # Fake tts result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_START, + data={ + "engine": "test", + "language": hass.config.language, + "voice": "test", + "tts_input": "fake-text", + }, + ) + ) + + # Proceed with media output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {"media_id": _MEDIA_ID}}, + ) + ) + + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.RUN_END + ) + ) + + with ( + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", + new=_send_tts, + ), + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + ): + satellite.transport = Mock() + conversation_task = hass.async_create_background_task( + satellite.async_start_conversation(announcement), "voip_start_conversation" + ) + await asyncio.sleep(0) + mock_protocol.outgoing_call.assert_called_once() + + # Trigger announcement and wait for it to finish + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + await tts_sent.wait() + + tts_sent.clear() + + # Trigger pipeline + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + # Wait for TTS + await tts_sent.wait() + await conversation_task From 55ac0b0f3760b09feee55ee6780de74d947a7d95 Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 30 Jan 2025 12:01:39 +1100 Subject: [PATCH 1256/2987] Fix loading of SMLIGHT integration when no internet is available (#136497) * Don't fail to load integration if internet unavailable * Add test case for no internet * Also test we recover after internet returns --- .../components/smlight/coordinator.py | 16 ++++--- tests/components/smlight/test_init.py | 44 ++++++++++++++++++- tests/components/smlight/test_update.py | 2 +- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 5b38ec4a89e..6be36439e9f 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -144,11 +144,15 @@ class SmFirmwareUpdateCoordinator(SmBaseDataUpdateCoordinator[SmFwData]): async def _internal_update_data(self) -> SmFwData: """Fetch data from the SMLIGHT device.""" info = await self.client.get_info() + esp_firmware = None + zb_firmware = None - return SmFwData( - info=info, - esp_firmware=await self.client.get_firmware_version(info.fw_channel), - zb_firmware=await self.client.get_firmware_version( + try: + esp_firmware = await self.client.get_firmware_version(info.fw_channel) + zb_firmware = await self.client.get_firmware_version( info.fw_channel, device=info.model, mode="zigbee" - ), - ) + ) + except SmlightConnectionError as err: + self.async_set_update_error(err) + + return SmFwData(info=info, esp_firmware=esp_firmware, zb_firmware=zb_firmware) diff --git a/tests/components/smlight/test_init.py b/tests/components/smlight/test_init.py index afc53932fb0..d0c5e494ae8 100644 --- a/tests/components/smlight/test_init.py +++ b/tests/components/smlight/test_init.py @@ -8,9 +8,14 @@ from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError, Smlig import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smlight.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.smlight.const import ( + DOMAIN, + SCAN_FIRMWARE_INTERVAL, + SCAN_INTERVAL, +) +from homeassistant.components.update import ATTR_INSTALLED_VERSION from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.issue_registry import IssueRegistry @@ -73,6 +78,41 @@ async def test_async_setup_missing_credentials( assert progress[0]["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" +async def test_async_setup_no_internet( + hass: HomeAssistant, + mock_config_entry_host: MockConfigEntry, + mock_smlight_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we still load integration when no internet is available.""" + mock_smlight_client.get_firmware_version.side_effect = SmlightConnectionError + + await setup_integration(hass, mock_config_entry_host) + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_UNKNOWN + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_UNKNOWN + + mock_smlight_client.get_firmware_version.side_effect = None + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_ON + assert entity.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" + + @pytest.mark.parametrize("error", [SmlightConnectionError, SmlightAuthError]) async def test_update_failed( hass: HomeAssistant, diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index 0bb2e34d7ca..4fca7369116 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -81,7 +81,7 @@ async def test_update_setup( mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test setup of SMLIGHT switches.""" + """Test setup of SMLIGHT update entities.""" entry = await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) From ff64e5a312e113c77d83a8a6155cbac28ce9e3e6 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 30 Jan 2025 03:57:36 +0100 Subject: [PATCH 1257/2987] Bump ZHA to 0.0.47 (#136883) --- 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 fa8bab409c9..6a42bc986e9 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.46"], + "requirements": ["zha==0.0.47"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 9e6da1045a4..a6b56e80d44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3131,7 +3131,7 @@ zeroconf==0.141.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.46 +zha==0.0.47 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 76ae46099c2..4e6d43a6b96 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2520,7 +2520,7 @@ zeroconf==0.141.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.46 +zha==0.0.47 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 From 8babdc0b717a5f6bac127545d602eb8a1873590b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 30 Jan 2025 01:07:55 -0800 Subject: [PATCH 1258/2987] Bump nest to 7.1.1 (#136888) --- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index f7e78b2d538..cd961276082 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/nest", "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], - "requirements": ["google-nest-sdm==7.1.0"] + "requirements": ["google-nest-sdm==7.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a6b56e80d44..533a77d4981 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1033,7 +1033,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.1.0 +google-nest-sdm==7.1.1 # homeassistant.components.google_photos google-photos-library-api==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e6d43a6b96..4491e64d808 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -883,7 +883,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.1.0 +google-nest-sdm==7.1.1 # homeassistant.components.google_photos google-photos-library-api==0.12.1 From 0764aca2f13a13f17151bbbf84f4689d9fd31ddc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 11:09:31 +0100 Subject: [PATCH 1259/2987] Poll supervisor job state when creating or restoring a backup (#136891) * Poll supervisor job state when creating or restoring a backup * Update tests * Add tests for create and restore jobs finishing early --- homeassistant/components/hassio/backup.py | 8 +- tests/components/hassio/test_backup.py | 180 ++++++++++++++++------ 2 files changed, 136 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 6b63ab92d5c..b81605264be 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -350,8 +350,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): backup_id = data.get("reference") backup_complete.set() + unsub = self._async_listen_job_events(backup.job_id, on_job_progress) try: - unsub = self._async_listen_job_events(backup.job_id, on_job_progress) + await self._get_job_state(backup.job_id, on_job_progress) await backup_complete.wait() finally: unsub() @@ -506,12 +507,13 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): @callback def on_job_progress(data: Mapping[str, Any]) -> None: - """Handle backup progress.""" + """Handle backup restore progress.""" if data.get("done") is True: restore_complete.set() + unsub = self._async_listen_job_events(job.job_id, on_job_progress) try: - unsub = self._async_listen_job_events(job.job_id, on_job_progress) + await self._get_job_state(job.job_id, on_job_progress) await restore_complete.wait() finally: unsub() diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 49360783517..f7379b81a14 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -301,6 +301,28 @@ TEST_BACKUP_DETAILS_5 = supervisor_backups.BackupComplete( type=TEST_BACKUP_5.type, ) +TEST_JOB_ID = "d17bd02be1f0437fa7264b16d38f700e" +TEST_JOB_NOT_DONE = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID(TEST_JOB_ID), + progress=0.0, + stage="copy_additional_locations", + done=False, + errors=[], + child_jobs=[], +) +TEST_JOB_DONE = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID(TEST_JOB_ID), + progress=0.0, + stage="copy_additional_locations", + done=True, + errors=[], + child_jobs=[], +) + @pytest.fixture(autouse=True) def fixture_supervisor_environ() -> Generator[None]: @@ -813,8 +835,9 @@ async def test_reader_writer_create( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -836,7 +859,7 @@ async def test_reader_writer_create( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( expected_supervisor_options @@ -847,7 +870,7 @@ async def test_reader_writer_create( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -877,6 +900,66 @@ async def test_reader_writer_create( assert response["event"] == {"manager_state": "idle"} +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_create_job_done( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test generating a backup, and backup job finishes early.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": TEST_JOB_ID} + + supervisor_client.backups.partial_backup.assert_called_once_with( + DEFAULT_BACKUP_OPTIONS + ) + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + supervisor_client.backups.download_backup.assert_not_called() + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + @pytest.mark.usefixtures("hassio_client") @pytest.mark.parametrize( ( @@ -1006,7 +1089,7 @@ async def test_reader_writer_create_per_agent_encryption( for i in range(1, 4) ], ) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = replace( TEST_BACKUP_DETAILS, locations=create_locations, @@ -1018,6 +1101,7 @@ async def test_reader_writer_create_per_agent_encryption( for location in create_locations }, ) + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE supervisor_client.mounts.info.return_value = mounts assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -1050,7 +1134,7 @@ async def test_reader_writer_create_per_agent_encryption( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( replace( @@ -1065,7 +1149,7 @@ async def test_reader_writer_create_per_agent_encryption( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1176,7 +1260,8 @@ async def test_reader_writer_create_missing_reference_error( ) -> None: """Test missing reference error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -1197,7 +1282,7 @@ async def test_reader_writer_create_missing_reference_error( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -1206,7 +1291,7 @@ async def test_reader_writer_create_missing_reference_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123"}, + "data": {"done": True, "uuid": TEST_JOB_ID}, }, } ) @@ -1248,8 +1333,9 @@ async def test_reader_writer_create_download_remove_error( ) -> None: """Test download and remove error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE method_mock = getattr(supervisor_client.backups, method) method_mock.side_effect = exception @@ -1282,7 +1368,7 @@ async def test_reader_writer_create_download_remove_error( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -1291,7 +1377,7 @@ async def test_reader_writer_create_download_remove_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1334,8 +1420,9 @@ async def test_reader_writer_create_info_error( ) -> None: """Test backup info error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.side_effect = exception + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1366,7 +1453,7 @@ async def test_reader_writer_create_info_error( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -1375,7 +1462,7 @@ async def test_reader_writer_create_info_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1408,8 +1495,9 @@ async def test_reader_writer_create_remote_backup( ) -> None: """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1440,7 +1528,7 @@ async def test_reader_writer_create_remote_backup( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( replace(DEFAULT_BACKUP_OPTIONS, location=[LOCATION_CLOUD_BACKUP]), @@ -1451,7 +1539,7 @@ async def test_reader_writer_create_remote_backup( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1510,7 +1598,7 @@ async def test_reader_writer_create_wrong_parameters( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS await client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1614,17 +1702,33 @@ async def test_agent_receive_remote_backup( ) +@pytest.mark.parametrize( + ("get_job_result", "supervisor_events"), + [ + ( + TEST_JOB_NOT_DONE, + [{"event": "job", "data": {"done": True, "uuid": TEST_JOB_ID}}], + ), + ( + TEST_JOB_DONE, + [], + ), + ], +) @pytest.mark.usefixtures("hassio_client", "setup_integration") async def test_reader_writer_restore( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + get_job_result: supervisor_jobs.Job, + supervisor_events: list[dict[str, Any]], ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = "abc123" + supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = get_job_result await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -1657,17 +1761,10 @@ async def test_reader_writer_restore( ), ) - await client.send_json_auto_id( - { - "type": "supervisor/event", - "data": { - "event": "job", - "data": {"done": True, "uuid": "abc123"}, - }, - } - ) - response = await client.receive_json() - assert response["success"] + for event in supervisor_events: + await client.send_json_auto_id({"type": "supervisor/event", "data": event}) + response = await client.receive_json() + assert response["success"] response = await client.receive_json() assert response["event"] == { @@ -1818,21 +1915,9 @@ async def test_restore_progress_after_restart( ) -> None: """Test restore backup progress after restart.""" - supervisor_client.jobs.get_job.return_value = supervisor_jobs.Job( - name="backup_manager_partial_backup", - reference="1ef41507", - uuid=UUID("d17bd02be1f0437fa7264b16d38f700e"), - progress=0.0, - stage="copy_additional_locations", - done=True, - errors=[], - child_jobs=[], - ) + supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE - with patch.dict( - os.environ, - MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, - ): + with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) client = await hass_ws_client(hass) @@ -1860,10 +1945,7 @@ async def test_restore_progress_after_restart_unknown_job( supervisor_client.jobs.get_job.side_effect = SupervisorError - with patch.dict( - os.environ, - MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, - ): + with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) client = await hass_ws_client(hass) From 5e646a3cb69747b85ebc46f0a8fdd7537902ea5c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 30 Jan 2025 11:46:27 +0100 Subject: [PATCH 1260/2987] Add missing discovery string from onewire (#136892) --- homeassistant/components/onewire/config_flow.py | 1 + homeassistant/components/onewire/strings.json | 3 +++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index e40e99d0903..8a5623772f7 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -147,6 +147,7 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="discovery_confirm", + description_placeholders={"host": self._discovery_data[CONF_HOST]}, errors=errors, ) diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 9613a927f8d..8f46369a70b 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -8,6 +8,9 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { + "discovery_confirm": { + "description": "Do you want to set up OWServer from {host}?" + }, "reconfigure": { "data": { "host": "[%key:common::config_flow::data::host%]", From aed779172d90c55a4435558a4678fff393eeddb8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 11:09:07 +0100 Subject: [PATCH 1261/2987] Ignore dangling symlinks when restoring backup (#136893) --- homeassistant/backup_restore.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py index 9287aa2bf1b..4d309469017 100644 --- a/homeassistant/backup_restore.py +++ b/homeassistant/backup_restore.py @@ -146,6 +146,7 @@ def _extract_backup( config_dir, dirs_exist_ok=True, ignore=shutil.ignore_patterns(*(keep)), + ignore_dangling_symlinks=True, ) elif restore_content.restore_database: for entry in KEEP_DATABASE: From b300fb1fabc6163e62847c71afe6172f52cc48ce Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 15:25:16 +0100 Subject: [PATCH 1262/2987] Fix handling of renamed backup files in the core writer (#136898) * Fix handling of renamed backup files in the core writer * Adjust mocking * Raise BackupAgentError instead of KeyError in get_backup_path * Add specific error indicating backup not found * Fix tests * Ensure backups are loaded * Fix tests --- homeassistant/components/backup/agent.py | 13 ++- homeassistant/components/backup/backup.py | 41 ++++++--- homeassistant/components/backup/manager.py | 32 +++---- tests/components/backup/common.py | 17 +++- tests/components/backup/conftest.py | 10 ++- .../backup/snapshots/test_backup.ambr | 33 +++++-- tests/components/backup/test_backup.py | 50 ++++++++--- tests/components/backup/test_http.py | 29 +++--- tests/components/backup/test_manager.py | 90 +++++++++++++++---- 9 files changed, 234 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index 33656b6edcc..297ccd6f685 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -27,6 +27,12 @@ class BackupAgentUnreachableError(BackupAgentError): _message = "The backup agent is unreachable." +class BackupNotFound(BackupAgentError): + """Raised when a backup is not found.""" + + error_code = "backup_not_found" + + class BackupAgent(abc.ABC): """Backup agent interface.""" @@ -94,11 +100,16 @@ class LocalBackupAgent(BackupAgent): @abc.abstractmethod def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup. + """Return the local path to an existing backup. The method should return the path to the backup file with the specified id. + Raises BackupAgentError if the backup does not exist. """ + @abc.abstractmethod + def get_new_backup_path(self, backup: AgentBackup) -> Path: + """Return the local path to a new backup.""" + class BackupAgentPlatformProtocol(Protocol): """Define the format of backup platforms which implement backup agents.""" diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index 3f60bd0b88e..c76b50b5935 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -11,7 +11,7 @@ from typing import Any from homeassistant.core import HomeAssistant from homeassistant.helpers.hassio import is_hassio -from .agent import BackupAgent, LocalBackupAgent +from .agent import BackupAgent, BackupNotFound, LocalBackupAgent from .const import DOMAIN, LOGGER from .models import AgentBackup from .util import read_backup @@ -39,7 +39,7 @@ class CoreLocalBackupAgent(LocalBackupAgent): super().__init__() self._hass = hass self._backup_dir = Path(hass.config.path("backups")) - self._backups: dict[str, AgentBackup] = {} + self._backups: dict[str, tuple[AgentBackup, Path]] = {} self._loaded_backups = False async def _load_backups(self) -> None: @@ -49,13 +49,13 @@ class CoreLocalBackupAgent(LocalBackupAgent): self._backups = backups self._loaded_backups = True - def _read_backups(self) -> dict[str, AgentBackup]: + def _read_backups(self) -> dict[str, tuple[AgentBackup, Path]]: """Read backups from disk.""" - backups: dict[str, AgentBackup] = {} + backups: dict[str, tuple[AgentBackup, Path]] = {} for backup_path in self._backup_dir.glob("*.tar"): try: backup = read_backup(backup_path) - backups[backup.backup_id] = backup + backups[backup.backup_id] = (backup, backup_path) except (OSError, TarError, json.JSONDecodeError, KeyError) as err: LOGGER.warning("Unable to read backup %s: %s", backup_path, err) return backups @@ -76,13 +76,13 @@ class CoreLocalBackupAgent(LocalBackupAgent): **kwargs: Any, ) -> None: """Upload a backup.""" - self._backups[backup.backup_id] = backup + self._backups[backup.backup_id] = (backup, self.get_new_backup_path(backup)) async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" if not self._loaded_backups: await self._load_backups() - return list(self._backups.values()) + return [backup for backup, _ in self._backups.values()] async def async_get_backup( self, @@ -93,10 +93,10 @@ class CoreLocalBackupAgent(LocalBackupAgent): if not self._loaded_backups: await self._load_backups() - if not (backup := self._backups.get(backup_id)): + if backup_id not in self._backups: return None - backup_path = self.get_backup_path(backup_id) + backup, backup_path = self._backups[backup_id] if not await self._hass.async_add_executor_job(backup_path.exists): LOGGER.debug( ( @@ -112,15 +112,28 @@ class CoreLocalBackupAgent(LocalBackupAgent): return backup def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup.""" - return self._backup_dir / f"{backup_id}.tar" + """Return the local path to an existing backup. + + Raises BackupAgentError if the backup does not exist. + """ + try: + return self._backups[backup_id][1] + except KeyError as err: + raise BackupNotFound(f"Backup {backup_id} does not exist") from err + + def get_new_backup_path(self, backup: AgentBackup) -> Path: + """Return the local path to a new backup.""" + return self._backup_dir / f"{backup.backup_id}.tar" async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: """Delete a backup file.""" - if await self.async_get_backup(backup_id) is None: - return + if not self._loaded_backups: + await self._load_backups() - backup_path = self.get_backup_path(backup_id) + try: + backup_path = self.get_backup_path(backup_id) + except BackupNotFound: + return await self._hass.async_add_executor_job(backup_path.unlink, True) LOGGER.debug("Deleted backup located at %s", backup_path) self._backups.pop(backup_id) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index fc56505e343..d1f27fa270b 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1346,10 +1346,24 @@ class CoreBackupReaderWriter(BackupReaderWriter): if agent_config and not agent_config.protected: password = None + backup = AgentBackup( + addons=[], + backup_id=backup_id, + database_included=include_database, + date=date_str, + extra_metadata=extra_metadata, + folders=[], + homeassistant_included=True, + homeassistant_version=HAVERSION, + name=backup_name, + protected=password is not None, + size=0, + ) + local_agent_tar_file_path = None if self._local_agent_id in agent_ids: local_agent = manager.local_backup_agents[self._local_agent_id] - local_agent_tar_file_path = local_agent.get_backup_path(backup_id) + local_agent_tar_file_path = local_agent.get_new_backup_path(backup) on_progress( CreateBackupEvent( @@ -1391,19 +1405,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): # ValueError from json_bytes raise BackupReaderWriterError(str(err)) from err else: - backup = AgentBackup( - addons=[], - backup_id=backup_id, - database_included=include_database, - date=date_str, - extra_metadata=extra_metadata, - folders=[], - homeassistant_included=True, - homeassistant_version=HAVERSION, - name=backup_name, - protected=password is not None, - size=size_in_bytes, - ) + backup = replace(backup, size=size_in_bytes) async_add_executor_job = self._hass.async_add_executor_job @@ -1517,7 +1519,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): manager = self._hass.data[DATA_MANAGER] if self._local_agent_id in agent_ids: local_agent = manager.local_backup_agents[self._local_agent_id] - tar_file_path = local_agent.get_backup_path(backup.backup_id) + tar_file_path = local_agent.get_new_backup_path(backup) await async_add_executor_job(make_backup_dir, tar_file_path.parent) await async_add_executor_job(shutil.move, temp_file, tar_file_path) else: diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index 97236ee995d..a7888dbd08c 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Callable, Coroutine +from collections.abc import AsyncIterator, Callable, Coroutine, Iterable from pathlib import Path from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch @@ -52,10 +52,17 @@ TEST_BACKUP_DEF456 = AgentBackup( protected=False, size=1, ) +TEST_BACKUP_PATH_DEF456 = Path("custom_def456.tar") TEST_DOMAIN = "test" +async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: + """Convert an iterable to an async iterator.""" + for i in iterable: + yield i + + class BackupAgentTest(BackupAgent): """Test backup agent.""" @@ -162,7 +169,13 @@ async def setup_backup_integration( if with_hassio and agent_id == LOCAL_AGENT_ID: continue agent = hass.data[DATA_MANAGER].backup_agents[agent_id] - agent._backups = {backups.backup_id: backups for backups in agent_backups} + + async def open_stream() -> AsyncIterator[bytes]: + """Open a stream.""" + return aiter_from_iter((b"backup data",)) + + 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 diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index bef48498ede..d0d9ac7e0e1 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -13,7 +13,7 @@ from homeassistant.components.backup import DOMAIN from homeassistant.components.backup.manager import NewBackup, WrittenBackup from homeassistant.core import HomeAssistant -from .common import TEST_BACKUP_PATH_ABC123 +from .common import TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456 from tests.common import get_fixture_path @@ -38,10 +38,14 @@ def mocked_tarfile_fixture() -> Generator[Mock]: @pytest.fixture(name="path_glob") -def path_glob_fixture() -> Generator[MagicMock]: +def path_glob_fixture(hass: HomeAssistant) -> Generator[MagicMock]: """Mock path glob.""" with patch( - "pathlib.Path.glob", return_value=[TEST_BACKUP_PATH_ABC123] + "pathlib.Path.glob", + return_value=[ + Path(hass.config.path()) / "backups" / TEST_BACKUP_PATH_ABC123, + Path(hass.config.path()) / "backups" / TEST_BACKUP_PATH_DEF456, + ], ) as path_glob: yield path_glob diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index 032eb7ac537..68b00632a6b 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_delete_backup[found_backups0-True-1] +# name: test_delete_backup[found_backups0-abc123-1-unlink_path0] dict({ 'id': 1, 'result': dict({ @@ -10,7 +10,7 @@ 'type': 'result', }) # --- -# name: test_delete_backup[found_backups1-False-0] +# name: test_delete_backup[found_backups1-def456-1-unlink_path1] dict({ 'id': 1, 'result': dict({ @@ -21,7 +21,7 @@ 'type': 'result', }) # --- -# name: test_delete_backup[found_backups2-True-0] +# name: test_delete_backup[found_backups2-abc123-0-None] dict({ 'id': 1, 'result': dict({ @@ -32,7 +32,7 @@ 'type': 'result', }) # --- -# name: test_load_backups[None] +# name: test_load_backups[mock_read_backup] dict({ 'id': 1, 'result': dict({ @@ -47,7 +47,7 @@ 'type': 'result', }) # --- -# name: test_load_backups[None].1 +# name: test_load_backups[mock_read_backup].1 dict({ 'id': 2, 'result': dict({ @@ -82,6 +82,29 @@ 'name': 'Test', 'with_automatic_settings': True, }), + dict({ + 'addons': list([ + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 1, + }), + }), + 'backup_id': 'def456', + 'database_included': False, + 'date': '1980-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test 2', + 'with_automatic_settings': None, + }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index 02252ef6fa5..ce34c51c105 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -12,21 +12,35 @@ from unittest.mock import MagicMock, mock_open, patch import pytest from syrupy import SnapshotAssertion -from homeassistant.components.backup import DOMAIN +from homeassistant.components.backup import DOMAIN, AgentBackup from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .common import TEST_BACKUP_ABC123, TEST_BACKUP_PATH_ABC123 +from .common import ( + TEST_BACKUP_ABC123, + TEST_BACKUP_DEF456, + TEST_BACKUP_PATH_ABC123, + TEST_BACKUP_PATH_DEF456, +) from tests.typing import ClientSessionGenerator, WebSocketGenerator +def mock_read_backup(backup_path: Path) -> AgentBackup: + """Mock read backup.""" + mock_backups = { + "abc123": TEST_BACKUP_ABC123, + "custom_def456": TEST_BACKUP_DEF456, + } + return mock_backups[backup_path.stem] + + @pytest.fixture(name="read_backup") def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]: """Mock read backup.""" with patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ) as read_backup: yield read_backup @@ -34,7 +48,7 @@ def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]: @pytest.mark.parametrize( "side_effect", [ - None, + mock_read_backup, OSError("Boom"), TarError("Boom"), json.JSONDecodeError("Boom", "test", 1), @@ -94,11 +108,21 @@ async def test_upload( @pytest.mark.usefixtures("read_backup") @pytest.mark.parametrize( - ("found_backups", "backup_exists", "unlink_calls"), + ("found_backups", "backup_id", "unlink_calls", "unlink_path"), [ - ([TEST_BACKUP_PATH_ABC123], True, 1), - ([TEST_BACKUP_PATH_ABC123], False, 0), - (([], True, 0)), + ( + [TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456], + TEST_BACKUP_ABC123.backup_id, + 1, + TEST_BACKUP_PATH_ABC123, + ), + ( + [TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456], + TEST_BACKUP_DEF456.backup_id, + 1, + TEST_BACKUP_PATH_DEF456, + ), + (([], TEST_BACKUP_ABC123.backup_id, 0, None)), ], ) async def test_delete_backup( @@ -108,8 +132,9 @@ async def test_delete_backup( snapshot: SnapshotAssertion, path_glob: MagicMock, found_backups: list[Path], - backup_exists: bool, + backup_id: str, unlink_calls: int, + unlink_path: Path | None, ) -> None: """Test delete backup.""" assert await async_setup_component(hass, DOMAIN, {}) @@ -118,12 +143,13 @@ async def test_delete_backup( path_glob.return_value = found_backups with ( - patch("pathlib.Path.exists", return_value=backup_exists), - patch("pathlib.Path.unlink") as unlink, + patch("pathlib.Path.unlink", autospec=True) as unlink, ): await client.send_json_auto_id( - {"type": "backup/delete", "backup_id": TEST_BACKUP_ABC123.backup_id} + {"type": "backup/delete", "backup_id": backup_id} ) assert await client.receive_json() == snapshot assert unlink.call_count == unlink_calls + for call in unlink.mock_calls: + assert call.args[0] == unlink_path diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index ee6803655d5..aac39c04d31 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -1,7 +1,7 @@ """Tests for the Backup integration.""" import asyncio -from collections.abc import AsyncIterator, Iterable +from collections.abc import AsyncIterator from io import BytesIO, StringIO import json import tarfile @@ -15,7 +15,12 @@ from homeassistant.components.backup import AddonInfo, AgentBackup, Folder from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.core import HomeAssistant -from .common import TEST_BACKUP_ABC123, BackupAgentTest, setup_backup_integration +from .common import ( + TEST_BACKUP_ABC123, + BackupAgentTest, + aiter_from_iter, + setup_backup_integration, +) from tests.common import MockUser, get_fixture_path from tests.typing import ClientSessionGenerator @@ -35,6 +40,9 @@ async def test_downloading_local_backup( "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", return_value=TEST_BACKUP_ABC123, ), + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.get_backup_path", + ), patch("pathlib.Path.exists", return_value=True), patch( "homeassistant.components.backup.http.FileResponse", @@ -73,9 +81,14 @@ async def test_downloading_local_encrypted_backup_file_not_found( await setup_backup_integration(hass) client = await hass_client() - with patch( - "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", - return_value=TEST_BACKUP_ABC123, + with ( + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", + return_value=TEST_BACKUP_ABC123, + ), + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.get_backup_path", + ), ): resp = await client.get( "/api/backup/download/abc123?agent_id=backup.local&password=blah" @@ -93,12 +106,6 @@ async def test_downloading_local_encrypted_backup( await _test_downloading_encrypted_backup(hass_client, "backup.local") -async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: - """Convert an iterable to an async iterator.""" - for i in iterable: - yield i - - @patch.object(BackupAgentTest, "async_download_backup") async def test_downloading_remote_encrypted_backup( download_mock, diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 5e5b0df74cd..69994028297 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -54,6 +54,8 @@ from .common import ( LOCAL_AGENT_ID, TEST_BACKUP_ABC123, TEST_BACKUP_DEF456, + TEST_BACKUP_PATH_ABC123, + TEST_BACKUP_PATH_DEF456, BackupAgentTest, setup_backup_platform, ) @@ -89,6 +91,15 @@ def generate_backup_id_fixture() -> Generator[MagicMock]: yield mock +def mock_read_backup(backup_path: Path) -> AgentBackup: + """Mock read backup.""" + mock_backups = { + "abc123": TEST_BACKUP_ABC123, + "custom_def456": TEST_BACKUP_DEF456, + } + return mock_backups[backup_path.stem] + + @pytest.mark.usefixtures("mock_backup_generation") async def test_create_backup_service( hass: HomeAssistant, @@ -1311,7 +1322,11 @@ class LocalBackupAgentTest(BackupAgentTest, LocalBackupAgent): """Local backup agent.""" def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup.""" + """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") @@ -2023,10 +2038,6 @@ async def test_receive_backup_file_write_error( with ( patch("pathlib.Path.open", open_mock), - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=TEST_BACKUP_ABC123, - ), ): resp = await client.post( "/api/backup/upload?agent_id=test.remote", @@ -2375,18 +2386,61 @@ async def test_receive_backup_file_read_error( @pytest.mark.usefixtures("path_glob") @pytest.mark.parametrize( - ("agent_id", "password_param", "restore_database", "restore_homeassistant", "dir"), + ( + "agent_id", + "backup_id", + "password_param", + "backup_path", + "restore_database", + "restore_homeassistant", + "dir", + ), [ - (LOCAL_AGENT_ID, {}, True, False, "backups"), - (LOCAL_AGENT_ID, {"password": "abc123"}, False, True, "backups"), - ("test.remote", {}, True, True, "tmp_backups"), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_ABC123.backup_id, + {}, + TEST_BACKUP_PATH_ABC123, + True, + False, + "backups", + ), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_DEF456.backup_id, + {}, + TEST_BACKUP_PATH_DEF456, + True, + False, + "backups", + ), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_ABC123.backup_id, + {"password": "abc123"}, + TEST_BACKUP_PATH_ABC123, + False, + True, + "backups", + ), + ( + "test.remote", + TEST_BACKUP_ABC123.backup_id, + {}, + TEST_BACKUP_PATH_ABC123, + True, + True, + "tmp_backups", + ), ], ) async def test_restore_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, agent_id: str, + backup_id: str, password_param: dict[str, str], + backup_path: Path, restore_database: bool, restore_homeassistant: bool, dir: str, @@ -2426,14 +2480,14 @@ async def test_restore_backup( patch.object(remote_agent, "async_download_backup") as download_mock, patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + 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", - "backup_id": TEST_BACKUP_ABC123.backup_id, + "backup_id": backup_id, "agent_id": agent_id, "restore_database": restore_database, "restore_homeassistant": restore_homeassistant, @@ -2473,17 +2527,17 @@ async def test_restore_backup( result = await ws_client.receive_json() assert result["success"] is True - backup_path = f"{hass.config.path()}/{dir}/abc123.tar" + full_backup_path = f"{hass.config.path()}/{dir}/{backup_path.name}" expected_restore_file = json.dumps( { - "path": backup_path, + "path": full_backup_path, "password": password, "remove_after_restore": agent_id != LOCAL_AGENT_ID, "restore_database": restore_database, "restore_homeassistant": restore_homeassistant, } ) - validate_password_mock.assert_called_once_with(Path(backup_path), password) + validate_password_mock.assert_called_once_with(Path(full_backup_path), password) assert mocked_write_text.call_args[0][0] == expected_restore_file assert mocked_service_call.called @@ -2533,7 +2587,7 @@ async def test_restore_backup_wrong_password( patch.object(remote_agent, "async_download_backup") as download_mock, patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ), ): download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) @@ -2581,8 +2635,8 @@ async def test_restore_backup_wrong_password( ("parameters", "expected_error", "expected_reason"), [ ( - {"backup_id": TEST_BACKUP_DEF456.backup_id}, - f"Backup def456 not found in agent {LOCAL_AGENT_ID}", + {"backup_id": "no_such_backup"}, + f"Backup no_such_backup not found in agent {LOCAL_AGENT_ID}", "backup_manager_error", ), ( @@ -2629,7 +2683,7 @@ async def test_restore_backup_wrong_parameters( patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ), ): await ws_client.send_json_auto_id( From fad3d5d29324ed2ef7c2fc28ae1cd99abeaa36ac Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 17:32:01 +0100 Subject: [PATCH 1263/2987] Don't blow up when a backup doesn't exist on supervisor (#136907) --- homeassistant/components/hassio/backup.py | 9 +-- tests/components/hassio/test_backup.py | 96 ++++++++++++++++++++--- 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index b81605264be..b9439183d8c 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -198,7 +198,10 @@ class SupervisorBackupAgent(BackupAgent): **kwargs: Any, ) -> AgentBackup | None: """Return a backup.""" - details = await self._client.backups.backup_info(backup_id) + try: + details = await self._client.backups.backup_info(backup_id) + except SupervisorNotFoundError: + return None if self.location not in details.locations: return None return _backup_details_to_agent_backup(details, self.location) @@ -212,10 +215,6 @@ class SupervisorBackupAgent(BackupAgent): location={self.location} ), ) - except SupervisorBadRequestError as err: - if err.args[0] != "Backup does not exist": - raise - _LOGGER.debug("Backup %s does not exist", backup_id) except SupervisorNotFoundError: _LOGGER.debug("Backup %s does not exist", backup_id) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index f7379b81a14..9ba73ade1a3 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -544,7 +544,7 @@ async def test_agent_download( hass_client: ClientSessionGenerator, supervisor_client: AsyncMock, ) -> None: - """Test agent download backup, when cloud user is logged in.""" + """Test agent download backup.""" client = await hass_client() backup_id = "abc123" supervisor_client.backups.list.return_value = [TEST_BACKUP] @@ -568,7 +568,7 @@ async def test_agent_download_unavailable_backup( hass_client: ClientSessionGenerator, supervisor_client: AsyncMock, ) -> None: - """Test agent download backup, when cloud user is logged in.""" + """Test agent download backup which does not exist.""" client = await hass_client() backup_id = "abc123" supervisor_client.backups.list.return_value = [TEST_BACKUP_3] @@ -630,6 +630,91 @@ async def test_agent_upload( supervisor_client.backups.remove_backup.assert_not_called() +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_agent_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + backup_id = "abc123" + + await client.send_json_auto_id( + { + "type": "backup/details", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {}, + "backup": { + "addons": [ + {"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"} + ], + "agents": {"hassio.local": {"protected": False, "size": 1048576}}, + "backup_id": "abc123", + "database_included": True, + "date": "1970-01-01T00:00:00+00:00", + "failed_agent_ids": [], + "folders": ["share"], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "with_automatic_settings": None, + }, + } + supervisor_client.backups.backup_info.assert_called_once_with(backup_id) + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + ("backup_info_side_effect", "expected_response"), + [ + ( + SupervisorBadRequestError("blah"), + { + "success": False, + "error": {"code": "unknown_error", "message": "Unknown error"}, + }, + ), + ( + SupervisorNotFoundError(), + { + "success": True, + "result": {"agent_errors": {}, "backup": None}, + }, + ), + ], +) +async def test_agent_get_backup_with_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + backup_info_side_effect: Exception, + expected_response: dict[str, Any], +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + backup_id = "abc123" + + supervisor_client.backups.backup_info.side_effect = backup_info_side_effect + await client.send_json_auto_id( + { + "type": "backup/details", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response == {"id": 1, "type": "result"} | expected_response + supervisor_client.backups.backup_info.assert_called_once_with(backup_id) + + @pytest.mark.usefixtures("hassio_client", "setup_integration") async def test_agent_delete_backup( hass: HomeAssistant, @@ -666,13 +751,6 @@ async def test_agent_delete_backup( "error": {"code": "unknown_error", "message": "Unknown error"}, }, ), - ( - SupervisorBadRequestError("Backup does not exist"), - { - "success": True, - "result": {"agent_errors": {}}, - }, - ), ( SupervisorNotFoundError(), { From 9e23ff9a4d41ffb0e094646295acb4f49a57ee47 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 30 Jan 2025 16:33:59 +0100 Subject: [PATCH 1264/2987] Fix onedrive does not fail on delete not found (#136910) * Fix onedrive does not fail on delete not found * Fix onedrive does not fail on delete not found --- homeassistant/components/onedrive/backup.py | 8 ++++++- tests/components/onedrive/test_backup.py | 24 ++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index a5a5c019797..94d60bc6398 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -190,7 +190,13 @@ class OneDriveBackupAgent(BackupAgent): **kwargs: Any, ) -> None: """Delete a backup file.""" - await self._get_backup_file_item(backup_id).delete() + + try: + await self._get_backup_file_item(backup_id).delete() + except APIError as err: + if err.response_status_code == 404: + return + raise @handle_backup_errors async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index a3d1129377f..3492202d3fe 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -156,6 +156,28 @@ async def test_agents_delete( mock_drive_items.delete.assert_called_once() +async def test_agents_delete_not_found_does_not_throw( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, +) -> None: + """Test agent delete backup.""" + mock_drive_items.delete = AsyncMock(side_effect=APIError(response_status_code=404)) + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock_drive_items.delete.assert_called_once() + + async def test_agents_upload( hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, @@ -257,7 +279,7 @@ async def test_agents_download( ("side_effect", "error"), [ ( - APIError(response_status_code=404, message="File not found."), + APIError(response_status_code=500), "Backup operation failed", ), (TimeoutError(), "Backup operation timed out"), From 613f0add7684b020a7bf86e6b6633083135d36d1 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Jan 2025 14:16:51 +0100 Subject: [PATCH 1265/2987] Convert valve position to int for Shelly BLU TRV (#136912) --- homeassistant/components/shelly/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 7140c79fbb6..5f0567d034a 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -208,7 +208,7 @@ RPC_NUMBERS: Final = { method_params_fn=lambda idx, value: { "id": idx, "method": "Trv.SetPosition", - "params": {"id": 0, "pos": value}, + "params": {"id": 0, "pos": int(value)}, }, removal_condition=lambda config, _status, key: config[key].get("enable", True) is True, From 08bb027eac0eac853753826ec53ac9ca487c04ec Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Jan 2025 09:15:13 -0800 Subject: [PATCH 1266/2987] Don't log errors when raising a backup exception in Google Drive (#136916) --- homeassistant/components/google_drive/backup.py | 13 ++++--------- tests/components/google_drive/test_backup.py | 6 +++--- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py index 4c81f041c8b..73e5902f8f5 100644 --- a/homeassistant/components/google_drive/backup.py +++ b/homeassistant/components/google_drive/backup.py @@ -80,16 +80,14 @@ class GoogleDriveBackupAgent(BackupAgent): try: await self._client.async_upload_backup(open_stream, backup) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("Upload backup error: %s", err) - raise BackupAgentError("Failed to upload backup") from err + raise BackupAgentError(f"Failed to upload backup: {err}") from err async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" try: return await self._client.async_list_backups() except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("List backups error: %s", err) - raise BackupAgentError("Failed to list backups") from err + raise BackupAgentError(f"Failed to list backups: {err}") from err async def async_get_backup( self, @@ -121,9 +119,7 @@ class GoogleDriveBackupAgent(BackupAgent): stream = await self._client.async_download(file_id) return ChunkAsyncStreamIterator(stream) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("Download backup error: %s", err) - raise BackupAgentError("Failed to download backup") from err - _LOGGER.error("Download backup_id: %s not found", backup_id) + raise BackupAgentError(f"Failed to download backup: {err}") from err raise BackupAgentError("Backup not found") async def async_delete_backup( @@ -143,5 +139,4 @@ class GoogleDriveBackupAgent(BackupAgent): await self._client.async_delete(file_id) _LOGGER.debug("Deleted backup_id: %s", backup_id) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("Delete backup error: %s", err) - raise BackupAgentError("Failed to delete backup") from err + raise BackupAgentError(f"Failed to delete backup: {err}") from err diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 62b7930012c..7e455ebb535 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -141,7 +141,7 @@ async def test_agents_list_backups_fail( assert response["success"] assert response["result"]["backups"] == [] assert response["result"]["agent_errors"] == { - TEST_AGENT_ID: "Failed to list backups" + TEST_AGENT_ID: "Failed to list backups: some error" } @@ -381,7 +381,7 @@ async def test_agents_upload_fail( await hass.async_block_till_done() assert resp.status == 201 - assert "Upload backup error: some error" in caplog.text + assert "Failed to upload backup: some error" in caplog.text async def test_agents_delete( @@ -430,7 +430,7 @@ async def test_agents_delete_fail( assert response["success"] assert response["result"] == { - "agent_errors": {TEST_AGENT_ID: "Failed to delete backup"} + "agent_errors": {TEST_AGENT_ID: "Failed to delete backup: some error"} } From b70598673b02f46b71e3699e376b5ffb26824fb0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Jan 2025 18:15:54 +0100 Subject: [PATCH 1267/2987] Show name of the backup agents in issue (#136925) * Show name of the backup agents in issue * Show name of the backup agents in issue * Update homeassistant/components/backup/manager.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/backup/manager.py | 6 +++++- tests/components/backup/test_manager.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index d1f27fa270b..1dbd8f8547d 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1166,7 +1166,11 @@ class BackupManager: learn_more_url="homeassistant://config/backup", severity=ir.IssueSeverity.WARNING, translation_key="automatic_backup_failed_upload_agents", - translation_placeholders={"failed_agents": ", ".join(agent_errors)}, + translation_placeholders={ + "failed_agents": ", ".join( + self.backup_agents[agent_id].name for agent_id in agent_errors + ) + }, ) async def async_can_decrypt_on_download( diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 69994028297..4a8d2360d3f 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -908,7 +908,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: { (DOMAIN, "automatic_backup_failed"): { "translation_key": "automatic_backup_failed_upload_agents", - "translation_placeholders": {"failed_agents": "test.remote"}, + "translation_placeholders": {"failed_agents": "remote"}, } }, ), From f479ed4ff04a0d724bd403c219bcaa5a475fd39d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Jan 2025 16:16:39 +0100 Subject: [PATCH 1268/2987] Fix Sonos importing deprecating constant (#136926) --- homeassistant/components/sonos/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 98bff8d2934..d530fa21e39 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -34,7 +34,11 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_time_interval -from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import create_eager_task @@ -503,7 +507,7 @@ class SonosDiscoveryManager: def _async_ssdp_discovered_player( self, info: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: - uid = info.upnp[ssdp.ATTR_UPNP_UDN] + uid = info.upnp[ATTR_UPNP_UDN] if not uid.startswith("uuid:RINCON_"): return uid = uid[5:] @@ -522,7 +526,7 @@ class SonosDiscoveryManager: cast(str, urlparse(info.ssdp_location).hostname), uid, info.ssdp_headers.get("X-RINCON-BOOTSEQ"), - cast(str, info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME)), + cast(str, info.upnp.get(ATTR_UPNP_MODEL_NAME)), None, ) From 07acabdb366bc4a79e6c7f045a24f098b3fafa1f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Jan 2025 16:16:22 +0100 Subject: [PATCH 1269/2987] Create Xbox signed session in executor (#136927) --- homeassistant/components/xbox/__init__.py | 4 +++- homeassistant/components/xbox/api.py | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 5282a34903a..ab0d510a709 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -6,6 +6,7 @@ import logging from xbox.webapi.api.client import XboxLiveClient from xbox.webapi.api.provider.smartglass.models import SmartglassConsoleList +from xbox.webapi.common.signed_session import SignedSession from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -36,7 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - auth = api.AsyncConfigEntryAuth(session) + signed_session = await hass.async_add_executor_job(SignedSession) + auth = api.AsyncConfigEntryAuth(signed_session, session) client = XboxLiveClient(auth) consoles: SmartglassConsoleList = await client.smartglass.get_console_list() diff --git a/homeassistant/components/xbox/api.py b/homeassistant/components/xbox/api.py index d4c47e4cc39..9fa7c14b5c9 100644 --- a/homeassistant/components/xbox/api.py +++ b/homeassistant/components/xbox/api.py @@ -11,10 +11,12 @@ from homeassistant.util.dt import utc_from_timestamp class AsyncConfigEntryAuth(AuthenticationManager): """Provide xbox authentication tied to an OAuth2 based config entry.""" - def __init__(self, oauth_session: OAuth2Session) -> None: + def __init__( + self, signed_session: SignedSession, oauth_session: OAuth2Session + ) -> None: """Initialize xbox auth.""" # Leaving out client credentials as they are handled by Home Assistant - super().__init__(SignedSession(), "", "", "") + super().__init__(signed_session, "", "", "") self._oauth_session = oauth_session self.oauth = self._get_oauth_token() From 252b13e63a0e4c1f884c9a264ed950bcbab5dbd7 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 30 Jan 2025 17:08:35 +0100 Subject: [PATCH 1270/2987] Pick onedrive owner from a more reliable source (#136929) * Pick onedrive owner from a more reliable source * fix --- homeassistant/components/onedrive/config_flow.py | 7 +++++-- tests/components/onedrive/conftest.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py index 83f6dd6e2ee..09c0d1b44cc 100644 --- a/homeassistant/components/onedrive/config_flow.py +++ b/homeassistant/components/onedrive/config_flow.py @@ -78,7 +78,7 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self.logger.exception("Unknown error") return self.async_abort(reason="unknown") - drive = response.json() + drive: dict = response.json() await self.async_set_unique_id(drive["parentReference"]["driveId"]) @@ -94,7 +94,10 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self._abort_if_unique_id_configured() - title = f"{drive['shared']['owner']['user']['displayName']}'s OneDrive" + user = drive.get("createdBy", {}).get("user", {}).get("displayName") + + title = f"{user}'s OneDrive" if user else "OneDrive" + return self.async_create_entry(title=title, data=data) async def async_step_reauth( diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 0cca8e9df0b..65142217017 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -88,7 +88,7 @@ def mock_adapter() -> Generator[MagicMock]: status_code=200, json={ "parentReference": {"driveId": "mock_drive_id"}, - "shared": {"owner": {"user": {"displayName": "John Doe"}}}, + "createdBy": {"user": {"displayName": "John Doe"}}, }, ) yield adapter From ad6c3f9e1097903c06e4df62816d5463f1dfa80b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 30 Jan 2025 16:59:39 +0100 Subject: [PATCH 1271/2987] Fix backup related translations in Synology DSM (#136931) refernce backup related strings in option-flow strings --- homeassistant/components/synology_dsm/strings.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 3d64f908256..d6d40be3fea 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -70,7 +70,13 @@ "data": { "scan_interval": "Minutes between scans", "timeout": "Timeout (seconds)", - "snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)" + "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%]" + }, + "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%]" } } } From 74f0af1ba1b01e4163f9367ee0a462dd5209497f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Jan 2025 17:43:48 +0100 Subject: [PATCH 1272/2987] Fix KeyError for Shelly virtual number component (#136932) --- homeassistant/components/shelly/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 5f0567d034a..c4420783bbb 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -186,7 +186,7 @@ RPC_NUMBERS: Final = { mode_fn=lambda config: VIRTUAL_NUMBER_MODE_MAP.get( config["meta"]["ui"]["view"], NumberMode.BOX ), - step_fn=lambda config: config["meta"]["ui"]["step"], + step_fn=lambda config: config["meta"]["ui"].get("step"), # If the unit is not set, the device sends an empty string unit=lambda config: config["meta"]["ui"]["unit"] if config["meta"]["ui"]["unit"] From 659a0df9abfbf51125a498e6927ac1c6307412d2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Jan 2025 18:38:11 +0100 Subject: [PATCH 1273/2987] Update frontend to 20250130.0 (#136937) --- 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 f4e426485c8..b545026059c 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==20250129.0"] + "requirements": ["home-assistant-frontend==20250130.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6d9e8f43755..01cfc57f3a8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.14.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250129.0 +home-assistant-frontend==20250130.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 533a77d4981..2bf3b5f1943 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,7 +1143,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250129.0 +home-assistant-frontend==20250130.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4491e64d808..6c5f81e6a2c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250129.0 +home-assistant-frontend==20250130.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From 3847057444bcc192e0a93e3646535c1310b42e9f Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Jan 2025 19:28:55 +0100 Subject: [PATCH 1274/2987] Bump version to 2025.2.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 77b223fcbcf..271226e92e2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index a592b8a194d..2e7b2dfcbc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b1" +version = "2025.2.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From cf737356fd33bd25bb286b54cb8284eb7f0c9759 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 12:55:14 -0600 Subject: [PATCH 1275/2987] Bump zeroconf to 0.142.0 (#136940) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.141.0...0.142.0 --- 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 6fe2b5b1923..be6f2d111d7 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.141.0"] + "requirements": ["zeroconf==0.142.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 01cfc57f3a8..a15e1bb61be 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.141.0 +zeroconf==0.142.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 31aeb180b8c..edc039286d7 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.141.0" + "zeroconf==0.142.0" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index a98d53b6037..412252a0846 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.141.0 +zeroconf==0.142.0 diff --git a/requirements_all.txt b/requirements_all.txt index cdc710bc3c1..02091e9ec2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3125,7 +3125,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.141.0 +zeroconf==0.142.0 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce31cb1dbc1..11905283d4d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2514,7 +2514,7 @@ yt-dlp[default]==2025.01.26 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.141.0 +zeroconf==0.142.0 # homeassistant.components.zeversolar zeversolar==0.3.2 From b12598d9633e16af9d2330b40db304dec13b2874 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 13:38:27 -0600 Subject: [PATCH 1276/2987] Bump aiohttp-asyncmdnsresolver to 0.0.2 (#136942) --- 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 a15e1bb61be..891d91e134b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.2b6 -aiohttp-asyncmdnsresolver==0.0.1 +aiohttp-asyncmdnsresolver==0.0.2 aiohttp-fast-zlib==0.2.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index edc039286d7..74e3d51a222 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "aiohttp==3.11.11", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", - "aiohttp-asyncmdnsresolver==0.0.1", + "aiohttp-asyncmdnsresolver==0.0.2", "aiozoneinfo==0.2.1", "astral==2.2", "async-interrupt==1.2.0", diff --git a/requirements.txt b/requirements.txt index 412252a0846..77fd3887db4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ aiohasupervisor==0.2.2b6 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 -aiohttp-asyncmdnsresolver==0.0.1 +aiohttp-asyncmdnsresolver==0.0.2 aiozoneinfo==0.2.1 astral==2.2 async-interrupt==1.2.0 From acb3f4ed78720b84b40a9c79a5763bad8c1afe54 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 30 Jan 2025 21:03:47 +0100 Subject: [PATCH 1277/2987] Add software version to onewire device info (#136934) --- .../components/onewire/onewirehub.py | 3 ++ tests/components/onewire/__init__.py | 4 +- .../onewire/snapshots/test_diagnostics.ambr | 1 + .../onewire/snapshots/test_init.ambr | 44 +++++++++---------- 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index a8d8dd06034..d65d7a90950 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -58,6 +58,7 @@ class OneWireHub: owproxy: protocol._Proxy devices: list[OWDeviceDescription] + _version: str def __init__(self, hass: HomeAssistant, config_entry: OneWireConfigEntry) -> None: """Initialize.""" @@ -73,6 +74,7 @@ class OneWireHub: port = self._config_entry.data[CONF_PORT] _LOGGER.debug("Initializing connection to %s:%s", host, port) self.owproxy = protocol.proxy(host, port) + self._version = self.owproxy.read(protocol.PTH_VERSION).decode() self.devices = _discover_devices(self.owproxy) async def initialize(self) -> None: @@ -85,6 +87,7 @@ class OneWireHub: """Populate the device registry.""" device_registry = dr.async_get(self._hass) for device in devices: + device.device_info["sw_version"] = self._version device_registry.async_get_or_create( config_entry_id=self._config_entry.entry_id, **device.device_info, diff --git a/tests/components/onewire/__init__.py b/tests/components/onewire/__init__.py index 9c025fe33af..595b660b722 100644 --- a/tests/components/onewire/__init__.py +++ b/tests/components/onewire/__init__.py @@ -13,7 +13,9 @@ from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES def setup_owproxy_mock_devices(owproxy: MagicMock, device_ids: list[str]) -> None: """Set up mock for owproxy.""" dir_side_effect: dict[str, list] = {} - read_side_effect: dict[str, list] = {} + read_side_effect: dict[str, list] = { + "/system/configuration/version": [b"3.2"], + } # Setup directory listing dir_side_effect["/"] = [[f"/{device_id}/" for device_id in device_ids]] diff --git a/tests/components/onewire/snapshots/test_diagnostics.ambr b/tests/components/onewire/snapshots/test_diagnostics.ambr index 6c5631331ca..c60d0a9748b 100644 --- a/tests/components/onewire/snapshots/test_diagnostics.ambr +++ b/tests/components/onewire/snapshots/test_diagnostics.ambr @@ -15,6 +15,7 @@ 'model_id': 'HB_HUB', 'name': 'EF.111111111113', 'serial_number': '111111111113', + 'sw_version': '3.2', }), 'family': 'EF', 'id': 'EF.111111111113', diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr index ee5d6d99158..5666dab6383 100644 --- a/tests/components/onewire/snapshots/test_init.ambr +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -27,7 +27,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -59,7 +59,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -91,7 +91,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -123,7 +123,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': , }) # --- @@ -155,7 +155,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -187,7 +187,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -219,7 +219,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -251,7 +251,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -283,7 +283,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -315,7 +315,7 @@ 'primary_config_entry': , 'serial_number': '222222222222', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -347,7 +347,7 @@ 'primary_config_entry': , 'serial_number': '222222222223', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -379,7 +379,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -411,7 +411,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -443,7 +443,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -475,7 +475,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -507,7 +507,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -539,7 +539,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -571,7 +571,7 @@ 'primary_config_entry': , 'serial_number': '222222222222', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -603,7 +603,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -635,7 +635,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -667,7 +667,7 @@ 'primary_config_entry': , 'serial_number': '111111111112', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -699,7 +699,7 @@ 'primary_config_entry': , 'serial_number': '111111111113', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- From ea496290c268c9e190e4b1a4fcfeebe74fc2689f Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 30 Jan 2025 21:59:00 +0100 Subject: [PATCH 1278/2987] Update knx-frontend to 2025.1.30.194235 (#136954) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index f34ce0f4589..86c050443e3 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "requirements": [ "xknx==3.5.0", "xknxproject==3.8.1", - "knx-frontend==2025.1.28.225404" + "knx-frontend==2025.1.30.194235" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 02091e9ec2a..f3c22e1b215 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1272,7 +1272,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.28.225404 +knx-frontend==2025.1.30.194235 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 11905283d4d..f481aea392a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1074,7 +1074,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.28.225404 +knx-frontend==2025.1.30.194235 # homeassistant.components.konnected konnected==1.2.0 From 00f8afe33280617c6859d61f5ce23bc570706399 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 30 Jan 2025 16:01:24 -0600 Subject: [PATCH 1279/2987] Consume extra system prompt in first pipeline (#136958) --- homeassistant/components/assist_satellite/entity.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 927229c9756..0229e0358b1 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -264,7 +264,6 @@ class AssistSatelliteEntity(entity.Entity): await self.async_start_conversation(announcement) finally: self._is_announcing = False - self._extra_system_prompt = None async def async_start_conversation( self, start_announcement: AssistSatelliteAnnouncement @@ -282,6 +281,10 @@ class AssistSatelliteEntity(entity.Entity): """Triggers an Assist pipeline in Home Assistant from a satellite.""" await self._cancel_running_pipeline() + # Consume system prompt in first pipeline + extra_system_prompt = self._extra_system_prompt + self._extra_system_prompt = None + if self._wake_word_intercept_future and start_stage in ( PipelineStage.WAKE_WORD, PipelineStage.STT, @@ -358,7 +361,7 @@ class AssistSatelliteEntity(entity.Entity): ), start_stage=start_stage, end_stage=end_stage, - conversation_extra_system_prompt=self._extra_system_prompt, + conversation_extra_system_prompt=extra_system_prompt, ), f"{self.entity_id}_pipeline", ) From f93b1cc950415b13daddb3281e65740cf9ef9911 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 30 Jan 2025 23:03:56 +0100 Subject: [PATCH 1280/2987] Make assist_satellite action descriptions consistent (#136955) - use third-person singular for descriptive language, following HA standards - use "a satellite" in both descriptions to match - use sentence-casing for "Start conversation" action name --- homeassistant/components/assist_satellite/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index e83f4666b5d..fa2dc984ab7 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -14,7 +14,7 @@ "services": { "announce": { "name": "Announce", - "description": "Let the satellite announce a message.", + "description": "Lets a satellite announce a message.", "fields": { "message": { "name": "Message", @@ -27,8 +27,8 @@ } }, "start_conversation": { - "name": "Start Conversation", - "description": "Start a conversation from a satellite.", + "name": "Start conversation", + "description": "Starts a conversation from a satellite.", "fields": { "start_message": { "name": "Message", From 6c93d6a2d0ec982dc10f2ea4ef4cc939c8294635 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Jan 2025 22:59:03 -0800 Subject: [PATCH 1281/2987] Include the redirect URL in the Google Drive instructions (#136906) * Include the redirect URL in the Google Drive instructions * Apply suggestions from code review Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../google_drive/application_credentials.py | 2 ++ .../components/google_drive/strings.json | 2 +- .../helpers/config_entry_oauth2_flow.py | 26 ++++++++++++------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/google_drive/application_credentials.py b/homeassistant/components/google_drive/application_credentials.py index c2f59b298cb..1c4421623d4 100644 --- a/homeassistant/components/google_drive/application_credentials.py +++ b/homeassistant/components/google_drive/application_credentials.py @@ -2,6 +2,7 @@ from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: @@ -18,4 +19,5 @@ async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, s "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", "more_info_url": "https://www.home-assistant.io/integrations/google_drive/", "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + "redirect_url": config_entry_oauth2_flow.async_get_redirect_uri(hass), } diff --git a/homeassistant/components/google_drive/strings.json b/homeassistant/components/google_drive/strings.json index 3441bec4294..e6658fb08e9 100644 --- a/homeassistant/components/google_drive/strings.json +++ b/homeassistant/components/google_drive/strings.json @@ -35,6 +35,6 @@ } }, "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Drive. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." + "description": "Follow the [instructions]({more_info_url}) to configure the Cloud Console:\n\n1. Go to the [OAuth consent screen]({oauth_consent_url}) and configure\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*." } } diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index c2a61335769..24a9de5b562 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -55,6 +55,21 @@ OAUTH_AUTHORIZE_URL_TIMEOUT_SEC = 30 OAUTH_TOKEN_TIMEOUT_SEC = 30 +@callback +def async_get_redirect_uri(hass: HomeAssistant) -> str: + """Return the redirect uri.""" + if "my" in hass.config.components: + return MY_AUTH_CALLBACK_PATH + + if (req := http.current_request.get()) is None: + raise RuntimeError("No current request in context") + + if (ha_host := req.headers.get(HEADER_FRONTEND_BASE)) is None: + raise RuntimeError("No header in request") + + return f"{ha_host}{AUTH_CALLBACK_PATH}" + + class AbstractOAuth2Implementation(ABC): """Base class to abstract OAuth2 authentication.""" @@ -144,16 +159,7 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): @property def redirect_uri(self) -> str: """Return the redirect uri.""" - if "my" in self.hass.config.components: - return MY_AUTH_CALLBACK_PATH - - if (req := http.current_request.get()) is None: - raise RuntimeError("No current request in context") - - if (ha_host := req.headers.get(HEADER_FRONTEND_BASE)) is None: - raise RuntimeError("No header in request") - - return f"{ha_host}{AUTH_CALLBACK_PATH}" + return async_get_redirect_uri(self.hass) @property def extra_authorize_data(self) -> dict: From 4613087e864ae89f98b8a4132b51df62be18adaf Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 31 Jan 2025 09:23:03 +0200 Subject: [PATCH 1282/2987] Add serial number to LG webOS TV device info (#136968) --- homeassistant/components/webostv/media_player.py | 3 +++ tests/components/webostv/conftest.py | 2 +- tests/components/webostv/snapshots/test_diagnostics.ambr | 1 + tests/components/webostv/snapshots/test_media_player.ambr | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index c8b871b3bf2..076b6caad24 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -284,6 +284,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): if model := self._client.system_info.get("modelName"): self._attr_device_info["model"] = model + if serial_number := self._client.system_info.get("serialNumber"): + self._attr_device_info["serial_number"] = serial_number + self._attr_extra_state_attributes = {} if self._client.sound_output is not None or self.state != MediaPlayerState.OFF: self._attr_extra_state_attributes = { diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py index bf007f5b936..c6594746cc5 100644 --- a/tests/components/webostv/conftest.py +++ b/tests/components/webostv/conftest.py @@ -42,7 +42,7 @@ def client_fixture(): client = mock_client_class.return_value client.hello_info = {"deviceUUID": FAKE_UUID} client.software_info = {"major_ver": "major", "minor_ver": "minor"} - client.system_info = {"modelName": TV_MODEL} + client.system_info = {"modelName": TV_MODEL, "serialNumber": "1234567890"} client.client_key = CLIENT_KEY client.apps = MOCK_APPS client.inputs = MOCK_INPUTS diff --git a/tests/components/webostv/snapshots/test_diagnostics.ambr b/tests/components/webostv/snapshots/test_diagnostics.ambr index a9bd6e91ee0..07ee50af1f8 100644 --- a/tests/components/webostv/snapshots/test_diagnostics.ambr +++ b/tests/components/webostv/snapshots/test_diagnostics.ambr @@ -41,6 +41,7 @@ 'sound_output': 'speaker', 'system_info': dict({ 'modelName': 'MODEL', + 'serialNumber': '1234567890', }), }), 'entry': dict({ diff --git a/tests/components/webostv/snapshots/test_media_player.ambr b/tests/components/webostv/snapshots/test_media_player.ambr index 35a703cc109..23f45a0f325 100644 --- a/tests/components/webostv/snapshots/test_media_player.ambr +++ b/tests/components/webostv/snapshots/test_media_player.ambr @@ -61,7 +61,7 @@ 'name': 'LG webOS TV MODEL', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '1234567890', 'suggested_area': None, 'sw_version': 'major.minor', 'via_device_id': None, From 4d4e11a0eb90639ec91a9d927e89b7a834becec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 31 Jan 2025 08:26:57 +0100 Subject: [PATCH 1283/2987] Fetch all programs instead of only the available ones at Home Connect (#136949) Fetch all programs instead of only the available ones --- .../components/home_connect/coordinator.py | 8 ++--- .../components/home_connect/switch.py | 4 +-- tests/components/home_connect/conftest.py | 19 +++++------- ...{programs-available.json => programs.json} | 0 tests/components/home_connect/test_select.py | 30 +++++++++---------- tests/components/home_connect/test_switch.py | 23 ++++++-------- 6 files changed, 35 insertions(+), 49 deletions(-) rename tests/components/home_connect/fixtures/{programs-available.json => programs.json} (100%) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 2c70d74150e..29bd961220e 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -25,7 +25,7 @@ from aiohomeconnect.model.error import ( HomeConnectError, HomeConnectRequestError, ) -from aiohomeconnect.model.program import EnumerateAvailableProgram +from aiohomeconnect.model.program import EnumerateProgram from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry @@ -48,7 +48,7 @@ class HomeConnectApplianceData: events: dict[EventKey, Event] = field(default_factory=dict) info: HomeAppliance - programs: list[EnumerateAvailableProgram] = field(default_factory=list) + programs: list[EnumerateProgram] = field(default_factory=list) settings: dict[SettingKey, GetSetting] status: dict[StatusKey, Status] @@ -243,9 +243,7 @@ class HomeConnectCoordinator( ): try: appliance_data.programs.extend( - ( - await self.client.get_available_programs(appliance.ha_id) - ).programs + (await self.client.get_all_programs(appliance.ha_id)).programs ) except HomeConnectError as error: _LOGGER.debug( diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index c3a0858e0bb..521252ccc2f 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -5,7 +5,7 @@ from typing import Any, cast from aiohomeconnect.model import EventKey, ProgramKey, SettingKey from aiohomeconnect.model.error import HomeConnectError -from aiohomeconnect.model.program import EnumerateAvailableProgram +from aiohomeconnect.model.program import EnumerateProgram from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity @@ -184,7 +184,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): self, coordinator: HomeConnectCoordinator, appliance: HomeConnectApplianceData, - program: EnumerateAvailableProgram, + program: EnumerateProgram, ) -> None: """Initialize the entity.""" desc = " ".join(["Program", program.key.split(".")[-1]]) diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index af039f04c03..ae98c69d242 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -9,9 +9,9 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( - ArrayOfAvailablePrograms, ArrayOfEvents, ArrayOfHomeAppliances, + ArrayOfPrograms, ArrayOfSettings, ArrayOfStatus, Event, @@ -37,9 +37,7 @@ from tests.common import MockConfigEntry, load_json_object_fixture MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( load_json_object_fixture("home_connect/appliances.json")["data"] ) -MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture( - "home_connect/programs-available.json" -) +MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json") MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") MOCK_STATUS = ArrayOfStatus.from_dict( load_json_object_fixture("home_connect/status.json")["data"] @@ -219,8 +217,8 @@ def _get_set_key_value_side_effect( return set_key_value_side_effect -async def _get_available_programs_side_effect(ha_id: str) -> ArrayOfAvailablePrograms: - """Get available programs.""" +async def _get_all_programs_side_effect(ha_id: str) -> ArrayOfPrograms: + """Get all programs.""" appliance_type = next( appliance for appliance in MOCK_APPLIANCES.homeappliances @@ -229,7 +227,7 @@ async def _get_available_programs_side_effect(ha_id: str) -> ArrayOfAvailablePro if appliance_type not in MOCK_PROGRAMS: raise HomeConnectApiError("error.key", "error description") - return ArrayOfAvailablePrograms.from_dict(MOCK_PROGRAMS[appliance_type]["data"]) + return ArrayOfPrograms.from_dict(MOCK_PROGRAMS[appliance_type]["data"]) async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings: @@ -290,9 +288,7 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: ) mock.get_settings = AsyncMock(side_effect=_get_settings_side_effect) mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS)) - mock.get_available_programs = AsyncMock( - side_effect=_get_available_programs_side_effect - ) + mock.get_all_programs = AsyncMock(side_effect=_get_all_programs_side_effect) mock.put_command = AsyncMock() mock.side_effect = mock @@ -323,7 +319,6 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.start_program = AsyncMock(side_effect=exception) mock.stop_program = AsyncMock(side_effect=exception) - mock.get_available_programs = AsyncMock(side_effect=exception) mock.set_selected_program = AsyncMock(side_effect=exception) mock.set_active_program_option = AsyncMock(side_effect=exception) mock.set_selected_program_option = AsyncMock(side_effect=exception) @@ -331,7 +326,7 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.get_settings = AsyncMock(side_effect=exception) mock.get_setting = AsyncMock(side_effect=exception) mock.get_status = AsyncMock(side_effect=exception) - mock.get_available_programs = AsyncMock(side_effect=exception) + mock.get_all_programs = AsyncMock(side_effect=exception) mock.put_command = AsyncMock(side_effect=exception) return mock diff --git a/tests/components/home_connect/fixtures/programs-available.json b/tests/components/home_connect/fixtures/programs.json similarity index 100% rename from tests/components/home_connect/fixtures/programs-available.json rename to tests/components/home_connect/fixtures/programs.json diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 6ebd37266cd..a0cdd15bf31 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -4,8 +4,8 @@ from collections.abc import Awaitable, Callable from unittest.mock import MagicMock from aiohomeconnect.model import ( - ArrayOfAvailablePrograms, ArrayOfEvents, + ArrayOfPrograms, Event, EventKey, EventMessage, @@ -13,7 +13,7 @@ from aiohomeconnect.model import ( ProgramKey, ) from aiohomeconnect.model.error import HomeConnectError -from aiohomeconnect.model.program import EnumerateAvailableProgram +from aiohomeconnect.model.program import EnumerateProgram import pytest from homeassistant.components.select import ( @@ -61,14 +61,14 @@ async def test_filter_unknown_programs( entity_registry: er.EntityRegistry, ) -> None: """Test select that only known programs are shown.""" - client.get_available_programs.side_effect = None - client.get_available_programs.return_value = ArrayOfAvailablePrograms( + client.get_all_programs.side_effect = None + client.get_all_programs.return_value = ArrayOfPrograms( [ - EnumerateAvailableProgram( + EnumerateProgram( key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, ), - EnumerateAvailableProgram( + EnumerateProgram( key=ProgramKey.UNKNOWN, raw_key="an unknown program", ), @@ -202,16 +202,14 @@ async def test_select_exception_handling( client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - client_with_exception.get_available_programs.side_effect = None - client_with_exception.get_available_programs.return_value = ( - ArrayOfAvailablePrograms( - [ - EnumerateAvailableProgram( - key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, - raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, - ) - ] - ) + client_with_exception.get_all_programs.side_effect = None + client_with_exception.get_all_programs.return_value = ArrayOfPrograms( + [ + EnumerateProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ) + ] ) assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 10d393423be..4d6b59eddd9 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -15,10 +15,7 @@ from aiohomeconnect.model import ( ) from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.event import ArrayOfEvents, EventType -from aiohomeconnect.model.program import ( - ArrayOfAvailablePrograms, - EnumerateAvailableProgram, -) +from aiohomeconnect.model.program import ArrayOfPrograms, EnumerateProgram from aiohomeconnect.model.setting import SettingConstraints import pytest @@ -250,16 +247,14 @@ async def test_switch_exception_handling( client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - client_with_exception.get_available_programs.side_effect = None - client_with_exception.get_available_programs.return_value = ( - ArrayOfAvailablePrograms( - [ - EnumerateAvailableProgram( - key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, - raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, - ) - ] - ) + client_with_exception.get_all_programs.side_effect = None + client_with_exception.get_all_programs.return_value = ArrayOfPrograms( + [ + EnumerateProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ) + ] ) client_with_exception.get_settings.side_effect = None client_with_exception.get_settings.return_value = ArrayOfSettings( From 99e307fe5a752dc4c732b90e24d8b4c81b61b231 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Jan 2025 23:33:58 -0800 Subject: [PATCH 1284/2987] Bump opower to 0.8.9 (#136911) * Bump opower to 0.8.9 * mypy --- homeassistant/components/opower/coordinator.py | 14 ++++++-------- homeassistant/components/opower/manifest.json | 2 +- homeassistant/components/opower/sensor.py | 8 ++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 13 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index f6f3524d630..6957ae4984c 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -5,18 +5,16 @@ import logging from types import MappingProxyType from typing import Any, cast -import aiohttp from opower import ( Account, AggregateType, - CannotConnect, CostRead, Forecast, - InvalidAuth, MeterType, Opower, ReadResolution, ) +from opower.exceptions import ApiException, CannotConnect, InvalidAuth from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.models import StatisticData, StatisticMetaData @@ -89,7 +87,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): raise UpdateFailed(f"Error during login: {err}") from err try: forecasts: list[Forecast] = await self.api.async_get_forecast() - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting forecasts: %s", err) raise _LOGGER.debug("Updating sensor data with: %s", forecasts) @@ -102,7 +100,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): """Insert Opower statistics.""" try: accounts = await self.api.async_get_accounts() - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting accounts: %s", err) raise for account in accounts: @@ -271,7 +269,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): cost_reads = await self.api.async_get_cost_reads( account, AggregateType.BILL, start, end ) - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting monthly cost reads: %s", err) raise _LOGGER.debug("Got %s monthly cost reads", len(cost_reads)) @@ -290,7 +288,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): daily_cost_reads = await self.api.async_get_cost_reads( account, AggregateType.DAY, start, end ) - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting daily cost reads: %s", err) raise _LOGGER.debug("Got %s daily cost reads", len(daily_cost_reads)) @@ -308,7 +306,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): hourly_cost_reads = await self.api.async_get_cost_reads( account, AggregateType.HOUR, start, end ) - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting hourly cost reads: %s", err) raise _LOGGER.debug("Got %s hourly cost reads", len(hourly_cost_reads)) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 7227f7171ac..d168cba5752 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.8"] + "requirements": ["opower==0.8.9"] } diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 7f8eb22d1e6..f9d0fe62332 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -97,7 +97,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.start_date, + value_fn=lambda data: str(data.start_date), ), OpowerEntityDescription( key="elec_end_date", @@ -105,7 +105,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.end_date, + value_fn=lambda data: str(data.end_date), ), ) GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( @@ -169,7 +169,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.start_date, + value_fn=lambda data: str(data.start_date), ), OpowerEntityDescription( key="gas_end_date", @@ -177,7 +177,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.end_date, + value_fn=lambda data: str(data.end_date), ), ) diff --git a/requirements_all.txt b/requirements_all.txt index f3c22e1b215..dc5dc04420f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1592,7 +1592,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.8.8 +opower==0.8.9 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f481aea392a..8707b3ff044 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1328,7 +1328,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.8.8 +opower==0.8.9 # homeassistant.components.oralb oralb-ble==0.17.6 From fc979cd564ee2d5fd27e05b32fa6f11b343ee4d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 01:34:39 -0600 Subject: [PATCH 1285/2987] Bump habluetooth to 3.15.0 (#136973) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 1fcd507da83..38677400418 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.14.0" + "habluetooth==3.15.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 891d91e134b..64353901fbf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.14.0 +habluetooth==3.15.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index dc5dc04420f..679c496e5a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1103,7 +1103,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.14.0 +habluetooth==3.15.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8707b3ff044..09478cb6447 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -944,7 +944,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.14.0 +habluetooth==3.15.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 From 270108e8e4c9484fae94bbc10f2cb895a956596c Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Fri, 31 Jan 2025 01:36:06 -0800 Subject: [PATCH 1286/2987] Bump total-connect-client to 2025.1.4 (#136793) --- .../totalconnect/alarm_control_panel.py | 4 +- .../components/totalconnect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/totalconnect/common.py | 39 +++++++++++-------- .../totalconnect/test_config_flow.py | 20 +++++++--- 6 files changed, 43 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index 48ba78acc92..021d1c7b886 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -73,7 +73,7 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): ) -> None: """Initialize the TotalConnect status.""" super().__init__(coordinator, location) - self._partition_id = partition_id + self._partition_id = int(partition_id) self._partition = self._location.partitions[partition_id] """ @@ -81,7 +81,7 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): for most users with new support for partitions. Add _# for partition 2 and beyond. """ - if partition_id == 1: + if int(partition_id) == 1: self._attr_name = None self._attr_unique_id = str(location.location_id) else: diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index 33306a7adba..6aff1ea392b 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/totalconnect", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], - "requirements": ["total-connect-client==2024.12"] + "requirements": ["total-connect-client==2025.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 679c496e5a5..a60f19c6ef3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2902,7 +2902,7 @@ tololib==1.1.0 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2024.12 +total-connect-client==2025.1.4 # homeassistant.components.tplink_lte tp-connected==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 09478cb6447..9c461aabfe8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2330,7 +2330,7 @@ tololib==1.1.0 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2024.12 +total-connect-client==2025.1.4 # homeassistant.components.tplink_omada tplink-omada-client==1.4.3 diff --git a/tests/components/totalconnect/common.py b/tests/components/totalconnect/common.py index 828cad71e07..34d451ec0b8 100644 --- a/tests/components/totalconnect/common.py +++ b/tests/components/totalconnect/common.py @@ -49,20 +49,15 @@ USER = { "UserFeatureList": "Master=0,User Administration=0,Configuration Administration=0", } -RESPONSE_AUTHENTICATE = { +RESPONSE_SESSION_DETAILS = { "ResultCode": ResultCode.SUCCESS.value, - "SessionID": 1, + "ResultData": "Success", + "SessionID": "12345", "Locations": LOCATIONS, "ModuleFlags": MODULE_FLAGS, "UserInfo": USER, } -RESPONSE_AUTHENTICATE_FAILED = { - "ResultCode": ResultCode.BAD_USER_OR_PASSWORD.value, - "ResultData": "test bad authentication", -} - - PARTITION_DISARMED = { "PartitionID": "1", "ArmingState": ArmingState.DISARMED, @@ -359,13 +354,13 @@ OPTIONS_DATA = {AUTO_BYPASS: False, CODE_REQUIRED: False} OPTIONS_DATA_CODE_REQUIRED = {AUTO_BYPASS: False, CODE_REQUIRED: True} PARTITION_DETAILS_1 = { - "PartitionID": 1, + "PartitionID": "1", "ArmingState": ArmingState.DISARMED.value, "PartitionName": "Test1", } PARTITION_DETAILS_2 = { - "PartitionID": 2, + "PartitionID": "2", "ArmingState": ArmingState.DISARMED.value, "PartitionName": "Test2", } @@ -402,6 +397,12 @@ RESPONSE_GET_ZONE_DETAILS_SUCCESS = { TOTALCONNECT_REQUEST = ( "homeassistant.components.totalconnect.TotalConnectClient.request" ) +TOTALCONNECT_GET_CONFIG = ( + "homeassistant.components.totalconnect.TotalConnectClient._get_configuration" +) +TOTALCONNECT_REQUEST_TOKEN = ( + "homeassistant.components.totalconnect.TotalConnectClient._request_token" +) async def setup_platform( @@ -420,7 +421,7 @@ async def setup_platform( mock_entry.add_to_hass(hass) responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -433,6 +434,8 @@ async def setup_platform( TOTALCONNECT_REQUEST, side_effect=responses, ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), ): assert await async_setup_component(hass, DOMAIN, {}) assert mock_request.call_count == 5 @@ -448,17 +451,21 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: mock_entry.add_to_hass(hass) responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, RESPONSE_DISARMED, ] - with patch( - TOTALCONNECT_REQUEST, - side_effect=responses, - ) as mock_request: + with ( + patch( + TOTALCONNECT_REQUEST, + side_effect=responses, + ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), + ): await hass.config_entries.async_setup(mock_entry.entry_id) assert mock_request.call_count == 5 await hass.async_block_till_done() diff --git a/tests/components/totalconnect/test_config_flow.py b/tests/components/totalconnect/test_config_flow.py index 86419bff817..f5020394bce 100644 --- a/tests/components/totalconnect/test_config_flow.py +++ b/tests/components/totalconnect/test_config_flow.py @@ -18,13 +18,15 @@ from homeassistant.data_entry_flow import FlowResultType from .common import ( CONFIG_DATA, CONFIG_DATA_NO_USERCODES, - RESPONSE_AUTHENTICATE, RESPONSE_DISARMED, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_PARTITION_DETAILS, + RESPONSE_SESSION_DETAILS, RESPONSE_SUCCESS, RESPONSE_USER_CODE_INVALID, + TOTALCONNECT_GET_CONFIG, TOTALCONNECT_REQUEST, + TOTALCONNECT_REQUEST_TOKEN, USERNAME, ) @@ -48,7 +50,7 @@ async def test_user_show_locations(hass: HomeAssistant) -> None: """Test user locations form.""" # user/pass provided, so check if valid then ask for usercodes on locations form responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -61,6 +63,8 @@ async def test_user_show_locations(hass: HomeAssistant) -> None: TOTALCONNECT_REQUEST, side_effect=responses, ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), patch( "homeassistant.components.totalconnect.async_setup_entry", return_value=True ), @@ -180,7 +184,7 @@ async def test_reauth(hass: HomeAssistant) -> None: async def test_no_locations(hass: HomeAssistant) -> None: """Test with no user locations.""" responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -191,6 +195,8 @@ async def test_no_locations(hass: HomeAssistant) -> None: TOTALCONNECT_REQUEST, side_effect=responses, ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), patch( "homeassistant.components.totalconnect.async_setup_entry", return_value=True ), @@ -221,7 +227,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -229,7 +235,11 @@ async def test_options_flow(hass: HomeAssistant) -> None: RESPONSE_DISARMED, ] - with patch(TOTALCONNECT_REQUEST, side_effect=responses): + with ( + patch(TOTALCONNECT_REQUEST, side_effect=responses), + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From f1c720606f1fa0fa71c544da0b9124be8430315b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 31 Jan 2025 10:38:30 +0100 Subject: [PATCH 1287/2987] Fixes to the user-facing strings of energenie_power_sockets (#136844) --- homeassistant/components/energenie_power_sockets/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/energenie_power_sockets/strings.json b/homeassistant/components/energenie_power_sockets/strings.json index e193b06b25f..4e4e49c68fb 100644 --- a/homeassistant/components/energenie_power_sockets/strings.json +++ b/homeassistant/components/energenie_power_sockets/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "Searching for Energenie-Power-Sockets Devices.", + "title": "Searching for Energenie Power Sockets devices", "description": "Choose a discovered device.", "data": { "device": "[%key:common::config_flow::data::device%]" @@ -13,7 +13,7 @@ "abort": { "usb_error": "Couldn't access USB devices!", "no_device": "Unable to discover any (new) supported device.", - "device_not_found": "No device was found for the given id.", + "device_not_found": "No device was found for the given ID.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, From ab5583ed40a8c0ebf03c4c051dd67d3c2fd777e3 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Fri, 31 Jan 2025 20:55:42 +1100 Subject: [PATCH 1288/2987] Suppress color_temp warning if color_temp_kelvin is provided (#136884) --- homeassistant/components/lifx/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index 3d37f1c3bc5..8286622e6f3 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -113,7 +113,7 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] | saturation = int(saturation / 100 * 65535) kelvin = 3500 - if _ATTR_COLOR_TEMP in kwargs: + if ATTR_COLOR_TEMP_KELVIN not in kwargs and _ATTR_COLOR_TEMP in kwargs: # added in 2025.1, can be removed in 2026.1 _LOGGER.warning( "The 'color_temp' parameter is deprecated. Please use 'color_temp_kelvin' for" From 3fb70316daadb48bcdfc6d1d71895006276f8458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 31 Jan 2025 10:10:57 +0000 Subject: [PATCH 1289/2987] Fix error messaging for cascading service calls (#136966) --- homeassistant/components/websocket_api/commands.py | 8 ++++---- tests/components/websocket_api/test_commands.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index cfa132b71eb..4a360b4a43c 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -275,10 +275,10 @@ async def handle_call_service( translation_domain=const.DOMAIN, translation_key="child_service_not_found", translation_placeholders={ - "domain": err.domain, - "service": err.service, - "child_domain": msg["domain"], - "child_service": msg["service"], + "domain": msg["domain"], + "service": msg["service"], + "child_domain": err.domain, + "child_service": err.service, }, ) except vol.Invalid as err: diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 22e839d84e4..2ddb5c628c7 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -460,10 +460,10 @@ async def test_call_service_child_not_found( "domain_test.test_service which was not found." ) assert msg["error"]["translation_placeholders"] == { - "domain": "non", - "service": "existing", - "child_domain": "domain_test", - "child_service": "test_service", + "domain": "domain_test", + "service": "test_service", + "child_domain": "non", + "child_service": "existing", } assert msg["error"]["translation_key"] == "child_service_not_found" assert msg["error"]["translation_domain"] == "websocket_api" From 230e101ee4b4fdc691de4cd3911d742ff86e57fe Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 11:23:33 +0100 Subject: [PATCH 1290/2987] Retry backup uploads in onedrive (#136980) * Retry backup uploads in onedrive * no exponential backup on timeout --- homeassistant/components/onedrive/backup.py | 34 ++++- tests/components/onedrive/conftest.py | 7 + tests/components/onedrive/test_backup.py | 138 +++++++++++++++++++- 3 files changed, 171 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 94d60bc6398..7f4bd5a0738 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps import html @@ -9,7 +10,7 @@ import json import logging from typing import Any, Concatenate, cast -from httpx import Response +from httpx import Response, TimeoutException from kiota_abstractions.api_error import APIError from kiota_abstractions.authentication import AnonymousAuthenticationProvider from kiota_abstractions.headers_collection import HeadersCollection @@ -42,6 +43,7 @@ from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN _LOGGER = logging.getLogger(__name__) UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB +MAX_RETRIES = 5 async def async_get_backup_agents( @@ -96,7 +98,7 @@ def handle_backup_errors[_R, **P]( ) _LOGGER.debug("Full error: %s", err, exc_info=True) raise BackupAgentError("Backup operation failed") from err - except TimeoutError as err: + except TimeoutException as err: _LOGGER.error( "Error during backup in %s: Timeout", func.__name__, @@ -268,6 +270,7 @@ class OneDriveBackupAgent(BackupAgent): start = 0 buffer: list[bytes] = [] buffer_size = 0 + retries = 0 async for chunk in stream: buffer.append(chunk) @@ -279,11 +282,28 @@ class OneDriveBackupAgent(BackupAgent): buffer_size > UPLOAD_CHUNK_SIZE ): # Loop in case the buffer is >= UPLOAD_CHUNK_SIZE * 2 slice_start = uploaded_chunks * UPLOAD_CHUNK_SIZE - await async_upload( - start, - start + UPLOAD_CHUNK_SIZE - 1, - chunk_data[slice_start : slice_start + UPLOAD_CHUNK_SIZE], - ) + try: + await async_upload( + start, + start + UPLOAD_CHUNK_SIZE - 1, + chunk_data[slice_start : slice_start + UPLOAD_CHUNK_SIZE], + ) + except APIError as err: + if ( + err.response_status_code and err.response_status_code < 500 + ): # no retry on 4xx errors + raise + if retries < MAX_RETRIES: + await asyncio.sleep(2**retries) + retries += 1 + continue + raise + except TimeoutException: + if retries < MAX_RETRIES: + retries += 1 + continue + raise + retries = 0 start += UPLOAD_CHUNK_SIZE uploaded_chunks += 1 buffer_size -= UPLOAD_CHUNK_SIZE diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 65142217017..649966a7828 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -176,3 +176,10 @@ def mock_instance_id() -> Generator[AsyncMock]: return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", ): yield + + +@pytest.fixture(autouse=True) +def mock_asyncio_sleep() -> Generator[AsyncMock]: + """Mock asyncio.sleep.""" + with patch("homeassistant.components.onedrive.backup.asyncio.sleep", AsyncMock()): + yield diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 3492202d3fe..162ecb7d92a 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -8,8 +8,10 @@ from io import StringIO from json import dumps from unittest.mock import Mock, patch +from httpx import TimeoutException from kiota_abstractions.api_error import APIError from msgraph.generated.models.drive_item import DriveItem +from msgraph_core.models import LargeFileUploadSession import pytest from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup @@ -255,6 +257,140 @@ async def test_broken_upload_session( assert "Failed to start backup upload" in caplog.text +@pytest.mark.parametrize( + "side_effect", + [ + APIError(response_status_code=500), + TimeoutException("Timeout"), + ], +) +async def test_agents_upload_errors_retried( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, + side_effect: Exception, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_adapter.send_async.side_effect = [ + side_effect, + LargeFileUploadSession(next_expected_ranges=["2-"]), + LargeFileUploadSession(next_expected_ranges=["2-"]), + ] + + 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, + patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert mock_adapter.send_async.call_count == 3 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + mock_drive_items.patch.assert_called_once() + + +async def test_agents_upload_4xx_errors_not_retried( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_adapter.send_async.side_effect = APIError(response_status_code=404) + + 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, + patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert mock_adapter.send_async.call_count == 1 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + assert mock_drive_items.patch.call_count == 0 + assert "Backup operation failed" in caplog.text + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (APIError(response_status_code=500), "Backup operation failed"), + (TimeoutException("Timeout"), "Backup operation timed out"), + ], +) +async def test_agents_upload_fails_after_max_retries( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, + side_effect: Exception, + error: str, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_adapter.send_async.side_effect = side_effect + + 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, + patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert mock_adapter.send_async.call_count == 6 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + assert mock_drive_items.patch.call_count == 0 + assert error in caplog.text + + async def test_agents_download( hass_client: ClientSessionGenerator, mock_drive_items: MagicMock, @@ -282,7 +418,7 @@ async def test_agents_download( APIError(response_status_code=500), "Backup operation failed", ), - (TimeoutError(), "Backup operation timed out"), + (TimeoutException("Timeout"), "Backup operation timed out"), ], ) async def test_delete_error( From e57832705428ad8a7ffeef0c9706f2f6aeee57cb Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Fri, 31 Jan 2025 11:46:12 +0100 Subject: [PATCH 1291/2987] Add more Homee cover tests (#136568) --- tests/components/homee/__init__.py | 40 ++- tests/components/homee/conftest.py | 2 + tests/components/homee/fixtures/cover3.json | 101 ------ tests/components/homee/fixtures/cover4.json | 101 ------ ...r1.json => cover_with_position_slats.json} | 8 +- ...r2.json => cover_with_slats_position.json} | 100 ++---- .../fixtures/cover_without_position.json | 48 +++ tests/components/homee/test_cover.py | 329 ++++++++++++------ 8 files changed, 358 insertions(+), 371 deletions(-) delete mode 100644 tests/components/homee/fixtures/cover3.json delete mode 100644 tests/components/homee/fixtures/cover4.json rename tests/components/homee/fixtures/{cover1.json => cover_with_position_slats.json} (95%) rename tests/components/homee/fixtures/{cover2.json => cover_with_slats_position.json} (50%) create mode 100644 tests/components/homee/fixtures/cover_without_position.json diff --git a/tests/components/homee/__init__.py b/tests/components/homee/__init__.py index 95fc6099269..a5f8ae00d1e 100644 --- a/tests/components/homee/__init__.py +++ b/tests/components/homee/__init__.py @@ -1,8 +1,14 @@ """Tests for the homee component.""" +from typing import Any +from unittest.mock import AsyncMock + +from pyHomee.model import HomeeAttribute, HomeeNode + +from homeassistant.components.homee.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: @@ -11,3 +17,35 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + +def build_mock_node(file: str) -> AsyncMock: + """Build a mocked Homee node from a json representation.""" + json_node = load_json_object_fixture(file, DOMAIN) + mock_node = AsyncMock(spec=HomeeNode) + + def get_attributes(attributes: list[Any]) -> list[AsyncMock]: + mock_attributes: list[AsyncMock] = [] + for attribute in attributes: + att = AsyncMock(spec=HomeeAttribute) + for key, value in attribute.items(): + setattr(att, key, value) + att.is_reversed = False + att.get_value = ( + lambda att=att: att.data if att.unit == "text" else att.current_value + ) + mock_attributes.append(att) + return mock_attributes + + for key, value in json_node.items(): + if key != "attributes": + setattr(mock_node, key, value) + + mock_node.attributes = get_attributes(json_node["attributes"]) + + def attribute_by_type(type, instance=0) -> HomeeAttribute | None: + return {attr.type: attr for attr in mock_node.attributes}.get(type) + + mock_node.get_attribute_by_type = attribute_by_type + + return mock_node diff --git a/tests/components/homee/conftest.py b/tests/components/homee/conftest.py index fb94ba0bbcc..5a3234e896b 100644 --- a/tests/components/homee/conftest.py +++ b/tests/components/homee/conftest.py @@ -61,6 +61,8 @@ def mock_homee() -> Generator[AsyncMock]: homee.settings = MagicMock() homee.settings.uid = HOMEE_ID homee.settings.homee_name = HOMEE_NAME + homee.settings.version = "1.2.3" + homee.settings.mac_address = "00:05:55:11:ee:cc" homee.reconnect_interval = 10 homee.connected = True diff --git a/tests/components/homee/fixtures/cover3.json b/tests/components/homee/fixtures/cover3.json deleted file mode 100644 index 0d3d5ea57e2..00000000000 --- a/tests/components/homee/fixtures/cover3.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "id": 3, - "name": "Test%20Cover", - "profile": 2002, - "image": "default", - "favorite": 0, - "order": 4, - "protocol": 23, - "routing": 0, - "state": 1, - "state_changed": 1687175681, - "added": 1672086680, - "history": 1, - "cube_type": 14, - "note": "TestCoverDevice", - "services": 7, - "phonetic_name": "", - "owner": 2, - "security": 0, - "attributes": [ - { - "id": 1, - "node_id": 3, - "instance": 0, - "minimum": 0, - "maximum": 4, - "current_value": 3.0, - "target_value": 0.0, - "last_value": 1.0, - "unit": "n%2Fa", - "step_value": 1.0, - "editable": 1, - "type": 135, - "state": 1, - "last_changed": 1687175680, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "can_observe": [300], - "observes": [75], - "automations": ["toggle"] - } - }, - { - "id": 2, - "node_id": 3, - "instance": 0, - "minimum": 0, - "maximum": 100, - "current_value": 75.0, - "target_value": 0.0, - "last_value": 100.0, - "unit": "%25", - "step_value": 0.5, - "editable": 1, - "type": 15, - "state": 1, - "last_changed": 1687175680, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "automations": ["step"], - "history": { - "day": 35, - "week": 5, - "month": 1 - } - } - }, - { - "id": 3, - "node_id": 3, - "instance": 0, - "minimum": -45, - "maximum": 90, - "current_value": 56.0, - "target_value": 56.0, - "last_value": 0.0, - "unit": "%C2%B0", - "step_value": 1.0, - "editable": 1, - "type": 113, - "state": 1, - "last_changed": 1678284920, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "automations": ["step"] - } - } - ] -} diff --git a/tests/components/homee/fixtures/cover4.json b/tests/components/homee/fixtures/cover4.json deleted file mode 100644 index a3de555794a..00000000000 --- a/tests/components/homee/fixtures/cover4.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "id": 3, - "name": "Test%20Cover", - "profile": 2002, - "image": "default", - "favorite": 0, - "order": 4, - "protocol": 23, - "routing": 0, - "state": 1, - "state_changed": 1687175681, - "added": 1672086680, - "history": 1, - "cube_type": 14, - "note": "TestCoverDevice", - "services": 7, - "phonetic_name": "", - "owner": 2, - "security": 0, - "attributes": [ - { - "id": 1, - "node_id": 3, - "instance": 0, - "minimum": 0, - "maximum": 4, - "current_value": 4.0, - "target_value": 1.0, - "last_value": 0.0, - "unit": "n%2Fa", - "step_value": 1.0, - "editable": 1, - "type": 135, - "state": 1, - "last_changed": 1687175680, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "can_observe": [300], - "observes": [75], - "automations": ["toggle"] - } - }, - { - "id": 2, - "node_id": 3, - "instance": 0, - "minimum": 0, - "maximum": 100, - "current_value": 25.0, - "target_value": 100.0, - "last_value": 0.0, - "unit": "%25", - "step_value": 0.5, - "editable": 1, - "type": 15, - "state": 1, - "last_changed": 1687175680, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "automations": ["step"], - "history": { - "day": 35, - "week": 5, - "month": 1 - } - } - }, - { - "id": 3, - "node_id": 3, - "instance": 0, - "minimum": -45, - "maximum": 90, - "current_value": -11.0, - "target_value": 0.0, - "last_value": -45.0, - "unit": "%C2%B0", - "step_value": 1.0, - "editable": 1, - "type": 113, - "state": 1, - "last_changed": 1678284920, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "automations": ["step"] - } - } - ] -} diff --git a/tests/components/homee/fixtures/cover1.json b/tests/components/homee/fixtures/cover_with_position_slats.json similarity index 95% rename from tests/components/homee/fixtures/cover1.json rename to tests/components/homee/fixtures/cover_with_position_slats.json index 8fedfb19d4f..8fd0d6f44fe 100644 --- a/tests/components/homee/fixtures/cover1.json +++ b/tests/components/homee/fixtures/cover_with_position_slats.json @@ -1,6 +1,6 @@ { "id": 3, - "name": "Test%20Cover", + "name": "Test Cover", "profile": 2002, "image": "default", "favorite": 0, @@ -27,7 +27,7 @@ "current_value": 1.0, "target_value": 1.0, "last_value": 4.0, - "unit": "n%2Fa", + "unit": "n/a", "step_value": 1.0, "editable": 1, "type": 135, @@ -53,7 +53,7 @@ "current_value": 0.0, "target_value": 0.0, "last_value": 0.0, - "unit": "%25", + "unit": "%", "step_value": 0.5, "editable": 1, "type": 15, @@ -82,7 +82,7 @@ "current_value": -45.0, "target_value": 0.0, "last_value": -45.0, - "unit": "%C2%B0", + "unit": "°", "step_value": 1.0, "editable": 1, "type": 113, diff --git a/tests/components/homee/fixtures/cover2.json b/tests/components/homee/fixtures/cover_with_slats_position.json similarity index 50% rename from tests/components/homee/fixtures/cover2.json rename to tests/components/homee/fixtures/cover_with_slats_position.json index b53c3d49b62..4b6eb466a85 100644 --- a/tests/components/homee/fixtures/cover2.json +++ b/tests/components/homee/fixtures/cover_with_slats_position.json @@ -1,19 +1,19 @@ { "id": 1, - "name": "Test%20Cover", + "name": "Test Slats", "profile": 2002, "image": "default", "favorite": 0, - "order": 4, + "order": 1, "protocol": 23, "routing": 0, "state": 1, - "state_changed": 1687175681, - "added": 1672086680, + "state_changed": 1676901608, + "added": 1672148537, "history": 1, "cube_type": 14, - "note": "TestCoverDevice", - "services": 7, + "note": "", + "services": 70, "phonetic_name": "", "owner": 2, "security": 0, @@ -22,67 +22,12 @@ "id": 1, "node_id": 1, "instance": 0, - "minimum": 0, - "maximum": 4, - "current_value": 1.0, - "target_value": 1.0, - "last_value": 0.0, - "unit": "n%2Fa", - "step_value": 1.0, - "editable": 1, - "type": 135, - "state": 1, - "last_changed": 1687175680, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "can_observe": [300], - "observes": [75], - "automations": ["toggle"] - } - }, - { - "id": 2, - "node_id": 1, - "instance": 0, - "minimum": 0, - "maximum": 100, - "current_value": 100.0, - "target_value": 0.0, - "last_value": 0.0, - "unit": "%25", - "step_value": 0.5, - "editable": 1, - "type": 15, - "state": 1, - "last_changed": 1687175680, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "automations": ["step"], - "history": { - "day": 35, - "week": 5, - "month": 1 - } - } - }, - { - "id": 3, - "node_id": 1, - "instance": 0, "minimum": -45, "maximum": 90, - "current_value": 90.0, - "target_value": 0.0, - "last_value": -45.0, - "unit": "%C2%B0", + "current_value": 1.0, + "target_value": 1.0, + "last_value": -21.0, + "unit": "°", "step_value": 1.0, "editable": 1, "type": 113, @@ -96,6 +41,31 @@ "options": { "automations": ["step"] } + }, + { + "id": 2, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 2, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 337, + "state": 1, + "last_changed": 1678284911, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "observes": [72] + } } ] } diff --git a/tests/components/homee/fixtures/cover_without_position.json b/tests/components/homee/fixtures/cover_without_position.json new file mode 100644 index 00000000000..e2bc6c7a38d --- /dev/null +++ b/tests/components/homee/fixtures/cover_without_position.json @@ -0,0 +1,48 @@ +{ + "id": 3, + "name": "Test Cover", + "profile": 2002, + "image": "default", + "favorite": 0, + "order": 4, + "protocol": 23, + "routing": 0, + "state": 1, + "state_changed": 1687175681, + "added": 1672086680, + "history": 1, + "cube_type": 14, + "note": "TestCoverDevice", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 4.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 135, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "observes": [75], + "automations": ["toggle"] + } + } + ] +} diff --git a/tests/components/homee/test_cover.py b/tests/components/homee/test_cover.py index a7feaa10b66..d52f3fa3164 100644 --- a/tests/components/homee/test_cover.py +++ b/tests/components/homee/test_cover.py @@ -1,97 +1,38 @@ """Test homee covers.""" -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock -from pyHomee import HomeeNode - -from homeassistant.components.cover import DOMAIN as COVER_DOMAIN, CoverState -from homeassistant.components.homee.const import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN as COVER_DOMAIN, + CoverEntityFeature, + CoverState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, +) from homeassistant.core import HomeAssistant -from . import setup_integration +from . import build_mock_node, setup_integration -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry -async def test_cover_open( - hass: HomeAssistant, mock_homee: AsyncMock, mock_config_entry: MockConfigEntry -) -> None: - """Test an open cover.""" - # Cover open, tilt open. - cover_json = load_json_object_fixture("cover1.json", DOMAIN) - cover_node = HomeeNode(cover_json) - mock_homee.nodes = [cover_node] - - await setup_integration(hass, mock_config_entry) - - assert hass.states.get("cover.test_cover").state == CoverState.OPEN - - attributes = hass.states.get("cover.test_cover").attributes - assert attributes.get("supported_features") == 143 - assert attributes.get("current_position") == 100 - assert attributes.get("current_tilt_position") == 100 - - -async def test_cover_closed( - hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry -) -> None: - """Test a closed cover.""" - # Cover closed, tilt closed. - cover_json = load_json_object_fixture("cover2.json", DOMAIN) - cover_node = HomeeNode(cover_json) - mock_homee.nodes = [cover_node] - - await setup_integration(hass, mock_config_entry) - - assert hass.states.get("cover.test_cover").state == CoverState.CLOSED - attributes = hass.states.get("cover.test_cover").attributes - assert attributes.get("current_position") == 0 - assert attributes.get("current_tilt_position") == 0 - - -async def test_cover_opening( - hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry -) -> None: - """Test an opening cover.""" - # opening, 75% homee / 25% HA - cover_json = load_json_object_fixture("cover3.json", DOMAIN) - cover_node = HomeeNode(cover_json) - mock_homee.nodes = [cover_node] - - await setup_integration(hass, mock_config_entry) - - assert hass.states.get("cover.test_cover").state == CoverState.OPENING - attributes = hass.states.get("cover.test_cover").attributes - assert attributes.get("current_position") == 25 - assert attributes.get("current_tilt_position") == 25 - - -async def test_cover_closing( - hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry -) -> None: - """Test a closing cover.""" - # closing, 25% homee / 75% HA - cover_json = load_json_object_fixture("cover4.json", DOMAIN) - cover_node = HomeeNode(cover_json) - mock_homee.nodes = [cover_node] - - await setup_integration(hass, mock_config_entry) - - assert hass.states.get("cover.test_cover").state == CoverState.CLOSING - attributes = hass.states.get("cover.test_cover").attributes - assert attributes.get("current_position") == 75 - assert attributes.get("current_tilt_position") == 74 - - -async def test_open_cover( - hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +async def test_open_close_stop_cover( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test opening the cover.""" - # Cover closed, tilt closed. - cover_json = load_json_object_fixture("cover2.json", DOMAIN) - cover_node = HomeeNode(cover_json) - mock_homee.nodes = [cover_node] + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] await setup_integration(hass, mock_config_entry) @@ -101,24 +42,214 @@ async def test_open_cover( {ATTR_ENTITY_ID: "cover.test_cover"}, blocking=True, ) - mock_homee.set_value.assert_called_once_with(cover_node.id, 1, 0) - - -async def test_close_cover( - hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry -) -> None: - """Test opening the cover.""" - # Cover open, tilt open. - cover_json = load_json_object_fixture("cover1.json", DOMAIN) - cover_node = HomeeNode(cover_json) - mock_homee.nodes = [cover_node] - - await setup_integration(hass, mock_config_entry) - await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: "cover.test_cover"}, blocking=True, ) - mock_homee.set_value.assert_called_once_with(cover_node.id, 1, 1) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: "cover.test_cover"}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + for index, call in enumerate(calls): + assert call[0] == (mock_homee.nodes[0].id, 1, index) + + +async def test_set_cover_position( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the cover position.""" + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + + await setup_integration(hass, mock_config_entry) + + # Slats have a range of -45 to 90. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_POSITION: 100}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_POSITION: 0}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_POSITION: 50}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + positions = [0, 100, 50] + for call in calls: + assert call[0] == (1, 2, positions.pop(0)) + + +async def test_close_open_slats( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test closing and opening slats.""" + mock_homee.nodes = [build_mock_node("cover_with_slats_position.json")] + + await setup_integration(hass, mock_config_entry) + + attributes = hass.states.get("cover.test_slats").attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test_slats"}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test_slats"}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + for index, call in enumerate(calls, start=1): + assert call[0] == (mock_homee.nodes[0].id, 2, index) + + +async def test_set_slat_position( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting slats position.""" + mock_homee.nodes = [build_mock_node("cover_with_slats_position.json")] + + await setup_integration(hass, mock_config_entry) + + # Slats have a range of -45 to 90 on this device. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_TILT_POSITION: 100}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_TILT_POSITION: 0}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_TILT_POSITION: 50}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + positions = [-45, 90, 22.5] + for call in calls: + assert call[0] == (1, 1, positions.pop(0)) + + +async def test_cover_positions( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test an open cover.""" + # Cover open, tilt open. + # mock_homee.nodes = [cover] + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + cover = mock_homee.nodes[0] + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.test_cover").state == CoverState.OPEN + + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_TILT_POSITION + ) + assert attributes.get("current_position") == 100 + assert attributes.get("current_tilt_position") == 100 + + cover.attributes[0].current_value = 1 + cover.attributes[1].current_value = 100 + cover.attributes[2].current_value = 90 + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("current_position") == 0 + assert attributes.get("current_tilt_position") == 0 + assert hass.states.get("cover.test_cover").state == CoverState.CLOSED + + cover.attributes[0].current_value = 3 + cover.attributes[1].current_value = 75 + cover.attributes[2].current_value = 56 + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + assert hass.states.get("cover.test_cover").state == CoverState.OPENING + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("current_position") == 25 + assert attributes.get("current_tilt_position") == 25 + + cover.attributes[0].current_value = 4 + cover.attributes[1].current_value = 25 + cover.attributes[2].current_value = -11 + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + assert hass.states.get("cover.test_cover").state == CoverState.CLOSING + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("current_position") == 75 + assert attributes.get("current_tilt_position") == 74 + + +async def test_reversed_cover( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a cover with inverted UP_DOWN attribute without position.""" + mock_homee.nodes = [build_mock_node("cover_without_position.json")] + cover = mock_homee.nodes[0] + + await setup_integration(hass, mock_config_entry) + + cover.attributes[0].is_reversed = True + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + assert hass.states.get("cover.test_cover").state == CoverState.OPEN + + cover.attributes[0].current_value = 0 + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + assert hass.states.get("cover.test_cover").state == CoverState.CLOSED From e512ad7a81f559c088d0789f3024fd4cd22396c5 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Fri, 31 Jan 2025 12:10:44 +0100 Subject: [PATCH 1292/2987] Fix missing duration translation for Swiss public transport integration (#136982) --- .../swiss_public_transport/icons.json | 2 +- .../swiss_public_transport/sensor.py | 2 + .../swiss_public_transport/strings.json | 4 +- .../snapshots/test_sensor.ambr | 101 +++++++++--------- .../swiss_public_transport/test_sensor.py | 2 +- 5 files changed, 58 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/icons.json b/homeassistant/components/swiss_public_transport/icons.json index 06a640a06b2..45cf4713705 100644 --- a/homeassistant/components/swiss_public_transport/icons.json +++ b/homeassistant/components/swiss_public_transport/icons.json @@ -10,7 +10,7 @@ "departure2": { "default": "mdi:bus-clock" }, - "duration": { + "trip_duration": { "default": "mdi:timeline-clock" }, "transfers": { diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index a0131938a37..c8075a6746c 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -56,8 +56,10 @@ SENSORS: tuple[SwissPublicTransportSensorEntityDescription, ...] = ( ], SwissPublicTransportSensorEntityDescription( key="duration", + translation_key="trip_duration", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, value_fn=lambda data_connection: data_connection["duration"], ), SwissPublicTransportSensorEntityDescription( diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index ef8cc5595e3..270cb097e0a 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -64,8 +64,8 @@ "departure2": { "name": "Departure +2" }, - "duration": { - "name": "Duration" + "trip_duration": { + "name": "Trip duration" }, "transfers": { "name": "Transfers" diff --git a/tests/components/swiss_public_transport/snapshots/test_sensor.ambr b/tests/components/swiss_public_transport/snapshots/test_sensor.ambr index dbd689fc8f6..b8ad82c7b79 100644 --- a/tests/components/swiss_public_transport/snapshots/test_sensor.ambr +++ b/tests/components/swiss_public_transport/snapshots/test_sensor.ambr @@ -192,55 +192,6 @@ 'state': '2024-01-06T17:05:00+00:00', }) # --- -# name: test_all_entities[sensor.zurich_bern_duration-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.zurich_bern_duration', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Duration', - 'platform': 'swiss_public_transport', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'Zürich Bern_duration', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.zurich_bern_duration-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by transport.opendata.ch', - 'device_class': 'duration', - 'friendly_name': 'Zürich Bern Duration', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.zurich_bern_duration', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '10', - }) -# --- # name: test_all_entities[sensor.zurich_bern_line-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -382,3 +333,55 @@ 'state': '0', }) # --- +# name: test_all_entities[sensor.zurich_bern_trip_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zurich_bern_trip_duration', + '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': 'Trip duration', + 'platform': 'swiss_public_transport', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'trip_duration', + 'unique_id': 'Zürich Bern_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.zurich_bern_trip_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by transport.opendata.ch', + 'device_class': 'duration', + 'friendly_name': 'Zürich Bern Trip duration', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zurich_bern_trip_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.003', + }) +# --- diff --git a/tests/components/swiss_public_transport/test_sensor.py b/tests/components/swiss_public_transport/test_sensor.py index 4afdd88c9de..6e832728277 100644 --- a/tests/components/swiss_public_transport/test_sensor.py +++ b/tests/components/swiss_public_transport/test_sensor.py @@ -83,7 +83,7 @@ async def test_fetching_data( hass.states.get("sensor.zurich_bern_departure_2").state == "2024-01-06T17:05:00+00:00" ) - assert hass.states.get("sensor.zurich_bern_duration").state == "10" + assert hass.states.get("sensor.zurich_bern_trip_duration").state == "0.003" assert hass.states.get("sensor.zurich_bern_platform").state == "0" assert hass.states.get("sensor.zurich_bern_transfers").state == "0" assert hass.states.get("sensor.zurich_bern_delay").state == "0" From 010cad08c05988dbcedff9232fb4f76d4ce1f691 Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Fri, 31 Jan 2025 12:12:07 +0100 Subject: [PATCH 1293/2987] Add tariff sensor and peak sensors (#136919) --- homeassistant/components/youless/sensor.py | 34 +++- homeassistant/components/youless/strings.json | 9 + tests/components/youless/__init__.py | 5 + tests/components/youless/fixtures/device.json | 2 +- tests/components/youless/fixtures/phase.json | 15 ++ .../youless/snapshots/test_sensor.ambr | 176 +++++++++++++++++- tests/components/youless/test_init.py | 2 +- 7 files changed, 231 insertions(+), 12 deletions(-) create mode 100644 tests/components/youless/fixtures/phase.json diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 3afb215ed5f..db8244c0b06 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -36,7 +36,7 @@ class YouLessSensorEntityDescription(SensorEntityDescription): """Describes a YouLess sensor entity.""" device_group: str - value_func: Callable[[YoulessAPI], float | None] + value_func: Callable[[YoulessAPI], float | None | str] SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( @@ -212,6 +212,38 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( lambda device: device.phase3.current.value if device.phase1 else None ), ), + YouLessSensorEntityDescription( + key="tariff", + device_group="power", + translation_key="active_tariff", + device_class=SensorDeviceClass.ENUM, + options=["1", "2"], + value_func=( + lambda device: str(device.current_tariff) if device.current_tariff else None + ), + ), + YouLessSensorEntityDescription( + key="average_peak", + device_group="power", + translation_key="average_peak", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=( + lambda device: device.average_power.value if device.average_power else None + ), + ), + YouLessSensorEntityDescription( + key="month_peak", + device_group="power", + translation_key="month_peak", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=( + lambda device: device.peak_power.value if device.peak_power else None + ), + ), YouLessSensorEntityDescription( key="delivery_low", device_group="delivery", diff --git a/homeassistant/components/youless/strings.json b/homeassistant/components/youless/strings.json index 8a3f6cb5d8b..c735e2b2ff2 100644 --- a/homeassistant/components/youless/strings.json +++ b/homeassistant/components/youless/strings.json @@ -52,6 +52,9 @@ "active_current_phase_a": { "name": "Current phase {phase}" }, + "active_tariff": { + "name": "Tariff" + }, "total_energy_import_tariff_kwh": { "name": "Energy import tariff {tariff}" }, @@ -66,6 +69,12 @@ }, "active_s0_w": { "name": "Current usage" + }, + "average_peak": { + "name": "Average peak" + }, + "month_peak": { + "name": "Month peak" } } } diff --git a/tests/components/youless/__init__.py b/tests/components/youless/__init__.py index 8770a7e2dc8..03db24cb7f7 100644 --- a/tests/components/youless/__init__.py +++ b/tests/components/youless/__init__.py @@ -25,6 +25,11 @@ async def init_component(hass: HomeAssistant) -> MockConfigEntry: json=load_json_array_fixture("enologic.json", youless.DOMAIN), headers={"Content-Type": "application/json"}, ) + mock.get( + "http://1.1.1.1/f", + json=load_json_object_fixture("phase.json", youless.DOMAIN), + headers={"Content-Type": "application/json"}, + ) entry = MockConfigEntry( domain=youless.DOMAIN, diff --git a/tests/components/youless/fixtures/device.json b/tests/components/youless/fixtures/device.json index 7d089851923..82d07dba739 100644 --- a/tests/components/youless/fixtures/device.json +++ b/tests/components/youless/fixtures/device.json @@ -1,5 +1,5 @@ { "model": "LS120", - "fw": "1.4.2-EL", + "fw": "1.5.1-EL", "mac": "de2:2d2:3d23" } diff --git a/tests/components/youless/fixtures/phase.json b/tests/components/youless/fixtures/phase.json new file mode 100644 index 00000000000..8a5aa3215ef --- /dev/null +++ b/tests/components/youless/fixtures/phase.json @@ -0,0 +1,15 @@ +{ + "tr": 1, + "i1": 0.123, + "v1": 240, + "l1": 462, + "v2": 240, + "l2": 230, + "i2": 0.123, + "v3": 240, + "l3": 230, + "i3": 0.123, + "pp": 1200, + "pts": 2501301621, + "pa": 400 +} diff --git a/tests/components/youless/snapshots/test_sensor.ambr b/tests/components/youless/snapshots/test_sensor.ambr index 0647d854d2a..9e79b5b9b5e 100644 --- a/tests/components/youless/snapshots/test_sensor.ambr +++ b/tests/components/youless/snapshots/test_sensor.ambr @@ -152,6 +152,57 @@ 'state': '1624.264', }) # --- +# name: test_sensors[sensor.power_meter_average_peak-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_average_peak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Average peak', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'average_peak', + 'unique_id': 'youless_localhost_average_peak', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_average_peak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Average peak', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_average_peak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '400', + }) +# --- # name: test_sensors[sensor.power_meter_current_phase_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -200,7 +251,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.123', }) # --- # name: test_sensors[sensor.power_meter_current_phase_2-entry] @@ -251,7 +302,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.123', }) # --- # name: test_sensors[sensor.power_meter_current_phase_3-entry] @@ -302,7 +353,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.123', }) # --- # name: test_sensors[sensor.power_meter_current_power_usage-entry] @@ -458,6 +509,57 @@ 'state': '4490.631', }) # --- +# name: test_sensors[sensor.power_meter_month_peak-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_month_peak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Month peak', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'month_peak', + 'unique_id': 'youless_localhost_month_peak', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_month_peak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Month peak', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_month_peak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1200', + }) +# --- # name: test_sensors[sensor.power_meter_power_phase_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -506,7 +608,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '462', }) # --- # name: test_sensors[sensor.power_meter_power_phase_2-entry] @@ -557,7 +659,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '230', }) # --- # name: test_sensors[sensor.power_meter_power_phase_3-entry] @@ -608,7 +710,63 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '230', + }) +# --- +# name: test_sensors[sensor.power_meter_tariff-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_tariff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tariff', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_tariff', + 'unique_id': 'youless_localhost_tariff', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.power_meter_tariff-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Power meter Tariff', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'sensor.power_meter_tariff', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', }) # --- # name: test_sensors[sensor.power_meter_total_energy_import-entry] @@ -710,7 +868,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '240', }) # --- # name: test_sensors[sensor.power_meter_voltage_phase_2-entry] @@ -761,7 +919,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '240', }) # --- # name: test_sensors[sensor.power_meter_voltage_phase_3-entry] @@ -812,7 +970,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '240', }) # --- # name: test_sensors[sensor.s0_meter_current_usage-entry] diff --git a/tests/components/youless/test_init.py b/tests/components/youless/test_init.py index 29db8c66af0..9f0956cea35 100644 --- a/tests/components/youless/test_init.py +++ b/tests/components/youless/test_init.py @@ -15,4 +15,4 @@ async def test_async_setup_entry(hass: HomeAssistant) -> None: assert await setup.async_setup_component(hass, youless.DOMAIN, {}) assert entry.state is ConfigEntryState.LOADED - assert len(hass.states.async_entity_ids()) == 19 + assert len(hass.states.async_entity_ids()) == 22 From a7903d344f2889dc95001ab259d45fce52218ccf Mon Sep 17 00:00:00 2001 From: Jan Stienstra <65826735+RunC0deRun@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:29:00 +0100 Subject: [PATCH 1294/2987] Bump jellyfin-apiclient-python to 1.10.0 (#136872) --- homeassistant/components/jellyfin/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json index 19358cff17c..810b9ea45a9 100644 --- a/homeassistant/components/jellyfin/manifest.json +++ b/homeassistant/components/jellyfin/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["jellyfin_apiclient_python"], - "requirements": ["jellyfin-apiclient-python==1.9.2"], + "requirements": ["jellyfin-apiclient-python==1.10.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index a60f19c6ef3..8a579ca6ba0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1247,7 +1247,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.9.2 +jellyfin-apiclient-python==1.10.0 # homeassistant.components.command_line # homeassistant.components.rest diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c461aabfe8..7612c8466d3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1058,7 +1058,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.9.2 +jellyfin-apiclient-python==1.10.0 # homeassistant.components.command_line # homeassistant.components.rest From 50f3d79fb21495d1f6d6d52b7dc858c7bfea7fb9 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Fri, 31 Jan 2025 11:29:23 +0000 Subject: [PATCH 1295/2987] Add post action to mastodon (#134788) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/mastodon/__init__.py | 24 +- homeassistant/components/mastodon/const.py | 7 + .../components/mastodon/coordinator.py | 15 ++ homeassistant/components/mastodon/icons.json | 5 + homeassistant/components/mastodon/notify.py | 68 +++-- .../components/mastodon/quality_scale.yaml | 8 +- homeassistant/components/mastodon/services.py | 142 ++++++++++ .../components/mastodon/services.yaml | 30 +++ .../components/mastodon/strings.json | 65 +++++ homeassistant/components/mastodon/utils.py | 11 + tests/components/mastodon/test_notify.py | 27 ++ tests/components/mastodon/test_services.py | 246 ++++++++++++++++++ 12 files changed, 607 insertions(+), 41 deletions(-) create mode 100644 homeassistant/components/mastodon/services.py create mode 100644 homeassistant/components/mastodon/services.yaml create mode 100644 tests/components/mastodon/test_services.py diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index f7f974ffbb0..2f713a97dfe 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -2,11 +2,8 @@ from __future__ import annotations -from dataclasses import dataclass - from mastodon.Mastodon import Mastodon, MastodonError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_CLIENT_ID, @@ -16,27 +13,24 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify from .const import CONF_BASE_URL, DOMAIN, LOGGER -from .coordinator import MastodonCoordinator +from .coordinator import MastodonConfigEntry, MastodonCoordinator, MastodonData +from .services import setup_services from .utils import construct_mastodon_username, create_mastodon_client PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR] - -@dataclass -class MastodonData: - """Mastodon data type.""" - - client: Mastodon - instance: dict - account: dict - coordinator: MastodonCoordinator +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -type MastodonConfigEntry = ConfigEntry[MastodonData] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Mastodon component.""" + setup_services(hass) + return True async def async_setup_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool: diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index e0593d15d2c..b7e86eaad5a 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -19,3 +19,10 @@ ACCOUNT_USERNAME: Final = "username" ACCOUNT_FOLLOWERS_COUNT: Final = "followers_count" ACCOUNT_FOLLOWING_COUNT: Final = "following_count" ACCOUNT_STATUSES_COUNT: Final = "statuses_count" + +ATTR_CONFIG_ENTRY_ID = "config_entry_id" +ATTR_STATUS = "status" +ATTR_VISIBILITY = "visibility" +ATTR_CONTENT_WARNING = "content_warning" +ATTR_MEDIA_WARNING = "media_warning" +ATTR_MEDIA = "media" diff --git a/homeassistant/components/mastodon/coordinator.py b/homeassistant/components/mastodon/coordinator.py index f1332a0ea43..4c6fe6b1c88 100644 --- a/homeassistant/components/mastodon/coordinator.py +++ b/homeassistant/components/mastodon/coordinator.py @@ -2,18 +2,33 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta from typing import Any from mastodon import Mastodon from mastodon.Mastodon import MastodonError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER +@dataclass +class MastodonData: + """Mastodon data type.""" + + client: Mastodon + instance: dict + account: dict + coordinator: MastodonCoordinator + + +type MastodonConfigEntry = ConfigEntry[MastodonData] + + class MastodonCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching Mastodon data.""" diff --git a/homeassistant/components/mastodon/icons.json b/homeassistant/components/mastodon/icons.json index 082e27a64c2..e7272c2b6f8 100644 --- a/homeassistant/components/mastodon/icons.json +++ b/homeassistant/components/mastodon/icons.json @@ -11,5 +11,10 @@ "default": "mdi:message-text" } } + }, + "services": { + "post": { + "service": "mdi:message-text" + } } } diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py index dd76d44a02c..8e7e9dc1947 100644 --- a/homeassistant/components/mastodon/notify.py +++ b/homeassistant/components/mastodon/notify.py @@ -2,7 +2,6 @@ from __future__ import annotations -import mimetypes from typing import Any, cast from mastodon import Mastodon @@ -16,15 +15,21 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_BASE_URL, DEFAULT_URL, LOGGER +from .const import ( + ATTR_CONTENT_WARNING, + ATTR_MEDIA_WARNING, + CONF_BASE_URL, + DEFAULT_URL, + DOMAIN, +) +from .utils import get_media_type ATTR_MEDIA = "media" ATTR_TARGET = "target" -ATTR_MEDIA_WARNING = "media_warning" -ATTR_CONTENT_WARNING = "content_warning" PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( { @@ -67,6 +72,17 @@ class MastodonNotificationService(BaseNotificationService): def send_message(self, message: str = "", **kwargs: Any) -> None: """Toot a message, with media perhaps.""" + ir.create_issue( + self.hass, + DOMAIN, + "deprecated_notify_action_mastodon", + breaks_in_ha_version="2025.9.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_notify_action", + ) + target = None if (target_list := kwargs.get(ATTR_TARGET)) is not None: target = cast(list[str], target_list)[0] @@ -82,8 +98,11 @@ class MastodonNotificationService(BaseNotificationService): media = data.get(ATTR_MEDIA) if media: if not self.hass.config.is_allowed_path(media): - LOGGER.warning("'%s' is not a whitelisted directory", media) - return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_whitelisted_directory", + translation_placeholders={"media": media}, + ) mediadata = self._upload_media(media) sensitive = data.get(ATTR_MEDIA_WARNING) @@ -93,34 +112,39 @@ class MastodonNotificationService(BaseNotificationService): try: self.client.status_post( message, - media_ids=mediadata["id"], - sensitive=sensitive, visibility=target, spoiler_text=content_warning, + media_ids=mediadata["id"], + sensitive=sensitive, ) - except MastodonAPIError: - LOGGER.error("Unable to send message") + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_send_message", + ) from err + else: try: self.client.status_post( message, visibility=target, spoiler_text=content_warning ) - except MastodonAPIError: - LOGGER.error("Unable to send message") + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_send_message", + ) from err def _upload_media(self, media_path: Any = None) -> Any: """Upload media.""" with open(media_path, "rb"): - media_type = self._media_type(media_path) + media_type = get_media_type(media_path) try: mediadata = self.client.media_post(media_path, mime_type=media_type) - except MastodonAPIError: - LOGGER.error("Unable to upload image %s", media_path) + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_upload_image", + translation_placeholders={"media_path": media_path}, + ) from err return mediadata - - def _media_type(self, media_path: Any = None) -> Any: - """Get media Type.""" - (media_type, _) = mimetypes.guess_type(media_path) - - return media_type diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml index 86702095e95..43636ed6924 100644 --- a/homeassistant/components/mastodon/quality_scale.yaml +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -29,7 +29,7 @@ rules: action-exceptions: status: todo comment: | - Legacy Notify needs rewriting once Notify architecture stabilizes. + Awaiting legacy Notify deprecation. config-entry-unloading: done docs-configuration-parameters: status: exempt @@ -42,7 +42,7 @@ rules: parallel-updates: status: todo comment: | - Does not set parallel-updates on notify platform. + Awaiting legacy Notify deprecation. reauthentication-flow: status: todo comment: | @@ -50,7 +50,7 @@ rules: test-coverage: status: todo comment: | - Legacy Notify needs rewriting once Notify architecture stabilizes. + Awaiting legacy Notify deprecation. # Gold devices: done @@ -78,7 +78,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: status: todo diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py new file mode 100644 index 00000000000..ab3a89c0c4b --- /dev/null +++ b/homeassistant/components/mastodon/services.py @@ -0,0 +1,142 @@ +"""Define services for the Mastodon integration.""" + +from enum import StrEnum +from functools import partial +from typing import Any, cast + +from mastodon import Mastodon +from mastodon.Mastodon import MastodonAPIError +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from . import MastodonConfigEntry +from .const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_CONTENT_WARNING, + ATTR_MEDIA, + ATTR_MEDIA_WARNING, + ATTR_STATUS, + ATTR_VISIBILITY, + DOMAIN, +) +from .utils import get_media_type + + +class StatusVisibility(StrEnum): + """StatusVisibility model.""" + + PUBLIC = "public" + UNLISTED = "unlisted" + PRIVATE = "private" + DIRECT = "direct" + + +SERVICE_POST = "post" +SERVICE_POST_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_STATUS): str, + vol.Optional(ATTR_VISIBILITY): vol.In([x.lower() for x in StatusVisibility]), + vol.Optional(ATTR_CONTENT_WARNING): str, + vol.Optional(ATTR_MEDIA): str, + vol.Optional(ATTR_MEDIA_WARNING): bool, + } +) + + +def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> MastodonConfigEntry: + """Get the Mastodon config entry.""" + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return cast(MastodonConfigEntry, entry) + + +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Mastodon integration.""" + + async def async_post(call: ServiceCall) -> ServiceResponse: + """Post a status.""" + entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) + client = entry.runtime_data.client + + status = call.data[ATTR_STATUS] + + visibility: str | None = ( + StatusVisibility(call.data[ATTR_VISIBILITY]) + if ATTR_VISIBILITY in call.data + else None + ) + spoiler_text: str | None = call.data.get(ATTR_CONTENT_WARNING) + media_path: str | None = call.data.get(ATTR_MEDIA) + media_warning: str | None = call.data.get(ATTR_MEDIA_WARNING) + + await hass.async_add_executor_job( + partial( + _post, + client=client, + status=status, + visibility=visibility, + spoiler_text=spoiler_text, + media_path=media_path, + sensitive=media_warning, + ) + ) + + return None + + def _post(client: Mastodon, **kwargs: Any) -> None: + """Post to Mastodon.""" + + media_data: dict[str, Any] | None = None + + media_path = kwargs.get("media_path") + if media_path: + if not hass.config.is_allowed_path(media_path): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_whitelisted_directory", + translation_placeholders={"media": media_path}, + ) + + media_type = get_media_type(media_path) + try: + media_data = client.media_post( + media_file=media_path, mime_type=media_type + ) + + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_upload_image", + translation_placeholders={"media_path": media_path}, + ) from err + + kwargs.pop("media_path", None) + + try: + media_ids: str | None = None + if media_data: + media_ids = media_data["id"] + client.status_post(media_ids=media_ids, **kwargs) + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_send_message", + ) from err + + hass.services.async_register( + DOMAIN, SERVICE_POST, async_post, schema=SERVICE_POST_SCHEMA + ) diff --git a/homeassistant/components/mastodon/services.yaml b/homeassistant/components/mastodon/services.yaml new file mode 100644 index 00000000000..161a0d152ca --- /dev/null +++ b/homeassistant/components/mastodon/services.yaml @@ -0,0 +1,30 @@ +post: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: mastodon + status: + required: true + selector: + text: + visibility: + selector: + select: + options: + - public + - unlisted + - private + - direct + translation_key: post_visibility + content_warning: + selector: + text: + media: + selector: + text: + media_warning: + required: true + selector: + boolean: diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index 9df94ecf204..87858f768e4 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -25,6 +25,29 @@ "unknown": "Unknown error occured when connecting to the Mastodon instance." } }, + "exceptions": { + "not_loaded": { + "message": "{target} is not loaded." + }, + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." + }, + "unable_to_send_message": { + "message": "Unable to send message." + }, + "unable_to_upload_image": { + "message": "Unable to upload image {media_path}." + }, + "not_whitelisted_directory": { + "message": "{media} is not a whitelisted directory." + } + }, + "issues": { + "deprecated_notify_action": { + "title": "Deprecated Notify action used for Mastodon", + "description": "The Notify action for Mastodon is deprecated.\n\nUse the `mastodon.post` action instead." + } + }, "entity": { "sensor": { "followers": { @@ -40,5 +63,47 @@ "unit_of_measurement": "posts" } } + }, + "services": { + "post": { + "name": "Post", + "description": "Posts a status on your Mastodon account.", + "fields": { + "config_entry_id": { + "name": "Mastodon account", + "description": "Select the Mastodon account to post to." + }, + "status": { + "name": "Status", + "description": "The status to post." + }, + "visibility": { + "name": "Visibility", + "description": "The visibility of the post (default: account setting)." + }, + "content_warning": { + "name": "Content warning", + "description": "A content warning will be shown before the status text is shown (default: no content warning)." + }, + "media": { + "name": "Media", + "description": "Attach an image or video to the post." + }, + "media_warning": { + "name": "Media warning", + "description": "If an image or video is attached, will mark the media as sensitive (default: no media warning)." + } + } + } + }, + "selector": { + "post_visibility": { + "options": { + "public": "Public - Visible to everyone", + "unlisted": "Unlisted - Public but not shown in public timelines", + "private": "Private - Followers only", + "direct": "Direct - Mentioned accounts only" + } + } } } diff --git a/homeassistant/components/mastodon/utils.py b/homeassistant/components/mastodon/utils.py index 8e1bd697027..e9c2567b675 100644 --- a/homeassistant/components/mastodon/utils.py +++ b/homeassistant/components/mastodon/utils.py @@ -2,6 +2,9 @@ from __future__ import annotations +import mimetypes +from typing import Any + from mastodon import Mastodon from .const import ACCOUNT_USERNAME, DEFAULT_NAME, INSTANCE_DOMAIN, INSTANCE_URI @@ -30,3 +33,11 @@ def construct_mastodon_username( ) return DEFAULT_NAME + + +def get_media_type(media_path: Any = None) -> Any: + """Get media type.""" + + (media_type, _) = mimetypes.guess_type(media_path) + + return media_type diff --git a/tests/components/mastodon/test_notify.py b/tests/components/mastodon/test_notify.py index ab2d7456baf..4242f88d34a 100644 --- a/tests/components/mastodon/test_notify.py +++ b/tests/components/mastodon/test_notify.py @@ -2,10 +2,13 @@ from unittest.mock import AsyncMock +from mastodon.Mastodon import MastodonAPIError +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -36,3 +39,27 @@ async def test_notify( ) assert mock_mastodon_client.status_post.assert_called_once + + +async def test_notify_failed( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the notify raising an error.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_mastodon_client.status_post.side_effect = MastodonAPIError + + with pytest.raises(HomeAssistantError, match="Unable to send message"): + await hass.services.async_call( + NOTIFY_DOMAIN, + "trwnh_mastodon_social", + { + "message": "test toot", + }, + blocking=True, + return_response=False, + ) diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py new file mode 100644 index 00000000000..b958bcff74c --- /dev/null +++ b/tests/components/mastodon/test_services.py @@ -0,0 +1,246 @@ +"""Tests for the Mastodon services.""" + +from unittest.mock import AsyncMock, Mock, patch + +from mastodon.Mastodon import MastodonAPIError +import pytest + +from homeassistant.components.mastodon.const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_CONTENT_WARNING, + ATTR_MEDIA, + ATTR_STATUS, + ATTR_VISIBILITY, + DOMAIN, +) +from homeassistant.components.mastodon.services import SERVICE_POST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("payload", "kwargs"), + [ + ( + { + ATTR_STATUS: "test toot", + }, + { + "status": "test toot", + "spoiler_text": None, + "visibility": None, + "media_ids": None, + "sensitive": None, + }, + ), + ( + {ATTR_STATUS: "test toot", ATTR_VISIBILITY: "private"}, + { + "status": "test toot", + "spoiler_text": None, + "visibility": "private", + "media_ids": None, + "sensitive": None, + }, + ), + ( + { + ATTR_STATUS: "test toot", + ATTR_CONTENT_WARNING: "Spoiler", + ATTR_VISIBILITY: "private", + }, + { + "status": "test toot", + "spoiler_text": "Spoiler", + "visibility": "private", + "media_ids": None, + "sensitive": None, + }, + ), + ( + { + ATTR_STATUS: "test toot", + ATTR_CONTENT_WARNING: "Spoiler", + ATTR_MEDIA: "/image.jpg", + }, + { + "status": "test toot", + "spoiler_text": "Spoiler", + "visibility": None, + "media_ids": "1", + "sensitive": None, + }, + ), + ], +) +async def test_service_post( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, + payload: dict[str, str], + kwargs: dict[str, str | None], +) -> None: + """Test the post service.""" + + await setup_integration(hass, mock_config_entry) + + with ( + patch.object(hass.config, "is_allowed_path", return_value=True), + patch.object(mock_mastodon_client, "media_post", return_value={"id": "1"}), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + } + | payload, + blocking=True, + return_response=False, + ) + + mock_mastodon_client.status_post.assert_called_with(**kwargs) + + mock_mastodon_client.status_post.reset_mock() + + +@pytest.mark.parametrize( + ("payload", "kwargs"), + [ + ( + { + ATTR_STATUS: "test toot", + }, + {"status": "test toot", "spoiler_text": None, "visibility": None}, + ), + ( + { + ATTR_STATUS: "test toot", + ATTR_CONTENT_WARNING: "Spoiler", + ATTR_MEDIA: "/image.jpg", + }, + { + "status": "test toot", + "spoiler_text": "Spoiler", + "visibility": None, + "media_ids": "1", + "sensitive": None, + }, + ), + ], +) +async def test_post_service_failed( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, + payload: dict[str, str], + kwargs: dict[str, str | None], +) -> None: + """Test the post service raising an error.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + hass.config.is_allowed_path = Mock(return_value=True) + mock_mastodon_client.media_post.return_value = {"id": "1"} + + mock_mastodon_client.status_post.side_effect = MastodonAPIError + + with pytest.raises(HomeAssistantError, match="Unable to send message"): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload, + blocking=True, + return_response=False, + ) + + +async def test_post_media_upload_failed( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the post service raising an error because media upload fails.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + payload = {"status": "test toot", "media": "/fail.jpg"} + + mock_mastodon_client.media_post.side_effect = MastodonAPIError + + with ( + patch.object(hass.config, "is_allowed_path", return_value=True), + pytest.raises(HomeAssistantError, match="Unable to upload image /fail.jpg"), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload, + blocking=True, + return_response=False, + ) + + +async def test_post_path_not_whitelisted( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the post service raising an error because the file path is not whitelisted.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + payload = {"status": "test toot", "media": "/fail.jpg"} + + with pytest.raises( + HomeAssistantError, match="/fail.jpg is not a whitelisted directory" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload, + blocking=True, + return_response=False, + ) + + +async def test_service_entry_availability( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the services without valid entry.""" + mock_config_entry.add_to_hass(hass) + mock_config_entry2 = MockConfigEntry(domain=DOMAIN) + mock_config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + payload = {"status": "test toot"} + + with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id} | payload, + blocking=True, + return_response=False, + ) + + with pytest.raises( + ServiceValidationError, match='Integration "mastodon" not found in registry' + ): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: "bad-config_id"} | payload, + blocking=True, + return_response=False, + ) From d83c335ed6926950285dda8e1c16b22db507b83a Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:45:58 +0100 Subject: [PATCH 1296/2987] Add support for standby quickmode to ViCare integration (#133156) --- .../components/vicare/binary_sensor.py | 4 +- homeassistant/components/vicare/button.py | 2 +- homeassistant/components/vicare/fan.py | 33 +- homeassistant/components/vicare/number.py | 4 +- homeassistant/components/vicare/sensor.py | 4 +- homeassistant/components/vicare/utils.py | 10 +- .../components/vicare/fixtures/VitoPure.json | 645 ++++++++++++++++++ .../components/vicare/snapshots/test_fan.ambr | 64 +- tests/components/vicare/test_fan.py | 5 +- 9 files changed, 754 insertions(+), 17 deletions(-) create mode 100644 tests/components/vicare/fixtures/VitoPure.json diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index ced02dae97e..61a5abce942 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -125,7 +125,7 @@ def _build_entities( device.api, ) for description in GLOBAL_SENSORS - if is_supported(description.key, description, device.api) + if is_supported(description.key, description.value_getter, device.api) ) # add component entities for component_list, entity_description_list in ( @@ -143,7 +143,7 @@ def _build_entities( ) for component in component_list for description in entity_description_list - if is_supported(description.key, description, component) + if is_supported(description.key, description.value_getter, component) ) return entities diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index ad7d600eba3..65182990bfb 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -59,7 +59,7 @@ def _build_entities( ) for device in device_list for description in BUTTON_DESCRIPTIONS - if is_supported(description.key, description, device.api) + if is_supported(description.key, description.value_getter, device.api) ] diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 190a893157c..10983a7ad24 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -5,6 +5,7 @@ from __future__ import annotations from contextlib import suppress import enum import logging +from typing import Any from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig @@ -25,7 +26,7 @@ from homeassistant.util.percentage import ( from .entity import ViCareEntity from .types import ViCareConfigEntry, ViCareDevice -from .utils import filter_state, get_device_serial +from .utils import filter_state, get_device_serial, is_supported _LOGGER = logging.getLogger(__name__) @@ -73,6 +74,12 @@ class VentilationMode(enum.StrEnum): return None +class VentilationQuickmode(enum.StrEnum): + """ViCare ventilation quickmodes.""" + + STANDBY = "standby" + + HA_TO_VICARE_MODE_VENTILATION = { VentilationMode.PERMANENT: "permanent", VentilationMode.VENTILATION: "ventilation", @@ -147,6 +154,19 @@ class ViCareFan(ViCareEntity, FanEntity): if supported_levels is not None and len(supported_levels) > 0: self._attr_supported_features |= FanEntityFeature.SET_SPEED + # evaluate quickmodes + quickmodes: list[str] = ( + device.getVentilationQuickmodes() + if is_supported( + "getVentilationQuickmodes", + lambda api: api.getVentilationQuickmodes(), + device, + ) + else [] + ) + if VentilationQuickmode.STANDBY in quickmodes: + self._attr_supported_features |= FanEntityFeature.TURN_OFF + def update(self) -> None: """Update state of fan.""" level: str | None = None @@ -155,6 +175,7 @@ class ViCareFan(ViCareEntity, FanEntity): self._attr_preset_mode = VentilationMode.from_vicare_mode( self._api.getActiveVentilationMode() ) + with suppress(PyViCareNotSupportedFeatureError): level = filter_state(self._api.getVentilationLevel()) if level is not None and level in ORDERED_NAMED_FAN_SPEEDS: @@ -175,8 +196,12 @@ class ViCareFan(ViCareEntity, FanEntity): @property def is_on(self) -> bool | None: """Return true if the entity is on.""" - # Viessmann ventilation unit cannot be turned off - return True + return self.percentage is not None and self.percentage > 0 + + def turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + + self._api.activateVentilationQuickmode(str(VentilationQuickmode.STANDBY)) @property def icon(self) -> str | None: @@ -206,6 +231,8 @@ class ViCareFan(ViCareEntity, FanEntity): """Set the speed of the fan, as a percentage.""" if self._attr_preset_mode != str(VentilationMode.PERMANENT): self.set_preset_mode(VentilationMode.PERMANENT) + elif self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + self._api.deactivateVentilationQuickmode(str(VentilationQuickmode.STANDBY)) level = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage) _LOGGER.debug("changing ventilation level to %s", level) diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index 8ffaa727634..534c0752cc1 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -353,7 +353,7 @@ def _build_entities( device.api, ) for description in DEVICE_ENTITY_DESCRIPTIONS - if is_supported(description.key, description, device.api) + if is_supported(description.key, description.value_getter, device.api) ) # add component entities entities.extend( @@ -366,7 +366,7 @@ def _build_entities( ) for circuit in get_circuits(device.api) for description in CIRCUIT_ENTITY_DESCRIPTIONS - if is_supported(description.key, description, circuit) + if is_supported(description.key, description.value_getter, circuit) ) return entities diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 091deeba2a9..c99e7857d9b 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -1007,7 +1007,7 @@ def _build_entities( device.api, ) for description in GLOBAL_SENSORS - if is_supported(description.key, description, device.api) + if is_supported(description.key, description.value_getter, device.api) ) # add component entities for component_list, entity_description_list in ( @@ -1025,7 +1025,7 @@ def _build_entities( ) for component in component_list for description in entity_description_list - if is_supported(description.key, description, component) + if is_supported(description.key, description.value_getter, component) ) return entities diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index a2c31df4259..ef018a60f16 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping import logging from typing import Any @@ -30,7 +30,7 @@ from .const import ( VICARE_TOKEN_FILENAME, HeatingType, ) -from .types import ViCareConfigEntry, ViCareRequiredKeysMixin +from .types import ViCareConfigEntry _LOGGER = logging.getLogger(__name__) @@ -81,12 +81,12 @@ def get_device_serial(device: PyViCareDevice) -> str | None: def is_supported( name: str, - entity_description: ViCareRequiredKeysMixin, + getter: Callable[[PyViCareDevice], Any], vicare_device, ) -> bool: """Check if the PyViCare device supports the requested sensor.""" try: - entity_description.value_getter(vicare_device) + getter(vicare_device) except PyViCareNotSupportedFeatureError: _LOGGER.debug("Feature not supported %s", name) return False @@ -131,5 +131,5 @@ def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceCompone def filter_state(state: str) -> str | None: - """Remove invalid states.""" + """Return the state if not 'nothing' or 'unknown'.""" return None if state in ("nothing", "unknown") else state diff --git a/tests/components/vicare/fixtures/VitoPure.json b/tests/components/vicare/fixtures/VitoPure.json new file mode 100644 index 00000000000..1e1cdef97ec --- /dev/null +++ b/tests/components/vicare/fixtures/VitoPure.json @@ -0,0 +1,645 @@ +{ + "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelFour", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelFour" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelOne", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelOne" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelThree", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelThree" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelTwo", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelTwo" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.filterChange", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.filterChange" + }, + { + "apiVersion": 1, + "commands": { + "setLevel": { + "isExecutable": true, + "name": "setLevel", + "params": { + "level": { + "constraints": { + "enum": ["levelOne", "levelTwo", "levelThree", "levelFour"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.permanent/commands/setLevel" + } + }, + "deviceId": "0", + "feature": "ventilation.operating.modes.permanent", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-12-17T13:24:03.411Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.permanent" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.sensorDriven", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.sensorDriven" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "levelTwo" + } + }, + "timestamp": "2024-12-17T13:24:03.411Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "timeout": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour/commands/deactivate" + }, + "setDefaultRuntime": { + "isExecutable": true, + "name": "setDefaultRuntime", + "params": { + "defaultRuntime": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour/commands/setDefaultRuntime" + }, + "setTimeout": { + "isExecutable": true, + "name": "setTimeout", + "params": { + "timeout": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour/commands/setTimeout" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.forcedLevelFour", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "defaultRuntime": { + "type": "number", + "unit": "minutes", + "value": 30 + }, + "isActiveWritable": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T13:24:04.515Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "timeout": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent/commands/deactivate" + }, + "setDefaultRuntime": { + "isExecutable": true, + "name": "setDefaultRuntime", + "params": { + "defaultRuntime": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent/commands/setDefaultRuntime" + }, + "setTimeout": { + "isExecutable": true, + "name": "setTimeout", + "params": { + "timeout": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent/commands/setTimeout" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.silent", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "defaultRuntime": { + "type": "number", + "unit": "minutes", + "value": 30 + }, + "isActiveWritable": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T13:24:04.515Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.standby/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.standby/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T13:24:04.515Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.productIdentification", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "product": { + "type": "object", + "value": { + "busAddress": 0, + "busType": "OwnBus", + "productFamily": "B_00059_VP300", + "viessmannIdentificationNumber": "################" + } + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.productIdentification" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.errors.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [] + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.messages.errors.raw" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "begin": { + "constraints": { + "regEx": "^[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + }, + "end": { + "constraints": { + "regEx": "^[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "device.time.daylightSaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.device.variant", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "Vitopure350" + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.device.variant" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["permanent", "ventilation", "sensorDriven"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setMode" + }, + "setModeContinuousSensorOverride": { + "isExecutable": false, + "name": "setModeContinuousSensorOverride", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setModeContinuousSensorOverride" + } + }, + "deviceId": "0", + "feature": "ventilation.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "sensorDriven" + } + }, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.state", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "demand": { + "type": "string", + "value": "unknown" + }, + "level": { + "type": "string", + "value": "unknown" + }, + "reason": { + "type": "string", + "value": "standby" + } + }, + "timestamp": "2024-12-17T13:24:04.515Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.state" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "standby", + "maxEntries": 4, + "modes": ["levelOne", "levelTwo", "levelThree", "levelFour"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "ventilation.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ] + } + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule" + } + ] +} diff --git a/tests/components/vicare/snapshots/test_fan.ambr b/tests/components/vicare/snapshots/test_fan.ambr index 3ecc4277fd9..745e77dac5c 100644 --- a/tests/components/vicare/snapshots/test_fan.ambr +++ b/tests/components/vicare/snapshots/test_fan.ambr @@ -60,6 +60,68 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', + }) +# --- +# name: test_all_entities[fan.model1_ventilation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.model1_ventilation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan', + 'original_name': 'Ventilation', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'ventilation', + 'unique_id': 'gateway1_deviceId1-ventilation', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fan.model1_ventilation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model1 Ventilation', + 'icon': 'mdi:fan', + 'percentage': 0, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': list([ + , + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.model1_ventilation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', }) # --- diff --git a/tests/components/vicare/test_fan.py b/tests/components/vicare/test_fan.py index aaf6a968ffd..5683f48f01f 100644 --- a/tests/components/vicare/test_fan.py +++ b/tests/components/vicare/test_fan.py @@ -23,7 +23,10 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - fixtures: list[Fixture] = [Fixture({"type:ventilation"}, "vicare/ViAir300F.json")] + fixtures: list[Fixture] = [ + Fixture({"type:ventilation"}, "vicare/ViAir300F.json"), + Fixture({"type:ventilation"}, "vicare/VitoPure.json"), + ] with ( patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.FAN]), From cde59613a58cdfccc4aae56b51412c7634c664f1 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:52:17 +0100 Subject: [PATCH 1297/2987] Refactor eheimdigital platform async_setup_entry (#136745) --- .../components/eheimdigital/climate.py | 21 +++-- .../components/eheimdigital/coordinator.py | 9 ++- .../components/eheimdigital/light.py | 31 ++++---- .../eheimdigital/snapshots/test_climate.ambr | 76 +++++++++++++++++++ tests/components/eheimdigital/test_climate.py | 35 +++++++++ 5 files changed, 148 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py index 16771ba227d..9b1f825dece 100644 --- a/homeassistant/components/eheimdigital/climate.py +++ b/homeassistant/components/eheimdigital/climate.py @@ -2,6 +2,7 @@ from typing import Any +from eheimdigital.device import EheimDigitalDevice from eheimdigital.heater import EheimDigitalHeater from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit @@ -39,17 +40,23 @@ async def async_setup_entry( """Set up the callbacks for the coordinator so climate entities can be added as devices are found.""" coordinator = entry.runtime_data - async def async_setup_device_entities(device_address: str) -> None: - """Set up the light entities for a device.""" - device = coordinator.hub.devices[device_address] + def async_setup_device_entities( + device_address: str | dict[str, EheimDigitalDevice], + ) -> None: + """Set up the climate entities for one or multiple devices.""" + entities: list[EheimDigitalHeaterClimate] = [] + if isinstance(device_address, str): + device_address = {device_address: coordinator.hub.devices[device_address]} + for device in device_address.values(): + if isinstance(device, EheimDigitalHeater): + entities.append(EheimDigitalHeaterClimate(coordinator, device)) + coordinator.known_devices.add(device.mac_address) - if isinstance(device, EheimDigitalHeater): - async_add_entities([EheimDigitalHeaterClimate(coordinator, device)]) + async_add_entities(entities) coordinator.add_platform_callback(async_setup_device_entities) - for device_address in entry.runtime_data.hub.devices: - await async_setup_device_entities(device_address) + async_setup_device_entities(coordinator.hub.devices) class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateEntity): diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py index f122a1227c5..ee4f09426b7 100644 --- a/homeassistant/components/eheimdigital/coordinator.py +++ b/homeassistant/components/eheimdigital/coordinator.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine -from typing import Any +from collections.abc import Callable from aiohttp import ClientError from eheimdigital.device import EheimDigitalDevice @@ -19,7 +18,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER -type AsyncSetupDeviceEntitiesCallback = Callable[[str], Coroutine[Any, Any, None]] +type AsyncSetupDeviceEntitiesCallback = Callable[ + [str | dict[str, EheimDigitalDevice]], None +] class EheimDigitalUpdateCoordinator( @@ -61,7 +62,7 @@ class EheimDigitalUpdateCoordinator( if device_address not in self.known_devices: for platform_callback in self.platform_callbacks: - await platform_callback(device_address) + platform_callback(device_address) async def _async_receive_callback(self) -> None: self.async_set_updated_data(self.hub.devices) diff --git a/homeassistant/components/eheimdigital/light.py b/homeassistant/components/eheimdigital/light.py index a119e0bda8d..5ae0a6e866a 100644 --- a/homeassistant/components/eheimdigital/light.py +++ b/homeassistant/components/eheimdigital/light.py @@ -3,6 +3,7 @@ from typing import Any from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.device import EheimDigitalDevice from eheimdigital.types import EheimDigitalClientError, LightMode from homeassistant.components.light import ( @@ -37,24 +38,28 @@ async def async_setup_entry( """Set up the callbacks for the coordinator so lights can be added as devices are found.""" coordinator = entry.runtime_data - async def async_setup_device_entities(device_address: str) -> None: - """Set up the light entities for a device.""" - device = coordinator.hub.devices[device_address] + def async_setup_device_entities( + device_address: str | dict[str, EheimDigitalDevice], + ) -> None: + """Set up the light entities for one or multiple devices.""" entities: list[EheimDigitalClassicLEDControlLight] = [] + if isinstance(device_address, str): + device_address = {device_address: coordinator.hub.devices[device_address]} + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicLEDControl): + for channel in range(2): + if len(device.tankconfig[channel]) > 0: + entities.append( + EheimDigitalClassicLEDControlLight( + coordinator, device, channel + ) + ) + coordinator.known_devices.add(device.mac_address) - if isinstance(device, EheimDigitalClassicLEDControl): - for channel in range(2): - if len(device.tankconfig[channel]) > 0: - entities.append( - EheimDigitalClassicLEDControlLight(coordinator, device, channel) - ) - coordinator.known_devices.add(device.mac_address) async_add_entities(entities) coordinator.add_platform_callback(async_setup_device_entities) - - for device_address in entry.runtime_data.hub.devices: - await async_setup_device_entities(device_address) + async_setup_device_entities(coordinator.hub.devices) class EheimDigitalClassicLEDControlLight( diff --git a/tests/components/eheimdigital/snapshots/test_climate.ambr b/tests/components/eheimdigital/snapshots/test_climate.ambr index 02d60677b24..d81c59e5af1 100644 --- a/tests/components/eheimdigital/snapshots/test_climate.ambr +++ b/tests/components/eheimdigital/snapshots/test_climate.ambr @@ -1,4 +1,80 @@ # serializer version: 1 +# name: test_dynamic_new_devices[climate.mock_heater_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.mock_heater_none', + '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': None, + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'heater', + 'unique_id': '00:00:00:00:00:02', + 'unit_of_measurement': None, + }) +# --- +# name: test_dynamic_new_devices[climate.mock_heater_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 24.2, + 'friendly_name': 'Mock Heater None', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 25.5, + }), + 'context': , + 'entity_id': 'climate.mock_heater_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- # name: test_setup_heater[climate.mock_heater_none-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py index 4e770882263..f64b7d7e740 100644 --- a/tests/components/eheimdigital/test_climate.py +++ b/tests/components/eheimdigital/test_climate.py @@ -56,6 +56,41 @@ async def test_setup_heater( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +async def test_dynamic_new_devices( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + heater_mock: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test light platform setup with at first no devices and dynamically adding a device.""" + mock_config_entry.add_to_hass(hass) + + eheimdigital_hub_mock.return_value.devices = {} + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert ( + len( + entity_registry.entities.get_entries_for_config_entry_id( + mock_config_entry.entry_id + ) + ) + == 0 + ) + + eheimdigital_hub_mock.return_value.devices = {"00:00:00:00:00:02": heater_mock} + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + @pytest.mark.parametrize( ("preset_mode", "heater_mode"), [ From f21ab24b8b812ce6e3ca63138af60877a1519a51 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 12:55:51 +0100 Subject: [PATCH 1298/2987] Add sensors for drink stats per key to lamarzocco (#136582) * Add sensors for drink stats per key to lamarzocco * Add icon * Use UOM translations * fix tests * remove translation key * Update sensor.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .../components/lamarzocco/icons.json | 3 + homeassistant/components/lamarzocco/sensor.py | 64 +++++- .../components/lamarzocco/strings.json | 10 +- .../lamarzocco/snapshots/test_sensor.ambr | 208 +++++++++++++++++- tests/components/lamarzocco/test_sensor.py | 1 + 5 files changed, 275 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/lamarzocco/icons.json b/homeassistant/components/lamarzocco/icons.json index 79267b4abd4..2be882fafea 100644 --- a/homeassistant/components/lamarzocco/icons.json +++ b/homeassistant/components/lamarzocco/icons.json @@ -95,6 +95,9 @@ "drink_stats_flushing": { "default": "mdi:chart-line" }, + "drink_stats_coffee_key": { + "default": "mdi:chart-scatter-plot" + }, "shot_timer": { "default": "mdi:timer" }, diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 406e8e40e92..a2d6143daa5 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -3,7 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass -from pylamarzocco.const import BoilerType, MachineModel +from pylamarzocco.const import KEYS_PER_MODEL, BoilerType, MachineModel, PhysicalKey from pylamarzocco.devices.machine import LaMarzoccoMachine from homeassistant.components.sensor import ( @@ -21,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .coordinator import LaMarzoccoConfigEntry +from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity # Coordinator is used to centralize the data updates @@ -37,6 +37,15 @@ class LaMarzoccoSensorEntityDescription( value_fn: Callable[[LaMarzoccoMachine], float | int] +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoKeySensorEntityDescription( + LaMarzoccoEntityDescription, SensorEntityDescription +): + """Description of a keyed La Marzocco sensor.""" + + value_fn: Callable[[LaMarzoccoMachine, PhysicalKey], int | None] + + ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="shot_timer", @@ -79,7 +88,6 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="drink_stats_coffee", translation_key="drink_stats_coffee", - native_unit_of_measurement="drinks", state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda device: device.statistics.total_coffee, available_fn=lambda device: len(device.statistics.drink_stats) > 0, @@ -88,7 +96,6 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="drink_stats_flushing", translation_key="drink_stats_flushing", - native_unit_of_measurement="drinks", state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda device: device.statistics.total_flushes, available_fn=lambda device: len(device.statistics.drink_stats) > 0, @@ -96,6 +103,18 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( ), ) +KEY_STATISTIC_ENTITIES: tuple[LaMarzoccoKeySensorEntityDescription, ...] = ( + LaMarzoccoKeySensorEntityDescription( + key="drink_stats_coffee_key", + translation_key="drink_stats_coffee_key", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda device, key: device.statistics.drink_stats.get(key), + available_fn=lambda device: len(device.statistics.drink_stats) > 0, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), +) + SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="scale_battery", @@ -120,6 +139,8 @@ async def async_setup_entry( """Set up sensor entities.""" config_coordinator = entry.runtime_data.config_coordinator + entities: list[LaMarzoccoSensorEntity | LaMarzoccoKeySensorEntity] = [] + entities = [ LaMarzoccoSensorEntity(config_coordinator, description) for description in ENTITIES @@ -142,6 +163,14 @@ async def async_setup_entry( if description.supported_fn(statistics_coordinator) ) + num_keys = KEYS_PER_MODEL[MachineModel(config_coordinator.device.model)] + if num_keys > 0: + entities.extend( + LaMarzoccoKeySensorEntity(statistics_coordinator, description, key) + for description in KEY_STATISTIC_ENTITIES + for key in range(1, num_keys + 1) + ) + def _async_add_new_scale() -> None: async_add_entities( LaMarzoccoScaleSensorEntity(config_coordinator, description) @@ -159,11 +188,36 @@ class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity): entity_description: LaMarzoccoSensorEntityDescription @property - def native_value(self) -> int | float: + def native_value(self) -> int | float | None: """State of the sensor.""" return self.entity_description.value_fn(self.coordinator.device) +class LaMarzoccoKeySensorEntity(LaMarzoccoEntity, SensorEntity): + """Sensor for a La Marzocco key.""" + + entity_description: LaMarzoccoKeySensorEntityDescription + + def __init__( + self, + coordinator: LaMarzoccoUpdateCoordinator, + description: LaMarzoccoKeySensorEntityDescription, + key: int, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, description) + self.key = key + self._attr_translation_placeholders = {"key": str(key)} + self._attr_unique_id = f"{super()._attr_unique_id}_key{key}" + + @property + def native_value(self) -> int | None: + """State of the sensor.""" + return self.entity_description.value_fn( + self.coordinator.device, PhysicalKey(self.key) + ) + + class LaMarzoccoScaleSensorEntity(LaMarzoccoSensorEntity, LaMarzoccScaleEntity): """Sensor for a La Marzocco scale.""" diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index cc96e4615dc..62050685c27 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -175,10 +175,16 @@ "name": "Current steam temperature" }, "drink_stats_coffee": { - "name": "Total coffees made" + "name": "Total coffees made", + "unit_of_measurement": "coffees" + }, + "drink_stats_coffee_key": { + "name": "Coffees made Key {key}", + "unit_of_measurement": "coffees" }, "drink_stats_flushing": { - "name": "Total flushes made" + "name": "Total flushes made", + "unit_of_measurement": "flushes" }, "shot_timer": { "name": "Shot timer" diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index 9e2eae482d2..be2b1672cb9 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -50,6 +50,206 @@ 'unit_of_measurement': '%', }) # --- +# name: test_sensors[sensor.gs012345_coffees_made_key_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs012345_coffees_made_key_1', + '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': 'Coffees made Key 1', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee_key', + 'unique_id': 'GS012345_drink_stats_coffee_key_key1', + 'unit_of_measurement': 'coffees', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Coffees made Key 1', + 'state_class': , + 'unit_of_measurement': 'coffees', + }), + 'context': , + 'entity_id': 'sensor.gs012345_coffees_made_key_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1047', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs012345_coffees_made_key_2', + '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': 'Coffees made Key 2', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee_key', + 'unique_id': 'GS012345_drink_stats_coffee_key_key2', + 'unit_of_measurement': 'coffees', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Coffees made Key 2', + 'state_class': , + 'unit_of_measurement': 'coffees', + }), + 'context': , + 'entity_id': 'sensor.gs012345_coffees_made_key_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '560', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs012345_coffees_made_key_3', + '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': 'Coffees made Key 3', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee_key', + 'unique_id': 'GS012345_drink_stats_coffee_key_key3', + 'unit_of_measurement': 'coffees', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Coffees made Key 3', + 'state_class': , + 'unit_of_measurement': 'coffees', + }), + 'context': , + 'entity_id': 'sensor.gs012345_coffees_made_key_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '468', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs012345_coffees_made_key_4', + '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': 'Coffees made Key 4', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee_key', + 'unique_id': 'GS012345_drink_stats_coffee_key_key4', + 'unit_of_measurement': 'coffees', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Coffees made Key 4', + 'state_class': , + 'unit_of_measurement': 'coffees', + }), + 'context': , + 'entity_id': 'sensor.gs012345_coffees_made_key_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '312', + }) +# --- # name: test_sensors[sensor.gs012345_current_coffee_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -241,7 +441,7 @@ 'supported_features': 0, 'translation_key': 'drink_stats_coffee', 'unique_id': 'GS012345_drink_stats_coffee', - 'unit_of_measurement': 'drinks', + 'unit_of_measurement': 'coffees', }) # --- # name: test_sensors[sensor.gs012345_total_coffees_made-state] @@ -249,7 +449,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS012345 Total coffees made', 'state_class': , - 'unit_of_measurement': 'drinks', + 'unit_of_measurement': 'coffees', }), 'context': , 'entity_id': 'sensor.gs012345_total_coffees_made', @@ -291,7 +491,7 @@ 'supported_features': 0, 'translation_key': 'drink_stats_flushing', 'unique_id': 'GS012345_drink_stats_flushing', - 'unit_of_measurement': 'drinks', + 'unit_of_measurement': 'flushes', }) # --- # name: test_sensors[sensor.gs012345_total_flushes_made-state] @@ -299,7 +499,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS012345 Total flushes made', 'state_class': , - 'unit_of_measurement': 'drinks', + 'unit_of_measurement': 'flushes', }), 'context': , 'entity_id': 'sensor.gs012345_total_flushes_made', diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py index 3385e2b3891..43a0826d551 100644 --- a/tests/components/lamarzocco/test_sensor.py +++ b/tests/components/lamarzocco/test_sensor.py @@ -18,6 +18,7 @@ from . import async_init_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, mock_lamarzocco: MagicMock, From c7041a97be79def58ffc771e2e3b2702f4c8b9cd Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:03:13 +0000 Subject: [PATCH 1299/2987] Do not duplicate device class translations in ring integration (#136868) --- .../components/ring/binary_sensor.py | 1 - homeassistant/components/ring/sensor.py | 1 - homeassistant/components/ring/strings.json | 6 - tests/components/ring/common.py | 60 ++++ .../ring/snapshots/test_binary_sensor.ambr | 6 +- .../ring/snapshots/test_sensor.ambr | 318 +++++++++++++++++- tests/components/ring/test_binary_sensor.py | 10 +- tests/components/ring/test_number.py | 5 +- tests/components/ring/test_sensor.py | 9 +- 9 files changed, 387 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 2c458985498..da0e0cc1d9b 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -55,7 +55,6 @@ BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = ( ), RingBinarySensorEntityDescription( key=KIND_MOTION, - translation_key=KIND_MOTION, device_class=BinarySensorDeviceClass.MOTION, capability=RingCapability.MOTION_DETECTION, deprecated_info=DeprecatedInfo( diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index cf851a113bc..a2f72b94336 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -258,7 +258,6 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription[Any], ...] = ( ), RingSensorEntityDescription[RingGeneric]( key="wifi_signal_strength", - translation_key="wifi_signal_strength", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 8320a3ec47f..219463d92d9 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -56,9 +56,6 @@ "binary_sensor": { "ding": { "name": "Ding" - }, - "motion": { - "name": "Motion" } }, "event": { @@ -122,9 +119,6 @@ }, "wifi_signal_category": { "name": "Wi-Fi signal category" - }, - "wifi_signal_strength": { - "name": "Wi-Fi signal strength" } }, "switch": { diff --git a/tests/components/ring/common.py b/tests/components/ring/common.py index 22fa1c2bf32..e7af1d94855 100644 --- a/tests/components/ring/common.py +++ b/tests/components/ring/common.py @@ -6,6 +6,7 @@ from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN from homeassistant.components.ring import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er, translation from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -35,3 +36,62 @@ async def setup_automation(hass: HomeAssistant, alias: str, entity_id: str) -> N } }, ) + + +async def async_check_entity_translations( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry_id: str, + platform_domain: str, +) -> None: + """Check that entity translations are used correctly. + + Check no unused translations in strings. + Check no translation_key defined when translation not in strings. + Check no translation defined when device class translation can be used. + """ + entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) + + assert entity_entries + assert len({entity_entry.domain for entity_entry in entity_entries}) == 1, ( + "Limit the loaded platforms to 1 platform." + ) + + translations = await translation.async_get_translations( + hass, "en", "entity", [DOMAIN] + ) + device_class_translations = await translation.async_get_translations( + hass, "en", "entity_component", [platform_domain] + ) + unique_device_classes = set() + used_translation_keys = set() + for entity_entry in entity_entries: + dc_translation = None + if entity_entry.original_device_class: + dc_translation_key = f"component.{platform_domain}.entity_component.{entity_entry.original_device_class.value}.name" + dc_translation = device_class_translations.get(dc_translation_key) + + if entity_entry.translation_key: + key = f"component.{DOMAIN}.entity.{entity_entry.domain}.{entity_entry.translation_key}.name" + entity_translation = translations.get(key) + assert entity_translation, ( + f"Translation key {entity_entry.translation_key} defined for {entity_entry.entity_id} not in strings.json" + ) + assert dc_translation != entity_translation, ( + f"Translation {key} is defined the same as the device class translation." + ) + used_translation_keys.add(key) + + else: + unique_key = (entity_entry.device_id, entity_entry.original_device_class) + assert unique_key not in unique_device_classes, ( + f"No translation key and multiple entities using {entity_entry.original_device_class}" + ) + unique_device_classes.add(entity_entry.original_device_class) + + for defined_key in translations: + if defined_key.split(".")[3] != platform_domain: + continue + assert defined_key in used_translation_keys, ( + f"Translation key {defined_key} unused." + ) diff --git a/tests/components/ring/snapshots/test_binary_sensor.ambr b/tests/components/ring/snapshots/test_binary_sensor.ambr index 2f8e4d8a219..84c727e6340 100644 --- a/tests/components/ring/snapshots/test_binary_sensor.ambr +++ b/tests/components/ring/snapshots/test_binary_sensor.ambr @@ -75,7 +75,7 @@ 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'motion', + 'translation_key': None, 'unique_id': '987654-motion', 'unit_of_measurement': None, }) @@ -123,7 +123,7 @@ 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'motion', + 'translation_key': None, 'unique_id': '765432-motion', 'unit_of_measurement': None, }) @@ -219,7 +219,7 @@ 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'motion', + 'translation_key': None, 'unique_id': '345678-motion', 'unit_of_measurement': None, }) diff --git a/tests/components/ring/snapshots/test_sensor.ambr b/tests/components/ring/snapshots/test_sensor.ambr index 9fd1ac7ba84..a90bb3fe5f6 100644 --- a/tests/components/ring/snapshots/test_sensor.ambr +++ b/tests/components/ring/snapshots/test_sensor.ambr @@ -117,11 +117,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '123456-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -131,7 +131,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Downstairs Wi-Fi signal strength', + 'friendly_name': 'Downstairs Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , @@ -294,6 +294,102 @@ 'state': 'unknown', }) # --- +# name: test_states[sensor.front_door_last_ding-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.front_door_last_ding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last ding', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_ding', + 'unique_id': '987654-last_ding', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_door_last_ding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Front Door Last ding', + }), + 'context': , + 'entity_id': 'sensor.front_door_last_ding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.front_door_last_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.front_door_last_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last motion', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_motion', + 'unique_id': '987654-last_motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_door_last_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Front Door Last motion', + }), + 'context': , + 'entity_id': 'sensor.front_door_last_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_states[sensor.front_door_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -412,11 +508,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '987654-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -426,7 +522,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Front Door Wi-Fi signal strength', + 'friendly_name': 'Front Door Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , @@ -485,6 +581,102 @@ 'state': 'unknown', }) # --- +# name: test_states[sensor.front_last_ding-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.front_last_ding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last ding', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_ding', + 'unique_id': '765432-last_ding', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_last_ding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Front Last ding', + }), + 'context': , + 'entity_id': 'sensor.front_last_ding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.front_last_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.front_last_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last motion', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_motion', + 'unique_id': '765432-last_motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_last_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Front Last motion', + }), + 'context': , + 'entity_id': 'sensor.front_last_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_states[sensor.front_wifi_signal_category-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -556,11 +748,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '765432-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -570,7 +762,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Front Wi-Fi signal strength', + 'friendly_name': 'Front Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , @@ -893,11 +1085,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '185036587-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -907,7 +1099,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Ingress Wi-Fi signal strength', + 'friendly_name': 'Ingress Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , @@ -1018,6 +1210,102 @@ 'state': 'unknown', }) # --- +# name: test_states[sensor.internal_last_ding-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.internal_last_ding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last ding', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_ding', + 'unique_id': '345678-last_ding', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.internal_last_ding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Internal Last ding', + }), + 'context': , + 'entity_id': 'sensor.internal_last_ding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.internal_last_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.internal_last_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last motion', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_motion', + 'unique_id': '345678-last_motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.internal_last_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Internal Last motion', + }), + 'context': , + 'entity_id': 'sensor.internal_last_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_states[sensor.internal_wifi_signal_category-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1089,11 +1377,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '345678-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -1103,7 +1391,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Internal Wi-Fi signal strength', + 'friendly_name': 'Internal Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , diff --git a/tests/components/ring/test_binary_sensor.py b/tests/components/ring/test_binary_sensor.py index 81d7d6e6687..c588b022265 100644 --- a/tests/components/ring/test_binary_sensor.py +++ b/tests/components/ring/test_binary_sensor.py @@ -18,7 +18,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component -from .common import MockConfigEntry, setup_automation, setup_platform +from .common import ( + MockConfigEntry, + async_check_entity_translations, + setup_automation, + setup_platform, +) from .device_mocks import ( FRONT_DEVICE_ID, FRONT_DOOR_DEVICE_ID, @@ -67,6 +72,9 @@ async def test_states( ) -> None: """Test states.""" await setup_platform(hass, Platform.BINARY_SENSOR) + await async_check_entity_translations( + hass, entity_registry, mock_config_entry.entry_id, BINARY_SENSOR_DOMAIN + ) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/ring/test_number.py b/tests/components/ring/test_number.py index aa484c6a7b2..9f1581742f2 100644 --- a/tests/components/ring/test_number.py +++ b/tests/components/ring/test_number.py @@ -14,7 +14,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import MockConfigEntry, setup_platform +from .common import MockConfigEntry, async_check_entity_translations, setup_platform from tests.common import snapshot_platform @@ -54,6 +54,9 @@ async def test_states( mock_config_entry.add_to_hass(hass) await setup_platform(hass, Platform.NUMBER) + await async_check_entity_translations( + hass, entity_registry, mock_config_entry.entry_id, NUMBER_DOMAIN + ) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/ring/test_sensor.py b/tests/components/ring/test_sensor.py index 48f679c4524..dcd3d5bddd6 100644 --- a/tests/components/ring/test_sensor.py +++ b/tests/components/ring/test_sensor.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .common import MockConfigEntry, setup_platform +from .common import MockConfigEntry, async_check_entity_translations, setup_platform from .device_mocks import ( DOWNSTAIRS_DEVICE_ID, FRONT_DEVICE_ID, @@ -57,6 +57,10 @@ def create_deprecated_and_disabled_sensor_entities( create_entry("ingress", "doorbell_volume", INGRESS_DEVICE_ID) create_entry("ingress", "mic_volume", INGRESS_DEVICE_ID) create_entry("ingress", "voice_volume", INGRESS_DEVICE_ID) + for desc in ("last_motion", "last_ding"): + create_entry("front", desc, FRONT_DEVICE_ID) + create_entry("front_door", desc, FRONT_DOOR_DEVICE_ID) + create_entry("internal", desc, INTERNAL_DEVICE_ID) # Disabled for desc in ("wifi_signal_category", "wifi_signal_strength"): @@ -78,6 +82,9 @@ async def test_states( """Test states.""" mock_config_entry.add_to_hass(hass) await setup_platform(hass, Platform.SENSOR) + await async_check_entity_translations( + hass, entity_registry, mock_config_entry.entry_id, SENSOR_DOMAIN + ) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 66f048f49f73d2c4c49583f685bb7894c856ce01 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 31 Jan 2025 13:15:22 +0100 Subject: [PATCH 1300/2987] Make Reolink reboot button always available (#136667) --- homeassistant/components/reolink/button.py | 3 ++- homeassistant/components/reolink/entity.py | 4 ++++ homeassistant/components/reolink/switch.py | 19 +------------------ 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 6b1fcc65a2f..c1a2aed4119 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -138,6 +138,7 @@ BUTTON_ENTITIES = ( HOST_BUTTON_ENTITIES = ( ReolinkHostButtonEntityDescription( key="reboot", + always_available=True, device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -218,7 +219,7 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): class ReolinkHostButtonEntity(ReolinkHostCoordinatorEntity, ButtonEntity): - """Base button entity class for Reolink IP cameras.""" + """Base button entity class for Reolink hosts.""" entity_description: ReolinkHostButtonEntityDescription diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 63c95c25025..e3a84579865 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -25,6 +25,7 @@ class ReolinkEntityDescription(EntityDescription): cmd_key: str | None = None cmd_id: int | None = None + always_available: bool = False @dataclass(frozen=True, kw_only=True) @@ -92,6 +93,9 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] @property def available(self) -> bool: """Return True if entity is available.""" + if self.entity_description.always_available: + return True + return ( self._host.api.session_active and not self._host.api.baichuan.privacy_mode() diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index cecb0b0000f..a0b8824782a 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -206,11 +206,9 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.pir_reduce_alarm(ch) is True, method=lambda api, ch, value: api.set_pir(ch, reduce_alarm=value), ), -) - -AVAILABILITY_SWITCH_ENTITIES = ( ReolinkSwitchEntityDescription( key="privacy_mode", + always_available=True, translation_key="privacy_mode", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "privacy_mode"), @@ -355,12 +353,6 @@ async def async_setup_entry( for entity_description in CHIME_SWITCH_ENTITIES for chime in reolink_data.host.api.chime_list ) - entities.extend( - ReolinkAvailabilitySwitchEntity(reolink_data, channel, entity_description) - for entity_description in AVAILABILITY_SWITCH_ENTITIES - for channel in reolink_data.host.api.channels - if entity_description.supported(reolink_data.host.api, channel) - ) # Can be removed in HA 2025.4.0 depricated_dict = {} @@ -426,15 +418,6 @@ class ReolinkSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity): self.async_write_ha_state() -class ReolinkAvailabilitySwitchEntity(ReolinkSwitchEntity): - """Switch entity class for Reolink IP cameras which will be available even if API is unavailable.""" - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._host.api.camera_online(self._channel) - - class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity): """Switch entity class for Reolink NVR features.""" From b702d88ab7b356744969dd93b9b6c320ff227cd4 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 31 Jan 2025 13:17:22 +0100 Subject: [PATCH 1301/2987] Use runtime_data in motionmount integration (#136999) --- homeassistant/components/motionmount/__init__.py | 12 ++++++++---- .../components/motionmount/binary_sensor.py | 13 ++++++++----- homeassistant/components/motionmount/entity.py | 6 ++++-- homeassistant/components/motionmount/number.py | 16 +++++++++++----- homeassistant/components/motionmount/select.py | 10 ++++++---- homeassistant/components/motionmount/sensor.py | 13 ++++++++----- 6 files changed, 45 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/motionmount/__init__.py b/homeassistant/components/motionmount/__init__.py index 9b27ce9bc6c..9c2ac6fa180 100644 --- a/homeassistant/components/motionmount/__init__.py +++ b/homeassistant/components/motionmount/__init__.py @@ -14,6 +14,8 @@ from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN, EMPTY_MAC +type MotionMountConfigEntry = ConfigEntry[motionmount.MotionMount] + PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.NUMBER, @@ -22,7 +24,7 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MotionMountConfigEntry) -> bool: """Set up Vogel's MotionMount from a config entry.""" host = entry.data[CONF_HOST] @@ -65,17 +67,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Store an API object for your platforms to access - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = mm + entry.runtime_data = mm 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: MotionMountConfigEntry +) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - mm: motionmount.MotionMount = hass.data[DOMAIN].pop(entry.entry_id) + mm = entry.runtime_data await mm.disconnect() return unload_ok diff --git a/homeassistant/components/motionmount/binary_sensor.py b/homeassistant/components/motionmount/binary_sensor.py index 45b6e821440..f19af67e198 100644 --- a/homeassistant/components/motionmount/binary_sensor.py +++ b/homeassistant/components/motionmount/binary_sensor.py @@ -6,19 +6,20 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import MotionMountConfigEntry from .entity import MotionMountEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: MotionMountConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" - mm = hass.data[DOMAIN][entry.entry_id] + mm = entry.runtime_data async_add_entities([MotionMountMovingSensor(mm, entry)]) @@ -29,7 +30,9 @@ class MotionMountMovingSensor(MotionMountEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOVING _attr_translation_key = "motionmount_is_moving" - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize moving binary sensor entity.""" super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-moving" diff --git a/homeassistant/components/motionmount/entity.py b/homeassistant/components/motionmount/entity.py index 57a5f638d54..81d4d0119b5 100644 --- a/homeassistant/components/motionmount/entity.py +++ b/homeassistant/components/motionmount/entity.py @@ -5,12 +5,12 @@ from typing import TYPE_CHECKING import motionmount -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_PIN from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.entity import Entity +from . import MotionMountConfigEntry from .const import DOMAIN, EMPTY_MAC _LOGGER = logging.getLogger(__name__) @@ -22,7 +22,9 @@ class MotionMountEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize general MotionMount entity.""" self.mm = mm self.config_entry = config_entry diff --git a/homeassistant/components/motionmount/number.py b/homeassistant/components/motionmount/number.py index b42c04a6588..6305820174f 100644 --- a/homeassistant/components/motionmount/number.py +++ b/homeassistant/components/motionmount/number.py @@ -5,21 +5,23 @@ import socket import motionmount from homeassistant.components.number import NumberEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import MotionMountConfigEntry from .const import DOMAIN from .entity import MotionMountEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: MotionMountConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" - mm: motionmount.MotionMount = hass.data[DOMAIN][entry.entry_id] + mm = entry.runtime_data async_add_entities( ( @@ -37,7 +39,9 @@ class MotionMountExtension(MotionMountEntity, NumberEntity): _attr_native_unit_of_measurement = PERCENTAGE _attr_translation_key = "motionmount_extension" - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize Extension number.""" super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-extension" @@ -66,7 +70,9 @@ class MotionMountTurn(MotionMountEntity, NumberEntity): _attr_native_unit_of_measurement = PERCENTAGE _attr_translation_key = "motionmount_turn" - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize Turn number.""" super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-turn" diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index 23fcf576af0..31c5056b91f 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -7,11 +7,11 @@ import socket import motionmount from homeassistant.components.select import SelectEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import MotionMountConfigEntry from .const import DOMAIN, WALL_PRESET_NAME from .entity import MotionMountEntity @@ -20,10 +20,12 @@ SCAN_INTERVAL = timedelta(seconds=60) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: MotionMountConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" - mm = hass.data[DOMAIN][entry.entry_id] + mm = entry.runtime_data async_add_entities([MotionMountPresets(mm, entry)], True) @@ -37,7 +39,7 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): def __init__( self, mm: motionmount.MotionMount, - config_entry: ConfigEntry, + config_entry: MotionMountConfigEntry, ) -> None: """Initialize Preset selector.""" super().__init__(mm, config_entry) diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py index 933b637b0c2..8e55fad4a8b 100644 --- a/homeassistant/components/motionmount/sensor.py +++ b/homeassistant/components/motionmount/sensor.py @@ -3,19 +3,20 @@ import motionmount from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import MotionMountConfigEntry from .entity import MotionMountEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: MotionMountConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" - mm = hass.data[DOMAIN][entry.entry_id] + mm = entry.runtime_data async_add_entities((MotionMountErrorStatusSensor(mm, entry),)) @@ -27,7 +28,9 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): _attr_options = ["none", "motor", "internal"] _attr_translation_key = "motionmount_error_status" - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize sensor entiry.""" super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-error-status" From 8eb9cc0e8ef35273fc698878d2ea25f82981906d Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Fri, 31 Jan 2025 13:19:04 +0100 Subject: [PATCH 1302/2987] Remove the unparsed config flow error from Swiss public transport (#136998) --- homeassistant/components/swiss_public_transport/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index 270cb097e0a..64817f89f42 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -28,7 +28,7 @@ "time_station": "Usually the departure time of a connection when it leaves the start station is tracked. Alternatively, track the time when the connection arrives at its end station.", "time_mode": "Time mode lets you change the departure timing and fix it to a specific time (e.g. 7:12:00 AM every morning) or add a moving offset (e.g. +00:05:00 taking into account the time to walk to the station)." }, - "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nCheck the [stationboard]({stationboard_url}) for valid stations.", + "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nConsult the stationboard linked above.", "title": "Swiss Public Transport" }, "time_fixed": { From 0773e37dab53438e6e95e4c81b46de13ebb12191 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 31 Jan 2025 13:23:44 +0100 Subject: [PATCH 1303/2987] Create/delete lists at runtime in Bring integration (#130098) --- homeassistant/components/bring/coordinator.py | 34 +++++- homeassistant/components/bring/entity.py | 14 +-- .../components/bring/quality_scale.yaml | 4 +- homeassistant/components/bring/sensor.py | 35 ++++-- homeassistant/components/bring/todo.py | 28 +++-- tests/components/bring/fixtures/items.json | 2 +- tests/components/bring/fixtures/items2.json | 46 ++++++++ .../bring/fixtures/items_invitation.json | 2 +- .../bring/fixtures/items_shared.json | 2 +- tests/components/bring/fixtures/lists2.json | 9 ++ .../bring/snapshots/test_diagnostics.ambr | 4 +- tests/components/bring/test_diagnostics.py | 11 +- tests/components/bring/test_init.py | 101 +++++++++++++++++- tests/components/bring/test_sensor.py | 6 +- tests/components/bring/test_todo.py | 11 +- 15 files changed, 266 insertions(+), 43 deletions(-) create mode 100644 tests/components/bring/fixtures/items2.json create mode 100644 tests/components/bring/fixtures/lists2.json diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index 0511d285afc..9473d0614e3 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -19,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -39,6 +40,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): config_entry: ConfigEntry user_settings: BringUserSettingsResponse + lists: list[BringList] def __init__(self, hass: HomeAssistant, bring: Bring) -> None: """Initialize the Bring data coordinator.""" @@ -49,10 +51,13 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): update_interval=timedelta(seconds=90), ) self.bring = bring + self.previous_lists: set[str] = set() async def _async_update_data(self) -> dict[str, BringData]: + """Fetch the latest data from bring.""" + try: - lists_response = await self.bring.load_lists() + self.lists = (await self.bring.load_lists()).lists except BringRequestException as e: raise UpdateFailed("Unable to connect and retrieve data from bring") from e except BringParseException as e: @@ -72,8 +77,14 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): ) from exc return self.data + if self.previous_lists - ( + current_lists := {lst.listUuid for lst in self.lists} + ): + self._purge_deleted_lists() + self.previous_lists = current_lists + list_dict: dict[str, BringData] = {} - for lst in lists_response.lists: + for lst in self.lists: if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx: continue try: @@ -95,6 +106,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): try: await self.bring.login() self.user_settings = await self.bring.get_all_user_settings() + self.lists = (await self.bring.load_lists()).lists except BringRequestException as e: raise ConfigEntryNotReady( translation_domain=DOMAIN, @@ -111,3 +123,21 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): translation_key="setup_authentication_exception", translation_placeholders={CONF_EMAIL: self.bring.mail}, ) from e + self._purge_deleted_lists() + + def _purge_deleted_lists(self) -> None: + """Purge device entries of deleted lists.""" + + device_reg = dr.async_get(self.hass) + identifiers = { + (DOMAIN, f"{self.config_entry.unique_id}_{lst.listUuid}") + for lst in self.lists + } + for device in dr.async_entries_for_config_entry( + device_reg, self.config_entry.entry_id + ): + if not set(device.identifiers) & identifiers: + _LOGGER.debug("Removing obsolete device entry %s", device.name) + device_reg.async_update_device( + device.id, remove_config_entry_id=self.config_entry.entry_id + ) diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py index 74076d66df9..3de0140d82c 100644 --- a/homeassistant/components/bring/entity.py +++ b/homeassistant/components/bring/entity.py @@ -2,11 +2,13 @@ from __future__ import annotations +from bring_api.types import BringList + from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import BringData, BringDataUpdateCoordinator +from .coordinator import BringDataUpdateCoordinator class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): @@ -17,20 +19,20 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): def __init__( self, coordinator: BringDataUpdateCoordinator, - bring_list: BringData, + bring_list: BringList, ) -> None: """Initialize the entity.""" - super().__init__(coordinator, bring_list.lst.listUuid) + super().__init__(coordinator, bring_list.listUuid) - self._list_uuid = bring_list.lst.listUuid + self._list_uuid = bring_list.listUuid self.device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - name=bring_list.lst.name, + name=bring_list.name, identifiers={ (DOMAIN, f"{coordinator.config_entry.unique_id}_{self._list_uuid}") }, manufacturer="Bring! Labs AG", model="Bring! Grocery Shopping List", - configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.data.keys()).index(self._list_uuid)}", + configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}", ) diff --git a/homeassistant/components/bring/quality_scale.yaml b/homeassistant/components/bring/quality_scale.yaml index 1fdb3f13f1b..0b4191d5c61 100644 --- a/homeassistant/components/bring/quality_scale.yaml +++ b/homeassistant/components/bring/quality_scale.yaml @@ -53,7 +53,7 @@ rules: docs-supported-functions: todo docs-troubleshooting: todo docs-use-cases: todo - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done @@ -65,7 +65,7 @@ rules: status: exempt comment: | no repairs - stale-devices: todo + stale-devices: done # Platinum async-dependency: done inject-websession: done diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index 02bd0e50788..651307a2eee 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -8,6 +8,7 @@ from enum import StrEnum from bring_api import BringUserSettingsResponse from bring_api.const import BRING_SUPPORTED_LOCALES +from bring_api.types import BringList from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,7 +16,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -90,16 +91,28 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" coordinator = config_entry.runtime_data + lists_added: set[str] = set() - async_add_entities( - BringSensorEntity( - coordinator, - bring_list, - description, - ) - for description in SENSOR_DESCRIPTIONS - for bring_list in coordinator.data.values() - ) + @callback + def add_entities() -> None: + """Add sensor entities.""" + nonlocal lists_added + + if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added: + async_add_entities( + BringSensorEntity( + coordinator, + bring_list, + description, + ) + for description in SENSOR_DESCRIPTIONS + for bring_list in coordinator.lists + if bring_list.listUuid in new_lists + ) + lists_added |= new_lists + + coordinator.async_add_listener(add_entities) + add_entities() class BringSensorEntity(BringBaseEntity, SensorEntity): @@ -110,7 +123,7 @@ class BringSensorEntity(BringBaseEntity, SensorEntity): def __init__( self, coordinator: BringDataUpdateCoordinator, - bring_list: BringData, + bring_list: BringList, entity_description: BringSensorEntityDescription, ) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index 7ab60084314..ad4de4196c1 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -12,6 +12,7 @@ from bring_api import ( BringNotificationType, BringRequestException, ) +from bring_api.types import BringList import voluptuous as vol from homeassistant.components.todo import ( @@ -20,7 +21,7 @@ from homeassistant.components.todo import ( TodoListEntity, TodoListEntityFeature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -45,14 +46,23 @@ async def async_setup_entry( ) -> None: """Set up the sensor from a config entry created in the integrations UI.""" coordinator = config_entry.runtime_data + lists_added: set[str] = set() - async_add_entities( - BringTodoListEntity( - coordinator, - bring_list=bring_list, - ) - for bring_list in coordinator.data.values() - ) + @callback + def add_entities() -> None: + """Add or remove todo list entities.""" + nonlocal lists_added + + if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added: + async_add_entities( + BringTodoListEntity(coordinator, bring_list) + for bring_list in coordinator.lists + if bring_list.listUuid in new_lists + ) + lists_added |= new_lists + + coordinator.async_add_listener(add_entities) + add_entities() platform = entity_platform.async_get_current_platform() @@ -81,7 +91,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): ) def __init__( - self, coordinator: BringDataUpdateCoordinator, bring_list: BringData + self, coordinator: BringDataUpdateCoordinator, bring_list: BringList ) -> None: """Initialize the entity.""" super().__init__(coordinator, bring_list) diff --git a/tests/components/bring/fixtures/items.json b/tests/components/bring/fixtures/items.json index eecdbaac8c7..02bfdc9e038 100644 --- a/tests/components/bring/fixtures/items.json +++ b/tests/components/bring/fixtures/items.json @@ -1,5 +1,5 @@ { - "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "status": "REGISTERED", "items": { "purchase": [ diff --git a/tests/components/bring/fixtures/items2.json b/tests/components/bring/fixtures/items2.json new file mode 100644 index 00000000000..c8f2a5e9d02 --- /dev/null +++ b/tests/components/bring/fixtures/items2.json @@ -0,0 +1,46 @@ +{ + "uuid": "b4776778-7f6c-496e-951b-92a35d3db0dd", + "status": "REGISTERED", + "items": { + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] + } +} diff --git a/tests/components/bring/fixtures/items_invitation.json b/tests/components/bring/fixtures/items_invitation.json index be3671c359a..6b6623011da 100644 --- a/tests/components/bring/fixtures/items_invitation.json +++ b/tests/components/bring/fixtures/items_invitation.json @@ -1,5 +1,5 @@ { - "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "status": "INVITATION", "items": { "purchase": [ diff --git a/tests/components/bring/fixtures/items_shared.json b/tests/components/bring/fixtures/items_shared.json index 5e381d27ca8..6892e07e4e6 100644 --- a/tests/components/bring/fixtures/items_shared.json +++ b/tests/components/bring/fixtures/items_shared.json @@ -1,5 +1,5 @@ { - "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "status": "SHARED", "items": { "purchase": [ diff --git a/tests/components/bring/fixtures/lists2.json b/tests/components/bring/fixtures/lists2.json new file mode 100644 index 00000000000..511de7bd181 --- /dev/null +++ b/tests/components/bring/fixtures/lists2.json @@ -0,0 +1,9 @@ +{ + "lists": [ + { + "listUuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", + "name": "Einkauf", + "theme": "ch.publisheria.bring.theme.home" + } + ] +} diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index 5955ded832a..740f4902fc3 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -47,7 +47,7 @@ ]), }), 'status': 'REGISTERED', - 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + 'uuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', }), 'lst': dict({ 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', @@ -101,7 +101,7 @@ ]), }), 'status': 'REGISTERED', - 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + 'uuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', }), 'lst': dict({ 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', diff --git a/tests/components/bring/test_diagnostics.py b/tests/components/bring/test_diagnostics.py index a86de5a0d2d..c4b8defca82 100644 --- a/tests/components/bring/test_diagnostics.py +++ b/tests/components/bring/test_diagnostics.py @@ -1,11 +1,15 @@ """Test for diagnostics platform of the Bring! integration.""" +from unittest.mock import AsyncMock + +from bring_api import BringItemsResponse import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bring.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -16,8 +20,13 @@ async def test_diagnostics( hass_client: ClientSessionGenerator, bring_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, + mock_bring_client: AsyncMock, ) -> None: """Test diagnostics.""" + mock_bring_client.get_list.side_effect = [ + BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)), + BringItemsResponse.from_json(load_fixture("items2.json", DOMAIN)), + ] bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py index 8c215e024d5..a77c709315f 100644 --- a/tests/components/bring/test_init.py +++ b/tests/components/bring/test_init.py @@ -3,7 +3,12 @@ from datetime import timedelta from unittest.mock import AsyncMock -from bring_api import BringAuthException, BringParseException, BringRequestException +from bring_api import ( + BringAuthException, + BringListResponse, + BringParseException, + BringRequestException, +) from freezegun.api import FrozenDateTimeFactory import pytest @@ -16,7 +21,7 @@ from homeassistant.helpers import device_registry as dr from .conftest import UUID -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture async def setup_integration( @@ -115,6 +120,25 @@ async def test_config_entry_not_ready( assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize("exception", [BringRequestException, BringParseException]) +async def test_config_entry_not_ready_udpdate_failed( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + exception: Exception, +) -> None: + """Test config entry not ready from update failed in _async_update_data.""" + mock_bring_client.load_lists.side_effect = [ + mock_bring_client.load_lists.return_value, + exception, + ] + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY + + @pytest.mark.parametrize( ("exception", "state"), [ @@ -133,7 +157,10 @@ async def test_config_entry_not_ready_auth_error( ) -> None: """Test config entry not ready from authentication error.""" - mock_bring_client.load_lists.side_effect = BringAuthException + mock_bring_client.load_lists.side_effect = [ + mock_bring_client.load_lists.return_value, + BringAuthException, + ] mock_bring_client.retrieve_new_access_token.side_effect = exception bring_config_entry.add_to_hass(hass) @@ -170,3 +197,71 @@ async def test_coordinator_skips_deactivated( await hass.async_block_till_done() assert mock_bring_client.get_list.await_count == 1 + + +async def test_purge_devices( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test removing device entry of deleted list.""" + list_uuid = "b4776778-7f6c-496e-951b-92a35d3db0dd" + await setup_integration(hass, bring_config_entry) + + assert bring_config_entry.state is ConfigEntryState.LOADED + + assert device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) + + mock_bring_client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists2.json", DOMAIN) + ) + + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) + is None + ) + + +async def test_create_devices( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test create device entry for new lists.""" + list_uuid = "b4776778-7f6c-496e-951b-92a35d3db0dd" + mock_bring_client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists2.json", DOMAIN) + ) + await setup_integration(hass, bring_config_entry) + + assert bring_config_entry.state is ConfigEntryState.LOADED + + assert ( + device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) + is None + ) + + mock_bring_client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists.json", DOMAIN) + ) + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) diff --git a/tests/components/bring/test_sensor.py b/tests/components/bring/test_sensor.py index 442fea5a247..f704debcea9 100644 --- a/tests/components/bring/test_sensor.py +++ b/tests/components/bring/test_sensor.py @@ -26,15 +26,19 @@ def sensor_only() -> Generator[None]: yield -@pytest.mark.usefixtures("mock_bring_client") async def test_setup( hass: HomeAssistant, bring_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_bring_client: AsyncMock, ) -> None: """Snapshot test states of sensor platform.""" + mock_bring_client.get_list.side_effect = [ + BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)), + BringItemsResponse.from_json(load_fixture("items2.json", DOMAIN)), + ] bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/bring/test_todo.py b/tests/components/bring/test_todo.py index 9cc4ae3d888..9df7b892db8 100644 --- a/tests/components/bring/test_todo.py +++ b/tests/components/bring/test_todo.py @@ -4,10 +4,11 @@ from collections.abc import Generator import re from unittest.mock import AsyncMock, patch -from bring_api import BringItemOperation, BringRequestException +from bring_api import BringItemOperation, BringItemsResponse, BringRequestException import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bring.const import DOMAIN from homeassistant.components.todo import ( ATTR_DESCRIPTION, ATTR_ITEM, @@ -21,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, load_fixture, snapshot_platform @pytest.fixture(autouse=True) @@ -40,9 +41,13 @@ async def test_todo( bring_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_bring_client: AsyncMock, ) -> None: """Snapshot test states of todo platform.""" - + mock_bring_client.get_list.side_effect = [ + BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)), + BringItemsResponse.from_json(load_fixture("items2.json", DOMAIN)), + ] bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() From d4a355e6847fbb8612c3446471b302017f3c4553 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 31 Jan 2025 13:29:07 +0100 Subject: [PATCH 1304/2987] Bump python-MotionMount to 2.3.0 (#136985) --- homeassistant/components/motionmount/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motionmount/manifest.json b/homeassistant/components/motionmount/manifest.json index 1fa3d31cfab..422be417006 100644 --- a/homeassistant/components/motionmount/manifest.json +++ b/homeassistant/components/motionmount/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/motionmount", "integration_type": "device", "iot_class": "local_push", - "requirements": ["python-MotionMount==2.2.0"], + "requirements": ["python-MotionMount==2.3.0"], "zeroconf": ["_tvm._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 8a579ca6ba0..6bb68d58a50 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2346,7 +2346,7 @@ pytautulli==23.1.1 pythinkingcleaner==0.0.3 # homeassistant.components.motionmount -python-MotionMount==2.2.0 +python-MotionMount==2.3.0 # homeassistant.components.awair python-awair==0.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7612c8466d3..0f7cef8c557 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1912,7 +1912,7 @@ pyswitchbee==1.8.3 pytautulli==23.1.1 # homeassistant.components.motionmount -python-MotionMount==2.2.0 +python-MotionMount==2.3.0 # homeassistant.components.awair python-awair==0.2.4 From 21ffcf853b31500cbdd1a85489395de5c81bd4dd Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 13:39:59 +0100 Subject: [PATCH 1305/2987] Call backup listener during setup in onedrive (#136990) --- homeassistant/components/onedrive/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 7419ca6e20c..4ae5ac73560 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -97,6 +97,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> backup_folder_id=backup_folder_id, ) + _async_notify_backup_listeners_soon(hass) + return True From 84ae476b678fa0e593e83a01db16359ca021189b Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Fri, 31 Jan 2025 15:22:25 +0100 Subject: [PATCH 1306/2987] Energy distance units (#136933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/number/const.py | 11 +++++ .../components/recorder/statistics.py | 2 + .../components/recorder/websocket_api.py | 2 + homeassistant/components/sensor/const.py | 14 +++++++ .../components/sensor/device_condition.py | 3 ++ .../components/sensor/device_trigger.py | 3 ++ homeassistant/components/sensor/strings.json | 5 +++ homeassistant/const.py | 9 +++++ homeassistant/util/unit_conversion.py | 28 +++++++++++++ tests/util/test_unit_conversion.py | 40 +++++++++++++++++++ 10 files changed, 117 insertions(+) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 1a9c6c91ca7..463fcc919c7 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -23,6 +23,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfEnergyDistance, UnitOfFrequency, UnitOfInformation, UnitOfIrradiance, @@ -166,6 +167,15 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ + ENERGY_DISTANCE = "energy_distance" + """Energy distance. + + Use this device class for sensors measuring energy by distance, for example the amount + of electric energy consumed by an electric car. + + Unit of measurement: `kWh/100km`, `mi/kWh`, `km/kWh` + """ + ENERGY_STORAGE = "energy_storage" """Stored energy. @@ -447,6 +457,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.MILLISECONDS, }, NumberDeviceClass.ENERGY: set(UnitOfEnergy), + NumberDeviceClass.ENERGY_DISTANCE: set(UnitOfEnergyDistance), NumberDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy), NumberDeviceClass.FREQUENCY: set(UnitOfFrequency), NumberDeviceClass.GAS: { diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 8995f57ef30..2b6640270ed 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -38,6 +38,7 @@ from homeassistant.util.unit_conversion import ( ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, + EnergyDistanceConverter, InformationConverter, MassConverter, PowerConverter, @@ -147,6 +148,7 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { for unit in ElectricPotentialConverter.VALID_UNITS }, **{unit: EnergyConverter for unit in EnergyConverter.VALID_UNITS}, + **{unit: EnergyDistanceConverter for unit in EnergyDistanceConverter.VALID_UNITS}, **{unit: InformationConverter for unit in InformationConverter.VALID_UNITS}, **{unit: MassConverter for unit in MassConverter.VALID_UNITS}, **{unit: PowerConverter for unit in PowerConverter.VALID_UNITS}, diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index ee5c5dd6d75..03d9e725170 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -25,6 +25,7 @@ from homeassistant.util.unit_conversion import ( ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, + EnergyDistanceConverter, InformationConverter, MassConverter, PowerConverter, @@ -67,6 +68,7 @@ UNIT_SCHEMA = vol.Schema( vol.Optional("electric_current"): vol.In(ElectricCurrentConverter.VALID_UNITS), vol.Optional("voltage"): vol.In(ElectricPotentialConverter.VALID_UNITS), vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS), + vol.Optional("energy_distance"): vol.In(EnergyDistanceConverter.VALID_UNITS), vol.Optional("information"): vol.In(InformationConverter.VALID_UNITS), vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS), vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS), diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index aaa14f4637c..59a87c419e0 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -23,6 +23,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfEnergyDistance, UnitOfFrequency, UnitOfInformation, UnitOfIrradiance, @@ -51,6 +52,7 @@ from homeassistant.util.unit_conversion import ( ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, + EnergyDistanceConverter, InformationConverter, MassConverter, PowerConverter, @@ -194,6 +196,15 @@ class SensorDeviceClass(StrEnum): Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ + ENERGY_DISTANCE = "energy_distance" + """Energy distance. + + Use this device class for sensors measuring energy by distance, for example the amount + of electric energy consumed by an electric car. + + Unit of measurement: `kWh/100km`, `mi/kWh`, `km/kWh` + """ + ENERGY_STORAGE = "energy_storage" """Stored energy. @@ -500,6 +511,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.DISTANCE: DistanceConverter, SensorDeviceClass.DURATION: DurationConverter, SensorDeviceClass.ENERGY: EnergyConverter, + SensorDeviceClass.ENERGY_DISTANCE: EnergyDistanceConverter, SensorDeviceClass.ENERGY_STORAGE: EnergyConverter, SensorDeviceClass.GAS: VolumeConverter, SensorDeviceClass.POWER: PowerConverter, @@ -541,6 +553,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.MILLISECONDS, }, SensorDeviceClass.ENERGY: set(UnitOfEnergy), + SensorDeviceClass.ENERGY_DISTANCE: set(UnitOfEnergyDistance), SensorDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy), SensorDeviceClass.FREQUENCY: set(UnitOfFrequency), SensorDeviceClass.GAS: { @@ -622,6 +635,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, }, + SensorDeviceClass.ENERGY_DISTANCE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.ENERGY_STORAGE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.ENUM: set(), SensorDeviceClass.FREQUENCY: {SensorStateClass.MEASUREMENT}, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index fc25dce18fc..4a68fbabe8f 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -48,6 +48,7 @@ CONF_IS_DATA_SIZE = "is_data_size" CONF_IS_DISTANCE = "is_distance" CONF_IS_DURATION = "is_duration" CONF_IS_ENERGY = "is_energy" +CONF_IS_ENERGY_DISTANCE = "is_energy_distance" CONF_IS_FREQUENCY = "is_frequency" CONF_IS_HUMIDITY = "is_humidity" CONF_IS_GAS = "is_gas" @@ -102,6 +103,7 @@ ENTITY_CONDITIONS = { SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_IS_DISTANCE}], SensorDeviceClass.DURATION: [{CONF_TYPE: CONF_IS_DURATION}], SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_IS_ENERGY}], + SensorDeviceClass.ENERGY_DISTANCE: [{CONF_TYPE: CONF_IS_ENERGY_DISTANCE}], SensorDeviceClass.ENERGY_STORAGE: [{CONF_TYPE: CONF_IS_ENERGY}], SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_IS_FREQUENCY}], SensorDeviceClass.GAS: [{CONF_TYPE: CONF_IS_GAS}], @@ -168,6 +170,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_DISTANCE, CONF_IS_DURATION, CONF_IS_ENERGY, + CONF_IS_ENERGY_DISTANCE, CONF_IS_FREQUENCY, CONF_IS_GAS, CONF_IS_HUMIDITY, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index d75b3aa6e41..0003b83d05a 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -47,6 +47,7 @@ CONF_DATA_SIZE = "data_size" CONF_DISTANCE = "distance" CONF_DURATION = "duration" CONF_ENERGY = "energy" +CONF_ENERGY_DISTANCE = "energy_distance" CONF_FREQUENCY = "frequency" CONF_GAS = "gas" CONF_HUMIDITY = "humidity" @@ -101,6 +102,7 @@ ENTITY_TRIGGERS = { SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_DISTANCE}], SensorDeviceClass.DURATION: [{CONF_TYPE: CONF_DURATION}], SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_ENERGY}], + SensorDeviceClass.ENERGY_DISTANCE: [{CONF_TYPE: CONF_ENERGY_DISTANCE}], SensorDeviceClass.ENERGY_STORAGE: [{CONF_TYPE: CONF_ENERGY}], SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_FREQUENCY}], SensorDeviceClass.GAS: [{CONF_TYPE: CONF_GAS}], @@ -168,6 +170,7 @@ TRIGGER_SCHEMA = vol.All( CONF_DISTANCE, CONF_DURATION, CONF_ENERGY, + CONF_ENERGY_DISTANCE, CONF_FREQUENCY, CONF_GAS, CONF_HUMIDITY, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index d44d621f82d..dcbb4d3c826 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -17,6 +17,7 @@ "is_distance": "Current {entity_name} distance", "is_duration": "Current {entity_name} duration", "is_energy": "Current {entity_name} energy", + "is_energy_distance": "Current {entity_name} energy per distance", "is_frequency": "Current {entity_name} frequency", "is_gas": "Current {entity_name} gas", "is_humidity": "Current {entity_name} humidity", @@ -69,6 +70,7 @@ "distance": "{entity_name} distance changes", "duration": "{entity_name} duration changes", "energy": "{entity_name} energy changes", + "energy_distance": "{entity_name} energy per distance changes", "frequency": "{entity_name} frequency changes", "gas": "{entity_name} gas changes", "humidity": "{entity_name} humidity changes", @@ -183,6 +185,9 @@ "energy": { "name": "Energy" }, + "energy_distance": { + "name": "Energy per distance" + }, "energy_storage": { "name": "Stored energy" }, diff --git a/homeassistant/const.py b/homeassistant/const.py index bdce303e64a..7775b618795 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -632,6 +632,15 @@ class UnitOfEnergy(StrEnum): GIGA_CALORIE = "Gcal" +# Energy Distance units +class UnitOfEnergyDistance(StrEnum): + """Energy Distance units.""" + + KILO_WATT_HOUR_PER_100_KM = "kWh/100km" + MILES_PER_KILO_WATT_HOUR = "mi/kWh" + KM_PER_KILO_WATT_HOUR = "km/kWh" + + # Electric_current units class UnitOfElectricCurrent(StrEnum): """Electric current units.""" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index ad320cdb9ae..67258c9cd09 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -17,6 +17,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfEnergyDistance, UnitOfInformation, UnitOfLength, UnitOfMass, @@ -90,6 +91,7 @@ class BaseUnitConverter: VALID_UNITS: set[str | None] _UNIT_CONVERSION: dict[str | None, float] + _UNIT_INVERSES: set[str] = set() @classmethod def convert(cls, value: float, from_unit: str | None, to_unit: str | None) -> float: @@ -105,6 +107,8 @@ class BaseUnitConverter: if from_unit == to_unit: return lambda value: value from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + if cls._are_unit_inverses(from_unit, to_unit): + return lambda val: to_ratio / (val / from_ratio) return lambda val: (val / from_ratio) * to_ratio @classmethod @@ -129,6 +133,8 @@ class BaseUnitConverter: if from_unit == to_unit: return lambda value: value from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + if cls._are_unit_inverses(from_unit, to_unit): + return lambda val: None if val is None else to_ratio / (val / from_ratio) return lambda val: None if val is None else (val / from_ratio) * to_ratio @classmethod @@ -138,6 +144,12 @@ class BaseUnitConverter: from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) return from_ratio / to_ratio + @classmethod + @lru_cache + def _are_unit_inverses(cls, from_unit: str | None, to_unit: str | None) -> bool: + """Return true if one unit is an inverse but not the other.""" + return (from_unit in cls._UNIT_INVERSES) != (to_unit in cls._UNIT_INVERSES) + class DataRateConverter(BaseUnitConverter): """Utility to convert data rate values.""" @@ -284,6 +296,22 @@ class EnergyConverter(BaseUnitConverter): VALID_UNITS = set(UnitOfEnergy) +class EnergyDistanceConverter(BaseUnitConverter): + """Utility to convert vehicle energy consumption values.""" + + UNIT_CLASS = "energy_distance" + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM: 1, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR: 100 * _KM_TO_M / _MILE_TO_M, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR: 100, + } + _UNIT_INVERSES: set[str] = { + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + } + VALID_UNITS = set(UnitOfEnergyDistance) + + class InformationConverter(BaseUnitConverter): """Utility to convert information values.""" diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 1336364f4cb..aeea4ad9a5a 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -18,6 +18,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfEnergyDistance, UnitOfInformation, UnitOfLength, UnitOfMass, @@ -43,6 +44,7 @@ from homeassistant.util.unit_conversion import ( ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, + EnergyDistanceConverter, InformationConverter, MassConverter, PowerConverter, @@ -79,6 +81,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { SpeedConverter, TemperatureConverter, UnitlessRatioConverter, + EnergyDistanceConverter, VolumeConverter, VolumeFlowRateConverter, ) @@ -115,6 +118,11 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo 1000, ), EnergyConverter: (UnitOfEnergy.WATT_HOUR, UnitOfEnergy.KILO_WATT_HOUR, 1000), + EnergyDistanceConverter: ( + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + 0.621371, + ), InformationConverter: (UnitOfInformation.BITS, UnitOfInformation.BYTES, 8), MassConverter: (UnitOfMass.STONES, UnitOfMass.KILOGRAMS, 0.157473), PowerConverter: (UnitOfPower.WATT, UnitOfPower.KILO_WATT, 1000), @@ -486,6 +494,38 @@ _CONVERTED_VALUE: dict[ (10, UnitOfEnergy.GIGA_CALORIE, 10000, UnitOfEnergy.MEGA_CALORIE), (10, UnitOfEnergy.GIGA_CALORIE, 11.622222, UnitOfEnergy.MEGA_WATT_HOUR), ], + EnergyDistanceConverter: [ + ( + 10, + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + 6.213712, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + ), + ( + 25, + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + 4, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + ), + ( + 20, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + 3.106856, + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + ), + ( + 10, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + 16.09344, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + ), + ( + 16.09344, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + 10, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + ), + ], InformationConverter: [ (8e3, UnitOfInformation.BITS, 8, UnitOfInformation.KILOBITS), (8e6, UnitOfInformation.BITS, 8, UnitOfInformation.MEGABITS), From 6f1539f60defe7150777014001806182aabdc9eb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 31 Jan 2025 16:32:11 +0100 Subject: [PATCH 1307/2987] Use device name as entity name in Eheim digital climate (#136997) --- .../components/eheimdigital/climate.py | 1 + .../eheimdigital/snapshots/test_climate.ambr | 20 +++++++++---------- tests/components/eheimdigital/test_climate.py | 16 +++++++-------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py index 9b1f825dece..7ad06659089 100644 --- a/homeassistant/components/eheimdigital/climate.py +++ b/homeassistant/components/eheimdigital/climate.py @@ -76,6 +76,7 @@ class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateE _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_preset_mode = PRESET_NONE _attr_translation_key = "heater" + _attr_name = None def __init__( self, coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalHeater diff --git a/tests/components/eheimdigital/snapshots/test_climate.ambr b/tests/components/eheimdigital/snapshots/test_climate.ambr index d81c59e5af1..171d3d427fc 100644 --- a/tests/components/eheimdigital/snapshots/test_climate.ambr +++ b/tests/components/eheimdigital/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_dynamic_new_devices[climate.mock_heater_none-entry] +# name: test_dynamic_new_devices[climate.mock_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24,7 +24,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -45,11 +45,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_dynamic_new_devices[climate.mock_heater_none-state] +# name: test_dynamic_new_devices[climate.mock_heater-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 24.2, - 'friendly_name': 'Mock Heater None', + 'friendly_name': 'Mock Heater', 'hvac_action': , 'hvac_modes': list([ , @@ -68,14 +68,14 @@ 'temperature': 25.5, }), 'context': , - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'auto', }) # --- -# name: test_setup_heater[climate.mock_heater_none-entry] +# name: test_setup_heater[climate.mock_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -100,7 +100,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -121,11 +121,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup_heater[climate.mock_heater_none-state] +# name: test_setup_heater[climate.mock_heater-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 24.2, - 'friendly_name': 'Mock Heater None', + 'friendly_name': 'Mock Heater', 'hvac_action': , 'hvac_modes': list([ , @@ -144,7 +144,7 @@ 'temperature': 25.5, }), 'context': , - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py index f64b7d7e740..f1f29ce9d34 100644 --- a/tests/components/eheimdigital/test_climate.py +++ b/tests/components/eheimdigital/test_climate.py @@ -123,7 +123,7 @@ async def test_set_preset_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_PRESET_MODE: preset_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_PRESET_MODE: preset_mode}, blocking=True, ) @@ -132,7 +132,7 @@ async def test_set_preset_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_PRESET_MODE: preset_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_PRESET_MODE: preset_mode}, blocking=True, ) @@ -161,7 +161,7 @@ async def test_set_temperature( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_TEMPERATURE: 26.0}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_TEMPERATURE: 26.0}, blocking=True, ) @@ -170,7 +170,7 @@ async def test_set_temperature( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_TEMPERATURE: 26.0}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_TEMPERATURE: 26.0}, blocking=True, ) @@ -204,7 +204,7 @@ async def test_set_hvac_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_HVAC_MODE: hvac_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_HVAC_MODE: hvac_mode}, blocking=True, ) @@ -213,7 +213,7 @@ async def test_set_hvac_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_HVAC_MODE: hvac_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_HVAC_MODE: hvac_mode}, blocking=True, ) @@ -239,7 +239,7 @@ async def test_state_update( ) await hass.async_block_till_done() - assert (state := hass.states.get("climate.mock_heater_none")) + assert (state := hass.states.get("climate.mock_heater")) assert state.attributes["hvac_action"] == HVACAction.IDLE assert state.attributes["preset_mode"] == HEATER_BIO_MODE @@ -249,6 +249,6 @@ async def test_state_update( await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() - assert (state := hass.states.get("climate.mock_heater_none")) + assert (state := hass.states.get("climate.mock_heater")) assert state.state == HVACMode.OFF assert state.attributes["preset_mode"] == HEATER_SMART_MODE From 64814e086f255575821f508858e970f8fc057093 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 16:50:30 +0100 Subject: [PATCH 1308/2987] Make sure we load the backup integration before frontend (#137010) --- homeassistant/bootstrap.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index d89a9595868..8c27f41aabe 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -161,6 +161,10 @@ FRONTEND_INTEGRATIONS = { # integrations can be removed and database migration status is # visible in frontend "frontend", + # Backup is an after dependency of frontend, after dependencies + # are not promoted from stage 2 to earlier stages, so we need to + # add it here. + "backup", } RECORDER_INTEGRATIONS = { # Setup after frontend From fafeedd01bd365ba697a1050cb24c9de27e9d958 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 31 Jan 2025 17:26:43 +0100 Subject: [PATCH 1309/2987] Revert previous PR and remove URL from error message instead (#137018) --- homeassistant/components/swiss_public_transport/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index 64817f89f42..1cdbd527467 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -2,7 +2,7 @@ "config": { "error": { "cannot_connect": "Cannot connect to server", - "bad_config": "Request failed due to bad config: Check at [stationboard]({stationboard_url}) if your station names are valid", + "bad_config": "Request failed due to bad config: Check the stationboard linked above if your station names are valid", "too_many_via_stations": "Too many via stations, only up to 5 via stations are allowed per connection.", "unknown": "An unknown error was raised by python-opendata-transport" }, @@ -28,7 +28,7 @@ "time_station": "Usually the departure time of a connection when it leaves the start station is tracked. Alternatively, track the time when the connection arrives at its end station.", "time_mode": "Time mode lets you change the departure timing and fix it to a specific time (e.g. 7:12:00 AM every morning) or add a moving offset (e.g. +00:05:00 taking into account the time to walk to the station)." }, - "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nConsult the stationboard linked above.", + "description": "Provide start and end station for your connection, and optionally up to 5 via stations.\n\nCheck the [stationboard]({stationboard_url}) for valid stations.", "title": "Swiss Public Transport" }, "time_fixed": { From f5924146c18ba3e7e9d4e77d90849d555c3df76b Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 31 Jan 2025 17:29:59 +0100 Subject: [PATCH 1310/2987] Add data_description's to motionmount integration (#137014) * Add data_description's * Use more common terminology --- homeassistant/components/motionmount/strings.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index bef04634431..1fcb6c47c99 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -11,6 +11,10 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of the MotionMount.", + "port": "The port of the MotionMount." } }, "zeroconf_confirm": { @@ -22,6 +26,9 @@ "description": "Your MotionMount requires a PIN to operate.", "data": { "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "The user level PIN configured on the MotionMount." } }, "backoff": { From b85b834bdc7023ce3a51579a3164c64b2a001e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 31 Jan 2025 17:31:31 +0100 Subject: [PATCH 1311/2987] Bump letpot to 0.4.0 (#137007) * Bump letpot to 0.4.0 * Fix test item --- homeassistant/components/letpot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/letpot/__init__.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/letpot/manifest.json b/homeassistant/components/letpot/manifest.json index 691584abc13..d08b5f70a51 100644 --- a/homeassistant/components/letpot/manifest.json +++ b/homeassistant/components/letpot/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "quality_scale": "bronze", - "requirements": ["letpot==0.3.0"] + "requirements": ["letpot==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6bb68d58a50..67d5910562c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1305,7 +1305,7 @@ led-ble==1.1.4 lektricowifi==0.0.43 # homeassistant.components.letpot -letpot==0.3.0 +letpot==0.4.0 # homeassistant.components.foscam libpyfoscam==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f7cef8c557..bebc407d809 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1104,7 +1104,7 @@ led-ble==1.1.4 lektricowifi==0.0.43 # homeassistant.components.letpot -letpot==0.3.0 +letpot==0.4.0 # homeassistant.components.foscam libpyfoscam==1.2.2 diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py index 829d1df54f3..ac552f907d4 100644 --- a/tests/components/letpot/__init__.py +++ b/tests/components/letpot/__init__.py @@ -2,7 +2,7 @@ import datetime -from letpot.models import AuthenticationInfo, LetPotDeviceStatus +from letpot.models import AuthenticationInfo, LetPotDeviceErrors, LetPotDeviceStatus from homeassistant.core import HomeAssistant @@ -26,6 +26,7 @@ AUTHENTICATION = AuthenticationInfo( ) STATUS = LetPotDeviceStatus( + errors=LetPotDeviceErrors(low_water=False), light_brightness=500, light_mode=1, light_schedule_end=datetime.time(12, 10), @@ -38,5 +39,4 @@ STATUS = LetPotDeviceStatus( raw=[77, 0, 1, 18, 98, 1, 0, 0, 1, 1, 1, 0, 1, 12, 0, 12, 10, 1, 244, 0, 0, 0], system_on=True, system_sound=False, - system_state=0, ) From e18dc063ba7da827506defa4f06189bf17cd44d6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 17:33:30 +0100 Subject: [PATCH 1312/2987] Make backup file names more user friendly (#136928) * Make backup file names more user friendly * Strip backup name * Strip backup name * Underscores --- homeassistant/components/backup/backup.py | 4 +- homeassistant/components/backup/manager.py | 2 +- homeassistant/components/backup/util.py | 9 ++ homeassistant/components/backup/websocket.py | 2 +- tests/components/backup/test_backup.py | 4 +- tests/components/backup/test_manager.py | 139 +++++++++++++++++-- tests/components/backup/test_util.py | 28 ++++ 7 files changed, 171 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index c76b50b5935..b6282186c06 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -14,7 +14,7 @@ from homeassistant.helpers.hassio import is_hassio from .agent import BackupAgent, BackupNotFound, LocalBackupAgent from .const import DOMAIN, LOGGER from .models import AgentBackup -from .util import read_backup +from .util import read_backup, suggested_filename async def async_get_backup_agents( @@ -123,7 +123,7 @@ class CoreLocalBackupAgent(LocalBackupAgent): def get_new_backup_path(self, backup: AgentBackup) -> Path: """Return the local path to a new backup.""" - return self._backup_dir / f"{backup.backup_id}.tar" + return self._backup_dir / suggested_filename(backup) async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: """Delete a backup file.""" diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 1dbd8f8547d..2576eb8d1f0 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -898,7 +898,7 @@ class BackupManager: ) backup_name = ( - name + (name if name is None else name.strip()) or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}" ) extra_metadata = extra_metadata or {} diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index 2416aa5f28e..e9d597aa709 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -20,6 +20,7 @@ from securetar import SecureTarError, SecureTarFile, SecureTarReadError from homeassistant.backup_restore import password_to_key from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonObjectType, json_loads_object from homeassistant.util.thread import ThreadWithException @@ -117,6 +118,14 @@ def read_backup(backup_path: Path) -> AgentBackup: ) +def suggested_filename(backup: AgentBackup) -> str: + """Suggest a filename for the backup.""" + date = dt_util.parse_datetime(backup.date, raise_on_error=True) + return "_".join( + f"{backup.name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split() + ) + + def validate_password(path: Path, password: str | None) -> bool: """Validate the password.""" with tarfile.open(path, "r:", bufsize=BUF_SIZE) as backup_file: diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index feb762bb50b..93dd81c3c14 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -199,7 +199,7 @@ async def handle_can_decrypt_on_download( vol.Optional("include_database", default=True): bool, vol.Optional("include_folders"): [vol.Coerce(Folder)], vol.Optional("include_homeassistant", default=True): bool, - vol.Optional("name"): str, + vol.Optional("name"): vol.Any(str, None), vol.Optional("password"): vol.Any(str, None), } ) diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index ce34c51c105..c441cae292c 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -103,7 +103,9 @@ async def test_upload( assert resp.status == 201 assert open_mock.call_count == 1 assert move_mock.call_count == 1 - assert move_mock.mock_calls[0].args[1].name == "abc123.tar" + assert ( + move_mock.mock_calls[0].args[1].name == "Test_-_1970-01-01_00.00_00000000.tar" + ) @pytest.mark.usefixtures("read_backup") diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 4a8d2360d3f..b98cec47e8d 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -21,6 +21,7 @@ from unittest.mock import ( patch, ) +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.backup import ( @@ -236,6 +237,64 @@ async def test_create_backup_service( "password": None, }, ), + ( + { + "agent_ids": ["backup.local"], + "extra_metadata": {"custom": "data"}, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": "user defined name", + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "user defined name", + "extra_metadata": { + "custom": "data", + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ( + { + "agent_ids": ["backup.local"], + "extra_metadata": {"custom": "data"}, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": " ", # Name which is just whitespace + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "Custom backup 2025.1.0", + "extra_metadata": { + "custom": "data", + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), ], ) async def test_async_create_backup( @@ -345,18 +404,70 @@ async def test_create_backup_wrong_parameters( @pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.parametrize( - ("agent_ids", "backup_directory", "temp_file_unlink_call_count"), + ( + "agent_ids", + "backup_directory", + "name", + "expected_name", + "expected_filename", + "temp_file_unlink_call_count", + ), [ - ([LOCAL_AGENT_ID], "backups", 0), - (["test.remote"], "tmp_backups", 1), - ([LOCAL_AGENT_ID, "test.remote"], "backups", 0), + ( + [LOCAL_AGENT_ID], + "backups", + None, + "Custom backup 2025.1.0", + "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + 0, + ), + ( + ["test.remote"], + "tmp_backups", + None, + "Custom backup 2025.1.0", + "abc123.tar", # We don't use friendly name for temporary backups + 1, + ), + ( + [LOCAL_AGENT_ID, "test.remote"], + "backups", + None, + "Custom backup 2025.1.0", + "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + 0, + ), + ( + [LOCAL_AGENT_ID], + "backups", + "custom_name", + "custom_name", + "custom_name_-_2025-01-30_05.42_12345678.tar", + 0, + ), + ( + ["test.remote"], + "tmp_backups", + "custom_name", + "custom_name", + "abc123.tar", # We don't use friendly name for temporary backups + 1, + ), + ( + [LOCAL_AGENT_ID, "test.remote"], + "backups", + "custom_name", + "custom_name", + "custom_name_-_2025-01-30_05.42_12345678.tar", + 0, + ), ], ) @pytest.mark.parametrize( "params", [ {}, - {"include_database": True, "name": "abc123"}, + {"include_database": True}, {"include_database": False}, {"password": "pass123"}, ], @@ -364,6 +475,7 @@ async def test_create_backup_wrong_parameters( async def test_initiate_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, mocked_json_bytes: Mock, mocked_tarfile: Mock, generate_backup_id: MagicMock, @@ -371,6 +483,9 @@ async def test_initiate_backup( params: dict[str, Any], agent_ids: list[str], backup_directory: str, + name: str | None, + expected_name: str, + expected_filename: str, temp_file_unlink_call_count: int, ) -> None: """Test generate backup.""" @@ -393,9 +508,9 @@ async def test_initiate_backup( ) ws_client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") include_database = params.get("include_database", True) - name = params.get("name", "Custom backup 2025.1.0") password = params.get("password") path_glob.return_value = [] @@ -427,7 +542,7 @@ async def test_initiate_backup( patch("pathlib.Path.unlink") as unlink_mock, ): await ws_client.send_json_auto_id( - {"type": "backup/generate", "agent_ids": agent_ids} | params + {"type": "backup/generate", "agent_ids": agent_ids, "name": name} | params ) result = await ws_client.receive_json() assert result["event"] == { @@ -487,7 +602,7 @@ async def test_initiate_backup( "exclude_database": not include_database, "version": "2025.1.0", }, - "name": name, + "name": expected_name, "protected": bool(password), "slug": backup_id, "type": "partial", @@ -514,7 +629,7 @@ async def test_initiate_backup( "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.1.0", - "name": name, + "name": expected_name, "with_automatic_settings": False, } @@ -528,7 +643,7 @@ async def test_initiate_backup( tar_file_path = str(mocked_tarfile.call_args_list[0][0][0]) backup_directory = hass.config.path(backup_directory) - assert tar_file_path == f"{backup_directory}/{backup_id}.tar" + assert tar_file_path == f"{backup_directory}/{expected_filename}" @pytest.mark.usefixtures("mock_backup_generation") @@ -1482,7 +1597,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local&agent_id=test.remote", 2, 1, - ["abc123.tar"], + ["Test_-_1970-01-01_00.00_00000000.tar"], {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, b"test", 0, @@ -1491,7 +1606,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local", 1, 1, - ["abc123.tar"], + ["Test_-_1970-01-01_00.00_00000000.tar"], {}, None, 0, diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index db759805c8f..3bcb53f7c86 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -15,6 +15,7 @@ from homeassistant.components.backup.util import ( DecryptedBackupStreamer, EncryptedBackupStreamer, read_backup, + suggested_filename, validate_password, ) from homeassistant.core import HomeAssistant @@ -384,3 +385,30 @@ async def test_encrypted_backup_streamer_error(hass: HomeAssistant) -> None: # padding. await encryptor.wait() assert isinstance(encryptor._workers[0].error, tarfile.TarError) + + +@pytest.mark.parametrize( + ("name", "resulting_filename"), + [ + ("test", "test_-_2025-01-30_13.42_12345678.tar"), + (" leading spaces", "leading_spaces_-_2025-01-30_13.42_12345678.tar"), + ("trailing spaces ", "trailing_spaces_-_2025-01-30_13.42_12345678.tar"), + ("double spaces ", "double_spaces_-_2025-01-30_13.42_12345678.tar"), + ], +) +def test_suggested_filename(name: str, resulting_filename: str) -> None: + """Test suggesting a filename.""" + backup = AgentBackup( + addons=[], + backup_id="1234", + date="2025-01-30 13:42:12.345678-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name=name, + protected=False, + size=1234, + ) + assert suggested_filename(backup) == resulting_filename From b1c3d0857a712f6f835c3de07169c4a0db693b21 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 31 Jan 2025 09:35:08 -0700 Subject: [PATCH 1313/2987] Add pets to litterrobot integration (#136865) --- .../components/litterrobot/__init__.py | 9 ++++- .../components/litterrobot/binary_sensor.py | 12 +++--- .../components/litterrobot/button.py | 10 ++--- .../components/litterrobot/coordinator.py | 2 + .../components/litterrobot/entity.py | 40 +++++++++++++------ .../components/litterrobot/select.py | 20 +++++----- .../components/litterrobot/sensor.py | 32 +++++++++++---- .../components/litterrobot/switch.py | 12 +++--- homeassistant/components/litterrobot/time.py | 12 +++--- tests/components/litterrobot/common.py | 10 +++++ tests/components/litterrobot/conftest.py | 19 ++++++++- tests/components/litterrobot/test_sensor.py | 10 +++++ 12 files changed, 133 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index 1f926d37a61..2823450d9ad 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import itertools + from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry @@ -46,6 +48,9 @@ async def async_remove_config_entry_device( identifier for identifier in device_entry.identifiers if identifier[0] == DOMAIN - for robot in entry.runtime_data.account.robots - if robot.serial == identifier[1] + for _id in itertools.chain( + (robot.serial for robot in entry.runtime_data.account.robots), + (pet.id for pet in entry.runtime_data.account.pets), + ) + if _id == identifier[1] ) diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index e6cf23fa27c..700985d285f 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -18,16 +18,16 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity, _RobotT +from .entity import LitterRobotEntity, _WhiskerEntityT @dataclass(frozen=True, kw_only=True) class RobotBinarySensorEntityDescription( - BinarySensorEntityDescription, Generic[_RobotT] + BinarySensorEntityDescription, Generic[_WhiskerEntityT] ): """A class that describes robot binary sensor entities.""" - is_on_fn: Callable[[_RobotT], bool] + is_on_fn: Callable[[_WhiskerEntityT], bool] BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, ...]] = { @@ -78,10 +78,12 @@ async def async_setup_entry( ) -class LitterRobotBinarySensorEntity(LitterRobotEntity[_RobotT], BinarySensorEntity): +class LitterRobotBinarySensorEntity( + LitterRobotEntity[_WhiskerEntityT], BinarySensorEntity +): """Litter-Robot binary sensor entity.""" - entity_description: RobotBinarySensorEntityDescription[_RobotT] + entity_description: RobotBinarySensorEntityDescription[_WhiskerEntityT] @property def is_on(self) -> bool: diff --git a/homeassistant/components/litterrobot/button.py b/homeassistant/components/litterrobot/button.py index 01888e7fbae..758548b3a67 100644 --- a/homeassistant/components/litterrobot/button.py +++ b/homeassistant/components/litterrobot/button.py @@ -14,14 +14,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity, _RobotT +from .entity import LitterRobotEntity, _WhiskerEntityT @dataclass(frozen=True, kw_only=True) -class RobotButtonEntityDescription(ButtonEntityDescription, Generic[_RobotT]): +class RobotButtonEntityDescription(ButtonEntityDescription, Generic[_WhiskerEntityT]): """A class that describes robot button entities.""" - press_fn: Callable[[_RobotT], Coroutine[Any, Any, bool]] + press_fn: Callable[[_WhiskerEntityT], Coroutine[Any, Any, bool]] ROBOT_BUTTON_MAP: dict[type[Robot], RobotButtonEntityDescription] = { @@ -62,10 +62,10 @@ async def async_setup_entry( ) -class LitterRobotButtonEntity(LitterRobotEntity[_RobotT], ButtonEntity): +class LitterRobotButtonEntity(LitterRobotEntity[_WhiskerEntityT], ButtonEntity): """Litter-Robot button entity.""" - entity_description: RobotButtonEntityDescription[_RobotT] + entity_description: RobotButtonEntityDescription[_WhiskerEntityT] async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/litterrobot/coordinator.py b/homeassistant/components/litterrobot/coordinator.py index a56a6607d32..c99d4794ff6 100644 --- a/homeassistant/components/litterrobot/coordinator.py +++ b/homeassistant/components/litterrobot/coordinator.py @@ -47,6 +47,7 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Update all device states from the Litter-Robot API.""" await self.account.refresh_robots() + await self.account.load_pets() async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -56,6 +57,7 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]): password=self.config_entry.data[CONF_PASSWORD], load_robots=True, subscribe_for_updates=True, + load_pets=True, ) except LitterRobotLoginException as ex: raise ConfigEntryAuthFailed("Invalid credentials") from ex diff --git a/homeassistant/components/litterrobot/entity.py b/homeassistant/components/litterrobot/entity.py index 36cbbb730ce..9e9cc8f0740 100644 --- a/homeassistant/components/litterrobot/entity.py +++ b/homeassistant/components/litterrobot/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Generic, TypeVar -from pylitterbot import Robot +from pylitterbot import Pet, Robot from pylitterbot.robot import EVENT_UPDATE from homeassistant.helpers.device_registry import DeviceInfo @@ -14,11 +14,31 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import LitterRobotDataUpdateCoordinator -_RobotT = TypeVar("_RobotT", bound=Robot) +_WhiskerEntityT = TypeVar("_WhiskerEntityT", bound=Robot | Pet) + + +def get_device_info(whisker_entity: _WhiskerEntityT) -> DeviceInfo: + """Get device info for a robot or pet.""" + if isinstance(whisker_entity, Robot): + return DeviceInfo( + identifiers={(DOMAIN, whisker_entity.serial)}, + manufacturer="Whisker", + model=whisker_entity.model, + name=whisker_entity.name, + serial_number=whisker_entity.serial, + sw_version=getattr(whisker_entity, "firmware", None), + ) + breed = ", ".join(breed for breed in whisker_entity.breeds or []) + return DeviceInfo( + identifiers={(DOMAIN, whisker_entity.id)}, + manufacturer="Whisker", + model=f"{breed} {whisker_entity.pet_type}".strip().capitalize(), + name=whisker_entity.name, + ) class LitterRobotEntity( - CoordinatorEntity[LitterRobotDataUpdateCoordinator], Generic[_RobotT] + CoordinatorEntity[LitterRobotDataUpdateCoordinator], Generic[_WhiskerEntityT] ): """Generic Litter-Robot entity representing common data and methods.""" @@ -26,7 +46,7 @@ class LitterRobotEntity( def __init__( self, - robot: _RobotT, + robot: _WhiskerEntityT, coordinator: LitterRobotDataUpdateCoordinator, description: EntityDescription, ) -> None: @@ -34,15 +54,9 @@ class LitterRobotEntity( super().__init__(coordinator) self.robot = robot self.entity_description = description - self._attr_unique_id = f"{robot.serial}-{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, robot.serial)}, - manufacturer="Whisker", - model=robot.model, - name=robot.name, - serial_number=robot.serial, - sw_version=getattr(robot, "firmware", None), - ) + _id = robot.serial if isinstance(robot, Robot) else robot.id + self._attr_unique_id = f"{_id}-{description.key}" + self._attr_device_info = get_device_info(robot) async def async_added_to_hass(self) -> None: """Set up a listener for the entity.""" diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index 1a3d2fc2fb4..f6e3781f3df 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -15,21 +15,21 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator -from .entity import LitterRobotEntity, _RobotT +from .entity import LitterRobotEntity, _WhiskerEntityT _CastTypeT = TypeVar("_CastTypeT", int, float, str) @dataclass(frozen=True, kw_only=True) class RobotSelectEntityDescription( - SelectEntityDescription, Generic[_RobotT, _CastTypeT] + SelectEntityDescription, Generic[_WhiskerEntityT, _CastTypeT] ): """A class that describes robot select entities.""" entity_category: EntityCategory = EntityCategory.CONFIG - current_fn: Callable[[_RobotT], _CastTypeT | None] - options_fn: Callable[[_RobotT], list[_CastTypeT]] - select_fn: Callable[[_RobotT, str], Coroutine[Any, Any, bool]] + current_fn: Callable[[_WhiskerEntityT], _CastTypeT | None] + options_fn: Callable[[_WhiskerEntityT], list[_CastTypeT]] + select_fn: Callable[[_WhiskerEntityT, str], Coroutine[Any, Any, bool]] ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { @@ -83,17 +83,19 @@ async def async_setup_entry( class LitterRobotSelectEntity( - LitterRobotEntity[_RobotT], SelectEntity, Generic[_RobotT, _CastTypeT] + LitterRobotEntity[_WhiskerEntityT], + SelectEntity, + Generic[_WhiskerEntityT, _CastTypeT], ): """Litter-Robot Select.""" - entity_description: RobotSelectEntityDescription[_RobotT, _CastTypeT] + entity_description: RobotSelectEntityDescription[_WhiskerEntityT, _CastTypeT] def __init__( self, - robot: _RobotT, + robot: _WhiskerEntityT, coordinator: LitterRobotDataUpdateCoordinator, - description: RobotSelectEntityDescription[_RobotT, _CastTypeT], + description: RobotSelectEntityDescription[_WhiskerEntityT, _CastTypeT], ) -> None: """Initialize a Litter-Robot select entity.""" super().__init__(robot, coordinator, description) diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index 6545d7c7ae7..3e25a0556c6 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import datetime from typing import Any, Generic -from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Robot +from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Pet, Robot from homeassistant.components.sensor import ( SensorDeviceClass, @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity, _RobotT +from .entity import LitterRobotEntity, _WhiskerEntityT def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str: @@ -35,11 +35,11 @@ def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str @dataclass(frozen=True, kw_only=True) -class RobotSensorEntityDescription(SensorEntityDescription, Generic[_RobotT]): +class RobotSensorEntityDescription(SensorEntityDescription, Generic[_WhiskerEntityT]): """A class that describes robot sensor entities.""" icon_fn: Callable[[Any], str | None] = lambda _: None - value_fn: Callable[[_RobotT], float | datetime | str | None] + value_fn: Callable[[_WhiskerEntityT], float | datetime | str | None] ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { @@ -146,6 +146,16 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { ], } +PET_SENSORS: list[RobotSensorEntityDescription] = [ + RobotSensorEntityDescription[Pet]( + key="weight", + device_class=SensorDeviceClass.WEIGHT, + native_unit_of_measurement=UnitOfMass.POUNDS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda pet: pet.weight, + ) +] + async def async_setup_entry( hass: HomeAssistant, @@ -154,7 +164,7 @@ async def async_setup_entry( ) -> None: """Set up Litter-Robot sensors using config entry.""" coordinator = entry.runtime_data - async_add_entities( + entities: list[LitterRobotSensorEntity] = [ LitterRobotSensorEntity( robot=robot, coordinator=coordinator, description=description ) @@ -162,13 +172,21 @@ async def async_setup_entry( for robot_type, entity_descriptions in ROBOT_SENSOR_MAP.items() if isinstance(robot, robot_type) for description in entity_descriptions + ] + entities.extend( + LitterRobotSensorEntity( + robot=pet, coordinator=coordinator, description=description + ) + for pet in coordinator.account.pets + for description in PET_SENSORS ) + async_add_entities(entities) -class LitterRobotSensorEntity(LitterRobotEntity[_RobotT], SensorEntity): +class LitterRobotSensorEntity(LitterRobotEntity[_WhiskerEntityT], SensorEntity): """Litter-Robot sensor entity.""" - entity_description: RobotSensorEntityDescription[_RobotT] + entity_description: RobotSensorEntityDescription[_WhiskerEntityT] @property def native_value(self) -> float | datetime | str | None: diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py index 7ded89d552b..4839748c068 100644 --- a/homeassistant/components/litterrobot/switch.py +++ b/homeassistant/components/litterrobot/switch.py @@ -14,16 +14,16 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity, _RobotT +from .entity import LitterRobotEntity, _WhiskerEntityT @dataclass(frozen=True, kw_only=True) -class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_RobotT]): +class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_WhiskerEntityT]): """A class that describes robot switch entities.""" entity_category: EntityCategory = EntityCategory.CONFIG - set_fn: Callable[[_RobotT, bool], Coroutine[Any, Any, bool]] - value_fn: Callable[[_RobotT], bool] + set_fn: Callable[[_WhiskerEntityT, bool], Coroutine[Any, Any, bool]] + value_fn: Callable[[_WhiskerEntityT], bool] ROBOT_SWITCHES = [ @@ -57,10 +57,10 @@ async def async_setup_entry( ) -class RobotSwitchEntity(LitterRobotEntity[_RobotT], SwitchEntity): +class RobotSwitchEntity(LitterRobotEntity[_WhiskerEntityT], SwitchEntity): """Litter-Robot switch entity.""" - entity_description: RobotSwitchEntityDescription[_RobotT] + entity_description: RobotSwitchEntityDescription[_WhiskerEntityT] @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py index 3fa93b14dd9..69d81d63eae 100644 --- a/homeassistant/components/litterrobot/time.py +++ b/homeassistant/components/litterrobot/time.py @@ -16,15 +16,15 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity, _RobotT +from .entity import LitterRobotEntity, _WhiskerEntityT @dataclass(frozen=True, kw_only=True) -class RobotTimeEntityDescription(TimeEntityDescription, Generic[_RobotT]): +class RobotTimeEntityDescription(TimeEntityDescription, Generic[_WhiskerEntityT]): """A class that describes robot time entities.""" - value_fn: Callable[[_RobotT], time | None] - set_fn: Callable[[_RobotT, time], Coroutine[Any, Any, bool]] + value_fn: Callable[[_WhiskerEntityT], time | None] + set_fn: Callable[[_WhiskerEntityT, time], Coroutine[Any, Any, bool]] def _as_local_time(start: datetime | None) -> time | None: @@ -64,10 +64,10 @@ async def async_setup_entry( ) -class LitterRobotTimeEntity(LitterRobotEntity[_RobotT], TimeEntity): +class LitterRobotTimeEntity(LitterRobotEntity[_WhiskerEntityT], TimeEntity): """Litter-Robot time entity.""" - entity_description: RobotTimeEntityDescription[_RobotT] + entity_description: RobotTimeEntityDescription[_WhiskerEntityT] @property def native_value(self) -> time | None: diff --git a/tests/components/litterrobot/common.py b/tests/components/litterrobot/common.py index b29fa753801..d96ce06ca59 100644 --- a/tests/components/litterrobot/common.py +++ b/tests/components/litterrobot/common.py @@ -150,5 +150,15 @@ FEEDER_ROBOT_DATA = { }, ], } +PET_DATA = { + "petId": "PET-123", + "userId": "1234567", + "createdAt": "2023-04-27T23:26:49.813Z", + "name": "Kitty", + "type": "CAT", + "gender": "FEMALE", + "lastWeightReading": 9.1, + "breeds": ["sphynx"], +} VACUUM_ENTITY_ID = "vacuum.test_litter_box" diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index e60e0cbd36d..d22c4b2ec49 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -5,13 +5,20 @@ from __future__ import annotations from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from pylitterbot import Account, FeederRobot, LitterRobot3, LitterRobot4, Robot +from pylitterbot import Account, FeederRobot, LitterRobot3, LitterRobot4, Pet, Robot from pylitterbot.exceptions import InvalidCommandException import pytest from homeassistant.core import HomeAssistant -from .common import CONFIG, DOMAIN, FEEDER_ROBOT_DATA, ROBOT_4_DATA, ROBOT_DATA +from .common import ( + CONFIG, + DOMAIN, + FEEDER_ROBOT_DATA, + PET_DATA, + ROBOT_4_DATA, + ROBOT_DATA, +) from tests.common import MockConfigEntry @@ -50,6 +57,7 @@ def create_mock_account( skip_robots: bool = False, v4: bool = False, feeder: bool = False, + pet: bool = False, ) -> MagicMock: """Create a mock Litter-Robot account.""" account = MagicMock(spec=Account) @@ -60,6 +68,7 @@ def create_mock_account( if skip_robots else [create_mock_robot(robot_data, account, v4, feeder, side_effect)] ) + account.pets = [Pet(PET_DATA, account.session)] if pet else [] return account @@ -81,6 +90,12 @@ def mock_account_with_feederrobot() -> MagicMock: return create_mock_account(feeder=True) +@pytest.fixture +def mock_account_with_pet() -> MagicMock: + """Mock account with Feeder-Robot.""" + return create_mock_account(pet=True) + + @pytest.fixture def mock_account_with_no_robots() -> MagicMock: """Mock a Litter-Robot account.""" diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index 360d13096a7..e290d96fcf4 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -104,3 +104,13 @@ async def test_feeder_robot_sensor( sensor = hass.states.get("sensor.test_food_level") assert sensor.state == "10" assert sensor.attributes["unit_of_measurement"] == PERCENTAGE + + +async def test_pet_weight_sensor( + hass: HomeAssistant, mock_account_with_pet: MagicMock +) -> None: + """Tests pet weight sensors.""" + await setup_integration(hass, mock_account_with_pet, PLATFORM_DOMAIN) + sensor = hass.states.get("sensor.kitty_weight") + assert sensor.state == "9.1" + assert sensor.attributes["unit_of_measurement"] == UnitOfMass.POUNDS From e0bf248867b244866e01039f3a95f56193d9ab5b Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 31 Jan 2025 17:49:25 +0100 Subject: [PATCH 1314/2987] Bumb python-homewizard-energy to 8.3.2 (#136995) --- homeassistant/components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 957ed912b7d..51a315b2286 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==v8.3.0"], + "requirements": ["python-homewizard-energy==v8.3.2"], "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 67d5910562c..637e25cec04 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2391,7 +2391,7 @@ python-google-drive-api==0.0.2 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.3.0 +python-homewizard-energy==v8.3.2 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bebc407d809..ebe7c1e9fe7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1936,7 +1936,7 @@ python-google-drive-api==0.0.2 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.3.0 +python-homewizard-energy==v8.3.2 # homeassistant.components.izone python-izone==1.2.9 From 64f679ba8f065d04875c76f9db00b138f5da985f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 18:20:30 +0100 Subject: [PATCH 1315/2987] Make supervisor backup file names more user friendly (#137020) --- homeassistant/components/backup/__init__.py | 3 +++ homeassistant/components/backup/util.py | 11 +++++++---- homeassistant/components/hassio/backup.py | 17 ++++++++++++++--- tests/components/hassio/test_backup.py | 14 ++++++++++++++ 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 3003f94c2ed..86e5b95d196 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -35,6 +35,7 @@ from .manager import ( WrittenBackup, ) from .models import AddonInfo, AgentBackup, Folder +from .util import suggested_filename, suggested_filename_from_name_date from .websocket import async_register_websocket_handlers __all__ = [ @@ -58,6 +59,8 @@ __all__ = [ "RestoreBackupState", "WrittenBackup", "async_get_manager", + "suggested_filename", + "suggested_filename_from_name_date", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index e9d597aa709..fbb13b4721a 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -118,12 +118,15 @@ def read_backup(backup_path: Path) -> AgentBackup: ) +def suggested_filename_from_name_date(name: str, date_str: str) -> str: + """Suggest a filename for the backup.""" + date = dt_util.parse_datetime(date_str, raise_on_error=True) + return "_".join(f"{name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split()) + + def suggested_filename(backup: AgentBackup) -> str: """Suggest a filename for the backup.""" - date = dt_util.parse_datetime(backup.date, raise_on_error=True) - return "_".join( - f"{backup.name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split() - ) + return suggested_filename_from_name_date(backup.name, backup.date) def validate_password(path: Path, password: str | None) -> bool: diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index b9439183d8c..24a1743155e 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import logging import os -from pathlib import Path +from pathlib import Path, PurePath from typing import Any, cast from uuid import UUID @@ -38,11 +38,14 @@ from homeassistant.components.backup import ( RestoreBackupState, WrittenBackup, async_get_manager as async_get_backup_manager, + suggested_filename as suggested_backup_filename, + suggested_filename_from_name_date, ) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util import dt as dt_util from .const import DOMAIN, EVENT_SUPERVISOR_EVENT from .handler import get_supervisor_client @@ -113,12 +116,15 @@ def _backup_details_to_agent_backup( AddonInfo(name=addon.name, slug=addon.slug, version=addon.version) for addon in details.addons ] + extra_metadata = details.extra or {} location = location or LOCATION_LOCAL return AgentBackup( addons=addons, backup_id=details.slug, database_included=database_included, - date=details.date.isoformat(), + date=extra_metadata.get( + "supervisor.backup_request_date", details.date.isoformat() + ), extra_metadata=details.extra or {}, folders=[Folder(folder) for folder in details.folders], homeassistant_included=homeassistant_included, @@ -174,7 +180,8 @@ class SupervisorBackupAgent(BackupAgent): return stream = await open_stream() upload_options = supervisor_backups.UploadBackupOptions( - location={self.location} + location={self.location}, + filename=PurePath(suggested_backup_filename(backup)), ) await self._client.backups.upload_backup( stream, @@ -301,6 +308,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): locations = [] locations = locations or [LOCATION_CLOUD_BACKUP] + date = dt_util.now().isoformat() + extra_metadata = extra_metadata | {"supervisor.backup_request_date": date} + filename = suggested_filename_from_name_date(backup_name, date) try: backup = await self._client.backups.partial_backup( supervisor_backups.PartialBackupOptions( @@ -314,6 +324,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): homeassistant_exclude_database=not include_database, background=True, extra=extra_metadata, + filename=PurePath(filename), ) ) except SupervisorError as err: diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 9ba73ade1a3..d001a358640 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -11,6 +11,7 @@ from dataclasses import replace from datetime import datetime from io import StringIO import os +from pathlib import PurePath from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch from uuid import UUID @@ -26,6 +27,7 @@ from aiohasupervisor.models import ( mounts as supervisor_mounts, ) from aiohasupervisor.models.mounts import MountsInfo +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.backup import ( @@ -854,8 +856,10 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( compressed=True, extra={ "instance_id": ANY, + "supervisor.backup_request_date": "2025-01-30T05:42:12.345678-08:00", "with_automatic_settings": False, }, + filename=PurePath("Test_-_2025-01-30_05.42_12345678.tar"), folders={"ssl"}, homeassistant_exclude_database=False, homeassistant=True, @@ -907,12 +911,14 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( async def test_reader_writer_create( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, extra_generate_options: dict[str, Any], expected_supervisor_options: supervisor_backups.PartialBackupOptions, ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -982,10 +988,12 @@ async def test_reader_writer_create( async def test_reader_writer_create_job_done( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, ) -> None: """Test generating a backup, and backup job finishes early.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE @@ -1140,6 +1148,7 @@ async def test_reader_writer_create_job_done( async def test_reader_writer_create_per_agent_encryption( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, commands: dict[str, Any], password: str | None, @@ -1151,6 +1160,7 @@ async def test_reader_writer_create_per_agent_encryption( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") mounts = MountsInfo( default_backup_mount=None, mounts=[ @@ -1170,6 +1180,7 @@ async def test_reader_writer_create_per_agent_encryption( supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = replace( TEST_BACKUP_DETAILS, + extra=DEFAULT_BACKUP_OPTIONS.extra, locations=create_locations, location_attributes={ location or LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( @@ -1254,6 +1265,7 @@ async def test_reader_writer_create_per_agent_encryption( upload_locations ) for call in supervisor_client.backups.upload_backup.mock_calls: + assert call.args[1].filename == PurePath("Test_-_2025-01-30_05.42_12345678.tar") upload_call_locations: set = call.args[1].location assert len(upload_call_locations) == 1 assert upload_call_locations.pop() in upload_locations @@ -1569,10 +1581,12 @@ async def test_reader_writer_create_info_error( async def test_reader_writer_create_remote_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, ) -> None: """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE From c4cb94bddd92a6400ad79ee3b2b07564fd560175 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 11:29:44 -0600 Subject: [PATCH 1316/2987] Bump habluetooth to 3.17.0 (#137022) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 38677400418..d6ed9281099 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.15.0" + "habluetooth==3.17.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 64353901fbf..a7fbe090f23 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.15.0 +habluetooth==3.17.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 637e25cec04..f2a36a4329e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1103,7 +1103,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.15.0 +habluetooth==3.17.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ebe7c1e9fe7..3a26c0786d9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -944,7 +944,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.15.0 +habluetooth==3.17.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 From f8f12957b5c8ba1d62005c1e41388e48a13d0815 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 12:15:31 -0600 Subject: [PATCH 1317/2987] Bump bleak-esphome to 2.6.0 (#137025) --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index bab62723c82..3a55730c60f 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.2.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.6.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index ecc7afb3661..9585be72c63 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.0.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.2.0" + "bleak-esphome==2.6.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index f2a36a4329e..b2695471121 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -597,7 +597,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.2.0 +bleak-esphome==2.6.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a26c0786d9..fdb6c498f34 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -528,7 +528,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.2.0 +bleak-esphome==2.6.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 From 256157d41377c4e01cb8696e16834f46eb77fcfe Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 31 Jan 2025 19:25:24 +0100 Subject: [PATCH 1318/2987] Update frontend to 20250131.0 (#137024) --- 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 b545026059c..2ecb165554a 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==20250130.0"] + "requirements": ["home-assistant-frontend==20250131.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a7fbe090f23..2d4e92e2e9a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.17.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250130.0 +home-assistant-frontend==20250131.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b2695471121..5e182110235 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250130.0 +home-assistant-frontend==20250131.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fdb6c498f34..86557711111 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -975,7 +975,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250130.0 +home-assistant-frontend==20250131.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From 065cdf421f947451599c67d83b8a4725b99ab4d7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 19:33:48 +0100 Subject: [PATCH 1319/2987] Delete old addon update backups when updating addon (#136977) * Delete old addon update backups when updating addon * Address review comments * Add tests --- homeassistant/components/backup/config.py | 77 ++-------- homeassistant/components/backup/manager.py | 64 +++++++++ homeassistant/components/hassio/backup.py | 23 ++- tests/components/hassio/test_update.py | 129 ++++++++++++++++- tests/components/hassio/test_websocket_api.py | 133 +++++++++++++++++- 5 files changed, 350 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 0baefe1f52d..4d0cd82bc44 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -2,8 +2,6 @@ from __future__ import annotations -import asyncio -from collections.abc import Callable from dataclasses import dataclass, field, replace import datetime as dt from datetime import datetime, timedelta @@ -252,7 +250,7 @@ class RetentionConfig: """Delete backups older than days.""" self._schedule_next(manager) - def _backups_filter( + def _delete_filter( backups: dict[str, ManagerBackup], ) -> dict[str, ManagerBackup]: """Return backups older than days to delete.""" @@ -269,7 +267,9 @@ class RetentionConfig: < now } - await _delete_filtered_backups(manager, _backups_filter) + await manager.async_delete_filtered_backups( + include_filter=_automatic_backups_filter, delete_filter=_delete_filter + ) manager.remove_next_delete_event = async_call_later( manager.hass, timedelta(days=1), _delete_backups @@ -521,74 +521,21 @@ class CreateBackupParametersDict(TypedDict, total=False): password: str | None -async def _delete_filtered_backups( - manager: BackupManager, - backup_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]], -) -> None: - """Delete backups parsed with a filter. - - :param manager: The backup manager. - :param backup_filter: A filter that should return the backups to delete. - """ - backups, get_agent_errors = await manager.async_get_backups() - if get_agent_errors: - LOGGER.debug( - "Error getting backups; continuing anyway: %s", - get_agent_errors, - ) - - # only delete backups that are created with the saved automatic settings - backups = { +def _automatic_backups_filter( + backups: dict[str, ManagerBackup], +) -> dict[str, ManagerBackup]: + """Return automatic backups.""" + return { backup_id: backup for backup_id, backup in backups.items() if backup.with_automatic_settings } - LOGGER.debug("Total automatic backups: %s", backups) - - filtered_backups = backup_filter(backups) - - if not filtered_backups: - return - - # always delete oldest backup first - filtered_backups = dict( - sorted( - filtered_backups.items(), - key=lambda backup_item: backup_item[1].date, - ) - ) - - if len(filtered_backups) >= len(backups): - # Never delete the last backup. - last_backup = filtered_backups.popitem() - LOGGER.debug("Keeping the last backup: %s", last_backup) - - LOGGER.debug("Backups to delete: %s", filtered_backups) - - if not filtered_backups: - return - - backup_ids = list(filtered_backups) - delete_results = await asyncio.gather( - *(manager.async_delete_backup(backup_id) for backup_id in filtered_backups) - ) - agent_errors = { - backup_id: error - for backup_id, error in zip(backup_ids, delete_results, strict=True) - if error - } - if agent_errors: - LOGGER.error( - "Error deleting old copies: %s", - agent_errors, - ) - async def delete_backups_exceeding_configured_count(manager: BackupManager) -> None: """Delete backups exceeding the configured retention count.""" - def _backups_filter( + def _delete_filter( backups: dict[str, ManagerBackup], ) -> dict[str, ManagerBackup]: """Return oldest backups more numerous than copies to delete.""" @@ -603,4 +550,6 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N )[: max(len(backups) - manager.config.data.retention.copies, 0)] ) - await _delete_filtered_backups(manager, _backups_filter) + await manager.async_delete_filtered_backups( + include_filter=_automatic_backups_filter, delete_filter=_delete_filter + ) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 2576eb8d1f0..42b5f522ecd 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -685,6 +685,70 @@ class BackupManager: return agent_errors + async def async_delete_filtered_backups( + self, + *, + include_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]], + delete_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]], + ) -> None: + """Delete backups parsed with a filter. + + :param include_filter: A filter that should return the backups to consider for + deletion. Note: The newest of the backups returned by include_filter will + unconditionally be kept, even if delete_filter returns all backups. + :param delete_filter: A filter that should return the backups to delete. + """ + backups, get_agent_errors = await self.async_get_backups() + if get_agent_errors: + LOGGER.debug( + "Error getting backups; continuing anyway: %s", + get_agent_errors, + ) + + # Run the include filter first to ensure we only consider backups that + # should be included in the deletion process. + backups = include_filter(backups) + + LOGGER.debug("Total automatic backups: %s", backups) + + backups_to_delete = delete_filter(backups) + + if not backups_to_delete: + return + + # always delete oldest backup first + backups_to_delete = dict( + sorted( + backups_to_delete.items(), + key=lambda backup_item: backup_item[1].date, + ) + ) + + if len(backups_to_delete) >= len(backups): + # Never delete the last backup. + last_backup = backups_to_delete.popitem() + LOGGER.debug("Keeping the last backup: %s", last_backup) + + LOGGER.debug("Backups to delete: %s", backups_to_delete) + + if not backups_to_delete: + return + + backup_ids = list(backups_to_delete) + delete_results = await asyncio.gather( + *(self.async_delete_backup(backup_id) for backup_id in backups_to_delete) + ) + agent_errors = { + backup_id: error + for backup_id, error in zip(backup_ids, delete_results, strict=True) + if error + } + if agent_errors: + LOGGER.error( + "Error deleting old copies: %s", + agent_errors, + ) + async def async_receive_backup( self, *, diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 24a1743155e..495e953df9d 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -33,6 +33,7 @@ from homeassistant.components.backup import ( Folder, IdleEvent, IncorrectPasswordError, + ManagerBackup, NewBackup, RestoreBackupEvent, RestoreBackupState, @@ -54,6 +55,8 @@ LOCATION_CLOUD_BACKUP = ".cloud_backup" LOCATION_LOCAL = ".local" MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID" +# Set on backups automatically created when updating an addon +TAG_ADDON_UPDATE = "supervisor.addon_update" _LOGGER = logging.getLogger(__name__) @@ -625,10 +628,20 @@ async def backup_addon_before_update( else: password = None + def addon_update_backup_filter( + backups: dict[str, ManagerBackup], + ) -> dict[str, ManagerBackup]: + """Return addon update backups.""" + return { + backup_id: backup + for backup_id, backup in backups.items() + if backup.extra_metadata.get(TAG_ADDON_UPDATE) == addon + } + try: await backup_manager.async_create_backup( agent_ids=[await _default_agent(client)], - extra_metadata={"supervisor.addon_update": addon}, + extra_metadata={TAG_ADDON_UPDATE: addon}, include_addons=[addon], include_all_addons=False, include_database=False, @@ -639,6 +652,14 @@ async def backup_addon_before_update( ) except BackupManagerError as err: raise HomeAssistantError(f"Error creating backup: {err}") from err + else: + try: + await backup_manager.async_delete_filtered_backups( + include_filter=addon_update_backup_filter, + delete_filter=lambda backups: backups, + ) + except BackupManagerError as err: + raise HomeAssistantError(f"Error deleting old backups: {err}") from err async def backup_core_before_update(hass: HomeAssistant) -> None: diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 62fe49c5f23..332f2050cf2 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -3,13 +3,13 @@ from datetime import timedelta import os from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohasupervisor import SupervisorBadRequestError, SupervisorError from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest -from homeassistant.components.backup import BackupManagerError +from homeassistant.components.backup import BackupManagerError, ManagerBackup from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.const import __version__ as HAVERSION @@ -338,6 +338,113 @@ async def test_update_addon_with_backup( update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) +@pytest.mark.parametrize( + ("backups", "removed_backups"), + [ + ( + {}, + [], + ), + ( + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-6": MagicMock( + date="2024-11-12T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + }, + ["backup-5"], + ), + ], +) +async def test_update_addon_with_backup_removes_old_backups( + hass: HomeAssistant, + supervisor_client: AsyncMock, + update_addon: AsyncMock, + backups: dict[str, ManagerBackup], + removed_backups: list[str], +) -> None: + """Test updating addon update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_backup", + autospec=True, + return_value={}, + ) as async_delete_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ), + ): + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update", "backup": True}, + blocking=True, + ) + mock_create_backup.assert_called_once_with( + agent_ids=["hassio.local"], + extra_metadata={"supervisor.addon_update": "test"}, + include_addons=["test"], + include_all_addons=False, + include_database=False, + include_folders=None, + include_homeassistant=False, + name="test 2.0.0", + password=None, + ) + assert len(async_delete_backup.mock_calls) == len(removed_backups) + for call in async_delete_backup.mock_calls: + assert call.args[1] in removed_backups + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> None: """Test updating OS update entity.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) @@ -550,9 +657,19 @@ async def test_update_addon_with_error( ) +@pytest.mark.parametrize( + ("create_backup_error", "delete_filtered_backups_error", "message"), + [ + (BackupManagerError, None, r"^Error creating backup: "), + (None, BackupManagerError, r"^Error deleting old backups: "), + ], +) async def test_update_addon_with_backup_and_error( hass: HomeAssistant, supervisor_client: AsyncMock, + create_backup_error: Exception | None, + delete_filtered_backups_error: Exception | None, + message: str, ) -> None: """Test updating addon update entity with error.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) @@ -573,9 +690,13 @@ async def test_update_addon_with_backup_and_error( with ( patch( "homeassistant.components.backup.manager.BackupManager.async_create_backup", - side_effect=BackupManagerError, + side_effect=create_backup_error, ), - pytest.raises(HomeAssistantError, match=r"^Error creating backup:"), + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_filtered_backups", + side_effect=delete_filtered_backups_error, + ), + pytest.raises(HomeAssistantError, match=message), ): assert not await hass.services.async_call( "update", diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index ab8dc1475e2..bcac19e0fa3 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -2,13 +2,13 @@ import os from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohasupervisor import SupervisorError from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest -from homeassistant.components.backup import BackupManagerError +from homeassistant.components.backup import BackupManagerError, ManagerBackup from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import ( ATTR_DATA, @@ -457,6 +457,114 @@ async def test_update_addon_with_backup( update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) +@pytest.mark.parametrize( + ("backups", "removed_backups"), + [ + ( + {}, + [], + ), + ( + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-6": MagicMock( + date="2024-11-12T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + }, + ["backup-5"], + ), + ], +) +async def test_update_addon_with_backup_removes_old_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + update_addon: AsyncMock, + backups: dict[str, ManagerBackup], + removed_backups: list[str], +) -> None: + """Test updating addon update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_backup", + autospec=True, + return_value={}, + ) as async_delete_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ), + ): + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": True} + ) + result = await client.receive_json() + assert result["success"] + mock_create_backup.assert_called_once_with( + agent_ids=["hassio.local"], + extra_metadata={"supervisor.addon_update": "test"}, + include_addons=["test"], + include_all_addons=False, + include_database=False, + include_folders=None, + include_homeassistant=False, + name="test 2.0.0", + password=None, + ) + assert len(async_delete_backup.mock_calls) == len(removed_backups) + for call in async_delete_backup.mock_calls: + assert call.args[1] in removed_backups + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + async def test_update_core( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -622,10 +730,20 @@ async def test_update_addon_with_error( } +@pytest.mark.parametrize( + ("create_backup_error", "delete_filtered_backups_error", "message"), + [ + (BackupManagerError, None, "Error creating backup: "), + (None, BackupManagerError, "Error deleting old backups: "), + ], +) async def test_update_addon_with_backup_and_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + create_backup_error: Exception | None, + delete_filtered_backups_error: Exception | None, + message: str, ) -> None: """Test updating addon with backup and error.""" client = await hass_ws_client(hass) @@ -647,7 +765,11 @@ async def test_update_addon_with_backup_and_error( with ( patch( "homeassistant.components.backup.manager.BackupManager.async_create_backup", - side_effect=BackupManagerError, + side_effect=create_backup_error, + ), + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_filtered_backups", + side_effect=delete_filtered_backups_error, ), ): await client.send_json_auto_id( @@ -655,10 +777,7 @@ async def test_update_addon_with_backup_and_error( ) result = await client.receive_json() assert not result["success"] - assert result["error"] == { - "code": "home_assistant_error", - "message": "Error creating backup: ", - } + assert result["error"] == {"code": "home_assistant_error", "message": message} async def test_update_core_with_error( From 9bc3c417aea0d949c33cb07021d2eea86100ea34 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 31 Jan 2025 19:36:40 +0100 Subject: [PATCH 1320/2987] Add codeowner to Home Connect (#137029) --- CODEOWNERS | 4 ++-- homeassistant/components/home_connect/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 7baeea72178..635f53d346f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -625,8 +625,8 @@ build.json @home-assistant/supervisor /tests/components/hlk_sw16/ @jameshilliard /homeassistant/components/holiday/ @jrieger @gjohansson-ST /tests/components/holiday/ @jrieger @gjohansson-ST -/homeassistant/components/home_connect/ @DavidMStraub @Diegorro98 -/tests/components/home_connect/ @DavidMStraub @Diegorro98 +/homeassistant/components/home_connect/ @DavidMStraub @Diegorro98 @MartinHjelmare +/tests/components/home_connect/ @DavidMStraub @Diegorro98 @MartinHjelmare /homeassistant/components/homeassistant/ @home-assistant/core /tests/components/homeassistant/ @home-assistant/core /homeassistant/components/homeassistant_alerts/ @home-assistant/core diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 905a7c67f11..1d9f3f363aa 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -1,7 +1,7 @@ { "domain": "home_connect", "name": "Home Connect", - "codeowners": ["@DavidMStraub", "@Diegorro98"], + "codeowners": ["@DavidMStraub", "@Diegorro98", "@MartinHjelmare"], "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/home_connect", From df59b1d4fac0678b8a1371c9d0083be63e7d6185 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 31 Jan 2025 10:45:01 -0800 Subject: [PATCH 1321/2987] Persist roborock maps to disk only on shutdown (#136889) * Persist roborock maps to disk only on shutdown * Rename on_unload to on_stop * Spawn 1 executor thread and block writes to disk * Update tests/components/roborock/test_image.py Co-authored-by: Joost Lekkerkerker * Use config entry setup instead of component setup --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/roborock/__init__.py | 24 ++++++++++------ .../components/roborock/coordinator.py | 18 ++++++++---- homeassistant/components/roborock/image.py | 10 ++----- .../components/roborock/roborock_storage.py | 20 +++++++++++-- tests/components/roborock/conftest.py | 10 +++++-- tests/components/roborock/test_image.py | 28 +++++++++++-------- tests/components/roborock/test_init.py | 8 ++++++ 7 files changed, 79 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 1b34dc891d1..b383c1acfd7 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -22,7 +22,7 @@ from roborock.version_a01_apis import RoborockMqttClientA01 from roborock.web_api import RoborockApiClient from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -118,13 +118,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> ) valid_coordinators = RoborockCoordinators(v1_coords, a01_coords) - async def on_unload() -> None: - release_tasks = set() - for coordinator in valid_coordinators.values(): - release_tasks.add(coordinator.release()) - await asyncio.gather(*release_tasks) + async def on_stop(_: Any) -> None: + _LOGGER.debug("Shutting down roborock") + await asyncio.gather( + *( + coordinator.async_shutdown() + for coordinator in valid_coordinators.values() + ) + ) - entry.async_on_unload(on_unload) + entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, + on_stop, + ) + ) entry.runtime_data = valid_coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -209,7 +217,7 @@ async def setup_device_v1( try: await coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady as ex: - await coordinator.release() + await coordinator.async_shutdown() if isinstance(coordinator.api, RoborockMqttClientV1): _LOGGER.warning( "Not setting up %s because the we failed to get data for the first time using the online client. " diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 36333f1c55e..8860a5c1f43 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from datetime import timedelta import logging @@ -116,10 +117,14 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # Right now this should never be called if the cloud api is the primary api, # but in the future if it is, a new else should be added. - async def release(self) -> None: - """Disconnect from API.""" - await self.api.async_release() - await self.cloud_api.async_release() + async def async_shutdown(self) -> None: + """Shutdown the coordinator.""" + await super().async_shutdown() + await asyncio.gather( + self.map_storage.flush(), + self.api.async_release(), + self.cloud_api.async_release(), + ) async def _update_device_prop(self) -> None: """Update device properties.""" @@ -226,8 +231,9 @@ class RoborockDataUpdateCoordinatorA01( ) -> dict[RoborockDyadDataProtocol | RoborockZeoProtocol, StateType]: return await self.api.update_values(self.request_protocols) - async def release(self) -> None: - """Disconnect from API.""" + async def async_shutdown(self) -> None: + """Shutdown the coordinator on config entry unload.""" + await super().async_shutdown() await self.api.async_release() @cached_property diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index b0de4f9caa5..b4776c27164 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -157,13 +157,9 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): ) if self.cached_map != content: self.cached_map = content - self.config_entry.async_create_task( - self.hass, - self.coordinator.map_storage.async_save_map( - self.map_flag, - content, - ), - f"{self.unique_id} map", + await self.coordinator.map_storage.async_save_map( + self.map_flag, + content, ) return self.cached_map diff --git a/homeassistant/components/roborock/roborock_storage.py b/homeassistant/components/roborock/roborock_storage.py index 62e15e889be..8a469b0a38e 100644 --- a/homeassistant/components/roborock/roborock_storage.py +++ b/homeassistant/components/roborock/roborock_storage.py @@ -31,6 +31,7 @@ class RoborockMapStorage: self._path_prefix = ( _storage_path_prefix(hass, entry_id) / MAPS_PATH / device_id_slug ) + self._write_queue: dict[int, bytes] = {} async def async_load_map(self, map_flag: int) -> bytes | None: """Load maps from disk.""" @@ -48,9 +49,22 @@ class RoborockMapStorage: return None async def async_save_map(self, map_flag: int, content: bytes) -> None: - """Write map if it should be updated.""" - filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" - await self._hass.async_add_executor_job(self._save_map, filename, content) + """Save the map to a pending write queue.""" + self._write_queue[map_flag] = content + + async def flush(self) -> None: + """Flush all maps to disk.""" + _LOGGER.debug("Flushing %s maps to disk", len(self._write_queue)) + + queue = self._write_queue.copy() + + def _flush_all() -> None: + for map_flag, content in queue.items(): + filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" + self._save_map(filename, content) + + await self._hass.async_add_executor_job(_flush_all) + self._write_queue.clear() def _save_map(self, filename: Path, content: bytes) -> None: """Write the map to disk.""" diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index e5fc5cb7eb6..43e5148c9a8 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -19,9 +19,9 @@ from homeassistant.components.roborock.const import ( CONF_USER_DATA, DOMAIN, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .mock_data import ( BASE_URL, @@ -207,13 +207,13 @@ async def setup_entry( ) -> Generator[MockConfigEntry]: """Set up the Roborock platform.""" with patch("homeassistant.components.roborock.PLATFORMS", platforms): - assert await async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) await hass.async_block_till_done() yield mock_roborock_entry @pytest.fixture -def cleanup_map_storage( +async def cleanup_map_storage( hass: HomeAssistant, mock_roborock_entry: MockConfigEntry ) -> Generator[pathlib.Path]: """Test cleanup, remove any map storage persisted during the test.""" @@ -225,4 +225,8 @@ def cleanup_map_storage( pathlib.Path(hass.config.path(tmp_path)) / mock_roborock_entry.entry_id ) yield storage_path + # We need to first unload the config entry because unloading it will + # persist any unsaved maps to storage. + if mock_roborock_entry.state is ConfigEntryState.LOADED: + await hass.config_entries.async_unload(mock_roborock_entry.entry_id) shutil.rmtree(str(storage_path), ignore_errors=True) diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index 90886f25929..fd6c8b2796a 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -12,6 +12,7 @@ from roborock import RoborockException from vacuum_map_parser_base.map_data import ImageConfig, ImageData from homeassistant.components.roborock import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -120,7 +121,7 @@ async def test_load_stored_image( MAP_DATA.image.data.save(img_byte_arr, format="PNG") img_bytes = img_byte_arr.getvalue() - # Load the image on demand, which should ensure it is cached on disk + # Load the image on demand, which should queue it to be cached on disk client = await hass_client() resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK @@ -151,22 +152,25 @@ async def test_fail_to_save_image( caplog: pytest.LogCaptureFixture, ) -> None: """Test that we gracefully handle a oserror on saving an image.""" - # Reload the config entry so that the map is saved in storage and entities exist. + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Ensure that map is still working properly. + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + # Test that we can get the image and it correctly serialized and unserialized. + assert resp.status == HTTPStatus.OK + with patch( "homeassistant.components.roborock.roborock_storage.Path.write_bytes", side_effect=OSError, ): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await hass.config_entries.async_unload(mock_roborock_entry.entry_id) + assert "Unable to write map file" in caplog.text - # Ensure that map is still working properly. - assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None - client = await hass_client() - resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - # Test that we can get the image and it correctly serialized and unserialized. - assert resp.status == HTTPStatus.OK - - assert "Unable to write map file" in caplog.text + # Config entry is unloaded successfully + assert mock_roborock_entry.state is ConfigEntryState.NOT_LOADED async def test_fail_to_load_image( diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index efd1c3f66f4..904a3af89d6 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -183,6 +183,10 @@ async def test_remove_from_hass( resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK + assert not cleanup_map_storage.exists() + + # Flush to disk + await hass.config_entries.async_unload(setup_entry.entry_id) assert cleanup_map_storage.exists() paths = list(cleanup_map_storage.walk()) assert len(paths) == 3 # One map image and two directories @@ -209,6 +213,10 @@ async def test_oserror_remove_image( resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK + # Image content is saved when unloading + assert not cleanup_map_storage.exists() + await hass.config_entries.async_unload(setup_entry.entry_id) + assert cleanup_map_storage.exists() paths = list(cleanup_map_storage.walk()) assert len(paths) == 3 # One map image and two directories From 92dd18a9bed750869a460f45159fcefcfafdeec1 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 31 Jan 2025 19:48:47 +0100 Subject: [PATCH 1322/2987] Ensure Reolink can start when privacy mode is enabled (#136514) * Allow startup when privacy mode is enabled * Add tests * remove duplicate privacy_mode * fix tests * Apply suggestions from code review Co-authored-by: Robert Resch * Store in subfolder and cleanup when removed * Add tests and fixes * fix styling * rename CONF_PRIVACY to CONF_SUPPORTS_PRIVACY_MODE * use helper store --------- Co-authored-by: Robert Resch --- homeassistant/components/reolink/__init__.py | 34 +++++++++++++------ .../components/reolink/config_flow.py | 5 ++- homeassistant/components/reolink/const.py | 1 + homeassistant/components/reolink/host.py | 30 ++++++++++++++-- homeassistant/components/reolink/util.py | 14 ++++++-- tests/components/reolink/conftest.py | 13 ++++++- tests/components/reolink/test_config_flow.py | 11 +++++- tests/components/reolink/test_init.py | 12 +++++++ 8 files changed, 102 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 576ab3c64f8..71ca5428740 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -28,11 +28,11 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .host import ReolinkHost from .services import async_setup_services -from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch +from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch, get_store from .views import PlaybackProxyView _LOGGER = logging.getLogger(__name__) @@ -67,7 +67,9 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry ) -> bool: """Set up Reolink from a config entry.""" - host = ReolinkHost(hass, config_entry.data, config_entry.options) + host = ReolinkHost( + hass, config_entry.data, config_entry.options, config_entry.entry_id + ) try: await host.async_init() @@ -92,21 +94,25 @@ async def async_setup_entry( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop) ) - # update the port info if needed for the next time + # update the config info if needed for the next time if ( host.api.port != config_entry.data[CONF_PORT] or host.api.use_https != config_entry.data[CONF_USE_HTTPS] + or host.api.supported(None, "privacy_mode") + != config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE) ): - _LOGGER.warning( - "HTTP(s) port of Reolink %s, changed from %s to %s", - host.api.nvr_name, - config_entry.data[CONF_PORT], - host.api.port, - ) + if host.api.port != config_entry.data[CONF_PORT]: + _LOGGER.warning( + "HTTP(s) port of Reolink %s, changed from %s to %s", + host.api.nvr_name, + config_entry.data[CONF_PORT], + host.api.port, + ) data = { **config_entry.data, CONF_PORT: host.api.port, CONF_USE_HTTPS: host.api.use_https, + CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"), } hass.config_entries.async_update_entry(config_entry, data=data) @@ -248,6 +254,14 @@ async def async_unload_entry( return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) +async def async_remove_entry( + hass: HomeAssistant, config_entry: ReolinkConfigEntry +) -> None: + """Handle removal of an entry.""" + store = get_store(hass, config_entry.entry_id) + await store.async_remove() + + async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ReolinkConfigEntry, device: dr.DeviceEntry ) -> bool: diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index e15a43e360b..7943cadef21 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -37,7 +37,7 @@ from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkException, @@ -287,6 +287,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): if not errors: user_input[CONF_PORT] = host.api.port user_input[CONF_USE_HTTPS] = host.api.use_https + user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported( + None, "privacy_mode" + ) mac_address = format_mac(host.api.mac_address) await self.async_set_unique_id(mac_address, raise_on_progress=False) diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index 8aa01bfac41..7bd93337c46 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -3,3 +3,4 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" +CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index e9b86f1e297..a23f53ff9cd 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -30,15 +30,17 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.storage import Store from homeassistant.util.ssl import SSLCipherList -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkSetupException, ReolinkWebhookException, UserNotAdmin, ) +from .util import get_store DEFAULT_TIMEOUT = 30 FIRST_TCP_PUSH_TIMEOUT = 10 @@ -64,9 +66,12 @@ class ReolinkHost: hass: HomeAssistant, config: Mapping[str, Any], options: Mapping[str, Any], + config_entry_id: str | None = None, ) -> None: """Initialize Reolink Host. Could be either NVR, or Camera.""" self._hass: HomeAssistant = hass + self._config_entry_id = config_entry_id + self._config = config self._unique_id: str = "" def get_aiohttp_session() -> aiohttp.ClientSession: @@ -150,6 +155,14 @@ class ReolinkHost: f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS}" ) + store: Store[str] | None = None + if self._config_entry_id is not None: + store = get_store(self._hass, self._config_entry_id) + if self._config.get(CONF_SUPPORTS_PRIVACY_MODE): + data = await store.async_load() + if data: + self._api.set_raw_host_data(data) + await self._api.get_host_data() if self._api.mac_address is None: @@ -161,6 +174,19 @@ class ReolinkHost: f"'{self._api.user_level}', only admin users can change camera settings" ) + self.privacy_mode = self._api.baichuan.privacy_mode() + + if ( + store + and self._api.supported(None, "privacy_mode") + and not self.privacy_mode + ): + _LOGGER.debug( + "Saving raw host data for next reload in case privacy mode is enabled" + ) + data = self._api.get_raw_host_data() + await store.async_save(data) + onvif_supported = self._api.supported(None, "ONVIF") self._onvif_push_supported = onvif_supported self._onvif_long_poll_supported = onvif_supported @@ -235,8 +261,6 @@ class ReolinkHost: self._hass, FIRST_TCP_PUSH_TIMEOUT, self._async_check_tcp_push ) - self.privacy_mode = self._api.baichuan.privacy_mode() - ch_list: list[int | None] = [None] if self._api.is_nvr: ch_list.extend(self._api.channels) diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index e43391f19fb..a5556b66a33 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from reolink_aio.exceptions import ( ApiError, @@ -26,10 +26,15 @@ from homeassistant.components.media_source import Unresolvable from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN -from .host import ReolinkHost + +if TYPE_CHECKING: + from .host import ReolinkHost + +STORAGE_VERSION = 1 type ReolinkConfigEntry = config_entries.ConfigEntry[ReolinkData] @@ -64,6 +69,11 @@ def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost: return config_entry.runtime_data.host +def get_store(hass: HomeAssistant, config_entry_id: str) -> Store[str]: + """Return the reolink store.""" + return Store[str](hass, STORAGE_VERSION, f"{DOMAIN}.{config_entry_id}.json") + + def get_device_uid_and_ch( device: dr.DeviceEntry, host: ReolinkHost ) -> tuple[list[str], int | None, bool]: diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index f8012f91351..2862aa55b4d 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -9,7 +9,11 @@ from reolink_aio.baichuan import Baichuan from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN +from homeassistant.components.reolink.const import ( + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DOMAIN, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -43,6 +47,7 @@ TEST_HOST_MODEL = "RLN8-410" TEST_ITEM_NUMBER = "P000" TEST_CAM_MODEL = "RLC-123" TEST_DUO_MODEL = "Reolink Duo PoE" +TEST_PRIVACY = True @pytest.fixture @@ -65,6 +70,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock = host_mock_class.return_value host_mock.get_host_data.return_value = None host_mock.get_states.return_value = None + host_mock.supported.return_value = True host_mock.check_new_firmware.return_value = False host_mock.unsubscribe.return_value = True host_mock.logout.return_value = True @@ -113,6 +119,9 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.capabilities = {"Host": ["RTSP"], "0": ["motion_detection"]} host_mock.checked_api_versions = {"GetEvents": 1} host_mock.abilities = {"abilityChn": [{"aiTrack": {"permit": 0, "ver": 0}}]} + host_mock.get_raw_host_data.return_value = ( + "{'host':'TEST_RESPONSE','channel':'TEST_RESPONSE'}" + ) # enums host_mock.whiteled_mode.return_value = 1 @@ -128,6 +137,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.baichuan.events_active = False host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") + yield host_mock_class @@ -158,6 +168,7 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 4d474588f38..4fe671f8cca 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -18,7 +18,11 @@ from reolink_aio.exceptions import ( from homeassistant import config_entries from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN +from homeassistant.components.reolink.const import ( + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DOMAIN, +) from homeassistant.components.reolink.exceptions import ReolinkWebhookException from homeassistant.components.reolink.host import DEFAULT_TIMEOUT from homeassistant.config_entries import ConfigEntryState @@ -43,6 +47,7 @@ from .conftest import ( TEST_PASSWORD, TEST_PASSWORD2, TEST_PORT, + TEST_PRIVACY, TEST_USE_HTTPS, TEST_USERNAME, TEST_USERNAME2, @@ -82,6 +87,7 @@ async def test_config_flow_manual_success( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -133,6 +139,7 @@ async def test_config_flow_privacy_success( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -294,6 +301,7 @@ async def test_config_flow_errors( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -465,6 +473,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 7895923dd12..25029375eb6 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -859,3 +859,15 @@ async def test_privacy_mode_change_callback( assert reolink_connect.get_states.call_count >= 1 assert hass.states.get(entity_id).state == STATE_ON + + +async def test_remove( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test removing of the reolink integration.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_remove(config_entry.entry_id) From f75a61ac904f689a7e9df233ade94c0bf8672991 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 12:52:38 -0600 Subject: [PATCH 1323/2987] Bump SQLAlchemy to 2.0.37 (#137028) changelog: https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-2.0.37 There is a bug fix that likely affects us that could lead to corrupted queries https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-e4d04d8eb1bccee16b74f5662aff8edd --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/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, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index d3b6e52ad11..7cef284ef60 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.36", + "SQLAlchemy==2.0.37", "fnv-hash-fast==1.2.2", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 01c95d6c5e4..0094770d53b 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.36", "sqlparse==0.5.0"] + "requirements": ["SQLAlchemy==2.0.37", "sqlparse==0.5.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2d4e92e2e9a..0a1b97abc55 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -61,7 +61,7 @@ PyTurboJPEG==1.7.5 PyYAML==6.0.2 requests==2.32.3 securetar==2025.1.4 -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 diff --git a/pyproject.toml b/pyproject.toml index 74e3d51a222..3ad3240907c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ dependencies = [ "PyYAML==6.0.2", "requests==2.32.3", "securetar==2025.1.4", - "SQLAlchemy==2.0.36", + "SQLAlchemy==2.0.37", "standard-aifc==3.13.0;python_version>='3.13'", "standard-telnetlib==3.13.0;python_version>='3.13'", "typing-extensions>=4.12.2,<5.0", diff --git a/requirements.txt b/requirements.txt index 77fd3887db4..02f3849148b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -39,7 +39,7 @@ python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 securetar==2025.1.4 -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5e182110235..1cfea1bb0e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -116,7 +116,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 86557711111..7b77388556d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -110,7 +110,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 From df166d178c8fc2b3f7589af6bf6a8d0790c6c776 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 31 Jan 2025 20:17:14 +0100 Subject: [PATCH 1324/2987] Bump deebot-client to 11.1.0b2 (#137030) --- 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 188f59f74e4..16929e1741a 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==11.1.0b1"] + "requirements": ["py-sucks==0.9.10", "deebot-client==11.1.0b2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1cfea1bb0e1..bb48565e2ee 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==11.1.0b1 +deebot-client==11.1.0b2 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7b77388556d..1695de16332 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ dbus-fast==2.30.2 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==11.1.0b1 +deebot-client==11.1.0b2 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 9a55b5e3f7622c3949eef07cca83c7d488e7592a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 31 Jan 2025 19:48:47 +0100 Subject: [PATCH 1325/2987] Ensure Reolink can start when privacy mode is enabled (#136514) * Allow startup when privacy mode is enabled * Add tests * remove duplicate privacy_mode * fix tests * Apply suggestions from code review Co-authored-by: Robert Resch * Store in subfolder and cleanup when removed * Add tests and fixes * fix styling * rename CONF_PRIVACY to CONF_SUPPORTS_PRIVACY_MODE * use helper store --------- Co-authored-by: Robert Resch --- homeassistant/components/reolink/__init__.py | 34 +++++++++++++------ .../components/reolink/config_flow.py | 5 ++- homeassistant/components/reolink/const.py | 1 + homeassistant/components/reolink/host.py | 30 ++++++++++++++-- homeassistant/components/reolink/util.py | 14 ++++++-- tests/components/reolink/conftest.py | 13 ++++++- tests/components/reolink/test_config_flow.py | 11 +++++- tests/components/reolink/test_init.py | 12 +++++++ 8 files changed, 102 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 576ab3c64f8..71ca5428740 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -28,11 +28,11 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .host import ReolinkHost from .services import async_setup_services -from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch +from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch, get_store from .views import PlaybackProxyView _LOGGER = logging.getLogger(__name__) @@ -67,7 +67,9 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry ) -> bool: """Set up Reolink from a config entry.""" - host = ReolinkHost(hass, config_entry.data, config_entry.options) + host = ReolinkHost( + hass, config_entry.data, config_entry.options, config_entry.entry_id + ) try: await host.async_init() @@ -92,21 +94,25 @@ async def async_setup_entry( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop) ) - # update the port info if needed for the next time + # update the config info if needed for the next time if ( host.api.port != config_entry.data[CONF_PORT] or host.api.use_https != config_entry.data[CONF_USE_HTTPS] + or host.api.supported(None, "privacy_mode") + != config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE) ): - _LOGGER.warning( - "HTTP(s) port of Reolink %s, changed from %s to %s", - host.api.nvr_name, - config_entry.data[CONF_PORT], - host.api.port, - ) + if host.api.port != config_entry.data[CONF_PORT]: + _LOGGER.warning( + "HTTP(s) port of Reolink %s, changed from %s to %s", + host.api.nvr_name, + config_entry.data[CONF_PORT], + host.api.port, + ) data = { **config_entry.data, CONF_PORT: host.api.port, CONF_USE_HTTPS: host.api.use_https, + CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"), } hass.config_entries.async_update_entry(config_entry, data=data) @@ -248,6 +254,14 @@ async def async_unload_entry( return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) +async def async_remove_entry( + hass: HomeAssistant, config_entry: ReolinkConfigEntry +) -> None: + """Handle removal of an entry.""" + store = get_store(hass, config_entry.entry_id) + await store.async_remove() + + async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ReolinkConfigEntry, device: dr.DeviceEntry ) -> bool: diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index e15a43e360b..7943cadef21 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -37,7 +37,7 @@ from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkException, @@ -287,6 +287,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): if not errors: user_input[CONF_PORT] = host.api.port user_input[CONF_USE_HTTPS] = host.api.use_https + user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported( + None, "privacy_mode" + ) mac_address = format_mac(host.api.mac_address) await self.async_set_unique_id(mac_address, raise_on_progress=False) diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index 8aa01bfac41..7bd93337c46 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -3,3 +3,4 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" +CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index e9b86f1e297..a23f53ff9cd 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -30,15 +30,17 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.storage import Store from homeassistant.util.ssl import SSLCipherList -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkSetupException, ReolinkWebhookException, UserNotAdmin, ) +from .util import get_store DEFAULT_TIMEOUT = 30 FIRST_TCP_PUSH_TIMEOUT = 10 @@ -64,9 +66,12 @@ class ReolinkHost: hass: HomeAssistant, config: Mapping[str, Any], options: Mapping[str, Any], + config_entry_id: str | None = None, ) -> None: """Initialize Reolink Host. Could be either NVR, or Camera.""" self._hass: HomeAssistant = hass + self._config_entry_id = config_entry_id + self._config = config self._unique_id: str = "" def get_aiohttp_session() -> aiohttp.ClientSession: @@ -150,6 +155,14 @@ class ReolinkHost: f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS}" ) + store: Store[str] | None = None + if self._config_entry_id is not None: + store = get_store(self._hass, self._config_entry_id) + if self._config.get(CONF_SUPPORTS_PRIVACY_MODE): + data = await store.async_load() + if data: + self._api.set_raw_host_data(data) + await self._api.get_host_data() if self._api.mac_address is None: @@ -161,6 +174,19 @@ class ReolinkHost: f"'{self._api.user_level}', only admin users can change camera settings" ) + self.privacy_mode = self._api.baichuan.privacy_mode() + + if ( + store + and self._api.supported(None, "privacy_mode") + and not self.privacy_mode + ): + _LOGGER.debug( + "Saving raw host data for next reload in case privacy mode is enabled" + ) + data = self._api.get_raw_host_data() + await store.async_save(data) + onvif_supported = self._api.supported(None, "ONVIF") self._onvif_push_supported = onvif_supported self._onvif_long_poll_supported = onvif_supported @@ -235,8 +261,6 @@ class ReolinkHost: self._hass, FIRST_TCP_PUSH_TIMEOUT, self._async_check_tcp_push ) - self.privacy_mode = self._api.baichuan.privacy_mode() - ch_list: list[int | None] = [None] if self._api.is_nvr: ch_list.extend(self._api.channels) diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index e43391f19fb..a5556b66a33 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from reolink_aio.exceptions import ( ApiError, @@ -26,10 +26,15 @@ from homeassistant.components.media_source import Unresolvable from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN -from .host import ReolinkHost + +if TYPE_CHECKING: + from .host import ReolinkHost + +STORAGE_VERSION = 1 type ReolinkConfigEntry = config_entries.ConfigEntry[ReolinkData] @@ -64,6 +69,11 @@ def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost: return config_entry.runtime_data.host +def get_store(hass: HomeAssistant, config_entry_id: str) -> Store[str]: + """Return the reolink store.""" + return Store[str](hass, STORAGE_VERSION, f"{DOMAIN}.{config_entry_id}.json") + + def get_device_uid_and_ch( device: dr.DeviceEntry, host: ReolinkHost ) -> tuple[list[str], int | None, bool]: diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index f8012f91351..2862aa55b4d 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -9,7 +9,11 @@ from reolink_aio.baichuan import Baichuan from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN +from homeassistant.components.reolink.const import ( + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DOMAIN, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -43,6 +47,7 @@ TEST_HOST_MODEL = "RLN8-410" TEST_ITEM_NUMBER = "P000" TEST_CAM_MODEL = "RLC-123" TEST_DUO_MODEL = "Reolink Duo PoE" +TEST_PRIVACY = True @pytest.fixture @@ -65,6 +70,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock = host_mock_class.return_value host_mock.get_host_data.return_value = None host_mock.get_states.return_value = None + host_mock.supported.return_value = True host_mock.check_new_firmware.return_value = False host_mock.unsubscribe.return_value = True host_mock.logout.return_value = True @@ -113,6 +119,9 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.capabilities = {"Host": ["RTSP"], "0": ["motion_detection"]} host_mock.checked_api_versions = {"GetEvents": 1} host_mock.abilities = {"abilityChn": [{"aiTrack": {"permit": 0, "ver": 0}}]} + host_mock.get_raw_host_data.return_value = ( + "{'host':'TEST_RESPONSE','channel':'TEST_RESPONSE'}" + ) # enums host_mock.whiteled_mode.return_value = 1 @@ -128,6 +137,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.baichuan.events_active = False host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") + yield host_mock_class @@ -158,6 +168,7 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 4d474588f38..4fe671f8cca 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -18,7 +18,11 @@ from reolink_aio.exceptions import ( from homeassistant import config_entries from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN +from homeassistant.components.reolink.const import ( + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DOMAIN, +) from homeassistant.components.reolink.exceptions import ReolinkWebhookException from homeassistant.components.reolink.host import DEFAULT_TIMEOUT from homeassistant.config_entries import ConfigEntryState @@ -43,6 +47,7 @@ from .conftest import ( TEST_PASSWORD, TEST_PASSWORD2, TEST_PORT, + TEST_PRIVACY, TEST_USE_HTTPS, TEST_USERNAME, TEST_USERNAME2, @@ -82,6 +87,7 @@ async def test_config_flow_manual_success( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -133,6 +139,7 @@ async def test_config_flow_privacy_success( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -294,6 +301,7 @@ async def test_config_flow_errors( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -465,6 +473,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 7895923dd12..25029375eb6 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -859,3 +859,15 @@ async def test_privacy_mode_change_callback( assert reolink_connect.get_states.call_count >= 1 assert hass.states.get(entity_id).state == STATE_ON + + +async def test_remove( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test removing of the reolink integration.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_remove(config_entry.entry_id) From a955901d4025c33a49cf2b53e231d0ff5c85eaa5 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:52:17 +0100 Subject: [PATCH 1326/2987] Refactor eheimdigital platform async_setup_entry (#136745) --- .../components/eheimdigital/climate.py | 21 +++-- .../components/eheimdigital/coordinator.py | 9 ++- .../components/eheimdigital/light.py | 31 ++++---- .../eheimdigital/snapshots/test_climate.ambr | 76 +++++++++++++++++++ tests/components/eheimdigital/test_climate.py | 35 +++++++++ 5 files changed, 148 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py index 16771ba227d..9b1f825dece 100644 --- a/homeassistant/components/eheimdigital/climate.py +++ b/homeassistant/components/eheimdigital/climate.py @@ -2,6 +2,7 @@ from typing import Any +from eheimdigital.device import EheimDigitalDevice from eheimdigital.heater import EheimDigitalHeater from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit @@ -39,17 +40,23 @@ async def async_setup_entry( """Set up the callbacks for the coordinator so climate entities can be added as devices are found.""" coordinator = entry.runtime_data - async def async_setup_device_entities(device_address: str) -> None: - """Set up the light entities for a device.""" - device = coordinator.hub.devices[device_address] + def async_setup_device_entities( + device_address: str | dict[str, EheimDigitalDevice], + ) -> None: + """Set up the climate entities for one or multiple devices.""" + entities: list[EheimDigitalHeaterClimate] = [] + if isinstance(device_address, str): + device_address = {device_address: coordinator.hub.devices[device_address]} + for device in device_address.values(): + if isinstance(device, EheimDigitalHeater): + entities.append(EheimDigitalHeaterClimate(coordinator, device)) + coordinator.known_devices.add(device.mac_address) - if isinstance(device, EheimDigitalHeater): - async_add_entities([EheimDigitalHeaterClimate(coordinator, device)]) + async_add_entities(entities) coordinator.add_platform_callback(async_setup_device_entities) - for device_address in entry.runtime_data.hub.devices: - await async_setup_device_entities(device_address) + async_setup_device_entities(coordinator.hub.devices) class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateEntity): diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py index f122a1227c5..ee4f09426b7 100644 --- a/homeassistant/components/eheimdigital/coordinator.py +++ b/homeassistant/components/eheimdigital/coordinator.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine -from typing import Any +from collections.abc import Callable from aiohttp import ClientError from eheimdigital.device import EheimDigitalDevice @@ -19,7 +18,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER -type AsyncSetupDeviceEntitiesCallback = Callable[[str], Coroutine[Any, Any, None]] +type AsyncSetupDeviceEntitiesCallback = Callable[ + [str | dict[str, EheimDigitalDevice]], None +] class EheimDigitalUpdateCoordinator( @@ -61,7 +62,7 @@ class EheimDigitalUpdateCoordinator( if device_address not in self.known_devices: for platform_callback in self.platform_callbacks: - await platform_callback(device_address) + platform_callback(device_address) async def _async_receive_callback(self) -> None: self.async_set_updated_data(self.hub.devices) diff --git a/homeassistant/components/eheimdigital/light.py b/homeassistant/components/eheimdigital/light.py index a119e0bda8d..5ae0a6e866a 100644 --- a/homeassistant/components/eheimdigital/light.py +++ b/homeassistant/components/eheimdigital/light.py @@ -3,6 +3,7 @@ from typing import Any from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.device import EheimDigitalDevice from eheimdigital.types import EheimDigitalClientError, LightMode from homeassistant.components.light import ( @@ -37,24 +38,28 @@ async def async_setup_entry( """Set up the callbacks for the coordinator so lights can be added as devices are found.""" coordinator = entry.runtime_data - async def async_setup_device_entities(device_address: str) -> None: - """Set up the light entities for a device.""" - device = coordinator.hub.devices[device_address] + def async_setup_device_entities( + device_address: str | dict[str, EheimDigitalDevice], + ) -> None: + """Set up the light entities for one or multiple devices.""" entities: list[EheimDigitalClassicLEDControlLight] = [] + if isinstance(device_address, str): + device_address = {device_address: coordinator.hub.devices[device_address]} + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicLEDControl): + for channel in range(2): + if len(device.tankconfig[channel]) > 0: + entities.append( + EheimDigitalClassicLEDControlLight( + coordinator, device, channel + ) + ) + coordinator.known_devices.add(device.mac_address) - if isinstance(device, EheimDigitalClassicLEDControl): - for channel in range(2): - if len(device.tankconfig[channel]) > 0: - entities.append( - EheimDigitalClassicLEDControlLight(coordinator, device, channel) - ) - coordinator.known_devices.add(device.mac_address) async_add_entities(entities) coordinator.add_platform_callback(async_setup_device_entities) - - for device_address in entry.runtime_data.hub.devices: - await async_setup_device_entities(device_address) + async_setup_device_entities(coordinator.hub.devices) class EheimDigitalClassicLEDControlLight( diff --git a/tests/components/eheimdigital/snapshots/test_climate.ambr b/tests/components/eheimdigital/snapshots/test_climate.ambr index 02d60677b24..d81c59e5af1 100644 --- a/tests/components/eheimdigital/snapshots/test_climate.ambr +++ b/tests/components/eheimdigital/snapshots/test_climate.ambr @@ -1,4 +1,80 @@ # serializer version: 1 +# name: test_dynamic_new_devices[climate.mock_heater_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.mock_heater_none', + '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': None, + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'heater', + 'unique_id': '00:00:00:00:00:02', + 'unit_of_measurement': None, + }) +# --- +# name: test_dynamic_new_devices[climate.mock_heater_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 24.2, + 'friendly_name': 'Mock Heater None', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 25.5, + }), + 'context': , + 'entity_id': 'climate.mock_heater_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- # name: test_setup_heater[climate.mock_heater_none-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py index 4e770882263..f64b7d7e740 100644 --- a/tests/components/eheimdigital/test_climate.py +++ b/tests/components/eheimdigital/test_climate.py @@ -56,6 +56,41 @@ async def test_setup_heater( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +async def test_dynamic_new_devices( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + heater_mock: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test light platform setup with at first no devices and dynamically adding a device.""" + mock_config_entry.add_to_hass(hass) + + eheimdigital_hub_mock.return_value.devices = {} + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert ( + len( + entity_registry.entities.get_entries_for_config_entry_id( + mock_config_entry.entry_id + ) + ) + == 0 + ) + + eheimdigital_hub_mock.return_value.devices = {"00:00:00:00:00:02": heater_mock} + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + @pytest.mark.parametrize( ("preset_mode", "heater_mode"), [ From 833b17a8ee0dad73b8916d90fa86b885983c3d66 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Fri, 31 Jan 2025 01:36:06 -0800 Subject: [PATCH 1327/2987] Bump total-connect-client to 2025.1.4 (#136793) --- .../totalconnect/alarm_control_panel.py | 4 +- .../components/totalconnect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/totalconnect/common.py | 39 +++++++++++-------- .../totalconnect/test_config_flow.py | 20 +++++++--- 6 files changed, 43 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index 48ba78acc92..021d1c7b886 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -73,7 +73,7 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): ) -> None: """Initialize the TotalConnect status.""" super().__init__(coordinator, location) - self._partition_id = partition_id + self._partition_id = int(partition_id) self._partition = self._location.partitions[partition_id] """ @@ -81,7 +81,7 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): for most users with new support for partitions. Add _# for partition 2 and beyond. """ - if partition_id == 1: + if int(partition_id) == 1: self._attr_name = None self._attr_unique_id = str(location.location_id) else: diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index 33306a7adba..6aff1ea392b 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/totalconnect", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], - "requirements": ["total-connect-client==2024.12"] + "requirements": ["total-connect-client==2025.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2bf3b5f1943..dc1bfd1a839 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2902,7 +2902,7 @@ tololib==1.1.0 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2024.12 +total-connect-client==2025.1.4 # homeassistant.components.tplink_lte tp-connected==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c5f81e6a2c..98706c45443 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2330,7 +2330,7 @@ tololib==1.1.0 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2024.12 +total-connect-client==2025.1.4 # homeassistant.components.tplink_omada tplink-omada-client==1.4.3 diff --git a/tests/components/totalconnect/common.py b/tests/components/totalconnect/common.py index 828cad71e07..34d451ec0b8 100644 --- a/tests/components/totalconnect/common.py +++ b/tests/components/totalconnect/common.py @@ -49,20 +49,15 @@ USER = { "UserFeatureList": "Master=0,User Administration=0,Configuration Administration=0", } -RESPONSE_AUTHENTICATE = { +RESPONSE_SESSION_DETAILS = { "ResultCode": ResultCode.SUCCESS.value, - "SessionID": 1, + "ResultData": "Success", + "SessionID": "12345", "Locations": LOCATIONS, "ModuleFlags": MODULE_FLAGS, "UserInfo": USER, } -RESPONSE_AUTHENTICATE_FAILED = { - "ResultCode": ResultCode.BAD_USER_OR_PASSWORD.value, - "ResultData": "test bad authentication", -} - - PARTITION_DISARMED = { "PartitionID": "1", "ArmingState": ArmingState.DISARMED, @@ -359,13 +354,13 @@ OPTIONS_DATA = {AUTO_BYPASS: False, CODE_REQUIRED: False} OPTIONS_DATA_CODE_REQUIRED = {AUTO_BYPASS: False, CODE_REQUIRED: True} PARTITION_DETAILS_1 = { - "PartitionID": 1, + "PartitionID": "1", "ArmingState": ArmingState.DISARMED.value, "PartitionName": "Test1", } PARTITION_DETAILS_2 = { - "PartitionID": 2, + "PartitionID": "2", "ArmingState": ArmingState.DISARMED.value, "PartitionName": "Test2", } @@ -402,6 +397,12 @@ RESPONSE_GET_ZONE_DETAILS_SUCCESS = { TOTALCONNECT_REQUEST = ( "homeassistant.components.totalconnect.TotalConnectClient.request" ) +TOTALCONNECT_GET_CONFIG = ( + "homeassistant.components.totalconnect.TotalConnectClient._get_configuration" +) +TOTALCONNECT_REQUEST_TOKEN = ( + "homeassistant.components.totalconnect.TotalConnectClient._request_token" +) async def setup_platform( @@ -420,7 +421,7 @@ async def setup_platform( mock_entry.add_to_hass(hass) responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -433,6 +434,8 @@ async def setup_platform( TOTALCONNECT_REQUEST, side_effect=responses, ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), ): assert await async_setup_component(hass, DOMAIN, {}) assert mock_request.call_count == 5 @@ -448,17 +451,21 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: mock_entry.add_to_hass(hass) responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, RESPONSE_DISARMED, ] - with patch( - TOTALCONNECT_REQUEST, - side_effect=responses, - ) as mock_request: + with ( + patch( + TOTALCONNECT_REQUEST, + side_effect=responses, + ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), + ): await hass.config_entries.async_setup(mock_entry.entry_id) assert mock_request.call_count == 5 await hass.async_block_till_done() diff --git a/tests/components/totalconnect/test_config_flow.py b/tests/components/totalconnect/test_config_flow.py index 86419bff817..f5020394bce 100644 --- a/tests/components/totalconnect/test_config_flow.py +++ b/tests/components/totalconnect/test_config_flow.py @@ -18,13 +18,15 @@ from homeassistant.data_entry_flow import FlowResultType from .common import ( CONFIG_DATA, CONFIG_DATA_NO_USERCODES, - RESPONSE_AUTHENTICATE, RESPONSE_DISARMED, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_PARTITION_DETAILS, + RESPONSE_SESSION_DETAILS, RESPONSE_SUCCESS, RESPONSE_USER_CODE_INVALID, + TOTALCONNECT_GET_CONFIG, TOTALCONNECT_REQUEST, + TOTALCONNECT_REQUEST_TOKEN, USERNAME, ) @@ -48,7 +50,7 @@ async def test_user_show_locations(hass: HomeAssistant) -> None: """Test user locations form.""" # user/pass provided, so check if valid then ask for usercodes on locations form responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -61,6 +63,8 @@ async def test_user_show_locations(hass: HomeAssistant) -> None: TOTALCONNECT_REQUEST, side_effect=responses, ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), patch( "homeassistant.components.totalconnect.async_setup_entry", return_value=True ), @@ -180,7 +184,7 @@ async def test_reauth(hass: HomeAssistant) -> None: async def test_no_locations(hass: HomeAssistant) -> None: """Test with no user locations.""" responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -191,6 +195,8 @@ async def test_no_locations(hass: HomeAssistant) -> None: TOTALCONNECT_REQUEST, side_effect=responses, ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), patch( "homeassistant.components.totalconnect.async_setup_entry", return_value=True ), @@ -221,7 +227,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -229,7 +235,11 @@ async def test_options_flow(hass: HomeAssistant) -> None: RESPONSE_DISARMED, ] - with patch(TOTALCONNECT_REQUEST, side_effect=responses): + with ( + patch(TOTALCONNECT_REQUEST, side_effect=responses), + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From 04a7c6f15e1aea1eda29bf651fe3a3452a9d460f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 31 Jan 2025 10:38:30 +0100 Subject: [PATCH 1328/2987] Fixes to the user-facing strings of energenie_power_sockets (#136844) --- homeassistant/components/energenie_power_sockets/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/energenie_power_sockets/strings.json b/homeassistant/components/energenie_power_sockets/strings.json index e193b06b25f..4e4e49c68fb 100644 --- a/homeassistant/components/energenie_power_sockets/strings.json +++ b/homeassistant/components/energenie_power_sockets/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "Searching for Energenie-Power-Sockets Devices.", + "title": "Searching for Energenie Power Sockets devices", "description": "Choose a discovered device.", "data": { "device": "[%key:common::config_flow::data::device%]" @@ -13,7 +13,7 @@ "abort": { "usb_error": "Couldn't access USB devices!", "no_device": "Unable to discover any (new) supported device.", - "device_not_found": "No device was found for the given id.", + "device_not_found": "No device was found for the given ID.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, From 5cec045cac5a0c82bc24093cef940dc08584fbce Mon Sep 17 00:00:00 2001 From: Jan Stienstra <65826735+RunC0deRun@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:29:00 +0100 Subject: [PATCH 1329/2987] Bump jellyfin-apiclient-python to 1.10.0 (#136872) --- homeassistant/components/jellyfin/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json index 19358cff17c..810b9ea45a9 100644 --- a/homeassistant/components/jellyfin/manifest.json +++ b/homeassistant/components/jellyfin/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["jellyfin_apiclient_python"], - "requirements": ["jellyfin-apiclient-python==1.9.2"], + "requirements": ["jellyfin-apiclient-python==1.10.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index dc1bfd1a839..84a2cd7ad2e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1247,7 +1247,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.9.2 +jellyfin-apiclient-python==1.10.0 # homeassistant.components.command_line # homeassistant.components.rest diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 98706c45443..326670c9f63 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1058,7 +1058,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.9.2 +jellyfin-apiclient-python==1.10.0 # homeassistant.components.command_line # homeassistant.components.rest From a74328e60061051f14a18c949c9901e824ef9d5e Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Fri, 31 Jan 2025 20:55:42 +1100 Subject: [PATCH 1330/2987] Suppress color_temp warning if color_temp_kelvin is provided (#136884) --- homeassistant/components/lifx/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index 3d37f1c3bc5..8286622e6f3 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -113,7 +113,7 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] | saturation = int(saturation / 100 * 65535) kelvin = 3500 - if _ATTR_COLOR_TEMP in kwargs: + if ATTR_COLOR_TEMP_KELVIN not in kwargs and _ATTR_COLOR_TEMP in kwargs: # added in 2025.1, can be removed in 2026.1 _LOGGER.warning( "The 'color_temp' parameter is deprecated. Please use 'color_temp_kelvin' for" From 9cd48dd452a5cafdc6d427803865a773a164eaf3 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 31 Jan 2025 10:45:01 -0800 Subject: [PATCH 1331/2987] Persist roborock maps to disk only on shutdown (#136889) * Persist roborock maps to disk only on shutdown * Rename on_unload to on_stop * Spawn 1 executor thread and block writes to disk * Update tests/components/roborock/test_image.py Co-authored-by: Joost Lekkerkerker * Use config entry setup instead of component setup --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/roborock/__init__.py | 24 ++++++++++------ .../components/roborock/coordinator.py | 18 ++++++++---- homeassistant/components/roborock/image.py | 10 ++----- .../components/roborock/roborock_storage.py | 20 +++++++++++-- tests/components/roborock/conftest.py | 10 +++++-- tests/components/roborock/test_image.py | 28 +++++++++++-------- tests/components/roborock/test_init.py | 8 ++++++ 7 files changed, 79 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 1b34dc891d1..b383c1acfd7 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -22,7 +22,7 @@ from roborock.version_a01_apis import RoborockMqttClientA01 from roborock.web_api import RoborockApiClient from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -118,13 +118,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> ) valid_coordinators = RoborockCoordinators(v1_coords, a01_coords) - async def on_unload() -> None: - release_tasks = set() - for coordinator in valid_coordinators.values(): - release_tasks.add(coordinator.release()) - await asyncio.gather(*release_tasks) + async def on_stop(_: Any) -> None: + _LOGGER.debug("Shutting down roborock") + await asyncio.gather( + *( + coordinator.async_shutdown() + for coordinator in valid_coordinators.values() + ) + ) - entry.async_on_unload(on_unload) + entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, + on_stop, + ) + ) entry.runtime_data = valid_coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -209,7 +217,7 @@ async def setup_device_v1( try: await coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady as ex: - await coordinator.release() + await coordinator.async_shutdown() if isinstance(coordinator.api, RoborockMqttClientV1): _LOGGER.warning( "Not setting up %s because the we failed to get data for the first time using the online client. " diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 36333f1c55e..8860a5c1f43 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from datetime import timedelta import logging @@ -116,10 +117,14 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # Right now this should never be called if the cloud api is the primary api, # but in the future if it is, a new else should be added. - async def release(self) -> None: - """Disconnect from API.""" - await self.api.async_release() - await self.cloud_api.async_release() + async def async_shutdown(self) -> None: + """Shutdown the coordinator.""" + await super().async_shutdown() + await asyncio.gather( + self.map_storage.flush(), + self.api.async_release(), + self.cloud_api.async_release(), + ) async def _update_device_prop(self) -> None: """Update device properties.""" @@ -226,8 +231,9 @@ class RoborockDataUpdateCoordinatorA01( ) -> dict[RoborockDyadDataProtocol | RoborockZeoProtocol, StateType]: return await self.api.update_values(self.request_protocols) - async def release(self) -> None: - """Disconnect from API.""" + async def async_shutdown(self) -> None: + """Shutdown the coordinator on config entry unload.""" + await super().async_shutdown() await self.api.async_release() @cached_property diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index b0de4f9caa5..b4776c27164 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -157,13 +157,9 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): ) if self.cached_map != content: self.cached_map = content - self.config_entry.async_create_task( - self.hass, - self.coordinator.map_storage.async_save_map( - self.map_flag, - content, - ), - f"{self.unique_id} map", + await self.coordinator.map_storage.async_save_map( + self.map_flag, + content, ) return self.cached_map diff --git a/homeassistant/components/roborock/roborock_storage.py b/homeassistant/components/roborock/roborock_storage.py index 62e15e889be..8a469b0a38e 100644 --- a/homeassistant/components/roborock/roborock_storage.py +++ b/homeassistant/components/roborock/roborock_storage.py @@ -31,6 +31,7 @@ class RoborockMapStorage: self._path_prefix = ( _storage_path_prefix(hass, entry_id) / MAPS_PATH / device_id_slug ) + self._write_queue: dict[int, bytes] = {} async def async_load_map(self, map_flag: int) -> bytes | None: """Load maps from disk.""" @@ -48,9 +49,22 @@ class RoborockMapStorage: return None async def async_save_map(self, map_flag: int, content: bytes) -> None: - """Write map if it should be updated.""" - filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" - await self._hass.async_add_executor_job(self._save_map, filename, content) + """Save the map to a pending write queue.""" + self._write_queue[map_flag] = content + + async def flush(self) -> None: + """Flush all maps to disk.""" + _LOGGER.debug("Flushing %s maps to disk", len(self._write_queue)) + + queue = self._write_queue.copy() + + def _flush_all() -> None: + for map_flag, content in queue.items(): + filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" + self._save_map(filename, content) + + await self._hass.async_add_executor_job(_flush_all) + self._write_queue.clear() def _save_map(self, filename: Path, content: bytes) -> None: """Write the map to disk.""" diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index e5fc5cb7eb6..43e5148c9a8 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -19,9 +19,9 @@ from homeassistant.components.roborock.const import ( CONF_USER_DATA, DOMAIN, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .mock_data import ( BASE_URL, @@ -207,13 +207,13 @@ async def setup_entry( ) -> Generator[MockConfigEntry]: """Set up the Roborock platform.""" with patch("homeassistant.components.roborock.PLATFORMS", platforms): - assert await async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) await hass.async_block_till_done() yield mock_roborock_entry @pytest.fixture -def cleanup_map_storage( +async def cleanup_map_storage( hass: HomeAssistant, mock_roborock_entry: MockConfigEntry ) -> Generator[pathlib.Path]: """Test cleanup, remove any map storage persisted during the test.""" @@ -225,4 +225,8 @@ def cleanup_map_storage( pathlib.Path(hass.config.path(tmp_path)) / mock_roborock_entry.entry_id ) yield storage_path + # We need to first unload the config entry because unloading it will + # persist any unsaved maps to storage. + if mock_roborock_entry.state is ConfigEntryState.LOADED: + await hass.config_entries.async_unload(mock_roborock_entry.entry_id) shutil.rmtree(str(storage_path), ignore_errors=True) diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index 90886f25929..fd6c8b2796a 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -12,6 +12,7 @@ from roborock import RoborockException from vacuum_map_parser_base.map_data import ImageConfig, ImageData from homeassistant.components.roborock import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -120,7 +121,7 @@ async def test_load_stored_image( MAP_DATA.image.data.save(img_byte_arr, format="PNG") img_bytes = img_byte_arr.getvalue() - # Load the image on demand, which should ensure it is cached on disk + # Load the image on demand, which should queue it to be cached on disk client = await hass_client() resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK @@ -151,22 +152,25 @@ async def test_fail_to_save_image( caplog: pytest.LogCaptureFixture, ) -> None: """Test that we gracefully handle a oserror on saving an image.""" - # Reload the config entry so that the map is saved in storage and entities exist. + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Ensure that map is still working properly. + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + # Test that we can get the image and it correctly serialized and unserialized. + assert resp.status == HTTPStatus.OK + with patch( "homeassistant.components.roborock.roborock_storage.Path.write_bytes", side_effect=OSError, ): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await hass.config_entries.async_unload(mock_roborock_entry.entry_id) + assert "Unable to write map file" in caplog.text - # Ensure that map is still working properly. - assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None - client = await hass_client() - resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - # Test that we can get the image and it correctly serialized and unserialized. - assert resp.status == HTTPStatus.OK - - assert "Unable to write map file" in caplog.text + # Config entry is unloaded successfully + assert mock_roborock_entry.state is ConfigEntryState.NOT_LOADED async def test_fail_to_load_image( diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index efd1c3f66f4..904a3af89d6 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -183,6 +183,10 @@ async def test_remove_from_hass( resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK + assert not cleanup_map_storage.exists() + + # Flush to disk + await hass.config_entries.async_unload(setup_entry.entry_id) assert cleanup_map_storage.exists() paths = list(cleanup_map_storage.walk()) assert len(paths) == 3 # One map image and two directories @@ -209,6 +213,10 @@ async def test_oserror_remove_image( resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK + # Image content is saved when unloading + assert not cleanup_map_storage.exists() + await hass.config_entries.async_unload(setup_entry.entry_id) + assert cleanup_map_storage.exists() paths = list(cleanup_map_storage.walk()) assert len(paths) == 3 # One map image and two directories From c9fd27555c477ff1028c8dc5e6e04d815f4bfb7e Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Jan 2025 22:59:03 -0800 Subject: [PATCH 1332/2987] Include the redirect URL in the Google Drive instructions (#136906) * Include the redirect URL in the Google Drive instructions * Apply suggestions from code review Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../google_drive/application_credentials.py | 2 ++ .../components/google_drive/strings.json | 2 +- .../helpers/config_entry_oauth2_flow.py | 26 ++++++++++++------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/google_drive/application_credentials.py b/homeassistant/components/google_drive/application_credentials.py index c2f59b298cb..1c4421623d4 100644 --- a/homeassistant/components/google_drive/application_credentials.py +++ b/homeassistant/components/google_drive/application_credentials.py @@ -2,6 +2,7 @@ from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: @@ -18,4 +19,5 @@ async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, s "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", "more_info_url": "https://www.home-assistant.io/integrations/google_drive/", "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + "redirect_url": config_entry_oauth2_flow.async_get_redirect_uri(hass), } diff --git a/homeassistant/components/google_drive/strings.json b/homeassistant/components/google_drive/strings.json index 3441bec4294..e6658fb08e9 100644 --- a/homeassistant/components/google_drive/strings.json +++ b/homeassistant/components/google_drive/strings.json @@ -35,6 +35,6 @@ } }, "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Drive. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." + "description": "Follow the [instructions]({more_info_url}) to configure the Cloud Console:\n\n1. Go to the [OAuth consent screen]({oauth_consent_url}) and configure\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*." } } diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index c2a61335769..24a9de5b562 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -55,6 +55,21 @@ OAUTH_AUTHORIZE_URL_TIMEOUT_SEC = 30 OAUTH_TOKEN_TIMEOUT_SEC = 30 +@callback +def async_get_redirect_uri(hass: HomeAssistant) -> str: + """Return the redirect uri.""" + if "my" in hass.config.components: + return MY_AUTH_CALLBACK_PATH + + if (req := http.current_request.get()) is None: + raise RuntimeError("No current request in context") + + if (ha_host := req.headers.get(HEADER_FRONTEND_BASE)) is None: + raise RuntimeError("No header in request") + + return f"{ha_host}{AUTH_CALLBACK_PATH}" + + class AbstractOAuth2Implementation(ABC): """Base class to abstract OAuth2 authentication.""" @@ -144,16 +159,7 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): @property def redirect_uri(self) -> str: """Return the redirect uri.""" - if "my" in self.hass.config.components: - return MY_AUTH_CALLBACK_PATH - - if (req := http.current_request.get()) is None: - raise RuntimeError("No current request in context") - - if (ha_host := req.headers.get(HEADER_FRONTEND_BASE)) is None: - raise RuntimeError("No header in request") - - return f"{ha_host}{AUTH_CALLBACK_PATH}" + return async_get_redirect_uri(self.hass) @property def extra_authorize_data(self) -> dict: From a391f0a7cc9ee7d745bac5f35aae42cf414ea3c7 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Jan 2025 23:33:58 -0800 Subject: [PATCH 1333/2987] Bump opower to 0.8.9 (#136911) * Bump opower to 0.8.9 * mypy --- homeassistant/components/opower/coordinator.py | 14 ++++++-------- homeassistant/components/opower/manifest.json | 2 +- homeassistant/components/opower/sensor.py | 8 ++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 13 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index f6f3524d630..6957ae4984c 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -5,18 +5,16 @@ import logging from types import MappingProxyType from typing import Any, cast -import aiohttp from opower import ( Account, AggregateType, - CannotConnect, CostRead, Forecast, - InvalidAuth, MeterType, Opower, ReadResolution, ) +from opower.exceptions import ApiException, CannotConnect, InvalidAuth from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.models import StatisticData, StatisticMetaData @@ -89,7 +87,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): raise UpdateFailed(f"Error during login: {err}") from err try: forecasts: list[Forecast] = await self.api.async_get_forecast() - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting forecasts: %s", err) raise _LOGGER.debug("Updating sensor data with: %s", forecasts) @@ -102,7 +100,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): """Insert Opower statistics.""" try: accounts = await self.api.async_get_accounts() - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting accounts: %s", err) raise for account in accounts: @@ -271,7 +269,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): cost_reads = await self.api.async_get_cost_reads( account, AggregateType.BILL, start, end ) - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting monthly cost reads: %s", err) raise _LOGGER.debug("Got %s monthly cost reads", len(cost_reads)) @@ -290,7 +288,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): daily_cost_reads = await self.api.async_get_cost_reads( account, AggregateType.DAY, start, end ) - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting daily cost reads: %s", err) raise _LOGGER.debug("Got %s daily cost reads", len(daily_cost_reads)) @@ -308,7 +306,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): hourly_cost_reads = await self.api.async_get_cost_reads( account, AggregateType.HOUR, start, end ) - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting hourly cost reads: %s", err) raise _LOGGER.debug("Got %s hourly cost reads", len(hourly_cost_reads)) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 7227f7171ac..d168cba5752 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.8"] + "requirements": ["opower==0.8.9"] } diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 7f8eb22d1e6..f9d0fe62332 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -97,7 +97,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.start_date, + value_fn=lambda data: str(data.start_date), ), OpowerEntityDescription( key="elec_end_date", @@ -105,7 +105,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.end_date, + value_fn=lambda data: str(data.end_date), ), ) GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( @@ -169,7 +169,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.start_date, + value_fn=lambda data: str(data.start_date), ), OpowerEntityDescription( key="gas_end_date", @@ -177,7 +177,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.end_date, + value_fn=lambda data: str(data.end_date), ), ) diff --git a/requirements_all.txt b/requirements_all.txt index 84a2cd7ad2e..2d1d4b1cca9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1592,7 +1592,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.8.8 +opower==0.8.9 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 326670c9f63..d699ae56a97 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1328,7 +1328,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.8.8 +opower==0.8.9 # homeassistant.components.oralb oralb-ble==0.17.6 From 6e55ba137add6061b6ed056aeeb0a24498553ebb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 17:33:30 +0100 Subject: [PATCH 1334/2987] Make backup file names more user friendly (#136928) * Make backup file names more user friendly * Strip backup name * Strip backup name * Underscores --- homeassistant/components/backup/backup.py | 4 +- homeassistant/components/backup/manager.py | 2 +- homeassistant/components/backup/util.py | 9 ++ homeassistant/components/backup/websocket.py | 2 +- tests/components/backup/test_backup.py | 4 +- tests/components/backup/test_manager.py | 139 +++++++++++++++++-- tests/components/backup/test_util.py | 28 ++++ 7 files changed, 171 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index c76b50b5935..b6282186c06 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -14,7 +14,7 @@ from homeassistant.helpers.hassio import is_hassio from .agent import BackupAgent, BackupNotFound, LocalBackupAgent from .const import DOMAIN, LOGGER from .models import AgentBackup -from .util import read_backup +from .util import read_backup, suggested_filename async def async_get_backup_agents( @@ -123,7 +123,7 @@ class CoreLocalBackupAgent(LocalBackupAgent): def get_new_backup_path(self, backup: AgentBackup) -> Path: """Return the local path to a new backup.""" - return self._backup_dir / f"{backup.backup_id}.tar" + return self._backup_dir / suggested_filename(backup) async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: """Delete a backup file.""" diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 1dbd8f8547d..2576eb8d1f0 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -898,7 +898,7 @@ class BackupManager: ) backup_name = ( - name + (name if name is None else name.strip()) or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}" ) extra_metadata = extra_metadata or {} diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index 2416aa5f28e..e9d597aa709 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -20,6 +20,7 @@ from securetar import SecureTarError, SecureTarFile, SecureTarReadError from homeassistant.backup_restore import password_to_key from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonObjectType, json_loads_object from homeassistant.util.thread import ThreadWithException @@ -117,6 +118,14 @@ def read_backup(backup_path: Path) -> AgentBackup: ) +def suggested_filename(backup: AgentBackup) -> str: + """Suggest a filename for the backup.""" + date = dt_util.parse_datetime(backup.date, raise_on_error=True) + return "_".join( + f"{backup.name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split() + ) + + def validate_password(path: Path, password: str | None) -> bool: """Validate the password.""" with tarfile.open(path, "r:", bufsize=BUF_SIZE) as backup_file: diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index feb762bb50b..93dd81c3c14 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -199,7 +199,7 @@ async def handle_can_decrypt_on_download( vol.Optional("include_database", default=True): bool, vol.Optional("include_folders"): [vol.Coerce(Folder)], vol.Optional("include_homeassistant", default=True): bool, - vol.Optional("name"): str, + vol.Optional("name"): vol.Any(str, None), vol.Optional("password"): vol.Any(str, None), } ) diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index ce34c51c105..c441cae292c 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -103,7 +103,9 @@ async def test_upload( assert resp.status == 201 assert open_mock.call_count == 1 assert move_mock.call_count == 1 - assert move_mock.mock_calls[0].args[1].name == "abc123.tar" + assert ( + move_mock.mock_calls[0].args[1].name == "Test_-_1970-01-01_00.00_00000000.tar" + ) @pytest.mark.usefixtures("read_backup") diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 4a8d2360d3f..b98cec47e8d 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -21,6 +21,7 @@ from unittest.mock import ( patch, ) +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.backup import ( @@ -236,6 +237,64 @@ async def test_create_backup_service( "password": None, }, ), + ( + { + "agent_ids": ["backup.local"], + "extra_metadata": {"custom": "data"}, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": "user defined name", + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "user defined name", + "extra_metadata": { + "custom": "data", + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ( + { + "agent_ids": ["backup.local"], + "extra_metadata": {"custom": "data"}, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": " ", # Name which is just whitespace + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "Custom backup 2025.1.0", + "extra_metadata": { + "custom": "data", + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), ], ) async def test_async_create_backup( @@ -345,18 +404,70 @@ async def test_create_backup_wrong_parameters( @pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.parametrize( - ("agent_ids", "backup_directory", "temp_file_unlink_call_count"), + ( + "agent_ids", + "backup_directory", + "name", + "expected_name", + "expected_filename", + "temp_file_unlink_call_count", + ), [ - ([LOCAL_AGENT_ID], "backups", 0), - (["test.remote"], "tmp_backups", 1), - ([LOCAL_AGENT_ID, "test.remote"], "backups", 0), + ( + [LOCAL_AGENT_ID], + "backups", + None, + "Custom backup 2025.1.0", + "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + 0, + ), + ( + ["test.remote"], + "tmp_backups", + None, + "Custom backup 2025.1.0", + "abc123.tar", # We don't use friendly name for temporary backups + 1, + ), + ( + [LOCAL_AGENT_ID, "test.remote"], + "backups", + None, + "Custom backup 2025.1.0", + "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + 0, + ), + ( + [LOCAL_AGENT_ID], + "backups", + "custom_name", + "custom_name", + "custom_name_-_2025-01-30_05.42_12345678.tar", + 0, + ), + ( + ["test.remote"], + "tmp_backups", + "custom_name", + "custom_name", + "abc123.tar", # We don't use friendly name for temporary backups + 1, + ), + ( + [LOCAL_AGENT_ID, "test.remote"], + "backups", + "custom_name", + "custom_name", + "custom_name_-_2025-01-30_05.42_12345678.tar", + 0, + ), ], ) @pytest.mark.parametrize( "params", [ {}, - {"include_database": True, "name": "abc123"}, + {"include_database": True}, {"include_database": False}, {"password": "pass123"}, ], @@ -364,6 +475,7 @@ async def test_create_backup_wrong_parameters( async def test_initiate_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, mocked_json_bytes: Mock, mocked_tarfile: Mock, generate_backup_id: MagicMock, @@ -371,6 +483,9 @@ async def test_initiate_backup( params: dict[str, Any], agent_ids: list[str], backup_directory: str, + name: str | None, + expected_name: str, + expected_filename: str, temp_file_unlink_call_count: int, ) -> None: """Test generate backup.""" @@ -393,9 +508,9 @@ async def test_initiate_backup( ) ws_client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") include_database = params.get("include_database", True) - name = params.get("name", "Custom backup 2025.1.0") password = params.get("password") path_glob.return_value = [] @@ -427,7 +542,7 @@ async def test_initiate_backup( patch("pathlib.Path.unlink") as unlink_mock, ): await ws_client.send_json_auto_id( - {"type": "backup/generate", "agent_ids": agent_ids} | params + {"type": "backup/generate", "agent_ids": agent_ids, "name": name} | params ) result = await ws_client.receive_json() assert result["event"] == { @@ -487,7 +602,7 @@ async def test_initiate_backup( "exclude_database": not include_database, "version": "2025.1.0", }, - "name": name, + "name": expected_name, "protected": bool(password), "slug": backup_id, "type": "partial", @@ -514,7 +629,7 @@ async def test_initiate_backup( "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.1.0", - "name": name, + "name": expected_name, "with_automatic_settings": False, } @@ -528,7 +643,7 @@ async def test_initiate_backup( tar_file_path = str(mocked_tarfile.call_args_list[0][0][0]) backup_directory = hass.config.path(backup_directory) - assert tar_file_path == f"{backup_directory}/{backup_id}.tar" + assert tar_file_path == f"{backup_directory}/{expected_filename}" @pytest.mark.usefixtures("mock_backup_generation") @@ -1482,7 +1597,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local&agent_id=test.remote", 2, 1, - ["abc123.tar"], + ["Test_-_1970-01-01_00.00_00000000.tar"], {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, b"test", 0, @@ -1491,7 +1606,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local", 1, 1, - ["abc123.tar"], + ["Test_-_1970-01-01_00.00_00000000.tar"], {}, None, 0, diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index db759805c8f..3bcb53f7c86 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -15,6 +15,7 @@ from homeassistant.components.backup.util import ( DecryptedBackupStreamer, EncryptedBackupStreamer, read_backup, + suggested_filename, validate_password, ) from homeassistant.core import HomeAssistant @@ -384,3 +385,30 @@ async def test_encrypted_backup_streamer_error(hass: HomeAssistant) -> None: # padding. await encryptor.wait() assert isinstance(encryptor._workers[0].error, tarfile.TarError) + + +@pytest.mark.parametrize( + ("name", "resulting_filename"), + [ + ("test", "test_-_2025-01-30_13.42_12345678.tar"), + (" leading spaces", "leading_spaces_-_2025-01-30_13.42_12345678.tar"), + ("trailing spaces ", "trailing_spaces_-_2025-01-30_13.42_12345678.tar"), + ("double spaces ", "double_spaces_-_2025-01-30_13.42_12345678.tar"), + ], +) +def test_suggested_filename(name: str, resulting_filename: str) -> None: + """Test suggesting a filename.""" + backup = AgentBackup( + addons=[], + backup_id="1234", + date="2025-01-30 13:42:12.345678-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name=name, + protected=False, + size=1234, + ) + assert suggested_filename(backup) == resulting_filename From eca30717a95f3719c889f8272dd5f94542ad47d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 12:55:14 -0600 Subject: [PATCH 1335/2987] Bump zeroconf to 0.142.0 (#136940) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.141.0...0.142.0 --- 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 6fe2b5b1923..be6f2d111d7 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.141.0"] + "requirements": ["zeroconf==0.142.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 01cfc57f3a8..a15e1bb61be 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.141.0 +zeroconf==0.142.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 2e7b2dfcbc1..fb8545681e8 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.141.0" + "zeroconf==0.142.0" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index a98d53b6037..412252a0846 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.141.0 +zeroconf==0.142.0 diff --git a/requirements_all.txt b/requirements_all.txt index 2d1d4b1cca9..00702b6914f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3125,7 +3125,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.141.0 +zeroconf==0.142.0 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d699ae56a97..325c01b0708 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2514,7 +2514,7 @@ yt-dlp[default]==2025.01.26 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.141.0 +zeroconf==0.142.0 # homeassistant.components.zeversolar zeversolar==0.3.2 From eb344ba3359f059f1743ff2787679bfe7aaa2e98 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 13:38:27 -0600 Subject: [PATCH 1336/2987] Bump aiohttp-asyncmdnsresolver to 0.0.2 (#136942) --- 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 a15e1bb61be..891d91e134b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.2b6 -aiohttp-asyncmdnsresolver==0.0.1 +aiohttp-asyncmdnsresolver==0.0.2 aiohttp-fast-zlib==0.2.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index fb8545681e8..e3bee8e6608 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "aiohttp==3.11.11", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", - "aiohttp-asyncmdnsresolver==0.0.1", + "aiohttp-asyncmdnsresolver==0.0.2", "aiozoneinfo==0.2.1", "astral==2.2", "async-interrupt==1.2.0", diff --git a/requirements.txt b/requirements.txt index 412252a0846..77fd3887db4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ aiohasupervisor==0.2.2b6 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 -aiohttp-asyncmdnsresolver==0.0.1 +aiohttp-asyncmdnsresolver==0.0.2 aiozoneinfo==0.2.1 astral==2.2 async-interrupt==1.2.0 From 71a40d9234d98fa6487290de5ee42d22b5282146 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 30 Jan 2025 21:59:00 +0100 Subject: [PATCH 1337/2987] Update knx-frontend to 2025.1.30.194235 (#136954) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index f34ce0f4589..86c050443e3 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "requirements": [ "xknx==3.5.0", "xknxproject==3.8.1", - "knx-frontend==2025.1.28.225404" + "knx-frontend==2025.1.30.194235" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 00702b6914f..348c6e81aa4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1272,7 +1272,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.28.225404 +knx-frontend==2025.1.30.194235 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 325c01b0708..d90fa84e2a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1074,7 +1074,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.28.225404 +knx-frontend==2025.1.30.194235 # homeassistant.components.konnected konnected==1.2.0 From ad86f9efd5ea15ecf61462ef0965f411494e31b4 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 30 Jan 2025 16:01:24 -0600 Subject: [PATCH 1338/2987] Consume extra system prompt in first pipeline (#136958) --- homeassistant/components/assist_satellite/entity.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 927229c9756..0229e0358b1 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -264,7 +264,6 @@ class AssistSatelliteEntity(entity.Entity): await self.async_start_conversation(announcement) finally: self._is_announcing = False - self._extra_system_prompt = None async def async_start_conversation( self, start_announcement: AssistSatelliteAnnouncement @@ -282,6 +281,10 @@ class AssistSatelliteEntity(entity.Entity): """Triggers an Assist pipeline in Home Assistant from a satellite.""" await self._cancel_running_pipeline() + # Consume system prompt in first pipeline + extra_system_prompt = self._extra_system_prompt + self._extra_system_prompt = None + if self._wake_word_intercept_future and start_stage in ( PipelineStage.WAKE_WORD, PipelineStage.STT, @@ -358,7 +361,7 @@ class AssistSatelliteEntity(entity.Entity): ), start_stage=start_stage, end_stage=end_stage, - conversation_extra_system_prompt=self._extra_system_prompt, + conversation_extra_system_prompt=extra_system_prompt, ), f"{self.entity_id}_pipeline", ) From c77bca1e4417faea5914d02f54a8600906f914c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 01:34:39 -0600 Subject: [PATCH 1339/2987] Bump habluetooth to 3.15.0 (#136973) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 1fcd507da83..38677400418 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.14.0" + "habluetooth==3.15.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 891d91e134b..64353901fbf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.14.0 +habluetooth==3.15.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 348c6e81aa4..b63d203b0e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.14.0 +habluetooth==3.15.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d90fa84e2a3..573ed230cb5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -941,7 +941,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.14.0 +habluetooth==3.15.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 From 26ae498974e018b18dcb8db6f0b29eb5e4059d74 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 19:33:48 +0100 Subject: [PATCH 1340/2987] Delete old addon update backups when updating addon (#136977) * Delete old addon update backups when updating addon * Address review comments * Add tests --- homeassistant/components/backup/config.py | 77 ++-------- homeassistant/components/backup/manager.py | 64 +++++++++ homeassistant/components/hassio/backup.py | 23 ++- tests/components/hassio/test_update.py | 129 ++++++++++++++++- tests/components/hassio/test_websocket_api.py | 133 +++++++++++++++++- 5 files changed, 350 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 0baefe1f52d..4d0cd82bc44 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -2,8 +2,6 @@ from __future__ import annotations -import asyncio -from collections.abc import Callable from dataclasses import dataclass, field, replace import datetime as dt from datetime import datetime, timedelta @@ -252,7 +250,7 @@ class RetentionConfig: """Delete backups older than days.""" self._schedule_next(manager) - def _backups_filter( + def _delete_filter( backups: dict[str, ManagerBackup], ) -> dict[str, ManagerBackup]: """Return backups older than days to delete.""" @@ -269,7 +267,9 @@ class RetentionConfig: < now } - await _delete_filtered_backups(manager, _backups_filter) + await manager.async_delete_filtered_backups( + include_filter=_automatic_backups_filter, delete_filter=_delete_filter + ) manager.remove_next_delete_event = async_call_later( manager.hass, timedelta(days=1), _delete_backups @@ -521,74 +521,21 @@ class CreateBackupParametersDict(TypedDict, total=False): password: str | None -async def _delete_filtered_backups( - manager: BackupManager, - backup_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]], -) -> None: - """Delete backups parsed with a filter. - - :param manager: The backup manager. - :param backup_filter: A filter that should return the backups to delete. - """ - backups, get_agent_errors = await manager.async_get_backups() - if get_agent_errors: - LOGGER.debug( - "Error getting backups; continuing anyway: %s", - get_agent_errors, - ) - - # only delete backups that are created with the saved automatic settings - backups = { +def _automatic_backups_filter( + backups: dict[str, ManagerBackup], +) -> dict[str, ManagerBackup]: + """Return automatic backups.""" + return { backup_id: backup for backup_id, backup in backups.items() if backup.with_automatic_settings } - LOGGER.debug("Total automatic backups: %s", backups) - - filtered_backups = backup_filter(backups) - - if not filtered_backups: - return - - # always delete oldest backup first - filtered_backups = dict( - sorted( - filtered_backups.items(), - key=lambda backup_item: backup_item[1].date, - ) - ) - - if len(filtered_backups) >= len(backups): - # Never delete the last backup. - last_backup = filtered_backups.popitem() - LOGGER.debug("Keeping the last backup: %s", last_backup) - - LOGGER.debug("Backups to delete: %s", filtered_backups) - - if not filtered_backups: - return - - backup_ids = list(filtered_backups) - delete_results = await asyncio.gather( - *(manager.async_delete_backup(backup_id) for backup_id in filtered_backups) - ) - agent_errors = { - backup_id: error - for backup_id, error in zip(backup_ids, delete_results, strict=True) - if error - } - if agent_errors: - LOGGER.error( - "Error deleting old copies: %s", - agent_errors, - ) - async def delete_backups_exceeding_configured_count(manager: BackupManager) -> None: """Delete backups exceeding the configured retention count.""" - def _backups_filter( + def _delete_filter( backups: dict[str, ManagerBackup], ) -> dict[str, ManagerBackup]: """Return oldest backups more numerous than copies to delete.""" @@ -603,4 +550,6 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N )[: max(len(backups) - manager.config.data.retention.copies, 0)] ) - await _delete_filtered_backups(manager, _backups_filter) + await manager.async_delete_filtered_backups( + include_filter=_automatic_backups_filter, delete_filter=_delete_filter + ) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 2576eb8d1f0..42b5f522ecd 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -685,6 +685,70 @@ class BackupManager: return agent_errors + async def async_delete_filtered_backups( + self, + *, + include_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]], + delete_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]], + ) -> None: + """Delete backups parsed with a filter. + + :param include_filter: A filter that should return the backups to consider for + deletion. Note: The newest of the backups returned by include_filter will + unconditionally be kept, even if delete_filter returns all backups. + :param delete_filter: A filter that should return the backups to delete. + """ + backups, get_agent_errors = await self.async_get_backups() + if get_agent_errors: + LOGGER.debug( + "Error getting backups; continuing anyway: %s", + get_agent_errors, + ) + + # Run the include filter first to ensure we only consider backups that + # should be included in the deletion process. + backups = include_filter(backups) + + LOGGER.debug("Total automatic backups: %s", backups) + + backups_to_delete = delete_filter(backups) + + if not backups_to_delete: + return + + # always delete oldest backup first + backups_to_delete = dict( + sorted( + backups_to_delete.items(), + key=lambda backup_item: backup_item[1].date, + ) + ) + + if len(backups_to_delete) >= len(backups): + # Never delete the last backup. + last_backup = backups_to_delete.popitem() + LOGGER.debug("Keeping the last backup: %s", last_backup) + + LOGGER.debug("Backups to delete: %s", backups_to_delete) + + if not backups_to_delete: + return + + backup_ids = list(backups_to_delete) + delete_results = await asyncio.gather( + *(self.async_delete_backup(backup_id) for backup_id in backups_to_delete) + ) + agent_errors = { + backup_id: error + for backup_id, error in zip(backup_ids, delete_results, strict=True) + if error + } + if agent_errors: + LOGGER.error( + "Error deleting old copies: %s", + agent_errors, + ) + async def async_receive_backup( self, *, diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index b9439183d8c..59242a32708 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -33,6 +33,7 @@ from homeassistant.components.backup import ( Folder, IdleEvent, IncorrectPasswordError, + ManagerBackup, NewBackup, RestoreBackupEvent, RestoreBackupState, @@ -51,6 +52,8 @@ LOCATION_CLOUD_BACKUP = ".cloud_backup" LOCATION_LOCAL = ".local" MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID" +# Set on backups automatically created when updating an addon +TAG_ADDON_UPDATE = "supervisor.addon_update" _LOGGER = logging.getLogger(__name__) @@ -614,10 +617,20 @@ async def backup_addon_before_update( else: password = None + def addon_update_backup_filter( + backups: dict[str, ManagerBackup], + ) -> dict[str, ManagerBackup]: + """Return addon update backups.""" + return { + backup_id: backup + for backup_id, backup in backups.items() + if backup.extra_metadata.get(TAG_ADDON_UPDATE) == addon + } + try: await backup_manager.async_create_backup( agent_ids=[await _default_agent(client)], - extra_metadata={"supervisor.addon_update": addon}, + extra_metadata={TAG_ADDON_UPDATE: addon}, include_addons=[addon], include_all_addons=False, include_database=False, @@ -628,6 +641,14 @@ async def backup_addon_before_update( ) except BackupManagerError as err: raise HomeAssistantError(f"Error creating backup: {err}") from err + else: + try: + await backup_manager.async_delete_filtered_backups( + include_filter=addon_update_backup_filter, + delete_filter=lambda backups: backups, + ) + except BackupManagerError as err: + raise HomeAssistantError(f"Error deleting old backups: {err}") from err async def backup_core_before_update(hass: HomeAssistant) -> None: diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 62fe49c5f23..332f2050cf2 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -3,13 +3,13 @@ from datetime import timedelta import os from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohasupervisor import SupervisorBadRequestError, SupervisorError from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest -from homeassistant.components.backup import BackupManagerError +from homeassistant.components.backup import BackupManagerError, ManagerBackup from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.const import __version__ as HAVERSION @@ -338,6 +338,113 @@ async def test_update_addon_with_backup( update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) +@pytest.mark.parametrize( + ("backups", "removed_backups"), + [ + ( + {}, + [], + ), + ( + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-6": MagicMock( + date="2024-11-12T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + }, + ["backup-5"], + ), + ], +) +async def test_update_addon_with_backup_removes_old_backups( + hass: HomeAssistant, + supervisor_client: AsyncMock, + update_addon: AsyncMock, + backups: dict[str, ManagerBackup], + removed_backups: list[str], +) -> None: + """Test updating addon update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_backup", + autospec=True, + return_value={}, + ) as async_delete_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ), + ): + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update", "backup": True}, + blocking=True, + ) + mock_create_backup.assert_called_once_with( + agent_ids=["hassio.local"], + extra_metadata={"supervisor.addon_update": "test"}, + include_addons=["test"], + include_all_addons=False, + include_database=False, + include_folders=None, + include_homeassistant=False, + name="test 2.0.0", + password=None, + ) + assert len(async_delete_backup.mock_calls) == len(removed_backups) + for call in async_delete_backup.mock_calls: + assert call.args[1] in removed_backups + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> None: """Test updating OS update entity.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) @@ -550,9 +657,19 @@ async def test_update_addon_with_error( ) +@pytest.mark.parametrize( + ("create_backup_error", "delete_filtered_backups_error", "message"), + [ + (BackupManagerError, None, r"^Error creating backup: "), + (None, BackupManagerError, r"^Error deleting old backups: "), + ], +) async def test_update_addon_with_backup_and_error( hass: HomeAssistant, supervisor_client: AsyncMock, + create_backup_error: Exception | None, + delete_filtered_backups_error: Exception | None, + message: str, ) -> None: """Test updating addon update entity with error.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) @@ -573,9 +690,13 @@ async def test_update_addon_with_backup_and_error( with ( patch( "homeassistant.components.backup.manager.BackupManager.async_create_backup", - side_effect=BackupManagerError, + side_effect=create_backup_error, ), - pytest.raises(HomeAssistantError, match=r"^Error creating backup:"), + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_filtered_backups", + side_effect=delete_filtered_backups_error, + ), + pytest.raises(HomeAssistantError, match=message), ): assert not await hass.services.async_call( "update", diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index ab8dc1475e2..bcac19e0fa3 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -2,13 +2,13 @@ import os from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohasupervisor import SupervisorError from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest -from homeassistant.components.backup import BackupManagerError +from homeassistant.components.backup import BackupManagerError, ManagerBackup from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import ( ATTR_DATA, @@ -457,6 +457,114 @@ async def test_update_addon_with_backup( update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) +@pytest.mark.parametrize( + ("backups", "removed_backups"), + [ + ( + {}, + [], + ), + ( + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-6": MagicMock( + date="2024-11-12T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + }, + ["backup-5"], + ), + ], +) +async def test_update_addon_with_backup_removes_old_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + update_addon: AsyncMock, + backups: dict[str, ManagerBackup], + removed_backups: list[str], +) -> None: + """Test updating addon update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_backup", + autospec=True, + return_value={}, + ) as async_delete_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ), + ): + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": True} + ) + result = await client.receive_json() + assert result["success"] + mock_create_backup.assert_called_once_with( + agent_ids=["hassio.local"], + extra_metadata={"supervisor.addon_update": "test"}, + include_addons=["test"], + include_all_addons=False, + include_database=False, + include_folders=None, + include_homeassistant=False, + name="test 2.0.0", + password=None, + ) + assert len(async_delete_backup.mock_calls) == len(removed_backups) + for call in async_delete_backup.mock_calls: + assert call.args[1] in removed_backups + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + async def test_update_core( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -622,10 +730,20 @@ async def test_update_addon_with_error( } +@pytest.mark.parametrize( + ("create_backup_error", "delete_filtered_backups_error", "message"), + [ + (BackupManagerError, None, "Error creating backup: "), + (None, BackupManagerError, "Error deleting old backups: "), + ], +) async def test_update_addon_with_backup_and_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + create_backup_error: Exception | None, + delete_filtered_backups_error: Exception | None, + message: str, ) -> None: """Test updating addon with backup and error.""" client = await hass_ws_client(hass) @@ -647,7 +765,11 @@ async def test_update_addon_with_backup_and_error( with ( patch( "homeassistant.components.backup.manager.BackupManager.async_create_backup", - side_effect=BackupManagerError, + side_effect=create_backup_error, + ), + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_filtered_backups", + side_effect=delete_filtered_backups_error, ), ): await client.send_json_auto_id( @@ -655,10 +777,7 @@ async def test_update_addon_with_backup_and_error( ) result = await client.receive_json() assert not result["success"] - assert result["error"] == { - "code": "home_assistant_error", - "message": "Error creating backup: ", - } + assert result["error"] == {"code": "home_assistant_error", "message": message} async def test_update_core_with_error( From 0272d37e88a852a7da1420907e440338f9cb5d68 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 11:23:33 +0100 Subject: [PATCH 1341/2987] Retry backup uploads in onedrive (#136980) * Retry backup uploads in onedrive * no exponential backup on timeout --- homeassistant/components/onedrive/backup.py | 34 ++++- tests/components/onedrive/conftest.py | 7 + tests/components/onedrive/test_backup.py | 138 +++++++++++++++++++- 3 files changed, 171 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 94d60bc6398..7f4bd5a0738 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps import html @@ -9,7 +10,7 @@ import json import logging from typing import Any, Concatenate, cast -from httpx import Response +from httpx import Response, TimeoutException from kiota_abstractions.api_error import APIError from kiota_abstractions.authentication import AnonymousAuthenticationProvider from kiota_abstractions.headers_collection import HeadersCollection @@ -42,6 +43,7 @@ from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN _LOGGER = logging.getLogger(__name__) UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB +MAX_RETRIES = 5 async def async_get_backup_agents( @@ -96,7 +98,7 @@ def handle_backup_errors[_R, **P]( ) _LOGGER.debug("Full error: %s", err, exc_info=True) raise BackupAgentError("Backup operation failed") from err - except TimeoutError as err: + except TimeoutException as err: _LOGGER.error( "Error during backup in %s: Timeout", func.__name__, @@ -268,6 +270,7 @@ class OneDriveBackupAgent(BackupAgent): start = 0 buffer: list[bytes] = [] buffer_size = 0 + retries = 0 async for chunk in stream: buffer.append(chunk) @@ -279,11 +282,28 @@ class OneDriveBackupAgent(BackupAgent): buffer_size > UPLOAD_CHUNK_SIZE ): # Loop in case the buffer is >= UPLOAD_CHUNK_SIZE * 2 slice_start = uploaded_chunks * UPLOAD_CHUNK_SIZE - await async_upload( - start, - start + UPLOAD_CHUNK_SIZE - 1, - chunk_data[slice_start : slice_start + UPLOAD_CHUNK_SIZE], - ) + try: + await async_upload( + start, + start + UPLOAD_CHUNK_SIZE - 1, + chunk_data[slice_start : slice_start + UPLOAD_CHUNK_SIZE], + ) + except APIError as err: + if ( + err.response_status_code and err.response_status_code < 500 + ): # no retry on 4xx errors + raise + if retries < MAX_RETRIES: + await asyncio.sleep(2**retries) + retries += 1 + continue + raise + except TimeoutException: + if retries < MAX_RETRIES: + retries += 1 + continue + raise + retries = 0 start += UPLOAD_CHUNK_SIZE uploaded_chunks += 1 buffer_size -= UPLOAD_CHUNK_SIZE diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 65142217017..649966a7828 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -176,3 +176,10 @@ def mock_instance_id() -> Generator[AsyncMock]: return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", ): yield + + +@pytest.fixture(autouse=True) +def mock_asyncio_sleep() -> Generator[AsyncMock]: + """Mock asyncio.sleep.""" + with patch("homeassistant.components.onedrive.backup.asyncio.sleep", AsyncMock()): + yield diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 3492202d3fe..162ecb7d92a 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -8,8 +8,10 @@ from io import StringIO from json import dumps from unittest.mock import Mock, patch +from httpx import TimeoutException from kiota_abstractions.api_error import APIError from msgraph.generated.models.drive_item import DriveItem +from msgraph_core.models import LargeFileUploadSession import pytest from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup @@ -255,6 +257,140 @@ async def test_broken_upload_session( assert "Failed to start backup upload" in caplog.text +@pytest.mark.parametrize( + "side_effect", + [ + APIError(response_status_code=500), + TimeoutException("Timeout"), + ], +) +async def test_agents_upload_errors_retried( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, + side_effect: Exception, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_adapter.send_async.side_effect = [ + side_effect, + LargeFileUploadSession(next_expected_ranges=["2-"]), + LargeFileUploadSession(next_expected_ranges=["2-"]), + ] + + 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, + patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert mock_adapter.send_async.call_count == 3 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + mock_drive_items.patch.assert_called_once() + + +async def test_agents_upload_4xx_errors_not_retried( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_adapter.send_async.side_effect = APIError(response_status_code=404) + + 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, + patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert mock_adapter.send_async.call_count == 1 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + assert mock_drive_items.patch.call_count == 0 + assert "Backup operation failed" in caplog.text + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (APIError(response_status_code=500), "Backup operation failed"), + (TimeoutException("Timeout"), "Backup operation timed out"), + ], +) +async def test_agents_upload_fails_after_max_retries( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, + side_effect: Exception, + error: str, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_adapter.send_async.side_effect = side_effect + + 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, + patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert mock_adapter.send_async.call_count == 6 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + assert mock_drive_items.patch.call_count == 0 + assert error in caplog.text + + async def test_agents_download( hass_client: ClientSessionGenerator, mock_drive_items: MagicMock, @@ -282,7 +418,7 @@ async def test_agents_download( APIError(response_status_code=500), "Backup operation failed", ), - (TimeoutError(), "Backup operation timed out"), + (TimeoutException("Timeout"), "Backup operation timed out"), ], ) async def test_delete_error( From 6bab5b2c320737b35ca22a3f268a3c00052ad55b Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Fri, 31 Jan 2025 12:10:44 +0100 Subject: [PATCH 1342/2987] Fix missing duration translation for Swiss public transport integration (#136982) --- .../swiss_public_transport/icons.json | 2 +- .../swiss_public_transport/sensor.py | 2 + .../swiss_public_transport/strings.json | 4 +- .../snapshots/test_sensor.ambr | 101 +++++++++--------- .../swiss_public_transport/test_sensor.py | 2 +- 5 files changed, 58 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/icons.json b/homeassistant/components/swiss_public_transport/icons.json index 06a640a06b2..45cf4713705 100644 --- a/homeassistant/components/swiss_public_transport/icons.json +++ b/homeassistant/components/swiss_public_transport/icons.json @@ -10,7 +10,7 @@ "departure2": { "default": "mdi:bus-clock" }, - "duration": { + "trip_duration": { "default": "mdi:timeline-clock" }, "transfers": { diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index a0131938a37..c8075a6746c 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -56,8 +56,10 @@ SENSORS: tuple[SwissPublicTransportSensorEntityDescription, ...] = ( ], SwissPublicTransportSensorEntityDescription( key="duration", + translation_key="trip_duration", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, value_fn=lambda data_connection: data_connection["duration"], ), SwissPublicTransportSensorEntityDescription( diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index ef8cc5595e3..270cb097e0a 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -64,8 +64,8 @@ "departure2": { "name": "Departure +2" }, - "duration": { - "name": "Duration" + "trip_duration": { + "name": "Trip duration" }, "transfers": { "name": "Transfers" diff --git a/tests/components/swiss_public_transport/snapshots/test_sensor.ambr b/tests/components/swiss_public_transport/snapshots/test_sensor.ambr index dbd689fc8f6..b8ad82c7b79 100644 --- a/tests/components/swiss_public_transport/snapshots/test_sensor.ambr +++ b/tests/components/swiss_public_transport/snapshots/test_sensor.ambr @@ -192,55 +192,6 @@ 'state': '2024-01-06T17:05:00+00:00', }) # --- -# name: test_all_entities[sensor.zurich_bern_duration-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.zurich_bern_duration', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Duration', - 'platform': 'swiss_public_transport', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'Zürich Bern_duration', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.zurich_bern_duration-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by transport.opendata.ch', - 'device_class': 'duration', - 'friendly_name': 'Zürich Bern Duration', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.zurich_bern_duration', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '10', - }) -# --- # name: test_all_entities[sensor.zurich_bern_line-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -382,3 +333,55 @@ 'state': '0', }) # --- +# name: test_all_entities[sensor.zurich_bern_trip_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zurich_bern_trip_duration', + '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': 'Trip duration', + 'platform': 'swiss_public_transport', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'trip_duration', + 'unique_id': 'Zürich Bern_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.zurich_bern_trip_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by transport.opendata.ch', + 'device_class': 'duration', + 'friendly_name': 'Zürich Bern Trip duration', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zurich_bern_trip_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.003', + }) +# --- diff --git a/tests/components/swiss_public_transport/test_sensor.py b/tests/components/swiss_public_transport/test_sensor.py index 4afdd88c9de..6e832728277 100644 --- a/tests/components/swiss_public_transport/test_sensor.py +++ b/tests/components/swiss_public_transport/test_sensor.py @@ -83,7 +83,7 @@ async def test_fetching_data( hass.states.get("sensor.zurich_bern_departure_2").state == "2024-01-06T17:05:00+00:00" ) - assert hass.states.get("sensor.zurich_bern_duration").state == "10" + assert hass.states.get("sensor.zurich_bern_trip_duration").state == "0.003" assert hass.states.get("sensor.zurich_bern_platform").state == "0" assert hass.states.get("sensor.zurich_bern_transfers").state == "0" assert hass.states.get("sensor.zurich_bern_delay").state == "0" From 00298db465eef687dc14f728e1cd157a15096aeb Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 13:39:59 +0100 Subject: [PATCH 1343/2987] Call backup listener during setup in onedrive (#136990) --- homeassistant/components/onedrive/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 7419ca6e20c..4ae5ac73560 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -97,6 +97,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> backup_folder_id=backup_folder_id, ) + _async_notify_backup_listeners_soon(hass) + return True From c28d465f3b8d8c4296e5131f0418e7ef321c8f8b Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 31 Jan 2025 17:49:25 +0100 Subject: [PATCH 1344/2987] Bumb python-homewizard-energy to 8.3.2 (#136995) --- homeassistant/components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 957ed912b7d..51a315b2286 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==v8.3.0"], + "requirements": ["python-homewizard-energy==v8.3.2"], "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index b63d203b0e0..50994859d2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2391,7 +2391,7 @@ python-google-drive-api==0.0.2 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.3.0 +python-homewizard-energy==v8.3.2 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 573ed230cb5..8a2b74c5ce9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1936,7 +1936,7 @@ python-google-drive-api==0.0.2 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.3.0 +python-homewizard-energy==v8.3.2 # homeassistant.components.izone python-izone==1.2.9 From 07b85163d522d3b69d2a4b37db91d430aac104c9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 31 Jan 2025 16:32:11 +0100 Subject: [PATCH 1345/2987] Use device name as entity name in Eheim digital climate (#136997) --- .../components/eheimdigital/climate.py | 1 + .../eheimdigital/snapshots/test_climate.ambr | 20 +++++++++---------- tests/components/eheimdigital/test_climate.py | 16 +++++++-------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py index 9b1f825dece..7ad06659089 100644 --- a/homeassistant/components/eheimdigital/climate.py +++ b/homeassistant/components/eheimdigital/climate.py @@ -76,6 +76,7 @@ class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateE _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_preset_mode = PRESET_NONE _attr_translation_key = "heater" + _attr_name = None def __init__( self, coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalHeater diff --git a/tests/components/eheimdigital/snapshots/test_climate.ambr b/tests/components/eheimdigital/snapshots/test_climate.ambr index d81c59e5af1..171d3d427fc 100644 --- a/tests/components/eheimdigital/snapshots/test_climate.ambr +++ b/tests/components/eheimdigital/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_dynamic_new_devices[climate.mock_heater_none-entry] +# name: test_dynamic_new_devices[climate.mock_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24,7 +24,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -45,11 +45,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_dynamic_new_devices[climate.mock_heater_none-state] +# name: test_dynamic_new_devices[climate.mock_heater-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 24.2, - 'friendly_name': 'Mock Heater None', + 'friendly_name': 'Mock Heater', 'hvac_action': , 'hvac_modes': list([ , @@ -68,14 +68,14 @@ 'temperature': 25.5, }), 'context': , - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'auto', }) # --- -# name: test_setup_heater[climate.mock_heater_none-entry] +# name: test_setup_heater[climate.mock_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -100,7 +100,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -121,11 +121,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup_heater[climate.mock_heater_none-state] +# name: test_setup_heater[climate.mock_heater-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 24.2, - 'friendly_name': 'Mock Heater None', + 'friendly_name': 'Mock Heater', 'hvac_action': , 'hvac_modes': list([ , @@ -144,7 +144,7 @@ 'temperature': 25.5, }), 'context': , - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py index f64b7d7e740..f1f29ce9d34 100644 --- a/tests/components/eheimdigital/test_climate.py +++ b/tests/components/eheimdigital/test_climate.py @@ -123,7 +123,7 @@ async def test_set_preset_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_PRESET_MODE: preset_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_PRESET_MODE: preset_mode}, blocking=True, ) @@ -132,7 +132,7 @@ async def test_set_preset_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_PRESET_MODE: preset_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_PRESET_MODE: preset_mode}, blocking=True, ) @@ -161,7 +161,7 @@ async def test_set_temperature( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_TEMPERATURE: 26.0}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_TEMPERATURE: 26.0}, blocking=True, ) @@ -170,7 +170,7 @@ async def test_set_temperature( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_TEMPERATURE: 26.0}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_TEMPERATURE: 26.0}, blocking=True, ) @@ -204,7 +204,7 @@ async def test_set_hvac_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_HVAC_MODE: hvac_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_HVAC_MODE: hvac_mode}, blocking=True, ) @@ -213,7 +213,7 @@ async def test_set_hvac_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_HVAC_MODE: hvac_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_HVAC_MODE: hvac_mode}, blocking=True, ) @@ -239,7 +239,7 @@ async def test_state_update( ) await hass.async_block_till_done() - assert (state := hass.states.get("climate.mock_heater_none")) + assert (state := hass.states.get("climate.mock_heater")) assert state.attributes["hvac_action"] == HVACAction.IDLE assert state.attributes["preset_mode"] == HEATER_BIO_MODE @@ -249,6 +249,6 @@ async def test_state_update( await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() - assert (state := hass.states.get("climate.mock_heater_none")) + assert (state := hass.states.get("climate.mock_heater")) assert state.state == HVACMode.OFF assert state.attributes["preset_mode"] == HEATER_SMART_MODE From 3107b813337cbe717f873b7eb292d92f368d4d3a Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Fri, 31 Jan 2025 13:19:04 +0100 Subject: [PATCH 1346/2987] Remove the unparsed config flow error from Swiss public transport (#136998) --- homeassistant/components/swiss_public_transport/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index 270cb097e0a..64817f89f42 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -28,7 +28,7 @@ "time_station": "Usually the departure time of a connection when it leaves the start station is tracked. Alternatively, track the time when the connection arrives at its end station.", "time_mode": "Time mode lets you change the departure timing and fix it to a specific time (e.g. 7:12:00 AM every morning) or add a moving offset (e.g. +00:05:00 taking into account the time to walk to the station)." }, - "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nCheck the [stationboard]({stationboard_url}) for valid stations.", + "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nConsult the stationboard linked above.", "title": "Swiss Public Transport" }, "time_fixed": { From f4166c53909989e8efe60b5e6e72274c5f6e1e0d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 16:50:30 +0100 Subject: [PATCH 1347/2987] Make sure we load the backup integration before frontend (#137010) --- homeassistant/bootstrap.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index d89a9595868..8c27f41aabe 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -161,6 +161,10 @@ FRONTEND_INTEGRATIONS = { # integrations can be removed and database migration status is # visible in frontend "frontend", + # Backup is an after dependency of frontend, after dependencies + # are not promoted from stage 2 to earlier stages, so we need to + # add it here. + "backup", } RECORDER_INTEGRATIONS = { # Setup after frontend From 4fe76ec78ce6e7781ab119fd29e6df00fddfcd8a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 31 Jan 2025 17:26:43 +0100 Subject: [PATCH 1348/2987] Revert previous PR and remove URL from error message instead (#137018) --- homeassistant/components/swiss_public_transport/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index 64817f89f42..1cdbd527467 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -2,7 +2,7 @@ "config": { "error": { "cannot_connect": "Cannot connect to server", - "bad_config": "Request failed due to bad config: Check at [stationboard]({stationboard_url}) if your station names are valid", + "bad_config": "Request failed due to bad config: Check the stationboard linked above if your station names are valid", "too_many_via_stations": "Too many via stations, only up to 5 via stations are allowed per connection.", "unknown": "An unknown error was raised by python-opendata-transport" }, @@ -28,7 +28,7 @@ "time_station": "Usually the departure time of a connection when it leaves the start station is tracked. Alternatively, track the time when the connection arrives at its end station.", "time_mode": "Time mode lets you change the departure timing and fix it to a specific time (e.g. 7:12:00 AM every morning) or add a moving offset (e.g. +00:05:00 taking into account the time to walk to the station)." }, - "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nConsult the stationboard linked above.", + "description": "Provide start and end station for your connection, and optionally up to 5 via stations.\n\nCheck the [stationboard]({stationboard_url}) for valid stations.", "title": "Swiss Public Transport" }, "time_fixed": { From b412164440d5797be059fc45d7a0453c3b012d20 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 18:20:30 +0100 Subject: [PATCH 1349/2987] Make supervisor backup file names more user friendly (#137020) --- homeassistant/components/backup/__init__.py | 3 +++ homeassistant/components/backup/util.py | 11 +++++++---- homeassistant/components/hassio/backup.py | 17 ++++++++++++++--- tests/components/hassio/test_backup.py | 14 ++++++++++++++ 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 3003f94c2ed..86e5b95d196 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -35,6 +35,7 @@ from .manager import ( WrittenBackup, ) from .models import AddonInfo, AgentBackup, Folder +from .util import suggested_filename, suggested_filename_from_name_date from .websocket import async_register_websocket_handlers __all__ = [ @@ -58,6 +59,8 @@ __all__ = [ "RestoreBackupState", "WrittenBackup", "async_get_manager", + "suggested_filename", + "suggested_filename_from_name_date", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index e9d597aa709..fbb13b4721a 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -118,12 +118,15 @@ def read_backup(backup_path: Path) -> AgentBackup: ) +def suggested_filename_from_name_date(name: str, date_str: str) -> str: + """Suggest a filename for the backup.""" + date = dt_util.parse_datetime(date_str, raise_on_error=True) + return "_".join(f"{name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split()) + + def suggested_filename(backup: AgentBackup) -> str: """Suggest a filename for the backup.""" - date = dt_util.parse_datetime(backup.date, raise_on_error=True) - return "_".join( - f"{backup.name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split() - ) + return suggested_filename_from_name_date(backup.name, backup.date) def validate_password(path: Path, password: str | None) -> bool: diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 59242a32708..495e953df9d 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import logging import os -from pathlib import Path +from pathlib import Path, PurePath from typing import Any, cast from uuid import UUID @@ -39,11 +39,14 @@ from homeassistant.components.backup import ( RestoreBackupState, WrittenBackup, async_get_manager as async_get_backup_manager, + suggested_filename as suggested_backup_filename, + suggested_filename_from_name_date, ) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util import dt as dt_util from .const import DOMAIN, EVENT_SUPERVISOR_EVENT from .handler import get_supervisor_client @@ -116,12 +119,15 @@ def _backup_details_to_agent_backup( AddonInfo(name=addon.name, slug=addon.slug, version=addon.version) for addon in details.addons ] + extra_metadata = details.extra or {} location = location or LOCATION_LOCAL return AgentBackup( addons=addons, backup_id=details.slug, database_included=database_included, - date=details.date.isoformat(), + date=extra_metadata.get( + "supervisor.backup_request_date", details.date.isoformat() + ), extra_metadata=details.extra or {}, folders=[Folder(folder) for folder in details.folders], homeassistant_included=homeassistant_included, @@ -177,7 +183,8 @@ class SupervisorBackupAgent(BackupAgent): return stream = await open_stream() upload_options = supervisor_backups.UploadBackupOptions( - location={self.location} + location={self.location}, + filename=PurePath(suggested_backup_filename(backup)), ) await self._client.backups.upload_backup( stream, @@ -304,6 +311,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): locations = [] locations = locations or [LOCATION_CLOUD_BACKUP] + date = dt_util.now().isoformat() + extra_metadata = extra_metadata | {"supervisor.backup_request_date": date} + filename = suggested_filename_from_name_date(backup_name, date) try: backup = await self._client.backups.partial_backup( supervisor_backups.PartialBackupOptions( @@ -317,6 +327,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): homeassistant_exclude_database=not include_database, background=True, extra=extra_metadata, + filename=PurePath(filename), ) ) except SupervisorError as err: diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 9ba73ade1a3..d001a358640 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -11,6 +11,7 @@ from dataclasses import replace from datetime import datetime from io import StringIO import os +from pathlib import PurePath from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch from uuid import UUID @@ -26,6 +27,7 @@ from aiohasupervisor.models import ( mounts as supervisor_mounts, ) from aiohasupervisor.models.mounts import MountsInfo +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.backup import ( @@ -854,8 +856,10 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( compressed=True, extra={ "instance_id": ANY, + "supervisor.backup_request_date": "2025-01-30T05:42:12.345678-08:00", "with_automatic_settings": False, }, + filename=PurePath("Test_-_2025-01-30_05.42_12345678.tar"), folders={"ssl"}, homeassistant_exclude_database=False, homeassistant=True, @@ -907,12 +911,14 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( async def test_reader_writer_create( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, extra_generate_options: dict[str, Any], expected_supervisor_options: supervisor_backups.PartialBackupOptions, ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -982,10 +988,12 @@ async def test_reader_writer_create( async def test_reader_writer_create_job_done( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, ) -> None: """Test generating a backup, and backup job finishes early.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE @@ -1140,6 +1148,7 @@ async def test_reader_writer_create_job_done( async def test_reader_writer_create_per_agent_encryption( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, commands: dict[str, Any], password: str | None, @@ -1151,6 +1160,7 @@ async def test_reader_writer_create_per_agent_encryption( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") mounts = MountsInfo( default_backup_mount=None, mounts=[ @@ -1170,6 +1180,7 @@ async def test_reader_writer_create_per_agent_encryption( supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = replace( TEST_BACKUP_DETAILS, + extra=DEFAULT_BACKUP_OPTIONS.extra, locations=create_locations, location_attributes={ location or LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( @@ -1254,6 +1265,7 @@ async def test_reader_writer_create_per_agent_encryption( upload_locations ) for call in supervisor_client.backups.upload_backup.mock_calls: + assert call.args[1].filename == PurePath("Test_-_2025-01-30_05.42_12345678.tar") upload_call_locations: set = call.args[1].location assert len(upload_call_locations) == 1 assert upload_call_locations.pop() in upload_locations @@ -1569,10 +1581,12 @@ async def test_reader_writer_create_info_error( async def test_reader_writer_create_remote_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, ) -> None: """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE From e86a633c23ebc4a7e28e4f5090b189bab51ac321 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 11:29:44 -0600 Subject: [PATCH 1350/2987] Bump habluetooth to 3.17.0 (#137022) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 38677400418..d6ed9281099 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.15.0" + "habluetooth==3.17.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 64353901fbf..a7fbe090f23 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.15.0 +habluetooth==3.17.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 50994859d2f..c955d01ac48 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.15.0 +habluetooth==3.17.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a2b74c5ce9..1eef877f6c5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -941,7 +941,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.15.0 +habluetooth==3.17.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 From ae79b0940140ab1ca99afb0191011027bf5b94e0 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 31 Jan 2025 19:25:24 +0100 Subject: [PATCH 1351/2987] Update frontend to 20250131.0 (#137024) --- 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 b545026059c..2ecb165554a 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==20250130.0"] + "requirements": ["home-assistant-frontend==20250131.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a7fbe090f23..2d4e92e2e9a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.17.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250130.0 +home-assistant-frontend==20250131.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c955d01ac48..e2f5a70d8b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,7 +1143,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250130.0 +home-assistant-frontend==20250131.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1eef877f6c5..eb4cba20f67 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250130.0 +home-assistant-frontend==20250131.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From ca2a555037d7dc5dada5096f30a45ffb8275c978 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 12:15:31 -0600 Subject: [PATCH 1352/2987] Bump bleak-esphome to 2.6.0 (#137025) --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index bab62723c82..3a55730c60f 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.2.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.6.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index ecc7afb3661..9585be72c63 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.0.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.2.0" + "bleak-esphome==2.6.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e2f5a70d8b3..660e0a0bc35 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -594,7 +594,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.2.0 +bleak-esphome==2.6.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb4cba20f67..c6b315d85aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -525,7 +525,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.2.0 +bleak-esphome==2.6.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 From 7deb1715ddf0d8959e62100fe0b279f09e933306 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 12:52:38 -0600 Subject: [PATCH 1353/2987] Bump SQLAlchemy to 2.0.37 (#137028) changelog: https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-2.0.37 There is a bug fix that likely affects us that could lead to corrupted queries https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-e4d04d8eb1bccee16b74f5662aff8edd --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/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, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index d3b6e52ad11..7cef284ef60 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.36", + "SQLAlchemy==2.0.37", "fnv-hash-fast==1.2.2", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 01c95d6c5e4..0094770d53b 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.36", "sqlparse==0.5.0"] + "requirements": ["SQLAlchemy==2.0.37", "sqlparse==0.5.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2d4e92e2e9a..0a1b97abc55 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -61,7 +61,7 @@ PyTurboJPEG==1.7.5 PyYAML==6.0.2 requests==2.32.3 securetar==2025.1.4 -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 diff --git a/pyproject.toml b/pyproject.toml index e3bee8e6608..74d634ea1a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ dependencies = [ "PyYAML==6.0.2", "requests==2.32.3", "securetar==2025.1.4", - "SQLAlchemy==2.0.36", + "SQLAlchemy==2.0.37", "standard-aifc==3.13.0;python_version>='3.13'", "standard-telnetlib==3.13.0;python_version>='3.13'", "typing-extensions>=4.12.2,<5.0", diff --git a/requirements.txt b/requirements.txt index 77fd3887db4..02f3849148b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -39,7 +39,7 @@ python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 securetar==2025.1.4 -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 diff --git a/requirements_all.txt b/requirements_all.txt index 660e0a0bc35..cc5ed9ee62d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -116,7 +116,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6b315d85aa..d0797b8f4a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -110,7 +110,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 From 5450ed8445af41857160c616730ba8b078ee3864 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 31 Jan 2025 20:17:14 +0100 Subject: [PATCH 1354/2987] Bump deebot-client to 11.1.0b2 (#137030) --- 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 188f59f74e4..16929e1741a 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==11.1.0b1"] + "requirements": ["py-sucks==0.9.10", "deebot-client==11.1.0b2"] } diff --git a/requirements_all.txt b/requirements_all.txt index cc5ed9ee62d..f321be6254f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -747,7 +747,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==11.1.0b1 +deebot-client==11.1.0b2 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0797b8f4a6..28f181530a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -637,7 +637,7 @@ dbus-fast==2.30.2 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==11.1.0b1 +deebot-client==11.1.0b2 # homeassistant.components.ihc # homeassistant.components.namecheapdns From e1105ef2fa224fc24742178834804be2b43c5d73 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Jan 2025 19:25:16 +0000 Subject: [PATCH 1355/2987] Bump version to 2025.2.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 271226e92e2..939eb70c3e4 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 74d634ea1a6..c8159776f8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b2" +version = "2025.2.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 4a2e9db9fe91f7d64ecbcf09559a22dc3beedfdb Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 20:59:34 +0100 Subject: [PATCH 1356/2987] Use readable backup names for onedrive (#137031) * Use readable names for onedrive * ensure filename is fixed * fix import --- homeassistant/components/onedrive/backup.py | 67 ++++++++++++--------- tests/components/onedrive/conftest.py | 5 +- tests/components/onedrive/test_backup.py | 38 ++---------- 3 files changed, 49 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 7f4bd5a0738..a7bac5d01fc 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -34,7 +34,12 @@ from msgraph.generated.models.drive_item_uploadable_properties import ( ) from msgraph_core.models import LargeFileUploadSession -from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + suggested_filename, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.httpx_client import get_async_client @@ -130,6 +135,10 @@ class OneDriveBackupAgent(BackupAgent): ) -> AsyncIterator[bytes]: """Download a backup file.""" # this forces the query to return a raw httpx response, but breaks typing + backup = await self._find_item_by_backup_id(backup_id) + if backup is None or backup.id is None: + raise BackupAgentError("Backup not found") + request_config = ( ContentRequestBuilder.ContentRequestBuilderGetRequestConfiguration( options=[ResponseHandlerOption(NativeResponseHandler())], @@ -137,7 +146,7 @@ class OneDriveBackupAgent(BackupAgent): ) response = cast( Response, - await self._get_backup_file_item(backup_id).content.get( + await self._items.by_drive_item_id(backup.id).content.get( request_configuration=request_config ), ) @@ -162,9 +171,10 @@ class OneDriveBackupAgent(BackupAgent): }, ) ) - upload_session = await self._get_backup_file_item( - backup.backup_id - ).create_upload_session.post(upload_session_request_body) + file_item = self._get_backup_file_item(suggested_filename(backup)) + upload_session = await file_item.create_upload_session.post( + upload_session_request_body + ) if upload_session is None or upload_session.upload_url is None: raise BackupAgentError( @@ -181,9 +191,7 @@ class OneDriveBackupAgent(BackupAgent): description = json.dumps(backup_dict) _LOGGER.debug("Creating metadata: %s", description) - await self._get_backup_file_item(backup.backup_id).patch( - DriveItem(description=description) - ) + await file_item.patch(DriveItem(description=description)) @handle_backup_errors async def async_delete_backup( @@ -192,13 +200,10 @@ class OneDriveBackupAgent(BackupAgent): **kwargs: Any, ) -> None: """Delete a backup file.""" - - try: - await self._get_backup_file_item(backup_id).delete() - except APIError as err: - if err.response_status_code == 404: - return - raise + backup = await self._find_item_by_backup_id(backup_id) + if backup is None or backup.id is None: + return + await self._items.by_drive_item_id(backup.id).delete() @handle_backup_errors async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: @@ -218,18 +223,12 @@ class OneDriveBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any ) -> AgentBackup | None: """Return a backup.""" - try: - drive_item = await self._get_backup_file_item(backup_id).get() - except APIError as err: - if err.response_status_code == 404: - return None - raise - if ( - drive_item is not None - and (description := drive_item.description) is not None - ): - return self._backup_from_description(description) - return None + backup = await self._find_item_by_backup_id(backup_id) + if backup is None: + return None + + assert backup.description # already checked in _find_item_by_backup_id + return self._backup_from_description(backup.description) def _backup_from_description(self, description: str) -> AgentBackup: """Create a backup object from a description.""" @@ -238,8 +237,20 @@ class OneDriveBackupAgent(BackupAgent): ) # OneDrive encodes the description on save automatically return AgentBackup.from_dict(json.loads(description)) + async def _find_item_by_backup_id(self, backup_id: str) -> DriveItem | None: + """Find a backup item by its backup ID.""" + + items = await self._items.by_drive_item_id(f"{self._folder_id}").children.get() + if items and (values := items.value): + for item in values: + if (description := item.description) is None: + continue + if backup_id in description: + return item + return None + def _get_backup_file_item(self, backup_id: str) -> DriveItemItemRequestBuilder: - return self._items.by_drive_item_id(f"{self._folder_id}:/{backup_id}.tar:") + return self._items.by_drive_item_id(f"{self._folder_id}:/{backup_id}:") async def _upload_file( self, upload_url: str, stream: AsyncIterator[bytes], total_size: int diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 649966a7828..205f5837ee7 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -125,7 +125,10 @@ def mock_graph_client(mock_adapter: MagicMock) -> Generator[MagicMock]: drive_items.children.get = AsyncMock( return_value=DriveItemCollectionResponse( value=[ - DriveItem(description=escape(dumps(BACKUP_METADATA))), + DriveItem( + id=BACKUP_METADATA["backup_id"], + description=escape(dumps(BACKUP_METADATA)), + ), DriveItem(), ] ) diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 162ecb7d92a..0114d924e1a 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -164,7 +164,7 @@ async def test_agents_delete_not_found_does_not_throw( mock_drive_items: MagicMock, ) -> None: """Test agent delete backup.""" - mock_drive_items.delete = AsyncMock(side_effect=APIError(response_status_code=404)) + mock_drive_items.children.get = AsyncMock(return_value=[]) client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -177,7 +177,7 @@ async def test_agents_delete_not_found_does_not_throw( assert response["success"] assert response["result"] == {"agent_errors": {}} - mock_drive_items.delete.assert_called_once() + assert mock_drive_items.delete.call_count == 0 async def test_agents_upload( @@ -448,22 +448,14 @@ async def test_delete_error( } -@pytest.mark.parametrize( - "problem", - [ - AsyncMock(return_value=None), - AsyncMock(side_effect=APIError(response_status_code=404)), - ], -) async def test_agents_backup_not_found( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_drive_items: MagicMock, - problem: AsyncMock, ) -> None: """Test backup not found.""" - mock_drive_items.get = problem + mock_drive_items.children.get = AsyncMock(return_value=[]) backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) @@ -473,26 +465,6 @@ async def test_agents_backup_not_found( assert response["result"]["backup"] is None -async def test_agents_backup_error( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test backup not found.""" - - mock_drive_items.get = AsyncMock(side_effect=APIError(response_status_code=500)) - backup_id = BACKUP_METADATA["backup_id"] - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["agent_errors"] == { - f"{DOMAIN}.{mock_config_entry.unique_id}": "Backup operation failed" - } - - async def test_reauth_on_403( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -501,7 +473,9 @@ async def test_reauth_on_403( ) -> None: """Test we re-authenticate on 403.""" - mock_drive_items.get = AsyncMock(side_effect=APIError(response_status_code=403)) + mock_drive_items.children.get = AsyncMock( + side_effect=APIError(response_status_code=403) + ) backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) From 164d38ac0df5b590ef18dd0bc9481da1e674da85 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Fri, 31 Jan 2025 21:03:17 +0100 Subject: [PATCH 1357/2987] Bump bthome-ble to 3.11.0 (#137032) bump bthome-ble to 3.11.0 --- homeassistant/components/bthome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index ad06f648d14..3783c087971 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.9.1"] + "requirements": ["bthome-ble==3.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index bb48565e2ee..b6b21975aee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -668,7 +668,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.9.1 +bthome-ble==3.11.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1695de16332..d8c9fd3613d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -585,7 +585,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.9.1 +bthome-ble==3.11.0 # homeassistant.components.buienradar buienradar==1.0.6 From 7103ea7e8f4a0f9def0731829f18c30cc3d1d5c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 31 Jan 2025 21:28:23 +0100 Subject: [PATCH 1358/2987] Add exception handling for updating LetPot time entities (#137033) * Handle exceptions for entity edits for LetPot * Set exception-translations: done --- homeassistant/components/letpot/entity.py | 30 +++++++++++ .../components/letpot/quality_scale.yaml | 4 +- homeassistant/components/letpot/strings.json | 8 +++ homeassistant/components/letpot/time.py | 3 +- tests/components/letpot/test_time.py | 52 +++++++++++++++++++ 5 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 tests/components/letpot/test_time.py diff --git a/homeassistant/components/letpot/entity.py b/homeassistant/components/letpot/entity.py index c9a8953b5d5..b4d505f4092 100644 --- a/homeassistant/components/letpot/entity.py +++ b/homeassistant/components/letpot/entity.py @@ -1,5 +1,11 @@ """Base class for LetPot entities.""" +from collections.abc import Callable, Coroutine +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.update_coordinator import CoordinatorEntity @@ -23,3 +29,27 @@ class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]): model_id=coordinator.device_client.device_model_code, serial_number=coordinator.device.serial_number, ) + + +def exception_handler[_EntityT: LetPotEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate the function to catch LetPot exceptions and raise them correctly.""" + + async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + except LetPotConnectionException as exception: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"exception": str(exception)}, + ) from exception + except LetPotException as exception: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unknown_error", + translation_placeholders={"exception": str(exception)}, + ) from exception + + return handler diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index 74b948ffbf7..7f8c3d3c04c 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -29,7 +29,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: status: done comment: | @@ -63,7 +63,7 @@ rules: entity-device-class: todo entity-disabled-by-default: todo entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: todo reconfiguration-flow: todo repair-issues: todo diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json index 93913c2bc4d..94d3ad02cfa 100644 --- a/homeassistant/components/letpot/strings.json +++ b/homeassistant/components/letpot/strings.json @@ -40,5 +40,13 @@ "name": "Light on" } } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the LetPot device: {exception}" + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the LetPot device: {exception}" + } } } diff --git a/homeassistant/components/letpot/time.py b/homeassistant/components/letpot/time.py index 229f02e0806..80ce9743d8c 100644 --- a/homeassistant/components/letpot/time.py +++ b/homeassistant/components/letpot/time.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import LetPotConfigEntry from .coordinator import LetPotDeviceCoordinator -from .entity import LetPotEntity +from .entity import LetPotEntity, 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. @@ -86,6 +86,7 @@ class LetPotTimeEntity(LetPotEntity, TimeEntity): """Return the time.""" return self.entity_description.value_fn(self.coordinator.data) + @exception_handler async def async_set_value(self, value: time) -> None: """Set the time.""" await self.entity_description.set_value_fn( diff --git a/tests/components/letpot/test_time.py b/tests/components/letpot/test_time.py new file mode 100644 index 00000000000..44a03e565c0 --- /dev/null +++ b/tests/components/letpot/test_time.py @@ -0,0 +1,52 @@ +"""Test time entities for the LetPot integration.""" + +from datetime import time +from unittest.mock import MagicMock + +from letpot.exceptions import LetPotConnectionException, LetPotException +import pytest + +from homeassistant.components.time import SERVICE_SET_VALUE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("exception", "user_error"), + [ + ( + LetPotConnectionException("Connection failed"), + "An error occurred while communicating with the LetPot device: Connection failed", + ), + ( + LetPotException("Random thing failed"), + "An unknown error occurred while communicating with the LetPot device: Random thing failed", + ), + ], +) +async def test_time_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + exception: Exception, + user_error: str, +) -> None: + """Test time entity exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_device_client.set_light_schedule.side_effect = exception + + assert hass.states.get("time.garden_light_on") is not None + with pytest.raises(HomeAssistantError, match=user_error): + 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"}, + ) From d51e72cd9500c201f9e11c363fe7d8ce63519406 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 31 Jan 2025 21:29:31 +0100 Subject: [PATCH 1359/2987] Update Overseerr string to mention CSRF (#137001) * Update Overseerr string to mention CSRF * Update homeassistant/components/overseerr/strings.json * Update homeassistant/components/overseerr/strings.json --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/overseerr/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index 5053bcedc41..14650fd5c25 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -27,7 +27,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_auth": "Authentication failed. Your API key is invalid or CSRF protection is turned on, preventing authentication.", "invalid_host": "The provided URL is not a valid host." } }, From 7a0400154e4a4e4f5f2def5b7b64c9aa17ee8094 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 15:00:39 -0600 Subject: [PATCH 1360/2987] Bump zeroconf to 0.143.0 (#137035) --- 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 be6f2d111d7..f4a78cd99e9 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.142.0"] + "requirements": ["zeroconf==0.143.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0a1b97abc55..88527d7169a 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.142.0 +zeroconf==0.143.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 3ad3240907c..5c3b794569c 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.142.0" + "zeroconf==0.143.0" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 02f3849148b..13f19304cbb 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.142.0 +zeroconf==0.143.0 diff --git a/requirements_all.txt b/requirements_all.txt index b6b21975aee..4e950d754f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3125,7 +3125,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.142.0 +zeroconf==0.143.0 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d8c9fd3613d..e894044334f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2514,7 +2514,7 @@ yt-dlp[default]==2025.01.26 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.142.0 +zeroconf==0.143.0 # homeassistant.components.zeversolar zeversolar==0.3.2 From dc7f44535639bf9c55965a58ef8db4ba30157ea2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 15:18:19 -0600 Subject: [PATCH 1361/2987] Bump bthome-ble to 3.12.3 (#137036) --- homeassistant/components/bthome/manifest.json | 2 +- homeassistant/components/bthome/sensor.py | 36 +++++++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 3783c087971..c8577113804 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.11.0"] + "requirements": ["bthome-ble==3.12.3"] } diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 417df9f5068..e46cbbea700 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -67,6 +67,16 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + # Conductivity (µS/cm) + ( + BTHomeSensorDeviceClass.CONDUCTIVITY, + Units.CONDUCTIVITY, + ): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}", + device_class=SensorDeviceClass.CONDUCTIVITY, + native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM, + state_class=SensorStateClass.MEASUREMENT, + ), # Count (-) (BTHomeSensorDeviceClass.COUNT, None): SensorEntityDescription( key=str(BTHomeSensorDeviceClass.COUNT), @@ -99,6 +109,12 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), + # Directions (°) + (BTHomeExtendedSensorDeviceClass.DIRECTION, Units.DEGREE): SensorEntityDescription( + key=f"{BTHomeExtendedSensorDeviceClass.DIRECTION}_{Units.DEGREE}", + native_unit_of_measurement=DEGREE, + state_class=SensorStateClass.MEASUREMENT, + ), # Distance (mm) ( BTHomeSensorDeviceClass.DISTANCE, @@ -221,6 +237,16 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), + # Precipitation (mm) + ( + BTHomeExtendedSensorDeviceClass.PRECIPITATION, + Units.LENGTH_MILLIMETERS, + ): SensorEntityDescription( + key=f"{BTHomeExtendedSensorDeviceClass.PRECIPITATION}_{Units.LENGTH_MILLIMETERS}", + device_class=SensorDeviceClass.PRECIPITATION, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + state_class=SensorStateClass.MEASUREMENT, + ), # Pressure (mbar) (BTHomeSensorDeviceClass.PRESSURE, Units.PRESSURE_MBAR): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.PRESSURE}_{Units.PRESSURE_MBAR}", @@ -357,16 +383,6 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfVolume.LITERS, state_class=SensorStateClass.TOTAL, ), - # Conductivity (µS/cm) - ( - BTHomeSensorDeviceClass.CONDUCTIVITY, - Units.CONDUCTIVITY, - ): SensorEntityDescription( - key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}", - device_class=SensorDeviceClass.CONDUCTIVITY, - native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM, - state_class=SensorStateClass.MEASUREMENT, - ), } diff --git a/requirements_all.txt b/requirements_all.txt index 4e950d754f4..80ac251e862 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -668,7 +668,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.11.0 +bthome-ble==3.12.3 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e894044334f..492b67251fc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -585,7 +585,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.11.0 +bthome-ble==3.12.3 # homeassistant.components.buienradar buienradar==1.0.6 From 5fa5bd130273a71f922730be49993b47c7b50e42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 16:30:20 -0600 Subject: [PATCH 1362/2987] Bump aiohttp-asyncmdnsresolver to 0.0.3 (#137040) --- 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 88527d7169a..76bfa8b1ded 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.2b6 -aiohttp-asyncmdnsresolver==0.0.2 +aiohttp-asyncmdnsresolver==0.0.3 aiohttp-fast-zlib==0.2.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index 5c3b794569c..afed8fd7091 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "aiohttp==3.11.11", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", - "aiohttp-asyncmdnsresolver==0.0.2", + "aiohttp-asyncmdnsresolver==0.0.3", "aiozoneinfo==0.2.1", "astral==2.2", "async-interrupt==1.2.0", diff --git a/requirements.txt b/requirements.txt index 13f19304cbb..a58065a3a7a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ aiohasupervisor==0.2.2b6 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 -aiohttp-asyncmdnsresolver==0.0.2 +aiohttp-asyncmdnsresolver==0.0.3 aiozoneinfo==0.2.1 astral==2.2 async-interrupt==1.2.0 From 7040614433de7cf44c3ee1e1defcf5381eb6aef4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 31 Jan 2025 23:56:45 +0100 Subject: [PATCH 1363/2987] Fix one occurrence of "api" to match all other in sensibo and HA (#137037) --- homeassistant/components/sensibo/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index c5ff0f135e6..6c5210d12bf 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -18,7 +18,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "api_key": "Follow the [documentation]({url}) to get your api key" + "api_key": "Follow the [documentation]({url}) to get your API key" } }, "reauth_confirm": { From c35e7715b7c830e23de90751b44da2521c155d4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 18:13:27 -0600 Subject: [PATCH 1364/2987] Bump habluetooth to 3.17.1 (#137045) --- .../components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/bluetooth/test_diagnostics.py | 33 +++++++++++++++++-- .../bluetooth/test_websocket_api.py | 22 +++++++++++-- 6 files changed, 54 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index d6ed9281099..51358f8a656 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.17.0" + "habluetooth==3.17.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 76bfa8b1ded..40bb031d2ec 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.17.0 +habluetooth==3.17.1 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 80ac251e862..a4df828bb66 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1103,7 +1103,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.17.0 +habluetooth==3.17.1 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 492b67251fc..ac40911c5bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -944,7 +944,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.17.0 +habluetooth==3.17.1 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 384eae7e49a..682cff62969 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -133,7 +133,20 @@ async def test_diagnostics( } }, "manager": { - "allocations": {}, + "allocations": { + "00:00:00:00:00:01": { + "allocated": [], + "free": 5, + "slots": 5, + "source": "00:00:00:00:00:01", + }, + "00:00:00:00:00:02": { + "allocated": [], + "free": 2, + "slots": 2, + "source": "00:00:00:00:00:02", + }, + }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -292,7 +305,14 @@ async def test_diagnostics_macos( } }, "manager": { - "allocations": {}, + "allocations": { + "Core Bluetooth": { + "allocated": [], + "free": 5, + "slots": 5, + "source": "Core Bluetooth", + }, + }, "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", @@ -486,7 +506,14 @@ async def test_diagnostics_remote_adapter( }, "dbus": {}, "manager": { - "allocations": {}, + "allocations": { + "00:00:00:00:00:01": { + "allocated": [], + "free": 5, + "slots": 5, + "source": "00:00:00:00:00:01", + }, + }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py index bacdbbd5eed..57199d04078 100644 --- a/tests/components/bluetooth/test_websocket_api.py +++ b/tests/components/bluetooth/test_websocket_api.py @@ -159,12 +159,30 @@ async def test_subscribe_connection_allocations( response = await client.receive_json() assert response["event"] == [ + { + "allocated": [], + "free": 5, + "slots": 5, + "source": "00:00:00:00:00:01", + }, + { + "allocated": [], + "free": 5, + "slots": 5, + "source": HCI0_SOURCE_ADDRESS, + }, + { + "allocated": [], + "free": 5, + "slots": 5, + "source": HCI1_SOURCE_ADDRESS, + }, { "allocated": [], "free": 0, "slots": 0, "source": NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, - } + }, ] manager = _get_manager() @@ -184,7 +202,7 @@ async def test_subscribe_connection_allocations( "free": 4, "slots": 5, "source": "AA:BB:CC:DD:EE:11", - } + }, ] manager.async_on_allocation_changed( Allocations( From e56772d37b1cba5143bbf8042c1f78c0587d5585 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 1 Feb 2025 01:38:11 +0100 Subject: [PATCH 1365/2987] Bump aioimaplib to version 2.0.1 (#137049) --- homeassistant/components/imap/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imap/manifest.json b/homeassistant/components/imap/manifest.json index a3370de94ca..515fee0e721 100644 --- a/homeassistant/components/imap/manifest.json +++ b/homeassistant/components/imap/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/imap", "iot_class": "cloud_push", "loggers": ["aioimaplib"], - "requirements": ["aioimaplib==2.0.0"] + "requirements": ["aioimaplib==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a4df828bb66..ce9c538fbc1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,7 +276,7 @@ aiohttp_sse==2.2.0 aiohue==4.7.3 # homeassistant.components.imap -aioimaplib==2.0.0 +aioimaplib==2.0.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac40911c5bc..bd338b85532 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -261,7 +261,7 @@ aiohttp_sse==2.2.0 aiohue==4.7.3 # homeassistant.components.imap -aioimaplib==2.0.0 +aioimaplib==2.0.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 From 5da9bfe0e3b658e12baa710948b99ae1cc5e7cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sat, 1 Feb 2025 01:03:20 +0000 Subject: [PATCH 1366/2987] Add dev docs and frontend PR links to PR template (#137034) --- .github/PULL_REQUEST_TEMPLATE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 23365feffb7..792dacd8032 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -46,6 +46,8 @@ - This PR fixes or closes issue: fixes # - This PR is related to issue: - Link to documentation pull request: +- Link to developer documentation pull request: +- Link to frontend pull request: ## Checklist 2: Use config entry ID as base for unique IDs. @@ -152,7 +131,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> async def _async_migrate_device_identifiers( - hass: HomeAssistant, config_entry: ConfigEntry, old_unique_id: str | None + hass: HomeAssistant, + config_entry: MinecraftServerConfigEntry, + old_unique_id: str | None, ) -> None: """Migrate the device identifiers to the new format.""" device_registry = dr.async_get(hass) diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index d2c8aca57e4..39e12228451 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -5,12 +5,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -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 MinecraftServerCoordinator +from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator from .entity import MinecraftServerEntity KEY_STATUS = "status" @@ -27,11 +25,11 @@ BINARY_SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MinecraftServerConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Minecraft Server binary sensor platform.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # Add binary sensor entities. async_add_entities( @@ -49,7 +47,7 @@ class MinecraftServerBinarySensorEntity(MinecraftServerEntity, BinarySensorEntit self, coordinator: MinecraftServerCoordinator, description: BinarySensorEntityDescription, - config_entry: ConfigEntry, + config_entry: MinecraftServerConfigEntry, ) -> None: """Initialize binary sensor base entity.""" super().__init__(coordinator, config_entry) diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index f66e4acf214..2cd1c1a94ab 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -6,17 +6,22 @@ from datetime import timedelta import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import ( MinecraftServer, + MinecraftServerAddressError, MinecraftServerConnectionError, MinecraftServerData, MinecraftServerNotInitializedError, + MinecraftServerType, ) +type MinecraftServerConfigEntry = ConfigEntry[MinecraftServerCoordinator] + SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) @@ -25,16 +30,15 @@ _LOGGER = logging.getLogger(__name__) class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): """Minecraft Server data update coordinator.""" - config_entry: ConfigEntry + config_entry: MinecraftServerConfigEntry + _api: MinecraftServer def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, - api: MinecraftServer, + config_entry: MinecraftServerConfigEntry, ) -> None: """Initialize coordinator instance.""" - self._api = api super().__init__( hass=hass, @@ -44,6 +48,22 @@ class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): update_interval=SCAN_INTERVAL, ) + async def _async_setup(self) -> None: + """Set up the Minecraft Server data coordinator.""" + + # Create API instance. + self._api = MinecraftServer( + self.hass, + self.config_entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION), + self.config_entry.data[CONF_ADDRESS], + ) + + # Initialize API instance. + try: + await self._api.async_initialize() + except MinecraftServerAddressError as error: + raise ConfigEntryNotReady(f"Initialization failed: {error}") from error + async def _async_update_data(self) -> MinecraftServerData: """Get updated data from the server.""" try: diff --git a/homeassistant/components/minecraft_server/diagnostics.py b/homeassistant/components/minecraft_server/diagnostics.py index 0bcffe1434a..61a65f9c2dd 100644 --- a/homeassistant/components/minecraft_server/diagnostics.py +++ b/homeassistant/components/minecraft_server/diagnostics.py @@ -5,20 +5,19 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .coordinator import MinecraftServerConfigEntry TO_REDACT: Iterable[Any] = {CONF_ADDRESS, CONF_NAME, "players_list"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MinecraftServerConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data return { "config_entry": { diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml index fc3db3b3075..eeda413f2ad 100644 --- a/homeassistant/components/minecraft_server/quality_scale.yaml +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -29,7 +29,7 @@ rules: status: done comment: Using confid entry ID as the dependency mcstatus doesn't provide a unique information. has-entity-name: done - runtime-data: todo + runtime-data: done test-before-configure: done test-before-setup: status: done diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 50571123003..6effa53fbf2 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -7,15 +7,14 @@ from dataclasses import dataclass from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TYPE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .api import MinecraftServerData, MinecraftServerType -from .const import DOMAIN, KEY_LATENCY, KEY_MOTD -from .coordinator import MinecraftServerCoordinator +from .const import KEY_LATENCY, KEY_MOTD +from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator from .entity import MinecraftServerEntity ATTR_PLAYERS_LIST = "players_list" @@ -158,11 +157,11 @@ SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MinecraftServerConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Minecraft Server sensor platform.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # Add sensor entities. async_add_entities( @@ -184,7 +183,7 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): self, coordinator: MinecraftServerCoordinator, description: MinecraftServerSensorEntityDescription, - config_entry: ConfigEntry, + config_entry: MinecraftServerConfigEntry, ) -> None: """Initialize sensor base entity.""" super().__init__(coordinator, config_entry) From 648c750a0fd2e7a7da4fe8e78b1dc38402f0f23b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Feb 2025 13:21:21 -0600 Subject: [PATCH 2639/2987] Bump ulid-transform to 1.2.1 (#139054) changelog: https://github.com/Bluetooth-Devices/ulid-transform/compare/v1.2.0...v1.2.1 --- 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 7847599223c..40f7e511332 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -65,7 +65,7 @@ SQLAlchemy==2.0.38 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 -ulid-transform==1.2.0 +ulid-transform==1.2.1 urllib3>=1.26.5,<2 uv==0.6.1 voluptuous-openapi==0.0.6 diff --git a/pyproject.toml b/pyproject.toml index 0a4228496e3..b43e4d284ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dependencies = [ "standard-aifc==3.13.0", "standard-telnetlib==3.13.0", "typing-extensions>=4.12.2,<5.0", - "ulid-transform==1.2.0", + "ulid-transform==1.2.1", # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 diff --git a/requirements.txt b/requirements.txt index 2bacda6b017..962cab71a53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,7 +43,7 @@ SQLAlchemy==2.0.38 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 -ulid-transform==1.2.0 +ulid-transform==1.2.1 urllib3>=1.26.5,<2 uv==0.6.1 voluptuous==0.15.2 From f3dd772b4386b94f5d96477c55f614ae2e607459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frederic=20Mari=C3=ABn?= Date: Sat, 22 Feb 2025 20:25:19 +0100 Subject: [PATCH 2640/2987] Bump pyrisco to 0.6.7 (#139065) --- homeassistant/components/risco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json index 149b8761589..43d471172d6 100644 --- a/homeassistant/components/risco/manifest.json +++ b/homeassistant/components/risco/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/risco", "iot_class": "local_push", "loggers": ["pyrisco"], - "requirements": ["pyrisco==0.6.5"] + "requirements": ["pyrisco==0.6.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 90065832988..7596d1e7d5f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2250,7 +2250,7 @@ pyrecswitch==1.0.2 pyrepetierng==0.1.0 # homeassistant.components.risco -pyrisco==0.6.5 +pyrisco==0.6.7 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1017a3c420..0e868a77f0c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1834,7 +1834,7 @@ pyrail==0.0.3 pyrainbird==6.0.1 # homeassistant.components.risco -pyrisco==0.6.5 +pyrisco==0.6.7 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 From 6c0c4bfd74eedf8a7faf84edc378f06d25e83170 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 22 Feb 2025 20:53:53 +0100 Subject: [PATCH 2641/2987] Bump pyfritzhome to 0.6.17 (#139066) bump pyfritzhome to 0.6.17 --- homeassistant/components/fritzbox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index 92405a977ee..f6155024cbf 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyfritzhome"], - "requirements": ["pyfritzhome==0.6.16"], + "requirements": ["pyfritzhome==0.6.17"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/requirements_all.txt b/requirements_all.txt index 7596d1e7d5f..0ffd8b7e781 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1972,7 +1972,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.16 +pyfritzhome==0.6.17 # homeassistant.components.ifttt pyfttt==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e868a77f0c..6d070883303 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1607,7 +1607,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.16 +pyfritzhome==0.6.17 # homeassistant.components.ifttt pyfttt==0.3 From a0c278135590a8cc65ae344838f39cbf6682225c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Feb 2025 20:56:05 +0100 Subject: [PATCH 2642/2987] Fix docstring parameter in entity platform (#139070) Fix docstring --- homeassistant/helpers/entity_platform.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index adf34f3b285..11a9786f86e 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -659,7 +659,7 @@ class EntityPlatform: This method must be run in the event loop. - :param subentry_id: subentry which the entities should be added to + :param config_subentry_id: subentry which the entities should be added to """ if config_subentry_id and ( not self.config_entry From 92788a04ff0f86d17130e022b606e487af5d0b6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 22 Feb 2025 21:08:39 +0100 Subject: [PATCH 2643/2987] Add entities that represent program options to Home Connect (#138674) * Add program options as entities * Use program options constraints * Only fetch the available options on refresh * Extract the option definitions getter from the loop * Add the option entities only when it is required * Fix typo --- .../components/home_connect/common.py | 102 +++++- .../components/home_connect/coordinator.py | 101 +++++- .../components/home_connect/entity.py | 63 +++- .../components/home_connect/icons.json | 33 ++ .../components/home_connect/number.py | 91 +++++- .../components/home_connect/select.py | 245 +++++++++++++- .../components/home_connect/sensor.py | 8 +- .../components/home_connect/strings.json | 251 +++++++++++++++ .../components/home_connect/switch.py | 89 +++++- tests/components/home_connect/conftest.py | 41 +++ .../home_connect/fixtures/settings.json | 5 + .../snapshots/test_diagnostics.ambr | 1 + tests/components/home_connect/test_entity.py | 299 ++++++++++++++++++ tests/components/home_connect/test_number.py | 163 +++++++++- tests/components/home_connect/test_select.py | 152 ++++++++- tests/components/home_connect/test_switch.py | 118 ++++++- 16 files changed, 1729 insertions(+), 33 deletions(-) create mode 100644 tests/components/home_connect/test_entity.py diff --git a/homeassistant/components/home_connect/common.py b/homeassistant/components/home_connect/common.py index c27230c01d8..a9f48eea5ba 100644 --- a/homeassistant/components/home_connect/common.py +++ b/homeassistant/components/home_connect/common.py @@ -1,5 +1,6 @@ """Common callbacks for all Home Connect platforms.""" +from collections import defaultdict from collections.abc import Callable from functools import partial from typing import cast @@ -9,7 +10,32 @@ from aiohomeconnect.model import EventKey from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity + + +def _create_option_entities( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, + known_entity_unique_ids: dict[str, str], + get_option_entities_for_appliance: Callable[ + [HomeConnectConfigEntry, HomeConnectApplianceData], + list[HomeConnectOptionEntity], + ], + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create the required option entities for the appliances.""" + option_entities_to_add = [ + entity + for entity in get_option_entities_for_appliance(entry, appliance) + if entity.unique_id not in known_entity_unique_ids + ] + known_entity_unique_ids.update( + { + cast(str, entity.unique_id): appliance.info.ha_id + for entity in option_entities_to_add + } + ) + async_add_entities(option_entities_to_add) def _handle_paired_or_connected_appliance( @@ -18,6 +44,12 @@ def _handle_paired_or_connected_appliance( get_entities_for_appliance: Callable[ [HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity] ], + get_option_entities_for_appliance: Callable[ + [HomeConnectConfigEntry, HomeConnectApplianceData], + list[HomeConnectOptionEntity], + ] + | None, + changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]], async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Handle a new paired appliance or an appliance that has been connected. @@ -34,6 +66,28 @@ def _handle_paired_or_connected_appliance( for entity in get_entities_for_appliance(entry, appliance) if entity.unique_id not in known_entity_unique_ids ] + if get_option_entities_for_appliance: + entities_to_add.extend( + entity + for entity in get_option_entities_for_appliance(entry, appliance) + if entity.unique_id not in known_entity_unique_ids + ) + changed_options_listener_remove_callback = ( + entry.runtime_data.async_add_listener( + partial( + _create_option_entities, + entry, + appliance, + known_entity_unique_ids, + get_option_entities_for_appliance, + async_add_entities, + ), + ) + ) + entry.async_on_unload(changed_options_listener_remove_callback) + changed_options_listener_remove_callbacks[appliance.info.ha_id].append( + changed_options_listener_remove_callback + ) known_entity_unique_ids.update( { cast(str, entity.unique_id): appliance.info.ha_id @@ -47,11 +101,17 @@ def _handle_paired_or_connected_appliance( def _handle_depaired_appliance( entry: HomeConnectConfigEntry, known_entity_unique_ids: dict[str, str], + changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]], ) -> None: """Handle a removed appliance.""" for entity_unique_id, appliance_id in known_entity_unique_ids.copy().items(): if appliance_id not in entry.runtime_data.data: known_entity_unique_ids.pop(entity_unique_id, None) + if appliance_id in changed_options_listener_remove_callbacks: + for listener in changed_options_listener_remove_callbacks.pop( + appliance_id + ): + listener() def setup_home_connect_entry( @@ -60,13 +120,44 @@ def setup_home_connect_entry( [HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity] ], async_add_entities: AddConfigEntryEntitiesCallback, + get_option_entities_for_appliance: Callable[ + [HomeConnectConfigEntry, HomeConnectApplianceData], + list[HomeConnectOptionEntity], + ] + | None = None, ) -> None: """Set up the callbacks for paired and depaired appliances.""" known_entity_unique_ids: dict[str, str] = {} + changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]] = ( + defaultdict(list) + ) entities: list[HomeConnectEntity] = [] for appliance in entry.runtime_data.data.values(): entities_to_add = get_entities_for_appliance(entry, appliance) + if get_option_entities_for_appliance: + entities_to_add.extend(get_option_entities_for_appliance(entry, appliance)) + for event_key in ( + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ): + changed_options_listener_remove_callback = ( + entry.runtime_data.async_add_listener( + partial( + _create_option_entities, + entry, + appliance, + known_entity_unique_ids, + get_option_entities_for_appliance, + async_add_entities, + ), + (appliance.info.ha_id, event_key), + ) + ) + entry.async_on_unload(changed_options_listener_remove_callback) + changed_options_listener_remove_callbacks[appliance.info.ha_id].append( + changed_options_listener_remove_callback + ) known_entity_unique_ids.update( { cast(str, entity.unique_id): appliance.info.ha_id @@ -83,6 +174,8 @@ def setup_home_connect_entry( entry, known_entity_unique_ids, get_entities_for_appliance, + get_option_entities_for_appliance, + changed_options_listener_remove_callbacks, async_add_entities, ), ( @@ -93,7 +186,12 @@ def setup_home_connect_entry( ) entry.async_on_unload( entry.runtime_data.async_add_special_listener( - partial(_handle_depaired_appliance, entry, known_entity_unique_ids), + partial( + _handle_depaired_appliance, + entry, + known_entity_unique_ids, + changed_options_listener_remove_callbacks, + ), (EventKey.BSH_COMMON_APPLIANCE_DEPAIRED,), ) ) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index ceedde7fe72..b5f0f711597 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -7,7 +7,7 @@ from collections import defaultdict from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any +from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( @@ -17,6 +17,8 @@ from aiohomeconnect.model import ( EventType, GetSetting, HomeAppliance, + OptionKey, + ProgramKey, SettingKey, Status, StatusKey, @@ -28,7 +30,7 @@ from aiohomeconnect.model.error import ( HomeConnectRequestError, UnauthorizedError, ) -from aiohomeconnect.model.program import EnumerateProgram +from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry @@ -53,6 +55,7 @@ class HomeConnectApplianceData: events: dict[EventKey, Event] info: HomeAppliance + options: dict[OptionKey, ProgramDefinitionOption] programs: list[EnumerateProgram] settings: dict[SettingKey, GetSetting] status: dict[StatusKey, Status] @@ -61,6 +64,8 @@ class HomeConnectApplianceData: """Update data with data from other instance.""" self.events.update(other.events) self.info.connected = other.info.connected + self.options.clear() + self.options.update(other.options) self.programs.clear() self.programs.extend(other.programs) self.settings.update(other.settings) @@ -172,8 +177,9 @@ class HomeConnectCoordinator( settings = self.data[event_message_ha_id].settings events = self.data[event_message_ha_id].events for event in event_message.data.items: - if event.key in SettingKey: - setting_key = SettingKey(event.key) + event_key = event.key + if event_key in SettingKey: + setting_key = SettingKey(event_key) if setting_key in settings: settings[setting_key].value = event.value else: @@ -183,7 +189,16 @@ class HomeConnectCoordinator( value=event.value, ) else: - events[event.key] = event + if event_key in ( + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ): + await self.update_options( + event_message_ha_id, + event_key, + ProgramKey(cast(str, event.value)), + ) + events[event_key] = event self._call_event_listener(event_message) case EventType.EVENT: @@ -338,6 +353,7 @@ class HomeConnectCoordinator( programs = [] events = {} + options = {} if appliance.type in APPLIANCES_WITH_PROGRAMS: try: all_programs = await self.client.get_all_programs(appliance.ha_id) @@ -351,15 +367,17 @@ class HomeConnectCoordinator( ) else: programs.extend(all_programs.programs) + current_program_key = None + program_options = None for program, event_key in ( - ( - all_programs.active, - EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, - ), ( all_programs.selected, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, ), + ( + all_programs.active, + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + ), ): if program and program.key: events[event_key] = Event( @@ -370,10 +388,30 @@ class HomeConnectCoordinator( "", program.key, ) + current_program_key = program.key + program_options = program.options + if current_program_key: + options = await self.get_options_definitions( + appliance.ha_id, current_program_key + ) + for option in program_options or []: + option_event_key = EventKey(option.key) + events[option_event_key] = Event( + option_event_key, + option.key, + 0, + "", + "", + option.value, + option.name, + display_value=option.display_value, + unit=option.unit, + ) appliance_data = HomeConnectApplianceData( events=events, info=appliance, + options=options, programs=programs, settings=settings, status=status, @@ -383,3 +421,48 @@ class HomeConnectCoordinator( appliance_data = appliance_data_to_update return appliance_data + + async def get_options_definitions( + self, ha_id: str, program_key: ProgramKey + ) -> dict[OptionKey, ProgramDefinitionOption]: + """Get options with constraints for appliance.""" + return { + option.key: option + for option in ( + await self.client.get_available_program(ha_id, program_key=program_key) + ).options + or [] + } + + async def update_options( + self, ha_id: str, event_key: EventKey, program_key: ProgramKey + ) -> None: + """Update options for appliance.""" + options = self.data[ha_id].options + events = self.data[ha_id].events + options_to_notify = options.copy() + options.clear() + if program_key is not ProgramKey.UNKNOWN: + options.update(await self.get_options_definitions(ha_id, program_key)) + + for option in options.values(): + option_value = option.constraints.default if option.constraints else None + if option_value is not None: + option_event_key = EventKey(option.key) + events[option_event_key] = Event( + option_event_key, + option.key.value, + 0, + "", + "", + option_value, + option.name, + unit=option.unit, + ) + options_to_notify.update(options) + for option_key in options_to_notify: + for listener in self.context_listeners.get( + (ha_id, EventKey(option_key)), + [], + ): + listener() diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 8eb9d757f14..52eaaecace7 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -1,17 +1,22 @@ """Home Connect entity base class.""" from abc import abstractmethod +import contextlib import logging +from typing import cast -from aiohomeconnect.model import EventKey +from aiohomeconnect.model import EventKey, OptionKey +from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError from homeassistant.core import callback +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 HomeConnectApplianceData, HomeConnectCoordinator +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -60,3 +65,59 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): return ( self.appliance.info.connected and self._attr_available and super().available ) + + +class HomeConnectOptionEntity(HomeConnectEntity): + """Class for entities that represents program options.""" + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.bsh_key in self.appliance.options + + @property + def option_value(self) -> str | int | float | bool | None: + """Return the state of the entity.""" + if event := self.appliance.events.get(EventKey(self.bsh_key)): + return event.value + return None + + async def async_set_option(self, value: str | float | bool) -> None: + """Set an option for the entity.""" + try: + # We try to set the active program option first, + # if it fails we try to set the selected program option + with contextlib.suppress(ActiveProgramNotSetError): + await self.coordinator.client.set_active_program_option( + self.appliance.info.ha_id, + option_key=self.bsh_key, + value=value, + ) + _LOGGER.debug( + "Updated %s for the active program, new state: %s", + self.entity_id, + self.state, + ) + return + + await self.coordinator.client.set_selected_program_option( + self.appliance.info.ha_id, + option_key=self.bsh_key, + value=value, + ) + _LOGGER.debug( + "Updated %s for the selected program, new state: %s", + self.entity_id, + self.state, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_option", + translation_placeholders=get_dict_from_home_connect_error(err), + ) from err + + @property + def bsh_key(self) -> OptionKey: + """Return the BSH key.""" + return cast(OptionKey, self.entity_description.key) diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json index 6b604fc004e..651c00328b6 100644 --- a/homeassistant/components/home_connect/icons.json +++ b/homeassistant/components/home_connect/icons.json @@ -208,6 +208,39 @@ }, "door-assistant_freezer": { "default": "mdi:door" + }, + "silence_on_demand": { + "default": "mdi:volume-mute", + "state": { + "on": "mdi:volume-mute", + "off": "mdi:volume-high" + } + }, + "half_load": { + "default": "mdi:fraction-one-half" + }, + "hygiene_plus": { + "default": "mdi:silverware-clean" + }, + "eco_dry": { + "default": "mdi:sprout" + }, + "fast_pre_heat": { + "default": "mdi:fire" + }, + "i_dos_1_active": { + "default": "mdi:numeric-1-circle" + }, + "i_dos_2_active": { + "default": "mdi:numeric-2-circle" + } + }, + "time": { + "start_in_relative": { + "default": "mdi:progress-clock" + }, + "finish_in_relative": { + "default": "mdi:progress-clock" } } } diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 26c4aa02372..63df33e5432 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -3,7 +3,7 @@ import logging from typing import cast -from aiohomeconnect.model import GetSetting, SettingKey +from aiohomeconnect.model import GetSetting, OptionKey, SettingKey from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.number import ( @@ -11,6 +11,7 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) +from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -24,11 +25,17 @@ from .const import ( SVE_TRANSLATION_PLACEHOLDER_VALUE, ) from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) +UNIT_MAP = { + "seconds": UnitOfTime.SECONDS, + "ml": UnitOfVolume.MILLILITERS, + "°C": UnitOfTemperature.CELSIUS, + "°F": UnitOfTemperature.FAHRENHEIT, +} NUMBERS = ( NumberEntityDescription( @@ -88,6 +95,32 @@ NUMBERS = ( ), ) +NUMBER_OPTIONS = ( + NumberEntityDescription( + key=OptionKey.BSH_COMMON_DURATION, + translation_key="duration", + ), + NumberEntityDescription( + key=OptionKey.BSH_COMMON_FINISH_IN_RELATIVE, + translation_key="finish_in_relative", + ), + NumberEntityDescription( + key=OptionKey.BSH_COMMON_START_IN_RELATIVE, + translation_key="start_in_relative", + ), + NumberEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY, + translation_key="fill_quantity", + device_class=NumberDeviceClass.VOLUME, + native_step=1, + ), + NumberEntityDescription( + key=OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, + translation_key="setpoint_temperature", + device_class=NumberDeviceClass.TEMPERATURE, + ), +) + def _get_entities_for_appliance( entry: HomeConnectConfigEntry, @@ -101,6 +134,18 @@ def _get_entities_for_appliance( ] +def _get_option_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectOptionEntity]: + """Get a list of currently available option entities.""" + return [ + HomeConnectOptionNumberEntity(entry.runtime_data, appliance, description) + for description in NUMBER_OPTIONS + if description.key in appliance.options + ] + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -111,6 +156,7 @@ async def async_setup_entry( entry, _get_entities_for_appliance, async_add_entities, + _get_option_entities_for_appliance, ) @@ -184,3 +230,44 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): or not hasattr(self, "_attr_native_step") ): await self.async_fetch_constraints() + + +class HomeConnectOptionNumberEntity(HomeConnectOptionEntity, NumberEntity): + """Number option class for Home Connect.""" + + async def async_set_native_value(self, value: float) -> None: + """Set the native value of the entity.""" + await self.async_set_option(value) + + def update_native_value(self) -> None: + """Set the value of the entity.""" + self._attr_native_value = cast(float | None, self.option_value) + option_definition = self.appliance.options.get(self.bsh_key) + if option_definition: + if option_definition.unit: + candidate_unit = UNIT_MAP.get( + option_definition.unit, option_definition.unit + ) + if ( + not hasattr(self, "_attr_native_unit_of_measurement") + or candidate_unit != self._attr_native_unit_of_measurement + ): + self._attr_native_unit_of_measurement = candidate_unit + self.__dict__.pop("unit_of_measurement", None) + option_constraints = option_definition.constraints + if option_constraints: + if ( + not hasattr(self, "_attr_native_min_value") + or self._attr_native_min_value != option_constraints.min + ) and option_constraints.min: + self._attr_native_min_value = option_constraints.min + if ( + not hasattr(self, "_attr_native_max_value") + or self._attr_native_max_value != option_constraints.max + ) and option_constraints.max: + self._attr_native_max_value = option_constraints.max + if ( + not hasattr(self, "_attr_native_step") + or self._attr_native_step != option_constraints.step_size + ) and option_constraints.step_size: + self._attr_native_step = option_constraints.step_size diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index bc281e3d928..f5298056080 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient -from aiohomeconnect.model import EventKey, ProgramKey +from aiohomeconnect.model import EventKey, OptionKey, ProgramKey from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.program import Execution @@ -17,17 +17,32 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry from .const import ( APPLIANCES_WITH_PROGRAMS, + BEAN_AMOUNT_OPTIONS, + BEAN_CONTAINER_OPTIONS, + CLEANING_MODE_OPTIONS, + COFFEE_MILK_RATIO_OPTIONS, + COFFEE_TEMPERATURE_OPTIONS, DOMAIN, + DRYING_TARGET_OPTIONS, + FLOW_RATE_OPTIONS, + HOT_WATER_TEMPERATURE_OPTIONS, + INTENSIVE_LEVEL_OPTIONS, PROGRAMS_TRANSLATION_KEYS_MAP, + REFERENCE_MAP_ID_OPTIONS, + SPIN_SPEED_OPTIONS, SVE_TRANSLATION_PLACEHOLDER_PROGRAM, + TEMPERATURE_OPTIONS, TRANSLATION_KEYS_PROGRAMS_MAP, + VARIO_PERFECT_OPTIONS, + VENTING_LEVEL_OPTIONS, + WARMING_LEVEL_OPTIONS, ) from .coordinator import ( HomeConnectApplianceData, HomeConnectConfigEntry, HomeConnectCoordinator, ) -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity from .utils import get_dict_from_home_connect_error @@ -44,6 +59,16 @@ class HomeConnectProgramSelectEntityDescription( error_translation_key: str +@dataclass(frozen=True, kw_only=True) +class HomeConnectSelectOptionEntityDescription( + SelectEntityDescription, +): + """Entity Description class for options that have enumeration values.""" + + translation_key_values: dict[str, str] + values_translation_key: dict[str, str] + + PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( HomeConnectProgramSelectEntityDescription( key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, @@ -65,6 +90,159 @@ PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( ), ) +PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = ( + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID, + translation_key="reference_map_id", + options=list(REFERENCE_MAP_ID_OPTIONS.keys()), + translation_key_values=REFERENCE_MAP_ID_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in REFERENCE_MAP_ID_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, + translation_key="reference_map_id", + options=list(CLEANING_MODE_OPTIONS.keys()), + translation_key_values=CLEANING_MODE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in CLEANING_MODE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, + translation_key="bean_amount", + options=list(BEAN_AMOUNT_OPTIONS.keys()), + translation_key_values=BEAN_AMOUNT_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in BEAN_AMOUNT_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE, + translation_key="coffee_temperature", + options=list(COFFEE_TEMPERATURE_OPTIONS.keys()), + translation_key_values=COFFEE_TEMPERATURE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in COFFEE_TEMPERATURE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_CONTAINER_SELECTION, + translation_key="bean_container", + options=list(BEAN_CONTAINER_OPTIONS.keys()), + translation_key_values=BEAN_CONTAINER_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in BEAN_CONTAINER_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE, + translation_key="flow_rate", + options=list(FLOW_RATE_OPTIONS.keys()), + translation_key_values=FLOW_RATE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in FLOW_RATE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO, + translation_key="coffee_milk_ratio", + options=list(COFFEE_MILK_RATIO_OPTIONS.keys()), + translation_key_values=COFFEE_MILK_RATIO_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in FLOW_RATE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE, + translation_key="hot_water_temperature", + options=list(HOT_WATER_TEMPERATURE_OPTIONS.keys()), + translation_key_values=HOT_WATER_TEMPERATURE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in HOT_WATER_TEMPERATURE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.LAUNDRY_CARE_DRYER_DRYING_TARGET, + translation_key="drying_target", + options=list(DRYING_TARGET_OPTIONS.keys()), + translation_key_values=DRYING_TARGET_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in DRYING_TARGET_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL, + translation_key="venting_level", + options=list(VENTING_LEVEL_OPTIONS.keys()), + translation_key_values=VENTING_LEVEL_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in VENTING_LEVEL_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL, + translation_key="intensive_level", + options=list(INTENSIVE_LEVEL_OPTIONS.keys()), + translation_key_values=INTENSIVE_LEVEL_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in INTENSIVE_LEVEL_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.COOKING_OVEN_WARMING_LEVEL, + translation_key="warming_level", + options=list(WARMING_LEVEL_OPTIONS.keys()), + translation_key_values=WARMING_LEVEL_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in WARMING_LEVEL_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + translation_key="washer_temperature", + options=list(TEMPERATURE_OPTIONS.keys()), + translation_key_values=TEMPERATURE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in TEMPERATURE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED, + translation_key="spin_speed", + options=list(SPIN_SPEED_OPTIONS.keys()), + translation_key_values=SPIN_SPEED_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in SPIN_SPEED_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT, + translation_key="vario_perfect", + options=list(VARIO_PERFECT_OPTIONS.keys()), + translation_key_values=VARIO_PERFECT_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in VARIO_PERFECT_OPTIONS.items() + }, + ), +) + def _get_entities_for_appliance( entry: HomeConnectConfigEntry, @@ -81,6 +259,18 @@ def _get_entities_for_appliance( ) +def _get_option_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectOptionEntity]: + """Get a list of entities.""" + return [ + HomeConnectSelectOptionEntity(entry.runtime_data, appliance, desc) + for desc in PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS + if desc.key in appliance.options + ] + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -91,6 +281,7 @@ async def async_setup_entry( entry, _get_entities_for_appliance, async_add_entities, + _get_option_entities_for_appliance, ) @@ -148,3 +339,53 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value, }, ) from err + + +class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): + """Select option class for Home Connect.""" + + entity_description: HomeConnectSelectOptionEntityDescription + _original_option_keys: set[str | None] + + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: HomeConnectSelectOptionEntityDescription, + ) -> None: + """Initialize the entity.""" + self._original_option_keys = set(desc.values_translation_key.keys()) + super().__init__( + coordinator, + appliance, + desc, + ) + + async def async_select_option(self, option: str) -> None: + """Select new option.""" + await self.async_set_option( + self.entity_description.translation_key_values[option] + ) + + def update_native_value(self) -> None: + """Set the value of the entity.""" + self._attr_current_option = ( + self.entity_description.values_translation_key.get( + cast(str, self.option_value), None + ) + if self.option_value is not None + else None + ) + if ( + (option_definition := self.appliance.options.get(self.bsh_key)) + and (option_constraints := option_definition.constraints) + and option_constraints.allowed_values + and self._original_option_keys != set(option_constraints.allowed_values) + ): + self._original_option_keys = set(option_constraints.allowed_values) + self._attr_options = [ + self.entity_description.values_translation_key[option] + for option in self._original_option_keys + if option is not None + ] + self.__dict__.pop("options", None) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index d9f45c8c31d..88dd017e7d9 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime, UnitOfVolume +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfVolume from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util, slugify @@ -56,12 +56,6 @@ BSH_PROGRAM_SENSORS = ( "WasherDryer", ), ), - HomeConnectSensorEntityDescription( - key=EventKey.BSH_COMMON_OPTION_DURATION, - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.SECONDS, - appliance_types=("Oven",), - ), HomeConnectSensorEntityDescription( key=EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS, native_unit_of_measurement=PERCENTAGE, diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 3ac9f90ba81..8a4dd68530f 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -98,6 +98,9 @@ }, "required_program_or_one_option_at_least": { "message": "A program or at least one of the possible options for a program should be specified" + }, + "set_option": { + "message": "Error setting the option for the program: {error}" } }, "issues": { @@ -859,6 +862,21 @@ }, "washer_i_dos_2_base_level": { "name": "i-Dos 2 base level" + }, + "duration": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_duration::name%]" + }, + "start_in_relative": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_start_in_relative::name%]" + }, + "finish_in_relative": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_finish_in_relative::name%]" + }, + "fill_quantity": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_fill_quantity::name%]" + }, + "setpoint_temperature": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_setpoint_temperature::name%]" } }, "select": { @@ -1179,6 +1197,200 @@ "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%]" } + }, + "reference_map_id": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_reference_map_id::name%]", + "state": { + "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]" + } + }, + "cleaning_mode": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_cleaning_mode::name%]", + "state": { + "consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_silent%]", + "consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_standard%]", + "consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_power%]" + } + }, + "bean_amount": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_amount::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_bean_amount_very_mild": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_mild%]", + "consumer_products_coffee_maker_enum_type_bean_amount_mild": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_mild%]", + "consumer_products_coffee_maker_enum_type_bean_amount_mild_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_mild_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_normal": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_normal%]", + "consumer_products_coffee_maker_enum_type_bean_amount_normal_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_normal_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_strong%]", + "consumer_products_coffee_maker_enum_type_bean_amount_strong_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_strong_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_very_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_strong%]", + "consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_extra_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_extra_strong%]", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot%]", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_triple_shot%]", + "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground%]" + } + }, + "coffee_temperature": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_coffee_temperature::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_coffee_temperature_88_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_88_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_90_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_90_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_92_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_92_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_94_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_94_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_95_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_95_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_96_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_96_c%]" + } + }, + "bean_container": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_bean_container_selection_right": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_right%]", + "consumer_products_coffee_maker_enum_type_bean_container_selection_left": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_left%]" + } + }, + "flow_rate": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_flow_rate::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_flow_rate_normal": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_normal%]", + "consumer_products_coffee_maker_enum_type_flow_rate_intense": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_intense%]", + "consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_intense_plus%]" + } + }, + "coffee_milk_ratio": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_coffee_milk_ratio::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent": "[%key:component::home_connect::selector::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": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent%]" + } + }, + "hot_water_temperature": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_hot_water_temperature::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": "[%key:component::home_connect::selector::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": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_max": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_max%]" + } + }, + "drying_target": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_dryer_option_drying_target::name%]", + "state": { + "laundry_care_dryer_enum_type_drying_target_iron_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_iron_dry%]", + "laundry_care_dryer_enum_type_drying_target_gentle_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_gentle_dry%]", + "laundry_care_dryer_enum_type_drying_target_cupboard_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_cupboard_dry%]", + "laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus%]", + "laundry_care_dryer_enum_type_drying_target_extra_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_extra_dry%]" + } + }, + "venting_level": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]", + "state": { + "cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]", + "cooking_hood_enum_type_stage_fan_stage01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage01%]", + "cooking_hood_enum_type_stage_fan_stage02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage02%]", + "cooking_hood_enum_type_stage_fan_stage03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage03%]", + "cooking_hood_enum_type_stage_fan_stage04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage04%]", + "cooking_hood_enum_type_stage_fan_stage05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage05%]" + } + }, + "intensive_level": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_intensive_level::name%]", + "state": { + "cooking_hood_enum_type_intensive_stage_intensive_stage_off": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_off%]", + "cooking_hood_enum_type_intensive_stage_intensive_stage1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage1%]", + "cooking_hood_enum_type_intensive_stage_intensive_stage2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage2%]" + } + }, + "warming_level": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_warming_level::name%]", + "state": { + "cooking_oven_enum_type_warming_level_low": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_low%]", + "cooking_oven_enum_type_warming_level_medium": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_medium%]", + "cooking_oven_enum_type_warming_level_high": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_high%]" + } + }, + "washer_temperature": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_temperature::name%]", + "state": { + "laundry_care_washer_enum_type_temperature_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_cold%]", + "laundry_care_washer_enum_type_temperature_g_c20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c20%]", + "laundry_care_washer_enum_type_temperature_g_c30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c30%]", + "laundry_care_washer_enum_type_temperature_g_c40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c40%]", + "laundry_care_washer_enum_type_temperature_g_c50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c50%]", + "laundry_care_washer_enum_type_temperature_g_c60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c60%]", + "laundry_care_washer_enum_type_temperature_g_c70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c70%]", + "laundry_care_washer_enum_type_temperature_g_c80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c80%]", + "laundry_care_washer_enum_type_temperature_g_c90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c90%]", + "laundry_care_washer_enum_type_temperature_ul_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_cold%]", + "laundry_care_washer_enum_type_temperature_ul_warm": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_warm%]", + "laundry_care_washer_enum_type_temperature_ul_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_hot%]", + "laundry_care_washer_enum_type_temperature_ul_extra_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_extra_hot%]" + } + }, + "spin_speed": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_spin_speed::name%]", + "state": { + "laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m400%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m600%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m800%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1000%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1200%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1400%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1600%]", + "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_off%]", + "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]", + "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]", + "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_high%]" + } + }, + "vario_perfect": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]", + "state": { + "laundry_care_common_enum_type_vario_perfect_off": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_off%]", + "laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]", + "laundry_care_common_enum_type_vario_perfect_speed_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_speed_perfect%]" + } } }, "sensor": { @@ -1365,6 +1577,45 @@ }, "door_assistant_freezer": { "name": "Freezer door assistant" + }, + "multiple_beverages": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_multiple_beverages::name%]" + }, + "intensiv_zone": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_intensiv_zone::name%]" + }, + "brilliance_dry": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_brilliance_dry::name%]" + }, + "vario_speed_plus": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_vario_speed_plus::name%]" + }, + "silence_on_demand": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_silence_on_demand::name%]" + }, + "half_load": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_half_load::name%]" + }, + "extra_dry": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_extra_dry::name%]" + }, + "hygiene_plus": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_hygiene_plus::name%]" + }, + "eco_dry": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_eco_dry::name%]" + }, + "zeolite_dry": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_zeolite_dry::name%]" + }, + "fast_pre_heat": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_fast_pre_heat::name%]" + }, + "i_dos1_active": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos1_active::name%]" + }, + "i_dos2_active": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos2_active::name%]" } }, "time": { diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 7dc375f430d..d5a92eef2a4 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -3,7 +3,7 @@ import logging from typing import Any, cast -from aiohomeconnect.model import EventKey, ProgramKey, SettingKey +from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.program import EnumerateProgram @@ -37,7 +37,7 @@ from .coordinator import ( HomeConnectConfigEntry, HomeConnectCoordinator, ) -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -100,6 +100,61 @@ POWER_SWITCH_DESCRIPTION = SwitchEntityDescription( translation_key="power", ) +SWITCH_OPTIONS = ( + SwitchEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES, + translation_key="multiple_beverages", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE, + translation_key="intensiv_zone", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY, + translation_key="brilliance_dry", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS, + translation_key="vario_speed_plus", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND, + translation_key="silence_on_demand", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, + translation_key="half_load", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY, + translation_key="extra_dry", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS, + translation_key="hygiene_plus", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_ECO_DRY, + translation_key="eco_dry", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY, + translation_key="zeolite_dry", + ), + SwitchEntityDescription( + key=OptionKey.COOKING_OVEN_FAST_PRE_HEAT, + translation_key="fast_pre_heat", + ), + SwitchEntityDescription( + key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE, + translation_key="i_dos1_active", + ), + SwitchEntityDescription( + key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE, + translation_key="i_dos2_active", + ), +) + def _get_entities_for_appliance( entry: HomeConnectConfigEntry, @@ -123,10 +178,21 @@ def _get_entities_for_appliance( for description in SWITCHES if description.key in appliance.settings ) - return entities +def _get_option_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectOptionEntity]: + """Get a list of currently available option entities.""" + return [ + HomeConnectSwitchOptionEntity(entry.runtime_data, appliance, description) + for description in SWITCH_OPTIONS + if description.key in appliance.options + ] + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -137,6 +203,7 @@ async def async_setup_entry( entry, _get_entities_for_appliance, async_add_entities, + _get_option_entities_for_appliance, ) @@ -403,3 +470,19 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): self.power_off_state = BSH_POWER_STANDBY else: self.power_off_state = None + + +class HomeConnectSwitchOptionEntity(HomeConnectOptionEntity, SwitchEntity): + """Switch option class for Home Connect.""" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the option.""" + await self.async_set_option(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the option.""" + await self.async_set_option(False) + + def update_native_value(self) -> None: + """Set the value of the entity.""" + self._attr_is_on = cast(bool | None, self.option_value) diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 7b74c2290c3..e0d60dc8614 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -23,6 +23,8 @@ from aiohomeconnect.model import ( HomeAppliance, Option, Program, + ProgramDefinition, + ProgramKey, SettingKey, ) from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError @@ -339,6 +341,29 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: mock.add_events = add_events + async def set_program_option_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs["option_key"]) + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=kwargs["value"], + ) + ] + ), + ), + ] + ) + async def stream_all_events() -> AsyncGenerator[EventMessage]: """Mock stream_all_events.""" while True: @@ -380,6 +405,17 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS)) mock.get_all_programs = AsyncMock(side_effect=_get_all_programs_side_effect) mock.put_command = AsyncMock() + mock.get_available_program = AsyncMock( + return_value=ProgramDefinition(ProgramKey.UNKNOWN, options=[]) + ) + mock.get_active_program_options = AsyncMock(return_value=ArrayOfOptions([])) + mock.get_selected_program_options = AsyncMock(return_value=ArrayOfOptions([])) + mock.set_active_program_option = AsyncMock( + side_effect=set_program_option_side_effect + ) + mock.set_selected_program_option = AsyncMock( + side_effect=set_program_option_side_effect + ) mock.side_effect = mock return mock @@ -420,6 +456,11 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.get_status = AsyncMock(side_effect=exception) mock.get_all_programs = AsyncMock(side_effect=exception) mock.put_command = AsyncMock(side_effect=exception) + mock.get_available_program = AsyncMock(side_effect=exception) + mock.get_active_program_options = AsyncMock(side_effect=exception) + mock.get_selected_program_options = AsyncMock(side_effect=exception) + mock.set_active_program_option = AsyncMock(side_effect=exception) + mock.set_selected_program_option = AsyncMock(side_effect=exception) return mock diff --git a/tests/components/home_connect/fixtures/settings.json b/tests/components/home_connect/fixtures/settings.json index a357d8fb43e..8f649e5790b 100644 --- a/tests/components/home_connect/fixtures/settings.json +++ b/tests/components/home_connect/fixtures/settings.json @@ -124,6 +124,11 @@ "key": "BSH.Common.Setting.ChildLock", "value": false, "type": "Boolean" + }, + { + "key": "LaundryCare.Washer.Setting.IDos2BaseLevel", + "value": 0, + "type": "Integer" } ] } diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index f3c73a32d95..512da8bd970 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -272,6 +272,7 @@ 'settings': dict({ 'BSH.Common.Setting.ChildLock': False, 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', + 'LaundryCare.Washer.Setting.IDos2BaseLevel': 0, }), 'status': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py new file mode 100644 index 00000000000..272fc21ba62 --- /dev/null +++ b/tests/components/home_connect/test_entity.py @@ -0,0 +1,299 @@ +"""Tests for Home Connect entity base classes.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock, MagicMock + +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfPrograms, + Event, + EventKey, + EventMessage, + EventType, + Option, + OptionKey, + Program, + ProgramDefinition, + ProgramKey, +) +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectError, + SelectedProgramNotSetError, +) +from aiohomeconnect.model.program import ( + ProgramDefinitionConstraints, + ProgramDefinitionOption, +) +import pytest + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SWITCH] + + +@pytest.mark.parametrize( + ("array_of_programs_program_arg", "event_key"), + [ + ( + "active", + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + ), + ( + "selected", + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ), + ], +) +@pytest.mark.parametrize( + ( + "appliance_ha_id", + "option_entity_id", + "options_state_stage_1", + "options_availability_stage_2", + "option_without_default", + "option_without_constraints", + ), + [ + ( + "Dishwasher", + { + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: "switch.dishwasher_half_load", + OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: "switch.dishwasher_silence_on_demand", + OptionKey.DISHCARE_DISHWASHER_ECO_DRY: "switch.dishwasher_eco_dry", + }, + [(STATE_ON, True), (STATE_OFF, False), (None, None)], + [False, True, True], + ( + OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS, + "switch.dishwasher_hygiene_plus", + ), + (OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY, "switch.dishwasher_extra_dry"), + ) + ], + indirect=["appliance_ha_id"], +) +async def test_program_options_retrieval( + array_of_programs_program_arg: str, + event_key: EventKey, + appliance_ha_id: str, + option_entity_id: dict[OptionKey, str], + options_state_stage_1: list[tuple[str, bool | None]], + options_availability_stage_2: list[bool], + option_without_default: tuple[OptionKey, str], + option_without_constraints: tuple[OptionKey, str], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the options are correctly retrieved at the start and updated on program updates.""" + original_get_all_programs_mock = client.get_all_programs.side_effect + options_values = [ + Option( + option_key, + value, + ) + for option_key, (_, value) in zip( + option_entity_id.keys(), options_state_stage_1, strict=True + ) + if value is not None + ] + + async def get_all_programs_with_options_mock(ha_id: str) -> ArrayOfPrograms: + if ha_id != appliance_ha_id: + return await original_get_all_programs_mock(ha_id) + + array_of_programs: ArrayOfPrograms = await original_get_all_programs_mock(ha_id) + return ArrayOfPrograms( + **( + { + "programs": array_of_programs.programs, + array_of_programs_program_arg: Program( + array_of_programs.programs[0].key, options=options_values + ), + } + ) + ) + + client.get_all_programs = AsyncMock(side_effect=get_all_programs_with_options_mock) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Boolean", + constraints=ProgramDefinitionConstraints( + default=False, + ), + ) + for option_key, (_, value) in zip( + option_entity_id.keys(), options_state_stage_1, strict=True + ) + if value is not None + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id, (state, _) in zip( + option_entity_id.values(), options_state_stage_1, strict=True + ): + if state is not None: + assert hass.states.is_state(entity_id, state) + else: + assert not hass.states.get(entity_id) + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + *[ + ProgramDefinitionOption( + option_key, + "Boolean", + constraints=ProgramDefinitionConstraints( + default=False, + ), + ) + for option_key, available in zip( + option_entity_id.keys(), + options_availability_stage_2, + strict=True, + ) + if available + ], + ProgramDefinitionOption( + option_without_default[0], + "Boolean", + constraints=ProgramDefinitionConstraints(), + ), + ProgramDefinitionOption( + option_without_constraints[0], + "Boolean", + ), + ], + ) + ) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.DISHCARE_DISHWASHER_AUTO_1, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + # Verify default values + # Every time the program is updated, the available options should use the default value if existing + for entity_id, available in zip( + option_entity_id.values(), options_availability_stage_2, strict=True + ): + assert hass.states.is_state( + entity_id, STATE_OFF if available else STATE_UNAVAILABLE + ) + for _, entity_id in (option_without_default, option_without_constraints): + assert hass.states.is_state(entity_id, STATE_UNKNOWN) + + +@pytest.mark.parametrize( + ( + "set_active_program_option_side_effect", + "set_selected_program_option_side_effect", + ), + [ + ( + ActiveProgramNotSetError("error.key"), + SelectedProgramNotSetError("error.key"), + ), + ( + HomeConnectError(), + None, + ), + ( + ActiveProgramNotSetError("error.key"), + HomeConnectError(), + ), + ], +) +async def test_option_entity_functionality_exception( + set_active_program_option_side_effect: HomeConnectError | None, + set_selected_program_option_side_effect: HomeConnectError | None, + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the option entity handles exceptions correctly.""" + entity_id = "switch.washer_i_dos_1_active" + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE, + "Boolean", + ) + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.get(entity_id) + + if set_active_program_option_side_effect: + client.set_active_program_option = AsyncMock( + side_effect=set_active_program_option_side_effect + ) + if set_selected_program_option_side_effect: + client.set_selected_program_option = AsyncMock( + side_effect=set_selected_program_option_side_effect + ) + + with pytest.raises(HomeAssistantError, match=r"Error.*setting.*option.*"): + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index edab86cf819..214dcb6137c 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -7,17 +7,34 @@ from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, ArrayOfSettings, + Event, + EventKey, EventMessage, EventType, GetSetting, + OptionKey, + ProgramDefinition, + ProgramKey, SettingKey, ) -from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectApiError, + HomeConnectError, + SelectedProgramNotSetError, +) +from aiohomeconnect.model.program import ( + ProgramDefinitionConstraints, + ProgramDefinitionOption, +) from aiohomeconnect.model.setting import SettingConstraints import pytest from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.number import ( + ATTR_MAX, + ATTR_MIN, + ATTR_STEP, ATTR_VALUE as SERVICE_ATTR_VALUE, DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, @@ -51,7 +68,6 @@ async def test_number( assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) async def test_paired_depaired_devices_flow( appliance_ha_id: str, hass: HomeAssistant, @@ -63,6 +79,17 @@ async def test_paired_depaired_devices_flow( entity_registry: er.EntityRegistry, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.BSH_COMMON_FINISH_IN_RELATIVE, + "Integer", + ) + ], + ) + ) assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @@ -369,3 +396,135 @@ async def test_number_entity_error( blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 + + +@pytest.mark.parametrize( + ( + "set_active_program_options_side_effect", + "set_selected_program_options_side_effect", + "called_mock_method", + ), + [ + ( + None, + SelectedProgramNotSetError("error.key"), + "set_active_program_option", + ), + ( + ActiveProgramNotSetError("error.key"), + None, + "set_selected_program_option", + ), + ], +) +@pytest.mark.parametrize( + ("appliance_ha_id", "entity_id", "option_key", "min", "max", "step_size", "unit"), + [ + ( + "Oven", + "number.oven_setpoint_temperature", + OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, + 50, + 260, + 1, + "°C", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_options_functionality( + entity_id: str, + option_key: OptionKey, + appliance_ha_id: str, + min: int, + max: int, + step_size: int, + unit: str, + set_active_program_options_side_effect: ActiveProgramNotSetError | None, + set_selected_program_options_side_effect: SelectedProgramNotSetError | None, + called_mock_method: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test options functionality.""" + + async def set_program_option_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs["option_key"]) + await client.add_events( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=kwargs["value"], + unit=unit, + ) + ] + ), + ), + ] + ) + + called_mock = AsyncMock(side_effect=set_program_option_side_effect) + if set_active_program_options_side_effect: + client.set_active_program_option.side_effect = ( + set_active_program_options_side_effect + ) + else: + assert set_selected_program_options_side_effect + client.set_selected_program_option.side_effect = ( + set_selected_program_options_side_effect + ) + setattr(client, called_mock_method, called_mock) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Double", + unit=unit, + constraints=ProgramDefinitionConstraints( + min=min, + max=max, + step_size=step_size, + ), + ) + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.attributes["unit_of_measurement"] == unit + assert entity_state.attributes[ATTR_MIN] == min + assert entity_state.attributes[ATTR_MAX] == max + assert entity_state.attributes[ATTR_STEP] == step_size + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, SERVICE_ATTR_VALUE: 80}, + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": 80, + } + assert hass.states.is_state(entity_id, "80.0") diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index a1e6fafd768..917c092136e 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -1,7 +1,7 @@ """Tests for home_connect select entities.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, @@ -10,13 +10,21 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, + OptionKey, + ProgramDefinition, ProgramKey, ) -from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectError, + SelectedProgramNotSetError, +) from aiohomeconnect.model.program import ( EnumerateProgram, EnumerateProgramConstraints, Execution, + ProgramDefinitionConstraints, + ProgramDefinitionOption, ) import pytest @@ -70,6 +78,17 @@ async def test_paired_depaired_devices_flow( entity_registry: er.EntityRegistry, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + "Enumeration", + ) + ], + ) + ) assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @@ -413,3 +432,132 @@ async def test_select_exception_handling( blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 + + +@pytest.mark.parametrize( + ( + "set_active_program_options_side_effect", + "set_selected_program_options_side_effect", + "called_mock_method", + ), + [ + ( + None, + SelectedProgramNotSetError("error.key"), + "set_active_program_option", + ), + ( + ActiveProgramNotSetError("error.key"), + None, + "set_selected_program_option", + ), + ], +) +@pytest.mark.parametrize( + ("entity_id", "option_key", "allowed_values", "expected_options"), + [ + ( + "select.washer_temperature", + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + None, + { + "laundry_care_washer_enum_type_temperature_cold", + "laundry_care_washer_enum_type_temperature_g_c_20", + "laundry_care_washer_enum_type_temperature_g_c_30", + "laundry_care_washer_enum_type_temperature_g_c_40", + "laundry_care_washer_enum_type_temperature_g_c_50", + "laundry_care_washer_enum_type_temperature_g_c_60", + "laundry_care_washer_enum_type_temperature_g_c_70", + "laundry_care_washer_enum_type_temperature_g_c_80", + "laundry_care_washer_enum_type_temperature_g_c_90", + "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", + }, + ), + ( + "select.washer_temperature", + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + [ + "LaundryCare.Washer.EnumType.Temperature.UlCold", + "LaundryCare.Washer.EnumType.Temperature.UlWarm", + "LaundryCare.Washer.EnumType.Temperature.UlHot", + "LaundryCare.Washer.EnumType.Temperature.UlExtraHot", + ], + { + "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", + }, + ), + ], +) +async def test_options_functionality( + entity_id: str, + option_key: OptionKey, + allowed_values: list[str | None] | None, + expected_options: set[str], + appliance_ha_id: str, + set_active_program_options_side_effect: ActiveProgramNotSetError | None, + set_selected_program_options_side_effect: SelectedProgramNotSetError | None, + called_mock_method: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test options functionality.""" + if set_active_program_options_side_effect: + client.set_active_program_option.side_effect = ( + set_active_program_options_side_effect + ) + else: + assert set_selected_program_options_side_effect + client.set_selected_program_option.side_effect = ( + set_selected_program_options_side_effect + ) + called_mock: AsyncMock = getattr(client, called_mock_method) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Enumeration", + constraints=ProgramDefinitionConstraints( + allowed_values=allowed_values + ), + ) + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "laundry_care_washer_enum_type_temperature_ul_warm", + }, + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": "LaundryCare.Washer.EnumType.Temperature.UlWarm", + } + assert hass.states.is_state( + entity_id, "laundry_care_washer_enum_type_temperature_ul_warm" + ) diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index d4e0f999197..1b38809dc05 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -5,17 +5,26 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfPrograms, ArrayOfSettings, Event, EventKey, EventMessage, + EventType, GetSetting, + OptionKey, + ProgramDefinition, ProgramKey, SettingKey, ) -from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError -from aiohomeconnect.model.event import ArrayOfEvents, EventType -from aiohomeconnect.model.program import ArrayOfPrograms, EnumerateProgram +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectApiError, + HomeConnectError, + SelectedProgramNotSetError, +) +from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption from aiohomeconnect.model.setting import SettingConstraints import pytest @@ -81,6 +90,17 @@ async def test_paired_depaired_devices_flow( entity_registry: er.EntityRegistry, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE, + "Boolean", + ) + ], + ) + ) assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @@ -840,3 +860,95 @@ async def test_create_issue( # 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( + ( + "set_active_program_options_side_effect", + "set_selected_program_options_side_effect", + "called_mock_method", + ), + [ + ( + None, + SelectedProgramNotSetError("error.key"), + "set_active_program_option", + ), + ( + ActiveProgramNotSetError("error.key"), + None, + "set_selected_program_option", + ), + ], +) +@pytest.mark.parametrize( + ("entity_id", "option_key", "appliance_ha_id"), + [ + ( + "switch.dishwasher_half_load", + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, + "Dishwasher", + ) + ], + indirect=["appliance_ha_id"], +) +async def test_options_functionality( + entity_id: str, + option_key: OptionKey, + appliance_ha_id: str, + set_active_program_options_side_effect: ActiveProgramNotSetError | None, + set_selected_program_options_side_effect: SelectedProgramNotSetError | None, + called_mock_method: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test options functionality.""" + if set_active_program_options_side_effect: + client.set_active_program_option.side_effect = ( + set_active_program_options_side_effect + ) + else: + assert set_selected_program_options_side_effect + client.set_selected_program_option.side_effect = ( + set_selected_program_options_side_effect + ) + called_mock: AsyncMock = getattr(client, called_mock_method) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, options=[ProgramDefinitionOption(option_key, "Boolean")] + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + assert hass.states.get(entity_id) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": False, + } + assert hass.states.is_state(entity_id, STATE_OFF) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": True, + } + assert hass.states.is_state(entity_id, STATE_ON) From 98c6a578b7da32fb4da67c37693244f73311aed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 22 Feb 2025 21:14:11 +0100 Subject: [PATCH 2644/2987] Add buttons to Home Connect (#138792) * Add buttons * Fix stale documentation --- .../components/home_connect/__init__.py | 1 + .../components/home_connect/button.py | 160 +++++++++ .../components/home_connect/coordinator.py | 14 + .../components/home_connect/strings.json | 17 + tests/components/home_connect/conftest.py | 18 + .../fixtures/available_commands.json | 142 ++++++++ tests/components/home_connect/test_button.py | 315 ++++++++++++++++++ 7 files changed, 667 insertions(+) create mode 100644 homeassistant/components/home_connect/button.py create mode 100644 tests/components/home_connect/fixtures/available_commands.json create mode 100644 tests/components/home_connect/test_button.py diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index b4ceb11be92..637fd7aa3a8 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -187,6 +187,7 @@ SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.LIGHT, Platform.NUMBER, Platform.SELECT, diff --git a/homeassistant/components/home_connect/button.py b/homeassistant/components/home_connect/button.py new file mode 100644 index 00000000000..138979409a5 --- /dev/null +++ b/homeassistant/components/home_connect/button.py @@ -0,0 +1,160 @@ +"""Provides button entities for Home Connect.""" + +from aiohomeconnect.model import CommandKey, EventKey +from aiohomeconnect.model.error import HomeConnectError + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +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 +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, +) +from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error + + +class HomeConnectCommandButtonEntityDescription(ButtonEntityDescription): + """Describes Home Connect button entity.""" + + key: CommandKey + + +COMMAND_BUTTONS = ( + HomeConnectCommandButtonEntityDescription( + key=CommandKey.BSH_COMMON_OPEN_DOOR, + translation_key="open_door", + ), + HomeConnectCommandButtonEntityDescription( + key=CommandKey.BSH_COMMON_PARTLY_OPEN_DOOR, + translation_key="partly_open_door", + ), + HomeConnectCommandButtonEntityDescription( + key=CommandKey.BSH_COMMON_PAUSE_PROGRAM, + translation_key="pause_program", + ), + HomeConnectCommandButtonEntityDescription( + key=CommandKey.BSH_COMMON_RESUME_PROGRAM, + translation_key="resume_program", + ), +) + + +def _get_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectEntity]: + """Get a list of entities.""" + entities: list[HomeConnectEntity] = [] + entities.extend( + HomeConnectCommandButtonEntity(entry.runtime_data, appliance, description) + for description in COMMAND_BUTTONS + if description.key in appliance.commands + ) + if appliance.info.type in APPLIANCES_WITH_PROGRAMS: + entities.append( + HomeConnectStopProgramButtonEntity(entry.runtime_data, appliance) + ) + + return entities + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HomeConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Home Connect button entities.""" + setup_home_connect_entry( + entry, + _get_entities_for_appliance, + async_add_entities, + ) + + +class HomeConnectButtonEntity(HomeConnectEntity, ButtonEntity): + """Describes Home Connect button entity.""" + + entity_description: ButtonEntityDescription + + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: ButtonEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__( + coordinator, + appliance, + # The entity is subscribed to the appliance connected event, + # but it will receive also the disconnected event + ButtonEntityDescription( + key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED, + ), + ) + self.entity_description = desc + self.appliance = appliance + self.unique_id = f"{appliance.info.ha_id}-{desc.key}" + + def update_native_value(self) -> None: + """Set the value of the entity.""" + + +class HomeConnectCommandButtonEntity(HomeConnectButtonEntity): + """Button entity for Home Connect commands.""" + + entity_description: HomeConnectCommandButtonEntityDescription + + async def async_press(self) -> None: + """Press the button.""" + try: + await self.coordinator.client.put_command( + self.appliance.info.ha_id, + command_key=self.entity_description.key, + value=True, + ) + except HomeConnectError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="execute_command", + translation_placeholders={ + **get_dict_from_home_connect_error(error), + "command": self.entity_description.key, + }, + ) from error + + +class HomeConnectStopProgramButtonEntity(HomeConnectButtonEntity): + """Button entity for stopping a program.""" + + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + ) -> None: + """Initialize the entity.""" + super().__init__( + coordinator, + appliance, + ButtonEntityDescription( + key="StopProgram", + translation_key="stop_program", + ), + ) + + async def async_press(self) -> None: + """Press the button.""" + try: + await self.coordinator.client.stop_program(self.appliance.info.ha_id) + except HomeConnectError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="stop_program", + translation_placeholders=get_dict_from_home_connect_error(error), + ) from error diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index b5f0f711597..80ae8173d86 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -11,6 +11,7 @@ from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( + CommandKey, Event, EventKey, EventMessage, @@ -53,6 +54,7 @@ EVENT_STREAM_RECONNECT_DELAY = 30 class HomeConnectApplianceData: """Class to hold Home Connect appliance data.""" + commands: set[CommandKey] events: dict[EventKey, Event] info: HomeAppliance options: dict[OptionKey, ProgramDefinitionOption] @@ -62,6 +64,7 @@ class HomeConnectApplianceData: def update(self, other: HomeConnectApplianceData) -> None: """Update data with data from other instance.""" + self.commands.update(other.commands) self.events.update(other.events) self.info.connected = other.info.connected self.options.clear() @@ -408,7 +411,18 @@ class HomeConnectCoordinator( unit=option.unit, ) + try: + commands = { + command.key + for command in ( + await self.client.get_available_commands(appliance.ha_id) + ).commands + } + except HomeConnectError: + commands = set() + appliance_data = HomeConnectApplianceData( + commands=commands, events=events, info=appliance, options=options, diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 8a4dd68530f..db53e76fb95 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -815,6 +815,23 @@ "name": "Wine compartment door" } }, + "button": { + "open_door": { + "name": "Open door" + }, + "partly_open_door": { + "name": "Partly open door" + }, + "pause_program": { + "name": "Pause program" + }, + "resume_program": { + "name": "Resume program" + }, + "stop_program": { + "name": "Stop program" + } + }, "light": { "cooking_lighting": { "name": "Functional light" diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index e0d60dc8614..49cbc89ba41 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( + ArrayOfCommands, ArrayOfEvents, ArrayOfHomeAppliances, ArrayOfOptions, @@ -50,6 +51,9 @@ MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings. MOCK_STATUS = ArrayOfStatus.from_dict( load_json_object_fixture("home_connect/status.json")["data"] ) +MOCK_AVAILABLE_COMMANDS: dict[str, Any] = load_json_object_fixture( + "home_connect/available_commands.json" +) CLIENT_ID = "1234" @@ -326,6 +330,14 @@ async def _get_setting_side_effect(ha_id: str, setting_key: SettingKey): raise HomeConnectApiError("error.key", "error description") +async def _get_available_commands_side_effect(ha_id: str) -> ArrayOfCommands: + """Get available commands.""" + for appliance in MOCK_APPLIANCES.homeappliances: + if appliance.ha_id == ha_id and appliance.type in MOCK_AVAILABLE_COMMANDS: + return ArrayOfCommands.from_dict(MOCK_AVAILABLE_COMMANDS[appliance.type]) + raise HomeConnectApiError("error.key", "error description") + + @pytest.fixture(name="client") def mock_client(request: pytest.FixtureRequest) -> MagicMock: """Fixture to mock Client from HomeConnect.""" @@ -385,6 +397,7 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: event_queue, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM ), ) + mock.stop_program = AsyncMock() mock.set_active_program_option = AsyncMock( side_effect=_get_set_program_options_side_effect(event_queue), ) @@ -404,6 +417,9 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: mock.get_setting = AsyncMock(side_effect=_get_setting_side_effect) mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS)) mock.get_all_programs = AsyncMock(side_effect=_get_all_programs_side_effect) + mock.get_available_commands = AsyncMock( + side_effect=_get_available_commands_side_effect + ) mock.put_command = AsyncMock() mock.get_available_program = AsyncMock( return_value=ProgramDefinition(ProgramKey.UNKNOWN, options=[]) @@ -446,6 +462,7 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.start_program = AsyncMock(side_effect=exception) mock.stop_program = AsyncMock(side_effect=exception) mock.set_selected_program = AsyncMock(side_effect=exception) + mock.stop_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) @@ -455,6 +472,7 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.get_setting = AsyncMock(side_effect=exception) mock.get_status = AsyncMock(side_effect=exception) mock.get_all_programs = AsyncMock(side_effect=exception) + mock.get_available_commands = AsyncMock(side_effect=exception) mock.put_command = AsyncMock(side_effect=exception) mock.get_available_program = AsyncMock(side_effect=exception) mock.get_active_program_options = AsyncMock(side_effect=exception) diff --git a/tests/components/home_connect/fixtures/available_commands.json b/tests/components/home_connect/fixtures/available_commands.json new file mode 100644 index 00000000000..e4ed6c21b7c --- /dev/null +++ b/tests/components/home_connect/fixtures/available_commands.json @@ -0,0 +1,142 @@ +{ + "Cooktop": { + "commands": [ + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Hood": { + "commands": [ + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Oven": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + }, + { + "key": "BSH.Common.Command.PartlyOpenDoor", + "name": "Partly open door" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "CleaningRobot": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Dishwasher": { + "commands": [ + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Dryer": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Washer": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "WasherDryer": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Freezer": { + "commands": [ + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + } + ] + }, + "FridgeFreezer": { + "commands": [ + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + } + ] + }, + "Refrigerator": { + "commands": [ + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + } + ] + } +} diff --git a/tests/components/home_connect/test_button.py b/tests/components/home_connect/test_button.py new file mode 100644 index 00000000000..5af7e40ca43 --- /dev/null +++ b/tests/components/home_connect/test_button.py @@ -0,0 +1,315 @@ +"""Tests for home_connect button entities.""" + +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from aiohomeconnect.model import ArrayOfCommands, CommandKey, EventMessage +from aiohomeconnect.model.command import Command +from aiohomeconnect.model.error import HomeConnectApiError +from aiohomeconnect.model.event import ArrayOfEvents, EventType +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.BUTTON] + + +async def test_buttons( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test button entities.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that removed devices are correctly removed from and added to hass on API events.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_available_commands_original_mock = client.get_available_commands + get_available_programs_mock = client.get_available_programs + + async def get_available_commands_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_available_commands_original_mock.side_effect(ha_id) + + async def get_available_programs_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_available_programs_mock.side_effect(ha_id) + + client.get_available_commands = AsyncMock( + side_effect=get_available_commands_side_effect + ) + client.get_available_programs = AsyncMock( + side_effect=get_available_programs_side_effect + ) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + client.get_available_commands = get_available_commands_original_mock + client.get_available_programs = get_available_programs_mock + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert len(new_entity_entries) > len(entity_entries) + + +async def test_button_entity_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + entity_ids = [ + "button.washer_pause_program", + "button.washer_stop_program", + ] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("entity_id", "method_call", "expected_kwargs"), + [ + ( + "button.washer_pause_program", + "put_command", + {"command_key": CommandKey.BSH_COMMON_PAUSE_PROGRAM, "value": True}, + ), + ("button.washer_stop_program", "stop_program", {}), + ], +) +async def test_button_functionality( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + entity_id: str, + method_call: str, + expected_kwargs: dict[str, Any], + appliance_ha_id: str, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + entity = hass.states.get(entity_id) + assert entity + assert entity.state != STATE_UNAVAILABLE + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + getattr(client, method_call).assert_called_with(appliance_ha_id, **expected_kwargs) + + +async def test_command_button_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + entity_id = "button.washer_pause_program" + + client_with_exception.get_available_commands = AsyncMock( + return_value=ArrayOfCommands( + [ + Command( + CommandKey.BSH_COMMON_PAUSE_PROGRAM, + "Pause Program", + ) + ] + ) + ) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + entity = hass.states.get(entity_id) + assert entity + assert entity.state != STATE_UNAVAILABLE + + with pytest.raises(HomeAssistantError, match=r"Error.*executing.*command"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +async def test_stop_program_button_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + entity_id = "button.washer_stop_program" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + entity = hass.states.get(entity_id) + assert entity + assert entity.state != STATE_UNAVAILABLE + + with pytest.raises(HomeAssistantError, match=r"Error.*stop.*program"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) From 93b01a3bc39d8ad079ee500196af0e09c9e6814a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Feb 2025 14:39:12 -0600 Subject: [PATCH 2645/2987] Fix minimum schema version to run event_id_post_migration (#139014) * Fix minimum version to run event_id_post_migration The table rebuild to fix the foreign key constraint was added in https://github.com/home-assistant/core/pull/120779 but the schema version was not bumped so we need to make sure any database that was created with schema 43 or older still has the migration run as otherwise they will not be able to purge the database with SQLite since each delete in the events table will due a full table scan of the states table to look for a foreign key that is not there fixes #138818 * Apply suggestions from code review * Update homeassistant/components/recorder/migration.py * Update homeassistant/components/recorder/migration.py * Update homeassistant/components/recorder/const.py * Apply suggestions from code review * Apply suggestions from code review * Apply suggestions from code review * Apply suggestions from code review * update tests, add more cover * update tests, add more cover * Update tests/components/recorder/test_migration_run_time_migrations_remember.py --- homeassistant/components/recorder/const.py | 5 ++ .../components/recorder/migration.py | 13 +++++- .../recorder/test_migration_from_schema_32.py | 15 ++++-- ..._migration_run_time_migrations_remember.py | 46 +++++++++++++++++-- 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index c91845e8436..b7ee984558c 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -50,6 +50,11 @@ STATES_META_SCHEMA_VERSION = 38 LAST_REPORTED_SCHEMA_VERSION = 43 LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 +LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43 +# https://github.com/home-assistant/core/pull/120779 +# fixed the foreign keys in the states table but it did +# not bump the schema version which means only databases +# created with schema 44 and later do not need the rebuild. INTEGRATION_PLATFORM_COMPILE_STATISTICS = "compile_statistics" INTEGRATION_PLATFORM_LIST_STATISTIC_IDS = "list_statistic_ids" diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index c6cdd6d317f..3aa12f2b1f9 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -52,6 +52,7 @@ from .auto_repairs.statistics.schema import ( from .const import ( CONTEXT_ID_AS_BINARY_SCHEMA_VERSION, EVENT_TYPE_IDS_SCHEMA_VERSION, + LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION, LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION, STATES_META_SCHEMA_VERSION, SupportedDialect, @@ -2490,9 +2491,10 @@ class BaseMigration(ABC): if self.initial_schema_version > self.max_initial_schema_version: _LOGGER.debug( "Data migration '%s' not needed, database created with version %s " - "after migrator was added", + "after migrator was added in version %s", self.migration_id, self.initial_schema_version, + self.max_initial_schema_version, ) return False if self.start_schema_version < self.required_schema_version: @@ -2868,7 +2870,14 @@ class EventIDPostMigration(BaseRunTimeMigration): """Migration to remove old event_id index from states.""" migration_id = "event_id_post_migration" - max_initial_schema_version = LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION - 1 + # Note we don't subtract 1 from the max_initial_schema_version + # in this case because we need to run this migration on databases + # version >= 43 because the schema was not bumped when the table + # rebuild was added in + # https://github.com/home-assistant/core/pull/120779 + # which means its only safe to assume version 44 and later + # do not need the table rebuild + max_initial_schema_version = LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION task = MigrationTask migration_version = 2 diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 0a5f5d4da73..012e227c11a 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -225,6 +225,7 @@ async def test_migrate_events_context_ids( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), patch.object(migration.EventsContextIDMigration, "migrate_data"), + patch.object(migration.EventIDPostMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( @@ -282,6 +283,7 @@ async def test_migrate_events_context_ids( patch( "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create ) as wrapped_idx_create, + patch.object(migration.EventIDPostMigration, "migrate_data"), ): async with async_test_recorder( hass, wait_recorder=False, wait_recorder_setup=False @@ -588,6 +590,7 @@ async def test_migrate_states_context_ids( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), patch.object(migration.StatesContextIDMigration, "migrate_data"), + patch.object(migration.EventIDPostMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( @@ -640,6 +643,7 @@ async def test_migrate_states_context_ids( patch( "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create ) as wrapped_idx_create, + patch.object(migration.EventIDPostMigration, "migrate_data"), ): async with async_test_recorder( hass, wait_recorder=False, wait_recorder_setup=False @@ -1127,6 +1131,7 @@ async def test_post_migrate_entity_ids( patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), patch.object(migration.EntityIDMigration, "migrate_data"), patch.object(migration.EntityIDPostMigration, "migrate_data"), + patch.object(migration.EventIDPostMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( @@ -1158,9 +1163,12 @@ async def test_post_migrate_entity_ids( return {state.state: state.entity_id for state in states} # Run again with new schema, let migration run - with patch( - "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create - ) as wrapped_idx_create: + with ( + patch( + "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create + ) as wrapped_idx_create, + patch.object(migration.EventIDPostMigration, "migrate_data"), + ): async with ( async_test_home_assistant() as hass, async_test_recorder(hass) as instance, @@ -1169,7 +1177,6 @@ async def test_post_migrate_entity_ids( await hass.async_block_till_done() await async_wait_recording_done(hass) - await async_wait_recording_done(hass) states_by_state = await instance.async_add_executor_job( _fetch_migrated_states diff --git a/tests/components/recorder/test_migration_run_time_migrations_remember.py b/tests/components/recorder/test_migration_run_time_migrations_remember.py index 43a1b028348..350126b4c72 100644 --- a/tests/components/recorder/test_migration_run_time_migrations_remember.py +++ b/tests/components/recorder/test_migration_run_time_migrations_remember.py @@ -115,7 +115,7 @@ def _create_engine_test( "event_context_id_as_binary": (0, 1), "event_type_id_migration": (2, 1), "entity_id_migration": (2, 1), - "event_id_post_migration": (0, 0), + "event_id_post_migration": (1, 1), "entity_id_post_migration": (0, 1), }, [ @@ -131,7 +131,7 @@ def _create_engine_test( "event_context_id_as_binary": (0, 0), "event_type_id_migration": (2, 1), "entity_id_migration": (2, 1), - "event_id_post_migration": (0, 0), + "event_id_post_migration": (1, 1), "entity_id_post_migration": (0, 1), }, ["ix_states_entity_id_last_updated_ts"], @@ -143,13 +143,43 @@ def _create_engine_test( "event_context_id_as_binary": (0, 0), "event_type_id_migration": (0, 0), "entity_id_migration": (2, 1), - "event_id_post_migration": (0, 0), + "event_id_post_migration": (1, 1), "entity_id_post_migration": (0, 1), }, ["ix_states_entity_id_last_updated_ts"], ), ( 38, + { + "state_context_id_as_binary": (0, 0), + "event_context_id_as_binary": (0, 0), + "event_type_id_migration": (0, 0), + "entity_id_migration": (0, 0), + "event_id_post_migration": (1, 1), + "entity_id_post_migration": (0, 0), + }, + [], + ), + ( + 43, + { + "state_context_id_as_binary": (0, 0), + "event_context_id_as_binary": (0, 0), + "event_type_id_migration": (0, 0), + "entity_id_migration": (0, 0), + # Schema was not bumped when the SQLite + # table rebuild was implemented so we need + # run event_id_post_migration up until + # schema 44 since its the first one we can + # be sure has the foreign key constraint was removed + # via https://github.com/home-assistant/core/pull/120779 + "event_id_post_migration": (1, 1), + "entity_id_post_migration": (0, 0), + }, + [], + ), + ( + 44, { "state_context_id_as_binary": (0, 0), "event_context_id_as_binary": (0, 0), @@ -266,8 +296,14 @@ async def test_data_migrator_logic( # the expected number of times. for migrator, mock in migrator_mocks.items(): needs_migrate_calls, migrate_data_calls = expected_migrator_calls[migrator] - assert len(mock["needs_migrate"].mock_calls) == needs_migrate_calls - assert len(mock["migrate_data"].mock_calls) == migrate_data_calls + assert len(mock["needs_migrate"].mock_calls) == needs_migrate_calls, ( + f"Expected {migrator} needs_migrate to be called {needs_migrate_calls} times," + f" got {len(mock['needs_migrate'].mock_calls)}" + ) + assert len(mock["migrate_data"].mock_calls) == migrate_data_calls, ( + f"Expected {migrator} migrate_data to be called {migrate_data_calls} times, " + f"got {len(mock['migrate_data'].mock_calls)}" + ) @pytest.mark.parametrize("enable_migrate_state_context_ids", [True]) From d821aa91626845d2f33e3fdf463edbd6c0697387 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Sun, 23 Feb 2025 05:51:54 +0900 Subject: [PATCH 2646/2987] Fix dryer's remaining time issue (#138764) Fix dryer's remain_time issue Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/sensor.py | 48 ++++++++++++--------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 95198d931a1..754b07cb2db 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -581,36 +581,44 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity): local_now = datetime.now( tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone) ) - if value in [0, None, time.min]: - # Reset to None + self._device_state = ( + self.coordinator.data[self._device_state_id].value + if self._device_state_id in self.coordinator.data + else None + ) + if value in [0, None, time.min] or ( + self._device_state == "power_off" + and self.entity_description.key + in [TimerProperty.REMAIN, TimerProperty.TOTAL] + ): + # Reset to None when power_off value = None elif self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: if self.entity_description.key in TIME_SENSOR_DESC: - # Set timestamp for time + # Set timestamp for absolute time value = local_now.replace(hour=value.hour, minute=value.minute) else: # Set timestamp for delta - new_state = ( - self.coordinator.data[self._device_state_id].value - if self._device_state_id in self.coordinator.data - else None - ) - if ( - self.native_value is not None - and self._device_state == new_state - ): - # Skip update when same state - return - - self._device_state = new_state - time_delta = timedelta( + event_data = timedelta( hours=value.hour, minutes=value.minute, seconds=value.second ) - value = ( - (local_now - time_delta) + new_time = ( + (local_now - event_data) if self.entity_description.key == TimerProperty.RUNNING - else (local_now + time_delta) + else (local_now + event_data) ) + # The remain_time may change during the wash/dry operation depending on various reasons. + # If there is a diff of more than 60sec, the new timestamp is used + if ( + parse_native_value := dt_util.parse_datetime( + str(self.native_value) + ) + ) is None or abs(new_time - parse_native_value) > timedelta( + seconds=60 + ): + value = new_time + else: + value = self.native_value elif self.entity_description.device_class == SensorDeviceClass.DURATION: # Set duration value = self._get_duration( From 5a0a3d27d9098c3d572a430c4907bd319930b263 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Feb 2025 15:11:28 -0600 Subject: [PATCH 2647/2987] Bump aiodiscover to 2.6.1 (#139055) changelog: https://github.com/Bluetooth-Devices/aiodiscover/compare/v2.6.0...v2.6.1 --- homeassistant/components/dhcp/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/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index 382a9b94ff7..65d43f80abe 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -15,7 +15,7 @@ "quality_scale": "internal", "requirements": [ "aiodhcpwatcher==1.1.1", - "aiodiscover==2.6.0", + "aiodiscover==2.6.1", "cached-ipaddress==0.8.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 40f7e511332..967ce98a705 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,7 +1,7 @@ # Automatically generated by gen_requirements_all.py, do not edit aiodhcpwatcher==1.1.1 -aiodiscover==2.6.0 +aiodiscover==2.6.1 aiodns==3.2.0 aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 diff --git a/requirements_all.txt b/requirements_all.txt index 0ffd8b7e781..ab0a714e296 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -219,7 +219,7 @@ aiocomelit==0.10.1 aiodhcpwatcher==1.1.1 # homeassistant.components.dhcp -aiodiscover==2.6.0 +aiodiscover==2.6.1 # homeassistant.components.dnsip aiodns==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6d070883303..5b03f3e9197 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -207,7 +207,7 @@ aiocomelit==0.10.1 aiodhcpwatcher==1.1.1 # homeassistant.components.dhcp -aiodiscover==2.6.0 +aiodiscover==2.6.1 # homeassistant.components.dnsip aiodns==3.2.0 From 17c1c0e1553fab9edd0691d35913d184c4bf6b35 Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Sat, 22 Feb 2025 17:35:32 -0600 Subject: [PATCH 2648/2987] Remove unnecessary debug message from vesync (#139083) Remove unnecessary debug write --- homeassistant/components/vesync/binary_sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/vesync/binary_sensor.py b/homeassistant/components/vesync/binary_sensor.py index 620222e4d2f..7b6f14e04dc 100644 --- a/homeassistant/components/vesync/binary_sensor.py +++ b/homeassistant/components/vesync/binary_sensor.py @@ -102,5 +102,4 @@ class VeSyncBinarySensor(BinarySensorEntity, VeSyncBaseEntity): @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - _LOGGER.debug(rgetattr(self.device, self.entity_description.key)) return self.entity_description.is_on(self.device) From b1b65e4d568514c63dd5af6936404ac0d876bf8b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 23 Feb 2025 00:59:51 +0100 Subject: [PATCH 2649/2987] Bump py-synologydsm-api to 2.7.0 (#139082) bump py-synologydsm-api to 2.7.0 --- 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 d076d843c36..dc5634e7a84 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.3"], + "requirements": ["py-synologydsm-api==2.7.0"], "ssdp": [ { "manufacturer": "Synology", diff --git a/requirements_all.txt b/requirements_all.txt index ab0a714e296..d55aec73653 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.3 +py-synologydsm-api==2.7.0 # homeassistant.components.atome pyAtome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b03f3e9197..f751c87ace6 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.3 +py-synologydsm-api==2.7.0 # homeassistant.components.hdmi_cec pyCEC==0.5.2 From 5b0eca7f8578c6e40154a00780d52613c1ffb453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 23 Feb 2025 01:42:25 +0100 Subject: [PATCH 2650/2987] Add select setting entities to Home Connect (#138884) * Add select setting entities * Improvements --- .../components/home_connect/const.py | 4 +- .../components/home_connect/select.py | 225 +++++++++++++----- .../components/home_connect/strings.json | 26 ++ .../home_connect/fixtures/settings.json | 11 +- .../snapshots/test_diagnostics.ambr | 2 +- tests/components/home_connect/test_select.py | 130 ++++++++++ 6 files changed, 340 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 3a22297ebee..692a5e91851 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -87,7 +87,7 @@ PROGRAMS_TRANSLATION_KEYS_MAP = { value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items() } -REFERENCE_MAP_ID_OPTIONS = { +AVAILABLE_MAPS_ENUM = { bsh_key_to_translation_key(option): option for option in ( "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.TempMap", @@ -305,7 +305,7 @@ PROGRAM_ENUM_OPTIONS = { for option_key, options in ( ( OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID, - REFERENCE_MAP_ID_OPTIONS, + AVAILABLE_MAPS_ENUM, ), ( OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index f5298056080..e4d50b0d5e9 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient -from aiohomeconnect.model import EventKey, OptionKey, ProgramKey +from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.program import Execution @@ -17,6 +17,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry from .const import ( APPLIANCES_WITH_PROGRAMS, + AVAILABLE_MAPS_ENUM, BEAN_AMOUNT_OPTIONS, BEAN_CONTAINER_OPTIONS, CLEANING_MODE_OPTIONS, @@ -28,9 +29,12 @@ from .const import ( HOT_WATER_TEMPERATURE_OPTIONS, INTENSIVE_LEVEL_OPTIONS, PROGRAMS_TRANSLATION_KEYS_MAP, - REFERENCE_MAP_ID_OPTIONS, SPIN_SPEED_OPTIONS, + SVE_TRANSLATION_KEY_SET_SETTING, + SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, + SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_PROGRAM, + SVE_TRANSLATION_PLACEHOLDER_VALUE, TEMPERATURE_OPTIONS, TRANSLATION_KEYS_PROGRAMS_MAP, VARIO_PERFECT_OPTIONS, @@ -43,7 +47,30 @@ from .coordinator import ( HomeConnectCoordinator, ) from .entity import HomeConnectEntity, HomeConnectOptionEntity -from .utils import get_dict_from_home_connect_error +from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error + +FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = { + bsh_key_to_translation_key(option): option + for option in ( + "Cooking.Hood.EnumType.ColorTemperature.custom", + "Cooking.Hood.EnumType.ColorTemperature.warm", + "Cooking.Hood.EnumType.ColorTemperature.warmToNeutral", + "Cooking.Hood.EnumType.ColorTemperature.neutral", + "Cooking.Hood.EnumType.ColorTemperature.neutralToCold", + "Cooking.Hood.EnumType.ColorTemperature.cold", + ) +} + +AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM = { + **{ + bsh_key_to_translation_key(option): option + for option in ("BSH.Common.EnumType.AmbientLightColor.CustomColor",) + }, + **{ + str(option): f"BSH.Common.EnumType.AmbientLightColor.Color{option}" + for option in range(1, 100) + }, +} @dataclass(frozen=True, kw_only=True) @@ -60,10 +87,8 @@ class HomeConnectProgramSelectEntityDescription( @dataclass(frozen=True, kw_only=True) -class HomeConnectSelectOptionEntityDescription( - SelectEntityDescription, -): - """Entity Description class for options that have enumeration values.""" +class HomeConnectSelectEntityDescription(SelectEntityDescription): + """Entity Description class for settings and options that have enumeration values.""" translation_key_values: dict[str, str] values_translation_key: dict[str, str] @@ -90,151 +115,184 @@ PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( ), ) -PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = ( - HomeConnectSelectOptionEntityDescription( - key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID, - translation_key="reference_map_id", - options=list(REFERENCE_MAP_ID_OPTIONS.keys()), - translation_key_values=REFERENCE_MAP_ID_OPTIONS, +SELECT_ENTITY_DESCRIPTIONS = ( + HomeConnectSelectEntityDescription( + key=SettingKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CURRENT_MAP, + translation_key="current_map", + options=list(AVAILABLE_MAPS_ENUM), + translation_key_values=AVAILABLE_MAPS_ENUM, values_translation_key={ value: translation_key - for translation_key, value in REFERENCE_MAP_ID_OPTIONS.items() + for translation_key, value in AVAILABLE_MAPS_ENUM.items() }, ), - HomeConnectSelectOptionEntityDescription( - key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, + HomeConnectSelectEntityDescription( + key=SettingKey.COOKING_HOOD_COLOR_TEMPERATURE, + translation_key="functional_light_color_temperature", + options=list(FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM), + translation_key_values=FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM, + values_translation_key={ + value: translation_key + for translation_key, value in FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM.items() + }, + ), + HomeConnectSelectEntityDescription( + key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + translation_key="ambient_light_color", + options=list(AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM), + translation_key_values=AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM, + values_translation_key={ + value: translation_key + for translation_key, value in AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM.items() + }, + ), +) + +PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = ( + HomeConnectSelectEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID, translation_key="reference_map_id", - options=list(CLEANING_MODE_OPTIONS.keys()), + options=list(AVAILABLE_MAPS_ENUM), + translation_key_values=AVAILABLE_MAPS_ENUM, + values_translation_key={ + value: translation_key + for translation_key, value in AVAILABLE_MAPS_ENUM.items() + }, + ), + HomeConnectSelectEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, + translation_key="cleaning_mode", + options=list(CLEANING_MODE_OPTIONS), translation_key_values=CLEANING_MODE_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in CLEANING_MODE_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, translation_key="bean_amount", - options=list(BEAN_AMOUNT_OPTIONS.keys()), + options=list(BEAN_AMOUNT_OPTIONS), translation_key_values=BEAN_AMOUNT_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in BEAN_AMOUNT_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE, translation_key="coffee_temperature", - options=list(COFFEE_TEMPERATURE_OPTIONS.keys()), + options=list(COFFEE_TEMPERATURE_OPTIONS), translation_key_values=COFFEE_TEMPERATURE_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in COFFEE_TEMPERATURE_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_CONTAINER_SELECTION, translation_key="bean_container", - options=list(BEAN_CONTAINER_OPTIONS.keys()), + options=list(BEAN_CONTAINER_OPTIONS), translation_key_values=BEAN_CONTAINER_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in BEAN_CONTAINER_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE, translation_key="flow_rate", - options=list(FLOW_RATE_OPTIONS.keys()), + options=list(FLOW_RATE_OPTIONS), translation_key_values=FLOW_RATE_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in FLOW_RATE_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO, translation_key="coffee_milk_ratio", - options=list(COFFEE_MILK_RATIO_OPTIONS.keys()), + options=list(COFFEE_MILK_RATIO_OPTIONS), translation_key_values=COFFEE_MILK_RATIO_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in FLOW_RATE_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE, translation_key="hot_water_temperature", - options=list(HOT_WATER_TEMPERATURE_OPTIONS.keys()), + options=list(HOT_WATER_TEMPERATURE_OPTIONS), translation_key_values=HOT_WATER_TEMPERATURE_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in HOT_WATER_TEMPERATURE_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.LAUNDRY_CARE_DRYER_DRYING_TARGET, translation_key="drying_target", - options=list(DRYING_TARGET_OPTIONS.keys()), + options=list(DRYING_TARGET_OPTIONS), translation_key_values=DRYING_TARGET_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in DRYING_TARGET_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL, translation_key="venting_level", - options=list(VENTING_LEVEL_OPTIONS.keys()), + options=list(VENTING_LEVEL_OPTIONS), translation_key_values=VENTING_LEVEL_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in VENTING_LEVEL_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL, translation_key="intensive_level", - options=list(INTENSIVE_LEVEL_OPTIONS.keys()), + options=list(INTENSIVE_LEVEL_OPTIONS), translation_key_values=INTENSIVE_LEVEL_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in INTENSIVE_LEVEL_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.COOKING_OVEN_WARMING_LEVEL, translation_key="warming_level", - options=list(WARMING_LEVEL_OPTIONS.keys()), + options=list(WARMING_LEVEL_OPTIONS), translation_key_values=WARMING_LEVEL_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in WARMING_LEVEL_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, translation_key="washer_temperature", - options=list(TEMPERATURE_OPTIONS.keys()), + options=list(TEMPERATURE_OPTIONS), translation_key_values=TEMPERATURE_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in TEMPERATURE_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED, translation_key="spin_speed", - options=list(SPIN_SPEED_OPTIONS.keys()), + options=list(SPIN_SPEED_OPTIONS), translation_key_values=SPIN_SPEED_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in SPIN_SPEED_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT, translation_key="vario_perfect", - options=list(VARIO_PERFECT_OPTIONS.keys()), + options=list(VARIO_PERFECT_OPTIONS), translation_key_values=VARIO_PERFECT_OPTIONS, values_translation_key={ value: translation_key @@ -249,14 +307,21 @@ def _get_entities_for_appliance( appliance: HomeConnectApplianceData, ) -> list[HomeConnectEntity]: """Get a list of entities.""" - return ( - [ - HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc) - for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS - ] - if appliance.info.type in APPLIANCES_WITH_PROGRAMS - else [] - ) + return [ + *( + [ + HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc) + for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS + ] + if appliance.info.type in APPLIANCES_WITH_PROGRAMS + else [] + ), + *[ + HomeConnectSelectEntity(entry.runtime_data, appliance, desc) + for desc in SELECT_ENTITY_DESCRIPTIONS + if desc.key in appliance.settings + ], + ] def _get_option_entities_for_appliance( @@ -341,17 +406,71 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): ) from err +class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): + """Select setting class for Home Connect.""" + + entity_description: HomeConnectSelectEntityDescription + + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: HomeConnectSelectEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__( + coordinator, + appliance, + desc, + ) + setting = appliance.settings.get(cast(SettingKey, desc.key)) + if setting and setting.constraints and setting.constraints.allowed_values: + self._attr_options = [ + desc.values_translation_key[option] + for option in setting.constraints.allowed_values + if option in desc.values_translation_key + ] + + async def async_select_option(self, option: str) -> None: + """Select new option.""" + value = self.entity_description.translation_key_values[option] + try: + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=cast(SettingKey, self.bsh_key), + value=value, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=SVE_TRANSLATION_KEY_SET_SETTING, + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, + SVE_TRANSLATION_PLACEHOLDER_VALUE: value, + }, + ) from err + + def update_native_value(self) -> None: + """Set the value of the entity.""" + data = self.appliance.settings[cast(SettingKey, self.bsh_key)] + self._attr_current_option = self.entity_description.values_translation_key.get( + data.value + ) + + class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): """Select option class for Home Connect.""" - entity_description: HomeConnectSelectOptionEntityDescription + entity_description: HomeConnectSelectEntityDescription _original_option_keys: set[str | None] def __init__( self, coordinator: HomeConnectCoordinator, appliance: HomeConnectApplianceData, - desc: HomeConnectSelectOptionEntityDescription, + desc: HomeConnectSelectEntityDescription, ) -> None: """Initialize the entity.""" self._original_option_keys = set(desc.values_translation_key.keys()) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index db53e76fb95..dde002d1caa 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -1215,6 +1215,32 @@ "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%]" } }, + "current_map": { + "name": "Current map", + "state": { + "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]" + } + }, + "functional_light_color_temperature": { + "name": "Functional light color temperature", + "state": { + "cooking_hood_enum_type_color_temperature_custom": "Custom", + "cooking_hood_enum_type_color_temperature_warm": "Warm", + "cooking_hood_enum_type_color_temperature_warm_to_neutral": "Warm to Neutral", + "cooking_hood_enum_type_color_temperature_neutral": "Neutral", + "cooking_hood_enum_type_color_temperature_neutral_to_cold": "Neutral to Cold", + "cooking_hood_enum_type_color_temperature_cold": "Cold" + } + }, + "ambient_light_color": { + "name": "Ambient light color", + "state": { + "b_s_h_common_enum_type_ambient_light_color_custom_color": "Custom" + } + }, "reference_map_id": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_reference_map_id::name%]", "state": { diff --git a/tests/components/home_connect/fixtures/settings.json b/tests/components/home_connect/fixtures/settings.json index 8f649e5790b..bd1bea18365 100644 --- a/tests/components/home_connect/fixtures/settings.json +++ b/tests/components/home_connect/fixtures/settings.json @@ -68,9 +68,16 @@ "type": "Double" }, { - "key": "BSH.Common.Setting.ColorTemperature", + "key": "Cooking.Hood.Setting.ColorTemperature", "value": "Cooking.Hood.EnumType.ColorTemperature.warmToNeutral", - "type": "BSH.Common.EnumType.ColorTemperature" + "type": "BSH.Common.EnumType.ColorTemperature", + "constraints": { + "allowedvalues": [ + "Cooking.Hood.EnumType.ColorTemperature.warm", + "Cooking.Hood.EnumType.ColorTemperature.neutral", + "Cooking.Hood.EnumType.ColorTemperature.cold" + ] + } }, { "key": "BSH.Common.Setting.AmbientLightEnabled", diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index 512da8bd970..28f45ce97ba 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -98,8 +98,8 @@ 'BSH.Common.Setting.AmbientLightEnabled': True, 'Cooking.Common.Setting.Lighting': True, 'Cooking.Common.Setting.LightingBrightness': 70, + 'Cooking.Hood.Setting.ColorTemperature': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', 'Cooking.Hood.Setting.ColorTemperaturePercent': 70, - 'unknown': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', }), 'status': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 917c092136e..d98dbd8e5f6 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -6,13 +6,16 @@ from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, ArrayOfPrograms, + ArrayOfSettings, Event, EventKey, EventMessage, EventType, + GetSetting, OptionKey, ProgramDefinition, ProgramKey, + SettingKey, ) from aiohomeconnect.model.error import ( ActiveProgramNotSetError, @@ -26,6 +29,7 @@ from aiohomeconnect.model.program import ( ProgramDefinitionConstraints, ProgramDefinitionOption, ) +from aiohomeconnect.model.setting import SettingConstraints import pytest from homeassistant.components.home_connect.const import DOMAIN @@ -434,6 +438,132 @@ async def test_select_exception_handling( assert getattr(client_with_exception, mock_attr).call_count == 2 +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize( + ( + "entity_id", + "setting_key", + "expected_options", + "value_to_set", + "expected_value_call_arg", + ), + [ + ( + "select.hood_functional_light_color_temperature", + SettingKey.COOKING_HOOD_COLOR_TEMPERATURE, + { + "cooking_hood_enum_type_color_temperature_warm", + "cooking_hood_enum_type_color_temperature_neutral", + "cooking_hood_enum_type_color_temperature_cold", + }, + "cooking_hood_enum_type_color_temperature_neutral", + "Cooking.Hood.EnumType.ColorTemperature.neutral", + ), + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + { + "b_s_h_common_enum_type_ambient_light_color_custom_color", + *[str(i) for i in range(1, 100)], + }, + "42", + "BSH.Common.EnumType.AmbientLightColor.Color42", + ), + ], +) +async def test_select_functionality( + appliance_ha_id: str, + entity_id: str, + setting_key: SettingKey, + expected_options: set[str], + value_to_set: str, + expected_value_call_arg: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test select functionality.""" + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": value_to_set}, + ) + await hass.async_block_till_done() + + client.set_setting.assert_called_once() + assert client.set_setting.call_args.args == (appliance_ha_id,) + assert client.set_setting.call_args.kwargs == { + "setting_key": setting_key, + "value": expected_value_call_arg, + } + assert hass.states.is_state(entity_id, value_to_set) + + +@pytest.mark.parametrize( + ("entity_id", "setting_key", "allowed_value", "value_to_set", "mock_attr"), + [ + ( + "select.hood_functional_light_color_temperature", + SettingKey.COOKING_HOOD_COLOR_TEMPERATURE, + "Cooking.Hood.EnumType.ColorTemperature.neutral", + "cooking_hood_enum_type_color_temperature_neutral", + "set_setting", + ), + ], +) +async def test_select_entity_error( + entity_id: str, + setting_key: SettingKey, + allowed_value: str, + value_to_set: str, + mock_attr: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test select entity error.""" + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=value_to_set, + constraints=SettingConstraints(allowed_values=[allowed_value]), + ) + ] + ) + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state is ConfigEntryState.LOADED + + with pytest.raises(HomeConnectError): + await getattr(client_with_exception, mock_attr)() + + with pytest.raises( + HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": value_to_set}, + blocking=True, + ) + assert getattr(client_with_exception, mock_attr).call_count == 2 + + @pytest.mark.parametrize( ( "set_active_program_options_side_effect", From 8ce2727447c8b0c3b79c4a5ac0cdac1ca0db2828 Mon Sep 17 00:00:00 2001 From: javers99 <90975080+javers99@users.noreply.github.com> Date: Sun, 23 Feb 2025 00:45:44 +0000 Subject: [PATCH 2651/2987] Fix typo in SSH connection string for cisco ios device_tracker (#138584) Update device_tracker.py Typo in "uft-8" -> pxssh.pxssh(encoding="utf-8") --- homeassistant/components/cisco_ios/device_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py index 0477ebb111c..6cc403817cf 100644 --- a/homeassistant/components/cisco_ios/device_tracker.py +++ b/homeassistant/components/cisco_ios/device_tracker.py @@ -104,7 +104,7 @@ class CiscoDeviceScanner(DeviceScanner): """Open connection to the router and get arp entries.""" try: - cisco_ssh: pxssh.pxssh[str] = pxssh.pxssh(encoding="uft-8") + cisco_ssh: pxssh.pxssh[str] = pxssh.pxssh(encoding="utf-8") cisco_ssh.login( self.host, self.username, From 0797c3228b513086ab98e48d2cfc3a09bbd4b4ca Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 23 Feb 2025 08:35:00 +0000 Subject: [PATCH 2652/2987] Bump pyprosegur to 0.0.14 (#139077) bump pyprosegur --- homeassistant/components/prosegur/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/prosegur/manifest.json b/homeassistant/components/prosegur/manifest.json index 6419b81aa7f..2e649ebd5bd 100644 --- a/homeassistant/components/prosegur/manifest.json +++ b/homeassistant/components/prosegur/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/prosegur", "iot_class": "cloud_polling", "loggers": ["pyprosegur"], - "requirements": ["pyprosegur==0.0.13"] + "requirements": ["pyprosegur==0.0.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index d55aec73653..ef4360a2061 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2223,7 +2223,7 @@ pypoint==3.0.0 pyprof2calltree==1.4.5 # homeassistant.components.prosegur -pyprosegur==0.0.13 +pyprosegur==0.0.14 # homeassistant.components.prusalink pyprusalink==2.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f751c87ace6..b78b82d8f2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1816,7 +1816,7 @@ pypoint==3.0.0 pyprof2calltree==1.4.5 # homeassistant.components.prosegur -pyprosegur==0.0.13 +pyprosegur==0.0.14 # homeassistant.components.prusalink pyprusalink==2.1.1 From 91668e99e326fcdf8dec20a3faa7f8640d7005bc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 23 Feb 2025 04:51:25 -0500 Subject: [PATCH 2653/2987] OpenAI to report when running out of funds (#139088) --- .../openai_conversation/conversation.py | 3 ++ .../openai_conversation/test_conversation.py | 31 +++++++++++++++---- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index fddabb740ac..cc09ec77c0e 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -287,6 +287,9 @@ class OpenAIConversationEntity( try: result = await client.chat.completions.create(**model_args) + except openai.RateLimitError as err: + LOGGER.error("Rate limited by OpenAI: %s", err) + raise HomeAssistantError("Rate limited or insufficient funds") from err except openai.OpenAIError as err: LOGGER.error("Error talking to OpenAI: %s", err) raise HomeAssistantError("Error talking to OpenAI") from err diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 2c956b7e63f..238fd5f2d7b 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -4,7 +4,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch from httpx import Response -from openai import RateLimitError +from openai import AuthenticationError, RateLimitError from openai.types.chat.chat_completion_chunk import ( ChatCompletionChunk, Choice, @@ -94,23 +94,42 @@ async def test_entity( ) +@pytest.mark.parametrize( + ("exception", "message"), + [ + ( + RateLimitError( + response=Response(status_code=429, request=""), body=None, message=None + ), + "Rate limited or insufficient funds", + ), + ( + AuthenticationError( + response=Response(status_code=401, request=""), body=None, message=None + ), + "Error talking to OpenAI", + ), + ], +) async def test_error_handling( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + exception, + message, ) -> None: """Test that we handle errors when calling completion API.""" with patch( "openai.resources.chat.completions.AsyncCompletions.create", new_callable=AsyncMock, - side_effect=RateLimitError( - response=Response(status_code=None, request=""), body=None, message=None - ), + side_effect=exception, ): result = await conversation.async_converse( hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id ) assert result.response.response_type == intent.IntentResponseType.ERROR, result - assert result.response.error_code == "unknown", result + assert result.response.speech["plain"]["speech"] == message, result.response.speech async def test_conversation_agent( From 746d1800f98021d0cab182af0d75c6d5081dad9b Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 23 Feb 2025 11:43:25 +0000 Subject: [PATCH 2654/2987] Add tests to Evohome for its native services (#139104) initial commit --- homeassistant/components/evohome/__init__.py | 20 +- homeassistant/components/evohome/climate.py | 21 +-- homeassistant/components/evohome/const.py | 7 +- tests/components/evohome/test_evo_services.py | 177 ++++++++++++++++++ tests/components/evohome/test_init.py | 42 +---- 5 files changed, 202 insertions(+), 65 deletions(-) create mode 100644 tests/components/evohome/test_evo_services.py diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index e322e266b8a..9dce352df30 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -25,6 +25,7 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_MODE, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME, @@ -40,11 +41,10 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from .const import ( - ATTR_DURATION_DAYS, - ATTR_DURATION_HOURS, + ATTR_DURATION, ATTR_DURATION_UNTIL, - ATTR_SYSTEM_MODE, - ATTR_ZONE_TEMP, + ATTR_PERIOD, + ATTR_SETPOINT, CONF_LOCATION_IDX, DOMAIN, SCAN_INTERVAL_DEFAULT, @@ -81,7 +81,7 @@ RESET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema( SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema( { vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_ZONE_TEMP): vol.All( + vol.Required(ATTR_SETPOINT): vol.All( vol.Coerce(float), vol.Range(min=4.0, max=35.0) ), vol.Optional(ATTR_DURATION_UNTIL): vol.All( @@ -222,7 +222,7 @@ def setup_service_functions( # Permanent-only modes will use this schema perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]] if perm_modes: # any of: "Auto", "HeatingOff": permanent only - schema = vol.Schema({vol.Required(ATTR_SYSTEM_MODE): vol.In(perm_modes)}) + schema = vol.Schema({vol.Required(ATTR_MODE): vol.In(perm_modes)}) system_mode_schemas.append(schema) modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]] @@ -232,8 +232,8 @@ def setup_service_functions( if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours schema = vol.Schema( { - vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes), - vol.Optional(ATTR_DURATION_HOURS): vol.All( + vol.Required(ATTR_MODE): vol.In(temp_modes), + vol.Optional(ATTR_DURATION): vol.All( cv.time_period, vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)), ), @@ -246,8 +246,8 @@ def setup_service_functions( if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days schema = vol.Schema( { - vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes), - vol.Optional(ATTR_DURATION_DAYS): vol.All( + vol.Required(ATTR_MODE): vol.In(temp_modes), + vol.Optional(ATTR_PERIOD): vol.All( cv.time_period, vol.Range(min=timedelta(days=1), max=timedelta(days=99)), ), diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 8a455b300f8..b44dc9791b0 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -29,7 +29,7 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature +from homeassistant.const import ATTR_MODE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -38,11 +38,10 @@ from homeassistant.util import dt as dt_util from . import EVOHOME_KEY from .const import ( - ATTR_DURATION_DAYS, - ATTR_DURATION_HOURS, + ATTR_DURATION, ATTR_DURATION_UNTIL, - ATTR_SYSTEM_MODE, - ATTR_ZONE_TEMP, + ATTR_PERIOD, + ATTR_SETPOINT, EvoService, ) from .coordinator import EvoDataUpdateCoordinator @@ -180,7 +179,7 @@ class EvoZone(EvoChild, EvoClimateEntity): return # otherwise it is EvoService.SET_ZONE_OVERRIDE - temperature = max(min(data[ATTR_ZONE_TEMP], self.max_temp), self.min_temp) + temperature = max(min(data[ATTR_SETPOINT], self.max_temp), self.min_temp) if ATTR_DURATION_UNTIL in data: duration: timedelta = data[ATTR_DURATION_UNTIL] @@ -349,16 +348,16 @@ class EvoController(EvoClimateEntity): Data validation is not required, it will have been done upstream. """ if service == EvoService.SET_SYSTEM_MODE: - mode = data[ATTR_SYSTEM_MODE] + mode = data[ATTR_MODE] else: # otherwise it is EvoService.RESET_SYSTEM mode = EvoSystemMode.AUTO_WITH_RESET - if ATTR_DURATION_DAYS in data: + if ATTR_PERIOD in data: until = dt_util.start_of_local_day() - until += data[ATTR_DURATION_DAYS] + until += data[ATTR_PERIOD] - elif ATTR_DURATION_HOURS in data: - until = dt_util.now() + data[ATTR_DURATION_HOURS] + elif ATTR_DURATION in data: + until = dt_util.now() + data[ATTR_DURATION] else: until = None diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index 12642addfa4..9da5969df1e 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -18,11 +18,10 @@ USER_DATA: Final = "user_data" SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300) SCAN_INTERVAL_MINIMUM: Final = timedelta(seconds=60) -ATTR_SYSTEM_MODE: Final = "mode" -ATTR_DURATION_DAYS: Final = "period" -ATTR_DURATION_HOURS: Final = "duration" +ATTR_PERIOD: Final = "period" # number of days +ATTR_DURATION: Final = "duration" # number of minutes, <24h -ATTR_ZONE_TEMP: Final = "setpoint" +ATTR_SETPOINT: Final = "setpoint" ATTR_DURATION_UNTIL: Final = "duration" diff --git a/tests/components/evohome/test_evo_services.py b/tests/components/evohome/test_evo_services.py new file mode 100644 index 00000000000..c9f20aecd4f --- /dev/null +++ b/tests/components/evohome/test_evo_services.py @@ -0,0 +1,177 @@ +"""The tests for the native services of Evohome.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import patch + +from evohomeasync2 import EvohomeClient +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.evohome.const import ( + ATTR_DURATION, + ATTR_PERIOD, + ATTR_SETPOINT, + DOMAIN, + EvoService, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize("install", ["default"]) +async def test_service_refresh_system( + hass: HomeAssistant, + evohome: EvohomeClient, +) -> None: + """Test Evohome's refresh_system service (for all temperature control systems).""" + + # EvoService.REFRESH_SYSTEM + with patch("evohomeasync2.location.Location.update") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.REFRESH_SYSTEM, + {}, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with() + + +@pytest.mark.parametrize("install", ["default"]) +async def test_service_reset_system( + hass: HomeAssistant, + ctl_id: str, +) -> None: + """Test Evohome's reset_system service (for a temperature control system).""" + + # EvoService.RESET_SYSTEM (if SZ_AUTO_WITH_RESET in modes) + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.RESET_SYSTEM, + {}, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with("AutoWithReset", until=None) + + +@pytest.mark.parametrize("install", ["default"]) +async def test_ctl_set_system_mode( + hass: HomeAssistant, + ctl_id: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test Evohome's set_system_mode service (for a temperature control system).""" + + # EvoService.SET_SYSTEM_MODE: Auto + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_SYSTEM_MODE, + { + ATTR_MODE: "Auto", + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with("Auto", until=None) + + freezer.move_to("2024-07-10T12:00:00+00:00") + + # EvoService.SET_SYSTEM_MODE: AutoWithEco, hours=12 + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_SYSTEM_MODE, + { + ATTR_MODE: "AutoWithEco", + ATTR_DURATION: {"hours": 12}, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with( + "AutoWithEco", until=datetime(2024, 7, 11, 0, 0, tzinfo=UTC) + ) + + # EvoService.SET_SYSTEM_MODE: Away, days=7 + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_SYSTEM_MODE, + { + ATTR_MODE: "Away", + ATTR_PERIOD: {"days": 7}, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with( + "Away", until=datetime(2024, 7, 16, 23, 0, tzinfo=UTC) + ) + + +@pytest.mark.parametrize("install", ["default"]) +async def test_zone_clear_zone_override( + hass: HomeAssistant, + zone_id: str, +) -> None: + """Test Evohome's clear_zone_override service (for a heating zone).""" + + # EvoZoneMode.FOLLOW_SCHEDULE + with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.RESET_ZONE_OVERRIDE, + { + ATTR_ENTITY_ID: zone_id, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with() + + +@pytest.mark.parametrize("install", ["default"]) +async def test_zone_set_zone_override( + hass: HomeAssistant, + zone_id: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test Evohome's set_zone_override service (for a heating zone).""" + + freezer.move_to("2024-07-10T12:00:00+00:00") + + # EvoZoneMode.PERMANENT_OVERRIDE + with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_ZONE_OVERRIDE, + { + ATTR_ENTITY_ID: zone_id, + ATTR_SETPOINT: 19.5, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with(19.5, until=None) + + # EvoZoneMode.TEMPORARY_OVERRIDE + with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_ZONE_OVERRIDE, + { + ATTR_ENTITY_ID: zone_id, + ATTR_SETPOINT: 19.5, + ATTR_DURATION: {"minutes": 135}, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with( + 19.5, until=datetime(2024, 7, 10, 14, 15, tzinfo=UTC) + ) diff --git a/tests/components/evohome/test_init.py b/tests/components/evohome/test_init.py index d327bdf14b4..53b9258523d 100644 --- a/tests/components/evohome/test_init.py +++ b/tests/components/evohome/test_init.py @@ -1,4 +1,4 @@ -"""The tests for evohome.""" +"""The tests for Evohome.""" from __future__ import annotations @@ -11,7 +11,7 @@ from evohomeasync2 import EvohomeClient, exceptions as exc import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.evohome.const import DOMAIN, EvoService +from homeassistant.components.evohome.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -187,41 +187,3 @@ async def test_setup( """ assert hass.services.async_services_for_domain(DOMAIN).keys() == snapshot - - -@pytest.mark.parametrize("install", ["default"]) -async def test_service_refresh_system( - hass: HomeAssistant, - evohome: EvohomeClient, -) -> None: - """Test EvoService.REFRESH_SYSTEM of an evohome system.""" - - # EvoService.REFRESH_SYSTEM - with patch("evohomeasync2.location.Location.update") as mock_fcn: - await hass.services.async_call( - DOMAIN, - EvoService.REFRESH_SYSTEM, - {}, - blocking=True, - ) - - mock_fcn.assert_awaited_once_with() - - -@pytest.mark.parametrize("install", ["default"]) -async def test_service_reset_system( - hass: HomeAssistant, - evohome: EvohomeClient, -) -> None: - """Test EvoService.RESET_SYSTEM of an evohome system.""" - - # EvoService.RESET_SYSTEM (if SZ_AUTO_WITH_RESET in modes) - with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: - await hass.services.async_call( - DOMAIN, - EvoService.RESET_SYSTEM, - {}, - blocking=True, - ) - - mock_fcn.assert_awaited_once_with("AutoWithReset", until=None) From f7a6d163bb132c15d827bd15f33c183afe861a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 23 Feb 2025 12:44:55 +0100 Subject: [PATCH 2655/2987] Add Home Connect functional light color temperature percent setting (#139096) Add functional light color temperature percent setting --- homeassistant/components/home_connect/number.py | 5 +++++ homeassistant/components/home_connect/strings.json | 3 +++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 63df33e5432..27b4bc7eb6f 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -83,6 +83,11 @@ NUMBERS = ( device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_3_setpoint_temperature", ), + NumberEntityDescription( + key=SettingKey.COOKING_HOOD_COLOR_TEMPERATURE_PERCENT, + translation_key="color_temperature_percent", + native_unit_of_measurement="%", + ), NumberEntityDescription( key=SettingKey.LAUNDRY_CARE_WASHER_I_DOS_1_BASE_LEVEL, device_class=NumberDeviceClass.VOLUME, diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index dde002d1caa..d6330c8b78b 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -874,6 +874,9 @@ "wine_compartment_3_setpoint_temperature": { "name": "Wine compartment 3 temperature" }, + "color_temperature_percent": { + "name": "Functional light color temperature percent" + }, "washer_i_dos_1_base_level": { "name": "i-Dos 1 base level" }, From 4ca39636e27ccfaa271c0bc4784404111874255a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 23 Feb 2025 13:27:14 +0100 Subject: [PATCH 2656/2987] Backup location feature requires Synology DSM 6.0 and higher (#139106) * the filestation api requires dsm 6.0 * fix tests --- .../components/synology_dsm/common.py | 10 +++++++-- tests/components/synology_dsm/common.py | 22 +++++++++++++++++++ tests/components/synology_dsm/conftest.py | 3 +++ tests/components/synology_dsm/test_backup.py | 7 +++--- .../synology_dsm/test_config_flow.py | 11 +++++----- .../synology_dsm/test_media_source.py | 2 ++ tests/components/synology_dsm/test_repairs.py | 5 +++-- 7 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 tests/components/synology_dsm/common.py diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index d61944c146d..2e80624ca5d 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -7,6 +7,7 @@ from collections.abc import Callable from contextlib import suppress import logging +from awesomeversion import AwesomeVersion from synology_dsm import SynologyDSM from synology_dsm.api.core.security import SynoCoreSecurity from synology_dsm.api.core.system import SynoCoreSystem @@ -135,6 +136,9 @@ class SynoApi: ) await self.async_login() + self.information = self.dsm.information + await self.information.update() + # check if surveillance station is used self._with_surveillance_station = bool( self.dsm.apis.get(SynoSurveillanceStation.CAMERA_API_KEY) @@ -165,7 +169,10 @@ class SynoApi: LOGGER.debug("Disabled fetching upgrade data during setup: %s", ex) # check if file station is used and permitted - self._with_file_station = bool(self.dsm.apis.get(SynoFileStation.LIST_API_KEY)) + self._with_file_station = bool( + self.information.awesome_version >= AwesomeVersion("6.0") + and self.dsm.apis.get(SynoFileStation.LIST_API_KEY) + ) if self._with_file_station: shares: list | None = None with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS): @@ -317,7 +324,6 @@ class SynoApi: async def _fetch_device_configuration(self) -> None: """Fetch initial device config.""" - self.information = self.dsm.information self.network = self.dsm.network await self.network.update() diff --git a/tests/components/synology_dsm/common.py b/tests/components/synology_dsm/common.py new file mode 100644 index 00000000000..e98b0d21d66 --- /dev/null +++ b/tests/components/synology_dsm/common.py @@ -0,0 +1,22 @@ +"""Configure Synology DSM tests.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock + +from awesomeversion import AwesomeVersion + +from .consts import SERIAL + + +def mock_dsm_information( + serial: str | None = SERIAL, + update_result: bool = True, + awesome_version: str = "7.2", +) -> Mock: + """Mock SynologyDSM information.""" + return Mock( + serial=serial, + update=AsyncMock(return_value=update_result), + awesome_version=AwesomeVersion(awesome_version), + ) diff --git a/tests/components/synology_dsm/conftest.py b/tests/components/synology_dsm/conftest.py index 331c879332d..96d6453cf16 100644 --- a/tests/components/synology_dsm/conftest.py +++ b/tests/components/synology_dsm/conftest.py @@ -8,6 +8,8 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .common import mock_dsm_information + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -31,6 +33,7 @@ def fixture_dsm(): dsm.login = AsyncMock(return_value=True) dsm.update = AsyncMock(return_value=True) + dsm.information = mock_dsm_information() dsm.network.update = AsyncMock(return_value=True) dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index ea68bbc991c..8e98f4dffa9 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -31,7 +31,8 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockStreamReader -from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME +from .common import mock_dsm_information +from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -99,7 +100,7 @@ def mock_dsm_with_filestation(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() dsm.file = AsyncMock( get_shared_folders=AsyncMock( return_value=[ @@ -147,12 +148,12 @@ def mock_dsm_without_filestation(): 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.information = mock_dsm_information() dsm.storage = Mock( disks_ids=["sda", "sdb", "sdc"], volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) dsm.file = None yield dsm diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index b25cf7a81ac..932cf057d3d 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -40,6 +40,7 @@ from homeassistant.helpers.service_info.ssdp import ( ) from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from .common import mock_dsm_information from .consts import ( DEVICE_TOKEN, HOST, @@ -72,7 +73,7 @@ def mock_controller_service(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm @@ -95,7 +96,7 @@ def mock_controller_service_2sa(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm @@ -116,7 +117,7 @@ def mock_controller_service_vdsm(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm @@ -137,7 +138,7 @@ def mock_controller_service_with_filestation(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() dsm.file = AsyncMock( get_shared_folders=AsyncMock( return_value=[ @@ -170,7 +171,7 @@ def mock_controller_service_failed(): volumes_ids=[], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=None) + dsm.information = mock_dsm_information(serial=None) dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm diff --git a/tests/components/synology_dsm/test_media_source.py b/tests/components/synology_dsm/test_media_source.py index baa91822ca0..dd454f92137 100644 --- a/tests/components/synology_dsm/test_media_source.py +++ b/tests/components/synology_dsm/test_media_source.py @@ -33,6 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.util.aiohttp import MockRequest +from .common import mock_dsm_information from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME from tests.common import MockConfigEntry @@ -44,6 +45,7 @@ def dsm_with_photos() -> MagicMock: dsm = MagicMock() dsm.login = AsyncMock(return_value=True) dsm.update = AsyncMock(return_value=True) + dsm.information = mock_dsm_information() dsm.network.update = AsyncMock(return_value=True) dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) diff --git a/tests/components/synology_dsm/test_repairs.py b/tests/components/synology_dsm/test_repairs.py index b2e7352f214..0dea980b553 100644 --- a/tests/components/synology_dsm/test_repairs.py +++ b/tests/components/synology_dsm/test_repairs.py @@ -25,7 +25,8 @@ 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 .common import mock_dsm_information +from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME from tests.common import ANY, MockConfigEntry from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow @@ -48,7 +49,7 @@ def mock_dsm_with_filestation(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() dsm.file = AsyncMock( get_shared_folders=AsyncMock( return_value=[ From 6ebda9322ddb170493d685ff0c374cdfa7c2fd88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 23 Feb 2025 13:54:02 +0100 Subject: [PATCH 2657/2987] Fetch allowed values for select entities at Home Connect (#139103) Fetch allowed values for enum settings --- .../components/home_connect/select.py | 30 +++++++--- tests/components/home_connect/test_select.py | 57 +++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index e4d50b0d5e9..d5657387358 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -1,6 +1,7 @@ """Provides a select platform for Home Connect.""" from collections.abc import Callable, Coroutine +import contextlib from dataclasses import dataclass from typing import Any, cast @@ -423,13 +424,6 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): appliance, desc, ) - setting = appliance.settings.get(cast(SettingKey, desc.key)) - if setting and setting.constraints and setting.constraints.allowed_values: - self._attr_options = [ - desc.values_translation_key[option] - for option in setting.constraints.allowed_values - if option in desc.values_translation_key - ] async def async_select_option(self, option: str) -> None: """Select new option.""" @@ -459,6 +453,28 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): data.value ) + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + setting = self.appliance.settings.get(cast(SettingKey, self.bsh_key)) + if ( + not setting + or not setting.constraints + or not setting.constraints.allowed_values + ): + with contextlib.suppress(HomeConnectError): + setting = await self.coordinator.client.get_setting( + self.appliance.info.ha_id, + setting_key=cast(SettingKey, self.bsh_key), + ) + + if setting and setting.constraints and setting.constraints.allowed_values: + self._attr_options = [ + self.entity_description.values_translation_key[option] + for option in setting.constraints.allowed_values + if option in self.entity_description.values_translation_key + ] + class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): """Select option class for Home Connect.""" diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index d98dbd8e5f6..22ece365e6b 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -509,6 +509,63 @@ async def test_select_functionality( assert hass.states.is_state(entity_id, value_to_set) +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize( + ( + "entity_id", + "test_setting_key", + "allowed_values", + "expected_options", + ), + [ + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + [f"BSH.Common.EnumType.AmbientLightColor.Color{i}" for i in range(50)], + {str(i) for i in range(1, 50)}, + ), + ], +) +async def test_fetch_allowed_values( + appliance_ha_id: str, + entity_id: str, + test_setting_key: SettingKey, + allowed_values: list[str | None], + expected_options: set[str], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test fetch allowed values.""" + original_get_setting_side_effect = client.get_setting + + async def get_setting_side_effect( + ha_id: str, setting_key: SettingKey + ) -> GetSetting: + if ha_id != appliance_ha_id or setting_key != test_setting_key: + return await original_get_setting_side_effect(ha_id, setting_key) + return GetSetting( + key=test_setting_key, + raw_key=test_setting_key.value, + value="", # Not important + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ) + + client.get_setting = AsyncMock(side_effect=get_setting_side_effect) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + @pytest.mark.parametrize( ("entity_id", "setting_key", "allowed_value", "value_to_set", "mock_attr"), [ From bd919159e58034073eadad8d18fa4faa81df3c6c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 23 Feb 2025 13:59:30 +0100 Subject: [PATCH 2658/2987] Bump aiohue to 4.7.4 (#139108) --- homeassistant/components/hue/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 22f1d3991e7..8bc3d84bd50 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -10,6 +10,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiohue"], - "requirements": ["aiohue==4.7.3"], + "requirements": ["aiohue==4.7.4"], "zeroconf": ["_hue._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index ef4360a2061..cb03d16903d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -273,7 +273,7 @@ aiohomekit==3.2.7 aiohttp_sse==2.2.0 # homeassistant.components.hue -aiohue==4.7.3 +aiohue==4.7.4 # homeassistant.components.imap aioimaplib==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b78b82d8f2e..af58c786530 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -258,7 +258,7 @@ aiohomekit==3.2.7 aiohttp_sse==2.2.0 # homeassistant.components.hue -aiohue==4.7.3 +aiohue==4.7.4 # homeassistant.components.imap aioimaplib==2.0.1 From 15ca2fe4890fe801b9e51ea7fe9e7420f61e0314 Mon Sep 17 00:00:00 2001 From: Tom Brien Date: Sun, 23 Feb 2025 13:21:41 +0000 Subject: [PATCH 2659/2987] Waze action support entities (#139068) --- .../components/waze_travel_time/__init__.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py index 34f22c9218f..3a91690ef07 100644 --- a/homeassistant/components/waze_travel_time/__init__.py +++ b/homeassistant/components/waze_travel_time/__init__.py @@ -17,6 +17,7 @@ from homeassistant.core import ( SupportsResponse, ) from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.location import find_coordinates from homeassistant.helpers.selector import ( BooleanSelector, SelectSelector, @@ -115,10 +116,21 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b client = WazeRouteCalculator( region=service.data[CONF_REGION].upper(), client=httpx_client ) + + origin_coordinates = find_coordinates(hass, service.data[CONF_ORIGIN]) + destination_coordinates = find_coordinates(hass, service.data[CONF_DESTINATION]) + + origin = origin_coordinates if origin_coordinates else service.data[CONF_ORIGIN] + destination = ( + destination_coordinates + if destination_coordinates + else service.data[CONF_DESTINATION] + ) + response = await async_get_travel_times( client=client, - origin=service.data[CONF_ORIGIN], - destination=service.data[CONF_DESTINATION], + origin=origin, + destination=destination, vehicle_type=service.data[CONF_VEHICLE_TYPE], avoid_toll_roads=service.data[CONF_AVOID_TOLL_ROADS], avoid_subscription_roads=service.data[CONF_AVOID_SUBSCRIPTION_ROADS], From 800fe1b01e2d89d37eff2ce3cdc0c2c1885f7916 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Sun, 23 Feb 2025 14:42:54 +0100 Subject: [PATCH 2660/2987] Remove individual lcn devices for each entity (#136450) --- homeassistant/components/lcn/__init__.py | 4 ++ homeassistant/components/lcn/entity.py | 35 +++++----------- homeassistant/components/lcn/helpers.py | 44 --------------------- tests/components/lcn/test_device_trigger.py | 16 ++++---- 4 files changed, 23 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 58924413c56..256e132b30d 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -49,6 +49,7 @@ from .helpers import ( InputType, async_update_config_entry, generate_unique_id, + purge_device_registry, register_lcn_address_devices, register_lcn_host_device, ) @@ -120,6 +121,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b register_lcn_host_device(hass, config_entry) register_lcn_address_devices(hass, config_entry) + # clean up orphaned devices + purge_device_registry(hass, config_entry.entry_id, {**config_entry.data}) + # forward config_entry to components await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/homeassistant/components/lcn/entity.py b/homeassistant/components/lcn/entity.py index 12d8f966801..ffb680c4237 100644 --- a/homeassistant/components/lcn/entity.py +++ b/homeassistant/components/lcn/entity.py @@ -3,19 +3,18 @@ from collections.abc import Callable from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_NAME, CONF_RESOURCE +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_RESOURCE from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType -from .const import CONF_DOMAIN_DATA, DOMAIN +from .const import DOMAIN from .helpers import ( AddressType, DeviceConnectionType, InputType, generate_unique_id, get_device_connection, - get_device_model, ) @@ -36,6 +35,14 @@ class LcnEntity(Entity): self.address: AddressType = config[CONF_ADDRESS] self._unregister_for_inputs: Callable | None = None self._name: str = config[CONF_NAME] + self._attr_device_info = DeviceInfo( + identifiers={ + ( + DOMAIN, + generate_unique_id(self.config_entry.entry_id, self.address), + ) + }, + ) @property def unique_id(self) -> str: @@ -44,28 +51,6 @@ class LcnEntity(Entity): self.config_entry.entry_id, self.address, self.config[CONF_RESOURCE] ) - @property - def device_info(self) -> DeviceInfo | None: - """Return device specific attributes.""" - address = f"{'g' if self.address[2] else 'm'}{self.address[0]:03d}{self.address[1]:03d}" - model = ( - "LCN resource" - f" ({get_device_model(self.config[CONF_DOMAIN], self.config[CONF_DOMAIN_DATA])})" - ) - - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - name=f"{address}.{self.config[CONF_RESOURCE]}", - model=model, - manufacturer="Issendorff", - via_device=( - DOMAIN, - generate_unique_id( - self.config_entry.entry_id, self.config[CONF_ADDRESS] - ), - ), - ) - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" self.device_connection = get_device_connection( diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index b999c6f3770..2176c669251 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from copy import deepcopy -from itertools import chain import re from typing import cast @@ -22,7 +21,6 @@ from homeassistant.const import ( CONF_NAME, CONF_RESOURCE, CONF_SENSORS, - CONF_SOURCE, CONF_SWITCHES, ) from homeassistant.core import HomeAssistant @@ -30,23 +28,14 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType from .const import ( - BINSENSOR_PORTS, CONF_CLIMATES, CONF_HARDWARE_SERIAL, CONF_HARDWARE_TYPE, - CONF_OUTPUT, CONF_SCENES, CONF_SOFTWARE_SERIAL, CONNECTION, DEVICE_CONNECTIONS, DOMAIN, - LED_PORTS, - LOGICOP_PORTS, - OUTPUT_PORTS, - S0_INPUTS, - SETPOINTS, - THRESHOLDS, - VARIABLES, ) # typing @@ -96,31 +85,6 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str: raise ValueError("Unknown domain") -def get_device_model(domain_name: str, domain_data: ConfigType) -> str: - """Return the model for the specified domain_data.""" - if domain_name in ("switch", "light"): - return "Output" if domain_data[CONF_OUTPUT] in OUTPUT_PORTS else "Relay" - if domain_name in ("binary_sensor", "sensor"): - if domain_data[CONF_SOURCE] in BINSENSOR_PORTS: - return "Binary Sensor" - if domain_data[CONF_SOURCE] in chain( - VARIABLES, SETPOINTS, THRESHOLDS, S0_INPUTS - ): - return "Variable" - if domain_data[CONF_SOURCE] in LED_PORTS: - return "Led" - if domain_data[CONF_SOURCE] in LOGICOP_PORTS: - return "Logical Operation" - return "Key" - if domain_name == "cover": - return "Motor" - if domain_name == "climate": - return "Regulator" - if domain_name == "scene": - return "Scene" - raise ValueError("Unknown domain") - - def generate_unique_id( entry_id: str, address: AddressType, @@ -169,13 +133,6 @@ def purge_device_registry( ) -> None: """Remove orphans from device registry which are not in entry data.""" device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - - # Find all devices that are referenced in the entity registry. - references_entities = { - entry.device_id - for entry in entity_registry.entities.get_entries_for_config_entry_id(entry_id) - } # Find device that references the host. references_host = set() @@ -198,7 +155,6 @@ def purge_device_registry( entry.id for entry in dr.async_entries_for_config_entry(device_registry, entry_id) } - - references_entities - references_host - references_entry_data ) diff --git a/tests/components/lcn/test_device_trigger.py b/tests/components/lcn/test_device_trigger.py index 6537c108981..94eb96591e2 100644 --- a/tests/components/lcn/test_device_trigger.py +++ b/tests/components/lcn/test_device_trigger.py @@ -45,9 +45,14 @@ async def test_get_triggers_module_device( ) ] - triggers = await async_get_device_automations( - hass, DeviceAutomationType.TRIGGER, device.id - ) + triggers = [ + trigger + for trigger in await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + if trigger[CONF_DOMAIN] == DOMAIN + ] + assert triggers == unordered(expected_triggers) @@ -63,11 +68,8 @@ async def test_get_triggers_non_module_device( identifiers={(DOMAIN, entry.entry_id)} ) group_device = get_device(hass, entry, (0, 5, True)) - resource_device = device_registry.async_get_device( - identifiers={(DOMAIN, f"{entry.entry_id}-m000007-output1")} - ) - for device in (host_device, group_device, resource_device): + for device in (host_device, group_device): triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device.id ) From c1e5673cbd11b84d7146eaa4fddd07308ebcc447 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sun, 23 Feb 2025 14:46:37 +0100 Subject: [PATCH 2661/2987] Allow rename of the backup folder for OneDrive (#138407) --- homeassistant/components/onedrive/__init__.py | 104 ++++++--- homeassistant/components/onedrive/backup.py | 2 +- .../components/onedrive/config_flow.py | 158 +++++++++++-- homeassistant/components/onedrive/const.py | 2 + .../components/onedrive/quality_scale.yaml | 5 +- .../components/onedrive/strings.json | 28 ++- tests/components/onedrive/conftest.py | 113 +++++++++- tests/components/onedrive/const.py | 45 +--- tests/components/onedrive/test_config_flow.py | 212 +++++++++++++++++- tests/components/onedrive/test_init.py | 128 ++++++++++- 10 files changed, 681 insertions(+), 116 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 4aa11daf39d..6805b073ea2 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from html import unescape from json import dumps, loads import logging @@ -10,10 +11,10 @@ from typing import cast from onedrive_personal_sdk import OneDriveClient from onedrive_personal_sdk.exceptions import ( AuthenticationError, - HttpRequestException, + NotFoundError, OneDriveException, ) -from onedrive_personal_sdk.models.items import ItemUpdate +from onedrive_personal_sdk.models.items import Item, ItemUpdate from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant, callback @@ -25,7 +26,7 @@ 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 .const import CONF_FOLDER_ID, CONF_FOLDER_NAME, DATA_BACKUP_AGENT_LISTENERS, DOMAIN from .coordinator import ( OneDriveConfigEntry, OneDriveRuntimeData, @@ -50,33 +51,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> client = OneDriveClient(get_access_token, async_get_clientsession(hass)) # get approot, will be created automatically if it does not exist - try: - approot = await client.get_approot() - except AuthenticationError as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="authentication_failed" - ) from err - except (HttpRequestException, OneDriveException, TimeoutError) as err: - _LOGGER.debug("Failed to get approot", exc_info=True) - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="failed_to_get_folder", - translation_placeholders={"folder": "approot"}, - ) from err + approot = await _handle_item_operation(client.get_approot, "approot") + folder_name = entry.data[CONF_FOLDER_NAME] - instance_id = await async_get_instance_id(hass) - backup_folder_name = f"backups_{instance_id[:8]}" try: - backup_folder = await client.create_folder( - parent_id=approot.id, name=backup_folder_name + backup_folder = await _handle_item_operation( + lambda: client.get_drive_item(path_or_id=entry.data[CONF_FOLDER_ID]), + folder_name, + ) + except NotFoundError: + _LOGGER.debug("Creating backup folder %s", folder_name) + backup_folder = await _handle_item_operation( + lambda: client.create_folder(parent_id=approot.id, name=folder_name), + folder_name, + ) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_FOLDER_ID: backup_folder.id} + ) + + # write instance id to description + if backup_folder.description != (instance_id := await async_get_instance_id(hass)): + await _handle_item_operation( + lambda: client.update_drive_item( + backup_folder.id, ItemUpdate(description=instance_id) + ), + folder_name, + ) + + # update in case folder was renamed manually inside OneDrive + if backup_folder.name != entry.data[CONF_FOLDER_NAME]: + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_FOLDER_NAME: backup_folder.name} ) - except (HttpRequestException, OneDriveException, TimeoutError) as err: - _LOGGER.debug("Failed to create backup folder", exc_info=True) - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="failed_to_get_folder", - translation_placeholders={"folder": backup_folder_name}, - ) from err coordinator = OneDriveUpdateCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() @@ -152,3 +158,47 @@ async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) - data=ItemUpdate(description=""), ) _LOGGER.debug("Migrated backup file %s", file.name) + + +async def async_migrate_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: + """Migrate old entry.""" + if entry.version > 1: + # This means the user has downgraded from a future version + return False + + if (version := entry.version) == 1 and (minor_version := entry.minor_version) == 1: + _LOGGER.debug( + "Migrating OneDrive config entry from version %s.%s", version, minor_version + ) + + instance_id = await async_get_instance_id(hass) + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_FOLDER_ID: "id", # will be updated during setup_entry + CONF_FOLDER_NAME: f"backups_{instance_id[:8]}", + }, + ) + _LOGGER.debug("Migration to version 1.2 successful") + return True + + +async def _handle_item_operation( + func: Callable[[], Awaitable[Item]], folder: str +) -> Item: + try: + return await func() + except NotFoundError: + raise + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + except (OneDriveException, TimeoutError) as err: + _LOGGER.debug("Failed to get approot", exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": folder}, + ) from err diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index f8a2a6699c4..9c7371bee4b 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -74,7 +74,7 @@ def async_register_backup_agents_listener( def handle_backup_errors[_R, **P]( func: Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]], ) -> Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]]: - """Handle backup errors with a specific translation key.""" + """Handle backup errors.""" @wraps(func) async def wrapper( diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py index 06c9ec253e3..3374c0369ee 100644 --- a/homeassistant/components/onedrive/config_flow.py +++ b/homeassistant/components/onedrive/config_flow.py @@ -8,22 +8,47 @@ from typing import Any, cast from onedrive_personal_sdk.clients.client import OneDriveClient from onedrive_personal_sdk.exceptions import OneDriveException +from onedrive_personal_sdk.models.items import AppRoot, ItemUpdate import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler +from homeassistant.helpers.instance_id import async_get as async_get_instance_id -from .const import CONF_DELETE_PERMANENTLY, DOMAIN, OAUTH_SCOPES +from .const import ( + CONF_DELETE_PERMANENTLY, + CONF_FOLDER_ID, + CONF_FOLDER_NAME, + DOMAIN, + OAUTH_SCOPES, +) from .coordinator import OneDriveConfigEntry +FOLDER_NAME_SCHEMA = vol.Schema({vol.Required(CONF_FOLDER_NAME): str}) + class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Config flow to handle OneDrive OAuth2 authentication.""" DOMAIN = DOMAIN + MINOR_VERSION = 2 + + client: OneDriveClient + approot: AppRoot + + def __init__(self) -> None: + """Initialize the OneDrive config flow.""" + super().__init__() + self.step_data: dict[str, Any] = {} @property def logger(self) -> logging.Logger: @@ -35,6 +60,15 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Extra data that needs to be appended to the authorize url.""" return {"scope": " ".join(OAUTH_SCOPES)} + @property + def apps_folder(self) -> str: + """Return the name of the Apps folder (translated).""" + return ( + path.split("/")[-1] + if (path := self.approot.parent_reference.path) + else "Apps" + ) + async def async_oauth_create_entry( self, data: dict[str, Any], @@ -44,12 +78,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): async def get_access_token() -> str: return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) - graph_client = OneDriveClient( + self.client = OneDriveClient( get_access_token, async_get_clientsession(self.hass) ) try: - approot = await graph_client.get_approot() + self.approot = await self.client.get_approot() except OneDriveException: self.logger.exception("Failed to connect to OneDrive") return self.async_abort(reason="connection_error") @@ -57,26 +91,118 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self.logger.exception("Unknown error") return self.async_abort(reason="unknown") - await self.async_set_unique_id(approot.parent_reference.drive_id) + await self.async_set_unique_id(self.approot.parent_reference.drive_id) - if self.source == SOURCE_REAUTH: - reauth_entry = self._get_reauth_entry() + if self.source != SOURCE_USER: self._abort_if_unique_id_mismatch( reason="wrong_drive", ) + + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() return self.async_update_reload_and_abort( entry=reauth_entry, data=data, ) - self._abort_if_unique_id_configured() + if self.source != SOURCE_RECONFIGURE: + self._abort_if_unique_id_configured() - title = ( - f"{approot.created_by.user.display_name}'s OneDrive" - if approot.created_by.user and approot.created_by.user.display_name - else "OneDrive" + self.step_data = data + + if self.source == SOURCE_RECONFIGURE: + return await self.async_step_reconfigure_folder() + + return await self.async_step_folder_name() + + async def async_step_folder_name( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step to ask for the folder name.""" + errors: dict[str, str] = {} + instance_id = await async_get_instance_id(self.hass) + if user_input is not None: + try: + folder = await self.client.create_folder( + self.approot.id, user_input[CONF_FOLDER_NAME] + ) + except OneDriveException: + self.logger.debug("Failed to create folder", exc_info=True) + errors["base"] = "folder_creation_error" + else: + if folder.description and folder.description != instance_id: + errors[CONF_FOLDER_NAME] = "folder_already_in_use" + if not errors: + title = ( + f"{self.approot.created_by.user.display_name}'s OneDrive" + if self.approot.created_by.user + and self.approot.created_by.user.display_name + else "OneDrive" + ) + return self.async_create_entry( + title=title, + data={ + **self.step_data, + CONF_FOLDER_ID: folder.id, + CONF_FOLDER_NAME: user_input[CONF_FOLDER_NAME], + }, + ) + + default_folder_name = ( + f"backups_{instance_id[:8]}" + if user_input is None + else user_input[CONF_FOLDER_NAME] + ) + + return self.async_show_form( + step_id="folder_name", + data_schema=self.add_suggested_values_to_schema( + FOLDER_NAME_SCHEMA, {CONF_FOLDER_NAME: default_folder_name} + ), + description_placeholders={ + "apps_folder": self.apps_folder, + "approot": self.approot.name, + }, + errors=errors, + ) + + async def async_step_reconfigure_folder( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the folder name.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + if ( + new_folder_name := user_input[CONF_FOLDER_NAME] + ) != reconfigure_entry.data[CONF_FOLDER_NAME]: + try: + await self.client.update_drive_item( + reconfigure_entry.data[CONF_FOLDER_ID], + ItemUpdate(name=new_folder_name), + ) + except OneDriveException: + self.logger.debug("Failed to update folder", exc_info=True) + errors["base"] = "folder_rename_error" + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + data={**reconfigure_entry.data, CONF_FOLDER_NAME: new_folder_name}, + ) + + return self.async_show_form( + step_id="reconfigure_folder", + data_schema=self.add_suggested_values_to_schema( + FOLDER_NAME_SCHEMA, + {CONF_FOLDER_NAME: reconfigure_entry.data[CONF_FOLDER_NAME]}, + ), + description_placeholders={ + "apps_folder": self.apps_folder, + "approot": self.approot.name, + }, + errors=errors, ) - return self.async_create_entry(title=title, data=data) async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -92,6 +218,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): return self.async_show_form(step_id="reauth_confirm") return await self.async_step_user() + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the entry.""" + return await self.async_step_user() + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/onedrive/const.py b/homeassistant/components/onedrive/const.py index 7aefa26ea81..fd21d84369c 100644 --- a/homeassistant/components/onedrive/const.py +++ b/homeassistant/components/onedrive/const.py @@ -6,6 +6,8 @@ from typing import Final from homeassistant.util.hass_dict import HassKey DOMAIN: Final = "onedrive" +CONF_FOLDER_NAME: Final = "folder_name" +CONF_FOLDER_ID: Final = "folder_id" CONF_DELETE_PERMANENTLY: Final = "delete_permanently" diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml index 44754e76f2c..dd9e7f26102 100644 --- a/homeassistant/components/onedrive/quality_scale.yaml +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -73,10 +73,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: - status: exempt - comment: | - Nothing to reconfigure. + reconfiguration-flow: done repair-issues: done stale-devices: status: exempt diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 27afe3e8a9b..37e19eb68ca 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -7,6 +7,26 @@ "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The OneDrive integration needs to re-authenticate your account" + }, + "folder_name": { + "title": "Pick a folder name", + "description": "This name will be used to create a folder that is specific for this Home Assistant instance. This folder will be created inside `{apps_folder}/{approot}`", + "data": { + "folder_name": "Folder name" + }, + "data_description": { + "folder_name": "Name of the folder" + } + }, + "reconfigure_folder": { + "title": "Change the folder name", + "description": "Rename the instance specific folder inside `{apps_folder}/{approot}`. This will only rename the folder (and does not select another folder), so make sure the new name is not already in use.", + "data": { + "folder_name": "[%key:component::onedrive::config::step::folder_name::data::folder_name%]" + }, + "data_description": { + "folder_name": "[%key:component::onedrive::config::step::folder_name::data_description::folder_name%]" + } } }, "abort": { @@ -23,10 +43,16 @@ "connection_error": "Failed to connect to OneDrive.", "wrong_drive": "New account does not contain previously configured OneDrive.", "unknown": "[%key:common::config_flow::error::unknown%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" + }, + "error": { + "folder_rename_error": "Failed to rename folder", + "folder_creation_error": "Failed to create folder", + "folder_already_in_use": "Folder already used for backups from another Home Assistant instance" } }, "options": { diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index ed419c820a9..8ff650012f9 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -5,13 +5,28 @@ from json import dumps import time from unittest.mock import AsyncMock, MagicMock, patch +from onedrive_personal_sdk.const import DriveState, DriveType +from onedrive_personal_sdk.models.items import ( + AppRoot, + Drive, + DriveQuota, + Folder, + IdentitySet, + ItemParentReference, + User, +) import pytest from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.onedrive.const import DOMAIN, OAUTH_SCOPES +from homeassistant.components.onedrive.const import ( + CONF_FOLDER_ID, + CONF_FOLDER_NAME, + DOMAIN, + OAUTH_SCOPES, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -19,10 +34,9 @@ from .const import ( BACKUP_METADATA, CLIENT_ID, CLIENT_SECRET, - MOCK_APPROOT, + IDENTITY_SET, + INSTANCE_ID, MOCK_BACKUP_FILE, - MOCK_BACKUP_FOLDER, - MOCK_DRIVE, MOCK_METADATA_FILE, ) @@ -66,8 +80,11 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: "expires_at": expires_at, "scope": " ".join(scopes), }, + CONF_FOLDER_NAME: "backups_123", + CONF_FOLDER_ID: "my_folder_id", }, unique_id="mock_drive_id", + minor_version=2, ) @@ -87,14 +104,80 @@ def mock_onedrive_client_init() -> Generator[MagicMock]: yield onedrive_client +@pytest.fixture +def mock_approot() -> AppRoot: + """Return a mocked approot.""" + return AppRoot( + id="id", + child_count=0, + size=0, + name="name", + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + created_by=IdentitySet( + user=User( + display_name="John Doe", + id="id", + email="john@doe.com", + ) + ), + ) + + +@pytest.fixture +def mock_drive() -> Drive: + """Return a mocked drive.""" + return Drive( + id="mock_drive_id", + name="My Drive", + drive_type=DriveType.PERSONAL, + owner=IDENTITY_SET, + quota=DriveQuota( + deleted=5, + remaining=805306368, + state=DriveState.NEARING, + total=5368709120, + used=4250000000, + ), + ) + + +@pytest.fixture +def mock_folder() -> Folder: + """Return a mocked backup folder.""" + return Folder( + id="my_folder_id", + name="name", + size=0, + child_count=0, + description="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + created_by=IdentitySet( + user=User( + display_name="John Doe", + id="id", + email="john@doe.com", + ), + ), + ) + + @pytest.fixture(autouse=True) -def mock_onedrive_client(mock_onedrive_client_init: MagicMock) -> Generator[MagicMock]: +def mock_onedrive_client( + mock_onedrive_client_init: MagicMock, + mock_approot: AppRoot, + mock_drive: Drive, + mock_folder: Folder, +) -> Generator[MagicMock]: """Return a mocked GraphServiceClient.""" client = mock_onedrive_client_init.return_value - client.get_approot.return_value = MOCK_APPROOT - client.create_folder.return_value = MOCK_BACKUP_FOLDER + client.get_approot.return_value = mock_approot + client.create_folder.return_value = mock_folder client.list_drive_items.return_value = [MOCK_BACKUP_FILE, MOCK_METADATA_FILE] - client.get_drive_item.return_value = MOCK_BACKUP_FILE + client.get_drive_item.return_value = mock_folder client.upload_file.return_value = MOCK_METADATA_FILE class MockStreamReader: @@ -105,7 +188,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 + client.get_drive.return_value = mock_drive return client @@ -131,8 +214,14 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(autouse=True) def mock_instance_id() -> Generator[AsyncMock]: """Mock the instance ID.""" - with patch( - "homeassistant.components.onedrive.async_get_instance_id", - return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", + with ( + patch( + "homeassistant.components.onedrive.async_get_instance_id", + return_value=INSTANCE_ID, + ) as mock_instance_id, + patch( + "homeassistant.components.onedrive.config_flow.async_get_instance_id", + new=mock_instance_id, + ), ): yield diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py index 0c04a6f4c82..6e91a7ef0ea 100644 --- a/tests/components/onedrive/const.py +++ b/tests/components/onedrive/const.py @@ -3,13 +3,8 @@ 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, IdentitySet, ItemParentReference, @@ -34,6 +29,8 @@ BACKUP_METADATA = { "size": 34519040, } +INSTANCE_ID = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0" + IDENTITY_SET = IdentitySet( user=User( display_name="John Doe", @@ -42,28 +39,6 @@ IDENTITY_SET = IdentitySet( ) ) -MOCK_APPROOT = AppRoot( - id="id", - child_count=0, - size=0, - name="name", - parent_reference=ItemParentReference( - drive_id="mock_drive_id", id="id", path="path" - ), - created_by=IDENTITY_SET, -) - -MOCK_BACKUP_FOLDER = Folder( - id="id", - name="name", - size=0, - child_count=0, - parent_reference=ItemParentReference( - drive_id="mock_drive_id", id="id", path="path" - ), - created_by=IDENTITY_SET, -) - MOCK_BACKUP_FILE = File( id="id", name="23e64aec.tar", @@ -75,7 +50,6 @@ MOCK_BACKUP_FILE = File( quick_xor_hash="hash", ), mime_type="application/x-tar", - description="", created_by=IDENTITY_SET, ) @@ -101,18 +75,3 @@ 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=805306368, - state=DriveState.NEARING, - total=5368709120, - used=4250000000, - ), -) diff --git a/tests/components/onedrive/test_config_flow.py b/tests/components/onedrive/test_config_flow.py index 1ae92332075..81cd44bd041 100644 --- a/tests/components/onedrive/test_config_flow.py +++ b/tests/components/onedrive/test_config_flow.py @@ -4,11 +4,14 @@ from http import HTTPStatus from unittest.mock import AsyncMock, MagicMock from onedrive_personal_sdk.exceptions import OneDriveException +from onedrive_personal_sdk.models.items import AppRoot, Folder, ItemUpdate import pytest from homeassistant import config_entries from homeassistant.components.onedrive.const import ( CONF_DELETE_PERMANENTLY, + CONF_FOLDER_ID, + CONF_FOLDER_NAME, DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN, @@ -20,7 +23,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow from . import setup_integration -from .const import CLIENT_ID, MOCK_APPROOT +from .const import CLIENT_ID from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -85,6 +88,11 @@ async def test_full_flow( token_callback = mock_onedrive_client_init.call_args[0][0] assert await token_callback() == "mock-access-token" + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -92,6 +100,8 @@ async def test_full_flow( assert result["result"].unique_id == "mock_drive_id" assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + assert result["data"][CONF_FOLDER_NAME] == "myFolder" + assert result["data"][CONF_FOLDER_ID] == "my_folder_id" @pytest.mark.usefixtures("current_request_with_host") @@ -101,10 +111,11 @@ async def test_full_flow_with_owner_not_found( aioclient_mock: AiohttpClientMocker, mock_setup_entry: AsyncMock, mock_onedrive_client: MagicMock, + mock_approot: MagicMock, ) -> None: """Ensure we get a default title if the drive's owner can't be read.""" - mock_onedrive_client.get_approot.return_value.created_by.user = None + mock_approot.created_by.user = None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -112,6 +123,11 @@ async def test_full_flow_with_owner_not_found( await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -119,6 +135,94 @@ async def test_full_flow_with_owner_not_found( assert result["result"].unique_id == "mock_drive_id" assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + assert result["data"][CONF_FOLDER_NAME] == "myFolder" + assert result["data"][CONF_FOLDER_ID] == "my_folder_id" + + mock_onedrive_client.reset_mock() + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_folder_already_in_use( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_onedrive_client: MagicMock, + mock_instance_id: AsyncMock, + mock_folder: Folder, +) -> None: + """Ensure a folder that is already in use is not allowed.""" + + mock_folder.description = "1234" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_FOLDER_NAME: "folder_already_in_use"} + + # clear error and try again + mock_onedrive_client.create_folder.return_value.description = mock_instance_id + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "John Doe's OneDrive" + assert result["result"].unique_id == "mock_drive_id" + assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + assert result["data"][CONF_FOLDER_NAME] == "myFolder" + assert result["data"][CONF_FOLDER_ID] == "my_folder_id" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_error_during_folder_creation( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_onedrive_client: MagicMock, +) -> None: + """Ensure we can create the backup folder.""" + + mock_onedrive_client.create_folder.side_effect = OneDriveException() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "folder_creation_error"} + + mock_onedrive_client.create_folder.side_effect = None + + # clear error and try again + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "John Doe's OneDrive" + assert result["result"].unique_id == "mock_drive_id" + assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + assert result["data"][CONF_FOLDER_NAME] == "myFolder" + assert result["data"][CONF_FOLDER_ID] == "my_folder_id" @pytest.mark.usefixtures("current_request_with_host") @@ -205,11 +309,11 @@ async def test_reauth_flow_id_changed( mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry, mock_onedrive_client: MagicMock, + mock_approot: AppRoot, ) -> None: """Test that the reauth flow fails on a different drive id.""" - app_root = MOCK_APPROOT - app_root.parent_reference.drive_id = "other_drive_id" - mock_onedrive_client.get_approot.return_value = app_root + + mock_approot.parent_reference.drive_id = "other_drive_id" await setup_integration(hass, mock_config_entry) @@ -226,6 +330,104 @@ async def test_reauth_flow_id_changed( assert result["reason"] == "wrong_drive" +@pytest.mark.usefixtures("current_request_with_host") +async def test_reconfigure_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Testing reconfgure flow.""" + await setup_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reconfigure_flow(hass) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_folder" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "newFolder"} + ) + + assert result["type"] is FlowResultType.ABORT + mock_onedrive_client.update_drive_item.assert_called_once_with( + mock_config_entry.data[CONF_FOLDER_ID], ItemUpdate(name="newFolder") + ) + assert mock_config_entry.data[CONF_FOLDER_NAME] == "newFolder" + assert mock_config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert mock_config_entry.data[CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reconfigure_flow_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Testing reconfgure flow errors.""" + mock_config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + result = await mock_config_entry.start_reconfigure_flow(hass) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_folder" + + mock_onedrive_client.update_drive_item.side_effect = OneDriveException() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "newFolder"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_folder" + assert result["errors"] == {"base": "folder_rename_error"} + + # clear side effect + mock_onedrive_client.update_drive_item.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "newFolder"} + ) + + assert mock_config_entry.data[CONF_FOLDER_NAME] == "newFolder" + assert mock_config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert mock_config_entry.data[CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reconfigure_flow_id_changed( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + mock_approot: AppRoot, +) -> None: + """Test that the reconfigure flow fails on a different drive id.""" + + mock_approot.parent_reference.drive_id = "other_drive_id" + + mock_config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + result = await mock_config_entry.start_reconfigure_flow(hass) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_drive" + + async def test_options_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index b4ec138ebf4..41c1966a4ae 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -1,22 +1,31 @@ """Test the OneDrive setup.""" -from copy import deepcopy +from copy import copy 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 +from onedrive_personal_sdk.exceptions import ( + AuthenticationError, + NotFoundError, + OneDriveException, +) +from onedrive_personal_sdk.models.items import AppRoot, Drive, Folder, ItemUpdate import pytest from syrupy import SnapshotAssertion -from homeassistant.components.onedrive.const import DOMAIN +from homeassistant.components.onedrive.const import ( + CONF_FOLDER_ID, + CONF_FOLDER_NAME, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant 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 +from .const import BACKUP_METADATA, INSTANCE_ID, MOCK_BACKUP_FILE from tests.common import MockConfigEntry @@ -72,11 +81,64 @@ async def test_get_integration_folder_error( mock_onedrive_client: MagicMock, caplog: pytest.LogCaptureFixture, ) -> None: - """Test faulty approot retrieval.""" - mock_onedrive_client.create_folder.side_effect = OneDriveException() + """Test faulty integration folder retrieval.""" + mock_onedrive_client.get_drive_item.side_effect = OneDriveException() await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - assert "Failed to get backups_9f86d081 folder" in caplog.text + assert "Failed to get backups_123 folder" in caplog.text + + +async def test_get_integration_folder_creation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + mock_approot: AppRoot, + mock_folder: Folder, +) -> None: + """Test faulty integration folder creation.""" + folder_name = copy(mock_config_entry.data[CONF_FOLDER_NAME]) + mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found") + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_onedrive_client.create_folder.assert_called_once_with( + parent_id=mock_approot.id, + name=folder_name, + ) + # ensure the folder id and name are updated + assert mock_config_entry.data[CONF_FOLDER_ID] == mock_folder.id + assert mock_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name + + +async def test_get_integration_folder_creation_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test faulty integration folder creation error.""" + mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found") + mock_onedrive_client.create_folder.side_effect = OneDriveException() + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to get backups_123 folder" in caplog.text + + +async def test_update_instance_id_description( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + mock_folder: Folder, +) -> None: + """Test we write the instance id to the folder.""" + mock_folder.description = "" + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + mock_onedrive_client.update_drive_item.assert_called_with( + mock_folder.id, ItemUpdate(description=INSTANCE_ID) + ) async def test_migrate_metadata_files( @@ -125,12 +187,13 @@ async def test_device( mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, + mock_drive: Drive, ) -> None: """Test the device.""" await setup_integration(hass, mock_config_entry) - device = device_registry.async_get_device({(DOMAIN, MOCK_DRIVE.id)}) + device = device_registry.async_get_device({(DOMAIN, mock_drive.id)}) assert device assert device == snapshot @@ -154,17 +217,62 @@ async def test_data_cap_issues( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_onedrive_client: MagicMock, + mock_drive: Drive, 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 + + +async def test_1_1_to_1_2_migration( + hass: HomeAssistant, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, + mock_folder: Folder, +) -> None: + """Test migration from 1.1 to 1.2.""" + old_config_entry = MockConfigEntry( + unique_id="mock_drive_id", + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": mock_config_entry.data["auth_implementation"], + "token": mock_config_entry.data["token"], + }, + ) + + # will always 404 after migration, because of dummy id + mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found") + + await setup_integration(hass, old_config_entry) + assert old_config_entry.data[CONF_FOLDER_ID] == mock_folder.id + assert old_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name + + +async def test_migration_guard_against_major_downgrade( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration guards against major downgrades.""" + old_config_entry = MockConfigEntry( + unique_id="mock_drive_id", + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": mock_config_entry.data["auth_implementation"], + "token": mock_config_entry.data["token"], + }, + version=2, + ) + + await setup_integration(hass, old_config_entry) + assert old_config_entry.state is ConfigEntryState.MIGRATION_ERROR From 1cd82ab8eea77d09e1261401fa7ec23362f59330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 23 Feb 2025 16:18:20 +0100 Subject: [PATCH 2662/2987] Deprecate Home Connect command actions (#139093) * Deprecate command actions * Improve issue description * Improve issue description Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/__init__.py | 12 +++++++++++ .../components/home_connect/strings.json | 4 ++++ tests/components/home_connect/test_init.py | 21 ++++++++++++++++--- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 637fd7aa3a8..51b38bf7cd3 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -405,6 +405,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: """Execute calls to services executing a command.""" client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) + async_create_issue( + hass, + DOMAIN, + "deprecated_command_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_command_actions", + ) + try: await client.put_command(ha_id, command_key=command_key, value=True) except HomeConnectError as err: @@ -610,6 +621,7 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions") + async_delete_issue(hass, DOMAIN, "deprecated_command_actions") return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index d6330c8b78b..977ad1f36f0 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -108,6 +108,10 @@ "title": "Deprecated binary door sensor detected in some automations or scripts", "description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue." }, + "deprecated_command_actions": { + "title": "The command related actions are deprecated in favor of the new buttons", + "description": "The `pause_program` and `resume_program` actions have been deprecated in favor of new button entities, if the command is available for your appliance. Please update your automations, scripts and panels that use this action to use the button entities instead, and press on submit to fix the issue." + }, "deprecated_program_switch": { "title": "Deprecated program switch detected in some automations or scripts", "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." diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 5e309a7446e..06498f891db 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -338,11 +338,27 @@ async def test_key_value_services( @pytest.mark.parametrize( - "service_call", - DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, + ("service_call", "issue_id"), + [ + *zip( + DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, + ["deprecated_set_program_and_option_actions"] + * ( + len(DEPRECATED_SERVICE_KV_CALL_PARAMS) + + len(SERVICE_PROGRAM_CALL_PARAMS) + ), + strict=True, + ), + *zip( + SERVICE_COMMAND_CALL_PARAMS, + ["deprecated_command_actions"] * len(SERVICE_COMMAND_CALL_PARAMS), + strict=True, + ), + ], ) async def test_programs_and_options_actions_deprecation( service_call: dict[str, Any], + issue_id: str, hass: HomeAssistant, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, @@ -354,7 +370,6 @@ async def test_programs_and_options_actions_deprecation( 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 From 0b961d98f58fbb61791f80fcc35a2dd80c621e66 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 23 Feb 2025 16:32:55 +0100 Subject: [PATCH 2663/2987] Move remember the milk config storage to own module (#138999) --- .../components/remember_the_milk/__init__.py | 130 ++---------------- .../components/remember_the_milk/const.py | 5 + .../components/remember_the_milk/entity.py | 22 ++- .../components/remember_the_milk/storage.py | 115 ++++++++++++++++ .../{test_init.py => test_storage.py} | 14 +- 5 files changed, 148 insertions(+), 138 deletions(-) create mode 100644 homeassistant/components/remember_the_milk/const.py create mode 100644 homeassistant/components/remember_the_milk/storage.py rename tests/components/remember_the_milk/{test_init.py => test_storage.py} (90%) diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index 2a95ed46b20..fc192bd538a 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -1,33 +1,25 @@ """Support to interact with Remember The Milk.""" -import json -import logging -from pathlib import Path - from rtmapi import Rtm import voluptuous as vol from homeassistant.components import configurator -from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TOKEN +from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from .const import LOGGER from .entity import RememberTheMilkEntity +from .storage import RememberTheMilkConfiguration # httplib2 is a transitive dependency from RtmAPI. If this dependency is not # set explicitly, the library does not work. -_LOGGER = logging.getLogger(__name__) DOMAIN = "remember_the_milk" -DEFAULT_NAME = DOMAIN CONF_SHARED_SECRET = "shared_secret" -CONF_ID_MAP = "id_map" -CONF_LIST_ID = "list_id" -CONF_TIMESERIES_ID = "timeseries_id" -CONF_TASK_ID = "task_id" RTM_SCHEMA = vol.Schema( { @@ -41,7 +33,6 @@ CONFIG_SCHEMA = vol.Schema( {DOMAIN: vol.All(cv.ensure_list, [RTM_SCHEMA])}, extra=vol.ALLOW_EXTRA ) -CONFIG_FILE_NAME = ".remember_the_milk.conf" SERVICE_CREATE_TASK = "create_task" SERVICE_COMPLETE_TASK = "complete_task" @@ -54,17 +45,17 @@ SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string}) def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Remember the milk component.""" - component = EntityComponent[RememberTheMilkEntity](_LOGGER, DOMAIN, hass) + component = EntityComponent[RememberTheMilkEntity](LOGGER, DOMAIN, hass) stored_rtm_config = RememberTheMilkConfiguration(hass) for rtm_config in config[DOMAIN]: account_name = rtm_config[CONF_NAME] - _LOGGER.debug("Adding Remember the milk account %s", account_name) + LOGGER.debug("Adding Remember the milk account %s", account_name) api_key = rtm_config[CONF_API_KEY] shared_secret = rtm_config[CONF_SHARED_SECRET] token = stored_rtm_config.get_token(account_name) if token: - _LOGGER.debug("found token for account %s", account_name) + LOGGER.debug("found token for account %s", account_name) _create_instance( hass, account_name, @@ -79,7 +70,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, account_name, api_key, shared_secret, stored_rtm_config, component ) - _LOGGER.debug("Finished adding all Remember the milk accounts") + LOGGER.debug("Finished adding all Remember the milk accounts") return True @@ -110,21 +101,21 @@ def _register_new_account( request_id = None api = Rtm(api_key, shared_secret, "write", None) url, frob = api.authenticate_desktop() - _LOGGER.debug("Sent authentication request to server") + LOGGER.debug("Sent authentication request to server") def register_account_callback(fields: list[dict[str, str]]) -> None: """Call for register the configurator.""" api.retrieve_token(frob) token = api.token if api.token is None: - _LOGGER.error("Failed to register, please try again") + LOGGER.error("Failed to register, please try again") configurator.notify_errors( hass, request_id, "Failed to register, please try again." ) return stored_rtm_config.set_token(account_name, token) - _LOGGER.debug("Retrieved new token from server") + LOGGER.debug("Retrieved new token from server") _create_instance( hass, @@ -152,104 +143,3 @@ def _register_new_account( link_url=url, submit_caption="login completed", ) - - -class RememberTheMilkConfiguration: - """Internal configuration data for RememberTheMilk class. - - This class stores the authentication token it get from the backend. - """ - - def __init__(self, hass: HomeAssistant) -> None: - """Create new instance of configuration.""" - self._config_file_path = hass.config.path(CONFIG_FILE_NAME) - self._config = {} - _LOGGER.debug("Loading configuration from file: %s", self._config_file_path) - try: - 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, - ) - - def _save_config(self) -> None: - """Write the configuration to a file.""" - Path(self._config_file_path).write_text( - json.dumps(self._config), encoding="utf8" - ) - - 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: 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() - - 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() - - 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: 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: - list id, timeseries id and the task id. - """ - self._initialize_profile(profile_name) - ids = self._config[profile_name][CONF_ID_MAP].get(hass_id) - if ids is None: - return None - return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_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 = { - CONF_LIST_ID: list_id, - CONF_TIMESERIES_ID: time_series_id, - CONF_TASK_ID: rtm_task_id, - } - self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple - self._save_config() - - 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() diff --git a/homeassistant/components/remember_the_milk/const.py b/homeassistant/components/remember_the_milk/const.py new file mode 100644 index 00000000000..2fccbf3ee52 --- /dev/null +++ b/homeassistant/components/remember_the_milk/const.py @@ -0,0 +1,5 @@ +"""Constants for the Remember The Milk integration.""" + +import logging + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/remember_the_milk/entity.py b/homeassistant/components/remember_the_milk/entity.py index 5f618a96c11..bf75debe367 100644 --- a/homeassistant/components/remember_the_milk/entity.py +++ b/homeassistant/components/remember_the_milk/entity.py @@ -1,14 +1,12 @@ """Support to interact with Remember The Milk.""" -import logging - from rtmapi import Rtm, RtmRequestFailedException from homeassistant.const import CONF_ID, CONF_NAME, STATE_OK from homeassistant.core import ServiceCall from homeassistant.helpers.entity import Entity -_LOGGER = logging.getLogger(__name__) +from .const import LOGGER class RememberTheMilkEntity(Entity): @@ -24,7 +22,7 @@ class RememberTheMilkEntity(Entity): self._rtm_api = Rtm(api_key, shared_secret, "delete", token) self._token_valid = None self._check_token() - _LOGGER.debug("Instance created for account %s", self._name) + LOGGER.debug("Instance created for account %s", self._name) def _check_token(self): """Check if the API token is still valid. @@ -34,7 +32,7 @@ class RememberTheMilkEntity(Entity): """ valid = self._rtm_api.token_valid() if not valid: - _LOGGER.error( + LOGGER.error( "Token for account %s is invalid. You need to register again!", self.name, ) @@ -64,7 +62,7 @@ class RememberTheMilkEntity(Entity): result = self._rtm_api.rtm.tasks.add( timeline=timeline, name=task_name, parse="1" ) - _LOGGER.debug( + LOGGER.debug( "Created new task '%s' in account %s", task_name, self.name ) if hass_id is not None: @@ -83,14 +81,14 @@ class RememberTheMilkEntity(Entity): task_id=rtm_id[2], timeline=timeline, ) - _LOGGER.debug( + LOGGER.debug( "Updated task with id '%s' in account %s to name %s", hass_id, self.name, task_name, ) except RtmRequestFailedException as rtm_exception: - _LOGGER.error( + LOGGER.error( "Error creating new Remember The Milk task for account %s: %s", self._name, rtm_exception, @@ -101,7 +99,7 @@ class RememberTheMilkEntity(Entity): hass_id = call.data[CONF_ID] rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) if rtm_id is None: - _LOGGER.error( + LOGGER.error( ( "Could not find task with ID %s in account %s. " "So task could not be closed" @@ -120,11 +118,9 @@ class RememberTheMilkEntity(Entity): timeline=timeline, ) self._rtm_config.delete_rtm_id(self._name, hass_id) - _LOGGER.debug( - "Completed task with id %s in account %s", hass_id, self._name - ) + LOGGER.debug("Completed task with id %s in account %s", hass_id, self._name) except RtmRequestFailedException as rtm_exception: - _LOGGER.error( + LOGGER.error( "Error creating new Remember The Milk task for account %s: %s", self._name, rtm_exception, diff --git a/homeassistant/components/remember_the_milk/storage.py b/homeassistant/components/remember_the_milk/storage.py new file mode 100644 index 00000000000..ae51acd963b --- /dev/null +++ b/homeassistant/components/remember_the_milk/storage.py @@ -0,0 +1,115 @@ +"""Store RTM configuration in Home Assistant storage.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant + +from .const import LOGGER + +CONFIG_FILE_NAME = ".remember_the_milk.conf" +CONF_ID_MAP = "id_map" +CONF_LIST_ID = "list_id" +CONF_TASK_ID = "task_id" +CONF_TIMESERIES_ID = "timeseries_id" + + +class RememberTheMilkConfiguration: + """Internal configuration data for Remember The Milk.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Create new instance of configuration.""" + self._config_file_path = hass.config.path(CONFIG_FILE_NAME) + self._config = {} + LOGGER.debug("Loading configuration from file: %s", self._config_file_path) + try: + 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, + ) + + def _save_config(self) -> None: + """Write the configuration to a file.""" + Path(self._config_file_path).write_text( + json.dumps(self._config), encoding="utf8" + ) + + 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: 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() + + 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() + + 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: 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: + list id, timeseries id and the task id. + """ + self._initialize_profile(profile_name) + ids = self._config[profile_name][CONF_ID_MAP].get(hass_id) + if ids is None: + return None + return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_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 = { + CONF_LIST_ID: list_id, + CONF_TIMESERIES_ID: time_series_id, + CONF_TASK_ID: rtm_task_id, + } + self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple + self._save_config() + + 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() diff --git a/tests/components/remember_the_milk/test_init.py b/tests/components/remember_the_milk/test_storage.py similarity index 90% rename from tests/components/remember_the_milk/test_init.py rename to tests/components/remember_the_milk/test_storage.py index 517c8cebc0e..6ae774a3d0d 100644 --- a/tests/components/remember_the_milk/test_init.py +++ b/tests/components/remember_the_milk/test_storage.py @@ -14,7 +14,9 @@ from .const import JSON_STRING, PROFILE, TOKEN 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): + with patch( + "homeassistant.components.remember_the_milk.storage.Path.open", open_mock + ): config = rtm.RememberTheMilkConfiguration(hass) assert open_mock.return_value.write.call_count == 0 assert config.get_token(PROFILE) is None @@ -42,7 +44,7 @@ def test_config_load(hass: HomeAssistant) -> None: """Test loading from the file.""" with ( patch( - "homeassistant.components.remember_the_milk.Path.open", + "homeassistant.components.remember_the_milk.storage.Path.open", mock_open(read_data=JSON_STRING), ), ): @@ -61,7 +63,7 @@ def test_config_load_file_error(hass: HomeAssistant, side_effect: Exception) -> config = rtm.RememberTheMilkConfiguration(hass) with ( patch( - "homeassistant.components.remember_the_milk.Path.open", + "homeassistant.components.remember_the_milk.storage.Path.open", side_effect=side_effect, ), ): @@ -78,7 +80,7 @@ def test_config_load_invalid_data(hass: HomeAssistant) -> None: config = rtm.RememberTheMilkConfiguration(hass) with ( patch( - "homeassistant.components.remember_the_milk.Path.open", + "homeassistant.components.remember_the_milk.storage.Path.open", mock_open(read_data="random characters"), ), ): @@ -98,7 +100,9 @@ def test_config_set_delete_id(hass: HomeAssistant) -> None: rtm_id = "3" open_mock = mock_open() config = rtm.RememberTheMilkConfiguration(hass) - with patch("homeassistant.components.remember_the_milk.Path.open", open_mock): + with patch( + "homeassistant.components.remember_the_milk.storage.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 From 4f5c7353f8563124cb8e5d368e65171a28ec3b08 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 23 Feb 2025 17:34:17 +0100 Subject: [PATCH 2664/2987] Test remember the milk configurator (#139122) --- .../components/remember_the_milk/conftest.py | 12 +++- tests/components/remember_the_milk/const.py | 5 ++ .../remember_the_milk/test_entity.py | 8 +-- .../components/remember_the_milk/test_init.py | 65 +++++++++++++++++++ 4 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 tests/components/remember_the_milk/test_init.py diff --git a/tests/components/remember_the_milk/conftest.py b/tests/components/remember_the_milk/conftest.py index f7257f35c64..ac80cf2972b 100644 --- a/tests/components/remember_the_milk/conftest.py +++ b/tests/components/remember_the_milk/conftest.py @@ -13,8 +13,16 @@ from .const import TOKEN @pytest.fixture(name="client") def client_fixture() -> Generator[MagicMock]: """Create a mock client.""" - with patch("homeassistant.components.remember_the_milk.entity.Rtm") as client_class: - client = client_class.return_value + client = MagicMock() + with ( + patch( + "homeassistant.components.remember_the_milk.entity.Rtm" + ) as entity_client_class, + patch("homeassistant.components.remember_the_milk.Rtm") as client_class, + ): + entity_client_class.return_value = client + client_class.return_value = client + client.token = TOKEN client.token_valid.return_value = True timelines = MagicMock() timelines.timeline.value = "1234" diff --git a/tests/components/remember_the_milk/const.py b/tests/components/remember_the_milk/const.py index 3f1d0067219..bed39eec5f8 100644 --- a/tests/components/remember_the_milk/const.py +++ b/tests/components/remember_the_milk/const.py @@ -3,6 +3,11 @@ import json PROFILE = "myprofile" +CONFIG = { + "name": f"{PROFILE}", + "api_key": "test-api-key", + "shared_secret": "test-shared-secret", +} TOKEN = "mytoken" JSON_STRING = json.dumps( { diff --git a/tests/components/remember_the_milk/test_entity.py b/tests/components/remember_the_milk/test_entity.py index e9d7a16d7ab..bdd4189e394 100644 --- a/tests/components/remember_the_milk/test_entity.py +++ b/tests/components/remember_the_milk/test_entity.py @@ -10,13 +10,7 @@ from homeassistant.components.remember_the_milk import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import PROFILE - -CONFIG = { - "name": f"{PROFILE}", - "api_key": "test-api-key", - "shared_secret": "test-shared-secret", -} +from .const import CONFIG, PROFILE @pytest.mark.parametrize( diff --git a/tests/components/remember_the_milk/test_init.py b/tests/components/remember_the_milk/test_init.py new file mode 100644 index 00000000000..feed2894d86 --- /dev/null +++ b/tests/components/remember_the_milk/test_init.py @@ -0,0 +1,65 @@ +"""Test the Remember The Milk integration.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.remember_the_milk import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import CONFIG, PROFILE, TOKEN + + +@pytest.fixture(autouse=True) +def configure_id() -> Generator[str]: + """Fixture to return a configure_id.""" + mock_id = "1-1" + with patch( + "homeassistant.components.configurator.Configurator._generate_unique_id" + ) as generate_id: + generate_id.return_value = mock_id + yield mock_id + + +@pytest.mark.parametrize( + ("token", "rtm_entity_exists", "configurator_end_state"), + [(TOKEN, True, "configured"), (None, False, "configure")], +) +async def test_configurator( + hass: HomeAssistant, + client: MagicMock, + storage: MagicMock, + configure_id: str, + token: str | None, + rtm_entity_exists: bool, + configurator_end_state: str, +) -> None: + """Test configurator.""" + storage.get_token.return_value = None + client.authenticate_desktop.return_value = ("test-url", "test-frob") + client.token = token + rtm_entity_id = f"{DOMAIN}.{PROFILE}" + configure_entity_id = f"configurator.{DOMAIN}_{PROFILE}" + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) + await hass.async_block_till_done() + + assert hass.states.get(rtm_entity_id) is None + state = hass.states.get(configure_entity_id) + assert state + assert state.state == "configure" + + await hass.services.async_call( + "configurator", + "configure", + {"configure_id": configure_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert bool(hass.states.get(rtm_entity_id)) == rtm_entity_exists + state = hass.states.get(configure_entity_id) + assert state + assert state.state == configurator_end_state From 3d507c7b442abd599972008214ada53bea2a867a Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sun, 23 Feb 2025 18:40:31 +0100 Subject: [PATCH 2665/2987] Change backup listener calls for existing backup integrations (#138988) --- .../components/google_drive/__init__.py | 19 +++++----------- homeassistant/components/onedrive/__init__.py | 20 ++++++----------- .../components/synology_dsm/__init__.py | 22 ++++++++----------- 3 files changed, 22 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/google_drive/__init__.py b/homeassistant/components/google_drive/__init__.py index b30bc2ae1f6..d5252bd01ea 100644 --- a/homeassistant/components/google_drive/__init__.py +++ b/homeassistant/components/google_drive/__init__.py @@ -7,7 +7,7 @@ from collections.abc import Callable from google_drive_api.exceptions import GoogleDriveApiError from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import instance_id from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -49,7 +49,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) except GoogleDriveApiError as err: raise ConfigEntryNotReady from err - _async_notify_backup_listeners_soon(hass) + def async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners)) return True @@ -58,15 +62,4 @@ async def async_unload_entry( hass: HomeAssistant, entry: GoogleDriveConfigEntry ) -> bool: """Unload a config entry.""" - _async_notify_backup_listeners_soon(hass) return True - - -def _async_notify_backup_listeners(hass: HomeAssistant) -> None: - for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): - listener() - - -@callback -def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: - hass.loop.call_soon(_async_notify_backup_listeners, hass) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 6805b073ea2..454c782af92 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -17,7 +17,7 @@ from onedrive_personal_sdk.exceptions import ( from onedrive_personal_sdk.models.items import Item, ItemUpdate from homeassistant.const import CONF_ACCESS_TOKEN, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( @@ -102,7 +102,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> translation_key="failed_to_migrate_files", ) from err - _async_notify_backup_listeners_soon(hass) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def update_listener(hass: HomeAssistant, entry: OneDriveConfigEntry) -> None: @@ -110,25 +109,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> entry.async_on_unload(entry.add_update_listener(update_listener)) + def async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners)) + return True async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: """Unload a OneDrive config entry.""" - _async_notify_backup_listeners_soon(hass) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -def _async_notify_backup_listeners(hass: HomeAssistant) -> None: - for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): - listener() - - -@callback -def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: - hass.loop.call_soon(_async_notify_backup_listeners, hass) - - async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) -> None: """Migrate backup files to metadata version 2.""" files = await client.list_drive_items(backup_folder_id) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 97095f5d299..1b26b7df84d 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -11,7 +11,7 @@ from synology_dsm.exceptions import SynologyDSMNotLoggedInException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_SCAN_INTERVAL, CONF_VERIFY_SSL -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -131,7 +131,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(_async_update_listener)) if entry.options[CONF_BACKUP_SHARE]: - _async_notify_backup_listeners_soon(hass) + + def async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload( + entry.async_on_state_change(async_notify_backup_listeners) + ) return True @@ -142,20 +149,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] await entry_data.api.async_unload() hass.data[DOMAIN].pop(entry.unique_id) - _async_notify_backup_listeners_soon(hass) return unload_ok -def _async_notify_backup_listeners(hass: HomeAssistant) -> None: - for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): - listener() - - -@callback -def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: - hass.loop.call_soon(_async_notify_backup_listeners, hass) - - async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) From 6ad6e82a2306ff09d19e7acfc614a6df5760d1f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Feb 2025 12:41:38 -0600 Subject: [PATCH 2666/2987] Bump thermobeacon-ble to 0.8.0 (#139119) --- homeassistant/components/thermobeacon/manifest.json | 8 +++++++- homeassistant/generated/bluetooth.py | 9 +++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thermobeacon/manifest.json b/homeassistant/components/thermobeacon/manifest.json index ce6a3f71ef3..e060cbd91bf 100644 --- a/homeassistant/components/thermobeacon/manifest.json +++ b/homeassistant/components/thermobeacon/manifest.json @@ -14,6 +14,12 @@ "manufacturer_data_start": [0], "connectable": false }, + { + "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 20, + "manufacturer_data_start": [0], + "connectable": false + }, { "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb", "manufacturer_id": 21, @@ -48,5 +54,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermobeacon", "iot_class": "local_push", - "requirements": ["thermobeacon-ble==0.7.0"] + "requirements": ["thermobeacon-ble==0.8.0"] } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 447b6d284f0..587fea8b941 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -688,6 +688,15 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "manufacturer_id": 17, "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb", }, + { + "connectable": False, + "domain": "thermobeacon", + "manufacturer_data_start": [ + 0, + ], + "manufacturer_id": 20, + "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb", + }, { "connectable": False, "domain": "thermobeacon", diff --git a/requirements_all.txt b/requirements_all.txt index cb03d16903d..04cc0c38d67 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2884,7 +2884,7 @@ tessie-api==0.1.1 # tf-models-official==2.5.0 # homeassistant.components.thermobeacon -thermobeacon-ble==0.7.0 +thermobeacon-ble==0.8.0 # homeassistant.components.thermopro thermopro-ble==0.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af58c786530..f72da658fb2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2321,7 +2321,7 @@ teslemetry-stream==0.6.10 tessie-api==0.1.1 # homeassistant.components.thermobeacon -thermobeacon-ble==0.7.0 +thermobeacon-ble==0.8.0 # homeassistant.components.thermopro thermopro-ble==0.11.0 From 8f9f9bc8e7ea7cd5f7f233329ac75a4494ed6d96 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 23 Feb 2025 19:59:10 +0100 Subject: [PATCH 2667/2987] Complete remember the milk typing (#139123) --- .strict-typing | 1 + .../components/remember_the_milk/__init__.py | 20 ++++++++++++++----- .../components/remember_the_milk/entity.py | 18 ++++++++++++----- .../components/remember_the_milk/storage.py | 3 ++- mypy.ini | 10 ++++++++++ 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/.strict-typing b/.strict-typing index 682e2c920ce..95eb2abb4b4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -407,6 +407,7 @@ homeassistant.components.raspberry_pi.* homeassistant.components.rdw.* homeassistant.components.recollect_waste.* homeassistant.components.recorder.* +homeassistant.components.remember_the_milk.* homeassistant.components.remote.* homeassistant.components.renault.* homeassistant.components.reolink.* diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index fc192bd538a..df9eec0622f 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -75,8 +75,14 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: def _create_instance( - hass, account_name, api_key, shared_secret, token, stored_rtm_config, component -): + hass: HomeAssistant, + account_name: str, + api_key: str, + shared_secret: str, + token: str, + stored_rtm_config: RememberTheMilkConfiguration, + component: EntityComponent[RememberTheMilkEntity], +) -> None: entity = RememberTheMilkEntity( account_name, api_key, shared_secret, token, stored_rtm_config ) @@ -96,9 +102,13 @@ def _create_instance( def _register_new_account( - hass, account_name, api_key, shared_secret, stored_rtm_config, component -): - request_id = None + hass: HomeAssistant, + account_name: str, + api_key: str, + shared_secret: str, + stored_rtm_config: RememberTheMilkConfiguration, + component: EntityComponent[RememberTheMilkEntity], +) -> None: api = Rtm(api_key, shared_secret, "write", None) url, frob = api.authenticate_desktop() LOGGER.debug("Sent authentication request to server") diff --git a/homeassistant/components/remember_the_milk/entity.py b/homeassistant/components/remember_the_milk/entity.py index bf75debe367..be69d16f72f 100644 --- a/homeassistant/components/remember_the_milk/entity.py +++ b/homeassistant/components/remember_the_milk/entity.py @@ -7,12 +7,20 @@ from homeassistant.core import ServiceCall from homeassistant.helpers.entity import Entity from .const import LOGGER +from .storage import RememberTheMilkConfiguration class RememberTheMilkEntity(Entity): """Representation of an interface to Remember The Milk.""" - def __init__(self, name, api_key, shared_secret, token, rtm_config): + def __init__( + self, + name: str, + api_key: str, + shared_secret: str, + token: str, + rtm_config: RememberTheMilkConfiguration, + ) -> None: """Create new instance of Remember The Milk component.""" self._name = name self._api_key = api_key @@ -20,11 +28,11 @@ class RememberTheMilkEntity(Entity): self._token = token self._rtm_config = rtm_config self._rtm_api = Rtm(api_key, shared_secret, "delete", token) - self._token_valid = None + self._token_valid = False self._check_token() LOGGER.debug("Instance created for account %s", self._name) - def _check_token(self): + def _check_token(self) -> bool: """Check if the API token is still valid. If it is not valid any more, delete it from the configuration. This @@ -127,12 +135,12 @@ class RememberTheMilkEntity(Entity): ) @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._name @property - def state(self): + def state(self) -> str: """Return the state of the device.""" if not self._token_valid: return "API token invalid" diff --git a/homeassistant/components/remember_the_milk/storage.py b/homeassistant/components/remember_the_milk/storage.py index ae51acd963b..593abb7da2c 100644 --- a/homeassistant/components/remember_the_milk/storage.py +++ b/homeassistant/components/remember_the_milk/storage.py @@ -4,6 +4,7 @@ from __future__ import annotations import json from pathlib import Path +from typing import cast from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant @@ -51,7 +52,7 @@ class RememberTheMilkConfiguration: 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 cast(str, self._config[profile_name][CONF_TOKEN]) return None def set_token(self, profile_name: str, token: str) -> None: diff --git a/mypy.ini b/mypy.ini index 4c062c99aec..a04242dc66d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3826,6 +3826,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.remember_the_milk.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.remote.*] check_untyped_defs = true disallow_incomplete_defs = true From d62c18c225b1d9eb752d50c1c000a83ad7dc689d Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sun, 23 Feb 2025 20:06:28 +0100 Subject: [PATCH 2668/2987] Fix flakey onedrive tests (#139129) --- tests/components/onedrive/conftest.py | 68 +++++++++++++++++++----- tests/components/onedrive/const.py | 48 +---------------- tests/components/onedrive/test_backup.py | 7 ++- tests/components/onedrive/test_init.py | 7 +-- 4 files changed, 65 insertions(+), 65 deletions(-) diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 8ff650012f9..74232f2cc39 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -1,6 +1,7 @@ """Fixtures for OneDrive tests.""" from collections.abc import AsyncIterator, Generator +from html import escape from json import dumps import time from unittest.mock import AsyncMock, MagicMock, patch @@ -10,7 +11,9 @@ from onedrive_personal_sdk.models.items import ( AppRoot, Drive, DriveQuota, + File, Folder, + Hashes, IdentitySet, ItemParentReference, User, @@ -30,15 +33,7 @@ from homeassistant.components.onedrive.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import ( - BACKUP_METADATA, - CLIENT_ID, - CLIENT_SECRET, - IDENTITY_SET, - INSTANCE_ID, - MOCK_BACKUP_FILE, - MOCK_METADATA_FILE, -) +from .const import BACKUP_METADATA, CLIENT_ID, CLIENT_SECRET, IDENTITY_SET, INSTANCE_ID from tests.common import MockConfigEntry @@ -165,20 +160,67 @@ def mock_folder() -> Folder: ) +@pytest.fixture +def mock_backup_file() -> File: + """Return a mocked backup file.""" + return File( + id="id", + name="23e64aec.tar", + size=34519040, + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + hashes=Hashes( + quick_xor_hash="hash", + ), + mime_type="application/x-tar", + created_by=IDENTITY_SET, + ) + + +@pytest.fixture +def mock_metadata_file() -> File: + """Return a mocked metadata file.""" + return File( + id="id", + name="23e64aec.tar", + size=34519040, + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + hashes=Hashes( + quick_xor_hash="hash", + ), + mime_type="application/x-tar", + description=escape( + dumps( + { + "metadata_version": 2, + "backup_id": "23e64aec", + "backup_file_id": "id", + } + ) + ), + created_by=IDENTITY_SET, + ) + + @pytest.fixture(autouse=True) def mock_onedrive_client( mock_onedrive_client_init: MagicMock, mock_approot: AppRoot, mock_drive: Drive, mock_folder: Folder, + mock_backup_file: File, + mock_metadata_file: File, ) -> Generator[MagicMock]: """Return a mocked GraphServiceClient.""" client = mock_onedrive_client_init.return_value client.get_approot.return_value = mock_approot client.create_folder.return_value = mock_folder - client.list_drive_items.return_value = [MOCK_BACKUP_FILE, MOCK_METADATA_FILE] + client.list_drive_items.return_value = [mock_backup_file, mock_metadata_file] client.get_drive_item.return_value = mock_folder - client.upload_file.return_value = MOCK_METADATA_FILE + client.upload_file.return_value = mock_metadata_file class MockStreamReader: async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]: @@ -193,12 +235,12 @@ def mock_onedrive_client( @pytest.fixture -def mock_large_file_upload_client() -> Generator[AsyncMock]: +def mock_large_file_upload_client(mock_backup_file: File) -> Generator[AsyncMock]: """Return a mocked LargeFileUploadClient upload.""" with patch( "homeassistant.components.onedrive.backup.LargeFileUploadClient.upload" ) as mock_upload: - mock_upload.return_value = MOCK_BACKUP_FILE + mock_upload.return_value = mock_backup_file yield mock_upload diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py index 6e91a7ef0ea..4e67c358179 100644 --- a/tests/components/onedrive/const.py +++ b/tests/components/onedrive/const.py @@ -1,15 +1,6 @@ """Consts for OneDrive tests.""" -from html import escape -from json import dumps - -from onedrive_personal_sdk.models.items import ( - File, - Hashes, - IdentitySet, - ItemParentReference, - User, -) +from onedrive_personal_sdk.models.items import IdentitySet, User CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -38,40 +29,3 @@ IDENTITY_SET = IdentitySet( email="john@doe.com", ) ) - -MOCK_BACKUP_FILE = File( - id="id", - name="23e64aec.tar", - size=34519040, - parent_reference=ItemParentReference( - drive_id="mock_drive_id", id="id", path="path" - ), - hashes=Hashes( - quick_xor_hash="hash", - ), - mime_type="application/x-tar", - created_by=IDENTITY_SET, -) - -MOCK_METADATA_FILE = File( - id="id", - name="23e64aec.tar", - size=34519040, - parent_reference=ItemParentReference( - drive_id="mock_drive_id", id="id", path="path" - ), - hashes=Hashes( - quick_xor_hash="hash", - ), - mime_type="application/x-tar", - description=escape( - dumps( - { - "metadata_version": 2, - "backup_id": "23e64aec", - "backup_file_id": "id", - } - ) - ), - created_by=IDENTITY_SET, -) diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 41ecbdb240f..c307e5190c1 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -11,6 +11,7 @@ from onedrive_personal_sdk.exceptions import ( HashMismatchError, OneDriveException, ) +from onedrive_personal_sdk.models.items import File import pytest from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup @@ -23,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import setup_integration -from .const import BACKUP_METADATA, MOCK_BACKUP_FILE, MOCK_METADATA_FILE +from .const import BACKUP_METADATA from tests.common import AsyncMock, MockConfigEntry from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator @@ -248,12 +249,14 @@ async def test_error_on_agents_download( hass_client: ClientSessionGenerator, mock_onedrive_client: MagicMock, mock_config_entry: MockConfigEntry, + mock_backup_file: File, + mock_metadata_file: File, ) -> 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], + [mock_backup_file, mock_metadata_file], [], ] diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index 41c1966a4ae..c7765e0a7f8 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -11,7 +11,7 @@ from onedrive_personal_sdk.exceptions import ( NotFoundError, OneDriveException, ) -from onedrive_personal_sdk.models.items import AppRoot, Drive, Folder, ItemUpdate +from onedrive_personal_sdk.models.items import AppRoot, Drive, File, Folder, ItemUpdate import pytest from syrupy import SnapshotAssertion @@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, issue_registry as ir from . import setup_integration -from .const import BACKUP_METADATA, INSTANCE_ID, MOCK_BACKUP_FILE +from .const import BACKUP_METADATA, INSTANCE_ID from tests.common import MockConfigEntry @@ -145,9 +145,10 @@ async def test_migrate_metadata_files( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_onedrive_client: MagicMock, + mock_backup_file: File, ) -> None: """Test migration of metadata files.""" - MOCK_BACKUP_FILE.description = escape( + mock_backup_file.description = escape( dumps({**BACKUP_METADATA, "metadata_version": 1}) ) await setup_integration(hass, mock_config_entry) From 580c6f26840778669981027664e059a53d05f406 Mon Sep 17 00:00:00 2001 From: SLaks Date: Sun, 23 Feb 2025 19:11:38 -0500 Subject: [PATCH 2669/2987] Allow arbitrary Gemini attachments (#138751) * Gemini: Allow arbitrary attachments This lets me use Gemini to extract information from PDFs, HTML, or other files. * Gemini: Only add deprecation warning when deprecated parameter has a value * Gemini: Use Files.upload() for both images and other files This simplifies the code. Within the Google client, this takes a different codepath (it uploads images as a file instead of re-saving them into inline bytes). I think that's a feature (it's probably more efficient?). * Gemini: Deduplicate filenames --- .../__init__.py | 55 ++++++++++++------- .../services.yaml | 5 ++ .../strings.json | 13 ++++- .../snapshots/test_init.ambr | 3 +- .../test_init.py | 33 ++--------- 5 files changed, 59 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index e9ab5cbdd3e..33e361d1433 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -2,12 +2,10 @@ from __future__ import annotations -import mimetypes from pathlib import Path from google import genai # type: ignore[attr-defined] from google.genai.errors import APIError, ClientError -from PIL import Image from requests.exceptions import Timeout import voluptuous as vol @@ -26,6 +24,7 @@ from homeassistant.exceptions import ( HomeAssistantError, ) from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from .const import ( @@ -38,6 +37,7 @@ from .const import ( SERVICE_GENERATE_CONTENT = "generate_content" CONF_IMAGE_FILENAME = "image_filename" +CONF_FILENAMES = "filenames" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = (Platform.CONVERSATION,) @@ -50,31 +50,43 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def generate_content(call: ServiceCall) -> ServiceResponse: """Generate content from text and optionally images.""" + + if call.data[CONF_IMAGE_FILENAME]: + # Deprecated in 2025.3, to remove in 2025.9 + async_create_issue( + hass, + DOMAIN, + "deprecated_image_filename_parameter", + breaks_in_ha_version="2025.9.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_image_filename_parameter", + ) + prompt_parts = [call.data[CONF_PROMPT]] - def append_images_to_prompt(): - image_filenames = call.data[CONF_IMAGE_FILENAME] - for image_filename in image_filenames: - if not hass.config.is_allowed_path(image_filename): - raise HomeAssistantError( - f"Cannot read `{image_filename}`, no access to path; " - "`allowlist_external_dirs` may need to be adjusted in " - "`configuration.yaml`" - ) - if not Path(image_filename).exists(): - raise HomeAssistantError(f"`{image_filename}` does not exist") - mime_type, _ = mimetypes.guess_type(image_filename) - if mime_type is None or not mime_type.startswith("image"): - raise HomeAssistantError(f"`{image_filename}` is not an image") - prompt_parts.append(Image.open(image_filename)) - - await hass.async_add_executor_job(append_images_to_prompt) - config_entry: GoogleGenerativeAIConfigEntry = hass.config_entries.async_entries( DOMAIN )[0] + client = config_entry.runtime_data + def append_files_to_prompt(): + image_filenames = call.data[CONF_IMAGE_FILENAME] + filenames = call.data[CONF_FILENAMES] + for filename in set(image_filenames + filenames): + if not hass.config.is_allowed_path(filename): + raise HomeAssistantError( + f"Cannot read `{filename}`, no access to path; " + "`allowlist_external_dirs` may need to be adjusted in " + "`configuration.yaml`" + ) + if not Path(filename).exists(): + raise HomeAssistantError(f"`{filename}` does not exist") + prompt_parts.append(client.files.upload(file=filename)) + + await hass.async_add_executor_job(append_files_to_prompt) + try: response = await client.aio.models.generate_content( model=RECOMMENDED_CHAT_MODEL, contents=prompt_parts @@ -105,6 +117,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.Optional(CONF_IMAGE_FILENAME, default=[]): vol.All( cv.ensure_list, [cv.string] ), + vol.Optional(CONF_FILENAMES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), } ), supports_response=SupportsResponse.ONLY, diff --git a/homeassistant/components/google_generative_ai_conversation/services.yaml b/homeassistant/components/google_generative_ai_conversation/services.yaml index f35697b89f8..82190d64540 100644 --- a/homeassistant/components/google_generative_ai_conversation/services.yaml +++ b/homeassistant/components/google_generative_ai_conversation/services.yaml @@ -9,3 +9,8 @@ generate_content: required: false selector: object: + filenames: + required: false + selector: + text: + multiple: true diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 9fea4805d38..772fadb089c 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -56,10 +56,21 @@ }, "image_filename": { "name": "Image filename", - "description": "Images", + "description": "Deprecated. Use filenames instead.", + "example": "/config/www/image.jpg" + }, + "filenames": { + "name": "Attachment filenames", + "description": "Attachments to add to the prompt (images, PDFs, etc)", "example": "/config/www/image.jpg" } } } + }, + "issues": { + "deprecated_image_filename_parameter": { + "title": "Deprecated 'image_filename' parameter", + "description": "The 'image_filename' parameter in Google Generative AI actions is deprecated. Please edit scripts and automations to use 'filenames' intead." + } } } diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index e2d93611ea6..8e6231cbffd 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -8,7 +8,8 @@ dict({ 'contents': list([ 'Describe this image from my doorbell camera', - b'image bytes', + b'some file', + b'some file', ]), 'model': 'models/gemini-2.0-flash', }), diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index f2e3ac10733..0dad485812e 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -66,8 +66,8 @@ async def test_generate_content_service_with_image( ), ) as mock_generate, patch( - "homeassistant.components.google_generative_ai_conversation.Image.open", - return_value=b"image bytes", + "google.genai.files.Files.upload", + return_value=b"some file", ), patch("pathlib.Path.exists", return_value=True), patch.object(hass.config, "is_allowed_path", return_value=True), @@ -77,7 +77,7 @@ async def test_generate_content_service_with_image( "generate_content", { "prompt": "Describe this image from my doorbell camera", - "image_filename": "doorbell_snapshot.jpg", + "filenames": ["doorbell_snapshot.jpg", "context.txt", "context.txt"], }, blocking=True, return_response=True, @@ -161,7 +161,7 @@ async def test_generate_content_service_with_image_not_allowed_path( "generate_content", { "prompt": "Describe this image from my doorbell camera", - "image_filename": "doorbell_snapshot.jpg", + "filenames": "doorbell_snapshot.jpg", }, blocking=True, return_response=True, @@ -186,30 +186,7 @@ async def test_generate_content_service_with_image_not_exists( "generate_content", { "prompt": "Describe this image from my doorbell camera", - "image_filename": "doorbell_snapshot.jpg", - }, - blocking=True, - return_response=True, - ) - - -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_content_service_with_non_image(hass: HomeAssistant) -> None: - """Test generate content service with a non image.""" - with ( - patch("pathlib.Path.exists", return_value=True), - patch.object(hass.config, "is_allowed_path", return_value=True), - patch("pathlib.Path.exists", return_value=True), - pytest.raises( - HomeAssistantError, match="`doorbell_snapshot.mp4` is not an image" - ), - ): - await hass.services.async_call( - "google_generative_ai_conversation", - "generate_content", - { - "prompt": "Describe this image from my doorbell camera", - "image_filename": "doorbell_snapshot.mp4", + "filenames": "doorbell_snapshot.jpg", }, blocking=True, return_response=True, From db5bf417904a77fa2be75e555fac639400599b70 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sun, 23 Feb 2025 22:37:25 -0500 Subject: [PATCH 2670/2987] bump soco to 0.30.9 (#139143) --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index bb3d99c4c93..5bbfc33ae5b 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco", "sonos_websocket"], - "requirements": ["soco==0.30.8", "sonos-websocket==0.1.3"], + "requirements": ["soco==0.30.9", "sonos-websocket==0.1.3"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/requirements_all.txt b/requirements_all.txt index 04cc0c38d67..179f82d04c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2754,7 +2754,7 @@ smart-meter-texas==0.5.5 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.8 +soco==0.30.9 # homeassistant.components.solaredge_local solaredge-local==0.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f72da658fb2..2b15ecf055d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2221,7 +2221,7 @@ smart-meter-texas==0.5.5 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.8 +soco==0.30.9 # homeassistant.components.solarlog solarlog_cli==0.4.0 From ea1045d826f7ed317ec578e6063bc67fcf20aa99 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Feb 2025 08:42:15 +0100 Subject: [PATCH 2671/2987] Bump github/codeql-action from 3.28.9 to 3.28.10 (#139162) --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a4469cde0d8..4bdddf50c25 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.9 + uses: github/codeql-action/init@v3.28.10 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.9 + uses: github/codeql-action/analyze@v3.28.10 with: category: "/language:python" From 8c4b8028cf515adbf005691fdf7eba46a1686181 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 24 Feb 2025 09:52:53 +0200 Subject: [PATCH 2672/2987] Bump aiowebostv to 0.7.0 (#139145) --- .../components/webostv/config_flow.py | 8 +- .../components/webostv/diagnostics.py | 18 ++--- homeassistant/components/webostv/helpers.py | 8 +- .../components/webostv/manifest.json | 2 +- .../components/webostv/media_player.py | 67 ++++++++-------- homeassistant/components/webostv/notify.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/webostv/conftest.py | 33 ++++---- tests/components/webostv/test_config_flow.py | 6 +- tests/components/webostv/test_media_player.py | 76 +++++++++---------- tests/components/webostv/test_notify.py | 2 +- 12 files changed, 117 insertions(+), 109 deletions(-) diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index fbc3eb958dd..80c8fb7f8f2 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -92,13 +92,13 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" else: await self.async_set_unique_id( - client.hello_info["deviceUUID"], raise_on_progress=False + client.tv_info.hello["deviceUUID"], raise_on_progress=False ) self._abort_if_unique_id_configured({CONF_HOST: self._host}) data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key} if not self._name: - self._name = f"{DEFAULT_NAME} {client.system_info['modelName']}" + self._name = f"{DEFAULT_NAME} {client.tv_info.system['modelName']}" return self.async_create_entry(title=self._name, data=data) return self.async_show_form(step_id="pairing", errors=errors) @@ -176,7 +176,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): except WEBOSTV_EXCEPTIONS: errors["base"] = "cannot_connect" else: - await self.async_set_unique_id(client.hello_info["deviceUUID"]) + await self.async_set_unique_id(client.tv_info.hello["deviceUUID"]) self._abort_if_unique_id_mismatch(reason="wrong_device") data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key} return self.async_update_reload_and_abort(reconfigure_entry, data=data) @@ -214,7 +214,7 @@ class OptionsFlowHandler(OptionsFlow): sources_list = [] try: client = await async_control_connect(self.hass, self.host, self.key) - sources_list = get_sources(client) + sources_list = get_sources(client.tv_state) except WebOsTvPairError: errors["base"] = "error_pairing" except WEBOSTV_EXCEPTIONS: diff --git a/homeassistant/components/webostv/diagnostics.py b/homeassistant/components/webostv/diagnostics.py index 7fb64a2cb8f..393a6a066ff 100644 --- a/homeassistant/components/webostv/diagnostics.py +++ b/homeassistant/components/webostv/diagnostics.py @@ -32,15 +32,15 @@ async def async_get_config_entry_diagnostics( client_data = { "is_registered": client.is_registered(), "is_connected": client.is_connected(), - "current_app_id": client.current_app_id, - "current_channel": client.current_channel, - "apps": client.apps, - "inputs": client.inputs, - "system_info": client.system_info, - "software_info": client.software_info, - "hello_info": client.hello_info, - "sound_output": client.sound_output, - "is_on": client.is_on, + "current_app_id": client.tv_state.current_app_id, + "current_channel": client.tv_state.current_channel, + "apps": client.tv_state.apps, + "inputs": client.tv_state.inputs, + "system_info": client.tv_info.system, + "software_info": client.tv_info.software, + "hello_info": client.tv_info.hello, + "sound_output": client.tv_state.sound_output, + "is_on": client.tv_state.is_on, } return async_redact_data( diff --git a/homeassistant/components/webostv/helpers.py b/homeassistant/components/webostv/helpers.py index 3c509a56d1e..f70f250f91d 100644 --- a/homeassistant/components/webostv/helpers.py +++ b/homeassistant/components/webostv/helpers.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from aiowebostv import WebOsClient +from aiowebostv import WebOsClient, WebOsTvState from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST @@ -83,16 +83,16 @@ def async_get_client_by_device_entry( ) -def get_sources(client: WebOsClient) -> list[str]: +def get_sources(tv_state: WebOsTvState) -> list[str]: """Construct sources list.""" sources = [] found_live_tv = False - for app in client.apps.values(): + for app in tv_state.apps.values(): sources.append(app["title"]) if app["id"] == LIVE_TV_APP_ID: found_live_tv = True - for source in client.inputs.values(): + for source in tv_state.inputs.values(): sources.append(source["label"]) if source["appId"] == LIVE_TV_APP_ID: found_live_tv = True diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 5fbcf759ee3..45c9628539c 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.2"], + "requirements": ["aiowebostv==0.7.0"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 33c09aa8708..780e9f418a5 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -11,7 +11,7 @@ from http import HTTPStatus import logging from typing import Any, Concatenate, cast -from aiowebostv import WebOsClient, WebOsTvPairError +from aiowebostv import WebOsTvPairError, WebOsTvState import voluptuous as vol from homeassistant import util @@ -205,51 +205,52 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): """Call disconnect on removal.""" self._client.unregister_state_update_callback(self.async_handle_state_update) - async def async_handle_state_update(self, _client: WebOsClient) -> None: + async def async_handle_state_update(self, tv_state: WebOsTvState) -> None: """Update state from WebOsClient.""" self._update_states() self.async_write_ha_state() def _update_states(self) -> None: """Update entity state attributes.""" + tv_state = self._client.tv_state self._update_sources() self._attr_state = ( - MediaPlayerState.ON if self._client.is_on else MediaPlayerState.OFF + MediaPlayerState.ON if tv_state.is_on else MediaPlayerState.OFF ) - self._attr_is_volume_muted = cast(bool, self._client.muted) + self._attr_is_volume_muted = cast(bool, tv_state.muted) self._attr_volume_level = None - if self._client.volume is not None: - self._attr_volume_level = self._client.volume / 100.0 + if tv_state.volume is not None: + self._attr_volume_level = tv_state.volume / 100.0 self._attr_source = self._current_source self._attr_source_list = sorted(self._source_list) self._attr_media_content_type = None - if self._client.current_app_id == LIVE_TV_APP_ID: + if tv_state.current_app_id == LIVE_TV_APP_ID: self._attr_media_content_type = MediaType.CHANNEL self._attr_media_title = None - if (self._client.current_app_id == LIVE_TV_APP_ID) and ( - self._client.current_channel is not None + if (tv_state.current_app_id == LIVE_TV_APP_ID) and ( + tv_state.current_channel is not None ): self._attr_media_title = cast( - str, self._client.current_channel.get("channelName") + str, tv_state.current_channel.get("channelName") ) self._attr_media_image_url = None - if self._client.current_app_id in self._client.apps: - icon: str = self._client.apps[self._client.current_app_id]["largeIcon"] + if tv_state.current_app_id in tv_state.apps: + icon: str = tv_state.apps[tv_state.current_app_id]["largeIcon"] if not icon.startswith("http"): - icon = self._client.apps[self._client.current_app_id]["icon"] + icon = tv_state.apps[tv_state.current_app_id]["icon"] self._attr_media_image_url = icon if self.state != MediaPlayerState.OFF or not self._supported_features: supported = SUPPORT_WEBOSTV - if self._client.sound_output == "external_speaker": + if tv_state.sound_output == "external_speaker": supported = supported | SUPPORT_WEBOSTV_VOLUME - elif self._client.sound_output != "lineout": + elif tv_state.sound_output != "lineout": supported = ( supported | SUPPORT_WEBOSTV_VOLUME @@ -265,9 +266,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): ) self._attr_assumed_state = True - if self._client.is_on and self._client.media_state: + if tv_state.is_on and tv_state.media_state: self._attr_assumed_state = False - for entry in self._client.media_state: + for entry in tv_state.media_state: if entry.get("playState") == "playing": self._attr_state = MediaPlayerState.PLAYING elif entry.get("playState") == "paused": @@ -275,35 +276,37 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): elif entry.get("playState") == "unloaded": self._attr_state = MediaPlayerState.IDLE + tv_info = self._client.tv_info if self.state != MediaPlayerState.OFF: - maj_v = self._client.software_info.get("major_ver") - min_v = self._client.software_info.get("minor_ver") + maj_v = tv_info.software.get("major_ver") + min_v = tv_info.software.get("minor_ver") if maj_v and min_v: self._attr_device_info["sw_version"] = f"{maj_v}.{min_v}" - if model := self._client.system_info.get("modelName"): + if model := tv_info.system.get("modelName"): self._attr_device_info["model"] = model - if serial_number := self._client.system_info.get("serialNumber"): + if serial_number := tv_info.system.get("serialNumber"): self._attr_device_info["serial_number"] = serial_number self._attr_extra_state_attributes = {} - if self._client.sound_output is not None or self.state != MediaPlayerState.OFF: + if tv_state.sound_output is not None or self.state != MediaPlayerState.OFF: self._attr_extra_state_attributes = { - ATTR_SOUND_OUTPUT: self._client.sound_output + ATTR_SOUND_OUTPUT: tv_state.sound_output } def _update_sources(self) -> None: """Update list of sources from current source, apps, inputs and configured list.""" + tv_state = self._client.tv_state source_list = self._source_list self._source_list = {} conf_sources = self._sources found_live_tv = False - for app in self._client.apps.values(): + for app in tv_state.apps.values(): if app["id"] == LIVE_TV_APP_ID: found_live_tv = True - if app["id"] == self._client.current_app_id: + if app["id"] == tv_state.current_app_id: self._current_source = app["title"] self._source_list[app["title"]] = app elif ( @@ -314,10 +317,10 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): ): self._source_list[app["title"]] = app - for source in self._client.inputs.values(): + for source in tv_state.inputs.values(): if source["appId"] == LIVE_TV_APP_ID: found_live_tv = True - if source["appId"] == self._client.current_app_id: + if source["appId"] == tv_state.current_app_id: self._current_source = source["label"] self._source_list[source["label"]] = source elif ( @@ -334,7 +337,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): # not appear in the app or input lists in some cases elif not found_live_tv: app = {"id": LIVE_TV_APP_ID, "title": "Live TV"} - if self._client.current_app_id == LIVE_TV_APP_ID: + if tv_state.current_app_id == LIVE_TV_APP_ID: self._current_source = app["title"] self._source_list["Live TV"] = app elif ( @@ -434,12 +437,12 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): """Play a piece of media.""" _LOGGER.debug("Call play media type <%s>, Id <%s>", media_type, media_id) - if media_type == MediaType.CHANNEL and self._client.channels: + if media_type == MediaType.CHANNEL and self._client.tv_state.channels: _LOGGER.debug("Searching channel") partial_match_channel_id = None perfect_match_channel_id = None - for channel in self._client.channels: + for channel in self._client.tv_state.channels: if media_id == channel["channelNumber"]: perfect_match_channel_id = channel["channelId"] continue @@ -484,7 +487,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): @cmd async def async_media_next_track(self) -> None: """Send next track command.""" - if self._client.current_app_id == LIVE_TV_APP_ID: + if self._client.tv_state.current_app_id == LIVE_TV_APP_ID: await self._client.channel_up() else: await self._client.fast_forward() @@ -492,7 +495,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): @cmd async def async_media_previous_track(self) -> None: """Send the previous track command.""" - if self._client.current_app_id == LIVE_TV_APP_ID: + if self._client.tv_state.current_app_id == LIVE_TV_APP_ID: await self._client.channel_down() else: await self._client.rewind() diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py index 2393cb4cd07..3966cea5e92 100644 --- a/homeassistant/components/webostv/notify.py +++ b/homeassistant/components/webostv/notify.py @@ -49,7 +49,7 @@ class LgWebOSNotificationService(BaseNotificationService): data = kwargs[ATTR_DATA] icon_path = data.get(ATTR_ICON) if data else None - if not client.is_on: + if not client.tv_state.is_on: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="notify_device_off", diff --git a/requirements_all.txt b/requirements_all.txt index 179f82d04c1..7c9d90ad8df 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.2 +aiowebostv==0.7.0 # homeassistant.components.withings aiowithings==3.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b15ecf055d..b9a7579d7f1 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.2 +aiowebostv==0.7.0 # homeassistant.components.withings aiowithings==3.1.5 diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py index c6594746cc5..7fbd8d667e2 100644 --- a/tests/components/webostv/conftest.py +++ b/tests/components/webostv/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch +from aiowebostv import WebOsTvInfo, WebOsTvState import pytest from homeassistant.components.webostv.const import LIVE_TV_APP_ID @@ -40,26 +41,30 @@ def client_fixture(): ), ): client = mock_client_class.return_value - client.hello_info = {"deviceUUID": FAKE_UUID} - client.software_info = {"major_ver": "major", "minor_ver": "minor"} - client.system_info = {"modelName": TV_MODEL, "serialNumber": "1234567890"} + client.tv_info = WebOsTvInfo( + hello={"deviceUUID": FAKE_UUID}, + system={"modelName": TV_MODEL, "serialNumber": "1234567890"}, + software={"major_ver": "major", "minor_ver": "minor"}, + ) client.client_key = CLIENT_KEY - client.apps = MOCK_APPS - client.inputs = MOCK_INPUTS - client.current_app_id = LIVE_TV_APP_ID + client.tv_state = WebOsTvState( + apps=MOCK_APPS, + inputs=MOCK_INPUTS, + current_app_id=LIVE_TV_APP_ID, + channels=[CHANNEL_1, CHANNEL_2], + current_channel=CHANNEL_1, + volume=37, + sound_output="speaker", + muted=False, + is_on=True, + media_state=[{"playState": ""}], + ) - client.channels = [CHANNEL_1, CHANNEL_2] - client.current_channel = CHANNEL_1 - - client.volume = 37 - client.sound_output = "speaker" - client.muted = False - client.is_on = True client.is_registered = Mock(return_value=True) client.is_connected = Mock(return_value=True) async def mock_state_update_callback(): - await client.register_state_update_callback.call_args[0][0](client) + await client.register_state_update_callback.call_args[0][0](client.tv_state) client.mock_state_update = AsyncMock(side_effect=mock_state_update_callback) diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index 34ab39618d8..564ff9afa9b 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -84,8 +84,8 @@ async def test_options_flow_live_tv_in_apps( hass: HomeAssistant, client, apps, inputs ) -> None: """Test options config flow Live TV found in apps.""" - client.apps = apps - client.inputs = inputs + client.tv_state.apps = apps + client.tv_state.inputs = inputs entry = await setup_webostv(hass) result = await hass.config_entries.options.async_init(entry.entry_id) @@ -411,7 +411,7 @@ async def test_reconfigure_wrong_device(hass: HomeAssistant, client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" - client.hello_info = {"deviceUUID": "wrong_uuid"} + client.tv_info.hello = {"deviceUUID": "wrong_uuid"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_HOST: "new_host"}, diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 679092efe3b..59e3fc68cf7 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -156,7 +156,7 @@ async def test_media_next_previous_track( getattr(client, client_call[1]).assert_called_once() # check next/previous for not Live TV channels - client.current_app_id = "in1" + client.tv_state.current_app_id = "in1" data = {ATTR_ENTITY_ID: ENTITY_ID} await hass.services.async_call(MP_DOMAIN, service, data, True) @@ -303,8 +303,8 @@ async def test_device_info_startup_off( hass: HomeAssistant, client, device_registry: dr.DeviceRegistry ) -> None: """Test device info when device is off at startup.""" - client.system_info = None - client.is_on = False + client.tv_info.system = {} + client.tv_state.is_on = False entry = await setup_webostv(hass) await client.mock_state_update() @@ -335,14 +335,14 @@ async def test_entity_attributes( assert state == snapshot(exclude=props("entity_picture")) # Volume level not available - client.volume = None + client.tv_state.volume = None await client.mock_state_update() attrs = hass.states.get(ENTITY_ID).attributes assert attrs.get(ATTR_MEDIA_VOLUME_LEVEL) is None # Channel change - client.current_channel = CHANNEL_2 + client.tv_state.current_channel = CHANNEL_2 await client.mock_state_update() attrs = hass.states.get(ENTITY_ID).attributes @@ -353,8 +353,8 @@ async def test_entity_attributes( assert device == snapshot # Sound output when off - client.sound_output = None - client.is_on = False + client.tv_state.sound_output = None + client.tv_state.is_on = False await client.mock_state_update() state = hass.states.get(ENTITY_ID) @@ -410,13 +410,13 @@ async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: assert len(sources) == 3 # Live TV is current app - client.apps = { + client.tv_state.apps = { LIVE_TV_APP_ID: { "title": "Live TV", "id": "some_id", }, } - client.current_app_id = "some_id" + client.tv_state.current_app_id = "some_id" await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -424,7 +424,7 @@ async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: assert len(sources) == 3 # Live TV is is in inputs - client.inputs = { + client.tv_state.inputs = { LIVE_TV_APP_ID: { "label": "Live TV", "id": "some_id", @@ -438,7 +438,7 @@ async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: assert len(sources) == 1 # Live TV is current input - client.inputs = { + client.tv_state.inputs = { LIVE_TV_APP_ID: { "label": "Live TV", "id": "some_id", @@ -452,7 +452,7 @@ async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: assert len(sources) == 1 # Live TV not found - client.current_app_id = "other_id" + client.tv_state.current_app_id = "other_id" await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -460,8 +460,8 @@ async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: assert len(sources) == 1 # Live TV not found in sources/apps but is current app - client.apps = {} - client.current_app_id = LIVE_TV_APP_ID + client.tv_state.apps = {} + client.tv_state.current_app_id = LIVE_TV_APP_ID await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -469,7 +469,7 @@ async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: assert len(sources) == 1 # Bad update, keep old update - client.inputs = {} + client.tv_state.inputs = {} await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -543,7 +543,7 @@ async def test_control_error_handling( """Test control errors handling.""" await setup_webostv(hass) client.play.side_effect = exception - client.is_on = is_on + client.tv_state.is_on = is_on await client.mock_state_update() data = {ATTR_ENTITY_ID: ENTITY_ID} @@ -566,7 +566,7 @@ async def test_turn_off_when_device_is_off(hass: HomeAssistant, client) -> None: async def test_supported_features(hass: HomeAssistant, client) -> None: """Test test supported features.""" - client.sound_output = "lineout" + client.tv_state.sound_output = "lineout" await setup_webostv(hass) await client.mock_state_update() @@ -577,7 +577,7 @@ async def test_supported_features(hass: HomeAssistant, client) -> None: assert attrs[ATTR_SUPPORTED_FEATURES] == supported # Support volume mute, step - client.sound_output = "external_speaker" + client.tv_state.sound_output = "external_speaker" await client.mock_state_update() supported = supported | SUPPORT_WEBOSTV_VOLUME attrs = hass.states.get(ENTITY_ID).attributes @@ -585,7 +585,7 @@ async def test_supported_features(hass: HomeAssistant, client) -> None: assert attrs[ATTR_SUPPORTED_FEATURES] == supported # Support volume mute, step, set - client.sound_output = "speaker" + client.tv_state.sound_output = "speaker" await client.mock_state_update() supported = supported | SUPPORT_WEBOSTV_VOLUME | MediaPlayerEntityFeature.VOLUME_SET attrs = hass.states.get(ENTITY_ID).attributes @@ -623,8 +623,8 @@ async def test_supported_features(hass: HomeAssistant, client) -> None: async def test_cached_supported_features(hass: HomeAssistant, client) -> None: """Test test supported features.""" - client.is_on = False - client.sound_output = None + client.tv_state.is_on = False + client.tv_state.sound_output = None supported = ( SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME | MediaPlayerEntityFeature.TURN_ON ) @@ -652,8 +652,8 @@ async def test_cached_supported_features(hass: HomeAssistant, client) -> None: ) # TV on, support volume mute, step - client.is_on = True - client.sound_output = "external_speaker" + client.tv_state.is_on = True + client.tv_state.sound_output = "external_speaker" await client.mock_state_update() supported = SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME @@ -662,8 +662,8 @@ async def test_cached_supported_features(hass: HomeAssistant, client) -> None: assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV off, support volume mute, step - client.is_on = False - client.sound_output = None + client.tv_state.is_on = False + client.tv_state.sound_output = None await client.mock_state_update() supported = SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME @@ -672,8 +672,8 @@ async def test_cached_supported_features(hass: HomeAssistant, client) -> None: assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV on, support volume mute, step, set - client.is_on = True - client.sound_output = "speaker" + client.tv_state.is_on = True + client.tv_state.sound_output = "speaker" await client.mock_state_update() supported = ( @@ -684,8 +684,8 @@ async def test_cached_supported_features(hass: HomeAssistant, client) -> None: assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV off, support volume mute, step, set - client.is_on = False - client.sound_output = None + client.tv_state.is_on = False + client.tv_state.sound_output = None await client.mock_state_update() supported = ( @@ -728,8 +728,8 @@ async def test_cached_supported_features(hass: HomeAssistant, client) -> None: async def test_supported_features_no_cache(hass: HomeAssistant, client) -> None: """Test supported features if device is off and no cache.""" - client.is_on = False - client.sound_output = None + client.tv_state.is_on = False + client.tv_state.sound_output = None await setup_webostv(hass) supported = ( @@ -772,7 +772,7 @@ async def test_get_image_http( ) -> None: """Test get image via http.""" url = "http://something/valid_icon" - client.apps[LIVE_TV_APP_ID]["icon"] = url + client.tv_state.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -797,7 +797,7 @@ async def test_get_image_http_error( ) -> None: """Test get image via http error.""" url = "http://something/icon_error" - client.apps[LIVE_TV_APP_ID]["icon"] = url + client.tv_state.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -823,7 +823,7 @@ async def test_get_image_https( ) -> None: """Test get image via http.""" url = "https://something/valid_icon_https" - client.apps[LIVE_TV_APP_ID]["icon"] = url + client.tv_state.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -871,18 +871,18 @@ async def test_update_media_state(hass: HomeAssistant, client) -> None: """Test updating media state.""" await setup_webostv(hass) - client.media_state = [{"playState": "playing"}] + client.tv_state.media_state = [{"playState": "playing"}] await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PLAYING - client.media_state = [{"playState": "paused"}] + client.tv_state.media_state = [{"playState": "paused"}] await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PAUSED - client.media_state = [{"playState": "unloaded"}] + client.tv_state.media_state = [{"playState": "unloaded"}] await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.IDLE - client.is_on = False + client.tv_state.is_on = False await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == STATE_OFF diff --git a/tests/components/webostv/test_notify.py b/tests/components/webostv/test_notify.py index fd56f0ea0bb..e64d58b8f91 100644 --- a/tests/components/webostv/test_notify.py +++ b/tests/components/webostv/test_notify.py @@ -104,7 +104,7 @@ async def test_errors( ) -> None: """Test error scenarios.""" await setup_webostv(hass) - client.is_on = is_on + client.tv_state.is_on = is_on assert hass.services.has_service("notify", SERVICE_NAME) From 183bbcd1e196f80bfeae2916a4eaffedf5df3d64 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 23 Feb 2025 23:53:23 -0800 Subject: [PATCH 2673/2987] Bump androidtvremote2 to 0.2.0 (#139141) --- homeassistant/components/androidtv_remote/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index d9c2dd05c44..1c45e825359 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["androidtvremote2"], - "requirements": ["androidtvremote2==0.1.2"], + "requirements": ["androidtvremote2==0.2.0"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 7c9d90ad8df..d8e24dcc73b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -461,7 +461,7 @@ amcrest==1.9.8 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.1.2 +androidtvremote2==0.2.0 # homeassistant.components.anel_pwrctrl anel-pwrctrl-homeassistant==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b9a7579d7f1..3c8f2a803fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -437,7 +437,7 @@ amberelectric==2.0.12 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.1.2 +androidtvremote2==0.2.0 # homeassistant.components.anova anova-wifi==0.17.0 From 8c42db7501afa55535c0a0ce388369693885e716 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Feb 2025 09:12:35 +0100 Subject: [PATCH 2674/2987] Bump actions/upload-artifact from 4.6.0 to 4.6.1 (#139161) --- .github/workflows/builder.yml | 2 +- .github/workflows/ci.yaml | 22 +++++++++++----------- .github/workflows/wheels.yml | 8 ++++---- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ffefee0d84e..88f6f37d6d6 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -69,7 +69,7 @@ jobs: run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - - name: Upload translations - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: translations path: translations.tar.gz diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6eafa360e83..2aead92791a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -537,7 +537,7 @@ jobs: python --version uv pip freeze >> pip_freeze.txt - name: Upload pip_freeze artifact - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pip-freeze-${{ matrix.python-version }} path: pip_freeze.txt @@ -661,7 +661,7 @@ jobs: . venv/bin/activate python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json - name: Upload licenses - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: licenses-${{ github.run_number }}-${{ matrix.python-version }} path: licenses-${{ matrix.python-version }}.json @@ -877,7 +877,7 @@ jobs: . venv/bin/activate python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests - name: Upload pytest_buckets - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pytest_buckets path: pytest_buckets.txt @@ -980,14 +980,14 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-full.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml @@ -1108,7 +1108,7 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1116,7 +1116,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1239,7 +1239,7 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1247,7 +1247,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1382,14 +1382,14 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 41e7b351184..743ae869ab9 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -91,7 +91,7 @@ jobs: ) > build_constraints.txt - name: Upload env_file - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: env_file path: ./.env_file @@ -99,14 +99,14 @@ jobs: overwrite: true - name: Upload build_constraints - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: build_constraints path: ./build_constraints.txt overwrite: true - name: Upload requirements_diff - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: requirements_diff path: ./requirements_diff.txt @@ -118,7 +118,7 @@ jobs: python -m script.gen_requirements_all ci - name: Upload requirements_all_wheels - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: requirements_all_wheels path: ./requirements_all_wheels_*.txt From 7f494c235c52938156d7d7a3d671528bc5f0ded0 Mon Sep 17 00:00:00 2001 From: Philipp S Date: Mon, 24 Feb 2025 09:28:23 +0100 Subject: [PATCH 2675/2987] Consider the zone radius in proximity distance calculation (#138819) * Fix proximity distance calculation The distance is now calculated to the edge of the zone instead of the centre * Adjust proximity test expectations to corrected distance calculation * Add proximity tests for zone changes * Improve comment on proximity distance calculation Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> * Apply suggestions from code review --------- Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> --- .../components/proximity/coordinator.py | 11 +- .../proximity/snapshots/test_diagnostics.ambr | 8 +- tests/components/proximity/test_init.py | 150 ++++++++++++++---- 3 files changed, 133 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index 055c15125f1..856138c9051 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -164,7 +164,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ) return None - distance_to_zone = distance( + distance_to_centre = distance( zone.attributes[ATTR_LATITUDE], zone.attributes[ATTR_LONGITUDE], latitude, @@ -172,8 +172,13 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ) # it is ensured, that distance can't be None, since zones must have lat/lon coordinates - assert distance_to_zone is not None - return round(distance_to_zone) + assert distance_to_centre is not None + + zone_radius: float = zone.attributes["radius"] + if zone_radius > distance_to_centre: + # we've arrived the zone + return 0 + return round(distance_to_centre - zone_radius) def _calc_direction_of_travel( self, diff --git a/tests/components/proximity/snapshots/test_diagnostics.ambr b/tests/components/proximity/snapshots/test_diagnostics.ambr index 42ec74710f9..f6cd4393511 100644 --- a/tests/components/proximity/snapshots/test_diagnostics.ambr +++ b/tests/components/proximity/snapshots/test_diagnostics.ambr @@ -5,19 +5,19 @@ 'entities': dict({ 'device_tracker.test1': dict({ 'dir_of_travel': None, - 'dist_to_zone': 2218752, + 'dist_to_zone': 2218742, 'is_in_ignored_zone': False, 'name': 'test1', }), 'device_tracker.test2': dict({ 'dir_of_travel': None, - 'dist_to_zone': 4077309, + 'dist_to_zone': 4077299, 'is_in_ignored_zone': False, 'name': 'test2', }), 'device_tracker.test3': dict({ 'dir_of_travel': None, - 'dist_to_zone': 4077309, + 'dist_to_zone': 4077299, 'is_in_ignored_zone': False, 'name': 'test3', }), @@ -42,7 +42,7 @@ }), 'proximity': dict({ 'dir_of_travel': None, - 'dist_to_zone': 2218752, + 'dist_to_zone': 2218742, 'nearest': 'test1', }), 'tracked_states': dict({ diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index 22a546e6abe..e9340014207 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -128,7 +128,7 @@ async def test_device_tracker_test1_away(hass: HomeAssistant) -> None: entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -152,7 +152,7 @@ async def test_device_tracker_test1_awayfurther( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -169,7 +169,7 @@ async def test_device_tracker_test1_awayfurther( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "away_from" @@ -193,7 +193,7 @@ async def test_device_tracker_test1_awaycloser( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -210,7 +210,7 @@ async def test_device_tracker_test1_awaycloser( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "towards" @@ -272,7 +272,7 @@ async def test_device_tracker_test1_awayfurther_a_bit(hass: HomeAssistant) -> No entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -289,7 +289,7 @@ async def test_device_tracker_test1_awayfurther_a_bit(hass: HomeAssistant) -> No entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "stationary" @@ -360,7 +360,7 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test1( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -383,13 +383,13 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test1( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -432,7 +432,7 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test2( entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -449,13 +449,13 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test2( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -489,7 +489,7 @@ async def test_device_tracker_test1_awayfurther_test2_in_ignored_zone( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -562,7 +562,7 @@ async def test_device_tracker_test1_awayfurther_test2_first( entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -602,7 +602,7 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -625,13 +625,13 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "989156" + assert state.state == "989146" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -648,13 +648,13 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "1364567" + assert state.state == "1364557" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "away_from" @@ -693,15 +693,15 @@ async def test_nearest_sensors(hass: HomeAssistant, config_zones) -> None: state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" state = hass.states.get("sensor.home_nearest_distance") - assert state.state == "1615590" + assert state.state == "1615580" state = hass.states.get("sensor.home_test1_direction_of_travel") assert state.state == "towards" state = hass.states.get("sensor.home_test1_distance") - assert state.state == "1615590" + assert state.state == "1615580" state = hass.states.get("sensor.home_test1_direction_of_travel") assert state.state == "towards" state = hass.states.get("sensor.home_test2_distance") - assert state.state == "5176058" + assert state.state == "5176048" state = hass.states.get("sensor.home_test2_direction_of_travel") assert state.state == "away_from" @@ -715,15 +715,15 @@ async def test_nearest_sensors(hass: HomeAssistant, config_zones) -> None: state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" state = hass.states.get("sensor.home_nearest_distance") - assert state.state == "1615590" + assert state.state == "1615580" state = hass.states.get("sensor.home_nearest_direction_of_travel") assert state.state == "towards" state = hass.states.get("sensor.home_test1_distance") - assert state.state == "1615590" + assert state.state == "1615580" state = hass.states.get("sensor.home_test1_direction_of_travel") assert state.state == "towards" state = hass.states.get("sensor.home_test2_distance") - assert state.state == "4611404" + assert state.state == "4611394" state = hass.states.get("sensor.home_test2_direction_of_travel") assert state.state == "towards" @@ -737,15 +737,15 @@ async def test_nearest_sensors(hass: HomeAssistant, config_zones) -> None: state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" state = hass.states.get("sensor.home_nearest_distance") - assert state.state == "2204122" + assert state.state == "2204112" state = hass.states.get("sensor.home_nearest_direction_of_travel") assert state.state == "away_from" state = hass.states.get("sensor.home_test1_distance") - assert state.state == "2204122" + assert state.state == "2204112" state = hass.states.get("sensor.home_test1_direction_of_travel") assert state.state == "away_from" state = hass.states.get("sensor.home_test2_distance") - assert state.state == "4611404" + assert state.state == "4611394" state = hass.states.get("sensor.home_test2_direction_of_travel") assert state.state == "towards" @@ -919,3 +919,95 @@ async def test_tracked_zone_is_removed(hass: HomeAssistant) -> None: assert state.state == STATE_UNAVAILABLE state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNAVAILABLE + + +async def test_tracked_zone_radius_is_changed(hass: HomeAssistant) -> None: + """Test that radius of the tracked zone is changed.""" + entry = await async_setup_single_entry( + hass, "zone.home", ["device_tracker.test1"], [], 1 + ) + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.10000001, "longitude": 10.1}, + ) + await hass.async_block_till_done() + + # check sensor entities before radius change + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218742" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + # change radius of tracked zone + hass.states.async_set( + "zone.home", + "zoning", + {"name": "Home", "latitude": 2.1, "longitude": 1.1, "radius": 110}, + ) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + radius = hass.states.get("zone.home").attributes["radius"] + assert radius == 110 + + # check sensor entities after radius change + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218642" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + +async def test_tracked_zone_location_is_changed(hass: HomeAssistant) -> None: + """Test that gps location of the tracked zone is changed.""" + entry = await async_setup_single_entry( + hass, "zone.home", ["device_tracker.test1"], [], 1 + ) + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, + ) + await hass.async_block_till_done() + + # check sensor entities before location change + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218742" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + # change location of tracked zone + hass.states.async_set( + "zone.home", + "zoning", + {"name": "Home", "latitude": 10, "longitude": 5, "radius": 10}, + ) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + latitude = hass.states.get("zone.home").attributes["latitude"] + assert latitude == 10 + longitude = hass.states.get("zone.home").attributes["longitude"] + assert longitude == 5 + + # check sensor entities after location change + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "1244478" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN From 257242e6e3b5f94a0483b189a9aeb660960a3609 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Mon, 24 Feb 2025 17:37:25 +0900 Subject: [PATCH 2676/2987] Remove unnecessary min/max setting of WATER_HEATER (#138969) Remove unnecessary min/max setting Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/number.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/lg_thinq/number.py b/homeassistant/components/lg_thinq/number.py index 0cbfcf9b5c8..7003519e0ce 100644 --- a/homeassistant/components/lg_thinq/number.py +++ b/homeassistant/components/lg_thinq/number.py @@ -118,16 +118,7 @@ DEVICE_TYPE_NUMBER_MAP: dict[DeviceType, tuple[NumberEntityDescription, ...]] = DeviceType.WASHTOWER_DRYER: WASHER_NUMBERS, DeviceType.WASHTOWER: WASHER_NUMBERS, DeviceType.WASHTOWER_WASHER: WASHER_NUMBERS, - DeviceType.WATER_HEATER: ( - NumberEntityDescription( - key=ThinQProperty.TARGET_TEMPERATURE, - native_max_value=60, - native_min_value=35, - native_step=1, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - translation_key=ThinQProperty.TARGET_TEMPERATURE, - ), - ), + DeviceType.WATER_HEATER: (NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE],), DeviceType.WINE_CELLAR: ( NUMBER_DESC[ThinQProperty.LIGHT_STATUS], NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE], @@ -179,7 +170,7 @@ class ThinQNumberEntity(ThinQEntity, NumberEntity): ) is not None: self._attr_native_unit_of_measurement = unit_of_measurement - # Undate range. + # Update range. if ( self.entity_description.native_min_value is None and (min_value := self.data.min) is not None From fc8affd243968d02782dff70d98a644dccf22df8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Feb 2025 12:33:14 +0100 Subject: [PATCH 2677/2987] Remove setup of rpi_power from onboarding (#139168) * Remove setup of rpi_power from onboarding * Remove test --- .../components/onboarding/manifest.json | 2 +- homeassistant/components/onboarding/views.py | 11 -------- tests/components/onboarding/test_views.py | 26 ------------------- 3 files changed, 1 insertion(+), 38 deletions(-) diff --git a/homeassistant/components/onboarding/manifest.json b/homeassistant/components/onboarding/manifest.json index 8e253d4bff9..3634894cd00 100644 --- a/homeassistant/components/onboarding/manifest.json +++ b/homeassistant/components/onboarding/manifest.json @@ -1,7 +1,7 @@ { "domain": "onboarding", "name": "Home Assistant Onboarding", - "after_dependencies": ["backup", "hassio"], + "after_dependencies": ["backup"], "codeowners": ["@home-assistant/core"], "dependencies": ["auth", "http", "person"], "documentation": "https://www.home-assistant.io/integrations/onboarding", diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index ea955987d80..b392c6b57b0 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -29,7 +29,6 @@ from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar -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 @@ -224,16 +223,6 @@ class CoreConfigOnboardingView(_BaseOnboardingView): "shopping_list", ] - # pylint: disable-next=import-outside-toplevel - from homeassistant.components import hassio - - if ( - is_hassio(hass) - and (core_info := hassio.get_core_info(hass)) - and "raspberrypi" in core_info["machine"] - ): - onboard_integrations.append("rpi_power") - for domain in onboard_integrations: # Create tasks so onboarding isn't affected # by errors in these integrations. diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 99623cb6efe..08d21a13331 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -529,32 +529,6 @@ async def test_onboarding_core_sets_up_radio_browser( assert len(hass.config_entries.async_entries("radio_browser")) == 1 -async def test_onboarding_core_sets_up_rpi_power( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - rpi, - mock_default_integrations, -) -> None: - """Test that the core step sets up rpi_power on RPi.""" - mock_storage(hass_storage, {"done": [const.STEP_USER]}) - - assert await async_setup_component(hass, "onboarding", {}) - await hass.async_block_till_done() - - client = await hass_client() - - resp = await client.post("/api/onboarding/core_config") - - assert resp.status == 200 - - await hass.async_block_till_done() - - rpi_power_state = hass.states.get("binary_sensor.rpi_power_status") - assert rpi_power_state - - async def test_onboarding_core_no_rpi_power( hass: HomeAssistant, hass_storage: dict[str, Any], From d9eb248e91c11bdec4173f65ccf4734c8122aee5 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 24 Feb 2025 13:23:39 +0100 Subject: [PATCH 2678/2987] Better handle runtime recovery mode in bootstrap (#138624) * Better handle runtime recovery mode in bootstrap * Add test --- homeassistant/bootstrap.py | 66 ++++++++++++++++++++------------------ tests/test_bootstrap.py | 7 +++- 2 files changed, 41 insertions(+), 32 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7c5cb7dce4c..9cfc1c95d8b 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -328,10 +328,10 @@ async def async_setup_hass( block_async_io.enable() - config_dict = None - basic_setup_success = False - if not (recovery_mode := runtime_config.recovery_mode): + config_dict = None + basic_setup_success = False + await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass) try: @@ -349,39 +349,43 @@ async def async_setup_hass( await async_from_config_dict(config_dict, hass) is not None ) - if config_dict is None: - recovery_mode = True - await stop_hass(hass) - hass = await create_hass() + if config_dict is None: + recovery_mode = True + await stop_hass(hass) + hass = await create_hass() - elif not basic_setup_success: - _LOGGER.warning("Unable to set up core integrations. Activating recovery mode") - recovery_mode = True - await stop_hass(hass) - hass = await create_hass() + elif not basic_setup_success: + _LOGGER.warning( + "Unable to set up core integrations. Activating recovery mode" + ) + recovery_mode = True + await stop_hass(hass) + hass = await create_hass() - elif any(domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS): - _LOGGER.warning( - "Detected that %s did not load. Activating recovery mode", - ",".join(CRITICAL_INTEGRATIONS), - ) + elif any( + domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS + ): + _LOGGER.warning( + "Detected that %s did not load. Activating recovery mode", + ",".join(CRITICAL_INTEGRATIONS), + ) - old_config = hass.config - old_logging = hass.data.get(DATA_LOGGING) + old_config = hass.config + old_logging = hass.data.get(DATA_LOGGING) - recovery_mode = True - await stop_hass(hass) - hass = await create_hass() + recovery_mode = True + await stop_hass(hass) + hass = await create_hass() - if old_logging: - hass.data[DATA_LOGGING] = old_logging - hass.config.debug = old_config.debug - hass.config.skip_pip = old_config.skip_pip - hass.config.skip_pip_packages = old_config.skip_pip_packages - hass.config.internal_url = old_config.internal_url - hass.config.external_url = old_config.external_url - # Setup loader cache after the config dir has been set - loader.async_setup(hass) + if old_logging: + hass.data[DATA_LOGGING] = old_logging + hass.config.debug = old_config.debug + hass.config.skip_pip = old_config.skip_pip + hass.config.skip_pip_packages = old_config.skip_pip_packages + hass.config.internal_url = old_config.internal_url + hass.config.external_url = old_config.external_url + # Setup loader cache after the config dir has been set + loader.async_setup(hass) if recovery_mode: _LOGGER.info("Starting in recovery mode") diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index d554ca9449a..0d7c8614c6f 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from homeassistant import bootstrap, config as config_util, loader, runner +from homeassistant import bootstrap, config as config_util, core, loader, runner from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( BASE_PLATFORMS, @@ -787,6 +787,9 @@ async def test_setup_hass_recovery_mode( ) -> None: """Test it works.""" with ( + patch( + "homeassistant.core.HomeAssistant", wraps=core.HomeAssistant + ) as mock_hass, patch("homeassistant.components.browser.setup") as browser_setup, patch( "homeassistant.config_entries.ConfigEntries.async_domains", @@ -805,6 +808,8 @@ async def test_setup_hass_recovery_mode( ), ) + mock_hass.assert_called_once() + assert "recovery_mode" in hass.config.components assert len(mock_mount_local_lib_path.mock_calls) == 0 From 571349e3a28dab5704477833e9ceed54dcf482de Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 24 Feb 2025 07:45:10 -0500 Subject: [PATCH 2679/2987] Add Snoo integration (#134243) Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/components/snoo/__init__.py | 63 ++++++++++ homeassistant/components/snoo/config_flow.py | 68 ++++++++++ homeassistant/components/snoo/const.py | 3 + homeassistant/components/snoo/coordinator.py | 39 ++++++ homeassistant/components/snoo/entity.py | 37 ++++++ homeassistant/components/snoo/manifest.json | 11 ++ .../components/snoo/quality_scale.yaml | 72 +++++++++++ homeassistant/components/snoo/sensor.py | 71 +++++++++++ homeassistant/components/snoo/strings.json | 44 +++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/snoo/__init__.py | 38 ++++++ tests/components/snoo/conftest.py | 73 +++++++++++ tests/components/snoo/const.py | 34 +++++ tests/components/snoo/test_config_flow.py | 118 ++++++++++++++++++ tests/components/snoo/test_init.py | 14 +++ 19 files changed, 700 insertions(+) create mode 100644 homeassistant/components/snoo/__init__.py create mode 100644 homeassistant/components/snoo/config_flow.py create mode 100644 homeassistant/components/snoo/const.py create mode 100644 homeassistant/components/snoo/coordinator.py create mode 100644 homeassistant/components/snoo/entity.py create mode 100644 homeassistant/components/snoo/manifest.json create mode 100644 homeassistant/components/snoo/quality_scale.yaml create mode 100644 homeassistant/components/snoo/sensor.py create mode 100644 homeassistant/components/snoo/strings.json create mode 100644 tests/components/snoo/__init__.py create mode 100644 tests/components/snoo/conftest.py create mode 100644 tests/components/snoo/const.py create mode 100644 tests/components/snoo/test_config_flow.py create mode 100644 tests/components/snoo/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 6a66c24c7e8..3397948d7c8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1413,6 +1413,8 @@ build.json @home-assistant/supervisor /tests/components/snapcast/ @luar123 /homeassistant/components/snmp/ @nmaggioni /tests/components/snmp/ @nmaggioni +/homeassistant/components/snoo/ @Lash-L +/tests/components/snoo/ @Lash-L /homeassistant/components/snooz/ @AustinBrunkhorst /tests/components/snooz/ @AustinBrunkhorst /homeassistant/components/solaredge/ @frenck @bdraco diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py new file mode 100644 index 00000000000..aaf0c828830 --- /dev/null +++ b/homeassistant/components/snoo/__init__.py @@ -0,0 +1,63 @@ +"""The Happiest Baby Snoo integration.""" + +from __future__ import annotations + +import asyncio +import logging + +from python_snoo.exceptions import InvalidSnooAuth, SnooAuthException, SnooDeviceError +from python_snoo.snoo import Snoo + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import SnooConfigEntry, SnooCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool: + """Set up Happiest Baby Snoo from a config entry.""" + + snoo = Snoo( + email=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + clientsession=async_get_clientsession(hass), + ) + + try: + await snoo.authorize() + except (SnooAuthException, InvalidSnooAuth) as ex: + raise ConfigEntryNotReady from ex + try: + devices = await snoo.get_devices() + except SnooDeviceError as ex: + raise ConfigEntryNotReady from ex + coordinators: dict[str, SnooCoordinator] = {} + tasks = [] + for device in devices: + coordinators[device.serialNumber] = SnooCoordinator(hass, device, snoo) + tasks.append(coordinators[device.serialNumber].setup()) + await asyncio.gather(*tasks) + entry.runtime_data = coordinators + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool: + """Unload a config entry.""" + disconnects = await asyncio.gather( + *(coordinator.snoo.disconnect() for coordinator in entry.runtime_data.values()), + return_exceptions=True, + ) + for disconnect in disconnects: + if isinstance(disconnect, Exception): + _LOGGER.warning( + "Failed to disconnect a logger with exception: %s", disconnect + ) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/snoo/config_flow.py b/homeassistant/components/snoo/config_flow.py new file mode 100644 index 00000000000..986ef6a0071 --- /dev/null +++ b/homeassistant/components/snoo/config_flow.py @@ -0,0 +1,68 @@ +"""Config flow for the Happiest Baby Snoo integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import jwt +from python_snoo.exceptions import InvalidSnooAuth, SnooAuthException +from python_snoo.snoo import Snoo +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class SnooConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Happiest Baby Snoo.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + hub = Snoo( + email=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + clientsession=async_get_clientsession(self.hass), + ) + + try: + tokens = await hub.authorize() + except SnooAuthException: + errors["base"] = "cannot_connect" + except InvalidSnooAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception %s") + errors["base"] = "unknown" + else: + user_uuid = jwt.decode( + tokens.aws_access, options={"verify_signature": False} + )["username"] + await self.async_set_unique_id(user_uuid) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/snoo/const.py b/homeassistant/components/snoo/const.py new file mode 100644 index 00000000000..ff8afe25056 --- /dev/null +++ b/homeassistant/components/snoo/const.py @@ -0,0 +1,3 @@ +"""Constants for the Happiest Baby Snoo integration.""" + +DOMAIN = "snoo" diff --git a/homeassistant/components/snoo/coordinator.py b/homeassistant/components/snoo/coordinator.py new file mode 100644 index 00000000000..bc06d20955c --- /dev/null +++ b/homeassistant/components/snoo/coordinator.py @@ -0,0 +1,39 @@ +"""Support for Snoo Coordinators.""" + +import logging + +from python_snoo.containers import SnooData, SnooDevice +from python_snoo.snoo import Snoo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +type SnooConfigEntry = ConfigEntry[dict[str, SnooCoordinator]] + +_LOGGER = logging.getLogger(__name__) + + +class SnooCoordinator(DataUpdateCoordinator[SnooData]): + """Snoo coordinator.""" + + config_entry: SnooConfigEntry + + def __init__(self, hass: HomeAssistant, device: SnooDevice, snoo: Snoo) -> None: + """Set up Snoo Coordinator.""" + super().__init__( + hass, + name=device.name, + logger=_LOGGER, + ) + self.device_unique_id = device.serialNumber + self.device = device + self.sensor_data_set: bool = False + self.snoo = snoo + + async def setup(self) -> None: + """Perform setup needed on every coordintaor creation.""" + await self.snoo.subscribe(self.device, self.async_set_updated_data) + # After we subscribe - get the status so that we have something to start with. + # We only need to do this once. The device will auto update otherwise. + await self.snoo.get_status(self.device) diff --git a/homeassistant/components/snoo/entity.py b/homeassistant/components/snoo/entity.py new file mode 100644 index 00000000000..25f54344674 --- /dev/null +++ b/homeassistant/components/snoo/entity.py @@ -0,0 +1,37 @@ +"""Base entity for the Snoo integration.""" + +from __future__ import annotations + +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 SnooCoordinator + + +class SnooDescriptionEntity(CoordinatorEntity[SnooCoordinator]): + """Defines an Snoo entity that uses a description.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: SnooCoordinator, description: EntityDescription + ) -> None: + """Initialize the Snoo entity.""" + super().__init__(coordinator) + self.device = coordinator.device + self.entity_description = description + self._attr_unique_id = f"{coordinator.device_unique_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_unique_id)}, + name=self.device.name, + manufacturer="Happiest Baby", + model="Snoo", + serial_number=self.device.serialNumber, + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.data is not None and super().available diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json new file mode 100644 index 00000000000..3dca8cfe7dd --- /dev/null +++ b/homeassistant/components/snoo/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "snoo", + "name": "Happiest Baby Snoo", + "codeowners": ["@Lash-L"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/snoo", + "iot_class": "cloud_push", + "loggers": ["snoo"], + "quality_scale": "bronze", + "requirements": ["python-snoo==0.6.0"] +} diff --git a/homeassistant/components/snoo/quality_scale.yaml b/homeassistant/components/snoo/quality_scale.yaml new file mode 100644 index 00000000000..f10bccb131a --- /dev/null +++ b/homeassistant/components/snoo/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: + status: done + comment: | + There are no common patterns currenty. + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/snoo/sensor.py b/homeassistant/components/snoo/sensor.py new file mode 100644 index 00000000000..e45b2b88592 --- /dev/null +++ b/homeassistant/components/snoo/sensor.py @@ -0,0 +1,71 @@ +"""Support for Snoo Sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from python_snoo.containers import SnooData, SnooStates + +from homeassistant.components.sensor import ( + EntityCategory, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + StateType, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SnooConfigEntry +from .entity import SnooDescriptionEntity + + +@dataclass(frozen=True, kw_only=True) +class SnooSensorEntityDescription(SensorEntityDescription): + """Describes a Snoo sensor.""" + + value_fn: Callable[[SnooData], StateType] + + +SENSOR_DESCRIPTIONS: list[SnooSensorEntityDescription] = [ + SnooSensorEntityDescription( + key="state", + translation_key="state", + value_fn=lambda data: data.state_machine.state.name, + device_class=SensorDeviceClass.ENUM, + options=[e.name for e in SnooStates], + ), + SnooSensorEntityDescription( + key="time_left", + translation_key="time_left", + value_fn=lambda data: data.state_machine.time_left_timestamp, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SnooConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Snoo device.""" + coordinators = entry.runtime_data + async_add_entities( + SnooSensor(coordinator, description) + for coordinator in coordinators.values() + for description in SENSOR_DESCRIPTIONS + ) + + +class SnooSensor(SnooDescriptionEntity, SensorEntity): + """A sensor using Snoo coordinator.""" + + entity_description: SnooSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json new file mode 100644 index 00000000000..567fa30fca7 --- /dev/null +++ b/homeassistant/components/snoo/strings.json @@ -0,0 +1,44 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Your Snoo username or email", + "password": "Your Snoo password" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "state": { + "name": "State", + "state": { + "baseline": "Baseline", + "level1": "Level 1", + "level2": "Level 2", + "level3": "Level 3", + "level4": "Level 4", + "stop": "Stopped", + "pretimeout": "Pre-timeout", + "timeout": "Timeout" + } + }, + "time_left": { + "name": "Time left" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 40af1df86cd..c92235aae47 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -575,6 +575,7 @@ FLOWS = { "smlight", "sms", "snapcast", + "snoo", "snooz", "solaredge", "solarlog", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2d28d4f46d7..6f4315c43dc 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5916,6 +5916,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "snoo": { + "name": "Happiest Baby Snoo", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "snooz": { "name": "Snooz", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index d8e24dcc73b..50c4ad93559 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2463,6 +2463,9 @@ python-roborock==2.11.1 # homeassistant.components.smarttub python-smarttub==0.0.38 +# homeassistant.components.snoo +python-snoo==0.6.0 + # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c8f2a803fb..a1c713424b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1996,6 +1996,9 @@ python-roborock==2.11.1 # homeassistant.components.smarttub python-smarttub==0.0.38 +# homeassistant.components.snoo +python-snoo==0.6.0 + # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/tests/components/snoo/__init__.py b/tests/components/snoo/__init__.py new file mode 100644 index 00000000000..f8529251720 --- /dev/null +++ b/tests/components/snoo/__init__.py @@ -0,0 +1,38 @@ +"""Tests for the Happiest Baby Snoo integration.""" + +from homeassistant.components.snoo.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +def create_entry( + hass: HomeAssistant, +) -> ConfigEntry: + """Add config entry in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="test-username", + data={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "sample", + }, + # This is also gotten from the fake jwt + unique_id="123e4567-e89b-12d3-a456-426614174000", + version=1, + ) + entry.add_to_hass(hass) + return entry + + +async def async_init_integration(hass: HomeAssistant) -> ConfigEntry: + """Set up the Snoo integration in Home Assistant.""" + + entry = create_entry(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/snoo/conftest.py b/tests/components/snoo/conftest.py new file mode 100644 index 00000000000..33642e67ff5 --- /dev/null +++ b/tests/components/snoo/conftest.py @@ -0,0 +1,73 @@ +"""Common fixtures for the Happiest Baby Snoo tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from python_snoo.containers import SnooDevice +from python_snoo.snoo import Snoo + +from .const import MOCK_AMAZON_AUTH, MOCK_SNOO_AUTH, MOCK_SNOO_DEVICES + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.snoo.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +class MockedSnoo(Snoo): + """Mock the Snoo object.""" + + def __init__(self, email, password, clientsession) -> None: + """Set up a Mocked Snoo.""" + super().__init__(email, password, clientsession) + self.auth_error = None + + async def subscribe(self, device: SnooDevice, function): + """Mock the subscribe function.""" + return AsyncMock() + + async def send_command(self, command: str, device: SnooDevice, **kwargs): + """Mock the send command function.""" + return AsyncMock() + + async def authorize(self): + """Do normal auth flow unless error is patched.""" + if self.auth_error: + raise self.auth_error + return await super().authorize() + + def set_auth_error(self, error: Exception | None): + """Set an error for authentication.""" + self.auth_error = error + + async def auth_amazon(self): + """Mock the amazon auth.""" + return MOCK_AMAZON_AUTH + + async def auth_snoo(self, id_token): + """Mock the snoo auth.""" + return MOCK_SNOO_AUTH + + async def schedule_reauthorization(self, snoo_expiry: int): + """Mock scheduling reauth.""" + return AsyncMock() + + async def get_devices(self) -> list[SnooDevice]: + """Move getting devices.""" + return [SnooDevice.from_dict(dev) for dev in MOCK_SNOO_DEVICES] + + +@pytest.fixture(name="bypass_api") +def bypass_api() -> MockedSnoo: + """Bypass the Snoo api.""" + api = MockedSnoo("email", "password", AsyncMock()) + with ( + patch("homeassistant.components.snoo.Snoo", return_value=api), + patch("homeassistant.components.snoo.config_flow.Snoo", return_value=api), + ): + yield api diff --git a/tests/components/snoo/const.py b/tests/components/snoo/const.py new file mode 100644 index 00000000000..c5d53780fa1 --- /dev/null +++ b/tests/components/snoo/const.py @@ -0,0 +1,34 @@ +"""Snoo constants for testing.""" + +MOCK_AMAZON_AUTH = { + # This is a JWT with random values. + "AccessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMWIyYzNkNC1lNWY2" + "LTQ3ODktOTBhYi1jZGVmMDEyMzQ1NjciLCJpc3MiOiJodHRwczovL2NvZ25pdG8taWRwLnVzLXdlc3Qt" + "Mi5hbWF6b25hd3MuY29tL3VzLXdlc3QtMl9FeGFtcGxlVXNlclBvb2xJZCIsImNsaWVudF9pZCI6ImFiY" + "2RlZmdoMTIzNDU2Nzg5MGFiY2RlZmdoMTIiLCJvcmlnaW5fanRpIjoiYjhkOWUwZjEtMmczaC00aTVqLT" + "ZrN2wtOG05bjBvMXAycTNyIiwiZXZlbnRfaWQiOiJmMGcxaDJpMy00ajVrLTZsN20tOG45by0wcDFxMnI" + "zczR0NXUiLCJ0b2tlbl91c2UiOiJhY2Nlc3MiLCJzY29wZSI6ImF3cy5jb2duaXRvLnNpZ25pbi51c2Vy" + "LmFkbWluIiwiYXV0aF90aW1lIjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDAsImlhdCI6MTcwMDAwM" + "DAwMCwianRpIjoidjZ3N3g4eTktMHoxYS0yYjNjLTRkNWUtNmY3ZzhoOWkwajFrIiwidXNlcm5hbWUiOi" + "IxMjNlNDU2Ny1lODliLTEyZDMtYTQ1Ni00MjY2MTQxNzQwMDAifQ.zH5vy5itWot_5-rdJgYoygeKx696" + "Uge46zxXMhdn5RE", + "IdToken": "random_id", + "RefreshToken": "refresh_token", +} + +MOCK_SNOO_AUTH = {"expiresIn": 10800, "snoo": {"token": "random_snoo_token"}} + +MOCK_SNOO_DEVICES = [ + { + "serialNumber": "random_num", + "deviceType": 1, + "firmwareVersion": 1.0, + "babyIds": ["35235-211235-dfasdf-32523"], + "name": "Test Snoo", + "presence": {}, + "presenceIoT": {}, + "awsIoT": {}, + "lastSSID": {}, + "provisionedAt": "random_time", + } +] diff --git a/tests/components/snoo/test_config_flow.py b/tests/components/snoo/test_config_flow.py new file mode 100644 index 00000000000..ffdfb22142d --- /dev/null +++ b/tests/components/snoo/test_config_flow.py @@ -0,0 +1,118 @@ +"""Test the Happiest Baby Snoo config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from python_snoo.exceptions import InvalidSnooAuth, SnooAuthException + +from homeassistant import config_entries +from homeassistant.components.snoo.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import create_entry +from .conftest import MockedSnoo + + +async def test_config_flow_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api: MockedSnoo +) -> None: + """Test we create the entry successfully.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert result["result"].unique_id == "123e4567-e89b-12d3-a456-426614174000" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error_msg"), + [ + (InvalidSnooAuth, "invalid_auth"), + (SnooAuthException, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_auth_issues( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + bypass_api: MockedSnoo, + exception, + error_msg, +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # Set Authorize to fail. + bypass_api.set_auth_error(exception) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + # Reset auth back to the original + bypass_api.set_auth_error(None) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error_msg} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_account_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api +) -> None: + """Ensure we abort if the config flow already exists.""" + create_entry(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/snoo/test_init.py b/tests/components/snoo/test_init.py new file mode 100644 index 00000000000..06f420b6518 --- /dev/null +++ b/tests/components/snoo/test_init.py @@ -0,0 +1,14 @@ +"""Test init for Snoo.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import async_init_integration +from .conftest import MockedSnoo + + +async def test_async_setup_entry(hass: HomeAssistant, bypass_api: MockedSnoo) -> None: + """Test a successful setup entry.""" + entry = await async_init_integration(hass) + assert len(hass.states.async_all("sensor")) == 2 + assert entry.state == ConfigEntryState.LOADED From beec67a247fbdca4b730624a2b203b02a90d1919 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 24 Feb 2025 13:52:31 +0100 Subject: [PATCH 2680/2987] Bump zwave-js-server-python to 0.60.1 (#139185) Bump zwave-js-server-python 0.60.1 --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 011776f4556..3178bdf46ad 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.60.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.60.1"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 50c4ad93559..738f8d3d918 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3158,7 +3158,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.60.0 +zwave-js-server-python==0.60.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1c713424b4..0c5dfa45469 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2538,7 +2538,7 @@ zeversolar==0.3.2 zha==0.0.49 # homeassistant.components.zwave_js -zwave-js-server-python==0.60.0 +zwave-js-server-python==0.60.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From 0b7a023d2e079dff5cdf04571fa01a24bcd13a31 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 24 Feb 2025 13:56:06 +0100 Subject: [PATCH 2681/2987] Fix description of `cycle` field in `input_select.select_previous` action (#139032) --- homeassistant/components/input_select/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/input_select/strings.json b/homeassistant/components/input_select/strings.json index c46e3740b68..72fd50f7ec7 100644 --- a/homeassistant/components/input_select/strings.json +++ b/homeassistant/components/input_select/strings.json @@ -44,7 +44,7 @@ "fields": { "cycle": { "name": "[%key:component::input_select::services::select_next::fields::cycle::name%]", - "description": "[%key:component::input_select::services::select_next::fields::cycle::description%]" + "description": "If the option should cycle from the first to the last option on the list." } } }, From 37240e811bd2655f77365cc0612b0163ddd08919 Mon Sep 17 00:00:00 2001 From: Antonio Larrosa Date: Mon, 24 Feb 2025 13:57:21 +0100 Subject: [PATCH 2682/2987] Add melcloud standard horizontal vane modes (#136654) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/melcloud/climate.py | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 03bb4babf1c..9c2ee60b12c 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -152,6 +152,14 @@ class AtaDeviceClimate(MelCloudClimate): self._attr_unique_id = f"{self.api.device.serial}-{self.api.device.mac}" self._attr_device_info = self.api.device_info + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + + # We can only check for vane_horizontal once we fetch the device data from the cloud + if self._device.vane_horizontal: + self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE + @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the optional state attributes with device specific additions.""" @@ -274,15 +282,29 @@ class AtaDeviceClimate(MelCloudClimate): """Return vertical vane position or mode.""" return self._device.vane_vertical + @property + def swing_horizontal_mode(self) -> str | None: + """Return horizontal vane position or mode.""" + return self._device.vane_horizontal + async def async_set_swing_mode(self, swing_mode: str) -> None: """Set vertical vane position or mode.""" await self.async_set_vane_vertical(swing_mode) + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set horizontal vane position or mode.""" + await self.async_set_vane_horizontal(swing_horizontal_mode) + @property def swing_modes(self) -> list[str] | None: """Return a list of available vertical vane positions and modes.""" return self._device.vane_vertical_positions + @property + def swing_horizontal_modes(self) -> list[str] | None: + """Return a list of available horizontal vane positions and modes.""" + return self._device.vane_horizontal_positions + async def async_turn_on(self) -> None: """Turn the entity on.""" await self._device.set({"power": True}) From f98720e525b62c7e5efbf5569ef8208a56439760 Mon Sep 17 00:00:00 2001 From: laiho-vogels <144690720+laiho-vogels@users.noreply.github.com> Date: Mon, 24 Feb 2025 13:59:34 +0100 Subject: [PATCH 2683/2987] Change code owner - MotionMount integration (#139187) --- CODEOWNERS | 4 ++-- homeassistant/components/motionmount/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 3397948d7c8..b16c1e7e1f8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -967,8 +967,8 @@ build.json @home-assistant/supervisor /tests/components/motionblinds_ble/ @LennP @jerrybboy /homeassistant/components/motioneye/ @dermotduffy /tests/components/motioneye/ @dermotduffy -/homeassistant/components/motionmount/ @RJPoelstra -/tests/components/motionmount/ @RJPoelstra +/homeassistant/components/motionmount/ @laiho-vogels +/tests/components/motionmount/ @laiho-vogels /homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco /tests/components/mqtt/ @emontnemery @jbouwh @bdraco /homeassistant/components/msteams/ @peroyvind diff --git a/homeassistant/components/motionmount/manifest.json b/homeassistant/components/motionmount/manifest.json index 2665836ffd4..337ce776b33 100644 --- a/homeassistant/components/motionmount/manifest.json +++ b/homeassistant/components/motionmount/manifest.json @@ -1,7 +1,7 @@ { "domain": "motionmount", "name": "Vogel's MotionMount", - "codeowners": ["@RJPoelstra"], + "codeowners": ["@laiho-vogels"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/motionmount", "integration_type": "device", From 5025e311299608800d4461a8cb7055165f14456b Mon Sep 17 00:00:00 2001 From: SteveDiks <126147459+SteveDiks@users.noreply.github.com> Date: Mon, 24 Feb 2025 14:01:40 +0100 Subject: [PATCH 2684/2987] Bump Weheat to 2025.2.22 (#139186) --- homeassistant/components/weheat/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index 1d60f66afba..a408303d062 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", "iot_class": "cloud_polling", - "requirements": ["weheat==2025.1.15"] + "requirements": ["weheat==2025.2.22"] } diff --git a/requirements_all.txt b/requirements_all.txt index 738f8d3d918..1ce88e0f55d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3055,7 +3055,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.1.15 +weheat==2025.2.22 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c5dfa45469..c6588b06c41 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2459,7 +2459,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.1.15 +weheat==2025.2.22 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.12 From 51a881f3b50ae8df3ed8f5ad21fbf57089e15a31 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Mon, 24 Feb 2025 06:09:43 -0800 Subject: [PATCH 2685/2987] Add ambient temperature and humidity status sensors to NUT (#124181) Co-authored-by: J. Nick Koston --- CODEOWNERS | 4 +- homeassistant/components/nut/diagnostics.py | 4 +- homeassistant/components/nut/icons.json | 6 + homeassistant/components/nut/manifest.json | 2 +- homeassistant/components/nut/sensor.py | 23 + homeassistant/components/nut/strings.json | 2 + tests/components/nut/conftest.py | 5 + .../nut/fixtures/EATON-EPDU-G3.json | 539 ++++++++++++++++++ tests/components/nut/test_init.py | 50 +- tests/components/nut/test_sensor.py | 71 ++- tests/components/nut/util.py | 27 + 11 files changed, 724 insertions(+), 9 deletions(-) create mode 100644 tests/components/nut/conftest.py create mode 100644 tests/components/nut/fixtures/EATON-EPDU-G3.json diff --git a/CODEOWNERS b/CODEOWNERS index b16c1e7e1f8..61b2eb5b557 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1051,8 +1051,8 @@ build.json @home-assistant/supervisor /tests/components/numato/ @clssn /homeassistant/components/number/ @home-assistant/core @Shulyaka /tests/components/number/ @home-assistant/core @Shulyaka -/homeassistant/components/nut/ @bdraco @ollo69 @pestevez -/tests/components/nut/ @bdraco @ollo69 @pestevez +/homeassistant/components/nut/ @bdraco @ollo69 @pestevez @tdfountain +/tests/components/nut/ @bdraco @ollo69 @pestevez @tdfountain /homeassistant/components/nws/ @MatthewFlamm @kamiyo /tests/components/nws/ @MatthewFlamm @kamiyo /homeassistant/components/nyt_games/ @joostlek diff --git a/homeassistant/components/nut/diagnostics.py b/homeassistant/components/nut/diagnostics.py index 532e4ece76b..ec59fa65c22 100644 --- a/homeassistant/components/nut/diagnostics.py +++ b/homeassistant/components/nut/diagnostics.py @@ -39,8 +39,8 @@ async def async_get_config_entry_diagnostics( hass_device = device_registry.async_get_device( identifiers={(DOMAIN, hass_data.unique_id)} ) - if not hass_device: - return data + # Device is always created + assert hass_device is not None data["device"] = { **attr.asdict(hass_device), diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index e0f78d6400b..91df9d10553 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -1,6 +1,12 @@ { "entity": { "sensor": { + "ambient_humidity_status": { + "default": "mdi:information-outline" + }, + "ambient_temperature_status": { + "default": "mdi:information-outline" + }, "battery_alarm_threshold": { "default": "mdi:information-outline" }, diff --git a/homeassistant/components/nut/manifest.json b/homeassistant/components/nut/manifest.json index fb6c8561b25..1ee85a84caf 100644 --- a/homeassistant/components/nut/manifest.json +++ b/homeassistant/components/nut/manifest.json @@ -1,7 +1,7 @@ { "domain": "nut", "name": "Network UPS Tools (NUT)", - "codeowners": ["@bdraco", "@ollo69", "@pestevez"], + "codeowners": ["@bdraco", "@ollo69", "@pestevez", "@tdfountain"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nut", "integration_type": "device", diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 22e0496d0de..2f574ec4842 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -46,8 +46,17 @@ NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = { "serial": ATTR_SERIAL_NUMBER, } +AMBIENT_THRESHOLD_STATUS_OPTIONS = [ + "good", + "warning-low", + "critical-low", + "warning-high", + "critical-high", +] + _LOGGER = logging.getLogger(__name__) + SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.status.display": SensorEntityDescription( key="ups.status.display", @@ -930,6 +939,13 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + "ambient.humidity.status": SensorEntityDescription( + key="ambient.humidity.status", + translation_key="ambient_humidity_status", + device_class=SensorDeviceClass.ENUM, + options=AMBIENT_THRESHOLD_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, + ), "ambient.temperature": SensorEntityDescription( key="ambient.temperature", translation_key="ambient_temperature", @@ -938,6 +954,13 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + "ambient.temperature.status": SensorEntityDescription( + key="ambient.temperature.status", + translation_key="ambient_temperature_status", + device_class=SensorDeviceClass.ENUM, + options=AMBIENT_THRESHOLD_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, + ), "watts": SensorEntityDescription( key="watts", translation_key="watts", diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 83b8d340dc1..b9485a320fb 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -80,7 +80,9 @@ "entity": { "sensor": { "ambient_humidity": { "name": "Ambient humidity" }, + "ambient_humidity_status": { "name": "Ambient humidity status" }, "ambient_temperature": { "name": "Ambient temperature" }, + "ambient_temperature_status": { "name": "Ambient temperature status" }, "battery_alarm_threshold": { "name": "Battery alarm threshold" }, "battery_capacity": { "name": "Battery capacity" }, "battery_charge": { "name": "Battery charge" }, diff --git a/tests/components/nut/conftest.py b/tests/components/nut/conftest.py new file mode 100644 index 00000000000..bcf1cb4a99f --- /dev/null +++ b/tests/components/nut/conftest.py @@ -0,0 +1,5 @@ +"""NUT session fixtures.""" + +import pytest + +pytest.register_assert_rewrite("tests.components.nut.util") diff --git a/tests/components/nut/fixtures/EATON-EPDU-G3.json b/tests/components/nut/fixtures/EATON-EPDU-G3.json new file mode 100644 index 00000000000..cd6aeb4fd92 --- /dev/null +++ b/tests/components/nut/fixtures/EATON-EPDU-G3.json @@ -0,0 +1,539 @@ +{ + "ambient.contacts.1.status": "opened", + "ambient.contacts.2.status": "opened", + "ambient.count": "0", + "ambient.humidity": "29.90", + "ambient.humidity.high": "90", + "ambient.humidity.high.critical": "90", + "ambient.humidity.high.warning": "65", + "ambient.humidity.low": "10", + "ambient.humidity.low.critical": "10", + "ambient.humidity.low.warning": "20", + "ambient.humidity.status": "good", + "ambient.present": "yes", + "ambient.temperature": "28.9", + "ambient.temperature.high": "43.30", + "ambient.temperature.high.critical": "43.30", + "ambient.temperature.high.warning": "37.70", + "ambient.temperature.low": "5", + "ambient.temperature.low.critical": "5", + "ambient.temperature.low.warning": "10", + "ambient.temperature.status": "good", + "device.contact": "Contact Name", + "device.count": "1", + "device.description": "ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE", + "device.location": "Device Location", + "device.macaddr": "00 00 00 FF FF FF ", + "device.mfr": "EATON", + "device.model": "ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE", + "device.part": "EMA000-00", + "device.serial": "A000A00000", + "device.type": "pdu", + "driver.debug": "0", + "driver.flag.allow_killpower": "0", + "driver.name": "snmp-ups", + "driver.parameter.pollinterval": "2", + "driver.parameter.port": "eaton-pdu", + "driver.parameter.synchronous": "auto", + "driver.state": "dumping", + "driver.version": "2.8.2.882-882-g63d90ebcb", + "driver.version.data": "eaton_epdu MIB 0.69", + "driver.version.internal": "1.31", + "input.current": "4.30", + "input.current.high.critical": "16", + "input.current.high.warning": "12.80", + "input.current.low.warning": "0", + "input.current.nominal": "16", + "input.current.status": "good", + "input.feed.color": "0", + "input.feed.desc": "Feed A", + "input.frequency": "60", + "input.frequency.status": "good", + "input.L1.current": "4.30", + "input.L1.current.high.critical": "16", + "input.L1.current.high.warning": "12.80", + "input.L1.current.low.warning": "0", + "input.L1.current.nominal": "16", + "input.L1.current.status": "good", + "input.L1.load": "26", + "input.L1.power": "529", + "input.L1.realpower": "482", + "input.L1.voltage": "122.91", + "input.L1.voltage.high.critical": "140", + "input.L1.voltage.high.warning": "130", + "input.L1.voltage.low.critical": "90", + "input.L1.voltage.low.warning": "95", + "input.L1.voltage.status": "good", + "input.load": "26", + "input.phases": "1", + "input.power": "532", + "input.realpower": "482", + "input.realpower.nominal": "1920", + "input.voltage": "122.91", + "input.voltage.high.critical": "140", + "input.voltage.high.warning": "130", + "input.voltage.low.critical": "90", + "input.voltage.low.warning": "95", + "input.voltage.status": "good", + "outlet.1.current": "0", + "outlet.1.current.high.critical": "16", + "outlet.1.current.high.warning": "12.80", + "outlet.1.current.low.warning": "0", + "outlet.1.current.status": "good", + "outlet.1.delay.shutdown": "120", + "outlet.1.delay.start": "1", + "outlet.1.desc": "Outlet A1", + "outlet.1.groupid": "1", + "outlet.1.id": "1", + "outlet.1.name": "A1", + "outlet.1.power": "0", + "outlet.1.realpower": "0", + "outlet.1.status": "on", + "outlet.1.switchable": "yes", + "outlet.1.timer.shutdown": "-1", + "outlet.1.timer.start": "-1", + "outlet.1.type": "nema520", + "outlet.10.current": "0.26", + "outlet.10.current.high.critical": "16", + "outlet.10.current.high.warning": "12.80", + "outlet.10.current.low.warning": "0", + "outlet.10.current.status": "good", + "outlet.10.delay.shutdown": "120", + "outlet.10.delay.start": "10", + "outlet.10.desc": "Outlet A10", + "outlet.10.groupid": "1", + "outlet.10.id": "10", + "outlet.10.name": "A10", + "outlet.10.power": "32", + "outlet.10.realpower": "15", + "outlet.10.status": "on", + "outlet.10.switchable": "yes", + "outlet.10.timer.shutdown": "-1", + "outlet.10.timer.start": "-1", + "outlet.10.type": "nema520", + "outlet.11.current": "0.24", + "outlet.11.current.high.critical": "16", + "outlet.11.current.high.warning": "12.80", + "outlet.11.current.low.warning": "0", + "outlet.11.current.status": "good", + "outlet.11.delay.shutdown": "120", + "outlet.11.delay.start": "11", + "outlet.11.desc": "Outlet A11", + "outlet.11.groupid": "1", + "outlet.11.id": "11", + "outlet.11.name": "A11", + "outlet.11.power": "29", + "outlet.11.realpower": "22", + "outlet.11.status": "on", + "outlet.11.switchable": "yes", + "outlet.11.timer.shutdown": "-1", + "outlet.11.timer.start": "-1", + "outlet.11.type": "nema520", + "outlet.12.current": "0", + "outlet.12.current.high.critical": "16", + "outlet.12.current.high.warning": "12.80", + "outlet.12.current.low.warning": "0", + "outlet.12.current.status": "good", + "outlet.12.delay.shutdown": "120", + "outlet.12.delay.start": "12", + "outlet.12.desc": "Outlet A12", + "outlet.12.groupid": "1", + "outlet.12.id": "12", + "outlet.12.name": "A12", + "outlet.12.power": "0", + "outlet.12.realpower": "0", + "outlet.12.status": "on", + "outlet.12.switchable": "yes", + "outlet.12.timer.shutdown": "-1", + "outlet.12.timer.start": "-1", + "outlet.12.type": "nema520", + "outlet.13.current": "0.23", + "outlet.13.current.high.critical": "16", + "outlet.13.current.high.warning": "12.80", + "outlet.13.current.low.warning": "0", + "outlet.13.current.status": "good", + "outlet.13.delay.shutdown": "0", + "outlet.13.delay.start": "0", + "outlet.13.desc": "Outlet A13", + "outlet.13.groupid": "1", + "outlet.13.id": "0", + "outlet.13.name": "A13", + "outlet.13.power": "27", + "outlet.13.realpower": "9", + "outlet.13.status": "on", + "outlet.13.switchable": "yes", + "outlet.13.timer.shutdown": "-1", + "outlet.13.timer.start": "-1", + "outlet.13.type": "nema520", + "outlet.14.current": "0.10", + "outlet.14.current.high.critical": "16", + "outlet.14.current.high.warning": "12.80", + "outlet.14.current.low.warning": "0", + "outlet.14.current.status": "good", + "outlet.14.delay.shutdown": "120", + "outlet.14.delay.start": "14", + "outlet.14.desc": "Outlet A14", + "outlet.14.groupid": "1", + "outlet.14.id": "14", + "outlet.14.name": "A14", + "outlet.14.power": "12", + "outlet.14.realpower": "7", + "outlet.14.status": "on", + "outlet.14.switchable": "yes", + "outlet.14.timer.shutdown": "-1", + "outlet.14.timer.start": "-1", + "outlet.14.type": "nema520", + "outlet.15.current": "0.03", + "outlet.15.current.high.critical": "16", + "outlet.15.current.high.warning": "12.80", + "outlet.15.current.low.warning": "0", + "outlet.15.current.status": "good", + "outlet.15.delay.shutdown": "120", + "outlet.15.delay.start": "15", + "outlet.15.desc": "Outlet A15", + "outlet.15.groupid": "1", + "outlet.15.id": "15", + "outlet.15.name": "A15", + "outlet.15.power": "3", + "outlet.15.realpower": "1", + "outlet.15.status": "on", + "outlet.15.switchable": "yes", + "outlet.15.timer.shutdown": "-1", + "outlet.15.timer.start": "-1", + "outlet.15.type": "nema520", + "outlet.16.current": "0.04", + "outlet.16.current.high.critical": "16", + "outlet.16.current.high.warning": "12.80", + "outlet.16.current.low.warning": "0", + "outlet.16.current.status": "good", + "outlet.16.delay.shutdown": "120", + "outlet.16.delay.start": "16", + "outlet.16.desc": "Outlet A16", + "outlet.16.groupid": "1", + "outlet.16.id": "16", + "outlet.16.name": "A16", + "outlet.16.power": "4", + "outlet.16.realpower": "1", + "outlet.16.status": "on", + "outlet.16.switchable": "yes", + "outlet.16.timer.shutdown": "-1", + "outlet.16.timer.start": "-1", + "outlet.16.type": "nema520", + "outlet.17.current": "0.19", + "outlet.17.current.high.critical": "16", + "outlet.17.current.high.warning": "12.80", + "outlet.17.current.low.warning": "0", + "outlet.17.current.status": "good", + "outlet.17.delay.shutdown": "0", + "outlet.17.delay.start": "0", + "outlet.17.desc": "Outlet A17", + "outlet.17.groupid": "1", + "outlet.17.id": "0", + "outlet.17.name": "A17", + "outlet.17.power": "23", + "outlet.17.realpower": "5", + "outlet.17.status": "on", + "outlet.17.switchable": "yes", + "outlet.17.timer.shutdown": "-1", + "outlet.17.timer.start": "-1", + "outlet.17.type": "nema520", + "outlet.18.current": "0.35", + "outlet.18.current.high.critical": "16", + "outlet.18.current.high.warning": "12.80", + "outlet.18.current.low.warning": "0", + "outlet.18.current.status": "good", + "outlet.18.delay.shutdown": "0", + "outlet.18.delay.start": "0", + "outlet.18.desc": "Outlet A18", + "outlet.18.groupid": "1", + "outlet.18.id": "0", + "outlet.18.name": "A18", + "outlet.18.power": "42", + "outlet.18.realpower": "34", + "outlet.18.status": "on", + "outlet.18.switchable": "yes", + "outlet.18.timer.shutdown": "-1", + "outlet.18.timer.start": "-1", + "outlet.18.type": "nema520", + "outlet.19.current": "0.12", + "outlet.19.current.high.critical": "16", + "outlet.19.current.high.warning": "12.80", + "outlet.19.current.low.warning": "0", + "outlet.19.current.status": "good", + "outlet.19.delay.shutdown": "0", + "outlet.19.delay.start": "0", + "outlet.19.desc": "Outlet A19", + "outlet.19.groupid": "1", + "outlet.19.id": "0", + "outlet.19.name": "A19", + "outlet.19.power": "15", + "outlet.19.realpower": "6", + "outlet.19.status": "on", + "outlet.19.switchable": "yes", + "outlet.19.timer.shutdown": "-1", + "outlet.19.timer.start": "-1", + "outlet.19.type": "nema520", + "outlet.2.current": "0.39", + "outlet.2.current.high.critical": "16", + "outlet.2.current.high.warning": "12.80", + "outlet.2.current.low.warning": "0", + "outlet.2.current.status": "good", + "outlet.2.delay.shutdown": "120", + "outlet.2.delay.start": "2", + "outlet.2.desc": "Outlet A2", + "outlet.2.groupid": "1", + "outlet.2.id": "2", + "outlet.2.name": "A2", + "outlet.2.power": "47", + "outlet.2.realpower": "43", + "outlet.2.status": "on", + "outlet.2.switchable": "yes", + "outlet.2.timer.shutdown": "-1", + "outlet.2.timer.start": "-1", + "outlet.2.type": "nema520", + "outlet.20.current": "0", + "outlet.20.current.high.critical": "16", + "outlet.20.current.high.warning": "12.80", + "outlet.20.current.low.warning": "0", + "outlet.20.current.status": "good", + "outlet.20.delay.shutdown": "120", + "outlet.20.delay.start": "20", + "outlet.20.desc": "Outlet A20", + "outlet.20.groupid": "1", + "outlet.20.id": "20", + "outlet.20.name": "A20", + "outlet.20.power": "0", + "outlet.20.realpower": "0", + "outlet.20.status": "on", + "outlet.20.switchable": "yes", + "outlet.20.timer.shutdown": "-1", + "outlet.20.timer.start": "-1", + "outlet.20.type": "nema520", + "outlet.21.current": "0", + "outlet.21.current.high.critical": "16", + "outlet.21.current.high.warning": "12.80", + "outlet.21.current.low.warning": "0", + "outlet.21.current.status": "good", + "outlet.21.delay.shutdown": "120", + "outlet.21.delay.start": "21", + "outlet.21.desc": "Outlet A21", + "outlet.21.groupid": "1", + "outlet.21.id": "21", + "outlet.21.name": "A21", + "outlet.21.power": "0", + "outlet.21.realpower": "0", + "outlet.21.status": "on", + "outlet.21.switchable": "yes", + "outlet.21.timer.shutdown": "-1", + "outlet.21.timer.start": "-1", + "outlet.21.type": "nema520", + "outlet.22.current": "0", + "outlet.22.current.high.critical": "16", + "outlet.22.current.high.warning": "12.80", + "outlet.22.current.low.warning": "0", + "outlet.22.current.status": "good", + "outlet.22.delay.shutdown": "0", + "outlet.22.delay.start": "0", + "outlet.22.desc": "Outlet A22", + "outlet.22.groupid": "1", + "outlet.22.id": "0", + "outlet.22.name": "A22", + "outlet.22.power": "0", + "outlet.22.realpower": "0", + "outlet.22.status": "on", + "outlet.22.switchable": "yes", + "outlet.22.timer.shutdown": "-1", + "outlet.22.timer.start": "-1", + "outlet.22.type": "nema520", + "outlet.23.current": "0.34", + "outlet.23.current.high.critical": "16", + "outlet.23.current.high.warning": "12.80", + "outlet.23.current.low.warning": "0", + "outlet.23.current.status": "good", + "outlet.23.delay.shutdown": "120", + "outlet.23.delay.start": "23", + "outlet.23.desc": "Outlet A23", + "outlet.23.groupid": "1", + "outlet.23.id": "23", + "outlet.23.name": "A23", + "outlet.23.power": "41", + "outlet.23.realpower": "39", + "outlet.23.status": "on", + "outlet.23.switchable": "yes", + "outlet.23.timer.shutdown": "-1", + "outlet.23.timer.start": "-1", + "outlet.23.type": "nema520", + "outlet.24.current": "0.19", + "outlet.24.current.high.critical": "16", + "outlet.24.current.high.warning": "12.80", + "outlet.24.current.low.warning": "0", + "outlet.24.current.status": "good", + "outlet.24.delay.shutdown": "0", + "outlet.24.delay.start": "0", + "outlet.24.desc": "Outlet A24", + "outlet.24.groupid": "1", + "outlet.24.id": "0", + "outlet.24.name": "A24", + "outlet.24.power": "23", + "outlet.24.realpower": "11", + "outlet.24.status": "on", + "outlet.24.switchable": "yes", + "outlet.24.timer.shutdown": "-1", + "outlet.24.timer.start": "-1", + "outlet.24.type": "nema520", + "outlet.3.current": "0.46", + "outlet.3.current.high.critical": "16", + "outlet.3.current.high.warning": "12.80", + "outlet.3.current.low.warning": "0", + "outlet.3.current.status": "good", + "outlet.3.delay.shutdown": "120", + "outlet.3.delay.start": "3", + "outlet.3.desc": "Outlet A3", + "outlet.3.groupid": "1", + "outlet.3.id": "3", + "outlet.3.name": "A3", + "outlet.3.power": "56", + "outlet.3.realpower": "53", + "outlet.3.status": "on", + "outlet.3.switchable": "yes", + "outlet.3.timer.shutdown": "-1", + "outlet.3.timer.start": "-1", + "outlet.3.type": "nema520", + "outlet.4.current": "0.44", + "outlet.4.current.high.critical": "16", + "outlet.4.current.high.warning": "12.80", + "outlet.4.current.low.warning": "0", + "outlet.4.current.status": "good", + "outlet.4.delay.shutdown": "120", + "outlet.4.delay.start": "4", + "outlet.4.desc": "Outlet A4", + "outlet.4.groupid": "1", + "outlet.4.id": "4", + "outlet.4.name": "A4", + "outlet.4.power": "53", + "outlet.4.realpower": "48", + "outlet.4.status": "on", + "outlet.4.switchable": "yes", + "outlet.4.timer.shutdown": "-1", + "outlet.4.timer.start": "-1", + "outlet.4.type": "nema520", + "outlet.5.current": "0.43", + "outlet.5.current.high.critical": "16", + "outlet.5.current.high.warning": "12.80", + "outlet.5.current.low.warning": "0", + "outlet.5.current.status": "good", + "outlet.5.delay.shutdown": "120", + "outlet.5.delay.start": "5", + "outlet.5.desc": "Outlet A5", + "outlet.5.groupid": "1", + "outlet.5.id": "5", + "outlet.5.name": "A5", + "outlet.5.power": "52", + "outlet.5.realpower": "48", + "outlet.5.status": "on", + "outlet.5.switchable": "yes", + "outlet.5.timer.shutdown": "-1", + "outlet.5.timer.start": "-1", + "outlet.5.type": "nema520", + "outlet.6.current": "1.07", + "outlet.6.current.high.critical": "16", + "outlet.6.current.high.warning": "12.80", + "outlet.6.current.low.warning": "0", + "outlet.6.current.status": "good", + "outlet.6.delay.shutdown": "120", + "outlet.6.delay.start": "6", + "outlet.6.desc": "Outlet A6", + "outlet.6.groupid": "1", + "outlet.6.id": "6", + "outlet.6.name": "A6", + "outlet.6.power": "131", + "outlet.6.realpower": "118", + "outlet.6.status": "on", + "outlet.6.switchable": "yes", + "outlet.6.timer.shutdown": "-1", + "outlet.6.timer.start": "-1", + "outlet.6.type": "nema520", + "outlet.7.current": "0", + "outlet.7.current.high.critical": "16", + "outlet.7.current.high.warning": "12.80", + "outlet.7.current.low.warning": "0", + "outlet.7.current.status": "good", + "outlet.7.delay.shutdown": "120", + "outlet.7.delay.start": "7", + "outlet.7.desc": "Outlet A7", + "outlet.7.groupid": "1", + "outlet.7.id": "7", + "outlet.7.name": "A7", + "outlet.7.power": "0", + "outlet.7.realpower": "0", + "outlet.7.status": "on", + "outlet.7.switchable": "yes", + "outlet.7.timer.shutdown": "-1", + "outlet.7.timer.start": "-1", + "outlet.7.type": "nema520", + "outlet.8.current": "0", + "outlet.8.current.high.critical": "16", + "outlet.8.current.high.warning": "12.80", + "outlet.8.current.low.warning": "0", + "outlet.8.current.status": "good", + "outlet.8.delay.shutdown": "120", + "outlet.8.delay.start": "8", + "outlet.8.desc": "Outlet A8", + "outlet.8.groupid": "1", + "outlet.8.id": "8", + "outlet.8.name": "A8", + "outlet.8.power": "0", + "outlet.8.realpower": "0", + "outlet.8.status": "on", + "outlet.8.switchable": "yes", + "outlet.8.timer.shutdown": "-1", + "outlet.8.timer.start": "-1", + "outlet.8.type": "nema520", + "outlet.9.current": "0", + "outlet.9.current.high.critical": "16", + "outlet.9.current.high.warning": "12.80", + "outlet.9.current.low.warning": "0", + "outlet.9.current.status": "good", + "outlet.9.delay.shutdown": "120", + "outlet.9.delay.start": "9", + "outlet.9.desc": "Outlet A9", + "outlet.9.groupid": "1", + "outlet.9.id": "9", + "outlet.9.name": "A9", + "outlet.9.power": "0", + "outlet.9.realpower": "0", + "outlet.9.status": "on", + "outlet.9.switchable": "yes", + "outlet.9.timer.shutdown": "-1", + "outlet.9.timer.start": "-1", + "outlet.9.type": "nema520", + "outlet.count": "24", + "outlet.current": "43.05", + "outlet.desc": "All outlets", + "outlet.frequency": "60", + "outlet.group.1.color": "16051527", + "outlet.group.1.count": "24", + "outlet.group.1.desc": "Section A", + "outlet.group.1.id": "1", + "outlet.group.1.input": "1", + "outlet.group.1.name": "A", + "outlet.group.1.phase": "1", + "outlet.group.1.status": "on", + "outlet.group.1.type": "outlet-section", + "outlet.group.1.voltage": "122.83", + "outlet.group.1.voltage.high.critical": "140", + "outlet.group.1.voltage.high.warning": "130", + "outlet.group.1.voltage.low.critical": "90", + "outlet.group.1.voltage.low.warning": "95", + "outlet.group.1.voltage.status": "good", + "outlet.group.count": "1", + "outlet.id": "0", + "outlet.switchable": "yes", + "outlet.voltage": "122.91", + "ups.firmware": "05.01.0002", + "ups.mfr": "EATON", + "ups.model": "ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE", + "ups.serial": "A000A00000", + "ups.status": "", + "ups.type": "pdu" +} diff --git a/tests/components/nut/test_init.py b/tests/components/nut/test_init.py index d5d85daa336..0585696cef2 100644 --- a/tests/components/nut/test_init.py +++ b/tests/components/nut/test_init.py @@ -1,12 +1,19 @@ """Test init of Nut integration.""" +from copy import deepcopy from unittest.mock import patch from aionut import NUTError, NUTLoginError from homeassistant.components.nut.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -147,3 +154,44 @@ async def test_device_location(hass: HomeAssistant) -> None: assert device_entry is not None assert device_entry.suggested_area == mock_device_location + + +async def test_update_options(hass: HomeAssistant) -> None: + """Test update options triggers reload.""" + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, list_vars={"ups.status": "OL"} + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "mock", + CONF_PASSWORD: "somepassword", + CONF_PORT: "mock", + CONF_USERNAME: "someuser", + }, + options={ + "device_options": { + "fake_option": "fake_option_value", + }, + }, + ) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED + + new_options = deepcopy(dict(mock_config_entry.options)) + new_options["device_options"].clear() + hass.config_entries.async_update_entry(mock_config_entry, options=new_options) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index afe57631910..eb171c39011 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -5,17 +5,23 @@ from unittest.mock import patch import pytest from homeassistant.components.nut.const import DOMAIN +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_RESOURCES, PERCENTAGE, STATE_UNKNOWN, + UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .util import _get_mock_nutclient, async_init_integration +from .util import ( + _get_mock_nutclient, + _test_sensor_and_attributes, + async_init_integration, +) from tests.common import MockConfigEntry @@ -32,7 +38,7 @@ from tests.common import MockConfigEntry "blazer_usb", ], ) -async def test_devices( +async def test_ups_devices( hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str ) -> None: """Test creation of device sensors.""" @@ -67,7 +73,7 @@ async def test_devices( ), ], ) -async def test_devices_with_unique_ids( +async def test_ups_devices_with_unique_ids( hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str, unique_id: str ) -> None: """Test creation of device sensors with unique ids.""" @@ -92,6 +98,65 @@ async def test_devices_with_unique_ids( ) +@pytest.mark.parametrize( + ("model", "unique_id_base"), + [ + ( + "EATON-EPDU-G3", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_", + ), + ], +) +async def test_pdu_devices_with_unique_ids( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Test creation of device sensors with unique ids.""" + + await _test_sensor_and_attributes( + hass, + entity_registry, + model, + unique_id=f"{unique_id_base}input.voltage", + device_id="sensor.ups1_input_voltage", + state_value="122.91", + expected_attributes={ + "device_class": SensorDeviceClass.VOLTAGE, + "state_class": SensorStateClass.MEASUREMENT, + "friendly_name": "Ups1 Input voltage", + "unit_of_measurement": UnitOfElectricPotential.VOLT, + }, + ) + + await _test_sensor_and_attributes( + hass, + entity_registry, + model, + unique_id=f"{unique_id_base}ambient.humidity.status", + device_id="sensor.ups1_ambient_humidity_status", + state_value="good", + expected_attributes={ + "device_class": SensorDeviceClass.ENUM, + "friendly_name": "Ups1 Ambient humidity status", + }, + ) + + await _test_sensor_and_attributes( + hass, + entity_registry, + model, + unique_id=f"{unique_id_base}ambient.temperature.status", + device_id="sensor.ups1_ambient_temperature_status", + state_value="good", + expected_attributes={ + "device_class": SensorDeviceClass.ENUM, + "friendly_name": "Ups1 Ambient temperature status", + }, + ) + + async def test_state_sensors(hass: HomeAssistant) -> None: """Test creation of status display sensors.""" entry = MockConfigEntry( diff --git a/tests/components/nut/util.py b/tests/components/nut/util.py index b6c9cffd390..bd82ffdd6b4 100644 --- a/tests/components/nut/util.py +++ b/tests/components/nut/util.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from homeassistant.components.nut.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, load_fixture @@ -79,3 +80,29 @@ async def async_init_integration( await hass.async_block_till_done() return entry + + +async def _test_sensor_and_attributes( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id: str, + device_id: str, + state_value: str, + expected_attributes: dict, +) -> None: + """Test creation of device sensors with unique ids.""" + + await async_init_integration(hass, model) + entry = entity_registry.async_get(device_id) + assert entry + assert entry.unique_id == unique_id + + state = hass.states.get(device_id) + assert state.state == state_value + + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all( + state.attributes[key] == attr for key, attr in expected_attributes.items() + ) From 377da5f9547fe2a5c825e7fd28efdbe5a396e993 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 24 Feb 2025 17:11:07 +0200 Subject: [PATCH 2686/2987] Update LG webOS TV diagnostics to use tv_info and tv_state dictionaries (#139189) --- .../components/webostv/diagnostics.py | 11 +- .../webostv/snapshots/test_diagnostics.ambr | 101 +++++++++++------- 2 files changed, 66 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/webostv/diagnostics.py b/homeassistant/components/webostv/diagnostics.py index 393a6a066ff..e4ea38064a8 100644 --- a/homeassistant/components/webostv/diagnostics.py +++ b/homeassistant/components/webostv/diagnostics.py @@ -32,15 +32,8 @@ async def async_get_config_entry_diagnostics( client_data = { "is_registered": client.is_registered(), "is_connected": client.is_connected(), - "current_app_id": client.tv_state.current_app_id, - "current_channel": client.tv_state.current_channel, - "apps": client.tv_state.apps, - "inputs": client.tv_state.inputs, - "system_info": client.tv_info.system, - "software_info": client.tv_info.software, - "hello_info": client.tv_info.hello, - "sound_output": client.tv_state.sound_output, - "is_on": client.tv_state.is_on, + "tv_info": client.tv_info.__dict__, + "tv_state": client.tv_state.__dict__, } return async_redact_data( diff --git a/tests/components/webostv/snapshots/test_diagnostics.ambr b/tests/components/webostv/snapshots/test_diagnostics.ambr index 030554b963a..2febee15deb 100644 --- a/tests/components/webostv/snapshots/test_diagnostics.ambr +++ b/tests/components/webostv/snapshots/test_diagnostics.ambr @@ -2,46 +2,73 @@ # name: test_diagnostics dict({ 'client': dict({ - 'apps': dict({ - 'com.webos.app.livetv': dict({ - 'icon': '**REDACTED**', - 'id': 'com.webos.app.livetv', - 'largeIcon': '**REDACTED**', - 'title': 'Live TV', - }), - }), - 'current_app_id': 'com.webos.app.livetv', - 'current_channel': dict({ - 'channelId': 'ch1id', - 'channelName': 'Channel 1', - 'channelNumber': '1', - }), - 'hello_info': dict({ - 'deviceUUID': '**REDACTED**', - }), - 'inputs': dict({ - 'in1': dict({ - 'appId': 'app0', - 'id': 'in1', - 'label': 'Input01', - }), - 'in2': dict({ - 'appId': 'app1', - 'id': 'in2', - 'label': 'Input02', - }), - }), 'is_connected': True, - 'is_on': True, 'is_registered': True, - 'software_info': dict({ - 'major_ver': 'major', - 'minor_ver': 'minor', + 'tv_info': dict({ + 'hello': dict({ + 'deviceUUID': '**REDACTED**', + }), + 'software': dict({ + 'major_ver': 'major', + 'minor_ver': 'minor', + }), + 'system': dict({ + 'modelName': 'MODEL', + 'serialNumber': '1234567890', + }), }), - 'sound_output': 'speaker', - 'system_info': dict({ - 'modelName': 'MODEL', - 'serialNumber': '1234567890', + 'tv_state': dict({ + 'apps': dict({ + 'com.webos.app.livetv': dict({ + 'icon': '**REDACTED**', + 'id': 'com.webos.app.livetv', + 'largeIcon': '**REDACTED**', + 'title': 'Live TV', + }), + }), + 'channel_info': None, + 'channels': list([ + dict({ + 'channelId': 'ch1id', + 'channelName': 'Channel 1', + 'channelNumber': '1', + }), + dict({ + 'channelId': 'ch2id', + 'channelName': 'Channel Name 2', + 'channelNumber': '20', + }), + ]), + 'current_app_id': 'com.webos.app.livetv', + 'current_channel': dict({ + 'channelId': 'ch1id', + 'channelName': 'Channel 1', + 'channelNumber': '1', + }), + 'inputs': dict({ + 'in1': dict({ + 'appId': 'app0', + 'id': 'in1', + 'label': 'Input01', + }), + 'in2': dict({ + 'appId': 'app1', + 'id': 'in2', + 'label': 'Input02', + }), + }), + 'is_on': True, + 'is_screen_on': False, + 'media_state': list([ + dict({ + 'playState': '', + }), + ]), + 'muted': False, + 'power_state': dict({ + }), + 'sound_output': 'speaker', + 'volume': 37, }), }), 'entry': dict({ From 351e594fe4cb6ec1b9f597e89c1b901910414a2a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Feb 2025 17:14:47 +0100 Subject: [PATCH 2687/2987] Add flag to backup store to track backup wizard completion (#138368) * Add flag to backup store to track backup wizard completion * Add comment * Update hassio tests * Update tests --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/backup/config.py | 8 + homeassistant/components/backup/store.py | 7 +- homeassistant/components/backup/websocket.py | 1 + .../backup/snapshots/test_store.ambr | 212 ++++++++++- .../backup/snapshots/test_websocket.ambr | 345 +++++++++++++++++- tests/components/backup/test_store.py | 75 ++++ tests/components/backup/test_websocket.py | 26 ++ .../hassio/snapshots/test_backup.ambr | 3 + tests/components/hassio/test_backup.py | 2 + 9 files changed, 658 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index f34c1b8887d..65f9f4789a6 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -39,6 +39,7 @@ class StoredBackupConfig(TypedDict): """Represent the stored backup config.""" agents: dict[str, StoredAgentConfig] + automatic_backups_configured: bool create_backup: StoredCreateBackupConfig last_attempted_automatic_backup: str | None last_completed_automatic_backup: str | None @@ -51,6 +52,7 @@ class BackupConfigData: """Represent loaded backup config data.""" agents: dict[str, AgentConfig] + automatic_backups_configured: bool # only used by frontend create_backup: CreateBackupConfig last_attempted_automatic_backup: datetime | None = None last_completed_automatic_backup: datetime | None = None @@ -88,6 +90,7 @@ class BackupConfigData: agent_id: AgentConfig(protected=agent_data["protected"]) for agent_id, agent_data in data["agents"].items() }, + automatic_backups_configured=data["automatic_backups_configured"], create_backup=CreateBackupConfig( agent_ids=data["create_backup"]["agent_ids"], include_addons=data["create_backup"]["include_addons"], @@ -127,6 +130,7 @@ class BackupConfigData: agents={ agent_id: agent.to_dict() for agent_id, agent in self.agents.items() }, + automatic_backups_configured=self.automatic_backups_configured, create_backup=self.create_backup.to_dict(), last_attempted_automatic_backup=last_attempted, last_completed_automatic_backup=last_completed, @@ -142,6 +146,7 @@ class BackupConfig: """Initialize backup config.""" self.data = BackupConfigData( agents={}, + automatic_backups_configured=False, create_backup=CreateBackupConfig(), retention=RetentionConfig(), schedule=BackupSchedule(), @@ -159,6 +164,7 @@ class BackupConfig: self, *, agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED, + automatic_backups_configured: bool | UndefinedType = UNDEFINED, create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED, retention: RetentionParametersDict | UndefinedType = UNDEFINED, schedule: ScheduleParametersDict | UndefinedType = UNDEFINED, @@ -172,6 +178,8 @@ class BackupConfig: self.data.agents[agent_id] = replace( self.data.agents[agent_id], **agent_config ) + if automatic_backups_configured is not UNDEFINED: + self.data.automatic_backups_configured = automatic_backups_configured if create_backup is not UNDEFINED: self.data.create_backup = replace(self.data.create_backup, **create_backup) if retention is not UNDEFINED: diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 8287080b5a2..883447853e6 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: STORE_DELAY_SAVE = 30 STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 4 +STORAGE_VERSION_MINOR = 5 class StoredBackupData(TypedDict): @@ -67,6 +67,11 @@ class _BackupStore(Store[StoredBackupData]): data["config"]["retention"]["copies"] = None if data["config"]["retention"]["days"] == 0: data["config"]["retention"]["days"] = None + if old_minor_version < 5: + # Version 1.5 adds automatic_backups_configured + data["config"]["automatic_backups_configured"] = ( + data["config"]["create_backup"]["password"] is not None + ) # Note: We allow reading data with major version 2. # Reject if major version is higher than 2. diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index b36343c7634..5084f904ec6 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -352,6 +352,7 @@ async def handle_config_info( { vol.Required("type"): "backup/config/update", vol.Optional("agents"): vol.Schema({str: {"protected": bool}}), + vol.Optional("automatic_backups_configured"): bool, vol.Optional("create_backup"): vol.Schema( { vol.Optional("agent_ids"): vol.All([str], vol.Unique()), diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index 04f88b84a97..41778322825 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -13,6 +13,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -39,7 +40,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -57,6 +58,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -84,7 +86,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -102,6 +104,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -128,7 +131,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -146,6 +149,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -173,7 +177,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -194,6 +198,7 @@ 'protected': True, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -220,7 +225,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -241,6 +246,7 @@ 'protected': True, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -268,7 +274,201 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data3] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data3].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data4] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': 'hunter2', + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data4].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': 'hunter2', + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, 'version': 1, }) # --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 742fec4c3f3..c100a87e8cc 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -258,6 +258,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -295,6 +296,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -344,6 +346,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -382,6 +385,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -420,6 +424,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -459,6 +464,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -497,6 +503,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -543,6 +550,7 @@ 'protected': False, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -583,6 +591,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'hassio.local', @@ -623,6 +632,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'hassio.local', @@ -662,6 +672,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -699,6 +710,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -744,6 +756,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -782,6 +795,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -820,6 +834,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -859,6 +874,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -897,6 +913,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -943,6 +960,7 @@ 'protected': False, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -983,6 +1001,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'backup.local', @@ -1022,6 +1041,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'backup.local', @@ -1061,6 +1081,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1098,6 +1119,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1137,6 +1159,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1164,7 +1187,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -1175,6 +1198,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1212,6 +1236,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1251,6 +1276,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1278,7 +1304,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -1289,6 +1315,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1326,6 +1353,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1365,6 +1393,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1392,7 +1421,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -1403,6 +1432,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1446,6 +1476,7 @@ 'protected': False, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1490,6 +1521,7 @@ 'protected': False, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1516,7 +1548,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -1527,6 +1559,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1570,6 +1603,7 @@ 'protected': False, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1613,6 +1647,7 @@ 'protected': True, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1657,6 +1692,7 @@ 'protected': True, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1683,7 +1719,237 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_config_update[commands14] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands14].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands14].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_config_update[commands15] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands15].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands15].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, 'version': 1, }) # --- @@ -1694,6 +1960,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1731,6 +1998,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1770,6 +2038,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1797,7 +2066,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -1808,6 +2077,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1845,6 +2115,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1885,6 +2156,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1913,7 +2185,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -1924,6 +2196,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1961,6 +2234,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2000,6 +2274,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2027,7 +2302,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2038,6 +2313,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2075,6 +2351,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2116,6 +2393,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2145,7 +2423,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2156,6 +2434,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2193,6 +2472,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2236,6 +2516,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2267,7 +2548,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2278,6 +2559,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2315,6 +2597,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2354,6 +2637,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2381,7 +2665,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2392,6 +2676,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2429,6 +2714,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2468,6 +2754,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2495,7 +2782,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2506,6 +2793,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2543,6 +2831,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2582,6 +2871,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2609,7 +2899,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2620,6 +2910,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2657,6 +2948,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2696,6 +2988,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2723,7 +3016,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2734,6 +3027,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2771,6 +3065,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2808,6 +3103,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2845,6 +3141,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2882,6 +3179,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2919,6 +3217,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2956,6 +3255,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2993,6 +3293,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3030,6 +3331,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3067,6 +3369,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3104,6 +3407,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3141,6 +3445,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3178,6 +3483,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3215,6 +3521,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3252,6 +3559,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3289,6 +3597,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3326,6 +3635,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3363,6 +3673,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3400,6 +3711,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3437,6 +3749,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3474,6 +3787,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3511,6 +3825,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3548,6 +3863,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3585,6 +3901,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py index eff53bda777..0d29bb2006a 100644 --- a/tests/components/backup/test_store.py +++ b/tests/components/backup/test_store.py @@ -99,6 +99,7 @@ def mock_delay_save() -> Generator[None]: ], "config": { "agents": {"test.remote": {"protected": True}}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": [], "include_addons": None, @@ -125,6 +126,80 @@ def mock_delay_save() -> Generator[None]: "key": DOMAIN, "version": 2, }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "agents": {"test.remote": {"protected": True}}, + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "minor_version": 4, + "version": 1, + }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "agents": {"test.remote": {"protected": True}}, + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": "hunter2", + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "minor_version": 4, + "version": 1, + }, ], ) async def test_store_migration( diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 6d5adb32c01..6605674a679 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -55,6 +55,7 @@ DEFAULT_STORAGE_DATA: dict[str, Any] = { "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": [], "include_addons": None, @@ -907,6 +908,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -938,6 +940,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": True, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -969,6 +972,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1000,6 +1004,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1031,6 +1036,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1062,6 +1068,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1096,6 +1103,7 @@ async def test_agents_info( "test-agent1": {"protected": True}, "test-agent2": {"protected": False}, }, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1127,6 +1135,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": True, "create_backup": { "agent_ids": ["hassio.local", "hassio.share", "test-agent"], "include_addons": None, @@ -1158,6 +1167,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": True, "create_backup": { "agent_ids": ["backup.local", "test-agent"], "include_addons": None, @@ -1343,6 +1353,18 @@ async def test_config_load_config_info( }, }, ], + [ + { + "type": "backup/config/update", + "automatic_backups_configured": False, + } + ], + [ + { + "type": "backup/config/update", + "automatic_backups_configured": True, + } + ], ], ) @patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) @@ -1774,6 +1796,7 @@ async def test_config_schedule_logic( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test.test-agent"], "include_addons": [], @@ -2436,6 +2459,7 @@ async def test_config_retention_copies_logic( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -2714,6 +2738,7 @@ async def test_config_retention_copies_logic_manual_backup( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -3161,6 +3186,7 @@ async def test_config_retention_days_logic( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], diff --git a/tests/components/hassio/snapshots/test_backup.ambr b/tests/components/hassio/snapshots/test_backup.ambr index a2f33bf9624..725239ee126 100644 --- a/tests/components/hassio/snapshots/test_backup.ambr +++ b/tests/components/hassio/snapshots/test_backup.ambr @@ -6,6 +6,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -43,6 +44,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'test-agent1', @@ -89,6 +91,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'test-agent1', diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 6a66d249dd1..c7f400cef5c 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -2480,6 +2480,7 @@ async def test_restore_progress_after_restart_unknown_job( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": True, "create_backup": { "agent_ids": ["test-agent1", "hassio.local", "test-agent2"], "include_addons": ["addon1", "addon2"], @@ -2511,6 +2512,7 @@ async def test_restore_progress_after_restart_unknown_job( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": True, "create_backup": { "agent_ids": ["test-agent1", "backup.local", "test-agent2"], "include_addons": ["addon1", "addon2"], From 461039f06a8eddf83203b95200728db737be95ab Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 24 Feb 2025 17:23:14 +0100 Subject: [PATCH 2688/2987] Add translations for exceptions and data descriptions to pyLoad integration (#138896) --- .../components/pyload/coordinator.py | 8 +++++-- homeassistant/components/pyload/strings.json | 22 ++++++++++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/pyload/coordinator.py b/homeassistant/components/pyload/coordinator.py index 937d8d71291..c57dfa7720d 100644 --- a/homeassistant/components/pyload/coordinator.py +++ b/homeassistant/components/pyload/coordinator.py @@ -78,10 +78,14 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]): return self.data except CannotConnect as e: raise UpdateFailed( - "Unable to connect and retrieve data from pyLoad API" + translation_domain=DOMAIN, + translation_key="setup_request_exception", ) from e except ParserError as e: - raise UpdateFailed("Unable to parse data from pyLoad API") from e + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_parse_exception", + ) from e async def _async_setup(self) -> None: """Set up the coordinator.""" diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index 0fd9b4befcf..ed15a438c28 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -12,7 +12,11 @@ }, "data_description": { "host": "The hostname or IP address of the device running your pyLoad instance.", - "port": "pyLoad uses port 8000 by default." + "username": "The username used to access the pyLoad instance.", + "password": "The password associated with the pyLoad account.", + "port": "pyLoad uses port 8000 by default.", + "ssl": "If enabled, the connection to the pyLoad instance will use HTTPS.", + "verify_ssl": "If checked, the SSL certificate will be validated to ensure a secure connection." } }, "reconfigure": { @@ -25,8 +29,12 @@ "port": "[%key:common::config_flow::data::port%]" }, "data_description": { - "host": "The hostname or IP address of the device running your pyLoad instance.", - "port": "pyLoad uses port 8000 by default." + "host": "[%key:component::pyload::config::step::user::data_description::host%]", + "verify_ssl": "[%key:component::pyload::config::step::user::data_description::verify_ssl%]", + "username": "[%key:component::pyload::config::step::user::data_description::username%]", + "password": "[%key:component::pyload::config::step::user::data_description::password%]", + "port": "[%key:component::pyload::config::step::user::data_description::port%]", + "ssl": "[%key:component::pyload::config::step::user::data_description::ssl%]" } }, "reauth_confirm": { @@ -34,6 +42,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::pyload::config::step::user::data_description::username%]", + "password": "[%key:component::pyload::config::step::user::data_description::password%]" } } }, @@ -91,10 +103,10 @@ }, "exceptions": { "setup_request_exception": { - "message": "Unable to connect and retrieve data from pyLoad API, try again later" + "message": "Unable to connect and retrieve data from pyLoad API" }, "setup_parse_exception": { - "message": "Unable to parse data from pyLoad API, try again later" + "message": "Unable to parse data from pyLoad API" }, "setup_authentication_exception": { "message": "Authentication failed for {username}, verify your login credentials" From 2e5f56b70d144b2d19a2e757dbb39cce25eb9216 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 24 Feb 2025 17:36:20 +0100 Subject: [PATCH 2689/2987] Refactor to-do list order and reordering in Habitica (#138566) --- homeassistant/components/habitica/todo.py | 54 +++++++++++-------- .../fixtures/reorder_dailies_response.json | 15 ++++++ .../fixtures/reorder_todos_response.json | 12 +++++ tests/components/habitica/test_todo.py | 31 +++++++++-- 4 files changed, 84 insertions(+), 28 deletions(-) create mode 100644 tests/components/habitica/fixtures/reorder_dailies_response.json create mode 100644 tests/components/habitica/fixtures/reorder_todos_response.json diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py index 29b98e90b04..71ba8e60e06 100644 --- a/homeassistant/components/habitica/todo.py +++ b/homeassistant/components/habitica/todo.py @@ -117,20 +117,24 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): """Move an item in the To-do list.""" if TYPE_CHECKING: assert self.todo_items + tasks_order = ( + self.coordinator.data.user.tasksOrder.todos + if self.entity_description.key is HabiticaTodoList.TODOS + else self.coordinator.data.user.tasksOrder.dailys + ) if previous_uid: - pos = self.todo_items.index( - next(item for item in self.todo_items if item.uid == previous_uid) - ) - if pos < self.todo_items.index( - next(item for item in self.todo_items if item.uid == uid) - ): + pos = tasks_order.index(UUID(previous_uid)) + if pos < tasks_order.index(UUID(uid)): pos += 1 + else: pos = 0 try: - await self.coordinator.habitica.reorder_task(UUID(uid), pos) + tasks_order[:] = ( + await self.coordinator.habitica.reorder_task(UUID(uid), pos) + ).data except TooManyRequestsError as e: raise HomeAssistantError( translation_domain=DOMAIN, @@ -144,20 +148,6 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): translation_key=f"move_{self.entity_description.key}_item_failed", translation_placeholders={"pos": str(pos)}, ) from e - else: - # move tasks in the coordinator until we have fresh data - tasks = self.coordinator.data.tasks - new_pos = ( - tasks.index( - next(task for task in tasks if task.id == UUID(previous_uid)) - ) - + 1 - if previous_uid - else 0 - ) - old_pos = tasks.index(next(task for task in tasks if task.id == UUID(uid))) - tasks.insert(new_pos, tasks.pop(old_pos)) - await self.coordinator.async_request_refresh() async def async_update_todo_item(self, item: TodoItem) -> None: """Update a Habitica todo.""" @@ -271,7 +261,7 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity): def todo_items(self) -> list[TodoItem]: """Return the todo items.""" - return [ + tasks = [ *( TodoItem( uid=str(task.id), @@ -288,6 +278,15 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity): if task.Type is TaskType.TODO ), ] + return sorted( + tasks, + key=lambda task: ( + float("inf") + if (uid := UUID(task.uid)) + not in (tasks_order := self.coordinator.data.user.tasksOrder.todos) + else tasks_order.index(uid) + ), + ) async def async_create_todo_item(self, item: TodoItem) -> None: """Create a Habitica todo.""" @@ -348,7 +347,7 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity): if TYPE_CHECKING: assert self.coordinator.data.user.lastCron - return [ + tasks = [ *( TodoItem( uid=str(task.id), @@ -365,3 +364,12 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity): if task.Type is TaskType.DAILY ) ] + return sorted( + tasks, + key=lambda task: ( + float("inf") + if (uid := UUID(task.uid)) + not in (tasks_order := self.coordinator.data.user.tasksOrder.dailys) + else tasks_order.index(uid) + ), + ) diff --git a/tests/components/habitica/fixtures/reorder_dailies_response.json b/tests/components/habitica/fixtures/reorder_dailies_response.json new file mode 100644 index 00000000000..3ad38ae9c2f --- /dev/null +++ b/tests/components/habitica/fixtures/reorder_dailies_response.json @@ -0,0 +1,15 @@ +{ + "success": true, + "data": [ + "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a", + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", + "bc1d1855-b2b8-4663-98ff-62e7b763dfc4", + "e97659e0-2c42-4599-a7bb-00282adc410d", + "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/reorder_todos_response.json b/tests/components/habitica/fixtures/reorder_todos_response.json new file mode 100644 index 00000000000..ba8118aa1da --- /dev/null +++ b/tests/components/habitica/fixtures/reorder_todos_response.json @@ -0,0 +1,12 @@ +{ + "success": true, + "data": [ + "88de7cd9-af2b-49ce-9afd-bf941d87336b", + "1aa3137e-ef72-4d1f-91ee-41933602f438", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "86ea2475-d1b5-4020-bdcc-c188c7996afa" + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/test_todo.py b/tests/components/habitica/test_todo.py index 01c033fcf95..3457af78403 100644 --- a/tests/components/habitica/test_todo.py +++ b/tests/components/habitica/test_todo.py @@ -6,7 +6,13 @@ from typing import Any from unittest.mock import AsyncMock, patch from uuid import UUID -from habiticalib import Direction, HabiticaTasksResponse, Task, TaskType +from habiticalib import ( + Direction, + HabiticaTaskOrderResponse, + HabiticaTasksResponse, + Task, + TaskType, +) import pytest from syrupy.assertion import SnapshotAssertion @@ -601,19 +607,23 @@ async def test_delete_completed_todo_items_exception( @pytest.mark.parametrize( - ("entity_id", "uid", "second_pos", "third_pos"), + ("entity_id", "uid", "second_pos", "third_pos", "fixture", "task_type"), [ ( "todo.test_user_to_do_s", "1aa3137e-ef72-4d1f-91ee-41933602f438", "88de7cd9-af2b-49ce-9afd-bf941d87336b", "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "reorder_todos_response.json", + "todos", ), ( "todo.test_user_dailies", "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", - "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", - "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a", + "bc1d1855-b2b8-4663-98ff-62e7b763dfc4", + "reorder_dailies_response.json", + "dailys", ), ], ids=["todo", "daily"], @@ -627,9 +637,14 @@ async def test_move_todo_item( uid: str, second_pos: str, third_pos: str, + fixture: str, + task_type: str, ) -> None: """Test move todo items.""" - + reorder_response = HabiticaTaskOrderResponse.from_json( + load_fixture(fixture, DOMAIN) + ) + habitica.reorder_task.return_value = reorder_response config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -650,6 +665,7 @@ async def test_move_todo_item( assert resp.get("success") habitica.reorder_task.assert_awaited_once_with(UUID(uid), 1) + habitica.reorder_task.reset_mock() # move down to third position @@ -665,6 +681,7 @@ async def test_move_todo_item( assert resp.get("success") habitica.reorder_task.assert_awaited_once_with(UUID(uid), 2) + habitica.reorder_task.reset_mock() # move to top position @@ -679,6 +696,10 @@ async def test_move_todo_item( assert resp.get("success") habitica.reorder_task.assert_awaited_once_with(UUID(uid), 0) + assert ( + getattr(config_entry.runtime_data.data.user.tasksOrder, task_type) + == reorder_response.data + ) @pytest.mark.parametrize( From ec3f5561dc79331a4acbef20f8a858480a0b587e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 24 Feb 2025 18:00:48 +0100 Subject: [PATCH 2690/2987] Add WebDAV backup agent (#137721) * Add WebDAV backup agent * Process code review * Increase timeout for large uploads * Make metadata file based * Update IQS * Grammar * Move to aiowebdav2 * Update helper text * Add decorator to handle backup errors * Bump version * Missed one * Add unauth handling * Apply suggestions from code review Co-authored-by: Josef Zweck * Update homeassistant/components/webdav/__init__.py * Update homeassistant/components/webdav/config_flow.py * Remove timeout Co-authored-by: Josef Zweck * remove unique_id * Add tests * Add missing tests * Bump version * Remove dropbox * Process code review * Bump version to relax pinned dependencies * Process code review * Add translatable exceptions * Process code review * Process code review --------- Co-authored-by: Josef Zweck --- CODEOWNERS | 2 + homeassistant/components/webdav/__init__.py | 70 ++++ homeassistant/components/webdav/backup.py | 273 +++++++++++++++ .../components/webdav/config_flow.py | 90 +++++ homeassistant/components/webdav/const.py | 13 + homeassistant/components/webdav/helpers.py | 38 +++ homeassistant/components/webdav/manifest.json | 12 + .../components/webdav/quality_scale.yaml | 145 ++++++++ homeassistant/components/webdav/strings.json | 41 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/webdav/__init__.py | 1 + tests/components/webdav/conftest.py | 80 +++++ tests/components/webdav/const.py | 52 +++ tests/components/webdav/test_backup.py | 323 ++++++++++++++++++ tests/components/webdav/test_config_flow.py | 149 ++++++++ 18 files changed, 1302 insertions(+) create mode 100644 homeassistant/components/webdav/__init__.py create mode 100644 homeassistant/components/webdav/backup.py create mode 100644 homeassistant/components/webdav/config_flow.py create mode 100644 homeassistant/components/webdav/const.py create mode 100644 homeassistant/components/webdav/helpers.py create mode 100644 homeassistant/components/webdav/manifest.json create mode 100644 homeassistant/components/webdav/quality_scale.yaml create mode 100644 homeassistant/components/webdav/strings.json create mode 100644 tests/components/webdav/__init__.py create mode 100644 tests/components/webdav/conftest.py create mode 100644 tests/components/webdav/const.py create mode 100644 tests/components/webdav/test_backup.py create mode 100644 tests/components/webdav/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 61b2eb5b557..bb8545c46b7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1695,6 +1695,8 @@ build.json @home-assistant/supervisor /tests/components/weatherflow_cloud/ @jeeftor /homeassistant/components/weatherkit/ @tjhorner /tests/components/weatherkit/ @tjhorner +/homeassistant/components/webdav/ @jpbede +/tests/components/webdav/ @jpbede /homeassistant/components/webhook/ @home-assistant/core /tests/components/webhook/ @home-assistant/core /homeassistant/components/webmin/ @autinerd diff --git a/homeassistant/components/webdav/__init__.py b/homeassistant/components/webdav/__init__.py new file mode 100644 index 00000000000..952a68d829f --- /dev/null +++ b/homeassistant/components/webdav/__init__.py @@ -0,0 +1,70 @@ +"""The WebDAV integration.""" + +from __future__ import annotations + +import logging + +from aiowebdav2.client import Client +from aiowebdav2.exceptions import UnauthorizedError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from .helpers import async_create_client, async_ensure_path_exists + +type WebDavConfigEntry = ConfigEntry[Client] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bool: + """Set up WebDAV from a config entry.""" + client = async_create_client( + hass=hass, + url=entry.data[CONF_URL], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + verify_ssl=entry.data.get(CONF_VERIFY_SSL, True), + ) + + try: + result = await client.check() + except UnauthorizedError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_username_password", + ) from err + + # Check if we can connect to the WebDAV server + # and access the root directory + if not result: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) + + # Ensure the backup directory exists + if not await async_ensure_path_exists( + client, entry.data.get(CONF_BACKUP_PATH, "/") + ): + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_access_or_create_backup_path", + ) + + entry.runtime_data = client + + def async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bool: + """Unload a WebDAV config entry.""" + return True diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py new file mode 100644 index 00000000000..2c19ca450e3 --- /dev/null +++ b/homeassistant/components/webdav/backup.py @@ -0,0 +1,273 @@ +"""Support for WebDAV backup.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +from functools import wraps +import logging +from typing import Any, Concatenate + +from aiohttp import ClientTimeout +from aiowebdav2 import Property, PropertyRequest +from aiowebdav2.exceptions import UnauthorizedError, WebDavError +from propcache.api import cached_property + +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, + suggested_filename, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.json import json_dumps +from homeassistant.util.json import json_loads_object + +from . import WebDavConfigEntry +from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +METADATA_VERSION = "1" +BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200) + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries: list[WebDavConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) + return [WebDavBackupAgent(hass, entry) for entry in entries] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed. + + :return: A function to unregister the listener. + """ + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +def handle_backup_errors[_R, **P]( + func: Callable[Concatenate[WebDavBackupAgent, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[WebDavBackupAgent, P], Coroutine[Any, Any, _R]]: + """Handle backup errors.""" + + @wraps(func) + async def wrapper(self: WebDavBackupAgent, *args: P.args, **kwargs: P.kwargs) -> _R: + try: + return await func(self, *args, **kwargs) + except UnauthorizedError as err: + raise BackupAgentError("Authentication error") from err + except WebDavError as err: + _LOGGER.debug("Full error: %s", err, exc_info=True) + raise BackupAgentError( + f"Backup operation failed: {err}", + ) from err + except TimeoutError as err: + _LOGGER.error( + "Error during backup in %s: Timeout", + func.__name__, + ) + raise BackupAgentError("Backup operation timed out") from err + + return wrapper + + +def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: + """Return the suggested filenames for the backup and metadata.""" + base_name = suggested_filename(backup).rsplit(".", 1)[0] + return f"{base_name}.tar", f"{base_name}.metadata.json" + + +class WebDavBackupAgent(BackupAgent): + """Backup agent interface.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: WebDavConfigEntry) -> None: + """Initialize the WebDAV backup agent.""" + super().__init__() + self._hass = hass + self._entry = entry + self._client = entry.runtime_data + self.name = entry.title + self.unique_id = entry.entry_id + + @cached_property + def _backup_path(self) -> str: + """Return the path to the backup.""" + return self._entry.data.get(CONF_BACKUP_PATH, "") + + @handle_backup_errors + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: An async iterator that yields bytes. + """ + backup = await self._find_backup_by_id(backup_id) + if backup is None: + raise BackupNotFound("Backup not found") + + return await self._client.download_iter( + f"{self._backup_path}/{suggested_filename(backup)}", + timeout=BACKUP_TIMEOUT, + ) + + @handle_backup_errors + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup. + + :param open_stream: A function returning an async iterator that yields bytes. + :param backup: Metadata about the backup that should be uploaded. + """ + (filename_tar, filename_meta) = suggested_filenames(backup) + + await self._client.upload_iter( + await open_stream(), + f"{self._backup_path}/{filename_tar}", + timeout=BACKUP_TIMEOUT, + ) + + _LOGGER.debug( + "Uploaded backup to %s", + f"{self._backup_path}/{filename_tar}", + ) + + await self._client.upload_iter( + json_dumps(backup.as_dict()), + f"{self._backup_path}/{filename_meta}", + ) + + await self._client.set_property_batch( + f"{self._backup_path}/{filename_meta}", + [ + Property( + namespace="homeassistant", + name="backup_id", + value=backup.backup_id, + ), + Property( + namespace="homeassistant", + name="metadata_version", + value=METADATA_VERSION, + ), + ], + ) + + _LOGGER.debug( + "Uploaded metadata file for %s", + f"{self._backup_path}/{filename_meta}", + ) + + @handle_backup_errors + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + """ + backup = await self._find_backup_by_id(backup_id) + if backup is None: + return + + (filename_tar, filename_meta) = suggested_filenames(backup) + backup_path = f"{self._backup_path}/{filename_tar}" + + await self._client.clean(backup_path) + await self._client.clean(f"{self._backup_path}/{filename_meta}") + + _LOGGER.debug( + "Deleted backup at %s", + backup_path, + ) + + @handle_backup_errors + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + metadata_files = await self._list_metadata_files() + return [ + await self._download_metadata(metadata_file) + for metadata_file in metadata_files + ] + + @handle_backup_errors + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + return await self._find_backup_by_id(backup_id) + + async def _list_metadata_files(self) -> list[str]: + """List metadata files.""" + files = await self._client.list_with_infos(self._backup_path) + return [ + file["path"] + for file in files + if file["path"].endswith(".json") + and await self._is_current_metadata_version(file["path"]) + ] + + async def _is_current_metadata_version(self, path: str) -> bool: + """Check if is current metadata version.""" + metadata_version = await self._client.get_property( + path, + PropertyRequest( + namespace="homeassistant", + name="metadata_version", + ), + ) + return metadata_version.value == METADATA_VERSION if metadata_version else False + + async def _find_backup_by_id(self, backup_id: str) -> AgentBackup | None: + """Find a backup by its backup ID on remote.""" + metadata_files = await self._list_metadata_files() + for metadata_file in metadata_files: + remote_backup_id = await self._client.get_property( + metadata_file, + PropertyRequest( + namespace="homeassistant", + name="backup_id", + ), + ) + if remote_backup_id and remote_backup_id.value == backup_id: + return await self._download_metadata(metadata_file) + + return None + + async def _download_metadata(self, path: str) -> AgentBackup: + """Download metadata file.""" + iterator = await self._client.download_iter(path) + metadata = await anext(iterator) + return AgentBackup.from_dict(json_loads_object(metadata)) diff --git a/homeassistant/components/webdav/config_flow.py b/homeassistant/components/webdav/config_flow.py new file mode 100644 index 00000000000..f75544d25ad --- /dev/null +++ b/homeassistant/components/webdav/config_flow.py @@ -0,0 +1,90 @@ +"""Config flow for the WebDAV integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from aiowebdav2.exceptions import UnauthorizedError +import voluptuous as vol +import yarl + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_BACKUP_PATH, DOMAIN +from .helpers import async_create_client + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + ) + ), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + ) + ), + vol.Optional(CONF_BACKUP_PATH, default="/"): str, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + } +) + + +class WebDavConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for WebDAV.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + client = async_create_client( + hass=self.hass, + url=user_input[CONF_URL], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + verify_ssl=user_input.get(CONF_VERIFY_SSL, True), + ) + + # Check if we can connect to the WebDAV server + # .check() already does the most of the error handling and will return True + # if we can access the root directory + try: + result = await client.check() + except UnauthorizedError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + if result: + self._async_abort_entries_match( + { + CONF_URL: user_input[CONF_URL], + CONF_USERNAME: user_input[CONF_USERNAME], + } + ) + + parsed_url = yarl.URL(user_input[CONF_URL]) + return self.async_create_entry( + title=f"{user_input[CONF_USERNAME]}@{parsed_url.host}", + data=user_input, + ) + + errors["base"] = "cannot_connect" + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/webdav/const.py b/homeassistant/components/webdav/const.py new file mode 100644 index 00000000000..faf8ce77ca5 --- /dev/null +++ b/homeassistant/components/webdav/const.py @@ -0,0 +1,13 @@ +"""Constants for the WebDAV integration.""" + +from collections.abc import Callable + +from homeassistant.util.hass_dict import HassKey + +DOMAIN = "webdav" + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) + +CONF_BACKUP_PATH = "backup_path" diff --git a/homeassistant/components/webdav/helpers.py b/homeassistant/components/webdav/helpers.py new file mode 100644 index 00000000000..9f91ed3bdb3 --- /dev/null +++ b/homeassistant/components/webdav/helpers.py @@ -0,0 +1,38 @@ +"""Helper functions for the WebDAV component.""" + +from aiowebdav2.client import Client, ClientOptions + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession + + +@callback +def async_create_client( + *, + hass: HomeAssistant, + url: str, + username: str, + password: str, + verify_ssl: bool = False, +) -> Client: + """Create a WebDAV client.""" + return Client( + url=url, + username=username, + password=password, + options=ClientOptions( + verify_ssl=verify_ssl, + session=async_get_clientsession(hass), + ), + ) + + +async def async_ensure_path_exists(client: Client, path: str) -> bool: + """Ensure that a path exists recursively on the WebDAV server.""" + parts = path.strip("/").split("/") + for i in range(1, len(parts) + 1): + sub_path = "/".join(parts[:i]) + if not await client.check(sub_path) and not await client.mkdir(sub_path): + return False + + return True diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json new file mode 100644 index 00000000000..a1ac779afc8 --- /dev/null +++ b/homeassistant/components/webdav/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "webdav", + "name": "WebDAV", + "codeowners": ["@jpbede"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/webdav", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["aiowebdav2"], + "quality_scale": "bronze", + "requirements": ["aiowebdav2==0.2.2"] +} diff --git a/homeassistant/components/webdav/quality_scale.yaml b/homeassistant/components/webdav/quality_scale.yaml new file mode 100644 index 00000000000..560626fda7e --- /dev/null +++ b/homeassistant/components/webdav/quality_scale.yaml @@ -0,0 +1,145 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + 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 have any custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + 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. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + No Options flow. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: | + This integration does not have entities. + 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. + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: + status: exempt + comment: | + This integration connects to a single service. + diagnostics: + status: exempt + comment: | + There is no data to diagnose. + discovery-update-info: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + discovery: + 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-known-limitations: + status: done + comment: | + No known limitations. + 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-use-cases: todo + 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. + exception-translations: done + icon-translations: + status: exempt + comment: | + This integration does not have entities. + reconfiguration-flow: + status: exempt + comment: | + Nothing to reconfigure. + repair-issues: todo + stale-devices: + status: exempt + comment: | + This integration connects to a single service. + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/webdav/strings.json b/homeassistant/components/webdav/strings.json new file mode 100644 index 00000000000..57117cdd9de --- /dev/null +++ b/homeassistant/components/webdav/strings.json @@ -0,0 +1,41 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "backup_path": "Backup path", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "url": "The URL of the WebDAV server. Check with your provider for the correct URL.", + "username": "The username for the WebDAV server.", + "password": "The password for the WebDAV server.", + "backup_path": "Define the path where the backups should be located (will be created automatically if it does not exist).", + "verify_ssl": "Whether to verify the SSL certificate of the server. If you are using a self-signed certificate, do not select this option." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "exceptions": { + "invalid_username_password": { + "message": "Invalid username or password" + }, + "cannot_connect": { + "message": "Cannot connect to WebDAV server" + }, + "cannot_access_or_create_backup_path": { + "message": "Cannot access or create backup path. Please check the path and permissions." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c92235aae47..de581c65297 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -692,6 +692,7 @@ FLOWS = { "weatherflow", "weatherflow_cloud", "weatherkit", + "webdav", "webmin", "webostv", "weheat", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6f4315c43dc..41083ee8e8c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7092,6 +7092,12 @@ } } }, + "webdav": { + "name": "WebDAV", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "webmin": { "name": "Webmin", "integration_type": "device", diff --git a/requirements_all.txt b/requirements_all.txt index 1ce88e0f55d..87dd9bb204e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -421,6 +421,9 @@ aiowaqi==3.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 +# homeassistant.components.webdav +aiowebdav2==0.2.2 + # homeassistant.components.webostv aiowebostv==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6588b06c41..f55ea287d37 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -403,6 +403,9 @@ aiowaqi==3.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 +# homeassistant.components.webdav +aiowebdav2==0.2.2 + # homeassistant.components.webostv aiowebostv==0.7.0 diff --git a/tests/components/webdav/__init__.py b/tests/components/webdav/__init__.py new file mode 100644 index 00000000000..33e0222fb34 --- /dev/null +++ b/tests/components/webdav/__init__.py @@ -0,0 +1 @@ +"""Tests for the WebDAV integration.""" diff --git a/tests/components/webdav/conftest.py b/tests/components/webdav/conftest.py new file mode 100644 index 00000000000..ccd3437aaa0 --- /dev/null +++ b/tests/components/webdav/conftest.py @@ -0,0 +1,80 @@ +"""Common fixtures for the WebDAV tests.""" + +from collections.abc import AsyncIterator, Generator +from json import dumps +from unittest.mock import AsyncMock, patch + +from aiowebdav2 import Property, PropertyRequest +import pytest + +from homeassistant.components.webdav.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME + +from .const import ( + BACKUP_METADATA, + MOCK_GET_PROPERTY_BACKUP_ID, + MOCK_GET_PROPERTY_METADATA_VERSION, + MOCK_LIST_WITH_INFOS, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.webdav.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + + +def _get_property(path: str, request: PropertyRequest) -> Property: + """Return the property of a file.""" + if path.endswith(".json") and request.name == "metadata_version": + return MOCK_GET_PROPERTY_METADATA_VERSION + + return MOCK_GET_PROPERTY_BACKUP_ID + + +async def _download_mock(path: str, timeout=None) -> AsyncIterator[bytes]: + """Mock the download function.""" + if path.endswith(".json"): + yield dumps(BACKUP_METADATA).encode() + + yield b"backup data" + + +@pytest.fixture(name="webdav_client") +def mock_webdav_client() -> Generator[AsyncMock]: + """Mock the aiowebdav client.""" + with ( + patch( + "homeassistant.components.webdav.helpers.Client", + autospec=True, + ) as mock_webdav_client, + ): + mock = mock_webdav_client.return_value + mock.check.return_value = True + mock.mkdir.return_value = True + mock.list_with_infos.return_value = MOCK_LIST_WITH_INFOS + mock.download_iter.side_effect = _download_mock + mock.upload_iter.return_value = None + mock.clean.return_value = None + mock.get_property.side_effect = _get_property + yield mock diff --git a/tests/components/webdav/const.py b/tests/components/webdav/const.py new file mode 100644 index 00000000000..777008b07a5 --- /dev/null +++ b/tests/components/webdav/const.py @@ -0,0 +1,52 @@ +"""Constants for WebDAV tests.""" + +from aiowebdav2 import Property + +BACKUP_METADATA = { + "addons": [], + "backup_id": "23e64aec", + "date": "2025-02-10T17:47:22.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.1", + "name": "Automatic backup 2025.2.1", + "protected": False, + "size": 34519040, +} + +MOCK_LIST_WITH_INFOS = [ + { + "content_type": "application/x-tar", + "created": "2025-02-10T17:47:22Z", + "etag": '"84d7d000-62dcd4ce886b4"', + "isdir": "False", + "modified": "Mon, 10 Feb 2025 17:47:22 GMT", + "name": "None", + "path": "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar", + "size": "2228736000", + }, + { + "content_type": "application/json", + "created": "2025-02-10T17:47:22Z", + "etag": '"8d0-62dcd4cec050a"', + "isdir": "False", + "modified": "Mon, 10 Feb 2025 17:47:22 GMT", + "name": "None", + "path": "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json", + "size": "2256", + }, +] + +MOCK_GET_PROPERTY_METADATA_VERSION = Property( + namespace="homeassistant", + name="metadata_version", + value="1", +) + +MOCK_GET_PROPERTY_BACKUP_ID = Property( + namespace="homeassistant", + name="backup_id", + value="23e64aec", +) diff --git a/tests/components/webdav/test_backup.py b/tests/components/webdav/test_backup.py new file mode 100644 index 00000000000..b02fb2e9628 --- /dev/null +++ b/tests/components/webdav/test_backup.py @@ -0,0 +1,323 @@ +"""Test the backups for WebDAV.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from io import StringIO +from unittest.mock import Mock, patch + +from aiowebdav2.exceptions import UnauthorizedError, WebDavError +import pytest + +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup +from homeassistant.components.webdav.backup import async_register_backup_agents_listener +from homeassistant.components.webdav.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import BACKUP_METADATA, MOCK_LIST_WITH_INFOS + +from tests.common import AsyncMock, MockConfigEntry +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_backup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, webdav_client: AsyncMock +) -> AsyncGenerator[None]: + """Set up webdav integration.""" + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + yield + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + { + "agent_id": f"{DOMAIN}.{mock_config_entry.entry_id}", + "name": mock_config_entry.title, + }, + ], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent list backups.""" + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": [], + "agents": { + "webdav.01JKXV07ASC62D620DGYNG2R8H": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2025-02-10T17:47:22.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.1", + "name": "Automatic backup 2025.2.1", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + ] + + +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent get backup.""" + + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == { + "addons": [], + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2025-02-10T17:47:22.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.1", + "name": "Automatic backup 2025.2.1", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + assert webdav_client.clean.call_count == 2 + + +async def test_agents_upload( + hass_client: ClientSessionGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + 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=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert webdav_client.upload_iter.call_count == 2 + assert webdav_client.set_property_batch.call_count == 1 + + +async def test_agents_download( + hass_client: ClientSessionGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + +async def test_error_on_agents_download( + hass_client: ClientSessionGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we get not found on a not existing backup on download.""" + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + webdav_client.list_with_infos.side_effect = [MOCK_LIST_WITH_INFOS, []] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 404 + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + ( + WebDavError("Unknown path"), + "Backup operation failed: Unknown path", + ), + (TimeoutError(), "Backup operation timed out"), + ], +) +async def test_delete_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test error during delete.""" + webdav_client.clean.side_effect = side_effect + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {f"{DOMAIN}.{mock_config_entry.entry_id}": error} + } + + +async def test_agents_delete_not_found_does_not_throw( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, +) -> None: + """Test agent delete backup.""" + webdav_client.list_with_infos.return_value = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + +async def test_agents_backup_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, +) -> None: + """Test backup not found.""" + webdav_client.list_with_infos.return_value = [] + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backup"] is None + + +async def test_raises_on_403( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we raise on 403.""" + webdav_client.list_with_infos.side_effect = UnauthorizedError( + "https://webdav.example.com" + ) + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == { + f"{DOMAIN}.{mock_config_entry.entry_id}": "Authentication error" + } + + +async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: + """Test listener gets cleaned up.""" + listener = AsyncMock() + 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 diff --git a/tests/components/webdav/test_config_flow.py b/tests/components/webdav/test_config_flow.py new file mode 100644 index 00000000000..eb887edb1a1 --- /dev/null +++ b/tests/components/webdav/test_config_flow.py @@ -0,0 +1,149 @@ +"""Test the WebDAV config flow.""" + +from unittest.mock import AsyncMock + +from aiowebdav2.exceptions import UnauthorizedError +import pytest + +from homeassistant import config_entries +from homeassistant.components.webdav.const import CONF_BACKUP_PATH, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_form(hass: HomeAssistant, webdav_client: AsyncMock) -> None: + """Test we get the form and create a entry on success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/backups", + CONF_VERIFY_SSL: False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "user@webdav.demo" + assert result["data"] == { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/backups", + CONF_VERIFY_SSL: False, + } + assert len(webdav_client.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_form_fail(hass: HomeAssistant, webdav_client: AsyncMock) -> None: + """Test to handle exceptions.""" + webdav_client.check.return_value = False + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # reset and test for success + webdav_client.check.return_value = True + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "user@webdav.demo" + assert "errors" not in result + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (UnauthorizedError("https://webdav.demo"), "invalid_auth"), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_form_unauthorized( + hass: HomeAssistant, + webdav_client: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test to handle unauthorized.""" + webdav_client.check.side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + # reset and test for success + webdav_client.check.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "user@webdav.demo" + assert "errors" not in result + + +async def test_duplicate_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, webdav_client: AsyncMock +) -> None: + """Test we get the form and create a entry on success.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" From 60479369b6266f924c5d7b1ff10b13394cdf5584 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Mon, 24 Feb 2025 18:02:18 +0100 Subject: [PATCH 2691/2987] Remove name in Minecraft Server config entry (#139113) * Remove CONF_NAME in config entry * Revert config entry version from 4 back to 3 * Add data_description for address in strings.json * Use config entry title as coordinator name * Use constant as mock config entry title --- .../minecraft_server/config_flow.py | 8 +- .../components/minecraft_server/const.py | 2 - .../minecraft_server/coordinator.py | 4 +- .../minecraft_server/diagnostics.py | 4 +- .../minecraft_server/quality_scale.yaml | 4 +- .../components/minecraft_server/strings.json | 10 +- tests/components/minecraft_server/conftest.py | 8 +- .../snapshots/test_binary_sensor.ambr | 16 +-- .../snapshots/test_diagnostics.ambr | 2 - .../snapshots/test_sensor.ambr | 120 +++++++++--------- .../minecraft_server/test_binary_sensor.py | 11 +- .../minecraft_server/test_config_flow.py | 8 +- .../components/minecraft_server/test_init.py | 4 +- .../minecraft_server/test_sensor.py | 40 +++--- 14 files changed, 118 insertions(+), 123 deletions(-) diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index 3ffdc33f3b2..d0f7cf5a8fb 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -8,10 +8,10 @@ from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_ADDRESS, CONF_TYPE from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType -from .const import DEFAULT_NAME, DOMAIN +from .const import DOMAIN DEFAULT_ADDRESS = "localhost:25565" @@ -37,7 +37,6 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): # Prepare config entry data. config_data = { - CONF_NAME: user_input[CONF_NAME], CONF_ADDRESS: address, } @@ -78,9 +77,6 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { - vol.Required( - CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) - ): str, vol.Required( CONF_ADDRESS, default=user_input.get(CONF_ADDRESS, DEFAULT_ADDRESS), diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py index e7a58741696..35a1c0dd5a5 100644 --- a/homeassistant/components/minecraft_server/const.py +++ b/homeassistant/components/minecraft_server/const.py @@ -1,7 +1,5 @@ """Constants for the Minecraft Server integration.""" -DEFAULT_NAME = "Minecraft Server" - DOMAIN = "minecraft_server" KEY_LATENCY = "latency" diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index 2cd1c1a94ab..457b0700535 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_ADDRESS, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -42,7 +42,7 @@ class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): super().__init__( hass=hass, - name=config_entry.data[CONF_NAME], + name=config_entry.title, config_entry=config_entry, logger=_LOGGER, update_interval=SCAN_INTERVAL, diff --git a/homeassistant/components/minecraft_server/diagnostics.py b/homeassistant/components/minecraft_server/diagnostics.py index 61a65f9c2dd..dd94411b969 100644 --- a/homeassistant/components/minecraft_server/diagnostics.py +++ b/homeassistant/components/minecraft_server/diagnostics.py @@ -5,12 +5,12 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_ADDRESS, CONF_NAME +from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from .coordinator import MinecraftServerConfigEntry -TO_REDACT: Iterable[Any] = {CONF_ADDRESS, CONF_NAME, "players_list"} +TO_REDACT: Iterable[Any] = {CONF_ADDRESS, "players_list"} async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml index eeda413f2ad..a866969fc33 100644 --- a/homeassistant/components/minecraft_server/quality_scale.yaml +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -6,9 +6,7 @@ rules: appropriate-polling: done brands: done common-modules: done - config-flow: - status: todo - comment: Check removal and replacement of name in config flow with the title (server address). + config-flow: done config-flow-test-coverage: status: todo comment: | diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json index c084c9e6df0..cb4670dcac4 100644 --- a/homeassistant/components/minecraft_server/strings.json +++ b/homeassistant/components/minecraft_server/strings.json @@ -2,12 +2,14 @@ "config": { "step": { "user": { - "title": "Link your Minecraft Server", - "description": "Set up your Minecraft Server instance to allow monitoring.", "data": { - "name": "[%key:common::config_flow::data::name%]", "address": "Server address" - } + }, + "data_description": { + "address": "The hostname, IP address or SRV record of your Minecraft server, optionally including the port." + }, + "title": "Link your Minecraft Server", + "description": "Set up your Minecraft Server instance to allow monitoring." } }, "abort": { diff --git a/tests/components/minecraft_server/conftest.py b/tests/components/minecraft_server/conftest.py index d34db5114cc..67b8bd17b3a 100644 --- a/tests/components/minecraft_server/conftest.py +++ b/tests/components/minecraft_server/conftest.py @@ -3,8 +3,8 @@ import pytest from homeassistant.components.minecraft_server.api import MinecraftServerType -from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE +from homeassistant.components.minecraft_server.const import DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_TYPE from .const import TEST_ADDRESS, TEST_CONFIG_ENTRY_ID @@ -18,8 +18,8 @@ def java_mock_config_entry() -> MockConfigEntry: domain=DOMAIN, unique_id=None, entry_id=TEST_CONFIG_ENTRY_ID, + title=TEST_ADDRESS, data={ - CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, CONF_TYPE: MinecraftServerType.JAVA_EDITION, }, @@ -34,8 +34,8 @@ def bedrock_mock_config_entry() -> MockConfigEntry: domain=DOMAIN, unique_id=None, entry_id=TEST_CONFIG_ENTRY_ID, + title=TEST_ADDRESS, data={ - CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, CONF_TYPE: MinecraftServerType.BEDROCK_EDITION, }, diff --git a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr index 2e4bf49089c..c93a87d70d8 100644 --- a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr +++ b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr @@ -3,10 +3,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', - 'friendly_name': 'Minecraft Server Status', + 'friendly_name': 'mc.dummyserver.com:25566 Status', }), 'context': , - 'entity_id': 'binary_sensor.minecraft_server_status', + 'entity_id': 'binary_sensor.mc_dummyserver_com_25566_status', 'last_changed': , 'last_reported': , 'last_updated': , @@ -17,10 +17,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', - 'friendly_name': 'Minecraft Server Status', + 'friendly_name': 'mc.dummyserver.com:25566 Status', }), 'context': , - 'entity_id': 'binary_sensor.minecraft_server_status', + 'entity_id': 'binary_sensor.mc_dummyserver_com_25566_status', 'last_changed': , 'last_reported': , 'last_updated': , @@ -31,10 +31,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', - 'friendly_name': 'Minecraft Server Status', + 'friendly_name': 'mc.dummyserver.com:25566 Status', }), 'context': , - 'entity_id': 'binary_sensor.minecraft_server_status', + 'entity_id': 'binary_sensor.mc_dummyserver_com_25566_status', 'last_changed': , 'last_reported': , 'last_updated': , @@ -45,10 +45,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', - 'friendly_name': 'Minecraft Server Status', + 'friendly_name': 'mc.dummyserver.com:25566 Status', }), 'context': , - 'entity_id': 'binary_sensor.minecraft_server_status', + 'entity_id': 'binary_sensor.mc_dummyserver_com_25566_status', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/minecraft_server/snapshots/test_diagnostics.ambr b/tests/components/minecraft_server/snapshots/test_diagnostics.ambr index 72d79795c6a..b722f4122f3 100644 --- a/tests/components/minecraft_server/snapshots/test_diagnostics.ambr +++ b/tests/components/minecraft_server/snapshots/test_diagnostics.ambr @@ -8,7 +8,6 @@ }), 'config_entry_data': dict({ 'address': '**REDACTED**', - 'name': '**REDACTED**', 'type': 'Bedrock Edition', }), 'config_entry_options': dict({ @@ -36,7 +35,6 @@ }), 'config_entry_data': dict({ 'address': '**REDACTED**', - 'name': '**REDACTED**', 'type': 'Java Edition', }), 'config_entry_options': dict({ diff --git a/tests/components/minecraft_server/snapshots/test_sensor.ambr b/tests/components/minecraft_server/snapshots/test_sensor.ambr index 47d638adf79..d2b044c06f5 100644 --- a/tests/components/minecraft_server/snapshots/test_sensor.ambr +++ b/tests/components/minecraft_server/snapshots/test_sensor.ambr @@ -2,11 +2,11 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Latency', + 'friendly_name': 'mc.dummyserver.com:25566 Latency', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.minecraft_server_latency', + 'entity_id': 'sensor.mc_dummyserver_com_25566_latency', 'last_changed': , 'last_reported': , 'last_updated': , @@ -16,11 +16,11 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players online', + 'friendly_name': 'mc.dummyserver.com:25566 Players online', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_online', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_online', 'last_changed': , 'last_reported': , 'last_updated': , @@ -30,11 +30,11 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].2 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players max', + 'friendly_name': 'mc.dummyserver.com:25566 Players max', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_max', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_max', 'last_changed': , 'last_reported': , 'last_updated': , @@ -44,10 +44,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].3 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server World message', + 'friendly_name': 'mc.dummyserver.com:25566 World message', }), 'context': , - 'entity_id': 'sensor.minecraft_server_world_message', + 'entity_id': 'sensor.mc_dummyserver_com_25566_world_message', 'last_changed': , 'last_reported': , 'last_updated': , @@ -57,10 +57,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].4 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Version', + 'friendly_name': 'mc.dummyserver.com:25566 Version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -70,10 +70,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].5 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Protocol version', + 'friendly_name': 'mc.dummyserver.com:25566 Protocol version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_protocol_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_protocol_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -83,10 +83,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].6 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Map name', + 'friendly_name': 'mc.dummyserver.com:25566 Map name', }), 'context': , - 'entity_id': 'sensor.minecraft_server_map_name', + 'entity_id': 'sensor.mc_dummyserver_com_25566_map_name', 'last_changed': , 'last_reported': , 'last_updated': , @@ -96,10 +96,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].7 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Game mode', + 'friendly_name': 'mc.dummyserver.com:25566 Game mode', }), 'context': , - 'entity_id': 'sensor.minecraft_server_game_mode', + 'entity_id': 'sensor.mc_dummyserver_com_25566_game_mode', 'last_changed': , 'last_reported': , 'last_updated': , @@ -109,10 +109,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].8 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Edition', + 'friendly_name': 'mc.dummyserver.com:25566 Edition', }), 'context': , - 'entity_id': 'sensor.minecraft_server_edition', + 'entity_id': 'sensor.mc_dummyserver_com_25566_edition', 'last_changed': , 'last_reported': , 'last_updated': , @@ -122,11 +122,11 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Latency', + 'friendly_name': 'mc.dummyserver.com:25566 Latency', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.minecraft_server_latency', + 'entity_id': 'sensor.mc_dummyserver_com_25566_latency', 'last_changed': , 'last_reported': , 'last_updated': , @@ -136,7 +136,7 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players online', + 'friendly_name': 'mc.dummyserver.com:25566 Players online', 'players_list': list([ 'Player 1', 'Player 2', @@ -145,7 +145,7 @@ 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_online', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_online', 'last_changed': , 'last_reported': , 'last_updated': , @@ -155,11 +155,11 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].2 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players max', + 'friendly_name': 'mc.dummyserver.com:25566 Players max', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_max', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_max', 'last_changed': , 'last_reported': , 'last_updated': , @@ -169,10 +169,10 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].3 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server World message', + 'friendly_name': 'mc.dummyserver.com:25566 World message', }), 'context': , - 'entity_id': 'sensor.minecraft_server_world_message', + 'entity_id': 'sensor.mc_dummyserver_com_25566_world_message', 'last_changed': , 'last_reported': , 'last_updated': , @@ -182,10 +182,10 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].4 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Version', + 'friendly_name': 'mc.dummyserver.com:25566 Version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -195,10 +195,10 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].5 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Protocol version', + 'friendly_name': 'mc.dummyserver.com:25566 Protocol version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_protocol_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_protocol_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -208,11 +208,11 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Latency', + 'friendly_name': 'mc.dummyserver.com:25566 Latency', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.minecraft_server_latency', + 'entity_id': 'sensor.mc_dummyserver_com_25566_latency', 'last_changed': , 'last_reported': , 'last_updated': , @@ -222,11 +222,11 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players online', + 'friendly_name': 'mc.dummyserver.com:25566 Players online', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_online', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_online', 'last_changed': , 'last_reported': , 'last_updated': , @@ -236,11 +236,11 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].2 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players max', + 'friendly_name': 'mc.dummyserver.com:25566 Players max', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_max', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_max', 'last_changed': , 'last_reported': , 'last_updated': , @@ -250,10 +250,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].3 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server World message', + 'friendly_name': 'mc.dummyserver.com:25566 World message', }), 'context': , - 'entity_id': 'sensor.minecraft_server_world_message', + 'entity_id': 'sensor.mc_dummyserver_com_25566_world_message', 'last_changed': , 'last_reported': , 'last_updated': , @@ -263,10 +263,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].4 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Version', + 'friendly_name': 'mc.dummyserver.com:25566 Version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -276,10 +276,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].5 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Protocol version', + 'friendly_name': 'mc.dummyserver.com:25566 Protocol version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_protocol_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_protocol_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -289,10 +289,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].6 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Map name', + 'friendly_name': 'mc.dummyserver.com:25566 Map name', }), 'context': , - 'entity_id': 'sensor.minecraft_server_map_name', + 'entity_id': 'sensor.mc_dummyserver_com_25566_map_name', 'last_changed': , 'last_reported': , 'last_updated': , @@ -302,10 +302,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].7 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Game mode', + 'friendly_name': 'mc.dummyserver.com:25566 Game mode', }), 'context': , - 'entity_id': 'sensor.minecraft_server_game_mode', + 'entity_id': 'sensor.mc_dummyserver_com_25566_game_mode', 'last_changed': , 'last_reported': , 'last_updated': , @@ -315,10 +315,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].8 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Edition', + 'friendly_name': 'mc.dummyserver.com:25566 Edition', }), 'context': , - 'entity_id': 'sensor.minecraft_server_edition', + 'entity_id': 'sensor.mc_dummyserver_com_25566_edition', 'last_changed': , 'last_reported': , 'last_updated': , @@ -328,11 +328,11 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Latency', + 'friendly_name': 'mc.dummyserver.com:25566 Latency', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.minecraft_server_latency', + 'entity_id': 'sensor.mc_dummyserver_com_25566_latency', 'last_changed': , 'last_reported': , 'last_updated': , @@ -342,7 +342,7 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players online', + 'friendly_name': 'mc.dummyserver.com:25566 Players online', 'players_list': list([ 'Player 1', 'Player 2', @@ -351,7 +351,7 @@ 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_online', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_online', 'last_changed': , 'last_reported': , 'last_updated': , @@ -361,11 +361,11 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].2 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players max', + 'friendly_name': 'mc.dummyserver.com:25566 Players max', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_max', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_max', 'last_changed': , 'last_reported': , 'last_updated': , @@ -375,10 +375,10 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].3 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server World message', + 'friendly_name': 'mc.dummyserver.com:25566 World message', }), 'context': , - 'entity_id': 'sensor.minecraft_server_world_message', + 'entity_id': 'sensor.mc_dummyserver_com_25566_world_message', 'last_changed': , 'last_reported': , 'last_updated': , @@ -388,10 +388,10 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].4 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Version', + 'friendly_name': 'mc.dummyserver.com:25566 Version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -401,10 +401,10 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].5 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Protocol version', + 'friendly_name': 'mc.dummyserver.com:25566 Protocol version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_protocol_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_protocol_version', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/minecraft_server/test_binary_sensor.py b/tests/components/minecraft_server/test_binary_sensor.py index 6321c91d74a..77537a5e8e4 100644 --- a/tests/components/minecraft_server/test_binary_sensor.py +++ b/tests/components/minecraft_server/test_binary_sensor.py @@ -64,7 +64,9 @@ async def test_binary_sensor( ): assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.minecraft_server_status") == snapshot + assert ( + hass.states.get("binary_sensor.mc_dummyserver_com_25566_status") == snapshot + ) @pytest.mark.parametrize( @@ -113,7 +115,9 @@ async def test_binary_sensor_update( freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.minecraft_server_status") == snapshot + assert ( + hass.states.get("binary_sensor.mc_dummyserver_com_25566_status") == snapshot + ) @pytest.mark.parametrize( @@ -167,5 +171,6 @@ async def test_binary_sensor_update_failure( async_fire_time_changed(hass) await hass.async_block_till_done() assert ( - hass.states.get("binary_sensor.minecraft_server_status").state == STATE_OFF + hass.states.get("binary_sensor.mc_dummyserver_com_25566_status").state + == STATE_OFF ) diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 41817986bcf..00e25028249 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -5,9 +5,9 @@ from unittest.mock import patch from mcstatus import BedrockServer, JavaServer from homeassistant.components.minecraft_server.api import MinecraftServerType -from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN +from homeassistant.components.minecraft_server.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_ADDRESS, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -22,7 +22,6 @@ from .const import ( from tests.common import MockConfigEntry USER_INPUT = { - CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, } @@ -146,7 +145,6 @@ async def test_java_connection(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == USER_INPUT[CONF_ADDRESS] - assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] assert result["data"][CONF_ADDRESS] == TEST_ADDRESS assert result["data"][CONF_TYPE] == MinecraftServerType.JAVA_EDITION @@ -169,7 +167,6 @@ async def test_bedrock_connection(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == USER_INPUT[CONF_ADDRESS] - assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] assert result["data"][CONF_ADDRESS] == TEST_ADDRESS assert result["data"][CONF_TYPE] == MinecraftServerType.BEDROCK_EDITION @@ -207,6 +204,5 @@ async def test_recovery(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == USER_INPUT[CONF_ADDRESS] - assert result2["data"][CONF_NAME] == USER_INPUT[CONF_NAME] assert result2["data"][CONF_ADDRESS] == TEST_ADDRESS assert result2["data"][CONF_TYPE] == MinecraftServerType.BEDROCK_EDITION diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 6f7a49a190c..c00c5ec80cd 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -6,7 +6,7 @@ from mcstatus import JavaServer import pytest from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN +from homeassistant.components.minecraft_server.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_NAME, CONF_PORT @@ -23,6 +23,8 @@ from .const import ( from tests.common import MockConfigEntry +DEFAULT_NAME = "Minecraft Server" + TEST_UNIQUE_ID = f"{TEST_HOST}-{TEST_PORT}" SENSOR_KEYS = [ diff --git a/tests/components/minecraft_server/test_sensor.py b/tests/components/minecraft_server/test_sensor.py index ff62f8ddf36..a4cea239f7a 100644 --- a/tests/components/minecraft_server/test_sensor.py +++ b/tests/components/minecraft_server/test_sensor.py @@ -22,35 +22,35 @@ from .const import ( from tests.common import async_fire_time_changed JAVA_SENSOR_ENTITIES: list[str] = [ - "sensor.minecraft_server_latency", - "sensor.minecraft_server_players_online", - "sensor.minecraft_server_players_max", - "sensor.minecraft_server_world_message", - "sensor.minecraft_server_version", - "sensor.minecraft_server_protocol_version", + "sensor.mc_dummyserver_com_25566_latency", + "sensor.mc_dummyserver_com_25566_players_online", + "sensor.mc_dummyserver_com_25566_players_max", + "sensor.mc_dummyserver_com_25566_world_message", + "sensor.mc_dummyserver_com_25566_version", + "sensor.mc_dummyserver_com_25566_protocol_version", ] JAVA_SENSOR_ENTITIES_DISABLED_BY_DEFAULT: list[str] = [ - "sensor.minecraft_server_players_max", - "sensor.minecraft_server_protocol_version", + "sensor.mc_dummyserver_com_25566_players_max", + "sensor.mc_dummyserver_com_25566_protocol_version", ] BEDROCK_SENSOR_ENTITIES: list[str] = [ - "sensor.minecraft_server_latency", - "sensor.minecraft_server_players_online", - "sensor.minecraft_server_players_max", - "sensor.minecraft_server_world_message", - "sensor.minecraft_server_version", - "sensor.minecraft_server_protocol_version", - "sensor.minecraft_server_map_name", - "sensor.minecraft_server_game_mode", - "sensor.minecraft_server_edition", + "sensor.mc_dummyserver_com_25566_latency", + "sensor.mc_dummyserver_com_25566_players_online", + "sensor.mc_dummyserver_com_25566_players_max", + "sensor.mc_dummyserver_com_25566_world_message", + "sensor.mc_dummyserver_com_25566_version", + "sensor.mc_dummyserver_com_25566_protocol_version", + "sensor.mc_dummyserver_com_25566_map_name", + "sensor.mc_dummyserver_com_25566_game_mode", + "sensor.mc_dummyserver_com_25566_edition", ] BEDROCK_SENSOR_ENTITIES_DISABLED_BY_DEFAULT: list[str] = [ - "sensor.minecraft_server_players_max", - "sensor.minecraft_server_protocol_version", - "sensor.minecraft_server_edition", + "sensor.mc_dummyserver_com_25566_players_max", + "sensor.mc_dummyserver_com_25566_protocol_version", + "sensor.mc_dummyserver_com_25566_edition", ] From 2bab7436d3498aa9ff6536240a4dc832542372b1 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Mon, 24 Feb 2025 10:07:05 -0700 Subject: [PATCH 2692/2987] Add vesync debug mode in library (#134571) * Debug mode pass through * Correct code, shouldn't have been lambda * listener for change * ruff * Update manifest.json * Reflect correct logger title * Ruff fix from merge --- homeassistant/components/vesync/__init__.py | 31 ++++++++++++++++--- homeassistant/components/vesync/const.py | 1 + homeassistant/components/vesync/manifest.json | 2 +- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index f9371d44507..01f88c64bf4 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -5,8 +5,13 @@ import logging from pyvesync import VeSync from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import ( + CONF_PASSWORD, + CONF_USERNAME, + EVENT_LOGGING_CHANGED, + Platform, +) +from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -17,6 +22,7 @@ from .const import ( VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, + VS_LISTENERS, VS_MANAGER, ) from .coordinator import VeSyncDataCoordinator @@ -42,7 +48,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b time_zone = str(hass.config.time_zone) - manager = VeSync(username, password, time_zone) + manager = VeSync( + username=username, + password=password, + time_zone=time_zone, + debug=logging.getLogger("pyvesync.vesync").level == logging.DEBUG, + redact=True, + ) login = await hass.async_add_executor_job(manager.login) @@ -62,6 +74,17 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + @callback + def _async_handle_logging_changed(_event: Event) -> None: + """Handle when the logging level changes.""" + manager.debug = logging.getLogger("pyvesync.vesync").level == logging.DEBUG + + cleanup = hass.bus.async_listen( + EVENT_LOGGING_CHANGED, _async_handle_logging_changed + ) + + hass.data[DOMAIN][VS_LISTENERS] = cleanup + async def async_new_device_discovery(service: ServiceCall) -> None: """Discover if new devices should be added.""" manager = hass.data[DOMAIN][VS_MANAGER] @@ -87,7 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - + hass.data[DOMAIN][VS_LISTENERS]() unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data.pop(DOMAIN) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 2e51b96451c..1273ab914f8 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -22,6 +22,7 @@ exceeds the quota of 7700. VS_DEVICES = "devices" VS_COORDINATOR = "coordinator" VS_MANAGER = "manager" +VS_LISTENERS = "listeners" VS_NUMBERS = "numbers" VS_HUMIDIFIER_MODE_AUTO = "auto" diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index 9e2fbcc1782..571c6ee0036 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -11,6 +11,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", - "loggers": ["pyvesync"], + "loggers": ["pyvesync.vesync"], "requirements": ["pyvesync==2.1.18"] } From 79dbc704702fd7ff1489ca16a99dfa48a9596e96 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 24 Feb 2025 18:09:51 +0100 Subject: [PATCH 2693/2987] Fix return value for DataUpdateCoordinator._async setup (#139181) Fix return value for coodinator async setup --- homeassistant/helpers/update_coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index be765ff422d..7130264eb0d 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -348,8 +348,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): only once during the first refresh. """ if self.setup_method is None: - return None - return await self.setup_method() + return + await self.setup_method() async def async_refresh(self) -> None: """Refresh data and log errors.""" From 6507955a144c006cb4cc32800ddbfc8c83728a63 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Feb 2025 18:55:13 +0100 Subject: [PATCH 2694/2987] Fix race in WS command recorder/info (#139177) * Fix race in WS command recorder/info * Add comment * Remove unnecessary local import --- .../recorder/basic_websocket_api.py | 33 +++++++++---------- .../components/recorder/test_websocket_api.py | 27 +++++++++------ 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/recorder/basic_websocket_api.py b/homeassistant/components/recorder/basic_websocket_api.py index 9cbc77b30c0..258f6c63a9d 100644 --- a/homeassistant/components/recorder/basic_websocket_api.py +++ b/homeassistant/components/recorder/basic_websocket_api.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import recorder as recorder_helper from .util import get_instance @@ -23,27 +24,23 @@ def async_setup(hass: HomeAssistant) -> None: vol.Required("type"): "recorder/info", } ) -@callback -def ws_info( +@websocket_api.async_response +async def ws_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Return status of the recorder.""" - if instance := get_instance(hass): - backlog = instance.backlog - migration_in_progress = instance.migration_in_progress - migration_is_live = instance.migration_is_live - recording = instance.recording - # We avoid calling is_alive() as it can block waiting - # for the thread state lock which will block the event loop. - is_running = instance.is_running - max_backlog = instance.max_backlog - else: - backlog = None - migration_in_progress = False - migration_is_live = False - recording = False - is_running = False - max_backlog = None + # Wait for db_connected to ensure the recorder instance is created and the + # migration flags are set. + await hass.data[recorder_helper.DATA_RECORDER].db_connected + instance = get_instance(hass) + backlog = instance.backlog + migration_in_progress = instance.migration_in_progress + migration_is_live = instance.migration_is_live + recording = instance.recording + # We avoid calling is_alive() as it can block waiting + # for the thread state lock which will block the event loop. + is_running = instance.is_running + max_backlog = instance.max_backlog recorder_info = { "backlog": backlog, diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 8cbbb7a711b..8f93264b682 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -2608,21 +2608,28 @@ async def test_recorder_info_bad_recorder_config( assert response["result"]["thread_running"] is False -async def test_recorder_info_no_instance( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator +async def test_recorder_info_wait_database_connect( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: - """Test getting recorder when there is no instance.""" + """Test getting recorder info waits for recorder database connection.""" client = await hass_ws_client() - with patch( - "homeassistant.components.recorder.basic_websocket_api.get_instance", - return_value=None, - ): - await client.send_json_auto_id({"type": "recorder/info"}) + recorder_helper.async_initialize_recorder(hass) + await client.send_json_auto_id({"type": "recorder/info"}) + + async with async_test_recorder(hass): response = await client.receive_json() assert response["success"] - assert response["result"]["recording"] is False - assert response["result"]["thread_running"] is False + assert response["result"] == { + "backlog": ANY, + "max_backlog": 65000, + "migration_in_progress": False, + "migration_is_live": False, + "recording": True, + "thread_running": True, + } async def test_recorder_info_migration_queue_exhausted( From b42973040c98eeaccefe23d88a34144cc2b891a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Feb 2025 13:01:25 -0500 Subject: [PATCH 2695/2987] Bump aiohttp to 3.11.13 (#139197) changelog: https://github.com/aio-libs/aiohttp/compare/v3.11.12...v3.11.13 --- 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 967ce98a705..335a3b1da29 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.2.0 aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.11.12 +aiohttp==3.11.13 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index b43e4d284ca..1224cc0c70e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.0", - "aiohttp==3.11.12", + "aiohttp==3.11.13", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index 962cab71a53..1ec004d7f65 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.3.0 -aiohttp==3.11.12 +aiohttp==3.11.13 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 From 1c83dab0a1aa1ee010958a94af5ba7cc00beff3a Mon Sep 17 00:00:00 2001 From: Tristan Date: Tue, 25 Feb 2025 06:29:55 +1100 Subject: [PATCH 2696/2987] Update Linkplay constants for Arylic S10+ and Arylic Up2Stream Amp 2.1 (#138198) --- homeassistant/components/linkplay/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/linkplay/utils.py b/homeassistant/components/linkplay/utils.py index 00bb691362b..7151ed1537a 100644 --- a/homeassistant/components/linkplay/utils.py +++ b/homeassistant/components/linkplay/utils.py @@ -25,10 +25,12 @@ MODELS_ARYLIC_A30: Final[str] = "A30" MODELS_ARYLIC_A50: Final[str] = "A50" MODELS_ARYLIC_A50S: Final[str] = "A50+" MODELS_ARYLIC_UP2STREAM_AMP: Final[str] = "Up2Stream Amp 2.0" +MODELS_ARYLIC_UP2STREAM_AMP_2P1: Final[str] = "Up2Stream Amp 2.1" MODELS_ARYLIC_UP2STREAM_AMP_V3: Final[str] = "Up2Stream Amp v3" MODELS_ARYLIC_UP2STREAM_AMP_V4: Final[str] = "Up2Stream Amp v4" MODELS_ARYLIC_UP2STREAM_PRO: Final[str] = "Up2Stream Pro v1" MODELS_ARYLIC_UP2STREAM_PRO_V3: Final[str] = "Up2Stream Pro v3" +MODELS_ARYLIC_S10P: Final[str] = "Arylic S10+" MODELS_ARYLIC_UP2STREAM_PLATE_AMP: Final[str] = "Up2Stream Plate Amp" MODELS_IEAST_AUDIOCAST_M5: Final[str] = "AudioCast M5" MODELS_WIIM_AMP: Final[str] = "WiiM Amp" @@ -49,9 +51,10 @@ PROJECTID_LOOKUP: Final[dict[str, tuple[str, str]]] = { "UP2STREAM_AMP_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3), "UP2STREAM_AMP_V4": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4), "UP2STREAM_PRO_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3), + "S10P_WIFI": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S10P), "ARYLIC_V20": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PLATE_AMP), "UP2STREAM_MINI_V3": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_GENERIC), + "UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_2P1), "RP0014_A50C_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), "ARYLIC_A30": (MANUFACTURER_ARYLIC, MODELS_GENERIC), "ARYLIC_SUBWOOFER": (MANUFACTURER_ARYLIC, MODELS_GENERIC), From 2451e5578a20cbb320e072a44688aaee8f0be44e Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Mon, 24 Feb 2025 19:39:04 +0000 Subject: [PATCH 2697/2987] Add support for Apps and Radios to Squeezebox Media Browser (#135009) --- .../components/squeezebox/browse_media.py | 179 ++++++++++++++++-- homeassistant/components/squeezebox/const.py | 8 +- .../components/squeezebox/media_player.py | 13 +- tests/components/squeezebox/conftest.py | 28 ++- .../squeezebox/test_media_browser.py | 171 +++++++++++++---- 5 files changed, 334 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index c0458067a23..e12d2aa8844 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib +from dataclasses import dataclass, field from typing import Any from pysqueezebox import Player @@ -18,6 +19,8 @@ from homeassistant.components.media_player import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.network import is_internal_request +from .const import UNPLAYABLE_TYPES + LIBRARY = [ "Favorites", "Artists", @@ -26,9 +29,11 @@ LIBRARY = [ "Playlists", "Genres", "New Music", + "Apps", + "Radios", ] -MEDIA_TYPE_TO_SQUEEZEBOX = { +MEDIA_TYPE_TO_SQUEEZEBOX: dict[str | MediaType, str] = { "Favorites": "favorites", "Artists": "artists", "Albums": "albums", @@ -41,19 +46,25 @@ MEDIA_TYPE_TO_SQUEEZEBOX = { MediaType.TRACK: "title", MediaType.PLAYLIST: "playlist", MediaType.GENRE: "genre", + "Apps": "apps", + "Radios": "radios", } -SQUEEZEBOX_ID_BY_TYPE = { +SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = { MediaType.ALBUM: "album_id", MediaType.ARTIST: "artist_id", MediaType.TRACK: "track_id", MediaType.PLAYLIST: "playlist_id", MediaType.GENRE: "genre_id", "Favorites": "item_id", + MediaType.APPS: "item_id", } CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | None]] = { "Favorites": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, + "Apps": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, + "Radios": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, + "App": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, "Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, "Albums": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, "Tracks": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, @@ -65,9 +76,14 @@ CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | None]] = MediaType.TRACK: {"item": MediaClass.TRACK, "children": None}, MediaType.GENRE: {"item": MediaClass.GENRE, "children": MediaClass.ARTIST}, MediaType.PLAYLIST: {"item": MediaClass.PLAYLIST, "children": MediaClass.TRACK}, + MediaType.APP: {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, + MediaType.APPS: {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, } -CONTENT_TYPE_TO_CHILD_TYPE = { +CONTENT_TYPE_TO_CHILD_TYPE: dict[ + str | MediaType, + str | MediaType | None, +] = { MediaType.ALBUM: MediaType.TRACK, MediaType.PLAYLIST: MediaType.PLAYLIST, MediaType.ARTIST: MediaType.ALBUM, @@ -78,15 +94,93 @@ CONTENT_TYPE_TO_CHILD_TYPE = { "Playlists": MediaType.PLAYLIST, "Genres": MediaType.GENRE, "Favorites": None, # can only be determined after inspecting the item + "Apps": MediaClass.APP, + "Radios": MediaClass.APP, + "App": None, # can only be determined after inspecting the item "New Music": MediaType.ALBUM, + MediaType.APPS: MediaType.APP, + MediaType.APP: MediaType.TRACK, } +@dataclass +class BrowseData: + """Class for browser to squeezebox mappings and other browse data.""" + + content_type_to_child_type: dict[ + str | MediaType, + str | MediaType | None, + ] = field(default_factory=dict) + content_type_media_class: dict[str | MediaType, dict[str, MediaClass | None]] = ( + field(default_factory=dict) + ) + squeezebox_id_by_type: dict[str | MediaType, str] = field(default_factory=dict) + media_type_to_squeezebox: dict[str | MediaType, str] = field(default_factory=dict) + known_apps_radios: set[str] = field(default_factory=set) + + def __post_init__(self) -> None: + """Initialise the maps.""" + self.content_type_media_class.update(CONTENT_TYPE_MEDIA_CLASS) + self.content_type_to_child_type.update(CONTENT_TYPE_TO_CHILD_TYPE) + self.squeezebox_id_by_type.update(SQUEEZEBOX_ID_BY_TYPE) + self.media_type_to_squeezebox.update(MEDIA_TYPE_TO_SQUEEZEBOX) + + +@dataclass +class BrowseItemResponse: + """Class for response data for browse item functions.""" + + child_item_type: str | MediaType + child_media_class: dict[str, MediaClass | None] + can_expand: bool + can_play: bool + + +def _add_new_command_to_browse_data( + browse_data: BrowseData, cmd: str | MediaType, type: str +) -> None: + """Add items to maps for new apps or radios.""" + browse_data.media_type_to_squeezebox[cmd] = cmd + browse_data.squeezebox_id_by_type[cmd] = type + browse_data.content_type_media_class[cmd] = { + "item": MediaClass.DIRECTORY, + "children": MediaClass.TRACK, + } + browse_data.content_type_to_child_type[cmd] = MediaType.TRACK + + +def _build_response_apps_radios_category( + browse_data: BrowseData, + cmd: str | MediaType, +) -> BrowseItemResponse: + """Build item for App or radio category.""" + return BrowseItemResponse( + child_item_type=cmd, + child_media_class=browse_data.content_type_media_class[cmd], + can_expand=True, + can_play=False, + ) + + +def _build_response_known_app( + browse_data: BrowseData, search_type: str, item: dict[str, Any] +) -> BrowseItemResponse: + """Build item for app or radio.""" + + return BrowseItemResponse( + child_item_type=search_type, + child_media_class=browse_data.content_type_media_class[search_type], + can_play=bool(item["isaudio"] and item.get("url")), + can_expand=item["hasitems"], + ) + + async def build_item_response( entity: MediaPlayerEntity, player: Player, payload: dict[str, str | None], browse_limit: int, + browse_data: BrowseData, ) -> BrowseMedia: """Create response payload for search described by payload.""" @@ -97,29 +191,30 @@ async def build_item_response( assert ( search_type is not None ) # async_browse_media will not call this function if search_type is None - media_class = CONTENT_TYPE_MEDIA_CLASS[search_type] + media_class = browse_data.content_type_media_class[search_type] children = None if search_id and search_id != search_type: - browse_id = (SQUEEZEBOX_ID_BY_TYPE[search_type], search_id) + browse_id = (browse_data.squeezebox_id_by_type[search_type], search_id) else: browse_id = None result = await player.async_browse( - MEDIA_TYPE_TO_SQUEEZEBOX[search_type], + browse_data.media_type_to_squeezebox[search_type], limit=browse_limit, browse_id=browse_id, ) if result is not None and result.get("items"): - item_type = CONTENT_TYPE_TO_CHILD_TYPE[search_type] + item_type = browse_data.content_type_to_child_type[search_type] children = [] list_playable = [] for item in result["items"]: - item_id = str(item["id"]) + item_id = str(item.get("id", "")) item_thumbnail: str | None = None + if item_type: child_item_type: MediaType | str = item_type child_media_class = CONTENT_TYPE_MEDIA_CLASS[item_type] @@ -144,6 +239,47 @@ async def build_item_response( can_expand = item["hasitems"] can_play = item["isaudio"] and item.get("url") + if search_type in ["Apps", "Radios"]: + # item["cmd"] contains the name of the command to use with the cli for the app + # add the command to the dictionaries + if item["title"] == "Search" or item.get("type") in UNPLAYABLE_TYPES: + # Skip searches in apps as they'd need UI or if the link isn't to audio + continue + app_cmd = "app-" + item["cmd"] + + if app_cmd not in browse_data.known_apps_radios: + browse_data.known_apps_radios.add(app_cmd) + + _add_new_command_to_browse_data(browse_data, app_cmd, "item_id") + + browse_item_response = _build_response_apps_radios_category( + browse_data, app_cmd + ) + + # Temporary variables until remainder of browse calls are restructured + child_item_type = browse_item_response.child_item_type + child_media_class = browse_item_response.child_media_class + can_expand = browse_item_response.can_expand + can_play = browse_item_response.can_play + + elif search_type in browse_data.known_apps_radios: + if ( + item.get("title") in ["Search", None] + or item.get("type") in UNPLAYABLE_TYPES + ): + # Skip searches in apps as they'd need UI + continue + + browse_item_response = _build_response_known_app( + browse_data, search_type, item + ) + + # Temporary variables until remainder of browse calls are restructured + child_item_type = browse_item_response.child_item_type + child_media_class = browse_item_response.child_media_class + can_expand = browse_item_response.can_expand + can_play = browse_item_response.can_play + if artwork_track_id := item.get("artwork_track_id"): if internal_request: item_thumbnail = player.generate_image_url_from_track_id( @@ -153,6 +289,8 @@ async def build_item_response( item_thumbnail = entity.get_browse_image_url( item_type, item_id, artwork_track_id ) + elif search_type in ["Apps", "Radios"]: + item_thumbnail = player.generate_image_url(item["icon"]) else: item_thumbnail = item.get("image_url") # will not be proxied by HA @@ -176,6 +314,7 @@ async def build_item_response( assert media_class["item"] is not None if not search_id: search_id = search_type + return BrowseMedia( title=result.get("title"), media_class=media_class["item"], @@ -188,7 +327,11 @@ async def build_item_response( ) -async def library_payload(hass: HomeAssistant, player: Player) -> BrowseMedia: +async def library_payload( + hass: HomeAssistant, + player: Player, + browse_media: BrowseData, +) -> BrowseMedia: """Create response payload to describe contents of library.""" library_info: dict[str, Any] = { "title": "Music Library", @@ -201,10 +344,10 @@ async def library_payload(hass: HomeAssistant, player: Player) -> BrowseMedia: } for item in LIBRARY: - media_class = CONTENT_TYPE_MEDIA_CLASS[item] + media_class = browse_media.content_type_media_class[item] result = await player.async_browse( - MEDIA_TYPE_TO_SQUEEZEBOX[item], + browse_media.media_type_to_squeezebox[item], limit=1, ) if result is not None and result.get("items") is not None: @@ -215,7 +358,7 @@ async def library_payload(hass: HomeAssistant, player: Player) -> BrowseMedia: media_class=media_class["children"], media_content_id=item, media_content_type=item, - can_play=item != "Favorites", + can_play=item not in ["Favorites", "Apps", "Radios"], can_expand=True, ) ) @@ -242,17 +385,23 @@ async def generate_playlist( player: Player, payload: dict[str, str], browse_limit: int, + browse_media: BrowseData, ) -> list | None: """Generate playlist from browsing payload.""" media_type = payload["search_type"] media_id = payload["search_id"] - if media_type not in SQUEEZEBOX_ID_BY_TYPE: + if media_type not in browse_media.squeezebox_id_by_type: raise BrowseError(f"Media type not supported: {media_type}") - browse_id = (SQUEEZEBOX_ID_BY_TYPE[media_type], media_id) + browse_id = (browse_media.squeezebox_id_by_type[media_type], media_id) + if media_type.startswith("app-"): + category = media_type + else: + category = "titles" + result = await player.async_browse( - "titles", limit=browse_limit, browse_id=browse_id + category, limit=browse_limit, browse_id=browse_id ) if result and "items" in result: items: list = result["items"] diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index 61ec3cac2fa..5ce95d25632 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -27,7 +27,12 @@ STATUS_QUERY_LIBRARYNAME = "libraryname" STATUS_QUERY_MAC = "mac" STATUS_QUERY_UUID = "uuid" STATUS_QUERY_VERSION = "version" -SQUEEZEBOX_SOURCE_STRINGS = ("source:", "wavin:", "spotify:") +SQUEEZEBOX_SOURCE_STRINGS = ( + "source:", + "wavin:", + "spotify:", + "loop:", +) SIGNAL_PLAYER_DISCOVERED = "squeezebox_player_discovered" SIGNAL_PLAYER_REDISCOVERED = "squeezebox_player_rediscovered" DISCOVERY_INTERVAL = 60 @@ -38,3 +43,4 @@ DEFAULT_BROWSE_LIMIT = 1000 DEFAULT_VOLUME_STEP = 5 ATTR_ANNOUNCE_VOLUME = "announce_volume" ATTR_ANNOUNCE_TIMEOUT = "announce_timeout" +UNPLAYABLE_TYPES = ("text", "actions") diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 48015f86ba0..0cd539b4584 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -47,6 +47,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow from .browse_media import ( + BrowseData, build_item_response, generate_playlist, library_payload, @@ -240,6 +241,7 @@ class SqueezeBoxMediaPlayerEntity( model=player.model, manufacturer=_manufacturer, ) + self._browse_data = BrowseData() @callback def _handle_coordinator_update(self) -> None: @@ -530,9 +532,7 @@ class SqueezeBoxMediaPlayerEntity( "search_type": MediaType.PLAYLIST, } playlist = await generate_playlist( - self._player, - payload, - self.browse_limit, + self._player, payload, self.browse_limit, self._browse_data ) except BrowseError: # a list of urls @@ -545,9 +545,7 @@ class SqueezeBoxMediaPlayerEntity( "search_type": media_type, } playlist = await generate_playlist( - self._player, - payload, - self.browse_limit, + self._player, payload, self.browse_limit, self._browse_data ) _LOGGER.debug("Generated playlist: %s", playlist) @@ -646,7 +644,7 @@ class SqueezeBoxMediaPlayerEntity( ) if media_content_type in [None, "library"]: - return await library_payload(self.hass, self._player) + return await library_payload(self.hass, self._player, self._browse_data) if media_content_id and media_source.is_media_source_id(media_content_id): return await media_source.async_browse_media( @@ -663,6 +661,7 @@ class SqueezeBoxMediaPlayerEntity( self._player, payload, self.browse_limit, + self._browse_data, ) async def async_get_browse_image( diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 9224334a716..cb77495e818 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -142,6 +142,9 @@ async def mock_async_browse( "title": "title", "playlists": "playlist", "playlist": "title", + "apps": "app", + "radios": "app", + "app-fakecommand": "track", } fake_items = [ { @@ -152,6 +155,8 @@ async def mock_async_browse( "item_type": child_types[media_type], "artwork_track_id": "b35bb9e9", "url": "file:///var/lib/squeezeboxserver/music/track_1.mp3", + "cmd": "fakecommand", + "icon": "plugins/Qobuz/html/images/qobuz.png", }, { "title": "Fake Item 2", @@ -161,6 +166,8 @@ async def mock_async_browse( "item_type": child_types[media_type], "image_url": "http://lms.internal:9000/html/images/favorites.png", "url": "file:///var/lib/squeezeboxserver/music/track_2.mp3", + "cmd": "fakecommand", + "icon": "plugins/Qobuz/html/images/qobuz.png", }, { "title": "Fake Item 3", @@ -169,6 +176,19 @@ async def mock_async_browse( "isaudio": True, "album_id": FAKE_VALID_ITEM_ID if media_type == "favorites" else None, "url": "file:///var/lib/squeezeboxserver/music/track_3.mp3", + "cmd": "fakecommand", + "icon": "plugins/Qobuz/html/images/qobuz.png", + }, + { + "title": "Fake Invalid Item 1", + "id": FAKE_VALID_ITEM_ID + "invalid_3", + "hasitems": media_type == "favorites", + "isaudio": True, + "album_id": FAKE_VALID_ITEM_ID if media_type == "favorites" else None, + "url": "file:///var/lib/squeezeboxserver/music/track_3.mp3", + "cmd": "fakecommand", + "icon": "plugins/Qobuz/html/images/qobuz.png", + "type": "text", }, ] @@ -198,7 +218,10 @@ async def mock_async_browse( "items": fake_items, } return None - if media_type in MEDIA_TYPE_TO_SQUEEZEBOX.values(): + if ( + media_type in MEDIA_TYPE_TO_SQUEEZEBOX.values() + or media_type == "app-fakecommand" + ): return { "title": media_type, "items": fake_items, @@ -232,6 +255,9 @@ def mock_pysqueezebox_player(uuid: str) -> MagicMock: mock_player.async_play_announcement = AsyncMock( side_effect=mock_async_play_announcement ) + mock_player.generate_image_url = MagicMock( + return_value="http://lms.internal:9000/html/images/favorites.png" + ) mock_player.name = TEST_PLAYER_NAME mock_player.player_id = uuid mock_player.mode = "stop" diff --git a/tests/components/squeezebox/test_media_browser.py b/tests/components/squeezebox/test_media_browser.py index c03c1b6344d..f00ea1754fc 100644 --- a/tests/components/squeezebox/test_media_browser.py +++ b/tests/components/squeezebox/test_media_browser.py @@ -19,6 +19,8 @@ from homeassistant.components.squeezebox.browse_media import ( from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from .conftest import FAKE_VALID_ITEM_ID + from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -66,56 +68,143 @@ async def test_async_browse_media_root( assert item["title"] == LIBRARY[idx] +@pytest.mark.parametrize( + ("category", "child_count"), + [ + ("Favorites", 4), + ("Artists", 4), + ("Albums", 4), + ("Playlists", 4), + ("Genres", 4), + ("New Music", 4), + ("Apps", 3), + ("Radios", 3), + ], +) async def test_async_browse_media_with_subitems( hass: HomeAssistant, config_entry: MockConfigEntry, hass_ws_client: WebSocketGenerator, + category: str, + child_count: int, ) -> None: """Test each category with subitems.""" - for category in ( - "Favorites", - "Artists", - "Albums", - "Playlists", - "Genres", - "New Music", + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, ): - with patch( - "homeassistant.components.squeezebox.browse_media.is_internal_request", - return_value=False, - ): - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "media_player/browse_media", - "entity_id": "media_player.test_player", - "media_content_id": "", - "media_content_type": category, - } - ) - response = await client.receive_json() - assert response["success"] - category_level = response["result"] - assert category_level["title"] == MEDIA_TYPE_TO_SQUEEZEBOX[category] - assert category_level["children"][0]["title"] == "Fake Item 1" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": category, + } + ) + response = await client.receive_json() + assert response["success"] + category_level = response["result"] + assert category_level["title"] == MEDIA_TYPE_TO_SQUEEZEBOX[category] + assert category_level["children"][0]["title"] == "Fake Item 1" + assert len(category_level["children"]) == child_count - # Look up a subitem - search_type = category_level["children"][0]["media_content_type"] - search_id = category_level["children"][0]["media_content_id"] - await client.send_json( + # Look up a subitem + search_type = category_level["children"][0]["media_content_type"] + search_id = category_level["children"][0]["media_content_id"] + await client.send_json( + { + "id": 2, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": search_id, + "media_content_type": search_type, + } + ) + response = await client.receive_json() + assert response["success"] + search = response["result"] + assert search["title"] == "Fake Item 1" + + +async def test_async_browse_media_for_apps( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test browsing for app category.""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + category = "Apps" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": category, + } + ) + response = await client.receive_json() + assert response["success"] + + # Look up a subitem + await client.send_json( + { + "id": 2, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": "app-fakecommand", + } + ) + response = await client.receive_json() + assert response["success"] + search = response["result"] + assert search["children"][0]["title"] == "Fake Item 1" + assert "Fake Invalid Item 1" not in search + + +async def test_generate_playlist_for_app( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the generate_playlist for app-fakecommand media type.""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + category = "Apps" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": category, + } + ) + response = await client.receive_json() + assert response["success"] + + try: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, { - "id": 2, - "type": "media_player/browse_media", - "entity_id": "media_player.test_player", - "media_content_id": search_id, - "media_content_type": search_type, - } + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "app-fakecommand", + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + }, + blocking=True, ) - response = await client.receive_json() - assert response["success"] - search = response["result"] - assert search["title"] == "Fake Item 1" + except BrowseError: + pytest.fail("generate_playlist fails for app") async def test_async_browse_tracks( @@ -142,7 +231,7 @@ async def test_async_browse_tracks( assert response["success"] tracks = response["result"] assert tracks["title"] == "titles" - assert len(tracks["children"]) == 3 + assert len(tracks["children"]) == 4 async def test_async_browse_error( From dc92e912c2885d69071bf1721c4ea60eef0fc3f2 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 24 Feb 2025 20:59:51 +0100 Subject: [PATCH 2698/2987] Add azure_storage as backup agent (#134085) Co-authored-by: Martin Hjelmare --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/microsoft.json | 1 + .../components/azure_storage/__init__.py | 82 +++++ .../components/azure_storage/backup.py | 182 ++++++++++ .../components/azure_storage/config_flow.py | 72 ++++ .../components/azure_storage/const.py | 16 + .../components/azure_storage/manifest.json | 12 + .../azure_storage/quality_scale.yaml | 133 ++++++++ .../components/azure_storage/strings.json | 48 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/azure_storage/__init__.py | 14 + tests/components/azure_storage/conftest.py | 63 ++++ tests/components/azure_storage/const.py | 36 ++ tests/components/azure_storage/test_backup.py | 317 ++++++++++++++++++ .../azure_storage/test_config_flow.py | 113 +++++++ tests/components/azure_storage/test_init.py | 54 +++ 21 files changed, 1169 insertions(+) create mode 100644 homeassistant/components/azure_storage/__init__.py create mode 100644 homeassistant/components/azure_storage/backup.py create mode 100644 homeassistant/components/azure_storage/config_flow.py create mode 100644 homeassistant/components/azure_storage/const.py create mode 100644 homeassistant/components/azure_storage/manifest.json create mode 100644 homeassistant/components/azure_storage/quality_scale.yaml create mode 100644 homeassistant/components/azure_storage/strings.json create mode 100644 tests/components/azure_storage/__init__.py create mode 100644 tests/components/azure_storage/conftest.py create mode 100644 tests/components/azure_storage/const.py create mode 100644 tests/components/azure_storage/test_backup.py create mode 100644 tests/components/azure_storage/test_config_flow.py create mode 100644 tests/components/azure_storage/test_init.py diff --git a/.strict-typing b/.strict-typing index 95eb2abb4b4..1df49300b1e 100644 --- a/.strict-typing +++ b/.strict-typing @@ -103,6 +103,7 @@ homeassistant.components.auth.* homeassistant.components.automation.* homeassistant.components.awair.* homeassistant.components.axis.* +homeassistant.components.azure_storage.* homeassistant.components.backup.* homeassistant.components.baf.* homeassistant.components.bang_olufsen.* diff --git a/CODEOWNERS b/CODEOWNERS index bb8545c46b7..87f170009f0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -180,6 +180,8 @@ build.json @home-assistant/supervisor /homeassistant/components/azure_event_hub/ @eavanvalkenburg /tests/components/azure_event_hub/ @eavanvalkenburg /homeassistant/components/azure_service_bus/ @hfurubotten +/homeassistant/components/azure_storage/ @zweckj +/tests/components/azure_storage/ @zweckj /homeassistant/components/backup/ @home-assistant/core /tests/components/backup/ @home-assistant/core /homeassistant/components/baf/ @bdraco @jfroy diff --git a/homeassistant/brands/microsoft.json b/homeassistant/brands/microsoft.json index 0e00c4a7bc3..918f67f06dd 100644 --- a/homeassistant/brands/microsoft.json +++ b/homeassistant/brands/microsoft.json @@ -6,6 +6,7 @@ "azure_devops", "azure_event_hub", "azure_service_bus", + "azure_storage", "microsoft_face_detect", "microsoft_face_identify", "microsoft_face", diff --git a/homeassistant/components/azure_storage/__init__.py b/homeassistant/components/azure_storage/__init__.py new file mode 100644 index 00000000000..873a9ab90ca --- /dev/null +++ b/homeassistant/components/azure_storage/__init__.py @@ -0,0 +1,82 @@ +"""The Azure Storage integration.""" + +from aiohttp import ClientTimeout +from azure.core.exceptions import ( + ClientAuthenticationError, + HttpResponseError, + ResourceNotFoundError, +) +from azure.core.pipeline.transport._aiohttp import ( + AioHttpTransport, +) # need to import from private file, as it is not properly imported in the init +from azure.storage.blob.aio import ContainerClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .const import ( + CONF_ACCOUNT_NAME, + CONF_CONTAINER_NAME, + CONF_STORAGE_ACCOUNT_KEY, + DATA_BACKUP_AGENT_LISTENERS, + DOMAIN, +) + +type AzureStorageConfigEntry = ConfigEntry[ContainerClient] + + +async def async_setup_entry( + hass: HomeAssistant, entry: AzureStorageConfigEntry +) -> bool: + """Set up Azure Storage integration.""" + # set increase aiohttp timeout for long running operations (up/download) + session = async_create_clientsession( + hass, timeout=ClientTimeout(connect=10, total=12 * 60 * 60) + ) + container_client = ContainerClient( + account_url=f"https://{entry.data[CONF_ACCOUNT_NAME]}.blob.core.windows.net/", + container_name=entry.data[CONF_CONTAINER_NAME], + credential=entry.data[CONF_STORAGE_ACCOUNT_KEY], + transport=AioHttpTransport(session=session), + ) + + try: + if not await container_client.exists(): + await container_client.create_container() + except ResourceNotFoundError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="account_not_found", + translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]}, + ) from err + except ClientAuthenticationError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]}, + ) from err + except HttpResponseError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]}, + ) from err + + entry.runtime_data = container_client + + def _async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(_async_notify_backup_listeners)) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: AzureStorageConfigEntry +) -> bool: + """Unload an Azure Storage config entry.""" + return True diff --git a/homeassistant/components/azure_storage/backup.py b/homeassistant/components/azure_storage/backup.py new file mode 100644 index 00000000000..6f39295761d --- /dev/null +++ b/homeassistant/components/azure_storage/backup.py @@ -0,0 +1,182 @@ +"""Support for Azure Storage backup.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +from functools import wraps +import json +import logging +from typing import Any, Concatenate + +from azure.core.exceptions import HttpResponseError +from azure.storage.blob import BlobProperties + +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, + suggested_filename, +) +from homeassistant.core import HomeAssistant, callback + +from . import AzureStorageConfigEntry +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +_LOGGER = logging.getLogger(__name__) +METADATA_VERSION = "1" + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries: list[AzureStorageConfigEntry] = hass.config_entries.async_loaded_entries( + DOMAIN + ) + return [AzureStorageBackupAgent(hass, entry) for entry in entries] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed.""" + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + hass.data.pop(DATA_BACKUP_AGENT_LISTENERS) + + return remove_listener + + +def handle_backup_errors[_R, **P]( + func: Callable[Concatenate[AzureStorageBackupAgent, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[AzureStorageBackupAgent, P], Coroutine[Any, Any, _R]]: + """Handle backup errors.""" + + @wraps(func) + async def wrapper( + self: AzureStorageBackupAgent, *args: P.args, **kwargs: P.kwargs + ) -> _R: + try: + return await func(self, *args, **kwargs) + except HttpResponseError as err: + _LOGGER.debug( + "Error during backup in %s: Status %s, message %s", + func.__name__, + err.status_code, + err.message, + exc_info=True, + ) + raise BackupAgentError( + f"Error during backup operation in {func.__name__}:" + f" Status {err.status_code}, message: {err.message}" + ) from err + + return wrapper + + +class AzureStorageBackupAgent(BackupAgent): + """Azure storage backup agent.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: AzureStorageConfigEntry) -> None: + """Initialize the Azure storage backup agent.""" + super().__init__() + self._client = entry.runtime_data + self.name = entry.title + self.unique_id = entry.entry_id + + @handle_backup_errors + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + blob = await self._find_blob_by_backup_id(backup_id) + if blob is None: + raise BackupNotFound(f"Backup {backup_id} not found") + download_stream = await self._client.download_blob(blob.name) + return download_stream.chunks() + + @handle_backup_errors + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + + metadata = { + "metadata_version": METADATA_VERSION, + "backup_id": backup.backup_id, + "backup_metadata": json.dumps(backup.as_dict()), + } + + await self._client.upload_blob( + name=suggested_filename(backup), + metadata=metadata, + data=await open_stream(), + length=backup.size, + ) + + @handle_backup_errors + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file.""" + blob = await self._find_blob_by_backup_id(backup_id) + if blob is None: + return + await self._client.delete_blob(blob.name) + + @handle_backup_errors + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + backups: list[AgentBackup] = [] + async for blob in self._client.list_blobs(include="metadata"): + metadata = blob.metadata + + if metadata.get("metadata_version") == METADATA_VERSION: + backups.append( + AgentBackup.from_dict(json.loads(metadata["backup_metadata"])) + ) + + return backups + + @handle_backup_errors + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + blob = await self._find_blob_by_backup_id(backup_id) + if blob is None: + return None + + return AgentBackup.from_dict(json.loads(blob.metadata["backup_metadata"])) + + async def _find_blob_by_backup_id(self, backup_id: str) -> BlobProperties | None: + """Find a blob by backup id.""" + async for blob in self._client.list_blobs(include="metadata"): + if ( + backup_id == blob.metadata.get("backup_id", "") + and blob.metadata.get("metadata_version") == METADATA_VERSION + ): + return blob + return None diff --git a/homeassistant/components/azure_storage/config_flow.py b/homeassistant/components/azure_storage/config_flow.py new file mode 100644 index 00000000000..e5b1214fa5b --- /dev/null +++ b/homeassistant/components/azure_storage/config_flow.py @@ -0,0 +1,72 @@ +"""Config flow for Azure Storage integration.""" + +import logging +from typing import Any + +from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError +from azure.core.pipeline.transport._aiohttp import ( + AioHttpTransport, +) # need to import from private file, as it is not properly imported in the init +from azure.storage.blob.aio import ContainerClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_ACCOUNT_NAME, + CONF_CONTAINER_NAME, + CONF_STORAGE_ACCOUNT_KEY, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for azure storage.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """User step for Azure Storage.""" + + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match( + {CONF_ACCOUNT_NAME: user_input[CONF_ACCOUNT_NAME]} + ) + container_client = ContainerClient( + account_url=f"https://{user_input[CONF_ACCOUNT_NAME]}.blob.core.windows.net/", + container_name=user_input[CONF_CONTAINER_NAME], + credential=user_input[CONF_STORAGE_ACCOUNT_KEY], + transport=AioHttpTransport(session=async_get_clientsession(self.hass)), + ) + try: + await container_client.exists() + except ResourceNotFoundError: + errors["base"] = "cannot_connect" + except ClientAuthenticationError: + errors[CONF_STORAGE_ACCOUNT_KEY] = "invalid_auth" + except Exception: + _LOGGER.exception("Unknown exception occurred") + errors["base"] = "unknown" + if not errors: + return self.async_create_entry( + title=f"{user_input[CONF_ACCOUNT_NAME]}/{user_input[CONF_CONTAINER_NAME]}", + data=user_input, + ) + + return self.async_show_form( + data_schema=vol.Schema( + { + vol.Required(CONF_ACCOUNT_NAME): str, + vol.Required( + CONF_CONTAINER_NAME, default="home-assistant-backups" + ): str, + vol.Required(CONF_STORAGE_ACCOUNT_KEY): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/azure_storage/const.py b/homeassistant/components/azure_storage/const.py new file mode 100644 index 00000000000..efcb338a096 --- /dev/null +++ b/homeassistant/components/azure_storage/const.py @@ -0,0 +1,16 @@ +"""Constants for the Azure Storage integration.""" + +from collections.abc import Callable +from typing import Final + +from homeassistant.util.hass_dict import HassKey + +DOMAIN: Final = "azure_storage" + +CONF_STORAGE_ACCOUNT_KEY: Final = "storage_account_key" +CONF_ACCOUNT_NAME: Final = "account_name" +CONF_CONTAINER_NAME: Final = "container_name" + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) diff --git a/homeassistant/components/azure_storage/manifest.json b/homeassistant/components/azure_storage/manifest.json new file mode 100644 index 00000000000..8f2d8aeaca7 --- /dev/null +++ b/homeassistant/components/azure_storage/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "azure_storage", + "name": "Azure Storage", + "codeowners": ["@zweckj"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/azure_storage", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["azure-storage-blob"], + "quality_scale": "bronze", + "requirements": ["azure-storage-blob==12.24.0"] +} diff --git a/homeassistant/components/azure_storage/quality_scale.yaml b/homeassistant/components/azure_storage/quality_scale.yaml new file mode 100644 index 00000000000..6b6f90de494 --- /dev/null +++ b/homeassistant/components/azure_storage/quality_scale.yaml @@ -0,0 +1,133 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + 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 have any custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + 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. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have any configuration parameters. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: | + This integration does not have entities. + 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. + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: + status: exempt + comment: | + This integration connects to a single service. + diagnostics: + status: exempt + comment: | + There is no data to diagnose. + discovery-update-info: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + discovery: + 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-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: 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. + exception-translations: done + icon-translations: + status: exempt + comment: | + This integration does not have entities. + reconfiguration-flow: todo + repair-issues: done + stale-devices: + status: exempt + comment: | + This integration connects to a single service. + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/azure_storage/strings.json b/homeassistant/components/azure_storage/strings.json new file mode 100644 index 00000000000..4bd4cb0dfba --- /dev/null +++ b/homeassistant/components/azure_storage/strings.json @@ -0,0 +1,48 @@ +{ + "config": { + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "storage_account_key": "Storage account key", + "account_name": "Account name", + "container_name": "Container name" + }, + "data_description": { + "storage_account_key": "Storage account access key used for authorization", + "account_name": "Name of the storage account", + "container_name": "Name of the storage container to be used (will be created if it does not exist)" + }, + "description": "Set up an Azure (Blob) storage account to be used for backups.", + "title": "Add Azure storage account" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "issues": { + "container_not_found": { + "title": "Storage container not found", + "description": "The storage container {container_name} has not been found in the storage account. Please re-create it manually, then fix this issue." + } + }, + "exceptions": { + "account_not_found": { + "message": "Storage account {account_name} not found" + }, + "cannot_connect": { + "message": "Can not connect to storage account {account_name}" + }, + "invalid_auth": { + "message": "Authentication failed for storage account {account_name}" + }, + "container_not_found": { + "message": "Storage container {container_name} not found" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index de581c65297..8284f77ef94 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -79,6 +79,7 @@ FLOWS = { "azure_data_explorer", "azure_devops", "azure_event_hub", + "azure_storage", "baf", "balboa", "bang_olufsen", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 41083ee8e8c..01ff9d14d90 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3800,6 +3800,12 @@ "iot_class": "cloud_push", "name": "Azure Service Bus" }, + "azure_storage": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Azure Storage" + }, "microsoft_face_detect": { "integration_type": "hub", "config_flow": false, diff --git a/mypy.ini b/mypy.ini index a04242dc66d..a6203993c87 100644 --- a/mypy.ini +++ b/mypy.ini @@ -785,6 +785,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.azure_storage.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.backup.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 87dd9bb204e..3b80e4f78a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -571,6 +571,9 @@ azure-kusto-ingest==4.5.1 # homeassistant.components.azure_service_bus azure-servicebus==7.10.0 +# homeassistant.components.azure_storage +azure-storage-blob==12.24.0 + # homeassistant.components.holiday babel==2.15.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f55ea287d37..4ec3192285d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -517,6 +517,9 @@ azure-kusto-data[aio]==4.5.1 # homeassistant.components.azure_data_explorer azure-kusto-ingest==4.5.1 +# homeassistant.components.azure_storage +azure-storage-blob==12.24.0 + # homeassistant.components.holiday babel==2.15.0 diff --git a/tests/components/azure_storage/__init__.py b/tests/components/azure_storage/__init__.py new file mode 100644 index 00000000000..bfd2e72d979 --- /dev/null +++ b/tests/components/azure_storage/__init__.py @@ -0,0 +1,14 @@ +"""Azure Storage integration tests.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the azure_storage integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/azure_storage/conftest.py b/tests/components/azure_storage/conftest.py new file mode 100644 index 00000000000..7c583ac391e --- /dev/null +++ b/tests/components/azure_storage/conftest.py @@ -0,0 +1,63 @@ +"""Fixtures for Azure Storage tests.""" + +from collections.abc import AsyncIterator, Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from azure.storage.blob import BlobProperties +import pytest + +from homeassistant.components.azure_storage.const import DOMAIN + +from .const import BACKUP_METADATA, USER_INPUT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.azure_storage.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture(autouse=True) +def mock_client() -> Generator[MagicMock]: + """Mock the Azure Storage client.""" + with ( + patch( + "homeassistant.components.azure_storage.config_flow.ContainerClient", + autospec=True, + ) as container_client, + patch( + "homeassistant.components.azure_storage.ContainerClient", + new=container_client, + ), + ): + client = container_client.return_value + client.exists.return_value = False + + async def async_list_blobs(): + yield BlobProperties(metadata=BACKUP_METADATA) + yield BlobProperties(metadata=BACKUP_METADATA) + + client.list_blobs.return_value = async_list_blobs() + + class MockStream: + async def chunks(self) -> AsyncIterator[bytes]: + yield b"backup data" + + client.download_blob.return_value = MockStream() + + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="account/container1", + domain=DOMAIN, + data=USER_INPUT, + ) diff --git a/tests/components/azure_storage/const.py b/tests/components/azure_storage/const.py new file mode 100644 index 00000000000..4edb754f650 --- /dev/null +++ b/tests/components/azure_storage/const.py @@ -0,0 +1,36 @@ +"""Consts for Azure Storage tests.""" + +from json import dumps + +from homeassistant.components.azure_storage.const import ( + CONF_ACCOUNT_NAME, + CONF_CONTAINER_NAME, + CONF_STORAGE_ACCOUNT_KEY, +) +from homeassistant.components.backup import AgentBackup + +USER_INPUT = { + CONF_ACCOUNT_NAME: "account", + CONF_CONTAINER_NAME: "container1", + CONF_STORAGE_ACCOUNT_KEY: "test", +} + +TEST_BACKUP = AgentBackup( + addons=[], + backup_id="23e64aec", + 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, +) + +BACKUP_METADATA = { + "metadata_version": "1", + "backup_id": "23e64aec", + "backup_metadata": dumps(TEST_BACKUP.as_dict()), +} diff --git a/tests/components/azure_storage/test_backup.py b/tests/components/azure_storage/test_backup.py new file mode 100644 index 00000000000..4dc1de0a26e --- /dev/null +++ b/tests/components/azure_storage/test_backup.py @@ -0,0 +1,317 @@ +"""Test the backups for OneDrive.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from io import StringIO +from unittest.mock import ANY, Mock, patch + +from azure.core.exceptions import HttpResponseError +from azure.storage.blob import BlobProperties +import pytest + +from homeassistant.components.azure_storage.backup import ( + async_register_backup_agents_listener, +) +from homeassistant.components.azure_storage.const import ( + DATA_BACKUP_AGENT_LISTENERS, + DOMAIN, +) +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import setup_integration +from .const import BACKUP_METADATA, TEST_BACKUP + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_backup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> AsyncGenerator[None]: + """Set up onedrive integration.""" + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + await setup_integration(hass, mock_config_entry) + + await hass.async_block_till_done() + yield + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + { + "agent_id": f"{DOMAIN}.{mock_config_entry.entry_id}", + "name": mock_config_entry.title, + }, + ], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent list backups.""" + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": [], + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "failed_agent_ids": [], + "extra_metadata": {}, + "with_automatic_settings": None, + } + ] + + +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent get backup.""" + + backup_id = TEST_BACKUP.backup_id + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == { + "addons": [], + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "extra_metadata": {}, + "name": "Core 2024.12.0.dev0", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + + +async def test_agents_get_backup_does_not_throw_on_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent get backup does not throw on a backup not found.""" + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": "random"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] is None + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock_client.delete_blob.assert_called_once() + + +async def test_agents_delete_not_throwing_on_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, +) -> None: + """Test agent delete backup does not throw on a backup not found.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": "random", + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + assert mock_client.delete_blob.call_count == 0 + + +async def test_agents_upload( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + 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=[b"test", b""]) + fetch_backup.return_value = TEST_BACKUP + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {TEST_BACKUP.backup_id}" in caplog.text + mock_client.upload_blob.assert_called_once_with( + name="Core_2024.12.0.dev0_2024-11-22_11.48_48727189.tar", + metadata=BACKUP_METADATA, + data=ANY, + length=ANY, + ) + + +async def test_agents_download( + hass_client: ClientSessionGenerator, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + mock_client.download_blob.assert_called_once() + + +async def test_agents_error_on_download_not_found( + hass_client: ClientSessionGenerator, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + + async def async_list_blobs( + metadata: dict[str, str], + ) -> AsyncGenerator[BlobProperties]: + yield BlobProperties(metadata=metadata) + + mock_client.list_blobs.side_effect = [ + async_list_blobs(BACKUP_METADATA), + async_list_blobs({}), + ] + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 404 + assert mock_client.download_blob.call_count == 0 + + +async def test_error_during_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the error wrapper.""" + mock_client.delete_blob.side_effect = HttpResponseError("Failed to delete backup") + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": { + f"{DOMAIN}.{mock_config_entry.entry_id}": ( + "Error during backup operation in async_delete_backup: " + "Status None, message: Failed to delete backup" + ) + } + } + + +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) + + hass.data[DATA_BACKUP_AGENT_LISTENERS] = [ + listener + ] # make sure it's the last listener + remove_listener() + + assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None diff --git a/tests/components/azure_storage/test_config_flow.py b/tests/components/azure_storage/test_config_flow.py new file mode 100644 index 00000000000..ed8bbed0718 --- /dev/null +++ b/tests/components/azure_storage/test_config_flow.py @@ -0,0 +1,113 @@ +"""Test the Azure storage config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError +import pytest + +from homeassistant.components.azure_storage.const import ( + CONF_ACCOUNT_NAME, + CONF_CONTAINER_NAME, + CONF_STORAGE_ACCOUNT_KEY, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import USER_INPUT + +from tests.common import MockConfigEntry + + +async def __async_start_flow( + hass: HomeAssistant, +) -> ConfigFlowResult: + """Initialize the config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + + return await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + +async def test_flow( + hass: HomeAssistant, + mock_client: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test config flow.""" + mock_client.exists.return_value = False + result = await __async_start_flow(hass) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["title"] + == f"{USER_INPUT[CONF_ACCOUNT_NAME]}/{USER_INPUT[CONF_CONTAINER_NAME]}" + ) + assert result["data"] == { + CONF_ACCOUNT_NAME: "account", + CONF_CONTAINER_NAME: "container1", + CONF_STORAGE_ACCOUNT_KEY: "test", + } + + +@pytest.mark.parametrize( + ("exception", "errors"), + [ + (ResourceNotFoundError, {"base": "cannot_connect"}), + (ClientAuthenticationError, {CONF_STORAGE_ACCOUNT_KEY: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_flow_errors( + hass: HomeAssistant, + mock_client: MagicMock, + mock_setup_entry: AsyncMock, + exception: Exception, + errors: dict[str, str], +) -> None: + """Test config flow errors.""" + mock_client.exists.side_effect = exception + + result = await __async_start_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == errors + + # fix and finish the test + mock_client.exists.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["title"] + == f"{USER_INPUT[CONF_ACCOUNT_NAME]}/{USER_INPUT[CONF_CONTAINER_NAME]}" + ) + assert result["data"] == { + CONF_ACCOUNT_NAME: "account", + CONF_CONTAINER_NAME: "container1", + CONF_STORAGE_ACCOUNT_KEY: "test", + } + + +async def test_abort_if_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort if the account is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await __async_start_flow(hass) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/azure_storage/test_init.py b/tests/components/azure_storage/test_init.py new file mode 100644 index 00000000000..ca725134737 --- /dev/null +++ b/tests/components/azure_storage/test_init.py @@ -0,0 +1,54 @@ +"""Test the Azure storage integration.""" + +from unittest.mock import MagicMock + +from azure.core.exceptions import ( + ClientAuthenticationError, + HttpResponseError, + ResourceNotFoundError, +) +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("exception", "state"), + [ + (ClientAuthenticationError, ConfigEntryState.SETUP_ERROR), + (HttpResponseError, ConfigEntryState.SETUP_RETRY), + (ResourceNotFoundError, ConfigEntryState.SETUP_ERROR), + ], +) +async def test_setup_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test various setup errors.""" + mock_client.exists.side_effect = exception() + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is state From a1076300c88ea56833c67e2fc730dc98a3f40ac4 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 24 Feb 2025 21:03:21 +0100 Subject: [PATCH 2699/2987] Bump onedrive quality scale to platinum (#137451) --- homeassistant/components/onedrive/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 698bc7f5ca4..5ab16402cb8 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["onedrive-personal-sdk==0.0.11"] } From 33c9f3cc7d5a40678b76971e7ced738f5f9079a7 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 24 Feb 2025 21:09:17 +0100 Subject: [PATCH 2700/2987] Bump pyloadapi to v1.4.2 (#139140) --- homeassistant/components/pyload/__init__.py | 2 +- homeassistant/components/pyload/button.py | 2 +- homeassistant/components/pyload/config_flow.py | 3 +-- homeassistant/components/pyload/manifest.json | 2 +- homeassistant/components/pyload/switch.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py index 8251722de50..cf8e922d70e 100644 --- a/homeassistant/components/pyload/__init__.py +++ b/homeassistant/components/pyload/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from aiohttp import CookieJar -from pyloadapi.api import PyLoadAPI +from pyloadapi import PyLoadAPI from homeassistant.const import ( CONF_HOST, diff --git a/homeassistant/components/pyload/button.py b/homeassistant/components/pyload/button.py index 6303ced09f0..5ee10a327d1 100644 --- a/homeassistant/components/pyload/button.py +++ b/homeassistant/components/pyload/button.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from enum import StrEnum from typing import Any -from pyloadapi.api import CannotConnect, InvalidAuth, PyLoadAPI +from pyloadapi import CannotConnect, InvalidAuth, PyLoadAPI from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index b9bfc579cfc..bc3bbc6cb34 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -7,8 +7,7 @@ import logging from typing import Any from aiohttp import CookieJar -from pyloadapi.api import PyLoadAPI -from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError +from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/pyload/manifest.json b/homeassistant/components/pyload/manifest.json index 4490057c8e0..134865b9d93 100644 --- a/homeassistant/components/pyload/manifest.json +++ b/homeassistant/components/pyload/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["pyloadapi"], - "requirements": ["PyLoadAPI==1.4.1"] + "requirements": ["PyLoadAPI==1.4.2"] } diff --git a/homeassistant/components/pyload/switch.py b/homeassistant/components/pyload/switch.py index 57160cbf5c1..46a54451b9a 100644 --- a/homeassistant/components/pyload/switch.py +++ b/homeassistant/components/pyload/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from enum import StrEnum from typing import Any -from pyloadapi.api import CannotConnect, InvalidAuth, PyLoadAPI +from pyloadapi import CannotConnect, InvalidAuth, PyLoadAPI from homeassistant.components.switch import ( SwitchDeviceClass, diff --git a/requirements_all.txt b/requirements_all.txt index 3b80e4f78a6..d0e098a6a0b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -57,7 +57,7 @@ PyFlume==0.6.5 PyFronius==0.7.3 # homeassistant.components.pyload -PyLoadAPI==1.4.1 +PyLoadAPI==1.4.2 # homeassistant.components.met_eireann PyMetEireann==2024.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ec3192285d..10c18f61725 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -54,7 +54,7 @@ PyFlume==0.6.5 PyFronius==0.7.3 # homeassistant.components.pyload -PyLoadAPI==1.4.1 +PyLoadAPI==1.4.2 # homeassistant.components.met_eireann PyMetEireann==2024.11.0 From 72f690d68163d55d0ff624d021a9eecffdf36ab3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Feb 2025 21:34:41 +0100 Subject: [PATCH 2701/2987] Add missing translations to switchbot (#139212) --- homeassistant/components/switchbot/strings.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 9c101204dcb..c9f93cce604 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -70,6 +70,10 @@ "data": { "retry_count": "Retry count", "lock_force_nightlatch": "Force Nightlatch operation mode" + }, + "data_description": { + "retry_count": "How many times to retry sending commands to your SwitchBot devices", + "lock_force_nightlatch": "Force Nightlatch operation mode even if Nightlatch is not detected" } } } From b662d32e44e1ed4ccef75eb8b82cf58797f1166f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Feb 2025 22:19:18 +0100 Subject: [PATCH 2702/2987] Fix bug in check_translations fixture (#139206) * Fix bug in check_translations fixture * Fix check for ignored translation errors * Fix websocket_api test --- tests/components/conftest.py | 7 +++++-- tests/components/websocket_api/test_commands.py | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index dd6776a1cad..cf10e2b8dfd 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -624,7 +624,8 @@ async def _validate_translation( if not translation_required: return - if full_key in translation_errors: + if translation_errors.get(full_key) in {"used", "unused"}: + # This translation key is in the ignore list, mark it as used translation_errors[full_key] = "used" return @@ -864,6 +865,7 @@ async def check_translations( if not isinstance(ignore_translations, list): ignore_translations = [ignore_translations] + # Set all ignored translation keys to "unused" translation_errors = {k: "unused" for k in ignore_translations} translation_coros = set() @@ -945,10 +947,11 @@ async def check_translations( # Run final checks unused_ignore = [k for k, v in translation_errors.items() if v == "unused"] if unused_ignore: + # Some ignored translations were not used pytest.fail( f"Unused ignore translations: {', '.join(unused_ignore)}. " "Please remove them from the ignore_translations fixture." ) for description in translation_errors.values(): - if description not in {"used", "unused"}: + if description != "used": pytest.fail(description) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 2ddb5c628c7..baa939c411b 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -540,6 +540,10 @@ async def test_call_service_schema_validation_error( assert len(calls) == 0 +@pytest.mark.parametrize( + "ignore_translations", + ["component.test.exceptions.custom_error.message"], +) async def test_call_service_error( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: From b86bb75e5ec605f07b474506ce86769979ac85ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Mon, 24 Feb 2025 23:25:24 +0100 Subject: [PATCH 2703/2987] Add missing exception translation to Home Connect (#139218) Add missing exception translation --- homeassistant/components/home_connect/__init__.py | 6 +++++- homeassistant/components/home_connect/strings.json | 3 +++ tests/components/home_connect/test_init.py | 4 +--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 51b38bf7cd3..405606c6159 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -213,7 +213,11 @@ async def _get_client_and_ha_id( break if entry is None: raise ServiceValidationError( - "Home Connect config entry not found for that device id" + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + translation_placeholders={ + "device_id": device_id, + }, ) ha_id = next( diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 977ad1f36f0..5072bb616dd 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -33,6 +33,9 @@ "appliance_not_found": { "message": "Appliance for device ID {device_id} not found" }, + "config_entry_not_found": { + "message": "Config entry for device ID {device_id} not found" + }, "turn_on_light": { "message": "Error turning on {entity_id}: {error}" }, diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 06498f891db..6e4e428bf6a 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -589,9 +589,7 @@ async def test_services_appliance_not_found( ) service_call["service_data"]["device_id"] = device_entry.id - with pytest.raises( - ServiceValidationError, match=r"Home Connect config entry.*not found" - ): + with pytest.raises(ServiceValidationError, match=r"Config entry.*not found"): await hass.services.async_call(**service_call) device_entry = device_registry.async_get_or_create( From 597c0ab9854c29054aa92a10421755917f224ecf Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 25 Feb 2025 02:05:30 +0100 Subject: [PATCH 2704/2987] Configure trusted publishing for PyPI file upload (#137607) --- .github/workflows/builder.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 88f6f37d6d6..68581c58d24 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -448,6 +448,9 @@ jobs: environment: ${{ needs.init.outputs.channel }} needs: ["init", "build_base"] runs-on: ubuntu-latest + permissions: + contents: read + id-token: write if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository @@ -473,16 +476,13 @@ jobs: run: | # Remove dist, build, and homeassistant.egg-info # when build locally for testing! - pip install twine build + pip install build python -m build - - name: Upload package - shell: bash - run: | - export TWINE_USERNAME="__token__" - export TWINE_PASSWORD="${{ secrets.TWINE_TOKEN }}" - - twine upload dist/* --skip-existing + - name: Upload package to PyPI + uses: pypa/gh-action-pypi-publish@v1.12.4 + with: + skip-existing: true hassfest-image: name: Build and test hassfest image From c115a7f455b4a5873e8cae767a76bf01789d7394 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Mon, 24 Feb 2025 20:20:48 -0500 Subject: [PATCH 2705/2987] Bump aiostreammagic to 2.11.0 (#139213) --- homeassistant/components/cambridge_audio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json index 14a389587d2..88d28e256aa 100644 --- a/homeassistant/components/cambridge_audio/manifest.json +++ b/homeassistant/components/cambridge_audio/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["aiostreammagic"], "quality_scale": "platinum", - "requirements": ["aiostreammagic==2.10.0"], + "requirements": ["aiostreammagic==2.11.0"], "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index d0e098a6a0b..f18deb65b35 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -386,7 +386,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.1 # homeassistant.components.cambridge_audio -aiostreammagic==2.10.0 +aiostreammagic==2.11.0 # homeassistant.components.switcher_kis aioswitcher==6.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10c18f61725..a449ef121e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -368,7 +368,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.1 # homeassistant.components.cambridge_audio -aiostreammagic==2.10.0 +aiostreammagic==2.11.0 # homeassistant.components.switcher_kis aioswitcher==6.0.0 From 54843bb4223804388c2557fad4ad6480487e03a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 25 Feb 2025 02:21:25 +0100 Subject: [PATCH 2706/2987] Add missing exception translation to Home Connect (#139223) --- homeassistant/components/home_connect/__init__.py | 8 +++++++- homeassistant/components/home_connect/strings.json | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 405606c6159..3e1bd1da156 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -203,7 +203,13 @@ async def _get_client_and_ha_id( device_registry = dr.async_get(hass) device_entry = device_registry.async_get(device_id) if device_entry is None: - raise ServiceValidationError("Device entry not found for device id") + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_entry_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) entry: HomeConnectConfigEntry | None = None for entry_id in device_entry.config_entries: _entry = hass.config_entries.async_get_entry(entry_id) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 5072bb616dd..672ad364365 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -33,6 +33,9 @@ "appliance_not_found": { "message": "Appliance for device ID {device_id} not found" }, + "device_entry_not_found": { + "message": "Device entry for device ID {device_id} not found" + }, "config_entry_not_found": { "message": "Config entry for device ID {device_id} not found" }, From 212c42ca77d987b5f0dee4536e3c04a92915a9b1 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Tue, 25 Feb 2025 01:25:31 +0000 Subject: [PATCH 2707/2987] Bump ohmepy to 1.3.2 (#139013) --- homeassistant/components/ohme/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index c1ca2bac62f..fb11fa0dd06 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.3.0"] + "requirements": ["ohme==1.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index f18deb65b35..6683ea5909b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1553,7 +1553,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.3.0 +ohme==1.3.2 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a449ef121e4..26689bfc459 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1301,7 +1301,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.3.0 +ohme==1.3.2 # homeassistant.components.ollama ollama==0.4.7 From 24bb13e0d173beb78aecaa8e1dc67a45ff7107f4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Feb 2025 09:13:10 +0100 Subject: [PATCH 2708/2987] Fix kitchen_sink statistic issues (#139228) --- .../components/kitchen_sink/__init__.py | 8 +-- .../kitchen_sink/snapshots/test_init.ambr | 52 +++++++++++++++++++ tests/components/kitchen_sink/test_init.py | 20 +++++++ 3 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 tests/components/kitchen_sink/snapshots/test_init.ambr diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index eff1a1ba8b2..de8e521f0e8 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -296,7 +296,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: metadata = { "source": RECORDER_DOMAIN, "name": None, - "statistic_id": "sensor.statistics_issue_1", + "statistic_id": "sensor.statistics_issues_issue_1", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, "has_mean": True, "has_sum": False, @@ -308,7 +308,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: metadata = { "source": RECORDER_DOMAIN, "name": None, - "statistic_id": "sensor.statistics_issue_2", + "statistic_id": "sensor.statistics_issues_issue_2", "unit_of_measurement": "cats", "has_mean": True, "has_sum": False, @@ -320,7 +320,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: metadata = { "source": RECORDER_DOMAIN, "name": None, - "statistic_id": "sensor.statistics_issue_3", + "statistic_id": "sensor.statistics_issues_issue_3", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, "has_mean": True, "has_sum": False, @@ -332,7 +332,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: metadata = { "source": RECORDER_DOMAIN, "name": None, - "statistic_id": "sensor.statistics_issue_4", + "statistic_id": "sensor.statistics_issues_issue_4", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, "has_mean": True, "has_sum": False, diff --git a/tests/components/kitchen_sink/snapshots/test_init.ambr b/tests/components/kitchen_sink/snapshots/test_init.ambr new file mode 100644 index 00000000000..b91131eb2b0 --- /dev/null +++ b/tests/components/kitchen_sink/snapshots/test_init.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_statistics_issues + dict({ + 'sensor.statistics_issues_issue_1': list([ + dict({ + 'data': dict({ + 'metadata_unit': 'm³', + 'state_unit': 'W', + 'statistic_id': 'sensor.statistics_issues_issue_1', + 'supported_unit': 'CCF, L, fl. oz., ft³, gal, mL, m³', + }), + 'type': 'units_changed', + }), + ]), + 'sensor.statistics_issues_issue_2': list([ + dict({ + 'data': dict({ + 'metadata_unit': 'cats', + 'state_unit': 'dogs', + 'statistic_id': 'sensor.statistics_issues_issue_2', + 'supported_unit': 'cats', + }), + 'type': 'units_changed', + }), + ]), + 'sensor.statistics_issues_issue_3': list([ + dict({ + 'data': dict({ + 'statistic_id': 'sensor.statistics_issues_issue_3', + }), + 'type': 'state_class_removed', + }), + dict({ + 'data': dict({ + 'metadata_unit': 'm³', + 'state_unit': 'W', + 'statistic_id': 'sensor.statistics_issues_issue_3', + 'supported_unit': 'CCF, L, fl. oz., ft³, gal, mL, m³', + }), + 'type': 'units_changed', + }), + ]), + 'sensor.statistics_issues_issue_4': list([ + dict({ + 'data': dict({ + 'statistic_id': 'sensor.statistics_issues_issue_4', + }), + 'type': 'no_state', + }), + ]), + }) +# --- diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index 7338c1dca99..50518f89107 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -5,6 +5,7 @@ from http import HTTPStatus from unittest.mock import ANY import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components.kitchen_sink import DOMAIN @@ -102,6 +103,25 @@ async def test_demo_statistics_growth(hass: HomeAssistant) -> None: assert statistics[statistic_id][0]["sum"] <= (2**20 + 24) +@pytest.mark.usefixtures("recorder_mock", "mock_history") +async def test_statistics_issues( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test that the kitchen sink sum statistics causes statistics issues.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + await hass.async_start() + await async_wait_recording_done(hass) + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": "recorder/validate_statistics"}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == snapshot + + @pytest.mark.freeze_time("2023-10-21") @pytest.mark.usefixtures("mock_history") async def test_issues_created( From 6342d8334bf6eb94eadd7c2f40b8bb06933744dd Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 25 Feb 2025 09:18:41 +0100 Subject: [PATCH 2709/2987] Bump aiowebdav2 to 0.3.0 (#139202) --- homeassistant/components/webdav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index a1ac779afc8..75a8d7ddfe2 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.2.2"] + "requirements": ["aiowebdav2==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6683ea5909b..7d8952bdb9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.2.2 +aiowebdav2==0.3.0 # homeassistant.components.webostv aiowebostv==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 26689bfc459..c1bd76b715b 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.webdav -aiowebdav2==0.2.2 +aiowebdav2==0.3.0 # homeassistant.components.webostv aiowebostv==0.7.0 From c386abd49dc4bd8decc0e716850361e739fe53cc Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 25 Feb 2025 09:32:06 +0100 Subject: [PATCH 2710/2987] Bump pylamarzocco to 1.4.7 (#139231) --- homeassistant/components/lamarzocco/binary_sensor.py | 2 +- homeassistant/components/lamarzocco/manifest.json | 2 +- homeassistant/components/lamarzocco/number.py | 3 ++- homeassistant/components/lamarzocco/select.py | 3 ++- homeassistant/components/lamarzocco/sensor.py | 8 +++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py index 39bd5d4b954..a98cddcda9c 100644 --- a/homeassistant/components/lamarzocco/binary_sensor.py +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -83,7 +83,7 @@ async def async_setup_entry( ] if ( - coordinator.device.model == MachineModel.LINEA_MINI + coordinator.device.model in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) and coordinator.device.config.scale ): entities.extend( diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index afd367b0f6e..eceb2bbf53b 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_polling", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==1.4.6"] + "requirements": ["pylamarzocco==1.4.7"] } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 3b3d569a6f7..666c57c1866 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -220,7 +220,8 @@ SCALE_KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( config.bbw_settings.doses[key] if config.bbw_settings else None ), supported_fn=( - lambda coordinator: coordinator.device.model == MachineModel.LINEA_MINI + lambda coordinator: coordinator.device.model + in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) and coordinator.device.config.scale is not None ), ), diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py index bd6ac1ee04f..d8217cefaff 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -88,6 +88,7 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( MachineModel.GS3_AV, MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI, + MachineModel.LINEA_MINI_R, ), ), LaMarzoccoSelectEntityDescription( @@ -138,7 +139,7 @@ async def async_setup_entry( ] if ( - coordinator.device.model == MachineModel.LINEA_MINI + coordinator.device.model in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) and coordinator.device.config.scale ): entities.extend( diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 6287ea91a40..0d4a5e53ebe 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -80,7 +80,7 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( BoilerType.STEAM ].current_temperature, supported_fn=lambda coordinator: coordinator.device.model - != MachineModel.LINEA_MINI, + not in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R), ), ) @@ -125,7 +125,8 @@ SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( device.config.scale.battery if device.config.scale else 0 ), supported_fn=( - lambda coordinator: coordinator.device.model == MachineModel.LINEA_MINI + lambda coordinator: coordinator.device.model + in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) ), ), ) @@ -148,7 +149,8 @@ async def async_setup_entry( ] if ( - config_coordinator.device.model == MachineModel.LINEA_MINI + config_coordinator.device.model + in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) and config_coordinator.device.config.scale ): entities.extend( diff --git a/requirements_all.txt b/requirements_all.txt index 7d8952bdb9d..d239ac021f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2077,7 +2077,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==1.4.6 +pylamarzocco==1.4.7 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c1bd76b715b..b770f80c3f1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1691,7 +1691,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.2 # homeassistant.components.lamarzocco -pylamarzocco==1.4.6 +pylamarzocco==1.4.7 # homeassistant.components.lastfm pylast==5.1.0 From bf190a8a73724e82e0acfb404291c8867256ff13 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Feb 2025 10:19:41 +0100 Subject: [PATCH 2711/2987] Add backup helper (#139199) * Add backup helper * Add hassio to stage 1 * Apply same changes to newly merged `webdav` and `azure_storage` to fix inflight conflict * Address comments, add tests --------- Co-authored-by: J. Nick Koston --- homeassistant/bootstrap.py | 17 ++--- homeassistant/components/backup/__init__.py | 27 +++---- .../components/backup/basic_websocket.py | 38 ++++++++++ homeassistant/components/backup/manager.py | 18 ++--- homeassistant/components/backup/websocket.py | 26 +------ .../components/frontend/manifest.json | 1 - homeassistant/components/hassio/backup.py | 4 +- .../components/onboarding/manifest.json | 1 - homeassistant/components/onboarding/views.py | 4 +- homeassistant/helpers/backup.py | 70 +++++++++++++++++++ script/hassfest/dependencies.py | 4 ++ tests/components/azure_storage/test_backup.py | 2 + tests/components/backup/common.py | 2 + .../backup/snapshots/test_websocket.ambr | 17 +++++ tests/components/backup/test_backup.py | 4 ++ tests/components/backup/test_websocket.py | 25 +++++++ tests/components/cloud/test_backup.py | 4 +- tests/components/google_drive/test_backup.py | 4 +- tests/components/hassio/test_backup.py | 8 +++ tests/components/hassio/test_update.py | 23 +++--- tests/components/hassio/test_websocket_api.py | 23 +++--- tests/components/kitchen_sink/test_backup.py | 4 +- tests/components/onboarding/test_views.py | 6 ++ tests/components/onedrive/test_backup.py | 4 +- tests/components/synology_dsm/test_backup.py | 5 +- tests/components/webdav/test_backup.py | 2 + tests/helpers/test_backup.py | 42 +++++++++++ 27 files changed, 289 insertions(+), 96 deletions(-) create mode 100644 homeassistant/components/backup/basic_websocket.py create mode 100644 homeassistant/helpers/backup.py create mode 100644 tests/helpers/test_backup.py diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 9cfc1c95d8b..e25bfbe358c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -74,6 +74,7 @@ from .core_config import async_process_ha_core_config from .exceptions import HomeAssistantError from .helpers import ( area_registry, + backup, category_registry, config_validation as cv, device_registry, @@ -163,16 +164,6 @@ FRONTEND_INTEGRATIONS = { # integrations can be removed and database migration status is # visible in frontend "frontend", - # Hassio is an after dependency of backup, after dependencies - # are not promoted from stage 2 to earlier stages, so we need to - # add it here. Hassio needs to be setup before backup, otherwise - # the backup integration will think we are a container/core install - # when using HAOS or Supervised install. - "hassio", - # Backup is an after dependency of frontend, after dependencies - # are not promoted from stage 2 to earlier stages, so we need to - # add it here. - "backup", } # 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. @@ -206,6 +197,8 @@ STAGE_1_INTEGRATIONS = { "mqtt_eventstream", # To provide account link implementations "cloud", + # Ensure supervisor is available + "hassio", } DEFAULT_INTEGRATIONS = { @@ -905,6 +898,10 @@ async def _async_set_up_integrations( if "recorder" in domains_to_setup: recorder.async_initialize_recorder(hass) + # Initialize backup + if "backup" in domains_to_setup: + backup.async_initialize_backup(hass) + stage_0_and_1_domains: list[tuple[str, set[str], int | None]] = [ *( (name, domain_group & domains_to_setup, timeout) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index a5159086945..d9d1c3cc2fe 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -1,8 +1,8 @@ """The Backup integration.""" -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.backup import DATA_BACKUP from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.typing import ConfigType @@ -32,6 +32,7 @@ from .manager import ( IdleEvent, IncorrectPasswordError, ManagerBackup, + ManagerStateEvent, NewBackup, RestoreBackupEvent, RestoreBackupStage, @@ -63,12 +64,12 @@ __all__ = [ "IncorrectPasswordError", "LocalBackupAgent", "ManagerBackup", + "ManagerStateEvent", "NewBackup", "RestoreBackupEvent", "RestoreBackupStage", "RestoreBackupState", "WrittenBackup", - "async_get_manager", "suggested_filename", "suggested_filename_from_name_date", ] @@ -91,7 +92,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: backup_manager = BackupManager(hass, reader_writer) hass.data[DATA_MANAGER] = backup_manager - await backup_manager.async_setup() + try: + await backup_manager.async_setup() + except Exception as err: + hass.data[DATA_BACKUP].manager_ready.set_exception(err) + raise + else: + hass.data[DATA_BACKUP].manager_ready.set_result(None) async_register_websocket_handlers(hass, with_hassio) @@ -122,15 +129,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_http_views(hass) return True - - -@callback -def async_get_manager(hass: HomeAssistant) -> BackupManager: - """Get the backup manager instance. - - Raises HomeAssistantError if the backup integration is not available. - """ - if DATA_MANAGER not in hass.data: - raise HomeAssistantError("Backup integration is not available") - - return hass.data[DATA_MANAGER] diff --git a/homeassistant/components/backup/basic_websocket.py b/homeassistant/components/backup/basic_websocket.py new file mode 100644 index 00000000000..614dc23a927 --- /dev/null +++ b/homeassistant/components/backup/basic_websocket.py @@ -0,0 +1,38 @@ +"""Websocket commands for the Backup integration.""" + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.backup import async_subscribe_events + +from .const import DATA_MANAGER +from .manager import ManagerStateEvent + + +@callback +def async_register_websocket_handlers(hass: HomeAssistant) -> None: + """Register websocket commands.""" + websocket_api.async_register_command(hass, handle_subscribe_events) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"}) +@websocket_api.async_response +async def handle_subscribe_events( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Subscribe to backup events.""" + + def on_event(event: ManagerStateEvent) -> None: + connection.send_message(websocket_api.event_message(msg["id"], event)) + + if DATA_MANAGER in hass.data: + manager = hass.data[DATA_MANAGER] + on_event(manager.last_event) + connection.subscriptions[msg["id"]] = async_subscribe_events(hass, on_event) + connection.send_result(msg["id"]) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 0f79cd79e0c..3bf31618b24 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -33,6 +33,7 @@ from homeassistant.helpers import ( integration_platform, issue_registry as ir, ) +from homeassistant.helpers.backup import DATA_BACKUP from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util, json as json_util @@ -332,7 +333,9 @@ class BackupManager: # Latest backup event and backup event subscribers self.last_event: ManagerStateEvent = IdleEvent() self.last_non_idle_event: ManagerStateEvent | None = None - self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = [] + self._backup_event_subscriptions = hass.data[ + DATA_BACKUP + ].backup_event_subscriptions async def async_setup(self) -> None: """Set up the backup manager.""" @@ -1279,19 +1282,6 @@ class BackupManager: for subscription in self._backup_event_subscriptions: subscription(event) - @callback - def async_subscribe_events( - self, - on_event: Callable[[ManagerStateEvent], None], - ) -> Callable[[], None]: - """Subscribe events.""" - - def remove_subscription() -> None: - self._backup_event_subscriptions.remove(on_event) - - self._backup_event_subscriptions.append(on_event) - return remove_subscription - def _update_issue_backup_failed(self) -> None: """Update issue registry when a backup fails.""" ir.async_create_issue( diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 5084f904ec6..8b5f35287dd 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -10,11 +10,7 @@ from homeassistant.helpers import config_validation as cv from .config import Day, ScheduleRecurrence from .const import DATA_MANAGER, LOGGER -from .manager import ( - DecryptOnDowloadNotSupported, - IncorrectPasswordError, - ManagerStateEvent, -) +from .manager import DecryptOnDowloadNotSupported, IncorrectPasswordError from .models import BackupNotFound, Folder @@ -34,7 +30,6 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> websocket_api.async_register_command(hass, handle_create_with_automatic_settings) websocket_api.async_register_command(hass, handle_delete) websocket_api.async_register_command(hass, handle_restore) - websocket_api.async_register_command(hass, handle_subscribe_events) websocket_api.async_register_command(hass, handle_config_info) websocket_api.async_register_command(hass, handle_config_update) @@ -401,22 +396,3 @@ def handle_config_update( changes.pop("type") manager.config.update(**changes) connection.send_result(msg["id"]) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"}) -@websocket_api.async_response -async def handle_subscribe_events( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Subscribe to backup events.""" - - def on_event(event: ManagerStateEvent) -> None: - connection.send_message(websocket_api.event_message(msg["id"], event)) - - manager = hass.data[DATA_MANAGER] - on_event(manager.last_event) - connection.subscriptions[msg["id"]] = manager.async_subscribe_events(on_event) - connection.send_result(msg["id"]) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 499e1fbddb2..b13b33685d5 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -1,7 +1,6 @@ { "domain": "frontend", "name": "Home Assistant Frontend", - "after_dependencies": ["backup"], "codeowners": ["@home-assistant/frontend"], "dependencies": [ "api", diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index e7d169c142c..fe69b9e08e5 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -45,13 +45,13 @@ from homeassistant.components.backup import ( RestoreBackupStage, RestoreBackupState, WrittenBackup, - async_get_manager as async_get_backup_manager, suggested_filename as suggested_backup_filename, suggested_filename_from_name_date, ) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum @@ -751,7 +751,7 @@ async def backup_addon_before_update( async def backup_core_before_update(hass: HomeAssistant) -> None: """Prepare for updating core.""" - backup_manager = async_get_backup_manager(hass) + backup_manager = await async_get_backup_manager(hass) client = get_supervisor_client(hass) try: diff --git a/homeassistant/components/onboarding/manifest.json b/homeassistant/components/onboarding/manifest.json index 3634894cd00..a4cf814eb2a 100644 --- a/homeassistant/components/onboarding/manifest.json +++ b/homeassistant/components/onboarding/manifest.json @@ -1,7 +1,6 @@ { "domain": "onboarding", "name": "Home Assistant Onboarding", - "after_dependencies": ["backup"], "codeowners": ["@home-assistant/core"], "dependencies": ["auth", "http", "person"], "documentation": "https://www.home-assistant.io/integrations/onboarding", diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index b392c6b57b0..a590588c009 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -20,7 +20,6 @@ from homeassistant.components.backup import ( BackupManager, Folder, IncorrectPasswordError, - async_get_manager as async_get_backup_manager, http as backup_http, ) from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID @@ -29,6 +28,7 @@ from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar +from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager 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 @@ -341,7 +341,7 @@ def with_backup_manager[_ViewT: BackupOnboardingView, **_P]( raise HTTPUnauthorized try: - manager = async_get_backup_manager(request.app[KEY_HASS]) + manager = await async_get_backup_manager(request.app[KEY_HASS]) except HomeAssistantError: return self.json( {"code": "backup_disabled"}, diff --git a/homeassistant/helpers/backup.py b/homeassistant/helpers/backup.py new file mode 100644 index 00000000000..4ab302749a1 --- /dev/null +++ b/homeassistant/helpers/backup.py @@ -0,0 +1,70 @@ +"""Helpers for the backup integration.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.components.backup import BackupManager, ManagerStateEvent + +DATA_BACKUP: HassKey[BackupData] = HassKey("backup_data") +DATA_MANAGER: HassKey[BackupManager] = HassKey("backup") + + +@dataclass(slots=True) +class BackupData: + """Backup data stored in hass.data.""" + + backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = field( + default_factory=list + ) + manager_ready: asyncio.Future[None] = field(default_factory=asyncio.Future) + + +@callback +def async_initialize_backup(hass: HomeAssistant) -> None: + """Initialize backup data. + + This creates the BackupData instance stored in hass.data[DATA_BACKUP] and + registers the basic backup websocket API which is used by frontend to subscribe + to backup events. + """ + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.backup import basic_websocket + + hass.data[DATA_BACKUP] = BackupData() + basic_websocket.async_register_websocket_handlers(hass) + + +async def async_get_manager(hass: HomeAssistant) -> BackupManager: + """Get the backup manager instance. + + Raises HomeAssistantError if the backup integration is not available. + """ + if DATA_BACKUP not in hass.data: + raise HomeAssistantError("Backup integration is not available") + + await hass.data[DATA_BACKUP].manager_ready + return hass.data[DATA_MANAGER] + + +@callback +def async_subscribe_events( + hass: HomeAssistant, + on_event: Callable[[ManagerStateEvent], None], +) -> Callable[[], None]: + """Subscribe to backup events.""" + backup_event_subscriptions = hass.data[DATA_BACKUP].backup_event_subscriptions + + def remove_subscription() -> None: + backup_event_subscriptions.remove(on_event) + + backup_event_subscriptions.append(on_event) + return remove_subscription diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index d29571eaa83..368c2f762b8 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -175,6 +175,10 @@ IGNORE_VIOLATIONS = { "logbook", # Temporary needed for migration until 2024.10 ("conversation", "assist_pipeline"), + # The onboarding integration provides a limited backup API used during + # onboarding. The onboarding integration waits for the backup manager + # to be ready before calling any backup functionality. + ("onboarding", "backup"), } diff --git a/tests/components/azure_storage/test_backup.py b/tests/components/azure_storage/test_backup.py index 4dc1de0a26e..7c5912a4981 100644 --- a/tests/components/azure_storage/test_backup.py +++ b/tests/components/azure_storage/test_backup.py @@ -19,6 +19,7 @@ from homeassistant.components.azure_storage.const import ( ) from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from . import setup_integration @@ -38,6 +39,7 @@ async def setup_backup_integration( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), ): + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {}) await setup_integration(hass, mock_config_entry) diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index b21698bf365..e41da5c1bad 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -18,6 +18,7 @@ from homeassistant.components.backup import ( ) from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from tests.common import mock_platform @@ -125,6 +126,7 @@ async def setup_backup_integration( ) -> dict[str, Mock]: """Set up the Backup integration.""" backups = backups or {} + async_initialize_backup(hass) with ( patch("homeassistant.components.backup.is_hassio", return_value=with_hassio), patch( diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index c100a87e8cc..17e3ca8b176 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -5768,3 +5768,20 @@ 'type': 'event', }) # --- +# name: test_subscribe_event_early + dict({ + 'event': dict({ + 'manager_state': 'idle', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_subscribe_event_early.1 + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index 38b61ce65ea..c9d797f4e30 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -14,6 +14,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.backup import DOMAIN, AgentBackup from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .common import ( @@ -63,6 +64,7 @@ async def test_load_backups( side_effect: Exception | None, ) -> None: """Test load backups.""" + async_initialize_backup(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_ws_client(hass) @@ -82,6 +84,7 @@ async def test_upload( hass_client: ClientSessionGenerator, ) -> None: """Test upload backup.""" + async_initialize_backup(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_client() @@ -137,6 +140,7 @@ async def test_delete_backup( unlink_path: Path | None, ) -> None: """Test delete backup.""" + async_initialize_backup(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_ws_client(hass) diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 6605674a679..9b2241882c4 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -27,6 +27,8 @@ from homeassistant.components.backup.manager import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.backup import async_initialize_backup +from homeassistant.setup import async_setup_component from .common import ( LOCAL_AGENT_ID, @@ -3264,6 +3266,29 @@ async def test_subscribe_event( assert await client.receive_json() == snapshot +async def test_subscribe_event_early( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test subscribe event before backup integration has started.""" + async_initialize_backup(hass) + await setup_backup_integration(hass, with_hassio=False) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + assert await client.receive_json() == snapshot + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + manager = hass.data[DATA_MANAGER] + + manager.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS, reason=None) + ) + assert await client.receive_json() == snapshot + + @pytest.mark.parametrize( ("agent_id", "backup_id", "password"), [ diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 18793cc00bb..5220d3eccd5 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -21,6 +21,7 @@ from homeassistant.components.cloud import DOMAIN from homeassistant.components.cloud.backup import async_register_backup_agents_listener from homeassistant.components.cloud.const import EVENT_CLOUD_EVENT from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockStreamReader @@ -44,7 +45,8 @@ async def setup_integration( cloud: MagicMock, cloud_logged_in: None, ) -> AsyncGenerator[None]: - """Set up cloud integration.""" + """Set up cloud and backup integrations.""" + async_initialize_backup(hass) with ( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 70431e2049f..2da397def5b 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -17,6 +17,7 @@ from homeassistant.components.backup import ( ) from homeassistant.components.google_drive import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .conftest import CONFIG_ENTRY_TITLE, TEST_AGENT_ID @@ -63,7 +64,8 @@ async def setup_integration( config_entry: MockConfigEntry, mock_api: MagicMock, ) -> None: - """Set up Google Drive integration.""" + """Set up Google Drive and backup integrations.""" + async_initialize_backup(hass) config_entry.add_to_hass(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) mock_api.list_files = AsyncMock( diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index c7f400cef5c..6e4fe4dd428 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -44,6 +44,7 @@ from homeassistant.components.backup import ( from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.backup import RESTORE_JOB_ID_ENV from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .test_init import MOCK_ENVIRON @@ -320,6 +321,7 @@ async def setup_backup_integration( hass: HomeAssistant, hassio_enabled: None, supervisor_client: AsyncMock ) -> None: """Set up Backup integration.""" + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await hass.async_block_till_done() @@ -432,6 +434,7 @@ async def test_agent_info( client = await hass_ws_client(hass) supervisor_client.mounts.info.return_value = mounts + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await client.send_json_auto_id({"type": "backup/agents/info"}) @@ -1287,6 +1290,7 @@ async def test_reader_writer_create_per_agent_encryption( ) supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE supervisor_client.mounts.info.return_value = mounts + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) for command in commands: @@ -2352,6 +2356,7 @@ async def test_restore_progress_after_restart( supervisor_client.jobs.get_job.return_value = get_job_result + async_initialize_backup(hass) with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -2375,6 +2380,7 @@ async def test_restore_progress_after_restart_report_progress( supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + async_initialize_backup(hass) with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -2457,6 +2463,7 @@ async def test_restore_progress_after_restart_unknown_job( supervisor_client.jobs.get_job.side_effect = SupervisorError + async_initialize_backup(hass) with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -2556,6 +2563,7 @@ async def test_config_load_config_info( hass_storage.update(storage_data) + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 83af302e1ce..a3718454538 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -18,6 +18,7 @@ from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -235,6 +236,13 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) +async def setup_backup_integration(hass: HomeAssistant) -> None: + """Set up the backup integration.""" + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + @pytest.mark.parametrize( ("commands", "default_mount", "expected_kwargs"), [ @@ -318,8 +326,7 @@ async def test_update_addon_with_backup( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) client = await hass_ws_client(hass) for command in commands: @@ -413,8 +420,7 @@ async def test_update_addon_with_backup_removes_old_backups( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) supervisor_client.mounts.info.return_value.default_backup_mount = None with ( @@ -588,8 +594,7 @@ async def test_update_core_with_backup( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) client = await hass_ws_client(hass) for command in commands: @@ -691,8 +696,7 @@ async def test_update_addon_with_backup_and_error( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) supervisor_client.homeassistant.update.return_value = None supervisor_client.mounts.info.return_value.default_backup_mount = None @@ -811,8 +815,7 @@ async def test_update_core_with_backup_and_error( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) supervisor_client.homeassistant.update.return_value = None supervisor_client.mounts.info.return_value.default_backup_mount = None diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index e752b53ae7a..b695cc1794a 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -26,6 +26,7 @@ from homeassistant.components.hassio.const import ( ) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component @@ -355,6 +356,13 @@ async def test_update_addon( update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) +async def setup_backup_integration(hass: HomeAssistant) -> None: + """Set up the backup integration.""" + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + @pytest.mark.parametrize( ("commands", "default_mount", "expected_kwargs"), [ @@ -438,8 +446,7 @@ async def test_update_addon_with_backup( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) client = await hass_ws_client(hass) for command in commands: @@ -533,8 +540,7 @@ async def test_update_addon_with_backup_removes_old_backups( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) client = await hass_ws_client(hass) supervisor_client.mounts.info.return_value.default_backup_mount = None @@ -686,8 +692,7 @@ async def test_update_core_with_backup( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) client = await hass_ws_client(hass) for command in commands: @@ -766,8 +771,7 @@ async def test_update_addon_with_backup_and_error( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) supervisor_client.homeassistant.update.return_value = None supervisor_client.mounts.info.return_value.default_backup_mount = None @@ -834,8 +838,7 @@ async def test_update_core_with_backup_and_error( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) supervisor_client.homeassistant.update.return_value = None supervisor_client.mounts.info.return_value.default_backup_mount = None diff --git a/tests/components/kitchen_sink/test_backup.py b/tests/components/kitchen_sink/test_backup.py index 7c693abcda8..933979ee913 100644 --- a/tests/components/kitchen_sink/test_backup.py +++ b/tests/components/kitchen_sink/test_backup.py @@ -15,6 +15,7 @@ from homeassistant.components.backup import ( from homeassistant.components.kitchen_sink import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import instance_id +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -35,7 +36,8 @@ async def backup_only() -> AsyncGenerator[None]: @pytest.fixture(autouse=True) async def setup_integration(hass: HomeAssistant) -> AsyncGenerator[None]: - """Set up Kitchen Sink integration.""" + """Set up Kitchen Sink and backup integrations.""" + async_initialize_backup(hass) with patch("homeassistant.components.backup.is_hassio", return_value=False): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 08d21a13331..b7189bda6cc 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -16,6 +16,7 @@ from homeassistant.components.onboarding import const, views from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from . import mock_storage @@ -765,6 +766,7 @@ async def test_onboarding_backup_info( mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -881,6 +883,7 @@ async def test_onboarding_backup_restore( mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -977,6 +980,7 @@ async def test_onboarding_backup_restore_error( mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -1020,6 +1024,7 @@ async def test_onboarding_backup_restore_unexpected_error( mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -1045,6 +1050,7 @@ async def test_onboarding_backup_upload( mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index c307e5190c1..a81eb03a51c 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -21,6 +21,7 @@ from homeassistant.components.onedrive.backup import ( 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.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from . import setup_integration @@ -35,7 +36,8 @@ async def setup_backup_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, ) -> AsyncGenerator[None]: - """Set up onedrive integration.""" + """Set up onedrive and backup integrations.""" + async_initialize_backup(hass) with ( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 8e98f4dffa9..24cfe29f52b 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -28,6 +28,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockStreamReader @@ -164,7 +165,8 @@ async def setup_dsm_with_filestation( hass: HomeAssistant, mock_dsm_with_filestation: MagicMock, ): - """Mock setup of synology dsm config entry.""" + """Mock setup of synology dsm config entry and backup integration.""" + async_initialize_backup(hass) with ( patch( "homeassistant.components.synology_dsm.common.SynologyDSM", @@ -222,6 +224,7 @@ async def test_agents_not_loaded( ) -> None: """Test backup agent with no loaded config entry.""" with patch("homeassistant.components.backup.is_hassio", return_value=False): + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/webdav/test_backup.py b/tests/components/webdav/test_backup.py index b02fb2e9628..2219e92f700 100644 --- a/tests/components/webdav/test_backup.py +++ b/tests/components/webdav/test_backup.py @@ -13,6 +13,7 @@ from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup from homeassistant.components.webdav.backup import async_register_backup_agents_listener from homeassistant.components.webdav.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .const import BACKUP_METADATA, MOCK_LIST_WITH_INFOS @@ -30,6 +31,7 @@ async def setup_backup_integration( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), ): + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {}) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/helpers/test_backup.py b/tests/helpers/test_backup.py new file mode 100644 index 00000000000..10ff5cb855f --- /dev/null +++ b/tests/helpers/test_backup.py @@ -0,0 +1,42 @@ +"""The tests for the backup helpers.""" + +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import backup as backup_helper +from homeassistant.setup import async_setup_component + + +async def test_async_get_manager(hass: HomeAssistant) -> None: + """Test async_get_manager.""" + backup_helper.async_initialize_backup(hass) + task = asyncio.create_task(backup_helper.async_get_manager(hass)) + assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + manager = await task + assert manager is hass.data[backup_helper.DATA_MANAGER] + + +async def test_async_get_manager_no_backup(hass: HomeAssistant) -> None: + """Test async_get_manager when the backup integration is not enabled.""" + with pytest.raises(HomeAssistantError, match="Backup integration is not available"): + await backup_helper.async_get_manager(hass) + + +async def test_async_get_manager_backup_failed_setup(hass: HomeAssistant) -> None: + """Test test_async_get_manager when the backup integration can't be set up.""" + backup_helper.async_initialize_backup(hass) + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_setup", + side_effect=Exception("Boom!"), + ): + assert not await async_setup_component(hass, BACKUP_DOMAIN, {}) + with ( + pytest.raises(Exception, match="Boom!"), + ): + await backup_helper.async_get_manager(hass) From d197acc0692c7cf115aa74416ba318b6a5817104 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 25 Feb 2025 11:46:40 +0100 Subject: [PATCH 2712/2987] Reduce requests made by webdav (#139238) * Reduce requests made by webdav * Update homeassistant/components/webdav/backup.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/webdav/backup.py | 70 +++++++++++++---------- tests/components/webdav/conftest.py | 19 +----- tests/components/webdav/const.py | 49 +++++----------- tests/components/webdav/test_backup.py | 38 ++++++++++-- 4 files changed, 90 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index 2c19ca450e3..a51866fde61 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -95,6 +95,23 @@ def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: return f"{base_name}.tar", f"{base_name}.metadata.json" +def _is_current_metadata_version(properties: list[Property]) -> bool: + """Check if any property is of the current metadata version.""" + return any( + prop.value == METADATA_VERSION + for prop in properties + if prop.namespace == "homeassistant" and prop.name == "metadata_version" + ) + + +def _backup_id_from_properties(properties: list[Property]) -> str | None: + """Return the backup ID from properties.""" + for prop in properties: + if prop.namespace == "homeassistant" and prop.name == "backup_id": + return prop.value + return None + + class WebDavBackupAgent(BackupAgent): """Backup agent interface.""" @@ -217,7 +234,7 @@ class WebDavBackupAgent(BackupAgent): metadata_files = await self._list_metadata_files() return [ await self._download_metadata(metadata_file) - for metadata_file in metadata_files + for metadata_file in metadata_files.values() ] @handle_backup_errors @@ -229,40 +246,33 @@ class WebDavBackupAgent(BackupAgent): """Return a backup.""" return await self._find_backup_by_id(backup_id) - async def _list_metadata_files(self) -> list[str]: + async def _list_metadata_files(self) -> dict[str, str]: """List metadata files.""" - files = await self._client.list_with_infos(self._backup_path) - return [ - file["path"] - for file in files - if file["path"].endswith(".json") - and await self._is_current_metadata_version(file["path"]) - ] - - async def _is_current_metadata_version(self, path: str) -> bool: - """Check if is current metadata version.""" - metadata_version = await self._client.get_property( - path, - PropertyRequest( - namespace="homeassistant", - name="metadata_version", - ), - ) - return metadata_version.value == METADATA_VERSION if metadata_version else False - - async def _find_backup_by_id(self, backup_id: str) -> AgentBackup | None: - """Find a backup by its backup ID on remote.""" - metadata_files = await self._list_metadata_files() - for metadata_file in metadata_files: - remote_backup_id = await self._client.get_property( - metadata_file, + files = await self._client.list_with_properties( + self._backup_path, + [ + PropertyRequest( + namespace="homeassistant", + name="metadata_version", + ), PropertyRequest( namespace="homeassistant", name="backup_id", ), - ) - if remote_backup_id and remote_backup_id.value == backup_id: - return await self._download_metadata(metadata_file) + ], + ) + return { + backup_id: file_name + for file_name, properties in files.items() + if file_name.endswith(".json") and _is_current_metadata_version(properties) + if (backup_id := _backup_id_from_properties(properties)) + } + + async def _find_backup_by_id(self, backup_id: str) -> AgentBackup | None: + """Find a backup by its backup ID on remote.""" + metadata_files = await self._list_metadata_files() + if metadata_file := metadata_files.get(backup_id): + return await self._download_metadata(metadata_file) return None diff --git a/tests/components/webdav/conftest.py b/tests/components/webdav/conftest.py index ccd3437aaa0..4fdd6fb7870 100644 --- a/tests/components/webdav/conftest.py +++ b/tests/components/webdav/conftest.py @@ -4,18 +4,12 @@ from collections.abc import AsyncIterator, Generator from json import dumps from unittest.mock import AsyncMock, patch -from aiowebdav2 import Property, PropertyRequest import pytest from homeassistant.components.webdav.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME -from .const import ( - BACKUP_METADATA, - MOCK_GET_PROPERTY_BACKUP_ID, - MOCK_GET_PROPERTY_METADATA_VERSION, - MOCK_LIST_WITH_INFOS, -) +from .const import BACKUP_METADATA, MOCK_LIST_WITH_PROPERTIES from tests.common import MockConfigEntry @@ -44,14 +38,6 @@ def mock_config_entry() -> MockConfigEntry: ) -def _get_property(path: str, request: PropertyRequest) -> Property: - """Return the property of a file.""" - if path.endswith(".json") and request.name == "metadata_version": - return MOCK_GET_PROPERTY_METADATA_VERSION - - return MOCK_GET_PROPERTY_BACKUP_ID - - async def _download_mock(path: str, timeout=None) -> AsyncIterator[bytes]: """Mock the download function.""" if path.endswith(".json"): @@ -72,9 +58,8 @@ def mock_webdav_client() -> Generator[AsyncMock]: mock = mock_webdav_client.return_value mock.check.return_value = True mock.mkdir.return_value = True - mock.list_with_infos.return_value = MOCK_LIST_WITH_INFOS + mock.list_with_properties.return_value = MOCK_LIST_WITH_PROPERTIES mock.download_iter.side_effect = _download_mock mock.upload_iter.return_value = None mock.clean.return_value = None - mock.get_property.side_effect = _get_property yield mock diff --git a/tests/components/webdav/const.py b/tests/components/webdav/const.py index 777008b07a5..52cad9a163b 100644 --- a/tests/components/webdav/const.py +++ b/tests/components/webdav/const.py @@ -16,37 +16,18 @@ BACKUP_METADATA = { "size": 34519040, } -MOCK_LIST_WITH_INFOS = [ - { - "content_type": "application/x-tar", - "created": "2025-02-10T17:47:22Z", - "etag": '"84d7d000-62dcd4ce886b4"', - "isdir": "False", - "modified": "Mon, 10 Feb 2025 17:47:22 GMT", - "name": "None", - "path": "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar", - "size": "2228736000", - }, - { - "content_type": "application/json", - "created": "2025-02-10T17:47:22Z", - "etag": '"8d0-62dcd4cec050a"', - "isdir": "False", - "modified": "Mon, 10 Feb 2025 17:47:22 GMT", - "name": "None", - "path": "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json", - "size": "2256", - }, -] - -MOCK_GET_PROPERTY_METADATA_VERSION = Property( - namespace="homeassistant", - name="metadata_version", - value="1", -) - -MOCK_GET_PROPERTY_BACKUP_ID = Property( - namespace="homeassistant", - name="backup_id", - value="23e64aec", -) +MOCK_LIST_WITH_PROPERTIES = { + "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar": [], + "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json": [ + Property( + namespace="homeassistant", + name="backup_id", + value="23e64aec", + ), + Property( + namespace="homeassistant", + name="metadata_version", + value="1", + ), + ], +} diff --git a/tests/components/webdav/test_backup.py b/tests/components/webdav/test_backup.py index 2219e92f700..c20e73cc786 100644 --- a/tests/components/webdav/test_backup.py +++ b/tests/components/webdav/test_backup.py @@ -6,6 +6,7 @@ from collections.abc import AsyncGenerator from io import StringIO from unittest.mock import Mock, patch +from aiowebdav2 import Property from aiowebdav2.exceptions import UnauthorizedError, WebDavError import pytest @@ -16,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component -from .const import BACKUP_METADATA, MOCK_LIST_WITH_INFOS +from .const import BACKUP_METADATA, MOCK_LIST_WITH_PROPERTIES from tests.common import AsyncMock, MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -210,7 +211,7 @@ async def test_error_on_agents_download( """Test we get not found on a not existing backup on download.""" client = await hass_client() backup_id = BACKUP_METADATA["backup_id"] - webdav_client.list_with_infos.side_effect = [MOCK_LIST_WITH_INFOS, []] + webdav_client.list_with_properties.side_effect = [MOCK_LIST_WITH_PROPERTIES, {}] resp = await client.get( f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" @@ -261,7 +262,7 @@ async def test_agents_delete_not_found_does_not_throw( webdav_client: AsyncMock, ) -> None: """Test agent delete backup.""" - webdav_client.list_with_infos.return_value = [] + webdav_client.list_with_properties.return_value = {} client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -282,7 +283,7 @@ async def test_agents_backup_not_found( webdav_client: AsyncMock, ) -> None: """Test backup not found.""" - webdav_client.list_with_infos.return_value = [] + webdav_client.list_with_properties.return_value = [] backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) @@ -299,7 +300,7 @@ async def test_raises_on_403( mock_config_entry: MockConfigEntry, ) -> None: """Test we raise on 403.""" - webdav_client.list_with_infos.side_effect = UnauthorizedError( + webdav_client.list_with_properties.side_effect = UnauthorizedError( "https://webdav.example.com" ) backup_id = BACKUP_METADATA["backup_id"] @@ -323,3 +324,30 @@ async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: remove_listener() assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None + + +async def test_metadata_misses_backup_id( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test getting a backup when metadata has backup id property.""" + MOCK_LIST_WITH_PROPERTIES[ + "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json" + ] = [ + Property( + namespace="homeassistant", + name="metadata_version", + value="1", + ) + ] + webdav_client.list_with_properties.return_value = MOCK_LIST_WITH_PROPERTIES + + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backup"] is None From 661b55d6eb62531389513f93735bbcf922899fa2 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Tue, 25 Feb 2025 12:06:24 +0100 Subject: [PATCH 2713/2987] Add Homee valve platform (#139188) --- homeassistant/components/homee/__init__.py | 1 + homeassistant/components/homee/strings.json | 5 + homeassistant/components/homee/valve.py | 81 +++++++++++++ tests/components/homee/fixtures/valve.json | 51 ++++++++ .../homee/snapshots/test_valve.ambr | 51 ++++++++ tests/components/homee/test_sensor.py | 25 ++-- tests/components/homee/test_valve.py | 110 ++++++++++++++++++ 7 files changed, 310 insertions(+), 14 deletions(-) create mode 100644 homeassistant/components/homee/valve.py create mode 100644 tests/components/homee/fixtures/valve.json create mode 100644 tests/components/homee/snapshots/test_valve.ambr create mode 100644 tests/components/homee/test_valve.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 0e4959c35ac..c576fa6d23c 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -20,6 +20,7 @@ PLATFORMS = [ Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, + Platform.VALVE, ] type HomeeConfigEntry = ConfigEntry[Homee] diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index f7e24acff99..a78e12341a3 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -205,6 +205,11 @@ "watchdog": { "name": "Watchdog" } + }, + "valve": { + "valve_position": { + "name": "Valve position" + } } }, "exceptions": { diff --git a/homeassistant/components/homee/valve.py b/homeassistant/components/homee/valve.py new file mode 100644 index 00000000000..b54d6334263 --- /dev/null +++ b/homeassistant/components/homee/valve.py @@ -0,0 +1,81 @@ +"""The Homee valve platform.""" + +from pyHomee.const import AttributeType +from pyHomee.model import HomeeAttribute + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityDescription, + ValveEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .entity import HomeeEntity + +VALVE_DESCRIPTIONS = { + AttributeType.CURRENT_VALVE_POSITION: ValveEntityDescription( + key="valve_position", + device_class=ValveDeviceClass.WATER, + ) +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the valve component.""" + + async_add_entities( + HomeeValve(attribute, config_entry, VALVE_DESCRIPTIONS[attribute.type]) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if attribute.type in VALVE_DESCRIPTIONS + ) + + +class HomeeValve(HomeeEntity, ValveEntity): + """Representation of a Homee valve.""" + + _attr_reports_position = True + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: ValveEntityDescription, + ) -> None: + """Initialize a Homee valve entity.""" + super().__init__(attribute, entry) + self.entity_description = description + self._attr_translation_key = description.key + + @property + def supported_features(self) -> ValveEntityFeature: + """Return the supported features.""" + if self._attribute.editable: + return ValveEntityFeature.SET_POSITION + return ValveEntityFeature(0) + + @property + def current_valve_position(self) -> int | None: + """Return the current valve position.""" + return int(self._attribute.current_value) + + @property + def is_closing(self) -> bool: + """Return if the valve is closing.""" + return self._attribute.target_value < self._attribute.current_value + + @property + def is_opening(self) -> bool: + """Return if the valve is opening.""" + return self._attribute.target_value > self._attribute.current_value + + async def async_set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + await self.async_set_value(position) diff --git a/tests/components/homee/fixtures/valve.json b/tests/components/homee/fixtures/valve.json new file mode 100644 index 00000000000..2b622cca6b1 --- /dev/null +++ b/tests/components/homee/fixtures/valve.json @@ -0,0 +1,51 @@ +{ + "id": 1, + "name": "Test Valve", + "profile": 3011, + "image": "nodeicon_valve", + "favorite": 0, + "order": 27, + "protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1736188706, + "added": 1610308228, + "history": 1, + "cube_type": 3, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "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": 1, + "type": 18, + "state": 1, + "last_changed": 1711796633, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 1, + "week": 26, + "month": 6 + } + } + } + ] +} diff --git a/tests/components/homee/snapshots/test_valve.ambr b/tests/components/homee/snapshots/test_valve.ambr new file mode 100644 index 00000000000..c76ecc6e780 --- /dev/null +++ b/tests/components/homee/snapshots/test_valve.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_valve_snapshot[valve.test_valve_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.test_valve_valve_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': 'Valve position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'valve_position', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_valve_snapshot[valve.test_valve_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'device_class': 'water', + 'friendly_name': 'Test Valve Valve position', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.test_valve_valve_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 index 0f66709c532..a2ba991c49b 100644 --- a/tests/components/homee/test_sensor.py +++ b/tests/components/homee/test_sensor.py @@ -1,9 +1,8 @@ """Test homee sensors.""" -from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch -from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.homee.const import ( @@ -12,13 +11,18 @@ from homeassistant.components.homee.const import ( WINDOW_MAP, WINDOW_MAP_REVERSED, ) -from homeassistant.const import LIGHT_LUX +from homeassistant.const import LIGHT_LUX, Platform 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 +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" async def test_up_down_values( @@ -110,19 +114,12 @@ async def test_sensor_snapshot( 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")] mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - 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() + with patch("homeassistant.components.homee.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_valve.py b/tests/components/homee/test_valve.py new file mode 100644 index 00000000000..166b52cc07b --- /dev/null +++ b/tests/components/homee/test_valve.py @@ -0,0 +1,110 @@ +"""Test Homee valves.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.valve import ( + DOMAIN as VALVE_DOMAIN, + SERVICE_SET_VALVE_POSITION, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + ValveEntityFeature, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_valve_set_position( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set valve position service.""" + mock_homee.nodes = [build_mock_node("valve.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test_valve_valve_position", "position": 100}, + ) + mock_homee.set_value.assert_called_once_with(1, 1, 100) + + +@pytest.mark.parametrize( + ("current_value", "target_value", "state"), + [ + (0.0, 0.0, STATE_CLOSED), + (0.0, 100.0, STATE_OPENING), + (100.0, 0.0, STATE_CLOSING), + (100.0, 100.0, STATE_OPEN), + ], +) +async def test_opening_closing( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + current_value: float, + target_value: float, + state: str, +) -> None: + """Test if opening/closing is detected correctly.""" + mock_homee.nodes = [build_mock_node("valve.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + valve = mock_homee.nodes[0].attributes[0] + valve.current_value = current_value + valve.target_value = target_value + valve.add_on_changed_listener.call_args_list[0][0][0](valve) + await hass.async_block_till_done() + + assert hass.states.get("valve.test_valve_valve_position").state == state + + +async def test_supported_features( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test supported features.""" + mock_homee.nodes = [build_mock_node("valve.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + valve = mock_homee.nodes[0].attributes[0] + attributes = hass.states.get("valve.test_valve_valve_position").attributes + assert attributes["supported_features"] == ValveEntityFeature.SET_POSITION + + valve.editable = 0 + valve.add_on_changed_listener.call_args_list[0][0][0](valve) + await hass.async_block_till_done() + + attributes = hass.states.get("valve.test_valve_valve_position").attributes + assert attributes["supported_features"] == ValveEntityFeature(0) + + +async def test_valve_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the valve snapshots.""" + mock_homee.nodes = [build_mock_node("valve.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.VALVE]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 051cc41d4f27c2a3bb5b422783a7b9d400befb55 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Tue, 25 Feb 2025 12:35:47 +0100 Subject: [PATCH 2714/2987] Fix units for LCN sensor (#138940) --- homeassistant/components/lcn/sensor.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index ee87ed2a91b..7783df8679a 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -3,7 +3,6 @@ from collections.abc import Iterable from functools import partial from itertools import chain -from typing import cast import pypck @@ -18,6 +17,11 @@ from homeassistant.const import ( CONF_ENTITIES, CONF_SOURCE, CONF_UNIT_OF_MEASUREMENT, + LIGHT_LUX, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfSpeed, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -47,6 +51,17 @@ DEVICE_CLASS_MAPPING = { pypck.lcn_defs.VarUnit.AMPERE: SensorDeviceClass.CURRENT, } +UNIT_OF_MEASUREMENT_MAPPING = { + pypck.lcn_defs.VarUnit.CELSIUS: UnitOfTemperature.CELSIUS, + pypck.lcn_defs.VarUnit.KELVIN: UnitOfTemperature.KELVIN, + pypck.lcn_defs.VarUnit.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, + pypck.lcn_defs.VarUnit.LUX_T: LIGHT_LUX, + pypck.lcn_defs.VarUnit.LUX_I: LIGHT_LUX, + pypck.lcn_defs.VarUnit.METERPERSECOND: UnitOfSpeed.METERS_PER_SECOND, + pypck.lcn_defs.VarUnit.VOLT: UnitOfElectricPotential.VOLT, + pypck.lcn_defs.VarUnit.AMPERE: UnitOfElectricCurrent.AMPERE, +} + def add_lcn_entities( config_entry: ConfigEntry, @@ -103,8 +118,10 @@ class LcnVariableSensor(LcnEntity, SensorEntity): config[CONF_DOMAIN_DATA][CONF_UNIT_OF_MEASUREMENT] ) - self._attr_native_unit_of_measurement = cast(str, self.unit.value) - self._attr_device_class = DEVICE_CLASS_MAPPING.get(self.unit, None) + self._attr_native_unit_of_measurement = UNIT_OF_MEASUREMENT_MAPPING.get( + self.unit + ) + self._attr_device_class = DEVICE_CLASS_MAPPING.get(self.unit) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" From 48d3dd88a17826bd4ee227efc336515616559731 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Tue, 25 Feb 2025 11:36:08 +0000 Subject: [PATCH 2715/2987] Add Ohme voltage and slot list sensor (#139203) * Bump ohmepy to 1.3.1 * Bump ohmepy to 1.3.2 * Add voltage and slot list sensor * CI fixes * Change slot list sensor name * Fix snapshot tests --- homeassistant/components/ohme/icons.json | 3 + homeassistant/components/ohme/sensor.py | 15 +++ homeassistant/components/ohme/strings.json | 3 + .../ohme/snapshots/test_sensor.ambr | 99 +++++++++++++++++++ 4 files changed, 120 insertions(+) diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json index ade48b4f80f..9771b0bf5c2 100644 --- a/homeassistant/components/ohme/icons.json +++ b/homeassistant/components/ohme/icons.json @@ -31,6 +31,9 @@ }, "ct_current": { "default": "mdi:gauge" + }, + "slot_list": { + "default": "mdi:calendar-clock" } }, "switch": { diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py index 1e0572fe858..d0425040b53 100644 --- a/homeassistant/components/ohme/sensor.py +++ b/homeassistant/components/ohme/sensor.py @@ -15,7 +15,9 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( PERCENTAGE, + STATE_UNKNOWN, UnitOfElectricCurrent, + UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, ) @@ -66,6 +68,13 @@ SENSOR_CHARGE_SESSION = [ state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda client: client.energy, ), + OhmeSensorDescription( + key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda client: client.power.volts, + ), OhmeSensorDescription( key="battery", translation_key="vehicle_battery", @@ -74,6 +83,12 @@ SENSOR_CHARGE_SESSION = [ suggested_display_precision=0, value_fn=lambda client: client.battery, ), + OhmeSensorDescription( + key="slot_list", + translation_key="slot_list", + value_fn=lambda client: ", ".join(str(x) for x in client.slots) + or STATE_UNKNOWN, + ), ] SENSOR_ADVANCED_SETTINGS = [ diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 46ccfca71fd..387b28565b2 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -85,6 +85,9 @@ }, "vehicle_battery": { "name": "Vehicle battery" + }, + "slot_list": { + "name": "Charge slots" } }, "switch": { diff --git a/tests/components/ohme/snapshots/test_sensor.ambr b/tests/components/ohme/snapshots/test_sensor.ambr index fc28b3b011c..9cef4bfffd9 100644 --- a/tests/components/ohme/snapshots/test_sensor.ambr +++ b/tests/components/ohme/snapshots/test_sensor.ambr @@ -1,4 +1,51 @@ # serializer version: 1 +# name: test_sensors[sensor.ohme_home_pro_charge_slots-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': None, + 'entity_id': 'sensor.ohme_home_pro_charge_slots', + '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': 'Charge slots', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'slot_list', + 'unique_id': 'chargerid_slot_list', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_charge_slots-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Charge slots', + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_charge_slots', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[sensor.ohme_home_pro_ct_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -327,3 +374,55 @@ 'state': '80', }) # --- +# name: test_sensors[sensor.ohme_home_pro_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.ohme_home_pro_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': 'Voltage', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'chargerid_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Ohme Home Pro Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- From 01fb6841da27c4dbec10b4ecc93aa2787b1b61de Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Feb 2025 12:36:20 +0100 Subject: [PATCH 2716/2987] Initiate source list as instance variable in Volumio (#139243) --- homeassistant/components/volumio/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index 514f1ad9221..773a125d483 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -70,7 +70,6 @@ class Volumio(MediaPlayerEntity): | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.BROWSE_MEDIA ) - _attr_source_list = [] def __init__(self, volumio, uid, name, info): """Initialize the media player.""" @@ -78,6 +77,7 @@ class Volumio(MediaPlayerEntity): unique_id = uid self._state = {} self.thumbnail_cache = {} + self._attr_source_list = [] self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, From 9e063fd77c3a11d6f7881303a0105fb7972aa912 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Feb 2025 12:36:59 +0100 Subject: [PATCH 2717/2987] `logbook.log` action: Make description of `name` field UI-friendly (#139200) --- homeassistant/components/logbook/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/logbook/strings.json b/homeassistant/components/logbook/strings.json index 27ad49b0e3a..5a38b57a9b7 100644 --- a/homeassistant/components/logbook/strings.json +++ b/homeassistant/components/logbook/strings.json @@ -7,7 +7,7 @@ "fields": { "name": { "name": "[%key:common::config_flow::data::name%]", - "description": "Custom name for an entity, can be referenced using an `entity_id`." + "description": "Custom name for an entity, can be referenced using the 'Entity ID' field." }, "message": { "name": "Message", From cea5cda881cb25300d0bd9e78998af31f519428d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Feb 2025 12:47:18 +0100 Subject: [PATCH 2718/2987] Treat "Twist Assist" & "Block to Block" as feature names and add descriptions in Z-Wave (#139239) Treat "Twist Assist" & "Block to Block" as feature names and add descriptions - name-case both "Twist Assist" and "Block to Block" so those feature names don't get translated - for proper translation of both features add useful descriptions of what they actually do - fix sentence-casing on "Operation type" --- homeassistant/components/zwave_js/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index e845cc28707..8f23fee4447 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -516,8 +516,8 @@ "name": "Auto relock time" }, "block_to_block": { - "description": "Enable block-to-block functionality.", - "name": "Block to block" + "description": "Whether the lock should run the motor until it hits resistance.", + "name": "Block to Block" }, "hold_and_release_time": { "description": "Duration in seconds the latch stays retracted.", @@ -529,11 +529,11 @@ }, "operation_type": { "description": "The operation type of the lock.", - "name": "Operation Type" + "name": "Operation type" }, "twist_assist": { - "description": "Enable Twist Assist.", - "name": "Twist assist" + "description": "Whether the motor should help in locking and unlocking.", + "name": "Twist Assist" } }, "name": "Set lock configuration" From bc7f5f39818007c02972c300c0df799089b1d62a Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Tue, 25 Feb 2025 20:58:01 +0900 Subject: [PATCH 2719/2987] Add climate's swing mode to LG ThinQ (#137619) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/climate.py | 52 +++++++++++++++++++ .../lg_thinq/snapshots/test_climate.ambr | 22 +++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index ff57709f9a8..063705f5d0d 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -12,6 +12,8 @@ from thinqconnect.integration import ExtendedProperty from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + SWING_OFF, + SWING_ON, ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, @@ -73,6 +75,13 @@ HVAC_TO_STR: dict[HVACMode, str] = { THINQ_PRESET_MODE: list[str] = ["air_clean", "aroma", "energy_saving"] +STR_TO_SWING = { + "true": SWING_ON, + "false": SWING_OFF, +} + +SWING_TO_STR = {v: k for k, v in STR_TO_SWING.items()} + _LOGGER = logging.getLogger(__name__) @@ -142,6 +151,14 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self._attr_supported_features |= ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) + # Supports swing mode. + if self.data.swing_modes: + self._attr_swing_modes = [SWING_ON, SWING_OFF] + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + + if self.data.swing_horizontal_modes: + self._attr_swing_horizontal_modes = [SWING_ON, SWING_OFF] + self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE def _update_status(self) -> None: """Update status itself.""" @@ -150,6 +167,13 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): # Update fan, hvac and preset mode. if self.supported_features & ClimateEntityFeature.FAN_MODE: self._attr_fan_mode = self.data.fan_mode + if self.supported_features & ClimateEntityFeature.SWING_MODE: + self._attr_swing_mode = STR_TO_SWING.get(self.data.swing_mode) + if self.supported_features & ClimateEntityFeature.SWING_HORIZONTAL_MODE: + self._attr_swing_horizontal_mode = STR_TO_SWING.get( + self.data.swing_horizontal_mode + ) + if self.data.is_on: hvac_mode = self._requested_hvac_mode or self.data.hvac_mode if hvac_mode in STR_TO_HVAC: @@ -268,6 +292,34 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self.coordinator.api.async_set_fan_mode(self.property_id, fan_mode) ) + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set new swing mode.""" + _LOGGER.debug( + "[%s:%s] async_set_swing_mode: %s", + self.coordinator.device_name, + self.property_id, + swing_mode, + ) + await self.async_call_api( + self.coordinator.api.async_set_swing_mode( + self.property_id, SWING_TO_STR.get(swing_mode) + ) + ) + + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new swing horizontal mode.""" + _LOGGER.debug( + "[%s:%s] async_set_swing_horizontal_mode: %s", + self.coordinator.device_name, + self.property_id, + swing_horizontal_mode, + ) + await self.async_call_api( + self.coordinator.api.async_set_swing_horizontal_mode( + self.property_id, SWING_TO_STR.get(swing_horizontal_mode) + ) + ) + def _round_by_step(self, temperature: float) -> float: """Round the value by step.""" if ( diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index e2fcc2540f3..db57e824487 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -20,6 +20,14 @@ 'preset_modes': list([ 'air_clean', ]), + 'swing_horizontal_modes': list([ + 'on', + 'off', + ]), + 'swing_modes': list([ + 'on', + 'off', + ]), 'target_temp_step': 1, }), 'config_entry_id': , @@ -44,7 +52,7 @@ 'original_name': None, 'platform': 'lg_thinq', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': , 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_climate_air_conditioner', 'unit_of_measurement': None, @@ -73,7 +81,17 @@ 'preset_modes': list([ 'air_clean', ]), - 'supported_features': , + 'supported_features': , + 'swing_horizontal_mode': 'off', + 'swing_horizontal_modes': list([ + 'on', + 'off', + ]), + 'swing_mode': 'off', + 'swing_modes': list([ + 'on', + 'off', + ]), 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 1, From 694a77fe3c1f7f89510a9ed80b02fe4738a294cb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Feb 2025 13:24:32 +0100 Subject: [PATCH 2720/2987] Bump aiowithings to 3.1.6 (#139242) --- homeassistant/components/withings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index 4c78e077d21..232997da054 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/withings", "iot_class": "cloud_push", "loggers": ["aiowithings"], - "requirements": ["aiowithings==3.1.5"] + "requirements": ["aiowithings==3.1.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index d239ac021f9..1274cd99deb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -428,7 +428,7 @@ aiowebdav2==0.3.0 aiowebostv==0.7.0 # homeassistant.components.withings -aiowithings==3.1.5 +aiowithings==3.1.6 # homeassistant.components.yandex_transport aioymaps==1.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b770f80c3f1..6e3238a5fe7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -410,7 +410,7 @@ aiowebdav2==0.3.0 aiowebostv==0.7.0 # homeassistant.components.withings -aiowithings==3.1.5 +aiowithings==3.1.6 # homeassistant.components.yandex_transport aioymaps==1.2.5 From 2509353221182f1db94a6e25dd25f8b335e13169 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 25 Feb 2025 13:40:21 +0100 Subject: [PATCH 2721/2987] Add update reward action to Habitica integration (#139157) --- homeassistant/components/habitica/const.py | 5 + homeassistant/components/habitica/icons.json | 7 + homeassistant/components/habitica/services.py | 148 +++++++++- .../components/habitica/services.yaml | 40 +++ .../components/habitica/strings.json | 72 ++++- tests/components/habitica/conftest.py | 7 + .../habitica/fixtures/create_tag.json | 8 + .../components/habitica/fixtures/reward.json | 27 ++ tests/components/habitica/fixtures/tasks.json | 5 +- .../habitica/snapshots/test_sensor.ambr | 4 + .../habitica/snapshots/test_services.ambr | 8 + tests/components/habitica/test_services.py | 267 +++++++++++++++++- 12 files changed, 593 insertions(+), 5 deletions(-) create mode 100644 tests/components/habitica/fixtures/create_tag.json create mode 100644 tests/components/habitica/fixtures/reward.json diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 5eb616142e5..5e18477d142 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -35,6 +35,10 @@ ATTR_TYPE = "type" ATTR_PRIORITY = "priority" ATTR_TAG = "tag" ATTR_KEYWORD = "keyword" +ATTR_REMOVE_TAG = "remove_tag" +ATTR_ALIAS = "alias" +ATTR_PRIORITY = "priority" +ATTR_COST = "cost" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" @@ -50,6 +54,7 @@ SERVICE_SCORE_REWARD = "score_reward" SERVICE_TRANSFORMATION = "transformation" +SERVICE_UPDATE_REWARD = "update_reward" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index 6ae6ebd728b..e119b063aa5 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -217,6 +217,13 @@ "sections": { "filter": "mdi:calendar-filter" } + }, + "update_reward": { + "service": "mdi:treasure-chest", + "sections": { + "tag_options": "mdi:tag", + "developer_options": "mdi:test-tube" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 59bcc8cc7cc..16bbeef9073 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -4,7 +4,8 @@ from __future__ import annotations from dataclasses import asdict import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast +from uuid import UUID from aiohttp import ClientError from habiticalib import ( @@ -13,6 +14,7 @@ from habiticalib import ( NotAuthorizedError, NotFoundError, Skill, + Task, TaskData, TaskPriority, TaskType, @@ -20,6 +22,7 @@ from habiticalib import ( ) import voluptuous as vol +from homeassistant.components.todo import ATTR_DESCRIPTION, ATTR_RENAME from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_NAME, CONF_NAME from homeassistant.core import ( @@ -34,14 +37,17 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.selector import ConfigEntrySelector from .const import ( + ATTR_ALIAS, ATTR_ARGS, ATTR_CONFIG_ENTRY, + ATTR_COST, ATTR_DATA, ATTR_DIRECTION, ATTR_ITEM, ATTR_KEYWORD, ATTR_PATH, ATTR_PRIORITY, + ATTR_REMOVE_TAG, ATTR_SKILL, ATTR_TAG, ATTR_TARGET, @@ -61,6 +67,7 @@ from .const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_REWARD, ) from .coordinator import HabiticaConfigEntry @@ -104,6 +111,21 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( } ) +SERVICE_UPDATE_TASK_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_TASK): cv.string, + vol.Optional(ATTR_RENAME): cv.string, + vol.Optional(ATTR_DESCRIPTION): cv.string, + vol.Optional(ATTR_TAG): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_REMOVE_TAG): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_ALIAS): vol.All( + cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$") + ), + vol.Optional(ATTR_COST): vol.Coerce(float), + } +) + SERVICE_GET_TASKS_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), @@ -516,6 +538,130 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 return result + async def update_task(call: ServiceCall) -> ServiceResponse: + """Update task action.""" + entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + await coordinator.async_refresh() + + try: + current_task = next( + task + for task in coordinator.data.tasks + if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) + and task.Type is TaskType.REWARD + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="task_not_found", + translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, + ) from e + + task_id = current_task.id + if TYPE_CHECKING: + assert task_id + data = Task() + + if rename := call.data.get(ATTR_RENAME): + data["text"] = rename + + if (description := call.data.get(ATTR_DESCRIPTION)) is not None: + data["notes"] = description + + tags = cast(list[str], call.data.get(ATTR_TAG)) + remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG)) + + if tags or remove_tags: + update_tags = set(current_task.tags) + user_tags = { + tag.name.lower(): tag.id + for tag in coordinator.data.user.tags + if tag.id and tag.name + } + + if tags: + # Creates new tag if it doesn't exist + async def create_tag(tag_name: str) -> UUID: + tag_id = (await coordinator.habitica.create_tag(tag_name)).data.id + if TYPE_CHECKING: + assert tag_id + return tag_id + + try: + update_tags.update( + { + user_tags.get(tag_name.lower()) + or (await create_tag(tag_name)) + for tag_name in tags + } + ) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + + if remove_tags: + update_tags.difference_update( + { + user_tags[tag_name.lower()] + for tag_name in remove_tags + if tag_name.lower() in user_tags + } + ) + + data["tags"] = list(update_tags) + + if (alias := call.data.get(ATTR_ALIAS)) is not None: + data["alias"] = alias + + if (cost := call.data.get(ATTR_COST)) is not None: + data["value"] = cost + + try: + response = await coordinator.habitica.update_task(task_id, data) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + return response.data.to_dict(omit_none=True) + + hass.services.async_register( + DOMAIN, + SERVICE_UPDATE_REWARD, + update_task, + schema=SERVICE_UPDATE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) hass.services.async_register( DOMAIN, SERVICE_API_CALL, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index f3095518290..b8479c1eeec 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -140,3 +140,43 @@ get_tasks: required: false selector: text: +update_reward: + fields: + config_entry: *config_entry + task: *task + rename: + selector: + text: + description: + required: false + selector: + text: + multiline: true + cost: + required: false + selector: + number: + min: 0 + step: 0.01 + unit_of_measurement: "🪙" + mode: box + tag_options: + collapsed: true + fields: + tag: + required: false + selector: + text: + multiple: true + remove_tag: + required: false + selector: + text: + multiple: true + developer_options: + collapsed: true + fields: + alias: + required: false + selector: + text: diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 396a10e05f9..75558cea078 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -7,7 +7,23 @@ "unit_tasks": "tasks", "unit_health_points": "HP", "unit_mana_points": "MP", - "unit_experience_points": "XP" + "unit_experience_points": "XP", + "config_entry_description": "Select the Habitica account to update a task.", + "task_description": "The name (or task ID) of the task you want to update.", + "rename_name": "Rename", + "rename_description": "The new title for the Habitica task.", + "description_name": "Update description", + "description_description": "The new description for the Habitica task.", + "tag_name": "Add tags", + "tag_description": "Add tags to the Habitica task. If a tag does not already exist, a new one will be created.", + "remove_tag_name": "Remove tags", + "remove_tag_description": "Remove tags from the Habitica task.", + "alias_name": "Task alias", + "alias_description": "A task alias can be used instead of the name or task ID. Only dashes, underscores, and alphanumeric characters are supported. The task alias must be unique among all your tasks.", + "developer_options_name": "Advanced settings", + "developer_options_description": "Additional features available in developer mode.", + "tag_options_name": "Tags", + "tag_options_description": "Add or remove tags from a task." }, "config": { "abort": { @@ -457,6 +473,12 @@ }, "authentication_failed": { "message": "Authentication failed. It looks like your API token has been reset. Please re-authenticate using your new token" + }, + "frequency_not_weekly": { + "message": "Unable to update task, weekly repeat settings apply only to weekly recurring dailies." + }, + "frequency_not_monthly": { + "message": "Unable to update task, monthly repeat settings apply only to monthly recurring dailies." } }, "issues": { @@ -651,6 +673,54 @@ "description": "Use the optional filters to narrow the returned tasks." } } + }, + "update_reward": { + "name": "Update a reward", + "description": "Updates a specific reward for the selected Habitica character", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to update a reward." + }, + "task": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::task_description%]" + }, + "rename": { + "name": "[%key:component::habitica::common::rename_name%]", + "description": "[%key:component::habitica::common::rename_description%]" + }, + "description": { + "name": "[%key:component::habitica::common::description_name%]", + "description": "[%key:component::habitica::common::description_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "remove_tag": { + "name": "[%key:component::habitica::common::remove_tag_name%]", + "description": "[%key:component::habitica::common::remove_tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "cost": { + "name": "Cost", + "description": "Update the cost of a reward." + } + }, + "sections": { + "tag_options": { + "name": "[%key:component::habitica::common::tag_options_name%]", + "description": "[%key:component::habitica::common::tag_options_description%]" + }, + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } } }, "selector": { diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index e04fc58ad15..45c33a9ebb6 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -14,6 +14,7 @@ from habiticalib import ( HabiticaResponse, HabiticaScoreResponse, HabiticaSleepResponse, + HabiticaTagResponse, HabiticaTaskOrderResponse, HabiticaTaskResponse, HabiticaTasksResponse, @@ -144,6 +145,12 @@ async def mock_habiticalib() -> Generator[AsyncMock]: load_fixture("anonymized.json", DOMAIN) ) ) + client.update_task.return_value = HabiticaTaskResponse.from_json( + load_fixture("task.json", DOMAIN) + ) + client.create_tag.return_value = HabiticaTagResponse.from_json( + load_fixture("create_tag.json", DOMAIN) + ) client.habitipy.return_value = { "tasks": { "user": { diff --git a/tests/components/habitica/fixtures/create_tag.json b/tests/components/habitica/fixtures/create_tag.json new file mode 100644 index 00000000000..638ec69d84e --- /dev/null +++ b/tests/components/habitica/fixtures/create_tag.json @@ -0,0 +1,8 @@ +{ + "success": true, + "data": { + "name": "Home Assistant", + "id": "8bc0afbf-ab8e-49a4-982d-67a40557ed1a" + }, + "notifications": [] +} diff --git a/tests/components/habitica/fixtures/reward.json b/tests/components/habitica/fixtures/reward.json new file mode 100644 index 00000000000..1c639c4298e --- /dev/null +++ b/tests/components/habitica/fixtures/reward.json @@ -0,0 +1,27 @@ +{ + "success": true, + "data": { + "_id": "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", + "type": "reward", + "text": "Belohne Dich selbst", + "notes": "Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!", + "tags": [], + "value": 10, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "byHabitica": false, + "reminders": [], + "createdAt": "2024-07-07T17:51:53.266Z", + "updatedAt": "2024-07-07T17:51:53.266Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "id": "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + }, + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index cf6e3864675..378652138bc 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -533,7 +533,10 @@ "type": "reward", "text": "Belohne Dich selbst", "notes": "Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!", - "tags": [], + "tags": [ + "3450351f-1323-4c7e-9fd2-0cdff25b3ce0", + "b2780f82-b3b5-49a3-a677-48f2c8c7e3bb" + ], "value": 10, "priority": 1, "attribute": "str", diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 881326f76d8..1fbc9eca595 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -1271,6 +1271,10 @@ 'th': False, 'w': True, }), + 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', + ]), 'text': 'Belohne Dich selbst', 'type': 'reward', 'value': 10.0, diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr index e25ed8db313..79c9e3eab66 100644 --- a/tests/components/habitica/snapshots/test_services.ambr +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -1081,6 +1081,8 @@ 'startDate': None, 'streak': None, 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', ]), 'text': 'Belohne Dich selbst', 'type': 'reward', @@ -3321,6 +3323,8 @@ 'startDate': None, 'streak': None, 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', ]), 'text': 'Belohne Dich selbst', 'type': 'reward', @@ -5580,6 +5584,8 @@ 'startDate': None, 'streak': None, 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', ]), 'text': 'Belohne Dich selbst', 'type': 'reward', @@ -5954,6 +5960,8 @@ 'startDate': None, 'streak': None, 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', ]), 'text': 'Belohne Dich selbst', 'type': 'reward', diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 5fca1884bdf..3f7ca14220b 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -6,16 +6,19 @@ from unittest.mock import AsyncMock, patch from uuid import UUID from aiohttp import ClientError -from habiticalib import Direction, Skill +from habiticalib import Direction, HabiticaTaskResponse, Skill, Task import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.habitica.const import ( + ATTR_ALIAS, ATTR_CONFIG_ENTRY, + ATTR_COST, ATTR_DIRECTION, ATTR_ITEM, ATTR_KEYWORD, ATTR_PRIORITY, + ATTR_REMOVE_TAG, ATTR_SKILL, ATTR_TAG, ATTR_TARGET, @@ -33,7 +36,9 @@ from homeassistant.components.habitica.const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_REWARD, ) +from homeassistant.components.todo import ATTR_DESCRIPTION, ATTR_RENAME from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -45,7 +50,7 @@ from .conftest import ( ERROR_TOO_MANY_REQUESTS, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica: reason" RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again in 5 seconds" @@ -889,3 +894,261 @@ async def test_get_tasks( ) assert response == snapshot + + +@pytest.mark.parametrize( + ("exception", "expected_exception", "exception_msg"), + [ + ( + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + ERROR_BAD_REQUEST, + HomeAssistantError, + REQUEST_EXCEPTION_MSG, + ), + ( + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), + ], +) +@pytest.mark.usefixtures("habitica") +async def test_update_task_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + exception: Exception, + expected_exception: Exception, + exception_msg: str, +) -> None: + """Test Habitica task action exceptions.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + habitica.update_task.side_effect = exception + with pytest.raises(expected_exception, match=exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + }, + return_response=True, + blocking=True, + ) + + +@pytest.mark.usefixtures("habitica") +async def test_task_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, +) -> None: + """Test Habitica task not found exceptions.""" + task_id = "7f902bbc-eb3d-4a8f-82cf-4e2025d69af1" + + with pytest.raises( + ServiceValidationError, + match="Unable to complete action, could not find the task '7f902bbc-eb3d-4a8f-82cf-4e2025d69af1'", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + }, + return_response=True, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_COST: 100, + }, + Task(value=100), + ), + ( + { + ATTR_RENAME: "RENAME", + }, + Task(text="RENAME"), + ), + ( + { + ATTR_DESCRIPTION: "DESCRIPTION", + }, + Task(notes="DESCRIPTION"), + ), + ( + { + ATTR_ALIAS: "ALIAS", + }, + Task(alias="ALIAS"), + ), + ], +) +async def test_update_reward( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica update_reward action.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + habitica.update_task.return_value = HabiticaTaskResponse.from_json( + load_fixture("task.json", DOMAIN) + ) + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.update_task.assert_awaited_with(UUID(task_id), call_args) + + +async def test_tags( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, +) -> None: + """Test adding tags to a task.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + ATTR_TAG: ["Schule"], + }, + return_response=True, + blocking=True, + ) + + call_args = habitica.update_task.call_args[0] + assert call_args[0] == UUID(task_id) + assert set(call_args[1]["tags"]) == { + UUID("2ac458af-0833-4f3f-bf04-98a0c33ef60b"), + UUID("3450351f-1323-4c7e-9fd2-0cdff25b3ce0"), + UUID("b2780f82-b3b5-49a3-a677-48f2c8c7e3bb"), + } + + +async def test_create_new_tag( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, +) -> None: + """Test adding a non-existent tag and create it as new.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + ATTR_TAG: ["Home Assistant"], + }, + return_response=True, + blocking=True, + ) + + habitica.create_tag.assert_awaited_with("Home Assistant") + + call_args = habitica.update_task.call_args[0] + assert call_args[0] == UUID(task_id) + assert set(call_args[1]["tags"]) == { + UUID("8bc0afbf-ab8e-49a4-982d-67a40557ed1a"), + UUID("3450351f-1323-4c7e-9fd2-0cdff25b3ce0"), + UUID("b2780f82-b3b5-49a3-a677-48f2c8c7e3bb"), + } + + +@pytest.mark.parametrize( + ("exception", "expected_exception", "exception_msg"), + [ + ( + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + ERROR_BAD_REQUEST, + HomeAssistantError, + REQUEST_EXCEPTION_MSG, + ), + ( + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), + ], +) +async def test_create_new_tag_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + exception: Exception, + expected_exception: Exception, + exception_msg: str, +) -> None: + """Test create new tag exception.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + habitica.create_tag.side_effect = exception + with pytest.raises(expected_exception, match=exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + ATTR_TAG: ["Home Assistant"], + }, + return_response=True, + blocking=True, + ) + + +async def test_remove_tags( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, +) -> None: + """Test removing tags from a task.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + ATTR_REMOVE_TAG: ["Kreativität"], + }, + return_response=True, + blocking=True, + ) + + call_args = habitica.update_task.call_args[0] + assert call_args[0] == UUID(task_id) + assert set(call_args[1]["tags"]) == {UUID("b2780f82-b3b5-49a3-a677-48f2c8c7e3bb")} From befed910da93b30b130f780dd76a74ac40da757d Mon Sep 17 00:00:00 2001 From: cdnninja Date: Tue, 25 Feb 2025 05:48:31 -0700 Subject: [PATCH 2722/2987] Add Re-Auth Flow to vesync (#137398) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/vesync/__init__.py | 4 +- .../components/vesync/config_flow.py | 34 +++++++++++ homeassistant/components/vesync/strings.json | 11 +++- tests/components/vesync/test_config_flow.py | 56 +++++++++++++++++++ tests/components/vesync/test_init.py | 19 ++----- 5 files changed, 107 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 01f88c64bf4..dddf7857545 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -59,8 +60,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b login = await hass.async_add_executor_job(manager.login) if not login: - _LOGGER.error("Unable to login to the VeSync server") - return False + raise ConfigEntryAuthFailed hass.data[DOMAIN] = {} hass.data[DOMAIN][VS_MANAGER] = manager diff --git a/homeassistant/components/vesync/config_flow.py b/homeassistant/components/vesync/config_flow.py index 07543440e91..e5537d8fcc9 100644 --- a/homeassistant/components/vesync/config_flow.py +++ b/homeassistant/components/vesync/config_flow.py @@ -1,5 +1,6 @@ """Config flow utilities.""" +from collections.abc import Mapping from typing import Any from pyvesync import VeSync @@ -57,3 +58,36 @@ class VeSyncFlowHandler(ConfigFlow, domain=DOMAIN): title=username, data={CONF_USERNAME: username, CONF_PASSWORD: password}, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication with vesync.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm re-authentication with vesync.""" + + if user_input: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + manager = VeSync(username, password) + login = await self.hass.async_add_executor_job(manager.login) + if login: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + }, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=DATA_SCHEMA, + description_placeholders={"name": "VeSync"}, + errors={"base": "invalid_auth"}, + ) diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index 2232b16329b..89f401da92f 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -7,13 +7,22 @@ "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The vesync integration needs to re-authenticate your account", + "data": { + "username": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/tests/components/vesync/test_config_flow.py b/tests/components/vesync/test_config_flow.py index 22a93e1ba56..38f28e73aed 100644 --- a/tests/components/vesync/test_config_flow.py +++ b/tests/components/vesync/test_config_flow.py @@ -48,3 +48,59 @@ async def test_config_flow_user_input(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_USERNAME] == "user" assert result["data"][CONF_PASSWORD] == "pass" + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test a successful reauth flow.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + ) + mock_entry.add_to_hass(hass) + + result = await mock_entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + with patch("pyvesync.vesync.VeSync.login", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_entry.data == { + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + } + + +async def test_reauth_flow_invalid_auth(hass: HomeAssistant) -> None: + """Test an authorization error reauth flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + ) + mock_entry.add_to_hass(hass) + + result = await mock_entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + + with patch("pyvesync.vesync.VeSync.login", return_value=False): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.FORM + with patch("pyvesync.vesync.VeSync.login", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index 011545af2ae..31df2418b3d 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -2,7 +2,6 @@ from unittest.mock import Mock, patch -import pytest from pyvesync import VeSync from homeassistant.components.vesync import SERVICE_UPDATE_DEVS, async_setup_entry @@ -19,25 +18,17 @@ async def test_async_setup_entry__not_login( hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, - caplog: pytest.LogCaptureFixture, ) -> None: """Test setup does not create config entry when not logged in.""" manager.login = Mock(return_value=False) - with ( - patch.object(hass.config_entries, "async_forward_entry_setups") as setups_mock, - patch( - "homeassistant.components.vesync.async_generate_device_list" - ) as process_mock, - ): - assert not await async_setup_entry(hass, config_entry) - await hass.async_block_till_done() - assert setups_mock.call_count == 0 - assert process_mock.call_count == 0 + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() assert manager.login.call_count == 1 - assert DOMAIN not in hass.data - assert "Unable to login to the VeSync server" in caplog.text + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN) async def test_async_setup_entry__no_devices( From d7301c62e2b51dd1911dee7139aa9fced8f3cb10 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 25 Feb 2025 14:02:10 +0100 Subject: [PATCH 2723/2987] Rework the velbus configflow to make it more user-friendly (#135609) --- homeassistant/components/velbus/__init__.py | 38 +++- .../components/velbus/config_flow.py | 110 +++++++--- homeassistant/components/velbus/const.py | 1 + .../components/velbus/quality_scale.yaml | 5 +- homeassistant/components/velbus/strings.json | 26 +++ .../velbus/snapshots/test_diagnostics.ambr | 2 +- tests/components/velbus/test_config_flow.py | 203 +++++++++++------- tests/components/velbus/test_init.py | 32 ++- 8 files changed, 297 insertions(+), 120 deletions(-) diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 41b8730eeb0..35c61892964 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -135,15 +135,39 @@ async def async_migrate_entry( hass: HomeAssistant, config_entry: VelbusConfigEntry ) -> bool: """Migrate old entry.""" - _LOGGER.debug("Migrating from version %s", config_entry.version) - cache_path = hass.config.path(STORAGE_DIR, f"velbuscache-{config_entry.entry_id}/") - if config_entry.version == 1: - # This is the config entry migration for adding the new program selection + _LOGGER.error( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + # This is the config entry migration for adding the new program selection + # migrate from 1.x to 2.1 + if config_entry.version < 2: # clean the velbusCache + cache_path = hass.config.path( + STORAGE_DIR, f"velbuscache-{config_entry.entry_id}/" + ) if os.path.isdir(cache_path): await hass.async_add_executor_job(shutil.rmtree, cache_path) - # set the new version - hass.config_entries.async_update_entry(config_entry, version=2) - _LOGGER.debug("Migration to version %s successful", config_entry.version) + # This is the config entry migration for swapping the usb unique id to the serial number + # migrate from 2.1 to 2.2 + if ( + config_entry.version < 3 + and config_entry.minor_version == 1 + and config_entry.unique_id is not None + ): + # not all velbus devices have a unique id, so handle this correctly + parts = config_entry.unique_id.split("_") + # old one should have 4 item + if len(parts) == 4: + hass.config_entries.async_update_entry(config_entry, unique_id=parts[1]) + + # update the config entry + hass.config_entries.async_update_entry(config_entry, version=2, minor_version=2) + + _LOGGER.error( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) return True diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 9e99b2631d4..fc5da92588a 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -4,22 +4,23 @@ from __future__ import annotations from typing import Any +import serial.tools.list_ports import velbusaio.controller from velbusaio.exceptions import VelbusConnectionFailed import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.helpers.service_info.usb import UsbServiceInfo -from homeassistant.util import slugify -from .const import DOMAIN +from .const import CONF_TLS, DOMAIN class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 2 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize the velbus config flow.""" @@ -27,14 +28,16 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): self._device: str = "" self._title: str = "" - def _create_device(self, name: str, prt: str) -> ConfigFlowResult: + def _create_device(self) -> ConfigFlowResult: """Create an entry async.""" - return self.async_create_entry(title=name, data={CONF_PORT: prt}) + return self.async_create_entry( + title=self._title, data={CONF_PORT: self._device} + ) - async def _test_connection(self, prt: str) -> bool: + async def _test_connection(self) -> bool: """Try to connect to the velbus with the port specified.""" try: - controller = velbusaio.controller.Velbus(prt) + controller = velbusaio.controller.Velbus(self._device) await controller.connect() await controller.stop() except VelbusConnectionFailed: @@ -46,43 +49,86 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Step when user initializes a integration.""" - self._errors = {} + return self.async_show_menu( + step_id="user", menu_options=["network", "usbselect"] + ) + + async def async_step_network( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle network step.""" if user_input is not None: - name = slugify(user_input[CONF_NAME]) - prt = user_input[CONF_PORT] - self._async_abort_entries_match({CONF_PORT: prt}) - if await self._test_connection(prt): - return self._create_device(name, prt) + self._title = "Velbus Network" + if user_input[CONF_TLS]: + self._device = "tls://" + else: + self._device = "" + if user_input[CONF_PASSWORD] != "": + self._device += f"{user_input[CONF_PASSWORD]}@" + self._device += f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" + self._async_abort_entries_match({CONF_PORT: self._device}) + if await self._test_connection(): + return self._create_device() + else: + user_input = { + CONF_TLS: True, + CONF_PORT: 27015, + } + + return self.async_show_form( + step_id="network", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_TLS): bool, + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT): int, + vol.Optional(CONF_PASSWORD): str, + } + ), + suggested_values=user_input, + ), + errors=self._errors, + ) + + async def async_step_usbselect( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle usb select step.""" + ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + list_of_ports = [ + f"{p}{', s/n: ' + p.serial_number if p.serial_number else ''}" + + (f" - {p.manufacturer}" if p.manufacturer else "") + for p in ports + ] + + if user_input is not None: + self._title = "Velbus USB" + self._device = ports[list_of_ports.index(user_input[CONF_PORT])].device + self._async_abort_entries_match({CONF_PORT: self._device}) + if await self._test_connection(): + return self._create_device() else: user_input = {} - user_input[CONF_NAME] = "" user_input[CONF_PORT] = "" return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_NAME, default=user_input[CONF_NAME]): str, - vol.Required(CONF_PORT, default=user_input[CONF_PORT]): str, - } + step_id="usbselect", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_PORT): vol.In(list_of_ports)}), + suggested_values=user_input, ), errors=self._errors, ) async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" - await self.async_set_unique_id( - f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}" - ) - dev_path = discovery_info.device - # check if this device is not already configured - self._async_abort_entries_match({CONF_PORT: dev_path}) - # check if we can make a valid velbus connection - if not await self._test_connection(dev_path): - return self.async_abort(reason="cannot_connect") - # store the data for the config step - self._device = dev_path + await self.async_set_unique_id(discovery_info.serial_number) + self._device = discovery_info.device self._title = "Velbus USB" + self._async_abort_entries_match({CONF_PORT: self._device}) + if not await self._test_connection(): + return self.async_abort(reason="cannot_connect") # call the config step self._set_confirm_only() return await self.async_step_discovery_confirm() @@ -92,7 +138,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle Discovery confirmation.""" if user_input is not None: - return self._create_device(self._title, self._device) + return self._create_device() return self.async_show_form( step_id="discovery_confirm", diff --git a/homeassistant/components/velbus/const.py b/homeassistant/components/velbus/const.py index b40f64e8607..f42e449bdcc 100644 --- a/homeassistant/components/velbus/const.py +++ b/homeassistant/components/velbus/const.py @@ -14,6 +14,7 @@ DOMAIN: Final = "velbus" CONF_CONFIG_ENTRY: Final = "config_entry" CONF_INTERFACE: Final = "interface" CONF_MEMO_TEXT: Final = "memo_text" +CONF_TLS: Final = "tls" SERVICE_SCAN: Final = "scan" SERVICE_SYNC: Final = "sync_clock" diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml index 0ad3e3ce485..829f48e6f52 100644 --- a/homeassistant/components/velbus/quality_scale.yaml +++ b/homeassistant/components/velbus/quality_scale.yaml @@ -8,10 +8,7 @@ rules: brands: done common-modules: done config-flow-test-coverage: done - config-flow: - status: todo - comment: | - Dynamically build up the port parameter based on inputs provided by the user, do not fill-in a name parameter, build it up in the config flow + config-flow: done dependency-transparency: done docs-actions: done docs-high-level-description: done diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index 69fc3d661e9..895f883678d 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -7,6 +7,32 @@ "name": "The name for this Velbus connection", "port": "Connection string" } + }, + "network": { + "title": "TCP/IP configuration", + "data": { + "tls": "Use TLS (secure connection)", + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "tls": "Enable this if you use a secure connection to your velbus interface, like a Signum.", + "host": "The IP address or hostname of the velbus interface.", + "port": "The port number of the velbus interface.", + "password": "The password of the velbus interface, this is only needed if the interface is password protected." + }, + "description": "TCP/IP configuration, in case you use a Signum, velserv, velbus-tcp or any other velbus to TCP/IP interface." + }, + "usbselect": { + "title": "USB configuration", + "data": { + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "port": "Select the serial port for your velbus USB interface." + }, + "description": "Select the serial port for your velbus USB interface." } }, "error": { diff --git a/tests/components/velbus/snapshots/test_diagnostics.ambr b/tests/components/velbus/snapshots/test_diagnostics.ambr index c8bff1841e8..a280bf4c9c2 100644 --- a/tests/components/velbus/snapshots/test_diagnostics.ambr +++ b/tests/components/velbus/snapshots/test_diagnostics.ambr @@ -10,7 +10,7 @@ 'discovery_keys': dict({ }), 'domain': 'velbus', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index 04b6a51043f..ee714624b45 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -7,14 +7,14 @@ import pytest import serial.tools.list_ports from velbusaio.exceptions import VelbusConnectionFailed -from homeassistant.components.velbus.const import DOMAIN +from homeassistant.components.velbus.const import CONF_TLS, DOMAIN from homeassistant.config_entries import SOURCE_USB, SOURCE_USER -from homeassistant.const import CONF_NAME, CONF_PORT, CONF_SOURCE +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.usb import UsbServiceInfo -from .const import PORT_SERIAL, PORT_TCP +from .const import PORT_SERIAL from tests.common import MockConfigEntry @@ -27,6 +27,8 @@ DISCOVERY_INFO = UsbServiceInfo( manufacturer="Velleman", ) +USB_DEV = "/dev/ttyACME100 - Some serial port, s/n: 1234 - Virtual serial port" + def com_port(): """Mock of a serial port.""" @@ -38,23 +40,15 @@ def com_port(): return port -@pytest.fixture(name="controller") -def mock_controller() -> Generator[MagicMock]: - """Mock a successful velbus controller.""" - with patch( - "homeassistant.components.velbus.config_flow.velbusaio.controller.Velbus", - autospec=True, - ) as controller: - yield controller - - @pytest.fixture(autouse=True) def override_async_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" - with patch( - "homeassistant.components.velbus.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry + with ( + patch( + "homeassistant.components.velbus.async_setup_entry", return_value=True + ) as mock, + ): + yield mock @pytest.fixture(name="controller_connection_failed") @@ -65,73 +59,126 @@ def mock_controller_connection_failed(): @pytest.mark.usefixtures("controller") -async def test_user(hass: HomeAssistant) -> None: - """Test user config.""" - # simple user form +async def test_user_network_succes(hass: HomeAssistant) -> None: + """Test user network config.""" + # inttial menu show result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) assert result assert result.get("flow_id") - assert result.get("type") is FlowResultType.FORM + assert result.get("type") is FlowResultType.MENU assert result.get("step_id") == "user" - - # try with a serial port - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: "Velbus Test Serial", CONF_PORT: PORT_SERIAL}, + assert result.get("menu_options") == ["network", "usbselect"] + # select the network option + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "network"}, + ) + assert result.get("type") is FlowResultType.FORM + # fill in the network form + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + { + CONF_TLS: False, + CONF_HOST: "velbus", + CONF_PORT: 6000, + CONF_PASSWORD: "", + }, ) assert result assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "velbus_test_serial" + assert result.get("title") == "Velbus Network" + data = result.get("data") + assert data + assert data[CONF_PORT] == "velbus:6000" + + +@pytest.mark.usefixtures("controller") +async def test_user_network_succes_tls(hass: HomeAssistant) -> None: + """Test user network config.""" + # inttial menu show + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result + assert result.get("flow_id") + assert result.get("type") is FlowResultType.MENU + assert result.get("step_id") == "user" + assert result.get("menu_options") == ["network", "usbselect"] + # select the network option + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "network"}, + ) + assert result["type"] is FlowResultType.FORM + # fill in the network form + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + { + CONF_TLS: True, + CONF_HOST: "velbus", + CONF_PORT: 6000, + CONF_PASSWORD: "password", + }, + ) + assert result + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "Velbus Network" + data = result.get("data") + assert data + assert data[CONF_PORT] == "tls://password@velbus:6000" + + +@pytest.mark.usefixtures("controller") +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_user_usb_succes(hass: HomeAssistant) -> None: + """Test user usb step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "usbselect"}, + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PORT: USB_DEV, + }, + ) + assert result + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "Velbus USB" data = result.get("data") assert data assert data[CONF_PORT] == PORT_SERIAL - # try with a ip:port combination - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: "Velbus Test TCP", CONF_PORT: PORT_TCP}, - ) - assert result - assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "velbus_test_tcp" - data = result.get("data") - assert data - assert data[CONF_PORT] == PORT_TCP - -@pytest.mark.usefixtures("controller_connection_failed") -async def test_user_fail(hass: HomeAssistant) -> None: - """Test user config.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: "Velbus Test Serial", CONF_PORT: PORT_SERIAL}, - ) - assert result - assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {CONF_PORT: "cannot_connect"} - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: "Velbus Test TCP", CONF_PORT: PORT_TCP}, - ) - assert result - assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {CONF_PORT: "cannot_connect"} - - -@pytest.mark.usefixtures("config_entry") -async def test_abort_if_already_setup(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("controller") +async def test_network_abort_if_already_setup(hass: HomeAssistant) -> None: """Test we abort if Velbus is already setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_PORT: "127.0.0.1:3788"}, + ) + entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_PORT: PORT_TCP, CONF_NAME: "velbus test"}, + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "network"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TLS: False, + CONF_HOST: "127.0.0.1", + CONF_PORT: 3788, + CONF_PASSWORD: "", + }, ) assert result assert result.get("type") is FlowResultType.ABORT @@ -156,7 +203,7 @@ async def test_flow_usb(hass: HomeAssistant) -> None: user_input={}, ) assert result - assert result["result"].unique_id == "0B1B:10CF_1234_Velleman_Velbus VMB1USB" + assert result["result"].unique_id == "1234" assert result.get("type") is FlowResultType.CREATE_ENTRY @@ -167,13 +214,23 @@ async def test_flow_usb_if_already_setup(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, data={CONF_PORT: PORT_SERIAL}, - unique_id="0B1B:10CF_1234_Velleman_Velbus VMB1USB", ) entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USB}, - data=DISCOVERY_INFO, + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "usbselect"}, + ) + assert result + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PORT: USB_DEV, + }, ) assert result assert result.get("type") is FlowResultType.ABORT diff --git a/tests/components/velbus/test_init.py b/tests/components/velbus/test_init.py index 3285099f2a2..2d28ba81cb1 100644 --- a/tests/components/velbus/test_init.py +++ b/tests/components/velbus/test_init.py @@ -16,6 +16,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from . import init_integration +from .const import PORT_TCP from tests.common import MockConfigEntry @@ -107,16 +108,41 @@ async def test_migrate_config_entry( """Test successful migration of entry data.""" legacy_config = {CONF_NAME: "fake_name", CONF_PORT: "1.2.3.4:5678"} entry = MockConfigEntry(domain=DOMAIN, unique_id="my own id", data=legacy_config) - entry.add_to_hass(hass) - - assert dict(entry.data) == legacy_config assert entry.version == 1 + assert entry.minor_version == 1 + + entry.add_to_hass(hass) # test in case we do not have a cache with patch("os.path.isdir", return_value=True), patch("shutil.rmtree"): await hass.config_entries.async_setup(entry.entry_id) assert dict(entry.data) == legacy_config assert entry.version == 2 + assert entry.minor_version == 2 + + +@pytest.mark.parametrize( + ("unique_id", "expected"), + [("vid:pid_serial_manufacturer_decription", "serial"), (None, None)], +) +async def test_migrate_config_entry_unique_id( + hass: HomeAssistant, + controller: AsyncMock, + unique_id: str, + expected: str, +) -> None: + """Test the migration of unique id.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_PORT: PORT_TCP, CONF_NAME: "velbus home"}, + unique_id=unique_id, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + assert entry.unique_id == expected + assert entry.version == 2 + assert entry.minor_version == 2 async def test_api_call( From 507c0739df39529a4d77a9a44b315e084eba17c8 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Tue, 25 Feb 2025 22:14:04 +0900 Subject: [PATCH 2724/2987] Add missing ATTR_HVAC_MODE of async_set_temperature to LG ThinQ (#137621) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/climate.py | 56 ++++++-------------- 1 file changed, 17 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index 063705f5d0d..73678e209f7 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -2,7 +2,6 @@ from __future__ import annotations -from dataclasses import dataclass import logging from typing import Any @@ -10,6 +9,7 @@ from thinqconnect import DeviceType from thinqconnect.integration import ExtendedProperty from homeassistant.components.climate import ( + ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, SWING_OFF, @@ -28,31 +28,19 @@ from . import ThinqConfigEntry from .coordinator import DeviceDataUpdateCoordinator from .entity import ThinQEntity - -@dataclass(frozen=True, kw_only=True) -class ThinQClimateEntityDescription(ClimateEntityDescription): - """Describes ThinQ climate entity.""" - - min_temp: float | None = None - max_temp: float | None = None - step: float | None = None - - -DEVICE_TYPE_CLIMATE_MAP: dict[DeviceType, tuple[ThinQClimateEntityDescription, ...]] = { +DEVICE_TYPE_CLIMATE_MAP: dict[DeviceType, tuple[ClimateEntityDescription, ...]] = { DeviceType.AIR_CONDITIONER: ( - ThinQClimateEntityDescription( + ClimateEntityDescription( key=ExtendedProperty.CLIMATE_AIR_CONDITIONER, name=None, translation_key=ExtendedProperty.CLIMATE_AIR_CONDITIONER, ), ), DeviceType.SYSTEM_BOILER: ( - ThinQClimateEntityDescription( + ClimateEntityDescription( key=ExtendedProperty.CLIMATE_SYSTEM_BOILER, name=None, - min_temp=16, - max_temp=30, - step=1, + translation_key=ExtendedProperty.CLIMATE_SYSTEM_BOILER, ), ), } @@ -65,13 +53,7 @@ STR_TO_HVAC: dict[str, HVACMode] = { "heat": HVACMode.HEAT, } -HVAC_TO_STR: dict[HVACMode, str] = { - HVACMode.AUTO: "auto", - HVACMode.COOL: "cool", - HVACMode.DRY: "air_dry", - HVACMode.FAN_ONLY: "fan", - HVACMode.HEAT: "heat", -} +HVAC_TO_STR = {v: k for k, v in STR_TO_HVAC.items()} THINQ_PRESET_MODE: list[str] = ["air_clean", "aroma", "energy_saving"] @@ -111,12 +93,10 @@ async def async_setup_entry( class ThinQClimateEntity(ThinQEntity, ClimateEntity): """Represent a thinq climate platform.""" - entity_description: ThinQClimateEntityDescription - def __init__( self, coordinator: DeviceDataUpdateCoordinator, - entity_description: ThinQClimateEntityDescription, + entity_description: ClimateEntityDescription, property_id: str, ) -> None: """Initialize a climate entity.""" @@ -190,18 +170,12 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self._attr_current_temperature = self.data.current_temp # Update min, max and step. - if (max_temp := self.entity_description.max_temp) is not None or ( - max_temp := self.data.max - ) is not None: - self._attr_max_temp = max_temp - if (min_temp := self.entity_description.min_temp) is not None or ( - min_temp := self.data.min - ) is not None: - self._attr_min_temp = min_temp - if (step := self.entity_description.step) is not None or ( - step := self.data.step - ) is not None: - self._attr_target_temperature_step = step + if self.data.max is not None: + self._attr_max_temp = self.data.max + if self.data.min is not None: + self._attr_min_temp = self.data.min + + self._attr_target_temperature_step = self.data.step # Update target temperatures. self._attr_target_temperature = self.data.target_temp @@ -342,6 +316,10 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self.property_id, kwargs, ) + if hvac_mode := kwargs.get(ATTR_HVAC_MODE): + await self.async_set_hvac_mode(HVACMode(hvac_mode)) + if hvac_mode == HVACMode.OFF: + return if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: if ( From d45fce86a9d9623e1fad38fdd0f941f1f1601607 Mon Sep 17 00:00:00 2001 From: Dan Bishop Date: Tue, 25 Feb 2025 13:18:12 +0000 Subject: [PATCH 2725/2987] Make Radarr units translatable (#139250) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/radarr/sensor.py | 2 -- homeassistant/components/radarr/strings.json | 6 ++++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index fa0cb95d549..a6d29ee9d1d 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -81,14 +81,12 @@ SENSOR_TYPES: dict[str, RadarrSensorEntityDescription[Any]] = { "movie": RadarrSensorEntityDescription[int]( key="movies", translation_key="movies", - native_unit_of_measurement="Movies", entity_registry_enabled_default=False, value_fn=lambda data, _: data, ), "queue": RadarrSensorEntityDescription[int]( key="queue", translation_key="queue", - native_unit_of_measurement="Movies", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL, value_fn=lambda data, _: data, diff --git a/homeassistant/components/radarr/strings.json b/homeassistant/components/radarr/strings.json index ec1baf6ffd8..cb624aff057 100644 --- a/homeassistant/components/radarr/strings.json +++ b/homeassistant/components/radarr/strings.json @@ -43,10 +43,12 @@ }, "sensor": { "movies": { - "name": "Movies" + "name": "Movies", + "unit_of_measurement": "[%key:component::radarr::entity::sensor::movies::name%]" }, "queue": { - "name": "Queue" + "name": "Queue", + "unit_of_measurement": "[%key:component::radarr::entity::sensor::movies::name%]" }, "start_time": { "name": "Start time" From 664e09790c7354be80710aa9b56716782168e7c3 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 25 Feb 2025 14:22:30 +0100 Subject: [PATCH 2726/2987] Improve Minecraft Server config flow tests (#139251) --- .../minecraft_server/quality_scale.yaml | 7 +- .../minecraft_server/test_config_flow.py | 202 ++++++++++-------- 2 files changed, 108 insertions(+), 101 deletions(-) diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml index a866969fc33..6cf1fc7772e 100644 --- a/homeassistant/components/minecraft_server/quality_scale.yaml +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -7,12 +7,7 @@ rules: brands: done common-modules: done config-flow: done - config-flow-test-coverage: - status: todo - comment: | - Merge test_show_config_form with full flow test. - Move full flow test to the top of all tests. - All test cases should end in either CREATE_ENTRY or ABORT. + config-flow-test-coverage: done dependency-transparency: done docs-actions: status: exempt diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 00e25028249..c57b74c6a65 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -26,8 +26,8 @@ USER_INPUT = { } -async def test_show_config_form(hass: HomeAssistant) -> None: - """Test if initial configuration form is shown.""" +async def test_full_flow_java(hass: HomeAssistant) -> None: + """Test config entry in case of a successful connection to a Java Edition server.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -35,96 +35,6 @@ async def test_show_config_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - -async def test_service_already_configured( - hass: HomeAssistant, bedrock_mock_config_entry: MockConfigEntry -) -> None: - """Test config flow abort if service is already configured.""" - bedrock_mock_config_entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.lookup", - return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), - ), - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.async_status", - return_value=TEST_BEDROCK_STATUS_RESPONSE, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_address_validation_failure(hass: HomeAssistant) -> None: - """Test error in case of a failed connection.""" - with ( - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.lookup", - side_effect=ValueError, - ), - patch( - "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", - side_effect=ValueError, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_java_connection_failure(hass: HomeAssistant) -> None: - """Test error in case of a failed connection to a Java Edition server.""" - with ( - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.lookup", - side_effect=ValueError, - ), - patch( - "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", - return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), - ), - patch( - "homeassistant.components.minecraft_server.api.JavaServer.async_status", - side_effect=OSError, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_bedrock_connection_failure(hass: HomeAssistant) -> None: - """Test error in case of a failed connection to a Bedrock Edition server.""" - with ( - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.lookup", - return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), - ), - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.async_status", - side_effect=OSError, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_java_connection(hass: HomeAssistant) -> None: - """Test config entry in case of a successful connection to a Java Edition server.""" with ( patch( "homeassistant.components.minecraft_server.api.BedrockServer.lookup", @@ -149,8 +59,15 @@ async def test_java_connection(hass: HomeAssistant) -> None: assert result["data"][CONF_TYPE] == MinecraftServerType.JAVA_EDITION -async def test_bedrock_connection(hass: HomeAssistant) -> None: +async def test_full_flow_bedrock(hass: HomeAssistant) -> None: """Test config entry in case of a successful connection to a Bedrock Edition server.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with ( patch( "homeassistant.components.minecraft_server.api.BedrockServer.lookup", @@ -171,8 +88,12 @@ async def test_bedrock_connection(hass: HomeAssistant) -> None: assert result["data"][CONF_TYPE] == MinecraftServerType.BEDROCK_EDITION -async def test_recovery(hass: HomeAssistant) -> None: - """Test config flow recovery (successful connection after a failed connection).""" +async def test_service_already_configured_java( + hass: HomeAssistant, java_mock_config_entry: MockConfigEntry +) -> None: + """Test config flow abort if a Java Edition server is already configured.""" + java_mock_config_entry.add_to_hass(hass) + with ( patch( "homeassistant.components.minecraft_server.api.BedrockServer.lookup", @@ -180,8 +101,99 @@ async def test_recovery(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + return_value=TEST_JAVA_STATUS_RESPONSE, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_service_already_configured_bedrock( + hass: HomeAssistant, bedrock_mock_config_entry: MockConfigEntry +) -> None: + """Test config flow abort if a Bedrock Edition server is already configured.""" + bedrock_mock_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.async_status", + return_value=TEST_BEDROCK_STATUS_RESPONSE, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_recovery_java(hass: HomeAssistant) -> None: + """Test config flow recovery with a Java Edition server (successful connection after a failed connection).""" + with ( + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + side_effect=OSError, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with ( + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + side_effect=ValueError, + ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + return_value=TEST_JAVA_STATUS_RESPONSE, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input=USER_INPUT + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == USER_INPUT[CONF_ADDRESS] + assert result2["data"][CONF_ADDRESS] == TEST_ADDRESS + assert result2["data"][CONF_TYPE] == MinecraftServerType.JAVA_EDITION + + +async def test_recovery_bedrock(hass: HomeAssistant) -> None: + """Test config flow recovery with a Bedrock Edition server (successful connection after a failed connection).""" + with ( + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.async_status", + side_effect=OSError, + ), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT From 7ba94a680dacf007eca5f26e5e356d9202bee543 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 25 Feb 2025 14:46:43 +0100 Subject: [PATCH 2727/2987] Revert "Bump Stookwijzer to 1.5.7" (#139253) --- homeassistant/components/stookwijzer/__init__.py | 2 ++ homeassistant/components/stookwijzer/config_flow.py | 2 ++ homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index cb198749c52..d8b9561bde9 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -9,6 +9,7 @@ from stookwijzer import Stookwijzer from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator @@ -43,6 +44,7 @@ async def async_migrate_entry( if entry.version == 1: latitude, longitude = await Stookwijzer.async_transform_coordinates( + async_get_clientsession(hass), entry.data[CONF_LOCATION][CONF_LATITUDE], entry.data[CONF_LOCATION][CONF_LONGITUDE], ) diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index 124b0f8bfbb..32b4836763f 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import LocationSelector from .const import DOMAIN @@ -26,6 +27,7 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: latitude, longitude = await Stookwijzer.async_transform_coordinates( + async_get_clientsession(self.hass), user_input[CONF_LOCATION][CONF_LATITUDE], user_input[CONF_LOCATION][CONF_LONGITUDE], ) diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index e8f6081b9be..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.7"] + "requirements": ["stookwijzer==1.5.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1274cd99deb..e3576e8618b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2811,7 +2811,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.7 +stookwijzer==1.5.4 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e3238a5fe7..baefe19b71b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2272,7 +2272,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.7 +stookwijzer==1.5.4 # homeassistant.components.streamlabswater streamlabswater==1.0.1 From a3bc55f49bcecb2055cff1f29f492abddf8ce37e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 25 Feb 2025 14:50:12 +0100 Subject: [PATCH 2728/2987] Add parallel updates to Home Connect (#139255) --- homeassistant/components/home_connect/binary_sensor.py | 2 ++ homeassistant/components/home_connect/button.py | 2 ++ homeassistant/components/home_connect/light.py | 2 ++ homeassistant/components/home_connect/number.py | 2 ++ homeassistant/components/home_connect/select.py | 2 ++ homeassistant/components/home_connect/sensor.py | 2 ++ homeassistant/components/home_connect/switch.py | 1 + homeassistant/components/home_connect/time.py | 2 ++ 8 files changed, 15 insertions(+) diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index 57ede4b2ff4..1f82aa71766 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -38,6 +38,8 @@ from .coordinator import ( ) from .entity import HomeConnectEntity +PARALLEL_UPDATES = 0 + REFRIGERATION_DOOR_BOOLEAN_MAP = { REFRIGERATION_STATUS_DOOR_CLOSED: False, REFRIGERATION_STATUS_DOOR_OPEN: True, diff --git a/homeassistant/components/home_connect/button.py b/homeassistant/components/home_connect/button.py index 138979409a5..0a5538ec588 100644 --- a/homeassistant/components/home_connect/button.py +++ b/homeassistant/components/home_connect/button.py @@ -18,6 +18,8 @@ from .coordinator import ( from .entity import HomeConnectEntity from .utils import get_dict_from_home_connect_error +PARALLEL_UPDATES = 1 + class HomeConnectCommandButtonEntityDescription(ButtonEntityDescription): """Describes Home Connect button entity.""" diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 9f9016855e9..72c6b9aaa2b 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -36,6 +36,8 @@ from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class HomeConnectLightEntityDescription(LightEntityDescription): diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 27b4bc7eb6f..404f063946c 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -30,6 +30,8 @@ from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 + UNIT_MAP = { "seconds": UnitOfTime.SECONDS, "ml": UnitOfVolume.MILLILITERS, diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index d5657387358..ef3e2ccbf82 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -50,6 +50,8 @@ from .coordinator import ( from .entity import HomeConnectEntity, HomeConnectOptionEntity from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error +PARALLEL_UPDATES = 1 + FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = { bsh_key_to_translation_key(option): option for option in ( diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 88dd017e7d9..be0b621b508 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -27,6 +27,8 @@ from .const import ( from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity +PARALLEL_UPDATES = 0 + EVENT_OPTIONS = ["confirmed", "off", "present"] diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index d5a92eef2a4..6f9aa0e679f 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -42,6 +42,7 @@ from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 SWITCHES = ( SwitchEntityDescription( diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index 3d16dd37e21..a1761219d30 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -23,6 +23,8 @@ from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity from .utils import get_dict_from_home_connect_error +PARALLEL_UPDATES = 1 + TIME_ENTITIES = ( TimeEntityDescription( key=SettingKey.BSH_COMMON_ALARM_CLOCK, From d4dd8fd9020157971084d43a7e534196e5752852 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2025 14:01:45 +0000 Subject: [PATCH 2729/2987] Bump fnv-hash-fast to 1.2.6 (#139246) --- homeassistant/components/homekit/manifest.json | 2 +- homeassistant/components/recorder/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, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 63254384666..f9a31489ca4 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -10,7 +10,7 @@ "loggers": ["pyhap"], "requirements": [ "HAP-python==4.9.2", - "fnv-hash-fast==1.2.3", + "fnv-hash-fast==1.2.6", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 6f555704670..40513c8ea24 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -8,7 +8,7 @@ "quality_scale": "internal", "requirements": [ "SQLAlchemy==2.0.38", - "fnv-hash-fast==1.2.3", + "fnv-hash-fast==1.2.6", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 335a3b1da29..6bcac95366d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 dbus-fast==2.33.0 -fnv-hash-fast==1.2.3 +fnv-hash-fast==1.2.6 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.24.0 diff --git a/pyproject.toml b/pyproject.toml index 1224cc0c70e..7a970b405a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "certifi>=2021.5.30", "ciso8601==2.3.2", "cronsim==2.6", - "fnv-hash-fast==1.2.3", + "fnv-hash-fast==1.2.6", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration "hass-nabucasa==0.92.0", diff --git a/requirements.txt b/requirements.txt index 1ec004d7f65..f002f0d6ecc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ bcrypt==4.2.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 -fnv-hash-fast==1.2.3 +fnv-hash-fast==1.2.6 hass-nabucasa==0.92.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index e3576e8618b..dcb11cf69c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -946,7 +946,7 @@ flux-led==1.1.3 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.2.3 +fnv-hash-fast==1.2.6 # homeassistant.components.foobot foobot_async==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index baefe19b71b..04c2d8eb789 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -805,7 +805,7 @@ flux-led==1.1.3 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.2.3 +fnv-hash-fast==1.2.6 # homeassistant.components.foobot foobot_async==1.0.0 From b8b153b87f801269076300a58d768e885f40242d Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Tue, 25 Feb 2025 06:07:42 -0800 Subject: [PATCH 2730/2987] Make default dim level configurable in Lutron (#137127) --- .../components/lutron/config_flow.py | 48 ++++++++++++++++++- homeassistant/components/lutron/const.py | 4 ++ homeassistant/components/lutron/light.py | 20 ++++++-- homeassistant/components/lutron/strings.json | 9 ++++ 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lutron/config_flow.py b/homeassistant/components/lutron/config_flow.py index 6a48e0d4b67..3f55a2b131b 100644 --- a/homeassistant/components/lutron/config_flow.py +++ b/homeassistant/components/lutron/config_flow.py @@ -9,10 +9,21 @@ from urllib.error import HTTPError from pylutron import Lutron import voluptuous as vol -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_USERNAME +from homeassistant.core import callback +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, +) -from .const import DOMAIN +from .const import CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -68,3 +79,36 @@ class LutronConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlowHandler: + """Get the options flow for this handler.""" + return OptionsFlowHandler() + + +class OptionsFlowHandler(OptionsFlow): + """Handle option flow for lutron.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Required( + CONF_DEFAULT_DIMMER_LEVEL, + default=self.config_entry.options.get( + CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL + ), + ): NumberSelector( + NumberSelectorConfig(min=1, max=255, mode=NumberSelectorMode.SLIDER) + ) + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/lutron/const.py b/homeassistant/components/lutron/const.py index 3862f7eb1d8..b69e35f38ba 100644 --- a/homeassistant/components/lutron/const.py +++ b/homeassistant/components/lutron/const.py @@ -1,3 +1,7 @@ """Lutron constants.""" DOMAIN = "lutron" + +CONF_DEFAULT_DIMMER_LEVEL = "default_dimmer_level" + +DEFAULT_DIMMER_LEVEL = 255 / 2 diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index 58183fb0a38..a7489e13b7b 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from pylutron import Output +from pylutron import Lutron, LutronEntity, Output from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -20,6 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN, LutronData +from .const import CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL from .entity import LutronDevice @@ -37,7 +38,7 @@ async def async_setup_entry( async_add_entities( ( - LutronLight(area_name, device, entry_data.client) + LutronLight(area_name, device, entry_data.client, config_entry) for area_name, device in entry_data.lights ), True, @@ -64,6 +65,17 @@ class LutronLight(LutronDevice, LightEntity): _prev_brightness: int | None = None _attr_name = None + def __init__( + self, + area_name: str, + lutron_device: LutronEntity, + controller: Lutron, + config_entry: ConfigEntry, + ) -> None: + """Initialize the device.""" + super().__init__(area_name, lutron_device, controller) + self._config_entry = config_entry + def turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" if flash := kwargs.get(ATTR_FLASH): @@ -72,7 +84,9 @@ class LutronLight(LutronDevice, LightEntity): if ATTR_BRIGHTNESS in kwargs and self._lutron_device.is_dimmable: brightness = kwargs[ATTR_BRIGHTNESS] elif self._prev_brightness == 0: - brightness = 255 / 2 + brightness = self._config_entry.options.get( + CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL + ) else: brightness = self._prev_brightness self._prev_brightness = brightness diff --git a/homeassistant/components/lutron/strings.json b/homeassistant/components/lutron/strings.json index b73e0bd15ed..37db509e294 100644 --- a/homeassistant/components/lutron/strings.json +++ b/homeassistant/components/lutron/strings.json @@ -19,6 +19,15 @@ } } }, + "options": { + "step": { + "init": { + "data": { + "default_dimmer_level": "Default light level when first turning on a light from Home Assistant" + } + } + } + }, "entity": { "event": { "button": { From b9dbf07a5e7e00a8e04c3b5c683ad11621f1658b Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:09:58 +0100 Subject: [PATCH 2731/2987] Set PARALLEL_UPDATES in all Minecraft Server platforms (#139259) --- homeassistant/components/minecraft_server/binary_sensor.py | 3 +++ .../components/minecraft_server/quality_scale.yaml | 6 +----- homeassistant/components/minecraft_server/sensor.py | 3 +++ 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 39e12228451..a7279040a6d 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -22,6 +22,9 @@ BINARY_SENSOR_DESCRIPTIONS = [ ), ] +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml index 6cf1fc7772e..61a975632bb 100644 --- a/homeassistant/components/minecraft_server/quality_scale.yaml +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -51,11 +51,7 @@ rules: log-when-unavailable: status: done comment: Handled by coordinator. - parallel-updates: - status: todo - comment: | - Although this is handled by the coordinator and no service actions are provided, - PARALLEL_UPDATES should still be set to 0 in binary_sensor and sensor according to the rule. + parallel-updates: done reauthentication-flow: status: exempt comment: No authentication is required for the integration. diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 6effa53fbf2..cfc16c7724d 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -30,6 +30,9 @@ KEY_VERSION = "version" UNIT_PLAYERS_MAX = "players" UNIT_PLAYERS_ONLINE = "players" +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class MinecraftServerSensorEntityDescription(SensorEntityDescription): From 75660469956aaf7c9039f4aaacfbc0d1e28c2677 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 25 Feb 2025 16:10:03 +0200 Subject: [PATCH 2732/2987] Bump aiowebostv to 0.7.1 (#139244) --- 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 45c9628539c..06cbca32453 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.7.0"], + "requirements": ["aiowebostv==0.7.1"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index dcb11cf69c7..f7b9f2425a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -425,7 +425,7 @@ aiowatttime==0.1.1 aiowebdav2==0.3.0 # homeassistant.components.webostv -aiowebostv==0.7.0 +aiowebostv==0.7.1 # homeassistant.components.withings aiowithings==3.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 04c2d8eb789..90ea8c808c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -407,7 +407,7 @@ aiowatttime==0.1.1 aiowebdav2==0.3.0 # homeassistant.components.webostv -aiowebostv==0.7.0 +aiowebostv==0.7.1 # homeassistant.components.withings aiowithings==3.1.6 From 923ec71bf673582508128bcccb409b91b0453de0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Feb 2025 15:10:21 +0100 Subject: [PATCH 2733/2987] Consistently capitalize "Velbus" brand name, camel-case "VelServ" (#139257) --- homeassistant/components/velbus/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index 895f883678d..a50395af115 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -17,12 +17,12 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "tls": "Enable this if you use a secure connection to your velbus interface, like a Signum.", - "host": "The IP address or hostname of the velbus interface.", - "port": "The port number of the velbus interface.", - "password": "The password of the velbus interface, this is only needed if the interface is password protected." + "tls": "Enable this if you use a secure connection to your Velbus interface, like a Signum.", + "host": "The IP address or hostname of the Velbus interface.", + "port": "The port number of the Velbus interface.", + "password": "The password of the Velbus interface, this is only needed if the interface is password protected." }, - "description": "TCP/IP configuration, in case you use a Signum, velserv, velbus-tcp or any other velbus to TCP/IP interface." + "description": "TCP/IP configuration, in case you use a Signum, VelServ, velbus-tcp or any other Velbus to TCP/IP interface." }, "usbselect": { "title": "USB configuration", @@ -30,9 +30,9 @@ "port": "[%key:common::config_flow::data::port%]" }, "data_description": { - "port": "Select the serial port for your velbus USB interface." + "port": "Select the serial port for your Velbus USB interface." }, - "description": "Select the serial port for your velbus USB interface." + "description": "Select the serial port for your Velbus USB interface." } }, "error": { From 1633700a5811f7fb9219976255cc3e4306a4c637 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2025 14:25:07 +0000 Subject: [PATCH 2734/2987] Bump cached-ipaddress to 0.9.2 (#139245) --- homeassistant/components/dhcp/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/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index 65d43f80abe..5b3a5abd26f 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -16,6 +16,6 @@ "requirements": [ "aiodhcpwatcher==1.1.1", "aiodiscover==2.6.1", - "cached-ipaddress==0.8.1" + "cached-ipaddress==0.9.2" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6bcac95366d..e4f9466a10e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.4 bluetooth-data-tools==1.23.4 -cached-ipaddress==0.8.1 +cached-ipaddress==0.9.2 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 diff --git a/requirements_all.txt b/requirements_all.txt index f7b9f2425a6..5e6841ecf1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -686,7 +686,7 @@ btsmarthub-devicelist==0.2.3 buienradar==1.0.6 # homeassistant.components.dhcp -cached-ipaddress==0.8.1 +cached-ipaddress==0.9.2 # homeassistant.components.caldav caldav==1.3.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 90ea8c808c4..46ce49503be 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -597,7 +597,7 @@ bthome-ble==3.12.4 buienradar==1.0.6 # homeassistant.components.dhcp -cached-ipaddress==0.8.1 +cached-ipaddress==0.9.2 # homeassistant.components.caldav caldav==1.3.9 From 1f93d2cefb3cc2cde56fb7be25cd78aa3aa8f5cb Mon Sep 17 00:00:00 2001 From: Dan Bishop Date: Tue, 25 Feb 2025 14:26:22 +0000 Subject: [PATCH 2735/2987] Make Sonarr component's units translatable (#139254) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/sonarr/sensor.py | 5 ----- homeassistant/components/sonarr/strings.json | 15 ++++++++++----- tests/components/sonarr/test_sensor.py | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index 6a0293e455c..983ac76d93e 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -90,7 +90,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "commands": SonarrSensorEntityDescription[list[Command]]( key="commands", translation_key="commands", - native_unit_of_measurement="Commands", entity_registry_enabled_default=False, value_fn=len, attributes_fn=lambda data: {c.name: c.status for c in data}, @@ -107,7 +106,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "queue": SonarrSensorEntityDescription[SonarrQueue]( key="queue", translation_key="queue", - native_unit_of_measurement="Episodes", entity_registry_enabled_default=False, value_fn=lambda data: data.totalRecords, attributes_fn=get_queue_attr, @@ -115,7 +113,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "series": SonarrSensorEntityDescription[list[SonarrSeries]]( key="series", translation_key="series", - native_unit_of_measurement="Series", entity_registry_enabled_default=False, value_fn=len, attributes_fn=lambda data: { @@ -129,7 +126,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "upcoming": SonarrSensorEntityDescription[list[SonarrCalendar]]( key="upcoming", translation_key="upcoming", - native_unit_of_measurement="Episodes", value_fn=len, attributes_fn=lambda data: { e.series.title: f"S{e.seasonNumber:02d}E{e.episodeNumber:02d}" for e in data @@ -138,7 +134,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "wanted": SonarrSensorEntityDescription[SonarrWantedMissing]( key="wanted", translation_key="wanted", - native_unit_of_measurement="Episodes", entity_registry_enabled_default=False, value_fn=lambda data: data.totalRecords, attributes_fn=get_wanted_attr, diff --git a/homeassistant/components/sonarr/strings.json b/homeassistant/components/sonarr/strings.json index 5b17f3283e8..940ec650270 100644 --- a/homeassistant/components/sonarr/strings.json +++ b/homeassistant/components/sonarr/strings.json @@ -37,22 +37,27 @@ "entity": { "sensor": { "commands": { - "name": "Commands" + "name": "Commands", + "unit_of_measurement": "commands" }, "diskspace": { "name": "Disk space" }, "queue": { - "name": "Queue" + "name": "Queue", + "unit_of_measurement": "episodes" }, "series": { - "name": "Shows" + "name": "Shows", + "unit_of_measurement": "series" }, "upcoming": { - "name": "Upcoming" + "name": "Upcoming", + "unit_of_measurement": "[%key:component::sonarr::entity::sensor::queue::unit_of_measurement%]" }, "wanted": { - "name": "Wanted" + "name": "Wanted", + "unit_of_measurement": "[%key:component::sonarr::entity::sensor::queue::unit_of_measurement%]" } } } diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py index 3ccff4c88ba..78f03e8b6de 100644 --- a/tests/components/sonarr/test_sensor.py +++ b/tests/components/sonarr/test_sensor.py @@ -49,7 +49,7 @@ async def test_sensors( state = hass.states.get("sensor.sonarr_commands") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Commands" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "commands" assert state.state == "2" state = hass.states.get("sensor.sonarr_disk_space") @@ -60,25 +60,25 @@ async def test_sensors( state = hass.states.get("sensor.sonarr_queue") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Episodes" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "episodes" assert state.attributes.get("The Andy Griffith Show S01E01") == "100.00%" assert state.state == "1" state = hass.states.get("sensor.sonarr_shows") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Series" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "series" assert state.attributes.get("The Andy Griffith Show") == "0/0 Episodes" assert state.state == "1" state = hass.states.get("sensor.sonarr_upcoming") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Episodes" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "episodes" assert state.attributes.get("Bob's Burgers") == "S04E11" assert state.state == "1" state = hass.states.get("sensor.sonarr_wanted") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Episodes" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "episodes" assert state.attributes.get("Bob's Burgers S04E11") == "2014-01-26T17:30:00-08:00" assert ( state.attributes.get("The Andy Griffith Show S01E01") From 776501f5e65789d5ff20e154f01542411f01cdff Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:41:36 +0100 Subject: [PATCH 2736/2987] Bump stookwijzer to 1.5.8 (#139258) --- homeassistant/components/stookwijzer/__init__.py | 2 -- homeassistant/components/stookwijzer/config_flow.py | 2 -- homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index d8b9561bde9..cb198749c52 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -9,7 +9,6 @@ from stookwijzer import Stookwijzer from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator @@ -44,7 +43,6 @@ async def async_migrate_entry( if entry.version == 1: latitude, longitude = await Stookwijzer.async_transform_coordinates( - async_get_clientsession(hass), entry.data[CONF_LOCATION][CONF_LATITUDE], entry.data[CONF_LOCATION][CONF_LONGITUDE], ) diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index 32b4836763f..124b0f8bfbb 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import LocationSelector from .const import DOMAIN @@ -27,7 +26,6 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: latitude, longitude = await Stookwijzer.async_transform_coordinates( - async_get_clientsession(self.hass), user_input[CONF_LOCATION][CONF_LATITUDE], user_input[CONF_LOCATION][CONF_LONGITUDE], ) diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 8243b903e8d..86fccf64db1 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.4"] + "requirements": ["stookwijzer==1.5.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5e6841ecf1e..55b4d140321 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2811,7 +2811,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.4 +stookwijzer==1.5.8 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 46ce49503be..072250cad20 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2272,7 +2272,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.4 +stookwijzer==1.5.8 # homeassistant.components.streamlabswater streamlabswater==1.0.1 From 2b55f3af3677b2a3c5d98b38f08d8447879fbd37 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 25 Feb 2025 15:42:12 +0100 Subject: [PATCH 2737/2987] Bump Velbus to bronze quality scale (#139256) --- homeassistant/components/velbus/manifest.json | 1 + script/hassfest/quality_scale.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 960f127d16e..29504277651 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,6 +13,7 @@ "velbus-packet", "velbus-protocol" ], + "quality_scale": "bronze", "requirements": ["velbus-aio==2025.1.1"], "usb": [ { diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 195dd93e630..d155cc74acb 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2167,7 +2167,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "velux", "venstar", "vera", - "velbus", "verisure", "versasense", "version", From 3059d069600cad10676bff47df0e718964e1bc66 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Tue, 25 Feb 2025 15:49:12 +0100 Subject: [PATCH 2738/2987] Add Homee number platform (#138962) Co-authored-by: Joostlek --- homeassistant/components/homee/__init__.py | 1 + homeassistant/components/homee/button.py | 2 +- homeassistant/components/homee/cover.py | 22 +- homeassistant/components/homee/entity.py | 6 +- homeassistant/components/homee/light.py | 12 +- homeassistant/components/homee/number.py | 130 +++ homeassistant/components/homee/strings.json | 44 + homeassistant/components/homee/switch.py | 4 +- homeassistant/components/homee/valve.py | 2 +- tests/components/homee/fixtures/numbers.json | 337 ++++++++ .../homee/snapshots/test_number.ambr | 802 ++++++++++++++++++ tests/components/homee/test_number.py | 74 ++ 12 files changed, 1414 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/homee/number.py create mode 100644 tests/components/homee/fixtures/numbers.json create mode 100644 tests/components/homee/snapshots/test_number.ambr create mode 100644 tests/components/homee/test_number.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index c576fa6d23c..d7785ad9104 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -18,6 +18,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.COVER, Platform.LIGHT, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, Platform.VALVE, diff --git a/homeassistant/components/homee/button.py b/homeassistant/components/homee/button.py index f39ee3f5a87..af6d769c1dc 100644 --- a/homeassistant/components/homee/button.py +++ b/homeassistant/components/homee/button.py @@ -75,4 +75,4 @@ class HomeeButton(HomeeEntity, ButtonEntity): async def async_press(self) -> None: """Handle the button press.""" - await self.async_set_value(1) + await self.async_set_homee_value(1) diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py index a3695f7ade6..6e7e4fd5c55 100644 --- a/homeassistant/components/homee/cover.py +++ b/homeassistant/components/homee/cover.py @@ -205,17 +205,17 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): """Open the cover.""" assert self._open_close_attribute is not None if not self._open_close_attribute.is_reversed: - await self.async_set_value(self._open_close_attribute, 0) + await self.async_set_homee_value(self._open_close_attribute, 0) else: - await self.async_set_value(self._open_close_attribute, 1) + await self.async_set_homee_value(self._open_close_attribute, 1) async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" assert self._open_close_attribute is not None if not self._open_close_attribute.is_reversed: - await self.async_set_value(self._open_close_attribute, 1) + await self.async_set_homee_value(self._open_close_attribute, 1) else: - await self.async_set_value(self._open_close_attribute, 0) + await self.async_set_homee_value(self._open_close_attribute, 0) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" @@ -230,12 +230,12 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): homee_max = attribute.maximum homee_position = (position / 100) * (homee_max - homee_min) + homee_min - await self.async_set_value(attribute, homee_position) + await self.async_set_homee_value(attribute, homee_position) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" if self._open_close_attribute is not None: - await self.async_set_value(self._open_close_attribute, 2) + await self.async_set_homee_value(self._open_close_attribute, 2) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" @@ -245,9 +245,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): ) ) is not None: if not slat_attribute.is_reversed: - await self.async_set_value(slat_attribute, 2) + await self.async_set_homee_value(slat_attribute, 2) else: - await self.async_set_value(slat_attribute, 1) + await self.async_set_homee_value(slat_attribute, 1) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" @@ -257,9 +257,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): ) ) is not None: if not slat_attribute.is_reversed: - await self.async_set_value(slat_attribute, 1) + await self.async_set_homee_value(slat_attribute, 1) else: - await self.async_set_value(slat_attribute, 2) + await self.async_set_homee_value(slat_attribute, 2) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" @@ -276,4 +276,4 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): homee_max = attribute.maximum homee_position = (position / 100) * (homee_max - homee_min) + homee_min - await self.async_set_value(attribute, homee_position) + await self.async_set_homee_value(attribute, homee_position) diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py index 5a7f34b1c37..165a655d82b 100644 --- a/homeassistant/components/homee/entity.py +++ b/homeassistant/components/homee/entity.py @@ -54,7 +54,7 @@ class HomeeEntity(Entity): """Return the availability of the underlying node.""" return (self._attribute.state == AttributeState.NORMAL) and self._host_connected - async def async_set_value(self, value: float) -> None: + async def async_set_homee_value(self, value: float) -> None: """Set an attribute value on the homee node.""" homee = self._entry.runtime_data try: @@ -144,7 +144,9 @@ class HomeeNodeEntity(Entity): return None - async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None: + async def async_set_homee_value( + self, attribute: HomeeAttribute, value: float + ) -> None: """Set an attribute value on the homee node.""" homee = self._entry.runtime_data try: diff --git a/homeassistant/components/homee/light.py b/homeassistant/components/homee/light.py index 12d127c0945..b9c4460075a 100644 --- a/homeassistant/components/homee/light.py +++ b/homeassistant/components/homee/light.py @@ -175,24 +175,26 @@ class HomeeLight(HomeeNodeEntity, LightEntity): kwargs[ATTR_BRIGHTNESS], ) ) - await self.async_set_value(self._dimmer_attr, target_value) + await self.async_set_homee_value(self._dimmer_attr, target_value) else: # If no brightness value is given, just turn on. - await self.async_set_value(self._on_off_attr, 1) + await self.async_set_homee_value(self._on_off_attr, 1) if ATTR_COLOR_TEMP_KELVIN in kwargs and self._temp_attr is not None: - await self.async_set_value(self._temp_attr, kwargs[ATTR_COLOR_TEMP_KELVIN]) + await self.async_set_homee_value( + self._temp_attr, kwargs[ATTR_COLOR_TEMP_KELVIN] + ) if ATTR_HS_COLOR in kwargs: color = kwargs[ATTR_HS_COLOR] if self._col_attr is not None: - await self.async_set_value( + await self.async_set_homee_value( self._col_attr, rgb_list_to_decimal(color_hs_to_RGB(*color)), ) async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" - await self.async_set_value(self._on_off_attr, 0) + await self.async_set_homee_value(self._on_off_attr, 0) def _get_supported_color_modes(self) -> set[ColorMode]: """Determine the supported color modes from the available attributes.""" diff --git a/homeassistant/components/homee/number.py b/homeassistant/components/homee/number.py new file mode 100644 index 00000000000..3f1f08a6618 --- /dev/null +++ b/homeassistant/components/homee/number.py @@ -0,0 +1,130 @@ +"""The Homee number platform.""" + +from pyHomee.const import AttributeType +from pyHomee.model import HomeeAttribute + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .const import HOMEE_UNIT_TO_HA_UNIT +from .entity import HomeeEntity + +NUMBER_DESCRIPTIONS = { + AttributeType.DOWN_POSITION: NumberEntityDescription( + key="down_position", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.DOWN_SLAT_POSITION: NumberEntityDescription( + key="down_slat_position", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.DOWN_TIME: NumberEntityDescription( + key="down_time", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + AttributeType.ENDPOSITION_CONFIGURATION: NumberEntityDescription( + key="endposition_configuration", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.MOTION_ALARM_CANCELATION_DELAY: NumberEntityDescription( + key="motion_alarm_cancelation_delay", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + AttributeType.OPEN_WINDOW_DETECTION_SENSIBILITY: NumberEntityDescription( + key="open_window_detection_sensibility", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.POLLING_INTERVAL: NumberEntityDescription( + key="polling_interval", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + AttributeType.SHUTTER_SLAT_TIME: NumberEntityDescription( + key="shutter_slat_time", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + AttributeType.SLAT_MAX_ANGLE: NumberEntityDescription( + key="slat_max_angle", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.SLAT_MIN_ANGLE: NumberEntityDescription( + key="slat_min_angle", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.SLAT_STEPS: NumberEntityDescription( + key="slat_steps", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.TEMPERATURE_OFFSET: NumberEntityDescription( + key="temperature_offset", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.UP_TIME: NumberEntityDescription( + key="up_time", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + AttributeType.WAKE_UP_INTERVAL: NumberEntityDescription( + key="wake_up_interval", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the number component.""" + + async_add_entities( + HomeeNumber(attribute, config_entry, NUMBER_DESCRIPTIONS[attribute.type]) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if attribute.type in NUMBER_DESCRIPTIONS and attribute.data != "fixed_value" + ) + + +class HomeeNumber(HomeeEntity, NumberEntity): + """Representation of a Homee number.""" + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: NumberEntityDescription, + ) -> None: + """Initialize a Homee number entity.""" + super().__init__(attribute, entry) + self.entity_description = description + self._attr_translation_key = description.key + self._attr_native_unit_of_measurement = HOMEE_UNIT_TO_HA_UNIT[attribute.unit] + self._attr_native_min_value = attribute.minimum + self._attr_native_max_value = attribute.maximum + self._attr_native_step = attribute.step_value + + @property + def available(self) -> bool: + """Return the availability of the entity.""" + return super().available and self._attribute.editable + + @property + def native_value(self) -> int: + """Return the native value of the number.""" + return int(self._attribute.current_value) + + async def async_set_native_value(self, value: float) -> None: + """Set the selected value.""" + await self.async_set_homee_value(value) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index a78e12341a3..cf5b90dbe2a 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -66,6 +66,50 @@ "name": "Light {instance}" } }, + "number": { + "down_position": { + "name": "Down position" + }, + "down_slat_position": { + "name": "Down slat position" + }, + "down_time": { + "name": "Down-movement duration" + }, + "endposition_configuration": { + "name": "End position" + }, + "motion_alarm_cancelation_delay": { + "name": "Motion alarm delay" + }, + "open_window_detection_sensibility": { + "name": "Window open sensibility" + }, + "polling_interval": { + "name": "Polling interval" + }, + "shutter_slat_time": { + "name": "Slat turn duration" + }, + "slat_max_angle": { + "name": "Maximum slat angle" + }, + "slat_min_angle": { + "name": "Minimum slat angle" + }, + "slat_steps": { + "name": "Slat steps" + }, + "temperature_offset": { + "name": "Temperature offset" + }, + "up_time": { + "name": "Up-movement duration" + }, + "wake_up_interval": { + "name": "Wake-up interval" + } + }, "sensor": { "brightness_instance": { "name": "Illuminance {instance}" diff --git a/homeassistant/components/homee/switch.py b/homeassistant/components/homee/switch.py index e8b87b2b8e0..86c7acdbf11 100644 --- a/homeassistant/components/homee/switch.py +++ b/homeassistant/components/homee/switch.py @@ -120,8 +120,8 @@ class HomeeSwitch(HomeeEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self.async_set_value(1) + await self.async_set_homee_value(1) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self.async_set_value(0) + await self.async_set_homee_value(0) diff --git a/homeassistant/components/homee/valve.py b/homeassistant/components/homee/valve.py index b54d6334263..9a4ff446a10 100644 --- a/homeassistant/components/homee/valve.py +++ b/homeassistant/components/homee/valve.py @@ -78,4 +78,4 @@ class HomeeValve(HomeeEntity, ValveEntity): async def async_set_valve_position(self, position: int) -> None: """Move the valve to a specific position.""" - await self.async_set_value(position) + await self.async_set_homee_value(position) diff --git a/tests/components/homee/fixtures/numbers.json b/tests/components/homee/fixtures/numbers.json new file mode 100644 index 00000000000..c8773a89568 --- /dev/null +++ b/tests/components/homee/fixtures/numbers.json @@ -0,0 +1,337 @@ +{ + "id": 1, + "name": "Test Number", + "profile": 2011, + "image": "default", + "favorite": 0, + "order": 1, + "protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1731020474, + "added": 1680027411, + "history": 1, + "cube_type": 3, + "note": "", + "services": 0, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 2, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 0.5, + "editable": 1, + "type": 349, + "state": 1, + "last_changed": 1624446307, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 3, + "node_id": 1, + "instance": 0, + "minimum": -75, + "maximum": 75, + "current_value": 38.0, + "target_value": 38.0, + "last_value": 38.0, + "unit": "°", + "step_value": 1.0, + "editable": 1, + "type": 350, + "state": 1, + "last_changed": 1624446307, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 4, + "node_id": 1, + "instance": 0, + "minimum": 4, + "maximum": 240, + "current_value": 57.0, + "target_value": 57.0, + "last_value": 90.0, + "unit": "s", + "step_value": 1.0, + "editable": 1, + "type": 111, + "state": 1, + "last_changed": 1615396252, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 5, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 130, + "current_value": 129.0, + "target_value": 129.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 325, + "state": 1, + "last_changed": 1672086680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 6, + "node_id": 1, + "instance": 0, + "minimum": 1, + "maximum": 15300, + "current_value": 10.0, + "target_value": 1.0, + "last_value": 10.0, + "unit": "s", + "step_value": 1.0, + "editable": 0, + "type": 28, + "state": 1, + "last_changed": 1676204559, + "changed_by": 0, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 7, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 3, + "current_value": 3.0, + "target_value": 3.0, + "last_value": 2.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 261, + "state": 1, + "last_changed": 1666336770, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 8, + "node_id": 1, + "instance": 0, + "minimum": 5, + "maximum": 45, + "current_value": 30.0, + "target_value": 30.0, + "last_value": 0.0, + "unit": "min", + "step_value": 5.0, + "editable": 1, + "type": 88, + "state": 1, + "last_changed": 1672086680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 9, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 24, + "current_value": 1.6, + "target_value": 1.6, + "last_value": 0.0, + "unit": "s", + "step_value": 0.1, + "editable": 1, + "type": 114, + "state": 1, + "last_changed": 1615396156, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 10, + "node_id": 1, + "instance": 0, + "minimum": -127, + "maximum": 127, + "current_value": 75.0, + "target_value": 75.0, + "last_value": 0.0, + "unit": "°", + "step_value": 1.0, + "editable": 1, + "type": 323, + "state": 1, + "last_changed": 1615396156, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 11, + "node_id": 1, + "instance": 0, + "minimum": -127, + "maximum": 127, + "current_value": -75.0, + "target_value": -75.0, + "last_value": 0.0, + "unit": "°", + "step_value": 1.0, + "editable": 1, + "type": 322, + "state": 1, + "last_changed": 1615396156, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 12, + "node_id": 1, + "instance": 0, + "minimum": 1, + "maximum": 20, + "current_value": 6.0, + "target_value": 6.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 174, + "state": 1, + "last_changed": 1672149083, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 13, + "node_id": 1, + "instance": 0, + "minimum": -5, + "maximum": 128, + "current_value": -3, + "target_value": -3, + "last_value": 128.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 64, + "state": 6, + "last_changed": 1711799534, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 14, + "node_id": 1, + "instance": 0, + "minimum": 4, + "maximum": 240, + "current_value": 57.0, + "target_value": 57.0, + "last_value": 90.0, + "unit": "s", + "step_value": 1.0, + "editable": 1, + "type": 110, + "state": 1, + "last_changed": 1615396246, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 15, + "node_id": 1, + "instance": 0, + "minimum": 30, + "maximum": 7200, + "current_value": 600.0, + "target_value": 600.0, + "last_value": 600.0, + "unit": "min", + "step_value": 30.0, + "editable": 1, + "type": 29, + "state": 1, + "last_changed": 1739333970, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 16, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 240, + "current_value": 12.0, + "target_value": 12.0, + "last_value": 12.0, + "unit": "h", + "step_value": 1.0, + "editable": 0, + "type": 29, + "state": 1, + "last_changed": 1735964135, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "fixed_value", + "name": "" + } + ] +} diff --git a/tests/components/homee/snapshots/test_number.ambr b/tests/components/homee/snapshots/test_number.ambr new file mode 100644 index 00000000000..04b1aefab00 --- /dev/null +++ b/tests/components/homee/snapshots/test_number.ambr @@ -0,0 +1,802 @@ +# serializer version: 1 +# name: test_number_snapshot[number.test_number_down_movement_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 240, + 'min': 4, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_down_movement_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Down-movement duration', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'down_time', + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_down_movement_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Down-movement duration', + 'max': 240, + 'min': 4, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_down_movement_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57', + }) +# --- +# name: test_number_snapshot[number.test_number_down_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_down_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': 'Down position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'down_position', + 'unique_id': '00055511EECC-1-2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_snapshot[number.test_number_down_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Down position', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 0.5, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.test_number_down_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_number_snapshot[number.test_number_down_slat_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 75, + 'min': -75, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_down_slat_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': 'Down slat position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'down_slat_position', + 'unique_id': '00055511EECC-1-3', + 'unit_of_measurement': '°', + }) +# --- +# name: test_number_snapshot[number.test_number_down_slat_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Down slat position', + 'max': 75, + 'min': -75, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'number.test_number_down_slat_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38', + }) +# --- +# name: test_number_snapshot[number.test_number_end_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 130, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_end_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': 'End position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'endposition_configuration', + 'unique_id': '00055511EECC-1-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_snapshot[number.test_number_end_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number End position', + 'max': 130, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.test_number_end_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '129', + }) +# --- +# name: test_number_snapshot[number.test_number_maximum_slat_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 127, + 'min': -127, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_maximum_slat_angle', + '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': 'Maximum slat angle', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'slat_max_angle', + 'unique_id': '00055511EECC-1-10', + 'unit_of_measurement': '°', + }) +# --- +# name: test_number_snapshot[number.test_number_maximum_slat_angle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Maximum slat angle', + 'max': 127, + 'min': -127, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'number.test_number_maximum_slat_angle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75', + }) +# --- +# name: test_number_snapshot[number.test_number_minimum_slat_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 127, + 'min': -127, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_minimum_slat_angle', + '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': 'Minimum slat angle', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'slat_min_angle', + 'unique_id': '00055511EECC-1-11', + 'unit_of_measurement': '°', + }) +# --- +# name: test_number_snapshot[number.test_number_minimum_slat_angle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Minimum slat angle', + 'max': 127, + 'min': -127, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'number.test_number_minimum_slat_angle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-75', + }) +# --- +# name: test_number_snapshot[number.test_number_motion_alarm_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 15300, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_motion_alarm_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion alarm delay', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm_cancelation_delay', + 'unique_id': '00055511EECC-1-6', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_motion_alarm_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Motion alarm delay', + 'max': 15300, + 'min': 1, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_motion_alarm_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_number_snapshot[number.test_number_polling_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 45, + 'min': 5, + 'mode': , + 'step': 5.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_polling_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Polling interval', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'polling_interval', + 'unique_id': '00055511EECC-1-8', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_polling_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Polling interval', + 'max': 45, + 'min': 5, + 'mode': , + 'step': 5.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_polling_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_number_snapshot[number.test_number_slat_steps-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 20, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_slat_steps', + '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': 'Slat steps', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'slat_steps', + 'unique_id': '00055511EECC-1-12', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_snapshot[number.test_number_slat_steps-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Slat steps', + 'max': 20, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.test_number_slat_steps', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_number_snapshot[number.test_number_slat_turn_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 24, + 'min': 0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_slat_turn_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Slat turn duration', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'shutter_slat_time', + 'unique_id': '00055511EECC-1-9', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_slat_turn_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Slat turn duration', + 'max': 24, + 'min': 0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_slat_turn_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_number_snapshot[number.test_number_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 128, + 'min': -5, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_temperature_offset', + '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': 'Temperature offset', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': '00055511EECC-1-13', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Temperature offset', + 'max': 128, + 'min': -5, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_number_snapshot[number.test_number_up_movement_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 240, + 'min': 4, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_up_movement_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Up-movement duration', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'up_time', + 'unique_id': '00055511EECC-1-14', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_up_movement_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Up-movement duration', + 'max': 240, + 'min': 4, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_up_movement_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57', + }) +# --- +# name: test_number_snapshot[number.test_number_wake_up_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 7200, + 'min': 30, + 'mode': , + 'step': 30.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_wake_up_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wake-up interval', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wake_up_interval', + 'unique_id': '00055511EECC-1-15', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_wake_up_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Wake-up interval', + 'max': 7200, + 'min': 30, + 'mode': , + 'step': 30.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_wake_up_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '600', + }) +# --- +# name: test_number_snapshot[number.test_number_window_open_sensibility-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_window_open_sensibility', + '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': 'Window open sensibility', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'open_window_detection_sensibility', + 'unique_id': '00055511EECC-1-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_snapshot[number.test_number_window_open_sensibility-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Window open sensibility', + 'max': 3, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.test_number_window_open_sensibility', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- diff --git a/tests/components/homee/test_number.py b/tests/components/homee/test_number.py new file mode 100644 index 00000000000..73ca707c2d5 --- /dev/null +++ b/tests/components/homee/test_number.py @@ -0,0 +1,74 @@ +"""Test Homee nmumbers.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_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 build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_set_value( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set_value service.""" + mock_homee.nodes = [build_mock_node("numbers.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number_down_position", ATTR_VALUE: 90}, + blocking=True, + ) + number = mock_homee.nodes[0].attributes[0] + mock_homee.set_value.assert_called_once_with(number.node_id, number.id, 90) + + +async def test_set_value_not_editable( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set_value if attribute is not editable.""" + mock_homee.nodes = [build_mock_node("numbers.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number_motion_alarm_delay", ATTR_VALUE: 10000}, + blocking=True, + ) + assert not mock_homee.set_value.called + assert not hass.states.async_available("number.test_number_motion_alarm_delay") + + +async def test_number_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the multisensor snapshot.""" + mock_homee.nodes = [build_mock_node("numbers.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From e99bf21a36d58da9024960159b99bfc80fe1b861 Mon Sep 17 00:00:00 2001 From: Matrix Date: Tue, 25 Feb 2025 22:51:21 +0800 Subject: [PATCH 2739/2987] Fix yolink lock v2 state update (#138710) --- homeassistant/components/yolink/lock.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/yolink/lock.py b/homeassistant/components/yolink/lock.py index 135d0e26d04..5e244dd08f2 100644 --- a/homeassistant/components/yolink/lock.py +++ b/homeassistant/components/yolink/lock.py @@ -51,15 +51,16 @@ class YoLinkLockEntity(YoLinkEntity, LockEntity): def update_entity_state(self, state: dict[str, Any]) -> None: """Update HA Entity State.""" state_value = state.get("state") - if self.coordinator.device.device_type == ATTR_DEVICE_LOCK_V2: - self._attr_is_locked = ( - state_value["lock"] == "locked" if state_value is not None else None - ) - else: - self._attr_is_locked = ( - state_value == "locked" if state_value is not None else None - ) - self.async_write_ha_state() + if state_value is not None: + if self.coordinator.device.device_type == ATTR_DEVICE_LOCK_V2: + self._attr_is_locked = ( + state_value["lock"] == "locked" if state_value is not None else None + ) + else: + self._attr_is_locked = ( + state_value == "locked" if state_value is not None else None + ) + self.async_write_ha_state() async def call_lock_state_change(self, state: str) -> None: """Call setState api to change lock state.""" @@ -69,7 +70,7 @@ class YoLinkLockEntity(YoLinkEntity, LockEntity): ) else: await self.call_device(ClientRequest("setState", {"state": state})) - self._attr_is_locked = state == "lock" + self._attr_is_locked = state in ["locked", "lock"] self.async_write_ha_state() async def async_lock(self, **kwargs: Any) -> None: From f96e31fad851d8ab61f75695ff83ba0ea0f5092f Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:51:43 +0100 Subject: [PATCH 2740/2987] Set Minecraft Server quality scale to silver (#139265) --- homeassistant/components/minecraft_server/manifest.json | 1 + homeassistant/components/minecraft_server/quality_scale.yaml | 2 +- script/hassfest/quality_scale.py | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index d6ade4853c9..be399a3c8dc 100644 --- a/homeassistant/components/minecraft_server/manifest.json +++ b/homeassistant/components/minecraft_server/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/minecraft_server", "iot_class": "local_polling", "loggers": ["dnspython", "mcstatus"], + "quality_scale": "silver", "requirements": ["mcstatus==11.1.1"] } diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml index 61a975632bb..288e58fad39 100644 --- a/homeassistant/components/minecraft_server/quality_scale.yaml +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -14,7 +14,7 @@ rules: comment: Integration doesn't provide any service actions. docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: done comment: Handled by coordinator. diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index d155cc74acb..5f90fff81d5 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1722,7 +1722,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "mikrotik", "mill", "min_max", - "minecraft_server", "minio", "mjpeg", "moat", From 1fb51ef1891555fa864ef23b7286dc53920c9abe Mon Sep 17 00:00:00 2001 From: Andrew <34544450+10100011@users.noreply.github.com> Date: Tue, 25 Feb 2025 14:54:10 +0000 Subject: [PATCH 2741/2987] Add OpenWeatherMap Minute forecast action (#128799) --- .../components/openweathermap/const.py | 1 + .../components/openweathermap/coordinator.py | 24 ++++ .../components/openweathermap/icons.json | 7 + .../components/openweathermap/services.yaml | 5 + .../components/openweathermap/strings.json | 11 ++ .../components/openweathermap/weather.py | 27 +++- .../snapshots/test_weather.ambr | 25 ++++ .../openweathermap/test_config_flow.py | 30 +++-- .../components/openweathermap/test_weather.py | 121 ++++++++++++++++++ 9 files changed, 240 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/openweathermap/icons.json create mode 100644 homeassistant/components/openweathermap/services.yaml create mode 100644 tests/components/openweathermap/snapshots/test_weather.ambr create mode 100644 tests/components/openweathermap/test_weather.py diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index 81a6544c7ce..de317709f5b 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -48,6 +48,7 @@ ATTR_API_WEATHER_CODE = "weather_code" ATTR_API_CLOUD_COVERAGE = "cloud_coverage" ATTR_API_FORECAST = "forecast" ATTR_API_CURRENT = "current" +ATTR_API_MINUTE_FORECAST = "minute_forecast" ATTR_API_HOURLY_FORECAST = "hourly_forecast" ATTR_API_DAILY_FORECAST = "daily_forecast" UPDATE_LISTENER = "update_listener" diff --git a/homeassistant/components/openweathermap/coordinator.py b/homeassistant/components/openweathermap/coordinator.py index 55c1aa469c2..994949b5e03 100644 --- a/homeassistant/components/openweathermap/coordinator.py +++ b/homeassistant/components/openweathermap/coordinator.py @@ -10,6 +10,7 @@ from pyopenweathermap import ( CurrentWeather, DailyWeatherForecast, HourlyWeatherForecast, + MinutelyWeatherForecast, OWMClient, RequestError, WeatherReport, @@ -34,10 +35,14 @@ from .const import ( ATTR_API_CONDITION, ATTR_API_CURRENT, ATTR_API_DAILY_FORECAST, + ATTR_API_DATETIME, ATTR_API_DEW_POINT, ATTR_API_FEELS_LIKE_TEMPERATURE, + ATTR_API_FORECAST, ATTR_API_HOURLY_FORECAST, ATTR_API_HUMIDITY, + ATTR_API_MINUTE_FORECAST, + ATTR_API_PRECIPITATION, ATTR_API_PRECIPITATION_KIND, ATTR_API_PRESSURE, ATTR_API_RAIN, @@ -106,6 +111,11 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): return { ATTR_API_CURRENT: current_weather, + ATTR_API_MINUTE_FORECAST: ( + self._get_minute_weather_data(weather_report.minutely_forecast) + if weather_report.minutely_forecast is not None + else {} + ), ATTR_API_HOURLY_FORECAST: [ self._get_hourly_forecast_weather_data(item) for item in weather_report.hourly_forecast @@ -116,6 +126,20 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): ], } + def _get_minute_weather_data( + self, minute_forecast: list[MinutelyWeatherForecast] + ) -> dict: + """Get minute weather data from the forecast.""" + return { + ATTR_API_FORECAST: [ + { + ATTR_API_DATETIME: item.date_time, + ATTR_API_PRECIPITATION: round(item.precipitation, 2), + } + for item in minute_forecast + ] + } + def _get_current_weather_data(self, current_weather: CurrentWeather): return { ATTR_API_CONDITION: self._get_condition(current_weather.condition.id), diff --git a/homeassistant/components/openweathermap/icons.json b/homeassistant/components/openweathermap/icons.json new file mode 100644 index 00000000000..d493b1538ba --- /dev/null +++ b/homeassistant/components/openweathermap/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "get_minute_forecast": { + "service": "mdi:weather-snowy-rainy" + } + } +} diff --git a/homeassistant/components/openweathermap/services.yaml b/homeassistant/components/openweathermap/services.yaml new file mode 100644 index 00000000000..6bbcf1b23e4 --- /dev/null +++ b/homeassistant/components/openweathermap/services.yaml @@ -0,0 +1,5 @@ +get_minute_forecast: + target: + entity: + domain: weather + integration: openweathermap diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index 46b5feab75c..0692087bc23 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -47,5 +47,16 @@ } } } + }, + "services": { + "get_minute_forecast": { + "name": "Get minute forecast", + "description": "Get minute weather forecast." + } + }, + "exceptions": { + "service_minute_forecast_mode": { + "message": "Minute forecast is available only when {name} mode is set to v3.0" + } } } diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index 43e9c0a868a..a6ad163e1c8 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -14,7 +14,9 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, SupportsResponse, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -28,6 +30,7 @@ from .const import ( ATTR_API_FEELS_LIKE_TEMPERATURE, ATTR_API_HOURLY_FORECAST, ATTR_API_HUMIDITY, + ATTR_API_MINUTE_FORECAST, ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, ATTR_API_VISIBILITY_DISTANCE, @@ -44,6 +47,8 @@ from .const import ( ) from .coordinator import WeatherUpdateCoordinator +SERVICE_GET_MINUTE_FORECAST = "get_minute_forecast" + async def async_setup_entry( hass: HomeAssistant, @@ -61,6 +66,14 @@ async def async_setup_entry( async_add_entities([owm_weather], False) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + name=SERVICE_GET_MINUTE_FORECAST, + schema=None, + func="async_get_minute_forecast", + supports_response=SupportsResponse.ONLY, + ) + class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): """Implementation of an OpenWeatherMap sensor.""" @@ -91,6 +104,7 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina manufacturer=MANUFACTURER, name=DEFAULT_NAME, ) + self.mode = mode if mode in (OWM_MODE_V30, OWM_MODE_V25): self._attr_supported_features = ( @@ -100,6 +114,17 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina elif mode == OWM_MODE_FREE_FORECAST: self._attr_supported_features = WeatherEntityFeature.FORECAST_HOURLY + async def async_get_minute_forecast(self) -> dict[str, list[dict]] | dict: + """Return Minute forecast.""" + + if self.mode == OWM_MODE_V30: + return self.coordinator.data[ATTR_API_MINUTE_FORECAST] + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_minute_forecast_mode", + translation_placeholders={"name": DEFAULT_NAME}, + ) + @property def condition(self) -> str | None: """Return the current condition.""" diff --git a/tests/components/openweathermap/snapshots/test_weather.ambr b/tests/components/openweathermap/snapshots/test_weather.ambr new file mode 100644 index 00000000000..c89dcb96a9c --- /dev/null +++ b/tests/components/openweathermap/snapshots/test_weather.ambr @@ -0,0 +1,25 @@ +# serializer version: 1 +# name: test_get_minute_forecast[mock_service_response] + dict({ + 'weather.openweathermap': dict({ + 'forecast': list([ + dict({ + 'datetime': 1728672360, + 'precipitation': 0, + }), + dict({ + 'datetime': 1728672420, + 'precipitation': 1.23, + }), + dict({ + 'datetime': 1728672480, + 'precipitation': 4.5, + }), + dict({ + 'datetime': 1728672540, + 'precipitation': 0, + }), + ]), + }), + }) +# --- diff --git a/tests/components/openweathermap/test_config_flow.py b/tests/components/openweathermap/test_config_flow.py index aec34360754..d5e01677dd8 100644 --- a/tests/components/openweathermap/test_config_flow.py +++ b/tests/components/openweathermap/test_config_flow.py @@ -18,7 +18,7 @@ from homeassistant.components.openweathermap.const import ( DEFAULT_LANGUAGE, DEFAULT_OWM_MODE, DOMAIN, - OWM_MODE_V25, + OWM_MODE_V30, ) from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import ( @@ -40,13 +40,15 @@ CONFIG = { CONF_LATITUDE: 50, CONF_LONGITUDE: 40, CONF_LANGUAGE: DEFAULT_LANGUAGE, - CONF_MODE: OWM_MODE_V25, + CONF_MODE: OWM_MODE_V30, } VALID_YAML_CONFIG = {CONF_API_KEY: "foo"} -def _create_mocked_owm_factory(is_valid: bool): +def _create_static_weather_report() -> WeatherReport: + """Create a static WeatherReport.""" + current_weather = CurrentWeather( date_time=datetime.fromtimestamp(1714063536, tz=UTC), temperature=6.84, @@ -60,8 +62,8 @@ def _create_mocked_owm_factory(is_valid: bool): wind_speed=9.83, wind_bearing=199, wind_gust=None, - rain={}, - snow={}, + rain={"1h": 1.21}, + snow=None, condition=WeatherCondition( id=803, main="Clouds", @@ -106,13 +108,21 @@ def _create_mocked_owm_factory(is_valid: bool): rain=0, snow=0, ) - minutely_weather_forecast = MinutelyWeatherForecast( - date_time=1728672360, precipitation=2.54 - ) - weather_report = WeatherReport( - current_weather, [minutely_weather_forecast], [], [daily_weather_forecast] + minutely_weather_forecast = [ + MinutelyWeatherForecast(date_time=1728672360, precipitation=0), + MinutelyWeatherForecast(date_time=1728672420, precipitation=1.23), + MinutelyWeatherForecast(date_time=1728672480, precipitation=4.5), + MinutelyWeatherForecast(date_time=1728672540, precipitation=0), + ] + return WeatherReport( + current_weather, minutely_weather_forecast, [], [daily_weather_forecast] ) + +def _create_mocked_owm_factory(is_valid: bool): + """Create a mocked OWM client.""" + + weather_report = _create_static_weather_report() mocked_owm_client = MagicMock() mocked_owm_client.validate_key = AsyncMock(return_value=is_valid) mocked_owm_client.get_weather = AsyncMock(return_value=weather_report) diff --git a/tests/components/openweathermap/test_weather.py b/tests/components/openweathermap/test_weather.py new file mode 100644 index 00000000000..5d3565d6ca9 --- /dev/null +++ b/tests/components/openweathermap/test_weather.py @@ -0,0 +1,121 @@ +"""Test the OpenWeatherMap weather entity.""" + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.openweathermap.const import ( + DEFAULT_LANGUAGE, + DOMAIN, + OWM_MODE_V25, + OWM_MODE_V30, +) +from homeassistant.components.openweathermap.weather import SERVICE_GET_MINUTE_FORECAST +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_API_KEY, + CONF_LANGUAGE, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MODE, + CONF_NAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from .test_config_flow import _create_static_weather_report + +from tests.common import AsyncMock, MockConfigEntry, patch + +ENTITY_ID = "weather.openweathermap" +API_KEY = "test_api_key" +LATITUDE = 12.34 +LONGITUDE = 56.78 +NAME = "openweathermap" + +# Define test data for mocked weather report +static_weather_report = _create_static_weather_report() + + +def mock_config_entry(mode: str) -> MockConfigEntry: + """Create a mock OpenWeatherMap config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: API_KEY, + CONF_LATITUDE: LATITUDE, + CONF_LONGITUDE: LONGITUDE, + CONF_NAME: NAME, + }, + options={CONF_MODE: mode, CONF_LANGUAGE: DEFAULT_LANGUAGE}, + version=5, + ) + + +@pytest.fixture +def mock_config_entry_v25() -> MockConfigEntry: + """Create a mock OpenWeatherMap v2.5 config entry.""" + return mock_config_entry(OWM_MODE_V25) + + +@pytest.fixture +def mock_config_entry_v30() -> MockConfigEntry: + """Create a mock OpenWeatherMap v3.0 config entry.""" + return mock_config_entry(OWM_MODE_V30) + + +async def setup_mock_config_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +): + """Set up the MockConfigEntry and assert it is loaded correctly.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID) + assert mock_config_entry.state is ConfigEntryState.LOADED + + +@patch( + "pyopenweathermap.client.onecall_client.OWMOneCallClient.get_weather", + AsyncMock(return_value=static_weather_report), +) +async def test_get_minute_forecast( + hass: HomeAssistant, + mock_config_entry_v30: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the get_minute_forecast Service call.""" + await setup_mock_config_entry(hass, mock_config_entry_v30) + + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_MINUTE_FORECAST, + {"entity_id": ENTITY_ID}, + blocking=True, + return_response=True, + ) + assert result == snapshot(name="mock_service_response") + + +@patch( + "pyopenweathermap.client.onecall_client.OWMOneCallClient.get_weather", + AsyncMock(return_value=static_weather_report), +) +async def test_mode_fail( + hass: HomeAssistant, + mock_config_entry_v25: MockConfigEntry, +) -> None: + """Test that Minute forecasting fails when mode is not v3.0.""" + await setup_mock_config_entry(hass, mock_config_entry_v25) + + # Expect a ServiceValidationError when mode is not OWM_MODE_V30 + with pytest.raises( + ServiceValidationError, + match="Minute forecast is available only when OpenWeatherMap mode is set to v3.0", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_MINUTE_FORECAST, + {"entity_id": ENTITY_ID}, + blocking=True, + return_response=True, + ) From 47e78e9008d6b0b4c880d21376009f31f309c213 Mon Sep 17 00:00:00 2001 From: Renier Moorcroft <66512715+RenierM26@users.noreply.github.com> Date: Tue, 25 Feb 2025 16:55:31 +0200 Subject: [PATCH 2742/2987] Fix Ezviz entity state for cameras that are offline (#136003) --- homeassistant/components/ezviz/camera.py | 5 ----- homeassistant/components/ezviz/entity.py | 10 ++++++++++ homeassistant/components/ezviz/image.py | 6 ++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 54879fd6a9b..e3d01bef83e 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -141,11 +141,6 @@ class EzvizCamera(EzvizEntity, Camera): if camera_password: self._attr_supported_features = CameraEntityFeature.STREAM - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.data["status"] != 2 - @property def is_on(self) -> bool: """Return true if on.""" diff --git a/homeassistant/components/ezviz/entity.py b/homeassistant/components/ezviz/entity.py index 44de4a0c9c7..54614e4899a 100644 --- a/homeassistant/components/ezviz/entity.py +++ b/homeassistant/components/ezviz/entity.py @@ -42,6 +42,11 @@ class EzvizEntity(CoordinatorEntity[EzvizDataUpdateCoordinator], Entity): """Return coordinator data for this entity.""" return self.coordinator.data[self._serial] + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.data["status"] != 2 + class EzvizBaseEntity(Entity): """Generic entity for EZVIZ individual poll entities.""" @@ -72,3 +77,8 @@ class EzvizBaseEntity(Entity): def data(self) -> dict[str, Any]: """Return coordinator data for this entity.""" return self.coordinator.data[self._serial] + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.data["status"] != 2 diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index f335406a367..ea032a8ec00 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging +from propcache import cached_property from pyezviz.exceptions import PyEzvizError from pyezviz.utils import decrypt_image @@ -62,6 +63,11 @@ class EzvizLastMotion(EzvizEntity, ImageEntity): else None ) + @cached_property + def available(self) -> bool: + """Entity gets data from ezviz API so always available.""" + return True + async def _async_load_image_from_url(self, url: str) -> Image | None: """Load an image by url.""" if response := await self._fetch_url(url): From 72502c1a151e0268abfb3363d4385f76ac3adc06 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Feb 2025 16:09:15 +0100 Subject: [PATCH 2743/2987] Use proper camel-case for "VeSync", fix sentence-casing in title (#139252) Just a quick follow-up PR to fix these two spelling mistakes. --- homeassistant/components/vesync/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index 89f401da92f..eabb2969580 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Enter Username and Password", + "title": "Enter username and password", "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" @@ -10,7 +10,7 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The vesync integration needs to re-authenticate your account", + "description": "The VeSync integration needs to re-authenticate your account", "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" From f607b95c00b6ee8b0a9dfb7cd1a38251d5d83439 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 25 Feb 2025 16:27:18 +0100 Subject: [PATCH 2744/2987] Add request made by `rest_command` to debug log (#139266) --- homeassistant/components/rest_command/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index f4c84bf72b5..c6a4206de4a 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -146,6 +146,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if content_type: headers[hdrs.CONTENT_TYPE] = content_type + _LOGGER.debug( + "Calling %s %s with headers: %s and payload: %s", + method, + request_url, + headers, + payload, + ) + try: async with getattr(websession, method)( request_url, From 27f7085b610936b7495844f70b7d268424111d5b Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 25 Feb 2025 16:27:56 +0100 Subject: [PATCH 2745/2987] Create repair for configured unavailable backup agents (#137382) * Create repair for configured not loaded agents * Rework to repair issue * Extract logic to config function * Update test * Handle empty agend ids config update * Address review comment * Update tests * Address comment --- homeassistant/components/backup/config.py | 51 +++++- homeassistant/components/backup/manager.py | 9 + homeassistant/components/backup/strings.json | 4 + tests/components/backup/test_manager.py | 19 +- tests/components/backup/test_websocket.py | 182 +++++++++++++++++++ 5 files changed, 262 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 65f9f4789a6..f4fa2e8bac6 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -12,16 +12,19 @@ from typing import TYPE_CHECKING, Self, TypedDict from cronsim import CronSim from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.event import async_call_later, async_track_point_in_time from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util -from .const import LOGGER +from .const import DOMAIN, LOGGER from .models import BackupManagerError, Folder if TYPE_CHECKING: from .manager import BackupManager, ManagerBackup +AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID = "automatic_backup_agents_unavailable" + CRON_PATTERN_DAILY = "{m} {h} * * *" CRON_PATTERN_WEEKLY = "{m} {h} * * {d}" @@ -151,6 +154,7 @@ class BackupConfig: retention=RetentionConfig(), schedule=BackupSchedule(), ) + self._hass = hass self._manager = manager def load(self, stored_config: StoredBackupConfig) -> None: @@ -182,6 +186,8 @@ class BackupConfig: self.data.automatic_backups_configured = automatic_backups_configured if create_backup is not UNDEFINED: self.data.create_backup = replace(self.data.create_backup, **create_backup) + if "agent_ids" in create_backup: + check_unavailable_agents(self._hass, self._manager) if retention is not UNDEFINED: new_retention = RetentionConfig(**retention) if new_retention != self.data.retention: @@ -562,3 +568,46 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N await manager.async_delete_filtered_backups( include_filter=_automatic_backups_filter, delete_filter=_delete_filter ) + + +@callback +def check_unavailable_agents(hass: HomeAssistant, manager: BackupManager) -> None: + """Check for unavailable agents.""" + if missing_agent_ids := set(manager.config.data.create_backup.agent_ids) - set( + manager.backup_agents + ): + LOGGER.debug( + "Agents %s are configured for automatic backup but are unavailable", + missing_agent_ids, + ) + + # Remove issues for unavailable agents that are not unavailable anymore. + issue_registry = ir.async_get(hass) + existing_missing_agent_issue_ids = { + issue_id + for domain, issue_id in issue_registry.issues + if domain == DOMAIN + and issue_id.startswith(AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID) + } + current_missing_agent_issue_ids = { + f"{AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID}_{agent_id}": agent_id + for agent_id in missing_agent_ids + } + for issue_id in existing_missing_agent_issue_ids - set( + current_missing_agent_issue_ids + ): + ir.async_delete_issue(hass, DOMAIN, issue_id) + for issue_id, agent_id in current_missing_agent_issue_ids.items(): + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + learn_more_url="homeassistant://config/backup", + severity=ir.IssueSeverity.WARNING, + translation_key="automatic_backup_agents_unavailable", + translation_placeholders={ + "agent_id": agent_id, + "backup_settings": "/config/backup/settings", + }, + ) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 3bf31618b24..bd970d7708a 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -32,6 +32,7 @@ from homeassistant.helpers import ( instance_id, integration_platform, issue_registry as ir, + start, ) from homeassistant.helpers.backup import DATA_BACKUP from homeassistant.helpers.json import json_bytes @@ -47,6 +48,7 @@ from .agent import ( from .config import ( BackupConfig, CreateBackupParametersDict, + check_unavailable_agents, delete_backups_exceeding_configured_count, ) from .const import ( @@ -417,6 +419,13 @@ class BackupManager: } ) + @callback + def check_unavailable_agents_after_start(hass: HomeAssistant) -> None: + """Check unavailable agents after start.""" + check_unavailable_agents(hass, self) + + start.async_at_started(self.hass, check_unavailable_agents_after_start) + async def _add_platform( self, hass: HomeAssistant, diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json index 32d76ded049..c3047d3a4ac 100644 --- a/homeassistant/components/backup/strings.json +++ b/homeassistant/components/backup/strings.json @@ -1,5 +1,9 @@ { "issues": { + "automatic_backup_agents_unavailable": { + "title": "The backup location {agent_id} is unavailable", + "description": "The backup location `{agent_id}` is unavailable but is still configured for automatic backups.\n\nPlease visit the [automatic backup configuration page]({backup_settings}) to review and update your backup locations. Backups will not be uploaded to selected locations that are unavailable." + }, "automatic_backup_failed_create": { "title": "Automatic backup could not be created", "description": "The automatic backup could not be created. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured." diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index b2b7e083a51..3c72929cfe0 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -982,7 +982,15 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: None, None, True, - {}, + { + (DOMAIN, "automatic_backup_agents_unavailable_test.unknown"): { + "translation_key": "automatic_backup_agents_unavailable", + "translation_placeholders": { + "agent_id": "test.unknown", + "backup_settings": "/config/backup/settings", + }, + }, + }, ), ( ["test.remote", "test.unknown"], @@ -994,7 +1002,14 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: (DOMAIN, "automatic_backup_failed"): { "translation_key": "automatic_backup_failed_upload_agents", "translation_placeholders": {"failed_agents": "test.unknown"}, - } + }, + (DOMAIN, "automatic_backup_agents_unavailable_test.unknown"): { + "translation_key": "automatic_backup_agents_unavailable", + "translation_placeholders": { + "agent_id": "test.unknown", + "backup_settings": "/config/backup/settings", + }, + }, }, ), # Error raised in async_initiate_backup diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 9b2241882c4..404ba52de4b 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -27,6 +27,7 @@ from homeassistant.components.backup.manager import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component @@ -34,7 +35,9 @@ 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 @@ -3244,6 +3247,185 @@ async def test_config_retention_days_logic( await hass.async_block_till_done() +async def test_configured_agents_unavailable_repair( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + issue_registry: ir.IssueRegistry, + hass_storage: dict[str, Any], +) -> None: + """Test creating and deleting repair issue for configured unavailable agents.""" + issue_id = "automatic_backup_agents_unavailable_test.agent" + ws_client = await hass_ws_client(hass) + hass_storage.update( + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "automatic_backups_configured": True, + "create_backup": { + "agent_ids": ["test.agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": ["mon"], + "recurrence": "custom_days", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + } + ) + + await setup_backup_integration(hass) + get_agents_mock = AsyncMock(return_value=[mock_backup_agent("agent")]) + register_listener_mock = Mock() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=get_agents_mock, + async_register_backup_agents_listener=register_listener_mock, + ), + ) + await hass.async_block_till_done() + + reload_backup_agents = register_listener_mock.call_args[1]["listener"] + + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "test.agent", "name": "agent"}, + ] + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + # Reload the agents with no agents returned. + + get_agents_mock.return_value = [] + reload_backup_agents() + await hass.async_block_till_done() + + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local", "name": "local"}, + ] + + assert issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == ["test.agent"] + + # Update the automatic backup configuration removing the unavailable agent. + + await ws_client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["backup.local"]}, + } + ) + result = await ws_client.receive_json() + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == ["backup.local"] + + # Reload the agents with one agent returned + # but not configured for automatic backups. + + get_agents_mock.return_value = [mock_backup_agent("agent")] + reload_backup_agents() + await hass.async_block_till_done() + + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "test.agent", "name": "agent"}, + ] + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == ["backup.local"] + + # Update the automatic backup configuration and configure the test agent. + + await ws_client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["backup.local", "test.agent"]}, + } + ) + result = await ws_client.receive_json() + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == [ + "backup.local", + "test.agent", + ] + + # Reload the agents with no agents returned again. + + get_agents_mock.return_value = [] + reload_backup_agents() + await hass.async_block_till_done() + + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local", "name": "local"}, + ] + + assert issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == [ + "backup.local", + "test.agent", + ] + + # Update the automatic backup configuration removing all agents. + + await ws_client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": []}, + } + ) + result = await ws_client.receive_json() + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == [] + + async def test_subscribe_event( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, From ca1677cc461666a3ded07a63d091926dcb6e9ee0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Feb 2025 16:52:58 +0100 Subject: [PATCH 2746/2987] Improve description of `openweathermap.get_minute_forecast` action (#139267) --- homeassistant/components/openweathermap/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index 0692087bc23..1aa161c87dc 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -51,7 +51,7 @@ "services": { "get_minute_forecast": { "name": "Get minute forecast", - "description": "Get minute weather forecast." + "description": "Retrieves a minute-by-minute weather forecast for one hour." } }, "exceptions": { From fcffe5151ddc1ec86dafaca6e4348315b5b241ad Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Feb 2025 17:00:09 +0100 Subject: [PATCH 2747/2987] Use right import in ezviz (#139272) --- homeassistant/components/ezviz/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index ea032a8ec00..28ebc7279e6 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from propcache import cached_property +from propcache.api import cached_property from pyezviz.exceptions import PyEzvizError from pyezviz.utils import decrypt_image From 433c2cb43eba4ee8bc46706f49524557c46980c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Br=C3=B8ndum?= <34370407+brondum@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:00:35 +0100 Subject: [PATCH 2748/2987] Change touchline dependency to pytouchline_extended (#136362) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/touchline/climate.py | 15 ++++++++------- homeassistant/components/touchline/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py index f7eec7c54f9..86526f4718b 100644 --- a/homeassistant/components/touchline/climate.py +++ b/homeassistant/components/touchline/climate.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any, NamedTuple -from pytouchline import PyTouchline +from pytouchline_extended import PyTouchline import voluptuous as vol from homeassistant.components.climate import ( @@ -53,12 +53,13 @@ def setup_platform( """Set up the Touchline devices.""" host = config[CONF_HOST] - py_touchline = PyTouchline() - number_of_devices = int(py_touchline.get_number_of_devices(host)) - add_entities( - (Touchline(PyTouchline(device_id)) for device_id in range(number_of_devices)), - True, - ) + py_touchline = PyTouchline(url=host) + number_of_devices = int(py_touchline.get_number_of_devices()) + devices = [ + Touchline(PyTouchline(id=device_id, url=host)) + for device_id in range(number_of_devices) + ] + add_entities(devices, True) class Touchline(ClimateEntity): diff --git a/homeassistant/components/touchline/manifest.json b/homeassistant/components/touchline/manifest.json index c003cca97a4..6d25462408b 100644 --- a/homeassistant/components/touchline/manifest.json +++ b/homeassistant/components/touchline/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pytouchline"], "quality_scale": "legacy", - "requirements": ["pytouchline==0.7"] + "requirements": ["pytouchline_extended==0.4.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 55b4d140321..1b0af492388 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2497,7 +2497,7 @@ pytile==2024.12.0 pytomorrowio==0.3.6 # homeassistant.components.touchline -pytouchline==0.7 +pytouchline_extended==0.4.5 # homeassistant.components.touchline_sl pytouchlinesl==0.3.0 From 9ec9110e1ee02e9d5cf45486388f59cceb02b0a2 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:03:31 +0100 Subject: [PATCH 2749/2987] Rename description field to notes in Habitica action (#139271) --- homeassistant/components/habitica/const.py | 1 + homeassistant/components/habitica/services.py | 9 +++++---- homeassistant/components/habitica/services.yaml | 2 +- homeassistant/components/habitica/strings.json | 10 +++++----- tests/components/habitica/test_services.py | 7 ++++--- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 5e18477d142..353bcbbd39d 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -39,6 +39,7 @@ ATTR_REMOVE_TAG = "remove_tag" ATTR_ALIAS = "alias" ATTR_PRIORITY = "priority" ATTR_COST = "cost" +ATTR_NOTES = "notes" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 16bbeef9073..57005cf2b72 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -22,7 +22,7 @@ from habiticalib import ( ) import voluptuous as vol -from homeassistant.components.todo import ATTR_DESCRIPTION, ATTR_RENAME +from homeassistant.components.todo import ATTR_RENAME from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_NAME, CONF_NAME from homeassistant.core import ( @@ -45,6 +45,7 @@ from .const import ( ATTR_DIRECTION, ATTR_ITEM, ATTR_KEYWORD, + ATTR_NOTES, ATTR_PATH, ATTR_PRIORITY, ATTR_REMOVE_TAG, @@ -116,7 +117,7 @@ SERVICE_UPDATE_TASK_SCHEMA = vol.Schema( vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), vol.Required(ATTR_TASK): cv.string, vol.Optional(ATTR_RENAME): cv.string, - vol.Optional(ATTR_DESCRIPTION): cv.string, + vol.Optional(ATTR_NOTES): cv.string, vol.Optional(ATTR_TAG): vol.All(cv.ensure_list, [str]), vol.Optional(ATTR_REMOVE_TAG): vol.All(cv.ensure_list, [str]), vol.Optional(ATTR_ALIAS): vol.All( @@ -566,8 +567,8 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 if rename := call.data.get(ATTR_RENAME): data["text"] = rename - if (description := call.data.get(ATTR_DESCRIPTION)) is not None: - data["notes"] = description + if (notes := call.data.get(ATTR_NOTES)) is not None: + data["notes"] = notes tags = cast(list[str], call.data.get(ATTR_TAG)) remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG)) diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index b8479c1eeec..7b486690ef5 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -147,7 +147,7 @@ update_reward: rename: selector: text: - description: + notes: required: false selector: text: diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 75558cea078..1bb2fcbd9d7 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -12,8 +12,8 @@ "task_description": "The name (or task ID) of the task you want to update.", "rename_name": "Rename", "rename_description": "The new title for the Habitica task.", - "description_name": "Update description", - "description_description": "The new description for the Habitica task.", + "notes_name": "Update notes", + "notes_description": "The new notes for the Habitica task.", "tag_name": "Add tags", "tag_description": "Add tags to the Habitica task. If a tag does not already exist, a new one will be created.", "remove_tag_name": "Remove tags", @@ -690,9 +690,9 @@ "name": "[%key:component::habitica::common::rename_name%]", "description": "[%key:component::habitica::common::rename_description%]" }, - "description": { - "name": "[%key:component::habitica::common::description_name%]", - "description": "[%key:component::habitica::common::description_description%]" + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" }, "tag": { "name": "[%key:component::habitica::common::tag_name%]", diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 3f7ca14220b..a4442016784 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -17,6 +17,7 @@ from homeassistant.components.habitica.const import ( ATTR_DIRECTION, ATTR_ITEM, ATTR_KEYWORD, + ATTR_NOTES, ATTR_PRIORITY, ATTR_REMOVE_TAG, ATTR_SKILL, @@ -38,7 +39,7 @@ from homeassistant.components.habitica.const import ( SERVICE_TRANSFORMATION, SERVICE_UPDATE_REWARD, ) -from homeassistant.components.todo import ATTR_DESCRIPTION, ATTR_RENAME +from homeassistant.components.todo import ATTR_RENAME from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -984,9 +985,9 @@ async def test_task_not_found( ), ( { - ATTR_DESCRIPTION: "DESCRIPTION", + ATTR_NOTES: "NOTES", }, - Task(notes="DESCRIPTION"), + Task(notes="NOTES"), ), ( { From f3021b40abc5917740dc4950f7098b9daa58d041 Mon Sep 17 00:00:00 2001 From: Galorhallen <12990764+Galorhallen@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:04:53 +0100 Subject: [PATCH 2750/2987] Add support for effects in Govee lights (#137846) --- .../govee_light_local/coordinator.py | 4 + .../components/govee_light_local/light.py | 62 ++ .../components/govee_light_local/strings.json | 24 + .../components/govee_light_local/conftest.py | 26 +- .../govee_light_local/test_config_flow.py | 60 +- .../govee_light_local/test_light.py | 624 ++++++++++++------ 6 files changed, 558 insertions(+), 242 deletions(-) diff --git a/homeassistant/components/govee_light_local/coordinator.py b/homeassistant/components/govee_light_local/coordinator.py index ecbed0c4f65..530ade1f743 100644 --- a/homeassistant/components/govee_light_local/coordinator.py +++ b/homeassistant/components/govee_light_local/coordinator.py @@ -89,6 +89,10 @@ class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]): """Set light color in kelvin.""" await device.set_temperature(temperature) + async def set_scene(self, device: GoveeController, scene: str) -> None: + """Set light scene.""" + await device.set_scene(scene) + @property def devices(self) -> list[GoveeDevice]: """Return a list of discovered Govee devices.""" diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py index 11ca53b53a1..984654477e9 100644 --- a/homeassistant/components/govee_light_local/light.py +++ b/homeassistant/components/govee_light_local/light.py @@ -10,9 +10,11 @@ from govee_local_api import GoveeDevice, GoveeLightFeatures from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, ATTR_RGB_COLOR, ColorMode, LightEntity, + LightEntityFeature, filter_supported_color_modes, ) from homeassistant.core import HomeAssistant, callback @@ -25,6 +27,8 @@ from .coordinator import GoveeLocalApiCoordinator, GoveeLocalConfigEntry _LOGGER = logging.getLogger(__name__) +_NONE_SCENE = "none" + async def async_setup_entry( hass: HomeAssistant, @@ -50,10 +54,22 @@ async def async_setup_entry( class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): """Govee Light.""" + _attr_translation_key = "govee_light" _attr_has_entity_name = True _attr_name = None _attr_supported_color_modes: set[ColorMode] _fixed_color_mode: ColorMode | None = None + _attr_effect_list: list[str] | None = None + _attr_effect: str | None = None + _attr_supported_features: LightEntityFeature = LightEntityFeature(0) + _last_color_state: ( + tuple[ + ColorMode | str | None, + int | None, + tuple[int, int, int] | tuple[int | None] | None, + ] + | None + ) = None def __init__( self, @@ -80,6 +96,13 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): if GoveeLightFeatures.BRIGHTNESS & capabilities.features: color_modes.add(ColorMode.BRIGHTNESS) + if ( + GoveeLightFeatures.SCENES & capabilities.features + and capabilities.scenes + ): + self._attr_supported_features = LightEntityFeature.EFFECT + self._attr_effect_list = [_NONE_SCENE, *capabilities.scenes.keys()] + self._attr_supported_color_modes = filter_supported_color_modes(color_modes) if len(self._attr_supported_color_modes) == 1: # If the light supports only a single color mode, set it now @@ -143,12 +166,27 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): if ATTR_RGB_COLOR in kwargs: self._attr_color_mode = ColorMode.RGB + self._attr_effect = None + self._last_color_state = None red, green, blue = kwargs[ATTR_RGB_COLOR] await self.coordinator.set_rgb_color(self._device, red, green, blue) elif ATTR_COLOR_TEMP_KELVIN in kwargs: self._attr_color_mode = ColorMode.COLOR_TEMP + self._attr_effect = None + self._last_color_state = None temperature: float = kwargs[ATTR_COLOR_TEMP_KELVIN] await self.coordinator.set_temperature(self._device, int(temperature)) + elif ATTR_EFFECT in kwargs: + effect = kwargs[ATTR_EFFECT] + if effect and self._attr_effect_list and effect in self._attr_effect_list: + if effect == _NONE_SCENE: + self._attr_effect = None + await self._restore_last_color_state() + else: + self._attr_effect = effect + self._save_last_color_state() + await self.coordinator.set_scene(self._device, effect) + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: @@ -159,3 +197,27 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): @callback def _update_callback(self, device: GoveeDevice) -> None: self.async_write_ha_state() + + def _save_last_color_state(self) -> None: + color_mode = self.color_mode + self._last_color_state = ( + color_mode, + self.brightness, + (self.color_temp_kelvin,) + if color_mode == ColorMode.COLOR_TEMP + else self.rgb_color, + ) + + async def _restore_last_color_state(self) -> None: + if self._last_color_state: + color_mode, brightness, color = self._last_color_state + if color: + if color_mode == ColorMode.RGB: + await self.coordinator.set_rgb_color(self._device, *color) + elif color_mode == ColorMode.COLOR_TEMP: + await self.coordinator.set_temperature(self._device, *color) + if brightness: + await self.coordinator.set_brightness( + self._device, int((float(brightness) / 255.0) * 100.0) + ) + self._last_color_state = None diff --git a/homeassistant/components/govee_light_local/strings.json b/homeassistant/components/govee_light_local/strings.json index ad8f0f41ae7..49f3a2cbeb9 100644 --- a/homeassistant/components/govee_light_local/strings.json +++ b/homeassistant/components/govee_light_local/strings.json @@ -9,5 +9,29 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "entity": { + "light": { + "govee_light": { + "state_attributes": { + "effect": { + "state": { + "none": "None", + "sunrise": "Sunrise", + "sunset": "Sunset", + "movie": "Movie", + "dating": "Dating", + "romantic": "Romantic", + "twinkle": "Twinkle", + "candlelight": "Candlelight", + "snowflake": "Snowflake", + "energetic": "Energetic", + "breathe": "Breathe", + "crossing": "Crossing" + } + } + } + } + } } } diff --git a/tests/components/govee_light_local/conftest.py b/tests/components/govee_light_local/conftest.py index 61a6394bd6a..a8b6955c384 100644 --- a/tests/components/govee_light_local/conftest.py +++ b/tests/components/govee_light_local/conftest.py @@ -4,15 +4,15 @@ from asyncio import Event from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from govee_local_api import GoveeLightCapabilities -from govee_local_api.light_capabilities import COMMON_FEATURES +from govee_local_api import GoveeLightCapabilities, GoveeLightFeatures +from govee_local_api.light_capabilities import COMMON_FEATURES, SCENE_CODES import pytest from homeassistant.components.govee_light_local.coordinator import GoveeController @pytest.fixture(name="mock_govee_api") -def fixture_mock_govee_api(): +def fixture_mock_govee_api() -> Generator[AsyncMock]: """Set up Govee Local API fixture.""" mock_api = AsyncMock(spec=GoveeController) mock_api.start = AsyncMock() @@ -21,8 +21,20 @@ def fixture_mock_govee_api(): mock_api.turn_on_off = AsyncMock() mock_api.set_brightness = AsyncMock() mock_api.set_color = AsyncMock() + mock_api.set_scene = AsyncMock() mock_api._async_update_data = AsyncMock() - return mock_api + + with ( + patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_api, + ) as mock_controller, + patch( + "homeassistant.components.govee_light_local.config_flow.GoveeController", + return_value=mock_api, + ), + ): + yield mock_controller.return_value @pytest.fixture(name="mock_setup_entry") @@ -38,3 +50,9 @@ def fixture_mock_setup_entry() -> Generator[AsyncMock]: DEFAULT_CAPABILITIES: GoveeLightCapabilities = GoveeLightCapabilities( features=COMMON_FEATURES, segments=[], scenes={} ) + +SCENE_CAPABILITIES: GoveeLightCapabilities = GoveeLightCapabilities( + features=COMMON_FEATURES | GoveeLightFeatures.SCENES, + segments=[], + scenes=SCENE_CODES, +) diff --git a/tests/components/govee_light_local/test_config_flow.py b/tests/components/govee_light_local/test_config_flow.py index 103159f1a2b..e6e336a70f2 100644 --- a/tests/components/govee_light_local/test_config_flow.py +++ b/tests/components/govee_light_local/test_config_flow.py @@ -32,15 +32,9 @@ async def test_creating_entry_has_no_devices( mock_govee_api.devices = [] - with ( - patch( - "homeassistant.components.govee_light_local.config_flow.GoveeController", - return_value=mock_govee_api, - ), - patch( - "homeassistant.components.govee_light_local.config_flow.DISCOVERY_TIMEOUT", - 0, - ), + with patch( + "homeassistant.components.govee_light_local.config_flow.DISCOVERY_TIMEOUT", + 0, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -67,24 +61,20 @@ async def test_creating_entry_has_with_devices( mock_govee_api.devices = _get_devices(mock_govee_api) - with patch( - "homeassistant.components.govee_light_local.config_flow.GoveeController", - return_value=mock_govee_api, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) - # Confirmation form - assert result["type"] is FlowResultType.FORM + # Confirmation form + assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() + await hass.async_block_till_done() - mock_govee_api.start.assert_awaited_once() - mock_setup_entry.assert_awaited_once() + mock_govee_api.start.assert_awaited_once() + mock_setup_entry.assert_awaited_once() async def test_creating_entry_errno( @@ -99,21 +89,17 @@ async def test_creating_entry_errno( mock_govee_api.start.side_effect = e mock_govee_api.devices = _get_devices(mock_govee_api) - with patch( - "homeassistant.components.govee_light_local.config_flow.GoveeController", - return_value=mock_govee_api, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) - # Confirmation form - assert result["type"] is FlowResultType.FORM + # Confirmation form + assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.ABORT + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.ABORT - await hass.async_block_till_done() + await hass.async_block_till_done() - assert mock_govee_api.start.call_count == 1 - mock_setup_entry.assert_not_awaited() + assert mock_govee_api.start.call_count == 1 + mock_setup_entry.assert_not_awaited() diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py index 24bdbba9e11..c5dde6a9b9e 100644 --- a/tests/components/govee_light_local/test_light.py +++ b/tests/components/govee_light_local/test_light.py @@ -10,7 +10,7 @@ from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .conftest import DEFAULT_CAPABILITIES +from .conftest import DEFAULT_CAPABILITIES, SCENE_CAPABILITIES from tests.common import MockConfigEntry @@ -30,28 +30,24 @@ async def test_light_known_device( ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.H615A") - assert light is not None + light = hass.states.get("light.H615A") + assert light is not None - color_modes = light.attributes[ATTR_SUPPORTED_COLOR_MODES] - assert set(color_modes) == {ColorMode.COLOR_TEMP, ColorMode.RGB} + color_modes = light.attributes[ATTR_SUPPORTED_COLOR_MODES] + assert set(color_modes) == {ColorMode.COLOR_TEMP, ColorMode.RGB} - # Remove - assert await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() - assert hass.states.get("light.H615A") is None + # Remove + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("light.H615A") is None async def test_light_unknown_device( @@ -69,26 +65,22 @@ async def test_light_unknown_device( ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.XYZK") - assert light is not None + light = hass.states.get("light.XYZK") + assert light is not None - assert light.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] + assert light.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: - """Test adding a known device.""" + """Test remove device.""" mock_govee_api.devices = [ GoveeDevice( @@ -100,49 +92,41 @@ async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> N ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert hass.states.get("light.H615A") is not None + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("light.H615A") is not None - # Remove 1 - assert await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() - assert len(hass.states.async_all()) == 0 + # Remove 1 + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 async def test_light_setup_retry( hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: - """Test adding an unknown device.""" + """Test setup retry.""" mock_govee_api.devices = [] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - with patch( - "homeassistant.components.govee_light_local.DISCOVERY_TIMEOUT", - 0, - ): - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + with patch( + "homeassistant.components.govee_light_local.DISCOVERY_TIMEOUT", + 0, + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY async def test_light_setup_retry_eaddrinuse( hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: - """Test adding an unknown device.""" + """Test retry on address already in use.""" mock_govee_api.start.side_effect = OSError() mock_govee_api.start.side_effect.errno = EADDRINUSE @@ -156,21 +140,17 @@ async def test_light_setup_retry_eaddrinuse( ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY async def test_light_setup_error( hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: - """Test adding an unknown device.""" + """Test setup error.""" mock_govee_api.start.side_effect = OSError() mock_govee_api.start.side_effect.errno = ENETDOWN @@ -184,19 +164,15 @@ async def test_light_setup_error( ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_ERROR + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_ERROR async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: - """Test adding a known device.""" + """Test light on and then off.""" mock_govee_api.devices = [ GoveeDevice( @@ -208,48 +184,44 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "off" + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) - # Turn off - await hass.services.async_call( - "light", - "turn_off", - {"entity_id": light.entity_id}, - blocking=True, - ) - await hass.async_block_till_done() + # Turn off + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": light.entity_id}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "off" - mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False) async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: @@ -264,67 +236,59 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "off" + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness_pct": 50}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness_pct": 50}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50) - assert light.attributes["brightness"] == 127 + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50) + assert light.attributes["brightness"] == 127 - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - assert light.attributes["brightness"] == 255 - mock_govee_api.set_brightness.assert_awaited_with( - mock_govee_api.devices[0], 100 - ) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["brightness"] == 255 + mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - assert light.attributes["brightness"] == 255 - mock_govee_api.set_brightness.assert_awaited_with( - mock_govee_api.devices[0], 100 - ) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["brightness"] == 255 + mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: @@ -339,54 +303,312 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "off" + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "rgb_color": [100, 255, 50]}, - blocking=True, + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "rgb_color": [100, 255, 50]}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["rgb_color"] == (100, 255, 50) + assert light.attributes["color_mode"] == ColorMode.RGB + + mock_govee_api.set_color.assert_awaited_with( + mock_govee_api.devices[0], rgb=(100, 255, 50), temperature=None + ) + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "kelvin": 4400}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["color_temp_kelvin"] == 4400 + assert light.attributes["color_mode"] == ColorMode.COLOR_TEMP + + mock_govee_api.set_color.assert_awaited_with( + mock_govee_api.devices[0], rgb=None, temperature=4400 + ) + + +async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: + """Test turning on scene.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, ) - await hass.async_block_till_done() + ] - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - assert light.attributes["rgb_color"] == (100, 255, 50) - assert light.attributes["color_mode"] == ColorMode.RGB + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - mock_govee_api.set_color.assert_awaited_with( - mock_govee_api.devices[0], rgb=(100, 255, 50), temperature=None + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "sunrise"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] == "sunrise" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + +async def test_scene_restore_rgb( + hass: HomeAssistant, mock_govee_api: MagicMock +) -> None: + """Test restore rgb color.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, ) + ] - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "kelvin": 4400}, - blocking=True, + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + initial_color = (12, 34, 56) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + # Set initial color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "rgb_color": initial_color}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["rgb_color"] == initial_color + assert light.attributes["brightness"] == 255 + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Activate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "sunrise"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] == "sunrise" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Deactivate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "none"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] is None + assert light.attributes["rgb_color"] == initial_color + assert light.attributes["brightness"] == 255 + + +async def test_scene_restore_temperature( + hass: HomeAssistant, mock_govee_api: MagicMock +) -> None: + """Test restore color temperature.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, ) - await hass.async_block_till_done() + ] - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - assert light.attributes["color_temp_kelvin"] == 4400 - assert light.attributes["color_mode"] == ColorMode.COLOR_TEMP + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - mock_govee_api.set_color.assert_awaited_with( - mock_govee_api.devices[0], rgb=None, temperature=4400 + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + initial_color = 3456 + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + # Set initial color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "color_temp_kelvin": initial_color}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["color_temp_kelvin"] == initial_color + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Activate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "sunrise"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] == "sunrise" + mock_govee_api.set_scene.assert_awaited_with(mock_govee_api.devices[0], "sunrise") + + # Deactivate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "none"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] is None + assert light.attributes["color_temp_kelvin"] == initial_color + + +async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: + """Test turn on 'none' scene.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, ) + ] + + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + initial_color = (12, 34, 56) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + # Set initial color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "rgb_color": initial_color}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["rgb_color"] == initial_color + assert light.attributes["brightness"] == 255 + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Activate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "none"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] is None + mock_govee_api.set_scene.assert_not_called() From 743cc428299135579dd87ffb2f7c2264c5ff0646 Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 25 Feb 2025 08:08:32 -0800 Subject: [PATCH 2751/2987] Add Burbank Water and Power (BWP) virtual integration (#139027) --- .../components/burbank_water_and_power/__init__.py | 1 + .../components/burbank_water_and_power/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/burbank_water_and_power/__init__.py create mode 100644 homeassistant/components/burbank_water_and_power/manifest.json diff --git a/homeassistant/components/burbank_water_and_power/__init__.py b/homeassistant/components/burbank_water_and_power/__init__.py new file mode 100644 index 00000000000..2b82c8bd56b --- /dev/null +++ b/homeassistant/components/burbank_water_and_power/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Burbank Water and Power (BWP).""" diff --git a/homeassistant/components/burbank_water_and_power/manifest.json b/homeassistant/components/burbank_water_and_power/manifest.json new file mode 100644 index 00000000000..7b938d3b98b --- /dev/null +++ b/homeassistant/components/burbank_water_and_power/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "burbank_water_and_power", + "name": "Burbank Water and Power (BWP)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 01ff9d14d90..e3185251114 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -850,6 +850,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "burbank_water_and_power": { + "name": "Burbank Water and Power (BWP)", + "integration_type": "virtual", + "supported_by": "opower" + }, "caldav": { "name": "CalDAV", "integration_type": "hub", From 2bba185e4c32939ee4f45fa69f6f80c6b42348e5 Mon Sep 17 00:00:00 2001 From: Paul Traina Date: Tue, 25 Feb 2025 08:09:51 -0800 Subject: [PATCH 2752/2987] Update adext to 0.4.4 (#139151) --- homeassistant/components/alarmdecoder/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alarmdecoder/manifest.json b/homeassistant/components/alarmdecoder/manifest.json index ae1a2f4684d..c2c12792801 100644 --- a/homeassistant/components/alarmdecoder/manifest.json +++ b/homeassistant/components/alarmdecoder/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["adext", "alarmdecoder"], - "requirements": ["adext==0.4.3"] + "requirements": ["adext==0.4.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1b0af492388..00194d2f15b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -140,7 +140,7 @@ adax==0.4.0 adb-shell[async]==0.4.4 # homeassistant.components.alarmdecoder -adext==0.4.3 +adext==0.4.4 # homeassistant.components.adguard adguardhome==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 072250cad20..180ed7d43e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -128,7 +128,7 @@ adax==0.4.0 adb-shell[async]==0.4.4 # homeassistant.components.alarmdecoder -adext==0.4.3 +adext==0.4.4 # homeassistant.components.adguard adguardhome==0.7.0 From 38cc26485a5ec055335b8dedfd2b601c87f6e285 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:21:05 +0100 Subject: [PATCH 2753/2987] Add sound mode support to Onkyo (#133531) --- homeassistant/components/onkyo/__init__.py | 17 ++- homeassistant/components/onkyo/config_flow.py | 139 +++++++++++++---- homeassistant/components/onkyo/const.py | 126 ++++++++++++++-- .../components/onkyo/media_player.py | 142 ++++++++++++++---- homeassistant/components/onkyo/strings.json | 23 ++- tests/components/onkyo/__init__.py | 6 +- tests/components/onkyo/test_config_flow.py | 128 ++++++++++------ 7 files changed, 447 insertions(+), 134 deletions(-) diff --git a/homeassistant/components/onkyo/__init__.py b/homeassistant/components/onkyo/__init__.py index fd5c0ba634a..2ebe86da561 100644 --- a/homeassistant/components/onkyo/__init__.py +++ b/homeassistant/components/onkyo/__init__.py @@ -1,6 +1,7 @@ """The onkyo component.""" from dataclasses import dataclass +import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform @@ -9,10 +10,18 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, OPTION_INPUT_SOURCES, InputSource +from .const import ( + DOMAIN, + OPTION_INPUT_SOURCES, + OPTION_LISTENING_MODES, + InputSource, + ListeningMode, +) from .receiver import Receiver, async_interview from .services import DATA_MP_ENTITIES, async_register_services +_LOGGER = logging.getLogger(__name__) + PLATFORMS = [Platform.MEDIA_PLAYER] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -24,6 +33,7 @@ class OnkyoData: receiver: Receiver sources: dict[InputSource, str] + sound_modes: dict[ListeningMode, str] type OnkyoConfigEntry = ConfigEntry[OnkyoData] @@ -50,7 +60,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo sources_store: dict[str, str] = entry.options[OPTION_INPUT_SOURCES] sources = {InputSource(k): v for k, v in sources_store.items()} - entry.runtime_data = OnkyoData(receiver, sources) + sound_modes_store: dict[str, str] = entry.options.get(OPTION_LISTENING_MODES, {}) + sound_modes = {ListeningMode(k): v for k, v in sound_modes_store.items()} + + entry.runtime_data = OnkyoData(receiver, sources, sound_modes) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 228748d5257..5d941be959a 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Onkyo.""" +from collections.abc import Mapping import logging from typing import Any @@ -33,12 +34,14 @@ from .const import ( CONF_SOURCES, DOMAIN, OPTION_INPUT_SOURCES, + OPTION_LISTENING_MODES, OPTION_MAX_VOLUME, OPTION_MAX_VOLUME_DEFAULT, OPTION_VOLUME_RESOLUTION, OPTION_VOLUME_RESOLUTION_DEFAULT, VOLUME_RESOLUTION_ALLOWED, InputSource, + ListeningMode, ) from .receiver import ReceiverInfo, async_discover, async_interview @@ -46,9 +49,14 @@ _LOGGER = logging.getLogger(__name__) CONF_DEVICE = "device" -INPUT_SOURCES_ALL_MEANINGS = [ - input_source.value_meaning for input_source in InputSource -] +INPUT_SOURCES_DEFAULT: dict[str, str] = {} +LISTENING_MODES_DEFAULT: dict[str, str] = {} +INPUT_SOURCES_ALL_MEANINGS = { + input_source.value_meaning: input_source for input_source in InputSource +} +LISTENING_MODES_ALL_MEANINGS = { + listening_mode.value_meaning: listening_mode for listening_mode in ListeningMode +} STEP_MANUAL_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) STEP_RECONFIGURE_SCHEMA = vol.Schema( { @@ -59,7 +67,14 @@ STEP_CONFIGURE_SCHEMA = STEP_RECONFIGURE_SCHEMA.extend( { vol.Required(OPTION_INPUT_SOURCES): SelectSelector( SelectSelectorConfig( - options=INPUT_SOURCES_ALL_MEANINGS, + options=list(INPUT_SOURCES_ALL_MEANINGS), + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required(OPTION_LISTENING_MODES): SelectSelector( + SelectSelectorConfig( + options=list(LISTENING_MODES_ALL_MEANINGS), multiple=True, mode=SelectSelectorMode.DROPDOWN, ) @@ -238,9 +253,8 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: self._receiver_info.host, }, options={ + **entry_options, OPTION_VOLUME_RESOLUTION: volume_resolution, - OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], - OPTION_INPUT_SOURCES: entry_options[OPTION_INPUT_SOURCES], }, ) @@ -250,12 +264,24 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): input_source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES] if not input_source_meanings: errors[OPTION_INPUT_SOURCES] = "empty_input_source_list" - else: + + listening_modes: list[str] = user_input[OPTION_LISTENING_MODES] + if not listening_modes: + errors[OPTION_LISTENING_MODES] = "empty_listening_mode_list" + + if not errors: input_sources_store: dict[str, str] = {} for input_source_meaning in input_source_meanings: - input_source = InputSource.from_meaning(input_source_meaning) + input_source = INPUT_SOURCES_ALL_MEANINGS[input_source_meaning] input_sources_store[input_source.value] = input_source_meaning + listening_modes_store: dict[str, str] = {} + for listening_mode_meaning in listening_modes: + listening_mode = LISTENING_MODES_ALL_MEANINGS[ + listening_mode_meaning + ] + listening_modes_store[listening_mode.value] = listening_mode_meaning + result = self.async_create_entry( title=self._receiver_info.model_name, data={ @@ -265,6 +291,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): OPTION_VOLUME_RESOLUTION: volume_resolution, OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT, OPTION_INPUT_SOURCES: input_sources_store, + OPTION_LISTENING_MODES: listening_modes_store, }, ) @@ -278,16 +305,13 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): if reconfigure_entry is None: suggested_values = { OPTION_VOLUME_RESOLUTION: OPTION_VOLUME_RESOLUTION_DEFAULT, - OPTION_INPUT_SOURCES: [], + OPTION_INPUT_SOURCES: INPUT_SOURCES_DEFAULT, + OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, } else: entry_options = reconfigure_entry.options suggested_values = { OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION], - OPTION_INPUT_SOURCES: [ - InputSource(input_source).value_meaning - for input_source in entry_options[OPTION_INPUT_SOURCES] - ], } return self.async_show_form( @@ -356,6 +380,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): OPTION_VOLUME_RESOLUTION: volume_resolution, OPTION_MAX_VOLUME: max_volume, OPTION_INPUT_SOURCES: sources_store, + OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, }, ) @@ -373,7 +398,14 @@ OPTIONS_STEP_INIT_SCHEMA = vol.Schema( ), vol.Required(OPTION_INPUT_SOURCES): SelectSelector( SelectSelectorConfig( - options=INPUT_SOURCES_ALL_MEANINGS, + options=list(INPUT_SOURCES_ALL_MEANINGS), + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required(OPTION_LISTENING_MODES): SelectSelector( + SelectSelectorConfig( + options=list(LISTENING_MODES_ALL_MEANINGS), multiple=True, mode=SelectSelectorMode.DROPDOWN, ) @@ -387,6 +419,7 @@ class OnkyoOptionsFlowHandler(OptionsFlow): _data: dict[str, Any] _input_sources: dict[InputSource, str] + _listening_modes: dict[ListeningMode, str] async def async_step_init( self, user_input: dict[str, Any] | None = None @@ -394,20 +427,40 @@ class OnkyoOptionsFlowHandler(OptionsFlow): """Manage the options.""" errors = {} - entry_options = self.config_entry.options + entry_options: Mapping[str, Any] = self.config_entry.options + entry_options = { + OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, + **entry_options, + } if user_input is not None: - self._input_sources = {} - for input_source_meaning in user_input[OPTION_INPUT_SOURCES]: - input_source = InputSource.from_meaning(input_source_meaning) - input_source_name = entry_options[OPTION_INPUT_SOURCES].get( - input_source.value, input_source_meaning - ) - self._input_sources[input_source] = input_source_name - - if not self._input_sources: + input_source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES] + if not input_source_meanings: errors[OPTION_INPUT_SOURCES] = "empty_input_source_list" - else: + + listening_mode_meanings: list[str] = user_input[OPTION_LISTENING_MODES] + if not listening_mode_meanings: + errors[OPTION_LISTENING_MODES] = "empty_listening_mode_list" + + if not errors: + self._input_sources = {} + for input_source_meaning in input_source_meanings: + input_source = INPUT_SOURCES_ALL_MEANINGS[input_source_meaning] + input_source_name = entry_options[OPTION_INPUT_SOURCES].get( + input_source.value, input_source_meaning + ) + self._input_sources[input_source] = input_source_name + + self._listening_modes = {} + for listening_mode_meaning in listening_mode_meanings: + listening_mode = LISTENING_MODES_ALL_MEANINGS[ + listening_mode_meaning + ] + listening_mode_name = entry_options[OPTION_LISTENING_MODES].get( + listening_mode.value, listening_mode_meaning + ) + self._listening_modes[listening_mode] = listening_mode_name + self._data = { OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION], OPTION_MAX_VOLUME: user_input[OPTION_MAX_VOLUME], @@ -423,6 +476,10 @@ class OnkyoOptionsFlowHandler(OptionsFlow): InputSource(input_source).value_meaning for input_source in entry_options[OPTION_INPUT_SOURCES] ], + OPTION_LISTENING_MODES: [ + ListeningMode(listening_mode).value_meaning + for listening_mode in entry_options[OPTION_LISTENING_MODES] + ], } return self.async_show_form( @@ -440,28 +497,48 @@ class OnkyoOptionsFlowHandler(OptionsFlow): if user_input is not None: input_sources_store: dict[str, str] = {} for input_source_meaning, input_source_name in user_input[ - "input_sources" + OPTION_INPUT_SOURCES ].items(): - input_source = InputSource.from_meaning(input_source_meaning) + input_source = INPUT_SOURCES_ALL_MEANINGS[input_source_meaning] input_sources_store[input_source.value] = input_source_name + listening_modes_store: dict[str, str] = {} + for listening_mode_meaning, listening_mode_name in user_input[ + OPTION_LISTENING_MODES + ].items(): + listening_mode = LISTENING_MODES_ALL_MEANINGS[listening_mode_meaning] + listening_modes_store[listening_mode.value] = listening_mode_name + return self.async_create_entry( data={ **self._data, OPTION_INPUT_SOURCES: input_sources_store, + OPTION_LISTENING_MODES: listening_modes_store, } ) - schema_dict: dict[Any, Selector] = {} - + input_sources_schema_dict: dict[Any, Selector] = {} for input_source, input_source_name in self._input_sources.items(): - schema_dict[ + input_sources_schema_dict[ vol.Required(input_source.value_meaning, default=input_source_name) ] = TextSelector() + listening_modes_schema_dict: dict[Any, Selector] = {} + for listening_mode, listening_mode_name in self._listening_modes.items(): + listening_modes_schema_dict[ + vol.Required(listening_mode.value_meaning, default=listening_mode_name) + ] = TextSelector() + return self.async_show_form( step_id="names", data_schema=vol.Schema( - {vol.Required("input_sources"): section(vol.Schema(schema_dict))} + { + vol.Required(OPTION_INPUT_SOURCES): section( + vol.Schema(input_sources_schema_dict) + ), + vol.Required(OPTION_LISTENING_MODES): section( + vol.Schema(listening_modes_schema_dict) + ), + } ), ) diff --git a/homeassistant/components/onkyo/const.py b/homeassistant/components/onkyo/const.py index bd4fe98ae7d..fcb1a8a0a9e 100644 --- a/homeassistant/components/onkyo/const.py +++ b/homeassistant/components/onkyo/const.py @@ -2,7 +2,7 @@ from enum import Enum import typing -from typing import ClassVar, Literal, Self +from typing import Literal, Self import pyeiscp @@ -24,7 +24,27 @@ VOLUME_RESOLUTION_ALLOWED: tuple[VolumeResolution, ...] = typing.get_args( OPTION_MAX_VOLUME = "max_volume" OPTION_MAX_VOLUME_DEFAULT = 100.0 + +class EnumWithMeaning(Enum): + """Enum with meaning.""" + + value_meaning: str + + def __new__(cls, value: str) -> Self: + """Create enum.""" + obj = object.__new__(cls) + obj._value_ = value + obj.value_meaning = cls._get_meanings()[value] + + return obj + + @staticmethod + def _get_meanings() -> dict[str, str]: + raise NotImplementedError + + OPTION_INPUT_SOURCES = "input_sources" +OPTION_LISTENING_MODES = "listening_modes" _INPUT_SOURCE_MEANINGS = { "00": "VIDEO1 ··· VCR/DVR ··· STB/DVR", @@ -71,7 +91,7 @@ _INPUT_SOURCE_MEANINGS = { } -class InputSource(Enum): +class InputSource(EnumWithMeaning): """Receiver input source.""" DVR = "00" @@ -116,24 +136,100 @@ class InputSource(Enum): HDMI_7 = "57" MAIN_SOURCE = "80" - __meaning_mapping: ClassVar[dict[str, Self]] = {} # type: ignore[misc] + @staticmethod + def _get_meanings() -> dict[str, str]: + return _INPUT_SOURCE_MEANINGS - value_meaning: str - def __new__(cls, value: str) -> Self: - """Create InputSource enum.""" - obj = object.__new__(cls) - obj._value_ = value - obj.value_meaning = _INPUT_SOURCE_MEANINGS[value] +_LISTENING_MODE_MEANINGS = { + "00": "STEREO", + "01": "DIRECT", + "02": "SURROUND", + "03": "FILM ··· GAME RPG ··· ADVANCED GAME", + "04": "THX", + "05": "ACTION ··· GAME ACTION", + "06": "MUSICAL ··· GAME ROCK ··· ROCK/POP", + "07": "MONO MOVIE", + "08": "ORCHESTRA ··· CLASSICAL", + "09": "UNPLUGGED", + "0A": "STUDIO MIX ··· ENTERTAINMENT SHOW", + "0B": "TV LOGIC ··· DRAMA", + "0C": "ALL CH STEREO ··· EXTENDED STEREO", + "0D": "THEATER DIMENSIONAL ··· FRONT STAGE SURROUND", + "0E": "ENHANCED 7/ENHANCE ··· GAME SPORTS ··· SPORTS", + "0F": "MONO", + "11": "PURE AUDIO ··· PURE DIRECT", + "12": "MULTIPLEX", + "13": "FULL MONO ··· MONO MUSIC", + "14": "DOLBY VIRTUAL/SURROUND ENHANCER", + "15": "DTS SURROUND SENSATION", + "16": "AUDYSSEY DSX", + "17": "DTS VIRTUAL:X", + "1F": "WHOLE HOUSE MODE ··· MULTI ZONE MUSIC", + "23": "STAGE (JAPAN GENRE CONTROL)", + "25": "ACTION (JAPAN GENRE CONTROL)", + "26": "MUSIC (JAPAN GENRE CONTROL)", + "2E": "SPORTS (JAPAN GENRE CONTROL)", + "40": "STRAIGHT DECODE ··· 5.1 CH SURROUND", + "41": "DOLBY EX/DTS ES", + "42": "THX CINEMA", + "43": "THX SURROUND EX", + "44": "THX MUSIC", + "45": "THX GAMES", + "50": "THX U(2)/S(2)/I/S CINEMA", + "51": "THX U(2)/S(2)/I/S MUSIC", + "52": "THX U(2)/S(2)/I/S GAMES", + "80": "DOLBY ATMOS/DOLBY SURROUND ··· PLII/PLIIx MOVIE", + "81": "PLII/PLIIx MUSIC", + "82": "DTS:X/NEURAL:X ··· NEO:6/NEO:X CINEMA", + "83": "NEO:6/NEO:X MUSIC", + "84": "DOLBY SURROUND THX CINEMA ··· PLII/PLIIx THX CINEMA", + "85": "DTS NEURAL:X THX CINEMA ··· NEO:6/NEO:X THX CINEMA", + "86": "PLII/PLIIx GAME", + "87": "NEURAL SURR", + "88": "NEURAL THX/NEURAL SURROUND", + "89": "DOLBY SURROUND THX GAMES ··· PLII/PLIIx THX GAMES", + "8A": "DTS NEURAL:X THX GAMES ··· NEO:6/NEO:X THX GAMES", + "8B": "DOLBY SURROUND THX MUSIC ··· PLII/PLIIx THX MUSIC", + "8C": "DTS NEURAL:X THX MUSIC ··· NEO:6/NEO:X THX MUSIC", + "8D": "NEURAL THX CINEMA", + "8E": "NEURAL THX MUSIC", + "8F": "NEURAL THX GAMES", + "90": "PLIIz HEIGHT", + "91": "NEO:6 CINEMA DTS SURROUND SENSATION", + "92": "NEO:6 MUSIC DTS SURROUND SENSATION", + "93": "NEURAL DIGITAL MUSIC", + "94": "PLIIz HEIGHT + THX CINEMA", + "95": "PLIIz HEIGHT + THX MUSIC", + "96": "PLIIz HEIGHT + THX GAMES", + "97": "PLIIz HEIGHT + THX U2/S2 CINEMA", + "98": "PLIIz HEIGHT + THX U2/S2 MUSIC", + "99": "PLIIz HEIGHT + THX U2/S2 GAMES", + "9A": "NEO:X GAME", + "A0": "PLIIx/PLII Movie + AUDYSSEY DSX", + "A1": "PLIIx/PLII MUSIC + AUDYSSEY DSX", + "A2": "PLIIx/PLII GAME + AUDYSSEY DSX", + "A3": "NEO:6 CINEMA + AUDYSSEY DSX", + "A4": "NEO:6 MUSIC + AUDYSSEY DSX", + "A5": "NEURAL SURROUND + AUDYSSEY DSX", + "A6": "NEURAL DIGITAL MUSIC + AUDYSSEY DSX", + "A7": "DOLBY EX + AUDYSSEY DSX", + "FF": "AUTO SURROUND", +} - cls.__meaning_mapping[obj.value_meaning] = obj - return obj +class ListeningMode(EnumWithMeaning): + """Receiver listening mode.""" - @classmethod - def from_meaning(cls, meaning: str) -> Self: - """Get InputSource enum from its meaning.""" - return cls.__meaning_mapping[meaning] + _ignore_ = "ListeningMode _k _v _meaning" + + ListeningMode = vars() + for _k in _LISTENING_MODE_MEANINGS: + ListeningMode["I" + _k] = _k + + @staticmethod + def _get_meanings() -> dict[str, str]: + return _LISTENING_MODE_MEANINGS ZONES = {"main": "Main", "zone2": "Zone 2", "zone3": "Zone 3", "zone4": "Zone 4"} diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 711cede15bc..7c91fda5f78 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from enum import Enum from functools import cache import logging from typing import Any, Literal @@ -39,6 +40,7 @@ from .const import ( PYEISCP_COMMANDS, ZONES, InputSource, + ListeningMode, VolumeResolution, ) from .receiver import Receiver, async_discover @@ -63,6 +65,8 @@ CONF_SOURCES_DEFAULT = { "fm": "Radio", } +ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo" + PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_HOST): cv.string, @@ -79,23 +83,23 @@ PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( } ) -SUPPORT_ONKYO_WO_VOLUME = ( + +SUPPORTED_FEATURES_BASE = ( MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.PLAY_MEDIA ) -SUPPORT_ONKYO = ( - SUPPORT_ONKYO_WO_VOLUME - | MediaPlayerEntityFeature.VOLUME_SET +SUPPORTED_FEATURES_VOLUME = ( + MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_STEP ) -DEFAULT_PLAYABLE_SOURCES = ( - InputSource.from_meaning("FM"), - InputSource.from_meaning("AM"), - InputSource.from_meaning("DAB"), +PLAYABLE_SOURCES = ( + InputSource.FM, + InputSource.AM, + InputSource.DAB, ) ATTR_PRESET = "preset" @@ -118,7 +122,6 @@ AUDIO_INFORMATION_MAPPING = [ "auto_phase_control_phase", "upmix_mode", ] - VIDEO_INFORMATION_MAPPING = [ "video_input_port", "input_resolution", @@ -131,7 +134,6 @@ VIDEO_INFORMATION_MAPPING = [ "picture_mode", "input_hdr", ] -ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo" type LibValue = str | tuple[str, ...] @@ -139,7 +141,19 @@ type LibValue = str | tuple[str, ...] def _get_single_lib_value(value: LibValue) -> str: if isinstance(value, str): return value - return value[0] + return value[-1] + + +def _get_lib_mapping[T: Enum](cmds: Any, cls: type[T]) -> dict[T, LibValue]: + result: dict[T, LibValue] = {} + for k, v in cmds["values"].items(): + try: + key = cls(k) + except ValueError: + continue + result[key] = v["name"] + + return result @cache @@ -154,15 +168,7 @@ def _input_source_lib_mappings(zone: str) -> dict[InputSource, LibValue]: case "zone4": cmds = PYEISCP_COMMANDS["zone4"]["SL4"] - result: dict[InputSource, LibValue] = {} - for k, v in cmds["values"].items(): - try: - source = InputSource(k) - except ValueError: - continue - result[source] = v["name"] - - return result + return _get_lib_mapping(cmds, InputSource) @cache @@ -170,6 +176,24 @@ def _rev_input_source_lib_mappings(zone: str) -> dict[LibValue, InputSource]: return {value: key for key, value in _input_source_lib_mappings(zone).items()} +@cache +def _listening_mode_lib_mappings(zone: str) -> dict[ListeningMode, LibValue]: + match zone: + case "main": + cmds = PYEISCP_COMMANDS["main"]["LMD"] + case "zone2": + cmds = PYEISCP_COMMANDS["zone2"]["LMZ"] + case _: + return {} + + return _get_lib_mapping(cmds, ListeningMode) + + +@cache +def _rev_listening_mode_lib_mappings(zone: str) -> dict[LibValue, ListeningMode]: + return {value: key for key, value in _listening_mode_lib_mappings(zone).items()} + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -303,6 +327,7 @@ async def async_setup_entry( volume_resolution: VolumeResolution = entry.options[OPTION_VOLUME_RESOLUTION] max_volume: float = entry.options[OPTION_MAX_VOLUME] sources = data.sources + sound_modes = data.sound_modes def connect_callback(receiver: Receiver) -> None: if not receiver.first_connect: @@ -331,6 +356,7 @@ async def async_setup_entry( volume_resolution=volume_resolution, max_volume=max_volume, sources=sources, + sound_modes=sound_modes, ) entities[zone] = zone_entity async_add_entities([zone_entity]) @@ -345,6 +371,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): _attr_should_poll = False _supports_volume: bool = False + _supports_sound_mode: bool = False _supports_audio_info: bool = False _supports_video_info: bool = False _query_timer: asyncio.TimerHandle | None = None @@ -357,6 +384,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): volume_resolution: VolumeResolution, max_volume: float, sources: dict[InputSource, str], + sound_modes: dict[ListeningMode, str], ) -> None: """Initialize the Onkyo Receiver.""" self._receiver = receiver @@ -381,7 +409,27 @@ class OnkyoMediaPlayer(MediaPlayerEntity): value: key for key, value in self._source_mapping.items() } + self._sound_mode_lib_mapping = _listening_mode_lib_mappings(zone) + self._rev_sound_mode_lib_mapping = _rev_listening_mode_lib_mappings(zone) + self._sound_mode_mapping = { + key: value + for key, value in sound_modes.items() + if key in self._sound_mode_lib_mapping + } + self._rev_sound_mode_mapping = { + value: key for key, value in self._sound_mode_mapping.items() + } + self._attr_source_list = list(self._rev_source_mapping) + self._attr_sound_mode_list = list(self._rev_sound_mode_mapping) + + self._attr_supported_features = SUPPORTED_FEATURES_BASE + if zone == "main": + self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME + self._supports_volume = True + self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE + self._supports_sound_mode = True + self._attr_extra_state_attributes = {} async def async_added_to_hass(self) -> None: @@ -394,13 +442,6 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._query_timer.cancel() self._query_timer = None - @property - def supported_features(self) -> MediaPlayerEntityFeature: - """Return media player features that are supported.""" - if self._supports_volume: - return SUPPORT_ONKYO - return SUPPORT_ONKYO_WO_VOLUME - @callback def _update_receiver(self, propname: str, value: Any) -> None: """Update a property in the receiver.""" @@ -466,6 +507,24 @@ class OnkyoMediaPlayer(MediaPlayerEntity): "input-selector" if self._zone == "main" else "selector", source_lib_single ) + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Select listening sound mode.""" + if not self.sound_mode_list or sound_mode not in self.sound_mode_list: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_sound_mode", + translation_placeholders={ + "invalid_sound_mode": sound_mode, + "entity_id": self.entity_id, + }, + ) + + sound_mode_lib = self._sound_mode_lib_mapping[ + self._rev_sound_mode_mapping[sound_mode] + ] + sound_mode_lib_single = _get_single_lib_value(sound_mode_lib) + self._update_receiver("listening-mode", sound_mode_lib_single) + async def async_select_output(self, hdmi_output: str) -> None: """Set hdmi-out.""" self._update_receiver("hdmi-output-selector", hdmi_output) @@ -476,7 +535,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): """Play radio station by preset number.""" if self.source is not None: source = self._rev_source_mapping[self.source] - if media_type.lower() == "radio" and source in DEFAULT_PLAYABLE_SOURCES: + if media_type.lower() == "radio" and source in PLAYABLE_SOURCES: self._update_receiver("preset", media_id) @callback @@ -517,7 +576,9 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._attr_extra_state_attributes.pop(ATTR_PRESET, None) self._attr_extra_state_attributes.pop(ATTR_VIDEO_OUT, None) elif command in ["volume", "master-volume"] and value != "N/A": - self._supports_volume = True + if not self._supports_volume: + self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME + self._supports_volume = True # AMP_VOL / (VOL_RESOLUTION * (MAX_VOL / 100)) volume_level: float = value / ( self._volume_resolution * self._max_volume / 100 @@ -535,6 +596,14 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._attr_extra_state_attributes[ATTR_PRESET] = value elif ATTR_PRESET in self._attr_extra_state_attributes: del self._attr_extra_state_attributes[ATTR_PRESET] + elif command == "listening-mode" and value != "N/A": + if not self._supports_sound_mode: + self._attr_supported_features |= ( + MediaPlayerEntityFeature.SELECT_SOUND_MODE + ) + self._supports_sound_mode = True + self._parse_sound_mode(value) + self._query_av_info_delayed() elif command == "audio-information": self._supports_audio_info = True self._parse_audio_information(value) @@ -561,6 +630,21 @@ class OnkyoMediaPlayer(MediaPlayerEntity): ) self._attr_source = source_meaning + @callback + def _parse_sound_mode(self, mode_lib: LibValue) -> None: + sound_mode = self._rev_sound_mode_lib_mapping[mode_lib] + if sound_mode in self._sound_mode_mapping: + self._attr_sound_mode = self._sound_mode_mapping[sound_mode] + return + + sound_mode_meaning = sound_mode.value_meaning + _LOGGER.error( + 'Listening mode "%s" is invalid for entity: %s', + sound_mode_meaning, + self.entity_id, + ) + self._attr_sound_mode = sound_mode_meaning + @callback def _parse_audio_information( self, audio_information: tuple[str] | Literal["N/A"] diff --git a/homeassistant/components/onkyo/strings.json b/homeassistant/components/onkyo/strings.json index b3b14efec44..d8131dd1149 100644 --- a/homeassistant/components/onkyo/strings.json +++ b/homeassistant/components/onkyo/strings.json @@ -27,17 +27,20 @@ "description": "Configure {name}", "data": { "volume_resolution": "Volume resolution", - "input_sources": "[%key:component::onkyo::options::step::init::data::input_sources%]" + "input_sources": "[%key:component::onkyo::options::step::init::data::input_sources%]", + "listening_modes": "[%key:component::onkyo::options::step::init::data::listening_modes%]" }, "data_description": { "volume_resolution": "Number of steps it takes for the receiver to go from the lowest to the highest possible volume.", - "input_sources": "[%key:component::onkyo::options::step::init::data_description::input_sources%]" + "input_sources": "[%key:component::onkyo::options::step::init::data_description::input_sources%]", + "listening_modes": "[%key:component::onkyo::options::step::init::data_description::listening_modes%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "empty_input_source_list": "[%key:component::onkyo::options::error::empty_input_source_list%]", + "empty_listening_mode_list": "[%key:component::onkyo::options::error::empty_listening_mode_list%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { @@ -53,11 +56,13 @@ "init": { "data": { "max_volume": "Maximum volume limit (%)", - "input_sources": "Input sources" + "input_sources": "Input sources", + "listening_modes": "Listening modes" }, "data_description": { "max_volume": "Maximum volume limit as a percentage. This will associate Home Assistant's maximum volume to this value on the receiver, i.e., if you set this to 50%, then setting the volume to 100% in Home Assistant will cause the volume on the receiver to be set to 50% of its maximum value.", - "input_sources": "List of input sources supported by the receiver." + "input_sources": "List of input sources supported by the receiver.", + "listening_modes": "List of listening modes supported by the receiver." } }, "names": { @@ -65,12 +70,17 @@ "input_sources": { "name": "Input source names", "description": "Mappings of receiver's input sources to their names." + }, + "listening_modes": { + "name": "Listening mode names", + "description": "Mappings of receiver's listening modes to their names." } } } }, "error": { - "empty_input_source_list": "Input source list cannot be empty" + "empty_input_source_list": "Input source list cannot be empty", + "empty_listening_mode_list": "Listening mode list cannot be empty" } }, "issues": { @@ -84,6 +94,9 @@ } }, "exceptions": { + "invalid_sound_mode": { + "message": "Cannot select sound mode \"{invalid_sound_mode}\" for entity: {entity_id}." + }, "invalid_source": { "message": "Cannot select input source \"{invalid_source}\" for entity: {entity_id}." } diff --git a/tests/components/onkyo/__init__.py b/tests/components/onkyo/__init__.py index 064075d109e..689711888d8 100644 --- a/tests/components/onkyo/__init__.py +++ b/tests/components/onkyo/__init__.py @@ -34,8 +34,9 @@ def create_config_entry_from_info(info: ReceiverInfo) -> MockConfigEntry: data = {CONF_HOST: info.host} options = { "volume_resolution": 80, - "input_sources": {"12": "tv"}, "max_volume": 100, + "input_sources": {"12": "tv"}, + "listening_modes": {"00": "stereo"}, } return MockConfigEntry( @@ -52,8 +53,9 @@ def create_empty_config_entry() -> MockConfigEntry: data = {CONF_HOST: ""} options = { "volume_resolution": 80, - "input_sources": {"12": "tv"}, "max_volume": 100, + "input_sources": {"12": "tv"}, + "listening_modes": {"00": "stereo"}, } return MockConfigEntry( diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index 203cc22cf95..000e74d5308 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -11,7 +11,9 @@ from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow from homeassistant.components.onkyo.const import ( DOMAIN, OPTION_INPUT_SOURCES, + OPTION_LISTENING_MODES, OPTION_MAX_VOLUME, + OPTION_MAX_VOLUME_DEFAULT, OPTION_VOLUME_RESOLUTION, ) from homeassistant.config_entries import SOURCE_USER @@ -226,7 +228,11 @@ async def test_ssdp_discovery_success( select_result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"volume_resolution": 200, "input_sources": ["TV"]}, + user_input={ + "volume_resolution": 200, + "input_sources": ["TV"], + "listening_modes": ["THX"], + }, ) assert select_result["type"] is FlowResultType.CREATE_ENTRY @@ -349,34 +355,6 @@ async def test_ssdp_discovery_no_host( assert result["reason"] == "unknown" -async def test_configure_empty_source_list( - hass: HomeAssistant, default_mock_discovery -) -> None: - """Test receiver configuration with no sources set.""" - - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - configure_result = await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"volume_resolution": 200, "input_sources": []}, - ) - - assert configure_result["errors"] == {"input_sources": "empty_input_source_list"} - - async def test_configure_no_resolution( hass: HomeAssistant, default_mock_discovery ) -> None: @@ -404,33 +382,61 @@ async def test_configure_no_resolution( ) -async def test_configure_resolution_set( - hass: HomeAssistant, default_mock_discovery -) -> None: - """Test receiver configure with specified resolution.""" +async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: + """Test receiver configure.""" - init_result = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "manual"}, ) - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "sample-host-name"}, ) - configure_result = await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"volume_resolution": 200, "input_sources": ["TV"]}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: [], + OPTION_LISTENING_MODES: ["THX"], + }, ) + assert result["step_id"] == "configure_receiver" + assert result["errors"] == {OPTION_INPUT_SOURCES: "empty_input_source_list"} - assert configure_result["type"] is FlowResultType.CREATE_ENTRY - assert configure_result["options"]["volume_resolution"] == 200 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: [], + }, + ) + assert result["step_id"] == "configure_receiver" + assert result["errors"] == {OPTION_LISTENING_MODES: "empty_listening_mode_list"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["options"] == { + OPTION_VOLUME_RESOLUTION: 200, + OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT, + OPTION_INPUT_SOURCES: {"12": "TV"}, + OPTION_LISTENING_MODES: {"04": "THX"}, + } async def test_configure_invalid_resolution_set( @@ -601,21 +607,26 @@ async def test_import_success( await hass.async_block_till_done() assert import_result["type"] is FlowResultType.CREATE_ENTRY - assert import_result["data"]["host"] == "host 1" - assert import_result["options"]["volume_resolution"] == 80 - assert import_result["options"]["max_volume"] == 100 - assert import_result["options"]["input_sources"] == { - "00": "Auxiliary", - "01": "Video", + assert import_result["data"] == {"host": "host 1"} + assert import_result["options"] == { + "volume_resolution": 80, + "max_volume": 100, + "input_sources": { + "00": "Auxiliary", + "01": "Video", + }, + "listening_modes": {}, } @pytest.mark.parametrize( "ignore_translations", [ - [ # The schema is dynamically created from input sources + [ # The schema is dynamically created from input sources and listening modes "component.onkyo.options.step.names.sections.input_sources.data.TV", "component.onkyo.options.step.names.sections.input_sources.data_description.TV", + "component.onkyo.options.step.names.sections.listening_modes.data.STEREO", + "component.onkyo.options.step.names.sections.listening_modes.data_description.STEREO", ] ], ) @@ -635,6 +646,7 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) user_input={ OPTION_MAX_VOLUME: 42, OPTION_INPUT_SOURCES: [], + OPTION_LISTENING_MODES: ["STEREO"], }, ) @@ -647,6 +659,20 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) user_input={ OPTION_MAX_VOLUME: 42, OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: [], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {OPTION_LISTENING_MODES: "empty_listening_mode_list"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + OPTION_MAX_VOLUME: 42, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["STEREO"], }, ) @@ -657,6 +683,7 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) result["flow_id"], user_input={ OPTION_INPUT_SOURCES: {"TV": "television"}, + OPTION_LISTENING_MODES: {"STEREO": "Duophonia"}, }, ) @@ -665,4 +692,5 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) OPTION_VOLUME_RESOLUTION: old_volume_resolution, OPTION_MAX_VOLUME: 42.0, OPTION_INPUT_SOURCES: {"12": "television"}, + OPTION_LISTENING_MODES: {"00": "Duophonia"}, } From 4e904bf5a3f202da06f38a0c3d6843e6d0c1afa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Gro=C3=9F?= Date: Tue, 25 Feb 2025 17:21:31 +0100 Subject: [PATCH 2754/2987] Use new python library for picnic component (#139111) --- CODEOWNERS | 4 ++-- homeassistant/components/picnic/__init__.py | 2 +- homeassistant/components/picnic/config_flow.py | 4 ++-- homeassistant/components/picnic/coordinator.py | 4 ++-- homeassistant/components/picnic/manifest.json | 6 +++--- homeassistant/components/picnic/services.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/picnic/test_config_flow.py | 2 +- 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 87f170009f0..1052a58fe88 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1146,8 +1146,8 @@ build.json @home-assistant/supervisor /tests/components/philips_js/ @elupus /homeassistant/components/pi_hole/ @shenxn /tests/components/pi_hole/ @shenxn -/homeassistant/components/picnic/ @corneyl -/tests/components/picnic/ @corneyl +/homeassistant/components/picnic/ @corneyl @codesalatdev +/tests/components/picnic/ @corneyl @codesalatdev /homeassistant/components/ping/ @jpbede /tests/components/ping/ @jpbede /homeassistant/components/plaato/ @JohNan diff --git a/homeassistant/components/picnic/__init__.py b/homeassistant/components/picnic/__init__.py index d2f023af79f..8de407133cd 100644 --- a/homeassistant/components/picnic/__init__.py +++ b/homeassistant/components/picnic/__init__.py @@ -1,6 +1,6 @@ """The Picnic integration.""" -from python_picnic_api import PicnicAPI +from python_picnic_api2 import PicnicAPI from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY_CODE, Platform diff --git a/homeassistant/components/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py index 4c8281f21de..a60086173a8 100644 --- a/homeassistant/components/picnic/config_flow.py +++ b/homeassistant/components/picnic/config_flow.py @@ -6,8 +6,8 @@ from collections.abc import Mapping import logging from typing import Any -from python_picnic_api import PicnicAPI -from python_picnic_api.session import PicnicAuthError +from python_picnic_api2 import PicnicAPI +from python_picnic_api2.session import PicnicAuthError import requests import voluptuous as vol diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index de686cad37d..9b23157dbf3 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -6,8 +6,8 @@ import copy from datetime import timedelta import logging -from python_picnic_api import PicnicAPI -from python_picnic_api.session import PicnicAuthError +from python_picnic_api2 import PicnicAPI +from python_picnic_api2.session import PicnicAuthError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN diff --git a/homeassistant/components/picnic/manifest.json b/homeassistant/components/picnic/manifest.json index 947dd0241d2..09f28da39a4 100644 --- a/homeassistant/components/picnic/manifest.json +++ b/homeassistant/components/picnic/manifest.json @@ -1,10 +1,10 @@ { "domain": "picnic", "name": "Picnic", - "codeowners": ["@corneyl"], + "codeowners": ["@corneyl", "@codesalatdev"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/picnic", "iot_class": "cloud_polling", - "loggers": ["python_picnic_api"], - "requirements": ["python-picnic-api==1.1.0"] + "loggers": ["python_picnic_api2"], + "requirements": ["python-picnic-api2==1.2.2"] } diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index bbc775891b7..76d7b8a6c44 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import cast -from python_picnic_api import PicnicAPI +from python_picnic_api2 import PicnicAPI import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall diff --git a/requirements_all.txt b/requirements_all.txt index 00194d2f15b..44cd0de4281 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2455,7 +2455,7 @@ python-otbr-api==2.7.0 python-overseerr==0.7.0 # homeassistant.components.picnic -python-picnic-api==1.1.0 +python-picnic-api2==1.2.2 # homeassistant.components.rabbitair python-rabbitair==0.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 180ed7d43e4..b6c384e9944 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1991,7 +1991,7 @@ python-otbr-api==2.7.0 python-overseerr==0.7.0 # homeassistant.components.picnic -python-picnic-api==1.1.0 +python-picnic-api2==1.2.2 # homeassistant.components.rabbitair python-rabbitair==0.0.8 diff --git a/tests/components/picnic/test_config_flow.py b/tests/components/picnic/test_config_flow.py index 8d668b28c16..ba4c36682e1 100644 --- a/tests/components/picnic/test_config_flow.py +++ b/tests/components/picnic/test_config_flow.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from python_picnic_api.session import PicnicAuthError +from python_picnic_api2.session import PicnicAuthError import requests from homeassistant import config_entries From a910fb879c9760da64f1db6d50787dbda03cab72 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Feb 2025 18:23:32 +0100 Subject: [PATCH 2755/2987] Bump securetar to 2025.2.1 (#139273) --- homeassistant/components/backup/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/backup/manifest.json b/homeassistant/components/backup/manifest.json index 6cbfb834c7f..db0719983b1 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["cronsim==2.6", "securetar==2025.1.4"] + "requirements": ["cronsim==2.6", "securetar==2025.2.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e4f9466a10e..6a6c1dfc3ed 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -60,7 +60,7 @@ python-slugify==8.0.4 PyTurboJPEG==1.7.5 PyYAML==6.0.2 requests==2.32.3 -securetar==2025.1.4 +securetar==2025.2.1 SQLAlchemy==2.0.38 standard-aifc==3.13.0 standard-telnetlib==3.13.0 diff --git a/pyproject.toml b/pyproject.toml index 7a970b405a6..a7e3917eb90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ dependencies = [ "python-slugify==8.0.4", "PyYAML==6.0.2", "requests==2.32.3", - "securetar==2025.1.4", + "securetar==2025.2.1", "SQLAlchemy==2.0.38", "standard-aifc==3.13.0", "standard-telnetlib==3.13.0", diff --git a/requirements.txt b/requirements.txt index f002f0d6ecc..b378688106d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,7 +38,7 @@ psutil-home-assistant==0.0.1 python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 -securetar==2025.1.4 +securetar==2025.2.1 SQLAlchemy==2.0.38 standard-aifc==3.13.0 standard-telnetlib==3.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 44cd0de4281..592add8e73e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2690,7 +2690,7 @@ screenlogicpy==0.10.0 scsgate==0.1.0 # homeassistant.components.backup -securetar==2025.1.4 +securetar==2025.2.1 # homeassistant.components.sendgrid sendgrid==6.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b6c384e9944..e9510d296fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2172,7 +2172,7 @@ sanix==1.0.6 screenlogicpy==0.10.0 # homeassistant.components.backup -securetar==2025.1.4 +securetar==2025.2.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense From a1d1f6ec97c68ecbb544cd40694d827ae429674a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2025 18:08:53 +0000 Subject: [PATCH 2756/2987] Fix race in async_get_integrations with multiple calls when an integration is not found (#139270) * Fix race in async_get_integrations with multiple calls when an integration is not found * Fix race in async_get_integrations with multiple calls when an integration is not found * Fix race in async_get_integrations with multiple calls when an integration is not found * tweaks * tweaks * tweaks * restore lost comment * tweak test * comment cache * improve test * improve comment --- homeassistant/loader.py | 68 ++++++++++++++++++++++------------------- tests/test_loader.py | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 31 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 92b588dbe15..008c2b057b2 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -40,7 +40,6 @@ from .generated.ssdp import SSDP from .generated.usb import USB from .generated.zeroconf import HOMEKIT, ZEROCONF from .helpers.json import json_bytes, json_fragment -from .helpers.typing import UNDEFINED from .util.hass_dict import HassKey from .util.json import JSON_DECODE_EXCEPTIONS, json_loads @@ -125,9 +124,9 @@ BLOCKED_CUSTOM_INTEGRATIONS: dict[str, BlockedIntegration] = { DATA_COMPONENTS: HassKey[dict[str, ModuleType | ComponentProtocol]] = HassKey( "components" ) -DATA_INTEGRATIONS: HassKey[dict[str, Integration | asyncio.Future[None]]] = HassKey( - "integrations" -) +DATA_INTEGRATIONS: HassKey[ + dict[str, Integration | asyncio.Future[Integration | IntegrationNotFound]] +] = HassKey("integrations") DATA_MISSING_PLATFORMS: HassKey[dict[str, bool]] = HassKey("missing_platforms") DATA_CUSTOM_COMPONENTS: HassKey[ dict[str, Integration] | asyncio.Future[dict[str, Integration]] @@ -1345,7 +1344,7 @@ def async_get_loaded_integration(hass: HomeAssistant, domain: str) -> Integratio Raises IntegrationNotLoaded if the integration is not loaded. """ cache = hass.data[DATA_INTEGRATIONS] - int_or_fut = cache.get(domain, UNDEFINED) + int_or_fut = cache.get(domain) # Integration is never subclassed, so we can check for type if type(int_or_fut) is Integration: return int_or_fut @@ -1355,7 +1354,7 @@ def async_get_loaded_integration(hass: HomeAssistant, domain: str) -> Integratio async def async_get_integration(hass: HomeAssistant, domain: str) -> Integration: """Get integration.""" cache = hass.data[DATA_INTEGRATIONS] - if type(int_or_fut := cache.get(domain, UNDEFINED)) is Integration: + if type(int_or_fut := cache.get(domain)) is Integration: return int_or_fut integrations_or_excs = await async_get_integrations(hass, [domain]) int_or_exc = integrations_or_excs[domain] @@ -1370,15 +1369,17 @@ async def async_get_integrations( """Get integrations.""" cache = hass.data[DATA_INTEGRATIONS] results: dict[str, Integration | Exception] = {} - needed: dict[str, asyncio.Future[None]] = {} - in_progress: dict[str, asyncio.Future[None]] = {} + needed: dict[str, asyncio.Future[Integration | IntegrationNotFound]] = {} + in_progress: dict[str, asyncio.Future[Integration | IntegrationNotFound]] = {} for domain in domains: - int_or_fut = cache.get(domain, UNDEFINED) + int_or_fut = cache.get(domain) # Integration is never subclassed, so we can check for type if type(int_or_fut) is Integration: results[domain] = int_or_fut - elif int_or_fut is not UNDEFINED: - in_progress[domain] = cast(asyncio.Future[None], int_or_fut) + elif int_or_fut: + if TYPE_CHECKING: + assert isinstance(int_or_fut, asyncio.Future) + in_progress[domain] = int_or_fut elif "." in domain: results[domain] = ValueError(f"Invalid domain {domain}") else: @@ -1386,14 +1387,13 @@ async def async_get_integrations( if in_progress: await asyncio.wait(in_progress.values()) - for domain in in_progress: - # When we have waited and it's UNDEFINED, it doesn't exist - # We don't cache that it doesn't exist, or else people can't fix it - # and then restart, because their config will never be valid. - if (int_or_fut := cache.get(domain, UNDEFINED)) is UNDEFINED: - results[domain] = IntegrationNotFound(domain) - else: - results[domain] = cast(Integration, int_or_fut) + # Here we retrieve the results we waited for + # instead of reading them from the cache since + # reading from the cache will have a race if + # the integration gets removed from the cache + # because it was not found. + for domain, future in in_progress.items(): + results[domain] = future.result() if not needed: return results @@ -1405,7 +1405,7 @@ async def async_get_integrations( for domain, future in needed.items(): if integration := custom.get(domain): results[domain] = cache[domain] = integration - future.set_result(None) + future.set_result(integration) for domain in results: if domain in needed: @@ -1419,18 +1419,24 @@ async def async_get_integrations( _resolve_integrations_from_root, hass, components, needed ) for domain, future in needed.items(): - int_or_exc = integrations.get(domain) - if not int_or_exc: - cache.pop(domain) - results[domain] = IntegrationNotFound(domain) - elif isinstance(int_or_exc, Exception): - cache.pop(domain) - exc = IntegrationNotFound(domain) - exc.__cause__ = int_or_exc - results[domain] = exc + if integration := integrations.get(domain): + results[domain] = cache[domain] = integration + future.set_result(integration) else: - results[domain] = cache[domain] = int_or_exc - future.set_result(None) + # We don't cache that it doesn't exist as configuration + # validation that relies on integrations being loaded + # would be unfixable. For example if a custom integration + # was temporarily removed. + # This allows restoring a missing integration to fix the + # validation error so the config validations checks do not + # block restarting. + del cache[domain] + exc = IntegrationNotFound(domain) + results[domain] = exc + # We don't use set_exception because + # we expect there will be cases where + # the a future exception is never retrieved + future.set_result(exc) return results diff --git a/tests/test_loader.py b/tests/test_loader.py index 4c3c4eb309f..8afe800144c 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -2039,3 +2039,59 @@ async def test_manifest_json_fragment_round_trip(hass: HomeAssistant) -> None: json_loads(json_dumps(integration.manifest_json_fragment)) == integration.manifest ) + + +async def test_async_get_integrations_multiple_non_existent( + hass: HomeAssistant, +) -> None: + """Test async_get_integrations with multiple non-existent integrations.""" + integrations = await loader.async_get_integrations(hass, ["does_not_exist"]) + assert isinstance(integrations["does_not_exist"], loader.IntegrationNotFound) + + async def slow_load_failure( + *args: Any, **kwargs: Any + ) -> dict[str, loader.Integration]: + await asyncio.sleep(0.1) + return {} + + with patch.object(hass, "async_add_executor_job", slow_load_failure): + task1 = hass.async_create_task( + loader.async_get_integrations(hass, ["does_not_exist", "does_not_exist2"]) + ) + # Task one should now be waiting for executor job + task2 = hass.async_create_task( + loader.async_get_integrations(hass, ["does_not_exist"]) + ) + # Task two should be waiting for the futures created in task one + task3 = hass.async_create_task( + loader.async_get_integrations(hass, ["does_not_exist2", "does_not_exist"]) + ) + # Task three should be waiting for the futures created in task one + integrations_1 = await task1 + assert isinstance(integrations_1["does_not_exist"], loader.IntegrationNotFound) + assert isinstance(integrations_1["does_not_exist2"], loader.IntegrationNotFound) + integrations_2 = await task2 + assert isinstance(integrations_2["does_not_exist"], loader.IntegrationNotFound) + integrations_3 = await task3 + assert isinstance(integrations_3["does_not_exist2"], loader.IntegrationNotFound) + assert isinstance(integrations_3["does_not_exist"], loader.IntegrationNotFound) + + # Make sure IntegrationNotFound is not cached + # so configuration errors can be fixed as to + # not prevent Home Assistant from being restarted + integration = loader.Integration( + hass, + "custom_components.does_not_exist", + None, + { + "name": "Does not exist", + "domain": "does_not_exist", + }, + ) + with patch.object( + loader, + "_resolve_integrations_from_root", + return_value={"does_not_exist": integration}, + ): + integrations = await loader.async_get_integrations(hass, ["does_not_exist"]) + assert integrations["does_not_exist"] is integration From cd4c79450b7a97c8994f16f8705290bba823e220 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Feb 2025 19:17:11 +0100 Subject: [PATCH 2757/2987] Bump python-overseerr to 0.7.1 (#139263) Co-authored-by: Shay Levy --- homeassistant/components/overseerr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overseerr/manifest.json b/homeassistant/components/overseerr/manifest.json index 6258481adcf..3c4321ebb37 100644 --- a/homeassistant/components/overseerr/manifest.json +++ b/homeassistant/components/overseerr/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["python-overseerr==0.7.0"] + "requirements": ["python-overseerr==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 592add8e73e..c318a069597 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2452,7 +2452,7 @@ python-opensky==1.0.1 python-otbr-api==2.7.0 # homeassistant.components.overseerr -python-overseerr==0.7.0 +python-overseerr==0.7.1 # homeassistant.components.picnic python-picnic-api2==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e9510d296fe..d42434585d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1988,7 +1988,7 @@ python-opensky==1.0.1 python-otbr-api==2.7.0 # homeassistant.components.overseerr -python-overseerr==0.7.0 +python-overseerr==0.7.1 # homeassistant.components.picnic python-picnic-api2==1.2.2 From 2cd496fdafda5a63fb20464970779d16d677dffd Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 25 Feb 2025 19:36:45 +0100 Subject: [PATCH 2758/2987] Add coordinator to SMHI (#139052) * Add coordinator to SMHI * Remove not needed logging * docstrings --- homeassistant/components/smhi/__init__.py | 13 ++- homeassistant/components/smhi/const.py | 7 ++ homeassistant/components/smhi/coordinator.py | 63 +++++++++++ homeassistant/components/smhi/entity.py | 17 +-- homeassistant/components/smhi/weather.py | 107 +++++++------------ tests/components/smhi/test_weather.py | 100 +++++++++-------- 6 files changed, 176 insertions(+), 131 deletions(-) create mode 100644 homeassistant/components/smhi/coordinator.py diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index 59b32948879..1869b333071 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -1,6 +1,5 @@ """Support for the Swedish weather institute weather service.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, @@ -10,10 +9,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant +from .coordinator import SMHIConfigEntry, SMHIDataUpdateCoordinator + PLATFORMS = [Platform.WEATHER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool: """Set up SMHI forecast as config entry.""" # Setting unique id where missing @@ -21,16 +22,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unique_id = f"{entry.data[CONF_LOCATION][CONF_LATITUDE]}-{entry.data[CONF_LOCATION][CONF_LONGITUDE]}" hass.config_entries.async_update_entry(entry, unique_id=unique_id) + coordinator = SMHIDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + 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: SMHIConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool: """Migrate old entry.""" if entry.version > 3: diff --git a/homeassistant/components/smhi/const.py b/homeassistant/components/smhi/const.py index 11401119227..6cbf928d5e6 100644 --- a/homeassistant/components/smhi/const.py +++ b/homeassistant/components/smhi/const.py @@ -1,5 +1,7 @@ """Constants in smhi component.""" +from datetime import timedelta +import logging from typing import Final from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN @@ -12,3 +14,8 @@ HOME_LOCATION_NAME = "Home" DEFAULT_NAME = "Weather" ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".smhi_{}" + +LOGGER = logging.getLogger(__package__) + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=31) +TIMEOUT = 10 diff --git a/homeassistant/components/smhi/coordinator.py b/homeassistant/components/smhi/coordinator.py new file mode 100644 index 00000000000..511ba8b38d9 --- /dev/null +++ b/homeassistant/components/smhi/coordinator.py @@ -0,0 +1,63 @@ +"""DataUpdateCoordinator for the SMHI integration.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + +from pysmhi import SMHIForecast, SmhiForecastException, SMHIPointForecast + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, TIMEOUT + +type SMHIConfigEntry = ConfigEntry[SMHIDataUpdateCoordinator] + + +@dataclass +class SMHIForecastData: + """Dataclass for SMHI data.""" + + daily: list[SMHIForecast] + hourly: list[SMHIForecast] + + +class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]): + """A SMHI Data Update Coordinator.""" + + config_entry: SMHIConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: SMHIConfigEntry) -> None: + """Initialize the SMHI coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self._smhi_api = SMHIPointForecast( + config_entry.data[CONF_LOCATION][CONF_LONGITUDE], + config_entry.data[CONF_LOCATION][CONF_LATITUDE], + session=aiohttp_client.async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> SMHIForecastData: + """Fetch data from SMHI.""" + try: + async with asyncio.timeout(TIMEOUT): + _forecast_daily = await self._smhi_api.async_get_daily_forecast() + _forecast_hourly = await self._smhi_api.async_get_hourly_forecast() + except SmhiForecastException as ex: + raise UpdateFailed( + "Failed to retrieve the forecast from the SMHI API" + ) from ex + + return SMHIForecastData( + daily=_forecast_daily, + hourly=_forecast_hourly, + ) diff --git a/homeassistant/components/smhi/entity.py b/homeassistant/components/smhi/entity.py index 8d650d31945..89dca3360ca 100644 --- a/homeassistant/components/smhi/entity.py +++ b/homeassistant/components/smhi/entity.py @@ -2,16 +2,16 @@ from __future__ import annotations -import aiohttp -from pysmhi import SMHIPointForecast +from abc import abstractmethod from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import SMHIDataUpdateCoordinator -class SmhiWeatherBaseEntity(Entity): +class SmhiWeatherBaseEntity(CoordinatorEntity[SMHIDataUpdateCoordinator]): """Representation of a base weather entity.""" _attr_attribution = "Swedish weather institute (SMHI)" @@ -22,11 +22,11 @@ class SmhiWeatherBaseEntity(Entity): self, latitude: str, longitude: str, - session: aiohttp.ClientSession, + coordinator: SMHIDataUpdateCoordinator, ) -> None: """Initialize the SMHI base weather entity.""" + super().__init__(coordinator) self._attr_unique_id = f"{latitude}, {longitude}" - self._smhi_api = SMHIPointForecast(longitude, latitude, session=session) self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, f"{latitude}, {longitude}")}, @@ -34,3 +34,8 @@ class SmhiWeatherBaseEntity(Entity): model="v2", configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html", ) + self.update_entity_data() + + @abstractmethod + def update_entity_data(self) -> None: + """Refresh the entity data.""" diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index b9cac9bdf2e..d2e31990012 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -2,14 +2,11 @@ from __future__ import annotations -import asyncio from collections.abc import Mapping -from datetime import datetime, timedelta -import logging +from datetime import timedelta from typing import Any, Final -import aiohttp -from pysmhi import SMHIForecast, SmhiForecastException +from pysmhi import SMHIForecast from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, @@ -39,10 +36,9 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, Forecast, - WeatherEntity, + SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, @@ -53,17 +49,14 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, sun +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import sun from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.event import async_call_later -from homeassistant.util import Throttle from .const import ATTR_SMHI_THUNDER_PROBABILITY, ENTITY_ID_SENSOR_FORMAT +from .coordinator import SMHIConfigEntry from .entity import SmhiWeatherBaseEntity -_LOGGER = logging.getLogger(__name__) - # Used to map condition from API results CONDITION_CLASSES: Final[dict[str, list[int]]] = { ATTR_CONDITION_CLOUDY: [5, 6], @@ -96,25 +89,25 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=31) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SMHIConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from map location.""" location = config_entry.data - session = aiohttp_client.async_get_clientsession(hass) + coordinator = config_entry.runtime_data entity = SmhiWeather( location[CONF_LOCATION][CONF_LATITUDE], location[CONF_LOCATION][CONF_LONGITUDE], - session=session, + coordinator=coordinator, ) entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(config_entry.title) - async_add_entities([entity], True) + async_add_entities([entity]) -class SmhiWeather(SmhiWeatherBaseEntity, WeatherEntity): +class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): """Representation of a weather entity.""" _attr_native_temperature_unit = UnitOfTemperature.CELSIUS @@ -126,61 +119,37 @@ class SmhiWeather(SmhiWeatherBaseEntity, WeatherEntity): WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY ) - def __init__( - self, - latitude: str, - longitude: str, - session: aiohttp.ClientSession, - ) -> None: - """Initialize the SMHI weather entity.""" - super().__init__(latitude, longitude, session) - self._forecast_daily: list[SMHIForecast] | None = None - self._forecast_hourly: list[SMHIForecast] | None = None - self._fail_count = 0 + def update_entity_data(self) -> None: + """Refresh the entity data.""" + if daily_data := self.coordinator.data.daily: + self._attr_native_temperature = daily_data[0]["temperature"] + self._attr_humidity = daily_data[0]["humidity"] + self._attr_native_wind_speed = daily_data[0]["wind_speed"] + self._attr_wind_bearing = daily_data[0]["wind_direction"] + self._attr_native_visibility = daily_data[0]["visibility"] + self._attr_native_pressure = daily_data[0]["pressure"] + self._attr_native_wind_gust_speed = daily_data[0]["wind_gust"] + self._attr_cloud_coverage = daily_data[0]["total_cloud"] + self._attr_condition = CONDITION_MAP.get(daily_data[0]["symbol"]) + if self._attr_condition == ATTR_CONDITION_SUNNY and not sun.is_up( + self.coordinator.hass + ): + self._attr_condition = ATTR_CONDITION_CLEAR_NIGHT @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return additional attributes.""" - if self._forecast_daily: + if daily_data := self.coordinator.data.daily: return { - ATTR_SMHI_THUNDER_PROBABILITY: self._forecast_daily[0]["thunder"], + ATTR_SMHI_THUNDER_PROBABILITY: daily_data[0]["thunder"], } return None - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self) -> None: - """Refresh the forecast data from SMHI weather API.""" - try: - async with asyncio.timeout(TIMEOUT): - self._forecast_daily = await self._smhi_api.async_get_daily_forecast() - self._forecast_hourly = await self._smhi_api.async_get_hourly_forecast() - self._fail_count = 0 - except (TimeoutError, SmhiForecastException): - _LOGGER.error("Failed to connect to SMHI API, retry in 5 minutes") - self._fail_count += 1 - if self._fail_count < 3: - async_call_later(self.hass, RETRY_TIMEOUT, self.retry_update) - return - - if self._forecast_daily: - self._attr_native_temperature = self._forecast_daily[0]["temperature"] - self._attr_humidity = self._forecast_daily[0]["humidity"] - self._attr_native_wind_speed = self._forecast_daily[0]["wind_speed"] - self._attr_wind_bearing = self._forecast_daily[0]["wind_direction"] - self._attr_native_visibility = self._forecast_daily[0]["visibility"] - self._attr_native_pressure = self._forecast_daily[0]["pressure"] - self._attr_native_wind_gust_speed = self._forecast_daily[0]["wind_gust"] - self._attr_cloud_coverage = self._forecast_daily[0]["total_cloud"] - self._attr_condition = CONDITION_MAP.get(self._forecast_daily[0]["symbol"]) - if self._attr_condition == ATTR_CONDITION_SUNNY and not sun.is_up( - self.hass - ): - self._attr_condition = ATTR_CONDITION_CLEAR_NIGHT - await self.async_update_listeners(("daily", "hourly")) - - async def retry_update(self, _: datetime) -> None: - """Retry refresh weather forecast.""" - await self.async_update(no_throttle=True) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.update_entity_data() + super()._handle_coordinator_update() def _get_forecast_data( self, forecast_data: list[SMHIForecast] | None @@ -219,10 +188,10 @@ class SmhiWeather(SmhiWeatherBaseEntity, WeatherEntity): return data - async def async_forecast_daily(self) -> list[Forecast] | None: + def _async_forecast_daily(self) -> list[Forecast] | None: """Service to retrieve the daily forecast.""" - return self._get_forecast_data(self._forecast_daily) + return self._get_forecast_data(self.coordinator.data.daily) - async def async_forecast_hourly(self) -> list[Forecast] | None: + def _async_forecast_hourly(self) -> list[Forecast] | None: """Service to retrieve the hourly forecast.""" - return self._get_forecast_data(self._forecast_hourly) + return self._get_forecast_data(self.coordinator.data.hourly) diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index f47566f2d5c..a09a9689d52 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -4,29 +4,27 @@ from datetime import datetime, timedelta from unittest.mock import patch from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory from pysmhi import SMHIForecast, SmhiForecastException from pysmhi.const import API_POINT_FORECAST import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smhi.const import ATTR_SMHI_THUNDER_PROBABILITY -from homeassistant.components.smhi.weather import CONDITION_CLASSES, RETRY_TIMEOUT +from homeassistant.components.smhi.weather import CONDITION_CLASSES from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_FORECAST_CONDITION, - ATTR_WEATHER_CLOUD_COVERAGE, - ATTR_WEATHER_HUMIDITY, - ATTR_WEATHER_PRESSURE, - ATTR_WEATHER_TEMPERATURE, - ATTR_WEATHER_VISIBILITY, - ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, - ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN as WEATHER_DOMAIN, SERVICE_GET_FORECASTS, ) -from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN, UnitOfSpeed +from homeassistant.const import ( + ATTR_ATTRIBUTION, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfSpeed, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -104,33 +102,38 @@ async def test_clear_night( assert response == snapshot(name="clear-night_forecast") -async def test_properties_no_data(hass: HomeAssistant) -> None: +async def test_properties_no_data( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + api_response: str, + freezer: FrozenDateTimeFactory, +) -> None: """Test properties when no API data available.""" + uri = API_POINT_FORECAST.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) + aioclient_mock.get(uri, text=api_response) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + with patch( - "homeassistant.components.smhi.entity.SMHIPointForecast.async_get_daily_forecast", + "homeassistant.components.smhi.coordinator.SMHIPointForecast.async_get_daily_forecast", side_effect=SmhiForecastException("boom"), ): - await hass.config_entries.async_setup(entry.entry_id) + freezer.tick(timedelta(minutes=35)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state assert state.name == "test" - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE assert state.attributes[ATTR_ATTRIBUTION] == "Swedish weather institute (SMHI)" - assert ATTR_WEATHER_HUMIDITY not in state.attributes - assert ATTR_WEATHER_PRESSURE not in state.attributes - assert ATTR_WEATHER_TEMPERATURE not in state.attributes - assert ATTR_WEATHER_VISIBILITY not in state.attributes - assert ATTR_WEATHER_WIND_SPEED not in state.attributes - assert ATTR_WEATHER_WIND_BEARING not in state.attributes - assert ATTR_WEATHER_CLOUD_COVERAGE not in state.attributes - assert ATTR_SMHI_THUNDER_PROBABILITY not in state.attributes - assert ATTR_WEATHER_WIND_GUST_SPEED not in state.attributes async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: @@ -215,11 +218,11 @@ async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.smhi.entity.SMHIPointForecast.async_get_daily_forecast", + "homeassistant.components.smhi.coordinator.SMHIPointForecast.async_get_daily_forecast", return_value=testdata, ), patch( - "homeassistant.components.smhi.entity.SMHIPointForecast.async_get_hourly_forecast", + "homeassistant.components.smhi.coordinator.SMHIPointForecast.async_get_hourly_forecast", return_value=None, ), ): @@ -246,55 +249,48 @@ async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: @pytest.mark.parametrize("error", [SmhiForecastException(), TimeoutError()]) async def test_refresh_weather_forecast_retry( - hass: HomeAssistant, error: Exception + hass: HomeAssistant, + error: Exception, + aioclient_mock: AiohttpClientMocker, + api_response: str, + freezer: FrozenDateTimeFactory, ) -> None: """Test the refresh weather forecast function.""" + uri = API_POINT_FORECAST.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) + aioclient_mock.get(uri, text=api_response) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) - now = dt_util.utcnow() + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() with patch( - "homeassistant.components.smhi.entity.SMHIPointForecast.async_get_daily_forecast", + "homeassistant.components.smhi.coordinator.SMHIPointForecast.async_get_daily_forecast", side_effect=error, ) as mock_get_forecast: - await hass.config_entries.async_setup(entry.entry_id) + freezer.tick(timedelta(minutes=35)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state assert state.name == "test" - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE assert mock_get_forecast.call_count == 1 - future = now + timedelta(seconds=RETRY_TIMEOUT + 1) - async_fire_time_changed(hass, future) + freezer.tick(timedelta(minutes=35)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE assert mock_get_forecast.call_count == 2 - future = future + timedelta(seconds=RETRY_TIMEOUT + 1) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_UNKNOWN - assert mock_get_forecast.call_count == 3 - - future = future + timedelta(seconds=RETRY_TIMEOUT + 1) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_UNKNOWN - # after three failed retries we stop retrying and go back to normal interval - assert mock_get_forecast.call_count == 3 - def test_condition_class() -> None: """Test condition class.""" From 75533463f794b935a28a4a08cb6d9b4dca798677 Mon Sep 17 00:00:00 2001 From: Dan Bishop Date: Tue, 25 Feb 2025 18:41:47 +0000 Subject: [PATCH 2759/2987] Make Radarr unit translation lowercase (#139261) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/radarr/strings.json | 4 ++-- tests/components/radarr/test_sensor.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/radarr/strings.json b/homeassistant/components/radarr/strings.json index cb624aff057..268d7955c1b 100644 --- a/homeassistant/components/radarr/strings.json +++ b/homeassistant/components/radarr/strings.json @@ -44,11 +44,11 @@ "sensor": { "movies": { "name": "Movies", - "unit_of_measurement": "[%key:component::radarr::entity::sensor::movies::name%]" + "unit_of_measurement": "movies" }, "queue": { "name": "Queue", - "unit_of_measurement": "[%key:component::radarr::entity::sensor::movies::name%]" + "unit_of_measurement": "[%key:component::radarr::entity::sensor::movies::unit_of_measurement%]" }, "start_time": { "name": "Start time" diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index 9139e13a957..f6b14bffa80 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -68,13 +68,13 @@ async def test_sensors( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "GB" state = hass.states.get("sensor.mock_title_movies") assert state.state == "1" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Movies" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "movies" state = hass.states.get("sensor.mock_title_start_time") assert state.state == "2020-09-01T23:50:20+00:00" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP state = hass.states.get("sensor.mock_title_queue") assert state.state == "2" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Movies" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "movies" assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL From ef465521460f92286a725969796336f79673f6ac Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 25 Feb 2025 20:03:14 +0100 Subject: [PATCH 2760/2987] Add common state translation string for charging and discharging (#139074) add common state translation string for charging and discharging --- homeassistant/components/blue_current/strings.json | 2 +- homeassistant/components/bmw_connected_drive/strings.json | 2 +- homeassistant/components/enphase_envoy/strings.json | 4 ++-- homeassistant/components/lektrico/strings.json | 2 +- homeassistant/components/lg_thinq/strings.json | 4 ++-- homeassistant/components/matter/strings.json | 2 +- homeassistant/components/ohme/strings.json | 2 +- homeassistant/components/peblar/strings.json | 2 +- homeassistant/components/reolink/strings.json | 4 ++-- homeassistant/components/roborock/strings.json | 4 ++-- homeassistant/components/tesla_fleet/strings.json | 2 +- homeassistant/components/tesla_wall_connector/strings.json | 2 +- homeassistant/components/teslemetry/strings.json | 2 +- homeassistant/components/tessie/strings.json | 4 ++-- homeassistant/strings.json | 4 +++- 15 files changed, 22 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 0154c794c33..2e48d768a74 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -28,7 +28,7 @@ "name": "Activity", "state": { "available": "Available", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "unavailable": "Unavailable", "error": "Error", "offline": "Offline" diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index edb0d5cfb12..4b16b719d8d 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -138,7 +138,7 @@ "name": "Charging status", "state": { "default": "Default", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "error": "Error", "complete": "Complete", "fully_charged": "Fully charged", diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 0c1facca1ea..b498c59e0d3 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -360,9 +360,9 @@ "acb_battery_state": { "name": "Battery state", "state": { - "discharging": "Discharging", + "discharging": "[%key:common::state::discharging%]", "idle": "[%key:common::state::idle%]", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "full": "Full" } }, diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json index e24700c9b09..3b4417c346a 100644 --- a/homeassistant/components/lektrico/strings.json +++ b/homeassistant/components/lektrico/strings.json @@ -86,7 +86,7 @@ "name": "State", "state": { "available": "Available", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "connected": "Connected", "error": "Error", "locked": "Locked", diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index a930860aa35..e1d3779f44b 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -411,7 +411,7 @@ "cancel": "Cancel", "carbonation": "Carbonation", "change_condition": "Settings Change", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "charging_complete": "Charging completed", "checking_turbidity": "Detecting soil level", "cleaning": "Cleaning", @@ -498,7 +498,7 @@ "cancel": "[%key:component::lg_thinq::entity::sensor::current_state::state::cancel%]", "carbonation": "[%key:component::lg_thinq::entity::sensor::current_state::state::carbonation%]", "change_condition": "[%key:component::lg_thinq::entity::sensor::current_state::state::change_condition%]", - "charging": "[%key:component::lg_thinq::entity::sensor::current_state::state::charging%]", + "charging": "[%key:common::state::charging%]", "charging_complete": "[%key:component::lg_thinq::entity::sensor::current_state::state::charging_complete%]", "checking_turbidity": "[%key:component::lg_thinq::entity::sensor::current_state::state::checking_turbidity%]", "cleaning": "[%key:component::lg_thinq::entity::sensor::current_state::state::cleaning%]", diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index f299b5cb628..1404d0a9076 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -263,7 +263,7 @@ "paused": "[%key:common::state::paused%]", "error": "Error", "seeking_charger": "Seeking charger", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "docked": "Docked" } }, diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 387b28565b2..4c845daa8f0 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -74,7 +74,7 @@ "state": { "unplugged": "Unplugged", "plugged_in": "Plugged in", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "paused": "[%key:common::state::paused%]", "pending_approval": "Pending approval", "finished": "Finished charging" diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index 4a1500e54c5..416f1a2c062 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -107,7 +107,7 @@ "cp_state": { "name": "State", "state": { - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "error": "Error", "fault": "Fault", "invalid": "Invalid", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 3da463beddf..335ed92d32e 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -741,8 +741,8 @@ "battery_state": { "name": "Battery state", "state": { - "discharging": "Discharging", - "charging": "Charging", + "discharging": "[%key:common::state::discharging%]", + "charging": "[%key:common::state::charging%]", "chargecomplete": "Charge complete" } }, diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 8968ac020a2..eb058ea74e3 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -128,7 +128,7 @@ "updating": "[%key:component::roborock::entity::sensor::status::state::updating%]", "washing": "Washing", "ready": "Ready", - "charging": "[%key:component::roborock::entity::sensor::status::state::charging%]", + "charging": "[%key:common::state::charging%]", "mop_washing": "Washing mop", "self_clean_cleaning": "Self clean cleaning", "self_clean_deep_cleaning": "Self clean deep cleaning", @@ -199,7 +199,7 @@ "cleaning": "Cleaning", "returning_home": "Returning home", "manual_mode": "Manual mode", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "charging_problem": "Charging problem", "paused": "[%key:common::state::paused%]", "spot_cleaning": "Spot cleaning", diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 540ea2b7135..331885893fe 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -329,7 +329,7 @@ "name": "Charging", "state": { "starting": "Starting", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "disconnected": "Disconnected", "stopped": "Stopped", "complete": "Complete", diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index 1a03207a012..b356a9f3ebc 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -42,7 +42,7 @@ "charging_finished": "Charging finished", "waiting_car": "Waiting for car", "charging_reduced": "Charging (reduced)", - "charging": "Charging" + "charging": "[%key:common::state::charging%]" } }, "status_code": { diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index b6b3d17e37c..9dc17fd2ef7 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -415,7 +415,7 @@ "name": "Charging", "state": { "starting": "Starting", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "disconnected": "Disconnected", "stopped": "Stopped", "complete": "Complete", diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index ccd17fbf6c8..4f0f5f67ebd 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -75,7 +75,7 @@ "name": "Charging", "state": { "starting": "Starting", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "disconnected": "Disconnected", "stopped": "Stopped", "complete": "Complete", @@ -212,7 +212,7 @@ "name": "State", "state": { "booting": "Booting", - "charging": "[%key:component::tessie::entity::sensor::charge_state_charging_state::state::charging%]", + "charging": "[%key:common::state::charging%]", "disconnected": "[%key:common::state::disconnected%]", "connected": "[%key:common::state::connected%]", "scheduled": "Scheduled", diff --git a/homeassistant/strings.json b/homeassistant/strings.json index fca55353aa0..f423c3bf59c 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -71,7 +71,9 @@ "standby": "Standby", "paused": "Paused", "home": "Home", - "not_home": "Away" + "not_home": "Away", + "charging": "Charging", + "discharging": "Discharging" }, "config_flow": { "title": { From 51c09c2aa4dae532b2f358cf238f6819f3947167 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Feb 2025 20:10:29 +0100 Subject: [PATCH 2761/2987] Add test fixture ignore_translations_for_mock_domains (#139235) * Add test fixture ignore_translations_for_mock_domains * Fix fixture * Avoid unnecessary attempt to get integration * Really fix fixture * Add forgotten parameter * Address review comment --- .../application_credentials/test_init.py | 25 +---- .../components/config/test_config_entries.py | 25 +---- tests/components/conftest.py | 93 ++++++++++++++++--- .../test_config_flow_failures.py | 68 +++++++------- .../test_silabs_multiprotocol_addon.py | 60 +++--------- tests/components/onkyo/test_config_flow.py | 2 +- tests/components/repairs/test_init.py | 30 +----- .../components/repairs/test_websocket_api.py | 68 +++----------- tests/components/sensor/test_recorder.py | 5 +- tests/components/synology_dsm/test_repairs.py | 2 +- .../components/websocket_api/test_commands.py | 9 +- tests/components/workday/test_repairs.py | 2 +- tests/components/zwave_js/test_repairs.py | 2 +- 13 files changed, 164 insertions(+), 227 deletions(-) diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index b72d9653c2d..9896e4c9fc0 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -423,10 +423,7 @@ async def test_import_named_credential( ] -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_credentials"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: """Test config flow base case with no credentials registered.""" result = await hass.config_entries.flow.async_init( @@ -436,10 +433,7 @@ async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: assert result.get("reason") == "missing_credentials" -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_credentials"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_config_flow_other_domain( hass: HomeAssistant, ws_client: ClientFixture, @@ -567,10 +561,7 @@ async def test_config_flow_multiple_entries( ) -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_credentials"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_config_flow_create_delete_credential( hass: HomeAssistant, ws_client: ClientFixture, @@ -616,10 +607,7 @@ async def test_config_flow_with_config_credential( assert result["data"].get("auth_implementation") == TEST_DOMAIN -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_configuration"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) @pytest.mark.parametrize("mock_application_credentials_integration", [None]) async def test_import_without_setup(hass: HomeAssistant, config_credential) -> None: """Test import of credentials without setting up the integration.""" @@ -635,10 +623,7 @@ async def test_import_without_setup(hass: HomeAssistant, config_credential) -> N assert result.get("reason") == "missing_configuration" -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_configuration"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) @pytest.mark.parametrize("mock_application_credentials_integration", [None]) async def test_websocket_without_platform( hass: HomeAssistant, ws_client: ClientFixture diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index a31836b598c..739b79e22bd 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -400,10 +400,7 @@ async def test_available_flows( ############################ -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.error.Should be unique."], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_initialize_flow(hass: HomeAssistant, client: TestClient) -> None: """Test we can initialize a flow.""" mock_platform(hass, "test.config_flow", None) @@ -513,10 +510,7 @@ async def test_initialize_flow_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.abort.bla"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_abort(hass: HomeAssistant, client: TestClient) -> None: """Test a flow that aborts.""" mock_platform(hass, "test.config_flow", None) @@ -826,10 +820,7 @@ async def test_get_progress_index_unauth( assert response["error"]["code"] == "unauthorized" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.error.Should be unique."], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_get_progress_flow(hass: HomeAssistant, client: TestClient) -> None: """Test we can query the API for same result as we get from init a flow.""" mock_platform(hass, "test.config_flow", None) @@ -863,10 +854,7 @@ async def test_get_progress_flow(hass: HomeAssistant, client: TestClient) -> Non assert data == data2 -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.error.Should be unique."], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_get_progress_flow_unauth( hass: HomeAssistant, client: TestClient, hass_admin_user: MockUser ) -> None: @@ -2870,10 +2858,7 @@ async def test_flow_with_multiple_schema_errors_base( } -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.abort.reconfigure_successful"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.usefixtures("freezer") async def test_supports_reconfigure( hass: HomeAssistant, diff --git a/tests/components/conftest.py b/tests/components/conftest.py index cf10e2b8dfd..6d6d0d4641f 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -22,6 +22,7 @@ from aiohasupervisor.models import ( import pytest import voluptuous as vol +from homeassistant import components, loader from homeassistant.components import repairs from homeassistant.config_entries import ( DISCOVERY_SOURCES, @@ -605,6 +606,7 @@ def _validate_translation_placeholders( async def _validate_translation( hass: HomeAssistant, translation_errors: dict[str, str], + ignore_translations_for_mock_domains: set[str], category: str, component: str, key: str, @@ -614,7 +616,25 @@ async def _validate_translation( ) -> None: """Raise if translation doesn't exist.""" full_key = f"component.{component}.{category}.{key}" + if component in ignore_translations_for_mock_domains: + try: + integration = await loader.async_get_integration(hass, component) + except loader.IntegrationNotFound: + return + component_paths = components.__path__ + if not any( + Path(f"{component_path}/{component}") == integration.file_path + for component_path in component_paths + ): + return + # If the integration exists, translation errors should be ignored via the + # ignore_missing_translations fixture instead of the + # ignore_translations_for_mock_domains fixture. + translation_errors[full_key] = f"The integration '{component}' exists" + return + translations = await async_get_translations(hass, "en", category, [component]) + if (translation := translations.get(full_key)) is not None: _validate_translation_placeholders( full_key, translation, description_placeholders, translation_errors @@ -625,6 +645,18 @@ async def _validate_translation( return if translation_errors.get(full_key) in {"used", "unused"}: + # If the does not integration exist, translation errors should be ignored + # via the ignore_translations_for_mock_domains fixture instead of the + # ignore_missing_translations fixture. + try: + await loader.async_get_integration(hass, component) + except loader.IntegrationNotFound: + translation_errors[full_key] = ( + f"Translation not found for {component}: `{category}.{key}`. " + f"The integration '{component}' does not exist." + ) + return + # This translation key is in the ignore list, mark it as used translation_errors[full_key] = "used" return @@ -636,11 +668,22 @@ async def _validate_translation( @pytest.fixture -def ignore_translations() -> str | list[str]: - """Ignore specific translations. +def ignore_missing_translations() -> str | list[str]: + """Ignore specific missing translations. - Override or parametrize this fixture with a fixture that returns, - a list of translation that should be ignored. + Override or parametrize this fixture with a fixture that returns + a list of missing translation that should be ignored. + """ + return [] + + +@pytest.fixture +def ignore_translations_for_mock_domains() -> str | list[str]: + """Don't validate translations for specific domains. + + Override or parametrize this fixture with a fixture that returns + a list of domains for which translations should not be validated. + This should only be used when testing mocked integrations. """ return [] @@ -673,6 +716,7 @@ async def _check_step_or_section_translations( translation_prefix: str, description_placeholders: dict[str, str], data_schema: vol.Schema | None, + ignore_translations_for_mock_domains: set[str], ) -> None: # neither title nor description are required # - title defaults to integration name @@ -681,6 +725,7 @@ async def _check_step_or_section_translations( await _validate_translation( hass, translation_errors, + ignore_translations_for_mock_domains, category, integration, f"{translation_prefix}.{header}", @@ -702,6 +747,7 @@ async def _check_step_or_section_translations( f"{translation_prefix}.sections.{data_key}", description_placeholders, data_value.schema, + ignore_translations_for_mock_domains, ) continue iqs_config_flow = _get_integration_quality_scale_rule( @@ -712,6 +758,7 @@ async def _check_step_or_section_translations( await _validate_translation( hass, translation_errors, + ignore_translations_for_mock_domains, category, integration, f"{translation_prefix}.{header}.{data_key}", @@ -725,6 +772,7 @@ async def _check_config_flow_result_translations( flow: FlowHandler, result: FlowResult[FlowContext, str], translation_errors: dict[str, str], + ignore_translations_for_mock_domains: set[str], ) -> None: if result["type"] is FlowResultType.CREATE_ENTRY: # No need to check translations for a completed flow @@ -760,6 +808,7 @@ async def _check_config_flow_result_translations( f"{key_prefix}step.{step_id}", result["description_placeholders"], result["data_schema"], + ignore_translations_for_mock_domains, ) if errors := result.get("errors"): @@ -767,6 +816,7 @@ async def _check_config_flow_result_translations( await _validate_translation( flow.hass, translation_errors, + ignore_translations_for_mock_domains, category, integration, f"{key_prefix}error.{error}", @@ -782,6 +832,7 @@ async def _check_config_flow_result_translations( await _validate_translation( flow.hass, translation_errors, + ignore_translations_for_mock_domains, category, integration, f"{key_prefix}abort.{result['reason']}", @@ -793,6 +844,7 @@ async def _check_create_issue_translations( issue_registry: ir.IssueRegistry, issue: ir.IssueEntry, translation_errors: dict[str, str], + ignore_translations_for_mock_domains: set[str], ) -> None: if issue.translation_key is None: # `translation_key` is only None on dismissed issues @@ -800,6 +852,7 @@ async def _check_create_issue_translations( await _validate_translation( issue_registry.hass, translation_errors, + ignore_translations_for_mock_domains, "issues", issue.domain, f"{issue.translation_key}.title", @@ -810,6 +863,7 @@ async def _check_create_issue_translations( await _validate_translation( issue_registry.hass, translation_errors, + ignore_translations_for_mock_domains, "issues", issue.domain, f"{issue.translation_key}.description", @@ -831,6 +885,7 @@ async def _check_exception_translation( exception: HomeAssistantError, translation_errors: dict[str, str], request: pytest.FixtureRequest, + ignore_translations_for_mock_domains: set[str], ) -> None: if exception.translation_key is None: if ( @@ -844,6 +899,7 @@ async def _check_exception_translation( await _validate_translation( hass, translation_errors, + ignore_translations_for_mock_domains, "exceptions", exception.translation_domain, f"{exception.translation_key}.message", @@ -853,7 +909,9 @@ async def _check_exception_translation( @pytest.fixture(autouse=True) async def check_translations( - ignore_translations: str | list[str], request: pytest.FixtureRequest + ignore_missing_translations: str | list[str], + ignore_translations_for_mock_domains: str | list[str], + request: pytest.FixtureRequest, ) -> AsyncGenerator[None]: """Check that translation requirements are met. @@ -862,11 +920,16 @@ async def check_translations( - issue registry entries - action (service) exceptions """ - if not isinstance(ignore_translations, list): - ignore_translations = [ignore_translations] + if not isinstance(ignore_missing_translations, list): + ignore_missing_translations = [ignore_missing_translations] + + if not isinstance(ignore_translations_for_mock_domains, list): + ignored_domains = {ignore_translations_for_mock_domains} + else: + ignored_domains = set(ignore_translations_for_mock_domains) # Set all ignored translation keys to "unused" - translation_errors = {k: "unused" for k in ignore_translations} + translation_errors = {k: "unused" for k in ignore_missing_translations} translation_coros = set() @@ -881,7 +944,7 @@ async def check_translations( ) -> FlowResult: result = await _original_flow_manager_async_handle_step(self, flow, *args) await _check_config_flow_result_translations( - self, flow, result, translation_errors + self, flow, result, translation_errors, ignored_domains ) return result @@ -892,7 +955,9 @@ async def check_translations( self, domain, issue_id, *args, **kwargs ) translation_coros.add( - _check_create_issue_translations(self, result, translation_errors) + _check_create_issue_translations( + self, result, translation_errors, ignored_domains + ) ) return result @@ -920,7 +985,11 @@ async def check_translations( except HomeAssistantError as err: translation_coros.add( _check_exception_translation( - self._hass, err, translation_errors, request + self._hass, + err, + translation_errors, + request, + ignored_domains, ) ) raise @@ -950,7 +1019,7 @@ async def check_translations( # Some ignored translations were not used pytest.fail( f"Unused ignore translations: {', '.join(unused_ignore)}. " - "Please remove them from the ignore_translations fixture." + "Please remove them from the ignore_missing_translations fixture." ) for description in translation_errors.values(): if description != "used": diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 8c2ee4b90ba..fb38704ae61 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -35,8 +35,8 @@ async def fixture_mock_supervisor_client(supervisor_client: AsyncMock): @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.unsupported_firmware"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) @pytest.mark.parametrize( "next_step", @@ -69,8 +69,8 @@ async def test_config_flow_cannot_probe_firmware( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.not_hassio"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_not_hassio_wrong_firmware( hass: HomeAssistant, @@ -98,8 +98,8 @@ async def test_config_flow_zigbee_not_hassio_wrong_firmware( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_already_running"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_addon_already_running( hass: HomeAssistant, @@ -136,8 +136,8 @@ async def test_config_flow_zigbee_flasher_addon_already_running( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_info_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_addon_info_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon cannot be installed.""" @@ -173,8 +173,8 @@ async def test_config_flow_zigbee_flasher_addon_info_fails(hass: HomeAssistant) @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_install_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_addon_install_fails( hass: HomeAssistant, @@ -207,8 +207,8 @@ async def test_config_flow_zigbee_flasher_addon_install_fails( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_set_config_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_addon_set_config_fails( hass: HomeAssistant, @@ -245,8 +245,8 @@ async def test_config_flow_zigbee_flasher_addon_set_config_fails( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_start_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_run_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon fails to run.""" @@ -310,8 +310,8 @@ async def test_config_flow_zigbee_flasher_uninstall_fails(hass: HomeAssistant) - @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.unsupported_firmware"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_confirmation_fails(hass: HomeAssistant) -> None: """Test the config flow failing due to Zigbee firmware not being detected.""" @@ -346,8 +346,8 @@ async def test_config_flow_zigbee_confirmation_fails(hass: HomeAssistant) -> Non @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.not_hassio_thread"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None: """Test when the stick is used with a non-hassio setup and Thread is selected.""" @@ -373,8 +373,8 @@ async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_info_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon cannot be installed.""" @@ -401,8 +401,8 @@ async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.otbr_addon_already_running"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_addon_already_running(hass: HomeAssistant) -> None: """Test failure case when the Thread addon is already running.""" @@ -440,8 +440,8 @@ async def test_config_flow_thread_addon_already_running(hass: HomeAssistant) -> @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_install_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon cannot be installed.""" @@ -471,8 +471,8 @@ async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> No @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_set_config_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon cannot be configured.""" @@ -502,8 +502,8 @@ async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_start_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_flasher_run_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon fails to run.""" @@ -567,8 +567,8 @@ async def test_config_flow_thread_flasher_uninstall_fails(hass: HomeAssistant) - @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.unsupported_firmware"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> None: """Test the config flow failing due to OpenThread firmware not being detected.""" @@ -609,8 +609,8 @@ async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> Non @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.options.abort.zha_still_using_stick"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_options_flow_zigbee_to_thread_zha_configured( hass: HomeAssistant, @@ -657,8 +657,8 @@ async def test_options_flow_zigbee_to_thread_zha_configured( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.options.abort.otbr_still_using_stick"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_options_flow_thread_to_zigbee_otbr_configured( hass: HomeAssistant, diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index 22e3e338986..fbba3d42bbe 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -450,10 +450,7 @@ async def test_option_flow_install_multi_pan_addon_zha_other_radio( } -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.not_hassio"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_non_hassio( hass: HomeAssistant, ) -> None: @@ -766,10 +763,7 @@ async def test_option_flow_addon_installed_same_device_do_not_uninstall_multi_pa assert result["type"] is FlowResultType.CREATE_ENTRY -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_already_running"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_flasher_already_running_failure( hass: HomeAssistant, addon_info, @@ -881,10 +875,7 @@ async def test_option_flow_addon_installed_same_device_flasher_already_installed assert result["type"] is FlowResultType.CREATE_ENTRY -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_install_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_flasher_install_failure( hass: HomeAssistant, addon_info, @@ -951,10 +942,7 @@ async def test_option_flow_flasher_install_failure( assert result["reason"] == "addon_install_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_start_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_flasher_addon_flash_failure( hass: HomeAssistant, addon_info, @@ -1017,10 +1005,7 @@ async def test_option_flow_flasher_addon_flash_failure( assert result["description_placeholders"]["addon_name"] == "Silicon Labs Flasher" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.zha_migration_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @patch( "homeassistant.components.zha.radio_manager.ZhaMultiPANMigrationHelper.async_initiate_migration", side_effect=Exception("Boom!"), @@ -1082,10 +1067,7 @@ async def test_option_flow_uninstall_migration_initiate_failure( mock_initiate_migration.assert_called_once() -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.zha_migration_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @patch( "homeassistant.components.zha.radio_manager.ZhaMultiPANMigrationHelper.async_finish_migration", side_effect=Exception("Boom!"), @@ -1187,10 +1169,7 @@ async def test_option_flow_do_not_install_multi_pan_addon( assert result["type"] is FlowResultType.CREATE_ENTRY -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_install_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_install_multi_pan_addon_install_fails( hass: HomeAssistant, addon_store_info, @@ -1234,10 +1213,7 @@ async def test_option_flow_install_multi_pan_addon_install_fails( assert result["reason"] == "addon_install_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_start_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_install_multi_pan_addon_start_fails( hass: HomeAssistant, addon_store_info, @@ -1299,10 +1275,7 @@ async def test_option_flow_install_multi_pan_addon_start_fails( assert result["reason"] == "addon_start_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_set_config_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_install_multi_pan_addon_set_options_fails( hass: HomeAssistant, addon_store_info, @@ -1346,10 +1319,7 @@ async def test_option_flow_install_multi_pan_addon_set_options_fails( assert result["reason"] == "addon_set_config_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_info_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_addon_info_fails( hass: HomeAssistant, addon_store_info, @@ -1373,10 +1343,7 @@ async def test_option_flow_addon_info_fails( assert result["reason"] == "addon_info_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.zha_migration_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @patch( "homeassistant.components.zha.radio_manager.ZhaMultiPANMigrationHelper.async_initiate_migration", side_effect=Exception("Boom!"), @@ -1432,10 +1399,7 @@ async def test_option_flow_install_multi_pan_addon_zha_migration_fails_step_1( set_addon_options.assert_not_called() -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.zha_migration_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @patch( "homeassistant.components.zha.radio_manager.ZhaMultiPANMigrationHelper.async_finish_migration", side_effect=Exception("Boom!"), diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index 000e74d5308..28186503ead 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -620,7 +620,7 @@ async def test_import_success( @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", [ [ # The schema is dynamically created from input sources and listening modes "component.onkyo.options.step.names.sections.input_sources.data.TV", diff --git a/tests/components/repairs/test_init.py b/tests/components/repairs/test_init.py index e78563503f1..9c4a0dfbd2a 100644 --- a/tests/components/repairs/test_init.py +++ b/tests/components/repairs/test_init.py @@ -21,16 +21,7 @@ from tests.common import mock_platform from tests.typing import WebSocketGenerator -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.test.issues.even_worse.title", - "component.test.issues.even_worse.description", - "component.test.issues.abc_123.title", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_create_update_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -170,14 +161,7 @@ async def test_create_issue_invalid_version( assert msg["result"] == {"issues": []} -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.test.issues.abc_123.title", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_ignore_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -347,10 +331,7 @@ async def test_ignore_issue( } -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_delete_issue( hass: HomeAssistant, @@ -505,10 +486,7 @@ async def test_non_compliant_platform( assert list(hass.data[DOMAIN]["platforms"].keys()) == ["fake_integration"] -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) @pytest.mark.freeze_time("2022-07-21 08:22:00") async def test_sync_methods( hass: HomeAssistant, diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index 399292fb83f..bbaf70e0a9b 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -151,10 +151,7 @@ async def mock_repairs_integration(hass: HomeAssistant) -> None: ) -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_dismiss_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -238,10 +235,7 @@ async def test_dismiss_issue( } -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_fix_non_existing_issue( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -289,19 +283,19 @@ async def test_fix_non_existing_issue( @pytest.mark.parametrize( - ("domain", "step", "description_placeholders", "ignore_translations"), + ( + "domain", + "step", + "description_placeholders", + "ignore_translations_for_mock_domains", + ), [ - ( - "fake_integration", - "custom_step", - None, - ["component.fake_integration.issues.abc_123.title"], - ), + ("fake_integration", "custom_step", None, ["fake_integration"]), ( "fake_integration_default_handler", "confirm", {"abc": "123"}, - ["component.fake_integration_default_handler.issues.abc_123.title"], + ["fake_integration_default_handler"], ), ], ) @@ -398,10 +392,7 @@ async def test_fix_issue_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_get_progress_unauth( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -433,10 +424,7 @@ async def test_get_progress_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_step_unauth( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -468,16 +456,7 @@ async def test_step_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.test.issues.even_worse.title", - "component.test.issues.even_worse.description", - "component.test.issues.abc_123.title", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_list_issues( hass: HomeAssistant, @@ -569,15 +548,7 @@ async def test_list_issues( } -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.fake_integration.issues.abc_123.title", - "component.fake_integration.issues.abc_123.fix_flow.abort.not_given", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_fix_issue_aborted( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -639,16 +610,7 @@ async def test_fix_issue_aborted( assert msg["result"]["issues"][0] == first_issue -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.test.issues.abc_123.title", - "component.test.issues.even_worse.title", - "component.test.issues.even_worse.description", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_get_issue_data( hass: HomeAssistant, hass_ws_client: WebSocketGenerator diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 615960defbb..a5b6a07dde5 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -5449,12 +5449,11 @@ async def test_exclude_attributes(hass: HomeAssistant) -> None: assert ATTR_FRIENDLY_NAME in states[0].attributes +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", [ [ - "component.test.issues..title", - "component.test.issues..description", "component.sensor.issues..title", "component.sensor.issues..description", ] diff --git a/tests/components/synology_dsm/test_repairs.py b/tests/components/synology_dsm/test_repairs.py index 0dea980b553..a094928b837 100644 --- a/tests/components/synology_dsm/test_repairs.py +++ b/tests/components/synology_dsm/test_repairs.py @@ -256,7 +256,7 @@ async def test_missing_backup_no_shares( @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", ["component.synology_dsm.issues.other_issue.title"], ) async def test_other_fixable_issues( diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index baa939c411b..c0114cde42b 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -540,10 +540,7 @@ async def test_call_service_schema_validation_error( assert len(calls) == 0 -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.exceptions.custom_error.message"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_call_service_error( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: @@ -2394,9 +2391,7 @@ async def test_execute_script( ), ], ) -@pytest.mark.parametrize( - "ignore_translations", ["component.test.exceptions.test_error.message"] -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_execute_script_err_localization( hass: HomeAssistant, websocket_client: MockHAClientWebSocket, diff --git a/tests/components/workday/test_repairs.py b/tests/components/workday/test_repairs.py index adbae5676e6..09b0149a424 100644 --- a/tests/components/workday/test_repairs.py +++ b/tests/components/workday/test_repairs.py @@ -430,7 +430,7 @@ async def test_bad_date_holiday( @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", ["component.workday.issues.issue_1.title"], ) async def test_other_fixable_issues( diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index a46320168eb..1d0f74c7269 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -180,7 +180,7 @@ async def test_device_config_file_changed_ignore_step( @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", ["component.zwave_js.issues.invalid_issue.title"], ) async def test_invalid_issue( From 19704cff0418a970be9b8c70319e20183f305d58 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2025 19:10:54 +0000 Subject: [PATCH 2762/2987] Fix grammar in loader comments (#139276) https://github.com/home-assistant/core/pull/139270#discussion_r1970315129 --- homeassistant/loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 008c2b057b2..3bc33f8374c 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1435,7 +1435,7 @@ async def async_get_integrations( results[domain] = exc # We don't use set_exception because # we expect there will be cases where - # the a future exception is never retrieved + # the future exception is never retrieved future.set_result(exc) return results From 570e11ba5b5bb6a5a37603e5acfa0e019a224e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 25 Feb 2025 20:22:30 +0100 Subject: [PATCH 2763/2987] Bump aiohomeconnect to 0.15.0 (#139277) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 06325afaed8..28714b31679 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.12.3"], + "requirements": ["aiohomeconnect==0.15.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index c318a069597..c8265568525 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -264,7 +264,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.12.3 +aiohomeconnect==0.15.0 # homeassistant.components.homekit_controller aiohomekit==3.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d42434585d1..bc065805b2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -249,7 +249,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.12.3 +aiohomeconnect==0.15.0 # homeassistant.components.homekit_controller aiohomekit==3.2.7 From b8a0cdea124c87c0a9e663f451f2841ea8491026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 25 Feb 2025 20:50:42 +0100 Subject: [PATCH 2764/2987] Add current cavity temperature sensor to Home Connect (#139282) --- homeassistant/components/home_connect/sensor.py | 6 ++++++ homeassistant/components/home_connect/strings.json | 3 +++ 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index be0b621b508..3f85bc3404c 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -179,6 +179,12 @@ SENSORS = ( ], translation_key="last_selected_map", ), + HomeConnectSensorEntityDescription( + key=StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + translation_key="current_cavity_temperature", + ), ) EVENT_SENSORS = ( diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 672ad364365..4fabd1e1c50 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -1529,6 +1529,9 @@ "map3": "Map 3" } }, + "current_cavity_temperature": { + "name": "Current cavity temperature" + }, "freezer_door_alarm": { "name": "Freezer door alarm", "state": { From df6a5d7459cfe6348c5628857c19ecc99956cced Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Tue, 25 Feb 2025 23:24:38 +0300 Subject: [PATCH 2765/2987] Bump anthropic to 0.47.2 (#139283) --- homeassistant/components/anthropic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json index b5cbb36c034..797a7299d16 100644 --- a/homeassistant/components/anthropic/manifest.json +++ b/homeassistant/components/anthropic/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/anthropic", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["anthropic==0.44.0"] + "requirements": ["anthropic==0.47.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index c8265568525..79015872b6d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -476,7 +476,7 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.44.0 +anthropic==0.47.2 # homeassistant.components.mcp_server anyio==4.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc065805b2e..479557ba478 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -449,7 +449,7 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.44.0 +anthropic==0.47.2 # homeassistant.components.mcp_server anyio==4.8.0 From fd47d6578e866de8a8bdb0fc64d652960c8fc3f4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Feb 2025 21:31:24 +0100 Subject: [PATCH 2766/2987] Adjust recorder validate_statistics handler (#139229) --- homeassistant/components/recorder/websocket_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 03d9e725170..d23ecab3dac 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -297,13 +297,13 @@ async def ws_list_statistic_ids( async def ws_validate_statistics( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: - """Fetch a list of available statistic_id.""" + """Validate statistics and return issues found.""" instance = get_instance(hass) - statistic_ids = await instance.async_add_executor_job( + validation_issues = await instance.async_add_executor_job( validate_statistics, hass, ) - connection.send_result(msg["id"], statistic_ids) + connection.send_result(msg["id"], validation_issues) @websocket_api.websocket_command( From 03f6508bd89f41eee089634739b0581be5849669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Tue, 25 Feb 2025 21:37:01 +0100 Subject: [PATCH 2767/2987] Fix re-connect logic in Apple TV integration (#139289) --- homeassistant/components/apple_tv/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index f4417134b37..b911b3cec99 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -233,7 +233,6 @@ class AppleTVManager(DeviceListener): pass except Exception: _LOGGER.exception("Failed to connect") - await self.disconnect() async def _connect_loop(self) -> None: """Connect loop background task function.""" From fe348e17a3b709660fb5d24a193461fb19519892 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2025 20:43:06 +0000 Subject: [PATCH 2768/2987] Revert "Bump stookwijzer==1.5.8" (#139287) --- homeassistant/components/stookwijzer/__init__.py | 2 ++ homeassistant/components/stookwijzer/config_flow.py | 2 ++ homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index cb198749c52..d8b9561bde9 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -9,6 +9,7 @@ from stookwijzer import Stookwijzer from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator @@ -43,6 +44,7 @@ async def async_migrate_entry( if entry.version == 1: latitude, longitude = await Stookwijzer.async_transform_coordinates( + async_get_clientsession(hass), entry.data[CONF_LOCATION][CONF_LATITUDE], entry.data[CONF_LOCATION][CONF_LONGITUDE], ) diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index 124b0f8bfbb..32b4836763f 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import LocationSelector from .const import DOMAIN @@ -26,6 +27,7 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: latitude, longitude = await Stookwijzer.async_transform_coordinates( + async_get_clientsession(self.hass), user_input[CONF_LOCATION][CONF_LATITUDE], user_input[CONF_LOCATION][CONF_LONGITUDE], ) diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 86fccf64db1..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.8"] + "requirements": ["stookwijzer==1.5.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 79015872b6d..7caab6809ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2811,7 +2811,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.8 +stookwijzer==1.5.4 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 479557ba478..3ca116b3c24 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2272,7 +2272,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.8 +stookwijzer==1.5.4 # homeassistant.components.streamlabswater streamlabswater==1.0.1 From 81db3dea4183918a85d4d264f253aa27f26c9293 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2025 20:56:39 +0000 Subject: [PATCH 2769/2987] Add option to ESPHome to subscribe to logs (#139073) --- .../components/esphome/config_flow.py | 5 ++ homeassistant/components/esphome/const.py | 1 + homeassistant/components/esphome/manager.py | 39 +++++++++++ homeassistant/components/esphome/strings.json | 3 +- tests/components/esphome/conftest.py | 26 +++++++- tests/components/esphome/test_config_flow.py | 66 ++++++++++++++++--- tests/components/esphome/test_manager.py | 62 ++++++++++++++++- 7 files changed, 189 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 695131b19f7..955a93cd2b7 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -41,6 +41,7 @@ from .const import ( CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, CONF_NOISE_PSK, + CONF_SUBSCRIBE_LOGS, DEFAULT_ALLOW_SERVICE_CALLS, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, DOMAIN, @@ -508,6 +509,10 @@ class OptionsFlowHandler(OptionsFlow): CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS ), ): bool, + vol.Required( + CONF_SUBSCRIBE_LOGS, + default=self.config_entry.options.get(CONF_SUBSCRIBE_LOGS, False), + ): bool, } ) return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index 143aaa6342a..aabebad01b6 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -5,6 +5,7 @@ from awesomeversion import AwesomeVersion DOMAIN = "esphome" CONF_ALLOW_SERVICE_CALLS = "allow_service_calls" +CONF_SUBSCRIBE_LOGS = "subscribe_logs" CONF_DEVICE_NAME = "device_name" CONF_NOISE_PSK = "noise_psk" diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 5f5ee1241f7..c73268de747 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from functools import partial import logging +import re from typing import TYPE_CHECKING, Any, NamedTuple from aioesphomeapi import ( @@ -16,6 +17,7 @@ from aioesphomeapi import ( HomeassistantServiceCall, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, + LogLevel, ReconnectLogic, RequiresEncryptionAPIError, UserService, @@ -61,6 +63,7 @@ from .bluetooth import async_connect_scanner from .const import ( CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, + CONF_SUBSCRIBE_LOGS, DEFAULT_ALLOW_SERVICE_CALLS, DEFAULT_URL, DOMAIN, @@ -74,8 +77,30 @@ from .domain_data import DomainData # Import config flow so that it's added to the registry from .entry_data import ESPHomeConfigEntry, RuntimeEntryData +if TYPE_CHECKING: + from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] + SubscribeLogsResponse, + ) + + _LOGGER = logging.getLogger(__name__) +LOG_LEVEL_TO_LOGGER = { + LogLevel.LOG_LEVEL_NONE: logging.DEBUG, + LogLevel.LOG_LEVEL_ERROR: logging.ERROR, + LogLevel.LOG_LEVEL_WARN: logging.WARNING, + LogLevel.LOG_LEVEL_INFO: logging.INFO, + LogLevel.LOG_LEVEL_CONFIG: logging.INFO, + LogLevel.LOG_LEVEL_DEBUG: logging.DEBUG, + LogLevel.LOG_LEVEL_VERBOSE: logging.DEBUG, + LogLevel.LOG_LEVEL_VERY_VERBOSE: logging.DEBUG, +} +# 7-bit and 8-bit C1 ANSI sequences +# https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python +ANSI_ESCAPE_78BIT = re.compile( + rb"(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])" +) + @callback def _async_check_firmware_version( @@ -341,6 +366,18 @@ class ESPHomeManager: # Re-connection logic will trigger after this await self.cli.disconnect() + def _async_on_log(self, msg: SubscribeLogsResponse) -> None: + """Handle a log message from the API.""" + logger_level = LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG) + if _LOGGER.isEnabledFor(logger_level): + log: bytes = msg.message + _LOGGER.log( + logger_level, + "%s: %s", + self.entry.title, + ANSI_ESCAPE_78BIT.sub(b"", log).decode("utf-8", "backslashreplace"), + ) + async def _on_connnect(self) -> None: """Subscribe to states and list entities on successful API login.""" entry = self.entry @@ -352,6 +389,8 @@ class ESPHomeManager: cli = self.cli stored_device_name = entry.data.get(CONF_DEVICE_NAME) unique_id_is_mac_address = unique_id and ":" in unique_id + if entry.options.get(CONF_SUBSCRIBE_LOGS): + cli.subscribe_logs(self._async_on_log, LogLevel.LOG_LEVEL_VERY_VERBOSE) results = await asyncio.gather( create_eager_task(cli.device_info()), create_eager_task(cli.list_entities_services()), diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 81b58de8df2..1534a49e365 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -54,7 +54,8 @@ "step": { "init": { "data": { - "allow_service_calls": "Allow the device to perform Home Assistant actions." + "allow_service_calls": "Allow the device to perform Home Assistant actions.", + "subscribe_logs": "Subscribe to logs from the device. When enabled, the device will send logs to Home Assistant and you can view them in the logs panel." } } } diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 2b7c127efd3..07f6c6ea697 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -6,7 +6,7 @@ import asyncio from asyncio import Event from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, Mock, patch from aioesphomeapi import ( @@ -17,6 +17,7 @@ from aioesphomeapi import ( EntityInfo, EntityState, HomeassistantServiceCall, + LogLevel, ReconnectLogic, UserService, VoiceAssistantAnnounceFinished, @@ -42,6 +43,10 @@ from . import DASHBOARD_HOST, DASHBOARD_PORT, DASHBOARD_SLUG from tests.common import MockConfigEntry +if TYPE_CHECKING: + from aioesphomeapi.api_pb2 import SubscribeLogsResponse + + _ONE_SECOND = 16000 * 2 # 16Khz 16-bit @@ -154,6 +159,7 @@ def mock_client(mock_device_info) -> APIClient: mock_client.device_info = AsyncMock(return_value=mock_device_info) mock_client.connect = AsyncMock() mock_client.disconnect = AsyncMock() + mock_client.subscribe_logs = Mock() mock_client.list_entities_services = AsyncMock(return_value=([], [])) mock_client.address = "127.0.0.1" mock_client.api_version = APIVersion(99, 99) @@ -222,6 +228,7 @@ class MockESPHomeDevice: ] | None ) + self.on_log_message: Callable[[SubscribeLogsResponse], None] self.device_info = device_info def set_state_callback(self, state_callback: Callable[[EntityState], None]) -> None: @@ -250,6 +257,16 @@ class MockESPHomeDevice: """Mock disconnecting.""" await self.on_disconnect(expected_disconnect) + def set_on_log_message( + self, on_log_message: Callable[[SubscribeLogsResponse], None] + ) -> None: + """Set the log message callback.""" + self.on_log_message = on_log_message + + def mock_on_log_message(self, log_message: SubscribeLogsResponse) -> None: + """Mock on log message.""" + self.on_log_message(log_message) + def set_on_connect(self, on_connect: Callable[[], None]) -> None: """Set the connect callback.""" self.on_connect = on_connect @@ -413,6 +430,12 @@ async def _mock_generic_device_entry( on_state_sub, on_state_request ) + def _subscribe_logs( + on_log_message: Callable[[SubscribeLogsResponse], None], log_level: LogLevel + ) -> None: + """Subscribe to log messages.""" + mock_device.set_on_log_message(on_log_message) + def _subscribe_voice_assistant( *, handle_start: Callable[ @@ -453,6 +476,7 @@ async def _mock_generic_device_entry( mock_client.subscribe_states = _subscribe_states mock_client.subscribe_service_calls = _subscribe_service_calls mock_client.subscribe_home_assistant_states = _subscribe_home_assistant_states + mock_client.subscribe_logs = _subscribe_logs try_connect_done = Event() diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 65dab4c516f..afca6f76b43 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -23,6 +23,7 @@ from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, CONF_NOISE_PSK, + CONF_SUBSCRIBE_LOGS, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, DOMAIN, ) @@ -1295,14 +1296,57 @@ async def test_zeroconf_no_encryption_key_via_dashboard( assert result["step_id"] == "encryption_key" -@pytest.mark.parametrize("option_value", [True, False]) -async def test_option_flow( +async def test_option_flow_allow_service_calls( hass: HomeAssistant, - option_value: bool, mock_client: APIClient, mock_generic_device_entry, ) -> None: - """Test config flow options.""" + """Test config flow options for allow service calls.""" + entry = await mock_generic_device_entry( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["data_schema"]({}) == { + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + CONF_SUBSCRIBE_LOGS: False, + } + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["data_schema"]({}) == { + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + CONF_SUBSCRIBE_LOGS: False, + } + with patch( + "homeassistant.components.esphome.async_setup_entry", return_value=True + ) as mock_reload: + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_ALLOW_SERVICE_CALLS: True, CONF_SUBSCRIBE_LOGS: False}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_ALLOW_SERVICE_CALLS: True, + CONF_SUBSCRIBE_LOGS: False, + } + assert len(mock_reload.mock_calls) == 1 + + +async def test_option_flow_subscribe_logs( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test config flow options with subscribe logs.""" entry = await mock_generic_device_entry( mock_client=mock_client, entity_info=[], @@ -1315,7 +1359,8 @@ async def test_option_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" assert result["data_schema"]({}) == { - CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + CONF_SUBSCRIBE_LOGS: False, } with patch( @@ -1323,15 +1368,16 @@ async def test_option_flow( ) as mock_reload: result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={ - CONF_ALLOW_SERVICE_CALLS: option_value, - }, + user_input={CONF_ALLOW_SERVICE_CALLS: False, CONF_SUBSCRIBE_LOGS: True}, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {CONF_ALLOW_SERVICE_CALLS: option_value} - assert len(mock_reload.mock_calls) == int(option_value) + assert result["data"] == { + CONF_ALLOW_SERVICE_CALLS: False, + CONF_SUBSCRIBE_LOGS: True, + } + assert len(mock_reload.mock_calls) == 1 @pytest.mark.usefixtures("mock_zeroconf") diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 7db1427d975..cf9d4a6f217 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -2,7 +2,8 @@ import asyncio from collections.abc import Awaitable, Callable -from unittest.mock import AsyncMock, call +import logging +from unittest.mock import AsyncMock, Mock, call from aioesphomeapi import ( APIClient, @@ -13,6 +14,7 @@ from aioesphomeapi import ( HomeassistantServiceCall, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, + LogLevel, RequiresEncryptionAPIError, UserService, UserServiceArg, @@ -24,6 +26,7 @@ from homeassistant import config_entries from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, + CONF_SUBSCRIBE_LOGS, DOMAIN, STABLE_BLE_VERSION_STR, ) @@ -44,6 +47,63 @@ from .conftest import MockESPHomeDevice from tests.common import MockConfigEntry, async_capture_events, async_mock_service +async def test_esphome_device_subscribe_logs( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test configuring a device to subscribe to logs.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "fe80::1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + }, + options={CONF_SUBSCRIBE_LOGS: True}, + ) + entry.add_to_hass(hass) + device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + entity_info=[], + user_service=[], + device_info={}, + states=[], + ) + await hass.async_block_till_done() + caplog.set_level(logging.DEBUG) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message") + ) + await hass.async_block_till_done() + assert "test_log_message" in caplog.text + + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message") + ) + await hass.async_block_till_done() + assert "test_error_log_message" in caplog.text + + caplog.set_level(logging.ERROR) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") + ) + await hass.async_block_till_done() + assert "test_debug_log_message" not in caplog.text + + caplog.set_level(logging.DEBUG) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") + ) + await hass.async_block_till_done() + assert "test_debug_log_message" in caplog.text + + async def test_esphome_device_service_calls_not_allowed( hass: HomeAssistant, mock_client: APIClient, From 3230e741e9325253aac0dd3254fed68b4b8302ef Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 25 Feb 2025 22:49:41 +0100 Subject: [PATCH 2770/2987] Remove not used constants in smhi (#139298) --- homeassistant/components/smhi/weather.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index d2e31990012..5faef04e03d 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping -from datetime import timedelta from typing import Any, Final from pysmhi import SMHIForecast @@ -80,12 +79,6 @@ CONDITION_MAP = { for cond_code in cond_codes } -TIMEOUT = 10 -# 5 minutes between retrying connect to API again -RETRY_TIMEOUT = 5 * 60 - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=31) - async def async_setup_entry( hass: HomeAssistant, From 7bc0c1b9121ec6eb078e43c99680d045b670655a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 25 Feb 2025 22:52:44 +0100 Subject: [PATCH 2771/2987] Bump `aioshelly` to version `13.0.0` (#139294) * Bump aioshelly to version 13.0.0 * MODEL_BLU_GATEWAY_GEN3 -> MODEL_BLU_GATEWAY_G3 --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/shelly/test_binary_sensor.py | 4 ++-- tests/components/shelly/test_climate.py | 8 ++++---- tests/components/shelly/test_number.py | 8 ++++---- tests/components/shelly/test_sensor.py | 4 ++-- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index c8073d6dbc2..ec08a005995 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==12.4.2"], + "requirements": ["aioshelly==13.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 7caab6809ba..4949a9fc4a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -371,7 +371,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.4.2 +aioshelly==13.0.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3ca116b3c24..17a6f6a6f56 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.4.2 +aioshelly==13.0.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 7f2d07b1ccc..1e7c54320e8 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -3,7 +3,7 @@ from copy import deepcopy from unittest.mock import Mock -from aioshelly.const import MODEL_BLU_GATEWAY_GEN3, MODEL_MOTION +from aioshelly.const import MODEL_BLU_GATEWAY_G3, MODEL_MOTION from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -486,7 +486,7 @@ async def test_blu_trv_binary_sensor_entity( snapshot: SnapshotAssertion, ) -> None: """Test BLU TRV binary sensor entity.""" - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) for entity in ("calibration",): entity_id = f"{BINARY_SENSOR_DOMAIN}.trv_name_{entity}" diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 5ad298c15a1..040d67cb9c4 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock, PropertyMock from aioshelly.const import ( BLU_TRV_IDENTIFIER, - MODEL_BLU_GATEWAY_GEN3, + MODEL_BLU_GATEWAY_G3, MODEL_VALVE, MODEL_WALL_DISPLAY, ) @@ -782,7 +782,7 @@ async def test_blu_trv_climate_set_temperature( entity_id = "climate.trv_name" monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 @@ -820,7 +820,7 @@ async def test_blu_trv_climate_disabled( entity_id = "climate.trv_name" monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 @@ -842,7 +842,7 @@ async def test_blu_trv_climate_hvac_action( entity_id = "climate.trv_name" monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) assert get_entity_attribute(hass, entity_id, ATTR_HVAC_ACTION) == HVACAction.IDLE diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index b1b65d99ab5..6bddd1eeb23 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -3,7 +3,7 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock -from aioshelly.const import MODEL_BLU_GATEWAY_GEN3 +from aioshelly.const import MODEL_BLU_GATEWAY_G3 from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest from syrupy import SnapshotAssertion @@ -405,7 +405,7 @@ async def test_blu_trv_number_entity( # disable automatic temperature control in the device monkeypatch.setitem(mock_blu_trv.config["blutrv:200"], "enable", False) - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) for entity in ("external_temperature", "valve_position"): entity_id = f"{NUMBER_DOMAIN}.trv_name_{entity}" @@ -421,7 +421,7 @@ async def test_blu_trv_ext_temp_set_value( hass: HomeAssistant, mock_blu_trv: Mock ) -> None: """Test the set value action for BLU TRV External Temperature number entity.""" - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) entity_id = f"{NUMBER_DOMAIN}.trv_name_external_temperature" @@ -461,7 +461,7 @@ async def test_blu_trv_valve_pos_set_value( # disable automatic temperature control to enable valve position entity monkeypatch.setitem(mock_blu_trv.config["blutrv:200"], "enable", False) - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) entity_id = f"{NUMBER_DOMAIN}.trv_name_valve_position" diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index ef7771e53ba..d0fec65c7de 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -3,7 +3,7 @@ from copy import deepcopy from unittest.mock import Mock -from aioshelly.const import MODEL_BLU_GATEWAY_GEN3 +from aioshelly.const import MODEL_BLU_GATEWAY_G3 from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -1416,7 +1416,7 @@ async def test_blu_trv_sensor_entity( snapshot: SnapshotAssertion, ) -> None: """Test BLU TRV sensor entity.""" - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) for entity in ("battery", "signal_strength", "valve_position"): entity_id = f"{SENSOR_DOMAIN}.trv_name_{entity}" From 622be70fee42215fb67b7ac33998861808c81f1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 25 Feb 2025 22:02:49 +0000 Subject: [PATCH 2772/2987] Remove timeout from vscode test launch configuration (#139288) --- .vscode/launch.json | 1 - 1 file changed, 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 15cdb9fb625..459a9e6acc5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -38,7 +38,6 @@ "module": "pytest", "justMyCode": false, "args": [ - "--timeout=10", "--picked" ], }, From 8644fb188761fbf50d791fb8c3707b16335893c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 25 Feb 2025 23:05:52 +0100 Subject: [PATCH 2773/2987] Add missing Home Connect context at event listener registration for appliance options (#139292) * Add missing context at event listener registration for appliance options * Add tests --- .../components/home_connect/common.py | 35 ++--- tests/components/home_connect/test_entity.py | 121 ++++++++++++++++++ 2 files changed, 141 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/home_connect/common.py b/homeassistant/components/home_connect/common.py index a9f48eea5ba..f52b59bc213 100644 --- a/homeassistant/components/home_connect/common.py +++ b/homeassistant/components/home_connect/common.py @@ -72,22 +72,27 @@ def _handle_paired_or_connected_appliance( for entity in get_option_entities_for_appliance(entry, appliance) if entity.unique_id not in known_entity_unique_ids ) - changed_options_listener_remove_callback = ( - entry.runtime_data.async_add_listener( - partial( - _create_option_entities, - entry, - appliance, - known_entity_unique_ids, - get_option_entities_for_appliance, - async_add_entities, - ), + for event_key in ( + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ): + changed_options_listener_remove_callback = ( + entry.runtime_data.async_add_listener( + partial( + _create_option_entities, + entry, + appliance, + known_entity_unique_ids, + get_option_entities_for_appliance, + async_add_entities, + ), + (appliance.info.ha_id, event_key), + ) + ) + entry.async_on_unload(changed_options_listener_remove_callback) + changed_options_listener_remove_callbacks[appliance.info.ha_id].append( + changed_options_listener_remove_callback ) - ) - entry.async_on_unload(changed_options_listener_remove_callback) - changed_options_listener_remove_callbacks[appliance.info.ha_id].append( - changed_options_listener_remove_callback - ) known_entity_unique_ids.update( { cast(str, entity.unique_id): appliance.info.ha_id diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py index 272fc21ba62..f173cda0b0c 100644 --- a/tests/components/home_connect/test_entity.py +++ b/tests/components/home_connect/test_entity.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, + ArrayOfHomeAppliances, ArrayOfPrograms, Event, EventKey, @@ -233,6 +234,126 @@ async def test_program_options_retrieval( assert hass.states.is_state(entity_id, STATE_UNKNOWN) +@pytest.mark.parametrize( + "event_key", + [ + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ], +) +@pytest.mark.parametrize( + ("appliance_ha_id", "option_key", "option_entity_id"), + [ + ( + "Dishwasher", + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, + "switch.dishwasher_half_load", + ) + ], + indirect=["appliance_ha_id"], +) +async def test_program_options_retrieval_after_appliance_connection( + event_key: EventKey, + appliance_ha_id: str, + option_key: OptionKey, + option_entity_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the options are correctly retrieved at the start and updated on program updates.""" + array_of_home_appliances = client.get_home_appliances.return_value + + async def get_home_appliances_with_options_mock() -> ArrayOfHomeAppliances: + return ArrayOfHomeAppliances( + [ + appliance + for appliance in array_of_home_appliances.homeappliances + if appliance.ha_id != appliance_ha_id + ] + ) + + client.get_home_appliances = AsyncMock( + side_effect=get_home_appliances_with_options_mock + ) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert not hass.states.get(option_entity_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED, + raw_key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED.value, + timestamp=0, + level="", + handling="", + value="", + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert not hass.states.get(option_entity_id) + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Boolean", + constraints=ProgramDefinitionConstraints( + default=False, + ), + ), + ], + ) + ) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.DISHCARE_DISHWASHER_AUTO_1, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.get(option_entity_id) + + @pytest.mark.parametrize( ( "set_active_program_option_side_effect", From 412ceca6f723f2187c583d58cb225f394baa0adf Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 25 Feb 2025 23:22:02 +0100 Subject: [PATCH 2774/2987] Sort common translation strings (#139300) sort common strings --- homeassistant/strings.json | 238 ++++++++++++++++++------------------- 1 file changed, 119 insertions(+), 119 deletions(-) diff --git a/homeassistant/strings.json b/homeassistant/strings.json index f423c3bf59c..29b7db7a011 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -1,13 +1,101 @@ { "common": { - "generic": { - "model": "Model", - "ui_managed": "Managed via UI" + "action": { + "close": "Close", + "connect": "Connect", + "disable": "Disable", + "disconnect": "Disconnect", + "enable": "Enable", + "open": "Open", + "pause": "Pause", + "reload": "Reload", + "restart": "Restart", + "start": "Start", + "stop": "Stop", + "toggle": "Toggle", + "turn_off": "Turn off", + "turn_on": "Turn on" + }, + "config_flow": { + "abort": { + "already_configured_account": "Account is already configured", + "already_configured_device": "Device is already configured", + "already_configured_location": "Location is already configured", + "already_configured_service": "Service is already configured", + "already_in_progress": "Configuration flow is already in progress", + "cloud_not_connected": "Not connected to Home Assistant Cloud.", + "no_devices_found": "No devices found on the network", + "oauth2_authorize_url_timeout": "Timeout generating authorize URL.", + "oauth2_error": "Received invalid token data.", + "oauth2_failed": "Error while obtaining access token.", + "oauth2_missing_configuration": "The component is not configured. Please follow the documentation.", + "oauth2_missing_credentials": "The integration requires application credentials.", + "oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", + "oauth2_timeout": "Timeout resolving OAuth token.", + "oauth2_unauthorized": "OAuth authorization error while obtaining access token.", + "oauth2_user_rejected_authorize": "Account linking rejected: {error}", + "reauth_successful": "Re-authentication was successful", + "reconfigure_successful": "Re-configuration was successful", + "single_instance_allowed": "Already configured. Only a single configuration possible.", + "unknown_authorize_url_generation": "Unknown error generating an authorize URL.", + "webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages." + }, + "create_entry": { + "authenticated": "Successfully authenticated" + }, + "data": { + "access_token": "Access token", + "api_key": "API key", + "api_token": "API token", + "device": "Device", + "elevation": "Elevation", + "email": "Email", + "host": "Host", + "ip": "IP address", + "language": "Language", + "latitude": "Latitude", + "llm_hass_api": "Control Home Assistant", + "location": "Location", + "longitude": "Longitude", + "mode": "Mode", + "name": "Name", + "password": "Password", + "path": "Path", + "pin": "PIN code", + "port": "Port", + "ssl": "Uses an SSL certificate", + "url": "URL", + "usb_path": "USB device path", + "username": "Username", + "verify_ssl": "Verify SSL certificate" + }, + "description": { + "confirm_setup": "Do you want to start setup?" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_access_token": "Invalid access token", + "invalid_api_key": "Invalid API key", + "invalid_auth": "Invalid authentication", + "invalid_host": "Invalid hostname or IP address", + "timeout_connect": "Timeout establishing connection", + "unknown": "Unexpected error" + }, + "title": { + "oauth2_pick_implementation": "Pick authentication method", + "reauth": "Authentication expired for {name}", + "via_hassio_addon": "{name} via Home Assistant add-on" + } }, "device_automation": { + "action_type": { + "toggle": "Toggle {entity_name}", + "turn_off": "Turn off {entity_name}", + "turn_on": "Turn on {entity_name}" + }, "condition_type": { - "is_on": "{entity_name} is on", - "is_off": "{entity_name} is off" + "is_off": "{entity_name} is off", + "is_on": "{entity_name} is on" }, "extra_fields": { "above": "Above", @@ -19,30 +107,35 @@ }, "trigger_type": { "changed_states": "{entity_name} turned on or off", - "turned_on": "{entity_name} turned on", - "turned_off": "{entity_name} turned off" - }, - "action_type": { - "toggle": "Toggle {entity_name}", - "turn_on": "Turn on {entity_name}", - "turn_off": "Turn off {entity_name}" + "turned_off": "{entity_name} turned off", + "turned_on": "{entity_name} turned on" } }, - "action": { - "connect": "Connect", - "disconnect": "Disconnect", - "enable": "Enable", - "disable": "Disable", + "generic": { + "model": "Model", + "ui_managed": "Managed via UI" + }, + "state": { + "active": "Active", + "charging": "Charging", + "closed": "Closed", + "connected": "Connected", + "disabled": "Disabled", + "discharging": "Discharging", + "disconnected": "Disconnected", + "enabled": "Enabled", + "home": "Home", + "idle": "Idle", + "locked": "Locked", + "no": "No", + "not_home": "Away", + "off": "Off", + "on": "On", "open": "Open", - "close": "Close", - "reload": "Reload", - "restart": "Restart", - "start": "Start", - "stop": "Stop", - "pause": "Pause", - "turn_on": "Turn on", - "turn_off": "Turn off", - "toggle": "Toggle" + "paused": "Paused", + "standby": "Standby", + "unlocked": "Unlocked", + "yes": "Yes" }, "time": { "monday": "Monday", @@ -52,99 +145,6 @@ "friday": "Friday", "saturday": "Saturday", "sunday": "Sunday" - }, - "state": { - "off": "Off", - "on": "On", - "yes": "Yes", - "no": "No", - "open": "Open", - "closed": "Closed", - "enabled": "Enabled", - "disabled": "Disabled", - "connected": "Connected", - "disconnected": "Disconnected", - "locked": "Locked", - "unlocked": "Unlocked", - "active": "Active", - "idle": "Idle", - "standby": "Standby", - "paused": "Paused", - "home": "Home", - "not_home": "Away", - "charging": "Charging", - "discharging": "Discharging" - }, - "config_flow": { - "title": { - "oauth2_pick_implementation": "Pick authentication method", - "reauth": "Authentication expired for {name}", - "via_hassio_addon": "{name} via Home Assistant add-on" - }, - "description": { - "confirm_setup": "Do you want to start setup?" - }, - "data": { - "device": "Device", - "name": "Name", - "email": "Email", - "username": "Username", - "password": "Password", - "host": "Host", - "ip": "IP address", - "port": "Port", - "url": "URL", - "usb_path": "USB device path", - "access_token": "Access token", - "api_key": "API key", - "api_token": "API token", - "llm_hass_api": "Control Home Assistant", - "ssl": "Uses an SSL certificate", - "verify_ssl": "Verify SSL certificate", - "elevation": "Elevation", - "longitude": "Longitude", - "latitude": "Latitude", - "location": "Location", - "pin": "PIN code", - "mode": "Mode", - "path": "Path", - "language": "Language" - }, - "create_entry": { - "authenticated": "Successfully authenticated" - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_access_token": "Invalid access token", - "invalid_api_key": "Invalid API key", - "invalid_auth": "Invalid authentication", - "invalid_host": "Invalid hostname or IP address", - "unknown": "Unexpected error", - "timeout_connect": "Timeout establishing connection" - }, - "abort": { - "single_instance_allowed": "Already configured. Only a single configuration possible.", - "already_configured_account": "Account is already configured", - "already_configured_device": "Device is already configured", - "already_configured_location": "Location is already configured", - "already_configured_service": "Service is already configured", - "already_in_progress": "Configuration flow is already in progress", - "no_devices_found": "No devices found on the network", - "webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages.", - "oauth2_error": "Received invalid token data.", - "oauth2_timeout": "Timeout resolving OAuth token.", - "oauth2_missing_configuration": "The component is not configured. Please follow the documentation.", - "oauth2_missing_credentials": "The integration requires application credentials.", - "oauth2_authorize_url_timeout": "Timeout generating authorize URL.", - "oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", - "oauth2_user_rejected_authorize": "Account linking rejected: {error}", - "oauth2_unauthorized": "OAuth authorization error while obtaining access token.", - "oauth2_failed": "Error while obtaining access token.", - "reauth_successful": "Re-authentication was successful", - "reconfigure_successful": "Re-configuration was successful", - "unknown_authorize_url_generation": "Unknown error generating an authorize URL.", - "cloud_not_connected": "Not connected to Home Assistant Cloud." - } } } } From bd306abace66a43cd2c42c3be7cdfecc7a6962cf Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Tue, 25 Feb 2025 23:55:53 +0000 Subject: [PATCH 2775/2987] Add album artist media browser category to Squeezebox (#139210) --- homeassistant/components/squeezebox/browse_media.py | 4 ++++ tests/components/squeezebox/conftest.py | 1 + tests/components/squeezebox/test_media_browser.py | 1 + 3 files changed, 6 insertions(+) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index e12d2aa8844..6bc1d2380cf 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -29,6 +29,7 @@ LIBRARY = [ "Playlists", "Genres", "New Music", + "Album Artists", "Apps", "Radios", ] @@ -41,6 +42,7 @@ MEDIA_TYPE_TO_SQUEEZEBOX: dict[str | MediaType, str] = { "Playlists": "playlists", "Genres": "genres", "New Music": "new music", + "Album Artists": "album artists", MediaType.ALBUM: "album", MediaType.ARTIST: "artist", MediaType.TRACK: "title", @@ -71,6 +73,7 @@ CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | None]] = "Playlists": {"item": MediaClass.DIRECTORY, "children": MediaClass.PLAYLIST}, "Genres": {"item": MediaClass.DIRECTORY, "children": MediaClass.GENRE}, "New Music": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, + "Album Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, MediaType.ALBUM: {"item": MediaClass.ALBUM, "children": MediaClass.TRACK}, MediaType.ARTIST: {"item": MediaClass.ARTIST, "children": MediaClass.ALBUM}, MediaType.TRACK: {"item": MediaClass.TRACK, "children": None}, @@ -98,6 +101,7 @@ CONTENT_TYPE_TO_CHILD_TYPE: dict[ "Radios": MediaClass.APP, "App": None, # can only be determined after inspecting the item "New Music": MediaType.ALBUM, + "Album Artists": MediaType.ARTIST, MediaType.APPS: MediaType.APP, MediaType.APP: MediaType.TRACK, } diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index cb77495e818..9ca750808c5 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -132,6 +132,7 @@ async def mock_async_browse( child_types = { "favorites": "favorites", "new music": "album", + "album artists": "artists", "albums": "album", "album": "track", "genres": "genre", diff --git a/tests/components/squeezebox/test_media_browser.py b/tests/components/squeezebox/test_media_browser.py index f00ea1754fc..7b11ef30a87 100644 --- a/tests/components/squeezebox/test_media_browser.py +++ b/tests/components/squeezebox/test_media_browser.py @@ -77,6 +77,7 @@ async def test_async_browse_media_root( ("Playlists", 4), ("Genres", 4), ("New Music", 4), + ("Album Artists", 4), ("Apps", 3), ("Radios", 3), ], From 3ff04d6d049cf8ff65eddfbf87b7e65b5d8aecfb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Feb 2025 02:14:58 +0000 Subject: [PATCH 2776/2987] Bump aioesphomeapi to 29.2.0 (#139309) --- 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 403da9286ab..b59dd544c49 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.1.1", + "aioesphomeapi==29.2.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.7.1" ], diff --git a/requirements_all.txt b/requirements_all.txt index 4949a9fc4a9..3a7fe746411 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.1.1 +aioesphomeapi==29.2.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 17a6f6a6f56..f01c344b3c7 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.1.1 +aioesphomeapi==29.2.0 # homeassistant.components.flo aioflo==2021.11.0 From b1865de58f99ebe77c9e1d35c6cf72c7fd194e57 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Feb 2025 08:13:25 +0100 Subject: [PATCH 2777/2987] Bump actions/download-artifact from 4.1.8 to 4.1.9 (#139317) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.1.8 to 4.1.9. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4.1.8...v4.1.9) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- .github/workflows/ci.yaml | 6 +++--- .github/workflows/wheels.yml | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 68581c58d24..7867e635f51 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -175,7 +175,7 @@ jobs: sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt - name: Download translations - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: translations @@ -462,7 +462,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Download translations - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: translations diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2aead92791a..8745ab63470 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -942,7 +942,7 @@ jobs: run: | echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Download pytest_buckets - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: pytest_buckets - name: Compile English translations @@ -1271,7 +1271,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1410,7 +1410,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: pattern: coverage-* - name: Upload coverage to Codecov diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 743ae869ab9..7c02c8d97cd 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -138,17 +138,17 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: requirements_diff @@ -187,22 +187,22 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: requirements_diff - name: Download requirements_all_wheels - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: requirements_all_wheels From 4530fe4bf70bc9ce7b842392bb20c00d01119bcf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Feb 2025 08:48:25 +0100 Subject: [PATCH 2778/2987] Bump home-assistant/builder from 2024.08.2 to 2025.02.0 (#139316) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 7867e635f51..0ad4c510a55 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -197,7 +197,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2024.08.2 + uses: home-assistant/builder@2025.02.0 with: args: | $BUILD_ARGS \ @@ -263,7 +263,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2024.08.2 + uses: home-assistant/builder@2025.02.0 with: args: | $BUILD_ARGS \ From eb26a2124bf4e2ca55dcd635ade83ea4cf00e5d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Feb 2025 07:58:13 +0000 Subject: [PATCH 2779/2987] Adjust remote ESPHome log subscription level on logging change (#139308) --- homeassistant/components/esphome/manager.py | 53 +++++++++++++++++---- tests/components/esphome/conftest.py | 5 +- tests/components/esphome/test_manager.py | 32 +++++++++++++ 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index c73268de747..e32bb7d6ded 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -35,6 +35,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import ( + CALLBACK_TYPE, Event, EventStateChangedData, HomeAssistant, @@ -95,6 +96,14 @@ LOG_LEVEL_TO_LOGGER = { LogLevel.LOG_LEVEL_VERBOSE: logging.DEBUG, LogLevel.LOG_LEVEL_VERY_VERBOSE: logging.DEBUG, } +LOGGER_TO_LOG_LEVEL = { + logging.NOTSET: LogLevel.LOG_LEVEL_VERY_VERBOSE, + logging.DEBUG: LogLevel.LOG_LEVEL_VERY_VERBOSE, + logging.INFO: LogLevel.LOG_LEVEL_CONFIG, + logging.WARNING: LogLevel.LOG_LEVEL_WARN, + logging.ERROR: LogLevel.LOG_LEVEL_ERROR, + logging.CRITICAL: LogLevel.LOG_LEVEL_ERROR, +} # 7-bit and 8-bit C1 ANSI sequences # https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python ANSI_ESCAPE_78BIT = re.compile( @@ -161,6 +170,8 @@ class ESPHomeManager: """Class to manage an ESPHome connection.""" __slots__ = ( + "_cancel_subscribe_logs", + "_log_level", "cli", "device_id", "domain_data", @@ -194,6 +205,8 @@ class ESPHomeManager: self.reconnect_logic: ReconnectLogic | None = None self.zeroconf_instance = zeroconf_instance self.entry_data = entry.runtime_data + self._cancel_subscribe_logs: CALLBACK_TYPE | None = None + self._log_level = LogLevel.LOG_LEVEL_NONE async def on_stop(self, event: Event) -> None: """Cleanup the socket client on HA close.""" @@ -368,15 +381,31 @@ class ESPHomeManager: def _async_on_log(self, msg: SubscribeLogsResponse) -> None: """Handle a log message from the API.""" - logger_level = LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG) - if _LOGGER.isEnabledFor(logger_level): - log: bytes = msg.message - _LOGGER.log( - logger_level, - "%s: %s", - self.entry.title, - ANSI_ESCAPE_78BIT.sub(b"", log).decode("utf-8", "backslashreplace"), - ) + log: bytes = msg.message + _LOGGER.log( + LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG), + "%s: %s", + self.entry.title, + ANSI_ESCAPE_78BIT.sub(b"", log).decode("utf-8", "backslashreplace"), + ) + + @callback + def _async_get_equivalent_log_level(self) -> LogLevel: + """Get the equivalent ESPHome log level for the current logger.""" + return LOGGER_TO_LOG_LEVEL.get( + _LOGGER.getEffectiveLevel(), LogLevel.LOG_LEVEL_VERY_VERBOSE + ) + + @callback + def _async_subscribe_logs(self, log_level: LogLevel) -> None: + """Subscribe to logs.""" + if self._cancel_subscribe_logs is not None: + self._cancel_subscribe_logs() + self._cancel_subscribe_logs = None + self._log_level = log_level + self._cancel_subscribe_logs = self.cli.subscribe_logs( + self._async_on_log, self._log_level + ) async def _on_connnect(self) -> None: """Subscribe to states and list entities on successful API login.""" @@ -390,7 +419,7 @@ class ESPHomeManager: stored_device_name = entry.data.get(CONF_DEVICE_NAME) unique_id_is_mac_address = unique_id and ":" in unique_id if entry.options.get(CONF_SUBSCRIBE_LOGS): - cli.subscribe_logs(self._async_on_log, LogLevel.LOG_LEVEL_VERY_VERBOSE) + self._async_subscribe_logs(self._async_get_equivalent_log_level()) results = await asyncio.gather( create_eager_task(cli.device_info()), create_eager_task(cli.list_entities_services()), @@ -542,6 +571,10 @@ class ESPHomeManager: def _async_handle_logging_changed(self, _event: Event) -> None: """Handle when the logging level changes.""" self.cli.set_debug(_LOGGER.isEnabledFor(logging.DEBUG)) + if self.entry.options.get(CONF_SUBSCRIBE_LOGS) and self._log_level != ( + new_log_level := self._async_get_equivalent_log_level() + ): + self._async_subscribe_logs(new_log_level) async def async_start(self) -> None: """Start the esphome connection manager.""" diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 07f6c6ea697..dc6195bfe1f 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -230,6 +230,7 @@ class MockESPHomeDevice: ) self.on_log_message: Callable[[SubscribeLogsResponse], None] self.device_info = device_info + self.current_log_level = LogLevel.LOG_LEVEL_NONE def set_state_callback(self, state_callback: Callable[[EntityState], None]) -> None: """Set the state callback.""" @@ -432,9 +433,11 @@ async def _mock_generic_device_entry( def _subscribe_logs( on_log_message: Callable[[SubscribeLogsResponse], None], log_level: LogLevel - ) -> None: + ) -> Callable[[], None]: """Subscribe to log messages.""" mock_device.set_on_log_message(on_log_message) + mock_device.current_log_level = log_level + return lambda: None def _subscribe_voice_assistant( *, diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index cf9d4a6f217..b805b065d5a 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -57,6 +57,7 @@ async def test_esphome_device_subscribe_logs( caplog: pytest.LogCaptureFixture, ) -> None: """Test configuring a device to subscribe to logs.""" + assert await async_setup_component(hass, "logger", {"logger": {}}) entry = MockConfigEntry( domain=DOMAIN, data={ @@ -76,6 +77,15 @@ async def test_esphome_device_subscribe_logs( states=[], ) await hass.async_block_till_done() + + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "DEBUG"}, + blocking=True, + ) + assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE + caplog.set_level(logging.DEBUG) device.mock_on_log_message( Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message") @@ -103,6 +113,28 @@ async def test_esphome_device_subscribe_logs( await hass.async_block_till_done() assert "test_debug_log_message" in caplog.text + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "WARNING"}, + blocking=True, + ) + assert device.current_log_level == LogLevel.LOG_LEVEL_WARN + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "ERROR"}, + blocking=True, + ) + assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "INFO"}, + blocking=True, + ) + assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG + async def test_esphome_device_service_calls_not_allowed( hass: HomeAssistant, From cab6ec0363824ce78932a7b711ed1d3513d7946a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Feb 2025 09:02:17 +0100 Subject: [PATCH 2780/2987] Fix homeassistant/expose_entity/list (#138872) Co-authored-by: Paulus Schoutsen --- .../homeassistant/exposed_entities.py | 11 +++--- .../homeassistant/test_exposed_entities.py | 34 ++++++++++++++----- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 7bd9f9ab7bc..b7e420dedde 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -437,18 +437,21 @@ def ws_expose_entity( def ws_list_exposed_entities( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: - """Expose an entity to an assistant.""" + """List entities which are exposed to assistants.""" result: dict[str, Any] = {} exposed_entities = hass.data[DATA_EXPOSED_ENTITIES] entity_registry = er.async_get(hass) for entity_id in chain(exposed_entities.entities, entity_registry.entities): - result[entity_id] = {} + exposed_to = {} entity_settings = async_get_entity_settings(hass, entity_id) for assistant, settings in entity_settings.items(): - if "should_expose" not in settings: + if "should_expose" not in settings or not settings["should_expose"]: continue - result[entity_id][assistant] = settings["should_expose"] + exposed_to[assistant] = True + if not exposed_to: + continue + result[entity_id] = exposed_to connection.send_result(msg["id"], {"exposed_entities": result}) diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index 1f1955c2f82..ec87672e75c 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -497,28 +497,48 @@ async def test_list_exposed_entities( entry1 = entity_registry.async_get_or_create("test", "test", "unique1") entry2 = entity_registry.async_get_or_create("test", "test", "unique2") + entity_registry.async_get_or_create("test", "test", "unique3") # Set options for registered entities await ws_client.send_json_auto_id( { "type": "homeassistant/expose_entity", "assistants": ["cloud.alexa", "cloud.google_assistant"], - "entity_ids": [entry1.entity_id, entry2.entity_id], + "entity_ids": [entry1.entity_id], "should_expose": True, } ) response = await ws_client.receive_json() assert response["success"] + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa", "cloud.google_assistant"], + "entity_ids": [entry2.entity_id], + "should_expose": False, + } + ) + response = await ws_client.receive_json() + assert response["success"] + # Set options for entities not in the entity registry await ws_client.send_json_auto_id( { "type": "homeassistant/expose_entity", "assistants": ["cloud.alexa", "cloud.google_assistant"], - "entity_ids": [ - "test.test", - "test.test2", - ], + "entity_ids": ["test.test"], + "should_expose": True, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa", "cloud.google_assistant"], + "entity_ids": ["test.test2"], "should_expose": False, } ) @@ -531,10 +551,8 @@ async def test_list_exposed_entities( assert response["success"] assert response["result"] == { "exposed_entities": { - "test.test": {"cloud.alexa": False, "cloud.google_assistant": False}, - "test.test2": {"cloud.alexa": False, "cloud.google_assistant": False}, + "test.test": {"cloud.alexa": True, "cloud.google_assistant": True}, "test.test_unique1": {"cloud.alexa": True, "cloud.google_assistant": True}, - "test.test_unique2": {"cloud.alexa": True, "cloud.google_assistant": True}, }, } From d15f9edc5709428f79b59daa17a8df9df7d57ee9 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 26 Feb 2025 11:51:35 +0100 Subject: [PATCH 2781/2987] Bump `accuweather` to version `4.1.0` (#139320) --- homeassistant/components/accuweather/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 75f4a265b5f..5a019ef968e 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["accuweather"], - "requirements": ["accuweather==4.0.0"], + "requirements": ["accuweather==4.1.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 3a7fe746411..9569e134bc2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -131,7 +131,7 @@ TwitterAPI==2.7.12 WSDiscovery==2.1.2 # homeassistant.components.accuweather -accuweather==4.0.0 +accuweather==4.1.0 # homeassistant.components.adax adax==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f01c344b3c7..ab22b808f92 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -119,7 +119,7 @@ Tami4EdgeAPI==3.0 WSDiscovery==2.1.2 # homeassistant.components.accuweather -accuweather==4.0.0 +accuweather==4.1.0 # homeassistant.components.adax adax==0.4.0 From 861ba0ee5e61004c900b1a0bc3bc759e216cfd37 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 26 Feb 2025 11:52:57 +0100 Subject: [PATCH 2782/2987] Bump ZHA to 0.0.50 (#139318) --- homeassistant/components/zha/manifest.json | 2 +- homeassistant/components/zha/strings.json | 129 +++++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 132 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 54de60b8669..25e4de77a32 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.49"], + "requirements": ["zha==0.0.50"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 2007adca0da..38f55fb550d 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -1044,6 +1044,63 @@ }, "valve_duration": { "name": "Irrigation duration" + }, + "down_movement": { + "name": "Down movement" + }, + "sustain_time": { + "name": "Sustain time" + }, + "up_movement": { + "name": "Up movement" + }, + "large_motion_detection_sensitivity": { + "name": "Motion detection sensitivity" + }, + "large_motion_detection_distance": { + "name": "Motion detection distance" + }, + "medium_motion_detection_distance": { + "name": "Medium motion detection distance" + }, + "medium_motion_detection_sensitivity": { + "name": "Medium motion detection sensitivity" + }, + "small_motion_detection_distance": { + "name": "Small motion detection distance" + }, + "small_motion_detection_sensitivity": { + "name": "Small motion detection sensitivity" + }, + "static_detection_sensitivity": { + "name": "Static detection sensitivity" + }, + "static_detection_distance": { + "name": "Static detection distance" + }, + "motion_detection_sensitivity": { + "name": "Motion detection sensitivity" + }, + "holiday_temperature": { + "name": "Holiday temperature" + }, + "boost_time": { + "name": "Boost time" + }, + "antifrost_temperature": { + "name": "Antifrost temperature" + }, + "eco_temperature": { + "name": "Eco temperature" + }, + "comfort_temperature": { + "name": "Comfort temperature" + }, + "valve_state_auto_shutdown": { + "name": "Valve state auto shutdown" + }, + "shutdown_timer": { + "name": "Shutdown timer" } }, "select": { @@ -1235,6 +1292,33 @@ }, "eco_mode": { "name": "Eco mode" + }, + "mode": { + "name": "Mode" + }, + "reverse": { + "name": "Reverse" + }, + "motion_state": { + "name": "Motion state" + }, + "motion_detection_mode": { + "name": "Motion detection mode" + }, + "screen_orientation": { + "name": "Screen orientation" + }, + "motor_thrust": { + "name": "Motor thrust" + }, + "display_brightness": { + "name": "Display brightness" + }, + "display_orientation": { + "name": "Display orientation" + }, + "hysteresis_mode": { + "name": "Hysteresis mode" } }, "sensor": { @@ -1561,6 +1645,27 @@ }, "error_status": { "name": "Error status" + }, + "brightness_level": { + "name": "Brightness level" + }, + "average_light_intensity_20mins": { + "name": "Average light intensity last 20 min" + }, + "todays_max_light_intensity": { + "name": "Today's max light intensity" + }, + "fault_code": { + "name": "Fault code" + }, + "water_flow": { + "name": "Water flow" + }, + "remaining_watering_time": { + "name": "Remaining watering time" + }, + "last_watering_duration": { + "name": "Last watering duration" } }, "switch": { @@ -1746,6 +1851,30 @@ }, "total_flow_reset_switch": { "name": "Total flow reset switch" + }, + "touch_control": { + "name": "Touch control" + }, + "sound_enabled": { + "name": "Sound enabled" + }, + "invert_relay": { + "name": "Invert relay" + }, + "boost_heating": { + "name": "Boost heating" + }, + "holiday_mode": { + "name": "Holiday mode" + }, + "heating_stop": { + "name": "Heating stop" + }, + "schedule_mode": { + "name": "Schedule mode" + }, + "auto_clean": { + "name": "Auto clean" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 9569e134bc2..c4570f25195 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3152,7 +3152,7 @@ zeroconf==0.145.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.49 +zha==0.0.50 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab22b808f92..6b30a0c0867 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2541,7 +2541,7 @@ zeroconf==0.145.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.49 +zha==0.0.50 # homeassistant.components.zwave_js zwave-js-server-python==0.60.1 From 5895245a31a8d60a6fcb2ca93225609ce288184a Mon Sep 17 00:00:00 2001 From: Christophe Gagnier Date: Wed, 26 Feb 2025 05:57:54 -0500 Subject: [PATCH 2783/2987] Bump pytechnove to 2.0.0 (#139314) --- homeassistant/components/technove/manifest.json | 2 +- homeassistant/components/technove/strings.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/technove/snapshots/test_diagnostics.ambr | 2 +- tests/components/technove/snapshots/test_sensor.ambr | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/technove/manifest.json b/homeassistant/components/technove/manifest.json index 722aa4004e1..746c2280aaa 100644 --- a/homeassistant/components/technove/manifest.json +++ b/homeassistant/components/technove/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/technove", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["python-technove==1.3.1"], + "requirements": ["python-technove==2.0.0"], "zeroconf": ["_technove-stations._tcp.local."] } diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json index 9976f0b3c59..05260845a03 100644 --- a/homeassistant/components/technove/strings.json +++ b/homeassistant/components/technove/strings.json @@ -70,7 +70,7 @@ "plugged_waiting": "Plugged, waiting", "plugged_charging": "Plugged, charging", "out_of_activation_period": "Out of activation period", - "high_charge_period": "High charge period" + "high_tariff_period": "High tariff period" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index c4570f25195..766addab2b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2479,7 +2479,7 @@ python-songpal==0.16.2 python-tado==0.18.6 # homeassistant.components.technove -python-technove==1.3.1 +python-technove==2.0.0 # homeassistant.components.telegram_bot python-telegram-bot[socks]==21.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b30a0c0867..ca35a30f50b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2012,7 +2012,7 @@ python-songpal==0.16.2 python-tado==0.18.6 # homeassistant.components.technove -python-technove==1.3.1 +python-technove==2.0.0 # homeassistant.components.telegram_bot python-telegram-bot[socks]==21.5 diff --git a/tests/components/technove/snapshots/test_diagnostics.ambr b/tests/components/technove/snapshots/test_diagnostics.ambr index 175e8f2022a..e16c51a2e98 100644 --- a/tests/components/technove/snapshots/test_diagnostics.ambr +++ b/tests/components/technove/snapshots/test_diagnostics.ambr @@ -6,7 +6,7 @@ 'current': 23.75, 'energy_session': 12.34, 'energy_total': 1234, - 'high_charge_period_active': False, + 'high_tariff_period_active': False, 'in_sharing_mode': False, 'is_battery_protected': False, 'is_session_active': True, diff --git a/tests/components/technove/snapshots/test_sensor.ambr b/tests/components/technove/snapshots/test_sensor.ambr index dec671b0f34..aaec5667e55 100644 --- a/tests/components/technove/snapshots/test_sensor.ambr +++ b/tests/components/technove/snapshots/test_sensor.ambr @@ -322,7 +322,7 @@ 'plugged_waiting', 'plugged_charging', 'out_of_activation_period', - 'high_charge_period', + 'high_tariff_period', ]), }), 'config_entry_id': , @@ -363,7 +363,7 @@ 'plugged_waiting', 'plugged_charging', 'out_of_activation_period', - 'high_charge_period', + 'high_tariff_period', ]), }), 'context': , From fe396cdf4b0f6e29aa38d2b235485999eb50195d Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Wed, 26 Feb 2025 02:59:13 -0800 Subject: [PATCH 2784/2987] Update python-smarttub dependency to 0.0.39 (#139313) --- homeassistant/components/smarttub/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index d5102f14437..b8d81db0ea5 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/smarttub", "iot_class": "cloud_polling", "loggers": ["smarttub"], - "requirements": ["python-smarttub==0.0.38"] + "requirements": ["python-smarttub==0.0.39"] } diff --git a/requirements_all.txt b/requirements_all.txt index 766addab2b6..11d223a21f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2467,7 +2467,7 @@ python-ripple-api==0.0.3 python-roborock==2.11.1 # homeassistant.components.smarttub -python-smarttub==0.0.38 +python-smarttub==0.0.39 # homeassistant.components.snoo python-snoo==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ca35a30f50b..3d25b71b2a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2000,7 +2000,7 @@ python-rabbitair==0.0.8 python-roborock==2.11.1 # homeassistant.components.smarttub -python-smarttub==0.0.38 +python-smarttub==0.0.39 # homeassistant.components.snoo python-snoo==0.6.0 From b82886a3e1b0edc5044d096ea4ff30810f9f8713 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Wed, 26 Feb 2025 15:25:59 +0300 Subject: [PATCH 2785/2987] Fix anthropic blocking call (#139299) --- homeassistant/components/anthropic/__init__.py | 6 +++++- homeassistant/components/anthropic/config_flow.py | 5 ++++- tests/components/anthropic/test_conversation.py | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index aa6cf509fa1..84c9054b476 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +from functools import partial + import anthropic from homeassistant.config_entries import ConfigEntry @@ -20,7 +22,9 @@ type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient] async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool: """Set up Anthropic from a config entry.""" - client = anthropic.AsyncAnthropic(api_key=entry.data[CONF_API_KEY]) + client = await hass.async_add_executor_job( + partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY]) + ) try: await client.messages.create( model="claude-3-haiku-20240307", diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index fa43a3c4bcc..63a70f31fea 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from functools import partial import logging from types import MappingProxyType from typing import Any @@ -59,7 +60,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - client = anthropic.AsyncAnthropic(api_key=data[CONF_API_KEY]) + client = await hass.async_add_executor_job( + partial(anthropic.AsyncAnthropic, api_key=data[CONF_API_KEY]) + ) await client.messages.create( model="claude-3-haiku-20240307", max_tokens=1, diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index bda9ca32b34..a35df281fb6 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -488,6 +488,7 @@ async def test_unknown_hass_api( CONF_LLM_HASS_API: "non-existing", }, ) + await hass.async_block_till_done() result = await conversation.async_converse( hass, "hello", "1234", Context(), agent_id="conversation.claude" From 4dca4a64b522f0ca8d454ddcfa7fe5329ef028ee Mon Sep 17 00:00:00 2001 From: Ben Bridts Date: Wed, 26 Feb 2025 13:26:12 +0100 Subject: [PATCH 2786/2987] Bump pybotvac to 0.0.26 (#139330) --- homeassistant/components/neato/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/neato/manifest.json b/homeassistant/components/neato/manifest.json index e4b471cb5ac..ef7cda52f19 100644 --- a/homeassistant/components/neato/manifest.json +++ b/homeassistant/components/neato/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/neato", "iot_class": "cloud_polling", "loggers": ["pybotvac"], - "requirements": ["pybotvac==0.0.25"] + "requirements": ["pybotvac==0.0.26"] } diff --git a/requirements_all.txt b/requirements_all.txt index 11d223a21f9..da1df50e3a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1843,7 +1843,7 @@ pyblackbird==0.6 pyblu==2.0.0 # homeassistant.components.neato -pybotvac==0.0.25 +pybotvac==0.0.26 # homeassistant.components.braviatv pybravia==0.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3d25b71b2a8..815f42090a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1520,7 +1520,7 @@ pyblackbird==0.6 pyblu==2.0.0 # homeassistant.components.neato -pybotvac==0.0.25 +pybotvac==0.0.26 # homeassistant.components.braviatv pybravia==0.3.4 From 0f827fbf2238506f15771fa03985f1e3bbf48e79 Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Wed, 26 Feb 2025 13:31:07 +0100 Subject: [PATCH 2787/2987] Bump stookwijzer==1.6.0 (#139332) --- homeassistant/components/stookwijzer/__init__.py | 6 ++---- homeassistant/components/stookwijzer/config_flow.py | 6 ++---- homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/stookwijzer/conftest.py | 2 +- 6 files changed, 8 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index d8b9561bde9..a4a00e4d1b8 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -9,7 +9,6 @@ from stookwijzer import Stookwijzer from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator @@ -43,13 +42,12 @@ async def async_migrate_entry( LOGGER.debug("Migrating from version %s", entry.version) if entry.version == 1: - latitude, longitude = await Stookwijzer.async_transform_coordinates( - async_get_clientsession(hass), + longitude, latitude = await Stookwijzer.async_transform_coordinates( entry.data[CONF_LOCATION][CONF_LATITUDE], entry.data[CONF_LOCATION][CONF_LONGITUDE], ) - if not latitude or not longitude: + if not longitude or not latitude: ir.async_create_issue( hass, DOMAIN, diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index 32b4836763f..52283e4842d 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import LocationSelector from .const import DOMAIN @@ -26,12 +25,11 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" errors = {} if user_input is not None: - latitude, longitude = await Stookwijzer.async_transform_coordinates( - async_get_clientsession(self.hass), + longitude, latitude = await Stookwijzer.async_transform_coordinates( user_input[CONF_LOCATION][CONF_LATITUDE], user_input[CONF_LOCATION][CONF_LONGITUDE], ) - if latitude and longitude: + if longitude and latitude: return self.async_create_entry( title="Stookwijzer", data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude}, diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 8243b903e8d..9b4cea567be 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.4"] + "requirements": ["stookwijzer==1.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index da1df50e3a2..7a60530b12c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2811,7 +2811,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.4 +stookwijzer==1.6.0 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 815f42090a5..af549502560 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2272,7 +2272,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.4 +stookwijzer==1.6.0 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index 3f7303e97f6..95a60e623a3 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -71,8 +71,8 @@ def mock_stookwijzer() -> Generator[MagicMock]: ), ): stookwijzer_mock.async_transform_coordinates.return_value = ( - 200000.123456789, 450000.123456789, + 200000.123456789, ) client = stookwijzer_mock.return_value From ee01aa73b8290d25bc6f70fe28df92bcb8c3d9d6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Feb 2025 13:44:09 +0100 Subject: [PATCH 2788/2987] Improve error message when failing to create backups (#139262) * Improve error message when failing to create backups * Check for expected error message in tests --- homeassistant/components/backup/manager.py | 17 ++- tests/components/backup/test_manager.py | 120 ++++++++++++++++++++- 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index bd970d7708a..317de85b823 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1620,7 +1620,13 @@ class CoreBackupReaderWriter(BackupReaderWriter): """Generate backup contents and return the size.""" if not tar_file_path: tar_file_path = self.temp_backup_dir / f"{backup_data['slug']}.tar" - make_backup_dir(tar_file_path.parent) + try: + make_backup_dir(tar_file_path.parent) + except OSError as err: + raise BackupReaderWriterError( + f"Failed to create dir {tar_file_path.parent}: " + f"{err} ({err.__class__.__name__})" + ) from err excludes = EXCLUDE_FROM_BACKUP if not database_included: @@ -1658,7 +1664,14 @@ class CoreBackupReaderWriter(BackupReaderWriter): file_filter=is_excluded_by_filter, arcname="data", ) - return (tar_file_path, tar_file_path.stat().st_size) + try: + stat_result = tar_file_path.stat() + except OSError as err: + raise BackupReaderWriterError( + f"Error getting size of {tar_file_path}: " + f"{err} ({err.__class__.__name__})" + ) from err + return (tar_file_path, stat_result.st_size) async def async_receive_backup( self, diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 3c72929cfe0..6e626e63748 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -1311,7 +1311,7 @@ async def test_initiate_backup_with_task_error( (1, None, 1, None, 1, None, 1, OSError("Boom!")), ], ) -async def test_initiate_backup_file_error( +async def test_initiate_backup_file_error_upload_to_agents( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, generate_backup_id: MagicMock, @@ -1325,7 +1325,7 @@ async def test_initiate_backup_file_error( unlink_call_count: int, unlink_exception: Exception | None, ) -> None: - """Test file error during generate backup.""" + """Test file error during generate backup, while uploading to agents.""" agent_ids = ["test.remote"] await setup_backup_integration(hass, remote_agents=["test.remote"]) @@ -1418,6 +1418,122 @@ async def test_initiate_backup_file_error( assert unlink_mock.call_count == unlink_call_count +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "mkdir_call_count", + "mkdir_exception", + "atomic_contents_add_call_count", + "atomic_contents_add_exception", + "stat_call_count", + "stat_exception", + "error_message", + ), + [ + (1, OSError("Boom!"), 0, None, 0, None, "Failed to create dir"), + (1, None, 1, OSError("Boom!"), 0, None, "Boom!"), + (1, None, 1, None, 1, OSError("Boom!"), "Error getting size"), + ], +) +async def test_initiate_backup_file_error_create_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + generate_backup_id: MagicMock, + path_glob: MagicMock, + caplog: pytest.LogCaptureFixture, + mkdir_call_count: int, + mkdir_exception: Exception | None, + atomic_contents_add_call_count: int, + atomic_contents_add_exception: Exception | None, + stat_call_count: int, + stat_exception: Exception | None, + error_message: str, +) -> None: + """Test file error during generate backup, while creating backup.""" + agent_ids = ["test.remote"] + + await setup_backup_integration(hass, remote_agents=["test.remote"]) + + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch( + "homeassistant.components.backup.manager.atomic_contents_add", + side_effect=atomic_contents_add_exception, + ) as atomic_contents_add_mock, + patch("pathlib.Path.mkdir", side_effect=mkdir_exception) as mkdir_mock, + patch("pathlib.Path.stat", side_effect=stat_exception) as stat_mock, + ): + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": agent_ids} + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": CreateBackupStage.HOME_ASSISTANT, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", + "stage": None, + "state": CreateBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert atomic_contents_add_mock.call_count == atomic_contents_add_call_count + assert mkdir_mock.call_count == mkdir_call_count + assert stat_mock.call_count == stat_call_count + + assert error_message in caplog.text + + def _mock_local_backup_agent(name: str) -> Mock: local_agent = mock_backup_agent(name) # This makes the local_agent pass isinstance checks for LocalBackupAgent From e591157e37407c117cad6909a8b36d23c6fc6582 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 26 Feb 2025 13:44:43 +0100 Subject: [PATCH 2789/2987] Add translations and icon for Twinkly select entity (#139336) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/twinkly/icons.json | 5 +++++ homeassistant/components/twinkly/select.py | 2 +- homeassistant/components/twinkly/strings.json | 16 ++++++++++++++++ .../twinkly/snapshots/test_select.ambr | 2 +- 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/twinkly/icons.json b/homeassistant/components/twinkly/icons.json index 82c95aebce6..d57d54aa507 100644 --- a/homeassistant/components/twinkly/icons.json +++ b/homeassistant/components/twinkly/icons.json @@ -4,6 +4,11 @@ "light": { "default": "mdi:string-lights" } + }, + "select": { + "mode": { + "default": "mdi:cogs" + } } } } diff --git a/homeassistant/components/twinkly/select.py b/homeassistant/components/twinkly/select.py index 86d9732b8cc..a5283b3f91d 100644 --- a/homeassistant/components/twinkly/select.py +++ b/homeassistant/components/twinkly/select.py @@ -29,7 +29,7 @@ async def async_setup_entry( class TwinklyModeSelect(TwinklyEntity, SelectEntity): """Twinkly Mode Selection.""" - _attr_name = "Mode" + _attr_translation_key = "mode" _attr_options = TWINKLY_MODES def __init__(self, coordinator: TwinklyCoordinator) -> None: diff --git a/homeassistant/components/twinkly/strings.json b/homeassistant/components/twinkly/strings.json index bbc3d67373d..c2e0efef92c 100644 --- a/homeassistant/components/twinkly/strings.json +++ b/homeassistant/components/twinkly/strings.json @@ -20,5 +20,21 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "select": { + "mode": { + "name": "Mode", + "state": { + "color": "Color", + "demo": "Demo", + "effect": "Effect", + "movie": "Uploaded effect", + "off": "[%key:common::state::off%]", + "playlist": "Playlist", + "rt": "Real time" + } + } + } } } diff --git a/tests/components/twinkly/snapshots/test_select.ambr b/tests/components/twinkly/snapshots/test_select.ambr index 26edd4b731d..6700aecd1f2 100644 --- a/tests/components/twinkly/snapshots/test_select.ambr +++ b/tests/components/twinkly/snapshots/test_select.ambr @@ -38,7 +38,7 @@ 'platform': 'twinkly', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'mode', 'unique_id': '00:2d:13:3b:aa:bb_mode', 'unit_of_measurement': None, }) From 2bf592d8aa951977d500f3a66ca341ce058a5e2b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Feb 2025 12:55:03 +0000 Subject: [PATCH 2790/2987] Bump recommended ESPHome Bluetooth proxy version to 2025.2.1 (#139196) --- homeassistant/components/esphome/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index aabebad01b6..eb5f03c4495 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -13,7 +13,7 @@ DEFAULT_ALLOW_SERVICE_CALLS = True DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False -STABLE_BLE_VERSION_STR = "2023.8.0" +STABLE_BLE_VERSION_STR = "2025.2.1" STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", From 0c092f80c7ac95bc1bb696da62d380f69320e95e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Feb 2025 14:09:38 +0100 Subject: [PATCH 2791/2987] Add default_db_url flag to WS command recorder/info (#139333) --- homeassistant/components/recorder/__init__.py | 9 +++-- .../recorder/basic_websocket_api.py | 3 ++ .../components/recorder/test_websocket_api.py | 40 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 5a95ace92cb..7cb71e70f65 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -149,9 +149,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: commit_interval = conf[CONF_COMMIT_INTERVAL] db_max_retries = conf[CONF_DB_MAX_RETRIES] db_retry_wait = conf[CONF_DB_RETRY_WAIT] - db_url = conf.get(CONF_DB_URL) or DEFAULT_URL.format( - hass_config_path=hass.config.path(DEFAULT_DB_FILE) - ) + db_url = conf.get(CONF_DB_URL) or get_default_url(hass) exclude = conf[CONF_EXCLUDE] exclude_event_types: set[EventType[Any] | str] = set( exclude.get(CONF_EVENT_TYPES, []) @@ -200,3 +198,8 @@ async def _async_setup_integration_platform( instance.queue_task(AddRecorderPlatformTask(domain, platform)) await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform) + + +def get_default_url(hass: HomeAssistant) -> str: + """Return the default URL.""" + return DEFAULT_URL.format(hass_config_path=hass.config.path(DEFAULT_DB_FILE)) diff --git a/homeassistant/components/recorder/basic_websocket_api.py b/homeassistant/components/recorder/basic_websocket_api.py index 258f6c63a9d..ce9aa452fae 100644 --- a/homeassistant/components/recorder/basic_websocket_api.py +++ b/homeassistant/components/recorder/basic_websocket_api.py @@ -10,6 +10,7 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import recorder as recorder_helper +from . import get_default_url from .util import get_instance @@ -34,6 +35,7 @@ async def ws_info( await hass.data[recorder_helper.DATA_RECORDER].db_connected instance = get_instance(hass) backlog = instance.backlog + db_in_default_location = instance.db_url == get_default_url(hass) migration_in_progress = instance.migration_in_progress migration_is_live = instance.migration_is_live recording = instance.recording @@ -44,6 +46,7 @@ async def ws_info( recorder_info = { "backlog": backlog, + "db_in_default_location": db_in_default_location, "max_backlog": max_backlog, "migration_in_progress": migration_in_progress, "migration_is_live": migration_is_live, diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 8f93264b682..a4e35bc8753 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -2562,6 +2562,7 @@ async def test_recorder_info( assert response["success"] assert response["result"] == { "backlog": 0, + "db_in_default_location": False, # We never use the default URL in tests "max_backlog": 65000, "migration_in_progress": False, "migration_is_live": False, @@ -2570,6 +2571,44 @@ async def test_recorder_info( } +@pytest.mark.parametrize( + ("db_url", "db_in_default_location"), + [ + ("sqlite:///{config_dir}/home-assistant_v2.db", True), + ("sqlite:///{config_dir}/custom.db", False), + ("mysql://root:root_password@127.0.0.1:3316/homeassistant-test", False), + ], +) +async def test_recorder_info_default_url( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + db_url: str, + db_in_default_location: bool, +) -> None: + """Test getting recorder status.""" + client = await hass_ws_client() + + # Ensure there are no queued events + await async_wait_recording_done(hass) + + with patch.object( + recorder_mock, "db_url", db_url.format(config_dir=hass.config.config_dir) + ): + await client.send_json_auto_id({"type": "recorder/info"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "backlog": 0, + "db_in_default_location": db_in_default_location, + "max_backlog": 65000, + "migration_in_progress": False, + "migration_is_live": False, + "recording": True, + "thread_running": True, + } + + async def test_recorder_info_no_recorder( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -2624,6 +2663,7 @@ async def test_recorder_info_wait_database_connect( assert response["success"] assert response["result"] == { "backlog": ANY, + "db_in_default_location": False, "max_backlog": 65000, "migration_in_progress": False, "migration_is_live": False, From b676c2f61b1da5c42199c946da257d85cb5779b7 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 26 Feb 2025 14:24:19 +0100 Subject: [PATCH 2792/2987] Improve action descriptions of LIFX integration (#139329) Improve action description of lifx integration - fix sentence-casing on two action names - change "Kelvin" unit name to proper uppercase - reference 'Theme' and 'Palette' fields by their friendly names for matching translations - change paint_theme action description to match HA style --- homeassistant/components/lifx/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index 39102d904d5..c407489d52d 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -66,7 +66,7 @@ } }, "set_state": { - "name": "Set State", + "name": "Set state", "description": "Sets a color/brightness and possibly turn the light on/off.", "fields": { "infrared": { @@ -209,11 +209,11 @@ }, "palette": { "name": "Palette", - "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to use for this effect. Overrides the theme attribute." + "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and Kelvin (1500-9000) values to use for this effect. Overrides the 'Theme' attribute." }, "theme": { "name": "[%key:component::lifx::entity::select::theme::name%]", - "description": "Predefined color theme to use for the effect. Overridden by the palette attribute." + "description": "Predefined color theme to use for the effect. Overridden by the 'Palette' attribute." }, "power_on": { "name": "Power on", @@ -243,7 +243,7 @@ }, "palette": { "name": "Palette", - "description": "List of 1 to 6 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to use for this effect." + "description": "List of 1 to 6 colors as hue (0-360), saturation (0-100), brightness (0-100) and Kelvin (1500-9000) values to use for this effect." }, "power_on": { "name": "Power on", @@ -256,16 +256,16 @@ "description": "Stops a running effect." }, "paint_theme": { - "name": "Paint Theme", - "description": "Paint either a provided theme or custom palette across one or more LIFX lights.", + "name": "Paint theme", + "description": "Paints either a provided theme or custom palette across one or more LIFX lights.", "fields": { "palette": { "name": "Palette", - "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to paint across the target lights. Overrides the theme attribute." + "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and Kelvin (1500-9000) values to paint across the target lights. Overrides the 'Theme' attribute." }, "theme": { "name": "[%key:component::lifx::entity::select::theme::name%]", - "description": "Predefined color theme to paint. Overridden by the palette attribute." + "description": "Predefined color theme to paint. Overridden by the 'Palette' attribute." }, "transition": { "name": "Transition", From bb9aba2a7dac8b54831781f3db8ccf6e094ea738 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 26 Feb 2025 14:48:18 +0100 Subject: [PATCH 2793/2987] Bump Music Assistant client to 1.1.1 (#139331) --- .../components/music_assistant/actions.py | 6 +++++- .../components/music_assistant/manifest.json | 2 +- .../components/music_assistant/media_browser.py | 11 +++++++++++ .../components/music_assistant/media_player.py | 4 +++- .../components/music_assistant/schemas.py | 16 ++++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 32 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py index bcd33b7fd6c..bf9a1260362 100644 --- a/homeassistant/components/music_assistant/actions.py +++ b/homeassistant/components/music_assistant/actions.py @@ -48,6 +48,7 @@ from .schemas import ( if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient + from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track from . import MusicAssistantConfigEntry @@ -173,6 +174,9 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: "offset": offset, "order_by": order_by, } + library_result: ( + list[Album] | list[Artist] | list[Track] | list[Radio] | list[Playlist] + ) if media_type == MediaType.ALBUM: library_result = await mass.music.get_library_albums( **base_params, @@ -181,7 +185,7 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: elif media_type == MediaType.ARTIST: library_result = await mass.music.get_library_artists( **base_params, - album_artists_only=call.data.get(ATTR_ALBUM_ARTISTS_ONLY), + album_artists_only=bool(call.data.get(ATTR_ALBUM_ARTISTS_ONLY)), ) elif media_type == MediaType.TRACK: library_result = await mass.music.get_library_tracks( diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index f5cdcf50673..fb8bb9c3ac2 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/music_assistant", "iot_class": "local_push", "loggers": ["music_assistant"], - "requirements": ["music-assistant-client==1.0.8"], + "requirements": ["music-assistant-client==1.1.1"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py index e65d6d4a975..a926e2a0595 100644 --- a/homeassistant/components/music_assistant/media_browser.py +++ b/homeassistant/components/music_assistant/media_browser.py @@ -166,6 +166,8 @@ async def build_playlist_items_listing( ) -> BrowseMedia: """Build Playlist items browse listing.""" playlist = await mass.music.get_item_by_uri(identifier) + if TYPE_CHECKING: + assert playlist.uri is not None return BrowseMedia( media_class=MediaClass.PLAYLIST, @@ -219,6 +221,9 @@ async def build_artist_items_listing( artist = await mass.music.get_item_by_uri(identifier) albums = await mass.music.get_artist_albums(artist.item_id, artist.provider) + if TYPE_CHECKING: + assert artist.uri is not None + return BrowseMedia( media_class=MediaType.ARTIST, media_content_id=artist.uri, @@ -267,6 +272,9 @@ async def build_album_items_listing( album = await mass.music.get_item_by_uri(identifier) tracks = await mass.music.get_album_tracks(album.item_id, album.provider) + if TYPE_CHECKING: + assert album.uri is not None + return BrowseMedia( media_class=MediaType.ALBUM, media_content_id=album.uri, @@ -340,6 +348,9 @@ def build_item( title = item.name img_url = mass.get_media_item_image_url(item) + if TYPE_CHECKING: + assert item.uri is not None + return BrowseMedia( media_class=media_class or item.media_type.value, media_content_id=item.uri, diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 5621b5eb562..bbbda095302 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -20,6 +20,7 @@ from music_assistant_models.enums import ( from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError from music_assistant_models.event import MassEvent from music_assistant_models.media_items import ItemMapping, MediaItemType, Track +from music_assistant_models.player_queue import PlayerQueue import voluptuous as vol from homeassistant.components import media_source @@ -78,7 +79,6 @@ from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient from music_assistant_models.player import Player - from music_assistant_models.player_queue import PlayerQueue SUPPORTED_FEATURES = ( MediaPlayerEntityFeature.PAUSE @@ -473,6 +473,8 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): album=album, media_type=MediaType(media_type) if media_type else None, ): + if TYPE_CHECKING: + assert item.uri is not None media_uris.append(item.uri) if not media_uris: diff --git a/homeassistant/components/music_assistant/schemas.py b/homeassistant/components/music_assistant/schemas.py index d8c4fe1649d..0954d1573e7 100644 --- a/homeassistant/components/music_assistant/schemas.py +++ b/homeassistant/components/music_assistant/schemas.py @@ -65,20 +65,20 @@ MEDIA_ITEM_SCHEMA = vol.Schema( def media_item_dict_from_mass_item( mass: MusicAssistantClient, - item: MediaItemType | ItemMapping | None, -) -> dict[str, Any] | None: + item: MediaItemType | ItemMapping, +) -> dict[str, Any]: """Parse a Music Assistant MediaItem.""" - if not item: - return None - base = { + base: dict[str, Any] = { ATTR_MEDIA_TYPE: item.media_type, ATTR_URI: item.uri, ATTR_NAME: item.name, ATTR_VERSION: item.version, ATTR_IMAGE: mass.get_media_item_image_url(item), } + artists: list[ItemMapping] | None if artists := getattr(item, "artists", None): base[ATTR_ARTISTS] = [media_item_dict_from_mass_item(mass, x) for x in artists] + album: ItemMapping | None if album := getattr(item, "album", None): base[ATTR_ALBUM] = media_item_dict_from_mass_item(mass, album) return base @@ -151,7 +151,11 @@ def queue_item_dict_from_mass_item( ATTR_QUEUE_ITEM_ID: item.queue_item_id, ATTR_NAME: item.name, ATTR_DURATION: item.duration, - ATTR_MEDIA_ITEM: media_item_dict_from_mass_item(mass, item.media_item), + ATTR_MEDIA_ITEM: ( + media_item_dict_from_mass_item(mass, item.media_item) + if item.media_item + else None + ), } if streamdetails := item.streamdetails: base[ATTR_STREAM_TITLE] = streamdetails.stream_title diff --git a/requirements_all.txt b/requirements_all.txt index 7a60530b12c..40df67dc93f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1447,7 +1447,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.0.8 +music-assistant-client==1.1.1 # homeassistant.components.tts mutagen==1.47.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af549502560..029b770512e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1219,7 +1219,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.0.8 +music-assistant-client==1.1.1 # homeassistant.components.tts mutagen==1.47.0 From bb120020a8e9bcdd1789275c5bc722dd3e7230ec Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 15:14:04 +0100 Subject: [PATCH 2794/2987] Refactor SmartThings (#137940) --- CODEOWNERS | 2 + .../components/smartthings/__init__.py | 478 +- .../smartthings/application_credentials.py | 64 + .../components/smartthings/binary_sensor.py | 162 +- .../components/smartthings/climate.py | 510 +- .../components/smartthings/config_flow.py | 313 +- homeassistant/components/smartthings/const.py | 64 +- homeassistant/components/smartthings/cover.py | 139 +- .../components/smartthings/entity.py | 107 +- homeassistant/components/smartthings/fan.py | 128 +- homeassistant/components/smartthings/light.py | 159 +- homeassistant/components/smartthings/lock.py | 42 +- .../components/smartthings/manifest.json | 9 +- homeassistant/components/smartthings/scene.py | 21 +- .../components/smartthings/sensor.py | 547 +- .../components/smartthings/smartapp.py | 545 -- .../components/smartthings/strings.json | 50 +- .../components/smartthings/switch.py | 59 +- .../generated/application_credentials.py | 1 + requirements_all.txt | 5 +- requirements_test_all.txt | 5 +- tests/components/smartthings/__init__.py | 76 +- tests/components/smartthings/conftest.py | 462 +- .../aeotec_home_energy_meter_gen5.json | 31 + .../device_status/base_electric_meter.json | 21 + .../device_status/c2c_arlo_pro_3_switch.json | 82 + .../fixtures/device_status/c2c_shade.json | 50 + .../fixtures/device_status/centralite.json | 60 + .../device_status/contact_sensor.json | 66 + .../device_status/da_ac_rac_000001.json | 879 +++ .../device_status/da_ac_rac_01001.json | 731 +++ .../device_status/da_ks_microwave_0101x.json | 600 ++ .../device_status/da_ref_normal_000001.json | 727 +++ .../device_status/da_rvc_normal_000001.json | 274 + .../device_status/da_wm_dw_000001.json | 786 +++ .../device_status/da_wm_wd_000001.json | 719 +++ .../device_status/da_wm_wm_000001.json | 1243 +++++ .../fixtures/device_status/ecobee_sensor.json | 51 + .../device_status/ecobee_thermostat.json | 98 + .../fixtures/device_status/fake_fan.json | 31 + .../ge_in_wall_smart_dimmer.json | 23 + .../hue_color_temperature_bulb.json | 75 + .../device_status/hue_rgbw_color_bulb.json | 94 + .../fixtures/device_status/iphone.json | 12 + .../device_status/multipurpose_sensor.json | 79 + .../sensibo_airconditioner_1.json | 57 + .../fixtures/device_status/smart_plug.json | 43 + .../fixtures/device_status/sonos_player.json | 259 + .../device_status/vd_network_audio_002s.json | 164 + .../fixtures/device_status/vd_stv_2017_k.json | 266 + .../device_status/virtual_thermostat.json | 97 + .../fixtures/device_status/virtual_valve.json | 13 + .../device_status/virtual_water_sensor.json | 28 + .../yale_push_button_deadbolt_lock.json | 110 + .../aeotec_home_energy_meter_gen5.json | 70 + .../fixtures/devices/base_electric_meter.json | 62 + .../devices/c2c_arlo_pro_3_switch.json | 79 + .../fixtures/devices/c2c_shade.json | 59 + .../fixtures/devices/centralite.json | 67 + .../fixtures/devices/contact_sensor.json | 71 + .../fixtures/devices/da_ac_rac_000001.json | 311 ++ .../fixtures/devices/da_ac_rac_01001.json | 264 + .../devices/da_ks_microwave_0101x.json | 176 + .../devices/da_ref_normal_000001.json | 412 ++ .../devices/da_rvc_normal_000001.json | 119 + .../fixtures/devices/da_wm_dw_000001.json | 168 + .../fixtures/devices/da_wm_wd_000001.json | 204 + .../fixtures/devices/da_wm_wm_000001.json | 260 + .../fixtures/devices/ecobee_sensor.json | 64 + .../fixtures/devices/ecobee_thermostat.json | 80 + .../fixtures/devices/fake_fan.json | 50 + .../devices/ge_in_wall_smart_dimmer.json | 65 + .../devices/hue_color_temperature_bulb.json | 73 + .../fixtures/devices/hue_rgbw_color_bulb.json | 81 + .../smartthings/fixtures/devices/iphone.json | 41 + .../fixtures/devices/multipurpose_sensor.json | 78 + .../devices/sensibo_airconditioner_1.json | 64 + .../fixtures/devices/smart_plug.json | 59 + .../fixtures/devices/sonos_player.json | 82 + .../devices/vd_network_audio_002s.json | 109 + .../fixtures/devices/vd_stv_2017_k.json | 148 + .../fixtures/devices/virtual_thermostat.json | 69 + .../fixtures/devices/virtual_valve.json | 49 + .../devices/virtual_water_sensor.json | 53 + .../yale_push_button_deadbolt_lock.json | 67 + .../smartthings/fixtures/locations.json | 9 + .../smartthings/fixtures/scenes.json | 34 + .../snapshots/test_binary_sensor.ambr | 529 ++ .../smartthings/snapshots/test_climate.ambr | 356 ++ .../smartthings/snapshots/test_cover.ambr | 100 + .../smartthings/snapshots/test_fan.ambr | 67 + .../smartthings/snapshots/test_init.ambr | 1024 ++++ .../smartthings/snapshots/test_light.ambr | 267 + .../smartthings/snapshots/test_lock.ambr | 50 + .../smartthings/snapshots/test_scene.ambr | 101 + .../smartthings/snapshots/test_sensor.ambr | 4857 +++++++++++++++++ .../smartthings/snapshots/test_switch.ambr | 471 ++ .../smartthings/test_binary_sensor.py | 158 +- tests/components/smartthings/test_climate.py | 1382 ++--- .../smartthings/test_config_flow.py | 1179 ++-- tests/components/smartthings/test_cover.py | 369 +- tests/components/smartthings/test_fan.py | 521 +- tests/components/smartthings/test_init.py | 571 +- tests/components/smartthings/test_light.py | 561 +- tests/components/smartthings/test_lock.py | 174 +- tests/components/smartthings/test_scene.py | 65 +- tests/components/smartthings/test_sensor.py | 306 +- tests/components/smartthings/test_smartapp.py | 186 - tests/components/smartthings/test_switch.py | 166 +- 109 files changed, 22599 insertions(+), 6175 deletions(-) create mode 100644 homeassistant/components/smartthings/application_credentials.py delete mode 100644 homeassistant/components/smartthings/smartapp.py create mode 100644 tests/components/smartthings/fixtures/device_status/aeotec_home_energy_meter_gen5.json create mode 100644 tests/components/smartthings/fixtures/device_status/base_electric_meter.json create mode 100644 tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json create mode 100644 tests/components/smartthings/fixtures/device_status/c2c_shade.json create mode 100644 tests/components/smartthings/fixtures/device_status/centralite.json create mode 100644 tests/components/smartthings/fixtures/device_status/contact_sensor.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_ks_microwave_0101x.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_rvc_normal_000001.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_wm_dw_000001.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_wm_wd_000001.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_wm_wm_000001.json create mode 100644 tests/components/smartthings/fixtures/device_status/ecobee_sensor.json create mode 100644 tests/components/smartthings/fixtures/device_status/ecobee_thermostat.json create mode 100644 tests/components/smartthings/fixtures/device_status/fake_fan.json create mode 100644 tests/components/smartthings/fixtures/device_status/ge_in_wall_smart_dimmer.json create mode 100644 tests/components/smartthings/fixtures/device_status/hue_color_temperature_bulb.json create mode 100644 tests/components/smartthings/fixtures/device_status/hue_rgbw_color_bulb.json create mode 100644 tests/components/smartthings/fixtures/device_status/iphone.json create mode 100644 tests/components/smartthings/fixtures/device_status/multipurpose_sensor.json create mode 100644 tests/components/smartthings/fixtures/device_status/sensibo_airconditioner_1.json create mode 100644 tests/components/smartthings/fixtures/device_status/smart_plug.json create mode 100644 tests/components/smartthings/fixtures/device_status/sonos_player.json create mode 100644 tests/components/smartthings/fixtures/device_status/vd_network_audio_002s.json create mode 100644 tests/components/smartthings/fixtures/device_status/vd_stv_2017_k.json create mode 100644 tests/components/smartthings/fixtures/device_status/virtual_thermostat.json create mode 100644 tests/components/smartthings/fixtures/device_status/virtual_valve.json create mode 100644 tests/components/smartthings/fixtures/device_status/virtual_water_sensor.json create mode 100644 tests/components/smartthings/fixtures/device_status/yale_push_button_deadbolt_lock.json create mode 100644 tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json create mode 100644 tests/components/smartthings/fixtures/devices/base_electric_meter.json create mode 100644 tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json create mode 100644 tests/components/smartthings/fixtures/devices/c2c_shade.json create mode 100644 tests/components/smartthings/fixtures/devices/centralite.json create mode 100644 tests/components/smartthings/fixtures/devices/contact_sensor.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/ecobee_sensor.json create mode 100644 tests/components/smartthings/fixtures/devices/ecobee_thermostat.json create mode 100644 tests/components/smartthings/fixtures/devices/fake_fan.json create mode 100644 tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json create mode 100644 tests/components/smartthings/fixtures/devices/hue_color_temperature_bulb.json create mode 100644 tests/components/smartthings/fixtures/devices/hue_rgbw_color_bulb.json create mode 100644 tests/components/smartthings/fixtures/devices/iphone.json create mode 100644 tests/components/smartthings/fixtures/devices/multipurpose_sensor.json create mode 100644 tests/components/smartthings/fixtures/devices/sensibo_airconditioner_1.json create mode 100644 tests/components/smartthings/fixtures/devices/smart_plug.json create mode 100644 tests/components/smartthings/fixtures/devices/sonos_player.json create mode 100644 tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json create mode 100644 tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json create mode 100644 tests/components/smartthings/fixtures/devices/virtual_thermostat.json create mode 100644 tests/components/smartthings/fixtures/devices/virtual_valve.json create mode 100644 tests/components/smartthings/fixtures/devices/virtual_water_sensor.json create mode 100644 tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json create mode 100644 tests/components/smartthings/fixtures/locations.json create mode 100644 tests/components/smartthings/fixtures/scenes.json create mode 100644 tests/components/smartthings/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/smartthings/snapshots/test_climate.ambr create mode 100644 tests/components/smartthings/snapshots/test_cover.ambr create mode 100644 tests/components/smartthings/snapshots/test_fan.ambr create mode 100644 tests/components/smartthings/snapshots/test_init.ambr create mode 100644 tests/components/smartthings/snapshots/test_light.ambr create mode 100644 tests/components/smartthings/snapshots/test_lock.ambr create mode 100644 tests/components/smartthings/snapshots/test_scene.ambr create mode 100644 tests/components/smartthings/snapshots/test_sensor.ambr create mode 100644 tests/components/smartthings/snapshots/test_switch.ambr delete mode 100644 tests/components/smartthings/test_smartapp.py diff --git a/CODEOWNERS b/CODEOWNERS index 1052a58fe88..3366bfb0885 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1401,6 +1401,8 @@ build.json @home-assistant/supervisor /tests/components/smappee/ @bsmappee /homeassistant/components/smart_meter_texas/ @grahamwetzler /tests/components/smart_meter_texas/ @grahamwetzler +/homeassistant/components/smartthings/ @joostlek +/tests/components/smartthings/ @joostlek /homeassistant/components/smarttub/ @mdz /tests/components/smarttub/ @mdz /homeassistant/components/smarty/ @z0mbieprocess diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 2914851ccbf..d580e36e45e 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -2,416 +2,144 @@ from __future__ import annotations -import asyncio -from collections.abc import Iterable -from http import HTTPStatus -import importlib +from dataclasses import dataclass import logging +from typing import TYPE_CHECKING -from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError -from pysmartapp.event import EVENT_TYPE_DEVICE -from pysmartthings import APIInvalidGrant, Attribute, Capability, SmartThings +from aiohttp import ClientError +from pysmartthings import ( + Attribute, + Capability, + Device, + Scene, + SmartThings, + SmartThingsAuthenticationFailedError, + Status, +) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryError, - ConfigEntryNotReady, -) -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import async_get_loaded_integration -from homeassistant.setup import SetupPhases, async_pause_setup +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) -from .config_flow import SmartThingsFlowHandler # noqa: F401 -from .const import ( - CONF_APP_ID, - CONF_INSTALLED_APP_ID, - CONF_LOCATION_ID, - CONF_REFRESH_TOKEN, - DATA_BROKERS, - DATA_MANAGER, - DOMAIN, - EVENT_BUTTON, - PLATFORMS, - SIGNAL_SMARTTHINGS_UPDATE, - TOKEN_REFRESH_INTERVAL, -) -from .smartapp import ( - format_unique_id, - setup_smartapp, - setup_smartapp_endpoint, - smartapp_sync_subscriptions, - unload_smartapp_endpoint, - validate_installed_app, - validate_webhook_requirements, -) +from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, MAIN, OLD_DATA _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +@dataclass +class SmartThingsData: + """Define an object to hold SmartThings data.""" + + devices: dict[str, FullDevice] + scenes: dict[str, Scene] + client: SmartThings -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Initialize the SmartThings platform.""" - await setup_smartapp_endpoint(hass, False) - return True +@dataclass +class FullDevice: + """Define an object to hold device data.""" + + device: Device + status: dict[str, dict[Capability, dict[Attribute, Status]]] -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Handle migration of a previous version config entry. +type SmartThingsConfigEntry = ConfigEntry[SmartThingsData] - A config entry created under a previous version must go through the - integration setup again so we can properly retrieve the needed data - elements. Force this by removing the entry and triggering a new flow. - """ - # Remove the entry which will invoke the callback to delete the app. - hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) - # only create new flow if there isn't a pending one for SmartThings. - if not hass.config_entries.flow.async_progress_by_handler(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) - ) - - # Return False because it could not be migrated. - return False +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.LOCK, + Platform.SCENE, + Platform.SENSOR, + Platform.SWITCH, +] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) -> bool: """Initialize config entry which represents an installed SmartApp.""" - # For backwards compat - if entry.unique_id is None: - hass.config_entries.async_update_entry( - entry, - unique_id=format_unique_id( - entry.data[CONF_APP_ID], entry.data[CONF_LOCATION_ID] - ), - ) - - if not validate_webhook_requirements(hass): - _LOGGER.warning( - "The 'base_url' of the 'http' integration must be configured and start with" - " 'https://'" - ) - return False - - api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN]) - - # Ensure platform modules are loaded since the DeviceBroker will - # import them below and we want them to be cached ahead of time - # so the integration does not do blocking I/O in the event loop - # to import the modules. - await async_get_loaded_integration(hass, DOMAIN).async_get_platforms(PLATFORMS) + # The oauth smartthings entry will have a token, older ones are version 3 + # after migration but still require reauthentication + if CONF_TOKEN not in entry.data: + raise ConfigEntryAuthFailed("Config entry missing token") + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) try: - # See if the app is already setup. This occurs when there are - # installs in multiple SmartThings locations (valid use-case) - manager = hass.data[DOMAIN][DATA_MANAGER] - smart_app = manager.smartapps.get(entry.data[CONF_APP_ID]) - if not smart_app: - # Validate and setup the app. - app = await api.app(entry.data[CONF_APP_ID]) - smart_app = setup_smartapp(hass, app) + await session.async_ensure_token_valid() + except ClientError as err: + raise ConfigEntryNotReady from err - # Validate and retrieve the installed app. - installed_app = await validate_installed_app( - api, entry.data[CONF_INSTALLED_APP_ID] - ) + client = SmartThings(session=async_get_clientsession(hass)) - # Get scenes - scenes = await async_get_entry_scenes(entry, api) + async def _refresh_token() -> str: + await session.async_ensure_token_valid() + token = session.token[CONF_ACCESS_TOKEN] + if TYPE_CHECKING: + assert isinstance(token, str) + return token - # Get SmartApp token to sync subscriptions - token = await api.generate_tokens( - entry.data[CONF_CLIENT_ID], - entry.data[CONF_CLIENT_SECRET], - entry.data[CONF_REFRESH_TOKEN], - ) - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_REFRESH_TOKEN: token.refresh_token} - ) + client.refresh_token_function = _refresh_token - # Get devices and their current status - devices = await api.devices(location_ids=[installed_app.location_id]) + device_status: dict[str, FullDevice] = {} + try: + devices = await client.get_devices() + for device in devices: + status = await client.get_device_status(device.device_id) + device_status[device.device_id] = FullDevice(device=device, status=status) + except SmartThingsAuthenticationFailedError as err: + raise ConfigEntryAuthFailed from err - async def retrieve_device_status(device): - try: - await device.status.refresh() - except ClientResponseError: - _LOGGER.debug( - ( - "Unable to update status for device: %s (%s), the device will" - " be excluded" - ), - device.label, - device.device_id, - exc_info=True, - ) - devices.remove(device) + scenes = { + scene.scene_id: scene + for scene in await client.get_scenes(location_id=entry.data[CONF_LOCATION_ID]) + } - await asyncio.gather(*(retrieve_device_status(d) for d in devices.copy())) + entry.runtime_data = SmartThingsData( + devices={ + device_id: device + for device_id, device in device_status.items() + if MAIN in device.status + }, + client=client, + scenes=scenes, + ) - # Sync device subscriptions - await smartapp_sync_subscriptions( - hass, - token.access_token, - installed_app.location_id, - installed_app.installed_app_id, - devices, - ) - - # Setup device broker - with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PLATFORMS): - # DeviceBroker has a side effect of importing platform - # modules when its created. In the future this should be - # refactored to not do this. - broker = await hass.async_add_import_executor_job( - DeviceBroker, hass, entry, token, smart_app, devices, scenes - ) - broker.connect() - hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker - - except APIInvalidGrant as ex: - raise ConfigEntryAuthFailed from ex - except ClientResponseError as ex: - if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): - raise ConfigEntryError( - "The access token is no longer valid. Please remove the integration and set up again." - ) from ex - _LOGGER.debug(ex, exc_info=True) - raise ConfigEntryNotReady from ex - except (ClientConnectionError, RuntimeWarning) as ex: - _LOGGER.debug(ex, exc_info=True) - raise ConfigEntryNotReady from ex + entry.async_create_background_task( + hass, + client.subscribe( + entry.data[CONF_LOCATION_ID], entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID] + ), + "smartthings_webhook", + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True -async def async_get_entry_scenes(entry: ConfigEntry, api): - """Get the scenes within an integration.""" - try: - return await api.scenes(location_id=entry.data[CONF_LOCATION_ID]) - except ClientResponseError as ex: - if ex.status == HTTPStatus.FORBIDDEN: - _LOGGER.exception( - ( - "Unable to load scenes for configuration entry '%s' because the" - " access token does not have the required access" - ), - entry.title, - ) - else: - raise - return [] - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SmartThingsConfigEntry +) -> bool: """Unload a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS].pop(entry.entry_id, None) - if broker: - broker.disconnect() - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Perform clean-up when entry is being removed.""" - api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN]) +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle config entry migration.""" - # Remove the installed_app, which if already removed raises a HTTPStatus.FORBIDDEN error. - installed_app_id = entry.data[CONF_INSTALLED_APP_ID] - try: - await api.delete_installed_app(installed_app_id) - except ClientResponseError as ex: - if ex.status == HTTPStatus.FORBIDDEN: - _LOGGER.debug( - "Installed app %s has already been removed", - installed_app_id, - exc_info=True, - ) - else: - raise - _LOGGER.debug("Removed installed app %s", installed_app_id) - - # Remove the app if not referenced by other entries, which if already - # removed raises a HTTPStatus.FORBIDDEN error. - all_entries = hass.config_entries.async_entries(DOMAIN) - app_id = entry.data[CONF_APP_ID] - app_count = sum(1 for entry in all_entries if entry.data[CONF_APP_ID] == app_id) - if app_count > 1: - _LOGGER.debug( - ( - "App %s was not removed because it is in use by other configuration" - " entries" - ), - app_id, - ) - return - # Remove the app - try: - await api.delete_app(app_id) - except ClientResponseError as ex: - if ex.status == HTTPStatus.FORBIDDEN: - _LOGGER.debug("App %s has already been removed", app_id, exc_info=True) - else: - raise - _LOGGER.debug("Removed app %s", app_id) - - if len(all_entries) == 1: - await unload_smartapp_endpoint(hass) - - -class DeviceBroker: - """Manages an individual SmartThings config entry.""" - - def __init__( - self, - hass: HomeAssistant, - entry: ConfigEntry, - token, - smart_app, - devices: Iterable, - scenes: Iterable, - ) -> None: - """Create a new instance of the DeviceBroker.""" - self._hass = hass - self._entry = entry - self._installed_app_id = entry.data[CONF_INSTALLED_APP_ID] - self._smart_app = smart_app - self._token = token - self._event_disconnect = None - self._regenerate_token_remove = None - self._assignments = self._assign_capabilities(devices) - self.devices = {device.device_id: device for device in devices} - self.scenes = {scene.scene_id: scene for scene in scenes} - - def _assign_capabilities(self, devices: Iterable): - """Assign platforms to capabilities.""" - assignments = {} - for device in devices: - capabilities = device.capabilities.copy() - slots = {} - for platform in PLATFORMS: - platform_module = importlib.import_module( - f".{platform}", self.__module__ - ) - if not hasattr(platform_module, "get_capabilities"): - continue - assigned = platform_module.get_capabilities(capabilities) - if not assigned: - continue - # Draw-down capabilities and set slot assignment - for capability in assigned: - if capability not in capabilities: - continue - capabilities.remove(capability) - slots[capability] = platform - assignments[device.device_id] = slots - return assignments - - def connect(self): - """Connect handlers/listeners for device/lifecycle events.""" - - # Setup interval to regenerate the refresh token on a periodic basis. - # Tokens expire in 30 days and once expired, cannot be recovered. - async def regenerate_refresh_token(now): - """Generate a new refresh token and update the config entry.""" - await self._token.refresh( - self._entry.data[CONF_CLIENT_ID], - self._entry.data[CONF_CLIENT_SECRET], - ) - self._hass.config_entries.async_update_entry( - self._entry, - data={ - **self._entry.data, - CONF_REFRESH_TOKEN: self._token.refresh_token, - }, - ) - _LOGGER.debug( - "Regenerated refresh token for installed app: %s", - self._installed_app_id, - ) - - self._regenerate_token_remove = async_track_time_interval( - self._hass, regenerate_refresh_token, TOKEN_REFRESH_INTERVAL + if entry.version < 3: + # We keep the old data around, so we can use that to clean up the webhook in the future + hass.config_entries.async_update_entry( + entry, version=3, data={OLD_DATA: dict(entry.data)} ) - # Connect handler to incoming device events - self._event_disconnect = self._smart_app.connect_event(self._event_handler) - - def disconnect(self): - """Disconnects handlers/listeners for device/lifecycle events.""" - if self._regenerate_token_remove: - self._regenerate_token_remove() - if self._event_disconnect: - self._event_disconnect() - - def get_assigned(self, device_id: str, platform: str): - """Get the capabilities assigned to the platform.""" - slots = self._assignments.get(device_id, {}) - return [key for key, value in slots.items() if value == platform] - - def any_assigned(self, device_id: str, platform: str): - """Return True if the platform has any assigned capabilities.""" - slots = self._assignments.get(device_id, {}) - return any(value for value in slots.values() if value == platform) - - async def _event_handler(self, req, resp, app): - """Broker for incoming events.""" - # Do not process events received from a different installed app - # under the same parent SmartApp (valid use-scenario) - if req.installed_app_id != self._installed_app_id: - return - - updated_devices = set() - for evt in req.events: - if evt.event_type != EVENT_TYPE_DEVICE: - continue - if not (device := self.devices.get(evt.device_id)): - continue - device.status.apply_attribute_update( - evt.component_id, - evt.capability, - evt.attribute, - evt.value, - data=evt.data, - ) - - # Fire events for buttons - if ( - evt.capability == Capability.button - and evt.attribute == Attribute.button - ): - data = { - "component_id": evt.component_id, - "device_id": evt.device_id, - "location_id": evt.location_id, - "value": evt.value, - "name": device.label, - "data": evt.data, - } - self._hass.bus.async_fire(EVENT_BUTTON, data) - _LOGGER.debug("Fired button event: %s", data) - else: - data = { - "location_id": evt.location_id, - "device_id": evt.device_id, - "component_id": evt.component_id, - "capability": evt.capability, - "attribute": evt.attribute, - "value": evt.value, - "data": evt.data, - } - _LOGGER.debug("Push update received: %s", data) - - updated_devices.add(device.device_id) - - async_dispatcher_send(self._hass, SIGNAL_SMARTTHINGS_UPDATE, updated_devices) + return True diff --git a/homeassistant/components/smartthings/application_credentials.py b/homeassistant/components/smartthings/application_credentials.py new file mode 100644 index 00000000000..1e637c6bd12 --- /dev/null +++ b/homeassistant/components/smartthings/application_credentials.py @@ -0,0 +1,64 @@ +"""Application credentials platform for SmartThings.""" + +from json import JSONDecodeError +import logging +from typing import cast + +from aiohttp import BasicAuth, ClientError + +from homeassistant.components.application_credentials import ( + AuthImplementation, + AuthorizationServer, + ClientCredential, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2Implementation + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> AbstractOAuth2Implementation: + """Return auth implementation.""" + return SmartThingsOAuth2Implementation( + hass, + DOMAIN, + credential, + authorization_server=AuthorizationServer( + authorize_url="https://api.smartthings.com/oauth/authorize", + token_url="https://auth-global.api.smartthings.com/oauth/token", + ), + ) + + +class SmartThingsOAuth2Implementation(AuthImplementation): + """Oauth2 implementation that only uses the external url.""" + + async def _token_request(self, data: dict) -> dict: + """Make a token request.""" + session = async_get_clientsession(self.hass) + + resp = await session.post( + self.token_url, + data=data, + auth=BasicAuth(self.client_id, self.client_secret), + ) + if resp.status >= 400: + try: + error_response = await resp.json() + except (ClientError, JSONDecodeError): + error_response = {} + error_code = error_response.get("error", "unknown") + error_description = error_response.get("error_description", "unknown error") + _LOGGER.error( + "Token request for %s failed (%s): %s", + self.domain, + error_code, + error_description, + ) + resp.raise_for_status() + return cast(dict, await resp.json()) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 6b511c86677..6afa4edcf17 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -2,84 +2,144 @@ from __future__ import annotations -from collections.abc import Sequence +from dataclasses import dataclass -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, SmartThings from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, 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 DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity -CAPABILITY_TO_ATTRIB = { - Capability.acceleration_sensor: Attribute.acceleration, - Capability.contact_sensor: Attribute.contact, - Capability.filter_status: Attribute.filter_status, - Capability.motion_sensor: Attribute.motion, - Capability.presence_sensor: Attribute.presence, - Capability.sound_sensor: Attribute.sound, - Capability.tamper_alert: Attribute.tamper, - Capability.valve: Attribute.valve, - Capability.water_sensor: Attribute.water, -} -ATTRIB_TO_CLASS = { - Attribute.acceleration: BinarySensorDeviceClass.MOVING, - Attribute.contact: BinarySensorDeviceClass.OPENING, - Attribute.filter_status: BinarySensorDeviceClass.PROBLEM, - Attribute.motion: BinarySensorDeviceClass.MOTION, - Attribute.presence: BinarySensorDeviceClass.PRESENCE, - Attribute.sound: BinarySensorDeviceClass.SOUND, - Attribute.tamper: BinarySensorDeviceClass.PROBLEM, - Attribute.valve: BinarySensorDeviceClass.OPENING, - Attribute.water: BinarySensorDeviceClass.MOISTURE, -} -ATTRIB_TO_ENTTIY_CATEGORY = { - Attribute.tamper: EntityCategory.DIAGNOSTIC, + +@dataclass(frozen=True, kw_only=True) +class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describe a SmartThings binary sensor entity.""" + + is_on_key: str + + +CAPABILITY_TO_SENSORS: dict[ + Capability, dict[Attribute, SmartThingsBinarySensorEntityDescription] +] = { + Capability.ACCELERATION_SENSOR: { + Attribute.ACCELERATION: SmartThingsBinarySensorEntityDescription( + key=Attribute.ACCELERATION, + device_class=BinarySensorDeviceClass.MOVING, + is_on_key="active", + ) + }, + Capability.CONTACT_SENSOR: { + Attribute.CONTACT: SmartThingsBinarySensorEntityDescription( + key=Attribute.CONTACT, + device_class=BinarySensorDeviceClass.DOOR, + is_on_key="open", + ) + }, + Capability.FILTER_STATUS: { + Attribute.FILTER_STATUS: SmartThingsBinarySensorEntityDescription( + key=Attribute.FILTER_STATUS, + device_class=BinarySensorDeviceClass.PROBLEM, + is_on_key="replace", + ) + }, + Capability.MOTION_SENSOR: { + Attribute.MOTION: SmartThingsBinarySensorEntityDescription( + key=Attribute.MOTION, + device_class=BinarySensorDeviceClass.MOTION, + is_on_key="active", + ) + }, + Capability.PRESENCE_SENSOR: { + Attribute.PRESENCE: SmartThingsBinarySensorEntityDescription( + key=Attribute.PRESENCE, + device_class=BinarySensorDeviceClass.PRESENCE, + is_on_key="present", + ) + }, + Capability.SOUND_SENSOR: { + Attribute.SOUND: SmartThingsBinarySensorEntityDescription( + key=Attribute.SOUND, + device_class=BinarySensorDeviceClass.SOUND, + is_on_key="detected", + ) + }, + Capability.TAMPER_ALERT: { + Attribute.TAMPER: SmartThingsBinarySensorEntityDescription( + key=Attribute.TAMPER, + device_class=BinarySensorDeviceClass.PROBLEM, + is_on_key="detected", + entity_category=EntityCategory.DIAGNOSTIC, + ) + }, + Capability.VALVE: { + Attribute.VALVE: SmartThingsBinarySensorEntityDescription( + key=Attribute.VALVE, + device_class=BinarySensorDeviceClass.OPENING, + is_on_key="open", + ) + }, + Capability.WATER_SENSOR: { + Attribute.WATER: SmartThingsBinarySensorEntityDescription( + key=Attribute.WATER, + device_class=BinarySensorDeviceClass.MOISTURE, + is_on_key="wet", + ) + }, } async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add binary sensors for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] - sensors = [] - for device in broker.devices.values(): - for capability in broker.get_assigned(device.device_id, "binary_sensor"): - attrib = CAPABILITY_TO_ATTRIB[capability] - sensors.append(SmartThingsBinarySensor(device, attrib)) - async_add_entities(sensors) - - -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - return [ - capability for capability in CAPABILITY_TO_ATTRIB if capability in capabilities - ] + entry_data = entry.runtime_data + async_add_entities( + SmartThingsBinarySensor( + entry_data.client, device, description, capability, attribute + ) + for device in entry_data.devices.values() + for capability, attribute_map in CAPABILITY_TO_SENSORS.items() + if capability in device.status[MAIN] + for attribute, description in attribute_map.items() + ) class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): """Define a SmartThings Binary Sensor.""" - def __init__(self, device, attribute): + entity_description: SmartThingsBinarySensorEntityDescription + + def __init__( + self, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsBinarySensorEntityDescription, + capability: Capability, + attribute: Attribute, + ) -> None: """Init the class.""" - super().__init__(device) + super().__init__(client, device, {capability}) self._attribute = attribute - self._attr_name = f"{device.label} {attribute}" - self._attr_unique_id = f"{device.device_id}.{attribute}" - self._attr_device_class = ATTRIB_TO_CLASS[attribute] - self._attr_entity_category = ATTRIB_TO_ENTTIY_CATEGORY.get(attribute) + self.capability = capability + self.entity_description = entity_description + self._attr_name = f"{device.device.label} {attribute}" + self._attr_unique_id = f"{device.device.device_id}.{attribute}" @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self._device.status.is_on(self._attribute) + return ( + self.get_attribute_value(self.capability, self._attribute) + == self.entity_description.is_on_key + ) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 238f8015620..2e05fb2fc4f 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -3,17 +3,15 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable, Sequence import logging from typing import Any -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command, SmartThings from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - DOMAIN as CLIMATE_DOMAIN, SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, @@ -23,12 +21,12 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity ATTR_OPERATION_STATE = "operation_state" @@ -97,124 +95,106 @@ UNIT_MAP = {"C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT} _LOGGER = logging.getLogger(__name__) +AC_CAPABILITIES = [ + Capability.AIR_CONDITIONER_MODE, + Capability.AIR_CONDITIONER_FAN_MODE, + Capability.SWITCH, + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_COOLING_SETPOINT, +] + +THERMOSTAT_CAPABILITIES = [ + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_HEATING_SETPOINT, + Capability.THERMOSTAT_MODE, +] + + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add climate entities for a config entry.""" - ac_capabilities = [ - Capability.air_conditioner_mode, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, + entry_data = entry.runtime_data + entities: list[ClimateEntity] = [ + SmartThingsAirConditioner(entry_data.client, device) + for device in entry_data.devices.values() + if all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) ] - - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] - entities: list[ClimateEntity] = [] - for device in broker.devices.values(): - if not broker.any_assigned(device.device_id, CLIMATE_DOMAIN): - continue - if all(capability in device.capabilities for capability in ac_capabilities): - entities.append(SmartThingsAirConditioner(device)) - else: - entities.append(SmartThingsThermostat(device)) - async_add_entities(entities, True) - - -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - supported = [ - Capability.air_conditioner_mode, - Capability.demand_response_load_control, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.thermostat, - Capability.thermostat_cooling_setpoint, - Capability.thermostat_fan_mode, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - Capability.thermostat_operating_state, - ] - # Can have this legacy/deprecated capability - if Capability.thermostat in capabilities: - return supported - # Or must have all of these thermostat capabilities - thermostat_capabilities = [ - Capability.temperature_measurement, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - ] - if all(capability in capabilities for capability in thermostat_capabilities): - return supported - # Or must have all of these A/C capabilities - ac_capabilities = [ - Capability.air_conditioner_mode, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - ] - if all(capability in capabilities for capability in ac_capabilities): - return supported - return None + entities.extend( + SmartThingsThermostat(entry_data.client, device) + for device in entry_data.devices.values() + if all( + capability in device.status[MAIN] for capability in THERMOSTAT_CAPABILITIES + ) + ) + async_add_entities(entities) class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): """Define a SmartThings climate entities.""" - def __init__(self, device): + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" - super().__init__(device) + super().__init__( + client, + device, + { + Capability.THERMOSTAT_FAN_MODE, + Capability.THERMOSTAT_MODE, + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_HEATING_SETPOINT, + Capability.THERMOSTAT_OPERATING_STATE, + Capability.THERMOSTAT_COOLING_SETPOINT, + Capability.RELATIVE_HUMIDITY_MEASUREMENT, + }, + ) self._attr_supported_features = self._determine_features() - self._hvac_mode = None - self._hvac_modes = None - def _determine_features(self): + def _determine_features(self) -> ClimateEntityFeature: flags = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - if self._device.get_capability( - Capability.thermostat_fan_mode, Capability.thermostat + if self.get_attribute_value( + Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE ): flags |= ClimateEntityFeature.FAN_MODE return flags async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - await self._device.set_thermostat_fan_mode(fan_mode, set_status=True) - - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command( + Capability.THERMOSTAT_FAN_MODE, + Command.SET_THERMOSTAT_FAN_MODE, + argument=fan_mode, + ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" - mode = STATE_TO_MODE[hvac_mode] - await self._device.set_thermostat_mode(mode, set_status=True) - - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command( + Capability.THERMOSTAT_MODE, + Command.SET_THERMOSTAT_MODE, + argument=STATE_TO_MODE[hvac_mode], + ) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new operation mode and target temperatures.""" + hvac_mode = self.hvac_mode # Operation state if operation_state := kwargs.get(ATTR_HVAC_MODE): - mode = STATE_TO_MODE[operation_state] - await self._device.set_thermostat_mode(mode, set_status=True) - await self.async_update() + await self.async_set_hvac_mode(operation_state) + hvac_mode = operation_state # Heat/cool setpoint heating_setpoint = None cooling_setpoint = None - if self.hvac_mode == HVACMode.HEAT: + if hvac_mode == HVACMode.HEAT: heating_setpoint = kwargs.get(ATTR_TEMPERATURE) - elif self.hvac_mode == HVACMode.COOL: + elif hvac_mode == HVACMode.COOL: cooling_setpoint = kwargs.get(ATTR_TEMPERATURE) else: heating_setpoint = kwargs.get(ATTR_TARGET_TEMP_LOW) @@ -222,135 +202,145 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): tasks = [] if heating_setpoint is not None: tasks.append( - self._device.set_heating_setpoint( - round(heating_setpoint, 3), set_status=True + self.execute_device_command( + Capability.THERMOSTAT_HEATING_SETPOINT, + Command.SET_HEATING_SETPOINT, + argument=round(heating_setpoint, 3), ) ) if cooling_setpoint is not None: tasks.append( - self._device.set_cooling_setpoint( - round(cooling_setpoint, 3), set_status=True + self.execute_device_command( + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + argument=round(cooling_setpoint, 3), ) ) await asyncio.gather(*tasks) - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) - - async def async_update(self) -> None: - """Update the attributes of the climate device.""" - thermostat_mode = self._device.status.thermostat_mode - self._hvac_mode = MODE_TO_STATE.get(thermostat_mode) - if self._hvac_mode is None: - _LOGGER.debug( - "Device %s (%s) returned an invalid hvac mode: %s", - self._device.label, - self._device.device_id, - thermostat_mode, - ) - - modes = set() - supported_modes = self._device.status.supported_thermostat_modes - if isinstance(supported_modes, Iterable): - for mode in supported_modes: - if (state := MODE_TO_STATE.get(mode)) is not None: - modes.add(state) - else: - _LOGGER.debug( - ( - "Device %s (%s) returned an invalid supported thermostat" - " mode: %s" - ), - self._device.label, - self._device.device_id, - mode, - ) - else: - _LOGGER.debug( - "Device %s (%s) returned invalid supported thermostat modes: %s", - self._device.label, - self._device.device_id, - supported_modes, - ) - self._hvac_modes = list(modes) - @property - def current_humidity(self): + def current_humidity(self) -> float | None: """Return the current humidity.""" - return self._device.status.humidity + if self.supports_capability(Capability.RELATIVE_HUMIDITY_MEASUREMENT): + return self.get_attribute_value( + Capability.RELATIVE_HUMIDITY_MEASUREMENT, Attribute.HUMIDITY + ) + return None @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" - return self._device.status.temperature + return self.get_attribute_value( + Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE + ) @property - def fan_mode(self): + def fan_mode(self) -> str | None: """Return the fan setting.""" - return self._device.status.thermostat_fan_mode + return self.get_attribute_value( + Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE + ) @property - def fan_modes(self): + def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" - return self._device.status.supported_thermostat_fan_modes + return self.get_attribute_value( + Capability.THERMOSTAT_FAN_MODE, Attribute.SUPPORTED_THERMOSTAT_FAN_MODES + ) @property def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" return OPERATING_STATE_TO_ACTION.get( - self._device.status.thermostat_operating_state + self.get_attribute_value( + Capability.THERMOSTAT_OPERATING_STATE, + Attribute.THERMOSTAT_OPERATING_STATE, + ) ) @property - def hvac_mode(self) -> HVACMode: + def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" - return self._hvac_mode + return MODE_TO_STATE.get( + self.get_attribute_value( + Capability.THERMOSTAT_MODE, Attribute.THERMOSTAT_MODE + ) + ) @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available operation modes.""" - return self._hvac_modes + return [ + state + for mode in self.get_attribute_value( + Capability.THERMOSTAT_MODE, Attribute.SUPPORTED_THERMOSTAT_MODES + ) + if (state := AC_MODE_TO_STATE.get(mode)) is not None + ] @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" if self.hvac_mode == HVACMode.COOL: - return self._device.status.cooling_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT + ) if self.hvac_mode == HVACMode.HEAT: - return self._device.status.heating_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_HEATING_SETPOINT, Attribute.HEATING_SETPOINT + ) return None @property - def target_temperature_high(self): + def target_temperature_high(self) -> float | None: """Return the highbound target temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: - return self._device.status.cooling_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT + ) return None @property def target_temperature_low(self): """Return the lowbound target temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: - return self._device.status.heating_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_HEATING_SETPOINT, Attribute.HEATING_SETPOINT + ) return None @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of measurement.""" - return UNIT_MAP.get(self._device.status.attributes[Attribute.temperature].unit) + unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + assert unit + return UNIT_MAP[unit] class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" - _hvac_modes: list[HVACMode] + _attr_preset_mode = None - def __init__(self, device) -> None: + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" - super().__init__(device) - self._hvac_modes = [] - self._attr_preset_mode = None + super().__init__( + client, + device, + { + Capability.AIR_CONDITIONER_MODE, + Capability.SWITCH, + Capability.FAN_OSCILLATION_MODE, + Capability.AIR_CONDITIONER_FAN_MODE, + Capability.THERMOSTAT_COOLING_SETPOINT, + Capability.TEMPERATURE_MEASUREMENT, + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Capability.DEMAND_RESPONSE_LOAD_CONTROL, + }, + ) + self._attr_hvac_modes = self._determine_hvac_modes() self._attr_preset_modes = self._determine_preset_modes() self._attr_swing_modes = self._determine_swing_modes() self._attr_supported_features = self._determine_supported_features() @@ -362,7 +352,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - if self._device.get_capability(Capability.fan_oscillation_mode): + if self.supports_capability(Capability.FAN_OSCILLATION_MODE): features |= ClimateEntityFeature.SWING_MODE if (self._attr_preset_modes is not None) and len(self._attr_preset_modes) > 0: features |= ClimateEntityFeature.PRESET_MODE @@ -370,14 +360,11 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - await self._device.set_fan_mode(fan_mode, set_status=True) - - # setting the fan must reset the preset mode (it deactivates the windFree function) - self._attr_preset_mode = None - - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.AIR_CONDITIONER_FAN_MODE, + Command.SET_FAN_MODE, + argument=fan_mode, + ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" @@ -386,23 +373,27 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): return tasks = [] # Turn on the device if it's off before setting mode. - if not self._device.status.switch: - tasks.append(self._device.switch_on(set_status=True)) + if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off": + tasks.append(self.async_turn_on()) mode = STATE_TO_AC_MODE[hvac_mode] # If new hvac_mode is HVAC_MODE_FAN_ONLY and AirConditioner support "wind" mode the AirConditioner new mode has to be "wind" # The conversion make the mode change working # The conversion is made only for device that wrongly has capability "wind" instead "fan_only" if hvac_mode == HVACMode.FAN_ONLY: - supported_modes = self._device.status.supported_ac_modes - if WIND in supported_modes: + if WIND in self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES + ): mode = WIND - tasks.append(self._device.set_air_conditioner_mode(mode, set_status=True)) + tasks.append( + self.execute_device_command( + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + argument=mode, + ) + ) await asyncio.gather(*tasks) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -410,53 +401,44 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): # operation mode if operation_mode := kwargs.get(ATTR_HVAC_MODE): if operation_mode == HVACMode.OFF: - tasks.append(self._device.switch_off(set_status=True)) + tasks.append(self.async_turn_off()) else: - if not self._device.status.switch: - tasks.append(self._device.switch_on(set_status=True)) + if ( + self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) + == "off" + ): + tasks.append(self.async_turn_on()) tasks.append(self.async_set_hvac_mode(operation_mode)) # temperature tasks.append( - self._device.set_cooling_setpoint(kwargs[ATTR_TEMPERATURE], set_status=True) + self.execute_device_command( + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + argument=kwargs[ATTR_TEMPERATURE], + ) ) await asyncio.gather(*tasks) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() async def async_turn_on(self) -> None: """Turn device on.""" - await self._device.switch_on(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.SWITCH, + Command.ON, + ) async def async_turn_off(self) -> None: """Turn device off.""" - await self._device.switch_off(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() - - async def async_update(self) -> None: - """Update the calculated fields of the AC.""" - modes = {HVACMode.OFF} - for mode in self._device.status.supported_ac_modes: - if (state := AC_MODE_TO_STATE.get(mode)) is not None: - modes.add(state) - else: - _LOGGER.debug( - "Device %s (%s) returned an invalid supported AC mode: %s", - self._device.label, - self._device.device_id, - mode, - ) - self._hvac_modes = list(modes) + await self.execute_device_command( + Capability.SWITCH, + Command.OFF, + ) @property def current_temperature(self) -> float | None: """Return the current temperature.""" - return self._device.status.temperature + return self.get_attribute_value( + Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE + ) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -465,100 +447,114 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): Include attributes from the Demand Response Load Control (drlc) and Power Consumption capabilities. """ - attributes = [ - "drlc_status_duration", - "drlc_status_level", - "drlc_status_start", - "drlc_status_override", - ] - state_attributes = {} - for attribute in attributes: - value = getattr(self._device.status, attribute) - if value is not None: - state_attributes[attribute] = value - return state_attributes + drlc_status = self.get_attribute_value( + Capability.DEMAND_RESPONSE_LOAD_CONTROL, + Attribute.DEMAND_RESPONSE_LOAD_CONTROL_STATUS, + ) + return { + "drlc_status_duration": drlc_status["duration"], + "drlc_status_level": drlc_status["drlcLevel"], + "drlc_status_start": drlc_status["start"], + "drlc_status_override": drlc_status["override"], + } @property def fan_mode(self) -> str: """Return the fan setting.""" - return self._device.status.fan_mode + return self.get_attribute_value( + Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE + ) @property def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" - return self._device.status.supported_ac_fan_modes + return self.get_attribute_value( + Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES + ) @property def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" - if not self._device.status.switch: + if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off": return HVACMode.OFF - return AC_MODE_TO_STATE.get(self._device.status.air_conditioner_mode) - - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available operation modes.""" - return self._hvac_modes + return AC_MODE_TO_STATE.get( + self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.AIR_CONDITIONER_MODE + ) + ) @property def target_temperature(self) -> float: """Return the temperature we try to reach.""" - return self._device.status.cooling_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT + ) @property def temperature_unit(self) -> str: """Return the unit of measurement.""" - return UNIT_MAP[self._device.status.attributes[Attribute.temperature].unit] + unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + assert unit + return UNIT_MAP[unit] def _determine_swing_modes(self) -> list[str] | None: """Return the list of available swing modes.""" - supported_swings = None - supported_modes = self._device.status.attributes[ - Attribute.supported_fan_oscillation_modes - ][0] - if supported_modes is not None: - supported_swings = [ - FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes - ] - return supported_swings + if ( + supported_modes := self.get_attribute_value( + Capability.FAN_OSCILLATION_MODE, + Attribute.SUPPORTED_FAN_OSCILLATION_MODES, + ) + ) is None: + return None + return [FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes] async def async_set_swing_mode(self, swing_mode: str) -> None: """Set swing mode.""" - fan_oscillation_mode = SWING_TO_FAN_OSCILLATION[swing_mode] - await self._device.set_fan_oscillation_mode(fan_oscillation_mode) - - # setting the fan must reset the preset mode (it deactivates the windFree function) - self._attr_preset_mode = None - - self.async_schedule_update_ha_state(True) + await self.execute_device_command( + Capability.FAN_OSCILLATION_MODE, + Command.SET_FAN_OSCILLATION_MODE, + argument=SWING_TO_FAN_OSCILLATION[swing_mode], + ) @property def swing_mode(self) -> str: """Return the swing setting.""" return FAN_OSCILLATION_TO_SWING.get( - self._device.status.fan_oscillation_mode, SWING_OFF + self.get_attribute_value( + Capability.FAN_OSCILLATION_MODE, Attribute.FAN_OSCILLATION_MODE + ), + SWING_OFF, ) def _determine_preset_modes(self) -> list[str] | None: """Return a list of available preset modes.""" - supported_modes: list | None = self._device.status.attributes[ - "supportedAcOptionalMode" - ].value - if supported_modes and WINDFREE in supported_modes: - return [WINDFREE] + if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE): + supported_modes = self.get_attribute_value( + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Attribute.SUPPORTED_AC_OPTIONAL_MODE, + ) + if supported_modes and WINDFREE in supported_modes: + return [WINDFREE] return None async def async_set_preset_mode(self, preset_mode: str) -> None: """Set special modes (currently only windFree is supported).""" - result = await self._device.command( - "main", - "custom.airConditionerOptionalMode", - "setAcOptionalMode", - [preset_mode], + await self.execute_device_command( + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Command.SET_AC_OPTIONAL_MODE, + argument=preset_mode, ) - if result: - self._device.status.update_attribute_value("acOptionalMode", preset_mode) - self._attr_preset_mode = preset_mode - - self.async_write_ha_state() + def _determine_hvac_modes(self) -> list[HVACMode]: + """Determine the supported HVAC modes.""" + modes = [HVACMode.OFF] + modes.extend( + state + for mode in self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES + ) + if (state := AC_MODE_TO_STATE.get(mode)) is not None + ) + return modes diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 7b49854740a..bcd2ddc192b 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -1,298 +1,83 @@ """Config flow to configure SmartThings.""" from collections.abc import Mapping -from http import HTTPStatus import logging from typing import Any -from aiohttp import ClientResponseError -from pysmartthings import APIResponseError, AppOAuth, SmartThings -from pysmartthings.installedapp import format_install_url -import voluptuous as vol +from pysmartthings import SmartThings -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from .const import ( - APP_OAUTH_CLIENT_NAME, - APP_OAUTH_SCOPES, - CONF_APP_ID, - CONF_INSTALLED_APP_ID, - CONF_LOCATION_ID, - CONF_REFRESH_TOKEN, - DOMAIN, - VAL_UID_MATCHER, -) -from .smartapp import ( - create_app, - find_app, - format_unique_id, - get_webhook_url, - setup_smartapp, - setup_smartapp_endpoint, - update_app, - validate_webhook_requirements, -) +from .const import CONF_LOCATION_ID, DOMAIN, OLD_DATA, SCOPES _LOGGER = logging.getLogger(__name__) -class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN): +class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Handle configuration of SmartThings integrations.""" - VERSION = 2 + VERSION = 3 + DOMAIN = DOMAIN - api: SmartThings - app_id: str - location_id: str + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) - def __init__(self) -> None: - """Create a new instance of the flow handler.""" - self.access_token: str | None = None - self.oauth_client_secret = None - self.oauth_client_id = None - self.installed_app_id = None - self.refresh_token = None - self.endpoints_initialized = False + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": " ".join(SCOPES)} - async def async_step_import(self, import_data: None) -> ConfigFlowResult: - """Occurs when a previously entry setup fails and is re-initiated.""" - return await self.async_step_user(import_data) + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an entry for SmartThings.""" + client = SmartThings(session=async_get_clientsession(self.hass)) + client.authenticate(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + locations = await client.get_locations() + location = locations[0] + # We pick to use the location id as unique id rather than the installed app id + # as the installed app id could change with the right settings in the SmartApp + # or the app used to sign in changed for any reason. + await self.async_set_unique_id(location.location_id) + if self.source != SOURCE_REAUTH: + self._abort_if_unique_id_configured() - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Validate and confirm webhook setup.""" - if not self.endpoints_initialized: - self.endpoints_initialized = True - await setup_smartapp_endpoint( - self.hass, len(self._async_current_entries()) == 0 + return self.async_create_entry( + title=location.name, + data={**data, CONF_LOCATION_ID: location.location_id}, ) - webhook_url = get_webhook_url(self.hass) - # Abort if the webhook is invalid - if not validate_webhook_requirements(self.hass): - return self.async_abort( - reason="invalid_webhook_url", - description_placeholders={ - "webhook_url": webhook_url, - "component_url": ( - "https://www.home-assistant.io/integrations/smartthings/" - ), + if (entry := self._get_reauth_entry()) and CONF_TOKEN not in entry.data: + if entry.data[OLD_DATA][CONF_LOCATION_ID] != location.location_id: + return self.async_abort(reason="reauth_location_mismatch") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={ + **data, + CONF_LOCATION_ID: location.location_id, }, + unique_id=location.location_id, ) - - # Show the confirmation - if user_input is None: - return self.async_show_form( - step_id="user", - description_placeholders={"webhook_url": webhook_url}, - ) - - # Show the next screen - return await self.async_step_pat() - - async def async_step_pat( - self, user_input: dict[str, str] | None = None - ) -> ConfigFlowResult: - """Get the Personal Access Token and validate it.""" - errors: dict[str, str] = {} - if user_input is None or CONF_ACCESS_TOKEN not in user_input: - return self._show_step_pat(errors) - - self.access_token = user_input[CONF_ACCESS_TOKEN] - - # Ensure token is a UUID - if not VAL_UID_MATCHER.match(self.access_token): - errors[CONF_ACCESS_TOKEN] = "token_invalid_format" - return self._show_step_pat(errors) - - # Setup end-point - self.api = SmartThings(async_get_clientsession(self.hass), self.access_token) - try: - app = await find_app(self.hass, self.api) - if app: - await app.refresh() # load all attributes - await update_app(self.hass, app) - # Find an existing entry to copy the oauth client - existing = next( - ( - entry - for entry in self._async_current_entries() - if entry.data[CONF_APP_ID] == app.app_id - ), - None, - ) - if existing: - self.oauth_client_id = existing.data[CONF_CLIENT_ID] - self.oauth_client_secret = existing.data[CONF_CLIENT_SECRET] - else: - # Get oauth client id/secret by regenerating it - app_oauth = AppOAuth(app.app_id) - app_oauth.client_name = APP_OAUTH_CLIENT_NAME - app_oauth.scope.extend(APP_OAUTH_SCOPES) - client = await self.api.generate_app_oauth(app_oauth) - self.oauth_client_secret = client.client_secret - self.oauth_client_id = client.client_id - else: - app, client = await create_app(self.hass, self.api) - self.oauth_client_secret = client.client_secret - self.oauth_client_id = client.client_id - setup_smartapp(self.hass, app) - self.app_id = app.app_id - - except APIResponseError as ex: - if ex.is_target_error(): - errors["base"] = "webhook_error" - else: - errors["base"] = "app_setup_error" - _LOGGER.exception( - "API error setting up the SmartApp: %s", ex.raw_error_response - ) - return self._show_step_pat(errors) - except ClientResponseError as ex: - if ex.status == HTTPStatus.UNAUTHORIZED: - errors[CONF_ACCESS_TOKEN] = "token_unauthorized" - _LOGGER.debug( - "Unauthorized error received setting up SmartApp", exc_info=True - ) - elif ex.status == HTTPStatus.FORBIDDEN: - errors[CONF_ACCESS_TOKEN] = "token_forbidden" - _LOGGER.debug( - "Forbidden error received setting up SmartApp", exc_info=True - ) - else: - errors["base"] = "app_setup_error" - _LOGGER.exception("Unexpected error setting up the SmartApp") - return self._show_step_pat(errors) - except Exception: - errors["base"] = "app_setup_error" - _LOGGER.exception("Unexpected error setting up the SmartApp") - return self._show_step_pat(errors) - - return await self.async_step_select_location() - - async def async_step_select_location( - self, user_input: dict[str, str] | None = None - ) -> ConfigFlowResult: - """Ask user to select the location to setup.""" - if user_input is None or CONF_LOCATION_ID not in user_input: - # Get available locations - existing_locations = [ - entry.data[CONF_LOCATION_ID] for entry in self._async_current_entries() - ] - locations = await self.api.locations() - locations_options = { - location.location_id: location.name - for location in locations - if location.location_id not in existing_locations - } - if not locations_options: - return self.async_abort(reason="no_available_locations") - - return self.async_show_form( - step_id="select_location", - data_schema=vol.Schema( - {vol.Required(CONF_LOCATION_ID): vol.In(locations_options)} - ), - ) - - self.location_id = user_input[CONF_LOCATION_ID] - await self.async_set_unique_id(format_unique_id(self.app_id, self.location_id)) - return await self.async_step_authorize() - - async def async_step_authorize( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Wait for the user to authorize the app installation.""" - user_input = {} if user_input is None else user_input - self.installed_app_id = user_input.get(CONF_INSTALLED_APP_ID) - self.refresh_token = user_input.get(CONF_REFRESH_TOKEN) - if self.installed_app_id is None: - # Launch the external setup URL - url = format_install_url(self.app_id, self.location_id) - return self.async_external_step(step_id="authorize", url=url) - - next_step_id = "install" - if self.source == SOURCE_REAUTH: - next_step_id = "update" - return self.async_external_step_done(next_step_id=next_step_id) - - def _show_step_pat(self, errors): - if self.access_token is None: - # Get the token from an existing entry to make it easier to setup multiple locations. - self.access_token = next( - ( - entry.data.get(CONF_ACCESS_TOKEN) - for entry in self._async_current_entries() - ), - None, - ) - - return self.async_show_form( - step_id="pat", - data_schema=vol.Schema( - {vol.Required(CONF_ACCESS_TOKEN, default=self.access_token): str} - ), - errors=errors, - description_placeholders={ - "token_url": "https://account.smartthings.com/tokens", - "component_url": ( - "https://www.home-assistant.io/integrations/smartthings/" - ), - }, + self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data_updates=data ) async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: - """Handle re-authentication of an existing config entry.""" + """Perform reauth upon migration of old entries.""" return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle re-authentication of an existing config entry.""" + """Confirm reauth dialog.""" if user_input is None: - return self.async_show_form(step_id="reauth_confirm") - self.app_id = self._get_reauth_entry().data[CONF_APP_ID] - self.location_id = self._get_reauth_entry().data[CONF_LOCATION_ID] - self._set_confirm_only() - return await self.async_step_authorize() - - async def async_step_update( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle re-authentication of an existing config entry.""" - return await self.async_step_update_confirm() - - async def async_step_update_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle re-authentication of an existing config entry.""" - if user_input is None: - self._set_confirm_only() - return self.async_show_form(step_id="update_confirm") - entry = self._get_reauth_entry() - return self.async_update_reload_and_abort( - entry, data_updates={CONF_REFRESH_TOKEN: self.refresh_token} - ) - - async def async_step_install( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Create a config entry at completion of a flow and authorization of the app.""" - data = { - CONF_ACCESS_TOKEN: self.access_token, - CONF_REFRESH_TOKEN: self.refresh_token, - CONF_CLIENT_ID: self.oauth_client_id, - CONF_CLIENT_SECRET: self.oauth_client_secret, - CONF_LOCATION_ID: self.location_id, - CONF_APP_ID: self.app_id, - CONF_INSTALLED_APP_ID: self.installed_app_id, - } - - location = await self.api.location(data[CONF_LOCATION_ID]) - - return self.async_create_entry(title=location.name, data=data) + return self.async_show_form( + step_id="reauth_confirm", + ) + return await self.async_step_user() diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index e50837697e7..c39d225dd09 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -1,15 +1,23 @@ """Constants used by the SmartThings component and platforms.""" -from datetime import timedelta -import re - -from homeassistant.const import Platform - DOMAIN = "smartthings" -APP_OAUTH_CLIENT_NAME = "Home Assistant" -APP_OAUTH_SCOPES = ["r:devices:*"] -APP_NAME_PREFIX = "homeassistant." +SCOPES = [ + "r:devices:*", + "w:devices:*", + "x:devices:*", + "r:hubs:*", + "r:locations:*", + "w:locations:*", + "x:locations:*", + "r:scenes:*", + "x:scenes:*", + "r:rules:*", + "w:rules:*", + "r:installedapps", + "w:installedapps", + "sse", +] CONF_APP_ID = "app_id" CONF_CLOUDHOOK_URL = "cloudhook_url" @@ -18,41 +26,5 @@ CONF_INSTANCE_ID = "instance_id" CONF_LOCATION_ID = "location_id" CONF_REFRESH_TOKEN = "refresh_token" -DATA_MANAGER = "manager" -DATA_BROKERS = "brokers" -EVENT_BUTTON = "smartthings.button" - -SIGNAL_SMARTTHINGS_UPDATE = "smartthings_update" -SIGNAL_SMARTAPP_PREFIX = "smartthings_smartap_" - -SETTINGS_INSTANCE_ID = "hassInstanceId" - -SUBSCRIPTION_WARNING_LIMIT = 40 - -STORAGE_KEY = DOMAIN -STORAGE_VERSION = 1 - -# Ordered 'specific to least-specific platform' in order for capabilities -# to be drawn-down and represented by the most appropriate platform. -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.CLIMATE, - Platform.COVER, - Platform.FAN, - Platform.LIGHT, - Platform.LOCK, - Platform.SCENE, - Platform.SENSOR, - Platform.SWITCH, -] - -IGNORED_CAPABILITIES = [ - "execute", - "healthCheck", - "ocf", -] - -TOKEN_REFRESH_INTERVAL = timedelta(days=14) - -VAL_UID = "^(?:([0-9a-fA-F]{32})|([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))$" -VAL_UID_MATCHER = re.compile(VAL_UID) +MAIN = "main" +OLD_DATA = "old_data" diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index daf9b0f38f8..97a7456d132 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -2,25 +2,23 @@ from __future__ import annotations -from collections.abc import Sequence from typing import Any -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command, SmartThings from homeassistant.components.cover import ( ATTR_POSITION, - DOMAIN as COVER_DOMAIN, CoverDeviceClass, CoverEntity, CoverEntityFeature, CoverState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity VALUE_TO_STATE = { @@ -32,114 +30,99 @@ VALUE_TO_STATE = { "unknown": None, } +CAPABILITIES = (Capability.WINDOW_SHADE, Capability.DOOR_CONTROL) + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add covers for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - [ - SmartThingsCover(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, COVER_DOMAIN) - ], - True, + SmartThingsCover(entry_data.client, device, capability) + for device in entry_data.devices.values() + for capability in device.status[MAIN] + if capability in CAPABILITIES ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - min_required = [ - Capability.door_control, - Capability.garage_door_control, - Capability.window_shade, - ] - # Must have one of the min_required - if any(capability in capabilities for capability in min_required): - # Return all capabilities supported/consumed - return [ - *min_required, - Capability.battery, - Capability.switch_level, - Capability.window_shade_level, - ] - - return None - - class SmartThingsCover(SmartThingsEntity, CoverEntity): """Define a SmartThings cover.""" - def __init__(self, device): + _state: CoverState | None = None + + def __init__( + self, client: SmartThings, device: FullDevice, capability: Capability + ) -> None: """Initialize the cover class.""" - super().__init__(device) - self._current_cover_position = None - self._state = None + super().__init__( + client, + device, + { + capability, + Capability.BATTERY, + Capability.WINDOW_SHADE_LEVEL, + Capability.SWITCH_LEVEL, + }, + ) + self.capability = capability self._attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) - if ( - Capability.switch_level in device.capabilities - or Capability.window_shade_level in device.capabilities - ): + if self.supports_capability(Capability.WINDOW_SHADE_LEVEL): + self.level_capability = Capability.WINDOW_SHADE_LEVEL + self.level_command = Command.SET_SHADE_LEVEL + else: + self.level_capability = Capability.SWITCH_LEVEL + self.level_command = Command.SET_LEVEL + if self.supports_capability( + Capability.SWITCH_LEVEL + ) or self.supports_capability(Capability.WINDOW_SHADE_LEVEL): self._attr_supported_features |= CoverEntityFeature.SET_POSITION - if Capability.door_control in device.capabilities: + if self.supports_capability(Capability.DOOR_CONTROL): self._attr_device_class = CoverDeviceClass.DOOR - elif Capability.window_shade in device.capabilities: + elif self.supports_capability(Capability.WINDOW_SHADE): self._attr_device_class = CoverDeviceClass.SHADE - elif Capability.garage_door_control in device.capabilities: - self._attr_device_class = CoverDeviceClass.GARAGE async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - # Same command for all 3 supported capabilities - await self._device.close(set_status=True) - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command(self.capability, Command.CLOSE) async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - # Same for all capability types - await self._device.open(set_status=True) - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command(self.capability, Command.OPEN) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - if not self.supported_features & CoverEntityFeature.SET_POSITION: - return - # Do not set_status=True as device will report progress. - if Capability.window_shade_level in self._device.capabilities: - await self._device.set_window_shade_level( - kwargs[ATTR_POSITION], set_status=False - ) - else: - await self._device.set_level(kwargs[ATTR_POSITION], set_status=False) + await self.execute_device_command( + self.level_capability, + self.level_command, + argument=kwargs[ATTR_POSITION], + ) - async def async_update(self) -> None: + def _update_attr(self) -> None: """Update the attrs of the cover.""" - if Capability.door_control in self._device.capabilities: - self._state = VALUE_TO_STATE.get(self._device.status.door) - elif Capability.window_shade in self._device.capabilities: - self._state = VALUE_TO_STATE.get(self._device.status.window_shade) - elif Capability.garage_door_control in self._device.capabilities: - self._state = VALUE_TO_STATE.get(self._device.status.door) + attribute = { + Capability.WINDOW_SHADE: Attribute.WINDOW_SHADE, + Capability.DOOR_CONTROL: Attribute.DOOR, + }[self.capability] + self._state = VALUE_TO_STATE.get( + self.get_attribute_value(self.capability, attribute) + ) - if Capability.window_shade_level in self._device.capabilities: - self._attr_current_cover_position = self._device.status.shade_level - elif Capability.switch_level in self._device.capabilities: - self._attr_current_cover_position = self._device.status.level + if self.supports_capability(Capability.SWITCH_LEVEL): + self._attr_current_cover_position = self.get_attribute_value( + Capability.SWITCH_LEVEL, Attribute.LEVEL + ) self._attr_extra_state_attributes = {} - battery = self._device.status.attributes[Attribute.battery].value - if battery is not None: - self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = battery + if self.supports_capability(Capability.BATTERY): + self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = ( + self.get_attribute_value(Capability.BATTERY, Attribute.BATTERY) + ) @property def is_opening(self) -> bool: diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index cc63213d122..f5f1f268801 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -2,13 +2,15 @@ from __future__ import annotations -from pysmartthings.device import DeviceEntity +from typing import Any, cast + +from pysmartthings import Attribute, Capability, Command, DeviceEvent, SmartThings from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE +from . import FullDevice +from .const import DOMAIN, MAIN class SmartThingsEntity(Entity): @@ -16,35 +18,86 @@ class SmartThingsEntity(Entity): _attr_should_poll = False - def __init__(self, device: DeviceEntity) -> None: + def __init__( + self, client: SmartThings, device: FullDevice, capabilities: set[Capability] + ) -> None: """Initialize the instance.""" - self._device = device - self._dispatcher_remove = None - self._attr_name = device.label - self._attr_unique_id = device.device_id + self.client = client + self.capabilities = capabilities + self._internal_state = { + capability: device.status[MAIN][capability] + for capability in capabilities + if capability in device.status[MAIN] + } + self.device = device + self._attr_name = device.device.label + self._attr_unique_id = device.device.device_id self._attr_device_info = DeviceInfo( configuration_url="https://account.smartthings.com", - identifiers={(DOMAIN, device.device_id)}, - manufacturer=device.status.ocf_manufacturer_name, - model=device.status.ocf_model_number, - name=device.label, - hw_version=device.status.ocf_hardware_version, - sw_version=device.status.ocf_firmware_version, + identifiers={(DOMAIN, device.device.device_id)}, + name=device.device.label, ) + if (ocf := device.status[MAIN].get(Capability.OCF)) is not None: + self._attr_device_info.update( + { + "manufacturer": cast( + str | None, ocf[Attribute.MANUFACTURER_NAME].value + ), + "model": cast(str | None, ocf[Attribute.MODEL_NUMBER].value), + "hw_version": cast( + str | None, ocf[Attribute.HARDWARE_VERSION].value + ), + "sw_version": cast( + str | None, ocf[Attribute.OCF_FIRMWARE_VERSION].value + ), + } + ) - async def async_added_to_hass(self): - """Device added to hass.""" + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + await super().async_added_to_hass() + for capability in self._internal_state: + self.async_on_remove( + self.client.add_device_event_listener( + self.device.device.device_id, + MAIN, + capability, + self._update_handler, + ) + ) + self._update_attr() - async def async_update_state(devices): - """Update device state.""" - if self._device.device_id in devices: - await self.async_update_ha_state(True) + def _update_handler(self, event: DeviceEvent) -> None: + self._internal_state[event.capability][event.attribute].value = event.value + self._internal_state[event.capability][event.attribute].data = event.data + self._handle_update() - self._dispatcher_remove = async_dispatcher_connect( - self.hass, SIGNAL_SMARTTHINGS_UPDATE, async_update_state + def supports_capability(self, capability: Capability) -> bool: + """Test if device supports a capability.""" + return capability in self.device.status[MAIN] + + def get_attribute_value(self, capability: Capability, attribute: Attribute) -> Any: + """Get the value of a device attribute.""" + return self._internal_state[capability][attribute].value + + def _update_attr(self) -> None: + """Update the attributes.""" + + def _handle_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attr() + self.async_write_ha_state() + + async def execute_device_command( + self, + capability: Capability, + command: Command, + argument: int | str | list[Any] | dict[str, Any] | None = None, + ) -> None: + """Execute a command on the device.""" + kwargs = {} + if argument is not None: + kwargs["argument"] = argument + await self.client.execute_device_command( + self.device.device.device_id, capability, command, MAIN, **kwargs ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect the device when removed.""" - if self._dispatcher_remove: - self._dispatcher_remove() diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 1f26a805dcb..23afb0baeb2 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -2,14 +2,12 @@ from __future__ import annotations -from collections.abc import Sequence import math from typing import Any -from pysmartthings import Capability +from pysmartthings import Attribute, Capability, Command, SmartThings from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( @@ -18,7 +16,8 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity SPEED_RANGE = (1, 3) # off is not included @@ -26,86 +25,73 @@ SPEED_RANGE = (1, 3) # off is not included async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add fans for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - SmartThingsFan(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, "fan") + SmartThingsFan(entry_data.client, device) + for device in entry_data.devices.values() + if Capability.SWITCH in device.status[MAIN] + and any( + capability in device.status[MAIN] + for capability in ( + Capability.FAN_SPEED, + Capability.AIR_CONDITIONER_FAN_MODE, + ) + ) + and Capability.THERMOSTAT_COOLING_SETPOINT not in device.status[MAIN] ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - - # MUST support switch as we need a way to turn it on and off - if Capability.switch not in capabilities: - return None - - # These are all optional but at least one must be supported - optional = [ - Capability.air_conditioner_fan_mode, - Capability.fan_speed, - ] - - # At least one of the optional capabilities must be supported - # to classify this entity as a fan. - # If they are not then return None and don't setup the platform. - if not any(capability in capabilities for capability in optional): - return None - - supported = [Capability.switch] - - supported.extend( - capability for capability in optional if capability in capabilities - ) - - return supported - - class SmartThingsFan(SmartThingsEntity, FanEntity): """Define a SmartThings Fan.""" _attr_speed_count = int_states_in_range(SPEED_RANGE) - def __init__(self, device): + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" - super().__init__(device) + super().__init__( + client, + device, + { + Capability.SWITCH, + Capability.FAN_SPEED, + Capability.AIR_CONDITIONER_FAN_MODE, + }, + ) self._attr_supported_features = self._determine_features() def _determine_features(self): flags = FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON - if self._device.get_capability(Capability.fan_speed): + if self.supports_capability(Capability.FAN_SPEED): flags |= FanEntityFeature.SET_SPEED - if self._device.get_capability(Capability.air_conditioner_fan_mode): + if self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE): flags |= FanEntityFeature.PRESET_MODE return flags async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" - await self._async_set_percentage(percentage) - - async def _async_set_percentage(self, percentage: int | None) -> None: - if percentage is None: - await self._device.switch_on(set_status=True) - elif percentage == 0: - await self._device.switch_off(set_status=True) + if percentage == 0: + await self.execute_device_command(Capability.SWITCH, Command.OFF) else: value = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) - await self._device.set_fan_speed(value, set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.FAN_SPEED, + Command.SET_FAN_SPEED, + argument=value, + ) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset_mode of the fan.""" - await self._device.set_fan_mode(preset_mode, set_status=True) - self.async_write_ha_state() + await self.execute_device_command( + Capability.AIR_CONDITIONER_FAN_MODE, + Command.SET_FAN_MODE, + argument=preset_mode, + ) async def async_turn_on( self, @@ -114,32 +100,30 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): **kwargs: Any, ) -> None: """Turn the fan on.""" - if FanEntityFeature.SET_SPEED in self._attr_supported_features: - # If speed is set in features then turn the fan on with the speed. - await self._async_set_percentage(percentage) + if ( + FanEntityFeature.SET_SPEED in self._attr_supported_features + and percentage is not None + ): + await self.async_set_percentage(percentage) else: - # If speed is not valid then turn on the fan with the - await self._device.switch_on(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command(Capability.SWITCH, Command.ON) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" - await self._device.switch_off(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command(Capability.SWITCH, Command.OFF) @property def is_on(self) -> bool: """Return true if fan is on.""" - return self._device.status.switch + return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) @property def percentage(self) -> int | None: """Return the current speed percentage.""" - return ranged_value_to_percentage(SPEED_RANGE, self._device.status.fan_speed) + return ranged_value_to_percentage( + SPEED_RANGE, + self.get_attribute_value(Capability.FAN_SPEED, Attribute.FAN_SPEED), + ) @property def preset_mode(self) -> str | None: @@ -147,7 +131,9 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): Requires FanEntityFeature.PRESET_MODE. """ - return self._device.status.fan_mode + return self.get_attribute_value( + Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE + ) @property def preset_modes(self) -> list[str] | None: @@ -155,4 +141,6 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): Requires FanEntityFeature.PRESET_MODE. """ - return self._device.status.supported_ac_fan_modes + return self.get_attribute_value( + Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES + ) diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 2ee369176cb..582f9dd5435 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -3,10 +3,9 @@ from __future__ import annotations import asyncio -from collections.abc import Sequence from typing import Any -from pysmartthings import Capability +from pysmartthings import Attribute, Capability, Command, SmartThings from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -18,54 +17,38 @@ from homeassistant.components.light import ( LightEntityFeature, brightness_supported, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity +CAPABILITIES = ( + Capability.SWITCH_LEVEL, + Capability.COLOR_CONTROL, + Capability.COLOR_TEMPERATURE, +) + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add lights for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - [ - SmartThingsLight(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, "light") - ], - True, + SmartThingsLight(entry_data.client, device) + for device in entry_data.devices.values() + if Capability.SWITCH in device.status[MAIN] + and any(capability in device.status[MAIN] for capability in CAPABILITIES) ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - supported = [ - Capability.switch, - Capability.switch_level, - Capability.color_control, - Capability.color_temperature, - ] - # Must be able to be turned on/off. - if Capability.switch not in capabilities: - return None - # Must have one of these - light_capabilities = [ - Capability.color_control, - Capability.color_temperature, - Capability.switch_level, - ] - if any(capability in capabilities for capability in light_capabilities): - return supported - return None - - -def convert_scale(value, value_scale, target_scale, round_digits=4): +def convert_scale( + value: float, value_scale: int, target_scale: int, round_digits: int = 4 +) -> float: """Convert a value to a different scale.""" return round(value * target_scale / value_scale, round_digits) @@ -76,46 +59,41 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): _attr_supported_color_modes: set[ColorMode] # SmartThings does not expose this attribute, instead it's - # implemented within each device-type handler. This value is the + # implemented within each device-type handler. This value is the # lowest kelvin found supported across 20+ handlers. _attr_min_color_temp_kelvin = 2000 # 500 mireds # SmartThings does not expose this attribute, instead it's - # implemented within each device-type handler. This value is the + # implemented within each device-type handler. This value is the # highest kelvin found supported across 20+ handlers. _attr_max_color_temp_kelvin = 9000 # 111 mireds - def __init__(self, device): + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Initialize a SmartThingsLight.""" - super().__init__(device) - self._attr_supported_color_modes = self._determine_color_modes() - self._attr_supported_features = self._determine_features() - - def _determine_color_modes(self): - """Get features supported by the device.""" + super().__init__( + client, + device, + { + Capability.COLOR_CONTROL, + Capability.COLOR_TEMPERATURE, + Capability.SWITCH_LEVEL, + Capability.SWITCH, + }, + ) color_modes = set() - # Color Temperature - if Capability.color_temperature in self._device.capabilities: + if self.supports_capability(Capability.COLOR_TEMPERATURE): color_modes.add(ColorMode.COLOR_TEMP) - # Color - if Capability.color_control in self._device.capabilities: + if self.supports_capability(Capability.COLOR_CONTROL): color_modes.add(ColorMode.HS) - # Brightness - if not color_modes and Capability.switch_level in self._device.capabilities: + if not color_modes and self.supports_capability(Capability.SWITCH_LEVEL): color_modes.add(ColorMode.BRIGHTNESS) if not color_modes: color_modes.add(ColorMode.ONOFF) - - return color_modes - - def _determine_features(self) -> LightEntityFeature: - """Get features supported by the device.""" + self._attr_supported_color_modes = color_modes features = LightEntityFeature(0) - # Transition - if Capability.switch_level in self._device.capabilities: + if self.supports_capability(Capability.SWITCH_LEVEL): features |= LightEntityFeature.TRANSITION - - return features + self._attr_supported_features = features async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" @@ -136,11 +114,10 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): kwargs[ATTR_BRIGHTNESS], kwargs.get(ATTR_TRANSITION, 0) ) else: - await self._device.switch_on(set_status=True) - - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command( + Capability.SWITCH, + Command.ON, + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" @@ -148,27 +125,39 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): if ATTR_TRANSITION in kwargs: await self.async_set_level(0, int(kwargs[ATTR_TRANSITION])) else: - await self._device.switch_off(set_status=True) + await self.execute_device_command( + Capability.SWITCH, + Command.OFF, + ) - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) - - async def async_update(self) -> None: + def _update_attr(self) -> None: """Update entity attributes when the device status has changed.""" # Brightness and transition if brightness_supported(self._attr_supported_color_modes): self._attr_brightness = int( - convert_scale(self._device.status.level, 100, 255, 0) + convert_scale( + self.get_attribute_value(Capability.SWITCH_LEVEL, Attribute.LEVEL), + 100, + 255, + 0, + ) ) # Color Temperature if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: - self._attr_color_temp_kelvin = self._device.status.color_temperature + self._attr_color_temp_kelvin = self.get_attribute_value( + Capability.COLOR_TEMPERATURE, Attribute.COLOR_TEMPERATURE + ) # Color if ColorMode.HS in self._attr_supported_color_modes: self._attr_hs_color = ( - convert_scale(self._device.status.hue, 100, 360), - self._device.status.saturation, + convert_scale( + self.get_attribute_value(Capability.COLOR_CONTROL, Attribute.HUE), + 100, + 360, + ), + self.get_attribute_value( + Capability.COLOR_CONTROL, Attribute.SATURATION + ), ) async def async_set_color(self, hs_color): @@ -176,14 +165,22 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): hue = convert_scale(float(hs_color[0]), 360, 100) hue = max(min(hue, 100.0), 0.0) saturation = max(min(float(hs_color[1]), 100.0), 0.0) - await self._device.set_color(hue, saturation, set_status=True) + await self.execute_device_command( + Capability.COLOR_CONTROL, + Command.SET_COLOR, + argument={"hue": hue, "saturation": saturation}, + ) async def async_set_color_temp(self, value: int): """Set the color temperature of the device.""" kelvin = max(min(value, 30000), 1) - await self._device.set_color_temperature(kelvin, set_status=True) + await self.execute_device_command( + Capability.COLOR_TEMPERATURE, + Command.SET_COLOR_TEMPERATURE, + argument=kelvin, + ) - async def async_set_level(self, brightness: int, transition: int): + async def async_set_level(self, brightness: int, transition: int) -> None: """Set the brightness of the light over transition.""" level = int(convert_scale(brightness, 255, 100, 0)) # Due to rounding, set level to 1 (one) so we don't inadvertently @@ -191,7 +188,11 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): level = 1 if level == 0 and brightness > 0 else level level = max(min(level, 100), 0) duration = int(transition) - await self._device.set_level(level, duration, set_status=True) + await self.execute_device_command( + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + argument=[level, duration], + ) @property def color_mode(self) -> ColorMode: @@ -208,4 +209,4 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): @property def is_on(self) -> bool: """Return true if light is on.""" - return self._device.status.switch + return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index 468b7c2083a..56274dfe161 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -2,17 +2,16 @@ from __future__ import annotations -from collections.abc import Sequence from typing import Any -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity ST_STATE_LOCKED = "locked" @@ -28,48 +27,47 @@ ST_LOCK_ATTR_MAP = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add locks for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - SmartThingsLock(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, "lock") + SmartThingsLock(entry_data.client, device, {Capability.LOCK}) + for device in entry_data.devices.values() + if Capability.LOCK in device.status[MAIN] ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - if Capability.lock in capabilities: - return [Capability.lock] - return None - - class SmartThingsLock(SmartThingsEntity, LockEntity): """Define a SmartThings lock.""" async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" - await self._device.lock(set_status=True) - self.async_write_ha_state() + await self.execute_device_command( + Capability.LOCK, + Command.LOCK, + ) async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" - await self._device.unlock(set_status=True) - self.async_write_ha_state() + await self.execute_device_command( + Capability.LOCK, + Command.UNLOCK, + ) @property def is_locked(self) -> bool: """Return true if lock is locked.""" - return self._device.status.lock == ST_STATE_LOCKED + return ( + self.get_attribute_value(Capability.LOCK, Attribute.LOCK) == ST_STATE_LOCKED + ) @property def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" state_attrs = {} - status = self._device.status.attributes[Attribute.lock] + status = self._internal_state[Capability.LOCK][Attribute.LOCK] if status.value: state_attrs["lock_state"] = status.value if isinstance(status.data, dict): diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index be313248eaf..b34ab90ca8c 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -1,10 +1,9 @@ { "domain": "smartthings", "name": "SmartThings", - "after_dependencies": ["cloud"], - "codeowners": [], + "codeowners": ["@joostlek"], "config_flow": true, - "dependencies": ["webhook"], + "dependencies": ["application_credentials"], "dhcp": [ { "hostname": "st*", @@ -29,6 +28,6 @@ ], "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", - "loggers": ["httpsig", "pysmartapp", "pysmartthings"], - "requirements": ["pysmartapp==0.3.5", "pysmartthings==0.7.8"] + "loggers": ["pysmartthings"], + "requirements": ["pysmartthings==1.2.0"] } diff --git a/homeassistant/components/smartthings/scene.py b/homeassistant/components/smartthings/scene.py index aa6655b0134..2b387859f22 100644 --- a/homeassistant/components/smartthings/scene.py +++ b/homeassistant/components/smartthings/scene.py @@ -2,39 +2,42 @@ from typing import Any +from pysmartthings import Scene as STScene, SmartThings + from homeassistant.components.scene import Scene -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import SmartThingsConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Add switches for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] - async_add_entities(SmartThingsScene(scene) for scene in broker.scenes.values()) + """Add lights for a config entry.""" + client = entry.runtime_data.client + scenes = entry.runtime_data.scenes + async_add_entities(SmartThingsScene(scene, client) for scene in scenes.values()) class SmartThingsScene(Scene): """Define a SmartThings scene.""" - def __init__(self, scene): + def __init__(self, scene: STScene, client: SmartThings) -> None: """Init the scene class.""" + self.client = client self._scene = scene self._attr_name = scene.name self._attr_unique_id = scene.scene_id async def async_activate(self, **kwargs: Any) -> None: """Activate scene.""" - await self._scene.execute() + await self.client.execute_scene(self._scene.scene_id) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Get attributes about the state.""" return { "icon": self._scene.icon, diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 3a283bb806b..b16d332a1ae 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -2,13 +2,12 @@ from __future__ import annotations -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime from typing import Any -from pysmartthings import Attribute, Capability -from pysmartthings.device import DeviceEntity, DeviceStatus +from pysmartthings import Attribute, Capability, SmartThings from homeassistant.components.sensor import ( SensorDeviceClass, @@ -16,14 +15,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, EntityCategory, UnitOfArea, - UnitOfElectricPotential, UnitOfEnergy, UnitOfMass, UnitOfPower, @@ -34,17 +31,23 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity +THERMOSTAT_CAPABILITIES = { + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_HEATING_SETPOINT, + Capability.THERMOSTAT_MODE, +} -def power_attributes(status: DeviceStatus) -> dict[str, Any]: + +def power_attributes(status: dict[str, Any]) -> dict[str, Any]: """Return the power attributes.""" state = {} - for attribute in ("power_consumption_start", "power_consumption_end"): - value = getattr(status, attribute) - if value is not None: - state[attribute] = value + for attribute in ("start", "end"): + if (value := status.get(attribute)) is not None: + state[f"power_consumption_{attribute}"] = value return state @@ -53,62 +56,70 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): """Describe a SmartThings sensor entity.""" value_fn: Callable[[Any], str | float | int | datetime | None] = lambda value: value - extra_state_attributes_fn: Callable[[DeviceStatus], dict[str, Any]] | None = None + extra_state_attributes_fn: Callable[[Any], dict[str, Any]] | None = None unique_id_separator: str = "." + capability_ignore_list: list[set[Capability]] | None = None CAPABILITY_TO_SENSORS: dict[ - str, dict[str, list[SmartThingsSensorEntityDescription]] + Capability, dict[Attribute, list[SmartThingsSensorEntityDescription]] ] = { - Capability.activity_lighting_mode: { - Attribute.lighting_mode: [ + # no fixtures + Capability.ACTIVITY_LIGHTING_MODE: { + Attribute.LIGHTING_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.lighting_mode, + key=Attribute.LIGHTING_MODE, name="Activity Lighting Mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.air_conditioner_mode: { - Attribute.air_conditioner_mode: [ + Capability.AIR_CONDITIONER_MODE: { + Attribute.AIR_CONDITIONER_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.air_conditioner_mode, + key=Attribute.AIR_CONDITIONER_MODE, name="Air Conditioner Mode", entity_category=EntityCategory.DIAGNOSTIC, + capability_ignore_list=[ + { + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_COOLING_SETPOINT, + } + ], ) ] }, - Capability.air_quality_sensor: { - Attribute.air_quality: [ + Capability.AIR_QUALITY_SENSOR: { + Attribute.AIR_QUALITY: [ SmartThingsSensorEntityDescription( - key=Attribute.air_quality, + key=Attribute.AIR_QUALITY, name="Air Quality", native_unit_of_measurement="CAQI", state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.alarm: { - Attribute.alarm: [ + Capability.ALARM: { + Attribute.ALARM: [ SmartThingsSensorEntityDescription( - key=Attribute.alarm, + key=Attribute.ALARM, name="Alarm", ) ] }, - Capability.audio_volume: { - Attribute.volume: [ + Capability.AUDIO_VOLUME: { + Attribute.VOLUME: [ SmartThingsSensorEntityDescription( - key=Attribute.volume, + key=Attribute.VOLUME, name="Volume", native_unit_of_measurement=PERCENTAGE, ) ] }, - Capability.battery: { - Attribute.battery: [ + Capability.BATTERY: { + Attribute.BATTERY: [ SmartThingsSensorEntityDescription( - key=Attribute.battery, + key=Attribute.BATTERY, name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -116,20 +127,22 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.body_mass_index_measurement: { - Attribute.bmi_measurement: [ + # no fixtures + Capability.BODY_MASS_INDEX_MEASUREMENT: { + Attribute.BMI_MEASUREMENT: [ SmartThingsSensorEntityDescription( - key=Attribute.bmi_measurement, + key=Attribute.BMI_MEASUREMENT, name="Body Mass Index", native_unit_of_measurement=f"{UnitOfMass.KILOGRAMS}/{UnitOfArea.SQUARE_METERS}", state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.body_weight_measurement: { - Attribute.body_weight_measurement: [ + # no fixtures + Capability.BODY_WEIGHT_MEASUREMENT: { + Attribute.BODY_WEIGHT_MEASUREMENT: [ SmartThingsSensorEntityDescription( - key=Attribute.body_weight_measurement, + key=Attribute.BODY_WEIGHT_MEASUREMENT, name="Body Weight", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, @@ -137,10 +150,11 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.carbon_dioxide_measurement: { - Attribute.carbon_dioxide: [ + # no fixtures + Capability.CARBON_DIOXIDE_MEASUREMENT: { + Attribute.CARBON_DIOXIDE: [ SmartThingsSensorEntityDescription( - key=Attribute.carbon_dioxide, + key=Attribute.CARBON_DIOXIDE, name="Carbon Dioxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO2, @@ -148,18 +162,20 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.carbon_monoxide_detector: { - Attribute.carbon_monoxide: [ + # no fixtures + Capability.CARBON_MONOXIDE_DETECTOR: { + Attribute.CARBON_MONOXIDE: [ SmartThingsSensorEntityDescription( - key=Attribute.carbon_monoxide, + key=Attribute.CARBON_MONOXIDE, name="Carbon Monoxide Detector", ) ] }, - Capability.carbon_monoxide_measurement: { - Attribute.carbon_monoxide_level: [ + # no fixtures + Capability.CARBON_MONOXIDE_MEASUREMENT: { + Attribute.CARBON_MONOXIDE_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.carbon_monoxide_level, + key=Attribute.CARBON_MONOXIDE_LEVEL, name="Carbon Monoxide Level", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO, @@ -167,79 +183,80 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.dishwasher_operating_state: { - Attribute.machine_state: [ + Capability.DISHWASHER_OPERATING_STATE: { + Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.machine_state, + key=Attribute.MACHINE_STATE, name="Dishwasher Machine State", ) ], - Attribute.dishwasher_job_state: [ + Attribute.DISHWASHER_JOB_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.dishwasher_job_state, + key=Attribute.DISHWASHER_JOB_STATE, name="Dishwasher Job State", ) ], - Attribute.completion_time: [ + Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( - key=Attribute.completion_time, + key=Attribute.COMPLETION_TIME, name="Dishwasher Completion Time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) ], }, - Capability.dryer_mode: { - Attribute.dryer_mode: [ + # part of the proposed spec, no fixtures + Capability.DRYER_MODE: { + Attribute.DRYER_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.dryer_mode, + key=Attribute.DRYER_MODE, name="Dryer Mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.dryer_operating_state: { - Attribute.machine_state: [ + Capability.DRYER_OPERATING_STATE: { + Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.machine_state, + key=Attribute.MACHINE_STATE, name="Dryer Machine State", ) ], - Attribute.dryer_job_state: [ + Attribute.DRYER_JOB_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.dryer_job_state, + key=Attribute.DRYER_JOB_STATE, name="Dryer Job State", ) ], - Attribute.completion_time: [ + Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( - key=Attribute.completion_time, + key=Attribute.COMPLETION_TIME, name="Dryer Completion Time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) ], }, - Capability.dust_sensor: { - Attribute.fine_dust_level: [ + Capability.DUST_SENSOR: { + Attribute.DUST_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.fine_dust_level, - name="Fine Dust Level", - state_class=SensorStateClass.MEASUREMENT, - ) - ], - Attribute.dust_level: [ - SmartThingsSensorEntityDescription( - key=Attribute.dust_level, + key=Attribute.DUST_LEVEL, name="Dust Level", state_class=SensorStateClass.MEASUREMENT, ) ], - }, - Capability.energy_meter: { - Attribute.energy: [ + Attribute.FINE_DUST_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.energy, + key=Attribute.FINE_DUST_LEVEL, + name="Fine Dust Level", + state_class=SensorStateClass.MEASUREMENT, + ) + ], + }, + Capability.ENERGY_METER: { + Attribute.ENERGY: [ + SmartThingsSensorEntityDescription( + key=Attribute.ENERGY, name="Energy Meter", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -247,10 +264,11 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.equivalent_carbon_dioxide_measurement: { - Attribute.equivalent_carbon_dioxide_measurement: [ + # no fixtures + Capability.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: { + Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: [ SmartThingsSensorEntityDescription( - key=Attribute.equivalent_carbon_dioxide_measurement, + key=Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT, name="Equivalent Carbon Dioxide Measurement", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO2, @@ -258,43 +276,45 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.formaldehyde_measurement: { - Attribute.formaldehyde_level: [ + # no fixtures + Capability.FORMALDEHYDE_MEASUREMENT: { + Attribute.FORMALDEHYDE_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.formaldehyde_level, + key=Attribute.FORMALDEHYDE_LEVEL, name="Formaldehyde Measurement", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.gas_meter: { - Attribute.gas_meter: [ + # no fixtures + Capability.GAS_METER: { + Attribute.GAS_METER: [ SmartThingsSensorEntityDescription( - key=Attribute.gas_meter, + key=Attribute.GAS_METER, name="Gas Meter", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.MEASUREMENT, ) ], - Attribute.gas_meter_calorific: [ + Attribute.GAS_METER_CALORIFIC: [ SmartThingsSensorEntityDescription( - key=Attribute.gas_meter_calorific, + key=Attribute.GAS_METER_CALORIFIC, name="Gas Meter Calorific", ) ], - Attribute.gas_meter_time: [ + Attribute.GAS_METER_TIME: [ SmartThingsSensorEntityDescription( - key=Attribute.gas_meter_time, + key=Attribute.GAS_METER_TIME, name="Gas Meter Time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) ], - Attribute.gas_meter_volume: [ + Attribute.GAS_METER_VOLUME: [ SmartThingsSensorEntityDescription( - key=Attribute.gas_meter_volume, + key=Attribute.GAS_METER_VOLUME, name="Gas Meter Volume", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.GAS, @@ -302,114 +322,117 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - Capability.illuminance_measurement: { - Attribute.illuminance: [ + # no fixtures + Capability.ILLUMINANCE_MEASUREMENT: { + Attribute.ILLUMINANCE: [ SmartThingsSensorEntityDescription( - key=Attribute.illuminance, + key=Attribute.ILLUMINANCE, name="Illuminance", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.infrared_level: { - Attribute.infrared_level: [ + # no fixtures + Capability.INFRARED_LEVEL: { + Attribute.INFRARED_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.infrared_level, + key=Attribute.INFRARED_LEVEL, name="Infrared Level", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.media_input_source: { - Attribute.input_source: [ + Capability.MEDIA_INPUT_SOURCE: { + Attribute.INPUT_SOURCE: [ SmartThingsSensorEntityDescription( - key=Attribute.input_source, + key=Attribute.INPUT_SOURCE, name="Media Input Source", ) ] }, - Capability.media_playback_repeat: { - Attribute.playback_repeat_mode: [ + # part of the proposed spec, no fixtures + Capability.MEDIA_PLAYBACK_REPEAT: { + Attribute.PLAYBACK_REPEAT_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.playback_repeat_mode, + key=Attribute.PLAYBACK_REPEAT_MODE, name="Media Playback Repeat", ) ] }, - Capability.media_playback_shuffle: { - Attribute.playback_shuffle: [ + # part of the proposed spec, no fixtures + Capability.MEDIA_PLAYBACK_SHUFFLE: { + Attribute.PLAYBACK_SHUFFLE: [ SmartThingsSensorEntityDescription( - key=Attribute.playback_shuffle, + key=Attribute.PLAYBACK_SHUFFLE, name="Media Playback Shuffle", ) ] }, - Capability.media_playback: { - Attribute.playback_status: [ + Capability.MEDIA_PLAYBACK: { + Attribute.PLAYBACK_STATUS: [ SmartThingsSensorEntityDescription( - key=Attribute.playback_status, + key=Attribute.PLAYBACK_STATUS, name="Media Playback Status", ) ] }, - Capability.odor_sensor: { - Attribute.odor_level: [ + Capability.ODOR_SENSOR: { + Attribute.ODOR_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.odor_level, + key=Attribute.ODOR_LEVEL, name="Odor Sensor", ) ] }, - Capability.oven_mode: { - Attribute.oven_mode: [ + Capability.OVEN_MODE: { + Attribute.OVEN_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.oven_mode, + key=Attribute.OVEN_MODE, name="Oven Mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.oven_operating_state: { - Attribute.machine_state: [ + Capability.OVEN_OPERATING_STATE: { + Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.machine_state, + key=Attribute.MACHINE_STATE, name="Oven Machine State", ) ], - Attribute.oven_job_state: [ + Attribute.OVEN_JOB_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.oven_job_state, + key=Attribute.OVEN_JOB_STATE, name="Oven Job State", ) ], - Attribute.completion_time: [ + Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( - key=Attribute.completion_time, + key=Attribute.COMPLETION_TIME, name="Oven Completion Time", ) ], }, - Capability.oven_setpoint: { - Attribute.oven_setpoint: [ + Capability.OVEN_SETPOINT: { + Attribute.OVEN_SETPOINT: [ SmartThingsSensorEntityDescription( - key=Attribute.oven_setpoint, + key=Attribute.OVEN_SETPOINT, name="Oven Set Point", ) ] }, - Capability.power_consumption_report: { - Attribute.power_consumption: [ + Capability.POWER_CONSUMPTION_REPORT: { + Attribute.POWER_CONSUMPTION: [ SmartThingsSensorEntityDescription( key="energy_meter", name="energy", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda value: ( - val / 1000 if (val := value.get("energy")) is not None else None - ), + value_fn=lambda value: value["energy"] / 1000, ), SmartThingsSensorEntityDescription( key="power_meter", @@ -417,7 +440,7 @@ CAPABILITY_TO_SENSORS: dict[ state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, - value_fn=lambda value: value.get("power"), + value_fn=lambda value: value["power"], extra_state_attributes_fn=power_attributes, ), SmartThingsSensorEntityDescription( @@ -426,11 +449,7 @@ CAPABILITY_TO_SENSORS: dict[ state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda value: ( - val / 1000 - if (val := value.get("deltaEnergy")) is not None - else None - ), + value_fn=lambda value: value["deltaEnergy"] / 1000, ), SmartThingsSensorEntityDescription( key="powerEnergy_meter", @@ -438,11 +457,7 @@ CAPABILITY_TO_SENSORS: dict[ state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda value: ( - val / 1000 - if (val := value.get("powerEnergy")) is not None - else None - ), + value_fn=lambda value: value["powerEnergy"] / 1000, ), SmartThingsSensorEntityDescription( key="energySaved_meter", @@ -450,18 +465,14 @@ CAPABILITY_TO_SENSORS: dict[ state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda value: ( - val / 1000 - if (val := value.get("energySaved")) is not None - else None - ), + value_fn=lambda value: value["energySaved"] / 1000, ), ] }, - Capability.power_meter: { - Attribute.power: [ + Capability.POWER_METER: { + Attribute.POWER: [ SmartThingsSensorEntityDescription( - key=Attribute.power, + key=Attribute.POWER, name="Power Meter", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -469,72 +480,76 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.power_source: { - Attribute.power_source: [ + # no fixtures + Capability.POWER_SOURCE: { + Attribute.POWER_SOURCE: [ SmartThingsSensorEntityDescription( - key=Attribute.power_source, + key=Attribute.POWER_SOURCE, name="Power Source", entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.refrigeration_setpoint: { - Attribute.refrigeration_setpoint: [ + # part of the proposed spec + Capability.REFRIGERATION_SETPOINT: { + Attribute.REFRIGERATION_SETPOINT: [ SmartThingsSensorEntityDescription( - key=Attribute.refrigeration_setpoint, + key=Attribute.REFRIGERATION_SETPOINT, name="Refrigeration Setpoint", + device_class=SensorDeviceClass.TEMPERATURE, ) ] }, - Capability.relative_humidity_measurement: { - Attribute.humidity: [ + Capability.RELATIVE_HUMIDITY_MEASUREMENT: { + Attribute.HUMIDITY: [ SmartThingsSensorEntityDescription( - key=Attribute.humidity, - name="Relative Humidity", + key=Attribute.HUMIDITY, + name="Relative Humidity Measurement", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.robot_cleaner_cleaning_mode: { - Attribute.robot_cleaner_cleaning_mode: [ + Capability.ROBOT_CLEANER_CLEANING_MODE: { + Attribute.ROBOT_CLEANER_CLEANING_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.robot_cleaner_cleaning_mode, + key=Attribute.ROBOT_CLEANER_CLEANING_MODE, name="Robot Cleaner Cleaning Mode", entity_category=EntityCategory.DIAGNOSTIC, ) - ] + ], }, - Capability.robot_cleaner_movement: { - Attribute.robot_cleaner_movement: [ + Capability.ROBOT_CLEANER_MOVEMENT: { + Attribute.ROBOT_CLEANER_MOVEMENT: [ SmartThingsSensorEntityDescription( - key=Attribute.robot_cleaner_movement, + key=Attribute.ROBOT_CLEANER_MOVEMENT, name="Robot Cleaner Movement", ) ] }, - Capability.robot_cleaner_turbo_mode: { - Attribute.robot_cleaner_turbo_mode: [ + Capability.ROBOT_CLEANER_TURBO_MODE: { + Attribute.ROBOT_CLEANER_TURBO_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.robot_cleaner_turbo_mode, + key=Attribute.ROBOT_CLEANER_TURBO_MODE, name="Robot Cleaner Turbo Mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.signal_strength: { - Attribute.lqi: [ + # no fixtures + Capability.SIGNAL_STRENGTH: { + Attribute.LQI: [ SmartThingsSensorEntityDescription( - key=Attribute.lqi, + key=Attribute.LQI, name="LQI Signal Strength", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ) ], - Attribute.rssi: [ + Attribute.RSSI: [ SmartThingsSensorEntityDescription( - key=Attribute.rssi, + key=Attribute.RSSI, name="RSSI Signal Strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, @@ -542,85 +557,99 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - Capability.smoke_detector: { - Attribute.smoke: [ + # no fixtures + Capability.SMOKE_DETECTOR: { + Attribute.SMOKE: [ SmartThingsSensorEntityDescription( - key=Attribute.smoke, + key=Attribute.SMOKE, name="Smoke Detector", ) ] }, - Capability.temperature_measurement: { - Attribute.temperature: [ + Capability.TEMPERATURE_MEASUREMENT: { + Attribute.TEMPERATURE: [ SmartThingsSensorEntityDescription( - key=Attribute.temperature, + key=Attribute.TEMPERATURE, name="Temperature Measurement", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.thermostat_cooling_setpoint: { - Attribute.cooling_setpoint: [ + Capability.THERMOSTAT_COOLING_SETPOINT: { + Attribute.COOLING_SETPOINT: [ SmartThingsSensorEntityDescription( - key=Attribute.cooling_setpoint, + key=Attribute.COOLING_SETPOINT, name="Thermostat Cooling Setpoint", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + capability_ignore_list=[ + { + Capability.AIR_CONDITIONER_FAN_MODE, + Capability.TEMPERATURE_MEASUREMENT, + Capability.AIR_CONDITIONER_MODE, + }, + THERMOSTAT_CAPABILITIES, + ], ) ] }, - Capability.thermostat_fan_mode: { - Attribute.thermostat_fan_mode: [ + # no fixtures + Capability.THERMOSTAT_FAN_MODE: { + Attribute.THERMOSTAT_FAN_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.thermostat_fan_mode, + key=Attribute.THERMOSTAT_FAN_MODE, name="Thermostat Fan Mode", entity_category=EntityCategory.DIAGNOSTIC, + capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) ] }, - Capability.thermostat_heating_setpoint: { - Attribute.heating_setpoint: [ + # no fixtures + Capability.THERMOSTAT_HEATING_SETPOINT: { + Attribute.HEATING_SETPOINT: [ SmartThingsSensorEntityDescription( - key=Attribute.heating_setpoint, + key=Attribute.HEATING_SETPOINT, name="Thermostat Heating Setpoint", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, + capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) ] }, - Capability.thermostat_mode: { - Attribute.thermostat_mode: [ + # no fixtures + Capability.THERMOSTAT_MODE: { + Attribute.THERMOSTAT_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.thermostat_mode, + key=Attribute.THERMOSTAT_MODE, name="Thermostat Mode", entity_category=EntityCategory.DIAGNOSTIC, + capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) ] }, - Capability.thermostat_operating_state: { - Attribute.thermostat_operating_state: [ + # no fixtures + Capability.THERMOSTAT_OPERATING_STATE: { + Attribute.THERMOSTAT_OPERATING_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.thermostat_operating_state, + key=Attribute.THERMOSTAT_OPERATING_STATE, name="Thermostat Operating State", + capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) ] }, - Capability.thermostat_setpoint: { - Attribute.thermostat_setpoint: [ + # deprecated capability + Capability.THERMOSTAT_SETPOINT: { + Attribute.THERMOSTAT_SETPOINT: [ SmartThingsSensorEntityDescription( - key=Attribute.thermostat_setpoint, + key=Attribute.THERMOSTAT_SETPOINT, name="Thermostat Setpoint", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.three_axis: { - Attribute.three_axis: [ + Capability.THREE_AXIS: { + Attribute.THREE_AXIS: [ SmartThingsSensorEntityDescription( key="X Coordinate", name="X Coordinate", @@ -641,75 +670,77 @@ CAPABILITY_TO_SENSORS: dict[ ), ] }, - Capability.tv_channel: { - Attribute.tv_channel: [ + Capability.TV_CHANNEL: { + Attribute.TV_CHANNEL: [ SmartThingsSensorEntityDescription( - key=Attribute.tv_channel, + key=Attribute.TV_CHANNEL, name="Tv Channel", ) ], - Attribute.tv_channel_name: [ + Attribute.TV_CHANNEL_NAME: [ SmartThingsSensorEntityDescription( - key=Attribute.tv_channel_name, + key=Attribute.TV_CHANNEL_NAME, name="Tv Channel Name", ) ], }, - Capability.tvoc_measurement: { - Attribute.tvoc_level: [ + # no fixtures + Capability.TVOC_MEASUREMENT: { + Attribute.TVOC_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.tvoc_level, + key=Attribute.TVOC_LEVEL, name="Tvoc Measurement", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.ultraviolet_index: { - Attribute.ultraviolet_index: [ + # no fixtures + Capability.ULTRAVIOLET_INDEX: { + Attribute.ULTRAVIOLET_INDEX: [ SmartThingsSensorEntityDescription( - key=Attribute.ultraviolet_index, + key=Attribute.ULTRAVIOLET_INDEX, name="Ultraviolet Index", state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.voltage_measurement: { - Attribute.voltage: [ + Capability.VOLTAGE_MEASUREMENT: { + Attribute.VOLTAGE: [ SmartThingsSensorEntityDescription( - key=Attribute.voltage, + key=Attribute.VOLTAGE, name="Voltage Measurement", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.washer_mode: { - Attribute.washer_mode: [ + # part of the proposed spec + Capability.WASHER_MODE: { + Attribute.WASHER_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.washer_mode, + key=Attribute.WASHER_MODE, name="Washer Mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.washer_operating_state: { - Attribute.machine_state: [ + Capability.WASHER_OPERATING_STATE: { + Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.machine_state, + key=Attribute.MACHINE_STATE, name="Washer Machine State", ) ], - Attribute.washer_job_state: [ + Attribute.WASHER_JOB_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.washer_job_state, + key=Attribute.WASHER_JOB_STATE, name="Washer Job State", ) ], - Attribute.completion_time: [ + Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( - key=Attribute.completion_time, + key=Attribute.COMPLETION_TIME, name="Washer Completion Time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, @@ -718,37 +749,37 @@ CAPABILITY_TO_SENSORS: dict[ }, } + UNITS = { "C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT, "lux": LIGHT_LUX, - "mG": None, # Three axis sensors never had a unit, so this removes it for now + "mG": None, } async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add sensors for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - SmartThingsSensor(device, attribute, description) - for device in broker.devices.values() - for capability in broker.get_assigned(device.device_id, "sensor") - for attribute, descriptions in CAPABILITY_TO_SENSORS[capability].items() - for description in descriptions + SmartThingsSensor(entry_data.client, device, description, capability, attribute) + for device in entry_data.devices.values() + for capability, attributes in device.status[MAIN].items() + if capability in CAPABILITY_TO_SENSORS + for attribute in attributes + for description in CAPABILITY_TO_SENSORS[capability].get(attribute, []) + if not description.capability_ignore_list + or not any( + all(capability in device.status[MAIN] for capability in capability_list) + for capability_list in description.capability_ignore_list + ) ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - return [ - capability for capability in CAPABILITY_TO_SENSORS if capability in capabilities - ] - - class SmartThingsSensor(SmartThingsEntity, SensorEntity): """Define a SmartThings Sensor.""" @@ -756,28 +787,30 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): def __init__( self, - device: DeviceEntity, - attribute: str, + client: SmartThings, + device: FullDevice, entity_description: SmartThingsSensorEntityDescription, + capability: Capability, + attribute: Attribute, ) -> None: """Init the class.""" - super().__init__(device) + super().__init__(client, device, {capability}) + self._attr_name = f"{device.device.label} {entity_description.name}" + self._attr_unique_id = f"{device.device.device_id}{entity_description.unique_id_separator}{entity_description.key}" self._attribute = attribute - self._attr_name = f"{device.label} {entity_description.name}" - self._attr_unique_id = f"{device.device_id}{entity_description.unique_id_separator}{entity_description.key}" + self.capability = capability self.entity_description = entity_description @property - def native_value(self) -> str | float | int | datetime | None: + def native_value(self) -> str | float | datetime | int | None: """Return the state of the sensor.""" - return self.entity_description.value_fn( - self._device.status.attributes[self._attribute].value - ) + res = self.get_attribute_value(self.capability, self._attribute) + return self.entity_description.value_fn(res) @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" - unit = self._device.status.attributes[self._attribute].unit + unit = self._internal_state[self.capability][self._attribute].unit return ( UNITS.get(unit, unit) if unit @@ -789,6 +822,6 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): """Return the state attributes.""" if self.entity_description.extra_state_attributes_fn: return self.entity_description.extra_state_attributes_fn( - self._device.status + self.get_attribute_value(self.capability, self._attribute) ) return None diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py deleted file mode 100644 index 76b6804075f..00000000000 --- a/homeassistant/components/smartthings/smartapp.py +++ /dev/null @@ -1,545 +0,0 @@ -"""SmartApp functionality to receive cloud-push notifications.""" - -from __future__ import annotations - -import asyncio -import functools -import logging -import secrets -from typing import Any -from urllib.parse import urlparse -from uuid import uuid4 - -from aiohttp import web -from pysmartapp import Dispatcher, SmartAppManager -from pysmartapp.const import SETTINGS_APP_ID -from pysmartthings import ( - APP_TYPE_WEBHOOK, - CAPABILITIES, - CLASSIFICATION_AUTOMATION, - App, - AppEntity, - AppOAuth, - AppSettings, - InstalledAppStatus, - SmartThings, - SourceType, - Subscription, - SubscriptionEntity, -) - -from homeassistant.components import cloud, webhook -from homeassistant.config_entries import ConfigFlowResult -from homeassistant.const import CONF_WEBHOOK_ID -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.network import NoURLAvailableError, get_url -from homeassistant.helpers.storage import Store - -from .const import ( - APP_NAME_PREFIX, - APP_OAUTH_CLIENT_NAME, - APP_OAUTH_SCOPES, - CONF_CLOUDHOOK_URL, - CONF_INSTALLED_APP_ID, - CONF_INSTANCE_ID, - CONF_REFRESH_TOKEN, - DATA_BROKERS, - DATA_MANAGER, - DOMAIN, - IGNORED_CAPABILITIES, - SETTINGS_INSTANCE_ID, - SIGNAL_SMARTAPP_PREFIX, - STORAGE_KEY, - STORAGE_VERSION, - SUBSCRIPTION_WARNING_LIMIT, -) - -_LOGGER = logging.getLogger(__name__) - - -def format_unique_id(app_id: str, location_id: str) -> str: - """Format the unique id for a config entry.""" - return f"{app_id}_{location_id}" - - -async def find_app(hass: HomeAssistant, api: SmartThings) -> AppEntity | None: - """Find an existing SmartApp for this installation of hass.""" - apps = await api.apps() - for app in [app for app in apps if app.app_name.startswith(APP_NAME_PREFIX)]: - # Load settings to compare instance id - settings = await app.settings() - if ( - settings.settings.get(SETTINGS_INSTANCE_ID) - == hass.data[DOMAIN][CONF_INSTANCE_ID] - ): - return app - return None - - -async def validate_installed_app(api, installed_app_id: str): - """Ensure the specified installed SmartApp is valid and functioning. - - Query the API for the installed SmartApp and validate that it is tied to - the specified app_id and is in an authorized state. - """ - installed_app = await api.installed_app(installed_app_id) - if installed_app.installed_app_status != InstalledAppStatus.AUTHORIZED: - raise RuntimeWarning( - f"Installed SmartApp instance '{installed_app.display_name}' " - f"({installed_app.installed_app_id}) is not AUTHORIZED " - f"but instead {installed_app.installed_app_status}" - ) - return installed_app - - -def validate_webhook_requirements(hass: HomeAssistant) -> bool: - """Ensure Home Assistant is setup properly to receive webhooks.""" - if cloud.async_active_subscription(hass): - return True - if hass.data[DOMAIN][CONF_CLOUDHOOK_URL] is not None: - return True - return get_webhook_url(hass).lower().startswith("https://") - - -def get_webhook_url(hass: HomeAssistant) -> str: - """Get the URL of the webhook. - - Return the cloudhook if available, otherwise local webhook. - """ - cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] - if cloud.async_active_subscription(hass) and cloudhook_url is not None: - return cloudhook_url - return webhook.async_generate_url(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) - - -def _get_app_template(hass: HomeAssistant): - try: - endpoint = f"at {get_url(hass, allow_cloud=False, prefer_external=True)}" - except NoURLAvailableError: - endpoint = "" - - cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] - if cloudhook_url is not None: - endpoint = "via Nabu Casa" - description = f"{hass.config.location_name} {endpoint}" - - return { - "app_name": APP_NAME_PREFIX + str(uuid4()), - "display_name": "Home Assistant", - "description": description, - "webhook_target_url": get_webhook_url(hass), - "app_type": APP_TYPE_WEBHOOK, - "single_instance": True, - "classifications": [CLASSIFICATION_AUTOMATION], - } - - -async def create_app(hass: HomeAssistant, api): - """Create a SmartApp for this instance of hass.""" - # Create app from template attributes - template = _get_app_template(hass) - app = App() - for key, value in template.items(): - setattr(app, key, value) - app, client = await api.create_app(app) - _LOGGER.debug("Created SmartApp '%s' (%s)", app.app_name, app.app_id) - - # Set unique hass id in settings - settings = AppSettings(app.app_id) - settings.settings[SETTINGS_APP_ID] = app.app_id - settings.settings[SETTINGS_INSTANCE_ID] = hass.data[DOMAIN][CONF_INSTANCE_ID] - await api.update_app_settings(settings) - _LOGGER.debug( - "Updated App Settings for SmartApp '%s' (%s)", app.app_name, app.app_id - ) - - # Set oauth scopes - oauth = AppOAuth(app.app_id) - oauth.client_name = APP_OAUTH_CLIENT_NAME - oauth.scope.extend(APP_OAUTH_SCOPES) - await api.update_app_oauth(oauth) - _LOGGER.debug("Updated App OAuth for SmartApp '%s' (%s)", app.app_name, app.app_id) - return app, client - - -async def update_app(hass: HomeAssistant, app): - """Ensure the SmartApp is up-to-date and update if necessary.""" - template = _get_app_template(hass) - template.pop("app_name") # don't update this - update_required = False - for key, value in template.items(): - if getattr(app, key) != value: - update_required = True - setattr(app, key, value) - if update_required: - await app.save() - _LOGGER.debug( - "SmartApp '%s' (%s) updated with latest settings", app.app_name, app.app_id - ) - - -def setup_smartapp(hass, app): - """Configure an individual SmartApp in hass. - - Register the SmartApp with the SmartAppManager so that hass will service - lifecycle events (install, event, etc...). A unique SmartApp is created - for each SmartThings account that is configured in hass. - """ - manager = hass.data[DOMAIN][DATA_MANAGER] - if smartapp := manager.smartapps.get(app.app_id): - # already setup - return smartapp - smartapp = manager.register(app.app_id, app.webhook_public_key) - smartapp.name = app.display_name - smartapp.description = app.description - smartapp.permissions.extend(APP_OAUTH_SCOPES) - return smartapp - - -async def setup_smartapp_endpoint(hass: HomeAssistant, fresh_install: bool): - """Configure the SmartApp webhook in hass. - - SmartApps are an extension point within the SmartThings ecosystem and - is used to receive push updates (i.e. device updates) from the cloud. - """ - if hass.data.get(DOMAIN): - # already setup - if not fresh_install: - return - - # We're doing a fresh install, clean up - await unload_smartapp_endpoint(hass) - - # Get/create config to store a unique id for this hass instance. - store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) - - if fresh_install or not (config := await store.async_load()): - # Create config - config = { - CONF_INSTANCE_ID: str(uuid4()), - CONF_WEBHOOK_ID: secrets.token_hex(), - CONF_CLOUDHOOK_URL: None, - } - await store.async_save(config) - - # Register webhook - webhook.async_register( - hass, DOMAIN, "SmartApp", config[CONF_WEBHOOK_ID], smartapp_webhook - ) - - # Create webhook if eligible - cloudhook_url = config.get(CONF_CLOUDHOOK_URL) - if ( - cloudhook_url is None - and cloud.async_active_subscription(hass) - and not hass.config_entries.async_entries(DOMAIN) - ): - cloudhook_url = await cloud.async_create_cloudhook( - hass, config[CONF_WEBHOOK_ID] - ) - config[CONF_CLOUDHOOK_URL] = cloudhook_url - await store.async_save(config) - _LOGGER.debug("Created cloudhook '%s'", cloudhook_url) - - # SmartAppManager uses a dispatcher to invoke callbacks when push events - # occur. Use hass' implementation instead of the built-in one. - dispatcher = Dispatcher( - signal_prefix=SIGNAL_SMARTAPP_PREFIX, - connect=functools.partial(async_dispatcher_connect, hass), - send=functools.partial(async_dispatcher_send, hass), - ) - # Path is used in digital signature validation - path = ( - urlparse(cloudhook_url).path - if cloudhook_url - else webhook.async_generate_path(config[CONF_WEBHOOK_ID]) - ) - manager = SmartAppManager(path, dispatcher=dispatcher) - manager.connect_install(functools.partial(smartapp_install, hass)) - manager.connect_update(functools.partial(smartapp_update, hass)) - manager.connect_uninstall(functools.partial(smartapp_uninstall, hass)) - - hass.data[DOMAIN] = { - DATA_MANAGER: manager, - CONF_INSTANCE_ID: config[CONF_INSTANCE_ID], - DATA_BROKERS: {}, - CONF_WEBHOOK_ID: config[CONF_WEBHOOK_ID], - # Will not be present if not enabled - CONF_CLOUDHOOK_URL: config.get(CONF_CLOUDHOOK_URL), - } - _LOGGER.debug( - "Setup endpoint for %s", - cloudhook_url - if cloudhook_url - else webhook.async_generate_url(hass, config[CONF_WEBHOOK_ID]), - ) - - -async def unload_smartapp_endpoint(hass: HomeAssistant): - """Tear down the component configuration.""" - if DOMAIN not in hass.data: - return - # Remove the cloudhook if it was created - cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] - if cloudhook_url and cloud.async_is_logged_in(hass): - await cloud.async_delete_cloudhook(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) - # Remove cloudhook from storage - store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) - await store.async_save( - { - CONF_INSTANCE_ID: hass.data[DOMAIN][CONF_INSTANCE_ID], - CONF_WEBHOOK_ID: hass.data[DOMAIN][CONF_WEBHOOK_ID], - CONF_CLOUDHOOK_URL: None, - } - ) - _LOGGER.debug("Cloudhook '%s' was removed", cloudhook_url) - # Remove the webhook - webhook.async_unregister(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) - # Disconnect all brokers - for broker in hass.data[DOMAIN][DATA_BROKERS].values(): - broker.disconnect() - # Remove all handlers from manager - hass.data[DOMAIN][DATA_MANAGER].dispatcher.disconnect_all() - # Remove the component data - hass.data.pop(DOMAIN) - - -async def smartapp_sync_subscriptions( - hass: HomeAssistant, - auth_token: str, - location_id: str, - installed_app_id: str, - devices, -): - """Synchronize subscriptions of an installed up.""" - api = SmartThings(async_get_clientsession(hass), auth_token) - tasks = [] - - async def create_subscription(target: str): - sub = Subscription() - sub.installed_app_id = installed_app_id - sub.location_id = location_id - sub.source_type = SourceType.CAPABILITY - sub.capability = target - try: - await api.create_subscription(sub) - _LOGGER.debug( - "Created subscription for '%s' under app '%s'", target, installed_app_id - ) - except Exception as error: # noqa: BLE001 - _LOGGER.error( - "Failed to create subscription for '%s' under app '%s': %s", - target, - installed_app_id, - error, - ) - - async def delete_subscription(sub: SubscriptionEntity): - try: - await api.delete_subscription(installed_app_id, sub.subscription_id) - _LOGGER.debug( - ( - "Removed subscription for '%s' under app '%s' because it was no" - " longer needed" - ), - sub.capability, - installed_app_id, - ) - except Exception as error: # noqa: BLE001 - _LOGGER.error( - "Failed to remove subscription for '%s' under app '%s': %s", - sub.capability, - installed_app_id, - error, - ) - - # Build set of capabilities and prune unsupported ones - capabilities = set() - for device in devices: - capabilities.update(device.capabilities) - # Remove items not defined in the library - capabilities.intersection_update(CAPABILITIES) - # Remove unused capabilities - capabilities.difference_update(IGNORED_CAPABILITIES) - capability_count = len(capabilities) - if capability_count > SUBSCRIPTION_WARNING_LIMIT: - _LOGGER.warning( - ( - "Some device attributes may not receive push updates and there may be" - " subscription creation failures under app '%s' because %s" - " subscriptions are required but there is a limit of %s per app" - ), - installed_app_id, - capability_count, - SUBSCRIPTION_WARNING_LIMIT, - ) - _LOGGER.debug( - "Synchronizing subscriptions for %s capabilities under app '%s': %s", - capability_count, - installed_app_id, - capabilities, - ) - - # Get current subscriptions and find differences - subscriptions = await api.subscriptions(installed_app_id) - for subscription in subscriptions: - if subscription.capability in capabilities: - capabilities.remove(subscription.capability) - else: - # Delete the subscription - tasks.append(delete_subscription(subscription)) - - # Remaining capabilities need subscriptions created - tasks.extend([create_subscription(c) for c in capabilities]) - - if tasks: - await asyncio.gather(*tasks) - else: - _LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id) - - -async def _find_and_continue_flow( - hass: HomeAssistant, - app_id: str, - location_id: str, - installed_app_id: str, - refresh_token: str, -): - """Continue a config flow if one is in progress for the specific installed app.""" - unique_id = format_unique_id(app_id, location_id) - flow = next( - ( - flow - for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) - if flow["context"].get("unique_id") == unique_id - ), - None, - ) - if flow is not None: - await _continue_flow(hass, app_id, installed_app_id, refresh_token, flow) - - -async def _continue_flow( - hass: HomeAssistant, - app_id: str, - installed_app_id: str, - refresh_token: str, - flow: ConfigFlowResult, -) -> None: - await hass.config_entries.flow.async_configure( - flow["flow_id"], - { - CONF_INSTALLED_APP_ID: installed_app_id, - CONF_REFRESH_TOKEN: refresh_token, - }, - ) - _LOGGER.debug( - "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", - flow["flow_id"], - installed_app_id, - app_id, - ) - - -async def smartapp_install(hass: HomeAssistant, req, resp, app): - """Handle a SmartApp installation and continue the config flow.""" - await _find_and_continue_flow( - hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token - ) - _LOGGER.debug( - "Installed SmartApp '%s' under parent app '%s'", - req.installed_app_id, - app.app_id, - ) - - -async def smartapp_update(hass: HomeAssistant, req, resp, app): - """Handle a SmartApp update and either update the entry or continue the flow.""" - unique_id = format_unique_id(app.app_id, req.location_id) - flow = next( - ( - flow - for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) - if flow["context"].get("unique_id") == unique_id - and flow["step_id"] == "authorize" - ), - None, - ) - if flow is not None: - await _continue_flow( - hass, app.app_id, req.installed_app_id, req.refresh_token, flow - ) - _LOGGER.debug( - "Continued reauth flow '%s' for SmartApp '%s' under parent app '%s'", - flow["flow_id"], - req.installed_app_id, - app.app_id, - ) - return - entry = next( - ( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id - ), - None, - ) - if entry: - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_REFRESH_TOKEN: req.refresh_token} - ) - _LOGGER.debug( - "Updated config entry '%s' for SmartApp '%s' under parent app '%s'", - entry.entry_id, - req.installed_app_id, - app.app_id, - ) - - await _find_and_continue_flow( - hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token - ) - _LOGGER.debug( - "Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id - ) - - -async def smartapp_uninstall(hass: HomeAssistant, req, resp, app): - """Handle when a SmartApp is removed from a location by the user. - - Find and delete the config entry representing the integration. - """ - entry = next( - ( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id - ), - None, - ) - if entry: - # Add as job not needed because the current coroutine was invoked - # from the dispatcher and is not being awaited. - await hass.config_entries.async_remove(entry.entry_id) - - _LOGGER.debug( - "Uninstalled SmartApp '%s' under parent app '%s'", - req.installed_app_id, - app.app_id, - ) - - -async def smartapp_webhook(hass: HomeAssistant, webhook_id: str, request): - """Handle a smartapp lifecycle event callback from SmartThings. - - Requests from SmartThings are digitally signed and the SmartAppManager - validates the signature for authenticity. - """ - manager = hass.data[DOMAIN][DATA_MANAGER] - data = await request.json() - result = await manager.handle_request(data, request.headers) - return web.json_response(result) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 31a552be149..5112d819026 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -1,43 +1,29 @@ { "config": { "step": { - "user": { - "title": "Confirm Callback URL", - "description": "SmartThings will be configured to send push updates to Home Assistant at:\n> {webhook_url}\n\nIf this is not correct, please update your configuration, restart Home Assistant, and try again." + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "pat": { - "title": "Enter Personal Access Token", - "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.\n\n**Please note that all Personal Access Tokens created after 30 December 2024 are only valid for 24 hours, after which the integration will stop working. We are working on a fix.**", - "data": { - "access_token": "[%key:common::config_flow::data::access_token%]" - } - }, - "select_location": { - "title": "Select Location", - "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.", - "data": { "location_id": "[%key:common::config_flow::data::location%]" } - }, - "authorize": { "title": "Authorize Home Assistant" }, "reauth_confirm": { - "title": "Reauthorize Home Assistant", - "description": "You are about to reauthorize Home Assistant with SmartThings. This will require you to log in and authorize the integration again." - }, - "update_confirm": { - "title": "Finish reauthentication", - "description": "You have almost successfully reauthorized Home Assistant with SmartThings. Please press the button down below to finish the process." + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The SmartThings integration needs to re-authenticate your account" } }, - "abort": { - "invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.", - "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant.", - "reauth_successful": "Home Assistant has been successfully reauthorized with SmartThings." - }, "error": { - "token_invalid_format": "The token must be in the UID/GUID format", - "token_unauthorized": "The token is invalid or no longer authorized.", - "token_forbidden": "The token does not have the required OAuth scopes.", - "app_setup_error": "Unable to set up the SmartApp. Please try again.", - "webhook_error": "SmartThings could not validate the webhook URL. Please ensure the webhook URL is reachable from the internet and try again." + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "abort": { + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reauth_account_mismatch": "Authenticated account does not match the account to be reauthenticated. Please log in with the correct account and pick the right location.", + "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location." } } } diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 7a88ca0c422..d8cd9f1f956 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -2,60 +2,67 @@ from __future__ import annotations -from collections.abc import Sequence from typing import Any -from pysmartthings import Capability +from pysmartthings import Attribute, Capability, Command from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity +CAPABILITIES = ( + Capability.SWITCH_LEVEL, + Capability.COLOR_CONTROL, + Capability.COLOR_TEMPERATURE, + Capability.FAN_SPEED, +) + +AC_CAPABILITIES = ( + Capability.AIR_CONDITIONER_MODE, + Capability.AIR_CONDITIONER_FAN_MODE, + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_COOLING_SETPOINT, +) + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add switches for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - SmartThingsSwitch(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, "switch") + SmartThingsSwitch(entry_data.client, device, {Capability.SWITCH}) + for device in entry_data.devices.values() + if Capability.SWITCH in device.status[MAIN] + and not any(capability in device.status[MAIN] for capability in CAPABILITIES) + and not all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - # Must be able to be turned on/off. - if Capability.switch in capabilities: - return [Capability.switch, Capability.energy_meter, Capability.power_meter] - return None - - class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): """Define a SmartThings switch.""" async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self._device.switch_off(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.SWITCH, + Command.OFF, + ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self._device.switch_on(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.SWITCH, + Command.ON, + ) @property def is_on(self) -> bool: """Return true if light is on.""" - return self._device.status.switch + return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 08fe28e4df5..b891e807a7f 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -28,6 +28,7 @@ APPLICATION_CREDENTIALS = [ "onedrive", "point", "senz", + "smartthings", "spotify", "tesla_fleet", "twitch", diff --git a/requirements_all.txt b/requirements_all.txt index 40df67dc93f..54c0a29bee5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,10 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartapp==0.3.5 - -# homeassistant.components.smartthings -pysmartthings==0.7.8 +pysmartthings==1.2.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 029b770512e..a3f171fa1a9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,10 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartapp==0.3.5 - -# homeassistant.components.smartthings -pysmartthings==0.7.8 +pysmartthings==1.2.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index 5a3e9135963..94a2e7512f2 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -1 +1,75 @@ -"""Tests for the SmartThings component.""" +"""Tests for the SmartThings integration.""" + +from typing import Any +from unittest.mock import AsyncMock + +from pysmartthings.models import Attribute, Capability, DeviceEvent +from syrupy import SnapshotAssertion + +from homeassistant.components.smartthings.const import MAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +def snapshot_smartthings_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + platform: Platform, +) -> None: + """Snapshot SmartThings entities.""" + entities = hass.states.async_all(platform) + for entity_state in entities: + entity_entry = entity_registry.async_get(entity_state.entity_id) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert entity_state == snapshot(name=f"{entity_entry.entity_id}-state") + + +def set_attribute_value( + mock: AsyncMock, + capability: Capability, + attribute: Attribute, + value: Any, + component: str = MAIN, +) -> None: + """Set the value of an attribute.""" + mock.get_device_status.return_value[component][capability][attribute].value = value + + +async def trigger_update( + hass: HomeAssistant, + mock: AsyncMock, + device_id: str, + capability: Capability, + attribute: Attribute, + value: str | float | dict[str, Any] | list[Any] | None, + data: dict[str, Any] | None = None, +) -> None: + """Trigger an update.""" + for call in mock.add_device_event_listener.call_args_list: + if call[0][0] == device_id and call[0][2] == capability: + call[0][3]( + DeviceEvent( + "abc", + "abc", + "abc", + device_id, + MAIN, + capability, + attribute, + value, + data, + ) + ) + await hass.async_block_till_done() diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 71a36c7885a..b7d0cb61607 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -1,358 +1,178 @@ """Test configuration and mocks for the SmartThings component.""" -import secrets -from typing import Any -from unittest.mock import Mock, patch -from uuid import uuid4 +from collections.abc import Generator +import time +from unittest.mock import AsyncMock, patch -from pysmartthings import ( - CLASSIFICATION_AUTOMATION, - AppEntity, - AppOAuthClient, - AppSettings, - DeviceEntity, +from pysmartthings.models import ( + DeviceResponse, DeviceStatus, - InstalledApp, - InstalledAppStatus, - InstalledAppType, - Location, - SceneEntity, - SmartThings, - Subscription, + LocationResponse, + SceneResponse, ) -from pysmartthings.api import Api import pytest -from homeassistant.components import webhook -from homeassistant.components.smartthings import DeviceBroker +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.smartthings import CONF_INSTALLED_APP_ID from homeassistant.components.smartthings.const import ( - APP_NAME_PREFIX, - CONF_APP_ID, - CONF_INSTALLED_APP_ID, - CONF_INSTANCE_ID, CONF_LOCATION_ID, CONF_REFRESH_TOKEN, - DATA_BROKERS, DOMAIN, - SETTINGS_INSTANCE_ID, - STORAGE_KEY, - STORAGE_VERSION, -) -from homeassistant.config_entries import SOURCE_USER, ConfigEntryState -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_WEBHOOK_ID, + SCOPES, ) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant -from homeassistant.core_config import async_process_ha_core_config from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry -from tests.components.light.conftest import mock_light_profiles # noqa: F401 - -COMPONENT_PREFIX = "homeassistant.components.smartthings." +from tests.common import MockConfigEntry, load_fixture -async def setup_platform( - hass: HomeAssistant, platform: str, *, devices=None, scenes=None -): - """Set up the SmartThings platform and prerequisites.""" - hass.config.components.add(DOMAIN) - config_entry = MockConfigEntry( - version=2, - domain=DOMAIN, - title="Test", - data={CONF_INSTALLED_APP_ID: str(uuid4())}, - ) - config_entry.add_to_hass(hass) - broker = DeviceBroker( - hass, config_entry, Mock(), Mock(), devices or [], scenes or [] - ) +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.smartthings.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry - hass.data[DOMAIN] = {DATA_BROKERS: {config_entry.entry_id: broker}} - config_entry.mock_state(hass, ConfigEntryState.LOADED) - await hass.config_entries.async_forward_entry_setups(config_entry, [platform]) - await hass.async_block_till_done() - return config_entry + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 @pytest.fixture(autouse=True) -async def setup_component( - hass: HomeAssistant, config_file: dict[str, str], hass_storage: dict[str, Any] -) -> None: - """Load the SmartThing component.""" - hass_storage[STORAGE_KEY] = {"data": config_file, "version": STORAGE_VERSION} - await async_process_ha_core_config( +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( hass, - {"external_url": "https://test.local"}, - ) - await async_setup_component(hass, "smartthings", {}) - - -def _create_location() -> Mock: - loc = Mock(Location) - loc.name = "Test Location" - loc.location_id = str(uuid4()) - return loc - - -@pytest.fixture(name="location") -def location_fixture() -> Mock: - """Fixture for a single location.""" - return _create_location() - - -@pytest.fixture(name="locations") -def locations_fixture(location: Mock) -> list[Mock]: - """Fixture for 2 locations.""" - return [location, _create_location()] - - -@pytest.fixture(name="app") -async def app_fixture(hass: HomeAssistant, config_file: dict[str, str]) -> Mock: - """Fixture for a single app.""" - app = Mock(AppEntity) - app.app_name = APP_NAME_PREFIX + str(uuid4()) - app.app_id = str(uuid4()) - app.app_type = "WEBHOOK_SMART_APP" - app.classifications = [CLASSIFICATION_AUTOMATION] - app.display_name = "Home Assistant" - app.description = f"{hass.config.location_name} at https://test.local" - app.single_instance = True - app.webhook_target_url = webhook.async_generate_url( - hass, hass.data[DOMAIN][CONF_WEBHOOK_ID] + DOMAIN, + ClientCredential("CLIENT_ID", "CLIENT_SECRET"), + DOMAIN, ) - settings = Mock(AppSettings) - settings.app_id = app.app_id - settings.settings = {SETTINGS_INSTANCE_ID: config_file[CONF_INSTANCE_ID]} - app.settings.return_value = settings - return app - -@pytest.fixture(name="app_oauth_client") -def app_oauth_client_fixture() -> Mock: - """Fixture for a single app's oauth.""" - client = Mock(AppOAuthClient) - client.client_id = str(uuid4()) - client.client_secret = str(uuid4()) - return client - - -@pytest.fixture(name="app_settings") -def app_settings_fixture(app, config_file): - """Fixture for an app settings.""" - settings = Mock(AppSettings) - settings.app_id = app.app_id - settings.settings = {SETTINGS_INSTANCE_ID: config_file[CONF_INSTANCE_ID]} - return settings - - -def _create_installed_app(location_id: str, app_id: str) -> Mock: - item = Mock(InstalledApp) - item.installed_app_id = str(uuid4()) - item.installed_app_status = InstalledAppStatus.AUTHORIZED - item.installed_app_type = InstalledAppType.WEBHOOK_SMART_APP - item.app_id = app_id - item.location_id = location_id - return item - - -@pytest.fixture(name="installed_app") -def installed_app_fixture(location: Mock, app: Mock) -> Mock: - """Fixture for a single installed app.""" - return _create_installed_app(location.location_id, app.app_id) - - -@pytest.fixture(name="installed_apps") -def installed_apps_fixture(installed_app, locations, app): - """Fixture for 2 installed apps.""" - return [installed_app, _create_installed_app(locations[1].location_id, app.app_id)] - - -@pytest.fixture(name="config_file") -def config_file_fixture() -> dict[str, str]: - """Fixture representing the local config file contents.""" - return {CONF_INSTANCE_ID: str(uuid4()), CONF_WEBHOOK_ID: secrets.token_hex()} - - -@pytest.fixture(name="smartthings_mock") -def smartthings_mock_fixture(locations): - """Fixture to mock smartthings API calls.""" - - async def _location(location_id): - return next( - location for location in locations if location.location_id == location_id - ) - - smartthings_mock = Mock(SmartThings) - smartthings_mock.location.side_effect = _location - mock = Mock(return_value=smartthings_mock) +@pytest.fixture +def mock_smartthings() -> Generator[AsyncMock]: + """Mock a SmartThings client.""" with ( - patch(COMPONENT_PREFIX + "SmartThings", new=mock), - patch(COMPONENT_PREFIX + "config_flow.SmartThings", new=mock), - patch(COMPONENT_PREFIX + "smartapp.SmartThings", new=mock), + patch( + "homeassistant.components.smartthings.SmartThings", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.smartthings.config_flow.SmartThings", + new=mock_client, + ), ): - yield smartthings_mock + client = mock_client.return_value + client.get_scenes.return_value = SceneResponse.from_json( + load_fixture("scenes.json", DOMAIN) + ).items + client.get_locations.return_value = LocationResponse.from_json( + load_fixture("locations.json", DOMAIN) + ).items + yield client -@pytest.fixture(name="device") -def device_fixture(location): - """Fixture representing devices loaded.""" - item = Mock(DeviceEntity) - item.device_id = "743de49f-036f-4e9c-839a-2f89d57607db" - item.name = "GE In-Wall Smart Dimmer" - item.label = "Front Porch Lights" - item.location_id = location.location_id - item.capabilities = [ - "switch", - "switchLevel", - "refresh", - "indicator", - "sensor", - "actuator", - "healthCheck", - "light", +@pytest.fixture( + params=[ + "da_ac_rac_000001", + "da_ac_rac_01001", + "multipurpose_sensor", + "contact_sensor", + "base_electric_meter", + "smart_plug", + "vd_stv_2017_k", + "c2c_arlo_pro_3_switch", + "yale_push_button_deadbolt_lock", + "ge_in_wall_smart_dimmer", + "centralite", + "da_ref_normal_000001", + "vd_network_audio_002s", + "iphone", + "da_wm_dw_000001", + "da_wm_wd_000001", + "da_wm_wm_000001", + "da_rvc_normal_000001", + "da_ks_microwave_0101x", + "hue_color_temperature_bulb", + "hue_rgbw_color_bulb", + "c2c_shade", + "sonos_player", + "aeotec_home_energy_meter_gen5", + "virtual_water_sensor", + "virtual_thermostat", + "virtual_valve", + "sensibo_airconditioner_1", + "ecobee_sensor", + "ecobee_thermostat", + "fake_fan", ] - item.components = {"main": item.capabilities} - item.status = Mock(DeviceStatus) - return item +) +def device_fixture( + mock_smartthings: AsyncMock, request: pytest.FixtureRequest +) -> Generator[str]: + """Return every device.""" + return request.param -@pytest.fixture(name="config_entry") -def config_entry_fixture(installed_app: Mock, location: Mock) -> MockConfigEntry: - """Fixture representing a config entry.""" - data = { - CONF_ACCESS_TOKEN: str(uuid4()), - CONF_INSTALLED_APP_ID: installed_app.installed_app_id, - CONF_APP_ID: installed_app.app_id, - CONF_LOCATION_ID: location.location_id, - CONF_REFRESH_TOKEN: str(uuid4()), - CONF_CLIENT_ID: str(uuid4()), - CONF_CLIENT_SECRET: str(uuid4()), - } +@pytest.fixture +def devices(mock_smartthings: AsyncMock, device_fixture: str) -> Generator[AsyncMock]: + """Return a specific device.""" + mock_smartthings.get_devices.return_value = DeviceResponse.from_json( + load_fixture(f"devices/{device_fixture}.json", DOMAIN) + ).items + mock_smartthings.get_device_status.return_value = DeviceStatus.from_json( + load_fixture(f"device_status/{device_fixture}.json", DOMAIN) + ).components + return mock_smartthings + + +@pytest.fixture +def mock_config_entry(expires_at: int) -> MockConfigEntry: + """Mock a config entry.""" return MockConfigEntry( domain=DOMAIN, - data=data, - title=location.name, - version=2, - source=SOURCE_USER, + title="My home", + unique_id="397678e5-9995-4a39-9d9f-ae6ba310236c", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(SCOPES), + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123", + }, + version=3, ) -@pytest.fixture(name="subscription_factory") -def subscription_factory_fixture(): - """Fixture for creating mock subscriptions.""" - - def _factory(capability): - sub = Subscription() - sub.capability = capability - return sub - - return _factory - - -@pytest.fixture(name="device_factory") -def device_factory_fixture(): - """Fixture for creating mock devices.""" - api = Mock(Api) - api.post_device_command.return_value = {"results": [{"status": "ACCEPTED"}]} - - def _factory(label, capabilities, status: dict | None = None): - device_data = { - "deviceId": str(uuid4()), - "name": "Device Type Handler Name", - "label": label, - "deviceManufacturerCode": "9135fc86-0929-4436-bf73-5d75f523d9db", - "locationId": "fcd829e9-82f4-45b9-acfd-62fda029af80", - "components": [ - { - "id": "main", - "capabilities": [ - {"id": capability, "version": 1} for capability in capabilities - ], - } - ], - "dth": { - "deviceTypeId": "b678b29d-2726-4e4f-9c3f-7aa05bd08964", - "deviceTypeName": "Switch", - "deviceNetworkType": "ZWAVE", - }, - "type": "DTH", - } - device = DeviceEntity(api, data=device_data) - if status: - for attribute, value in status.items(): - device.status.apply_attribute_update("main", "", attribute, value) - return device - - return _factory - - -@pytest.fixture(name="scene_factory") -def scene_factory_fixture(location): - """Fixture for creating mock devices.""" - - def _factory(name): - scene = Mock(SceneEntity) - scene.scene_id = str(uuid4()) - scene.name = name - scene.icon = None - scene.color = None - scene.location_id = location.location_id - return scene - - return _factory - - -@pytest.fixture(name="scene") -def scene_fixture(scene_factory): - """Fixture for an individual scene.""" - return scene_factory("Test Scene") - - -@pytest.fixture(name="event_factory") -def event_factory_fixture(): - """Fixture for creating mock devices.""" - - def _factory( - device_id, - event_type="DEVICE_EVENT", - capability="", - attribute="Updated", - value="Value", - data=None, - ): - event = Mock() - event.event_type = event_type - event.device_id = device_id - event.component_id = "main" - event.capability = capability - event.attribute = attribute - event.value = value - event.data = data - event.location_id = str(uuid4()) - return event - - return _factory - - -@pytest.fixture(name="event_request_factory") -def event_request_factory_fixture(event_factory): - """Fixture for creating mock smartapp event requests.""" - - def _factory(device_ids=None, events=None): - request = Mock() - request.installed_app_id = uuid4() - if events is None: - events = [] - if device_ids: - events.extend([event_factory(device_id) for device_id in device_ids]) - events.append(event_factory(uuid4())) - events.append(event_factory(device_ids[0], event_type="OTHER")) - request.events = events - return request - - return _factory +@pytest.fixture +def mock_old_config_entry() -> MockConfigEntry: + """Mock the old config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="My home", + unique_id="appid123-2be1-4e40-b257-e4ef59083324_397678e5-9995-4a39-9d9f-ae6ba310236c", + data={ + CONF_ACCESS_TOKEN: "mock-access-token", + CONF_REFRESH_TOKEN: "mock-refresh-token", + CONF_CLIENT_ID: "CLIENT_ID", + CONF_CLIENT_SECRET: "CLIENT_SECRET", + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123aa123-2be1-4e40-b257-e4ef59083324", + }, + version=2, + ) diff --git a/tests/components/smartthings/fixtures/device_status/aeotec_home_energy_meter_gen5.json b/tests/components/smartthings/fixtures/device_status/aeotec_home_energy_meter_gen5.json new file mode 100644 index 00000000000..95ae6310be8 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/aeotec_home_energy_meter_gen5.json @@ -0,0 +1,31 @@ +{ + "components": { + "main": { + "powerMeter": { + "power": { + "value": 2859.743, + "unit": "W", + "timestamp": "2025-02-10T21:09:08.228Z" + } + }, + "voltageMeasurement": { + "voltage": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": null + } + }, + "energyMeter": { + "energy": { + "value": 19978.536, + "unit": "kWh", + "timestamp": "2025-02-10T21:09:08.357Z" + } + }, + "refresh": {} + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/base_electric_meter.json b/tests/components/smartthings/fixtures/device_status/base_electric_meter.json new file mode 100644 index 00000000000..b4fa67b6f7e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/base_electric_meter.json @@ -0,0 +1,21 @@ +{ + "components": { + "main": { + "powerMeter": { + "power": { + "value": 938.3, + "unit": "W", + "timestamp": "2025-02-09T17:56:21.748Z" + } + }, + "energyMeter": { + "energy": { + "value": 1930.362, + "unit": "kWh", + "timestamp": "2025-02-09T17:56:21.918Z" + } + }, + "refresh": {} + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json b/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json new file mode 100644 index 00000000000..371a779f83c --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json @@ -0,0 +1,82 @@ +{ + "components": { + "main": { + "videoCapture": { + "stream": { + "value": null + }, + "clip": { + "value": null + } + }, + "videoStream": { + "supportedFeatures": { + "value": null + }, + "stream": { + "value": null + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-02-03T21:55:57.991Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-08T21:56:09.761Z" + } + }, + "alarm": { + "alarm": { + "value": "off", + "timestamp": "2025-02-08T21:56:09.761Z" + } + }, + "refresh": {}, + "soundSensor": { + "sound": { + "value": "not detected", + "timestamp": "2025-02-08T21:56:09.761Z" + } + }, + "motionSensor": { + "motion": { + "value": "inactive", + "timestamp": "2025-02-08T21:56:09.761Z" + } + }, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-08T21:56:10.041Z" + }, + "type": { + "value": null + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-02-08T21:56:10.041Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/c2c_shade.json b/tests/components/smartthings/fixtures/device_status/c2c_shade.json new file mode 100644 index 00000000000..cc5bcd84482 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/c2c_shade.json @@ -0,0 +1,50 @@ +{ + "components": { + "main": { + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-02-07T23:01:15.966Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "offline", + "data": { + "reason": "DEVICE-OFFLINE" + }, + "timestamp": "2025-02-08T09:04:47.694Z" + } + }, + "switchLevel": { + "levelRange": { + "value": null + }, + "level": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-08T09:04:47.694Z" + } + }, + "refresh": {}, + "windowShade": { + "supportedWindowShadeCommands": { + "value": null + }, + "windowShade": { + "value": "open", + "timestamp": "2025-02-08T09:04:47.694Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/centralite.json b/tests/components/smartthings/fixtures/device_status/centralite.json new file mode 100644 index 00000000000..efdf54d9128 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/centralite.json @@ -0,0 +1,60 @@ +{ + "components": { + "main": { + "powerMeter": { + "power": { + "value": 0.0, + "unit": "W", + "timestamp": "2025-02-09T17:49:15.190Z" + } + }, + "switchLevel": { + "levelRange": { + "value": null + }, + "level": { + "value": 0, + "unit": "%", + "timestamp": "2025-02-09T17:49:15.112Z" + } + }, + "refresh": {}, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "16015010", + "timestamp": "2025-01-26T10:19:54.783Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-01-26T10:19:54.788Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-01-26T10:19:54.789Z" + }, + "currentVersion": { + "value": "16015010", + "timestamp": "2025-01-26T10:19:54.775Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T17:24:16.864Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/contact_sensor.json b/tests/components/smartthings/fixtures/device_status/contact_sensor.json new file mode 100644 index 00000000000..fa158d41b39 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/contact_sensor.json @@ -0,0 +1,66 @@ +{ + "components": { + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T17:16:42.674Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 59.0, + "unit": "F", + "timestamp": "2025-02-09T17:11:44.249Z" + } + }, + "refresh": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-09T13:23:50.726Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "00000103", + "timestamp": "2025-02-09T13:59:19.101Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-02-09T13:59:19.101Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-02-09T13:59:19.102Z" + }, + "currentVersion": { + "value": "00000103", + "timestamp": "2025-02-09T13:59:19.102Z" + }, + "lastUpdateTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json new file mode 100644 index 00000000000..c80fcf9c298 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json @@ -0,0 +1,879 @@ +{ + "components": { + "1": { + "relativeHumidityMeasurement": { + "humidity": { + "value": 0, + "unit": "%", + "timestamp": "2021-04-06T16:43:35.291Z" + } + }, + "custom.airConditionerOdorController": { + "airConditionerOdorControllerProgress": { + "value": null, + "timestamp": "2021-04-08T04:11:38.269Z" + }, + "airConditionerOdorControllerState": { + "value": null, + "timestamp": "2021-04-08T04:11:38.269Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": null, + "timestamp": "2021-04-08T04:04:19.901Z" + }, + "maximumSetpoint": { + "value": null, + "timestamp": "2021-04-08T04:04:19.901Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": null, + "timestamp": "2021-04-08T03:50:50.930Z" + }, + "airConditionerMode": { + "value": null, + "timestamp": "2021-04-08T03:50:50.930Z" + } + }, + "custom.spiMode": { + "spiMode": { + "value": null, + "timestamp": "2021-04-06T16:57:57.686Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": null, + "unit": "CAQI", + "timestamp": "2021-04-06T16:57:57.602Z" + } + }, + "custom.airConditionerOptionalMode": { + "supportedAcOptionalMode": { + "value": null, + "timestamp": "2021-04-06T16:57:57.659Z" + }, + "acOptionalMode": { + "value": null, + "timestamp": "2021-04-06T16:57:57.659Z" + } + }, + "switch": { + "switch": { + "value": null, + "timestamp": "2021-04-06T16:44:10.518Z" + } + }, + "custom.airConditionerTropicalNightMode": { + "acTropicalNightModeLevel": { + "value": null, + "timestamp": "2021-04-06T16:44:10.498Z" + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mndt": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnfv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnhw": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "di": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnsl": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "dmv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "n": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnmo": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "vid": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnmn": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnml": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnpv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnos": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "pi": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "icv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": null, + "timestamp": "2021-04-06T16:44:10.381Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2024-09-10T10:26:28.605Z" + }, + "availableAcFanModes": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "remoteControlStatus", + "airQualitySensor", + "dustSensor", + "odorSensor", + "veryFineDustSensor", + "custom.dustFilter", + "custom.deodorFilter", + "custom.deviceReportStateConfiguration", + "audioVolume", + "custom.autoCleaningMode", + "custom.airConditionerTropicalNightMode", + "custom.airConditionerOdorController", + "demandResponseLoadControl", + "relativeHumidityMeasurement" + ], + "timestamp": "2024-09-10T10:26:28.605Z" + } + }, + "fanOscillationMode": { + "supportedFanOscillationModes": { + "value": null, + "timestamp": "2021-04-06T16:44:10.325Z" + }, + "availableFanOscillationModes": { + "value": null + }, + "fanOscillationMode": { + "value": "fixed", + "timestamp": "2025-02-08T00:44:53.247Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null, + "timestamp": "2021-04-06T16:44:10.373Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:44:10.122Z" + }, + "fineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:44:10.122Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null, + "timestamp": "2021-04-06T16:44:09.800Z" + }, + "reportStateRealtime": { + "value": null, + "timestamp": "2021-04-06T16:44:09.800Z" + }, + "reportStatePeriod": { + "value": null, + "timestamp": "2021-04-06T16:44:09.800Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null, + "timestamp": "2021-04-06T16:43:59.136Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:54.748Z" + } + }, + "audioVolume": { + "volume": { + "value": null, + "unit": "%", + "timestamp": "2021-04-06T16:43:53.541Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": null, + "timestamp": "2021-04-06T16:43:53.364Z" + } + }, + "custom.autoCleaningMode": { + "supportedAutoCleaningModes": { + "value": null + }, + "timedCleanDuration": { + "value": null + }, + "operatingState": { + "value": null + }, + "timedCleanDurationRange": { + "value": null + }, + "supportedOperatingStates": { + "value": null + }, + "progress": { + "value": null + }, + "autoCleaningMode": { + "value": null, + "timestamp": "2021-04-06T16:43:53.344Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + } + }, + "odorSensor": { + "odorLevel": { + "value": null, + "timestamp": "2021-04-06T16:43:38.992Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": null, + "timestamp": "2021-04-06T16:43:39.097Z" + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + } + }, + "custom.energyType": { + "energyType": { + "value": null, + "timestamp": "2021-04-06T16:43:38.843Z" + }, + "energySavingSupport": { + "value": null + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:38.529Z" + } + } + }, + "main": { + "relativeHumidityMeasurement": { + "humidity": { + "value": 60, + "unit": "%", + "timestamp": "2024-12-30T13:10:23.759Z" + } + }, + "custom.airConditionerOdorController": { + "airConditionerOdorControllerProgress": { + "value": null, + "timestamp": "2021-04-06T16:43:37.555Z" + }, + "airConditionerOdorControllerState": { + "value": null, + "timestamp": "2021-04-06T16:43:37.555Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 16, + "unit": "C", + "timestamp": "2025-01-08T06:30:58.307Z" + }, + "maximumSetpoint": { + "value": 30, + "unit": "C", + "timestamp": "2024-09-10T10:26:28.781Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["cool", "dry", "wind", "auto", "heat"], + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "airConditionerMode": { + "value": "heat", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "custom.spiMode": { + "spiMode": { + "value": "off", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2021-12-29T01:36:51.289Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "ARTIK051_KRAC_18K", + "timestamp": "2025-02-08T00:44:53.855Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": null, + "unit": "CAQI", + "timestamp": "2021-04-06T16:43:37.208Z" + } + }, + "custom.airConditionerOptionalMode": { + "supportedAcOptionalMode": { + "value": ["off", "windFree"], + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "acOptionalMode": { + "value": "off", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T16:37:54.072Z" + } + }, + "custom.airConditionerTropicalNightMode": { + "acTropicalNightModeLevel": { + "value": 0, + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2021-04-06T16:43:35.933Z" + }, + "mndt": { + "value": null, + "timestamp": "2021-04-06T16:43:35.912Z" + }, + "mnfv": { + "value": "0.1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnhw": { + "value": "1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "di": { + "value": "96a5ef74-5832-a84b-f1f7-ca799957065d", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnsl": { + "value": null, + "timestamp": "2021-04-06T16:43:35.803Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "n": { + "value": "[room a/c] Samsung", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnmo": { + "value": "ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000", + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "vid": { + "value": "DA-AC-RAC-000001", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnpv": { + "value": "0G3MPDCKA00010E", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnos": { + "value": "TizenRT2.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "pi": { + "value": "96a5ef74-5832-a84b-f1f7-ca799957065d", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "low", + "timestamp": "2025-02-09T09:14:39.249Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2025-02-09T09:14:39.249Z" + }, + "availableAcFanModes": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "remoteControlStatus", + "airQualitySensor", + "dustSensor", + "veryFineDustSensor", + "custom.dustFilter", + "custom.deodorFilter", + "custom.deviceReportStateConfiguration", + "samsungce.dongleSoftwareInstallation", + "demandResponseLoadControl", + "custom.airConditionerOdorController" + ], + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24070101, + "timestamp": "2024-09-04T06:35:09.557Z" + } + }, + "fanOscillationMode": { + "supportedFanOscillationModes": { + "value": null, + "timestamp": "2021-04-06T16:43:35.782Z" + }, + "availableFanOscillationModes": { + "value": null + }, + "fanOscillationMode": { + "value": "fixed", + "timestamp": "2025-02-09T09:14:39.249Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 25, + "unit": "C", + "timestamp": "2025-02-09T16:33:29.164Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:35.665Z" + }, + "fineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:35.665Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null, + "timestamp": "2021-04-06T16:43:35.643Z" + }, + "reportStateRealtime": { + "value": null, + "timestamp": "2021-04-06T16:43:35.643Z" + }, + "reportStatePeriod": { + "value": null, + "timestamp": "2021-04-06T16:43:35.643Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 25, + "unit": "C", + "timestamp": "2025-02-09T09:15:11.608Z" + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": ["1"], + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": -1, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2024-09-10T10:26:28.781Z" + } + }, + "audioVolume": { + "volume": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 2247300, + "deltaEnergy": 400, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 2247300, + "energySaved": 0, + "start": "2025-02-09T15:45:29Z", + "end": "2025-02-09T16:15:33Z" + }, + "timestamp": "2025-02-09T16:15:33.639Z" + } + }, + "custom.autoCleaningMode": { + "supportedAutoCleaningModes": { + "value": null + }, + "timedCleanDuration": { + "value": null + }, + "operatingState": { + "value": null + }, + "timedCleanDurationRange": { + "value": null + }, + "supportedOperatingStates": { + "value": null + }, + "progress": { + "value": null + }, + "autoCleaningMode": { + "value": "off", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "refresh": {}, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["oic.r.temperature"], + "if": ["oic.if.baseline", "oic.if.a"], + "range": [16.0, 30.0], + "units": "C", + "temperature": 22.0 + } + }, + "data": { + "href": "/temperature/desired/0" + }, + "timestamp": "2023-07-19T03:07:43.270Z" + } + }, + "samsungce.selfCheck": { + "result": { + "value": null + }, + "supportedActions": { + "value": ["start"], + "timestamp": "2024-09-04T06:35:09.557Z" + }, + "progress": { + "value": null + }, + "errors": { + "value": [], + "timestamp": "2025-02-08T00:44:53.349Z" + }, + "status": { + "value": "ready", + "timestamp": "2025-02-08T00:44:53.549Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": null, + "timestamp": "2021-04-06T16:43:35.379Z" + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "1.0", + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2021-12-29T07:29:17.526Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "43CEZFTFFL7Z2", + "timestamp": "2025-02-08T00:44:53.855Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-02-08T00:44:53.855Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-08T00:44:53.855Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:35.363Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json new file mode 100644 index 00000000000..257d553cb9f --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json @@ -0,0 +1,731 @@ +{ + "components": { + "main": { + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": ["custom.spiMode.setSpiMode"], + "timestamp": "2025-02-09T05:44:01.769Z" + } + }, + "relativeHumidityMeasurement": { + "humidity": { + "value": 42, + "unit": "%", + "timestamp": "2025-02-09T17:02:45.042Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 16, + "unit": "C", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "maximumSetpoint": { + "value": 30, + "unit": "C", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": [], + "timestamp": "2025-02-09T14:35:56.800Z" + }, + "supportedAcModes": { + "value": ["auto", "cool", "dry", "wind", "heat"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "custom.spiMode": { + "spiMode": { + "value": null + } + }, + "custom.airConditionerOptionalMode": { + "supportedAcOptionalMode": { + "value": [ + "off", + "sleep", + "quiet", + "smart", + "speed", + "windFree", + "windFreeSleep" + ], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "acOptionalMode": { + "value": "off", + "timestamp": "2025-02-09T05:44:01.853Z" + } + }, + "samsungce.airConditionerBeep": { + "beep": { + "value": "off", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "ARA-WW-TP1-22-COMMON_11240702", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "di": { + "value": "4ece486b-89db-f06a-d54d-748b676b4d8e", + "timestamp": "2025-02-09T15:42:12.714Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-02-09T15:42:12.714Z" + }, + "n": { + "value": "Samsung-Room-Air-Conditioner", + "timestamp": "2025-02-09T15:42:12.714Z" + }, + "mnmo": { + "value": "ARA-WW-TP1-22-COMMON|10229641|60010523001511014600083200800000", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "vid": { + "value": "DA-AC-RAC-01001", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "pi": { + "value": "4ece486b-89db-f06a-d54d-748b676b4d8e", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-02-09T15:42:12.714Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "custom.deodorFilter", + "custom.electricHepaFilter", + "custom.periodicSensing", + "custom.doNotDisturbMode", + "samsungce.deviceInfoPrivate", + "samsungce.quickControl", + "samsungce.welcomeCooling", + "samsungce.airConditionerBeep", + "samsungce.airConditionerLighting", + "samsungce.individualControlLock", + "samsungce.alwaysOnSensing", + "samsungce.buttonDisplayCondition", + "airQualitySensor", + "dustSensor", + "odorSensor", + "veryFineDustSensor", + "custom.spiMode", + "audioNotification" + ], + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24100102, + "timestamp": "2025-01-28T21:31:35.935Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "010", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "tsId": { + "value": "DA01", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "fanOscillationMode": { + "supportedFanOscillationModes": { + "value": ["fixed", "vertical", "horizontal", "all"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "availableFanOscillationModes": { + "value": null + }, + "fanOscillationMode": { + "value": "fixed", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "custom.periodicSensing": { + "automaticExecutionSetting": { + "value": null + }, + "automaticExecutionMode": { + "value": null + }, + "supportedAutomaticExecutionSetting": { + "value": null + }, + "supportedAutomaticExecutionMode": { + "value": null + }, + "periodicSensing": { + "value": null + }, + "periodicSensingInterval": { + "value": null + }, + "lastSensingTime": { + "value": null + }, + "lastSensingLevel": { + "value": null + }, + "periodicSensingStatus": { + "value": null + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 0, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "audioVolume": { + "volume": { + "value": 0, + "unit": "%", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 13836, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 13836, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-02-09T16:08:15Z", + "end": "2025-02-09T17:02:44Z" + }, + "timestamp": "2025-02-09T17:02:44.883Z" + } + }, + "custom.autoCleaningMode": { + "supportedAutoCleaningModes": { + "value": null + }, + "timedCleanDuration": { + "value": null + }, + "operatingState": { + "value": null + }, + "timedCleanDurationRange": { + "value": null + }, + "supportedOperatingStates": { + "value": null + }, + "progress": { + "value": null + }, + "autoCleaningMode": { + "value": "on", + "timestamp": "2025-02-09T05:44:02.014Z" + } + }, + "samsungce.individualControlLock": { + "lockState": { + "value": null + } + }, + "audioNotification": {}, + "execute": { + "data": { + "value": null + } + }, + "samsungce.welcomeCooling": { + "latestRequestId": { + "value": null + }, + "operatingState": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "protocolType": { + "value": ["helper_hotspot", "ble_ocf"], + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "samsungce.selfCheck": { + "result": { + "value": null + }, + "supportedActions": { + "value": ["start", "cancel"], + "timestamp": "2025-02-09T04:52:00.923Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-02-09T04:52:00.923Z" + }, + "errors": { + "value": [], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "status": { + "value": "ready", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": 1, + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "dustFilterUsage": { + "value": 12, + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "dustFilterLastResetDate": { + "value": null + }, + "dustFilterStatus": { + "value": "normal", + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "dustFilterCapacity": { + "value": 500, + "unit": "Hour", + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "dustFilterResetType": { + "value": ["replaceable", "washable"], + "timestamp": "2025-02-09T12:00:10.310Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "1.0", + "timestamp": "2025-01-28T21:31:39.517Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2025-01-28T21:38:35.560Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2025-01-28T21:31:37.357Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": false, + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": true, + "timestamp": "2025-01-28T21:38:35.731Z" + } + }, + "bypassable": { + "bypassStatus": { + "value": "bypassed", + "timestamp": "2025-01-28T21:31:35.935Z" + } + }, + "samsungce.airQualityHealthConcern": { + "supportedAirQualityHealthConcerns": { + "value": null + }, + "airQualityHealthConcern": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-02-05T20:07:11.459Z" + }, + "otnDUID": { + "value": "U7CB2ZD4QPDUC", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-01-28T21:31:38.089Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "operatingState": { + "value": "none", + "timestamp": "2025-02-05T20:07:11.459Z" + }, + "progress": { + "value": null + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": null + } + }, + "custom.veryFineDustFilter": { + "veryFineDustFilterStatus": { + "value": null + }, + "veryFineDustFilterResetType": { + "value": null + }, + "veryFineDustFilterUsage": { + "value": null + }, + "veryFineDustFilterLastResetDate": { + "value": null + }, + "veryFineDustFilterUsageStep": { + "value": null + }, + "veryFineDustFilterCapacity": { + "value": null + } + }, + "samsungce.silentAction": {}, + "custom.airConditionerOdorController": { + "airConditionerOdorControllerProgress": { + "value": 0, + "timestamp": "2025-02-09T04:52:00.923Z" + }, + "airConditionerOdorControllerState": { + "value": "off", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": 21, + "timestamp": "2025-01-28T21:31:35.935Z" + }, + "binaryId": { + "value": "ARA-WW-TP1-22-COMMON", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "custom.airConditionerTropicalNightMode": { + "acTropicalNightModeLevel": { + "value": 6, + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "high", + "timestamp": "2025-02-09T14:07:45.816Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "availableAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2025-02-09T05:44:01.769Z" + } + }, + "samsungce.dustFilterAlarm": { + "alarmThreshold": { + "value": 500, + "unit": "Hour", + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "supportedAlarmThresholds": { + "value": [180, 300, 500, 700], + "unit": "Hour", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "custom.electricHepaFilter": { + "electricHepaFilterCapacity": { + "value": null + }, + "electricHepaFilterUsageStep": { + "value": null + }, + "electricHepaFilterLastResetDate": { + "value": null + }, + "electricHepaFilterStatus": { + "value": null + }, + "electricHepaFilterUsage": { + "value": null + }, + "electricHepaFilterResetType": { + "value": null + } + }, + "samsungce.airConditionerLighting": { + "supportedLightingLevels": { + "value": ["on", "off"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "lighting": { + "value": "on", + "timestamp": "2025-02-09T09:30:03.213Z" + } + }, + "samsungce.buttonDisplayCondition": { + "switch": { + "value": "enabled", + "timestamp": "2025-02-09T05:17:41.282Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 27, + "unit": "C", + "timestamp": "2025-02-09T16:38:17.028Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null + }, + "fineDustLevel": { + "value": null + } + }, + "sec.calmConnectionCare": { + "role": { + "value": ["things"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "protocols": { + "value": null + }, + "version": { + "value": "1.0", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "disabled", + "timestamp": "2025-02-09T05:17:39.792Z" + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-02-09T05:17:39.792Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 16, + "maximum": 30, + "step": 1 + }, + "unit": "C", + "timestamp": "2025-02-09T05:17:41.533Z" + }, + "coolingSetpoint": { + "value": 23, + "unit": "C", + "timestamp": "2025-02-09T14:07:45.643Z" + } + }, + "samsungce.alwaysOnSensing": { + "origins": { + "value": [], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "alwaysOn": { + "value": "off", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "refresh": {}, + "odorSensor": { + "odorLevel": { + "value": null + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null + }, + "deodorFilterLastResetDate": { + "value": null + }, + "deodorFilterStatus": { + "value": null + }, + "deodorFilterResetType": { + "value": null + }, + "deodorFilterUsage": { + "value": null + }, + "deodorFilterUsageStep": { + "value": null + } + }, + "custom.doNotDisturbMode": { + "doNotDisturb": { + "value": null + }, + "startTime": { + "value": null + }, + "endTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ks_microwave_0101x.json b/tests/components/smartthings/fixtures/device_status/da_ks_microwave_0101x.json new file mode 100644 index 00000000000..181b62666c7 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ks_microwave_0101x.json @@ -0,0 +1,600 @@ +{ + "components": { + "main": { + "doorControl": { + "door": { + "value": null + } + }, + "samsungce.kitchenDeviceDefaults": { + "defaultOperationTime": { + "value": 30, + "timestamp": "2022-03-23T15:59:12.609Z" + }, + "defaultOvenMode": { + "value": "MicroWave", + "timestamp": "2025-02-08T21:13:36.289Z" + }, + "defaultOvenSetpoint": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP2X_DA-KS-MICROWAVE-0101X", + "timestamp": "2025-02-08T21:13:36.256Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T00:11:12.010Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "AKS-WW-TP2-20-MICROWAVE-OTR_40230125", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "mnhw": { + "value": "MediaTek", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "di": { + "value": "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2023-07-03T22:00:58.832Z" + }, + "n": { + "value": "Samsung Microwave", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "mnmo": { + "value": "TP2X_DA-KS-MICROWAVE-0101X|40436241|50040100011411000200000000000000", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "vid": { + "value": "DA-KS-MICROWAVE-0101X", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "mnpv": { + "value": "DAWIT 3.0", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "mnos": { + "value": "TizenRT 2.0 + IPv6", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "pi": { + "value": "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2022-03-23T15:59:12.742Z" + } + }, + "samsungce.kitchenDeviceIdentification": { + "regionCode": { + "value": "US", + "timestamp": "2025-02-08T21:13:36.289Z" + }, + "modelCode": { + "value": "ME8000T-/AA0", + "timestamp": "2025-02-08T21:13:36.289Z" + }, + "fuel": { + "value": null + }, + "type": { + "value": "microwave", + "timestamp": "2022-03-23T15:59:10.971Z" + }, + "representativeComponent": { + "value": null + } + }, + "samsungce.kitchenModeSpecification": { + "specification": { + "value": { + "single": [ + { + "mode": "MicroWave", + "supportedOptions": { + "operationTime": { + "max": "01:40:00" + }, + "powerLevel": { + "default": "100%", + "supportedValues": [ + "0%", + "10%", + "20%", + "30%", + "40%", + "50%", + "60%", + "70%", + "80%", + "90%", + "100%" + ] + } + } + }, + { + "mode": "ConvectionBake", + "supportedOptions": { + "temperature": { + "F": { + "min": 100, + "max": 425, + "default": 350, + "supportedValues": [ + 100, 200, 225, 250, 275, 300, 325, 350, 375, 400, 425 + ] + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "ConvectionRoast", + "supportedOptions": { + "temperature": { + "F": { + "min": 200, + "max": 425, + "default": 325, + "supportedValues": [ + 200, 225, 250, 275, 300, 325, 350, 375, 400, 425 + ] + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "Grill", + "supportedOptions": { + "temperature": { + "F": { + "min": 425, + "max": 425, + "default": 425, + "resolution": 5 + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "SpeedBake", + "supportedOptions": { + "operationTime": { + "max": "01:40:00" + }, + "powerLevel": { + "default": "30%", + "supportedValues": ["10%", "30%", "50%", "70%"] + } + } + }, + { + "mode": "SpeedRoast", + "supportedOptions": { + "operationTime": { + "max": "01:40:00" + }, + "powerLevel": { + "default": "30%", + "supportedValues": ["10%", "30%", "50%", "70%"] + } + } + }, + { + "mode": "KeepWarm", + "supportedOptions": { + "temperature": { + "F": { + "min": 175, + "max": 175, + "default": 175, + "resolution": 5 + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "Autocook", + "supportedOptions": {} + }, + { + "mode": "Cookie", + "supportedOptions": { + "temperature": { + "F": { + "min": 325, + "max": 325, + "default": 325, + "resolution": 5 + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "SteamClean", + "supportedOptions": { + "operationTime": { + "max": "00:06:30" + } + } + } + ] + }, + "timestamp": "2025-02-08T10:21:03.790Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["doorControl", "samsungce.hoodFanSpeed"], + "timestamp": "2025-02-08T21:13:36.152Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22120101, + "timestamp": "2023-07-03T09:36:13.282Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "621", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-02-08T21:13:36.256Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 1, + "unit": "F", + "timestamp": "2025-02-09T00:11:15.291Z" + } + }, + "samsungce.ovenOperatingState": { + "completionTime": { + "value": "2025-02-08T21:13:36.184Z", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "operatingState": { + "value": "ready", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "progress": { + "value": 0, + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "ovenJobState": { + "value": "ready", + "timestamp": "2025-02-08T21:13:36.160Z" + }, + "operationTime": { + "value": "00:00:00", + "timestamp": "2025-02-08T21:13:36.188Z" + } + }, + "ovenMode": { + "supportedOvenModes": { + "value": [ + "Microwave", + "ConvectionBake", + "ConvectionRoast", + "grill", + "Others", + "warming" + ], + "timestamp": "2025-02-08T10:21:03.790Z" + }, + "ovenMode": { + "value": "Others", + "timestamp": "2025-02-08T21:13:36.289Z" + } + }, + "samsungce.ovenMode": { + "supportedOvenModes": { + "value": [ + "MicroWave", + "ConvectionBake", + "ConvectionRoast", + "Grill", + "SpeedBake", + "SpeedRoast", + "KeepWarm", + "Autocook", + "Cookie", + "SteamClean" + ], + "timestamp": "2025-02-08T10:21:03.790Z" + }, + "ovenMode": { + "value": "NoOperation", + "timestamp": "2025-02-08T21:13:36.289Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-08T21:13:36.152Z" + } + }, + "ovenSetpoint": { + "ovenSetpointRange": { + "value": null + }, + "ovenSetpoint": { + "value": 0, + "timestamp": "2025-02-09T00:01:09.108Z" + } + }, + "refresh": {}, + "samsungce.hoodFanSpeed": { + "settableMaxFanSpeed": { + "value": 3, + "timestamp": "2025-02-09T00:01:06.959Z" + }, + "hoodFanSpeed": { + "value": 0, + "timestamp": "2025-02-09T00:01:07.813Z" + }, + "supportedHoodFanSpeed": { + "value": [0, 1, 2, 3, 4, 5], + "timestamp": "2022-03-23T15:59:12.796Z" + }, + "settableMinFanSpeed": { + "value": 0, + "timestamp": "2025-02-09T00:01:06.959Z" + } + }, + "samsungce.doorState": { + "doorState": { + "value": "closed", + "timestamp": "2025-02-08T21:13:36.227Z" + } + }, + "samsungce.microwavePower": { + "supportedPowerLevels": { + "value": [ + "0%", + "10%", + "20%", + "30%", + "40%", + "50%", + "60%", + "70%", + "80%", + "90%", + "100%" + ], + "timestamp": "2025-02-08T21:13:36.160Z" + }, + "powerLevel": { + "value": "0%", + "timestamp": "2025-02-08T21:13:36.160Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.temperatures"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "0", + "x.com.samsung.da.current": "1", + "x.com.samsung.da.increment": "5", + "x.com.samsung.da.unit": "Fahrenheit" + } + ] + } + }, + "data": { + "href": "/temperatures/vs/0" + }, + "timestamp": "2023-07-19T05:50:12.609Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-08T21:13:36.357Z" + } + }, + "samsungce.definedRecipe": { + "definedRecipe": { + "value": { + "cavityId": "0", + "recipeType": "0", + "categoryId": 0, + "itemId": 0, + "servingSize": 0, + "browingLevel": 0, + "option": 0 + }, + "timestamp": "2025-02-08T21:13:36.160Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "U7CNQWBWSCD7C", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-02-08T21:13:36.213Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-08T21:13:36.213Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "ovenOperatingState": { + "completionTime": { + "value": "2025-02-08T21:13:36.184Z", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "machineState": { + "value": "ready", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "supportedMachineStates": { + "value": null + }, + "ovenJobState": { + "value": "ready", + "timestamp": "2025-02-08T21:13:36.160Z" + }, + "operationTime": { + "value": 0, + "timestamp": "2025-02-08T21:13:36.188Z" + } + } + }, + "hood": { + "samsungce.hoodFanSpeed": { + "settableMaxFanSpeed": { + "value": 3, + "timestamp": "2025-02-09T00:01:06.959Z" + }, + "hoodFanSpeed": { + "value": 0, + "timestamp": "2025-02-09T00:01:07.813Z" + }, + "supportedHoodFanSpeed": { + "value": [0, 1, 2, 3, 4, 5], + "timestamp": "2022-03-23T15:59:12.796Z" + }, + "settableMinFanSpeed": { + "value": 0, + "timestamp": "2025-02-09T00:01:06.959Z" + } + }, + "samsungce.lamp": { + "brightnessLevel": { + "value": "off", + "timestamp": "2025-02-08T21:13:36.289Z" + }, + "supportedBrightnessLevel": { + "value": ["off", "low", "high"], + "timestamp": "2025-02-08T21:13:36.289Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json b/tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json new file mode 100644 index 00000000000..0c5a883b4f9 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json @@ -0,0 +1,727 @@ +{ + "components": { + "pantry-01": { + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-02-07T10:47:54.524Z" + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "pantry-02": { + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-02-07T10:47:54.524Z" + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "icemaker": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2024-10-12T13:55:04.008Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T13:55:01.720Z" + } + } + }, + "onedoor": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-11-08T04:14:59.899Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.freezerConvertMode", "custom.fridgeMode"], + "timestamp": "2024-11-12T08:23:59.944Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": null + }, + "maximumSetpoint": { + "value": null + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "cooler": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T16:26:21.425Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-11-08T04:14:59.899Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["custom.fridgeMode"], + "timestamp": "2024-10-12T13:55:04.008Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 37, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 34, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + }, + "maximumSetpoint": { + "value": 44, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 34, + "maximum": 44, + "step": 1 + }, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + }, + "coolingSetpoint": { + "value": 37, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + } + }, + "freezer": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T14:48:16.247Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-11-08T04:14:59.899Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["custom.fridgeMode", "samsungce.freezerConvertMode"], + "timestamp": "2024-11-08T01:09:17.382Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 0, + "unit": "F", + "timestamp": "2025-01-23T04:42:18.178Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": -8, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + }, + "maximumSetpoint": { + "value": 5, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": -8, + "maximum": 5, + "step": 1 + }, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + }, + "coolingSetpoint": { + "value": 0, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + } + }, + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T16:26:21.425Z" + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-02-07T10:47:54.524Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": 20, + "timestamp": "2024-11-08T01:09:17.382Z" + }, + "binaryId": { + "value": "TP2X_REF_20K", + "timestamp": "2025-02-09T13:55:01.720Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "A-RFWW-TP2-21-COMMON_20220110", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnhw": { + "value": "MediaTek", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "di": { + "value": "7db87911-7dce-1cf2-7119-b953432a2f09", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "n": { + "value": "[refrigerator] Samsung", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnmo": { + "value": "TP2X_REF_20K|00115641|0004014D011411200103000020000000", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "vid": { + "value": "DA-REF-NORMAL-000001", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "pi": { + "value": "7db87911-7dce-1cf2-7119-b953432a2f09", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-21T22:04:22.037Z" + } + }, + "samsungce.fridgeVacationMode": { + "vacationMode": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "temperatureMeasurement", + "thermostatCoolingSetpoint", + "custom.fridgeMode", + "custom.deodorFilter", + "samsungce.dongleSoftwareInstallation", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "demandResponseLoadControl", + "samsungce.fridgeVacationMode", + "sec.diagnosticsInformation" + ], + "timestamp": "2025-02-09T13:55:01.720Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24100101, + "timestamp": "2024-11-08T04:14:59.025Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": null + }, + "endpoint": { + "value": null + }, + "minVersion": { + "value": null + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": null + }, + "protocolType": { + "value": null + }, + "tsId": { + "value": null + }, + "mnId": { + "value": null + }, + "dumpType": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-01-19T21:07:55.703Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-01-19T21:07:55.703Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": [ + "icemaker-02", + "pantry-01", + "pantry-02", + "cvroom", + "onedoor" + ], + "timestamp": "2024-11-08T01:09:17.382Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 0, + "duration": 0, + "override": false + }, + "timestamp": "2025-01-19T21:07:55.691Z" + } + }, + "samsungce.sabbathMode": { + "supportedActions": { + "value": ["on", "off"], + "timestamp": "2025-01-19T21:07:55.799Z" + }, + "status": { + "value": "off", + "timestamp": "2025-01-19T21:07:55.799Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 1568087, + "deltaEnergy": 7, + "power": 6, + "powerEnergy": 13.555977778169844, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-02-09T17:38:01Z", + "end": "2025-02-09T17:49:00Z" + }, + "timestamp": "2025-02-09T17:49:00.507Z" + } + }, + "refresh": {}, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.rm.micomdata"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.rm.micomdata": "D0C0022B00000000000DFE15051F5AA54400000000000000000000000000000000000000000000000001F04A00C5E0", + "x.com.samsung.rm.micomdataLength": 94 + } + }, + "data": { + "href": "/rm/micomdata/vs/0" + }, + "timestamp": "2023-07-19T05:25:39.852Z" + } + }, + "refrigeration": { + "defrost": { + "value": "off", + "timestamp": "2025-01-19T21:07:55.772Z" + }, + "rapidCooling": { + "value": "off", + "timestamp": "2025-01-19T21:07:55.725Z" + }, + "rapidFreezing": { + "value": "off", + "timestamp": "2025-01-19T21:07:55.725Z" + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null + }, + "deodorFilterLastResetDate": { + "value": null + }, + "deodorFilterStatus": { + "value": null + }, + "deodorFilterResetType": { + "value": null + }, + "deodorFilterUsage": { + "value": null + }, + "deodorFilterUsageStep": { + "value": null + } + }, + "samsungce.powerCool": { + "activated": { + "value": false, + "timestamp": "2025-01-19T21:07:55.725Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-02-07T10:47:54.524Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-02-07T10:47:54.524Z" + }, + "drMaxDuration": { + "value": 1440, + "unit": "min", + "timestamp": "2022-02-07T11:39:47.504Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": false, + "timestamp": "2022-02-07T11:39:47.504Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-01-19T21:07:55.725Z" + }, + "otnDUID": { + "value": "P7CNQWBWM3XBW", + "timestamp": "2025-01-19T21:07:55.744Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-01-19T21:07:55.744Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-01-19T21:07:55.725Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.powerFreeze": { + "activated": { + "value": false, + "timestamp": "2025-01-19T21:07:55.725Z" + } + }, + "custom.waterFilter": { + "waterFilterUsageStep": { + "value": 1, + "timestamp": "2025-01-19T21:07:55.758Z" + }, + "waterFilterResetType": { + "value": ["replaceable"], + "timestamp": "2025-01-19T21:07:55.758Z" + }, + "waterFilterCapacity": { + "value": null + }, + "waterFilterLastResetDate": { + "value": null + }, + "waterFilterUsage": { + "value": 100, + "timestamp": "2025-02-09T04:02:12.910Z" + }, + "waterFilterStatus": { + "value": "replace", + "timestamp": "2025-02-09T04:02:12.910Z" + } + } + }, + "cvroom": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["temperatureMeasurement", "thermostatCoolingSetpoint"], + "timestamp": "2022-02-07T11:39:42.105Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "icemaker-02": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-02-07T11:39:42.105Z" + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_rvc_normal_000001.json b/tests/components/smartthings/fixtures/device_status/da_rvc_normal_000001.json new file mode 100644 index 00000000000..3bb2011a2b5 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_rvc_normal_000001.json @@ -0,0 +1,274 @@ +{ + "components": { + "main": { + "custom.disabledComponents": { + "disabledComponents": { + "value": ["station"], + "timestamp": "2020-11-03T04:43:07.114Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": null, + "timestamp": "2020-11-03T04:43:07.092Z" + } + }, + "refresh": {}, + "samsungce.robotCleanerOperatingState": { + "supportedOperatingState": { + "value": [ + "homing", + "error", + "idle", + "charging", + "chargingForRemainingJob", + "paused", + "cleaning" + ], + "timestamp": "2020-11-03T04:43:06.547Z" + }, + "operatingState": { + "value": "idle", + "timestamp": "2023-06-18T15:59:24.580Z" + }, + "cleaningStep": { + "value": null + }, + "homingReason": { + "value": "none", + "timestamp": "2020-11-03T04:43:22.926Z" + }, + "isMapBasedOperationAvailable": { + "value": null + } + }, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2022-09-09T22:55:13.962Z" + }, + "type": { + "value": null + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.alarms"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.code": "4", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.triggeredTime": "2023-06-18T15:59:30", + "x.com.samsung.da.state": "deleted" + } + ] + } + }, + "data": { + "href": "/alarms/vs/0" + }, + "timestamp": "2023-06-18T15:59:28.267Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2023-06-18T15:59:27.658Z" + } + }, + "robotCleanerTurboMode": { + "robotCleanerTurboMode": { + "value": "off", + "timestamp": "2022-09-08T02:53:49.826Z" + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2020-06-02T23:30:52.793Z" + }, + "mndt": { + "value": null, + "timestamp": "2020-06-03T13:34:18.508Z" + }, + "mnfv": { + "value": "1.0", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnhw": { + "value": "1.0", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "di": { + "value": "3442dfc6-17c0-a65f-dae0-4c6e01786f44", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnsl": { + "value": null, + "timestamp": "2020-06-03T00:49:53.813Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2021-12-23T07:09:40.610Z" + }, + "n": { + "value": "[robot vacuum] Samsung", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnmo": { + "value": "powerbot_7000_17M|50016055|80010404011141000100000000000000", + "timestamp": "2022-09-07T06:42:36.551Z" + }, + "vid": { + "value": "DA-RVC-NORMAL-000001", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnpv": { + "value": "00", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnos": { + "value": "Tizen(3/0)", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "pi": { + "value": "3442dfc6-17c0-a65f-dae0-4c6e01786f44", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2019-07-07T19:45:19.771Z" + } + }, + "samsungce.robotCleanerCleaningMode": { + "supportedCleaningMode": { + "value": ["auto", "spot", "manual", "stop"], + "timestamp": "2020-11-03T04:43:06.547Z" + }, + "repeatModeEnabled": { + "value": false, + "timestamp": "2020-12-21T01:32:56.245Z" + }, + "supportRepeatMode": { + "value": true, + "timestamp": "2020-11-03T04:43:06.547Z" + }, + "cleaningMode": { + "value": "stop", + "timestamp": "2022-09-09T21:25:20.601Z" + } + }, + "robotCleanerMovement": { + "robotCleanerMovement": { + "value": "idle", + "timestamp": "2023-06-18T15:59:24.580Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.robotCleanerMapAreaInfo", + "samsungce.robotCleanerMapCleaningInfo", + "samsungce.robotCleanerPatrol", + "samsungce.robotCleanerPetMonitoring", + "samsungce.robotCleanerPetMonitoringReport", + "samsungce.robotCleanerPetCleaningSchedule", + "soundDetection", + "samsungce.soundDetectionSensitivity", + "samsungce.musicPlaylist", + "mediaPlayback", + "mediaTrackControl", + "imageCapture", + "videoCapture", + "audioVolume", + "audioMute", + "audioNotification", + "powerConsumptionReport", + "custom.hepaFilter", + "samsungce.robotCleanerMotorFilter", + "samsungce.robotCleanerRelayCleaning", + "audioTrackAddressing", + "samsungce.robotCleanerWelcome" + ], + "timestamp": "2022-09-08T01:03:48.820Z" + } + }, + "robotCleanerCleaningMode": { + "robotCleanerCleaningMode": { + "value": "stop", + "timestamp": "2022-09-09T21:25:20.601Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": null + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": null + }, + "newVersionAvailable": { + "value": null + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22100101, + "timestamp": "2022-11-01T09:26:07.107Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_dw_000001.json b/tests/components/smartthings/fixtures/device_status/da_wm_dw_000001.json new file mode 100644 index 00000000000..5535055f686 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_dw_000001.json @@ -0,0 +1,786 @@ +{ + "components": { + "main": { + "samsungce.dishwasherWashingCourse": { + "customCourseCandidates": { + "value": null + }, + "washingCourse": { + "value": "normal", + "timestamp": "2025-02-08T20:21:26.497Z" + }, + "supportedCourses": { + "value": [ + "auto", + "normal", + "heavy", + "delicate", + "express", + "rinseOnly", + "selfClean" + ], + "timestamp": "2025-02-08T18:00:37.194Z" + } + }, + "dishwasherOperatingState": { + "completionTime": { + "value": "2025-02-08T22:49:26Z", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "progress": { + "value": null + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2024-09-10T10:21:02.853Z" + }, + "dishwasherJobState": { + "value": "unknown", + "timestamp": "2025-02-08T20:21:26.452Z" + } + }, + "samsungce.dishwasherWashingOptions": { + "dryPlus": { + "value": null + }, + "stormWash": { + "value": null + }, + "hotAirDry": { + "value": null + }, + "selectedZone": { + "value": { + "value": "all", + "settable": ["none", "upper", "lower", "all"] + }, + "timestamp": "2022-11-09T00:20:42.461Z" + }, + "speedBooster": { + "value": { + "value": false, + "settable": [false, true] + }, + "timestamp": "2023-11-24T14:46:55.375Z" + }, + "highTempWash": { + "value": { + "value": false, + "settable": [false, true] + }, + "timestamp": "2025-02-08T07:39:54.739Z" + }, + "sanitizingWash": { + "value": null + }, + "heatedDry": { + "value": null + }, + "zoneBooster": { + "value": { + "value": "none", + "settable": ["none", "left", "right", "all"] + }, + "timestamp": "2022-11-20T07:10:27.445Z" + }, + "addRinse": { + "value": null + }, + "supportedList": { + "value": [ + "selectedZone", + "zoneBooster", + "speedBooster", + "sanitize", + "highTempWash" + ], + "timestamp": "2021-06-27T01:19:38.000Z" + }, + "rinsePlus": { + "value": null + }, + "sanitize": { + "value": { + "value": false, + "settable": [false, true] + }, + "timestamp": "2025-01-18T23:49:09.964Z" + }, + "steamSoak": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_DW_A51_20_COMMON", + "timestamp": "2025-02-08T19:29:30.987Z" + } + }, + "custom.dishwasherOperatingProgress": { + "dishwasherOperatingProgress": { + "value": "none", + "timestamp": "2025-02-08T20:21:26.452Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T20:21:26.386Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.waterConsumptionReport": { + "waterConsumption": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "DA_DW_A51_20_COMMON_30230714", + "timestamp": "2023-11-02T15:58:55.699Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "di": { + "value": "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-07-04T13:53:32.032Z" + }, + "n": { + "value": "[dishwasher] Samsung", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnmo": { + "value": "DA_DW_A51_20_COMMON|30007242|40010201001311000101000000000000", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "vid": { + "value": "DA-WM-DW-000001", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "pi": { + "value": "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2021-06-27T01:19:37.615Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.waterConsumptionReport", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "demandResponseLoadControl", + "sec.diagnosticsInformation", + "custom.waterFilter" + ], + "timestamp": "2025-02-08T19:29:32.447Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24040105, + "timestamp": "2024-07-02T02:56:22.508Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": null + }, + "endpoint": { + "value": null + }, + "minVersion": { + "value": null + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": null + }, + "protocolType": { + "value": null + }, + "tsId": { + "value": null + }, + "mnId": { + "value": null + }, + "dumpType": { + "value": null + } + }, + "samsungce.dishwasherOperation": { + "supportedOperatingState": { + "value": ["ready", "running", "paused"], + "timestamp": "2024-09-10T10:21:02.853Z" + }, + "operatingState": { + "value": "ready", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "reservable": { + "value": false, + "timestamp": "2025-02-08T18:00:37.194Z" + }, + "progressPercentage": { + "value": 1, + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "remainingTimeStr": { + "value": "02:28", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": 148.0, + "unit": "min", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "timeLeftToStart": { + "value": 0.0, + "unit": "min", + "timestamp": "2025-02-08T18:00:37.482Z" + } + }, + "samsungce.dishwasherJobState": { + "scheduledJobs": { + "value": [ + { + "jobName": "washing", + "timeInSec": 3600 + }, + { + "jobName": "rinsing", + "timeInSec": 1020 + }, + { + "jobName": "drying", + "timeInSec": 1200 + } + ], + "timestamp": "2025-02-08T20:21:26.928Z" + }, + "dishwasherJobState": { + "value": "none", + "timestamp": "2025-02-08T20:21:26.452Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-08T18:00:37.450Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 101600, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-02-08T20:21:21Z", + "end": "2025-02-08T20:21:26Z" + }, + "timestamp": "2025-02-08T20:21:26.596Z" + } + }, + "refresh": {}, + "samsungce.dishwasherWashingCourseDetails": { + "predefinedCourses": { + "value": [ + { + "courseName": "auto", + "energyUsage": 3, + "waterUsage": 3, + "temperature": { + "min": 50, + "max": 60, + "unit": "C" + }, + "expectedTime": { + "time": 136, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [false, true] + }, + "sanitize": { + "default": false, + "settable": [false, true] + }, + "speedBooster": { + "default": false, + "settable": [false, true] + }, + "zoneBooster": { + "default": "none", + "settable": ["none", "left"] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "normal", + "energyUsage": 3, + "waterUsage": 4, + "temperature": { + "min": 45, + "max": 62, + "unit": "C" + }, + "expectedTime": { + "time": 148, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [false, true] + }, + "sanitize": { + "default": false, + "settable": [false, true] + }, + "speedBooster": { + "default": false, + "settable": [false, true] + }, + "zoneBooster": { + "default": "none", + "settable": ["none", "left"] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "heavy", + "energyUsage": 4, + "waterUsage": 5, + "temperature": { + "min": 65, + "max": 65, + "unit": "C" + }, + "expectedTime": { + "time": 155, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [false, true] + }, + "sanitize": { + "default": false, + "settable": [false, true] + }, + "speedBooster": { + "default": false, + "settable": [false, true] + }, + "zoneBooster": { + "default": "none", + "settable": ["none", "left"] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "delicate", + "energyUsage": 2, + "waterUsage": 3, + "temperature": { + "min": 50, + "max": 50, + "unit": "C" + }, + "expectedTime": { + "time": 112, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [] + }, + "sanitize": { + "default": false, + "settable": [] + }, + "speedBooster": { + "default": false, + "settable": [false, true] + }, + "zoneBooster": { + "default": "none", + "settable": [] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "express", + "energyUsage": 2, + "waterUsage": 2, + "temperature": { + "min": 52, + "max": 52, + "unit": "C" + }, + "expectedTime": { + "time": 60, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [false, true] + }, + "sanitize": { + "default": false, + "settable": [false, true] + }, + "speedBooster": { + "default": false, + "settable": [] + }, + "zoneBooster": { + "default": "none", + "settable": ["none", "left"] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "rinseOnly", + "energyUsage": 1, + "waterUsage": 1, + "temperature": { + "min": 40, + "max": 40, + "unit": "C" + }, + "expectedTime": { + "time": 14, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [] + }, + "sanitize": { + "default": false, + "settable": [] + }, + "speedBooster": { + "default": false, + "settable": [] + }, + "zoneBooster": { + "default": "none", + "settable": [] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "selfClean", + "energyUsage": 5, + "waterUsage": 4, + "temperature": { + "min": 70, + "max": 70, + "unit": "C" + }, + "expectedTime": { + "time": 139, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [] + }, + "sanitize": { + "default": false, + "settable": [] + }, + "speedBooster": { + "default": false, + "settable": [] + }, + "zoneBooster": { + "default": "none", + "settable": [] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "all"] + } + } + } + ], + "timestamp": "2025-02-08T18:00:37.194Z" + }, + "waterUsageMax": { + "value": 5, + "timestamp": "2025-02-08T18:00:37.194Z" + }, + "energyUsageMax": { + "value": 5, + "timestamp": "2025-02-08T18:00:37.194Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["oic.r.operational.state"], + "if": ["oic.if.baseline", "oic.if.a"], + "currentMachineState": "idle", + "machineStates": ["pause", "active", "idle"], + "jobStates": [ + "None", + "Predrain", + "Prewash", + "Wash", + "Rinse", + "Drying", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "02:16:00", + "progressPercentage": "1" + } + }, + "data": { + "href": "/operational/state/0" + }, + "timestamp": "2023-07-19T04:23:15.606Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "custom.dishwasherOperatingPercentage": { + "dishwasherOperatingPercentage": { + "value": 1, + "timestamp": "2025-02-08T20:21:26.452Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-08T18:00:37.555Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": null + }, + "supportedCourses": { + "value": ["82", "83", "84", "85", "86", "87", "88"], + "timestamp": "2025-02-08T18:00:37.194Z" + } + }, + "custom.dishwasherDelayStartTime": { + "dishwasherDelayStartTime": { + "value": "00:00:00", + "timestamp": "2025-02-08T18:00:37.482Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2023-08-25T03:23:06.667Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2024-10-01T00:08:09.813Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "MTCNQWBWIV6TS", + "timestamp": "2025-02-08T18:00:37.538Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2022-07-20T03:37:30.706Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-08T18:00:37.538Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "custom.waterFilter": { + "waterFilterUsageStep": { + "value": null + }, + "waterFilterResetType": { + "value": null + }, + "waterFilterCapacity": { + "value": null + }, + "waterFilterLastResetDate": { + "value": null + }, + "waterFilterUsage": { + "value": null + }, + "waterFilterStatus": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001.json b/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001.json new file mode 100644 index 00000000000..fe43b490387 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001.json @@ -0,0 +1,719 @@ +{ + "components": { + "hca.main": { + "hca.dryerMode": { + "mode": { + "value": "normal", + "timestamp": "2025-02-08T18:10:11.023Z" + }, + "supportedModes": { + "value": ["normal", "timeDry", "quickDry"], + "timestamp": "2025-02-08T18:10:10.497Z" + } + } + }, + "main": { + "custom.dryerWrinklePrevent": { + "operatingState": { + "value": "ready", + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "dryerWrinklePrevent": { + "value": "off", + "timestamp": "2025-02-08T18:10:10.840Z" + } + }, + "samsungce.dryerDryingTemperature": { + "dryingTemperature": { + "value": "medium", + "timestamp": "2025-02-08T18:10:10.840Z" + }, + "supportedDryingTemperature": { + "value": ["none", "extraLow", "low", "mediumLow", "medium", "high"], + "timestamp": "2025-01-04T22:52:14.884Z" + } + }, + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-06-14T06:49:02.183Z" + } + }, + "samsungce.dryerCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-02-08T18:10:10.990Z" + }, + "presets": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20233741", + "timestamp": "2025-02-08T18:10:11.113Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "3000000100111100020B000000000000", + "timestamp": "2025-02-08T18:10:11.113Z" + }, + "description": { + "value": "DA_WM_A51_20_COMMON_DV6300R/DC92-02385A_0090", + "timestamp": "2025-02-08T18:10:11.113Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_WM_A51_20_COMMON", + "timestamp": "2025-02-08T18:10:11.113Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T18:10:10.911Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.dryerFreezePrevent": { + "operatingState": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "DA_WM_A51_20_COMMON_30230708", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "di": { + "value": "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "n": { + "value": "[dryer] Samsung", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnmo": { + "value": "DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "vid": { + "value": "DA-WM-WD-000001", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "pi": { + "value": "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-01-04T22:52:14.222Z" + } + }, + "custom.dryerDryLevel": { + "dryerDryLevel": { + "value": "normal", + "timestamp": "2025-02-08T18:10:10.840Z" + }, + "supportedDryerDryLevel": { + "value": ["none", "damp", "less", "normal", "more", "very"], + "timestamp": "2021-06-01T22:54:28.224Z" + } + }, + "samsungce.dryerAutoCycleLink": { + "dryerAutoCycleLink": { + "value": "on", + "timestamp": "2025-02-08T18:10:11.986Z" + } + }, + "samsungce.dryerCycle": { + "dryerCycle": { + "value": "Table_00_Course_01", + "timestamp": "2025-02-08T18:10:11.023Z" + }, + "supportedCycles": { + "value": [ + { + "cycle": "01", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8410", + "default": "medium", + "options": ["medium"] + } + } + }, + { + "cycle": "9C", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "A5", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "9E", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8308", + "default": "mediumLow", + "options": ["mediumLow"] + } + } + }, + { + "cycle": "9B", + "supportedOptions": { + "dryingLevel": { + "raw": "D520", + "default": "very", + "options": ["very"] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "27", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "E5", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "A0", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "A4", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "853E", + "default": "high", + "options": ["extraLow", "low", "mediumLow", "medium", "high"] + } + } + }, + { + "cycle": "A6", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "A3", + "supportedOptions": { + "dryingLevel": { + "raw": "D308", + "default": "normal", + "options": ["normal"] + }, + "dryingTemperature": { + "raw": "8410", + "default": "medium", + "options": ["medium"] + } + } + }, + { + "cycle": "A2", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8102", + "default": "extraLow", + "options": ["extraLow"] + } + } + } + ], + "timestamp": "2025-01-04T22:52:14.884Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-02-08T18:10:11.023Z" + }, + "specializedFunctionClassification": { + "value": 4, + "timestamp": "2025-02-08T18:10:10.497Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.dryerCyclePreset", + "samsungce.welcomeMessage", + "samsungce.dongleSoftwareInstallation", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "demandResponseLoadControl", + "samsungce.dryerFreezePrevent", + "sec.diagnosticsInformation" + ], + "timestamp": "2024-07-05T16:04:06.674Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-03T02:59:11.115Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": null + }, + "endpoint": { + "value": null + }, + "minVersion": { + "value": null + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": null + }, + "protocolType": { + "value": null + }, + "tsId": { + "value": null + }, + "mnId": { + "value": null + }, + "dumpType": { + "value": null + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-08T18:10:10.825Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "samsungce.detergentOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-08T18:10:10.497Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 4495500, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-02-07T04:00:19Z", + "end": "2025-02-08T18:10:11Z" + }, + "timestamp": "2025-02-08T18:10:11.053Z" + } + }, + "dryerOperatingState": { + "completionTime": { + "value": "2025-02-08T19:25:10Z", + "timestamp": "2025-02-08T18:10:10.962Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-02-08T18:10:10.962Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-02-08T18:10:10.962Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-02-08T18:10:10.962Z" + } + }, + "samsungce.detergentState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "detergentType": { + "value": "none", + "timestamp": "2021-06-01T22:54:28.372Z" + } + }, + "samsungce.dryerDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-02-08T18:10:10.962Z" + } + }, + "refresh": {}, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON_DV6300R/DC92-02385A_0090", + "x.com.samsung.da.serialNum": "FFFFFFFFFFFFFFF", + "x.com.samsung.da.otnDUID": "7XCDM6YAIRCGM", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02198A220728(E256)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "18112816,20112625", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-06T22:48:43.192Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-08T18:10:10.970Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-02-08T18:10:11.023Z" + }, + "supportedCourses": { + "value": [ + "01", + "9C", + "A5", + "9E", + "9B", + "27", + "E5", + "A0", + "A4", + "A6", + "A3", + "A2" + ], + "timestamp": "2025-02-08T18:10:10.497Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-06-14T06:49:02.183Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-06-14T06:49:02.721Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.dryerOperatingState": { + "operatingState": { + "value": "ready", + "timestamp": "2025-02-07T04:00:18.186Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2022-11-01T13:43:26.961Z" + }, + "scheduledJobs": { + "value": [ + { + "jobName": "drying", + "timeInMin": 57 + }, + { + "jobName": "cooling", + "timeInMin": 3 + } + ], + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-02-07T04:00:18.186Z" + }, + "remainingTimeStr": { + "value": "01:15", + "timestamp": "2025-02-07T04:00:18.186Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-02-07T04:00:18.186Z" + }, + "remainingTime": { + "value": 75, + "unit": "min", + "timestamp": "2025-02-07T04:00:18.186Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "7XCDM6YAIRCGM", + "timestamp": "2025-02-08T18:10:11.113Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-02T00:29:53.432Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-02T00:29:53.432Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.dryerDryingTime": { + "supportedDryingTime": { + "value": ["0", "20", "30", "40", "50", "60"], + "timestamp": "2021-06-01T22:54:28.224Z" + }, + "dryingTime": { + "value": "0", + "unit": "min", + "timestamp": "2025-02-08T18:10:10.840Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001.json b/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001.json new file mode 100644 index 00000000000..6a141c9462e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001.json @@ -0,0 +1,1243 @@ +{ + "components": { + "hca.main": { + "hca.washerMode": { + "mode": { + "value": "normal", + "timestamp": "2025-02-07T02:29:55.623Z" + }, + "supportedModes": { + "value": ["normal", "quickWash"], + "timestamp": "2025-02-07T02:29:55.152Z" + } + } + }, + "main": { + "samsungce.washerDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-02-07T02:29:55.546Z" + }, + "minimumReservableTime": { + "value": 45, + "unit": "min", + "timestamp": "2025-02-07T03:09:45.594Z" + } + }, + "samsungce.washerWaterLevel": { + "supportedWaterLevel": { + "value": null + }, + "waterLevel": { + "value": null + } + }, + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null + } + }, + "custom.washerWaterTemperature": { + "supportedWasherWaterTemperature": { + "value": ["none", "tapCold", "cold", "warm", "hot", "extraHot"], + "timestamp": "2024-12-25T22:13:27.760Z" + }, + "washerWaterTemperature": { + "value": "warm", + "timestamp": "2025-02-07T02:29:55.691Z" + } + }, + "samsungce.softenerAutoReplenishment": { + "regularSoftenerType": { + "value": null + }, + "regularSoftenerAlarmEnabled": { + "value": null + }, + "regularSoftenerInitialAmount": { + "value": null + }, + "regularSoftenerRemainingAmount": { + "value": null + }, + "regularSoftenerDosage": { + "value": null + }, + "regularSoftenerOrderThreshold": { + "value": null + } + }, + "samsungce.autoDispenseSoftener": { + "remainingAmount": { + "value": null + }, + "amount": { + "value": null + }, + "supportedDensity": { + "value": null + }, + "density": { + "value": null + }, + "supportedAmount": { + "value": null + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-06-15T14:11:34.909Z" + } + }, + "samsungce.autoDispenseDetergent": { + "remainingAmount": { + "value": null + }, + "amount": { + "value": null + }, + "supportedDensity": { + "value": null + }, + "density": { + "value": null + }, + "supportedAmount": { + "value": null + }, + "availableTypes": { + "value": null + }, + "type": { + "value": null + }, + "recommendedAmount": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20233741", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "2001000100131100022B010000000000", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "description": { + "value": "DA_WM_TP2_20_COMMON_WF6300R/DC92-02338B_0080", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_WM_TP2_20_COMMON", + "timestamp": "2025-02-07T02:29:55.453Z" + } + }, + "samsungce.washerWaterValve": { + "waterValve": { + "value": null + }, + "supportedWaterValve": { + "value": null + } + }, + "washerOperatingState": { + "completionTime": { + "value": "2025-02-07T03:54:45Z", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "washerJobState": { + "value": "none", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-02-07T02:29:55.546Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-07T03:09:45.456Z" + } + }, + "custom.washerAutoSoftener": { + "washerAutoSoftener": { + "value": null + } + }, + "samsungce.washerFreezePrevent": { + "operatingState": { + "value": null + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.washerCycle": { + "supportedCycles": { + "value": [ + { + "cycle": "01", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A43B", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "833E", + "default": "warm", + "options": ["tapCold", "cold", "warm", "hot", "extraHot"] + } + } + }, + { + "cycle": "70", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C53E", + "default": "extraHeavy", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A53F", + "default": "extraHigh", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "843E", + "default": "hot", + "options": ["tapCold", "cold", "warm", "hot", "extraHot"] + } + } + }, + { + "cycle": "55", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A43F", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "831E", + "default": "warm", + "options": ["tapCold", "cold", "warm", "hot"] + } + } + }, + { + "cycle": "71", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A20F", + "default": "low", + "options": ["rinseHold", "noSpin", "low", "medium"] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "830E", + "default": "warm", + "options": ["tapCold", "cold", "warm"] + } + } + }, + { + "cycle": "72", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A43F", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8520", + "default": "extraHot", + "options": ["extraHot"] + } + } + }, + { + "cycle": "77", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A21F", + "default": "low", + "options": ["rinseHold", "noSpin", "low", "medium", "high"] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "830E", + "default": "warm", + "options": ["tapCold", "cold", "warm"] + } + } + }, + { + "cycle": "E5", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C53E", + "default": "extraHeavy", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A43F", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "831E", + "default": "warm", + "options": ["tapCold", "cold", "warm", "hot"] + } + } + }, + { + "cycle": "57", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C000", + "default": "none", + "options": [] + }, + "spinLevel": { + "raw": "A520", + "default": "extraHigh", + "options": ["extraHigh"] + }, + "rinseCycle": { + "raw": "9204", + "default": "2", + "options": ["2"] + }, + "waterTemperature": { + "raw": "8520", + "default": "extraHot", + "options": ["extraHot"] + } + } + }, + { + "cycle": "73", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C000", + "default": "none", + "options": [] + }, + "spinLevel": { + "raw": "A43F", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "913F", + "default": "1", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "74", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A207", + "default": "low", + "options": ["rinseHold", "noSpin", "low"] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "830E", + "default": "warm", + "options": ["tapCold", "cold", "warm"] + } + } + }, + { + "cycle": "75", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A30F", + "default": "medium", + "options": ["rinseHold", "noSpin", "low", "medium"] + }, + "rinseCycle": { + "raw": "920F", + "default": "2", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "810E", + "default": "tapCold", + "options": ["tapCold", "cold", "warm"] + } + } + }, + { + "cycle": "78", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C13E", + "default": "extraLight", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A53F", + "default": "extraHigh", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "913F", + "default": "1", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "831E", + "default": "warm", + "options": ["tapCold", "cold", "warm", "hot"] + } + } + } + ], + "timestamp": "2024-12-25T22:13:27.760Z" + }, + "washerCycle": { + "value": "Table_00_Course_01", + "timestamp": "2025-02-07T02:29:55.623Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2021-06-01T22:52:20.068Z" + }, + "specializedFunctionClassification": { + "value": 4, + "timestamp": "2025-02-07T02:29:55.152Z" + } + }, + "samsungce.waterConsumptionReport": { + "waterConsumption": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "DA_WM_TP2_20_COMMON_30230804", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnhw": { + "value": "MediaTek", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "di": { + "value": "f984b91d-f250-9d42-3436-33f09a422a47", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "n": { + "value": "[washer] Samsung", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnmo": { + "value": "DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "vid": { + "value": "DA-WM-WM-000001", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnos": { + "value": "TizenRT 2.0 + IPv6", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "pi": { + "value": "f984b91d-f250-9d42-3436-33f09a422a47", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-25T22:13:27.131Z" + } + }, + "custom.dryerDryLevel": { + "dryerDryLevel": { + "value": null + }, + "supportedDryerDryLevel": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.autoDispenseDetergent", + "samsungce.autoDispenseSoftener", + "samsungce.waterConsumptionReport", + "samsungce.washerCyclePreset", + "samsungce.welcomeMessage", + "samsungce.dongleSoftwareInstallation", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "samsungce.energyPlanner", + "demandResponseLoadControl", + "samsungce.softenerAutoReplenishment", + "samsungce.softenerOrder", + "samsungce.softenerState", + "samsungce.washerBubbleSoak", + "samsungce.washerFreezePrevent", + "custom.dryerDryLevel", + "samsungce.washerWaterLevel", + "samsungce.washerWaterValve", + "samsungce.washerWashingTime", + "custom.washerAutoDetergent", + "custom.washerAutoSoftener" + ], + "timestamp": "2024-07-01T16:13:35.173Z" + } + }, + "custom.washerRinseCycles": { + "supportedWasherRinseCycles": { + "value": ["0", "1", "2", "3", "4", "5"], + "timestamp": "2024-12-25T22:13:27.760Z" + }, + "washerRinseCycles": { + "value": "2", + "timestamp": "2025-02-07T02:29:55.691Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-03T02:14:52.963Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "210", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-02-07T02:29:55.453Z" + } + }, + "samsungce.washerOperatingState": { + "washerJobState": { + "value": "none", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "operatingState": { + "value": "ready", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2022-11-04T14:21:57.546Z" + }, + "scheduledJobs": { + "value": [ + { + "jobName": "wash", + "timeInMin": 23 + }, + { + "jobName": "rinse", + "timeInMin": 10 + }, + { + "jobName": "spin", + "timeInMin": 9 + } + ], + "timestamp": "2025-02-07T02:30:43.851Z" + }, + "scheduledPhases": { + "value": [ + { + "phaseName": "wash", + "timeInMin": 23 + }, + { + "phaseName": "rinse", + "timeInMin": 10 + }, + { + "phaseName": "spin", + "timeInMin": 9 + } + ], + "timestamp": "2025-02-07T02:30:43.851Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "remainingTimeStr": { + "value": "00:45", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "washerJobPhase": { + "value": "none", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "operationTime": { + "value": 45, + "unit": "min", + "timestamp": "2025-02-07T03:09:45.594Z" + }, + "remainingTime": { + "value": 45, + "unit": "min", + "timestamp": "2025-02-07T03:09:45.534Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-07T02:29:55.407Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "samsungce.detergentOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 352800, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-02-07T03:09:24Z", + "end": "2025-02-07T03:09:45Z" + }, + "timestamp": "2025-02-07T03:09:45.703Z" + } + }, + "samsungce.detergentAutoReplenishment": { + "neutralDetergentType": { + "value": null + }, + "regularDetergentRemainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "babyDetergentRemainingAmount": { + "value": null + }, + "neutralDetergentRemainingAmount": { + "value": null + }, + "neutralDetergentAlarmEnabled": { + "value": null + }, + "neutralDetergentOrderThreshold": { + "value": null + }, + "babyDetergentInitialAmount": { + "value": null + }, + "babyDetergentType": { + "value": null + }, + "neutralDetergentInitialAmount": { + "value": null + }, + "regularDetergentDosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "babyDetergentDosage": { + "value": null + }, + "regularDetergentOrderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "regularDetergentType": { + "value": "none", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "regularDetergentInitialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "regularDetergentAlarmEnabled": { + "value": false, + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "neutralDetergentDosage": { + "value": null + }, + "babyDetergentOrderThreshold": { + "value": null + }, + "babyDetergentAlarmEnabled": { + "value": null + } + }, + "samsungce.softenerOrder": { + "alarmEnabled": { + "value": null + }, + "orderThreshold": { + "value": null + } + }, + "custom.washerSoilLevel": { + "supportedWasherSoilLevel": { + "value": [ + "none", + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ], + "timestamp": "2024-12-25T22:13:27.760Z" + }, + "washerSoilLevel": { + "value": "normal", + "timestamp": "2025-02-07T02:29:55.691Z" + } + }, + "samsungce.washerBubbleSoak": { + "status": { + "value": null + } + }, + "samsungce.washerCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-02-07T02:29:55.805Z" + }, + "presets": { + "value": null + } + }, + "samsungce.detergentState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "detergentType": { + "value": "none", + "timestamp": "2021-06-01T22:52:19.999Z" + } + }, + "refresh": {}, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000", + "x.com.samsung.da.description": "DA_WM_TP2_20_COMMON_WF6300R/DC92-02338B_0080", + "x.com.samsung.da.serialNum": "01FW57AR401623N", + "x.com.samsung.da.otnDUID": "U7CNQWBWJM5U4", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": ["errCode", "dump"], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "210", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02674A220725(F541)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_WM_TP2_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "18112816,20050607", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-06T16:52:15.994Z" + } + }, + "samsungce.softenerState": { + "remainingAmount": { + "value": null + }, + "dosage": { + "value": null + }, + "softenerType": { + "value": null + }, + "initialAmount": { + "value": null + } + }, + "samsungce.energyPlanner": { + "data": { + "value": null + }, + "plan": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-07T02:29:55.634Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-02-07T02:29:55.623Z" + }, + "supportedCourses": { + "value": [ + "01", + "70", + "55", + "71", + "72", + "77", + "E5", + "57", + "73", + "74", + "75", + "78" + ], + "timestamp": "2025-02-07T02:29:55.152Z" + } + }, + "samsungce.washerWashingTime": { + "supportedWashingTimes": { + "value": null + }, + "washingTime": { + "value": null + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-06-15T14:11:34.909Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-06-15T14:26:38.584Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2022-06-15T14:11:37.255Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": false, + "timestamp": "2022-06-15T14:11:37.255Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-02-07T02:29:55.548Z" + }, + "otnDUID": { + "value": "U7CNQWBWJM5U4", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-01T23:36:22.798Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-07T02:29:55.548Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "custom.washerAutoDetergent": { + "washerAutoDetergent": { + "value": null + } + }, + "custom.washerSpinLevel": { + "washerSpinLevel": { + "value": "high", + "timestamp": "2025-02-07T02:29:55.691Z" + }, + "supportedWasherSpinLevel": { + "value": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ], + "timestamp": "2024-12-25T22:13:27.760Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/ecobee_sensor.json b/tests/components/smartthings/fixtures/device_status/ecobee_sensor.json new file mode 100644 index 00000000000..e9d8addfcb3 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ecobee_sensor.json @@ -0,0 +1,51 @@ +{ + "components": { + "main": { + "presenceSensor": { + "presence": { + "value": "not present", + "timestamp": "2025-02-11T13:58:50.044Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-01-16T21:14:07.471Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-11T14:23:22.053Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 71, + "unit": "F", + "timestamp": "2025-02-11T14:36:16.823Z" + } + }, + "refresh": {}, + "motionSensor": { + "motion": { + "value": "inactive", + "timestamp": "2025-02-11T13:58:50.044Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/ecobee_thermostat.json b/tests/components/smartthings/fixtures/device_status/ecobee_thermostat.json new file mode 100644 index 00000000000..dd4b8717195 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ecobee_thermostat.json @@ -0,0 +1,98 @@ +{ + "components": { + "main": { + "relativeHumidityMeasurement": { + "humidity": { + "value": 32, + "unit": "%", + "timestamp": "2025-02-11T14:36:17.275Z" + } + }, + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": "heating", + "timestamp": "2025-02-11T13:39:58.286Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-01-16T21:14:07.448Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-11T13:39:58.286Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 71, + "unit": "F", + "timestamp": "2025-02-11T14:23:21.556Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 71, + "unit": "F", + "timestamp": "2025-02-11T13:39:58.286Z" + }, + "heatingSetpointRange": { + "value": null + } + }, + "thermostatFanMode": { + "thermostatFanMode": { + "value": "auto", + "data": { + "supportedThermostatFanModes": ["on", "auto"] + }, + "timestamp": "2025-02-11T13:39:58.286Z" + }, + "supportedThermostatFanModes": { + "value": ["on", "auto"], + "timestamp": "2025-02-11T13:39:58.286Z" + } + }, + "refresh": {}, + "thermostatMode": { + "thermostatMode": { + "value": "heat", + "data": { + "supportedThermostatModes": ["off", "cool", "auxheatonly", "auto"] + }, + "timestamp": "2025-02-11T13:39:58.286Z" + }, + "supportedThermostatModes": { + "value": ["off", "cool", "auxheatonly", "auto"], + "timestamp": "2025-02-11T13:39:58.286Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 73, + "unit": "F", + "timestamp": "2025-02-11T13:39:58.286Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/fake_fan.json b/tests/components/smartthings/fixtures/device_status/fake_fan.json new file mode 100644 index 00000000000..91efb69cee6 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/fake_fan.json @@ -0,0 +1,31 @@ +{ + "components": { + "main": { + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T23:21:22.908Z" + } + }, + "fanSpeed": { + "fanSpeed": { + "value": 60, + "timestamp": "2025-02-10T21:09:08.357Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": null, + "timestamp": "2021-04-06T16:44:10.381Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2024-09-10T10:26:28.605Z" + }, + "availableAcFanModes": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/ge_in_wall_smart_dimmer.json b/tests/components/smartthings/fixtures/device_status/ge_in_wall_smart_dimmer.json new file mode 100644 index 00000000000..bff74f135be --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ge_in_wall_smart_dimmer.json @@ -0,0 +1,23 @@ +{ + "components": { + "main": { + "switchLevel": { + "levelRange": { + "value": null + }, + "level": { + "value": 39, + "unit": "%", + "timestamp": "2025-02-07T02:39:25.819Z" + } + }, + "refresh": {}, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T23:21:22.908Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/hue_color_temperature_bulb.json b/tests/components/smartthings/fixtures/device_status/hue_color_temperature_bulb.json new file mode 100644 index 00000000000..6bdf7ceb2dd --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/hue_color_temperature_bulb.json @@ -0,0 +1,75 @@ +{ + "components": { + "main": { + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2023-12-17T18:11:41.671Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-07T15:14:53.823Z" + } + }, + "switchLevel": { + "levelRange": { + "value": { + "minimum": 1, + "maximum": 100 + }, + "unit": "%", + "timestamp": "2025-02-07T15:14:53.823Z" + }, + "level": { + "value": 70, + "unit": "%", + "timestamp": "2025-02-07T21:56:04.127Z" + } + }, + "refresh": {}, + "synthetic.lightingEffectFade": { + "fade": { + "value": null + } + }, + "colorTemperature": { + "colorTemperatureRange": { + "value": { + "minimum": 2000, + "maximum": 6535 + }, + "unit": "K", + "timestamp": "2025-02-07T15:14:53.823Z" + }, + "colorTemperature": { + "value": 3000, + "unit": "K", + "timestamp": "2025-02-07T21:56:04.127Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-02-07T21:56:04.127Z" + } + }, + "synthetic.lightingEffectCircadian": { + "circadian": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/hue_rgbw_color_bulb.json b/tests/components/smartthings/fixtures/device_status/hue_rgbw_color_bulb.json new file mode 100644 index 00000000000..5868472267c --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/hue_rgbw_color_bulb.json @@ -0,0 +1,94 @@ +{ + "components": { + "main": { + "colorControl": { + "saturation": { + "value": 60, + "timestamp": "2025-02-07T15:14:53.812Z" + }, + "color": { + "value": null + }, + "hue": { + "value": 60.8072, + "timestamp": "2025-02-07T15:14:53.812Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2023-12-17T18:11:41.678Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-07T15:14:53.812Z" + } + }, + "switchLevel": { + "levelRange": { + "value": { + "minimum": 1, + "maximum": 100 + }, + "unit": "%", + "timestamp": "2025-02-07T15:14:53.812Z" + }, + "level": { + "value": 70, + "unit": "%", + "timestamp": "2025-02-07T21:56:02.381Z" + } + }, + "refresh": {}, + "synthetic.lightingEffectFade": { + "fade": { + "value": null + } + }, + "samsungim.hueSyncMode": { + "mode": { + "value": "normal", + "timestamp": "2025-02-07T15:14:53.812Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T07:08:19.519Z" + } + }, + "colorTemperature": { + "colorTemperatureRange": { + "value": { + "minimum": 2000, + "maximum": 6535 + }, + "unit": "K", + "timestamp": "2025-02-06T15:14:52.807Z" + }, + "colorTemperature": { + "value": 3000, + "unit": "K", + "timestamp": "2025-02-07T21:56:02.381Z" + } + }, + "synthetic.lightingEffectCircadian": { + "circadian": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/iphone.json b/tests/components/smartthings/fixtures/device_status/iphone.json new file mode 100644 index 00000000000..618ce440ff0 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/iphone.json @@ -0,0 +1,12 @@ +{ + "components": { + "main": { + "presenceSensor": { + "presence": { + "value": "present", + "timestamp": "2023-09-22T18:12:25.012Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/multipurpose_sensor.json b/tests/components/smartthings/fixtures/device_status/multipurpose_sensor.json new file mode 100644 index 00000000000..e0b37de7e3c --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/multipurpose_sensor.json @@ -0,0 +1,79 @@ +{ + "components": { + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-08T14:00:28.332Z" + } + }, + "threeAxis": { + "threeAxis": { + "value": [20, 8, -1042], + "unit": "mG", + "timestamp": "2025-02-09T17:27:36.673Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 67.0, + "unit": "F", + "timestamp": "2025-02-09T17:56:19.744Z" + } + }, + "refresh": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 50, + "unit": "%", + "timestamp": "2025-02-09T12:24:02.074Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "0000001B", + "timestamp": "2025-02-09T04:20:25.600Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-02-09T04:20:25.600Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-02-09T04:20:25.601Z" + }, + "currentVersion": { + "value": "0000001B", + "timestamp": "2025-02-09T04:20:25.593Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "accelerationSensor": { + "acceleration": { + "value": "inactive", + "timestamp": "2025-02-09T17:27:46.812Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/sensibo_airconditioner_1.json b/tests/components/smartthings/fixtures/device_status/sensibo_airconditioner_1.json new file mode 100644 index 00000000000..b4263e7eb87 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/sensibo_airconditioner_1.json @@ -0,0 +1,57 @@ +{ + "components": { + "main": { + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2024-12-04T10:10:02.934Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-09T10:09:47.758Z" + } + }, + "refresh": {}, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": null + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2025-02-09T10:09:47.758Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 20, + "unit": "C", + "timestamp": "2025-02-09T10:09:47.758Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T10:09:47.758Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/smart_plug.json b/tests/components/smartthings/fixtures/device_status/smart_plug.json new file mode 100644 index 00000000000..f4f591483c6 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/smart_plug.json @@ -0,0 +1,43 @@ +{ + "components": { + "main": { + "refresh": {}, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "00102101", + "timestamp": "2025-02-08T19:37:03.624Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-02-08T19:37:03.622Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-02-08T19:37:03.624Z" + }, + "currentVersion": { + "value": "00102101", + "timestamp": "2025-02-08T19:37:03.594Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-02-09T17:31:12.210Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/sonos_player.json b/tests/components/smartthings/fixtures/device_status/sonos_player.json new file mode 100644 index 00000000000..057b6c62d0d --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/sonos_player.json @@ -0,0 +1,259 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop"], + "timestamp": "2025-02-02T13:18:40.078Z" + }, + "playbackStatus": { + "value": "playing", + "timestamp": "2025-02-09T19:53:58.330Z" + } + }, + "mediaPresets": { + "presets": { + "value": [ + { + "id": "10", + "imageUrl": "https://www.storytel.com//images/320x320/0000059036.jpg", + "mediaSource": "Storytel", + "name": "Dra \u00e5t skogen Sune!" + }, + { + "id": "22", + "imageUrl": "https://www.storytel.com//images/320x320/0000001894.jpg", + "mediaSource": "Storytel", + "name": "Fy katten Sune" + }, + { + "id": "29", + "imageUrl": "https://www.storytel.com//images/320x320/0000001896.jpg", + "mediaSource": "Storytel", + "name": "Gult \u00e4r fult, Sune" + }, + { + "id": "2", + "imageUrl": "https://static.mytuner.mobi/media/tvos_radios/2l5zg6lhjbab.png", + "mediaSource": "myTuner Radio", + "name": "Kiss" + }, + { + "id": "3", + "imageUrl": "https://www.storytel.com//images/320x320/0000046017.jpg", + "mediaSource": "Storytel", + "name": "L\u00e4skigt Sune!" + }, + { + "id": "16", + "imageUrl": "https://www.storytel.com//images/320x320/0002590598.jpg", + "mediaSource": "Storytel", + "name": "Pluggh\u00e4sten Sune" + }, + { + "id": "14", + "imageUrl": "https://www.storytel.com//images/320x320/0000000070.jpg", + "mediaSource": "Storytel", + "name": "Sagan om Sune" + }, + { + "id": "18", + "imageUrl": "https://www.storytel.com//images/320x320/0000006452.jpg", + "mediaSource": "Storytel", + "name": "Sk\u00e4mtaren Sune" + }, + { + "id": "26", + "imageUrl": "https://www.storytel.com//images/320x320/0000001892.jpg", + "mediaSource": "Storytel", + "name": "Spik och panik, Sune!" + }, + { + "id": "7", + "imageUrl": "https://www.storytel.com//images/320x320/0003119145.jpg", + "mediaSource": "Storytel", + "name": "Sune - T\u00e5gsemestern" + }, + { + "id": "25", + "imageUrl": "https://www.storytel.com//images/320x320/0000000071.jpg", + "mediaSource": "Storytel", + "name": "Sune b\u00f6rjar tv\u00e5an" + }, + { + "id": "9", + "imageUrl": "https://www.storytel.com//images/320x320/0000006448.jpg", + "mediaSource": "Storytel", + "name": "Sune i Grekland" + }, + { + "id": "8", + "imageUrl": "https://www.storytel.com//images/320x320/0002492498.jpg", + "mediaSource": "Storytel", + "name": "Sune i Ullared" + }, + { + "id": "30", + "imageUrl": "https://www.storytel.com//images/320x320/0002072946.jpg", + "mediaSource": "Storytel", + "name": "Sune och familjen Anderssons sjuka jul" + }, + { + "id": "17", + "imageUrl": "https://www.storytel.com//images/320x320/0000000475.jpg", + "mediaSource": "Storytel", + "name": "Sune och klantpappan" + }, + { + "id": "11", + "imageUrl": "https://www.storytel.com//images/320x320/0000042688.jpg", + "mediaSource": "Storytel", + "name": "Sune och Mamma Mysko" + }, + { + "id": "20", + "imageUrl": "https://www.storytel.com//images/320x320/0000000072.jpg", + "mediaSource": "Storytel", + "name": "Sune och syster vampyr" + }, + { + "id": "15", + "imageUrl": "https://www.storytel.com//images/320x320/0000039918.jpg", + "mediaSource": "Storytel", + "name": "Sune slutar f\u00f6rsta klass" + }, + { + "id": "5", + "imageUrl": "https://www.storytel.com//images/320x320/0000017431.jpg", + "mediaSource": "Storytel", + "name": "Sune v\u00e4rsta killen!" + }, + { + "id": "27", + "imageUrl": "https://www.storytel.com//images/320x320/0000068900.jpg", + "mediaSource": "Storytel", + "name": "Sunes halloween" + }, + { + "id": "19", + "imageUrl": "https://www.storytel.com//images/320x320/0000000476.jpg", + "mediaSource": "Storytel", + "name": "Sunes hemligheter" + }, + { + "id": "21", + "imageUrl": "https://www.storytel.com//images/320x320/0002370989.jpg", + "mediaSource": "Storytel", + "name": "Sunes hj\u00e4rnsl\u00e4pp" + }, + { + "id": "24", + "imageUrl": "https://www.storytel.com//images/320x320/0000001889.jpg", + "mediaSource": "Storytel", + "name": "Sunes jul" + }, + { + "id": "28", + "imageUrl": "https://www.storytel.com//images/320x320/0000034437.jpg", + "mediaSource": "Storytel", + "name": "Sunes party" + }, + { + "id": "4", + "imageUrl": "https://www.storytel.com//images/320x320/0000006450.jpg", + "mediaSource": "Storytel", + "name": "Sunes skolresa" + }, + { + "id": "13", + "imageUrl": "https://www.storytel.com//images/320x320/0000000477.jpg", + "mediaSource": "Storytel", + "name": "Sunes sommar" + }, + { + "id": "12", + "imageUrl": "https://www.storytel.com//images/320x320/0000046015.jpg", + "mediaSource": "Storytel", + "name": "Sunes Sommarstuga" + }, + { + "id": "6", + "imageUrl": "https://www.storytel.com//images/320x320/0002099327.jpg", + "mediaSource": "Storytel", + "name": "Supersnuten Sune" + }, + { + "id": "23", + "imageUrl": "https://www.storytel.com//images/320x320/0000563738.jpg", + "mediaSource": "Storytel", + "name": "Zunes stolpskott" + } + ], + "timestamp": "2025-02-02T13:18:48.272Z" + } + }, + "audioVolume": { + "volume": { + "value": 15, + "unit": "%", + "timestamp": "2025-02-09T19:57:37.230Z" + } + }, + "mediaGroup": { + "groupMute": { + "value": "unmuted", + "timestamp": "2025-02-07T01:19:54.911Z" + }, + "groupPrimaryDeviceId": { + "value": "RINCON_38420B9108F601400", + "timestamp": "2025-02-09T19:52:24.000Z" + }, + "groupId": { + "value": "RINCON_38420B9108F601400:3579458382", + "timestamp": "2025-02-09T19:54:06.936Z" + }, + "groupVolume": { + "value": 12, + "unit": "%", + "timestamp": "2025-02-07T01:19:54.911Z" + }, + "groupRole": { + "value": "ungrouped", + "timestamp": "2025-02-09T19:52:23.974Z" + } + }, + "refresh": {}, + "mediaTrackControl": { + "supportedTrackControlCommands": { + "value": ["nextTrack", "previousTrack"], + "timestamp": "2025-02-02T13:18:40.123Z" + } + }, + "audioMute": { + "mute": { + "value": "unmuted", + "timestamp": "2025-02-09T19:57:35.487Z" + } + }, + "audioNotification": {}, + "audioTrackData": { + "totalTime": { + "value": null + }, + "audioTrackData": { + "value": { + "album": "Forever Young", + "albumArtUrl": "http://192.168.1.123:1400/getaa?s=1&u=x-sonos-spotify%3aspotify%253atrack%253a3bg2qahpZmsg5wV2EMPXIk%3fsid%3d9%26flags%3d8232%26sn%3d9", + "artist": "David Guetta", + "mediaSource": "Spotify", + "title": "Forever Young" + }, + "timestamp": "2025-02-09T19:53:55.615Z" + }, + "elapsedTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/vd_network_audio_002s.json b/tests/components/smartthings/fixtures/device_status/vd_network_audio_002s.json new file mode 100644 index 00000000000..a0bcbd742f4 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/vd_network_audio_002s.json @@ -0,0 +1,164 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop"], + "timestamp": "2025-02-09T15:42:12.923Z" + }, + "playbackStatus": { + "value": "stopped", + "timestamp": "2025-02-09T15:42:12.923Z" + } + }, + "samsungvd.soundFrom": { + "mode": { + "value": 3, + "timestamp": "2025-02-09T15:42:13.215Z" + }, + "detailName": { + "value": "External Device", + "timestamp": "2025-02-09T15:42:13.215Z" + } + }, + "audioVolume": { + "volume": { + "value": 17, + "unit": "%", + "timestamp": "2025-02-09T17:25:51.839Z" + } + }, + "samsungvd.audioGroupInfo": { + "role": { + "value": null + }, + "status": { + "value": null + } + }, + "refresh": {}, + "audioNotification": {}, + "execute": { + "data": { + "value": null + } + }, + "samsungvd.audioInputSource": { + "supportedInputSources": { + "value": ["digital", "HDMI1", "bluetooth", "wifi", "HDMI2"], + "timestamp": "2025-02-09T17:18:44.680Z" + }, + "inputSource": { + "value": "HDMI1", + "timestamp": "2025-02-09T17:18:44.680Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-02-09T17:25:51.536Z" + } + }, + "ocf": { + "st": { + "value": "2024-12-10T02:12:44Z", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mndt": { + "value": "2023-01-01", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnfv": { + "value": "SAT-iMX8M23WWC-1010.5", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnhw": { + "value": "", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "di": { + "value": "0d94e5db-8501-2355-eb4f-214163702cac", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnsl": { + "value": "", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "n": { + "value": "Soundbar Living", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnmo": { + "value": "HW-Q990C", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "vid": { + "value": "VD-NetworkAudio-002S", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnml": { + "value": "", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnpv": { + "value": "7.0", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "pi": { + "value": "0d94e5db-8501-2355-eb4f-214163702cac", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-31T01:03:42.587Z" + } + }, + "audioMute": { + "mute": { + "value": "unmuted", + "timestamp": "2025-02-09T17:18:44.787Z" + } + }, + "samsungvd.thingStatus": { + "updatedTime": { + "value": 1739115734, + "timestamp": "2025-02-09T15:42:13.949Z" + }, + "status": { + "value": "Idle", + "timestamp": "2025-02-09T15:42:13.949Z" + } + }, + "audioTrackData": { + "totalTime": { + "value": 0, + "timestamp": "2024-12-31T00:29:29.953Z" + }, + "audioTrackData": { + "value": { + "title": "", + "artist": "", + "album": "" + }, + "timestamp": "2024-12-31T00:29:29.953Z" + }, + "elapsedTime": { + "value": 0, + "timestamp": "2024-12-31T00:29:29.828Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/vd_stv_2017_k.json b/tests/components/smartthings/fixtures/device_status/vd_stv_2017_k.json new file mode 100644 index 00000000000..18496942e2f --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/vd_stv_2017_k.json @@ -0,0 +1,266 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop", "fastForward", "rewind"], + "timestamp": "2020-05-07T02:58:10.250Z" + }, + "playbackStatus": { + "value": null, + "timestamp": "2020-08-04T21:53:22.108Z" + } + }, + "audioVolume": { + "volume": { + "value": 13, + "unit": "%", + "timestamp": "2021-08-21T19:19:52.832Z" + } + }, + "samsungvd.supportsPowerOnByOcf": { + "supportsPowerOnByOcf": { + "value": null, + "timestamp": "2020-10-29T10:47:20.305Z" + } + }, + "samsungvd.mediaInputSource": { + "supportedInputSourcesMap": { + "value": [ + { + "id": "dtv", + "name": "TV" + }, + { + "id": "HDMI1", + "name": "PlayStation 4" + }, + { + "id": "HDMI4", + "name": "HT-CT370" + }, + { + "id": "HDMI4", + "name": "HT-CT370" + } + ], + "timestamp": "2021-10-16T15:18:11.622Z" + }, + "inputSource": { + "value": "HDMI1", + "timestamp": "2021-08-28T16:29:59.716Z" + } + }, + "mediaInputSource": { + "supportedInputSources": { + "value": ["digitalTv", "HDMI1", "HDMI4", "HDMI4"], + "timestamp": "2021-10-16T15:18:11.622Z" + }, + "inputSource": { + "value": "HDMI1", + "timestamp": "2021-08-28T16:29:59.716Z" + } + }, + "custom.tvsearch": {}, + "samsungvd.ambient": {}, + "refresh": {}, + "custom.error": { + "error": { + "value": null, + "timestamp": "2020-08-04T21:53:22.148Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.tv.deviceinfo"], + "if": ["oic.if.baseline", "oic.if.r"], + "x.com.samsung.country": "USA", + "x.com.samsung.infolinkversion": "T-INFOLINK2017-1008", + "x.com.samsung.modelid": "17_KANTM_UHD", + "x.com.samsung.tv.blemac": "CC:6E:A4:1F:4C:F7", + "x.com.samsung.tv.btmac": "CC:6E:A4:1F:4C:F7", + "x.com.samsung.tv.category": "tv", + "x.com.samsung.tv.countrycode": "US", + "x.com.samsung.tv.duid": "B2NBQRAG357IX", + "x.com.samsung.tv.ethmac": "c0:48:e6:e7:fc:2c", + "x.com.samsung.tv.p2pmac": "ce:6e:a4:1f:4c:f6", + "x.com.samsung.tv.udn": "717fb7ed-b310-4cfe-8954-1cd8211dd689", + "x.com.samsung.tv.wifimac": "cc:6e:a4:1f:4c:f6" + } + }, + "data": { + "href": "/sec/tv/deviceinfo" + }, + "timestamp": "2021-08-30T19:18:12.303Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2021-10-16T15:18:11.317Z" + } + }, + "tvChannel": { + "tvChannel": { + "value": "", + "timestamp": "2020-05-07T02:58:10.479Z" + }, + "tvChannelName": { + "value": "", + "timestamp": "2021-08-21T18:53:06.643Z" + } + }, + "ocf": { + "st": { + "value": "2021-08-21T14:50:34Z", + "timestamp": "2021-08-21T19:19:51.890Z" + }, + "mndt": { + "value": "2017-01-01", + "timestamp": "2021-08-21T19:19:51.890Z" + }, + "mnfv": { + "value": "T-KTMAKUC-1290.3", + "timestamp": "2021-08-21T18:52:57.543Z" + }, + "mnhw": { + "value": "0-0", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "di": { + "value": "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnsl": { + "value": "http://www.samsung.com/sec/tv/overview/", + "timestamp": "2021-08-21T19:19:51.890Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2021-08-21T18:52:58.071Z" + }, + "n": { + "value": "[TV] Samsung 8 Series (49)", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnmo": { + "value": "UN49MU8000", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "vid": { + "value": "VD-STV_2017_K", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnpv": { + "value": "Tizen 3.0", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnos": { + "value": "4.1.10", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "pi": { + "value": "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2021-08-21T18:52:58.071Z" + } + }, + "custom.picturemode": { + "pictureMode": { + "value": "Dynamic", + "timestamp": "2020-12-23T01:33:37.069Z" + }, + "supportedPictureModes": { + "value": ["Dynamic", "Standard", "Natural", "Movie"], + "timestamp": "2020-05-07T02:58:10.585Z" + }, + "supportedPictureModesMap": { + "value": [ + { + "id": "modeDynamic", + "name": "Dynamic" + }, + { + "id": "modeStandard", + "name": "Standard" + }, + { + "id": "modeNatural", + "name": "Natural" + }, + { + "id": "modeMovie", + "name": "Movie" + } + ], + "timestamp": "2020-12-23T01:33:37.069Z" + } + }, + "samsungvd.ambientContent": { + "supportedAmbientApps": { + "value": [], + "timestamp": "2021-01-17T01:10:11.985Z" + } + }, + "custom.accessibility": {}, + "custom.recording": {}, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungvd.ambient", "samsungvd.ambientContent"], + "timestamp": "2021-01-17T01:10:11.985Z" + } + }, + "custom.soundmode": { + "supportedSoundModesMap": { + "value": [ + { + "id": "modeStandard", + "name": "Standard" + } + ], + "timestamp": "2021-08-21T19:19:52.887Z" + }, + "soundMode": { + "value": "Standard", + "timestamp": "2020-12-23T01:33:37.272Z" + }, + "supportedSoundModes": { + "value": ["Standard"], + "timestamp": "2021-08-21T19:19:52.887Z" + } + }, + "audioMute": { + "mute": { + "value": "muted", + "timestamp": "2021-08-21T19:19:52.832Z" + } + }, + "mediaTrackControl": { + "supportedTrackControlCommands": { + "value": null, + "timestamp": "2020-08-04T21:53:22.384Z" + } + }, + "custom.launchapp": {}, + "samsungvd.firmwareVersion": { + "firmwareVersion": { + "value": null, + "timestamp": "2020-10-29T10:47:19.376Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/virtual_thermostat.json b/tests/components/smartthings/fixtures/device_status/virtual_thermostat.json new file mode 100644 index 00000000000..c2c36fa249e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/virtual_thermostat.json @@ -0,0 +1,97 @@ +{ + "components": { + "main": { + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": "pending cool", + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 814.7469111058201, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "heatingSetpointRange": { + "value": { + "maximum": 3226.693210895862, + "step": 9234.459191378826, + "minimum": 6214.940743832475 + }, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": { + "maximum": 1826.722761785079, + "step": 138.2080712609211, + "minimum": 9268.726934158902 + }, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "temperature": { + "value": 8554.194688973037, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "thermostatFanMode": { + "thermostatFanMode": { + "value": "followschedule", + "data": {}, + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "supportedThermostatFanModes": { + "value": ["on"], + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "thermostatMode": { + "thermostatMode": { + "value": "auxheatonly", + "data": {}, + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "supportedThermostatModes": { + "value": ["rush hour"], + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "battery": { + "quantity": { + "value": 51, + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "type": { + "value": "38140", + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "maximum": 7288.145606306409, + "step": 7620.031701049315, + "minimum": 4997.721228739137 + }, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "coolingSetpoint": { + "value": 244.33726326608746, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/virtual_valve.json b/tests/components/smartthings/fixtures/device_status/virtual_valve.json new file mode 100644 index 00000000000..8cb66c72595 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/virtual_valve.json @@ -0,0 +1,13 @@ +{ + "components": { + "main": { + "refresh": {}, + "valve": { + "valve": { + "value": "closed", + "timestamp": "2025-02-11T11:27:02.262Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/virtual_water_sensor.json b/tests/components/smartthings/fixtures/device_status/virtual_water_sensor.json new file mode 100644 index 00000000000..8200bfe81a1 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/virtual_water_sensor.json @@ -0,0 +1,28 @@ +{ + "components": { + "main": { + "waterSensor": { + "water": { + "value": "dry", + "timestamp": "2025-02-10T21:58:18.784Z" + } + }, + "refresh": {}, + "battery": { + "quantity": { + "value": 84, + "timestamp": "2025-02-10T21:58:18.784Z" + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-10T21:58:18.784Z" + }, + "type": { + "value": "46120", + "timestamp": "2025-02-10T21:58:18.784Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/yale_push_button_deadbolt_lock.json b/tests/components/smartthings/fixtures/device_status/yale_push_button_deadbolt_lock.json new file mode 100644 index 00000000000..0bb1af96f70 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/yale_push_button_deadbolt_lock.json @@ -0,0 +1,110 @@ +{ + "components": { + "main": { + "lock": { + "supportedUnlockDirections": { + "value": null + }, + "supportedLockValues": { + "value": null + }, + "lock": { + "value": "locked", + "data": {}, + "timestamp": "2025-02-09T17:29:56.641Z" + }, + "supportedLockCommands": { + "value": null + } + }, + "refresh": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 86, + "unit": "%", + "timestamp": "2025-02-09T17:18:14.150Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "00840847", + "timestamp": "2025-02-09T11:48:45.331Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-02-09T11:48:45.331Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-02-09T11:48:45.332Z" + }, + "currentVersion": { + "value": "00840847", + "timestamp": "2025-02-09T11:48:45.328Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "lockCodes": { + "codeLength": { + "value": null, + "timestamp": "2020-08-04T15:29:24.127Z" + }, + "maxCodes": { + "value": 250, + "timestamp": "2023-08-22T01:34:19.751Z" + }, + "maxCodeLength": { + "value": 8, + "timestamp": "2023-08-22T01:34:18.690Z" + }, + "codeChanged": { + "value": "8 unset", + "data": { + "codeName": "Code 8" + }, + "timestamp": "2025-01-06T04:56:31.712Z" + }, + "lock": { + "value": "locked", + "data": { + "method": "manual" + }, + "timestamp": "2023-07-10T23:03:42.305Z" + }, + "minCodeLength": { + "value": 4, + "timestamp": "2023-08-22T01:34:18.781Z" + }, + "codeReport": { + "value": 5, + "timestamp": "2022-08-01T01:36:58.424Z" + }, + "scanCodes": { + "value": "Complete", + "timestamp": "2025-01-06T04:56:31.730Z" + }, + "lockCodes": { + "value": "{\"1\":\"Salim\",\"2\":\"Saima\",\"3\":\"Sarah\",\"4\":\"Aisha\",\"5\":\"Moiz\"}", + "timestamp": "2025-01-06T04:56:28.325Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json b/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json new file mode 100644 index 00000000000..5ef0e2fd9eb --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json @@ -0,0 +1,70 @@ +{ + "items": [ + { + "deviceId": "f0af21a2-d5a1-437c-b10a-b34a87394b71", + "name": "aeotec-home-energy-meter-gen5", + "label": "Aeotec Energy Monitor", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "3e0921d3-0a66-3d49-b458-752e596838e9", + "deviceManufacturerCode": "0086-0002-005F", + "locationId": "6911ddf5-f0cb-4516-a06a-3a2a6ec22bca", + "ownerId": "93257fc4-6471-2566-b06e-2fe72dd979fa", + "roomId": "cdf080f0-0542-41d7-a606-aff69683e04c", + "components": [ + { + "id": "main", + "label": "Meter", + "capabilities": [ + { + "id": "powerMeter", + "version": 1 + }, + { + "id": "energyMeter", + "version": 1 + }, + { + "id": "voltageMeasurement", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "CurbPowerMeter", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-01-12T23:02:44.917Z", + "parentDeviceId": "6a2d07a4-dd77-48bc-9acf-017029aaf099", + "profile": { + "id": "6372c227-93c7-32ef-9be5-aef2221adff1" + }, + "zwave": { + "networkId": "0A", + "driverId": "b98b34ce-1d1d-480c-bb17-41307a90cde0", + "executingLocally": true, + "hubId": "6a2d07a4-dd77-48bc-9acf-017029aaf099", + "networkSecurityLevel": "ZWAVE_S0_LEGACY", + "provisioningState": "PROVISIONED", + "manufacturerId": 134, + "productType": 2, + "productId": 95 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/base_electric_meter.json b/tests/components/smartthings/fixtures/devices/base_electric_meter.json new file mode 100644 index 00000000000..9e0c130978c --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/base_electric_meter.json @@ -0,0 +1,62 @@ +{ + "items": [ + { + "deviceId": "68e786a6-7f61-4c3a-9e13-70b803cf782b", + "name": "base-electric-meter", + "label": "Aeon Energy Monitor", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "8e619cd9-c271-3ba0-9015-62bc074bc47f", + "deviceManufacturerCode": "0086-0002-0009", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "powerMeter", + "version": 1 + }, + { + "id": "energyMeter", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "CurbPowerMeter", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-06-03T16:23:57.284Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "d382796f-8ed5-3088-8735-eb03e962203b" + }, + "zwave": { + "networkId": "2A", + "driverId": "4fb7ec02-2697-4d73-977d-2b1c65c4484f", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "networkSecurityLevel": "ZWAVE_LEGACY_NON_SECURE", + "provisioningState": "PROVISIONED", + "manufacturerId": 134, + "productType": 2, + "productId": 9 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json b/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json new file mode 100644 index 00000000000..a9e3bddb2ca --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json @@ -0,0 +1,79 @@ +{ + "items": [ + { + "deviceId": "10e06a70-ee7d-4832-85e9-a0a06a7a05bd", + "name": "c2c-arlo-pro-3-switch", + "label": "2nd Floor Hallway", + "manufacturerName": "SmartThings", + "presentationId": "SmartThings-smartthings-c2c_arlo_pro_3", + "deviceManufacturerCode": "Arlo", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "68b45114-9af8-4906-8636-b973a6faa271", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "soundSensor", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "videoStream", + "version": 1 + }, + { + "id": "motionSensor", + "version": 1 + }, + { + "id": "videoCapture", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "alarm", + "version": 1 + } + ], + "categories": [ + { + "name": "Camera", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-11-21T21:55:59.340Z", + "profile": { + "id": "89aefc3a-e210-4678-944c-638d47d296f6" + }, + "viper": { + "manufacturerName": "Arlo", + "modelName": "VMC4041PB", + "endpointAppId": "viper_555d6f40-b65a-11ea-8fe0-77cb99571462" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/c2c_shade.json b/tests/components/smartthings/fixtures/devices/c2c_shade.json new file mode 100644 index 00000000000..265eab11ff5 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/c2c_shade.json @@ -0,0 +1,59 @@ +{ + "items": [ + { + "deviceId": "571af102-15db-4030-b76b-245a691f74a5", + "name": "c2c-shade", + "label": "Curtain 1A", + "manufacturerName": "SmartThings", + "presentationId": "SmartThings-smartthings-c2c-shade", + "deviceManufacturerCode": "WonderLabs Company", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "windowShade", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + } + ], + "categories": [ + { + "name": "Blind", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-07T23:01:15.883Z", + "profile": { + "id": "0ceffb3e-10d3-4123-bb42-2a92c93c6e25" + }, + "viper": { + "manufacturerName": "WonderLabs Company", + "modelName": "WoCurtain3", + "hwVersion": "WoCurtain3-WoCurtain3", + "endpointAppId": "viper_f18eb770-077d-11ea-bb72-9922e3ed0d38" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/centralite.json b/tests/components/smartthings/fixtures/devices/centralite.json new file mode 100644 index 00000000000..68cdbdf4499 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/centralite.json @@ -0,0 +1,67 @@ +{ + "items": [ + { + "deviceId": "d0268a69-abfb-4c92-a646-61cec2e510ad", + "name": "plug-level-power", + "label": "Dimmer Debian", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "bb7c4cfb-6eaf-3efc-823b-06a54fc9ded9", + "deviceManufacturerCode": "CentraLite", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "powerMeter", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "SmartPlug", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-08-15T22:16:37.926Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "24195ea4-635c-3450-a235-71bc78ab3d1c" + }, + "zigbee": { + "eui": "000D6F0003C04BC9", + "networkId": "F50E", + "driverId": "f2e891c6-00cc-446c-9192-8ebda63d9898", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/contact_sensor.json b/tests/components/smartthings/fixtures/devices/contact_sensor.json new file mode 100644 index 00000000000..a5de2e2cbfe --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/contact_sensor.json @@ -0,0 +1,71 @@ +{ + "items": [ + { + "deviceId": "2d9a892b-1c93-45a5-84cb-0e81889498c6", + "name": "contact-profile", + "label": ".Front Door Open/Closed Sensor", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "a7f2c1d9-89b3-35a4-b217-fc68d9e4e752", + "deviceManufacturerCode": "Visonic", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "68b45114-9af8-4906-8636-b973a6faa271", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "ContactSensor", + "categoryType": "manufacturer" + }, + { + "name": "ContactSensor", + "categoryType": "user" + } + ] + } + ], + "createTime": "2023-09-28T17:38:59.179Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "22aa5a07-ac33-365f-b2f1-5ecef8cdb0eb" + }, + "zigbee": { + "eui": "000D6F000576F604", + "networkId": "5A44", + "driverId": "408981c2-91d4-4dfc-bbfb-84ca0205d993", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json new file mode 100644 index 00000000000..ec7f16b090a --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json @@ -0,0 +1,311 @@ +{ + "items": [ + { + "deviceId": "96a5ef74-5832-a84b-f1f7-ca799957065d", + "name": "[room a/c] Samsung", + "label": "AC Office Granit", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-RAC-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "58d3fd7c-c512-4da3-b500-ef269382756c", + "ownerId": "f9a28d7c-1ed5-d9e9-a81c-18971ec081db", + "roomId": "85a79db4-9cf2-4f09-a5b2-cd70a5c0cef0", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "fanOscillationMode", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "custom.spiMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.airConditionerOptionalMode", + "version": 1 + }, + { + "id": "custom.airConditionerTropicalNightMode", + "version": 1 + }, + { + "id": "custom.autoCleaningMode", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.airConditionerOdorController", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "1", + "label": "1", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "fanOscillationMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.autoCleaningMode", + "version": 1 + }, + { + "id": "custom.airConditionerTropicalNightMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "custom.spiMode", + "version": 1 + }, + { + "id": "custom.airConditionerOptionalMode", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.airConditionerOdorController", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-04-06T16:43:34.753Z", + "profile": { + "id": "60fbc713-8da5-315d-b31a-6d6dcde4be7b" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "[room a/c] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000", + "platformVersion": "0G3MPDCKA00010E", + "platformOS": "TizenRT2.0", + "hwVersion": "1.0", + "firmwareVersion": "0.1.0", + "vendorId": "DA-AC-RAC-000001", + "lastSignupTime": "2021-04-06T16:43:27.889445Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json new file mode 100644 index 00000000000..8d9ebde5bcd --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json @@ -0,0 +1,264 @@ +{ + "items": [ + { + "deviceId": "4ece486b-89db-f06a-d54d-748b676b4d8e", + "name": "Samsung-Room-Air-Conditioner", + "label": "Aire Dormitorio Principal", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-RAC-01001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c4189ac1-208f-461a-8ab6-ea67937b3743", + "ownerId": "85ea07e1-7063-f673-3ba5-125293f297c8", + "roomId": "1f66199a-1773-4d8f-97b7-44c312a62cf7", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "bypassable", + "version": 1 + }, + { + "id": "fanOscillationMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "custom.spiMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.airConditionerOptionalMode", + "version": 1 + }, + { + "id": "custom.airConditionerTropicalNightMode", + "version": 1 + }, + { + "id": "custom.autoCleaningMode", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.veryFineDustFilter", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.electricHepaFilter", + "version": 1 + }, + { + "id": "custom.doNotDisturbMode", + "version": 1 + }, + { + "id": "custom.periodicSensing", + "version": 1 + }, + { + "id": "custom.airConditionerOdorController", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.alwaysOnSensing", + "version": 1 + }, + { + "id": "samsungce.airConditionerBeep", + "version": 1 + }, + { + "id": "samsungce.airConditionerLighting", + "version": 1 + }, + { + "id": "samsungce.airQualityHealthConcern", + "version": 1 + }, + { + "id": "samsungce.buttonDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.dustFilterAlarm", + "version": 1 + }, + { + "id": "samsungce.individualControlLock", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.silentAction", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.welcomeCooling", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + }, + { + "id": "sec.calmConnectionCare", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-01-28T21:31:35.755Z", + "profile": { + "id": "091a55f4-7054-39fa-b23e-b56deb7580f8" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Samsung-Room-Air-Conditioner", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "ARA-WW-TP1-22-COMMON|10229641|60010523001511014600083200800000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "ARA-WW-TP1-22-COMMON_11240702", + "vendorId": "DA-AC-RAC-01001", + "vendorResourceClientServerVersion": "Realtek Release 3.1.240221", + "lastSignupTime": "2025-01-28T21:31:30.090416369Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json b/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json new file mode 100644 index 00000000000..f6599fee461 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json @@ -0,0 +1,176 @@ +{ + "items": [ + { + "deviceId": "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "name": "Samsung Microwave", + "label": "Microwave", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-KS-MICROWAVE-0101X", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "f4d03391-ab13-4c1d-b4dc-d6ddf86014a2", + "deviceTypeName": "oic.d.microwave", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "ovenSetpoint", + "version": 1 + }, + { + "id": "ovenMode", + "version": 1 + }, + { + "id": "ovenOperatingState", + "version": 1 + }, + { + "id": "doorControl", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.hoodFanSpeed", + "version": 1 + }, + { + "id": "samsungce.definedRecipe", + "version": 1 + }, + { + "id": "samsungce.doorState", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceIdentification", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceDefaults", + "version": 1 + }, + { + "id": "samsungce.ovenMode", + "version": 1 + }, + { + "id": "samsungce.ovenOperatingState", + "version": 1 + }, + { + "id": "samsungce.microwavePower", + "version": 1 + }, + { + "id": "samsungce.kitchenModeSpecification", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + } + ], + "categories": [ + { + "name": "Microwave", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hood", + "label": "hood", + "capabilities": [ + { + "id": "samsungce.lamp", + "version": 1 + }, + { + "id": "samsungce.hoodFanSpeed", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2022-03-23T15:59:10.704Z", + "profile": { + "id": "e5db3b6f-cad6-3caa-9775-9c9cae20f4a4" + }, + "ocf": { + "ocfDeviceType": "oic.d.microwave", + "name": "Samsung Microwave", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP2X_DA-KS-MICROWAVE-0101X|40436241|50040100011411000200000000000000", + "platformVersion": "DAWIT 3.0", + "platformOS": "TizenRT 2.0 + IPv6", + "hwVersion": "MediaTek", + "firmwareVersion": "AKS-WW-TP2-20-MICROWAVE-OTR_40230125", + "vendorId": "DA-KS-MICROWAVE-0101X", + "vendorResourceClientServerVersion": "MediaTek Release 2.220916.2", + "lastSignupTime": "2022-04-17T15:33:11.063457Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json new file mode 100644 index 00000000000..67afc0ad32c --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json @@ -0,0 +1,412 @@ +{ + "items": [ + { + "deviceId": "7db87911-7dce-1cf2-7119-b953432a2f09", + "name": "[refrigerator] Samsung", + "label": "Refrigerator", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-REF-NORMAL-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "3a1f7e7c-4e59-4c29-adb0-0813be691efd", + "deviceTypeName": "Samsung OCF Refrigerator", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "refrigeration", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.waterFilter", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.fridgeVacationMode", + "version": 1 + }, + { + "id": "samsungce.powerCool", + "version": 1 + }, + { + "id": "samsungce.powerFreeze", + "version": 1 + }, + { + "id": "samsungce.sabbathMode", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + } + ], + "categories": [ + { + "name": "Refrigerator", + "categoryType": "manufacturer" + }, + { + "name": "Refrigerator", + "categoryType": "user" + } + ] + }, + { + "id": "freezer", + "label": "freezer", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cooler", + "label": "cooler", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cvroom", + "label": "cvroom", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "onedoor", + "label": "onedoor", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker", + "label": "icemaker", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker-02", + "label": "icemaker-02", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "pantry-01", + "label": "pantry-01", + "capabilities": [ + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "pantry-02", + "label": "pantry-02", + "capabilities": [ + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2022-01-08T16:50:43.544Z", + "profile": { + "id": "f2a9af35-5df8-3477-91df-94941d302591" + }, + "ocf": { + "ocfDeviceType": "oic.d.refrigerator", + "name": "[refrigerator] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP2X_REF_20K|00115641|0004014D011411200103000020000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "MediaTek", + "firmwareVersion": "A-RFWW-TP2-21-COMMON_20220110", + "vendorId": "DA-REF-NORMAL-000001", + "vendorResourceClientServerVersion": "MediaTek Release 2.210524.1", + "lastSignupTime": "2024-08-06T15:24:29.362093Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json b/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json new file mode 100644 index 00000000000..b355eedb17a --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json @@ -0,0 +1,119 @@ +{ + "items": [ + { + "deviceId": "3442dfc6-17c0-a65f-dae0-4c6e01786f44", + "name": "[robot vacuum] Samsung", + "label": "Robot vacuum", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-RVC-NORMAL-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "5d425f41-042a-4d9a-92c4-e43150a61bae", + "deviceTypeName": "Samsung OCF Robot Vacuum", + "components": [ + { + "id": "main", + "label": "Robot vacuum", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "robotCleanerTurboMode", + "version": 1 + }, + { + "id": "robotCleanerMovement", + "version": 1 + }, + { + "id": "robotCleanerCleaningMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.robotCleanerCleaningMode", + "version": 1 + }, + { + "id": "samsungce.robotCleanerOperatingState", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + } + ], + "categories": [ + { + "name": "RobotCleaner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2018-06-06T23:04:25Z", + "profile": { + "id": "61b1c3cd-61cc-3dde-a4ba-9477d5e559cb" + }, + "ocf": { + "ocfDeviceType": "oic.d.robotcleaner", + "name": "[robot vacuum] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "powerbot_7000_17M|50016055|80010404011141000100000000000000", + "platformVersion": "00", + "platformOS": "Tizen(3/0)", + "hwVersion": "1.0", + "firmwareVersion": "1.0", + "vendorId": "DA-RVC-NORMAL-000001", + "lastSignupTime": "2020-11-03T04:43:02.729Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json new file mode 100644 index 00000000000..1c7024e153f --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json @@ -0,0 +1,168 @@ +{ + "items": [ + { + "deviceId": "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "name": "[dishwasher] Samsung", + "label": "Dishwasher", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-DW-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "f4d03391-ab13-4c1d-b4dc-d6ddf86014a2", + "deviceTypeName": "Samsung OCF Dishwasher", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "dishwasherOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dishwasherOperatingProgress", + "version": 1 + }, + { + "id": "custom.dishwasherOperatingPercentage", + "version": 1 + }, + { + "id": "custom.dishwasherDelayStartTime", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "custom.waterFilter", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dishwasherJobState", + "version": 1 + }, + { + "id": "samsungce.dishwasherWashingCourse", + "version": 1 + }, + { + "id": "samsungce.dishwasherWashingCourseDetails", + "version": 1 + }, + { + "id": "samsungce.dishwasherOperation", + "version": 1 + }, + { + "id": "samsungce.dishwasherWashingOptions", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.waterConsumptionReport", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Dishwasher", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-06-27T01:19:35.408Z", + "profile": { + "id": "0cba797c-40ee-3473-aa01-4ee5b6cb8c67" + }, + "ocf": { + "ocfDeviceType": "oic.d.dishwasher", + "name": "[dishwasher] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_DW_A51_20_COMMON|30007242|40010201001311000101000000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "ARTIK051", + "firmwareVersion": "DA_DW_A51_20_COMMON_30230714", + "vendorId": "DA-WM-DW-000001", + "vendorResourceClientServerVersion": "ARTIK051 Release 2.210224.1", + "lastSignupTime": "2021-10-16T17:28:59.984202Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json new file mode 100644 index 00000000000..b9a650718e2 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json @@ -0,0 +1,204 @@ +{ + "items": [ + { + "deviceId": "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "name": "[dryer] Samsung", + "label": "Dryer", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-WD-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "781d5f1e-c87e-455e-87f7-8e954879e91d", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "2a8637b2-77ad-475e-b537-7b6f7f97fff6", + "deviceTypeName": "Samsung OCF Dryer", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "dryerOperatingState", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dryerDryLevel", + "version": 1 + }, + { + "id": "custom.dryerWrinklePrevent", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.detergentOrder", + "version": 1 + }, + { + "id": "samsungce.detergentState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.dryerAutoCycleLink", + "version": 1 + }, + { + "id": "samsungce.dryerCycle", + "version": 1 + }, + { + "id": "samsungce.dryerCyclePreset", + "version": 1 + }, + { + "id": "samsungce.dryerDelayEnd", + "version": 1 + }, + { + "id": "samsungce.dryerDryingTemperature", + "version": 1 + }, + { + "id": "samsungce.dryerDryingTime", + "version": 1 + }, + { + "id": "samsungce.dryerFreezePrevent", + "version": 1 + }, + { + "id": "samsungce.dryerOperatingState", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Dryer", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hca.main", + "label": "hca.main", + "capabilities": [ + { + "id": "hca.dryerMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-06-01T22:54:25.907Z", + "profile": { + "id": "53a1d049-eeda-396c-8324-e33438ef57be" + }, + "ocf": { + "ocfDeviceType": "oic.d.dryer", + "name": "[dryer] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "ARTIK051", + "firmwareVersion": "DA_WM_A51_20_COMMON_30230708", + "vendorId": "DA-WM-WD-000001", + "vendorResourceClientServerVersion": "ARTIK051 Release 2.210224.1", + "lastSignupTime": "2021-06-01T22:54:22.826697Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json new file mode 100644 index 00000000000..852a2afa932 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json @@ -0,0 +1,260 @@ +{ + "items": [ + { + "deviceId": "f984b91d-f250-9d42-3436-33f09a422a47", + "name": "[washer] Samsung", + "label": "Washer", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-WM-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "781d5f1e-c87e-455e-87f7-8e954879e91d", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "2a8637b2-77ad-475e-b537-7b6f7f97fff6", + "deviceTypeName": "Samsung OCF Washer", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "washerOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dryerDryLevel", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "custom.washerAutoDetergent", + "version": 1 + }, + { + "id": "custom.washerAutoSoftener", + "version": 1 + }, + { + "id": "custom.washerRinseCycles", + "version": 1 + }, + { + "id": "custom.washerSoilLevel", + "version": 1 + }, + { + "id": "custom.washerSpinLevel", + "version": 1 + }, + { + "id": "custom.washerWaterTemperature", + "version": 1 + }, + { + "id": "samsungce.autoDispenseDetergent", + "version": 1 + }, + { + "id": "samsungce.autoDispenseSoftener", + "version": 1 + }, + { + "id": "samsungce.detergentOrder", + "version": 1 + }, + { + "id": "samsungce.detergentState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.detergentAutoReplenishment", + "version": 1 + }, + { + "id": "samsungce.softenerAutoReplenishment", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softenerOrder", + "version": 1 + }, + { + "id": "samsungce.softenerState", + "version": 1 + }, + { + "id": "samsungce.washerBubbleSoak", + "version": 1 + }, + { + "id": "samsungce.washerCycle", + "version": 1 + }, + { + "id": "samsungce.washerCyclePreset", + "version": 1 + }, + { + "id": "samsungce.washerDelayEnd", + "version": 1 + }, + { + "id": "samsungce.washerFreezePrevent", + "version": 1 + }, + { + "id": "samsungce.washerOperatingState", + "version": 1 + }, + { + "id": "samsungce.washerWashingTime", + "version": 1 + }, + { + "id": "samsungce.washerWaterLevel", + "version": 1 + }, + { + "id": "samsungce.washerWaterValve", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.waterConsumptionReport", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.energyPlanner", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Washer", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hca.main", + "label": "hca.main", + "capabilities": [ + { + "id": "hca.washerMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-06-01T22:52:18.023Z", + "profile": { + "id": "3f221c79-d81c-315f-8e8b-b5742802a1e3" + }, + "ocf": { + "ocfDeviceType": "oic.d.washer", + "name": "[washer] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 2.0 + IPv6", + "hwVersion": "MediaTek", + "firmwareVersion": "DA_WM_TP2_20_COMMON_30230804", + "vendorId": "DA-WM-WM-000001", + "vendorResourceClientServerVersion": "MediaTek Release 2.211214.1", + "lastSignupTime": "2021-06-01T22:52:13.923649Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/ecobee_sensor.json b/tests/components/smartthings/fixtures/devices/ecobee_sensor.json new file mode 100644 index 00000000000..4c37a17f1a0 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ecobee_sensor.json @@ -0,0 +1,64 @@ +{ + "items": [ + { + "deviceId": "d5dc3299-c266-41c7-bd08-f540aea54b89", + "name": "ecobee Sensor", + "label": "Child Bedroom", + "manufacturerName": "0A0b", + "presentationId": "ST_635a866e-a3ea-4184-9d60-9c72ea603dfd", + "deviceManufacturerCode": "ecobee", + "locationId": "b6fe1fcb-e82b-4ce8-a5e1-85e96adba06c", + "ownerId": "b473ee01-2b1f-7bb1-c433-3caec75960bc", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "motionSensor", + "version": 1 + }, + { + "id": "presenceSensor", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + } + ], + "categories": [ + { + "name": "MotionSensor", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-01-16T21:14:07.283Z", + "profile": { + "id": "8ab3ca07-0d07-471b-a276-065e46d7aa8a" + }, + "viper": { + "manufacturerName": "ecobee", + "modelName": "aresSmart-ecobee3_remote_sensor", + "swVersion": "250206213001", + "hwVersion": "250206213001", + "endpointAppId": "viper_92ccdcc0-4184-11eb-b9c5-036180216747" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/ecobee_thermostat.json b/tests/components/smartthings/fixtures/devices/ecobee_thermostat.json new file mode 100644 index 00000000000..9becb0923c2 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ecobee_thermostat.json @@ -0,0 +1,80 @@ +{ + "items": [ + { + "deviceId": "028469cb-6e89-4f14-8d9a-bfbca5e0fbfc", + "name": "v4 - ecobee Thermostat - Heat and Cool (F)", + "label": "Main Floor", + "manufacturerName": "0A0b", + "presentationId": "ST_5334da38-8076-4b40-9f6c-ac3fccaa5d24", + "deviceManufacturerCode": "ecobee", + "locationId": "b6fe1fcb-e82b-4ce8-a5e1-85e96adba06c", + "ownerId": "b473ee01-2b1f-7bb1-c433-3caec75960bc", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "thermostatFanMode", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-01-16T21:14:07.276Z", + "profile": { + "id": "234d537d-d388-497f-b0f4-2e25025119ba" + }, + "viper": { + "manufacturerName": "ecobee", + "modelName": "aresSmart-thermostat", + "swVersion": "250206151734", + "hwVersion": "250206151734", + "endpointAppId": "viper_92ccdcc0-4184-11eb-b9c5-036180216747" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/fake_fan.json b/tests/components/smartthings/fixtures/devices/fake_fan.json new file mode 100644 index 00000000000..7b8e174d420 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/fake_fan.json @@ -0,0 +1,50 @@ +{ + "items": [ + { + "deviceId": "f1af21a2-d5a1-437c-b10a-b34a87394b71", + "name": "fake-fan", + "label": "Fake fan", + "manufacturerName": "Myself", + "presentationId": "3f0921d3-0a66-3d49-b458-752e596838e9", + "deviceManufacturerCode": "0086-0002-005F", + "locationId": "6f11ddf5-f0cb-4516-a06a-3a2a6ec22bca", + "ownerId": "9f257fc4-6471-2566-b06e-2fe72dd979fa", + "roomId": "cdf080f0-0542-41d7-a606-aff69683e04c", + "components": [ + { + "id": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "fanSpeed", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Fan", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-01-12T23:02:44.917Z", + "parentDeviceId": "6a2dd7a4-dd77-48bc-9acf-017029aaf099", + "profile": { + "id": "6372cd27-93c7-32ef-9be5-aef2221adff1" + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json new file mode 100644 index 00000000000..910eacec2cc --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json @@ -0,0 +1,65 @@ +{ + "items": [ + { + "deviceId": "aaedaf28-2ae0-4c1d-b57e-87f6a420c298", + "name": "GE Dimmer Switch", + "label": "Basement Exit Light", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "31cf01ee-cb49-3d95-ac2d-2afab47f25c7", + "deviceManufacturerCode": "0063-4944-3130", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "roomId": "e73dcd00-6953-431d-ae79-73fd2f2c528e", + "components": [ + { + "id": "main", + "label": "Basement Exit Light", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Switch", + "categoryType": "manufacturer" + }, + { + "name": "Switch", + "categoryType": "user" + } + ] + } + ], + "createTime": "2020-05-25T18:18:01Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "ec5458c2-c011-3479-a59b-82b42820c2f7" + }, + "zwave": { + "networkId": "14", + "driverId": "2cbf55e3-dbc2-48a2-8be5-4c3ce756b692", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "networkSecurityLevel": "ZWAVE_LEGACY_NON_SECURE", + "provisioningState": "NONFUNCTIONAL", + "manufacturerId": 99, + "productType": 18756, + "productId": 12592 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/hue_color_temperature_bulb.json b/tests/components/smartthings/fixtures/devices/hue_color_temperature_bulb.json new file mode 100644 index 00000000000..7f729001453 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/hue_color_temperature_bulb.json @@ -0,0 +1,73 @@ +{ + "items": [ + { + "deviceId": "440063de-a200-40b5-8a6b-f3399eaa0370", + "name": "hue-color-temperature-bulb", + "label": "Bathroom spot", + "manufacturerName": "0A2r", + "presentationId": "ST_b93bec0e-1a81-4471-83fc-4dddca504acd", + "deviceManufacturerCode": "Signify Netherlands B.V.", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "colorTemperature", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "synthetic.lightingEffectCircadian", + "version": 1 + }, + { + "id": "synthetic.lightingEffectFade", + "version": 1 + } + ], + "categories": [ + { + "name": "Light", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-12-17T18:11:41.453Z", + "profile": { + "id": "a79e4507-ecaa-3c7e-b660-a3a71f30eafb" + }, + "viper": { + "uniqueIdentifier": "ea409b82a6184ad9b49bd6318692cc1c", + "manufacturerName": "Signify Netherlands B.V.", + "modelName": "Hue ambiance spot", + "swVersion": "1.122.2", + "hwVersion": "LTG002", + "endpointAppId": "viper_71ee45b0-a794-11e9-86b2-fdd6b9f75ce6" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/hue_rgbw_color_bulb.json b/tests/components/smartthings/fixtures/devices/hue_rgbw_color_bulb.json new file mode 100644 index 00000000000..eeca03fec01 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/hue_rgbw_color_bulb.json @@ -0,0 +1,81 @@ +{ + "items": [ + { + "deviceId": "cb958955-b015-498c-9e62-fc0c51abd054", + "name": "hue-rgbw-color-bulb", + "label": "Standing light", + "manufacturerName": "0A2r", + "presentationId": "ST_2733b8dc-4b0f-4593-8e49-2432202abd52", + "deviceManufacturerCode": "Signify Netherlands B.V.", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "colorControl", + "version": 1 + }, + { + "id": "colorTemperature", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "samsungim.hueSyncMode", + "version": 1 + }, + { + "id": "synthetic.lightingEffectCircadian", + "version": 1 + }, + { + "id": "synthetic.lightingEffectFade", + "version": 1 + } + ], + "categories": [ + { + "name": "Light", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-12-17T18:11:41.454Z", + "profile": { + "id": "71be1b96-c5b5-38f7-a22c-65f5392ce7ed" + }, + "viper": { + "uniqueIdentifier": "f5f891a57b9d45408230b4228bdc2111", + "manufacturerName": "Signify Netherlands B.V.", + "modelName": "Hue color lamp", + "swVersion": "1.122.2", + "hwVersion": "LCA001", + "endpointAppId": "viper_71ee45b0-a794-11e9-86b2-fdd6b9f75ce6" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/iphone.json b/tests/components/smartthings/fixtures/devices/iphone.json new file mode 100644 index 00000000000..3fc26307c90 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/iphone.json @@ -0,0 +1,41 @@ +{ + "items": [ + { + "deviceId": "184c67cc-69e2-44b6-8f73-55c963068ad9", + "name": "iPhone", + "label": "iPhone", + "manufacturerName": "SmartThings", + "presentationId": "SmartThings-smartthings-Mobile_Presence", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "presenceSensor", + "version": 1 + } + ], + "categories": [ + { + "name": "MobilePresence", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-12-02T16:14:24.394Z", + "parentDeviceId": "b8e11599-5297-4574-8e62-885995fcaa20", + "profile": { + "id": "21d0f660-98b4-3f7b-8114-fe62e555628e" + }, + "type": "MOBILE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json new file mode 100644 index 00000000000..3770614a366 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json @@ -0,0 +1,78 @@ +{ + "items": [ + { + "deviceId": "7d246592-93db-4d72-a10d-5a51793ece8c", + "name": "Multipurpose Sensor", + "label": "Deck Door", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "c385e2bc-acb8-317b-be2a-6efd1f879720", + "deviceManufacturerCode": "SmartThings", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "roomId": "b277a3c0-b8fe-44de-9133-c1108747810c", + "components": [ + { + "id": "main", + "label": "Deck Door", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "threeAxis", + "version": 1 + }, + { + "id": "accelerationSensor", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "MultiFunctionalSensor", + "categoryType": "manufacturer" + }, + { + "name": "Door", + "categoryType": "user" + } + ] + } + ], + "createTime": "2019-02-23T16:53:57Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "4471213f-121b-38fd-b022-51df37ac1d4c" + }, + "zigbee": { + "eui": "24FD5B00010AED6B", + "networkId": "C972", + "driverId": "408981c2-91d4-4dfc-bbfb-84ca0205d993", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/sensibo_airconditioner_1.json b/tests/components/smartthings/fixtures/devices/sensibo_airconditioner_1.json new file mode 100644 index 00000000000..ae6596755a3 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/sensibo_airconditioner_1.json @@ -0,0 +1,64 @@ +{ + "items": [ + { + "deviceId": "bf4b1167-48a3-4af7-9186-0900a678ffa5", + "name": "sensibo-airconditioner-1", + "label": "Office", + "manufacturerName": "0ABU", + "presentationId": "sensibo-airconditioner-1", + "deviceManufacturerCode": "Sensibo", + "locationId": "fe14085e-bacb-4997-bc0c-df08204eaea2", + "ownerId": "49228038-22ca-1c78-d7ab-b774b4569480", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-12-04T10:10:02.873Z", + "profile": { + "id": "ddaffb28-8ebb-4bd6-9d6f-57c28dcb434d" + }, + "viper": { + "manufacturerName": "Sensibo", + "modelName": "skyplus", + "swVersion": "SKY40147", + "hwVersion": "SKY40147", + "endpointAppId": "viper_5661d200-806e-11e9-abe0-3b2f83c8954c" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/smart_plug.json b/tests/components/smartthings/fixtures/devices/smart_plug.json new file mode 100644 index 00000000000..24d0fbc6e84 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/smart_plug.json @@ -0,0 +1,59 @@ +{ + "items": [ + { + "deviceId": "550a1c72-65a0-4d55-b97b-75168e055398", + "name": "SYLVANIA SMART+ Smart Plug", + "label": "Arlo Beta Basestation", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "28127039-043b-3df0-adf2-7541403dc4c1", + "deviceManufacturerCode": "LEDVANCE", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "components": [ + { + "id": "main", + "label": "Pi Hole", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Switch", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2018-10-05T12:23:14Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "daeff874-075a-32e3-8b11-bdb99d8e67c7" + }, + "zigbee": { + "eui": "F0D1B80000051E05", + "networkId": "801E", + "driverId": "f2e891c6-00cc-446c-9192-8ebda63d9898", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/sonos_player.json b/tests/components/smartthings/fixtures/devices/sonos_player.json new file mode 100644 index 00000000000..67d1ef24cf9 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/sonos_player.json @@ -0,0 +1,82 @@ +{ + "items": [ + { + "deviceId": "c85fced9-c474-4a47-93c2-037cc7829536", + "name": "sonos-player", + "label": "Elliots Rum", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "ef0a871d-9ed1-377d-8746-0da1dfd50598", + "deviceManufacturerCode": "Sonos", + "locationId": "eed0e167-e793-459b-80cb-a0b02e2b86c2", + "ownerId": "2c69cc36-85ae-c41a-9981-a4ee96cd9137", + "roomId": "105e6d1a-52a4-4797-a235-5a48d7d433c8", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "mediaGroup", + "version": 1 + }, + { + "id": "mediaPresets", + "version": 1 + }, + { + "id": "mediaTrackControl", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "audioTrackData", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Speaker", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-02T13:18:28.570Z", + "parentDeviceId": "2f7f7d2b-e683-48ae-86f7-e57df6a0bce2", + "profile": { + "id": "0443d359-3f76-383f-82a4-6fc4a879ef1d" + }, + "lan": { + "networkId": "38420B9108F6", + "driverId": "c21a6c77-872c-474e-be5b-5f6f11a240ef", + "executingLocally": true, + "hubId": "2f7f7d2b-e683-48ae-86f7-e57df6a0bce2", + "provisioningState": "TYPED" + }, + "type": "LAN", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json b/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json new file mode 100644 index 00000000000..7fb07533810 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json @@ -0,0 +1,109 @@ +{ + "items": [ + { + "deviceId": "0d94e5db-8501-2355-eb4f-214163702cac", + "name": "Soundbar", + "label": "Soundbar Living", + "manufacturerName": "Samsung Electronics", + "presentationId": "VD-NetworkAudio-002S", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c4189ac1-208f-461a-8ab6-ea67937b3743", + "ownerId": "85ea07e1-7063-f673-3ba5-125293f297c8", + "roomId": "db506ec3-83b1-4125-9c4c-eb597da5db6a", + "deviceTypeName": "Samsung OCF Network Audio Player", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "audioTrackData", + "version": 1 + }, + { + "id": "samsungvd.audioInputSource", + "version": 1 + }, + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "samsungvd.soundFrom", + "version": 1 + }, + { + "id": "samsungvd.thingStatus", + "version": 1 + }, + { + "id": "samsungvd.audioGroupInfo", + "version": 1, + "ephemeral": true + } + ], + "categories": [ + { + "name": "NetworkAudio", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-10-26T02:58:40.549Z", + "profile": { + "id": "3a714028-20ea-3feb-9891-46092132c737" + }, + "ocf": { + "ocfDeviceType": "oic.d.networkaudio", + "name": "Soundbar Living", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "HW-Q990C", + "platformVersion": "7.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "SAT-iMX8M23WWC-1010.5", + "vendorId": "VD-NetworkAudio-002S", + "vendorResourceClientServerVersion": "3.2.41", + "lastSignupTime": "2024-10-26T02:58:36.491256384Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json b/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json new file mode 100644 index 00000000000..3c22a214495 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json @@ -0,0 +1,148 @@ +{ + "items": [ + { + "deviceId": "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "name": "[TV] Samsung 8 Series (49)", + "label": "[TV] Samsung 8 Series (49)", + "manufacturerName": "Samsung Electronics", + "presentationId": "VD-STV_2017_K", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "deviceTypeName": "Samsung OCF TV", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "tvChannel", + "version": 1 + }, + { + "id": "mediaInputSource", + "version": 1 + }, + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "mediaTrackControl", + "version": 1 + }, + { + "id": "custom.error", + "version": 1 + }, + { + "id": "custom.picturemode", + "version": 1 + }, + { + "id": "custom.soundmode", + "version": 1 + }, + { + "id": "custom.accessibility", + "version": 1 + }, + { + "id": "custom.launchapp", + "version": 1 + }, + { + "id": "custom.recording", + "version": 1 + }, + { + "id": "custom.tvsearch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungvd.ambient", + "version": 1 + }, + { + "id": "samsungvd.ambientContent", + "version": 1 + }, + { + "id": "samsungvd.mediaInputSource", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "samsungvd.firmwareVersion", + "version": 1 + }, + { + "id": "samsungvd.supportsPowerOnByOcf", + "version": 1 + } + ], + "categories": [ + { + "name": "Television", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2020-05-07T02:58:10Z", + "profile": { + "id": "bac5c673-8eea-3d00-b1d2-283b46539017" + }, + "ocf": { + "ocfDeviceType": "oic.d.tv", + "name": "[TV] Samsung 8 Series (49)", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "UN49MU8000", + "platformVersion": "Tizen 3.0", + "platformOS": "4.1.10", + "hwVersion": "0-0", + "firmwareVersion": "T-KTMAKUC-1290.3", + "vendorId": "VD-STV_2017_K", + "locale": "en_US", + "lastSignupTime": "2021-08-21T18:52:56.748359Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/virtual_thermostat.json b/tests/components/smartthings/fixtures/devices/virtual_thermostat.json new file mode 100644 index 00000000000..d5bf3b32a0c --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/virtual_thermostat.json @@ -0,0 +1,69 @@ +{ + "items": [ + { + "deviceId": "2894dc93-0f11-49cc-8a81-3a684cebebf6", + "name": "asd", + "label": "asd", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "78906115-bf23-3c43-9cd6-f42ca3d5517a", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "thermostatFanMode", + "version": 1 + }, + { + "id": "battery", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-10T22:04:56.174Z", + "profile": { + "id": "e921d7f2-5851-363d-89d5-5e83f5ab44c6" + }, + "virtual": { + "name": "asd", + "executingLocally": false + }, + "type": "VIRTUAL", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/virtual_valve.json b/tests/components/smartthings/fixtures/devices/virtual_valve.json new file mode 100644 index 00000000000..1988617afad --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/virtual_valve.json @@ -0,0 +1,49 @@ +{ + "items": [ + { + "deviceId": "612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3", + "name": "volvo", + "label": "volvo", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "916408b6-c94e-38b8-9fbf-03c8a48af5c3", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "valve", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "WaterValve", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-11T11:27:02.052Z", + "profile": { + "id": "f8e25992-7f5d-31da-b04d-497012590113" + }, + "virtual": { + "name": "volvo", + "executingLocally": false + }, + "type": "VIRTUAL", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json b/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json new file mode 100644 index 00000000000..ad3a45a0481 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json @@ -0,0 +1,53 @@ +{ + "items": [ + { + "deviceId": "a2a6018b-2663-4727-9d1d-8f56953b5116", + "name": "asd", + "label": "asd", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "838ae989-b832-3610-968c-2940491600f6", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "waterSensor", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "LeakSensor", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-10T21:58:18.688Z", + "profile": { + "id": "39230a95-d42d-34d4-a33c-f79573495a30" + }, + "virtual": { + "name": "asd", + "executingLocally": false + }, + "type": "VIRTUAL", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json new file mode 100644 index 00000000000..e83a1be7644 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json @@ -0,0 +1,67 @@ +{ + "items": [ + { + "deviceId": "a9f587c5-5d8b-4273-8907-e7f609af5158", + "name": "Yale Push Button Deadbolt Lock", + "label": "Basement Door Lock", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "45f9424f-4e20-34b0-abb6-5f26b189acb0", + "deviceManufacturerCode": "Yale", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "components": [ + { + "id": "main", + "label": "Basement Door Lock", + "capabilities": [ + { + "id": "lock", + "version": 1 + }, + { + "id": "lockCodes", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "SmartLock", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2016-11-18T23:01:19Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "51b76691-3c3a-3fce-8c7c-4f9d50e5885a" + }, + "zigbee": { + "eui": "000D6F0002FB6E24", + "networkId": "C771", + "driverId": "ce930ffd-8155-4dca-aaa9-6c4158fc4278", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/locations.json b/tests/components/smartthings/fixtures/locations.json new file mode 100644 index 00000000000..abfa17dc4b7 --- /dev/null +++ b/tests/components/smartthings/fixtures/locations.json @@ -0,0 +1,9 @@ +{ + "items": [ + { + "locationId": "397678e5-9995-4a39-9d9f-ae6ba310236c", + "name": "Home" + } + ], + "_links": null +} diff --git a/tests/components/smartthings/fixtures/scenes.json b/tests/components/smartthings/fixtures/scenes.json new file mode 100644 index 00000000000..aa4f1aaa3d1 --- /dev/null +++ b/tests/components/smartthings/fixtures/scenes.json @@ -0,0 +1,34 @@ +{ + "items": [ + { + "sceneId": "743b0f37-89b8-476c-aedf-eea8ad8cd29d", + "sceneName": "Away", + "sceneIcon": "203", + "sceneColor": null, + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "createdBy": "12d4af93-cb68-b108-87f5-625437d7371f", + "createdDate": 1738964737000, + "lastUpdatedDate": 1738964737000, + "lastExecutedDate": null, + "editable": false, + "apiVersion": "20200501" + }, + { + "sceneId": "f3341e8b-9b32-4509-af2e-4f7c952e98ba", + "sceneName": "Home", + "sceneIcon": "204", + "sceneColor": null, + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "createdBy": "12d4af93-cb68-b108-87f5-625437d7371f", + "createdDate": 1738964731000, + "lastUpdatedDate": 1738964731000, + "lastExecutedDate": null, + "editable": false, + "apiVersion": "20200501" + } + ], + "_links": { + "next": null, + "previous": null + } +} diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..1317c19edd7 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -0,0 +1,529 @@ +# serializer version: 1 +# name: test_all_entities[c2c_arlo_pro_3_switch][binary_sensor.2nd_floor_hallway_motion-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.2nd_floor_hallway_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '2nd Floor Hallway motion', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][binary_sensor.2nd_floor_hallway_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': '2nd Floor Hallway motion', + }), + 'context': , + 'entity_id': 'binary_sensor.2nd_floor_hallway_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][binary_sensor.2nd_floor_hallway_sound-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.2nd_floor_hallway_sound', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '2nd Floor Hallway sound', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.sound', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][binary_sensor.2nd_floor_hallway_sound-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'sound', + 'friendly_name': '2nd Floor Hallway sound', + }), + 'context': , + 'entity_id': 'binary_sensor.2nd_floor_hallway_sound', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_contact-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.front_door_open_closed_sensor_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '.Front Door Open/Closed Sensor contact', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_contact-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': '.Front Door Open/Closed Sensor contact', + }), + 'context': , + 'entity_id': 'binary_sensor.front_door_open_closed_sensor_contact', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_contact-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.refrigerator_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator contact', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_contact-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Refrigerator contact', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_contact', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_motion-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.child_bedroom_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Child Bedroom motion', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'Child Bedroom motion', + }), + 'context': , + 'entity_id': 'binary_sensor.child_bedroom_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_presence-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.child_bedroom_presence', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Child Bedroom presence', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.presence', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_presence-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'presence', + 'friendly_name': 'Child Bedroom presence', + }), + 'context': , + 'entity_id': 'binary_sensor.child_bedroom_presence', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[iphone][binary_sensor.iphone_presence-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.iphone_presence', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'iPhone presence', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '184c67cc-69e2-44b6-8f73-55c963068ad9.presence', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[iphone][binary_sensor.iphone_presence-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'presence', + 'friendly_name': 'iPhone presence', + }), + 'context': , + 'entity_id': 'binary_sensor.iphone_presence', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_acceleration-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.deck_door_acceleration', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door acceleration', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.acceleration', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_acceleration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moving', + 'friendly_name': 'Deck Door acceleration', + }), + 'context': , + 'entity_id': 'binary_sensor.deck_door_acceleration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_contact-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.deck_door_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door contact', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_contact-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Deck Door contact', + }), + 'context': , + 'entity_id': 'binary_sensor.deck_door_contact', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[virtual_valve][binary_sensor.volvo_valve-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.volvo_valve', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'volvo valve', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3.valve', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[virtual_valve][binary_sensor.volvo_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'volvo valve', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_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': None, + 'entity_id': 'binary_sensor.asd_water', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'asd water', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116.water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'asd water', + }), + 'context': , + 'entity_id': 'binary_sensor.asd_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr new file mode 100644 index 00000000000..bd76637cfb7 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -0,0 +1,356 @@ +# serializer version: 1 +# name: test_all_entities[da_ac_rac_000001][climate.ac_office_granit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_modes': list([ + 'windFree', + ]), + 'swing_modes': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.ac_office_granit', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Office Granit', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_000001][climate.ac_office_granit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 25, + 'drlc_status_duration': 0, + 'drlc_status_level': -1, + 'drlc_status_override': False, + 'drlc_status_start': '1970-01-01T00:00:00Z', + 'fan_mode': 'low', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'friendly_name': 'AC Office Granit', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': None, + 'preset_modes': list([ + 'windFree', + ]), + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': None, + 'temperature': 25, + }), + 'context': , + 'entity_id': 'climate.ac_office_granit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][climate.aire_dormitorio_principal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_modes': list([ + 'windFree', + ]), + 'swing_modes': list([ + 'off', + 'vertical', + 'horizontal', + 'both', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.aire_dormitorio_principal', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_01001][climate.aire_dormitorio_principal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 27, + 'drlc_status_duration': 0, + 'drlc_status_level': 0, + 'drlc_status_override': False, + 'drlc_status_start': '1970-01-01T00:00:00Z', + 'fan_mode': 'high', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'friendly_name': 'Aire Dormitorio Principal', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': None, + 'preset_modes': list([ + 'windFree', + ]), + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'off', + 'vertical', + 'horizontal', + 'both', + ]), + 'temperature': 23, + }), + 'context': , + 'entity_id': 'climate.aire_dormitorio_principal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[ecobee_thermostat][climate.main_floor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 7.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.main_floor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Main Floor', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_thermostat][climate.main_floor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 32, + 'current_temperature': 21.7, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'friendly_name': 'Main Floor', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 7.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 21.7, + }), + 'context': , + 'entity_id': 'climate.main_floor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_all_entities[virtual_thermostat][climate.asd-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'on', + ]), + 'hvac_modes': list([ + ]), + 'max_temp': 35.0, + 'min_temp': 7.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.asd', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'asd', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[virtual_thermostat][climate.asd-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 4734.6, + 'fan_mode': 'followschedule', + 'fan_modes': list([ + 'on', + ]), + 'friendly_name': 'asd', + 'hvac_action': , + 'hvac_modes': list([ + ]), + 'max_temp': 35.0, + 'min_temp': 7.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.asd', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_cover.ambr b/tests/components/smartthings/snapshots/test_cover.ambr new file mode 100644 index 00000000000..6283e4fef04 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_cover.ambr @@ -0,0 +1,100 @@ +# serializer version: 1 +# name: test_all_entities[c2c_shade][cover.curtain_1a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.curtain_1a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain 1A', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '571af102-15db-4030-b76b-245a691f74a5', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_shade][cover.curtain_1a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'shade', + 'friendly_name': 'Curtain 1A', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.curtain_1a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][cover.microwave-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.microwave', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Microwave', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][cover.microwave-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Microwave', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.microwave', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_fan.ambr b/tests/components/smartthings/snapshots/test_fan.ambr new file mode 100644 index 00000000000..400ceef8390 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_fan.ambr @@ -0,0 +1,67 @@ +# serializer version: 1 +# name: test_all_entities[fake_fan][fan.fake_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.fake_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fake fan', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'f1af21a2-d5a1-437c-b10a-b34a87394b71', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fake_fan][fan.fake_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Fake fan', + 'percentage': 2000, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.fake_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr new file mode 100644 index 00000000000..546d99a967f --- /dev/null +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -0,0 +1,1024 @@ +# serializer version: 1 +# name: test_devices[aeotec_home_energy_meter_gen5] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'f0af21a2-d5a1-437c-b10a-b34a87394b71', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Aeotec Energy Monitor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[base_electric_meter] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '68e786a6-7f61-4c3a-9e13-70b803cf782b', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Aeon Energy Monitor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[c2c_arlo_pro_3_switch] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '10e06a70-ee7d-4832-85e9-a0a06a7a05bd', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': '2nd Floor Hallway', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[c2c_shade] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '571af102-15db-4030-b76b-245a691f74a5', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Curtain 1A', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[centralite] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'd0268a69-abfb-4c92-a646-61cec2e510ad', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Dimmer Debian', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[contact_sensor] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '2d9a892b-1c93-45a5-84cb-0e81889498c6', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': '.Front Door Open/Closed Sensor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ac_rac_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '96a5ef74-5832-a84b-f1f7-ca799957065d', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', + 'model_id': None, + 'name': 'AC Office Granit', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '0.1.0', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ac_rac_01001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '4ece486b-89db-f06a-d54d-748b676b4d8e', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'ARA-WW-TP1-22-COMMON|10229641|60010523001511014600083200800000', + 'model_id': None, + 'name': 'Aire Dormitorio Principal', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'ARA-WW-TP1-22-COMMON_11240702', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ks_microwave_0101x] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'MediaTek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '2bad3237-4886-e699-1b90-4a51a3d55c8a', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP2X_DA-KS-MICROWAVE-0101X|40436241|50040100011411000200000000000000', + 'model_id': None, + 'name': 'Microwave', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'AKS-WW-TP2-20-MICROWAVE-OTR_40230125', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ref_normal_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'MediaTek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '7db87911-7dce-1cf2-7119-b953432a2f09', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP2X_REF_20K|00115641|0004014D011411200103000020000000', + 'model_id': None, + 'name': 'Refrigerator', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'A-RFWW-TP2-21-COMMON_20220110', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_rvc_normal_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '3442dfc6-17c0-a65f-dae0-4c6e01786f44', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'powerbot_7000_17M|50016055|80010404011141000100000000000000', + 'model_id': None, + 'name': 'Robot vacuum', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_wm_dw_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'f36dc7ce-cac0-0667-dc14-a3704eb5e676', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_DW_A51_20_COMMON|30007242|40010201001311000101000000000000', + 'model_id': None, + 'name': 'Dishwasher', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_DW_A51_20_COMMON_30230714', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_wm_wd_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '02f7256e-8353-5bdd-547f-bd5b1647e01b', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000', + 'model_id': None, + 'name': 'Dryer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_WM_A51_20_COMMON_30230708', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_wm_wm_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'MediaTek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'f984b91d-f250-9d42-3436-33f09a422a47', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000', + 'model_id': None, + 'name': 'Washer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_WM_TP2_20_COMMON_30230804', + 'via_device_id': None, + }) +# --- +# name: test_devices[ecobee_sensor] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'd5dc3299-c266-41c7-bd08-f540aea54b89', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Child Bedroom', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[ecobee_thermostat] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Main Floor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[fake_fan] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'f1af21a2-d5a1-437c-b10a-b34a87394b71', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Fake fan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[ge_in_wall_smart_dimmer] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'aaedaf28-2ae0-4c1d-b57e-87f6a420c298', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Basement Exit Light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[hue_color_temperature_bulb] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '440063de-a200-40b5-8a6b-f3399eaa0370', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Bathroom spot', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[hue_rgbw_color_bulb] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'cb958955-b015-498c-9e62-fc0c51abd054', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Standing light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[iphone] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '184c67cc-69e2-44b6-8f73-55c963068ad9', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'iPhone', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[multipurpose_sensor] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '7d246592-93db-4d72-a10d-5a51793ece8c', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Deck Door', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[sensibo_airconditioner_1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'bf4b1167-48a3-4af7-9186-0900a678ffa5', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Office', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[smart_plug] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '550a1c72-65a0-4d55-b97b-75168e055398', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Arlo Beta Basestation', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[sonos_player] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'c85fced9-c474-4a47-93c2-037cc7829536', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Elliots Rum', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[vd_network_audio_002s] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '0d94e5db-8501-2355-eb4f-214163702cac', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'HW-Q990C', + 'model_id': None, + 'name': 'Soundbar Living', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'SAT-iMX8M23WWC-1010.5', + 'via_device_id': None, + }) +# --- +# name: test_devices[vd_stv_2017_k] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0-0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'UN49MU8000', + 'model_id': None, + 'name': '[TV] Samsung 8 Series (49)', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'T-KTMAKUC-1290.3', + 'via_device_id': None, + }) +# --- +# name: test_devices[virtual_thermostat] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '2894dc93-0f11-49cc-8a81-3a684cebebf6', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'asd', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[virtual_valve] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'volvo', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[virtual_water_sensor] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'a2a6018b-2663-4727-9d1d-8f56953b5116', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'asd', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[yale_push_button_deadbolt_lock] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'a9f587c5-5d8b-4273-8907-e7f609af5158', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Basement Door Lock', + '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/smartthings/snapshots/test_light.ambr b/tests/components/smartthings/snapshots/test_light.ambr new file mode 100644 index 00000000000..8e7f424f658 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_light.ambr @@ -0,0 +1,267 @@ +# serializer version: 1 +# name: test_all_entities[centralite][light.dimmer_debian-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.dimmer_debian', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dimmer Debian', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[centralite][light.dimmer_debian-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Dimmer Debian', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.dimmer_debian', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[ge_in_wall_smart_dimmer][light.basement_exit_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.basement_exit_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Basement Exit Light', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'aaedaf28-2ae0-4c1d-b57e-87f6a420c298', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ge_in_wall_smart_dimmer][light.basement_exit_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Basement Exit Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.basement_exit_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[hue_color_temperature_bulb][light.bathroom_spot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.bathroom_spot', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bathroom spot', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '440063de-a200-40b5-8a6b-f3399eaa0370', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[hue_color_temperature_bulb][light.bathroom_spot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 178, + 'color_mode': , + 'color_temp': 333, + 'color_temp_kelvin': 3000, + 'friendly_name': 'Bathroom spot', + 'hs_color': tuple( + 27.825, + 56.895, + ), + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'rgb_color': tuple( + 255, + 177, + 110, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.496, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.bathroom_spot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[hue_rgbw_color_bulb][light.standing_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.standing_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Standing light', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'cb958955-b015-498c-9e62-fc0c51abd054', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[hue_rgbw_color_bulb][light.standing_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Standing light', + 'hs_color': None, + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.standing_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_lock.ambr b/tests/components/smartthings/snapshots/test_lock.ambr new file mode 100644 index 00000000000..94370f8570b --- /dev/null +++ b/tests/components/smartthings/snapshots/test_lock.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_all_entities[yale_push_button_deadbolt_lock][lock.basement_door_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.basement_door_lock', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Basement Door Lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][lock.basement_door_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Basement Door Lock', + 'lock_state': 'locked', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.basement_door_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_scene.ambr b/tests/components/smartthings/snapshots/test_scene.ambr new file mode 100644 index 00000000000..fd9abc9fcca --- /dev/null +++ b/tests/components/smartthings/snapshots/test_scene.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_all_entities[scene.away-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'scene', + 'entity_category': None, + 'entity_id': 'scene.away', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Away', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '743b0f37-89b8-476c-aedf-eea8ad8cd29d', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[scene.away-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color': None, + 'friendly_name': 'Away', + 'icon': '203', + 'location_id': '88a3a314-f0c8-40b4-bb44-44ba06c9c42f', + }), + 'context': , + 'entity_id': 'scene.away', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[scene.home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'scene', + 'entity_category': None, + 'entity_id': 'scene.home', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Home', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f3341e8b-9b32-4509-af2e-4f7c952e98ba', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[scene.home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color': None, + 'friendly_name': 'Home', + 'icon': '204', + 'location_id': '88a3a314-f0c8-40b4-bb44-44ba06c9c42f', + }), + 'context': , + 'entity_id': 'scene.home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..92928b9606b --- /dev/null +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -0,0 +1,4857 @@ +# serializer version: 1 +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_meter-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.aeotec_energy_monitor_energy_meter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aeotec Energy Monitor Energy Meter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.energy', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aeotec Energy Monitor Energy Meter', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.aeotec_energy_monitor_energy_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19978.536', + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power_meter-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.aeotec_energy_monitor_power_meter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aeotec Energy Monitor Power Meter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Aeotec Energy Monitor Power Meter', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.aeotec_energy_monitor_power_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2859.743', + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage_measurement-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.aeotec_energy_monitor_voltage_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aeotec Energy Monitor Voltage Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.voltage', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Aeotec Energy Monitor Voltage Measurement', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.aeotec_energy_monitor_voltage_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy_meter-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.aeon_energy_monitor_energy_meter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aeon Energy Monitor Energy Meter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b.energy', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aeon Energy Monitor Energy Meter', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.aeon_energy_monitor_energy_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1930.362', + }) +# --- +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power_meter-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.aeon_energy_monitor_power_meter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aeon Energy Monitor Power Meter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b.power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Aeon Energy Monitor Power Meter', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.aeon_energy_monitor_power_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '938.3', + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_alarm-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': None, + 'entity_id': 'sensor.2nd_floor_hallway_alarm', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '2nd Floor Hallway Alarm', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '2nd Floor Hallway Alarm', + }), + 'context': , + 'entity_id': 'sensor.2nd_floor_hallway_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.2nd_floor_hallway_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '2nd Floor Hallway Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': '2nd Floor Hallway Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.2nd_floor_hallway_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[centralite][sensor.dimmer_debian_power_meter-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.dimmer_debian_power_meter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dimmer Debian Power Meter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad.power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[centralite][sensor.dimmer_debian_power_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Dimmer Debian Power Meter', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.dimmer_debian_power_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.front_door_open_closed_sensor_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '.Front Door Open/Closed Sensor Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': '.Front Door Open/Closed Sensor Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.front_door_open_closed_sensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature_measurement-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.front_door_open_closed_sensor_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '.Front Door Open/Closed Sensor Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': '.Front Door Open/Closed Sensor Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.front_door_open_closed_sensor_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_air_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': None, + 'entity_id': 'sensor.ac_office_granit_air_quality', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Office Granit Air Quality', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.airQuality', + 'unit_of_measurement': 'CAQI', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC Office Granit Air Quality', + 'state_class': , + 'unit_of_measurement': 'CAQI', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_deltaenergy-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.ac_office_granit_deltaenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit deltaEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_deltaenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit deltaEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_deltaenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.4', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_dust_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.ac_office_granit_dust_level', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Office Granit Dust Level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.dustLevel', + 'unit_of_measurement': 'μg/m^3', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_dust_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC Office Granit Dust Level', + 'state_class': , + 'unit_of_measurement': 'μg/m^3', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_dust_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_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.ac_office_granit_energy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2247.3', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energysaved-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.ac_office_granit_energysaved', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit energySaved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energysaved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit energySaved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_energysaved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_fine_dust_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.ac_office_granit_fine_dust_level', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Office Granit Fine Dust Level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.fineDustLevel', + 'unit_of_measurement': 'μg/m^3', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_fine_dust_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC Office Granit Fine Dust Level', + 'state_class': , + 'unit_of_measurement': 'μg/m^3', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_fine_dust_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_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.ac_office_granit_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'AC Office Granit power', + 'power_consumption_end': '2025-02-09T16:15:33Z', + 'power_consumption_start': '2025-02-09T15:45:29Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_powerenergy-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.ac_office_granit_powerenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit powerEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_powerenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit powerEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_powerenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_relative_humidity_measurement-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.ac_office_granit_relative_humidity_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit Relative Humidity Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_relative_humidity_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'AC Office Granit Relative Humidity Measurement', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_relative_humidity_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature_measurement-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.ac_office_granit_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'AC Office Granit Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_volume-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': None, + 'entity_id': 'sensor.ac_office_granit_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Office Granit Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC Office Granit Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_air_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': None, + 'entity_id': 'sensor.aire_dormitorio_principal_air_quality', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Air Quality', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.airQuality', + 'unit_of_measurement': 'CAQI', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aire Dormitorio Principal Air Quality', + 'state_class': , + 'unit_of_measurement': 'CAQI', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_deltaenergy-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.aire_dormitorio_principal_deltaenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal deltaEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_deltaenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal deltaEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_deltaenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_dust_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.aire_dormitorio_principal_dust_level', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Dust Level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.dustLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_dust_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aire Dormitorio Principal Dust Level', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_dust_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_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.aire_dormitorio_principal_energy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.836', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energysaved-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.aire_dormitorio_principal_energysaved', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal energySaved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energysaved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal energySaved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_energysaved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_fine_dust_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.aire_dormitorio_principal_fine_dust_level', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Fine Dust Level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.fineDustLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_fine_dust_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aire Dormitorio Principal Fine Dust Level', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_fine_dust_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_odor_sensor-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': None, + 'entity_id': 'sensor.aire_dormitorio_principal_odor_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Odor Sensor', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.odorLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_odor_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aire Dormitorio Principal Odor Sensor', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_odor_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_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.aire_dormitorio_principal_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Aire Dormitorio Principal power', + 'power_consumption_end': '2025-02-09T17:02:44Z', + 'power_consumption_start': '2025-02-09T16:08:15Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_powerenergy-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.aire_dormitorio_principal_powerenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal powerEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_powerenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal powerEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_powerenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_relative_humidity_measurement-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.aire_dormitorio_principal_relative_humidity_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Relative Humidity Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_relative_humidity_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Aire Dormitorio Principal Relative Humidity Measurement', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_relative_humidity_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature_measurement-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.aire_dormitorio_principal_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Aire Dormitorio Principal Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_volume-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': None, + 'entity_id': 'sensor.aire_dormitorio_principal_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aire Dormitorio Principal Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.microwave_oven_completion_time', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Microwave Oven Completion Time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Oven Completion Time', + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-08T21:13:36.184Z', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_job_state-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': None, + 'entity_id': 'sensor.microwave_oven_job_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Microwave Oven Job State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Oven Job State', + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ready', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_machine_state-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': None, + 'entity_id': 'sensor.microwave_oven_machine_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Microwave Oven Machine State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Oven Machine State', + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ready', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_mode-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.microwave_oven_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Microwave Oven Mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Oven Mode', + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Others', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_set_point-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': None, + 'entity_id': 'sensor.microwave_oven_set_point', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Microwave Oven Set Point', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenSetpoint', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_set_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Oven Set Point', + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_set_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature_measurement-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.microwave_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Microwave Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Microwave Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.microwave_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_deltaenergy-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.refrigerator_deltaenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator deltaEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_deltaenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator deltaEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_deltaenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.007', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_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.refrigerator_energy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1568.087', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energysaved-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.refrigerator_energysaved', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator energySaved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energysaved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator energySaved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_energysaved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_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.refrigerator_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Refrigerator power', + 'power_consumption_end': '2025-02-09T17:49:00Z', + 'power_consumption_start': '2025-02-09T17:38:01Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_powerenergy-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.refrigerator_powerenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator powerEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_powerenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator powerEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_powerenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0135559777781698', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature_measurement-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.refrigerator_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Temperature Measurement', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_thermostat_cooling_setpoint-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': None, + 'entity_id': 'sensor.refrigerator_thermostat_cooling_setpoint', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator Thermostat Cooling Setpoint', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.coolingSetpoint', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_thermostat_cooling_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Thermostat Cooling Setpoint', + }), + 'context': , + 'entity_id': 'sensor.refrigerator_thermostat_cooling_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Robot vacuum Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Robot vacuum Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_cleaning_mode-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.robot_vacuum_robot_cleaner_cleaning_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Robot vacuum Robot Cleaner Cleaning Mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerCleaningMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_cleaning_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum Robot Cleaner Cleaning Mode', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_robot_cleaner_cleaning_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_movement-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': None, + 'entity_id': 'sensor.robot_vacuum_robot_cleaner_movement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Robot vacuum Robot Cleaner Movement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerMovement', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_movement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum Robot Cleaner Movement', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_robot_cleaner_movement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_turbo_mode-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.robot_vacuum_robot_cleaner_turbo_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Robot vacuum Robot Cleaner Turbo Mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerTurboMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_turbo_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum Robot Cleaner Turbo Mode', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_robot_cleaner_turbo_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_deltaenergy-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.dishwasher_deltaenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dishwasher deltaEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_deltaenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher deltaEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_deltaenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_dishwasher_completion_time', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dishwasher Dishwasher Completion Time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Dishwasher Dishwasher Completion Time', + }), + 'context': , + 'entity_id': 'sensor.dishwasher_dishwasher_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-08T22:49:26+00:00', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_job_state-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': None, + 'entity_id': 'sensor.dishwasher_dishwasher_job_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dishwasher Dishwasher Job State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Dishwasher Job State', + }), + 'context': , + 'entity_id': 'sensor.dishwasher_dishwasher_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_machine_state-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': None, + 'entity_id': 'sensor.dishwasher_dishwasher_machine_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dishwasher Dishwasher Machine State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Dishwasher Machine State', + }), + 'context': , + 'entity_id': 'sensor.dishwasher_dishwasher_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_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.dishwasher_energy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dishwasher energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '101.6', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energysaved-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.dishwasher_energysaved', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dishwasher energySaved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energysaved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher energySaved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_energysaved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_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.dishwasher_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dishwasher power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Dishwasher power', + 'power_consumption_end': '2025-02-08T20:21:26Z', + 'power_consumption_start': '2025-02-08T20:21:21Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_powerenergy-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.dishwasher_powerenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dishwasher powerEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_powerenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher powerEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_powerenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_deltaenergy-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.dryer_deltaenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dryer deltaEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_deltaenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer deltaEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_deltaenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_dryer_completion_time', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dryer Dryer Completion Time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Dryer Dryer Completion Time', + }), + 'context': , + 'entity_id': 'sensor.dryer_dryer_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-08T19:25:10+00:00', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_job_state-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': None, + 'entity_id': 'sensor.dryer_dryer_job_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dryer Dryer Job State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Dryer Job State', + }), + 'context': , + 'entity_id': 'sensor.dryer_dryer_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_machine_state-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': None, + 'entity_id': 'sensor.dryer_dryer_machine_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dryer Dryer Machine State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Dryer Machine State', + }), + 'context': , + 'entity_id': 'sensor.dryer_dryer_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_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.dryer_energy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dryer energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4495.5', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energysaved-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.dryer_energysaved', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dryer energySaved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energysaved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer energySaved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_energysaved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_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.dryer_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dryer power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Dryer power', + 'power_consumption_end': '2025-02-08T18:10:11Z', + 'power_consumption_start': '2025-02-07T04:00:19Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_powerenergy-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.dryer_powerenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dryer powerEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_powerenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer powerEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_powerenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_deltaenergy-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.washer_deltaenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer deltaEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_deltaenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer deltaEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_deltaenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_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.washer_energy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '352.8', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energysaved-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.washer_energysaved', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer energySaved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energysaved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer energySaved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_energysaved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_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.washer_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Washer power', + 'power_consumption_end': '2025-02-07T03:09:45Z', + 'power_consumption_start': '2025-02-07T03:09:24Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_powerenergy-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.washer_powerenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer powerEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_powerenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer powerEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_powerenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_washer_completion_time', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer Washer Completion Time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Washer Washer Completion Time', + }), + 'context': , + 'entity_id': 'sensor.washer_washer_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-07T03:54:45+00:00', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_job_state-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': None, + 'entity_id': 'sensor.washer_washer_job_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Washer Washer Job State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.washerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Washer Job State', + }), + 'context': , + 'entity_id': 'sensor.washer_washer_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_machine_state-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': None, + 'entity_id': 'sensor.washer_washer_machine_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Washer Washer Machine State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Washer Machine State', + }), + 'context': , + 'entity_id': 'sensor.washer_washer_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature_measurement-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.child_bedroom_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Child Bedroom Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Child Bedroom Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.child_bedroom_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22', + }) +# --- +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_relative_humidity_measurement-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.main_floor_relative_humidity_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Main Floor Relative Humidity Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_relative_humidity_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Main Floor Relative Humidity Measurement', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.main_floor_relative_humidity_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature_measurement-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.main_floor_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Main Floor Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Main Floor Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.main_floor_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.deck_door_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Deck Door Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.deck_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature_measurement-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.deck_door_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Deck Door Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.deck_door_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.4', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_x_coordinate-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': None, + 'entity_id': 'sensor.deck_door_x_coordinate', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Deck Door X Coordinate', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c X Coordinate', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_x_coordinate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Deck Door X Coordinate', + }), + 'context': , + 'entity_id': 'sensor.deck_door_x_coordinate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_y_coordinate-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': None, + 'entity_id': 'sensor.deck_door_y_coordinate', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Deck Door Y Coordinate', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Y Coordinate', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_y_coordinate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Deck Door Y Coordinate', + }), + 'context': , + 'entity_id': 'sensor.deck_door_y_coordinate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_z_coordinate-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': None, + 'entity_id': 'sensor.deck_door_z_coordinate', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Deck Door Z Coordinate', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Z Coordinate', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_z_coordinate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Deck Door Z Coordinate', + }), + 'context': , + 'entity_id': 'sensor.deck_door_z_coordinate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1042', + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_air_conditioner_mode-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.office_air_conditioner_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Office Air Conditioner Mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.airConditionerMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_air_conditioner_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office Air Conditioner Mode', + }), + 'context': , + 'entity_id': 'sensor.office_air_conditioner_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_thermostat_cooling_setpoint-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': None, + 'entity_id': 'sensor.office_thermostat_cooling_setpoint', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Office Thermostat Cooling Setpoint', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_thermostat_cooling_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Office Thermostat Cooling Setpoint', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_thermostat_cooling_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-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': None, + 'entity_id': 'sensor.elliots_rum_media_playback_status', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Elliots Rum Media Playback Status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.playbackStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elliots Rum Media Playback Status', + }), + 'context': , + 'entity_id': 'sensor.elliots_rum_media_playback_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_all_entities[sonos_player][sensor.elliots_rum_volume-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': None, + 'entity_id': 'sensor.elliots_rum_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Elliots Rum Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sonos_player][sensor.elliots_rum_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elliots Rum Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.elliots_rum_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-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': None, + 'entity_id': 'sensor.soundbar_living_media_playback_status', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Soundbar Living Media Playback Status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.playbackStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Soundbar Living Media Playback Status', + }), + 'context': , + 'entity_id': 'sensor.soundbar_living_media_playback_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stopped', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_volume-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': None, + 'entity_id': 'sensor.soundbar_living_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Soundbar Living Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Soundbar Living Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.soundbar_living_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-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': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '[TV] Samsung 8 Series (49) Media Input Source', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.inputSource', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49) Media Input Source', + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'HDMI1', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-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': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '[TV] Samsung 8 Series (49) Media Playback Status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.playbackStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49) Media Playback Status', + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel-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': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '[TV] Samsung 8 Series (49) Tv Channel', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49) Tv Channel', + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel_name-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': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '[TV] Samsung 8 Series (49) Tv Channel Name', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannelName', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49) Tv Channel Name', + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_volume-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': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '[TV] Samsung 8 Series (49) Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49) Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13', + }) +# --- +# name: test_all_entities[virtual_thermostat][sensor.asd_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.asd_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'asd Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[virtual_thermostat][sensor.asd_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'asd Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.asd_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[virtual_thermostat][sensor.asd_temperature_measurement-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.asd_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'asd Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[virtual_thermostat][sensor.asd_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'asd Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.asd_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4734.552604985020', + }) +# --- +# name: test_all_entities[virtual_water_sensor][sensor.asd_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.asd_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'asd Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[virtual_water_sensor][sensor.asd_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'asd Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.asd_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][sensor.basement_door_lock_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.basement_door_lock_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Door Lock Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][sensor.basement_door_lock_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Basement Door Lock Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.basement_door_lock_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '86', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr new file mode 100644 index 00000000000..cf3245eed7d --- /dev/null +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -0,0 +1,471 @@ +# serializer version: 1 +# name: test_all_entities[c2c_arlo_pro_3_switch][switch.2nd_floor_hallway-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': None, + 'entity_id': 'switch.2nd_floor_hallway', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '2nd Floor Hallway', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][switch.2nd_floor_hallway-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '2nd Floor Hallway', + }), + 'context': , + 'entity_id': 'switch.2nd_floor_hallway', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][switch.microwave-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': None, + 'entity_id': 'switch.microwave', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Microwave', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][switch.microwave-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave', + }), + 'context': , + 'entity_id': 'switch.microwave', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][switch.robot_vacuum-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': None, + 'entity_id': 'switch.robot_vacuum', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Robot vacuum', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][switch.robot_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum', + }), + 'context': , + 'entity_id': 'switch.robot_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][switch.dishwasher-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': None, + 'entity_id': 'switch.dishwasher', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dishwasher', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][switch.dishwasher-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher', + }), + 'context': , + 'entity_id': 'switch.dishwasher', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][switch.dryer-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': None, + 'entity_id': 'switch.dryer', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dryer', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][switch.dryer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer', + }), + 'context': , + 'entity_id': 'switch.dryer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][switch.washer-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': None, + 'entity_id': 'switch.washer', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Washer', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][switch.washer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer', + }), + 'context': , + 'entity_id': 'switch.washer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][switch.office-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': None, + 'entity_id': 'switch.office', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Office', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][switch.office-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office', + }), + 'context': , + 'entity_id': 'switch.office', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[smart_plug][switch.arlo_beta_basestation-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': None, + 'entity_id': 'switch.arlo_beta_basestation', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Arlo Beta Basestation', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[smart_plug][switch.arlo_beta_basestation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Arlo Beta Basestation', + }), + 'context': , + 'entity_id': 'switch.arlo_beta_basestation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-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': None, + 'entity_id': 'switch.soundbar_living', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Soundbar Living', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Soundbar Living', + }), + 'context': , + 'entity_id': 'switch.soundbar_living', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][switch.tv_samsung_8_series_49-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': None, + 'entity_id': 'switch.tv_samsung_8_series_49', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '[TV] Samsung 8 Series (49)', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][switch.tv_samsung_8_series_49-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49)', + }), + 'context': , + 'entity_id': 'switch.tv_samsung_8_series_49', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index 52fd5d28aa7..eb473d3be04 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -1,139 +1,53 @@ -"""Test for the SmartThings binary_sensor platform. +"""Test for the SmartThings binary_sensor platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import ATTRIBUTES, CAPABILITIES, Attribute, Capability +from pysmartthings import Attribute, Capability +import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.binary_sensor import ( - DEVICE_CLASSES, - DOMAIN as BINARY_SENSOR_DOMAIN, -) -from homeassistant.components.smartthings import binary_sensor -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE, EntityCategory +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_mapping_integrity() -> None: - """Test ensures the map dicts have proper integrity.""" - # Ensure every CAPABILITY_TO_ATTRIB key is in CAPABILITIES - # Ensure every CAPABILITY_TO_ATTRIB value is in ATTRIB_TO_CLASS keys - for capability, attrib in binary_sensor.CAPABILITY_TO_ATTRIB.items(): - assert capability in CAPABILITIES, capability - assert attrib in ATTRIBUTES, attrib - assert attrib in binary_sensor.ATTRIB_TO_CLASS, attrib - # Ensure every ATTRIB_TO_CLASS value is in DEVICE_CLASSES - for attrib, device_class in binary_sensor.ATTRIB_TO_CLASS.items(): - assert attrib in ATTRIBUTES, attrib - assert device_class in DEVICE_CLASSES, device_class - - -async def test_entity_state(hass: HomeAssistant, device_factory) -> None: - """Tests the state attributes properly match the light types.""" - device = device_factory( - "Motion Sensor 1", [Capability.motion_sensor], {Attribute.motion: "inactive"} - ) - await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device]) - state = hass.states.get("binary_sensor.motion_sensor_1_motion") - assert state.state == "off" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} {Attribute.motion}" - - -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Motion Sensor 1", - [Capability.motion_sensor], - { - Attribute.motion: "inactive", - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("binary_sensor.motion_sensor_1_motion") - assert entry - assert entry.unique_id == f"{device.device_id}.{Attribute.motion}" - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the binary_sensor updates when receiving a signal.""" - # Arrange - device = device_factory( - "Motion Sensor 1", [Capability.motion_sensor], {Attribute.motion: "inactive"} - ) - await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device]) - device.status.apply_attribute_update( - "main", Capability.motion_sensor, Attribute.motion, "active" - ) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.motion_sensor_1_motion") - assert state is not None - assert state.state == "on" - - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the binary_sensor is removed when the config entry is unloaded.""" - # Arrange - device = device_factory( - "Motion Sensor 1", [Capability.motion_sensor], {Attribute.motion: "inactive"} - ) - config_entry = await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "binary_sensor") - # Assert - assert ( - hass.states.get("binary_sensor.motion_sensor_1_motion").state - == STATE_UNAVAILABLE + snapshot_smartthings_entities( + hass, entity_registry, snapshot, Platform.BINARY_SENSOR ) -async def test_entity_category( - hass: HomeAssistant, entity_registry: er.EntityRegistry, device_factory +@pytest.mark.parametrize("device_fixture", ["da_ref_normal_000001"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Tests the state attributes properly match the light types.""" - device1 = device_factory( - "Motion Sensor 1", [Capability.motion_sensor], {Attribute.motion: "inactive"} - ) - device2 = device_factory( - "Tamper Sensor 2", [Capability.tamper_alert], {Attribute.tamper: "inactive"} - ) - await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device1, device2]) + """Test state update.""" + await setup_integration(hass, mock_config_entry) - entry = entity_registry.async_get("binary_sensor.motion_sensor_1_motion") - assert entry - assert entry.entity_category is None + assert hass.states.get("binary_sensor.refrigerator_contact").state == STATE_OFF - entry = entity_registry.async_get("binary_sensor.tamper_sensor_2_tamper") - assert entry - assert entry.entity_category is EntityCategory.DIAGNOSTIC + await trigger_update( + hass, + devices, + "7db87911-7dce-1cf2-7119-b953432a2f09", + Capability.CONTACT_SENSOR, + Attribute.CONTACT, + "open", + ) + + assert hass.states.get("binary_sensor.refrigerator_contact").state == STATE_ON diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index d39ee2d6bed..380c4072860 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -1,12 +1,11 @@ -"""Test for the SmartThings climate platform. +"""Test for the SmartThings climate platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from typing import Any +from unittest.mock import AsyncMock, call -from pysmartthings import Attribute, Capability -from pysmartthings.device import Status +from pysmartthings import Attribute, Capability, Command, Status import pytest +from syrupy import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, @@ -26,748 +25,835 @@ from homeassistant.components.climate import ( SERVICE_SET_PRESET_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, - ClimateEntityFeature, + SWING_HORIZONTAL, + SWING_OFF, HVACAction, HVACMode, ) -from homeassistant.components.smartthings import climate -from homeassistant.components.smartthings.const import DOMAIN +from homeassistant.components.smartthings.const import MAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import ( + set_attribute_value, + setup_integration, + snapshot_smartthings_entities, + trigger_update, +) + +from tests.common import MockConfigEntry -@pytest.fixture(name="legacy_thermostat") -def legacy_thermostat_fixture(device_factory): - """Fixture returns a legacy thermostat.""" - device = device_factory( - "Legacy Thermostat", - capabilities=[Capability.thermostat], - status={ - Attribute.cooling_setpoint: 74, - Attribute.heating_setpoint: 68, - Attribute.thermostat_fan_mode: "auto", - Attribute.supported_thermostat_fan_modes: ["auto", "on"], - Attribute.thermostat_mode: "auto", - Attribute.supported_thermostat_modes: climate.MODE_TO_STATE.keys(), - Attribute.thermostat_operating_state: "idle", - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="basic_thermostat") -def basic_thermostat_fixture(device_factory): - """Fixture returns a basic thermostat.""" - device = device_factory( - "Basic Thermostat", - capabilities=[ - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - ], - status={ - Attribute.cooling_setpoint: 74, - Attribute.heating_setpoint: 68, - Attribute.thermostat_mode: "off", - Attribute.supported_thermostat_modes: ["off", "auto", "heat", "cool"], - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="minimal_thermostat") -def minimal_thermostat_fixture(device_factory): - """Fixture returns a minimal thermostat without cooling.""" - device = device_factory( - "Minimal Thermostat", - capabilities=[ - Capability.temperature_measurement, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - ], - status={ - Attribute.heating_setpoint: 68, - Attribute.thermostat_mode: "off", - Attribute.supported_thermostat_modes: ["off", "heat"], - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="thermostat") -def thermostat_fixture(device_factory): - """Fixture returns a fully-featured thermostat.""" - device = device_factory( - "Thermostat", - capabilities=[ - Capability.temperature_measurement, - Capability.relative_humidity_measurement, - Capability.thermostat_cooling_setpoint, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - Capability.thermostat_operating_state, - Capability.thermostat_fan_mode, - ], - status={ - Attribute.cooling_setpoint: 74, - Attribute.heating_setpoint: 68, - Attribute.thermostat_fan_mode: "on", - Attribute.supported_thermostat_fan_modes: ["auto", "on"], - Attribute.thermostat_mode: "heat", - Attribute.supported_thermostat_modes: [ - "auto", - "heat", - "cool", - "off", - "eco", - ], - Attribute.thermostat_operating_state: "idle", - Attribute.humidity: 34, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="buggy_thermostat") -def buggy_thermostat_fixture(device_factory): - """Fixture returns a buggy thermostat.""" - device = device_factory( - "Buggy Thermostat", - capabilities=[ - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - ], - status={ - Attribute.thermostat_mode: "heating", - Attribute.cooling_setpoint: 74, - Attribute.heating_setpoint: 68, - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="air_conditioner") -def air_conditioner_fixture(device_factory): - """Fixture returns a air conditioner.""" - device = device_factory( - "Air Conditioner", - capabilities=[ - Capability.air_conditioner_mode, - Capability.demand_response_load_control, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - Capability.fan_oscillation_mode, - ], - status={ - Attribute.air_conditioner_mode: "auto", - Attribute.supported_ac_modes: [ - "cool", - "dry", - "wind", - "auto", - "heat", - "fanOnly", - ], - Attribute.drlc_status: { - "duration": 0, - "drlcLevel": -1, - "start": "1970-01-01T00:00:00Z", - "override": False, - }, - Attribute.fan_mode: "medium", - Attribute.supported_ac_fan_modes: [ - "auto", - "low", - "medium", - "high", - "turbo", - ], - Attribute.switch: "on", - Attribute.cooling_setpoint: 23, - "supportedAcOptionalMode": ["windFree"], - Attribute.supported_fan_oscillation_modes: [ - "all", - "horizontal", - "vertical", - "fixed", - ], - Attribute.fan_oscillation_mode: "vertical", - }, - ) - device.status.attributes[Attribute.temperature] = Status(24, "C", None) - return device - - -@pytest.fixture(name="air_conditioner_windfree") -def air_conditioner_windfree_fixture(device_factory): - """Fixture returns a air conditioner.""" - device = device_factory( - "Air Conditioner", - capabilities=[ - Capability.air_conditioner_mode, - Capability.demand_response_load_control, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - Capability.fan_oscillation_mode, - ], - status={ - Attribute.air_conditioner_mode: "auto", - Attribute.supported_ac_modes: [ - "cool", - "dry", - "wind", - "auto", - "heat", - "wind", - ], - Attribute.drlc_status: { - "duration": 0, - "drlcLevel": -1, - "start": "1970-01-01T00:00:00Z", - "override": False, - }, - Attribute.fan_mode: "medium", - Attribute.supported_ac_fan_modes: [ - "auto", - "low", - "medium", - "high", - "turbo", - ], - Attribute.switch: "on", - Attribute.cooling_setpoint: 23, - "supportedAcOptionalMode": ["windFree"], - Attribute.supported_fan_oscillation_modes: [ - "all", - "horizontal", - "vertical", - "fixed", - ], - Attribute.fan_oscillation_mode: "vertical", - }, - ) - device.status.attributes[Attribute.temperature] = Status(24, "C", None) - return device - - -async def test_legacy_thermostat_entity_state( - hass: HomeAssistant, legacy_thermostat +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[legacy_thermostat]) - state = hass.states.get("climate.legacy_thermostat") - assert state.state == HVACMode.HEAT_COOL - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.AUTO, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.OFF, - ] - assert state.attributes[ATTR_FAN_MODE] == "auto" - assert state.attributes[ATTR_FAN_MODES] == ["auto", "on"] - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20 # celsius - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 23.3 # celsius - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.CLIMATE) -async def test_basic_thermostat_entity_state( - hass: HomeAssistant, basic_thermostat +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_fan_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[basic_thermostat]) - state = hass.states.get("climate.basic_thermostat") - assert state.state == HVACMode.OFF - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert ATTR_HVAC_ACTION not in state.attributes - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.OFF, - ] - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius + """Test climate set fan mode.""" + await setup_integration(hass, mock_config_entry) - -async def test_minimal_thermostat_entity_state( - hass: HomeAssistant, minimal_thermostat -) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[minimal_thermostat]) - state = hass.states.get("climate.minimal_thermostat") - assert state.state == HVACMode.OFF - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert ATTR_HVAC_ACTION not in state.attributes - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.HEAT, - HVACMode.OFF, - ] - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius - - -async def test_thermostat_entity_state(hass: HomeAssistant, thermostat) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) - state = hass.states.get("climate.thermostat") - assert state.state == HVACMode.HEAT - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.AUTO, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.OFF, - ] - assert state.attributes[ATTR_FAN_MODE] == "on" - assert state.attributes[ATTR_FAN_MODES] == ["auto", "on"] - assert state.attributes[ATTR_TEMPERATURE] == 20 # celsius - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius - assert state.attributes[ATTR_CURRENT_HUMIDITY] == 34 - - -async def test_buggy_thermostat_entity_state( - hass: HomeAssistant, buggy_thermostat -) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[buggy_thermostat]) - state = hass.states.get("climate.buggy_thermostat") - assert state.state == STATE_UNKNOWN - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert state.state is STATE_UNKNOWN - assert state.attributes[ATTR_TEMPERATURE] is None - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius - assert state.attributes[ATTR_HVAC_MODES] == [] - - -async def test_buggy_thermostat_invalid_mode( - hass: HomeAssistant, buggy_thermostat -) -> None: - """Tests when an invalid operation mode is included.""" - buggy_thermostat.status.update_attribute_value( - Attribute.supported_thermostat_modes, ["heat", "emergency heat", "other"] - ) - await setup_platform(hass, CLIMATE_DOMAIN, devices=[buggy_thermostat]) - state = hass.states.get("climate.buggy_thermostat") - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] - - -async def test_air_conditioner_entity_state( - hass: HomeAssistant, air_conditioner -) -> None: - """Tests when an invalid operation mode is included.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.HEAT_COOL - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.SWING_MODE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.COOL, - HVACMode.DRY, - HVACMode.FAN_ONLY, - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.OFF, - ] - assert state.attributes[ATTR_FAN_MODE] == "medium" - assert sorted(state.attributes[ATTR_FAN_MODES]) == [ - "auto", - "high", - "low", - "medium", - "turbo", - ] - assert state.attributes[ATTR_TEMPERATURE] == 23 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 24 - assert state.attributes["drlc_status_duration"] == 0 - assert state.attributes["drlc_status_level"] == -1 - assert state.attributes["drlc_status_start"] == "1970-01-01T00:00:00Z" - assert state.attributes["drlc_status_override"] is False - - -async def test_set_fan_mode(hass: HomeAssistant, thermostat, air_conditioner) -> None: - """Test the fan mode is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat, air_conditioner]) - entity_ids = ["climate.thermostat", "climate.air_conditioner"] await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_FAN_MODE: "auto"}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_FAN_MODE: "auto"}, blocking=True, ) - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state.attributes[ATTR_FAN_MODE] == "auto", entity_id + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_FAN_MODE, + Command.SET_FAN_MODE, + MAIN, + argument="auto", + ) -async def test_set_hvac_mode(hass: HomeAssistant, thermostat, air_conditioner) -> None: - """Test the hvac mode is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat, air_conditioner]) - entity_ids = ["climate.thermostat", "climate.air_conditioner"] +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_hvac_mode_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC HVAC mode to off.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_HVAC_MODE: HVACMode.COOL}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state.state == HVACMode.COOL, entity_id - - -async def test_ac_set_hvac_mode_from_off(hass: HomeAssistant, air_conditioner) -> None: - """Test setting HVAC mode when the unit is off.""" - air_conditioner.status.update_attribute_value( - Attribute.air_conditioner_mode, "heat" + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.OFF, + MAIN, ) - air_conditioner.status.update_attribute_value(Attribute.switch, "off") - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.OFF + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +@pytest.mark.parametrize( + ("hvac_mode", "argument"), + [ + (HVACMode.HEAT_COOL, "auto"), + (HVACMode.COOL, "cool"), + (HVACMode.DRY, "dry"), + (HVACMode.HEAT, "heat"), + (HVACMode.FAN_ONLY, "fanOnly"), + ], +) +async def test_ac_set_hvac_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + hvac_mode: HVACMode, + argument: str, +) -> None: + """Test setting AC HVAC mode.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["auto", "cool", "dry", "heat", "fanOnly"], + ) + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument=argument, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_hvac_mode_turns_on( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC HVAC mode turns on the device if it is off.""" + + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { - ATTR_ENTITY_ID: "climate.air_conditioner", + ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_HVAC_MODE: HVACMode.HEAT_COOL, }, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.HEAT_COOL - - -async def test_ac_set_hvac_mode_off(hass: HomeAssistant, air_conditioner) -> None: - """Test the AC HVAC mode can be turned off set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state != HVACMode.OFF - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.air_conditioner", ATTR_HVAC_MODE: HVACMode.OFF}, - blocking=True, - ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.OFF + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.ON, + MAIN, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="auto", + ), + ] +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_ac_set_hvac_mode_wind( - hass: HomeAssistant, air_conditioner_windfree + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the AC HVAC mode to fan only as wind mode for supported models.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner_windfree]) - state = hass.states.get("climate.air_conditioner") - assert state.state != HVACMode.OFF + """Test setting AC HVAC mode to wind if the device supports it.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["auto", "cool", "dry", "heat", "wind"], + ) + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.air_conditioner", ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.FAN_ONLY + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="wind", + ) -async def test_set_temperature_heat_mode(hass: HomeAssistant, thermostat) -> None: - """Test the temperature is set successfully when in heat mode.""" - thermostat.status.thermostat_mode = "heat" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_temperature( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC temperature.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.thermostat", ATTR_TEMPERATURE: 21}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_TEMPERATURE: 23}, blocking=True, ) - state = hass.states.get("climate.thermostat") - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 21 - assert thermostat.status.heating_setpoint == 69.8 - - -async def test_set_temperature_cool_mode(hass: HomeAssistant, thermostat) -> None: - """Test the temperature is set successfully when in cool mode.""" - thermostat.status.thermostat_mode = "cool" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.thermostat", ATTR_TEMPERATURE: 21}, - blocking=True, + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=23, ) - state = hass.states.get("climate.thermostat") - assert state.attributes[ATTR_TEMPERATURE] == 21 -async def test_set_temperature(hass: HomeAssistant, thermostat) -> None: - """Test the temperature is set successfully.""" - thermostat.status.thermostat_mode = "auto" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_temperature_and_hvac_mode_while_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC temperature and HVAC mode while off.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.thermostat", - ATTR_TARGET_TEMP_HIGH: 25.5, - ATTR_TARGET_TEMP_LOW: 22.2, + ATTR_ENTITY_ID: "climate.ac_office_granit", + ATTR_TEMPERATURE: 23, + ATTR_HVAC_MODE: HVACMode.HEAT_COOL, }, blocking=True, ) - state = hass.states.get("climate.thermostat") - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 25.5 - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 22.2 + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.ON, + MAIN, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=23.0, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.ON, + MAIN, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="auto", + ), + ] -async def test_set_temperature_ac(hass: HomeAssistant, air_conditioner) -> None: - """Test the temperature is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.air_conditioner", ATTR_TEMPERATURE: 27}, - blocking=True, - ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_TEMPERATURE] == 27 - - -async def test_set_temperature_ac_with_mode( - hass: HomeAssistant, air_conditioner +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_temperature_and_hvac_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the temperature is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) + """Test setting AC temperature and HVAC mode.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.air_conditioner", - ATTR_TEMPERATURE: 27, - ATTR_HVAC_MODE: HVACMode.COOL, + ATTR_ENTITY_ID: "climate.ac_office_granit", + ATTR_TEMPERATURE: 23, + ATTR_HVAC_MODE: HVACMode.HEAT_COOL, }, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_TEMPERATURE] == 27 - assert state.state == HVACMode.COOL + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=23.0, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="auto", + ), + ] -async def test_set_temperature_ac_with_mode_from_off( - hass: HomeAssistant, air_conditioner +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_temperature_and_hvac_mode_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the temp and mode is set successfully when the unit is off.""" - air_conditioner.status.update_attribute_value( - Attribute.air_conditioner_mode, "heat" - ) - air_conditioner.status.update_attribute_value(Attribute.switch, "off") - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - assert hass.states.get("climate.air_conditioner").state == HVACMode.OFF + """Test setting AC temperature and HVAC mode OFF.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.air_conditioner", - ATTR_TEMPERATURE: 27, - ATTR_HVAC_MODE: HVACMode.COOL, - }, - blocking=True, - ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_TEMPERATURE] == 27 - assert state.state == HVACMode.COOL - - -async def test_set_temperature_ac_with_mode_to_off( - hass: HomeAssistant, air_conditioner -) -> None: - """Test the temp and mode is set successfully to turn off the unit.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - assert hass.states.get("climate.air_conditioner").state != HVACMode.OFF - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: "climate.air_conditioner", - ATTR_TEMPERATURE: 27, + ATTR_ENTITY_ID: "climate.ac_office_granit", + ATTR_TEMPERATURE: 23, ATTR_HVAC_MODE: HVACMode.OFF, }, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_TEMPERATURE] == 27 - assert state.state == HVACMode.OFF + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.OFF, + MAIN, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=23.0, + ), + ] -async def test_set_temperature_with_mode(hass: HomeAssistant, thermostat) -> None: - """Test the temperature and mode is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: "climate.thermostat", - ATTR_TARGET_TEMP_HIGH: 25.5, - ATTR_TARGET_TEMP_LOW: 22.2, - ATTR_HVAC_MODE: HVACMode.HEAT_COOL, - }, - blocking=True, - ) - state = hass.states.get("climate.thermostat") - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 25.5 - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 22.2 - assert state.state == HVACMode.HEAT_COOL - - -async def test_set_turn_off(hass: HomeAssistant, air_conditioner) -> None: - """Test the a/c is turned off successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.HEAT_COOL - await hass.services.async_call( - CLIMATE_DOMAIN, SERVICE_TURN_OFF, {"entity_id": "all"}, blocking=True - ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.OFF - - -async def test_set_turn_on(hass: HomeAssistant, air_conditioner) -> None: - """Test the a/c is turned on successfully.""" - air_conditioner.status.update_attribute_value(Attribute.switch, "off") - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.OFF - await hass.services.async_call( - CLIMATE_DOMAIN, SERVICE_TURN_ON, {"entity_id": "all"}, blocking=True - ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.HEAT_COOL - - -async def test_entity_and_device_attributes( +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +@pytest.mark.parametrize( + ("service", "command"), + [ + (SERVICE_TURN_ON, Command.ON), + (SERVICE_TURN_OFF, Command.OFF), + ], +) +async def test_ac_toggle_power( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - thermostat, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + command: Command, ) -> None: - """Test the attributes of the entries are correct.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) + """Test toggling AC power.""" + await setup_integration(hass, mock_config_entry) - entry = entity_registry.async_get("climate.thermostat") - assert entry - assert entry.unique_id == thermostat.device_id - - entry = device_registry.async_get_device( - identifiers={(DOMAIN, thermostat.device_id)} - ) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, thermostat.device_id)} - assert entry.name == thermostat.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - - -async def test_set_windfree_off(hass: HomeAssistant, air_conditioner) -> None: - """Test if the windfree preset can be turned on and is turned off when fan mode is set.""" - entity_ids = ["climate.air_conditioner"] - air_conditioner.status.update_attribute_value(Attribute.switch, "on") - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) await hass.services.async_call( CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_PRESET_MODE: "windFree"}, + service, + {ATTR_ENTITY_ID: "climate.ac_office_granit"}, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_PRESET_MODE] == "windFree" - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_FAN_MODE: "low"}, - blocking=True, + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + command, + MAIN, ) - state = hass.states.get("climate.air_conditioner") - assert not state.attributes[ATTR_PRESET_MODE] -async def test_set_swing_mode(hass: HomeAssistant, air_conditioner) -> None: - """Test the fan swing is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - entity_ids = ["climate.air_conditioner"] +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_swing_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test climate set swing mode.""" + set_attribute_value( + devices, + Capability.FAN_OSCILLATION_MODE, + Attribute.SUPPORTED_FAN_OSCILLATION_MODES, + ["fixed"], + ) + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_SWING_MODE: "vertical"}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_SWING_MODE: SWING_OFF}, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_SWING_MODE] == "vertical" + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.FAN_OSCILLATION_MODE, + Command.SET_FAN_OSCILLATION_MODE, + MAIN, + argument="fixed", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_preset_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test climate set preset mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_PRESET_MODE: "windFree"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Command.SET_AC_OPTIONAL_MODE, + MAIN, + argument="windFree", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("climate.ac_office_granit").state == HVACMode.OFF + + await trigger_update( + hass, + devices, + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Attribute.SWITCH, + "on", + ) + + assert hass.states.get("climate.ac_office_granit").state == HVACMode.HEAT + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +@pytest.mark.parametrize( + ( + "capability", + "attribute", + "value", + "state_attribute", + "original_value", + "expected_value", + ), + [ + ( + Capability.TEMPERATURE_MEASUREMENT, + Attribute.TEMPERATURE, + 20, + ATTR_CURRENT_TEMPERATURE, + 25, + 20, + ), + ( + Capability.AIR_CONDITIONER_FAN_MODE, + Attribute.FAN_MODE, + "auto", + ATTR_FAN_MODE, + "low", + "auto", + ), + ( + Capability.AIR_CONDITIONER_FAN_MODE, + Attribute.SUPPORTED_AC_FAN_MODES, + ["low", "auto"], + ATTR_FAN_MODES, + ["auto", "low", "medium", "high", "turbo"], + ["low", "auto"], + ), + ( + Capability.THERMOSTAT_COOLING_SETPOINT, + Attribute.COOLING_SETPOINT, + 23, + ATTR_TEMPERATURE, + 25, + 23, + ), + ( + Capability.FAN_OSCILLATION_MODE, + Attribute.FAN_OSCILLATION_MODE, + "horizontal", + ATTR_SWING_MODE, + SWING_OFF, + SWING_HORIZONTAL, + ), + ( + Capability.FAN_OSCILLATION_MODE, + Attribute.FAN_OSCILLATION_MODE, + "direct", + ATTR_SWING_MODE, + SWING_OFF, + SWING_OFF, + ), + ], + ids=[ + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_TEMPERATURE, + ATTR_SWING_MODE, + f"{ATTR_SWING_MODE}_off", + ], +) +async def test_ac_state_attributes_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + capability: Capability, + attribute: Attribute, + value: Any, + state_attribute: str, + original_value: Any, + expected_value: Any, +) -> None: + """Test state attributes update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("climate.ac_office_granit").attributes[state_attribute] + == original_value + ) + + await trigger_update( + hass, + devices, + "96a5ef74-5832-a84b-f1f7-ca799957065d", + capability, + attribute, + value, + ) + + assert ( + hass.states.get("climate.ac_office_granit").attributes[state_attribute] + == expected_value + ) + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +async def test_thermostat_set_fan_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test thermostat set fan mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.asd", ATTR_FAN_MODE: "on"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_FAN_MODE, + Command.SET_THERMOSTAT_FAN_MODE, + MAIN, + argument="on", + ) + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +async def test_thermostat_set_hvac_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test thermostat set HVAC mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.asd", ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_MODE, + Command.SET_THERMOSTAT_MODE, + MAIN, + argument="auto", + ) + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +@pytest.mark.parametrize( + ("state", "data", "calls"), + [ + ( + "auto", + {ATTR_TARGET_TEMP_LOW: 15, ATTR_TARGET_TEMP_HIGH: 23}, + [ + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_HEATING_SETPOINT, + Command.SET_HEATING_SETPOINT, + MAIN, + argument=59.0, + ), + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=73.4, + ), + ], + ), + ( + "cool", + {ATTR_TEMPERATURE: 15}, + [ + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=59.0, + ) + ], + ), + ( + "heat", + {ATTR_TEMPERATURE: 23}, + [ + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_HEATING_SETPOINT, + Command.SET_HEATING_SETPOINT, + MAIN, + argument=73.4, + ) + ], + ), + ( + "heat", + {ATTR_TEMPERATURE: 23, ATTR_HVAC_MODE: HVACMode.COOL}, + [ + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_MODE, + Command.SET_THERMOSTAT_MODE, + MAIN, + argument="cool", + ), + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=73.4, + ), + ], + ), + ], +) +async def test_thermostat_set_temperature( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + state: str, + data: dict[str, Any], + calls: list[call], +) -> None: + """Test thermostat set temperature.""" + set_attribute_value( + devices, Capability.THERMOSTAT_MODE, Attribute.THERMOSTAT_MODE, state + ) + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.asd"} | data, + blocking=True, + ) + assert devices.execute_device_command.mock_calls == calls + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +async def test_humidity( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test humidity extra state attribute.""" + devices.get_device_status.return_value[MAIN][ + Capability.RELATIVE_HUMIDITY_MEASUREMENT + ] = {Attribute.HUMIDITY: Status(50)} + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.asd") + assert state + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 50 + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +async def test_updating_humidity( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test updating humidity extra state attribute.""" + devices.get_device_status.return_value[MAIN][ + Capability.RELATIVE_HUMIDITY_MEASUREMENT + ] = {Attribute.HUMIDITY: Status(50)} + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.asd") + assert state + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 50 + + await trigger_update( + hass, + devices, + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.RELATIVE_HUMIDITY_MEASUREMENT, + Attribute.HUMIDITY, + 40, + ) + + assert hass.states.get("climate.asd").attributes[ATTR_CURRENT_HUMIDITY] == 40 + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +@pytest.mark.parametrize( + ( + "capability", + "attribute", + "value", + "state_attribute", + "original_value", + "expected_value", + ), + [ + ( + Capability.TEMPERATURE_MEASUREMENT, + Attribute.TEMPERATURE, + 20, + ATTR_CURRENT_TEMPERATURE, + 4734.6, + -6.7, + ), + ( + Capability.THERMOSTAT_FAN_MODE, + Attribute.THERMOSTAT_FAN_MODE, + "auto", + ATTR_FAN_MODE, + "followschedule", + "auto", + ), + ( + Capability.THERMOSTAT_FAN_MODE, + Attribute.SUPPORTED_THERMOSTAT_FAN_MODES, + ["auto", "circulate"], + ATTR_FAN_MODES, + ["on"], + ["auto", "circulate"], + ), + ( + Capability.THERMOSTAT_OPERATING_STATE, + Attribute.THERMOSTAT_OPERATING_STATE, + "fan only", + ATTR_HVAC_ACTION, + HVACAction.COOLING, + HVACAction.FAN, + ), + ( + Capability.THERMOSTAT_MODE, + Attribute.SUPPORTED_THERMOSTAT_MODES, + ["coolClean", "dryClean"], + ATTR_HVAC_MODES, + [], + [HVACMode.COOL, HVACMode.DRY], + ), + ], + ids=[ + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODES, + ], +) +async def test_thermostat_state_attributes_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + capability: Capability, + attribute: Attribute, + value: Any, + state_attribute: str, + original_value: Any, + expected_value: Any, +) -> None: + """Test state attributes update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("climate.asd").attributes[state_attribute] == original_value + + await trigger_update( + hass, + devices, + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + capability, + attribute, + value, + ) + + assert hass.states.get("climate.asd").attributes[state_attribute] == expected_value diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 05ddc3a71de..647e0ea5284 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -1,813 +1,436 @@ """Tests for the SmartThings config flow module.""" from http import HTTPStatus -from unittest.mock import AsyncMock, Mock, patch -from uuid import uuid4 +from unittest.mock import AsyncMock -from aiohttp import ClientResponseError -from pysmartthings import APIResponseError -from pysmartthings.installedapp import format_install_url +import pytest -from homeassistant import config_entries -from homeassistant.components.smartthings import smartapp +from homeassistant.components.smartthings import OLD_DATA from homeassistant.components.smartthings.const import ( - CONF_APP_ID, CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, CONF_REFRESH_TOKEN, DOMAIN, ) -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_TOKEN, +) from homeassistant.core import HomeAssistant -from homeassistant.core_config import async_process_ha_core_config from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator -async def test_import_shows_user_step(hass: HomeAssistant) -> None: - """Test import source shows the user form.""" - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - -async def test_entry_created( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, ) -> None: - """Test local webhook, new app, install event creates entry.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app.return_value = (app, app_oauth_client) - smartthings_mock.locations.return_value = [location] - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - - # Webhook confirmation shown + """Check a full flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_install(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret - assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id - assert result["title"] == location.name - entry = next( - (entry for entry in hass.config_entries.async_entries(DOMAIN)), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id + DOMAIN, context={"source": SOURCE_USER} ) - -async def test_entry_created_from_update_event( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock -) -> None: - """Test local webhook, new app, update event creates entry.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app.return_value = (app, app_oauth_client) - smartthings_mock.locations.return_value = [location] - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_update(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret - assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id - assert result["title"] == location.name - entry = next( - (entry for entry in hass.config_entries.async_entries(DOMAIN)), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id - ) - - -async def test_entry_created_existing_app_new_oauth_client( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock -) -> None: - """Test entry is created with an existing app and generation of a new oauth client.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [app] - smartthings_mock.generate_app_oauth.return_value = app_oauth_client - smartthings_mock.locations.return_value = [location] - smartthings_mock.create_app = AsyncMock(return_value=(app, app_oauth_client)) - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_install(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret - assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id - assert result["title"] == location.name - entry = next( - (entry for entry in hass.config_entries.async_entries(DOMAIN)), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id - ) - - -async def test_entry_created_existing_app_copies_oauth_client( - hass: HomeAssistant, app, location, smartthings_mock -) -> None: - """Test entry is created with an existing app and copies the oauth client from another entry.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - oauth_client_id = str(uuid4()) - oauth_client_secret = str(uuid4()) - smartthings_mock.apps.return_value = [app] - smartthings_mock.locations.return_value = [location] - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_APP_ID: app.app_id, - CONF_CLIENT_ID: oauth_client_id, - CONF_CLIENT_SECRET: oauth_client_secret, - CONF_LOCATION_ID: str(uuid4()), - CONF_INSTALLED_APP_ID: str(uuid4()), - CONF_ACCESS_TOKEN: token, - }, - ) - entry.add_to_hass(hass) - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - # Assert access token is defaulted to an existing entry for convenience. - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_install(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == oauth_client_secret - assert result["data"][CONF_CLIENT_ID] == oauth_client_id - assert result["title"] == location.name - entry = next( - ( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.data[CONF_INSTALLED_APP_ID] == installed_app_id - ), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id - ) - - -async def test_entry_created_with_cloudhook( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock -) -> None: - """Test cloud, new app, install event creates entry.""" - hass.config.components.add("cloud") - # Unload the endpoint so we can reload it under the cloud. - await smartapp.unload_smartapp_endpoint(hass) - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app = AsyncMock(return_value=(app, app_oauth_client)) - smartthings_mock.locations = AsyncMock(return_value=[location]) - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - - with ( - patch.object( - smartapp.cloud, - "async_active_subscription", - Mock(return_value=True), - ), - patch.object( - smartapp.cloud, - "async_create_cloudhook", - AsyncMock(return_value="http://cloud.test"), - ) as mock_create_cloudhook, - ): - await smartapp.setup_smartapp_endpoint(hass, True) - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - # One is done by app fixture, one done by new config entry - assert mock_create_cloudhook.call_count == 2 - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_install(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret - assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id - assert result["title"] == location.name - entry = next( - (entry for entry in hass.config_entries.async_entries(DOMAIN)), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id - ) - - -async def test_invalid_webhook_aborts(hass: HomeAssistant) -> None: - """Test flow aborts if webhook is invalid.""" - # Webhook confirmation shown - await async_process_ha_core_config( + state = config_entry_oauth2_flow._encode_jwt( hass, - {"external_url": "http://example.local:8123"}, - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "invalid_webhook_url" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - assert "component_url" in result["description_placeholders"] - - -async def test_invalid_token_shows_error(hass: HomeAssistant) -> None: - """Test an error is shown for invalid token formats.""" - token = "123456789" - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {CONF_ACCESS_TOKEN: "token_invalid_format"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_unauthorized_token_shows_error( - hass: HomeAssistant, smartthings_mock -) -> None: - """Test an error is shown for unauthorized token formats.""" - token = str(uuid4()) - request_info = Mock(real_url="http://example.com") - smartthings_mock.apps.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.UNAUTHORIZED - ) - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {CONF_ACCESS_TOKEN: "token_unauthorized"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_forbidden_token_shows_error( - hass: HomeAssistant, smartthings_mock -) -> None: - """Test an error is shown for forbidden token formats.""" - token = str(uuid4()) - request_info = Mock(real_url="http://example.com") - smartthings_mock.apps.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.FORBIDDEN - ) - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {CONF_ACCESS_TOKEN: "token_forbidden"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_webhook_problem_shows_error( - hass: HomeAssistant, smartthings_mock -) -> None: - """Test an error is shown when there's an problem with the webhook endpoint.""" - token = str(uuid4()) - data = {"error": {}} - request_info = Mock(real_url="http://example.com") - error = APIResponseError( - request_info=request_info, - history=None, - data=data, - status=HTTPStatus.UNPROCESSABLE_ENTITY, - ) - error.is_target_error = Mock(return_value=True) - smartthings_mock.apps.side_effect = error - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {"base": "webhook_error"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_api_error_shows_error(hass: HomeAssistant, smartthings_mock) -> None: - """Test an error is shown when other API errors occur.""" - token = str(uuid4()) - data = {"error": {}} - request_info = Mock(real_url="http://example.com") - error = APIResponseError( - request_info=request_info, - history=None, - data=data, - status=HTTPStatus.BAD_REQUEST, - ) - smartthings_mock.apps.side_effect = error - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {"base": "app_setup_error"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_unknown_response_error_shows_error( - hass: HomeAssistant, smartthings_mock -) -> None: - """Test an error is shown when there is an unknown API error.""" - token = str(uuid4()) - request_info = Mock(real_url="http://example.com") - error = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.NOT_FOUND - ) - smartthings_mock.apps.side_effect = error - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {"base": "app_setup_error"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_unknown_error_shows_error(hass: HomeAssistant, smartthings_mock) -> None: - """Test an error is shown when there is an unknown API error.""" - token = str(uuid4()) - smartthings_mock.apps.side_effect = Exception("Unknown error") - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {"base": "app_setup_error"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_no_available_locations_aborts( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock -) -> None: - """Test select location aborts if no available locations.""" - token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app.return_value = (app, app_oauth_client) - smartthings_mock.locations.return_value = [location] - entry = MockConfigEntry( - domain=DOMAIN, data={CONF_LOCATION_ID: location.location_id} - ) - entry.add_to_hass(hass) - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_available_locations" - - -async def test_reauth( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock -) -> None: - """Test reauth flow.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app.return_value = (app, app_oauth_client) - smartthings_mock.locations.return_value = [location] - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_APP_ID: app.app_id, - CONF_CLIENT_ID: app_oauth_client.client_id, - CONF_CLIENT_SECRET: app_oauth_client.client_secret, - CONF_LOCATION_ID: location.location_id, - CONF_INSTALLED_APP_ID: installed_app_id, - CONF_ACCESS_TOKEN: token, - CONF_REFRESH_TOKEN: "abc", + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", }, - unique_id=smartapp.format_unique_id(app.app_id, location.location_id), ) - entry.add_to_hass(hass) - result = await entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + "https://api.smartthings.com/oauth/authorize" + "?response_type=code&client_id=CLIENT_ID" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" + "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" + "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + result["data"]["token"].pop("expires_at") + assert result["data"][CONF_TOKEN] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + } + assert result["result"].unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_duplicate_entry( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate entry is not able to set up.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + "https://api.smartthings.com/oauth/authorize" + "?response_type=code&client_id=CLIENT_ID" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" + "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" + "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauthentication( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - await smartapp.smartapp_update(hass, request, None, app) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "update_confirm" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + mock_config_entry.data["token"].pop("expires_at") + assert mock_config_entry.data[CONF_TOKEN] == { + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + } - assert entry.data[CONF_REFRESH_TOKEN] == refresh_token + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_account_mismatch( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with different account.""" + mock_config_entry.add_to_hass(hass) + + mock_smartthings.get_locations.return_value[ + 0 + ].location_id = "123123123-2be1-4e40-b257-e4ef59083324" + + result = await mock_config_entry.start_reauth_flow(hass) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_account_mismatch" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_migration( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_old_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with different account.""" + mock_old_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_old_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_old_config_entry.state is ConfigEntryState.SETUP_ERROR + + result = hass.config_entries.flow.async_progress()[0] + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_old_config_entry.state is ConfigEntryState.LOADED + assert len(hass.config_entries.flow.async_progress()) == 0 + mock_old_config_entry.data[CONF_TOKEN].pop("expires_at") + assert mock_old_config_entry.data == { + "auth_implementation": DOMAIN, + "old_data": { + CONF_ACCESS_TOKEN: "mock-access-token", + CONF_REFRESH_TOKEN: "mock-refresh-token", + CONF_CLIENT_ID: "CLIENT_ID", + CONF_CLIENT_SECRET: "CLIENT_SECRET", + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123aa123-2be1-4e40-b257-e4ef59083324", + }, + CONF_TOKEN: { + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", + }, + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + } + assert mock_old_config_entry.unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" + assert mock_old_config_entry.version == 3 + assert mock_old_config_entry.minor_version == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_migration_wrong_location( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_old_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with wrong location.""" + mock_old_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_old_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_old_config_entry.state is ConfigEntryState.SETUP_ERROR + + mock_smartthings.get_locations.return_value[ + 0 + ].location_id = "123123123-2be1-4e40-b257-e4ef59083324" + + result = hass.config_entries.flow.async_progress()[0] + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_location_mismatch" + assert mock_old_config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_old_config_entry.data == { + OLD_DATA: { + CONF_ACCESS_TOKEN: "mock-access-token", + CONF_REFRESH_TOKEN: "mock-refresh-token", + CONF_CLIENT_ID: "CLIENT_ID", + CONF_CLIENT_SECRET: "CLIENT_SECRET", + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123aa123-2be1-4e40-b257-e4ef59083324", + } + } + assert ( + mock_old_config_entry.unique_id + == "appid123-2be1-4e40-b257-e4ef59083324_397678e5-9995-4a39-9d9f-ae6ba310236c" + ) + assert mock_old_config_entry.version == 3 + assert mock_old_config_entry.minor_version == 1 diff --git a/tests/components/smartthings/test_cover.py b/tests/components/smartthings/test_cover.py index 31443c12ab2..37f12b44880 100644 --- a/tests/components/smartthings/test_cover.py +++ b/tests/components/smartthings/test_cover.py @@ -1,249 +1,192 @@ -"""Test for the SmartThings cover platform. +"""Test for the SmartThings cover platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command, Status +import pytest +from syrupy import SnapshotAssertion from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN as COVER_DOMAIN, +) +from homeassistant.components.smartthings.const import MAIN +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, - CoverState, + STATE_OPEN, + STATE_OPENING, + Platform, ) -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Garage", - [Capability.garage_door_control], - { - Attribute.door: "open", - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.COVER) + + +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_OPEN_COVER, Command.OPEN), + (SERVICE_CLOSE_COVER, Command.CLOSE), + ], +) +async def test_cover_open_close( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test cover open and close command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + action, + {ATTR_ENTITY_ID: "cover.curtain_1a"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "571af102-15db-4030-b76b-245a691f74a5", + Capability.WINDOW_SHADE, + command, + MAIN, ) - # Act - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("cover.garage") - assert entry - assert entry.unique_id == device.device_id - - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" -async def test_open(hass: HomeAssistant, device_factory) -> None: - """Test the cover opens doors, garages, and shades successfully.""" - # Arrange - devices = { - device_factory("Door", [Capability.door_control], {Attribute.door: "closed"}), - device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "closed"} - ), - device_factory( - "Shade", [Capability.window_shade], {Attribute.window_shade: "closed"} - ), +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_cover_set_position( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test cover set position command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.curtain_1a", ATTR_POSITION: 25}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "571af102-15db-4030-b76b-245a691f74a5", + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + MAIN, + argument=25, + ) + + +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_cover_battery( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test battery extra state attribute.""" + devices.get_device_status.return_value[MAIN][Capability.BATTERY] = { + Attribute.BATTERY: Status(50) } - await setup_platform(hass, COVER_DOMAIN, devices=devices) - entity_ids = ["cover.door", "cover.garage", "cover.shade"] - # Act - await hass.services.async_call( - COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: entity_ids}, blocking=True - ) - # Assert - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state is not None - assert state.state == CoverState.OPENING + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("cover.curtain_1a") + assert state + assert state.attributes[ATTR_BATTERY_LEVEL] == 50 -async def test_close(hass: HomeAssistant, device_factory) -> None: - """Test the cover closes doors, garages, and shades successfully.""" - # Arrange - devices = { - device_factory("Door", [Capability.door_control], {Attribute.door: "open"}), - device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "open"} - ), - device_factory( - "Shade", [Capability.window_shade], {Attribute.window_shade: "open"} - ), +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_cover_battery_updating( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test battery extra state attribute.""" + devices.get_device_status.return_value[MAIN][Capability.BATTERY] = { + Attribute.BATTERY: Status(50) } - await setup_platform(hass, COVER_DOMAIN, devices=devices) - entity_ids = ["cover.door", "cover.garage", "cover.shade"] - # Act - await hass.services.async_call( - COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: entity_ids}, blocking=True + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("cover.curtain_1a") + assert state + assert state.attributes[ATTR_BATTERY_LEVEL] == 50 + + await trigger_update( + hass, + devices, + "571af102-15db-4030-b76b-245a691f74a5", + Capability.BATTERY, + Attribute.BATTERY, + 49, ) - # Assert - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state is not None - assert state.state == CoverState.CLOSING + + state = hass.states.get("cover.curtain_1a") + assert state + assert state.attributes[ATTR_BATTERY_LEVEL] == 49 -async def test_set_cover_position_switch_level( - hass: HomeAssistant, device_factory +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the cover sets to the specific position for legacy devices that use Capability.switch_level.""" - # Arrange - device = device_factory( - "Shade", - [Capability.window_shade, Capability.battery, Capability.switch_level], - {Attribute.window_shade: "opening", Attribute.battery: 95, Attribute.level: 10}, - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_SET_COVER_POSITION, - {ATTR_POSITION: 50, "entity_id": "all"}, - blocking=True, + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.curtain_1a").state == STATE_OPEN + + await trigger_update( + hass, + devices, + "571af102-15db-4030-b76b-245a691f74a5", + Capability.WINDOW_SHADE, + Attribute.WINDOW_SHADE, + "opening", ) - state = hass.states.get("cover.shade") - # Result of call does not update state - assert state.state == CoverState.OPENING - assert state.attributes[ATTR_BATTERY_LEVEL] == 95 - assert state.attributes[ATTR_CURRENT_POSITION] == 10 - # Ensure API called - - assert device._api.post_device_command.call_count == 1 + assert hass.states.get("cover.curtain_1a").state == STATE_OPENING -async def test_set_cover_position(hass: HomeAssistant, device_factory) -> None: - """Test the cover sets to the specific position.""" - # Arrange - device = device_factory( - "Shade", - [Capability.window_shade, Capability.battery, Capability.window_shade_level], - { - Attribute.window_shade: "opening", - Attribute.battery: 95, - Attribute.shade_level: 10, - }, - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_SET_COVER_POSITION, - {ATTR_POSITION: 50, "entity_id": "all"}, - blocking=True, - ) - - state = hass.states.get("cover.shade") - # Result of call does not update state - assert state.state == CoverState.OPENING - assert state.attributes[ATTR_BATTERY_LEVEL] == 95 - assert state.attributes[ATTR_CURRENT_POSITION] == 10 - # Ensure API called - - assert device._api.post_device_command.call_count == 1 - - -async def test_set_cover_position_unsupported( - hass: HomeAssistant, device_factory +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_position_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test set position does nothing when not supported by device.""" - # Arrange - device = device_factory( - "Shade", [Capability.window_shade], {Attribute.window_shade: "opening"} - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_SET_COVER_POSITION, - {"entity_id": "all", ATTR_POSITION: 50}, - blocking=True, + """Test position update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.curtain_1a").attributes[ATTR_CURRENT_POSITION] == 100 + + await trigger_update( + hass, + devices, + "571af102-15db-4030-b76b-245a691f74a5", + Capability.SWITCH_LEVEL, + Attribute.LEVEL, + 50, ) - state = hass.states.get("cover.shade") - assert ATTR_CURRENT_POSITION not in state.attributes - - # Ensure API was not called - - assert device._api.post_device_command.call_count == 0 - - -async def test_update_to_open_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the cover updates to open when receiving a signal.""" - # Arrange - device = device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "opening"} - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - device.status.update_attribute_value(Attribute.door, "open") - assert hass.states.get("cover.garage").state == CoverState.OPENING - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("cover.garage") - assert state is not None - assert state.state == CoverState.OPEN - - -async def test_update_to_closed_from_signal( - hass: HomeAssistant, device_factory -) -> None: - """Test the cover updates to closed when receiving a signal.""" - # Arrange - device = device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "closing"} - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - device.status.update_attribute_value(Attribute.door, "closed") - assert hass.states.get("cover.garage").state == CoverState.CLOSING - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("cover.garage") - assert state is not None - assert state.state == CoverState.CLOSED - - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the lock is removed when the config entry is unloaded.""" - # Arrange - device = device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "open"} - ) - config_entry = await setup_platform(hass, COVER_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, COVER_DOMAIN) - # Assert - assert hass.states.get("cover.garage").state == STATE_UNAVAILABLE + assert hass.states.get("cover.curtain_1a").attributes[ATTR_CURRENT_POSITION] == 50 diff --git a/tests/components/smartthings/test_fan.py b/tests/components/smartthings/test_fan.py index b78c453b402..58287355381 100644 --- a/tests/components/smartthings/test_fan.py +++ b/tests/components/smartthings/test_fan.py @@ -1,433 +1,168 @@ -"""Test for the SmartThings fan platform. +"""Test for the SmartThings fan platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability +from pysmartthings import Capability, Command +import pytest +from syrupy import SnapshotAssertion from homeassistant.components.fan import ( ATTR_PERCENTAGE, ATTR_PRESET_MODE, - ATTR_PRESET_MODES, DOMAIN as FAN_DOMAIN, - FanEntityFeature, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, ) -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.smartthings import MAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - STATE_UNAVAILABLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities + +from tests.common import MockConfigEntry -async def test_entity_state(hass: HomeAssistant, device_factory) -> None: - """Tests the state attributes properly match the fan types.""" - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "on", Attribute.fan_speed: 2}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - - # Dimmer 1 - state = hass.states.get("fan.fan_1") - assert state.state == "on" - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == FanEntityFeature.SET_SPEED - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_PERCENTAGE] == 66 - - -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={ - Attribute.switch: "on", - Attribute.fan_speed: 2, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("fan.fan_1") - assert entry - assert entry.unique_id == device.device_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.FAN) -# Setup platform tests with varying capabilities -async def test_setup_mode_capability(hass: HomeAssistant, device_factory) -> None: - """Test setting up a fan with only the mode capability.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "off", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == FanEntityFeature.PRESET_MODE - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_PRESET_MODE] == "high" - assert state.attributes[ATTR_PRESET_MODES] == ["high", "low", "medium"] - - -async def test_setup_speed_capability(hass: HomeAssistant, device_factory) -> None: - """Test setting up a fan with only the speed capability.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={ - Attribute.switch: "off", - Attribute.fan_speed: 2, - }, - ) - - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == FanEntityFeature.SET_SPEED - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_PERCENTAGE] == 66 - - -async def test_setup_both_capabilities(hass: HomeAssistant, device_factory) -> None: - """Test setting up a fan with both the mode and speed capability.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[ - Capability.switch, - Capability.fan_speed, - Capability.air_conditioner_fan_mode, - ], - status={ - Attribute.switch: "off", - Attribute.fan_speed: 2, - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == FanEntityFeature.SET_SPEED - | FanEntityFeature.PRESET_MODE - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_PERCENTAGE] == 66 - assert state.attributes[ATTR_PRESET_MODE] == "high" - assert state.attributes[ATTR_PRESET_MODES] == ["high", "low", "medium"] - - -# Speed Capability Tests - - -async def test_turn_off_speed_capability(hass: HomeAssistant, device_factory) -> None: - """Test the fan turns of successfully.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "on", Attribute.fan_speed: 2}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "fan", "turn_off", {"entity_id": "fan.fan_1"}, blocking=True - ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "off" - - -async def test_turn_on_speed_capability(hass: HomeAssistant, device_factory) -> None: - """Test the fan turns of successfully.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "fan", "turn_on", {ATTR_ENTITY_ID: "fan.fan_1"}, blocking=True - ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - - -async def test_turn_on_with_speed_speed_capability( - hass: HomeAssistant, device_factory +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, Command.ON), + (SERVICE_TURN_OFF, Command.OFF), + ], +) +async def test_turn_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, ) -> None: - """Test the fan turns on to the specified speed.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act + """Test turning on and off.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "fan", - "turn_on", - {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PERCENTAGE: 100}, + FAN_DOMAIN, + action, + {ATTR_ENTITY_ID: "fan.fake_fan"}, blocking=True, ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_PERCENTAGE] == 100 - - -async def test_turn_off_with_speed_speed_capability( - hass: HomeAssistant, device_factory -) -> None: - """Test the fan turns off with the speed.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "on", Attribute.fan_speed: 100}, + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.SWITCH, + command, + MAIN, ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act + + +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_set_percentage( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the speed percentage of the fan.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "fan", - "set_percentage", - {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PERCENTAGE: 0}, + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: "fan.fake_fan", ATTR_PERCENTAGE: 50}, blocking=True, ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "off" - - -async def test_set_percentage_speed_capability( - hass: HomeAssistant, device_factory -) -> None: - """Test setting to specific fan speed.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.FAN_SPEED, + Command.SET_FAN_SPEED, + MAIN, + argument=2, ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act + + +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_set_percentage_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the speed percentage of the fan.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "fan", - "set_percentage", - {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PERCENTAGE: 100}, + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: "fan.fake_fan", ATTR_PERCENTAGE: 0}, blocking=True, ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_PERCENTAGE] == 100 + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.SWITCH, + Command.OFF, + MAIN, + ) -async def test_update_from_signal_speed_capability( - hass: HomeAssistant, device_factory +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_set_percentage_on( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the fan updates when receiving a signal.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - await device.switch_on(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" + """Test setting the speed percentage of the fan.""" + await setup_integration(hass, mock_config_entry) - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the fan is removed when the config entry is unloaded.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, - ) - config_entry = await setup_platform(hass, FAN_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "fan") - # Assert - assert hass.states.get("fan.fan_1").state == STATE_UNAVAILABLE - - -# Preset Mode Tests - - -async def test_turn_off_mode_capability(hass: HomeAssistant, device_factory) -> None: - """Test the fan turns of successfully.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "on", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act await hass.services.async_call( - "fan", "turn_off", {"entity_id": "fan.fan_1"}, blocking=True - ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "off" - assert state.attributes[ATTR_PRESET_MODE] == "high" - - -async def test_turn_on_mode_capability(hass: HomeAssistant, device_factory) -> None: - """Test the fan turns of successfully.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "off", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "fan", "turn_on", {ATTR_ENTITY_ID: "fan.fan_1"}, blocking=True - ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_PRESET_MODE] == "high" - - -async def test_update_from_signal_mode_capability( - hass: HomeAssistant, device_factory -) -> None: - """Test the fan updates when receiving a signal.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "off", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - await device.switch_on(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - - -async def test_set_preset_mode_mode_capability( - hass: HomeAssistant, device_factory -) -> None: - """Test setting to specific fan mode.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "off", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "fan", - "set_preset_mode", - {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PRESET_MODE: "low"}, + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.fake_fan", ATTR_PERCENTAGE: 50}, blocking=True, ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.attributes[ATTR_PRESET_MODE] == "low" + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.FAN_SPEED, + Command.SET_FAN_SPEED, + MAIN, + argument=2, + ) + + +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_set_preset_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the speed percentage of the fan.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "fan.fake_fan", ATTR_PRESET_MODE: "turbo"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.AIR_CONDITIONER_FAN_MODE, + Command.SET_FAN_MODE, + MAIN, + argument="turbo", + ) diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 83372b58228..be88f11903e 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -1,568 +1,31 @@ """Tests for the SmartThings component init module.""" -from collections.abc import Callable, Coroutine -from datetime import datetime, timedelta -from http import HTTPStatus -from typing import Any -from unittest.mock import Mock, patch -from uuid import uuid4 +from unittest.mock import AsyncMock -from aiohttp import ClientConnectionError, ClientResponseError -from pysmartthings import InstalledAppStatus, OAuthToken -import pytest +from syrupy import SnapshotAssertion -from homeassistant import config_entries -from homeassistant.components import cloud, smartthings -from homeassistant.components.smartthings.const import ( - CONF_CLOUDHOOK_URL, - CONF_INSTALLED_APP_ID, - CONF_REFRESH_TOKEN, - DATA_BROKERS, - DOMAIN, - EVENT_BUTTON, - PLATFORMS, - SIGNAL_SMARTTHINGS_UPDATE, -) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.smartthings.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.core_config import async_process_ha_core_config -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import device_registry as dr + +from . import setup_integration from tests.common import MockConfigEntry -async def test_migration_creates_new_flow( - hass: HomeAssistant, smartthings_mock, config_entry -) -> None: - """Test migration deletes app and creates new flow.""" - - config_entry.add_to_hass(hass) - hass.config_entries.async_update_entry(config_entry, version=1) - - await smartthings.async_migrate_entry(hass, config_entry) - await hass.async_block_till_done() - - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - assert not hass.config_entries.async_entries(DOMAIN) - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["handler"] == "smartthings" - assert flows[0]["context"] == {"source": config_entries.SOURCE_IMPORT} - - -async def test_unrecoverable_api_errors_create_new_flow( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test a new config flow is initiated when there are API errors. - - 401 (unauthorized): Occurs when the access token is no longer valid. - 403 (forbidden/not found): Occurs when the app or installed app could - not be retrieved/found (likely deleted?) - """ - - config_entry.add_to_hass(hass) - request_info = Mock(real_url="http://example.com") - smartthings_mock.app.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.UNAUTHORIZED - ) - - # Assert setup returns false - result = await hass.config_entries.async_setup(config_entry.entry_id) - assert not result - - assert config_entry.state == ConfigEntryState.SETUP_ERROR - - -async def test_recoverable_api_errors_raise_not_ready( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test config entry not ready raised for recoverable API errors.""" - config_entry.add_to_hass(hass) - request_info = Mock(real_url="http://example.com") - smartthings_mock.app.side_effect = ClientResponseError( - request_info=request_info, - history=None, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - - with pytest.raises(ConfigEntryNotReady): - await smartthings.async_setup_entry(hass, config_entry) - - -async def test_scenes_api_errors_raise_not_ready( - hass: HomeAssistant, config_entry, app, installed_app, smartthings_mock -) -> None: - """Test if scenes are unauthorized we continue to load platforms.""" - config_entry.add_to_hass(hass) - request_info = Mock(real_url="http://example.com") - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - smartthings_mock.scenes.side_effect = ClientResponseError( - request_info=request_info, - history=None, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - with pytest.raises(ConfigEntryNotReady): - await smartthings.async_setup_entry(hass, config_entry) - - -async def test_connection_errors_raise_not_ready( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test config entry not ready raised for connection errors.""" - config_entry.add_to_hass(hass) - smartthings_mock.app.side_effect = ClientConnectionError() - - with pytest.raises(ConfigEntryNotReady): - await smartthings.async_setup_entry(hass, config_entry) - - -async def test_base_url_no_longer_https_does_not_load( - hass: HomeAssistant, config_entry, app, smartthings_mock -) -> None: - """Test base_url no longer valid creates a new flow.""" - await async_process_ha_core_config( - hass, - {"external_url": "http://example.local:8123"}, - ) - config_entry.add_to_hass(hass) - smartthings_mock.app.return_value = app - - # Assert setup returns false - result = await smartthings.async_setup_entry(hass, config_entry) - assert not result - - -async def test_unauthorized_installed_app_raises_not_ready( - hass: HomeAssistant, config_entry, app, installed_app, smartthings_mock -) -> None: - """Test config entry not ready raised when the app isn't authorized.""" - config_entry.add_to_hass(hass) - installed_app.installed_app_status = InstalledAppStatus.PENDING - - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - - with pytest.raises(ConfigEntryNotReady): - await smartthings.async_setup_entry(hass, config_entry) - - -async def test_scenes_unauthorized_loads_platforms( +async def test_devices( hass: HomeAssistant, - config_entry, - app, - installed_app, - device, - smartthings_mock, - subscription_factory, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: - """Test if scenes are unauthorized we continue to load platforms.""" - config_entry.add_to_hass(hass) - request_info = Mock(real_url="http://example.com") - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - smartthings_mock.devices.return_value = [device] - smartthings_mock.scenes.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.FORBIDDEN - ) - mock_token = Mock() - mock_token.access_token = str(uuid4()) - mock_token.refresh_token = str(uuid4()) - smartthings_mock.generate_tokens.return_value = mock_token - subscriptions = [ - subscription_factory(capability) for capability in device.capabilities - ] - smartthings_mock.subscriptions.return_value = subscriptions + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - with patch.object( - hass.config_entries, "async_forward_entry_setups" - ) as forward_mock: - assert await hass.config_entries.async_setup(config_entry.entry_id) - # Assert platforms loaded - await hass.async_block_till_done() - forward_mock.assert_called_once_with(config_entry, PLATFORMS) + device_id = devices.get_devices.return_value[0].device_id + device = device_registry.async_get_device({(DOMAIN, device_id)}) -async def test_config_entry_loads_platforms( - hass: HomeAssistant, - config_entry, - app, - installed_app, - device, - smartthings_mock, - subscription_factory, - scene, -) -> None: - """Test config entry loads properly and proxies to platforms.""" - config_entry.add_to_hass(hass) - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - smartthings_mock.devices.return_value = [device] - smartthings_mock.scenes.return_value = [scene] - mock_token = Mock() - mock_token.access_token = str(uuid4()) - mock_token.refresh_token = str(uuid4()) - smartthings_mock.generate_tokens.return_value = mock_token - subscriptions = [ - subscription_factory(capability) for capability in device.capabilities - ] - smartthings_mock.subscriptions.return_value = subscriptions - - with patch.object( - hass.config_entries, "async_forward_entry_setups" - ) as forward_mock: - assert await hass.config_entries.async_setup(config_entry.entry_id) - # Assert platforms loaded - await hass.async_block_till_done() - forward_mock.assert_called_once_with(config_entry, PLATFORMS) - - -async def test_config_entry_loads_unconnected_cloud( - hass: HomeAssistant, - config_entry, - app, - installed_app, - device, - smartthings_mock, - subscription_factory, - scene, -) -> None: - """Test entry loads during startup when cloud isn't connected.""" - config_entry.add_to_hass(hass) - hass.data[DOMAIN][CONF_CLOUDHOOK_URL] = "https://test.cloud" - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - smartthings_mock.devices.return_value = [device] - smartthings_mock.scenes.return_value = [scene] - mock_token = Mock() - mock_token.access_token = str(uuid4()) - mock_token.refresh_token = str(uuid4()) - smartthings_mock.generate_tokens.return_value = mock_token - subscriptions = [ - subscription_factory(capability) for capability in device.capabilities - ] - smartthings_mock.subscriptions.return_value = subscriptions - with patch.object( - hass.config_entries, "async_forward_entry_setups" - ) as forward_mock: - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - forward_mock.assert_called_once_with(config_entry, PLATFORMS) - - -async def test_unload_entry(hass: HomeAssistant, config_entry) -> None: - """Test entries are unloaded correctly.""" - connect_disconnect = Mock() - smart_app = Mock() - smart_app.connect_event.return_value = connect_disconnect - broker = smartthings.DeviceBroker(hass, config_entry, Mock(), smart_app, [], []) - broker.connect() - hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] = broker - - with patch.object( - hass.config_entries, "async_forward_entry_unload", return_value=True - ) as forward_mock: - assert await smartthings.async_unload_entry(hass, config_entry) - - assert connect_disconnect.call_count == 1 - assert config_entry.entry_id not in hass.data[DOMAIN][DATA_BROKERS] - # Assert platforms unloaded - await hass.async_block_till_done() - assert forward_mock.call_count == len(PLATFORMS) - - -async def test_remove_entry( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test that the installed app and app are removed up.""" - # Act - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - - -async def test_remove_entry_cloudhook( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test that the installed app, app, and cloudhook are removed up.""" - hass.config.components.add("cloud") - # Arrange - config_entry.add_to_hass(hass) - hass.data[DOMAIN][CONF_CLOUDHOOK_URL] = "https://test.cloud" - # Act - with ( - patch.object( - cloud, "async_is_logged_in", return_value=True - ) as mock_async_is_logged_in, - patch.object(cloud, "async_delete_cloudhook") as mock_async_delete_cloudhook, - ): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - assert mock_async_is_logged_in.call_count == 1 - assert mock_async_delete_cloudhook.call_count == 1 - - -async def test_remove_entry_app_in_use( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test app is not removed if in use by another config entry.""" - # Arrange - config_entry.add_to_hass(hass) - data = config_entry.data.copy() - data[CONF_INSTALLED_APP_ID] = str(uuid4()) - entry2 = MockConfigEntry(version=2, domain=DOMAIN, data=data) - entry2.add_to_hass(hass) - # Act - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 0 - - -async def test_remove_entry_already_deleted( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test handles when the apps have already been removed.""" - request_info = Mock(real_url="http://example.com") - # Arrange - smartthings_mock.delete_installed_app.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.FORBIDDEN - ) - smartthings_mock.delete_app.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.FORBIDDEN - ) - # Act - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - - -async def test_remove_entry_installedapp_api_error( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test raises exceptions removing the installed app.""" - request_info = Mock(real_url="http://example.com") - # Arrange - smartthings_mock.delete_installed_app.side_effect = ClientResponseError( - request_info=request_info, - history=None, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - # Act - with pytest.raises(ClientResponseError): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 0 - - -async def test_remove_entry_installedapp_unknown_error( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test raises exceptions removing the installed app.""" - # Arrange - smartthings_mock.delete_installed_app.side_effect = ValueError - # Act - with pytest.raises(ValueError): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 0 - - -async def test_remove_entry_app_api_error( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test raises exceptions removing the app.""" - # Arrange - request_info = Mock(real_url="http://example.com") - smartthings_mock.delete_app.side_effect = ClientResponseError( - request_info=request_info, - history=None, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - # Act - with pytest.raises(ClientResponseError): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - - -async def test_remove_entry_app_unknown_error( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test raises exceptions removing the app.""" - # Arrange - smartthings_mock.delete_app.side_effect = ValueError - # Act - with pytest.raises(ValueError): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - - -async def test_broker_regenerates_token(hass: HomeAssistant, config_entry) -> None: - """Test the device broker regenerates the refresh token.""" - token = Mock(OAuthToken) - token.refresh_token = str(uuid4()) - stored_action = None - config_entry.add_to_hass(hass) - - def async_track_time_interval( - hass: HomeAssistant, - action: Callable[[datetime], Coroutine[Any, Any, None] | None], - interval: timedelta, - ) -> None: - nonlocal stored_action - stored_action = action - - with patch( - "homeassistant.components.smartthings.async_track_time_interval", - new=async_track_time_interval, - ): - broker = smartthings.DeviceBroker(hass, config_entry, token, Mock(), [], []) - broker.connect() - - assert stored_action - await stored_action(None) - assert token.refresh.call_count == 1 - assert config_entry.data[CONF_REFRESH_TOKEN] == token.refresh_token - - -async def test_event_handler_dispatches_updated_devices( - hass: HomeAssistant, - config_entry, - device_factory, - event_request_factory, - event_factory, -) -> None: - """Test the event handler dispatches updated devices.""" - devices = [ - device_factory("Bedroom 1 Switch", ["switch"]), - device_factory("Bathroom 1", ["switch"]), - device_factory("Sensor", ["motionSensor"]), - device_factory("Lock", ["lock"]), - ] - device_ids = [ - devices[0].device_id, - devices[1].device_id, - devices[2].device_id, - devices[3].device_id, - ] - event = event_factory( - devices[3].device_id, - capability="lock", - attribute="lock", - value="locked", - data={"codeId": "1"}, - ) - request = event_request_factory(device_ids=device_ids, events=[event]) - config_entry.add_to_hass(hass) - hass.config_entries.async_update_entry( - config_entry, - data={ - **config_entry.data, - CONF_INSTALLED_APP_ID: request.installed_app_id, - }, - ) - called = False - - def signal(ids): - nonlocal called - called = True - assert device_ids == ids - - async_dispatcher_connect(hass, SIGNAL_SMARTTHINGS_UPDATE, signal) - - broker = smartthings.DeviceBroker(hass, config_entry, Mock(), Mock(), devices, []) - broker.connect() - - await broker._event_handler(request, None, None) - await hass.async_block_till_done() - - assert called - for device in devices: - assert device.status.values["Updated"] == "Value" - assert devices[3].status.attributes["lock"].value == "locked" - assert devices[3].status.attributes["lock"].data == {"codeId": "1"} - - broker.disconnect() - - -async def test_event_handler_ignores_other_installed_app( - hass: HomeAssistant, config_entry, device_factory, event_request_factory -) -> None: - """Test the event handler dispatches updated devices.""" - device = device_factory("Bedroom 1 Switch", ["switch"]) - request = event_request_factory([device.device_id]) - called = False - - def signal(ids): - nonlocal called - called = True - - async_dispatcher_connect(hass, SIGNAL_SMARTTHINGS_UPDATE, signal) - broker = smartthings.DeviceBroker(hass, config_entry, Mock(), Mock(), [device], []) - broker.connect() - - await broker._event_handler(request, None, None) - await hass.async_block_till_done() - - assert not called - - broker.disconnect() - - -async def test_event_handler_fires_button_events( - hass: HomeAssistant, - config_entry, - device_factory, - event_factory, - event_request_factory, -) -> None: - """Test the event handler fires button events.""" - device = device_factory("Button 1", ["button"]) - event = event_factory( - device.device_id, capability="button", attribute="button", value="pushed" - ) - request = event_request_factory(events=[event]) - config_entry.add_to_hass(hass) - hass.config_entries.async_update_entry( - config_entry, - data={ - **config_entry.data, - CONF_INSTALLED_APP_ID: request.installed_app_id, - }, - ) - called = False - - def handler(evt): - nonlocal called - called = True - assert evt.data == { - "component_id": "main", - "device_id": device.device_id, - "location_id": event.location_id, - "value": "pushed", - "name": device.label, - "data": None, - } - - hass.bus.async_listen(EVENT_BUTTON, handler) - broker = smartthings.DeviceBroker(hass, config_entry, Mock(), Mock(), [device], []) - broker.connect() - - await broker._event_handler(request, None, None) - await hass.async_block_till_done() - - assert called - - broker.disconnect() + assert device is not None + assert device == snapshot diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py index b46188b5b5f..8d47e90c9f5 100644 --- a/tests/components/smartthings/test_light.py +++ b/tests/components/smartthings/test_light.py @@ -1,342 +1,307 @@ -"""Test for the SmartThings light platform. +"""Test for the SmartThings light platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from typing import Any +from unittest.mock import AsyncMock, call -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command import pytest +from syrupy import SnapshotAssertion from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, DOMAIN as LIGHT_DOMAIN, ColorMode, - LightEntityFeature, ) -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.smartthings.const import MAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - STATE_UNAVAILABLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import ( + set_attribute_value, + setup_integration, + snapshot_smartthings_entities, + trigger_update, +) + +from tests.common import MockConfigEntry -@pytest.fixture(name="light_devices") -def light_devices_fixture(device_factory): - """Fixture returns a set of mock light devices.""" - return [ - device_factory( - "Dimmer 1", - capabilities=[Capability.switch, Capability.switch_level], - status={Attribute.switch: "on", Attribute.level: 100}, - ), - device_factory( - "Color Dimmer 1", - capabilities=[ - Capability.switch, - Capability.switch_level, - Capability.color_control, - ], - status={ - Attribute.switch: "off", - Attribute.level: 0, - Attribute.hue: 76.0, - Attribute.saturation: 55.0, - }, - ), - device_factory( - "Color Dimmer 2", - capabilities=[ - Capability.switch, - Capability.switch_level, - Capability.color_control, - Capability.color_temperature, - ], - status={ - Attribute.switch: "on", - Attribute.level: 100, - Attribute.hue: 76.0, - Attribute.saturation: 0.0, - Attribute.color_temperature: 4500, - }, - ), - ] - - -async def test_entity_state(hass: HomeAssistant, light_devices) -> None: - """Tests the state attributes properly match the light types.""" - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - - # Dimmer 1 - state = hass.states.get("light.dimmer_1") - assert state.state == "on" - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] - assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION - assert isinstance(state.attributes[ATTR_BRIGHTNESS], int) - assert state.attributes[ATTR_BRIGHTNESS] == 255 - - # Color Dimmer 1 - state = hass.states.get("light.color_dimmer_1") - assert state.state == "off" - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] - assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION - - # Color Dimmer 2 - state = hass.states.get("light.color_dimmer_2") - assert state.state == "on" - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ - ColorMode.COLOR_TEMP, - ColorMode.HS, - ] - assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION - assert state.attributes[ATTR_BRIGHTNESS] == 255 - assert ATTR_HS_COLOR not in state.attributes[ATTR_HS_COLOR] - assert isinstance(state.attributes[ATTR_COLOR_TEMP_KELVIN], int) - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 4500 - - -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Light 1", - [Capability.switch, Capability.switch_level], - { - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, LIGHT_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("light.light_1") - assert entry - assert entry.unique_id == device.device_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.LIGHT) -async def test_turn_off(hass: HomeAssistant, light_devices) -> None: - """Test the light turns of successfully.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", "turn_off", {"entity_id": "light.color_dimmer_2"}, blocking=True - ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "off" - - -async def test_turn_off_with_transition(hass: HomeAssistant, light_devices) -> None: - """Test the light turns of successfully with transition.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", - "turn_off", - {ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_TRANSITION: 2}, - blocking=True, - ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "off" - - -async def test_turn_on(hass: HomeAssistant, light_devices) -> None: - """Test the light turns of successfully.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", "turn_on", {ATTR_ENTITY_ID: "light.color_dimmer_1"}, blocking=True - ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_1") - assert state is not None - assert state.state == "on" - - -async def test_turn_on_with_brightness(hass: HomeAssistant, light_devices) -> None: - """Test the light turns on to the specified brightness.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", - "turn_on", - { - ATTR_ENTITY_ID: "light.color_dimmer_1", - ATTR_BRIGHTNESS: 75, - ATTR_TRANSITION: 2, - }, - blocking=True, - ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_1") - assert state is not None - assert state.state == "on" - # round-trip rounding error (expected) - assert state.attributes[ATTR_BRIGHTNESS] == 74 - - -async def test_turn_on_with_minimal_brightness( - hass: HomeAssistant, light_devices +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +@pytest.mark.parametrize( + ("data", "calls"), + [ + ( + {}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Command.ON, + MAIN, + ) + ], + ), + ( + {ATTR_COLOR_TEMP_KELVIN: 4000}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Command.SET_COLOR_TEMPERATURE, + MAIN, + argument=4000, + ), + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Command.ON, + MAIN, + ), + ], + ), + ( + {ATTR_HS_COLOR: [350, 90]}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Command.SET_COLOR, + MAIN, + argument={"hue": 97.2222, "saturation": 90.0}, + ), + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Command.ON, + MAIN, + ), + ], + ), + ( + {ATTR_BRIGHTNESS: 50}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + MAIN, + argument=[20, 0], + ) + ], + ), + ( + {ATTR_BRIGHTNESS: 50, ATTR_TRANSITION: 3}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + MAIN, + argument=[20, 3], + ) + ], + ), + ], +) +async def test_turn_on_light( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + data: dict[str, Any], + calls: list[call], ) -> None: - """Test lights set to lowest brightness when converted scale would be zero. + """Test light turn on command.""" + await setup_integration(hass, mock_config_entry) - SmartThings light brightness is a percentage (0-100), but Home Assistant uses a - 0-255 scale. This tests if a really low value (1-2) is passed, we don't - set the level to zero, which turns off the lights in SmartThings. - """ - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act await hass.services.async_call( - "light", - "turn_on", - {ATTR_ENTITY_ID: "light.color_dimmer_1", ATTR_BRIGHTNESS: 2}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.standing_light"} | data, blocking=True, ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_1") - assert state is not None - assert state.state == "on" - # round-trip rounding error (expected) - assert state.attributes[ATTR_BRIGHTNESS] == 3 + assert devices.execute_device_command.mock_calls == calls -async def test_turn_on_with_color(hass: HomeAssistant, light_devices) -> None: - """Test the light turns on with color.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +@pytest.mark.parametrize( + ("data", "calls"), + [ + ( + {}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Command.OFF, + MAIN, + ) + ], + ), + ( + {ATTR_TRANSITION: 3}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + MAIN, + argument=[0, 3], + ) + ], + ), + ], +) +async def test_turn_off_light( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + data: dict[str, Any], + calls: list[call], +) -> None: + """Test light turn off command.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "light", - "turn_on", - {ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_HS_COLOR: (180, 50)}, + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.standing_light"} | data, blocking=True, ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_HS_COLOR] == (180, 50) + assert devices.execute_device_command.mock_calls == calls -async def test_turn_on_with_color_temp(hass: HomeAssistant, light_devices) -> None: - """Test the light turns on with color temp.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", - "turn_on", - {ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_COLOR_TEMP_KELVIN: 3333}, - blocking=True, +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("light.standing_light").state == STATE_OFF + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Attribute.SWITCH, + "on", ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 3333 + + assert hass.states.get("light.standing_light").state == STATE_ON -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the light updates when receiving a signal.""" - # Arrange - device = device_factory( - "Color Dimmer 2", - capabilities=[ - Capability.switch, - Capability.switch_level, - Capability.color_control, - Capability.color_temperature, - ], - status={ - Attribute.switch: "off", - Attribute.level: 100, - Attribute.hue: 76.0, - Attribute.saturation: 55.0, - Attribute.color_temperature: 4500, - }, +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_updating_brightness( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test brightness update.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("light.standing_light").attributes[ATTR_BRIGHTNESS] == 178 + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH_LEVEL, + Attribute.LEVEL, + 20, ) - await setup_platform(hass, LIGHT_DOMAIN, devices=[device]) - await device.switch_on(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "on" + + assert hass.states.get("light.standing_light").attributes[ATTR_BRIGHTNESS] == 51 -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the light is removed when the config entry is unloaded.""" - # Arrange - device = device_factory( - "Color Dimmer 2", - capabilities=[ - Capability.switch, - Capability.switch_level, - Capability.color_control, - Capability.color_temperature, - ], - status={ - Attribute.switch: "off", - Attribute.level: 100, - Attribute.hue: 76.0, - Attribute.saturation: 55.0, - Attribute.color_temperature: 4500, - }, +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_updating_hs( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test hue/saturation update.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("light.standing_light").attributes[ATTR_HS_COLOR] == ( + 218.906, + 60, + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 20, + ) + + assert hass.states.get("light.standing_light").attributes[ATTR_HS_COLOR] == ( + 72.0, + 60, + ) + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_updating_color_temp( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test color temperature update.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + set_attribute_value(devices, Capability.COLOR_CONTROL, Attribute.SATURATION, 0) + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.COLOR_TEMP + ) + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_TEMP_KELVIN] + == 3000 + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Attribute.COLOR_TEMPERATURE, + 2000, + ) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_TEMP_KELVIN] + == 2000 ) - config_entry = await setup_platform(hass, LIGHT_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "light") - # Assert - assert hass.states.get("light.color_dimmer_2").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_lock.py b/tests/components/smartthings/test_lock.py index 3c2a2651fb9..28191eceb9a 100644 --- a/tests/components/smartthings/test_lock.py +++ b/tests/components/smartthings/test_lock.py @@ -1,129 +1,85 @@ -"""Test for the SmartThings lock platform. +"""Test for the SmartThings lock platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability -from pysmartthings.device import Status +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState +from homeassistant.components.smartthings.const import MAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Lock_1", - [Capability.lock], - { - Attribute.lock: "unlocked", - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("lock.lock_1") - assert entry - assert entry.unique_id == device.device_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.LOCK) -async def test_lock(hass: HomeAssistant, device_factory) -> None: - """Test the lock locks successfully.""" - # Arrange - device = device_factory("Lock_1", [Capability.lock]) - device.status.attributes[Attribute.lock] = Status( - "unlocked", - None, - { - "method": "Manual", - "codeId": None, - "codeName": "Code 1", - "lockName": "Front Door", - "usedCode": "Code 2", - }, - ) - await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - # Act +@pytest.mark.parametrize("device_fixture", ["yale_push_button_deadbolt_lock"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_LOCK, Command.LOCK), + (SERVICE_UNLOCK, Command.UNLOCK), + ], +) +async def test_lock_unlock( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test lock and unlock command.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - LOCK_DOMAIN, "lock", {"entity_id": "lock.lock_1"}, blocking=True + LOCK_DOMAIN, + action, + {ATTR_ENTITY_ID: "lock.basement_door_lock"}, + blocking=True, ) - # Assert - state = hass.states.get("lock.lock_1") - assert state is not None - assert state.state == "locked" - assert state.attributes["method"] == "Manual" - assert state.attributes["lock_state"] == "locked" - assert state.attributes["code_name"] == "Code 1" - assert state.attributes["used_code"] == "Code 2" - assert state.attributes["lock_name"] == "Front Door" - assert "code_id" not in state.attributes - - -async def test_unlock(hass: HomeAssistant, device_factory) -> None: - """Test the lock unlocks successfully.""" - # Arrange - device = device_factory("Lock_1", [Capability.lock], {Attribute.lock: "locked"}) - await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - LOCK_DOMAIN, "unlock", {"entity_id": "lock.lock_1"}, blocking=True + devices.execute_device_command.assert_called_once_with( + "a9f587c5-5d8b-4273-8907-e7f609af5158", + Capability.LOCK, + command, + MAIN, ) - # Assert - state = hass.states.get("lock.lock_1") - assert state is not None - assert state.state == "unlocked" -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the lock updates when receiving a signal.""" - # Arrange - device = device_factory("Lock_1", [Capability.lock], {Attribute.lock: "unlocked"}) - await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - await device.lock(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("lock.lock_1") - assert state is not None - assert state.state == "locked" +@pytest.mark.parametrize("device_fixture", ["yale_push_button_deadbolt_lock"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("lock.basement_door_lock").state == LockState.LOCKED -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the lock is removed when the config entry is unloaded.""" - # Arrange - device = device_factory("Lock_1", [Capability.lock], {Attribute.lock: "locked"}) - config_entry = await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "lock") - # Assert - assert hass.states.get("lock.lock_1").state == STATE_UNAVAILABLE + await trigger_update( + hass, + devices, + "a9f587c5-5d8b-4273-8907-e7f609af5158", + Capability.LOCK, + Attribute.LOCK, + "open", + ) + + assert hass.states.get("lock.basement_door_lock").state == LockState.UNLOCKED diff --git a/tests/components/smartthings/test_scene.py b/tests/components/smartthings/test_scene.py index a20db1aaae8..7ef287b9e96 100644 --- a/tests/components/smartthings/test_scene.py +++ b/tests/components/smartthings/test_scene.py @@ -1,52 +1,47 @@ -"""Test for the SmartThings scene platform. +"""Test for the SmartThings scene platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNAVAILABLE +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities + +from tests.common import MockConfigEntry -async def test_entity_and_device_attributes( - hass: HomeAssistant, entity_registry: er.EntityRegistry, scene +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_smartthings: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: - """Test the attributes of the entity are correct.""" - # Act - await setup_platform(hass, SCENE_DOMAIN, scenes=[scene]) - # Assert - entry = entity_registry.async_get("scene.test_scene") - assert entry - assert entry.unique_id == scene.scene_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SCENE) -async def test_scene_activate(hass: HomeAssistant, scene) -> None: - """Test the scene is activated.""" - await setup_platform(hass, SCENE_DOMAIN, scenes=[scene]) +async def test_activate_scene( + hass: HomeAssistant, + mock_smartthings: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test activating a scene.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( SCENE_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "scene.test_scene"}, + {ATTR_ENTITY_ID: "scene.away"}, blocking=True, ) - state = hass.states.get("scene.test_scene") - assert state.attributes["icon"] == scene.icon - assert state.attributes["color"] == scene.color - assert state.attributes["location_id"] == scene.location_id - assert scene.execute.call_count == 1 - -async def test_unload_config_entry(hass: HomeAssistant, scene) -> None: - """Test the scene is removed when the config entry is unloaded.""" - # Arrange - config_entry = await setup_platform(hass, SCENE_DOMAIN, scenes=[scene]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, SCENE_DOMAIN) - # Assert - assert hass.states.get("scene.test_scene").state == STATE_UNAVAILABLE + mock_smartthings.execute_scene.assert_called_once_with( + "743b0f37-89b8-476c-aedf-eea8ad8cd29d" + ) diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index a6a48202f1d..7f8464e69aa 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -1,290 +1,56 @@ -"""Test for the SmartThings sensors platform. +"""Test for the SmartThings sensors platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability +import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, - PERCENTAGE, - STATE_UNAVAILABLE, - STATE_UNKNOWN, - EntityCategory, -) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_entity_state(hass: HomeAssistant, device_factory) -> None: - """Tests the state attributes properly match the sensor types.""" - device = device_factory("Sensor 1", [Capability.battery], {Attribute.battery: 100}) - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - state = hass.states.get("sensor.sensor_1_battery") - assert state.state == "100" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} Battery" - - -async def test_entity_three_axis_state(hass: HomeAssistant, device_factory) -> None: - """Tests the state attributes properly match the three axis types.""" - device = device_factory( - "Three Axis", [Capability.three_axis], {Attribute.three_axis: [100, 75, 25]} - ) - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - state = hass.states.get("sensor.three_axis_x_coordinate") - assert state.state == "100" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} X Coordinate" - state = hass.states.get("sensor.three_axis_y_coordinate") - assert state.state == "75" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} Y Coordinate" - state = hass.states.get("sensor.three_axis_z_coordinate") - assert state.state == "25" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} Z Coordinate" - - -async def test_entity_three_axis_invalid_state( - hass: HomeAssistant, device_factory -) -> None: - """Tests the state attributes properly match the three axis types.""" - device = device_factory( - "Three Axis", - [Capability.three_axis], - {Attribute.three_axis: [None, None, None]}, - ) - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - state = hass.states.get("sensor.three_axis_x_coordinate") - assert state.state == STATE_UNKNOWN - state = hass.states.get("sensor.three_axis_y_coordinate") - assert state.state == STATE_UNKNOWN - state = hass.states.get("sensor.three_axis_z_coordinate") - assert state.state == STATE_UNKNOWN - - -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Sensor 1", - [Capability.battery], - { - Attribute.battery: 100, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("sensor.sensor_1_battery") - assert entry - assert entry.unique_id == f"{device.device_id}.{Attribute.battery}" - assert entry.entity_category is EntityCategory.DIAGNOSTIC - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SENSOR) -async def test_energy_sensors_for_switch_device( +@pytest.mark.parametrize("device_fixture", ["aeotec_home_energy_meter_gen5"]) +async def test_state_update( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - device_factory, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Switch_1", - [Capability.switch, Capability.power_meter, Capability.energy_meter], - { - Attribute.switch: "off", - Attribute.power: 355, - Attribute.energy: 11.422, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("sensor.aeotec_energy_monitor_energy_meter").state + == "19978.536" ) - # Act - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - # Assert - state = hass.states.get("sensor.switch_1_energy_meter") - assert state - assert state.state == "11.422" - entry = entity_registry.async_get("sensor.switch_1_energy_meter") - assert entry - assert entry.unique_id == f"{device.device_id}.{Attribute.energy}" - assert entry.entity_category is None - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - state = hass.states.get("sensor.switch_1_power_meter") - assert state - assert state.state == "355" - entry = entity_registry.async_get("sensor.switch_1_power_meter") - assert entry - assert entry.unique_id == f"{device.device_id}.{Attribute.power}" - assert entry.entity_category is None - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - - -async def test_power_consumption_sensor( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - device_factory, -) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "refrigerator", - [Capability.power_consumption_report], - { - Attribute.power_consumption: { - "energy": 1412002, - "deltaEnergy": 25, - "power": 109, - "powerEnergy": 24.304498331745464, - "persistedEnergy": 0, - "energySaved": 0, - "start": "2021-07-30T16:45:25Z", - "end": "2021-07-30T16:58:33Z", - }, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, + await trigger_update( + hass, + devices, + "f0af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.ENERGY_METER, + Attribute.ENERGY, + 20000.0, ) - # Act - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - # Assert - state = hass.states.get("sensor.refrigerator_energy") - assert state - assert state.state == "1412.002" - entry = entity_registry.async_get("sensor.refrigerator_energy") - assert entry - assert entry.unique_id == f"{device.device_id}.energy_meter" - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - state = hass.states.get("sensor.refrigerator_power") - assert state - assert state.state == "109" - assert state.attributes["power_consumption_start"] == "2021-07-30T16:45:25Z" - assert state.attributes["power_consumption_end"] == "2021-07-30T16:58:33Z" - entry = entity_registry.async_get("sensor.refrigerator_power") - assert entry - assert entry.unique_id == f"{device.device_id}.power_meter" - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - - device = device_factory( - "vacuum", - [Capability.power_consumption_report], - { - Attribute.power_consumption: {}, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, + assert ( + hass.states.get("sensor.aeotec_energy_monitor_energy_meter").state == "20000.0" ) - # Act - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - # Assert - state = hass.states.get("sensor.vacuum_energy") - assert state - assert state.state == "unknown" - entry = entity_registry.async_get("sensor.vacuum_energy") - assert entry - assert entry.unique_id == f"{device.device_id}.energy_meter" - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - - -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the binary_sensor updates when receiving a signal.""" - # Arrange - device = device_factory("Sensor 1", [Capability.battery], {Attribute.battery: 100}) - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - device.status.apply_attribute_update( - "main", Capability.battery, Attribute.battery, 75 - ) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("sensor.sensor_1_battery") - assert state is not None - assert state.state == "75" - - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the binary_sensor is removed when the config entry is unloaded.""" - # Arrange - device = device_factory("Sensor 1", [Capability.battery], {Attribute.battery: 100}) - config_entry = await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") - # Assert - assert hass.states.get("sensor.sensor_1_battery").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_smartapp.py b/tests/components/smartthings/test_smartapp.py deleted file mode 100644 index c7861866fad..00000000000 --- a/tests/components/smartthings/test_smartapp.py +++ /dev/null @@ -1,186 +0,0 @@ -"""Tests for the smartapp module.""" - -from unittest.mock import AsyncMock, Mock, patch -from uuid import uuid4 - -from pysmartthings import CAPABILITIES, AppEntity, Capability -import pytest - -from homeassistant.components.smartthings import smartapp -from homeassistant.components.smartthings.const import ( - CONF_REFRESH_TOKEN, - DATA_MANAGER, - DOMAIN, -) -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def test_update_app(hass: HomeAssistant, app) -> None: - """Test update_app does not save if app is current.""" - await smartapp.update_app(hass, app) - assert app.save.call_count == 0 - - -async def test_update_app_updated_needed(hass: HomeAssistant, app) -> None: - """Test update_app updates when an app is needed.""" - mock_app = Mock(AppEntity) - mock_app.app_name = "Test" - - await smartapp.update_app(hass, mock_app) - - assert mock_app.save.call_count == 1 - assert mock_app.app_name == "Test" - assert mock_app.display_name == app.display_name - assert mock_app.description == app.description - assert mock_app.webhook_target_url == app.webhook_target_url - assert mock_app.app_type == app.app_type - assert mock_app.single_instance == app.single_instance - assert mock_app.classifications == app.classifications - - -async def test_smartapp_update_saves_token( - hass: HomeAssistant, smartthings_mock, location, device_factory -) -> None: - """Test update saves token.""" - # Arrange - entry = MockConfigEntry( - domain=DOMAIN, data={"installed_app_id": str(uuid4()), "app_id": str(uuid4())} - ) - entry.add_to_hass(hass) - app = Mock() - app.app_id = entry.data["app_id"] - request = Mock() - request.installed_app_id = entry.data["installed_app_id"] - request.auth_token = str(uuid4()) - request.refresh_token = str(uuid4()) - request.location_id = location.location_id - - # Act - await smartapp.smartapp_update(hass, request, None, app) - # Assert - assert entry.data[CONF_REFRESH_TOKEN] == request.refresh_token - - -async def test_smartapp_uninstall(hass: HomeAssistant, config_entry) -> None: - """Test the config entry is unloaded when the app is uninstalled.""" - config_entry.add_to_hass(hass) - app = Mock() - app.app_id = config_entry.data["app_id"] - request = Mock() - request.installed_app_id = config_entry.data["installed_app_id"] - - with patch.object(hass.config_entries, "async_remove") as remove: - await smartapp.smartapp_uninstall(hass, request, None, app) - assert remove.call_count == 1 - - -async def test_smartapp_webhook(hass: HomeAssistant) -> None: - """Test the smartapp webhook calls the manager.""" - manager = Mock() - manager.handle_request = AsyncMock(return_value={}) - hass.data[DOMAIN][DATA_MANAGER] = manager - request = Mock() - request.headers = [] - request.json = AsyncMock(return_value={}) - result = await smartapp.smartapp_webhook(hass, "", request) - - assert result.body == b"{}" - - -async def test_smartapp_sync_subscriptions( - hass: HomeAssistant, smartthings_mock, device_factory, subscription_factory -) -> None: - """Test synchronization adds and removes and ignores unused.""" - smartthings_mock.subscriptions.return_value = [ - subscription_factory(Capability.thermostat), - subscription_factory(Capability.switch), - subscription_factory(Capability.switch_level), - ] - devices = [ - device_factory("", [Capability.battery, "ping"]), - device_factory("", [Capability.switch, Capability.switch_level]), - device_factory("", [Capability.switch, Capability.execute]), - ] - - await smartapp.smartapp_sync_subscriptions( - hass, str(uuid4()), str(uuid4()), str(uuid4()), devices - ) - - assert smartthings_mock.subscriptions.call_count == 1 - assert smartthings_mock.delete_subscription.call_count == 1 - assert smartthings_mock.create_subscription.call_count == 1 - - -async def test_smartapp_sync_subscriptions_up_to_date( - hass: HomeAssistant, smartthings_mock, device_factory, subscription_factory -) -> None: - """Test synchronization does nothing when current.""" - smartthings_mock.subscriptions.return_value = [ - subscription_factory(Capability.battery), - subscription_factory(Capability.switch), - subscription_factory(Capability.switch_level), - ] - devices = [ - device_factory("", [Capability.battery, "ping"]), - device_factory("", [Capability.switch, Capability.switch_level]), - device_factory("", [Capability.switch]), - ] - - await smartapp.smartapp_sync_subscriptions( - hass, str(uuid4()), str(uuid4()), str(uuid4()), devices - ) - - assert smartthings_mock.subscriptions.call_count == 1 - assert smartthings_mock.delete_subscription.call_count == 0 - assert smartthings_mock.create_subscription.call_count == 0 - - -async def test_smartapp_sync_subscriptions_limit_warning( - hass: HomeAssistant, - smartthings_mock, - device_factory, - subscription_factory, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test synchronization over the limit logs a warning.""" - smartthings_mock.subscriptions.return_value = [] - devices = [ - device_factory("", CAPABILITIES), - ] - - await smartapp.smartapp_sync_subscriptions( - hass, str(uuid4()), str(uuid4()), str(uuid4()), devices - ) - - assert ( - "Some device attributes may not receive push updates and there may be " - "subscription creation failures" in caplog.text - ) - - -async def test_smartapp_sync_subscriptions_handles_exceptions( - hass: HomeAssistant, smartthings_mock, device_factory, subscription_factory -) -> None: - """Test synchronization does nothing when current.""" - smartthings_mock.delete_subscription.side_effect = Exception - smartthings_mock.create_subscription.side_effect = Exception - smartthings_mock.subscriptions.return_value = [ - subscription_factory(Capability.battery), - subscription_factory(Capability.switch), - subscription_factory(Capability.switch_level), - ] - devices = [ - device_factory("", [Capability.thermostat, "ping"]), - device_factory("", [Capability.switch, Capability.switch_level]), - device_factory("", [Capability.switch]), - ] - - await smartapp.smartapp_sync_subscriptions( - hass, str(uuid4()), str(uuid4()), str(uuid4()), devices - ) - - assert smartthings_mock.subscriptions.call_count == 1 - assert smartthings_mock.delete_subscription.call_count == 1 - assert smartthings_mock.create_subscription.call_count == 1 diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index fadd7600e87..a1e420a8edb 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -1,115 +1,89 @@ -"""Test for the SmartThings switch platform. +"""Test for the SmartThings switch platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE +from homeassistant.components.smartthings.const import MAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Switch_1", - [Capability.switch], - { - Attribute.switch: "on", - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("switch.switch_1") - assert entry - assert entry.unique_id == device.device_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SWITCH) -async def test_turn_off(hass: HomeAssistant, device_factory) -> None: - """Test the switch turns of successfully.""" - # Arrange - device = device_factory("Switch_1", [Capability.switch], {Attribute.switch: "on"}) - await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - # Act +@pytest.mark.parametrize("device_fixture", ["c2c_arlo_pro_3_switch"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, Command.ON), + (SERVICE_TURN_OFF, Command.OFF), + ], +) +async def test_switch_turn_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test switch turn on and off command.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "switch", "turn_off", {"entity_id": "switch.switch_1"}, blocking=True + SWITCH_DOMAIN, + action, + {ATTR_ENTITY_ID: "switch.2nd_floor_hallway"}, + blocking=True, ) - # Assert - state = hass.states.get("switch.switch_1") - assert state is not None - assert state.state == "off" - - -async def test_turn_on(hass: HomeAssistant, device_factory) -> None: - """Test the switch turns of successfully.""" - # Arrange - device = device_factory( - "Switch_1", - [Capability.switch, Capability.power_meter, Capability.energy_meter], - {Attribute.switch: "off", Attribute.power: 355, Attribute.energy: 11.422}, + devices.execute_device_command.assert_called_once_with( + "10e06a70-ee7d-4832-85e9-a0a06a7a05bd", Capability.SWITCH, command, MAIN ) - await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "switch", "turn_on", {"entity_id": "switch.switch_1"}, blocking=True + + +@pytest.mark.parametrize("device_fixture", ["c2c_arlo_pro_3_switch"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("switch.2nd_floor_hallway").state == STATE_ON + + await trigger_update( + hass, + devices, + "10e06a70-ee7d-4832-85e9-a0a06a7a05bd", + Capability.SWITCH, + Attribute.SWITCH, + "off", ) - # Assert - state = hass.states.get("switch.switch_1") - assert state is not None - assert state.state == "on" - -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the switch updates when receiving a signal.""" - # Arrange - device = device_factory("Switch_1", [Capability.switch], {Attribute.switch: "off"}) - await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - await device.switch_on(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("switch.switch_1") - assert state is not None - assert state.state == "on" - - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the switch is removed when the config entry is unloaded.""" - # Arrange - device = device_factory("Switch 1", [Capability.switch], {Attribute.switch: "on"}) - config_entry = await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "switch") - # Assert - assert hass.states.get("switch.switch_1").state == STATE_UNAVAILABLE + assert hass.states.get("switch.2nd_floor_hallway").state == STATE_OFF From 7e97ef588b8ea7e12d8356f6a9c55c79669a1691 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Feb 2025 15:27:52 +0100 Subject: [PATCH 2795/2987] Add keys initiate_flow and entry_type to data entry translations (#138882) --- homeassistant/components/kitchen_sink/strings.json | 8 ++++++-- script/hassfest/translations.py | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index e2fbb99c89f..e0cdf75b707 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -11,7 +11,6 @@ }, "config_subentries": { "entity": { - "title": "Add entity", "step": { "add_sensor": { "description": "Configure the new sensor", @@ -27,7 +26,12 @@ "state": "Initial state" } } - } + }, + "initiate_flow": { + "user": "Add sensor", + "reconfigure": "Reconfigure sensor" + }, + "entry_type": "Sensor" } }, "options": { diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 2e5ec3e8ba0..c257f185f51 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -185,6 +185,8 @@ def gen_data_entry_schema( vol.Optional("abort"): {str: translation_value_validator}, vol.Optional("progress"): {str: translation_value_validator}, vol.Optional("create_entry"): {str: translation_value_validator}, + vol.Optional("initiate_flow"): {str: translation_value_validator}, + vol.Optional("entry_type"): translation_value_validator, } if flow_title == REQUIRED: schema[vol.Required("title")] = translation_value_validator @@ -289,7 +291,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: gen_data_entry_schema( config=config, integration=integration, - flow_title=REQUIRED, + flow_title=REMOVED, require_step_title=False, ), slug_validator=vol.Any("_", cv.slug), From 5324f3e5420a91e308429efaae8498d1e29e31f1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 26 Feb 2025 15:44:16 +0100 Subject: [PATCH 2796/2987] Add support for swing horizontal mode for mqtt climate (#139303) * Add support for swing horizontal mode for mqtt climate * Fix import --- .../components/mqtt/abbreviations.py | 6 ++ homeassistant/components/mqtt/climate.py | 57 +++++++++++ tests/components/climate/common.py | 18 +++- tests/components/mqtt/test_climate.py | 96 ++++++++++++++++++- tests/components/mqtt/test_discovery.py | 1 - 5 files changed, 174 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 584b238b3a8..2d73cc5865c 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -218,10 +218,16 @@ ABBREVIATIONS = { "sup_vol": "support_volume_set", "sup_feat": "supported_features", "sup_clrm": "supported_color_modes", + "swing_h_mode_cmd_tpl": "swing_horizontal_mode_command_template", + "swing_h_mode_cmd_t": "swing_horizontal_mode_command_topic", + "swing_h_mode_stat_tpl": "swing_horizontal_mode_state_template", + "swing_h_mode_stat_t": "swing_horizontal_mode_state_topic", + "swing_h_modes": "swing_horizontal_modes", "swing_mode_cmd_tpl": "swing_mode_command_template", "swing_mode_cmd_t": "swing_mode_command_topic", "swing_mode_stat_tpl": "swing_mode_state_template", "swing_mode_stat_t": "swing_mode_state_topic", + "swing_modes": "swing_modes", "temp_cmd_tpl": "temperature_command_template", "temp_cmd_t": "temperature_command_topic", "temp_hi_cmd_tpl": "temperature_high_command_template", diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index a65eb18e3f1..931a57a71cc 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -113,11 +113,19 @@ CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic" CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template" CONF_PRESET_MODE_COMMAND_TEMPLATE = "preset_mode_command_template" CONF_PRESET_MODES_LIST = "preset_modes" + +CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template" +CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC = "swing_horizontal_mode_command_topic" +CONF_SWING_HORIZONTAL_MODE_LIST = "swing_horizontal_modes" +CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE = "swing_horizontal_mode_state_template" +CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC = "swing_horizontal_mode_state_topic" + CONF_SWING_MODE_COMMAND_TEMPLATE = "swing_mode_command_template" CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic" CONF_SWING_MODE_LIST = "swing_modes" CONF_SWING_MODE_STATE_TEMPLATE = "swing_mode_state_template" CONF_SWING_MODE_STATE_TOPIC = "swing_mode_state_topic" + CONF_TEMP_HIGH_COMMAND_TEMPLATE = "temperature_high_command_template" CONF_TEMP_HIGH_COMMAND_TOPIC = "temperature_high_command_topic" CONF_TEMP_HIGH_STATE_TEMPLATE = "temperature_high_state_template" @@ -145,6 +153,8 @@ MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset( climate.ATTR_MIN_TEMP, climate.ATTR_PRESET_MODE, climate.ATTR_PRESET_MODES, + climate.ATTR_SWING_HORIZONTAL_MODE, + climate.ATTR_SWING_HORIZONTAL_MODES, climate.ATTR_SWING_MODE, climate.ATTR_SWING_MODES, climate.ATTR_TARGET_TEMP_HIGH, @@ -162,6 +172,7 @@ VALUE_TEMPLATE_KEYS = ( CONF_MODE_STATE_TEMPLATE, CONF_ACTION_TEMPLATE, CONF_PRESET_MODE_VALUE_TEMPLATE, + CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE, CONF_SWING_MODE_STATE_TEMPLATE, CONF_TEMP_HIGH_STATE_TEMPLATE, CONF_TEMP_LOW_STATE_TEMPLATE, @@ -174,6 +185,7 @@ COMMAND_TEMPLATE_KEYS = { CONF_MODE_COMMAND_TEMPLATE, CONF_POWER_COMMAND_TEMPLATE, CONF_PRESET_MODE_COMMAND_TEMPLATE, + CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE, CONF_SWING_MODE_COMMAND_TEMPLATE, CONF_TEMP_COMMAND_TEMPLATE, CONF_TEMP_HIGH_COMMAND_TEMPLATE, @@ -194,6 +206,8 @@ TOPIC_KEYS = ( CONF_POWER_COMMAND_TOPIC, CONF_PRESET_MODE_COMMAND_TOPIC, CONF_PRESET_MODE_STATE_TOPIC, + CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC, + CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC, CONF_SWING_MODE_COMMAND_TOPIC, CONF_SWING_MODE_STATE_TOPIC, CONF_TEMP_COMMAND_TOPIC, @@ -302,6 +316,13 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_PRESET_MODE_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_PRESET_MODE_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_PRESET_MODE_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC): valid_publish_topic, + vol.Optional( + CONF_SWING_HORIZONTAL_MODE_LIST, default=[SWING_ON, SWING_OFF] + ): cv.ensure_list, + vol.Optional(CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_SWING_MODE_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_SWING_MODE_COMMAND_TOPIC): valid_publish_topic, vol.Optional( @@ -515,6 +536,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): _attr_fan_mode: str | None = None _attr_hvac_mode: HVACMode | None = None + _attr_swing_horizontal_mode: str | None = None _attr_swing_mode: str | None = None _default_name = DEFAULT_NAME _entity_id_format = climate.ENTITY_ID_FORMAT @@ -543,6 +565,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): if (precision := config.get(CONF_PRECISION)) is not None: self._attr_precision = precision self._attr_fan_modes = config[CONF_FAN_MODE_LIST] + self._attr_swing_horizontal_modes = config[CONF_SWING_HORIZONTAL_MODE_LIST] self._attr_swing_modes = config[CONF_SWING_MODE_LIST] self._attr_target_temperature_step = config[CONF_TEMP_STEP] @@ -568,6 +591,11 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None or self._optimistic: self._attr_fan_mode = FAN_LOW + if ( + self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is None + or self._optimistic + ): + self._attr_swing_horizontal_mode = SWING_OFF if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None or self._optimistic: self._attr_swing_mode = SWING_OFF if self._topic[CONF_MODE_STATE_TOPIC] is None or self._optimistic: @@ -629,6 +657,11 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): ): support |= ClimateEntityFeature.FAN_MODE + if (self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is not None) or ( + self._topic[CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC] is not None + ): + support |= ClimateEntityFeature.SWING_HORIZONTAL_MODE + if (self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None) or ( self._topic[CONF_SWING_MODE_COMMAND_TOPIC] is not None ): @@ -744,6 +777,16 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): ), {"_attr_fan_mode"}, ) + self.add_subscription( + CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC, + partial( + self._handle_mode_received, + CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE, + "_attr_swing_horizontal_mode", + CONF_SWING_HORIZONTAL_MODE_LIST, + ), + {"_attr_swing_horizontal_mode"}, + ) self.add_subscription( CONF_SWING_MODE_STATE_TOPIC, partial( @@ -782,6 +825,20 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): self.async_write_ha_state() + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new swing horizontal mode.""" + payload = self._command_templates[CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE]( + swing_horizontal_mode + ) + await self._publish(CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC, payload) + + if ( + self._optimistic + or self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is None + ): + self._attr_swing_horizontal_mode = swing_horizontal_mode + self.async_write_ha_state() + async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new swing mode.""" payload = self._command_templates[CONF_SWING_MODE_COMMAND_TEMPLATE](swing_mode) diff --git a/tests/components/climate/common.py b/tests/components/climate/common.py index d6aedd23671..8f5834d9180 100644 --- a/tests/components/climate/common.py +++ b/tests/components/climate/common.py @@ -11,6 +11,7 @@ from homeassistant.components.climate import ( ATTR_HUMIDITY, ATTR_HVAC_MODE, ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -20,10 +21,11 @@ from homeassistant.components.climate import ( SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, + HVACMode, ) -from homeassistant.components.climate.const import HVACMode from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -211,6 +213,20 @@ def set_operation_mode( hass.services.call(DOMAIN, SERVICE_SET_HVAC_MODE, data) +async def async_set_swing_horizontal_mode( + hass: HomeAssistant, swing_horizontal_mode: str, entity_id: str = ENTITY_MATCH_ALL +) -> None: + """Set new target swing horizontal mode.""" + data = {ATTR_SWING_HORIZONTAL_MODE: swing_horizontal_mode} + + if entity_id is not None: + data[ATTR_ENTITY_ID] = entity_id + + await hass.services.async_call( + DOMAIN, SERVICE_SET_SWING_HORIZONTAL_MODE, data, blocking=True + ) + + async def async_set_swing_mode( hass: HomeAssistant, swing_mode: str, entity_id: str = ENTITY_MATCH_ALL ) -> None: diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 5edd73e3f5a..3760b0226f5 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -15,6 +15,7 @@ from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HUMIDITY, ATTR_HVAC_ACTION, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -85,6 +86,7 @@ DEFAULT_CONFIG = { "temperature_high_command_topic": "temperature-high-topic", "fan_mode_command_topic": "fan-mode-topic", "swing_mode_command_topic": "swing-mode-topic", + "swing_horizontal_mode_command_topic": "swing-horizontal-mode-topic", "preset_mode_command_topic": "preset-mode-topic", "preset_modes": [ "eco", @@ -111,6 +113,7 @@ async def test_setup_params( assert state.attributes.get("temperature") == 21 assert state.attributes.get("fan_mode") == "low" assert state.attributes.get("swing_mode") == "off" + assert state.attributes.get("swing_horizontal_mode") == "off" assert state.state == "off" assert state.attributes.get("min_temp") == DEFAULT_MIN_TEMP assert state.attributes.get("max_temp") == DEFAULT_MAX_TEMP @@ -123,6 +126,7 @@ async def test_setup_params( | ClimateEntityFeature.TARGET_HUMIDITY | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON @@ -159,6 +163,7 @@ async def test_supported_features( state = hass.states.get(ENTITY_CLIMATE) support = ( ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE @@ -562,12 +567,29 @@ async def test_set_swing_mode_bad_attr( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" + assert state.attributes.get("swing_horizontal_mode") == "off" + with pytest.raises(vol.Invalid) as excinfo: + await common.async_set_swing_horizontal_mode(hass, None, ENTITY_CLIMATE) # type:ignore[arg-type] + assert ( + "string value is None for dictionary value @ data['swing_horizontal_mode']" + in str(excinfo.value) + ) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "off" + @pytest.mark.parametrize( "hass_config", [ help_custom_config( - climate.DOMAIN, DEFAULT_CONFIG, ({"swing_mode_state_topic": "swing-state"},) + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "swing_mode_state_topic": "swing-state", + "swing_horizontal_mode_state_topic": "swing-horizontal-state", + }, + ), ) ], ) @@ -579,19 +601,32 @@ async def test_set_swing_pessimistic( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") is None + assert state.attributes.get("swing_horizontal_mode") is None await common.async_set_swing_mode(hass, "on", ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") is None + await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") is None + async_fire_mqtt_message(hass, "swing-state", "on") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + async_fire_mqtt_message(hass, "swing-horizontal-state", "on") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + async_fire_mqtt_message(hass, "swing-state", "bogus state") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + async_fire_mqtt_message(hass, "swing-horizontal-state", "bogus state") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + @pytest.mark.parametrize( "hass_config", @@ -599,7 +634,13 @@ async def test_set_swing_pessimistic( help_custom_config( climate.DOMAIN, DEFAULT_CONFIG, - ({"swing_mode_state_topic": "swing-state", "optimistic": True},), + ( + { + "swing_mode_state_topic": "swing-state", + "swing_horizontal_mode_state_topic": "swing-horizontal-state", + "optimistic": True, + }, + ), ) ], ) @@ -611,19 +652,32 @@ async def test_set_swing_optimistic( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" + assert state.attributes.get("swing_horizontal_mode") == "off" await common.async_set_swing_mode(hass, "on", ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + async_fire_mqtt_message(hass, "swing-state", "off") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" + async_fire_mqtt_message(hass, "swing-horizontal-state", "off") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "off" + async_fire_mqtt_message(hass, "swing-state", "bogus state") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" + async_fire_mqtt_message(hass, "swing-horizontal-state", "bogus state") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "off" + @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_swing( @@ -638,6 +692,15 @@ async def test_set_swing( mqtt_mock.async_publish.assert_called_once_with("swing-mode-topic", "on", 0, False) state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + mqtt_mock.reset_mock() + + assert state.attributes.get("swing_horizontal_mode") == "off" + await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_called_once_with( + "swing-horizontal-mode-topic", "on", 0, False + ) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) @@ -1337,6 +1400,7 @@ async def test_get_target_temperature_low_high_with_templates( "temperature_low_command_topic": "temperature-low-topic", "temperature_high_command_topic": "temperature-high-topic", "fan_mode_command_topic": "fan-mode-topic", + "swing_horizontal_mode_command_topic": "swing-horizontal-mode-topic", "swing_mode_command_topic": "swing-mode-topic", "preset_mode_command_topic": "preset-mode-topic", "preset_modes": [ @@ -1359,6 +1423,7 @@ async def test_get_target_temperature_low_high_with_templates( "action_topic": "action", "mode_state_topic": "mode-state", "fan_mode_state_topic": "fan-state", + "swing_horizontal_mode_state_topic": "swing-horizontal-state", "swing_mode_state_topic": "swing-state", "temperature_state_topic": "temperature-state", "target_humidity_state_topic": "humidity-state", @@ -1396,6 +1461,12 @@ async def test_get_with_templates( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + # Swing Horizontal Mode + assert state.attributes.get("swing_horizontal_mode") is None + async_fire_mqtt_message(hass, "swing-horizontal-state", '"on"') + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + # Temperature - with valid value assert state.attributes.get("temperature") is None async_fire_mqtt_message(hass, "temperature-state", '"1031"') @@ -1495,6 +1566,7 @@ async def test_get_with_templates( "temperature_low_command_topic": "temperature-low-topic", "temperature_high_command_topic": "temperature-high-topic", "fan_mode_command_topic": "fan-mode-topic", + "swing_horizontal_mode_command_topic": "swing-horizontal-mode-topic", "swing_mode_command_topic": "swing-mode-topic", "preset_mode_command_topic": "preset-mode-topic", "preset_modes": [ @@ -1511,6 +1583,7 @@ async def test_get_with_templates( "power_command_template": "power: {{ value }}", "preset_mode_command_template": "preset_mode: {{ value }}", "mode_command_template": "mode: {{ value }}", + "swing_horizontal_mode_command_template": "swing_horizontal_mode: {{ value }}", "swing_mode_command_template": "swing_mode: {{ value }}", "temperature_command_template": "temp: {{ value }}", "temperature_high_command_template": "temp_hi: {{ value }}", @@ -1580,6 +1653,15 @@ async def test_set_and_templates( state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" + # Swing Horizontal Mode + await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_called_once_with( + "swing-horizontal-mode-topic", "swing_horizontal_mode: on", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + # Swing Mode await common.async_set_swing_mode(hass, "on", ENTITY_CLIMATE) mqtt_mock.async_publish.assert_called_once_with( @@ -1940,6 +2022,7 @@ async def test_unique_id( ("fan_mode_state_topic", "low", ATTR_FAN_MODE, "low"), ("mode_state_topic", "cool", None, None), ("mode_state_topic", "fan_only", None, None), + ("swing_horizontal_mode_state_topic", "on", ATTR_SWING_HORIZONTAL_MODE, "on"), ("swing_mode_state_topic", "on", ATTR_SWING_MODE, "on"), ("temperature_low_state_topic", "19.1", ATTR_TARGET_TEMP_LOW, 19.1), ("temperature_high_state_topic", "22.9", ATTR_TARGET_TEMP_HIGH, 22.9), @@ -2178,6 +2261,13 @@ async def test_precision_whole( "medium", "fan_mode_command_template", ), + ( + climate.SERVICE_SET_SWING_HORIZONTAL_MODE, + "swing_horizontal_mode_command_topic", + {"swing_horizontal_mode": "on"}, + "on", + "swing_horizontal_mode_command_template", + ), ( climate.SERVICE_SET_SWING_MODE, "swing_mode_command_topic", @@ -2378,6 +2468,7 @@ async def test_unload_entry( "current_temperature_topic": "current-temperature-topic", "preset_mode_state_topic": "preset-mode-state-topic", "preset_modes": ["eco", "away"], + "swing_horizontal_mode_state_topic": "swing-horizontal-mode-state-topic", "swing_mode_state_topic": "swing-mode-state-topic", "target_humidity_state_topic": "target-humidity-state-topic", "temperature_high_state_topic": "temperature-high-state-topic", @@ -2399,6 +2490,7 @@ async def test_unload_entry( ("current-humidity-topic", "45", "46"), ("current-temperature-topic", "18.0", "18.1"), ("preset-mode-state-topic", "eco", "away"), + ("swing-horizontal-mode-state-topic", "on", "off"), ("swing-mode-state-topic", "on", "off"), ("target-humidity-state-topic", "45", "50"), ("temperature-state-topic", "18", "19"), diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 982167feee1..47c3a1e1988 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -2380,7 +2380,6 @@ ABBREVIATIONS_WHITE_LIST = [ "CONF_PRECISION", "CONF_QOS", "CONF_SCHEMA", - "CONF_SWING_MODE_LIST", "CONF_TEMP_STEP", # Removed "CONF_WHITE_VALUE", From 2826198d5d0655a6c890afcaa08f70f8e8abe60b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 15:48:51 +0100 Subject: [PATCH 2797/2987] Add entity translations to SmartThings (#139342) * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * fix * fix * Add AC tests * Add thermostat tests * Add cover tests * Add device tests * Add light tests * Add rest of the tests * Add oauth * Add oauth tests * Add oauth tests * Add oauth tests * Add oauth tests * Bump version * Add rest of the tests * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Iterate over entities instead * use set * use const * uncomment * fix handler * Fix device info * Fix device info * Fix lib * Fix lib * Fix * Fix * Fix * Fix * Fix * Fix * Fix * Fix * Fix * Add fake fan * Fix * Add entity translations to SmartThings * Fix --- .../components/smartthings/binary_sensor.py | 6 +- .../components/smartthings/climate.py | 3 + homeassistant/components/smartthings/cover.py | 1 + .../components/smartthings/entity.py | 2 +- homeassistant/components/smartthings/fan.py | 1 + homeassistant/components/smartthings/light.py | 1 + homeassistant/components/smartthings/lock.py | 2 + .../components/smartthings/sensor.py | 134 +- .../components/smartthings/strings.json | 183 ++ .../components/smartthings/switch.py | 2 + .../snapshots/test_binary_sensor.ambr | 102 +- .../smartthings/snapshots/test_climate.ambr | 16 +- .../smartthings/snapshots/test_cover.ambr | 8 +- .../smartthings/snapshots/test_fan.ambr | 4 +- .../smartthings/snapshots/test_light.ambr | 16 +- .../smartthings/snapshots/test_lock.ambr | 4 +- .../smartthings/snapshots/test_sensor.ambr | 2320 ++++++++--------- .../smartthings/snapshots/test_switch.ambr | 40 +- .../smartthings/test_binary_sensor.py | 4 +- tests/components/smartthings/test_sensor.py | 9 +- 20 files changed, 1517 insertions(+), 1341 deletions(-) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 6afa4edcf17..99cbd3f9353 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -33,6 +33,7 @@ CAPABILITY_TO_SENSORS: dict[ Capability.ACCELERATION_SENSOR: { Attribute.ACCELERATION: SmartThingsBinarySensorEntityDescription( key=Attribute.ACCELERATION, + translation_key="acceleration", device_class=BinarySensorDeviceClass.MOVING, is_on_key="active", ) @@ -47,6 +48,7 @@ CAPABILITY_TO_SENSORS: dict[ Capability.FILTER_STATUS: { Attribute.FILTER_STATUS: SmartThingsBinarySensorEntityDescription( key=Attribute.FILTER_STATUS, + translation_key="filter_status", device_class=BinarySensorDeviceClass.PROBLEM, is_on_key="replace", ) @@ -75,7 +77,7 @@ CAPABILITY_TO_SENSORS: dict[ Capability.TAMPER_ALERT: { Attribute.TAMPER: SmartThingsBinarySensorEntityDescription( key=Attribute.TAMPER, - device_class=BinarySensorDeviceClass.PROBLEM, + device_class=BinarySensorDeviceClass.TAMPER, is_on_key="detected", entity_category=EntityCategory.DIAGNOSTIC, ) @@ -83,6 +85,7 @@ CAPABILITY_TO_SENSORS: dict[ Capability.VALVE: { Attribute.VALVE: SmartThingsBinarySensorEntityDescription( key=Attribute.VALVE, + translation_key="valve", device_class=BinarySensorDeviceClass.OPENING, is_on_key="open", ) @@ -133,7 +136,6 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): self._attribute = attribute self.capability = capability self.entity_description = entity_description - self._attr_name = f"{device.device.label} {attribute}" self._attr_unique_id = f"{device.device.device_id}.{attribute}" @property diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 2e05fb2fc4f..2c3b8f3ac03 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -135,6 +135,8 @@ async def async_setup_entry( class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): """Define a SmartThings climate entities.""" + _attr_name = None + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" super().__init__( @@ -322,6 +324,7 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" + _attr_name = None _attr_preset_mode = None def __init__(self, client: SmartThings, device: FullDevice) -> None: diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 97a7456d132..fd4752b4e28 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -51,6 +51,7 @@ async def async_setup_entry( class SmartThingsCover(SmartThingsEntity, CoverEntity): """Define a SmartThings cover.""" + _attr_name = None _state: CoverState | None = None def __init__( diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index f5f1f268801..b2e556c6718 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -17,6 +17,7 @@ class SmartThingsEntity(Entity): """Defines a SmartThings entity.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, client: SmartThings, device: FullDevice, capabilities: set[Capability] @@ -30,7 +31,6 @@ class SmartThingsEntity(Entity): if capability in device.status[MAIN] } self.device = device - self._attr_name = device.device.label self._attr_unique_id = device.device.device_id self._attr_device_info = DeviceInfo( configuration_url="https://account.smartthings.com", diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 23afb0baeb2..8edf01ec613 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -48,6 +48,7 @@ async def async_setup_entry( class SmartThingsFan(SmartThingsEntity, FanEntity): """Define a SmartThings Fan.""" + _attr_name = None _attr_speed_count = int_states_in_range(SPEED_RANGE) def __init__(self, client: SmartThings, device: FullDevice) -> None: diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 582f9dd5435..54e8ad18a7c 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -56,6 +56,7 @@ def convert_scale( class SmartThingsLight(SmartThingsEntity, LightEntity): """Define a SmartThings Light.""" + _attr_name = None _attr_supported_color_modes: set[ColorMode] # SmartThings does not expose this attribute, instead it's diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index 56274dfe161..f56ecd5d565 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -42,6 +42,8 @@ async def async_setup_entry( class SmartThingsLock(SmartThingsEntity, LockEntity): """Define a SmartThings lock.""" + _attr_name = None + async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" await self.execute_device_command( diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index b16d332a1ae..6685d6be726 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -69,7 +69,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.LIGHTING_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.LIGHTING_MODE, - name="Activity Lighting Mode", + translation_key="lighting_mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] @@ -78,7 +78,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.AIR_CONDITIONER_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.AIR_CONDITIONER_MODE, - name="Air Conditioner Mode", + translation_key="air_conditioner_mode", entity_category=EntityCategory.DIAGNOSTIC, capability_ignore_list=[ { @@ -93,7 +93,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.AIR_QUALITY: [ SmartThingsSensorEntityDescription( key=Attribute.AIR_QUALITY, - name="Air Quality", + translation_key="air_quality", native_unit_of_measurement="CAQI", state_class=SensorStateClass.MEASUREMENT, ) @@ -103,7 +103,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ALARM: [ SmartThingsSensorEntityDescription( key=Attribute.ALARM, - name="Alarm", + translation_key="alarm", ) ] }, @@ -111,7 +111,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.VOLUME: [ SmartThingsSensorEntityDescription( key=Attribute.VOLUME, - name="Volume", + translation_key="audio_volume", native_unit_of_measurement=PERCENTAGE, ) ] @@ -120,7 +120,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.BATTERY: [ SmartThingsSensorEntityDescription( key=Attribute.BATTERY, - name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, @@ -132,7 +131,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.BMI_MEASUREMENT: [ SmartThingsSensorEntityDescription( key=Attribute.BMI_MEASUREMENT, - name="Body Mass Index", + translation_key="body_mass_index", native_unit_of_measurement=f"{UnitOfMass.KILOGRAMS}/{UnitOfArea.SQUARE_METERS}", state_class=SensorStateClass.MEASUREMENT, ) @@ -143,7 +142,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.BODY_WEIGHT_MEASUREMENT: [ SmartThingsSensorEntityDescription( key=Attribute.BODY_WEIGHT_MEASUREMENT, - name="Body Weight", + translation_key="body_weight", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -155,7 +154,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.CARBON_DIOXIDE: [ SmartThingsSensorEntityDescription( key=Attribute.CARBON_DIOXIDE, - name="Carbon Dioxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, @@ -167,7 +165,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.CARBON_MONOXIDE: [ SmartThingsSensorEntityDescription( key=Attribute.CARBON_MONOXIDE, - name="Carbon Monoxide Detector", + translation_key="carbon_monoxide_detector", ) ] }, @@ -176,7 +174,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.CARBON_MONOXIDE_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.CARBON_MONOXIDE_LEVEL, - name="Carbon Monoxide Level", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO, state_class=SensorStateClass.MEASUREMENT, @@ -187,19 +184,19 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, - name="Dishwasher Machine State", + translation_key="dishwasher_machine_state", ) ], Attribute.DISHWASHER_JOB_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.DISHWASHER_JOB_STATE, - name="Dishwasher Job State", + translation_key="dishwasher_job_state", ) ], Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( key=Attribute.COMPLETION_TIME, - name="Dishwasher Completion Time", + translation_key="completion_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) @@ -210,7 +207,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.DRYER_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.DRYER_MODE, - name="Dryer Mode", + translation_key="dryer_mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] @@ -219,19 +216,19 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, - name="Dryer Machine State", + translation_key="dryer_machine_state", ) ], Attribute.DRYER_JOB_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.DRYER_JOB_STATE, - name="Dryer Job State", + translation_key="dryer_job_state", ) ], Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( key=Attribute.COMPLETION_TIME, - name="Dryer Completion Time", + translation_key="completion_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) @@ -241,14 +238,14 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.DUST_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.DUST_LEVEL, - name="Dust Level", + translation_key="dust_level", state_class=SensorStateClass.MEASUREMENT, ) ], Attribute.FINE_DUST_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.FINE_DUST_LEVEL, - name="Fine Dust Level", + translation_key="fine_dust_level", state_class=SensorStateClass.MEASUREMENT, ) ], @@ -257,7 +254,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ENERGY: [ SmartThingsSensorEntityDescription( key=Attribute.ENERGY, - name="Energy Meter", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -269,7 +265,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: [ SmartThingsSensorEntityDescription( key=Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT, - name="Equivalent Carbon Dioxide Measurement", + translation_key="equivalent_carbon_dioxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, @@ -281,7 +277,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.FORMALDEHYDE_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.FORMALDEHYDE_LEVEL, - name="Formaldehyde Measurement", + translation_key="formaldehyde", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, ) @@ -292,7 +288,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.GAS_METER: [ SmartThingsSensorEntityDescription( key=Attribute.GAS_METER, - name="Gas Meter", + translation_key="gas_meter", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.MEASUREMENT, @@ -301,13 +297,13 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.GAS_METER_CALORIFIC: [ SmartThingsSensorEntityDescription( key=Attribute.GAS_METER_CALORIFIC, - name="Gas Meter Calorific", + translation_key="gas_meter_calorific", ) ], Attribute.GAS_METER_TIME: [ SmartThingsSensorEntityDescription( key=Attribute.GAS_METER_TIME, - name="Gas Meter Time", + translation_key="gas_meter_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) @@ -315,7 +311,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.GAS_METER_VOLUME: [ SmartThingsSensorEntityDescription( key=Attribute.GAS_METER_VOLUME, - name="Gas Meter Volume", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.MEASUREMENT, @@ -327,7 +322,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ILLUMINANCE: [ SmartThingsSensorEntityDescription( key=Attribute.ILLUMINANCE, - name="Illuminance", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, @@ -339,7 +333,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.INFRARED_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.INFRARED_LEVEL, - name="Infrared Level", + translation_key="infrared_level", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ) @@ -349,7 +343,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.INPUT_SOURCE: [ SmartThingsSensorEntityDescription( key=Attribute.INPUT_SOURCE, - name="Media Input Source", + translation_key="media_input_source", ) ] }, @@ -358,7 +352,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.PLAYBACK_REPEAT_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_REPEAT_MODE, - name="Media Playback Repeat", + translation_key="media_playback_repeat", ) ] }, @@ -367,7 +361,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.PLAYBACK_SHUFFLE: [ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_SHUFFLE, - name="Media Playback Shuffle", + translation_key="media_playback_shuffle", ) ] }, @@ -375,7 +369,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.PLAYBACK_STATUS: [ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_STATUS, - name="Media Playback Status", + translation_key="media_playback_status", ) ] }, @@ -383,7 +377,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ODOR_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.ODOR_LEVEL, - name="Odor Sensor", + translation_key="odor_sensor", ) ] }, @@ -391,7 +385,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.OVEN_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.OVEN_MODE, - name="Oven Mode", + translation_key="oven_mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] @@ -400,19 +394,19 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, - name="Oven Machine State", + translation_key="oven_machine_state", ) ], Attribute.OVEN_JOB_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.OVEN_JOB_STATE, - name="Oven Job State", + translation_key="oven_job_state", ) ], Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( key=Attribute.COMPLETION_TIME, - name="Oven Completion Time", + translation_key="completion_time", ) ], }, @@ -420,7 +414,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.OVEN_SETPOINT: [ SmartThingsSensorEntityDescription( key=Attribute.OVEN_SETPOINT, - name="Oven Set Point", + translation_key="oven_setpoint", ) ] }, @@ -428,7 +422,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.POWER_CONSUMPTION: [ SmartThingsSensorEntityDescription( key="energy_meter", - name="energy", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -436,7 +429,6 @@ CAPABILITY_TO_SENSORS: dict[ ), SmartThingsSensorEntityDescription( key="power_meter", - name="power", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, @@ -445,7 +437,7 @@ CAPABILITY_TO_SENSORS: dict[ ), SmartThingsSensorEntityDescription( key="deltaEnergy_meter", - name="deltaEnergy", + translation_key="energy_difference", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -453,7 +445,7 @@ CAPABILITY_TO_SENSORS: dict[ ), SmartThingsSensorEntityDescription( key="powerEnergy_meter", - name="powerEnergy", + translation_key="power_energy", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -461,7 +453,7 @@ CAPABILITY_TO_SENSORS: dict[ ), SmartThingsSensorEntityDescription( key="energySaved_meter", - name="energySaved", + translation_key="energy_saved", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -473,7 +465,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.POWER: [ SmartThingsSensorEntityDescription( key=Attribute.POWER, - name="Power Meter", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -485,7 +476,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.POWER_SOURCE: [ SmartThingsSensorEntityDescription( key=Attribute.POWER_SOURCE, - name="Power Source", + translation_key="power_source", entity_category=EntityCategory.DIAGNOSTIC, ) ] @@ -495,7 +486,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.REFRIGERATION_SETPOINT: [ SmartThingsSensorEntityDescription( key=Attribute.REFRIGERATION_SETPOINT, - name="Refrigeration Setpoint", + translation_key="refrigeration_setpoint", device_class=SensorDeviceClass.TEMPERATURE, ) ] @@ -504,7 +495,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.HUMIDITY: [ SmartThingsSensorEntityDescription( key=Attribute.HUMIDITY, - name="Relative Humidity Measurement", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, @@ -515,7 +505,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ROBOT_CLEANER_CLEANING_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.ROBOT_CLEANER_CLEANING_MODE, - name="Robot Cleaner Cleaning Mode", + translation_key="robot_cleaner_cleaning_mode", entity_category=EntityCategory.DIAGNOSTIC, ) ], @@ -524,7 +514,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ROBOT_CLEANER_MOVEMENT: [ SmartThingsSensorEntityDescription( key=Attribute.ROBOT_CLEANER_MOVEMENT, - name="Robot Cleaner Movement", + translation_key="robot_cleaner_movement", ) ] }, @@ -532,7 +522,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ROBOT_CLEANER_TURBO_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.ROBOT_CLEANER_TURBO_MODE, - name="Robot Cleaner Turbo Mode", + translation_key="robot_cleaner_turbo_mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] @@ -542,7 +532,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.LQI: [ SmartThingsSensorEntityDescription( key=Attribute.LQI, - name="LQI Signal Strength", + translation_key="link_quality", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ) @@ -550,7 +540,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.RSSI: [ SmartThingsSensorEntityDescription( key=Attribute.RSSI, - name="RSSI Signal Strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -562,7 +551,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.SMOKE: [ SmartThingsSensorEntityDescription( key=Attribute.SMOKE, - name="Smoke Detector", + translation_key="smoke_detector", ) ] }, @@ -570,7 +559,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.TEMPERATURE: [ SmartThingsSensorEntityDescription( key=Attribute.TEMPERATURE, - name="Temperature Measurement", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ) @@ -580,7 +568,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.COOLING_SETPOINT: [ SmartThingsSensorEntityDescription( key=Attribute.COOLING_SETPOINT, - name="Thermostat Cooling Setpoint", + translation_key="thermostat_cooling_setpoint", device_class=SensorDeviceClass.TEMPERATURE, capability_ignore_list=[ { @@ -598,7 +586,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.THERMOSTAT_FAN_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.THERMOSTAT_FAN_MODE, - name="Thermostat Fan Mode", + translation_key="thermostat_fan_mode", entity_category=EntityCategory.DIAGNOSTIC, capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) @@ -609,7 +597,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.HEATING_SETPOINT: [ SmartThingsSensorEntityDescription( key=Attribute.HEATING_SETPOINT, - name="Thermostat Heating Setpoint", + translation_key="thermostat_heating_setpoint", device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, capability_ignore_list=[THERMOSTAT_CAPABILITIES], @@ -621,7 +609,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.THERMOSTAT_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.THERMOSTAT_MODE, - name="Thermostat Mode", + translation_key="thermostat_mode", entity_category=EntityCategory.DIAGNOSTIC, capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) @@ -632,7 +620,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.THERMOSTAT_OPERATING_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.THERMOSTAT_OPERATING_STATE, - name="Thermostat Operating State", + translation_key="thermostat_operating_state", capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) ] @@ -642,7 +630,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.THERMOSTAT_SETPOINT: [ SmartThingsSensorEntityDescription( key=Attribute.THERMOSTAT_SETPOINT, - name="Thermostat Setpoint", + translation_key="thermostat_setpoint", device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, ) @@ -652,19 +640,19 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.THREE_AXIS: [ SmartThingsSensorEntityDescription( key="X Coordinate", - name="X Coordinate", + translation_key="x_coordinate", unique_id_separator=" ", value_fn=lambda value: value[0], ), SmartThingsSensorEntityDescription( key="Y Coordinate", - name="Y Coordinate", + translation_key="y_coordinate", unique_id_separator=" ", value_fn=lambda value: value[1], ), SmartThingsSensorEntityDescription( key="Z Coordinate", - name="Z Coordinate", + translation_key="z_coordinate", unique_id_separator=" ", value_fn=lambda value: value[2], ), @@ -674,13 +662,13 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.TV_CHANNEL: [ SmartThingsSensorEntityDescription( key=Attribute.TV_CHANNEL, - name="Tv Channel", + translation_key="tv_channel", ) ], Attribute.TV_CHANNEL_NAME: [ SmartThingsSensorEntityDescription( key=Attribute.TV_CHANNEL_NAME, - name="Tv Channel Name", + translation_key="tv_channel_name", ) ], }, @@ -689,7 +677,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.TVOC_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.TVOC_LEVEL, - name="Tvoc Measurement", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, ) @@ -700,7 +688,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ULTRAVIOLET_INDEX: [ SmartThingsSensorEntityDescription( key=Attribute.ULTRAVIOLET_INDEX, - name="Ultraviolet Index", + translation_key="uv_index", state_class=SensorStateClass.MEASUREMENT, ) ] @@ -709,7 +697,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.VOLTAGE: [ SmartThingsSensorEntityDescription( key=Attribute.VOLTAGE, - name="Voltage Measurement", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ) @@ -720,7 +707,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.WASHER_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.WASHER_MODE, - name="Washer Mode", + translation_key="washer_mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] @@ -729,19 +716,19 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, - name="Washer Machine State", + translation_key="washer_machine_state", ) ], Attribute.WASHER_JOB_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.WASHER_JOB_STATE, - name="Washer Job State", + translation_key="washer_job_state", ) ], Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( key=Attribute.COMPLETION_TIME, - name="Washer Completion Time", + translation_key="completion_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) @@ -795,7 +782,6 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): ) -> None: """Init the class.""" super().__init__(client, device, {capability}) - self._attr_name = f"{device.device.label} {entity_description.name}" self._attr_unique_id = f"{device.device.device_id}{entity_description.unique_id_separator}{entity_description.key}" self._attribute = attribute self.capability = capability diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 5112d819026..9cfc6176d20 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -25,5 +25,188 @@ "reauth_account_mismatch": "Authenticated account does not match the account to be reauthenticated. Please log in with the correct account and pick the right location.", "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location." } + }, + "entity": { + "binary_sensor": { + "acceleration": { + "name": "Acceleration" + }, + "filter_status": { + "name": "Filter status" + }, + "valve": { + "name": "Valve" + } + }, + "sensor": { + "lighting_mode": { + "name": "Activity lighting mode" + }, + "air_conditioner_mode": { + "name": "Air conditioner mode" + }, + "air_quality": { + "name": "Air quality" + }, + "alarm": { + "name": "Alarm" + }, + "audio_volume": { + "name": "Volume" + }, + "body_mass_index": { + "name": "Body mass index" + }, + "body_weight": { + "name": "Body weight" + }, + "carbon_monoxide_detector": { + "name": "Carbon monoxide detector" + }, + "dishwasher_machine_state": { + "name": "Machine state" + }, + "dishwasher_job_state": { + "name": "Job state" + }, + "completion_time": { + "name": "Completion time" + }, + "dryer_mode": { + "name": "Dryer mode" + }, + "dryer_machine_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" + }, + "dryer_job_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" + }, + "dust_level": { + "name": "Dust level" + }, + "fine_dust_level": { + "name": "Fine dust level" + }, + "equivalent_carbon_dioxide": { + "name": "Equivalent carbon dioxide" + }, + "formaldehyde": { + "name": "Formaldehyde" + }, + "gas_meter": { + "name": "Gas meter" + }, + "gas_meter_calorific": { + "name": "Gas meter calorific" + }, + "gas_meter_time": { + "name": "Gas meter time" + }, + "infrared_level": { + "name": "Infrared level" + }, + "media_input_source": { + "name": "Media input source" + }, + "media_playback_repeat": { + "name": "Media playback repeat" + }, + "media_playback_shuffle": { + "name": "Media playback shuffle" + }, + "media_playback_status": { + "name": "Media playback status" + }, + "odor_sensor": { + "name": "Odor sensor" + }, + "oven_mode": { + "name": "Oven mode" + }, + "oven_machine_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" + }, + "oven_job_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" + }, + "oven_setpoint": { + "name": "Set point" + }, + "energy_difference": { + "name": "Energy difference" + }, + "power_energy": { + "name": "Power energy" + }, + "energy_saved": { + "name": "Energy saved" + }, + "power_source": { + "name": "Power source" + }, + "refrigeration_setpoint": { + "name": "[%key:component::smartthings::entity::sensor::oven_setpoint::name%]" + }, + "robot_cleaner_cleaning_mode": { + "name": "Cleaning mode" + }, + "robot_cleaner_movement": { + "name": "Movement" + }, + "robot_cleaner_turbo_mode": { + "name": "Turbo mode" + }, + "link_quality": { + "name": "Link quality" + }, + "smoke_detector": { + "name": "Smoke detector" + }, + "thermostat_cooling_setpoint": { + "name": "Cooling set point" + }, + "thermostat_fan_mode": { + "name": "Fan mode" + }, + "thermostat_heating_setpoint": { + "name": "Heating set point" + }, + "thermostat_mode": { + "name": "Mode" + }, + "thermostat_operating_state": { + "name": "Operating state" + }, + "thermostat_setpoint": { + "name": "[%key:component::smartthings::entity::sensor::oven_setpoint::name%]" + }, + "x_coordinate": { + "name": "X coordinate" + }, + "y_coordinate": { + "name": "Y coordinate" + }, + "z_coordinate": { + "name": "Z coordinate" + }, + "tv_channel": { + "name": "TV channel" + }, + "tv_channel_name": { + "name": "TV channel name" + }, + "uv_index": { + "name": "UV index" + }, + "washer_mode": { + "name": "Washer mode" + }, + "washer_machine_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" + }, + "washer_job_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" + } + } } } diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index d8cd9f1f956..380005f1b93 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -48,6 +48,8 @@ async def async_setup_entry( class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): """Define a SmartThings switch.""" + _attr_name = None + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.execute_device_command( diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 1317c19edd7..27a5e38a123 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -13,7 +13,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.2nd_floor_hallway_motion', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,7 +24,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '2nd Floor Hallway motion', + 'original_name': 'Motion', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -37,7 +37,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'motion', - 'friendly_name': '2nd Floor Hallway motion', + 'friendly_name': '2nd Floor Hallway Motion', }), 'context': , 'entity_id': 'binary_sensor.2nd_floor_hallway_motion', @@ -61,7 +61,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.2nd_floor_hallway_sound', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -72,7 +72,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '2nd Floor Hallway sound', + 'original_name': 'Sound', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -85,7 +85,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'sound', - 'friendly_name': '2nd Floor Hallway sound', + 'friendly_name': '2nd Floor Hallway Sound', }), 'context': , 'entity_id': 'binary_sensor.2nd_floor_hallway_sound', @@ -95,7 +95,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_contact-entry] +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -108,8 +108,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.front_door_open_closed_sensor_contact', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.front_door_open_closed_sensor_door', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -120,7 +120,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '.Front Door Open/Closed Sensor contact', + 'original_name': 'Door', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -129,21 +129,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_contact-state] +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_door-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', - 'friendly_name': '.Front Door Open/Closed Sensor contact', + 'friendly_name': '.Front Door Open/Closed Sensor Door', }), 'context': , - 'entity_id': 'binary_sensor.front_door_open_closed_sensor_contact', + 'entity_id': 'binary_sensor.front_door_open_closed_sensor_door', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_contact-entry] +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -156,8 +156,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.refrigerator_contact', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.refrigerator_door', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -168,7 +168,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator contact', + 'original_name': 'Door', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -177,14 +177,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_contact-state] +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', - 'friendly_name': 'Refrigerator contact', + 'friendly_name': 'Refrigerator Door', }), 'context': , - 'entity_id': 'binary_sensor.refrigerator_contact', + 'entity_id': 'binary_sensor.refrigerator_door', 'last_changed': , 'last_reported': , 'last_updated': , @@ -205,7 +205,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.child_bedroom_motion', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -216,7 +216,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Child Bedroom motion', + 'original_name': 'Motion', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -229,7 +229,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'motion', - 'friendly_name': 'Child Bedroom motion', + 'friendly_name': 'Child Bedroom Motion', }), 'context': , 'entity_id': 'binary_sensor.child_bedroom_motion', @@ -253,7 +253,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.child_bedroom_presence', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -264,7 +264,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Child Bedroom presence', + 'original_name': 'Presence', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -277,7 +277,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'presence', - 'friendly_name': 'Child Bedroom presence', + 'friendly_name': 'Child Bedroom Presence', }), 'context': , 'entity_id': 'binary_sensor.child_bedroom_presence', @@ -301,7 +301,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.iphone_presence', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -312,7 +312,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'iPhone presence', + 'original_name': 'Presence', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -325,7 +325,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'presence', - 'friendly_name': 'iPhone presence', + 'friendly_name': 'iPhone Presence', }), 'context': , 'entity_id': 'binary_sensor.iphone_presence', @@ -349,7 +349,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.deck_door_acceleration', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -360,11 +360,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Deck Door acceleration', + 'original_name': 'Acceleration', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'acceleration', 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.acceleration', 'unit_of_measurement': None, }) @@ -373,7 +373,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'moving', - 'friendly_name': 'Deck Door acceleration', + 'friendly_name': 'Deck Door Acceleration', }), 'context': , 'entity_id': 'binary_sensor.deck_door_acceleration', @@ -383,7 +383,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_contact-entry] +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -396,8 +396,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.deck_door_contact', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.deck_door_door', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -408,7 +408,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Deck Door contact', + 'original_name': 'Door', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -417,14 +417,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_contact-state] +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_door-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', - 'friendly_name': 'Deck Door contact', + 'friendly_name': 'Deck Door Door', }), 'context': , - 'entity_id': 'binary_sensor.deck_door_contact', + 'entity_id': 'binary_sensor.deck_door_door', 'last_changed': , 'last_reported': , 'last_updated': , @@ -445,7 +445,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.volvo_valve', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -456,11 +456,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'volvo valve', + 'original_name': 'Valve', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'valve', 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3.valve', 'unit_of_measurement': None, }) @@ -469,7 +469,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'opening', - 'friendly_name': 'volvo valve', + 'friendly_name': 'volvo Valve', }), 'context': , 'entity_id': 'binary_sensor.volvo_valve', @@ -479,7 +479,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_water-entry] +# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_moisture-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -492,8 +492,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.asd_water', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.asd_moisture', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -504,7 +504,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'asd water', + 'original_name': 'Moisture', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -513,14 +513,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_water-state] +# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_moisture-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'moisture', - 'friendly_name': 'asd water', + 'friendly_name': 'asd Moisture', }), 'context': , - 'entity_id': 'binary_sensor.asd_water', + 'entity_id': 'binary_sensor.asd_moisture', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index bd76637cfb7..ba32776011a 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -35,7 +35,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.ac_office_granit', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -46,7 +46,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Office Granit', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -140,7 +140,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.aire_dormitorio_principal', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -151,7 +151,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -234,7 +234,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.main_floor', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -245,7 +245,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Main Floor', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -307,7 +307,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.asd', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -318,7 +318,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'asd', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , diff --git a/tests/components/smartthings/snapshots/test_cover.ambr b/tests/components/smartthings/snapshots/test_cover.ambr index 6283e4fef04..102be416cea 100644 --- a/tests/components/smartthings/snapshots/test_cover.ambr +++ b/tests/components/smartthings/snapshots/test_cover.ambr @@ -13,7 +13,7 @@ 'domain': 'cover', 'entity_category': None, 'entity_id': 'cover.curtain_1a', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,7 +24,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Curtain 1A', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -63,7 +63,7 @@ 'domain': 'cover', 'entity_category': None, 'entity_id': 'cover.microwave', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -74,7 +74,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Microwave', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , diff --git a/tests/components/smartthings/snapshots/test_fan.ambr b/tests/components/smartthings/snapshots/test_fan.ambr index 400ceef8390..33caffcacc6 100644 --- a/tests/components/smartthings/snapshots/test_fan.ambr +++ b/tests/components/smartthings/snapshots/test_fan.ambr @@ -21,7 +21,7 @@ 'domain': 'fan', 'entity_category': None, 'entity_id': 'fan.fake_fan', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -32,7 +32,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Fake fan', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , diff --git a/tests/components/smartthings/snapshots/test_light.ambr b/tests/components/smartthings/snapshots/test_light.ambr index 8e7f424f658..8766811c443 100644 --- a/tests/components/smartthings/snapshots/test_light.ambr +++ b/tests/components/smartthings/snapshots/test_light.ambr @@ -17,7 +17,7 @@ 'domain': 'light', 'entity_category': None, 'entity_id': 'light.dimmer_debian', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -28,7 +28,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Dimmer Debian', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -74,7 +74,7 @@ 'domain': 'light', 'entity_category': None, 'entity_id': 'light.basement_exit_light', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -85,7 +85,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Basement Exit Light', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -135,7 +135,7 @@ 'domain': 'light', 'entity_category': None, 'entity_id': 'light.bathroom_spot', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -146,7 +146,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Bathroom spot', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -216,7 +216,7 @@ 'domain': 'light', 'entity_category': None, 'entity_id': 'light.standing_light', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -227,7 +227,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Standing light', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , diff --git a/tests/components/smartthings/snapshots/test_lock.ambr b/tests/components/smartthings/snapshots/test_lock.ambr index 94370f8570b..2cf9688c3dd 100644 --- a/tests/components/smartthings/snapshots/test_lock.ambr +++ b/tests/components/smartthings/snapshots/test_lock.ambr @@ -13,7 +13,7 @@ 'domain': 'lock', 'entity_category': None, 'entity_id': 'lock.basement_door_lock', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,7 +24,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Basement Door Lock', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 92928b9606b..2fca1a8d108 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_meter-entry] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -14,8 +14,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeotec_energy_monitor_energy_meter', - 'has_entity_name': False, + 'entity_id': 'sensor.aeotec_energy_monitor_energy_2', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -26,7 +26,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aeotec Energy Monitor Energy Meter', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -35,23 +35,23 @@ 'unit_of_measurement': 'kWh', }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_meter-state] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Aeotec Energy Monitor Energy Meter', + 'friendly_name': 'Aeotec Energy Monitor Energy', 'state_class': , 'unit_of_measurement': 'kWh', }), 'context': , - 'entity_id': 'sensor.aeotec_energy_monitor_energy_meter', + 'entity_id': 'sensor.aeotec_energy_monitor_energy_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '19978.536', }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power_meter-entry] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -66,8 +66,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeotec_energy_monitor_power_meter', - 'has_entity_name': False, + 'entity_id': 'sensor.aeotec_energy_monitor_power', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -78,7 +78,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aeotec Energy Monitor Power Meter', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -87,23 +87,23 @@ 'unit_of_measurement': 'W', }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power_meter-state] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Aeotec Energy Monitor Power Meter', + 'friendly_name': 'Aeotec Energy Monitor Power', 'state_class': , 'unit_of_measurement': 'W', }), 'context': , - 'entity_id': 'sensor.aeotec_energy_monitor_power_meter', + 'entity_id': 'sensor.aeotec_energy_monitor_power', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2859.743', }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage_measurement-entry] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -118,8 +118,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeotec_energy_monitor_voltage_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.aeotec_energy_monitor_voltage', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -130,7 +130,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aeotec Energy Monitor Voltage Measurement', + 'original_name': 'Voltage', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -139,22 +139,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage_measurement-state] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', - 'friendly_name': 'Aeotec Energy Monitor Voltage Measurement', + 'friendly_name': 'Aeotec Energy Monitor Voltage', 'state_class': , }), 'context': , - 'entity_id': 'sensor.aeotec_energy_monitor_voltage_measurement', + 'entity_id': 'sensor.aeotec_energy_monitor_voltage', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy_meter-entry] +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -169,8 +169,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeon_energy_monitor_energy_meter', - 'has_entity_name': False, + 'entity_id': 'sensor.aeon_energy_monitor_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -181,7 +181,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aeon Energy Monitor Energy Meter', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -190,23 +190,23 @@ 'unit_of_measurement': 'kWh', }) # --- -# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy_meter-state] +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Aeon Energy Monitor Energy Meter', + 'friendly_name': 'Aeon Energy Monitor Energy', 'state_class': , 'unit_of_measurement': 'kWh', }), 'context': , - 'entity_id': 'sensor.aeon_energy_monitor_energy_meter', + 'entity_id': 'sensor.aeon_energy_monitor_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1930.362', }) # --- -# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power_meter-entry] +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -221,8 +221,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeon_energy_monitor_power_meter', - 'has_entity_name': False, + 'entity_id': 'sensor.aeon_energy_monitor_power', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -233,7 +233,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aeon Energy Monitor Power Meter', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -242,16 +242,16 @@ 'unit_of_measurement': 'W', }) # --- -# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power_meter-state] +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Aeon Energy Monitor Power Meter', + 'friendly_name': 'Aeon Energy Monitor Power', 'state_class': , 'unit_of_measurement': 'W', }), 'context': , - 'entity_id': 'sensor.aeon_energy_monitor_power_meter', + 'entity_id': 'sensor.aeon_energy_monitor_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -272,7 +272,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.2nd_floor_hallway_alarm', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -283,11 +283,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '2nd Floor Hallway Alarm', + 'original_name': 'Alarm', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'alarm', 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.alarm', 'unit_of_measurement': None, }) @@ -319,7 +319,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.2nd_floor_hallway_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -330,7 +330,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '2nd Floor Hallway Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -354,7 +354,7 @@ 'state': '100', }) # --- -# name: test_all_entities[centralite][sensor.dimmer_debian_power_meter-entry] +# name: test_all_entities[centralite][sensor.dimmer_debian_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -369,8 +369,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dimmer_debian_power_meter', - 'has_entity_name': False, + 'entity_id': 'sensor.dimmer_debian_power', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -381,7 +381,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dimmer Debian Power Meter', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -390,16 +390,16 @@ 'unit_of_measurement': 'W', }) # --- -# name: test_all_entities[centralite][sensor.dimmer_debian_power_meter-state] +# name: test_all_entities[centralite][sensor.dimmer_debian_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Dimmer Debian Power Meter', + 'friendly_name': 'Dimmer Debian Power', 'state_class': , 'unit_of_measurement': 'W', }), 'context': , - 'entity_id': 'sensor.dimmer_debian_power_meter', + 'entity_id': 'sensor.dimmer_debian_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -420,7 +420,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.front_door_open_closed_sensor_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -431,7 +431,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '.Front Door Open/Closed Sensor Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -455,7 +455,7 @@ 'state': '100', }) # --- -# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature_measurement-entry] +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -470,8 +470,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.front_door_open_closed_sensor_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.front_door_open_closed_sensor_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -482,7 +482,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '.Front Door Open/Closed Sensor Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -491,16 +491,16 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature_measurement-state] +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': '.Front Door Open/Closed Sensor Temperature Measurement', + 'friendly_name': '.Front Door Open/Closed Sensor Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.front_door_open_closed_sensor_temperature_measurement', + 'entity_id': 'sensor.front_door_open_closed_sensor_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -523,7 +523,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.ac_office_granit_air_quality', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -534,11 +534,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Office Granit Air Quality', + 'original_name': 'Air quality', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'air_quality', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.airQuality', 'unit_of_measurement': 'CAQI', }) @@ -546,7 +546,7 @@ # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_air_quality-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Air Quality', + 'friendly_name': 'AC Office Granit Air quality', 'state_class': , 'unit_of_measurement': 'CAQI', }), @@ -558,58 +558,6 @@ 'state': 'unknown', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_deltaenergy-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.ac_office_granit_deltaenergy', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'AC Office Granit deltaEnergy', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.deltaEnergy_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_deltaenergy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'AC Office Granit deltaEnergy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_deltaenergy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.4', - }) -# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_dust_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -626,7 +574,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.ac_office_granit_dust_level', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -637,11 +585,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Office Granit Dust Level', + 'original_name': 'Dust level', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'dust_level', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.dustLevel', 'unit_of_measurement': 'μg/m^3', }) @@ -649,7 +597,7 @@ # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_dust_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Dust Level', + 'friendly_name': 'AC Office Granit Dust level', 'state_class': , 'unit_of_measurement': 'μg/m^3', }), @@ -677,7 +625,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.ac_office_granit_energy', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -688,7 +636,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Office Granit energy', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -701,7 +649,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'AC Office Granit energy', + 'friendly_name': 'AC Office Granit Energy', 'state_class': , 'unit_of_measurement': , }), @@ -713,7 +661,7 @@ 'state': '2247.3', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energysaved-entry] +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -728,8 +676,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_energysaved', - 'has_entity_name': False, + 'entity_id': 'sensor.ac_office_granit_energy_difference', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -740,25 +688,77 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Office Granit energySaved', + 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energySaved_meter', + 'translation_key': 'energy_difference', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energysaved-state] +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'AC Office Granit energySaved', + 'friendly_name': 'AC Office Granit Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.ac_office_granit_energysaved', + 'entity_id': 'sensor.ac_office_granit_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.4', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy_saved-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.ac_office_granit_energy_saved', + '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 saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_energy_saved', 'last_changed': , 'last_reported': , 'last_updated': , @@ -781,7 +781,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.ac_office_granit_fine_dust_level', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -792,11 +792,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Office Granit Fine Dust Level', + 'original_name': 'Fine dust level', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'fine_dust_level', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.fineDustLevel', 'unit_of_measurement': 'μg/m^3', }) @@ -804,7 +804,7 @@ # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_fine_dust_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Fine Dust Level', + 'friendly_name': 'AC Office Granit Fine dust level', 'state_class': , 'unit_of_measurement': 'μg/m^3', }), @@ -816,6 +816,58 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_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.ac_office_granit_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'AC Office Granit Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -832,7 +884,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.ac_office_granit_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -843,7 +895,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Office Granit power', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -856,7 +908,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'AC Office Granit power', + 'friendly_name': 'AC Office Granit Power', 'power_consumption_end': '2025-02-09T16:15:33Z', 'power_consumption_start': '2025-02-09T15:45:29Z', 'state_class': , @@ -870,7 +922,7 @@ 'state': '0', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_powerenergy-entry] +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -885,8 +937,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_powerenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.ac_office_granit_power_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -897,32 +949,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Office Granit powerEnergy', + 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'power_energy', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_powerenergy-state] +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'AC Office Granit powerEnergy', + 'friendly_name': 'AC Office Granit Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.ac_office_granit_powerenergy', + 'entity_id': 'sensor.ac_office_granit_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_relative_humidity_measurement-entry] +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -937,60 +989,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_relative_humidity_measurement', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'AC Office Granit Relative Humidity Measurement', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.humidity', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_relative_humidity_measurement-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'AC Office Granit Relative Humidity Measurement', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_relative_humidity_measurement', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '60', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature_measurement-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.ac_office_granit_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.ac_office_granit_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1001,7 +1001,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Office Granit Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -1010,16 +1010,16 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature_measurement-state] +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'AC Office Granit Temperature Measurement', + 'friendly_name': 'AC Office Granit Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.ac_office_granit_temperature_measurement', + 'entity_id': 'sensor.ac_office_granit_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1040,7 +1040,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.ac_office_granit_volume', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1051,11 +1051,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Office Granit Volume', + 'original_name': 'Volume', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'audio_volume', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.volume', 'unit_of_measurement': '%', }) @@ -1090,7 +1090,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_air_quality', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1101,11 +1101,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Air Quality', + 'original_name': 'Air quality', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'air_quality', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.airQuality', 'unit_of_measurement': 'CAQI', }) @@ -1113,7 +1113,7 @@ # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_air_quality-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Air Quality', + 'friendly_name': 'Aire Dormitorio Principal Air quality', 'state_class': , 'unit_of_measurement': 'CAQI', }), @@ -1125,58 +1125,6 @@ 'state': 'unknown', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_deltaenergy-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.aire_dormitorio_principal_deltaenergy', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal deltaEnergy', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.deltaEnergy_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_deltaenergy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Aire Dormitorio Principal deltaEnergy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_deltaenergy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_dust_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1193,7 +1141,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_dust_level', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1204,11 +1152,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Dust Level', + 'original_name': 'Dust level', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'dust_level', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.dustLevel', 'unit_of_measurement': None, }) @@ -1216,7 +1164,7 @@ # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_dust_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Dust Level', + 'friendly_name': 'Aire Dormitorio Principal Dust level', 'state_class': , }), 'context': , @@ -1243,7 +1191,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_energy', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1254,7 +1202,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal energy', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -1267,7 +1215,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Aire Dormitorio Principal energy', + 'friendly_name': 'Aire Dormitorio Principal Energy', 'state_class': , 'unit_of_measurement': , }), @@ -1279,7 +1227,7 @@ 'state': '13.836', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energysaved-entry] +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1294,8 +1242,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_energysaved', - 'has_entity_name': False, + 'entity_id': 'sensor.aire_dormitorio_principal_energy_difference', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1306,25 +1254,77 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal energySaved', + 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energySaved_meter', + 'translation_key': 'energy_difference', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energysaved-state] +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Aire Dormitorio Principal energySaved', + 'friendly_name': 'Aire Dormitorio Principal Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_energysaved', + 'entity_id': 'sensor.aire_dormitorio_principal_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy_saved-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.aire_dormitorio_principal_energy_saved', + '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 saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_energy_saved', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1347,7 +1347,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_fine_dust_level', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1358,11 +1358,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Fine Dust Level', + 'original_name': 'Fine dust level', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'fine_dust_level', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.fineDustLevel', 'unit_of_measurement': None, }) @@ -1370,7 +1370,7 @@ # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_fine_dust_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Fine Dust Level', + 'friendly_name': 'Aire Dormitorio Principal Fine dust level', 'state_class': , }), 'context': , @@ -1381,6 +1381,58 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_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.aire_dormitorio_principal_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Aire Dormitorio Principal Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_odor_sensor-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1395,7 +1447,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_odor_sensor', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1406,11 +1458,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Odor Sensor', + 'original_name': 'Odor sensor', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'odor_sensor', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.odorLevel', 'unit_of_measurement': None, }) @@ -1418,7 +1470,7 @@ # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_odor_sensor-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Odor Sensor', + 'friendly_name': 'Aire Dormitorio Principal Odor sensor', }), 'context': , 'entity_id': 'sensor.aire_dormitorio_principal_odor_sensor', @@ -1444,7 +1496,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1455,7 +1507,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal power', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -1468,7 +1520,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Aire Dormitorio Principal power', + 'friendly_name': 'Aire Dormitorio Principal Power', 'power_consumption_end': '2025-02-09T17:02:44Z', 'power_consumption_start': '2025-02-09T16:08:15Z', 'state_class': , @@ -1482,7 +1534,7 @@ 'state': '0', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_powerenergy-entry] +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1497,8 +1549,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_powerenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.aire_dormitorio_principal_power_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1509,32 +1561,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal powerEnergy', + 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'power_energy', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_powerenergy-state] +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Aire Dormitorio Principal powerEnergy', + 'friendly_name': 'Aire Dormitorio Principal Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_powerenergy', + 'entity_id': 'sensor.aire_dormitorio_principal_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_relative_humidity_measurement-entry] +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1549,60 +1601,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_relative_humidity_measurement', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Relative Humidity Measurement', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.humidity', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_relative_humidity_measurement-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'Aire Dormitorio Principal Relative Humidity Measurement', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_relative_humidity_measurement', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '42', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature_measurement-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.aire_dormitorio_principal_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.aire_dormitorio_principal_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1613,7 +1613,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -1622,16 +1622,16 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature_measurement-state] +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Aire Dormitorio Principal Temperature Measurement', + 'friendly_name': 'Aire Dormitorio Principal Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_temperature_measurement', + 'entity_id': 'sensor.aire_dormitorio_principal_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1652,7 +1652,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_volume', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1663,11 +1663,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Volume', + 'original_name': 'Volume', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'audio_volume', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.volume', 'unit_of_measurement': '%', }) @@ -1686,7 +1686,7 @@ 'state': '0', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_completion_time-entry] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1699,8 +1699,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.microwave_oven_completion_time', - 'has_entity_name': False, + 'entity_id': 'sensor.microwave_completion_time', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1711,29 +1711,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Microwave Oven Completion Time', + 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'completion_time', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.completionTime', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_completion_time-state] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_completion_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave Oven Completion Time', + 'friendly_name': 'Microwave Completion time', }), 'context': , - 'entity_id': 'sensor.microwave_oven_completion_time', + 'entity_id': 'sensor.microwave_completion_time', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2025-02-08T21:13:36.184Z', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_job_state-entry] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_job_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1746,8 +1746,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.microwave_oven_job_state', - 'has_entity_name': False, + 'entity_id': 'sensor.microwave_job_state', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1758,29 +1758,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Microwave Oven Job State', + 'original_name': 'Job state', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'oven_job_state', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenJobState', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_job_state-state] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_job_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave Oven Job State', + 'friendly_name': 'Microwave Job state', }), 'context': , - 'entity_id': 'sensor.microwave_oven_job_state', + 'entity_id': 'sensor.microwave_job_state', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'ready', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_machine_state-entry] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_machine_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1793,8 +1793,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.microwave_oven_machine_state', - 'has_entity_name': False, + 'entity_id': 'sensor.microwave_machine_state', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1805,22 +1805,22 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Microwave Oven Machine State', + 'original_name': 'Machine state', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'oven_machine_state', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.machineState', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_machine_state-state] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_machine_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave Oven Machine State', + 'friendly_name': 'Microwave Machine state', }), 'context': , - 'entity_id': 'sensor.microwave_oven_machine_state', + 'entity_id': 'sensor.microwave_machine_state', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1841,7 +1841,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.microwave_oven_mode', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1852,11 +1852,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Microwave Oven Mode', + 'original_name': 'Oven mode', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'oven_mode', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenMode', 'unit_of_measurement': None, }) @@ -1864,7 +1864,7 @@ # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave Oven Mode', + 'friendly_name': 'Microwave Oven mode', }), 'context': , 'entity_id': 'sensor.microwave_oven_mode', @@ -1874,7 +1874,7 @@ 'state': 'Others', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_set_point-entry] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_set_point-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1887,8 +1887,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.microwave_oven_set_point', - 'has_entity_name': False, + 'entity_id': 'sensor.microwave_set_point', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1899,29 +1899,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Microwave Oven Set Point', + 'original_name': 'Set point', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'oven_setpoint', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenSetpoint', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_set_point-state] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_set_point-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave Oven Set Point', + 'friendly_name': 'Microwave Set point', }), 'context': , - 'entity_id': 'sensor.microwave_oven_set_point', + 'entity_id': 'sensor.microwave_set_point', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature_measurement-entry] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1936,8 +1936,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.microwave_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.microwave_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1948,7 +1948,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Microwave Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -1957,30 +1957,28 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature_measurement-state] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Microwave Temperature Measurement', + 'friendly_name': 'Microwave Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.microwave_temperature_measurement', + 'entity_id': 'sensor.microwave_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '-17', }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_deltaenergy-entry] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_cooling_set_point-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1988,8 +1986,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.refrigerator_deltaenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.refrigerator_cooling_set_point', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1998,31 +1996,29 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator deltaEnergy', + 'original_name': 'Cooling set point', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.deltaEnergy_meter', - 'unit_of_measurement': , + 'translation_key': 'thermostat_cooling_setpoint', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.coolingSetpoint', + 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_deltaenergy-state] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_cooling_set_point-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Refrigerator deltaEnergy', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Cooling set point', }), 'context': , - 'entity_id': 'sensor.refrigerator_deltaenergy', + 'entity_id': 'sensor.refrigerator_cooling_set_point', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.007', + 'state': 'unknown', }) # --- # name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy-entry] @@ -2041,7 +2037,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.refrigerator_energy', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2052,7 +2048,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator energy', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -2065,7 +2061,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Refrigerator energy', + 'friendly_name': 'Refrigerator Energy', 'state_class': , 'unit_of_measurement': , }), @@ -2077,7 +2073,7 @@ 'state': '1568.087', }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energysaved-entry] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2092,8 +2088,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.refrigerator_energysaved', - 'has_entity_name': False, + 'entity_id': 'sensor.refrigerator_energy_difference', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2104,25 +2100,77 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator energySaved', + 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energySaved_meter', + 'translation_key': 'energy_difference', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energysaved-state] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Refrigerator energySaved', + 'friendly_name': 'Refrigerator Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.refrigerator_energysaved', + 'entity_id': 'sensor.refrigerator_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.007', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy_saved-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.refrigerator_energy_saved', + '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 saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_energy_saved', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2145,7 +2193,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.refrigerator_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2156,7 +2204,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator power', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -2169,7 +2217,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Refrigerator power', + 'friendly_name': 'Refrigerator Power', 'power_consumption_end': '2025-02-09T17:49:00Z', 'power_consumption_start': '2025-02-09T17:38:01Z', 'state_class': , @@ -2183,7 +2231,7 @@ 'state': '6', }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_powerenergy-entry] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2198,8 +2246,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.refrigerator_powerenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.refrigerator_power_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2210,32 +2258,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator powerEnergy', + 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'power_energy', 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_powerenergy-state] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Refrigerator powerEnergy', + 'friendly_name': 'Refrigerator Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.refrigerator_powerenergy', + 'entity_id': 'sensor.refrigerator_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0135559777781698', }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature_measurement-entry] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2250,8 +2298,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.refrigerator_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.refrigerator_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2262,7 +2310,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -2271,63 +2319,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature_measurement-state] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Refrigerator Temperature Measurement', + 'friendly_name': 'Refrigerator Temperature', 'state_class': , }), 'context': , - 'entity_id': 'sensor.refrigerator_temperature_measurement', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_thermostat_cooling_setpoint-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': None, - 'entity_id': 'sensor.refrigerator_thermostat_cooling_setpoint', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Refrigerator Thermostat Cooling Setpoint', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.coolingSetpoint', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_thermostat_cooling_setpoint-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Refrigerator Thermostat Cooling Setpoint', - }), - 'context': , - 'entity_id': 'sensor.refrigerator_thermostat_cooling_setpoint', + 'entity_id': 'sensor.refrigerator_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2348,7 +2348,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.robot_vacuum_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2359,7 +2359,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Robot vacuum Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -2383,7 +2383,7 @@ 'state': '100', }) # --- -# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_cleaning_mode-entry] +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_cleaning_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2396,8 +2396,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.robot_vacuum_robot_cleaner_cleaning_mode', - 'has_entity_name': False, + 'entity_id': 'sensor.robot_vacuum_cleaning_mode', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2408,29 +2408,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Robot vacuum Robot Cleaner Cleaning Mode', + 'original_name': 'Cleaning mode', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'robot_cleaner_cleaning_mode', 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerCleaningMode', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_cleaning_mode-state] +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_cleaning_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Robot vacuum Robot Cleaner Cleaning Mode', + 'friendly_name': 'Robot vacuum Cleaning mode', }), 'context': , - 'entity_id': 'sensor.robot_vacuum_robot_cleaner_cleaning_mode', + 'entity_id': 'sensor.robot_vacuum_cleaning_mode', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'stop', }) # --- -# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_movement-entry] +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_movement-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2443,8 +2443,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.robot_vacuum_robot_cleaner_movement', - 'has_entity_name': False, + 'entity_id': 'sensor.robot_vacuum_movement', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2455,29 +2455,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Robot vacuum Robot Cleaner Movement', + 'original_name': 'Movement', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'robot_cleaner_movement', 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerMovement', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_movement-state] +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_movement-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Robot vacuum Robot Cleaner Movement', + 'friendly_name': 'Robot vacuum Movement', }), 'context': , - 'entity_id': 'sensor.robot_vacuum_robot_cleaner_movement', + 'entity_id': 'sensor.robot_vacuum_movement', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'idle', }) # --- -# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_turbo_mode-entry] +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_turbo_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2490,8 +2490,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.robot_vacuum_robot_cleaner_turbo_mode', - 'has_entity_name': False, + 'entity_id': 'sensor.robot_vacuum_turbo_mode', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2502,81 +2502,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Robot vacuum Robot Cleaner Turbo Mode', + 'original_name': 'Turbo mode', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'robot_cleaner_turbo_mode', 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerTurboMode', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_turbo_mode-state] +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_turbo_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Robot vacuum Robot Cleaner Turbo Mode', + 'friendly_name': 'Robot vacuum Turbo mode', }), 'context': , - 'entity_id': 'sensor.robot_vacuum_robot_cleaner_turbo_mode', + 'entity_id': 'sensor.robot_vacuum_turbo_mode', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_deltaenergy-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.dishwasher_deltaenergy', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Dishwasher deltaEnergy', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.deltaEnergy_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_deltaenergy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Dishwasher deltaEnergy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.dishwasher_deltaenergy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_completion_time-entry] +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2589,8 +2537,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dishwasher_dishwasher_completion_time', - 'has_entity_name': False, + 'entity_id': 'sensor.dishwasher_completion_time', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2601,123 +2549,29 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dishwasher Dishwasher Completion Time', + 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'completion_time', 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.completionTime', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_completion_time-state] +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_completion_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', - 'friendly_name': 'Dishwasher Dishwasher Completion Time', + 'friendly_name': 'Dishwasher Completion time', }), 'context': , - 'entity_id': 'sensor.dishwasher_dishwasher_completion_time', + 'entity_id': 'sensor.dishwasher_completion_time', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2025-02-08T22:49:26+00:00', }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_job_state-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': None, - 'entity_id': 'sensor.dishwasher_dishwasher_job_state', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Dishwasher Dishwasher Job State', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_job_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dishwasher Dishwasher Job State', - }), - 'context': , - 'entity_id': 'sensor.dishwasher_dishwasher_job_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_machine_state-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': None, - 'entity_id': 'sensor.dishwasher_dishwasher_machine_state', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Dishwasher Dishwasher Machine State', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_machine_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dishwasher Dishwasher Machine State', - }), - 'context': , - 'entity_id': 'sensor.dishwasher_dishwasher_machine_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stop', - }) -# --- # name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2734,7 +2588,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.dishwasher_energy', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2745,7 +2599,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dishwasher energy', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -2758,7 +2612,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Dishwasher energy', + 'friendly_name': 'Dishwasher Energy', 'state_class': , 'unit_of_measurement': , }), @@ -2770,7 +2624,7 @@ 'state': '101.6', }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energysaved-entry] +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2785,8 +2639,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dishwasher_energysaved', - 'has_entity_name': False, + 'entity_id': 'sensor.dishwasher_energy_difference', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2797,31 +2651,177 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dishwasher energySaved', + 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energySaved_meter', + 'translation_key': 'energy_difference', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energysaved-state] +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Dishwasher energySaved', + 'friendly_name': 'Dishwasher Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.dishwasher_energysaved', + 'entity_id': 'sensor.dishwasher_energy_difference', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy_saved-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.dishwasher_energy_saved', + '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 saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_job_state-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': None, + 'entity_id': 'sensor.dishwasher_job_state', + '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': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dishwasher_job_state', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Job state', + }), + 'context': , + 'entity_id': 'sensor.dishwasher_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_machine_state-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': None, + 'entity_id': 'sensor.dishwasher_machine_state', + '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': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dishwasher_machine_state', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Machine state', + }), + 'context': , + 'entity_id': 'sensor.dishwasher_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- # name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2838,7 +2838,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.dishwasher_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2849,7 +2849,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dishwasher power', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -2862,7 +2862,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Dishwasher power', + 'friendly_name': 'Dishwasher Power', 'power_consumption_end': '2025-02-08T20:21:26Z', 'power_consumption_start': '2025-02-08T20:21:21Z', 'state_class': , @@ -2876,7 +2876,7 @@ 'state': '0', }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_powerenergy-entry] +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2891,8 +2891,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dishwasher_powerenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.dishwasher_power_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2903,84 +2903,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dishwasher powerEnergy', + 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'power_energy', 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_powerenergy-state] +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Dishwasher powerEnergy', + 'friendly_name': 'Dishwasher Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.dishwasher_powerenergy', + 'entity_id': 'sensor.dishwasher_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_deltaenergy-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.dryer_deltaenergy', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Dryer deltaEnergy', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.deltaEnergy_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_deltaenergy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Dryer deltaEnergy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.dryer_deltaenergy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_completion_time-entry] +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2993,8 +2941,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dryer_dryer_completion_time', - 'has_entity_name': False, + 'entity_id': 'sensor.dryer_completion_time', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3005,123 +2953,29 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dryer Dryer Completion Time', + 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'completion_time', 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.completionTime', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_completion_time-state] +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_completion_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', - 'friendly_name': 'Dryer Dryer Completion Time', + 'friendly_name': 'Dryer Completion time', }), 'context': , - 'entity_id': 'sensor.dryer_dryer_completion_time', + 'entity_id': 'sensor.dryer_completion_time', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2025-02-08T19:25:10+00:00', }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_job_state-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': None, - 'entity_id': 'sensor.dryer_dryer_job_state', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Dryer Dryer Job State', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_job_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dryer Dryer Job State', - }), - 'context': , - 'entity_id': 'sensor.dryer_dryer_job_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'none', - }) -# --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_machine_state-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': None, - 'entity_id': 'sensor.dryer_dryer_machine_state', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Dryer Dryer Machine State', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_machine_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dryer Dryer Machine State', - }), - 'context': , - 'entity_id': 'sensor.dryer_dryer_machine_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stop', - }) -# --- # name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3138,7 +2992,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.dryer_energy', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3149,7 +3003,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dryer energy', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3162,7 +3016,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Dryer energy', + 'friendly_name': 'Dryer Energy', 'state_class': , 'unit_of_measurement': , }), @@ -3174,7 +3028,7 @@ 'state': '4495.5', }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energysaved-entry] +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3189,8 +3043,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dryer_energysaved', - 'has_entity_name': False, + 'entity_id': 'sensor.dryer_energy_difference', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3201,31 +3055,177 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dryer energySaved', + 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energySaved_meter', + 'translation_key': 'energy_difference', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energysaved-state] +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Dryer energySaved', + 'friendly_name': 'Dryer Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.dryer_energysaved', + 'entity_id': 'sensor.dryer_energy_difference', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy_saved-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.dryer_energy_saved', + '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 saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_job_state-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': None, + 'entity_id': 'sensor.dryer_job_state', + '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': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_job_state', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Job state', + }), + 'context': , + 'entity_id': 'sensor.dryer_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_machine_state-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': None, + 'entity_id': 'sensor.dryer_machine_state', + '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': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_machine_state', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Machine state', + }), + 'context': , + 'entity_id': 'sensor.dryer_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- # name: test_all_entities[da_wm_wd_000001][sensor.dryer_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3242,7 +3242,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.dryer_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3253,7 +3253,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dryer power', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3266,7 +3266,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Dryer power', + 'friendly_name': 'Dryer Power', 'power_consumption_end': '2025-02-08T18:10:11Z', 'power_consumption_start': '2025-02-07T04:00:19Z', 'state_class': , @@ -3280,7 +3280,7 @@ 'state': '0', }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_powerenergy-entry] +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3295,8 +3295,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dryer_powerenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.dryer_power_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3307,39 +3307,37 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dryer powerEnergy', + 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'power_energy', 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_powerenergy-state] +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Dryer powerEnergy', + 'friendly_name': 'Dryer Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.dryer_powerenergy', + 'entity_id': 'sensor.dryer_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_deltaenergy-entry] +# name: test_all_entities[da_wm_wm_000001][sensor.washer_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3347,8 +3345,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.washer_deltaenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.washer_completion_time', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3357,31 +3355,29 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Washer deltaEnergy', + 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.deltaEnergy_meter', - 'unit_of_measurement': , + 'translation_key': 'completion_time', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.completionTime', + 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_deltaenergy-state] +# name: test_all_entities[da_wm_wm_000001][sensor.washer_completion_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Washer deltaEnergy', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'timestamp', + 'friendly_name': 'Washer Completion time', }), 'context': , - 'entity_id': 'sensor.washer_deltaenergy', + 'entity_id': 'sensor.washer_completion_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': '2025-02-07T03:54:45+00:00', }) # --- # name: test_all_entities[da_wm_wm_000001][sensor.washer_energy-entry] @@ -3400,7 +3396,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.washer_energy', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3411,7 +3407,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Washer energy', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3424,7 +3420,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Washer energy', + 'friendly_name': 'Washer Energy', 'state_class': , 'unit_of_measurement': , }), @@ -3436,7 +3432,7 @@ 'state': '352.8', }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_energysaved-entry] +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3451,8 +3447,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.washer_energysaved', - 'has_entity_name': False, + 'entity_id': 'sensor.washer_energy_difference', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3463,31 +3459,177 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Washer energySaved', + 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energySaved_meter', + 'translation_key': 'energy_difference', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_energysaved-state] +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Washer energySaved', + 'friendly_name': 'Washer Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.washer_energysaved', + 'entity_id': 'sensor.washer_energy_difference', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy_saved-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.washer_energy_saved', + '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 saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_job_state-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': None, + 'entity_id': 'sensor.washer_job_state', + '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': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_job_state', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.washerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Job state', + }), + 'context': , + 'entity_id': 'sensor.washer_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_machine_state-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': None, + 'entity_id': 'sensor.washer_machine_state', + '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': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_machine_state', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Machine state', + }), + 'context': , + 'entity_id': 'sensor.washer_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- # name: test_all_entities[da_wm_wm_000001][sensor.washer_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3504,7 +3646,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.washer_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3515,7 +3657,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Washer power', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3528,7 +3670,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Washer power', + 'friendly_name': 'Washer Power', 'power_consumption_end': '2025-02-07T03:09:45Z', 'power_consumption_start': '2025-02-07T03:09:24Z', 'state_class': , @@ -3542,7 +3684,7 @@ 'state': '0', }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_powerenergy-entry] +# name: test_all_entities[da_wm_wm_000001][sensor.washer_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3557,8 +3699,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.washer_powerenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.washer_power_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3569,174 +3711,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Washer powerEnergy', + 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'power_energy', 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_powerenergy-state] +# name: test_all_entities[da_wm_wm_000001][sensor.washer_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Washer powerEnergy', + 'friendly_name': 'Washer Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.washer_powerenergy', + 'entity_id': 'sensor.washer_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_completion_time-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.washer_washer_completion_time', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Washer Washer Completion Time', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.completionTime', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_completion_time-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Washer Washer Completion Time', - }), - 'context': , - 'entity_id': 'sensor.washer_washer_completion_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2025-02-07T03:54:45+00:00', - }) -# --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_job_state-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': None, - 'entity_id': 'sensor.washer_washer_job_state', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Washer Washer Job State', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.washerJobState', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_job_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Washer Washer Job State', - }), - 'context': , - 'entity_id': 'sensor.washer_washer_job_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'none', - }) -# --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_machine_state-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': None, - 'entity_id': 'sensor.washer_washer_machine_state', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Washer Washer Machine State', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.machineState', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_machine_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Washer Washer Machine State', - }), - 'context': , - 'entity_id': 'sensor.washer_washer_machine_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stop', - }) -# --- -# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature_measurement-entry] +# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3751,8 +3751,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.child_bedroom_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.child_bedroom_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3763,7 +3763,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Child Bedroom Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3772,23 +3772,23 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature_measurement-state] +# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Child Bedroom Temperature Measurement', + 'friendly_name': 'Child Bedroom Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.child_bedroom_temperature_measurement', + 'entity_id': 'sensor.child_bedroom_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '22', }) # --- -# name: test_all_entities[ecobee_thermostat][sensor.main_floor_relative_humidity_measurement-entry] +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3803,8 +3803,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.main_floor_relative_humidity_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.main_floor_humidity', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3815,7 +3815,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Main Floor Relative Humidity Measurement', + 'original_name': 'Humidity', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3824,23 +3824,23 @@ 'unit_of_measurement': '%', }) # --- -# name: test_all_entities[ecobee_thermostat][sensor.main_floor_relative_humidity_measurement-state] +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', - 'friendly_name': 'Main Floor Relative Humidity Measurement', + 'friendly_name': 'Main Floor Humidity', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.main_floor_relative_humidity_measurement', + 'entity_id': 'sensor.main_floor_humidity', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '32', }) # --- -# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature_measurement-entry] +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3855,8 +3855,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.main_floor_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.main_floor_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3867,7 +3867,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Main Floor Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3876,16 +3876,16 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature_measurement-state] +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Main Floor Temperature Measurement', + 'friendly_name': 'Main Floor Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.main_floor_temperature_measurement', + 'entity_id': 'sensor.main_floor_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -3906,7 +3906,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.deck_door_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3917,7 +3917,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Deck Door Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3941,7 +3941,7 @@ 'state': '50', }) # --- -# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature_measurement-entry] +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3956,8 +3956,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.deck_door_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.deck_door_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3968,7 +3968,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Deck Door Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3977,16 +3977,16 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature_measurement-state] +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Deck Door Temperature Measurement', + 'friendly_name': 'Deck Door Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.deck_door_temperature_measurement', + 'entity_id': 'sensor.deck_door_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4007,7 +4007,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.deck_door_x_coordinate', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4018,11 +4018,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Deck Door X Coordinate', + 'original_name': 'X coordinate', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'x_coordinate', 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c X Coordinate', 'unit_of_measurement': None, }) @@ -4030,7 +4030,7 @@ # name: test_all_entities[multipurpose_sensor][sensor.deck_door_x_coordinate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Deck Door X Coordinate', + 'friendly_name': 'Deck Door X coordinate', }), 'context': , 'entity_id': 'sensor.deck_door_x_coordinate', @@ -4054,7 +4054,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.deck_door_y_coordinate', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4065,11 +4065,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Deck Door Y Coordinate', + 'original_name': 'Y coordinate', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'y_coordinate', 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Y Coordinate', 'unit_of_measurement': None, }) @@ -4077,7 +4077,7 @@ # name: test_all_entities[multipurpose_sensor][sensor.deck_door_y_coordinate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Deck Door Y Coordinate', + 'friendly_name': 'Deck Door Y coordinate', }), 'context': , 'entity_id': 'sensor.deck_door_y_coordinate', @@ -4101,7 +4101,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.deck_door_z_coordinate', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4112,11 +4112,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Deck Door Z Coordinate', + 'original_name': 'Z coordinate', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'z_coordinate', 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Z Coordinate', 'unit_of_measurement': None, }) @@ -4124,7 +4124,7 @@ # name: test_all_entities[multipurpose_sensor][sensor.deck_door_z_coordinate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Deck Door Z Coordinate', + 'friendly_name': 'Deck Door Z coordinate', }), 'context': , 'entity_id': 'sensor.deck_door_z_coordinate', @@ -4148,7 +4148,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.office_air_conditioner_mode', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4159,11 +4159,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Office Air Conditioner Mode', + 'original_name': 'Air conditioner mode', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'air_conditioner_mode', 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.airConditionerMode', 'unit_of_measurement': None, }) @@ -4171,7 +4171,7 @@ # name: test_all_entities[sensibo_airconditioner_1][sensor.office_air_conditioner_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Office Air Conditioner Mode', + 'friendly_name': 'Office Air conditioner mode', }), 'context': , 'entity_id': 'sensor.office_air_conditioner_mode', @@ -4181,7 +4181,7 @@ 'state': 'cool', }) # --- -# name: test_all_entities[sensibo_airconditioner_1][sensor.office_thermostat_cooling_setpoint-entry] +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_cooling_set_point-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4194,8 +4194,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_thermostat_cooling_setpoint', - 'has_entity_name': False, + 'entity_id': 'sensor.office_cooling_set_point', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4206,24 +4206,24 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Office Thermostat Cooling Setpoint', + 'original_name': 'Cooling set point', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'thermostat_cooling_setpoint', 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.coolingSetpoint', 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensibo_airconditioner_1][sensor.office_thermostat_cooling_setpoint-state] +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_cooling_set_point-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Office Thermostat Cooling Setpoint', + 'friendly_name': 'Office Cooling set point', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.office_thermostat_cooling_setpoint', + 'entity_id': 'sensor.office_cooling_set_point', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4244,7 +4244,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.elliots_rum_media_playback_status', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4255,11 +4255,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Elliots Rum Media Playback Status', + 'original_name': 'Media playback status', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'media_playback_status', 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.playbackStatus', 'unit_of_measurement': None, }) @@ -4267,7 +4267,7 @@ # name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Elliots Rum Media Playback Status', + 'friendly_name': 'Elliots Rum Media playback status', }), 'context': , 'entity_id': 'sensor.elliots_rum_media_playback_status', @@ -4291,7 +4291,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.elliots_rum_volume', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4302,11 +4302,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Elliots Rum Volume', + 'original_name': 'Volume', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'audio_volume', 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.volume', 'unit_of_measurement': '%', }) @@ -4339,7 +4339,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.soundbar_living_media_playback_status', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4350,11 +4350,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Soundbar Living Media Playback Status', + 'original_name': 'Media playback status', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'media_playback_status', 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.playbackStatus', 'unit_of_measurement': None, }) @@ -4362,7 +4362,7 @@ # name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Soundbar Living Media Playback Status', + 'friendly_name': 'Soundbar Living Media playback status', }), 'context': , 'entity_id': 'sensor.soundbar_living_media_playback_status', @@ -4386,7 +4386,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.soundbar_living_volume', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4397,11 +4397,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Soundbar Living Volume', + 'original_name': 'Volume', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'audio_volume', 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.volume', 'unit_of_measurement': '%', }) @@ -4434,7 +4434,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4445,11 +4445,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '[TV] Samsung 8 Series (49) Media Input Source', + 'original_name': 'Media input source', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'media_input_source', 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.inputSource', 'unit_of_measurement': None, }) @@ -4457,7 +4457,7 @@ # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49) Media Input Source', + 'friendly_name': '[TV] Samsung 8 Series (49) Media input source', }), 'context': , 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', @@ -4481,7 +4481,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4492,11 +4492,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '[TV] Samsung 8 Series (49) Media Playback Status', + 'original_name': 'Media playback status', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'media_playback_status', 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.playbackStatus', 'unit_of_measurement': None, }) @@ -4504,7 +4504,7 @@ # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49) Media Playback Status', + 'friendly_name': '[TV] Samsung 8 Series (49) Media playback status', }), 'context': , 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', @@ -4528,7 +4528,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4539,11 +4539,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '[TV] Samsung 8 Series (49) Tv Channel', + 'original_name': 'TV channel', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'tv_channel', 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannel', 'unit_of_measurement': None, }) @@ -4551,7 +4551,7 @@ # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49) Tv Channel', + 'friendly_name': '[TV] Samsung 8 Series (49) TV channel', }), 'context': , 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel', @@ -4575,7 +4575,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel_name', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4586,11 +4586,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '[TV] Samsung 8 Series (49) Tv Channel Name', + 'original_name': 'TV channel name', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'tv_channel_name', 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannelName', 'unit_of_measurement': None, }) @@ -4598,7 +4598,7 @@ # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel_name-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49) Tv Channel Name', + 'friendly_name': '[TV] Samsung 8 Series (49) TV channel name', }), 'context': , 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel_name', @@ -4622,7 +4622,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.tv_samsung_8_series_49_volume', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4633,11 +4633,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '[TV] Samsung 8 Series (49) Volume', + 'original_name': 'Volume', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'audio_volume', 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.volume', 'unit_of_measurement': '%', }) @@ -4670,7 +4670,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.asd_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4681,7 +4681,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'asd Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -4705,7 +4705,7 @@ 'state': '100', }) # --- -# name: test_all_entities[virtual_thermostat][sensor.asd_temperature_measurement-entry] +# name: test_all_entities[virtual_thermostat][sensor.asd_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4720,8 +4720,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.asd_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.asd_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4732,7 +4732,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'asd Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -4741,16 +4741,16 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[virtual_thermostat][sensor.asd_temperature_measurement-state] +# name: test_all_entities[virtual_thermostat][sensor.asd_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'asd Temperature Measurement', + 'friendly_name': 'asd Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.asd_temperature_measurement', + 'entity_id': 'sensor.asd_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4771,7 +4771,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.asd_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4782,7 +4782,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'asd Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -4820,7 +4820,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.basement_door_lock_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4831,7 +4831,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Basement Door Lock Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index cf3245eed7d..d12bd4ea5b6 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -13,7 +13,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.2nd_floor_hallway', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,7 +24,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '2nd Floor Hallway', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -60,7 +60,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.microwave', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -71,7 +71,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Microwave', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -107,7 +107,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.robot_vacuum', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -118,7 +118,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Robot vacuum', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -154,7 +154,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.dishwasher', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -165,7 +165,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Dishwasher', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -201,7 +201,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.dryer', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -212,7 +212,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Dryer', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -248,7 +248,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.washer', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -259,7 +259,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Washer', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -295,7 +295,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.office', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -306,7 +306,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Office', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -342,7 +342,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.arlo_beta_basestation', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -353,7 +353,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Arlo Beta Basestation', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -389,7 +389,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.soundbar_living', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -400,7 +400,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Soundbar Living', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -436,7 +436,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.tv_samsung_8_series_49', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -447,7 +447,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '[TV] Samsung 8 Series (49)', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index eb473d3be04..f46be2edc89 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -39,7 +39,7 @@ async def test_state_update( """Test state update.""" await setup_integration(hass, mock_config_entry) - assert hass.states.get("binary_sensor.refrigerator_contact").state == STATE_OFF + assert hass.states.get("binary_sensor.refrigerator_door").state == STATE_OFF await trigger_update( hass, @@ -50,4 +50,4 @@ async def test_state_update( "open", ) - assert hass.states.get("binary_sensor.refrigerator_contact").state == STATE_ON + assert hass.states.get("binary_sensor.refrigerator_door").state == STATE_ON diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 7f8464e69aa..8b8bb8930f4 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -37,10 +37,7 @@ async def test_state_update( """Test state update.""" await setup_integration(hass, mock_config_entry) - assert ( - hass.states.get("sensor.aeotec_energy_monitor_energy_meter").state - == "19978.536" - ) + assert hass.states.get("sensor.aeotec_energy_monitor_energy_2").state == "19978.536" await trigger_update( hass, @@ -51,6 +48,4 @@ async def test_state_update( 20000.0, ) - assert ( - hass.states.get("sensor.aeotec_energy_monitor_energy_meter").state == "20000.0" - ) + assert hass.states.get("sensor.aeotec_energy_monitor_energy_2").state == "20000.0" From e09b40c2bd7d4a6822dfc9a80eb53bae248e2160 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 26 Feb 2025 15:51:16 +0100 Subject: [PATCH 2798/2987] Improve logging for selected options in Onkyo (#139279) Different error for not selected option --- .../components/onkyo/media_player.py | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 7c91fda5f78..8f9587bc426 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -398,6 +398,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._volume_resolution = volume_resolution self._max_volume = max_volume + self._options_sources = sources self._source_lib_mapping = _input_source_lib_mappings(zone) self._rev_source_lib_mapping = _rev_input_source_lib_mappings(zone) self._source_mapping = { @@ -409,6 +410,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): value: key for key, value in self._source_mapping.items() } + self._options_sound_modes = sound_modes self._sound_mode_lib_mapping = _listening_mode_lib_mappings(zone) self._rev_sound_mode_lib_mapping = _rev_listening_mode_lib_mappings(zone) self._sound_mode_mapping = { @@ -623,11 +625,20 @@ class OnkyoMediaPlayer(MediaPlayerEntity): return source_meaning = source.value_meaning - _LOGGER.error( - 'Input source "%s" is invalid for entity: %s', - source_meaning, - self.entity_id, - ) + + if source not in self._options_sources: + _LOGGER.warning( + 'Input source "%s" for entity: %s is not in the list. Check integration options', + source_meaning, + self.entity_id, + ) + else: + _LOGGER.error( + 'Input source "%s" is invalid for entity: %s', + source_meaning, + self.entity_id, + ) + self._attr_source = source_meaning @callback @@ -638,11 +649,20 @@ class OnkyoMediaPlayer(MediaPlayerEntity): return sound_mode_meaning = sound_mode.value_meaning - _LOGGER.error( - 'Listening mode "%s" is invalid for entity: %s', - sound_mode_meaning, - self.entity_id, - ) + + if sound_mode not in self._options_sound_modes: + _LOGGER.warning( + 'Listening mode "%s" for entity: %s is not in the list. Check integration options', + sound_mode_meaning, + self.entity_id, + ) + else: + _LOGGER.error( + 'Listening mode "%s" is invalid for entity: %s', + sound_mode_meaning, + self.entity_id, + ) + self._attr_sound_mode = sound_mode_meaning @callback From 9be8fd4eac934066f67982931f74d7c4ee451b95 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 15:59:23 +0100 Subject: [PATCH 2799/2987] Change no fixtures comment in SmartThings (#139344) --- .../components/smartthings/sensor.py | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 6685d6be726..9c544ea5d73 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -64,7 +64,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): CAPABILITY_TO_SENSORS: dict[ Capability, dict[Attribute, list[SmartThingsSensorEntityDescription]] ] = { - # no fixtures + # Haven't seen at devices yet Capability.ACTIVITY_LIGHTING_MODE: { Attribute.LIGHTING_MODE: [ SmartThingsSensorEntityDescription( @@ -126,7 +126,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.BODY_MASS_INDEX_MEASUREMENT: { Attribute.BMI_MEASUREMENT: [ SmartThingsSensorEntityDescription( @@ -137,7 +137,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.BODY_WEIGHT_MEASUREMENT: { Attribute.BODY_WEIGHT_MEASUREMENT: [ SmartThingsSensorEntityDescription( @@ -149,7 +149,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.CARBON_DIOXIDE_MEASUREMENT: { Attribute.CARBON_DIOXIDE: [ SmartThingsSensorEntityDescription( @@ -160,7 +160,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.CARBON_MONOXIDE_DETECTOR: { Attribute.CARBON_MONOXIDE: [ SmartThingsSensorEntityDescription( @@ -169,7 +169,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.CARBON_MONOXIDE_MEASUREMENT: { Attribute.CARBON_MONOXIDE_LEVEL: [ SmartThingsSensorEntityDescription( @@ -202,7 +202,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - # part of the proposed spec, no fixtures + # part of the proposed spec, Haven't seen at devices yet Capability.DRYER_MODE: { Attribute.DRYER_MODE: [ SmartThingsSensorEntityDescription( @@ -260,7 +260,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: { Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: [ SmartThingsSensorEntityDescription( @@ -272,7 +272,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.FORMALDEHYDE_MEASUREMENT: { Attribute.FORMALDEHYDE_LEVEL: [ SmartThingsSensorEntityDescription( @@ -283,7 +283,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.GAS_METER: { Attribute.GAS_METER: [ SmartThingsSensorEntityDescription( @@ -317,7 +317,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - # no fixtures + # Haven't seen at devices yet Capability.ILLUMINANCE_MEASUREMENT: { Attribute.ILLUMINANCE: [ SmartThingsSensorEntityDescription( @@ -328,7 +328,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.INFRARED_LEVEL: { Attribute.INFRARED_LEVEL: [ SmartThingsSensorEntityDescription( @@ -347,7 +347,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # part of the proposed spec, no fixtures + # part of the proposed spec, Haven't seen at devices yet Capability.MEDIA_PLAYBACK_REPEAT: { Attribute.PLAYBACK_REPEAT_MODE: [ SmartThingsSensorEntityDescription( @@ -356,7 +356,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # part of the proposed spec, no fixtures + # part of the proposed spec, Haven't seen at devices yet Capability.MEDIA_PLAYBACK_SHUFFLE: { Attribute.PLAYBACK_SHUFFLE: [ SmartThingsSensorEntityDescription( @@ -471,7 +471,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.POWER_SOURCE: { Attribute.POWER_SOURCE: [ SmartThingsSensorEntityDescription( @@ -527,7 +527,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.SIGNAL_STRENGTH: { Attribute.LQI: [ SmartThingsSensorEntityDescription( @@ -546,7 +546,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - # no fixtures + # Haven't seen at devices yet Capability.SMOKE_DETECTOR: { Attribute.SMOKE: [ SmartThingsSensorEntityDescription( @@ -581,7 +581,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.THERMOSTAT_FAN_MODE: { Attribute.THERMOSTAT_FAN_MODE: [ SmartThingsSensorEntityDescription( @@ -592,7 +592,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.THERMOSTAT_HEATING_SETPOINT: { Attribute.HEATING_SETPOINT: [ SmartThingsSensorEntityDescription( @@ -604,7 +604,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.THERMOSTAT_MODE: { Attribute.THERMOSTAT_MODE: [ SmartThingsSensorEntityDescription( @@ -615,7 +615,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.THERMOSTAT_OPERATING_STATE: { Attribute.THERMOSTAT_OPERATING_STATE: [ SmartThingsSensorEntityDescription( @@ -672,7 +672,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - # no fixtures + # Haven't seen at devices yet Capability.TVOC_MEASUREMENT: { Attribute.TVOC_LEVEL: [ SmartThingsSensorEntityDescription( @@ -683,7 +683,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.ULTRAVIOLET_INDEX: { Attribute.ULTRAVIOLET_INDEX: [ SmartThingsSensorEntityDescription( From e403bee95b87e138761a51dab9ba2d40ec472508 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:05:59 +0100 Subject: [PATCH 2800/2987] Set options for carbon monoxide detector sensor in SmartThings (#139346) --- homeassistant/components/smartthings/sensor.py | 2 ++ homeassistant/components/smartthings/strings.json | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 9c544ea5d73..da4fa20526e 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -166,6 +166,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.CARBON_MONOXIDE, translation_key="carbon_monoxide_detector", + options=["detected", "clear", "tested"], + device_class=SensorDeviceClass.ENUM, ) ] }, diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9cfc6176d20..9076aa8b2b5 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -61,7 +61,12 @@ "name": "Body weight" }, "carbon_monoxide_detector": { - "name": "Carbon monoxide detector" + "name": "Carbon monoxide detector", + "state": { + "detected": "Detected", + "clear": "Clear", + "tested": "Tested" + } }, "dishwasher_machine_state": { "name": "Machine state" From fdf69fcd7dea7f708664fba22ded72a8cb313bd9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 26 Feb 2025 16:09:20 +0100 Subject: [PATCH 2801/2987] Improve calculating supported features in template light (#139339) --- homeassistant/components/template/light.py | 2 +- tests/components/template/test_light.py | 54 ++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 9391e368e2b..206703ddcce 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -1013,7 +1013,7 @@ class LightTemplate(TemplateEntity, LightEntity): if render in (None, "None", ""): self._supports_transition = False return - self._attr_supported_features &= LightEntityFeature.EFFECT + self._attr_supported_features &= ~LightEntityFeature.TRANSITION self._supports_transition = bool(render) if self._supports_transition: self._attr_supported_features |= LightEntityFeature.TRANSITION diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index b5ba93a4bd0..a94ec233f81 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -1847,6 +1847,60 @@ async def test_supports_transition_template( ) != expected_value +@pytest.mark.parametrize("count", [1]) +async def test_supports_transition_template_updates( + hass: HomeAssistant, count: int +) -> None: + """Test the template for the supports transition dynamically.""" + light_config = { + "test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on", "entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off", "entity_id": "light.test_state"}, + "set_temperature": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "color_temp": "{{color_temp}}", + }, + }, + "set_effect": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "effect": "{{effect}}", + }, + }, + "effect_list_template": "{{ ['Disco', 'Police'] }}", + "effect_template": "{{ None }}", + "supports_transition_template": "{{ states('sensor.test') }}", + } + } + await async_setup_light(hass, count, light_config) + state = hass.states.get("light.test_template_light") + assert state is not None + + hass.states.async_set("sensor.test", 0) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") + supported_features = state.attributes.get("supported_features") + assert supported_features == LightEntityFeature.EFFECT + + hass.states.async_set("sensor.test", 1) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") + supported_features = state.attributes.get("supported_features") + assert ( + supported_features == LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT + ) + + hass.states.async_set("sensor.test", 0) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") + supported_features = state.attributes.get("supported_features") + assert supported_features == LightEntityFeature.EFFECT + + @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( "light_config", From c1898ece8068c8573989c168182de05519917ff6 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 26 Feb 2025 16:13:45 +0100 Subject: [PATCH 2802/2987] Update frontend to 20250226.0 (#139340) Co-authored-by: Robert Resch --- 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 b13b33685d5..7bd361041e1 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250221.0"] + "requirements": ["home-assistant-frontend==20250226.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6a6c1dfc3ed..b248be0eb96 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.24.0 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250221.0 +home-assistant-frontend==20250226.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 54c0a29bee5..082524036e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1152,7 +1152,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250221.0 +home-assistant-frontend==20250226.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a3f171fa1a9..8cac6cc79d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250221.0 +home-assistant-frontend==20250226.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 From 3c3c4d2641e2405ca3fa8731992e44e26bbaa7f6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:17:55 +0100 Subject: [PATCH 2803/2987] Use particulate matter device class in SmartThings (#139351) Use particule matter device class in SmartThings --- .../components/smartthings/sensor.py | 7 +- .../components/smartthings/strings.json | 6 - .../smartthings/snapshots/test_sensor.ambr | 410 +++++++++--------- 3 files changed, 213 insertions(+), 210 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index da4fa20526e..ec4fc94ae80 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -16,6 +16,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, @@ -240,14 +241,16 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.DUST_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.DUST_LEVEL, - translation_key="dust_level", + device_class=SensorDeviceClass.PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ) ], Attribute.FINE_DUST_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.FINE_DUST_LEVEL, - translation_key="fine_dust_level", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ) ], diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9076aa8b2b5..9d7ea5938f5 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -86,12 +86,6 @@ "dryer_job_state": { "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" }, - "dust_level": { - "name": "Dust level" - }, - "fine_dust_level": { - "name": "Fine dust level" - }, "equivalent_carbon_dioxide": { "name": "Equivalent carbon dioxide" }, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 2fca1a8d108..8f8f514ef07 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -558,57 +558,6 @@ 'state': 'unknown', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_dust_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.ac_office_granit_dust_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': 'Dust level', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'dust_level', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.dustLevel', - 'unit_of_measurement': 'μg/m^3', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_dust_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Dust level', - 'state_class': , - 'unit_of_measurement': 'μg/m^3', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_dust_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -765,57 +714,6 @@ 'state': '0.0', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_fine_dust_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.ac_office_granit_fine_dust_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': 'Fine dust level', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fine_dust_level', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.fineDustLevel', - 'unit_of_measurement': 'μg/m^3', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_fine_dust_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Fine dust level', - 'state_class': , - 'unit_of_measurement': 'μg/m^3', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_fine_dust_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -868,6 +766,110 @@ 'state': '60', }) # --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm10-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.ac_office_granit_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.dustLevel', + 'unit_of_measurement': 'μg/m^3', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'AC Office Granit PM10', + 'state_class': , + 'unit_of_measurement': 'μg/m^3', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.fineDustLevel', + 'unit_of_measurement': 'μg/m^3', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'AC Office Granit PM2.5', + 'state_class': , + 'unit_of_measurement': 'μg/m^3', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1125,56 +1127,6 @@ 'state': 'unknown', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_dust_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.aire_dormitorio_principal_dust_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': 'Dust level', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'dust_level', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.dustLevel', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_dust_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Dust level', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_dust_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1331,56 +1283,6 @@ 'state': '0.0', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_fine_dust_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.aire_dormitorio_principal_fine_dust_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': 'Fine dust level', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fine_dust_level', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.fineDustLevel', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_fine_dust_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Fine dust level', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_fine_dust_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1480,6 +1382,110 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm10-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.aire_dormitorio_principal_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.dustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'Aire Dormitorio Principal PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.fineDustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Aire Dormitorio Principal PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 9262dec4443ef8ef62464cdd798294b9d40e21dc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:18:14 +0100 Subject: [PATCH 2804/2987] Set options for dishwasher job state sensor in SmartThings (#139349) --- .../components/smartthings/sensor.py | 21 +++++++++++++ .../components/smartthings/strings.json | 14 ++++++++- .../smartthings/snapshots/test_sensor.ambr | 30 +++++++++++++++++-- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index ec4fc94ae80..feac0b4a09b 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -42,6 +42,13 @@ THERMOSTAT_CAPABILITIES = { Capability.THERMOSTAT_MODE, } +JOB_STATE_MAP = { + "preDrain": "pre_drain", + "preWash": "pre_wash", + "wrinklePrevent": "wrinkle_prevent", + "unknown": None, +} + def power_attributes(status: dict[str, Any]) -> dict[str, Any]: """Return the power attributes.""" @@ -194,6 +201,20 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.DISHWASHER_JOB_STATE, translation_key="dishwasher_job_state", + options=[ + "airwash", + "cooling", + "drying", + "finish", + "pre_drain", + "pre_wash", + "rinse", + "spin", + "wash", + "wrinkle_prevent", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: JOB_STATE_MAP.get(value, value), ) ], Attribute.COMPLETION_TIME: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9d7ea5938f5..7ee3e57ac64 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -72,7 +72,19 @@ "name": "Machine state" }, "dishwasher_job_state": { - "name": "Job state" + "name": "Job state", + "state": { + "airwash": "Airwash", + "cooling": "Cooling", + "drying": "Drying", + "finish": "Finish", + "pre_drain": "Pre-drain", + "pre_wash": "Pre-wash", + "rinse": "Rinse", + "spin": "Spin", + "wash": "Wash", + "wrinkle_prevent": "Wrinkle prevention" + } }, "completion_time": { "name": "Completion time" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 8f8f514ef07..0df93a3a02a 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2739,7 +2739,20 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'airwash', + 'cooling', + 'drying', + 'finish', + 'pre_drain', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'wrinkle_prevent', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2757,7 +2770,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Job state', 'platform': 'smartthings', @@ -2771,7 +2784,20 @@ # name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_job_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Dishwasher Job state', + 'options': list([ + 'airwash', + 'cooling', + 'drying', + 'finish', + 'pre_drain', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'wrinkle_prevent', + ]), }), 'context': , 'entity_id': 'sensor.dishwasher_job_state', From 37c8764426adb42150c4ec19a36661d43b8ee457 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:18:37 +0100 Subject: [PATCH 2805/2987] Set options for dishwasher machine state sensor in SmartThings (#139347) * Set options for dishwasher machine state sensor in SmartThings * Fix --- homeassistant/components/smartthings/sensor.py | 2 ++ .../components/smartthings/strings.json | 7 ++++++- .../smartthings/snapshots/test_sensor.ambr | 16 ++++++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index feac0b4a09b..fb40632626f 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -195,6 +195,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, translation_key="dishwasher_machine_state", + options=["pause", "run", "stop"], + device_class=SensorDeviceClass.ENUM, ) ], Attribute.DISHWASHER_JOB_STATE: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 7ee3e57ac64..a577d1267d7 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -69,7 +69,12 @@ } }, "dishwasher_machine_state": { - "name": "Machine state" + "name": "Machine state", + "state": { + "pause": "[%key:common::state::paused%]", + "run": "Running", + "stop": "Stopped" + } }, "dishwasher_job_state": { "name": "Job state", diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 0df93a3a02a..01156462455 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2812,7 +2812,13 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2830,7 +2836,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Machine state', 'platform': 'smartthings', @@ -2844,7 +2850,13 @@ # name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_machine_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Dishwasher Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), }), 'context': , 'entity_id': 'sensor.dishwasher_machine_state', From bd80a7884888d9524ae79c000d0813775a615d6f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:18:59 +0100 Subject: [PATCH 2806/2987] Set options for alarm sensor in SmartThings (#139345) * Set options for alarm sensor in SmartThings * Set options for alarm sensor in SmartThings * Fix --- homeassistant/components/smartthings/sensor.py | 2 ++ .../components/smartthings/strings.json | 8 +++++++- .../smartthings/snapshots/test_sensor.ambr | 18 ++++++++++++++++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index fb40632626f..73cc8c32a09 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -112,6 +112,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.ALARM, translation_key="alarm", + options=["both", "strobe", "siren", "off"], + device_class=SensorDeviceClass.ENUM, ) ] }, diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index a577d1267d7..2faf3df682d 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -49,7 +49,13 @@ "name": "Air quality" }, "alarm": { - "name": "Alarm" + "name": "Alarm", + "state": { + "both": "Strobe and siren", + "strobe": "Strobe", + "siren": "Siren", + "off": "[%key:common::state::off%]" + } }, "audio_volume": { "name": "Volume" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 01156462455..77d7ddf6643 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -263,7 +263,14 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'both', + 'strobe', + 'siren', + 'off', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -281,7 +288,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Alarm', 'platform': 'smartthings', @@ -295,7 +302,14 @@ # name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_alarm-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': '2nd Floor Hallway Alarm', + 'options': list([ + 'both', + 'strobe', + 'siren', + 'off', + ]), }), 'context': , 'entity_id': 'sensor.2nd_floor_hallway_alarm', From b964bc58bef0671acd205b8d2da12b2e24054a64 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 26 Feb 2025 16:19:19 +0100 Subject: [PATCH 2807/2987] Fix variable scopes in scripts (#138883) Co-authored-by: Erik --- homeassistant/helpers/script.py | 103 +++++----- homeassistant/helpers/script_variables.py | 218 ++++++++++++++++++++-- tests/helpers/test_script.py | 146 +++++++++++++++ tests/helpers/test_script_variables.py | 124 +++++++++--- 4 files changed, 504 insertions(+), 87 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 38bc96b67ef..bf7a4a0971c 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -12,7 +12,6 @@ from datetime import datetime, timedelta from functools import partial import itertools import logging -from types import MappingProxyType from typing import Any, Literal, TypedDict, cast, overload import async_interrupt @@ -90,7 +89,7 @@ from . import condition, config_validation as cv, service, template from .condition import ConditionCheckerType, trace_condition_function from .dispatcher import async_dispatcher_connect, async_dispatcher_send_internal from .event import async_call_later, async_track_template -from .script_variables import ScriptVariables +from .script_variables import ScriptRunVariables, ScriptVariables from .template import Template from .trace import ( TraceElement, @@ -177,7 +176,7 @@ def _set_result_unless_done(future: asyncio.Future[None]) -> None: future.set_result(None) -def action_trace_append(variables: dict[str, Any], path: str) -> TraceElement: +def action_trace_append(variables: TemplateVarsType, path: str) -> TraceElement: """Append a TraceElement to trace[path].""" trace_element = TraceElement(variables, path) trace_append_element(trace_element, ACTION_TRACE_NODE_MAX_LEN) @@ -189,7 +188,7 @@ async def trace_action( hass: HomeAssistant, script_run: _ScriptRun, stop: asyncio.Future[None], - variables: dict[str, Any], + variables: TemplateVarsType, ) -> AsyncGenerator[TraceElement]: """Trace action execution.""" path = trace_path_get() @@ -411,7 +410,7 @@ class _ScriptRun: self, hass: HomeAssistant, script: Script, - variables: dict[str, Any], + variables: ScriptRunVariables, context: Context | None, log_exceptions: bool, ) -> None: @@ -485,14 +484,16 @@ class _ScriptRun: script_stack.pop() self._finish() - return ScriptRunResult(self._conversation_response, response, self._variables) + return ScriptRunResult( + self._conversation_response, response, self._variables.local_scope + ) async def _async_step(self, log_exceptions: bool) -> None: continue_on_error = self._action.get(CONF_CONTINUE_ON_ERROR, False) with trace_path(str(self._step)): async with trace_action( - self._hass, self, self._stop, self._variables + self._hass, self, self._stop, self._variables.non_parallel_scope ) as trace_element: if self._stop.done(): return @@ -526,7 +527,7 @@ class _ScriptRun: ex, continue_on_error, self._log_exceptions or log_exceptions ) finally: - trace_element.update_variables(self._variables) + trace_element.update_variables(self._variables.non_parallel_scope) def _finish(self) -> None: self._script._runs.remove(self) # noqa: SLF001 @@ -624,11 +625,16 @@ class _ScriptRun: except ScriptStoppedError as ex: raise asyncio.CancelledError from ex - async def _async_run_script(self, script: Script) -> None: + async def _async_run_script( + self, script: Script, *, parallel: bool = False + ) -> None: """Execute a script.""" result = await self._async_run_long_action( self._hass.async_create_task_internal( - script.async_run(self._variables, self._context), eager_start=True + script.async_run( + self._variables.enter_scope(parallel=parallel), self._context + ), + eager_start=True, ) ) if result and result.conversation_response is not UNDEFINED: @@ -647,7 +653,7 @@ class _ScriptRun: """Run a script with a trace path.""" trace_path_stack_cv.set(copy(trace_path_stack_cv.get())) with trace_path([str(idx), "sequence"]): - await self._async_run_script(script) + await self._async_run_script(script, parallel=True) results = await asyncio.gather( *(async_run_with_trace(idx, script) for idx, script in enumerate(scripts)), @@ -760,14 +766,11 @@ class _ScriptRun: with trace_path("else"): await self._async_run_script(if_data["if_else"]) - @async_trace_path("repeat") - async def _async_step_repeat(self) -> None: # noqa: C901 - """Repeat a sequence.""" + async def _async_do_step_repeat(self) -> None: # noqa: C901 + """Repeat a sequence helper.""" description = self._action.get(CONF_ALIAS, "sequence") repeat = self._action[CONF_REPEAT] - saved_repeat_vars = self._variables.get("repeat") - def set_repeat_var( iteration: int, count: int | None = None, item: Any = None ) -> None: @@ -776,7 +779,7 @@ class _ScriptRun: repeat_vars["last"] = iteration == count if item is not None: repeat_vars["item"] = item - self._variables["repeat"] = repeat_vars + self._variables.define_local("repeat", repeat_vars) script = self._script._get_repeat_script(self._step) # noqa: SLF001 warned_too_many_loops = False @@ -927,10 +930,14 @@ class _ScriptRun: # while all the cpu time is consumed. await asyncio.sleep(0) - if saved_repeat_vars: - self._variables["repeat"] = saved_repeat_vars - else: - self._variables.pop("repeat", None) # Not set if count = 0 + @async_trace_path("repeat") + async def _async_step_repeat(self) -> None: + """Repeat a sequence.""" + self._variables = self._variables.enter_scope() + try: + await self._async_do_step_repeat() + finally: + self._variables = self._variables.exit_scope() ### Stop actions ### @@ -959,11 +966,12 @@ class _ScriptRun: ## Variable actions ## async def _async_step_variables(self) -> None: - """Set a variable value.""" - self._step_log("setting variables") - self._variables = self._action[CONF_VARIABLES].async_render( - self._hass, self._variables, render_as_defaults=False - ) + """Define a local variable.""" + self._step_log("defining local variables") + for key, value in ( + self._action[CONF_VARIABLES].async_simple_render(self._variables).items() + ): + self._variables.define_local(key, value) ## External actions ## @@ -1016,7 +1024,7 @@ class _ScriptRun: """Perform the device automation specified in the action.""" self._step_log("device automation") await device_action.async_call_action_from_config( - self._hass, self._action, self._variables, self._context + self._hass, self._action, dict(self._variables), self._context ) async def _async_step_event(self) -> None: @@ -1189,12 +1197,15 @@ class _ScriptRun: self._step_log("wait for trigger", timeout) - variables = {**self._variables} - self._variables["wait"] = { - "remaining": timeout, - "completed": False, - "trigger": None, - } + variables = dict(self._variables) + self._variables.assign_parallel_protected( + "wait", + { + "remaining": timeout, + "completed": False, + "trigger": None, + }, + ) trace_set_result(wait=self._variables["wait"]) if timeout == 0: @@ -1240,7 +1251,9 @@ class _ScriptRun: timeout = self._get_timeout_seconds_from_action() self._step_log("wait template", timeout) - self._variables["wait"] = {"remaining": timeout, "completed": False} + self._variables.assign_parallel_protected( + "wait", {"remaining": timeout, "completed": False} + ) trace_set_result(wait=self._variables["wait"]) wait_template = self._action[CONF_WAIT_TEMPLATE] @@ -1369,7 +1382,7 @@ async def _async_stop_scripts_at_shutdown(hass: HomeAssistant, event: Event) -> ) -type _VarsType = dict[str, Any] | Mapping[str, Any] | MappingProxyType[str, Any] +type _VarsType = dict[str, Any] | Mapping[str, Any] | ScriptRunVariables def _referenced_extract_ids(data: Any, key: str, found: set[str]) -> None: @@ -1407,7 +1420,7 @@ class ScriptRunResult: conversation_response: str | None | UndefinedType service_response: ServiceResponse - variables: dict[str, Any] + variables: Mapping[str, Any] class Script: @@ -1422,7 +1435,6 @@ class Script: *, # Used in "Running " log message change_listener: Callable[[], Any] | None = None, - copy_variables: bool = False, log_exceptions: bool = True, logger: logging.Logger | None = None, max_exceeded: str = DEFAULT_MAX_EXCEEDED, @@ -1476,8 +1488,6 @@ class Script: self._parallel_scripts: dict[int, list[Script]] = {} self._sequence_scripts: dict[int, Script] = {} self.variables = variables - self._variables_dynamic = template.is_complex(variables) - self._copy_variables_on_run = copy_variables @property def change_listener(self) -> Callable[..., Any] | None: @@ -1755,25 +1765,19 @@ class Script: if self.top_level: if self.variables: try: - variables = self.variables.async_render( + run_variables = self.variables.async_render( self._hass, run_variables, ) except exceptions.TemplateError as err: self._log("Error rendering variables: %s", err, level=logging.ERROR) raise - elif run_variables: - variables = dict(run_variables) - else: - variables = {} + variables = ScriptRunVariables.create_top_level(run_variables) variables["context"] = context - elif self._copy_variables_on_run: - # This is not the top level script, variables have been turned to a dict - variables = cast(dict[str, Any], copy(run_variables)) else: - # This is not the top level script, variables have been turned to a dict - variables = cast(dict[str, Any], run_variables) + # This is not the top level script, run_variables is an instance of ScriptRunVariables + variables = cast(ScriptRunVariables, run_variables) # Prevent non-allowed recursive calls which will cause deadlocks when we try to # stop (restart) or wait for (queued) our own script run. @@ -1999,7 +2003,6 @@ class Script: max_runs=self.max_runs, logger=self._logger, top_level=False, - copy_variables=True, ) parallel_script.change_listener = partial( self._chain_change_listener, parallel_script diff --git a/homeassistant/helpers/script_variables.py b/homeassistant/helpers/script_variables.py index 2b4507abd64..54200e094e6 100644 --- a/homeassistant/helpers/script_variables.py +++ b/homeassistant/helpers/script_variables.py @@ -2,8 +2,10 @@ from __future__ import annotations +from collections import ChainMap, UserDict from collections.abc import Mapping -from typing import Any +from dataclasses import dataclass, field +from typing import Any, cast from homeassistant.core import HomeAssistant, callback @@ -24,30 +26,23 @@ class ScriptVariables: hass: HomeAssistant, run_variables: Mapping[str, Any] | None, *, - render_as_defaults: bool = True, limited: bool = False, ) -> dict[str, Any]: """Render script variables. - The run variables are used to compute the static variables. - - If `render_as_defaults` is True, the run variables will not be overridden. - + The run variables are included in the result. + The run variables are used to compute the rendered variable values. + The run variables will not be overridden. + The rendering happens one at a time, with previous results influencing the next. """ if self._has_template is None: self._has_template = template.is_complex(self.variables) if not self._has_template: - if render_as_defaults: - rendered_variables = dict(self.variables) + rendered_variables = dict(self.variables) - if run_variables is not None: - rendered_variables.update(run_variables) - else: - rendered_variables = ( - {} if run_variables is None else dict(run_variables) - ) - rendered_variables.update(self.variables) + if run_variables is not None: + rendered_variables.update(run_variables) return rendered_variables @@ -56,7 +51,7 @@ class ScriptVariables: for key, value in self.variables.items(): # We can skip if we're going to override this key with # run variables anyway - if render_as_defaults and key in rendered_variables: + if key in rendered_variables: continue rendered_variables[key] = template.render_complex( @@ -65,6 +60,197 @@ class ScriptVariables: return rendered_variables + @callback + def async_simple_render(self, run_variables: Mapping[str, Any]) -> dict[str, Any]: + """Render script variables. + + Simply renders the variables, the run variables are not included in the result. + The run variables are used to compute the rendered variable values. + The rendering happens one at a time, with previous results influencing the next. + """ + if self._has_template is None: + self._has_template = template.is_complex(self.variables) + + if not self._has_template: + return self.variables + + run_variables = dict(run_variables) + rendered_variables = {} + + for key, value in self.variables.items(): + rendered_variable = template.render_complex(value, run_variables) + rendered_variables[key] = rendered_variable + run_variables[key] = rendered_variable + + return rendered_variables + def as_dict(self) -> dict[str, Any]: """Return dict version of this class.""" return self.variables + + +@dataclass +class _ParallelData: + """Data used in each parallel sequence.""" + + # `protected` is for variables that need special protection in parallel sequences. + # What this means is that such a variable defined in one parallel sequence will not be + # clobbered by the variable with the same name assigned in another parallel sequence. + # It also means that such a variable will not be visible in the outer scope. + # Currently the only such variable is `wait`. + protected: dict[str, Any] = field(default_factory=dict) + # `outer_scope_writes` is for variables that are written to the outer scope from + # a parallel sequence. This is used for generating correct traces of changed variables + # for each of the parallel sequences, isolating them from one another. + outer_scope_writes: dict[str, Any] = field(default_factory=dict) + + +@dataclass(kw_only=True) +class ScriptRunVariables(UserDict[str, Any]): + """Class to hold script run variables. + + The purpose of this class is to provide proper variable scoping semantics for scripts. + Each instance institutes a new local scope, in which variables can be defined. + Each instance has a reference to the previous instance, except for the top-level instance. + The instances therefore form a chain, in which variable lookup and assignment is performed. + The variables defined lower in the chain naturally override those defined higher up. + """ + + # _previous is the previous ScriptRunVariables in the chain + _previous: ScriptRunVariables | None = None + # _parent is the previous non-empty ScriptRunVariables in the chain + _parent: ScriptRunVariables | None = None + + # _local_data is the store for local variables + _local_data: dict[str, Any] | None = None + # _parallel_data is used for each parallel sequence + _parallel_data: _ParallelData | None = None + + # _non_parallel_scope includes all scopes all the way to the most recent parallel split + _non_parallel_scope: ChainMap[str, Any] + # _full_scope includes all scopes (all the way to the top-level) + _full_scope: ChainMap[str, Any] + + @classmethod + def create_top_level( + cls, + initial_data: Mapping[str, Any] | None = None, + ) -> ScriptRunVariables: + """Create a new top-level ScriptRunVariables.""" + local_data: dict[str, Any] = {} + non_parallel_scope = full_scope = ChainMap(local_data) + self = cls( + _local_data=local_data, + _non_parallel_scope=non_parallel_scope, + _full_scope=full_scope, + ) + if initial_data is not None: + self.update(initial_data) + return self + + def enter_scope(self, *, parallel: bool = False) -> ScriptRunVariables: + """Return a new child scope. + + :param parallel: Whether the new scope starts a parallel sequence. + """ + if self._local_data is not None or self._parallel_data is not None: + parent = self + else: + parent = cast( # top level always has local data, so we can cast safely + ScriptRunVariables, self._parent + ) + + parallel_data: _ParallelData | None + if not parallel: + parallel_data = None + non_parallel_scope = self._non_parallel_scope + full_scope = self._full_scope + else: + parallel_data = _ParallelData() + non_parallel_scope = ChainMap( + parallel_data.protected, parallel_data.outer_scope_writes + ) + full_scope = self._full_scope.new_child(parallel_data.protected) + + return ScriptRunVariables( + _previous=self, + _parent=parent, + _parallel_data=parallel_data, + _non_parallel_scope=non_parallel_scope, + _full_scope=full_scope, + ) + + def exit_scope(self) -> ScriptRunVariables: + """Exit the current scope. + + Does no clean-up, but simply returns the previous scope. + """ + if self._previous is None: + raise ValueError("Cannot exit top-level scope") + return self._previous + + def __delitem__(self, key: str) -> None: + """Delete a variable (disallowed).""" + raise TypeError("Deleting items is not allowed in ScriptRunVariables.") + + def __setitem__(self, key: str, value: Any) -> None: + """Assign value to a variable.""" + self._assign(key, value, parallel_protected=False) + + def assign_parallel_protected(self, key: str, value: Any) -> None: + """Assign value to a variable which is to be protected in parallel sequences.""" + self._assign(key, value, parallel_protected=True) + + def _assign(self, key: str, value: Any, *, parallel_protected: bool) -> None: + """Assign value to a variable. + + Value is always assigned to the variable in the nearest scope, in which it is defined. + If the variable is not defined at all, it is created in the top-level scope. + + :param parallel_protected: Whether variable is to be protected in parallel sequences. + """ + if self._local_data is not None and key in self._local_data: + self._local_data[key] = value + return + + if self._parent is None: + assert self._local_data is not None # top level always has local data + self._local_data[key] = value + return + + if self._parallel_data is not None: + if parallel_protected: + self._parallel_data.protected[key] = value + return + self._parallel_data.protected.pop(key, None) + self._parallel_data.outer_scope_writes[key] = value + + self._parent._assign(key, value, parallel_protected=parallel_protected) # noqa: SLF001 + + def define_local(self, key: str, value: Any) -> None: + """Define a local variable and assign value to it.""" + if self._local_data is None: + self._local_data = {} + self._non_parallel_scope = self._non_parallel_scope.new_child( + self._local_data + ) + self._full_scope = self._full_scope.new_child(self._local_data) + self._local_data[key] = value + + @property + def data(self) -> Mapping[str, Any]: # type: ignore[override] + """Return variables in full scope. + + Defined here for UserDict compatibility. + """ + return self._full_scope + + @property + def non_parallel_scope(self) -> Mapping[str, Any]: + """Return variables in non-parallel scope.""" + return self._non_parallel_scope + + @property + def local_scope(self) -> Mapping[str, Any]: + """Return variables in local scope.""" + return self._local_data if self._local_data is not None else {} diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index f3cbb982ad0..df589a41daa 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -452,6 +452,68 @@ async def test_service_response_data_errors( await script_obj.async_run(context=context) +async def test_calling_service_response_data_in_scopes(hass: HomeAssistant) -> None: + """Test response variable is still set after scopes end.""" + expected_var = {"data": "value-12345"} + + def mock_service(call: ServiceCall) -> ServiceResponse: + """Mock service call.""" + if call.return_response: + return expected_var + return None + + hass.services.async_register( + "test", "script", mock_service, supports_response=SupportsResponse.OPTIONAL + ) + + sequence = cv.SCRIPT_SCHEMA( + { + "parallel": [ + { + "alias": "Sequential group", + "sequence": [ + { + "alias": "variables", + "variables": {"state": "off"}, + }, + { + "alias": "service step1", + "action": "test.script", + "response_variable": "my_response", + }, + ], + } + ] + } + ) + + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + + assert result.variables["my_response"] == expected_var + + expected_trace = { + "0": [{"variables": {"my_response": expected_var}}], + "0/parallel/0/sequence/0": [{"variables": {"state": "off"}}], + "0/parallel/0/sequence/1": [ + { + "result": { + "params": { + "domain": "test", + "service": "script", + "service_data": {}, + "target": {}, + }, + "running_script": False, + }, + "variables": {"my_response": expected_var}, + } + ], + } + assert_action_trace(expected_trace) + + async def test_data_template_with_templated_key(hass: HomeAssistant) -> None: """Test the calling of a service with a data_template with a templated key.""" context = Context() @@ -1706,6 +1768,90 @@ async def test_wait_variables_out(hass: HomeAssistant, mode, action_type) -> Non assert float(remaining) == 0.0 +async def test_wait_in_sequence(hass: HomeAssistant) -> None: + """Test wait variable is still set after sequence ends.""" + sequence = cv.SCRIPT_SCHEMA( + [ + { + "alias": "Sequential group", + "sequence": [ + { + "alias": "variables", + "variables": {"state": "off"}, + }, + { + "alias": "wait template", + "wait_template": "{{ state == 'off' }}", + }, + ], + }, + ] + ) + + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + + expected_var = {"completed": True, "remaining": None} + + assert result.variables["wait"] == expected_var + + expected_trace = { + "0": [{"variables": {"wait": expected_var}}], + "0/sequence/0": [{"variables": {"state": "off"}}], + "0/sequence/1": [ + { + "result": {"wait": expected_var}, + "variables": {"wait": expected_var}, + } + ], + } + assert_action_trace(expected_trace) + + +async def test_wait_in_parallel(hass: HomeAssistant) -> None: + """Test wait variable is not set after parallel ends.""" + sequence = cv.SCRIPT_SCHEMA( + { + "parallel": [ + { + "alias": "Sequential group", + "sequence": [ + { + "alias": "variables", + "variables": {"state": "off"}, + }, + { + "alias": "wait template", + "wait_template": "{{ state == 'off' }}", + }, + ], + } + ] + } + ) + + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + + expected_var = {"completed": True, "remaining": None} + + assert "wait" not in result.variables + + expected_trace = { + "0": [{}], + "0/parallel/0/sequence/0": [{"variables": {"state": "off"}}], + "0/parallel/0/sequence/1": [ + { + "result": {"wait": expected_var}, + "variables": {"wait": expected_var}, + } + ], + } + assert_action_trace(expected_trace) + + async def test_wait_for_trigger_bad( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/helpers/test_script_variables.py b/tests/helpers/test_script_variables.py index 3675c857279..974a91674a7 100644 --- a/tests/helpers/test_script_variables.py +++ b/tests/helpers/test_script_variables.py @@ -5,12 +5,13 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.script_variables import ScriptRunVariables, ScriptVariables async def test_static_vars() -> None: """Test static vars.""" orig = {"hello": "world"} - var = cv.SCRIPT_VARIABLES_SCHEMA(orig) + var = ScriptVariables(orig) rendered = var.async_render(None, None) assert rendered is not orig assert rendered == orig @@ -20,31 +21,28 @@ async def test_static_vars_run_args() -> None: """Test static vars.""" orig = {"hello": "world"} orig_copy = dict(orig) - var = cv.SCRIPT_VARIABLES_SCHEMA(orig) + var = ScriptVariables(orig) rendered = var.async_render(None, {"hello": "override", "run": "var"}) assert rendered == {"hello": "override", "run": "var"} # Make sure we don't change original vars assert orig == orig_copy -async def test_static_vars_no_default() -> None: +async def test_static_vars_simple() -> None: """Test static vars.""" orig = {"hello": "world"} - var = cv.SCRIPT_VARIABLES_SCHEMA(orig) - rendered = var.async_render(None, None, render_as_defaults=False) - assert rendered is not orig - assert rendered == orig + var = ScriptVariables(orig) + rendered = var.async_simple_render({}) + assert rendered is orig -async def test_static_vars_run_args_no_default() -> None: +async def test_static_vars_run_args_simple() -> None: """Test static vars.""" orig = {"hello": "world"} orig_copy = dict(orig) - var = cv.SCRIPT_VARIABLES_SCHEMA(orig) - rendered = var.async_render( - None, {"hello": "override", "run": "var"}, render_as_defaults=False - ) - assert rendered == {"hello": "world", "run": "var"} + var = ScriptVariables(orig) + rendered = var.async_simple_render({"hello": "override", "run": "var"}) + assert rendered is orig # Make sure we don't change original vars assert orig == orig_copy @@ -78,14 +76,14 @@ async def test_template_vars_run_args(hass: HomeAssistant) -> None: } -async def test_template_vars_no_default(hass: HomeAssistant) -> None: +async def test_template_vars_simple(hass: HomeAssistant) -> None: """Test template vars.""" var = cv.SCRIPT_VARIABLES_SCHEMA({"hello": "{{ 1 + 1 }}"}) - rendered = var.async_render(hass, None, render_as_defaults=False) + rendered = var.async_simple_render({}) assert rendered == {"hello": 2} -async def test_template_vars_run_args_no_default(hass: HomeAssistant) -> None: +async def test_template_vars_run_args_simple(hass: HomeAssistant) -> None: """Test template vars.""" var = cv.SCRIPT_VARIABLES_SCHEMA( { @@ -93,16 +91,13 @@ async def test_template_vars_run_args_no_default(hass: HomeAssistant) -> None: "something_2": "{{ run_var_ex + 1 }}", } ) - rendered = var.async_render( - hass, + rendered = var.async_simple_render( { "run_var_ex": 5, "something_2": 1, - }, - render_as_defaults=False, + } ) assert rendered == { - "run_var_ex": 5, "something": 6, "something_2": 6, } @@ -113,3 +108,90 @@ async def test_template_vars_error(hass: HomeAssistant) -> None: var = cv.SCRIPT_VARIABLES_SCHEMA({"hello": "{{ canont.work }}"}) with pytest.raises(TemplateError): var.async_render(hass, None) + + +async def test_script_vars_exit_top_level() -> None: + """Test exiting top level script run variables.""" + script_vars = ScriptRunVariables.create_top_level() + with pytest.raises(ValueError): + script_vars.exit_scope() + + +async def test_script_vars_delete_var() -> None: + """Test deleting from script run variables.""" + script_vars = ScriptRunVariables.create_top_level({"x": 1, "y": 2}) + with pytest.raises(TypeError): + del script_vars["x"] + with pytest.raises(TypeError): + script_vars.pop("y") + assert script_vars._full_scope == {"x": 1, "y": 2} + + +async def test_script_vars_scopes() -> None: + """Test script run variables scopes.""" + script_vars = ScriptRunVariables.create_top_level() + script_vars["x"] = 1 + script_vars["y"] = 1 + assert script_vars["x"] == 1 + assert script_vars["y"] == 1 + + script_vars_2 = script_vars.enter_scope() + script_vars_2.define_local("x", 2) + assert script_vars_2["x"] == 2 + assert script_vars_2["y"] == 1 + + script_vars_3 = script_vars_2.enter_scope() + script_vars_3["x"] = 3 + script_vars_3["y"] = 3 + assert script_vars_3["x"] == 3 + assert script_vars_3["y"] == 3 + + script_vars_4 = script_vars_3.enter_scope() + assert script_vars_4["x"] == 3 + assert script_vars_4["y"] == 3 + + assert script_vars_4.exit_scope() is script_vars_3 + + assert script_vars_3._full_scope == {"x": 3, "y": 3} + assert script_vars_3.local_scope == {} + + assert script_vars_3.exit_scope() is script_vars_2 + + assert script_vars_2._full_scope == {"x": 3, "y": 3} + assert script_vars_2.local_scope == {"x": 3} + + assert script_vars_2.exit_scope() is script_vars + + assert script_vars._full_scope == {"x": 1, "y": 3} + assert script_vars.local_scope == {"x": 1, "y": 3} + + +async def test_script_vars_parallel() -> None: + """Test script run variables parallel support.""" + script_vars = ScriptRunVariables.create_top_level({"x": 1, "y": 1, "z": 1}) + + script_vars_2a = script_vars.enter_scope(parallel=True) + script_vars_3a = script_vars_2a.enter_scope() + + script_vars_2b = script_vars.enter_scope(parallel=True) + script_vars_3b = script_vars_2b.enter_scope() + + script_vars_3a["x"] = "a" + script_vars_3a.assign_parallel_protected("y", "a") + + script_vars_3b["x"] = "b" + script_vars_3b.assign_parallel_protected("y", "b") + + assert script_vars_3a._full_scope == {"x": "b", "y": "a", "z": 1} + assert script_vars_3a.non_parallel_scope == {"x": "a", "y": "a"} + + assert script_vars_3b._full_scope == {"x": "b", "y": "b", "z": 1} + assert script_vars_3b.non_parallel_scope == {"x": "b", "y": "b"} + + assert script_vars_3a.exit_scope() is script_vars_2a + assert script_vars_2a.exit_scope() is script_vars + assert script_vars_3b.exit_scope() is script_vars_2b + assert script_vars_2b.exit_scope() is script_vars + + assert script_vars._full_scope == {"x": "b", "y": 1, "z": 1} + assert script_vars.local_scope == {"x": "b", "y": 1, "z": 1} From 998757f09ee8bda5749633710d95bc88280b2b5e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:40:34 +0100 Subject: [PATCH 2808/2987] Add translatable states to SmartThings media source input (#139353) Add translatable states to media source input --- .../components/smartthings/sensor.py | 14 +++++++++ .../components/smartthings/strings.json | 29 ++++++++++++++++++- .../smartthings/snapshots/test_sensor.ambr | 20 +++++++++++-- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 73cc8c32a09..b77f3245040 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -67,6 +67,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): extra_state_attributes_fn: Callable[[Any], dict[str, Any]] | None = None unique_id_separator: str = "." capability_ignore_list: list[set[Capability]] | None = None + options_attribute: Attribute | None = None CAPABILITY_TO_SENSORS: dict[ @@ -374,6 +375,9 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.INPUT_SOURCE, translation_key="media_input_source", + device_class=SensorDeviceClass.ENUM, + options_attribute=Attribute.SUPPORTED_INPUT_SOURCES, + value_fn=lambda value: value.lower(), ) ] }, @@ -841,3 +845,13 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): self.get_attribute_value(self.capability, self._attribute) ) return None + + @property + def options(self) -> list[str] | None: + """Return the options for this sensor.""" + if self.entity_description.options_attribute: + options = self.get_attribute_value( + self.capability, self.entity_description.options_attribute + ) + return [option.lower() for option in options] + return super().options diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 2faf3df682d..d5989288769 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -128,7 +128,34 @@ "name": "Infrared level" }, "media_input_source": { - "name": "Media input source" + "name": "Media input source", + "state": { + "am": "AM", + "fm": "FM", + "cd": "CD", + "hdmi": "HDMI", + "hdmi1": "HDMI 1", + "hdmi2": "HDMI 2", + "hdmi3": "HDMI 3", + "hdmi4": "HDMI 4", + "hdmi5": "HDMI 5", + "hdmi6": "HDMI 6", + "digitaltv": "Digital TV", + "usb": "USB", + "youtube": "YouTube", + "aux": "AUX", + "bluetooth": "Bluetooth", + "digital": "Digital", + "melon": "Melon", + "wifi": "Wi-Fi", + "network": "Network", + "optical": "Optical", + "coaxial": "Coaxial", + "analog1": "Analog 1", + "analog2": "Analog 2", + "analog3": "Analog 3", + "phono": "Phono" + } }, "media_playback_repeat": { "name": "Media playback repeat" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 77d7ddf6643..6046b4381b5 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -4483,7 +4483,14 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'digitaltv', + 'hdmi1', + 'hdmi4', + 'hdmi4', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4501,7 +4508,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Media input source', 'platform': 'smartthings', @@ -4515,14 +4522,21 @@ # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': '[TV] Samsung 8 Series (49) Media input source', + 'options': list([ + 'digitaltv', + 'hdmi1', + 'hdmi4', + 'hdmi4', + ]), }), 'context': , 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'HDMI1', + 'state': 'hdmi1', }) # --- # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-entry] From 775a81829bd87560a874ed9e57c6f08ffd49bff0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:49:00 +0100 Subject: [PATCH 2809/2987] Add translatable states to SmartThings media playback (#139354) Add translatable states to media playback --- .../components/smartthings/sensor.py | 14 ++++ .../smartthings/snapshots/test_sensor.ambr | 66 +++++++++++++++++-- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index b77f3245040..0e4e4a11983 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -49,6 +49,10 @@ JOB_STATE_MAP = { "unknown": None, } +MEDIA_PLAYBACK_STATE_MAP = { + "fast forwarding": "fast_forwarding", +} + def power_attributes(status: dict[str, Any]) -> dict[str, Any]: """Return the power attributes.""" @@ -404,6 +408,16 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_STATUS, translation_key="media_playback_status", + options=[ + "paused", + "playing", + "stopped", + "fast_forwarding", + "rewinding", + "buffering", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: MEDIA_PLAYBACK_STATE_MAP.get(value, value), ) ] }, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 6046b4381b5..84575008c7a 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -4293,7 +4293,16 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4311,7 +4320,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Media playback status', 'platform': 'smartthings', @@ -4325,7 +4334,16 @@ # name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Elliots Rum Media playback status', + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), }), 'context': , 'entity_id': 'sensor.elliots_rum_media_playback_status', @@ -4388,7 +4406,16 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4406,7 +4433,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Media playback status', 'platform': 'smartthings', @@ -4420,7 +4447,16 @@ # name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Soundbar Living Media playback status', + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), }), 'context': , 'entity_id': 'sensor.soundbar_living_media_playback_status', @@ -4544,7 +4580,16 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4562,7 +4607,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Media playback status', 'platform': 'smartthings', @@ -4576,7 +4621,16 @@ # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': '[TV] Samsung 8 Series (49) Media playback status', + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), }), 'context': , 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', From fc1190dafd5a020059466fec76afc18bf6a6ed23 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:59:20 +0100 Subject: [PATCH 2810/2987] Add translatable states to oven mode in SmartThings (#139356) --- .../components/smartthings/sensor.py | 31 ++++++++++ .../components/smartthings/strings.json | 33 +++++++++- .../smartthings/snapshots/test_sensor.ambr | 62 ++++++++++++++++++- 3 files changed, 122 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 0e4e4a11983..d4f88964eee 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -53,6 +53,34 @@ MEDIA_PLAYBACK_STATE_MAP = { "fast forwarding": "fast_forwarding", } +OVEN_MODE = { + "Conventional": "conventional", + "Bake": "bake", + "BottomHeat": "bottom_heat", + "ConvectionBake": "convection_bake", + "ConvectionRoast": "convection_roast", + "Broil": "broil", + "ConvectionBroil": "convection_broil", + "SteamCook": "steam_cook", + "SteamBake": "steam_bake", + "SteamRoast": "steam_roast", + "SteamBottomHeatplusConvection": "steam_bottom_heat_plus_convection", + "Microwave": "microwave", + "MWplusGrill": "microwave_plus_grill", + "MWplusConvection": "microwave_plus_convection", + "MWplusHotBlast": "microwave_plus_hot_blast", + "MWplusHotBlast2": "microwave_plus_hot_blast_2", + "SlimMiddle": "slim_middle", + "SlimStrong": "slim_strong", + "SlowCook": "slow_cook", + "Proof": "proof", + "Dehydrate": "dehydrate", + "Others": "others", + "StrongSteam": "strong_steam", + "Descale": "descale", + "Rinse": "rinse", +} + def power_attributes(status: dict[str, Any]) -> dict[str, Any]: """Return the power attributes.""" @@ -435,6 +463,9 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.OVEN_MODE, translation_key="oven_mode", entity_category=EntityCategory.DIAGNOSTIC, + options=list(OVEN_MODE.values()), + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: OVEN_MODE.get(value, value), ) ] }, diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index d5989288769..b88c27fad77 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -170,7 +170,38 @@ "name": "Odor sensor" }, "oven_mode": { - "name": "Oven mode" + "name": "Oven mode", + "state": { + "heating": "Heating", + "grill": "Grill", + "warming": "Warming", + "defrosting": "Defrosting", + "conventional": "Conventional", + "bake": "Bake", + "bottom_heat": "Bottom heat", + "convection_bake": "Convection bake", + "convection_roast": "Convection roast", + "broil": "Broil", + "convection_broil": "Convection broil", + "steam_cook": "Steam cook", + "steam_bake": "Steam bake", + "steam_roast": "Steam roast", + "steam_bottom_heat_plus_convection": "Steam bottom heat plus convection", + "microwave": "Microwave", + "microwave_plus_grill": "Microwave plus grill", + "microwave_plus_convection": "Microwave plus convection", + "microwave_plus_hot_blast": "Microwave plus hot blast", + "microwave_plus_hot_blast_2": "Microwave plus hot blast 2", + "slim_middle": "Slim middle", + "slim_strong": "Slim strong", + "slow_cook": "Slow cook", + "proof": "Proof", + "dehydrate": "Dehydrate", + "others": "Others", + "strong_steam": "Strong steam", + "descale": "Descale", + "rinse": "Rinse" + } }, "oven_machine_state": { "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 84575008c7a..41691d26435 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1852,7 +1852,35 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1870,7 +1898,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Oven mode', 'platform': 'smartthings', @@ -1884,14 +1912,42 @@ # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Microwave Oven mode', + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), }), 'context': , 'entity_id': 'sensor.microwave_oven_mode', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Others', + 'state': 'others', }) # --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_set_point-entry] From b777c29bab497a018c4713670cb9eb288b7906e8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:12:27 +0100 Subject: [PATCH 2811/2987] Add translatable states to oven job state in SmartThings (#139361) --- .../components/smartthings/sensor.py | 29 ++++++++++++ .../components/smartthings/strings.json | 21 ++++++++- .../smartthings/snapshots/test_sensor.ambr | 44 ++++++++++++++++++- 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index d4f88964eee..91b9a09fd19 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -49,6 +49,14 @@ JOB_STATE_MAP = { "unknown": None, } +OVEN_JOB_STATE_MAP = { + "scheduledStart": "scheduled_start", + "fastPreheat": "fast_preheat", + "scheduledEnd": "scheduled_end", + "stone_heating": "stone_heating", + "timeHoldPreheat": "time_hold_preheat", +} + MEDIA_PLAYBACK_STATE_MAP = { "fast forwarding": "fast_forwarding", } @@ -480,6 +488,27 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.OVEN_JOB_STATE, translation_key="oven_job_state", + options=[ + "cleaning", + "cooking", + "cooling", + "draining", + "preheat", + "ready", + "rinsing", + "finished", + "scheduled_start", + "warming", + "defrosting", + "sensing", + "searing", + "fast_preheat", + "scheduled_end", + "stone_heating", + "time_hold_preheat", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: OVEN_JOB_STATE_MAP.get(value, value), ) ], Attribute.COMPLETION_TIME: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index b88c27fad77..5012cc9efa3 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -207,7 +207,26 @@ "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" }, "oven_job_state": { - "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", + "state": { + "cleaning": "Cleaning", + "cooking": "Cooking", + "cooling": "Cooling", + "draining": "Draining", + "preheat": "Preheat", + "ready": "Ready", + "rinsing": "Rinsing", + "finished": "Finished", + "scheduled_start": "Scheduled start", + "warming": "Warming", + "defrosting": "Defrosting", + "sensing": "Sensing", + "searing": "Searing", + "fast_preheat": "Fast preheat", + "scheduled_end": "Scheduled end", + "stone_heating": "Stone heating", + "time_hold_preheat": "Time hold preheat" + } }, "oven_setpoint": { "name": "Set point" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 41691d26435..dde39d8b515 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1758,7 +1758,27 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1776,7 +1796,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Job state', 'platform': 'smartthings', @@ -1790,7 +1810,27 @@ # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_job_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Microwave Job state', + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), }), 'context': , 'entity_id': 'sensor.microwave_job_state', From 51099ae7d67a3074c552433409148ffca9e16445 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:13:02 +0100 Subject: [PATCH 2812/2987] Add translatable states to oven machine state (#139358) --- homeassistant/components/smartthings/sensor.py | 2 ++ .../components/smartthings/strings.json | 7 ++++++- .../smartthings/snapshots/test_sensor.ambr | 16 ++++++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 91b9a09fd19..c05dd546623 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -482,6 +482,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, translation_key="oven_machine_state", + options=["ready", "running", "paused"], + device_class=SensorDeviceClass.ENUM, ) ], Attribute.OVEN_JOB_STATE: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 5012cc9efa3..897d07961bb 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -204,7 +204,12 @@ } }, "oven_machine_state": { - "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]", + "state": { + "ready": "Ready", + "running": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", + "paused": "[%key:common::state::paused%]" + } }, "oven_job_state": { "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index dde39d8b515..1741e3ed2a1 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1845,7 +1845,13 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1863,7 +1869,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Machine state', 'platform': 'smartthings', @@ -1877,7 +1883,13 @@ # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_machine_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Microwave Machine state', + 'options': list([ + 'ready', + 'running', + 'paused', + ]), }), 'context': , 'entity_id': 'sensor.microwave_machine_state', From cadee73da869438aa3be3f7c48d0dbefb2d19525 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:25:50 +0100 Subject: [PATCH 2813/2987] Add translatable states to robot cleaner movement in SmartThings (#139363) --- .../components/smartthings/sensor.py | 18 +++++++++++ .../components/smartthings/strings.json | 14 ++++++++- .../smartthings/snapshots/test_sensor.ambr | 30 +++++++++++++++++-- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index c05dd546623..c11ce51ceaa 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -61,6 +61,10 @@ MEDIA_PLAYBACK_STATE_MAP = { "fast forwarding": "fast_forwarding", } +ROBOT_CLEANER_MOVEMENT_MAP = { + "powerOff": "off", +} + OVEN_MODE = { "Conventional": "conventional", "Bake": "bake", @@ -625,6 +629,20 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.ROBOT_CLEANER_MOVEMENT, translation_key="robot_cleaner_movement", + options=[ + "homing", + "idle", + "charging", + "alarm", + "off", + "reserve", + "point", + "after", + "cleaning", + "pause", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: ROBOT_CLEANER_MOVEMENT_MAP.get(value, value), ) ] }, diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 897d07961bb..a5335be616e 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -255,7 +255,19 @@ "name": "Cleaning mode" }, "robot_cleaner_movement": { - "name": "Movement" + "name": "Movement", + "state": { + "homing": "Homing", + "idle": "[%key:common::state::idle%]", + "charging": "[%key:common::state::charging%]", + "alarm": "Alarm", + "off": "[%key:common::state::off%]", + "reserve": "Reserve", + "point": "Point", + "after": "After", + "cleaning": "Cleaning", + "pause": "[%key:common::state::paused%]" + } }, "robot_cleaner_turbo_mode": { "name": "Turbo mode" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 1741e3ed2a1..4db096fdb22 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2563,7 +2563,20 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'homing', + 'idle', + 'charging', + 'alarm', + 'off', + 'reserve', + 'point', + 'after', + 'cleaning', + 'pause', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2581,7 +2594,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Movement', 'platform': 'smartthings', @@ -2595,7 +2608,20 @@ # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_movement-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Robot vacuum Movement', + 'options': list([ + 'homing', + 'idle', + 'charging', + 'alarm', + 'off', + 'reserve', + 'point', + 'after', + 'cleaning', + 'pause', + ]), }), 'context': , 'entity_id': 'sensor.robot_vacuum_movement', From 5e5fd6a2f2810896d1e63457d6ba2d67c915639f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:33:13 +0100 Subject: [PATCH 2814/2987] Add translatable states to robot cleaner cleaning mode in SmartThings (#139362) * Add translatable states to robot cleaner cleaning mode in SmartThings * Update homeassistant/components/smartthings/strings.json * Update homeassistant/components/smartthings/strings.json --------- Co-authored-by: Josef Zweck --- .../components/smartthings/sensor.py | 2 ++ .../components/smartthings/strings.json | 10 ++++++++- .../smartthings/snapshots/test_sensor.ambr | 22 +++++++++++++++++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index c11ce51ceaa..f5c9fa823f0 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -620,6 +620,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.ROBOT_CLEANER_CLEANING_MODE, translation_key="robot_cleaner_cleaning_mode", + options=["auto", "part", "repeat", "manual", "stop", "map"], + device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, ) ], diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index a5335be616e..0fdb705091d 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -252,7 +252,15 @@ "name": "[%key:component::smartthings::entity::sensor::oven_setpoint::name%]" }, "robot_cleaner_cleaning_mode": { - "name": "Cleaning mode" + "name": "Cleaning mode", + "state": { + "auto": "Auto", + "part": "Partial", + "repeat": "Repeat", + "manual": "Manual", + "stop": "[%key:common::action::stop%]", + "map": "Map" + } }, "robot_cleaner_movement": { "name": "Movement", diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 4db096fdb22..22a67538098 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2516,7 +2516,16 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'auto', + 'part', + 'repeat', + 'manual', + 'stop', + 'map', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2534,7 +2543,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Cleaning mode', 'platform': 'smartthings', @@ -2548,7 +2557,16 @@ # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_cleaning_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Robot vacuum Cleaning mode', + 'options': list([ + 'auto', + 'part', + 'repeat', + 'manual', + 'stop', + 'map', + ]), }), 'context': , 'entity_id': 'sensor.robot_vacuum_cleaning_mode', From 92268f894a31b7d1e39009f198d36237a3882a06 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:34:29 +0100 Subject: [PATCH 2815/2987] Add translatable states to washer machine state in SmartThings (#139366) --- homeassistant/components/smartthings/sensor.py | 6 +++++- .../components/smartthings/strings.json | 7 ++++++- .../smartthings/snapshots/test_sensor.ambr | 16 ++++++++++++++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index f5c9fa823f0..65c48d5e0fe 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -93,6 +93,8 @@ OVEN_MODE = { "Rinse": "rinse", } +WASHER_OPTIONS = ["pause", "run", "stop"] + def power_attributes(status: dict[str, Any]) -> dict[str, Any]: """Return the power attributes.""" @@ -242,7 +244,7 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, translation_key="dishwasher_machine_state", - options=["pause", "run", "stop"], + options=WASHER_OPTIONS, device_class=SensorDeviceClass.ENUM, ) ], @@ -847,6 +849,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, translation_key="washer_machine_state", + options=WASHER_OPTIONS, + device_class=SensorDeviceClass.ENUM, ) ], Attribute.WASHER_JOB_STATE: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 0fdb705091d..6c14d5c2a4d 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -326,7 +326,12 @@ "name": "Washer mode" }, "washer_machine_state": { - "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]", + "state": { + "pause": "[%key:common::state::paused%]", + "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", + "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + } }, "washer_job_state": { "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 22a67538098..87fe69b9640 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -3798,7 +3798,13 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3816,7 +3822,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Machine state', 'platform': 'smartthings', @@ -3830,7 +3836,13 @@ # name: test_all_entities[da_wm_wm_000001][sensor.washer_machine_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Washer Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), }), 'context': , 'entity_id': 'sensor.washer_machine_state', From 468208502f58fb271885431a4de57d985b66a52a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:52:57 +0100 Subject: [PATCH 2816/2987] Add translatable states to smoke detector in SmartThings (#139365) --- homeassistant/components/smartthings/sensor.py | 2 ++ homeassistant/components/smartthings/strings.json | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 65c48d5e0fe..c966899f8f9 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -684,6 +684,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.SMOKE, translation_key="smoke_detector", + options=["detected", "clear", "tested"], + device_class=SensorDeviceClass.ENUM, ) ] }, diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 6c14d5c2a4d..fb260d8f689 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -284,7 +284,12 @@ "name": "Link quality" }, "smoke_detector": { - "name": "Smoke detector" + "name": "Smoke detector", + "state": { + "detected": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::detected%]", + "clear": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::clear%]", + "tested": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::tested%]" + } }, "thermostat_cooling_setpoint": { "name": "Cooling set point" From 3eea932b240ea170734fac999df7c11e0c4b82f5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:53:16 +0100 Subject: [PATCH 2817/2987] Add translatable states to robot cleaner turbo mode in SmartThings (#139364) --- homeassistant/components/smartthings/sensor.py | 9 +++++++++ .../components/smartthings/strings.json | 8 +++++++- .../smartthings/snapshots/test_sensor.ambr | 18 ++++++++++++++++-- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index c966899f8f9..5e07112e677 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -61,6 +61,10 @@ MEDIA_PLAYBACK_STATE_MAP = { "fast forwarding": "fast_forwarding", } +ROBOT_CLEANER_TURBO_MODE_STATE_MAP = { + "extraSilence": "extra_silence", +} + ROBOT_CLEANER_MOVEMENT_MAP = { "powerOff": "off", } @@ -655,6 +659,11 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.ROBOT_CLEANER_TURBO_MODE, translation_key="robot_cleaner_turbo_mode", + options=["on", "off", "silence", "extra_silence"], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: ROBOT_CLEANER_TURBO_MODE_STATE_MAP.get( + value, value + ), entity_category=EntityCategory.DIAGNOSTIC, ) ] diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index fb260d8f689..c17e63357ff 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -278,7 +278,13 @@ } }, "robot_cleaner_turbo_mode": { - "name": "Turbo mode" + "name": "Turbo mode", + "state": { + "on": "[%key:common::state::on%]", + "off": "[%key:common::state::off%]", + "silence": "Silent", + "extra_silence": "Extra silent" + } }, "link_quality": { "name": "Link quality" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 87fe69b9640..eecd801d062 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2654,7 +2654,14 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'off', + 'silence', + 'extra_silence', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2672,7 +2679,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Turbo mode', 'platform': 'smartthings', @@ -2686,7 +2693,14 @@ # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_turbo_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Robot vacuum Turbo mode', + 'options': list([ + 'on', + 'off', + 'silence', + 'extra_silence', + ]), }), 'context': , 'entity_id': 'sensor.robot_vacuum_turbo_mode', From 269482845150a4bea36ec9a3d221ccce6a835d4f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 18:07:56 +0100 Subject: [PATCH 2818/2987] Add translatable states to washer job state in SmartThings (#139368) * Add translatable states to washer job state in SmartThings * fix * Update homeassistant/components/smartthings/sensor.py --- .../components/smartthings/sensor.py | 30 +++++++++++- .../components/smartthings/strings.json | 22 ++++++++- .../smartthings/snapshots/test_sensor.ambr | 46 +++++++++++++++++-- 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 5e07112e677..e0fded8f801 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -43,6 +43,14 @@ THERMOSTAT_CAPABILITIES = { } JOB_STATE_MAP = { + "airWash": "air_wash", + "airwash": "air_wash", + "aIRinse": "ai_rinse", + "aISpin": "ai_spin", + "aIWash": "ai_wash", + "delayWash": "delay_wash", + "weightSensing": "weight_sensing", + "freezeProtection": "freeze_protection", "preDrain": "pre_drain", "preWash": "pre_wash", "wrinklePrevent": "wrinkle_prevent", @@ -257,7 +265,7 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.DISHWASHER_JOB_STATE, translation_key="dishwasher_job_state", options=[ - "airwash", + "air_wash", "cooling", "drying", "finish", @@ -868,6 +876,26 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.WASHER_JOB_STATE, translation_key="washer_job_state", + options=[ + "air_wash", + "ai_rinse", + "ai_spin", + "ai_wash", + "cooling", + "delay_wash", + "drying", + "finish", + "none", + "pre_wash", + "rinse", + "spin", + "wash", + "weight_sensing", + "wrinkle_prevent", + "freeze_protection", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: JOB_STATE_MAP.get(value, value), ) ], Attribute.COMPLETION_TIME: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index c17e63357ff..3130c618a2c 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -85,7 +85,7 @@ "dishwasher_job_state": { "name": "Job state", "state": { - "airwash": "Airwash", + "air_wash": "Air wash", "cooling": "Cooling", "drying": "Drying", "finish": "Finish", @@ -345,7 +345,25 @@ } }, "washer_job_state": { - "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", + "state": { + "air_wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::air_wash%]", + "ai_rise": "AI rise", + "ai_spin": "AI spin", + "ai_wash": "AI wash", + "cooling": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::cooling%]", + "delay_wash": "Delay wash", + "drying": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::drying%]", + "finish": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::finish%]", + "none": "None", + "pre_wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::pre_wash%]", + "rinse": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::rinse%]", + "spin": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::spin%]", + "wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wash%]", + "weight_sensing": "Weight sensing", + "wrinkle_prevent": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wrinkle_prevent%]", + "freeze_protection": "Freeze protection" + } } } } diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index eecd801d062..5531e520ec7 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2921,7 +2921,7 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - 'airwash', + 'air_wash', 'cooling', 'drying', 'finish', @@ -2967,7 +2967,7 @@ 'device_class': 'enum', 'friendly_name': 'Dishwasher Job state', 'options': list([ - 'airwash', + 'air_wash', 'cooling', 'drying', 'finish', @@ -3765,7 +3765,26 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3783,7 +3802,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Job state', 'platform': 'smartthings', @@ -3797,7 +3816,26 @@ # name: test_all_entities[da_wm_wm_000001][sensor.washer_job_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Washer Job state', + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), }), 'context': , 'entity_id': 'sensor.washer_job_state', From 5be7f491469c7549be1c234e34dcb11dd14b4f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 26 Feb 2025 18:11:40 +0100 Subject: [PATCH 2819/2987] Improve Home Connect oven cavity temperature sensor (#139355) * Improve oven cavity temperature translation * Fetch cavity temperature unit * Handle generic Home Connect error * Improve test clarity --- .../components/home_connect/const.py | 9 ++ .../components/home_connect/number.py | 9 +- .../components/home_connect/sensor.py | 30 ++++++- .../components/home_connect/strings.json | 4 +- tests/components/home_connect/test_sensor.py | 83 +++++++++++++++++++ 5 files changed, 124 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 692a5e91851..66c635f5d95 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -4,6 +4,8 @@ from typing import cast from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey, StatusKey +from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume + from .utils import bsh_key_to_translation_key DOMAIN = "home_connect" @@ -21,6 +23,13 @@ APPLIANCES_WITH_PROGRAMS = ( "WasherDryer", ) +UNIT_MAP = { + "seconds": UnitOfTime.SECONDS, + "ml": UnitOfVolume.MILLILITERS, + "°C": UnitOfTemperature.CELSIUS, + "°F": UnitOfTemperature.FAHRENHEIT, +} + BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On" BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off" diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 404f063946c..cef35005b32 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -11,7 +11,6 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,6 +22,7 @@ from .const import ( SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, + UNIT_MAP, ) from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity, HomeConnectOptionEntity @@ -32,13 +32,6 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 -UNIT_MAP = { - "seconds": UnitOfTime.SECONDS, - "ml": UnitOfVolume.MILLILITERS, - "°C": UnitOfTemperature.CELSIUS, - "°F": UnitOfTemperature.FAHRENHEIT, -} - NUMBERS = ( NumberEntityDescription( key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 3f85bc3404c..924744ded56 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,10 +1,12 @@ """Provides a sensor for Home Connect.""" +import contextlib from dataclasses import dataclass from datetime import timedelta from typing import cast from aiohomeconnect.model import EventKey, StatusKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.sensor import ( SensorDeviceClass, @@ -23,6 +25,7 @@ from .const import ( BSH_OPERATION_STATE_FINISHED, BSH_OPERATION_STATE_PAUSE, BSH_OPERATION_STATE_RUN, + UNIT_MAP, ) from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity @@ -40,6 +43,7 @@ class HomeConnectSensorEntityDescription( default_value: str | None = None appliance_types: tuple[str, ...] | None = None + fetch_unit: bool = False BSH_PROGRAM_SENSORS = ( @@ -183,7 +187,8 @@ SENSORS = ( key=StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - translation_key="current_cavity_temperature", + translation_key="oven_current_cavity_temperature", + fetch_unit=True, ), ) @@ -318,6 +323,29 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): case _: self._attr_native_value = status + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + if self.entity_description.fetch_unit: + data = self.appliance.status[cast(StatusKey, self.bsh_key)] + if data.unit: + self._attr_native_unit_of_measurement = UNIT_MAP.get( + data.unit, data.unit + ) + else: + await self.fetch_unit() + + async def fetch_unit(self) -> None: + """Fetch the unit of measurement.""" + with contextlib.suppress(HomeConnectError): + data = await self.coordinator.client.get_status_value( + self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key) + ) + if data.unit: + self._attr_native_unit_of_measurement = UNIT_MAP.get( + data.unit, data.unit + ) + class HomeConnectProgramSensor(HomeConnectSensor): """Sensor class for Home Connect sensors that reports information related to the running program.""" diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 4fabd1e1c50..92b59919583 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -1529,8 +1529,8 @@ "map3": "Map 3" } }, - "current_cavity_temperature": { - "name": "Current cavity temperature" + "oven_current_cavity_temperature": { + "name": "Current oven cavity temperature" }, "freezer_door_alarm": { "name": "Freezer door alarm", diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index 1ec137b95be..31fc9ea6d3f 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, + ArrayOfStatus, Event, EventKey, EventMessage, @@ -565,3 +566,85 @@ async def test_sensors_states( ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) + + +@pytest.mark.parametrize( + ( + "appliance_ha_id", + "entity_id", + "status_key", + "unit_get_status", + "unit_get_status_value", + "get_status_value_call_count", + ), + [ + ( + "Oven", + "sensor.oven_current_oven_cavity_temperature", + StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + "°C", + None, + 0, + ), + ( + "Oven", + "sensor.oven_current_oven_cavity_temperature", + StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + None, + "°C", + 1, + ), + ], + indirect=["appliance_ha_id"], +) +async def test_sensor_unit_fetching( + appliance_ha_id: str, + entity_id: str, + status_key: StatusKey, + unit_get_status: str | None, + unit_get_status_value: str | None, + get_status_value_call_count: int, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the sensor entities are capable of fetching units.""" + + async def get_status_mock(ha_id: str) -> ArrayOfStatus: + if ha_id != appliance_ha_id: + return ArrayOfStatus([]) + return ArrayOfStatus( + [ + Status( + key=status_key, + raw_key=status_key.value, + value=0, + unit=unit_get_status, + ) + ] + ) + + client.get_status = AsyncMock(side_effect=get_status_mock) + client.get_status_value = AsyncMock( + return_value=Status( + key=status_key, + raw_key=status_key.value, + value=0, + unit=unit_get_status_value, + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + entity_state = hass.states.get(entity_id) + assert entity_state + assert ( + entity_state.attributes["unit_of_measurement"] == unit_get_status + or unit_get_status_value + ) + + assert client.get_status_value.call_count == get_status_value_call_count From 561b3ae21b2170d80ab70f3ee86bf994dec02e26 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 18:14:59 +0100 Subject: [PATCH 2820/2987] Add translatable states to dryer machine state in Smartthings (#139369) --- homeassistant/components/smartthings/sensor.py | 2 ++ .../components/smartthings/strings.json | 7 ++++++- .../smartthings/snapshots/test_sensor.ambr | 16 ++++++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index e0fded8f801..8d53b830707 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -304,6 +304,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, translation_key="dryer_machine_state", + options=WASHER_OPTIONS, + device_class=SensorDeviceClass.ENUM, ) ], Attribute.DRYER_JOB_STATE: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 3130c618a2c..40e14fc1b51 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -104,7 +104,12 @@ "name": "Dryer mode" }, "dryer_machine_state": { - "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]", + "state": { + "pause": "[%key:common::state::paused%]", + "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", + "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + } }, "dryer_job_state": { "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 5531e520ec7..122ced1eb6f 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -3408,7 +3408,13 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3426,7 +3432,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Machine state', 'platform': 'smartthings', @@ -3440,7 +3446,13 @@ # name: test_all_entities[da_wm_wd_000001][sensor.dryer_machine_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Dryer Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), }), 'context': , 'entity_id': 'sensor.dryer_machine_state', From 25ee2e58a5a34e28a51c358cb8d5affcc9483e56 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 18:15:14 +0100 Subject: [PATCH 2821/2987] Add translatable states to dryer job state in SmartThings (#139370) * Add translatable states to washer job state in SmartThings * Add translatable states to dryer job state in Smartthings * fix * fix --- .../components/smartthings/sensor.py | 23 +++++++++++ .../components/smartthings/strings.json | 19 ++++++++- .../smartthings/snapshots/test_sensor.ambr | 40 ++++++++++++++++++- 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 8d53b830707..d7aaaaa84c5 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -48,6 +48,10 @@ JOB_STATE_MAP = { "aIRinse": "ai_rinse", "aISpin": "ai_spin", "aIWash": "ai_wash", + "aIDrying": "ai_drying", + "internalCare": "internal_care", + "continuousDehumidifying": "continuous_dehumidifying", + "thawingFrozenInside": "thawing_frozen_inside", "delayWash": "delay_wash", "weightSensing": "weight_sensing", "freezeProtection": "freeze_protection", @@ -312,6 +316,25 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.DRYER_JOB_STATE, translation_key="dryer_job_state", + options=[ + "cooling", + "delay_wash", + "drying", + "finished", + "none", + "refreshing", + "weight_sensing", + "wrinkle_prevent", + "dehumidifying", + "ai_drying", + "sanitizing", + "internal_care", + "freeze_protection", + "continuous_dehumidifying", + "thawing_frozen_inside", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: JOB_STATE_MAP.get(value, value), ) ], Attribute.COMPLETION_TIME: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 40e14fc1b51..9a757b4e9e8 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -112,7 +112,24 @@ } }, "dryer_job_state": { - "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", + "state": { + "cooling": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::cooling%]", + "delay_wash": "[%key:component::smartthings::entity::sensor::washer_job_state::state::delay_wash%]", + "drying": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::drying%]", + "finished": "[%key:component::smartthings::entity::sensor::oven_job_state::state::finished%]", + "none": "[%key:component::smartthings::entity::sensor::washer_job_state::state::none%]", + "refreshing": "Refreshing", + "weight_sensing": "[%key:component::smartthings::entity::sensor::washer_job_state::state::weight_sensing%]", + "wrinkle_prevent": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wrinkle_prevent%]", + "dehumidifying": "Dehumidifying", + "ai_drying": "AI drying", + "sanitizing": "Sanitizing", + "internal_care": "Internal care", + "freeze_protection": "Freeze protection", + "continuous_dehumidifying": "Continuous dehumidifying", + "thawing_frozen_inside": "Thawing frozen inside" + } }, "equivalent_carbon_dioxide": { "name": "Equivalent carbon dioxide" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 122ced1eb6f..f487ff632a1 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -3361,7 +3361,25 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3379,7 +3397,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Job state', 'platform': 'smartthings', @@ -3393,7 +3411,25 @@ # name: test_all_entities[da_wm_wd_000001][sensor.dryer_job_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Dryer Job state', + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), }), 'context': , 'entity_id': 'sensor.dryer_job_state', From 3a21c3617377d5581fbf1f9e38eaa66f7f45ad13 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 18:19:28 +0100 Subject: [PATCH 2822/2987] Don't create entities for disabled capabilities in SmartThings (#139343) * Don't create entities for disabled capabilities in SmartThings * Fix * fix * fix --- .../components/smartthings/__init__.py | 28 +- .../smartthings/snapshots/test_cover.ambr | 49 -- .../smartthings/snapshots/test_sensor.ambr | 456 ------------------ 3 files changed, 26 insertions(+), 507 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index d580e36e45e..846170552e9 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from aiohttp import ClientError from pysmartthings import ( @@ -93,7 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) try: devices = await client.get_devices() for device in devices: - status = await client.get_device_status(device.device_id) + status = process_status(await client.get_device_status(device.device_id)) device_status[device.device_id] = FullDevice(device=device, status=status) except SmartThingsAuthenticationFailedError as err: raise ConfigEntryAuthFailed from err @@ -143,3 +143,27 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return True + + +def process_status( + status: dict[str, dict[Capability, dict[Attribute, Status]]], +) -> dict[str, dict[Capability, dict[Attribute, Status]]]: + """Remove disabled capabilities from status.""" + if (main_component := status.get("main")) is None or ( + disabled_capabilities_capability := main_component.get( + Capability.CUSTOM_DISABLED_CAPABILITIES + ) + ) is None: + return status + disabled_capabilities = cast( + list[Capability], + disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, + ) + for capability in disabled_capabilities: + # We still need to make sure the climate entity can work without this capability + if ( + capability in main_component + and capability != Capability.DEMAND_RESPONSE_LOAD_CONTROL + ): + del main_component[capability] + return status diff --git a/tests/components/smartthings/snapshots/test_cover.ambr b/tests/components/smartthings/snapshots/test_cover.ambr index 102be416cea..aa928c09b7a 100644 --- a/tests/components/smartthings/snapshots/test_cover.ambr +++ b/tests/components/smartthings/snapshots/test_cover.ambr @@ -49,52 +49,3 @@ 'state': 'open', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][cover.microwave-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'cover', - 'entity_category': None, - 'entity_id': 'cover.microwave', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ks_microwave_0101x][cover.microwave-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Microwave', - 'supported_features': , - }), - 'context': , - 'entity_id': 'cover.microwave', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index f487ff632a1..778b05fa183 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -521,57 +521,6 @@ 'state': '15.0', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_air_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': None, - 'entity_id': 'sensor.ac_office_granit_air_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': 'Air quality', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'air_quality', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.airQuality', - 'unit_of_measurement': 'CAQI', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_air_quality-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Air quality', - 'state_class': , - 'unit_of_measurement': 'CAQI', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_air_quality', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -780,110 +729,6 @@ 'state': '60', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm10-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.ac_office_granit_pm10', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'PM10', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.dustLevel', - 'unit_of_measurement': 'μg/m^3', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm10-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pm10', - 'friendly_name': 'AC Office Granit PM10', - 'state_class': , - 'unit_of_measurement': 'μg/m^3', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_pm10', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm2_5-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_pm2_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'PM2.5', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.fineDustLevel', - 'unit_of_measurement': 'μg/m^3', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm2_5-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pm25', - 'friendly_name': 'AC Office Granit PM2.5', - 'state_class': , - 'unit_of_measurement': 'μg/m^3', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_pm2_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1090,57 +935,6 @@ 'state': '100', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_air_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': None, - 'entity_id': 'sensor.aire_dormitorio_principal_air_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': 'Air quality', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'air_quality', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.airQuality', - 'unit_of_measurement': 'CAQI', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_air_quality-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Air quality', - 'state_class': , - 'unit_of_measurement': 'CAQI', - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_air_quality', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1349,157 +1143,6 @@ 'state': '42', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_odor_sensor-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': None, - 'entity_id': 'sensor.aire_dormitorio_principal_odor_sensor', - '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': 'Odor sensor', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'odor_sensor', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.odorLevel', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_odor_sensor-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Odor sensor', - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_odor_sensor', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm10-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.aire_dormitorio_principal_pm10', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'PM10', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.dustLevel', - 'unit_of_measurement': 'µg/m³', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm10-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pm10', - 'friendly_name': 'Aire Dormitorio Principal PM10', - 'state_class': , - 'unit_of_measurement': 'µg/m³', - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_pm10', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm2_5-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_pm2_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'PM2.5', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.fineDustLevel', - 'unit_of_measurement': 'µg/m³', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm2_5-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pm25', - 'friendly_name': 'Aire Dormitorio Principal PM2.5', - 'state_class': , - 'unit_of_measurement': 'µg/m³', - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_pm2_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2101,54 +1744,6 @@ 'state': '-17', }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_cooling_set_point-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': None, - 'entity_id': 'sensor.refrigerator_cooling_set_point', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Cooling set point', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'thermostat_cooling_setpoint', - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.coolingSetpoint', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_cooling_set_point-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Refrigerator Cooling set point', - }), - 'context': , - 'entity_id': 'sensor.refrigerator_cooling_set_point', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2411,57 +2006,6 @@ 'state': '0.0135559777781698', }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_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.refrigerator_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': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.temperature', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Refrigerator Temperature', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.refrigerator_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 2e972422c29fefe7bda97476eb87b0d931df9b8c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 18:19:45 +0100 Subject: [PATCH 2823/2987] Fix typo in SmartThing string (#139373) --- homeassistant/components/smartthings/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9a757b4e9e8..e5ffbe35e8b 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -370,7 +370,7 @@ "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", "state": { "air_wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::air_wash%]", - "ai_rise": "AI rise", + "ai_rinse": "AI rinse", "ai_spin": "AI spin", "ai_wash": "AI wash", "cooling": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::cooling%]", From 7f0db3181d13a2b80bfea7f3b3edc1604b180ed1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 26 Feb 2025 19:54:29 +0100 Subject: [PATCH 2824/2987] Bump version to 2025.4.0 (#139381) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8745ab63470..6145e985ce3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 11 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 9 - HA_SHORT_VERSION: "2025.3" + HA_SHORT_VERSION: "2025.4" DEFAULT_PYTHON: "3.13" ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 7775b618795..b9695c350a7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 -MINOR_VERSION: Final = 3 +MINOR_VERSION: Final = 4 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index a7e3917eb90..eda2a495726 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0.dev0" +version = "2025.4.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 9dbce6d904e8db6c19d7b440f7cbdbe9ec1ab287 Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Wed, 26 Feb 2025 20:31:24 +0100 Subject: [PATCH 2825/2987] Bump stookwijzer==1.6.1 (#139380) --- homeassistant/components/stookwijzer/__init__.py | 8 ++++---- homeassistant/components/stookwijzer/config_flow.py | 6 +++--- homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/stookwijzer/conftest.py | 8 ++++---- tests/components/stookwijzer/test_config_flow.py | 6 +++--- tests/components/stookwijzer/test_init.py | 6 +++--- 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index a4a00e4d1b8..9adfc09de0e 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -42,12 +42,12 @@ async def async_migrate_entry( LOGGER.debug("Migrating from version %s", entry.version) if entry.version == 1: - longitude, latitude = await Stookwijzer.async_transform_coordinates( + xy = await Stookwijzer.async_transform_coordinates( entry.data[CONF_LOCATION][CONF_LATITUDE], entry.data[CONF_LOCATION][CONF_LONGITUDE], ) - if not longitude or not latitude: + if not xy: ir.async_create_issue( hass, DOMAIN, @@ -65,8 +65,8 @@ async def async_migrate_entry( entry, version=2, data={ - CONF_LATITUDE: latitude, - CONF_LONGITUDE: longitude, + CONF_LATITUDE: xy["x"], + CONF_LONGITUDE: xy["y"], }, ) diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index 52283e4842d..ff14bce26e6 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -25,14 +25,14 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" errors = {} if user_input is not None: - longitude, latitude = await Stookwijzer.async_transform_coordinates( + xy = await Stookwijzer.async_transform_coordinates( user_input[CONF_LOCATION][CONF_LATITUDE], user_input[CONF_LOCATION][CONF_LONGITUDE], ) - if longitude and latitude: + if xy: return self.async_create_entry( title="Stookwijzer", - data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude}, + data={CONF_LATITUDE: xy["x"], CONF_LONGITUDE: xy["y"]}, ) errors["base"] = "unknown" diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 9b4cea567be..dd10f57f485 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.6.0"] + "requirements": ["stookwijzer==1.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 082524036e8..dcda559d7d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2808,7 +2808,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.6.0 +stookwijzer==1.6.1 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8cac6cc79d0..5ed82bd81b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2269,7 +2269,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.6.0 +stookwijzer==1.6.1 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index 95a60e623a3..40582dc4be3 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -70,10 +70,10 @@ def mock_stookwijzer() -> Generator[MagicMock]: new=stookwijzer_mock, ), ): - stookwijzer_mock.async_transform_coordinates.return_value = ( - 450000.123456789, - 200000.123456789, - ) + stookwijzer_mock.async_transform_coordinates.return_value = { + "x": 450000.123456789, + "y": 200000.123456789, + } client = stookwijzer_mock.return_value client.lki = 2 diff --git a/tests/components/stookwijzer/test_config_flow.py b/tests/components/stookwijzer/test_config_flow.py index 6dddf83c27a..060d2bdc26c 100644 --- a/tests/components/stookwijzer/test_config_flow.py +++ b/tests/components/stookwijzer/test_config_flow.py @@ -32,8 +32,8 @@ async def test_full_user_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Stookwijzer" assert result["data"] == { - CONF_LATITUDE: 200000.123456789, - CONF_LONGITUDE: 450000.123456789, + CONF_LATITUDE: 450000.123456789, + CONF_LONGITUDE: 200000.123456789, } assert len(mock_setup_entry.mock_calls) == 1 @@ -47,7 +47,7 @@ async def test_connection_error( ) -> None: """Test user configuration flow while connection fails.""" original_return_value = mock_stookwijzer.async_transform_coordinates.return_value - mock_stookwijzer.async_transform_coordinates.return_value = (None, None) + mock_stookwijzer.async_transform_coordinates.return_value = None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} diff --git a/tests/components/stookwijzer/test_init.py b/tests/components/stookwijzer/test_init.py index ddefb6be772..4306b9afc26 100644 --- a/tests/components/stookwijzer/test_init.py +++ b/tests/components/stookwijzer/test_init.py @@ -66,8 +66,8 @@ async def test_migrate_entry( assert mock_v1_config_entry.version == 2 assert mock_v1_config_entry.data == { - CONF_LATITUDE: 200000.123456789, - CONF_LONGITUDE: 450000.123456789, + CONF_LATITUDE: 450000.123456789, + CONF_LONGITUDE: 200000.123456789, } @@ -81,7 +81,7 @@ async def test_entry_migration_failure( assert mock_v1_config_entry.version == 1 # Failed getting the transformed coordinates - mock_stookwijzer.async_transform_coordinates.return_value = (None, None) + mock_stookwijzer.async_transform_coordinates.return_value = None mock_v1_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_v1_config_entry.entry_id) From 6d7dad41d9ac70b1991f6e8360d93b5a817deb9a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Feb 2025 20:31:45 +0100 Subject: [PATCH 2826/2987] Bump hatasmota to 0.10.0 (#139382) --- homeassistant/components/tasmota/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 783483c6ffd..2e0d8af2338 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["HATasmota==0.9.2"] + "requirements": ["HATasmota==0.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index dcda559d7d3..0fc22d7564b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -19,7 +19,7 @@ DoorBirdPy==3.0.8 HAP-python==4.9.2 # homeassistant.components.tasmota -HATasmota==0.9.2 +HATasmota==0.10.0 # homeassistant.components.mastodon Mastodon.py==1.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ed82bd81b0..cb8d26677e2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -19,7 +19,7 @@ DoorBirdPy==3.0.8 HAP-python==4.9.2 # homeassistant.components.tasmota -HATasmota==0.9.2 +HATasmota==0.10.0 # homeassistant.components.mastodon Mastodon.py==1.8.1 From 42f55bf271ab872754a112d842e7edb54b05de78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 26 Feb 2025 21:02:00 +0100 Subject: [PATCH 2827/2987] Small improvements to Home Connect strings and icons (#139386) * Small improvements to Home Connect strings and icons * Fix test --- .../components/home_connect/icons.json | 17 +++++++++++++++++ .../components/home_connect/strings.json | 10 +++++----- tests/components/home_connect/test_entity.py | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json index 651c00328b6..f781db3ab24 100644 --- a/homeassistant/components/home_connect/icons.json +++ b/homeassistant/components/home_connect/icons.json @@ -49,6 +49,23 @@ "default": "mdi:map-marker-remove-variant" } }, + "button": { + "open_door": { + "default": "mdi:door-open" + }, + "partly_open_door": { + "default": "mdi:door-open" + }, + "pause_program": { + "default": "mdi:pause" + }, + "resume_program": { + "default": "mdi:play" + }, + "stop_program": { + "default": "mdi:stop" + } + }, "sensor": { "operation_state": { "default": "mdi:state-machine", diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 92b59919583..7b06128dbe6 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -354,7 +354,7 @@ "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" + "consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "Intense +" } }, "coffee_milk_ratio": { @@ -410,7 +410,7 @@ "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_cupboard_dry_plus": "Cupboard dry +", "laundry_care_dryer_enum_type_drying_target_extra_dry": "Extra dry" } }, @@ -592,7 +592,7 @@ "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", + "name": "Vario speed +", "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": { @@ -608,7 +608,7 @@ "description": "Defines if improved drying for glasses and plasticware is enabled." }, "dishcare_dishwasher_option_hygiene_plus": { - "name": "Hygiene plus", + "name": "Hygiene +", "description": "Defines if the cleaning is done with increased temperature. This ensures maximum hygienic cleanliness for regular use." }, "dishcare_dishwasher_option_eco_dry": { @@ -1462,7 +1462,7 @@ "inactive": "Inactive", "ready": "Ready", "delayedstart": "Delayed start", - "run": "Run", + "run": "Running", "pause": "[%key:common::state::paused%]", "actionrequired": "Action required", "finished": "Finished", diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py index f173cda0b0c..2422cbe547c 100644 --- a/tests/components/home_connect/test_entity.py +++ b/tests/components/home_connect/test_entity.py @@ -85,7 +85,7 @@ def platforms() -> list[str]: [False, True, True], ( OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS, - "switch.dishwasher_hygiene_plus", + "switch.dishwasher_hygiene", ), (OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY, "switch.dishwasher_extra_dry"), ) From f3fb7cd8e83de7b7910dddc647f13d73c14cb481 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 26 Feb 2025 14:14:03 -0600 Subject: [PATCH 2828/2987] Bump intents to 2025.2.26 (#139387) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 2d4a8053d75..c4f1860eed6 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.5"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.26"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b248be0eb96..c49580ae47b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250226.0 -home-assistant-intents==2025.2.5 +home-assistant-intents==2025.2.26 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.5 diff --git a/requirements_all.txt b/requirements_all.txt index 0fc22d7564b..acac164af8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1155,7 +1155,7 @@ holidays==0.67 home-assistant-frontend==20250226.0 # homeassistant.components.conversation -home-assistant-intents==2025.2.5 +home-assistant-intents==2025.2.26 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb8d26677e2..e37faf9f609 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -984,7 +984,7 @@ holidays==0.67 home-assistant-frontend==20250226.0 # homeassistant.components.conversation -home-assistant-intents==2025.2.5 +home-assistant-intents==2025.2.26 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index b2e4005cf79..1f177643bd5 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.7 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.26 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From 036eef2b6bc3941d05a53a050089f2e558df9e51 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 26 Feb 2025 15:22:08 -0500 Subject: [PATCH 2829/2987] Bump ZHA to 0.0.51 (#139383) * Bump ZHA to 0.0.51 * Fix unit tests not accounting for primary entities --- homeassistant/components/zha/entity.py | 4 ++++ homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../zha/snapshots/test_diagnostics.ambr | 11 +--------- tests/components/zha/test_sensor.py | 21 ++++++++++++------- tests/components/zha/test_websocket_api.py | 7 +++++-- 7 files changed, 26 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 499721722fa..e3339661d15 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -59,6 +59,10 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity): def name(self) -> str | UndefinedType | None: """Return the name of the entity.""" meta = self.entity_data.entity.info_object + if meta.primary: + self._attr_name = None + return super().name + original_name = super().name if original_name not in (UNDEFINED, None) or meta.fallback_name is None: diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 25e4de77a32..0cc2524469e 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.50"], + "requirements": ["zha==0.0.51"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index acac164af8e..70b8bf20e41 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3149,7 +3149,7 @@ zeroconf==0.145.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.50 +zha==0.0.51 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e37faf9f609..f86e597f50c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2538,7 +2538,7 @@ zeroconf==0.145.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.50 +zha==0.0.51 # homeassistant.components.zwave_js zwave-js-server-python==0.60.1 diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index 7a599b00a21..ba8aa9ea245 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -179,16 +179,7 @@ }), '0x0010': dict({ 'attribute': "ZCLAttributeDef(id=0x0010, name='cie_addr', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", - 'value': list([ - 50, - 79, - 50, - 2, - 0, - 141, - 21, - 0, - ]), + 'value': None, }), '0x0011': dict({ 'attribute': "ZCLAttributeDef(id=0x0011, name='zone_id', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 2d69cf1ff36..88fb9974c1b 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -30,6 +30,7 @@ from homeassistant.core import HomeAssistant from .common import send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +ENTITY_ID_NO_PREFIX = "sensor.fakemanufacturer_fakemodel" ENTITY_ID_PREFIX = "sensor.fakemanufacturer_fakemodel_{}" @@ -335,7 +336,7 @@ async def async_test_pi_heating_demand( "humidity", async_test_humidity, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -344,7 +345,7 @@ async def async_test_pi_heating_demand( "temperature", async_test_temperature, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -353,7 +354,7 @@ async def async_test_pi_heating_demand( "pressure", async_test_pressure, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -362,7 +363,7 @@ async def async_test_pi_heating_demand( "illuminance", async_test_illuminance, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -492,7 +493,7 @@ async def async_test_pi_heating_demand( "device_temperature", async_test_device_temperature, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -501,7 +502,7 @@ async def async_test_pi_heating_demand( "setpoint_change_source", async_test_setpoint_change_source, 10, - None, + {}, None, STATE_UNKNOWN, ), @@ -510,7 +511,7 @@ async def async_test_pi_heating_demand( "pi_heating_demand", async_test_pi_heating_demand, 10, - None, + {}, None, STATE_UNKNOWN, ), @@ -558,7 +559,6 @@ async def test_sensor( gateway.get_or_create_device(zigpy_device) await gateway.async_device_initialized(zigpy_device) await hass.async_block_till_done(wait_background_tasks=True) - entity_id = ENTITY_ID_PREFIX.format(entity_suffix) zigpy_device = zigpy_device_mock( { @@ -570,6 +570,11 @@ async def test_sensor( } ) + if hass.states.get(ENTITY_ID_NO_PREFIX): + entity_id = ENTITY_ID_NO_PREFIX + else: + entity_id = ENTITY_ID_PREFIX.format(entity_suffix) + assert hass.states.get(entity_id).state == initial_sensor_state # test sensor associated logic diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index f6afee9eb83..ae1ea90d1f9 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -420,8 +420,11 @@ async def test_list_groupable_devices( assert entity_reference[ATTR_NAME] is not None assert entity_reference["entity_id"] is not None - for entity_reference in endpoint["entities"]: - assert entity_reference["original_name"] is not None + if len(endpoint["entities"]) == 1: + assert endpoint["entities"][0]["original_name"] is None + else: + for entity_reference in endpoint["entities"]: + assert entity_reference["original_name"] is not None # Make sure there are no groupable devices when the device is unavailable # Make device unavailable From b505722f3807560cf41939ca2dd37a7fda29997e Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 27 Feb 2025 10:00:50 +0100 Subject: [PATCH 2830/2987] Bump onedrive to 0.0.12 (#139410) * Bump onedrive to 0.0.12 * Add alternative name --- homeassistant/components/onedrive/manifest.json | 2 +- homeassistant/components/onedrive/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 5ab16402cb8..31a1f2ccb06 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "platinum", - "requirements": ["onedrive-personal-sdk==0.0.11"] + "requirements": ["onedrive-personal-sdk==0.0.12"] } diff --git a/homeassistant/components/onedrive/sensor.py b/homeassistant/components/onedrive/sensor.py index 0ca2b166e3f..fa7c0b125fe 100644 --- a/homeassistant/components/onedrive/sensor.py +++ b/homeassistant/components/onedrive/sensor.py @@ -103,7 +103,7 @@ class OneDriveDriveStateSensor( self._attr_unique_id = f"{coordinator.data.id}_{description.key}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - name=coordinator.data.name, + name=coordinator.data.name or coordinator.config_entry.title, identifiers={(DOMAIN, coordinator.data.id)}, manufacturer="Microsoft", model=f"OneDrive {coordinator.data.drive_type.value.capitalize()}", diff --git a/requirements_all.txt b/requirements_all.txt index 70b8bf20e41..d1186a9d1a7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1565,7 +1565,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.11 +onedrive-personal-sdk==0.0.12 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f86e597f50c..2ee967f69ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1313,7 +1313,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.11 +onedrive-personal-sdk==0.0.12 # homeassistant.components.onvif onvif-zeep-async==3.2.5 From 2150a668b0bc0bc0a22e3d7c353f06b70a822424 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 27 Feb 2025 10:17:57 +0100 Subject: [PATCH 2831/2987] Add reauthentication to azure_storage (#139411) * Add reauthentication to azure_storage * update docstring --- .../components/azure_storage/__init__.py | 8 ++- .../components/azure_storage/config_flow.py | 70 ++++++++++++++++--- .../azure_storage/quality_scale.yaml | 2 +- .../components/azure_storage/strings.json | 13 +++- .../azure_storage/test_config_flow.py | 61 ++++++++++++++++ 5 files changed, 140 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/azure_storage/__init__.py b/homeassistant/components/azure_storage/__init__.py index 873a9ab90ca..f22e7b70c12 100644 --- a/homeassistant/components/azure_storage/__init__.py +++ b/homeassistant/components/azure_storage/__init__.py @@ -13,7 +13,11 @@ from azure.storage.blob.aio import ContainerClient from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import ( @@ -52,7 +56,7 @@ async def async_setup_entry( translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]}, ) from err except ClientAuthenticationError as err: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]}, diff --git a/homeassistant/components/azure_storage/config_flow.py b/homeassistant/components/azure_storage/config_flow.py index e5b1214fa5b..c98576af5d1 100644 --- a/homeassistant/components/azure_storage/config_flow.py +++ b/homeassistant/components/azure_storage/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Azure Storage integration.""" +from collections.abc import Mapping import logging from typing import Any @@ -26,6 +27,26 @@ _LOGGER = logging.getLogger(__name__) class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for azure storage.""" + def get_account_url(self, account_name: str) -> str: + """Get the account URL.""" + return f"https://{account_name}.blob.core.windows.net/" + + async def validate_config( + self, container_client: ContainerClient + ) -> dict[str, str]: + """Validate the configuration.""" + errors: dict[str, str] = {} + try: + await container_client.exists() + except ResourceNotFoundError: + errors["base"] = "cannot_connect" + except ClientAuthenticationError: + errors[CONF_STORAGE_ACCOUNT_KEY] = "invalid_auth" + except Exception: + _LOGGER.exception("Unknown exception occurred") + errors["base"] = "unknown" + return errors + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -38,20 +59,13 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): {CONF_ACCOUNT_NAME: user_input[CONF_ACCOUNT_NAME]} ) container_client = ContainerClient( - account_url=f"https://{user_input[CONF_ACCOUNT_NAME]}.blob.core.windows.net/", + account_url=self.get_account_url(user_input[CONF_ACCOUNT_NAME]), container_name=user_input[CONF_CONTAINER_NAME], credential=user_input[CONF_STORAGE_ACCOUNT_KEY], transport=AioHttpTransport(session=async_get_clientsession(self.hass)), ) - try: - await container_client.exists() - except ResourceNotFoundError: - errors["base"] = "cannot_connect" - except ClientAuthenticationError: - errors[CONF_STORAGE_ACCOUNT_KEY] = "invalid_auth" - except Exception: - _LOGGER.exception("Unknown exception occurred") - errors["base"] = "unknown" + errors = await self.validate_config(container_client) + if not errors: return self.async_create_entry( title=f"{user_input[CONF_ACCOUNT_NAME]}/{user_input[CONF_CONTAINER_NAME]}", @@ -70,3 +84,39 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + container_client = ContainerClient( + account_url=self.get_account_url(reauth_entry.data[CONF_ACCOUNT_NAME]), + container_name=reauth_entry.data[CONF_CONTAINER_NAME], + credential=user_input[CONF_STORAGE_ACCOUNT_KEY], + transport=AioHttpTransport(session=async_get_clientsession(self.hass)), + ) + errors = await self.validate_config(container_client) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, + data={**reauth_entry.data, **user_input}, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_STORAGE_ACCOUNT_KEY): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/azure_storage/quality_scale.yaml b/homeassistant/components/azure_storage/quality_scale.yaml index 6b6f90de494..5b147dfe0e4 100644 --- a/homeassistant/components/azure_storage/quality_scale.yaml +++ b/homeassistant/components/azure_storage/quality_scale.yaml @@ -57,7 +57,7 @@ rules: status: exempt comment: | This integration does not have platforms. - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/azure_storage/strings.json b/homeassistant/components/azure_storage/strings.json index 4bd4cb0dfba..5d39b54b8db 100644 --- a/homeassistant/components/azure_storage/strings.json +++ b/homeassistant/components/azure_storage/strings.json @@ -19,10 +19,21 @@ }, "description": "Set up an Azure (Blob) storage account to be used for backups.", "title": "Add Azure storage account" + }, + "reauth_confirm": { + "data": { + "storage_account_key": "[%key:component::azure_storage::config::step::user::data::storage_account_key%]" + }, + "data_description": { + "storage_account_key": "[%key:component::azure_storage::config::step::user::data_description::storage_account_key%]" + }, + "description": "Provide a new storage account key.", + "title": "Reauthenticate Azure storage account" } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "issues": { diff --git a/tests/components/azure_storage/test_config_flow.py b/tests/components/azure_storage/test_config_flow.py index ed8bbed0718..d5c0726e94a 100644 --- a/tests/components/azure_storage/test_config_flow.py +++ b/tests/components/azure_storage/test_config_flow.py @@ -15,6 +15,7 @@ from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import setup_integration from .const import USER_INPUT from tests.common import MockConfigEntry @@ -111,3 +112,63 @@ async def test_abort_if_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the reauth flow works.""" + + await setup_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_STORAGE_ACCOUNT_KEY: "new_key"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + **USER_INPUT, + CONF_STORAGE_ACCOUNT_KEY: "new_key", + } + + +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the reauth flow works with an errors.""" + + await setup_integration(hass, mock_config_entry) + + mock_client.exists.side_effect = Exception() + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_STORAGE_ACCOUNT_KEY: "new_key"} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + # fix the error and finish the flow successfully + mock_client.exists.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_STORAGE_ACCOUNT_KEY: "new_key"} + ) + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + **USER_INPUT, + CONF_STORAGE_ACCOUNT_KEY: "new_key", + } From 63daed0ed6c8765bfc2391b9b26dd17b02340c5c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 10:43:13 +0100 Subject: [PATCH 2832/2987] Bump codecov/codecov-action from 5.3.1 to 5.4.0 (#139408) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6145e985ce3..97986f26ee3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1276,7 +1276,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v5.3.1 + uses: codecov/codecov-action@v5.4.0 with: fail_ci_if_error: true flags: full-suite @@ -1415,7 +1415,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v5.3.1 + uses: codecov/codecov-action@v5.4.0 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} From b1a70c86c3fda7dd042f4df32b689fd8db4d5945 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 10:44:13 +0100 Subject: [PATCH 2833/2987] Bump docker/build-push-action from 6.14.0 to 6.15.0 (#139407) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 0ad4c510a55..df5d3eee6ae 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -509,7 +509,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0 + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -522,7 +522,7 @@ jobs: - name: Push Docker image if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' id: push - uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0 + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile From 8c98cede60fd1ab6e2ceb83f8d9f4ebfd2b639a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 10:44:50 +0100 Subject: [PATCH 2834/2987] Bump actions/attest-build-provenance from 2.2.0 to 2.2.1 (#139406) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index df5d3eee6ae..ed5005584bd 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -531,7 +531,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0 + uses: actions/attest-build-provenance@f9eaf234fc1c2e333c1eca18177db0f44fa6ba52 # v2.2.1 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} From df59adf5d1bf37f752e54709f78016abc20f0a57 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 27 Feb 2025 11:06:03 +0100 Subject: [PATCH 2835/2987] Add reconfiguration to azure_storage (#139414) * Add reauthentication to azure_storage * Add reconfigure to azure_storage * iqs * update string * ruff --- .../components/azure_storage/config_flow.py | 38 +++++++++++++++++++ .../azure_storage/quality_scale.yaml | 2 +- .../components/azure_storage/strings.json | 15 +++++++- .../azure_storage/test_config_flow.py | 24 ++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/azure_storage/config_flow.py b/homeassistant/components/azure_storage/config_flow.py index c98576af5d1..2862d290f95 100644 --- a/homeassistant/components/azure_storage/config_flow.py +++ b/homeassistant/components/azure_storage/config_flow.py @@ -120,3 +120,41 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the entry.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + container_client = ContainerClient( + account_url=self.get_account_url( + reconfigure_entry.data[CONF_ACCOUNT_NAME] + ), + container_name=user_input[CONF_CONTAINER_NAME], + credential=user_input[CONF_STORAGE_ACCOUNT_KEY], + transport=AioHttpTransport(session=async_get_clientsession(self.hass)), + ) + errors = await self.validate_config(container_client) + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + data={**reconfigure_entry.data, **user_input}, + ) + return self.async_show_form( + data_schema=vol.Schema( + { + vol.Required( + CONF_CONTAINER_NAME, + default=reconfigure_entry.data[CONF_CONTAINER_NAME], + ): str, + vol.Required( + CONF_STORAGE_ACCOUNT_KEY, + default=reconfigure_entry.data[CONF_STORAGE_ACCOUNT_KEY], + ): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/azure_storage/quality_scale.yaml b/homeassistant/components/azure_storage/quality_scale.yaml index 5b147dfe0e4..6199ba514a3 100644 --- a/homeassistant/components/azure_storage/quality_scale.yaml +++ b/homeassistant/components/azure_storage/quality_scale.yaml @@ -121,7 +121,7 @@ rules: status: exempt comment: | This integration does not have entities. - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: done stale-devices: status: exempt diff --git a/homeassistant/components/azure_storage/strings.json b/homeassistant/components/azure_storage/strings.json index 5d39b54b8db..e9053f113cc 100644 --- a/homeassistant/components/azure_storage/strings.json +++ b/homeassistant/components/azure_storage/strings.json @@ -29,11 +29,24 @@ }, "description": "Provide a new storage account key.", "title": "Reauthenticate Azure storage account" + }, + "reconfigure": { + "data": { + "container_name": "[%key:component::azure_storage::config::step::user::data::container_name%]", + "storage_account_key": "[%key:component::azure_storage::config::step::user::data::storage_account_key%]" + }, + "data_description": { + "container_name": "[%key:component::azure_storage::config::step::user::data_description::container_name%]", + "storage_account_key": "[%key:component::azure_storage::config::step::user::data_description::storage_account_key%]" + }, + "description": "Change the settings of the Azure storage integration.", + "title": "Reconfigure Azure storage account" } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "issues": { diff --git a/tests/components/azure_storage/test_config_flow.py b/tests/components/azure_storage/test_config_flow.py index d5c0726e94a..67dc44f9f2c 100644 --- a/tests/components/azure_storage/test_config_flow.py +++ b/tests/components/azure_storage/test_config_flow.py @@ -172,3 +172,27 @@ async def test_reauth_flow_errors( **USER_INPUT, CONF_STORAGE_ACCOUNT_KEY: "new_key", } + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the reconfigure flow works.""" + + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_CONTAINER_NAME: "new_container"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + **USER_INPUT, + CONF_CONTAINER_NAME: "new_container", + } From cc18ec2de8674eb4be213b6ae7dbf4d0681a3622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 27 Feb 2025 12:00:14 +0100 Subject: [PATCH 2836/2987] Fix fetch options error for Home connect (#139392) * Handle errors when obtaining options definitions * Don't fetch program options if the program key is unknown * Test to ensure that available program endpoint is not called on unknown program --- .../components/home_connect/coordinator.py | 31 +++++--- .../home_connect/test_coordinator.py | 22 +++++- tests/components/home_connect/test_entity.py | 73 +++++++++++++++++++ 3 files changed, 113 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 80ae8173d86..d9200b282c9 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -440,13 +440,27 @@ class HomeConnectCoordinator( self, ha_id: str, program_key: ProgramKey ) -> dict[OptionKey, ProgramDefinitionOption]: """Get options with constraints for appliance.""" - return { - option.key: option - for option in ( - await self.client.get_available_program(ha_id, program_key=program_key) - ).options - or [] - } + if program_key is ProgramKey.UNKNOWN: + return {} + try: + return { + option.key: option + for option in ( + await self.client.get_available_program( + ha_id, program_key=program_key + ) + ).options + or [] + } + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching options for %s: %s", + ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + return {} async def update_options( self, ha_id: str, event_key: EventKey, program_key: ProgramKey @@ -456,8 +470,7 @@ class HomeConnectCoordinator( events = self.data[ha_id].events options_to_notify = options.copy() options.clear() - if program_key is not ProgramKey.UNKNOWN: - options.update(await self.get_options_definitions(ha_id, program_key)) + options.update(await self.get_options_definitions(ha_id, program_key)) for option in options.values(): option_value = option.constraints.default if option.constraints else None diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 51f42a98f42..3dd9ffbe7c1 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -75,21 +75,35 @@ async def test_coordinator_update_failing_get_appliances( assert config_entry.state == ConfigEntryState.SETUP_RETRY -async def test_coordinator_update_failing_get_settings_status( +@pytest.mark.parametrize( + "mock_method", + [ + "get_settings", + "get_status", + "get_all_programs", + "get_available_commands", + "get_available_program", + ], +) +async def test_coordinator_update_failing( + mock_method: str, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - client_with_exception: MagicMock, + client: MagicMock, ) -> None: """Test that although is not possible to get settings and status, the config entry is loaded. This is for cases where some appliances are reachable and some are not in the same configuration entry. """ - # Get home appliances does pass at client_with_exception.get_home_appliances mock, so no need to mock it again + setattr(client, mock_method, AsyncMock(side_effect=HomeConnectError())) + assert config_entry.state == ConfigEntryState.NOT_LOADED - await integration_setup(client_with_exception) + await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED + getattr(client, mock_method).assert_called() + @pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) @pytest.mark.parametrize( diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py index 2422cbe547c..bad02888dbf 100644 --- a/tests/components/home_connect/test_entity.py +++ b/tests/components/home_connect/test_entity.py @@ -23,6 +23,7 @@ from aiohomeconnect.model.error import ( SelectedProgramNotSetError, ) from aiohomeconnect.model.program import ( + EnumerateProgram, ProgramDefinitionConstraints, ProgramDefinitionOption, ) @@ -234,6 +235,78 @@ async def test_program_options_retrieval( assert hass.states.is_state(entity_id, STATE_UNKNOWN) +@pytest.mark.parametrize( + ("array_of_programs_program_arg", "event_key"), + [ + ( + "active", + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + ), + ( + "selected", + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ), + ], +) +async def test_no_options_retrieval_on_unknown_program( + array_of_programs_program_arg: str, + event_key: EventKey, + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that no options are retrieved when the program is unknown.""" + + async def get_all_programs_with_options_mock(ha_id: str) -> ArrayOfPrograms: + return ArrayOfPrograms( + **( + { + "programs": [ + EnumerateProgram(ProgramKey.UNKNOWN, "unknown program") + ], + array_of_programs_program_arg: Program( + ProgramKey.UNKNOWN, options=[] + ), + } + ) + ) + + client.get_all_programs = AsyncMock(side_effect=get_all_programs_with_options_mock) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert client.get_available_program.call_count == 0 + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.UNKNOWN, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert client.get_available_program.call_count == 0 + + @pytest.mark.parametrize( "event_key", [ From 7b14b6af0ed5b6eb920373ec923836e8328f7154 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Thu, 27 Feb 2025 20:03:44 +0900 Subject: [PATCH 2837/2987] Add water heater entity to LG ThinQ (#138257) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/__init__.py | 1 + .../components/lg_thinq/water_heater.py | 201 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 homeassistant/components/lg_thinq/water_heater.py diff --git a/homeassistant/components/lg_thinq/__init__.py b/homeassistant/components/lg_thinq/__init__.py index 72d81af4ff0..f83cbadf925 100644 --- a/homeassistant/components/lg_thinq/__init__.py +++ b/homeassistant/components/lg_thinq/__init__.py @@ -47,6 +47,7 @@ PLATFORMS = [ Platform.SENSOR, Platform.SWITCH, Platform.VACUUM, + Platform.WATER_HEATER, ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lg_thinq/water_heater.py b/homeassistant/components/lg_thinq/water_heater.py new file mode 100644 index 00000000000..5a5c8d024b6 --- /dev/null +++ b/homeassistant/components/lg_thinq/water_heater.py @@ -0,0 +1,201 @@ +"""Support for waterheater entities.""" + +from __future__ import annotations + +import logging +from typing import Any + +from thinqconnect import DeviceType +from thinqconnect.integration import ExtendedProperty + +from homeassistant.components.water_heater import ( + ATTR_OPERATION_MODE, + STATE_ECO, + STATE_HEAT_PUMP, + STATE_OFF, + STATE_PERFORMANCE, + WaterHeaterEntity, + WaterHeaterEntityDescription, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ThinqConfigEntry +from .coordinator import DeviceDataUpdateCoordinator +from .entity import ThinQEntity + +DEVICE_TYPE_WH_MAP: dict[DeviceType, WaterHeaterEntityDescription] = { + DeviceType.WATER_HEATER: WaterHeaterEntityDescription( + key=ExtendedProperty.WATER_HEATER, + name=None, + ), + DeviceType.SYSTEM_BOILER: WaterHeaterEntityDescription( + key=ExtendedProperty.WATER_BOILER, + name=None, + ), +} + +# Mapping between device and HA operation modes +DEVICE_OP_MODE_TO_HA = { + "auto": STATE_ECO, + "heat_pump": STATE_HEAT_PUMP, + "turbo": STATE_PERFORMANCE, + "vacation": STATE_OFF, +} +HA_STATE_TO_DEVICE_OP_MODE = {v: k for k, v in DEVICE_OP_MODE_TO_HA.items()} + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ThinqConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up an entry for water_heater platform.""" + entities: list[ThinQWaterHeaterEntity] = [] + for coordinator in entry.runtime_data.coordinators.values(): + if ( + description := DEVICE_TYPE_WH_MAP.get(coordinator.api.device.device_type) + ) is not None: + if coordinator.api.device.device_type == DeviceType.WATER_HEATER: + entities.append( + ThinQWaterHeaterEntity( + coordinator, description, ExtendedProperty.WATER_HEATER + ) + ) + elif coordinator.api.device.device_type == DeviceType.SYSTEM_BOILER: + entities.append( + ThinQWaterBoilerEntity( + coordinator, description, ExtendedProperty.WATER_BOILER + ) + ) + if entities: + async_add_entities(entities) + + +class ThinQWaterHeaterEntity(ThinQEntity, WaterHeaterEntity): + """Represent a ThinQ water heater entity.""" + + def __init__( + self, + coordinator: DeviceDataUpdateCoordinator, + entity_description: WaterHeaterEntityDescription, + property_id: str, + ) -> None: + """Initialize a water_heater entity.""" + super().__init__(coordinator, entity_description, property_id) + self._attr_supported_features = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + ) + self._attr_temperature_unit = ( + self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS + ) + if modes := self.data.job_modes: + self._attr_operation_list = [ + DEVICE_OP_MODE_TO_HA.get(mode, mode) for mode in modes + ] + else: + self._attr_operation_list = [STATE_HEAT_PUMP] + + def _update_status(self) -> None: + """Update status itself.""" + super()._update_status() + self._attr_current_temperature = self.data.current_temp + self._attr_target_temperature = self.data.target_temp + + if self.data.max is not None: + self._attr_max_temp = self.data.max + if self.data.min is not None: + self._attr_min_temp = self.data.min + if self.data.step is not None: + self._attr_target_temperature_step = self.data.step + + self._attr_temperature_unit = ( + self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS + ) + if self.data.is_on: + self._attr_current_operation = ( + DEVICE_OP_MODE_TO_HA.get(job_mode, job_mode) + if (job_mode := self.data.job_mode) is not None + else STATE_HEAT_PUMP + ) + else: + self._attr_current_operation = STATE_OFF + + _LOGGER.debug( + "[%s:%s] update status: c:%s, t:%s, op_mode:%s, op_list:%s, is_on:%s", + self.coordinator.device_name, + self.property_id, + self.current_temperature, + self.target_temperature, + self.current_operation, + self.operation_list, + self.data.is_on, + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperatures.""" + _LOGGER.debug( + "[%s:%s] async_set_temperature: %s", + self.coordinator.device_name, + self.property_id, + kwargs, + ) + if (operation_mode := kwargs.get(ATTR_OPERATION_MODE)) is not None: + await self.async_set_operation_mode(str(operation_mode)) + if operation_mode == STATE_OFF: + return + + if ( + temperature := kwargs.get(ATTR_TEMPERATURE) + ) is not None and temperature != self.target_temperature: + await self.async_call_api( + self.coordinator.api.async_set_target_temperature( + self.property_id, temperature + ) + ) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode.""" + mode = HA_STATE_TO_DEVICE_OP_MODE.get(operation_mode, operation_mode) + _LOGGER.debug( + "[%s:%s] async_set_operation_mode: %s", + self.coordinator.device_name, + self.property_id, + mode, + ) + await self.async_call_api( + self.coordinator.api.async_set_job_mode(self.property_id, mode) + ) + + +class ThinQWaterBoilerEntity(ThinQWaterHeaterEntity): + """Represent a ThinQ water boiler entity.""" + + def __init__( + self, + coordinator: DeviceDataUpdateCoordinator, + entity_description: WaterHeaterEntityDescription, + property_id: str, + ) -> None: + """Initialize a water_heater entity.""" + super().__init__(coordinator, entity_description, property_id) + self._attr_supported_features |= WaterHeaterEntityFeature.ON_OFF + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + _LOGGER.debug( + "[%s:%s] async_turn_on", self.coordinator.device_name, self.property_id + ) + await self.async_call_api(self.coordinator.api.async_turn_on(self.property_id)) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + _LOGGER.debug( + "[%s:%s] async_turn_off", self.coordinator.device_name, self.property_id + ) + await self.async_call_api(self.coordinator.api.async_turn_off(self.property_id)) From 5b1783e85980a4b4e11ee4285a75a0f3242424b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 11:41:27 +0000 Subject: [PATCH 2838/2987] Bump habluetooth to 3.24.1 (#139420) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 8eeb4d67109..6c851e603d9 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.4", "bluetooth-data-tools==1.23.4", "dbus-fast==2.33.0", - "habluetooth==3.24.0" + "habluetooth==3.24.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c49580ae47b..012206d2833 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.33.0 fnv-hash-fast==1.2.6 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.24.0 +habluetooth==3.24.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index d1186a9d1a7..0fddd6a3f65 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1109,7 +1109,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.24.0 +habluetooth==3.24.1 # homeassistant.components.cloud hass-nabucasa==0.92.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ee967f69ac..ca7aa099d97 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.24.0 +habluetooth==3.24.1 # homeassistant.components.cloud hass-nabucasa==0.92.0 From 735b843f5e55fd83cf37d5961bcdf4a9511e1466 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 12:22:43 +0000 Subject: [PATCH 2839/2987] Bump bleak-esphome to 2.8.0 (#139426) --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 0bc3ae55236..18dcbb5cb65 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.1"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.8.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index b59dd544c49..d07754d68a0 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.2.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.7.1" + "bleak-esphome==2.8.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 0fddd6a3f65..ce90fe2120e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -603,7 +603,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.7.1 +bleak-esphome==2.8.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ca7aa099d97..e198c65fb27 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -534,7 +534,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.7.1 +bleak-esphome==2.8.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 From 7ae13a4d7245742d383d4e99f73b95c84feaef4b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 13:25:55 +0100 Subject: [PATCH 2840/2987] Bump pysmartthings to 2.0.0 (#139418) * Bump pysmartthings to 2.0.0 * Fix * Fix * Fix * Fix --- .../components/smartthings/__init__.py | 8 +++--- homeassistant/components/smartthings/cover.py | 2 +- .../components/smartthings/entity.py | 13 +++++++-- .../components/smartthings/manifest.json | 2 +- .../components/smartthings/sensor.py | 28 +++++++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smartthings/__init__.py | 2 +- .../smartthings/snapshots/test_sensor.ambr | 8 +++--- tests/components/smartthings/test_sensor.py | 14 +++++----- 10 files changed, 50 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 846170552e9..4bc9b270360 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -46,7 +46,7 @@ class FullDevice: """Define an object to hold device data.""" device: Device - status: dict[str, dict[Capability, dict[Attribute, Status]]] + status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]] type SmartThingsConfigEntry = ConfigEntry[SmartThingsData] @@ -146,8 +146,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def process_status( - status: dict[str, dict[Capability, dict[Attribute, Status]]], -) -> dict[str, dict[Capability, dict[Attribute, Status]]]: + status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]], +) -> dict[str, dict[Capability | str, dict[Attribute | str, Status]]]: """Remove disabled capabilities from status.""" if (main_component := status.get("main")) is None or ( disabled_capabilities_capability := main_component.get( @@ -156,7 +156,7 @@ def process_status( ) is None: return status disabled_capabilities = cast( - list[Capability], + list[Capability | str], disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, ) for capability in disabled_capabilities: diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index fd4752b4e28..0b0f03679eb 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -41,7 +41,7 @@ async def async_setup_entry( """Add covers for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsCover(entry_data.client, device, capability) + SmartThingsCover(entry_data.client, device, Capability(capability)) for device in entry_data.devices.values() for capability in device.status[MAIN] if capability in CAPABILITIES diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index b2e556c6718..1383196ce15 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -4,7 +4,14 @@ from __future__ import annotations from typing import Any, cast -from pysmartthings import Attribute, Capability, Command, DeviceEvent, SmartThings +from pysmartthings import ( + Attribute, + Capability, + Command, + DeviceEvent, + SmartThings, + Status, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -25,7 +32,7 @@ class SmartThingsEntity(Entity): """Initialize the instance.""" self.client = client self.capabilities = capabilities - self._internal_state = { + self._internal_state: dict[Capability | str, dict[Attribute | str, Status]] = { capability: device.status[MAIN][capability] for capability in capabilities if capability in device.status[MAIN] @@ -58,7 +65,7 @@ class SmartThingsEntity(Entity): await super().async_added_to_hass() for capability in self._internal_state: self.async_on_remove( - self.client.add_device_event_listener( + self.client.add_device_capability_event_listener( self.device.device.device_id, MAIN, capability, diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index b34ab90ca8c..c5277241aa4 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==1.2.0"] + "requirements": ["pysmartthings==2.0.0"] } diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index d7aaaaa84c5..bc986894045 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -130,6 +130,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): unique_id_separator: str = "." capability_ignore_list: list[set[Capability]] | None = None options_attribute: Attribute | None = None + except_if_state_none: bool = False CAPABILITY_TO_SENSORS: dict[ @@ -579,6 +580,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energy"] / 1000, + except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="power_meter", @@ -587,6 +589,7 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfPower.WATT, value_fn=lambda value: value["power"], extra_state_attributes_fn=power_attributes, + except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="deltaEnergy_meter", @@ -595,6 +598,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["deltaEnergy"] / 1000, + except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="powerEnergy_meter", @@ -603,6 +607,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["powerEnergy"] / 1000, + except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="energySaved_meter", @@ -611,6 +616,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energySaved"] / 1000, + except_if_state_none=True, ), ] }, @@ -953,14 +959,20 @@ async def async_setup_entry( async_add_entities( SmartThingsSensor(entry_data.client, device, description, capability, attribute) for device in entry_data.devices.values() - for capability, attributes in device.status[MAIN].items() - if capability in CAPABILITY_TO_SENSORS - for attribute in attributes - for description in CAPABILITY_TO_SENSORS[capability].get(attribute, []) - if not description.capability_ignore_list - or not any( - all(capability in device.status[MAIN] for capability in capability_list) - for capability_list in description.capability_ignore_list + for capability, attributes in CAPABILITY_TO_SENSORS.items() + if capability in device.status[MAIN] + for attribute, descriptions in attributes.items() + for description in descriptions + if ( + not description.capability_ignore_list + or not any( + all(capability in device.status[MAIN] for capability in capability_list) + for capability_list in description.capability_ignore_list + ) + ) + and ( + not description.except_if_state_none + or device.status[MAIN][capability][attribute].value is not None ) ) diff --git a/requirements_all.txt b/requirements_all.txt index ce90fe2120e..a4bc8becc83 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==1.2.0 +pysmartthings==2.0.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e198c65fb27..5612b5547b2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==1.2.0 +pysmartthings==2.0.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index 94a2e7512f2..a5e51c7d434 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -57,7 +57,7 @@ async def trigger_update( data: dict[str, Any] | None = None, ) -> None: """Trigger an update.""" - for call in mock.add_device_event_listener.call_args_list: + for call in mock.add_device_capability_event_listener.call_args_list: if call[0][0] == device_id and call[0][2] == capability: call[0][3]( DeviceEvent( diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 778b05fa183..93a683afe82 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_2-entry] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -14,7 +14,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeotec_energy_monitor_energy_2', + 'entity_id': 'sensor.aeotec_energy_monitor_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -35,7 +35,7 @@ 'unit_of_measurement': 'kWh', }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_2-state] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -44,7 +44,7 @@ 'unit_of_measurement': 'kWh', }), 'context': , - 'entity_id': 'sensor.aeotec_energy_monitor_energy_2', + 'entity_id': 'sensor.aeotec_energy_monitor_energy', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 8b8bb8930f4..c83950de9e9 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -28,7 +28,7 @@ async def test_all_entities( snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SENSOR) -@pytest.mark.parametrize("device_fixture", ["aeotec_home_energy_meter_gen5"]) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_state_update( hass: HomeAssistant, devices: AsyncMock, @@ -37,15 +37,15 @@ async def test_state_update( """Test state update.""" await setup_integration(hass, mock_config_entry) - assert hass.states.get("sensor.aeotec_energy_monitor_energy_2").state == "19978.536" + assert hass.states.get("sensor.ac_office_granit_temperature").state == "25" await trigger_update( hass, devices, - "f0af21a2-d5a1-437c-b10a-b34a87394b71", - Capability.ENERGY_METER, - Attribute.ENERGY, - 20000.0, + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.TEMPERATURE_MEASUREMENT, + Attribute.TEMPERATURE, + 20, ) - assert hass.states.get("sensor.aeotec_energy_monitor_energy_2").state == "20000.0" + assert hass.states.get("sensor.ac_office_granit_temperature").state == "20" From 59eb323f8d9314d128b0df9984856888695a6988 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 27 Feb 2025 14:29:57 +0100 Subject: [PATCH 2841/2987] Bump reolink-aio to 0.12.1 (#139427) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 37e448aa820..f923efdbbf2 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.12.0"] + "requirements": ["reolink-aio==0.12.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a4bc8becc83..78848c01ed3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2618,7 +2618,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.0 +reolink-aio==0.12.1 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5612b5547b2..aa7f720b8f1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2121,7 +2121,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.0 +reolink-aio==0.12.1 # homeassistant.components.rflink rflink==0.0.66 From f111a2c34a8d4e0be6501334bc11f9d873fef5e5 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 27 Feb 2025 14:30:29 +0100 Subject: [PATCH 2842/2987] Fix Music Assistant media player entity features (#139428) * Fix Music Assistant supported media player features * Update supported features when player config changes * Add tests --- .../music_assistant/media_player.py | 46 +++++-- tests/components/music_assistant/common.py | 39 +++++- .../music_assistant/test_media_player.py | 116 +++++++++++++++++- 3 files changed, 182 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index bbbda095302..c079fd20e91 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -9,6 +9,7 @@ import functools import os from typing import TYPE_CHECKING, Any, Concatenate +from music_assistant_models.constants import PLAYER_CONTROL_NONE from music_assistant_models.enums import ( EventType, MediaType, @@ -80,19 +81,14 @@ if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient from music_assistant_models.player import Player -SUPPORTED_FEATURES = ( - MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.STOP +SUPPORTED_FEATURES_BASE = ( + MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.SHUFFLE_SET | MediaPlayerEntityFeature.REPEAT_SET - | MediaPlayerEntityFeature.TURN_ON - | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA - | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.MEDIA_ENQUEUE @@ -212,11 +208,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): """Initialize MediaPlayer entity.""" super().__init__(mass, player_id) self._attr_icon = self.player.icon.replace("mdi-", "mdi:") - self._attr_supported_features = SUPPORTED_FEATURES - if PlayerFeature.SET_MEMBERS in self.player.supported_features: - self._attr_supported_features |= MediaPlayerEntityFeature.GROUPING - if PlayerFeature.VOLUME_MUTE in self.player.supported_features: - self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE + self._set_supported_features() self._attr_device_class = MediaPlayerDeviceClass.SPEAKER self._prev_time: float = 0 @@ -241,6 +233,19 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): ) ) + # we subscribe to the player config changed event to update + # the supported features of the player + async def player_config_changed(event: MassEvent) -> None: + self._set_supported_features() + await self.async_on_update() + self.async_write_ha_state() + + self.async_on_remove( + self.mass.subscribe( + player_config_changed, EventType.PLAYER_CONFIG_UPDATED, self.player_id + ) + ) + @property def active_queue(self) -> PlayerQueue | None: """Return the active queue for this player (if any).""" @@ -682,3 +687,20 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): if isinstance(queue_option, MediaPlayerEnqueue): queue_option = QUEUE_OPTION_MAP.get(queue_option) return queue_option + + def _set_supported_features(self) -> None: + """Set supported features based on player capabilities.""" + supported_features = SUPPORTED_FEATURES_BASE + if PlayerFeature.SET_MEMBERS in self.player.supported_features: + supported_features |= MediaPlayerEntityFeature.GROUPING + if PlayerFeature.PAUSE in self.player.supported_features: + supported_features |= MediaPlayerEntityFeature.PAUSE + if self.player.mute_control != PLAYER_CONTROL_NONE: + supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE + if self.player.volume_control != PLAYER_CONTROL_NONE: + supported_features |= MediaPlayerEntityFeature.VOLUME_STEP + supported_features |= MediaPlayerEntityFeature.VOLUME_SET + if self.player.power_control != PLAYER_CONTROL_NONE: + supported_features |= MediaPlayerEntityFeature.TURN_ON + supported_features |= MediaPlayerEntityFeature.TURN_OFF + self._attr_supported_features = supported_features diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 7c0f9df751a..863d945ccd1 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -2,9 +2,11 @@ from __future__ import annotations +import asyncio from typing import Any from unittest.mock import AsyncMock, MagicMock +from music_assistant_models.api import MassEvent from music_assistant_models.enums import EventType from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track from music_assistant_models.player import Player @@ -134,15 +136,42 @@ async def trigger_subscription_callback( hass: HomeAssistant, client: MagicMock, event: EventType = EventType.PLAYER_UPDATED, + object_id: str | None = None, data: Any = None, ) -> None: """Trigger a subscription callback.""" # trigger callback on all subscribers - for sub in client.subscribe_events.call_args_list: - callback = sub.kwargs["callback"] - event_filter = sub.kwargs.get("event_filter") - if event_filter in (None, event): - callback(event, data) + for sub in client.subscribe.call_args_list: + cb_func = sub.kwargs.get("cb_func", sub.args[0]) + event_filter = sub.kwargs.get( + "event_filter", sub.args[1] if len(sub.args) > 1 else None + ) + id_filter = sub.kwargs.get( + "id_filter", sub.args[2] if len(sub.args) > 2 else None + ) + if not ( + event_filter is None + or event == event_filter + or (isinstance(event_filter, list) and event in event_filter) + ): + continue + if not ( + id_filter is None + or object_id == id_filter + or (isinstance(id_filter, list) and object_id in id_filter) + ): + continue + + event = MassEvent( + event=event, + object_id=object_id, + data=data, + ) + if asyncio.iscoroutinefunction(cb_func): + await cb_func(event) + else: + cb_func(event) + await hass.async_block_till_done() diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 25dfcd22c72..44317d4977a 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -2,7 +2,13 @@ from unittest.mock import MagicMock, call -from music_assistant_models.enums import MediaType, QueueOption +from music_assistant_models.constants import PLAYER_CONTROL_NONE +from music_assistant_models.enums import ( + EventType, + MediaType, + PlayerFeature, + QueueOption, +) from music_assistant_models.media_items import Track import pytest from syrupy import SnapshotAssertion @@ -20,6 +26,7 @@ from homeassistant.components.media_player import ( SERVICE_CLEAR_PLAYLIST, SERVICE_JOIN, SERVICE_UNJOIN, + MediaPlayerEntityFeature, ) from homeassistant.components.music_assistant.const import DOMAIN as MASS_DOMAIN from homeassistant.components.music_assistant.media_player import ( @@ -59,7 +66,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities +from .common import ( + setup_integration_from_fixtures, + snapshot_music_assistant_entities, + trigger_subscription_callback, +) from tests.common import AsyncMock @@ -607,3 +618,104 @@ async def test_media_player_get_queue_action( # no call is made, this info comes from the cached queue data assert music_assistant_client.send_command.call_count == 0 assert response == snapshot(exclude=paths(f"{entity_id}.elapsed_time")) + + +async def test_media_player_supported_features( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test if media_player entity supported features are cortrectly (re)mapped.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + expected_features = ( + MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.SHUFFLE_SET + | MediaPlayerEntityFeature.REPEAT_SET + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.CLEAR_PLAYLIST + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.MEDIA_ENQUEUE + | MediaPlayerEntityFeature.MEDIA_ANNOUNCE + | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.GROUPING + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + ) + assert state.attributes["supported_features"] == expected_features + # remove power control capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[ + mass_player_id + ].power_control = PLAYER_CONTROL_NONE + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.TURN_ON + expected_features &= ~MediaPlayerEntityFeature.TURN_OFF + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove volume control capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[ + mass_player_id + ].volume_control = PLAYER_CONTROL_NONE + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.VOLUME_SET + expected_features &= ~MediaPlayerEntityFeature.VOLUME_STEP + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove mute control capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[ + mass_player_id + ].mute_control = PLAYER_CONTROL_NONE + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.VOLUME_MUTE + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove pause capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[mass_player_id].supported_features.remove( + PlayerFeature.PAUSE + ) + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.PAUSE + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove grouping capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[mass_player_id].supported_features.remove( + PlayerFeature.SET_MEMBERS + ) + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.GROUPING + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features From 0da6b28808c5fce034fb257ad68042ea601f7c71 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 28 Feb 2025 03:02:14 +1300 Subject: [PATCH 2843/2987] Add lawn mower entity id format (#139402) * add missing entity id format * use ENTITY_ID_FORMAT in mqtt lawn mower --- homeassistant/components/lawn_mower/__init__.py | 1 + homeassistant/components/mqtt/lawn_mower.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py index 0680bfc9d71..f8c3e0cd67d 100644 --- a/homeassistant/components/lawn_mower/__init__.py +++ b/homeassistant/components/lawn_mower/__init__.py @@ -28,6 +28,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) DATA_COMPONENT: HassKey[EntityComponent[LawnMowerEntity]] = HassKey(DOMAIN) +ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=60) diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 7727efcf04d..1917c56f209 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components import lawn_mower from homeassistant.components.lawn_mower import ( + ENTITY_ID_FORMAT, LawnMowerActivity, LawnMowerEntity, LawnMowerEntityFeature, @@ -50,7 +51,6 @@ CONF_START_MOWING_COMMAND_TOPIC = "start_mowing_command_topic" CONF_START_MOWING_COMMAND_TEMPLATE = "start_mowing_command_template" DEFAULT_NAME = "MQTT Lawn Mower" -ENTITY_ID_FORMAT = lawn_mower.DOMAIN + ".{}" MQTT_LAWN_MOWER_ATTRIBUTES_BLOCKED: frozenset[str] = frozenset() From f677b910a6582704f5f8f481abb49bd04c9a353b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 15:23:25 +0100 Subject: [PATCH 2844/2987] Add diagnostics to SmartThings (#139423) --- .../components/smartthings/diagnostics.py | 50 + tests/components/smartthings/__init__.py | 28 +- .../snapshots/test_diagnostics.ambr | 1163 +++++++++++++++++ .../smartthings/test_diagnostics.py | 44 + 4 files changed, 1272 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/smartthings/diagnostics.py create mode 100644 tests/components/smartthings/snapshots/test_diagnostics.ambr create mode 100644 tests/components/smartthings/test_diagnostics.py diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py new file mode 100644 index 00000000000..bcf40645d22 --- /dev/null +++ b/homeassistant/components/smartthings/diagnostics.py @@ -0,0 +1,50 @@ +"""Diagnostics support for SmartThings.""" + +from __future__ import annotations + +import asyncio +from dataclasses import asdict +from typing import Any + +from pysmartthings import DeviceEvent + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from . import SmartThingsConfigEntry +from .const import DOMAIN + +EVENT_WAIT_TIME = 5 + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: SmartThingsConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device entry.""" + device_id = next( + identifier for identifier in device.identifiers if identifier[0] == DOMAIN + )[0] + + events: list[DeviceEvent] = [] + + def register_event(event: DeviceEvent) -> None: + events.append(event) + + client = entry.runtime_data.client + + listener = client.add_device_event_listener(device_id, register_event) + + await asyncio.sleep(EVENT_WAIT_TIME) + + listener() + + device_status = await client.get_device_status(device_id) + + status: dict[str, Any] = {} + for component, capabilities in device_status.items(): + status[component] = {} + for capability, attributes in capabilities.items(): + status[component][capability] = {} + for attribute, value in attributes.items(): + status[component][capability][attribute] = asdict(value) + return {"events": [asdict(event) for event in events], "status": status} diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index a5e51c7d434..6939d3c5dcc 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -57,19 +57,21 @@ async def trigger_update( data: dict[str, Any] | None = None, ) -> None: """Trigger an update.""" + event = DeviceEvent( + "abc", + "abc", + "abc", + device_id, + MAIN, + capability, + attribute, + value, + data, + ) + for call in mock.add_device_event_listener.call_args_list: + if call[0][0] == device_id: + call[0][3](event) for call in mock.add_device_capability_event_listener.call_args_list: if call[0][0] == device_id and call[0][2] == capability: - call[0][3]( - DeviceEvent( - "abc", - "abc", - "abc", - device_id, - MAIN, - capability, - attribute, - value, - data, - ) - ) + call[0][3](event) await hass.async_block_till_done() diff --git a/tests/components/smartthings/snapshots/test_diagnostics.ambr b/tests/components/smartthings/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..50f568df5d1 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_diagnostics.ambr @@ -0,0 +1,1163 @@ +# serializer version: 1 +# name: test_device[da_ac_rac_000001] + dict({ + 'events': list([ + ]), + 'status': dict({ + '1': dict({ + 'airConditionerFanMode': dict({ + 'availableAcFanModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.381000+00:00', + 'unit': None, + 'value': None, + }), + 'supportedAcFanModes': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.605000+00:00', + 'unit': None, + 'value': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), + }), + 'airConditionerMode': dict({ + 'airConditionerMode': dict({ + 'data': None, + 'timestamp': '2021-04-08T03:50:50.930000+00:00', + 'unit': None, + 'value': None, + }), + 'availableAcModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAcModes': dict({ + 'data': None, + 'timestamp': '2021-04-08T03:50:50.930000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'airQualitySensor': dict({ + 'airQuality': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.602000+00:00', + 'unit': 'CAQI', + 'value': None, + }), + }), + 'audioVolume': dict({ + 'volume': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:53.541000+00:00', + 'unit': '%', + 'value': None, + }), + }), + 'custom.airConditionerOdorController': dict({ + 'airConditionerOdorControllerProgress': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:11:38.269000+00:00', + 'unit': None, + 'value': None, + }), + 'airConditionerOdorControllerState': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:11:38.269000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.airConditionerOptionalMode': dict({ + 'acOptionalMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.659000+00:00', + 'unit': None, + 'value': None, + }), + 'supportedAcOptionalMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.659000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.airConditionerTropicalNightMode': dict({ + 'acTropicalNightModeLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.498000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.autoCleaningMode': dict({ + 'autoCleaningMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:53.344000+00:00', + 'unit': None, + 'value': None, + }), + 'operatingState': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAutoCleaningModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedOperatingStates': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDurationRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.deodorFilter': dict({ + 'deodorFilterCapacity': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterLastResetDate': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterResetType': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterStatus': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterUsage': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterUsageStep': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.deviceReportStateConfiguration': dict({ + 'reportStatePeriod': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:09.800000+00:00', + 'unit': None, + 'value': None, + }), + 'reportStateRealtime': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:09.800000+00:00', + 'unit': None, + 'value': None, + }), + 'reportStateRealtimePeriod': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:09.800000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.disabledCapabilities': dict({ + 'disabledCapabilities': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.605000+00:00', + 'unit': None, + 'value': list([ + 'remoteControlStatus', + 'airQualitySensor', + 'dustSensor', + 'odorSensor', + 'veryFineDustSensor', + 'custom.dustFilter', + 'custom.deodorFilter', + 'custom.deviceReportStateConfiguration', + 'audioVolume', + 'custom.autoCleaningMode', + 'custom.airConditionerTropicalNightMode', + 'custom.airConditionerOdorController', + 'demandResponseLoadControl', + 'relativeHumidityMeasurement', + ]), + }), + }), + 'custom.dustFilter': dict({ + 'dustFilterCapacity': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterLastResetDate': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterResetType': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterStatus': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterUsage': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterUsageStep': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.energyType': dict({ + 'drMaxDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingInfo': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingLevel': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperation': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperationSupport': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingSupport': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energyType': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:38.843000+00:00', + 'unit': None, + 'value': None, + }), + 'notificationTemplateID': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedEnergySavingLevels': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.spiMode': dict({ + 'spiMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.686000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.thermostatSetpointControl': dict({ + 'maximumSetpoint': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:04:19.901000+00:00', + 'unit': None, + 'value': None, + }), + 'minimumSetpoint': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:04:19.901000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'demandResponseLoadControl': dict({ + 'drlcStatus': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:54.748000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'dustSensor': dict({ + 'dustLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.122000+00:00', + 'unit': 'μg/m^3', + 'value': None, + }), + 'fineDustLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.122000+00:00', + 'unit': 'μg/m^3', + 'value': None, + }), + }), + 'fanOscillationMode': dict({ + 'availableFanOscillationModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanOscillationMode': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.247000+00:00', + 'unit': None, + 'value': 'fixed', + }), + 'supportedFanOscillationModes': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.325000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'ocf': dict({ + 'di': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'dmv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'icv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mndt': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnfv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnhw': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnml': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnmn': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnmo': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnos': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnpv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnsl': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'n': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'pi': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'st': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'vid': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'odorSensor': dict({ + 'odorLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:38.992000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'powerConsumptionReport': dict({ + 'powerConsumption': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:53.364000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'relativeHumidityMeasurement': dict({ + 'humidity': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.291000+00:00', + 'unit': '%', + 'value': 0, + }), + }), + 'remoteControlStatus': dict({ + 'remoteControlEnabled': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.097000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'switch': dict({ + 'switch': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.518000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'temperatureMeasurement': dict({ + 'temperature': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.373000+00:00', + 'unit': None, + 'value': None, + }), + 'temperatureRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'thermostatCoolingSetpoint': dict({ + 'coolingSetpoint': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:59.136000+00:00', + 'unit': None, + 'value': None, + }), + 'coolingSetpointRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'veryFineDustSensor': dict({ + 'veryFineDustLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:38.529000+00:00', + 'unit': 'μg/m^3', + 'value': None, + }), + }), + }), + 'main': dict({ + 'airConditionerFanMode': dict({ + 'availableAcFanModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.249000+00:00', + 'unit': None, + 'value': 'low', + }), + 'supportedAcFanModes': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.249000+00:00', + 'unit': None, + 'value': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), + }), + 'airConditionerMode': dict({ + 'airConditionerMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'heat', + }), + 'availableAcModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAcModes': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': list([ + 'cool', + 'dry', + 'wind', + 'auto', + 'heat', + ]), + }), + }), + 'audioVolume': dict({ + 'volume': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': '%', + 'value': 100, + }), + }), + 'custom.airConditionerOptionalMode': dict({ + 'acOptionalMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'off', + }), + 'supportedAcOptionalMode': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': list([ + 'off', + 'windFree', + ]), + }), + }), + 'custom.airConditionerTropicalNightMode': dict({ + 'acTropicalNightModeLevel': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 0, + }), + }), + 'custom.autoCleaningMode': dict({ + 'autoCleaningMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'off', + }), + 'operatingState': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAutoCleaningModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedOperatingStates': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDurationRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.disabledCapabilities': dict({ + 'disabledCapabilities': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': list([ + 'remoteControlStatus', + 'airQualitySensor', + 'dustSensor', + 'veryFineDustSensor', + 'custom.dustFilter', + 'custom.deodorFilter', + 'custom.deviceReportStateConfiguration', + 'samsungce.dongleSoftwareInstallation', + 'demandResponseLoadControl', + 'custom.airConditionerOdorController', + ]), + }), + }), + 'custom.disabledComponents': dict({ + 'disabledComponents': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': list([ + '1', + ]), + }), + }), + 'custom.energyType': dict({ + 'drMaxDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingInfo': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingLevel': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperation': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperationSupport': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingSupport': dict({ + 'data': None, + 'timestamp': '2021-12-29T07:29:17.526000+00:00', + 'unit': None, + 'value': 'False', + }), + 'energyType': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': '1.0', + }), + 'notificationTemplateID': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedEnergySavingLevels': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.spiMode': dict({ + 'spiMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'off', + }), + }), + 'custom.thermostatSetpointControl': dict({ + 'maximumSetpoint': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': 'C', + 'value': 30, + }), + 'minimumSetpoint': dict({ + 'data': None, + 'timestamp': '2025-01-08T06:30:58.307000+00:00', + 'unit': 'C', + 'value': 16, + }), + }), + 'demandResponseLoadControl': dict({ + 'drlcStatus': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': dict({ + 'drlcLevel': -1, + 'drlcType': 1, + 'duration': 0, + 'override': False, + 'start': '1970-01-01T00:00:00Z', + }), + }), + }), + 'execute': dict({ + 'data': dict({ + 'data': dict({ + 'href': '/temperature/desired/0', + }), + 'timestamp': '2023-07-19T03:07:43.270000+00:00', + 'unit': None, + 'value': dict({ + 'payload': dict({ + 'if': list([ + 'oic.if.baseline', + 'oic.if.a', + ]), + 'range': list([ + 16.0, + 30.0, + ]), + 'rt': list([ + 'oic.r.temperature', + ]), + 'temperature': 22.0, + 'units': 'C', + }), + }), + }), + }), + 'fanOscillationMode': dict({ + 'availableFanOscillationModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanOscillationMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.249000+00:00', + 'unit': None, + 'value': 'fixed', + }), + 'supportedFanOscillationModes': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.782000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'ocf': dict({ + 'di': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', + }), + 'dmv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'res.1.1.0,sh.1.1.0', + }), + 'icv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'core.1.1.0', + }), + 'mndt': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.912000+00:00', + 'unit': None, + 'value': None, + }), + 'mnfv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '0.1.0', + }), + 'mnhw': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '1.0', + }), + 'mnml': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'http://www.samsung.com', + }), + 'mnmn': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'Samsung Electronics', + }), + 'mnmo': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', + }), + 'mnos': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'TizenRT2.0', + }), + 'mnpv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '0G3MPDCKA00010E', + }), + 'mnsl': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.803000+00:00', + 'unit': None, + 'value': None, + }), + 'n': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '[room a/c] Samsung', + }), + 'pi': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', + }), + 'st': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.933000+00:00', + 'unit': None, + 'value': None, + }), + 'vid': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'DA-AC-RAC-000001', + }), + }), + 'powerConsumptionReport': dict({ + 'powerConsumption': dict({ + 'data': None, + 'timestamp': '2025-02-09T16:15:33.639000+00:00', + 'unit': None, + 'value': dict({ + 'deltaEnergy': 400, + 'end': '2025-02-09T16:15:33Z', + 'energy': 2247300, + 'energySaved': 0, + 'persistedEnergy': 2247300, + 'power': 0, + 'powerEnergy': 0.0, + 'start': '2025-02-09T15:45:29Z', + }), + }), + }), + 'refresh': dict({ + }), + 'relativeHumidityMeasurement': dict({ + 'humidity': dict({ + 'data': None, + 'timestamp': '2024-12-30T13:10:23.759000+00:00', + 'unit': '%', + 'value': 60, + }), + }), + 'samsungce.deviceIdentification': dict({ + 'binaryId': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': 'ARTIK051_KRAC_18K', + }), + 'description': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'micomAssayCode': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'modelClassificationCode': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'modelName': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'releaseYear': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'serialNumber': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'serialNumberExtra': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'samsungce.driverVersion': dict({ + 'versionNumber': dict({ + 'data': None, + 'timestamp': '2024-09-04T06:35:09.557000+00:00', + 'unit': None, + 'value': 24070101, + }), + }), + 'samsungce.selfCheck': dict({ + 'errors': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.349000+00:00', + 'unit': None, + 'value': list([ + ]), + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'result': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'status': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.549000+00:00', + 'unit': None, + 'value': 'ready', + }), + 'supportedActions': dict({ + 'data': None, + 'timestamp': '2024-09-04T06:35:09.557000+00:00', + 'unit': None, + 'value': list([ + 'start', + ]), + }), + }), + 'samsungce.softwareUpdate': dict({ + 'availableModules': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': list([ + ]), + }), + 'lastUpdatedDate': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'newVersionAvailable': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': 'False', + }), + 'operatingState': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'otnDUID': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': '43CEZFTFFL7Z2', + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'targetModule': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'switch': dict({ + 'switch': dict({ + 'data': None, + 'timestamp': '2025-02-09T16:37:54.072000+00:00', + 'unit': None, + 'value': 'off', + }), + }), + 'temperatureMeasurement': dict({ + 'temperature': dict({ + 'data': None, + 'timestamp': '2025-02-09T16:33:29.164000+00:00', + 'unit': 'C', + 'value': 25, + }), + 'temperatureRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'thermostatCoolingSetpoint': dict({ + 'coolingSetpoint': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:15:11.608000+00:00', + 'unit': 'C', + 'value': 25, + }), + 'coolingSetpointRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + }), + }), + }) +# --- diff --git a/tests/components/smartthings/test_diagnostics.py b/tests/components/smartthings/test_diagnostics.py new file mode 100644 index 00000000000..22f1c77cdd1 --- /dev/null +++ b/tests/components/smartthings/test_diagnostics.py @@ -0,0 +1,44 @@ +"""Test SmartThings diagnostics.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.smartthings.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_device +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_device( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + devices: AsyncMock, + mock_smartthings: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a device entry.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "96a5ef74-5832-a84b-f1f7-ca799957065d")} + ) + + with patch("homeassistant.components.smartthings.diagnostics.EVENT_WAIT_TIME", 0.1): + diag = await get_diagnostics_for_device( + hass, hass_client, mock_config_entry, device + ) + + assert diag == snapshot( + exclude=props("last_changed", "last_reported", "last_updated") + ) From 744a7a0e826e67f820d086218e900ed520ea3215 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 27 Feb 2025 14:51:40 +0000 Subject: [PATCH 2845/2987] Fix conversation agent fallback (#139421) --- .../components/assist_pipeline/pipeline.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 788a207b83a..75811a0ec36 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1103,12 +1103,16 @@ class PipelineRun: ) & conversation.ConversationEntityFeature.CONTROL: intent_filter = _async_local_fallback_intent_filter - # Try local intents first, if preferred. - elif self.pipeline.prefer_local_intents and ( - intent_response := await conversation.async_handle_intents( - self.hass, - user_input, - intent_filter=intent_filter, + # Try local intents + if ( + intent_response is None + and self.pipeline.prefer_local_intents + and ( + intent_response := await conversation.async_handle_intents( + self.hass, + user_input, + intent_filter=intent_filter, + ) ) ): # Local intent matched From df594748cffe38a9fad44326c056c1019dfe6938 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 16:00:24 +0100 Subject: [PATCH 2846/2987] Bump ruff to 0.9.8 (#139434) --- .pre-commit-config.yaml | 2 +- homeassistant/components/zone/__init__.py | 4 ++-- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5b701b21b9e..37114684c9f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.7 + rev: v0.9.8 hooks: - id: ruff args: diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 1c43a79e10e..813425c95f2 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -363,7 +363,7 @@ class Zone(collection.CollectionEntity): """Return entity instance initialized from storage.""" zone = cls(config) zone.editable = True - zone._generate_attrs() # noqa: SLF001 + zone._generate_attrs() return zone @classmethod @@ -371,7 +371,7 @@ class Zone(collection.CollectionEntity): """Return entity instance initialized from yaml.""" zone = cls(config) zone.editable = False - zone._generate_attrs() # noqa: SLF001 + zone._generate_attrs() return zone @property diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 8c9308e739b..c133c4b544a 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.1 -ruff==0.9.7 +ruff==0.9.8 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 1f177643bd5..c09d547ba79 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -24,7 +24,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.7 \ + stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.8 \ PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.26 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" From b02eaed6b0f4cbfc4ae341ee2946cb773628b70d Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 27 Feb 2025 16:42:08 +0100 Subject: [PATCH 2847/2987] Update frontend to 20250227.0 (#139437) --- 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 7bd361041e1..5399b22f075 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250226.0"] + "requirements": ["home-assistant-frontend==20250227.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 012206d2833..b8e0b417353 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.24.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250226.0 +home-assistant-frontend==20250227.0 home-assistant-intents==2025.2.26 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 78848c01ed3..920bb3ac81c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1152,7 +1152,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250226.0 +home-assistant-frontend==20250227.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aa7f720b8f1..2ddd495c900 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250226.0 +home-assistant-frontend==20250227.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 From a339fbaa8291bde5c9aeb6345684b28b50678516 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 16:56:30 +0000 Subject: [PATCH 2848/2987] Bump aioesphomeapi to 29.3.0 (#139441) --- 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 d07754d68a0..fea2aa03c7a 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.2.0", + "aioesphomeapi==29.3.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.8.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 920bb3ac81c..e7b05e7c455 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.2.0 +aioesphomeapi==29.3.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ddd495c900..239b8ac90ed 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.2.0 +aioesphomeapi==29.3.0 # homeassistant.components.flo aioflo==2021.11.0 From 9502dbee56760f42e7129d7166673d40de1e2201 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 27 Feb 2025 18:39:01 +0100 Subject: [PATCH 2849/2987] Add more diagnostic info to Reolink (#139436) * Add diagnostic info * Bump reolink-aio to 0.12.1 * Add tests --- .../components/reolink/diagnostics.py | 10 +++++++ tests/components/reolink/conftest.py | 6 ++++ .../reolink/snapshots/test_diagnostics.ambr | 29 +++++++++++++++++++ tests/components/reolink/test_diagnostics.py | 2 ++ 4 files changed, 47 insertions(+) diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index 693f2ba59a4..1d0e5d919e7 100644 --- a/homeassistant/components/reolink/diagnostics.py +++ b/homeassistant/components/reolink/diagnostics.py @@ -25,6 +25,14 @@ async def async_get_config_entry_diagnostics( IPC_cam[ch]["firmware version"] = api.camera_sw_version(ch) IPC_cam[ch]["encoding main"] = await api.get_encoding(ch) + chimes: dict[int, dict[str, Any]] = {} + for chime in api.chime_list: + chimes[chime.dev_id] = {} + chimes[chime.dev_id]["channel"] = chime.channel + chimes[chime.dev_id]["name"] = chime.name + chimes[chime.dev_id]["online"] = chime.online + chimes[chime.dev_id]["event_types"] = chime.chime_event_types + return { "model": api.model, "hardware version": api.hardware_version, @@ -41,9 +49,11 @@ async def async_get_config_entry_diagnostics( "channels": api.channels, "stream channels": api.stream_channels, "IPC cams": IPC_cam, + "Chimes": chimes, "capabilities": api.capabilities, "cmd list": host.update_cmd, "firmware ch list": host.firmware_ch_list, "api versions": api.checked_api_versions, "abilities": api.abilities, + "BC_abilities": api.baichuan.abilities, } diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 2862aa55b4d..5af55b48dda 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -123,6 +123,8 @@ def reolink_connect_class() -> Generator[MagicMock]: "{'host':'TEST_RESPONSE','channel':'TEST_RESPONSE'}" ) + reolink_connect.chime_list = [] + # enums host_mock.whiteled_mode.return_value = 1 host_mock.whiteled_mode_list.return_value = ["off", "auto"] @@ -137,6 +139,10 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.baichuan.events_active = False host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") + host_mock.baichuan.abilities = { + 0: {"chnID": 0, "aitype": 34615}, + "Host": {"pushAlarm": 7}, + } yield host_mock_class diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 71c5397fbd1..f8d5318e9bd 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -1,6 +1,27 @@ # serializer version: 1 # name: test_entry_diagnostics dict({ + 'BC_abilities': dict({ + '0': dict({ + 'aitype': 34615, + 'chnID': 0, + }), + 'Host': dict({ + 'pushAlarm': 7, + }), + }), + 'Chimes': dict({ + '12345678': dict({ + 'channel': 0, + 'event_types': list([ + 'md', + 'people', + 'visitor', + ]), + 'name': 'Test chime', + 'online': True, + }), + }), 'HTTP(S) port': 1234, 'HTTPS': True, 'IPC cams': dict({ @@ -41,6 +62,10 @@ 0, ]), 'cmd list': dict({ + 'DingDongOpt': dict({ + '0': 2, + 'null': 2, + }), 'GetAiAlarm': dict({ '0': 5, 'null': 5, @@ -81,6 +106,10 @@ '0': 2, 'null': 4, }), + 'GetDingDongCfg': dict({ + '0': 3, + 'null': 3, + }), 'GetEmail': dict({ '0': 1, 'null': 2, diff --git a/tests/components/reolink/test_diagnostics.py b/tests/components/reolink/test_diagnostics.py index 57b474c13ad..d45163d3cf0 100644 --- a/tests/components/reolink/test_diagnostics.py +++ b/tests/components/reolink/test_diagnostics.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock +from reolink_aio.api import Chime from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -15,6 +16,7 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, reolink_connect: MagicMock, + test_chime: Chime, config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: From ffac52255423fd572246b6906d72aaa872f64f1a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 18:39:18 +0100 Subject: [PATCH 2850/2987] Fix SmartThings diagnostics (#139447) --- homeassistant/components/smartthings/diagnostics.py | 9 ++++----- tests/components/smartthings/test_diagnostics.py | 5 +++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py index bcf40645d22..fc34415e419 100644 --- a/homeassistant/components/smartthings/diagnostics.py +++ b/homeassistant/components/smartthings/diagnostics.py @@ -21,25 +21,24 @@ async def async_get_device_diagnostics( hass: HomeAssistant, entry: SmartThingsConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" + client = entry.runtime_data.client device_id = next( identifier for identifier in device.identifiers if identifier[0] == DOMAIN - )[0] + )[1] + + device_status = await client.get_device_status(device_id) events: list[DeviceEvent] = [] def register_event(event: DeviceEvent) -> None: events.append(event) - client = entry.runtime_data.client - listener = client.add_device_event_listener(device_id, register_event) await asyncio.sleep(EVENT_WAIT_TIME) listener() - device_status = await client.get_device_status(device_id) - status: dict[str, Any] = {} for component, capabilities in device_status.items(): status[component] = {} diff --git a/tests/components/smartthings/test_diagnostics.py b/tests/components/smartthings/test_diagnostics.py index 22f1c77cdd1..768be155c86 100644 --- a/tests/components/smartthings/test_diagnostics.py +++ b/tests/components/smartthings/test_diagnostics.py @@ -34,6 +34,8 @@ async def test_device( identifiers={(DOMAIN, "96a5ef74-5832-a84b-f1f7-ca799957065d")} ) + mock_smartthings.get_device_status.reset_mock() + with patch("homeassistant.components.smartthings.diagnostics.EVENT_WAIT_TIME", 0.1): diag = await get_diagnostics_for_device( hass, hass_client, mock_config_entry, device @@ -42,3 +44,6 @@ async def test_device( assert diag == snapshot( exclude=props("last_changed", "last_reported", "last_updated") ) + mock_smartthings.get_device_status.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d" + ) From df006aeaded7e6ba9eba95966a23c237c8d1ce51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 27 Feb 2025 19:23:46 +0100 Subject: [PATCH 2851/2987] Bump aiohomeconnect to 0.15.1 (#139445) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 28714b31679..2f5ef4d1b37 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.15.0"], + "requirements": ["aiohomeconnect==0.15.1"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index e7b05e7c455..8cd0e8ea131 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -264,7 +264,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.15.0 +aiohomeconnect==0.15.1 # homeassistant.components.homekit_controller aiohomekit==3.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 239b8ac90ed..f8824b27cb2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -249,7 +249,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.15.0 +aiohomeconnect==0.15.1 # homeassistant.components.homekit_controller aiohomekit==3.2.7 From 8cc7e7b76fe1611573edb10dcc3fdd63c8fd5ba9 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 27 Feb 2025 20:07:12 +0100 Subject: [PATCH 2852/2987] Full test coverage for Vodafone Station init (#139451) Full test coverage for Vodafone Station init --- .../components/vodafone_station/test_init.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/components/vodafone_station/test_init.py diff --git a/tests/components/vodafone_station/test_init.py b/tests/components/vodafone_station/test_init.py new file mode 100644 index 00000000000..12b3c3dce8f --- /dev/null +++ b/tests/components/vodafone_station/test_init.py @@ -0,0 +1,33 @@ +"""Tests for Vodafone Station init.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.device_tracker import CONF_CONSIDER_HOME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_reload_config_entry_with_options( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the the config entry is reloaded with options.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CONSIDER_HOME: 37, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_CONSIDER_HOME: 37, + } From 4c00c56afde0da4cdbae0be6b60d843b50890e5c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 20:30:18 +0100 Subject: [PATCH 2853/2987] Bump pysmartthings to 2.0.1 (#139454) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index c5277241aa4..1f52cd23ff3 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.0.0"] + "requirements": ["pysmartthings==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8cd0e8ea131..b4235c7de0a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.0.0 +pysmartthings==2.0.1 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f8824b27cb2..624052bc2e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.0.0 +pysmartthings==2.0.1 # homeassistant.components.smarty pysmarty2==0.10.2 From 938855bea3eed1ebc0a099f12be42b4233bd10e8 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 27 Feb 2025 20:42:04 +0100 Subject: [PATCH 2854/2987] Improve onedrive migration (#139458) --- homeassistant/components/onedrive/__init__.py | 40 ++++++++++++++----- tests/components/onedrive/test_init.py | 27 +++++++++++-- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 454c782af92..f10b8fe0d91 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -41,14 +41,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: """Set up OneDrive from a config entry.""" - implementation = await async_get_config_entry_implementation(hass, entry) - session = OAuth2Session(hass, entry, implementation) - - async def get_access_token() -> str: - await session.async_ensure_token_valid() - return cast(str, session.token[CONF_ACCESS_TOKEN]) - - client = OneDriveClient(get_access_token, async_get_clientsession(hass)) + client, get_access_token = await _get_onedrive_client(hass, entry) # get approot, will be created automatically if it does not exist approot = await _handle_item_operation(client.get_approot, "approot") @@ -164,20 +157,47 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) - _LOGGER.debug( "Migrating OneDrive config entry from version %s.%s", version, minor_version ) - + client, _ = await _get_onedrive_client(hass, entry) instance_id = await async_get_instance_id(hass) + try: + approot = await client.get_approot() + folder = await client.get_drive_item( + f"{approot.id}:/backups_{instance_id[:8]}:" + ) + except OneDriveException: + _LOGGER.exception("Migration to version 1.2 failed") + return False + hass.config_entries.async_update_entry( entry, data={ **entry.data, - CONF_FOLDER_ID: "id", # will be updated during setup_entry + CONF_FOLDER_ID: folder.id, CONF_FOLDER_NAME: f"backups_{instance_id[:8]}", }, + minor_version=2, ) _LOGGER.debug("Migration to version 1.2 successful") return True +async def _get_onedrive_client( + hass: HomeAssistant, entry: OneDriveConfigEntry +) -> tuple[OneDriveClient, Callable[[], Awaitable[str]]]: + """Get OneDrive client.""" + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) + + async def get_access_token() -> str: + await session.async_ensure_token_valid() + return cast(str, session.token[CONF_ACCESS_TOKEN]) + + return ( + OneDriveClient(get_access_token, async_get_clientsession(hass)), + get_access_token, + ) + + async def _handle_item_operation( func: Callable[[], Awaitable[Item]], folder: str ) -> Item: diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index c7765e0a7f8..952ca01e1cb 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -236,7 +236,6 @@ async def test_data_cap_issues( async def test_1_1_to_1_2_migration( hass: HomeAssistant, - mock_onedrive_client: MagicMock, mock_config_entry: MockConfigEntry, mock_folder: Folder, ) -> None: @@ -251,12 +250,34 @@ async def test_1_1_to_1_2_migration( }, ) + await setup_integration(hass, old_config_entry) + assert old_config_entry.data[CONF_FOLDER_ID] == mock_folder.id + assert old_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name + assert old_config_entry.minor_version == 2 + + +async def test_1_1_to_1_2_migration_failure( + hass: HomeAssistant, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration from 1.1 to 1.2 failure.""" + old_config_entry = MockConfigEntry( + unique_id="mock_drive_id", + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": mock_config_entry.data["auth_implementation"], + "token": mock_config_entry.data["token"], + }, + ) + # will always 404 after migration, because of dummy id mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found") await setup_integration(hass, old_config_entry) - assert old_config_entry.data[CONF_FOLDER_ID] == mock_folder.id - assert old_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name + assert old_config_entry.state is ConfigEntryState.MIGRATION_ERROR + assert old_config_entry.minor_version == 1 async def test_migration_guard_against_major_downgrade( From ef7058f70311642e6a117fd4b29fb69293fac858 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 27 Feb 2025 21:47:20 +0100 Subject: [PATCH 2855/2987] Improve descriptions of `lyric.set_hold_time` action and field (#139385) * Fix misleading descriptions on lyric.set_hold_time action While on Honeywell Lyric thermostats the user can set a "Hold Until" time of day, the set_hold_time action does define a time period instead (Example: 01:00:00) Therefore both descriptions are incorrectly using "until" for explaining the purpose of the action itself and the `time_period` field. This commit re-words both and adds some additional context that helps users (and translators) better understand this action and its purpose. In addition the action name is changed to proper sentence-casing. * Replace "time" with "duration" for additional clarity --- homeassistant/components/lyric/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 83c65359643..bc48a791e70 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -53,12 +53,12 @@ }, "services": { "set_hold_time": { - "name": "Set Hold Time", - "description": "Sets the time to hold until.", + "name": "Set hold time", + "description": "Sets the time period to keep the temperature and override the schedule.", "fields": { "time_period": { - "name": "Time Period", - "description": "Time to hold until." + "name": "Time period", + "description": "Duration for which to override the schedule." } } } From e11ead410bbd9179ded05ba5e07b6de9919ec0ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 20:50:23 +0000 Subject: [PATCH 2856/2987] Add coverage to ensure we do not load base platforms before recorder (#139464) --- tests/test_bootstrap.py | 43 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 0d7c8614c6f..e89d038f8ce 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1528,3 +1528,46 @@ def test_should_rollover_is_always_false() -> None: ).shouldRollover(Mock()) is False ) + + +async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> None: + """Verify stage 0 not load base platforms before recorder. + + If a stage 0 integration has a base platform in its dependencies and + it loads before the recorder, it may load integrations that expect + the recorder to be loaded. We need to ensure that no stage 0 integration + has a base platform in its dependencies that loads before the recorder. + """ + integrations_before_recorder: set[str] = set() + for _, integrations, _ in bootstrap.STAGE_0_INTEGRATIONS: + integrations_before_recorder |= integrations + if "recorder" in integrations: + break + + integrations_or_execs = await loader.async_get_integrations( + hass, integrations_before_recorder + ) + integrations: list[Integration] = [] + resolve_deps_tasks: list[asyncio.Task[bool]] = [] + for integration in integrations_or_execs.values(): + assert not isinstance(integrations_or_execs, Exception) + integrations.append(integration) + resolve_deps_tasks.append(integration.resolve_dependencies()) + + await asyncio.gather(*resolve_deps_tasks) + base_platform_py_files = {f"{base_platform}.py" for base_platform in BASE_PLATFORMS} + for integration in integrations: + domain_with_base_platforms_deps = BASE_PLATFORMS.intersection( + integration.all_dependencies + ) + assert not domain_with_base_platforms_deps, ( + f"{integration.domain} has base platforms in dependencies: " + f"{domain_with_base_platforms_deps}" + ) + integration_top_level_files = base_platform_py_files.intersection( + integration._top_level_files + ) + assert not integration_top_level_files, ( + f"{integration.domain} has base platform files in top level files: " + f"{integration_top_level_files}" + ) From 0afdd9556f41a33d845ad19ad57a0e329fffe94a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 21:45:13 +0000 Subject: [PATCH 2857/2987] Bump aioesphomeapi to 29.3.1 (#139465) --- homeassistant/components/esphome/diagnostics.py | 4 +--- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/conftest.py | 5 ++++- tests/components/esphome/test_diagnostics.py | 1 + 6 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py index 58c9a8fe666..c68bd560791 100644 --- a/homeassistant/components/esphome/diagnostics.py +++ b/homeassistant/components/esphome/diagnostics.py @@ -13,9 +13,7 @@ from . import CONF_NOISE_PSK from .dashboard import async_get_dashboard from .entry_data import ESPHomeConfigEntry -CONF_MAC_ADDRESS = "mac_address" - -REDACT_KEYS = {CONF_NOISE_PSK, CONF_PASSWORD, CONF_MAC_ADDRESS} +REDACT_KEYS = {CONF_NOISE_PSK, CONF_PASSWORD, "mac_address", "bluetooth_mac_address"} async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index fea2aa03c7a..b4360077604 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.3.0", + "aioesphomeapi==29.3.1", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.8.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index b4235c7de0a..a321d9467b6 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.3.0 +aioesphomeapi==29.3.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 624052bc2e9..38feed9656c 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.3.0 +aioesphomeapi==29.3.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index dc6195bfe1f..94f621b8646 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -581,7 +581,10 @@ async def mock_bluetooth_entry( return await _mock_generic_device_entry( hass, mock_client, - {"bluetooth_proxy_feature_flags": bluetooth_proxy_feature_flags}, + { + "bluetooth_mac_address": "AA:BB:CC:DD:EE:FC", + "bluetooth_proxy_feature_flags": bluetooth_proxy_feature_flags, + }, ([], []), [], ) diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 2b2629324d2..a4b858ed7de 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -89,6 +89,7 @@ async def test_diagnostics_with_bluetooth( "storage_data": { "api_version": {"major": 99, "minor": 99}, "device_info": { + "bluetooth_mac_address": "**REDACTED**", "bluetooth_proxy_feature_flags": 63, "compilation_time": "", "esphome_version": "1.0.0", From ef13b35c359f2a6362d14c4b0ce1a25f5f17923d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 22:50:34 +0100 Subject: [PATCH 2858/2987] Only lowercase SmartThings media input source if we have it (#139468) --- homeassistant/components/smartthings/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index bc986894045..2d817c182da 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -461,7 +461,7 @@ CAPABILITY_TO_SENSORS: dict[ translation_key="media_input_source", device_class=SensorDeviceClass.ENUM, options_attribute=Attribute.SUPPORTED_INPUT_SOURCES, - value_fn=lambda value: value.lower(), + value_fn=lambda value: value.lower() if value else None, ) ] }, From 6fa93edf2751b4f4f28c2267dc3479681c6f9228 Mon Sep 17 00:00:00 2001 From: rappenze Date: Thu, 27 Feb 2025 23:27:18 +0100 Subject: [PATCH 2859/2987] Bump pyfibaro to 0.8.2 (#139471) --- homeassistant/components/fibaro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json index d2a1186b05b..cd4d1de838c 100644 --- a/homeassistant/components/fibaro/manifest.json +++ b/homeassistant/components/fibaro/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyfibaro"], - "requirements": ["pyfibaro==0.8.0"] + "requirements": ["pyfibaro==0.8.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index a321d9467b6..1c1b33fca80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1957,7 +1957,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.8.0 +pyfibaro==0.8.2 # homeassistant.components.fido pyfido==2.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38feed9656c..9bd33de07c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1595,7 +1595,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.8.0 +pyfibaro==0.8.2 # homeassistant.components.fido pyfido==2.1.2 From 4e8186491cf655c8e61c6cf0e955e89d89ce916a Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Thu, 27 Feb 2025 19:10:42 -0800 Subject: [PATCH 2860/2987] Fix Gemini Schema validation for #139416 (#139478) Fixed Schema validation for issue #139477 --- .../conversation.py | 15 ++++++- .../snapshots/test_conversation.ambr | 2 +- .../test_conversation.py | 42 +++++++++++++++++-- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index c99c4c07a7d..2c84249dcb3 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -111,9 +111,20 @@ def _format_schema(schema: dict[str, Any]) -> Schema: continue if key == "any_of": val = [_format_schema(subschema) for subschema in val] - if key == "type": + elif key == "type": val = val.upper() - if key == "items": + elif key == "format": + # Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema + # formats that are not supported are ignored + if schema.get("type") == "string" and val not in ("enum", "date-time"): + continue + if schema.get("type") == "number" and val not in ("float", "double"): + continue + if schema.get("type") == "integer" and val not in ("int32", "int64"): + continue + if schema.get("type") not in ("string", "number", "integer"): + continue + elif key == "items": val = _format_schema(val) elif key == "properties": val = {k: _format_schema(v) for k, v in val.items()} diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index 7c9bb896bd3..106366fd240 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -6,7 +6,7 @@ tuple( ), dict({ - 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format='lower', items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=[Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)], max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=[Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)], max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), 'model': 'models/gemini-2.0-flash', diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 229ee0b323e..5e887d3cab7 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -493,6 +493,42 @@ async def test_escape_decode() -> None: {"type": "string", "enum": ["a", "b", "c"]}, {"type": "STRING", "enum": ["a", "b", "c"]}, ), + ( + {"type": "string", "format": "enum", "enum": ["a", "b", "c"]}, + {"type": "STRING", "format": "enum", "enum": ["a", "b", "c"]}, + ), + ( + {"type": "string", "format": "date-time"}, + {"type": "STRING", "format": "date-time"}, + ), + ( + {"type": "string", "format": "byte"}, + {"type": "STRING"}, + ), + ( + {"type": "number", "format": "float"}, + {"type": "NUMBER", "format": "float"}, + ), + ( + {"type": "number", "format": "double"}, + {"type": "NUMBER", "format": "double"}, + ), + ( + {"type": "number", "format": "hex"}, + {"type": "NUMBER"}, + ), + ( + {"type": "integer", "format": "int32"}, + {"type": "INTEGER", "format": "int32"}, + ), + ( + {"type": "integer", "format": "int64"}, + {"type": "INTEGER", "format": "int64"}, + ), + ( + {"type": "integer", "format": "int8"}, + {"type": "INTEGER"}, + ), ( {"type": "integer", "enum": [1, 2, 3]}, {"type": "STRING", "enum": ["1", "2", "3"]}, @@ -515,11 +551,11 @@ async def test_escape_decode() -> None: ] }, ), - ({"type": "string", "format": "lower"}, {"format": "lower", "type": "STRING"}), - ({"type": "boolean", "format": "bool"}, {"format": "bool", "type": "BOOLEAN"}), + ({"type": "string", "format": "lower"}, {"type": "STRING"}), + ({"type": "boolean", "format": "bool"}, {"type": "BOOLEAN"}), ( {"type": "number", "format": "percent"}, - {"type": "NUMBER", "format": "percent"}, + {"type": "NUMBER"}, ), ( { From 6953c20a657543c36ceb6bf9778b5e68f92515f3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 09:15:13 +0100 Subject: [PATCH 2861/2987] Set SmartThings suggested display precision (#139470) --- .../components/smartthings/sensor.py | 5 ++ .../smartthings/snapshots/test_sensor.ambr | 90 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 2d817c182da..cd12bf46e25 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -580,6 +580,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energy"] / 1000, + suggested_display_precision=2, except_if_state_none=True, ), SmartThingsSensorEntityDescription( @@ -589,6 +590,7 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfPower.WATT, value_fn=lambda value: value["power"], extra_state_attributes_fn=power_attributes, + suggested_display_precision=2, except_if_state_none=True, ), SmartThingsSensorEntityDescription( @@ -598,6 +600,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["deltaEnergy"] / 1000, + suggested_display_precision=2, except_if_state_none=True, ), SmartThingsSensorEntityDescription( @@ -607,6 +610,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["powerEnergy"] / 1000, + suggested_display_precision=2, except_if_state_none=True, ), SmartThingsSensorEntityDescription( @@ -616,6 +620,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energySaved"] / 1000, + suggested_display_precision=2, except_if_state_none=True, ), ] diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 93a683afe82..b67d15bef55 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -545,6 +545,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -597,6 +600,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -649,6 +655,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -753,6 +762,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -807,6 +819,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -959,6 +974,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1011,6 +1029,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1063,6 +1084,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1167,6 +1191,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1221,6 +1248,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1768,6 +1798,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1820,6 +1853,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1872,6 +1908,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1924,6 +1963,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1978,6 +2020,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2326,6 +2371,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2378,6 +2426,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2430,6 +2481,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2614,6 +2668,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2668,6 +2725,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2768,6 +2828,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2820,6 +2883,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2872,6 +2938,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3066,6 +3135,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3120,6 +3192,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3220,6 +3295,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3272,6 +3350,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3324,6 +3405,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3520,6 +3604,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3574,6 +3661,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, From 05df57295193d3f01b40cbfe7fbc80498571bf68 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 10:30:31 +0100 Subject: [PATCH 2862/2987] Bump pysmartthings to 2.1.0 (#139460) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 1f52cd23ff3..5dd570f2751 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.0.1"] + "requirements": ["pysmartthings==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1c1b33fca80..00509109413 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.0.1 +pysmartthings==2.1.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bd33de07c7..609639b0735 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.0.1 +pysmartthings==2.1.0 # homeassistant.components.smarty pysmarty2==0.10.2 From 9d10e0e054b2e8ebcf88304c24fd5166aed0c5c0 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 28 Feb 2025 11:18:16 +0100 Subject: [PATCH 2863/2987] Change webdav namespace to absolut URI (#139456) * Change webdav namespace to absolut URI * Add const file --- homeassistant/components/webdav/backup.py | 13 +++++++------ tests/components/webdav/const.py | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index a51866fde61..f810547022b 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -30,6 +30,7 @@ _LOGGER = logging.getLogger(__name__) METADATA_VERSION = "1" BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200) +NAMESPACE = "https://home-assistant.io" async def async_get_backup_agents( @@ -100,14 +101,14 @@ def _is_current_metadata_version(properties: list[Property]) -> bool: return any( prop.value == METADATA_VERSION for prop in properties - if prop.namespace == "homeassistant" and prop.name == "metadata_version" + if prop.namespace == NAMESPACE and prop.name == "metadata_version" ) def _backup_id_from_properties(properties: list[Property]) -> str | None: """Return the backup ID from properties.""" for prop in properties: - if prop.namespace == "homeassistant" and prop.name == "backup_id": + if prop.namespace == NAMESPACE and prop.name == "backup_id": return prop.value return None @@ -186,12 +187,12 @@ class WebDavBackupAgent(BackupAgent): f"{self._backup_path}/{filename_meta}", [ Property( - namespace="homeassistant", + namespace=NAMESPACE, name="backup_id", value=backup.backup_id, ), Property( - namespace="homeassistant", + namespace=NAMESPACE, name="metadata_version", value=METADATA_VERSION, ), @@ -252,11 +253,11 @@ class WebDavBackupAgent(BackupAgent): self._backup_path, [ PropertyRequest( - namespace="homeassistant", + namespace=NAMESPACE, name="metadata_version", ), PropertyRequest( - namespace="homeassistant", + namespace=NAMESPACE, name="backup_id", ), ], diff --git a/tests/components/webdav/const.py b/tests/components/webdav/const.py index 52cad9a163b..8d6b8ad67d7 100644 --- a/tests/components/webdav/const.py +++ b/tests/components/webdav/const.py @@ -20,12 +20,12 @@ MOCK_LIST_WITH_PROPERTIES = { "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar": [], "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json": [ Property( - namespace="homeassistant", + namespace="https://home-assistant.io", name="backup_id", value="23e64aec", ), Property( - namespace="homeassistant", + namespace="https://home-assistant.io", name="metadata_version", value="1", ), From 1be98366635c32360736900d607c90194ccbe37c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Feb 2025 11:44:16 +0100 Subject: [PATCH 2864/2987] Fail recorder.backup.async_pre_backup if Home Assistant is not running (#139491) Fail recorder.backup.async_pre_backup if hass is not running --- homeassistant/components/recorder/backup.py | 4 ++- tests/components/recorder/test_backup.py | 38 ++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/backup.py b/homeassistant/components/recorder/backup.py index d47cbe92bd4..eeebe328007 100644 --- a/homeassistant/components/recorder/backup.py +++ b/homeassistant/components/recorder/backup.py @@ -2,7 +2,7 @@ from logging import getLogger -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError from .util import async_migration_in_progress, get_instance @@ -14,6 +14,8 @@ async def async_pre_backup(hass: HomeAssistant) -> None: """Perform operations before a backup starts.""" _LOGGER.info("Backup start notification, locking database for writes") instance = get_instance(hass) + if hass.state is not CoreState.running: + raise HomeAssistantError("Home Assistant is not running") if async_migration_in_progress(hass): raise HomeAssistantError("Database migration in progress") await instance.lock_database() diff --git a/tests/components/recorder/test_backup.py b/tests/components/recorder/test_backup.py index 08fbef01bdd..bed9e88fcbf 100644 --- a/tests/components/recorder/test_backup.py +++ b/tests/components/recorder/test_backup.py @@ -1,12 +1,13 @@ """Test backup platform for the Recorder integration.""" +from contextlib import AbstractContextManager, nullcontext as does_not_raise from unittest.mock import patch import pytest from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.backup import async_post_backup, async_pre_backup -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -19,6 +20,41 @@ async def test_async_pre_backup(recorder_mock: Recorder, hass: HomeAssistant) -> assert lock_mock.called +RAISES_HASS_NOT_RUNNING = pytest.raises( + HomeAssistantError, match="Home Assistant is not running" +) + + +@pytest.mark.parametrize( + ("core_state", "expected_result", "lock_calls"), + [ + (CoreState.final_write, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.not_running, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.running, does_not_raise(), 1), + (CoreState.starting, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.stopped, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.stopping, RAISES_HASS_NOT_RUNNING, 0), + ], +) +async def test_async_pre_backup_core_state( + recorder_mock: Recorder, + hass: HomeAssistant, + core_state: CoreState, + expected_result: AbstractContextManager, + lock_calls: int, +) -> None: + """Test pre backup in different core states.""" + hass.set_state(core_state) + with ( # pylint: disable=confusing-with-statement + patch( + "homeassistant.components.recorder.core.Recorder.lock_database" + ) as lock_mock, + expected_result, + ): + await async_pre_backup(hass) + assert len(lock_mock.mock_calls) == lock_calls + + async def test_async_pre_backup_with_timeout( recorder_mock: Recorder, hass: HomeAssistant ) -> None: From 5cf56ec11370c702a25edb03bf3685bef2c6f812 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Feb 2025 11:44:58 +0100 Subject: [PATCH 2865/2987] Adjust recorder backup platform tests (#139492) --- tests/components/recorder/test_backup.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/components/recorder/test_backup.py b/tests/components/recorder/test_backup.py index bed9e88fcbf..a4362b1fa4c 100644 --- a/tests/components/recorder/test_backup.py +++ b/tests/components/recorder/test_backup.py @@ -17,7 +17,7 @@ async def test_async_pre_backup(recorder_mock: Recorder, hass: HomeAssistant) -> "homeassistant.components.recorder.core.Recorder.lock_database" ) as lock_mock: await async_pre_backup(hass) - assert lock_mock.called + assert lock_mock.called RAISES_HASS_NOT_RUNNING = pytest.raises( @@ -75,13 +75,17 @@ async def test_async_pre_backup_with_migration( ) -> None: """Test pre backup with migration.""" with ( + patch( + "homeassistant.components.recorder.core.Recorder.lock_database" + ) as lock_mock, patch( "homeassistant.components.recorder.backup.async_migration_in_progress", return_value=True, ), - pytest.raises(HomeAssistantError), + pytest.raises(HomeAssistantError, match="Database migration in progress"), ): await async_pre_backup(hass) + assert not lock_mock.called async def test_async_post_backup(recorder_mock: Recorder, hass: HomeAssistant) -> None: @@ -90,7 +94,7 @@ async def test_async_post_backup(recorder_mock: Recorder, hass: HomeAssistant) - "homeassistant.components.recorder.core.Recorder.unlock_database" ) as unlock_mock: await async_post_backup(hass) - assert unlock_mock.called + assert unlock_mock.called async def test_async_post_backup_failure( @@ -102,7 +106,9 @@ async def test_async_post_backup_failure( "homeassistant.components.recorder.core.Recorder.unlock_database", return_value=False, ) as unlock_mock, - pytest.raises(HomeAssistantError), + pytest.raises( + HomeAssistantError, match="Could not release database write lock" + ), ): await async_post_backup(hass) assert unlock_mock.called From 12cb349160c5f47f6776e647e275f8b9e5444f31 Mon Sep 17 00:00:00 2001 From: pglab-electronics <89299919+pglab-electronics@users.noreply.github.com> Date: Fri, 28 Feb 2025 12:07:01 +0100 Subject: [PATCH 2866/2987] Add Sensor to PG LAB Integration (#138802) --- .../components/pglab/device_sensor.py | 56 +++++++++ homeassistant/components/pglab/discovery.py | 28 ++++- homeassistant/components/pglab/entity.py | 18 ++- homeassistant/components/pglab/sensor.py | 119 ++++++++++++++++++ homeassistant/components/pglab/strings.json | 11 ++ .../pglab/snapshots/test_sensor.ambr | 95 ++++++++++++++ tests/components/pglab/test_sensor.py | 71 +++++++++++ 7 files changed, 391 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/pglab/device_sensor.py create mode 100644 homeassistant/components/pglab/sensor.py create mode 100644 tests/components/pglab/snapshots/test_sensor.ambr create mode 100644 tests/components/pglab/test_sensor.py diff --git a/homeassistant/components/pglab/device_sensor.py b/homeassistant/components/pglab/device_sensor.py new file mode 100644 index 00000000000..d202d11d6e7 --- /dev/null +++ b/homeassistant/components/pglab/device_sensor.py @@ -0,0 +1,56 @@ +"""Device Sensor for PG LAB Electronics.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pypglab.device import Device as PyPGLabDevice +from pypglab.sensor import Sensor as PyPGLabSensors + +from homeassistant.core import callback + +if TYPE_CHECKING: + from .entity import PGLabEntity + + +class PGLabDeviceSensor: + """Keeps PGLab device sensor update.""" + + def __init__(self, pglab_device: PyPGLabDevice) -> None: + """Initialize the device sensor.""" + + # get a reference of PG Lab device internal sensors state + self._sensors: PyPGLabSensors = pglab_device.sensors + + self._ha_sensors: list[PGLabEntity] = [] # list of HA entity sensors + + async def subscribe_topics(self): + """Subscribe to the device sensors topics.""" + self._sensors.set_on_state_callback(self.state_updated) + await self._sensors.subscribe_topics() + + def add_ha_sensor(self, entity: PGLabEntity) -> None: + """Add a new HA sensor to the list.""" + self._ha_sensors.append(entity) + + def remove_ha_sensor(self, entity: PGLabEntity) -> None: + """Remove a HA sensor from the list.""" + self._ha_sensors.remove(entity) + + @callback + def state_updated(self, payload: str) -> None: + """Handle state updates.""" + + # notify all HA sensors that PG LAB device sensor fields have been updated + for s in self._ha_sensors: + s.state_updated(payload) + + @property + def state(self) -> dict: + """Return the device sensors state.""" + return self._sensors.state + + @property + def sensors(self) -> PyPGLabSensors: + """Return the pypglab device sensors.""" + return self._sensors diff --git a/homeassistant/components/pglab/discovery.py b/homeassistant/components/pglab/discovery.py index af6bedc9bf4..fec6f5ce40d 100644 --- a/homeassistant/components/pglab/discovery.py +++ b/homeassistant/components/pglab/discovery.py @@ -28,17 +28,20 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.entity import Entity from .const import DISCOVERY_TOPIC, DOMAIN, LOGGER +from .device_sensor import PGLabDeviceSensor if TYPE_CHECKING: from . import PGLABConfigEntry # Supported platforms. PLATFORMS = [ + Platform.SENSOR, Platform.SWITCH, ] # Used to create a new component entity. CREATE_NEW_ENTITY = { + Platform.SENSOR: "pglab_create_new_entity_sensor", Platform.SWITCH: "pglab_create_new_entity_switch", } @@ -74,6 +77,7 @@ class DiscoverDeviceInfo: # When the hash string changes the devices entities must be rebuilt. self._hash = pglab_device.hash self._entities: list[tuple[str, str]] = [] + self._sensors = PGLabDeviceSensor(pglab_device) def add_entity(self, entity: Entity) -> None: """Add an entity.""" @@ -93,6 +97,20 @@ class DiscoverDeviceInfo: """Return array of entities available.""" return self._entities + @property + def sensors(self) -> PGLabDeviceSensor: + """Return the PGLab device sensor.""" + return self._sensors + + +async def createDiscoverDeviceInfo(pglab_device: PyPGLabDevice) -> DiscoverDeviceInfo: + """Create a new DiscoverDeviceInfo instance.""" + discovery_info = DiscoverDeviceInfo(pglab_device) + + # Subscribe to sensor state changes. + await discovery_info.sensors.subscribe_topics() + return discovery_info + @dataclass class PGLabDiscovery: @@ -223,7 +241,7 @@ class PGLabDiscovery: self.__clean_discovered_device(hass, pglab_device.id) # Add a new device. - discovery_info = DiscoverDeviceInfo(pglab_device) + discovery_info = await createDiscoverDeviceInfo(pglab_device) self._discovered[pglab_device.id] = discovery_info # Create all new relay entities. @@ -233,6 +251,14 @@ class PGLabDiscovery: hass, CREATE_NEW_ENTITY[Platform.SWITCH], pglab_device, r ) + # Create all new sensor entities. + async_dispatcher_send( + hass, + CREATE_NEW_ENTITY[Platform.SENSOR], + pglab_device, + discovery_info.sensors, + ) + topics = { "discovery_topic": { "topic": f"{self._discovery_topic}/#", diff --git a/homeassistant/components/pglab/entity.py b/homeassistant/components/pglab/entity.py index 1b8975a3bbe..175b4c1eb0f 100644 --- a/homeassistant/components/pglab/entity.py +++ b/homeassistant/components/pglab/entity.py @@ -43,12 +43,20 @@ class PGLabEntity(Entity): connections={(CONNECTION_NETWORK_MAC, device.mac)}, ) - async def async_added_to_hass(self) -> None: - """Update the device discovery info.""" - + async def subscribe_to_update(self): + """Subscribe to the entity updates.""" self._entity.set_on_state_callback(self.state_updated) await self._entity.subscribe_topics() + async def unsubscribe_to_update(self): + """Unsubscribe to the entity updates.""" + await self._entity.unsubscribe_topics() + self._entity.set_on_state_callback(None) + + async def async_added_to_hass(self) -> None: + """Update the device discovery info.""" + + await self.subscribe_to_update() await super().async_added_to_hass() # Inform PGLab discovery instance that a new entity is available. @@ -60,9 +68,7 @@ class PGLabEntity(Entity): """Unsubscribe when removed.""" await super().async_will_remove_from_hass() - - await self._entity.unsubscribe_topics() - self._entity.set_on_state_callback(None) + await self.unsubscribe_to_update() @callback def state_updated(self, payload: str) -> None: diff --git a/homeassistant/components/pglab/sensor.py b/homeassistant/components/pglab/sensor.py new file mode 100644 index 00000000000..f868e7ae101 --- /dev/null +++ b/homeassistant/components/pglab/sensor.py @@ -0,0 +1,119 @@ +"""Sensor for PG LAB Electronics.""" + +from __future__ import annotations + +from datetime import timedelta + +from pypglab.const import SENSOR_REBOOT_TIME, SENSOR_TEMPERATURE, SENSOR_VOLTAGE +from pypglab.device import Device as PyPGLabDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import Platform, UnitOfElectricPotential, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.dt import utcnow + +from . import PGLABConfigEntry +from .device_sensor import PGLabDeviceSensor +from .discovery import PGLabDiscovery +from .entity import PGLabEntity + +PARALLEL_UPDATES = 0 + +SENSOR_INFO: list[SensorEntityDescription] = [ + SensorEntityDescription( + key=SENSOR_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_VOLTAGE, + translation_key="mpu_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_REBOOT_TIME, + translation_key="runtime", + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:progress-clock", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PGLABConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensor for device.""" + + @callback + def async_discover( + pglab_device: PyPGLabDevice, + pglab_device_sensor: PGLabDeviceSensor, + ) -> None: + """Discover and add a PG LAB Sensor.""" + pglab_discovery = config_entry.runtime_data + for description in SENSOR_INFO: + pglab_sensor = PGLabSensor( + pglab_discovery, pglab_device, pglab_device_sensor, description + ) + async_add_entities([pglab_sensor]) + + # Register the callback to create the sensor entity when discovered. + pglab_discovery = config_entry.runtime_data + await pglab_discovery.register_platform(hass, Platform.SENSOR, async_discover) + + +class PGLabSensor(PGLabEntity, SensorEntity): + """A PGLab sensor.""" + + def __init__( + self, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, + pglab_device_sensor: PGLabDeviceSensor, + description: SensorEntityDescription, + ) -> None: + """Initialize the Sensor class.""" + + super().__init__( + discovery=pglab_discovery, + device=pglab_device, + entity=pglab_device_sensor.sensors, + ) + + self._type = description.key + self._pglab_device_sensor = pglab_device_sensor + self._attr_unique_id = f"{pglab_device.id}_{description.key}" + self.entity_description = description + + @callback + def state_updated(self, payload: str) -> None: + """Handle state updates.""" + + # get the sensor value from pglab multi fields sensor + value = self._pglab_device_sensor.state[self._type] + + if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: + self._attr_native_value = utcnow() - timedelta(seconds=value) + else: + self._attr_native_value = value + + super().state_updated(payload) + + async def subscribe_to_update(self): + """Register the HA sensor to be notify when the sensor status is changed.""" + self._pglab_device_sensor.add_ha_sensor(self) + + async def unsubscribe_to_update(self): + """Unregister the HA sensor from sensor tatus updates.""" + self._pglab_device_sensor.remove_ha_sensor(self) diff --git a/homeassistant/components/pglab/strings.json b/homeassistant/components/pglab/strings.json index 8f9021cdcca..4fad408ad98 100644 --- a/homeassistant/components/pglab/strings.json +++ b/homeassistant/components/pglab/strings.json @@ -19,6 +19,17 @@ "relay": { "name": "Relay {relay_id}" } + }, + "sensor": { + "temperature": { + "name": "Temperature" + }, + "runtime": { + "name": "Run time" + }, + "mpu_voltage": { + "name": "MPU voltage" + } } } } diff --git a/tests/components/pglab/snapshots/test_sensor.ambr b/tests/components/pglab/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..f25f459bb70 --- /dev/null +++ b/tests/components/pglab/snapshots/test_sensor.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_sensors[mpu_voltage][initial_sensor_mpu_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'test MPU voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_mpu_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[mpu_voltage][updated_sensor_mpu_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'test MPU voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_mpu_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.31', + }) +# --- +# name: test_sensors[run_time][initial_sensor_run_time] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'test Run time', + 'icon': 'mdi:progress-clock', + }), + 'context': , + 'entity_id': 'sensor.test_run_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[run_time][updated_sensor_run_time] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'test Run time', + 'icon': 'mdi:progress-clock', + }), + 'context': , + 'entity_id': 'sensor.test_run_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-02-26T01:04:54+00:00', + }) +# --- +# name: test_sensors[temperature][initial_sensor_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[temperature][updated_sensor_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33.4', + }) +# --- diff --git a/tests/components/pglab/test_sensor.py b/tests/components/pglab/test_sensor.py new file mode 100644 index 00000000000..ff20d1452a4 --- /dev/null +++ b/tests/components/pglab/test_sensor.py @@ -0,0 +1,71 @@ +"""The tests for the PG LAB Electronics sensor.""" + +import json + +from freezegun import freeze_time +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def send_discovery_message(hass: HomeAssistant) -> None: + """Send mqtt discovery message.""" + + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "00000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + +@freeze_time("2024-02-26 01:21:34") +@pytest.mark.parametrize( + "sensor_suffix", + [ + "temperature", + "mpu_voltage", + "run_time", + ], +) +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mqtt_mock: MqttMockHAClient, + setup_pglab, + sensor_suffix: str, +) -> None: + """Check if sensors are properly created and updated.""" + + # send the discovery message to make E-BOARD device discoverable + await send_discovery_message(hass) + + # check initial sensors state + state = hass.states.get(f"sensor.test_{sensor_suffix}") + assert state == snapshot(name=f"initial_sensor_{sensor_suffix}") + + # update sensors value via mqtt + update_payload = {"temp": 33.4, "volt": 3.31, "rtime": 1000} + async_fire_mqtt_message(hass, "pglab/test/sensor/value", json.dumps(update_payload)) + await hass.async_block_till_done() + + # check updated sensors state + state = hass.states.get(f"sensor.test_{sensor_suffix}") + assert state == snapshot(name=f"updated_sensor_{sensor_suffix}") From a296c5e9ad301cc56fad055078073bc5bb3386b5 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Fri, 28 Feb 2025 06:44:01 -0500 Subject: [PATCH 2867/2987] Add floor_entities function and filter (#136509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/helpers/template.py | 12 ++++++ tests/helpers/test_template.py | 69 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 7866250d658..7dc3097cdb3 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1525,6 +1525,15 @@ def floor_areas(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]: return [entry.id for entry in entries if entry.id] +def floor_entities(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]: + """Return entity_ids for a given floor ID or name.""" + return [ + entity_id + for area_id in floor_areas(hass, floor_id_or_name) + for entity_id in area_entities(hass, area_id) + ] + + def areas(hass: HomeAssistant) -> Iterable[str | None]: """Return all areas.""" return list(area_registry.async_get(hass).areas) @@ -3048,6 +3057,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["floor_areas"] = hassfunction(floor_areas) self.filters["floor_areas"] = self.globals["floor_areas"] + self.globals["floor_entities"] = hassfunction(floor_entities) + self.filters["floor_entities"] = self.globals["floor_entities"] + self.globals["integration_entities"] = hassfunction(integration_entities) self.filters["integration_entities"] = self.globals["integration_entities"] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index b3a30806cbd..016aedb2f99 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -5881,6 +5881,75 @@ async def test_floor_areas( assert info.rate_limit is None +async def test_floor_entities( + hass: HomeAssistant, + floor_registry: fr.FloorRegistry, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test floor_entities function.""" + + # Test non existing floor ID + info = render_to_info(hass, "{{ floor_entities('skyring') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'skyring' | floor_entities }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ floor_entities(42) }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | floor_entities }}") + assert_result_info(info, []) + assert info.rate_limit is None + + floor = floor_registry.async_create("First floor") + area1 = area_registry.async_create("Living room") + area2 = area_registry.async_create("Dining room") + area_registry.async_update(area1.id, floor_id=floor.floor_id) + area_registry.async_update(area2.id, floor_id=floor.floor_id) + + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "living_room", + config_entry=config_entry, + ) + entity_registry.async_update_entity(entity_entry.entity_id, area_id=area1.id) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "dining_room", + config_entry=config_entry, + ) + entity_registry.async_update_entity(entity_entry.entity_id, area_id=area2.id) + + # Get entities by floor ID + expected = ["light.hue_living_room", "light.hue_dining_room"] + info = render_to_info(hass, f"{{{{ floor_entities('{floor.floor_id}') }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{floor.floor_id}' | floor_entities }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + # Get entities by floor name + info = render_to_info(hass, f"{{{{ floor_entities('{floor.name}') }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{floor.name}' | floor_entities }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + async def test_labels( hass: HomeAssistant, label_registry: lr.LabelRegistry, From 9a62b0f2457e6e0b95d52f3c83083a666e563322 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 13:05:30 +0100 Subject: [PATCH 2868/2987] Enable ASYNC ruff rules (#139507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- pyproject.toml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eda2a495726..5ee20b96bfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -705,12 +705,7 @@ required-version = ">=0.9.1" [tool.ruff.lint] select = [ "A001", # Variable {name} is shadowing a Python builtin - "ASYNC210", # Async functions should not call blocking HTTP methods - "ASYNC220", # Async functions should not create subprocesses with blocking methods - "ASYNC221", # Async functions should not run processes with blocking methods - "ASYNC222", # Async functions should not wait on processes with blocking methods - "ASYNC230", # Async functions should not open files with blocking methods like open - "ASYNC251", # Async functions should not call time.sleep + "ASYNC", # flake8-async "B002", # Python does not support the unary prefix increment "B005", # Using .strip() with multi-character strings is misleading "B007", # Loop control variable {name} not used within loop body @@ -810,6 +805,8 @@ select = [ ] ignore = [ + "ASYNC109", # Async function definition with a `timeout` parameter Use `asyncio.timeout` instead + "ASYNC110", # Use `asyncio.Event` instead of awaiting `asyncio.sleep` in a `while` loop "D202", # No blank lines allowed after function docstring "D203", # 1 blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line From 62dc0ac485b8b3b98794e767b45d754a5382000a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Feb 2025 13:38:56 +0100 Subject: [PATCH 2869/2987] Bump actions/cache from 4.2.1 to 4.2.2 (#139490) Bumps [actions/cache](https://github.com/actions/cache) from 4.2.1 to 4.2.2. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4.2.1...v4.2.2) --- updated-dependencies: - dependency-name: actions/cache dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 44 +++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 97986f26ee3..829888f3fe2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -240,7 +240,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.1 + uses: actions/cache@v4.2.2 with: path: venv key: >- @@ -256,7 +256,7 @@ jobs: uv pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v4.2.1 + uses: actions/cache@v4.2.2 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -286,7 +286,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -295,7 +295,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -326,7 +326,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -335,7 +335,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -366,7 +366,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -375,7 +375,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -482,7 +482,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.1 + uses: actions/cache@v4.2.2 with: path: venv key: >- @@ -490,7 +490,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore uv wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v4.2.1 + uses: actions/cache@v4.2.2 with: path: ${{ env.UV_CACHE_DIR }} key: >- @@ -578,7 +578,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -611,7 +611,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -649,7 +649,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -692,7 +692,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -739,7 +739,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -791,7 +791,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -799,7 +799,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v4.2.1 + uses: actions/cache@v4.2.2 with: path: .mypy_cache key: >- @@ -865,7 +865,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -929,7 +929,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -1051,7 +1051,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -1181,7 +1181,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -1328,7 +1328,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true From 0310418efcab37affbc927a0dc509bdd7a6bf792 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Feb 2025 13:54:31 +0100 Subject: [PATCH 2870/2987] Bump dawidd6/action-download-artifact from 8 to 9 (#139488) Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 8 to 9. - [Release notes](https://github.com/dawidd6/action-download-artifact/releases) - [Commits](https://github.com/dawidd6/action-download-artifact/compare/v8...v9) --- updated-dependencies: - dependency-name: dawidd6/action-download-artifact dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ed5005584bd..e730f03e1b4 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -94,7 +94,7 @@ jobs: - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v8 + uses: dawidd6/action-download-artifact@v9 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -105,7 +105,7 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v8 + uses: dawidd6/action-download-artifact@v9 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/intents-package From d6f9040bafecbe994c57ddf65dcb9665fc6c27c7 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 28 Feb 2025 14:14:56 +0100 Subject: [PATCH 2871/2987] Make the Tuya backend library compatible with the newer paho mqtt client. (#139518) * Make the Tuya backend library compatible with the newer paho mqtt client. * Improve classnames and docstrings --- homeassistant/components/tuya/__init__.py | 74 ++++++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index c8a639cd239..32119add5f4 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -3,7 +3,8 @@ from __future__ import annotations import logging -from typing import Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple +from urllib.parse import urlsplit from tuya_sharing import ( CustomerDevice, @@ -11,6 +12,7 @@ from tuya_sharing import ( SharingDeviceListener, SharingTokenListener, ) +from tuya_sharing.mq import SharingMQ, SharingMQConfig from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -45,13 +47,81 @@ class HomeAssistantTuyaData(NamedTuple): listener: SharingDeviceListener +if TYPE_CHECKING: + import paho.mqtt.client as mqtt + + +class ManagerCompat(Manager): + """Extended Manager class from the Tuya device sharing SDK. + + The extension ensures compatibility a paho-mqtt client version >= 2.1.0. + It overrides extend refresh_mq method to ensure correct paho.mqtt client calls. + + This code can be removed when a version of tuya-device-sharing with + https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available. + """ + + def refresh_mq(self): + """Refresh the MQTT connection.""" + if self.mq is not None: + self.mq.stop() + self.mq = None + + home_ids = [home.id for home in self.user_homes] + device = [ + device + for device in self.device_map.values() + if hasattr(device, "id") and getattr(device, "set_up", False) + ] + + sharing_mq = SharingMQCompat(self.customer_api, home_ids, device) + sharing_mq.start() + sharing_mq.add_message_listener(self.on_message) + self.mq = sharing_mq + + +class SharingMQCompat(SharingMQ): + """Extended SharingMQ class from the Tuya device sharing SDK. + + The extension ensures compatibility a paho-mqtt client version >= 2.1.0. + It overrides _start method to ensure correct paho.mqtt client calls. + + This code can be removed when a version of tuya-device-sharing with + https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available. + """ + + def _start(self, mq_config: SharingMQConfig) -> mqtt.Client: + """Start the MQTT client.""" + # 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 + + mqttc = mqtt.Client(client_id=mq_config.client_id) + mqttc.username_pw_set(mq_config.username, mq_config.password) + mqttc.user_data_set({"mqConfig": mq_config}) + mqttc.on_connect = self._on_connect + mqttc.on_message = self._on_message + mqttc.on_subscribe = self._on_subscribe + mqttc.on_log = self._on_log + mqttc.on_disconnect = self._on_disconnect + + url = urlsplit(mq_config.url) + if url.scheme == "ssl": + mqttc.tls_set() + + mqttc.connect(url.hostname, url.port) + + mqttc.loop_start() + return mqttc + + async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool: """Async setup hass config entry.""" if CONF_APP_TYPE in entry.data: raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.") token_listener = TokenListener(hass, entry) - manager = Manager( + manager = ManagerCompat( TUYA_CLIENT_ID, entry.data[CONF_USER_CODE], entry.data[CONF_TERMINAL_ID], From b79c6e772af85618ba0c19836c099b68ee48510a Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 28 Feb 2025 14:17:02 +0100 Subject: [PATCH 2872/2987] Add new mediatypes to Music Assistant integration (#139338) * Bump Music Assistant client to 1.1.0 * Add some casts to help mypy * Add handling of the new media types in Music Assistant * mypy cleanup * lint * update snapshot * Adjust tests --------- Co-authored-by: Franck Nijhof --- .../components/music_assistant/actions.py | 36 +- .../components/music_assistant/const.py | 2 + .../components/music_assistant/schemas.py | 8 + .../components/music_assistant/services.yaml | 7 + .../components/music_assistant/strings.json | 3 + tests/components/music_assistant/common.py | 26 +- .../fixtures/library_audiobooks.json | 489 ++++++++++++++++++ .../fixtures/library_podcasts.json | 309 +++++++++++ .../snapshots/test_actions.ambr | 196 ++++++- .../music_assistant/test_actions.py | 16 +- 10 files changed, 1087 insertions(+), 5 deletions(-) create mode 100644 tests/components/music_assistant/fixtures/library_audiobooks.json create mode 100644 tests/components/music_assistant/fixtures/library_podcasts.json diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py index bf9a1260362..031229d1544 100644 --- a/homeassistant/components/music_assistant/actions.py +++ b/homeassistant/components/music_assistant/actions.py @@ -23,6 +23,7 @@ from .const import ( ATTR_ALBUM_TYPE, ATTR_ALBUMS, ATTR_ARTISTS, + ATTR_AUDIOBOOKS, ATTR_CONFIG_ENTRY_ID, ATTR_FAVORITE, ATTR_ITEMS, @@ -32,6 +33,7 @@ from .const import ( ATTR_OFFSET, ATTR_ORDER_BY, ATTR_PLAYLISTS, + ATTR_PODCASTS, ATTR_RADIO, ATTR_SEARCH, ATTR_SEARCH_ALBUM, @@ -48,7 +50,15 @@ from .schemas import ( if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient - from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track + from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, + Playlist, + Podcast, + Radio, + Track, + ) from . import MusicAssistantConfigEntry @@ -155,6 +165,14 @@ async def handle_search(call: ServiceCall) -> ServiceResponse: media_item_dict_from_mass_item(mass, item) for item in search_results.radio ], + ATTR_AUDIOBOOKS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.audiobooks + ], + ATTR_PODCASTS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.podcasts + ], } ) return response @@ -175,7 +193,13 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: "order_by": order_by, } library_result: ( - list[Album] | list[Artist] | list[Track] | list[Radio] | list[Playlist] + list[Album] + | list[Artist] + | list[Track] + | list[Radio] + | list[Playlist] + | list[Audiobook] + | list[Podcast] ) if media_type == MediaType.ALBUM: library_result = await mass.music.get_library_albums( @@ -199,6 +223,14 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: library_result = await mass.music.get_library_playlists( **base_params, ) + elif media_type == MediaType.AUDIOBOOK: + library_result = await mass.music.get_library_audiobooks( + **base_params, + ) + elif media_type == MediaType.PODCAST: + library_result = await mass.music.get_library_podcasts( + **base_params, + ) else: raise ServiceValidationError(f"Unsupported media type {media_type}") diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index 1980c495278..d2ee1f75028 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -34,6 +34,8 @@ ATTR_ARTISTS = "artists" ATTR_ALBUMS = "albums" ATTR_TRACKS = "tracks" ATTR_PLAYLISTS = "playlists" +ATTR_AUDIOBOOKS = "audiobooks" +ATTR_PODCASTS = "podcasts" ATTR_RADIO = "radio" ATTR_ITEMS = "items" ATTR_RADIO_MODE = "radio_mode" diff --git a/homeassistant/components/music_assistant/schemas.py b/homeassistant/components/music_assistant/schemas.py index 0954d1573e7..7501d3d2038 100644 --- a/homeassistant/components/music_assistant/schemas.py +++ b/homeassistant/components/music_assistant/schemas.py @@ -15,6 +15,7 @@ from .const import ( ATTR_ALBUM, ATTR_ALBUMS, ATTR_ARTISTS, + ATTR_AUDIOBOOKS, ATTR_BIT_DEPTH, ATTR_CONTENT_TYPE, ATTR_CURRENT_INDEX, @@ -31,6 +32,7 @@ from .const import ( ATTR_OFFSET, ATTR_ORDER_BY, ATTR_PLAYLISTS, + ATTR_PODCASTS, ATTR_PROVIDER, ATTR_QUEUE_ID, ATTR_QUEUE_ITEM_ID, @@ -101,6 +103,12 @@ SEARCH_RESULT_SCHEMA = vol.Schema( vol.Required(ATTR_RADIO): vol.All( cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] ), + vol.Required(ATTR_AUDIOBOOKS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), + vol.Required(ATTR_PODCASTS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), }, ) diff --git a/homeassistant/components/music_assistant/services.yaml b/homeassistant/components/music_assistant/services.yaml index 73e8e2d7521..a3715ea2580 100644 --- a/homeassistant/components/music_assistant/services.yaml +++ b/homeassistant/components/music_assistant/services.yaml @@ -21,7 +21,10 @@ play_media: options: - artist - album + - audiobook + - folder - playlist + - podcast - track - radio artist: @@ -118,7 +121,9 @@ search: options: - artist - album + - audiobook - playlist + - podcast - track - radio artist: @@ -160,7 +165,9 @@ get_library: options: - artist - album + - audiobook - playlist + - podcast - track - radio favorite: diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 32b72088518..7338af7cb65 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -195,8 +195,11 @@ "options": { "artist": "Artist", "album": "Album", + "audiobook": "Audiobook", + "folder": "Folder", "track": "Track", "playlist": "Playlist", + "podcast": "Podcast", "radio": "Radio" } }, diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 863d945ccd1..6d7ef927c6e 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -8,7 +8,15 @@ from unittest.mock import AsyncMock, MagicMock from music_assistant_models.api import MassEvent from music_assistant_models.enums import EventType -from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track +from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, + Playlist, + Podcast, + Radio, + Track, +) from music_assistant_models.player import Player from music_assistant_models.player_queue import PlayerQueue from syrupy import SnapshotAssertion @@ -62,6 +70,10 @@ async def setup_integration_from_fixtures( music.get_playlist_tracks = AsyncMock(return_value=library_playlist_tracks) library_radios = create_library_radios_from_fixture() music.get_library_radios = AsyncMock(return_value=library_radios) + library_audiobooks = create_library_audiobooks_from_fixture() + music.get_library_audiobooks = AsyncMock(return_value=library_audiobooks) + library_podcasts = create_library_podcasts_from_fixture() + music.get_library_podcasts = AsyncMock(return_value=library_podcasts) music.get_item_by_uri = AsyncMock() config_entry.add_to_hass(hass) @@ -132,6 +144,18 @@ def create_library_radios_from_fixture() -> list[Radio]: return [Radio.from_dict(radio_data) for radio_data in fixture_data] +def create_library_audiobooks_from_fixture() -> list[Audiobook]: + """Create MA Audiobooks from fixture.""" + fixture_data = load_and_parse_fixture("library_audiobooks") + return [Audiobook.from_dict(radio_data) for radio_data in fixture_data] + + +def create_library_podcasts_from_fixture() -> list[Podcast]: + """Create MA Podcasts from fixture.""" + fixture_data = load_and_parse_fixture("library_podcasts") + return [Podcast.from_dict(radio_data) for radio_data in fixture_data] + + async def trigger_subscription_callback( hass: HomeAssistant, client: MagicMock, diff --git a/tests/components/music_assistant/fixtures/library_audiobooks.json b/tests/components/music_assistant/fixtures/library_audiobooks.json new file mode 100644 index 00000000000..1994ee68e14 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_audiobooks.json @@ -0,0 +1,489 @@ +{ + "library_audiobooks": [ + { + "item_id": "1", + "provider": "library", + "name": "Test Audiobook", + "version": "", + "sort_name": "test audiobook", + "uri": "library://audiobook/1", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "test-audiobook.mp3", + "provider_domain": "filesystem_smb", + "provider_instance": "filesystem_smb--7Kf8QySu", + "available": true, + "audio_format": { + "content_type": "mp3", + "codec_type": "?", + "sample_rate": 48000, + "bit_depth": 16, + "channels": 1, + "output_format_str": "mp3", + "bit_rate": 90304 + }, + "url": null, + "details": "1738502411" + } + ], + "metadata": { + "description": "Cover (front)", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "test-audiobook.mp3", + "provider": "filesystem_smb--7Kf8QySu", + "remotely_accessible": false + } + ], + "genres": [], + "mood": null, + "style": null, + "copyright": null, + "lyrics": "", + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": null, + "authors": ["TestWriter"], + "narrators": [], + "duration": 9, + "fully_played": true, + "resume_position_ms": 9000 + }, + { + "item_id": "11", + "provider": "library", + "name": "Test Audiobook 0", + "version": "", + "sort_name": "test audiobook 0", + "uri": "library://audiobook/11", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "0", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "12", + "provider": "library", + "name": "Test Audiobook 1", + "version": "", + "sort_name": "test audiobook 1", + "uri": "library://audiobook/12", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "1", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "13", + "provider": "library", + "name": "Test Audiobook 2", + "version": "", + "sort_name": "test audiobook 2", + "uri": "library://audiobook/13", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "2", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "14", + "provider": "library", + "name": "Test Audiobook 3", + "version": "", + "sort_name": "test audiobook 3", + "uri": "library://audiobook/14", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "3", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "15", + "provider": "library", + "name": "Test Audiobook 4", + "version": "", + "sort_name": "test audiobook 4", + "uri": "library://audiobook/15", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "4", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_podcasts.json b/tests/components/music_assistant/fixtures/library_podcasts.json new file mode 100644 index 00000000000..2c6a9c62f65 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_podcasts.json @@ -0,0 +1,309 @@ +{ + "library_podcasts": [ + { + "item_id": "6", + "provider": "library", + "name": "Test Podcast 0", + "version": "", + "sort_name": "test podcast 0", + "uri": "library://podcast/6", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "0", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "7", + "provider": "library", + "name": "Test Podcast 1", + "version": "", + "sort_name": "test podcast 1", + "uri": "library://podcast/7", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "1", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "8", + "provider": "library", + "name": "Test Podcast 2", + "version": "", + "sort_name": "test podcast 2", + "uri": "library://podcast/8", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "2", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "9", + "provider": "library", + "name": "Test Podcast 3", + "version": "", + "sort_name": "test podcast 3", + "uri": "library://podcast/9", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "3", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "10", + "provider": "library", + "name": "Test Podcast 4", + "version": "", + "sort_name": "test podcast 4", + "uri": "library://podcast/10", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "4", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + } + ] +} diff --git a/tests/components/music_assistant/snapshots/test_actions.ambr b/tests/components/music_assistant/snapshots/test_actions.ambr index 6c30ffc512c..32c8776c953 100644 --- a/tests/components/music_assistant/snapshots/test_actions.ambr +++ b/tests/components/music_assistant/snapshots/test_actions.ambr @@ -1,5 +1,195 @@ # serializer version: 1 -# name: test_get_library_action +# name: test_get_library_action[album] + dict({ + 'items': list([ + dict({ + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'A Space Love Adventure', + 'uri': 'library://artist/289', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Synth Punk EP', + 'uri': 'library://album/396', + 'version': '', + }), + dict({ + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Various Artists', + 'uri': 'library://artist/96', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Synthwave (The 80S Revival)', + 'uri': 'library://album/95', + 'version': 'The 80S Revival', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[artist] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'W O L F C L U B', + 'uri': 'library://artist/127', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[audiobook] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook', + 'uri': 'library://audiobook/1', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 0', + 'uri': 'library://audiobook/11', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 1', + 'uri': 'library://audiobook/12', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 2', + 'uri': 'library://audiobook/13', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 3', + 'uri': 'library://audiobook/14', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 4', + 'uri': 'library://audiobook/15', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[playlist] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': '1970s Rock Hits', + 'uri': 'library://playlist/40', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[podcast] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 0', + 'uri': 'library://podcast/6', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 1', + 'uri': 'library://podcast/7', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 2', + 'uri': 'library://podcast/8', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 3', + 'uri': 'library://podcast/9', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 4', + 'uri': 'library://podcast/10', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[radio] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'fm4 | ORF | HQ', + 'uri': 'library://radio/1', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[track] dict({ 'items': list([ dict({ @@ -192,8 +382,12 @@ ]), 'artists': list([ ]), + 'audiobooks': list([ + ]), 'playlists': list([ ]), + 'podcasts': list([ + ]), 'radio': list([ ]), 'tracks': list([ diff --git a/tests/components/music_assistant/test_actions.py b/tests/components/music_assistant/test_actions.py index 4d3917091c1..ba8b1acdeac 100644 --- a/tests/components/music_assistant/test_actions.py +++ b/tests/components/music_assistant/test_actions.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock from music_assistant_models.media_items import SearchResults +import pytest from syrupy import SnapshotAssertion from homeassistant.components.music_assistant.actions import ( @@ -47,9 +48,22 @@ async def test_search_action( assert response == snapshot +@pytest.mark.parametrize( + "media_type", + [ + "artist", + "album", + "track", + "playlist", + "audiobook", + "podcast", + "radio", + ], +) async def test_get_library_action( hass: HomeAssistant, music_assistant_client: MagicMock, + media_type: str, snapshot: SnapshotAssertion, ) -> None: """Test music assistant get_library action.""" @@ -60,7 +74,7 @@ async def test_get_library_action( { ATTR_CONFIG_ENTRY_ID: entry.entry_id, ATTR_FAVORITE: False, - ATTR_MEDIA_TYPE: "track", + ATTR_MEDIA_TYPE: media_type, }, blocking=True, return_response=True, From d157919da2111f8a0fa5efe12a0220028cbe4acd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Feb 2025 14:19:18 +0100 Subject: [PATCH 2873/2987] Bump actions/attest-build-provenance from 2.2.1 to 2.2.2 (#139489) Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 2.2.1 to 2.2.2. - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/f9eaf234fc1c2e333c1eca18177db0f44fa6ba52...bd77c077858b8d561b7a36cbe48ef4cc642ca39d) --- updated-dependencies: - dependency-name: actions/attest-build-provenance dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index e730f03e1b4..f3bdd0084af 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -531,7 +531,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@f9eaf234fc1c2e333c1eca18177db0f44fa6ba52 # v2.2.1 + uses: actions/attest-build-provenance@bd77c077858b8d561b7a36cbe48ef4cc642ca39d # v2.2.2 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} From 030a1460de46fd60f014a36723c7773c2c6066fd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 28 Feb 2025 14:20:39 +0100 Subject: [PATCH 2874/2987] Log a warning when replacing existing config entry with same unique id (#130567) * Log a warning when replacing existing config entry with same unique id * Exclude mobile_app * Ignore custom integrations * Apply suggestions from code review * Apply suggestions from code review * Update config_entries.py * Fix handler * Adjust and add tests * Apply suggestions from code review * Apply suggestions from code review * Update comment * Update config_entries.py * Apply suggestions from code review --- homeassistant/config_entries.py | 17 ++++++++++ tests/test_config_entries.py | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 2639c429e71..98d9e3c760c 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1628,6 +1628,23 @@ class ConfigEntriesFlowManager( result["handler"], flow.unique_id ) + if existing_entry is not None and flow.handler != "mobile_app": + # This causes the old entry to be removed and replaced, when the flow + # should instead be aborted. + # In case of manual flows, integrations should implement options, reauth, + # reconfigure to allow the user to change settings. + # In case of non user visible flows, the integration should optionally + # update the existing entry before aborting. + # see https://developers.home-assistant.io/blog/2025/01/16/config-flow-unique-id/ + report_usage( + "creates a config entry when another entry with the same unique ID " + "exists", + core_behavior=ReportBehavior.LOG, + core_integration_behavior=ReportBehavior.LOG, + custom_integration_behavior=ReportBehavior.LOG, + integration_domain=flow.handler, + ) + # Unload the entry before setting up the new one. if existing_entry is not None and existing_entry.state.recoverable: await self.config_entries.async_unload(existing_entry.entry_id) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 7066417bfee..66aa29d95d1 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -8899,3 +8899,63 @@ async def test_add_description_placeholder_automatically_not_overwrites( result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], None) assert result["type"] == FlowResultType.FORM assert result["description_placeholders"] == {"name": "Custom title"} + + +@pytest.mark.parametrize( + ("domain", "expected_log"), + [ + ("some_integration", True), + ("mobile_app", False), + ], +) +async def test_create_entry_existing_unique_id( + hass: HomeAssistant, + domain: str, + expected_log: bool, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test to highlight unexpected behavior on create_entry.""" + entry = MockConfigEntry( + title="From config flow", + domain=domain, + entry_id="01J915Q6T9F6G5V0QJX6HBC94T", + data={"host": "any", "port": 123}, + unique_id="mock-unique-id", + ) + entry.add_to_hass(hass) + + assert len(hass.config_entries.async_entries(domain)) == 1 + + mock_setup_entry = AsyncMock(return_value=True) + + mock_integration(hass, MockModule(domain, async_setup_entry=mock_setup_entry)) + mock_platform(hass, f"{domain}.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Test user step.""" + await self.async_set_unique_id("mock-unique-id") + return self.async_create_entry(title="mock-title", data={}) + + with ( + mock_config_flow(domain, TestFlow), + patch.object(frame, "_REPORTED_INTEGRATIONS", set()), + ): + result = await hass.config_entries.flow.async_init( + domain, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert len(hass.config_entries.async_entries(domain)) == 1 + + log_text = ( + f"Detected that integration '{domain}' creates a config entry " + "when another entry with the same unique ID exists. Please " + "create a bug report at https:" + ) + assert (log_text in caplog.text) == expected_log From 228a4eb39129ba39efaa328bf6f4a77560f78baf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Feb 2025 14:25:35 +0100 Subject: [PATCH 2875/2987] Improve error handling in CoreBackupReaderWriter (#139508) --- homeassistant/components/backup/manager.py | 27 +++++++++++-- tests/components/backup/test_manager.py | 46 +++++++++++++++++----- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 317de85b823..c8b515e3aee 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -14,6 +14,7 @@ from itertools import chain import json from pathlib import Path, PurePath import shutil +import sys import tarfile import time from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast @@ -308,6 +309,12 @@ class DecryptOnDowloadNotSupported(BackupManagerError): _message = "On-the-fly decryption is not supported for this backup." +class BackupManagerExceptionGroup(BackupManagerError, ExceptionGroup): + """Raised when multiple exceptions occur.""" + + error_code = "multiple_errors" + + class BackupManager: """Define the format that backup managers can have.""" @@ -1605,10 +1612,24 @@ class CoreBackupReaderWriter(BackupReaderWriter): ) finally: # Inform integrations the backup is done + # If there's an unhandled exception, we keep it so we can rethrow it in case + # the post backup actions also fail. + unhandled_exc = sys.exception() try: - await manager.async_post_backup_actions() - except BackupManagerError as err: - raise BackupReaderWriterError(str(err)) from err + try: + await manager.async_post_backup_actions() + except BackupManagerError as err: + raise BackupReaderWriterError(str(err)) from err + except Exception as err: + if not unhandled_exc: + raise + # If there's an unhandled exception, we wrap both that and the exception + # from the post backup actions in an ExceptionGroup so the caller is + # aware of both exceptions. + raise BackupManagerExceptionGroup( + f"Multiple errors when creating backup: {unhandled_exc}, {err}", + [unhandled_exc, err], + ) from None def _mkdir_and_generate_backup_contents( self, diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 6e626e63748..e4762f35327 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -8,6 +8,7 @@ from dataclasses import replace from io import StringIO import json from pathlib import Path +import re import tarfile from typing import Any from unittest.mock import ( @@ -35,6 +36,7 @@ from homeassistant.components.backup.agent import BackupAgentError from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.components.backup.manager import ( BackupManagerError, + BackupManagerExceptionGroup, BackupManagerState, CreateBackupStage, CreateBackupState, @@ -1646,34 +1648,60 @@ async def test_exception_platform_pre(hass: HomeAssistant) -> None: assert str(err.value) == "Error during pre-backup: Test exception" +@pytest.mark.parametrize( + ("unhandled_error", "expected_exception", "expected_msg"), + [ + (None, BackupManagerError, "Error during post-backup: Test exception"), + ( + HomeAssistantError("Boom"), + BackupManagerExceptionGroup, + ( + "Multiple errors when creating backup: Error during pre-backup: Boom, " + "Error during post-backup: Test exception (2 sub-exceptions)" + ), + ), + ( + Exception("Boom"), + BackupManagerExceptionGroup, + ( + "Multiple errors when creating backup: Error during pre-backup: Boom, " + "Error during post-backup: Test exception (2 sub-exceptions)" + ), + ), + ], +) @pytest.mark.usefixtures("mock_backup_generation") -async def test_exception_platform_post(hass: HomeAssistant) -> None: +async def test_exception_platform_post( + hass: HomeAssistant, + unhandled_error: Exception | None, + expected_exception: type[Exception], + expected_msg: str, +) -> None: """Test exception in post step.""" - async def _mock_step(hass: HomeAssistant) -> None: - raise HomeAssistantError("Test exception") - remote_agent = mock_backup_agent("remote") await setup_backup_platform( hass, domain="test", platform=Mock( - async_pre_backup=AsyncMock(), - async_post_backup=_mock_step, + # We let the pre_backup fail to test that unhandled errors are not discarded + # when post backup fails + async_pre_backup=AsyncMock(side_effect=unhandled_error), + async_post_backup=AsyncMock( + side_effect=HomeAssistantError("Test exception") + ), async_get_backup_agents=AsyncMock(return_value=[remote_agent]), ), ) await setup_backup_integration(hass) - with pytest.raises(BackupManagerError) as err: + with pytest.raises(expected_exception, match=re.escape(expected_msg)): await hass.services.async_call( DOMAIN, "create", blocking=True, ) - assert str(err.value) == "Error during post-backup: Test exception" - @pytest.mark.parametrize( ( From ac15d9b3d400fe7b581f52d9e642763e4c70cb0b Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 28 Feb 2025 23:26:39 +1000 Subject: [PATCH 2876/2987] Fix shift state in Teslemetry (#139505) * Fix shift state * Different fix --- homeassistant/components/teslemetry/sensor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 70315e92da0..56c8830d736 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta from propcache.api import cached_property from teslemetry_stream import Signal +from teslemetry_stream.const import ShiftState from homeassistant.components.sensor import ( RestoreSensor, @@ -69,7 +70,7 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): polling_value_fn: Callable[[StateType], StateType] = lambda x: x polling_available_fn: Callable[[StateType], bool] = lambda x: x is not None streaming_key: Signal | None = None - streaming_value_fn: Callable[[StateType], StateType] = lambda x: x + streaming_value_fn: Callable[[str | int | float], StateType] = lambda x: x streaming_firmware: str = "2024.26" @@ -212,7 +213,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( polling_available_fn=lambda x: True, polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"), streaming_key=Signal.GEAR, - streaming_value_fn=lambda x: SHIFT_STATES.get(str(x)), + streaming_value_fn=lambda x: str(ShiftState.get(x, "P")).lower(), options=list(SHIFT_STATES.values()), device_class=SensorDeviceClass.ENUM, entity_registry_enabled_default=False, From c2a773641778088fe97cb52ce2f072b8fd90eff4 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 28 Feb 2025 14:30:47 +0100 Subject: [PATCH 2877/2987] Don't split wheels builder anymore (#139522) --- .github/workflows/wheels.yml | 40 ++---------------------------------- 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 7c02c8d97cd..4b1628c57bb 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -218,15 +218,7 @@ jobs: sed -i "/uv/d" requirements.txt sed -i "/uv/d" requirements_diff.txt - - name: Split requirements all - run: | - # We split requirements all into multiple files. - # This is to prevent the build from running out of memory when - # resolving packages on 32-bits systems (like armhf, armv7). - - split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt - - - name: Build wheels (part 1) + - name: Build wheels uses: home-assistant/wheels@2024.11.0 with: abi: ${{ matrix.abi }} @@ -238,32 +230,4 @@ jobs: skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" - requirements: "requirements_all.txtaa" - - - name: Build wheels (part 2) - uses: home-assistant/wheels@2024.11.0 - with: - abi: ${{ matrix.abi }} - tag: musllinux_1_2 - arch: ${{ matrix.arch }} - wheels-key: ${{ secrets.WHEELS_KEY }} - env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl - constraints: "homeassistant/package_constraints.txt" - requirements-diff: "requirements_diff.txt" - requirements: "requirements_all.txtab" - - - name: Build wheels (part 3) - uses: home-assistant/wheels@2024.11.0 - with: - abi: ${{ matrix.abi }} - tag: musllinux_1_2 - arch: ${{ matrix.arch }} - wheels-key: ${{ secrets.WHEELS_KEY }} - env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl - constraints: "homeassistant/package_constraints.txt" - requirements-diff: "requirements_diff.txt" - requirements: "requirements_all.txtac" + requirements: "requirements_all.txt" From 40d2d6df2cbeb0561f9f55104a6d361bb211053b Mon Sep 17 00:00:00 2001 From: Jeef Date: Fri, 28 Feb 2025 06:32:52 -0700 Subject: [PATCH 2878/2987] Bump weatherflow4py to 1.3.1 (#135529) * version bump of dep * update requirements --- homeassistant/components/weatherflow_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index 98c98cfbac7..9ffa457a355 100644 --- a/homeassistant/components/weatherflow_cloud/manifest.json +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud", "iot_class": "cloud_polling", "loggers": ["weatherflow4py"], - "requirements": ["weatherflow4py==1.0.6"] + "requirements": ["weatherflow4py==1.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 00509109413..9f70f98ecf0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3046,7 +3046,7 @@ waterfurnace==1.1.0 watergate-local-api==2024.4.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==1.0.6 +weatherflow4py==1.3.1 # homeassistant.components.cisco_webex_teams webexpythonsdk==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 609639b0735..5bf3fde31e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2453,7 +2453,7 @@ watchdog==6.0.0 watergate-local-api==2024.4.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==1.0.6 +weatherflow4py==1.3.1 # homeassistant.components.nasweb webio-api==0.1.11 From 3cd7f502165ec10db0fd2acb01d3fc0f555210e9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 15:47:51 +0100 Subject: [PATCH 2879/2987] Bump yt-dlp to 2025.02.19 (#139526) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index f0f8ee03ad0..575c0fa878d 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2025.01.26"], + "requirements": ["yt-dlp[default]==2025.02.19"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 9f70f98ecf0..18d94649d0b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3131,7 +3131,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.01.26 +yt-dlp[default]==2025.02.19 # homeassistant.components.zabbix zabbix-utils==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5bf3fde31e1..98af884569b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2526,7 +2526,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.01.26 +yt-dlp[default]==2025.02.19 # homeassistant.components.zamg zamg==0.3.6 From 1b27365c58c3e6607ace6ba5120239f4864752fe Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 28 Feb 2025 16:00:31 +0100 Subject: [PATCH 2880/2987] Suppress unsupported event 'EVT_USP_RpsPowerDeniedByPsuOverload' by bumping aiounifi to v83 (#139519) Bump aiounifi to v83 --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index f5ad99b72f7..dd255c57c13 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiounifi"], - "requirements": ["aiounifi==82"], + "requirements": ["aiounifi==83"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 18d94649d0b..c8e7bbc806a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -404,7 +404,7 @@ aiotedee==0.2.20 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==82 +aiounifi==83 # homeassistant.components.usb aiousbwatcher==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 98af884569b..e8e7c4a34f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -386,7 +386,7 @@ aiotedee==0.2.20 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==82 +aiounifi==83 # homeassistant.components.usb aiousbwatcher==1.1.1 From 0f0866cd5281df5d62b3d14fc55241093e0f128a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 28 Feb 2025 16:03:47 +0100 Subject: [PATCH 2881/2987] Improve description of `mode` field in `geniushub.set_zone_mode` action (#139513) Improve description of `mode` field in 'geniushub.set_zone_mode' action As the three choices for the `mode` field show up as radio buttons in the UI the description does not need to repeat them. This improves translations by avoiding any over-translation of these values. --- homeassistant/components/geniushub/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/geniushub/strings.json b/homeassistant/components/geniushub/strings.json index 42d53c7fa00..79eee2c9a1b 100644 --- a/homeassistant/components/geniushub/strings.json +++ b/homeassistant/components/geniushub/strings.json @@ -45,7 +45,7 @@ }, "mode": { "name": "[%key:common::config_flow::data::mode%]", - "description": "One of: off, timer or footprint." + "description": "The zone's operating mode." } } }, From 5fa5d08b18f136ed0a2b57a2a3d95826c771233d Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 28 Feb 2025 16:16:23 +0100 Subject: [PATCH 2882/2987] Bump wheels to 2025.02.0 (#139525) --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 4b1628c57bb..c651ccbe715 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -159,7 +159,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2024.11.0 + uses: home-assistant/wheels@2025.02.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -219,7 +219,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2024.11.0 + uses: home-assistant/wheels@2025.02.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From 0681652aec080208e0a76f22a9bb2e766332d680 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 28 Feb 2025 16:18:57 +0100 Subject: [PATCH 2883/2987] Add diagnostics to onedrive (#139516) * Add diagnostics to onedrive * redact PII * add raw data --- .../components/onedrive/diagnostics.py | 33 +++++++++++++++++++ .../components/onedrive/quality_scale.yaml | 5 +-- .../onedrive/snapshots/test_diagnostics.ambr | 31 +++++++++++++++++ tests/components/onedrive/test_diagnostics.py | 26 +++++++++++++++ 4 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/onedrive/diagnostics.py create mode 100644 tests/components/onedrive/snapshots/test_diagnostics.ambr create mode 100644 tests/components/onedrive/test_diagnostics.py diff --git a/homeassistant/components/onedrive/diagnostics.py b/homeassistant/components/onedrive/diagnostics.py new file mode 100644 index 00000000000..0e1ed94e155 --- /dev/null +++ b/homeassistant/components/onedrive/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics support for OneDrive.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from .coordinator import OneDriveConfigEntry + +TO_REDACT = {"display_name", "email", CONF_ACCESS_TOKEN, CONF_TOKEN} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: OneDriveConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + coordinator = entry.runtime_data.coordinator + + data = { + "drive": asdict(coordinator.data), + "config": { + **entry.data, + **entry.options, + }, + } + + return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml index dd9e7f26102..023410d89b2 100644 --- a/homeassistant/components/onedrive/quality_scale.yaml +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -41,10 +41,7 @@ rules: # Gold devices: done - diagnostics: - status: exempt - comment: | - There is no data to diagnose. + diagnostics: done discovery-update-info: status: exempt comment: | diff --git a/tests/components/onedrive/snapshots/test_diagnostics.ambr b/tests/components/onedrive/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..827b9397313 --- /dev/null +++ b/tests/components/onedrive/snapshots/test_diagnostics.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config': dict({ + 'auth_implementation': 'onedrive', + 'folder_id': 'my_folder_id', + 'folder_name': 'name', + 'token': '**REDACTED**', + }), + 'drive': dict({ + 'drive_type': 'personal', + 'id': 'mock_drive_id', + 'name': 'My Drive', + 'owner': dict({ + 'application': None, + 'user': dict({ + 'display_name': '**REDACTED**', + 'email': '**REDACTED**', + 'id': 'id', + }), + }), + 'quota': dict({ + 'deleted': 5, + 'remaining': 805306368, + 'state': 'nearing', + 'total': 5368709120, + 'used': 4250000000, + }), + }), + }) +# --- diff --git a/tests/components/onedrive/test_diagnostics.py b/tests/components/onedrive/test_diagnostics.py new file mode 100644 index 00000000000..f82d9925ee6 --- /dev/null +++ b/tests/components/onedrive/test_diagnostics.py @@ -0,0 +1,26 @@ +"""Tests for the diagnostics data provided by the OneDrive integration.""" + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + await setup_integration(hass, mock_config_entry) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) From fca19a3ec139233788670e733684e8612156a91c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 28 Feb 2025 09:25:38 -0600 Subject: [PATCH 2884/2987] Move climate intent to homeassistant integration (#139371) * Move climate intent to homeassistant integration * Move get temperature intent to intent integration * Clean up old test --- homeassistant/components/climate/__init__.py | 1 - homeassistant/components/climate/const.py | 1 - homeassistant/components/climate/intent.py | 43 +- homeassistant/components/intent/__init__.py | 44 ++ homeassistant/helpers/intent.py | 1 + homeassistant/helpers/llm.py | 11 +- tests/components/climate/test_intent.py | 330 -------------- tests/components/intent/test_temperature.py | 456 +++++++++++++++++++ 8 files changed, 508 insertions(+), 379 deletions(-) create mode 100644 tests/components/intent/test_temperature.py diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 3ea0f887e76..287a2397121 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -68,7 +68,6 @@ from .const import ( # noqa: F401 FAN_ON, FAN_TOP, HVAC_MODES, - INTENT_GET_TEMPERATURE, INTENT_SET_TEMPERATURE, PRESET_ACTIVITY, PRESET_AWAY, diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index d347ccbbb29..ecc0066cd93 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -126,7 +126,6 @@ DEFAULT_MAX_HUMIDITY = 99 DOMAIN = "climate" -INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" INTENT_SET_TEMPERATURE = "HassClimateSetTemperature" SERVICE_SET_AUX_HEAT = "set_aux_heat" diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 9837a326188..7691a2db0f1 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -1,4 +1,4 @@ -"""Intents for the client integration.""" +"""Intents for the climate integration.""" from __future__ import annotations @@ -11,7 +11,6 @@ from homeassistant.helpers import config_validation as cv, intent from . import ( ATTR_TEMPERATURE, DOMAIN, - INTENT_GET_TEMPERATURE, INTENT_SET_TEMPERATURE, SERVICE_SET_TEMPERATURE, ClimateEntityFeature, @@ -20,49 +19,9 @@ from . import ( async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the climate intents.""" - intent.async_register(hass, GetTemperatureIntent()) intent.async_register(hass, SetTemperatureIntent()) -class GetTemperatureIntent(intent.IntentHandler): - """Handle GetTemperature intents.""" - - intent_type = INTENT_GET_TEMPERATURE - description = "Gets the current temperature of a climate device or entity" - slot_schema = { - vol.Optional("area"): intent.non_empty_string, - vol.Optional("name"): intent.non_empty_string, - } - platforms = {DOMAIN} - - async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: - """Handle the intent.""" - hass = intent_obj.hass - slots = self.async_validate_slots(intent_obj.slots) - - name: str | None = None - if "name" in slots: - name = slots["name"]["value"] - - area: str | None = None - if "area" in slots: - area = slots["area"]["value"] - - match_constraints = intent.MatchTargetsConstraints( - name=name, area_name=area, domains=[DOMAIN], assistant=intent_obj.assistant - ) - match_result = intent.async_match_targets(hass, match_constraints) - if not match_result.is_match: - raise intent.MatchFailedError( - result=match_result, constraints=match_constraints - ) - - response = intent_obj.create_response() - response.response_type = intent.IntentResponseType.QUERY_ANSWER - response.async_set_states(matched_states=match_result.states) - return response - - class SetTemperatureIntent(intent.IntentHandler): """Handle SetTemperature intents.""" diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index a1451f8fcca..2f9587e2173 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -9,6 +9,7 @@ from aiohttp import web import voluptuous as vol from homeassistant.components import http +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.cover import ( ATTR_POSITION, DOMAIN as COVER_DOMAIN, @@ -140,6 +141,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.async_register(hass, GetCurrentDateIntentHandler()) intent.async_register(hass, GetCurrentTimeIntentHandler()) intent.async_register(hass, RespondIntentHandler()) + intent.async_register(hass, GetTemperatureIntent()) return True @@ -444,6 +446,48 @@ class RespondIntentHandler(intent.IntentHandler): return response +class GetTemperatureIntent(intent.IntentHandler): + """Handle GetTemperature intents.""" + + intent_type = intent.INTENT_GET_TEMPERATURE + description = "Gets the current temperature of a climate device or entity" + slot_schema = { + vol.Optional("area"): intent.non_empty_string, + vol.Optional("name"): intent.non_empty_string, + } + platforms = {CLIMATE_DOMAIN} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + + name: str | None = None + if "name" in slots: + name = slots["name"]["value"] + + area: str | None = None + if "area" in slots: + area = slots["area"]["value"] + + match_constraints = intent.MatchTargetsConstraints( + name=name, + area_name=area, + domains=[CLIMATE_DOMAIN], + assistant=intent_obj.assistant, + ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.QUERY_ANSWER + response.async_set_states(matched_states=match_result.states) + return response + + async def _async_process_intent( hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol ) -> None: diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index c93545ed414..cecb84d0373 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -59,6 +59,7 @@ INTENT_GET_CURRENT_DATE = "HassGetCurrentDate" INTENT_GET_CURRENT_TIME = "HassGetCurrentTime" INTENT_RESPOND = "HassRespond" INTENT_BROADCAST = "HassBroadcast" +INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 2ef785e7f71..4ad2bdd6563 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -19,7 +19,6 @@ from homeassistant.components.calendar import ( DOMAIN as CALENDAR_DOMAIN, SERVICE_GET_EVENTS, ) -from homeassistant.components.climate import INTENT_GET_TEMPERATURE from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.intent import async_device_supports_timers @@ -285,7 +284,7 @@ class AssistAPI(API): """API exposing Assist API to LLMs.""" IGNORE_INTENTS = { - INTENT_GET_TEMPERATURE, + intent.INTENT_GET_TEMPERATURE, INTENT_GET_WEATHER, INTENT_OPEN_COVER, # deprecated INTENT_CLOSE_COVER, # deprecated @@ -530,9 +529,11 @@ def _get_exposed_entities( info["areas"] = ", ".join(area_names) if attributes := { - attr_name: str(attr_value) - if isinstance(attr_value, (Enum, Decimal, int)) - else attr_value + attr_name: ( + str(attr_value) + if isinstance(attr_value, (Enum, Decimal, int)) + else attr_value + ) for attr_name, attr_value in state.attributes.items() if attr_name in interesting_attributes }: diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index 65d607e618b..4ce06199eb8 100644 --- a/tests/components/climate/test_intent.py +++ b/tests/components/climate/test_intent.py @@ -14,7 +14,6 @@ from homeassistant.components.climate import ( HVACMode, intent as climate_intent, ) -from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform, UnitOfTemperature from homeassistant.core import HomeAssistant @@ -131,335 +130,6 @@ class MockClimateEntityNoSetTemperature(ClimateEntity): _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] -async def test_get_temperature( - hass: HomeAssistant, - area_registry: ar.AreaRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test HassClimateGetTemperature intent.""" - assert await async_setup_component(hass, "homeassistant", {}) - await climate_intent.async_setup_intents(hass) - - climate_1 = MockClimateEntity() - climate_1._attr_name = "Climate 1" - climate_1._attr_unique_id = "1234" - climate_1._attr_current_temperature = 10.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "1234", suggested_object_id="climate_1" - ) - - climate_2 = MockClimateEntity() - climate_2._attr_name = "Climate 2" - climate_2._attr_unique_id = "5678" - climate_2._attr_current_temperature = 22.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "5678", suggested_object_id="climate_2" - ) - - await create_mock_platform(hass, [climate_1, climate_2]) - - # Add climate entities to different areas: - # climate_1 => living room - # climate_2 => bedroom - # nothing in office - living_room_area = area_registry.async_create(name="Living Room") - bedroom_area = area_registry.async_create(name="Bedroom") - office_area = area_registry.async_create(name="Office") - - entity_registry.async_update_entity( - climate_1.entity_id, area_id=living_room_area.id - ) - entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) - - # First climate entity will be selected (no area) - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert response.matched_states - assert response.matched_states[0].entity_id == climate_1.entity_id - state = response.matched_states[0] - assert state.attributes["current_temperature"] == 10.0 - - # Select by area (climate_2) - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": bedroom_area.name}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - state = response.matched_states[0] - assert state.attributes["current_temperature"] == 22.0 - - # Select by name (climate_2) - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": "Climate 2"}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - state = response.matched_states[0] - assert state.attributes["current_temperature"] == 22.0 - - # Check area with no climate entities - with pytest.raises(intent.MatchFailedError) as error: - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": office_area.name}}, - assistant=conversation.DOMAIN, - ) - - # Exception should contain details of what we tried to match - assert isinstance(error.value, intent.MatchFailedError) - assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA - constraints = error.value.constraints - assert constraints.name is None - assert constraints.area_name == office_area.name - assert constraints.domains and (set(constraints.domains) == {DOMAIN}) - assert constraints.device_classes is None - - # Check wrong name - with pytest.raises(intent.MatchFailedError) as error: - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": "Does not exist"}}, - ) - - assert isinstance(error.value, intent.MatchFailedError) - assert error.value.result.no_match_reason == intent.MatchFailedReason.NAME - constraints = error.value.constraints - assert constraints.name == "Does not exist" - assert constraints.area_name is None - assert constraints.domains and (set(constraints.domains) == {DOMAIN}) - assert constraints.device_classes is None - - # Check wrong name with area - with pytest.raises(intent.MatchFailedError) as error: - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}}, - ) - - assert isinstance(error.value, intent.MatchFailedError) - assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA - constraints = error.value.constraints - assert constraints.name == "Climate 1" - assert constraints.area_name == bedroom_area.name - assert constraints.domains and (set(constraints.domains) == {DOMAIN}) - assert constraints.device_classes is None - - -async def test_get_temperature_no_entities( - hass: HomeAssistant, -) -> None: - """Test HassClimateGetTemperature intent with no climate entities.""" - assert await async_setup_component(hass, "homeassistant", {}) - await climate_intent.async_setup_intents(hass) - - await create_mock_platform(hass, []) - - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN - - -async def test_not_exposed( - hass: HomeAssistant, - area_registry: ar.AreaRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test HassClimateGetTemperature intent when entities aren't exposed.""" - assert await async_setup_component(hass, "homeassistant", {}) - await climate_intent.async_setup_intents(hass) - - climate_1 = MockClimateEntity() - climate_1._attr_name = "Climate 1" - climate_1._attr_unique_id = "1234" - climate_1._attr_current_temperature = 10.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "1234", suggested_object_id="climate_1" - ) - - climate_2 = MockClimateEntity() - climate_2._attr_name = "Climate 2" - climate_2._attr_unique_id = "5678" - climate_2._attr_current_temperature = 22.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "5678", suggested_object_id="climate_2" - ) - - await create_mock_platform(hass, [climate_1, climate_2]) - - # Add climate entities to same area - living_room_area = area_registry.async_create(name="Living Room") - bedroom_area = area_registry.async_create(name="Bedroom") - entity_registry.async_update_entity( - climate_1.entity_id, area_id=living_room_area.id - ) - entity_registry.async_update_entity( - climate_2.entity_id, area_id=living_room_area.id - ) - - # Should fail with empty name - with pytest.raises(intent.InvalidSlotInfo): - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": ""}}, - assistant=conversation.DOMAIN, - ) - - # Should fail with empty area - with pytest.raises(intent.InvalidSlotInfo): - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": ""}}, - assistant=conversation.DOMAIN, - ) - - # Expose second, hide first - async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) - async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True) - - # Second climate entity is exposed - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - - # Using the area should work - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": living_room_area.name}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - - # Using the name of the exposed entity should work - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": climate_2.name}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - - # Using the name of the *unexposed* entity should fail - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": climate_1.name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Expose first, hide second - async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True) - async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) - - # Second climate entity is exposed - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_1.entity_id - - # Wrong area name - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": bedroom_area.name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA - - # Neither are exposed - async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) - async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) - - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Should fail with area - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": living_room_area.name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Should fail with both names - for name in (climate_1.name, climate_2.name): - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - async def test_set_temperature( hass: HomeAssistant, area_registry: ar.AreaRegistry, diff --git a/tests/components/intent/test_temperature.py b/tests/components/intent/test_temperature.py new file mode 100644 index 00000000000..0279fa44b28 --- /dev/null +++ b/tests/components/intent/test_temperature.py @@ -0,0 +1,456 @@ +"""Test temperature intents.""" + +from collections.abc import Generator +from typing import Any + +import pytest + +from homeassistant.components import conversation +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import Platform, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers import area_registry as ar, entity_registry as er, intent +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.fixture(autouse=True) +def mock_setup_integration(hass: HomeAssistant) -> None: + """Fixture to set up a mock integration.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [CLIMATE_DOMAIN] + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, + config_entry: ConfigEntry, + ) -> bool: + await hass.config_entries.async_unload_platforms(config_entry, [Platform.TODO]) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + +async def create_mock_platform( + hass: HomeAssistant, + entities: list[ClimateEntity], +) -> MockConfigEntry: + """Create a todo platform with the specified entities.""" + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Set up test event platform via config entry.""" + async_add_entities(entities) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{CLIMATE_DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +class MockClimateEntity(ClimateEntity): + """Mock Climate device to use in tests.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_mode = HVACMode.OFF + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the thermostat temperature.""" + value = kwargs[ATTR_TEMPERATURE] + self._attr_target_temperature = value + + +class MockClimateEntityNoSetTemperature(ClimateEntity): + """Mock Climate device to use in tests.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_mode = HVACMode.OFF + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + + +async def test_get_temperature( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test HassClimateGetTemperature intent.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to different areas: + # climate_1 => living room + # climate_2 => bedroom + # nothing in office + living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") + office_area = area_registry.async_create(name="Office") + + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) + + # First climate entity will be selected (no area) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert response.matched_states + assert response.matched_states[0].entity_id == climate_1.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 10.0 + + # Select by area (climate_2) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": bedroom_area.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + + # Select by name (climate_2) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Climate 2"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + + # Check area with no climate entities + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": office_area.name}}, + assistant=conversation.DOMAIN, + ) + + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA + constraints = error.value.constraints + assert constraints.name is None + assert constraints.area_name == office_area.name + assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) + assert constraints.device_classes is None + + # Check wrong name + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Does not exist"}}, + ) + + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.NAME + constraints = error.value.constraints + assert constraints.name == "Does not exist" + assert constraints.area_name is None + assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) + assert constraints.device_classes is None + + # Check wrong name with area + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}}, + ) + + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA + constraints = error.value.constraints + assert constraints.name == "Climate 1" + assert constraints.area_name == bedroom_area.name + assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) + assert constraints.device_classes is None + + +async def test_get_temperature_no_entities( + hass: HomeAssistant, +) -> None: + """Test HassClimateGetTemperature intent with no climate entities.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + await create_mock_platform(hass, []) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN + + +async def test_not_exposed( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test HassClimateGetTemperature intent when entities aren't exposed.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to same area + living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity( + climate_2.entity_id, area_id=living_room_area.id + ) + + # Should fail with empty name + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + # Should fail with empty area + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + # Expose second, hide first + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True) + + # Second climate entity is exposed + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the area should work + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": living_room_area.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the name of the exposed entity should work + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": climate_2.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the name of the *unexposed* entity should fail + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": climate_1.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Expose first, hide second + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) + + # Second climate entity is exposed + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + + # Wrong area name + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": bedroom_area.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA + + # Neither are exposed + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Should fail with area + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": living_room_area.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Should fail with both names + for name in (climate_1.name, climate_2.name): + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT From 271d225e5124745e6aaf94355d20ee34507ab1bc Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 28 Feb 2025 17:05:36 +0100 Subject: [PATCH 2885/2987] Update frontend to 20250228.0 (#139531) --- 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 5399b22f075..d8eb53467f0 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250227.0"] + "requirements": ["home-assistant-frontend==20250228.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b8e0b417353..54401a12592 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.24.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250227.0 +home-assistant-frontend==20250228.0 home-assistant-intents==2025.2.26 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c8e7bbc806a..69024d3dfbf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1152,7 +1152,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250227.0 +home-assistant-frontend==20250228.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8e7c4a34f4..9b1edabb9b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250227.0 +home-assistant-frontend==20250228.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 From 2e077cbf12586aef2e75433bb75793e44b82a07a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexey=20ALERT=20Rubash=D1=91ff?= Date: Fri, 28 Feb 2025 19:32:07 +0200 Subject: [PATCH 2886/2987] Bump pyoverkiz to 1.16.1 (#139532) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index c25accd87f3..14f69291be4 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.16.0"], + "requirements": ["pyoverkiz==1.16.1"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 69024d3dfbf..dbaa1bd3b1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2199,7 +2199,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.0 +pyoverkiz==1.16.1 # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9b1edabb9b6..693e9002389 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1795,7 +1795,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.0 +pyoverkiz==1.16.1 # homeassistant.components.onewire pyownet==0.10.0.post1 From e9bb4625d8d0fde99d91e9a8bbb7edc6cd6f5383 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 28 Feb 2025 10:33:58 -0700 Subject: [PATCH 2887/2987] Set device class for wind direction weatherflow entities (#139397) * Set wind_direction device class in weatherflow * Remove measurement state_class from wind direction entities --- homeassistant/components/weatherflow/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index 683413236c1..8eee472fe5c 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -267,16 +267,16 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( WeatherFlowSensorEntityDescription( key="wind_direction", translation_key="wind_direction", + device_class=SensorDeviceClass.WIND_DIRECTION, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION], raw_data_conv_fn=lambda raw_data: raw_data.magnitude, ), WeatherFlowSensorEntityDescription( key="wind_direction_average", translation_key="wind_direction_average", + device_class=SensorDeviceClass.WIND_DIRECTION, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, raw_data_conv_fn=lambda raw_data: raw_data.magnitude, ), ) From 49c27ae7bc72ce14069eba1ce0e83f5b07669a7f Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 28 Feb 2025 12:02:30 -0600 Subject: [PATCH 2888/2987] Check area temperature sensors in get temperature intent (#139221) * Check area temperature sensors in get temperature intent * Fix candidate check * Add new code back in * Remove cruft from climate --- homeassistant/components/intent/__init__.py | 73 ++++++++- homeassistant/helpers/intent.py | 22 ++- tests/components/intent/test_temperature.py | 173 ++++++++++++++++++-- 3 files changed, 247 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 2f9587e2173..922fa376903 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -2,13 +2,14 @@ from __future__ import annotations +from collections.abc import Collection import logging from typing import Any, Protocol from aiohttp import web import voluptuous as vol -from homeassistant.components import http +from homeassistant.components import http, sensor from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.cover import ( ATTR_POSITION, @@ -40,7 +41,12 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, State -from homeassistant.helpers import config_validation as cv, integration_platform, intent +from homeassistant.helpers import ( + area_registry as ar, + config_validation as cv, + integration_platform, + intent, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -454,6 +460,9 @@ class GetTemperatureIntent(intent.IntentHandler): slot_schema = { vol.Optional("area"): intent.non_empty_string, vol.Optional("name"): intent.non_empty_string, + vol.Optional("floor"): intent.non_empty_string, + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, } platforms = {CLIMATE_DOMAIN} @@ -470,13 +479,71 @@ class GetTemperatureIntent(intent.IntentHandler): if "area" in slots: area = slots["area"]["value"] + floor_name: str | None = None + if "floor" in slots: + floor_name = slots["floor"]["value"] + + match_preferences = intent.MatchTargetsPreferences( + area_id=slots.get("preferred_area_id", {}).get("value"), + floor_id=slots.get("preferred_floor_id", {}).get("value"), + ) + + if (not name) and (area or match_preferences.area_id): + # Look for temperature sensors assigned to an area + area_registry = ar.async_get(hass) + area_temperature_ids: dict[str, str] = {} + + # Keep candidates that are registered as area temperature sensors + def area_candidate_filter( + candidate: intent.MatchTargetsCandidate, + possible_area_ids: Collection[str], + ) -> bool: + for area_id in possible_area_ids: + temperature_id = area_temperature_ids.get(area_id) + if (temperature_id is None) and ( + area_entry := area_registry.async_get_area(area_id) + ): + temperature_id = area_entry.temperature_entity_id or "" + area_temperature_ids[area_id] = temperature_id + + if candidate.state.entity_id == temperature_id: + return True + + return False + + match_constraints = intent.MatchTargetsConstraints( + area_name=area, + floor_name=floor_name, + domains=[sensor.DOMAIN], + device_classes=[sensor.SensorDeviceClass.TEMPERATURE], + assistant=intent_obj.assistant, + single_target=True, + ) + match_result = intent.async_match_targets( + hass, + match_constraints, + match_preferences, + area_candidate_filter=area_candidate_filter, + ) + if match_result.is_match: + # Found temperature sensor + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.QUERY_ANSWER + response.async_set_states(matched_states=match_result.states) + return response + + # Look for climate devices match_constraints = intent.MatchTargetsConstraints( name=name, area_name=area, + floor_name=floor_name, domains=[CLIMATE_DOMAIN], assistant=intent_obj.assistant, + single_target=True, + ) + match_result = intent.async_match_targets( + hass, match_constraints, match_preferences ) - match_result = intent.async_match_targets(hass, match_constraints) if not match_result.is_match: raise intent.MatchFailedError( result=match_result, constraints=match_constraints diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index cecb84d0373..0bb96615d3f 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -507,12 +507,22 @@ def _add_areas( candidate.area = areas.async_get_area(candidate.device.area_id) +def _default_area_candidate_filter( + candidate: MatchTargetsCandidate, possible_area_ids: Collection[str] +) -> bool: + """Keep candidates in the possible areas.""" + return (candidate.area is not None) and (candidate.area.id in possible_area_ids) + + @callback def async_match_targets( # noqa: C901 hass: HomeAssistant, constraints: MatchTargetsConstraints, preferences: MatchTargetsPreferences | None = None, states: list[State] | None = None, + area_candidate_filter: Callable[ + [MatchTargetsCandidate, Collection[str]], bool + ] = _default_area_candidate_filter, ) -> MatchTargetsResult: """Match entities based on constraints in order to handle an intent.""" preferences = preferences or MatchTargetsPreferences() @@ -623,9 +633,7 @@ def async_match_targets( # noqa: C901 } candidates = [ - c - for c in candidates - if (c.area is not None) and (c.area.id in possible_area_ids) + c for c in candidates if area_candidate_filter(c, possible_area_ids) ] if not candidates: return MatchTargetsResult( @@ -649,9 +657,7 @@ def async_match_targets( # noqa: C901 # May be constrained by floors above possible_area_ids.intersection_update(matching_area_ids) candidates = [ - c - for c in candidates - if (c.area is not None) and (c.area.id in possible_area_ids) + c for c in candidates if area_candidate_filter(c, possible_area_ids) ] if not candidates: return MatchTargetsResult( @@ -701,7 +707,7 @@ def async_match_targets( # noqa: C901 group_candidates = [ c for c in group_candidates - if (c.area is not None) and (c.area.id == preferences.area_id) + if area_candidate_filter(c, {preferences.area_id}) ] if len(group_candidates) < 2: # Disambiguated by area @@ -747,7 +753,7 @@ def async_match_targets( # noqa: C901 if preferences.area_id: # Filter by area filtered_candidates = [ - c for c in candidates if c.area and (c.area.id == preferences.area_id) + c for c in candidates if area_candidate_filter(c, {preferences.area_id}) ] if (len(filtered_candidates) > 1) and preferences.floor_id: diff --git a/tests/components/intent/test_temperature.py b/tests/components/intent/test_temperature.py index 0279fa44b28..622e55fe24a 100644 --- a/tests/components/intent/test_temperature.py +++ b/tests/components/intent/test_temperature.py @@ -14,10 +14,16 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import Platform, UnitOfTemperature +from homeassistant.const import ATTR_DEVICE_CLASS, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers import area_registry as ar, entity_registry as er, intent +from homeassistant.helpers import ( + area_registry as ar, + entity_registry as er, + floor_registry as fr, + intent, +) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component @@ -131,6 +137,7 @@ async def test_get_temperature( hass: HomeAssistant, area_registry: ar.AreaRegistry, entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, ) -> None: """Test HassClimateGetTemperature intent.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -157,29 +164,133 @@ async def test_get_temperature( # Add climate entities to different areas: # climate_1 => living room # climate_2 => bedroom - # nothing in office + # nothing in bathroom + # nothing in office yet + # nothing in attic yet living_room_area = area_registry.async_create(name="Living Room") bedroom_area = area_registry.async_create(name="Bedroom") office_area = area_registry.async_create(name="Office") + attic_area = area_registry.async_create(name="Attic") + bathroom_area = area_registry.async_create(name="Bathroom") entity_registry.async_update_entity( climate_1.entity_id, area_id=living_room_area.id ) entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) - # First climate entity will be selected (no area) + # Put areas on different floors: + # first floor => living room and office + # 2nd floor => bedroom + # 3rd floor => attic + floor_registry = fr.async_get(hass) + first_floor = floor_registry.async_create("First floor") + living_room_area = area_registry.async_update( + living_room_area.id, floor_id=first_floor.floor_id + ) + office_area = area_registry.async_update( + office_area.id, floor_id=first_floor.floor_id + ) + + second_floor = floor_registry.async_create("Second floor") + bedroom_area = area_registry.async_update( + bedroom_area.id, floor_id=second_floor.floor_id + ) + bathroom_area = area_registry.async_update( + bathroom_area.id, floor_id=second_floor.floor_id + ) + + third_floor = floor_registry.async_create("Third floor") + attic_area = area_registry.async_update( + attic_area.id, floor_id=third_floor.floor_id + ) + + # Add temperature sensors to each area that should *not* be selected + for area in (living_room_area, office_area, bedroom_area, attic_area): + wrong_temperature_entry = entity_registry.async_get_or_create( + "sensor", "test", f"wrong_temperature_{area.id}" + ) + hass.states.async_set( + wrong_temperature_entry.entity_id, + "10.0", + { + ATTR_TEMPERATURE: "Temperature", + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + entity_registry.async_update_entity( + wrong_temperature_entry.entity_id, area_id=area.id + ) + + # Create temperature sensor and assign them to the office/attic + office_temperature_id = "sensor.office_temperature" + attic_temperature_id = "sensor.attic_temperature" + hass.states.async_set( + office_temperature_id, + "15.5", + { + ATTR_TEMPERATURE: "Temperature", + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + office_area = area_registry.async_update( + office_area.id, temperature_entity_id=office_temperature_id + ) + + hass.states.async_set( + attic_temperature_id, + "18.1", + { + ATTR_TEMPERATURE: "Temperature", + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + attic_area = area_registry.async_update( + attic_area.id, temperature_entity_id=attic_temperature_id + ) + + # Multiple climate entities match (error) + with pytest.raises(intent.MatchFailedError) as error: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.MatchFailedError) + assert ( + error.value.result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS + ) + + # Select by area (office_temperature) response = await intent.async_handle( hass, "test", intent.INTENT_GET_TEMPERATURE, - {}, + {"area": {"value": office_area.name}}, assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert response.matched_states - assert response.matched_states[0].entity_id == climate_1.entity_id + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == office_temperature_id state = response.matched_states[0] - assert state.attributes["current_temperature"] == 10.0 + assert state.state == "15.5" + + # Select by preferred area (attic_temperature) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"preferred_area_id": {"value": attic_area.id}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == attic_temperature_id + state = response.matched_states[0] + assert state.state == "18.1" # Select by area (climate_2) response = await intent.async_handle( @@ -215,7 +326,7 @@ async def test_get_temperature( hass, "test", intent.INTENT_GET_TEMPERATURE, - {"area": {"value": office_area.name}}, + {"area": {"value": bathroom_area.name}}, assistant=conversation.DOMAIN, ) @@ -224,7 +335,7 @@ async def test_get_temperature( assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA constraints = error.value.constraints assert constraints.name is None - assert constraints.area_name == office_area.name + assert constraints.area_name == bathroom_area.name assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) assert constraints.device_classes is None @@ -262,6 +373,48 @@ async def test_get_temperature( assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) assert constraints.device_classes is None + # Select by floor (climate_1) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"floor": {"value": first_floor.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 10.0 + + # Select by preferred area (climate_2) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"preferred_area_id": {"value": bedroom_area.id}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + + # Select by preferred floor (climate_1) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"preferred_floor_id": {"value": first_floor.floor_id}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 10.0 + async def test_get_temperature_no_entities( hass: HomeAssistant, From 70bb56e0fc07822b5f48ee80f5fa5b7f8cec56b8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 28 Feb 2025 18:36:12 +0000 Subject: [PATCH 2889/2987] Text-to-Speech refactor (#139482) * Refactor TTS * More cleanup * Cleanup * Consolidate more * Inline another function * Inline another function * Improve cleanup --- homeassistant/components/tts/__init__.py | 586 ++++++++++-------- homeassistant/components/tts/media_source.py | 13 +- tests/components/elevenlabs/test_tts.py | 2 +- tests/components/google_translate/test_tts.py | 2 +- tests/components/marytts/test_tts.py | 2 +- tests/components/microsoft/test_tts.py | 2 +- tests/components/tts/test_init.py | 2 +- tests/components/tts/test_media_source.py | 6 +- tests/components/voicerss/test_tts.py | 6 +- tests/components/yandextts/test_tts.py | 4 +- 10 files changed, 357 insertions(+), 268 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 6c7e521f3ef..199d644738b 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping +from dataclasses import dataclass from datetime import datetime from functools import partial import hashlib @@ -16,6 +17,7 @@ import re import secrets import subprocess import tempfile +from time import monotonic from typing import Any, Final, TypedDict, final from aiohttp import web @@ -37,11 +39,20 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, + EVENT_HOMEASSISTANT_STOP, PLATFORM_FORMAT, STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + HassJob, + HassJobType, + HomeAssistant, + ServiceCall, + callback, +) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent @@ -129,9 +140,10 @@ SCHEMA_SERVICE_CLEAR_CACHE = vol.Schema({}) class TTSCache(TypedDict): """Cached TTS file.""" - filename: str + extension: str voice: bytes pending: asyncio.Task | None + last_used: float @callback @@ -192,9 +204,11 @@ async def async_get_media_source_audio( media_source_id: str, ) -> tuple[str, bytes]: """Get TTS audio as extension, data.""" - return await hass.data[DATA_TTS_MANAGER].async_get_tts_audio( - **media_source_id_to_kwargs(media_source_id), + manager = hass.data[DATA_TTS_MANAGER] + cache_key = manager.async_cache_message_in_memory( + **media_source_id_to_kwargs(media_source_id) ) + return await manager.async_get_tts_audio(cache_key) @callback @@ -306,11 +320,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Legacy config options conf = config[DOMAIN][0] if config.get(DOMAIN) else {} - use_cache: bool = conf.get(CONF_CACHE, DEFAULT_CACHE) + use_file_cache: bool = conf.get(CONF_CACHE, DEFAULT_CACHE) cache_dir: str = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR) - time_memory: int = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) + memory_cache_maxage: int = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) - tts = SpeechManager(hass, use_cache, cache_dir, time_memory) + tts = SpeechManager(hass, use_file_cache, cache_dir, memory_cache_maxage) try: await tts.async_init_cache() @@ -383,6 +397,40 @@ CACHED_PROPERTIES_WITH_ATTR_ = { } +@dataclass +class ResultStream: + """Class that will stream the result when available.""" + + # Streaming/conversion properties + url: str + extension: str + content_type: str + + # TTS properties + engine: str + use_file_cache: bool + language: str + options: dict + + _manager: SpeechManager + + @cached_property + def _result_cache_key(self) -> asyncio.Future[str]: + """Get the future that returns the cache key.""" + return asyncio.Future() + + @callback + def async_set_message_cache_key(self, cache_key: str) -> None: + """Set cache key for message to be streamed.""" + self._result_cache_key.set_result(cache_key) + + async def async_get_result(self) -> bytes: + """Get the stream of this result.""" + cache_key = await self._result_cache_key + _extension, data = await self._manager.async_get_tts_audio(cache_key) + return data + + class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Represent a single TTS engine.""" @@ -521,29 +569,82 @@ def _hash_options(options: dict) -> str: return opts_hash.hexdigest() +class MemcacheCleanup: + """Helper to clean up the stale sessions.""" + + unsub: CALLBACK_TYPE | None = None + + def __init__( + self, hass: HomeAssistant, maxage: float, memcache: dict[str, TTSCache] + ) -> None: + """Initialize the cleanup.""" + self.hass = hass + self.maxage = maxage + self.memcache = memcache + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._on_hass_stop) + self.cleanup_job = HassJob( + self._cleanup, "chat_session_cleanup", job_type=HassJobType.Callback + ) + + @callback + def schedule(self) -> None: + """Schedule the cleanup.""" + if self.unsub: + return + self.unsub = async_call_later( + self.hass, + self.maxage + 1, + self.cleanup_job, + ) + + @callback + def _on_hass_stop(self, event: Event) -> None: + """Cancel the cleanup on shutdown.""" + if self.unsub: + self.unsub() + self.unsub = None + + @callback + def _cleanup(self, _now: datetime) -> None: + """Clean up and schedule follow-up if necessary.""" + self.unsub = None + memcache = self.memcache + maxage = self.maxage + now = monotonic() + + for cache_key, info in list(memcache.items()): + if info["last_used"] + maxage < now: + _LOGGER.debug("Cleaning up %s", cache_key) + del memcache[cache_key] + + # Still items left, check again in timeout time. + if memcache: + self.schedule() + + class SpeechManager: """Representation of a speech store.""" def __init__( self, hass: HomeAssistant, - use_cache: bool, + use_file_cache: bool, cache_dir: str, - time_memory: int, + memory_cache_maxage: int, ) -> None: """Initialize a speech store.""" self.hass = hass self.providers: dict[str, Provider] = {} - self.use_cache = use_cache + self.use_file_cache = use_file_cache self.cache_dir = cache_dir - self.time_memory = time_memory + self.memory_cache_maxage = memory_cache_maxage self.file_cache: dict[str, str] = {} self.mem_cache: dict[str, TTSCache] = {} - - # filename <-> token - self.filename_to_token: dict[str, str] = {} - self.token_to_filename: dict[str, str] = {} + self.token_to_stream: dict[str, ResultStream] = {} + self.memcache_cleanup = MemcacheCleanup( + hass, memory_cache_maxage, self.mem_cache + ) def _init_cache(self) -> dict[str, str]: """Init cache folder and fetch files.""" @@ -563,18 +664,21 @@ class SpeechManager: async def async_clear_cache(self) -> None: """Read file cache and delete files.""" - self.mem_cache = {} + self.mem_cache.clear() - def remove_files() -> None: + def remove_files(files: list[str]) -> None: """Remove files from filesystem.""" - for filename in self.file_cache.values(): + for filename in files: try: os.remove(os.path.join(self.cache_dir, filename)) except OSError as err: _LOGGER.warning("Can't remove cache file '%s': %s", filename, err) - await self.hass.async_add_executor_job(remove_files) - self.file_cache = {} + task = self.hass.async_add_executor_job( + remove_files, list(self.file_cache.values()) + ) + self.file_cache.clear() + await task @callback def async_register_legacy_engine( @@ -629,107 +733,153 @@ class SpeechManager: return language, merged_options - async def async_get_url_path( + @callback + def async_create_result_stream( self, engine: str, - message: str, - cache: bool | None = None, + message: str | None = None, + use_file_cache: bool | None = None, language: str | None = None, options: dict | None = None, - ) -> str: - """Get URL for play message. - - This method is a coroutine. - """ + ) -> ResultStream: + """Create a streaming URL where the rendered TTS can be retrieved.""" if (engine_instance := get_engine_instance(self.hass, engine)) is None: raise HomeAssistantError(f"Provider {engine} not found") language, options = self.process_options(engine_instance, language, options) - cache_key = self._generate_cache_key(message, language, options, engine) - use_cache = cache if cache is not None else self.use_cache + if use_file_cache is None: + use_file_cache = self.use_file_cache - # Is speech already in memory - if cache_key in self.mem_cache: - filename = self.mem_cache[cache_key]["filename"] - # Is file store in file cache - elif use_cache and cache_key in self.file_cache: - filename = self.file_cache[cache_key] - self.hass.async_create_task(self._async_file_to_mem(cache_key)) - # Load speech from engine into memory - else: - filename = await self._async_get_tts_audio( - engine_instance, cache_key, message, use_cache, language, options - ) + extension = options.get(ATTR_PREFERRED_FORMAT, _DEFAULT_FORMAT) + token = f"{secrets.token_urlsafe(16)}.{extension}" + content, _ = mimetypes.guess_type(token) + result_stream = ResultStream( + url=f"/api/tts_proxy/{token}", + extension=extension, + content_type=content or "audio/mpeg", + use_file_cache=use_file_cache, + engine=engine, + language=language, + options=options, + _manager=self, + ) + self.token_to_stream[token] = result_stream - # Use a randomly generated token instead of exposing the filename - token = self.filename_to_token.get(filename) - if not token: - # Keep extension (.mp3, etc.) - token = secrets.token_urlsafe(16) + os.path.splitext(filename)[1] + if message is None: + return result_stream - # Map token <-> filename - self.filename_to_token[filename] = token - self.token_to_filename[token] = filename + cache_key = self._async_ensure_cached_in_memory( + engine=engine, + engine_instance=engine_instance, + message=message, + use_file_cache=use_file_cache, + language=language, + options=options, + ) + result_stream.async_set_message_cache_key(cache_key) - return f"/api/tts_proxy/{token}" - - async def async_get_tts_audio( - self, - engine: str, - message: str, - cache: bool | None = None, - language: str | None = None, - options: dict | None = None, - ) -> tuple[str, bytes]: - """Fetch TTS audio.""" - if (engine_instance := get_engine_instance(self.hass, engine)) is None: - raise HomeAssistantError(f"Provider {engine} not found") - - language, options = self.process_options(engine_instance, language, options) - cache_key = self._generate_cache_key(message, language, options, engine) - use_cache = cache if cache is not None else self.use_cache - - # If we have the file, load it into memory if necessary - if cache_key not in self.mem_cache: - if use_cache and cache_key in self.file_cache: - await self._async_file_to_mem(cache_key) - else: - await self._async_get_tts_audio( - engine_instance, cache_key, message, use_cache, language, options - ) - - extension = os.path.splitext(self.mem_cache[cache_key]["filename"])[1][1:] - cached = self.mem_cache[cache_key] - if pending := cached.get("pending"): - await pending - cached = self.mem_cache[cache_key] - return extension, cached["voice"] + return result_stream @callback - def _generate_cache_key( + def async_cache_message_in_memory( self, - message: str, - language: str, - options: dict | None, engine: str, + message: str, + use_file_cache: bool | None = None, + language: str | None = None, + options: dict | None = None, ) -> str: - """Generate a cache key for a message.""" + """Make sure a message is cached in memory and returns cache key.""" + if (engine_instance := get_engine_instance(self.hass, engine)) is None: + raise HomeAssistantError(f"Provider {engine} not found") + + language, options = self.process_options(engine_instance, language, options) + if use_file_cache is None: + use_file_cache = self.use_file_cache + + return self._async_ensure_cached_in_memory( + engine=engine, + engine_instance=engine_instance, + message=message, + use_file_cache=use_file_cache, + language=language, + options=options, + ) + + @callback + def _async_ensure_cached_in_memory( + self, + engine: str, + engine_instance: TextToSpeechEntity | Provider, + message: str, + use_file_cache: bool, + language: str, + options: dict, + ) -> str: + """Ensure a message is cached. + + Requires options, language to be processed. + """ options_key = _hash_options(options) if options else "-" msg_hash = hashlib.sha1(bytes(message, "utf-8")).hexdigest() - return KEY_PATTERN.format( + cache_key = KEY_PATTERN.format( msg_hash, language.replace("_", "-"), options_key, engine ).lower() - async def _async_get_tts_audio( + # Is speech already in memory + if cache_key in self.mem_cache: + return cache_key + + if use_file_cache and cache_key in self.file_cache: + coro = self._async_load_file_to_mem(cache_key) + else: + coro = self._async_generate_tts_audio( + engine_instance, cache_key, message, use_file_cache, language, options + ) + + task = self.hass.async_create_task(coro, eager_start=False) + + def handle_error(future: asyncio.Future) -> None: + """Handle error.""" + if not (err := future.exception()): + return + # Truncate message so we don't flood the logs. Cutting off at 32 chars + # but since we add 3 dots to truncated message, we cut off at 35. + trunc_msg = message if len(message) < 35 else f"{message[0:32]}…" + _LOGGER.error("Error generating audio for %s: %s", trunc_msg, err) + self.mem_cache.pop(cache_key, None) + + task.add_done_callback(handle_error) + + self.mem_cache[cache_key] = { + "extension": "", + "voice": b"", + "pending": task, + "last_used": monotonic(), + } + return cache_key + + async def async_get_tts_audio(self, cache_key: str) -> tuple[str, bytes]: + """Fetch TTS audio.""" + cached = self.mem_cache.get(cache_key) + if cached is None: + raise HomeAssistantError("Audio not cached") + if pending := cached.get("pending"): + await pending + cached = self.mem_cache[cache_key] + cached["last_used"] = monotonic() + return cached["extension"], cached["voice"] + + async def _async_generate_tts_audio( self, engine_instance: TextToSpeechEntity | Provider, cache_key: str, message: str, - cache: bool, + cache_to_disk: bool, language: str, options: dict[str, Any], - ) -> str: - """Receive TTS, store for view in cache and return filename. + ) -> None: + """Start loading of the TTS audio. This method is a coroutine. """ @@ -773,96 +923,66 @@ class SpeechManager: if sample_bytes is not None: sample_bytes = int(sample_bytes) - async def get_tts_data() -> str: - """Handle data available.""" - if engine_instance.name is None or engine_instance.name is UNDEFINED: - raise HomeAssistantError("TTS engine name is not set.") + if engine_instance.name is None or engine_instance.name is UNDEFINED: + raise HomeAssistantError("TTS engine name is not set.") - if isinstance(engine_instance, Provider): - extension, data = await engine_instance.async_get_tts_audio( - message, language, options - ) - else: - extension, data = await engine_instance.internal_async_get_tts_audio( - message, language, options - ) - - if data is None or extension is None: - raise HomeAssistantError( - f"No TTS from {engine_instance.name} for '{message}'" - ) - - # Only convert if we have a preferred format different than the - # expected format from the TTS system, or if a specific sample - # rate/format/channel count is requested. - needs_conversion = ( - (final_extension != extension) - or (sample_rate is not None) - or (sample_channels is not None) - or (sample_bytes is not None) + if isinstance(engine_instance, Provider): + extension, data = await engine_instance.async_get_tts_audio( + message, language, options + ) + else: + extension, data = await engine_instance.internal_async_get_tts_audio( + message, language, options ) - if needs_conversion: - data = await async_convert_audio( - self.hass, - extension, - data, - to_extension=final_extension, - to_sample_rate=sample_rate, - to_sample_channels=sample_channels, - to_sample_bytes=sample_bytes, - ) + if data is None or extension is None: + raise HomeAssistantError( + f"No TTS from {engine_instance.name} for '{message}'" + ) - # Create file infos - filename = f"{cache_key}.{final_extension}".lower() + # Only convert if we have a preferred format different than the + # expected format from the TTS system, or if a specific sample + # rate/format/channel count is requested. + needs_conversion = ( + (final_extension != extension) + or (sample_rate is not None) + or (sample_channels is not None) + or (sample_bytes is not None) + ) - # Validate filename - if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match( - filename - ): - raise HomeAssistantError( - f"TTS filename '{filename}' from {engine_instance.name} is invalid!" - ) - - # Save to memory - if final_extension == "mp3": - data = self.write_tags( - filename, data, engine_instance.name, message, language, options - ) - - self._async_store_to_memcache(cache_key, filename, data) - - if cache: - self.hass.async_create_task( - self._async_save_tts_audio(cache_key, filename, data) - ) - - return filename - - audio_task = self.hass.async_create_task(get_tts_data(), eager_start=False) - - def handle_error(_future: asyncio.Future) -> None: - """Handle error.""" - if audio_task.exception(): - self.mem_cache.pop(cache_key, None) - - audio_task.add_done_callback(handle_error) + if needs_conversion: + data = await async_convert_audio( + self.hass, + extension, + data, + to_extension=final_extension, + to_sample_rate=sample_rate, + to_sample_channels=sample_channels, + to_sample_bytes=sample_bytes, + ) + # Create file infos filename = f"{cache_key}.{final_extension}".lower() - self.mem_cache[cache_key] = { - "filename": filename, - "voice": b"", - "pending": audio_task, - } - return filename - async def _async_save_tts_audio( - self, cache_key: str, filename: str, data: bytes - ) -> None: - """Store voice data to file and file_cache. + # Validate filename + if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match( + filename + ): + raise HomeAssistantError( + f"TTS filename '{filename}' from {engine_instance.name} is invalid!" + ) + + # Save to memory + if final_extension == "mp3": + data = self.write_tags( + filename, data, engine_instance.name, message, language, options + ) + + self._async_store_to_memcache(cache_key, final_extension, data) + + if not cache_to_disk: + return - This method is a coroutine. - """ voice_file = os.path.join(self.cache_dir, filename) def save_speech() -> None: @@ -870,13 +990,19 @@ class SpeechManager: with open(voice_file, "wb") as speech: speech.write(data) - try: - await self.hass.async_add_executor_job(save_speech) - self.file_cache[cache_key] = filename - except OSError as err: - _LOGGER.error("Can't write %s: %s", filename, err) + # Don't await, we're going to do this in the background + task = self.hass.async_add_executor_job(save_speech) - async def _async_file_to_mem(self, cache_key: str) -> None: + def write_done(future: asyncio.Future) -> None: + """Write is done task.""" + if err := future.exception(): + _LOGGER.error("Can't write %s: %s", filename, err) + else: + self.file_cache[cache_key] = filename + + task.add_done_callback(write_done) + + async def _async_load_file_to_mem(self, cache_key: str) -> None: """Load voice from file cache into memory. This method is a coroutine. @@ -897,64 +1023,22 @@ class SpeechManager: del self.file_cache[cache_key] raise HomeAssistantError(f"Can't read {voice_file}") from err - self._async_store_to_memcache(cache_key, filename, data) + extension = os.path.splitext(filename)[1][1:] + + self._async_store_to_memcache(cache_key, extension, data) @callback def _async_store_to_memcache( - self, cache_key: str, filename: str, data: bytes + self, cache_key: str, extension: str, data: bytes ) -> None: """Store data to memcache and set timer to remove it.""" self.mem_cache[cache_key] = { - "filename": filename, + "extension": extension, "voice": data, "pending": None, + "last_used": monotonic(), } - - @callback - def async_remove_from_mem(_: datetime) -> None: - """Cleanup memcache.""" - self.mem_cache.pop(cache_key, None) - - async_call_later( - self.hass, - self.time_memory, - HassJob( - async_remove_from_mem, - name="tts remove_from_mem", - cancel_on_shutdown=True, - ), - ) - - async def async_read_tts(self, token: str) -> tuple[str | None, bytes]: - """Read a voice file and return binary. - - This method is a coroutine. - """ - filename = self.token_to_filename.get(token) - if not filename: - raise HomeAssistantError(f"{token} was not recognized!") - - if not (record := _RE_VOICE_FILE.match(filename.lower())) and not ( - record := _RE_LEGACY_VOICE_FILE.match(filename.lower()) - ): - raise HomeAssistantError("Wrong tts file format!") - - cache_key = KEY_PATTERN.format( - record.group(1), record.group(2), record.group(3), record.group(4) - ) - - if cache_key not in self.mem_cache: - if cache_key not in self.file_cache: - raise HomeAssistantError(f"{cache_key} not in cache!") - await self._async_file_to_mem(cache_key) - - cached = self.mem_cache[cache_key] - if pending := cached.get("pending"): - await pending - cached = self.mem_cache[cache_key] - - content, _ = mimetypes.guess_type(filename) - return content, cached["voice"] + self.memcache_cleanup.schedule() @staticmethod def write_tags( @@ -1042,9 +1126,9 @@ class TextToSpeechUrlView(HomeAssistantView): url = "/api/tts_get_url" name = "api:tts:geturl" - def __init__(self, tts: SpeechManager) -> None: + def __init__(self, manager: SpeechManager) -> None: """Initialize a tts view.""" - self.tts = tts + self.manager = manager async def post(self, request: web.Request) -> web.Response: """Generate speech and provide url.""" @@ -1061,45 +1145,53 @@ class TextToSpeechUrlView(HomeAssistantView): engine = data.get("engine_id") or data[ATTR_PLATFORM] message = data[ATTR_MESSAGE] - cache = data.get(ATTR_CACHE) + use_file_cache = data.get(ATTR_CACHE) language = data.get(ATTR_LANGUAGE) options = data.get(ATTR_OPTIONS) try: - path = await self.tts.async_get_url_path( - engine, message, cache=cache, language=language, options=options + stream = self.manager.async_create_result_stream( + engine, + message, + use_file_cache=use_file_cache, + language=language, + options=options, ) except HomeAssistantError as err: _LOGGER.error("Error on init tts: %s", err) return self.json({"error": err}, HTTPStatus.BAD_REQUEST) - base = get_url(self.tts.hass) - url = base + path + base = get_url(self.manager.hass) + url = base + stream.url - return self.json({"url": url, "path": path}) + return self.json({"url": url, "path": stream.url}) class TextToSpeechView(HomeAssistantView): """TTS view to serve a speech audio.""" requires_auth = False - url = "/api/tts_proxy/{filename}" + url = "/api/tts_proxy/{token}" name = "api:tts_speech" - def __init__(self, tts: SpeechManager) -> None: + def __init__(self, manager: SpeechManager) -> None: """Initialize a tts view.""" - self.tts = tts + self.manager = manager - async def get(self, request: web.Request, filename: str) -> web.Response: + async def get(self, request: web.Request, token: str) -> web.Response: """Start a get request.""" - try: - # filename is actually token, but we keep its name for compatibility - content, data = await self.tts.async_read_tts(filename) - except HomeAssistantError as err: - _LOGGER.error("Error on load tts: %s", err) + stream = self.manager.token_to_stream.get(token) + + if stream is None: return web.Response(status=HTTPStatus.NOT_FOUND) - return web.Response(body=data, content_type=content) + try: + data = await stream.async_get_result() + except HomeAssistantError as err: + _LOGGER.error("Error on get tts: %s", err) + return web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR) + + return web.Response(body=data, content_type=stream.content_type) @websocket_api.websocket_command( diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index 4f1fa59f001..aa2cd6e7555 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import mimetypes from typing import TypedDict from yarl import URL @@ -73,7 +72,7 @@ class MediaSourceOptions(TypedDict): message: str language: str | None options: dict | None - cache: bool | None + use_file_cache: bool | None @callback @@ -98,10 +97,10 @@ def media_source_id_to_kwargs(media_source_id: str) -> MediaSourceOptions: "message": parsed.query["message"], "language": parsed.query.get("language"), "options": options, - "cache": None, + "use_file_cache": None, } if "cache" in parsed.query: - kwargs["cache"] = parsed.query["cache"] == "true" + kwargs["use_file_cache"] = parsed.query["cache"] == "true" return kwargs @@ -119,7 +118,7 @@ class TTSMediaSource(MediaSource): async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" try: - url = await self.hass.data[DATA_TTS_MANAGER].async_get_url_path( + stream = self.hass.data[DATA_TTS_MANAGER].async_create_result_stream( **media_source_id_to_kwargs(item.identifier) ) except Unresolvable: @@ -127,9 +126,7 @@ class TTSMediaSource(MediaSource): except HomeAssistantError as err: raise Unresolvable(str(err)) from err - mime_type = mimetypes.guess_type(url)[0] or "audio/mpeg" - - return PlayMedia(url, mime_type) + return PlayMedia(stream.url, stream.content_type) async def async_browse_media( self, diff --git a/tests/components/elevenlabs/test_tts.py b/tests/components/elevenlabs/test_tts.py index c4234cb38ae..a63672cc85d 100644 --- a/tests/components/elevenlabs/test_tts.py +++ b/tests/components/elevenlabs/test_tts.py @@ -350,7 +350,7 @@ async def test_tts_service_speak_error( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) tts_entity._client.generate.assert_called_once_with( diff --git a/tests/components/google_translate/test_tts.py b/tests/components/google_translate/test_tts.py index 5b691da4bdc..54ad47405a1 100644 --- a/tests/components/google_translate/test_tts.py +++ b/tests/components/google_translate/test_tts.py @@ -475,6 +475,6 @@ async def test_service_say_error( await retrieve_media( hass, hass_client, service_calls[1].data[ATTR_MEDIA_CONTENT_ID] ) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(mock_gtts.mock_calls) == 2 diff --git a/tests/components/marytts/test_tts.py b/tests/components/marytts/test_tts.py index 0ad27cde29b..25231c15a32 100644 --- a/tests/components/marytts/test_tts.py +++ b/tests/components/marytts/test_tts.py @@ -155,7 +155,7 @@ async def test_service_say_http_error( await retrieve_media( hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) mock_speak.assert_called_once() diff --git a/tests/components/microsoft/test_tts.py b/tests/components/microsoft/test_tts.py index e10ec589113..38f1318a683 100644 --- a/tests/components/microsoft/test_tts.py +++ b/tests/components/microsoft/test_tts.py @@ -366,7 +366,7 @@ async def test_service_say_error( await retrieve_media( hass, hass_client, service_calls[1].data[ATTR_MEDIA_CONTENT_ID] ) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(mock_tts.mock_calls) == 2 diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 4d0767cddf3..86ca2de5791 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1197,7 +1197,7 @@ async def test_service_get_tts_error( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) diff --git a/tests/components/tts/test_media_source.py b/tests/components/tts/test_media_source.py index d90923b02ab..9e50cc6b512 100644 --- a/tests/components/tts/test_media_source.py +++ b/tests/components/tts/test_media_source.py @@ -268,7 +268,7 @@ async def test_generate_media_source_id_and_media_source_id_to_kwargs( "message": "hello", "language": "en_US", "options": {"age": 5}, - "cache": True, + "use_file_cache": True, } kwargs = { @@ -284,7 +284,7 @@ async def test_generate_media_source_id_and_media_source_id_to_kwargs( "message": "hello", "language": "en_US", "options": {"age": [5, 6]}, - "cache": True, + "use_file_cache": True, } kwargs = { @@ -300,5 +300,5 @@ async def test_generate_media_source_id_and_media_source_id_to_kwargs( "message": "hello", "language": "en_US", "options": {"age": {"k1": [5, 6], "k2": "v2"}}, - "cache": True, + "use_file_cache": True, } diff --git a/tests/components/voicerss/test_tts.py b/tests/components/voicerss/test_tts.py index 776c0ac153a..e6a30d7fac2 100644 --- a/tests/components/voicerss/test_tts.py +++ b/tests/components/voicerss/test_tts.py @@ -200,7 +200,7 @@ async def test_service_say_error( assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA @@ -234,7 +234,7 @@ async def test_service_say_timeout( assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA @@ -273,7 +273,7 @@ async def test_service_say_error_msg( assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA diff --git a/tests/components/yandextts/test_tts.py b/tests/components/yandextts/test_tts.py index 77878c2be51..098fc025bf3 100644 --- a/tests/components/yandextts/test_tts.py +++ b/tests/components/yandextts/test_tts.py @@ -223,7 +223,7 @@ async def test_service_say_timeout( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 @@ -269,7 +269,7 @@ async def test_service_say_http_error( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) From bf27ccce17bbaf7bed0378165dec0ef89ad866c2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 28 Feb 2025 19:58:26 +0100 Subject: [PATCH 2890/2987] Clarify description of `icloud.update` action (#139535) Currently the description of the `icloud.update` action can be easily misunderstood as just updating the device list or forcing a software update on all devices. This commit changes the description to make clear that it asks for a state update of all devices. --- homeassistant/components/icloud/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/icloud/strings.json b/homeassistant/components/icloud/strings.json index adc96043d66..fc78e8c2ba6 100644 --- a/homeassistant/components/icloud/strings.json +++ b/homeassistant/components/icloud/strings.json @@ -46,7 +46,7 @@ "services": { "update": { "name": "Update", - "description": "Updates iCloud devices.", + "description": "Asks for a state update of all devices linked to an iCloud account.", "fields": { "account": { "name": "Account", From 086c91485ff527cfead19b9c0792e9d3503a22c7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 20:03:24 +0100 Subject: [PATCH 2891/2987] Set SmartThings delta energy to Total (#139474) --- .../components/smartthings/sensor.py | 2 +- .../smartthings/snapshots/test_sensor.ambr | 24 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index cd12bf46e25..0a695876da4 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -596,7 +596,7 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key="deltaEnergy_meter", translation_key="energy_difference", - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["deltaEnergy"] / 1000, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index b67d15bef55..78aa4db62f8 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -582,7 +582,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -620,7 +620,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'AC Office Granit Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -1011,7 +1011,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1049,7 +1049,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Aire Dormitorio Principal Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -1835,7 +1835,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1873,7 +1873,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Refrigerator Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -2408,7 +2408,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2446,7 +2446,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Dishwasher Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -2865,7 +2865,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2903,7 +2903,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Dryer Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -3332,7 +3332,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -3370,7 +3370,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Washer Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , From 90fc6ffdbfec3038b0d022a4692c4a0f9cc0de8c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 28 Feb 2025 19:15:31 +0000 Subject: [PATCH 2892/2987] Add support for continue conversation in Assist Pipeline (#139480) * Add support for continue conversation in Assist Pipeline * Also forward to ESPHome * Update snapshot * And mobile app --- .../components/assist_pipeline/__init__.py | 2 +- .../components/assist_pipeline/pipeline.py | 99 +++++++++-- .../assist_pipeline/websocket_api.py | 2 +- .../components/conversation/models.py | 2 + .../components/esphome/assist_satellite.py | 5 +- .../snapshots/test_conversation.ambr | 1 + tests/components/assist_pipeline/conftest.py | 15 +- .../assist_pipeline/snapshots/test_init.ambr | 21 ++- .../snapshots/test_websocket.ambr | 7 + tests/components/assist_pipeline/test_init.py | 168 ++++++++++++++++-- tests/components/conversation/__init__.py | 3 +- .../conversation/snapshots/test_chat_log.ambr | 2 + .../snapshots/test_default_agent.ambr | 19 ++ .../conversation/snapshots/test_http.ambr | 12 ++ .../conversation/snapshots/test_init.ambr | 13 ++ .../esphome/test_assist_satellite.py | 29 ++- tests/components/mobile_app/test_webhook.py | 1 + .../ollama/snapshots/test_conversation.ambr | 1 + tests/syrupy.py | 6 +- 19 files changed, 362 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 9a32821e3a0..59bd987d90e 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -117,7 +117,7 @@ async def async_pipeline_from_audio_stream( """ with chat_session.async_get_chat_session(hass, conversation_id) as session: pipeline_input = PipelineInput( - conversation_id=session.conversation_id, + session=session, device_id=device_id, stt_metadata=stt_metadata, stt_stream=stt_stream, diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 75811a0ec36..038874d1966 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -96,6 +96,9 @@ ENGINE_LANGUAGE_PAIRS = ( ) KEY_ASSIST_PIPELINE: HassKey[PipelineData] = HassKey(DOMAIN) +KEY_PIPELINE_CONVERSATION_DATA: HassKey[dict[str, PipelineConversationData]] = HassKey( + "pipeline_conversation_data" +) def validate_language(data: dict[str, Any]) -> Any: @@ -590,6 +593,12 @@ class PipelineRun: _device_id: str | None = None """Optional device id set during run start.""" + _conversation_data: PipelineConversationData | None = None + """Data tied to the conversation ID.""" + + _intent_agent_only = False + """If request should only be handled by agent, ignoring sentence triggers and local processing.""" + def __post_init__(self) -> None: """Set language for pipeline.""" self.language = self.pipeline.language or self.hass.config.language @@ -1007,19 +1016,36 @@ class PipelineRun: yield chunk.audio - async def prepare_recognize_intent(self) -> None: + async def prepare_recognize_intent(self, session: chat_session.ChatSession) -> None: """Prepare recognizing an intent.""" - agent_info = conversation.async_get_agent_info( - self.hass, - self.pipeline.conversation_engine or conversation.HOME_ASSISTANT_AGENT, + self._conversation_data = async_get_pipeline_conversation_data( + self.hass, session ) - if agent_info is None: - engine = self.pipeline.conversation_engine or "default" - raise IntentRecognitionError( - code="intent-not-supported", - message=f"Intent recognition engine {engine} is not found", + if self._conversation_data.continue_conversation_agent is not None: + agent_info = conversation.async_get_agent_info( + self.hass, self._conversation_data.continue_conversation_agent ) + self._conversation_data.continue_conversation_agent = None + if agent_info is None: + raise IntentRecognitionError( + code="intent-agent-not-found", + message=f"Intent recognition engine {self._conversation_data.continue_conversation_agent} asked for follow-up but is no longer found", + ) + self._intent_agent_only = True + + else: + agent_info = conversation.async_get_agent_info( + self.hass, + self.pipeline.conversation_engine or conversation.HOME_ASSISTANT_AGENT, + ) + + if agent_info is None: + engine = self.pipeline.conversation_engine or "default" + raise IntentRecognitionError( + code="intent-not-supported", + message=f"Intent recognition engine {engine} is not found", + ) self.intent_agent = agent_info.id @@ -1031,7 +1057,7 @@ class PipelineRun: conversation_extra_system_prompt: str | None, ) -> str: """Run intent recognition portion of pipeline. Returns text to speak.""" - if self.intent_agent is None: + if self.intent_agent is None or self._conversation_data is None: raise RuntimeError("Recognize intent was not prepared") if self.pipeline.conversation_language == MATCH_ALL: @@ -1078,7 +1104,7 @@ class PipelineRun: agent_id = self.intent_agent processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT intent_response: intent.IntentResponse | None = None - if not processed_locally: + if not processed_locally and not self._intent_agent_only: # Sentence triggers override conversation agent if ( trigger_response_text @@ -1195,6 +1221,9 @@ class PipelineRun: ) ) + if conversation_result.continue_conversation: + self._conversation_data.continue_conversation_agent = agent_id + return speech async def prepare_text_to_speech(self) -> None: @@ -1458,8 +1487,8 @@ class PipelineInput: run: PipelineRun - conversation_id: str - """Identifier for the conversation.""" + session: chat_session.ChatSession + """Session for the conversation.""" stt_metadata: stt.SpeechMetadata | None = None """Metadata of stt input audio. Required when start_stage = stt.""" @@ -1484,7 +1513,9 @@ class PipelineInput: async def execute(self) -> None: """Run pipeline.""" - self.run.start(conversation_id=self.conversation_id, device_id=self.device_id) + self.run.start( + conversation_id=self.session.conversation_id, device_id=self.device_id + ) current_stage: PipelineStage | None = self.run.start_stage stt_audio_buffer: list[EnhancedAudioChunk] = [] stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None @@ -1568,7 +1599,7 @@ class PipelineInput: assert intent_input is not None tts_input = await self.run.recognize_intent( intent_input, - self.conversation_id, + self.session.conversation_id, self.device_id, self.conversation_extra_system_prompt, ) @@ -1652,7 +1683,7 @@ class PipelineInput: <= PIPELINE_STAGE_ORDER.index(PipelineStage.INTENT) <= end_stage_index ): - prepare_tasks.append(self.run.prepare_recognize_intent()) + prepare_tasks.append(self.run.prepare_recognize_intent(self.session)) if ( start_stage_index @@ -1931,7 +1962,7 @@ class PipelineRunDebug: class PipelineStore(Store[SerializedPipelineStorageCollection]): - """Store entity registry data.""" + """Store pipeline data.""" async def _async_migrate_func( self, @@ -2013,3 +2044,37 @@ async def async_run_migrations(hass: HomeAssistant) -> None: for pipeline, attr_updates in updates: await async_update_pipeline(hass, pipeline, **attr_updates) + + +@dataclass +class PipelineConversationData: + """Hold data for the duration of a conversation.""" + + continue_conversation_agent: str | None = None + """The agent that requested the conversation to be continued.""" + + +@callback +def async_get_pipeline_conversation_data( + hass: HomeAssistant, session: chat_session.ChatSession +) -> PipelineConversationData: + """Get the pipeline data for a specific conversation.""" + all_conversation_data = hass.data.get(KEY_PIPELINE_CONVERSATION_DATA) + if all_conversation_data is None: + all_conversation_data = {} + hass.data[KEY_PIPELINE_CONVERSATION_DATA] = all_conversation_data + + data = all_conversation_data.get(session.conversation_id) + + if data is not None: + return data + + @callback + def do_cleanup() -> None: + """Handle cleanup.""" + all_conversation_data.pop(session.conversation_id) + + session.async_on_cleanup(do_cleanup) + + data = all_conversation_data[session.conversation_id] = PipelineConversationData() + return data diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index d2d54a1b7c3..937b3a0ea45 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -239,7 +239,7 @@ async def websocket_run( with chat_session.async_get_chat_session( hass, msg.get("conversation_id") ) as session: - input_args["conversation_id"] = session.conversation_id + input_args["session"] = session pipeline_input = PipelineInput(**input_args) try: diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py index 08a68fa0164..7bdd13afc01 100644 --- a/homeassistant/components/conversation/models.py +++ b/homeassistant/components/conversation/models.py @@ -62,12 +62,14 @@ class ConversationResult: response: intent.IntentResponse conversation_id: str | None = None + continue_conversation: bool = False def as_dict(self) -> dict[str, Any]: """Return result as a dict.""" return { "response": self.response.as_dict(), "conversation_id": self.conversation_id, + "continue_conversation": self.continue_conversation, } diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 016b1c3494d..0af74621153 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -284,7 +284,10 @@ class EsphomeAssistSatellite( elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: assert event.data is not None data_to_send = { - "conversation_id": event.data["intent_output"]["conversation_id"] or "", + "conversation_id": event.data["intent_output"]["conversation_id"], + "continue_conversation": event.data["intent_output"][ + "continue_conversation" + ], } elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: assert event.data is not None diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 93f3b03d9af..de414019317 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_unknown_hass_api dict({ + 'continue_conversation': False, 'conversation_id': '1234', 'response': IntentResponse( card=dict({ diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 02ec7c04607..a0549f27f05 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import AsyncIterable, Generator from pathlib import Path from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest @@ -24,7 +24,7 @@ from homeassistant.components.assist_pipeline.pipeline import ( from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import chat_session, device_registry as dr from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component @@ -379,3 +379,14 @@ def pipeline_storage(pipeline_data) -> PipelineStorageCollection: def make_10ms_chunk(header: bytes) -> bytes: """Return 10ms of zeros with the given header.""" return header + bytes(BYTES_PER_CHUNK - len(header)) + + +@pytest.fixture +def mock_chat_session(hass: HomeAssistant) -> Generator[chat_session.ChatSession]: + """Mock the ulid of chat sessions.""" + # pylint: disable-next=contextmanager-generator-missing-cleanup + with ( + patch("homeassistant.helpers.chat_session.ulid_now", return_value="mock-ulid"), + chat_session.async_get_chat_session(hass) as session, + ): + yield session diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 11e6bc2339a..f5e5f813db6 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -45,6 +45,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -137,6 +138,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -229,6 +231,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -345,6 +348,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -432,7 +436,7 @@ list([ dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -440,7 +444,7 @@ }), dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test input', @@ -452,6 +456,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -484,7 +489,7 @@ list([ dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -492,7 +497,7 @@ }), dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test input', @@ -504,6 +509,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -536,7 +542,7 @@ list([ dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -544,7 +550,7 @@ }), dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test input', @@ -556,6 +562,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -588,7 +595,7 @@ list([ dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index f677fa6d8cf..509f2072509 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -43,6 +43,7 @@ # name: test_audio_pipeline.4 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -127,6 +128,7 @@ # name: test_audio_pipeline_debug.4 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -223,6 +225,7 @@ # name: test_audio_pipeline_with_enhancements.4 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -329,6 +332,7 @@ # name: test_audio_pipeline_with_wake_word_no_timeout.6 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -596,6 +600,7 @@ # name: test_pipeline_empty_tts_output.2 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -715,6 +720,7 @@ # name: test_text_only_pipeline[extra_msg0].2 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -762,6 +768,7 @@ # name: test_text_only_pipeline[extra_msg1].2 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 1651950c173..e983e4a96e3 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -27,7 +27,7 @@ from homeassistant.components.assist_pipeline.const import ( ) from homeassistant.const import MATCH_ALL from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import intent +from homeassistant.helpers import chat_session, intent from homeassistant.setup import async_setup_component from .conftest import ( @@ -675,6 +675,7 @@ async def test_wake_word_detection_aborted( mock_wake_word_provider_entity: MockWakeWordEntity, init_components, pipeline_data: assist_pipeline.pipeline.PipelineData, + mock_chat_session: chat_session.ChatSession, snapshot: SnapshotAssertion, ) -> None: """Test creating a pipeline from an audio stream with wake word.""" @@ -693,7 +694,7 @@ async def test_wake_word_detection_aborted( pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) pipeline_input = assist_pipeline.pipeline.PipelineInput( - conversation_id="mock-conversation-id", + session=mock_chat_session, device_id=None, stt_metadata=stt.SpeechMetadata( language="", @@ -766,6 +767,7 @@ async def test_tts_audio_output( mock_tts_provider: MockTTSProvider, init_components, pipeline_data: assist_pipeline.pipeline.PipelineData, + mock_chat_session: chat_session.ChatSession, snapshot: SnapshotAssertion, ) -> None: """Test using tts_audio_output with wav sets options correctly.""" @@ -780,7 +782,7 @@ async def test_tts_audio_output( pipeline_input = assist_pipeline.pipeline.PipelineInput( tts_input="This is a test.", - conversation_id="mock-conversation-id", + session=mock_chat_session, device_id=None, run=assist_pipeline.pipeline.PipelineRun( hass, @@ -823,6 +825,7 @@ async def test_tts_wav_preferred_format( hass_client: ClientSessionGenerator, mock_tts_provider: MockTTSProvider, init_components, + mock_chat_session: chat_session.ChatSession, pipeline_data: assist_pipeline.pipeline.PipelineData, ) -> None: """Test that preferred format options are given to the TTS system if supported.""" @@ -837,7 +840,7 @@ async def test_tts_wav_preferred_format( pipeline_input = assist_pipeline.pipeline.PipelineInput( tts_input="This is a test.", - conversation_id="mock-conversation-id", + session=mock_chat_session, device_id=None, run=assist_pipeline.pipeline.PipelineRun( hass, @@ -891,6 +894,7 @@ async def test_tts_dict_preferred_format( hass_client: ClientSessionGenerator, mock_tts_provider: MockTTSProvider, init_components, + mock_chat_session: chat_session.ChatSession, pipeline_data: assist_pipeline.pipeline.PipelineData, ) -> None: """Test that preferred format options are given to the TTS system if supported.""" @@ -905,7 +909,7 @@ async def test_tts_dict_preferred_format( pipeline_input = assist_pipeline.pipeline.PipelineInput( tts_input="This is a test.", - conversation_id="mock-conversation-id", + session=mock_chat_session, device_id=None, run=assist_pipeline.pipeline.PipelineRun( hass, @@ -962,6 +966,7 @@ async def test_tts_dict_preferred_format( async def test_sentence_trigger_overrides_conversation_agent( hass: HomeAssistant, init_components, + mock_chat_session: chat_session.ChatSession, pipeline_data: assist_pipeline.pipeline.PipelineData, ) -> None: """Test that sentence triggers are checked before a non-default conversation agent.""" @@ -991,7 +996,7 @@ async def test_sentence_trigger_overrides_conversation_agent( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test trigger sentence", - conversation_id="mock-conversation-id", + session=mock_chat_session, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1039,6 +1044,7 @@ async def test_sentence_trigger_overrides_conversation_agent( async def test_prefer_local_intents( hass: HomeAssistant, init_components, + mock_chat_session: chat_session.ChatSession, pipeline_data: assist_pipeline.pipeline.PipelineData, ) -> None: """Test that the default agent is checked first when local intents are preferred.""" @@ -1069,7 +1075,7 @@ async def test_prefer_local_intents( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="I'd like to order a stout please", - conversation_id="mock-conversation-id", + session=mock_chat_session, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1113,10 +1119,150 @@ async def test_prefer_local_intents( ) +async def test_intent_continue_conversation( + hass: HomeAssistant, + init_components, + mock_chat_session: chat_session.ChatSession, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that a conversation agent flagging continue conversation gets response.""" + events: list[assist_pipeline.PipelineEvent] = [] + + # Fake a test agent and prefer local intents + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + await assist_pipeline.pipeline.async_update_pipeline( + hass, pipeline, conversation_engine="test-agent" + ) + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="Set a timer", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), + ): + await pipeline_input.validate() + + response = intent.IntentResponse("en") + response.async_set_speech("For how long?") + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + response=response, + conversation_id=mock_chat_session.conversation_id, + continue_conversation=True, + ), + ) as mock_async_converse: + await pipeline_input.execute() + + mock_async_converse.assert_called() + + results = [ + event.data + for event in events + if event.type + in ( + assist_pipeline.PipelineEventType.INTENT_START, + assist_pipeline.PipelineEventType.INTENT_END, + ) + ] + assert results[1]["intent_output"]["continue_conversation"] is True + + # Change conversation agent to default one and register sentence trigger that should not be called + await assist_pipeline.pipeline.async_update_pipeline( + hass, pipeline, conversation_engine=None + ) + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["Hello"], + }, + "action": { + "set_conversation_response": "test trigger response", + }, + } + }, + ) + + # Because we did continue conversation, it should respond to the test agent again. + events.clear() + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="Hello", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), + ) as mock_prepare: + await pipeline_input.validate() + + # It requested test agent even if that was not default agent. + assert mock_prepare.mock_calls[0][1][1] == "test-agent" + + response = intent.IntentResponse("en") + response.async_set_speech("Timer set for 20 minutes") + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + response=response, + conversation_id=mock_chat_session.conversation_id, + ), + ) as mock_async_converse: + await pipeline_input.execute() + + mock_async_converse.assert_called() + + # Snapshot will show it was still handled by the test agent and not default agent + results = [ + event.data + for event in events + if event.type + in ( + assist_pipeline.PipelineEventType.INTENT_START, + assist_pipeline.PipelineEventType.INTENT_END, + ) + ] + assert results[0]["engine"] == "test-agent" + assert results[1]["intent_output"]["continue_conversation"] is False + + async def test_stt_language_used_instead_of_conversation_language( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components, + mock_chat_session: chat_session.ChatSession, snapshot: SnapshotAssertion, ) -> None: """Test that the STT language is used first when the conversation language is '*' (all languages).""" @@ -1147,7 +1293,7 @@ async def test_stt_language_used_instead_of_conversation_language( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test input", - conversation_id="mock-conversation-id", + session=mock_chat_session, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1192,6 +1338,7 @@ async def test_tts_language_used_instead_of_conversation_language( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components, + mock_chat_session: chat_session.ChatSession, snapshot: SnapshotAssertion, ) -> None: """Test that the TTS language is used after STT when the conversation language is '*' (all languages).""" @@ -1222,7 +1369,7 @@ async def test_tts_language_used_instead_of_conversation_language( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test input", - conversation_id="mock-conversation-id", + session=mock_chat_session, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1267,6 +1414,7 @@ async def test_pipeline_language_used_instead_of_conversation_language( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components, + mock_chat_session: chat_session.ChatSession, snapshot: SnapshotAssertion, ) -> None: """Test that the pipeline language is used last when the conversation language is '*' (all languages).""" @@ -1297,7 +1445,7 @@ async def test_pipeline_language_used_instead_of_conversation_language( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test input", - conversation_id="mock-conversation-id", + session=mock_chat_session, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), diff --git a/tests/components/conversation/__init__.py b/tests/components/conversation/__init__.py index 314188dbd82..eeab8b6b9af 100644 --- a/tests/components/conversation/__init__.py +++ b/tests/components/conversation/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import AsyncGenerator from dataclasses import dataclass, field from typing import Literal from unittest.mock import patch @@ -49,7 +50,7 @@ class MockAgent(conversation.AbstractConversationAgent): @pytest.fixture -async def mock_chat_log(hass: HomeAssistant) -> MockChatLog: +async def mock_chat_log(hass: HomeAssistant) -> AsyncGenerator[MockChatLog]: """Return mock chat logs.""" # pylint: disable-next=contextmanager-generator-missing-cleanup with ( diff --git a/tests/components/conversation/snapshots/test_chat_log.ambr b/tests/components/conversation/snapshots/test_chat_log.ambr index 1ddbf68bb84..ff8ebf724cd 100644 --- a/tests/components/conversation/snapshots/test_chat_log.ambr +++ b/tests/components/conversation/snapshots/test_chat_log.ambr @@ -151,6 +151,7 @@ # --- # name: test_template_error dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -171,6 +172,7 @@ # --- # name: test_unknown_llm_api dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ diff --git a/tests/components/conversation/snapshots/test_default_agent.ambr b/tests/components/conversation/snapshots/test_default_agent.ambr index c2b16ea2912..02e4ef1befe 100644 --- a/tests/components/conversation/snapshots/test_default_agent.ambr +++ b/tests/components/conversation/snapshots/test_default_agent.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_custom_sentences dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -26,6 +27,7 @@ # --- # name: test_custom_sentences.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -51,6 +53,7 @@ # --- # name: test_custom_sentences_config dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -76,6 +79,7 @@ # --- # name: test_intent_alias_added_removed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -106,6 +110,7 @@ # --- # name: test_intent_alias_added_removed.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -136,6 +141,7 @@ # --- # name: test_intent_alias_added_removed.2 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -156,6 +162,7 @@ # --- # name: test_intent_conversion_not_expose_new dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -176,6 +183,7 @@ # --- # name: test_intent_conversion_not_expose_new.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -206,6 +214,7 @@ # --- # name: test_intent_entity_added_removed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -236,6 +245,7 @@ # --- # name: test_intent_entity_added_removed.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -266,6 +276,7 @@ # --- # name: test_intent_entity_added_removed.2 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -296,6 +307,7 @@ # --- # name: test_intent_entity_added_removed.3 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -316,6 +328,7 @@ # --- # name: test_intent_entity_exposed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -346,6 +359,7 @@ # --- # name: test_intent_entity_fail_if_unexposed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -366,6 +380,7 @@ # --- # name: test_intent_entity_remove_custom_name dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -386,6 +401,7 @@ # --- # name: test_intent_entity_remove_custom_name.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -416,6 +432,7 @@ # --- # name: test_intent_entity_remove_custom_name.2 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -436,6 +453,7 @@ # --- # name: test_intent_entity_renamed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -466,6 +484,7 @@ # --- # name: test_intent_entity_renamed.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index c6ac6c2df9c..849a5b17102 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -202,6 +202,7 @@ # --- # name: test_http_api_handle_failure dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -222,6 +223,7 @@ # --- # name: test_http_api_no_match dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -242,6 +244,7 @@ # --- # name: test_http_api_unexpected_failure dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -262,6 +265,7 @@ # --- # name: test_http_processing_intent[None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -292,6 +296,7 @@ # --- # name: test_http_processing_intent[conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -322,6 +327,7 @@ # --- # name: test_http_processing_intent[homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -352,6 +358,7 @@ # --- # name: test_ws_api[payload0] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -372,6 +379,7 @@ # --- # name: test_ws_api[payload1] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -392,6 +400,7 @@ # --- # name: test_ws_api[payload2] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -412,6 +421,7 @@ # --- # name: test_ws_api[payload3] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -432,6 +442,7 @@ # --- # name: test_ws_api[payload4] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -452,6 +463,7 @@ # --- # name: test_ws_api[payload5] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 911c7043a6d..3d843d4e32a 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_custom_agent dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -44,6 +45,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -74,6 +76,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -104,6 +107,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -134,6 +138,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -164,6 +169,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -194,6 +200,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -224,6 +231,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -254,6 +262,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -284,6 +293,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -314,6 +324,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -344,6 +355,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -374,6 +386,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 30535236970..56914a0b829 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -25,7 +25,7 @@ from aioesphomeapi import ( ) import pytest -from homeassistant.components import assist_satellite, tts +from homeassistant.components import assist_satellite, conversation, tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite import ( AssistSatelliteConfiguration, @@ -285,12 +285,21 @@ async def test_pipeline_api_audio( event_callback( PipelineEvent( type=PipelineEventType.INTENT_END, - data={"intent_output": {"conversation_id": conversation_id}}, + data={ + "intent_output": conversation.ConversationResult( + response=intent_helper.IntentResponse("en"), + conversation_id=conversation_id, + continue_conversation=True, + ).as_dict() + }, ) ) assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END, - {"conversation_id": conversation_id}, + { + "conversation_id": conversation_id, + "continue_conversation": True, + }, ) # TTS @@ -484,7 +493,12 @@ async def test_pipeline_udp_audio( event_callback( PipelineEvent( type=PipelineEventType.INTENT_END, - data={"intent_output": {"conversation_id": conversation_id}}, + data={ + "intent_output": conversation.ConversationResult( + response=intent_helper.IntentResponse("en"), + conversation_id=conversation_id, + ).as_dict() + }, ) ) @@ -690,7 +704,12 @@ async def test_pipeline_media_player( event_callback( PipelineEvent( type=PipelineEventType.INTENT_END, - data={"intent_output": {"conversation_id": conversation_id}}, + data={ + "intent_output": conversation.ConversationResult( + response=intent_helper.IntentResponse("en"), + conversation_id=conversation_id, + ).as_dict() + }, ) ) diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index dda5f369ad5..b071caebd16 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -1081,6 +1081,7 @@ async def test_webhook_handle_conversation_process( }, }, "conversation_id": None, + "continue_conversation": False, } diff --git a/tests/components/ollama/snapshots/test_conversation.ambr b/tests/components/ollama/snapshots/test_conversation.ambr index 93f3b03d9af..de414019317 100644 --- a/tests/components/ollama/snapshots/test_conversation.ambr +++ b/tests/components/ollama/snapshots/test_conversation.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_unknown_hass_api dict({ + 'continue_conversation': False, 'conversation_id': '1234', 'response': IntentResponse( card=dict({ diff --git a/tests/syrupy.py b/tests/syrupy.py index 3c8e398f0f8..e028d5839cb 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -109,7 +109,11 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): serializable_data = cls._serializable_issue_registry_entry(data) elif isinstance(data, dict) and "flow_id" in data and "handler" in data: serializable_data = cls._serializable_flow_result(data) - elif isinstance(data, dict) and set(data) == {"conversation_id", "response"}: + elif isinstance(data, dict) and set(data) == { + "conversation_id", + "response", + "continue_conversation", + }: serializable_data = cls._serializable_conversation_result(data) elif isinstance(data, vol.Schema): serializable_data = voluptuous_serialize.convert(data) From 39bc37d22568cfc3add4c205cb030bd2a3dbd083 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 20:33:25 +0100 Subject: [PATCH 2893/2987] Remove orphan devices on startup in SmartThings (#139541) --- .../components/smartthings/__init__.py | 17 ++++++++++++++- tests/components/smartthings/test_init.py | 21 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 4bc9b270360..d6de1d3d252 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -21,13 +21,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, MAIN, OLD_DATA +from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, DOMAIN, MAIN, OLD_DATA _LOGGER = logging.getLogger(__name__) @@ -123,6 +124,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + for device_entry in device_entries: + device_id = next( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ) + if device_id in entry.runtime_data.devices: + continue + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=entry.entry_id + ) + return True diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index be88f11903e..372f23eec42 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +import pytest from syrupy import SnapshotAssertion from homeassistant.components.smartthings.const import DOMAIN @@ -29,3 +30,23 @@ async def test_devices( assert device is not None assert device == snapshot + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_removing_stale_devices( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test removing stale devices.""" + mock_config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "aaa-bbb-ccc")}, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not device_registry.async_get_device({(DOMAIN, "aaa-bbb-ccc")}) From 455363871f99e041e649c09b313a001134cc9620 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 20:39:49 +0100 Subject: [PATCH 2894/2987] Use last event as color mode in SmartThings (#139473) * Use last event as color mode in SmartThings * Use last event as color mode in SmartThings * Fix --- homeassistant/components/smartthings/light.py | 37 +++--- tests/components/smartthings/test_light.py | 116 +++++++++++++++++- 2 files changed, 135 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 54e8ad18a7c..aa3a8d35859 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -3,12 +3,13 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import Any, cast -from pysmartthings import Attribute, Capability, Command, SmartThings +from pysmartthings import Attribute, Capability, Command, DeviceEvent, SmartThings from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_TRANSITION, @@ -19,6 +20,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import FullDevice, SmartThingsConfigEntry from .const import MAIN @@ -53,7 +55,7 @@ def convert_scale( return round(value * target_scale / value_scale, round_digits) -class SmartThingsLight(SmartThingsEntity, LightEntity): +class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): """Define a SmartThings Light.""" _attr_name = None @@ -84,18 +86,28 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): color_modes = set() if self.supports_capability(Capability.COLOR_TEMPERATURE): color_modes.add(ColorMode.COLOR_TEMP) + self._attr_color_mode = ColorMode.COLOR_TEMP if self.supports_capability(Capability.COLOR_CONTROL): color_modes.add(ColorMode.HS) + self._attr_color_mode = ColorMode.HS if not color_modes and self.supports_capability(Capability.SWITCH_LEVEL): color_modes.add(ColorMode.BRIGHTNESS) if not color_modes: color_modes.add(ColorMode.ONOFF) + if len(color_modes) == 1: + self._attr_color_mode = list(color_modes)[0] self._attr_supported_color_modes = color_modes features = LightEntityFeature(0) if self.supports_capability(Capability.SWITCH_LEVEL): features |= LightEntityFeature.TRANSITION self._attr_supported_features = features + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_extra_data()) is not None: + self._attr_color_mode = last_state.as_dict()[ATTR_COLOR_MODE] + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" tasks = [] @@ -195,17 +207,14 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): argument=[level, duration], ) - @property - def color_mode(self) -> ColorMode: - """Return the color mode of the light.""" - if len(self._attr_supported_color_modes) == 1: - # The light supports only a single color mode - return list(self._attr_supported_color_modes)[0] - - # The light supports hs + color temp, determine which one it is - if self._attr_hs_color and self._attr_hs_color[1]: - return ColorMode.HS - return ColorMode.COLOR_TEMP + def _update_handler(self, event: DeviceEvent) -> None: + """Handle device updates.""" + if event.capability in (Capability.COLOR_CONTROL, Capability.COLOR_TEMPERATURE): + self._attr_color_mode = { + Capability.COLOR_CONTROL: ColorMode.HS, + Capability.COLOR_TEMPERATURE: ColorMode.COLOR_TEMP, + }[cast(Capability, event.capability)] + super()._update_handler(event) @property def is_on(self) -> bool: diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py index 8d47e90c9f5..56eadde748b 100644 --- a/tests/components/smartthings/test_light.py +++ b/tests/components/smartthings/test_light.py @@ -12,7 +12,12 @@ from homeassistant.components.light import ( ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, + ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, ColorMode, ) @@ -25,7 +30,7 @@ from homeassistant.const import ( STATE_ON, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from . import ( @@ -35,7 +40,7 @@ from . import ( trigger_update, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, mock_restore_cache_with_extra_data async def test_all_entities( @@ -228,6 +233,15 @@ async def test_updating_brightness( set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") await setup_integration(hass, mock_config_entry) + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 40, + ) + assert hass.states.get("light.standing_light").attributes[ATTR_BRIGHTNESS] == 178 await trigger_update( @@ -252,8 +266,17 @@ async def test_updating_hs( set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") await setup_integration(hass, mock_config_entry) + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 40, + ) + assert hass.states.get("light.standing_light").attributes[ATTR_HS_COLOR] == ( - 218.906, + 144.0, 60, ) @@ -280,9 +303,17 @@ async def test_updating_color_temp( ) -> None: """Test color temperature update.""" set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") - set_attribute_value(devices, Capability.COLOR_CONTROL, Attribute.SATURATION, 0) await setup_integration(hass, mock_config_entry) + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Attribute.COLOR_TEMPERATURE, + 3000, + ) + assert ( hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] is ColorMode.COLOR_TEMP @@ -305,3 +336,80 @@ async def test_updating_color_temp( hass.states.get("light.standing_light").attributes[ATTR_COLOR_TEMP_KELVIN] == 2000 ) + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_color_modes( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test color mode changes.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + set_attribute_value(devices, Capability.COLOR_CONTROL, Attribute.SATURATION, 50) + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.HS + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Attribute.COLOR_TEMPERATURE, + 2000, + ) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.COLOR_TEMP + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 20, + ) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.HS + ) + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_color_mode_after_startup( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test color mode after startup.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + + RESTORE_DATA = { + ATTR_BRIGHTNESS: 178, + ATTR_COLOR_MODE: ColorMode.COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN: 3000, + ATTR_HS_COLOR: (144.0, 60), + ATTR_MAX_COLOR_TEMP_KELVIN: 9000, + ATTR_MIN_COLOR_TEMP_KELVIN: 2000, + ATTR_RGB_COLOR: (255, 128, 0), + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP, ColorMode.HS], + ATTR_XY_COLOR: (0.61, 0.35), + } + + mock_restore_cache_with_extra_data( + hass, ((State("light.standing_light", STATE_ON), RESTORE_DATA),) + ) + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.COLOR_TEMP + ) From 1a80934593907875194ad2b0cf291bd890c6330e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 28 Feb 2025 19:40:13 +0000 Subject: [PATCH 2895/2987] Move TTS entity to own file (#139538) * Move entity to own file * Move entity tests --- homeassistant/components/tts/__init__.py | 161 +---------------------- homeassistant/components/tts/entity.py | 159 ++++++++++++++++++++++ tests/components/tts/test_entity.py | 144 ++++++++++++++++++++ tests/components/tts/test_init.py | 138 +------------------ 4 files changed, 310 insertions(+), 292 deletions(-) create mode 100644 homeassistant/components/tts/entity.py create mode 100644 tests/components/tts/test_entity.py diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 199d644738b..5b2da44eae2 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -3,10 +3,8 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime -from functools import partial import hashlib from http import HTTPStatus import io @@ -18,7 +16,7 @@ import secrets import subprocess import tempfile from time import monotonic -from typing import Any, Final, TypedDict, final +from typing import Any, Final, TypedDict from aiohttp import web import mutagen @@ -28,22 +26,8 @@ import voluptuous as vol from homeassistant.components import ffmpeg, websocket_api from homeassistant.components.http import HomeAssistantView -from homeassistant.components.media_player import ( - ATTR_MEDIA_ANNOUNCE, - ATTR_MEDIA_CONTENT_ID, - ATTR_MEDIA_CONTENT_TYPE, - DOMAIN as DOMAIN_MP, - SERVICE_PLAY_MEDIA, - MediaType, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ENTITY_ID, - EVENT_HOMEASSISTANT_STOP, - PLATFORM_FORMAT, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, PLATFORM_FORMAT from homeassistant.core import ( CALLBACK_TYPE, Event, @@ -58,9 +42,8 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import UNDEFINED, ConfigType -from homeassistant.util import dt as dt_util, language as language_util +from homeassistant.util import language as language_util from .const import ( ATTR_CACHE, @@ -78,6 +61,7 @@ from .const import ( DOMAIN, TtsAudioType, ) +from .entity import TextToSpeechEntity from .helper import get_engine_instance from .legacy import PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, Provider, async_setup_legacy from .media_source import generate_media_source_id, media_source_id_to_kwargs @@ -95,6 +79,7 @@ __all__ = [ "PLATFORM_SCHEMA_BASE", "Provider", "SampleFormat", + "TextToSpeechEntity", "TtsAudioType", "Voice", "async_default_engine", @@ -389,14 +374,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.data[DATA_COMPONENT].async_unload_entry(entry) -CACHED_PROPERTIES_WITH_ATTR_ = { - "default_language", - "default_options", - "supported_languages", - "supported_options", -} - - @dataclass class ResultStream: """Class that will stream the result when available.""" @@ -431,134 +408,6 @@ class ResultStream: return data -class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): - """Represent a single TTS engine.""" - - _attr_should_poll = False - __last_tts_loaded: str | None = None - - _attr_default_language: str - _attr_default_options: Mapping[str, Any] | None = None - _attr_supported_languages: list[str] - _attr_supported_options: list[str] | None = None - - @property - @final - def state(self) -> str | None: - """Return the state of the entity.""" - if self.__last_tts_loaded is None: - return None - return self.__last_tts_loaded - - @cached_property - def supported_languages(self) -> list[str]: - """Return a list of supported languages.""" - return self._attr_supported_languages - - @cached_property - def default_language(self) -> str: - """Return the default language.""" - return self._attr_default_language - - @cached_property - def supported_options(self) -> list[str] | None: - """Return a list of supported options like voice, emotions.""" - return self._attr_supported_options - - @cached_property - def default_options(self) -> Mapping[str, Any] | None: - """Return a mapping with the default options.""" - return self._attr_default_options - - @callback - def async_get_supported_voices(self, language: str) -> list[Voice] | None: - """Return a list of supported voices for a language.""" - return None - - async def async_internal_added_to_hass(self) -> None: - """Call when the entity is added to hass.""" - await super().async_internal_added_to_hass() - try: - _ = self.default_language - except AttributeError as err: - raise AttributeError( - "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" - ) from err - try: - _ = self.supported_languages - except AttributeError as err: - raise AttributeError( - "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" - ) from err - state = await self.async_get_last_state() - if ( - state is not None - and state.state is not None - and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) - ): - self.__last_tts_loaded = state.state - - async def async_speak( - self, - media_player_entity_id: list[str], - message: str, - cache: bool, - language: str | None = None, - options: dict | None = None, - ) -> None: - """Speak via a Media Player.""" - await self.hass.services.async_call( - DOMAIN_MP, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: media_player_entity_id, - ATTR_MEDIA_CONTENT_ID: generate_media_source_id( - self.hass, - message=message, - engine=self.entity_id, - language=language, - options=options, - cache=cache, - ), - ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, - ATTR_MEDIA_ANNOUNCE: True, - }, - blocking=True, - context=self._context, - ) - - @final - async def internal_async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Process an audio stream to TTS service. - - Only streaming content is allowed! - """ - self.__last_tts_loaded = dt_util.utcnow().isoformat() - self.async_write_ha_state() - return await self.async_get_tts_audio( - message=message, language=language, options=options - ) - - def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Load tts audio file from the engine.""" - raise NotImplementedError - - async def async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Load tts audio file from the engine. - - Return a tuple of file extension and data as bytes. - """ - return await self.hass.async_add_executor_job( - partial(self.get_tts_audio, message, language, options=options) - ) - - def _hash_options(options: dict) -> str: """Hashes an options dictionary.""" opts_hash = hashlib.blake2s(digest_size=5) diff --git a/homeassistant/components/tts/entity.py b/homeassistant/components/tts/entity.py new file mode 100644 index 00000000000..ef65886452d --- /dev/null +++ b/homeassistant/components/tts/entity.py @@ -0,0 +1,159 @@ +"""Entity for Text-to-Speech.""" + +from collections.abc import Mapping +from functools import partial +from typing import Any, final + +from propcache.api import cached_property + +from homeassistant.components.media_player import ( + ATTR_MEDIA_ANNOUNCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as DOMAIN_MP, + SERVICE_PLAY_MEDIA, + MediaType, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import callback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt as dt_util + +from .const import TtsAudioType +from .media_source import generate_media_source_id +from .models import Voice + +CACHED_PROPERTIES_WITH_ATTR_ = { + "default_language", + "default_options", + "supported_languages", + "supported_options", +} + + +class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): + """Represent a single TTS engine.""" + + _attr_should_poll = False + __last_tts_loaded: str | None = None + + _attr_default_language: str + _attr_default_options: Mapping[str, Any] | None = None + _attr_supported_languages: list[str] + _attr_supported_options: list[str] | None = None + + @property + @final + def state(self) -> str | None: + """Return the state of the entity.""" + if self.__last_tts_loaded is None: + return None + return self.__last_tts_loaded + + @cached_property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return self._attr_supported_languages + + @cached_property + def default_language(self) -> str: + """Return the default language.""" + return self._attr_default_language + + @cached_property + def supported_options(self) -> list[str] | None: + """Return a list of supported options like voice, emotions.""" + return self._attr_supported_options + + @cached_property + def default_options(self) -> Mapping[str, Any] | None: + """Return a mapping with the default options.""" + return self._attr_default_options + + @callback + def async_get_supported_voices(self, language: str) -> list[Voice] | None: + """Return a list of supported voices for a language.""" + return None + + async def async_internal_added_to_hass(self) -> None: + """Call when the entity is added to hass.""" + await super().async_internal_added_to_hass() + try: + _ = self.default_language + except AttributeError as err: + raise AttributeError( + "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + ) from err + try: + _ = self.supported_languages + except AttributeError as err: + raise AttributeError( + "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + ) from err + state = await self.async_get_last_state() + if ( + state is not None + and state.state is not None + and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + ): + self.__last_tts_loaded = state.state + + async def async_speak( + self, + media_player_entity_id: list[str], + message: str, + cache: bool, + language: str | None = None, + options: dict | None = None, + ) -> None: + """Speak via a Media Player.""" + await self.hass.services.async_call( + DOMAIN_MP, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player_entity_id, + ATTR_MEDIA_CONTENT_ID: generate_media_source_id( + self.hass, + message=message, + engine=self.entity_id, + language=language, + options=options, + cache=cache, + ), + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + context=self._context, + ) + + @final + async def internal_async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Process an audio stream to TTS service. + + Only streaming content is allowed! + """ + self.__last_tts_loaded = dt_util.utcnow().isoformat() + self.async_write_ha_state() + return await self.async_get_tts_audio( + message=message, language=language, options=options + ) + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load tts audio file from the engine.""" + raise NotImplementedError + + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load tts audio file from the engine. + + Return a tuple of file extension and data as bytes. + """ + return await self.hass.async_add_executor_job( + partial(self.get_tts_audio, message, language, options=options) + ) diff --git a/tests/components/tts/test_entity.py b/tests/components/tts/test_entity.py new file mode 100644 index 00000000000..d82ec6a5d2b --- /dev/null +++ b/tests/components/tts/test_entity.py @@ -0,0 +1,144 @@ +"""Tests for the TTS entity.""" + +import pytest + +from homeassistant.components import tts +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, State + +from .common import ( + DEFAULT_LANG, + SUPPORT_LANGUAGES, + TEST_DOMAIN, + MockTTSEntity, + mock_config_entry_setup, +) + +from tests.common import mock_restore_cache + + +class DefaultEntity(tts.TextToSpeechEntity): + """Test entity.""" + + _attr_supported_languages = SUPPORT_LANGUAGES + _attr_default_language = DEFAULT_LANG + + +async def test_default_entity_attributes() -> None: + """Test default entity attributes.""" + entity = DefaultEntity() + + assert entity.hass is None + assert entity.default_language == DEFAULT_LANG + assert entity.supported_languages == SUPPORT_LANGUAGES + assert entity.supported_options is None + assert entity.default_options is None + assert entity.async_get_supported_voices("test") is None + + +async def test_restore_state( + hass: HomeAssistant, + mock_tts_entity: MockTTSEntity, +) -> None: + """Test we restore state in the integration.""" + entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" + timestamp = "2023-01-01T23:59:59+00:00" + mock_restore_cache(hass, (State(entity_id, timestamp),)) + + config_entry = await mock_config_entry_setup(hass, mock_tts_entity) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + state = hass.states.get(entity_id) + assert state + assert state.state == timestamp + + +async def test_tts_entity_subclass_properties( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test for errors when subclasses of the TextToSpeechEntity are missing required properties.""" + + class TestClass1(tts.TextToSpeechEntity): + _attr_default_language = DEFAULT_LANG + _attr_supported_languages = SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass1()) + + class TestClass2(tts.TextToSpeechEntity): + @property + def default_language(self) -> str: + return DEFAULT_LANG + + @property + def supported_languages(self) -> list[str]: + return SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass2()) + + assert all(record.exc_info is None for record in caplog.records) + + caplog.clear() + + class TestClass3(tts.TextToSpeechEntity): + _attr_default_language = DEFAULT_LANG + + await mock_config_entry_setup(hass, TestClass3()) + + assert ( + "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + caplog.clear() + + class TestClass4(tts.TextToSpeechEntity): + _attr_supported_languages = SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass4()) + + assert ( + "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + caplog.clear() + + class TestClass5(tts.TextToSpeechEntity): + @property + def default_language(self) -> str: + return DEFAULT_LANG + + await mock_config_entry_setup(hass, TestClass5()) + + assert ( + "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + caplog.clear() + + class TestClass6(tts.TextToSpeechEntity): + @property + def supported_languages(self) -> list[str]: + return SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass6()) + + assert ( + "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 86ca2de5791..8dece920907 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -20,14 +20,13 @@ from homeassistant.components.media_player import ( ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .common import ( DEFAULT_LANG, - SUPPORT_LANGUAGES, TEST_DOMAIN, MockTTS, MockTTSEntity, @@ -38,37 +37,12 @@ from .common import ( retrieve_media, ) -from tests.common import ( - MockModule, - async_mock_service, - mock_integration, - mock_platform, - mock_restore_cache, -) +from tests.common import MockModule, async_mock_service, mock_integration, mock_platform from tests.typing import ClientSessionGenerator, WebSocketGenerator ORIG_WRITE_TAGS = tts.SpeechManager.write_tags -class DefaultEntity(tts.TextToSpeechEntity): - """Test entity.""" - - _attr_supported_languages = SUPPORT_LANGUAGES - _attr_default_language = DEFAULT_LANG - - -async def test_default_entity_attributes() -> None: - """Test default entity attributes.""" - entity = DefaultEntity() - - assert entity.hass is None - assert entity.default_language == DEFAULT_LANG - assert entity.supported_languages == SUPPORT_LANGUAGES - assert entity.supported_options is None - assert entity.default_options is None - assert entity.async_get_supported_voices("test") is None - - async def test_config_entry_unload( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -120,24 +94,6 @@ async def test_config_entry_unload( assert state is None -async def test_restore_state( - hass: HomeAssistant, - mock_tts_entity: MockTTSEntity, -) -> None: - """Test we restore state in the integration.""" - entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" - timestamp = "2023-01-01T23:59:59+00:00" - mock_restore_cache(hass, (State(entity_id, timestamp),)) - - config_entry = await mock_config_entry_setup(hass, mock_tts_entity) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - state = hass.states.get(entity_id) - assert state - assert state.state == timestamp - - @pytest.mark.parametrize( "setup", ["mock_setup", "mock_config_entry_setup"], indirect=True ) @@ -1840,96 +1796,6 @@ async def test_async_convert_audio_error(hass: HomeAssistant) -> None: await tts.async_convert_audio(hass, "wav", bytes(0), "mp3") -async def test_ttsentity_subclass_properties( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test for errors when subclasses of the TextToSpeechEntity are missing required properties.""" - - class TestClass1(tts.TextToSpeechEntity): - _attr_default_language = DEFAULT_LANG - _attr_supported_languages = SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass1()) - - class TestClass2(tts.TextToSpeechEntity): - @property - def default_language(self) -> str: - return DEFAULT_LANG - - @property - def supported_languages(self) -> list[str]: - return SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass2()) - - assert all(record.exc_info is None for record in caplog.records) - - caplog.clear() - - class TestClass3(tts.TextToSpeechEntity): - _attr_default_language = DEFAULT_LANG - - await mock_config_entry_setup(hass, TestClass3()) - - assert ( - "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - caplog.clear() - - class TestClass4(tts.TextToSpeechEntity): - _attr_supported_languages = SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass4()) - - assert ( - "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - caplog.clear() - - class TestClass5(tts.TextToSpeechEntity): - @property - def default_language(self) -> str: - return DEFAULT_LANG - - await mock_config_entry_setup(hass, TestClass5()) - - assert ( - "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - caplog.clear() - - class TestClass6(tts.TextToSpeechEntity): - @property - def supported_languages(self) -> list[str]: - return SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass6()) - - assert ( - "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - - async def test_default_engine_prefer_entity( hass: HomeAssistant, mock_tts_entity: MockTTSEntity, From 437e54511620de7dc5b44a69d7f8682f8e6ae769 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 28 Feb 2025 20:45:47 +0100 Subject: [PATCH 2896/2987] Rework Comelit tests (#139475) * Rework Comelit tests * allign * restore coverage --- tests/components/comelit/__init__.py | 12 + tests/components/comelit/conftest.py | 104 +++++++ tests/components/comelit/const.py | 38 +-- .../comelit/snapshots/test_diagnostics.ambr | 3 +- tests/components/comelit/test_config_flow.py | 264 +++++++++++------- tests/components/comelit/test_diagnostics.py | 51 +--- 6 files changed, 304 insertions(+), 168 deletions(-) create mode 100644 tests/components/comelit/conftest.py diff --git a/tests/components/comelit/__init__.py b/tests/components/comelit/__init__.py index 916a684de4b..6475f500f01 100644 --- a/tests/components/comelit/__init__.py +++ b/tests/components/comelit/__init__.py @@ -1 +1,13 @@ """Tests for the Comelit SimpleHome integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/comelit/conftest.py b/tests/components/comelit/conftest.py new file mode 100644 index 00000000000..d2d450ccb8d --- /dev/null +++ b/tests/components/comelit/conftest.py @@ -0,0 +1,104 @@ +"""Configure tests for Comelit SimpleHome.""" + +import pytest + +from homeassistant.components.comelit.const import ( + BRIDGE, + DOMAIN as COMELIT_DOMAIN, + VEDO, +) +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE + +from .const import ( + BRIDGE_DEVICE_QUERY, + BRIDGE_HOST, + BRIDGE_PIN, + BRIDGE_PORT, + VEDO_DEVICE_QUERY, + VEDO_HOST, + VEDO_PIN, + VEDO_PORT, +) + +from tests.common import AsyncMock, Generator, MockConfigEntry, patch + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.comelit.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_serial_bridge() -> Generator[AsyncMock]: + """Mock a Comelit serial bridge.""" + with ( + patch( + "homeassistant.components.comelit.coordinator.ComeliteSerialBridgeApi", + autospec=True, + ) as mock_comelit_serial_bridge, + patch( + "homeassistant.components.comelit.config_flow.ComeliteSerialBridgeApi", + new=mock_comelit_serial_bridge, + ), + ): + bridge = mock_comelit_serial_bridge.return_value + bridge.get_all_devices.return_value = BRIDGE_DEVICE_QUERY + bridge.host = BRIDGE_HOST + bridge.port = BRIDGE_PORT + bridge.pin = BRIDGE_PIN + yield bridge + + +@pytest.fixture +def mock_serial_bridge_config_entry() -> Generator[MockConfigEntry]: + """Mock a Comelit config entry for Comelit bridge.""" + return MockConfigEntry( + domain=COMELIT_DOMAIN, + data={ + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + CONF_TYPE: BRIDGE, + }, + ) + + +@pytest.fixture +def mock_vedo() -> Generator[AsyncMock]: + """Mock a Comelit vedo.""" + with ( + patch( + "homeassistant.components.comelit.coordinator.ComelitVedoApi", + autospec=True, + ) as mock_comelit_vedo, + patch( + "homeassistant.components.comelit.config_flow.ComelitVedoApi", + new=mock_comelit_vedo, + ), + ): + vedo = mock_comelit_vedo.return_value + vedo.get_all_areas_and_zones.return_value = VEDO_DEVICE_QUERY + vedo.host = VEDO_HOST + vedo.port = VEDO_PORT + vedo.pin = VEDO_PIN + vedo.type = VEDO + yield vedo + + +@pytest.fixture +def mock_vedo_config_entry() -> Generator[MockConfigEntry]: + """Mock a Comelit config entry for Comelit vedo.""" + return MockConfigEntry( + domain=COMELIT_DOMAIN, + data={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index 92fdfebfa1d..3151b83d175 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -1,7 +1,10 @@ """Common stuff for Comelit SimpleHome tests.""" -from aiocomelit import ComelitVedoAreaObject, ComelitVedoZoneObject -from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit import ( + ComelitSerialBridgeObject, + ComelitVedoAreaObject, + ComelitVedoZoneObject, +) from aiocomelit.const import ( CLIMATE, COVER, @@ -9,37 +12,20 @@ from aiocomelit.const import ( LIGHT, OTHER, SCENARIO, - VEDO, WATT, AlarmAreaState, AlarmZoneState, ) -from homeassistant.components.comelit.const import DOMAIN -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE +BRIDGE_HOST = "fake_bridge_host" +BRIDGE_PORT = 80 +BRIDGE_PIN = 1234 -MOCK_CONFIG = { - DOMAIN: { - CONF_DEVICES: [ - { - CONF_HOST: "fake_host", - CONF_PORT: 80, - CONF_PIN: 1234, - }, - { - CONF_HOST: "fake_vedo_host", - CONF_PORT: 8080, - CONF_PIN: 1234, - CONF_TYPE: VEDO, - }, - ] - } -} +VEDO_HOST = "fake_vedo_host" +VEDO_PORT = 8080 +VEDO_PIN = 5678 -MOCK_USER_BRIDGE_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] -MOCK_USER_VEDO_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][1] - -FAKE_PIN = 5678 +FAKE_PIN = 0000 BRIDGE_DEVICE_QUERY = { CLIMATE: {}, diff --git a/tests/components/comelit/snapshots/test_diagnostics.ambr b/tests/components/comelit/snapshots/test_diagnostics.ambr index 877f48a4611..b9891eb3209 100644 --- a/tests/components/comelit/snapshots/test_diagnostics.ambr +++ b/tests/components/comelit/snapshots/test_diagnostics.ambr @@ -57,9 +57,10 @@ }), 'entry': dict({ 'data': dict({ - 'host': 'fake_host', + 'host': 'fake_bridge_host', 'pin': '**REDACTED**', 'port': 80, + 'type': 'Serial bridge', }), 'disabled_by': None, 'discovery_keys': dict({ diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index eeaea0e41e9..dd1d1fb3836 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -1,59 +1,93 @@ """Tests for Comelit SimpleHome config flow.""" -from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock from aiocomelit import CannotAuthenticate, CannotConnect +from aiocomelit.const import BRIDGE, VEDO import pytest from homeassistant.components.comelit.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import FAKE_PIN, MOCK_USER_BRIDGE_DATA, MOCK_USER_VEDO_DATA +from .const import ( + BRIDGE_HOST, + BRIDGE_PIN, + BRIDGE_PORT, + FAKE_PIN, + VEDO_HOST, + VEDO_PIN, + VEDO_PORT, +) from tests.common import MockConfigEntry -@pytest.mark.parametrize( - ("class_api", "user_input"), - [ - ("ComeliteSerialBridgeApi", MOCK_USER_BRIDGE_DATA), - ("ComelitVedoApi", MOCK_USER_VEDO_DATA), - ], -) -async def test_full_flow( - hass: HomeAssistant, class_api: str, user_input: dict[str, Any] +async def test_flow_serial_bridge( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, ) -> None: """Test starting a flow by user.""" - with ( - patch( - f"aiocomelit.api.{class_api}.login", - ), - patch( - f"aiocomelit.api.{class_api}.logout", - ), - patch("homeassistant.components.comelit.async_setup_entry") as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=user_input - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_HOST] == user_input[CONF_HOST] - assert result["data"][CONF_PORT] == user_input[CONF_PORT] - assert result["data"][CONF_PIN] == user_input[CONF_PIN] - assert not result["result"].unique_id - await hass.async_block_till_done() + 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 mock_setup_entry.called + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + CONF_TYPE: BRIDGE, + } + assert not result["result"].unique_id + await hass.async_block_till_done() + + +async def test_flow_vedo( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: + """Test starting a flow by user.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + } + assert not result["result"].unique_id + await hass.async_block_till_done() @pytest.mark.parametrize( @@ -64,7 +98,13 @@ async def test_full_flow( (ConnectionResetError, "unknown"), ], ) -async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> None: +async def test_exception_connection( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + side_effect, + error, +) -> None: """Test starting a flow by user with a connection error.""" result = await hass.config_entries.flow.async_init( @@ -73,59 +113,65 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "user" - with ( - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.login", - side_effect=side_effect, - ), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), - patch( - "homeassistant.components.comelit.async_setup_entry", - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_BRIDGE_DATA - ) + mock_vedo.login.side_effect = side_effect - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] is not None - assert result["errors"]["base"] == error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_vedo.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == VEDO_HOST + assert result["data"] == { + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + } -async def test_reauth_successful(hass: HomeAssistant) -> None: +async def test_reauth_successful( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: """Test starting a reauthentication flow.""" - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) - mock_config.add_to_hass(hass) - result = await mock_config.start_reauth_flow(hass) + mock_vedo_config_entry.add_to_hass(hass) + result = await mock_vedo_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.login", - ), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), - patch("homeassistant.components.comelit.async_setup_entry"), - patch("requests.get") as mock_request_get, - ): - mock_request_get.return_value.status_code = 200 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: FAKE_PIN, + }, + ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PIN: FAKE_PIN, - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" @pytest.mark.parametrize( @@ -136,30 +182,40 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: (ConnectionResetError, "unknown"), ], ) -async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> None: +async def test_reauth_not_successful( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: """Test starting a reauthentication flow but no connection found.""" - - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) - mock_config.add_to_hass(hass) - result = await mock_config.start_reauth_flow(hass) + mock_vedo_config_entry.add_to_hass(hass) + result = await mock_vedo_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch("aiocomelit.api.ComeliteSerialBridgeApi.login", side_effect=side_effect), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), - patch("homeassistant.components.comelit.async_setup_entry"), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PIN: FAKE_PIN, - }, - ) + mock_vedo.login.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: FAKE_PIN, + }, + ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] is not None - assert result["errors"]["base"] == error + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} + + mock_vedo.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: VEDO_PIN, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_vedo_config_entry.data[CONF_PIN] == VEDO_PIN diff --git a/tests/components/comelit/test_diagnostics.py b/tests/components/comelit/test_diagnostics.py index 39d75af1152..cabcd0f4cac 100644 --- a/tests/components/comelit/test_diagnostics.py +++ b/tests/components/comelit/test_diagnostics.py @@ -2,21 +2,14 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock from syrupy import SnapshotAssertion from syrupy.filters import props -from homeassistant.components.comelit.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .const import ( - BRIDGE_DEVICE_QUERY, - MOCK_USER_BRIDGE_DATA, - MOCK_USER_VEDO_DATA, - VEDO_DEVICE_QUERY, -) +from . import setup_integration from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -25,25 +18,17 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics_bridge( hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, ) -> None: """Test Bridge config entry diagnostics.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_serial_bridge_config_entry) - with ( - patch("aiocomelit.api.ComeliteSerialBridgeApi.login"), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.get_all_devices", - return_value=BRIDGE_DEVICE_QUERY, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state == ConfigEntryState.LOADED - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_serial_bridge_config_entry + ) == snapshot( exclude=props( "entry_id", "created_at", @@ -54,25 +39,17 @@ async def test_entry_diagnostics_bridge( async def test_entry_diagnostics_vedo( hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, ) -> None: """Test Vedo System config entry diagnostics.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_VEDO_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_vedo_config_entry) - with ( - patch("aiocomelit.api.ComelitVedoApi.login"), - patch( - "aiocomelit.api.ComelitVedoApi.get_all_areas_and_zones", - return_value=VEDO_DEVICE_QUERY, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state == ConfigEntryState.LOADED - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_vedo_config_entry + ) == snapshot( exclude=props( "entry_id", "created_at", From 6ce48eab45564934a6648b23ca8e8b4348600b5c Mon Sep 17 00:00:00 2001 From: rappenze Date: Fri, 28 Feb 2025 20:47:03 +0100 Subject: [PATCH 2897/2987] Use new pyfibaro library features (#139476) --- homeassistant/components/fibaro/__init__.py | 113 +++++++----------- .../components/fibaro/config_flow.py | 17 +-- tests/components/fibaro/conftest.py | 7 +- tests/components/fibaro/test_config_flow.py | 58 +++------ 4 files changed, 76 insertions(+), 119 deletions(-) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 8ede0169482..9a521e27486 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -7,21 +7,20 @@ from collections.abc import Callable, Mapping import logging from typing import Any -from pyfibaro.fibaro_client import FibaroClient +from pyfibaro.fibaro_client import ( + FibaroAuthenticationFailed, + FibaroClient, + FibaroConnectFailed, +) from pyfibaro.fibaro_device import DeviceModel -from pyfibaro.fibaro_room import RoomModel +from pyfibaro.fibaro_info import InfoModel from pyfibaro.fibaro_scene import SceneModel from pyfibaro.fibaro_state_resolver import FibaroEvent, FibaroStateResolver -from requests.exceptions import HTTPError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo from homeassistant.util import slugify @@ -74,63 +73,31 @@ FIBARO_TYPEMAP = { class FibaroController: """Initiate Fibaro Controller Class.""" - def __init__(self, config: Mapping[str, Any]) -> None: + def __init__( + self, fibaro_client: FibaroClient, info: InfoModel, import_plugins: bool + ) -> None: """Initialize the Fibaro controller.""" - - # The FibaroClient uses the correct API version automatically - self._client = FibaroClient(config[CONF_URL]) - self._client.set_authentication(config[CONF_USERNAME], config[CONF_PASSWORD]) + self._client = fibaro_client + self._fibaro_info = info # Whether to import devices from plugins - self._import_plugins = config[CONF_IMPORT_PLUGINS] - self._room_map: dict[int, RoomModel] # Mapping roomId to room object + self._import_plugins = import_plugins + # Mapping roomId to room object + self._room_map = {room.fibaro_id: room for room in self._client.read_rooms()} self._device_map: dict[int, DeviceModel] # Mapping deviceId to device object self.fibaro_devices: dict[Platform, list[DeviceModel]] = defaultdict( list ) # List of devices by entity platform # All scenes - self._scenes: list[SceneModel] = [] + self._scenes = self._client.read_scenes() self._callbacks: dict[int, list[Any]] = {} # Update value callbacks by deviceId # Event callbacks by device id self._event_callbacks: dict[int, list[Callable[[FibaroEvent], None]]] = {} - self.hub_serial: str # Unique serial number of the hub - self.hub_name: str # The friendly name of the hub - self.hub_model: str - self.hub_software_version: str - self.hub_api_url: str = config[CONF_URL] + # Unique serial number of the hub + self.hub_serial = info.serial_number # Device infos by fibaro device id self._device_infos: dict[int, DeviceInfo] = {} - - def connect(self) -> None: - """Start the communication with the Fibaro controller.""" - - # Return value doesn't need to be checked, - # it is only relevant when connecting without credentials - self._client.connect() - info = self._client.read_info() - self.hub_serial = info.serial_number - self.hub_name = info.hc_name - self.hub_model = info.platform - self.hub_software_version = info.current_version - - self._room_map = {room.fibaro_id: room for room in self._client.read_rooms()} self._read_devices() - self._scenes = self._client.read_scenes() - - def connect_with_error_handling(self) -> None: - """Translate connect errors to easily differentiate auth and connect failures. - - When there is a better error handling in the used library this can be improved. - """ - try: - self.connect() - except HTTPError as http_ex: - if http_ex.response.status_code == 403: - raise FibaroAuthFailed from http_ex - - raise FibaroConnectFailed from http_ex - except Exception as ex: - raise FibaroConnectFailed from ex def enable_state_handler(self) -> None: """Start StateHandler thread for monitoring updates.""" @@ -310,6 +277,14 @@ class FibaroController: """Return list of scenes.""" return self._scenes + def read_fibaro_info(self) -> InfoModel: + """Return the general info about the hub.""" + return self._fibaro_info + + def get_frontend_url(self) -> str: + """Return the url to the Fibaro hub web UI.""" + return self._client.frontend_url() + def _read_devices(self) -> None: """Read and process the device list.""" devices = self._client.read_devices() @@ -375,11 +350,17 @@ class FibaroController: pass +def connect_fibaro_client(data: Mapping[str, Any]) -> tuple[InfoModel, FibaroClient]: + """Connect to the fibaro hub and read some basic data.""" + client = FibaroClient(data[CONF_URL]) + info = client.connect_with_credentials(data[CONF_USERNAME], data[CONF_PASSWORD]) + return (info, client) + + def init_controller(data: Mapping[str, Any]) -> FibaroController: - """Validate the user input allows us to connect to fibaro.""" - controller = FibaroController(data) - controller.connect_with_error_handling() - return controller + """Connect to the fibaro hub and init the controller.""" + info, client = connect_fibaro_client(data) + return FibaroController(client, info, data[CONF_IMPORT_PLUGINS]) async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bool: @@ -393,22 +374,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bo raise ConfigEntryNotReady( f"Could not connect to controller at {entry.data[CONF_URL]}" ) from connect_ex - except FibaroAuthFailed as auth_ex: + except FibaroAuthenticationFailed as auth_ex: raise ConfigEntryAuthFailed from auth_ex entry.runtime_data = controller # register the hub device info separately as the hub has sometimes no entities + fibaro_info = controller.read_fibaro_info() device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, controller.hub_serial)}, serial_number=controller.hub_serial, - manufacturer="Fibaro", - name=controller.hub_name, - model=controller.hub_model, - sw_version=controller.hub_software_version, - configuration_url=controller.hub_api_url.removesuffix("/api/"), + manufacturer=fibaro_info.manufacturer_name, + name=fibaro_info.hc_name, + model=fibaro_info.model_name, + sw_version=fibaro_info.current_version, + configuration_url=controller.get_frontend_url(), + connections={(dr.CONNECTION_NETWORK_MAC, fibaro_info.mac_address)}, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -443,11 +426,3 @@ async def async_remove_config_entry_device( return False return True - - -class FibaroConnectFailed(HomeAssistantError): - """Error to indicate we cannot connect to fibaro home center.""" - - -class FibaroAuthFailed(HomeAssistantError): - """Error to indicate that authentication failed on fibaro home center.""" diff --git a/homeassistant/components/fibaro/config_flow.py b/homeassistant/components/fibaro/config_flow.py index 0ffd9aaa48f..d941ceab37f 100644 --- a/homeassistant/components/fibaro/config_flow.py +++ b/homeassistant/components/fibaro/config_flow.py @@ -6,6 +6,7 @@ from collections.abc import Mapping import logging from typing import Any +from pyfibaro.fibaro_client import FibaroAuthenticationFailed, FibaroConnectFailed from slugify import slugify import voluptuous as vol @@ -13,7 +14,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -from . import FibaroAuthFailed, FibaroConnectFailed, init_controller +from . import connect_fibaro_client from .const import CONF_IMPORT_PLUGINS, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -33,16 +34,16 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - controller = await hass.async_add_executor_job(init_controller, data) + info, _ = await hass.async_add_executor_job(connect_fibaro_client, data) _LOGGER.debug( "Successfully connected to fibaro home center %s with name %s", - controller.hub_serial, - controller.hub_name, + info.serial_number, + info.hc_name, ) return { - "serial_number": slugify(controller.hub_serial), - "name": controller.hub_name, + "serial_number": slugify(info.serial_number), + "name": info.hc_name, } @@ -75,7 +76,7 @@ class FibaroConfigFlow(ConfigFlow, domain=DOMAIN): info = await _validate_input(self.hass, user_input) except FibaroConnectFailed: errors["base"] = "cannot_connect" - except FibaroAuthFailed: + except FibaroAuthenticationFailed: errors["base"] = "invalid_auth" else: await self.async_set_unique_id(info["serial_number"]) @@ -106,7 +107,7 @@ class FibaroConfigFlow(ConfigFlow, domain=DOMAIN): await _validate_input(self.hass, new_data) except FibaroConnectFailed: errors["base"] = "cannot_connect" - except FibaroAuthFailed: + except FibaroAuthenticationFailed: errors["base"] = "invalid_auth" else: return self.async_update_reload_and_abort( diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 583c44a41e6..17357e34198 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -209,19 +209,22 @@ def mock_fibaro_client() -> Generator[Mock]: info_mock.hc_name = TEST_NAME info_mock.current_version = TEST_VERSION info_mock.platform = TEST_MODEL + info_mock.manufacturer_name = "Fibaro" + info_mock.model_name = "Home Center 2" + info_mock.mac_address = "00:22:4d:b7:13:24" with patch( "homeassistant.components.fibaro.FibaroClient", autospec=True ) as fibaro_client_mock: client = fibaro_client_mock.return_value - client.set_authentication.return_value = None - client.connect.return_value = True + client.connect_with_credentials.return_value = info_mock client.read_info.return_value = info_mock client.read_rooms.return_value = [] client.read_scenes.return_value = [] client.read_devices.return_value = [] client.register_update_handler.return_value = None client.unregister_update_handler.return_value = None + client.frontend_url.return_value = TEST_URL.removesuffix("/api/") yield client diff --git a/tests/components/fibaro/test_config_flow.py b/tests/components/fibaro/test_config_flow.py index 508bb81973d..aee7c2eb903 100644 --- a/tests/components/fibaro/test_config_flow.py +++ b/tests/components/fibaro/test_config_flow.py @@ -2,8 +2,8 @@ from unittest.mock import Mock +from pyfibaro.fibaro_client import FibaroAuthenticationFailed, FibaroConnectFailed import pytest -from requests.exceptions import HTTPError from homeassistant import config_entries from homeassistant.components.fibaro import DOMAIN @@ -23,8 +23,10 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_fibaro_client") async def _recovery_after_failure_works( hass: HomeAssistant, mock_fibaro_client: Mock, result: FlowResult ) -> None: - mock_fibaro_client.connect.side_effect = None - mock_fibaro_client.connect.return_value = True + mock_fibaro_client.connect_with_credentials.side_effect = None + mock_fibaro_client.connect_with_credentials.return_value = ( + mock_fibaro_client.read_info() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -48,8 +50,10 @@ async def _recovery_after_failure_works( async def _recovery_after_reauth_failure_works( hass: HomeAssistant, mock_fibaro_client: Mock, result: FlowResult ) -> None: - mock_fibaro_client.connect.side_effect = None - mock_fibaro_client.connect.return_value = True + mock_fibaro_client.connect_with_credentials.side_effect = None + mock_fibaro_client.connect_with_credentials.return_value = ( + mock_fibaro_client.read_info() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -101,7 +105,9 @@ async def test_config_flow_user_initiated_auth_failure( assert result["step_id"] == "user" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=403)) + mock_fibaro_client.connect_with_credentials.side_effect = ( + FibaroAuthenticationFailed() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -119,7 +125,7 @@ async def test_config_flow_user_initiated_auth_failure( await _recovery_after_failure_works(hass, mock_fibaro_client, result) -async def test_config_flow_user_initiated_unknown_failure_1( +async def test_config_flow_user_initiated_connect_failure( hass: HomeAssistant, mock_fibaro_client: Mock ) -> None: """Unknown failure in flow manually initialized by the user.""" @@ -131,37 +137,7 @@ async def test_config_flow_user_initiated_unknown_failure_1( assert result["step_id"] == "user" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=500)) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} - - await _recovery_after_failure_works(hass, mock_fibaro_client, result) - - -async def test_config_flow_user_initiated_unknown_failure_2( - hass: HomeAssistant, mock_fibaro_client: Mock -) -> None: - """Unknown failure in flow manually initialized by the user.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - mock_fibaro_client.connect.side_effect = Exception() + mock_fibaro_client.connect_with_credentials.side_effect = FibaroConnectFailed() result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -208,7 +184,7 @@ async def test_reauth_connect_failure( assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = Exception() + mock_fibaro_client.connect_with_credentials.side_effect = FibaroConnectFailed() result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -233,7 +209,9 @@ async def test_reauth_auth_failure( assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=403)) + mock_fibaro_client.connect_with_credentials.side_effect = ( + FibaroAuthenticationFailed() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], From 5a6ffe19013cac6ce373ea54c12b80b983bd2ae9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Feb 2025 19:49:31 +0000 Subject: [PATCH 2898/2987] Update Bluetooth remote config entries if the MAC is corrected (#139457) * fix ble mac * fixes * fixes * fixes * restore deleted test --- .../components/bluetooth/__init__.py | 19 +++++-- .../components/bluetooth/config_flow.py | 18 +++++-- .../components/bluetooth/test_config_flow.py | 37 ++++++++++++++ tests/components/bluetooth/test_init.py | 49 +++++++++++++++++++ 4 files changed, 117 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index c46ef22803e..7abc929fde5 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -311,11 +311,24 @@ async def async_update_device( update the device with the new location so they can figure out where the adapter is. """ + address = details[ADAPTER_ADDRESS] + connections = {(dr.CONNECTION_BLUETOOTH, address)} device_registry = dr.async_get(hass) + # We only have one device for the config entry + # so if the address has been corrected, make + # sure the device entry reflects the correct + # address + for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + for conn_type, conn_value in device.connections: + if conn_type == dr.CONNECTION_BLUETOOTH and conn_value != address: + device_registry.async_update_device( + device.id, new_connections=connections + ) + break device_entry = device_registry.async_get_or_create( config_entry_id=entry.entry_id, - name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]), - connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])}, + name=adapter_human_name(adapter, address), + connections=connections, manufacturer=details[ADAPTER_MANUFACTURER], model=adapter_model(details), sw_version=details.get(ADAPTER_SW_VERSION), @@ -342,9 +355,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) ) + return True address = entry.unique_id assert address is not None - assert source_entry is not None source_domain = entry.data[CONF_SOURCE_DOMAIN] if mac_manufacturer := await get_manufacturer_from_mac(address): manufacturer = f"{mac_manufacturer} ({source_domain})" diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index e76277306f5..328707bd722 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -186,16 +186,28 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by an external scanner.""" source = user_input[CONF_SOURCE] await self.async_set_unique_id(source) + source_config_entry_id = user_input[CONF_SOURCE_CONFIG_ENTRY_ID] data = { CONF_SOURCE: source, CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL], CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN], - CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID], + CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id, CONF_SOURCE_DEVICE_ID: user_input[CONF_SOURCE_DEVICE_ID], } self._abort_if_unique_id_configured(updates=data) - manager = get_manager() - scanner = manager.async_scanner_by_source(source) + for entry in self._async_current_entries(include_ignore=False): + # If the mac address needs to be corrected, migrate + # the config entry to the new mac address + if ( + entry.data.get(CONF_SOURCE_CONFIG_ENTRY_ID) == source_config_entry_id + and entry.unique_id != source + ): + self.hass.config_entries.async_update_entry( + entry, unique_id=source, data={**entry.data, **data} + ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="already_configured") + scanner = get_manager().async_scanner_by_source(source) assert scanner is not None return self.async_create_entry(title=scanner.name, data=data) diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index f0136396c22..45d177de132 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -608,3 +608,40 @@ async def test_async_step_integration_discovery_remote_adapter( await hass.async_block_till_done() cancel_scanner() await hass.async_block_till_done() + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_step_integration_discovery_remote_adapter_mac_fix( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test remote adapter corrects mac address via integration discovery.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + bluetooth_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOURCE: "AA:BB:CC:DD:EE:FF", + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + CONF_SOURCE_DEVICE_ID: None, + }, + ) + bluetooth_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_SOURCE: "AA:AA:AA:AA:AA:AA", + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + CONF_SOURCE_DEVICE_ID: None, + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert bluetooth_entry.unique_id == "AA:AA:AA:AA:AA:AA" + assert bluetooth_entry.data[CONF_SOURCE] == "AA:AA:AA:AA:AA:AA" diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 2c8c9e70e7f..de299c58b93 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -3300,3 +3300,52 @@ async def test_cleanup_orphened_remote_scanner_config_entry( assert not hass.config_entries.async_entry_for_domain_unique_id( "bluetooth", scanner.source ) + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_fix_incorrect_mac_remote_scanner_config_entry( + hass: HomeAssistant, +) -> None: + """Test the remote scanner config entries can replace a incorrect mac.""" + source_entry = MockConfigEntry(domain="test") + source_entry.add_to_hass(hass) + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeRemoteScanner("AA:BB:CC:DD:EE:FF", "esp32", connector, True) + assert scanner.source == "AA:BB:CC:DD:EE:FF" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: source_entry.entry_id, + }, + unique_id=scanner.source, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) + await hass.config_entries.async_unload(entry.entry_id) + + new_scanner = FakeRemoteScanner("AA:BB:CC:DD:EE:AA", "esp32", connector, True) + assert new_scanner.source == "AA:BB:CC:DD:EE:AA" + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_SOURCE: new_scanner.source}, + unique_id=new_scanner.source, + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", new_scanner.source + ) + # Incorrect connection should be removed + assert not hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) From 0f615bbe4f25094c503192ff7e363e2ce8748090 Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Fri, 28 Feb 2025 11:50:39 -0800 Subject: [PATCH 2899/2987] Add OptionsFlowHandler test for Lutron (#139463) --- tests/components/lutron/test_config_flow.py | 42 ++++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/tests/components/lutron/test_config_flow.py b/tests/components/lutron/test_config_flow.py index 47b2a4891cf..df861fafffe 100644 --- a/tests/components/lutron/test_config_flow.py +++ b/tests/components/lutron/test_config_flow.py @@ -6,11 +6,11 @@ from urllib.error import HTTPError import pytest -from homeassistant.components.lutron.const import DOMAIN +from homeassistant.components.lutron.const import CONF_DEFAULT_DIMMER_LEVEL, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.data_entry_flow import FlowResultType, InvalidData from tests.common import MockConfigEntry @@ -146,3 +146,41 @@ MOCK_DATA_IMPORT = { CONF_USERNAME: "lutron", CONF_PASSWORD: "integration", } + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_DATA_STEP, + unique_id="12345678901", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # Try to set an out of range dimmer level (260) + out_of_range_level = 260 + + # The voluptuous validation will raise an exception before the handler processes it + with pytest.raises(InvalidData): + await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_DEFAULT_DIMMER_LEVEL: out_of_range_level}, + ) + + # Now try with a valid value + valid_level = 100 + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_DEFAULT_DIMMER_LEVEL: valid_level}, + ) + + # Verify that the flow finishes successfully with the valid value + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_DEFAULT_DIMMER_LEVEL: valid_level} From 32950df0b700a74346a5c43a90675dfe525be9ba Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 28 Feb 2025 20:51:56 +0100 Subject: [PATCH 2900/2987] Specify recorder as after dependency in sql integration (#139037) * Specify recorder as after dependency in sql integration * Remove hassfest exception --------- Co-authored-by: J. Nick Koston --- homeassistant/components/sql/manifest.json | 1 + script/hassfest/dependencies.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index c18b1b9f05f..2b00a5b0d65 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -1,6 +1,7 @@ { "domain": "sql", "name": "SQL", + "after_dependencies": ["recorder"], "codeowners": ["@gjohansson-ST", "@dougiteixeira"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 368c2f762b8..b22027500dd 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -153,8 +153,6 @@ ALLOWED_USED_COMPONENTS = { } IGNORE_VIOLATIONS = { - # Has same requirement, gets defaults. - ("sql", "recorder"), # Sharing a base class ("lutron_caseta", "lutron"), ("ffmpeg_noise", "ffmpeg_motion"), From c21234672dc5f1ee169502a49a7a0f94789f2c10 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 28 Feb 2025 20:56:43 +0100 Subject: [PATCH 2901/2987] Ensure Hue bridge is added first to the device registry (#139438) --- homeassistant/components/hue/v2/device.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index 25a027f9ebe..7bb3d28e962 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -94,7 +94,12 @@ async def async_setup_devices(bridge: HueBridge): add_device(hue_resource) # create/update all current devices found in controllers - known_devices = [add_device(hue_device) for hue_device in dev_controller] + # sort the devices to ensure bridges are added first + hue_devices = list(dev_controller) + hue_devices.sort( + key=lambda dev: dev.metadata.archetype != DeviceArchetypes.BRIDGE_V2 + ) + known_devices = [add_device(hue_device) for hue_device in hue_devices] known_devices += [add_device(hue_room) for hue_room in api.groups.room] known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone] From ed06831e9d401d15b85d986bfff31bfdd30cdc90 Mon Sep 17 00:00:00 2001 From: StaleLoafOfBread <45444205+StaleLoafOfBread@users.noreply.github.com> Date: Fri, 28 Feb 2025 14:59:35 -0500 Subject: [PATCH 2902/2987] Fix alert not respecting can_acknowledge setting (#139483) * fix(alert): check can_ack prior to acking * fix(alert): add test for when can_acknowledge=False * fix(alert): warn on can_ack blocking an ack * Raise error when trying to acknowledge alert with can_acknowledge set to False * Rewrite can_ack check as guard Co-authored-by: Franck Nijhof * Make can_ack service error msg human readable because it will show up in the UI * format with ruff * Make pytest aware of service error when acking an unackable alert --------- Co-authored-by: Franck Nijhof --- homeassistant/components/alert/entity.py | 5 ++-- tests/components/alert/test_init.py | 30 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alert/entity.py b/homeassistant/components/alert/entity.py index 629047b15ba..a11b281428f 100644 --- a/homeassistant/components/alert/entity.py +++ b/homeassistant/components/alert/entity.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import STATE_IDLE, STATE_OFF, STATE_ON from homeassistant.core import Event, EventStateChangedData, HassJob, HomeAssistant -from homeassistant.exceptions import ServiceNotFound +from homeassistant.exceptions import ServiceNotFound, ServiceValidationError from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import ( async_track_point_in_time, @@ -195,7 +195,8 @@ class AlertEntity(Entity): async def async_turn_off(self, **kwargs: Any) -> None: """Async Acknowledge alert.""" - LOGGER.debug("Acknowledged Alert: %s", self._attr_name) + if not self._can_ack: + raise ServiceValidationError("This alert cannot be acknowledged") self._ack = True self.async_write_ha_state() diff --git a/tests/components/alert/test_init.py b/tests/components/alert/test_init.py index 27997a093e5..4407775a582 100644 --- a/tests/components/alert/test_init.py +++ b/tests/components/alert/test_init.py @@ -28,6 +28,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component from tests.common import MockEntityPlatform, async_mock_service @@ -116,6 +117,35 @@ async def test_silence(hass: HomeAssistant, mock_notifier: list[ServiceCall]) -> assert hass.states.get(ENTITY_ID).state == STATE_ON +async def test_silence_can_acknowledge_false(hass: HomeAssistant) -> None: + """Test that attempting to silence an alert with can_acknowledge=False will not silence.""" + # Create copy of config where can_acknowledge is False + config = deepcopy(TEST_CONFIG) + config[DOMAIN][NAME]["can_acknowledge"] = False + + # Setup the alert component + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Ensure the alert is currently on + hass.states.async_set(ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == STATE_ON + + # Attempt to acknowledge + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + # The state should still be ON because can_acknowledge=False + assert hass.states.get(ENTITY_ID).state == STATE_ON + + async def test_reset(hass: HomeAssistant, mock_notifier: list[ServiceCall]) -> None: """Test resetting the alert.""" assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) From 3f48826370431da963c16746455b35cfba731db6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 21:06:45 +0100 Subject: [PATCH 2903/2987] Bump pysmartthings to 2.2.0 (#139539) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 5dd570f2751..0ca6c1f3b26 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.1.0"] + "requirements": ["pysmartthings==2.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index dbaa1bd3b1e..049b307e399 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.1.0 +pysmartthings==2.2.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 693e9002389..dbec7989182 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.1.0 +pysmartthings==2.2.0 # homeassistant.components.smarty pysmarty2==0.10.2 From 00b7c4f9ef74211e92f2f304312d5f7a39c66b41 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 22:30:57 +0100 Subject: [PATCH 2904/2987] Improve SmartThings OCF device info (#139547) --- homeassistant/components/smartthings/entity.py | 18 ++++++------------ .../smartthings/snapshots/test_init.ambr | 16 ++++++++-------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 1383196ce15..0d6ee32b473 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, cast +from typing import Any from pysmartthings import ( Attribute, @@ -44,19 +44,13 @@ class SmartThingsEntity(Entity): identifiers={(DOMAIN, device.device.device_id)}, name=device.device.label, ) - if (ocf := device.status[MAIN].get(Capability.OCF)) is not None: + if (ocf := device.device.ocf) is not None: self._attr_device_info.update( { - "manufacturer": cast( - str | None, ocf[Attribute.MANUFACTURER_NAME].value - ), - "model": cast(str | None, ocf[Attribute.MODEL_NUMBER].value), - "hw_version": cast( - str | None, ocf[Attribute.HARDWARE_VERSION].value - ), - "sw_version": cast( - str | None, ocf[Attribute.OCF_FIRMWARE_VERSION].value - ), + "manufacturer": ocf.manufacturer_name, + "model": ocf.model_number.split("|")[0], + "hw_version": ocf.hardware_version, + "sw_version": ocf.firmware_version, } ) diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 546d99a967f..0b5aeb57c18 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -219,7 +219,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', + 'model': 'ARTIK051_KRAC_18K', 'model_id': None, 'name': 'AC Office Granit', 'name_by_user': None, @@ -252,7 +252,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'ARA-WW-TP1-22-COMMON|10229641|60010523001511014600083200800000', + 'model': 'ARA-WW-TP1-22-COMMON', 'model_id': None, 'name': 'Aire Dormitorio Principal', 'name_by_user': None, @@ -285,7 +285,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'TP2X_DA-KS-MICROWAVE-0101X|40436241|50040100011411000200000000000000', + 'model': 'TP2X_DA-KS-MICROWAVE-0101X', 'model_id': None, 'name': 'Microwave', 'name_by_user': None, @@ -318,7 +318,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'TP2X_REF_20K|00115641|0004014D011411200103000020000000', + 'model': 'TP2X_REF_20K', 'model_id': None, 'name': 'Refrigerator', 'name_by_user': None, @@ -351,7 +351,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'powerbot_7000_17M|50016055|80010404011141000100000000000000', + 'model': 'powerbot_7000_17M', 'model_id': None, 'name': 'Robot vacuum', 'name_by_user': None, @@ -384,7 +384,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'DA_DW_A51_20_COMMON|30007242|40010201001311000101000000000000', + 'model': 'DA_DW_A51_20_COMMON', 'model_id': None, 'name': 'Dishwasher', 'name_by_user': None, @@ -417,7 +417,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000', + 'model': 'DA_WM_A51_20_COMMON', 'model_id': None, 'name': 'Dryer', 'name_by_user': None, @@ -450,7 +450,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000', + 'model': 'DA_WM_TP2_20_COMMON', 'model_id': None, 'name': 'Washer', 'name_by_user': None, From ac4c379a0ed98484ce88ca8317c8260964396731 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Feb 2025 21:42:33 +0000 Subject: [PATCH 2905/2987] Bump PySwitchBot to 0.56.1 (#139544) changelog: https://github.com/sblibs/pySwitchbot/compare/0.56.0...0.56.1 --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 92a1c25d6f5..567a33a8f43 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.56.0"] + "requirements": ["PySwitchbot==0.56.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 049b307e399..12fa6c7c7df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.56.0 +PySwitchbot==0.56.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dbec7989182..d11597c908c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.56.0 +PySwitchbot==0.56.1 # homeassistant.components.syncthru PySyncThru==0.8.0 From 2d6068b8426238a2c39b794d2d874c5fe3176d91 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 22:58:35 +0100 Subject: [PATCH 2906/2987] Create device for the hub in SmartThings (#139545) * Create device for the hub in SmartThings * Create device for the hub in SmartThings * Create device for the hub in SmartThings --- .../components/smartthings/__init__.py | 12 +- .../components/smartthings/entity.py | 5 + .../fixtures/device_status/hub.json | 3 + .../aeotec_home_energy_meter_gen5.json | 1 - .../fixtures/devices/base_electric_meter.json | 1 - .../fixtures/devices/centralite.json | 1 - .../fixtures/devices/contact_sensor.json | 1 - .../fixtures/devices/fake_fan.json | 1 - .../devices/ge_in_wall_smart_dimmer.json | 1 - .../smartthings/fixtures/devices/hub.json | 718 ++++++++++++++++++ .../smartthings/fixtures/devices/iphone.json | 1 - .../fixtures/devices/multipurpose_sensor.json | 1 - .../fixtures/devices/smart_plug.json | 1 - .../fixtures/devices/sonos_player.json | 1 - .../yale_push_button_deadbolt_lock.json | 1 - .../smartthings/snapshots/test_init.ambr | 33 + tests/components/smartthings/test_init.py | 34 +- 17 files changed, 802 insertions(+), 14 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/hub.json create mode 100644 tests/components/smartthings/fixtures/devices/hub.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index d6de1d3d252..2bacd476332 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -99,6 +99,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) except SmartThingsAuthenticationFailedError as err: raise ConfigEntryAuthFailed from err + device_registry = dr.async_get(hass) + for dev in device_status.values(): + for component in dev.device.components: + if component.id == MAIN and Capability.BRIDGE in component.capabilities: + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, dev.device.device_id)}, + name=dev.device.label, + ) scenes = { scene.scene_id: scene for scene in await client.get_scenes(location_id=entry.data[CONF_LOCATION_ID]) @@ -124,7 +133,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - device_registry = dr.async_get(hass) device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) for device_entry in device_entries: device_id = next( @@ -132,7 +140,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) for identifier in device_entry.identifiers if identifier[0] == DOMAIN ) - if device_id in entry.runtime_data.devices: + if device_id in device_status: continue device_registry.async_update_device( device_entry.id, remove_config_entry_id=entry.entry_id diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 0d6ee32b473..790f3672680 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -44,6 +44,11 @@ class SmartThingsEntity(Entity): identifiers={(DOMAIN, device.device.device_id)}, name=device.device.label, ) + if device.device.parent_device_id: + self._attr_device_info["via_device"] = ( + DOMAIN, + device.device.parent_device_id, + ) if (ocf := device.device.ocf) is not None: self._attr_device_info.update( { diff --git a/tests/components/smartthings/fixtures/device_status/hub.json b/tests/components/smartthings/fixtures/device_status/hub.json new file mode 100644 index 00000000000..98ff4c3a8b4 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/hub.json @@ -0,0 +1,3 @@ +{ + "components": {} +} diff --git a/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json b/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json index 5ef0e2fd9eb..ab2fe41c678 100644 --- a/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json +++ b/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json @@ -45,7 +45,6 @@ } ], "createTime": "2023-01-12T23:02:44.917Z", - "parentDeviceId": "6a2d07a4-dd77-48bc-9acf-017029aaf099", "profile": { "id": "6372c227-93c7-32ef-9be5-aef2221adff1" }, diff --git a/tests/components/smartthings/fixtures/devices/base_electric_meter.json b/tests/components/smartthings/fixtures/devices/base_electric_meter.json index 9e0c130978c..4d00d6f169c 100644 --- a/tests/components/smartthings/fixtures/devices/base_electric_meter.json +++ b/tests/components/smartthings/fixtures/devices/base_electric_meter.json @@ -37,7 +37,6 @@ } ], "createTime": "2023-06-03T16:23:57.284Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "d382796f-8ed5-3088-8735-eb03e962203b" }, diff --git a/tests/components/smartthings/fixtures/devices/centralite.json b/tests/components/smartthings/fixtures/devices/centralite.json index 68cdbdf4499..dff2be78f70 100644 --- a/tests/components/smartthings/fixtures/devices/centralite.json +++ b/tests/components/smartthings/fixtures/devices/centralite.json @@ -45,7 +45,6 @@ } ], "createTime": "2024-08-15T22:16:37.926Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "24195ea4-635c-3450-a235-71bc78ab3d1c" }, diff --git a/tests/components/smartthings/fixtures/devices/contact_sensor.json b/tests/components/smartthings/fixtures/devices/contact_sensor.json index a5de2e2cbfe..92fe6a8bbff 100644 --- a/tests/components/smartthings/fixtures/devices/contact_sensor.json +++ b/tests/components/smartthings/fixtures/devices/contact_sensor.json @@ -49,7 +49,6 @@ } ], "createTime": "2023-09-28T17:38:59.179Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "22aa5a07-ac33-365f-b2f1-5ecef8cdb0eb" }, diff --git a/tests/components/smartthings/fixtures/devices/fake_fan.json b/tests/components/smartthings/fixtures/devices/fake_fan.json index 7b8e174d420..8656e290c8d 100644 --- a/tests/components/smartthings/fixtures/devices/fake_fan.json +++ b/tests/components/smartthings/fixtures/devices/fake_fan.json @@ -36,7 +36,6 @@ } ], "createTime": "2023-01-12T23:02:44.917Z", - "parentDeviceId": "6a2dd7a4-dd77-48bc-9acf-017029aaf099", "profile": { "id": "6372cd27-93c7-32ef-9be5-aef2221adff1" }, diff --git a/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json index 910eacec2cc..314586300b9 100644 --- a/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json +++ b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json @@ -40,7 +40,6 @@ } ], "createTime": "2020-05-25T18:18:01Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "ec5458c2-c011-3479-a59b-82b42820c2f7" }, diff --git a/tests/components/smartthings/fixtures/devices/hub.json b/tests/components/smartthings/fixtures/devices/hub.json new file mode 100644 index 00000000000..4de0823d758 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/hub.json @@ -0,0 +1,718 @@ +{ + "items": [ + { + "deviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "name": "SmartThings v2 Hub", + "label": "Home Hub", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "63f1469e-dc4a-3689-8cc5-69e293c1eb21", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "f7f39cf6-ff3a-4bcb-8d1b-00a3324c016d", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "bridge", + "version": 1 + } + ], + "categories": [ + { + "name": "Hub", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2016-11-13T18:18:07Z", + "childDevices": [ + { + "deviceId": "0781c9d0-92cb-4c7b-bb5b-2f2dbe0c41f3", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "08ee0358-9f40-4afa-b5a0-3a6aba18c267", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "09076422-62cc-4b2d-8beb-b53bc451c704", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "0b5577db-5074-4b70-a2c5-efec286d264d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "115236ea-59e5-4cd4-bade-d67c409967bc", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "1691801c-ae59-438b-89dc-f2c761fe937d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "1a987293-0962-4447-99d4-aa82655ffb55", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "2533fdd0-e064-4fa2-b77b-1e17260b58d7", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "265e653b-3c0b-4fa6-8e2a-f6a69c7040f0", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "277e0a96-c8ec-41aa-b4cf-0bac57dc1cee", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "2d9a892b-1c93-45a5-84cb-0e81889498c6", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "374ba6fa-5a08-4ea2-969c-1fa43d86e21f", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "37c0cdda-9158-41ad-9635-4ca32df9fe5b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "3f82e13c-bd39-4043-bb54-7432a4e47113", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4339f999-1ad2-46fb-9103-cb628b30a022", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4a59f635-9f0a-4a6c-a2f0-ffb7ef182a7c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4c3469c9-3556-4f19-a2e1-1c0a598341dc", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4fddedf0-2662-476e-b1fd-aceaec17ad3a", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "550a1c72-65a0-4d55-b97b-75168e055398", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "630cf009-eb3b-409e-a77a-9b298540532f", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6356b240-c7d8-403c-883e-ae438d432abe", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "68e786a6-7f61-4c3a-9e13-70b803cf782b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6a2e5058-36f3-4668-aa43-49a66f8df93d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6b5535c7-c039-42ee-9970-8af86c6b0775", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6c1b7cfa-7429-4f35-9d02-ab1dfd2f1297", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6ca56087-481f-4e93-9727-fb91049fe396", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6e3e44b3-d84a-4efc-a97b-b5e0dae28ddc", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6f4d2e72-7af4-4c96-97ab-d6b6a0d6bc4b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7111243f-39d6-4ed0-a277-f040e40a806d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7b9d924a-de0c-44f9-ac5c-f15869c59411", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7bedac4c-5681-4897-a2ef-e9153cb19ba0", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7d246592-93db-4d72-a10d-5a51793ece8c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "803cb0d9-addd-4c2d-aaef-d4e20bf88228", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "829da938-6e92-4a93-8923-7c67f9663c03", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "84f1eaf0-592e-459a-a2b3-4fc43e004dae", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "8eacf25f-aa33-4d9e-ba90-0e4ac3ceb8e0", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "8f873071-a9aa-4580-a736-8f5f696e044a", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "91172212-e9ff-4ca6-9626-e7af0361c9ad", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "92138ee5-d3bf-4348-98e8-445dedc319cb", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "971b05df-6ed3-446e-b54f-5092eac01921", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "9a9cb299-5279-4dea-9249-b5c153d22ba1", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "9b479ba0-81e1-4877-87c5-c301a87cbdab", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "9dd17f8f-cf5e-4647-a11c-d8f24cdf9b2a", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "a1e6525c-1e24-403c-b18c-eecb65e22ccf", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "a9d42ef0-f972-44b0-86bc-efd6569a1aef", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "a9f587c5-5d8b-4273-8907-e7f609af5158", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "aaedaf28-2ae0-4c1d-b57e-87f6a420c298", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "b3a84295-ac3c-4fb1-95e4-4a4bbb1b0bce", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "b90c085d-7d1f-4abc-a66d-d5ce3f96be02", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "bafc5147-2e48-498b-97ff-34c93fae7814", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "c1107a0c-fa71-43c5-8ff9-a128ea6c4f20", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "c5209cd2-fcb5-46be-b685-5b05f22dcb2c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "c5699ff6-af09-4922-901d-bb81b8345bc3", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "cfcd9a21-a943-4519-9972-3c7890cd25b1", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "d0268a69-abfb-4c92-a646-61cec2e510ad", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "d20891e5-59b4-46ce-9184-b7fdf0c7ae4c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "d48848b9-25b0-4423-8fcf-96a022ac571e", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "ea2aa187-40fd-4140-9742-453e691c4469", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f27d0b27-24fd-4d8c-b003-d3d7aaba1e70", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f3c18803-cbec-48e3-8f15-3c31f302d68b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f3e184b2-631a-47b2-b583-32ac2fec9e3c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f4e0517a-d94f-4bd6-a464-222c8c413a66", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + } + ], + "profile": { + "id": "d77ba2f6-c377-36f5-bb68-15db9d1aa0e1" + }, + "hub": { + "hubEui": "D052A872947A0001", + "firmwareVersion": "000.055.00005", + "hubDrivers": [ + { + "driverVersion": "2025-01-19T15:05:25.835006968", + "driverId": "00425c55-0932-416f-a1ba-78fae98ab614", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-12-17T18:00:36.611958104", + "driverId": "01976eca-e7ff-4d1b-91db-9c980ce668d7", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-12-17T18:00:48.572636846", + "driverId": "0f206d13-508e-4342-9cbb-937e02489141", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-12-17T18:00:07.735400483", + "driverId": "2cbf55e3-dbc2-48a2-8be5-4c3ce756b692", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-11-04T22:39:17.976631549", + "driverId": "3fb97b6c-f481-441b-a14e-f270d738764e", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T18:17:51.437710641", + "driverId": "408981c2-91d4-4dfc-bbfb-84ca0205d993", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T18:17:35.032104982", + "driverId": "4eb5b19a-7bbc-452f-859b-c6d7d857b2da", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2023-08-08T18:58:32.479650566", + "driverId": "4fb7ec02-2697-4d73-977d-2b1c65c4484f", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-12-17T18:00:47.743217473", + "driverId": "572a2641-2af8-47e4-bfe5-ad83748fd7a1", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2023-07-12T03:33:26.23424277", + "driverId": "5ad2cc83-5503-4040-a98b-b0fc9931b9fe", + "channelId": "479886db-f6f5-41dd-979c-9c5f9366f070" + }, + { + "driverVersion": "2024-09-17T20:08:25.82515546", + "driverId": "5db3363a-d954-412f-93e0-2ee40572658b", + "channelId": "2423da55-101c-4b21-af58-0903656b85ca" + }, + { + "driverVersion": "2024-12-08T10:10:03.832334965", + "driverId": "6342be70-6da0-4535-afc1-ff6378d6c650", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2022-02-01T21:35:33.624882", + "driverId": "6a90f7a0-e275-4366-bbf2-2e8a502efc5d", + "channelId": "479886db-f6f5-41dd-979c-9c5f9366f070" + }, + { + "driverVersion": "2024-09-28T21:56:32.002090649", + "driverId": "7333473f-722c-465d-9e5d-f3a6ca760489", + "channelId": "f8900c5e-d591-4979-9826-75a867e9e0bd" + }, + { + "driverVersion": "2025-02-03T22:38:47.582952919", + "driverId": "7beb8bc2-8dfa-45c2-8fdb-7373d4597b12", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-11-15T16:18:24.739596514", + "driverId": "7ca45ba9-7cfe-4547-b752-fe41a0efb848", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-02-06T21:13:39.427465986", + "driverId": "8bf71a5d-677b-4391-93c2-e76471f3d7eb", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-21T19:06:49.949052991", + "driverId": "9050ac53-358c-47a1-a927-9a70f5f28cee", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T19:30:29.754256377", + "driverId": "92f39ab3-7b2f-47ee-94a7-ba47c4ee8a47", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-12-17T18:00:21.846431345", + "driverId": "9870bccd-2b3d-4edf-8940-532fcb11e946", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-12-09T21:10:00.424854506", + "driverId": "a6994e70-93de-4a76-8b5d-42971a3427c9", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2022-01-03T08:19:45.80869", + "driverId": "a89371c4-8765-404b-9de9-e9882cc48bd8", + "channelId": "14bcc056-f80d-416b-9445-467b0db325e3" + }, + { + "driverVersion": "2025-01-11T20:03:43.842469565", + "driverId": "b1504ded-efa4-4ef0-acd5-ae24e7a92e6e", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-12-08T09:45:01.460678797", + "driverId": "bb1b3fd4-dcba-4d55-8d85-58ed7f1979fb", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-11-04T22:39:18.253781754", + "driverId": "c21a6c77-872c-474e-be5b-5f6f11a240ef", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-01-30T21:36:15.547412569", + "driverId": "c856a3fd-69ee-4478-a224-d7279b6d978f", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2025-01-13T18:55:57.509807915", + "driverId": "cd898d81-6c27-4d27-a529-dfadc8caae5a", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-12-17T18:00:48.892833142", + "driverId": "ce930ffd-8155-4dca-aaa9-6c4158fc4278", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T19:30:41.208767469", + "driverId": "d620900d-f7bc-4ab5-a171-6dd159872f7d", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-10-10T19:30:33.46670456", + "driverId": "d6b43c85-1561-446b-9e3e-15e2ad81a952", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2023-07-11T18:43:49.169154271", + "driverId": "d9c3f8b8-c3c3-4b77-9ddd-01d08102c84b", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T18:17:54.195543653", + "driverId": "dbe192cb-f6a1-4369-a843-d1c42e5c91ba", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2022-10-02T20:15:49.147522379", + "driverId": "e120daf2-8000-4a9d-93fa-653214ce70d1", + "channelId": "479886db-f6f5-41dd-979c-9c5f9366f070" + }, + { + "driverVersion": "2023-08-15T20:08:28.115440571", + "driverId": "e7947a05-947d-4bb5-92c4-2aafaff6d69c", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2025-02-05T18:49:13.3338494", + "driverId": "f2e891c6-00cc-446c-9192-8ebda63d9898", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + } + ], + "hubData": { + "zwaveStaticDsk": "13740-14339-50623-49310-29679-58685-46457-16097", + "zwaveS2": true, + "hardwareType": "V2_HUB", + "hardwareId": "000D", + "zigbeeFirmware": "5.7.10", + "zigbee3": true, + "zigbeeOta": "ENABLED_WITH_LIGHTS", + "otaEnable": "true", + "zigbeeUnsecureRejoin": true, + "zigbeeRequiresExternalHardware": false, + "threadRequiresExternalHardware": false, + "failoverAvailability": "Unsupported", + "primarySupportAvailability": "Unsupported", + "secondarySupportAvailability": "Unsupported", + "zigbeeAvailability": "Available", + "zwaveAvailability": "Available", + "lanAvailability": "Available", + "matterAvailability": "Available", + "localVirtualDeviceAvailability": "Available", + "childDeviceAvailability": "Unsupported", + "edgeDriversAvailability": "Available", + "hubReplaceAvailability": "Available", + "hubLocalApiAvailability": "Available", + "zigbeeManualFirmwareUpdateSupported": true, + "matterRendezvousHedgeSupported": true, + "matterSoftwareComponentVersion": "1.3-0", + "matterDeviceDiagnosticsAvailability": "Available", + "zigbeeDeviceDiagnosticsAvailability": "Available", + "hedgeTlsCertificate": "", + "zigbeeChannel": "14", + "zigbeePanId": "0EE7", + "zigbeeEui": "D052A872947A0001", + "zigbeeNodeID": "0000", + "zwaveNodeID": "01", + "zwaveHomeID": "CF0F089E", + "zwaveSucID": "01", + "zwaveVersion": "6.10", + "zwaveRegion": "US", + "macAddress": "D0:52:A8:72:91:02", + "localIP": "192.168.168.189", + "zigbeeRadioFunctional": true, + "zwaveRadioFunctional": true, + "zigbeeRadioEnabled": true, + "zwaveRadioEnabled": true, + "zigbeeRadioDetected": true, + "zwaveRadioDetected": true + } + }, + "type": "HUB", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + }, + { + "deviceId": "374ba6fa-5a08-4ea2-969c-1fa43d86e21f", + "name": "Multipurpose Sensor", + "label": "Mail Box", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "c385e2bc-acb8-317b-be2a-6efd1f879720", + "deviceManufacturerCode": "SmartThings", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "f7f39cf6-ff3a-4bcb-8d1b-00a3324c016d", + "components": [ + { + "id": "main", + "label": "Mail Box", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "threeAxis", + "version": 1 + }, + { + "id": "accelerationSensor", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "MultiFunctionalSensor", + "categoryType": "manufacturer" + }, + { + "name": "MultiFunctionalSensor", + "categoryType": "user" + } + ] + } + ], + "createTime": "2022-08-16T21:08:09.983Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "4471213f-121b-38fd-b022-51df37ac1d4c" + }, + "zigbee": { + "eui": "24FD5B00010A3A95", + "networkId": "E71B", + "driverId": "408981c2-91d4-4dfc-bbfb-84ca0205d993", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/iphone.json b/tests/components/smartthings/fixtures/devices/iphone.json index 3fc26307c90..1ae79aa06ef 100644 --- a/tests/components/smartthings/fixtures/devices/iphone.json +++ b/tests/components/smartthings/fixtures/devices/iphone.json @@ -27,7 +27,6 @@ } ], "createTime": "2021-12-02T16:14:24.394Z", - "parentDeviceId": "b8e11599-5297-4574-8e62-885995fcaa20", "profile": { "id": "21d0f660-98b4-3f7b-8114-fe62e555628e" }, diff --git a/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json index 3770614a366..b056ecf007b 100644 --- a/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json +++ b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json @@ -56,7 +56,6 @@ } ], "createTime": "2019-02-23T16:53:57Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "4471213f-121b-38fd-b022-51df37ac1d4c" }, diff --git a/tests/components/smartthings/fixtures/devices/smart_plug.json b/tests/components/smartthings/fixtures/devices/smart_plug.json index 24d0fbc6e84..105ae43c3d0 100644 --- a/tests/components/smartthings/fixtures/devices/smart_plug.json +++ b/tests/components/smartthings/fixtures/devices/smart_plug.json @@ -37,7 +37,6 @@ } ], "createTime": "2018-10-05T12:23:14Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "daeff874-075a-32e3-8b11-bdb99d8e67c7" }, diff --git a/tests/components/smartthings/fixtures/devices/sonos_player.json b/tests/components/smartthings/fixtures/devices/sonos_player.json index 67d1ef24cf9..f7f54a01b49 100644 --- a/tests/components/smartthings/fixtures/devices/sonos_player.json +++ b/tests/components/smartthings/fixtures/devices/sonos_player.json @@ -61,7 +61,6 @@ } ], "createTime": "2025-02-02T13:18:28.570Z", - "parentDeviceId": "2f7f7d2b-e683-48ae-86f7-e57df6a0bce2", "profile": { "id": "0443d359-3f76-383f-82a4-6fc4a879ef1d" }, diff --git a/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json index e83a1be7644..117aa1344cb 100644 --- a/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json +++ b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json @@ -45,7 +45,6 @@ } ], "createTime": "2016-11-18T23:01:19Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "51b76691-3c3a-3fce-8c7c-4f9d50e5885a" }, diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 0b5aeb57c18..f3ed12a9a9a 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1022,3 +1022,36 @@ 'via_device_id': None, }) # --- +# name: test_hub_via_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '074fa784-8be8-4c70-8e22-6f5ed6f81b7e', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Home Hub', + '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/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 372f23eec42..3ffe2c11a42 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +from pysmartthings import DeviceResponse, DeviceStatus import pytest from syrupy import SnapshotAssertion @@ -11,7 +12,7 @@ from homeassistant.helpers import device_registry as dr from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture async def test_devices( @@ -50,3 +51,34 @@ async def test_removing_stale_devices( await hass.async_block_till_done() assert not device_registry.async_get_device({(DOMAIN, "aaa-bbb-ccc")}) + + +async def test_hub_via_device( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + mock_smartthings: AsyncMock, +) -> None: + """Test hub with child devices.""" + mock_smartthings.get_devices.return_value = DeviceResponse.from_json( + load_fixture("devices/hub.json", DOMAIN) + ).items + mock_smartthings.get_device_status.side_effect = [ + DeviceStatus.from_json( + load_fixture(f"device_status/{fixture}.json", DOMAIN) + ).components + for fixture in ("hub", "multipurpose_sensor") + ] + await setup_integration(hass, mock_config_entry) + + hub_device = device_registry.async_get_device( + {(DOMAIN, "074fa784-8be8-4c70-8e22-6f5ed6f81b7e")} + ) + assert hub_device == snapshot + assert ( + device_registry.async_get_device( + {(DOMAIN, "374ba6fa-5a08-4ea2-969c-1fa43d86e21f")} + ).via_device_id + == hub_device.id + ) From b43a7ff1fe5ea8d4f8099f6011e422ee58510d13 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 28 Feb 2025 22:01:39 +0000 Subject: [PATCH 2907/2987] Stream the TTS result from webview (#139543) --- homeassistant/components/tts/__init__.py | 36 ++++++++++++++++++------ 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 5b2da44eae2..32c4ba20670 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections.abc import AsyncGenerator from dataclasses import dataclass from datetime import datetime import hashlib @@ -379,7 +380,7 @@ class ResultStream: """Class that will stream the result when available.""" # Streaming/conversion properties - url: str + token: str extension: str content_type: str @@ -391,6 +392,11 @@ class ResultStream: _manager: SpeechManager + @cached_property + def url(self) -> str: + """Get the URL to stream the result.""" + return f"/api/tts_proxy/{self.token}" + @cached_property def _result_cache_key(self) -> asyncio.Future[str]: """Get the future that returns the cache key.""" @@ -401,11 +407,11 @@ class ResultStream: """Set cache key for message to be streamed.""" self._result_cache_key.set_result(cache_key) - async def async_get_result(self) -> bytes: + async def async_stream_result(self) -> AsyncGenerator[bytes]: """Get the stream of this result.""" cache_key = await self._result_cache_key _extension, data = await self._manager.async_get_tts_audio(cache_key) - return data + yield data def _hash_options(options: dict) -> str: @@ -603,7 +609,7 @@ class SpeechManager: token = f"{secrets.token_urlsafe(16)}.{extension}" content, _ = mimetypes.guess_type(token) result_stream = ResultStream( - url=f"/api/tts_proxy/{token}", + token=token, extension=extension, content_type=content or "audio/mpeg", use_file_cache=use_file_cache, @@ -1027,20 +1033,32 @@ class TextToSpeechView(HomeAssistantView): """Initialize a tts view.""" self.manager = manager - async def get(self, request: web.Request, token: str) -> web.Response: + async def get(self, request: web.Request, token: str) -> web.StreamResponse: """Start a get request.""" stream = self.manager.token_to_stream.get(token) if stream is None: return web.Response(status=HTTPStatus.NOT_FOUND) + response: web.StreamResponse | None = None try: - data = await stream.async_get_result() - except HomeAssistantError as err: - _LOGGER.error("Error on get tts: %s", err) + async for data in stream.async_stream_result(): + if response is None: + response = web.StreamResponse() + response.content_type = stream.content_type + await response.prepare(request) + + await response.write(data) + # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 + _LOGGER.error("Error streaming tts: %s", err) + + # Empty result or exception happened + if response is None: return web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR) - return web.Response(body=data, content_type=stream.content_type) + await response.write_eof() + return response @websocket_api.websocket_command( From b1ee019e3a99a4ecee17b3edf8bbedfcbd794683 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 23:02:06 +0100 Subject: [PATCH 2908/2987] Bump pysmartthings to 2.3.0 (#139546) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 0ca6c1f3b26..9fa6d28fa0a 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.2.0"] + "requirements": ["pysmartthings==2.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 12fa6c7c7df..bc33b7f17c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.2.0 +pysmartthings==2.3.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d11597c908c..a4620bc21de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.2.0 +pysmartthings==2.3.0 # homeassistant.components.smarty pysmarty2==0.10.2 From db05aa17d3675d82fc6fc28bcc442d114beb24ef Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 23:03:57 +0100 Subject: [PATCH 2909/2987] Add SmartThings Viper device info (#139548) --- .../components/smartthings/entity.py | 9 ++++ .../smartthings/snapshots/test_init.ambr | 50 +++++++++---------- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 790f3672680..0240549740f 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -58,6 +58,15 @@ class SmartThingsEntity(Entity): "sw_version": ocf.firmware_version, } ) + if (viper := device.device.viper) is not None: + self._attr_device_info.update( + { + "manufacturer": viper.manufacturer_name, + "model": viper.model_name, + "hw_version": viper.hardware_version, + "sw_version": viper.software_version, + } + ) async def async_added_to_hass(self) -> None: """Subscribe to updates.""" diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index f3ed12a9a9a..7f0e5c17cf2 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -86,8 +86,8 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Arlo', + 'model': 'VMC4041PB', 'model_id': None, 'name': '2nd Floor Hallway', 'name_by_user': None, @@ -108,7 +108,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'WoCurtain3-WoCurtain3', 'id': , 'identifiers': set({ tuple( @@ -119,8 +119,8 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'WonderLabs Company', + 'model': 'WoCurtain3', 'model_id': None, 'name': 'Curtain 1A', 'name_by_user': None, @@ -471,7 +471,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': '250206213001', 'id': , 'identifiers': set({ tuple( @@ -482,15 +482,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'ecobee', + 'model': 'aresSmart-ecobee3_remote_sensor', 'model_id': None, 'name': 'Child Bedroom', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '250206213001', 'via_device_id': None, }) # --- @@ -504,7 +504,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': '250206151734', 'id': , 'identifiers': set({ tuple( @@ -515,15 +515,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'ecobee', + 'model': 'aresSmart-thermostat', 'model_id': None, 'name': 'Main Floor', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '250206151734', 'via_device_id': None, }) # --- @@ -603,7 +603,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'LTG002', 'id': , 'identifiers': set({ tuple( @@ -614,15 +614,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Signify Netherlands B.V.', + 'model': 'Hue ambiance spot', 'model_id': None, 'name': 'Bathroom spot', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '1.122.2', 'via_device_id': None, }) # --- @@ -636,7 +636,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'LCA001', 'id': , 'identifiers': set({ tuple( @@ -647,15 +647,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Signify Netherlands B.V.', + 'model': 'Hue color lamp', 'model_id': None, 'name': 'Standing light', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '1.122.2', 'via_device_id': None, }) # --- @@ -735,7 +735,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'SKY40147', 'id': , 'identifiers': set({ tuple( @@ -746,15 +746,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Sensibo', + 'model': 'skyplus', 'model_id': None, 'name': 'Office', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': 'SKY40147', 'via_device_id': None, }) # --- From ee1fe2cae45f3b9524f48776182a7f397f4f8973 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Feb 2025 22:17:44 +0000 Subject: [PATCH 2910/2987] Bump bleak-esphome to 2.9.0 (#139467) * Bump bleak-esphome to 2.9.0 changelog: https://github.com/Bluetooth-Devices/bleak-esphome/compare/v2.8.0...v2.9.0 * fixes --- .../components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/__init__.py | 6 +- homeassistant/components/esphome/const.py | 1 + .../components/esphome/diagnostics.py | 8 ++- homeassistant/components/esphome/manager.py | 29 +++++++-- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/conftest.py | 15 +++++ tests/components/esphome/test_bluetooth.py | 20 +++--- tests/components/esphome/test_diagnostics.py | 7 ++- tests/components/esphome/test_manager.py | 63 +++++++++++++++++++ 12 files changed, 131 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 18dcbb5cb65..68781282d66 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.8.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.9.0"] } diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index fee2531fa20..1e1a2763b59 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN +from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN from .dashboard import async_setup as async_setup_dashboard from .domain_data import DomainData @@ -87,6 +87,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> None: """Remove an esphome config entry.""" - if mac_address := entry.unique_id: - async_remove_scanner(hass, mac_address.upper()) + if bluetooth_mac_address := entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS): + async_remove_scanner(hass, bluetooth_mac_address.upper()) await DomainData.get(hass).get_or_create_store(hass, entry).async_remove() diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index eb5f03c4495..a31f5441dbb 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -8,6 +8,7 @@ CONF_ALLOW_SERVICE_CALLS = "allow_service_calls" CONF_SUBSCRIBE_LOGS = "subscribe_logs" CONF_DEVICE_NAME = "device_name" CONF_NOISE_PSK = "noise_psk" +CONF_BLUETOOTH_MAC_ADDRESS = "bluetooth_mac_address" DEFAULT_ALLOW_SERVICE_CALLS = True DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py index c68bd560791..0903e874a15 100644 --- a/homeassistant/components/esphome/diagnostics.py +++ b/homeassistant/components/esphome/diagnostics.py @@ -25,13 +25,17 @@ async def async_get_config_entry_diagnostics( diag["config"] = config_entry.as_dict() entry_data = config_entry.runtime_data + device_info = entry_data.device_info if (storage_data := await entry_data.store.async_load()) is not None: diag["storage_data"] = storage_data if ( - config_entry.unique_id - and (scanner := async_scanner_by_source(hass, config_entry.unique_id.upper())) + device_info + and ( + scanner_mac := device_info.bluetooth_mac_address or device_info.mac_address + ) + and (scanner := async_scanner_by_source(hass, scanner_mac.upper())) and (bluetooth_device := entry_data.bluetooth_device) ): diag["bluetooth"] = { diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index e32bb7d6ded..0a47fb66815 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -63,6 +63,7 @@ from homeassistant.util.async_ import create_eager_task from .bluetooth import async_connect_scanner from .const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, CONF_SUBSCRIBE_LOGS, DEFAULT_ALLOW_SERVICE_CALLS, @@ -431,6 +432,13 @@ class ESPHomeManager: device_mac = format_mac(device_info.mac_address) mac_address_matches = unique_id == device_mac + if ( + bluetooth_mac_address := device_info.bluetooth_mac_address + ) and entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) != bluetooth_mac_address: + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_BLUETOOTH_MAC_ADDRESS: bluetooth_mac_address}, + ) # # Migrate config entry to new unique ID if the current # unique id is not a mac address. @@ -498,7 +506,9 @@ class ESPHomeManager: ) ) else: - bluetooth.async_remove_scanner(hass, device_info.mac_address) + bluetooth.async_remove_scanner( + hass, device_info.bluetooth_mac_address or device_info.mac_address + ) if device_info.voice_assistant_feature_flags_compat(api_version) and ( Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms @@ -617,11 +627,22 @@ class ESPHomeManager: ) _setup_services(hass, entry_data, services) - if entry_data.device_info is not None and entry_data.device_info.name: - reconnect_logic.name = entry_data.device_info.name + if (device_info := entry_data.device_info) is not None: + if device_info.name: + reconnect_logic.name = device_info.name + if ( + bluetooth_mac_address := device_info.bluetooth_mac_address + ) and entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) != bluetooth_mac_address: + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_BLUETOOTH_MAC_ADDRESS: bluetooth_mac_address, + }, + ) if entry.unique_id is None: hass.config_entries.async_update_entry( - entry, unique_id=format_mac(entry_data.device_info.mac_address) + entry, unique_id=format_mac(device_info.mac_address) ) await reconnect_logic.start() diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index b4360077604..b97878d11b5 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.3.1", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.8.0" + "bleak-esphome==2.9.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index bc33b7f17c7..8c64934cc45 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -603,7 +603,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.8.0 +bleak-esphome==2.9.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a4620bc21de..0a45a3c4015 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -534,7 +534,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.8.0 +bleak-esphome==2.9.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 94f621b8646..2786ed8324c 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -30,6 +30,7 @@ from zeroconf import Zeroconf from homeassistant.components.esphome import dashboard from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, CONF_NOISE_PSK, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, @@ -578,6 +579,19 @@ async def mock_bluetooth_entry( async def _mock_bluetooth_entry( bluetooth_proxy_feature_flags: BluetoothProxyFeature, ) -> MockESPHomeDevice: + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_BLUETOOTH_MAC_ADDRESS: "AA:BB:CC:DD:EE:FC", + }, + options={ + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS + }, + ) + entry.add_to_hass(hass) return await _mock_generic_device_entry( hass, mock_client, @@ -587,6 +601,7 @@ async def mock_bluetooth_entry( }, ([], []), [], + entry=entry, ) return _mock_bluetooth_entry diff --git a/tests/components/esphome/test_bluetooth.py b/tests/components/esphome/test_bluetooth.py index 19bc5a2e7c7..dd7a8f59fe5 100644 --- a/tests/components/esphome/test_bluetooth.py +++ b/tests/components/esphome/test_bluetooth.py @@ -13,7 +13,7 @@ async def test_bluetooth_connect_with_raw_adv( hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice ) -> None: """Test bluetooth connect with raw advertisements.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is not None assert scanner.connectable is True assert scanner.scanning is True @@ -21,11 +21,11 @@ async def test_bluetooth_connect_with_raw_adv( await mock_bluetooth_entry_with_raw_adv.mock_disconnect(True) await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is None await mock_bluetooth_entry_with_raw_adv.mock_connect() await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.scanning is True @@ -33,7 +33,7 @@ async def test_bluetooth_connect_with_legacy_adv( hass: HomeAssistant, mock_bluetooth_entry_with_legacy_adv: MockESPHomeDevice ) -> None: """Test bluetooth connect with legacy advertisements.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is not None assert scanner.connectable is True assert scanner.scanning is True @@ -41,11 +41,11 @@ async def test_bluetooth_connect_with_legacy_adv( await mock_bluetooth_entry_with_legacy_adv.mock_disconnect(True) await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is None await mock_bluetooth_entry_with_legacy_adv.mock_connect() await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.scanning is True @@ -55,10 +55,10 @@ async def test_bluetooth_device_linked_via_device( device_registry: dr.DeviceRegistry, ) -> None: """Test the Bluetooth device is linked to the ESPHome device.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.connectable is True entry = hass.config_entries.async_entry_for_domain_unique_id( - "bluetooth", "11:22:33:44:55:AA" + "bluetooth", "AA:BB:CC:DD:EE:FC" ) assert entry is not None esp_device = device_registry.async_get_device( @@ -71,7 +71,7 @@ async def test_bluetooth_device_linked_via_device( ) assert esp_device is not None device = device_registry.async_get_device( - connections={(dr.CONNECTION_BLUETOOTH, "11:22:33:44:55:AA")} + connections={(dr.CONNECTION_BLUETOOTH, "AA:BB:CC:DD:EE:FC")} ) assert device is not None assert device.via_device_id == esp_device.id @@ -81,7 +81,7 @@ async def test_bluetooth_cleanup_on_remove_entry( hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice ) -> None: """Test bluetooth is cleaned up on entry removal.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.connectable is True await hass.config_entries.async_unload( mock_bluetooth_entry_with_raw_adv.entry.entry_id diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index a4b858ed7de..2d64170bc97 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -37,7 +37,7 @@ async def test_diagnostics_with_bluetooth( mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice, ) -> None: """Test diagnostics for config entry with Bluetooth.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is not None assert scanner.connectable is True entry = mock_bluetooth_entry_with_raw_adv.entry @@ -55,9 +55,9 @@ async def test_diagnostics_with_bluetooth( "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, - "name": "test (11:22:33:44:55:AA)", + "name": "test (AA:BB:CC:DD:EE:FC)", "scanning": True, - "source": "11:22:33:44:55:AA", + "source": "AA:BB:CC:DD:EE:FC", "start_time": ANY, "time_since_last_device_detection": {}, "type": "ESPHomeScanner", @@ -66,6 +66,7 @@ async def test_diagnostics_with_bluetooth( "config": { "created_at": ANY, "data": { + "bluetooth_mac_address": "**REDACTED**", "device_name": "test", "host": "test.local", "password": "", diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index b805b065d5a..ddb1babd8a4 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -25,6 +25,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, CONF_SUBSCRIBE_LOGS, DOMAIN, @@ -475,6 +476,39 @@ async def test_unique_id_updated_to_mac(hass: HomeAssistant, mock_client) -> Non assert entry.unique_id == "11:22:33:44:55:aa" +@pytest.mark.usefixtures("mock_zeroconf") +async def test_add_missing_bluetooth_mac_address( + hass: HomeAssistant, mock_client +) -> None: + """Test bluetooth mac is added if its missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="mock-config-name", + ) + entry.add_to_hass(hass) + subscribe_done = hass.loop.create_future() + + def async_subscribe_states(*args, **kwargs) -> None: + subscribe_done.set_result(None) + + mock_client.subscribe_states = async_subscribe_states + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + mac_address="1122334455aa", + bluetooth_mac_address="AA:BB:CC:DD:EE:FF", + ) + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + async with asyncio.timeout(1): + await subscribe_done + + assert entry.unique_id == "11:22:33:44:55:aa" + assert entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) == "AA:BB:CC:DD:EE:FF" + + @pytest.mark.usefixtures("mock_zeroconf") async def test_unique_id_not_updated_if_name_same_and_already_mac( hass: HomeAssistant, mock_client: APIClient @@ -1337,3 +1371,32 @@ async def test_entry_missing_unique_id( await mock_esphome_device(mock_client=mock_client, mock_storage=True) await hass.async_block_till_done() assert entry.unique_id == "11:22:33:44:55:aa" + + +async def test_entry_missing_bluetooth_mac_address( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test the bluetooth_mac_address is added if available.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=None, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + }, + options={CONF_ALLOW_SERVICE_CALLS: True}, + ) + entry.add_to_hass(hass) + await mock_esphome_device( + mock_client=mock_client, + mock_storage=True, + device_info={"bluetooth_mac_address": "AA:BB:CC:DD:EE:FC"}, + ) + await hass.async_block_till_done() + assert entry.data[CONF_BLUETOOTH_MAC_ADDRESS] == "AA:BB:CC:DD:EE:FC" From 577b22374a0bb00839c1770278df5b7d96168e78 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Feb 2025 22:25:50 +0000 Subject: [PATCH 2911/2987] Revert polling changes to HomeKit Controller (#139550) This reverts #116200 We changed the polling logic to avoid polling if all chars are marked as watchable to avoid crashing the firmware on a very limited set of devices as it was more in line with what iOS does. In the end, the user ended up replacing the device in #116143 because it turned out to be unreliable in other ways. The vendor has since issued a firmware update that may resolve the problem with all of these devices. In practice it turns out many more devices report that chars are evented and never send events. After a few months of data and reports the trade-off does not seem worth it since users are having to set up manual polling on a wide range of devices. The amount of devices with evented chars that do not actually send state vastly exceeds the number of devices that might crash if they are polled too often so restore the previous behavior fixes #138561 fixes #100331 fixes #124529 fixes #123456 fixes #130763 fixes #124099 fixes #124916 fixes #135434 fixes #125273 fixes #124099 fixes #119617 --- .../homekit_controller/connection.py | 38 ------------------- .../homekit_controller/test_connection.py | 10 ++--- 2 files changed, 5 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 211aec2c2d5..43cbdec67fa 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -154,7 +154,6 @@ class HKDevice: self._pending_subscribes: set[tuple[int, int]] = set() self._subscribe_timer: CALLBACK_TYPE | None = None self._load_platforms_lock = asyncio.Lock() - self._full_update_requested: bool = False @property def entity_map(self) -> Accessories: @@ -841,48 +840,11 @@ class HKDevice: async def async_request_update(self, now: datetime | None = None) -> None: """Request an debounced update from the accessory.""" - self._full_update_requested = True await self._debounced_update.async_call() async def async_update(self, now: datetime | None = None) -> None: """Poll state of all entities attached to this bridge/accessory.""" to_poll = self.pollable_characteristics - accessories = self.entity_map.accessories - - if ( - not self._full_update_requested - and len(accessories) == 1 - and self.available - and not (to_poll - self.watchable_characteristics) - and self.pairing.is_available - and await self.pairing.controller.async_reachable( - self.unique_id, timeout=5.0 - ) - ): - # If its a single accessory and all chars are watchable, - # only poll the firmware version to keep the connection alive - # https://github.com/home-assistant/core/issues/123412 - # - # Firmware revision is used here since iOS does this to keep camera - # connections alive, and the goal is to not regress - # https://github.com/home-assistant/core/issues/116143 - # by polling characteristics that are not normally polled frequently - # and may not be tested by the device vendor. - # - _LOGGER.debug( - "Accessory is reachable, limiting poll to firmware version: %s", - self.unique_id, - ) - first_accessory = accessories[0] - accessory_info = first_accessory.services.first( - service_type=ServicesTypes.ACCESSORY_INFORMATION - ) - assert accessory_info is not None - firmware_iid = accessory_info[CharacteristicsTypes.FIRMWARE_REVISION].iid - to_poll = {(first_accessory.aid, firmware_iid)} - - self._full_update_requested = False - if not to_poll: self.async_update_available_state() _LOGGER.debug( diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index 7ea791f9a1e..00c7bb16259 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -375,9 +375,9 @@ async def test_poll_firmware_version_only_all_watchable_accessory_mode( state = await helper.poll_and_get_state() assert state.state == STATE_OFF assert mock_get_characteristics.call_count == 2 - # Verify only firmware version is polled - assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 7)} - assert mock_get_characteristics.call_args_list[1][0][0] == {(1, 7)} + # Verify everything is polled + assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 10), (1, 11)} + assert mock_get_characteristics.call_args_list[1][0][0] == {(1, 10), (1, 11)} # Test device goes offline helper.pairing.available = False @@ -429,8 +429,8 @@ async def test_manual_poll_all_chars( ) as mock_get_characteristics: # Initial state is that the light is off await helper.poll_and_get_state() - # Verify only firmware version is polled - assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 7)} + # Verify poll polls all chars + assert len(mock_get_characteristics.call_args_list[0][0][0]) > 1 # Now do a manual poll to ensure all chars are polled mock_get_characteristics.reset_mock() From d6750624ce020bd6a6ba2429cb4ecc3817db2f0a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 23:32:09 +0100 Subject: [PATCH 2912/2987] Add SmartThings hub connections (#139549) --- homeassistant/components/smartthings/__init__.py | 6 ++++++ tests/components/smartthings/snapshots/test_init.ambr | 8 ++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 2bacd476332..f3a95e57831 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -103,10 +103,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) for dev in device_status.values(): for component in dev.device.components: if component.id == MAIN and Capability.BRIDGE in component.capabilities: + assert dev.device.hub device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, dev.device.device_id)}, + connections={ + (dr.CONNECTION_NETWORK_MAC, dev.device.hub.mac_address) + }, name=dev.device.label, + sw_version=dev.device.hub.firmware_version, + model=dev.device.hub.hardware_type, ) scenes = { scene.scene_id: scene diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 7f0e5c17cf2..18bc802e2bc 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1029,6 +1029,10 @@ 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ + tuple( + 'mac', + 'd0:52:a8:72:91:02', + ), }), 'disabled_by': None, 'entry_type': None, @@ -1044,14 +1048,14 @@ 'labels': set({ }), 'manufacturer': None, - 'model': None, + 'model': 'V2_HUB', 'model_id': None, 'name': 'Home Hub', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '000.055.00005', 'via_device_id': None, }) # --- From ebd6daa31d0655b6269c6345e7148f830af9772b Mon Sep 17 00:00:00 2001 From: andylittle Date: Fri, 28 Feb 2025 14:47:40 -0800 Subject: [PATCH 2913/2987] Tuya tyd fix (#135558) Add support for tuya tyd light --- homeassistant/components/tuya/light.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index d94308ebd33..67a94c4e267 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -327,6 +327,18 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness_min=DPCode.BRIGHTNESS_MIN_1, ), ), + # Outdoor Flood Light + # Not documented + "tyd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), # Solar Light # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 "tyndj": ( From 615d79b429ad814d5597567b70b95694d5f4d25f Mon Sep 17 00:00:00 2001 From: LaithBudairi <69572447+LaithBudairi@users.noreply.github.com> Date: Sat, 1 Mar 2025 00:58:39 +0200 Subject: [PATCH 2914/2987] Add missing 'state_class' attribute for Growatt plant sensors (#132145) * Add missing 'state_class' attribute for Growatt plant sensors * Update total.py * Update total.py 'TOTAL_INCREASING' * Update total.py "maximum_output" -> 'TOTAL_INCREASING' * Update homeassistant/components/growatt_server/sensor/total.py --------- Co-authored-by: Franck Nijhof --- homeassistant/components/growatt_server/sensor/total.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/growatt_server/sensor/total.py b/homeassistant/components/growatt_server/sensor/total.py index 8111728d1e9..578745c8610 100644 --- a/homeassistant/components/growatt_server/sensor/total.py +++ b/homeassistant/components/growatt_server/sensor/total.py @@ -26,6 +26,7 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="todayEnergy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="total_output_power", @@ -33,6 +34,7 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="invTodayPpv", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), GrowattSensorEntityDescription( key="total_energy_output", From 1dc6a94093e62d8e2a9a5c6301dd421db13398db Mon Sep 17 00:00:00 2001 From: Daniele Ricci Date: Sat, 1 Mar 2025 06:15:28 +0100 Subject: [PATCH 2915/2987] Fix caldav todo list not updating after adding items with Assist (#135980) caldav: fix todo list not updating after adding items with Assist --- homeassistant/components/caldav/todo.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index fada4693cf0..73f172dabec 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -138,6 +138,8 @@ class WebDavTodoListEntity(TodoListEntity): await self.hass.async_add_executor_job( partial(self._calendar.save_todo, **item_data), ) + # refreshing async otherwise it would take too much time + self.hass.async_create_task(self.async_update_ha_state(force_refresh=True)) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err @@ -172,6 +174,8 @@ class WebDavTodoListEntity(TodoListEntity): obj_type="todo", ), ) + # refreshing async otherwise it would take too much time + self.hass.async_create_task(self.async_update_ha_state(force_refresh=True)) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err @@ -195,3 +199,5 @@ class WebDavTodoListEntity(TodoListEntity): await self.hass.async_add_executor_job(item.delete) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV delete error: {err}") from err + # refreshing async otherwise it would take too much time + self.hass.async_create_task(self.async_update_ha_state(force_refresh=True)) From 8e7960fa0ebe4125f3cf03d05029744d4cb04613 Mon Sep 17 00:00:00 2001 From: Juan Grande Date: Sat, 1 Mar 2025 00:10:35 -0800 Subject: [PATCH 2916/2987] Fix bug in derivative sensor when source sensor's state is constant (#139230) Previously, when the source sensor's state remains constant, the derivative sensor repeats its latest value indefinitely. This patch fixes this bug by consuming the state_reported event and updating the sensor's output even when the source sensor doesn't change its state. --- homeassistant/components/derivative/sensor.py | 66 ++++++++--- tests/components/derivative/test_sensor.py | 111 ++++++++++++++++-- 2 files changed, 150 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 90f8a95919d..f6c2b45ef9c 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -24,7 +24,14 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTime, ) -from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback +from homeassistant.core import ( + Event, + EventStateChangedData, + EventStateReportedData, + HomeAssistant, + State, + callback, +) from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.device_registry import DeviceInfo @@ -32,7 +39,10 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + async_track_state_change_event, + async_track_state_report_event, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -200,13 +210,33 @@ class DerivativeSensor(RestoreSensor, SensorEntity): _LOGGER.warning("Could not restore last state: %s", err) @callback - def calc_derivative(event: Event[EventStateChangedData]) -> None: + def on_state_reported(event: Event[EventStateReportedData]) -> None: + """Handle constant sensor state.""" + if self._attr_native_value == Decimal(0): + # If the derivative is zero, and the source sensor hasn't + # changed state, then we know it will still be zero. + return + new_state = event.data["new_state"] + if new_state is not None: + calc_derivative( + new_state, new_state.state, event.data["old_last_reported"] + ) + + @callback + def on_state_changed(event: Event[EventStateChangedData]) -> None: + """Handle changed sensor state.""" + new_state = event.data["new_state"] + old_state = event.data["old_state"] + if new_state is not None and old_state is not None: + calc_derivative(new_state, old_state.state, old_state.last_reported) + + def calc_derivative( + new_state: State, old_value: str, old_last_reported: datetime + ) -> None: """Handle the sensor state changes.""" - if ( - (old_state := event.data["old_state"]) is None - or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) - or (new_state := event.data["new_state"]) is None - or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) + if old_value in (STATE_UNKNOWN, STATE_UNAVAILABLE) or new_state.state in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, ): return @@ -220,15 +250,15 @@ class DerivativeSensor(RestoreSensor, SensorEntity): self._state_list = [ (time_start, time_end, state) for time_start, time_end, state in self._state_list - if (new_state.last_updated - time_end).total_seconds() + if (new_state.last_reported - time_end).total_seconds() < self._time_window ] try: elapsed_time = ( - new_state.last_updated - old_state.last_updated + new_state.last_reported - old_last_reported ).total_seconds() - delta_value = Decimal(new_state.state) - Decimal(old_state.state) + delta_value = Decimal(new_state.state) - Decimal(old_value) new_derivative = ( delta_value / Decimal(elapsed_time) @@ -240,7 +270,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): _LOGGER.warning("While calculating derivative: %s", err) except DecimalException as err: _LOGGER.warning( - "Invalid state (%s > %s): %s", old_state.state, new_state.state, err + "Invalid state (%s > %s): %s", old_value, new_state.state, err ) except AssertionError as err: _LOGGER.error("Could not calculate derivative: %s", err) @@ -257,7 +287,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): # add latest derivative to the window list self._state_list.append( - (old_state.last_updated, new_state.last_updated, new_derivative) + (old_last_reported, new_state.last_reported, new_derivative) ) def calculate_weight( @@ -277,13 +307,19 @@ class DerivativeSensor(RestoreSensor, SensorEntity): else: derivative = Decimal("0.00") for start, end, value in self._state_list: - weight = calculate_weight(start, end, new_state.last_updated) + weight = calculate_weight(start, end, new_state.last_reported) derivative = derivative + (value * Decimal(weight)) self._attr_native_value = round(derivative, self._round_digits) self.async_write_ha_state() self.async_on_remove( async_track_state_change_event( - self.hass, self._sensor_source_id, calc_derivative + self.hass, self._sensor_source_id, on_state_changed + ) + ) + + self.async_on_remove( + async_track_state_report_event( + self.hass, self._sensor_source_id, on_state_reported ) ) diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index a543de974f1..f8d88066f16 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -39,7 +39,7 @@ async def test_state(hass: HomeAssistant) -> None: await hass.async_block_till_done() freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}, force_update=True) + hass.states.async_set(entity_id, 1, {}) await hass.async_block_till_done() state = hass.states.get("sensor.derivative") @@ -51,6 +51,49 @@ async def test_state(hass: HomeAssistant) -> None: assert state.attributes.get("unit_of_measurement") == "kW" +async def test_no_change(hass: HomeAssistant) -> None: + """Test derivative sensor state updated when source sensor doesn't change.""" + config = { + "sensor": { + "platform": "derivative", + "name": "derivative", + "source": "sensor.energy", + "unit": "kW", + "round": 2, + } + } + + assert await async_setup_component(hass, "sensor", config) + + entity_id = config["sensor"]["source"] + base = dt_util.utcnow() + with freeze_time(base) as freezer: + hass.states.async_set(entity_id, 0, {}) + await hass.async_block_till_done() + + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.derivative") + assert state is not None + + # Testing a energy sensor at 1 kWh for 1hour = 0kW + assert round(float(state.state), config["sensor"]["round"]) == 0.0 + + assert state.attributes.get("unit_of_measurement") == "kW" + + assert state.last_changed == base + timedelta(seconds=2 * 3600) + + async def _setup_sensor( hass: HomeAssistant, config: dict[str, Any] ) -> tuple[dict[str, Any], str]: @@ -86,7 +129,7 @@ async def setup_tests( with freeze_time(base) as freezer: for time, value in zip(times, values, strict=False): freezer.move_to(base + timedelta(seconds=time)) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() state = hass.states.get("sensor.power") @@ -159,6 +202,53 @@ async def test_dataSet6(hass: HomeAssistant) -> None: await setup_tests(hass, {}, times=[0, 60], values=[0, 1 / 60], expected_state=1) +async def test_data_moving_average_with_zeroes(hass: HomeAssistant) -> None: + """Test that zeroes are properly handled within the time window.""" + # We simulate the following situation: + # The temperature rises 1 °C per minute for 10 minutes long. Then, it + # stays constant for another 10 minutes. There is a data point every + # minute and we use a time window of 10 minutes. + # Therefore, we can expect the derivative to peak at 1 after 10 minutes + # and then fall down to 0 in steps of 10%. + + temperature_values = [] + for temperature in range(10): + temperature_values += [temperature] + temperature_values += [10] * 11 + time_window = 600 + times = list(range(0, 1200 + 60, 60)) + + config, entity_id = await _setup_sensor( + hass, + { + "time_window": {"seconds": time_window}, + "unit_time": UnitOfTime.MINUTES, + "round": 1, + }, + ) + + base = dt_util.utcnow() + with freeze_time(base) as freezer: + last_derivative = 0 + for time, value in zip(times, temperature_values, strict=True): + now = base + timedelta(seconds=time) + freezer.move_to(now) + hass.states.async_set(entity_id, value, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + + if time_window == time: + assert derivative == 1.0 + elif time_window < time < time_window * 2: + assert (0.1 - 1e-6) < abs(derivative - last_derivative) < (0.1 + 1e-6) + elif time == time_window * 2: + assert derivative == 0 + + last_derivative = derivative + + async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> None: """Test derivative sensor state.""" # We simulate the following situation: @@ -188,7 +278,7 @@ async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> N for time, value in zip(times, temperature_values, strict=False): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() if time_window < time < times[-1] - time_window: @@ -232,7 +322,7 @@ async def test_data_moving_average_for_irregular_times(hass: HomeAssistant) -> N for time, value in zip(times, temperature_values, strict=False): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() if time_window < time and time > times[3]: @@ -270,7 +360,7 @@ async def test_double_signal_after_delay(hass: HomeAssistant) -> None: for time, value in zip(times, temperature_values, strict=False): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() state = hass.states.get("sensor.power") derivative = round(float(state.state), config["sensor"]["round"]) @@ -302,24 +392,22 @@ async def test_prefix(hass: HomeAssistant) -> None: entity_id, 1000, {"unit_of_measurement": UnitOfPower.WATT}, - force_update=True, ) await hass.async_block_till_done() freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) hass.states.async_set( entity_id, - 1000, + 2000, {"unit_of_measurement": UnitOfPower.WATT}, - force_update=True, ) await hass.async_block_till_done() state = hass.states.get("sensor.derivative") assert state is not None - # Testing a power sensor at 1000 Watts for 1hour = 0kW/h - assert round(float(state.state), config["sensor"]["round"]) == 0.0 + # Testing a power sensor increasing by 1000 Watts per hour = 1kW/h + assert round(float(state.state), config["sensor"]["round"]) == 1.0 assert state.attributes.get("unit_of_measurement") == f"kW/{UnitOfTime.HOURS}" @@ -345,7 +433,7 @@ async def test_suffix(hass: HomeAssistant) -> None: await hass.async_block_till_done() freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1000, {}, force_update=True) + hass.states.async_set(entity_id, 1000, {}) await hass.async_block_till_done() state = hass.states.get("sensor.derivative") @@ -375,7 +463,6 @@ async def test_total_increasing_reset(hass: HomeAssistant) -> None: entity_id, value, {ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING}, - force_update=True, ) await hass.async_block_till_done() From a6e2dc485bc2d951c4a37dd00b35ff7a84395127 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Mar 2025 09:44:04 +0000 Subject: [PATCH 2917/2987] Bump orjson to 3.10.15 (#135223) --- 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 54401a12592..1f1cb3c4f4c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -44,7 +44,7 @@ ifaddr==0.2.0 Jinja2==3.1.5 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.10.12 +orjson==3.10.15 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.1.0 diff --git a/pyproject.toml b/pyproject.toml index 5ee20b96bfc..6a75ffa002b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ dependencies = [ "Pillow==11.1.0", "propcache==0.3.0", "pyOpenSSL==25.0.0", - "orjson==3.10.12", + "orjson==3.10.15", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", diff --git a/requirements.txt b/requirements.txt index b378688106d..76c5059e29e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,7 +32,7 @@ cryptography==44.0.1 Pillow==11.1.0 propcache==0.3.0 pyOpenSSL==25.0.0 -orjson==3.10.12 +orjson==3.10.15 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 From 18217a594f8c17aa89e75e657f42fc4828481741 Mon Sep 17 00:00:00 2001 From: Filip Agh Date: Sat, 1 Mar 2025 11:50:24 +0100 Subject: [PATCH 2918/2987] Fix update data for multiple Gree devices (#139469) fix sync date for multiple devices do not use handler for explicit update devices as internal communication lib do not provide which device is updated use ha update loop copy data object to prevent rewrite data from internal lib allow more time to process response before log warning about long wait for response and make log message more clear --- homeassistant/components/gree/const.py | 1 + homeassistant/components/gree/coordinator.py | 14 ++++++++++---- tests/components/gree/test_climate.py | 3 ++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/gree/const.py b/homeassistant/components/gree/const.py index f926eb1c53e..14236f09fa2 100644 --- a/homeassistant/components/gree/const.py +++ b/homeassistant/components/gree/const.py @@ -20,3 +20,4 @@ MAX_ERRORS = 2 TARGET_TEMPERATURE_STEP = 1 UPDATE_INTERVAL = 60 +MAX_EXPECTED_RESPONSE_TIME_INTERVAL = UPDATE_INTERVAL * 2 diff --git a/homeassistant/components/gree/coordinator.py b/homeassistant/components/gree/coordinator.py index 0d1aa60deaa..c8b4e6cff54 100644 --- a/homeassistant/components/gree/coordinator.py +++ b/homeassistant/components/gree/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy from datetime import datetime, timedelta import logging from typing import Any @@ -24,6 +25,7 @@ from .const import ( DISPATCH_DEVICE_DISCOVERED, DOMAIN, MAX_ERRORS, + MAX_EXPECTED_RESPONSE_TIME_INTERVAL, UPDATE_INTERVAL, ) @@ -48,7 +50,6 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): always_update=False, ) self.device = device - self.device.add_handler(Response.DATA, self.device_state_updated) self.device.add_handler(Response.RESULT, self.device_state_updated) self._error_count: int = 0 @@ -88,7 +89,9 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # raise update failed if time for more than MAX_ERRORS has passed since last update now = utcnow() elapsed_success = now - self._last_response_time - if self.update_interval and elapsed_success >= self.update_interval: + if self.update_interval and elapsed_success >= timedelta( + seconds=MAX_EXPECTED_RESPONSE_TIME_INTERVAL + ): if not self._last_error_time or ( (now - self.update_interval) >= self._last_error_time ): @@ -96,16 +99,19 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._error_count += 1 _LOGGER.warning( - "Device %s is unresponsive for %s seconds", + "Device %s took an unusually long time to respond, %s seconds", self.name, elapsed_success, ) + else: + self._error_count = 0 if self.last_update_success and self._error_count >= MAX_ERRORS: raise UpdateFailed( f"Device {self.name} is unresponsive for too long and now unavailable" ) - return self.device.raw_properties + self._last_response_time = utcnow() + return copy.deepcopy(self.device.raw_properties) async def push_state_update(self): """Send state updates to the physical device.""" diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index 0cb187f5a60..d7c011a4c25 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -52,6 +52,7 @@ from homeassistant.components.gree.const import ( DISCOVERY_SCAN_INTERVAL, FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW, + MAX_EXPECTED_RESPONSE_TIME_INTERVAL, UPDATE_INTERVAL, ) from homeassistant.const import ( @@ -346,7 +347,7 @@ async def test_unresponsive_device( await async_setup_gree(hass) async def run_update(): - freezer.tick(timedelta(seconds=UPDATE_INTERVAL)) + freezer.tick(timedelta(seconds=MAX_EXPECTED_RESPONSE_TIME_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() From 66a17bd072094f74383530b8286577b2fbb20187 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 12:06:16 +0100 Subject: [PATCH 2919/2987] Bump pysmartthings to 2.4.0 (#139564) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 9fa6d28fa0a..e0cf6739290 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.3.0"] + "requirements": ["pysmartthings==2.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8c64934cc45..7df4c3f7b44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.3.0 +pysmartthings==2.4.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0a45a3c4015..48dc3bb48d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.3.0 +pysmartthings==2.4.0 # homeassistant.components.smarty pysmarty2==0.10.2 From 2c620f1f6082c60aaf153c6f2380954c5c5a9093 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 1 Mar 2025 12:12:36 +0100 Subject: [PATCH 2920/2987] Improve description of `door` field in `subaru.unlock_specific_door` action (#139558) * Improve description of `door` field in `subaru.unlock_specific_door` action In the UI the `door` field of the `subaru.unlock_specific_door` action presents three radio buttons for the three possible choices 'all', 'driver' and 'tailgate'. Therefore the field description should no longer repeat those options to avoid over-translation that will not match the actual choices. In addition proper sentence-casing is applied to several title keys. * Fix sentence-casing in two title keys --- homeassistant/components/subaru/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 00da729dccd..7525e73f802 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Subaru Starlink Configuration", + "title": "Subaru Starlink configuration", "description": "Please enter your MySubaru credentials\nNOTE: Initial setup may take up to 30 seconds", "data": { "username": "[%key:common::config_flow::data::username%]", @@ -49,7 +49,7 @@ "options": { "step": { "init": { - "title": "Subaru Starlink Options", + "title": "Subaru Starlink options", "description": "When enabled, vehicle polling will send a remote command to your vehicle every 2 hours to obtain new sensor data. Without vehicle polling, new sensor data is only received when the vehicle automatically pushes data (normally after engine shutdown).", "data": { "update_enabled": "Enable vehicle polling" @@ -106,7 +106,7 @@ "fields": { "door": { "name": "Door", - "description": "One of the following: 'all', 'driver', 'tailgate'." + "description": "Which door(s) to open." } } } From dfe19217371436a45761b08df4622fb3210ab9f8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 1 Mar 2025 12:12:58 +0100 Subject: [PATCH 2921/2987] Improve description of `media_content_type` in `media_extractor.play_media` action (#139559) * Improve `media_content_type` in `media_extractor.play_media` action In the UI the `media_content_type` field of the `media_extractor.play_media` action already presents a selector with the choices MUSIC, TVSHOW, VIDEO, EPISODE, CHANNEL or PLAYLIST MUSIC. Therefore these can be removed from the field description to avoid any over-translation that will create an unnecessary mismatch in the UI. * Fix casing of `media_extractor.play_media` action name --- homeassistant/components/media_extractor/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_extractor/strings.json b/homeassistant/components/media_extractor/strings.json index 125aa08337a..11b5a884e4d 100644 --- a/homeassistant/components/media_extractor/strings.json +++ b/homeassistant/components/media_extractor/strings.json @@ -17,12 +17,12 @@ }, "media_content_type": { "name": "Media content type", - "description": "The type of the content to play. Must be one of MUSIC, TVSHOW, VIDEO, EPISODE, CHANNEL or PLAYLIST MUSIC." + "description": "The type of the content to play." } } }, "extract_media_url": { - "name": "Get Media URL", + "name": "Get media URL", "description": "Extract media URL from a service.", "fields": { "url": { From 042e4d20c5f2fb45c623e1859e0fd67ca68f27c8 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 1 Mar 2025 12:37:44 +0100 Subject: [PATCH 2922/2987] Bump aiowebdav2 to 0.3.1 (#139567) --- homeassistant/components/webdav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index 75a8d7ddfe2..b4950bc23f3 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.3.0"] + "requirements": ["aiowebdav2==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7df4c3f7b44..efa3da8d3d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.3.0 +aiowebdav2==0.3.1 # homeassistant.components.webostv aiowebostv==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 48dc3bb48d4..527d9f654dc 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.webdav -aiowebdav2==0.3.0 +aiowebdav2==0.3.1 # homeassistant.components.webostv aiowebostv==0.7.1 From fe5cd5c55c89f5a4b1d56a7b4a59743907b10983 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 12:47:58 +0100 Subject: [PATCH 2923/2987] Validate scopes in SmartThings config flow (#139569) --- .../components/smartthings/config_flow.py | 2 + .../components/smartthings/strings.json | 3 +- .../smartthings/test_config_flow.py | 111 ++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index bcd2ddc192b..b39fe662124 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -34,6 +34,8 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for SmartThings.""" + if data[CONF_TOKEN]["scope"].split() != SCOPES: + return self.async_abort(reason="missing_scopes") client = SmartThings(session=async_get_clientsession(self.hass)) client.authenticate(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) locations = await client.get_locations() diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index e5ffbe35e8b..9fd417284af 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -23,7 +23,8 @@ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_account_mismatch": "Authenticated account does not match the account to be reauthenticated. Please log in with the correct account and pick the right location.", - "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location." + "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location.", + "missing_scopes": "Authentication failed. Please make sure you have granted all required permissions." } }, "entity": { diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 647e0ea5284..61e2b464920 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -101,6 +101,66 @@ async def test_full_flow( assert result["result"].unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" +@pytest.mark.usefixtures("current_request_with_host") +async def test_not_enough_scopes( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we abort if we don't have enough scopes.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + "https://api.smartthings.com/oauth/authorize" + "?response_type=code&client_id=CLIENT_ID" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" + "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" + "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_scopes" + + @pytest.mark.usefixtures("current_request_with_host") async def test_duplicate_entry( hass: HomeAssistant, @@ -227,6 +287,57 @@ async def test_reauthentication( } +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauthentication_wrong_scopes( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with wrong scopes.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_scopes" + + @pytest.mark.usefixtures("current_request_with_host") async def test_reauth_account_mismatch( hass: HomeAssistant, From 1852052dffa1175ea80cb4cfb987ee98d2fc4d00 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 13:05:58 +0100 Subject: [PATCH 2924/2987] Add suggested area to SmartThings (#139570) * Add suggested area to SmartThings * Add suggested areas to SmartThings --- .../components/smartthings/__init__.py | 9 ++ .../components/smartthings/binary_sensor.py | 10 +- .../components/smartthings/climate.py | 14 ++- homeassistant/components/smartthings/cover.py | 11 +- .../components/smartthings/entity.py | 9 +- homeassistant/components/smartthings/fan.py | 7 +- homeassistant/components/smartthings/light.py | 7 +- homeassistant/components/smartthings/lock.py | 2 +- .../components/smartthings/sensor.py | 12 ++- .../components/smartthings/switch.py | 4 +- tests/components/smartthings/conftest.py | 4 + .../fixtures/devices/base_electric_meter.json | 2 +- .../devices/c2c_arlo_pro_3_switch.json | 2 +- .../fixtures/devices/centralite.json | 2 +- .../fixtures/devices/contact_sensor.json | 2 +- .../fixtures/devices/da_ac_rac_000001.json | 2 +- .../fixtures/devices/da_ac_rac_01001.json | 2 +- .../devices/da_ks_microwave_0101x.json | 2 +- .../devices/da_ref_normal_000001.json | 2 +- .../devices/da_rvc_normal_000001.json | 2 +- .../fixtures/devices/da_wm_dw_000001.json | 2 +- .../fixtures/devices/da_wm_wd_000001.json | 2 +- .../fixtures/devices/da_wm_wm_000001.json | 2 +- .../fixtures/devices/fake_fan.json | 2 +- .../devices/ge_in_wall_smart_dimmer.json | 2 +- .../smartthings/fixtures/devices/hub.json | 2 +- .../fixtures/devices/multipurpose_sensor.json | 2 +- .../fixtures/devices/smart_plug.json | 2 +- .../fixtures/devices/sonos_player.json | 2 +- .../devices/vd_network_audio_002s.json | 2 +- .../fixtures/devices/vd_stv_2017_k.json | 2 +- .../fixtures/devices/virtual_thermostat.json | 2 +- .../fixtures/devices/virtual_valve.json | 2 +- .../devices/virtual_water_sensor.json | 2 +- .../yale_push_button_deadbolt_lock.json | 2 +- .../smartthings/fixtures/rooms.json | 17 +++ .../smartthings/snapshots/test_init.ambr | 100 +++++++++--------- 37 files changed, 163 insertions(+), 91 deletions(-) create mode 100644 tests/components/smartthings/fixtures/rooms.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index f3a95e57831..b7850bc9333 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -39,6 +39,7 @@ class SmartThingsData: devices: dict[str, FullDevice] scenes: dict[str, Scene] + rooms: dict[str, str] client: SmartThings @@ -92,6 +93,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) device_status: dict[str, FullDevice] = {} try: + rooms = { + room.room_id: room.name + for room in await client.get_rooms(location_id=entry.data[CONF_LOCATION_ID]) + } devices = await client.get_devices() for device in devices: status = process_status(await client.get_device_status(device.device_id)) @@ -113,6 +118,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) name=dev.device.label, sw_version=dev.device.hub.firmware_version, model=dev.device.hub.hardware_type, + suggested_area=( + rooms.get(dev.device.room_id) if dev.device.room_id else None + ), ) scenes = { scene.scene_id: scene @@ -127,6 +135,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) }, client=client, scenes=scenes, + rooms=rooms, ) entry.async_create_background_task( diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 99cbd3f9353..080a90440be 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -109,7 +109,12 @@ async def async_setup_entry( entry_data = entry.runtime_data async_add_entities( SmartThingsBinarySensor( - entry_data.client, device, description, capability, attribute + entry_data.client, + device, + description, + entry_data.rooms, + capability, + attribute, ) for device in entry_data.devices.values() for capability, attribute_map in CAPABILITY_TO_SENSORS.items() @@ -128,11 +133,12 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): client: SmartThings, device: FullDevice, entity_description: SmartThingsBinarySensorEntityDescription, + rooms: dict[str, str], capability: Capability, attribute: Attribute, ) -> None: """Init the class.""" - super().__init__(client, device, {capability}) + super().__init__(client, device, rooms, {capability}) self._attribute = attribute self.capability = capability self.entity_description = entity_description diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 2c3b8f3ac03..bfda5c00d5e 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -118,12 +118,12 @@ async def async_setup_entry( """Add climate entities for a config entry.""" entry_data = entry.runtime_data entities: list[ClimateEntity] = [ - SmartThingsAirConditioner(entry_data.client, device) + SmartThingsAirConditioner(entry_data.client, entry_data.rooms, device) for device in entry_data.devices.values() if all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) ] entities.extend( - SmartThingsThermostat(entry_data.client, device) + SmartThingsThermostat(entry_data.client, entry_data.rooms, device) for device in entry_data.devices.values() if all( capability in device.status[MAIN] for capability in THERMOSTAT_CAPABILITIES @@ -137,11 +137,14 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): _attr_name = None - def __init__(self, client: SmartThings, device: FullDevice) -> None: + def __init__( + self, client: SmartThings, rooms: dict[str, str], device: FullDevice + ) -> None: """Init the class.""" super().__init__( client, device, + rooms, { Capability.THERMOSTAT_FAN_MODE, Capability.THERMOSTAT_MODE, @@ -327,11 +330,14 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): _attr_name = None _attr_preset_mode = None - def __init__(self, client: SmartThings, device: FullDevice) -> None: + def __init__( + self, client: SmartThings, rooms: dict[str, str], device: FullDevice + ) -> None: """Init the class.""" super().__init__( client, device, + rooms, { Capability.AIR_CONDITIONER_MODE, Capability.SWITCH, diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 0b0f03679eb..564de8443b1 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -41,7 +41,9 @@ async def async_setup_entry( """Add covers for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsCover(entry_data.client, device, Capability(capability)) + SmartThingsCover( + entry_data.client, device, entry_data.rooms, Capability(capability) + ) for device in entry_data.devices.values() for capability in device.status[MAIN] if capability in CAPABILITIES @@ -55,12 +57,17 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): _state: CoverState | None = None def __init__( - self, client: SmartThings, device: FullDevice, capability: Capability + self, + client: SmartThings, + device: FullDevice, + rooms: dict[str, str], + capability: Capability, ) -> None: """Initialize the cover class.""" super().__init__( client, device, + rooms, { capability, Capability.BATTERY, diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 0240549740f..542401109ad 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -27,7 +27,11 @@ class SmartThingsEntity(Entity): _attr_has_entity_name = True def __init__( - self, client: SmartThings, device: FullDevice, capabilities: set[Capability] + self, + client: SmartThings, + device: FullDevice, + rooms: dict[str, str], + capabilities: set[Capability], ) -> None: """Initialize the instance.""" self.client = client @@ -43,6 +47,9 @@ class SmartThingsEntity(Entity): configuration_url="https://account.smartthings.com", identifiers={(DOMAIN, device.device.device_id)}, name=device.device.label, + suggested_area=( + rooms.get(device.device.room_id) if device.device.room_id else None + ), ) if device.device.parent_device_id: self._attr_device_info["via_device"] = ( diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 8edf01ec613..9aa467cbfa8 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -31,7 +31,7 @@ async def async_setup_entry( """Add fans for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsFan(entry_data.client, device) + SmartThingsFan(entry_data.client, entry_data.rooms, device) for device in entry_data.devices.values() if Capability.SWITCH in device.status[MAIN] and any( @@ -51,11 +51,14 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): _attr_name = None _attr_speed_count = int_states_in_range(SPEED_RANGE) - def __init__(self, client: SmartThings, device: FullDevice) -> None: + def __init__( + self, client: SmartThings, rooms: dict[str, str], device: FullDevice + ) -> None: """Init the class.""" super().__init__( client, device, + rooms, { Capability.SWITCH, Capability.FAN_SPEED, diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index aa3a8d35859..eee333f131f 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -41,7 +41,7 @@ async def async_setup_entry( """Add lights for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsLight(entry_data.client, device) + SmartThingsLight(entry_data.client, entry_data.rooms, device) for device in entry_data.devices.values() if Capability.SWITCH in device.status[MAIN] and any(capability in device.status[MAIN] for capability in CAPABILITIES) @@ -71,11 +71,14 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): # highest kelvin found supported across 20+ handlers. _attr_max_color_temp_kelvin = 9000 # 111 mireds - def __init__(self, client: SmartThings, device: FullDevice) -> None: + def __init__( + self, client: SmartThings, rooms: dict[str, str], device: FullDevice + ) -> None: """Initialize a SmartThingsLight.""" super().__init__( client, device, + rooms, { Capability.COLOR_CONTROL, Capability.COLOR_TEMPERATURE, diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index f56ecd5d565..76a643e417e 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -33,7 +33,7 @@ async def async_setup_entry( """Add locks for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsLock(entry_data.client, device, {Capability.LOCK}) + SmartThingsLock(entry_data.client, device, entry_data.rooms, {Capability.LOCK}) for device in entry_data.devices.values() if Capability.LOCK in device.status[MAIN] ) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 0a695876da4..ff6e7f252b0 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -962,7 +962,14 @@ async def async_setup_entry( """Add sensors for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsSensor(entry_data.client, device, description, capability, attribute) + SmartThingsSensor( + entry_data.client, + device, + description, + entry_data.rooms, + capability, + attribute, + ) for device in entry_data.devices.values() for capability, attributes in CAPABILITY_TO_SENSORS.items() if capability in device.status[MAIN] @@ -992,11 +999,12 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): client: SmartThings, device: FullDevice, entity_description: SmartThingsSensorEntityDescription, + rooms: dict[str, str], capability: Capability, attribute: Attribute, ) -> None: """Init the class.""" - super().__init__(client, device, {capability}) + super().__init__(client, device, rooms, {capability}) self._attr_unique_id = f"{device.device.device_id}{entity_description.unique_id_separator}{entity_description.key}" self._attribute = attribute self.capability = capability diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 380005f1b93..f470a90bb39 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -37,7 +37,9 @@ async def async_setup_entry( """Add switches for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsSwitch(entry_data.client, device, {Capability.SWITCH}) + SmartThingsSwitch( + entry_data.client, device, entry_data.rooms, {Capability.SWITCH} + ) for device in entry_data.devices.values() if Capability.SWITCH in device.status[MAIN] and not any(capability in device.status[MAIN] for capability in CAPABILITIES) diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index b7d0cb61607..a47f32d3a8b 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -8,6 +8,7 @@ from pysmartthings.models import ( DeviceResponse, DeviceStatus, LocationResponse, + RoomResponse, SceneResponse, ) import pytest @@ -78,6 +79,9 @@ def mock_smartthings() -> Generator[AsyncMock]: client.get_locations.return_value = LocationResponse.from_json( load_fixture("locations.json", DOMAIN) ).items + client.get_rooms.return_value = RoomResponse.from_json( + load_fixture("rooms.json", DOMAIN) + ).items yield client diff --git a/tests/components/smartthings/fixtures/devices/base_electric_meter.json b/tests/components/smartthings/fixtures/devices/base_electric_meter.json index 4d00d6f169c..a81ca788b29 100644 --- a/tests/components/smartthings/fixtures/devices/base_electric_meter.json +++ b/tests/components/smartthings/fixtures/devices/base_electric_meter.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "0086-0002-0009", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json b/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json index a9e3bddb2ca..21d4d475e7a 100644 --- a/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json +++ b/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Arlo", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "68b45114-9af8-4906-8636-b973a6faa271", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/centralite.json b/tests/components/smartthings/fixtures/devices/centralite.json index dff2be78f70..d94043efbc8 100644 --- a/tests/components/smartthings/fixtures/devices/centralite.json +++ b/tests/components/smartthings/fixtures/devices/centralite.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "CentraLite", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/contact_sensor.json b/tests/components/smartthings/fixtures/devices/contact_sensor.json index 92fe6a8bbff..68070abbfc3 100644 --- a/tests/components/smartthings/fixtures/devices/contact_sensor.json +++ b/tests/components/smartthings/fixtures/devices/contact_sensor.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Visonic", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "68b45114-9af8-4906-8636-b973a6faa271", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json index ec7f16b090a..d831e15a86b 100644 --- a/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "58d3fd7c-c512-4da3-b500-ef269382756c", "ownerId": "f9a28d7c-1ed5-d9e9-a81c-18971ec081db", - "roomId": "85a79db4-9cf2-4f09-a5b2-cd70a5c0cef0", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Air Conditioner", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json index 8d9ebde5bcd..db6f8d09673 100644 --- a/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "c4189ac1-208f-461a-8ab6-ea67937b3743", "ownerId": "85ea07e1-7063-f673-3ba5-125293f297c8", - "roomId": "1f66199a-1773-4d8f-97b7-44c312a62cf7", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Air Conditioner", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json b/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json index f6599fee461..f636b069e38 100644 --- a/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json +++ b/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "f4d03391-ab13-4c1d-b4dc-d6ddf86014a2", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "oic.d.microwave", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json index 67afc0ad32c..29372cac23c 100644 --- a/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "3a1f7e7c-4e59-4c29-adb0-0813be691efd", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Refrigerator", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json b/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json index b355eedb17a..b7f8ab2a42c 100644 --- a/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "5d425f41-042a-4d9a-92c4-e43150a61bae", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Robot Vacuum", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json index 1c7024e153f..33392081bf5 100644 --- a/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "f4d03391-ab13-4c1d-b4dc-d6ddf86014a2", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Dishwasher", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json index b9a650718e2..ef47260a989 100644 --- a/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "781d5f1e-c87e-455e-87f7-8e954879e91d", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "2a8637b2-77ad-475e-b537-7b6f7f97fff6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Dryer", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json index 852a2afa932..4996eebab96 100644 --- a/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "781d5f1e-c87e-455e-87f7-8e954879e91d", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "2a8637b2-77ad-475e-b537-7b6f7f97fff6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Washer", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/fake_fan.json b/tests/components/smartthings/fixtures/devices/fake_fan.json index 8656e290c8d..6a447ae7aff 100644 --- a/tests/components/smartthings/fixtures/devices/fake_fan.json +++ b/tests/components/smartthings/fixtures/devices/fake_fan.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "0086-0002-005F", "locationId": "6f11ddf5-f0cb-4516-a06a-3a2a6ec22bca", "ownerId": "9f257fc4-6471-2566-b06e-2fe72dd979fa", - "roomId": "cdf080f0-0542-41d7-a606-aff69683e04c", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json index 314586300b9..646196fa980 100644 --- a/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json +++ b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json @@ -8,7 +8,7 @@ "presentationId": "31cf01ee-cb49-3d95-ac2d-2afab47f25c7", "deviceManufacturerCode": "0063-4944-3130", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", - "roomId": "e73dcd00-6953-431d-ae79-73fd2f2c528e", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/hub.json b/tests/components/smartthings/fixtures/devices/hub.json index 4de0823d758..81046859db6 100644 --- a/tests/components/smartthings/fixtures/devices/hub.json +++ b/tests/components/smartthings/fixtures/devices/hub.json @@ -8,7 +8,7 @@ "presentationId": "63f1469e-dc4a-3689-8cc5-69e293c1eb21", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "f7f39cf6-ff3a-4bcb-8d1b-00a3324c016d", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json index b056ecf007b..c8088d6473d 100644 --- a/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json +++ b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json @@ -8,7 +8,7 @@ "presentationId": "c385e2bc-acb8-317b-be2a-6efd1f879720", "deviceManufacturerCode": "SmartThings", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", - "roomId": "b277a3c0-b8fe-44de-9133-c1108747810c", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/smart_plug.json b/tests/components/smartthings/fixtures/devices/smart_plug.json index 105ae43c3d0..e5ec6c38dad 100644 --- a/tests/components/smartthings/fixtures/devices/smart_plug.json +++ b/tests/components/smartthings/fixtures/devices/smart_plug.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "LEDVANCE", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/sonos_player.json b/tests/components/smartthings/fixtures/devices/sonos_player.json index f7f54a01b49..c84caf57475 100644 --- a/tests/components/smartthings/fixtures/devices/sonos_player.json +++ b/tests/components/smartthings/fixtures/devices/sonos_player.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Sonos", "locationId": "eed0e167-e793-459b-80cb-a0b02e2b86c2", "ownerId": "2c69cc36-85ae-c41a-9981-a4ee96cd9137", - "roomId": "105e6d1a-52a4-4797-a235-5a48d7d433c8", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json b/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json index 7fb07533810..20f4aa71fec 100644 --- a/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json +++ b/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "c4189ac1-208f-461a-8ab6-ea67937b3743", "ownerId": "85ea07e1-7063-f673-3ba5-125293f297c8", - "roomId": "db506ec3-83b1-4125-9c4c-eb597da5db6a", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Network Audio Player", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json b/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json index 3c22a214495..42630f452d5 100644 --- a/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json +++ b/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF TV", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/virtual_thermostat.json b/tests/components/smartthings/fixtures/devices/virtual_thermostat.json index d5bf3b32a0c..1b7a55d779d 100644 --- a/tests/components/smartthings/fixtures/devices/virtual_thermostat.json +++ b/tests/components/smartthings/fixtures/devices/virtual_thermostat.json @@ -8,7 +8,7 @@ "presentationId": "78906115-bf23-3c43-9cd6-f42ca3d5517a", "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", - "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/virtual_valve.json b/tests/components/smartthings/fixtures/devices/virtual_valve.json index 1988617afad..e46b7846631 100644 --- a/tests/components/smartthings/fixtures/devices/virtual_valve.json +++ b/tests/components/smartthings/fixtures/devices/virtual_valve.json @@ -8,7 +8,7 @@ "presentationId": "916408b6-c94e-38b8-9fbf-03c8a48af5c3", "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", - "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json b/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json index ad3a45a0481..ffea2664c88 100644 --- a/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json +++ b/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json @@ -8,7 +8,7 @@ "presentationId": "838ae989-b832-3610-968c-2940491600f6", "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", - "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json index 117aa1344cb..20f0dd5ca26 100644 --- a/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json +++ b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Yale", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/rooms.json b/tests/components/smartthings/fixtures/rooms.json new file mode 100644 index 00000000000..355db9a3423 --- /dev/null +++ b/tests/components/smartthings/fixtures/rooms.json @@ -0,0 +1,17 @@ +{ + "items": [ + { + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "locationId": "397678e5-9995-4a39-9d9f-ae6ba310236b", + "name": "Theater", + "backgroundImage": null + }, + { + "roomId": "cdf080f0-0542-41d7-a606-aff69683e04c", + "locationId": "397678e5-9995-4a39-9d9f-ae6ba310236b", + "name": "Toilet", + "backgroundImage": null + } + ], + "_links": null +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 18bc802e2bc..fb856ae32d6 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_devices[aeotec_home_energy_meter_gen5] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'toilet', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -27,14 +27,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Toilet', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[base_electric_meter] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -60,14 +60,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[c2c_arlo_pro_3_switch] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -93,7 +93,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -133,7 +133,7 @@ # --- # name: test_devices[centralite] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -159,14 +159,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[contact_sensor] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -192,14 +192,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[da_ac_rac_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -225,14 +225,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': '0.1.0', 'via_device_id': None, }) # --- # name: test_devices[da_ac_rac_01001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -258,14 +258,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'ARA-WW-TP1-22-COMMON_11240702', 'via_device_id': None, }) # --- # name: test_devices[da_ks_microwave_0101x] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -291,14 +291,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'AKS-WW-TP2-20-MICROWAVE-OTR_40230125', 'via_device_id': None, }) # --- # name: test_devices[da_ref_normal_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -324,14 +324,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'A-RFWW-TP2-21-COMMON_20220110', 'via_device_id': None, }) # --- # name: test_devices[da_rvc_normal_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -357,14 +357,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': '1.0', 'via_device_id': None, }) # --- # name: test_devices[da_wm_dw_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -390,14 +390,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'DA_DW_A51_20_COMMON_30230714', 'via_device_id': None, }) # --- # name: test_devices[da_wm_wd_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -423,14 +423,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, }) # --- # name: test_devices[da_wm_wm_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -456,7 +456,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'DA_WM_TP2_20_COMMON_30230804', 'via_device_id': None, }) @@ -529,7 +529,7 @@ # --- # name: test_devices[fake_fan] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -555,14 +555,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[ge_in_wall_smart_dimmer] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -588,7 +588,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -694,7 +694,7 @@ # --- # name: test_devices[multipurpose_sensor] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -720,7 +720,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -760,7 +760,7 @@ # --- # name: test_devices[smart_plug] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -786,14 +786,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[sonos_player] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -819,14 +819,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[vd_network_audio_002s] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -852,14 +852,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'SAT-iMX8M23WWC-1010.5', 'via_device_id': None, }) # --- # name: test_devices[vd_stv_2017_k] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -885,14 +885,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'T-KTMAKUC-1290.3', 'via_device_id': None, }) # --- # name: test_devices[virtual_thermostat] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -918,14 +918,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[virtual_valve] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -951,14 +951,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[virtual_water_sensor] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -984,14 +984,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[yale_push_button_deadbolt_lock] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -1017,14 +1017,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_hub_via_device DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': None, @@ -1054,7 +1054,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': '000.055.00005', 'via_device_id': None, }) From 3edc7913deec2e16f463968935e4b46f65988273 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 1 Mar 2025 13:06:10 +0100 Subject: [PATCH 2925/2987] Fix blog post link in comment (#139568) --- homeassistant/config_entries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 98d9e3c760c..bfea2c29eac 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1635,7 +1635,7 @@ class ConfigEntriesFlowManager( # reconfigure to allow the user to change settings. # In case of non user visible flows, the integration should optionally # update the existing entry before aborting. - # see https://developers.home-assistant.io/blog/2025/01/16/config-flow-unique-id/ + # see https://developers.home-assistant.io/blog/2025/03/01/config-flow-unique-id/ report_usage( "creates a config entry when another entry with the same unique ID " "exists", From df9590200473a3dad4725c30b012c33acf14f598 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 13:08:28 +0100 Subject: [PATCH 2926/2987] Only determine SmartThings swing modes if we support it (#139571) Only determine swing modes if we support it --- homeassistant/components/smartthings/climate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index bfda5c00d5e..b2f8819601c 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -351,7 +351,8 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): ) self._attr_hvac_modes = self._determine_hvac_modes() self._attr_preset_modes = self._determine_preset_modes() - self._attr_swing_modes = self._determine_swing_modes() + if self.supports_capability(Capability.FAN_OSCILLATION_MODE): + self._attr_swing_modes = self._determine_swing_modes() self._attr_supported_features = self._determine_supported_features() def _determine_supported_features(self) -> ClimateEntityFeature: From 43f48b85620c0f0ab9669a5ed48fe0d3d76937aa Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 1 Mar 2025 13:23:27 +0100 Subject: [PATCH 2927/2987] Bump azure_storage quality to platinum (#139452) --- homeassistant/components/azure_storage/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/azure_storage/manifest.json b/homeassistant/components/azure_storage/manifest.json index 8f2d8aeaca7..729334f851d 100644 --- a/homeassistant/components/azure_storage/manifest.json +++ b/homeassistant/components/azure_storage/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["azure-storage-blob"], - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["azure-storage-blob==12.24.0"] } From 91eba0855e0da6403b89cd16a9d82b99c9302614 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Sat, 1 Mar 2025 13:29:50 +0100 Subject: [PATCH 2928/2987] Handle IPv6 URLs in devolo Home Network (#139191) * Handle IPv6 URLs in devolo Home Network * Use yarl --- .../components/devolo_home_network/entity.py | 3 +- .../devolo_home_network/conftest.py | 7 ++++ .../snapshots/test_init.ambr | 37 +++++++++++++++++++ .../devolo_home_network/test_init.py | 4 +- 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index 93ec1b9a3a2..64d8ff131e8 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -8,6 +8,7 @@ from devolo_plc_api.device_api import ( WifiGuestAccessGet, ) from devolo_plc_api.plcnet_api import DataRate, LogicalNetwork +from yarl import URL from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -43,7 +44,7 @@ class DevoloEntity(Entity): self.entry = entry self._attr_device_info = DeviceInfo( - configuration_url=f"http://{self.device.ip}", + configuration_url=URL.build(scheme="http", host=self.device.ip), identifiers={(DOMAIN, str(self.device.serial_number))}, manufacturer="devolo", model=self.device.product, diff --git a/tests/components/devolo_home_network/conftest.py b/tests/components/devolo_home_network/conftest.py index fd03063cd34..2b3fd989754 100644 --- a/tests/components/devolo_home_network/conftest.py +++ b/tests/components/devolo_home_network/conftest.py @@ -27,6 +27,13 @@ def mock_repeater_device(mock_device: MockDevice): return mock_device +@pytest.fixture +def mock_ipv6_device(mock_device: MockDevice): + """Mock connecting to a devolo home network device using IPv6.""" + mock_device.ip = "2001:db8::1" + return mock_device + + @pytest.fixture def mock_nonwifi_device(mock_device: MockDevice): """Mock connecting to a devolo home network device without wifi.""" diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr index bdc597819a7..5753fd82817 100644 --- a/tests/components/devolo_home_network/snapshots/test_init.ambr +++ b/tests/components/devolo_home_network/snapshots/test_init.ambr @@ -36,6 +36,43 @@ 'via_device_id': None, }) # --- +# name: test_setup_entry[mock_ipv6_device] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://[2001:db8::1]', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'devolo_home_network', + '1234567890', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'devolo', + 'model': 'dLAN pro 1200+ WiFi ac', + 'model_id': '2730', + 'name': 'Mock Title', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234567890', + 'suggested_area': None, + 'sw_version': '5.6.1', + 'via_device_id': None, + }) +# --- # name: test_setup_entry[mock_repeater_device] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 71823eabe82..56d2c21a5b2 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -27,7 +27,9 @@ from .mock import MockDevice from tests.common import MockConfigEntry -@pytest.mark.parametrize("device", ["mock_device", "mock_repeater_device"]) +@pytest.mark.parametrize( + "device", ["mock_device", "mock_repeater_device", "mock_ipv6_device"] +) async def test_setup_entry( hass: HomeAssistant, device: str, From 679b57e450df94711059c968c0b2ff4d0d5f32e3 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 1 Mar 2025 14:22:14 +0100 Subject: [PATCH 2929/2987] Add strict typing to Vodafone Station (#139573) --- .strict-typing | 1 + .../components/vodafone_station/coordinator.py | 4 ++-- mypy.ini | 10 ++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.strict-typing b/.strict-typing index 1df49300b1e..4b2a94b2db4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -528,6 +528,7 @@ homeassistant.components.vallox.* homeassistant.components.valve.* homeassistant.components.velbus.* homeassistant.components.vlc_telnet.* +homeassistant.components.vodafone_station.* homeassistant.components.wake_on_lan.* homeassistant.components.wake_word.* homeassistant.components.wallbox.* diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index cd640d10cb6..b7986d06c25 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from json.decoder import JSONDecodeError -from typing import Any +from typing import Any, cast from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions @@ -164,7 +164,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): @property def serial_number(self) -> str: """Device serial number.""" - return self.data.sensors["sys_serial_number"] + return cast(str, self.data.sensors["sys_serial_number"]) @property def device_info(self) -> DeviceInfo: diff --git a/mypy.ini b/mypy.ini index a6203993c87..0792f820965 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5039,6 +5039,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.vodafone_station.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.wake_on_lan.*] check_untyped_defs = true disallow_incomplete_defs = true From c5e0418f7561ac89a800eafb15ee41ab5519bc4a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Mar 2025 16:41:11 +0000 Subject: [PATCH 2930/2987] Bump aiohomekit to 3.2.8 (#139579) changelog: https://github.com/Jc2k/aiohomekit/compare/3.2.7...3.2.8 --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index b7c82b9fd51..98db9a397d3 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.2.7"], + "requirements": ["aiohomekit==3.2.8"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index efa3da8d3d3..b10d8372466 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -267,7 +267,7 @@ aiohasupervisor==0.3.0 aiohomeconnect==0.15.1 # homeassistant.components.homekit_controller -aiohomekit==3.2.7 +aiohomekit==3.2.8 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 527d9f654dc..a1120979e69 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -252,7 +252,7 @@ aiohasupervisor==0.3.0 aiohomeconnect==0.15.1 # homeassistant.components.homekit_controller -aiohomekit==3.2.7 +aiohomekit==3.2.8 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 From 2de941bc1125a682fe4fcbcfea6a76f7a71e920a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 1 Mar 2025 19:35:39 +0100 Subject: [PATCH 2931/2987] Fix - Allow brightness only light MQTT json light to be set up using the `brightness` flag or via `supported_color_modes` (#139585) * Fix - Allow brightness only light MQTT json light to be set up using the `brightness` flag or via `supported_color_modes` * Improve comment --- .../components/mqtt/light/schema_json.py | 4 ++ tests/components/mqtt/test_light_json.py | 71 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 14e21e61d48..4473385d550 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -217,6 +217,10 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_color_mode = next(iter(self.supported_color_modes)) else: self._attr_color_mode = ColorMode.UNKNOWN + elif config.get(CONF_BRIGHTNESS): + # Brightness is supported and no supported_color_modes are set, + # so set brightness as the supported color mode. + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} def _update_color(self, values: dict[str, Any]) -> None: color_mode: str = values["color_mode"] diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 7ddd04a09a6..bcf9d4bd736 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -361,6 +361,77 @@ async def test_no_color_brightness_color_temp_if_no_topics( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "supported_color_modes": ["brightness"], + } + } + }, + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "brightness": True, + } + } + }, + ], +) +async def test_brightness_only( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test brightness only light. + + There are two possible configurations for brightness only light: + 1) Set up "brightness" as supported color mode. + 2) Set "brightness" flag to true. + """ + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == [ + light.ColorMode.BRIGHTNESS + ] + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") is None + assert state.attributes.get("hs_color") is None + + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "brightness": 50}') + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") == 50 + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") is None + assert state.attributes.get("hs_color") is None + + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"OFF"}') + + state = hass.states.get("light.test") + assert state.state == STATE_OFF + + @pytest.mark.parametrize( "hass_config", [ From 9a331de8789fe56382c7ce108d0fa4832a30ef69 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sat, 1 Mar 2025 19:45:07 +0100 Subject: [PATCH 2932/2987] Remove deprecated import from configuration.yaml from opentherm_gw (#139581) * Remove deprecated import from configuration.yaml in opentherm_gw * Remove tests for removed funcionality from opentherm_gw --- .../components/opentherm_gw/__init__.py | 60 +------------------ .../components/opentherm_gw/strings.json | 6 -- .../opentherm_gw/test_config_flow.py | 24 -------- tests/components/opentherm_gw/test_init.py | 29 +-------- 4 files changed, 2 insertions(+), 117 deletions(-) diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 8c92c70ab49..f16e9f186be 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -10,7 +10,7 @@ from serial import SerialException import voluptuous as vol from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DATE, ATTR_ID, @@ -21,9 +21,6 @@ from homeassistant.const import ( CONF_ID, CONF_NAME, EVENT_HOMEASSISTANT_STOP, - PRECISION_HALVES, - PRECISION_TENTHS, - PRECISION_WHOLE, Platform, ) from homeassistant.core import HomeAssistant, ServiceCall @@ -32,10 +29,8 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, - issue_registry as ir, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_CH_OVRD, @@ -44,9 +39,6 @@ from .const import ( ATTR_LEVEL, ATTR_TRANSP_ARG, ATTR_TRANSP_CMD, - CONF_CLIMATE, - CONF_FLOOR_TEMP, - CONF_PRECISION, CONF_TEMPORARY_OVRD_MODE, CONNECTION_TIMEOUT, DATA_GATEWAYS, @@ -70,29 +62,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -# *_SCHEMA required for deprecated import from configuration.yaml, can be removed in 2025.4.0 -CLIMATE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_PRECISION): vol.In( - [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] - ), - vol.Optional(CONF_FLOOR_TEMP, default=False): cv.boolean, - } -) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: cv.schema_with_slug_keys( - { - vol.Required(CONF_DEVICE): cv.string, - vol.Optional(CONF_CLIMATE, default={}): CLIMATE_SCHEMA, - vol.Optional(CONF_NAME): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -164,33 +133,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -# Deprecated import from configuration.yaml, can be removed in 2025.4.0 -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the OpenTherm Gateway component.""" - if DOMAIN in config: - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_import_from_configuration_yaml", - breaks_in_ha_version="2025.4.0", - is_fixable=False, - is_persistent=False, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_import_from_configuration_yaml", - ) - if not hass.config_entries.async_entries(DOMAIN) and DOMAIN in config: - conf = config[DOMAIN] - for device_id, device_config in conf.items(): - device_config[CONF_ID] = device_id - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=device_config - ) - ) - return True - - def register_services(hass: HomeAssistant) -> None: """Register services for the component.""" service_reset_schema = vol.Schema( diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index b49dea4a267..cc57a7d9e0c 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -354,12 +354,6 @@ } } }, - "issues": { - "deprecated_import_from_configuration_yaml": { - "title": "Deprecated configuration", - "description": "Configuration of the OpenTherm Gateway integration through configuration.yaml is deprecated. Your configuration has been migrated to config entries. Please remove any OpenTherm Gateway configuration from your configuration.yaml." - } - }, "options": { "step": { "init": { diff --git a/tests/components/opentherm_gw/test_config_flow.py b/tests/components/opentherm_gw/test_config_flow.py index 57bea4e55dc..99a2dde4acc 100644 --- a/tests/components/opentherm_gw/test_config_flow.py +++ b/tests/components/opentherm_gw/test_config_flow.py @@ -54,30 +54,6 @@ async def test_form_user( assert mock_pyotgw.return_value.disconnect.await_count == 1 -# Deprecated import from configuration.yaml, can be removed in 2025.4.0 -async def test_form_import( - hass: HomeAssistant, - mock_pyotgw: MagicMock, - mock_setup_entry: AsyncMock, -) -> None: - """Test import from existing config.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_ID: "legacy_gateway", CONF_DEVICE: "/dev/ttyUSB1"}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "legacy_gateway" - assert result["data"] == { - CONF_NAME: "legacy_gateway", - CONF_DEVICE: "/dev/ttyUSB1", - CONF_ID: "legacy_gateway", - } - assert mock_pyotgw.return_value.connect.await_count == 1 - assert mock_pyotgw.return_value.disconnect.await_count == 1 - - async def test_form_duplicate_entries( hass: HomeAssistant, mock_pyotgw: MagicMock, diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index 3e85afbf782..4085e25c614 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -4,18 +4,13 @@ from unittest.mock import MagicMock from pyotgw.vars import OTGW, OTGW_ABOUT -from homeassistant import setup from homeassistant.components.opentherm_gw.const import ( DOMAIN, OpenThermDeviceIdentifier, ) from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import MOCK_GATEWAY_ID, VERSION_TEST @@ -153,25 +148,3 @@ async def test_climate_entity_migration( updated_entry.unique_id == f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity" ) - - -# Deprecation test, can be removed in 2025.4.0 -async def test_configuration_yaml_deprecation( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - mock_config_entry: MockConfigEntry, - mock_pyotgw: MagicMock, -) -> None: - """Test that existing configuration in configuration.yaml creates an issue.""" - - await setup.async_setup_component( - hass, DOMAIN, {DOMAIN: {"legacy_gateway": {"device": "/dev/null"}}} - ) - - await hass.async_block_till_done() - assert ( - issue_registry.async_get_issue( - DOMAIN, "deprecated_import_from_configuration_yaml" - ) - is not None - ) From 9fe08f292d05e3831a686984db1fc530db37faa5 Mon Sep 17 00:00:00 2001 From: M-A Date: Sat, 1 Mar 2025 13:58:45 -0500 Subject: [PATCH 2933/2987] Bump env_canada to 0.8.0 (#138237) * Bump env_canada to 0.8.0 * Fix requirements*.txt * Grepped more --------- Co-authored-by: Franck Nijhof --- homeassistant/components/environment_canada/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/script/test_gen_requirements_all.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 76534662ff7..fc05e093b33 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env-canada==0.7.2"] + "requirements": ["env-canada==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b10d8372466..c052c470bb6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -869,7 +869,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.7.2 +env-canada==0.8.0 # homeassistant.components.season ephem==4.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1120979e69..bc5586bf6c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -738,7 +738,7 @@ energyzero==2.1.1 enocean==0.50 # homeassistant.components.environment_canada -env-canada==0.7.2 +env-canada==0.8.0 # homeassistant.components.season ephem==4.1.6 diff --git a/tests/script/test_gen_requirements_all.py b/tests/script/test_gen_requirements_all.py index 519a5c21855..b667bdd3ddf 100644 --- a/tests/script/test_gen_requirements_all.py +++ b/tests/script/test_gen_requirements_all.py @@ -41,9 +41,9 @@ def test_requirement_override_markers() -> None: ): assert ( gen_requirements_all.process_action_requirement( - "env-canada==0.7.2", "pytest" + "env-canada==0.8.0", "pytest" ) - == "env-canada==0.7.2;python_version<'3.13'" + == "env-canada==0.8.0;python_version<'3.13'" ) assert ( gen_requirements_all.process_action_requirement("other==1.0", "pytest") From ee206938d8062461e005d95d1d7c0eaec67b5272 Mon Sep 17 00:00:00 2001 From: Joris Drenth Date: Sat, 1 Mar 2025 19:59:13 +0100 Subject: [PATCH 2934/2987] Update wallbox to 0.8.0 (#139553) Update Wallbox dependencies --- homeassistant/components/wallbox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wallbox/manifest.json b/homeassistant/components/wallbox/manifest.json index 63102646508..d217a018303 100644 --- a/homeassistant/components/wallbox/manifest.json +++ b/homeassistant/components/wallbox/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wallbox", "iot_class": "cloud_polling", "loggers": ["wallbox"], - "requirements": ["wallbox==0.7.0"] + "requirements": ["wallbox==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c052c470bb6..ffbab3d272d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3034,7 +3034,7 @@ vultr==0.1.2 wakeonlan==2.1.0 # homeassistant.components.wallbox -wallbox==0.7.0 +wallbox==0.8.0 # homeassistant.components.folder_watcher watchdog==6.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc5586bf6c7..2ae0a20caab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2444,7 +2444,7 @@ vultr==0.1.2 wakeonlan==2.1.0 # homeassistant.components.wallbox -wallbox==0.7.0 +wallbox==0.8.0 # homeassistant.components.folder_watcher watchdog==6.0.0 From d4099ab91732eff26ce56936875cf1a9ec7a0016 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 1 Mar 2025 20:16:11 +0100 Subject: [PATCH 2935/2987] Bump aiocomelit to 0.11.1 (#139589) --- homeassistant/components/comelit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 238dede8546..20d481e9a5b 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.10.1"] + "requirements": ["aiocomelit==0.11.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index ffbab3d272d..4c1a15839e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -213,7 +213,7 @@ aiobafi6==0.9.0 aiobotocore==2.13.1 # homeassistant.components.comelit -aiocomelit==0.10.1 +aiocomelit==0.11.1 # homeassistant.components.dhcp aiodhcpwatcher==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ae0a20caab..a26b276bc02 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -201,7 +201,7 @@ aiobafi6==0.9.0 aiobotocore==2.13.1 # homeassistant.components.comelit -aiocomelit==0.10.1 +aiocomelit==0.11.1 # homeassistant.components.dhcp aiodhcpwatcher==1.1.1 From 4813da33d6841afc27069406c40e32948c664976 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 1 Mar 2025 20:16:32 +0100 Subject: [PATCH 2936/2987] Improve field descriptions of `zha.permit` action (#139584) Make the field descriptions of `source_ieee` and `install_code` UI-friendly by cross-referencing them using their friendly names to allow matching translations. Better explain the alternative of using the `qr_code` field by adding that this contains both the IEEE address and the Install code of the joining device. --- homeassistant/components/zha/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 38f55fb550d..be1642227bd 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -274,15 +274,15 @@ }, "source_ieee": { "name": "Source IEEE", - "description": "IEEE address of the joining device (must be used with the install code)." + "description": "IEEE address of the joining device (must be combined with the 'Install code' field)." }, "install_code": { "name": "Install code", - "description": "Install code of the joining device (must be used with the source_ieee)." + "description": "Install code of the joining device (must be combined with the 'Source IEEE' field)." }, "qr_code": { "name": "QR code", - "description": "Value of the QR install code (different between vendors)." + "description": "Provides both the IEEE address and the install code of the joining device (different between vendors)." } } }, From b1a2b89691a3ffc96d9d1899706b3e6c29fee7c2 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 1 Mar 2025 20:18:52 +0100 Subject: [PATCH 2937/2987] Bump motionblinds to 0.6.26 (#139591) --- homeassistant/components/motion_blinds/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index b327c146300..1654d5b5937 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.25"] + "requirements": ["motionblinds==0.6.26"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4c1a15839e7..0554810837f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1432,7 +1432,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.25 +motionblinds==0.6.26 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a26b276bc02..c86feb62135 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1204,7 +1204,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.25 +motionblinds==0.6.26 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 From 0c5766184b4b49d72302e31a99578c0a86cb937f Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 1 Mar 2025 19:22:34 +0000 Subject: [PATCH 2938/2987] Fix Manufacturer naming for Squeezelite model name for Squeezebox (#139586) Squeezelite Manufacturer Fix --- homeassistant/components/squeezebox/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 0cd539b4584..1767d92730a 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -224,7 +224,7 @@ class SqueezeBoxMediaPlayerEntity( self._previous_media_position = 0 self._attr_unique_id = format_mac(player.player_id) _manufacturer = None - if player.model == "SqueezeLite" or "SqueezePlay" in player.model: + if player.model.startswith("SqueezeLite") or "SqueezePlay" in player.model: _manufacturer = "Ralph Irving" elif ( "Squeezebox" in player.model From 51beb1c0a86881eb9b411e8b1417527da7b39310 Mon Sep 17 00:00:00 2001 From: Trevor Morgan <5444727+clever-trevor@users.noreply.github.com> Date: Sat, 1 Mar 2025 19:26:04 +0000 Subject: [PATCH 2939/2987] Add simplisafe OUTDOOR_ALARM_SECURITY_BELL_BOX device type (#134386) * Update binary_sensor.py to included OUTDOOR_ALARM_SECURITY_BELL_BOX device type Add support for DeviceTypes.OUTDOOR_ALARM_SECURITY_BELL_BOX This is an external siren device in Simplisafe which is not currently discovered with the HA integration * Fixed formatting error --------- Co-authored-by: Franck Nijhof --- homeassistant/components/simplisafe/binary_sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index e1f69ed8113..38a80ddd354 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -34,6 +34,7 @@ SUPPORTED_BATTERY_SENSOR_TYPES = [ DeviceTypes.PANIC_BUTTON, DeviceTypes.REMOTE, DeviceTypes.SIREN, + DeviceTypes.OUTDOOR_ALARM_SECURITY_BELL_BOX, DeviceTypes.SMOKE, DeviceTypes.SMOKE_AND_CARBON_MONOXIDE, DeviceTypes.TEMPERATURE, @@ -47,6 +48,7 @@ TRIGGERED_SENSOR_TYPES = { DeviceTypes.MOTION: BinarySensorDeviceClass.MOTION, DeviceTypes.MOTION_V2: BinarySensorDeviceClass.MOTION, DeviceTypes.SIREN: BinarySensorDeviceClass.SAFETY, + DeviceTypes.OUTDOOR_ALARM_SECURITY_BELL_BOX: BinarySensorDeviceClass.SAFETY, DeviceTypes.SMOKE: BinarySensorDeviceClass.SMOKE, # Although this sensor can technically apply to both smoke and carbon, we use the # SMOKE device class for simplicity: From b3f14d72c05666d0a450774e603fdaf01d0a22c4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 20:47:42 +0100 Subject: [PATCH 2940/2987] Don't require not needed scopes in SmartThings (#139576) * Don't require not needed scopes * Don't require not needed scopes --- homeassistant/components/smartthings/const.py | 2 -- .../smartthings/test_config_flow.py | 33 +++++++------------ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index c39d225dd09..80c4cf90226 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -14,8 +14,6 @@ SCOPES = [ "x:scenes:*", "r:rules:*", "w:rules:*", - "r:installedapps", - "w:installedapps", "sse", ] diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 61e2b464920..2fbd686e4d3 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -57,7 +57,7 @@ async def test_full_flow( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse" ) client = await hass_client_no_auth() @@ -75,8 +75,7 @@ async def test_full_flow( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -93,8 +92,7 @@ async def test_full_flow( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", } @@ -130,7 +128,7 @@ async def test_not_enough_scopes( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse" ) client = await hass_client_no_auth() @@ -192,7 +190,7 @@ async def test_duplicate_entry( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse" ) client = await hass_client_no_auth() @@ -210,8 +208,7 @@ async def test_duplicate_entry( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -261,8 +258,7 @@ async def test_reauthentication( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -280,8 +276,7 @@ async def test_reauthentication( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", } @@ -377,8 +372,7 @@ async def test_reauth_account_mismatch( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, @@ -429,8 +423,7 @@ async def test_migration( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, @@ -461,8 +454,7 @@ async def test_migration( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, @@ -516,8 +508,7 @@ async def test_migration_wrong_location( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, From dd21d48ae48c010b5dbb0468f872ce11c176b723 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Sat, 1 Mar 2025 20:53:06 +0100 Subject: [PATCH 2941/2987] Homee: fix watchdog icon (#139577) fix watchdog icon --- homeassistant/components/homee/icons.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json index 07ae598095b..17ac0ecd1f2 100644 --- a/homeassistant/components/homee/icons.json +++ b/homeassistant/components/homee/icons.json @@ -9,7 +9,7 @@ } }, "switch": { - "watchdog_on_off": { + "watchdog": { "default": "mdi:dog" }, "manual_operation": { From 913a4ee9ba38a5cede4b9dd94af47907a11521bb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 1 Mar 2025 21:14:08 +0100 Subject: [PATCH 2942/2987] Improve certificate handling in MQTT config flow (#137234) * Improve mqtt broker certificate handling in config flow * Expand test cases --- homeassistant/components/mqtt/config_flow.py | 146 ++++++++- homeassistant/components/mqtt/strings.json | 8 +- tests/components/mqtt/test_config_flow.py | 296 +++++++++++++++++-- 3 files changed, 415 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 22568b0f2b8..ad188c50aa9 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -5,14 +5,21 @@ from __future__ import annotations import asyncio from collections import OrderedDict from collections.abc import Callable, Mapping +from enum import IntEnum import logging import queue from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError from types import MappingProxyType from typing import TYPE_CHECKING, Any -from cryptography.hazmat.primitives.serialization import load_pem_private_key -from cryptography.x509 import load_pem_x509_certificate +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + load_der_private_key, + load_pem_private_key, +) +from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate import voluptuous as vol from homeassistant.components.file_upload import process_uploaded_file @@ -105,6 +112,8 @@ _LOGGER = logging.getLogger(__name__) ADDON_SETUP_TIMEOUT = 5 ADDON_SETUP_TIMEOUT_ROUNDS = 5 +CONF_CLIENT_KEY_PASSWORD = "client_key_password" + MQTT_TIMEOUT = 5 ADVANCED_OPTIONS = "advanced_options" @@ -165,12 +174,14 @@ BROKER_VERIFICATION_SELECTOR = SelectSelector( # mime configuration from https://pki-tutorial.readthedocs.io/en/latest/mime.html CA_CERT_UPLOAD_SELECTOR = FileSelector( - FileSelectorConfig(accept=".crt,application/x-x509-ca-cert") + FileSelectorConfig(accept=".pem,.crt,.cer,.der,application/x-x509-ca-cert") ) CERT_UPLOAD_SELECTOR = FileSelector( - FileSelectorConfig(accept=".crt,application/x-x509-user-cert") + FileSelectorConfig(accept=".pem,.crt,.cer,.der,application/x-x509-user-cert") +) +KEY_UPLOAD_SELECTOR = FileSelector( + FileSelectorConfig(accept=".pem,.key,.der,.pk8,application/pkcs8") ) -KEY_UPLOAD_SELECTOR = FileSelector(FileSelectorConfig(accept=".key,application/pkcs8")) REAUTH_SCHEMA = vol.Schema( { @@ -710,17 +721,88 @@ class MQTTOptionsFlowHandler(OptionsFlow): ) -async def _get_uploaded_file(hass: HomeAssistant, id: str) -> str: - """Get file content from uploaded file.""" +@callback +def async_is_pem_data(data: bytes) -> bool: + """Return True if data is in PEM format.""" + return ( + b"-----BEGIN CERTIFICATE-----" in data + or b"-----BEGIN PRIVATE KEY-----" in data + or b"-----BEGIN RSA PRIVATE KEY-----" in data + or b"-----BEGIN ENCRYPTED PRIVATE KEY-----" in data + ) - def _proces_uploaded_file() -> str: + +class PEMType(IntEnum): + """Type of PEM data.""" + + CERTIFICATE = 1 + PRIVATE_KEY = 2 + + +@callback +def async_convert_to_pem( + data: bytes, pem_type: PEMType, password: str | None = None +) -> str | None: + """Convert data to PEM format.""" + try: + if async_is_pem_data(data): + if not password: + # Assume unencrypted PEM encoded private key + return data.decode(DEFAULT_ENCODING) + # Return decrypted PEM encoded private key + return ( + load_pem_private_key(data, password=password.encode(DEFAULT_ENCODING)) + .private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption(), + ) + .decode(DEFAULT_ENCODING) + ) + # Convert from DER encoding to PEM + if pem_type == PEMType.CERTIFICATE: + return ( + load_der_x509_certificate(data) + .public_bytes( + encoding=Encoding.PEM, + ) + .decode(DEFAULT_ENCODING) + ) + # Assume DER encoded private key + pem_key_data: bytes = load_der_private_key( + data, password.encode(DEFAULT_ENCODING) if password else None + ).private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption(), + ) + return pem_key_data.decode("utf-8") + except (TypeError, ValueError, SSLError): + _LOGGER.exception("Error converting %s file data to PEM format", pem_type.name) + return None + + +async def _get_uploaded_file(hass: HomeAssistant, id: str) -> bytes: + """Get file content from uploaded certificate or key file.""" + + def _proces_uploaded_file() -> bytes: with process_uploaded_file(hass, id) as file_path: - return file_path.read_text(encoding=DEFAULT_ENCODING) + return file_path.read_bytes() return await hass.async_add_executor_job(_proces_uploaded_file) -async def async_get_broker_settings( +def _validate_pki_file( + file_id: str | None, pem_data: str | None, errors: dict[str, str], error: str +) -> bool: + """Return False if uploaded file could not be converted to PEM format.""" + if file_id and not pem_data: + errors["base"] = error + return False + return True + + +async def async_get_broker_settings( # noqa: C901 flow: ConfigFlow | OptionsFlow, fields: OrderedDict[Any, Any], entry_config: MappingProxyType[str, Any] | None, @@ -768,6 +850,10 @@ async def async_get_broker_settings( validated_user_input.update(user_input) client_certificate_id: str | None = user_input.get(CONF_CLIENT_CERT) client_key_id: str | None = user_input.get(CONF_CLIENT_KEY) + # We do not store the private key password in the entry data + client_key_password: str | None = validated_user_input.pop( + CONF_CLIENT_KEY_PASSWORD, None + ) if (client_certificate_id and not client_key_id) or ( not client_certificate_id and client_key_id ): @@ -775,7 +861,14 @@ async def async_get_broker_settings( return False certificate_id: str | None = user_input.get(CONF_CERTIFICATE) if certificate_id: - certificate = await _get_uploaded_file(hass, certificate_id) + certificate_data_raw = await _get_uploaded_file(hass, certificate_id) + certificate = async_convert_to_pem( + certificate_data_raw, PEMType.CERTIFICATE + ) + if not _validate_pki_file( + certificate_id, certificate, errors, "bad_certificate" + ): + return False # Return to form for file upload CA cert or client cert and key if ( @@ -797,9 +890,26 @@ async def async_get_broker_settings( return False if client_certificate_id: - client_certificate = await _get_uploaded_file(hass, client_certificate_id) + client_certificate_data = await _get_uploaded_file( + hass, client_certificate_id + ) + client_certificate = async_convert_to_pem( + client_certificate_data, PEMType.CERTIFICATE + ) + if not _validate_pki_file( + client_certificate_id, client_certificate, errors, "bad_client_cert" + ): + return False + if client_key_id: - client_key = await _get_uploaded_file(hass, client_key_id) + client_key_data = await _get_uploaded_file(hass, client_key_id) + client_key = async_convert_to_pem( + client_key_data, PEMType.PRIVATE_KEY, password=client_key_password + ) + if not _validate_pki_file( + client_key_id, client_key, errors, "client_key_error" + ): + return False certificate_data: dict[str, Any] = {} if certificate: @@ -956,6 +1066,14 @@ async def async_get_broker_settings( description={"suggested_value": user_input_basic.get(CONF_CLIENT_KEY)}, ) ] = KEY_UPLOAD_SELECTOR + fields[ + vol.Optional( + CONF_CLIENT_KEY_PASSWORD, + description={ + "suggested_value": user_input_basic.get(CONF_CLIENT_KEY_PASSWORD) + }, + ) + ] = PASSWORD_SELECTOR verification_mode = current_config.get(SET_CA_CERT) or ( "off" if current_ca_certificate is None @@ -1060,7 +1178,7 @@ def check_certicate_chain() -> str | None: with open(private_key, "rb") as client_key_file: load_pem_private_key(client_key_file.read(), password=None) except (TypeError, ValueError): - return "bad_client_key" + return "client_key_error" # Check the certificate chain context = SSLContext(PROTOCOL_TLS_CLIENT) if client_certificate and private_key: diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index fc316306d56..8805f447d69 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -26,6 +26,7 @@ "client_id": "Client ID (leave empty to randomly generated one)", "client_cert": "Upload client certificate file", "client_key": "Upload private key file", + "client_key_password": "[%key:common::config_flow::data::password%]", "keepalive": "The time between sending keep alive messages", "tls_insecure": "Ignore broker certificate validation", "protocol": "MQTT protocol", @@ -45,6 +46,7 @@ "client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.", "client_cert": "The client certificate to authenticate against your MQTT broker.", "client_key": "The private key file that belongs to your client certificate.", + "client_key_password": "The password for the private key file (if set).", "keepalive": "A value less than 90 seconds is advised.", "tls_insecure": "Option to ignore validation of your MQTT broker's certificate.", "protocol": "The MQTT protocol your broker operates at. For example 3.1.1.", @@ -93,8 +95,8 @@ "bad_will": "Invalid will topic", "bad_discovery_prefix": "Invalid discovery prefix", "bad_certificate": "The CA certificate is invalid", - "bad_client_cert": "Invalid client certificate, ensure a PEM coded file is supplied", - "bad_client_key": "Invalid private key, ensure a PEM coded file is supplied without password", + "bad_client_cert": "Invalid client certificate, ensure a valid file is supplied", + "client_key_error": "Invalid private key file or invalid password supplied", "bad_client_cert_key": "Client certificate and private key are not a valid pair", "bad_ws_headers": "Supply valid HTTP headers as a JSON object", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -207,7 +209,7 @@ "bad_discovery_prefix": "[%key:component::mqtt::config::error::bad_discovery_prefix%]", "bad_certificate": "[%key:component::mqtt::config::error::bad_certificate%]", "bad_client_cert": "[%key:component::mqtt::config::error::bad_client_cert%]", - "bad_client_key": "[%key:component::mqtt::config::error::bad_client_key%]", + "client_key_error": "[%key:component::mqtt::config::error::client_key_error%]", "bad_client_cert_key": "[%key:component::mqtt::config::error::bad_client_cert_key%]", "bad_ws_headers": "[%key:component::mqtt::config::error::bad_ws_headers%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index de70fd32763..f39e32a0d8b 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -40,8 +40,37 @@ ADD_ON_DISCOVERY_INFO = { "protocol": "3.1.1", "ssl": False, } -MOCK_CLIENT_CERT = b"## mock client certificate file ##" -MOCK_CLIENT_KEY = b"## mock key file ##" + +MOCK_CA_CERT = ( + b"-----BEGIN CERTIFICATE-----\n" + b"## mock CA certificate file ##" + b"\n-----END CERTIFICATE-----\n" +) +MOCK_GENERIC_CERT = ( + b"-----BEGIN CERTIFICATE-----\n" + b"## mock generic certificate file ##" + b"\n-----END CERTIFICATE-----\n" +) +MOCK_CA_CERT_DER = b"## mock DER formatted CA certificate file ##\n" +MOCK_CLIENT_CERT = ( + b"-----BEGIN CERTIFICATE-----\n" + b"## mock client certificate file ##" + b"\n-----END CERTIFICATE-----\n" +) +MOCK_CLIENT_CERT_DER = b"## mock DER formatted client certificate file ##\n" +MOCK_CLIENT_KEY = ( + b"-----BEGIN PRIVATE KEY-----\n" + b"## mock client key file ##" + b"\n-----END PRIVATE KEY-----" +) +MOCK_ENCRYPTED_CLIENT_KEY = ( + b"-----BEGIN ENCRYPTED PRIVATE KEY-----\n" + b"## mock client key file ##\n" + b"-----END ENCRYPTED PRIVATE KEY-----" +) +MOCK_CLIENT_KEY_DER = b"## mock DER formatted key file ##\n" +MOCK_ENCRYPTED_CLIENT_KEY_DER = b"## mock DER formatted encrypted key file ##\n" + MOCK_ENTRY_DATA = { mqtt.CONF_BROKER: "test-broker", @@ -102,15 +131,27 @@ def mock_ssl_context() -> Generator[dict[str, MagicMock]]: patch("homeassistant.components.mqtt.config_flow.SSLContext") as mock_context, patch( "homeassistant.components.mqtt.config_flow.load_pem_private_key" - ) as mock_key_check, + ) as mock_pem_key_check, + patch( + "homeassistant.components.mqtt.config_flow.load_der_private_key" + ) as mock_der_key_check, patch( "homeassistant.components.mqtt.config_flow.load_pem_x509_certificate" - ) as mock_cert_check, + ) as mock_pem_cert_check, + patch( + "homeassistant.components.mqtt.config_flow.load_der_x509_certificate" + ) as mock_der_cert_check, ): + mock_pem_key_check().private_bytes.return_value = MOCK_CLIENT_KEY + mock_pem_cert_check().public_bytes.return_value = MOCK_GENERIC_CERT + mock_der_key_check().private_bytes.return_value = MOCK_CLIENT_KEY + mock_der_cert_check().public_bytes.return_value = MOCK_GENERIC_CERT yield { "context": mock_context, - "load_pem_x509_certificate": mock_cert_check, - "load_pem_private_key": mock_key_check, + "load_der_private_key": mock_der_key_check, + "load_der_x509_certificate": mock_der_cert_check, + "load_pem_private_key": mock_pem_key_check, + "load_pem_x509_certificate": mock_pem_cert_check, } @@ -180,9 +221,31 @@ def mock_try_connection_time_out() -> Generator[MagicMock]: yield mock_client() +@pytest.fixture +def mock_ca_cert() -> bytes: + """Mock the CA certificate.""" + return MOCK_CA_CERT + + +@pytest.fixture +def mock_client_cert() -> bytes: + """Mock the client certificate.""" + return MOCK_CLIENT_CERT + + +@pytest.fixture +def mock_client_key() -> bytes: + """Mock the client key.""" + return MOCK_CLIENT_KEY + + @pytest.fixture def mock_process_uploaded_file( - tmp_path: Path, mock_temp_dir: str + tmp_path: Path, + mock_ca_cert: bytes, + mock_client_cert: bytes, + mock_client_key: bytes, + mock_temp_dir: str, ) -> Generator[MagicMock]: """Mock upload certificate files.""" file_id_ca = str(uuid4()) @@ -195,15 +258,15 @@ def mock_process_uploaded_file( ) -> Iterator[Path | None]: if file_id == file_id_ca: with open(tmp_path / "ca.crt", "wb") as cafile: - cafile.write(b"## mock CA certificate file ##") + cafile.write(mock_ca_cert) yield tmp_path / "ca.crt" elif file_id == file_id_cert: with open(tmp_path / "client.crt", "wb") as certfile: - certfile.write(b"## mock client certificate file ##") + certfile.write(mock_client_cert) yield tmp_path / "client.crt" elif file_id == file_id_key: with open(tmp_path / "client.key", "wb") as keyfile: - keyfile.write(b"## mock key file ##") + keyfile.write(mock_client_key) yield tmp_path / "client.key" else: pytest.fail(f"Unexpected file_id: {file_id}") @@ -1024,12 +1087,37 @@ async def test_option_flow( assert yaml_mock.await_count +@pytest.mark.parametrize( + ("mock_ca_cert", "mock_client_cert", "mock_client_key", "client_key_password"), + [ + (MOCK_GENERIC_CERT, MOCK_GENERIC_CERT, MOCK_CLIENT_KEY, ""), + ( + MOCK_GENERIC_CERT, + MOCK_GENERIC_CERT, + MOCK_ENCRYPTED_CLIENT_KEY, + "very*secret", + ), + (MOCK_CA_CERT_DER, MOCK_CLIENT_CERT_DER, MOCK_CLIENT_KEY_DER, ""), + ( + MOCK_CA_CERT_DER, + MOCK_CLIENT_CERT_DER, + MOCK_ENCRYPTED_CLIENT_KEY_DER, + "very*secret", + ), + ], + ids=[ + "pem_certs_private_key_no_password", + "pem_certs_private_key_with_password", + "der_certs_private_key_no_password", + "der_certs_private_key_with_password", + ], +) @pytest.mark.parametrize( "test_error", [ "bad_certificate", "bad_client_cert", - "bad_client_key", + "client_key_error", "bad_client_cert_key", "invalid_inclusion", None, @@ -1042,31 +1130,54 @@ async def test_bad_certificate( mock_ssl_context: dict[str, MagicMock], mock_process_uploaded_file: MagicMock, test_error: str | None, + client_key_password: str, + mock_ca_cert: bytes, ) -> None: """Test bad certificate tests.""" + + def _side_effect_on_client_cert(data: bytes) -> MagicMock: + """Raise on client cert only. + + The function is called twice, once for the CA chain + and once for the client cert. We only want to raise on a client cert. + """ + if data == MOCK_CLIENT_CERT_DER: + raise ValueError + mock_certificate_side_effect = MagicMock() + mock_certificate_side_effect().public_bytes.return_value = MOCK_GENERIC_CERT + return mock_certificate_side_effect + # Mock certificate files file_id = mock_process_uploaded_file.file_id + set_ca_cert = "custom" + set_client_cert = True + tls_insecure = False test_input = { mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345, mqtt.CONF_CERTIFICATE: file_id[mqtt.CONF_CERTIFICATE], mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT], mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY], - "set_ca_cert": True, + "client_key_password": client_key_password, + "set_ca_cert": set_ca_cert, "set_client_cert": True, } - set_client_cert = True - set_ca_cert = "custom" - tls_insecure = False if test_error == "bad_certificate": # CA chain is not loading mock_ssl_context["context"]().load_verify_locations.side_effect = SSLError + # Fail on the CA cert if DER encoded + mock_ssl_context["load_der_x509_certificate"].side_effect = ValueError elif test_error == "bad_client_cert": # Client certificate is invalid mock_ssl_context["load_pem_x509_certificate"].side_effect = ValueError - elif test_error == "bad_client_key": + # Fail on the client cert if DER encoded + mock_ssl_context[ + "load_der_x509_certificate" + ].side_effect = _side_effect_on_client_cert + elif test_error == "client_key_error": # Client key file is invalid mock_ssl_context["load_pem_private_key"].side_effect = ValueError + mock_ssl_context["load_der_private_key"].side_effect = ValueError elif test_error == "bad_client_cert_key": # Client key file file and certificate do not pair mock_ssl_context["context"]().load_cert_chain.side_effect = SSLError @@ -2078,8 +2189,8 @@ async def test_setup_with_advanced_settings( CONF_USERNAME: "user", CONF_PASSWORD: "secret", mqtt.CONF_KEEPALIVE: 30, - mqtt.CONF_CLIENT_CERT: "## mock client certificate file ##", - mqtt.CONF_CLIENT_KEY: "## mock key file ##", + mqtt.CONF_CLIENT_CERT: MOCK_CLIENT_CERT.decode(encoding="utf-8"), + mqtt.CONF_CLIENT_KEY: MOCK_CLIENT_KEY.decode(encoding="utf-8"), "tls_insecure": True, mqtt.CONF_TRANSPORT: "websockets", mqtt.CONF_WS_PATH: "/custom_path/", @@ -2091,6 +2202,155 @@ async def test_setup_with_advanced_settings( } +@pytest.mark.usefixtures("mock_ssl_context") +@pytest.mark.parametrize( + ("mock_ca_cert", "mock_client_cert", "mock_client_key", "client_key_password"), + [ + (MOCK_GENERIC_CERT, MOCK_GENERIC_CERT, MOCK_CLIENT_KEY, ""), + ( + MOCK_GENERIC_CERT, + MOCK_GENERIC_CERT, + MOCK_ENCRYPTED_CLIENT_KEY, + "very*secret", + ), + (MOCK_CA_CERT_DER, MOCK_CLIENT_CERT_DER, MOCK_CLIENT_KEY_DER, ""), + ( + MOCK_CA_CERT_DER, + MOCK_CLIENT_CERT_DER, + MOCK_ENCRYPTED_CLIENT_KEY_DER, + "very*secret", + ), + ], + ids=[ + "pem_certs_private_key_no_password", + "pem_certs_private_key_with_password", + "der_certs_private_key_no_password", + "der_certs_private_key_with_password", + ], +) +async def test_setup_with_certificates( + hass: HomeAssistant, + mock_try_connection: MagicMock, + mock_process_uploaded_file: MagicMock, + client_key_password: str, +) -> None: + """Test config flow setup with PEM and DER encoded certificates.""" + file_id = mock_process_uploaded_file.file_id + + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) + config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + config_entry, + data={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 1234, + }, + ) + + mock_try_connection.return_value = True + + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert result["data_schema"].schema["advanced_options"] + + # first iteration, basic settings + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + "advanced_options": True, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert "advanced_options" not in result["data_schema"].schema + assert result["data_schema"].schema[CONF_CLIENT_ID] + assert result["data_schema"].schema[mqtt.CONF_KEEPALIVE] + assert result["data_schema"].schema["set_client_cert"] + assert result["data_schema"].schema["set_ca_cert"] + assert result["data_schema"].schema[mqtt.CONF_TLS_INSECURE] + assert result["data_schema"].schema[CONF_PROTOCOL] + assert result["data_schema"].schema[mqtt.CONF_TRANSPORT] + assert mqtt.CONF_CLIENT_CERT not in result["data_schema"].schema + assert mqtt.CONF_CLIENT_KEY not in result["data_schema"].schema + + # second iteration, advanced settings with request for client cert + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + "set_ca_cert": "custom", + "set_client_cert": True, + mqtt.CONF_TLS_INSECURE: False, + CONF_PROTOCOL: "3.1.1", + mqtt.CONF_TRANSPORT: "tcp", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert "advanced_options" not in result["data_schema"].schema + assert result["data_schema"].schema[CONF_CLIENT_ID] + assert result["data_schema"].schema[mqtt.CONF_KEEPALIVE] + assert result["data_schema"].schema["set_client_cert"] + assert result["data_schema"].schema["set_ca_cert"] + assert result["data_schema"].schema["client_key_password"] + assert result["data_schema"].schema[mqtt.CONF_TLS_INSECURE] + assert result["data_schema"].schema[CONF_PROTOCOL] + assert result["data_schema"].schema[mqtt.CONF_CERTIFICATE] + assert result["data_schema"].schema[mqtt.CONF_CLIENT_CERT] + assert result["data_schema"].schema[mqtt.CONF_CLIENT_KEY] + assert result["data_schema"].schema[mqtt.CONF_TRANSPORT] + + # third iteration, advanced settings with client cert and key and CA certificate + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + "set_ca_cert": "custom", + "set_client_cert": True, + "client_key_password": client_key_password, + mqtt.CONF_CERTIFICATE: file_id[mqtt.CONF_CERTIFICATE], + mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT], + mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY], + mqtt.CONF_TLS_INSECURE: False, + mqtt.CONF_TRANSPORT: "tcp", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check config entry result + assert config_entry.data == { + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + mqtt.CONF_CLIENT_CERT: MOCK_GENERIC_CERT.decode(encoding="utf-8"), + mqtt.CONF_CLIENT_KEY: MOCK_CLIENT_KEY.decode(encoding="utf-8"), + "tls_insecure": False, + mqtt.CONF_TRANSPORT: "tcp", + mqtt.CONF_CERTIFICATE: MOCK_GENERIC_CERT.decode(encoding="utf-8"), + } + + @pytest.mark.usefixtures("mock_ssl_context", "mock_process_uploaded_file") async def test_change_websockets_transport_to_tcp( hass: HomeAssistant, mock_try_connection: MagicMock From c1686953239e4f18819c45b59aafe1d92d411236 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 1 Mar 2025 20:18:30 +0000 Subject: [PATCH 2943/2987] Clean up squeezebox build_item_response part 1 (#139321) * initial * final * is internal change * test data coverage * Review fixes * final --- .../components/squeezebox/browse_media.py | 153 +++++++++++------- tests/components/squeezebox/conftest.py | 2 +- 2 files changed, 93 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 6bc1d2380cf..82fa55c7b2f 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -138,6 +138,8 @@ class BrowseItemResponse: child_media_class: dict[str, MediaClass | None] can_expand: bool can_play: bool + title: str + id: str def _add_new_command_to_browse_data( @@ -154,11 +156,12 @@ def _add_new_command_to_browse_data( def _build_response_apps_radios_category( - browse_data: BrowseData, - cmd: str | MediaType, + browse_data: BrowseData, cmd: str | MediaType, item: dict[str, Any] ) -> BrowseItemResponse: """Build item for App or radio category.""" return BrowseItemResponse( + id=item.get("id", ""), + title=item["title"], child_item_type=cmd, child_media_class=browse_data.content_type_media_class[cmd], can_expand=True, @@ -172,6 +175,8 @@ def _build_response_known_app( """Build item for app or radio.""" return BrowseItemResponse( + id=item.get("id", ""), + title=item["title"], child_item_type=search_type, child_media_class=browse_data.content_type_media_class[search_type], can_play=bool(item["isaudio"] and item.get("url")), @@ -179,6 +184,61 @@ def _build_response_known_app( ) +def _build_response_favorites(item: dict[str, Any]) -> BrowseItemResponse: + """Build item for Favorites.""" + if "album_id" in item: + return BrowseItemResponse( + id=str(item["album_id"]), + title=item["title"], + child_item_type=MediaType.ALBUM, + child_media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM], + can_expand=True, + can_play=True, + ) + if item["hasitems"] and not item["isaudio"]: + return BrowseItemResponse( + id=item.get("id", ""), + title=item["title"], + child_item_type="Favorites", + child_media_class=CONTENT_TYPE_MEDIA_CLASS["Favorites"], + can_expand=True, + can_play=False, + ) + return BrowseItemResponse( + id=item.get("id", ""), + title=item["title"], + child_item_type="Favorites", + child_media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK], + can_expand=item["hasitems"], + can_play=bool(item["isaudio"] and item.get("url")), + ) + + +def _get_item_thumbnail( + item: dict[str, Any], + player: Player, + entity: MediaPlayerEntity, + item_type: str | MediaType | None, + search_type: str, + internal_request: bool, +) -> str | None: + """Construct path to thumbnail image.""" + item_thumbnail: str | None = None + if artwork_track_id := item.get("artwork_track_id"): + if internal_request: + item_thumbnail = player.generate_image_url_from_track_id(artwork_track_id) + elif item_type is not None: + item_thumbnail = entity.get_browse_image_url( + item_type, item.get("id", ""), artwork_track_id + ) + + elif search_type in ["Apps", "Radios"]: + item_thumbnail = player.generate_image_url(item["icon"]) + if item_thumbnail is None: + item_thumbnail = item.get("image_url") # will not be proxied by HA + return item_thumbnail + + async def build_item_response( entity: MediaPlayerEntity, player: Player, @@ -216,34 +276,12 @@ async def build_item_response( children = [] list_playable = [] for item in result["items"]: - item_id = str(item.get("id", "")) item_thumbnail: str | None = None - if item_type: - child_item_type: MediaType | str = item_type - child_media_class = CONTENT_TYPE_MEDIA_CLASS[item_type] - can_expand = child_media_class["children"] is not None - can_play = True - if search_type == "Favorites": - if "album_id" in item: - item_id = str(item["album_id"]) - child_item_type = MediaType.ALBUM - child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM] - can_expand = True - can_play = True - elif item["hasitems"] and not item["isaudio"]: - child_item_type = "Favorites" - child_media_class = CONTENT_TYPE_MEDIA_CLASS["Favorites"] - can_expand = True - can_play = False - else: - child_item_type = "Favorites" - child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK] - can_expand = item["hasitems"] - can_play = item["isaudio"] and item.get("url") + browse_item_response = _build_response_favorites(item) - if search_type in ["Apps", "Radios"]: + elif search_type in ["Apps", "Radios"]: # item["cmd"] contains the name of the command to use with the cli for the app # add the command to the dictionaries if item["title"] == "Search" or item.get("type") in UNPLAYABLE_TYPES: @@ -253,19 +291,12 @@ async def build_item_response( if app_cmd not in browse_data.known_apps_radios: browse_data.known_apps_radios.add(app_cmd) - - _add_new_command_to_browse_data(browse_data, app_cmd, "item_id") + _add_new_command_to_browse_data(browse_data, app_cmd, "item_id") browse_item_response = _build_response_apps_radios_category( - browse_data, app_cmd + browse_data=browse_data, cmd=app_cmd, item=item ) - # Temporary variables until remainder of browse calls are restructured - child_item_type = browse_item_response.child_item_type - child_media_class = browse_item_response.child_media_class - can_expand = browse_item_response.can_expand - can_play = browse_item_response.can_play - elif search_type in browse_data.known_apps_radios: if ( item.get("title") in ["Search", None] @@ -278,39 +309,39 @@ async def build_item_response( browse_data, search_type, item ) - # Temporary variables until remainder of browse calls are restructured - child_item_type = browse_item_response.child_item_type - child_media_class = browse_item_response.child_media_class - can_expand = browse_item_response.can_expand - can_play = browse_item_response.can_play + elif item_type: + browse_item_response = BrowseItemResponse( + id=str(item.get("id", "")), + title=item["title"], + child_item_type=item_type, + child_media_class=CONTENT_TYPE_MEDIA_CLASS[item_type], + can_expand=CONTENT_TYPE_MEDIA_CLASS[item_type]["children"] + is not None, + can_play=True, + ) - if artwork_track_id := item.get("artwork_track_id"): - if internal_request: - item_thumbnail = player.generate_image_url_from_track_id( - artwork_track_id - ) - elif item_type is not None: - item_thumbnail = entity.get_browse_image_url( - item_type, item_id, artwork_track_id - ) - elif search_type in ["Apps", "Radios"]: - item_thumbnail = player.generate_image_url(item["icon"]) - else: - item_thumbnail = item.get("image_url") # will not be proxied by HA + item_thumbnail = _get_item_thumbnail( + item=item, + player=player, + entity=entity, + item_type=item_type, + search_type=search_type, + internal_request=internal_request, + ) - assert child_media_class["item"] is not None + assert browse_item_response.child_media_class["item"] is not None children.append( BrowseMedia( - title=item["title"], - media_class=child_media_class["item"], - media_content_id=item_id, - media_content_type=child_item_type, - can_play=can_play, - can_expand=can_expand, + title=browse_item_response.title, + media_class=browse_item_response.child_media_class["item"], + media_content_id=browse_item_response.id, + media_content_type=browse_item_response.child_item_type, + can_play=browse_item_response.can_play, + can_expand=browse_item_response.can_expand, thumbnail=item_thumbnail, ) ) - list_playable.append(can_play) + list_playable.append(browse_item_response.can_play) if children is None: raise BrowseError(f"Media not found: {search_type} / {search_id}") diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 9ca750808c5..429c3b62087 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -163,7 +163,7 @@ async def mock_async_browse( "title": "Fake Item 2", "id": FAKE_VALID_ITEM_ID + "_2", "hasitems": media_type == "favorites", - "isaudio": True, + "isaudio": False, "item_type": child_types[media_type], "image_url": "http://lms.internal:9000/html/images/favorites.png", "url": "file:///var/lib/squeezeboxserver/music/track_2.mp3", From 2cce1b024e69186498f2b25d8f63d3a708258b0f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Mar 2025 15:43:00 -0500 Subject: [PATCH 2944/2987] Migrate Assist Pipeline to use TTS stream (#139542) * Migrate Pipeline to use TTS stream * Fix tests --- .../components/assist_pipeline/pipeline.py | 63 ++++----- homeassistant/components/tts/__init__.py | 35 +++-- .../assist_pipeline/snapshots/test_init.ambr | 24 ++++ .../snapshots/test_websocket.ambr | 90 +++++++++---- tests/components/assist_pipeline/test_init.py | 42 +++--- .../assist_pipeline/test_websocket.py | 121 +++++------------- tests/components/tts/test_init.py | 23 ---- 7 files changed, 196 insertions(+), 202 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 038874d1966..a028fa638df 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -19,14 +19,7 @@ import wave import hass_nabucasa import voluptuous as vol -from homeassistant.components import ( - conversation, - media_source, - stt, - tts, - wake_word, - websocket_api, -) +from homeassistant.components import conversation, stt, tts, wake_word, websocket_api from homeassistant.components.tts import ( generate_media_source_id as tts_generate_media_source_id, ) @@ -569,8 +562,7 @@ class PipelineRun: id: str = field(default_factory=ulid_util.ulid_now) stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False, repr=False) - tts_engine: str = field(init=False, repr=False) - tts_options: dict | None = field(init=False, default=None) + tts_stream: tts.ResultStream | None = field(init=False, default=None) wake_word_entity_id: str | None = field(init=False, default=None, repr=False) wake_word_entity: wake_word.WakeWordDetectionEntity = field(init=False, repr=False) @@ -648,13 +640,18 @@ class PipelineRun: self._device_id = device_id self._start_debug_recording_thread() - data = { + data: dict[str, Any] = { "pipeline": self.pipeline.id, "language": self.language, "conversation_id": conversation_id, } if self.runner_data is not None: data["runner_data"] = self.runner_data + if self.tts_stream: + data["tts_output"] = { + "url": self.tts_stream.url, + "mime_type": self.tts_stream.content_type, + } self.process_event(PipelineEvent(PipelineEventType.RUN_START, data)) @@ -1246,36 +1243,31 @@ class PipelineRun: tts_options[tts.ATTR_PREFERRED_SAMPLE_BYTES] = SAMPLE_WIDTH try: - options_supported = await tts.async_support_options( - self.hass, - engine, - self.pipeline.tts_language, - tts_options, + self.tts_stream = tts.async_create_stream( + hass=self.hass, + engine=engine, + language=self.pipeline.tts_language, + options=tts_options, ) except HomeAssistantError as err: - raise TextToSpeechError( - code="tts-not-supported", - message=f"Text-to-speech engine '{engine}' not found", - ) from err - if not options_supported: raise TextToSpeechError( code="tts-not-supported", message=( f"Text-to-speech engine {engine} " - f"does not support language {self.pipeline.tts_language} or options {tts_options}" + f"does not support language {self.pipeline.tts_language} or options {tts_options}:" + f" {err}" ), - ) - - self.tts_engine = engine - self.tts_options = tts_options + ) from err async def text_to_speech(self, tts_input: str) -> None: """Run text-to-speech portion of pipeline.""" + assert self.tts_stream is not None + self.process_event( PipelineEvent( PipelineEventType.TTS_START, { - "engine": self.tts_engine, + "engine": self.tts_stream.engine, "language": self.pipeline.tts_language, "voice": self.pipeline.tts_voice, "tts_input": tts_input, @@ -1288,14 +1280,9 @@ class PipelineRun: tts_media_id = tts_generate_media_source_id( self.hass, tts_input, - engine=self.tts_engine, - language=self.pipeline.tts_language, - options=self.tts_options, - ) - tts_media = await media_source.async_resolve_media( - self.hass, - tts_media_id, - None, + engine=self.tts_stream.engine, + language=self.tts_stream.language, + options=self.tts_stream.options, ) except Exception as src_error: _LOGGER.exception("Unexpected error during text-to-speech") @@ -1304,10 +1291,12 @@ class PipelineRun: message="Unexpected error during text-to-speech", ) from src_error - _LOGGER.debug("TTS result %s", tts_media) + self.tts_stream.async_set_message(tts_input) + tts_output = { "media_id": tts_media_id, - **asdict(tts_media), + "url": self.tts_stream.url, + "mime_type": self.tts_stream.content_type, } self.process_event( diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 32c4ba20670..98ce76cafde 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -79,13 +79,13 @@ __all__ = [ "PLATFORM_SCHEMA", "PLATFORM_SCHEMA_BASE", "Provider", + "ResultStream", "SampleFormat", "TextToSpeechEntity", "TtsAudioType", "Voice", "async_default_engine", "async_get_media_source_audio", - "async_support_options", "generate_media_source_id", ] @@ -167,22 +167,19 @@ def async_resolve_engine(hass: HomeAssistant, engine: str | None) -> str | None: return async_default_engine(hass) -async def async_support_options( +@callback +def async_create_stream( hass: HomeAssistant, engine: str, language: str | None = None, options: dict | None = None, -) -> bool: - """Return if an engine supports options.""" - if (engine_instance := get_engine_instance(hass, engine)) is None: - raise HomeAssistantError(f"Provider {engine} not found") - - try: - hass.data[DATA_TTS_MANAGER].process_options(engine_instance, language, options) - except HomeAssistantError: - return False - - return True +) -> ResultStream: + """Create a streaming URL where the rendered TTS can be retrieved.""" + return hass.data[DATA_TTS_MANAGER].async_create_result_stream( + engine=engine, + language=language, + options=options, + ) async def async_get_media_source_audio( @@ -407,6 +404,18 @@ class ResultStream: """Set cache key for message to be streamed.""" self._result_cache_key.set_result(cache_key) + @callback + def async_set_message(self, message: str) -> None: + """Set message to be generated.""" + cache_key = self._manager.async_cache_message_in_memory( + engine=self.engine, + message=message, + use_file_cache=self.use_file_cache, + language=self.language, + options=self.options, + ) + self._result_cache_key.set_result(cache_key) + async def async_stream_result(self) -> AsyncGenerator[bytes]: """Get the stream of this result.""" cache_key = await self._result_cache_key diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index f5e5f813db6..2375d48fcf9 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -6,6 +6,10 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -99,6 +103,10 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -192,6 +200,10 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -285,6 +297,10 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -402,6 +418,10 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }), 'type': , }), @@ -598,6 +618,10 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }), 'type': , }), diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 509f2072509..d937b5396d1 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -8,6 +8,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline.1 @@ -93,6 +97,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_debug.1 @@ -190,6 +198,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_with_enhancements.1 @@ -275,6 +287,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.1 @@ -382,6 +398,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_with_wake_word_timeout.1 @@ -585,6 +605,10 @@ 'stt_binary_handler_id': None, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_pipeline_empty_tts_output.1 @@ -634,6 +658,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_cooldown_different_ids.1 @@ -645,6 +673,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_cooldown_same_id @@ -656,6 +688,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_cooldown_same_id.1 @@ -667,6 +703,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_stream_failed @@ -678,6 +718,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_stream_failed.1 @@ -798,28 +842,6 @@ 'message': 'Timeout running pipeline', }) # --- -# name: test_tts_failed - dict({ - 'conversation_id': 'mock-ulid', - 'language': 'en', - 'pipeline': , - 'runner_data': dict({ - 'stt_binary_handler_id': None, - 'timeout': 300, - }), - }) -# --- -# name: test_tts_failed.1 - dict({ - 'engine': 'test', - 'language': 'en-US', - 'tts_input': 'Lights are on.', - 'voice': 'james_earl_jones', - }) -# --- -# name: test_tts_failed.2 - None -# --- # name: test_wake_word_cooldown_different_entities dict({ 'conversation_id': 'mock-ulid', @@ -829,6 +851,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_entities.1 @@ -840,6 +866,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_entities.2 @@ -892,6 +922,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_ids.1 @@ -903,6 +937,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_ids.2 @@ -958,6 +996,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_same_id.1 @@ -969,6 +1011,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_same_id.2 diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index e983e4a96e3..0e04d1f0cd2 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -43,13 +43,21 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture(autouse=True) -def mock_ulid() -> Generator[Mock]: - """Mock the ulid of chat sessions.""" - with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: - mock_ulid_now.return_value = "mock-ulid" +def mock_chat_session_id() -> Generator[Mock]: + """Mock the conversation ID of chat sessions.""" + with patch( + "homeassistant.helpers.chat_session.ulid_now", return_value="mock-ulid" + ) as mock_ulid_now: yield mock_ulid_now +@pytest.fixture(autouse=True) +def mock_tts_token() -> Generator[None]: + """Mock the TTS token for URLs.""" + with patch("secrets.token_urlsafe", return_value="mocked-token"): + yield + + def process_events(events: list[assist_pipeline.PipelineEvent]) -> list[dict]: """Process events to remove dynamic values.""" processed = [] @@ -797,10 +805,16 @@ async def test_tts_audio_output( await pipeline_input.validate() # Verify TTS audio settings - assert pipeline_input.run.tts_options is not None - assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" - assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_SAMPLE_RATE) == 16000 - assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS) == 1 + assert pipeline_input.run.tts_stream.options is not None + assert pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" + assert ( + pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_SAMPLE_RATE) + == 16000 + ) + assert ( + pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS) + == 1 + ) with patch.object(mock_tts_provider, "get_tts_audio") as mock_get_tts_audio: await pipeline_input.execute() @@ -809,9 +823,7 @@ async def test_tts_audio_output( if event.type == assist_pipeline.PipelineEventType.TTS_END: # We must fetch the media URL to trigger the TTS assert event.data - media_id = event.data["tts_output"]["media_id"] - resolved = await media_source.async_resolve_media(hass, media_id, None) - await client.get(resolved.url) + await client.get(event.data["tts_output"]["url"]) # Ensure that no unsupported options were passed in assert mock_get_tts_audio.called @@ -875,9 +887,7 @@ async def test_tts_wav_preferred_format( if event.type == assist_pipeline.PipelineEventType.TTS_END: # We must fetch the media URL to trigger the TTS assert event.data - media_id = event.data["tts_output"]["media_id"] - resolved = await media_source.async_resolve_media(hass, media_id, None) - await client.get(resolved.url) + await client.get(event.data["tts_output"]["url"]) assert mock_get_tts_audio.called options = mock_get_tts_audio.call_args_list[0].kwargs["options"] @@ -949,9 +959,7 @@ async def test_tts_dict_preferred_format( if event.type == assist_pipeline.PipelineEventType.TTS_END: # We must fetch the media URL to trigger the TTS assert event.data - media_id = event.data["tts_output"]["media_id"] - resolved = await media_source.async_resolve_media(hass, media_id, None) - await client.get(resolved.url) + await client.get(event.data["tts_output"]["url"]) assert mock_get_tts_audio.called options = mock_get_tts_audio.call_args_list[0].kwargs["options"] diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index f856bbe7f61..060c0dce660 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -20,6 +20,8 @@ from homeassistant.components.assist_pipeline.pipeline import ( DeviceAudioQueue, Pipeline, PipelineData, + async_get_pipelines, + async_update_pipeline, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -38,13 +40,21 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def mock_ulid() -> Generator[Mock]: - """Mock the ulid of chat sessions.""" - with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: - mock_ulid_now.return_value = "mock-ulid" +def mock_chat_session_id() -> Generator[Mock]: + """Mock the conversation ID of chat sessions.""" + with patch( + "homeassistant.helpers.chat_session.ulid_now", return_value="mock-ulid" + ) as mock_ulid_now: yield mock_ulid_now +@pytest.fixture(autouse=True) +def mock_tts_token() -> Generator[None]: + """Mock the TTS token for URLs.""" + with patch("secrets.token_urlsafe", return_value="mocked-token"): + yield + + @pytest.mark.parametrize( "extra_msg", [ @@ -825,74 +835,6 @@ async def test_stt_stream_failed( assert msg["result"] == {"events": events} -async def test_tts_failed( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - init_components, - snapshot: SnapshotAssertion, -) -> None: - """Test pipeline run with text-to-speech error.""" - events = [] - client = await hass_ws_client(hass) - - with patch( - "homeassistant.components.media_source.async_resolve_media", - side_effect=RuntimeError, - ): - await client.send_json_auto_id( - { - "type": "assist_pipeline/run", - "start_stage": "tts", - "end_stage": "tts", - "input": {"text": "Lights are on."}, - } - ) - - # result - msg = await client.receive_json() - assert msg["success"] - - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - msg["event"]["data"]["pipeline"] = ANY - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) - - # tts start - msg = await client.receive_json() - assert msg["event"]["type"] == "tts-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) - - # tts error - msg = await client.receive_json() - assert msg["event"]["type"] == "error" - assert msg["event"]["data"]["code"] == "tts-failed" - events.append(msg["event"]) - - # run end - msg = await client.receive_json() - assert msg["event"]["type"] == "run-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) - - pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_debug)[0] - pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] - - await client.send_json_auto_id( - { - "type": "assist_pipeline/pipeline_debug/get", - "pipeline_id": pipeline_id, - "pipeline_run_id": pipeline_run_id, - } - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] == {"events": events} - - async def test_tts_provider_missing( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -903,23 +845,22 @@ async def test_tts_provider_missing( """Test pipeline run with text-to-speech error.""" client = await hass_ws_client(hass) - with patch( - "homeassistant.components.tts.async_support_options", - side_effect=HomeAssistantError, - ): - await client.send_json_auto_id( - { - "type": "assist_pipeline/run", - "start_stage": "tts", - "end_stage": "tts", - "input": {"text": "Lights are on."}, - } - ) + pipelines = async_get_pipelines(hass) + await async_update_pipeline(hass, pipelines[0], tts_engine="unavailable") - # result - msg = await client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "tts-not-supported" + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "tts", + "end_stage": "tts", + "input": {"text": "Lights are on."}, + } + ) + + # result + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "tts-not-supported" async def test_tts_provider_bad_options( @@ -933,8 +874,8 @@ async def test_tts_provider_bad_options( client = await hass_ws_client(hass) with patch( - "homeassistant.components.tts.async_support_options", - return_value=False, + "homeassistant.components.tts.SpeechManager.process_options", + side_effect=HomeAssistantError("Language not supported"), ): await client.send_json_auto_id( { diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 8dece920907..1b9692cc70c 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1376,29 +1376,6 @@ def test_resolve_engine(hass: HomeAssistant, setup: str, engine_id: str) -> None assert tts.async_resolve_engine(hass, None) is None -@pytest.mark.parametrize( - ("setup", "engine_id"), - [ - ("mock_setup", "test"), - ("mock_config_entry_setup", "tts.test"), - ], - indirect=["setup"], -) -async def test_support_options(hass: HomeAssistant, setup: str, engine_id: str) -> None: - """Test supporting options.""" - assert await tts.async_support_options(hass, engine_id, "en_US") is True - assert await tts.async_support_options(hass, engine_id, "nl") is False - assert ( - await tts.async_support_options( - hass, engine_id, "en_US", {"invalid_option": "yo"} - ) - is False - ) - - with pytest.raises(HomeAssistantError): - await tts.async_support_options(hass, "non-existing") - - async def test_legacy_fetching_in_async( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: From 3588784f1ef77e50c5d2c044813df2b3e138335f Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 1 Mar 2025 22:27:31 +0100 Subject: [PATCH 2945/2987] Add create_reward action to Habitica integration (#139304) Add create_reward action to Habitica --- homeassistant/components/habitica/const.py | 1 + homeassistant/components/habitica/icons.json | 6 + homeassistant/components/habitica/services.py | 82 +++++++++----- .../components/habitica/services.yaml | 21 +++- .../components/habitica/strings.json | 42 ++++++- tests/components/habitica/conftest.py | 3 + tests/components/habitica/test_services.py | 103 +++++++++++++++++- 7 files changed, 224 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 353bcbbd39d..bd1363ca979 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -56,6 +56,7 @@ SERVICE_SCORE_REWARD = "score_reward" SERVICE_TRANSFORMATION = "transformation" SERVICE_UPDATE_REWARD = "update_reward" +SERVICE_CREATE_REWARD = "create_reward" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index e119b063aa5..83df86f3945 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -224,6 +224,12 @@ "tag_options": "mdi:tag", "developer_options": "mdi:test-tube" } + }, + "create_reward": { + "service": "mdi:treasure-chest-outline", + "sections": { + "developer_options": "mdi:test-tube" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 57005cf2b72..1abe977681f 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -61,6 +61,7 @@ from .const import ( SERVICE_API_CALL, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_REWARD, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, @@ -112,18 +113,29 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( } ) -SERVICE_UPDATE_TASK_SCHEMA = vol.Schema( +BASE_TASK_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), - vol.Required(ATTR_TASK): cv.string, vol.Optional(ATTR_RENAME): cv.string, vol.Optional(ATTR_NOTES): cv.string, vol.Optional(ATTR_TAG): vol.All(cv.ensure_list, [str]), - vol.Optional(ATTR_REMOVE_TAG): vol.All(cv.ensure_list, [str]), vol.Optional(ATTR_ALIAS): vol.All( cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$") ), - vol.Optional(ATTR_COST): vol.Coerce(float), + vol.Optional(ATTR_COST): vol.All(vol.Coerce(float), vol.Range(0)), + } +) + +SERVICE_UPDATE_TASK_SCHEMA = BASE_TASK_SCHEMA.extend( + { + vol.Required(ATTR_TASK): cv.string, + vol.Optional(ATTR_REMOVE_TAG): vol.All(cv.ensure_list, [str]), + } +) + +SERVICE_CREATE_TASK_SCHEMA = BASE_TASK_SCHEMA.extend( + { + vol.Required(ATTR_NAME): cv.string, } ) @@ -539,33 +551,36 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 return result - async def update_task(call: ServiceCall) -> ServiceResponse: - """Update task action.""" + async def create_or_update_task(call: ServiceCall) -> ServiceResponse: + """Create or update task action.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data await coordinator.async_refresh() + is_update = call.service == SERVICE_UPDATE_REWARD + current_task = None - try: - current_task = next( - task - for task in coordinator.data.tasks - if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) - and task.Type is TaskType.REWARD - ) - except StopIteration as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="task_not_found", - translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, - ) from e + if is_update: + try: + current_task = next( + task + for task in coordinator.data.tasks + if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) + and task.Type is TaskType.REWARD + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="task_not_found", + translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, + ) from e - task_id = current_task.id - if TYPE_CHECKING: - assert task_id data = Task() - if rename := call.data.get(ATTR_RENAME): - data["text"] = rename + if not is_update: + data["type"] = TaskType.REWARD + + if (text := call.data.get(ATTR_RENAME)) or (text := call.data.get(ATTR_NAME)): + data["text"] = text if (notes := call.data.get(ATTR_NOTES)) is not None: data["notes"] = notes @@ -574,7 +589,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG)) if tags or remove_tags: - update_tags = set(current_task.tags) + update_tags = set(current_task.tags) if current_task else set() user_tags = { tag.name.lower(): tag.id for tag in coordinator.data.user.tags @@ -634,7 +649,13 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 data["value"] = cost try: - response = await coordinator.habitica.update_task(task_id, data) + if is_update: + if TYPE_CHECKING: + assert current_task + assert current_task.id + response = await coordinator.habitica.update_task(current_task.id, data) + else: + response = await coordinator.habitica.create_task(data) except TooManyRequestsError as e: raise HomeAssistantError( translation_domain=DOMAIN, @@ -659,10 +680,17 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, SERVICE_UPDATE_REWARD, - update_task, + create_or_update_task, schema=SERVICE_UPDATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_CREATE_REWARD, + create_or_update_task, + schema=SERVICE_CREATE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) hass.services.async_register( DOMAIN, SERVICE_API_CALL, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index 7b486690ef5..b92b765e18c 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -147,14 +147,14 @@ update_reward: rename: selector: text: - notes: + notes: ¬es required: false selector: text: multiline: true cost: required: false - selector: + selector: &cost_selector number: min: 0 step: 0.01 @@ -163,7 +163,7 @@ update_reward: tag_options: collapsed: true fields: - tag: + tag: &tag required: false selector: text: @@ -173,10 +173,23 @@ update_reward: selector: text: multiple: true - developer_options: + developer_options: &developer_options collapsed: true fields: alias: required: false selector: text: +create_reward: + fields: + config_entry: *config_entry + name: + required: true + selector: + text: + notes: *notes + cost: + required: true + selector: *cost_selector + tag: *tag + developer_options: *developer_options diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 1bb2fcbd9d7..0658e594d07 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -23,7 +23,9 @@ "developer_options_name": "Advanced settings", "developer_options_description": "Additional features available in developer mode.", "tag_options_name": "Tags", - "tag_options_description": "Add or remove tags from a task." + "tag_options_description": "Add or remove tags from a task.", + "name_description": "The title for the Habitica task.", + "cost_name": "Cost" }, "config": { "abort": { @@ -707,7 +709,7 @@ "description": "[%key:component::habitica::common::alias_description%]" }, "cost": { - "name": "Cost", + "name": "[%key:component::habitica::common::cost_name%]", "description": "Update the cost of a reward." } }, @@ -721,6 +723,42 @@ "description": "[%key:component::habitica::common::developer_options_description%]" } } + }, + "create_reward": { + "name": "Create reward", + "description": "Adds a new custom reward.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to create a reward." + }, + "name": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::name_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "cost": { + "name": "[%key:component::habitica::common::cost_name%]", + "description": "The cost of the reward." + } + }, + "sections": { + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } } }, "selector": { diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index 45c33a9ebb6..efb4f7300bf 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -151,6 +151,9 @@ async def mock_habiticalib() -> Generator[AsyncMock]: client.create_tag.return_value = HabiticaTagResponse.from_json( load_fixture("create_tag.json", DOMAIN) ) + client.create_task.return_value = HabiticaTaskResponse.from_json( + load_fixture("task.json", DOMAIN) + ) client.habitipy.return_value = { "tasks": { "user": { diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index a4442016784..0b25dc4385e 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from uuid import UUID from aiohttp import ClientError -from habiticalib import Direction, HabiticaTaskResponse, Skill, Task +from habiticalib import Direction, HabiticaTaskResponse, Skill, Task, TaskType import pytest from syrupy.assertion import SnapshotAssertion @@ -30,6 +30,7 @@ from homeassistant.components.habitica.const import ( SERVICE_ACCEPT_QUEST, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_REWARD, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, @@ -41,6 +42,7 @@ from homeassistant.components.habitica.const import ( ) from homeassistant.components.todo import ATTR_RENAME from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -943,6 +945,51 @@ async def test_update_task_exceptions( ) +@pytest.mark.parametrize( + ("exception", "expected_exception", "exception_msg"), + [ + ( + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + ERROR_BAD_REQUEST, + HomeAssistantError, + REQUEST_EXCEPTION_MSG, + ), + ( + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), + ], +) +@pytest.mark.usefixtures("habitica") +async def test_create_task_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + exception: Exception, + expected_exception: Exception, + exception_msg: str, +) -> None: + """Test Habitica task create action exceptions.""" + + habitica.create_task.side_effect = exception + with pytest.raises(expected_exception, match=exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_NAME: "TITLE", + }, + return_response=True, + blocking=True, + ) + + @pytest.mark.usefixtures("habitica") async def test_task_not_found( hass: HomeAssistant, @@ -1024,6 +1071,60 @@ async def test_update_reward( habitica.update_task.assert_awaited_with(UUID(task_id), call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_NAME: "TITLE", + ATTR_COST: 100, + }, + Task(type=TaskType.REWARD, text="TITLE", value=100), + ), + ( + { + ATTR_NAME: "TITLE", + }, + Task(type=TaskType.REWARD, text="TITLE"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_NOTES: "NOTES", + }, + Task(type=TaskType.REWARD, text="TITLE", notes="NOTES"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ALIAS: "ALIAS", + }, + Task(type=TaskType.REWARD, text="TITLE", alias="ALIAS"), + ), + ], +) +async def test_create_reward( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica create_reward action.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.create_task.assert_awaited_with(call_args) + + async def test_tags( hass: HomeAssistant, config_entry: MockConfigEntry, From 1786bb990376ba69a827e9392631024c021a6198 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 2 Mar 2025 00:28:48 +0300 Subject: [PATCH 2946/2987] Use model list to check anthropic API key (#139307) Anthropic model list --- homeassistant/components/anthropic/__init__.py | 11 ++++------- .../components/anthropic/config_flow.py | 7 +------ tests/components/anthropic/conftest.py | 6 ++---- tests/components/anthropic/test_config_flow.py | 4 ++-- .../components/anthropic/test_conversation.py | 18 +++++++++--------- tests/components/anthropic/test_init.py | 5 ++--- 6 files changed, 20 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index 84c9054b476..a9745d1a6a5 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from .const import DOMAIN, LOGGER +from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL PLATFORMS = (Platform.CONVERSATION,) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -26,12 +26,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY]) ) try: - await client.messages.create( - model="claude-3-haiku-20240307", - max_tokens=1, - messages=[{"role": "user", "content": "Hi"}], - timeout=10.0, - ) + model_id = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + model = await client.models.retrieve(model_id=model_id, timeout=10.0) + LOGGER.debug("Anthropic model: %s", model.display_name) except anthropic.AuthenticationError as err: LOGGER.error("Invalid API key: %s", err) return False diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 63a70f31fea..5f1f4fdeea7 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -63,12 +63,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: client = await hass.async_add_executor_job( partial(anthropic.AsyncAnthropic, api_key=data[CONF_API_KEY]) ) - await client.messages.create( - model="claude-3-haiku-20240307", - max_tokens=1, - messages=[{"role": "user", "content": "Hi"}], - timeout=10.0, - ) + await client.models.list(timeout=10.0) class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/tests/components/anthropic/conftest.py b/tests/components/anthropic/conftest.py index ce6b98c480c..f8ab098cc09 100644 --- a/tests/components/anthropic/conftest.py +++ b/tests/components/anthropic/conftest.py @@ -1,7 +1,7 @@ """Tests helpers.""" from collections.abc import AsyncGenerator -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest @@ -43,9 +43,7 @@ async def mock_init_component( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> AsyncGenerator[None]: """Initialize integration.""" - with patch( - "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock - ): + with patch("anthropic.resources.models.AsyncModels.retrieve"): assert await async_setup_component(hass, "anthropic", {}) await hass.async_block_till_done() yield diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index a5a025b00d0..5973d9a3ee8 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -49,7 +49,7 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.anthropic.config_flow.anthropic.resources.messages.AsyncMessages.create", + "homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock, ), patch( @@ -151,7 +151,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ) with patch( - "homeassistant.components.anthropic.config_flow.anthropic.resources.messages.AsyncMessages.create", + "homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock, side_effect=side_effect, ): diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index a35df281fb6..6c8244a59ba 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -127,9 +127,7 @@ async def test_entity( CONF_LLM_HASS_API: "assist", }, ) - with patch( - "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock - ): + with patch("anthropic.resources.models.AsyncModels.retrieve"): await hass.config_entries.async_reload(mock_config_entry.entry_id) state = hass.states.get("conversation.claude") @@ -173,8 +171,11 @@ async def test_template_error( "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", }, ) - with patch( - "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock + with ( + patch("anthropic.resources.models.AsyncModels.retrieve"), + patch( + "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock + ), ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -205,6 +206,7 @@ async def test_template_variables( }, ) with ( + patch("anthropic.resources.models.AsyncModels.retrieve"), patch( "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock ) as mock_create, @@ -230,8 +232,8 @@ async def test_template_variables( 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"] + assert "The user name is Test User." in mock_create.call_args.kwargs["system"] + assert "The user id is 12345." in mock_create.call_args.kwargs["system"] async def test_conversation_agent( @@ -497,9 +499,7 @@ async def test_unknown_hass_api( assert result == snapshot -@patch("anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock) async def test_conversation_id( - mock_create, hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component, diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index ee87bb708d0..305e442f52d 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -1,6 +1,6 @@ """Tests for the Anthropic integration.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from anthropic import ( APIConnectionError, @@ -55,8 +55,7 @@ async def test_init_error( ) -> None: """Test initialization errors.""" with patch( - "anthropic.resources.messages.AsyncMessages.create", - new_callable=AsyncMock, + "anthropic.resources.models.AsyncModels.retrieve", side_effect=side_effect, ): assert await async_setup_component(hass, "anthropic", {}) From 35825be12bab4d3805ffef4b4d9ce52922c19cf6 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 1 Mar 2025 22:36:51 +0100 Subject: [PATCH 2947/2987] =?UTF-8?q?Update=20quality=20scale=20to=20plati?= =?UTF-8?q?num=20=F0=9F=8F=86=EF=B8=8F=20for=20pyLoad=20integration=20(#13?= =?UTF-8?q?8891)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add quality scale file to pyLoad integration * set strict-typing to done * set parallel-updates to done * docs * update docs * flow coverage done * set platinum quality scale --- .strict-typing | 1 + homeassistant/components/pyload/manifest.json | 1 + .../components/pyload/quality_scale.yaml | 82 +++++++++++++++++++ mypy.ini | 10 +++ script/hassfest/quality_scale.py | 2 - 5 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/pyload/quality_scale.yaml diff --git a/.strict-typing b/.strict-typing index 4b2a94b2db4..8d0d71e85fe 100644 --- a/.strict-typing +++ b/.strict-typing @@ -396,6 +396,7 @@ homeassistant.components.pure_energie.* homeassistant.components.purpleair.* homeassistant.components.pushbullet.* homeassistant.components.pvoutput.* +homeassistant.components.pyload.* homeassistant.components.python_script.* homeassistant.components.qbus.* homeassistant.components.qnap_qsw.* diff --git a/homeassistant/components/pyload/manifest.json b/homeassistant/components/pyload/manifest.json index 134865b9d93..feaa23af7de 100644 --- a/homeassistant/components/pyload/manifest.json +++ b/homeassistant/components/pyload/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["pyloadapi"], + "quality_scale": "platinum", "requirements": ["PyLoadAPI==1.4.2"] } diff --git a/homeassistant/components/pyload/quality_scale.yaml b/homeassistant/components/pyload/quality_scale.yaml new file mode 100644 index 00000000000..a9ce552961b --- /dev/null +++ b/homeassistant/components/pyload/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration registers no 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: The integration registers no actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: The integration registers no events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: The integration registers no actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no configuration parameters + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: The integration is a web service, there are no discoverable devices. + discovery: + status: exempt + comment: The integration is a web service, there are no discoverable devices. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: The integration is a web service, there are no devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: The integration is a web service, there are no devices. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: The integration has no repairs. + stale-devices: + status: exempt + comment: The integration is a web service, there are no devices. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/mypy.ini b/mypy.ini index 0792f820965..c69401b8605 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3716,6 +3716,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.pyload.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.python_script.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 5f90fff81d5..1e335eaeb49 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -812,7 +812,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "pushsafer", "pvoutput", "pvpc_hourly_pricing", - "pyload", "qbittorrent", "qingping", "qld_bushfire", @@ -1890,7 +1889,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "pushsafer", "pvoutput", "pvpc_hourly_pricing", - "pyload", "qbittorrent", "qingping", "qld_bushfire", From 13918f07d8afdeebe49172f4259747f088b9ef03 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 1 Mar 2025 22:39:19 +0100 Subject: [PATCH 2948/2987] Switch cleanup for Shelly (part 2) (#138922) * Switch cleanup for Shelly (part 2) * apply review comment * Update tests/components/shelly/test_climate.py Co-authored-by: Maciej Bieniek * apply review comments --------- Co-authored-by: Maciej Bieniek --- homeassistant/components/shelly/switch.py | 103 +++++++++----------- homeassistant/components/shelly/utils.py | 11 +++ tests/components/shelly/conftest.py | 9 +- tests/components/shelly/test_climate.py | 1 + tests/components/shelly/test_coordinator.py | 7 ++ tests/components/shelly/test_init.py | 9 ++ tests/components/shelly/test_switch.py | 24 ++++- 7 files changed, 102 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 41826706945..68708a2cc2b 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -7,8 +7,9 @@ from dataclasses import dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import MODEL_2, MODEL_25, MODEL_WALL_DISPLAY, RPC_GENERATIONS +from aioshelly.const import MODEL_2, MODEL_25, RPC_GENERATIONS +from homeassistant.components.climate import DOMAIN as CLIMATE_PLATFORM from homeassistant.components.switch import ( DOMAIN as SWITCH_PLATFORM, SwitchEntity, @@ -27,7 +28,6 @@ from .entity import ( RpcEntityDescription, ShellyBlockEntity, ShellyRpcAttributeEntity, - ShellyRpcEntity, ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rpc, @@ -36,12 +36,9 @@ from .utils import ( async_remove_orphaned_entities, async_remove_shelly_entity, get_device_entry_gen, - get_rpc_key_ids, get_virtual_component_ids, is_block_channel_type_light, - is_rpc_channel_type_light, - is_rpc_thermostat_internal_actuator, - is_rpc_thermostat_mode, + is_rpc_exclude_from_relay, ) @@ -67,6 +64,18 @@ class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription): method_params_fn: Callable[[int | None, bool], dict] +RPC_RELAY_SWITCHES = { + "switch": RpcSwitchDescription( + key="switch", + sub_key="output", + removal_condition=is_rpc_exclude_from_relay, + is_on=lambda status: bool(status["output"]), + method_on="Switch.Set", + method_off="Switch.Set", + method_params_fn=lambda id, value: {"id": id, "on": value}, + ), +} + RPC_SWITCHES = { "boolean": RpcSwitchDescription( key="boolean", @@ -162,32 +171,10 @@ def async_setup_rpc_entry( """Set up entities for RPC device.""" coordinator = config_entry.runtime_data.rpc assert coordinator - switch_key_ids = get_rpc_key_ids(coordinator.device.status, "switch") - switch_ids = [] - for id_ in switch_key_ids: - if is_rpc_channel_type_light(coordinator.device.config, id_): - continue - - if coordinator.model == MODEL_WALL_DISPLAY: - # There are three configuration scenarios for WallDisplay: - # - relay mode (no thermostat) - # - thermostat mode using the internal relay as an actuator - # - thermostat mode using an external (from another device) relay as - # an actuator - if not is_rpc_thermostat_mode(id_, coordinator.device.status): - # The device is not in thermostat mode, we need to remove a climate - # entity - unique_id = f"{coordinator.mac}-thermostat:{id_}" - async_remove_shelly_entity(hass, "climate", unique_id) - elif is_rpc_thermostat_internal_actuator(coordinator.device.status): - # The internal relay is an actuator, skip this ID so as not to create - # a switch entity - continue - - switch_ids.append(id_) - unique_id = f"{coordinator.mac}-switch:{id_}" - async_remove_shelly_entity(hass, "light", unique_id) + async_setup_entry_rpc( + hass, config_entry, async_add_entities, RPC_RELAY_SWITCHES, RpcRelaySwitch + ) async_setup_entry_rpc( hass, config_entry, async_add_entities, RPC_SWITCHES, RpcSwitch @@ -218,10 +205,16 @@ def async_setup_rpc_entry( "script", ) - if not switch_ids: - return - - async_add_entities(RpcRelaySwitch(coordinator, id_) for id_ in switch_ids) + # if the climate is removed, from the device configuration, we need + # to remove orphaned entities + async_remove_orphaned_entities( + hass, + config_entry.entry_id, + coordinator.mac, + CLIMATE_PLATFORM, + coordinator.device.status, + "thermostat", + ) class BlockSleepingMotionSwitch( @@ -305,28 +298,6 @@ class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): super()._update_callback() -class RpcRelaySwitch(ShellyRpcEntity, SwitchEntity): - """Entity that controls a relay on RPC based Shelly devices.""" - - def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: - """Initialize relay switch.""" - super().__init__(coordinator, f"switch:{id_}") - self._id = id_ - - @property - def is_on(self) -> bool: - """If switch is on.""" - return bool(self.status["output"]) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on relay.""" - await self.call_rpc("Switch.Set", {"id": self._id, "on": True}) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off relay.""" - await self.call_rpc("Switch.Set", {"id": self._id, "on": False}) - - class RpcSwitch(ShellyRpcAttributeEntity, SwitchEntity): """Entity that controls a switch on RPC based Shelly devices.""" @@ -351,3 +322,21 @@ class RpcSwitch(ShellyRpcAttributeEntity, SwitchEntity): self.entity_description.method_off, self.entity_description.method_params_fn(self._id, False), ) + + +class RpcRelaySwitch(RpcSwitch): + """Entity that controls a switch on RPC based Shelly devices.""" + + # False to avoid double naming as True is inerithed from base class + _attr_has_entity_name = False + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator, key, attribute, description) + self._attr_unique_id: str = f"{coordinator.mac}-{key}" diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 2e81f745819..d9e86427d0b 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -627,3 +627,14 @@ async def get_rpc_script_event_types(device: RpcDevice, id: int) -> list[str]: code_response = await device.script_getcode(id) matches = SHELLY_EMIT_EVENT_PATTERN.finditer(code_response["data"]) return sorted([*{str(event_type.group(1)) for event_type in matches}]) + + +def is_rpc_exclude_from_relay( + settings: dict[str, Any], status: dict[str, Any], channel: str +) -> bool: + """Return true if rpc channel should be excludeed from switch platform.""" + ch = int(channel.split(":")[1]) + if is_rpc_thermostat_internal_actuator(status): + return True + + return is_rpc_channel_type_light(settings, ch) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index a332d16f95d..0063c5c2697 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -101,6 +101,7 @@ MOCK_BLOCKS = [ "overpower": 0, "power": 53.4, "energy": 1234567.89, + "output": True, }, channel="0", type="relay", @@ -207,7 +208,7 @@ MOCK_CONFIG = { }, "sys": { "ui_data": {}, - "device": {"name": "Test name"}, + "device": {"name": "Test name", "mac": MOCK_MAC}, }, "wifi": {"sta": {"enable": True}, "sta1": {"enable": False}}, "ws": {"enable": False, "server": None}, @@ -312,7 +313,11 @@ MOCK_STATUS_COAP = { MOCK_STATUS_RPC = { - "switch:0": {"output": True}, + "switch:0": { + "id": 0, + "output": True, + "apower": 85.3, + }, "input:0": {"id": 0, "state": None}, "input:1": {"id": 1, "percent": 89, "xpercent": 8.9}, "input:2": { diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 040d67cb9c4..c78e87ebfce 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -751,6 +751,7 @@ async def test_wall_display_thermostat_mode_external_actuator( new_status = deepcopy(mock_rpc_device.status) new_status["sys"]["relay_in_thermostat"] = False + new_status.pop("cover:0") monkeypatch.setattr(mock_rpc_device, "status", new_status) await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 8c011e4ad0d..8de434d19d0 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -386,6 +386,8 @@ async def test_rpc_reload_on_cfg_change( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC reload on config change.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) # Generate config change from switch to light @@ -710,6 +712,8 @@ async def test_rpc_reconnect_error( exc: Exception, ) -> None: """Test RPC reconnect error.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) assert get_entity_state(hass, "switch.test_switch_0") == STATE_ON @@ -729,9 +733,12 @@ async def test_rpc_error_running_connected_events( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """Test RPC error while running connected events.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) with patch( "homeassistant.components.shelly.coordinator.async_ensure_ble_enabled", side_effect=DeviceConnectionError, diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index b05bce76728..f3ce807b655 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -366,8 +366,11 @@ async def test_entry_unload( entity_id: str, mock_block_device: Mock, mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Test entry unload.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) entry = await init_integration(hass, gen) assert entry.state is ConfigEntryState.LOADED @@ -410,6 +413,9 @@ async def test_entry_unload_not_connected( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test entry unload when not connected.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) + with patch( "homeassistant.components.shelly.coordinator.async_stop_scanner" ) as mock_stop_scanner: @@ -435,6 +441,9 @@ async def test_entry_unload_not_connected_but_we_think_we_are( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test entry unload when not connected but we think we are still connected.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) + with patch( "homeassistant.components.shelly.coordinator.async_stop_scanner", side_effect=DeviceConnectionError, diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 5aae9dfffc9..1e5ae9dd88c 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -288,6 +288,8 @@ async def test_rpc_device_services( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test RPC device turn on/off services.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) await hass.services.async_call( @@ -310,9 +312,14 @@ async def test_rpc_device_services( async def test_rpc_device_unique_ids( - hass: HomeAssistant, mock_rpc_device: Mock, entity_registry: EntityRegistry + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, ) -> None: """Test RPC device unique_ids.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) entry = entity_registry.async_get("switch.test_switch_0") @@ -340,6 +347,8 @@ async def test_rpc_set_state_errors( ) -> None: """Test RPC device set state connection/call errors.""" monkeypatch.setattr(mock_rpc_device, "call_rpc", AsyncMock(side_effect=exc)) + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) with pytest.raises(HomeAssistantError): @@ -360,6 +369,8 @@ async def test_rpc_auth_error( "call_rpc", AsyncMock(side_effect=InvalidAuthError), ) + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) entry = await init_integration(hass, 2) assert entry.state is ConfigEntryState.LOADED @@ -409,15 +420,22 @@ async def test_wall_display_relay_mode( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test Wall Display in relay mode.""" - climate_entity_id = "climate.test_name" + climate_entity_id = "climate.test_name_thermostat_0" switch_entity_id = "switch.test_switch_0" + config_entry = await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + assert hass.states.get(climate_entity_id) is not None + assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 + new_status = deepcopy(mock_rpc_device.status) new_status["sys"]["relay_in_thermostat"] = False new_status.pop("thermostat:0") + new_status.pop("cover:0") monkeypatch.setattr(mock_rpc_device, "status", new_status) - await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() # the climate entity should be removed assert hass.states.get(climate_entity_id) is None From f7927f9da1fd13b996475ac17d7909e6240de334 Mon Sep 17 00:00:00 2001 From: Tatham Oddie Date: Sun, 2 Mar 2025 07:54:48 +1000 Subject: [PATCH 2949/2987] Introduce demo valve (#138187) --- homeassistant/components/demo/__init__.py | 1 + homeassistant/components/demo/valve.py | 89 +++++++++++++++++++++++ tests/components/demo/test_valve.py | 83 +++++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 homeassistant/components/demo/valve.py create mode 100644 tests/components/demo/test_valve.py diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 9314fc211de..dbc65119bfa 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -48,6 +48,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.TIME, Platform.UPDATE, Platform.VACUUM, + Platform.VALVE, Platform.WATER_HEATER, Platform.WEATHER, ] diff --git a/homeassistant/components/demo/valve.py b/homeassistant/components/demo/valve.py new file mode 100644 index 00000000000..9c6acd45a8a --- /dev/null +++ b/homeassistant/components/demo/valve.py @@ -0,0 +1,89 @@ +"""Demo valve platform that implements valves.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveState +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in frontend + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + async_add_entities( + [ + DemoValve("Front Garden", ValveState.OPEN), + DemoValve("Orchard", ValveState.CLOSED), + ] + ) + + +class DemoValve(ValveEntity): + """Representation of a Demo valve.""" + + _attr_should_poll = False + + def __init__( + self, + name: str, + state: str, + moveable: bool = True, + ) -> None: + """Initialize the valve.""" + self._attr_name = name + if moveable: + self._attr_supported_features = ( + ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + ) + self._state = state + self._moveable = moveable + + @property + def is_open(self) -> bool: + """Return true if valve is open.""" + return self._state == ValveState.OPEN + + @property + def is_opening(self) -> bool: + """Return true if valve is opening.""" + return self._state == ValveState.OPENING + + @property + def is_closing(self) -> bool: + """Return true if valve is closing.""" + return self._state == ValveState.CLOSING + + @property + def is_closed(self) -> bool: + """Return true if valve is closed.""" + return self._state == ValveState.CLOSED + + @property + def reports_position(self) -> bool: + """Return True if entity reports position, False otherwise.""" + return False + + async def async_open_valve(self, **kwargs: Any) -> None: + """Open the valve.""" + self._state = ValveState.OPENING + self.async_write_ha_state() + await asyncio.sleep(OPEN_CLOSE_DELAY) + self._state = ValveState.OPEN + self.async_write_ha_state() + + async def async_close_valve(self, **kwargs: Any) -> None: + """Close the valve.""" + self._state = ValveState.CLOSING + self.async_write_ha_state() + await asyncio.sleep(OPEN_CLOSE_DELAY) + self._state = ValveState.CLOSED + self.async_write_ha_state() diff --git a/tests/components/demo/test_valve.py b/tests/components/demo/test_valve.py new file mode 100644 index 00000000000..1057065ce70 --- /dev/null +++ b/tests/components/demo/test_valve.py @@ -0,0 +1,83 @@ +"""The tests for the Demo valve platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.demo import DOMAIN, valve as demo_valve +from homeassistant.components.valve import ( + DOMAIN as VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + ValveState, +) +from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import async_capture_events + +FRONT_GARDEN = "valve.front_garden" +ORCHARD = "valve.orchard" + + +@pytest.fixture +async def valve_only() -> None: + """Enable only the valve platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.VALVE], + ): + yield + + +@pytest.fixture(autouse=True) +async def setup_comp(hass: HomeAssistant, valve_only: None): + """Set up demo component.""" + assert await async_setup_component( + hass, VALVE_DOMAIN, {VALVE_DOMAIN: {"platform": DOMAIN}} + ) + await hass.async_block_till_done() + + +@patch.object(demo_valve, "OPEN_CLOSE_DELAY", 0) +async def test_closing(hass: HomeAssistant) -> None: + """Test the closing of a valve.""" + state = hass.states.get(FRONT_GARDEN) + assert state.state == ValveState.OPEN + await hass.async_block_till_done() + + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: FRONT_GARDEN}, + blocking=False, + ) + await hass.async_block_till_done() + + assert state_changes[0].data["entity_id"] == FRONT_GARDEN + assert state_changes[0].data["new_state"].state == ValveState.CLOSING + + assert state_changes[1].data["entity_id"] == FRONT_GARDEN + assert state_changes[1].data["new_state"].state == ValveState.CLOSED + + +@patch.object(demo_valve, "OPEN_CLOSE_DELAY", 0) +async def test_opening(hass: HomeAssistant) -> None: + """Test the opening of a valve.""" + state = hass.states.get(ORCHARD) + assert state.state == ValveState.CLOSED + await hass.async_block_till_done() + + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) + await hass.services.async_call( + VALVE_DOMAIN, SERVICE_OPEN_VALVE, {ATTR_ENTITY_ID: ORCHARD}, blocking=False + ) + await hass.async_block_till_done() + + assert state_changes[0].data["entity_id"] == ORCHARD + assert state_changes[0].data["new_state"].state == ValveState.OPENING + + assert state_changes[1].data["entity_id"] == ORCHARD + assert state_changes[1].data["new_state"].state == ValveState.OPEN From a2a11ad02ecee60fdb61ea895747435401313819 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 1 Mar 2025 22:55:49 +0100 Subject: [PATCH 2950/2987] =?UTF-8?q?Update=20quality=20scale=20to=20plati?= =?UTF-8?q?num=20=F0=9F=8F=86=EF=B8=8F=20for=20IronOS=20integration=20(#13?= =?UTF-8?q?8217)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update status in iron_os quality_scale.yaml --- homeassistant/components/iron_os/manifest.json | 1 + homeassistant/components/iron_os/quality_scale.yaml | 10 +++++++--- script/hassfest/quality_scale.py | 1 - 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json index 462e75c5b6e..c9868791668 100644 --- a/homeassistant/components/iron_os/manifest.json +++ b/homeassistant/components/iron_os/manifest.json @@ -13,5 +13,6 @@ "documentation": "https://www.home-assistant.io/integrations/iron_os", "iot_class": "local_polling", "loggers": ["pynecil"], + "quality_scale": "platinum", "requirements": ["pynecil==4.0.1"] } diff --git a/homeassistant/components/iron_os/quality_scale.yaml b/homeassistant/components/iron_os/quality_scale.yaml index c80b8b5adfe..8f7eb5ff36a 100644 --- a/homeassistant/components/iron_os/quality_scale.yaml +++ b/homeassistant/components/iron_os/quality_scale.yaml @@ -21,8 +21,10 @@ rules: entity-unique-id: done has-entity-name: done runtime-data: done - test-before-configure: todo - test-before-setup: todo + test-before-configure: + status: exempt + comment: Device is set up from a Bluetooth discovery + test-before-setup: done unique-config-entry: done # Silver @@ -70,7 +72,9 @@ rules: repair-issues: status: exempt comment: no repairs/issues - stale-devices: todo + stale-devices: + status: exempt + comment: Stale devices are removed with the config entry as there is only one device per entry # Platinum async-dependency: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 1e335eaeb49..9ddce29a4f3 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1588,7 +1588,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "intellifire", "intesishome", "ios", - "iron_os", "iotawatt", "iotty", "iperf3", From 56ddfa9ff80a3c042a27da5541799fafa1980ff5 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 1 Mar 2025 23:05:55 +0100 Subject: [PATCH 2951/2987] Bump deebot-client to 12.3.1 (#139598) --- 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 b31fa7f347d..6d3dc5c9be6 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.2.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==12.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0554810837f..8e11909a56c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -756,7 +756,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==12.2.0 +deebot-client==12.3.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c86feb62135..99fdb680f63 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -646,7 +646,7 @@ dbus-fast==2.33.0 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==12.2.0 +deebot-client==12.3.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 89b655c192d55920ae755397499fd022d4ffa42d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Mar 2025 16:13:04 -0600 Subject: [PATCH 2952/2987] Fix handling of NaN float values for current humidity in ESPHome (#139600) fixes #131837 --- homeassistant/components/esphome/climate.py | 9 +++++++-- tests/components/esphome/test_climate.py | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 478ce9bae2c..b651f16dfd7 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -3,6 +3,7 @@ from __future__ import annotations from functools import partial +from math import isfinite from typing import Any, cast from aioesphomeapi import ( @@ -238,9 +239,13 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti @esphome_state_property def current_humidity(self) -> int | None: """Return the current humidity.""" - if not self._static_info.supports_current_humidity: + if ( + not self._static_info.supports_current_humidity + or (val := self._state.current_humidity) is None + or not isfinite(val) + ): return None - return round(self._state.current_humidity) + return round(val) @property @esphome_float_state_property diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 2a5013444dd..03d2f78a5d2 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -407,7 +407,7 @@ async def test_climate_entity_with_inf_value( target_temperature=math.inf, fan_mode=ClimateFanMode.AUTO, swing_mode=ClimateSwingMode.BOTH, - current_humidity=20.1, + current_humidity=math.inf, target_humidity=25.7, ) ] @@ -422,7 +422,7 @@ async def test_climate_entity_with_inf_value( assert state is not None assert state.state == HVACMode.AUTO attributes = state.attributes - assert attributes[ATTR_CURRENT_HUMIDITY] == 20 + assert ATTR_CURRENT_HUMIDITY not in attributes assert attributes[ATTR_HUMIDITY] == 26 assert attributes[ATTR_MAX_HUMIDITY] == 30 assert attributes[ATTR_MIN_HUMIDITY] == 10 From cc8ed2c228cca6ca0fdc282075e286bf72c6feec Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 2 Mar 2025 00:29:42 +0200 Subject: [PATCH 2953/2987] Fix demo valve platform to use AddConfigEntryEntitiesCallback (#139602) --- homeassistant/components/demo/valve.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/demo/valve.py b/homeassistant/components/demo/valve.py index 9c6acd45a8a..03f0123dd96 100644 --- a/homeassistant/components/demo/valve.py +++ b/homeassistant/components/demo/valve.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveState from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in frontend @@ -16,7 +16,7 @@ OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in fronte async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Demo config entry.""" async_add_entities( From 3e9304253d360af9303f3dbc1b3dac88a53e8776 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 2 Mar 2025 08:58:15 +1000 Subject: [PATCH 2954/2987] Bump Tesla Fleet API to v0.9.12 (#139565) * bump * Update manifest.json * Fix versions * remove tesla_bluetooth * Remove mistake --- 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 bb8f6041771..53aff3d0a54 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.10"] + "requirements": ["tesla-fleet-api==0.9.12"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index dfe6d7cb3f9..4e9228acd2f 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.10", "teslemetry-stream==0.6.10"] + "requirements": ["tesla-fleet-api==0.9.12", "teslemetry-stream==0.6.10"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index d777cf5051e..d4ac56883e8 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.10"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8e11909a56c..b770799a778 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2872,7 +2872,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.10 +tesla-fleet-api==0.9.12 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 99fdb680f63..31314c763ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2312,7 +2312,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.10 +tesla-fleet-api==0.9.12 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From e3eb6051de652875f794e814f0396367898fd3de Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 2 Mar 2025 00:04:13 +0100 Subject: [PATCH 2955/2987] Fix duplicate unique id issue in Sensibo (#139582) * Fix duplicate unique id issue in Sensibo * Fixes * Mods --- .../components/sensibo/binary_sensor.py | 6 ++--- homeassistant/components/sensibo/button.py | 3 ++- homeassistant/components/sensibo/climate.py | 3 ++- .../components/sensibo/coordinator.py | 25 ++++++++++++++----- homeassistant/components/sensibo/number.py | 3 ++- homeassistant/components/sensibo/select.py | 3 ++- homeassistant/components/sensibo/sensor.py | 5 ++-- homeassistant/components/sensibo/switch.py | 3 ++- homeassistant/components/sensibo/update.py | 3 ++- tests/components/sensibo/test_coordinator.py | 4 +++ 10 files changed, 40 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 0d6c47ce46c..c7116db7954 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -130,9 +130,10 @@ async def async_setup_entry( """Handle additions of devices and sensors.""" entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] nonlocal added_devices - new_devices, remove_devices, added_devices = coordinator.get_devices( + new_devices, remove_devices, new_added_devices = coordinator.get_devices( added_devices ) + added_devices = new_added_devices if LOGGER.isEnabledFor(logging.DEBUG): LOGGER.debug( @@ -168,8 +169,7 @@ async def async_setup_entry( device_data.model, DEVICE_SENSOR_TYPES ) ) - - async_add_entities(entities) + async_add_entities(entities) entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) _add_remove_devices() diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py index ed0688d6f2c..d36967dae06 100644 --- a/homeassistant/components/sensibo/button.py +++ b/homeassistant/components/sensibo/button.py @@ -46,7 +46,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 2190d121248..906c4259ce5 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -149,7 +149,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/coordinator.py b/homeassistant/components/sensibo/coordinator.py index e19f24295b9..3fa8a6e5dae 100644 --- a/homeassistant/components/sensibo/coordinator.py +++ b/homeassistant/components/sensibo/coordinator.py @@ -56,18 +56,31 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]): ) -> tuple[set[str], set[str], set[str]]: """Addition and removal of devices.""" data = self.data - motion_sensors = { + current_motion_sensors = { sensor_id for device_data in data.parsed.values() if device_data.motion_sensors for sensor_id in device_data.motion_sensors } - devices: set[str] = set(data.parsed) - new_devices: set[str] = motion_sensors | devices - added_devices - remove_devices = added_devices - devices - motion_sensors - added_devices = (added_devices - remove_devices) | new_devices + current_devices: set[str] = set(data.parsed) + LOGGER.debug( + "Current devices: %s, moption sensors: %s", + current_devices, + current_motion_sensors, + ) + new_devices: set[str] = ( + current_motion_sensors | current_devices + ) - added_devices + remove_devices = added_devices - current_devices - current_motion_sensors + new_added_devices = (added_devices - remove_devices) | new_devices - return (new_devices, remove_devices, added_devices) + LOGGER.debug( + "New devices: %s, Removed devices: %s, Added devices: %s", + new_devices, + remove_devices, + new_added_devices, + ) + return (new_devices, remove_devices, new_added_devices) async def _async_update_data(self) -> SensiboData: """Fetch data from Sensibo.""" diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index 9d077b308a0..e71ed6f0235 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -76,7 +76,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 73c0734ef73..5a0546b1aa2 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -115,7 +115,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index 4174d4b859b..09f095bfaec 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -253,9 +253,8 @@ async def async_setup_entry( entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] nonlocal added_devices - new_devices, remove_devices, added_devices = coordinator.get_devices( - added_devices - ) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: entities.extend( diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index 8c140074e57..03e7c12ec2b 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -89,7 +89,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index 2103bbbf64a..6f868e5f366 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -56,7 +56,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/tests/components/sensibo/test_coordinator.py b/tests/components/sensibo/test_coordinator.py index 6cb8e6fe923..2d56fc4c51c 100644 --- a/tests/components/sensibo/test_coordinator.py +++ b/tests/components/sensibo/test_coordinator.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory from pysensibo.exceptions import AuthenticationError, SensiboError from pysensibo.model import SensiboData +import pytest from homeassistant.components.climate import HVACMode from homeassistant.components.sensibo.const import DOMAIN @@ -25,6 +26,7 @@ async def test_coordinator( mock_client: MagicMock, get_data: tuple[SensiboData, dict[str, Any], dict[str, Any]], freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the Sensibo coordinator with errors.""" config_entry = MockConfigEntry( @@ -87,3 +89,5 @@ async def test_coordinator( mock_data.assert_called_once() state = hass.states.get("climate.hallway") assert state.state == STATE_UNAVAILABLE + + assert "Platform sensibo does not generate unique IDs" not in caplog.text From 55fd5fa86902918c670dbd5e97500b4dfe264c0f Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 2 Mar 2025 01:12:19 +0200 Subject: [PATCH 2956/2987] Bump aioshelly to 13.1.0 (#139601) Co-authored-by: Franck Nijhof --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index ec08a005995..722fd4c128a 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==13.0.0"], + "requirements": ["aioshelly==13.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index b770799a778..8a8f0b51613 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -371,7 +371,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.0.0 +aioshelly==13.1.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 31314c763ba..5ead556907a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.0.0 +aioshelly==13.1.0 # homeassistant.components.skybell aioskybell==22.7.0 From 077ff63b38a92fdfeb884dccf7df8957a5c44d56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Mar 2025 17:51:09 -0600 Subject: [PATCH 2957/2987] Bump inkbird-ble to 0.7.1 (#139603) changelog: https://github.com/Bluetooth-Devices/inkbird-ble/compare/v0.7.0...v0.7.1 --- homeassistant/components/inkbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index 1a251f52582..acc7414edac 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -28,5 +28,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.7.0"] + "requirements": ["inkbird-ble==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8a8f0b51613..f2da895114f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1226,7 +1226,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.7.0 +inkbird-ble==0.7.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ead556907a..c328478338d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1040,7 +1040,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.7.0 +inkbird-ble==0.7.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 From 4a7fd89abde2da04d596eeb0732b7fb2b4ce233d Mon Sep 17 00:00:00 2001 From: wittypluck Date: Sun, 2 Mar 2025 02:32:55 +0100 Subject: [PATCH 2958/2987] Bump pyopenweathermap to 0.2.2 and remove deprecated API version v2.5 (#139599) * Bump pyopenweathermap * Remove deprecated API mode v2.5 --- .../components/openweathermap/__init__.py | 6 +++--- homeassistant/components/openweathermap/const.py | 2 -- .../components/openweathermap/manifest.json | 2 +- homeassistant/components/openweathermap/weather.py | 3 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/openweathermap/test_weather.py | 14 +++++++------- 7 files changed, 14 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index fa51b91dc6d..40ddf0ff37e 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant -from .const import CONFIG_FLOW_VERSION, OWM_MODE_V25, PLATFORMS +from .const import CONFIG_FLOW_VERSION, DEFAULT_OWM_MODE, OWM_MODES, PLATFORMS from .coordinator import WeatherUpdateCoordinator from .repairs import async_create_issue, async_delete_issue from .utils import build_data_and_options @@ -39,7 +39,7 @@ async def async_setup_entry( language = entry.options[CONF_LANGUAGE] mode = entry.options[CONF_MODE] - if mode == OWM_MODE_V25: + if mode not in OWM_MODES: async_create_issue(hass, entry.entry_id) else: async_delete_issue(hass, entry.entry_id) @@ -70,7 +70,7 @@ async def async_migrate_entry( _LOGGER.debug("Migrating OpenWeatherMap entry from version %s", version) if version < 5: - combined_data = {**data, **options, CONF_MODE: OWM_MODE_V25} + combined_data = {**data, **options, CONF_MODE: DEFAULT_OWM_MODE} new_data, new_options = build_data_and_options(combined_data) config_entries.async_update_entry( entry, diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index de317709f5b..fbd2cb1aee2 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -62,10 +62,8 @@ FORECAST_MODE_ONECALL_DAILY = "onecall_daily" OWM_MODE_FREE_CURRENT = "current" OWM_MODE_FREE_FORECAST = "forecast" OWM_MODE_V30 = "v3.0" -OWM_MODE_V25 = "v2.5" OWM_MODES = [ OWM_MODE_V30, - OWM_MODE_V25, OWM_MODE_FREE_CURRENT, OWM_MODE_FREE_FORECAST, ] diff --git a/homeassistant/components/openweathermap/manifest.json b/homeassistant/components/openweathermap/manifest.json index 14313a5a77e..88510aaae8c 100644 --- a/homeassistant/components/openweathermap/manifest.json +++ b/homeassistant/components/openweathermap/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/openweathermap", "iot_class": "cloud_polling", "loggers": ["pyopenweathermap"], - "requirements": ["pyopenweathermap==0.2.1"] + "requirements": ["pyopenweathermap==0.2.2"] } diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index a6ad163e1c8..12d883c871a 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -42,7 +42,6 @@ from .const import ( DOMAIN, MANUFACTURER, OWM_MODE_FREE_FORECAST, - OWM_MODE_V25, OWM_MODE_V30, ) from .coordinator import WeatherUpdateCoordinator @@ -106,7 +105,7 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina ) self.mode = mode - if mode in (OWM_MODE_V30, OWM_MODE_V25): + if mode == OWM_MODE_V30: self._attr_supported_features = ( WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY diff --git a/requirements_all.txt b/requirements_all.txt index f2da895114f..8946b355e03 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2179,7 +2179,7 @@ pyombi==0.1.10 pyopenuv==2023.02.0 # homeassistant.components.openweathermap -pyopenweathermap==0.2.1 +pyopenweathermap==0.2.2 # homeassistant.components.opnsense pyopnsense==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c328478338d..e0f26ae9e98 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1778,7 +1778,7 @@ pyoctoprintapi==0.1.12 pyopenuv==2023.02.0 # homeassistant.components.openweathermap -pyopenweathermap==0.2.1 +pyopenweathermap==0.2.2 # homeassistant.components.opnsense pyopnsense==0.4.0 diff --git a/tests/components/openweathermap/test_weather.py b/tests/components/openweathermap/test_weather.py index 5d3565d6ca9..e9817e739ac 100644 --- a/tests/components/openweathermap/test_weather.py +++ b/tests/components/openweathermap/test_weather.py @@ -6,7 +6,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.openweathermap.const import ( DEFAULT_LANGUAGE, DOMAIN, - OWM_MODE_V25, + OWM_MODE_FREE_CURRENT, OWM_MODE_V30, ) from homeassistant.components.openweathermap.weather import SERVICE_GET_MINUTE_FORECAST @@ -52,9 +52,9 @@ def mock_config_entry(mode: str) -> MockConfigEntry: @pytest.fixture -def mock_config_entry_v25() -> MockConfigEntry: - """Create a mock OpenWeatherMap v2.5 config entry.""" - return mock_config_entry(OWM_MODE_V25) +def mock_config_entry_free_current() -> MockConfigEntry: + """Create a mock OpenWeatherMap FREE_CURRENT config entry.""" + return mock_config_entry(OWM_MODE_FREE_CURRENT) @pytest.fixture @@ -97,15 +97,15 @@ async def test_get_minute_forecast( @patch( - "pyopenweathermap.client.onecall_client.OWMOneCallClient.get_weather", + "pyopenweathermap.client.free_client.OWMFreeClient.get_weather", AsyncMock(return_value=static_weather_report), ) async def test_mode_fail( hass: HomeAssistant, - mock_config_entry_v25: MockConfigEntry, + mock_config_entry_free_current: MockConfigEntry, ) -> None: """Test that Minute forecasting fails when mode is not v3.0.""" - await setup_mock_config_entry(hass, mock_config_entry_v25) + await setup_mock_config_entry(hass, mock_config_entry_free_current) # Expect a ServiceValidationError when mode is not OWM_MODE_V30 with pytest.raises( From 7293ae5d51b8e4b38d982af05b6004f91099a0c7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Mar 2025 22:59:14 -0500 Subject: [PATCH 2959/2987] Fix type for ESPHome assist satellite events (#139618) --- homeassistant/components/esphome/assist_satellite.py | 6 +++--- tests/components/esphome/test_assist_satellite.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 0af74621153..fdd16d20d77 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -285,9 +285,9 @@ class EsphomeAssistSatellite( assert event.data is not None data_to_send = { "conversation_id": event.data["intent_output"]["conversation_id"], - "continue_conversation": event.data["intent_output"][ - "continue_conversation" - ], + "continue_conversation": str( + int(event.data["intent_output"]["continue_conversation"]) + ), } elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: assert event.data is not None diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 56914a0b829..3281a760c39 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -298,7 +298,7 @@ async def test_pipeline_api_audio( VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END, { "conversation_id": conversation_id, - "continue_conversation": True, + "continue_conversation": "1", }, ) From 220509fd6c55b9f0400539bdb7ffec536504ae04 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 2 Mar 2025 05:00:22 +0100 Subject: [PATCH 2960/2987] Fix body text of imap message not available in custom event data template (#139609) --- homeassistant/components/imap/coordinator.py | 2 +- tests/components/imap/test_init.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 74f7a86c0d6..34d3f43eb69 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -280,7 +280,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): if self.custom_event_template is not None: try: data["custom"] = self.custom_event_template.async_render( - data, parse_result=True + data | {"text": message.text}, parse_result=True ) _LOGGER.debug( "IMAP custom template (%s) for msguid %s (%s) rendered to: %s, initial: %s", diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index b86855bd78f..bdd29f7442b 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -726,9 +726,10 @@ async def test_message_data( [ ("{{ subject }}", "Test subject", None), ('{{ "@example.com" in sender }}', True, None), + ('{{ "body" in text }}', True, None), ("{% bad template }}", None, "Error rendering IMAP custom template"), ], - ids=["subject_test", "sender_filter", "template_error"], + ids=["subject_test", "sender_filter", "body_filter", "template_error"], ) async def test_custom_template( hass: HomeAssistant, From b2c7c5b1aa8e01e660256b0f696fe10464e6c601 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 2 Mar 2025 11:05:25 +0100 Subject: [PATCH 2961/2987] Treat "Core" as name, fix grammar in `reload_core_config` action (#139622) * Treat "Core" as name, fix grammar in `reload_core_config` action Change three occurrences of "core" to "Core" so they are not translated but kept as a name instead. Fix singular/plural mismatch in the field description of the `reload_core_config` action. * Change "us customary" to "US customary" --- homeassistant/components/homeassistant/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 590afd697b5..4ca56471452 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -12,7 +12,7 @@ }, "imperial_unit_system": { "title": "The imperial unit system is deprecated", - "description": "The imperial unit system is deprecated and your system is currently using us customary. Please update your configuration to use the us customary unit system and reload the core configuration to fix this issue." + "description": "The imperial unit system is deprecated and your system is currently using US customary. Please update your configuration to use the US customary unit system and reload the Core configuration to fix this issue." }, "deprecated_yaml": { "title": "The {integration_title} YAML configuration is being removed", @@ -111,8 +111,8 @@ "description": "Checks the Home Assistant YAML-configuration files for errors. Errors will be shown in the Home Assistant logs." }, "reload_core_config": { - "name": "Reload core configuration", - "description": "Reloads the core configuration from the YAML-configuration." + "name": "Reload Core configuration", + "description": "Reloads the Core configuration from the YAML-configuration." }, "restart": { "name": "[%key:common::action::restart%]", @@ -160,7 +160,7 @@ }, "update_entity": { "name": "Update entity", - "description": "Forces one or more entities to update its data.", + "description": "Forces one or more entities to update their data.", "fields": { "entity_id": { "name": "Entities to update", From e6c946b3f4ae7ee6f34d7faf5736fe099a3bb19a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 2 Mar 2025 13:15:43 +0100 Subject: [PATCH 2962/2987] Bump pysmartthings to 2.4.1 (#139627) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index e0cf6739290..7a25dc2ac13 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.4.0"] + "requirements": ["pysmartthings==2.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8946b355e03..f98ec465f1b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.4.0 +pysmartthings==2.4.1 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e0f26ae9e98..87e301ae4a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.4.0 +pysmartthings==2.4.1 # homeassistant.components.smarty pysmarty2==0.10.2 From b0b5567316f4e063a0bf16ec518f67f465db42d2 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:04:13 +0100 Subject: [PATCH 2963/2987] Add `update_habit` action to Habitica integration (#139311) * Add update_habit action * icons --- homeassistant/components/habitica/const.py | 5 + homeassistant/components/habitica/icons.json | 7 ++ homeassistant/components/habitica/services.py | 48 +++++++- .../components/habitica/services.yaml | 65 +++++++++- .../components/habitica/strings.json | 72 ++++++++++++ tests/components/habitica/test_services.py | 111 +++++++++++++++++- 6 files changed, 299 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index bd1363ca979..ecaa66378f0 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -40,6 +40,10 @@ ATTR_ALIAS = "alias" ATTR_PRIORITY = "priority" ATTR_COST = "cost" ATTR_NOTES = "notes" +ATTR_UP_DOWN = "up_down" +ATTR_FREQUENCY = "frequency" +ATTR_COUNTER_UP = "counter_up" +ATTR_COUNTER_DOWN = "counter_down" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" @@ -57,6 +61,7 @@ SERVICE_TRANSFORMATION = "transformation" SERVICE_UPDATE_REWARD = "update_reward" SERVICE_CREATE_REWARD = "create_reward" +SERVICE_UPDATE_HABIT = "update_habit" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index 83df86f3945..ca4795dd514 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -230,6 +230,13 @@ "sections": { "developer_options": "mdi:test-tube" } + }, + "update_habit": { + "service": "mdi:contrast-box", + "sections": { + "tag_options": "mdi:tag", + "developer_options": "mdi:test-tube" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 1abe977681f..3c4a59990a3 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -10,6 +10,7 @@ from uuid import UUID from aiohttp import ClientError from habiticalib import ( Direction, + Frequency, HabiticaException, NotAuthorizedError, NotFoundError, @@ -41,8 +42,11 @@ from .const import ( ATTR_ARGS, ATTR_CONFIG_ENTRY, ATTR_COST, + ATTR_COUNTER_DOWN, + ATTR_COUNTER_UP, ATTR_DATA, ATTR_DIRECTION, + ATTR_FREQUENCY, ATTR_ITEM, ATTR_KEYWORD, ATTR_NOTES, @@ -54,6 +58,7 @@ from .const import ( ATTR_TARGET, ATTR_TASK, ATTR_TYPE, + ATTR_UP_DOWN, DOMAIN, EVENT_API_CALL_SUCCESS, SERVICE_ABORT_QUEST, @@ -69,6 +74,7 @@ from .const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, ) from .coordinator import HabiticaConfigEntry @@ -123,6 +129,13 @@ BASE_TASK_SCHEMA = vol.Schema( cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$") ), vol.Optional(ATTR_COST): vol.All(vol.Coerce(float), vol.Range(0)), + vol.Optional(ATTR_PRIORITY): vol.All( + vol.Upper, vol.In(TaskPriority._member_names_) + ), + vol.Optional(ATTR_UP_DOWN): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_COUNTER_UP): vol.All(int, vol.Range(0)), + vol.Optional(ATTR_COUNTER_DOWN): vol.All(int, vol.Range(0)), + vol.Optional(ATTR_FREQUENCY): vol.Coerce(Frequency), } ) @@ -173,6 +186,12 @@ ITEMID_MAP = { "shiny_seed": Skill.SHINY_SEED, } +SERVICE_TASK_TYPE_MAP = { + SERVICE_UPDATE_REWARD: TaskType.REWARD, + SERVICE_CREATE_REWARD: TaskType.REWARD, + SERVICE_UPDATE_HABIT: TaskType.HABIT, +} + def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: """Return config entry or raise if not found or not loaded.""" @@ -551,12 +570,12 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 return result - async def create_or_update_task(call: ServiceCall) -> ServiceResponse: + async def create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa: C901 """Create or update task action.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data await coordinator.async_refresh() - is_update = call.service == SERVICE_UPDATE_REWARD + is_update = call.service in (SERVICE_UPDATE_REWARD, SERVICE_UPDATE_HABIT) current_task = None if is_update: @@ -565,7 +584,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 task for task in coordinator.data.tasks if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) - and task.Type is TaskType.REWARD + and task.Type is SERVICE_TASK_TYPE_MAP[call.service] ) except StopIteration as e: raise ServiceValidationError( @@ -648,6 +667,22 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 if (cost := call.data.get(ATTR_COST)) is not None: data["value"] = cost + if priority := call.data.get(ATTR_PRIORITY): + data["priority"] = TaskPriority[priority] + + if frequency := call.data.get(ATTR_FREQUENCY): + data["frequency"] = frequency + + if up_down := call.data.get(ATTR_UP_DOWN): + data["up"] = "up" in up_down + data["down"] = "down" in up_down + + if counter_up := call.data.get(ATTR_COUNTER_UP): + data["counterUp"] = counter_up + + if counter_down := call.data.get(ATTR_COUNTER_DOWN): + data["counterDown"] = counter_down + try: if is_update: if TYPE_CHECKING: @@ -684,6 +719,13 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 schema=SERVICE_UPDATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_UPDATE_HABIT, + create_or_update_task, + schema=SERVICE_UPDATE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) hass.services.async_register( DOMAIN, SERVICE_CREATE_REWARD, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index b92b765e18c..f5a9c2b0032 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -144,7 +144,7 @@ update_reward: fields: config_entry: *config_entry task: *task - rename: + rename: &rename selector: text: notes: ¬es @@ -160,7 +160,7 @@ update_reward: step: 0.01 unit_of_measurement: "🪙" mode: box - tag_options: + tag_options: &tag_options collapsed: true fields: tag: &tag @@ -176,7 +176,7 @@ update_reward: developer_options: &developer_options collapsed: true fields: - alias: + alias: &alias required: false selector: text: @@ -193,3 +193,62 @@ create_reward: selector: *cost_selector tag: *tag developer_options: *developer_options +update_habit: + fields: + config_entry: *config_entry + task: *task + rename: *rename + notes: *notes + up_down: + required: false + selector: + select: + options: + - value: up + label: "➕" + - value: down + label: "➖" + multiple: true + mode: list + priority: + required: false + selector: + select: + options: + - "trivial" + - "easy" + - "medium" + - "hard" + mode: dropdown + translation_key: "priority" + frequency: + required: false + selector: + select: + options: + - "daily" + - "weekly" + - "monthly" + translation_key: "frequency" + mode: dropdown + tag_options: *tag_options + developer_options: + collapsed: true + fields: + counter_up: + required: false + selector: + number: + min: 0 + step: 1 + unit_of_measurement: "➕" + mode: box + counter_down: + required: false + selector: + number: + min: 0 + step: 1 + unit_of_measurement: "➖" + mode: box + alias: *alias diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 0658e594d07..22ea44351da 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -759,6 +759,70 @@ "description": "[%key:component::habitica::common::developer_options_description%]" } } + }, + "update_habit": { + "name": "Update a habit", + "description": "Updates a specific habit for the selected Habitica character", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to update a habit." + }, + "task": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::task_description%]" + }, + "rename": { + "name": "[%key:component::habitica::common::rename_name%]", + "description": "[%key:component::habitica::common::rename_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "remove_tag": { + "name": "[%key:component::habitica::common::remove_tag_name%]", + "description": "[%key:component::habitica::common::remove_tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "priority": { + "name": "Difficulty", + "description": "Update the difficulty of a task." + }, + "frequency": { + "name": "Counter reset", + "description": "Update when a habit's counter resets: daily resets at the start of a new day, weekly after Sunday night, and monthly at the beginning of a new month." + }, + "up_down": { + "name": "Rewards or losses", + "description": "Update if the habit is good and rewarding (positive), bad and penalizing (negative), or both." + }, + "counter_up": { + "name": "Adjust positive counter", + "description": "Update the up counter of a positive habit." + }, + "counter_down": { + "name": "Adjust negative counter", + "description": "Update the down counter of a negative habit." + } + }, + "sections": { + "tag_options": { + "name": "[%key:component::habitica::common::tag_options_name%]", + "description": "[%key:component::habitica::common::tag_options_description%]" + }, + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } } }, "selector": { @@ -793,6 +857,14 @@ "medium": "Medium", "hard": "Hard" } + }, + "frequency": { + "options": { + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly", + "yearly": "Yearly" + } } } } diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 0b25dc4385e..10a8bc0a588 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -6,7 +6,15 @@ from unittest.mock import AsyncMock, patch from uuid import UUID from aiohttp import ClientError -from habiticalib import Direction, HabiticaTaskResponse, Skill, Task, TaskType +from habiticalib import ( + Direction, + Frequency, + HabiticaTaskResponse, + Skill, + Task, + TaskPriority, + TaskType, +) import pytest from syrupy.assertion import SnapshotAssertion @@ -14,7 +22,10 @@ from homeassistant.components.habitica.const import ( ATTR_ALIAS, ATTR_CONFIG_ENTRY, ATTR_COST, + ATTR_COUNTER_DOWN, + ATTR_COUNTER_UP, ATTR_DIRECTION, + ATTR_FREQUENCY, ATTR_ITEM, ATTR_KEYWORD, ATTR_NOTES, @@ -25,6 +36,7 @@ from homeassistant.components.habitica.const import ( ATTR_TARGET, ATTR_TASK, ATTR_TYPE, + ATTR_UP_DOWN, DOMAIN, SERVICE_ABORT_QUEST, SERVICE_ACCEPT_QUEST, @@ -38,6 +50,7 @@ from homeassistant.components.habitica.const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, ) from homeassistant.components.todo import ATTR_RENAME @@ -919,6 +932,13 @@ async def test_get_tasks( ), ], ) +@pytest.mark.parametrize( + ("service", "task_id"), + [ + (SERVICE_UPDATE_REWARD, "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"), + (SERVICE_UPDATE_HABIT, "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a"), + ], +) @pytest.mark.usefixtures("habitica") async def test_update_task_exceptions( hass: HomeAssistant, @@ -927,15 +947,16 @@ async def test_update_task_exceptions( exception: Exception, expected_exception: Exception, exception_msg: str, + service: str, + task_id: str, ) -> None: """Test Habitica task action exceptions.""" - task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" habitica.update_task.side_effect = exception with pytest.raises(expected_exception, match=exception_msg): await hass.services.async_call( DOMAIN, - SERVICE_UPDATE_REWARD, + service, service_data={ ATTR_CONFIG_ENTRY: config_entry.entry_id, ATTR_TASK: task_id, @@ -1125,6 +1146,90 @@ async def test_create_reward( habitica.create_task.assert_awaited_with(call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_RENAME: "RENAME", + }, + Task(text="RENAME"), + ), + ( + { + ATTR_NOTES: "NOTES", + }, + Task(notes="NOTES"), + ), + ( + { + ATTR_UP_DOWN: [""], + }, + Task(up=False, down=False), + ), + ( + { + ATTR_UP_DOWN: ["up"], + }, + Task(up=True, down=False), + ), + ( + { + ATTR_UP_DOWN: ["down"], + }, + Task(up=False, down=True), + ), + ( + { + ATTR_PRIORITY: "trivial", + }, + Task(priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_FREQUENCY: "daily", + }, + Task(frequency=Frequency.DAILY), + ), + ( + { + ATTR_COUNTER_UP: 1, + ATTR_COUNTER_DOWN: 2, + }, + Task(counterUp=1, counterDown=2), + ), + ( + { + ATTR_ALIAS: "ALIAS", + }, + Task(alias="ALIAS"), + ), + ], +) +async def test_update_habit( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica habit action.""" + task_id = "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_HABIT, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.update_task.assert_awaited_with(UUID(task_id), call_args) + + async def test_tags( hass: HomeAssistant, config_entry: MockConfigEntry, From ee2b53ed0f23919cb8fd994a6b7d6d130ee81d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexey=20ALERT=20Rubash=D1=91ff?= Date: Sun, 2 Mar 2025 15:10:45 +0200 Subject: [PATCH 2964/2987] Bump pyoverkiz to 1.16.2 (#139623) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 14f69291be4..07ec02d76a6 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.16.1"], + "requirements": ["pyoverkiz==1.16.2"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index f98ec465f1b..696aef8b03b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2199,7 +2199,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.1 +pyoverkiz==1.16.2 # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 87e301ae4a8..b9509b7fac3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1795,7 +1795,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.1 +pyoverkiz==1.16.2 # homeassistant.components.onewire pyownet==0.10.0.post1 From 29f680f9120e3f1ccf4c6bbd636d654d50ba85c9 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Mar 2025 14:12:54 +0100 Subject: [PATCH 2965/2987] Add FrankEver virtual integration (#139629) * Add FranvEver virtual integration * Fix file name --- homeassistant/components/frankever/__init__.py | 1 + homeassistant/components/frankever/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/frankever/__init__.py create mode 100644 homeassistant/components/frankever/manifest.json diff --git a/homeassistant/components/frankever/__init__.py b/homeassistant/components/frankever/__init__.py new file mode 100644 index 00000000000..66eeecb1e59 --- /dev/null +++ b/homeassistant/components/frankever/__init__.py @@ -0,0 +1 @@ +"""FrankEver virtual integration.""" diff --git a/homeassistant/components/frankever/manifest.json b/homeassistant/components/frankever/manifest.json new file mode 100644 index 00000000000..37d7be765ef --- /dev/null +++ b/homeassistant/components/frankever/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "frankever", + "name": "FrankEver", + "integration_type": "virtual", + "supported_by": "shelly" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e3185251114..1db5de7ac69 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2046,6 +2046,11 @@ "config_flow": false, "iot_class": "cloud_push" }, + "frankever": { + "name": "FrankEver", + "integration_type": "virtual", + "supported_by": "shelly" + }, "free_mobile": { "name": "Free Mobile", "integration_type": "hub", From 3eadfcc01d3aae78e1eb82852ac5452d8eb54e4b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 2 Mar 2025 14:17:56 +0100 Subject: [PATCH 2966/2987] Still request scopes in SmartThings (#139626) Still request scopes --- homeassistant/components/smartthings/config_flow.py | 4 ++-- homeassistant/components/smartthings/const.py | 6 ++++++ tests/components/smartthings/test_config_flow.py | 9 ++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index b39fe662124..0ad1b5553b1 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from .const import CONF_LOCATION_ID, DOMAIN, OLD_DATA, SCOPES +from .const import CONF_LOCATION_ID, DOMAIN, OLD_DATA, REQUESTED_SCOPES, SCOPES _LOGGER = logging.getLogger(__name__) @@ -30,7 +30,7 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): @property def extra_authorize_data(self) -> dict[str, Any]: """Extra data that needs to be appended to the authorize url.""" - return {"scope": " ".join(SCOPES)} + return {"scope": " ".join(REQUESTED_SCOPES)} async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for SmartThings.""" diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 80c4cf90226..23fd48a4e1e 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -17,6 +17,12 @@ SCOPES = [ "sse", ] +REQUESTED_SCOPES = [ + *SCOPES, + "r:installedapps", + "w:installedapps", +] + CONF_APP_ID = "app_id" CONF_CLOUDHOOK_URL = "cloudhook_url" CONF_INSTALLED_APP_ID = "installed_app_id" diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 2fbd686e4d3..858384db0b6 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -57,7 +57,8 @@ async def test_full_flow( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse+r:installedapps+" + "w:installedapps" ) client = await hass_client_no_auth() @@ -128,7 +129,8 @@ async def test_not_enough_scopes( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse+r:installedapps+" + "w:installedapps" ) client = await hass_client_no_auth() @@ -190,7 +192,8 @@ async def test_duplicate_entry( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse+r:installedapps+" + "w:installedapps" ) client = await hass_client_no_auth() From d922c723d4ceff3d565110455b5bafc82bf0b4c6 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Mar 2025 14:19:52 +0100 Subject: [PATCH 2967/2987] Add LinkedGo virtual integration (#139625) --- homeassistant/components/linkedgo/__init__.py | 1 + homeassistant/components/linkedgo/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/linkedgo/__init__.py create mode 100644 homeassistant/components/linkedgo/manifest.json diff --git a/homeassistant/components/linkedgo/__init__.py b/homeassistant/components/linkedgo/__init__.py new file mode 100644 index 00000000000..e26fefa6b96 --- /dev/null +++ b/homeassistant/components/linkedgo/__init__.py @@ -0,0 +1 @@ +"""LinkedGo virtual integration.""" diff --git a/homeassistant/components/linkedgo/manifest.json b/homeassistant/components/linkedgo/manifest.json new file mode 100644 index 00000000000..03c650cac08 --- /dev/null +++ b/homeassistant/components/linkedgo/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "linkedgo", + "name": "LinkedGo", + "integration_type": "virtual", + "supported_by": "shelly" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1db5de7ac69..a92311d31d0 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3413,6 +3413,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "linkedgo": { + "name": "LinkedGo", + "integration_type": "virtual", + "supported_by": "shelly" + }, "linkplay": { "name": "LinkPlay", "integration_type": "hub", From 5b1f3d3e7f99600a04b374101621c96ee4b14c55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Mar 2025 07:23:40 -0600 Subject: [PATCH 2968/2987] Fix arm vacation mode showing as armed away in elkm1 (#139613) Add native arm vacation mode support to elkm1 Vacation mode is currently implemented as a custom service which will be deprecated in a future PR. Note that the custom service was added long before HA had a native vacation mode which was added in #45980 --- homeassistant/components/elkm1/alarm_control_panel.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index 8113a4d99a6..393845f65ff 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -105,6 +105,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity): AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_NIGHT + | AlarmControlPanelEntityFeature.ARM_VACATION ) _element: Area @@ -204,7 +205,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity): ArmedStatus.ARMED_STAY_INSTANT: AlarmControlPanelState.ARMED_HOME, ArmedStatus.ARMED_TO_NIGHT: AlarmControlPanelState.ARMED_NIGHT, ArmedStatus.ARMED_TO_NIGHT_INSTANT: AlarmControlPanelState.ARMED_NIGHT, - ArmedStatus.ARMED_TO_VACATION: AlarmControlPanelState.ARMED_AWAY, + ArmedStatus.ARMED_TO_VACATION: AlarmControlPanelState.ARMED_VACATION, } if self._element.alarm_state is None: From 0694f9e1648b6025310ffd426ac754dad92bf4a2 Mon Sep 17 00:00:00 2001 From: Maghiel Dijksman Date: Sun, 2 Mar 2025 14:25:19 +0100 Subject: [PATCH 2969/2987] Fix Tuya unsupported Temperature & Humidity Sensors (with or without external probe) (#138542) * add category qxj for th sensor with external probe. partly fixes #136472 * add TEMP_CURRENT_EXTERNAL for th sensor with external probe. fixes #136472 * ruff format * add translation_key temperature_external for TEMP_CURRENT_EXTERNAL --------- Co-authored-by: Franck Nijhof --- .../components/tuya/binary_sensor.py | 3 ++ homeassistant/components/tuya/const.py | 6 +++ homeassistant/components/tuya/sensor.py | 41 +++++++++++++++++++ homeassistant/components/tuya/strings.json | 3 ++ homeassistant/components/tuya/switch.py | 9 ++++ 5 files changed, 62 insertions(+) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 1e13f101110..486dd6e1387 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -291,6 +291,9 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { # Temperature and Humidity Sensor # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 "wsdcg": (TAMPER_BINARY_SENSOR,), + # Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": (TAMPER_BINARY_SENSOR,), # Pressure Sensor # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm "ylcg": ( diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 08bdef474ef..a40260ed787 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -333,6 +333,12 @@ class DPCode(StrEnum): TEMP_CONTROLLER = "temp_controller" TEMP_CURRENT = "temp_current" # Current temperature in °C TEMP_CURRENT_F = "temp_current_f" # Current temperature in °F + TEMP_CURRENT_EXTERNAL = ( + "temp_current_external" # Current external temperature in Celsius + ) + TEMP_CURRENT_EXTERNAL_F = ( + "temp_current_external_f" # Current external temperature in Fahrenheit + ) TEMP_INDOOR = "temp_indoor" # Indoor temperature in °C TEMP_SET = "temp_set" # Set the temperature in °C TEMP_SET_F = "temp_set_f" # Set the temperature in °F diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 073202bed94..b1150be306a 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -715,6 +715,47 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": ( + TuyaSensorEntityDescription( + key=DPCode.VA_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL, + translation_key="temperature_external", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_VALUE, + translation_key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), # Pressure Sensor # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm "ylcg": ( diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 8ec61cc8aa5..83847d32fb5 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -469,6 +469,9 @@ "temperature": { "name": "[%key:component::sensor::entity_component::temperature::name%]" }, + "temperature_external": { + "name": "Probe temperature" + }, "humidity": { "name": "[%key:component::sensor::entity_component::humidity::name%]" }, diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 76d8b481a90..4000e8d9b24 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -612,6 +612,15 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { device_class=SwitchDeviceClass.OUTLET, ), ), + # SIREN: Siren (switch) with Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + device_class=SwitchDeviceClass.OUTLET, + ), + ), # Ceiling Light # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r "xdd": ( From b7bedd4b8fa836357f24b25e57b3a12b8d87ac42 Mon Sep 17 00:00:00 2001 From: Martreides <8385298+Martreides@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:32:10 +0100 Subject: [PATCH 2970/2987] Fix Nederlandse Spoorwegen to ignore trains in the past (#138331) * Update NS integration to show first next train instead of just the first. * Handle no first or next trip. * Remove debug statement. * Remove seconds and revert back to minutes. * Make use of dt_util.now(). * Fix issue with next train if no first train. --- .../nederlandse_spoorwegen/sensor.py | 120 +++++++++++------- 1 file changed, 77 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index ff3eea9252c..1e7fc54f4f7 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -20,7 +20,7 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -119,6 +119,8 @@ class NSDepartureSensor(SensorEntity): self._time = time self._state = None self._trips = None + self._first_trip = None + self._next_trip = None @property def name(self): @@ -133,44 +135,44 @@ class NSDepartureSensor(SensorEntity): @property def extra_state_attributes(self): """Return the state attributes.""" - if not self._trips: + if not self._trips or self._first_trip is None: return None - if self._trips[0].trip_parts: - route = [self._trips[0].departure] - route.extend(k.destination for k in self._trips[0].trip_parts) + if self._first_trip.trip_parts: + route = [self._first_trip.departure] + route.extend(k.destination for k in self._first_trip.trip_parts) # Static attributes attributes = { - "going": self._trips[0].going, + "going": self._first_trip.going, "departure_time_planned": None, "departure_time_actual": None, "departure_delay": False, - "departure_platform_planned": self._trips[0].departure_platform_planned, - "departure_platform_actual": self._trips[0].departure_platform_actual, + "departure_platform_planned": self._first_trip.departure_platform_planned, + "departure_platform_actual": self._first_trip.departure_platform_actual, "arrival_time_planned": None, "arrival_time_actual": None, "arrival_delay": False, - "arrival_platform_planned": self._trips[0].arrival_platform_planned, - "arrival_platform_actual": self._trips[0].arrival_platform_actual, + "arrival_platform_planned": self._first_trip.arrival_platform_planned, + "arrival_platform_actual": self._first_trip.arrival_platform_actual, "next": None, - "status": self._trips[0].status.lower(), - "transfers": self._trips[0].nr_transfers, + "status": self._first_trip.status.lower(), + "transfers": self._first_trip.nr_transfers, "route": route, "remarks": None, } # Planned departure attributes - if self._trips[0].departure_time_planned is not None: - attributes["departure_time_planned"] = self._trips[ - 0 - ].departure_time_planned.strftime("%H:%M") + if self._first_trip.departure_time_planned is not None: + attributes["departure_time_planned"] = ( + self._first_trip.departure_time_planned.strftime("%H:%M") + ) # Actual departure attributes - if self._trips[0].departure_time_actual is not None: - attributes["departure_time_actual"] = self._trips[ - 0 - ].departure_time_actual.strftime("%H:%M") + if self._first_trip.departure_time_actual is not None: + attributes["departure_time_actual"] = ( + self._first_trip.departure_time_actual.strftime("%H:%M") + ) # Delay departure attributes if ( @@ -182,16 +184,16 @@ class NSDepartureSensor(SensorEntity): attributes["departure_delay"] = True # Planned arrival attributes - if self._trips[0].arrival_time_planned is not None: - attributes["arrival_time_planned"] = self._trips[ - 0 - ].arrival_time_planned.strftime("%H:%M") + if self._first_trip.arrival_time_planned is not None: + attributes["arrival_time_planned"] = ( + self._first_trip.arrival_time_planned.strftime("%H:%M") + ) # Actual arrival attributes - if self._trips[0].arrival_time_actual is not None: - attributes["arrival_time_actual"] = self._trips[ - 0 - ].arrival_time_actual.strftime("%H:%M") + if self._first_trip.arrival_time_actual is not None: + attributes["arrival_time_actual"] = ( + self._first_trip.arrival_time_actual.strftime("%H:%M") + ) # Delay arrival attributes if ( @@ -202,15 +204,14 @@ class NSDepartureSensor(SensorEntity): attributes["arrival_delay"] = True # Next attributes - if len(self._trips) > 1: - if self._trips[1].departure_time_actual is not None: - attributes["next"] = self._trips[1].departure_time_actual.strftime( - "%H:%M" - ) - elif self._trips[1].departure_time_planned is not None: - attributes["next"] = self._trips[1].departure_time_planned.strftime( - "%H:%M" - ) + if self._next_trip.departure_time_actual is not None: + attributes["next"] = self._next_trip.departure_time_actual.strftime("%H:%M") + elif self._next_trip.departure_time_planned is not None: + attributes["next"] = self._next_trip.departure_time_planned.strftime( + "%H:%M" + ) + else: + attributes["next"] = None return attributes @@ -225,6 +226,7 @@ class NSDepartureSensor(SensorEntity): ): self._state = None self._trips = None + self._first_trip = None return # Set the search parameter to search from a specific trip time @@ -236,19 +238,51 @@ class NSDepartureSensor(SensorEntity): .strftime("%d-%m-%Y %H:%M") ) else: - trip_time = datetime.now().strftime("%d-%m-%Y %H:%M") + trip_time = dt_util.now().strftime("%d-%m-%Y %H:%M") try: self._trips = self._nsapi.get_trips( trip_time, self._departure, self._via, self._heading, True, 0, 2 ) if self._trips: - if self._trips[0].departure_time_actual is None: - planned_time = self._trips[0].departure_time_planned - self._state = planned_time.strftime("%H:%M") + all_times = [] + + # If a train is delayed we can observe this through departure_time_actual. + for trip in self._trips: + if trip.departure_time_actual is None: + all_times.append(trip.departure_time_planned) + else: + all_times.append(trip.departure_time_actual) + + # Remove all trains that already left. + filtered_times = [ + (i, time) + for i, time in enumerate(all_times) + if time > dt_util.now() + ] + + if len(filtered_times) > 0: + sorted_times = sorted(filtered_times, key=lambda x: x[1]) + self._first_trip = self._trips[sorted_times[0][0]] + self._state = sorted_times[0][1].strftime("%H:%M") + + # Filter again to remove trains that leave at the exact same time. + filtered_times = [ + (i, time) + for i, time in enumerate(all_times) + if time > sorted_times[0][1] + ] + + if len(filtered_times) > 0: + sorted_times = sorted(filtered_times, key=lambda x: x[1]) + self._next_trip = self._trips[sorted_times[0][0]] + else: + self._next_trip = None + else: - actual_time = self._trips[0].departure_time_actual - self._state = actual_time.strftime("%H:%M") + self._first_trip = None + self._state = None + except ( requests.exceptions.ConnectionError, requests.exceptions.HTTPError, From 5ac3fe6ee12f7dda3296bb4f6e7db573f6323e79 Mon Sep 17 00:00:00 2001 From: rappenze Date: Sun, 2 Mar 2025 14:38:56 +0100 Subject: [PATCH 2971/2987] Fibaro integration refactorings (#139624) * Fibaro integration refactorings * Fix execute_action * Add test * more tests * Add tests * Fix test * More tests --- homeassistant/components/fibaro/__init__.py | 18 +-- homeassistant/components/fibaro/climate.py | 96 +++++++------ homeassistant/components/fibaro/entity.py | 19 +-- tests/components/fibaro/conftest.py | 54 ++++++- tests/components/fibaro/test_climate.py | 150 +++++++++++++++++++- tests/components/fibaro/test_light.py | 28 +++- 6 files changed, 287 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 9a521e27486..33b2598a636 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -12,6 +12,7 @@ from pyfibaro.fibaro_client import ( FibaroClient, FibaroConnectFailed, ) +from pyfibaro.fibaro_data_helper import read_rooms from pyfibaro.fibaro_device import DeviceModel from pyfibaro.fibaro_info import InfoModel from pyfibaro.fibaro_scene import SceneModel @@ -83,7 +84,7 @@ class FibaroController: # Whether to import devices from plugins self._import_plugins = import_plugins # Mapping roomId to room object - self._room_map = {room.fibaro_id: room for room in self._client.read_rooms()} + self._room_map = read_rooms(fibaro_client) self._device_map: dict[int, DeviceModel] # Mapping deviceId to device object self.fibaro_devices: dict[Platform, list[DeviceModel]] = defaultdict( list @@ -269,9 +270,7 @@ class FibaroController: def get_room_name(self, room_id: int) -> str | None: """Get the room name by room id.""" - assert self._room_map - room = self._room_map.get(room_id) - return room.name if room else None + return self._room_map.get(room_id) def read_scenes(self) -> list[SceneModel]: """Return list of scenes.""" @@ -294,20 +293,17 @@ class FibaroController: for device in devices: try: device.fibaro_controller = self - if device.room_id == 0: + room_name = self.get_room_name(device.room_id) + if not room_name: room_name = "Unknown" - else: - room_name = self._room_map[device.room_id].name device.room_name = room_name device.friendly_name = f"{room_name} {device.name}" device.ha_id = ( f"{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}" ) if device.enabled and (not device.is_plugin or self._import_plugins): - device.mapped_platform = self._map_device_to_platform(device) - else: - device.mapped_platform = None - if (platform := device.mapped_platform) is None: + platform = self._map_device_to_platform(device) + if platform is None: continue device.unique_id_str = f"{slugify(self.hub_serial)}.{device.fibaro_id}" self._create_device_info(device, devices) diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index d601450a70f..7a8cc3fd2a9 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -129,13 +129,13 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): def __init__(self, fibaro_device: DeviceModel) -> None: """Initialize the Fibaro device.""" super().__init__(fibaro_device) - self._temp_sensor_device: FibaroEntity | None = None - self._target_temp_device: FibaroEntity | None = None - self._op_mode_device: FibaroEntity | None = None - self._fan_mode_device: FibaroEntity | None = None + self._temp_sensor_device: DeviceModel | None = None + self._target_temp_device: DeviceModel | None = None + self._op_mode_device: DeviceModel | None = None + self._fan_mode_device: DeviceModel | None = None self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) - siblings = fibaro_device.fibaro_controller.get_siblings(fibaro_device) + siblings = self.controller.get_siblings(fibaro_device) _LOGGER.debug("%s siblings: %s", fibaro_device.ha_id, siblings) tempunit = "C" for device in siblings: @@ -147,23 +147,23 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): and (device.value.has_value or device.has_heating_thermostat_setpoint) and device.unit in ("C", "F") ): - self._temp_sensor_device = FibaroEntity(device) + self._temp_sensor_device = device tempunit = device.unit if any( action for action in TARGET_TEMP_ACTIONS if action in device.actions ): - self._target_temp_device = FibaroEntity(device) + self._target_temp_device = device self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE if device.has_unit: tempunit = device.unit if any(action for action in OP_MODE_ACTIONS if action in device.actions): - self._op_mode_device = FibaroEntity(device) + self._op_mode_device = device self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE if "setFanMode" in device.actions: - self._fan_mode_device = FibaroEntity(device) + self._fan_mode_device = device self._attr_supported_features |= ClimateEntityFeature.FAN_MODE if tempunit == "F": @@ -172,7 +172,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): self._attr_temperature_unit = UnitOfTemperature.CELSIUS if self._fan_mode_device: - fan_modes = self._fan_mode_device.fibaro_device.supported_modes + fan_modes = self._fan_mode_device.supported_modes self._attr_fan_modes = [] for mode in fan_modes: if mode not in FANMODES: @@ -184,7 +184,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if self._op_mode_device: self._attr_preset_modes = [] self._attr_hvac_modes: list[HVACMode] = [] - device = self._op_mode_device.fibaro_device + device = self._op_mode_device if device.has_supported_thermostat_modes: for mode in device.supported_thermostat_modes: try: @@ -222,15 +222,15 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): "- _fan_mode_device %s" ), self.ha_id, - self._temp_sensor_device.ha_id if self._temp_sensor_device else "None", - self._target_temp_device.ha_id if self._target_temp_device else "None", - self._op_mode_device.ha_id if self._op_mode_device else "None", - self._fan_mode_device.ha_id if self._fan_mode_device else "None", + self._temp_sensor_device.fibaro_id if self._temp_sensor_device else "None", + self._target_temp_device.fibaro_id if self._target_temp_device else "None", + self._op_mode_device.fibaro_id if self._op_mode_device else "None", + self._fan_mode_device.fibaro_id if self._fan_mode_device else "None", ) await super().async_added_to_hass() # Register update callback for child devices - siblings = self.fibaro_device.fibaro_controller.get_siblings(self.fibaro_device) + siblings = self.controller.get_siblings(self.fibaro_device) for device in siblings: if device != self.fibaro_device: self.controller.register(device.fibaro_id, self._update_callback) @@ -240,14 +240,14 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): """Return the fan setting.""" if not self._fan_mode_device: return None - mode = self._fan_mode_device.fibaro_device.mode + mode = self._fan_mode_device.mode return FANMODES[mode] def set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" if not self._fan_mode_device: return - self._fan_mode_device.action("setFanMode", HA_FANMODES[fan_mode]) + self._fan_mode_device.execute_action("setFanMode", [HA_FANMODES[fan_mode]]) @property def fibaro_op_mode(self) -> str | int: @@ -255,7 +255,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if not self._op_mode_device: return HA_OPMODES_HVAC[HVACMode.AUTO] - device = self._op_mode_device.fibaro_device + device = self._op_mode_device if device.has_operating_mode: return device.operating_mode @@ -281,17 +281,17 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if not self._op_mode_device: return - if "setOperatingMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action("setOperatingMode", HA_OPMODES_HVAC[hvac_mode]) - elif "setThermostatMode" in self._op_mode_device.fibaro_device.actions: - device = self._op_mode_device.fibaro_device + device = self._op_mode_device + if "setOperatingMode" in device.actions: + device.execute_action("setOperatingMode", [HA_OPMODES_HVAC[hvac_mode]]) + elif "setThermostatMode" in device.actions: if device.has_supported_thermostat_modes: for mode in device.supported_thermostat_modes: if mode.lower() == hvac_mode: - self._op_mode_device.action("setThermostatMode", mode) + device.execute_action("setThermostatMode", [mode]) break - elif "setMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action("setMode", HA_OPMODES_HVAC[hvac_mode]) + elif "setMode" in device.actions: + device.execute_action("setMode", [HA_OPMODES_HVAC[hvac_mode]]) @property def hvac_action(self) -> HVACAction | None: @@ -299,7 +299,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if not self._op_mode_device: return None - device = self._op_mode_device.fibaro_device + device = self._op_mode_device if device.has_thermostat_operating_state: with suppress(ValueError): return HVACAction(device.thermostat_operating_state.lower()) @@ -315,15 +315,15 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if not self._op_mode_device: return None - if self._op_mode_device.fibaro_device.has_thermostat_mode: - mode = self._op_mode_device.fibaro_device.thermostat_mode + if self._op_mode_device.has_thermostat_mode: + mode = self._op_mode_device.thermostat_mode if self.preset_modes is not None and mode in self.preset_modes: return mode return None - if self._op_mode_device.fibaro_device.has_operating_mode: - mode = self._op_mode_device.fibaro_device.operating_mode + if self._op_mode_device.has_operating_mode: + mode = self._op_mode_device.operating_mode else: - mode = self._op_mode_device.fibaro_device.mode + mode = self._op_mode_device.mode if mode not in OPMODES_PRESET: return None @@ -334,20 +334,22 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if self._op_mode_device is None: return - if "setThermostatMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action("setThermostatMode", preset_mode) - elif "setOperatingMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action( - "setOperatingMode", HA_OPMODES_PRESET[preset_mode] + if "setThermostatMode" in self._op_mode_device.actions: + self._op_mode_device.execute_action("setThermostatMode", [preset_mode]) + elif "setOperatingMode" in self._op_mode_device.actions: + self._op_mode_device.execute_action( + "setOperatingMode", [HA_OPMODES_PRESET[preset_mode]] + ) + elif "setMode" in self._op_mode_device.actions: + self._op_mode_device.execute_action( + "setMode", [HA_OPMODES_PRESET[preset_mode]] ) - elif "setMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action("setMode", HA_OPMODES_PRESET[preset_mode]) @property def current_temperature(self) -> float | None: """Return the current temperature.""" if self._temp_sensor_device: - device = self._temp_sensor_device.fibaro_device + device = self._temp_sensor_device if device.has_heating_thermostat_setpoint: return device.heating_thermostat_setpoint return device.value.float_value() @@ -357,7 +359,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" if self._target_temp_device: - device = self._target_temp_device.fibaro_device + device = self._target_temp_device if device.has_heating_thermostat_setpoint_future: return device.heating_thermostat_setpoint_future return device.target_level @@ -368,9 +370,11 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): temperature = kwargs.get(ATTR_TEMPERATURE) target = self._target_temp_device if target is not None and temperature is not None: - if "setThermostatSetpoint" in target.fibaro_device.actions: - target.action("setThermostatSetpoint", self.fibaro_op_mode, temperature) - elif "setHeatingThermostatSetpoint" in target.fibaro_device.actions: - target.action("setHeatingThermostatSetpoint", temperature) + if "setThermostatSetpoint" in target.actions: + target.execute_action( + "setThermostatSetpoint", [self.fibaro_op_mode, temperature] + ) + elif "setHeatingThermostatSetpoint" in target.actions: + target.execute_action("setHeatingThermostatSetpoint", [temperature]) else: - target.action("setTargetLevel", temperature) + target.execute_action("setTargetLevel", [temperature]) diff --git a/homeassistant/components/fibaro/entity.py b/homeassistant/components/fibaro/entity.py index 6a8e12136c8..5375b058315 100644 --- a/homeassistant/components/fibaro/entity.py +++ b/homeassistant/components/fibaro/entity.py @@ -11,6 +11,8 @@ from pyfibaro.fibaro_device import DeviceModel from homeassistant.const import ATTR_ARMED, ATTR_BATTERY_LEVEL from homeassistant.helpers.entity import Entity +from . import FibaroController + _LOGGER = logging.getLogger(__name__) @@ -22,7 +24,7 @@ class FibaroEntity(Entity): def __init__(self, fibaro_device: DeviceModel) -> None: """Initialize the device.""" self.fibaro_device = fibaro_device - self.controller = fibaro_device.fibaro_controller + self.controller: FibaroController = fibaro_device.fibaro_controller self.ha_id = fibaro_device.ha_id self._attr_name = fibaro_device.friendly_name self._attr_unique_id = fibaro_device.unique_id_str @@ -54,15 +56,6 @@ class FibaroEntity(Entity): return self.fibaro_device.value_2.int_value() return None - def dont_know_message(self, cmd: str) -> None: - """Make a warning in case we don't know how to perform an action.""" - _LOGGER.warning( - "Not sure how to %s: %s (available actions: %s)", - cmd, - str(self.ha_id), - str(self.fibaro_device.actions), - ) - def set_level(self, level: int) -> None: """Set the level of Fibaro device.""" self.action("setValue", level) @@ -97,11 +90,7 @@ class FibaroEntity(Entity): def action(self, cmd: str, *args: Any) -> None: """Perform an action on the Fibaro HC.""" - if cmd in self.fibaro_device.actions: - self.fibaro_device.execute_action(cmd, args) - _LOGGER.debug("-> %s.%s%s called", str(self.ha_id), str(cmd), str(args)) - else: - self.dont_know_message(cmd) + self.fibaro_device.execute_action(cmd, args) @property def current_binary_state(self) -> bool: diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 17357e34198..55b7e35132c 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -157,12 +157,31 @@ def mock_thermostat() -> Mock: return climate +@pytest.fixture +def mock_thermostat_parent() -> Mock: + """Fixture for a thermostat.""" + climate = Mock() + climate.fibaro_id = 5 + climate.parent_fibaro_id = 0 + climate.name = "Test climate" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.device" + climate.base_type = "com.fibaro.device" + climate.properties = {"manufacturer": ""} + climate.actions = [] + return climate + + @pytest.fixture def mock_thermostat_with_operating_mode() -> Mock: """Fixture for a thermostat.""" climate = Mock() - climate.fibaro_id = 4 - climate.parent_fibaro_id = 0 + climate.fibaro_id = 6 + climate.endpoint_id = 1 + climate.parent_fibaro_id = 5 climate.name = "Test climate" climate.room_id = 1 climate.dead = False @@ -171,20 +190,47 @@ def mock_thermostat_with_operating_mode() -> Mock: climate.type = "com.fibaro.thermostatDanfoss" climate.base_type = "com.fibaro.device" climate.properties = {"manufacturer": ""} - climate.actions = {"setOperationMode": 1} + climate.actions = {"setOperatingMode": 1, "setTargetLevel": 1} climate.supported_features = {} climate.has_supported_operating_modes = True climate.supported_operating_modes = [0, 1, 15] climate.has_operating_mode = True climate.operating_mode = 15 + climate.has_supported_thermostat_modes = False climate.has_thermostat_mode = False + climate.has_unit = True + climate.unit = "C" + climate.has_heating_thermostat_setpoint = False + climate.has_heating_thermostat_setpoint_future = False + climate.target_level = 23 value_mock = Mock() value_mock.has_value = True - value_mock.int_value.return_value = 20 + value_mock.float_value.return_value = 20 climate.value = value_mock return climate +@pytest.fixture +def mock_fan_device() -> Mock: + """Fixture for a fan endpoint of a thermostat device.""" + climate = Mock() + climate.fibaro_id = 7 + climate.endpoint_id = 1 + climate.parent_fibaro_id = 5 + climate.name = "Test fan" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.fan" + climate.base_type = "com.fibaro.device" + climate.properties = {"manufacturer": ""} + climate.actions = {"setFanMode": 1} + climate.supported_modes = [0, 1, 2] + climate.mode = 1 + return climate + + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" diff --git a/tests/components/fibaro/test_climate.py b/tests/components/fibaro/test_climate.py index 31022e19a08..339d9d23077 100644 --- a/tests/components/fibaro/test_climate.py +++ b/tests/components/fibaro/test_climate.py @@ -130,5 +130,153 @@ async def test_hvac_mode_with_operation_mode_support( # Act await init_integration(hass, mock_config_entry) # Assert - state = hass.states.get("climate.room_1_test_climate_4") + state = hass.states.get("climate.room_1_test_climate_6") assert state.state == HVACMode.AUTO + + +async def test_set_hvac_mode_with_operation_mode_support( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_with_operating_mode: Mock, + mock_room: Mock, +) -> None: + """Test that set_hvac_mode() works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_thermostat_with_operating_mode] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": "climate.room_1_test_climate_6", "hvac_mode": HVACMode.HEAT}, + blocking=True, + ) + + # Assert + mock_thermostat_with_operating_mode.execute_action.assert_called_once() + + +async def test_fan_mode( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: Mock, + mock_room: Mock, +) -> None: + """Test that operating mode works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [ + mock_thermostat_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + state = hass.states.get("climate.room_1_test_climate_6") + assert state.attributes["fan_mode"] == "low" + assert state.attributes["fan_modes"] == ["off", "low", "auto_high"] + + +async def test_set_fan_mode( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: Mock, + mock_room: Mock, +) -> None: + """Test that set_fan_mode() works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [ + mock_thermostat_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": "climate.room_1_test_climate_6", "fan_mode": "off"}, + blocking=True, + ) + + # Assert + mock_fan_device.execute_action.assert_called_once() + + +async def test_target_temperature( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: Mock, + mock_room: Mock, +) -> None: + """Test that operating mode works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [ + mock_thermostat_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + state = hass.states.get("climate.room_1_test_climate_6") + assert state.attributes["temperature"] == 23 + + +async def test_set_target_temperature( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: Mock, + mock_room: Mock, +) -> None: + """Test that set_fan_mode() works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [ + mock_thermostat_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "climate", + "set_temperature", + {"entity_id": "climate.room_1_test_climate_6", "temperature": 25.5}, + blocking=True, + ) + + # Assert + mock_thermostat_with_operating_mode.execute_action.assert_called_once() diff --git a/tests/components/fibaro/test_light.py b/tests/components/fibaro/test_light.py index d0a24e009b7..88576e86dc6 100644 --- a/tests/components/fibaro/test_light.py +++ b/tests/components/fibaro/test_light.py @@ -2,7 +2,8 @@ from unittest.mock import Mock, patch -from homeassistant.const import Platform +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -55,3 +56,28 @@ async def test_light_brightness( state = hass.states.get("light.room_1_test_light_3") assert state.attributes["brightness"] == 51 assert state.state == "on" + + +async def test_light_turn_off( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_room: Mock, +) -> None: + """Test activate scene is called.""" + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + # Act + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.room_1_test_light_3"}, + blocking=True, + ) + # Assert + assert mock_light.execute_action.call_count == 1 From 0c803520a33af3b528756e8b099daeaecc3a957f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 2 Mar 2025 14:40:28 +0100 Subject: [PATCH 2972/2987] Motion blind type list (#139590) * Add blind_type_list * fix * styling * fix typing * Bump motionblinds to 0.6.26 --- homeassistant/components/motion_blinds/__init__.py | 14 +++++++++++++- .../components/motion_blinds/config_flow.py | 1 + homeassistant/components/motion_blinds/const.py | 1 + homeassistant/components/motion_blinds/gateway.py | 9 +++++++-- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index df06ffb75fc..2abcc273e23 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import ( + CONF_BLIND_TYPE_LIST, CONF_INTERFACE, CONF_WAIT_FOR_PUSH, DEFAULT_INTERFACE, @@ -39,6 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: key = entry.data[CONF_API_KEY] multicast_interface = entry.data.get(CONF_INTERFACE, DEFAULT_INTERFACE) wait_for_push = entry.options.get(CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH) + blind_type_list = entry.data.get(CONF_BLIND_TYPE_LIST) # Create multicast Listener async with setup_lock: @@ -81,7 +83,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Connect to motion gateway multicast = hass.data[DOMAIN][KEY_MULTICAST_LISTENER] connect_gateway_class = ConnectMotionGateway(hass, multicast) - if not await connect_gateway_class.async_connect_gateway(host, key): + if not await connect_gateway_class.async_connect_gateway( + host, key, blind_type_list + ): raise ConfigEntryNotReady motion_gateway = connect_gateway_class.gateway_device api_lock = asyncio.Lock() @@ -95,6 +99,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entry, _LOGGER, coordinator_info ) + # store blind type list for next time + if entry.data.get(CONF_BLIND_TYPE_LIST) != motion_gateway.blind_type_list: + data = { + **entry.data, + CONF_BLIND_TYPE_LIST: motion_gateway.blind_type_list, + } + hass.config_entries.async_update_entry(entry, data=data) + # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index d8d1e7c21f1..a7bb34af1e6 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -156,6 +156,7 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: key = user_input[CONF_API_KEY] + assert self._host connect_gateway_class = ConnectMotionGateway(self.hass) if not await connect_gateway_class.async_connect_gateway(self._host, key): diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py index 96067d7ceb0..950fa3ab4c7 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -8,6 +8,7 @@ DEFAULT_GATEWAY_NAME = "Motionblinds Gateway" PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SENSOR] +CONF_BLIND_TYPE_LIST = "blind_type_list" CONF_WAIT_FOR_PUSH = "wait_for_push" CONF_INTERFACE = "interface" DEFAULT_WAIT_FOR_PUSH = False diff --git a/homeassistant/components/motion_blinds/gateway.py b/homeassistant/components/motion_blinds/gateway.py index 44f7caa74b2..9826557919c 100644 --- a/homeassistant/components/motion_blinds/gateway.py +++ b/homeassistant/components/motion_blinds/gateway.py @@ -42,11 +42,16 @@ class ConnectMotionGateway: for blind in self.gateway_device.device_list.values(): blind.Update_from_cache() - async def async_connect_gateway(self, host, key): + async def async_connect_gateway( + self, + host: str, + key: str, + blind_type_list: dict[str, int] | None = None, + ) -> bool: """Connect to the Motion Gateway.""" _LOGGER.debug("Initializing with host %s (key %s)", host, key[:3]) self._gateway_device = MotionGateway( - ip=host, key=key, multicast=self._multicast + ip=host, key=key, multicast=self._multicast, blind_type_list=blind_type_list ) try: # update device info and get the connected sub devices From c9abe760237c44c7a83d0c52fc3fd602809469cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Mar 2025 08:13:06 -0600 Subject: [PATCH 2973/2987] Use multiple indexed group-by queries to get start time states for MySQL (#138786) * tweaks * mysql * mysql * Update homeassistant/components/recorder/history/modern.py * Update homeassistant/components/recorder/history/modern.py * Update homeassistant/components/recorder/const.py * Update homeassistant/components/recorder/statistics.py * Apply suggestions from code review * mysql * mysql * cover * make sure db is fully init on old schema * fixes * fixes * coverage * coverage * coverage * s/slow_dependant_subquery/slow_dependent_subquery/g * reword * comment that callers are responsible for staying under the limit * comment that callers are responsible for staying under the limit * switch to kwargs * reduce branching complexity * split stats query * preen * split tests * split tests --- homeassistant/components/recorder/const.py | 6 + .../components/recorder/history/modern.py | 149 +++- .../components/recorder/models/database.py | 10 + .../components/recorder/statistics.py | 79 ++- homeassistant/components/recorder/util.py | 29 +- .../history/test_websocket_api_schema_32.py | 6 +- tests/components/recorder/common.py | 13 +- tests/components/recorder/conftest.py | 7 + ...est_filters_with_entityfilter_schema_37.py | 15 +- tests/components/recorder/test_history.py | 27 +- .../recorder/test_history_db_schema_32.py | 5 +- .../recorder/test_history_db_schema_42.py | 5 +- .../recorder/test_purge_v32_schema.py | 4 +- tests/components/recorder/test_statistics.py | 653 +++++++++++++++++- tests/components/recorder/test_util.py | 11 +- 15 files changed, 965 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index b7ee984558c..36ff63a0496 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -30,6 +30,12 @@ CONF_DB_INTEGRITY_CHECK = "db_integrity_check" MAX_QUEUE_BACKLOG_MIN_VALUE = 65000 MIN_AVAILABLE_MEMORY_FOR_QUEUE_BACKLOG = 256 * 1024**2 +# As soon as we have more than 999 ids, split the query as the +# MySQL optimizer handles it poorly and will no longer +# do an index only scan with a group-by +# https://github.com/home-assistant/core/issues/132865#issuecomment-2543160459 +MAX_IDS_FOR_INDEXED_GROUP_BY = 999 + # The maximum number of rows (events) we purge in one delete statement DEFAULT_MAX_BIND_VARS = 4000 diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 8958913bce6..566e30713f0 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -6,11 +6,12 @@ from collections.abc import Callable, Iterable, Iterator from datetime import datetime from itertools import groupby from operator import itemgetter -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from sqlalchemy import ( CompoundSelect, Select, + StatementLambdaElement, Subquery, and_, func, @@ -26,8 +27,9 @@ from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_ from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.helpers.recorder import get_instance from homeassistant.util import dt as dt_util +from homeassistant.util.collection import chunked_or_all -from ..const import LAST_REPORTED_SCHEMA_VERSION +from ..const import LAST_REPORTED_SCHEMA_VERSION, MAX_IDS_FOR_INDEXED_GROUP_BY from ..db_schema import ( SHARED_ATTR_OR_LEGACY_ATTRIBUTES, StateAttributes, @@ -149,6 +151,7 @@ def _significant_states_stmt( no_attributes: bool, include_start_time_state: bool, run_start_ts: float | None, + slow_dependent_subquery: bool, ) -> Select | CompoundSelect: """Query the database for significant state changes.""" include_last_changed = not significant_changes_only @@ -187,6 +190,7 @@ def _significant_states_stmt( metadata_ids, no_attributes, include_last_changed, + slow_dependent_subquery, ).subquery(), no_attributes, include_last_changed, @@ -257,7 +261,68 @@ def get_significant_states_with_session( start_time_ts = start_time.timestamp() end_time_ts = datetime_to_timestamp_or_none(end_time) single_metadata_id = metadata_ids[0] if len(metadata_ids) == 1 else None - stmt = lambda_stmt( + rows: list[Row] = [] + if TYPE_CHECKING: + assert instance.database_engine is not None + slow_dependent_subquery = instance.database_engine.optimizer.slow_dependent_subquery + if include_start_time_state and slow_dependent_subquery: + # https://github.com/home-assistant/core/issues/137178 + # If we include the start time state we need to limit the + # number of metadata_ids we query for at a time to avoid + # hitting limits in the MySQL optimizer that prevent + # the start time state query from using an index-only optimization + # to find the start time state. + iter_metadata_ids = chunked_or_all(metadata_ids, MAX_IDS_FOR_INDEXED_GROUP_BY) + else: + iter_metadata_ids = (metadata_ids,) + for metadata_ids_chunk in iter_metadata_ids: + stmt = _generate_significant_states_with_session_stmt( + start_time_ts, + end_time_ts, + single_metadata_id, + metadata_ids_chunk, + metadata_ids_in_significant_domains, + significant_changes_only, + no_attributes, + include_start_time_state, + oldest_ts, + slow_dependent_subquery, + ) + row_chunk = cast( + list[Row], + execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False), + ) + if rows: + rows += row_chunk + else: + # If we have no rows yet, we can just assign the chunk + # as this is the common case since its rare that + # we exceed the MAX_IDS_FOR_INDEXED_GROUP_BY limit + rows = row_chunk + return _sorted_states_to_dict( + rows, + start_time_ts if include_start_time_state else None, + entity_ids, + entity_id_to_metadata_id, + minimal_response, + compressed_state_format, + no_attributes=no_attributes, + ) + + +def _generate_significant_states_with_session_stmt( + start_time_ts: float, + end_time_ts: float | None, + single_metadata_id: int | None, + metadata_ids: list[int], + metadata_ids_in_significant_domains: list[int], + significant_changes_only: bool, + no_attributes: bool, + include_start_time_state: bool, + oldest_ts: float | None, + slow_dependent_subquery: bool, +) -> StatementLambdaElement: + return lambda_stmt( lambda: _significant_states_stmt( start_time_ts, end_time_ts, @@ -268,6 +333,7 @@ def get_significant_states_with_session( no_attributes, include_start_time_state, oldest_ts, + slow_dependent_subquery, ), track_on=[ bool(single_metadata_id), @@ -276,17 +342,9 @@ def get_significant_states_with_session( significant_changes_only, no_attributes, include_start_time_state, + slow_dependent_subquery, ], ) - return _sorted_states_to_dict( - execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False), - start_time_ts if include_start_time_state else None, - entity_ids, - entity_id_to_metadata_id, - minimal_response, - compressed_state_format, - no_attributes=no_attributes, - ) def get_full_significant_states_with_session( @@ -554,13 +612,14 @@ def get_last_state_changes( ) -def _get_start_time_state_for_entities_stmt( +def _get_start_time_state_for_entities_stmt_dependent_sub_query( epoch_time: float, metadata_ids: list[int], no_attributes: bool, include_last_changed: bool, ) -> Select: """Baked query to get states for specific entities.""" + # Engine has a fast dependent subquery optimizer # This query is the result of significant research in # https://github.com/home-assistant/core/issues/132865 # A reverse index scan with a limit 1 is the fastest way to get the @@ -570,7 +629,9 @@ def _get_start_time_state_for_entities_stmt( # before a specific point in time for all entities. stmt = ( _stmt_and_join_attributes_for_start_state( - no_attributes, include_last_changed, False + no_attributes=no_attributes, + include_last_changed=include_last_changed, + include_last_reported=False, ) .select_from(StatesMeta) .join( @@ -600,6 +661,55 @@ def _get_start_time_state_for_entities_stmt( ) +def _get_start_time_state_for_entities_stmt_group_by( + epoch_time: float, + metadata_ids: list[int], + no_attributes: bool, + include_last_changed: bool, +) -> Select: + """Baked query to get states for specific entities.""" + # Simple group-by for MySQL, must use less + # than 1000 metadata_ids in the IN clause for MySQL + # or it will optimize poorly. Callers are responsible + # for ensuring that the number of metadata_ids is less + # than 1000. + most_recent_states_for_entities_by_date = ( + select( + States.metadata_id.label("max_metadata_id"), + func.max(States.last_updated_ts).label("max_last_updated"), + ) + .filter( + (States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids) + ) + .group_by(States.metadata_id) + .subquery() + ) + stmt = ( + _stmt_and_join_attributes_for_start_state( + no_attributes=no_attributes, + include_last_changed=include_last_changed, + include_last_reported=False, + ) + .join( + most_recent_states_for_entities_by_date, + and_( + States.metadata_id + == most_recent_states_for_entities_by_date.c.max_metadata_id, + States.last_updated_ts + == most_recent_states_for_entities_by_date.c.max_last_updated, + ), + ) + .filter( + (States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids) + ) + ) + if no_attributes: + return stmt + return stmt.outerjoin( + StateAttributes, (States.attributes_id == StateAttributes.attributes_id) + ) + + def _get_oldest_possible_ts( hass: HomeAssistant, utc_point_in_time: datetime ) -> float | None: @@ -620,6 +730,7 @@ def _get_start_time_state_stmt( metadata_ids: list[int], no_attributes: bool, include_last_changed: bool, + slow_dependent_subquery: bool, ) -> Select: """Return the states at a specific point in time.""" if single_metadata_id: @@ -634,7 +745,15 @@ def _get_start_time_state_stmt( ) # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. - return _get_start_time_state_for_entities_stmt( + if slow_dependent_subquery: + return _get_start_time_state_for_entities_stmt_group_by( + epoch_time, + metadata_ids, + no_attributes, + include_last_changed, + ) + + return _get_start_time_state_for_entities_stmt_dependent_sub_query( epoch_time, metadata_ids, no_attributes, diff --git a/homeassistant/components/recorder/models/database.py b/homeassistant/components/recorder/models/database.py index b86fd299793..2a4924edab3 100644 --- a/homeassistant/components/recorder/models/database.py +++ b/homeassistant/components/recorder/models/database.py @@ -37,3 +37,13 @@ class DatabaseOptimizer: # https://wiki.postgresql.org/wiki/Loose_indexscan # https://github.com/home-assistant/core/issues/126084 slow_range_in_select: bool + + # MySQL 8.x+ can end up with a file-sort on a dependent subquery + # which makes the query painfully slow. + # https://github.com/home-assistant/core/issues/137178 + # The solution is to use multiple indexed group-by queries instead + # of the subquery as long as the group by does not exceed + # 999 elements since as soon as we hit 1000 elements MySQL + # will no longer use the group_index_range optimization. + # https://github.com/home-assistant/core/issues/132865#issuecomment-2543160459 + slow_dependent_subquery: bool diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index c42a0f77c39..97fe73c54fe 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -28,6 +28,7 @@ from homeassistant.helpers.recorder import DATA_RECORDER from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util +from homeassistant.util.collection import chunked_or_all from homeassistant.util.unit_conversion import ( AreaConverter, BaseUnitConverter, @@ -59,6 +60,7 @@ from .const import ( INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, INTEGRATION_PLATFORM_VALIDATE_STATISTICS, + MAX_IDS_FOR_INDEXED_GROUP_BY, SupportedDialect, ) from .db_schema import ( @@ -1669,6 +1671,7 @@ def _augment_result_with_change( drop_sum = "sum" not in _types prev_sums = {} if tmp := _statistics_at_time( + get_instance(hass), session, {metadata[statistic_id][0] for statistic_id in result}, table, @@ -2027,7 +2030,39 @@ def get_latest_short_term_statistics_with_session( ) -def _generate_statistics_at_time_stmt( +def _generate_statistics_at_time_stmt_group_by( + table: type[StatisticsBase], + metadata_ids: set[int], + start_time_ts: float, + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], +) -> StatementLambdaElement: + """Create the statement for finding the statistics for a given time.""" + # Simple group-by for MySQL, must use less + # than 1000 metadata_ids in the IN clause for MySQL + # or it will optimize poorly. Callers are responsible + # for ensuring that the number of metadata_ids is less + # than 1000. + return _generate_select_columns_for_types_stmt(table, types) + ( + lambda q: q.join( + most_recent_statistic_ids := ( + select( + func.max(table.start_ts).label("max_start_ts"), + table.metadata_id.label("max_metadata_id"), + ) + .filter(table.start_ts < start_time_ts) + .filter(table.metadata_id.in_(metadata_ids)) + .group_by(table.metadata_id) + .subquery() + ), + and_( + table.start_ts == most_recent_statistic_ids.c.max_start_ts, + table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, + ), + ) + ) + + +def _generate_statistics_at_time_stmt_dependent_sub_query( table: type[StatisticsBase], metadata_ids: set[int], start_time_ts: float, @@ -2041,8 +2076,7 @@ def _generate_statistics_at_time_stmt( # databases. Since all databases support this query as a join # condition we can use it as a subquery to get the last start_time_ts # before a specific point in time for all entities. - stmt = _generate_select_columns_for_types_stmt(table, types) - stmt += ( + return _generate_select_columns_for_types_stmt(table, types) + ( lambda q: q.select_from(StatisticsMeta) .join( table, @@ -2064,10 +2098,10 @@ def _generate_statistics_at_time_stmt( ) .where(table.metadata_id.in_(metadata_ids)) ) - return stmt def _statistics_at_time( + instance: Recorder, session: Session, metadata_ids: set[int], table: type[StatisticsBase], @@ -2076,8 +2110,41 @@ def _statistics_at_time( ) -> Sequence[Row] | None: """Return last known statistics, earlier than start_time, for the metadata_ids.""" start_time_ts = start_time.timestamp() - stmt = _generate_statistics_at_time_stmt(table, metadata_ids, start_time_ts, types) - return cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) + if TYPE_CHECKING: + assert instance.database_engine is not None + if not instance.database_engine.optimizer.slow_dependent_subquery: + stmt = _generate_statistics_at_time_stmt_dependent_sub_query( + table=table, + metadata_ids=metadata_ids, + start_time_ts=start_time_ts, + types=types, + ) + return cast(list[Row], execute_stmt_lambda_element(session, stmt)) + rows: list[Row] = [] + # https://github.com/home-assistant/core/issues/132865 + # If we include the start time state we need to limit the + # number of metadata_ids we query for at a time to avoid + # hitting limits in the MySQL optimizer that prevent + # the start time state query from using an index-only optimization + # to find the start time state. + for metadata_ids_chunk in chunked_or_all( + metadata_ids, MAX_IDS_FOR_INDEXED_GROUP_BY + ): + stmt = _generate_statistics_at_time_stmt_group_by( + table=table, + metadata_ids=metadata_ids_chunk, + start_time_ts=start_time_ts, + types=types, + ) + row_chunk = cast(list[Row], execute_stmt_lambda_element(session, stmt)) + if rows: + rows += row_chunk + else: + # If we have no rows yet, we can just assign the chunk + # as this is the common case since its rare that + # we exceed the MAX_IDS_FOR_INDEXED_GROUP_BY limit + rows = row_chunk + return rows def _build_sum_converted_stats( diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index a686c7c6498..0acaf0aa68f 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -464,6 +464,7 @@ def setup_connection_for_dialect( """Execute statements needed for dialect connection.""" version: AwesomeVersion | None = None slow_range_in_select = False + slow_dependent_subquery = False if dialect_name == SupportedDialect.SQLITE: if first_connection: old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined] @@ -505,9 +506,8 @@ def setup_connection_for_dialect( result = query_on_connection(dbapi_connection, "SELECT VERSION()") version_string = result[0][0] version = _extract_version_from_server_response(version_string) - is_maria_db = "mariadb" in version_string.lower() - if is_maria_db: + if "mariadb" in version_string.lower(): if not version or version < MIN_VERSION_MARIA_DB: _raise_if_version_unsupported( version or version_string, "MariaDB", MIN_VERSION_MARIA_DB @@ -523,19 +523,21 @@ def setup_connection_for_dialect( instance.hass, version, ) - + slow_range_in_select = bool( + not version + or version < MARIADB_WITH_FIXED_IN_QUERIES_105 + or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106 + or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107 + or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108 + ) elif not version or version < MIN_VERSION_MYSQL: _raise_if_version_unsupported( version or version_string, "MySQL", MIN_VERSION_MYSQL ) - - slow_range_in_select = bool( - not version - or version < MARIADB_WITH_FIXED_IN_QUERIES_105 - or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106 - or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107 - or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108 - ) + else: + # MySQL + # https://github.com/home-assistant/core/issues/137178 + slow_dependent_subquery = True # Ensure all times are using UTC to avoid issues with daylight savings execute_on_connection(dbapi_connection, "SET time_zone = '+00:00'") @@ -565,7 +567,10 @@ def setup_connection_for_dialect( return DatabaseEngine( dialect=SupportedDialect(dialect_name), version=version, - optimizer=DatabaseOptimizer(slow_range_in_select=slow_range_in_select), + optimizer=DatabaseOptimizer( + slow_range_in_select=slow_range_in_select, + slow_dependent_subquery=slow_dependent_subquery, + ), max_bind_vars=DEFAULT_MAX_BIND_VARS, ) diff --git a/tests/components/history/test_websocket_api_schema_32.py b/tests/components/history/test_websocket_api_schema_32.py index 7b84c47e81b..c9577e20fcf 100644 --- a/tests/components/history/test_websocket_api_schema_32.py +++ b/tests/components/history/test_websocket_api_schema_32.py @@ -1,5 +1,7 @@ """The tests the History component websocket_api.""" +from collections.abc import Generator + import pytest from homeassistant.components import recorder @@ -17,9 +19,9 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 5e1f02baeed..28eb097f576 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -15,7 +15,7 @@ from typing import Any, Literal, cast from unittest.mock import MagicMock, patch, sentinel from freezegun import freeze_time -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event as sqlalchemy_event from sqlalchemy.orm.session import Session from homeassistant import core as ha @@ -414,7 +414,15 @@ def create_engine_test_for_schema_version_postfix( schema_module = get_schema_module_path(schema_version_postfix) importlib.import_module(schema_module) old_db_schema = sys.modules[schema_module] + instance: Recorder | None = None + if "hass" in kwargs: + hass: HomeAssistant = kwargs.pop("hass") + instance = recorder.get_instance(hass) engine = create_engine(*args, **kwargs) + if instance is not None: + instance = recorder.get_instance(hass) + instance.engine = engine + sqlalchemy_event.listen(engine, "connect", instance._setup_recorder_connection) old_db_schema.Base.metadata.create_all(engine) with Session(engine) as session: session.add( @@ -435,7 +443,7 @@ def get_schema_module_path(schema_version_postfix: str) -> str: @contextmanager -def old_db_schema(schema_version_postfix: str) -> Iterator[None]: +def old_db_schema(hass: HomeAssistant, schema_version_postfix: str) -> Iterator[None]: """Fixture to initialize the db with the old schema.""" schema_module = get_schema_module_path(schema_version_postfix) importlib.import_module(schema_module) @@ -455,6 +463,7 @@ def old_db_schema(schema_version_postfix: str) -> Iterator[None]: CREATE_ENGINE_TARGET, new=partial( create_engine_test_for_schema_version_postfix, + hass=hass, schema_version_postfix=schema_version_postfix, ), ), diff --git a/tests/components/recorder/conftest.py b/tests/components/recorder/conftest.py index 9cdf9dbb372..681205126af 100644 --- a/tests/components/recorder/conftest.py +++ b/tests/components/recorder/conftest.py @@ -13,6 +13,7 @@ from sqlalchemy.orm.session import Session from homeassistant.components import recorder from homeassistant.components.recorder import db_schema +from homeassistant.components.recorder.const import MAX_IDS_FOR_INDEXED_GROUP_BY from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant @@ -190,3 +191,9 @@ def instrument_migration( instrumented_migration.live_migration_done_stall.set() instrumented_migration.non_live_migration_done_stall.set() yield instrumented_migration + + +@pytest.fixture(params=[1, 2, MAX_IDS_FOR_INDEXED_GROUP_BY]) +def ids_for_start_time_chunk_sizes(request: pytest.FixtureRequest) -> int: + """Fixture to test different chunk sizes for start time query.""" + return request.param diff --git a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py index d3024df4ed6..2e9883aaf53 100644 --- a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py +++ b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py @@ -1,6 +1,6 @@ """The tests for the recorder filter matching the EntityFilter component.""" -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Generator import json from unittest.mock import patch @@ -32,12 +32,21 @@ from homeassistant.helpers.entityfilter import ( from .common import async_wait_recording_done, old_db_schema +from tests.typing import RecorderInstanceContextManager + + +@pytest.fixture +async def mock_recorder_before_hass( + async_test_recorder: RecorderInstanceContextManager, +) -> None: + """Set up recorder.""" + # This test is for schema 37 and below (32 is new enough to test) @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index 166451cc971..d6223eb55b3 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -2,10 +2,11 @@ from __future__ import annotations +from collections.abc import Generator from copy import copy from datetime import datetime, timedelta import json -from unittest.mock import sentinel +from unittest.mock import patch, sentinel from freezegun import freeze_time import pytest @@ -36,6 +37,24 @@ from .common import ( from tests.typing import RecorderInstanceContextManager +@pytest.fixture +def multiple_start_time_chunk_sizes( + ids_for_start_time_chunk_sizes: int, +) -> Generator[None]: + """Fixture to test different chunk sizes for start time query. + + Force the recorder to use different chunk sizes for start time query. + + In effect this forces get_significant_states_with_session + to call _generate_significant_states_with_session_stmt multiple times. + """ + with patch( + "homeassistant.components.recorder.history.modern.MAX_IDS_FOR_INDEXED_GROUP_BY", + ids_for_start_time_chunk_sizes, + ): + yield + + @pytest.fixture async def mock_recorder_before_hass( async_test_recorder: RecorderInstanceContextManager, @@ -429,6 +448,7 @@ async def test_ensure_state_can_be_copied( assert_states_equal_without_context(copy(hist[entity_id][1]), hist[entity_id][1]) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states(hass: HomeAssistant) -> None: """Test that only significant states are returned. @@ -443,6 +463,7 @@ async def test_get_significant_states(hass: HomeAssistant) -> None: assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_minimal_response( hass: HomeAssistant, ) -> None: @@ -512,6 +533,7 @@ async def test_get_significant_states_minimal_response( ) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("time_zone", ["Europe/Berlin", "US/Hawaii", "UTC"]) async def test_get_significant_states_with_initial( time_zone, hass: HomeAssistant @@ -544,6 +566,7 @@ async def test_get_significant_states_with_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_without_initial( hass: HomeAssistant, ) -> None: @@ -578,6 +601,7 @@ async def test_get_significant_states_without_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_entity_id( hass: HomeAssistant, ) -> None: @@ -596,6 +620,7 @@ async def test_get_significant_states_entity_id( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_multiple_entity_ids( hass: HomeAssistant, ) -> None: diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index 142d2fc87f6..908a67cd635 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator from copy import copy from datetime import datetime, timedelta import json @@ -50,9 +51,9 @@ def disable_states_meta_manager(): @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/test_history_db_schema_42.py b/tests/components/recorder/test_history_db_schema_42.py index 1523f373ea8..20d0c162d35 100644 --- a/tests/components/recorder/test_history_db_schema_42.py +++ b/tests/components/recorder/test_history_db_schema_42.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator from copy import copy from datetime import datetime, timedelta import json @@ -42,9 +43,9 @@ async def mock_recorder_before_hass( @pytest.fixture(autouse=True) -def db_schema_42(): +def db_schema_42(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 42.""" - with old_db_schema("42"): + with old_db_schema(hass, "42"): yield diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index 45bef68dabd..0212e4b012e 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -58,9 +58,9 @@ async def mock_recorder_before_hass( @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 6e192295c58..ed883c5403e 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1,5 +1,6 @@ """The tests for sensor recorder platform.""" +from collections.abc import Generator from datetime import timedelta from typing import Any from unittest.mock import ANY, Mock, patch @@ -18,7 +19,8 @@ from homeassistant.components.recorder.statistics import ( STATISTIC_UNIT_TO_UNIT_CONVERTER, PlatformCompiledStatistics, _generate_max_mean_min_statistic_in_sub_period_stmt, - _generate_statistics_at_time_stmt, + _generate_statistics_at_time_stmt_dependent_sub_query, + _generate_statistics_at_time_stmt_group_by, _generate_statistics_during_period_stmt, async_add_external_statistics, async_import_statistics, @@ -57,6 +59,24 @@ from tests.common import MockPlatform, mock_platform from tests.typing import RecorderInstanceContextManager, WebSocketGenerator +@pytest.fixture +def multiple_start_time_chunk_sizes( + ids_for_start_time_chunk_sizes: int, +) -> Generator[None]: + """Fixture to test different chunk sizes for start time query. + + Force the statistics query to use different chunk sizes for start time query. + + In effect this forces _statistics_at_time + to call _generate_statistics_at_time_stmt_group_by multiple times. + """ + with patch( + "homeassistant.components.recorder.statistics.MAX_IDS_FOR_INDEXED_GROUP_BY", + ids_for_start_time_chunk_sizes, + ): + yield + + @pytest.fixture async def mock_recorder_before_hass( async_test_recorder: RecorderInstanceContextManager, @@ -1113,6 +1133,7 @@ async def test_import_statistics_errors( assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_daily_statistics_sum( @@ -1293,6 +1314,215 @@ async def test_daily_statistics_sum( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +async def test_multiple_daily_statistics_sum( + hass: HomeAssistant, + setup_recorder: None, + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test daily statistics.""" + await hass.config.async_set_time_zone(timezone) + await async_wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 23:00:00")) + period5 = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + period6 = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 23:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 4, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 5, + }, + { + "start": period5, + "last_reset": None, + "state": 4, + "sum": 6, + }, + { + "start": period6, + "last_reset": None, + "state": 5, + "sum": 7, + }, + ) + external_metadata1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy 1", + "source": "test", + "statistic_id": "test:total_energy_import2", + "unit_of_measurement": "kWh", + } + external_metadata2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy 2", + "source": "test", + "statistic_id": "test:total_energy_import1", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics(hass, external_metadata1, external_statistics) + async_add_external_statistics(hass, external_metadata2, external_statistics) + + await async_wait_recording_done(hass) + stats = statistics_during_period( + hass, + zero, + period="day", + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + ) + day1_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + day1_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + day2_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + day2_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + day3_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + day3_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-06 00:00:00")) + expected_stats_inner = [ + { + "start": day1_start.timestamp(), + "end": day1_end.timestamp(), + "last_reset": None, + "state": 1.0, + "sum": 3.0, + }, + { + "start": day2_start.timestamp(), + "end": day2_end.timestamp(), + "last_reset": None, + "state": 3.0, + "sum": 5.0, + }, + { + "start": day3_start.timestamp(), + "end": day3_end.timestamp(), + "last_reset": None, + "state": 5.0, + "sum": 7.0, + }, + ] + expected_stats = { + "test:total_energy_import1": expected_stats_inner, + "test:total_energy_import2": expected_stats_inner, + } + assert stats == expected_stats + + # Get change + stats = statistics_during_period( + hass, + start_time=period1, + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + period="day", + types={"change"}, + ) + expected_inner = [ + { + "start": day1_start.timestamp(), + "end": day1_end.timestamp(), + "change": 3.0, + }, + { + "start": day2_start.timestamp(), + "end": day2_end.timestamp(), + "change": 2.0, + }, + { + "start": day3_start.timestamp(), + "end": day3_end.timestamp(), + "change": 2.0, + }, + ] + assert stats == { + "test:total_energy_import1": expected_inner, + "test:total_energy_import2": expected_inner, + } + + # Get data with start during the first period + stats = statistics_during_period( + hass, + start_time=period1 + timedelta(hours=1), + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + period="day", + ) + assert stats == expected_stats + + # Get data with end during the third period + stats = statistics_during_period( + hass, + start_time=zero, + end_time=period6 - timedelta(hours=1), + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + period="day", + ) + assert stats == expected_stats + + # Try to get data for entities which do not exist + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids={ + "not", + "the", + "same", + "test:total_energy_import1", + "test:total_energy_import2", + }, + period="day", + ) + assert stats == expected_stats + + # Use 5minute to ensure table switch works + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids=[ + "test:total_energy_import1", + "with_other", + "test:total_energy_import2", + ], + period="5minute", + ) + assert stats == {} + + # Ensure future date has not data + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, start_time=future, end_time=future, period="day" + ) + assert stats == {} + + +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_weekly_statistics_mean( @@ -1428,6 +1658,7 @@ async def test_weekly_statistics_mean( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_weekly_statistics_sum( @@ -1608,6 +1839,7 @@ async def test_weekly_statistics_sum( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") async def test_monthly_statistics_sum( @@ -1914,20 +2146,43 @@ def test_cache_key_for_generate_max_mean_min_statistic_in_sub_period_stmt() -> N assert cache_key_1 != cache_key_3 -def test_cache_key_for_generate_statistics_at_time_stmt() -> None: - """Test cache key for _generate_statistics_at_time_stmt.""" - stmt = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) +def test_cache_key_for_generate_statistics_at_time_stmt_group_by() -> None: + """Test cache key for _generate_statistics_at_time_stmt_group_by.""" + stmt = _generate_statistics_at_time_stmt_group_by( + StatisticsShortTerm, {0}, 0.0, set() + ) cache_key_1 = stmt._generate_cache_key() - stmt2 = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) + stmt2 = _generate_statistics_at_time_stmt_group_by( + StatisticsShortTerm, {0}, 0.0, set() + ) cache_key_2 = stmt2._generate_cache_key() assert cache_key_1 == cache_key_2 - stmt3 = _generate_statistics_at_time_stmt( + stmt3 = _generate_statistics_at_time_stmt_group_by( StatisticsShortTerm, {0}, 0.0, {"sum", "mean"} ) cache_key_3 = stmt3._generate_cache_key() assert cache_key_1 != cache_key_3 +def test_cache_key_for_generate_statistics_at_time_stmt_dependent_sub_query() -> None: + """Test cache key for _generate_statistics_at_time_stmt_dependent_sub_query.""" + stmt = _generate_statistics_at_time_stmt_dependent_sub_query( + StatisticsShortTerm, {0}, 0.0, set() + ) + cache_key_1 = stmt._generate_cache_key() + stmt2 = _generate_statistics_at_time_stmt_dependent_sub_query( + StatisticsShortTerm, {0}, 0.0, set() + ) + cache_key_2 = stmt2._generate_cache_key() + assert cache_key_1 == cache_key_2 + stmt3 = _generate_statistics_at_time_stmt_dependent_sub_query( + StatisticsShortTerm, {0}, 0.0, {"sum", "mean"} + ) + cache_key_3 = stmt3._generate_cache_key() + assert cache_key_1 != cache_key_3 + + +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_change( @@ -2263,6 +2518,392 @@ async def test_change( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +async def test_change_multiple( + hass: HomeAssistant, + setup_recorder: None, + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test deriving change from sum statistic.""" + await hass.config.async_set_time_zone(timezone) + await async_wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 5, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 8, + }, + ) + external_metadata1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.total_energy_import1", + "unit_of_measurement": "kWh", + } + external_metadata2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.total_energy_import2", + "unit_of_measurement": "kWh", + } + async_import_statistics(hass, external_metadata1, external_statistics) + async_import_statistics(hass, external_metadata2, external_statistics) + await async_wait_recording_done(hass) + # Get change from far in the past + stats = statistics_during_period( + hass, + zero, + period="hour", + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + types={"change"}, + ) + hour1_start = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + hour1_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + hour2_start = hour1_end + hour2_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + hour3_start = hour2_end + hour3_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + hour4_start = hour3_end + hour4_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 04:00:00")) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0, + }, + ] + expected_stats = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats + + # Get change + sum from far in the past + stats = statistics_during_period( + hass, + zero, + period="hour", + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + types={"change", "sum"}, + ) + hour1_start = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + hour1_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + hour2_start = hour1_end + hour2_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + hour3_start = hour2_end + hour3_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + hour4_start = hour3_end + hour4_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 04:00:00")) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0, + "sum": 2.0, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0, + "sum": 3.0, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0, + "sum": 5.0, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0, + "sum": 8.0, + }, + ] + expected_stats_change_sum = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats_change_sum + + # Get change from far in the past with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0 * 1000, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0 * 1000, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0 * 1000, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0 * 1000, + }, + ] + expected_stats_wh = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats_wh + + # Get change from far in the past with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import1", "unknown", {"unit_of_measurement": "MWh"} + ) + hass.states.async_set( + "sensor.total_energy_import2", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0 / 1000, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0 / 1000, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0 / 1000, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0 / 1000, + }, + ] + expected_stats_mwh = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats_mwh + hass.states.async_remove("sensor.total_energy_import1") + hass.states.async_remove("sensor.total_energy_import2") + + # Get change from the first recorded hour + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == expected_stats + + # Get change from the first recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == expected_stats_wh + + # Get change from the first recorded hour with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import1", "unknown", {"unit_of_measurement": "MWh"} + ) + hass.states.async_set( + "sensor.total_energy_import2", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == expected_stats_mwh + hass.states.async_remove("sensor.total_energy_import1") + hass.states.async_remove("sensor.total_energy_import2") + + # Get change from the second recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats["sensor.total_energy_import1"][ + 1:4 + ], + "sensor.total_energy_import2": expected_stats["sensor.total_energy_import2"][ + 1:4 + ], + } + + # Get change from the second recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats_wh["sensor.total_energy_import1"][ + 1:4 + ], + "sensor.total_energy_import2": expected_stats_wh["sensor.total_energy_import2"][ + 1:4 + ], + } + + # Get change from the second recorded hour with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import1", "unknown", {"unit_of_measurement": "MWh"} + ) + hass.states.async_set( + "sensor.total_energy_import2", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats_mwh[ + "sensor.total_energy_import1" + ][1:4], + "sensor.total_energy_import2": expected_stats_mwh[ + "sensor.total_energy_import2" + ][1:4], + } + hass.states.async_remove("sensor.total_energy_import1") + hass.states.async_remove("sensor.total_energy_import2") + + # Get change from the second until the third recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + end_time=hour4_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats["sensor.total_energy_import1"][ + 1:3 + ], + "sensor.total_energy_import2": expected_stats["sensor.total_energy_import2"][ + 1:3 + ], + } + + # Get change from the fourth recorded hour + stats = statistics_during_period( + hass, + start_time=hour4_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats["sensor.total_energy_import1"][ + 3:4 + ], + "sensor.total_energy_import2": expected_stats["sensor.total_energy_import2"][ + 3:4 + ], + } + + # Test change with a far future start date + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, + start_time=future, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == {} + + +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_change_with_none( diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index c9020762d4b..6c324f4b01a 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -417,7 +417,12 @@ def test_supported_mysql(caplog: pytest.LogCaptureFixture, mysql_version) -> Non dbapi_connection = MagicMock(cursor=_make_cursor_mock) - util.setup_connection_for_dialect(instance_mock, "mysql", dbapi_connection, True) + database_engine = util.setup_connection_for_dialect( + instance_mock, "mysql", dbapi_connection, True + ) + assert database_engine is not None + assert database_engine.optimizer.slow_range_in_select is False + assert database_engine.optimizer.slow_dependent_subquery is True assert "minimum supported version" not in caplog.text @@ -502,6 +507,7 @@ def test_supported_pgsql(caplog: pytest.LogCaptureFixture, pgsql_version) -> Non assert "minimum supported version" not in caplog.text assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is True + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.parametrize( @@ -583,6 +589,7 @@ def test_supported_sqlite(caplog: pytest.LogCaptureFixture, sqlite_version) -> N assert "minimum supported version" not in caplog.text assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is False + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.parametrize( @@ -675,6 +682,7 @@ async def test_issue_for_mariadb_with_MDEV_25020( assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is True + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.parametrize( @@ -731,6 +739,7 @@ async def test_no_issue_for_mariadb_with_MDEV_25020( assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is False + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) From 0a3562aca31cbcdcf934ab0fafeed5862dbe9ffc Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 2 Mar 2025 16:45:57 +0100 Subject: [PATCH 2974/2987] Add prefix path support to pyLoad integration (#139139) * Add prefix path configuration support * fix typo * formatting * uppercase * changes * redact host --- homeassistant/components/pyload/__init__.py | 37 +++++++++++-- .../components/pyload/config_flow.py | 55 +++++++++++-------- .../components/pyload/diagnostics.py | 13 +++-- homeassistant/components/pyload/strings.json | 22 +++----- tests/components/pyload/conftest.py | 34 +++++++++--- .../pyload/snapshots/test_diagnostics.ambr | 4 +- tests/components/pyload/test_init.py | 20 +++++++ 7 files changed, 126 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py index cf8e922d70e..ca7bbb0c1dc 100644 --- a/homeassistant/components/pyload/__init__.py +++ b/homeassistant/components/pyload/__init__.py @@ -2,14 +2,18 @@ from __future__ import annotations +import logging + from aiohttp import CookieJar from pyloadapi import PyLoadAPI +from yarl import URL from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SSL, + CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, Platform, @@ -19,17 +23,14 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from .coordinator import PyLoadConfigEntry, PyLoadCoordinator +_LOGGER = logging.getLogger(__name__) + PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool: """Set up pyLoad from a config entry.""" - url = ( - f"{'https' if entry.data[CONF_SSL] else 'http'}://" - f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}/" - ) - session = async_create_clientsession( hass, verify_ssl=entry.data[CONF_VERIFY_SSL], @@ -37,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo ) pyloadapi = PyLoadAPI( session, - api_url=url, + api_url=URL(entry.data[CONF_URL]), username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], ) @@ -55,3 +56,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool: + """Migrate config entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", entry.version, entry.minor_version + ) + + if entry.version == 1 and entry.minor_version == 0: + url = URL.build( + scheme="https" if entry.data[CONF_SSL] else "http", + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + ).human_repr() + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_URL: url}, minor_version=1, version=1 + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + entry.version, + entry.minor_version, + ) + return True diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index bc3bbc6cb34..50d354d345d 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -9,19 +9,17 @@ from typing import Any from aiohttp import CookieJar from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI import voluptuous as vol +from yarl import URL from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PASSWORD, - CONF_PORT, - CONF_SSL, + CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.selector import ( TextSelector, @@ -29,15 +27,18 @@ from homeassistant.helpers.selector import ( TextSelectorType, ) -from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN +from .const import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_HOST): str, - vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Required(CONF_SSL, default=False): cv.boolean, + vol.Required(CONF_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + autocomplete="url", + ), + ), vol.Required(CONF_VERIFY_SSL, default=True): bool, vol.Required(CONF_USERNAME): TextSelector( TextSelectorConfig( @@ -80,14 +81,9 @@ async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> Non user_input[CONF_VERIFY_SSL], cookie_jar=CookieJar(unsafe=True), ) - - url = ( - f"{'https' if user_input[CONF_SSL] else 'http'}://" - f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}/" - ) pyload = PyLoadAPI( session, - api_url=url, + api_url=URL(user_input[CONF_URL]), username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD], ) @@ -99,6 +95,7 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for pyLoad.""" VERSION = 1 + MINOR_VERSION = 1 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -106,9 +103,8 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - self._async_abort_entries_match( - {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} - ) + url = URL(user_input[CONF_URL]).human_repr() + self._async_abort_entries_match({CONF_URL: url}) try: await validate_input(self.hass, user_input) except (CannotConnect, ParserError): @@ -120,7 +116,14 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: title = DEFAULT_NAME - return self.async_create_entry(title=title, data=user_input) + + return self.async_create_entry( + title=title, + data={ + **user_input, + CONF_URL: url, + }, + ) return self.async_show_form( step_id="user", @@ -144,9 +147,8 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): reauth_entry = self._get_reauth_entry() if user_input is not None: - new_input = reauth_entry.data | user_input try: - await validate_input(self.hass, new_input) + await validate_input(self.hass, {**reauth_entry.data, **user_input}) except (CannotConnect, ParserError): errors["base"] = "cannot_connect" except InvalidAuth: @@ -155,7 +157,9 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - return self.async_update_reload_and_abort(reauth_entry, data=new_input) + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input + ) return self.async_show_form( step_id="reauth_confirm", @@ -191,15 +195,18 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): else: return self.async_update_reload_and_abort( reconfig_entry, - data=user_input, + data={ + **user_input, + CONF_URL: URL(user_input[CONF_URL]).human_repr(), + }, reload_even_if_entry_is_unchanged=False, ) - + suggested_values = user_input if user_input else reconfig_entry.data return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( STEP_USER_DATA_SCHEMA, - user_input or reconfig_entry.data, + suggested_values, ), description_placeholders={CONF_NAME: reconfig_entry.data[CONF_USERNAME]}, errors=errors, diff --git a/homeassistant/components/pyload/diagnostics.py b/homeassistant/components/pyload/diagnostics.py index 105a9a953e2..98fab38da1d 100644 --- a/homeassistant/components/pyload/diagnostics.py +++ b/homeassistant/components/pyload/diagnostics.py @@ -5,13 +5,15 @@ from __future__ import annotations from dataclasses import asdict from typing import Any -from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from yarl import URL + +from homeassistant.components.diagnostics import REDACTED, async_redact_data +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant from .coordinator import PyLoadConfigEntry, PyLoadData -TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_HOST} +TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_URL} async def async_get_config_entry_diagnostics( @@ -21,6 +23,9 @@ async def async_get_config_entry_diagnostics( pyload_data: PyLoadData = config_entry.runtime_data.data return { - "config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT), + "config_entry_data": { + **async_redact_data(dict(config_entry.data), TO_REDACT), + CONF_URL: URL(config_entry.data[CONF_URL]).with_host(REDACTED).human_repr(), + }, "pyload_data": asdict(pyload_data), } diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index ed15a438c28..9414f7f7bb8 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -3,38 +3,30 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]", + "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "ssl": "[%key:common::config_flow::data::ssl%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", - "port": "[%key:common::config_flow::data::port%]" + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "host": "The hostname or IP address of the device running your pyLoad instance.", + "url": "Specify the full URL of your pyLoad web interface, including the protocol (HTTP or HTTPS), hostname or IP address, port (pyLoad uses 8000 by default), and any path prefix if applicable.\nExample: `https://example.com:8000/path`", "username": "The username used to access the pyLoad instance.", "password": "The password associated with the pyLoad account.", - "port": "pyLoad uses port 8000 by default.", - "ssl": "If enabled, the connection to the pyLoad instance will use HTTPS.", "verify_ssl": "If checked, the SSL certificate will be validated to ensure a secure connection." } }, "reconfigure": { "data": { - "host": "[%key:common::config_flow::data::host%]", + "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "ssl": "[%key:common::config_flow::data::ssl%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", - "port": "[%key:common::config_flow::data::port%]" + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "host": "[%key:component::pyload::config::step::user::data_description::host%]", - "verify_ssl": "[%key:component::pyload::config::step::user::data_description::verify_ssl%]", + "url": "[%key:component::pyload::config::step::user::data_description::url%]", "username": "[%key:component::pyload::config::step::user::data_description::username%]", "password": "[%key:component::pyload::config::step::user::data_description::password%]", - "port": "[%key:component::pyload::config::step::user::data_description::port%]", - "ssl": "[%key:component::pyload::config::step::user::data_description::ssl%]" + "verify_ssl": "[%key:component::pyload::config::step::user::data_description::verify_ssl%]" } }, "reauth_confirm": { diff --git a/tests/components/pyload/conftest.py b/tests/components/pyload/conftest.py index 46144771cc1..9b410a5fdd6 100644 --- a/tests/components/pyload/conftest.py +++ b/tests/components/pyload/conftest.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_SSL, + CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, ) @@ -19,10 +20,8 @@ from homeassistant.const import ( from tests.common import MockConfigEntry USER_INPUT = { - CONF_HOST: "pyload.local", + CONF_URL: "https://pyload.local:8000/prefix", CONF_PASSWORD: "test-password", - CONF_PORT: 8000, - CONF_SSL: True, CONF_USERNAME: "test-username", CONF_VERIFY_SSL: False, } @@ -33,10 +32,8 @@ REAUTH_INPUT = { } NEW_INPUT = { - CONF_HOST: "pyload.local", + CONF_URL: "https://pyload.local:8000/prefix", CONF_PASSWORD: "new-password", - CONF_PORT: 8000, - CONF_SSL: True, CONF_USERNAME: "new-username", CONF_VERIFY_SSL: False, } @@ -97,5 +94,28 @@ def mock_pyloadapi() -> Generator[MagicMock]: def mock_config_entry() -> MockConfigEntry: """Mock pyLoad configuration entry.""" return MockConfigEntry( - domain=DOMAIN, title=DEFAULT_NAME, data=USER_INPUT, entry_id="XXXXXXXXXXXXXX" + domain=DOMAIN, + title=DEFAULT_NAME, + data=USER_INPUT, + entry_id="XXXXXXXXXXXXXX", + ) + + +@pytest.fixture(name="config_entry_migrate") +def mock_config_entry_migrate() -> MockConfigEntry: + """Mock pyLoad configuration entry for migration.""" + return MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_NAME, + data={ + CONF_HOST: "pyload.local", + CONF_PASSWORD: "test-password", + CONF_PORT: 8000, + CONF_SSL: True, + CONF_USERNAME: "test-username", + CONF_VERIFY_SSL: False, + }, + version=1, + minor_version=0, + entry_id="XXXXXXXXXXXXXX", ) diff --git a/tests/components/pyload/snapshots/test_diagnostics.ambr b/tests/components/pyload/snapshots/test_diagnostics.ambr index e2b51ad184a..81a5d750bc0 100644 --- a/tests/components/pyload/snapshots/test_diagnostics.ambr +++ b/tests/components/pyload/snapshots/test_diagnostics.ambr @@ -2,10 +2,8 @@ # name: test_diagnostics dict({ 'config_entry_data': dict({ - 'host': '**REDACTED**', 'password': '**REDACTED**', - 'port': 8000, - 'ssl': True, + 'url': 'https://**redacted**:8000/prefix', 'username': '**REDACTED**', 'verify_ssl': False, }), diff --git a/tests/components/pyload/test_init.py b/tests/components/pyload/test_init.py index 00b1f0aa3a8..5c85979b9df 100644 --- a/tests/components/pyload/test_init.py +++ b/tests/components/pyload/test_init.py @@ -8,6 +8,7 @@ from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError import pytest from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import CONF_PATH, CONF_URL from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, async_fire_time_changed @@ -88,3 +89,22 @@ async def test_coordinator_update_invalid_auth( await hass.async_block_till_done() assert any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_migration( + hass: HomeAssistant, + config_entry_migrate: MockConfigEntry, +) -> None: + """Test config entry migration.""" + + config_entry_migrate.add_to_hass(hass) + assert config_entry_migrate.data.get(CONF_PATH) is None + + await hass.config_entries.async_setup(config_entry_migrate.entry_id) + await hass.async_block_till_done() + + assert config_entry_migrate.state is ConfigEntryState.LOADED + assert config_entry_migrate.version == 1 + assert config_entry_migrate.minor_version == 1 + assert config_entry_migrate.data[CONF_URL] == "https://pyload.local:8000/" From 8d6178ffa6ddc93b03bd75e1b4cd2b66acdba2b1 Mon Sep 17 00:00:00 2001 From: MarioZG Date: Sun, 2 Mar 2025 15:48:57 +0000 Subject: [PATCH 2975/2987] Add last updated attribute to UK transport train sensor (#139352) added last updated attribute to train sensor Co-authored-by: Franck Nijhof --- homeassistant/components/uk_transport/sensor.py | 6 +++++- tests/components/uk_transport/test_sensor.py | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py index b06d0e24891..594d46c74ab 100644 --- a/homeassistant/components/uk_transport/sensor.py +++ b/homeassistant/components/uk_transport/sensor.py @@ -32,6 +32,7 @@ ATTR_NEXT_BUSES = "next_buses" ATTR_STATION_CODE = "station_code" ATTR_CALLING_AT = "calling_at" ATTR_NEXT_TRAINS = "next_trains" +ATTR_LAST_UPDATED = "last_updated" CONF_API_APP_KEY = "app_key" CONF_API_APP_ID = "app_id" @@ -199,7 +200,9 @@ class UkTransportLiveBusTimeSensor(UkTransportSensor): def extra_state_attributes(self) -> dict[str, Any] | None: """Return other details about the sensor state.""" if self._data is not None: - attrs = {ATTR_NEXT_BUSES: self._next_buses} + attrs = { + ATTR_NEXT_BUSES: self._next_buses, + } for key in ( ATTR_ATCOCODE, ATTR_LOCALITY, @@ -272,6 +275,7 @@ class UkTransportLiveTrainTimeSensor(UkTransportSensor): attrs = { ATTR_STATION_CODE: self._station_code, ATTR_CALLING_AT: self._calling_at, + ATTR_LAST_UPDATED: self._data[ATTR_REQUEST_TIME], } if self._next_trains: attrs[ATTR_NEXT_TRAINS] = self._next_trains diff --git a/tests/components/uk_transport/test_sensor.py b/tests/components/uk_transport/test_sensor.py index a4a9aea18c8..ba547c5eecc 100644 --- a/tests/components/uk_transport/test_sensor.py +++ b/tests/components/uk_transport/test_sensor.py @@ -8,6 +8,7 @@ import requests_mock from homeassistant.components.uk_transport.sensor import ( ATTR_ATCOCODE, ATTR_CALLING_AT, + ATTR_LAST_UPDATED, ATTR_LOCALITY, ATTR_NEXT_BUSES, ATTR_NEXT_TRAINS, @@ -90,3 +91,4 @@ async def test_train(hass: HomeAssistant) -> None: == "London Waterloo" ) assert train_state.attributes[ATTR_NEXT_TRAINS][0]["estimated"] == "06:13" + assert train_state.attributes[ATTR_LAST_UPDATED] == "2017-07-10T06:10:05+01:00" From 4c8a58f7cc4135ccbb578145615c607fc26fb5ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Mar 2025 08:50:35 -0700 Subject: [PATCH 2976/2987] Fix broken link in ESPHome BLE repair (#139639) ESPHome always uses .0 in the URL for the changelog, and we never had a patch version in the stable BLE version field so we need to switch it to .0 for the URL. --- homeassistant/components/esphome/const.py | 4 +++- tests/components/esphome/test_manager.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index a31f5441dbb..18d15d0fbbd 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -19,6 +19,8 @@ STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", } -DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" +# ESPHome always uses .0 for the changelog URL +STABLE_BLE_URL_VERSION = f"{STABLE_BLE_VERSION.major}.{STABLE_BLE_VERSION.minor}.0" +DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html" DATA_FFMPEG_PROXY = f"{DOMAIN}.ffmpeg_proxy" diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index ddb1babd8a4..905a3f6bdc7 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -29,6 +29,7 @@ from homeassistant.components.esphome.const import ( CONF_DEVICE_NAME, CONF_SUBSCRIBE_LOGS, DOMAIN, + STABLE_BLE_URL_VERSION, STABLE_BLE_VERSION_STR, ) from homeassistant.const import ( @@ -366,7 +367,7 @@ async def test_esphome_device_with_old_bluetooth( ) assert ( issue.learn_more_url - == f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" + == f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html" ) From d006d33dc0b940aecbf3bdf4526b1e1d62aaf9b7 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sun, 2 Mar 2025 16:52:25 +0100 Subject: [PATCH 2977/2987] Remove deprecated device migration from opentherm_gw (#139612) --- .../components/opentherm_gw/__init__.py | 17 ------ tests/components/opentherm_gw/test_init.py | 53 ------------------- 2 files changed, 70 deletions(-) diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index f16e9f186be..8a0a2412c25 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -87,23 +87,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b gateway = OpenThermGatewayHub(hass, config_entry) hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] = gateway - # Migration can be removed in 2025.4.0 - dev_reg = dr.async_get(hass) - if ( - migrate_device := dev_reg.async_get_device( - {(DOMAIN, config_entry.data[CONF_ID])} - ) - ) is not None: - dev_reg.async_update_device( - migrate_device.id, - new_identifiers={ - ( - DOMAIN, - f"{config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.GATEWAY}", - ) - }, - ) - # Migration can be removed in 2025.4.0 ent_reg = er.async_get(hass) if ( diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index 4085e25c614..e97e6d87f7c 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -71,59 +71,6 @@ async def test_device_registry_update( assert gw_dev.sw_version == VERSION_NEW -# Device migration test can be removed in 2025.4.0 -async def test_device_migration( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, - mock_pyotgw: MagicMock, -) -> None: - """Test that the device registry is updated correctly.""" - mock_config_entry.add_to_hass(hass) - - device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - identifiers={ - (DOMAIN, MOCK_GATEWAY_ID), - }, - name="Mock Gateway", - manufacturer="Schelte Bron", - model="OpenTherm Gateway", - sw_version=VERSION_TEST, - ) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert ( - device_registry.async_get_device(identifiers={(DOMAIN, MOCK_GATEWAY_ID)}) - is None - ) - - gw_dev = device_registry.async_get_device( - identifiers={(DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.GATEWAY}")} - ) - assert gw_dev is not None - - assert ( - device_registry.async_get_device( - identifiers={ - (DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.BOILER}") - } - ) - is not None - ) - - assert ( - device_registry.async_get_device( - identifiers={ - (DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.THERMOSTAT}") - } - ) - is not None - ) - - # Entity migration test can be removed in 2025.4.0 async def test_climate_entity_migration( hass: HomeAssistant, From de4540c68e3e52f45f2542b59b0b69f97163e826 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sun, 2 Mar 2025 17:28:11 +0100 Subject: [PATCH 2978/2987] Remove deprecated entity migration from opentherm_gw (#139641) --- .../components/opentherm_gw/__init__.py | 19 +----------- tests/components/opentherm_gw/test_init.py | 29 +------------------ 2 files changed, 2 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 8a0a2412c25..87da159872d 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -9,7 +9,6 @@ import pyotgw.vars as gw_vars from serial import SerialException import voluptuous as vol -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DATE, @@ -25,11 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_registry as er, -) +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( @@ -87,18 +82,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b gateway = OpenThermGatewayHub(hass, config_entry) hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] = gateway - # Migration can be removed in 2025.4.0 - ent_reg = er.async_get(hass) - if ( - entity_id := ent_reg.async_get_entity_id( - CLIMATE_DOMAIN, DOMAIN, config_entry.data[CONF_ID] - ) - ) is not None: - ent_reg.async_update_entity( - entity_id, - new_unique_id=f"{config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity", - ) - config_entry.add_update_listener(options_updated) try: diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index e97e6d87f7c..84629137ce1 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -8,9 +8,8 @@ from homeassistant.components.opentherm_gw.const import ( DOMAIN, OpenThermDeviceIdentifier, ) -from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from .conftest import MOCK_GATEWAY_ID, VERSION_TEST @@ -69,29 +68,3 @@ async def test_device_registry_update( ) assert gw_dev is not None assert gw_dev.sw_version == VERSION_NEW - - -# Entity migration test can be removed in 2025.4.0 -async def test_climate_entity_migration( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - mock_config_entry: MockConfigEntry, - mock_pyotgw: MagicMock, -) -> None: - """Test that the climate entity unique_id gets migrated correctly.""" - mock_config_entry.add_to_hass(hass) - entry = entity_registry.async_get_or_create( - domain="climate", - platform="opentherm_gw", - unique_id=mock_config_entry.data[CONF_ID], - ) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - updated_entry = entity_registry.async_get(entry.entity_id) - assert updated_entry is not None - assert ( - updated_entry.unique_id - == f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity" - ) From 40099547ef6dbd59caf811cafb85df276ba17ccd Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sun, 2 Mar 2025 17:36:37 +0100 Subject: [PATCH 2979/2987] Add typing/async to NMBS (#139002) * Add typing/async to NMBS * Fix tests * Boolean fields * Update homeassistant/components/nmbs/sensor.py Co-authored-by: Jorim Tielemans --------- Co-authored-by: Shay Levy Co-authored-by: Jorim Tielemans --- homeassistant/components/nmbs/__init__.py | 9 +- homeassistant/components/nmbs/config_flow.py | 50 ++++---- homeassistant/components/nmbs/const.py | 8 +- homeassistant/components/nmbs/manifest.json | 2 +- homeassistant/components/nmbs/sensor.py | 126 +++++++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nmbs/__init__.py | 19 --- tests/components/nmbs/conftest.py | 5 +- tests/components/nmbs/test_config_flow.py | 4 +- 10 files changed, 101 insertions(+), 126 deletions(-) diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py index 7d06baf37b6..4a2783143ca 100644 --- a/homeassistant/components/nmbs/__init__.py +++ b/homeassistant/components/nmbs/__init__.py @@ -8,6 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -22,13 +23,13 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the NMBS component.""" - api_client = iRail() + api_client = iRail(session=async_get_clientsession(hass)) hass.data.setdefault(DOMAIN, {}) - station_response = await hass.async_add_executor_job(api_client.get_stations) - if station_response == -1: + station_response = await api_client.get_stations() + if station_response is None: return False - hass.data[DOMAIN] = station_response["station"] + hass.data[DOMAIN] = station_response.stations return True diff --git a/homeassistant/components/nmbs/config_flow.py b/homeassistant/components/nmbs/config_flow.py index e45b2d9adeb..60ab015e22b 100644 --- a/homeassistant/components/nmbs/config_flow.py +++ b/homeassistant/components/nmbs/config_flow.py @@ -3,11 +3,13 @@ from typing import Any from pyrail import iRail +from pyrail.models import StationDetails import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import Platform from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( BooleanSelector, SelectOptionDict, @@ -31,17 +33,15 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize.""" - self.api_client = iRail() - self.stations: list[dict[str, Any]] = [] + self.stations: list[StationDetails] = [] - async def _fetch_stations(self) -> list[dict[str, Any]]: + async def _fetch_stations(self) -> list[StationDetails]: """Fetch the stations.""" - stations_response = await self.hass.async_add_executor_job( - self.api_client.get_stations - ) - if stations_response == -1: + api_client = iRail(session=async_get_clientsession(self.hass)) + stations_response = await api_client.get_stations() + if stations_response is None: raise CannotConnect("The API is currently unavailable.") - return stations_response["station"] + return stations_response.stations async def _fetch_stations_choices(self) -> list[SelectOptionDict]: """Fetch the stations options.""" @@ -50,7 +50,7 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): self.stations = await self._fetch_stations() return [ - SelectOptionDict(value=station["id"], label=station["standardname"]) + SelectOptionDict(value=station.id, label=station.standard_name) for station in self.stations ] @@ -72,12 +72,12 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): [station_from] = [ station for station in self.stations - if station["id"] == user_input[CONF_STATION_FROM] + if station.id == user_input[CONF_STATION_FROM] ] [station_to] = [ station for station in self.stations - if station["id"] == user_input[CONF_STATION_TO] + if station.id == user_input[CONF_STATION_TO] ] vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS) else "" await self.async_set_unique_id( @@ -85,7 +85,7 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured() - config_entry_name = f"Train from {station_from['standardname']} to {station_to['standardname']}" + config_entry_name = f"Train from {station_from.standard_name} to {station_to.standard_name}" return self.async_create_entry( title=config_entry_name, data=user_input, @@ -127,18 +127,18 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): station_live = None for station in self.stations: if user_input[CONF_STATION_FROM] in ( - station["standardname"], - station["name"], + station.standard_name, + station.name, ): station_from = station if user_input[CONF_STATION_TO] in ( - station["standardname"], - station["name"], + station.standard_name, + station.name, ): station_to = station if CONF_STATION_LIVE in user_input and user_input[CONF_STATION_LIVE] in ( - station["standardname"], - station["name"], + station.standard_name, + station.name, ): station_live = station @@ -148,29 +148,29 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="same_station") # config flow uses id and not the standard name - user_input[CONF_STATION_FROM] = station_from["id"] - user_input[CONF_STATION_TO] = station_to["id"] + user_input[CONF_STATION_FROM] = station_from.id + user_input[CONF_STATION_TO] = station_to.id if station_live: - user_input[CONF_STATION_LIVE] = station_live["id"] + user_input[CONF_STATION_LIVE] = station_live.id entity_registry = er.async_get(self.hass) prefix = "live" vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS, False) else "" if entity_id := entity_registry.async_get_entity_id( Platform.SENSOR, DOMAIN, - f"{prefix}_{station_live['standardname']}_{station_from['standardname']}_{station_to['standardname']}", + f"{prefix}_{station_live.standard_name}_{station_from.standard_name}_{station_to.standard_name}", ): - new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}{vias}" + new_unique_id = f"{DOMAIN}_{prefix}_{station_live.id}_{station_from.id}_{station_to.id}{vias}" entity_registry.async_update_entity( entity_id, new_unique_id=new_unique_id ) if entity_id := entity_registry.async_get_entity_id( Platform.SENSOR, DOMAIN, - f"{prefix}_{station_live['name']}_{station_from['name']}_{station_to['name']}", + f"{prefix}_{station_live.name}_{station_from.name}_{station_to.name}", ): - new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}{vias}" + new_unique_id = f"{DOMAIN}_{prefix}_{station_live.id}_{station_from.id}_{station_to.id}{vias}" entity_registry.async_update_entity( entity_id, new_unique_id=new_unique_id ) diff --git a/homeassistant/components/nmbs/const.py b/homeassistant/components/nmbs/const.py index fddb7365501..04c8beb327d 100644 --- a/homeassistant/components/nmbs/const.py +++ b/homeassistant/components/nmbs/const.py @@ -19,11 +19,7 @@ CONF_SHOW_ON_MAP = "show_on_map" def find_station_by_name(hass: HomeAssistant, station_name: str): """Find given station_name in the station list.""" return next( - ( - s - for s in hass.data[DOMAIN] - if station_name in (s["standardname"], s["name"]) - ), + (s for s in hass.data[DOMAIN] if station_name in (s.standard_name, s.name)), None, ) @@ -31,6 +27,6 @@ def find_station_by_name(hass: HomeAssistant, station_name: str): def find_station(hass: HomeAssistant, station_name: str): """Find given station_id in the station list.""" return next( - (s for s in hass.data[DOMAIN] if station_name in s["id"]), + (s for s in hass.data[DOMAIN] if station_name in s.id), None, ) diff --git a/homeassistant/components/nmbs/manifest.json b/homeassistant/components/nmbs/manifest.json index 9016eff11f8..37ff9429a54 100644 --- a/homeassistant/components/nmbs/manifest.json +++ b/homeassistant/components/nmbs/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pyrail"], "quality_scale": "legacy", - "requirements": ["pyrail==0.0.3"] + "requirements": ["pyrail==0.4.1"] } diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index c6dea2d0843..822b0236dd0 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -2,10 +2,12 @@ from __future__ import annotations +from datetime import datetime import logging from typing import Any from pyrail import iRail +from pyrail.models import ConnectionDetails, LiveboardDeparture, StationDetails import voluptuous as vol from homeassistant.components.sensor import ( @@ -23,6 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -44,8 +47,6 @@ from .const import ( # noqa: F401 _LOGGER = logging.getLogger(__name__) -API_FAILURE = -1 - DEFAULT_NAME = "NMBS" DEFAULT_ICON = "mdi:train" @@ -63,12 +64,12 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( ) -def get_time_until(departure_time=None): +def get_time_until(departure_time: datetime | None = None): """Calculate the time between now and a train's departure time.""" if departure_time is None: return 0 - delta = dt_util.utc_from_timestamp(int(departure_time)) - dt_util.now() + delta = dt_util.as_utc(departure_time) - dt_util.utcnow() return round(delta.total_seconds() / 60) @@ -77,11 +78,9 @@ def get_delay_in_minutes(delay=0): return round(int(delay) / 60) -def get_ride_duration(departure_time, arrival_time, delay=0): +def get_ride_duration(departure_time: datetime, arrival_time: datetime, delay=0): """Calculate the total travel time in minutes.""" - duration = dt_util.utc_from_timestamp( - int(arrival_time) - ) - dt_util.utc_from_timestamp(int(departure_time)) + duration = arrival_time - departure_time duration_time = int(round(duration.total_seconds() / 60)) return duration_time + get_delay_in_minutes(delay) @@ -157,7 +156,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up NMBS sensor entities based on a config entry.""" - api_client = iRail() + api_client = iRail(session=async_get_clientsession(hass)) name = config_entry.data.get(CONF_NAME, None) show_on_map = config_entry.data.get(CONF_SHOW_ON_MAP, False) @@ -189,9 +188,9 @@ class NMBSLiveBoard(SensorEntity): def __init__( self, api_client: iRail, - live_station: dict[str, Any], - station_from: dict[str, Any], - station_to: dict[str, Any], + live_station: StationDetails, + station_from: StationDetails, + station_to: StationDetails, excl_vias: bool, ) -> None: """Initialize the sensor for getting liveboard data.""" @@ -201,7 +200,8 @@ class NMBSLiveBoard(SensorEntity): self._station_to = station_to self._excl_vias = excl_vias - self._attrs: dict[str, Any] | None = {} + self._attrs: LiveboardDeparture | None = None + self._state: str | None = None self.entity_registry_enabled_default = False @@ -209,22 +209,20 @@ class NMBSLiveBoard(SensorEntity): @property def name(self) -> str: """Return the sensor default name.""" - return f"Trains in {self._station['standardname']}" + return f"Trains in {self._station.standard_name}" @property def unique_id(self) -> str: """Return the unique ID.""" - unique_id = ( - f"{self._station['id']}_{self._station_from['id']}_{self._station_to['id']}" - ) + unique_id = f"{self._station.id}_{self._station_from.id}_{self._station_to.id}" vias = "_excl_vias" if self._excl_vias else "" return f"nmbs_live_{unique_id}{vias}" @property def icon(self) -> str: """Return the default icon or an alert icon if delays.""" - if self._attrs and int(self._attrs["delay"]) > 0: + if self._attrs and int(self._attrs.delay) > 0: return DEFAULT_ICON_ALERT return DEFAULT_ICON @@ -240,15 +238,15 @@ class NMBSLiveBoard(SensorEntity): if self._state is None or not self._attrs: return None - delay = get_delay_in_minutes(self._attrs["delay"]) - departure = get_time_until(self._attrs["time"]) + delay = get_delay_in_minutes(self._attrs.delay) + departure = get_time_until(self._attrs.time) attrs = { "departure": f"In {departure} minutes", "departure_minutes": departure, - "extra_train": int(self._attrs["isExtra"]) > 0, - "vehicle_id": self._attrs["vehicle"], - "monitored_station": self._station["standardname"], + "extra_train": self._attrs.is_extra, + "vehicle_id": self._attrs.vehicle, + "monitored_station": self._station.standard_name, } if delay > 0: @@ -257,28 +255,26 @@ class NMBSLiveBoard(SensorEntity): return attrs - def update(self) -> None: + async def async_update(self, **kwargs: Any) -> None: """Set the state equal to the next departure.""" - liveboard = self._api_client.get_liveboard(self._station["id"]) + liveboard = await self._api_client.get_liveboard(self._station.id) - if liveboard == API_FAILURE: + if liveboard is None: _LOGGER.warning("API failed in NMBSLiveBoard") return - if not (departures := liveboard.get("departures")): + if not (departures := liveboard.departures): _LOGGER.warning("API returned invalid departures: %r", liveboard) return _LOGGER.debug("API returned departures: %r", departures) - if departures["number"] == "0": + if len(departures) == 0: # No trains are scheduled return - next_departure = departures["departure"][0] + next_departure = departures[0] self._attrs = next_departure - self._state = ( - f"Track {next_departure['platform']} - {next_departure['station']}" - ) + self._state = f"Track {next_departure.platform} - {next_departure.station}" class NMBSSensor(SensorEntity): @@ -292,8 +288,8 @@ class NMBSSensor(SensorEntity): api_client: iRail, name: str, show_on_map: bool, - station_from: dict[str, Any], - station_to: dict[str, Any], + station_from: StationDetails, + station_to: StationDetails, excl_vias: bool, ) -> None: """Initialize the NMBS connection sensor.""" @@ -304,13 +300,13 @@ class NMBSSensor(SensorEntity): self._station_to = station_to self._excl_vias = excl_vias - self._attrs: dict[str, Any] | None = {} + self._attrs: ConnectionDetails | None = None self._state = None @property def unique_id(self) -> str: """Return the unique ID.""" - unique_id = f"{self._station_from['id']}_{self._station_to['id']}" + unique_id = f"{self._station_from.id}_{self._station_to.id}" vias = "_excl_vias" if self._excl_vias else "" return f"nmbs_connection_{unique_id}{vias}" @@ -319,14 +315,14 @@ class NMBSSensor(SensorEntity): def name(self) -> str: """Return the name of the sensor.""" if self._name is None: - return f"Train from {self._station_from['standardname']} to {self._station_to['standardname']}" + return f"Train from {self._station_from.standard_name} to {self._station_to.standard_name}" return self._name @property def icon(self) -> str: """Return the sensor default icon or an alert icon if any delay.""" if self._attrs: - delay = get_delay_in_minutes(self._attrs["departure"]["delay"]) + delay = get_delay_in_minutes(self._attrs.departure.delay) if delay > 0: return "mdi:alert-octagon" @@ -338,19 +334,19 @@ class NMBSSensor(SensorEntity): if self._state is None or not self._attrs: return None - delay = get_delay_in_minutes(self._attrs["departure"]["delay"]) - departure = get_time_until(self._attrs["departure"]["time"]) - canceled = int(self._attrs["departure"]["canceled"]) + delay = get_delay_in_minutes(self._attrs.departure.delay) + departure = get_time_until(self._attrs.departure.time) + canceled = self._attrs.departure.canceled attrs = { - "destination": self._attrs["departure"]["station"], - "direction": self._attrs["departure"]["direction"]["name"], - "platform_arriving": self._attrs["arrival"]["platform"], - "platform_departing": self._attrs["departure"]["platform"], - "vehicle_id": self._attrs["departure"]["vehicle"], + "destination": self._attrs.departure.station, + "direction": self._attrs.departure.direction.name, + "platform_arriving": self._attrs.arrival.platform, + "platform_departing": self._attrs.departure.platform, + "vehicle_id": self._attrs.departure.vehicle, } - if canceled != 1: + if not canceled: attrs["departure"] = f"In {departure} minutes" attrs["departure_minutes"] = departure attrs["canceled"] = False @@ -364,14 +360,14 @@ class NMBSSensor(SensorEntity): attrs[ATTR_LONGITUDE] = self.station_coordinates[1] if self.is_via_connection and not self._excl_vias: - via = self._attrs["vias"]["via"][0] + via = self._attrs.vias.via[0] - attrs["via"] = via["station"] - attrs["via_arrival_platform"] = via["arrival"]["platform"] - attrs["via_transfer_platform"] = via["departure"]["platform"] + attrs["via"] = via.station + attrs["via_arrival_platform"] = via.arrival.platform + attrs["via_transfer_platform"] = via.departure.platform attrs["via_transfer_time"] = get_delay_in_minutes( - via["timebetween"] - ) + get_delay_in_minutes(via["departure"]["delay"]) + via.timebetween + ) + get_delay_in_minutes(via.departure.delay) if delay > 0: attrs["delay"] = f"{delay} minutes" @@ -390,8 +386,8 @@ class NMBSSensor(SensorEntity): if self._state is None or not self._attrs: return [] - latitude = float(self._attrs["departure"]["stationinfo"]["locationY"]) - longitude = float(self._attrs["departure"]["stationinfo"]["locationX"]) + latitude = float(self._attrs.departure.station_info.latitude) + longitude = float(self._attrs.departure.station_info.longitude) return [latitude, longitude] @property @@ -400,24 +396,24 @@ class NMBSSensor(SensorEntity): if not self._attrs: return False - return "vias" in self._attrs and int(self._attrs["vias"]["number"]) > 0 + return self._attrs.vias is not None and len(self._attrs.vias) > 0 - def update(self) -> None: + async def async_update(self, **kwargs: Any) -> None: """Set the state to the duration of a connection.""" - connections = self._api_client.get_connections( - self._station_from["id"], self._station_to["id"] + connections = await self._api_client.get_connections( + self._station_from.id, self._station_to.id ) - if connections == API_FAILURE: + if connections is None: _LOGGER.warning("API failed in NMBSSensor") return - if not (connection := connections.get("connection")): + if not (connection := connections.connections): _LOGGER.warning("API returned invalid connection: %r", connections) return _LOGGER.debug("API returned connection: %r", connection) - if int(connection[0]["departure"]["left"]) > 0: + if connection[0].departure.left: next_connection = connection[1] else: next_connection = connection[0] @@ -431,9 +427,9 @@ class NMBSSensor(SensorEntity): return duration = get_ride_duration( - next_connection["departure"]["time"], - next_connection["arrival"]["time"], - next_connection["departure"]["delay"], + next_connection.departure.time, + next_connection.arrival.time, + next_connection.departure.delay, ) self._state = duration diff --git a/requirements_all.txt b/requirements_all.txt index 696aef8b03b..5d274a3ba6a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2244,7 +2244,7 @@ pyqvrpro==0.52 pyqwikswitch==0.93 # homeassistant.components.nmbs -pyrail==0.0.3 +pyrail==0.4.1 # homeassistant.components.rainbird pyrainbird==6.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b9509b7fac3..19e143e3975 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1834,7 +1834,7 @@ pyps4-2ndscreen==1.3.1 pyqwikswitch==0.93 # homeassistant.components.nmbs -pyrail==0.0.3 +pyrail==0.4.1 # homeassistant.components.rainbird pyrainbird==6.0.1 diff --git a/tests/components/nmbs/__init__.py b/tests/components/nmbs/__init__.py index 91226950aba..3d284e5bb77 100644 --- a/tests/components/nmbs/__init__.py +++ b/tests/components/nmbs/__init__.py @@ -1,20 +1 @@ """Tests for the NMBS integration.""" - -import json -from typing import Any - -from tests.common import load_fixture - - -def mock_api_unavailable() -> dict[str, Any]: - """Mock for unavailable api.""" - return -1 - - -def mock_station_response() -> dict[str, Any]: - """Mock for valid station response.""" - dummy_stations_response: dict[str, Any] = json.loads( - load_fixture("stations.json", "nmbs") - ) - - return dummy_stations_response diff --git a/tests/components/nmbs/conftest.py b/tests/components/nmbs/conftest.py index 69200fc4c98..a39334ba62c 100644 --- a/tests/components/nmbs/conftest.py +++ b/tests/components/nmbs/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from pyrail.models import StationsApiResponse import pytest from homeassistant.components.nmbs.const import ( @@ -38,8 +39,8 @@ def mock_nmbs_client() -> Generator[AsyncMock]: ), ): client = mock_client.return_value - client.get_stations.return_value = load_json_object_fixture( - "stations.json", DOMAIN + client.get_stations.return_value = StationsApiResponse.from_dict( + load_json_object_fixture("stations.json", DOMAIN) ) yield client diff --git a/tests/components/nmbs/test_config_flow.py b/tests/components/nmbs/test_config_flow.py index ff4c5bdf72a..7e0f087607b 100644 --- a/tests/components/nmbs/test_config_flow.py +++ b/tests/components/nmbs/test_config_flow.py @@ -142,7 +142,7 @@ async def test_unavailable_api( hass: HomeAssistant, mock_nmbs_client: AsyncMock ) -> None: """Test starting a flow by user and api is unavailable.""" - mock_nmbs_client.get_stations.return_value = -1 + mock_nmbs_client.get_stations.return_value = None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, @@ -203,7 +203,7 @@ async def test_unavailable_api_import( hass: HomeAssistant, mock_nmbs_client: AsyncMock ) -> None: """Test starting a flow by import and api is unavailable.""" - mock_nmbs_client.get_stations.return_value = -1 + mock_nmbs_client.get_stations.return_value = None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, From 1226354823913b646667d3126b06761f43fc662e Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sun, 2 Mar 2025 17:37:48 +0100 Subject: [PATCH 2980/2987] Finish removing import from configuration.yaml support from opentherm_gw (#139643) --- .../components/opentherm_gw/config_flow.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index bcbf279f3f7..a100dcb730f 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -95,19 +95,6 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN): """Handle manual initiation of the config flow.""" return await self.async_step_init(user_input) - # Deprecated import from configuration.yaml, can be removed in 2025.4.0 - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import an OpenTherm Gateway device as a config entry. - - This flow is triggered by `async_setup` for configured devices. - """ - formatted_config = { - CONF_NAME: import_data.get(CONF_NAME, import_data[CONF_ID]), - CONF_DEVICE: import_data[CONF_DEVICE], - CONF_ID: import_data[CONF_ID], - } - return await self.async_step_init(info=formatted_config) - def _show_form(self, errors: dict[str, str] | None = None) -> ConfigFlowResult: """Show the config flow form with possible errors.""" return self.async_show_form( From fca4ef3b1eb75af770fb5d7e01295930886dbd27 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 2 Mar 2025 19:52:37 +0100 Subject: [PATCH 2981/2987] Fix scope comparison in SmartThings (#139652) --- homeassistant/components/smartthings/config_flow.py | 2 +- tests/components/smartthings/test_config_flow.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 0ad1b5553b1..02b11b190c9 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -34,7 +34,7 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for SmartThings.""" - if data[CONF_TOKEN]["scope"].split() != SCOPES: + if not set(data[CONF_TOKEN]["scope"].split()) >= set(SCOPES): return self.async_abort(reason="missing_scopes") client = SmartThings(session=async_get_clientsession(self.hass)) client.authenticate(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 858384db0b6..a16747c1190 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -261,7 +261,7 @@ async def test_reauthentication( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", + "r:scenes:* x:scenes:* r:rules:* sse w:rules:*", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -279,7 +279,7 @@ async def test_reauthentication( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", + "r:scenes:* x:scenes:* r:rules:* sse w:rules:*", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", } From 05e23f0fc70bb6ecbd5cbe7252a0650da90b95d9 Mon Sep 17 00:00:00 2001 From: martin12as <86385658+martin12as@users.noreply.github.com> Date: Sun, 2 Mar 2025 16:00:05 -0300 Subject: [PATCH 2982/2987] Add nut commands to turn off/on outlet 1 & 2 (#139044) * Update const.py * Update strings.json * Update homeassistant/components/nut/strings.json Co-authored-by: tdfountain <174762217+tdfountain@users.noreply.github.com> * Update homeassistant/components/nut/strings.json Co-authored-by: tdfountain <174762217+tdfountain@users.noreply.github.com> --------- Co-authored-by: tdfountain <174762217+tdfountain@users.noreply.github.com> --- homeassistant/components/nut/const.py | 8 ++++++++ homeassistant/components/nut/strings.json | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 6db40a910a0..924c591e783 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -63,6 +63,10 @@ COMMAND_TEST_FAILURE_STOP = "test.failure.stop" COMMAND_TEST_PANEL_START = "test.panel.start" COMMAND_TEST_PANEL_STOP = "test.panel.stop" COMMAND_TEST_SYSTEM_START = "test.system.start" +COMMAND_OUTLET1_OFF = "outlet.1.load.off" +COMMAND_OUTLET1_ON = "outlet.1.load.on" +COMMAND_OUTLET2_OFF = "outlet.2.load.off" +COMMAND_OUTLET2_ON = "outlet.2.load.on" INTEGRATION_SUPPORTED_COMMANDS = { COMMAND_BEEPER_DISABLE, @@ -91,4 +95,8 @@ INTEGRATION_SUPPORTED_COMMANDS = { COMMAND_TEST_PANEL_START, COMMAND_TEST_PANEL_STOP, COMMAND_TEST_SYSTEM_START, + COMMAND_OUTLET1_OFF, + COMMAND_OUTLET1_ON, + COMMAND_OUTLET2_OFF, + COMMAND_OUTLET2_ON, } diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index b9485a320fb..4242ac9d9b2 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -74,7 +74,11 @@ "test_failure_stop": "Stop simulating a power failure", "test_panel_start": "Start testing the UPS panel", "test_panel_stop": "Stop a UPS panel test", - "test_system_start": "Start a system test" + "test_system_start": "Start a system test", + "outlet_1_load_on": "Power outlet 1 on", + "outlet_1_load_off": "Power outlet 1 off", + "outlet_2_load_on": "Power outlet 2 on", + "outlet_2_load_off": "Power outlet 1 off" } }, "entity": { From e63b17cd5832abf43fd57b757016147f69b3c1ad Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 2 Mar 2025 20:04:53 +0100 Subject: [PATCH 2983/2987] Make spelling of "All-Link" consistent in Insteon integration (#139651) "All-Link" is a fixed term in the Insteon integration that should be kept in translations. To clarify that this commit makes all occurrences in the Insteon integration consistent (plus fixing one typo). On the other end the word "database" is sentence-cased as this can be translated, just as "record" etc. Finally the description of the "Load All-Link database" action is made consistent using descriptive third-person singular as all other actions do. --- homeassistant/components/insteon/strings.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index 538107dd816..3a15d667ca7 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -111,7 +111,7 @@ }, "services": { "add_all_link": { - "name": "Add all link", + "name": "Add All-Link", "description": "Tells the Insteon Modem (IM) start All-Linking mode. Once the IM is in All-Linking mode, press the link button on the device to complete All-Linking.", "fields": { "group": { @@ -120,13 +120,13 @@ }, "mode": { "name": "[%key:common::config_flow::data::mode%]", - "description": "Linking mode controller - IM is controller responder - IM is responder." + "description": "Linking mode of the Insteon Modem." } } }, "delete_all_link": { - "name": "Delete all link", - "description": "Tells the Insteon Modem (IM) to remove an All-Link record from the All-Link Database of the IM and a device. Once the IM is set to delete the link, press the link button on the corresponding device to complete the process.", + "name": "Delete All-Link", + "description": "Tells the Insteon Modem (IM) to remove an All-Link record from the All-Link database of the IM and a device. Once the IM is set to delete the link, press the link button on the corresponding device to complete the process.", "fields": { "group": { "name": "Group", @@ -135,8 +135,8 @@ } }, "load_all_link_database": { - "name": "Load all link database", - "description": "Load the All-Link Database for a device. WARNING - Loading a device All-LInk database is very time consuming and inconsistent. This may take a LONG time and may need to be repeated to obtain all records.", + "name": "Load All-Link database", + "description": "Loads the All-Link database for a device. WARNING - Loading a device All-Link database is very time consuming and inconsistent. This may take a LONG time and may need to be repeated to obtain all records.", "fields": { "entity_id": { "name": "Entity", @@ -149,8 +149,8 @@ } }, "print_all_link_database": { - "name": "Print all link database", - "description": "Prints the All-Link Database for a device. Requires that the All-Link Database is loaded into memory.", + "name": "Print All-Link database", + "description": "Prints the All-Link database for a device. Requires that the All-Link database is loaded into memory.", "fields": { "entity_id": { "name": "Entity", @@ -159,8 +159,8 @@ } }, "print_im_all_link_database": { - "name": "Print IM all link database", - "description": "Prints the All-Link Database for the INSTEON Modem (IM)." + "name": "Print IM All-Link database", + "description": "Prints the All-Link database for the INSTEON Modem (IM)." }, "x10_all_units_off": { "name": "X10 all units off", From f76e295204fa1b67bd3c625dc37552e38f8c12fd Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sun, 2 Mar 2025 12:24:27 -0700 Subject: [PATCH 2984/2987] Add fault event to balboa (#138623) * Add fault sensor to balboa * Use an event instead of sensor for faults * Don't set fault initially in conftest * Use event type per fault message code * Set fault to None in conftest --- homeassistant/components/balboa/__init__.py | 2 +- homeassistant/components/balboa/event.py | 91 +++++++++++++++++++ homeassistant/components/balboa/strings.json | 29 ++++++ tests/components/balboa/conftest.py | 2 + .../balboa/snapshots/test_event.ambr | 90 ++++++++++++++++++ tests/components/balboa/test_event.py | 82 +++++++++++++++++ 6 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/balboa/event.py create mode 100644 tests/components/balboa/snapshots/test_event.ambr create mode 100644 tests/components/balboa/test_event.py diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py index 207826d136e..54ae569bb78 100644 --- a/homeassistant/components/balboa/__init__.py +++ b/homeassistant/components/balboa/__init__.py @@ -21,6 +21,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.EVENT, Platform.FAN, Platform.LIGHT, Platform.SELECT, @@ -28,7 +29,6 @@ PLATFORMS = [ Platform.TIME, ] - KEEP_ALIVE_INTERVAL = timedelta(minutes=1) SYNC_TIME_INTERVAL = timedelta(hours=1) diff --git a/homeassistant/components/balboa/event.py b/homeassistant/components/balboa/event.py new file mode 100644 index 00000000000..57263c34783 --- /dev/null +++ b/homeassistant/components/balboa/event.py @@ -0,0 +1,91 @@ +"""Support for Balboa events.""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from pybalboa import EVENT_UPDATE, SpaClient + +from homeassistant.components.event import EventEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval + +from . import BalboaConfigEntry +from .entity import BalboaEntity + +FAULT = "fault" +FAULT_DATE = "fault_date" +REQUEST_FAULT_LOG_INTERVAL = timedelta(minutes=5) + +FAULT_MESSAGE_CODE_MAP: dict[int, str] = { + 15: "sensor_out_of_sync", + 16: "low_flow", + 17: "flow_failed", + 18: "settings_reset", + 19: "priming_mode", + 20: "clock_failed", + 21: "settings_reset", + 22: "memory_failure", + 26: "service_sensor_sync", + 27: "heater_dry", + 28: "heater_may_be_dry", + 29: "water_too_hot", + 30: "heater_too_hot", + 31: "sensor_a_fault", + 32: "sensor_b_fault", + 34: "pump_stuck", + 35: "hot_fault", + 36: "gfci_test_failed", + 37: "standby_mode", +} +FAULT_EVENT_TYPES = sorted(set(FAULT_MESSAGE_CODE_MAP.values())) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BalboaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the spa's events.""" + async_add_entities([BalboaEventEntity(entry.runtime_data)]) + + +class BalboaEventEntity(BalboaEntity, EventEntity): + """Representation of a Balboa event entity.""" + + _attr_event_types = FAULT_EVENT_TYPES + _attr_translation_key = FAULT + + def __init__(self, spa: SpaClient) -> None: + """Initialize a Balboa event entity.""" + super().__init__(spa, FAULT) + + @callback + def _async_handle_event(self) -> None: + """Handle the fault event.""" + if not (fault := self._client.fault): + return + fault_date = fault.fault_datetime.isoformat() + if self.state_attributes.get(FAULT_DATE) != fault_date: + self._trigger_event( + FAULT_MESSAGE_CODE_MAP.get(fault.message_code, fault.message), + {FAULT_DATE: fault_date, "code": fault.message_code}, + ) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + self.async_on_remove(self._client.on(EVENT_UPDATE, self._async_handle_event)) + + async def request_fault_log(now: datetime | None = None) -> None: + """Request the most recent fault log.""" + await self._client.request_fault_log() + + await request_fault_log() + self.async_on_remove( + async_track_time_interval( + self.hass, request_fault_log, REQUEST_FAULT_LOG_INTERVAL + ) + ) diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 9779984b182..784ce8533a8 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -57,6 +57,35 @@ } } }, + "event": { + "fault": { + "name": "Fault", + "state_attributes": { + "event_type": { + "state": { + "sensor_out_of_sync": "Sensors are out of sync", + "low_flow": "The water flow is low", + "flow_failed": "The water flow has failed", + "settings_reset": "The settings have been reset", + "priming_mode": "Priming mode", + "clock_failed": "The clock has failed", + "memory_failure": "Program memory failure", + "service_sensor_sync": "Sensors are out of sync -- call for service", + "heater_dry": "The heater is dry", + "heater_may_be_dry": "The heater may be dry", + "water_too_hot": "The water is too hot", + "heater_too_hot": "The heater is too hot", + "sensor_a_fault": "Sensor A fault", + "sensor_b_fault": "Sensor B fault", + "pump_stuck": "A pump may be stuck on", + "hot_fault": "Hot fault", + "gfci_test_failed": "The GFCI test failed", + "standby_mode": "Standby mode (hold mode)" + } + } + } + } + }, "fan": { "pump": { "name": "Pump {index}" diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py index 90f8fdc3d6e..18639b0c9be 100644 --- a/tests/components/balboa/conftest.py +++ b/tests/components/balboa/conftest.py @@ -68,4 +68,6 @@ def client_fixture() -> Generator[MagicMock]: client.pumps = [] client.temperature_range.state = LowHighRange.LOW + client.fault = None + yield client diff --git a/tests/components/balboa/snapshots/test_event.ambr b/tests/components/balboa/snapshots/test_event.ambr new file mode 100644 index 00000000000..fc8f591a9fc --- /dev/null +++ b/tests/components/balboa/snapshots/test_event.ambr @@ -0,0 +1,90 @@ +# serializer version: 1 +# name: test_events[event.fakespa_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'clock_failed', + 'flow_failed', + 'gfci_test_failed', + 'heater_dry', + 'heater_may_be_dry', + 'heater_too_hot', + 'hot_fault', + 'low_flow', + 'memory_failure', + 'priming_mode', + 'pump_stuck', + 'sensor_a_fault', + 'sensor_b_fault', + 'sensor_out_of_sync', + 'service_sensor_sync', + 'settings_reset', + 'standby_mode', + 'water_too_hot', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.fakespa_fault', + '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': 'Fault', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fault', + 'unique_id': 'FakeSpa-fault-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[event.fakespa_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'clock_failed', + 'flow_failed', + 'gfci_test_failed', + 'heater_dry', + 'heater_may_be_dry', + 'heater_too_hot', + 'hot_fault', + 'low_flow', + 'memory_failure', + 'priming_mode', + 'pump_stuck', + 'sensor_a_fault', + 'sensor_b_fault', + 'sensor_out_of_sync', + 'service_sensor_sync', + 'settings_reset', + 'standby_mode', + 'water_too_hot', + ]), + 'friendly_name': 'FakeSpa Fault', + }), + 'context': , + 'entity_id': 'event.fakespa_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/balboa/test_event.py b/tests/components/balboa/test_event.py new file mode 100644 index 00000000000..04f25f6cfa0 --- /dev/null +++ b/tests/components/balboa/test_event.py @@ -0,0 +1,82 @@ +"""Tests of the events of the balboa integration.""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.const import STATE_UNKNOWN, 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_EVENT = "event.fakespa_fault" +FAULT_DATE = "fault_date" + + +async def test_events( + hass: HomeAssistant, + client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test spa events.""" + with patch("homeassistant.components.balboa.PLATFORMS", [Platform.EVENT]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_event(hass: HomeAssistant, client: MagicMock) -> None: + """Test spa fault event.""" + await init_integration(hass) + + # check the state is unknown + state = hass.states.get(ENTITY_EVENT) + assert state.state == STATE_UNKNOWN + + # set a fault + client.fault = MagicMock( + fault_datetime=datetime(2025, 2, 15, 13, 0), message_code=16 + ) + client.emit("") + await hass.async_block_till_done() + + # check new state is what we expect + state = hass.states.get(ENTITY_EVENT) + assert state.attributes[ATTR_EVENT_TYPE] == "low_flow" + assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00" + assert state.attributes["code"] == 16 + + # set fault to None + client.fault = None + client.emit("") + await hass.async_block_till_done() + + # validate state remains unchanged + state = hass.states.get(ENTITY_EVENT) + assert state.attributes[ATTR_EVENT_TYPE] == "low_flow" + assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00" + assert state.attributes["code"] == 16 + + # set fault to an unknown one + client.fault = MagicMock( + fault_datetime=datetime(2025, 2, 15, 14, 0), message_code=-1 + ) + # validate a ValueError is raises + with pytest.raises(ValueError): + client.emit("") + await hass.async_block_till_done() + + # validate state remains unchanged + state = hass.states.get(ENTITY_EVENT) + assert state.attributes[ATTR_EVENT_TYPE] == "low_flow" + assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00" + assert state.attributes["code"] == 16 From 18b0f54a3e5dc8af0e5baab2808f53f8ccf9821f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 2 Mar 2025 20:49:19 +0100 Subject: [PATCH 2985/2987] Fix typo in `outlet_2_load_off` of NUT integration (#139656) Fix typo in `outlet_2_load_off` Fix small copy & paste error in https://github.com/home-assistant/core/pull/139044 --- homeassistant/components/nut/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 4242ac9d9b2..1cd5415b0d6 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -78,7 +78,7 @@ "outlet_1_load_on": "Power outlet 1 on", "outlet_1_load_off": "Power outlet 1 off", "outlet_2_load_on": "Power outlet 2 on", - "outlet_2_load_off": "Power outlet 1 off" + "outlet_2_load_off": "Power outlet 2 off" } }, "entity": { From 387bf83ba8427bf8babd60f10201e5f37d6eaae7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Mar 2025 12:53:45 -0700 Subject: [PATCH 2986/2987] Bump aioesphomeapi to 29.3.2 (#139653) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.3.1...v29.3.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 b97878d11b5..26c4b21d565 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.3.1", + "aioesphomeapi==29.3.2", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.9.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 5d274a3ba6a..45484a6f2d0 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.3.1 +aioesphomeapi==29.3.2 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 19e143e3975..d2be4b80bfb 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.3.1 +aioesphomeapi==29.3.2 # homeassistant.components.flo aioflo==2021.11.0 From 8536f2b4cbcceb6c4bd4def6d37d26e5181b74fa Mon Sep 17 00:00:00 2001 From: Niklas Neesen Date: Sun, 2 Mar 2025 20:57:13 +0100 Subject: [PATCH 2987/2987] Fix vicare exception for specific ventilation device type (#138343) * fix for exception for specific ventilation device type + tests * fix for exception for specific ventilation device type + tests * New Testset just for fan * update test_sensor.ambr --- homeassistant/components/vicare/fan.py | 10 +- .../fixtures/Vitocal222G_Vitovent300W.json | 3019 +++++++++++++++++ .../components/vicare/snapshots/test_fan.ambr | 126 + tests/components/vicare/test_climate.py | 4 +- tests/components/vicare/test_fan.py | 1 + 5 files changed, 3157 insertions(+), 3 deletions(-) create mode 100644 tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 26136260a4b..d84b2038dde 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -196,7 +196,10 @@ class ViCareFan(ViCareEntity, FanEntity): @property def is_on(self) -> bool | None: """Return true if the entity is on.""" - if self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + if ( + self._attr_supported_features & FanEntityFeature.TURN_OFF + and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY) + ): return False return self.percentage is not None and self.percentage > 0 @@ -209,7 +212,10 @@ class ViCareFan(ViCareEntity, FanEntity): @property def icon(self) -> str | None: """Return the icon to use in the frontend.""" - if self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + if ( + self._attr_supported_features & FanEntityFeature.TURN_OFF + and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY) + ): return "mdi:fan-off" if hasattr(self, "_attr_preset_mode"): if self._attr_preset_mode == VentilationMode.VENTILATION: diff --git a/tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json b/tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json new file mode 100644 index 00000000000..a733d33a12a --- /dev/null +++ b/tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json @@ -0,0 +1,3019 @@ +{ + "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.errors.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [] + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.messages.errors.raw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.sensors.temperature.commonSupply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.commonSupply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.bufferCylinder.sensors.temperature.main", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.buffer.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.buffer.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.bufferCylinder.sensors.temperature.top", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.buffer.sensors.temperature.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.buffer.sensors.temperature.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.bufferCylinder.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.bufferCylinder.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.bufferCylinder.sensors.temperature.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.bufferCylinder.sensors.temperature.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.circulation.pump", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.frostprotection", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-11T20:58:18.395Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.frostprotection" + }, + { + "apiVersion": 1, + "commands": { + "setCurve": { + "isExecutable": true, + "name": "setCurve", + "params": { + "shift": { + "constraints": { + "max": 40, + "min": -15, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "slope": { + "constraints": { + "max": 3.5, + "min": 0, + "stepping": 0.1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve/commands/setCurve" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.heating.curve", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "shift": { + "type": "number", + "unit": "", + "value": 0 + }, + "slope": { + "type": "number", + "unit": "", + "value": 0.4 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.curve" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "standby", + "maxEntries": 8, + "modes": ["reduced", "normal", "fixed"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.heating.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "mon": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "sat": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "sun": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "thu": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "tue": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "wed": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ] + } + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.schedule" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["dhw", "dhwAndHeating", "standby"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active/commands/setMode" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "dhwAndHeating" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.dhwAndHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhwAndHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.dhwAndHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhwAndHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.dhwAndHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhwAndHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.dhwAndHeatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhwAndHeatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.dhwAndHeatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhwAndHeatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.dhwAndHeatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhwAndHeatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.forcedNormal", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.forcedNormal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.forcedNormal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.forcedNormal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.forcedNormal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.forcedNormal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.forcedReduced", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.forcedReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.forcedReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.forcedReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.forcedReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.forcedReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.normalStandby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.normalStandby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.normalStandby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.normalStandby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.normalStandby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.normalStandby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "normal" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "unknown" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfort", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfort", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfort" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 22 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": { + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normal", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "demand": { + "type": "string", + "value": "unknown" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 22 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normal" + }, + { + "apiVersion": 1, + "commands": { + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reduced", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "unknown" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.3 + } + }, + "timestamp": "2025-02-11T20:49:01.456Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.temperature", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 33.2 + } + }, + "timestamp": "2025-02-11T19:48:05.380Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature" + }, + { + "apiVersion": 1, + "commands": { + "setLevels": { + "isExecutable": true, + "name": "setLevels", + "params": { + "maxTemperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "minTemperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setLevels" + }, + "setMax": { + "isExecutable": true, + "name": "setMax", + "params": { + "temperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setMax" + }, + "setMin": { + "isExecutable": true, + "name": "setMin", + "params": { + "temperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setMin" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.temperature.levels", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "max": { + "type": "number", + "unit": "celsius", + "value": 44 + }, + "min": { + "type": "number", + "unit": "celsius", + "value": 15 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature.levels" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 20, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0/commands/setName" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "name": { + "type": "string", + "value": "" + }, + "type": { + "type": "string", + "value": "heatingCircuit" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.statistics", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "hours": { + "type": "number", + "unit": "hour", + "value": 4332.4 + }, + "starts": { + "type": "number", + "unit": "", + "value": 21314 + } + }, + "timestamp": "2025-02-11T20:34:55.482Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.1.statistics", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.1.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "phase": { + "type": "string", + "value": "off" + } + }, + "timestamp": "2025-02-11T20:45:56.068Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.1", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.1" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.controller.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.controller.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.charging", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-11T19:42:36.300Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.charging" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.dhw.oneTimeCharge", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-11T19:42:36.300Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 8, + "modes": ["5/25-cycles", "5/10-cycles", "on"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "07:30" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "07:30" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ] + } + } + }, + "timestamp": "2025-02-11T17:50:12.565Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.primary", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.primary" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 8, + "modes": ["top", "normal", "temp-2"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ] + } + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.bottom", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.bottom" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.bottom", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.bottom", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.bottom" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.top", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.outlet", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.outlet" + }, + { + "apiVersion": 1, + "commands": { + "setHysteresis": { + "isExecutable": true, + "name": "setHysteresis", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresis" + }, + "setHysteresisSwitchOffValue": { + "isExecutable": false, + "name": "setHysteresisSwitchOffValue", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOffValue" + }, + "setHysteresisSwitchOnValue": { + "isExecutable": true, + "name": "setHysteresisSwitchOnValue", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOnValue" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.hysteresis", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "switchOffValue": { + "type": "number", + "unit": "kelvin", + "value": 5 + }, + "switchOnValue": { + "type": "number", + "unit": "kelvin", + "value": 5 + }, + "value": { + "type": "number", + "unit": "kelvin", + "value": 5 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis" + }, + { + "apiVersion": 1, + "commands": { + "setTargetTemperature": { + "isExecutable": true, + "name": "setTargetTemperature", + "params": { + "temperature": { + "constraints": { + "efficientLowerBorder": 10, + "efficientUpperBorder": 60, + "max": 60, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main/commands/setTargetTemperature" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 50 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main" + }, + { + "apiVersion": 1, + "commands": { + "setTargetTemperature": { + "isExecutable": true, + "name": "setTargetTemperature", + "params": { + "temperature": { + "constraints": { + "max": 60, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.temp2/commands/setTargetTemperature" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.temp2", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 60 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.temp2" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "heating.operating.programs.holiday", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "" + }, + "start": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.sensors.temperature.return", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 6.9 + } + }, + "timestamp": "2025-02-11T20:58:31.054Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.sensors.temperature.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 5.2 + } + }, + "timestamp": "2025-02-11T20:48:38.307Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.9 + } + }, + "timestamp": "2025-02-11T20:46:37.502Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.outside", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 1.9 + } + }, + "timestamp": "2025-02-11T21:00:13.154Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.outside" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.return", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.5 + } + }, + "timestamp": "2025-02-11T20:48:00.474Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.power.cumulativeProduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.power.cumulativeProduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.power.production", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.power.production" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.pumps.circuit", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.pumps.circuit" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.sensors.temperature.collector", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.sensors.temperature.collector" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.sensors.temperature.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.sensors.temperature.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelFour", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelFour" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelOne", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelOne" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelThree", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelThree" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelTwo", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelTwo" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["standby", "standard", "ventilation"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setMode" + }, + "setModeContinuousSensorOverride": { + "isExecutable": false, + "name": "setModeContinuousSensorOverride", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setModeContinuousSensorOverride" + } + }, + "deviceId": "0", + "feature": "ventilation.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "ventilation" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.standard", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.standard" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "levelThree" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.state", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "demand": { + "type": "string", + "value": "ventilation" + }, + "level": { + "type": "string", + "value": "levelThree" + }, + "reason": { + "type": "string", + "value": "schedule" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.state" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.comfort/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.comfort/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.comfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.comfort" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.eco/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.eco/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.eco" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.holiday", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "" + }, + "start": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "levelOne", + "maxEntries": 8, + "modes": ["levelTwo", "levelThree", "levelFour"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "ventilation.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "mon": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "sat": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "sun": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "thu": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "tue": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "wed": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ] + } + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 20, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.name/commands/setName" + } + }, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.0.name", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "name": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-01-12T22:36:28.706Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.name" + }, + { + "apiVersion": 1, + "commands": {}, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.1.name", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-02-02T01:29:44.670Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.name" + }, + { + "apiVersion": 1, + "commands": {}, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.2.name", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-02-02T01:29:44.670Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.name" + } + ] +} diff --git a/tests/components/vicare/snapshots/test_fan.ambr b/tests/components/vicare/snapshots/test_fan.ambr index 0bac421e2c7..2c9e815f7bf 100644 --- a/tests/components/vicare/snapshots/test_fan.ambr +++ b/tests/components/vicare/snapshots/test_fan.ambr @@ -1,4 +1,69 @@ # serializer version: 1 +# name: test_all_entities[fan.model0_ventilation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.model0_ventilation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan', + 'original_name': 'Ventilation', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'ventilation', + 'unique_id': 'gateway0_deviceSerialViAir300F-ventilation', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fan.model0_ventilation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Ventilation', + 'icon': 'mdi:fan', + 'percentage': 0, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': list([ + , + , + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.model0_ventilation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[fan.model1_ventilation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -62,3 +127,64 @@ 'state': 'off', }) # --- +# name: test_all_entities[fan.model2_ventilation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.model2_ventilation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan', + 'original_name': 'Ventilation', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'ventilation', + 'unique_id': 'gateway2_################-ventilation', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fan.model2_ventilation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model2 Ventilation', + 'icon': 'mdi:fan', + 'preset_mode': None, + 'preset_modes': list([ + , + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.model2_ventilation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vicare/test_climate.py b/tests/components/vicare/test_climate.py index f48a8988cf0..9299f6567b1 100644 --- a/tests/components/vicare/test_climate.py +++ b/tests/components/vicare/test_climate.py @@ -23,7 +23,9 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] + fixtures: list[Fixture] = [ + Fixture({"type:boiler"}, "vicare/Vitodens300W.json"), + ] with ( patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.CLIMATE]), diff --git a/tests/components/vicare/test_fan.py b/tests/components/vicare/test_fan.py index 5683f48f01f..8c42c92fb50 100644 --- a/tests/components/vicare/test_fan.py +++ b/tests/components/vicare/test_fan.py @@ -26,6 +26,7 @@ async def test_all_entities( fixtures: list[Fixture] = [ Fixture({"type:ventilation"}, "vicare/ViAir300F.json"), Fixture({"type:ventilation"}, "vicare/VitoPure.json"), + Fixture({"type:heatpump"}, "vicare/Vitocal222G_Vitovent300W.json"), ] with ( patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)),